Case Study: A Floating Point Bug That Hid for a Decade

In this latest blog from the developers on our Product Traction team, they share a case study regarding a bug that stayed hidden for a decade which suddenly broke in a Release build on macOS, and how they resolved it.

In legacy codebases, long-stable logic can suddenly go awry without a single line being changed. This case study explores such an incident, where rendering logic for football field yard numbers—untouched for over 10 years—suddenly broke in a Release build on macOS. The story takes us through symptom tracing, debugging paths, and architectural implications, ultimately revealing the subtle and unforgiving world of floating-point arithmetic.

Background

The project in question, EnVision Visual Performance Design by Box5 Software, is a design tool for marching bands that renders yard lines and their associated numbers on a football field in 3D using OpenGL. The relevant snippet that determines what number to draw is:

for (float x = -yardLineSize; x <= yardLineSize; x += 3)
{
  yardNumber = (yardLineSize - std::abs(x)) / 3;
  glCallList(fieldNumbersLists[yardNumber]);
}

With:

const float yardLineSize = 15.0f;

The intended logic was symmetrical. The yardNumber should increment from 0 at the edge of the field to 5 at the center and back to 0 again, which is later augmented to be 50, 40, 30 etc. to represent the yard lines of a football field. When the loop runs with x values of -15 to 15 in steps of 3, the desired output is:

yardNumber: 0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0

And that’s exactly what we had been seeing—until something changed.

The Bug Appears

After a decade of quiet operation, the Release build on macOS (built using modern versions of Xcode) began displaying incorrect yard numbers:

  • “20” was rendered as “10”

  • “10” appeared as “00”

This only occurred in Release mode and only on macOS, not in Debug builds nor on other platforms.

Initial suspicion fell on:

  • The image filenames being mismatched with their contents, which are used to render the number in 3D

  • Incorrect indexing into the OpenGL display list

  • Differences in build configurations

None of those proved to be the culprit.

The Logging Trail

Detailed logging was introduced:

yardLineSize: 15.000000
yardNumber: 0, x: -15.000000, abs(x): 15.000000, div3: -0.000000
yardNumber: 0, x: -12.000000, abs(x): 12.000000, div3: 1.000000
yardNumber: 1, x: -9.000000, abs(x): 9.000000, div3: 2.000000
...
yardNumber: 0, x: 15.000000, abs(x): 15.000000, div3: -0.000000

At a glance, everything looked fine—div3 printed as 1.000000, 2.000000, etc. But then came the “aha” moment.

Root Cause: Precision and Truncation

Although the floating-point values printed as whole numbers, the actual in-memory values were slightly less than the expected integers due to floating-point rounding error. When these values were cast to int, they truncated rather than rounded, causing:

(int)(0.9999999) == 0
(int)(1.9999999) == 1

Thus:

  • yardNumber was off by one in several iterations.

  • Debug mode kept extra precision and didn't expose the bug.

  • Release mode, with its aggressive optimizations and possible register-only arithmetic, surfaced the problem.

The Fix

The following line:

yardNumber = (yardLineSize - std::abs(x)) / 3;

was changed to explicitly round the result:

yardNumber = std::round((yardLineSize - std::abs(x)) / 3);

This eliminated the dependency on implicit casting behaviour and ensured correct integer values regardless of the compiler, platform, or build mode.

Why Now?

The code hadn’t changed in 10 years, so why did this break now?

Modern Compiler Toolchains

  • Aggressive Optimizations: Modern compilers (e.g., Clang in Xcode) use aggressive floating-point optimizations in Release builds.

  • Register Precision: Intermediate values are often held in extended precision registers and not flushed to memory, exposing subtle differences.

  • Architecture Differences: macOS’s transition to Apple Silicon may have shifted precision characteristics in floating-point units.

  • C++ Standards Evolution: New standards give compilers more leeway in reordering expressions or contracting floating-point operations.

This wasn’t new behaviour—it was latent incorrectness that had gone unnoticed for years due to luck and compiler leniency.

Lessons Learned

  1. Don’t Trust Truncation:
    Always explicitly round when converting from float to int unless truncation is the actual intent.

  2. Log Precisely:
    Print enough digits of precision in logs when debugging floating-point behaviour. What appears as 1.000000 might be 0.9999999.

  3. Test Release Builds:
    Never assume behaviour in Debug mode will match Release—especially for math-heavy rendering code.

  4. Legacy Code Doesn’t Mean Correct Code:
    Just because something hasn’t changed doesn’t mean it isn’t broken. It may simply have never been tested under the right (wrong) conditions.

Conclusion

This case reminds us of the deep complexity of floating-point arithmetic in real-world applications. Even simple division can betray assumptions if precision, casting, and rounding are not handled explicitly. A bug hidden for over a decade was finally revealed—not by human error, but by the inexorable march of toolchain progress.

And it was fixed, not by rewriting logic, but by being explicit about rounding—a small change that restored the intended behavior and stabilized the field once more.


The Thin Air Labs Product Traction team provides strategic product, design and development services for companies of all sizes, with a specific focus on team extensions where they seamlessly integrate into an existing team. Whether they are deployed as a team extension or as an independent unit, they always work with a Founder-First Mindset to ensure their clients receive the support they need.

To learn more about our Product Traction service, go here.