From 90aa74a3cb1094a6f3d4f0ef908fda0f1097c68c Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 11:31:46 +0200 Subject: [PATCH] fix(physics): skip collision registration for phantom-Setup scenery (small plants / grass) User asked how AC differentiates collidable vs phantom scenery. The retail signal is at the Setup level: a Setup with NO CylSpheres, NO Spheres, AND zero overall Radius is decorative -- the player walks through it. retail header docs/research/named-retail/acclient.h enum PhysicsState includes ETHEREAL_PS = 0x4 / IGNORE_COLLISIONS_PS = 0x10 for runtime-toggled flags, but the static signal for scenery is the empty Setup collision arrays. Pre-fix the mesh-bounds-fallback at GameWindow.cs:4297-4429 ran on every outdoor scenery entity regardless of Setup intent, then clamped the resulting cylinder radius to >= 0.3 m. So small plants/grass got a 0.3 m collision cylinder and blocked the player even though the Setup explicitly said no collision. Fix: before the mesh-bounds fallback, check the cached Setup. If it's a Setup-typed object (0x020xxxxx) AND CylSpheres / Spheres / Radius are all empty/zero, mark it phantom and skip the collision registration entirely. Non-phantom scenery (trees with real CylSpheres or canopy-only BSPs) still gets the mesh-bounds fallback so the player walks under canopies but bumps into trunks. Raw GfxObjs (0x010xxxxx, no Setup metadata) keep the old fallback behaviour because they don't expose phantom intent. Tests stay 1439 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 37 ++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 73ccb1c..2b9bfb0 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4284,6 +4284,36 @@ public sealed class GameWindow : IDisposable } } + // L-fix3 (2026-04-28): retail "decorative / phantom" detection. + // A Setup is phantom (no collision) when it has NO CylSpheres, + // NO Spheres, AND zero overall Radius. Small plants, grass + // tufts, flowers, ground-cover bushes all match — retail + // ships them with empty collision arrays so the player walks + // through them. Without this gate the mesh-bounds fallback + // below assigns every plant a 0.3 m+ collision cylinder + // (line ~4409 clamp) and they block the player. + // + // The gate is layered AFTER the Setup CylSphere / BSP + // registrations above (which are no-ops for phantom Setups + // anyway), so non-phantom scenery (trees with real + // CylSpheres or canopy-only BSPs) still gets the + // mesh-bounds fallback. The check is on entity.SourceGfxObjOrSetupId + // to look up the cached Setup; if it's a raw GfxObj + // (0x010xxxxx, no Setup metadata) we keep the old fallback + // behaviour because GfxObjs don't expose phantom intent. + bool isPhantomSetup = false; + if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + var setupInfoForPhantom = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setupInfoForPhantom is not null + && setupInfoForPhantom.CylSpheres.Count == 0 + && setupInfoForPhantom.Spheres.Count == 0 + && setupInfoForPhantom.Radius <= 0.0001f) + { + isPhantomSetup = true; + } + } + // VISUAL mesh-bounds collision: for SCENERY entities (IDs with // 0x80000000 bit set, indicating procedurally-placed scenery), // ALWAYS compute a cylinder from the world-space mesh AABB. @@ -4294,7 +4324,12 @@ public sealed class GameWindow : IDisposable // For stabs (low IDs) and live entities, keep the existing Setup // CylSphere / BSP registrations — those are placed with precise // frame data and don't have the scenery offset issue. - if ((_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) && entity.MeshRefs.Count > 0) + // + // L-fix3: skip entirely when the Setup is phantom — retail + // decorative meshes have no collision data on purpose. + if (!isPhantomSetup + && (_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) + && entity.MeshRefs.Count > 0) { float entScale = entity.Scale > 0f ? entity.Scale : 1f; bool haveBounds = false;