Skip to content

feat(determinism): Cross-platform deterministic simulation math and lockstep desync fixes#4

Open
Okladnoj wants to merge 10 commits into
okji/feat/deterministic-math-v2from
okji/feat/deterministic-math-v2.2.1-clean
Open

feat(determinism): Cross-platform deterministic simulation math and lockstep desync fixes#4
Okladnoj wants to merge 10 commits into
okji/feat/deterministic-math-v2from
okji/feat/deterministic-math-v2.2.1-clean

Conversation

@Okladnoj

Copy link
Copy Markdown
Owner

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

  • Deterministic math via GameMath (fdlibm) under USE_DETERMINISTIC_MATH; FMA contraction disabled
  • Single-precision sweep + NaN/Inf→int cast guards to avoid x87 ↔ SSE2/NEON divergence
  • Route divergent calls (Bezier D3DXVec4Transform/Dot, Atan/Atan2, PartitionManager radius) through deterministic paths
  • Logic/network fixes: SpecialPowerModule retail-CRC, network player-index mapping

Tested in 2v2v2v2 (2 players + 6 hard bots) cross-platform lockstep with per-frame CRC.
RETAIL_COMPATIBLE_CRC=1 keeps original DX8 behavior. Thanks to @bobtista and @xezon for review feedback.

Okladnoj added 10 commits July 12, 2026 21:56
… 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.
- 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.
…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.
@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

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 OmniBlade left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Division by zero yields different results on macOS and Windows.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@Okladnoj

Copy link
Copy Markdown
Owner Author


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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok I'll rename to Div_Safe

*/

double minDistSqr = 1e12; // double, not real
Real minDistSqr = 1e12f;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, I hadn't come across this one yet, good find :)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the problem here?


#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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is already covered with TheSuperHackers#2857


// double, not real
double dist = WWMath::Sqrtf(minDistSqr);
Real dist = WWMath::Sqrtf(minDistSqr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need casting to float?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants