Releases: mas-bandwidth/fixed
Release list
Production Ready
Deterministic Q48.16 fixed-point math, the same bits on every platform.
- fixNormalize lifts short vectors into range before dividing: results are unit within fixIsNormalized's tolerance at every input magnitude, down to a raw length of 1, with direction preserved bit for bit.
- The vector-layer reductions (fixDotRaw, fixFromDotRaw, fixDotQuat, fixMulQuat, fixInvMulQuat) join fixMul in spelling their expressions in bare native operators where __int128 exists, eliminating the seam-call stack traffic that dominates unoptimized builds — bit-identical on both arms.
fixed 1.3.2 — fixMul stops paying per inlined call at -O0
A pure speed change to unoptimized builds, and the frozen hashes prove it: every one in the repository is unchanged, on both 128-bit arms, so fixMul returns the same bits it always did.
What was slow. The 128-bit seam in v1.3.0 gave every 128-bit operation a named FIX_ALWAYS_INLINE function, and fixMul became three of them:
fixInt128 product = fixInt128MulI64( a, b );
fixInt128 r = fixInt128Shr( fixInt128Add( product, fixInt128FromI64( FIX_HALF ) ), FIX_FRACTION_BITS );"Are they inlined?" was the wrong question — both spellings are. always_inline substitutes a body; it does not optimize it. At -O0 the substituted body still materializes its operand and its result to a stack slot, so one expression on the most frequently executed function in the library became three inlined calls with stores and reloads between them. -O2 folds all of it, which is why optimized builds never showed the problem and only unoptimized ones regressed — measured at roughly 2x for a consumer's whole debug test run, and far worse under a sanitizer that instruments exactly that stack traffic.
What it is now. fixMul spells the rounding expression a second time in native operators, under the existing #if FIX_INT128_EMULATED arm selection. The emulated arm — plain MSVC, and anything built with FIX_FORCE_EMULATED_INT128 — is untouched: it is doing real work and has nothing to fold. Visual C++ support is unchanged.
The native spelling is a transcription of the seam's own native bodies rather than a second algorithm: fixInt128MulI64( a, b ) is (fixInt128)a * b, fixInt128Add( x, y ) is (fixInt128)( (fixUInt128)x + (fixUInt128)y ), fixInt128Shr( x, n ) is x >> n. The unsigned round trip in the add comes across with everything else — it is what keeps the overflow behavior defined and the two arms in agreement at the boundary. The saturation tail stays on the seam: off by default, cold when on.
Measured, on fixed3d's Debug suite with BOX3D_VALIDATE=ON, three interleaved runs each: 39.4 / 40.4 / 40.8 s before, 24.2 / 23.6 / 24.7 s after. About 40% off an unoptimized consumer's test run, and roughly four fifths of the regression that arrived with the seam.
Optimized builds do not move, and that is checked rather than asserted: building fixed3d at -O2 against the old and the new header produces 83 of 83 project object files byte-identical.
Bit-identity is the whole suite, not a spot check: every test binary on both arms, negative controls included, run before and after with their full output diffed — identical apart from the self-reported wall-clock lines. Clean under UBSan on both arms.
The seam header and USAGE.md now name this exemption where the "no bare 128-bit operator" rule is stated, so the count of exceptions stays visible.
The remainder of the unoptimized regression — the fixInt128 reductions in fixed_vec.h — is tracked in #18, which stays open.
fixed 1.3.1 — the 256-bit division goes by limbs
A pure speed change to fixUInt256DivMod, and the frozen hashes prove it: every one in the repository is unchanged, so the new divider agrees bit for bit with the old one everywhere this library divides.
What was slow. The 256-bit division was shift-subtract — one iteration per bit of the dividend, around two hundred, each a 256-bit shift, compare and subtract. Exact, and fine while nothing hot reached it. Something hot now does: a consumer storing inverse quantities at Q24.40 puts a 3x3 solve of 2^40-scaled values in a per-substep path, and cofactors of those values exceed the 128-bit arm's limit by construction, so every one of those solves lands here. It measured +503% on a joint-heavy scene.
What it is now. Knuth Algorithm D over 64-bit limbs: at most three 128-by-64 divides instead of two hundred bit steps. fixUInt128DivRemBy64 is the new seam primitive that makes it possible — the divq instruction on x86_64, shift-subtract on Windows targets that lack it (ClangCL does not link compiler-rt builtins), the compiler's divide elsewhere, and the existing emulated divide on the emulated arm. Plain MSVC needs nothing new.
The test found the thing the test was for. It was written first, with the old implementation kept inside it as the oracle — known-correct, already shipped, and sharing no code with the algorithm that replaced it, since one walks bits and the other walks limbs.
Then its coverage was measured rather than assumed, and the first version reached the add-back branch exactly zero times. Its hand-written "add-back vectors" looked plausible and tested nothing: that branch fires for roughly one input in 2^63 and cannot be reasoned onto by eye. Eight real vectors were found by instrumenting and searching, the generator that produced them is in the test too, and the negative control now removes the add-back specifically — so a sweep that stops covering it turns the control green, and a green control is a failure.
Coverage across the suite: 5,370 estimate corrections, 4,008 add-backs, 4,542 zero-shift normalizations, 1,214 maximum-shift normalizations, 37 equal-top-limb entries.
Clean under UBSan on both 128-bit arms, and the new suite runs on the emulated arm too — which is the only coverage the emulated 128-by-64 divide has.
fixed 1.3.0 — the inverse carries its full width
The 3x3 matrix inverse and solve are now correct at every magnitude, and say so when a result is not representable.
The bug. The inverse of a Q48.16 matrix is a ratio of two quantities that do not both fit in 128 bits — a cofactor reaches 126 bits, the determinant 190, an inverse numerator 158. The wide arm squeezed them in anyway, dropping 16 fraction bits from each cofactor and accumulating the determinant through an int64_t cast. Both steps were silent: the cast wrapped past 2^79 and the determinant overflowed shortly after, so past a certain size the function returned a value of arbitrary magnitude and arbitrary sign. For a physics consumer inverting an inertia tensor that means a positive-definite tensor inverting negative — a body that accelerates against its own torque.
The fix. fixed_int256.h carries the wide arm at a width that cannot overflow, built entirely on the fixUInt128 seam so it is native where __int128 exists and emulated where it does not. The exact arm's range test now names all nine cofactors rather than the three the determinant expands along. fixDivShifted saturates with the true sign instead of truncating to 64 bits. And fixSolve3's wide arm solves directly at 256 bits rather than inverting and multiplying, which kept one rounding where the old fallback took two through the coarsest value in the calculation.
The boundary is now documented and asserted. Q48.16 holds no value between zero and 1/65536, so the inverse of a matrix with entries past 65,536 is not a small number in this format — it is no number at all, and zero is the exact truncated answer rather than a failure. USAGE.md states where that line falls and what a zero result means; usage_test.c asserts it.
How it is held. inverse_envelope_test.c walks the magnitudes from cube side 1 to 500 — a factor of 10^12 in the cofactors — against an oracle that shares no arithmetic with the implementation, so it is an equality at every size rather than a tolerance. It was verified red against 1.2.1 (41 checks, including the sign checks). Its negative control is the historical bug restored verbatim rather than an invented perturbation, and it runs on the emulated 128-bit arm and under UBSan on both arms.
EXPECTED_GEOMETRY_HASH is re-captured deliberately; exactly two values move, both from the huge matrix in that suite, and every other value was checked bit-identical.
Also in this release since 1.2.1: 128-bit emulation making plain MSVC a supported compiler, the arbitrary-scale quantize and Q-format-crossing family, full test coverage of the geometric layer, and USAGE.md.
fixed 1.2.1 — the API override macros guard independently
Bug fix: defining FIX_API no longer suppresses the four other override macros — each macro now guards separately, with a dedicated override test (override_api_test.c) keeping it that way. Documentation polish rides along.
fixed v1.2.0 — the wide world-position family
Seven functions that let a consumer with 128-bit world positions actually use this library. Additions only — backward compatible, both frozen hashes unchanged.
`fixLerpPositionWide` · `fixTransformWorldPointWide` · `fixInvTransformWorldPointWide` · `fixMakeWorldTransformWide` · `fixMulWorldTransformsWide` · `fixInvMulWorldTransformsWide` · `fixToRelativeTransformWide`
Why
A consumer whose world positions are 128-bit cannot call the narrow forms at all — the parameter type is the thing that differs. Bounding volumes are built from world positions, so the two families have to cross a library boundary together or neither can.
The one that is not a mechanical widening
`fixLerpPositionWide` computes `a + t*(b-a)`, not `(1-t)a + tb`. Widening the narrow form directly would multiply an absolute 128-bit coordinate and overflow; the reformulation multiplies only the difference, which is in local range.
One rounding instead of two, so it is deliberately not bit-identical to the narrow build. Anyone running both widths carries separate goldens for it — the wide answer is the more accurate one.
Testing
Every function is called. The round-trip property is pinned against the narrow rotation round-trip rather than against the input: fixed-point quaternion rotate-then-inverse loses a couple of ULPs, a control measurement confirmed the wide route loses the same and adds none, so what is asserted is that the 128-bit translation contributes nothing.
Proven able to fail: swapping the lerp difference operands breaks four checks including monotonicity.
fixed v1.1.0 — the wide AABB operations a real consumer needed
Adds two wide-AABB operations. Additions only — backward compatible, both frozen hashes unchanged.
fixMakeAABBWideAt( points, count, radius, origin ) |
build a wide box from local points placed at a wide origin |
fixAABBWide_Transform( transform, box ) |
transform a wide box by a local transform |
How they were found
By trying to wire box3d's ludicrous build to this library and failing to compile. The wide AABB surface looked complete and was not:
fixMakeAABBWidetakes an array of wide points. Real consumers build boxes from local mesh and hull vertices placed at a wide origin — vertices stay Q48.16 even when the body sits a light-year out. That is a different function, not a different call.fixAABBWide_Transformdid not exist at all.
Inherited limitation, stated rather than papered over
The transform narrows the centre to local range, so a box whose centre exceeds Q48.16 saturates. Extents survive at any distance — that is the fixAABBWide_Extents fix from v1.0.0 — but a transform about a distant centre does not. This matches the behaviour it was ported from, so it is a faithful port and not a regression. A wide-centre formulation belongs in its own change with its own goldens.
Verification
Both new functions are exercised by test/aabb_test.c — a header-only function nothing calls is invisible to a passing build. Proven able to fail: dropping the abs from the rotation matrix breaks three checks, including the never-shrinks property that would matter in a broadphase.
fixed-core determinism 0x3e1c7997594d2019
wide boundary 0xeedc16ea642ffb5f
fixed v1.0.0 — the deterministic fixed-point core, standing on its own
A small, standalone, deterministic fixed-point math library. Extracted from the fixed-point conversion of Box3D and now a library in its own right, with its own namespace.
What is in it
- Q48.16 scalar (
fixed_t) with pure-integer arithmetic — mul, div, sqrt, abs, floor, ceil, clamp, conversions - Integer-only transcendentals —
fixComputeCosSin,fixAtan2,fixSin,fixCos,fixUnwindAngle. Correct against libm to cos/sin < 0.0017 and atan2 < 0.00004 rad - Vectors, quaternions, matrices, transforms, with validity predicates
- Q32.32 time (
fixTime), exact conversion to and fromfixed_t - Wide Q112.16 128-bit set — world coordinates sharing the scalar type's 16 fraction bits, so the boundary between wide world space and local space is an exact integer subtract rather than a rescale
- Bounding volumes and planes in both widths, exported unconditionally
Why it exists
Deterministic lockstep and client/server simulation require every machine to compute the same numbers from the same inputs. Floating point does not: fused multiply-add, libm transcendentals and -ffp-contract all differ across compilers and architectures. Fixed point removes the whole class of divergence.
The guarantee, checked in code
Results are bit-identical across arm64, x86-64, Linux, macOS and Windows. That is enforced, not asserted — frozen hashes in CI:
fixed-core determinism 0x3e1c7997594d2019
wide boundary 0xeedc16ea642ffb5f
plus a negative-control build carrying a deliberate 1-ulp error in multiply, which the test suite is required to FAIL. If that build ever passes, the suite has gone blind.
Naming
Every symbol carries a fix / FIX_ prefix. Nothing is named b3 — these types are not box3d's, and a consumer wraps them in its own vocabulary rather than sharing a namespace.
Two families that read alike are kept distinct: scalar fixMin/fixMax/fixAbs/fixClamp/fixLerp/fixMul, versus componentwise fixVecMin/fixVecMax/fixVecAbs/fixVecClamp/fixVecLerp/fixVecMul.
Provenance
Derived from Box3D by Erin Catto (MIT). Box3D's copyright and licence are retained and apply to all Box3D-derived material. See NOTICE.