EveryMirrorFunctionMatchesCSharpMath's generic random/special-value cases
never happen to hit a point where a radius-2 cell wins (about 3.5e-5 of
points) or an integer point at or past 2^23, so an HLSL mutant of cellular's
search radius, its lower-bound prune, or the removed F2=0 guard could change
cultmath_cellular's real output and still pass this test.
Adds three targeted point cases, reusing CellularAndSminGradTests' own
fixtures so the mirror test does not carry a second copy of the construction:
- the six points from AxisAlignedRadiusTwoOffsets/FindPointRealizingOffset,
each of which realizes a different axis-aligned radius-2 offset as F1 or
F2 in the independent 7x7x7 oracle;
- four integer points at 2^24 (both signs), where distinct cells' jittered
features can round onto the same float32 value, reaching the F2 = 0 guard;
- one exact F1 = F2 tie point, the midpoint of two adjacent cells' feature
points (bit-exact-equidistant from both by construction, no search
needed).
Verified against four HLSL mutants applied to shaders/CultMath.hlsl on
scratch copies (not committed) and reverted after each check:
- `dz < 2` instead of `dz <= 2`: killed by the axis-slice point at
(-56.671272, -67.01973, -79.001).
- `dx` starting at -1 instead of -2: killed by the axis-slice point at
(62, -77.91331, -79.42451).
- pruning against f1 instead of f2: killed by an axis-slice point and both
of the 2^24 integer points (3 mismatches).
- the F2 = 0 guard removed (`grad2 = (p - c2) / f2`, unconditional): killed
by the (2^24, 2^24, 2^24) integer point (NaN vs the guarded zero).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CellularAndSminGradTests.cs changes for cut 2a-i's pass-2 findings:
- FeaturePoint and the two other independent pcg3d/pcg4d call sites now hash
int3(cell)/int4(int3(cellId), 0) to match production's integer-hashing fix
(previous commit); the oracle would otherwise diverge from cellular() and
every comparison test would fail.
- New JitterAndIdPassAChiSquareUniformityTest: jitter.x/y/z and id each pass
a chi-square uniformity test (16 bins, 15 df, critical value 37.7 at
alpha=0.001) over 200,000 cells. This is the behavioural pin for the
hashing fix; reverting to pcg3d(cell)'s float-bit hash fails it.
- Two false comments corrected to the measured facts: the
NearestFeatureWins test's claim that |component|=2 offsets are "provably
never the true nearest" is false (their lower bound is 1, not sqrt(3); Soul
measured 3 F1 wins in 2,000,000 samples), and SearchReachesEveryAxis...'s
claim of "lower bound is exactly 2" and "zero hits in 3,000,000" is also
false (lower bound is 1; Soul measured about 1 hit in 28,000).
- PrunedSearchIsBitIdentical... renamed to
...BelowTwoToTheTwentyFifth and its comment states that domain explicitly
(design.md, previous commit).
- GradientTolerance split into SminGradientTolerance (2.5e-4f) and
CellularGradientTolerance (1.3e-3f): the two functions have different
curvature (smin_grad is exactly quadratic in a band; cellular's F1/F2 are
real distance fields), so a shared 3e-4 tolerance carried almost no
headroom over either's actual measured noise (re-measured here at 5.3e-4
for smin's old eps, 2.0e-4/2.6e-4 for cellular), not the "~6e-5" the old
comment claimed. smin_grad's eps moves from 0.001f to 0.01f (no truncation
cost there; dilutes float noise 8x) with a matching widened seam-exclusion
band so p +/- eps cannot step across the seam the check at p already
avoided. cellular's eps stays at 0.0005f (a small sweep found it already
near the truncation/noise sweet spot for a real distance field). Both new
tolerances keep >5x headroom over their own measured worst case and both
still die under a 1.01x-scale mutant on the returned gradient — smin down
to 1.0005x, cellular (M9/M16-style, either gradient) down to 1.002x.
- GradientMatchesCentralDifferencesAwayFromTheF1EqualsF2Seam also excludes a
band around F2 = F3 now, not just F1 = F2: the second-nearest feature's
identity swaps there too, a genuine kink the old test could silently hit
(an unfiltered large sample found a 0.78 edge error this way, swamping the
~2e-4 the filtered test actually sees).
- AxisAlignedRadiusTwoOffsets and FindPointRealizingOffset are extracted from
SearchReachesEveryAxisAlignedRadiusTwoSlice as internal fixtures so
HlslSourceCompatibilityTests (next commit) can reuse the same construction
instead of a second copy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
design.md follow-ups matching the math.cs/HLSL fix in the previous commit:
- The F6 "5x5x5 is exact" argument is rewritten with the corrected near-side
neighbour bound (choose the axis closest to its own boundary, not a fixed
axis), the m <= 1/2 derivation, and the counterexample that showed the old
argument wrong. Same text as the math.cs comment, so the two do not drift.
- "Integer hashing" now states that cellular hashes the winning cell's
integer coordinate, not its float32 bit pattern, and records the measured
chi-square evidence (203.8 over 1,000,000 cells, 15 df, critical value
37.7) for why the old bit-pattern hash failed a uniformity check.
- "Precision domain" gets a new paragraph: the lower-bound prune's bit
identity to the unpruned 5x5x5 search holds for |p| < 2^25, not
unconditionally. Above that, cell + offset itself starts to round in
float32, so the pruned and unpruned loops can visit different actual
neighbours at the same nominal offset (Soul, cut 2a-i: 180 of 1,100,000
points differ at |p| ~= 3.4e7). This is a float32 domain limit on the
equivalence proof, not a bug in the prune; the committed test's [-50, 50]^3
sweep is comfortably inside it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul (cut 2a-i, pass 2) found two defects in cellular's own math.cs source
and its HLSL mirror:
- cellular hashed pcg3d/pcg4d over the FLOAT32 BIT PATTERN of each
integer-valued cell coordinate (pcg3d(neighbor), pcg4d(float4(cellId, 0)))
instead of the integer coordinate itself. Two adjacent cells' bit patterns
share more structure (same exponent, mantissa one increment apart) than
the integers do going into pcg3d's own mixing, and that structure survived
into the jitter: measured chi-square 203.8 for jitter.x over 1,000,000
cells against 15 degrees of freedom (critical value 37.7). Both call sites
now hash int3(neighbor)/int4(int3(cellId), 0) directly, in C# and in the
HLSL mirror (both shaders/CultMath.hlsl and the Unity copy, byte-identical
as before). This changes cellular's noise output — nothing has shipped, so
that is acceptable, but it is a real behavioural change, not a comment fix.
- The F6 exactness proof's "near-side neighbour" bound was wrong as written:
picking the near-side neighbour on a FIXED axis is not always within
sqrt(3) of the query (counterexample: u = (0.5, 0, 0) with the x-neighbour
jittered to (-1, 1-2^-24, 1-2^-24) gives ~2.06, past the radius-2
exclusion). The comment is rewritten to the correct argument: choose the
axis whose u_i sits closest to a cell boundary (m = min(u_i, 1-u_i) <=
1/2 on that axis), and the near-side neighbour on THAT axis has squared
distance at most (1+m)^2 + 2*(1-m)^2 = 3 - 2m + 3m^2 <= 3 on [0, 1/2],
strictly below 3 since the jitter never reaches the boundary. Margin
2 - sqrt(3) ~= 0.268 (Soul's adversarial check: worst in-box F2 is exactly
sqrt(3), only in the unattainable corner limit).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stryker (mtp, --since 29e50ad3) found 261 survivors / 24 timeouts across the
whole repo; triaging the ones in this fix batch's own new code (cellular's
loop, its prune, cellular_unit) surfaced one real gap disguised as
collateral timeout noise from a batched infinite-loop mutant: dz < 2 (and
the dy/dx equivalents), silently dropping the +2 layer on one axis alone,
passed every existing random-sampled test. That is not the sweeps being
weak: the exactness proof's own margin (F1, F2 always < sqrt(3), radius-2
cells' lower bound is exactly 2) makes a genuine win from any single
|offset component| = 2 cell astronomically rare over uniformly random
points (measured zero hits in 3,000,000 samples).
SearchReachesEveryAxisAlignedRadiusTwoSlice constructs a real example for
each of the six axis-aligned radius-2 offsets directly: for a candidate
integer cell, build the query point that is the closest point of that
cell's own unit cube to the target neighbour's actual jittered feature.
That construction finds a hit for every offset within a couple thousand
candidate cells, deterministically. This kills dz < 2 / dy < 2 / dx < 2 (all
manually confirmed).
Also manually confirmed via Stryker's own reported mutants: the dz--/dy--/
dx-- (increment-to-decrement) mutants are genuine infinite loops, correctly
caught as timeouts. The f2 >= 0.0f mutant (disabling the F2 = 0 guard) is
killed by the existing NoNaNAtIntegerPointsNear2To24 test. The remaining
survivors/timeouts in the new code (the lower-bound prune's >>= vs >
comparison, its arithmetic terms, and cellular_unit's >> vs >>> shift on an
already-unsigned operand) are equivalent mutants: since the per-axis lower
bound is always 0 or 1 by construction, and pruning is a proven-safe
optimization that never changes cellular's output, no mutation confined to
"when does the prune trigger" can be observed by any value-based test.
Verified by hand: disabling pruning entirely reproduces the unpruned
reference bit for bit, as it must.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- smin_grad is Quilez's quadratic-polynomial smin, not cubic.
- cellular's search radius is 5x5x5 (F6), not 3x3x3; adds the exactness
proof and the pruning claim.
- id source is now pcg4d over the winning cell, not the pcg3d hash that
produced its jitter (F4).
- F2 = 0 is documented as reachable (past |p| of about 2^23), not
unreachable; removes the false "unreachable" claim and adds a "Precision
domain" note stating the float32 feature-spacing/tie behaviour honestly
(F3, minor finding).
- The struct-mirror comparison note now says it also matches field name and
type in declaration order, not just position (F2).
No entry added to THIRD-PARTY-NOTICES for the Quilez/Worley formulas: per
Self's standing ruling, a published formula is not ported code.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cut 2a-i fix batch 1, findings F1, F3, F4, F5, F6, F7 (Soul falsification of
the first landing):
- cellular now searches a provably exact 5x5x5 neighbourhood (radius 2), not
3x3x3: 3x3x3 missed the true F2 for 9 of 400,000 sampled points. The
comment in math.cs proves 5x5x5 always contains the true F1 and F2 (both
are always < sqrt(3), every excluded cell is always > 2). A cheap
position-only lower-bound prune skips the pcg3d/jitter/distance work for
cells that provably cannot beat the running F2, without changing any
output bit (F6).
- F2 = 0 now gets the same degenerate-gradient guard F1 = 0 already had,
fixing a NaN that was reachable past |p| of about 2^23 (F3).
- id now comes from pcg4d over the winning cell alone, never from the pcg3d
hash that produced that cell's jitter, so id and jitter are no longer
deterministic functions of each other (F4).
- cellular_unit maps the top 24 bits instead of a plain divide-by-2^32, so
the mapping is always inside [0, 1); the old formula rounded
0xFFFFFF80..0xFFFFFFFF up to exactly 1.0f (F7).
- Central-difference tolerances tightened from 0.02 to 3e-4 (measured float
noise at the committed eps is about 6e-5), and cellular's own gradient
test now differentiates cellular's RETURNED .w values instead of a
duplicate FindF1F2 search, so a mutant that corrupts only the returned
gradient can't hide behind an independently-computed value (F1, F5).
- The struct-mirror comparison in HlslSourceCompatibilityTests now checks
field name and declared type in declaration order, not just position, so
a reordered-and-relabelled mirror struct (H2) or a renamed/retyped one
(H3) both fail the walk instead of surviving on values alone (F2).
- FindF1F2 widened to a true independent 7x7x7 brute-force oracle; new tests
pin production's exact agreement with it, the pruned search's bit-for-bit
equivalence to an unpruned 5x5x5 reference, no NaN at 2^24, and id's
correlation with jitter under 0.05.
- Comment/doc fix: smin_grad is Quilez's quadratic-polynomial smin, not
cubic.
Both shaders/CultMath.hlsl and unity/.../Shaders/CultMath.hlsl stay
byte-identical.
Manually verified mutants M9 (grad1 x 1.01), M16 (grad2 x 1.01), and M18 (id
from hash.y) all die against these tests; H2/H3 struct mutants die via the
new comparator tests. Local dotnet test: 191/191 passing. FXC cs_5_0 compile
of a scratch kernel using both cultmath_smin_grad and cultmath_cellular
succeeds.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stryker (scoped to this cut's diff) survived two mutants on the grad2 ternary
in math.cellular: forcing the true branch unconditionally, and loosening
f2 > 0.0f to f2 >= 0.0f. Both are real findings about the code, not the
tests: F2 = 0 needs two distinct integer cells' pcg3d-jittered feature points
to land on the exact float32 value in every component, which the jitter's
~24-bit float precision makes unreachable for any p this function is
actually called with. The spec only asks for the degenerate guard at F1 = 0;
F2's copy of it was added for symmetry and had no reachable case to defend.
Deleted the guard (grad2 is now a bare (p - c2) / f2) in math.cs and both
HLSL mirrors, and corrected design.md's invariant-8 section, which had
overstated this as symmetric with F1's guard.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
dotnet test found two failures where a fixed-decimal-place Assert.Equal
snapped two numbers that already agreed to within about 2e-5 to different
rounded digits, because their true difference straddled a rounding boundary
(0.824987829 vs 0.825006127 -> 0.82 vs 0.83). Both were central-difference
checks (smin_grad's own invariant-8 property test, and cellular's gradient-
matches-central-differences test), not a correctness bug in the primitives.
AssertWithinTolerance replaces the precision-based assertion for both central-
difference comparisons with an explicit absolute-difference check, which
states the actual claim (agreement within the finite-difference step's own
truncation error) without the rounding-boundary artifact. Also dropped an
unused local function (F2At) the build flagged.
Found by the third ygg-verify.sh test run on 77c3580.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Inside namespace CultMath.Tests, unqualified `Random` resolves to the
enclosing CultMath namespace's own Random struct (design.md: "Random is
CultMath's own xorshift32") before `using System;` is even considered, so
`new Random(seed)` in the new test file was constructing CultMath.Random,
which has no NextSingle(). HlslSourceCompatibilityTests.cs already works
around this the same way (`new System.Random(...)`); CellularAndSminGradTests
now qualifies every use the same way.
Found by the second ygg-verify.sh build/test run on e613ed6.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Inside the static math class, the unqualified name `float3` resolves to
math's own `float3(float,float,float)` constructor-function first, not the
CultMath.float3 type, so `float3.zero` fails to compile there (CS0119). No
prior code in math.cs used the Type.zero static-field spelling from inside
math itself for this reason. Switched cellular's zero vectors to
`new float3(0.0f, 0.0f, 0.0f)`, which is unambiguous under `new`.
Found by the first ygg-verify.sh build/test run on 3eed434.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CellularAndSminGradTests pins the rules the cut map calls out, each against a
mutant it must kill:
- smin's value never exceeds the ordinary minimum, and matches min with that
input's own gradient once |a.w - b.w| exceeds k on either side.
- smin_grad's gradient matches central differences of its own .w at random
seeded points (the formula is exact, not an approximation, so no exclusion
band is needed away from the |a.w-b.w|=k seam itself).
- A direct case falsifies blending the gradient with a constant 0.5 or with
(1 - h) instead of h.
- cellular's F1 never exceeds F2, matched against an independent brute-force
Worley search that shares only the public pcg3d hash, not cellular's control
flow.
- The true nearest feature wins even when an independent search finds it in
the (1, 1, 1) neighbour offset, the last one cellular's own loop order
visits, ruling out a "first found" or early-exit mutant.
- The gradient at a feature point is finite zero, not NaN.
- Both nearest and edge gradients match central differences away from a band
around the F1 = F2 set, where the nearest and second-nearest identity swap
discontinuously.
- id is constant across small moves that stay in the same feature's region and
differs from a distant cell's id.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HlslSourceCompatibilityTests previously flattened only bare vector returns to
scalar leaves. Values() now recurses through struct fields of any type, so a
struct return (invariant 8's one shape, e.g. CultCellular) is compared field
by field down to bit-for-bit scalars, exactly like a vector return already
was. Components() stays scoped to scalar-typed fields, since it only ever
builds function arguments and must not recurse.
TransformHlslToCSharp gains transformation 5: struct fields gain `public`,
since HLSL has no field access-modifier concept and a C# struct's fields
default to private, which would hide them from Values()' reflection. The old
wrap-in-a-class transformation is renumbered 6. design.md's transformation
list documents both, matching design.md:92's numbered list.
StructReturnComparisonRejectsAMismatchedField is the extension's own test: two
hand-built structs with the same shape (mirroring a real mirror-vs-C#
comparison) prove the field-by-field, bit-for-bit walk actually rejects a
struct whose leaf field differs, or whose nested vector's own component
differs, not just a length check.
EveryMirrorFunctionMatchesCSharpMath and HlslMirrorTests' required-symbol list
now cover cultmath_smin_grad and cultmath_cellular; the mirrored-function
count moves from 28 to 30.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds cultmath_smin_grad, the CultCellular struct, and cultmath_cellular to
both tracked copies of CultMath.hlsl (packages/cultmath/shaders and the Unity
package), placed after cultmath_pcg4d so cultmath_cellular can call
cultmath_pcg3d and cultmath_distance, both of which HLSL requires to be
declared earlier in the file. Both copies stay byte-identical.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
smin_grad(float4,float4,float) carries Inigo Quilez's cubic-polynomial smooth
minimum to invariant 8's value-and-gradient form: the gradient blend lerp(b,a,h)
is the exact analytic derivative, not an approximation, since the h-derivative
terms cancel algebraically inside the smoothing band.
cellular(float3) is Worley's cellular texture basis function (SIGGRAPH 1996),
searched over the jittered 3x3x3 neighbourhood of pcg3d-selected feature
points. It returns the new CultCellular struct (nearest, edge, id), following
the repo's existing degenerate-normal convention
(GameCult.Geometry.CultGeometryIsoSurface.EmitOrientedTriangle) to return a
finite zero gradient instead of NaN at a feature point.
Both are fresh implementations from the cited literature, not ports, so they
get a design.md/doc-comment citation instead of a THIRD-PARTY-NOTICES entry.
design.md gains an "Invariant 8: Value-and-Gradient Primitives" section
documenting both primitives' semantics and conventions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Stryker.NET becomes CultLib's mutation tool (CultMath config, baseline 2243
mutants, 350 survived). Behavioural tests close the three areas Aetheria
leans on: the BatchMath SIMD body, first_order_intercept's branches, and
equality and comparison across the vector types. 350 -> 264 survivors;
Soul passed the cut after one accumulation test was added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul's pass on 41440cb found BatchMath.cs:115
(AddRadialFalloffAcceleration2D(ReadOnlySpan<float2>, ...)) accumulates
onto the caller's acceleration buffer, but no test ever seeded that
buffer with nonzero values: mutating += to = passed all 133 tests. The
float-array overload already had this guard
(RadialFalloffAccumulatesOntoExistingAcceleration); add the float2
equivalent, with a nonzero, per-element-distinct starting buffer, mixing
in-range and out-of-range particles so both the accumulated and
unchanged-seed cases are checked.
Checked every other += site in BatchMath.cs for the same gap: the
array-overload's scalar tail and vector body, and both
IntegrateSemiImplicitEuler2D overloads, all already seed their
accumulator buffers (vx/vy/px/py, velocity/position) with nonzero,
per-element values in their existing tests. Only this one site was bare.
Verified by hand-mutating BatchMath.cs:115 (+= to =): the new test fails
(expected 6.78, actual 0.48); reverted the source byte-exactly after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The vector-vector self-tie added in the previous commit does not cover
float2/float3/float4's vector-scalar and scalar-vector overloads: their
existing no-tie fixture (a distinct vector against a single scalar) never
had a component equal to that scalar, so their boundary-flip mutants
(< to <=, > to >=) kept surviving. Add a uniform vector (every component
the same value) against a scalar equal to that value, tying every
component of both scalar overloads at once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The no-tie fixture in each *ComparisonOperatorsDifferPerComponent test
correctly kills direction mutations (< swapped for >) but leaves every
strict/non-strict boundary swap (< to <=, > to >=) surviving, since with
no equal component the two behave identically. That default is
appropriate for a float-threshold epsilon check, but these are
general-purpose vector comparison operators (CultMath's own
RectIntersectionTreatsTouchingEdgesAsContact test compares literal
endpoints), and for the int types an exact tie is the routine case, not
the rare one. Add a complementary "compared against itself" call per type
(vector-vector for double2/double3/int2/int3/int4, plus vector-scalar and
scalar-vector for float2/float3/float4) so every component ties and the
boundary swaps are actually exercised.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Precision` was declared `const float`, so `Assert.Equal(a, b, Precision)`
bound to xunit's absolute-tolerance overload (allowing any two values
within 4.0 of each other) instead of the intended 4-decimal-place
overload. Hand-mutating the `t2 > 0 ? min(t1, t2) : t1` ternary to always
return `t1` (math.cs:287) exposed it: the assertion silently accepted a
result almost 0.27 away from the expected value. Declaring it `const int`
selects the correct overload; confirmed the same hand mutation now fails.
Also add the float2-overload euler-integration length-validation test the
mutation report showed missing (BatchMath.cs:208's ValidateEqualLengths
call had no test at all).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul-style self-check against the actual mutation report (not just green
tests) found several fixtures whose values coincidentally produced the
same output on the buggy path as on the correct one:
- BatchMath's scalar-tail fixtures put the one remainder particle/step
either outside the falloff radius or mask-frozen, so the y-axis
arithmetic a mutation would touch was never reached with an observable
effect. Pin the remainder particle in range (radial falloff) and dynamic
(Euler integration) so the scalar tail's own math is exercised, not just
the vector body's.
- The float2-overload of AddRadialFalloffAcceleration2D had no no-op
(radius <= 0 / strength == 0) test at all.
- first_order_intercept_time's negative-determinant test used a
perpendicular relative velocity, making b = 2*dot(vel, pos) exactly
zero; a mutant that drops the "no solution" branch entirely and falls
through to the linear fallback coincidentally computes 0 too. Use a
non-perpendicular fixture so the fallback's nonzero value is
distinguishable from the correct zero.
- No fixture ever reached the `t1 > 0` branch at all (a > 0, both
quadratic roots real and positive), so the boundary/negate checks
guarding it and the `t2 > 0 ? min(t1, t2) : t1` line were never
covered. Add that case.
- bool2.@false/@true, bool3.@true and bool4.@false have no consumer
anywhere in the suite (bool3.@false and bool4.@true are read by
HlslSemanticsTests); pin all six vector constants directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul: a git-URL package lands read-only in Unity's cache, where a file with
no meta is warned about and ignored on every import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hand-verified that weakening float3.Equals from && to || passes the whole
suite. The same shape recurs with no shared base type across float2/3/4,
double2/3, int2/3/4, bool2/3/4, quaternion, float2x2 and float3x3. Add
data-driven tests, per component position, that a value differing at
exactly one component is unequal under Equals/==/!=, that GetHashCode
stays consistent with Equals, and that </>/<=/>= (including the
vector-scalar and scalar-vector overloads on the float types) each report
a different result per component. float2x2/float3x3 are covered at row
granularity since they have no == operator; quaternion and bool2/3/4 have
no ordering operators and are covered for equality only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
math.first_order_intercept_time had a single closing-target test. Add
coverage for zero relative velocity, the a-approx-zero linear fallback, a
target that outruns the projectile (negative determinant), a receding
target with both quadratic roots non-positive, and a closing case with
off-origin, off-axis shooter and target state. The closing cases assert
that the projectile (inheriting the shooter's velocity) and the target
actually coincide at the returned time; the no-solution branches pin the
value the source's own contract specifies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both existing BatchMath tests used 2-3 particles while LaneCount >= 4, so
only the scalar tail ever ran, and both held y at 0 for every particle, so
every y-axis mutation was invisible. Add fixtures sized LaneCount * 3 + 1
with distinct nonzero x/y per particle, pinned against a scalar reference
computed independently in the test, plus explicit coverage of the early
return, negative deltaTime, length-mismatch, and empty-span guards on all
four BatchMath overloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
math.erf/math.erfinv (source, tests, README) already landed on main via
87b95ae, b688cb2, fc9ce97, 4cdc02b, 6e8362b, cc71f17. This is the release
step 0.2.3 skipped needing: bump the package version, rebuild the tracked
Unity DLL/pdb deterministically, and add the CHANGELOG.md this package
never had (docs/semver-policy.md names this exact gap).
- packages/cultmath/src/CultMath/CultMath.csproj: Version 0.2.3 -> 0.2.4.
- packages/cultmath/unity/org.gamecult.cultmath/package.json: 0.2.3 -> 0.2.4.
- packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md: new, [0.2.4]
entry only (Added: erf, erfinv). Earlier versions are not backfilled, per
the semver policy's own ruling on this package's pre-existing gap.
- packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll
and .pdb: rebuilt via packages/cultmath/scripts/build-unity-package.ps1
(dotnet build, netstandard2.1, Release, --no-incremental, no Source Link).
Built on Windows; the assembly is platform-neutral IL. The script's own
determinism check (fresh build hash == tracked hash) and its version-parity
check (package.json version == assembly FileVersion) both pass.
- No HLSL mirror change: erf/erfinv are C#-only, and
packages/cultmath/shaders/CultMath.hlsl already matches the copy under
unity/org.gamecult.cultmath/Shaders/CultMath.hlsl byte for byte.
Verified independent of build-unity-package.ps1's own checks: a scratch
console app loaded the tagged cultmath-unity-v0.2.3 DLL and this build's
fresh DLL into separate AssemblyLoadContexts and diffed their public
surface. Only two members were added (math.erf(float), math.erfinv(float)),
none removed; AssemblyVersion/FileVersion/InformationalVersion match apart
from the 0.2.3 -> 0.2.4 bump; TargetFrameworkAttribute is unchanged
(.NETStandard,Version=v2.1). erf/erfinv round-trip and domain-edge behavior
(erfinv(+-1) = +-Infinity, erfinv(1.5) = NaN) verified by invoking the
loaded methods directly.
dotnet test packages/cultmath/tests/CultMath.Tests -c Release: 102 passed,
0 failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What lands:
- Cut 3: the native MsQuic bridge, with the Windows release leg and F5
closed on both platforms, and the closerace fixture fix.
- Cut 4: the koffi Node binding, consumer, session manager and trust.
- Cut 5: the TS provider. It reaches the C# native connector, which is
the Unity overlay's path, on Windows.
- Fix batches 1-4: use-after-free, handler isolation, the
handshake-timer eviction, the koffi allocation leak, one C#-shaped
inbox, encode-once, advertisedHost, publish-only
(acceptClientFrames: false), and maxConnections.
- Retention: the provider seeds late joiners with the newest
latest-only frame per (channel, body), in TS and C#.
- Cut 6, narrowed: native binaries committed under
packages/cultmesh-ts/native, built by Actions. A second from-scratch
build reproduced them byte for byte.
Gates: four Opus Soul passes on the runtime and one Sonnet pass on the
packaging. The last runtime pass found that the connection death under
load was the old handshake eviction, which fix 3a had removed, and it
confirmed StreamPixels' load (60 Hz latest-only for 10 minutes) with
flat memory.
Also carried, per the operator's ruling: CultMath erf/erfinv (Cut 12.0
and its fix batch) from the branch base.
Map conflicts were resolved in favour of main, which owns the maps. The
only branch-side lines main lacked were a Cut 3 "still outstanding"
block that main's closure supersedes.
Open follow-ups, recorded on the map: IPv6-literal endpoints cannot be
dialled; consumer-side native queues are unbounded while Unity is not
polling; a client can see connect() resolve past maxConnections before
the refusal arrives; subtraction (about -130, mostly comments) is
deferred.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul found that on a Windows clone (core.autocrlf=true) SHA256SUMS and
MANIFEST.txt were written with CRLF, so the documented
`sha256sum -c SHA256SUMS` failed on every line: each filename ended in
\r. CI stayed green only because it checks out on Linux. After this
rule and a fresh checkout, the check passes on Starfire for all four
binaries.
Provenance, recorded here because verify-committed only checks the
committed files against the committed sums: run 35912901846 rebuilt
all four binaries from scratch after they were committed, and every
artifact is byte-identical to the committed file (libmsquic.so.2
c09a72f9..., libgamecult_mesh_quic_native.so 0ee3d283..., msquic.dll
c17c6581..., gamecult_mesh_quic_native.dll 9dae391a...). Two
independent CI builds produce the same bytes, which shows both where
they came from and that the build is reproducible.
Self edited this one-line config directly instead of dispatching Hands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cultmesh-quic-native-{win32-x64,linux-x64}.tar.gz` collided by substring
with the negative grep for the parked `@gamecult/cultmesh-quic-native-*`
platform package names (11.9, not built in this campaign). Rename to
`quic-native-{win32-x64,linux-x64}.tar.gz`, matching the upload-artifact
names already used. The release job has not run (no tag pushed), so this
has no runtime effect yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The binaries are committed and verify-committed went green on the trigger's
own run (https://github.com/GameCult/CultLib/actions/runs/35912901846).
Reverts quic-native.yml to the triggers named in cut 6 (docs/typescript-
quic-realtime-cut.md:2153-2156): push to main and pull_request run
verify-committed only, workflow_dispatch runs the two builds, a
cultmesh-quic-native-v* tag runs all four. Diff against 4d75d90 (the file
before the temporary trigger was added) is empty.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Downloaded from the TEMPORARY push-triggered run
https://github.com/GameCult/CultLib/actions/runs/35912386052 (commit
e5bf1c9), built by build-win32-x64 and build-linux-x64 in quic-native.yml.
Every file's digest was verified against the job logs before this commit
(win32-x64/msquic.dll's also matches build-quic-native.ps1's own pinned
expectedRuntimeSha256, confirming it is the pinned MsQuic 2.5.9 runtime).
win32-x64/gamecult_mesh_quic_native.dll 9dae391a44d59f12e19baa08a44d86c7e8b4916ca5d40007a42d65913ce0896f
win32-x64/msquic.dll c17c65813fe007eb486a5270d244f3659261355795b27abb99bd19ff77539263
linux-x64/libgamecult_mesh_quic_native.so 0ee3d28324c79a5b0135368187e7bd75c1969ad94fc6985755e81de4ec96e430
linux-x64/libmsquic.so.2 c09a72f9b3bfd9119044b305c6064731c1e1879411e6f54276474fb58b524b68
SHA256SUMS is generated sha256sum output over exactly these four relative
paths, committed verbatim. MANIFEST.txt records the pinned MsQuic sources,
the bridge commit, the run URL, and each build job's printed host/compiler
line; build.release notes this run used the TEMPORARY push trigger rather
than a tag, since no release exists yet.
Cut 6 (packaging), 11.5/11.7, Q8 Option 1: the operator (via this session)
downloads, verifies and commits; no workflow writes to the tree.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
workflow_dispatch cannot be dispatched against quic-cut6 until this workflow
file is registered from the default branch (a GitHub constraint, not a
permissions gap: `gh workflow run` and a raw dispatch API call both 404).
Rather than touch main or push a tag, add push: branches: [quic-cut6] and
extend verify-committed's and both build jobs' `if:` guards with a
`refs/heads/quic-cut6` arm, so this push itself runs the three jobs. The
release job's guard is untouched: it still matches only a
cultmesh-quic-native-v* tag, so this push does not create a release.
Every added line is marked TEMPORARY and paired with its match; both are
removed in one commit once the binaries are committed and verify-committed
goes green on that push.
Cut 6 (packaging).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four jobs: verify-committed checks packages/cultmesh-ts/native/SHA256SUMS
against the tree on every push to main and every pull request; build-win32-x64
and build-linux-x64 run scripts/build-quic-native.{ps1,sh} on
workflow_dispatch or a cultmesh-quic-native-v* tag and upload their output as
artifacts; release, needing both builds and gated to the tag push, is the
only job with write permission and attaches the two platform archives plus a
committed-format SHA256SUMS and a draft MANIFEST.txt to a GitHub Release.
build-linux-x64 runs inside the pinned debian:13@sha256:f324c7ff… container
per Q6 A: the Actions container is the only permitted Linux release builder,
because the artifact links against that image's glibc and the deploy host
(Yggdrasil) runs the same OS. No release or tag has been pushed by this
commit; the release job exists but has not run.
Cut 6 (packaging), docs/typescript-quic-realtime-cut.md:2131-2156, narrowed
per StreamPixels cut map section 4.3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The package directory must be self-contained once copied out of the repo
(pnpm materialises only `files`), so the licence text already committed
beside the Unity plugin binaries is copied here rather than referenced.
Same MIT text for both platforms. Cut 6 (packaging), 11.4.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The closure smoke already asserted dist/index.js and dist/index.d.ts for
every workspace package; extend it for cultmesh-ts to assert the four
committed native binaries plus SHA256SUMS, MANIFEST.txt and
MSQUIC-LICENSE.txt are in the packed file list, on every host. The tarball
carries both platforms; the host only decides which one loads. Cut 6
(packaging), 11.5/11.6.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`* text=auto` already autodetects them by content, but that is a heuristic.
Say explicitly that packages/cultmesh-ts/native/**/*.dll and *.so(.N) are
binary so no future text-processing rule can apply EOL conversion and poison
the digests SHA256SUMS pins. Cut 6 (packaging).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The committed QUIC native binaries only reach a consumer if `files` carries
them: pnpm materialises a file: dependency from `files` alone, and the same
allowlist gates `npm pack` and the deploy `git archive`. Cut 6 (packaging).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fixture fix (44ab376) lived only on quic-cut3-winrel. Cut 4 branched
from cc71f17 before it, so the QUIC line never carried it. This merges it
into quic-cut6 before packaging, so the release line runs the closerace
fixture whose poll timeout outlasts its own settle budget.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The synthetic connection P17 drives directly at acceptConnection() never
reaches CONNECTED (it was never dialed anywhere), so it never joins `peers`
either way — connectionCount alone couldn't tell "refused" apart from
"attached but still handshaking". Manually restoring the disposed-check's
removal and rerunning showed the original assertion passing regardless.
retain() is synchronous and only the attach path calls it, so comparing
CultMeshQuicNativeRuntime.refCount immediately around the acceptConnection()
call is what actually distinguishes the two: confirmed by hand that the
mutation now fails this test by assertion (2 !== 1) before restoring it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
P3 ("a latest-only send failure evicts the peer...") raced wall-clock
against a tight while-loop that does no real I/O per iteration (broadcast()
never awaits a latest-only peer's send), so it spun encoding and enqueueing
frames fast enough to starve the event-loop turns the native pump needs to
notice the dead connection — a self-inflicted race that any extra load in
the test process (mutation testing included, regardless of which line was
mutated) could push past the 2s bound with nothing to do with the mutant
under test. Fixed by decoupling how many attempts to seed (a small fixed
count, each with a real yield) from how long to wait for the result (one
waitUntil afterward).
New tests for mutants Soul found surviving by assertion:
- I1: an inbox key keeps its first-arrival queue position when a later
publish overwrites its value.
- L1: the outbox keeps one send in flight per peer, proven by
prototype-patching sendEncodedFrame to track concurrency (the provider
builds its peer transports internally, so this is the only vantage point
from outside the module).
- P15: broadcast() after dispose rejects.
- P17: an accept arriving after dispose is refused, not attached, driven
directly at acceptConnection() with a real (but otherwise unused) native
connection handle rather than a fabricated pointer.
Plus coverage for the new options: a publish-only provider faults a client
that sends to it and never queues anything, and a connection beyond
maxConnections is refused without disturbing existing peers.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's probe proved a client can grow the provider's memory without limit
(20k reliable frames plus 20k distinct latest-only keys took
receiveQueueSize to 40000 and RSS from 97 to 318 MB) because StreamPixels
never calls receive() to drain it, and there was no cap on connections
either. The pinned certificate only authenticates the server, so any
internet client can dial in.
Add acceptClientFrames (default true, unchanged for existing callers and
interop peers, which all use receive()): when false, any inbound stream a
peer opens faults that connection at STREAM_STARTED, before a frame can be
decoded or queued, so no inbox or generation-map entry is ever created for
client data, and receive() itself rejects clearly.
Add maxConnections (default 1024): a connection beyond the cap is refused
at accept, shut down before any transport, outbox, or retained-frame
seeding is allocated for it. Existing peers are unaffected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This brings fix batch 3c (per-runtime out-buffers, freed on the last
release, plus a steady-state RSS test in its own child process) onto
the branch carrying fix batches 3a and 3b and retention.
The merge was automatic. Checked by hand: release() frees the reused
buffers only after the reference count reaches zero and the pump has
exited. retain() (from 3a) is called only while the provider already
holds a reference, so it cannot resurrect a runtime that is closing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hand-mutating CultMeshRealtimeInbox.receive() to check `completed`
before the ready queue (so dispose() rejects an already-queued frame
instead of draining it first) found no existing test that dies for it.
Add one: two reliable-ordered frames sit in the provider's receive
queue, the provider is disposed, and both frames must still come back
from receive() before the next call rejects.
Verified by hand against the final code and restored, per the fix
batch's mutation list; not committed as a harness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reproduces Soul's probe directly: 20,000 client-sent latest-only frames
on one key, none ever drained by receive(), leave exactly 1 pending
frame in the provider's receive queue, and that frame carries the
newest sequence sent. A second test sends 20 frames across 50 distinct
keys and checks the queue holds exactly 50 (bounded by key count, not
frame count). A third proves reliable-ordered client frames are never
coalesced and stay in order at the provider.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's probe found readyFrames growing without limit: a publish-only
consumer of CultMeshQuicRealtimeProvider.receive() (StreamPixels' Node
service) let a single unauthenticated client key grow the provider's
receive queue by one entry per frame sent, forever.
Replace CultMeshRealtimeLatestOnlyCoalescer plus the three separate
ready-queue/waiter implementations built around it (the transport's
inbound path, the provider's outbox pump, and the provider's own
readyFrames array) with one CultMeshRealtimeInbox<T>, shaped like the
C# reference's CultMeshRealtimeInbox: latest-only entries coalesce to
at most one pending value per (channel, body) key, keeping that key's
first-arrival queue position; reliable-ordered entries always queue,
unbounded; receive() drains whatever is queued before a complete(error)
rejects further calls.
The provider's pumpReceivedFrom now publishes into this inbox under the
frame's (channel, body) key instead of pushing onto an unbounded array,
so a slow or absent receive() caller backs up to at most one pending
frame per distinct key. Added provider.receiveQueueSize (test-visible,
mirrors the existing trackedStreamCount precedent) to make that bound
observable.
realtime-quic.ts: 1298 -> 1273 lines (HEAD 1b0c71d), -16 against the
275010a anchor. Soul estimated -90 to -120 from unifying four ~209-line
sites into one ~80-line class; the actual saving is smaller because two
of those sites already need their own promise/waiter/AbortSignal
plumbing in TypeScript (no Channel with native CancellationToken
support), so consolidating them removes duplication but the surviving
single copy of that plumbing is not itself small. No further
correctness-neutral cut was found without code-golfing working logic.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Without sendOnStream's disposed guard, the queued send never settles (no
event or sweep is left to reject it), so a bare assert.rejects(send, ...)
would hang the whole file instead of failing this one test. Race the send
against a 3s timeout that rejects with a different message, so the mutant
is a loud assertion mismatch either way the native layer happens to fail
first.
Verified by hand: reverted sendOnStream's `if (this.disposed)` guard to
`if (false)` locally, rebuilt, and ran P8 alone — it died by assertion in
81ms (a native stream-send error on the already-shut-down connection,
still not matching /disposed/i), not a timeout. Restored the guard before
committing. Also hand-verified fix 1 the same way: commenting out the
`clearTimeout(handshakeTimer)` on CONNECTED reproduces the original
handshake-timer eviction, and the new fix-1 test dies by assertion
(0 !== 1) rather than a hang.
Full Windows pass on Starfire after restoring: 158/158 cultmesh-ts tests
and all 5 QUIC interop tests (including the native-connector and
late-join lanes), msquic.dll confirmed as the pinned build (4,181,856
bytes, SHA-256 C17C6581...).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
By the time the memory test ran in-process, ~150 earlier tests in the
suite had already grown and partly freed the glibc heap, so a real
per-call native leak could land in already-mapped free space and never
show up as RSS growth in that process: confirmed by hand, the
in-process version of this test kept passing even with `nextEvent`
mutated back to allocating fresh koffi buffers every call, once it ran
inside the full suite rather than alone.
Spawn a dedicated `node --expose-gc` child process for the measurement
instead. A fresh process has no such history, matching the ~8 MiB
(fixed) vs ~30 MiB (pre-fix) split measured in isolation. Confirmed by
hand again with the same mutation: the child-process version now fails
reliably (~28.7 MiB growth) inside the full 150-test suite, not just
standalone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The prior version opened connection B only after A had already faulted,
then asserted a boolean flag set by B's own CONNECTED event. That flag
never flipped on Linux: something about accepting a second connection
right after the first one's forced shutdown made the assertion
unreliable, even though A's fault itself isolated correctly (visible in
the log: the onFault throw fired once, promptly).
Rewritten to establish B as a normal, fully connected loopback pair
first, fault A only afterward, then prove B's handler is still live by
sending an actual frame on B and confirming the server side still
receives it — a stronger assertion than a connected flag, and one that
does not depend on a second connection's accept timing relative to a
fault that just tore another one down.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
P8's test opened a fresh reliable stream on the first send, so disposing
before its microtask ran hit streamOpen() against an already-shut-down
connection (its own native error) instead of ever reaching sendOnStream's
disposed guard. Prime the stream with an awaited first send so the second,
raced send chains straight onto sendOnStream with no streamOpen call in
between.
P10b's test registered connection A's and B's server-side listeners after
an `await`, the exact async-registration gap fix 1 exists to close: a
connection's next native event can be dispatched, and silently dropped
(no listener registered yet), before that await's continuation runs.
Register both listeners synchronously inside their LISTENER_NEW_CONNECTION
callback instead.
The IPv6 fix 3 test asserted the wrong expectation: WHATWG URL's own
`hostname` keeps the brackets for an IPv6 literal, so the round trip
through parseQuicRealtimeEndpoint correctly returns "[::1]", not "::1".
Confirmed via a Linux verify run (cultlib-quic-native-dev image): before
this commit, exactly these three new tests failed and every other test
(155) passed, including every other fix/P3 test added alongside them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>