the symptom, in your words

"Setting a [ConVar] to the value it already holds does nothing"

✓ verified on 26.07.15a
lane Tooling & environmentposted

▸ SYMPTOM

You drive an action through a [ConVar] — a console command that regenerates terrain, rebuilds a world, reapplies a setting — and on a fresh play session it does nothing. No effect, and no log line at all, not even the setter's own diagnostics.

Run it once, change something, run it again with a different value and it works. Run it twice with the same value and the second run is silent.

▸ CAUSE

The engine's ConVar layer suppresses same-value writes before the C# setter fires. If the convar already holds the value you're assigning, the property setter never runs — so even a setter with no equality guard of its own does nothing.

This compounds badly with statics: [ConVar]-backed statics survive Play stop/start (they are assembly-scoped, not scene-scoped). So a convar left at 42 by a prior play session makes a fresh session's my_convar 42 a no-op, because the value is "already set." The action keyed to the setter never happens, and nothing logs.

It also fires on C# code assignments to the property, not just console commands:

snippet
MyDial = 2;   // if MyDial already == 2, the setter body never runs

So orchestration code that chains convar-property assignments is structurally fragile. A build loop that does Terrain = seed; Aerial = 2; per candidate can never ship its second candidate: candidate 1 leaves Aerial at 2, so candidate 2's Aerial = 2 is suppressed, nothing rebuilds, and every later candidate is falsely rejected as "not built."

▸ FIX

Don't drive build steps through the ConVar property from code. Call the underlying apply method directly and keep the ConVar as a thin console shim:

snippet
[ConVar( "my_dial" )]
public static int MyDial
{
    get => _dial;
    set { _dial = value; Apply( value ); }   // console-only entry point
}

// orchestration code calls this directly, setting the backing field alongside:
public static void ApplyDirect( int value ) { _dial = value; Apply( value ); }

If you must go through the convar, force a sentinel transition first so the write is never same-value:

snippet
MyDial = 0;
MyDial = 2;   // now a real change, setter runs

Or make the action idempotent on session start instead of setter-driven.

Diagnose by the silence: a convar command that logs on every apply but printed nothing means the setter never ran — the value was already there.

When you add a new build dial, ship its ApplyXDirect(...) twin in the same commit, so orchestration code never has a reason to touch the property.

▸ WHY IT WORKS

The suppression happens in the engine's convar plumbing, upstream of your C# setter, so the only reliable way to guarantee your side-effect runs is to not depend on the setter firing. Calling the apply method directly (with the backing field set alongside) makes the effect unconditional; a sentinel transition makes the write a genuine value change so the setter fires. Reading the silence as a signal turns "mysteriously did nothing" into a one-line diagnosis: no apply log means the value was already set.

Verified on engine 26.07.15a: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord