Now · Updated 2552.08.07.08.17

System Link — Bug Tracker

Chronological record of bugs found during development. Each entry has the Bug, Root Cause Analysis (RCA), and Fix.

Chronological record of bugs found during development. Each entry has the Bug, Root Cause Analysis (RCA), and Fix.


Read this first when you hit an editor or PIE crash. Some failures originate in the dev machine, not the project, and chasing them in the codebase is wasted effort.

#GPU "device hung" / DXGI_ERROR_DEVICE_HUNG crashes → suspect the CPU, not the game

Dev rig: Intel i9-14900KF + RTX 4080 SUPER, NVIDIA driver 581.95, UE 5.7.

The signature to recognize (seen 2026-06-08): editor GPU crash with

  • LogD3D12RHI: GPU crash detected: Device 0 Removed: DXGI_ERROR_DEVICE_HUNG
  • NVIDIA Aftermath: Status: Timeout, Page Fault Info: No information on faulting address
  • GPU breadcrumb stuck on generic engine work (UpdateAllPrimitiveSceneInfos /
  • FRDGBuilder::SubmitBufferUploads), not a named SystemLink material/effect/pass.

Why it's not us: when a real asset/shader kills the GPU, Aftermath reports a faulting address or pins a specific render pass. A generic breadcrumb with no page fault is the classic fingerprint of Intel 13th/14th-gen Raptor Lake i9 instability — an unstable CPU feeds a subtly-corrupt command stream and the GPU hangs as the victim. It commonly shows up in Unreal as "device hung" / "out of video memory," and can trigger at idle/light load.

Before blaming the project:

  1. BIOS → apply Intel Default Settings power profile (PL/IccMax at Intel spec, motherboard
  2. "MCE/unlimited power" OFF). Ensure latest microcode (rig was on 0x12F — current).

  1. Don't leave the editor open 12–20h; restart periodically. Clean-install the NVIDIA Studio driver.
  1. If it also crashes under sustained load (cooking, shader compiles, gaming), the chip may be
  2. permanently degraded — Intel extended the warranty to 5 years for this; RMA is an option.

Full analysis of the 2026-06-08 crash: dump was Saved/Logs/D3D12.0.2026.06.08-08.18.04.nv-gpudmp

  • SystemLink.log. Idle/background overnight (AppHasFocus=false, ~20h uptime), Sequencer layout open
  • (context, not cause).


#BUG-001 — Shotgun fire animation plays once then never replays

Date: 2026-05-19 Branch: melee-ability-polish

Bug: Shotgun fire animation in ABP_MC_TP_Shotgun Firing state played correctly on the first shot, then never played again on subsequent shots.

RCA: Non-looping asset players in a state machine state retain their time position across re-entries. After the first play, the asset player sits at t=end. Re-entering the state finds nothing left to play. AR didn't hit this because its fire anim is looping (wraps around continuously).

Fix: Enabled Always Reset on Entry on the Firing state in ABP_MC_TP_Shotgun. Asset player resets to t=0 on every state entry.


#BUG-002 — Inertialization warning on editor quit

Date: 2026-05-19 Branch: melee-ability-polish

Bug: UE printed a warning on PIE stop about Inertial Blend logic having no Inertialization node downstream in ABP_MC_TP_Shotgun.

RCA: The Not Firing → Firing transition used Inertial Blend but had no Inertialization node placed in the AnimGraph to consume it.

Fix: Added a single Inertialization node between the state machine output and the Layered Blend Per Bone in ABP_MC_TP_Shotgun.


#BUG-003 — Sequencer editor crash on save with Possessable bindings + constraints

Date: 2026-05-19 Branch: melee-ability-polish

Bug: Editor crashed on save/load of a Sequencer sequence after adding constraints to a Possessable binding. Asset LS_TP_Shotgun_Idle2.uasset corrupted.

RCA: Possessable bindings + constraints cause a TransformableComponentHandle harvest failure on save. The handle can't resolve the Possessable reference during serialization.

Fix: Every Sequencer binding must be converted to a Spawnable before adding any constraints. Right-click binding → Convert to Spawnable. See Docs/SequencerAuthoringWorkflow.md.


#BUG-004 — Respawn stuck — player unable to respawn after death

Date: ~2026-05-20 Branch: melee-ability-polish

Bug: After dying, the respawn flow stalled. Player stayed dead permanently.

RCA: See Docs/RespawnSystem.md for full RCA. Short version: InventoryLoaded state flag was not cleared before OnPawnInitialized on the new pawn, causing the equip flow to be skipped.

Fix: Clear stale InventoryLoaded before OnPawnInitialized. Use Client_OnLoadoutReady RPC (not OnRep_CarriedWeapons) to set InventoryLoaded. See feedback_respawn_inventory_flow.md.


#BUG-005 — Hit direction incorrect — using controller location instead of hit location

Date: ~2026-05-20 Branch: melee-ability (or health)

Bug: Hit direction indicator on the HUD pointed the wrong way. Hit reactions played in the wrong cardinal direction.

RCA: GetInstigator() on a GameplayEffectContext returns the Controller, which has no meaningful world location (it floats above the pawn or at origin). Subtracting controller location from victim location produced a wrong direction vector.

Fix: Use GetEffectCauser() instead of GetInstigator() to get the world location of the damage source. See feedback_gas_effect_context_causer.md.


#BUG-006 — Extra bullets fired per respawn (accumulating fire abilities)

Date: ~2026-04-16 Branch: weapon-swap

Bug: After each respawn, the player fired one additional bullet per shot. After 3 respawns, 3 extra bullets fired per trigger pull.

RCA: Fire ability FSLAbilitySet_GrantedHandles were stored on USLWeaponsComponent (pawn). Pawn is destroyed on respawn, zeroing out the component-local handles. TakeFromASC became a no-op (handles were zero/invalid). Fire ability specs accumulated on the ASC (on PlayerState, which survives respawn) without ever being revoked.

Fix: All GAS handles for player abilities must live on ASLPlayerState, not the pawn or any component. See feedback_ability_handles_playerstate.md.


#BUG-007 — HUD not initialized on first spawn (PossessedBy before HUD BeginPlay)

Date: ~2026-05-22 Branch: hud / weapon-swap

Bug: On first spawn, HUD widgets were not initialized — weapon name, ammo count blank. Worked fine on respawn.

RCA: PossessedBy fires before ASLPlayerHUD::BeginPlay constructs the HUD widget. The initialization call in PossessedBy tried to find a widget that didn't exist yet.

Fix: ASLPlayerHUD::BeginPlay calls InitializeLocalPlayerHUD on the already-possessed pawn after widget construction. See feedback_hud_init_timing.md.


#BUG-008 — Blueprint reparent causes fatal World Leak crash (REINST_ stuck in TransBuffer)

Date: Multiple occurrences

Bug: Reparenting a Blueprint mid-session causes a fatal crash on the next PIE stop or save. Log shows REINST_ class stuck in TransBuffer.

RCA: Reparenting a BP creates a REINST_ intermediate class. If the editor holds a reference to it in the transaction buffer, world teardown fails.

Fix: After reparenting a BP, always restart the editor before running PIE. See feedback_blueprint_reparent_crash.md.


#BUG-009 — Muzzle socket warning in ResolveMuzzleLocation when no weapon mesh

Date: 2026-05-XX Branch: melee-ability-polish

Bug: ResolveMuzzleLocation printed a warning when the character had no weapon mesh set (e.g. unarmed state).

RCA: Code attempted to get a socket location from an invalid/empty skeletal mesh component.

Fix: Added a validity check before socket lookup in ResolveMuzzleLocation. Returns character eye location as fallback when no mesh is present.


#BUG-010 — Two pistols visible in FP view when sidearm drawn

Date: 2026-06-05 Branch: sidearm-initial-imp

Bug: When the local player held LT to draw the sidearm in first-person view, two pistol meshes appeared — one for FPSidearmMesh and one for TPSidearmMesh.

RCA: OnSidearmTagChanged showed both FPSidearmMesh and TPSidearmMesh when the SidearmActive tag was applied, regardless of current view mode. TPSidearmMesh uses lighting channel 0 so it is visible to the local camera in FP.

Fix: Added IsFirstPersonView() check in OnSidearmTagChanged. In FP: show FPSidearmMesh, hide TPSidearmMesh. In TP: show TPSidearmMesh, hide FPSidearmMesh. Also requires ApplyViewMode BP implementation to swap sidearm mesh visibility when switching view modes.


#BUG-011 — TP sidearm mesh not visible on observing clients

Date: 2026-06-06 Branch: sidearm-initial-imp

Bug: A remote client watching another player draw their sidearm could not see the pistol mesh. The TPSidearmMesh stayed hidden.

RCA: Two compounding causes:

  1. RegisterGameplayTagEvent with NewOrRemoved does not fire for a tag that is already active at bind time. Clients that joined mid-draw (or where binding was delayed) never received the "tag added" callback.
  1. GetPawnASC() can return null during OnRep_EquippedWeapon for simulated proxies because the PlayerState hasn't replicated yet. The callback binding silently failed with no retry.

Fix:

  1. After calling RegisterGameplayTagEvent, immediately check GetTagCount and manually call OnSidearmTagChanged if the tag is already active.
  1. Added BindSidearmTagCallback() call inside UpdateSidearmMesh (called from OnRep_Sidearm) as a second retry point that fires later in the replication sequence when PlayerState is more likely valid.

See feedback_gas_tag_callback_patterns.md.


#BUG-012 — Sidearm mesh spawns at character's feet

Date: 2026-06-05 Branch: sidearm-initial-imp

Bug: After drawing the sidearm, the pistol mesh appeared at the character's feet instead of in the left hand.

RCA: UpdateSidearmMesh called SetSkeletalMeshAsset to assign the mesh but never called AttachToComponent to snap it to the socket. The component remained at its default attachment position (character root).

Fix: Added AttachToComponent call in UpdateSidearmMesh using AttachSocketName from the data asset's FSLWeaponSkeletalMeshData, with SnapToTargetNotIncludingScale transform rules.


#BUG-013 — Sidearm actor orphaned on player death (open)

Date: 2026-06-06 Branch: sidearm-initial-imp Status: Open — needs BP death cleanup work

Bug: When a player dies, the sidearm weapon actor is never destroyed or dropped. DropWeaponActor in the death cleanup BP iterates CarriedWeapons, but SidearmWeapon is a separate slot not in that array. The sidearm actor persists alive and attached to the ragdoll. On respawn, a new sidearm is spawned via LoadDefaultLoadout while the old one remains orphaned in the level.

RCA: Death cleanup was authored before the sidearm slot existed. No code path destroys or nulls SidearmWeapon on death. SetSidearmWeapon(nullptr) would drop it as a world pickup (undesirable — design says sidearm is non-droppable on death). A destroy-not-drop path doesn't exist yet.

Fix (pending): Add a DestroySidearmOnDeath() function that destroys the sidearm actor directly (no pickup spawn) and nulls SidearmWeapon. Call it from the death cleanup BP alongside DropWeaponActor for CarriedWeapons.


#BUG-014 — Pistol mesh invisible on listen-server host when viewing a client with the sidearm drawn

Date: 2026-06-08 Branch: sidearm-initial-imp

Bug: As the listen-server host, looking at a client player who had the sidearm drawn, the pistol mesh did not appear — even though the client's character was correctly in the sidearm pose.

RCA: The sidearm animation reads SidearmActive by polling HasMatchingGameplayTag every tick in BuildAnimSnapshots (reliable on all machines), but sidearm mesh visibility was driven by a one-shot RegisterGameplayTagEvent callback (OnSidearmTagChanged). On the listen-server host, that event path is unreliable for a simulated proxy (no OnRep on the server, bind/already-active timing differs from a pure client), so the visibility toggle never reached the mesh. The pose showed because it polls; the mesh stayed hidden because it relied on the event. The mesh was attached — SetSidearmWeapon calls OnRep_Sidearm directly on authority — only its visibility was stuck.

Fix: Drive visibility from the same per-tick poll the animation uses. Extracted the FP/TP SetHiddenInGame logic into USLWeaponsComponent::UpdateSidearmMeshVisibility(bool), called every tick from ASLCharacterBase::BuildAnimSnapshots with the polled bIsSidearmActive. OnSidearmTagChanged now delegates to it (kept as a harmless fast-path). SetHiddenInGame is idempotent so per-tick is cheap; BuildAnimSnapshots early-returns on dedicated servers. Confirmed in PIE 2026-06-08.

Lesson: Don't drive must-be-correct visual state from one-shot tag events on a listen server — poll it, same family as the AnimNotify-server-unreliable rule. See memory feedback_gas_tag_callback_patterns Rule 3.


#BUG-015 — Main-weapon fire FX play when firing the sidearm (pistol)

Date: 2026-06-10 Branch: sidearm-initial-imp

Bug: Firing the pistol (sidearm drawn) showed the equipped main weapon's fire FX instead of the pistol's. Observers saw the AR/Shotgun fire cue; the local shooter saw the main weapon's FP muzzle flash. No actual second shot — the main weapon's fire ability was correctly blocked by SidearmActive; this was cosmetic only.

RCA: Two independent cosmetic leaks, both downstream of duplicating the AR setup for the pistol:

  1. Cue: GC_SL_Pistol_PrimaryFire / _Impact were duplicated from the AR cues and kept the AR's GameplayCue.Weapon.AssaultRifle. tag, and DA_SL_Pistol's FireCueTag/ImpactCueTag still pointed at the AR tags. So the pistol executed the AR cue and two GameplayCueNotify_Burst assets shared one tag (an ambiguous collision affecting the AR too). Note the C++ dispatch was correct — DispatchFireCue routes through GetActiveFireMode() (the sidearm when drawn); only the asset tag wiring* was wrong.
  1. Local FP: the pistol fire ability's OnLocallyPredictedShotFired (duplicated from the AR) spawned the muzzle flash attached to the main FP weapon mesh (GetFPWeaponMesh) instead of the sidearm mesh.

Fix:

  1. Registered GameplayCue.Weapon.Pistol.PrimaryFire / .Impact, set the GC_SL_Pistol_* blueprints' Gameplay Cue Tags to them, and repointed DA_SL_Pistol's FireCueTag/ImpactCueTag — clears the collision and gives the pistol its own cue. (Verified in PIE: local + cue both fire correctly.)
  1. Repointed the local FP muzzle-flash attach to the sidearm mesh (BP). Added ASLCharacterBase::GetActiveWeaponMesh() (BlueprintPure, view- + slot-aware) so fire FX attach to whichever weapon is actually firing — FP pistol mesh on the shooter, TP pistol mesh on observers — without FP/TP branching in BP. (Helper pending rebuild.)

Lesson: When duplicating a weapon's cue/ability assets for a new weapon, immediately re-tag the cue and repoint the data asset — a duplicated GameplayCueNotify that keeps the source tag silently collides on that tag. Attach fire FX to GetActiveWeaponMesh(), never a hardcoded main-weapon mesh getter.


#BUG-016 — Sidearm animations stop after swapping the main weapon

Date: 2026-06-10 Branch: sidearm-initial-imp Status: Fixed in code — pending rebuild + PIE verification

Bug: With the pistol carried, equipping a different main weapon and back (e.g. Shotgun → AR) stopped the pistol's animations from playing — the sidearm pose went static.

RCA: The sidearm arm pose is a linked anim layer (ALI_SL_Sidearm) applied only in USLWeaponsComponent::UpdateSidearmMesh, which runs on a sidearm-slot change. The main-weapon equip path (FSLWeaponViewDriverBase::UnequipWeapon) cleared layers with LinkAnimClassLayers(nullptr), which unlinks every linked layer — including the sidearm's — and the equip then re-linked only the new main weapon's layer. Because the sidearm slot didn't change, its layer was never re-linked, so it stayed unlinked after the swap.

Fix: Replaced the all-layer nuke in UnequipWeapon with a targeted UnlinkAnimClassLayers(EquippedWeaponData.AnimLayerClass) (removes only the outgoing main weapon's layer; the sidearm uses a different interface so it survives), and removed the second all-layer clear in the "Ensure layers" fallback. Relies on the sidearm (ALI_SL_Sidearm) and main weapons (ALI_SL_Weapon) being distinct anim-layer interfaces.

Lesson: LinkAnimClassLayers(nullptr) is a global unlink. To remove one weapon's layer while other layers (sidearm) must persist, use targeted UnlinkAnimClassLayers(LayerClass).


#BUG-017 — Double footstep notifies with the Sidearm Blend node (both locomotion sets fire at once)

Date: 2026-06-12 Branch: sidearm-initial-imp Status: Fixed in code — pending rebuild + PIE verification

Bug: With the custom Sidearm Blend AnimGraph node in use, footstep anim notifies fired twice — both the sidearm walk/run blendspace and the main-weapon (default) walk/run blendspace played their footstep notifies simultaneously, even while holstered (only the default pose should be audible).

RCA: FAnimNode_SidearmBlend::Update_AnyThread called SidearmPose.Update(), LowerPose.Update(), and DefaultPose.Update() unconditionally at full weight every frame, while Evaluate_AnyThread was correctly lazy (pass-through to DefaultPose when CurrentAlpha == 0). Anim notifies fire during the Update phase as a sequence/blendspace player advances its play time — independent of the pose's output weight in Evaluate. So the holstered branches kept ticking and firing their footstep notifies alongside the active branch. Update and Evaluate relevance were out of sync.

Fix: Gated the per-pose Updates by relevance and passed weight-scaled contexts, matching FAnimNode_LayeredBoneBlend:

  • bSidearmRelevant = CurrentAlpha > ZERO_ANIMWEIGHT_THRESH → update SidearmPose/LowerPose via Context.FractionalWeight(CurrentAlpha).
  • bDefaultRelevant = CurrentAlpha < 1 - ZERO_ANIMWEIGHT_THRESH → update DefaultPose via Context.FractionalWeight(1 - CurrentAlpha).

Steady-state now ticks only the contributing side (holstered = default only; drawn = sidearm only). During the short crossfade both tick at fractional weight so the engine's weight-based notify filtering can suppress the fading-out side.

Lesson: A custom blend anim node's Update_AnyThread relevance must stay in sync with Evaluate_AnyThread. Notifies fire on Update regardless of Evaluate output weight — only tick poses that actually contribute, and pass Context.FractionalWeight(weight) so the engine can weight-filter notifies during a blend. Same family as BUG-001's "Always Reset on Entry."


#BUG-018 — Sidearm pistol fires full-auto when drawn mid-AR-burst (+ spam-tappable on release)

Date: 2026-06-13 Branch: sidearm-initial-imp Status: Fixed (Busy/Firing block live in BP; StopPrimaryFire fix pending rebuild) — PIE verification pending

Bug: Holding RT to fire the AR (full-auto), then tapping LT to draw the sidearm, made the pistol fire full-auto at the AR's cadence. Separately, the single-shot pistol could be tapped faster than its fire rate when drawn over a full-auto main weapon.

RCA — two independent issues:

  1. Full-auto carryover. Fire input (PrimaryFireAction) is bound Started-only (one event per pull). A FullAuto weapon's fire ability is a single activation that loops internally while RT is held, owning States.Weapon.Firing the whole time. Each loop iteration resolves GetActiveFireWeapon(). Drawing the sidearm mid-loop flipped the active weapon to the pistol, so the already-running AR loop kept firing the pistol at the AR's full-auto rate. ActivationBlockedTags doesn't cancel a running ability, so BP_GA_SL_SidearmMode activating didn't stop the loop.
  1. Premature cancel / spam. ASLPlayerController::StopPrimaryFire decided whether to cancel-on-release by reading WC->GetPrimaryFireMode() — the equipped/main weapon's mode — not the active (sidearm) mode. With a FullAuto main weapon + SingleShot sidearm drawn, releasing RT fell through to CancelAbilities(PrimaryFire), cancelling the pistol's fire ability and clearing its PostFireDelay (0.2s) → the player could spam-tap the pistol faster than 400 RPM.

Fix:

  1. Weapon Action Lock (Docs/WeaponActionLock.md): BP_GA_SL_SidearmMode now lists States.Weapon.Firing in ActivationBlockedTags, so the sidearm can't be drawn while the AR fire loop holds Firing → no mid-burst weapon switch → no full-auto carryover. (The AR loop owning Firing continuously is what makes this block solid.)
  1. StopPrimaryFire now reads WC->GetActiveFireMode(/bPrimary=/true) instead of GetPrimaryFireMode(), so the cancel-on-release decision uses the sidearm's SingleShot mode when it's drawn — preserving its PostFireDelay gate.

Lesson: With the "active fire weapon" indirection, every fire-path decision must go through the active getters (GetActiveFireMode/GetActiveFireWeaponData), never the equipped-weapon getters — StopPrimaryFire was a missed site from the original sidearm fire routing. And remember ActivationBlockedTags/BlockAbilitiesWithTag block new activations only; a running auto-fire loop is stopped by blocking the switch (via Firing), not by blocking the fire ability itself.


#BUG-019 — A dropped weapon hangs in mid-air when dropping all weapons on death

Date: 2026-06-14 Branch: sidearm-initial-imp Status: Fixed in code — pending rebuild + PIE verification

Bug: With DropAllWeaponsOnDeath dropping the full loadout on death (2 carried + the sidearm), one of the dropped weapon pickups would freeze floating in mid-air instead of arcing to the ground.

RCA: Pickups (ASLPickupBase) don't use rigid-body physics — they fall via a UProjectileMovementComponent (bShouldBounce, MaxBounces = 2) whose root BounceCollision sphere blocked both WorldStatic and WorldDynamic. Pickups themselves are ECC_WorldDynamic. All drops spawn at the same point (DropSpawnOffset = (40,0,50), only a 40 cm radius), so the 35 cm bounce spheres of the 3 simultaneously-launched pickups overlapped and bounced off each other — racking up MaxBounces immediately → StopMovementImmediately() → frozen mid-air. Only surfaced now because the old BP Drop Loot dropped fewer weapons (and never the sidearm); 3 at once tipped it over. (The frozen weapon looked like a shotgun only because the unfinished BP_SL_WeaponPickup_Pistol still shows the duplicated shotgun mesh — cosmetic, unrelated.)

Fix: First attempt blocked WorldStatic only (dropped the WorldDynamic block) — but that made pickups fall through any floor that isn't WorldStatic (the test-level floor was movable/dynamic). Final fix keeps blocking both WorldStatic and WorldDynamic (bounces off any floor), and instead suppresses pickup-vs-pickup collision per-actor: LaunchPickup calls BounceCollision->IgnoreActorWhenMoving(OtherPickup, true) mutually for every other ASLPickupBase (via GetAllActorsOfClass). Pickups pass through each other but still bounce off world geometry, regardless of how the floor is set up. Base-class fix → covers every multi-drop.

Lesson: ProjectileMovementComponent + bShouldBounce + low MaxBounces means any overlap at launch (including siblings of the same object type) can instantly stop the projectile. Don't solve it by un-blocking a world object channel (breaks bouncing off that floor type) — keep the world bounce and exclude just the sibling actors with IgnoreActorWhenMoving.


#BUG-020 — Sidearm ammo HUD reads 0 on the listen-server host (correct on clients)

Date: 2026-06-16 Branch: sidearm-initial-imp Status: Fixed — verified on listen server (host + client both read the correct count from spawn)

Bug: The new sidearm ammo indicator showed 0 until the first shot — but only on the listen-server HOST. Clients displayed the correct count (e.g. 24) immediately. Confirmed with print strings in the widget's OnAmmoChanged: Server: 0, Client 1: 24. The first pistol shot snapped the host's display to the real value.

RCA: USLAmmoWidget::SetWeapon binds the widget to the sidearm actor's OnAmmoChanged and shows whatever CurrentAmmo is at bind time, then updates on later broadcasts. The actor's initial fill happens in ASLWeaponActor::BeginPlay via a raw assignment CurrentAmmo = GetMaxAmmo() that does not broadcast OnAmmoChanged.

  • Clients were correct anyway: CurrentAmmo replicates with REPNOTIFY_Always, so OnRep_CurrentAmmo fires and broadcasts the value to the bound widget.
  • Host (authority) has no OnRep. The sidearm indicator binds around HUD-init time — before BeginPlay set the value — so its initial push read 0, and nothing ever re-broadcast the fill. Only the first shot's SetCurrentAmmo (which does broadcast) corrected it.

This is the authority-side mirror of the same gap the REPNOTIFY_Always on CurrentAmmo already fixed for clients (see the comment in GetLifetimeReplicatedProps).

Fix: ASLWeaponActor::BeginPlay now calls SetCurrentAmmo(GetMaxAmmo()) instead of the raw assign. SetCurrentAmmo broadcasts OnAmmoChanged on a value change (0 → MaxAmmo), so the host's already-bound widget receives the initial fill — mirroring OnRep for clients. Safe across bind orderings (late binders still read the value via SetWeapon's push; early binders now get the broadcast) and authority-only (inside HasAuthority()), so clients are unaffected. Function-body-only change → Live Coding picked it up without a restart.

Lesson: Authority-side initial state must broadcast for any UI bound before that init — clients lean on REPNOTIFY_Always, but the listen-server host has no OnRep to compensate. When a replicated value is seeded with a raw assign on authority, early-bound listeners silently miss it; use the setter that broadcasts (or broadcast explicitly). Same family as BUG-014 (don't rely on one-shot/no-broadcast paths on the host — poll or broadcast).


#BUG-021 — Sidearm draw silently lost when firing at the same instant (controller)

Date: 2026-06-16 Branch: grenade-initial-imp (sidearm fix, found during grenade work) Status: Fixed in code — pending PIE re-verify

Bug: Playtesting on a controller, pulling RT (fire) and LT (draw sidearm) at the same time would "cancel" the draw — the sidearm never appeared, even though LT was still held. Releasing and re-pulling LT worked.

RCA: Not a cancel — a swallowed one-shot input. Confirmed config (read live via the MCP bridge):

  • GA_SidearmMode.ActivationBlockedTags = Dead, Meleeing, Busy, RefireLock (no CancelAbilitiesWithTag — nothing cancels a running draw).
  • The fire abilities apply Firing (owned) and RefireLock (the loose tag held for RefireLockDuration after a shot — the BUG-018 lock).
  • The draw is dispatched as a one-shot GameplayEvent on LT Started (ASLPlayerController::StartSidearmDraw → HandleGameplayEvent(Events.Weapon.SidearmDraw)).

When fire wins the same-frame race, the shot applies RefireLock; the SidearmDraw event then finds GA_SidearmMode blocked, so it never activates — and because it was a one-shot event, the intent is lost even though LT is still held. SidearmActive never sets, so there's no real draw (the "cancelled animation" is just the draw silently failing). Nothing retries until LT is released and re-pulled (by which time RefireLock has cleared).

Fix: Keep the RefireLock block (it's the BUG-018 fix — prevents drawing mid-burst → full-auto pistol). Instead make the held LT retry: bind SidearmAction on ETriggerEvent::Triggered (fires every frame held) → StartSidearmDraw, and guard StartSidearmDraw to no-op once States.Weapon.SidearmActive is set. The held LT now re-sends the draw each frame until the transient block clears (~RefireLockDuration), then draws; the guard stops the retry the instant it succeeds, so no event spam and no double-activation. Controller-only change; BUG-018 intact.

Lesson: A one-shot HandleGameplayEvent on a held input is fragile — if activation is transiently blocked at the press instant (a lock tag, cooldown, another ability), the intent is silently dropped. For held inputs, re-attempt on Triggered with an "already-active" guard (or buffer the intent), so the action fires as soon as the block clears instead of requiring a re-press.


#BUG-022 — Character frozen in the holster pose when pumping the draw (left trigger)

Date: 2026-06-17 Branch: grenade-initial-imp (sidearm fix during grenade work) Status: Fixed (Anim Blueprint)

Bug: Rapidly pumping LT (draw/holster the sidearm) could leave the character frozen in the holster pose indefinitely.

RCA: Confirmed via a gated on-screen debug readout (bDebugSidearm in BuildAnimSnapshots, printing the sidearm tags + anim flags + GetActiveReticleClass). When stuck: ActiveTag cnt=0 DrawingTag cnt=0 AnimDraw=0 AnimHolster=0 HolLeft=-20.83 ShouldReticle=<AR>. So every gameplay/cosmetic flag was correctly clear and the C++ latch self-cleared — the freeze was purely in the ABP. The Holster state plays a non-looping clip (the draw reversed); rapid re-entry retained the asset player's time at t=end (the holstered pose) and the state didn't transition back out. Classic non-looping-state issue — the BUG-001 family (feedback_animbp_state_reset_on_entry).

Console note: showdebug abilitysystem was unusable here — opening the console steals input focus, which released the held LT and changed the state. The on-screen bDebugSidearm print (fixed-key, updates in place) was the only way to read state mid-bug. Kept (gated, off by default) for future sidearm/anim debugging.

Fix: On the ABP Holster state, enable "Always Reset on Entry" (reset to t=0 each entry) and use an explicit NOT bIsSidearmHolstering exit transition (+ Inertialization) rather than an automatic time-remaining rule (which deadlocks when the non-looping asset is parked at t=end).

Lesson: Same as BUG-001 — every non-looping state-machine state needs "Always Reset on Entry." And when a stuck-state bug can't use the console (input-focus-sensitive, like held-trigger states), a gated on-screen debug readout of the relevant flags is the fastest way to split "C++ logic bug" from "ABP stuck" — here it instantly exonerated the C++.


#BUG-023 — Clients can't throw grenades (montage plays, no grenade spawns)

Date: 2026-06-22 Branch: grenade-initial-imp Status: Fixed & verified ✅ (2026-07-01) — 2-player listen-server PIE: host AND client both spawn a real grenade; both observers see both projectiles + explosions.

Bug: On a listen server, the host throws grenades fine, but a client sees the throw montage play with no grenade spawned. Deterministic for clients. (Same root cause as the earlier intermittent FP "no grenade" misfire.)

RCA: The authoritative spawn (ASLCharacterBase::ThrowActiveGrenade) was being triggered from the throw montage's Events.Grenade.Release anim notify. Anim notifies depend on the mesh ticking its montage — the server does not reliably tick a remote client's montage (VisibilityBasedAnimTickOption / not rendering), so the Release event never fires on the server instance of the client's Local-Predicted ability → ThrowActiveGrenade never runs → no spawn. The host's own pawn mesh ticks locally, so the host works. The sibling intermittent FP bug was the same fragility via a different trigger: a BP Delay feeding the spawn could be cancelled when a montage blend-out fired EndAbility first (~17–50 ms margin).

Fix: Decouple the spawn from both the anim notify and the ability lifetime. New authority-only ASLCharacterBase::BeginGrenadeThrow() (called once on ability activation) schedules ThrowActiveGrenade on a world timer on the character (GetWorldTimerManager, ThrowReleaseTime later). A world timer fires regardless of mesh-tick or whether the ability is still active. BP change required: GA_ThrowGrenade must call BeginGrenadeThrow once after the count gate, instead of routing the Release notify (or a Delay) into ThrowActiveGrenade.

Lesson: Authoritative gameplay effects must never hinge on anim notifies in multiplayer — the server doesn't tick remote clients' montages, so a notify that works for the listen-server host silently no-ops for clients. Drive authoritative timing off a world timer (or an ability task), never the mesh. Reuses the ThrowReleaseTime data field added precisely for this.


#BUG-024 — Other players' equip sound heard map-wide (no distance falloff)

Date: 2026-06-24 · Consolidated audit: Docs/AudioAudit.md Branch: grenade-initial-imp Status: Fixed (C++) — pending the BP cue-node swap below

Bug: During multiplayer play-testing, when a remote player dies and respawns you hear their weapon equip sound at full volume no matter where you are on the map. Most noticeable on respawn (the equip-first flow always fires an equip), but applies to any remote equip.

RCA (corrected — first guess was wrong, see note): USLGameplayAbility_Equip::ExecuteEquipCue fires ASC->ExecuteGameplayCue(EquipCueTag), which GAS multicasts to every client (correct — it's a third-person cosmetic), so a far player's equip cue executes on your client. The cue (GC_SL_AssaultRifle_Equip / GC_SL_Shotgun_Equip) is a GameplayCueNotify_Burst whose burst_sounds[0] = Rifle_Raise_Cue (which does have WeaponHandling_att attenuation), spawn policy AlwaysPlay, placement DoNotAttach. DoNotAttach → the Burst notify spawns the sound at the cue's world location. But ExecuteEquipCue never set CueParams.Location, so the sound spawned at world origin (0,0,0). The sound is spatialized and does attenuate — but from origin, and the compact TestMap playspace sits within Rifle_Raise_Cue's falloff of origin, so it reads as "heard everywhere."

First-guess correction: I initially blamed a Play Sound 2D node bypassing attenuation. A binary scan of the cue .uassets found no sound node at all — these are data-driven Burst notifies (sound is a CDO property). The real cause is the missing cue Location, not a 2D play node. Lesson reinforced: verify the actual play mechanism before writing the RCA.

Fix:

  • C++ (sufficient): ExecuteEquipCue now sets CueParams.Location = Pawn->GetActorLocation() + Normal, so the Burst notify spawns the equip sound at the equipping player and attenuates from there. Needs rebuild.
  • Robustness (DONE 2026-06-25, via bridge): the equip cues' burst_sounds[0] placement is now AttachToTarget (override_placement_info=True) on GC_SL_AssaultRifle_Equip + GC_SL_Shotgun_Equip, so the sound anchors to the player's actor regardless of whether a caller sets Location. Spawn policy left at ALWAYS/TargetActor. (Note: GameplayCueNotify struct fields are EditDefaultsOnly and can't be set on struct-value copies via the Python bridge — had to rebuild the whole burst_effects via struct constructors and assign it top-level on the CDO.)

Lesson: A GameplayCueNotify_Burst sound with DoNotAttach plays at CueParameters.Location — if the caller never sets it, the sound plays at world origin. On a small map that sounds like "no distance falloff" even when attenuation is correct. When a cue sound seems un-spatialized, check (1) the cue Location is actually set by the caller, and (2) the notify's attach/placement — before suspecting the sound asset or a 2D node. Audit every EquipCueTag cue (and weapons duplicated from these). (FP-local sounds like the sidearm DrawSound/HolsterSound are deliberately PlaySound2D + IsLocallyControlled()-gated — those are correct.)


#BUG-025 — Player auto-collects its own death-drop on respawn (heard as map-wide "equip" sound)

Date: 2026-06-26 Branch: grenade-initial-imp Status: Fixed (C++) — pending rebuild + test

Bug: A stationary player, on respawn, triggers a pickup-collection sound — heard map-wide. Originally misread as "hearing the other player equip their AR on spawn." Persisted after gutting every equip cue/montage/sequence (those were never the source).

RCA (two independent layers):

  1. Why it fires: DropAllWeaponsOnDeath drops the AR/sidearm at the death spot, but the pickup launch is gentle (AR pickup LaunchHorizontalSpeed=150/Vertical=100, +40 cm offset, 30° pitch) → the drop lands only ~1 m from where you died. On a one-PlayerStart test map you die at the spawn and RestartPlayer respawns you at that same spawn — on top of your own drop. The new pawn's begin-overlap with the pickup trigger → OnSphereOverlapOnCollected. Since the pawn already owns a fresh AR (loadout), ASLWeaponPickup::OnCollected takes the AlreadyCarried → AddAmmo path (auto, no prompt) → plays the pickup sound. No movement needed — the drop comes to the spawn.
  1. Why map-wide: the pickup sounds (Object_PickUp, HealthPickup) had no Attenuation Settings while played via PlaySoundAtLocation (3D node, but a sound with no attenuation never falls off). Same class of issue as BUG-024, different asset.

Fix:

  • Collection (root cause): spawn-grace on the character. ASLCharacterBase::BeginPlay sets PickupCollectionGraceEnd = Now + PickupCollectionGracePeriod (default 1 s); ASLPickupBase::OnSphereOverlap returns early if !Character->CanCollectPickups(). Begin-overlap fires once on spawn, so a pawn that spawns on a drop won't auto-grab it — it must leave and re-enter. (A post-landing timer on the pickup does NOT work: the drop lands in ~0.4 s but respawn is seconds later, so it's long collectable by then.)
  • Audio: assigned SA_Default3D attenuation to Object_PickUp + HealthPickup (via bridge) so legit pickups fall off with distance.

Lesson: Drop-on-death + respawn-at-death-location = self-collection. Any "X happens on spawn without the player doing anything" should be checked against what spawns/lands at the spawn point, not just placed actors. And (again, cf. BUG-024) a 3D PlaySoundAtLocation with a no-attenuation sound is heard everywhere — the node being 3D is necessary but not sufficient; the sound asset needs Attenuation Settings.


#BUG-026 — Red reticle (enemy target) works on host but not clients after the target respawns

Date: 2026-06-26 Branch: grenade-initial-imp Status: Fixed (C++) — pending rebuild + test

Bug: As a client, aiming at another player did not turn the reticle red; as the host it worked. Narrowed down: only after the target had died (via BP_SL_TestDamageEmitter) and respawned — i.e. an alive, respawned target reads as "enemy" on the host but not on observing clients.

RCA: USLWeaponsComponent::CheckForEnemyTarget (drives OnTargetDetected via the WBP_SL_HUDWidget "Red Reticle Check") traced ECC_Pawn and returned Cast<APawn>(Hit.GetActor()) != nullptr. The only thing blocking ECC_Pawn is the capsule — and the death/respawn flow toggles capsule collision via paths that don't reach an observing client: the death ability disables it server-side only (SLGameplayAbility_Death.cpp:86, SetCollisionEnabled is not replicated) and Client_OnRespawnBegin disables it on the owning client only. Respawn is a fresh RestartPlayer pawn, but with RagdollDestroyDelay the corpse lingers and the observer's view of the target's capsule state after a death is unreliable. Net: the host (authoritative) sees the correct capsule; observing clients diverge → no red.

Fix: trace ECC_WeaponTrace (the channel real shots use — against the mesh's physics-asset bodies, which authoritative hit detection already relies on and which works on respawned proxies) and require ASLCharacterBase instead of any pawn. Removes the capsule dependency entirely and makes the reticle agree with where a shot would actually land. (FFA: any non-owner SL character is hostile — there's no team system.)

Lesson: Don't drive client-visible state off component collision-enabled flagsSetCollisionEnabled is not replicated, so any per-frame check that depends on it (here, an ECC_Pawn/capsule trace) will diverge between host and clients whenever something toggles it (death, ragdoll, respawn). Trace the same channel you act on (ECC_WeaponTrace) so cosmetic feedback matches authoritative behavior.

Open edge case — corpse targetability (decision: leave as-is, 2026-06-26): with the weapon channel, a ragdoll corpse's mesh can briefly read as a target (red reticle on a body you just killed) until it self-destroys after RagdollDestroyDelay. Decision: do nothing for now (you can shoot ragdolls in most shooters; not worth code). Do NOT add a Dead-tag check in CheckForEnemyTarget: the ASC lives on the PlayerState, so the corpse and the respawned pawn share one ASC which reads alive after respawn — the tag can't distinguish them, and the corpse is UnPossess'd so it may resolve no ASC at all. If it ever matters, fix it in the collision layer (on ragdoll, set the corpse mesh to ignore ECC_WeaponTrace) so the reticle trace AND the fire trace ignore corpses from one source of truth — keep CheckForEnemyTarget a dumb "did I hit an ASLCharacterBase" check. A possession check (GetController() != nullptr) is a replication-safe fallback if it must live in the reticle, but collision is the principled home.


#BUG-027 — Red reticle stops working after switching to the pistol (stale reticle reference)

Date: 2026-07-01 Branch: grenade-initial-imp Status: Fixed (Blueprint — WBP_SL_HUDWidget)

Bug: Aiming at an enemy turned the reticle red normally, but after drawing the sidearm (pistol) neither the pistol reticle nor the main-weapon reticle (after holstering back) would turn red. Intermittent-feeling — depended on which swap ran last. (Recurrence of the 2026-06-15 footgun documented in SidearmMode.md §13; sibling to BUG-026, which was the C++ detection side.)

RCA: CheckForEnemyTarget (C++) is correct — this is purely the HUD Blueprint driver. USLHUDWidget::SwapReticle destroys the old reticle widget and creates a new one on every swap (equip and sidearm draw/holster). The WBP_SL_HUDWidget Event Tick was pushing OnTargetDetected / OnSpreadChanged into a cached Reticle variable that was only assigned once, so after the first swap it pointed at the destroyed widget → target/spread silently drove a dead reticle. The pistol has its own PrimaryReticleClass (WBP_SL_Reticle_Pistol), so drawing it always triggers a swap — which is why the bug is pistol-specific now (before the sidearm, the reticle almost never swapped mid-life).

Fix: point the Event Tick's Target pins for OnTargetDetected and OnSpreadChanged at the live reticle via the C++ Get Reticle BlueprintPure (returns the current ActiveReticle) instead of the cached Reticle variable. (Equivalent alternative: re-SET the Reticle variable from SwapReticle's return value inside RefreshReticle.)

Secondary footgun hit during the fix (cost a round): deleting the Reticle BP variable silently retargeted every node it fed — including a Set Visibility node that managed the reticle's show/hide. In UMG a Set Visibility node whose Target pin loses its connection falls back to self (the whole HUD widget), so instead of showing the reticle it acted on the HUD → the crosshair vanished entirely (the reticle starts collapsed and is un-collapsed by the driver, so a mis-targeted visibility call leaves it hidden). Fix: re-point that Set Visibility's Target at the reticle (Get Reticle). Lesson: before deleting a BP variable, check every node it feeds — a dropped Target pin defaults to self, which is especially dangerous for Set Visibility/Set Is Enabled calls that then silently act on the whole widget.

Lesson: Anything that recreates a widget (SwapReticle) makes every cached reference to it stale. Per-frame drivers must read the live widget each tick (Get Reticle), never a reference cached at init. This is the third time this exact desync has surfaced (2026-06-15, and again here) because reticle creation and the tick driver reference the widget two different ways — candidate for a C++ unification pass (drive spread + target-detection from USLHUDWidget::NativeTick off ActiveReticle) so it's structurally impossible to desync.


#BUG-028 — Client-only: red reticle dead, AR fire sticks, then no weapon can fire

Date: 2026-07-28 Branch: movement-feel Status: 🔎 OPEN — investigation only, no fix attempted. Evidence below is from a live 2-player PIE session.

Bug (as a non-host client, 2-player listen-server PIE): three symptoms in one session —

  1. Aiming at an enemy stopped turning the reticle red (screenshot: reticle blue while centred on an enemy Spartan).
  1. Fired the AR and the fire "stuck".
  1. Switched to the shotgun and could not fire at all.

Symptoms 2 and 3 together point at a stuck tag on the ASC (the ASC lives on the PlayerState, so it survives a weapon swap — which is exactly why changing weapons didn't help). Symptom 1 looks separate; see below.

#⚠ This is NOT BUG-026 or BUG-027 — both of those fixes are ruled out

The reticle was tested live via the editor bridge by calling OnTargetDetected(true) directly on the widget returned by GetReticle(), every frame for ~5 seconds (a register_slate_post_tick_callback loop, re-resolving the live reticle each tick). It never turned red.

That bypasses both previous causes:

  • not BUG-026 (detection): no trace was involved at all, the event was called directly.
  • not BUG-027 (stale cached reference): the widget was re-resolved from GetReticle() every single frame.

All four reticle Blueprints do implement OnTargetDetected (verified in the asset name tables), so the event is not simply unimplemented.

#Evidence, with confidence

FindingHow measuredConfidence
Client burns through reticles ~3.5× faster than host: client on instance _32 while host was on _9, same session/weaponinstance names via bridge, both PIE worldsSolid
Reticle churn is not per-tick — held at _22 across 2 s, but went 17 → 18 → 22 → 32 over ~2 minrepeated samplingSolid
States.Character.InventoryLoaded count = 2 on client, 1 on hostGetGameplayTagCountSolid
No weapon tag stuck (Firing/Busy/RefireLock/PumpCooldown all absent)tag counts, client ASCSolid, but sampled while the AR was NOT stuck
Per-frame OnTargetDetected(true) on the live client reticle produces no redslate post-tick callbackSolid
Host reticle behaviour under the same pokeUNTESTED (first attempt used blocking time.sleep, which freezes the game thread — see Footguns)

Session log also shows, repeatedly:


Warning: Attempting to predict SLTags.States.Weapon.Busy tag addition, but potentially non-replicated tag already exists

Warning: Attempted to remove tag: SLTags.States.Weapon.Busy ... not explicitly in the container!

Warning: Attempted to remove tag: SLTags.States.Weapon.SidearmDrawing ... not explicitly in the container!

These are GAS reporting a corrupt tag count container — something adds these as loose (non-replicated) tags while an ability also predicts them. When the two paths interleave the counts drift, and a tag can stick at count ≥ 1 with nothing able to remove it. That is a plausible single cause for symptoms 2 and 3, and the InventoryLoaded = 2 above is the same fault caught red-handed on a tag we can still see.

#Leading hypothesis (untested)

The client has more than one WBP_SL_HUDWidget instance, and GetHUDWidget() returns one the viewport isn't rendering. It would explain the reticle result (poking "the live reticle" does nothing visible because it lives in an off-screen HUD) and the client-only churn (the client rebuilding its HUD repeatedly). It fits the known ordering hazard that PossessedBy fires before ASLPlayerHUD::BeginPlay (see feedback_hud_init_timing), which is exactly the shape that produces a second init on a client.

#Next diagnostic steps (do these BEFORE writing any fix)

  1. Count WBP_SL_HUDWidget_C instances in each PIE world and determine which one is actually in the viewport.
  2. If the client has two, that is the bug and most of the above are symptoms.

  1. Find what recreates the reticle on the client. Only WBP_SL_HUDWidget calls SwapReticle/ClearReticle;
  2. SwapReticle early-outs when the class is unchanged (SLHUDWidget.cpp:59) and the active class never changed, so something is calling ClearReticle() and re-swapping, or driving swaps around that guard.

  1. Catch the fire lockup in the act. When the AR sticks, leave PIE running and read the client ASC tag counts —
  2. that names the stuck tag directly. It was not reproducible during this session's inspection window.

  1. Track down who adds Busy / SidearmDrawing / InventoryLoaded as loose tags while an ability predicts
  2. the same tags; mixing the two is what corrupts the counts.

#Update 2026-07-28 (second session, 2-player PIE)

  • Duplicate-HUD hypothesis is DEAD. obj list class=WBP_SL_HUDWidget_C shows exactly one per player
  • (GameInstance_N.WBP_SL_HUDWidget_C_0 twice). The third instance is outered to a World, not a GameInstance — an editor preview instance, not a gameplay HUD.

  • **NEW, and unrelated to the reticle: `GetSocketInfoByName(MuzzleSocket): No SkeletalMesh for
  • Component(FPWeaponMesh) spams hundreds of times, on BOTH worlds. The C++ fire path guards this (SLGameplayAbility_Fire.cpp:306 checks GetSkeletalMeshAsset() first); the Blueprint** callers do not — BP_GA_SL_AssualtRifle_PrimaryFire, the shotgun/placeholder fire abilities, and the GC_SL_*_PrimaryFire cues all reference MuzzleSocket. Cues multicast to every machine, so on a machine where the pawn is a remote proxy the FP weapon mesh is empty → failed lookup → zero transform → muzzle FX at the world origin plus the spam. Same family as FireAbilityNetworkTesting.md` Test 1. Fix: guard the BP socket lookups the way the C++ does, or gate the FP-muzzle branch on locally-controlled.

  • This does NOT explain the reticle. CheckForEnemyTarget traces from the camera location/forward
  • passed in as parameters — it never reads a socket, so a broken muzzle lookup cannot suppress red targeting.

  • Stalls persist: UNetDriver::TickDispatch: Very long time between ticks ... Realtime: 15.18 on both drivers.
  • Also seen: BP_SL_MasterChief is stuck and failed to move! penetrating BP_SL_GrenadePickup_Frag — separate
  • issue, grenade pickups can trap a character.

Remaining blind spot (the only one): the contents of OnTargetDetected inside WBP_SL_Reticle_AssaultRifle. Every other explanation has been eliminated by direct test — the event is implemented, the widget is live and parented, and calling it every frame on the live instance changes nothing. The bridge can read assets and live objects but not Blueprint graph logic, so this needs a human to open the graph. Do that before theorising further.

Related: BUG-026 (detection traced the wrong channel), BUG-027 (stale cached reticle reference). BUG-027's own lesson already proposed the structural fix that would likely prevent this whole family: drive spread and target-detection from USLHUDWidget::NativeTick off ActiveReticle in C++, so reticle creation and the per-frame driver cannot reference the widget two different ways. This is now the fourth appearance of a red-reticle desync.


#BUG-028 — RESOLUTION (2026-07-28)

Status: ✅ Root-caused and fixed for the reticle symptom (Phase 4 of AimTargetRefactor.md). The fire-stick symptom is still open — see the loose-tag note at the end.

What it actually was. Not detection, not a stale widget reference, and not the reticle Blueprint's OnTargetDetected contents (the "remaining blind spot" above was a dead end — the event was fine all along).

WBP_SL_HUDWidget's Event Tick ran one long chain: a validated GET on the cached SLPlayer Character, into a Branch on Is Equipping (read from Anim State Snapshot, not a gameplay tag), into the reticle spread section behind a second validated GET on the cached Weapons Component, and only then into the Red Reticle Check. Every one of those Is Not Valid pins was unwired. When a cached per-pawn reference went stale — which happens on every respawn, because the pawn is a new actor and nothing re-acquired it — the chain died at that gate: no error, no log, no Accessed None, and every downstream node (including the reticle colour) silently stopped for the rest of the match.

How it was finally caught. Instrumentation, not inference. sl.AimTarget.Debug proved the trace, channel, classification and respawned proxies were all correct on the client in the same frame the reticle was blue — eliminating four suspects in one PIE run. sl.DumpWidgets showed exactly one HUD widget per machine with the right owning player, killing the duplicate-widget theory. A same-frame bridge sample then showed ResolveAimTarget = Hostile, CheckForEnemyTarget = true, perception = Hostile — every C++ path correct while the widget stayed blue. That isolated the fault to Blueprint before the graph was ever read.

Fix: USLHUDWidget::InitializePerception() binds USLPlayerPerceptionComponent::OnAimTargetChanged and drives USLReticle::OnTargetDetected. It is called from ASLPlayerCharacter::InitializeLocalPlayerHUD on every possession, so a respawned pawn rebinds automatically. The Red Reticle Check was deleted from the Blueprint. Verified on host, on client, across weapon swaps (two different reticle classes), and through client death then respawn then re-aim.

Lesson (the real one, and it outlives this bug): a validated GET with an unwired Is Not Valid pin is a silent, permanent failure. It cannot be found in a log because it never writes one. Combined with a per-pawn reference cached in a widget — which respawn invalidates by construction — it produces a system that works perfectly until someone dies and then never works again, with zero diagnostics. Prefer state that lives on the pawn (a component) over references to the pawn (a cached variable): a respawned pawn brings a correctly-wired component with it, so there is nothing left to go stale. Where a BP validated get must stay, always wire Is Not Valid, even if only to a print.

Still open from BUG-028: the fire-stick / can't-fire symptom. Two loose gameplay tags were measured inflating on the client — InventoryLoaded at 2 and 5 against 1 on the server, and ReadyToFinishLoadout at 6 against 3. Loose tags are counters, not booleans: three unguarded AddLooseGameplayTag sites race one RemoveLooseGameplayTag guarded by HasMatchingGameplayTag (true at any count >= 1), so the count can never drain. A blocking tag stuck this way would lock out every weapon, which matches the symptom, but it has not been caught in the act. Fix direction: SetLooseGameplayTagCount(Tag, 1/0) for boolean state. Deliberately left unfixed so the repro survives. Use sl.DumpTags (flags any count > 1) and sl.ClearWeaponLocks.


#BUG-032 — Overshield indicator shows when the player has no overshield

Date: 2026-08-05 Branch: level1-import Status: ✅ Fixed — PIE-verified by Beepers 2026-08-05 (pickup → bar appears, fills, drains). Not separately confirmed: the die-holding-overshield respawn case, and the client-side path.

Bug: The HUD shows an overshield layer on players who do not have overshield.

RCA: Not a state bug — measured live through the MCP bridge against the running PIE session: CurrentShield 50 / MaxShield 50 on both players and no Overshield tag on any pawn, so OvershieldPercent computes to exactly 0 and OnOvershieldChanged(0.0) fires correctly.

The problem is that nothing acted on the zero. WB_SL_HealthWidget sets the bar's percent and never touches visibility anywhere — an ASCII scan of the whole 260 KB asset finds only bCommentBubbleVisible (editor metadata). A UMG ProgressBar still draws its BACKGROUND brush at 0%, so the empty track keeps rendering and reads as an active overshield.

Note the widget also carries an OvershieldFill animation (lowercase s) alongside the OverShieldProgressBar element (capital S) — worth keeping in mind, since a mismatch of exactly that kind silently breaks BindWidget.

Fix: USLHealthWidget binds OverShieldProgressBar via BindWidgetOptional and calls ApplyOvershieldVisibility() immediately after every OnOvershieldChanged — both in the init path and in BroadcastCurrentValues. Collapsed at <= 0, SelfHitTestInvisible above it. Put in C++ rather than the graph because that is where the percent is already computed, and this widget has a demonstrated habit of not managing its own visibility.

Rule: publishing a value is not the same as reflecting it. A progress bar at 0% is still a visible widget — driving Percent alone never hides anything.

Second defect found in the same pass — spurious overshield-lost on respawn. InitializeHealthWidget reset PrevShieldPercent and bShieldRecharging but not PrevOvershieldPercent. That value is the edge detector for the overshield transitions, so dying while holding overshield carried it into the next life: the first broadcast after respawn evaluated OvershieldPercent <= 0 && PrevOvershieldPercent > 0 as true and fired OnOvershieldDepleted on a freshly spawned player, playing the overshield-lost sound and visual for an overshield they never had. Fixed by resetting it alongside the others.

Rule: every "previous value" used for edge detection is per-life state. If a respawn resets one, it must reset all of them — a missed one does not fail loudly, it fires a transition that never happened.

Third defect — the listen-server host skipped the reset entirely. NativeConstruct and InitializeHealthWidget are the two entry points, and they did not reset the same things: NativeConstruct cleared PrevShieldPercent and bShieldRecharging but never called OnReset(). NativeConstruct is the path taken when the pawn is already possessed at widget construction — i.e. the listen-server host — so the host never received the Blueprint reset that zeroes the overshield bar's render opacity and stops its animations, and started against whatever the widget was authored with. Clients, which arrive via InitializeHealthWidget, always did. Fixed by making both paths reset identically.

Rule: when two entry points initialise the same widget, they must reset the same state. A gap between them is a host-only or client-only bug — it reproduces for one player, not the other, and gets blamed on replication.

How the overshield bar actually works (worth recording — it is not obvious from either side alone): three things lower its render opacity (OnReset, OnOvershieldDepleted, and Update Overshield's False branch) and exactly one raises it — OnOvershieldActivated playing the OvershieldFill animation with Restore State off, so the widget keeps the animation's end values. That single raiser is a transition event, which is why a stale PrevOvershieldPercent could leave a player holding overshield with an invisible bar: Update Overshield sets the Percent, and nothing ever turns the bar on.

Still open (Blueprint side): WB_SL_HealthWidget hides the bar with Render Opacity, not visibility. OnReset zeroes it on every respawn, but OnOvershieldChangedUpdate Overshield runs immediately afterwards — if that function sets opacity to 1 unconditionally rather than branching on percent, it undoes the reset and no authored default value will help. Check inside Update Overshield.


#BUG-031 — "Press E to equip" prompt sticks on the HUD forever

Date: 2026-08-05 Branch: level1-import Status: Fixed in C++, NOT yet PIE-verified

Bug: The weapon pickup prompt stays on screen permanently, advertising a weapon that is not there. Caught in PIE showing an assault rifle prompt while the only weapon pickup in Level1 was a Pistol — the prompt belonged to a pickup that no longer existed.

RCA: Diagnosed live through the MCP bridge against the running PIE session:

  • sl.DumpTags → no stuck tags (InventoryLoaded count=1, no inflation).
  • GetPendingPickup()NONE on every pawn, so C++ believed nothing was pending.
  • Calling USLHUDWidget::HideWeaponPickupPrompt() directly on the live widget cleared the prompt,
  • proving the C++ hide path works and simply was never invoked.

The prompt is raised by ShowPromptForSetPendingPickup(this) + Client_ShowPickupPrompt, and was only ever retracted from OnOverlapEnd or AcceptPickup. Neither runs when the pickup is destroyed underneath a standing player — collected by another player, or consumed as ammo.

The trap is that this leaves no evidence. ASLCharacterBase::PendingPickup is a UPROPERTY, so UE nulls it automatically when the pickup actor dies. The character's server state reads perfectly clean while the client's HUD still shows the prompt, and every later check sees a null PendingPickup and concludes there is nothing to retract — so it never clears for the rest of the match.

SECOND CAUSE — found after the first fix, and probably the one that actually bit. On PIE exit Beepers hit:


Accessed None trying to read property CallFunc_GetHUDWidget_ReturnValue_1

Node: HideWeaponPickupPrompt   Graph: EventGraph   Blueprint: BP_SL_MasterChief

So the hide was dispatched — Client_HidePickupPromptOnHidePickupPrompt → BP → but GetHUDWidget() returned None, and HideWeaponPickupPrompt was called on nothing.

ASLPlayerCharacter::GetHUDWidget() resolves through GetController()GetHUD(). A pawn that has been unpossessed has no controller, so any hide issued at death, respawn or teardown silently reaches nothing. This does not need the pickup to be destroyed at all: walk onto a pickup, die while standing on it, and the overlap-end hide fires against a pawn that can no longer find its own HUD.

This is the BUG-028 shape again — a validated GET whose failure branch does nothing, dying silently with no gameplay-visible error.

Fix (two parts, both needed):

  1. ASLWeaponPickup tracks PromptedCharacters explicitly and overrides EndPlay to retract the prompt
  2. from everyone still holding it. Show/clear funnel through ShowPromptFor() / ClearPromptFor() so the three call sites cannot drift. Also covers a case never reported: two players on one pickup, one takes it, the other's prompt used to stick.

  1. ASLPlayerCharacter::InitializeLocalPlayerHUD now calls HUD->HideWeaponPickupPrompt(). The HUD
  2. belongs to the PlayerController and outlives pawns, so a fresh pawn inherits the old one's prompt. Clearing on every possession makes the new pawn start clean no matter how the last one died.

  1. Client_ShowPickupPrompt / Client_HidePickupPrompt bail when GetHUDWidget() is null, so the
  2. Blueprint handlers are never entered without a HUD to act on. This is what silences the Accessed None … CallFunc_GetHUDWidget_ReturnValue log; the retract that actually mattered is covered by (2).

  1. The EndPlay retract in (1) fires only for EEndPlayReason::Destroyed. Every other reason is the
  2. world going away, taking the HUD with it — dispatching a hide there retracts nothing and merely logs. The first cut of (1) missed this and added a second Accessed None on PIE exit.

Rules:

  • A UPROPERTY pointer that auto-nulls is not an exit path — it destroys the evidence that cleanup was
  • needed. Destruction of the thing that caused a state must retract that state explicitly (as BUG-029).

  • Anything owned by the PlayerController outlives the pawn. Cleanup routed through the pawn will be
  • skipped exactly when it matters most (death, respawn, teardown), because the pawn has lost its controller by then. Give the surviving object a reset on possession as well.


#BUG-030 — Other players' first-person arms render in the world (floating, disembodied)

Date: 2026-08-04 Branch: level1-import Status: ✅ Fixed — PIE-verified by Beepers 2026-08-05

Bug: In multiplayer, a remote player's first-person arms and FP weapon render in world space for everyone else — visible as disembodied arms floating in mid-air, detached from the owner's body. The same player's third-person body renders correctly at the same time, so you see both representations of them at once.

Surfaced when BP_SL_MasterChief's old SetMeshVisibility Blueprint function was deleted and ASLPlayerCharacter::ApplyViewModeVisuals() became the only thing driving mesh visibility.

RCA: Two faults compounding, both rooted in the same wrong assumption — that mesh visibility is a purely local-view concern.

  1. The FP hide sat behind a locally-controlled guard. ApplyViewModeVisuals() opened with
  2. if (!IsLocallyControlled()) return; before touching any mesh. That guard is correct for the third-person meshes, the camera boom and the shadow mode, but not for the FP meshes. The TP meshes hide via SetOwnerNoSee, which the renderer evaluates per viewer — so setting it on a pawn you do not control is meaningless. The FP meshes hide via plain SetVisibility, which is machine-wide. A remote pawn therefore never got told to hide its FP meshes, and they kept the visible state they spawned with.

  1. There was no call site that ran on a remote pawn anyway. ApplyViewModeVisuals is invoked from
  2. the "Set Mesh Visibility" event track on BP_SL_MasterChief's view-mode timeline, which only ever runs on the pawn's own client. On every other machine the function was never called for that pawn at all — so even with the guard fixed, nothing would have triggered the hide.

Why they float: FPMesh attaches to FPMeshPivot → Camera → FPCameraPivot → CameraBoom. On an observed pawn that rig is driven by replicated control rotation rather than a local camera, so the arms sit out at the camera position with no body under them.

The old Blueprint function masked this because it was called from graphs that happened to run on the right machines; the C++ port made the guard the single point of truth and the gap became visible.

Fix: Split the FP visibility out of the local-only path and give it call sites that run everywhere.

  • New ASLPlayerCharacter::ApplyFirstPersonMeshVisibility() computes
  • IsLocallyControlled() && ViewMode == FirstPerson and applies it to FPMesh / FPWeaponMesh with bPropagateToChildren=true. FPSidearmMesh is a child of FPMesh, so propagation covers it.

  • ApplyViewModeVisuals() now calls it before its IsLocallyControlled() early-return; everything
  • below the guard (TP SetOwnerNoSee, camera boom rotation source, shadow mode) is unchanged.

  • Also called from BeginPlay(), PossessedBy() and OnRep_PlayerState() — above their early returns.
  • BeginPlay covers a remote pawn spawning on our machine; the other two re-evaluate once possession has replicated and IsLocallyControlled() is finally meaningful.

Rule: SetOwnerNoSee is per-viewer, SetVisibility is per-machine. Anything hidden with SetVisibility must be driven on every machine, so it cannot live behind IsLocallyControlled().


#BUG-029 — Host invisible to all clients after the host respawns (ragdoll never cleared on observers)

Date: 2026-07-28 Branch: aim-target-refactor Status: 🔎 OPEN — root-caused, no fix attempted. Next up.

Bug: In a 3-player listen-server PIE session, the host player was not visible in either client window. Each client could see the other client normally. The host's own window looked completely normal.

It is not a visibility bug. The host's pawn exists on every machine, and on the clients its mesh reports visible=true, hiddenInGame=false, ownerNoSee=false, onlyOwnerSee=false — configured identically to the client proxies that render fine. It has fallen out of the world:

BP_SL_MasterChief_C_9Server (authority)Both client proxies
Actor location(117, 1123, 90)(117, 1123, -54559)
Mesh location(107, 1120, 10)~(-115, 298, -1153901)
IsSimulatingPhysicsfalsetrue
CollisionQueryOnlyQueryAndPhysics
Movement modeWalkingFalling
Velocity Z0-4000 (terminal)

X and Y still track the server exactly; Z does not. The observing clients are running an un-cleared ragdoll that free-falls forever.

RCA: ASLCharacterBase::EnableRagdoll() is invoked from an anim notify (SLAnimNotify_DeathFinished.cpp:21), so it executes locally on whichever machine plays the death montage — it is not authoritative or replicated state. And there is no DisableRagdoll() anywhere in the codebase: the design assumes the ragdolled pawn is destroyed (SLGameModeBase then WeakOldPawn->Destroy()). Therefore any machine that ragdolls a pawn which is not subsequently destroyed on that machine is stuck simulating with no path back. The respawn cleanup runs server-side plus an owning-client RPC; for a listen-server host the owning client is the server, so the host repairs its own view and observing clients get nothing.

Same family as BUG-023: anim notifies are not authoritative in MP — the server does not tick remote clients' montages, so notify-driven state diverges per machine. Ragdoll is state, but it is being driven like local FX.

Very likely also the true cause of BUG-026. A client aiming at a host who has died and respawned is tracing at a proxy whose physics-asset bodies sit ~1.1M units below the map — so the reticle can never turn red regardless of collision channel. The ECC_Pawn to ECC_WeaponTrace change treated a symptom; the proxy was never there to hit. Re-read BUG-026 in that light before trusting its RCA.

Fix direction (not started): make ragdoll replicated state, not a per-machine notify side effect — either a replicated bRagdollActive with OnRep_ that applies/clears it, or a death gameplay cue, which is what CLAUDE.md's GAS-first rule already prescribes ("any cosmetic that must reach all clients — prefer cues over custom Multicast RPCs"). Add a real DisableRagdoll() that restores capsule collision, mesh collision profile and SetSimulatePhysics(false), so recovery is possible at all.

Test plan (requested): the network divergence itself needs multiplayer PIE, but the recoverability half is unit-testable headlessly — spawn an ASLCharacterBase in a temp world, call EnableRagdoll() then DisableRagdoll(), and assert capsule collision, mesh collision profile and IsSimulatingPhysics() all return to their pre-ragdoll values, plus idempotence under repeated calls. That test is only possible once DisableRagdoll() exists — which is itself an argument for adding it rather than continuing to rely on destroying the pawn. Pair it with a scripted host-death/respawn check in multiplayer PIE for the replication half.

Lesson: anything a remote machine must agree on is state, not an anim notify. A notify is a good place to trigger local FX and a bad place to own a fact. If a fact can be entered, it needs an explicit exit — "the actor gets destroyed" is not an exit path, it is an assumption about someone else's cleanup.