From 46ca3ba26bd87a4f1d9fb53ae92394cae31f00cf Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 13:16:22 +0200 Subject: [PATCH] feat(physics): live-entity collision registration (Commit B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NPCs / monsters / other players now register into ShadowObjectRegistry as collision targets. The local player walks into them and stops at the body cylinder, instead of passing through. GameWindow.OnLiveEntitySpawnedLocked: after the WorldEntity is built and stored in `_entitiesByServerGuid`, call the new RegisterLiveEntityCollision helper for any non-self entity. The helper honors retail's geometry-priority order (acclient_2013_pseudo_c.txt: 276858-276987) — CylSpheres > Setup.Radius > Sphere fallback — and applies the retail phantom-Setup skip (no CylSpheres / no Spheres / zero Radius → walk-through, matching FUN_FindObjCollisions's OK_TS fallthrough at :276917). GameWindow.OnLivePositionUpdated: after the entity's render pos/rot are set to server truth, push the same coordinates into the registry via ShadowObjectRegistry.UpdatePosition (the cheap preserve-shape-and-flags path Commit A added). Mirrors retail's SetPosition → change_cell → AddShadowObject chain ( acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). The local player's own server guid is filtered out at both registration and update — its PhysicsBody is the simulator (the source of truth for our collisions), not a collision target. The decoded EntityCollisionFlags + raw PhysicsState bits are stored on each ShadowEntry but NOT YET CONSULTED by the collision resolver — Commit C is where the PvP exemption block lands. Practical effect of THIS commit: every visible body, including non-PK other players, blocks the local player. Two non-PK players currently can't pass through each other; that's the rule Commit C reverts to retail. No new unit tests in this commit (Commit A's ShadowObjectRegistry + EntityCollisionFlags suite covers the new field plumbing). Verification is live: at Holtburg the +Acdream test character should stop on contact with NPCs / vendors. Phantom decorations (small plants, grass) continue to pass through (L-fix3 phantom skip extends naturally to the live path via the same Setup-shape gate). dotnet build green, dotnet test 1454 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2b9bfb0..ba3c978 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2067,6 +2067,18 @@ public sealed class GameWindow : IDisposable // UpdateMotion / UpdatePosition events can reseat this entity by guid. _entitiesByServerGuid[spawn.Guid] = entity; + // Commit B 2026-04-29 — live-entity collision registration. The + // local player is the simulator (its PhysicsBody is the source of + // truth for our own movement); only remotes register as targets. + // Phantom-Setup entities (no CylSpheres / no Spheres / no Radius) + // are deliberately skipped — retail FUN's `FindObjCollisions` + // falls through to OK_TS for any object with no collision + // geometry (acclient_2013_pseudo_c.txt:276917,276987). + if (spawn.Guid != _playerServerGuid) + { + RegisterLiveEntityCollision(entity, setup, spawn, origin); + } + // Phase B.2: capture the server-sent MotionTableId for our own // character so UpdatePlayerAnimation can pass it to GetIdleCycle. // The Setup's DefaultMotionTable is often 0 for human characters; @@ -2284,6 +2296,100 @@ public sealed class GameWindow : IDisposable } } + /// + /// Commit B 2026-04-29 — register a live (server-spawned) entity into + /// the as a single collision body. + /// One entry per entity (in contrast to static scenery's per-CylSphere + /// registration) so RemoveLiveEntityByServerGuid's single + /// Deregister(entity.Id) cleans it up without leaks. + /// + /// + /// Geometry-priority order matches retail + /// (acclient_2013_pseudo_c.txt:276858-276987): CylSpheres > + /// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are + /// rejected — retail's FindObjCollisions falls through to + /// OK_TS in that case. + /// + /// + /// + /// Carries derived from the PWD + /// bitfield (acclient.h:6431-6463) plus IsCreature + /// derived from the inbound ItemType. Commit C consumes these in + /// the PvP exemption block. + /// + /// + private void RegisterLiveEntityCollision( + AcDream.Core.World.WorldEntity entity, + DatReaderWriter.DBObjs.Setup setup, + AcDream.Core.Net.WorldSession.EntitySpawn spawn, + System.Numerics.Vector3 origin) + { + if (spawn.Position is null) return; + + bool hasCyl = setup.CylSpheres.Count > 0; + bool hasSphere = setup.Spheres.Count > 0; + bool hasRadius = setup.Radius > 0.0001f; + + // Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917). + if (!hasCyl && !hasSphere && !hasRadius) + return; + + float entScale = spawn.ObjScale ?? 1.0f; + float radius; + float height; + + if (hasCyl) + { + // Pick the largest CylSphere as the body cylinder. Retail + // tests every CylSphere in turn (276891) but for collision + // BLOCKING the largest is sufficient — the player will stop + // at the body's outer radius. + var sph = setup.CylSpheres[0]; + for (int i = 1; i < setup.CylSpheres.Count; i++) + { + if (setup.CylSpheres[i].Radius > sph.Radius) sph = setup.CylSpheres[i]; + } + radius = sph.Radius * entScale; + height = (sph.Height > 0 ? sph.Height : sph.Radius * 4f) * entScale; + } + else if (hasRadius) + { + radius = setup.Radius * entScale; + height = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale; + } + else + { + // Sphere-only: largest sphere as a Cylinder approximation. + var sph = setup.Spheres[0]; + for (int i = 1; i < setup.Spheres.Count; i++) + { + if (setup.Spheres[i].Radius > sph.Radius) sph = setup.Spheres[i]; + } + radius = sph.Radius * entScale; + height = sph.Radius * 2f * entScale; + } + + if (radius <= 0f) return; + + // Decode PvP / Player / Impenetrable from PWD._bitfield. + // IsCreature comes from the spawn's ItemType (server-known type). + var flags = AcDream.Core.Physics.EntityCollisionFlags.None; + if (spawn.ObjectDescriptionFlags is { } odf) + flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf); + if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature) + flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature; + + uint state = spawn.PhysicsState ?? 0u; + + _physicsEngine.ShadowObjects.Register( + entity.Id, entity.SourceGfxObjOrSetupId, + entity.Position, entity.Rotation, radius, + origin.X, origin.Y, spawn.Position.Value.LandblockId, + AcDream.Core.Physics.ShadowCollisionType.Cylinder, + cylHeight: height, scale: 1.0f, + state: state, flags: flags); + } + private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) { if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) @@ -2980,6 +3086,18 @@ public sealed class GameWindow : IDisposable entity.Position = worldPos; entity.Rotation = rot; + // Commit B 2026-04-29 — keep the shadow registry in sync with + // server-authoritative position so the player's collision broadphase + // tests against the up-to-date target body. Skip the local player + // (its body is the simulator, not a target). Retail does the + // equivalent via SetPosition → change_cell → AddShadowObject + // (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862). + if (update.Guid != _playerServerGuid) + { + _physicsEngine.ShadowObjects.UpdatePosition( + entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId); + } + // Track remote-entity motion for stop detection. Only record the // timestamp when position moved MEANINGFULLY (> 0.05m). Updates // that report the same position keep the old Time, so the