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);
+ }
}