fix(opstack): two cell-sweep pads assumed MinRadius <= MaxRadius; a third already didn't

Second defect from the full 28-op EffectOverBox audit. Three ops roll a radius
as Lerp(MinRadius, MaxRadius, hash01), which lands in [min(A,B), max(A,B)] --
Lerp does not require A <= B. Their EffectOverBox sweeps lattice cells padded by
the largest radius a cell could hold, so a pad below the true maximum means the
cells are never examined and the op reports Identity for a box its own Eval will
fill or carve: no geometry, no collision.

  FGridColumnMod   ~1086  Max(MaxRadius, 0)                        exposed
  FShaftFieldSource ~1436 Max(ShaftMaxRadius, ConnectorRadius)     exposed
  FIslandBlobSource ~1803 Max(IslandMinRadius, IslandMaxRadius)    already correct

The third one is the argument: the concern was met and guarded once in this same
file, and the other two shipped without it. Fixed with FMath::Max3, a spelling
already used here (~2481) and in VoxelCaveMorphology.cpp.

Shipped defaults are ordered correctly (2/5, 2/7, 5/11), so this is a NO-OP at
defaults -- that is its acceptance signal, and any moved number means the diff
did more than intended. It needs a mis-ordered asset value, which nothing
prevents: ClampMin is a per-property floor and UE cannot express "<= that other
property". ColumnMinRadius is also settable per-room via UVoxelTerrainOpDefinition.

Deliberately NOT fixed by normalising the params: swapping the Lerp endpoints
maps the same hash to a different radius, changing geometry and breaking the
eight equivalence tests. Only the bound becomes conservative; Eval is byte-
identical -- verified, no Eval/Roll/GetCells line in the diff.

Also audited and found sound (recorded so they are not re-chased): FPassageCarveOp
and the passage bounding sphere, FOriginSpineOp, FBoundarySealOp, FShaftLedgeMod,
FCaveArchMod, FDomeMod.

Not built -- Jahni builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:50:01 +02:00
parent 108982d135
commit eaa44bf0c0
3 changed files with 178 additions and 4 deletions
@@ -1081,9 +1081,10 @@ namespace
{
if (ColDensity <= 0.0f || Spacing <= 0.0f) { return EVoxelOpEffect::Identity; }
// Marge : le centre d'une colonne vit dans sa cellule, son influence porte au plus
// MaxRadius + ColBlend. Sur-estimer coûte du CPU ; sous-estimer serait un trou.
const float Reach = FMath::Max(MaxRadius, 0.0f) + ColBlend;
// Marge : l'enveloppe de `Lerp(MinRadius, MaxRadius, t)` est max(MinRadius, MaxRadius),
// pas `MaxRadius` seul si l'asset inverse les paramètres. The bound must cover both
// endpoints; using `MaxRadius` alone would leave a hole when the asset reverses them.
const float Reach = FMath::Max3(MinRadius, MaxRadius, 0.0f) + ColBlend;
const int32 CX0 = FMath::FloorToInt(((float)VoxelBox.Min.X - Reach) / Spacing);
const int32 CX1 = FMath::FloorToInt(((float)VoxelBox.Max.X + Reach) / Spacing);
@@ -1433,7 +1434,10 @@ namespace
// La source répond pour la paire source+carve (SIMPLIFICATION DE PHASE 1) : `CarveOnly`
// si une primitive atteint la boîte, `Identity` sinon. `ExtraReach` couvre la rugosité
// et le blend en aval — le sous-estimer serait un TROU.
const float Pad = FMath::Max(P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
// L'enveloppe doit couvrir les deux bornes de `Lerp(ShaftMinRadius, ShaftMaxRadius, t)`,
// pas `ShaftMaxRadius` seul si l'asset inverse les paramètres. The bound must cover
// both radius endpoints before adding connector and downstream reach.
const float Pad = FMath::Max3(P.ShaftMinRadius, P.ShaftMaxRadius, P.ConnectorRadius) + ExtraReach;
const FBox Padded = VoxelBox.ExpandBy(Pad);
const float Spacing = FMath::Max(P.ShaftSpacing, 1.0f);