acdream/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
Erik d53891557d feat(phys L.2g slice 1): ShadowObjectRegistry.UpdatePhysicsState
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) <noreply@anthropic.com>
2026-05-12 22:22:32 +02:00

325 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Cell-based spatial index for object collision. Each entity registers
/// into the outdoor terrain cells (24m × 24m) it overlaps. The Transition
/// system queries this to find nearby objects during collision detection.
///
/// Retail AC uses the same cell-based approach (no k-d tree / octree).
/// Outdoor cells are 24×24m (8 cells per 192m landblock, 64 cells per lb).
/// Cell ID = landblock high 16 bits | (cellX * 8 + cellY + 1) in low 16.
/// </summary>
public sealed class ShadowObjectRegistry
{
private readonly Dictionary<uint, List<ShadowEntry>> _cells = new();
private readonly Dictionary<uint, List<uint>> _entityToCells = new(); // for deregistration
/// <summary>
/// Register an entity into the cells it overlaps based on world position + radius.
///
/// <para>
/// The optional <paramref name="state"/> + <paramref name="flags"/>
/// parameters carry retail <c>PhysicsState</c> bits and decoded
/// <see cref="EntityCollisionFlags"/> respectively, so the
/// <c>FindObjCollisions</c> retail-faithful exemption block (PvP rule,
/// ETHEREAL skip, viewer-vs-creature) can short-circuit without an
/// extra lookup. Default <c>state=0</c> + <c>flags=None</c> preserves
/// the original "static decoration" behavior — the existing 5
/// landblock-entity registration sites pass nothing.
/// </para>
/// </summary>
public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
float radius, float worldOffsetX, float worldOffsetY, uint landblockId,
ShadowCollisionType collisionType = ShadowCollisionType.BSP,
float cylHeight = 0f, float scale = 1.0f,
uint state = 0u,
EntityCollisionFlags flags = EntityCollisionFlags.None)
{
Deregister(entityId);
// The radius parameter should already be the WORLD-SPACE bounding
// radius (i.e., already multiplied by scale) so the broad-phase cell
// occupancy is correct. Callers are responsible for that.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
int minCx = Math.Max(0, (int)((localX - radius) / 24f));
int maxCx = Math.Min(7, (int)((localX + radius) / 24f));
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius,
collisionType, cylHeight, scale, state, flags);
var cellIds = new List<uint>();
uint lbPrefix = landblockId & 0xFFFF0000u;
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = lbPrefix | (uint)(cx * 8 + cy + 1);
cellIds.Add(cellId);
if (!_cells.TryGetValue(cellId, out var list))
{
list = new List<ShadowEntry>();
_cells[cellId] = list;
}
list.Add(entry);
}
}
_entityToCells[entityId] = cellIds;
}
/// <summary>
/// Update an already-registered entity's world position + rotation,
/// preserving its <see cref="ShadowEntry.State"/>,
/// <see cref="ShadowEntry.Flags"/>, and shape parameters.
///
/// <para>
/// Cheaper than <see cref="Deregister"/> + <see cref="Register"/> for
/// the 510 Hz <c>UpdatePosition (0xF748)</c> stream the server emits
/// per visible entity: this is the path retail's
/// <c>CPhysicsObj::SetPosition</c> takes (cited at
/// <c>acclient_2013_pseudo_c.txt:284276</c>) — same shape, new cell
/// membership. If the entity isn't already registered, this is a
/// no-op so callers don't have to gate.
/// </para>
/// </summary>
public void UpdatePosition(uint entityId, Vector3 worldPos, Quaternion rotation,
float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Find the existing entry (any cell holds a copy with the same
// entity-scoped state + flags + shape).
if (!_entityToCells.TryGetValue(entityId, out var oldCells) || oldCells.Count == 0)
return;
ShadowEntry? template = null;
foreach (var oldCellId in oldCells)
{
if (_cells.TryGetValue(oldCellId, out var list))
{
foreach (var e in list)
{
if (e.EntityId == entityId)
{
template = e;
break;
}
}
}
if (template is not null) break;
}
if (template is null)
return;
// Preserve everything except position + rotation.
var t = template.Value;
Register(entityId, t.GfxObjId, worldPos, rotation, t.Radius,
worldOffsetX, worldOffsetY, landblockId,
t.CollisionType, t.CylHeight, t.Scale,
t.State, t.Flags);
}
/// <summary>
/// Update the cached <see cref="ShadowEntry.State"/> bits for an
/// already-registered entity. Called by the inbound
/// <c>SetState (0xF74B)</c> dispatcher when the server broadcasts a
/// post-spawn <c>PhysicsState</c> change — chiefly doors flipping
/// <c>ETHEREAL_PS = 0x4</c> on Use, so the
/// <see cref="CollisionExemption.ShouldSkip"/> short-circuit can honor
/// the new state on the next resolve.
///
/// <para>
/// Retail equivalent: <c>CPhysicsObj::set_state</c> at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:283044</c>
/// — 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.
/// </para>
///
/// <para>
/// Implementation: <see cref="ShadowEntry"/> 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).
/// </para>
/// </summary>
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 };
}
}
}
/// <summary>Remove an entity from all cells it was registered in.</summary>
public void Deregister(uint entityId)
{
if (!_entityToCells.TryGetValue(entityId, out var cellIds))
return;
foreach (var cellId in cellIds)
{
if (_cells.TryGetValue(cellId, out var list))
list.RemoveAll(e => e.EntityId == entityId);
}
_entityToCells.Remove(entityId);
}
/// <summary>Remove all entities belonging to a landblock.</summary>
public void RemoveLandblock(uint landblockId)
{
uint lbPrefix = landblockId & 0xFFFF0000u;
var toRemove = new List<uint>();
foreach (var kvp in _cells)
{
if ((kvp.Key & 0xFFFF0000u) == lbPrefix)
toRemove.Add(kvp.Key);
}
foreach (var cellId in toRemove)
_cells.Remove(cellId);
// Clean up entity-to-cell map
var entitiesToRemove = new List<uint>();
foreach (var kvp in _entityToCells)
{
kvp.Value.RemoveAll(c => (c & 0xFFFF0000u) == lbPrefix);
if (kvp.Value.Count == 0)
entitiesToRemove.Add(kvp.Key);
}
foreach (var eid in entitiesToRemove)
_entityToCells.Remove(eid);
}
/// <summary>Get all objects registered in a specific cell.</summary>
public IReadOnlyList<ShadowEntry> GetObjectsInCell(uint cellId)
{
if (_cells.TryGetValue(cellId, out var list))
return list;
return Array.Empty<ShadowEntry>();
}
/// <summary>
/// Get all objects near a world position. Searches the given landblock plus
/// all 8 adjacent landblocks to handle objects near cell/landblock boundaries.
/// Within each landblock, queries only the cells the query sphere overlaps.
/// </summary>
public void GetNearbyObjects(Vector3 worldPos, float queryRadius,
float worldOffsetX, float worldOffsetY, uint landblockId,
List<ShadowEntry> results)
{
results.Clear();
var seen = new HashSet<uint>();
// Extract landblock X/Y from the ID.
int lbX = (int)((landblockId >> 24) & 0xFF);
int lbY = (int)((landblockId >> 16) & 0xFF);
// Search the player's landblock and all 8 neighbors.
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
int nx = lbX + dx;
int ny = lbY + dy;
if (nx < 0 || nx > 255 || ny < 0 || ny > 255) continue;
uint neighborLb = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
uint nbPrefix = neighborLb & 0xFFFF0000u;
// Compute local position relative to this neighbor landblock.
float nbOffX = worldOffsetX + dx * 192f;
float nbOffY = worldOffsetY + dy * 192f;
float localX = worldPos.X - nbOffX;
float localY = worldPos.Y - nbOffY;
int minCx = Math.Max(0, (int)((localX - queryRadius) / 24f));
int maxCx = Math.Min(7, (int)((localX + queryRadius) / 24f));
int minCy = Math.Max(0, (int)((localY - queryRadius) / 24f));
int maxCy = Math.Min(7, (int)((localY + queryRadius) / 24f));
for (int cx = minCx; cx <= maxCx; cx++)
{
for (int cy = minCy; cy <= maxCy; cy++)
{
uint cellId = nbPrefix | (uint)(cx * 8 + cy + 1);
if (!_cells.TryGetValue(cellId, out var list)) continue;
foreach (var entry in list)
{
if (seen.Add(entry.EntityId))
results.Add(entry);
}
}
}
}
}
}
public int TotalRegistered => _entityToCells.Count;
/// <summary>
/// Debug: enumerate every registered ShadowEntry (deduplicated across cells).
/// For each entity, returns the first entry found in any cell it occupies.
/// Intended for debug rendering only.
/// </summary>
public IEnumerable<ShadowEntry> AllEntriesForDebug()
{
var seen = new HashSet<uint>();
foreach (var kvp in _cells)
{
foreach (var entry in kvp.Value)
{
if (seen.Add(entry.EntityId))
yield return entry;
}
}
}
}
/// <summary>
/// Collision type for a shadow entry. BSP uses full polygon collision.
/// Cylinder uses a simple cylinder-sphere intersection test.
/// </summary>
public enum ShadowCollisionType : byte { BSP, Cylinder }
public readonly record struct ShadowEntry(
uint EntityId,
uint GfxObjId,
Vector3 Position,
Quaternion Rotation,
float Radius,
ShadowCollisionType CollisionType = ShadowCollisionType.BSP,
float CylHeight = 0f,
float Scale = 1.0f,
/// <summary>
/// Retail <c>PhysicsState</c> bits (<c>acclient.h:2815</c>). Used
/// by <c>FindObjCollisions</c> to honor <c>ETHEREAL_PS=0x4</c> +
/// <c>IGNORE_COLLISIONS_PS=0x10</c> short-circuits. Zero for static
/// landblock entities (default behavior matches pre-Commit-A).
/// </summary>
uint State = 0u,
/// <summary>
/// Decoded player / PK / PKLite / Impenetrable flags driving the
/// retail PvP exemption block in <c>FindObjCollisions</c>. Built
/// from <c>PWD._bitfield</c> at <c>CreateObject</c> time via
/// <see cref="EntityCollisionFlagsExt.FromPwdBitfield(uint)"/>.
/// </summary>
EntityCollisionFlags Flags = EntityCollisionFlags.None);