From d53891557d20b6ea47ad50083d1a60a2e6f33252 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 22:22:32 +0200 Subject: [PATCH] feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New mutator that overwrites cached PhysicsState bits on every shadow copy of the named entity. The existing CollisionExemption.ShouldSkip(...) check (acclient_2013_pseudo_c.txt:276782) reads the same cached field, so a post-spawn ETHEREAL flip is now honored on the next resolver tick without any resolver-path change. Retail anchor: CPhysicsObj::set_state at acclient_2013_pseudo_c.txt:283044. Slice 1 scopes to the bare state-write — retail's cosmetic side-effect handlers (0x800 lighting, 0x20 nodraw, 0x4000 hidden) don't fire for the ETHEREAL bit and stay deferred. Three TDD tests cover: ETHEREAL flip from 0->0x4; unregistered-entity no-op; entity spanning multiple cells gets all copies updated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Physics/ShadowObjectRegistry.cs | 41 ++++++++++++++++ .../Physics/ShadowObjectRegistryTests.cs | 49 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index 6b4ea11..fd3673e 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -126,6 +126,47 @@ public sealed class ShadowObjectRegistry t.State, t.Flags); } + /// + /// Update the cached bits for an + /// already-registered entity. Called by the inbound + /// SetState (0xF74B) dispatcher when the server broadcasts a + /// post-spawn PhysicsState change — chiefly doors flipping + /// ETHEREAL_PS = 0x4 on Use, so the + /// short-circuit can honor + /// the new state on the next resolve. + /// + /// + /// Retail equivalent: CPhysicsObj::set_state at + /// docs/research/named-retail/acclient_2013_pseudo_c.txt:283044 + /// — direct write `this->state = arg2`. Retail also fires side-effect + /// handlers for the 0x800 (lighting), 0x20 (nodraw), 0x4000 (hidden) + /// changed bits; ETHEREAL (0x4) doesn't trigger any of them, so slice 1 + /// scopes to the bare state-write. + /// + /// + /// + /// Implementation: is a value-type record + /// copied into per-cell lists, so we rewrite the copy in each cell the + /// entity occupies. Unregistered entities are a no-op (callers don't + /// have to gate). + /// + /// + public void UpdatePhysicsState(uint entityId, uint newState) + { + if (!_entityToCells.TryGetValue(entityId, out var cellIds)) + return; // not registered — no-op + + foreach (var cellId in cellIds) + { + if (!_cells.TryGetValue(cellId, out var list)) continue; + for (int i = 0; i < list.Count; i++) + { + if (list[i].EntityId == entityId) + list[i] = list[i] with { State = newState }; + } + } + } + /// Remove an entity from all cells it was registered in. public void Deregister(uint entityId) { diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs index 73143d8..f3d7b08 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; @@ -252,4 +253,52 @@ public class ShadowObjectRegistryTests Assert.Equal(0u, entry.State); Assert.Equal(EntityCollisionFlags.None, entry.Flags); } + + // ----------------------------------------------------------------------- + // UpdatePhysicsState — L.2g slice 1 (doors flip ETHEREAL post-spawn) + // ----------------------------------------------------------------------- + + [Fact] + public void UpdatePhysicsState_FlipsEthereal_NextLookupSeesNewBits() + { + var reg = new ShadowObjectRegistry(); + const uint doorId = 0x000F4244u; + reg.Register(doorId, 0x020019FFu, new Vector3(12f, 12f, 50f), + Quaternion.Identity, 1f, OffX, OffY, LbId, + state: 0u, flags: EntityCollisionFlags.None); + + var before = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0u, before.State); + + reg.UpdatePhysicsState(doorId, 0x00000004u); + + var after = reg.AllEntriesForDebug().Single(e => e.EntityId == doorId); + Assert.Equal(0x00000004u, after.State); + } + + [Fact] + public void UpdatePhysicsState_UnregisteredEntity_IsNoOp() + { + var reg = new ShadowObjectRegistry(); + reg.UpdatePhysicsState(0xDEADBEEFu, 0x00000004u); + Assert.Equal(0, reg.TotalRegistered); + } + + [Fact] + public void UpdatePhysicsState_EntitySpanningMultipleCells_AllCellsUpdated() + { + var reg = new ShadowObjectRegistry(); + reg.Register(99u, 0x01000099u, new Vector3(24f, 12f, 50f), + Quaternion.Identity, 2f, OffX, OffY, LbId, + state: 0u); + + reg.UpdatePhysicsState(99u, 0x00000004u); + + uint cellA = LbId | 1u; + uint cellB = LbId | (1u*8 + 0 + 1); + var inA = reg.GetObjectsInCell(cellA).Single(e => e.EntityId == 99u); + var inB = reg.GetObjectsInCell(cellB).Single(e => e.EntityId == 99u); + Assert.Equal(0x00000004u, inA.State); + Assert.Equal(0x00000004u, inB.State); + } }