Reference · Updated 2552.08.11.16.33

Footguns — SystemLink Hard-Won Gotchas

The canonical internal index of traps we've hit, why they bite, and the rule that avoids them. Scannable on purpose: each entry is symptom → rule → pointer to the fuller writeup.

The canonical internal index of traps we've hit, why they bite, and the rule that avoids them. Scannable on purpose: each entry is symptom → rule → pointer to the fuller writeup.

  • Bugs with full RCAs live in Docs/BugTracker.md (BUG-0xx). This doc points at them; it doesn't replace them.
  • Public/lightweight version is Docs/AI/known-pitfalls.md (safe for the generated site). Keep secrets/infra out of that one; this file is internal.
  • When you burn a session on something new, add a one-liner here.

#Multiplayer / Authority / Replication

  • Anim notifies are NOT authoritative in MP. The server doesn't tick a remote client's montage, so a notify that spawns/damages works for the listen-host but silently no-ops for clients. Drive authoritative timing off a world timer / ability task, never the mesh. Notifies = local cosmetics only. → BUG-023; memory feedback_anim_notify_server_authority.
  • Broadcast on the authoritative change, not just OnRep. The listen-server host never receives its own OnRep, so HUD/state that only updates in OnRep is stale on the host. Broadcast in the setter and OnRep. → BUG-020.
  • Don't drive client-visible state off SetCollisionEnabled flags. Collision-enabled isn't replicated; any per-frame check depending on it (e.g. an ECC_Pawn capsule trace) diverges between host and clients after death/ragdoll/respawn toggles it. Trace the same channel you act on (ECC_WeaponTrace). → BUG-026.
  • Drop-on-death + respawn-at-death-spot = self-collection. A pawn that spawns on top of its own dropped pickup auto-collects it. Fix with a spawn-grace window on the character, not a post-landing timer on the pickup. → BUG-025.
  • GAS EventData.OptionalObject is unreliable on the owning client. Use GetEquippedWeapon (or equivalent replicated state), not GetWeaponDataFromEvent, in Server-Initiated ability BPs. → memory feedback_gas_event_data_object_ptr.
  • Simultaneously-dropped bouncing pickups freeze mid-air. Two+ pickups spawned at the same point bounce off each other, hit MaxBounces instantly, and StopMovementImmediately. Make them ignore each other during the arc (IgnoreActorWhenMoving). → BUG-019.
  • Anything a remote machine must agree on is STATE, not an anim notify. Notifies run only on machines that play the montage, and the server does not tick remote clients' montages. EnableRagdoll() was notify-driven with no DisableRagdoll() at all — so an observing client could ragdoll a pawn the server thought was alive, and nothing could ever clear it. The pawn free-fell out of the world forever, presenting as "that player is invisible". If a state can be entered it needs an explicit exit; "the actor gets destroyed" is an assumption about someone else's cleanup, not an exit. → BUG-029, BUG-023.
  • Overlap events fire on EVERY machine that simulates them. OnComponentBeginOverlap is not a server event. Both migrated 5.6 interactables acted straight off it: the client opened its own door / moved its own pawn locally, the server did it again, and any NetMulticast the client called was silently dropped (multicasts only replicate from the server). Gate every overlap handler on HasAuthority(), decide on the server, replicate the state. A door opened client-side is a desync; a pawn moved client-side is a desync and a cheat vector. → Docs/Doors.md §1, Docs/Teleporters.md §1.
  • A Client_ RPC on a level-placed actor goes NOWHERE. RPC routing needs an owning connection, and a level actor has no owner — so the call compiles, runs, and silently reaches nobody. To show one player a cosmetic from a world actor, hop through something that player does own: the pawn (ASLCharacterBase::Client_ShowPickupPrompt) or the controller (ASLPlayerController::Client_OnTeleported, which calls straight back into the teleporter so the authoring stays there). → Docs/Teleporters.md §5.
  • Server-side SetControlRotation on a player is overwritten within a frame. The autonomous client owns its control rotation and ships it upstream every move. Use APlayerController::ClientSetRotation to turn a player from the server. Setting the pawn's actor rotation does nothing at all to where a possessed player is looking — that was the migrated teleporter's arrival-facing bug, and it reads as "the teleporter feels wrong" rather than as a rotation bug. → Docs/Teleporters.md §2.
  • A teleport destination must go not-ready BEFORE the move, not after. Landing on the exit pad puts the pawn inside that pad's own trigger volume, which fires BeginOverlap synchronously inside TeleportTo. Still ready at that instant = sent straight back, forever. Consequence worth knowing: readiness returning must not re-evaluate whoever is standing there, so using a teleporter twice means stepping off and back on. → Docs/Teleporters.md §2.
  • Never release a latch on EndOverlap alone — dying inside the volume strands it. EndOverlap is not reliably delivered for an actor destroyed inside a trigger, so any "latch on enter, clear on leave" handshake bricks permanently the first time someone dies standing in it. The migrated teleporter died this way (a Target Actor that was never cleared); ASLDoor survives it by also binding OnDestroyed per occupant. Prefer a timer to a latch when the thing being tracked can dieASLTeleporter has no occupant state at all for exactly this reason. → Docs/Doors.md §6, Docs/Teleporters.md §1.
  • A one-shot cosmetic can't ride on replicated state that didn't change. Replicated state is right for a glow (a late joiner receives the current value and is correct for free); a NetMulticast is wrong for anything (not delivered to whoever was non-relevant at the time). For a momentary event everyone must see, replicate a counter and play the effect in its OnRep — and guard it on BeginPlay having run, or a joining client plays a flash for a teleport that happened before they connected. → Docs/Teleporters.md §2.
  • SetOwnerNoSee is per-VIEWER; SetVisibility is per-MACHINE — never hide both behind IsLocallyControlled(). The TP meshes hide with SetOwnerNoSee, which the renderer evaluates per viewer, so setting it on a pawn you don't control is meaningless and the guard is correct. The FP meshes hide with plain SetVisibility, which is machine-wide — so a remote pawn behind that same guard is never told to hide, and its first-person arms render in the world for everyone else, floating disembodied off that pawn's camera rig. Compounding it: the only call site was the view-mode timeline, which runs solely on the pawn's own client, so a remote pawn had no call site at all. Anything hidden with SetVisibility needs a call site that runs on every machineBeginPlay for spawn, plus PossessedBy/OnRep_PlayerState to re-evaluate once possession has replicated and IsLocallyControlled() finally means something. → BUG-030.

#GAS

  • Loose gameplay tags are COUNTERS, not booleans. Add increments, Remove decrements. Any tag meaning a state written from more than one code path ends up at a count > 1 — and the natural guard, if (HasMatchingGameplayTag) Remove, cannot drain it, because that check is true at any count ≥ 1. Measured in 3-player PIE: InventoryLoaded at 2 and 5 on clients against 1 on the server, ReadyToFinishLoadout at 6 against 3 (it inflates on the host too, so it reproduces in single-player). A stuck blocking tag locks the player out of every weapon for the rest of the match. Use USL_BlueprintLibrary::SetBooleanStateTag(ASC, Tag, bPresent) (BP node: Set Boolean State Tag) — it assigns via SetLooseGameplayTagCount instead of incrementing. Verify with sl.DumpTags, which flags any count above 1. → BUG-028; Docs/RespawnSystem.md.
  • Ability handles must live on the PlayerState, not the pawn/component. Pawn destruction on respawn zeroes component-local handles → TakeFromASC no-ops → specs accumulate (extra bullets per respawn). → memory feedback_ability_handles_playerstate.
  • Only USLAbilitySet grants abilities. Never call GiveAbility directly. A separately-carried slot (sidearm) never runs through GrantWeaponAbilities, so its abilities MUST be set-granted.
  • GAS stacking: effects like ShieldRegen must use Stack Per Target, limit 1, or they compound. → Docs/HealthSystem.md.
  • PlayMontageAndWait replicates; Montage_Play is local-only. For TP fire anim, use the ability task (GAS multicasts to sim proxies). FP is local cosmetic. No separate multicast RPC needed. → memory GAS Montage Replication.
  • Effect causer drives hit-direction. Set Context.AddInstigator(controller, causer) where causer is the exploding/firing actor, so radial hit-direction radiates from the blast point. → BUG-005.
  • Tag-callback vs poll for proxy-visible state. One-shot GAS tag-event callbacks are unreliable on the listen-host for simulated proxies; poll the replicated tag in BuildAnimSnapshots instead. → BUG-014; memory feedback_gas_tag_callback_patterns.

#GAS — gameplay cues

  • A native UGameplayCueNotify_Static subclass is NOT registered and will never fire. The cue manager scans asset data in GameplayCueNotifyPaths and reads the GameplayCueName property off it (GameplayCueManager.cpp:889) — a C++ class is not an asset in a content path, so it is invisible. Every native cue class needs a Blueprint subclass in a scanned path. Failure is completely silent: ExecuteGameplayCue finds no handler, logs nothing, and the code looks correct. The mobility thruster cues compiled, passed a Live Coding build, and did nothing at all for two days. → Docs/MobilityCues.md.
  • A Blueprint cue MUST be named so its own name derives the intended tag — strip GC_, _., prepend GameplayCue.. GC_Mobility_DashGameplayCue.Mobility.Dash ✅; the project's usual GC_SL_ prefix → GameplayCue.SL.Mobility.Dash, which is not a registered tag and silently kills registration. The symptom is maddening: GameplayCueTag reads correctly on the CDO and in the details panel while GameplayCueName (the registry mirror the manager registers from) is None, so the cue never fires from a cold start. Cause is AbilitySystemGlobals.h:129 — when a child's tag equals its parent's, the engine clears the child's tag, re-derives from the ASSET NAME, and on failure restores the tag but not GameplayCueName. Cues parented to engine classes never hit this (their parent CDO tag is invalid); cues parented to our own GC_* native classes always do. Do not apply the SL naming convention to these assets.Docs/MobilityCues.md.
  • An in-editor tag edit registers a cue for that session only. Setting GameplayCueTag in Class Defaults runs PostEditChangePropertyHandleAssetAdded, registering it live — so a broken asset appears to work until the next restart. Never accept "it fired once" as proof; restart the editor and fire it again. This masked a genuinely broken asset for a day, and made a correct diagnostic look like a false negative.
  • To ADD cosmetics to a cue that already has C++, use the HandleGameplayCue EVENT, not an OnExecute override. The native router calls K2_HandleGameplayCue and then still runs OnExecute (GameplayCueNotify_Static.cpp:65), so the event adds rather than replaces, returns void (real exec pins), and cannot be accidentally unwired. Overriding OnExecute instead replaces the native implementation — the VFX vanishes the moment a sound is added, with no error.
  • OnExecute is BlueprintPure, so Parent: On Execute has NO exec pins — and a pure node only runs when something pulls its output. Wire it into the Return Node's Return Value or the parent never executes at all, while the node sits in the graph looking connected. Wiring it to two consumers evaluates it twice and spawns the effect twice. The surrounding function graph does have exec flow, so impure nodes are fine there; it is only the parent call that is pure. → Docs/MobilityCues.md.
  • Cue notifies are non-instanced and OnExecute is const — no member state, no Delay, no Timeline. Use a world timer, or UGameplayCueNotify_BurstLatent.
  • A GameplayCueNotify_Burst/_Static cue CANNOT stop an effect it started. It has OnExecute and nothing else — no OnRemove, no WhileActive — and it is CDO-based, so stashing the spawned NiagaraComponent in a variable writes to the shared class default and will not survive to a later call anyway. "Spawn it in the cue, Deactivate it when the ability ends" is not possible in this cue type. Either make the effect self-terminating (Niagara Loop Behavior Once + a Loop Duration matching the ability), or move to an instanced GameplayCueNotify_Looping actor cue driven by AddGameplayCue/RemoveGameplayCue instead of ExecuteGameplayCue. Note you cannot reparent Burst→Looping (Static is a UObject, Looping is an AActor), and reparenting a BP mid-session crashes the editor anyway — make a new asset.
  • Never wrap a cue in if (HasAuthority()) inside a predicted ability. ExecuteGameplayCue already branches internally: authority multicasts, and a client holding a local prediction key plays it immediately (GameplayCueManager.cpp:1513). An authority gate throws that away and costs the acting player a full round trip before they see their own effect. It does not double-play — the predicting client skips the multicast echo via PredictionKey.IsLocalClientKey() (AbilitySystemComponent.cpp:1612). Keep authority gating for game state (charges, cooldowns), never for cosmetics.

#GAS — abilities that move the character

  • A gameplay ability cannot outlive its own spec, and respawn removes it. PossessedBy does TakeFromASC + re-grant, which ends any running instance. Measured: an ability activated on a client respawn was ended (cancelled=0, not by its own code) two frames after activating, while the thing it was waiting for arrived ~35 frames later. On the host the re-grant happens before OnPawnInitialized, so this only ever shows on clients. Do not put a wait that spans respawn initialisation inside an ability — put it on the pawn, which outlives the churn. → Docs/RespawnSystem.md (Loadout & Default Equip).
  • Check first, then listen — a tag callback never fires for state that is already true. RegisterGameplayTagEvent and Wait Gameplay Tag Query both react to changes. Anything that only listens will hang forever whenever it starts after the state it wants, and on a client the RPCs can land in either order, so both cases genuinely occur. Always evaluate the condition on entry and only register callbacks if it is not yet satisfied. → BUG-028; ASLPlayerCharacter::BeginLoadoutEquipWatch.
  • When something works, know WHY before you tidy it. The spawn equip worked for a reason nobody had written down: a never-cleared tag made the wait resolve two frames before the ability was destroyed. Three separate principled-looking fixes — clearing the tag on BeginPlay, consuming it at the point of use, reimplementing the ability in C++ — each removed that coincidence without replacing it, and each regressed a different respawn case. A count nobody reads is not worth a working system.
  • An ability that never ends looks exactly like a broken cooldown or resource. USLGameplayAbility_Dash has ActivationOwnedTags = States.Character.Dashing and BlockAbilitiesWithTag = Dashing (a sane re-entry guard). When the ability failed to end, that tag stayed applied forever and blocked every later activation — presenting as "I only get one dash, it never recharges" while the charges were sitting full at 1/1 the whole time. Diagnose by reading the ASC's tags, not the resource: tag still present at rest = the ability didn't end. → 2026-07-27, dash.
  • **Don't hang an ability's lifetime on UAbilityTask_ApplyRootMotion*'s OnFinish. Observed on 5.7: the movement completed cleanly (movement mode and velocity back to normal, root motion source gone) but OnFinish never reached the bound handler, so EndAbility never ran. Own the lifetime with a separate UAbilityTask_WaitDelay instead. Make it slightly longer** than the motion — ending the ability destroys the root motion task, and OnDestroy yanks the source out before it can apply its clamp-on-finish, so exact-duration timing loses the exit velocity settings.
  • Never let each machine resolve an input-derived direction independently. The dash originally computed its direction inside the ability from GetLastMovementInputVector. The client computed correct sideways vectors; the server saw a remote pawn's input as stale/empty, hit the "no input → forward" fallback, and its correction overrode the client — so every dash went forward on screen no matter which way you held. Fix: resolve it once on the machine that owns the input (the input handler) and pass it in the FGameplayEventData — GAS forwards trigger data to the server for predicted activations (verified: AbilitySystemComponent_Abilities.cpp:1923 sends the whole struct via ServerTryActivateAbilityWithEventData). A yaw in EventMagnitude is enough for a horizontal direction. This applies to any directional ability: dash, dodge, directional melee, thrust.
  • The same rule covers choices derived from the WORLD, not just from input — "which enemy am I lunging at" is as divergent as "which way am I holding". Two machines sweeping independently can disagree about whether a target exists at all, and that is not a small error: for the melee lunge it is a 1600-speed leap against a 600-speed nudge. Two valid shapes, pick by whether the server can see the input: send the resolved vector (dash sends a yaw — mandatory, since the server cannot see a remote pawn's input), or send the actor and let each machine recompute the geometry (melee sends FGameplayEventData::Target — the server then aims at where the target actually is, which is what its own damage sweep will use a moment later). The second keeps a few degrees of divergence; the first has none but aims at stale positions. → USLGameplayAbility_Melee::FindLungeTarget, Docs/Melee.md.
  • LaunchCharacter is not replayed on a client correction. FSavedMove_Character never stores PendingLaunchVelocity; all the engine does is set bForceNoCombine so the resulting velocity rides in the move ("Launch velocity gives instant and potentially huge change of velocity", CharacterMovementComponent.cpp:12541). So a launched impulse works only because client and server each apply a matching launch at nearly the same moment — the server's lands ~RTT/2 later, by which time a moving target has moved. It is fine at small magnitudes and gets proportionally worse as speed rises: the melee lunge was untroubled at 600 and is worth watching at 1600. If a launch-based move starts rubber-banding, the fix is a root motion source, not a bigger number. Diagnose with sl.Melee.Debug 2 (draws server red / client green — daylight between the arrows is the divergence).
  • A raw Velocity/MovementMode write from an ability is not predicted. Character Movement only replays what it can reconstruct (its own moves and root motion sources), so an ability that sets velocity directly rubber-bands on a non-host client and the two machines disagree about where you ended up. Use a root motion source (ApplyRootMotionConstantForce and friends) — it replicates and replays. Symptom to watch for: it looks perfect on the listen-server host and wrong everywhere else.

#Animation / AnimBP / Control Rig

  • Every non-looping state-machine state needs "Always Reset on Entry." Non-looping asset players retain their time at t=end; the anim plays once then never again (or freezes the pose). → BUG-001, BUG-022; memory feedback_animbp_state_reset_on_entry.
  • Get Curve Value inline in the AnimGraph / an Anim Layer returns 0. Read it in Thread Safe Update Animation and cache to a bool. → memory feedback_get_curve_value_thread_safe_update; Docs/LeftHandIK.md.
  • Anim Layers are single-use per ABP. For multi-state pose-in/out reuse, the only answer is a custom C++ AnimGraph node — never BP anim layers. → memory project_sidearm_blend_node.
  • Two Bone IK "elbow correct XOR shoulder inside-out" is a Secondary Axis problem, not a pole one. If moving the pole vector flips which is wrong (good elbow ⟺ twisted shoulder), stop chasing it with the pole. RigUnit_TwoBoneIKSimplePerItem shares ONE Secondary Axis across both bones; a per-bone roll mismatch (e.g. lowerarm_l rolled ~39° vs upperarm_l) makes it unsolvable until you flip the secondary sign. Confirm the primary from the ref pose (inverse_transform_direction(bone, childDir)), then try the four ±secondary signs. Left MC arm resolved at Primary (1,0,0) / Secondary (0,-1,0). Severe mismatch ⇒ use Full Body IK (PBIK) instead. → Docs/LeftHandIK.md.
  • Creating Control Rig member variables via the Python bridge HARD-CRASHES the editor (RigVMExternalVariable.cpp assert). CR graph nodes/links/pin-defaults are scriptable; add rig variables by hand, then bind. save_asset after each safe batch. → memory feedback_controlrig_python_membervar_crash.
  • Sequencer bindings must be Spawnables before adding constraints, or save/load crashes (TransformableComponentHandle harvest failure). → memory feedback_sequencer_spawnables; Docs/SequencerAuthoringWorkflow.md.
  • Scaling the FP arm-rig mesh TRANSLATES every socket-attached weapon. SetRelativeScale3D on the arms scales about their pivot (which sits below the camera), so a socket-attached gun moves toward that pivot — shrink the arms ~40% (e.g. ADS counter-scale) and the weapon drops ~80 cm below the camera, out the bottom of frame (looks like a hide bug; it's rendering at your feet). Corollary: scale the weapon mesh alone instead and the un-scaled arms balloon around it ("gripping air"). Keep arms + weapon proportional → don't per-scale for FOV; drive camera FOV only. → Docs/ADS.md § "Viewmodel counter-scale (superseded)".
  • **A child FP mesh inherits the arm rig's scale — which is the equipped main weapon's FirstPersonMeshScale.** So the sidearm's on-screen size silently depended on which primary was held. Divide the main-weapon baseline back out to give the sidearm its own independent FP scale. → Docs/ADS.md; SLPlayerCharacter::ApplyFPWeaponObstructionScale.
  • FP viewmodel swells/detaches when ADS narrows the camera FOV — it's NOT a socket bug. The arms/weapons share the world camera FOV, so zooming (95→65) magnifies the near viewmodel ~1.7× about screen-centre → giant, off-centre, sheared gun that reads as "gripping air." Don't chase the socket or counter-scale it (both were tried, both failed). Fix = UE native first-person rendering: flag each FP mesh FirstPersonPrimitiveType=FirstPerson + camera bEnableFirstPersonFieldOfView/FirstPersonFieldOfView + r.FirstPerson.Enabled=True (read-only cvar → editor restart). Watch for one unflagged FP mesh (the sidearm) still swelling while the rest are fine. → Docs/ADS.md.

#UI / UMG / HUD

  • BindWidgetOptional name mismatch fails SILENTLY. The property is null with no compile warning (unlike BindWidget), so the child's delegate-driven BIEs never fire. Match the widget's instance name to the C++ property name exactly. → memory feedback_bindwidgetoptional_silent_noop.
  • Anything that recreates a widget makes cached references stale. SwapReticle destroys+recreates the reticle every swap; a ref cached at init goes stale → per-tick drivers (spread, red-target) silently drive a dead widget. Read the live widget each tick (Get Reticle). → BUG-027; Docs/SidearmMode.md §13.
  • A validated GET with an unwired Is Not Valid pin is a SILENT, PERMANENT failure. It writes no log, so it cannot be grepped for or found in a crash. Combined with a per-pawn reference cached in a widget — which every respawn invalidates by construction — it produces a system that works perfectly until someone dies and then never works again. WBP_SL_HUDWidget's Event Tick was one chain of these; a respawn killed the whole chain including the reticle colour, and it cost four sessions across BUG-026/027/028. Always wire Is Not Valid, even if only to a print. Better: keep the state on a component that lives on the pawn, so a respawn brings a fresh correct copy and there is nothing to go stale. → BUG-028 RESOLUTION.
  • Deleting a BP variable silently retargets its consumers to self. Especially dangerous for Set Visibility / Set Is Enabled — they then act on the whole widget (e.g. the reticle vanishes). Check every node a variable feeds before deleting it. → BUG-027.
  • HUD init timing: PossessedBy fires before ASLPlayerHUD::BeginPlay on first spawn. Re-run local HUD init on the already-possessed pawn after widget construction. → memory feedback_hud_init_timing.
  • Bind weapon HUD delegates from C++ in InitializeLocalPlayerHUD, never from a GAS abilityFinalizeEquip broadcasts synchronously and races the async Wait-Tag-Query resumption. → memory feedback_weapon_hud_binding.
  • HUD child widgets that need the pawn must NOT auto-bind in NativeConstruct. Expose via BindWidget, add an Initialize<Child>(Character) helper, call it from ASLPlayerCharacter::InitializeLocalPlayerHUD. → memory feedback_hud_widget_init_timing.
  • In AHUD subclasses use Get Owning Player Controller, not Get Player Controller (Index N) — each split-screen player has its own HUD.
  • Reticle tick widgets need a Canvas Panel root (Overlay only supports alignment/padding, not free X/Y). Overlay children in the primary layout must be HAlign/VAlign Fill or they're zero-size at top-left.
  • Don't use bShouldSelectUponReceivingFocus for focus highlighting — selection is STICKY. It selects on focus-in but CommonUI never deselects on focus-out (selection persists by design, for toggles/tabs), so every button you navigate past stays lit. Deselecting via NativeOnRemovedFromFocusPath/NativeOnFocusLost does NOT work — CommonButton drives selection off its internal Slate button's focus, not the widget focus path. Fix: don't use Selection at all — style the Hovered brush; CommonUI shows it on focus non-stickily. Reserve real Selection + UCommonButtonGroupBase for genuine radio/tab state. → Docs/UISystem.md §6.31.
  • An activatable screen with no desired-focus-target is un-navigable on a controller — and the AutoFocusWidget CDO picker won't hold the value. NativeGetDesiredFocusTarget() returns null → no widget gets focus on open → D-pad/stick nav has no starting point (screen displays fine, so it reads as a nav bug, not a focus bug). A UWidget* CDO ref into the regenerated WidgetTree can't re-bind, so the old AutoFocusWidget Class-Defaults picker silently reverted to blank on compile (and tended to crash the UMG editor) — it's now un-exposed from BP for that reason. Fix: override Get Desired Focus Target in the widget Graph returning the first button. USLScreenWidget::ValidateCompiledWidgetTree fails the compile if neither that override nor a C++-set AutoFocusWidget exists. → Docs/UISystem.md §6.29; USLCommonActivatableWidget.
  • A UCommonActionWidget glyph in the UMG designer proves NOTHING about runtime. GetIcon() has an editor-only branch that renders DesignTimeKey and bypasses the whole runtime pipeline; the runtime path then Collapseds itself on any missing link (widget not named InputActionWidget, no action pushed, IMC not applied, no matching CD_SL_* brush) with no warning or log. Never validate prompt icons in the designer — only in PIE. → Docs/UISystem.md §6.32.
  • CommonUI glyphs need the UI mapping context APPLIED — QueryKeysMappedToAction only sees live contexts. IMC_SL_UI sat orphaned (zero referencers) and never applied, so every glyph collapsed. But don't "fix" it by applying it permanently: UI actions default to bConsumeInput=True and share keys with gameplay (Confirm/A vs Jump/A, NextTab/E vs Interact/E), so a persistent higher-priority UI context silently eats jump/interact/grenade with nothing in the log. Apply it only while a menu owns input — wired into Push/PopGameplayInputSuppression. → Docs/UISystem.md §4.2, §6.33.
  • Bound action bar: handling back and DISPLAYING back are separate opt-ins. bIsBackHandler=true makes B/Esc work; bIsBackActionDisplayedInActionBar (defaults false) is what offers it to the bar — so back works but never shows, reading as a broken bar. USLScreenWidget now sets both. Two more: the entry widget needs InputActionWidget AND Text_ActionName (all of UCommonBoundActionButton::UpdateInputActionWidget is wrapped in if (InputActionWidget), and the SetText is inside it → no glyph widget = blank button, text included); and a blank label with a good glyph means no display name (OverrideDisplayName → else InputAction->ActionDescription). The bar does NOT need to be in the same widget tree — it reads the local player's action router. → Docs/UISystem.md §5.3, §6.35.
  • On a CommonButton, "On Focus Lost" NEVER FIRES — the partner of "On Focused" is "On Unfocused". Two classes expose similar-looking focus events: On Focused/On Unfocused are UCommonButtonBase (bound to the internal Slate button's focus delegates — these work), while On Focus Received/On Focus Lost are UUserWidget and never fire, because NativeOnFocusReceived forwards user focus to the inner SCommonButton so the widget never holds focus. Pairing On Focused (CommonButton) with On Focus Lost (UUserWidget) = focus visual sets but never clears → every button stays lit, looking exactly like sticky selection (§6.31) when selection is off. Tell them apart: the UUserWidget node has an In Focus Event pin. Focus-path events (NativeOnAddedToFocusPath) DO fire — the path includes ancestors. → Docs/UISystem.md §6.34.
  • Focus ≠ hover in CommonUI. The default-click-action glyph fallback is gated on IsHovered() (real mouse hover); UCommonButtonBase has no focus-path handlers, so a gamepad-focused button never shows a glyph. Don't fix it with TriggeringEnhancedInputAction on every button — that registers competing A bindings resolved by registration order + reachability, not focus, so A always clicks the first button. Drive the action widget from focus on USLButtonBase instead. → Docs/UISystem.md §6.33.
  • Closing a CommonUI menu doesn't hand input back to gameplay if the game layer isn't itself an activatable widget. CommonUI applies a Menu input config while a screen is active but only reverts to another activatable widget's config — with a plain (non-CommonUI) HUD/game layer beneath, the menu closes yet look/keyboard stay dead (clicking the viewport doesn't help). Explicitly restore FInputModeGameOnly + SetAllUserFocusToGameViewport() when the last menu closes (hooked to the input-suppression 1→0 pop so every close path — Resume click, B/Circle, Esc — covers it). → ASLPlayerController::RestoreGameInputMode.
  • No mouse cursor in a CommonUI menu opened from a gamepad — and moving the mouse won't bring it back. The OS cursor is tied to the active input device, not bShowMouseCursor: CommonUI hides it whenever the device is Gamepad (pads navigate by focus) and only reveals it once the device flips to MouseAndKeyboard — which can fail to happen when you entered from a pad. Confirmed by reading the live PIE PC (bShowMouseCursor=False); force-setting it True restored the cursor + clicks, proving hit-testing was fine. Fix: for Menu-mode screens, drive SetShowMouseCursor off the input method — show on MouseAndKeyboard / hide on Gamepad via OnInputMethodChanged (seed with the current type on activate), plus reveal on NativeOnMouseMove as a fallback for when the device hasn't flipped yet; the close path re-hides it. Adds a virtual → needs a full rebuild, not Live Coding. Note: only in-editor PIE (not Standalone, a separate process) is inspectable via the bridge — is_in_play_in_editor() must be True. → USLCommonActivatableWidget::SetMenuCursorShown; Docs/SettingsMenuBuildout.md §9.
  • "Unbind all Events from X" unbinds EVERYONE, not just this widget. It compiles to EX_ClearMulticastDelegateClearDelegate() (ScriptCore.cpp), which wipes the delegate's whole invocation list. Harmless while you're the only listener, which is exactly why it survives review and detonates later — on a subsystem delegate (shared, outlives every widget) one screen closing silently kills every other screen's binding. Use Unbind Event from X and feed its red Event pin from the same custom-event delegate pin the Bind node uses, so the pair can't drift.
  • A local-player subsystem lookup returns None during WORLD TEARDOWN — guard any close-path node that needs one. Symptom: "Accessed None trying to read property CallFunc_GetLocalPlayerSubsystem_ReturnValue" pointing at a node on Event Destruct or On Deactivated. The trigger is stopping PIE while the screen is open (or any world teardown): the local player is destroyed first, then widgets deactivate/destruct, so Get <X> Subsystem has no local player to resolve from. Closing the screen normally is fine — the player is still alive, so don't conclude your close path is broken. Seen twice: an unbind on Destruct, and SaveProfile on Deactivated. Fix: Get … Subsystem → IsValid → Branch before the call. Data isn't lost in the teardown case if the subsystem flushes dirty state in Deinitialize (as USLPlayerProfileSubsystem does) — that backstop exists precisely because the explicit save can be skipped here.
  • A rotator row broadcasts THREE delegates — bind the wrong one and settings save as their clamp minimum. USLRotatorRow::BroadcastValue fires OnValueChanged(int32 Index), OnBoolChanged(Index != 0) and OnFloatChanged(NumericValues[Index]). OnValueChanged carries the index, not the value — feed it to a clamped setter and every notch collapses to the floor (SetFieldOfView(0..6) → clamped to 70; ADS → 0.1; sensitivity → 0.05). It reads as a persistence bug — "my setting won't save" — when the write is landing perfectly, just with garbage. Numeric rows bind OnFloatChanged, Off/On rows bind OnBoolChanged. OnFloatChanged only fires once NumericValues exists, so the row must call SetNumericRange before it seeds (SetSelectedByFloat silently early-returns on an empty NumericValues, leaving the row at index 0 — which is what "the seed does nothing" looks like). → BUG: 2026-07-26 settings menu.
  • A bound-event's function name is baked at creation and lies after a rename or reparent. BndEvt__WBP_SL_Settings_Row_SensX_..._SLOnFloatSettingChanged__DelegateSignature survived both the widget being renamed to Row_Sensitivity and the row class changing from USLSliderRow to USLRotatorRow — so the name advertised a delegate the class no longer had. Don't identify a live binding from that string (it cost a wrong diagnosis). Check the package name table for the delegate property (OnValueChanged vs OnFloatChanged), or just delete the node and re-bind from the Details panel, which regenerates the name.
  • You rarely need to unbind a BP delegate at all. Bindings hold weak refs (ProcessMulticastDelegate skips unbound entries) and stale ones are pruned by CompactInvocationList() inside AddUnique (ScriptDelegates.h) — the next bind sweeps the last widget's corpse. So an unbind on a teardown path is pure risk for no benefit. Binding a delegate to yourself (a row binding its own OnValueChanged) needs no cleanup either — same lifetime.

#Controller / Force Feedback

  • ClientPlayForceFeedback produces NO rumble in PIE — test force feedback in a separate process. Confirmed 2026-08-11 with instrumented calls: five k2_client_play_force_feedback calls landed on the correct local BP_SL_PlayerController_C_0 with bForceFeedbackEnabled=True and produced nothing, and weapon fire was equally silent. The same weapons rumbled correctly the moment the session was launched as Standalone Game. A UForceFeedbackComponent placed in the level does work in PIE, so the failure is specific to PlayerController-routed effects, not to force feedback as a whole — which is what makes it so misleading. Half a session went into asset curves, Duration, data-asset assignments and save-game defaults, all of which were fine.
  • "Play as Listen Server" is STILL PIE while RunUnderOneProcess=True — which is the default, and it makes the rumble failure look like a server-authority bug. It is not: AActor::GetFunctionCallspace (Actor.cpp:5576) explicitly returns Local for a client RPC on a listen-server host's own controller — GetNetConnection() is null, GetNetOwningPlayer() is a ULocalPlayer, and the branch is commented "This is a local player, call locally." To test anything force-feedback related in multiplayer, uncheck Editor Preferences → Level Editor → Play → Multiplayer Options → Run Under One Process so each player is a real game process. The setting lives in Saved/Config/WindowsEditor/EditorPerProjectUserSettings.ini.
  • A Python exception inside a Slate post-tick callback is SWALLOWED. A diagnostic that logs after the call it is testing will silently record nothing and look like a negative result. k2_play_dynamic_force_feedback was throwing on its arguments for several rounds and read as "the player felt nothing", which is a different conclusion entirely. Log before AND after the call, and count invocations, so "it did not fire" is distinguishable from "it fired and did nothing".

#Input / Enhanced Input

  • bEnableLegacyInputScales=True hides a NEGATIVE pitch scale. Config/DefaultInput.ini has it on, so APlayerController still applies the deprecated InputPitchScale = -2.5 and InputYawScale = +2.5. Vertical look therefore depends on a sign nobody can see from the input assets, and it cancels an IMC Negate(Y) — which is how "Invert Look Y is backwards" happened (2026-07-27): two negations made the baseline inverted, and the toggle was faithfully inverting an already-wrong start. If you ever turn that flag off (Epic recommends off for Enhanced Input) everyone's vertical look flips and all look sensitivity changes by 2.5× — and it will look like a Settings-menu bug. Count your negations end to end: IMC modifier → Look() sign → legacy scale.
  • Look modifiers live on the IMC MAPPING, not on the Input Action. IA_SL_Look has an empty asset-level modifier list; the Negate / DeadZone sit per-key inside IMC_Default. Also, in 5.7 the IMC's top-level Mappings array is deprecated and reads back empty — the live store is default_key_mappings.mappings. Inspecting the wrong one says "no mappings at all" and sends you hunting a phantom.
  • Never put a DeadZone on a mouse axis. A stick reports deflection (rests near zero, needs a floor); a mouse reports deltas. With the default 0.2 lower threshold, small movements are zeroed and the remainder rescaled, so slow, fine aim goes unresponsive and slightly jumpy — and it presents weeks later as "the mouse feels bad" with no obvious cause. Keep DeadZone on Gamepad_Right2D only. Watch the editing trap that caused ours: changing a modifier's type in the dropdown replaces the entry rather than removing it, so "delete the Negate" can quietly leave a DeadZone in its place. Read the modifier list back after editing.

#Automation Tests (C++)

  • A stray ensure fails the test even when every assertion passed. Automation treats any logged Error as a failure, so an engine ensure inside your fixture reads as a broken test and sends you hunting a phantom assertion. Ours came from NewObject and ClassWithin: ULocalPlayerSubsystem is ClassWithin=ULocalPlayer and ULocalPlayer is ClassWithin=UEngine, so the fixture must build the whole chain — NewObject<ULocalPlayer>(GEngine) then NewObject<USLPlayerProfileSubsystem>(OwnerStub). Ensures fire once per call site, so only the alphabetically-first test shows the Error and the rest log it as a Warning — making it look like one specific test is broken.
  • A subsystem is testable headlessly when its init path doesn't need a world. USLPlayerProfileSubsystem works standalone because LoadProfile does everything Initialize does and never touches the local player — so the settings logic runs with no editor world, no PIE, no possessed pawn, in ~1s. Check for that property before reaching for a Functional Test; it's the difference between a one-second test and a one-minute one.
  • Tests that write save games must use their own slots and clean up. They hit the real Saved/SaveGames. Use dedicated profile names, delete the slots at start and end (a crashed run leaves them behind and the next run starts dirty), and never touch the names a player would own. Corollary: the legacy-slot adoption path is currently untested because exercising it would clobber the player's real Default profile.
  • fill_data_table_from_csv_string / _json_string REPLACE the table — they do not append. The name says "fill"; the behaviour is "overwrite everything". Adding one row by refilling with a hand-written CSV silently destroyed five rows of authored copy (2026-07-27). Append pattern: export_data_table_to_json_string → parse → append the new row → fill_data_table_from_json_string. Use JSON, not CSV: CSV flattens the \r\n inside multi-paragraph FText, so even a faithful-looking round-trip loses line breaks.
  • **Automation TestEqual has no UScriptStruct* overload, and TestNotNull won't take a TObjectPtr.** Compare structs with TestTrue(A == B) and unwrap object pointers with .Get(). Both fail as template-resolution errors that don't name the real problem.
  • Run them from PowerShell, not the Bash tool — through Bash the command silently produced no run at all (no log, no output, exit 0). Working invocation:
  • UnrealEditor-Cmd.exe <project> -ExecCmds="Automation RunTests SystemLink.Settings.PlayerProfile" -unattended -nopause -nosplash -nullrhi -testexit="Automation Test Queue Empty", then read results out of Saved/Logs/SystemLink.log (Test Completed. Result=).

  • time.sleep() in editor Python BLOCKS THE GAME THREAD — the viewport does not render during it. Any "do a thing, wait, do it again" loop written with sleep freezes PIE between steps, so a visual test proves nothing: the frames you were waiting to see were never drawn. Cost a false negative while diagnosing BUG-028. For anything that must be observed over time, use unreal.register_slate_post_tick_callback(fn) and unregister after N frames — that runs per frame with the game live. Sampling state repeatedly (not observing visuals) is fine with sleep, but remember you're sampling a frozen game.
  • A slate post-tick callback that throws NEVER STOPS. Put the unregister/exit check first, before any lookup that can fail, and wrap the body in try/except. A poke loop written the other way round kept running after PIE closed — the destroyed pawn made get_hud_widget() throw every frame, so the "stop after N frames" line below it was never reached, and it logged an error per frame for 8,736 frames until manually killed. Recovery: the exec namespace is shared between bridge calls, so a handle stashed in a variable is still reachable later — unreal.unregister_slate_post_tick_callback(handle). Stash the handle somewhere findable before registering, or you have no way to stop it.
  • In multiplayer PIE, get_game_world() returns the SERVER world only. Inspecting a client means reaching its world explicitly: unreal.find_object(None, "/Game/<Map path>/UEDPIE_1_<MapName>.<MapName>") (index 0 = server, 1+ = clients). Symptom of getting this wrong: every pawn reports authority=True and you conclude the client is fine. Confirm with has_authority() / is_locally_controlled() before trusting anything you read.

#Editor Automation (Unreal MCP bridge)

  • A Blueprint gameplay cue MUST be named so its own name derives the intended tag — strip GC_, turn _ into ., prepend GameplayCue.. GC_Mobility_Dash gives GameplayCue.Mobility.Dash; the project's usual GC_SL_ prefix gives GameplayCue.SL.Mobility.Dash, which is not a registered tag and silently kills registration. The symptom is maddening: GameplayCueTag reads correctly on the CDO and in the details panel, while GameplayCueName — the registry mirror the manager actually registers from — is None, so the cue never fires from a cold start. Cause is AbilitySystemGlobals.h:129: when a child's tag equals its parent's, the engine clears the child's tag, re-derives from the ASSET NAME, and on failure restores the tag but not GameplayCueName. Cues parented to engine classes never hit this (their parent CDO tag is invalid); cues parented to our own GC_* native classes always do. → Docs/MobilityCues.md.
  • An in-editor tag edit registers a cue for that session only. Setting GameplayCueTag in Class Defaults runs PostEditChangePropertyHandleAssetAdded, which registers it live — so a broken asset appears to work until the next editor restart. Never accept "it fired once" as proof a cue is registered; restart and fire it again. This masked a genuinely broken asset for a day.
  • Writing IMC key mappings via the bridge can silently land nowhere in UE 5.7. Mappings now live in default_key_mappings.mappings (an InputMappingContextMappingData struct); the old top-level mappings array is deprecated and reads back 0. A write to the wrong one "succeeds" but the key is unbound at runtime — e.g. the pause action never fired because IA_SL_UI_Pause was never really in IMC_Default. Always read the mapping back (default_key_mappings.mappings) after writing. Construct keys with unreal.Key(); k.import_text('Gamepad_Special_Right') (the Key(...) ctor takes no args; there's no key_name attr). → 2026-07-09 pause-menu debug; CurrentFocus.md.
  • WidgetBlueprint.WidgetTree is protected from Python — you cannot build or read widget trees, nor set widget-referencing props (AutoFocusWidget) via the bridge. Simple CDO scalars/bools on a WBP (e.g. bPauseGameWhileActive) are settable + save_asset-able; anything that references a widget instance is a manual editor step. → Docs/CodexHandoff_WidgetTrees.md.

#C++ / UObject / Build

  • Never name a UObject member function like a base virtual (Initialize, Tick, BeginPlay…). Silent shadowing (C4263/C4264) breaks engine init / hides the base virtual. → memory feedback_uobject_function_name_clashes. (Live example: USLGrenadeIndicator::Initialize → renamed InitializeIndicator.)
  • Don't name locals Slot in a UUserWidget method (C4458 — Slot is a UUserWidget member) or Character in an AController method (member on AController). Use descriptive names.
  • Smoothing a value toward a target with a manual signed step oscillates at the target. Dir = (Target > Cur) ? +1 : -1; Cur += Dirdt/Duration flips to −1 the instant Cur reaches Target, so it jitters ±step every frame. Use FMath::FInterpConstantTo (constant rate, stops AT target) or FInterpTo (eased). Downstream symptom can be non-obvious: an oscillating camera FOV* made the close-up FP weapon appear to vibrate/detach while the far world looked stable. → Docs/ADS.md.
  • Incomplete type + TWeakObjectPtr<T> assignment fails to compile (C2679) — the weak-ptr assign needs the UObject base conversion, i.e. the complete type. #include the header, forward declaration isn't enough. → grenade pickup ISLDamageable work; #include "AbilitySystemComponent.h".
  • **TObjectPtr for stored UPROPERTY members; raw T* for returns/params/locals.** Forward-declare in the header; the .cpp needs the #include to call methods / use IsValid. → memory feedback_cpp_pointer_conventions.
  • New reflected C++ (classes, UPROPERTY, UFUNCTION, delegates) needs a full rebuild + editor restart — Live Coding won't surface them to Blueprint, and worse, it can hard-crash the editor. Live Coding patches code, not object layout or reflection data: existing instances keep the old layout while patched code reads the new one. Seen 2026-07-29 — adding UPROPERTYs to ASLCharacterBase and changing GetLifetimeReplicatedProps, then Live Coding, produced a clean segfault right after Patch creation ... successful. Function-body edits are fine to Live Code; anything touching layout, reflection or replication is close-editor → full build → relaunch. (Not the CPU-instability signature in BugTracker.md's preamble — that one is a GPU device-hung with a generic breadcrumb.)
  • AActor::FellOutOfWorld fires EVERY TICK while below Kill Z, not once, and its default implementation is Destroy(). If anything else owns the actor's lifetime — e.g. a respawn scheduled on a weak lambda — calling Super silently cancels it. Symptom: the player dies and simply never respawns, with nothing logged. Latch the handler and don't call Super unless destruction is genuinely what you want. → BUG-029 follow-up.
  • Startup crash with no symbols after C++ renames? Delete Binaries/ + Intermediate/ at project root AND in plugins, regenerate, rebuild. Stuck-build-state hammer, not a general step. → memory feedback_clean_binaries_intermediate.
  • Reparenting a Blueprint mid-session crashes the editor ON THE SPOTFatal World Leaks, with a REINST_<YourBP>_C_n in the TransBuffer holding the old World alive. It is not "restart afterwards to be safe": the editor dies during the reparent, before you get the chance. Reproduced 2026-08-06 reparenting WBP_DashProgress to USLDashIndicator. Your work is normally safe — the asset is written before the GC check (verified: saved 8 s before the crash, with the new parent intact) — so relaunch and confirm the parent rather than redoing it. Undo history is gone. Prefer setting the parent on a freshly created Blueprint, or accept the crash and relaunch. → memory feedback_blueprint_reparent_crash.
  • **Never call component Set* mutators from a COMPONENT's own constructor — assign the members directly. UBoxComponent::SetBoxExtent calls UShapeComponent::UpdateBodySetup, which does NewObject<UBodySetup>(this, NAME_None, ...), and creating a subobject with an empty name inside a constructor is fatal**: NewObject with empty name can't be used to create default subobjects. The same path is reachable from SetCollisionEnabled / SetCollisionResponseTo (via EnsurePhysicsStateCreated) and from bVisualizeComponent = true (billboard subobject). The failure mode is the worst kind: it fires during CDO construction at EDITOR STARTUP, so the editor dies before any window opens and the project cannot be loaded at all until the code is fixed — there is no in-editor way out. Write BoxExtent = ..., bHiddenInGame = true, and use BodyInstance.SetCollisionEnabled(Type, /bUpdatePhysicsFilterData=/false) / BodyInstance.SetResponseToChannel(...), which are the constructor-safe equivalents. The engine's own UBoxComponent constructor assigns BoxExtent directly for exactly this reason. Note this only bites in the component's constructor — the same Set calls are fine from an actor's constructor after CreateDefaultSubobject, which is why they look safe in most engine code. Reproduced 2026-08-07 writing USLShootableComponent.
  • Read the callstack in Saved/Crashes/<...>/CrashContext.runtime-xml, not just the fatal line in the log. SystemLink.log often ends at appError called: Fatal error: ... with no stack at all, which makes a startup crash look unattributable and invites guessing. The <CallStack> node in the crash context names the actual frame — for the constructor crash above it said USLShootableComponent::USLShootableComponent outright, and a first guess made without it cost an extra build-and-restart cycle.
  • When renaming a UPROPERTY, add a CoreRedirects entry to Config/DefaultSystemLinkCore.ini or data is lost.

#Unity Builds

  • A duplicated anonymous-namespace helper compiles fine until you COMMIT the file. UBT's adaptive non-unity build decides what to exclude from the unity blob using git status — so a modified/untracked .cpp compiles as its own translation unit and a private helper in it cannot clash with anything. Commit it and it rejoins the blob, and two files with the same anonymous-namespace function are suddenly one TU: error C2084: 'X' already has a body, followed by a cascade of unrelated-looking format-string errors from every log macro that used it. Hit 2026-08-11 with NetRoleLabel copied from SLDoor.cpp into SLTeleporter.cpp — clean builds all session, then a wall of errors on the next build after the commit, in a file that had not been touched. Do not copy a file-local helper between .cpp files; share it (this one now lives in Debug/SLDebug.h as SLNetRoleLabel). Note static does not save you — in one TU it is still a duplicate definition.

#Audio

  • A GameplayCueNotify_Burst sound with DoNotAttach plays at CueParameters.Location — if the caller never sets it, it plays at world origin (0,0,0), which on a small map reads as "heard everywhere" even with correct attenuation. Set the cue Location and/or use AttachToTarget. → 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-025.
  • Play Sound 2D in a Local-Predicted ability → the listen-host hears every player's sound. Gate thrower/owner-only feedback behind Is Locally Controlled. → Docs/AudioAudit.md.

#Editor / Tooling / Bridge

  • **The systemlink-unreal bridge can script CDO tags, data-asset fields, ability-set grants, and Control Rig graph nodes — but NOT K2/anim-graph internals (graph node wiring isn't exposed to Python) and NOT Control Rig member-variable creation** (crashes). → memory project_unreal_mcp_bridge, feedback_controlrig_python_membervar_crash.
  • InputMappingContext.mappings is DEPRECATED in UE 5.7 — reads 0. Real mappings live in default_key_mappings.mappings (InputMappingContextMappingData). imc.map_key(action, key) writes to the new struct correctly (use it), but any read/dedupe/count must go through default_key_mappings.mappings, not the top-level mappings (which silently returns empty). Also unreal.Key() takes no ctor args — build via k = unreal.Key(); k.set_editor_property("key_name", "SpaceBar").
  • Editing an asset via the bridge while it's OPEN in the editor → stale graph view. The open editor panel doesn't repaint when Python mutates the model, so it shows leftover state (e.g. a variable dropdown left on a pin after you rewired it). The on-disk/model state is correct; the view is stale. Verify topology by reading get_links() / pin.get_linked_source_pins(), not the screenshot; then close (Don't Save) + reopen the asset to refresh. Prefer editing assets that are closed. (2026-07-02, Control Rig Effector pin.)
  • unreal.WidgetTree isn't exported to Python — you can't walk a widget tree via the bridge. Probe exact child names with find_object(WidgetTree, "<name>") instead (control-test with a known-bound child). → Initialize/BindWidget debugging session.
  • Never call Slate-touching widget methods on a bare new_object(WidgetClass) bridge instance — it ACCESS-VIOLATION crashes the editor. A widget made via new_object has no constructed Slate; methods that reach into it deref null. Cost a crash 2026-07-23 calling USLRotatorRow::SetNumericRange (→ base UCommonRotator::PopulateTextLabels → null Slate rotator) on a throwaway instance just to "verify the function exists". Verify BP-exposed functions by reading the compiled BP/graph or a real constructed instance, NOT by invoking Slate methods on a bare CDO/new_object. The same call from the widget's Construct graph is safe (Slate exists by then).
  • A UMG designer property edit can poison the widget tree silently — the NEXT compile is what hard-kills the editor. Working in a duplicated CommonUI screen (WBP_SL_PauseWBP_SL_Page), the editor vanished with no LogExit and no fatal entry in SystemLink.log. Timeline: autosave → 1 min 52 s of total log silence → Compile → 75 ms later the Slate ensure Array has changed during ranged-for iteration! → ~1.5 s later the process is gone. The three preceding compiles of the same asset succeeded, so the compile isn't inherently fatal — something entered during the silent window. Details-panel property edits are NOT logged at all, so whatever was changed there is invisible in the log: the compile is the trigger, a malformed property/layout state is the likely setup. Prime suspect = copy/pasting slot/layout data (Anchors/Offsets/Alignment/Padding) between widgets under different parent panel types — slot data is a different UObject class per panel (UCanvasPanelSlot vs UHorizontalBoxSlot vs UOverlaySlot), unlike Render Transform which is a plain UWidget struct and type-compatible anywhere. Rules: prefer typing layout values over pasting them across dissimilar parents; compile from the Content Browser with the widget editor closed; after duplicating a CommonUI screen, restart the editor before heavy editing. Related ensure seen on the duplicate's first compile: Attempting to enqueue CD_SL_Xbox for compile while compiling: WBP_SL_Page_C (CommonUI controller data recursively enqueued mid-compile). Diagnostic worth reusing: when the editor disappears leaving nothing in the UE log, check Windows Error Reporting (Get-WinEvent -LogName Application) — here UnrealEditor-Slate.dll, 0xc0000409 P9=2 (FAST_FAIL_STACK_COOKIE_CHECK_FAILURE, BEX64) with a completely clean System log (no nvlddmkm/WHEA/TDR/Kernel-Power) ⇒ genuine Slate memory corruption, not the GPU/CPU-instability family below. The crashed session's log is preserved in Saved/Crashes/UECC-*/SystemLink.log (the live one rotates on relaunch). (2026-07-25.)
  • Banned MSVC toolchain (14.40–14.43): if a build flags it, install 14.44.35207.
  • git push to the self-hosted Git remote fails the first attempt with a local credential helper error — run it a second time.
  • Dev-rig GPU "device hung" crashes (no page fault, generic breadcrumb) are Raptor Lake CPU instability, not game/shader bugs. Don't hunt the codebase. (But verify the stack — a genuine RigVM/engine assert, like the CR member-var crash, is a real bug, not this.) → memory project_dev_machine_gpu_crashes.
    • Known precipitant: launching Maya while the editor has been up for hours. 2026-08-02, editor resident ~18 h, opening Maya to review a mesh → "GPU Crashed or D3D Device Removed", Aftermath dump, nvlddmkm event 153 ×3 in the Windows System log. Maya itself survived; the editor died. Close the editor before opening Maya — this rig does not reliably tolerate two GPU-heavy apps at once.
    • The two-minute triage that separates the families: IsAssert + CrashType in the crash context, and whether the Windows System log has nvlddmkm/WHEA entries at the crash time. IsAssert=false + CrashType=GPUCrash + nvlddmkm present ⇒ hardware, stop looking. An assert with a clean System log ⇒ a real engine bug (as the 2026-07-25 Slate corruption was).

#Content / Assets

- .uasset history is behind Git LFS — git show / git cat-file hand you a 129-byte POINTER, not the asset. Inspecting a committed asset (to recover clobbered data, or to diff what changed) needs `git cat-file -p HEAD:pathgit lfs smudge > out.uasset. Without the smudge the file looks empty of content and it's easy to conclude the history doesn't have what you need — when it does. Recovering a clobbered asset: git checkout HEAD -- <path>` restores it byte-exact, but the editor must be closed first, or its in-memory copy gets re-saved over the restore.
  • A UObject created for an asset must be created WITH that asset as its outer. unreal.SomeClass() from Python builds it in /Engine/Transient; assigning that into an asset's property "works" in memory and then serialises as null on save, with no warning. Use unreal.new_object(Class, outer=OwningAsset). Seen 2026-07-27 adding an input modifier to IMC_Default — the mapping saved with a null modifier that did nothing at runtime. Always read back after a scripted asset edit and check for None in the array, which is now covered by SystemLink.Content.InputContextIntegrity.
  • The reparent World-Leak crash can arrive LATER, at the next PIE stop — not during the reparent. Reparenting WB_Teleport (UserWidget → USLScreenEffectWidget) compiled cleanly and the editor kept running; PIE started 10 s later and died on teardown with ====Fatal World Leaks====. The holder was the editor's undo bufferUUnrealEdEngine::Trans → TransBuffer → WB_Teleport_C_0 — still referencing the pre-reparent widget instance, which kept the PIE world alive past BeginTearingDown. Because the reparent looked successful, the crash reads as a bug in whatever code spawns that widget. Restart the editor after ANY reparent, before pressing PIE, and treat a World Leak naming a recently-reparented class as this, not as a leak in your own code. The asset does save first, so never redo the reparent — relaunch and verify. (2026-08-10.)
  • A migrated Blueprint's class/interface pins can come across EMPTY, and an empty pin is a branch that is always false. Both 5.6 interactables shipped with a Does Object Implement Interface node whose Interface was left unset — nothing logs, nothing warns, the node just returns false forever. In the teleporter it gated the entire cosmetic Sequence (sound, ring hide, HUD call) while the teleport itself, upstream of the branch, kept working — so it read as "works, effects not hooked up yet" for months. After any migration, walk every class/interface/soft-reference pin in the graph before trusting behaviour. Prefer a compile-checked TSubclassOf filter over an interface asset that can be lost. → Docs/Doors.md §1, Docs/Teleporters.md §1.
  • The 5.6 migration left DUPLICATE assets with near-identical names, only one of them wired to current systems. Level 1 has the legacy BP_OverShieldPickup_C placed rather than the GAS BP_SL_OvershieldPickup; there are two WB_Teleport widgets (Environment/Teleporter/ and UI/HUD/Teleporter/). Before editing or assigning any migrated asset, search the whole Content/ tree for the name — editing the wrong copy produces changes that never appear in game. → Docs/CurrentFocus.md, Docs/Teleporters.md §5.
  • Asset SL prefix only when the asset inherits an SL C++ base class (DA_SL_Pistol, BP_GA_SL_...). Raw assets (skeletal meshes, plain animations, plain ABPs) get no SL prefix.
  • AssualtAssault typo persists in ~14 .uasset names — rename in the Content Browser (not on disk) so references auto-update. → Docs/CurrentFocus.md Deferred Cleanup.
  • Spawn System Attached does NOT warn on an unknown socket name — it silently attaches at the component origin, so the effect plays from the actor's root instead of where you aimed it. Cost a session on the dash thrusters via ThursterLeftSocket (the real ones are ThrusterLeftSocket / ThrusterRightSocket). Verify the name exists before debugging the effect itself.
  • A marketplace Niagara system's exposed User Parameters may be WIRED TO NOTHING. In NS_RocketExhaust_Afterburn only User.Emissive_Boost and the eight User.Particulate_ are actually consumed; every User.Thrusters_, User.Smoke_, User.EnergyCore_ and User.HeatHaze_* shows in the User Parameters panel and is read by no module — those emitters use baked constants instead. Set Niagara Variable against them is a silent no-op. To tell wired from decorative without the editor, extract ASCII from the .uasset and look for the compiled HLSL uniform (float User_<Name>): present = wired, absent = decoy. Niagara's Python API exposes none of this (unreal.NiagaraSystem has no emitter/module access at all).
  • Check for an existing scale hook before authoring one. The same pack's NMS_GlobalScale / NMS_GlobalVelocity modules read Engine.Owner.Scale in every emitter, so Set Relative Scale 3D on the spawned component already scales sprite size and velocity — and it works regardless of local/world space, because it bypasses the user parameter store. The usual "component scale only affects Local Space emitters" rule does not apply when a module multiplies by owner scale explicitly.

#Asset Deletion / Orphan Sweeps

  • NEVER decide a deletion from a package name-table scan — use the asset registry. Scanning .uasset bytes for /Game/... strings looks reliable and was validated against UE's own registry on one dataset (329 on disk / 127 used / 202 orphaned, exact agreement). It then missed two live references on a different dataset and deleted Sitting_1/Sitting_2 out from under ABP_MasterChiefMenu, which came back as three ERROR! Sequence Players. UE does not always store a package reference as one contiguous string, so sibling assets in the same folder scanned correctly while these two did not. The agreement was a property of that first data, not of the method. AssetRegistry.get_dependencies() / get_referencers() found them instantly. (2026-08-01, commit 7b2b91fa.)
  • String presence is not a live reference either — the error runs both ways. After a rename, a raw scan reported 37 broken references and every one was a false positive: name tables keep stale strings after the reference itself has been repointed. Only unresolvable registry dependencies count. Nearly reverted a clean rename over this.
  • CoreRedirects are invisible to any reference scan. They live in DefaultEngine.ini, not in package data, so redirect targets look unreferenced. An orphan sweep must parse [CoreRedirects] and treat every NewName as a root — otherwise it deletes the assets the redirects point at and breaks both the content and the redirect. → Docs/Level1Import.md §3.
  • Reachability roots are not just the levels. Anything referenced from elsewhere in the project must be a root too, or the sweep eats it — /Game/Generic/Fonts/Microgramma_D_Extended_Bold_Font is reachable from no level but is used by SystemLink/UI/Styles.
  • EditorAssetLibrary.delete_asset in a loop CRASHES the editorEXCEPTION_ACCESS_VIOLATION in Background Worker #0 on the third delete, because it force-deletes and a background worker is still churning the registry from the previous one. One delete per call.
  • Deleting a redirector does not release the editor's file handle. The leftover .uasset cannot be removed from disk until the editor closes (Device or resource busy). Finish renames with the editor shut down.

#Maya / Collision Export

  • MItMeshPolygon.getNormal() HARD-CRASHES Maya on Level1/scenes/main-floor.mb — process gone, "Attempting to save in …[Recovered]", no traceback. The scene carries invalid/unused components that kill the polygon normal iterator; MItMeshEdge walks are in the same family. It is not a bad script — it is the data. Derive normals and areas yourself with Newell's method from MFnMesh.getPoints() + getVertices(), which is stable on the identical geometry. Cost a crashed session 2026-07-31, and cost the earlier attempt its shape fidelity (it fell back to bounding boxes). → Tools/maya_build_main_floor_collision.py.
  • UE binds collision to geometry BY NAME ONLY: UCX_<RenderMeshName>_00, _01, … where RenderMeshName matches the render mesh node inside the FBX, not the asset name. Rename the render mesh later and every hull silently detaches.
  • One FBX holding several render meshes imports only the FIRST mesh's collision. 21 meshes each with correct UCX = 20 pieces silently collisionless. Either combine to a single render mesh or export one FBX per mesh. (UBX_/USP_ also break on non-uniformly scaled meshes — Epic's own advice is to use UCX_ for everything.)
  • UCX must be a CLOSED CONVEX solid. UE does not decompose concave input, it produces "unpredictable results". A non-planar quad face is not strictly convex either — fan it into triangles.
  • Thin collision fails by DEPENETRATION, not tunnelling — and the threshold is half the thickness. Penetrate a slab of thickness T by depth d and the solver exits d up or T − d down, taking the shorter: past T/2 it ejects the player downward, under the floor. Rule: thickness ≥ 2 × (fastest speed ÷ lowest frame rate) → for SystemLink's dash (1625 cm/s, 30 fps = 54 cm/frame) that is ≥ 110 cm. Minimums: 100 cm floors, 50 cm walls, never under 20 cm. Thickness is free (a box is 12 triangles at any depth), so never be stingy; better still, extend floor hulls to one shared base plane. Swept movement ignores thickness entirely — it only bites on teleports, spawns, corrections and skipped sweeps. → Docs/CollisionMeshes.md §0.
  • An axis-aligned grid tested at cell CENTRES drops every cell a diagonal edge only partly covers — that is a hole in the floor a player falls through. Silent: the decomposition looks fine, and only a coverage test over sampled walkable points finds it (6 of 1479 here, all at diagonal edges of the ground plane). Include a cell the surface merely touches: an overhang of one cell at a diagonal edge is invisible, a hole is not.

Living doc — append the one-liner when a new footgun costs you a session, and cross-reference the full RCA in BugTracker.md or the relevant feature doc.