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 ownOnRep, so HUD/state that only updates inOnRepis stale on the host. Broadcast in the setter andOnRep. → BUG-020.
- Don't drive client-visible state off
SetCollisionEnabledflags. Collision-enabled isn't replicated; any per-frame check depending on it (e.g. anECC_Pawncapsule 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.OptionalObjectis unreliable on the owning client. UseGetEquippedWeapon(or equivalent replicated state), notGetWeaponDataFromEvent, in Server-Initiated ability BPs. → memoryfeedback_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 noDisableRagdoll()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.
OnComponentBeginOverlapis 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 anyNetMulticastthe client called was silently dropped (multicasts only replicate from the server). Gate every overlap handler onHasAuthority(), 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
SetControlRotationon a player is overwritten within a frame. The autonomous client owns its control rotation and ships it upstream every move. UseAPlayerController::ClientSetRotationto 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
BeginOverlapsynchronously insideTeleportTo. 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
EndOverlapalone — dying inside the volume strands it.EndOverlapis 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 (aTarget Actorthat was never cleared);ASLDoorsurvives it by also bindingOnDestroyedper occupant. Prefer a timer to a latch when the thing being tracked can die —ASLTeleporterhas 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
NetMulticastis 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 itsOnRep— and guard it onBeginPlayhaving run, or a joining client plays a flash for a teleport that happened before they connected. →Docs/Teleporters.md§2.
SetOwnerNoSeeis per-VIEWER;SetVisibilityis per-MACHINE — never hide both behindIsLocallyControlled(). The TP meshes hide withSetOwnerNoSee, 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 plainSetVisibility, 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 withSetVisibilityneeds a call site that runs on every machine —BeginPlayfor spawn, plusPossessedBy/OnRep_PlayerStateto re-evaluate once possession has replicated andIsLocallyControlled()finally means something. → BUG-030.
#GAS
- Loose gameplay tags are COUNTERS, not booleans.
Addincrements,Removedecrements. 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:InventoryLoadedat 2 and 5 on clients against 1 on the server,ReadyToFinishLoadoutat 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. UseUSL_BlueprintLibrary::SetBooleanStateTag(ASC, Tag, bPresent)(BP node: Set Boolean State Tag) — it assigns viaSetLooseGameplayTagCountinstead of incrementing. Verify withsl.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 →
TakeFromASCno-ops → specs accumulate (extra bullets per respawn). → memoryfeedback_ability_handles_playerstate.
- Only
USLAbilitySetgrants abilities. Never callGiveAbilitydirectly. A separately-carried slot (sidearm) never runs throughGrantWeaponAbilities, 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.
PlayMontageAndWaitreplicates;Montage_Playis local-only. For TP fire anim, use the ability task (GAS multicasts to sim proxies). FP is local cosmetic. No separate multicast RPC needed. → memoryGAS 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
BuildAnimSnapshotsinstead. → BUG-014; memoryfeedback_gas_tag_callback_patterns.
#GAS — gameplay cues
- A native
UGameplayCueNotify_Staticsubclass is NOT registered and will never fire. The cue manager scans asset data inGameplayCueNotifyPathsand reads theGameplayCueNameproperty 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:ExecuteGameplayCuefinds 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_,_→., prependGameplayCue..GC_Mobility_Dash→GameplayCue.Mobility.Dash✅; the project's usualGC_SL_prefix →GameplayCue.SL.Mobility.Dash, which is not a registered tag and silently kills registration. The symptom is maddening:GameplayCueTagreads correctly on the CDO and in the details panel whileGameplayCueName(the registry mirror the manager registers from) isNone, so the cue never fires from a cold start. Cause isAbilitySystemGlobals.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 notGameplayCueName. Cues parented to engine classes never hit this (their parent CDO tag is invalid); cues parented to our ownGC_*native classes always do. Do not apply theSLnaming convention to these assets. →Docs/MobilityCues.md.
- An in-editor tag edit registers a cue for that session only. Setting
GameplayCueTagin Class Defaults runsPostEditChangeProperty→HandleAssetAdded, 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
HandleGameplayCueEVENT, not anOnExecuteoverride. The native router callsK2_HandleGameplayCueand then still runsOnExecute(GameplayCueNotify_Static.cpp:65), so the event adds rather than replaces, returns void (real exec pins), and cannot be accidentally unwired. OverridingOnExecuteinstead replaces the native implementation — the VFX vanishes the moment a sound is added, with no error.
OnExecuteisBlueprintPure, soParent: On Executehas NO exec pins — and a pure node only runs when something pulls its output. Wire it into the Return Node'sReturn Valueor 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
OnExecuteisconst— no member state, no Delay, no Timeline. Use a world timer, orUGameplayCueNotify_BurstLatent.
- A
GameplayCueNotify_Burst/_Staticcue CANNOT stop an effect it started. It hasOnExecuteand nothing else — noOnRemove, noWhileActive— and it is CDO-based, so stashing the spawnedNiagaraComponentin a variable writes to the shared class default and will not survive to a later call anyway. "Spawn it in the cue,Deactivateit when the ability ends" is not possible in this cue type. Either make the effect self-terminating (Niagara Loop BehaviorOnce+ a Loop Duration matching the ability), or move to an instancedGameplayCueNotify_Loopingactor cue driven byAddGameplayCue/RemoveGameplayCueinstead ofExecuteGameplayCue. Note you cannot reparent Burst→Looping (Static is aUObject, Looping is anAActor), 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.ExecuteGameplayCuealready 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 viaPredictionKey.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.
PossessedBydoesTakeFromASC+ 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 beforeOnPawnInitialized, 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.
RegisterGameplayTagEventandWait Gameplay Tag Queryboth 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_DashhasActivationOwnedTags = States.Character.DashingandBlockAbilitiesWithTag = 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*'sOnFinish. Observed on 5.7: the movement completed cleanly (movement mode and velocity back to normal, root motion source gone) butOnFinishnever reached the bound handler, soEndAbilitynever ran. Own the lifetime with a separateUAbilityTask_WaitDelayinstead. Make it slightly longer** than the motion — ending the ability destroys the root motion task, andOnDestroyyanks 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 theFGameplayEventData— GAS forwards trigger data to the server for predicted activations (verified:AbilitySystemComponent_Abilities.cpp:1923sends the whole struct viaServerTryActivateAbilityWithEventData). A yaw inEventMagnitudeis 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.
LaunchCharacteris not replayed on a client correction.FSavedMove_Characternever storesPendingLaunchVelocity; all the engine does is setbForceNoCombineso 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 withsl.Melee.Debug 2(draws server red / client green — daylight between the arrows is the divergence).
- A raw
Velocity/MovementModewrite 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 (ApplyRootMotionConstantForceand 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; memoryfeedback_animbp_state_reset_on_entry.
Get Curve Valueinline in the AnimGraph / an Anim Layer returns 0. Read it in Thread Safe Update Animation and cache to a bool. → memoryfeedback_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_TwoBoneIKSimplePerItemshares ONE Secondary Axis across both bones; a per-bone roll mismatch (e.g.lowerarm_lrolled ~39° vsupperarm_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±secondarysigns. 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.cppassert). CR graph nodes/links/pin-defaults are scriptable; add rig variables by hand, then bind.save_assetafter each safe batch. → memoryfeedback_controlrig_python_membervar_crash.
- Sequencer bindings must be Spawnables before adding constraints, or save/load crashes (
TransformableComponentHandleharvest failure). → memoryfeedback_sequencer_spawnables;Docs/SequencerAuthoringWorkflow.md.
- Scaling the FP arm-rig mesh TRANSLATES every socket-attached weapon.
SetRelativeScale3Don 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+ camerabEnableFirstPersonFieldOfView/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
BindWidgetOptionalname mismatch fails SILENTLY. The property is null with no compile warning (unlikeBindWidget), so the child's delegate-driven BIEs never fire. Match the widget's instance name to the C++ property name exactly. → memoryfeedback_bindwidgetoptional_silent_noop.
- Anything that recreates a widget makes cached references stale.
SwapReticledestroys+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
GETwith an unwiredIs Not Validpin 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 wireIs 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 forSet 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:
PossessedByfires beforeASLPlayerHUD::BeginPlayon first spawn. Re-run local HUD init on the already-possessed pawn after widget construction. → memoryfeedback_hud_init_timing.
- Bind weapon HUD delegates from C++ in
InitializeLocalPlayerHUD, never from a GAS ability —FinalizeEquipbroadcasts synchronously and races the async Wait-Tag-Query resumption. → memoryfeedback_weapon_hud_binding.
- HUD child widgets that need the pawn must NOT auto-bind in
NativeConstruct. Expose viaBindWidget, add anInitialize<Child>(Character)helper, call it fromASLPlayerCharacter::InitializeLocalPlayerHUD. → memoryfeedback_hud_widget_init_timing.
- In AHUD subclasses use
Get Owning Player Controller, notGet 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
bShouldSelectUponReceivingFocusfor 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 viaNativeOnRemovedFromFocusPath/NativeOnFocusLostdoes 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 +UCommonButtonGroupBasefor 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
AutoFocusWidgetCDO 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). AUWidget*CDO ref into the regenerated WidgetTree can't re-bind, so the oldAutoFocusWidgetClass-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: overrideGet Desired Focus Targetin the widget Graph returning the first button.USLScreenWidget::ValidateCompiledWidgetTreefails the compile if neither that override nor a C++-setAutoFocusWidgetexists. →Docs/UISystem.md§6.29;USLCommonActivatableWidget.
- A
UCommonActionWidgetglyph in the UMG designer proves NOTHING about runtime.GetIcon()has an editor-only branch that rendersDesignTimeKeyand bypasses the whole runtime pipeline; the runtime path thenCollapseds itself on any missing link (widget not namedInputActionWidget, no action pushed, IMC not applied, no matchingCD_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 —
QueryKeysMappedToActiononly sees live contexts.IMC_SL_UIsat orphaned (zero referencers) and never applied, so every glyph collapsed. But don't "fix" it by applying it permanently: UI actions default tobConsumeInput=Trueand 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 intoPush/PopGameplayInputSuppression. →Docs/UISystem.md§4.2, §6.33.
- Bound action bar: handling back and DISPLAYING back are separate opt-ins.
bIsBackHandler=truemakes B/Esc work;bIsBackActionDisplayedInActionBar(defaults false) is what offers it to the bar — so back works but never shows, reading as a broken bar.USLScreenWidgetnow sets both. Two more: the entry widget needsInputActionWidgetANDText_ActionName(all ofUCommonBoundActionButton::UpdateInputActionWidgetis wrapped inif (InputActionWidget), and theSetTextis inside it → no glyph widget = blank button, text included); and a blank label with a good glyph means no display name (OverrideDisplayName→ elseInputAction->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 UnfocusedareUCommonButtonBase(bound to the internal Slate button's focus delegates — these work), whileOn Focus Received/On Focus LostareUUserWidgetand never fire, becauseNativeOnFocusReceivedforwards user focus to the innerSCommonButtonso the widget never holds focus. PairingOn Focused(CommonButton) withOn 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 anIn Focus Eventpin. 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);UCommonButtonBasehas no focus-path handlers, so a gamepad-focused button never shows a glyph. Don't fix it withTriggeringEnhancedInputActionon 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 onUSLButtonBaseinstead. →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, driveSetShowMouseCursoroff the input method — show on MouseAndKeyboard / hide on Gamepad viaOnInputMethodChanged(seed with the current type on activate), plus reveal onNativeOnMouseMoveas 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_ClearMulticastDelegate→ClearDelegate()(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. UseUnbind Event from Xand feed its redEventpin 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 DestructorOn 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, soGet <X> Subsystemhas 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, andSaveProfileon Deactivated. Fix:Get … Subsystem → IsValid → Branchbefore the call. Data isn't lost in the teardown case if the subsystem flushes dirty state inDeinitialize(asUSLPlayerProfileSubsystemdoes) — 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::BroadcastValuefiresOnValueChanged(int32 Index),OnBoolChanged(Index != 0)andOnFloatChanged(NumericValues[Index]).OnValueChangedcarries 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 bindOnFloatChanged, Off/On rows bindOnBoolChanged.OnFloatChangedonly fires onceNumericValuesexists, so the row must callSetNumericRangebefore it seeds (SetSelectedByFloatsilently early-returns on an emptyNumericValues, 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__DelegateSignaturesurvived both the widget being renamed toRow_Sensitivityand the row class changing fromUSLSliderRowtoUSLRotatorRow— 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 (OnValueChangedvsOnFloatChanged), 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 (
ProcessMulticastDelegateskips unbound entries) and stale ones are pruned byCompactInvocationList()insideAddUnique(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 ownOnValueChanged) needs no cleanup either — same lifetime.
#Controller / Force Feedback
ClientPlayForceFeedbackproduces NO rumble in PIE — test force feedback in a separate process. Confirmed 2026-08-11 with instrumented calls: fivek2_client_play_force_feedbackcalls landed on the correct localBP_SL_PlayerController_C_0withbForceFeedbackEnabled=Trueand produced nothing, and weapon fire was equally silent. The same weapons rumbled correctly the moment the session was launched as Standalone Game. AUForceFeedbackComponentplaced 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 aULocalPlayer, 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 inSaved/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_feedbackwas 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=Truehides a NEGATIVE pitch scale.Config/DefaultInput.inihas it on, soAPlayerControllerstill applies the deprecatedInputPitchScale = -2.5andInputYawScale = +2.5. Vertical look therefore depends on a sign nobody can see from the input assets, and it cancels an IMCNegate(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_Lookhas an empty asset-level modifier list; theNegate/DeadZonesit per-key insideIMC_Default. Also, in 5.7 the IMC's top-levelMappingsarray is deprecated and reads back empty — the live store isdefault_key_mappings.mappings. Inspecting the wrong one says "no mappings at all" and sends you hunting a phantom.
- Never put a
DeadZoneon 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. KeepDeadZoneonGamepad_Right2Donly. 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
ensurefails 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 fromNewObjectandClassWithin:ULocalPlayerSubsystemisClassWithin=ULocalPlayerandULocalPlayerisClassWithin=UEngine, so the fixture must build the whole chain —NewObject<ULocalPlayer>(GEngine)thenNewObject<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.
USLPlayerProfileSubsystemworks standalone becauseLoadProfiledoes everythingInitializedoes 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 realDefaultprofile.
fill_data_table_from_csv_string/_json_stringREPLACE 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\ninside multi-paragraphFText, so even a faithful-looking round-trip loses line breaks.
- **Automation
TestEqualhas noUScriptStruct*overload, andTestNotNullwon't take aTObjectPtr.** Compare structs withTestTrue(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 withsleepfreezes 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, useunreal.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 withsleep, 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 madeget_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 reportsauthority=Trueand you conclude the client is fine. Confirm withhas_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., prependGameplayCue..GC_Mobility_DashgivesGameplayCue.Mobility.Dash; the project's usualGC_SL_prefix givesGameplayCue.SL.Mobility.Dash, which is not a registered tag and silently kills registration. The symptom is maddening:GameplayCueTagreads correctly on the CDO and in the details panel, whileGameplayCueName— the registry mirror the manager actually registers from — isNone, so the cue never fires from a cold start. Cause isAbilitySystemGlobals.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 notGameplayCueName. Cues parented to engine classes never hit this (their parent CDO tag is invalid); cues parented to our ownGC_*native classes always do. →Docs/MobilityCues.md.
- An in-editor tag edit registers a cue for that session only. Setting
GameplayCueTagin Class Defaults runsPostEditChangeProperty→HandleAssetAdded, 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(anInputMappingContextMappingDatastruct); the old top-levelmappingsarray 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 becauseIA_SL_UI_Pausewas never really inIMC_Default. Always read the mapping back (default_key_mappings.mappings) after writing. Construct keys withunreal.Key(); k.import_text('Gamepad_Special_Right')(theKey(...)ctor takes no args; there's nokey_nameattr). → 2026-07-09 pause-menu debug;CurrentFocus.md.
WidgetBlueprint.WidgetTreeis 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
UObjectmember function like a base virtual (Initialize,Tick,BeginPlay…). Silent shadowing (C4263/C4264) breaks engine init / hides the base virtual. → memoryfeedback_uobject_function_name_clashes. (Live example:USLGrenadeIndicator::Initialize→ renamedInitializeIndicator.)
- Don't name locals
Slotin aUUserWidgetmethod (C4458 —Slotis aUUserWidgetmember) orCharacterin anAControllermethod (member onAController). 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/Durationflips to −1 the instantCurreachesTarget, so it jitters ±step every frame. UseFMath::FInterpConstantTo(constant rate, stops AT target) orFInterpTo(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 theUObjectbase conversion, i.e. the complete type.#includethe header, forward declaration isn't enough. → grenade pickupISLDamageablework;#include "AbilitySystemComponent.h".
- **TObjectPtr for stored UPROPERTY members; raw
T*for returns/params/locals.** Forward-declare in the header; the.cppneeds the#includeto call methods / useIsValid. → memoryfeedback_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
ASLCharacterBaseand changingGetLifetimeReplicatedProps, then Live Coding, produced a clean segfault right afterPatch 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 inBugTracker.md's preamble — that one is a GPU device-hung with a generic breadcrumb.)
AActor::FellOutOfWorldfires EVERY TICK while below Kill Z, not once, and its default implementation isDestroy(). If anything else owns the actor's lifetime — e.g. a respawn scheduled on a weak lambda — callingSupersilently cancels it. Symptom: the player dies and simply never respawns, with nothing logged. Latch the handler and don't callSuperunless 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. → memoryfeedback_clean_binaries_intermediate.
- Reparenting a Blueprint mid-session crashes the editor ON THE SPOT —
Fatal World Leaks, with aREINST_<YourBP>_C_nin 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 reparentingWBP_DashProgresstoUSLDashIndicator. 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. → memoryfeedback_blueprint_reparent_crash.
- **Never call component
Set*mutators from a COMPONENT's own constructor — assign the members directly.UBoxComponent::SetBoxExtentcallsUShapeComponent::UpdateBodySetup, which doesNewObject<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 fromSetCollisionEnabled/SetCollisionResponseTo(viaEnsurePhysicsStateCreated) and frombVisualizeComponent = 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. WriteBoxExtent = ...,bHiddenInGame = true, and useBodyInstance.SetCollisionEnabled(Type, /bUpdatePhysicsFilterData=/false)/BodyInstance.SetResponseToChannel(...), which are the constructor-safe equivalents. The engine's ownUBoxComponentconstructor assignsBoxExtentdirectly for exactly this reason. Note this only bites in the component's constructor — the sameSetcalls are fine from an actor's constructor afterCreateDefaultSubobject, which is why they look safe in most engine code. Reproduced 2026-08-07 writingUSLShootableComponent.
- Read the callstack in
Saved/Crashes/<...>/CrashContext.runtime-xml, not just the fatal line in the log.SystemLink.logoften ends atappError 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 saidUSLShootableComponent::USLShootableComponentoutright, and a first guess made without it cost an extra build-and-restart cycle.
- When renaming a UPROPERTY, add a
CoreRedirectsentry toConfig/DefaultSystemLinkCore.inior 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.cppcompiles 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 withNetRoleLabelcopied fromSLDoor.cppintoSLTeleporter.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 inDebug/SLDebug.hasSLNetRoleLabel). Notestaticdoes not save you — in one TU it is still a duplicate definition.
#Audio
- A
GameplayCueNotify_Burstsound withDoNotAttachplays atCueParameters.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 cueLocationand/or use AttachToTarget. → BUG-024.
- A 3D
PlaySoundAtLocationwith 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 2Din a Local-Predicted ability → the listen-host hears every player's sound. Gate thrower/owner-only feedback behindIs Locally Controlled. →Docs/AudioAudit.md.
#Editor / Tooling / Bridge
- **The
systemlink-unrealbridge 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). → memoryproject_unreal_mcp_bridge,feedback_controlrig_python_membervar_crash.
InputMappingContext.mappingsis DEPRECATED in UE 5.7 — reads 0. Real mappings live indefault_key_mappings.mappings(InputMappingContextMappingData).imc.map_key(action, key)writes to the new struct correctly (use it), but any read/dedupe/count must go throughdefault_key_mappings.mappings, not the top-levelmappings(which silently returns empty). Alsounreal.Key()takes no ctor args — build viak = 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 RigEffectorpin.)
unreal.WidgetTreeisn't exported to Python — you can't walk a widget tree via the bridge. Probe exact child names withfind_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 vianew_objecthas no constructed Slate; methods that reach into it deref null. Cost a crash 2026-07-23 callingUSLRotatorRow::SetNumericRange(→ baseUCommonRotator::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_Pause→WBP_SL_Page), the editor vanished with noLogExitand no fatal entry inSystemLink.log. Timeline: autosave → 1 min 52 s of total log silence → Compile → 75 ms later the Slate ensureArray 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 (UCanvasPanelSlotvsUHorizontalBoxSlotvsUOverlaySlot), unlike Render Transform which is a plainUWidgetstruct 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) — hereUnrealEditor-Slate.dll,0xc0000409P9=2(FAST_FAIL_STACK_COOKIE_CHECK_FAILURE, BEX64) with a completely clean System log (nonvlddmkm/WHEA/TDR/Kernel-Power) ⇒ genuine Slate memory corruption, not the GPU/CPU-instability family below. The crashed session's log is preserved inSaved/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 pushto 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,
nvlddmkmevent 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+CrashTypein the crash context, and whether the Windows System log hasnvlddmkm/WHEA entries at the crash time.IsAssert=false+CrashType=GPUCrash+nvlddmkmpresent ⇒ 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:path | git 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. Useunreal.new_object(Class, outer=OwningAsset). Seen 2026-07-27 adding an input modifier toIMC_Default— the mapping saved with a null modifier that did nothing at runtime. Always read back after a scripted asset edit and check forNonein the array, which is now covered bySystemLink.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 buffer —UUnrealEdEngine::Trans → TransBuffer → WB_Teleport_C_0— still referencing the pre-reparent widget instance, which kept the PIE world alive pastBeginTearingDown. 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 Interfacenode whose Interface was left unset — nothing logs, nothing warns, the node just returns false forever. In the teleporter it gated the entire cosmeticSequence(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-checkedTSubclassOffilter 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_Cplaced rather than the GASBP_SL_OvershieldPickup; there are twoWB_Teleportwidgets (Environment/Teleporter/andUI/HUD/Teleporter/). Before editing or assigning any migrated asset, search the wholeContent/tree for the name — editing the wrong copy produces changes that never appear in game. →Docs/CurrentFocus.md,Docs/Teleporters.md§5.
- Asset
SLprefix 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 noSLprefix.
Assualt→Assaulttypo persists in ~14.uassetnames — rename in the Content Browser (not on disk) so references auto-update. →Docs/CurrentFocus.mdDeferred Cleanup.
Spawn System Attacheddoes 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 viaThursterLeftSocket(the real ones areThrusterLeftSocket/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_AfterburnonlyUser.Emissive_Boostand the eightUser.Particulate_are actually consumed; everyUser.Thrusters_,User.Smoke_,User.EnergyCore_andUser.HeatHaze_*shows in the User Parameters panel and is read by no module — those emitters use baked constants instead.Set Niagara Variableagainst them is a silent no-op. To tell wired from decorative without the editor, extract ASCII from the.uassetand look for the compiled HLSL uniform (float User_<Name>): present = wired, absent = decoy. Niagara's Python API exposes none of this (unreal.NiagaraSystemhas no emitter/module access at all).
- Check for an existing scale hook before authoring one. The same pack's
NMS_GlobalScale/NMS_GlobalVelocitymodules readEngine.Owner.Scalein every emitter, soSet Relative Scale 3Don 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
.uassetbytes 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 deletedSitting_1/Sitting_2out from underABP_MasterChiefMenu, which came back as threeERROR!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, commit7b2b91fa.)
- 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 everyNewNameas 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_Fontis reachable from no level but is used bySystemLink/UI/Styles.
EditorAssetLibrary.delete_assetin a loop CRASHES the editor —EXCEPTION_ACCESS_VIOLATIONinBackground Worker #0on 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
.uassetcannot 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 onLevel1/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;MItMeshEdgewalks are in the same family. It is not a bad script — it is the data. Derive normals and areas yourself with Newell's method fromMFnMesh.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, … whereRenderMeshNamematches 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 useUCX_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
Tby depthdand the solver exitsdup orT − ddown, taking the shorter: pastT/2it 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.