Skip to content

feat(math): Route game logic math through WWMath with 3-mode deterministic support - #2670

Open
Okladnoj wants to merge 14 commits into
TheSuperHackers:mainfrom
Okladnoj:okji/feat/deterministic-math-v2
Open

feat(math): Route game logic math through WWMath with 3-mode deterministic support#2670
Okladnoj wants to merge 14 commits into
TheSuperHackers:mainfrom
Okladnoj:okji/feat/deterministic-math-v2

Conversation

@Okladnoj

@Okladnoj Okladnoj commented May 1, 2026

Copy link
Copy Markdown

Rework of #2602, incorporating review feedback:

  • GameMath via FetchContent (per @stephanmeesters, @OmniBlade recommendation)
  • Trig.cpp preserved, redirected to WWMath instead of deleted (per @xezon request for standalone change)
  • 3 math modes: VC6 (x87 inline asm), CRT (standard library), GameMath deterministic (per @Mauller recommendation)
  • USE_DETERMINISTIC_MATH defaults on for non-VC6. BaseDefines.h turns it off automatically when gmath.h is not available or when RETAIL_COMPATIBLE_CRC is set, so a build without GameMath falls back to the CRT path rather than failing
  • GameMath keeps its own intrinsics default — the earlier GM_ENABLE_INTRINSICS=OFF override was dropped after a Windows replay run showed byte-identical CRC logs with intrinsics on and off
  • Linear history on top of current main, no merge commits

Open question: Replay checks pass both with and without USE_DETERMINISTIC_MATH, even though golden replays were recorded with an x87 build. The replays may not contain MSG_LOGIC_CRC messages, meaning the check only validates absence of crashes rather than game state CRC parity. If anyone has insight on this — please share.

Testing results

Cross-platform deterministic math parity verified with SimulationMathCrc::runBenchmark — computes CRC over 10 000 iterations of sin/cos/tan/atan2/sqrt/pow across a fixed input set.

System Compiler Math Library CRC Perf (10 000 iters)
Win32 x86 MSVC (modern) fdlibm (deterministic) 🟩 76B53840 ~6 ms
macOS ARM64 Apple Clang fdlibm (deterministic) 🟩 76B53840 ~11 ms
Win32 x86 MSVC (modern) system math (native) 🟦 E8B6385A ~3 ms
macOS ARM64 Apple Clang system math (native) 🟦 E8B6385A ~5 ms
Win32 x86 VC6 (legacy) x87 CRT (no fdlibm) 🟧 B7B83850 ~17 ms
Win32 x86 VC6 (legacy) system math (native) 🟥 8BB5B841 ~5 ms
  • 🟩 cross-platform deterministic parity achieved (Win32 modern = macOS ARM64)
  • 🟦 native system math match (Win32 modern = macOS ARM64)
  • 🟧 VC6 deterministic (x87 CRT, separate group — fdlibm not supported)
  • 🟥 VC6 native (x87 CRT, separate group)

Key fix: -ffp-contract=off in cmake/compilers.cmake — prevents Clang from emitting FMA instructions (fmadd) that skip intermediate rounding, breaking bit-exact parity with MSVC's /fp:precise default.

image

@greptile-apps

greptile-apps Bot commented May 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR routes game math through WWMath and adds deterministic GameMath support. The main changes are:

  • GameMath integration through CMake for non-VC6 builds.
  • WWMath wrappers for deterministic, native, and legacy math paths.
  • Trig.cpp forwarding preserved through WWMath.
  • A diagnostic math CRC benchmark for deterministic/native comparisons.
  • Broad game logic and rendering math call sites moved from raw CRT calls to WWMath.

Confidence Score: 4/5

This is close, but the math-mode gate should be fixed before merging.

  • The new default gate still turns off the GameMath path in normal non-VC6 builds.
  • The updated call sites can look deterministic while still using CRT math underneath.
  • The CRC helper itself now routes the previously raw math calls through WWMath.

Core/Libraries/Include/Lib/BaseDefines.h

Important Files Changed

Filename Overview
Core/Libraries/Include/Lib/BaseDefines.h Adds the shared math-mode defaults, but the default CRC setting still disables the GameMath path.
Core/GameEngine/Source/Common/Diagnostic/SimulationMathCrc.cpp Updates the diagnostic CRC path to use WWMath wrappers for the benchmarked math operations.
Core/Libraries/Source/WWVegas/WWMath/wwmath.h Provides the wrapper surface used by the changed math call sites.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
Core/Libraries/Include/Lib/BaseDefines.h:35-36
**Deterministic math disabled**

`RETAIL_COMPATIBLE_CRC` defaults to `1`, so this condition is true even when `gmath.h` is present. That undefines `USE_DETERMINISTIC_MATH`, and the WWMath wrappers compile their CRT branches instead of the GameMath branches. A default non-VC6 build can therefore run the old platform math path and still pass through the new WWMath call sites, so cross-platform simulation can diverge even though GameMath was fetched.

Reviews (11): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread Generals/Code/GameEngine/Source/Common/System/Trig.cpp Outdated
Comment thread Core/GameEngine/Source/Common/Diagnostic/SimulationMathCrc.cpp Outdated
Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
@Okladnoj

Okladnoj commented May 1, 2026

Copy link
Copy Markdown
Author
image Here is what replay playback looks like at the moment.

I’m testing this on a separate branch:
https://github.com/Okladnoj/GeneralsGameCode/okji/test/deterministic-math-v2

I slightly adjusted the CI there so I can run Win32 and get access to the game resources.

@Okladnoj
Okladnoj force-pushed the okji/feat/deterministic-math-v2 branch from 854cc7b to 779f714 Compare May 1, 2026 00:49
@Skyaero42

Copy link
Copy Markdown

You did not review the changes you made with AI. It has issues that you should fix before asking it to be reviewed.

@Okladnoj Okladnoj left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

reviwed all changes

@xezon

xezon commented May 2, 2026

Copy link
Copy Markdown

This change does too many things. It is better to first consolidate trig and wwmath and maybe other sources of math, before going into gamemath territory.

@Okladnoj
Okladnoj force-pushed the okji/feat/deterministic-math-v2 branch from 4b5675d to ddea128 Compare May 3, 2026 15:09
@Okladnoj

Okladnoj commented May 3, 2026

Copy link
Copy Markdown
Author

This change does too many things. It is better to first consolidate trig and wwmath and maybe other sources of math, before going into gamemath territory.

@xezon Hey! I understand your point, but the reason I didn't fully consolidate trig and wwmath in this PR is exactly to avoid doing too many things at once.

As we saw in PR #2602, fully removing trig.h and replacing it with WWMath across the codebase touches over 120 files. Mixing a massive 120+ file architectural refactoring with a core feature addition (GameMath) made the previous PR extremely difficult to review and broke compilation for some standalone utilities, because trig.h is used outside of just game math.

That's exactly why I chose this "routing" approach for this PR. By keeping the trig.h interface intact and just routing its internal implementation to WWMath, we achieve the deterministic math goals with a much smaller and safer footprint.

Perhaps the best option would be to test this PR first, and if everything is fine — merge it. And only after that, we can focus on a second PR dedicated purely to the architectural cleanup (removing trig.h across all 120+ files)?

Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing gm math variants.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ceil(float) and Floor(float) are original EA code (line 157 in main). They are only used in rendering (visrasterizer.cpp) and Normalize_Angle. Not part of CRC game logic — no need to wrap.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keep it simple and consolidate code. No math function duplicates.

Comment thread Core/Libraries/Include/Lib/BaseType.h Outdated
Real x, y, z;

Real length() const { return (Real)sqrt( x*x + y*y + z*z ); }
Real length() const { return (Real)Sqrt( x*x + y*y + z*z ); }

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 is now calling a Sqrt(double). Is this intentional? If yes, why?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, intentional. Sqrt(double) is a free function from trig.h → WWMath::SqrtOrigin(x). Original EA called bare sqrt(). Coord3D::length() is used in game logic and participates in CRC — must be deterministic.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

And (Real)sqrt( x*x + y*y + z*z ); was calling double sqrt(double) ?

Comment thread cmake/gamemath.cmake Outdated
Comment thread Core/GameEngine/Source/Common/Diagnostic/SimulationMathCrc.cpp Outdated
Comment thread Core/GameEngine/Source/Common/Diagnostic/SimulationMathCrc.cpp Outdated
Comment thread Core/GameEngine/Source/Common/Diagnostic/SimulationMathCrc.cpp Outdated
Okladnoj added a commit to Okladnoj/GeneralsGameCode that referenced this pull request May 5, 2026


- Merge gmath.h include + USE_DETERMINISTIC_MATH into single __has_include block
- Replace all #ifdef/#if defined() with #if USE_DETERMINISTIC_MATH
- Remove TheSuperHackers @fix prefix from cmake comment
- Expand ODR abbreviation in gamemath.cmake comment
- Add blank lines after setFPMode() in benchmark
- Fix iters abbreviation in printf
- Simplify benchmark: remove replay dependency, auto-trigger at frame 400
Okladnoj added a commit to Okladnoj/GeneralsGameCode that referenced this pull request May 5, 2026


- Merge gmath.h include + USE_DETERMINISTIC_MATH into single __has_include block
- Replace all #ifdef/#if defined() with #if USE_DETERMINISTIC_MATH
- Remove TheSuperHackers @fix prefix from cmake comment
- Expand ODR abbreviation in gamemath.cmake comment
- Add blank lines after setFPMode() in benchmark
- Fix iters abbreviation in printf
- Simplify benchmark: remove replay dependency, auto-trigger at frame 400
- Rename WWMath wrappers to Function_Name convention (578 replacements, 79 files)
fbraz3 added a commit to fbraz3/GeneralsX that referenced this pull request May 7, 2026
* feat(deterministic-math): scaffold phase 4 routing

Port the first deterministic math batch derived from TheSuperHackers PR TheSuperHackers#2670 with incremental gating and attribution compliance.

- add non-MSVC anti-FMA compile flag (-ffp-contract=off)

- route trig and sqrt gateways through WWMath wrappers

- add gamemath.cmake integration scaffold with deterministic flag

- update project rule for upstream PR attribution comments

- update lessons learned and May dev diary

* fix(headless): stabilize replay simulation on macOS

- Override ParticleSystemManagerDummy::update() as no-op to prevent
  headless replay from executing the full particle update path, which
  caused EXC_BAD_ACCESS crash at ParticleSystemManager::update()+560

- Route SDL3GameEngine::createRadar() and createParticleSystemManager()
  to their Dummy counterparts when dummy=true (headless mode), matching
  upstream Win32GameEngine factory behavior

- Guard ParticleSystemManager::update() loop against stale null entries
  with early continue before sys->update() dispatch

- Skip smudge rendering path in headless via m_headless guard in
  ParticleSystemManager::update()

- Add null-file guards in RecorderClass::readNextFrame(),
  appendNextCommand(), and updatePlayback() for both Generals and ZH
  to prevent null dereference when playback file is closed mid-loop

* fix(replay-headless): harden texture creation flow

Guard D3DX8 and DX8 wrapper texture allocation paths when device or caps are unavailable in headless replay windows. Fail texture load tasks safely instead of dereferencing null state.

Also harden missing texture fallback handling and record session notes in May diary and lessons.

* fix(replay-recording): handle mixed path separators correctly when serializing map name

The loop condition checking for path separators was incomplete on Linux/macOS paths:
- realMapPathToPortableMapPath() converts platform paths to portable format
- Portable paths may contain forward slashes (Linux/macOS standard)
- Loop condition find(backslash) never matched forward-slash-only paths
- This left newMapName EMPTY when writing replay header
- Result: replays stored with corrupted map name field

Fix: Check !isEmpty() AND (find(backslash) OR find(forward slash))
- Loop correctly terminates when last token (filename) is reached
- Works with both Windows (backslash) and Unix (forward slash) separators
- Applies to both GameInfoToAsciiString() and GameInfo::setMap()

Test results:
- macos_skirmish_1v1.rep: PASS
- macos_6p_custom_map_2.rep: PASS (CRC fallback resolves map)
- macos_1v1_custom_map_1.rep: CRC mismatch (expected, data incompatible)

* fix(replay-mapcache): normalize map cache path and replay map field

Fix cross-platform replay/map issues found on macOS:\n- write/read MapCache.ini using portable path join (no literal \ filename)\n- keep replay header path handling for absolute and directory-based -replay inputs\n- add explicit replay CRC mismatch diagnostics for headless runs\n- encode/decode replay map field to preserve special characters in map names\n\nValidation:\n- macOS z_generals build completed successfully\n- replay tests: official/custom map cases load natively; incompatible replay reports frame-0 CRC mismatch

* fix(particle-emitter): null-safe strdup in copy constructor

ParticleEmitterClass copy constructor called ::_strdup() on NameString
and UserString without null checks, causing SIGSEGV when either field
was null.

Crash observed at:
  ParticleEmitterClass::Clone() -> copy ctor -> ::_strdup(nullptr)
  -> strlen(nullptr) -> SIGSEGV (KERN_INVALID_ADDRESS at 0x0)

Triggered by W3DGhostObject::snapShot() during normal gameplay.

Fix: guard strdup calls with null check before dereferencing.
Applied to both GeneralsMD and Generals variants.

* docs(replay): add headless testing reference and tech debt notes

- HEADLESS_REPLAY_TESTING.md: commands, parameters, output interpretation,
  platform notes, debug tips (GDB/lldb) for macOS and Linux
- REPLAY_MAPCACHE_TECH_DEBT.md: tracked known issues for custom map CRC
  fallback and (resolved) MapCache.ini backslash filename bug
@Okladnoj

Okladnoj commented May 8, 2026

Copy link
Copy Markdown
Author

Hi @xezon! I have addressed all your review feedback points and updated the PR.

CI Status:
The CI is completely green. I ran the benchmarks on both Win32 and VC6 with the latest changes, and the CRC results perfectly match our previous deterministic baselines (76B53840 for deterministic, E8B6385A for native).

To save you from hunting through all the comment threads, here is a consolidated list of the answers and solutions to your review points:

  • Function_Name convention / Naming inconsistencies
    Fixed. Renamed all math wrappers to use the _Origin and _Trig convention. The _Trig suffix also cleanly resolves conflicts with legacy EA names (e.g., ACos_Trig vs Acos).
  • Move #define next to #include gmath
    Fixed.
  • Redundant VC6 guard
    Fixed — removed the outer #if !(defined(_MSC_VER)...) guard, kept only __has_include. VC6 doesn't support __has_include, so the block is naturally skipped.
  • "origin" terminology
    "Origin" means the original EA code called bare CRT functions (sqrt, acos, sinf...). The suffix explicitly marks which exact CRT function was used originally. These are not just type variants — they are different precision math paths.
  • Missing gm math variants / CeilfOrigin identical to Ceil
    Ceil(float) and Floor(float) are original EA code used only in rendering (visrasterizer.cpp). Determinism isn't needed there. However, CeilfOrigin(float) is a game logic wrapper that routes to gm_ceilf. Therefore, they are not identical.
  • C++ overloads instead of f suffix
    Overloads are dangerous here. GameMath only provides float functions (the double version always narrows). With overloads, the compiler silently picks the version by argument type and could inadvertently change the precision path. Explicit names protect against this.
  • No @fix prefix in CMake / What is ODR? / Line breaks / iters typo
    Fixed.
  • Benchmark in GameLogic::update()
    Moved the auto-benchmark out of the replay loop. It is now a simple compile-time flag. (Did not prepare an ImGui stub since ImGui does not exist in the project).
  • VS6 exclusion necessary in CMake?
    Yes, it is necessary. VC6 doesn't support <stdint.h> and long long required by GameMath. Removing the exclusion will break the build.
  • Sqrt(double) intentional in BaseType.h?
    Yes, intentional. Coord3D::length() is used in game logic and participates in CRC — it must be strictly deterministic.

Okladnoj added a commit to OKJID/GameClient that referenced this pull request May 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keep it simple and consolidate code. No math function duplicates.

Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
static WWINLINE double PowOrigin(double x, double y) { return pow(x, y); }
static WWINLINE float PowfOrigin(float x, float y) { return powf(x, y); }
static WWINLINE double CeilOrigin(double x) { return ceil(x); }
static WWINLINE float CeilfOrigin(float x) { return ceilf(x); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ok. Then remove these duplicates and simply call ceil or std::ceil & Co at the non logical critical call sites. This way these extra functions can be removed here.

Comment thread Core/Libraries/Source/WWVegas/WWMath/wwmath.h Outdated
static WWINLINE double AtanOrigin(double x) { return (double)gm_atanf((float)x); }
static WWINLINE float AtanfOrigin(float x) { return gm_atanf(x); }
static WWINLINE double ACosOrigin(double x) { return (double)gm_acosf((float)x); }
static WWINLINE float ACosfOrigin(float x) { return gm_acosf(x); }

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 reason f suffix math functions exist is for C. C does not support function overloading.

I do not agree with your arguments for dangerous overloads. Overloading is very common in C++ and is desired to call the right function for the right type. Programmer does not need to remember to call f version for floats.

auto f1 = getValue();
auto f2 = acos(f1); // function overload picks the right version for the supported float type

Comment thread Core/Libraries/Include/Lib/BaseType.h Outdated
Real x, y, z;

Real length() const { return (Real)sqrt( x*x + y*y + z*z ); }
Real length() const { return (Real)Sqrt( x*x + y*y + z*z ); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

And (Real)sqrt( x*x + y*y + z*z ); was calling double sqrt(double) ?

Real Sin(Real x)
{
return sinf(x);
return WWMath::Sin_Trig(x);

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 is the point of moving the function body to WWMath, when it is just meant to be called through this trig file? Better keep it simple and just do it in here. No trampoline to WWMath.

@Okladnoj

Copy link
Copy Markdown
Author

Hi @xezon! Thanks for the detailed review. I agree with some of your points regarding code cleanliness (I will remove the Ceil/Floor wrappers for the renderer).

However, there are a couple of critical architectural points concerning the preservation of old replays (suffixes) and determinism (Trig.cpp) that I want to clarify before pushing changes.

1. C++ Overloads vs Explicit types (why suffixes are needed)

I want to explain why I had to come to an explicit separation of functions via suffixes instead of using C++ overloads. This is tied to the necessity of preserving 100% backwards compatibility for old builds (VC6 Retail Compatibility).

I introduced 3 types of functions because they reflect 3 completely different mathematical paths (math paths) in the original EA engine. Our codebase serves three build modes at once (VC6, Win32, and Deterministic), and if we don't strictly fix the paths, we will lose Retail compatibility on old compilers:

  1. Without suffix (WWMath::Cos): This is the original Westwood Math implementation. In the original game on VC6/Win32, it compiles into inline x87 asm (fcos).
  2. With _Trig suffix (WWMath::Cos_Trig): This is a replacement for the global Cos() function from Trig.cpp. In the original game on VC6, it called the CRT function cosf() (not fcos!). The difference in the lowest bits between fcos and cosf() is critical: if I merge them into a single function without a suffix, the retail build will start calling fcos instead of cosf(), and the original logic will break.
  3. With _Origin and f_Origin suffixes (ACos_Origin vs ACosf_Origin): These replace direct system calls to acos(double) and acosf(float) in GameLogic. The deterministic library GameMath provides only float versions. My double version is forced to do a narrowing cast: (double)gm_acosf((float)x).
    The original EA code often passed variables of type float into system functions expecting double (e.g., acos()), relying on automatic type promotion by the compiler.
    If I switch to C++ overloads (just ACos), then when passing a float, the compiler will automatically pick the float overload. This will change the original math path (instead of calling the double version with narrowing, it will call the pure float version).

Explicit suffixes strictly lock the original execution path. They guarantee that the exact function intended in the original game is called, avoiding unpredictable compiler behavior during overload resolution.

Examples (The mechanics of overload conflicts)

Here is, with examples, how the overload mechanism breaks the original branches when compiling under VC6:

Example A: Conflicting identical signatures (_Trig)
In the original game, we had two different math paths that took the exact same type (float), but executed different instructions:

  1. The original WWMath::Cos(float) → compiled into fcos (inline asm).
  2. The original Trig::Cos(float) → compiled into cosf() (CRT).

C++ overloads only work with different argument types. How is the compiler supposed to know which of the two Cos(1.0f) calls should go to assembler, and which should go to the system CRT, if their signatures are absolutely identical? It can't.
If we remove _Trig and leave only WWMath::Cos(float), then in the VC6 build, all code from the former Trig.cpp will start invoking the fcos assembler instead of the original cosf(). The math is broken.

Example B: Path substitution via typing (_Origin)
On the calling code side in GameLogic, EA often wrote like this:

float myVal = 0.5f;
float result = acos(myVal); // In the original, this is a call to <math.h> double acos(double)

Since acos in C accepted a double, the compiler did an implicit cast: float -> double -> acos(double) -> float.

What happens if we introduce the overloads WWMath::ACos(float) and WWMath::ACos(double)?
The call to WWMath::ACos(myVal) will see the float type. The C++ overload mechanism will directly call the float overload, completely ignoring the original path with promotion to double. The VC6 logic is broken! The explicit suffix ACos_Origin(double) takes away the compiler's right to choose and strictly forces the original math path.

2. Sqrt(double) in BaseType.h:391

And (Real)sqrt( x*x + y*y + z*z ); was calling double sqrt(double) ?

Yes, in the original game it fell back to the system CRT double sqrt(double). But the problem is that Coord3D::length() is actively used in game logic (it participates in physics and CRC calculations). If I leave the system double, we will have discrepancies between Mac, Win32, and VC6. I have to forcibly cast it to deterministic float (at the cost of precision loss) to guarantee cross-platform sync.

3. "Trampolines" in Trig.cpp

What is the point of moving the function body to WWMath... No trampoline to WWMath.

The fact is that I was acting exactly according to your original task from the previous PR (#2602).
You wrote then: "Generally it is a bad sign if simplifying code would break something. If so, it needs to be fixed", and asked me to physically delete the old Trig.cpp files, migrating everything to WWMath.

I did exactly that. But stephanmeesters discovered that completely deleting trig.h breaks the VC6 / Win32 compilation (over 120 files are affected due to implicit includes).
To save the VC6 build, I had to restore the old Trig.h interface.

But I moved the implementation itself to wwmath.h to fulfill your requirement for math consolidation. If I write #if USE_DETERMINISTIC_MATH directly inside Trig.cpp, I will have to do it twice (since there are two Trig.cpp files in the engine — in Generals and GeneralsMD).
The trampoline is a transitional compromise that allowed us to not break the VC6 build and to gather the deterministic logic strictly in one place, as we planned. In the second phase, when there is already a working system with deterministic math in the main branch, we can start looking for the best way to delete trig.h and fully rely on wwmath.

4. Duplicates (Ceil / Floor)

Regarding Ceil and Floor — here I completely agree with you.
Since these functions (along with their original EA versions) are used exclusively in rendering (e.g., in visrasterizer.cpp) and do not participate in CRC calculations for network play, wrapping them in WWMath makes no sense.
I will completely remove these wrappers from wwmath.h and write direct calls to std::ceil / std::floor right at their call sites in the render code.

Comment thread cmake/gamemath.cmake Outdated
@@ -0,0 +1,16 @@
# FORCE is required to guarantee cross-platform bit-exact determinism.
# Intrinsics would use platform-specific SIMD, breaking CRC parity between architectures.
set(GM_ENABLE_INTRINSICS OFF CACHE BOOL "Disable intrinsics for cross-arch determinism" FORCE)

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 shouldn't be needed, only intrinsics that match behaviour with the C functions are used and there are test cases that ensure this holds true.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How do we verify that?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

GameMath ships with a test for this that compares the intrinsic and none intrinsic versions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

GameMath has tests that check both code paths.

// GameMath only provides float-precision functions. All call sites pass float-width
// values, so the narrowing is lossless in practice.
#if USE_DETERMINISTIC_MATH
static WWINLINE double Sqrt_Origin(double x) { return (double)gm_sqrtf((float)x); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Game math provides double versions of all math functions unlike the original math lib you were using so this needs updating.

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 fixed that on my branch.

@xezon

xezon commented May 18, 2026

Copy link
Copy Markdown

Hi @xezon! Thanks for the detailed review. I agree with some of your points regarding code cleanliness (I will remove the Ceil/Floor wrappers for the renderer).

However, there are a couple of critical architectural points concerning the preservation of old replays (suffixes) and determinism (Trig.cpp) that I want to clarify before pushing changes.

It is a bit tough to fight through this much AI generated text. Please push the last state of the code and then I can take a look at it in Visual Studio and try to polish it up if it needs polishing. I expect this is faster than chatting about where to go with this. Generally, try to not trust the AI generated code too much. It generates code that is for machines, not humans.

@xezon xezon added Major Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour Platform Work towards platform support, such as Linux, MacOS labels May 18, 2026
@Okladnoj

Copy link
Copy Markdown
Author

It is a bit tough to fight through this much AI generated text. Please push the last state of the code and then I can take a look at it in Visual Studio and try to polish it up if it needs polishing. I expect this is faster than chatting about where to go with this. Generally, try to not trust the AI generated code too much. It generates code that is for machines, not humans.

I wrote every point personally — I only asked AI to format it properly, fix spelling, and translate it into English, exactly like I’m asking now, because my English is not very strong.

I personally worked through every point of that long text, so it would be better to read it carefully and understand the reasoning behind it — there is nothing unnecessary there.

The main point is that suffixes like _Trig and _Origin are physically necessary for us, because overloading cannot handle this task properly.

In the original project, before deterministic math was introduced, there were places with mixed math inside the game logic that affects the CRC. When USE_DETERMINISTIC_MATH is disabled, we need to support the old CRC calculation system, which means we need simultaneous _Trig and _Origin implementations.

If we could simply remove USE_DETERMINISTIC_MATH from the project, there would not be such a large-scale transformation and interweaving of math functions. But in the old mode, we support not only Win32, but also VC6 with its own assembly functions.

@Okladnoj

Copy link
Copy Markdown
Author

Hi @xezon! Thanks for the detailed review. I agree with some of your points regarding code cleanliness (I will remove the Ceil/Floor wrappers for the renderer).

@xezon
In short, I don’t think it can be explained much shorter or simpler than in that message.

The project’s math was not always written with a clean and transparent architecture — or at least not all parts of it were. Maybe this was even done intentionally to make it harder to reverse-engineer the CRC logic.

At the moment, all workflows build successfully, and all replays also play successfully both with deterministic math enabled and disabled.

Above, I sent a screenshot of your job, plus one additional replay run that I configured specifically to verify Win32.

@xezon

xezon commented May 18, 2026

Copy link
Copy Markdown

Ok fair comments. I was under the impression I was chatting with AI generated text because of all the polished formatting. Can you push the latest state to the branch that you have now? I would like to take a look at it in Visual Studio next.

Btw, Replay Check is currently broken. We need to wait until after that is fixed.

@Okladnoj

Copy link
Copy Markdown
Author

Ok fair comments. I was under the impression I was chatting with AI generated text because of all the polished formatting. Can you push the latest state to the branch that you have now? I would like to take a look at it in Visual Studio next.

Btw, Replay Check is currently broken. We need to wait until after that is fixed.

The branch is already up to date — I haven't made any changes since the last push, I was waiting for your feedback. Feel free to take the current branch and work on it in VS. If you need my help — push your changes and I'll pick up from there.

Regarding the broken Replay Check — the CI runner has no way to obtain the game data. I solved this by extracting a minimal set of files from the Steam distribution (no textures, audio, or GUI — just enough for replay verification), uploaded them as a release to a private repository (Okladnoj/generals-gamedata), and connected it to the workflow via a PAT secret (GAMEDATA_PAT). The CI downloads the data using gh release download, verifies SHA256, and uses it for replay check. You can see the configuration on the test branch: okji/test/deterministic-math-v2 — file .github/workflows/check-replays.yml. Feel free to adopt this approach — or give me access to your organization, and I'll create a similar private repo with the data and wire it up to your CI.

@xezon

xezon commented May 23, 2026

Copy link
Copy Markdown

The branch is already up to date

The last push in from 08 May

@xezon

This comment was marked as resolved.

@Okladnoj

Copy link
Copy Markdown
Author

Hi @xezon @OmniBlade @Caball009 @bobtista
Rebased onto current main, history is linear now.

The old history had a lot of dead ends, so instead of replaying all 96 commits I rebuilt it as 12. The tree is identical to what merging current main into the old head gives, so the diff itself is unchanged.

RETAIL=1 - OK

image

DET=1 - OK

image

2 reps (mac-win and win-mac sides)

1x1x2x2x2_rep_v5.zip

WWINLINE double WWMath::Atan(double x)
{
#if USE_DETERMINISTIC_MATH
return (double) gm_atanf((float)x);

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 and some of the other double overloads still using the float precision version?

value&=0x7fffffff;
return *(float*)&value;
#if USE_DETERMINISTIC_MATH
return (double)gm_powf((float)x, (float)y); // gm_pow diverges on x87, gm_powf is bit-identical

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Report this upstream with a test case and we can look into fixing it if possible?

@stephanmeesters stephanmeesters 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.

I think many files that do not contribute to the CRC are currently routed through WWMath unnecessarily. Review did not flag all cases please do a pass yourself

if (WWMath::Fabs(pos.X - bcX) > (beX + extent) ||
WWMath::Fabs(pos.Y - bcY) > (beY + extent) ||
WWMath::Fabs(pos.Z - bcZ) > (beZ + extent))
if (WWMath::Fabsf_Legacy(pos.X - bcX) > (beX + extent) ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be fabsf? Many times in this file

#endif

Bool reallyscale = (WWMath::Fabs(scale - ident_scale) > scale_epsilon);
Bool reallyscale = (WWMath::Fabsf_Legacy(scale - ident_scale) > scale_epsilon);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be fabsf?

if (light.Get_Flag(LightClass::FAR_ATTENUATION)) {

if (WWMath::Fabs(atten_end - atten_start) < WWMATH_EPSILON) {
if (WWMath::Fabsf_Legacy(atten_end - atten_start) < WWMATH_EPSILON) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be fabsf?

const static Vector3 offset_a = Vector3(WWMath::Cos(WWMATH_PI / 2), WWMath::Sin(WWMATH_PI /2 ), 0);
const static Vector3 offset_b = Vector3(WWMath::Cos(7 * WWMATH_PI / 6), WWMath::Sin(7 * WWMATH_PI / 6), 0);
const static Vector3 offset_c = Vector3(WWMath::Cos(11 * WWMATH_PI / 6), WWMath::Sin(11 * WWMATH_PI / 6), 0);
const static Vector3 offset_a = Vector3(WWMath::Cosf_Legacy(WWMATH_PI / 2), WWMath::Sinf_Legacy(WWMATH_PI /2 ), 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be cosf?

if (!ClampFix) {
offset_u = offset_u - WWMath::Floor(offset_u);
offset_v = offset_v - WWMath::Floor(offset_v);
offset_u = offset_u - WWMath::Floorf(offset_u);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be floorf?

float c,s;
c=WWMath::Cos(CurrentAngle);
s=WWMath::Sin(CurrentAngle);
c=WWMath::Cosf_Legacy(CurrentAngle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be cosf?

c=WWMath::Cos(CurrentAngle);
s=WWMath::Sin(CurrentAngle);
c=WWMath::Cosf_Legacy(CurrentAngle);
s=WWMath::Sinf_Legacy(CurrentAngle);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be sinf?

if (!ClampFix) {
CurrentStep.U -= WWMath::Floor(CurrentStep.U);
CurrentStep.V -= WWMath::Floor(CurrentStep.V);
CurrentStep.U -= WWMath::Floorf(CurrentStep.U);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If not participating in crc can be floorf?

@OmniBlade

Copy link
Copy Markdown

I think many files that do not contribute to the CRC are currently routed through WWMath unnecessarily. Review did not flag all cases please do a pass yourself

For maintainability isn't it better to just route everything? That way the standard is to use the routing functions for everything and you don't have to worry about if a calculation you are doing takes part in CRC or not to select the correct function to use.

@stephanmeesters

stephanmeesters commented Aug 20, 2026

Copy link
Copy Markdown

For maintainability isn't it better to just route everything? That way the standard is to use the routing functions for everything and you don't have to worry about if a calculation you are doing takes part in CRC or not to select the correct function to use.

It's certainly simpler but I don't think we can justify the performance penalty. I don't know if it's strictly true right now but I would expect anything in GameEngineDevice/ and WW3D2/ not to need GameMath.

@OmniBlade

Copy link
Copy Markdown

For maintainability isn't it better to just route everything? That way the standard is to use the routing functions for everything and you don't have to worry about if a calculation you are doing takes part in CRC or not to select the correct function to use.

It's certainly simpler but I don't think we can justify the performance penalty. I don't know if it's strictly true right now but I would expect anything in GameEngineDevice/ and WW3D2/ not to need GameMath.

Do we have benchmarks for the current iteration of this PR for with and without deterministic math enabled so the scale of the performance difference is established before we look at optimising?

The override was a precaution and never had a measurement behind it. A
Windows replay run built with intrinsics enabled produced CRC logs that
are byte-identical to the run with them disabled, so the override only
cost speed.
@Okladnoj

Okladnoj commented Aug 21, 2026

Copy link
Copy Markdown
Author

Hi @OmniBlade @stephanmeesters @xezon

All three double overloads that game logic actually calls — Pow, Atan, Atan2 — route through float for the same reason. The 32-bit Windows build sets the x87 precision control to _PC_24 (in setFPMode), so the fdlibm double routines lose the tail of the mantissa there, while ARM64 computes them at full precision. The gm_*f single-precision versions don't depend on the precision control and come out bit-identical on both platforms.

Measured, mac vs Windows under _PC_24:

Pow(0.4, 1.3)          3FD3727E49ADFAD4   vs   3FD3727E40000000
Pow(2.7, 11.9)         410096F75FA58808   vs   410096F7A0000000
Pow(187.66, -59.13)    2405E16D4F560B15   vs   2405E16BA0000000
Atan2(0.4, 1.3)        3FD31A9B43436DE1   vs   3FD31A9B40000000
Atan2(1, -1)           4002D97C7F3321D2   vs   4002D97C80000000

In game this showed up as: frame 1 on Akas Magic, object 312 rotating, dy=446C04C6, dx=C3A4DDB0 — the call resolved to gm_atan2(double, double) and gave 3FF4128C on mac against 3FF4128B on Windows.

Exp, Log and Log10 diverge the same way in that test, but their double versions aren't called from game logic, so they're left as they are.

I'll open an issue on GameMath with test cases if needed

@OmniBlade

OmniBlade commented Aug 21, 2026

Copy link
Copy Markdown

I thought using _PC_53 was discussed as with the current setting ALL returns in the game is done at 32bit precision in all cases on 32bit builds. _PC_24 should only be left in VC6 builds for retail compatibility. Seems like it was done in the belief that it speeds up calculations on the fpu. Because the 32bit ABI returns results in the x87 register for functions returning floating point values, any double returns anywhere will be truncated even if the code gen otherwise uses SSE for the maths and does everything at an appropriate precision.

@Okladnoj

Copy link
Copy Markdown
Author

I thought using _PC_53 was discussed as with the current setting ALL returns in the game is done at 32bit precision in all cases on 32bit builds. _PC_24 should only be left in VC6 builds for retail compatibility. Seems like it was done in the belief that it speeds up calculations on the fpu. Because the 32bit ABI returns results in the x87 register for functions returning floating point values, any double returns anywhere will be truncated even if the code gen otherwise uses SSE for the maths and does everything at an appropriate precision.

Yes, that's the mechanism: setFPMode sets _PC_24 unconditionally, not just in VC6. Because of the ABI, that truncates any double return in a 32-bit build.

We did try _PC_53. I have a math dump under both modes: under _PC_24 mac and win differ on 27 values, under _PC_53 on two, and both of those are Atan2, which has since been routed through float.

Back in July bobtista dropped _PC_24 and then restored it — his note says the replay desynced early without it. That was before several of our fixes, and I don't know whether that desync was on retail or on DET.

If we move to _PC_53, I'll have to run the whole test series again to confirm determinism in practice.

@OmniBlade

Copy link
Copy Markdown

Yes, that's the mechanism: setFPMode sets _PC_24 unconditionally, not just in VC6. Because of the ABI, that truncates any double return in a 32-bit build.

We did try _PC_53. I have a math dump under both modes: under _PC_24 mac and win differ on 27 values, under _PC_53 on two, and both of those are Atan2, which has since been routed through float.

Back in July bobtista dropped _PC_24 and then restored it — his note says the replay desynced early without it. That was before several of our fixes, and I don't know whether that desync was on retail or on DET.

If we move to _PC_53, I'll have to run the whole test series again to confirm determinism in practice.

I think we should move to that because it should give IEEE behaviour or close to on 32bit builds while still making double work as double. @bobtista would need to confirm but when I see replay desync I normally assume its retail which I would have expected needs to keep _PC_24. The other option is to just abandon double throughout the code base which is pretty much what retail does with that setting. If test cases could be provided for GameMath so we could investigate where in the code calcuations differ I'd also much rather try and fix the discrepancies upstream as results are supposed to match cross platform with the library.

@bobtista

Copy link
Copy Markdown

Yes, that's the mechanism: setFPMode sets _PC_24 unconditionally, not just in VC6. Because of the ABI, that truncates any double return in a 32-bit build.
We did try _PC_53. I have a math dump under both modes: under _PC_24 mac and win differ on 27 values, under _PC_53 on two, and both of those are Atan2, which has since been routed through float.
Back in July bobtista dropped _PC_24 and then restored it — his note says the replay desynced early without it. That was before several of our fixes, and I don't know whether that desync was on retail or on DET.
If we move to _PC_53, I'll have to run the whole test series again to confirm determinism in practice.

I think we should move to that because it should give IEEE behaviour or close to on 32bit builds while still making double work as double. @bobtista would need to confirm but when I see replay desync I normally assume its retail which I would have expected needs to keep _PC_24. The other option is to just abandon double throughout the code base which is pretty much what retail does with that setting. If test cases could be provided for GameMath so we could investigate where in the code calcuations differ I'd also much rather try and fix the discrepancies upstream as results are supposed to match cross platform with the library.

Yeah that desync was not retail, sorry that wasn't clear. Both experiments were under RETAIL_COMPATIBLE_CRC=0 with USE_DETERMINISTIC_MATH=1.
I tried the 53-bit behavior twice:

  1. Setting _PC_53 explicitly fixed the double discrepancies but broke single-precision paths such as gm_atan2f. Restored _PC_24 and routed the affected simulation call through Atan2f.
  2. Later, I stopped setting _MCW_PC, leaving the x87 at its default precision. In a 32-bit build replaying an x64 recording from the same tree, _PC_24 tracked to the recording’s own divergence floor at about frame 2600; without _PC_24, it diverged around frame 200.

You're right about double returns through the x87 ABI. The other side is that 53-bit x87 intermediates do not round like SSE/NEON binary32 operations. The float-path differences were bigger than the double-return problem.

GPT says "the synthetic dump was misleading in the second experiment: without _PC_24 it matched x64, while the real simulation still diverged around frame 200. I would use replay parity to decide this."
For the affected deterministic simulation paths, using the float GameMath variants produced reliable results. Upstream GameMath test cases are still worthwhile, but changing setFPMode should be a separate PR if this PR doesn't modify _MCW_PC or _PC_24 right?

@bobtista

Copy link
Copy Markdown

btw I mentioned it a few weeks ago - Div_Safe enables its zero-divisor guards under USE_DETERMINISTIC_MATH. BaseDefines.h undefines that macro when gmath.h is unavailable, so a RETAIL_COMPATIBLE_CRC=0 build without GameMath silently falls back to the unguarded divisions. Whether those guards are enabled is a retail-parity decision, not a GameMath-availability decision; the guard should be keyed to !RETAIL_COMPATIBLE_CRC.
Also the description still says that missing gmath.h is a compile error, while BaseDefines.h silently disables deterministic math. Either enforce the error or update the description.
The corresponding divide guards are missing from Generals(GeneralsMD has the guarded versions) at:

  • ActiveBody.cpp:664
  • DozerAIUpdate.cpp:533
  • BridgeBehavior.cpp:1118
  • SlavedUpdate.cpp:198

@Okladnoj

Copy link
Copy Markdown
Author

I think we should move to that because it should give IEEE behaviour or close to on 32bit builds while still making double work as double. @bobtista would need to confirm but when I see replay desync I normally assume its retail which I would have expected needs to keep _PC_24. The other option is to just abandon double throughout the code base which is pretty much what retail does with that setting. If test cases could be provided for GameMath so we could investigate where in the code calcuations differ I'd also much rather try and fix the discrepancies upstream as results are supposed to match cross platform with the library.

I ran the whole library on both platforms. Each function is called on the same
value several ways: through double, through float, and with the conversions in
both directions. For the two argument functions each argument is varied on its
own. On Windows the whole thing runs twice, under _PC_24 and under _PC_53.

Test and dumps: https://github.com/Okladnoj/GeneralsGameCode/blob/okji/test/deterministic-math-v2.2.8/tests/math-diff.txt

macOS against win32, differing lines:

                 _PC_24   _PC_53
through double       98        0
float -> double      11       16
plain float           0        1
total               109       17

Under _PC_24 everything that goes through double breaks. Under _PC_53 it is
the other way round: double precision matches completely, and the only thing
that breaks is widening a returned float to double — exactly what Pow, Atan
and Atan2 do. Worth noting separately: under _PC_53 erfc differs by one ULP
even in plain float, with no conversion involved.

Should we rework the math for _PC_53?

!!! _PC_24 has been tested by me personally over dozens of hours of
mac-windows network games. _PC_53 is a Pandora's box: for every function there
will be some combination of boundary arguments, or some chain of nested
calculations, where mac-windows determinism breaks. Places like that only turn
up in live play, at random.

Four divisions in Generals were left unguarded while Zero Hour already
routed the same places through WWMath::Div_Safe. Fallback values match
the Zero Hour side so the two games behave alike.
@Okladnoj

Copy link
Copy Markdown
Author

GameMath silently falls back to the unguarded divisions. Whether those guards are enabled is a retail-parity decision, not a GameMath-availability decision; the guard should be keyed to !RETAIL_COMPATIBLE_CRC.

There is no dependency on RETAIL_COMPATIBLE_CRC anywhere in WWMath today, and I would rather not introduce one for the sake of a single Div_Safe.

If that dependency really belongs there, then let's drop USE_DETERMINISTIC_MATH altogether and keep it to one macro rather than two.

The other two are done: the description now matches what BaseDefines.h actually does, and the four missing guards in Generals are in.

@OmniBlade

Copy link
Copy Markdown

I ran the whole library on both platforms. Each function is called on the same value several ways: through double, through float, and with the conversions in both directions. For the two argument functions each argument is varied on its own. On Windows the whole thing runs twice, under _PC_24 and under _PC_53.

Test and dumps: https://github.com/Okladnoj/GeneralsGameCode/blob/okji/test/deterministic-math-v2.2.8/tests/math-diff.txt

macOS against win32, differing lines:

                 _PC_24   _PC_53
through double       98        0
float -> double      11       16
plain float           0        1
total               109       17

Under _PC_24 everything that goes through double breaks. Under _PC_53 it is the other way round: double precision matches completely, and the only thing that breaks is widening a returned float to double — exactly what Pow, Atan and Atan2 do. Worth noting separately: under _PC_53 erfc differs by one ULP even in plain float, with no conversion involved.

Just looking at your results it seems that _PC_53 differs less from macOS 64bit than does _PC_24.

I'm arguing that Pow. Atan and Atan2 with double returns should be using the double versions of the GameMath functions and would presumable work correctly under _PC_53?

When you say erfc at float you mean gm_erfcf right? Tricky for me to test this as I only have x86 available so it would be interesting if the same discrepancy exists between x86 32bit and 64bit and if 64bit windows matches macOS. At a guess based on disassembly in godbolt, at -O2 msvc doesn't load the results from gm_expf into the sse registers and instead uses x87 to multiply the results together resulting in intermediate precision being higher than it should be.

Should we rework the math for _PC_53?

I would say yes if we want to use the double type, otherwise refactor to only use float math throughout the code base. On RETAIL setting _PC_24 basically makes the game behave exactly like that had been done and the double variables throughout the code base are basically wasting memory. On modern is more complicated at a lot of the math will be done at the correct precision due to favouring SSE2 instructions, but returns will be incorrectly dropping precision for double returns. This will also affect the internal behaviour of GameMath itself, any internal calls will have precision dropped potentially causing differences even before the precision is dropped at the final return.

@OmniBlade

OmniBlade commented Aug 24, 2026

Copy link
Copy Markdown

Further to my last comment, its seems setting /fp:strict makes the code gen in godbolt look more correct, might be worth testing msvc with that set and see how it compares to macOS then with _PC_53

Edit: My bad, it also requires a few changes to gm_erfcf to change the code gen, but might be worth checking behaviour with just the fp mode set.

@Okladnoj

Copy link
Copy Markdown
Author

Hi! @OmniBlade

I ran the whole matrix: x86 and x64, /fp:precise and /fp:strict, and on x86
each of those under both precision control settings. I also added arithmetic on
the results — r1*r2, 1/r1, r1*r2+r3 — since the previous test only called
the functions and wrote the result down without feeding it into anything.

Against macOS, out of 2904 rows:

                  _PC_24   _PC_53   x64
through double       312        0     0
float -> double       11       16     0
double -> float       15        0     0
plain float            0        6     0
total                338       22     0
  • x64 matches macOS byte for byte, under both /fp models.
  • /fp:strict does not change a single row anywhere.
  • All six plain float differences under _PC_53 are in gm_erfcf. I could not
    find any call to it in the current math code.
  • What differs most between _PC_24 and _PC_53 are the rows where a result is
    fed into arithmetic rather than just stored. Almost all of them are double.
  • The eleven float -> double differences under _PC_24 are among the sixteen
    under _PC_53.
math-summary.txt
Differing lines against mac-arm64-clang, by row kind.

row kind             rows       x64-prec     x64-strict  x86-prec-PC24 x86-strict-PC24  x86-prec-PC53 x86-strict-PC53
------------------ ------ -------------- -------------- -------------- -------------- -------------- --------------
through double       1194              0              0            312            312              0              0
float -> double       258              0              0             11             11             16             16
double -> float       420              0              0             15             15              0              0
plain float          1032              0              0              0              0              6              6

total                2904              0              0            338            338             22             22

.d                    204              0              0             51             51              0              0
.d2f                  204              0              0             12             12              0              0
.dd                    54              0              0              8              8              0              0
.dd2f                  54              0              0              1              1              0              0
.df                    54              0              0              8              8              0              0
.df2f                  54              0              0              1              1              0              0
.f                    258              0              0              0              0              1              1
.f2d                  258              0              0             11             11             16             16
.fd                    54              0              0              8              8              0              0
.fd2f                  54              0              0              0              0              0              0
.ff                    54              0              0              8              8              0              0
.ff2f                  54              0              0              1              1              0              0
.inv.d                258              0              0             59             59              0              0
.inv.f                258              0              0              0              0              1              1
.mad.d                258              0              0             94             94              0              0
.mad.f                258              0              0              0              0              2              2
.mul.d                258              0              0             76             76              0              0
.mul.f                258              0              0              0              0              2              2

Row suffixes: .d double function, .f float function, .f2d float function
with the result widened to double, .d2f double function narrowed to float. For
two argument functions .dd, .df, .fd, .ff vary each argument between a
full double and one that has been through a float, and .mul, .inv, .mad
are r1*r2, 1/r1 and r1*r2+r3 over the results.

math-diff.txt

Test and dumps: https://github.com/Okladnoj/GeneralsGameCode/tree/okji/test/deterministic-math-v2.2.8/tests

@OmniBlade

Copy link
Copy Markdown

So I've done more research into this and it seems that any floating point maths done in a single expression involving a floating point function return is subject to optimisation to use the x87 FPU. /fp:strict should prevent this but doesn't appear to. I expect that if you build the code at /Od and use _PC_53 the differences will vanish as this seems to be the ONLY way in MSVC to disable this optimisation globally.

To get the correct behaviour in optimised code, every return expression for a function returning a floating point value would need enclosing in brackets so return x * y; would become return (x * y); and any function call would need wrapping in a cast so float z = floatfunc(x) + floatfunc(y); would become float z = float(floatfunc(x)) + float(floatfunc(y)); or use C casts or static_cast.

Note that setting _PC_24 is a fragile fix and that any double math done in the way described above would then be subject to potentially differing results between platforms.

Correct solutions are in my opinion to sweep the code base to make the changes suggested to make MSVC happy or forget MSVC as a valid target for 32bit windows binaries post VC6 being dropped.

@Okladnoj

Copy link
Copy Markdown
Author

@Caball009 @xezon @bobtista

Benchmark: what the deterministic math costs, and which configuration costs least

Only two of the four combinations apply

The grid was run in full — every combination of argument type and x87 precision
control. But this is deterministic math, and there the type and the precision
control are not picked independently of one another. Two combinations apply:

  • all game logic in double with _PC_53
  • all game logic in float with _PC_24

The remaining cells are marked no det in the report. Their timings are real and
are left in the table, but they are not options for the game.

Today the game calls both the float and the double entry points, so either
precision setting fits only half of the calls. Those cells are marked partial.


Step 1. Call frequency

gm_fabsf is over half of all math calls and a few per cent of the time.
gm_sqrt is under four per cent of the calls and a fifth of the time.

Counters sit at the 52 places where the code reaches GameMath. A counter is keyed
on the gm_ function the call arrived at, not on the WWMath wrapper it came
through: WWMath::Fabsf and WWMath::Fabsf_Legacy both land in gm_fabsf.

Results: https://github.com/OKJID/GameClient/blob/6c6dff1e43f6d38e683af5542aaf8622f72780c0/tests

Two multiplayer battles on Ring Ring 8, eight slots, 44 and 69 minutes. From each
I took the stretch where the call volume has risen and holds: 20 and 22 minutes,
72 000 and 79 200 logic frames. Those boundaries were picked by call volume and
wall clock, not by what a call costs. Both profiles are kept side by side, so the
spread between the two battles is visible rather than averaged away.

Calls per logic frame:

function 44 min battle 69 min battle
gm_fabsf 16,472.5 15,639.9
gm_sqrtf 6,059.6 6,133.5
gm_powf 2,349.2 2,351.1
gm_sinf 1,423.2 1,398.8
gm_cosf 1,391.5 1,045.7
gm_sqrt 1,175.8 1,178.2
gm_atan2f 633.0 732.8
gm_lrintf 579.5 618.9
gm_acosf 153.4 144.2
gm_floorf 3.7 30.9
gm_asinf 1.5 1.0
total 30,242.7 29,274.9

gm_powf and gm_sqrt agree to within a fraction of a per cent across the two
independent battles.

The profile stores the counter readings as they are, as whole numbers; the rate
per frame is worked out by dividing where it is needed, so nothing is lost to
rounding along the way.

There are no nanoseconds at this step, deliberately. A call count is a property
of the game, the same on any machine and in any build.


Step 2. The benchmark

Set up here: https://github.com/Okladnoj/GeneralsGameCode/blob/53a81f86bbc829049d21fa223b6103c5eb482bfd/tests

  • bench_game_math.c — times every function in double and in float, GameMath
    against the system library. Best of three rounds of a million calls. Arguments
    are read through volatile; without that the compiler folds the system calls
    at compile time and the comparison measures nothing.
  • run_bench_game_math.ps1 — runs the whole matrix on Windows in one command.
  • weigh_bench.sh — multiplies nanoseconds per call by the frequency from
    step 1.
  • profile_from_counts.sh — builds the profile from a raw counter dump.

Windows, from an ordinary shell — no Developer Command Prompt needed, the script
finds the toolchain itself:

powershell -ExecutionPolicy Bypass -File tests\run_bench_game_math.ps1
powershell -ExecutionPolicy Bypass -File tests\run_bench_game_math.ps1 -Reverse

It walks x86 and x64, each in two compiler modes — /fp:precise and
/fp:strict. On 32-bit x86 the table runs twice, under _PC_24 and under
_PC_53. -Reverse swaps those two runs round; both orders sit side by side in
the report. Builds happen in a temporary directory outside the repository and are
removed afterwards.

macOS:

cc -O2 -ffp-contract=off -Wno-macro-redefined tests/bench_game_math.c \
   -I build/macos/_deps/gamemath-src/include \
   build/macos/_deps/gamemath-build/libgm.a -lm -o bench_game_math
./bench_game_math

Then, from the tests directory:

sh weigh_bench.sh

Nanoseconds per call, win32 x86, /fp:precise, before frequency is taken into
account:

function double, ns float, ns
sqrt 60.5 25.9
pow 73.8 32.8
acos 42.9 21.7
atan2 36.4 22.4
lrint 15.0 3.9
fabs 8.2 3.8
sin 8.1 11.6
cos 8.1 11.3

Only sin and cos came out faster in double. For the other seven it is the
other way round, and for sqrt and pow the difference is exactly twofold.


Results

Generated here: https://github.com/Okladnoj/GeneralsGameCode/blob/53a81f86bbc829049d21fa223b6103c5eb482bfd/tests/bench-weighted.txt

Milliseconds per logic frame. The same set of calls and the same number of them —
the only thing that changes is which version of the function they go through: as
the game does it today, all double, all float.

configuration                 as the game         all double          all float  fastest valid
----------------------  -----------------  -----------------  -----------------  -------------
mac-arm64-clang            0.6295 ok          1.1881 ok          0.5258 ok       all float
x64-prec                   0.3413 ok          0.7762 ok          0.2920 ok       all float
x64-prec-rev               0.3420 ok          0.7763 ok          0.2923 ok       all float
x64-strict                 0.3362 ok          0.7495 ok          0.2906 ok       all float
x64-strict-rev             0.3377 ok          0.7420 ok          0.2935 ok       all float
x86-prec-PC24              0.4198 partial     0.8074 no det      0.3791 ok       all float
x86-prec-PC24-rev          0.4221 partial     0.8226 no det      0.3789 ok       all float
x86-strict-PC24            0.3976 partial     0.7691 no det      0.3567 ok       all float
x86-strict-PC24-rev        0.3938 partial     0.7730 no det      0.3510 ok       all float
x86-prec-PC53              0.4150 partial     0.7990 ok          0.3751 no det   all double
x86-prec-PC53-rev          0.4170 partial     0.8025 ok          0.3751 no det   all double
x86-strict-PC53            0.3957 partial     0.7820 ok          0.3519 no det   all double
x86-strict-PC53-rev        0.3952 partial     0.7822 ok          0.3515 no det   all double

The game builds with no explicit /fp flag, that is with the MSVC default,
/fp:precise. Two cells of that row apply:

configuration ms per frame
float + _PC_24 0.3791
double + _PC_53 0.7990

float + _PC_24 is 2.11 times faster than double + _PC_53.

The ratio holds across both battles and both run orders: 2.108 and 2.118 on the
44 minute one, 2.122 and 2.133 on the 69 minute one.

Without the frequencies the same comparison gives 1.90 — that is the nanoseconds
summed over the nine functions in use, each counted once. With the frequencies
from step 1 it comes to 2.11. sqrt makes the difference: it is both the most
expensive in double and one of the most frequent.

@xezon

xezon commented Aug 28, 2026

Copy link
Copy Markdown

Nice tests. The performance advantage speaks for using float _PC_24.

@OmniBlade

OmniBlade commented Sep 2, 2026

Copy link
Copy Markdown

We could look at using CPU intrinsics for sqrt as its supposedly had defined results under the IEEE standard.

Also, why is the arm code so slow compared to the x86 code? Being run on a slower CPU?

I'd like to see comparison on the C Runtime math vs game math as well.

@OmniBlade

Copy link
Copy Markdown

Nice tests. The performance advantage speaks for using float _PC_24.

Are you reading the same table I am? Doesn't look like it makes much difference to performance but overall float precision is faster if all math is done at that precision.

@Okladnoj

Okladnoj commented Sep 2, 2026

Copy link
Copy Markdown
Author

Hi @OmniBlade

BUILD_WITH_INTRINSICS appears in one place in GameMath at pin 59f7ccd
common/math_private.h:24. It enables USE_SSE on Windows x86/x64 and on
clang/gcc for i386/amd64.

USE_SSE is used in four files:

  • float/s_lrintf.c
  • float/s_llrintf.c
  • double/s_lrint.c
  • double/s_llrint.c

win32 x86, /fp:precise, nanoseconds per call:

GameMath CRT
sqrt double 60.5 2.6
sqrt float 25.9 2.6

The machines the measurements were taken on:

macOS Windows
CPU Apple M3 Max, 12 P-cores + 4 E AMD Ryzen 7 4700U, 8C/8T
year 2023 2020
base clock 2.0 GHz
clock during the run up to 4.05 GHz on P-cores 3.80 GHz (189.98% of base)
RAM 48 GB 16 GB
chassis RedmiBook 14 II laptop
compiler Apple clang 17.0.0 MSVC 17.14.33
OS macOS 26.5.2 Windows 11, build 26200

Nanoseconds per call, GameMath:

function mac ARM64 win x86
sqrt double 126.4 60.5
sqrt float 38.2 25.9
pow float 68.4 32.8
acos double 85.0 42.9
sin double 12.2 8.1
cos double 11.9 8.1
atan2 double 21.8 36.4
rint double 5.2 15.0
fabs double 1.6 8.2

Milliseconds per logic frame, the same calls at the same counts, costed through
the system entry points and through the GameMath ones:

                                as the game            all float
configuration           GameMath    system  GameMath    system
----------------------  -------- ---------  -------- ---------
mac-arm64-clang           0.6295    0.0285    0.5258    0.0283   x22.11 / x18.55
x64-prec                  0.3413    0.0734    0.2920    0.0725   x4.65 / x4.03
x64-prec-rev              0.3420    0.0756    0.2923    0.0748   x4.52 / x3.91
x64-strict                0.3362    0.0982    0.2906    0.0980   x3.42 / x2.97
x64-strict-rev            0.3377    0.0989    0.2935    0.0985   x3.41 / x2.98
x86-prec-PC24             0.4198    0.2712    0.3791    0.2711   x1.55 / x1.40
x86-prec-PC24-rev         0.4221    0.2726    0.3789    0.2728   x1.55 / x1.39
x86-strict-PC24           0.3976    0.4343    0.3567    0.4344   x0.92 / x0.82
x86-strict-PC24-rev       0.3938    0.4409    0.3510    0.4410   x0.89 / x0.80
x86-prec-PC53             0.4150    0.2730    0.3751    0.2730   x1.52 / x1.37
x86-prec-PC53-rev         0.4170    0.2718    0.3751    0.2719   x1.53 / x1.38
x86-strict-PC53           0.3957    0.3065    0.3519    0.3068   x1.29 / x1.15
x86-strict-PC53-rev       0.3952    0.3074    0.3515    0.3077   x1.29 / x1.14

Per function, win32 x86 /fp:precise, nanoseconds per call:

function GameMath double CRT double GameMath float CRT float
sqrt 60.5 2.6 25.9 2.6
pow 73.8 28.9 32.8 29.2
acos 42.9 11.6 21.7 11.6
atan2 36.4 155.8 22.4 155.8
lrint 15.0 3.9 3.9 4.0
fabs 8.2 2.6 3.8 2.6
sin 8.1 13.2 11.6 13.2
cos 8.1 13.1 11.3 13.1

Under /fp:strict the system sqrt changes with the x87 precision control:
23.9 ns at _PC_24 against 6.1 ns at _PC_53. Under /fp:precise it is 2.6 ns
in both modes.

@OmarAglan

Copy link
Copy Markdown

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7a4f6f1b7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{

#if RETAIL_COMPATIBLE_CRC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the non-retail unavailable sentinel

When RETAIL_COMPATIBLE_CRC=0 (which is required to enable the new deterministic-math mode), under-construction objects skip startPowerRecharge(), so removing this branch leaves m_availableOnFrame at zero instead of 0xFFFFFFFF. Consequently, isReady() treats their non-shared special powers as immediately available until the creation callback initializes the timer; restore the conditional initialization in both the Zero Hour and mirrored Generals implementation.

Useful? React with 👍 / 👎.

#define DEFINE_WEAPONSLOTTYPE_NAMES

#define NO_DEBUG_CRC
//#define NO_DEBUG_CRC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep deep CRC dumping disabled in ObjectCreationList

In a DEBUG_LOGGING build, commenting out this definition lets CRCDebug.h define DEBUG_CRC, activating the 27 DUMP* calls later in this file. Object creation then constructs and potentially records large amounts of deep-CRC diagnostic data, adding unintended overhead and noise to release-logging/replay experiments; restore NO_DEBUG_CRC unless this instrumentation is explicitly requested.

Useful? React with 👍 / 👎.


// once we've ceiled/floored, trunc and round are identical, and currently, round is faster... (srj)
#if RTS_GENERALS /*&& RETAIL_COMPATIBLE_CRC*/
#if RTS_GENERALS && RETAIL_COMPATIBLE_CRC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the correct rounding path for non-retail Generals

When Generals is built with RETAIL_COMPATIBLE_CRC=0, this new condition switches REAL_TO_INT_CEIL/FLOOR to the approximate helpers, which are incorrect at integral boundaries: fast_float_ceil(1.0f) produces 2.0f, while fast_float_floor(-1.0f) produces -2.0f. This directly breaks gameplay callers such as BridgeBehavior.cpp, where an even numObjects makes REAL_TO_INT_CEIL(numObjects / 2.0f) add an extra loop iteration, triggering the “Creating too many scaffold objects” assertion in debug builds and creating an extra scaffold in release builds.

Useful? React with 👍 / 👎.

#if USE_DETERMINISTIC_MATH
return gm_sinf(val);
#else
return (float)Sin((double)val);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Retain legacy trig when retail CRC mode is selected

With RETAIL_COMPATIBLE_CRC=1, deterministic math is disabled, but the float Sin and Cos overloads now delegate to the double CRT overloads instead of preserving their previous Win32 x87 implementations (or sinf/cosf on other platforms). Existing simulation callers such as Geometry.cpp, BuildAssistant.cpp, AISkirmishPlayer.cpp, and AIStates.cpp still invoke these float overloads, so their results can change by a bit and break the retail-CRC guarantee; select the legacy implementation in retail-compatible mode or migrate every such caller to the appropriate legacy entry point.

Useful? React with 👍 / 👎.

*/
Real minTurnRadius = (maxTurnRate > 0.0f) ? (maxSpeed / maxTurnRate) : 999999.0f;
// determine required turn radius based on our current speed and max turn rate
Real minTurnRadius = WWMath::Div_Safe(maxSpeed, maxTurnRate, 999999.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.

P2 Badge Preserve the zero-turn-rate fallback in native builds

When deterministic math is disabled—the default retail-compatible configuration—WWMath::Div_Safe performs an unguarded division, so replacing the prior maxTurnRate > 0.0f conditional here loses its explicit zero-rate handling. A delivery aircraft whose locomotor has a zero maximum turn rate now receives an infinite turn radius instead of 999999.0f, and that infinity propagates into timeToTravelThatDist; retain the caller-side conditional or make Div_Safe honor its fallback in every math mode.

Useful? React with 👍 / 👎.

@OmarAglan

Copy link
Copy Markdown

@codex Explain and summarize what this pr do?

@OmniBlade

Copy link
Copy Markdown

@Okladnoj Yes, the sqrt intrinsics aren't used in GameMath at the moment but looking at the results you provide it looks like that is low hanging fruit for significant performance increase presuming they can be shown to generate identical results.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Provided git ref 7a4f6f1b7c6585c0a2383667a39d9f3aea73ee5b does not exist
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

Labels

Gen Relates to Generals Major Severity: Minor < Major < Critical < Blocker Platform Work towards platform support, such as Linux, MacOS ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants