Fix ShieldManager's missing-envelope fallback regression

ShieldManager's fallback (no ShieldEnvelope yet, e.g. every ship prefab
before the operator hand-rigs it per Q5) hard-failed to a fixed
Vector3.forward direction with a per-hit Debug.LogError. That silently
changed behaviour for every ship in the game until the operator edits
Longinus.prefab/Djinni.prefab on their own schedule -- worse than either
the deleted code or a loud one-time failure.

Extract ShieldEnvelope's local-direction primitive into two static,
no-component-required entry points -- SurfaceDirection(Transform, Vector3)
and LegacyLocalSurfacePoint(Transform, Vector3) -- so both FieldDriver's and
ShieldManager's fallbacks go through one owner instead of each re-deriving
the pre-Cut-2 formula by hand. ShieldManager's fallback now reproduces the
deleted normalize(shield.transform.InverseTransformPoint(point)) formula
exactly via the shared static, logging once (a warning, not a per-hit
error). FieldDriver's fallback is rewired onto the same static rather than
its own inline copy of the maths.

Documented, not silently assumed: FieldDriver's "envelope present" branch
(envelope.ProjectToSurface, world-space) and its fallback (the legacy local-
space formula) 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, not true in general. This is a characteristic of
Cut 2 as scoped by the map (FieldDriver.cs:125 reads envelope.ProjectToSurface
directly, per the map's own text), not something this fix introduces or
resolves; flagged so nobody assumes the two branches are interchangeable for
a future non-origin, non-identity FieldDriver carrier.

ShieldPanelCut2Verify.CheckLegacyFallbackFormulas pins both static entry
points against an independent re-derivation of the exact pre-Cut-2 formulas,
on a carrier with a non-trivial transform (nonzero position, non-identity
rotation) so the check isn't vacuous. mutation_tests_shield_panel_cut2.py
gained two cases: SurfaceDirection collapsing back to a fixed Vector3.forward
(the exact regression fixed here), and LegacyLocalSurfacePoint collapsing to
the raw, untransformed world point -- both correctly fail the probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-18 06:45:07 +02:00
commit 7848a8a86e
5 changed files with 201 additions and 18 deletions

View file

@ -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;
}
/// <summary>
/// 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.
/// </summary>
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<MeshCollider>();