diff --git a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
index 9557a21c2..55634b84e 100644
--- a/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
+++ b/Assets/Scripts/Editor/ShieldPanelCut2Verify.cs
@@ -70,6 +70,13 @@ namespace Aetheria.EditorTools
// 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");
+
+ // Pin the fallback: a carrier with NO ShieldEnvelope component must produce exactly
+ // the pre-Cut-2 hit direction/position, for both FieldDriver's and ShieldManager's
+ // formulas. This is what a Longinus/Djinni prefab looks like before the operator
+ // adds the component (Q5) -- the fallback is not a courtesy, it is load-bearing for
+ // every ship in the game until then.
+ ok &= CheckLegacyFallbackFormulas();
}
catch (Exception e)
{
@@ -238,6 +245,75 @@ namespace Aetheria.EditorTools
return ok;
}
+ ///
+ /// Pins ShieldEnvelope's two static fallback entry points (SurfaceDirection,
+ /// LegacyLocalSurfacePoint) against an INDEPENDENT re-derivation of the exact pre-Cut-2
+ /// formulas -- not a call into the production code under test -- on a carrier with no
+ /// ShieldEnvelope component and a non-trivial transform (nonzero position, non-identity
+ /// rotation), so the check is not vacuous the way it would be at the origin with identity
+ /// rotation. This is the regression the coordinator flagged: ShieldManager's fallback used
+ /// to silently change behaviour (hard-fail to Vector3.forward) instead of reproducing the
+ /// deleted formula, which would have shown every ship's shield hits in the wrong place
+ /// until the operator hand-edits Longinus.prefab/Djinni.prefab.
+ ///
+ static bool CheckLegacyFallbackFormulas()
+ {
+ var go = new GameObject("LegacyFallbackProbe");
+ bool ok = true;
+ try
+ {
+ go.transform.position = new Vector3(5f, -2f, 13f);
+ go.transform.rotation = Quaternion.Euler(15f, 40f, -8f);
+ go.transform.localScale = new Vector3(4f, 3f, 12f);
+ // Deliberately no MeshCollider/MeshFilter/ShieldEnvelope on this object -- this is
+ // exactly what Longinus.prefab's "Shield" object looks like before the operator
+ // rigs it (Q5), and what FieldShieldTest's cubesphere looked like before Cut 2.
+
+ var probePoints = new[]
+ {
+ go.transform.position + new Vector3(10f, 2f, -6f),
+ go.transform.position + new Vector3(-3f, 8f, 20f),
+ go.transform.position + new Vector3(0.4f, -0.1f, 0.2f),
+ };
+
+ foreach (var worldPoint in probePoints)
+ {
+ // Independent re-derivation of ShieldManager's deleted formula
+ // (normalize(shield.transform.InverseTransformPoint(point))).
+ var expectedDirection = go.transform.InverseTransformPoint(worldPoint).normalized;
+ var actualDirection = ShieldEnvelope.SurfaceDirection(go.transform, worldPoint);
+ if (Vector3.Distance(expectedDirection, actualDirection) > 1e-5f)
+ {
+ Debug.LogError($"{Tag} SurfaceDirection(transform, p) fallback diverged from "
+ + $"ShieldManager's deleted formula: expected={expectedDirection:F6} actual={actualDirection:F6}");
+ ok = false;
+ }
+
+ // Independent re-derivation of FieldDriver.AddHit's deleted formula
+ // (normalize(InverseTransformPoint(p)) * transform.localScale).
+ var expectedLocalSurface = Vector3.Scale(
+ go.transform.InverseTransformPoint(worldPoint).normalized, go.transform.localScale);
+ var actualLocalSurface = ShieldEnvelope.LegacyLocalSurfacePoint(go.transform, worldPoint);
+ if (Vector3.Distance(expectedLocalSurface, actualLocalSurface) > 1e-3f)
+ {
+ Debug.LogError($"{Tag} LegacyLocalSurfacePoint(transform, p) fallback diverged from "
+ + $"FieldDriver's deleted formula: expected={expectedLocalSurface:F6} actual={actualLocalSurface:F6}");
+ ok = false;
+ }
+ }
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(go);
+ }
+
+ if (ok)
+ Debug.Log($"{Tag} Legacy fallback statics (SurfaceDirection, LegacyLocalSurfacePoint) match "
+ + "the pre-Cut-2 formulas exactly, for a carrier with no ShieldEnvelope component and a "
+ + "non-trivial transform.");
+ return ok;
+ }
+
static Vector3 LocalExtentsOf(GameObject envelopeObj)
{
var meshCollider = envelopeObj.GetComponent();
diff --git a/Assets/Scripts/Gameplay/FieldDriver.cs b/Assets/Scripts/Gameplay/FieldDriver.cs
index 7dee8de47..dd6b36cc3 100644
--- a/Assets/Scripts/Gameplay/FieldDriver.cs
+++ b/Assets/Scripts/Gameplay/FieldDriver.cs
@@ -139,6 +139,18 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
return transform.localScale;
}
+ // Cut 2 note on the two branches below NOT being the same computation shape: envelope.
+ // ProjectToSurface returns a WORLD-space point (it transforms back through rotation and
+ // translation -- Cut 3's panel presenter needs that). The fallback reproduces the pre-Cut-2
+ // formula exactly instead, which stayed in LOCAL space with no rotation/translation applied,
+ // because that is what the shader consumes here (_Hits is uploaded and read in the field
+ // mesh's own local space, not world space). The two branches are only numerically identical
+ // when the carrier's transform has zero position and identity rotation -- true of the one
+ // FieldDriver carrier in the tree today (FieldShieldTest.unity's cubesphere), NOT true in
+ // general. This is an existing characteristic of Cut 2 as scoped by the map (FieldDriver.cs:125
+ // reads envelope.ProjectToSurface(position) directly, per the map's own Cut 2 text), not
+ // something introduced by this fallback -- flagging it so nobody assumes the two branches are
+ // interchangeable for a future non-origin, non-identity FieldDriver carrier.
public void AddHit(float3 position, float3 direction, float magnitude)
{
if (_hits.Count >= MaxHits) return;
@@ -148,7 +160,7 @@ public class FieldDriver : MonoBehaviour, IAbsorbPresenter, IGrabPresenter, IMel
else
{
WarnMissingEnvelopeOnce();
- surfacePosition = (normalize(transform.InverseTransformPoint(position.ToUnity()).ToCultMath()) * transform.localScale.ToCultMath()).ToUnity();
+ surfacePosition = ShieldEnvelope.LegacyLocalSurfacePoint(transform, position.ToUnity());
}
var hit = new FieldHit
{
diff --git a/Assets/Scripts/Gameplay/ShieldEnvelope.cs b/Assets/Scripts/Gameplay/ShieldEnvelope.cs
index 1d88d1b1a..3057e9775 100644
--- a/Assets/Scripts/Gameplay/ShieldEnvelope.cs
+++ b/Assets/Scripts/Gameplay/ShieldEnvelope.cs
@@ -71,15 +71,20 @@ public class ShieldEnvelope : MonoBehaviour
}
// 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)
+ // transform's rotation and (non-uniform) scale. This is the one primitive ProjectToSurface,
+ // SurfaceDirection, and both static fallback entry points below 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). Static and keyed on an explicit Transform so a caller
+ // with no ShieldEnvelope component can still go through this one owner instead of re-deriving
+ // the maths itself.
+ static Vector3 LocalUnitDirection(Transform t, Vector3 worldPoint)
{
- var local = transform.InverseTransformPoint(worldPoint);
+ var local = t.InverseTransformPoint(worldPoint);
return local.sqrMagnitude > 1e-12f ? local.normalized : Vector3.forward;
}
+ Vector3 LocalUnitDirection(Vector3 worldPoint) => LocalUnitDirection(transform, worldPoint);
+
///
/// 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
@@ -135,5 +140,38 @@ public class ShieldEnvelope : MonoBehaviour
/// 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);
+ public Vector3 SurfaceDirection(Vector3 worldPoint) => LocalUnitDirection(transform, worldPoint);
+
+ // --- Fallback entry points for a carrier with no ShieldEnvelope component yet. ---
+ //
+ // Not every carrier gets the component in the same commit as this file (Q5: the operator rigs
+ // prefabs and scenes by hand, on their own schedule), so both FieldDriver and ShieldManager
+ // must keep working, unchanged, until their own object is rigged. "Unchanged" means byte-for-
+ // byte the pre-Cut-2 formula, not a plausible-looking replacement -- a silently different
+ // fallback is worse than either the old code or a hard failure. Rather than let each consumer
+ // re-derive that formula by hand (recreating the exact duplicate-authority problem this cut
+ // exists to remove), both fallback formulas are named, static, one-owner methods here, and
+ // ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins each one against an independent
+ // re-derivation of the original code.
+
+ ///
+ /// Compatibility path for a Transform with no ShieldEnvelope component: the exact direction
+ /// ShieldManager.ShowHit computed by hand before this cut (normalize(InverseTransformPoint),
+ /// no scale or mesh-extents factor at all -- a direction never needed one).
+ ///
+ public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)
+ => LocalUnitDirection(transform, worldPoint);
+
+ ///
+ /// Compatibility path for a Transform with no ShieldEnvelope component: the exact local-frame
+ /// value FieldDriver.AddHit computed by hand before this cut -- normalize(local) scaled by the
+ /// transform's own localScale, deliberately NOT transformed back through rotation/translation
+ /// (FieldDriver's shader consumes this in local mesh space, not world space; see the
+ /// ProjectToSurface-vs-this note in FieldDriver.cs). This is a legacy, local-space formula,
+ /// not a general "surface point in the world" API -- ProjectToSurface is that, and requires a
+ /// real ShieldEnvelope (it needs the mesh's local extents, which no mesh means no envelope to
+ /// read them from).
+ ///
+ public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)
+ => Vector3.Scale(LocalUnitDirection(transform, worldPoint), transform.localScale);
}
diff --git a/Assets/Scripts/Gameplay/ShieldManager.cs b/Assets/Scripts/Gameplay/ShieldManager.cs
index 6d4623b86..cf12797d7 100644
--- a/Assets/Scripts/Gameplay/ShieldManager.cs
+++ b/Assets/Scripts/Gameplay/ShieldManager.cs
@@ -22,6 +22,13 @@ public class ShieldManager : MonoBehaviour
_envelope = GetComponent();
}
+ private void WarnMissingEnvelopeOnce()
+ {
+ if (_loggedMissingEnvelope) return;
+ _loggedMissingEnvelope = true;
+ Debug.LogWarning("ShieldManager has no ShieldEnvelope; falling back to its own transform for the hit direction.", this);
+ }
+
private void OnCollisionEnter(Collision other)
{
var otherShield = other.collider.GetComponent();
@@ -90,19 +97,18 @@ public class ShieldManager : MonoBehaviour
// 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.
+ //
+ // Every "Shield" object gets a ShieldEnvelope in this cut, but the operator rigs prefabs by
+ // hand on their own schedule (Q5), so until Longinus.prefab/Djinni.prefab are actually
+ // edited, every ship in the game has none yet. A missing envelope must therefore behave
+ // exactly like the deleted code, not degrade -- a fallback that silently changes behaviour
+ // (this used to hard-fail to a fixed direction) is worse than either the old code or a
+ // loud failure. So this goes through ShieldEnvelope's own static fallback entry point
+ // (one owner, no second copy of the maths) rather than recomputing the projection here.
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;
+ WarnMissingEnvelopeOnce();
+ shield.Direction = ShieldEnvelope.SurfaceDirection(shield.transform, point);
}
else
{
diff --git a/tests/mutation_tests_shield_panel_cut2.py b/tests/mutation_tests_shield_panel_cut2.py
index 4e2716521..bf6987842 100644
--- a/tests/mutation_tests_shield_panel_cut2.py
+++ b/tests/mutation_tests_shield_panel_cut2.py
@@ -145,6 +145,57 @@ def main() -> int:
finally:
SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
+ # --- the regression the coordinator flagged directly: ShieldManager's (and FieldDriver's)
+ # missing-envelope fallback must reproduce the deleted pre-Cut-2 formula, not silently swap
+ # in a fixed direction. This mutates the shared static SurfaceDirection entry point back to
+ # the fixed Vector3.forward every hit direction degraded to before the fix. ---
+ print("\n=== mutation: SurfaceDirection fallback collapses to a fixed Vector3.forward ===")
+ anchor = (
+ " public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n"
+ " => LocalUnitDirection(transform, worldPoint);"
+ )
+ mutated = (
+ " // mutation_tests_shield_panel_cut2.py: reintroduce the exact regression the\n"
+ " // coordinator flagged -- a fallback that silently changes behaviour instead of\n"
+ " // reproducing the deleted formula.\n"
+ " public static Vector3 SurfaceDirection(Transform transform, Vector3 worldPoint)\n"
+ " => Vector3.forward;"
+ )
+ try:
+ replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
+ code, text = run_probe(scratch / "fixed_forward.log")
+ caught = "SurfaceDirection(transform, p) fallback diverged" in text
+ if code == 0 or not caught:
+ failures.append("fixed-Vector3.forward regression: probe did not go red naming the SurfaceDirection divergence")
+ else:
+ print("OK: caught — probe exited non-zero, legacy fallback pin fired")
+ finally:
+ SHIELD_ENVELOPE_CS.write_text(original_envelope, encoding="utf-8")
+
+ # --- the other fallback static, mutated to the "wrong transform" shape the coordinator
+ # named: dropping the local-frame projection and returning the raw world point instead. ---
+ print("\n=== mutation: LegacyLocalSurfacePoint fallback returns the raw world point ===")
+ anchor = (
+ " public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n"
+ " => Vector3.Scale(LocalUnitDirection(transform, worldPoint), transform.localScale);"
+ )
+ mutated = (
+ " // mutation_tests_shield_panel_cut2.py: the \"wrong transform\" mutation -- skip the\n"
+ " // local-frame projection entirely and hand back the untransformed world point.\n"
+ " public static Vector3 LegacyLocalSurfacePoint(Transform transform, Vector3 worldPoint)\n"
+ " => worldPoint;"
+ )
+ try:
+ replace_unique(SHIELD_ENVELOPE_CS, anchor, mutated)
+ code, text = run_probe(scratch / "wrong_transform.log")
+ caught = "LegacyLocalSurfacePoint(transform, p) fallback diverged" in text
+ if code == 0 or not caught:
+ failures.append("raw-world-point regression: probe did not go red naming the LegacyLocalSurfacePoint divergence")
+ else:
+ print("OK: caught — probe exited non-zero, legacy fallback pin 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")