From d6aa526dd3459c6159dca448cfe1a36256fb9568 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 09:16:12 +0200 Subject: [PATCH] =?UTF-8?q?diag(render/physics):=20flap=20root-caused=20to?= =?UTF-8?q?=20physics=20rest=20=C2=B5m-jitter;=20refute=20prior=20diagnose?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apparatus + handoff for the indoor flap. Confirmed (primary evidence): the flap is the portal-flood clip being µm-sensitive at the threshold, driven by a ~1-8µm jitter in the player RenderPosition (physics resting position not bit-stable; Lerp surfaces it). REFUTES the 2026-06-07 see-through/EnvCell/outdoor-node diagnosis (ModelId GfxObj 0x01000A2B IS the solid exterior) AND an enqueue-once attempt (retail propagates late slices via AddToCell; the existing PropagatesNewSlicesToExit test caught it; reverted). Adds: Build determinism test, A8CellAudit gfxobj dump, [pv-input] 6dp probe + [render-sig] outRoot/bshell fields. No functional fix shipped. Next: higher-precision physics rest trace -> port retail kill_velocity/contact rest-stability. Canonical: docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-08-flap-rootcause-physics-rest-handoff.md | 119 ++++++++++++++++++ ...ortal-flood-membership-stability-design.md | 7 +- src/AcDream.App/Rendering/GameWindow.cs | 29 +++++ .../Rendering/RenderingDiagnostics.cs | 14 +++ .../Rendering/PortalVisibilityBuilderTests.cs | 32 +++++ tools/A8CellAudit/Program.cs | 100 +++++++++++++++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md diff --git a/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md b/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md new file mode 100644 index 00000000..eb872196 --- /dev/null +++ b/docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md @@ -0,0 +1,119 @@ +# Handoff — the indoor FLAP traced to a physics rest µm-jitter; prior diagnoses REFUTED — 2026-06-08 + +> **CANONICAL PICKUP for the indoor render flap.** This session refuted the 2026-06-07 cutover-flip +> diagnosis AND an enqueue-once attempt, confirmed the real mechanism with primary evidence, and traced +> the root all the way down to a **physics resting-position µm jitter**. The fix is in physics +> (rest-stability), is teed up, and needs one more **higher-precision** trace to pin the exact cause +> before porting. Spec: `docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md` +> (its §4 enqueue-once design is REFUTED — see §3 here; its §6 physics contingency is now the active +> direction). + +--- + +## 1. What the flap IS (confirmed, primary evidence) + +At the Holtburg cottage **doorway threshold**, the portal-visibility flood set oscillates frame-to-frame +(`ids=[0170,0171,0172,0173,0174,0175]` ↔ `[0170,0171]`, i.e. 6↔2 cells) **from a stable viewer cell** +(`root=0xA9B40170`, `outRoot=n`). The deep `0172-0175` cluster pops in/out → textures "battle." + +- It is **NOT** see-through walls from outside (standing outside with the door closed is **stable** — + user visual gate), **NOT** the outdoor node, **NOT** a root toggle, **NOT** nondeterminism. +- `PortalVisibilityBuilder.Build` is a **pure deterministic** function (proved by + `PortalVisibilityBuilderTests.Build_IsDeterministic_*`, passes). So the flip requires a **varying + input**. +- The high-precision `[pv-input]` probe (6 dp) shows the camera eye AND the **player `RenderPosition`** + carry perpetual **~1–8 µm** float jitter at rest (e.g. player Z `94.000000 ↔ 94.000008`). At the + threshold a grazing portal's clip is so knife-edge that this µm jitter flips its empty/non-empty + result → the flood membership flips → the flap. + +**Mechanism chain:** +`physics resting position blips ~µm → ComputeRenderPosition Lerp surfaces it as µm eye jitter → the +portal-flood clip (clip-non-empty membership) is µm-sensitive at the grazing threshold portal → flips → +flap.` Retail is flap-free because its authoritative local position is bit-stable at rest (so its same +clip-non-empty membership never crosses the boundary). + +## 2. REFUTED — the 2026-06-07 cutover-flip diagnosis (do NOT act on its F1/F2) + +`docs/research/2026-06-07-cutover-flip-render-residuals-diagnosis-handoff.md` is wrong on its +load-bearing claims (primary evidence in this session): + +- "See-through from outside" — **not reproduced** (outside, door closed, is stable). +- "Walls ARE the EnvCell shells; ModelId is a partial frame" — **refuted**: the cottage ModelId GfxObj + `0x01000A2B` is a full closed exterior (76 render polys, bbox 20×18×10.4 m, 46 outward-facing walls + + roof — `tools/A8CellAudit gfxobj 0x01000A2B`). EnvCell shells are interior-facing. **F2 (EnvCell + back-faces) targets the wrong geometry.** +- "Oscillation = outdoor-node flood (1↔13)" — **corrected**: it is the *indoor* flood, stable root, + 2↔6. F1 targeted the wrong root. +- "branch=RetailPViewInside every frame proves the flap is gone" — **tautological** (post-flip + `clipRoot = viewerRoot ?? _outdoorNode` is ~never null, so `branch` can't report `OutdoorRoot`). + +## 3. REFUTED — enqueue-once traversal (TDD caught it) + +Hypothesis: the flap is acdream's `MaxReprocessPerCell` re-enqueue drift; restore retail's enqueue-once +(first-discovery only, no re-enqueue). **Refuted:** retail does NOT stop at first discovery — its +`AddViewToPortals` growth branch calls **`AddToCell`** (decomp :433494), so a cell's later-grown view +IS propagated (late slices reach exit portals). The existing test +`PortalVisibilityBuilderTests.Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` encodes exactly +this retail behavior; enqueue-once broke it. The change + its test were reverted (tree clean, 27 portal +tests green). **The divergence is the re-clip DRIFT, not the propagation — and underneath, the flap is +the µm input jitter (which removing the drift would only *reduce*, not eliminate; `Build` is +deterministic so only a bit-stable INPUT guarantees no flap).** + +## 4. The root — physics resting position not bit-stable + +`PlayerMovementController.ComputeRenderPosition` (line 810): `Vector3.Lerp(_prevPhysicsPos, +_currPhysicsPos, alpha)`. `Lerp(a, a, t) == a` exactly, so the µm `RenderPosition` jitter means +**`_prev != _curr`** — the physics body's resting position blips ~µm between ticks. Retail's +`kill_velocity` (`OBJECTINFO::kill_velocity` = `set_velocity(0)`, decomp :274467) is called by +`validate_transition` (:272567) on **every** grounded collision/slide with a valid contact plane, +keeping rest bit-stable. + +acdream rest path: +- `calc_acceleration` (PhysicsBody.cs:191) zeroes gravity only when **`Contact && OnWalkable && + !Sledding`**. +- `UpdatePhysicsInternal` (PhysicsBody.cs:352) skips position integration when `velocity <= 0`. +- Player flags set per tick in `PlayerMovementController` (1271-1301): `Contact|OnWalkable` only when + `resolveResult.IsOnGround && Velocity.Z <= 0`; else cleared → gravity. +- acdream's `kill_velocity` (PhysicsEngine.cs:837) is **narrower than retail's** — fires only on + `ObjectInfo.VelocityKilled` (the airborne steep-roof/wall reset), NOT on every grounded contact. + +So at a *clean* rest the position is bit-stable; the blip is an **intermittent** failure (a stray +gravity tick / µm velocity residual / contact-plane not re-established). The `[resolve]` probe (3 dp) +shows the body stable to **mm** at spawn rest (`94.000` repeated) — confirming the blip is **sub-mm**, +below that probe's precision — and shows `groundedIn=True` but `walkable=False cp=none` (no contact +plane established at rest), a lead toward the Contact/contact-plane path. + +## 5. NEXT STEPS (the physics rest-stability fix) + +1. **Higher-precision physics rest trace (REQUIRED before fixing).** The 3-dp `[resolve]` probe is too + coarse. Add a 6-dp per-tick probe of the resting body: `_body.Position`, `Velocity`, `Acceleration`, + `TransientState` (Contact/OnWalkable), `resolveResult.IsOnGround`, contact-plane valid. Launch, let + the character sit at spawn (no input needed — autonomous), capture ~10 s, and find the tick where the + position blips µm and which condition failed (gravity applied? velocity residual? resolve re-snap? + Contact cleared?). +2. **Port the retail-faithful rest-stability fix** for the pinned cause — most likely one of: + (a) broaden `kill_velocity` to match retail's `validate_transition` (zero velocity on every grounded + contact with a valid contact plane, :272567); (b) ensure the `Contact` flag / contact plane is + re-established on the zero-distance rest sweep so `calc_acceleration` keeps gravity off; (c) a + retail-faithful "supported body at rest is frozen" (skip integration/resolve when grounded + zero + velocity + no movement input). TDD: a test asserting the resting body position is **bit-stable across + N ticks** with no input. +3. **Visual gate** at the cottage doorway threshold: hold still — the 2↔6 oscillation is gone (re-run + `[pv-input]`/`[render-sig]`, flood `ids=` constant at rest). + +**DO NOT RETRY:** the overlap-predicate render band-aid (rejected by user — not retail); enqueue-once +(refuted, §3); any render-side debounce/grace (forbidden). + +## 6. Apparatus (committed this session) + state + +- **Keep (real regression value):** `PortalVisibilityBuilderTests.Build_IsDeterministic_*` (proves Build + deterministic); `tools/A8CellAudit` `gfxobj` mode (dumps render geometry — used to refute the ModelId + claim). +- **Diagnostic probes (env-gated, inert off; KEEP for the physics trace + flap visual gate, strip after + the fix ships):** `[pv-input]` (`ACDREAM_PROBE_PVINPUT`, 6-dp Build inputs + flood count, + RenderingDiagnostics + GameWindow); the `outRoot=`/`bshell=` fields on `[render-sig]`; + `launch-pvinput.ps1`, `launch-bshell-probe.ps1`, `launch-resolve.ps1`. +- Tree: PortalVisibilityBuilder.cs reverted to the re-enqueue (no functional change shipped). Build + green; App.Tests green (portal-visibility 27/27). +- Memory to update: `project_indoor_flap_rootcause` (root is the physics rest µm-jitter, not the render + diagnosis or enqueue-once). diff --git a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md index 08621cc2..359a7ae8 100644 --- a/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md +++ b/docs/superpowers/specs/2026-06-08-portal-flood-membership-stability-design.md @@ -2,7 +2,12 @@ **Date:** 2026-06-08 **Branch:** `claude/thirsty-goldberg-51bb9b` -**Status:** design approved (user, 2026-06-08); TDD implementation pending behind a visual gate. +**Status:** ⚠️ **§4 (enqueue-once) REFUTED 2026-06-08** — retail propagates late slices via `AddToCell` +(decomp :433494); the existing `Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit` test encodes +that and enqueue-once broke it (reverted). The flap's confirmed root is the **physics resting position +µm-jitter** (§6 contingency, now the active direction). **CANONICAL PICKUP:** +`docs/research/2026-06-08-flap-rootcause-physics-rest-handoff.md`. Keep §1–§3 (mechanism + retail +grounding) as accurate diagnosis; treat §4–§5 as a refuted approach. --- diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a1a96a34..a0b7aa60 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7560,6 +7560,19 @@ public sealed class GameWindow : IDisposable clipAssembly = pviewResult.ClipAssembly; envCellShellFilter = pviewResult.DrawableCells; _interiorPartition = pviewResult.Partition; + + // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + + // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player + // identical to cm; this answers whether the inputs differ sub-cm (jitter) or are + // byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled. + if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null) + { + var vp = envCellViewProj; + char pvOutRoot = ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'; + Console.WriteLine(System.FormattableString.Invariant( + $"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); + } + sigPvFrame = pviewResult.PortalFrame; sigClipAssembly = pviewResult.ClipAssembly; sigDrawableCells = pviewResult.DrawableCells; @@ -9190,6 +9203,22 @@ public sealed class GameWindow : IDisposable sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount); sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount); + // Diagnosis 2026-06-07: draw-vs-occlude probe for the see-through residual. + // outRoot=Y means clipRoot is the synthetic outdoor node (eye outdoors). bshell=total/withMesh + // counts the building ModelId exterior shells queued in partition.Outdoor for this frame — the + // GfxObj exteriors that SHOULD draw the solid walls. Correlate with ids= (the flooded interior + // cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is + // failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells, + // the outdoor flood is drawing interiors over the exterior. + sb.Append(" outRoot=").Append(ReferenceEquals(clipRoot, _outdoorNode) ? 'Y' : 'n'); + if (partition is not null) + { + int shellTotal = 0, shellMesh = 0; + foreach (var e in partition.Outdoor) + if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; } + sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh); + } + if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null) { sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n'); diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index ad6b7793..03621062 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -129,6 +129,20 @@ public static class RenderingDiagnostics public static bool ProbeShellEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SHELL") == "1"; + /// + /// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE + /// [pv-input] line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH + /// precision (camera eye + player position to 6 dp, plus orientation-sensitive view-projection + /// elements) alongside the resulting flood cell count. The live flap shows the flood set flipping + /// 2↔6 at an eye/player that is identical to cm; this probe answers whether the Build INPUTS differ + /// below cm precision (sub-cm view jitter → robustness fix) or are byte-identical while the output + /// still flips (nondeterminism → surgical bug). Runs WITHOUT the heavy [flap]/[render-sig] + /// spam so the log stays diffable. Throwaway apparatus — strip once the jitter source is pinned. + /// Initial state from ACDREAM_PROBE_PVINPUT=1. + /// + public static bool ProbePvInputEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_PVINPUT") == "1"; + // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs index 746a6f44..38818c92 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs @@ -58,6 +58,38 @@ public class PortalVisibilityBuilderTests $"OutsideView width {outsideWidth} should be a sliver, far less than full window {windowOnlyWidth}"); } + [Fact] + public void Build_IsDeterministic_IdenticalInputsGiveIdenticalVisibleSet() + { + // Flap root-cause apparatus (2026-06-07): the live threshold flap shows OrderedVisibleCells + // flipping 2<->6 at an eye+player identical to cm. Build is a pure static function with + // all-fresh per-call state, so identical inputs MUST yield an identical visible set. If this + // FAILS, the flap is a determinism bug INSIDE Build; if it PASSES (expected), the live flip is + // sub-cm INPUT jitter and the fix must make membership robust, not Build deterministic. + // Exercises the re-enqueue fixpoint via a diamond: 0x0004 is reached from BOTH 0x0002 and 0x0003. + var cam = Cell(0x0001, + new CellPortalInfo(0x0002, 0, 0, 0), + new CellPortalInfo(0x0003, 1, 0, 0)); + cam.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -3f)); // left -> 0002 + cam.PortalPolygons.Add(QuadX(0.05f, 0.6f, -3f)); // right -> 0003 + var left = Cell(0x0002, new CellPortalInfo(0x0004, 0, 0, 0)); + left.PortalPolygons.Add(QuadX(-0.6f, -0.05f, -6f)); + var right = Cell(0x0003, new CellPortalInfo(0x0004, 0, 0, 0)); + right.PortalPolygons.Add(QuadX(0.05f, 0.6f, -6f)); + var back = Cell(0x0004, new CellPortalInfo(0xFFFF, 0, 0, 0)); + back.PortalPolygons.Add(QuadX(-0.6f, 0.6f, -9f)); + var all = new Dictionary + { [0x0001] = cam, [0x0002] = left, [0x0003] = right, [0x0004] = back }; + + var a = Build(cam, all); + var b = Build(cam, all); + + Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells); + Assert.Equal( + a.CellViews.Keys.OrderBy(k => k).ToArray(), + b.CellViews.Keys.OrderBy(k => k).ToArray()); + } + [Fact] public void Build_EyeStandingInInteriorPortal_FloodsNeighbour() { diff --git a/tools/A8CellAudit/Program.cs b/tools/A8CellAudit/Program.cs index 872eef80..d3948ac8 100644 --- a/tools/A8CellAudit/Program.cs +++ b/tools/A8CellAudit/Program.cs @@ -30,6 +30,14 @@ else if (args.Length > 0 && string.Equals(args[0], "portals", StringComparison.O foreach (var envCellId in ids) DumpCellPortals(dats, envCellId); } +else if (args.Length > 0 && string.Equals(args[0], "gfxobj", StringComparison.OrdinalIgnoreCase)) +{ + var ids = args.Length == 1 + ? new uint[] { 0x01000A2Bu } + : args.Skip(1).Select(ParseHex).ToArray(); + foreach (var gfxObjId in ids) + DumpGfxObj(dats, gfxObjId); +} else { var ids = args.Length == 0 @@ -365,6 +373,98 @@ static (int RegistryBuildings, int ShellEntities) DumpLandblockBuildings(LandBlo return (registryBuildingId - 1, shellEntities); } +static void DumpGfxObj(DatCollection dats, uint gfxObjId) +{ + Console.WriteLine($"=== GfxObj 0x{gfxObjId:X8} (RENDER polygons) ==="); + var g = dats.Get(gfxObjId); + if (g is null) + { + Console.WriteLine("missing GfxObj"); + return; + } + + var verts = g.VertexArray.Vertices; + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var v in verts.Values) + { + min = Vector3.Min(min, v.Origin); + max = Vector3.Max(max, v.Origin); + } + var centroid = (min + max) * 0.5f; + + int walls = 0, floors = 0, ceilings = 0, slopes = 0; + int outwardWalls = 0, inwardWalls = 0; + int emitPos = 0, emitNeg = 0, skipped = 0; + + foreach (var (polyId, poly) in g.Polygons.OrderBy(p => p.Key)) + { + if (poly.VertexIds.Count < 3) continue; + + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) + || poly.Stippling.HasFlag(StipplingType.Both) + || (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); + if (hasPos) emitPos++; + if (hasNeg) emitNeg++; + if (!hasPos && !hasNeg) skipped++; + + var n = ComputeNormalG(g, poly); + bool isWall = Math.Abs(n.Z) <= 0.15f; + bool isFloor = n.Z > 0.9f; + bool isCeiling = n.Z < -0.9f; + if (isFloor) floors++; + else if (isCeiling) ceilings++; + else if (isWall) walls++; + else slopes++; + + if (isWall) + { + var pc = PolyCentroidG(g, poly); + var toFace = pc - centroid; + float outward = Vector3.Dot(n, toFace); // >0 => front face points away from center (exterior) + if (outward > 0) outwardWalls++; else inwardWalls++; + } + } + + Console.WriteLine( + $"verts={verts.Count} renderPolys={g.Polygons.Count} hasPhysics={(g.PhysicsPolygons?.Count ?? 0)} " + + $"emitPos={emitPos} emitNeg={emitNeg} skipped={skipped}"); + Console.WriteLine( + $"bbox min=({min.X:F2},{min.Y:F2},{min.Z:F2}) max=({max.X:F2},{max.Y:F2},{max.Z:F2}) " + + $"size=({max.X - min.X:F2},{max.Y - min.Y:F2},{max.Z - min.Z:F2})"); + Console.WriteLine( + $"classify: walls={walls} (outwardFacing={outwardWalls} inwardFacing={inwardWalls}) " + + $"floors={floors} ceilings={ceilings} slopes={slopes}"); + Console.WriteLine(); +} + +static Vector3 ComputeNormalG(GfxObj g, DatReaderWriter.Types.Polygon poly) +{ + if (poly.VertexIds.Count < 3) return Vector3.Zero; + if (!g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var a) || + !g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var b) || + !g.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var c)) + { + return Vector3.Zero; + } + var n = Vector3.Cross(b.Origin - a.Origin, c.Origin - a.Origin); + return n.LengthSquared() > 0f ? Vector3.Normalize(n) : Vector3.Zero; +} + +static Vector3 PolyCentroidG(GfxObj g, DatReaderWriter.Types.Polygon poly) +{ + var sum = Vector3.Zero; + int count = 0; + foreach (var vid in poly.VertexIds) + if (g.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) + { + sum += v.Origin; + count++; + } + return count > 0 ? sum / count : Vector3.Zero; +} + static bool IsSupported(uint id) { uint type = id & 0xFF000000u;