diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
new file mode 100644
index 000000000..9557a21c2
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
@@ -0,0 +1,251 @@
+using System;
+using UnityEditor;
+using UnityEngine;
+
+namespace Aetheria.EditorTools
+{
+ ///
+ /// 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 <repo> -executeMethod
+ /// Aetheria.EditorTools.ShieldPanelCut2Verify.Run -quit -logFile <path>
+ ///
+ 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("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(meshPath);
+ var mc = go.AddComponent();
+ mc.sharedMesh = mesh;
+ }
+ go.AddComponent();
+ return go;
+ }
+
+ static bool CheckEllipsoidSurface(GameObject envelopeObj, string label)
+ {
+ var envelope = envelopeObj.GetComponent();
+ 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();
+ 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 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();
+ // 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();
+ 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();
+ if (meshCollider != null && meshCollider.sharedMesh != null)
+ return meshCollider.sharedMesh.bounds.extents;
+ return Vector3.one;
+ }
+
+ static double Sq(double x) => x * x;
+ }
+}
diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta
new file mode 100644
index 000000000..6adb5a959
--- /dev/null
+++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fc6b6136927b78c45a78cfc0694ce93a
\ No newline at end of file
diff --git a/Assets/Scripts/Gameplay/FieldDriver.cs b/Assets/Scripts/Gameplay/FieldDriver.cs
index 3ca0927cb..7dee8de47 100644
--- a/Assets/Scripts/Gameplay/FieldDriver.cs
+++ b/Assets/Scripts/Gameplay/FieldDriver.cs
@@ -75,6 +75,8 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
private Material _field;
private ComputeBuffer _hitBuffer;
private List _hits = new List();
+ 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();
_field = GetComponent().material;
_hitBuffer = new ComputeBuffer(MaxHits, 32);
+ _envelope = GetComponent();
+ }
+
+ // 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));
}
}
}
diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs b/Assets/Scripts/Gameplay/ShieldEnvelope.cs
new file mode 100644
index 000000000..1d88d1b1a
--- /dev/null
+++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs
@@ -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();
+ if (meshCollider != null) mesh = meshCollider.sharedMesh;
+ if (mesh == null)
+ {
+ var meshFilter = GetComponent();
+ 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;
+ }
+
+ /// World-space centre of the ellipsoid.
+ public Vector3 Center => transform.position;
+
+ /// World-space semi-axis lengths of the ellipsoid, accounting for the attached
+ /// collider mesh's own local-space size (see the field comment above).
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public Vector3 ProjectToSurface(Vector3 worldPoint)
+ {
+ if (!_readExtents) ReadLocalExtents();
+ var dir = LocalUnitDirection(worldPoint);
+ var localSurface = Vector3.Scale(dir, _localExtents);
+ return transform.TransformPoint(localSurface);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(worldPoint);
+}
diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta
new file mode 100644
index 000000000..d247e6050
--- /dev/null
+++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e25c67ee83c01364fb121caeda952606
\ No newline at end of file
diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs
index 853e17c01..6d4623b86 100644
--- a/Assets/Scripts/Gameplay/ShieldManager.cs
+++ b/Assets/Scripts/Gameplay/ShieldManager.cs
@@ -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();
+ }
+
private void OnCollisionEnter(Collision other)
{
var otherShield = other.collider.GetComponent();
@@ -73,7 +81,33 @@ public class ShieldManager : MonoBehaviour
public void ShowHit(Vector3 point, float duration)
{
var shield = ShieldPrototype.Instantiate();
- 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;
}
}
diff --git a/tests/mutation_tests_shield_panel_cut2.py b/tests/mutation_tests_shield_panel_cut2.py
new file mode 100644
index 000000000..4e2716521
--- /dev/null
+++ b/tests/mutation_tests_shield_panel_cut2.py
@@ -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())