Math module internals

CNA snapshot 009d40f5  ·  Development › Module internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page); oracle-compared (recorded by CNA, not re-run here). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the TARGET sources, tests and recorded oracle corpora; no build or test was executed. The oracle corpora (matrix, bounding sphere, framework packing) are CNA's recorded outputs of the genuine XNA framework and cover only the functions named on the page. Everything else is unit-test or source-read evidence; deeper per-type ABI and oracle coverage, hash codes and structure size beyond Color remain unverified, and the C ABI library was not built.

modules/math/ owns CNA's XNA-shaped value layer: vectors, matrices, quaternions, colour, points and rectangles, bounding volumes, planes and rays, and curves. Its results flow into graphics transforms, effects, packed vertices, content serialization and the C ABI's value routes. It has no device, platform or lifecycle to initialise, so the maintenance risk is different from most modules: preserving evaluation order, packing and failure behaviour while changing arithmetic that looks harmless. This page is for maintainers who touch those numbers; the type-by-type reference is Math types, whose numerical-behaviour table summarises the same evidence at user level. Everything below was checked by reading the TARGET snapshot, its tests and its recorded oracle corpora; nothing was built or run for this page.

Physical module and API boundary

math/CMakeLists.txt builds cna_math (alias CNA::Math) through cna_add_module from a CONFIGURE_DEPENDS glob of src/*.cpp, seventeen files at TARGET, so it is a compiled static archive and not a header-only library. It links Sharp Runtime's Core.Base component PUBLIC (the exception types, the SharpRuntime integer aliases such as bytecs, intcs and UInt32, and the System::IEquatable interface base) and cna_core_headers PUBLIC. The core edge is headers only: the public headers use the CNAEXT marker, and Color.cpp includes core's inline Internal/PackedRounding.hpp; no core symbol is referenced, so the core archive stays off the link line (Core: boundary). The closure is a checked contract: ModuleProbes.cmake registers probe_math with a link-closure check, and the probe's link line may contain no other libcna_ archive, no SDL3, enet, FFmpeg or renderer library. A renderer may consume these values, but the module never selects a renderer or opens a window; a backend translates coordinates and precision at its own boundary rather than by changing math semantics to suit one API.

Public types live under modules/math/include/Microsoft/Xna/Framework. Most are held or returned by value; the only types that own mutable state are Curve and CurveKeyCollection, and there is no allocator, no explicit dispose and no resource to release, so the ownership questions are value copies, the mutable key collection and layout or serialized structure (ownership and lifetime master map). The families, and where each is implemented and tested:

FamilyImplementationMain tests (static definitions)
Vector2, Vector3, Vector4Vector3.cpp, Vector2.cpp, Vector4.cppVector3Tests (84), Vector2Tests (85), Vector4Tests (79), Vector3CompoundAssignmentTests (5)
MatrixMatrix.cppMatrixTests.cpp (85), MatrixOracleTests.cpp (3, over a 444-case corpus)
Quaternion, Plane, RayQuaternion.cpp, Plane.cpp, Ray.cppQuaternionTests (57), PlaneTests (32), RayTests (19)
BoundingBox, BoundingSphere, BoundingFrustumBoundingBox.cpp, BoundingSphere.cpp, BoundingFrustum.cppBoundingBoxTests (51), BoundingSphereTests (52), BoundingSphereOracleTests (4, over a 445-set corpus), BoundingFrustumTests (40)
Point, Rectangle, MathHelperPoint.cpp, Rectangle.cpp, MathHelper.cppPointTests (18), RectangleTests (35), MathHelperTests (55)
ColorColor.cppColorTests.cpp (61); the packing corpus is checked from the graphics tree
Curve, CurveKey, CurveKeyCollection and the enumsCurve.cpp, CurveKey.cpp, CurveKeyCollection.cppCurveTests (28), CurveKeyCollectionTests (21), CurveKeyTests (17), five small enum-identity files
Cross-type contractsheadersObjectEqualityContractTests (15: Equals(std::any) for the fourteen boxed-equality types), XmlSerializationEXTTests (4)

The test directory holds 27 files with 873 static GoogleTest definitions at TARGET (counted by macro; that is a definition count, not a count of executed cases, and nothing was executed). XmlSerializationEXT.hpp is a header-only, opt-in CNAEXT file that states each value type's member list once for System::Xml::Serialization::XmlSerializer (element names are the field names, in XNA's order, and Matrix writes M11 to M44 row by row). Including it is what pulls in Sharp Runtime's Xml.Serialization component, so the math archive itself does not depend on it. That component is not configured on a Windows target, and UnitTests.cmake then removes XmlSerializationEXTTests.cpp from the inventory as well. The text-form counterpart is the opt-in Framework.Design converters, a second public surface over the same types.

Layout and object shape

Which types are plain data and which carry a vtable matters to anything that copies bytes:

  • Matrix declares sixteen public float fields M11, M12, …, M44 in row order and no virtual members; Vector2/3/4, Quaternion, Plane, Ray, Point and Rectangle are likewise plain field structs with no base class. A new field in any of them changes layout for every consumer that copies these types, including the C ABI adapters and serialized data.
  • Color derives from IPackedVectorT<UInt32> and System::IEquatable<Color>, so it holds a vtable pointer before its packed word; BoundingBox, BoundingSphere, BoundingFrustum and CurveKey override Equals through IEquatable and are polymorphic as well. Only Color has a test that pins this (ColorTest.SizeIsLargerThanFourBytesVtablePresent), because casting a Color* to bytes for pixel I/O once wrote into the vtable; pixel buffers are plain byte arrays that construct a Color per pixel.
  • The C ABI never exposes the C++ layout. math_values.h defines row-major CNA_Matrix (m11…m44) and other plain structs, and core.h defines CNA_Color as four uint8_t channels r, g, b, a; the packed word is a separate route, cna_color_get_packed_value. The C header repeats the enum identities as the same numbers (CNA_CONTAINMENT_*, CNA_PLANE_INTERSECTION_*, CNA_CURVE_*), and the enum-identity tests pin the C++ side. The C routes live in CnaCApiMatrix.cpp, CnaCApiColor.cpp, CnaCApiGeometry.cpp, CnaCApiCurve.cpp, CnaCApiVectors.cpp and CnaCApiMath.cpp, and are exercised by the CApi_MatrixSmoke, CApi_ColorSmoke, CApi_GeometrySmoke, CApi_CurveSmoke, CApi_VectorSmoke and CApi_MathValuesSmoke tests when the experimental C API is built (see route families; that library was not built for this page).

Under the default-OFF CNA_ENABLE_UNITY_BUILD pilot, BuildPerformance.cmake compiles the math sources in six groups of at most three files, because six files deliberately use the same helper names in anonymous namespaces and would collide in one unity translation unit; test objects are batched by eight, and the seven math test files that use the name kEps get a per-file unique spelling. A new math source with anonymous-namespace helpers must be placed in a group where its names do not clash. See Core: boundary for the core half of that pilot.

Matrix convention, numerical order and graphics consequences

Row vectors, axes and the view and projection terms

The API uses the row-vector convention: a position is a row multiplied on the left, and translation lives in M41 to M43. Vector3::Transform(position, matrix) computes X from position.X*M11 + position.Y*M21 + position.Z*M31 + M41, and the other components from the next columns; TransformNormal is the same without the M4x term. Matrix::Multiply(a, b) is the ordinary row-by-column product, so v * (a * b) applies a first. The named-axis accessors in Matrix.cpp read rows of a world matrix: Right is (M11, M12, M13), Up is (M21, M22, M23), Backward is (M31, M32, M33), Forward, Down and Left are the negated rows, and Translation is row four. Matrix::CreateWorld(position, forward, up) fills those rows from the normalised axes and the position.

The view and projection builders read as follows at TARGET:

BuilderWhat the source does
CreateLookAt(position, target, up)The backward axis is normalize(position - target), the vector from the target back to the camera (not camera-to-target); the right axis is normalize(cross(up, backward)) and the camera up is cross(backward, right). The three axes fill the first three columns (M11, M21, M31 is right, M12, M22, M32 is up, M13, M23, M33 is backward) and M41 to M43 hold the negated dot products of each axis with the position, so the camera looks down −Z. It validates nothing: coincident position and target, or an up vector parallel to the view direction, normalise a zero vector and produce non-finite entries without an exception.
CreatePerspectiveFieldOfView(fov, aspect, near, far)Throws std::invalid_argument when fov <= 0 or fov >= 3.141593f, when either distance is not positive, or when near >= far. M22 is 1/tan(fov/2) and M11 is that divided by the aspect ratio; M33 is far/(near-far), M34 is −1, M43 is near*far/(near-far) and M44 is 0, which maps view-space depth −near to 0 and −far to 1. The aspect ratio is not validated.
CreatePerspective, CreatePerspectiveOffCenterThe same near and far validation and depth terms; width, height and the off-centre bounds are not validated.
CreateOrthographic, CreateOrthographicOffCenterNo validation at all; M33 is 1/(near-far) and M43 is near/(near-far), the same 0-to-1 depth convention.

Do not transpose a matrix, or swap the near and far terms, inside shared math to make one shader look right. GL-style consumers read a matrix as column-major storage, so the CNAEXT helper Matrix::ToColumnMajor(float out[16]) writes the fields in their declared order M11, M12, …, M44 and the graphics effects (BasicEffect.cpp is one caller) use it at the upload boundary. When an on-screen result looks transposed or depth looks inverted, inspect that renderer's upload and clip-space translation first, then the math.

Which arithmetic is deliberately wider than float

Several results are bit-for-bit reproductions of the XNA 4.0 runtime, and the comments and tests state where each choice was measured. XNA ships as a 32-bit assembly whose float arithmetic runs on the x87 unit, so an expression is accumulated at extended precision and rounded to float only when it is stored. CNA reproduces that with double intermediates in specific places:

FunctionEvaluation at TARGETRecorded measurement (from the source comments)
Matrix::Multiply(matrix, matrix)Both operands widened to double; each entry is ((a0*b0)+(a1*b1)) + ((a2*b2)+(a3*b3)) in double, narrowed once when stored168 products: a float accumulation reproduces 48, the wide form all 168
Matrix::InvertThe 2×2 minors and the four cofactors are float locals (narrowed); the determinant and its reciprocal stay double; every entry is one wide expression narrowed once52 inversions: all 52 reproduced; also narrowing the reciprocal reproduces 8, plain float throughout reproduces 3
Vector3::Transform(position, matrix)Each component is ((px*M1c) + (py*M2c)) + ((pz*M3c) + M4c) in double, narrowed once60 cases: float reproduces 8, the wide form all 60
Vector3::TransformNormalThe same shape without the translation term60 further cases, measured with the position transform
BoundingSphere::CreateFromPointsPrivate helpers SquaredLength, WideLength and WideDistance accumulate in double; the seed midpoint and the growth share are computed wide and stored as float445 point sets, see the bounding sphere

Three neighbouring choices are deliberately not wide, and the tests pin them from the other side: CreateRotationX/Y/Z narrow the sine and cosine to float before they reach the matrix; MathHelper::ToRadians is one single-precision multiplication by the single-precision constant 0.017453292519943295f; and ToDegrees multiplies by 180.0f / Pi in single precision rather than by the exactly rounded 57.29578. Everything else is plain float: Matrix::Multiply(matrix, scalar), Add, Subtract, Lerp, Vector3::Transform(vector, quaternion) and, importantly, Vector3::Length, LengthSquared, Distance and DistanceSquared, which sum their squares in float. The oracle-test comments record that XNA's own Length, Distance and DistanceSquared accumulate wide; only the private helpers inside BoundingSphere.cpp follow that rule, and no oracle covers the public Vector3 functions (see the evidence section). The MathHelper interpolators (Hermite, CatmullRom) also use double intermediates, for a different reason: their comments cite lost precision (and, for Hermite, a NaN in place of infinity for large amount), and Vector3::CatmullRom delegates to MathHelper::CatmullRom per component.

An optimisation that changes association, contracts a multiply-add (std::fma, compiler contraction), vectorises a dot product, or substitutes an algebraically equal formula can change the last bit and, downstream, rendered pixels. Read MatrixOracleTests.cpp and the corpus tests/reference/xna40/framework/matrix-oracle.txt before approving such a change. The corpus has 444 cases, and the test asserts the family counts: 168 multiplications (120 random and 48 of the cancel shape, a rotation against its own inverse and the conjugation A-1 T A that a scene transform performs), 42 rotations, 52 inversions, 60 position and 60 normal transforms, and 31 conversions each way. EveryMeasuredCaseAnswersTheSameBits compares raw IEEE-754 words, and ANarrowAccumulationWouldNotHavePassed is a negative control that fails unless a float accumulation disagrees with the corpus on more than 100 entries, so the cases cannot silently stop discriminating. CNA's corpus README records the files as output of the genuine XNA Game Studio 4.0 framework run under Wine, produced by run-matrix-oracle.sh; the values are CNA's recorded oracle and were not re-run for this page. A match on these cases is an empirical match, not a proof for every input, and where a function is not bit-exact by design a test should state its tolerance rather than assume none is needed.

Worked case: Decompose and the sign of a zero

This is a fixed, historical defect that shows how small a numeric detail can be. Matrix::Decompose takes each row's sign from the product of the row's four elements, tested with < 0.0f exactly as FNA's Math.Sign(x) < 0 does (Math.Sign is 0, not −1, for negative zero). An earlier version used std::signbit, which is true for −0.0. Every axis-aligned quarter turn has exact zeros in each row and (-1) * 0 is −0.0, so the sign flipped, the normalised 3×3 became a reflection, and Quaternion::CreateFromRotationMatrix of a reflection returned the identity: a 90-degree node silently lost its orientation. MatrixTest.DecomposeAxisAlignedQuarterTurnRecoversTheRotationRatherThanIdentity now asserts unit, positive scale and the rotation for all three axes. Its documented boundary is MatrixTest.DecomposeReportsPositiveScaleForAMirroredAffineTransform: the fourth-column element (M14, M24, M34) is part of each product and is zero for every affine transform, so the sign is always +1 and a mirrored transform decomposes to positive scales. A reflection has no rotation, so the quaternion that comes back is not a unit rotation (CreateScale(-1, 1, 1) gives (0, 0, 0, 0.7071) and true), and a sheared matrix also reports success. That is FNA's algorithm, not XNA 4.0's: the XNA IL negates the largest axis's scale when the basis determinant is negative and returns false for a shear, and the test's own comment attributes the FNA heuristic to XNA (CNA-BUG-259). Decompose is therefore not a mirroring test; use the sign of Determinant() of the 3×3 for handedness. It returns false with an identity rotation when any scale is within about 1.2e-7 of zero (the file-local epsilon described below).

Other numeric details that are easy to disturb

  • There are two epsilons. MathHelper::MachineEpsilonFloat is computed at static-initialisation time by halving until 1 + e compares equal to 1 in float, which gives 2−24 (about 5.96e-8) when float arithmetic is evaluated at float precision; MathHelper::WithinEpsilon tests |a - b| < MachineEpsilonFloat and is used by Curve::ComputeTangent, CurveKeyCollection::setItemProperty, the Hermite shortcuts and Ray. Matrix::Decompose instead calls a file-local WithinEpsilon in Matrix.cpp with the literal FLT_EPSILON (1.192092896e-07) and a <= test, twice as large; the source comment calls the two values “typically identical”, which they are not. The C ABI publishes CNA_MATH_MACHINE_EPSILON_FLOAT as the literal 5.96046448E-8F in math.h, and CApi_MathValuesSmoke compares the macro with that literal; the C++ tests only assert that MachineEpsilonFloat is positive and below 1e-4.
  • Matrix::ToString reproduces XNA's exact outer format with the extra spaces after the first and before the last brace, measured on the genuine importer; text that other layers compare or parse depends on it.
  • Hash codes are not XNA's integers. Matrix::GetHashCode sums per-float bit hashes, and BoundingBox combines Min and Max hashes in a platform-sized integer with a boost-style mix; treat them as consistent with Equals and nothing more.
  • CheckForNaNs throws std::logic_error on Vector2, Vector3 and Vector4 in every build, but on Matrix and Quaternion only when NDEBUG is not defined. No code in the snapshot, tests included, calls it.

Color packing and exceptional floating point

Color presents R, G, B and A but stores one 32-bit word in AABBGGRR order: R in bits 0 to 7, G in 8 to 15, B in 16 to 23 and A in 24 to 31. A renderer, image path or binding that treats that word as AARRGGBB swaps red and blue even though every getter looks right; ColorTest.RedPackedValueIsAabbggrr, BluePackedValueIsAabbggrr and CornflowerBluePackedValue pin the order. The routes that make a byte from something else deliberately use different rounding, and consolidating them behind one generic float-to-byte helper is the mistake to avoid:

RouteRule at TARGET
Color(float r, g, b[, a]), Color(Vector3), Color(Vector4), PackFromVector4Multiply the unit value by 255, saturate to 0–255, round to nearest with ties to even through CNA::Internal::ClampAndRound (core's PackedRounding.hpp), and map NaN to 0. Infinities saturate. The Vector3 form sets alpha to 255.
Color(int r, g, b[, a])Clamps each argument to 0–255; no rounding is involved.
Color(byte …), setPackedValuePropertyStored as given.
Lerp(a, b, amount)Clamps amount to 0–1 with MathHelper::Clamp, interpolates each channel in float over byte units, then truncates toward zero after a guarded narrowing (a non-finite value becomes 0 and the rest is clamped to ±1,000,000) and the integer constructor clamps to 0–255. The midpoint of black and white is therefore 127, not 128. A NaN amount is defined and yields 0 in every channel, alpha included (ColorTest.LerpWithNaNAmountIsDefinedNotUndefinedBehavior).
Multiply, operator*Scales each channel in float with the same guarded truncation; a negative scale clamps to 0.
FromNonPremultiplied(Vector4) and (int r, g, b, a)The float overload premultiplies RGB by W and then uses the rounding float constructor; the integer overload divides r*a by 255 with integer division. ColorTest.FromNonPremultipliedOverloadsAgreeWithXna shows both give (128, 0, 32, 128) for the same input.
ToVector3, ToVector4Each byte divided by 255.

The source comments contrast these rules with FNA: FNA's float constructor truncates (Color(new Vector4(0.25f, 0.5f, 0.75f, 1)) is {63, 127, 191, 255} there and {64, 128, 191, 255} in the measured XNA runtime), FNA's PackFromVector4 neither clamps nor rounds, and CNA follows the measured XNA runtime. The measurements are the 28 color/* cases of the 74-case framework-packing-oracle.json (its other cases are the packed-vector types and six bounding-sphere ties); XnaFrameworkPackingTests.cpp in the graphics tree reproduces every case and its CoversEveryMeasuredCase fails if a case is added without a reproduction. The same core helpers (RoundHalfToEven, ClampAndRound, UnpackUNorm, UnpackSNorm) serve fourteen integer-packed graphics types, so a change to them is a graphics change as well: RoundHalfToEven is written out with floor and a tie correction so it does not depend on the current floating-point rounding mode, and it casts through long long, which is why ClampAndRound screens NaN and out-of-range values first (casting NaN or an out-of-range float to an integer is undefined in C++). ClassicTextureFormatTests.cpp is a broader cross-layer check on texture formats that draw through Color. ColorTests, that corpus and the C ABI's CApi_ColorSmoke together are the evidence for this section; the ties-to-even claim rests on the recorded XNA measurements and unit tests, not on the site having run them.

Geometry and curve failure boundaries

The bounding volumes, Plane, Ray and Curve answer containment, intersection and interpolation questions independently of any renderer. ContainmentType (Disjoint 0, Contains 1, Intersects 2), PlaneIntersectionType (Front 0, Back 1, Intersecting 2) and the curve enums (CurveLoopType 0–4, CurveTangent 0–2, CurveContinuity 0–1) are public behaviour with fixed numeric identities that the C ABI repeats, not helper return codes. Error behaviour is specific to each function, and no single policy applies:

Failure map

WhereBehaviour at TARGET
Matrix::CreatePerspective*std::invalid_argument for a non-positive near or far distance, near >= far, and, for the field-of-view form, a field of view that is not strictly between 0 and 3.141593f.
Other Matrix builders, Invert, Plane::Normalize, Vector3::NormalizeNo validation: singular or zero-length input divides by zero and returns non-finite values.
BoundingSphere::CreateFromPointsstd::invalid_argument for an empty list.
BoundingBox::CreateFromPointsSystem::ArgumentException for an empty list — a different exception family from the sphere's.
BoundingBox::GetCorners(vector&) and BoundingFrustum::GetCorners(vector&)A destination smaller than eight elements throws System::ArgumentOutOfRangeException for the box and std::out_of_range for the frustum.
Array overloads of Vector2/3/4::Transform and TransformNormalstd::out_of_range for a negative index or length or a range beyond either array.
Curve::ComputeTangent(index, …), CurveKeyCollection index, RemoveAt, CopyTostd::out_of_range; the C++ code adds negative-index guards that the FNA reference does not have.
BoundingFrustum::Intersects(Ray)Partial: System::NotImplementedException when the ray origin lies exactly on a frustum plane (see below).
ColorNo exceptions: NaN, infinities and out-of-range inputs are defined (see above).

The C ABI's exception barrier turns these families into result codes (std::invalid_argument and std::out_of_range into invalid-argument results, std::logic_error into an invalid-state result, NotImplementedException into not-supported), so changing the exception type of a math function changes what a binding sees; the header of cna_bounding_frustum_intersects_ray documents the not-supported case. Inspect the target function and its tests before deciding what an invalid input should do.

BoundingSphere::CreateFromPoints

The growth algorithm is XNA's own and is the most heavily measured geometry code in the module. The seed pair is chosen by the distance between each axis's extreme points, not by the square of it (they differ where two squared spans round apart but their single-precision roots agree); ties are resolved with >=, so the later of two equally wide axes seeds the sphere (FNA uses >, and a right triangle's sphere comes out mirrored under that rule); the seed radius is half the pair's distance, not the distance from the midpoint to an end; and each point that lies beyond the radius sets the new radius to the mean of the old radius and the distance and slides the centre by 1 - r'/d of the difference. Every intermediate is stored as float and evaluated wide between stores. BoundingSphereOracleTests.cpp compares bits against tests/reference/xna40/framework/bounding-sphere-oracle.txt, 445 point sets in five families (200 seed, 60 grow, 150 step, 21 box, 14 span), with two negative controls (ChoosingTheAxisBySquaredSpanWouldNotHavePassed, TheFormerGrowthWouldNotHavePassed). The box family models a mesh's repeated control points, where whether a point that lies exactly on the sphere grows it is decided in the last bit. CreateFromFrustum builds the sphere from GetCorners().

BoundingFrustum

BoundingFrustum.cpp stores its matrix and derives six normalised planes in the order Near, Far, Left, Right, Top, Bottom plus eight corners, and recomputes both whenever setMatrixProperty runs. The near plane comes from the third column alone, which is consistent with the 0-to-1 depth convention above. The plane normals point outward, so a Front classification against any plane means “outside” and yields Disjoint. Three behaviours to keep in mind:

  • Contains(point) ends its plane loop at the first plane the point lies exactly on and reports Intersects, even if a later plane would have rejected it.
  • Intersects(Ray) answers no hit for any ray whose origin is outside the frustum, whatever its direction, a distance of 0 for an origin inside, and throws NotImplementedException for an origin exactly on a plane; a real entry distance is never computed. The three tests cover the inside and outside origins and the output-parameter form, not the boundary case. The user-level statement is on Math types.
  • Equality and hashing compare and hash only the stored matrix.

Curve, CurveKey and CurveKeyCollection

CurveKeyCollection::Add inserts a key in ascending position order (a key equal to an existing position goes after it, and duplicates are accepted), Clone re-adds every key, and setItemProperty replaces a key in place when the new position is within the machine epsilon of the old one and otherwise erases it and re-inserts it, so the index of the new key can change. CurveKey has no position setter, so a position changes only through the collection. The non-const indexer returns a mutable CurveKey&; Curve::ComputeTangent mutates keys through it, and any code that edits keys changes the interpolation path without touching Curve::Evaluate. Curve owns its collection by value.

Case in Curve::EvaluateResult
No keys / one key0 / that key's value
Before the first keyPre-loop: Constant the first value; Linear first.Value - first.TangentIn * (first.Position - position); Cycle, CycleOffset (shifted by cycle * (last.Value - first.Value)) and Oscillate map the position back into the key range
After the last keyPost-loop with the same modes; Linear uses last.Value + first.TangentOut * (position - last.Position) — the first key's out tangent, which CurveTest.EvaluatePostLoopLinear pins with its own comment
Inside the rangeCubic Hermite between the neighbouring keys using the previous key's TangentOut and the next key's TangentIn; if the previous key has Step continuity the previous key's value is held, subject to the first caveat below

Four details in the source deserve scoped caution; each was found by reading and none is exercised by a test. First, the Step branch returns the next key's value when the evaluated position is at least 1.0f and the previous key's value otherwise: the comparison is against the absolute constant 1, not against the segment's end, and the only Step test evaluates at 0.5 in a segment from 0 to 1. That branch follows FNA's Curve.cs and diverges from XNA 4.0, whose IL (Curve.FindSegment, Curve.Hermite) compares the segment-normalised parameter with 1, so XNA keeps the previous key's value until the segment's end; this was established by reading the IL, and no runtime oracle has compared the two. Second, ComputeTangent zeroes a Smooth tangent when the neighbour position span is near zero, with two different tests for the two tangents: the in tangent tests WithinEpsilon(pn, 0) (span below about 6e-8) and the out tangent tests |pn| < denorm_min. XNA 4.0's IL tests the neighbour value difference against 1.1920929E-07f for both, so this is FNA's code and diverges from XNA as well. The only smooth-tangent test uses equal values, so it cannot tell the formulas apart. Third, the cyclic loop modes divide by last.Position - first.Position with no guard, so a collection whose first and last key share a position turns an out-of-range evaluation into an infinity cast to int; XNA's IL caches an inverse time range that stays 0 unless the range exceeds float.Epsilon. Fourth, duplicate positions are accepted by Add and unguarded in Evaluate, which divides by the segment width; XNA's FindSegment yields parameter 0 for a segment narrower than 1e-10. A related divergence sits in the table above: the post-loop Linear case uses the first key's out tangent where XNA's IL uses the last key's, again FNA's behaviour. Treat all of these as behaviour to preserve or fix deliberately, with a test.

The module adds no locks (see the thread and callback map). Independent values may be computed on different threads; concurrent mutation of the same value, Curve or CurveKeyCollection is not a guarantee anywhere in the source. C ABI math routes marshal copies across the boundary, so a change to a field, an enum value, packing or an exceptional input needs the C header, its smoke test and binding review as well (C API internals).

How to modify it safely

  1. A transform bug. Write the smallest failing MatrixTest or Vector3Test case that names the row or column convention it relies on, then run MatrixOracleTests. Remember the limits of that oracle: it covers Multiply, Invert, the three rotation builders, position and normal transforms and the two angle conversions. It does not cover CreateLookAt, the perspective builders or Quaternion: MatrixTest.CreateLookAtM44IsOne checks only M44, the perspective tests check the thrown cases and M34, and the view and projection terms are exercised only indirectly by consumers in the content and graphics-ext test trees. For those, add a value-level test first, then a renderer pixel check.
  2. A colour change. Include 0, 1, halves and exact ties, NaN, infinities and a packed-word round trip; run ColorTests and, from the graphics tree, XnaFrameworkPacking and the texture-format tests. Keep the AABBGGRR order and the separate rounding of the constructors, Lerp and Multiply.
  3. A geometry change. Test inside, boundary or tangent, and outside cases plus degenerate arguments in the owning suite, and say in the test which exception family is expected.
  4. A curve change. Cover keys before and after the range, each loop mode and each tangent and continuity mode, including a case whose values are not all equal.
  5. A public type change. Add C ABI value tests and check the external binding marshalling, and consider the converters in Framework.Design: a property name, field or constructor change there is a second public surface. Also check XmlSerializationEXT.hpp.

Build and test routes: the unit-math build preset builds the focused CnaMathTests executable after cmake --preset unit (STUB renderer, Debug); it is EXCLUDE_FROM_ALL and is not a separate CTest registration, while the aggregate CnaTests discovers each case as its own CTest entry. The unit-core-math-unity preset builds core and math tests with the unity pilot. When outputs leave the module, run the aggregate and the consumer or renderer tests as well (Building: tests, Test architecture, I need to change public XNA behavior).

Self-review questions: did the change alter arithmetic association, contract a multiply-add or change a float conversion? Does Color's packed word stay AABBGGRR? Did a new enumerator or reordering change the C++ and C numeric identities? Can a serialized or foreign caller still read the same layout? Are the new tests true oracles or round trips, or do they compare a function with another implementation that changed with it?

What the evidence covers, and what it does not

Source-read at 009d40f5 and, where stated, recorded in CNA's own corpora: the row-vector convention, the view and projection terms, the wide-accumulation table, the Decompose case, the colour rules, the failure map and the frustum and curve behaviour. CNA's corpora record the genuine XNA framework's answers for the 444-case matrix corpus, the 445-set bounding-sphere corpus and the 28 colour cases, and CNA's tests compare CNA's output with them bit for bit; those are the only oracle-compared parts of this module, and whether the tests pass at TARGET was not executed here. The following carry no oracle at TARGET, and the deeper per-type ABI and oracle coverage remains open:

  • CreateLookAt, the perspective and orthographic builders, Quaternion, Plane, Ray, BoundingBox, BoundingFrustum and Curve: unit tests only.
  • The public Vector3::Length, LengthSquared, Distance and DistanceSquared, which accumulate in float although the recorded measurement says XNA accumulates wider.
  • Hash codes, ToString forms other than Matrix's, and exception types (the failure map records what the code does, not that XNA does the same).
  • Structure size and alignment beyond the Color vtable test, and the C ABI value routes, which were not built or run for this page.

Curated source and test route

  1. math/CMakeLists.txt, then Matrix.hpp: the target boundary, the public fields and the CNAEXT-marked members.
  2. Matrix.cpp (CreateLookAt, the perspective builders, Multiply, Invert, Decompose) and Vector3.cpp (Transform), then MatrixOracleTests.cpp and matrix-oracle.txt for the measured expectations.
  3. Color.hpp, Color.cpp, ColorTests.cpp and core's PackedRounding.hpp: packed layout, constructor rounding and the truncating interpolation.
  4. BoundingSphere.cpp with its oracle test, then BoundingFrustum.cpp and Curve.cpp with CurveKeyCollection.cpp: measured growth, stateful geometry and key interpolation; read the matching geometry and curve tests before generalising an edge case.
  5. math_values.h and CnaCApiGeometry.cpp: how values and failures cross the C boundary.

For where the module sits among the others see the module index; for task routes, the Maintainer Handbook.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.