Collapse the two implicit shield ellipsoids into ShieldEnvelope (Cut 2)

FieldDriver derived the shield ellipsoid from its own transform.localScale;
ShieldManager derived it independently from the Shield prefab instance's
transform, doing the same normalise-and-project by hand against a different
object. Add ShieldEnvelope as the one reader of a transform's centre, radii,
surface projection, and true outward normal (derived, not authored, per R6 --
no second copy of numbers the physical MeshCollider already owns), and switch
both consumers onto it, deleting their hand-rolled projections.

Radii read the attached collider mesh's own bounds.extents per axis rather
than assuming a flat 0.5 or 1.0: Shield.prefab's Icosphere collider measures
(0.0095106, 0.01, 0.01), not a uniform round number, and the cubesphere's
collider measures exactly (1,1,1) -- confirmed by a throwaway batchmode probe
against the actual mesh assets, not guessed.

SurfaceNormal is not normalize(p): a non-uniform ellipsoid's true normal is
the gradient of the implicit surface, transformed by rotation and the INVERSE
of lossyScale (a normal is a covector). The first implementation used
TransformDirection (rotation and forward scale) and the batchmode probe's
finite-difference gradient check caught it immediately -- exactly the
plausible-looking-wrong-answer failure mode the cut map called out by name.
Both original consumers keep normalize(p)-equivalent behaviour via the new
SurfaceDirection method, since they only ever wanted a direction.

FieldDriver falls back to its pre-cut formulas (logged once) when no
ShieldEnvelope is present, since it has no guaranteed carrier yet.
ShieldManager does not: every object that carries it gets an envelope in this
cut, so a missing one logs an error and degrades to a fixed direction rather
than recomputing a second projection.

No prefab or scene file is touched here (Q5: the operator rigs prefabs and
scenes by hand); ShieldPanelCut2Verify's batchmode probe instead builds
synthetic transforms carrying Longinus's and Djinni's actual override values
plus the real Icosphere mesh, and proves ProjectToSurface lands exactly on the
ellipsoid and SurfaceNormal matches a finite-difference gradient to 1e-3.
mutation_tests_shield_panel_cut2.py mutates SurfaceNormal three ways (back to
normalize(p), back to TransformDirection, and a flattened 0.5 extents factor)
and confirms the probe goes red for each, naming the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 06:33:38 +02:00
commit a454a5a77f
7 changed files with 629 additions and 7 deletions

View file

@ -0,0 +1,251 @@
using System;
using UnityEditor;
using UnityEngine;
namespace Aetheria.EditorTools
{
/// <summary>
/// Batchmode probe for the shield-panel Cut 2 landing (docs/shield-panel-cut.md):
/// ShieldEnvelope collapsing the two implicit ellipsoids (FieldDriver's transform, the Shield
/// prefab instance's transform) into one reader. Same reason Cut 1 has one (the map's Q7):
/// Tests.asmdef cannot see Assembly-CSharp, so this batchmode probe stands in for NUnit
/// coverage.
///
/// Cut 2 does not edit any prefab or scene (Q5: the operator rigs prefabs/scenes by hand), so
/// this cannot literally load Longinus.prefab/Djinni.prefab and find a ShieldEnvelope on them
/// yet. Instead it builds synthetic transforms carrying the exact override values the map's
/// §1 table recorded for Longinus and Djinni (scale, position, rotation) plus the real
/// Icosphere collider mesh those ships' Shield objects actually use, and runs the map's own
/// verification rule against those: ProjectToSurface lands exactly on the ellipsoid, and
/// SurfaceNormal matches a finite-difference gradient of the implicit surface to within 1e-3,
/// checked on Longinus's 4:1-aspect envelope specifically (a sphere could not distinguish a
/// correct normal from a plain normalize(p)).
///
/// Run via: Unity -batchmode -projectPath &lt;repo&gt; -executeMethod
/// Aetheria.EditorTools.ShieldPanelCut2Verify.Run -quit -logFile &lt;path&gt;
/// </summary>
public static class ShieldPanelCut2Verify
{
const string Tag = "[ShieldPanelCut2Verify]";
public static void Run()
{
bool ok = true;
GameObject longinus = null, djinni = null, cubesphere = null;
try
{
ok &= CheckIcosphereExtents();
longinus = BuildEnvelope(
"LonginusShieldProbe",
new Vector3(395.3487f, 235.47029f, 990.37805f),
new Vector3(0f, 0.73f, 1.32f),
Quaternion.identity,
"Assets/Models/Icosphere.fbx");
djinni = BuildEnvelope(
"DjinniShieldProbe",
new Vector3(857.9317f, 424.00012f, 1036.2562f),
new Vector3(0f, 1.75f, -1.06f),
new Quaternion(0.08715578f, 0f, 0f, 0.9961947f),
"Assets/Models/Icosphere.fbx");
cubesphere = BuildEnvelope(
"FieldShieldTestProbe",
new Vector3(4f, 3f, 12f),
Vector3.zero,
Quaternion.identity,
null); // no mesh -- matches the cubesphere's collider extents of (1,1,1) exactly
ok &= CheckEllipsoidSurface(longinus, "Longinus");
ok &= CheckEllipsoidSurface(djinni, "Djinni");
ok &= CheckEllipsoidSurface(cubesphere, "FieldShieldTest cubesphere");
ok &= CheckNormalMatchesGradient(longinus, "Longinus");
ok &= CheckNormalMatchesGradient(djinni, "Djinni");
// The sphere-vs-ellipsoid distinction the map's whole Cut 2 rests on: on
// Longinus's 4:1 envelope, the true normal and normalize(p) must visibly differ.
ok &= CheckNormalDiffersFromNormalizeP(longinus, "Longinus");
// Radii must reflect the measured mesh factor (per-axis; the Icosphere is not a
// perfect cube of extents -- see IcosphereExtents above), not a guessed flat 0.5.
ok &= CheckRadiiUsesMeasuredExtents(longinus, new Vector3(395.3487f, 235.47029f, 990.37805f), IcosphereExtents, "Longinus");
ok &= CheckRadiiUsesMeasuredExtents(cubesphere, new Vector3(4f, 3f, 12f), Vector3.one, "FieldShieldTest cubesphere");
}
catch (Exception e)
{
Debug.LogError($"{Tag} exception: {e}");
ok = false;
}
finally
{
if (longinus != null) UnityEngine.Object.DestroyImmediate(longinus);
if (djinni != null) UnityEngine.Object.DestroyImmediate(djinni);
if (cubesphere != null) UnityEngine.Object.DestroyImmediate(cubesphere);
}
Debug.Log(ok ? $"{Tag} PASS" : $"{Tag} FAIL — see errors above");
EditorApplication.Exit(ok ? 0 : 1);
}
// Measured once via a throwaway batchmode probe (deleted): the Icosphere collider mesh's
// bounds.extents at import scale. Only y and z happen to land on exactly 0.01 -- x is
// slightly smaller (a base icosahedron's vertices are not all equidistant from its own AABB
// faces on every axis, so its bounding box is not a perfect cube even though the shape is
// "spherical"). This is exactly why ShieldEnvelope reads the mesh's actual bounds rather
// than assuming a round number on every axis: the real collider is not perfectly isotropic.
static readonly Vector3 IcosphereExtents = new Vector3(0.0095106f, 0.01f, 0.01f);
static bool CheckIcosphereExtents()
{
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>("Assets/Models/Icosphere.fbx");
if (mesh == null)
{
Debug.LogError($"{Tag} Icosphere.fbx mesh not found.");
return false;
}
var e = mesh.bounds.extents;
bool ok = Vector3.Distance(e, IcosphereExtents) < 1e-4f;
if (!ok)
Debug.LogError($"{Tag} Icosphere.fbx bounds.extents is {e:F7}, expected {IcosphereExtents:F7} "
+ "(measured once via batchmode; if the asset legitimately changed, update this constant "
+ "and re-derive ShieldEnvelope's documented factor).");
else
Debug.Log($"{Tag} Icosphere.fbx bounds.extents={e:F7} (as measured).");
return ok;
}
static GameObject BuildEnvelope(string name, Vector3 scale, Vector3 position, Quaternion rotation, string meshPath)
{
var go = new GameObject(name);
go.transform.position = position;
go.transform.rotation = rotation;
go.transform.localScale = scale;
if (meshPath != null)
{
var mesh = AssetDatabase.LoadAssetAtPath<Mesh>(meshPath);
var mc = go.AddComponent<MeshCollider>();
mc.sharedMesh = mesh;
}
go.AddComponent<ShieldEnvelope>();
return go;
}
static bool CheckEllipsoidSurface(GameObject envelopeObj, string label)
{
var envelope = envelopeObj.GetComponent<ShieldEnvelope>();
var center = envelope.Center;
var radii = envelope.Radii;
bool ok = true;
var rng = new System.Random(1337);
for (int i = 0; i < 20; i++)
{
var probe = center + new Vector3(
(float)(rng.NextDouble() * 4 - 2) * radii.x,
(float)(rng.NextDouble() * 4 - 2) * radii.y,
(float)(rng.NextDouble() * 4 - 2) * radii.z);
if (probe == center) probe += Vector3.right * 0.001f;
var surface = envelope.ProjectToSurface(probe);
var local = envelopeObj.transform.InverseTransformPoint(surface);
var extents = LocalExtentsOf(envelopeObj);
double sum = Sq(local.x / extents.x) + Sq(local.y / extents.y) + Sq(local.z / extents.z);
if (Math.Abs(sum - 1.0) > 1e-3)
{
Debug.LogError($"{Tag} {label}: ProjectToSurface point {i} is off the ellipsoid, sum(p_i/r_i)^2={sum:F6} (want 1).");
ok = false;
}
}
if (ok) Debug.Log($"{Tag} {label}: 20/20 ProjectToSurface probes land on the ellipsoid.");
return ok;
}
static bool CheckNormalMatchesGradient(GameObject envelopeObj, string label)
{
var envelope = envelopeObj.GetComponent<ShieldEnvelope>();
bool ok = true;
var rng = new System.Random(2026);
const float h = 1e-3f;
for (int i = 0; i < 10; i++)
{
var dir = new Vector3((float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f, (float)rng.NextDouble() - 0.5f).normalized;
var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(dir, envelope.Radii * 3f);
var surface = envelope.ProjectToSurface(probe);
var analytic = envelope.SurfaceNormal(surface);
// Finite-difference gradient of F(x) = sum((local_i)/extents_i)^2 - 1 in world space.
var extents = LocalExtentsOf(envelopeObj);
Func<Vector3, double> f = worldP =>
{
var l = envelopeObj.transform.InverseTransformPoint(worldP);
return Sq(l.x / extents.x) + Sq(l.y / extents.y) + Sq(l.z / extents.z);
};
var grad = new Vector3(
(float)((f(surface + Vector3.right * h) - f(surface - Vector3.right * h)) / (2 * h)),
(float)((f(surface + Vector3.up * h) - f(surface - Vector3.up * h)) / (2 * h)),
(float)((f(surface + Vector3.forward * h) - f(surface - Vector3.forward * h)) / (2 * h)));
var fd = grad.normalized;
var dot = Vector3.Dot(analytic, fd);
if (dot < 1 - 1e-3f)
{
Debug.LogError($"{Tag} {label}: SurfaceNormal probe {i} disagrees with the finite-difference "
+ $"gradient, dot={dot:F6} (want ~1). analytic={analytic} fd={fd}");
ok = false;
}
}
if (ok) Debug.Log($"{Tag} {label}: 10/10 SurfaceNormal probes match the finite-difference gradient within 1e-3.");
return ok;
}
static bool CheckNormalDiffersFromNormalizeP(GameObject envelopeObj, string label)
{
var envelope = envelopeObj.GetComponent<ShieldEnvelope>();
// normalize(p) and the true gradient normal coincide at every point lying exactly on a
// principal axis (a pole) -- both directions collapse to that axis there, for any radii.
// They diverge everywhere else, more so the more the axes differ. Longinus's radii are
// ~3.95/2.35/9.90 (x/y/z), so a direction mixing the short x axis and the long z axis,
// off every pole, is where the two disagree most.
var localDir = new Vector3(0.8f, 0f, 0.6f).normalized;
var probe = envelope.Center + envelopeObj.transform.rotation * Vector3.Scale(localDir, envelope.Radii * 3f);
var surface = envelope.ProjectToSurface(probe);
var trueNormal = envelope.SurfaceNormal(surface);
var wrongNormal = (surface - envelope.Center).normalized;
var dot = Vector3.Dot(trueNormal, wrongNormal);
// On this envelope the two are safely coincident only at the poles; off-axis they must
// differ by more than a token amount, or SurfaceNormal has silently degenerated into
// normalize(p) again.
bool ok = dot < 0.999f;
if (!ok)
Debug.LogError($"{Tag} {label}: SurfaceNormal equals normalize(p) (dot={dot:F6}) on a 4:1 "
+ "ellipsoid's flank -- this is the exact wrong-answer mutation the map warns about.");
else
Debug.Log($"{Tag} {label}: SurfaceNormal correctly diverges from normalize(p) on the flank (dot={dot:F6}).");
return ok;
}
static bool CheckRadiiUsesMeasuredExtents(GameObject envelopeObj, Vector3 lossyScale, Vector3 extentFactor, string label)
{
var envelope = envelopeObj.GetComponent<ShieldEnvelope>();
var expected = Vector3.Scale(lossyScale, extentFactor);
var actual = envelope.Radii;
bool ok = Vector3.Distance(expected, actual) < 1e-2f;
if (!ok)
Debug.LogError($"{Tag} {label}: Radii={actual:F6} does not match lossyScale*measuredExtents={expected:F6}. "
+ $"actualLossyScale={envelopeObj.transform.lossyScale:F6} localScale={envelopeObj.transform.localScale:F6}");
else
Debug.Log($"{Tag} {label}: Radii={actual:F6} matches lossyScale*measuredExtents.");
return ok;
}
static Vector3 LocalExtentsOf(GameObject envelopeObj)
{
var meshCollider = envelopeObj.GetComponent<MeshCollider>();
if (meshCollider != null && meshCollider.sharedMesh != null)
return meshCollider.sharedMesh.bounds.extents;
return Vector3.one;
}
static double Sq(double x) => x * x;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fc6b6136927b78c45a78cfc0694ce93a

View file

@ -75,6 +75,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
private Material _field;
private ComputeBuffer _hitBuffer;
private List<FieldHit> _hits = new List<FieldHit>();
private ShieldEnvelope _envelope;
private bool _loggedMissingEnvelope;
private float _waveOffset;
private float _meleeTime;
private bool _meleeActive;
@ -115,14 +117,42 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
_collider = GetComponent<MeshCollider>();
_field = GetComponent<MeshRenderer>().material;
_hitBuffer = new ComputeBuffer(MaxHits, 32);
_envelope = GetComponent<ShieldEnvelope>();
}
// Cut 2 of docs/shield-panel-cut.md: FieldDriver no longer owns the ellipsoid envelope --
// ShieldEnvelope does, and this transform's localScale is display geometry for the field mesh
// only now. Absent an envelope (compatibility path only; every real carrier gets one in this
// cut), fall back to the pre-Cut-2 formulas and log once rather than silently keeping a second
// opinion about the envelope.
private void WarnMissingEnvelopeOnce()
{
if (_loggedMissingEnvelope) return;
_loggedMissingEnvelope = true;
Debug.LogWarning("FieldDriver has no ShieldEnvelope; falling back to its own transform for the envelope.", this);
}
private Vector3 EnvelopeRadiiOrFallback()
{
if (_envelope != null) return _envelope.Radii;
WarnMissingEnvelopeOnce();
return transform.localScale;
}
public void AddHit(float3 position, float3 direction, float magnitude)
{
if (_hits.Count >= MaxHits) return;
Vector3 surfacePosition;
if (_envelope != null)
surfacePosition = _envelope.ProjectToSurface(position.ToUnity());
else
{
WarnMissingEnvelopeOnce();
surfacePosition = (normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath()).ToUnity();
}
var hit = new FieldHit
{
Position = normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath(),
Position = surfacePosition.ToCultMath(),
Direction = normalize((transform.rotation * direction.ToUnity()).ToCultMath()),
Magnitude = magnitude,
Time = 0
@ -198,7 +228,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
_field.SetFloat("_TwistFront", FrontTwist);
_field.SetFloat("_TwistRear", RearTwist);
_field.SetVector("_InverseScale", new Vector4(1/transform.localScale.x,1/transform.localScale.y,1/transform.localScale.z));
var envelopeRadii = EnvelopeRadiiOrFallback();
_field.SetVector("_InverseScale", new Vector4(1/envelopeRadii.x,1/envelopeRadii.y,1/envelopeRadii.z));
_field.SetMatrix("_ReflRotate", refractionRotation);
for (int i = 0; i < _hits.Count; i++)
@ -294,9 +325,10 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
default:
throw new ArgumentOutOfRangeException();
}
_field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, transform.localScale));
_field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), transform.localScale));
_field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), transform.localScale));
var tendrilRadii = EnvelopeRadiiOrFallback();
_field.SetVector("_TendrilBase", Vector3.Scale(transform.InverseTransformPoint(_tendrilBasePos).normalized, tendrilRadii));
_field.SetVector("_TendrilBend", Vector3.Scale(transform.InverseTransformPoint(_tendrilBendTarget), tendrilRadii));
_field.SetVector("_TendrilTarget", Vector3.Scale(transform.InverseTransformPoint(_tendrilTargetPos), tendrilRadii));
}
}
}

View file

@ -0,0 +1,139 @@
using UnityEngine;
// Cut 2 of docs/shield-panel-cut.md (R4/R6): the one owner of "where is the shield surface, and
// which way does it face". Before this component existed, that answer was computed twice and
// independently -- FieldDriver.cs derived it from its own transform.localScale, and
// ShieldManager.cs derived it from the Shield prefab instance's transform -- with no shared
// authority and no way for the two to notice if they disagreed. This collapses both into one
// reader of a single transform. It stores nothing; every value below is derived fresh.
public class ShieldEnvelope : MonoBehaviour
{
// R6: the envelope is derived from the existing transform, not authored as separate centre +
// radii fields, because the transform already drives the physical collider (Shield.prefab's
// MeshCollider / FieldShieldTest's cubesphere MeshCollider) -- authoring a second copy next to
// it would be exactly the kind of duplicate authority this cut exists to remove.
//
// The one number a bare transform can't supply is the conversion from "lossyScale" to
// "ellipsoid semi-axis length in world units", because that depends on how big the attached
// collider mesh already is in local space. The map's draft guessed a flat 0.5 (unit-diameter
// sphere); measuring the two meshes actually in the tree (batchmode probe, both meshes'
// MeshCollider.sharedMesh.bounds) found neither is 0.5:
// - Shield.prefab's Icosphere collider: bounds.extents = (0.0095106, 0.01, 0.01) -- not a
// perfect cube, because a base icosahedron's vertices are not all equidistant from its own
// axis-aligned bounding box on every axis even though the shape is "spherical". Shield.prefab's
// base localScale of 100 is there to cancel the round number (100 * 0.01 = radius 1 on y/z),
// which is why the ship overrides (e.g. Longinus ~395,235,990) read directly as ~100x the
// documented "effective radii" in docs/shield-panel-cut.md's table without this factor, and
// why that factor must be read per-axis from the mesh rather than assumed uniform.
// - The FieldShieldTest cubesphere's collider: bounds.extents = (1, 1, 1) exactly, so its
// lossyScale (4, 3, 12) already *is* the radii with no correction -- which is why
// FieldDriver's pre-Cut-2 code could get away with using transform.localScale directly.
// Reading the mesh instead of hardcoding either number is what makes one component correct for
// both authorities without either one special-casing the other.
Vector3 _localExtents = Vector3.one;
bool _readExtents;
void Awake() => ReadLocalExtents();
void ReadLocalExtents()
{
if (_readExtents) return;
_readExtents = true;
Mesh mesh = null;
var meshCollider = GetComponent<MeshCollider>();
if (meshCollider != null) mesh = meshCollider.sharedMesh;
if (mesh == null)
{
var meshFilter = GetComponent<MeshFilter>();
if (meshFilter != null) mesh = meshFilter.sharedMesh;
}
// No mesh at all: nothing to correct for, so the transform's own scale is already the
// radii (extents of 1 is a no-op multiplier). This is the compatibility case, not the
// expected one -- every carrier in the tree today (Shield.prefab, the cubesphere) has one.
_localExtents = mesh != null ? mesh.bounds.extents : Vector3.one;
}
/// <summary>World-space centre of the ellipsoid.</summary>
public Vector3 Center => transform.position;
/// <summary>World-space semi-axis lengths of the ellipsoid, accounting for the attached
/// collider mesh's own local-space size (see the field comment above).</summary>
public Vector3 Radii
{
get
{
if (!_readExtents) ReadLocalExtents(); // covers edit-mode / pre-Awake callers
var s = transform.lossyScale;
return new Vector3(s.x * _localExtents.x, s.y * _localExtents.y, s.z * _localExtents.z);
}
}
// Local-space unit direction from the envelope's centre toward worldPoint, accounting for the
// transform's rotation and (non-uniform) scale. This is the one primitive both ProjectToSurface
// and SurfaceDirection are built from, and the only place InverseTransformPoint may appear for
// shield-surface purposes (see the negative check in docs/shield-panel-cut.md Cut 2).
Vector3 LocalUnitDirection(Vector3 worldPoint)
{
var local = transform.InverseTransformPoint(worldPoint);
return local.sqrMagnitude > 1e-12f ? local.normalized : Vector3.forward;
}
/// <summary>
/// Projects a world-space point outward onto the ellipsoid surface, returning a world-space
/// point. This is the true surface projection: local direction, scale by the local-space
/// extents, transform back through the object's rotation and scale.
/// </summary>
public Vector3 ProjectToSurface(Vector3 worldPoint)
{
if (!_readExtents) ReadLocalExtents();
var dir = LocalUnitDirection(worldPoint);
var localSurface = Vector3.Scale(dir, _localExtents);
return transform.TransformPoint(localSurface);
}
/// <summary>
/// The true outward normal of the ellipsoid at a world-space point already on (or near) its
/// surface: normalize(p.x/a^2, p.y/b^2, p.z/c^2) in local space, then rotated back to world.
/// This is deliberately NOT normalize(p) -- normalize(p) is only the correct normal for a
/// sphere. On a non-uniform ellipsoid (Longinus is ~4:1) it is visibly wrong: it points along
/// the radius vector rather than perpendicular to the surface, which reads as the panel tilting
/// toward the ship's centre instead of lying flat against the hull (docs/shield-panel-cut.md
/// Cut 2 and the Cut 4 flank-click check). FieldDriver and ShieldManager both get away with
/// normalize(p) today only because they use it to pick a *direction*, not a *normal* -- see
/// SurfaceDirection below, which is what they keep using.
/// </summary>
public Vector3 SurfaceNormal(Vector3 worldSurfacePoint)
{
if (!_readExtents) ReadLocalExtents();
var local = transform.InverseTransformPoint(worldSurfacePoint);
var a = Mathf.Max(_localExtents.x, 1e-6f);
var b = Mathf.Max(_localExtents.y, 1e-6f);
var c = Mathf.Max(_localExtents.z, 1e-6f);
var gradient = new Vector3(local.x / (a * a), local.y / (b * b), local.z / (c * c));
// A normal is a covector: under a non-uniform scale it transforms by the INVERSE of the
// scale, then the rotation -- the opposite of how an ordinary vector (or TransformDirection,
// which multiplies by scale) transforms. Dividing by lossyScale here and rotating with the
// bare quaternion (no TransformDirection) is what makes this differ from normalize(p) on a
// non-uniform ellipsoid; using TransformDirection directly was tried and measurably wrong
// (see the batchmode probe in docs/shield-panel-cut.md Cut 2 -- it disagreed with a
// finite-difference gradient by 5-20%, not floating-point noise).
var s = transform.lossyScale;
var gradientOverScale = new Vector3(
s.x != 0 ? gradient.x / s.x : gradient.x,
s.y != 0 ? gradient.y / s.y : gradient.y,
s.z != 0 ? gradient.z / s.z : gradient.z);
var worldGradient = transform.rotation * gradientOverScale;
return worldGradient.sqrMagnitude > 1e-12f ? worldGradient.normalized : transform.forward;
}
/// <summary>
/// The local-frame unit direction from the envelope's centre toward worldPoint -- exactly what
/// FieldDriver.AddHit and ShieldManager.ShowHit computed by hand before this cut, kept as its
/// own named method (rather than inlining InverseTransformPoint at each call site) so the
/// negative check ("no shield-surface projection outside ShieldEnvelope.cs") stays true. This
/// is a *direction*, not the true surface normal -- see the SurfaceNormal doc comment.
/// </summary>
public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(worldPoint);
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e25c67ee83c01364fb121caeda952606

View file

@ -11,9 +11,17 @@ public class ShieldManager : MonoBehaviour
{
public Prototype ShieldPrototype;
public float CollisionHitDuration = 3;
public Entity Entity { get; set; }
private ShieldEnvelope _envelope;
private bool _loggedMissingEnvelope;
void Awake()
{
_envelope = GetComponent<ShieldEnvelope>();
}
private void OnCollisionEnter(Collision other)
{
var otherShield = other.collider.GetComponent<ShieldManager>();
@ -73,7 +81,33 @@ public class ShieldManager : MonoBehaviour
public void ShowHit(Vector3 point, float duration)
{
var shield = ShieldPrototype.Instantiate<ShieldAnimation>();
shield.Direction = normalize(shield.transform.InverseTransformPoint(point).ToCultMath()).ToUnity();
// Cut 2 of docs/shield-panel-cut.md: this used to compute
// normalize(shield.transform.InverseTransformPoint(point)) itself, duplicating
// FieldDriver's projection against a different transform (Authority B vs A in the map's
// §1). shield.transform sits at the same place as this ShieldManager's own transform (the
// Shield prefab instance's pooled visual is parented under it with an identity local
// offset), so this ShieldManager's ShieldEnvelope is the same envelope. Direction is a pick
// of direction, not the true surface normal -- ShieldAnimation.Direction keeps its existing
// meaning (R6's note that both existing consumers get away with normalize(p) because they
// want a direction, not a normal).
// Unlike FieldDriver (a compatibility path is explicit in the map for that consumer),
// ShieldManager has no legitimate reason to be missing its envelope -- Cut 2 adds
// ShieldEnvelope to every "Shield" object that carries a ShieldManager. Recomputing the
// projection here "just in case" would recreate the exact duplicate authority this cut
// removes, so a missing envelope logs loudly and degrades to a fixed direction instead.
if (_envelope == null)
{
if (!_loggedMissingEnvelope)
{
Debug.LogError("ShieldManager has no ShieldEnvelope; the shield hit direction cannot be computed.", this);
_loggedMissingEnvelope = true;
}
shield.Direction = Vector3.forward;
}
else
{
shield.Direction = _envelope.SurfaceDirection(point);
}
shield.Duration = duration;
}
}

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Mutation tests for docs/shield-panel-cut.md Cut 2 (the ShieldEnvelope landing).
Assets/Scripts/Editor/ShieldPanelCut2Verify.cs is the batchmode probe Cut 2 added, for the same
reason Cut 1 has one (the map's Q7): Tests.asmdef cannot see Assembly-CSharp, so ShieldEnvelope
(pure, testable math though it is) has no NUnit path. A probe nobody can break is a probe nobody
can trust, so this mutates the *real* source in specific ways the cut's own text calls out as the
plausible wrong answers, runs ShieldPanelCut2Verify.Run in Unity batchmode, asserts it goes red
for the reason the cut names, then restores the source exactly. A control case first proves the
unmutated tree passes.
The mutation this file exists for is the one the map calls out by name: "the ellipsoid normal is
exactly the kind of arithmetic that has a plausible-looking wrong answer" — SurfaceNormal
collapsing to normalize(p), which is only correct for a sphere and silently wrong (but
plausible-looking) for Longinus's 4:1 ellipsoid. Two more mutations cover the other two
correctness-load-bearing lines this cut introduced: the per-axis mesh-extents factor collapsing
to a flat guess, and the covector (inverse-scale) transform collapsing to the more "obvious" but
wrong TransformDirection.
This is a separate script rather than an addition to tests/mutation_tests.py (the item-provenance
cut's headless dotnet-test runner, with no path to driving a Unity batchmode check) or
tests/mutation_tests_shield_panel_cut1.py (that one owns Cut 1's rules; this one touches only
Cut 2's file, ShieldEnvelope.cs).
Run from the repo root: python tests/mutation_tests_shield_panel_cut2.py
Exit code 0 only if every case behaved as Cut 2's verification section demands.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
UNITY_EXE = r"C:\Program Files\Unity\Hub\Editor\6000.3.24f1\Editor\Unity.exe"
SHIELD_ENVELOPE_CS = REPO_ROOT / "Assets" / "Scripts" / "Gameplay" / "ShieldEnvelope.cs"
def run_probe(log_path: Path) -> tuple[int, str]:
proc = subprocess.run(
[UNITY_EXE, "-batchmode", "-nographics", "-quit", "-projectPath", str(REPO_ROOT),
"-executeMethod", "Aetheria.EditorTools.ShieldPanelCut2Verify.Run", "-logFile", str(log_path)],
timeout=600,
)
text = log_path.read_text(encoding="utf-8", errors="replace")
return proc.returncode, text
def replace_unique(path: Path, anchor: str, mutated: str) -> str:
original = path.read_text(encoding="utf-8")
count = original.count(anchor)
if count != 1:
raise SystemExit(f"{path}: anchor must occur exactly once, found {count}\n---\n{anchor}")
path.write_text(original.replace(anchor, mutated), encoding="utf-8")
return original
def main() -> int:
scratch = REPO_ROOT / "tests" / "_scratch_shield_panel_cut2"
scratch.mkdir(parents=True, exist_ok=True)
failures: list[str] = []
original_envelope = SHIELD_ENVELOPE_CS.read_text(encoding="utf-8")
try:
# --- control: the real, unmutated tree must pass ---
print("\n=== control: unmutated tree ===")
code, text = run_probe(scratch / "control.log")
if code != 0 or "[ShieldPanelCut2Verify] PASS" not in text:
failures.append("control: expected exit 0 and PASS on the unmutated tree")
else:
print("control OK: exit 0, PASS")
# --- the mutation the map calls out by name: SurfaceNormal collapsing to normalize(p).
# This is the "plausible-looking wrong answer" — correct for a sphere, silently wrong for
# a non-uniform ellipsoid — that the map's Cut 2 verification section says must have a
# test that fails under its own mutation. ---
print("\n=== mutation: SurfaceNormal degenerates to normalize(p) ===")
anchor = (
" var s = transform.lossyScale;\n"
" var gradientOverScale = new Vector3(\n"
" s.x != 0 ? gradient.x / s.x : gradient.x,\n"
" s.y != 0 ? gradient.y / s.y : gradient.y,\n"
" s.z != 0 ? gradient.z / s.z : gradient.z);\n"
" var worldGradient = transform.rotation * gradientOverScale;"
)
mutated = (
" // mutation_tests_shield_panel_cut2.py: reintroduce normalize(p) as the normal —\n"
" // correct for a sphere, plausible-looking, wrong for a non-uniform ellipsoid.\n"
" var worldGradient = transform.TransformDirection(local);"
)
try:
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
code, text = run_probe(scratch / "normalize_p.log")
caught = "SurfaceNormal equals normalize(p)" in text or "disagrees with the finite-difference gradient" in text
if code == 0 or not caught:
failures.append("normalize(p) regression: probe did not go red naming the sphere-vs-ellipsoid distinction")
else:
print("OK: caught — probe exited non-zero, sphere-vs-ellipsoid distinction check fired")
finally:
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
# --- the covector-transform bug this cut's own Hands run hit and fixed: dividing by scale
# is required (a normal is a covector), multiplying (TransformDirection) is the "obvious"
# but wrong choice for a non-uniform scale. ---
print("\n=== mutation: SurfaceNormal multiplies by scale instead of dividing (TransformDirection) ===")
anchor = (
" var s = transform.lossyScale;\n"
" var gradientOverScale = new Vector3(\n"
" s.x != 0 ? gradient.x / s.x : gradient.x,\n"
" s.y != 0 ? gradient.y / s.y : gradient.y,\n"
" s.z != 0 ? gradient.z / s.z : gradient.z);\n"
" var worldGradient = transform.rotation * gradientOverScale;"
)
mutated = (
" // mutation_tests_shield_panel_cut2.py: the \"obvious\" wrong transform — a normal\n"
" // is a covector and must be scaled by the INVERSE of lossyScale, not lossyScale.\n"
" var worldGradient = transform.TransformDirection(gradient);"
)
try:
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
code, text = run_probe(scratch / "transformdirection.log")
caught = "disagrees with the finite-difference gradient" in text
if code == 0 or not caught:
failures.append("TransformDirection regression: probe did not go red naming the finite-difference disagreement")
else:
print("OK: caught — probe exited non-zero, finite-difference gradient check fired")
finally:
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
# --- the mesh-extents factor collapsing to a flat, wrong guess (the map's own draft
# guessed 0.5; this checks the fix stays measured-per-axis, not re-flattened). ---
print("\n=== mutation: Radii factor flattened to a guessed 0.5 instead of the measured mesh extents ===")
anchor = "_localExtents = mesh != null ? mesh.bounds.extents : Vector3.one;"
mutated = "_localExtents = mesh != null ? new Vector3(0.5f, 0.5f, 0.5f) : Vector3.one;"
try:
replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
code, text = run_probe(scratch / "flat_half.log")
caught = "does not match lossyScale*measuredExtents" in text
if code == 0 or not caught:
failures.append("flat-0.5 regression: probe did not go red naming the Radii mismatch")
else:
print("OK: caught — probe exited non-zero, Radii mismatch check fired")
finally:
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
finally:
# belt-and-suspenders: never leave a mutated file behind even if something above threw
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
print("\n=== summary ===")
if failures:
for f in failures:
print(f"FAIL: {f}")
return 1
print(f"All cases behaved as Cut 2's verification section demands. Logs: {scratch}")
return 0
if __name__ == "__main__":
sys.exit(main())