feat(determinism): Cross-platform deterministic simulation math and lockstep desync fixes#4
Conversation
… check isPlayerConnected expects a Network slot index, but was receiving a PlayerList index. Added proper mapping to avoid spurious mismatches at low CRC intervals.
…rModule Hardcode m_availableOnFrame to 0 to simplify initialization and ensure deterministic behavior regardless of the CRC macro.
…t build processes
- Introduced `WWMath::Div_FixNaN` to safely handle division by zero and prevent floating point exceptions (NaNs). - Replaced unsafe divisions with `Div_FixNaN` across core GameLogic modules (AI, Pathfinding, Object Behaviors, Updates, Weapons). - Synchronized deterministic math behavior between Windows (x86) and Mac (ARM64), resolving cross-platform desyncs caused by architecture-specific NaN handling.
…behaviors" -m "Using WWMath::Div_FixNaN to protect against division by zero in: - SlowDeathBehavior: Prevents NaN when maxHealth is 0, avoiding divergent (Int)NaN casts across architectures (x86 yields INT_MIN, arm64 yields 0). - DumbProjectileBehavior: Prevents Inf when flightPathSpeed is 0, avoiding divergent (Int)Inf casts across architectures.
…dius math for determinism
…istic BezierMath D3DX math resolves differently across platforms (real d3dx8.lib on Windows vs C shim on Mac/clang), diverging the projectile Bezier flight path by ~1 ULP. The drift materializes into a CRC'd object (radiation field spawn) and desyncs lockstep. BezierMath computes transform/dot in fixed-order 32-bit float under USE_DETERMINISTIC_MATH; RETAIL_COMPATIBLE_CRC=1 keeps original DX8.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
OmniBlade
left a comment
There was a problem hiding this comment.
In addition to the comments here, why is there both an overloaded Sqrt and Sqrtf. Should stick to either using overloading and ensuring arguments are correct or do all calls at a fixed precision.
| { | ||
| #if USE_DETERMINISTIC_MATH | ||
| return gm_atan2(x, y); | ||
| return (double) gm_atan2f((float)x, (float)y); |
There was a problem hiding this comment.
Disagree with this, correct fix is to identify callsites where incorrect version of Atan2 is being called in my opinion. Keep double operations at double precision. Perhaps rename to Atan2D and avoid overloading instead?
There was a problem hiding this comment.
We already have so many variations of math functions... Is it really worth introducing yet another pair, AtanD2 and Atan2D2?
And where is the guarantee that we won't miss something or forget, while implementing some modules, that Atan2 must not be used in the game core and Atan2D2 should be used instead?
I'm simply voting for stability.
There was a problem hiding this comment.
You are basically violating the contract though, Atan2 double implies doing the calc at double You don't need another pair, you have ATan2 and ATan2d or ATan2f and ATan2 (the second is probably better with regards to standard naming, but the former probably requires less changes to the code).
You shouldn't have Sqrt(float) and Sqrtf(float) either, you should have one or the other.
There was a problem hiding this comment.
Alternatively just remove the double variation all together (though that will generate a bunch of warnings for narrowing conversions without casts at higher warning levels) to force everything through the float precision. I'm still at a loss as to why it going through the double precision is an issue unless we are worrying about x87 use which I don't think we should be catering for outside of the VC6 build. Basically support FLT_EVAL_METHOD == 0 from float.h and assert at runtime that desyncs are likely when this condition is not met.
There was a problem hiding this comment.
+1 with Omniblade here. gm_atan2 at double is already fdlibm and should be bit-identical cross-platform, so the float demotion shouldn't be needed for determinism.
| Real overkillPercent = (float)overkillDamage / (float)getObject()->getBodyModule()->getMaxHealth(); | ||
| Int overkillModifier = overkillPercent * getSlowDeathBehaviorModuleData()->m_modifierBonusPerOverkillPercent; | ||
| Real maxHealth = getObject()->getBodyModule()->getMaxHealth(); | ||
| Real overkillPercent = WWMath::Div_FixNaN((float)overkillDamage, maxHealth, 0.0f); |
There was a problem hiding this comment.
Why is this required for division calculation? IEEE 754 standards state that all results of the standard operators and sqrt produce specific results so I'm assuming this is only needed for VC6 builds that only use x87. SSE2 and NEON should match in division behaviour (and other IEEE conformant floating point units)?
There was a problem hiding this comment.
Division by zero yields different results on macOS and Windows.
There was a problem hiding this comment.
I'm with Omniblade here that the division matches on SSE2/NEON. x/0 is ±Inf on both (only the 0/0 NaN sign bit differs, which doesn't matter here). The actual divergence is the (Int) cast on the next line: x86 cvttss2si converts Inf/NaN to 0x80000000, ARM64 fcvtzs saturates to INT_MAX/0. So the guard fixes the desync, but guarding the cast would target the actual divergence, and we could clean up the double overload's demotion to float
@OmniBlade
I actually had a version without overloads originally — I was asked to switch
to overloads here and here, and by now the whole implementation has gone quite
far down that path:
|
|
|
||
| static WWINLINE float Normalize_Angle(float angle); // Normalizes the angle to the range -PI..PI | ||
|
|
||
| static WWINLINE float Div_FixNaN(float dividend, float divisor, float fallback = 0.0f); |
There was a problem hiding this comment.
The double overload does the division in float and hands it back as double, so every double division through it drops to 24-bit. Could keep it double eg (divisor == 0.0) ? fallback : dividend / divisor.
Also nit: naming, it's really a div-by-zero guard — x/0 gives Inf, only 0/0 gives NaN. Not sure what to name it, maybe Div_Safe?
There was a problem hiding this comment.
ok I'll rename to Div_Safe
| */ | ||
|
|
||
| double minDistSqr = 1e12; // double, not real | ||
| Real minDistSqr = 1e12f; |
There was a problem hiding this comment.
I ran into this too - the fix seems good, maybe it needs to be gated behind USE_DETERMINISTIC_MATH or RETAIL_COMPATIBLE_CRC or VC6?
|
|
||
| #define USUAL_TOLERANCE 1.0f | ||
|
|
||
| class BezierMath |
There was a problem hiding this comment.
Nice, I hadn't come across this one yet, good find :)
|
|
||
| #ifndef RETAIL_COMPATIBLE_CRC | ||
| #define RETAIL_COMPATIBLE_CRC (1) // Game is expected to be CRC compatible with retail Generals 1.08, Zero Hour 1.04 | ||
| #define RETAIL_COMPATIBLE_CRC (0) // Game is expected to be CRC compatible with retail Generals 1.08, Zero Hour 1.04 |
There was a problem hiding this comment.
This won't fly in TSH, that change will be its own PR there.
| // TheSuperHackers @bugfix Caball009 14/06/2026 Check if player is still connected, | ||
| // to avoid spurious mismatches at low CRC intervals, e.g. every frame. | ||
| if (!TheNetwork->isPlayerConnected(it->first)) | ||
| // @fix 08/07/2026 okladnoj: it->first is the PlayerList index, but isPlayerConnected expects Network slot index! |
There was a problem hiding this comment.
I think this is already covered with TheSuperHackers#2857
|
|
||
| // double, not real | ||
| double dist = WWMath::Sqrtf(minDistSqr); | ||
| Real dist = WWMath::Sqrtf(minDistSqr); |
There was a problem hiding this comment.
I wonder if this will cause trouble with VC6 retail compat.
| WWINLINE double WWMath::Div_FixNaN(double dividend, double divisor, double fallback) | ||
| { | ||
| #if USE_DETERMINISTIC_MATH | ||
| return (double) ((divisor == 0.0) ? (float) fallback : (float) dividend / (float) divisor); |

Makes the simulation bit-for-bit deterministic across platforms (Windows x86 ↔ macOS arm64),
so lockstep multiplayer and replays stay in sync. Continues #2670, @bobtista message
GameMath(fdlibm) underUSE_DETERMINISTIC_MATH; FMA contraction disabledD3DXVec4Transform/Dot,Atan/Atan2, PartitionManager radius) through deterministic pathsTested in 2v2v2v2 (2 players + 6 hard bots) cross-platform lockstep with per-frame CRC.
RETAIL_COMPATIBLE_CRC=1keeps original DX8 behavior. Thanks to @bobtista and @xezon for review feedback.