Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196

# Conflicts:
#	docs/ISSUES.md
#	docs/architecture/retail-divergence-register.md
This commit is contained in:
Erik 2026-07-09 23:18:52 +02:00
commit 217a4bad69
329 changed files with 81439 additions and 8499 deletions

View file

@ -38,6 +38,7 @@
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.UI.Abstractions\AcDream.UI.Abstractions.csproj" />
<ProjectReference Include="..\AcDream.UI.ImGui\AcDream.UI.ImGui.csproj" />
</ItemGroup>

View file

@ -0,0 +1,213 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AcDream.Core.Rendering;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>Stage indices for per-frame CPU attribution.</summary>
public enum FrameStage
{
/// <summary>Whole OnUpdate body (simulation + streaming apply).</summary>
Update = 0,
/// <summary>WbMeshAdapter.Tick — staged mesh/texture GPU upload drain.</summary>
Upload = 1,
/// <summary>ImGui Render (dev overlay).</summary>
ImGui = 2,
}
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
/// bool check per frame when off.
///
/// <para>Permanent apparatus — every MP-track gate reads it; do not strip.
/// Whole-frame GPU timing self-disables under <c>ACDREAM_WB_DIAG=1</c>
/// (nested TimeElapsed is illegal GL; see GpuFrameTimer).</para>
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity);
private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity);
private readonly FrameStatsBuffer[] _stageUs;
private readonly long[] _stageAccumTicks;
private readonly bool _wbDiagActive =
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
private long _lastAllocBytes;
private long _lastReportTicks;
private int _gc0Base, _gc1Base, _gc2Base;
private int _framesInWindow;
private int _ownerThreadId;
private bool _threadWarned;
private bool _wbDiagNoticePrinted;
private bool _wasEnabled;
public FrameProfiler()
{
_stageUs = new FrameStatsBuffer[StageCount];
for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity);
_stageAccumTicks = new long[StageCount];
}
/// <summary>
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
/// </summary>
public void FrameBoundary(GL gl)
{
bool enabled = RenderingDiagnostics.FrameProfEnabled;
if (!enabled)
{
if (_wasEnabled)
{
// Dispose (not just Stop) so a later re-enable rebuilds the
// query ring fresh — a kept instance would poll slots left
// pending from BEFORE the pause and report temporally stale
// GPU samples. Safe here: this runs at the top of OnRender
// with the GL context current.
_gpuTimer?.Dispose();
_gpuTimer = null;
_wasEnabled = false;
_lastBoundaryTimestamp = 0;
}
return;
}
if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId;
else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId)
{
_threadWarned = true;
Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable");
}
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
// baselines, emit nothing. Clear any stage ticks a StageScope
// disposed after toggle-off may have accumulated mid-pause —
// EndStage still runs on scopes that were live when the flag
// flipped, and that partial delta must not leak into the first
// re-enabled frame.
_wasEnabled = true;
_lastReportTicks = DateTime.UtcNow.Ticks;
Array.Clear(_stageAccumTicks);
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)");
}
}
else
{
long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency;
_cpuUs.Push(cpuUs);
_allocBytes.Push(allocNow - _lastAllocBytes);
for (int i = 0; i < StageCount; i++)
{
_stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency);
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
if (_gpuTimer?.FrameBoundary() is long gpuUs)
_gpuUs.Push(gpuUs);
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
int gc0 = GC.CollectionCount(0) - _gc0Base;
int gc1 = GC.CollectionCount(1) - _gc1Base;
int gc2 = GC.CollectionCount(2) - _gc2Base;
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
_lastReportTicks = nowTicks;
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
_framesInWindow = 0;
_cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset();
for (int i = 0; i < StageCount; i++) _stageUs[i].Reset();
}
}
/// <summary>
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
/// Zero-cost (default scope) when the profiler is off.
/// </summary>
public StageScope BeginStage(FrameStage stage)
=> RenderingDiagnostics.FrameProfEnabled
? new StageScope(this, stage, Stopwatch.GetTimestamp())
: default;
internal void EndStage(FrameStage stage, long startTimestamp)
=> _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp;
/// <summary>Pure report formatter — unit-tested; invariant culture.</summary>
public static string FormatReport(
int frameCount,
FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive,
FrameStatsBuffer alloc, int gc0, int gc1, int gc2,
FrameStatsBuffer[] stages)
{
var ci = CultureInfo.InvariantCulture;
var sb = new StringBuilder(256);
sb.Append("[frame-prof] n=").Append(frameCount);
sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}",
cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0,
cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0);
if (gpuActive)
sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}",
gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0);
else
sb.Append(" | gpu=off(wbdiag)");
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}",
alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2);
string[] names = { "upd", "upl", "imgui" };
for (int i = 0; i < stages.Length && i < names.Length; i++)
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}",
names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);
return sb.ToString();
}
public void Dispose() => _gpuTimer?.Dispose();
}
/// <summary>Disposable stage scope; default instance is a no-op.</summary>
public readonly struct StageScope : IDisposable
{
private readonly FrameProfiler? _owner;
private readonly FrameStage _stage;
private readonly long _start;
internal StageScope(FrameProfiler owner, FrameStage stage, long start)
{
_owner = owner; _stage = stage; _start = start;
}
public void Dispose() => _owner?.EndStage(_stage, _start);
}

View file

@ -0,0 +1,69 @@
using System;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples
/// (microseconds or bytes) with percentile/max over the current window.
/// Pure and allocation-free after construction: <see cref="Percentile"/>
/// sorts into a preallocated scratch array, so the 5-second report path
/// allocates nothing. Not thread-safe — owned by the window loop thread.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public sealed class FrameStatsBuffer
{
private readonly long[] _samples;
private readonly long[] _scratch;
private int _cursor;
private int _count;
public FrameStatsBuffer(int capacity)
{
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
_samples = new long[capacity];
_scratch = new long[capacity];
}
public int Count => _count;
public void Push(long value)
{
_samples[_cursor] = value;
_cursor = (_cursor + 1) % _samples.Length;
if (_count < _samples.Length) _count++;
}
public void Reset()
{
_cursor = 0;
_count = 0;
}
/// <summary>
/// Nearest-rank percentile over the current window: element at
/// ceil(q·n) in the ascending sort (1-based), 0 when empty.
/// </summary>
public long Percentile(double q)
{
if (_count == 0) return 0;
Array.Copy(_samples, _scratch, _count);
Array.Sort(_scratch, 0, _count);
int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank
if (rank < 1) rank = 1;
if (rank > _count) rank = _count;
return _scratch[rank - 1];
}
public long Max()
{
if (_count == 0) return 0; // documented empty behavior, matches Percentile
// Seed from the first live sample so all-negative windows (the alloc
// channel can go negative if the boundary ever crosses threads) return
// the true max instead of clamping to 0. Slots [0.._count) are always
// the live window regardless of ring wraparound.
long max = _samples[0];
for (int i = 1; i < _count; i++)
if (_samples[i] > max) max = _samples[i];
return max;
}
}

View file

@ -0,0 +1,87 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
private const int RingDepth = 4;
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
public GpuFrameTimer(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
for (int i = 0; i < RingDepth; i++)
_queries[i] = _gl.GenQuery();
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// </summary>
public long? FrameBoundary()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
}
public void Dispose()
{
Stop();
for (int i = 0; i < RingDepth; i++)
_gl.DeleteQuery(_queries[i]);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,619 @@
using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion;
using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity;
namespace AcDream.App.Physics;
/// <summary>
/// #184 Slice 2a — the per-remote dead-reckoning physics tick, extracted
/// verbatim from <c>GameWindow.TickAnimations</c> (Code Structure Rule 1: no
/// new feature bodies in the &gt;10k-line <c>GameWindow</c>). One instance is
/// owned by <c>GameWindow</c> and called once per animated remote entity per
/// frame, from inside the same guard the body used to live under
/// (<c>ae.Sequencer != null &amp;&amp; serverGuid != 0 &amp;&amp; serverGuid != _playerServerGuid
/// &amp;&amp; rm.LastServerPosTime &gt; 0</c>).
///
/// <para>Slice 2a extracted this verbatim (fork intact). Slice 2b then COLLAPSED
/// the player/NPC fork: the former Path A (grounded PLAYER remotes advanced by the
/// interp catch-up with the sweep deliberately OMITTED, per the now-retired issue
/// #40 premise) is gone — <b>every</b> remote now runs the SAME catch-up +
/// <c>ResolveWithTransition</c> sweep + shadow-follows-resolved, so packed PLAYER
/// remotes de-overlap exactly like NPCs (retail <c>UpdateObjectInternal</c>
/// 0x005156b0 has no player/remote fork). The only surviving player/NPC split is
/// the omega handling (players keep the <c>ObservedOmega||seqOmega</c> world-frame
/// fallback; NPCs + airborne bodies use <c>ObservedOmega</c>-only body-frame) and
/// the <c>!IsPlayerGuid</c>-gated stale-velocity anim-cycle stop. See
/// <c>docs/research/2026-07-07-184-slice2-unify-extract-handoff.md</c>.</para>
///
/// <para>Shared helpers that GameWindow also calls elsewhere are injected:
/// <c>GetSetupCylinder</c> (a general Setup-dimension helper with ~9 callers,
/// incl. the local player's own cylinder — kept on GameWindow) and
/// <c>ApplyServerControlledVelocityCycle</c> (anim-cycle selection, also called
/// from the UP handler) arrive as delegates. <c>SyncRemoteShadowToBody</c>
/// (remote-physics-specific) moved here and is called back from the UP-branch
/// tail; <c>ApplyPositionManagerDelta</c> / <c>TickRemoteMoveTo</c> had no other
/// callers and moved here outright.</para>
/// </summary>
internal sealed class RemotePhysicsUpdater
{
// Moved from GameWindow (#184 Slice 2a — the DR tick was its only caller).
private const double ServerControlledVelocityStaleSeconds = 0.60;
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine;
private readonly System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
internal RemotePhysicsUpdater(
AcDream.Core.Physics.PhysicsEngine physicsEngine,
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
{
_physicsEngine = physicsEngine;
_getSetupCylinder = getSetupCylinder;
_applyServerControlledVelocityCycle = applyServerControlledVelocityCycle;
}
// Duplicated one-liner (GameWindow keeps its own copy — many callers there).
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
/// <summary>
/// #184 Slice 2a — the per-remote DR tick (retail <c>UpdateObjectInternal</c>
/// shape), verbatim from the former <c>GameWindow.TickAnimations</c> guard
/// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor).
/// </summary>
public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
// movement managers' UseTime. This is where this entity, as a
// watched target, pushes its position to its voyeurs (any entity
// moving-to it), and where its own target-info staleness times
// out. Runs for every remote regardless of the grounded/airborne
// branch below (which drive MoveToManager.UseTime via
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
rm.Host?.HandleTargetting();
// #184 Slice 2b — the UNIFIED per-remote tick. The former Path A
// (grounded PLAYER remotes: interp catch-up with the ResolveWithTransition
// sweep OMITTED, per the now-retired issue-#40 "collision is the sender's
// problem" premise) is GONE — every remote now runs the SAME catch-up +
// sweep + shadow-follows-resolved, so packed PLAYER remotes de-overlap
// exactly like NPCs. Retail's UpdateObjectInternal (0x005156b0) has NO
// player/remote fork; the only surviving player/NPC split is the omega
// handling (Step 2 below) and the !IsPlayerGuid-gated anim-cycle stop.
//
// Stop detection stays explicit on packet receipt (UpdateMotion
// ForwardCommand cleared -> Ready; UpdatePosition HasVelocity cleared ->
// StopCompletely). Mirrors retail update_object -> UpdatePositionInternal
// -> UpdatePhysicsInternal (FUN_00515020 / FUN_00513730 / FUN_005111D0).
// The bare block scopes this update's locals (formerly the else body).
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
// simulate airborne physics for them).
//
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
// Otherwise the force-OnWalkable + apply_current_movement
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
// and gravity never gets to integrate the arc. The airborne
// body keeps the launch velocity from the VectorUpdate;
// UpdatePhysicsInternal below applies gravity each tick;
// the next UpdatePosition snaps to the new ground location
// and re-grounds.
if (!rm.Airborne)
{
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
// #184 (2026-07-07): a grounded remote carries NO translation
// velocity. Its per-tick movement is the interp CATCH-UP toward
// the MoveOrTeleport-queued server waypoint (computed at the
// sticky-compose site below), which the KEPT ResolveWithTransition
// sweep de-overlaps against neighbours — and the resolved position
// is written back into the SHADOW (below) so the de-overlap
// persists and neighbours collide against the resolved body, not
// the raw server pos. This REPLACES the old synth-velocity model
// (get_state_velocity / SERVERVEL Body.Velocity = ServerVelocity):
// retail's UpdateObjectInternal (0x005156b0) has NO synth-velocity
// leg — a remote translates by adjust_offset and the UP is a gentle
// target. As of #184 Slice 2b this grounded model is the SINGLE
// remote path (players + NPCs) — retail has no fork.
rm.Body.Velocity = System.Numerics.Vector3.Zero;
// Stale server-velocity → stop the locomotion CYCLE (the legs).
// ANIM ONLY — translation is the catch-up. Kept verbatim (same
// !moveToArmed && !stickyArmed gate) from the old SERVERVEL branch
// so a scripted-path NPC that stops server-side drops out of its
// walk/run cycle; ApplyServerControlledVelocityCycle selects the
// anim from ServerVelocity, independent of Body.Velocity.
bool moveToArmed = rm.MoveTo is
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
bool stickyArmed =
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
&& !moveToArmed && !stickyArmed)
{
double velocityAge = nowSec - rm.LastServerPosTime;
if (velocityAge > ServerControlledVelocityStaleSeconds)
{
rm.ServerVelocity = System.Numerics.Vector3.Zero;
rm.HasServerVelocity = false;
_applyServerControlledVelocityCycle(
serverGuid,
ae,
rm,
System.Numerics.Vector3.Zero);
}
}
// R4-V4: tick the MoveToManager UNCONDITIONALLY (retail
// MovementManager::UseTime per tick, UpdateObjectInternal call
// @0x00515998) — UseTime runs HandleMoveToPosition /
// HandleTurnToHeading (steering + arrival + fail-distance),
// dispatching its per-node locomotion (turn / RunForward) through
// the sink (the LEGS). Position comes from the catch-up; legs from
// this per-node dispatch + the funnel. The #170-deleted per-frame
// apply_current_movement is NOT reintroduced.
TickRemoteMoveTo(rm);
}
else
{
// Airborne — keep Active flag (so UpdatePhysicsInternal
// doesn't early-return) but DON'T set Contact / OnWalkable.
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
}
// Step 2: integrate rotation manually per tick. We can't
// rely on PhysicsBody.update_object here — its MinQuantum
// gate (1/30 s) causes it to SKIP integration when our
// 60fps render dt (~0.016s) is below the quantum, meaning
// rotation never advances. Measured snap per UP was ~129°
// = the full expected 1s × 2.24 rad/s, confirming zero
// between-tick rotation.
//
// Manual integration matches retail's FUN_005256b0
// apply_physics (Orientation *= quat(ω × dt)). Use
// ObservedOmega derived from server UP rotation deltas so
// the rate exactly matches server physics — hard-snap on
// next UP becomes invisible by construction.
// #184 Slice 2b: PLAYERS keep the ObservedOmega||seqOmega fallback +
// world-frame (pre-multiply, Concatenate) application inherited from the
// former Path A — a circling player sends RunForward+TurnLeft on ONE UM
// whose RunForward cycle synthesises zero omega, so ObservedOmega (from
// the wire TurnCommand) must carry the turn or the body would not rotate
// between UPs ("rectangle when running circles"). NPCs + AIRBORNE bodies
// keep ObservedOmega-only, body-frame (post-multiply, Multiply) — a
// seqOmega fallback would change NPC turning (handoff 4.1), so the split
// is preserved. For an upright body + a yaw (world-Z) omega the two
// multiplication orders commute, so this fork is faithful, not cosmetic.
// calc_acceleration zeroes Body.Omega for grounded bodies before
// UpdatePhysicsInternal; the explicit zero here covers the airborne case
// (a wire-set Body.Omega would otherwise double-integrate on top of the
// manual rotation).
rm.Body.Omega = System.Numerics.Vector3.Zero;
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
{
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
?? System.Numerics.Vector3.Zero;
System.Numerics.Vector3 omegaToApply =
rm.ObservedOmega.LengthSquared() > 1e-9f ? rm.ObservedOmega : seqOmega;
if (omegaToApply.LengthSquared() > 1e-9f)
{
float angleDelta = omegaToApply.Length() * (float)dt;
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
}
}
else if (rm.ObservedOmega.LengthSquared() > 1e-8f)
{
float omegaMag = rm.ObservedOmega.Length();
var axis = rm.ObservedOmega / omegaMag;
float angle = omegaMag * dt;
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
}
// Step 3: integrate physics — retail FUN_005111D0
// UpdatePhysicsInternal. Pure Euler:
// position += velocity × dt + 0.5 × accel × dt²
//
// Call UpdatePhysicsInternal DIRECTLY rather than via
// PhysicsBody.update_object (FUN_00515020). update_object gates
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
// deltaTime < MinQuantum → early return AND LastUpdateTime is
// NOT advanced. Net effect: position never integrates between
// UpdatePositions and the only Body.Position changes come
// from the UP hard-snap, producing a visible teleport-stride
// on slopes (the "staircase" the user reported).
//
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
// directly for the same reason. Remote motion mirrors that.
// Omega is already integrated manually above, so we zero it
// here to prevent UpdatePhysicsInternal's own omega pass from
// double-integrating.
var preIntegratePos = rm.Body.Position;
// R5-V3 (#171) + #184 (2026-07-07): retail chains Interpolation →
// Sticky over ONE shared delta frame (PositionManager::adjust_offset
// 0x00555190), composed BEFORE UpdatePhysicsInternal + the transition
// sweep so collision resolves whichever movement won (preIntegratePos
// captured first — the sweep covers it).
// • GROUNDED: the interp CATCH-UP SEEDS the frame (world→local) —
// the movement source is the adjust_offset walk toward the
// MoveOrTeleport-queued server waypoint, exactly like Path A
// (:10173). StickyManager::adjust_offset then OVERWRITES the
// Origin when armed (0x00555430 ASSIGNS m_fOrigin — the REPLACE
// dichotomy), so a stuck monster still steers via #171.
// • AIRBORNE: seed an EMPTY frame (no catch-up — the arc integrates
// from velocity + gravity, unchanged).
// Body.Velocity is 0 when grounded (set above), so UpdatePhysicsInternal
// adds no translation on top of the catch-up — no double-move.
if (rm.Host is { } npcHost)
{
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
if (!rm.Airborne)
{
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
{
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
rm.Body.Orientation, offsetNpc),
};
}
else
{
pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
}
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
ApplyPositionManagerDelta(rm.Body, pmDelta);
}
else if (!rm.Airborne)
{
// No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202).
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
rm.Body.Position.X, rm.Body.Position.Y);
rm.Body.Position += rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
terrainNormal: terrainNormalNpc);
}
rm.Body.calc_acceleration();
rm.Body.UpdatePhysicsInternal(dt);
var postIntegratePos = rm.Body.Position;
// Step 4: collision sweep — retail FUN_00514B90 →
// FUN_005148A0 → Transition::FindTransitionalPosition.
// Projects the sphere from preIntegratePos to postIntegratePos
// through the BSP + terrain, resolving:
// - terrain Z snap along the slope (fixes the "staircase" where
// horizontal Euler motion up a slope sinks into rising ground
// until the next UP pops it up)
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
// - object collisions via ShadowObjectRegistry
// - step-up / step-down against walkable ledges
// ResolveWithTransition is the same call PlayerMovementController
// uses for the local player; remotes now get the full retail
// treatment between UpdatePositions instead of pure kinematics.
//
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
// a SpherePath without a starting cell). One-frame grace until
// the first UP arrives; harmless because the entity is
// server-freshly-spawned at a valid Z anyway.
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
{
// #184 Slice 3 (2026-07-07): Setup-DERIVED mover sphere so
// creatures de-overlap at their TRUE radii (a big monster
// spreads wider, a small one tighter), not the hardcoded
// human 0.48/1.835. GetSetupCylinder returns (setup.Radius,
// setup.Height) × ObjScale — the creature's own dat Setup
// scaled by its wire ObjScale, the same source the local
// player + moveto/sticky use, and consistent with the
// spawn-time shadow registration's entScale. Retail seeds
// the transition from the object's own Setup sphere list ×
// m_scale (CPhysicsObj::transition 0x00512dc0 → init_sphere;
// ObjScale from set_description 0x00514f40). This narrows
// TS-46 (remotes no longer use human dims); the two-scalar
// API is still a lossy stand-in for retail's full (≤2)
// sphere list, and stepUp/stepDown stay 0.4 (retail derives
// those from the Setup too — an adjacent divergence left as-is).
// Fallback to the human capsule for a shapeless / unresolvable
// Setup (GetSetupCylinder returns (0,0)); a zero radius would
// degenerate the sweep.
var (deR, deH) = _getSetupCylinder(serverGuid, ae.Entity);
if (deR < 0.05f) { deR = 0.48f; deH = 1.835f; }
var resolveResult = _physicsEngine.ResolveWithTransition(
preIntegratePos, postIntegratePos, rm.CellId,
sphereRadius: deR,
sphereHeight: deH,
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
// airborne remotes must NOT pre-seed the
// ContactPlane, otherwise AdjustOffset's snap-to-plane
// branch zeroes the +Z offset every step (same bug
// we hit on the local jump).
isOnGround: !rm.Airborne,
body: rm.Body, // persist ContactPlane across frames for slope tracking
// Retail default physics state includes EdgeSlide; remote DR
// should exercise the same edge/cliff branch as local movement.
// #184 Slice 2b: a remote PLAYER mover ALSO carries IsPlayer, so
// CollisionExemption's PvP block fires exactly as it does for the
// LOCAL player (PlayerMovementController :920) — two non-PK players
// WALK THROUGH each other (retail sets IsPlayer on every object's
// own transition via OBJECTINFO::init 0x0050cf30 `state |= 0x100`
// from its weenie IsPlayer(); FindObjCollisions pc:276812 exempts a
// non-PK player pair). Without IsPlayer the mover would de-overlap
// two players — MORE solid than retail (you can stand inside another
// non-PK player in AC). Players still COLLIDE with monsters (target
// not IsPlayer → no exemption) + terrain + walls. PK/PKLite/
// Impenetrable are NOT plumbed onto the remote mover yet, so a PK
// pair walks through where retail collides — the SAME M1.5 gap the
// local player carries (see TS-23; PlayerDescription PK status
// unparsed).
moverFlags: IsPlayerGuid(serverGuid)
? AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide
: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
// Fix #42 (2026-05-05): skip the moving remote's
// own ShadowEntry. _animatedEntities is keyed by
// entity.Id so kv.Key matches the EntityId the
// ShadowObjectRegistry has for this remote.
// Without this, the airborne sweep collides with
// the remote's own cylinder and produces ~1m of
// horizontal drift on the first jump frame
// (validated by [SWEEP-OBJ] traces).
movingEntityId: ae.Entity.Id);
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
// #184 (2026-07-07) — SHADOW-FOLLOWS-RESOLVED (the load-bearing
// de-overlap fix, proven in RemoteDeOverlapMechanismTests). Retail
// re-registers a moved object's shadow every transition step
// (SetPositionInternal → remove/add_shadows_to_cells, Ghidra
// 0x00515330) so its m_position — the RESOLVED position — is what
// OTHER creatures collide against. acdream's shadow otherwise only
// syncs to the RAW server pos on UpdatePosition, so neighbours would
// de-overlap against each other's OVERLAPPING shadows and any spread
// would be discarded on the next UP (never accumulating), AND the
// player would collide with a shadow offset from where the monster
// renders (the reverted attempt's "stuck on an invisible monster").
// Syncing the shadow to the resolved body every tick makes the
// de-overlap PERSIST and keeps collision == render. Re-flood is cheap
// MOVEMENT-GATED (#184 review): re-flood only when the resolved
// body moved > ~1 cm since the last shadow registration. This is
// SAFE now that #184 Slice 2b RETIRED the per-UP raw-pos sync for
// players too — every remote's shadow (player + NPC) is written ONLY
// by this loop + the UP-branch tail, both to the resolved body, so a
// net-stationary (de-overlapped, sweep-
// blocked) creature keeps its correct shadow and never re-floods,
// while a moving/de-overlapping crowd (which moves every tick) still
// syncs every tick. Bounds the per-tick RegisterMultiPart flood cost
// to actually-moving remotes — the perf risk the review flagged for
// a packed town. (In-place shadow-move + cell-relink-on-change is a
// further optimization if profiling still shows churn.)
if (System.Numerics.Vector3.DistanceSquared(
rm.Body.Position, rm.LastShadowSyncPos) > 1e-4f)
{
SyncRemoteShadowToBody(ae.Entity.Id, rm, liveCenterX, liveCenterY);
}
// #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions
// (pc:282699-282715) runs after EVERY SetPositionInternal —
// remote objects included; a VectorUpdate-launched jump arc
// is ordinary object physics in retail. acdream ported the
// velocity reflection for the LOCAL player only (L.3a,
// PlayerMovementController ~:940), so a remote jumping into
// a dungeon ceiling had its POSITION pinned by the sweep
// while its +Z velocity kept integrating — the char hovered
// at the roof until gravity burned the arc off, landing
// late (user report, 0x0007 dungeon). Mirror the local
// site exactly:
// v_new = v (1 + elasticity)·dot(v, n)·n
// with the AD-25 suppression (bounce only when airborne
// before AND after — corridor slides and landings don't
// reflect; the landing snap below keeps its
// `Velocity.Z <= 0` gate intact). Inelastic movers
// (missiles, later) zero out instead.
if (resolveResult.CollisionNormalValid)
{
bool prevOnWalkable = rm.Body.OnWalkable;
bool nowOnWalkable = resolveResult.IsOnGround;
bool applyBounce = rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Sledding)
? !(prevOnWalkable && nowOnWalkable)
: (!prevOnWalkable && !nowOnWalkable);
if (applyBounce)
{
if (rm.Body.State.HasFlag(
AcDream.Core.Physics.PhysicsStateFlags.Inelastic))
{
rm.Body.Velocity = System.Numerics.Vector3.Zero;
}
else
{
var vRem = rm.Body.Velocity;
var nRem = resolveResult.CollisionNormal;
float dotVN = System.Numerics.Vector3.Dot(vRem, nRem);
if (dotVN < 0f)
{
rm.Body.Velocity =
vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f)));
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine(
$"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}");
}
}
}
}
// K-fix15 (2026-04-26): post-resolve landing
// detection for airborne remotes. Mirrors
// PlayerMovementController's local-player landing
// path: when the resolver says we're on ground AND
// velocity is no longer pointing up, transition
// back to grounded — clear Airborne, restore
// Contact + OnWalkable, remove Gravity, zero any
// residual downward velocity, and trigger
// HitGround so the sequencer can swap from
// Falling → idle/locomotion. Without this, an
// airborne remote falls through the floor (gravity
// keeps building Velocity.Z negative until the
// sphere-sweep clamps each frame, but Airborne
// stays true forever).
if (rm.Airborne
&& resolveResult.IsOnGround
&& rm.Body.Velocity.Z <= 0f)
{
rm.Airborne = false;
// #184 (2026-07-07): clear the interp queue on landing (mirrors
// the player-remote landing). Airborne UPs hard-snap and never
// Enqueue, so any pre-jump waypoints are stale; without this the
// first grounded catch-up after touchdown chases them backward.
rm.Interp.Clear();
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
rm.Body.Velocity = new System.Numerics.Vector3(
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
// #161: HitGround MUST run with the Gravity state
// bit still set — CMotionInterp::HitGround
// (0x00528ac0) gates on state&0x400 (retail never
// clears GRAVITY on landing; it's a persistent
// object property). Clearing it first made this
// re-apply a silent no-op, which is why the
// falling pose never exited. The re-apply
// dispatches the PRESERVED pre-fall forward
// command through the funnel → the motion table
// plays the Falling→X landing link. (The old
// K-fix17 forced SetCycle is deleted: it read the
// then-clobbered InterpretedState.ForwardCommand
// — 0x40000015 — and re-set the very Falling
// cycle it meant to clear.)
// R4-V5 (closes the V4 wiring-contract gap the
// adversarial review caught): retail order —
// minterp first, then moveto (MovementManager::
// HitGround 0x00524300, §2d — the R5-V5 facade
// relay). Re-arms a moveto suspended by the
// airborne UseTime contact gate; without it a
// chasing NPC that lands stalls until ACE's
// ~1 Hz re-emit.
rm.Movement.HitGround();
// DR bookkeeping only (partner of the jump-start
// `State |= Gravity`): stops the per-tick gravity
// integration for the grounded body.
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
}
}
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
if (rm.CellId != 0)
ae.Entity.ParentCellId = rm.CellId;
ae.Entity.Rotation = rm.Body.Orientation;
}
// R5-V3 (#171): retail UpdateObjectInternal tail —
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
// right after CPartArray::HandleMovement, UNCONDITIONAL for
// every entity in both grounded and airborne branches): the
// sticky 1 s lease watchdog (StickyManager::UseTime
// 0x00555610 — a stick not re-issued by a fresh server arm
// within 1 s tears itself down). No-op while nothing is stuck.
rm.Host?.PositionManager.UseTime();
}
/// <summary>
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
/// side's steering, arrival, fail-distance; R5-V5 facade relay). Moved from
/// GameWindow (#184 Slice 2a); the DR tick is its only caller.
/// </summary>
private static void TickRemoteMoveTo(RemoteMotion rm)
{
rm.Movement.UseTime();
}
/// <summary>
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
/// stand-in for retail's <c>Frame::combine</c> in
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
/// out by the body orientation. An untouched (identity) rotation means "no
/// turn"; the P5 pin (identity quaternion = heading 0) makes compass addition
/// the exact frame-combine here. Moved from GameWindow (#184 Slice 2a); the
/// DR tick is its only caller.
/// </summary>
private static void ApplyPositionManagerDelta(
AcDream.Core.Physics.PhysicsBody body,
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
{
if (delta.Origin != System.Numerics.Vector3.Zero)
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
if (!delta.Orientation.IsIdentity)
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
body.Orientation,
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
+ delta.GetHeading());
}
/// <summary>
/// #184 — shadow-follows-resolved. Re-register a remote creature's collision
/// SHADOW at its RESOLVED body position, so OTHER creatures (and the player)
/// de-overlap / collide against where the monster actually IS (== where it
/// renders), not the raw overlapping server position. Retail re-registers a
/// moved object's shadow every accepted transition step (SetPositionInternal
/// → remove/add_shadows_to_cells, Ghidra 0x00515330). The streaming centre is
/// passed in (<paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>)
/// rather than snapshotted, since it moves on recentre. Updates
/// <see cref="RemoteMotion.LastShadowSyncPos"/> so callers can movement-gate.
/// Moved from GameWindow (#184 Slice 2a); called by the DR tick AND the NPC
/// UP-branch tail.
/// </summary>
public void SyncRemoteShadowToBody(uint entityId, RemoteMotion rm, int liveCenterX, int liveCenterY)
{
int shLbX = (int)((rm.CellId >> 24) & 0xFFu);
int shLbY = (int)((rm.CellId >> 16) & 0xFFu);
float shOffX = (shLbX - liveCenterX) * 192f;
float shOffY = (shLbY - liveCenterY) * 192f;
_physicsEngine.ShadowObjects.UpdatePosition(
entityId, rm.Body.Position, rm.Body.Orientation,
shOffX, shOffY, rm.CellId, seedCellId: rm.CellId);
rm.LastShadowSyncPos = rm.Body.Position;
}
}

View file

@ -0,0 +1,149 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// R5-V2 — the App-side <see cref="IPhysicsObjHost"/> per entity: acdream's
/// stand-in for retail's <c>CPhysicsObj</c> as the movement managers see it.
/// One is built per entity (a remote <c>RemoteMotion</c> or the local player)
/// in <c>GameWindow.EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c>
/// and registered in <c>GameWindow._physicsHosts</c> (guid → host), so
/// <see cref="GetObjectA"/> can resolve OTHER entities' hosts — the
/// cross-entity delivery path the <see cref="TargetManager"/> voyeur system
/// needs.
///
/// <para>Owns a <see cref="TargetManager"/> (retail
/// <c>CPhysicsObj::target_manager</c>). Its <c>set_target</c>/<c>clear_target</c>/
/// <c>add_voyeur</c>/<c>remove_voyeur</c>/<c>receive_target_update</c> seams
/// forward to it exactly as retail's CPhysicsObj does; the movement managers'
/// target seams are repointed here, replacing the AP-79 poll adapter. The
/// per-entity accessors (position/velocity/radius/contact/clocks) and the
/// <see cref="HandleUpdateTarget"/> fan-out are injected by GameWindow so this
/// class stays free of GameWindow's internals (code-structure rule #1).</para>
///
/// <para>R5-V3: owns a <see cref="PositionManager"/> too (retail
/// <c>CPhysicsObj::position_manager</c> — retail creates it lazily via
/// <c>get_position_manager</c>; acdream constructs it eagerly, which is
/// behaviorally identical because the empty facade no-ops until its first
/// <c>StickTo</c>/<c>ConstrainTo</c>). <see cref="HandleUpdateTarget"/> fans
/// deliveries to the injected MoveToManager fan FIRST, then the
/// PositionManager — retail <c>CPhysicsObj::HandleUpdateTarget</c> order
/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager
/// @0x00512c1a).</para>
/// </summary>
public sealed class EntityPhysicsHost : IPhysicsObjHost
{
private readonly Func<Position> _getPosition;
private readonly Func<Vector3> _getVelocity;
private readonly Func<float> _getRadius;
private readonly Func<bool> _inContact;
private readonly Func<float?> _minterpMaxSpeed;
private readonly Func<double> _curTime;
private readonly Func<double> _physicsTimerTime;
private readonly Func<uint, IPhysicsObjHost?> _getObjectA;
private readonly Action<TargetInfo> _handleUpdateTarget;
private readonly Action _interruptCurrentMovement;
private readonly TargetManager _targetManager;
public EntityPhysicsHost(
uint id,
Func<Position> getPosition,
Func<Vector3> getVelocity,
Func<float> getRadius,
Func<bool> inContact,
Func<float?> minterpMaxSpeed,
Func<double> curTime,
Func<double> physicsTimerTime,
Func<uint, IPhysicsObjHost?> getObjectA,
Action<TargetInfo> handleUpdateTarget,
Action interruptCurrentMovement)
{
Id = id;
_getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition));
_getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity));
_getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius));
_inContact = inContact ?? throw new ArgumentNullException(nameof(inContact));
_minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed));
_curTime = curTime ?? throw new ArgumentNullException(nameof(curTime));
_physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime));
_getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA));
_handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget));
_interruptCurrentMovement = interruptCurrentMovement
?? throw new ArgumentNullException(nameof(interruptCurrentMovement));
_targetManager = new TargetManager(this);
PositionManager = new PositionManager(this);
}
// ── IPhysicsObjHost accessors ──────────────────────────────────────────
public uint Id { get; }
public Position Position => _getPosition();
public Vector3 Velocity => _getVelocity();
public float Radius => _getRadius();
public bool InContact => _inContact();
public float? MinterpMaxSpeed => _minterpMaxSpeed();
public double CurTime => _curTime();
public double PhysicsTimerTime => _physicsTimerTime();
/// <summary>The owned voyeur manager (retail
/// <c>CPhysicsObj::target_manager</c>).</summary>
public TargetManager TargetManager => _targetManager;
/// <summary>R5-V3 — the owned <see cref="PositionManager"/> facade (retail
/// <c>CPhysicsObj::position_manager</c>): sticky follow + (unarmed)
/// constraint leash. Seam targets: <c>MoveToManager.StickTo/Unstick</c>,
/// <c>MotionInterpreter.UnstickFromObject</c>, the per-tick
/// <c>AdjustOffset</c>/<c>UseTime</c> drivers.</summary>
public PositionManager PositionManager { get; }
// ── IPhysicsObjHost fan-out / target-tracking seams ────────────────────
public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id);
public void HandleUpdateTarget(TargetInfo info)
{
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order:
// MovementManager (the injected MoveToManager fan) first, then
// PositionManager (@0x00512c1a — the R5-V3 sticky consumer).
_handleUpdateTarget(info);
PositionManager.HandleUpdateTarget(info);
}
public void InterruptCurrentMovement() => _interruptCurrentMovement();
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
=> _targetManager.SetTarget(contextId, objectId, radius, quantum);
public void ClearTarget() => _targetManager.ClearTarget();
public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info);
public void AddVoyeur(uint watcherId, float radius, double quantum)
=> _targetManager.AddVoyeur(watcherId, radius, quantum);
public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId);
// ── per-tick driver + lifecycle (called by GameWindow) ─────────────────
/// <summary>Retail <c>TargetManager::HandleTargetting</c> — the per-tick
/// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally
/// for every entity in <c>UpdateObjectInternal</c>, BEFORE the movement
/// managers' <c>UseTime</c>.</summary>
public void HandleTargetting() => _targetManager.HandleTargetting();
/// <summary>Retail <c>CPhysicsObj::exit_world</c>'s
/// <c>TargetManager::NotifyVoyeurOfEvent(ExitWorld)</c> — tell every
/// watcher of this entity that it left the world (they drop the
/// stick/moveto). Called on despawn before the host is removed from the
/// registry.</summary>
public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld);
/// <summary>R5-V3 (#171): retail <c>CPhysicsObj::teleport_hook</c>'s tail
/// (0x00514ed0 @0x00514f1b-0x00514f28) — <c>TargetManager::ClearTarget</c>
/// (drop this entity's OWN subscription) then
/// <c>NotifyVoyeurOfEvent(Teleported)</c> (every entity watching THIS one
/// drops its stick/moveto — <c>StickyManager::HandleUpdateTarget</c>'s
/// non-Ok teardown path). Called after a teleport placement.</summary>
public void NotifyTeleported()
{
_targetManager.ClearTarget();
_targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported);
}
}

File diff suppressed because it is too large Load diff

View file

@ -35,8 +35,55 @@ public static class InteriorEntityPartition
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> OutdoorStatic { get; } = new();
public List<WorldEntity> Dynamics { get; } = new();
// MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames
// so pruning itself doesn't allocate.
private readonly List<uint> _emptyCellScratch = new();
/// <summary>
/// MP-Alloc (2026-07-05): clear every collection in place for reuse
/// by <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>.
/// The per-cell lists inside <see cref="ByCell"/> are cleared and
/// KEPT (not removed) so a steady-state frame with the same visible
/// cell set reuses the same List&lt;WorldEntity&gt; instances instead
/// of reallocating one per cell every frame.
/// </summary>
internal void ClearForReuse()
{
foreach (var list in ByCell.Values)
list.Clear();
OutdoorStatic.Clear();
Dynamics.Clear();
}
/// <summary>
/// MP-Alloc: drop any cell bucket that ended this frame with zero
/// entries (either newly emptied, or a leftover key from a previous
/// frame's visible-cell set that this frame never touched). Keeps
/// ByCell.Count / .Keys bit-identical to the old always-fresh-
/// Dictionary behavior — callers that inspect key presence/count
/// directly (not just TryGetValue) must see exactly the cells that
/// actually received at least one static this frame.
/// </summary>
internal void PruneEmptyCellBuckets()
{
_emptyCellScratch.Clear();
foreach (var (cellId, list) in ByCell)
{
if (list.Count == 0)
_emptyCellScratch.Add(cellId);
}
foreach (var cellId in _emptyCellScratch)
ByCell.Remove(cellId);
}
}
/// <summary>
/// Allocating overload — always returns a brand-new <see cref="Result"/>.
/// Kept for tests and any one-shot caller; the per-frame render path
/// uses the <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>
/// reuse overload instead (see <see cref="RetailPViewRenderer"/>).
/// </summary>
public static Result Partition(
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
@ -44,6 +91,28 @@ public static class InteriorEntityPartition
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
{
var result = new Result();
Partition(result, visibleCells, landblockEntries);
return result;
}
/// <summary>
/// MP-Alloc (2026-07-05): reuse overload. Clears <paramref name="result"/>
/// in place (see <see cref="Result.ClearForReuse"/>) and refills it,
/// reusing each cell's existing <c>List&lt;WorldEntity&gt;</c> when the
/// cell key survives from the previous frame instead of allocating a new
/// one — the per-cell dictionary entries persist across frames (cleared,
/// never removed) since the visible-cell set is usually stable frame to
/// frame. Identical partitioning output to the allocating overload; only
/// the backing storage is reused.
/// </summary>
public static void Partition(
Result result,
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
{
result.ClearForReuse();
foreach (var entry in landblockEntries)
{
foreach (var e in entry.Entities)
@ -70,7 +139,8 @@ public static class InteriorEntityPartition
}
}
}
return result;
result.PruneEmptyCellBuckets();
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the

View file

@ -48,6 +48,19 @@ public sealed unsafe class ParticleRenderer : IDisposable
private float[] _instanceScratch = new float[256 * 16];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
// each previously `new`ing a List<ParticleDraw> (BuildDrawList) and a
// List<ParticleInstance> (the per-batch `run` list) that became garbage
// as soon as the call returned. All Draw() calls happen sequentially on
// the render thread (verified: every call site in GameWindow.cs is a
// plain synchronous invocation from the single-threaded OnRender chain,
// none dispatched via Task.Run/Parallel) and each call fully drains its
// lists before returning, so a single pair of reused fields is safe -
// no call overlaps another's use of these buffers.
private readonly List<ParticleDraw> _drawListScratch = new(64);
private readonly List<ParticleInstance> _runScratch = new(64);
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
@ -133,6 +146,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
// draws IS _drawListScratch (see BuildDrawList) - sorting it in place
// is fine, nothing else reads it between BuildDrawList's return and
// this call.
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
@ -144,7 +160,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.Disable(EnableCap.CullFace);
_gl.ActiveTexture(TextureUnit.Texture0);
var run = new List<ParticleInstance>(64);
var run = _runScratch;
for (int i = 0; i < draws.Count;)
{
var key = draws[i].Key;
@ -178,7 +194,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
{
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
var draws = _drawListScratch;
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
{
if (em.RenderPass != renderPass)

View file

@ -180,10 +180,13 @@ public static class RenderBootstrap
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
var classificationCache = new Wb.EntityClassificationCache();
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
var drawDispatcher = new Wb.WbDrawDispatcher(
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
bindless, classificationCache);
bindless, classificationCache, translucencyFades);
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
// --- Vitals dat font (GameWindow ~1820-1822) ---

View file

@ -8,8 +8,12 @@ namespace AcDream.App.Rendering;
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
/// a STATEFUL sought position that converges from the current swept
/// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
/// filter. Pseudocode:
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ────────────────────────────────────────────────
// ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position — the persisted sweep
// TARGET, re-derived each frame from the current swept
// viewer (NOT from itself).
// _publishedEye = retail viewer — the swept, published eye; the base
// of next frame's interpolation (SmartBox::
// PlayerPhysicsUpdatedCallback passes &this->viewer
// into UpdateCamera, 0x00452d75).
// _dampedForward = the sought's look direction. Sweeps translate but
// never rotate, so the viewer's rotation is always the
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount;
private Vector3 _dampedEye;
private Vector3 _soughtEye;
private Vector3 _publishedEye;
private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised;
@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
// 5. Exponential damping (independent translation + rotation rates).
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
// desired pose and assigns the result to viewer_sought_position
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
// target converges onto whatever the collision produced last frame
// and re-extends gradually. The full-length ideal boom is never swept
// directly. Pseudocode:
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
if (!_initialised)
{
_dampedEye = targetEye;
_soughtEye = targetEye;
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
_dampedForward = targetForward;
_initialised = true;
}
@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// VIEWER (0x00456fae), not the previous sought. The forward base is the
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
(_dampedEye, _dampedForward, _) =
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
// Retail UpdateCamera dead-band (0x00456fcd0x00457035): once the step
// off the viewer is sub-epsilon in translation AND rotation, the sought
// parks EXACTLY ON the viewer — an exact fixed point instead of an
// asymptote. Kills the at-rest drift AND the residual micro-jitter when
// pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
(_soughtEye, _dampedForward, _) =
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
// (0x00453ce0) keeps TWO states: viewer_sought_position (the damped
// desired eye) and viewer (the published eye = set_viewer(curr_pos)).
// The collision produces the PUBLISHED eye each frame but must NOT
// feed back into the damped state — writing the clamped result into
// _dampedEye makes next frame's lerp start from the wall and fight
// the clamp, which shows up as visible oscillation/vibration when the
// eye is pressed against a wall. So collide into a separate local and
// leave _dampedEye as the clean, uncollided sought position.
Vector3 publishedEye = _dampedEye;
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
// and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
// — the sought is NOT reset on success). Pressed against a wall, the
// sweep ray extends only one interpolation step past the contact, so a
// knife-edge r±ε graze can move the eye by at most that step (sub-mm at
// high fps) instead of re-solving the full-length boom with its 0.27 m
// bistable contact pair — the #180 strobe fix.
Vector3 publishedEye = _soughtEye;
// The viewer cell defaults to the player cell (collision off / null probe); the sweep
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
// robust per-frame "which cell is the camera in?" answer.
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
// ViewerCellId == 0): the sought resets to the returned position and
// re-extends from there.
if (swept.ViewerCellId == 0)
_soughtEye = swept.Eye;
}
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot).
@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the snap test),
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
/// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
/// is below <see cref="SnapEpsilon"/> AND the rotation step is below
/// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
/// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
/// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
return (candidateEye, candidateForward, false);
}

View file

@ -42,6 +42,21 @@ public sealed class RetailPViewRenderer
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
// place and reuses each cell's list across frames when the cell stays
// visible.
private readonly InteriorEntityPartition.Result _partitionResult = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every consumer (DrawEntityBucket, DrawExitPortalMasks,
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -110,7 +125,9 @@ public sealed class RetailPViewRenderer
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
_drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch;
UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
@ -129,6 +146,11 @@ public sealed class RetailPViewRenderer
prepareCells = _lookInPrepareScratch;
}
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
// is built once per frame in GameWindow, player-anchored.)
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
@ -137,7 +159,8 @@ public sealed class RetailPViewRenderer
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,

View file

@ -7,6 +7,7 @@ in vec3 vWorldPos;
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95
@ -16,6 +17,7 @@ in flat uint vTextureLayer;
// alpha < 0.05 (skip empty fragments — large
// transparent overdraw cost otherwise)
uniform int uRenderPass;
uniform int uLightDebug; // #176 stripe hunt (see mesh_modern.vert) — mode 3 handled here
// SceneLighting UBO — IDENTICAL layout to mesh_instanced.frag binding=1.
struct Light {
@ -85,6 +87,14 @@ void main() {
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
vec3 lit = vLit;
// #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =
// the pattern comes from texture/per-pixel machinery. Throwaway diagnostic.
if (uLightDebug == 3) {
FragColor = vec4(min(lit, vec3(1.0)), 1.0);
return;
}
// Lightning flash — additive scene bump (matches mesh_instanced.frag).
lit += uFogParams.z * vec3(0.6, 0.6, 0.75);
@ -93,5 +103,9 @@ void main() {
vec3 rgb = color.rgb * lit;
rgb = applyFog(rgb, vWorldPos);
FragColor = vec4(rgb, color.a);
// #188: multiply the FINAL alpha only — the discard thresholds above stay
// keyed on the raw sampled color.a, so the last few frames of a fade
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
// than popping invisible early against the discard cutoff.
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
}

View file

@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
uint instanceIndoor[];
};
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
// while a TransparentPartHook translucency fade is in flight for the
// entity/part this instance belongs to (e.g. the "fading wall" secret-
// passage doors). Multiplied against the sampled texture alpha in
// mesh_modern.frag.
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
@ -132,6 +142,11 @@ uniform mat4 uViewProjection;
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
// #176 stripe-hunt isolation modes (ACDREAM_LIGHT_DEBUG, throwaway diagnostic):
// 0 = off; 1 = ambient-only vLit (all point/sun contributions killed);
// 2 = DYNAMIC point lights killed (purples + viewer fill off, statics stay);
// 3 = handled in the frag (raw vLit visualization, texture ignored).
uniform int uLightDebug;
// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO
// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO
@ -178,6 +193,7 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
if (L.coneAngleEtc.y > 0.5) {
if (uLightDebug == 2) return vec3(0.0); // #176 stripe hunt: dynamics killed
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
if (ndl <= 0.0) return vec3(0.0);
@ -214,6 +230,7 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) {
vec3 lit = uCellAmbient.xyz;
if (uLightDebug == 1) return lit; // #176 stripe hunt: ambient only
// SUN / directional — OBJECT path only (mode 0). retail's EnvCell path
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
@ -259,10 +276,12 @@ out vec3 vWorldPos;
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform;
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;

View file

@ -878,6 +878,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0);
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from
@ -890,7 +892,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_shader.SetMatrix4("uViewProjection", _lastViewProjection);
var allInstances = new List<InstanceData>();
var drawCalls = new List<(ObjectRenderData renderData, int count, int offset)>();
var drawCalls = new List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)>();
if (filter == null)
{
@ -902,7 +904,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
var renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData != null && !renderData.IsSetup)
{
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
allInstances.AddRange(transforms);
}
}
@ -950,12 +952,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
var renderData = _meshManager.TryGetRenderData(gfxObjId);
if (renderData != null && !renderData.IsSetup)
{
drawCalls.Add((renderData, transforms.Count, allInstances.Count));
drawCalls.Add((renderData, gfxObjId, transforms.Count, allInstances.Count));
allInstances.AddRange(transforms);
}
}
}
// #176 seam-draw probe: stash this call's filter so the opaque-pass
// emitter inside RenderModernMDIInternal can report flood membership
// per target cell (null on the unfiltered/outdoor path).
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
_seamProbeFilter = filter;
// WB EnvCellRenderManager.cs:470-483:
if (allInstances.Count > 0)
{
@ -1085,7 +1093,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
// #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL
// dynamic lights on every cell (stable), not the per-object sphere-overlap cull that
// let the portal set flip as the flood shifted → floor-lighting flap.
AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
@ -1100,7 +1111,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private void RenderModernMDIInternal(
AcDream.App.Rendering.Shader shader,
List<(ObjectRenderData renderData, int count, int offset)> drawCalls,
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
List<InstanceData> allInstances,
WbRenderPass renderPass)
{
@ -1297,7 +1308,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance.
if (_clipSlotData.Length < uniqueInstanceCount)
_clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
if (_cellIdToSlot is null)
// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1): force every shell
// instance to slot 0 (no-clip) — retail draws cell shells WHOLE.
if (_cellIdToSlot is null
|| AcDream.Core.Rendering.RenderingDiagnostics.ClipDebugNoShellTrim)
{
Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
}
@ -1327,6 +1341,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
// #176 seam-draw probe: emitted HERE (not in Render) so the per-cell light
// sets read through the just-cleared cache against THIS frame's
// _pointSnapshot — the exact data the SSBO upload below carries.
if (renderPass == WbRenderPass.Opaque
&& AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled)
EmitSeamDrawProbe(drawCalls, allInstances, _seamProbeFilter);
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
@ -1416,6 +1437,104 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
// ---------------------------------------------------------------------------
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus.
// The in-engine replacement for the RenderDoc pixel-history the pipeline
// can't have (RenderDoc hides GL_ARB_bindless_texture → our mandatory-modern
// startup gate throws). Per opaque pass: for each target cell — flood
// membership, every shell instance (count + translation, F3 z shows the
// +0.02 lift; n≥2 for one (cell,gfx) = the runtime double-draw), and the
// cell's 8-light set resolved to stable IDENTITIES (owner-cell low16 +
// intensity; raw indices shuffle when the pool rebuilds). Plus the
// snapshot's HOT lights (intensity ≥ 50 — the portal purples; fixtures are
// ~12). Change-deduped block with a 2 s heartbeat: a purple identity
// flipping with flood membership = the snapshot-scope mechanism; two
// coincident instances = the z-fight. See RenderingDiagnostics.
// ---------------------------------------------------------------------------
private HashSet<uint>? _seamProbeFilter;
private string? _seamSig;
private long _seamLastEmitMs;
private void EmitSeamDrawProbe(
List<(ObjectRenderData renderData, ulong gfxObjId, int count, int offset)> drawCalls,
List<InstanceData> allInstances,
HashSet<uint>? filter)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var snap = _pointSnapshot;
var sb = new System.Text.StringBuilder(640);
var sorted = new List<uint>(AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells);
sorted.Sort();
foreach (uint cell in sorted)
{
sb.Append("\n[seam-cell] cell=0x").Append(cell.ToString("X8"));
sb.Append(" flood=").Append(filter is null ? '?' : (filter.Contains(cell) ? 'Y' : 'N'));
int totalInst = 0;
foreach (var dc in drawCalls)
{
int n = 0;
InstanceData first = default;
for (int i = dc.offset; i < dc.offset + dc.count; i++)
{
if (allInstances[i].CellId != cell) continue;
if (n == 0) first = allInstances[i];
n++;
}
if (n == 0) continue;
totalInst += n;
var t = first.Transform.Translation;
sb.AppendFormat(ci, " g=0x{0:X8}:n={1}@({2:F2},{3:F2},{4:F3})",
dc.gfxObjId, n, t.X, t.Y, t.Z);
}
if (totalInst == 0) sb.Append(" inst=0");
// The 8-light set this cell's instances carry (fresh: the per-pass
// cache was cleared at the top of RenderModernMDIInternal).
int[] set = GetCellLightSet(cell);
sb.Append(" L=[");
bool any = false;
for (int k = 0; k < set.Length; k++)
{
int idx = set[k];
if (idx < 0) continue;
if (any) sb.Append(',');
if (snap is not null && idx < snap.Count)
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
else
sb.Append('?').Append(idx);
any = true;
}
sb.Append(']');
}
sb.Append("\n[seam-snap] pool=").Append(snap?.Count ?? 0).Append(" hot=[");
if (snap is not null)
{
bool anyHot = false;
for (int i = 0; i < snap.Count; i++)
{
var ls = snap[i];
if (ls.Intensity < 50f) continue;
if (anyHot) sb.Append(',');
sb.AppendFormat(ci, "0x{0:X8}:I{1:F0}rgb({2:F2},{3:F2},{4:F2})",
ls.CellId, ls.Intensity, ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z);
anyHot = true;
}
}
sb.Append(']');
string sig = sb.ToString();
long now = System.Environment.TickCount64;
bool changed = sig != _seamSig;
if (!changed && (now - _seamLastEmitMs) < 2000) return;
_seamSig = sig;
_seamLastEmitMs = now;
System.Console.WriteLine($"[seam-blk] t={now} changed={(changed ? 1 : 0)}{sig}");
}
// ---------------------------------------------------------------------------
// SetCullMode
// Verbatim copy of WB BaseObjectRenderManager.cs:850-866.

View file

@ -1,3 +1,4 @@
using AcDream.Content;
using Chorizite.Core.Render.Enums;
using Silk.NET.OpenGL;
using System;

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
using AcDream.Content;
using Chorizite.Core.Render;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.Enums;
@ -94,27 +95,5 @@ namespace AcDream.App.Rendering.Wb {
_refCounts.Clear();
_freeSlots.Clear();
}
public struct TextureKey : IEquatable<TextureKey> {
public uint SurfaceId;
public uint PaletteId;
public StipplingType Stippling;
public bool IsSolid;
public bool Equals(TextureKey other) {
return SurfaceId == other.SurfaceId &&
PaletteId == other.PaletteId &&
Stippling == other.Stippling &&
IsSolid == other.IsSolid;
}
public override bool Equals(object? obj) {
return obj is TextureKey other && Equals(other);
}
public override int GetHashCode() {
return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid);
}
}
}
}

View file

@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache;
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
// secret-passage walls). ClassifyBatches reads this per part to compute
// the instance's opacity multiplier; never mutated here.
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled =
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
private uint _instIndoorSsbo;
private uint[] _indoorData = new uint[256];
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
private uint _instAlphaSsbo;
private float[] _alphaData = new float[256];
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter,
BindlessSupport bindless,
EntityClassificationCache classificationCache)
EntityClassificationCache classificationCache,
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ArgumentNullException.ThrowIfNull(meshAdapter);
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
ArgumentNullException.ThrowIfNull(classificationCache);
ArgumentNullException.ThrowIfNull(translucencyFades);
_gl = gl;
_shader = shader;
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_meshAdapter = meshAdapter;
_entitySpawnAdapter = entitySpawnAdapter;
_cache = classificationCache;
_translucencyFades = translucencyFades;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
}
/// <summary>
@ -910,6 +927,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
_shader.SetInt("uLightingMode", 0);
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
// #128 self-heal: fresh re-request dedup per Draw pass.
_missRequested.Clear();
@ -1111,6 +1130,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
MaybeEmitEntityDump(entity, cacheLb, _currentEntityCulled);
// #176 seam-draw probe: any entity parented to a target cell reports
// its position + light set (a floor-coincident static/plate would be
// the z-fight's second draw; the player entity is the positive
// control). Before the culled-continue, like the dump above.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
&& entity.ParentCellId is { } seamPc
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
MaybeEmitSeamEnt(entity);
}
prevTupleEntityId = entity.Id;
@ -1279,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
// #188: setupPartIndex is the SAME index space
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
// consumer of this exact indexing: one Matrix4x4 per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null)
{
@ -1321,15 +1358,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
// sets draw_state|=1 and skips the whole part outright — not a
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
// t=1 commits the bitwise-exact value so this check is safe.
float opacityMultiplier = 1.0f;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
{
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
drewAny = true;
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
drewAny = true;
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
// retail's CPartArray for such an object is a single-entry array,
// so TransparentPartHook.PartIndex for it is always 0.
float opacityMultiplier = 1.0f;
bool fullyInvisible = false;
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
{
if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue;
}
if (!fullyInvisible)
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
drewAny = true;
}
}
// Track THIS entity for the next iteration's flush check. Only
@ -1416,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_indoorData.Length < totalInstances)
_indoorData = new uint[totalInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < totalInstances)
_alphaData = new float[totalInstances + 256];
_opaqueDraws.Clear();
_translucentDraws.Clear();
@ -1451,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
_indoorData[cursor] = grp.IndoorFlags[i];
// #188: Opacities[] is parallel to Matrices[]; write at the same
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
_alphaData[cursor] = grp.Opacities[i];
cursor++;
}
@ -1542,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (uint* dp = _indoorData)
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
// #188: per-instance opacity buffer (binding=7), one float per instance,
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
// is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (float* ap = _alphaData)
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
// per-instance buffer holds 8 int indices into it per instance, laid out
@ -2045,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
// #188: cache-hit entities are always non-animated (the Tier-1 cache
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
// state for entities whose animation hooks fired — so a cached
// instance can never be mid-fade. Always unmodified opacity.
grp.Opacities.Add(1.0f);
}
/// <summary>
@ -2075,6 +2158,44 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
// [seam-ent] line per target-cell entity, re-emitted on state change: world
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
// and the SelectForObject light set resolved to identities (owner-cell
// low16 + intensity). Sig dict is bounded by the handful of entities that
// ever live in the target cells.
private readonly Dictionary<ulong, string> _seamEntSigs = new();
private void MaybeEmitSeamEnt(WorldEntity entity)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var snap = _pointSnapshot;
var sb = new System.Text.StringBuilder(200);
sb.AppendFormat(ci,
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
entity.ServerGuid, entity.ParentCellId ?? 0u,
entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false;
for (int k = 0; k < _currentEntityLightSet.Length; k++)
{
int idx = _currentEntityLightSet[k];
if (idx < 0) continue;
if (any) sb.Append(',');
if (snap is not null && idx < snap.Count)
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
else
sb.Append('?').Append(idx);
any = true;
}
sb.Append(']');
string sig = sb.ToString();
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
_seamEntSigs[entity.Id] = sig;
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
}
private void ComputeEntityLightSet(WorldEntity entity)
{
// #142: set the indoor flag first so it's available even when the early-return
@ -2132,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ulong palHash,
AcSurfaceMetadataTable metaTable,
Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List<CachedBatch>? collector = null)
{
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
@ -2150,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
: TranslucencyKind.Opaque;
}
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha —
// the no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
if (texHandle == 0) continue;
@ -2179,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Matrices.Add(model);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -2261,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2460,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// for outdoor objects (gets the sun). Written into _indoorData at the same
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List<uint> IndoorFlags = new();
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
// layout time the dispatcher writes Opacities[i] into _alphaData at
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
public readonly List<float> Opacities = new();
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Content;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using DatReaderWriter;

View file

@ -255,7 +255,43 @@ public sealed class GpuWorldState
{
if (entity.ServerGuid == 0) return;
// Remove from current landblock (find it by scanning)
uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu;
// Fast path: already drawn in the correct loaded bucket → nothing to do
// (avoids per-frame list churn for a settled, stationary entity).
if (_loaded.TryGetValue(canonical, out var target))
{
foreach (var e in target.Entities)
if (ReferenceEquals(e, entity)) return;
}
// Remove the entity from wherever it currently lives — a loaded bucket
// OR a pending bucket — then re-append to its current landblock.
//
// Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn /
// run-out "invisible player" bug: a persistent (server-spawned) entity
// that spawned into a not-yet-loaded landblock sits in _pendingByLandblock,
// and the old code scanned ONLY _loaded — so it silently no-op'd and left
// the player stranded, hidden, even after its landblock finished loading
// (the AddLandblock pending-drain had already run empty before the churn
// re-parked the player, and the player is excluded from the server-object
// re-hydrate — so RelocateEntity was the ONLY path that could recover it,
// and it couldn't reach a pending entity). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to
// await AddLandblock otherwise.
RemoveEntityFromAllBuckets(entity);
AppendLiveEntity(canonical, entity);
}
/// <summary>
/// Remove <paramref name="entity"/> (by reference) from whichever
/// <see cref="_loaded"/> or <see cref="_pendingByLandblock"/> bucket it
/// currently occupies. At most one bucket holds a given entity, so this
/// stops after the first hit. Called by <see cref="RelocateEntity"/> before
/// re-appending, so a stranded pending entity can be promoted.
/// </summary>
private void RemoveEntityFromAllBuckets(WorldEntity entity)
{
foreach (var kvp in _loaded)
{
var entities = kvp.Value.Entities;
@ -263,23 +299,21 @@ public sealed class GpuWorldState
{
if (ReferenceEquals(entities[i], entity))
{
if (kvp.Key == newCanonicalLb) return; // already in the right place
// Remove from old
var newList = new List<WorldEntity>(entities.Count - 1);
for (int j = 0; j < entities.Count; j++)
if (j != i) newList.Add(entities[j]);
_loaded[kvp.Key] = new LoadedLandblock(
kvp.Value.LandblockId,
kvp.Value.Heightmap,
newList);
// Add to new (via AppendLiveEntity which handles pending)
AppendLiveEntity(newCanonicalLb, entity);
kvp.Value.LandblockId, kvp.Value.Heightmap, newList);
return;
}
}
}
foreach (var kvp in _pendingByLandblock)
{
if (kvp.Value.Remove(entity))
return;
}
}
public void RemoveLandblock(uint landblockId)

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>acdream-bake</AssemblyName>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- NO Silk.NET / GL packages here — ever. acdream-bake is an offline CLI
that drives the GL-free AcDream.Content.MeshExtractor; it must not
ship GL binaries (mirrors AcDream.Content's own constraint). -->
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.7" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,252 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AcDream.Content;
using AcDream.Content.Pak;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.Bake;
/// <summary>Options for one bake run (parsed from CLI args by Program, or built directly by tests).</summary>
public sealed record BakeOptions {
public required string DatDir { get; init; }
public required string OutPath { get; init; }
public HashSet<uint>? IdFilter { get; init; }
public HashSet<uint>? LandblockFilter { get; init; }
public int Threads { get; init; } = System.Environment.ProcessorCount;
}
/// <summary>
/// The bake pipeline: enumerate ids -> sorted-batch parallel extraction ->
/// deterministic pak write. Public (rather than inline in Program) so the
/// dat-gated byte-reproducibility test can drive the REAL pipeline.
///
/// <para><b>Determinism + bounded memory (review finding 1):</b> the full id
/// list is built first and sorted by pak key; work proceeds in fixed-size
/// batches — extraction is parallel WITHIN a batch, then the batch's results
/// are sorted by key and written sequentially before the next batch starts.
/// Batches are contiguous key ranges, so the blob region ends up in global
/// key order regardless of thread scheduling, and memory is bounded by one
/// batch's decoded output instead of the whole game's. Side-staged
/// particle-preload meshes are deduped into a keyed map as each batch
/// drains (first instance wins — extraction output per id is
/// deterministic, so instance choice cannot affect bytes) and written after
/// all batches, sorted by key, skipping keys already written.</para>
/// </summary>
public static class BakeRunner {
/// <summary>Ids extracted in parallel per batch before the sequential sorted write. Bounds peak memory (~512 decoded ObjectMeshData) while keeping the workers busy.</summary>
private const int BatchSize = 512;
public static int Run(BakeOptions options) {
Console.WriteLine("acdream-bake");
Console.WriteLine($"dat dir: {options.DatDir}");
Console.WriteLine($"out: {options.OutPath}");
Console.WriteLine($"threads: {options.Threads}");
Console.WriteLine();
var sw = Stopwatch.StartNew();
using var dats = new DatCollection(options.DatDir, DatAccessType.Read);
// The unified AcDream.Content.DatCollectionAdapter (MP1b review
// finding 7): same instance class the client and the equivalence
// suite use — adapter drift between bake and runtime is impossible
// by construction.
using var datReaderWriter = new DatCollectionAdapter(dats);
var extractorLogger = new ConsoleErrorLogger(nameof(MeshExtractor));
// Thread-safe side-stage sink for particle-preload meshes MeshExtractor
// emits mid-extraction (MP1a documented contract; extractor is shared
// across the batch workers).
var sideStaged = new ConcurrentQueue<ObjectMeshData>();
var extractor = new MeshExtractor(datReaderWriter, extractorLogger, data => sideStaged.Enqueue(data));
// ---- enumeration -----------------------------------------------------
var gfxObjIds = dats.GetAllIdsOfType<GfxObj>().ToList();
var setupIds = dats.GetAllIdsOfType<Setup>().ToList();
var envCellIds = EnumerateEnvCellIds(dats, options.LandblockFilter);
if (options.IdFilter is not null) {
gfxObjIds = gfxObjIds.Where(options.IdFilter.Contains).ToList();
setupIds = setupIds.Where(options.IdFilter.Contains).ToList();
envCellIds = envCellIds.Where(options.IdFilter.Contains).ToList();
}
// Full work list sorted by pak key — the batching below preserves this
// global order on disk (determinism precondition).
var work = new List<(ulong Key, PakAssetType Type, uint FileId)>(gfxObjIds.Count + setupIds.Count + envCellIds.Count);
work.AddRange(gfxObjIds.Select(id => (PakKey.Compose(PakAssetType.GfxObjMesh, id), PakAssetType.GfxObjMesh, id)));
work.AddRange(setupIds.Select(id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id)));
work.AddRange(envCellIds.Select(id => (PakKey.Compose(PakAssetType.EnvCellMesh, id), PakAssetType.EnvCellMesh, id)));
work.Sort((a, b) => a.Key.CompareTo(b.Key));
Console.WriteLine($"enumerated: {gfxObjIds.Count:N0} GfxObj, {setupIds.Count:N0} Setup, {envCellIds.Count:N0} EnvCell " +
$"({work.Count:N0} total)");
Console.WriteLine();
// ---- sorted-batch extraction + streaming write -------------------------
var header = new PakHeader {
PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration,
CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration,
HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration,
LanguageIteration = (uint)dats.Local.Iteration!.CurrentIteration,
BakeToolVersion = 1,
};
var failures = new ConcurrentBag<(PakAssetType Type, uint FileId, string Reason)>();
var writtenKeys = new HashSet<ulong>();
var sideStagedByKey = new Dictionary<ulong, ObjectMeshData>();
long completed = 0;
int writtenCounts0 = 0, writtenCounts1 = 0, writtenCounts2 = 0; // GfxObj, Setup, EnvCell
var lastProgressReport = Stopwatch.StartNew();
using (var writer = new PakWriter(options.OutPath, header)) {
for (int batchStart = 0; batchStart < work.Count; batchStart += BatchSize) {
var batch = work.Skip(batchStart).Take(BatchSize).ToList();
var batchResults = new ConcurrentBag<(ulong Key, PakAssetType Type, ObjectMeshData Data)>();
Parallel.ForEach(
batch,
new ParallelOptions { MaxDegreeOfParallelism = options.Threads },
item => {
var (key, type, fileId) = item;
try {
// EnvCell entries store the cell's synthetic geometry mesh
// (bit 32 set — see MeshExtractor.PrepareMeshData's EnvCell
// branch); the cell's static objects are covered by their
// own GfxObj/Setup entries.
ulong extractorId = type == PakAssetType.EnvCellMesh ? fileId | 0x1_0000_0000UL : fileId;
// isSetup: matches the runtime's own request sites —
// WbMeshAdapter.IncrementRefCount/EnsureLoaded pass
// isSetup: false for every MeshRef id (incl. cell-geometry
// ids); only true Setup extraction passes true. (Review
// finding 10 — keep aligned with PakEquivalenceTests.)
bool isSetup = type == PakAssetType.SetupMesh;
var data = extractor.PrepareMeshData(extractorId, isSetup);
if (data is not null) {
batchResults.Add((key, type, data));
}
else {
failures.Add((type, fileId, "extractor returned null (no polygons or unresolvable id)"));
}
}
catch (Exception ex) {
// A malformed dat entry skips that id — never fatal to the
// bake, matching the runtime's own per-id behavior.
failures.Add((type, fileId, ex.Message));
}
Interlocked.Increment(ref completed);
});
// Sequential sorted write: batch results in key order.
foreach (var (key, type, data) in batchResults.OrderBy(r => r.Key)) {
writer.AddBlob(key, data);
writtenKeys.Add(key);
switch (type) {
case PakAssetType.GfxObjMesh: writtenCounts0++; break;
case PakAssetType.SetupMesh: writtenCounts1++; break;
case PakAssetType.EnvCellMesh: writtenCounts2++; break;
}
}
// Drain side-staged preloads per batch into the dedup map so the
// queue never grows past one batch's emissions (memory bound);
// written after all batches, sorted (see below).
while (sideStaged.TryDequeue(out var staged)) {
uint fileId = (uint)(staged.ObjectId & 0xFFFFFFFFu);
ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId);
if (!sideStagedByKey.ContainsKey(key)) sideStagedByKey[key] = staged;
}
if (lastProgressReport.Elapsed.TotalSeconds >= 5 || batchStart + BatchSize >= work.Count) {
ReportProgress(completed, work.Count, failures.Count, sw.Elapsed);
lastProgressReport.Restart();
}
}
// Side-staged preload meshes not already covered by a primary entry:
// sorted by key for deterministic placement.
int sideStagedWritten = 0, sideStagedDuped = 0;
foreach (var (key, data) in sideStagedByKey.OrderBy(kv => kv.Key)) {
if (writtenKeys.Contains(key)) { sideStagedDuped++; continue; }
writer.AddBlob(key, data);
writtenKeys.Add(key);
sideStagedWritten++;
}
writer.Finish();
sw.Stop();
var outSize = new FileInfo(options.OutPath).Length;
Console.WriteLine();
Console.WriteLine("=== bake summary ===");
Console.WriteLine($" GfxObj baked: {writtenCounts0:N0}");
Console.WriteLine($" Setup baked: {writtenCounts1:N0}");
Console.WriteLine($" EnvCell baked: {writtenCounts2:N0}");
Console.WriteLine($" side-staged baked (particle preload GfxObjs): {sideStagedWritten:N0} ({sideStagedDuped:N0} deduped)");
Console.WriteLine($" total blobs: {writtenKeys.Count:N0}");
Console.WriteLine($" failures: {failures.Count:N0}");
Console.WriteLine($" elapsed: {sw.Elapsed.TotalSeconds:F1} s");
Console.WriteLine($" output size: {outSize / 1024.0 / 1024.0:F1} MB");
Console.WriteLine($" output path: {options.OutPath}");
}
if (!failures.IsEmpty) {
Console.WriteLine();
Console.WriteLine($"failures ({failures.Count}):");
foreach (var (type, fileId, reason) in failures.OrderBy(f => f.FileId).Take(200)) {
Console.WriteLine($" {type,-12} 0x{fileId:X8}: {reason}");
}
if (failures.Count > 200) Console.WriteLine($" ... and {failures.Count - 200} more");
}
return 0;
}
private static void ReportProgress(long done, int total, int failures, TimeSpan elapsed) {
double rate = elapsed.TotalSeconds > 0 ? done / elapsed.TotalSeconds : 0;
double etaSeconds = rate > 0 ? (total - done) / rate : 0;
Console.WriteLine($"[{elapsed:hh\\:mm\\:ss}] baked {done:N0}/{total:N0}, failures={failures:N0}, " +
$"elapsed={elapsed.TotalSeconds:F0}s, ETA={etaSeconds:F0}s");
}
/// <summary>
/// Enumerates EnvCell ids by walking the cell dat's LandBlockInfo entries
/// (0xFFFE low 16 bits) and, for each, the NumCells-derived cell id range —
/// GetAllIdsOfType&lt;T&gt;() does not cover cell-dat range-based types
/// (DatReaderWriter's documented limitation; see the low-16-bit bucketing
/// idiom in src/AcDream.Cli/Program.cs's CountCellByLow16, and the
/// firstCellId/NumCells hydration idiom in GameWindow.BuildPhysicsDatBundle
/// / BuildInteriorEntitiesForStreaming).
/// </summary>
private static List<uint> EnumerateEnvCellIds(DatCollection dats, HashSet<uint>? landblockFilter) {
var landblockInfoIds = new List<uint>();
foreach (var file in dats.Cell.Tree) {
if ((file.Id & 0xFFFFu) != 0xFFFEu) continue;
uint landblockId = file.Id & 0xFFFF0000u;
if (landblockFilter is not null && !landblockFilter.Contains(landblockId)) continue;
landblockInfoIds.Add(file.Id);
}
var envCellIds = new List<uint>();
foreach (var lbInfoId in landblockInfoIds) {
if (!dats.Cell.TryGet<LandBlockInfo>(lbInfoId, out var lbInfo) || lbInfo is null) continue;
if (lbInfo.NumCells == 0) continue;
uint landblockId = lbInfoId & 0xFFFF0000u;
uint firstCellId = landblockId | 0x0100u;
for (uint offset = 0; offset < lbInfo.NumCells; offset++) {
envCellIds.Add(firstCellId + offset);
}
}
return envCellIds;
}
}

View file

@ -0,0 +1,38 @@
using System;
using Microsoft.Extensions.Logging;
namespace AcDream.Bake;
/// <summary>
/// Minimal <see cref="ILogger"/> writing to stderr with a level prefix.
/// MeshExtractor logs failures via ILogger.LogError/LogWarning (dat-read
/// anomalies, malformed entries) — per the project's "logger injection for
/// silent catches" lesson, the bake tool must NOT wire a NullLogger here or
/// every per-id extraction failure goes silent. A tiny hand-rolled logger
/// avoids pulling in the Microsoft.Extensions.Logging.Console package
/// (not already a transitive dependency) for what is a one-line need.
/// </summary>
public sealed class ConsoleErrorLogger : ILogger {
private readonly string _categoryName;
public ConsoleErrorLogger(string categoryName) {
_categoryName = categoryName;
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) {
if (!IsEnabled(logLevel)) return;
var message = formatter(state, exception);
Console.Error.WriteLine($"[{logLevel}] {_categoryName}: {message}");
if (exception is not null) Console.Error.WriteLine(exception);
}
}
/// <summary>Factory producing <see cref="ConsoleErrorLogger"/> instances for any category/type.</summary>
public sealed class ConsoleErrorLoggerProvider : ILoggerProvider {
public ILogger CreateLogger(string categoryName) => new ConsoleErrorLogger(categoryName);
public void Dispose() { }
}

View file

@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AcDream.Bake;
// acdream-bake: offline CLI producing a versioned pak file containing every
// ObjectMeshData the client's decode workers would otherwise produce at
// runtime (GfxObj / Setup / EnvCell mesh+texture payloads).
//
// Thin arg-parsing shell over BakeRunner (the real pipeline lives there so
// the dat-gated byte-reproducibility test can drive it directly).
//
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
string? datDir = null;
string? outPath = null;
HashSet<uint>? idFilter = null;
HashSet<uint>? landblockFilter = null;
int threads = Environment.ProcessorCount;
for (int i = 0; i < args.Length; i++) {
switch (args[i]) {
case "--dat-dir":
datDir = args.ElementAtOrDefault(++i);
break;
case "--out":
outPath = args.ElementAtOrDefault(++i);
break;
case "--ids":
idFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--landblocks":
landblockFilter = ParseHexList(args.ElementAtOrDefault(++i));
break;
case "--threads":
if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t;
break;
default:
Console.Error.WriteLine($"unrecognized argument: {args[i]}");
return 2;
}
}
if (string.IsNullOrWhiteSpace(datDir)) {
Console.Error.WriteLine("usage: acdream-bake --dat-dir <path> [--out <file>] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads <n>]");
return 2;
}
if (!Directory.Exists(datDir)) {
Console.Error.WriteLine($"error: directory not found: {datDir}");
return 2;
}
outPath ??= Path.Combine(datDir, "acdream.pak");
return BakeRunner.Run(new BakeOptions {
DatDir = datDir,
OutPath = outPath,
IdFilter = idFilter,
LandblockFilter = landblockFilter,
Threads = threads,
});
static HashSet<uint> ParseHexList(string? raw) {
var result = new HashSet<uint>();
if (string.IsNullOrWhiteSpace(raw)) return result;
foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token;
if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) {
result.Add(value);
}
else {
Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped");
}
}
return result;
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<!-- MP1a: the inline DXT decode moved from ObjectMeshManager uses these
exact versions (mirror AcDream.App.csproj — keep in lockstep). -->
<PackageReference Include="BCnEncoder.Net.ImageSharp" Version="1.1.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<!-- NO GL binding packages here — ever. This assembly is the GL-free
extraction half; the MP1b bake tool references it and must not ship
GL binaries. Upload-format hints use the Content-owned
UploadPixelFormat / UploadPixelType enums (UploadFormats.cs),
numerically identical to the GL ABI constants; App casts at the
upload boundary. -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -7,19 +7,32 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace AcDream.App.Rendering.Wb;
namespace AcDream.Content;
/// <summary>
/// Adapts acdream's <see cref="DatCollection"/> to WB's <see cref="IDatReaderWriter"/> interface.
/// THE <see cref="DatCollection"/> → <see cref="IDatReaderWriter"/> adapter,
/// shared by all three consumers: ObjectMeshManager (AcDream.App, via
/// WbMeshAdapter), acdream-bake (AcDream.Bake.BakeRunner), and the pak
/// equivalence suite (AcDream.Content.Tests). MP1b's review found the
/// original App-internal copy had silently forked into three near-identical
/// versions — drift between them is exactly what the live-vs-pak equivalence
/// suite CANNOT detect (both sides would drift together only if they share
/// one implementation), so unification here is load-bearing, not cosmetic.
/// GL-free (DatReaderWriter only), hence Content is the right home
/// (established in MP1a alongside <see cref="IDatReaderWriter"/>).
///
/// O-D7 fallback path: taken because ObjectMeshManager has 26 _dats.X call sites (threshold is 20),
/// making a full refactor to DatCollection larger than spec permits in a single task.
/// This adapter lets ObjectMeshManager stay byte-identical to the WB original while
/// routing all DAT I/O through our single DatCollection. The adapter is dropped in T7
/// when the WorldBuilder project reference is removed entirely.
/// <para>History: O-D7 originally introduced this adapter (App-internal)
/// because ObjectMeshManager had 26 <c>_dats.X</c> call sites, letting it
/// stay byte-identical to the WB original while routing all DAT I/O through
/// the single DatCollection.</para>
///
/// <para>Iteration properties return the REAL dat iterations (the App copy
/// hardcoded 0 — a stub nothing read; the bake tool stamps iterations into
/// the pak header from DatCollection directly, and any future consumer of
/// these properties should get truth, so the unification intentionally
/// keeps the real values).</para>
/// </summary>
internal sealed class DatCollectionAdapter : IDatReaderWriter
{
public sealed class DatCollectionAdapter : IDatReaderWriter {
private readonly DatCollection _dats;
private readonly DatDatabaseWrapper _portal;
private readonly DatDatabaseWrapper _cell;
@ -27,8 +40,7 @@ internal sealed class DatCollectionAdapter : IDatReaderWriter
private readonly DatDatabaseWrapper _language;
private readonly ReadOnlyDictionary<uint, IDatDatabase> _cellRegions;
public DatCollectionAdapter(DatCollection dats)
{
public DatCollectionAdapter(DatCollection dats) {
ArgumentNullException.ThrowIfNull(dats);
_dats = dats;
_portal = new DatDatabaseWrapper(dats.Portal);
@ -50,18 +62,16 @@ internal sealed class DatCollectionAdapter : IDatReaderWriter
public IDatDatabase HighRes => _highRes;
public IDatDatabase Language => _language;
// RegionFileMap is used by some WB internals but not by ObjectMeshManager.
// RegionFileMap is used by some WB internals but not by any acdream consumer.
public ReadOnlyDictionary<uint, uint> RegionFileMap =>
new ReadOnlyDictionary<uint, uint>(new Dictionary<uint, uint>());
// Iteration properties — not used by ObjectMeshManager, so delegate to 0.
public int PortalIteration => 0;
public int CellIteration => 0;
public int HighResIteration => 0;
public int LanguageIteration => 0;
public int PortalIteration => _portal.Iteration;
public int CellIteration => _cell.Iteration;
public int HighResIteration => _highRes.Iteration;
public int LanguageIteration => _language.Iteration;
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead)
{
public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) {
// Route to cell db (the only region we expose)
return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead);
}
@ -71,15 +81,12 @@ internal sealed class DatCollectionAdapter : IDatReaderWriter
/// Mirrors DefaultDatReaderWriter.ResolveId — checks each underlying DatDatabase
/// via DatDatabase.TypeFromId (which reads the type range tables).
/// </summary>
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id)
{
public IEnumerable<IDatReaderWriter.IdResolution> ResolveId(uint id) {
var results = new List<IDatReaderWriter.IdResolution>();
void CheckDb(DatDatabaseWrapper wrapper)
{
void CheckDb(DatDatabaseWrapper wrapper) {
var rawDb = wrapper.RawDatabase;
if (rawDb.Tree.TryGetFile(id, out _))
{
if (rawDb.Tree.TryGetFile(id, out _)) {
var type = rawDb.TypeFromId(id);
if (type != DBObjType.Unknown)
results.Add(new IDatReaderWriter.IdResolution(wrapper, type));
@ -101,8 +108,7 @@ internal sealed class DatCollectionAdapter : IDatReaderWriter
public bool TrySave<T>(uint regionId, T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException("DatCollectionAdapter is read-only.");
public void Dispose()
{
public void Dispose() {
// The underlying DatCollection is owned by the caller — do not dispose it here.
// Individual wrapper objects hold no unmanaged resources.
}
@ -110,17 +116,16 @@ internal sealed class DatCollectionAdapter : IDatReaderWriter
/// <summary>
/// Wraps a <see cref="DatDatabase"/> as <see cref="IDatDatabase"/>.
/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase but lives in our namespace
/// so the WorldBuilder project reference can be dropped in T7.
/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase (taken into our
/// tree in Phase O; moved here from AcDream.App in the MP1b adapter
/// unification).
/// </summary>
internal sealed class DatDatabaseWrapper : IDatDatabase
{
public sealed class DatDatabaseWrapper : IDatDatabase {
private readonly DatDatabase _db;
private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new();
private readonly object _lock = new();
public DatDatabaseWrapper(DatDatabase db)
{
public DatDatabaseWrapper(DatDatabase db) {
ArgumentNullException.ThrowIfNull(db);
_db = db;
}
@ -134,18 +139,14 @@ internal sealed class DatDatabaseWrapper : IDatDatabase
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj =>
_db.GetAllIdsOfType<T>();
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
{
if (_cache.TryGetValue((typeof(T), fileId), out var cached))
{
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj {
if (_cache.TryGetValue((typeof(T), fileId), out var cached)) {
value = (T)cached;
return true;
}
lock (_lock)
{
if (_db.TryGet<T>(fileId, out value))
{
lock (_lock) {
if (_db.TryGet<T>(fileId, out value)) {
_cache.TryAdd((typeof(T), fileId), value);
return true;
}
@ -154,8 +155,9 @@ internal sealed class DatDatabaseWrapper : IDatDatabase
// a miss for an id whose BTree entry EXISTS is always an anomaly —
// either Unpack returned false or the lookup flickered transiently.
// Legit not-found probes (e.g. Portal→HighRes fallback) stay silent.
if (_db.Tree.TryGetFile(fileId, out _))
{
// Kept through the MP1b unification — the tripwire now covers the
// bake tool and the equivalence suite too, not just the client.
if (_db.Tree.TryGetFile(fileId, out _)) {
Console.WriteLine(
$"[dat-miss] {typeof(T).Name} 0x{fileId:X8} entry EXISTS but TryGet failed " +
$"(thread={Environment.CurrentManagedThreadId})");
@ -165,18 +167,14 @@ internal sealed class DatDatabaseWrapper : IDatDatabase
return false;
}
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value)
{
lock (_lock)
{
public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) {
lock (_lock) {
return _db.TryGetFileBytes(fileId, out value);
}
}
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead)
{
lock (_lock)
{
public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) {
lock (_lock) {
return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead);
}
}
@ -184,8 +182,7 @@ internal sealed class DatDatabaseWrapper : IDatDatabase
public bool TrySave<T>(T obj, int iteration = 0) where T : IDBObj =>
throw new NotSupportedException("DatDatabaseWrapper is read-only.");
public void Dispose()
{
public void Dispose() {
// The underlying DatDatabase is owned by DatCollection — do not dispose here.
}
}

View file

@ -1,7 +1,11 @@
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.App.Rendering.Wb {
// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only —
// consumed by MeshExtractor.PrepareCellStructEdgeLineData, which must be
// GL-free.
namespace AcDream.Content {
public static class EdgeLineBuilder {
public static List<Vector3> BuildEdgeLines(CellStruct cellStruct) {
var edgeMap = new Dictionary<EdgeKey, List<Edge>>();

View file

@ -5,12 +5,17 @@ using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
// Phase O-T7: verbatim copy of WorldBuilder.Shared.Services.IDatReaderWriter +
// IDatDatabase into the AcDream.App.Rendering.Wb namespace so the
// WorldBuilder.Shared project reference can be dropped.
// The only consumer of IDatReaderWriter in acdream is DatCollectionAdapter +
// ObjectMeshManager, both already in this namespace.
// IDatDatabase, taken into our tree so the WorldBuilder.Shared project
// reference could be dropped.
//
// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only —
// MeshExtractor's constructor takes IDatReaderWriter and must be GL-free.
// MP1b review (finding 7): the concrete DatCollection-backed implementation
// (DatCollectionAdapter, this assembly) now lives HERE too — one shared
// adapter for ObjectMeshManager (App), acdream-bake, and the equivalence
// suite, so the three consumers can never drift apart.
namespace AcDream.App.Rendering.Wb;
namespace AcDream.Content;
/// <summary>
/// Interface for the dat reader/writer

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,124 @@
using Chorizite.Core.Lib;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
namespace AcDream.Content;
/// <summary>
/// Vertex format for scenery mesh rendering: position, normal, UV.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
public struct VertexPositionNormalTexture {
public Vector3 Position;
public Vector3 Normal;
public Vector2 UV;
public static int Size => 8 * sizeof(float); // 3+3+2 = 8 floats = 32 bytes
public VertexPositionNormalTexture(Vector3 position, Vector3 normal, Vector2 uv) {
Position = position;
Normal = normal;
UV = uv;
}
}
/// <summary>
/// Staged data for a particle emitter to be created on the GL thread.
/// </summary>
public struct StagedEmitter {
public ParticleEmitter Emitter;
public uint PartIndex;
public Matrix4x4 Offset;
}
/// <summary>
/// CPU-side mesh data prepared on a background thread.
/// Contains vertex data and per-batch index/texture info, but NO GPU resources.
/// </summary>
public class ObjectMeshData {
public ulong ObjectId { get; set; }
public bool IsSetup { get; set; }
public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty<VertexPositionNormalTexture>();
public List<MeshBatchData> Batches { get; set; } = new();
/// <summary>
/// #125 (2026-06-12): GL upload-retry counter. A failed upload through
/// the App-side upload path (it returns null from its
/// catch) used to be dropped permanently — the staged item was consumed,
/// no render data was produced, and the prepared data lingered in the CPU
/// cache where <c>PrepareMeshDataAsync</c>'s cache-hit short-circuit
/// returned it without ever re-staging it for upload (session-sticky
/// invisible mesh, one [wb-error] line). The drain loop now re-stages a
/// failed upload for the NEXT frame up to the App-side upload retry
/// limit (<c>MaxUploadRetries</c>). The counter lives on the mesh-data
/// object so
/// it resets to 0 naturally whenever the id is re-prepared (fresh object),
/// and bounds a deterministic GL failure to a few loud lines instead of a
/// silent permanent drop OR an unbounded per-frame retry storm. Retail
/// loads content synchronously and has no such failure mode — this
/// converges our async pipeline toward that guarantee.
/// </summary>
public int UploadAttempts;
/// <summary>For EnvCell: the geometry of the cell itself.</summary>
public ObjectMeshData? EnvCellGeometry { get; set; }
/// <summary>For Setup objects: parts with their local transforms.</summary>
public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new();
/// <summary>Particle emitters from physics scripts.</summary>
public List<StagedEmitter> ParticleEmitters { get; set; } = new();
/// <summary>Per-format texture atlas data (to be uploaded to GPU on main thread).</summary>
public Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> TextureBatches { get; set; } = new();
/// <summary>Local bounding box.</summary>
public BoundingBox BoundingBox { get; set; }
/// <summary>Approximate center point used for depth sorting / transparency ordering.</summary>
public Vector3 SortCenter { get; set; }
/// <summary>DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo.</summary>
public uint DIDDegrade { get; set; }
/// <summary>Sphere used for mouse selection.</summary>
public Sphere? SelectionSphere { get; set; }
/// <summary>Edge line vertices for Environment wireframe rendering.</summary>
public Vector3[] EdgeLines { get; set; } = Array.Empty<Vector3>();
}
/// <summary>
/// CPU-side data for a single rendering batch (indices + texture reference).
/// </summary>
public class MeshBatchData {
public ushort[] Indices { get; set; } = Array.Empty<ushort>();
public (int Width, int Height, TextureFormat Format) TextureFormat { get; set; }
public TextureKey TextureKey { get; set; }
public int TextureIndex { get; set; }
public byte[] TextureData { get; set; } = Array.Empty<byte>();
public UploadPixelFormat? UploadPixelFormat { get; set; }
public UploadPixelType? UploadPixelType { get; set; }
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
}
/// <summary>
/// CPU-side texture info for deduplication during background preparation.
/// </summary>
public class TextureBatchData {
public TextureKey Key { get; set; }
public byte[] TextureData { get; set; } = Array.Empty<byte>();
public UploadPixelFormat? UploadPixelFormat { get; set; }
public UploadPixelType? UploadPixelType { get; set; }
public List<ushort> Indices { get; set; } = new();
public DatReaderWriter.Enums.CullMode CullMode { get; set; }
public bool IsTransparent { get; set; }
public bool IsAdditive { get; set; }
public bool HasWrappingUVs { get; set; }
}

View file

@ -0,0 +1,36 @@
using System;
namespace AcDream.Content.Pak;
/// <summary>
/// Standard CRC-32 (IEEE 802.3 polynomial 0xEDB88320, the same variant used
/// by zip/gzip/PNG). Implemented directly rather than pulling in
/// System.IO.Hashing to keep AcDream.Content's dependency surface minimal
/// (mirrors the "no GL binaries, keep it lean" intent of the project — see
/// AcDream.Content.csproj's package comments). Used as the pak TOC's
/// per-blob corruption tripwire.
/// </summary>
public static class Crc32 {
private static readonly uint[] Table = BuildTable();
private static uint[] BuildTable() {
const uint polynomial = 0xEDB88320u;
var table = new uint[256];
for (uint i = 0; i < 256; i++) {
uint c = i;
for (int k = 0; k < 8; k++) {
c = (c & 1) != 0 ? polynomial ^ (c >> 1) : c >> 1;
}
table[i] = c;
}
return table;
}
public static uint Compute(ReadOnlySpan<byte> data) {
uint crc = 0xFFFFFFFFu;
foreach (var b in data) {
crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFFu;
}
}

View file

@ -0,0 +1,519 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using Chorizite.Core.Render.Enums;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using BoundingBox = Chorizite.Core.Lib.BoundingBox;
using CullMode = DatReaderWriter.Enums.CullMode;
namespace AcDream.Content.Pak;
/// <summary>
/// Deterministic binary (de)serializer for the <see cref="ObjectMeshData"/>
/// family (<see cref="MeshBatchData"/>, <see cref="TextureBatchData"/>,
/// <see cref="StagedEmitter"/>, <see cref="TextureKey"/>, plus the DRW
/// <see cref="Sphere"/> / <see cref="BoundingBox"/> value types).
///
/// Layout rules (normative, see
/// docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1"):
/// primitives raw little-endian; arrays as count:i32 + payload; blittable
/// arrays (VertexPositionNormalTexture[], ushort[] indices, byte[] texture
/// data) written via <see cref="MemoryMarshal.AsBytes{T}(Span{T})"/> bulk
/// copy; <see cref="ObjectMeshData.TextureBatches"/> is written sorted by the
/// key tuple (Width, Height, Format) so bakes are byte-reproducible run to
/// run regardless of dictionary insertion order; nullable fields as
/// present:byte + value.
///
/// EVERY field of every type in the family is serialized — see the plan's
/// Task 3 checklist. <see cref="ObjectMeshData.EnvCellGeometry"/> nests
/// recursively (present:byte + nested block) since MeshExtractor can
/// populate it with another full ObjectMeshData.
/// </summary>
public static class ObjectMeshDataSerializer {
public static void Write(ObjectMeshData data, Stream stream) {
using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true);
WriteObjectMeshData(bw, data);
}
/// <summary>Deserializes directly over <paramref name="bytes"/> — no defensive copy (PakReader's single-pass read reuses its CRC buffer here).</summary>
public static ObjectMeshData Read(byte[] bytes) {
using var ms = new MemoryStream(bytes, writable: false);
using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true);
return ReadObjectMeshData(br);
}
public static ObjectMeshData Read(ReadOnlySpan<byte> bytes) => Read(bytes.ToArray());
// ---- ObjectMeshData -----------------------------------------------------
private static void WriteObjectMeshData(BinaryWriter w, ObjectMeshData data) {
w.Write(data.ObjectId);
w.Write(data.IsSetup);
WriteVertexArray(w, data.Vertices);
w.Write(data.Batches.Count);
foreach (var batch in data.Batches) WriteMeshBatchData(w, batch);
w.Write(data.UploadAttempts);
// EnvCellGeometry: recursive nested block.
w.Write(data.EnvCellGeometry is not null);
if (data.EnvCellGeometry is not null) WriteObjectMeshData(w, data.EnvCellGeometry);
w.Write(data.SetupParts.Count);
foreach (var (gfxObjId, transform) in data.SetupParts) {
w.Write(gfxObjId);
WriteMatrix4x4(w, transform);
}
w.Write(data.ParticleEmitters.Count);
foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter);
WriteTextureBatches(w, data.TextureBatches);
WriteBoundingBox(w, data.BoundingBox);
WriteVector3(w, data.SortCenter);
w.Write(data.DIDDegrade);
w.Write(data.SelectionSphere is not null);
if (data.SelectionSphere is not null) WriteSphere(w, data.SelectionSphere);
WriteVector3Array(w, data.EdgeLines);
}
private static ObjectMeshData ReadObjectMeshData(BinaryReader r) {
var data = new ObjectMeshData {
ObjectId = r.ReadUInt64(),
IsSetup = r.ReadBoolean(),
};
data.Vertices = ReadVertexArray(r);
int batchCount = r.ReadInt32();
var batches = new List<MeshBatchData>(batchCount);
for (int i = 0; i < batchCount; i++) batches.Add(ReadMeshBatchData(r));
data.Batches = batches;
data.UploadAttempts = r.ReadInt32();
bool hasEnvCellGeometry = r.ReadBoolean();
data.EnvCellGeometry = hasEnvCellGeometry ? ReadObjectMeshData(r) : null;
int setupPartCount = r.ReadInt32();
var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount);
for (int i = 0; i < setupPartCount; i++) {
ulong gfxObjId = r.ReadUInt64();
var transform = ReadMatrix4x4(r);
setupParts.Add((gfxObjId, transform));
}
data.SetupParts = setupParts;
int emitterCount = r.ReadInt32();
var emitters = new List<StagedEmitter>(emitterCount);
for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r));
data.ParticleEmitters = emitters;
data.TextureBatches = ReadTextureBatches(r);
data.BoundingBox = ReadBoundingBox(r);
data.SortCenter = ReadVector3(r);
data.DIDDegrade = r.ReadUInt32();
bool hasSelectionSphere = r.ReadBoolean();
data.SelectionSphere = hasSelectionSphere ? ReadSphere(r) : null;
data.EdgeLines = ReadVector3Array(r);
return data;
}
// ---- MeshBatchData -------------------------------------------------------
private static void WriteMeshBatchData(BinaryWriter w, MeshBatchData batch) {
WriteUInt16Array(w, batch.Indices);
WriteTextureFormatTuple(w, batch.TextureFormat);
WriteTextureKey(w, batch.TextureKey);
w.Write(batch.TextureIndex);
WriteByteArray(w, batch.TextureData);
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
w.Write((int)batch.CullMode);
}
private static MeshBatchData ReadMeshBatchData(BinaryReader r) {
var batch = new MeshBatchData {
Indices = ReadUInt16Array(r),
TextureFormat = ReadTextureFormatTuple(r),
TextureKey = ReadTextureKey(r),
TextureIndex = r.ReadInt32(),
TextureData = ReadByteArray(r),
};
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
batch.CullMode = (CullMode)r.ReadInt32();
return batch;
}
// ---- TextureBatchData / TextureBatches dictionary -------------------------
private static void WriteTextureBatchData(BinaryWriter w, TextureBatchData batch) {
WriteTextureKey(w, batch.Key);
WriteByteArray(w, batch.TextureData);
WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0);
WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0);
WriteUInt16List(w, batch.Indices);
w.Write((int)batch.CullMode);
w.Write(batch.IsTransparent);
w.Write(batch.IsAdditive);
w.Write(batch.HasWrappingUVs);
}
private static TextureBatchData ReadTextureBatchData(BinaryReader r) {
var batch = new TextureBatchData {
Key = ReadTextureKey(r),
TextureData = ReadByteArray(r),
};
batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v);
batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v);
batch.Indices = ReadUInt16List(r);
batch.CullMode = (CullMode)r.ReadInt32();
batch.IsTransparent = r.ReadBoolean();
batch.IsAdditive = r.ReadBoolean();
batch.HasWrappingUVs = r.ReadBoolean();
return batch;
}
/// <summary>
/// Writes the TextureBatches dictionary sorted ascending by the key tuple
/// (Width, Height, Format) — REQUIRED for byte-reproducible bakes
/// independent of Dictionary iteration/insertion order.
/// </summary>
private static void WriteTextureBatches(
BinaryWriter w,
Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> batches) {
var sortedKeys = batches.Keys
.OrderBy(k => k.Width)
.ThenBy(k => k.Height)
.ThenBy(k => (int)k.Format)
.ToList();
w.Write(sortedKeys.Count);
foreach (var key in sortedKeys) {
w.Write(key.Width);
w.Write(key.Height);
w.Write((int)key.Format);
var list = batches[key];
w.Write(list.Count);
foreach (var item in list) WriteTextureBatchData(w, item);
}
}
private static Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>> ReadTextureBatches(BinaryReader r) {
int groupCount = r.ReadInt32();
var result = new Dictionary<(int Width, int Height, TextureFormat Format), List<TextureBatchData>>(groupCount);
for (int i = 0; i < groupCount; i++) {
int width = r.ReadInt32();
int height = r.ReadInt32();
var format = (TextureFormat)r.ReadInt32();
int listCount = r.ReadInt32();
var list = new List<TextureBatchData>(listCount);
for (int j = 0; j < listCount; j++) list.Add(ReadTextureBatchData(r));
result[(width, height, format)] = list;
}
return result;
}
// ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------
private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) {
w.Write(emitter.PartIndex);
WriteMatrix4x4(w, emitter.Offset);
w.Write(emitter.Emitter is not null);
if (emitter.Emitter is not null) WriteParticleEmitter(w, emitter.Emitter);
}
private static StagedEmitter ReadStagedEmitter(BinaryReader r) {
uint partIndex = r.ReadUInt32();
var offset = ReadMatrix4x4(r);
bool hasEmitter = r.ReadBoolean();
var pe = hasEmitter ? ReadParticleEmitter(r) : null;
return new StagedEmitter {
PartIndex = partIndex,
Offset = offset,
Emitter = pe!,
};
}
/// <summary>
/// Field-by-field serialization of DatReaderWriter.DBObjs.ParticleEmitter
/// (the dat DBObj type — StagedEmitter.Emitter resolves to THIS type via
/// ObjectMeshData.cs's `using DatReaderWriter.DBObjs;`, NOT
/// AcDream.Core.Vfx.ParticleEmitter). Every public field/property on the
/// pinned Chorizite.DatReaderWriter 2.1.7 type is written explicitly
/// (verified via reflection against the exact installed package) rather
/// than delegating to the type's own Pack/Unpack: those require a live
/// DatBinWriter/DatBinReader bound to a DatDatabase, which would couple
/// our pak's determinism to a third-party wire-format helper we don't
/// control the versioning of.
/// </summary>
private static void WriteParticleEmitter(BinaryWriter w, ParticleEmitter pe) {
w.Write(pe.Id);
w.Write(pe.DataCategory);
w.Write(pe.Unknown);
w.Write((int)pe.EmitterType);
w.Write((int)pe.ParticleType);
w.Write(pe.GfxObjId.DataId);
w.Write(pe.HwGfxObjId.DataId);
w.Write(pe.Birthrate);
w.Write(pe.MaxParticles);
w.Write(pe.InitialParticles);
w.Write(pe.TotalParticles);
w.Write(pe.TotalSeconds);
w.Write(pe.Lifespan);
w.Write(pe.LifespanRand);
WriteVector3(w, pe.OffsetDir);
w.Write(pe.MinOffset);
w.Write(pe.MaxOffset);
WriteVector3(w, pe.A);
w.Write(pe.MinA);
w.Write(pe.MaxA);
WriteVector3(w, pe.B);
w.Write(pe.MinB);
w.Write(pe.MaxB);
WriteVector3(w, pe.C);
w.Write(pe.MinC);
w.Write(pe.MaxC);
w.Write(pe.StartScale);
w.Write(pe.FinalScale);
w.Write(pe.ScaleRand);
w.Write(pe.StartTrans);
w.Write(pe.FinalTrans);
w.Write(pe.TransRand);
w.Write(pe.IsParentLocal);
}
private static ParticleEmitter ReadParticleEmitter(BinaryReader r) {
var pe = new ParticleEmitter {
Id = r.ReadUInt32(),
DataCategory = r.ReadUInt32(),
};
pe.Unknown = r.ReadUInt32();
pe.EmitterType = (DatReaderWriter.Enums.EmitterType)r.ReadInt32();
pe.ParticleType = (DatReaderWriter.Enums.ParticleType)r.ReadInt32();
pe.GfxObjId = new QualifiedDataId<GfxObj> { DataId = r.ReadUInt32() };
pe.HwGfxObjId = new QualifiedDataId<GfxObj> { DataId = r.ReadUInt32() };
pe.Birthrate = r.ReadDouble();
pe.MaxParticles = r.ReadInt32();
pe.InitialParticles = r.ReadInt32();
pe.TotalParticles = r.ReadInt32();
pe.TotalSeconds = r.ReadDouble();
pe.Lifespan = r.ReadDouble();
pe.LifespanRand = r.ReadDouble();
pe.OffsetDir = ReadVector3(r);
pe.MinOffset = r.ReadSingle();
pe.MaxOffset = r.ReadSingle();
pe.A = ReadVector3(r);
pe.MinA = r.ReadSingle();
pe.MaxA = r.ReadSingle();
pe.B = ReadVector3(r);
pe.MinB = r.ReadSingle();
pe.MaxB = r.ReadSingle();
pe.C = ReadVector3(r);
pe.MinC = r.ReadSingle();
pe.MaxC = r.ReadSingle();
pe.StartScale = r.ReadSingle();
pe.FinalScale = r.ReadSingle();
pe.ScaleRand = r.ReadSingle();
pe.StartTrans = r.ReadSingle();
pe.FinalTrans = r.ReadSingle();
pe.TransRand = r.ReadSingle();
pe.IsParentLocal = r.ReadBoolean();
return pe;
}
// ---- TextureKey ------------------------------------------------------
private static void WriteTextureKey(BinaryWriter w, TextureKey key) {
w.Write(key.SurfaceId);
w.Write(key.PaletteId);
w.Write((byte)key.Stippling);
w.Write(key.IsSolid);
}
private static TextureKey ReadTextureKey(BinaryReader r) {
return new TextureKey {
SurfaceId = r.ReadUInt32(),
PaletteId = r.ReadUInt32(),
Stippling = (DatReaderWriter.Enums.StipplingType)r.ReadByte(),
IsSolid = r.ReadBoolean(),
};
}
private static void WriteTextureFormatTuple(BinaryWriter w, (int Width, int Height, TextureFormat Format) tuple) {
w.Write(tuple.Width);
w.Write(tuple.Height);
w.Write((int)tuple.Format);
}
private static (int Width, int Height, TextureFormat Format) ReadTextureFormatTuple(BinaryReader r) {
int width = r.ReadInt32();
int height = r.ReadInt32();
var format = (TextureFormat)r.ReadInt32();
return (width, height, format);
}
// ---- Sphere / BoundingBox (DRW / Chorizite value types) -------------------
private static void WriteSphere(BinaryWriter w, Sphere sphere) {
WriteVector3(w, sphere.Origin);
w.Write(sphere.Radius);
}
private static Sphere ReadSphere(BinaryReader r) {
var origin = ReadVector3(r);
float radius = r.ReadSingle();
return new Sphere { Origin = origin, Radius = radius };
}
private static void WriteBoundingBox(BinaryWriter w, BoundingBox box) {
WriteVector3(w, box.Min);
WriteVector3(w, box.Max);
}
private static BoundingBox ReadBoundingBox(BinaryReader r) {
var min = ReadVector3(r);
var max = ReadVector3(r);
return new BoundingBox(min, max);
}
// ---- primitive helpers -------------------------------------------------
private static void WriteVector3(BinaryWriter w, Vector3 v) {
w.Write(v.X);
w.Write(v.Y);
w.Write(v.Z);
}
private static Vector3 ReadVector3(BinaryReader r) {
float x = r.ReadSingle();
float y = r.ReadSingle();
float z = r.ReadSingle();
return new Vector3(x, y, z);
}
private static void WriteMatrix4x4(BinaryWriter w, Matrix4x4 m) {
w.Write(m.M11); w.Write(m.M12); w.Write(m.M13); w.Write(m.M14);
w.Write(m.M21); w.Write(m.M22); w.Write(m.M23); w.Write(m.M24);
w.Write(m.M31); w.Write(m.M32); w.Write(m.M33); w.Write(m.M34);
w.Write(m.M41); w.Write(m.M42); w.Write(m.M43); w.Write(m.M44);
}
private static Matrix4x4 ReadMatrix4x4(BinaryReader r) {
return new Matrix4x4(
r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(),
r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(),
r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(),
r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (32 bytes/vertex).</summary>
private static void WriteVertexArray(BinaryWriter w, VertexPositionNormalTexture[] vertices) {
w.Write(vertices.Length);
if (vertices.Length == 0) return;
var bytes = MemoryMarshal.AsBytes(vertices.AsSpan());
w.Write(bytes);
}
private static VertexPositionNormalTexture[] ReadVertexArray(BinaryReader r) {
int count = r.ReadInt32();
if (count == 0) return Array.Empty<VertexPositionNormalTexture>();
var result = new VertexPositionNormalTexture[count];
var bytes = r.ReadBytes(count * VertexPositionNormalTexture.Size);
bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan()));
return result;
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (12 bytes/Vector3).</summary>
private static void WriteVector3Array(BinaryWriter w, Vector3[] array) {
w.Write(array.Length);
if (array.Length == 0) return;
var bytes = MemoryMarshal.AsBytes(array.AsSpan());
w.Write(bytes);
}
private static Vector3[] ReadVector3Array(BinaryReader r) {
int count = r.ReadInt32();
if (count == 0) return Array.Empty<Vector3>();
var result = new Vector3[count];
var bytes = r.ReadBytes(count * 12);
bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan()));
return result;
}
/// <summary>count:i32 + MemoryMarshal.AsBytes bulk copy (2 bytes/ushort).</summary>
private static void WriteUInt16Array(BinaryWriter w, ushort[] array) {
w.Write(array.Length);
if (array.Length == 0) return;
var bytes = MemoryMarshal.AsBytes(array.AsSpan());
w.Write(bytes);
}
private static ushort[] ReadUInt16Array(BinaryReader r) {
int count = r.ReadInt32();
if (count == 0) return Array.Empty<ushort>();
var result = new ushort[count];
var bytes = r.ReadBytes(count * sizeof(ushort));
bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan()));
return result;
}
private static void WriteUInt16List(BinaryWriter w, List<ushort> list) {
w.Write(list.Count);
if (list.Count == 0) return;
var array = list.ToArray();
var bytes = MemoryMarshal.AsBytes(array.AsSpan());
w.Write(bytes);
}
private static List<ushort> ReadUInt16List(BinaryReader r) {
int count = r.ReadInt32();
var list = new List<ushort>(count);
if (count == 0) return list;
var array = new ushort[count];
var bytes = r.ReadBytes(count * sizeof(ushort));
bytes.CopyTo(MemoryMarshal.AsBytes(array.AsSpan()));
list.AddRange(array);
return list;
}
/// <summary>count:i32 + raw byte payload.</summary>
private static void WriteByteArray(BinaryWriter w, byte[] array) {
w.Write(array.Length);
if (array.Length > 0) w.Write(array);
}
private static byte[] ReadByteArray(BinaryReader r) {
int count = r.ReadInt32();
return count == 0 ? Array.Empty<byte>() : r.ReadBytes(count);
}
/// <summary>present:byte + value:i32 for a nullable enum-backed-by-int field.</summary>
private static void WriteNullableInt32Enum(BinaryWriter w, bool present, int value) {
w.Write(present);
w.Write(value);
}
private static TEnum? ReadNullableInt32Enum<TEnum>(BinaryReader r, Func<int, TEnum> project) where TEnum : struct, Enum {
bool present = r.ReadBoolean();
int value = r.ReadInt32();
return present ? project(value) : null;
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Buffers.Binary;
using System.IO;
namespace AcDream.Content.Pak;
/// <summary>
/// Pak format constants shared by writer and reader.
/// </summary>
public static class PakFormat {
/// <summary>
/// The one format version this build writes and reads. PakWriter stamps
/// it unconditionally (callers cannot override it via the header
/// template); PakReader refuses to open any other version. Bump ONLY
/// with an accompanying reader migration path.
/// </summary>
public const uint CurrentFormatVersion = 1;
}
/// <summary>
/// Fixed 64-byte pak file header. Layout (all integers little-endian):
/// <code>
/// offset size field
/// 0 4 magic 'ACPK' (0x4B504341)
/// 4 4 formatVersion = 1
/// 8 4 portalIteration (DatCollection.Portal.Iteration)
/// 12 4 cellIteration
/// 16 4 highResIteration
/// 20 4 languageIteration
/// 24 8 tocOffset (u64)
/// 32 4 tocCount (u32)
/// 36 4 bakeToolVersion = 1
/// 40 24 reserved (zero)
/// </code>
/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)".
/// </summary>
public struct PakHeader {
public const int Size = 64;
public const uint MagicValue = 0x4B504341u; // 'ACPK' little-endian
/// <summary>Always <see cref="MagicValue"/> after <see cref="ReadFrom(ReadOnlySpan{byte})"/>; not settable by callers building a header to write.</summary>
public uint Magic { get; private set; } = MagicValue;
public uint FormatVersion;
public uint PortalIteration;
public uint CellIteration;
public uint HighResIteration;
public uint LanguageIteration;
public ulong TocOffset;
public uint TocCount;
public uint BakeToolVersion;
public PakHeader() { }
public void WriteTo(Span<byte> dest) {
if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest));
BinaryPrimitives.WriteUInt32LittleEndian(dest[0..4], MagicValue);
BinaryPrimitives.WriteUInt32LittleEndian(dest[4..8], FormatVersion);
BinaryPrimitives.WriteUInt32LittleEndian(dest[8..12], PortalIteration);
BinaryPrimitives.WriteUInt32LittleEndian(dest[12..16], CellIteration);
BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], HighResIteration);
BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], LanguageIteration);
BinaryPrimitives.WriteUInt64LittleEndian(dest[24..32], TocOffset);
BinaryPrimitives.WriteUInt32LittleEndian(dest[32..36], TocCount);
BinaryPrimitives.WriteUInt32LittleEndian(dest[36..40], BakeToolVersion);
dest[40..64].Clear(); // reserved, zero
}
public void WriteTo(Stream stream) {
Span<byte> buf = stackalloc byte[Size];
WriteTo(buf);
stream.Write(buf);
}
public static PakHeader ReadFrom(ReadOnlySpan<byte> src) {
if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src));
var magic = BinaryPrimitives.ReadUInt32LittleEndian(src[0..4]);
if (magic != MagicValue) {
throw new InvalidDataException($"pak header magic mismatch: expected 0x{MagicValue:X8}, got 0x{magic:X8}");
}
return new PakHeader {
FormatVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[4..8]),
PortalIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[8..12]),
CellIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[12..16]),
HighResIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]),
LanguageIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]),
TocOffset = BinaryPrimitives.ReadUInt64LittleEndian(src[24..32]),
TocCount = BinaryPrimitives.ReadUInt32LittleEndian(src[32..36]),
BakeToolVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[36..40]),
};
}
public static PakHeader ReadFrom(Stream stream) {
Span<byte> buf = stackalloc byte[Size];
stream.ReadExactly(buf);
return ReadFrom((ReadOnlySpan<byte>)buf);
}
}
/// <summary>
/// One 24-byte TOC entry: <c>key u64, offset u64, length u32, crc32 u32</c>.
/// Entries in a pak's TOC are sorted ascending by <see cref="Key"/> to allow
/// binary-search lookup. <see cref="Crc32"/> is a corruption tripwire computed
/// over the blob bytes; the reader verifies lazily on first access.
/// </summary>
public struct PakTocEntry {
public const int Size = 24;
public ulong Key;
public ulong Offset;
public uint Length;
public uint Crc32;
public void WriteTo(Span<byte> dest) {
if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest));
BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key);
BinaryPrimitives.WriteUInt64LittleEndian(dest[8..16], Offset);
BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], Length);
BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], Crc32);
}
public void WriteTo(Stream stream) {
Span<byte> buf = stackalloc byte[Size];
WriteTo(buf);
stream.Write(buf);
}
public static PakTocEntry ReadFrom(ReadOnlySpan<byte> src) {
if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src));
return new PakTocEntry {
Key = BinaryPrimitives.ReadUInt64LittleEndian(src[0..8]),
Offset = BinaryPrimitives.ReadUInt64LittleEndian(src[8..16]),
Length = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]),
Crc32 = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]),
};
}
}

View file

@ -0,0 +1,33 @@
namespace AcDream.Content.Pak;
/// <summary>
/// MP1b pak asset-type discriminant — the top byte of a <see cref="PakKey"/>.
/// Numeric values are a WIRE FORMAT (persisted in every pak's TOC): never
/// renumber existing members, only append.
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.2;
/// plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1".
/// </summary>
public enum PakAssetType : byte {
GfxObjMesh = 1,
SetupMesh = 2,
EnvCellMesh = 3,
}
/// <summary>
/// Composes/decomposes the 64-bit pak asset key: <c>type:u8 | fileId:u32 | reserved:u24</c>.
/// Layout: <c>((ulong)type &lt;&lt; 56) | ((ulong)fileId &lt;&lt; 24)</c> — the low 24 bits are
/// reserved (variant/zero in v1). Ascending numeric key order equals ascending
/// (type, fileId) tuple order, which is what makes the pak TOC's binary search
/// over raw u64 keys valid.
/// </summary>
public static class PakKey {
public static ulong Compose(PakAssetType type, uint fileId) {
return ((ulong)type << 56) | ((ulong)fileId << 24);
}
public static (PakAssetType Type, uint FileId) Decompose(ulong key) {
var type = (PakAssetType)(byte)(key >> 56);
var fileId = (uint)((key >> 24) & 0xFFFFFFFFu);
return (type, fileId);
}
}

View file

@ -0,0 +1,222 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.MemoryMappedFiles;
namespace AcDream.Content.Pak;
/// <summary>
/// mmap reader for an acdream pak file. One <see cref="MemoryMappedFile"/>
/// for the whole file's lifetime; TOC is loaded once at construction into a
/// key-sorted array for O(log n) binary-search lookup.
///
/// <para><b>Corrupt = missing (documented contract):</b> a pak is an external
/// file — the reader's job is to surface corruption loudly ONCE and then
/// behave as if the damaged entry were absent, never to hand back garbage or
/// throw from a lookup. Three tripwires feed the same "missing" state:
/// (1) structurally invalid TOC entries (offset/length outside the file) are
/// detected at open; (2) blob CRC-32 is verified LAZILY on first access and
/// cached; (3) a blob whose bytes fail deserialization despite a matching
/// CRC (malformed structure) fails at read. Each is logged once per entry.
/// Structurally unopenable files (bad magic, wrong format version,
/// unfinalized header, TOC past EOF) throw at OPEN — before any lookups —
/// with a message naming the problem.</para>
///
/// <para><b>Thread safety:</b> no locks on the read path — the mapped memory
/// is immutable for the lifetime of this reader (acdream never mutates a pak
/// while it's open for reading; MP1c's cutover writes a NEW pak and swaps it
/// in). The lazy per-entry verdict set is a ConcurrentDictionary because
/// MP1c calls TryReadObjectMeshData from up to 4 decode workers concurrently.</para>
///
/// <para><b>Perf note:</b> a first read costs ONE exact-size byte[] copy out
/// of the map — CRC and deserialization both run over that same buffer in a
/// single pass. True span-over-mmap zero-copy (deserializing straight out of
/// the mapped view with no copy at all) is deferred to MP1c profiling — do
/// not add it speculatively.</para>
/// </summary>
public sealed class PakReader : IDisposable {
private readonly MemoryMappedFile _mmf;
private readonly MemoryMappedViewAccessor _accessor;
private readonly long _fileLength;
private readonly PakTocEntry[] _toc; // sorted ascending by Key
/// <summary>Lazy per-entry verdict: absent = not yet judged, 0 = bad (bounds/crc/structure), 1 = ok.</summary>
private readonly ConcurrentDictionary<int, int> _entryVerdictByTocIndex = new();
private readonly ConcurrentDictionary<int, bool> _loggedCorruption = new();
public PakHeader Header { get; }
public PakReader(string path) {
_fileLength = new FileInfo(path).Length;
if (_fileLength < PakHeader.Size) {
throw new InvalidDataException($"pak file '{path}' is {_fileLength} bytes — smaller than the {PakHeader.Size}-byte header");
}
_mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, mapName: null, capacity: 0, MemoryMappedFileAccess.Read);
_accessor = _mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read);
var headerBytes = new byte[PakHeader.Size];
_accessor.ReadArray(0, headerBytes, 0, PakHeader.Size);
Header = PakHeader.ReadFrom((ReadOnlySpan<byte>)headerBytes);
if (Header.FormatVersion != PakFormat.CurrentFormatVersion) {
throw new InvalidDataException(
$"pak file '{path}' has format version {Header.FormatVersion}; this build reads only " +
$"version {PakFormat.CurrentFormatVersion}. Re-bake with the matching acdream-bake.");
}
// Half-written pak: PakWriter writes a placeholder header (TocOffset 0)
// first and only seeks back to finalize it in Finish(). A crash between
// those leaves TocOffset below the header size — structurally
// unfinalized; refuse loudly rather than serving an empty TOC over a
// file that has blob bytes in it.
if (Header.TocOffset < PakHeader.Size) {
throw new InvalidDataException(
$"pak file '{path}' has an unfinalized header (tocOffset={Header.TocOffset}) — " +
"the bake was interrupted before Finish(); re-bake.");
}
long tocBytesTotal = (long)Header.TocCount * PakTocEntry.Size;
if ((long)Header.TocOffset + tocBytesTotal > _fileLength) {
throw new InvalidDataException(
$"pak file '{path}' TOC (offset {Header.TocOffset}, {Header.TocCount} entries) extends past " +
$"the file's actual length ({_fileLength} bytes) — truncated or corrupt file");
}
_toc = new PakTocEntry[Header.TocCount];
var tocBytes = new byte[PakTocEntry.Size];
long tocPos = (long)Header.TocOffset;
for (int i = 0; i < _toc.Length; i++) {
_accessor.ReadArray(tocPos, tocBytes, 0, PakTocEntry.Size);
_toc[i] = PakTocEntry.ReadFrom((ReadOnlySpan<byte>)tocBytes);
tocPos += PakTocEntry.Size;
// Per-entry bounds validation (corrupt = missing, not throw): a
// blob region must sit fully between the header and the TOC, and
// its offset must survive the (long) cast the accessor needs.
ref readonly var entry = ref _toc[i];
bool invalid =
entry.Offset < PakHeader.Size ||
entry.Offset > long.MaxValue ||
entry.Offset + entry.Length > Header.TocOffset ||
entry.Offset + entry.Length > (ulong)_fileLength;
if (invalid) {
_entryVerdictByTocIndex[i] = 0;
LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, length={entry.Length}, " +
$"file={_fileLength}, toc@{Header.TocOffset})");
}
}
}
/// <summary>True if <paramref name="key"/> is present AND its blob verifies (bounds + CRC).</summary>
public bool ContainsKey(ulong key) {
int index = BinarySearch(key);
if (index < 0) return false;
return VerdictFor(index) == 1;
}
/// <summary>
/// Reads and deserializes the <see cref="ObjectMeshData"/> stored under
/// <paramref name="key"/>. Returns false (data null) if the key is absent
/// OR the blob fails any tripwire (bounds, CRC, structural deserialization
/// failure) — logged once per entry, per the corrupt-=-missing contract.
/// </summary>
public bool TryReadObjectMeshData(ulong key, out ObjectMeshData? data) {
data = null;
int index = BinarySearch(key);
if (index < 0) return false;
// Previously judged bad (bounds at open, or an earlier CRC/structure
// failure): missing, no re-read, no re-log.
bool judged = _entryVerdictByTocIndex.TryGetValue(index, out var verdict);
if (judged && verdict == 0) return false;
// Single pass (review finding 4): ONE copy out of the map; CRC and
// deserialization both run over this same buffer.
ref readonly var entry = ref _toc[index];
var bytes = new byte[entry.Length];
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
if (!judged) {
uint actualCrc = Crc32.Compute(bytes);
if (actualCrc != entry.Crc32) {
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})");
return false;
}
_entryVerdictByTocIndex[index] = 1;
}
try {
data = ObjectMeshDataSerializer.Read(bytes);
return true;
}
catch (Exception ex) {
// Structurally malformed blob behind a valid CRC (bake-side bug or
// a tamper that recomputed the CRC). External-file input: demote
// to missing, log once — never propagate from a lookup.
data = null;
_entryVerdictByTocIndex[index] = 0;
LogCorruptionOnce(index, $"deserialization failed despite matching CRC: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
/// <summary>Returns the blob's file offset for alignment assertions in tests.</summary>
public long GetBlobOffsetForTest(ulong key) {
int index = BinarySearch(key);
if (index < 0) throw new KeyNotFoundException($"pak key 0x{key:X16} not found");
return (long)_toc[index].Offset;
}
/// <summary>O(n) linear scan, used only to cross-check the binary search in tests.</summary>
public bool DebugLinearScanContainsKey(ulong key) {
for (int i = 0; i < _toc.Length; i++) {
if (_toc[i].Key == key) return VerdictFor(i) == 1;
}
return false;
}
/// <summary>Resolves (and caches) the entry's verdict, reading + CRC-checking the blob if not yet judged.</summary>
private int VerdictFor(int tocIndex) {
if (_entryVerdictByTocIndex.TryGetValue(tocIndex, out var cached)) return cached;
ref readonly var entry = ref _toc[tocIndex];
var bytes = new byte[entry.Length];
_accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length);
uint actualCrc = Crc32.Compute(bytes);
bool ok = actualCrc == entry.Crc32;
_entryVerdictByTocIndex[tocIndex] = ok ? 1 : 0;
if (!ok) {
LogCorruptionOnce(tocIndex, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})");
}
return ok ? 1 : 0;
}
private void LogCorruptionOnce(int tocIndex, string reason) {
if (!_loggedCorruption.TryAdd(tocIndex, true)) return;
ref readonly var entry = ref _toc[tocIndex];
Console.Error.WriteLine(
$"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} (length {entry.Length}): " +
$"{reason} — treating as missing");
}
private int BinarySearch(ulong key) {
int lo = 0, hi = _toc.Length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
ulong midKey = _toc[mid].Key;
if (midKey == key) return mid;
if (midKey < key) lo = mid + 1;
else hi = mid - 1;
}
return -1;
}
public void Dispose() {
_accessor.Dispose();
_mmf.Dispose();
}
}

View file

@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace AcDream.Content.Pak;
/// <summary>
/// Streams pak blobs to disk: header placeholder -> 64-byte-aligned blobs ->
/// TOC (sorted by key) -> seek back and finalize the header once
/// tocOffset/tocCount are known. This lets the writer stream blobs without
/// knowing the final count up front (plan: "TOC last").
/// </summary>
public sealed class PakWriter : IDisposable {
private const int Alignment = 64;
private readonly FileStream _stream;
private readonly PakHeader _headerTemplate;
private readonly List<PakTocEntry> _tocEntries = new();
private readonly HashSet<ulong> _seenKeys = new();
private bool _finished;
public PakWriter(string path, PakHeader headerTemplate) {
_stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
_headerTemplate = headerTemplate;
// The format version is the WRITER's identity, not caller input —
// stamp it unconditionally so a caller-populated header template can
// never produce a pak claiming a version this code doesn't write
// (the default-0 footgun; PakReader rejects any version but
// PakFormat.CurrentFormatVersion).
_headerTemplate.FormatVersion = PakFormat.CurrentFormatVersion;
// Write a header placeholder now; finalized in Finish() once TocOffset/TocCount are known.
_headerTemplate.WriteTo(_stream);
PadToAlignment();
}
/// <summary>
/// Adds one blob (an <see cref="ObjectMeshData"/> serialized via
/// <see cref="ObjectMeshDataSerializer"/>) under <paramref name="key"/>.
/// Blobs are written immediately at the current (64-byte-aligned) stream
/// position; the writer pads after each blob so the NEXT blob also
/// starts aligned.
/// </summary>
public void AddBlob(ulong key, ObjectMeshData data) {
ThrowIfFinished();
if (!_seenKeys.Add(key)) {
throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key));
}
long offset = _stream.Position;
using var ms = new MemoryStream();
ObjectMeshDataSerializer.Write(data, ms);
var bytes = ms.ToArray();
_stream.Write(bytes);
PadToAlignment();
_tocEntries.Add(new PakTocEntry {
Key = key,
Offset = (ulong)offset,
Length = (uint)bytes.Length,
Crc32 = Content.Pak.Crc32.Compute(bytes),
});
}
/// <summary>Writes the sorted TOC and finalizes the header. Must be called exactly once.</summary>
public void Finish() {
ThrowIfFinished();
_finished = true;
var sorted = _tocEntries.OrderBy(e => e.Key).ToList();
ulong tocOffset = (ulong)_stream.Position;
foreach (var entry in sorted) {
entry.WriteTo(_stream);
}
var finalHeader = _headerTemplate;
finalHeader.TocOffset = tocOffset;
finalHeader.TocCount = (uint)sorted.Count;
_stream.Position = 0;
finalHeader.WriteTo(_stream);
_stream.Flush();
}
private void PadToAlignment() {
long pos = _stream.Position;
long remainder = pos % Alignment;
if (remainder == 0) return;
long pad = Alignment - remainder;
Span<byte> zeros = stackalloc byte[(int)pad];
zeros.Clear();
_stream.Write(zeros);
}
private void ThrowIfFinished() {
if (_finished) throw new InvalidOperationException("PakWriter.Finish() has already been called.");
}
public void Dispose() {
// Best-effort finalize so a forgotten Finish() call doesn't leave a
// half-written file with a placeholder header claiming 0 entries
// while blobs are on disk. try/finally, NOT a bare sequence: Finish()
// performs real I/O (TOC write, header seek-back, flush) and can
// throw for environmental reasons (disk full, I/O error) — the
// stream must ALWAYS be closed so the file handle is never leaked
// mid-unwind (review finding 5 on the a5926ebc cleanup, which had
// removed exactly this guarantee).
try {
if (!_finished) {
Finish();
}
}
finally {
_stream.Dispose();
}
}
}

View file

@ -0,0 +1,30 @@
using DatReaderWriter.Enums;
using System;
namespace AcDream.Content;
// MP1a (2026-07-05): lifted verbatim out of TextureAtlasManager (GL class)
// so the GL-free mesh-extraction path (MeshExtractor / ObjectMeshData) can
// reference the atlas dedup key without a Silk.NET dependency. Field set,
// equality semantics, and hash behavior are UNCHANGED.
public struct TextureKey : IEquatable<TextureKey> {
public uint SurfaceId;
public uint PaletteId;
public StipplingType Stippling;
public bool IsSolid;
public bool Equals(TextureKey other) {
return SurfaceId == other.SurfaceId &&
PaletteId == other.PaletteId &&
Stippling == other.Stippling &&
IsSolid == other.IsSolid;
}
public override bool Equals(object? obj) {
return obj is TextureKey other && Equals(other);
}
public override int GetHashCode() {
return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid);
}
}

View file

@ -0,0 +1,34 @@
namespace AcDream.Content;
// MP1a follow-up (2026-07-05): Content-owned upload-format hint enums.
// AcDream.Content must stay Silk.NET-free (the MP1b bake tool must not ship
// GL binaries), so the moved MeshBatchData/TextureBatchData records carry
// these instead of Silk.NET.OpenGL.PixelFormat/PixelType. The underlying
// values are the OpenGL ABI constants, kept numerically identical to the
// corresponding Silk.NET.OpenGL members (verified against Silk.NET.OpenGL
// 2.23.0) so the App-boundary cast
// `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` is
// value-preserving. GL enum constants are a stable specification ABI —
// they cannot drift between Silk.NET versions.
//
// Members are ONLY the values the extraction code actually assigns
// (see MeshExtractor.cs). Add new members with their GL constant if a
// future decode path needs them — never renumber.
/// <summary>
/// GL pixel-format upload hint computed at extraction time.
/// Numeric values = OpenGL <c>GL_*</c> constants (= Silk.NET.OpenGL.PixelFormat).
/// </summary>
public enum UploadPixelFormat {
/// <summary>GL_RGBA.</summary>
Rgba = 0x1908,
}
/// <summary>
/// GL pixel-type upload hint computed at extraction time.
/// Numeric values = OpenGL <c>GL_*</c> constants (= Silk.NET.OpenGL.PixelType).
/// </summary>
public enum UploadPixelType {
/// <summary>GL_UNSIGNED_BYTE.</summary>
UnsignedByte = 0x1401,
}

View file

@ -119,6 +119,9 @@ public static class CreateObject
ushort TeleportSequence = 0,
ushort ServerControlSequence = 0,
ushort ForcePositionSequence = 0,
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
@ -215,6 +218,21 @@ public static class CreateObject
/// 0x40=TurnSpeed.
/// </para>
/// </summary>
/// <param name="MoveToRunRate">
/// R4-V3 deliverable D — the trailing <c>f32 runRate</c> on MoveToObject
/// (6) / MoveToPosition (7) payloads. Retail's <c>unpack_movement</c>
/// writes this straight onto <c>CMotionInterp::my_run_rate</c>
/// (r4-moveto-decomp.md §2f: <c>this->motion_interpreter->my_run_rate =
/// read_float()</c>, both @300603 case 6 and @300660 case 7 — SAME
/// write for both types, immediately after <c>UnPackNet</c>). Today this
/// field only seeds <c>PlanMoveToStart</c>'s local heuristic (plan M13);
/// the interp's actual <see cref="AcDream.Core.Physics.MotionInterpreter.MyRunRate"/>
/// field is a SEPARATE consumer write the V4/V5 MoveToManager cutover
/// performs at the GameWindow mt 6-9 routing site (r4-port-plan.md §4,
/// step 2: <c>Motion.MyRunRate = MoveToRunRate</c>) — this record is
/// wire-primitive only; it does not write MotionInterpreter state
/// itself.
/// </param>
public readonly record struct ServerMotionState(
ushort Stance,
ushort? ForwardCommand,
@ -228,7 +246,30 @@ public static class CreateObject
uint? MoveToParameters = null,
float? MoveToSpeed = null,
float? MoveToRunRate = null,
MoveToPathData? MoveToPath = null)
MoveToPathData? MoveToPath = null,
// R4-V3 (closes M7): movement types 8 (TurnToObject) and 9
// (TurnToHeading) — previously dropped end-to-end (UpdateMotion.cs
// only branched on `movementType is 6 or 7`). Carries the DECODED
// wire payload (guid + standalone wire_heading for type 8, plus the
// shared 3-dword UnPackNet triple for both types) per V0-pins.md P6.
TurnToPathData? TurnToPath = null,
// R4-V3 (closes M14-wire-note): the 0xF74C motionFlags sticky-guid
// trailer, mt=0 (Invalid) only — ACE MovementInvalid.Write gates the
// trailing guid on MotionFlags.StickToObject (0x1); the decomp's
// `unpack_movement` case 0 reads it right after
// InterpretedMotionState::UnPack (r4-moveto-decomp.md §2f
// @0052455d: `if (header & 0x100) sticky_object_guid = read_dword()`
// — bit 0x100 of the combined header word is motionFlags byte1&0x1).
// R5-V4 consumes it: the GameWindow mt-0 tail routes it into
// CPhysicsObj::stick_to_object's port (target PartArray radii →
// PositionManager.StickTo — decomp 0x005127e0, call @00524589).
uint? StickyObjectGuid = null,
// R5-V4 (closes the "documented but NOT consumed" note in
// UpdateMotion.cs): motionFlags & 0x2 — retail `unpack_movement`
// case 0 writes it onto `motion_interpreter->standing_longjump`
// UNCONDITIONALLY (@0052458e: absent flag CLEARS it). Consumed at
// the GameWindow mt-0 tails (remote + player).
bool StandingLongJump = false)
{
/// <summary>
/// ACE/retail movement types 6 and 7 are server-controlled
@ -238,6 +279,13 @@ public static class CreateObject
/// </summary>
public bool IsServerControlledMoveTo => MovementType is 6 or 7;
/// <summary>
/// R4-V3: movement types 8 (TurnToObject) and 9 (TurnToHeading) —
/// the turn-only sibling of <see cref="IsServerControlledMoveTo"/>.
/// Neither carries an InterpretedMotionState.ForwardCommand either.
/// </summary>
public bool IsServerControlledTurnTo => MovementType is 8 or 9;
public bool MoveToCanRun => !MoveToParameters.HasValue
|| (MoveToParameters.Value & 0x2u) != 0;
@ -297,6 +345,51 @@ public static class CreateObject
float MinDistance,
float FailDistance,
float WalkRunThreshold,
float DesiredHeading,
uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire
/// <summary>
/// R4-V3 (closes M7) — path-control payload of a server-controlled
/// TurnTo packet (movementType 8 TurnToObject or 9 TurnToHeading).
/// Sibling of <see cref="MoveToPathData"/>: kept as a SEPARATE record
/// rather than widening <c>MoveToPathData</c> in place, because the two
/// wire forms genuinely diverge (7-dword <c>UnPackNet</c> with an
/// Origin+optional-guid head for move types vs. the 3-dword
/// <c>UnPackNet</c> with a guid+standalone-heading head for turn types —
/// V0-pins.md P6) and a single record would need every move-only field
/// nullable for turn payloads (and vice versa) for no reader benefit —
/// no code path ever needs "either a move or a turn path" polymorphically,
/// every consumer already switches on <see cref="ServerMotionState.MovementType"/>
/// first.
///
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>TargetGuid</c>, f32
/// <c>WireHeading</c> — the STANDALONE heading field (ACE
/// <c>TurnToObject.DesiredHeading</c>, distinct from
/// <see cref="DesiredHeading"/> below despite ACE always populating
/// both from the same source; V0-pins.md P6's fixture caveat: never
/// distinguish the two fields by value in a test, only by
/// OFFSET). Consumed ONLY in retail's unresolvable-object fallback
/// (decomp §2f case 8: <c>if (GetObjectA(object_id) == 0) {
/// params.desired_heading = wire_heading; goto TurnToHeading; }</c>)
/// — the resolved-object path never reads it.</item>
/// <item>TurnToParameters (0xc bytes, exact retail order —
/// <c>MovementParameters::UnPackNet</c> 3-dword TurnTo form, decomp
/// §2g): u32 <c>Bitfield</c>, f32 <c>Speed</c>, f32
/// <c>DesiredHeading</c>. Present for BOTH type 8 and type 9 (type 9
/// has no guid/WireHeading head — <see cref="TargetGuid"/> and
/// <see cref="WireHeading"/> are null).</item>
/// </list>
///
/// Feeds <see cref="AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo"/>
/// at the (future) App-layer consumer — this record stays wire-primitive
/// (no domain-object construction in the Net-layer parser).
/// </summary>
public readonly record struct TurnToPathData(
uint? TargetGuid,
float? WireHeading,
uint Bitfield,
float Speed,
float DesiredHeading);
/// <summary>
@ -532,11 +625,13 @@ public static class CreateObject
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
// 9 sequence timestamps, always present at end of PhysicsData.
// Indices per holtburger: 0=position, 4=teleport, 5=serverControl,
// 6=forcePosition, 8=instance.
// PhysicsTimeStamp enum order (acclient.h:6084; ACE
// WorldObject_Networking.cs:411-420): 0=position, 1=movement,
// 4=teleport, 5=serverControl, 6=forcePosition, 8=instance.
if (body.Length - pos < 9 * 2) return PartialResult();
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
@ -849,6 +944,7 @@ public static class CreateObject
return new Parsed(guid, position, setupTableId, animParts,
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
physicsState, objectDescriptionFlags,
friction, elasticity,
IconId: iconId,

View file

@ -9,18 +9,27 @@ namespace AcDream.Core.Net.Messages;
/// the extent + velocity to validate the jump and replicate it to nearby
/// clients.
///
/// Wire layout (from holtburger JumpActionData::pack):
/// u32 0xF7B1 (GameAction envelope)
/// u32 sequence
/// u32 0xF61B (Jump sub-opcode)
/// f32 extent (0.01.0 charge power)
/// f32 velocity.x, f32 velocity.y, f32 velocity.z
/// u16 instanceSequence
/// u16 serverControlSequence
/// u16 teleportSequence
/// u16 forcePositionSequence
/// u32 objectGuid (0 for normal jump)
/// u32 spellId (0 for normal jump)
/// <para>
/// Wire layout — ports retail's <c>JumpPack::Pack</c> verbatim
/// (<c>0x00516d10</c>, decomp lines ~284934-284963). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61B</item>
/// <item><b>f32 extent</b> (0.0-1.0 charge power)</item>
/// <item><b>f32 velocity.x, velocity.y, velocity.z</b></item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// <b>D4 fix.</b> Retail's <c>JumpPack</c> does NOT pack an objectGuid or a
/// spellId, and DOES pack the full <c>Position</c> — the pre-slice code had
/// it backwards (two spurious trailing zero u32s, no Position at all).
/// </para>
/// </summary>
public static class JumpAction
{
@ -28,29 +37,45 @@ public static class JumpAction
public const uint JumpOpcode = 0xF61Bu;
public static byte[] Build(
uint gameActionSequence,
float extent,
Vector3 velocity,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
uint gameActionSequence,
float extent,
Vector3 velocity,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence)
{
var w = new PacketWriter(48);
var w = new PacketWriter(80);
w.WriteUInt32(GameActionOpcode);
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(JumpOpcode);
w.WriteFloat(extent);
w.WriteFloat(velocity.X);
w.WriteFloat(velocity.Y);
w.WriteFloat(velocity.Z);
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
w.WriteFloat(position.Z);
// Quaternion wire order: W, X, Y, Z
w.WriteFloat(rotation.W);
w.WriteFloat(rotation.X);
w.WriteFloat(rotation.Y);
w.WriteFloat(rotation.Z);
w.WriteUInt16(instanceSequence);
w.WriteUInt16(serverControlSequence);
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
w.WriteUInt32(0); // objectGuid — 0 for normal jump
w.WriteUInt32(0); // spellId — 0 for normal jump
w.AlignTo4();
return w.ToArray();
}

View file

@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
@ -11,61 +12,39 @@ namespace AcDream.Core.Net.Messages;
/// state and to drive interpolated position for other nearby clients.
///
/// <para>
/// Wire layout (ported from
/// <c>references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs</c>
/// <c>MoveToStateActionData::pack</c> and
/// <c>types.rs RawMotionState::pack</c>):
/// Wire layout — ports retail's <c>MoveToStatePack::Pack</c> verbatim
/// (<c>0x005168f0</c>, decomp lines ~284694-284722). Confirmed against the
/// Ghidra decompile-by-address bridge during this slice (2026-06-30):
/// </para>
/// <list type="bullet">
/// <item><b>GameAction envelope</b>: u32 0xF7B1, u32 sequence, u32 0xF61C</item>
/// <item><b>RawMotionState</b>: u32 packed_flags (bits 0-10 = flag bits,
/// bits 11-31 = command list length), then conditional u32/f32 fields
/// in flag-bit order (see <c>RawMotionFlags</c> in types.rs)</item>
/// <item><b>WorldPosition</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ</item>
/// <item><b>RawMotionState::Pack</b>: see <see cref="RawMotionStatePacker"/>
/// — default-difference flags dword + conditional fields + actions.</item>
/// <item><b>Position::Pack</b>: u32 cellId, f32 x, f32 y, f32 z,
/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes)</item>
/// <item><b>Sequences</b>: u16 instance, u16 serverControl,
/// u16 teleport, u16 forcePosition</item>
/// <item><b>Contact byte</b>: u8 (1 = on ground, 0 = airborne)</item>
/// <item><b>Trailing byte</b>: <c>(standingLongjump ? 0x02 : 0) |
/// (contact ? 0x01 : 0)</c> — <c>MoveToStatePack::Pack</c> trailing
/// byte expression <c>(longjump_mode == 0) - 1U &amp; 2 |
/// contact != 0</c>.</item>
/// <item><b>Align to 4 bytes</b></item>
/// </list>
///
/// <para>
/// The command list length is packed into bits 11-31 of the flags dword.
/// We always send 0 commands (no discrete motion events), so those bits stay 0.
/// </para>
/// </summary>
public static class MoveToState
{
public const uint GameActionOpcode = 0xF7B1u;
public const uint MoveToStateAction = 0xF61Cu;
// RawMotionFlags bit positions (from holtburger types.rs)
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
/// <summary>
/// Build a MoveToState GameAction body.
/// </summary>
/// <param name="gameActionSequence">Monotonically increasing counter from
/// <see cref="WorldSession.NextGameActionSequence"/>.</param>
/// <param name="forwardCommand">Raw motion command (u32 in AC's command space),
/// e.g. 0x45000005 = WalkForward. Null = no forward command.</param>
/// <param name="forwardSpeed">Normalised speed scalar (0.0-1.0). Only written
/// when <paramref name="forwardCommand"/> is non-null.</param>
/// <param name="sidestepCommand">Sidestep command or null.</param>
/// <param name="sidestepSpeed">Sidestep speed or null.</param>
/// <param name="turnCommand">Turn command or null.</param>
/// <param name="turnSpeed">Turn speed or null.</param>
/// <param name="holdKey">Hold-key state (1=None, 2=Run). Null = omit.</param>
/// <param name="rawMotionState">Complete raw-motion snapshot, matching
/// retail's <c>CPhysicsObj::InqRawMotionState()</c>. Fields equal to
/// <see cref="RawMotionState.Default"/> are omitted from the wire
/// (see <see cref="RawMotionStatePacker"/>).</param>
/// <param name="cellId">Landblock cell ID (u32).</param>
/// <param name="position">World-space position relative to the landblock.</param>
/// <param name="rotation">Rotation quaternion. AC wire order is W, X, Y, Z.</param>
@ -73,27 +52,22 @@ public static class MoveToState
/// <param name="serverControlSequence">Server-control sequence number.</param>
/// <param name="teleportSequence">Teleport sequence number.</param>
/// <param name="forcePositionSequence">Force-position sequence number.</param>
/// <param name="contactLongJump">1 if the character is on the ground, 0 if airborne.</param>
/// <param name="contact">True if the character is on the ground.</param>
/// <param name="standingLongjump">True during a standing (charged,
/// stationary) longjump wind-up. Not yet implemented in acdream —
/// callers pass <c>false</c> honestly until that feature lands.</param>
public static byte[] Build(
uint gameActionSequence,
uint? forwardCommand,
float? forwardSpeed,
uint? sidestepCommand,
float? sidestepSpeed,
uint? turnCommand,
float? turnSpeed,
uint? holdKey,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
byte contactLongJump = 1,
uint? forwardHoldKey = null,
uint? sidestepHoldKey = null,
uint? turnHoldKey = null)
uint gameActionSequence,
RawMotionState rawMotionState,
uint cellId,
Vector3 position,
Quaternion rotation,
ushort instanceSequence,
ushort serverControlSequence,
ushort teleportSequence,
ushort forcePositionSequence,
bool contact = true,
bool standingLongjump = false)
{
var w = new PacketWriter(128);
@ -102,43 +76,10 @@ public static class MoveToState
w.WriteUInt32(gameActionSequence);
w.WriteUInt32(MoveToStateAction);
// --- RawMotionState ---
// Build the flags word. Command list length (bits 11-31) is always 0.
// Field order matches holtburger's RawMotionState::pack — for any axis
// where we send a COMMAND + SPEED, retail expects the matching
// *_HOLD_KEY to accompany them (see holtburger's
// build_motion_state_raw_motion_state). Without the per-axis hold
// keys the server gets the flags but can't classify the input as a
// continuously-held key, so other players see the character sliding
// forward without an animation cycle.
uint flags = 0u;
if (holdKey.HasValue) flags |= FlagCurrentHoldKey;
if (forwardCommand.HasValue) flags |= FlagForwardCommand;
if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey;
if (forwardSpeed.HasValue) flags |= FlagForwardSpeed;
if (sidestepCommand.HasValue) flags |= FlagSidestepCommand;
if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey;
if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed;
if (turnCommand.HasValue) flags |= FlagTurnCommand;
if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey;
if (turnSpeed.HasValue) flags |= FlagTurnSpeed;
// --- RawMotionState::Pack (0x0051ed10) ---
RawMotionStatePacker.Pack(w, rawMotionState);
w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list)
// Conditional fields in RawMotionFlags bit order:
if (holdKey.HasValue) w.WriteUInt32(holdKey.Value);
// FlagCurrentStyle (0x2): not sent — we don't track stance changes here
if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value);
if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value);
if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value);
if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value);
if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value);
if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value);
if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value);
if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value);
if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value);
// --- WorldPosition (32 bytes) ---
// --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) ---
w.WriteUInt32(cellId);
w.WriteFloat(position.X);
w.WriteFloat(position.Y);
@ -155,8 +96,10 @@ public static class MoveToState
w.WriteUInt16(teleportSequence);
w.WriteUInt16(forcePositionSequence);
// --- Contact byte + 4-byte align ---
w.WriteByte(contactLongJump);
// --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): ---
// ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0)
byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0));
w.WriteByte(trailing);
w.AlignTo4();
return w.ToArray();

View file

@ -0,0 +1,98 @@
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Ports retail's <c>RawMotionState::Pack</c> verbatim
/// (<c>0x0051ed10</c>, decomp lines ~293761-294013; bitfield layout
/// <c>acclient.h RawMotionState::PackBitfield</c>, line 46474). Confirmed
/// against the Ghidra decompile-by-address bridge during this slice
/// (2026-06-30).
///
/// <para>
/// <b>D1 fix.</b> Retail does NOT set a field's bit merely because the
/// caller supplied a value — it compares every field against its retail
/// DEFAULT (<see cref="RawMotionState.Default"/>) and sets the bit (and
/// emits the field) only when the live value DIFFERS. This packer mirrors
/// that default-difference comparison exactly, field by field, in bit
/// order.
/// </para>
///
/// <para>
/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's
/// <c>num_actions</c> is a 5-bit field occupying bits 11-15, not "the rest
/// of the dword"):
/// </para>
/// <list type="table">
/// <item><term>0x001</term><description>current_holdkey != HoldKey.None</description></item>
/// <item><term>0x002</term><description>current_style != 0x8000003D</description></item>
/// <item><term>0x004</term><description>forward_command != 0x41000003</description></item>
/// <item><term>0x008</term><description>forward_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x010</term><description>forward_speed != 1.0f</description></item>
/// <item><term>0x020</term><description>sidestep_command != 0</description></item>
/// <item><term>0x040</term><description>sidestep_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x080</term><description>sidestep_speed != 1.0f</description></item>
/// <item><term>0x100</term><description>turn_command != 0</description></item>
/// <item><term>0x200</term><description>turn_holdkey != HoldKey.Invalid</description></item>
/// <item><term>0x400</term><description>turn_speed != 1.0f</description></item>
/// <item><term>0xF800</term><description>num_actions (bits 11-15, count not values)</description></item>
/// </list>
/// </summary>
public static class RawMotionStatePacker
{
private const uint FlagCurrentHoldKey = 0x001u;
private const uint FlagCurrentStyle = 0x002u;
private const uint FlagForwardCommand = 0x004u;
private const uint FlagForwardHoldKey = 0x008u;
private const uint FlagForwardSpeed = 0x010u;
private const uint FlagSidestepCommand = 0x020u;
private const uint FlagSidestepHoldKey = 0x040u;
private const uint FlagSidestepSpeed = 0x080u;
private const uint FlagTurnCommand = 0x100u;
private const uint FlagTurnHoldKey = 0x200u;
private const uint FlagTurnSpeed = 0x400u;
private const int NumActionsShift = 11;
public static void Pack(PacketWriter w, RawMotionState state)
{
var defaults = RawMotionState.Default;
uint flags = 0u;
if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey;
if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle;
if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand;
if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey;
if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed;
if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand;
if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey;
if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed;
if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand;
if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey;
if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed;
int numActions = state.Actions.Count;
flags |= (uint)(numActions << NumActionsShift);
w.WriteUInt32(flags);
if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey);
if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle);
if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand);
if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey);
if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed);
if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand);
if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey);
if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed);
if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand);
if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey);
if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed);
foreach (var action in state.Actions)
{
w.WriteUInt16(action.Command);
ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0));
w.WriteUInt16(stampWord);
}
}
}

View file

@ -34,7 +34,20 @@ namespace AcDream.Core.Net.Messages;
/// u32 flagsAndCommandCount, then each present field in flag order
/// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16,
/// TurnCommand u16, forward speed f32, sidestep speed f32,
/// turn speed f32), commands list, align.</item>
/// turn speed f32), commands list, align; THEN — only when
/// <c>motionFlags &amp; 0x1</c> (StickToObject) — one trailing u32
/// sticky object guid (ACE <c>MovementInvalid.Write</c>; R4-V3).</item>
/// <item>MoveToObject (6) / MoveToPosition (7): [u32 target guid,
/// MoveToObject only] Origin (u32 cell + 3×f32), MoveToParameters /
/// <c>UnPackNet</c> 7-dword form (u32 bitfield + 6×f32), f32
/// runRate. R4-V3: exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>.</item>
/// <item>TurnToObject (8) / TurnToHeading (9): [u32 target guid, f32
/// standalone wire heading, TurnToObject only] TurnToParameters /
/// <c>UnPackNet</c> 3-dword form (u32 bitfield, f32 speed, f32
/// desired heading). R4-V3 (closes M7): exposed via
/// <see cref="AcDream.Core.Net.Messages.CreateObject.TurnToPathData"/>
/// — previously dropped end-to-end.</item>
/// </list>
/// </item>
/// </list>
@ -57,10 +70,21 @@ public static class UpdateMotion
/// command is nullable because the <c>ForwardCommand</c> flag may be
/// unset in the InterpretedMotionState; the stance is always present
/// (even if 0, meaning "no specific stance").
///
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
/// <c>MotionSequenceGate</c>) — retail compares them against
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
/// and stores <c>last_move_was_autonomous</c>
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>
/// </summary>
public readonly record struct Parsed(
uint Guid,
CreateObject.ServerMotionState MotionState);
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary>
/// Parse a reassembled UpdateMotion body. <paramref name="body"/> must
@ -82,8 +106,11 @@ public static class UpdateMotion
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
// ObjectInstance sequence (u16) — tracked but not used for pose.
// ObjectInstance sequence (u16) — retail's dispatch-level
// INSTANCE_TS staleness gate compares this against the object's
// known incarnation (DispatchSmartBoxEvent case 0xF74C).
if (body.Length - pos < 2) return null;
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
// MovementData header: u16 movementSequence, u16 serverControlSequence,
@ -102,13 +129,34 @@ public static class UpdateMotion
// Previous version mistakenly reserved 8 bytes here, which shifted
// every subsequent field by 2 and made every remote-char UpdateMotion
// decode as garbage (stance read from the packed-flags dword).
//
// L.2g S1 (DEV-6): the header fields feed retail's
// CPhysics::SetObjectMovement staleness gates + the
// last_move_was_autonomous store, so parse them instead of
// skipping.
if (body.Length - pos < 6) return null;
pos += 6;
ushort movementSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
ushort serverControlSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
bool isAutonomous = body[pos] != 0;
pos += 2; // u8 isAutonomous + Align(4) pad byte
// movementType u8, motionFlags u8, currentStyle u16
if (body.Length - pos < 4) return null;
byte movementType = body[pos]; pos += 1;
byte _motionFlags = body[pos]; pos += 1;
// MotionFlags (ACE ACE.Entity.Enum.MotionFlags, byte):
// 0x1 = StickToObject, 0x2 = StandingLongJump.
// R4-V3 (closes M14-wire-note): previously discarded as
// `_motionFlags`. StickToObject now drives the sticky-guid
// trailer parse below (mt=0 only, per ACE MovementInvalid.Write
// + decomp §2f case 0 @0052455d). StandingLongJump (0x2) is
// carried on ServerMotionState.StandingLongJump (R5-V4) and
// consumed at the GameWindow mt-0 tails — retail's
// `unpack_movement` case 0 writes it onto
// `motion_interpreter->standing_longjump` (§2f @0052458e,
// UNCONDITIONAL — an absent flag clears it).
byte motionFlags = body[pos]; pos += 1;
ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
@ -118,7 +166,7 @@ public static class UpdateMotion
var hex = new System.Text.StringBuilder();
for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} ");
System.Console.WriteLine(
$" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
$" UM raw: mt=0x{movementType:X2} mf=0x{motionFlags:X2} cs=0x{currentStyle:X4} | {hex}");
}
ushort? forwardCommand = null;
@ -131,6 +179,8 @@ public static class UpdateMotion
float? moveToSpeed = null;
float? moveToRunRate = null;
CreateObject.MoveToPathData? moveToPath = null;
CreateObject.TurnToPathData? turnToPath = null;
uint? stickyObjectGuid = null;
List<CreateObject.MotionItem>? commands = null;
if (movementType == 0)
@ -139,7 +189,7 @@ public static class UpdateMotion
// MovementInvalid branch, just reached via the header'd path.
// Includes the Commands list (MotionItem[]) that carries
// Actions, emotes, and other one-shots not in ForwardCommand.
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
uint flags = packed & 0x7Fu;
@ -162,13 +212,13 @@ public static class UpdateMotion
if ((flags & 0x1u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
if ((flags & 0x2u) != 0)
{
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType));
if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous);
forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
pos += 2;
}
@ -223,6 +273,23 @@ public static class UpdateMotion
commands.Add(new CreateObject.MotionItem(cmd, seq, speed));
}
}
// R4-V3 (closes M14-wire-note): the sticky-guid trailer.
// ACE MovementInvalid.Write (Motion/MovementInvalid.cs:41-47)
// writes the InterpretedMotionState FIRST (everything above,
// including the Commands list), THEN — only when
// MotionFlags.StickToObject (0x1) is set — one trailing u32
// guid. Decomp confirms the same order (§2f case 0:
// InterpretedMotionState::UnPack, then
// `if (header & 0x100) sticky_object_guid = read_dword()`).
// Parsed here purely to keep the cursor honest past this
// field; R5's PositionManager::StickTo body is the eventual
// consumer (M14 — "seam now, body R5").
if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4)
{
stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
done:;
}
else if (movementType is 6 or 7)
@ -236,6 +303,14 @@ public static class UpdateMotion
out moveToRunRate,
out moveToPath);
}
else if (movementType is 8 or 9)
{
TryParseTurnToPayload(
body,
pos,
movementType,
out turnToPath);
}
return new Parsed(guid, new CreateObject.ServerMotionState(
currentStyle, forwardCommand, forwardSpeed, commands,
@ -244,7 +319,14 @@ public static class UpdateMotion
moveToParameters,
moveToSpeed,
moveToRunRate,
moveToPath));
moveToPath,
turnToPath,
stickyObjectGuid,
// R5-V4: MotionFlags 0x2 — consumed at the GameWindow mt-0
// tails (retail unpack_movement @0052458e writes it onto
// minterp->standing_longjump unconditionally).
(motionFlags & 0x2) != 0),
instanceSequence, movementSequence, serverControlSequence, isAutonomous);
}
catch
{
@ -326,6 +408,73 @@ public static class UpdateMotion
minDistance,
failDistance,
walkRunThreshold,
desiredHeading,
movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire
return true;
}
/// <summary>
/// R4-V3 (closes M7) — parses movementType 8 (TurnToObject) / 9
/// (TurnToHeading), byte-for-byte per V0-pins.md P6 (built + confirmed
/// against ACE's own writers: <c>TurnToObjectExtensions.Write</c>,
/// <c>TurnToParametersExtensions.Write</c>,
/// <c>TurnToHeadingExtensions.Write</c> —
/// <c>references/ACE/Source/ACE.Server/Network/Motion/</c>):
/// <list type="bullet">
/// <item>type 8 (TurnToObject) only: u32 <c>object guid</c>, f32
/// <c>wire_heading</c> (the STANDALONE heading field — ACE
/// <c>TurnToObject.DesiredHeading</c>, "used instead of the
/// DesiredHeading in the TurnToParameters" per its own field
/// comment).</item>
/// <item>TurnToParameters / <c>MovementParameters::UnPackNet</c>
/// 3-dword TurnTo form (0xc bytes, decomp §2g), present for BOTH
/// types: u32 <c>bitfield</c>, f32 <c>speed</c>, f32
/// <c>desired_heading</c>.</item>
/// </list>
/// Matches retail's read order exactly (decomp §2f case 8:
/// <c>object_id = read_dword(); wire_heading = read_dword();
/// UnPackNet(...)</c>). The unresolvable-object fallback (substitute
/// <c>wire_heading</c> into <c>params.desired_heading</c>, degrade to
/// TurnToHeading) is a CONSUMER decision (needs a live object-id
/// resolution the wire parser has no visibility into) — this method
/// only exposes both heading fields distinctly so that fallback can be
/// implemented at the call site (V4/V5, GameWindow routing per
/// r4-port-plan.md §4).
/// </summary>
private static bool TryParseTurnToPayload(
ReadOnlySpan<byte> body,
int pos,
byte movementType,
out CreateObject.TurnToPathData? path)
{
path = null;
uint? targetGuid = null;
float? wireHeading = null;
if (movementType == 8)
{
if (body.Length - pos < 8) return false;
targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
wireHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
}
// TurnToParameters / UnPackNet 3-dword TurnTo form (0xc bytes):
// bitfield, speed, desired_heading.
if (body.Length - pos < 12) return false;
uint bitfield = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
pos += 4;
float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
path = new CreateObject.TurnToPathData(
targetGuid,
wireHeading,
bitfield,
speed,
desiredHeading);
return true;
}

View file

@ -110,7 +110,13 @@ public sealed class WorldSession : IDisposable
uint? Priority = null,
int? Structure = null,
int? MaxStructure = null,
float? Workmanship = null);
float? Workmanship = null,
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
// per-entity MotionSequenceGate (retail update_times INSTANCE_TS /
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0);
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
public event Action<EntitySpawn>? EntitySpawned;
@ -132,7 +138,11 @@ public sealed class WorldSession : IDisposable
/// </summary>
public readonly record struct EntityMotionUpdate(
uint Guid,
CreateObject.ServerMotionState MotionState);
CreateObject.ServerMotionState MotionState,
ushort InstanceSequence,
ushort MovementSequence,
ushort ServerControlSequence,
bool IsAutonomous);
/// <summary>
/// Fires when the session parses a 0xF74C UpdateMotion game message.
@ -827,7 +837,10 @@ public sealed class WorldSession : IDisposable
parsed.Value.Priority,
parsed.Value.Structure,
parsed.Value.MaxStructure,
parsed.Value.Workmanship));
parsed.Value.Workmanship,
InstanceSequence: parsed.Value.InstanceSequence,
MovementSequence: parsed.Value.MovementSequence,
ServerControlSequence: parsed.Value.ServerControlSequence));
}
}
else if (op == DeleteObject.Opcode)
@ -864,7 +877,11 @@ public sealed class WorldSession : IDisposable
{
MotionUpdated?.Invoke(new EntityMotionUpdate(
motion.Value.Guid,
motion.Value.MotionState));
motion.Value.MotionState,
motion.Value.InstanceSequence,
motion.Value.MovementSequence,
motion.Value.ServerControlSequence,
motion.Value.IsAutonomous));
}
}
else if (op == UpdatePosition.Opcode)

View file

@ -1,4 +1,5 @@
using AcDream.Core.Physics;
using Drw = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Combat;
@ -200,109 +201,146 @@ public enum CombatAnimationKind
Death,
}
/// <summary>
/// The full 32-bit retail MotionCommand values the planner classifies, each
/// derived DIRECTLY from <see cref="Drw"/> (Chorizite.DatReaderWriter — the
/// same enum ACE broadcasts and that the local DAT MotionTables key on) so
/// the values can never drift from the wire again.
///
/// <para>
/// History (ISSUES #159): these were previously hand-transcribed from the
/// Sept 2013 EoR decomp's <c>command_ids</c> table, which numbers the whole
/// late-combat block (<c>Offhand*</c> / <c>Attack4-6</c> / <c>Punch*</c>)
/// THREE lower than ACE/DRW — a contiguous low-word "+3" shift that begins
/// around <c>SnowAngelState</c> (0x115). Against a live ACE server those
/// commands silently misclassified: the resolver returned the correct ACE
/// value (e.g. wire 0x0173 -&gt; <c>0x10000173</c> <c>OffhandSlashHigh</c>),
/// but the planner's constant held the 2013 value (<c>0x10000170</c>), so no
/// match. <c>Reload</c> was worse — hardcoded <c>0x100000D4</c>, a value
/// absent from DRW entirely; the real ACE/DRW <c>Reload</c> is the
/// <c>0x40000016</c> SubState. Deriving from the enum by name fixes the whole
/// block at once and drops ~80 magic numbers.
/// See <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c>.
/// </para>
/// </summary>
internal static class CombatAnimationMotionCommands
{
public const uint HandCombat = 0x8000003Cu;
public const uint SwordCombat = 0x8000003Eu;
public const uint BowCombat = 0x8000003Fu;
public const uint SwordShieldCombat = 0x80000040u;
public const uint CrossbowCombat = 0x80000041u;
public const uint SlingCombat = 0x80000043u;
public const uint TwoHandedSwordCombat = 0x80000044u;
public const uint TwoHandedStaffCombat = 0x80000045u;
public const uint DualWieldCombat = 0x80000046u;
public const uint ThrownWeaponCombat = 0x80000047u;
public const uint Magic = 0x80000049u;
public const uint AtlatlCombat = 0x8000013Bu;
public const uint ThrownShieldCombat = 0x8000013Cu;
// Combat stances (Style class 0x80). Already correct before #159.
public const uint HandCombat = (uint)Drw.HandCombat;
public const uint SwordCombat = (uint)Drw.SwordCombat;
public const uint BowCombat = (uint)Drw.BowCombat;
public const uint SwordShieldCombat = (uint)Drw.SwordShieldCombat;
public const uint CrossbowCombat = (uint)Drw.CrossbowCombat;
public const uint SlingCombat = (uint)Drw.SlingCombat;
public const uint TwoHandedSwordCombat = (uint)Drw.TwoHandedSwordCombat;
public const uint TwoHandedStaffCombat = (uint)Drw.TwoHandedStaffCombat;
public const uint DualWieldCombat = (uint)Drw.DualWieldCombat;
public const uint ThrownWeaponCombat = (uint)Drw.ThrownWeaponCombat;
public const uint Magic = (uint)Drw.Magic;
public const uint AtlatlCombat = (uint)Drw.AtlatlCombat;
public const uint ThrownShieldCombat = (uint)Drw.ThrownShieldCombat;
public const uint FallDown = 0x10000050u;
public const uint Twitch1 = 0x10000051u;
public const uint Twitch2 = 0x10000052u;
public const uint Twitch3 = 0x10000053u;
public const uint Twitch4 = 0x10000054u;
public const uint StaggerBackward = 0x10000055u;
public const uint StaggerForward = 0x10000056u;
public const uint Sanctuary = 0x10000057u;
public const uint ThrustMed = 0x10000058u;
public const uint ThrustLow = 0x10000059u;
public const uint ThrustHigh = 0x1000005Au;
public const uint SlashHigh = 0x1000005Bu;
public const uint SlashMed = 0x1000005Cu;
public const uint SlashLow = 0x1000005Du;
public const uint BackhandHigh = 0x1000005Eu;
public const uint BackhandMed = 0x1000005Fu;
public const uint BackhandLow = 0x10000060u;
public const uint Shoot = 0x10000061u;
public const uint AttackHigh1 = 0x10000062u;
public const uint AttackMed1 = 0x10000063u;
public const uint AttackLow1 = 0x10000064u;
public const uint AttackHigh2 = 0x10000065u;
public const uint AttackMed2 = 0x10000066u;
public const uint AttackLow2 = 0x10000067u;
public const uint AttackHigh3 = 0x10000068u;
public const uint AttackMed3 = 0x10000069u;
public const uint AttackLow3 = 0x1000006Au;
// Hit reactions / staggers (Action class 0x10, unshifted).
public const uint FallDown = (uint)Drw.FallDown;
public const uint Twitch1 = (uint)Drw.Twitch1;
public const uint Twitch2 = (uint)Drw.Twitch2;
public const uint Twitch3 = (uint)Drw.Twitch3;
public const uint Twitch4 = (uint)Drw.Twitch4;
public const uint StaggerBackward = (uint)Drw.StaggerBackward;
public const uint StaggerForward = (uint)Drw.StaggerForward;
public const uint Sanctuary = (uint)Drw.Sanctuary;
public const uint MissileAttack1 = 0x100000D0u;
public const uint MissileAttack2 = 0x100000D1u;
public const uint MissileAttack3 = 0x100000D2u;
public const uint CastSpell = 0x400000D3u;
public const uint Reload = 0x100000D4u;
public const uint UseMagicStaff = 0x400000E0u;
public const uint UseMagicWand = 0x400000E1u;
// Single melee attacks (Action class 0x10, unshifted).
public const uint ThrustMed = (uint)Drw.ThrustMed;
public const uint ThrustLow = (uint)Drw.ThrustLow;
public const uint ThrustHigh = (uint)Drw.ThrustHigh;
public const uint SlashHigh = (uint)Drw.SlashHigh;
public const uint SlashMed = (uint)Drw.SlashMed;
public const uint SlashLow = (uint)Drw.SlashLow;
public const uint BackhandHigh = (uint)Drw.BackhandHigh;
public const uint BackhandMed = (uint)Drw.BackhandMed;
public const uint BackhandLow = (uint)Drw.BackhandLow;
public const uint DoubleSlashLow = 0x1000011Fu;
public const uint DoubleSlashMed = 0x10000120u;
public const uint DoubleSlashHigh = 0x10000121u;
public const uint TripleSlashLow = 0x10000122u;
public const uint TripleSlashMed = 0x10000123u;
public const uint TripleSlashHigh = 0x10000124u;
public const uint DoubleThrustLow = 0x10000125u;
public const uint DoubleThrustMed = 0x10000126u;
public const uint DoubleThrustHigh = 0x10000127u;
public const uint TripleThrustLow = 0x10000128u;
public const uint TripleThrustMed = 0x10000129u;
public const uint TripleThrustHigh = 0x1000012Au;
// Missile attacks (Action 0x10) + reload (SubState 0x40, NOT the dead
// 2013 0x100000D4 — the real DRW Reload is 0x40000016).
public const uint Shoot = (uint)Drw.Shoot;
public const uint MissileAttack1 = (uint)Drw.MissileAttack1;
public const uint MissileAttack2 = (uint)Drw.MissileAttack2;
public const uint MissileAttack3 = (uint)Drw.MissileAttack3;
public const uint Reload = (uint)Drw.Reload;
public const uint OffhandSlashHigh = 0x10000170u;
public const uint OffhandSlashMed = 0x10000171u;
public const uint OffhandSlashLow = 0x10000172u;
public const uint OffhandThrustHigh = 0x10000173u;
public const uint OffhandThrustMed = 0x10000174u;
public const uint OffhandThrustLow = 0x10000175u;
public const uint OffhandDoubleSlashLow = 0x10000176u;
public const uint OffhandDoubleSlashMed = 0x10000177u;
public const uint OffhandDoubleSlashHigh = 0x10000178u;
public const uint OffhandTripleSlashLow = 0x10000179u;
public const uint OffhandTripleSlashMed = 0x1000017Au;
public const uint OffhandTripleSlashHigh = 0x1000017Bu;
public const uint OffhandDoubleThrustLow = 0x1000017Cu;
public const uint OffhandDoubleThrustMed = 0x1000017Du;
public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
public const uint OffhandTripleThrustLow = 0x1000017Fu;
public const uint OffhandTripleThrustMed = 0x10000180u;
public const uint OffhandTripleThrustHigh = 0x10000181u;
public const uint OffhandKick = 0x10000182u;
public const uint AttackHigh4 = 0x10000183u;
public const uint AttackMed4 = 0x10000184u;
public const uint AttackLow4 = 0x10000185u;
public const uint AttackHigh5 = 0x10000186u;
public const uint AttackMed5 = 0x10000187u;
public const uint AttackLow5 = 0x10000188u;
public const uint AttackHigh6 = 0x10000189u;
public const uint AttackMed6 = 0x1000018Au;
public const uint AttackLow6 = 0x1000018Bu;
public const uint PunchFastHigh = 0x1000018Cu;
public const uint PunchFastMed = 0x1000018Du;
public const uint PunchFastLow = 0x1000018Eu;
public const uint PunchSlowHigh = 0x1000018Fu;
public const uint PunchSlowMed = 0x10000190u;
public const uint PunchSlowLow = 0x10000191u;
public const uint OffhandPunchFastHigh = 0x10000192u;
public const uint OffhandPunchFastMed = 0x10000193u;
public const uint OffhandPunchFastLow = 0x10000194u;
public const uint OffhandPunchSlowHigh = 0x10000195u;
public const uint OffhandPunchSlowMed = 0x10000196u;
public const uint OffhandPunchSlowLow = 0x10000197u;
// Creature attacks 1-6 (Action class 0x10). 1-3 are the unshifted low
// block (0x62-0x6A); 4-6 live in the shifted late block (0x186+).
public const uint AttackHigh1 = (uint)Drw.AttackHigh1;
public const uint AttackMed1 = (uint)Drw.AttackMed1;
public const uint AttackLow1 = (uint)Drw.AttackLow1;
public const uint AttackHigh2 = (uint)Drw.AttackHigh2;
public const uint AttackMed2 = (uint)Drw.AttackMed2;
public const uint AttackLow2 = (uint)Drw.AttackLow2;
public const uint AttackHigh3 = (uint)Drw.AttackHigh3;
public const uint AttackMed3 = (uint)Drw.AttackMed3;
public const uint AttackLow3 = (uint)Drw.AttackLow3;
public const uint AttackHigh4 = (uint)Drw.AttackHigh4;
public const uint AttackMed4 = (uint)Drw.AttackMed4;
public const uint AttackLow4 = (uint)Drw.AttackLow4;
public const uint AttackHigh5 = (uint)Drw.AttackHigh5;
public const uint AttackMed5 = (uint)Drw.AttackMed5;
public const uint AttackLow5 = (uint)Drw.AttackLow5;
public const uint AttackHigh6 = (uint)Drw.AttackHigh6;
public const uint AttackMed6 = (uint)Drw.AttackMed6;
public const uint AttackLow6 = (uint)Drw.AttackLow6;
// Spell casts (SubState class 0x40).
public const uint CastSpell = (uint)Drw.CastSpell;
public const uint UseMagicStaff = (uint)Drw.UseMagicStaff;
public const uint UseMagicWand = (uint)Drw.UseMagicWand;
// Multi-strike melee (Action class 0x10, unshifted 0x11F-0x12A).
public const uint DoubleSlashLow = (uint)Drw.DoubleSlashLow;
public const uint DoubleSlashMed = (uint)Drw.DoubleSlashMed;
public const uint DoubleSlashHigh = (uint)Drw.DoubleSlashHigh;
public const uint TripleSlashLow = (uint)Drw.TripleSlashLow;
public const uint TripleSlashMed = (uint)Drw.TripleSlashMed;
public const uint TripleSlashHigh = (uint)Drw.TripleSlashHigh;
public const uint DoubleThrustLow = (uint)Drw.DoubleThrustLow;
public const uint DoubleThrustMed = (uint)Drw.DoubleThrustMed;
public const uint DoubleThrustHigh = (uint)Drw.DoubleThrustHigh;
public const uint TripleThrustLow = (uint)Drw.TripleThrustLow;
public const uint TripleThrustMed = (uint)Drw.TripleThrustMed;
public const uint TripleThrustHigh = (uint)Drw.TripleThrustHigh;
// Offhand strikes (Action class 0x10, shifted late block 0x173+).
public const uint OffhandSlashHigh = (uint)Drw.OffhandSlashHigh;
public const uint OffhandSlashMed = (uint)Drw.OffhandSlashMed;
public const uint OffhandSlashLow = (uint)Drw.OffhandSlashLow;
public const uint OffhandThrustHigh = (uint)Drw.OffhandThrustHigh;
public const uint OffhandThrustMed = (uint)Drw.OffhandThrustMed;
public const uint OffhandThrustLow = (uint)Drw.OffhandThrustLow;
public const uint OffhandDoubleSlashLow = (uint)Drw.OffhandDoubleSlashLow;
public const uint OffhandDoubleSlashMed = (uint)Drw.OffhandDoubleSlashMed;
public const uint OffhandDoubleSlashHigh = (uint)Drw.OffhandDoubleSlashHigh;
public const uint OffhandTripleSlashLow = (uint)Drw.OffhandTripleSlashLow;
public const uint OffhandTripleSlashMed = (uint)Drw.OffhandTripleSlashMed;
public const uint OffhandTripleSlashHigh = (uint)Drw.OffhandTripleSlashHigh;
public const uint OffhandDoubleThrustLow = (uint)Drw.OffhandDoubleThrustLow;
public const uint OffhandDoubleThrustMed = (uint)Drw.OffhandDoubleThrustMed;
public const uint OffhandDoubleThrustHigh = (uint)Drw.OffhandDoubleThrustHigh;
public const uint OffhandTripleThrustLow = (uint)Drw.OffhandTripleThrustLow;
public const uint OffhandTripleThrustMed = (uint)Drw.OffhandTripleThrustMed;
public const uint OffhandTripleThrustHigh = (uint)Drw.OffhandTripleThrustHigh;
public const uint OffhandKick = (uint)Drw.OffhandKick;
// Punches (Action class 0x10, shifted late block).
public const uint PunchFastHigh = (uint)Drw.PunchFastHigh;
public const uint PunchFastMed = (uint)Drw.PunchFastMed;
public const uint PunchFastLow = (uint)Drw.PunchFastLow;
public const uint PunchSlowHigh = (uint)Drw.PunchSlowHigh;
public const uint PunchSlowMed = (uint)Drw.PunchSlowMed;
public const uint PunchSlowLow = (uint)Drw.PunchSlowLow;
public const uint OffhandPunchFastHigh = (uint)Drw.OffhandPunchFastHigh;
public const uint OffhandPunchFastMed = (uint)Drw.OffhandPunchFastMed;
public const uint OffhandPunchFastLow = (uint)Drw.OffhandPunchFastLow;
public const uint OffhandPunchSlowHigh = (uint)Drw.OffhandPunchSlowHigh;
public const uint OffhandPunchSlowMed = (uint)Drw.OffhandPunchSlowMed;
public const uint OffhandPunchSlowLow = (uint)Drw.OffhandPunchSlowLow;
}

View file

@ -37,7 +37,8 @@ public static class LightInfoLoader
uint ownerId,
Vector3 entityPosition,
Quaternion entityRotation,
bool isDynamic = false)
bool isDynamic = false,
uint cellId = 0)
{
var results = new List<LightSource>();
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
@ -89,6 +90,7 @@ public static class LightInfoLoader
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
ConeAngle = info.ConeAngle,
OwnerId = ownerId,
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
IsLit = true,
IsDynamic = isDynamic,
};

View file

@ -176,8 +176,28 @@ public sealed class LightManager
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
/// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
/// registered lights (chase-boom swing churned the eviction boundary), then
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
/// purples entered/left the pool as the camera turned — the seam-floor
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
/// (=resident) lit lights optionally FILTERED by last frame's rendered
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s
/// <c>visibleCells</c> param; fixes Town Network starvation without
/// reproducing c500912b — see that method's doc), then dynamics-first nearest-
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop
/// that in a properly cell-scoped room only ever evicts far-out-of-range
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
@ -190,28 +210,100 @@ public sealed class LightManager
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
// Pool-sort state for BuildPointLightSnapshot: the comparison delegate is
// cached (allocated once) and reads the anchor from a field so the per-frame
// over-cap sort allocates nothing beyond List.Sort's own wrapper — the same
// profile as the previous static-lambda sort (MP-Alloc discipline).
private Vector3 _poolAnchor;
private Comparison<LightSource>? _poolComparison;
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
/// path; unlit torches contribute nothing). When more than
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
/// most relevant lights survive the cap. Call once per frame before
/// per-object selection.
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
/// The sun and unlit lights are excluded (the sun is global ambient-path;
/// unlit torches contribute nothing).
/// <para>
/// Retail anchors (#176 corrected reading, 2026-07-06):
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
/// entries leave only via the flush machinery). It is NOT the per-frame portal
/// flood: camera gaze cannot remove a cell from it. acdream's <c>_all</c>
/// (register at hydration, unregister at unload) is that resident set, so the
/// collection is simply every registered lit light. The under-room portal
/// purples reaching the corridor's pool is retail-correct (cdb: retail applies
/// them to every Hub cell) — the faceted purple wedge is faithful.
/// </para>
/// <para>
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
/// first (retail's dynamic lights live in their own 7-slot pool —
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
/// set from <c>player-&gt;m_position</c>, SmartBox 0x00453d3a, with the
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
/// of PLAYER position and light registration ONLY — camera rotation/position
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap;
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor
/// purple blink by making the SORT itself camera-dependent). The optional
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change
/// this: it narrows the input set before the player-anchored sort runs, using
/// a value the caller captured from last frame's already-rendered draw list,
/// not a fresh camera-seeded computation performed here. Call once per frame
/// before per-object selection.
/// </para>
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
/// <param name="playerWorldPos">The player's world position (render position;
/// callers pass the camera position only when no player exists — retail's
/// player/viewer branch).</param>
/// <param name="visibleCells">
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
/// always in scope) or its <c>CellId</c> is in this set. Fixes the Town Network
/// starvation case (463 registered fixtures): the player-nearest cap sorts by
/// raw Euclidean distance, which is not a reliable proxy for "same room" in a
/// dense, maze-like hub — a fixture on the other side of a wall can be
/// geometrically closer than the player's own room's torches and win the cap,
/// leaving the visible room dark. Scoping candidacy to the frame's actual
/// visible cells (the render already computes this — callers pass last frame's
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
/// re-threading a mid-render callback) removes those from contention before the
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
/// parameter only narrows candidacy, it does not change the sort (the #176
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
/// seam-floor flicker regression, c500912b). Null (the default) preserves the
/// legacy unscoped behavior — outdoor / no-clipRoot callers pass null.
/// </param>
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue;
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)
{
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_poolAnchor = playerWorldPos;
_poolComparison ??= (a, b) =>
{
// Dynamics-first mirrors retail's separate dynamic pool; ties by
// player distance mirror insert_light's player-nearest sort.
if (a.IsDynamic != b.IsDynamic) return a.IsDynamic ? -1 : 1;
float da = (a.WorldPosition - _poolAnchor).LengthSquared();
float db = (b.WorldPosition - _poolAnchor).LengthSquared();
return da.CompareTo(db);
};
_pointSnapshot.Sort(_poolComparison);
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
}
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
// the flag check keeps it zero-cost off.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
}
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
@ -333,4 +425,75 @@ public sealed class LightManager
}
return count;
}
/// <summary>
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
/// (retail minimize_object_lighting).
/// </summary>
public static int SelectForCell(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
int count = 0;
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
// every cell — stable regardless of the cell's relation to each light).
for (int li = 0; li < snapshot.Count && count < cap; li++)
if (snapshot[li].IsDynamic)
outIndices[count++] = li;
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
int staticStart = count;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
if (light.IsDynamic) continue; // dynamics already added
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue;
if (count < cap)
{
int j = count;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > staticStart && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
}

View file

@ -46,6 +46,12 @@ public sealed class LightSource
public float Range = 10f; // metres, hard cutoff
public float ConeAngle = 0f; // radians, Spot only
public uint OwnerId; // attached entity id; 0 = world-global
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) for the
// cross-cell block-offset distance math. #176 correction (2026-07-06): it is
// NOT a pool filter — retail collects from ALL resident cells
// (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood);
// acdream keeps the tag for probes ([indoor-light]/[seam-*]) + future parity.
public bool IsLit = true; // SetLightHook latch
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
// false = static dat-baked bake (1/d³, range×1.3)

View file

@ -0,0 +1,33 @@
namespace AcDream.Core.Meshing;
/// <summary>
/// Should a hydrated stab/entity survive when its visual mesh flattens to
/// zero drawable parts?
///
/// <para>
/// <b>Why this exists (#79/#93, 2026-07-09).</b> A dat-authored "light attach
/// point" is a Setup whose sole purpose is to carry a <c>Setup.Lights</c>
/// entry — its own visual part is commonly a #136-class runtime-hidden
/// marker (degrades to nothing at any real distance, matching retail's
/// editor-only placement markers). Retail's light registration
/// (<c>CObjCell::add_light</c>, populated at <c>CEnvCell::UnPack</c>) is
/// entirely independent of a fixture's own mesh visibility — a mesh-less
/// carrier still lights the room. The Town Network fountain room's only
/// light (Setup 0x02000365, a ceiling fixture 5 m above the fountain) never
/// registered because the mesh-empty gate treated "nothing to draw" as
/// "nothing exists," dropping the entity — and its Lights — before the
/// light-registration pass ever saw it.
/// </para>
/// </summary>
public static class EntityHydrationRules
{
/// <summary>
/// True when the entity should still be added to the landblock's entity
/// set even with zero mesh refs, because it has dat-authored lights to
/// register. An entity with any mesh is always kept (unchanged from the
/// pre-existing gate); the entity is dropped only when it has neither
/// geometry to draw nor lights to register.
/// </summary>
public static bool ShouldKeepEntity(int meshRefCount, int setupLightCount)
=> meshRefCount > 0 || setupLightCount > 0;
}

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Physics;
/// <summary>
/// Runtime-default <see cref="IMotionCommandCatalog"/>. Built from the
/// <see cref="DRWMotionCommand"/> enum (generated from the protocol XML;
/// mirrors ACE's <c>MotionCommand</c> enum and matches the values actually
/// found in the local DAT <c>MotionTable</c> records). Use this catalog
/// while talking to ACE — it is what
/// <see cref="MotionCommandResolver.ReconstructFullCommand"/> delegates to.
///
/// <para>
/// Reconstruction is a flat <c>wireLow16 -&gt; full32</c> lookup built once
/// at construction. When more than one enum value shares the same low 16
/// bits (a true class collision — class byte differs, low word doesn't),
/// the LOWER class byte wins: Action (0x10) &lt; ChatEmote (0x12/0x13)
/// &lt; Modifier (0x20) &lt; SubState (0x41/...) &lt; ... &lt; Style (0x80).
/// See <see cref="ResolveClassPriority"/>.
/// </para>
///
/// <para>
/// <b>No magic per-range override.</b> The original
/// <c>MotionCommandResolver.ApplyNamedRetailOverrides</c> force-mapped
/// wire 0x016E-0x0197 to <c>0x10000000 | lo</c> on the theory that the
/// generated DRW enum was "shifted." Verified false: building the lookup
/// straight from <see cref="DRWMotionCommand"/> (Chorizite.DatReaderWriter
/// 2.1.7, 409 distinct values, zero same-low16 collisions) already resolves
/// <c>LifestoneRecall</c> (0x0153), <c>MarketplaceRecall</c> (0x0166),
/// <c>AllegianceHometownRecall</c> (0x0171), and <c>OffhandSlashHigh</c>
/// (0x0173) to their correct Action-class full values with no override.
/// The override loop is deleted in this slice (it was masking nothing; the
/// enum was already correct). See
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c>.
/// </para>
/// </summary>
public sealed class AceModernCommandCatalog : IMotionCommandCatalog
{
private readonly Dictionary<ushort, uint> _lookup;
public AceModernCommandCatalog()
{
_lookup = BuildLookup();
}
public uint ReconstructFullCommand(ushort wireCommand)
{
if (wireCommand == 0) return 0u;
_lookup.TryGetValue(wireCommand, out var full);
return full;
}
private static Dictionary<ushort, uint> BuildLookup()
{
var byLow = new Dictionary<ushort, List<uint>>(512);
foreach (DRWMotionCommand v in Enum.GetValues(typeof(DRWMotionCommand)))
{
uint full = (uint)v;
ushort lo = (ushort)(full & 0xFFFFu);
if (lo == 0) continue; // Invalid / unmappable
if (!byLow.TryGetValue(lo, out var list))
byLow[lo] = list = new List<uint>(1);
if (!list.Contains(full))
list.Add(full);
}
var result = new Dictionary<ushort, uint>(byLow.Count);
foreach (var (lo, candidates) in byLow)
{
result[lo] = candidates.Count == 1
? candidates[0]
: ResolveClassPriority(candidates);
}
return result;
}
/// <summary>
/// Given a set of full 32-bit MotionCommand values that all share the
/// same low 16 bits, return the one whose class byte (bits 24-31) is
/// numerically lowest — retail's class priority (Action 0x10 beats
/// Modifier 0x20 beats SubState 0x41 beats Style 0x80, etc). Exposed
/// as a static so it can be exercised directly by tests even when the
/// live enum has no real collisions to exercise it through
/// <see cref="BuildLookup"/>.
/// </summary>
public static uint ResolveClassPriority(IReadOnlyList<uint> candidates)
{
uint best = candidates[0];
for (int i = 1; i < candidates.Count; i++)
{
uint candidate = candidates[i];
if ((candidate >> 24) < (best >> 24))
best = candidate;
}
return best;
}
}

File diff suppressed because it is too large Load diff

View file

@ -573,15 +573,25 @@ public static class BSPQuery
// -------------------------------------------------------------------------
/// <summary>
/// Polygon.adjust_sphere_to_poly — compute parametric contact time.
/// CPolygon::adjust_sphere_to_poly (0x00538170, pc:321999) — the parametric
/// time along <paramref name="movement"/> at which the sphere SURFACE
/// touches the polygon's plane, approaching from the side the start
/// position is on.
///
/// <para>
/// Returns 1.0 if the sphere currently intersects the polygon (needs further
/// back-off), or the parametric time [0,1] of first contact along the movement
/// vector. Used by adjust_to_plane binary-search loop.
/// Returns 1.0 when the start center is already within one radius of the
/// plane (no forward touch time computable — 0x005381a4), 0.0 when the
/// movement is parallel to the plane (|dot| ≤ 2e-4 — 0x005381d2), else
/// <c>(±radius dpPos) / dpMove</c> with the sign chosen by the start's
/// plane side (0x005381ea). UNCLAMPED, matching retail.
/// </para>
///
/// <para>ACE: Polygon.cs adjust_sphere_to_poly.</para>
/// <para>
/// ⚠️ Do NOT re-import ACE's Polygon.cs version — its early-out and its
/// ±radius selection (`movement.LengthSquared() &lt;= r²`) are misdecodes
/// of the retail x87 flow; see
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md (#180).
/// </para>
/// </summary>
private static float AdjustSphereToPoly(
ResolvedPolygon poly,
@ -589,19 +599,14 @@ public static class BSPQuery
Vector3 curPos,
Vector3 movement)
{
Vector3 cp = Vector3.Zero;
if (PolygonHitsSpherePrecise(
poly.Plane, poly.Vertices,
checkPos.Center, checkPos.Radius,
ref cp))
return 1f;
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
if (MathF.Abs(dpPos) < checkPos.Radius) return 1f;
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
float dpMove = Vector3.Dot(movement, poly.Plane.Normal);
if (MathF.Abs(dpMove) < PhysicsGlobals.EPSILON) return 0f;
if (MathF.Abs(dpMove) <= PhysicsGlobals.EPSILON) return 0f;
float t = (-checkPos.Radius - dpPos) / dpMove;
return Math.Clamp(t, 0f, 1f);
float r = dpPos < 0f ? -checkPos.Radius : checkPos.Radius;
return (r - dpPos) / dpMove;
}
// =========================================================================
@ -1104,15 +1109,32 @@ public static class BSPQuery
// -------------------------------------------------------------------------
/// <summary>
/// BSPTree.adjust_to_plane — binary-search for non-penetrating sphere position.
/// BSPTREE::adjust_to_plane (0x00539bf0, pc:323440) — find the closest
/// non-penetrating position along <c>[curPos → checkPos]</c> and commit it
/// into <paramref name="checkPos"/>.
///
/// <para>
/// Runs up to 15 forward iterations until touching, then up to 15 binary-search
/// iterations to narrow the touch point. Modifies checkPos.Center in place.
/// Returns false if convergence fails.
/// Two known-time bounds: <c>clearTime</c> (retail var_50, init 0.0 — the
/// start is clear) and <c>hitTime</c> (retail var_48, init 1.0 — the check
/// position hit). Phase 1 walks plane-touch times from
/// <see cref="AdjustSphereToPoly"/>, re-testing the whole tree at each —
/// the tree test can surface a DIFFERENT blocking polygon, which feeds the
/// next iteration (retail passes <c>&amp;hitPoly</c> through). Phase 2
/// binary-searches the bounds with the SAME iteration counter; window
/// &lt; 0.02 is CONVERGED, and the final center commits at the last
/// known-clear time (0x00539de1). The only failure exit is Phase-1
/// exhaustion (15 iterations without a clear touch — 0x00539ce9).
/// </para>
///
/// <para>ACE: BSPTree.cs adjust_to_plane.</para>
/// <para>
/// ⚠️ Do NOT re-import ACE's BSPTree.cs version — its Phase-1 branch is
/// inverted and its convergence exit returns false, making the function
/// always-fail (dead on the server; PERFECT_CLIP is a client camera flag).
/// That dead shape is what quantized PathClipped camera stops to whole
/// transition steps: the #180 sawtooth AND the original #176 strobe
/// (pulledIn 0.27 ↔ 0.53 = 1-step vs 2-step backoff). Pseudocode + decode:
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md.
/// </para>
/// </summary>
private static bool AdjustToPlane(
PhysicsBSPNode root,
@ -1124,53 +1146,62 @@ public static class BSPQuery
{
var movement = checkPos.Center - curPos;
double lowerTime = 0.0;
double upperTime = 1.0;
double clearTime = 0.0; // retail var_50 — known-clear
double hitTime = 1.0; // retail var_48 — known-hit
int i = 0;
const int MaxIter = 15;
// Phase 1: step forward until non-intersecting.
for (int i = 0; i < MaxIter; i++)
// Phase 1 (0x00539c4d): walk plane-touch times.
while (true)
{
float touchTime = AdjustSphereToPoly(hitPoly, checkPos, curPos, movement);
if (touchTime == 1f)
{
checkPos.Center = curPos + movement * (float)touchTime;
// Start already within one radius of the plane — no projectable touch
// time; fall through to the pure [0,1] binary search (0x00539c61).
if (touchTime == 1f) break;
ResolvedPolygon? hp2 = null;
Vector3 cp2 = Vector3.Zero;
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
ref hp2, ref cp2))
{
lowerTime = touchTime;
break;
}
upperTime = touchTime;
}
if (i == MaxIter - 1) return false;
}
// Phase 2: binary-search.
for (int j = 0; j < MaxIter; j++)
{
double average = (lowerTime + upperTime) * 0.5;
checkPos.Center = curPos + movement * (float)average;
checkPos.Center = curPos + movement * touchTime;
ResolvedPolygon? hp2 = null;
Vector3 cp2 = Vector3.Zero;
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
ref hp2, ref cp2))
upperTime = (lowerTime + upperTime) * 0.5;
else
lowerTime = (lowerTime + upperTime) * 0.5;
{
clearTime = touchTime; // touch position clear → refine toward the hit
break;
}
if (upperTime - lowerTime < 0.02)
return false;
// Still blocked — possibly by a different polygon; the next plane
// adjustment targets it (retail's &hitPoly write-back, 0x00539cd3).
if (hp2 is not null) hitPoly = hp2;
i++;
hitTime = touchTime;
if (i >= MaxIter) return false; // the only failure exit (0x00539ce9)
}
// Phase 2 (0x00539ddb): binary search; the counter CONTINUES from Phase 1.
while (i < MaxIter)
{
double avg = (clearTime + hitTime) * 0.5;
checkPos.Center = curPos + movement * (float)avg;
ResolvedPolygon? hp2 = null;
Vector3 cp2 = Vector3.Zero;
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
ref hp2, ref cp2))
clearTime = avg;
else
hitTime = avg;
if (hitTime - clearTime < 0.02) break; // converged (0x00539dca)
i++;
}
// Commit the last known-clear position (0x00539de1). Phase 2 always
// succeeds, converged or not — retail has no failure path here.
checkPos.Center = curPos + movement * (float)clearTime;
return true;
}
@ -1399,26 +1430,35 @@ public static class BSPQuery
// -------------------------------------------------------------------------
// slide_sphere — BSPTree level
// ACE: BSPTree.cs slide_sphere
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
// at 0x0053a697). ACE: BSPTree.cs:310-316.
// -------------------------------------------------------------------------
/// <summary>
/// BSPTree.slide_sphere — apply sliding collision response.
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
/// crease between the collision normal and the contact plane, applied to
/// sphere 0's check position (ACE BSPTree.cs:315 —
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
///
/// <para>
/// Sets the sliding normal on CollisionInfo so the outer transition loop
/// applies a wall-slide projection.
/// #137 mechanism 2 (2026-07-06): this was a stub that set
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
/// never writes the sliding normal — its only in-transition writer is
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
/// leaked normal survived to the body writeback and absorbed the next
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
/// phantom's dead-stop half (ISSUES #137).
/// </para>
///
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
/// </summary>
/// <param name="worldNormal">Collision normal already in world space (the
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
private static TransitionState SlideSphere(
Transition transition,
Vector3 collisionNormal)
{
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
return TransitionState.Slid;
}
Vector3 worldNormal)
=> transition.SlideSphereInternal(
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
// -------------------------------------------------------------------------
// collide_with_pt — BSPTree level
@ -1894,11 +1934,14 @@ public static class BSPQuery
if (engine is not null && !path.StepUp && !path.StepDown)
return StepSphereUp(transition, worldNormal, engine);
// No engine OR step-up/step-down already in progress — fall
// back to wall-slide.
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
// No engine OR step-up/step-down already in progress — the
// real slide response. Retail: a blocked step-up funnels to
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
// SpherePath.cs:316); the slide records the collision normal
// itself and never writes the sliding normal (#137 mechanism
// 2 — the old SetSlidingNormal stub here leaked a normal that
// wedged the next frame's forward offset).
return SlideSphere(transition, worldNormal);
}
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
@ -1937,9 +1980,10 @@ public static class BSPQuery
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
var worldNormal = L2W(hitPoly1!.Plane.Normal);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
// Retail head-sphere full hit → BSPTREE::slide_sphere
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
// slide, no sliding-normal write (#137 mechanism 2).
return SlideSphere(transition, worldNormal);
}
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.

View file

@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
/// (animation-hook frame = damage frame for melee / thrown).
/// </description></item>
/// <item><description>
/// <see cref="TransparentPartHook"/> → #188
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
/// </description></item>
/// <item><description>
/// <see cref="ReplaceObjectHook"/>,
/// <see cref="TransparentHook"/>,
/// <see cref="TransparentHook"/> (whole-object variant — NOT yet
/// ported, #188 scope was the per-part case only),
/// <see cref="LuminousHook"/>,
/// <see cref="DiffuseHook"/>,
/// <see cref="ScaleHook"/>,
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
/// <see cref="SetOmegaHook"/>,
/// <see cref="TextureVelocityHook"/>,
/// <see cref="SetLightHook"/> →
/// GfxObjMesh / renderer state mutations on the target entity.
/// GfxObjMesh / renderer state mutations on the target entity
/// (<see cref="SetLightHook"/> is the one exception already wired,
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
/// </description></item>
/// <item><description>
/// <see cref="AnimationDoneHook"/> → UI / controller notifications

View file

@ -0,0 +1,27 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Reconstructs a full 32-bit retail MotionCommand from the 16-bit wire
/// value broadcast in <c>InterpretedMotionState.Commands[]</c> (the server
/// truncates the class byte — see <see cref="MotionCommandResolver"/> for
/// why that byte must be restored before routing).
///
/// <para>
/// Two implementations exist because the wire-numbering used by ACE / the
/// local DATs and the wire-numbering used by the Sept 2013 EoR retail
/// decomp diverge for ~130 command names (a contiguous low-word "+3" shift
/// starting at <c>SnowAngelState</c>). See
/// <see cref="AceModernCommandCatalog"/> (runtime default) and
/// <see cref="Retail2013CommandCatalog"/> (conformance/reference), and
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> for the
/// full divergence analysis.
/// </para>
/// </summary>
public interface IMotionCommandCatalog
{
/// <summary>
/// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value.
/// Returns 0 if no entry matches.
/// </summary>
uint ReconstructFullCommand(ushort wireCommand);
}

View file

@ -0,0 +1,115 @@
using System.Collections.Generic;
namespace AcDream.Core.Physics;
/// <summary>
/// L.2g S2 — supporting types for the inbound CMotionInterp funnel
/// (deviation DEV-1). Spec:
/// docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md; decomp:
/// MovementManager::unpack_movement 0x00524440,
/// CMotionInterp::move_to_interpreted_state 0x005289c0,
/// apply_interpreted_movement 0x00528600, DoInterpretedMotion 0x00528360 —
/// dispatch order validated against a LIVE retail-observer cdb trace
/// (tools/cdb/l2g-observer.cdb).
/// </summary>
public interface IInterpretedMotionSink
{
/// <summary>
/// The <c>CPhysicsObj::DoInterpretedMotion → … →
/// CMotionTable::GetObjectSequence</c> backend — called for every motion
/// that passes <c>contact_allows_move</c>, in retail dispatch order
/// (style → forward-or-Falling → sidestep → turn → actions). R2-Q5: the
/// production implementation is <c>Motion.MotionTableDispatchSink</c>,
/// which dispatches straight into the entity's motion-table stack
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
/// no sink-side axis pick or fallback chain.
/// </summary>
/// <returns>
/// R3-W5: retail's own <c>CPhysicsObj::DoInterpretedMotion</c> RETURNS a
/// result (<c>result == 0</c> = the motion table found a cycle for this
/// id; nonzero = <c>MotionTableManagerError.MotionFailed</c>/no-table) —
/// <c>CMotionInterp::DoInterpretedMotion</c>'s caller (raw 305591-305610)
/// gates BOTH the <c>add_to_queue</c> call AND the
/// <c>InterpretedMotionState::ApplyMotion</c> state-write on that result.
/// This matters concretely for the very first dispatch of every
/// <c>apply_interpreted_movement</c> call — the STYLE/stance id
/// (<c>current_style</c>, always <c>&gt;= 0x80000000</c>) has no
/// locomotion <c>MotionData</c> entry in the dat, so
/// <c>CMotionTable::DoObjectMotion</c> genuinely fails for it; if that
/// failure isn't propagated, the state-write's negative-motion branch
/// (<see cref="InterpretedMotionState.ApplyMotion"/>: <c>arg2 &lt; 0 →
/// forward_command = 0x41000003</c>) clobbers <c>ForwardCommand</c>
/// BEFORE the very next line reads it for the real forward dispatch —
/// confirmed regression against the 183-case live retail-observer trace
/// (<c>RetailObserverTraceConformanceTests</c>) when this was <c>void</c>.
/// Return <c>true</c> when the underlying <c>PerformMovement</c> call
/// succeeded (<c>MotionTableManagerError.Success</c>).
/// </returns>
bool ApplyMotion(uint motion, float speed);
/// <summary>
/// <c>StopInterpretedMotion</c> notification for an axis the incoming
/// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional
/// (no contact gate) per apply_interpreted_movement.
/// </summary>
/// <returns>Same result-propagation rationale as <see cref="ApplyMotion"/>
/// — retail's <c>CPhysicsObj::StopInterpretedMotion</c> also returns a
/// result that gates the post-stop <c>add_to_queue</c> + state-removal.</returns>
bool StopMotion(uint motion);
/// <summary>
/// R4-V5 wedge fix — retail <c>CPhysicsObj::StopCompletely_Internal</c>
/// (0x0050ead0) → <c>CPartArray::StopCompletelyInternal</c> (0x00518890)
/// → <c>MotionTableManager::PerformMovement(MovementStruct{type=5})</c>:
/// the ANIMATION-side full stop dispatched from the middle of
/// <c>CMotionInterp::StopCompletely</c> (raw @00527e90). The manager
/// queues its pending_animations entry UNCONDITIONALLY for type 5, and
/// that entry's AnimationDone → MotionDone is the matched pop for the A9
/// pending_motions node <c>StopCompletely</c> enqueues right after this
/// call — without it the A9 node is an orphan and <c>MotionsPending</c>
/// never drains at idle (the 2026-07-03 moveto wedge). Default
/// implementation returns <c>true</c> (the null-sink "no animation
/// backend" posture — bare tests and sink-less interps are unaffected).
/// </summary>
bool StopCompletely() => true;
}
/// <summary>
/// One entry of the inbound action list (retail <c>MotionItem</c> /
/// <c>InterpretedMotionState.actions</c>): a one-shot command with the
/// 15-bit server action stamp + autonomy bit
/// (<c>u16 ((stamp &amp; 0x7FFF) | (autonomous ? 0x8000 : 0))</c>).
/// </summary>
public readonly record struct InboundMotionAction(
uint Command, int Stamp, bool Autonomous, float Speed);
/// <summary>
/// A fully-decoded inbound <c>InterpretedMotionState</c>: wire fields where
/// present, retail UnPack defaults where absent
/// (<c>InterpretedMotionState::UnPack</c> 0x0051f400 /
/// ctor 0x0051e8d0). A flags=0 "empty" UM decodes to exactly
/// <see cref="Default"/> — a retail-verbatim full stop.
/// </summary>
public struct InboundInterpretedState
{
public uint CurrentStyle;
public uint ForwardCommand;
public float ForwardSpeed;
public uint SideStepCommand;
public float SideStepSpeed;
public uint TurnCommand;
public float TurnSpeed;
public IReadOnlyList<InboundMotionAction>? Actions;
public static InboundInterpretedState Default() => new()
{
CurrentStyle = 0x8000003Du,
ForwardCommand = 0x41000003u,
ForwardSpeed = 1.0f,
SideStepCommand = 0u,
SideStepSpeed = 1.0f,
TurnCommand = 0u,
TurnSpeed = 1.0f,
Actions = null,
};
}

View file

@ -0,0 +1,159 @@
using System;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R1-P1 — verbatim port of retail's <c>AnimSequenceNode</c> (Phase R plan
/// `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`, stage R1;
/// oracle `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md`
/// §25-28).
///
/// One node of a <c>CSequence</c>'s animation list: a resolved dat
/// <see cref="Animation"/> plus a playable frame window
/// (<see cref="LowFrame"/>..<see cref="HighFrame"/>) and a signed
/// <see cref="Framerate"/> whose SIGN is retail's playback-direction flag.
///
/// Retail semantics preserved exactly (gap-map items G1/G2/G16/G18):
/// <list type="bullet">
/// <item>The boundary pair (<see cref="GetStartingFrame"/> /
/// <see cref="GetEndingFrame"/>) is direction-aware and returns BARE
/// integers — retail has NO epsilon here (0x00525c80/0x00525cb0). ACE's
/// <c>PhysicsGlobals.EPSILON</c> subtraction compensates for ACE's own
/// <c>float FrameNumber</c> and must not be copied (P0-pins.md).</item>
/// <item><see cref="MultiplyFramerate"/> SWAPS <see cref="LowFrame"/> and
/// <see cref="HighFrame"/> when the factor is negative (0x00525be0) —
/// coupled with the boundary pair's <c>framerate &lt; 0</c> test.</item>
/// <item><see cref="SetAnimationId"/> clamps in retail's exact order
/// (0x00525d60): high&lt;0 → num1; low≥num → num1; high≥num → num1;
/// low&gt;high → high=low.</item>
/// <item><see cref="GetPosFrame(int)"/> returns null out of range
/// (retail; ACE's identity-frame return is an ACE-ism).</item>
/// </list>
///
/// Unlike the legacy <c>AnimationSequencer.AnimNode</c>, retail nodes carry
/// NO per-node IsLooping/Velocity/Omega — loop membership is list structure
/// (<c>first_cyclic</c>) and physics accumulators live on the sequence
/// (G16).
/// </summary>
public sealed class AnimSequenceNode
{
/// <summary>Resolved dat animation, or null (id 0 / missing).</summary>
public Animation? Anim { get; private set; }
/// <summary>
/// Frames per second; NEGATIVE means reverse playback (retail's
/// direction flag). Default 30f (0x00525d30).
/// </summary>
public float Framerate = 30f;
/// <summary>Inclusive window low bound. Default 1 (0x00525d30).</summary>
public int LowFrame = -1;
/// <summary>Inclusive window high bound; 1 = "to the end" sentinel
/// resolved by <see cref="SetAnimationId"/>. Default 1.</summary>
public int HighFrame = -1;
public bool HasAnim => Anim is not null;
/// <summary>Default ctor — retail defaults (0x00525d30).</summary>
public AnimSequenceNode()
{
}
/// <summary>
/// Ctor from a MotionData <see cref="AnimData"/> entry (0x00525f90):
/// copy framerate/low/high, then resolve + clamp via
/// <see cref="SetAnimationId"/>.
/// </summary>
public AnimSequenceNode(AnimData animData, IAnimationLoader loader)
{
Framerate = animData.Framerate;
LowFrame = animData.LowFrame;
HighFrame = animData.HighFrame;
SetAnimationId((uint)animData.AnimId, loader);
}
/// <summary>
/// Resolve the dat animation and clamp the frame window
/// (0x00525d60). The clamp block runs only when an animation resolved;
/// order is retail-exact.
/// </summary>
public void SetAnimationId(uint animId, IAnimationLoader loader)
{
Anim = animId == 0 ? null : loader.LoadAnimation(animId);
if (Anim is null)
return;
int numFrames = Anim.PartFrames.Count;
if (HighFrame < 0)
HighFrame = numFrames - 1;
if (LowFrame >= numFrames)
LowFrame = numFrames - 1;
if (HighFrame >= numFrames)
HighFrame = numFrames - 1;
if (LowFrame > HighFrame)
HighFrame = LowFrame;
}
/// <summary>
/// Direction-aware starting boundary (0x00525c80): reverse playback
/// starts at <c>high_frame + 1</c>, forward at <c>low_frame</c>.
/// BARE int — no epsilon (G1).
/// </summary>
public int GetStartingFrame() => Framerate < 0f ? HighFrame + 1 : LowFrame;
/// <summary>
/// Direction-aware ending boundary (0x00525cb0): reverse ends at
/// <c>low_frame</c>, forward at <c>high_frame + 1</c>. BARE int.
/// </summary>
public int GetEndingFrame() => Framerate < 0f ? LowFrame : HighFrame + 1;
/// <summary>
/// Scale playback rate (0x00525be0): a NEGATIVE factor swaps
/// low/high before multiplying — the swapped fields plus the
/// now-negative framerate are how retail encodes reversed windows.
/// </summary>
public void MultiplyFramerate(float factor)
{
if (factor < 0f)
{
(LowFrame, HighFrame) = (HighFrame, LowFrame);
}
Framerate *= factor;
}
/// <summary>
/// Root-motion frame at a double position (0x005247b0): floor then the
/// int overload.
/// </summary>
public Frame? GetPosFrame(double frameNumber)
=> GetPosFrame((int)Math.Floor(frameNumber));
/// <summary>
/// Root-motion frame by index (0x00525c10): null when no animation,
/// index out of range, or the animation carries no PosFrames.
/// </summary>
public Frame? GetPosFrame(int index)
{
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
return null;
if (Anim.PosFrames is null || index >= Anim.PosFrames.Count)
return null;
return Anim.PosFrames[index];
}
/// <summary>
/// Skeletal part frame by index — same bounds discipline as
/// <see cref="GetPosFrame(int)"/>.
/// </summary>
public AnimationFrame? GetPartFrame(int index)
{
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
return null;
return Anim.PartFrames[index];
}
}

View file

@ -0,0 +1,692 @@
using System;
using System.Linq;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
// Alias the DatReaderWriter enum so it doesn't clash with
// AcDream.Core.Physics.MotionCommand (a static class of uint constants) —
// same convention as AnimationSequencerTests.cs.
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// R2-Q2 — verbatim port of retail's CMotionTable (the motion-selection
// dispatcher). Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md
// (all §-references below point there); ambiguity pins:
// docs/research/2026-07-02-r2-motiontable/Q0-pins.md (A1-A5).
//
// Scope (r2-port-plan.md §3 Q2): pure motion-SELECTION logic — resolves which
// MotionData(s) to append to a CSequence and updates a MotionState in place.
// Does NOT own a pending-animation queue (that is MotionTableManager, Q3) and
// does NOT drive per-tick advance (CSequence.Update, R1). This file re-homes
// the adapter's already-field-validated GetLink (AnimationSequencer.cs
// :953-997) verbatim per the keep-list (r2-port-plan.md §2) and adds every
// other CMotionTable/free-function member the decomp documents.
//
// Command-word class bits (decomp §1 "Motion-id class bits", §15):
// 0x80000000 style-class (top bit set; the low 31 bits + top bit read as
// a NEGATIVE int32 — retail's `(int32_t)ebx_1 < 0` test)
// 0x40000000 cycle-class (cyclic/looping base state, `this->cycles`)
// 0x20000000 modifier-class (physics-only overlay, `this->modifiers`)
// 0x10000000 action-class (one-shot, `add_action` FIFO)
// 0x41000003 Ready — the "stop / default state" sentinel (decomp §15)
//
// DatReaderWriter.DBObjs.MotionTable field map (verified via reflection,
// package Chorizite.DatReaderWriter 2.1.7, 2026-07-02):
// DefaultStyle : MotionCommand → this->default_style
// StyleDefaults : Dictionary<MotionCommand,MotionCommand> → this->style_defaults
// Cycles : Dictionary<int,MotionData> → this->cycles
// Modifiers : Dictionary<int,MotionData> → this->modifiers
// Links : Dictionary<int,MotionCommandData> → this->links
// (MotionCommandData.MotionData : Dictionary<int,MotionData> = the inner
// per-target-substate hash)
// MotionData.Bitfield : byte (A5 CONFIRMED present — bit0=clear-mods-on-
// entry, bit1=substate-gated for is_allowed)
// MotionData.Anims : List<AnimData> — retail's `num_anims`/`anims[]` pair;
// Anims.Count IS num_anims (A3 — no separate packed-byte field exists on
// the managed type; the decomp's "packed byte at MotionData+0x10" is
// this same count, just read through the raw struct layout).
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// Retail's <c>CMotionTable</c> (0x00522xxx-0x00523xxx region) — the
/// motion-selection dispatcher. Wraps a loaded <see cref="MotionTable"/> DBObj
/// and resolves <c>(style, substate, speed)</c> requests into MotionData
/// chains appended to a <see cref="CSequence"/>, mutating a
/// <see cref="MotionState"/> in place.
/// </summary>
public sealed class CMotionTable
{
private readonly MotionTable _table;
public CMotionTable(MotionTable table)
{
ArgumentNullException.ThrowIfNull(table);
_table = table;
}
// ── free functions (decomp §2) ──────────────────────────────────────
/// <summary>
/// <c>same_sign</c> 0x00522260 (@298253). True when <paramref name="a"/>
/// and <paramref name="b"/> are on the same side of zero (0 counts as
/// non-negative, matching the decomp's <c>&gt;= 0f</c> reading).
/// </summary>
internal static bool SameSign(float a, float b) => (a >= 0f) == (b >= 0f);
/// <summary>
/// <c>change_cycle_speed</c> 0x00522290 (@298276): rescale
/// <paramref name="sequence"/>'s cyclic-tail framerate by
/// <c>newSpeed/oldSpeed</c>. Ported VERBATIM including the A4-#2 gap: when
/// <paramref name="oldSpeed"/> is ~0 and <paramref name="newSpeed"/> is
/// NOT ~0, retail's fabsl branch structure suppresses the rescale
/// entirely (no zeroing, no scaling — a silent no-op). Only when BOTH are
/// ~0 does retail explicitly zero the framerate.
/// </summary>
internal static void ChangeCycleSpeed(CSequence sequence, MotionData? cyclic, float oldSpeed, float newSpeed)
{
const float Epsilon = 0.000199999995f; // ~0.0002f, verbatim retail constant
if (MathF.Abs(oldSpeed) > Epsilon)
{
sequence.MultiplyCyclicAnimationFramerate(newSpeed / oldSpeed);
return;
}
// oldSpeed ~ 0: only the "newSpeed also ~0" leg does anything (zero
// the framerate). The "newSpeed NOT ~0" leg is a silent no-op —
// retail's own gap, kept verbatim (Q0-pins A4-#2).
if (MathF.Abs(newSpeed) <= Epsilon)
{
sequence.MultiplyCyclicAnimationFramerate(0f);
}
}
/// <summary>
/// <c>add_motion</c> 0x005224b0 (@298437): UNCONDITIONALLY sets
/// <paramref name="sequence"/>'s velocity/omega to
/// <c>motion.Velocity/Omega * speedMod</c> (replace, not accumulate — a
/// dat-silent MotionData carries a zero Vector3, so "unconditional
/// replace" and "replace with zero" are the same call; G17 core), then
/// appends every AnimData in <paramref name="motion"/> speed-scaled
/// (framerate only, retail <c>AnimData::operator*</c>).
/// </summary>
internal static void AddMotion(CSequence sequence, MotionData? motion, float speedMod)
{
if (motion is null)
return;
sequence.SetVelocity(motion.Velocity * speedMod);
sequence.SetOmega(motion.Omega * speedMod);
foreach (var ad in motion.Anims)
{
sequence.AppendAnimation(new AnimData
{
AnimId = ad.AnimId,
LowFrame = ad.LowFrame,
HighFrame = ad.HighFrame,
Framerate = ad.Framerate * speedMod,
});
}
}
/// <summary>
/// <c>combine_motion</c> 0x00522580 (@298472): ADDS
/// <paramref name="motion"/>'s velocity/omega (scaled by
/// <paramref name="speedMod"/>) onto the sequence's existing physics.
/// Never touches <c>anims</c> — used for modifier overlays where the base
/// cycle's animation frames stay untouched.
/// </summary>
internal static void CombineMotion(CSequence sequence, MotionData? motion, float speedMod)
{
if (motion is null)
return;
sequence.CombinePhysics(motion.Velocity * speedMod, motion.Omega * speedMod);
}
/// <summary>
/// <c>subtract_motion</c> 0x00522600 (@298492): inverse of
/// <see cref="CombineMotion"/> — used when REMOVING a modifier's
/// physics contribution.
/// </summary>
internal static void SubtractMotion(CSequence sequence, MotionData? motion, float speedMod)
{
if (motion is null)
return;
sequence.SubtractPhysics(motion.Velocity * speedMod, motion.Omega * speedMod);
}
// ── members ──────────────────────────────────────────────────────────
/// <summary>
/// <c>is_allowed</c> 0x005226c0 (@298526): a bitfield-bit1 ("gated")
/// cycle is only reusable when the current substate matches the
/// candidate, OR the current substate already equals the owning style's
/// default substate. Null <paramref name="candidate"/> is never allowed.
/// </summary>
public bool IsAllowed(uint candidateSubstate, MotionData? candidate, MotionState state)
{
if (candidate is null)
return false;
if ((candidate.Bitfield & 2) != 0)
{
uint substate = state.Substate;
if (candidateSubstate != substate)
{
uint defaultSubstate = LookupStyleDefault(state.Style);
return defaultSubstate == substate;
}
}
return true;
}
/// <summary>
/// <c>get_link</c> 0x00522710 (@298552). Re-homed verbatim from the
/// working adapter (<c>AnimationSequencer.GetLink</c>, field-validated —
/// the reversed-key branch fixed the Ready→WalkBackward "left leg
/// twitches" glitch) per Q0-pins A1: EITHER speed negative routes through
/// the swapped-key branch (link stored FROM <paramref name="toSubstate"/>
/// TO <paramref name="fromSubstate"/>), falling back to the style's
/// default-substate hop; otherwise the forward branch (link FROM
/// <paramref name="fromSubstate"/> TO <paramref name="toSubstate"/>),
/// falling back to the style-level catch-all (unstyled outer key).
/// </summary>
public MotionData? GetLink(uint fromStyle, uint fromSubstate, float fromSubstateMod, uint toSubstate, float toSubstateMod)
{
if (toSubstateMod < 0f || fromSubstateMod < 0f)
{
// Reversed-direction path: link FROM toSubstate TO fromSubstate.
int reversedKey = (int)((fromStyle << 16) | (toSubstate & 0xFFFFFFu));
if (_table.Links.TryGetValue(reversedKey, out var revLink)
&& revLink.MotionData.TryGetValue((int)fromSubstate, out var revResult))
{
return revResult;
}
// Style-defaults fallback (decomp §4 second predicate block, "else" branch).
uint defaultSubstate = LookupStyleDefault(fromStyle);
int subKey = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu));
if (_table.Links.TryGetValue(subKey, out var subLink)
&& subLink.MotionData.TryGetValue((int)defaultSubstate, out var subResult))
{
return subResult;
}
return null;
}
// Forward-direction path: link FROM fromSubstate TO toSubstate.
int outerKey1 = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu));
if (_table.Links.TryGetValue(outerKey1, out var cmd1)
&& cmd1.MotionData.TryGetValue((int)toSubstate, out var result1))
{
return result1;
}
// Fallback: style-level catch-all (unstyled outer key, decomp §4 "else if" branch).
int outerKey2 = (int)(fromStyle << 16);
if (_table.Links.TryGetValue(outerKey2, out var cmd2)
&& cmd2.MotionData.TryGetValue((int)toSubstate, out var result2))
{
return result2;
}
return null;
}
/// <summary>
/// <c>GetObjectSequence</c> 0x00522860 (@298636) — the FULL dispatcher.
/// See decomp §5 for the annotated retail source; the branch structure
/// below mirrors it 1:1 (comments cite the corresponding decomp block).
/// </summary>
/// <param name="motion">Requested target substate/motion id.</param>
/// <param name="state">In/out current motion state.</param>
/// <param name="sequence">Sequence to build/mutate.</param>
/// <param name="speed">Requested speed_mod for the new substate.</param>
/// <param name="outTicks">OUT: tick count of appended animation, minus 1.</param>
/// <param name="stopCall">false = normal "do motion"; true = re-invoked
/// from <see cref="StopSequenceMotion"/>.</param>
public bool GetObjectSequence(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks, bool stopCall)
{
outTicks = 0;
uint style = state.Style;
if (style == 0)
return false;
uint substate = state.Substate;
if (substate == 0)
return false;
uint styleDefault = LookupStyleDefault(style); // var_c
uint target = motion; // ebx_1 working copy
// ---- FAST PATH: requesting the style default while already a
// modifier-class substate, not a stop-call. ----
if (target == styleDefault && !stopCall && (substate & 0x20000000) != 0)
return true;
float requestedSpeed = speed; // ebp_1
// ================================================================
// BRANCH 1: target interpreted as NEGATIVE int32 — style-change request.
// ================================================================
if ((int)target < 0)
{
if (style == target)
return true; // already in that style
uint currentStyleDefault = LookupStyleDefault(style); // eax_1
MotionData? exitLink = null; // var_4_1
if (substate != currentStyleDefault)
{
exitLink = GetLink(style, substate, state.SubstateMod, currentStyleDefault, requestedSpeed);
}
uint targetStyleDefault = LookupStyleDefault(target); // arg7_style_default
if (_table.StyleDefaults.ContainsKey((DRWMotionCommand)target))
{
MotionData? newCycle = LookupCycle(target, targetStyleDefault); // eax_5
if (newCycle is not null)
{
if ((newCycle.Bitfield & 1) != 0)
state.ClearModifiers();
// link FROM current style's default substate TO target style's default substate
MotionData? directOrHop1 = GetLink(style, styleDefault, state.SubstateMod, target, requestedSpeed); // arg2
MotionData? hop2 = null; // var_10_1
if (directOrHop1 is null && target != style)
{
// DOUBLE-HOP VIA default_style.
directOrHop1 = GetLink(style, styleDefault, 1f, (uint)_table.DefaultStyle, 1f);
uint defaultStyleDefaultSubstate = LookupStyleDefault((uint)_table.DefaultStyle);
hop2 = GetLink((uint)_table.DefaultStyle, defaultStyleDefaultSubstate, 1f, target, 1f);
}
sequence.ClearPhysics();
sequence.RemoveCyclicAnims();
AddMotion(sequence, exitLink, requestedSpeed);
AddMotion(sequence, directOrHop1, requestedSpeed);
AddMotion(sequence, hop2, requestedSpeed);
AddMotion(sequence, newCycle, requestedSpeed);
state.Substate = targetStyleDefault;
state.Style = target;
state.SubstateMod = speed;
ReModify(sequence, state);
uint numAnims2 = (uint)(exitLink?.Anims.Count ?? 0);
uint ecx20 = (uint)(directOrHop1?.Anims.Count ?? 0);
uint numAnims1 = (uint)(hop2?.Anims.Count ?? 0);
outTicks = (uint)newCycle.Anims.Count + numAnims1 + ecx20 + numAnims2 - 1;
return true;
}
}
// else: fall through to the class-bit blocks below with target still negative.
}
// ================================================================
// BRANCH 2: target has the CYCLE-CLASS bit (0x40000000) set.
// ================================================================
if ((target & 0x40000000) != 0)
{
uint substateId = target & 0xFFFFFFu;
MotionData? cyclic = LookupCycle(style, substateId); // eax_24
if (cyclic is null)
{
// No cycle for THIS style at that id -> retry under the
// table-wide default_style (decomp §5 "label_522ae6" retry —
// unconditional, no style==0 guard in retail).
cyclic = LookupCycle((uint)_table.DefaultStyle, substateId);
}
if (cyclic is not null && IsAllowed(target, cyclic, state))
{
// ---- FAST RE-SPEED PATH ----
if (target == substate
&& SameSign(requestedSpeed, state.SubstateMod)
&& sequence.HasAnims())
{
ChangeCycleSpeed(sequence, cyclic, state.SubstateMod, requestedSpeed);
SubtractMotion(sequence, cyclic, state.SubstateMod);
CombineMotion(sequence, cyclic, requestedSpeed);
state.SubstateMod = speed;
return true;
}
if ((cyclic.Bitfield & 1) != 0)
state.ClearModifiers();
MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_34
bool sameSignDirect = directLink is not null && SameSign(requestedSpeed, state.SubstateMod);
MotionData? hop2 = null; // var_10_1
MotionData? linkOrHop1 = directLink; // arg2
if (directLink is null || !sameSignDirect)
{
uint styleDefaultSubstate = LookupStyleDefault(style);
linkOrHop1 = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f);
hop2 = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed);
}
sequence.ClearPhysics();
sequence.RemoveCyclicAnims();
if (hop2 is null)
{
float signedSpeed = (state.SubstateMod == 0f || SameSign(state.SubstateMod, speed))
? speed : -speed;
AddMotion(sequence, linkOrHop1, signedSpeed);
}
else
{
AddMotion(sequence, linkOrHop1, state.SubstateMod);
AddMotion(sequence, hop2, requestedSpeed);
}
AddMotion(sequence, cyclic, requestedSpeed);
// Leaving a modifier-class substate for something else: re-register
// the OLD substate as a still-active modifier unless the style's
// default substate IS the new target.
uint oldSubstate = state.Substate;
if (oldSubstate != target && (oldSubstate & 0x20000000) != 0)
{
uint styleDefaultSubstate2 = LookupStyleDefault(style);
if (styleDefaultSubstate2 != target)
state.AddModifierNoCheck(oldSubstate, state.SubstateMod);
}
state.SubstateMod = speed;
state.Substate = target;
ReModify(sequence, state);
uint ecx45 = (uint)(linkOrHop1?.Anims.Count ?? 0);
uint numAnims1b = (uint)(hop2?.Anims.Count ?? 0);
outTicks = (uint)cyclic.Anims.Count + numAnims1b + ecx45 - 1;
return true;
}
// is_allowed rejected (or no cyclic resolved) -> fall through.
}
// ================================================================
// BRANCH 3: target has the ACTION-CLASS bit (0x10000000) set.
// ================================================================
if ((target & 0x10000000) != 0)
{
MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_57
if (baseCycle is not null)
{
MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_60
if (directLink is not null)
{
state.AddAction(target, requestedSpeed);
sequence.ClearPhysics();
sequence.RemoveCyclicAnims();
AddMotion(sequence, directLink, requestedSpeed);
AddMotion(sequence, baseCycle, state.SubstateMod);
ReModify(sequence, state);
outTicks = (uint)directLink.Anims.Count;
return true;
}
// No direct link -> route through the style default (double-hop out-and-back).
uint styleDefaultSubstate = LookupStyleDefault(style);
MotionData? outHop = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); // eax_66
if (outHop is not null)
{
MotionData? actionLink = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); // eax_68
if (actionLink is not null)
{
MotionData? baseCycleRefetch = LookupCycle(style, substate & 0xFFFFFFu); // eax_69 (same key, re-fetched)
if (baseCycleRefetch is not null)
{
MotionData? returnHop = GetLink(style, styleDefaultSubstate, 1f, substate, state.SubstateMod);
state.AddAction(target, requestedSpeed);
sequence.ClearPhysics();
sequence.RemoveCyclicAnims();
AddMotion(sequence, outHop, 1f);
AddMotion(sequence, actionLink, requestedSpeed);
AddMotion(sequence, returnHop, 1f);
AddMotion(sequence, baseCycleRefetch, state.SubstateMod);
ReModify(sequence, state);
// A4-#1: outTicks = outHop + actionLink [+ returnHop] ONLY —
// never the base cycle, never double-counted (ACE's bug, not retail's).
uint ticks = (uint)outHop.Anims.Count + (uint)actionLink.Anims.Count;
if (returnHop is not null)
ticks += (uint)returnHop.Anims.Count;
outTicks = ticks;
return true;
}
}
}
}
}
// ================================================================
// BRANCH 4: target has the MODIFIER-CLASS bit (0x20000000) set.
// ================================================================
if ((target & 0x20000000) != 0)
{
MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_81
if (baseCycle is not null && (baseCycle.Bitfield & 1) == 0)
{
uint modKey = target & 0xFFFFFFu;
MotionData? modifierStyled = LookupModifier(style, modKey, styleSpecific: true); // eax_85
MotionData? modifierGlobal = null; // eax_87
if (modifierStyled is null)
modifierGlobal = LookupModifier(style, modKey, styleSpecific: false);
MotionData? modifierData = modifierStyled ?? modifierGlobal;
if (modifierStyled is not null || modifierGlobal is not null)
{
bool added = state.AddModifier(target, requestedSpeed);
if (!added)
{
// Toggle: already active (or == current substate) -> stop then re-add.
StopSequenceMotion(target, 1f, state, sequence, out _);
added = state.AddModifier(target, requestedSpeed);
}
if (added)
{
CombineMotion(sequence, modifierData, requestedSpeed);
return true;
}
}
}
}
return false;
}
/// <summary>
/// <c>re_modify</c> 0x005222e0 (@298300): after installing a new base
/// cycle/substate, replay every previously-active modifier back onto the
/// sequence by re-invoking <see cref="GetObjectSequence"/> for each one.
/// Q0-pins A4-#5: the retail copy-ctor snapshot exists ONLY as a
/// loop-termination bound (both the live list and the snapshot pop one
/// entry per iteration; the loop ends when the snapshot empties) — the
/// C# port deep-copies via <c>MotionState(MotionState other)</c>, pops
/// both in lockstep, and terminates on the snapshot's emptiness.
/// </summary>
public void ReModify(CSequence sequence, MotionState state)
{
if (!state.Modifiers.Any())
return;
var snapshot = new MotionState(state);
do
{
var head = state.Modifiers.First();
uint motion = head.Motion;
float speedMod = head.SpeedMod;
state.RemoveModifier(head);
var snapshotHead = snapshot.Modifiers.First();
snapshot.RemoveModifier(snapshotHead);
GetObjectSequence(motion, state, sequence, speedMod, out _, stopCall: false);
} while (snapshot.Modifiers.Any());
}
/// <summary>
/// <c>StopSequenceMotion</c> 0x00522fc0 (@298954): two independent stop
/// mechanisms. Stopping the active cycle re-drives
/// <see cref="GetObjectSequence"/> toward the style's default substate
/// (i.e. "return to idle"). Stopping a modifier just unwinds its physics
/// contribution and removes it from the chain — no animation
/// re-sequencing needed since modifiers don't touch <c>anims[]</c>.
/// Action-class ids are NOT handled here (decomp §7 note — actions
/// complete via the MotionTableManager tick-countdown, Q3 scope).
/// </summary>
public bool StopSequenceMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks)
{
outTicks = 0;
// Case A: stopping the CYCLE-class substate we are currently in.
if ((motion & 0x40000000) != 0 && motion == state.Substate)
{
uint styleDefaultSubstate = LookupStyleDefault(state.Style);
GetObjectSequence(styleDefaultSubstate, state, sequence, 1f, out outTicks, stopCall: true);
return true;
}
// Case B: stopping a MODIFIER-class id.
if ((motion & 0x20000000) != 0)
{
foreach (var node in state.Modifiers)
{
if (node.Motion != motion)
continue;
uint modKey = motion & 0xFFFFFFu;
MotionData? modData = LookupModifier(state.Style, modKey, styleSpecific: true);
modData ??= LookupModifier(state.Style, modKey, styleSpecific: false);
if (modData is not null)
{
SubtractMotion(sequence, modData, node.SpeedMod);
state.RemoveModifier(node);
return true;
}
break; // matching motion id found but no MotionData anywhere -> give up
}
}
return false;
}
/// <summary>
/// <c>SetDefaultState</c> 0x005230a0 (@299004): reset a MotionState to
/// the motion table's baseline (<c>default_style</c> + that style's
/// default substate), clearing all modifiers/actions and installing the
/// base cyclic animation for that (style,substate) fresh via
/// <see cref="CSequence.ClearAnimations"/> (hard reset, not just
/// <c>RemoveCyclicAnims</c>).
/// </summary>
public bool SetDefaultState(MotionState state, CSequence sequence, out uint outTicks)
{
outTicks = 0;
if (!_table.StyleDefaults.TryGetValue(_table.DefaultStyle, out var defaultSubstateCmd))
return false;
uint defaultSubstate = (uint)defaultSubstateCmd;
state.ClearModifiers();
state.ClearActions();
MotionData? cyclic = LookupCycle((uint)_table.DefaultStyle, defaultSubstate);
if (cyclic is null)
return false;
state.Style = (uint)_table.DefaultStyle;
state.Substate = defaultSubstate;
state.SubstateMod = 1f;
outTicks = (uint)cyclic.Anims.Count - 1;
sequence.ClearPhysics();
sequence.ClearAnimations();
AddMotion(sequence, cyclic, state.SubstateMod);
return true;
}
/// <summary><c>DoObjectMotion</c> 0x00523e90 (@300045): thin wrapper — "do" ==
/// <see cref="GetObjectSequence"/> with the stop-flag forced to false.</summary>
public bool DoObjectMotion(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks)
=> GetObjectSequence(motion, state, sequence, speed, out outTicks, stopCall: false);
/// <summary><c>StopObjectMotion</c> 0x00523ec0 (@300053): thin wrapper —
/// tailcall straight into <see cref="StopSequenceMotion"/>.</summary>
public bool StopObjectMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks)
=> StopSequenceMotion(motion, speed, state, sequence, out outTicks);
/// <summary>
/// <c>StopObjectCompletely</c> 0x00523ed0 (@300062): the "return to idle
/// from everything" call — strips every active modifier first (each one
/// individually going through the same subtract-and-unlink path as a
/// single <see cref="StopObjectMotion"/> call), then stops the base
/// cycle/substate itself (which re-drives <see cref="GetObjectSequence"/>
/// toward the style's default substate). Q0-pins A4-#4: return =
/// <c>finalStopOk ? true : anyModifierStopOk</c>, ported verbatim.
/// </summary>
public bool StopObjectCompletely(MotionState state, CSequence sequence, out uint outTicks)
{
outTicks = 0;
bool anyModifierStopOk = false;
// Stop EVERY currently-active modifier first (list shrinks each
// iteration because StopSequenceMotion unlinks the node it stops).
while (state.Modifiers.Any())
{
var node = state.Modifiers.First();
float speedMod = node.SpeedMod;
if (StopSequenceMotion(node.Motion, speedMod, state, sequence, out outTicks))
anyModifierStopOk = true;
else
break; // defensive: avoid infinite loop if a stop can't unlink (shouldn't happen)
}
// Finally stop the base cycle/substate itself.
if (StopSequenceMotion(state.Substate, state.SubstateMod, state, sequence, out outTicks))
return true;
return anyModifierStopOk;
}
// ── private lookup helpers ──────────────────────────────────────────
private uint LookupStyleDefault(uint style)
=> _table.StyleDefaults.TryGetValue((DRWMotionCommand)style, out var def) ? (uint)def : 0u;
private MotionData? LookupCycle(uint style, uint substate)
{
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
return _table.Cycles.TryGetValue(key, out var data) ? data : null;
}
private MotionData? LookupModifier(uint style, uint modKey, bool styleSpecific)
{
int key = styleSpecific ? (int)((style << 16) | modKey) : (int)modKey;
return _table.Modifiers.TryGetValue(key, out var data) ? data : null;
}
}

View file

@ -0,0 +1,511 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R1-P4 host seam standing in for retail's <c>CPhysicsObj.anim_hooks</c>
/// SmartArray + the global <c>AnimDoneHook</c> singleton
/// (<c>add_anim_hook</c> 0x00514c20; <c>process_hooks</c> 0x00511550 drains
/// once per physics tick — the drain point stays with the host until R6
/// places it per retail's UpdateObjectInternal order).
/// </summary>
public interface IAnimHookQueue
{
/// <summary>Queue a matched AnimFrame hook (already direction-filtered
/// by <c>execute_hooks</c>).</summary>
void AddAnimHook(DatReaderWriter.Types.AnimationHook hook);
/// <summary>Queue the global animation-done hook — retail's
/// <c>AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
/// CPartArray::AnimationDone(1)</c> chain (R2 consumes it as
/// MotionDone).</summary>
void AddAnimDoneHook();
}
/// <summary>
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
/// surgery (Phase R plan stage R1; oracle
/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17,
/// §20, §24). The per-tick advance (`update`/`update_internal`/
/// `apply_physics`/hook dispatch) lands in R1-P3/P4.
///
/// Structure: a doubly-linked animation-node list with two cursors —
/// <see cref="CurrAnim"/> (the node currently playing) and
/// <see cref="FirstCyclic"/> (where the looping tail begins; everything
/// before it is one-shot "link" animation). Retail invariant (G10):
/// <c>append_animation</c> slides <c>first_cyclic</c> to the JUST-APPENDED
/// node on EVERY call — the cyclic tail is always exactly the last node
/// appended so far.
///
/// Physics accumulators (<see cref="Velocity"/>/<see cref="Omega"/>) live on
/// the SEQUENCE, not per node (G16); retail replaces them via
/// <c>set_velocity/set_omega</c> and algebraically blends via
/// <c>combine_physics/subtract_physics</c> (the R2 fast path's mechanism).
///
/// Divergence register (rows added with this commit):
/// <list type="bullet">
/// <item><c>frame_number</c> is x87 <c>long double</c> in retail
/// (acclient.h:30747); C# <c>double</c> is the closest available (G15).</item>
/// <item>The intrusive DLList is a managed <see cref="LinkedList{T}"/>;
/// node identity semantics preserved via <see cref="LinkedListNode{T}"/>
/// references.</item>
/// </list>
/// </summary>
public sealed class CSequence
{
private readonly LinkedList<AnimSequenceNode> _animList = new(); // anim_list (DLList)
private LinkedListNode<AnimSequenceNode>? _firstCyclic; // first_cyclic
private LinkedListNode<AnimSequenceNode>? _currAnim; // curr_anim
private readonly IAnimationLoader _loader;
/// <summary>Fractional frame position within <see cref="CurrAnim"/>.
/// Retail x87 long double → double (register row, G15).</summary>
public double FrameNumber;
/// <summary>Sequence root-motion velocity accumulator (body-local).</summary>
public Vector3 Velocity;
/// <summary>Sequence angular-velocity accumulator.</summary>
public Vector3 Omega;
/// <summary>Static pose used when no animation node is active
/// (<c>placement_frame</c>, §16).</summary>
public AnimationFrame? PlacementFrame { get; private set; }
public uint PlacementFrameId { get; private set; }
public CSequence(IAnimationLoader loader) => _loader = loader;
// ── inspection surface (adapter + tests) ────────────────────────────
public AnimSequenceNode? CurrAnim => _currAnim?.Value;
public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value;
public int Count => _animList.Count;
/// <summary><c>has_anims</c> (0x00524bd0).</summary>
public bool HasAnims() => _animList.Count > 0;
/// <summary>TEST SEAM: reposition curr_anim by list index (retail state
/// reached via update_internal, which lands in P4).</summary>
public void SetCurrAnimForTest(int index)
{
var n = _animList.First;
for (int i = 0; i < index && n != null; i++) n = n.Next;
_currAnim = n;
}
// ── append_animation (0x00525510, §24) ──────────────────────────────
/// <summary>
/// Append a MotionData anim entry. A node whose dat animation fails to
/// resolve is discarded. <c>first_cyclic</c> slides to the appended
/// node on EVERY call; <c>curr_anim</c> seeds to the head (with
/// <c>frame_number = get_starting_frame()</c>) only when it was null.
/// </summary>
public void AppendAnimation(AnimData animData)
{
var node = new AnimSequenceNode(animData, _loader);
if (!node.HasAnim)
return; // retail deletes the node — discard
_animList.AddLast(node);
_firstCyclic = _animList.Last;
if (_currAnim is null)
{
_currAnim = _animList.First;
FrameNumber = _currAnim!.Value.GetStartingFrame();
}
}
// ── clear family (§3-§5) ────────────────────────────────────────────
/// <summary><c>clear</c> (0x005255b0): full wipe INCLUDING the placement
/// fields — the raw body resets them (the "2-instruction clear" note in
/// the gap map was wrong; raw decomp is authority).</summary>
public void Clear()
{
ClearAnimations();
ClearPhysics();
PlacementFrame = null;
PlacementFrameId = 0;
}
/// <summary><c>clear_animations</c> (0x00524dc0): delete every node,
/// null both cursors, zero <c>frame_number</c>.</summary>
public void ClearAnimations()
{
_animList.Clear();
_firstCyclic = null;
_currAnim = null;
FrameNumber = 0.0;
}
/// <summary><c>clear_physics</c> (0x00524d50).</summary>
public void ClearPhysics()
{
Velocity = Vector3.Zero;
Omega = Vector3.Zero;
}
// ── remove family (§6-§8) ───────────────────────────────────────────
/// <summary>
/// <c>remove_cyclic_anims</c> (0x00524e40): delete <c>first_cyclic</c>
/// → tail. A removed <c>curr_anim</c> snaps BACK to the previous node
/// with <c>frame_number = prev.get_ending_frame()</c> (or 0.0 when the
/// list emptied). Afterwards <c>first_cyclic</c> = new tail (or null).
/// </summary>
public void RemoveCyclicAnims()
{
var node = _firstCyclic;
while (node is not null)
{
var next = node.Next;
if (ReferenceEquals(_currAnim, node))
{
var prev = node.Previous;
_currAnim = prev;
FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame();
}
_animList.Remove(node);
node = next;
}
_firstCyclic = _animList.Last;
}
/// <summary>
/// <c>remove_link_animations(count)</c> (0x00524be0): delete up to
/// <paramref name="count"/> predecessors of <c>first_cyclic</c>. A
/// removed <c>curr_anim</c> snaps FORWARD to <c>first_cyclic</c> with
/// <c>frame_number = get_starting_frame()</c>.
/// </summary>
public void RemoveLinkAnimations(int count)
{
for (int i = 0; i < count; i++)
{
var prev = _firstCyclic?.Previous;
if (prev is null)
break;
if (ReferenceEquals(_currAnim, prev))
{
_currAnim = _firstCyclic;
if (_firstCyclic is not null)
FrameNumber = _firstCyclic.Value.GetStartingFrame();
}
_animList.Remove(prev);
}
}
/// <summary><c>remove_all_link_animations</c> (0x00524ca0): loop until
/// <c>first_cyclic</c> has no predecessor.</summary>
public void RemoveAllLinkAnimations()
{
while (_firstCyclic?.Previous is not null)
RemoveLinkAnimations(1);
}
/// <summary>
/// <c>apricot</c> (0x00524b40; the PDB-verified retail name): trim
/// consumed nodes from the head, bounded by BOTH <c>curr_anim</c>
/// (stop — still live) and <c>first_cyclic</c> (defensive bound —
/// never delete into the cyclic tail). Called after every update (§22).
/// </summary>
public void Apricot()
{
var head = _animList.First;
if (head is null || ReferenceEquals(head, _currAnim))
return;
while (!ReferenceEquals(head, _firstCyclic))
{
_animList.Remove(head!);
head = _animList.First;
if (head is null || ReferenceEquals(head, _currAnim))
break;
}
}
// ── physics accumulators (§10-§13) ──────────────────────────────────
public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880
public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0
public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0
public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900
// ── multiply_cyclic_animation_fr (0x00524940, §14) ──────────────────
/// <summary>
/// Scale the framerate of every node from <c>first_cyclic</c> to the
/// tail. Framerates ONLY (G13) — retail rescales the sequence
/// velocity/omega separately via <c>change_cycle_speed</c>'s
/// <c>subtract_motion</c>/<c>combine_motion</c> composite (R2).
/// </summary>
public void MultiplyCyclicAnimationFramerate(float factor)
{
for (var n = _firstCyclic; n is not null; n = n.Next)
n.Value.MultiplyFramerate(factor);
}
// ── placement + accessors (§15-§17) ─────────────────────────────────
/// <summary><c>set_placement_frame</c> (0x005249b0).</summary>
public void SetPlacementFrame(AnimationFrame? frame, uint id)
{
PlacementFrame = frame;
PlacementFrameId = id;
}
/// <summary><c>get_curr_animframe</c> (0x00524970): the floored current
/// part frame, or the placement frame when no node is active.</summary>
public AnimationFrame? GetCurrAnimframe()
{
if (_currAnim is null)
return PlacementFrame;
return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber));
}
/// <summary><c>get_curr_frame_number</c> (0x005249d0).</summary>
public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber);
// ── apply_physics (0x00524ab0, §19) ─────────────────────────────────
/// <summary>
/// Accumulated-physics root motion: advance <paramref name="frame"/> by
/// <see cref="Velocity"/>/<see cref="Omega"/> over a signed quantum —
/// MAGNITUDE from <paramref name="quantum"/>, SIGN from
/// <paramref name="signSource"/> (retail copysign semantics; call sites
/// pass 1/framerate as magnitude and the signed elapsed time as sign).
/// </summary>
public void ApplyPhysics(Frame frame, double quantum, double signSource)
{
double signed = Math.Abs(quantum);
if (signSource < 0.0)
signed = -signed;
float sq = (float)signed;
frame.Origin += Velocity * sq;
FrameOps.Rotate(frame, Omega * sq);
}
// ── R1-P4: the frame-advance core ───────────────────────────────────
/// <summary>Host hook queue (<c>hook_obj</c>); null = hooks dropped
/// (objects without a physics host).</summary>
public IAnimHookQueue? HookObj;
/// <summary>
/// <c>CSequence::update</c> (0x00525b80, §22): non-empty list →
/// <see cref="UpdateInternal"/> then <see cref="Apricot"/>; empty list
/// with a Frame → accumulated-physics free motion.
/// </summary>
public void Update(double timeElapsed, Frame? frame)
{
if (_animList.Count > 0 && _currAnim is not null)
{
UpdateInternal(timeElapsed, frame);
Apricot();
}
else if (frame is not null)
{
ApplyPhysics(frame, timeElapsed, timeElapsed);
}
}
/// <summary>
/// <c>CSequence::update_internal</c> (0x005255d0, §21 — ACE-verified
/// skeleton, P0-pins.md): advance <see cref="FrameNumber"/> by
/// framerate·dt; on overshoot clamp to the RAW high/low frame, compute
/// the leftover time, and mark animDone; fire the pose/physics/hook
/// triple for EVERY crossed integer frame (ascending forward,
/// descending reverse); queue the global AnimDoneHook when the list
/// HEAD is no longer the cyclic tail (G5's structural gate); advance to
/// the next node and LOOP with the carried leftover (P0 pin).
/// Iterative (retail while-true), NO safety cap (G4), NO boundary
/// epsilon (G1/G3).
/// </summary>
public void UpdateInternal(double timeElapsed, Frame? frame)
{
while (true)
{
if (_currAnim is null)
return;
var currAnim = _currAnim.Value;
double framerate = currAnim.Framerate;
double frametime = framerate * timeElapsed;
int lastFrame = (int)Math.Floor(FrameNumber);
FrameNumber += frametime;
double frameTimeElapsed = 0.0;
bool animDone = false;
if (frametime > 0.0)
{
if (currAnim.HighFrame < Math.Floor(FrameNumber))
{
double frameOffset = FrameNumber - currAnim.HighFrame - 1.0;
if (frameOffset < 0.0)
frameOffset = 0.0;
if (Math.Abs(framerate) > FrameOps.FEpsilon)
frameTimeElapsed = frameOffset / framerate;
FrameNumber = currAnim.HighFrame;
animDone = true;
}
while (Math.Floor(FrameNumber) > lastFrame)
{
if (frame is not null)
{
var pos = currAnim.GetPosFrame(lastFrame);
if (pos is not null)
FrameOps.Combine(frame, pos);
if (Math.Abs(framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
}
ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1);
lastFrame++;
}
}
else if (frametime < 0.0)
{
if (currAnim.LowFrame > Math.Floor(FrameNumber))
{
double frameOffset = FrameNumber - currAnim.LowFrame;
if (frameOffset > 0.0)
frameOffset = 0.0;
if (Math.Abs(framerate) > FrameOps.FEpsilon)
frameTimeElapsed = frameOffset / framerate;
FrameNumber = currAnim.LowFrame;
animDone = true;
}
while (Math.Floor(FrameNumber) < lastFrame)
{
if (frame is not null)
{
var pos = currAnim.GetPosFrame(lastFrame);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
if (Math.Abs(framerate) > FrameOps.FEpsilon)
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
}
ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1);
lastFrame--;
}
}
else
{
if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon)
ApplyPhysics(frame, timeElapsed, timeElapsed);
return;
}
if (!animDone)
return;
// AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) —
// fire only when the consumed head is not already the cyclic
// tail (retail 0x00525943-0x00525968).
if (HookObj is not null
&& _animList.First is not null
&& !ReferenceEquals(_animList.First, _firstCyclic))
{
HookObj.AddAnimDoneHook();
}
AdvanceToNextAnimation(timeElapsed, frame);
timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry
}
}
/// <summary>
/// <c>CSequence::advance_to_next_animation</c> (0x005252b0, §23): four
/// pose operations per transition — un-apply the outgoing node's pose
/// (subtract1 + residual physics), step (forward wraps to
/// <c>first_cyclic</c>; REVERSE wraps to the LIST TAIL — asymmetric by
/// design), reseed <see cref="FrameNumber"/> from the incoming node's
/// direction-aware boundary, apply the incoming pose (combine +
/// physics). Pose ops run in BOTH directions (ACE's framerate-sign
/// gates are ACE-isms; the decomp is unconditional apart from the
/// degenerate-framerate guard).
/// </summary>
public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
{
if (_currAnim is null)
return;
var outgoing = _currAnim.Value;
// (a) un-apply the outgoing node's pose at the CURRENT FrameNumber.
if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon)
{
var pos = outgoing.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Subtract1(frame, pos);
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
}
// (b) step; (c) reseed FrameNumber from the incoming boundary.
if (timeElapsed >= 0.0)
{
_currAnim = _currAnim.Next ?? _firstCyclic;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetStartingFrame();
}
else
{
_currAnim = _currAnim.Previous ?? _animList.Last;
if (_currAnim is null)
return;
FrameNumber = _currAnim.Value.GetEndingFrame();
}
// (d) apply the incoming node's pose at the new FrameNumber.
var incoming = _currAnim.Value;
if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon)
{
var pos = incoming.GetPosFrame((int)FrameNumber);
if (pos is not null)
FrameOps.Combine(frame, pos);
ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
}
}
/// <summary>
/// <c>CSequence::execute_hooks</c> (0x00524830, §18): QUEUE the part
/// frame's hooks whose direction is Both (0) or matches the playback
/// direction (+1 forward / 1 backward) into the host. Null part frame
/// guarded (retail has a latent null-deref here — documented safe
/// divergence, G18).
/// </summary>
private void ExecuteHooks(AnimationFrame? partFrame, int direction)
{
if (partFrame is null || HookObj is null)
return;
foreach (var hook in partFrame.Hooks)
{
if (hook is null)
continue;
int dir = (int)hook.Direction;
if (dir == 0 || dir == direction)
HookObj.AddAnimHook(hook);
}
}
// ── internal cursors for P4 (update_internal operates on nodes) ─────
internal LinkedListNode<AnimSequenceNode>? CurrAnimNode
{
get => _currAnim;
set => _currAnim = value;
}
internal LinkedListNode<AnimSequenceNode>? FirstCyclicNode => _firstCyclic;
internal LinkedList<AnimSequenceNode> AnimList => _animList;
}

View file

@ -0,0 +1,120 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — verbatim port of retail's <c>ConstraintManager</c> (acclient.h:31529,
/// struct #3467; decomp 0x00556090-0x005562xx,
/// <c>r5-constraintmanager-decomp.md</c>). The server-position <b>rubber-band
/// leash</b>: while the owning object is in ground contact it progressively
/// resists movement past a start→max distance band from a pinned anchor, and
/// hard-clamps at the max. Read as a jump gate by
/// <c>CMotionInterp::jump_is_allowed</c> (block jump while
/// <see cref="IsFullyConstrained"/>).
///
/// <para><b>Arming is UNPORTED in acdream (R5).</b> Retail arms the leash ONLY
/// from <c>SmartBox::HandleReceivedPosition</c> (on every inbound server
/// position packet) with two constants from
/// <c>CPhysicsObj::GetStart/MaxConstraintDistance</c> whose values BN elided
/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation
/// is not SmartBox, so nothing calls <see cref="ConstrainTo"/> — the leash
/// stays disarmed and <see cref="IsFullyConstrained"/> stays false, matching
/// register TS-35's current stub behavior. The class is ported for structural
/// completeness of <see cref="PositionManager"/>; the leash-arming port + the
/// two unknown constants are a deferred issue (port-plan §Constraint scope).</para>
/// </summary>
public sealed class ConstraintManager
{
private readonly IPhysicsObjHost _host;
/// <summary>+0x04 retail <c>is_constrained</c>.</summary>
public bool IsConstrained { get; private set; }
/// <summary>+0x08 retail <c>constraint_pos_offset</c> — recomputed every
/// <see cref="AdjustOffset"/> as the LENGTH of that tick's step (NOT the
/// distance to the anchor — see the ACE correctness note, port-plan §2b);
/// the next tick's contact branch compares it against start/max.</summary>
public float ConstraintPosOffset { get; private set; }
/// <summary>+0x0c retail <c>constraint_pos</c> — the leash anchor. Stored
/// by <see cref="ConstrainTo"/>, never read by <see cref="AdjustOffset"/>
/// (retail + ACE — write-only in this class).</summary>
public Position ConstraintPos { get; private set; }
/// <summary>+0x48 retail <c>constraint_distance_start</c> — the near edge
/// of the brake band.</summary>
public float ConstraintDistanceStart { get; private set; }
/// <summary>+0x4c retail <c>constraint_distance_max</c> — the far edge
/// (full clamp).</summary>
public float ConstraintDistanceMax { get; private set; }
public ConstraintManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>
/// Retail <c>ConstraintManager::ConstrainTo</c> (0x00556240). Pin the leash
/// anchor and band; initialize <see cref="ConstraintPosOffset"/> to the
/// CURRENT distance from anchor to the mover (the leash starts already
/// extended to wherever the object is, not at zero).
/// </summary>
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
{
IsConstrained = true;
ConstraintPos = anchor;
ConstraintDistanceStart = startDistance;
ConstraintDistanceMax = maxDistance;
ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin);
}
/// <summary>Retail <c>ConstraintManager::UnConstrain</c> (0x005560c0) —
/// clears the constrained flag only (leaves the band/anchor/offset stale
/// until the next <see cref="ConstrainTo"/>).</summary>
public void UnConstrain() => IsConstrained = false;
/// <summary>
/// Retail <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0):
/// <c>constraint_distance_max * 0.9 &lt; constraint_pos_offset</c> — the
/// object counts as fully constrained once it has strained past 90 % of the
/// max leash. Read by <c>jump_is_allowed</c> to block jumps. Always false
/// while the leash is disarmed (acdream never arms it — see class note).
/// </summary>
public bool IsFullyConstrained()
=> ConstraintDistanceMax * 0.9f < ConstraintPosOffset;
/// <summary>
/// Retail <c>ConstraintManager::adjust_offset</c> (0x00556180). The last
/// stage of <see cref="PositionManager.AdjustOffset"/>'s chain — clamps the
/// ALREADY-composed per-tick <paramref name="offset"/> (interp + sticky)
/// while grounded, then records its length for the next tick. No-op unless
/// <see cref="IsConstrained"/>. See port-plan §2b.
/// </summary>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
_ = quantum; // retail body doesn't use the quantum directly.
if (!IsConstrained)
return;
if (_host.InContact) // transient_state & 1 — clamp only while grounded.
{
if (ConstraintPosOffset < ConstraintDistanceMax)
{
if (ConstraintPosOffset > ConstraintDistanceStart)
{
// Linear brake taper: 1.0 just past start → 0.0 at max.
float taper = (ConstraintDistanceMax - ConstraintPosOffset)
/ (ConstraintDistanceMax - ConstraintDistanceStart);
offset.Origin *= taper;
}
}
else
{
offset.Origin = Vector3.Zero; // past max — fully pinned.
}
}
// Unconditional (grounded OR airborne): track this tick's step length.
ConstraintPosOffset = offset.Origin.Length();
}
}

View file

@ -0,0 +1,82 @@
using System;
using System.Numerics;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R1-P3 — verbatim ports of retail's <c>Frame</c> rotation helpers used by
/// the CSequence physics path (oracle: raw decomp reads 2026-07-02).
///
/// <list type="bullet">
/// <item><c>Frame::grotate</c> (0x005357a0, pc:319782): build the
/// axis-angle quaternion from a GLOBAL rotation vector (angle = |v|,
/// axis = v/|v|, half-angle sin/cos) and PREMULTIPLY it onto the frame's
/// orientation — the incremental rotation is applied in world space.
/// Rotations with |v|² &lt; F_EPSILON² are skipped.</item>
/// <item><c>Frame::rotate</c> (0x004525b0, pc:91477): map the LOCAL
/// rotation vector through the frame's local→global rotation
/// (m_fl2gv — our quaternion), then <c>grotate</c>.</item>
/// </list>
/// </summary>
public static class FrameOps
{
/// <summary>Retail F_EPSILON (0.000199999995f); grotate gates on its
/// square against |v|².</summary>
public const float FEpsilon = 0.000199999995f;
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
public static void GRotate(Frame frame, Vector3 rotationGlobal)
{
float magSq = rotationGlobal.LengthSquared();
if (magSq < FEpsilon * FEpsilon)
return;
float angle = MathF.Sqrt(magSq);
float invMag = 1f / angle;
float half = angle * 0.5f;
float s = MathF.Sin(half);
float c = MathF.Cos(half);
// Retail's set_rotate receives the raw Hamilton product r ⊗ q
// (r = the new axis-angle quat, q = the current orientation) —
// rotation applied in GLOBAL space. System.Numerics
// Quaternion.Multiply(r, q) is that product with
// Transform(v, r*q) == r-applied-after-q. set_rotate renormalizes.
var r = new Quaternion(
rotationGlobal.X * s * invMag,
rotationGlobal.Y * s * invMag,
rotationGlobal.Z * s * invMag,
c);
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
}
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
/// global through the orientation, then <see cref="GRotate"/>.</summary>
public static void Rotate(Frame frame, Vector3 rotationLocal)
=> GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation));
/// <summary>
/// <c>Frame::combine</c> as used by the CSequence pose path (ACE
/// <c>AFrame.Combine</c>, verified against the pre-R1 acdream port):
/// apply the pose — origin += rotate(pos.Origin by orientation), then
/// orientation ∘= pos.Orientation.
/// </summary>
public static void Combine(Frame frame, Frame pos)
{
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
}
/// <summary>
/// <c>Frame::subtract1</c> — un-apply the pose: orientation ∘=
/// conj(pos.Orientation) FIRST, then origin = rotate(pos.Origin by the
/// UPDATED orientation) (inverse order of <see cref="Combine"/>).
/// </summary>
public static void Subtract1(Frame frame, Frame pos)
{
frame.Orientation = Quaternion.Normalize(
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
}
}

View file

@ -0,0 +1,100 @@
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 seam — the acdream stand-in for retail's <c>CPhysicsObj</c> as seen BY
/// its owned managers. Retail's <c>StickyManager</c> / <c>ConstraintManager</c>
/// / <c>TargetManager</c> each hold a raw <c>physics_obj</c> pointer and call
/// back through it (position/velocity/radius accessors, target-tracking
/// registration, the <c>HandleUpdateTarget</c> fan-out) and — for the voyeur
/// system — resolve OTHER physics objects via <c>CObjectMaint::GetObjectA</c>
/// and drive their <c>add_voyeur</c> / <c>receive_target_update</c> /
/// <c>remove_voyeur</c> entry points. This interface is that back-pointer.
///
/// <para>The App layer implements one host per entity (a remote
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
/// <see cref="GetObjectA"/> is backed by the App's live entity table
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
/// cross-entity delivery path.</para>
/// </summary>
public interface IPhysicsObjHost
{
/// <summary>Retail <c>physics_obj-&gt;id</c> — this object's guid.</summary>
uint Id { get; }
/// <summary>Retail <c>physics_obj-&gt;m_position</c> — world-space cell +
/// frame (acdream seams carry WORLD space; see the MoveToManager binding
/// note).</summary>
Position Position { get; }
/// <summary>Retail <c>CPhysicsObj::get_velocity</c>.</summary>
Vector3 Velocity { get; }
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the mover's cylinder
/// radius.</summary>
float Radius { get; }
/// <summary>Retail <c>physics_obj-&gt;transient_state &amp; 1</c> — the
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
bool InContact { get; }
/// <summary>Retail <c>CPhysicsObj::get_minterp()-&gt;get_max_speed()</c> —
/// the mover's max locomotion speed, or <c>null</c> if it has no motion
/// interpreter yet (StickyManager falls back to a 15.0 constant).</summary>
float? MinterpMaxSpeed { get; }
/// <summary>Retail <c>Timer::cur_time</c> — the wall/game clock (seconds).
/// Drives the sticky 1 s timeout and target 10 s staleness deadlines.</summary>
double CurTime { get; }
/// <summary>Retail <c>PhysicsTimer::curr_time</c> — the physics-tick clock
/// (seconds). Drives <c>TargetManager::HandleTargetting</c>'s 0.5 s
/// throttle. Retail uses a DIFFERENT clock here than <see cref="CurTime"/>;
/// acdream may bind both to the same source.</summary>
double PhysicsTimerTime { get; }
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
/// physics object by guid, or <c>null</c> if not currently known/visible.
/// The cross-entity seam for the voyeur round-trip and sticky live-target
/// resolve.</summary>
IPhysicsObjHost? GetObjectA(uint id);
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
/// Called from <see cref="TargetManager.ReceiveUpdate"/> and the timeout
/// path.</summary>
void HandleUpdateTarget(TargetInfo info);
/// <summary>Retail <c>CPhysicsObj::interrupt_current_movement</c> →
/// <c>MovementManager::CancelMoveTo(0x36)</c>.</summary>
void InterruptCurrentMovement();
/// <summary>Retail <c>CPhysicsObj::set_target(ctx, objId, radius,
/// quantum)</c> → <see cref="TargetManager.SetTarget"/> (lazily creating
/// the TargetManager). Called by <c>StickyManager::StickTo</c> and
/// <c>MoveToManager</c>'s object-move entry points.</summary>
void SetTarget(uint contextId, uint objectId, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
/// <see cref="TargetManager.ClearTarget"/>.</summary>
void ClearTarget();
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
void ReceiveTargetUpdate(TargetInfo info);
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
void AddVoyeur(uint watcherId, float radius, double quantum);
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
/// watcher unsubscribes.</summary>
void RemoveVoyeur(uint watcherId);
}

View file

@ -0,0 +1,40 @@
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// Mutable stand-in for retail's <c>Frame</c> when it is used as the per-tick
/// <b>delta accumulator</b> that <c>PositionManager::adjust_offset</c> and its
/// three sub-managers (Interpolation / Sticky / Constraint) write into
/// (retail arg2, e.g. <c>StickyManager::adjust_offset</c> 0x00555430,
/// <c>ConstraintManager::adjust_offset</c> 0x00556180). acdream's
/// <see cref="CellFrame"/> is an immutable record — retail's per-tick math
/// mutates <c>m_fOrigin</c> in place across the interp→sticky→constraint chain
/// (each sub-manager composes on top of the previous one's write), so the
/// accumulator needs a mutable shape.
///
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
/// position delta, in the mover's LOCAL frame after
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
/// (P5 convention, degrees).</para>
/// </summary>
public sealed class MotionDeltaFrame
{
/// <summary>Retail <c>m_fOrigin</c> — the accumulated per-tick position
/// delta (mover-local frame).</summary>
public Vector3 Origin;
/// <summary>Retail <c>Frame</c> rotation — carries the
/// <c>Frame::set_heading</c> output.</summary>
public Quaternion Orientation = Quaternion.Identity;
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
public float GetHeading() => MoveToMath.GetHeading(Orientation);
/// <summary>Retail <c>Frame::set_heading(headingDeg)</c> — pure
/// yaw-about-Z setter (P5 compass degrees).</summary>
public void SetHeading(float headingDeg) =>
Orientation = MoveToMath.SetHeading(Orientation, headingDeg);
}

View file

@ -0,0 +1,30 @@
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R3-W1 — verbatim port of retail's <c>CMotionInterp::MotionNode</c>
/// (<c>acclient.h:53293</c>, struct #5857):
/// <code>
/// struct __cppobj CMotionInterp::MotionNode : LListData
/// {
/// unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr)
/// unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag
/// unsigned int jump_error_code; // +0xc
/// };
/// </code>
/// This is the node type queued onto <c>CMotionInterp::pending_motions</c>
/// by <c>add_to_queue</c> (0x00527b80, docs/research/2026-07-02-r3-motioninterp/
/// r3-motioninterp-decomp.md §1a) and consumed head-first, unconditionally,
/// by <c>MotionDone</c> (0x00527ec0, §1c) / <c>HandleExitWorld</c> (0x00527f30,
/// §1d). W1 ports only the shape — the queue itself (<c>PendingMotions</c>,
/// <c>add_to_queue</c>, <c>MotionDone</c>) is R3-W2 scope (r3-port-plan.md §3).
/// </summary>
/// <param name="ContextId">Retail <c>context_id</c> (+4) — the
/// <c>MovementParameters.ContextId</c> that produced this node.</param>
/// <param name="Motion">Retail <c>motion</c> (+8) — the applied motion id.
/// Bit <c>0x10000000</c> marks an "action-class" motion; <c>MotionDone</c>
/// only pops the state action-FIFO head when this bit is set on the queue
/// head it is consuming.</param>
/// <param name="JumpErrorCode">Retail <c>jump_error_code</c> (+0xc) — the
/// <c>motion_allows_jump</c> result computed at enqueue time. Peeked by
/// <c>jump_is_allowed</c>'s pending-head short-circuit (W0-pins A2).</param>
public readonly record struct MotionNode(uint ContextId, uint Motion, uint JumpErrorCode);

View file

@ -0,0 +1,122 @@
using System.Collections.Generic;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// One entry of a <see cref="MotionState"/> chain — retail's
/// <c>MotionList</c> node (12 bytes: motion + speed_mod + next;
/// acclient.h, r2-motiontable-decomp.md §14). One struct, two independent
/// chain disciplines: the modifier chain is a PUSH-FRONT STACK, the action
/// chain a TAIL-APPEND FIFO.
/// </summary>
public sealed class MotionEntry
{
public uint Motion;
public float SpeedMod = 1f;
public MotionEntry(uint motion, float speedMod)
{
Motion = motion;
SpeedMod = speedMod;
}
}
/// <summary>
/// R2-Q1 — verbatim port of retail's <c>MotionState</c> (acclient.h:31081;
/// r2-motiontable-decomp.md §13): the motion-table-facing state block —
/// current style / substate / substate_mod plus the modifier stack and the
/// action FIFO that <c>CMotionTable::GetObjectSequence</c>,
/// <c>StopSequenceMotion</c>, <c>re_modify</c>, and
/// <c>MotionTableManager::AnimationDone</c> read and write.
/// </summary>
public sealed class MotionState
{
/// <summary>Current style/stance command word (0 = unset; ctor 0x00525fd0).</summary>
public uint Style;
/// <summary>Current base substate command word (0 = unset).</summary>
public uint Substate;
/// <summary>Speed modifier of the base substate (default 1.0).</summary>
public float SubstateMod = 1f;
private readonly LinkedList<MotionEntry> _modifiers = new(); // modifier_head — push-front stack
private readonly LinkedList<MotionEntry> _actions = new(); // action_head/tail — FIFO
public MotionState()
{
}
/// <summary>
/// Deep copy (retail copy ctor 0x00526790; Q0-pins A4-#5): both chains
/// CLONED — <c>re_modify</c>'s snapshot is a termination bound, never
/// shared state.
/// </summary>
public MotionState(MotionState other)
{
Style = other.Style;
Substate = other.Substate;
SubstateMod = other.SubstateMod;
foreach (var m in other._modifiers)
_modifiers.AddLast(new MotionEntry(m.Motion, m.SpeedMod));
foreach (var a in other._actions)
_actions.AddLast(new MotionEntry(a.Motion, a.SpeedMod));
}
/// <summary>Modifier chain in retail order (newest first — push-front).</summary>
public IEnumerable<MotionEntry> Modifiers => _modifiers;
/// <summary>Action FIFO in retail order (oldest first).</summary>
public IEnumerable<MotionEntry> Actions => _actions;
/// <summary><c>add_modifier_no_check</c> (0x00525ff0): unconditional
/// push-front.</summary>
public void AddModifierNoCheck(uint motion, float speedMod)
=> _modifiers.AddFirst(new MotionEntry(motion, speedMod));
/// <summary>
/// <c>add_modifier</c> (0x00526340): refuse when the motion is already
/// a modifier (caller must stop-then-re-add to refresh) or already IS
/// the current base substate.
/// </summary>
public bool AddModifier(uint motion, float speedMod)
{
for (var n = _modifiers.First; n is not null; n = n.Next)
if (n.Value.Motion == motion)
return false;
if (Substate == motion)
return false;
AddModifierNoCheck(motion, speedMod);
return true;
}
/// <summary><c>remove_modifier</c> (0x00526040) — by node identity
/// (retail's node+predecessor pair collapses in a managed list).</summary>
public void RemoveModifier(MotionEntry entry)
{
_modifiers.Remove(entry);
}
/// <summary><c>clear_modifiers</c> (0x00526070).</summary>
public void ClearModifiers() => _modifiers.Clear();
/// <summary><c>add_action</c> (0x005260a0): tail append.</summary>
public void AddAction(uint motion, float speedMod)
=> _actions.AddLast(new MotionEntry(motion, speedMod));
/// <summary><c>remove_action_head</c> (0x00526120): pop the FIFO head,
/// returning its motion id (0 when empty).</summary>
public uint RemoveActionHead()
{
var head = _actions.First;
if (head is null)
return 0;
_actions.RemoveFirst();
return head.Value.Motion;
}
/// <summary><c>clear_actions</c> (0x005260f0).</summary>
public void ClearActions() => _actions.Clear();
}

View file

@ -0,0 +1,89 @@
using System;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R2-Q5 — the <see cref="IInterpretedMotionSink"/> that dispatches the
/// CMotionInterp funnel's retail-ordered motion events STRAIGHT into the
/// entity's motion-table stack via
/// <see cref="AnimationSequencer.PerformMovement"/> (lazy initialize_state +
/// <c>MotionTableManager::PerformMovement</c> 0x0051c0b0).
///
/// <para>
/// This replaces the App-side <c>RemoteMotionSink</c> (deleted): no axis
/// collection, no single-cycle priority pick, no Commit pass, no HasCycle
/// probe, no Run→Walk→Ready missing-cycle chain — retail's
/// <c>GetObjectSequence</c> (0x00522860) + <c>is_allowed</c> decide what
/// plays. Forward locomotion installs the substate cycle (Branch 2);
/// sidesteps resolve by the dat (cycle when authored, else modifier);
/// turns are Branch-4 physics-only modifiers blended over the substate via
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
/// AP-73 row approximated with one cycle.
/// </para>
///
/// <para>
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
/// interim ObservedOmega seam: the App seeds the remote body's angular
/// velocity from the wire turn so rotation starts the same tick (retail
/// rotates the body from the sequence omega inside the per-tick
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
/// the dispatch so a consumer sees the seed even if the dat lacks the
/// modifier entry.
/// </para>
/// </summary>
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
{
private readonly AnimationSequencer _sequencer;
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
/// TurnLeft arrives either as the explicit 0x0E command or as
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
public Action<uint, float>? TurnApplied { get; set; }
/// <summary>Turn-class stop observed.</summary>
public Action? TurnStopped { get; set; }
public MotionTableDispatchSink(AnimationSequencer sequencer)
{
ArgumentNullException.ThrowIfNull(sequencer);
_sequencer = sequencer;
}
private static bool IsTurn(uint motion)
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
public bool ApplyMotion(uint motion, float speed)
{
if (IsTurn(motion))
TurnApplied?.Invoke(motion, speed);
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
return result == MotionTableManagerError.Success;
}
public bool StopMotion(uint motion)
{
if (IsTurn(motion))
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
return result == MotionTableManagerError.Success;
}
/// <summary>
/// R4-V5 wedge fix — retail <c>CPartArray::StopCompletelyInternal</c>
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
/// The manager stops everything and queues its Ready-sentinel
/// pending_animations entry UNCONDITIONALLY — the completable partner
/// of the interp's A9 pending_motions node. A full stop ends any turn
/// cycle, so the ObservedOmega seam is notified like
/// <see cref="StopMotion"/>'s turn-class branch.
/// </summary>
public bool StopCompletely()
{
TurnStopped?.Invoke();
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
return result == MotionTableManagerError.Success;
}
}

View file

@ -0,0 +1,508 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.Core.Physics;
namespace AcDream.Core.Physics.Motion;
// ─────────────────────────────────────────────────────────────────────────────
// R2-Q3 — verbatim port of retail's MotionTableManager (the pending-animation
// queue + tick-countdown completion machinery sitting ABOVE CMotionTable).
// Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11
// (all addresses/line numbers below point there); ambiguity pins:
// docs/research/2026-07-02-r2-motiontable/Q0-pins.md; plan:
// docs/research/2026-07-02-r2-motiontable/r2-port-plan.md §3 Q3 + §4 (the
// MotionDone -> R3 boundary contract).
//
// Struct layout (acclient.h:57614/31092/31097; decomp §11 offset map):
// physics_obj @0x0, table @0x4, state @0x8 (24 bytes), animation_counter
// @0x20, pending_animations {head_,tail_} @0x24/0x28. sizeof == 0x2c.
// C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target
// as an injectable seam (IMotionDoneSink, part A of this file) since R3 binds
// the real MotionInterpreter/MovementManager consumer.
// ─────────────────────────────────────────────────────────────────────────────
/// <summary>
/// The R2 seam standing in for retail's <c>CPhysicsObj::MotionDone</c> — the
/// callback <see cref="MotionTableManager.AnimationDone"/> and
/// <see cref="MotionTableManager.CheckForCompletedMotions"/> fire for every
/// pending-queue entry whose tick countdown reaches zero. R2 leaves this
/// consumer-injectable (register row: MotionDone observed-not-consumed,
/// r2-port-plan.md §4); R3 binds it to <c>MotionInterpreter.MotionDone</c>
/// (pops <c>CMotionInterp.pending_motions</c>, recomputes IsAnimating).
/// </summary>
public interface IMotionDoneSink
{
/// <summary>
/// <c>CPhysicsObj::MotionDone(motion, success)</c>. <paramref name="success"/>
/// is the CALLER's flag: true from the real per-tick animation-done hook
/// (<see cref="MotionTableManager.AnimationDone"/> invoked with
/// <c>success:true</c>), false from the enter/exit-world drains,
/// hardcoded true from <see cref="MotionTableManager.CheckForCompletedMotions"/>.
/// </summary>
void MotionDone(uint motion, bool success);
}
/// <summary>
/// One <c>pending_animations</c> queue node — retail's
/// <c>MotionTableManager::AnimNode</c> (acclient.h:57614; 16 bytes:
/// dllist_next/prev + motion + num_anims). <c>NumAnims</c> doubles as a
/// RELATIVE tick-duration countdown once queued (not an absolute deadline —
/// decomp §11 <c>AnimationDone</c> note), not an anim-array length.
///
/// Register note (reusing the R1 AD-34 wording): retail's intrusive DLList
/// is ported as a managed <see cref="LinkedList{T}"/> of <see cref="PendingMotion"/>;
/// node identity semantics (the tail-anchored backward scan in
/// <see cref="MotionTableManager.RemoveRedundantLinks"/>, the in-place
/// truncation in <see cref="MotionTableManager.TruncateAnimationList"/>) are
/// preserved via <see cref="LinkedListNode{T}"/> references rather than raw
/// prev/next pointers.
/// </summary>
public sealed class PendingMotion
{
public uint Motion;
public uint NumAnims;
public PendingMotion(uint motion, uint numAnims)
{
Motion = motion;
NumAnims = numAnims;
}
}
/// <summary>
/// Retail's <c>MotionTableManager</c> (0x0051bxxx region) — owns the
/// pending-animation queue and the tick-countdown completion machinery that
/// sits ABOVE <see cref="CMotionTable"/>'s pure selection logic.
/// <see cref="PerformMovement"/> is the single chokepoint between a wire-level
/// <see cref="MovementStruct"/> and the motion-table state machine (decomp
/// §11 <c>PerformMovement</c> 0x0051c0b0).
/// </summary>
public sealed class MotionTableManager
{
// Motion-id class bits (decomp §1 / §15).
private const uint CycleClassBit = 0x40000000u;
private const uint ModifierClassBit = 0x20000000u;
private const uint ActionClassBit = 0x10000000u;
/// <summary>
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
/// CYCLE-class tail scan (decomp §11/§15 literal "0xb0000000"). Bit
/// decomposition: <c>0xb0000000 == 0x80000000 (style) | 0x20000000
/// (modifier) | 0x10000000 (action)</c> — i.e. STYLE|MODIFIER|ACTION,
/// deliberately EXCLUDING the cycle-class bit (0x40000000) itself. The
/// decomp's own prose gloss ("cycle|action|..." mask) is imprecise; the
/// literal hex constant (ported verbatim below) is the ground truth —
/// an intervening node of a DIFFERENT cycle does not block a cycle-tail
/// collapse (two cycles naturally supersede via the base-cycle rebuild
/// mechanism), but an intervening style-change/modifier/action does.
/// </summary>
private const uint CycleTailBlockMask = 0xb0000000u;
/// <summary>
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
/// STYLE-class tail scan (decomp §11/§15 literal "0x70000000"). Bit
/// decomposition: <c>0x70000000 == 0x40000000 (cycle) | 0x20000000
/// (modifier) | 0x10000000 (action)</c> — CYCLE|MODIFIER|ACTION,
/// deliberately EXCLUDING the style-class top bit (0x80000000) itself
/// (the match test is already exact-equality on the full style id, so a
/// DIFFERENT style in between doesn't need a separate block check here).
/// </summary>
private const uint StyleTailBlockMask = 0x70000000u;
/// <summary>Sentinel "stop-completely / default-state-installed" motion id
/// (decomp §15 "0x41000003").</summary>
public const uint ReadySentinel = 0x41000003u;
private readonly CMotionTable? _table;
private readonly MotionState _state;
private readonly CSequence _sequence;
private readonly IMotionDoneSink _sink;
private readonly LinkedList<PendingMotion> _pendingAnimations = new(); // pending_animations
private int _animationCounter; // animation_counter (@0x20)
/// <summary>Current motion state — retail's embedded <c>state</c> member
/// (@0x8, 24 bytes). Exposed for callers that need to read style/substate.</summary>
public MotionState State => _state;
/// <summary>
/// <c>Create</c> 0x0051bc50 (@290510). Retail's static factory
/// zero-initializes <c>physics_obj</c>/<c>table</c>, placement-constructs
/// <c>state</c>, zeros <c>animation_counter</c>, and nulls
/// <c>pending_animations</c>'s head/tail — all of which the C# field
/// initializers below already give for free. <paramref name="table"/> may
/// be null (retail: "no motion table loaded" — <see cref="PerformMovement"/>
/// returns error 7 in that case, matching <c>SetMotionTableID</c> never
/// having been called).
/// </summary>
public MotionTableManager(CMotionTable? table, MotionState state, CSequence sequence, IMotionDoneSink sink)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(sequence);
ArgumentNullException.ThrowIfNull(sink);
_table = table;
_state = state;
_sequence = sequence;
_sink = sink;
}
/// <summary>Read-only inspection surface for tests: the pending queue in
/// head-to-tail order.</summary>
public IEnumerable<PendingMotion> PendingAnimations => _pendingAnimations;
/// <summary>Read-only inspection surface for tests: the current tick
/// countdown accumulator.</summary>
public int AnimationCounter => _animationCounter;
// ── add_to_queue / remove_redundant_links / truncate_animation_list ────
/// <summary>
/// <c>add_to_queue</c> 0x0051bfe0 (@290854): append a new node to the
/// tail of <c>pending_animations</c>, then opportunistically call
/// <see cref="RemoveRedundantLinks"/> to collapse any now-superseded
/// earlier entries.
/// </summary>
public void AddToQueue(uint motion, uint ticks)
{
_pendingAnimations.AddLast(new PendingMotion(motion, ticks));
RemoveRedundantLinks();
}
/// <summary>
/// <c>remove_redundant_links</c> 0x0051bf20 (@290771): retail's
/// TAIL-ANCHORED SINGLE SCAN (ported verbatim — NOT ACE's restructured
/// outer loop, per r2-port-plan.md §3 Q3).
///
/// 1. Skip backward over trailing zero-<c>NumAnims</c> nodes (already
/// neutered / instant entries). If the list bottoms out, return.
/// 2. If the effective tail's motion is CYCLE-class-and-not-modifier-class
/// (<c>&amp;0x40000000 != 0 &amp;&amp; &amp;0x20000000 == 0</c>): scan backward for an
/// EARLIER node with the SAME motion id AND non-zero <c>NumAnims</c>.
/// Blocked (abort, no truncation) by any intervening non-zero node
/// whose motion matches the 0xb0000000 class mask.
/// 3. Else if the effective tail's motion is STYLE-class
/// (<c>(int)motion &lt; 0</c>): same backward scan, EXACT match (no
/// additional class requirement on the match itself), blocked by any
/// intervening non-zero node matching the WIDER 0x70000000 class mask.
/// 4. On a match, <see cref="TruncateAnimationList"/> from the matched
/// node's successor through the tail (matched node itself untouched).
/// </summary>
public void RemoveRedundantLinks()
{
var tail = _pendingAnimations.Last;
if (tail is null)
return;
// Step 1: skip trailing zero-tick nodes.
while (tail is not null && tail.Value.NumAnims == 0)
{
tail = tail.Previous;
}
if (tail is null)
return;
uint motion = tail.Value.Motion;
if ((motion & CycleClassBit) != 0 && (motion & ModifierClassBit) == 0)
{
// CYCLE-class (not modifier-class) tail: match = same motion AND
// non-zero NumAnims; block = non-zero AND matches 0xb0000000.
var scan = tail.Previous;
LinkedListNode<PendingMotion>? matched = null;
while (scan is not null)
{
if (scan.Value.Motion == motion && scan.Value.NumAnims != 0)
{
matched = scan;
break;
}
if (scan.Value.NumAnims != 0 && (scan.Value.Motion & CycleTailBlockMask) != 0)
return; // blocked by an intervening "important" non-zero node
scan = scan.Previous;
}
if (matched is not null)
TruncateAnimationList(matched);
}
else if ((int)motion < 0)
{
// STYLE-class tail: exact-equality match; block mask is wider
// (0x70000000) with no additional match-side class requirement.
var scan = tail.Previous;
LinkedListNode<PendingMotion>? matched = null;
while (scan is not null)
{
if (scan.Value.Motion == motion)
{
matched = scan;
break;
}
if (scan.Value.NumAnims != 0 && (scan.Value.Motion & StyleTailBlockMask) != 0)
return;
scan = scan.Previous;
}
if (matched is not null)
TruncateAnimationList(matched);
}
// else: modifier-class or action-class tail -> no redundancy scan (retail: neither branch taken).
}
/// <summary>
/// <c>truncate_animation_list</c> 0x0051bca0 (@290533): walk
/// <c>pending_animations.tail_</c> BACKWARD toward (but not including)
/// <paramref name="stopAtExclusive"/>, zeroing each node's
/// <c>NumAnims</c> tick countdown IN PLACE (nodes stay queued — retail
/// does NOT unlink them here) and accumulating the total ticks removed,
/// then strips that many ticks' worth of link animations from the live
/// <see cref="CSequence"/> via <see cref="CSequence.RemoveLinkAnimations"/>.
/// </summary>
private void TruncateAnimationList(LinkedListNode<PendingMotion> stopAtExclusive)
{
uint removedTicks = 0;
var node = _pendingAnimations.Last;
while (!ReferenceEquals(node, stopAtExclusive))
{
if (node is null)
return; // stopAtExclusive wasn't actually in the list -> abort quietly
removedTicks += node.Value.NumAnims;
node.Value.NumAnims = 0;
node = node.Previous;
}
_sequence.RemoveLinkAnimations((int)removedTicks);
}
// ── AnimationDone / CheckForCompletedMotions / UseTime ─────────────────
/// <summary>
/// <c>AnimationDone</c> 0x0051bce0 (@290558): advance the animation clock
/// by one tick and fire <see cref="IMotionDoneSink.MotionDone"/> for every
/// queued motion whose relative-duration countdown has elapsed.
/// <c>NumAnims</c> on a queue node is a RELATIVE tick-duration (not an
/// absolute deadline) — subtracted from the running counter after firing,
/// forming a decrementing countdown chain (one <c>AnimationDone</c> call
/// can pop MULTIPLE queued entries via counter rollover).
/// </summary>
/// <param name="success">Passed straight through to
/// <see cref="IMotionDoneSink.MotionDone"/> for every entry popped this
/// call.</param>
public void AnimationDone(bool success)
{
var head = _pendingAnimations.First;
if (head is null)
return;
_animationCounter += 1;
while (head is not null && head.Value.NumAnims <= _animationCounter)
{
if ((head.Value.Motion & ActionClassBit) != 0)
_state.RemoveActionHead();
_sink.MotionDone(head.Value.Motion, success);
_animationCounter -= (int)head.Value.NumAnims;
_pendingAnimations.RemoveFirst();
head = _pendingAnimations.First;
}
// Drained-list counter reset: avoid drift once the queue is empty.
if (_animationCounter != 0 && head is null)
_animationCounter = 0;
}
/// <summary>
/// <c>CheckForCompletedMotions</c> 0x0051be00 (@290645): pop every head
/// node ALREADY at <c>NumAnims == 0</c> (zero-tick entries, or ones
/// neutered by <see cref="TruncateAnimationList"/>). Unlike
/// <see cref="AnimationDone"/>: no counter increment, no counter
/// decrement, success is hardcoded <c>true</c>.
/// </summary>
public void CheckForCompletedMotions()
{
var head = _pendingAnimations.First;
if (head is null)
return;
while (head is not null && head.Value.NumAnims == 0)
{
if ((head.Value.Motion & ActionClassBit) != 0)
_state.RemoveActionHead();
_sink.MotionDone(head.Value.Motion, true);
_pendingAnimations.RemoveFirst();
head = _pendingAnimations.First;
}
}
/// <summary><c>UseTime</c> 0x0051bfd0 (@290845): per-frame entry point,
/// tailcalls <see cref="CheckForCompletedMotions"/>.</summary>
public void UseTime() => CheckForCompletedMotions();
// ── initialize_state / HandleEnterWorld / HandleExitWorld ──────────────
/// <summary>
/// <c>initialize_state</c> 0x0051c030 (@290875): install the motion
/// table's baseline state (<see cref="CMotionTable.SetDefaultState"/>) and
/// queue the <see cref="ReadySentinel"/> ("0x41000003" — initial
/// default-state-installed marker) with the resulting tick count, then
/// opportunistically collapse redundant links.
/// </summary>
public void InitializeState()
{
uint outTicks = 0;
if (_table is not null)
{
_table.SetDefaultState(_state, _sequence, out outTicks);
}
AddToQueue(ReadySentinel, outTicks);
}
/// <summary>
/// <c>HandleEnterWorld</c> 0x0051bdd0 (@290634): strip any stale link
/// animations from the live sequence
/// (<see cref="CSequence.RemoveAllLinkAnimations"/>), then fully drain
/// <c>pending_animations</c> exactly like <see cref="HandleExitWorld"/> —
/// each drained entry fires <see cref="IMotionDoneSink.MotionDone"/> with
/// <c>success:false</c> via repeated <see cref="AnimationDone"/> calls
/// (decomp: <c>while (head_ != 0) AnimationDone(this, 0)</c>).
/// </summary>
public void HandleEnterWorld()
{
_sequence.RemoveAllLinkAnimations();
DrainQueue();
}
/// <summary>
/// <c>HandleExitWorld</c> 0x0051bda0 (@290625): fully drain
/// <c>pending_animations</c>, signaling "failure/aborted"
/// (<c>success:false</c>) for every entry via repeated
/// <see cref="AnimationDone"/> calls.
/// </summary>
public void HandleExitWorld() => DrainQueue();
private void DrainQueue()
{
while (_pendingAnimations.First is not null)
AnimationDone(false);
}
// ── PerformMovement ──────────────────────────────────────────────────
/// <summary>
/// <c>PerformMovement</c> 0x0051c0b0 (@290906) — the single chokepoint
/// between a wire-level <see cref="MovementStruct"/> (interpreted
/// command / stop / stop-completely) and the motion-table state machine.
/// Error codes: <c>7</c> = no motion table loaded; <c>0x43</c> =
/// DoObjectMotion/StopObjectMotion returned failure; <c>0</c> = success.
/// Unhandled <see cref="MovementType"/>s (RawCommand, StopRawCommand,
/// MoveToObject/Position, TurnToObject/Heading) are NOT MotionTableManager's
/// job — decomp's BN artifact returns the CSequence pointer reinterpreted
/// as a code (§11 note: "likely dead/unreachable... never consulted"); the
/// C# port returns <see cref="MotionTableManagerError.NotHandled"/> instead
/// of leaking a pointer value, since no caller in this codebase depends on
/// that BN quirk.
/// </summary>
public uint PerformMovement(MotionTableMovement movement)
{
if (_table is null)
return MotionTableManagerError.NoTable; // 7
uint outTicks;
switch (movement.Type)
{
case MovementType.InterpretedCommand:
if (_table.DoObjectMotion(movement.Motion, _state, _sequence, movement.Speed, out outTicks))
{
AddToQueue(movement.Motion, outTicks);
return MotionTableManagerError.Success; // 0
}
return MotionTableManagerError.MotionFailed; // 0x43
case MovementType.StopInterpretedCommand:
if (_table.StopObjectMotion(movement.Motion, movement.Speed, _state, _sequence, out outTicks))
{
AddToQueue(ReadySentinel, outTicks);
return MotionTableManagerError.Success;
}
return MotionTableManagerError.MotionFailed;
case MovementType.StopCompletely:
_table.StopObjectCompletely(_state, _sequence, out outTicks);
AddToQueue(ReadySentinel, outTicks); // UNCONDITIONAL — queued regardless of return value.
return MotionTableManagerError.Success;
default:
// RawCommand, StopRawCommand, and the MoveTo*/TurnTo* types are
// not MotionTableManager's job (decomp §11 note).
return MotionTableManagerError.NotHandled;
}
}
}
/// <summary>
/// <c>PerformMovement</c> error/result codes (decomp §15 "PerformMovement
/// error codes"). Named constants standing in for retail's raw hex return
/// values, kept as plain <see cref="uint"/> to match
/// <see cref="MotionTableManager.PerformMovement"/>'s retail-verbatim return
/// type.
/// </summary>
public static class MotionTableManagerError
{
/// <summary>0 — success.</summary>
public const uint Success = 0u;
/// <summary>7 — no motion table loaded.</summary>
public const uint NoTable = 7u;
/// <summary>0x43 — DoObjectMotion/StopObjectMotion returned failure.</summary>
public const uint MotionFailed = 0x43u;
/// <summary>
/// C#-port-only sentinel for the "unhandled MovementType" default case.
/// Retail's BN decompile shows this leaking the CSequence pointer
/// reinterpreted as a return code (decomp §11 note, "likely dead/
/// unreachable in practice"); no known caller depends on that value, so
/// the port returns this distinguishable constant instead of fabricating
/// a pointer-shaped number.
/// </summary>
public const uint NotHandled = 0xFFFFFFFFu;
}
/// <summary>
/// Minimal retail-verbatim <c>MovementStruct</c> subset (acclient.h:38069)
/// needed by <see cref="MotionTableManager.PerformMovement"/>: just the
/// dispatch type, the motion id, and the speed scalar
/// (<c>arg2-&gt;params-&gt;speed</c>). Defined here rather than reusing
/// <c>AcDream.Core.Physics.MotionInterpreter.MovementStruct</c> because that
/// type serves a different (CMotionInterp-level) call site with fields
/// (<c>ObjectId</c>, <c>Position</c>, <c>Autonomous</c>,
/// <c>ModifyInterpretedState/RawState</c>) MotionTableManager never reads —
/// CLAUDE.md/plan instruction: do not modify MotionInterpreter.cs.
/// <see cref="AcDream.Core.Physics.MovementType"/> IS reused (its 5 values
/// match retail's <c>MovementTypes::Type</c> 1:1 for the cases
/// MotionTableManager handles).
/// </summary>
public readonly struct MotionTableMovement
{
public readonly MovementType Type;
public readonly uint Motion;
public readonly float Speed;
public MotionTableMovement(MovementType type, uint motion, float speed)
{
Type = type;
Motion = motion;
Speed = speed;
}
public static MotionTableMovement Interpreted(uint motion, float speed) =>
new(MovementType.InterpretedCommand, motion, speed);
public static MotionTableMovement StopInterpreted(uint motion, float speed) =>
new(MovementType.StopInterpretedCommand, motion, speed);
public static MotionTableMovement StopCompletely() =>
new(MovementType.StopCompletely, 0u, 1f);
}

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
/// collision tests against). Used as the BSP shadow-shape part-pose override
/// at server-entity registration (doors: the CLOSED pose).
///
/// <para>
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style &lt;&lt; 16) |
/// (substate &amp; 0xFFFFFF)</c> — the raw dat <c>Cycles</c> dictionary is
/// keyed by the COMBINED word, not the bare style (the first cut of this
/// helper looked up the bare style, always missed, and silently fell back
/// to placement frames — the #175 "not fixed" report).
/// </para>
/// </summary>
public static class MotionTablePose
{
/// <summary>
/// Resolve the default-state pose frames. Returns null (callers fall
/// back to placement frames) when the table has no default-style
/// substate, no matching cycle, or no animation. A pose covering FEWER
/// parts than the Setup is returned as-is —
/// <see cref="ShadowShapeBuilder"/> falls back to the placement frame
/// PER PART beyond the override's length (e.g. a door anim that poses
/// only the panel parts, not the BSP-less frame header).
/// </summary>
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
/// <param name="loadAnimation">Animation loader (production:
/// <c>id => dats.Get&lt;Animation&gt;(id)</c>).</param>
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
MotionTable mt,
Func<uint, Animation?> loadAnimation)
{
if (mt is null) return null;
// SetDefaultState: StyleDefaults[DefaultStyle] → the default substate.
if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd))
return null;
// LookupCycle key (CMotionTable.cs:683 — same wrap semantics).
uint style = (uint)mt.DefaultStyle;
uint substate = (uint)defaultSubstateCmd;
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0)
return null;
var animRef = cycle.Anims[0];
var anim = loadAnimation(animRef.AnimId);
if (anim is null || anim.PartFrames.Count == 0) return null;
int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
var frames = anim.PartFrames[idx].Frames;
return frames.Count > 0 ? frames : null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,306 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager
/// port: <c>heading_diff</c>, <c>heading_greater</c>, <c>Position::heading</c>
/// / <c>Frame::get_heading</c> / <c>Frame::set_heading</c>, and
/// <c>Position::cylinder_distance</c>. No GL/App dependency — Core-only,
/// per the Code Structure Rules. NAME WATCH: this file (not
/// <c>MoveToManager.cs</c>, per r4-port-plan.md §3 "New code target") is the
/// R4-V1 deliverable; the manager itself is R4-V2.
/// </summary>
public static class MoveToMath
{
/// <summary>
/// Universal heading/distance epsilon (same literal as R3's A5/A6 —
/// r4-moveto-decomp.md §12 constants inventory).
/// </summary>
public const float Epsilon = 0.000199999995f;
/// <summary>
/// Retail <c>heading_diff</c> (<c>0x00528fb0</c>, free function, raw
/// 306327-306347), PINNED by direct disassembly of the PDB-matched
/// retail binary (ghidra-confirmations.md §P3 — the strongest evidence
/// tier in the R4 pin set):
/// <code>
/// d = h1 - h2;
/// if (fabs(h1 - h2) &lt; F_EPSILON) d = 0;
/// if (d &lt; -F_EPSILON) d += 360;
/// if (F_EPSILON &lt; d &amp;&amp; turnCmd != TurnRight) d = 360 - d; // the mirror
/// return d;
/// </code>
/// The mirror gates on the turn command NOT being TurnRight
/// (0x6500000d) — TurnLeft (and any other command) measures the
/// COMPLEMENTARY angle. This CONTRADICTS r4-moveto-decomp.md §5g's
/// "arg3 UNUSED" claim, which the Ghidra disassembly pin overrides
/// (V0-pins.md §P3 adjudication). Call sites: <c>BeginTurnToHeading</c>
/// passes the CONSTANT TurnRight (mirror explicitly disabled — the
/// direction pick stays the ≤180 test elsewhere); <c>HandleTurnToHeading</c>
/// passes the LIVE <c>current_command</c> (can be TurnLeft).
/// </summary>
/// <param name="h1">First heading, degrees.</param>
/// <param name="h2">Second heading, degrees.</param>
/// <param name="turnCmd">The active turn command id — gates the mirror.</param>
/// <returns>Normalized heading difference, degrees.</returns>
public static float HeadingDiff(float h1, float h2, uint turnCmd)
{
float d = h1 - h2;
if (MathF.Abs(h1 - h2) < Epsilon)
{
d = 0f;
}
if (d < -Epsilon)
{
d += 360f;
}
if (Epsilon < d && turnCmd != MotionCommand.TurnRight)
{
d = 360f - d;
}
return d;
}
/// <summary>
/// Retail <c>heading_greater</c> (<c>00528f60</c>, free function, raw
/// 306281-306323), verbatim per r4-moveto-decomp.md §5f:
/// <code>
/// if (fabs(a - b) &gt; 180) greater = (b &gt; a); // wrapped case: compare flipped
/// else greater = (a &gt; b);
/// if (turnCmd == TurnRight) return greater;
/// return !greater; // TurnLeft (and any other cmd): inverted
/// </code>
/// "Has the turn passed the target heading" — direction-aware,
/// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is
/// <c>== TurnRight</c> (not <c>!= TurnRight</c> as in
/// <see cref="HeadingDiff"/>'s mirror) — every OTHER command inverts,
/// not just TurnLeft specifically.
/// </summary>
public static bool HeadingGreater(float a, float b, uint turnCmd)
{
bool greater = MathF.Abs(a - b) > 180f
? b > a
: a > b;
return turnCmd == MotionCommand.TurnRight ? greater : !greater;
}
/// <summary>
/// Retail <c>Position::heading(from, to)</c> (<c>0x005a9520</c>, raw
/// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 =
/// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360).
/// <code>
/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360
/// </code>
/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
/// Horizontal (X/Y) only — Z (height) does not participate, matching
/// retail's compass-heading semantics. An in-tree twin of this formula
/// already exists at <c>SceneryHelpers.cs:75</c> (render-side,
/// independently verified — not reused directly to keep this file
/// GL-free per the Code Structure Rules, but the formula is identical).
/// </summary>
public static float PositionHeading(Vector3 from, Vector3 to)
{
float dx = to.X - from.X;
float dy = to.Y - from.Y;
float headingDeg = 450f - MathF.Atan2(dy, dx) * (180f / MathF.PI);
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
return headingDeg;
}
/// <summary>
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
/// extracts the compass heading (P5 convention) from a body orientation
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
/// wire-correct at the QUATERNION level but its internal scalar
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
/// SHIFTED convention, not retail's. This method uses the CORRECT
/// scalar bridge derived from acdream's own body convention
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
/// <c>YawDeg = atan2Deg(forward.Y, forward.X)</c> and
/// <c>heading = (90 - YawDeg) mod 360</c> — the exact inverse of
/// <see cref="SetHeading"/>. Identity quaternion (Yaw=PI/2, i.e. facing
/// +Y/North) → heading 0, matching P5's "identity quaternion faces
/// heading 0" pin.
/// </summary>
public static float GetHeading(Quaternion orientation)
{
var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), orientation);
float yawDeg = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI);
float headingDeg = 90f - yawDeg;
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
return headingDeg;
}
/// <summary>
/// Retail <c>Frame::set_heading</c> (<c>0x00535e40</c>, raw
/// 320055-320066) — builds a body orientation quaternion facing
/// <paramref name="headingDeg"/> (P5 compass convention), preserving
/// acdream's body-orientation convention (rotation about world Z only;
/// <paramref name="baseOrientation"/>'s pitch/roll, if any, is
/// discarded — matching retail's <c>set_heading</c>, which is a pure
/// yaw-about-Z setter). Exact inverse of <see cref="GetHeading"/>:
/// <c>YawDeg = 90 - headingDeg</c>, then <c>Orientation =
/// AxisAngle(Z, Yaw - PI/2)</c> per
/// <c>PlayerMovementController.cs:1025</c>'s convention.
/// </summary>
/// <param name="baseOrientation">Unused beyond signature parity with
/// the render-side <c>SceneryHelpers.SetHeading</c> twin — retail's
/// <c>set_heading</c> is a pure yaw-about-Z setter with no dependency
/// on the prior orientation's roll/pitch component in the body-frame
/// convention this port uses.</param>
/// <param name="headingDeg">Desired compass heading, degrees.</param>
public static Quaternion SetHeading(Quaternion baseOrientation, float headingDeg)
{
_ = baseOrientation;
float yawDeg = 90f - headingDeg;
float yaw = yawDeg * (MathF.PI / 180f);
return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f);
}
/// <summary>
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
/// faces +X, re-synced into the body quaternion every Update). Same P5
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
/// </summary>
public static float HeadingFromYaw(float yawRad)
{
float headingDeg = 90f - yawRad * (180f / MathF.PI);
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
return headingDeg;
}
/// <summary>
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
/// player's heading snap must write <c>Yaw</c>, NOT the body
/// quaternion, which the controller re-derives from Yaw every frame).
/// Returns radians wrapped to [-π, π] matching the controller's own
/// wrap discipline.
/// </summary>
public static float YawFromHeading(float headingDeg)
{
float yaw = (90f - headingDeg) * (MathF.PI / 180f);
while (yaw > MathF.PI) yaw -= 2f * MathF.PI;
while (yaw < -MathF.PI) yaw += 2f * MathF.PI;
return yaw;
}
/// <summary>
/// Retail <c>Position::cylinder_distance</c>, the pure-math shape
/// consumed by <c>MoveToManager::GetCurrentDistance</c>
/// (<c>005291b0</c>, r4-moveto-decomp.md §5a) when <c>use_spheres</c>
/// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
/// distance; position moves use plain center (Euclidean) distance
/// instead (not ported here — <c>Vector3.Distance</c> already covers
/// it). BN garbles the x87 plumbing in the raw, so the exact
/// radius-combination arithmetic is not directly visible; ported per
/// the PDB argument ORDER (own radius/height/position, target
/// radius/height/position) with the standard cylinder-distance shape:
/// planar (X/Y) center distance minus the sum of both radii, clamped
/// at zero (overlapping cylinders report 0, never negative). Height is
/// accepted for signature parity with the retail call (own/target
/// height feed the caller's contact-plane logic elsewhere) but does
/// NOT participate in this edge-to-edge planar computation — matching
/// retail's use of cylinder_distance purely for the horizontal arrival
/// gate.
/// </summary>
public static float CylinderDistance(
float ownRadius, float ownHeight, Vector3 ownPos,
float targetRadius, float targetHeight, Vector3 targetPos)
{
_ = ownHeight;
_ = targetHeight;
float dx = targetPos.X - ownPos.X;
float dy = targetPos.Y - ownPos.Y;
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
float edgeDist = centerDist - ownRadius - targetRadius;
return edgeDist > 0f ? edgeDist : 0f;
}
/// <summary>
/// Retail <c>Position::cylinder_distance_no_z</c> — the <b>signed</b>
/// horizontal (X/Y) edge-to-edge distance between two cylinders:
/// <c>centerDist ownRadius targetRadius</c>. Unlike
/// <see cref="CylinderDistance"/> (the arrival-gate variant, which CLAMPS at
/// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE
/// value. <c>StickyManager::adjust_offset</c> (0x00555430) relies on the
/// sign: when the follower is inside the desired 0.3 m stick gap the
/// distance goes negative, the per-tick delta inverts, and the mover backs
/// off to restore the gap (ACE StickyManager.cs:156).
/// </summary>
public static float CylinderDistanceNoZ(
float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos)
{
float dx = targetPos.X - ownPos.X;
float dy = targetPos.Y - ownPos.Y;
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
return centerDist - ownRadius - targetRadius;
}
/// <summary>
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c> — normalize
/// <paramref name="v"/> in place, returning <c>true</c> if the vector was
/// too small to normalize (near-zero) and leaving it unchanged in that
/// case. Consumed by <c>StickyManager::adjust_offset</c> (don't chase
/// jitter when already at the target) and
/// <see cref="TargetManager.ReceiveUpdate"/> (interpolated-heading
/// fallback). A public shared twin of the private helper in
/// <c>ParticleSystem</c>; same 1e-8 near-zero length guard.
/// </summary>
/// <returns><c>true</c> = too small (left unchanged); <c>false</c> =
/// normalized.</returns>
public static bool NormalizeCheckSmall(ref Vector3 v)
{
float length = v.Length();
if (length < 1e-8f)
return true;
v /= length;
return false;
}
/// <summary>
/// Retail <c>Position::globaltolocalvec</c> — rotate a WORLD-space vector
/// into a frame's LOCAL coordinates by the inverse of the frame's
/// orientation. Consumed by <c>StickyManager::adjust_offset</c>
/// (0x00555430) to express the self→target offset in the mover's own frame
/// before flattening Z and steering. Pure rotation (no translation) —
/// <paramref name="worldVec"/> is a direction/offset, not a point.
/// </summary>
public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec)
=> Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation));
/// <summary>
/// Landblock-local wire origin → world space (verbatim relocation from
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
/// TurnTo packets carry positions block-local; acdream's streaming world
/// re-centers on a live-center landblock grid.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
}

View file

@ -0,0 +1,47 @@
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R4-V2 — verbatim port of retail's <c>MoveToManager::MovementNode</c>
/// (<c>acclient.h:57702</c>, struct #6350):
/// <code>
/// struct __cppobj MoveToManager::MovementNode : DLListData
/// { // +0 dllist_next, +4 dllist_prev (DLListData)
/// MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued
/// float heading; // +0xc — only meaningful for type 9
/// };
/// </code>
///
/// <para>
/// <b>NAME WATCH (r4-port-plan.md §3 "New code target"):</b> named
/// <c>MoveToNode</c>, NOT <c>MovementNode</c>, to avoid colliding with R2's
/// <see cref="MotionNode"/> (the UNRELATED <c>CMotionInterp::pending_motions</c>
/// node — a different queue on a different class; see the AD-34 register
/// wording on both node types' shared "managed collection standing in for
/// retail's intrusive DLList/LList" pattern).
/// </para>
///
/// <para>
/// Retail allocates these with <c>operator new(0x10)</c> and links them onto
/// <c>MoveToManager::pending_actions</c> (a <c>DLList</c> — doubly-linked,
/// unlike <c>CMotionInterp</c>'s singly-linked <c>LList</c>) via
/// <c>DLListBase::InsertAfter</c> (tail-append; r4-moveto-decomp.md §4a).
/// Ported as a managed <see cref="System.Collections.Generic.LinkedList{T}"/>
/// of this value type — same pattern as <see cref="MotionNode"/>'s port
/// (AD-34 wording): node identity semantics preserved via
/// <c>LinkedListNode&lt;MoveToNode&gt;</c> references rather than raw
/// prev/next pointers, FIFO order preserved via tail-append +
/// <c>RemoveFirst</c>.
/// </para>
/// </summary>
/// <param name="Type">Retail <c>type</c> (+8) — only
/// <see cref="MovementType.MoveToPosition"/> (7) and
/// <see cref="MovementType.TurnToHeading"/> (9) are ever queued
/// (r4-moveto-decomp.md §4a: <c>AddMoveToPositionNode</c> /
/// <c>AddTurnToHeadingNode</c> are the only two producers;
/// <see cref="MoveToManager.BeginNextNode"/>'s dispatch is a defensive
/// <c>if/if</c>, not a full switch — an unknown type stalls rather than
/// throwing, matching the raw's shape).</param>
/// <param name="Heading">Retail <c>heading</c> (+0xc) — only meaningful for
/// <see cref="MovementType.TurnToHeading"/> nodes; zero/unused for
/// <see cref="MovementType.MoveToPosition"/> nodes.</param>
public readonly record struct MoveToNode(MovementType Type, float Heading);

View file

@ -0,0 +1,171 @@
using System;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5-V5 — retail <c>MovementManager</c> (acclient.h <c>/* 3463 */</c>, 0x10
/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's
/// two managers. Decomp extract:
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>.
///
/// <code>
/// struct MovementManager {
/// CMotionInterp *motion_interpreter; // +0x0 → Minterp
/// MoveToManager *moveto_manager; // +0x4 → MoveTo (lazy)
/// CPhysicsObj *physics_obj; // +0x8 ┐ carried by the children +
/// CWeenieObject *weenie_obj; // +0xc ┘ the MoveToFactory closure
/// };
/// </code>
///
/// <para><b>Construction mapping.</b> Retail lazily creates BOTH children
/// (<c>CMotionInterp::Create</c> + <c>enter_default_state</c> at every entry
/// point; <c>MoveToManager::Create</c> via <see cref="MakeMoveToManager"/>).
/// acdream constructs the interp eagerly at entity construction
/// (<c>RemoteMotion</c> / <c>PlayerMovementController</c> ctors — the lazy
/// window is never observable; register TS-38 already covers the
/// <c>Initted</c> side of this) and hands it to this ctor. The moveto side
/// keeps retail's lazy <see cref="MakeMoveToManager"/> mechanism: retail
/// constructs from the <c>physics_obj</c>/<c>weenie_obj</c> backpointers;
/// acdream's <see cref="MoveToFactory"/> closure is the stand-in for those
/// two fields, carrying the full seam wiring (set once at the bind site —
/// <c>EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c> / the chase
/// harness — which then calls <see cref="MakeMoveToManager"/> immediately,
/// preserving the pre-facade eager timing; the ctor is side-effect-free so
/// the timing is unobservable either way).</para>
///
/// <para><b>Deliberately NOT absorbed here</b> (the R5-V5 slice keeps these
/// at their current owners): <c>unpack_movement</c> 0x00524440 ≡ the
/// GameWindow UM path + <c>RouteServerMoveTo</c> (Core.Net wire types stay
/// out of Core.Physics); <c>move_to_interpreted_state</c> 0x00524170 ≡ the
/// funnel's <c>MotionInterpreter.MoveToInterpretedState</c> call sites;
/// <c>MotionDone</c> 0x005242d0 ≡ the sequencer's <c>MotionDoneTarget</c>
/// seam (register AD-36); <c>LeaveGround</c> 0x00524320's moveto half is a
/// COMDAT no-op (see <c>PlayerMovementController</c>'s landing comment) so
/// call sites keep invoking <c>Minterp.LeaveGround()</c> directly;
/// <c>EnterDefaultState</c>/<c>HandleEnterWorld</c>/<c>ReportExhaustion</c>/
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
///
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
/// route never re-asserted it — status quo preserved (zero-behavior-change
/// slice), not a new deviation.</para>
/// </summary>
public sealed class MovementManager
{
/// <summary>Retail <c>motion_interpreter</c> (+0x0). Always present in
/// acdream (eager construction — see the class doc); direct child access
/// for interp-specific ops mirrors retail's <c>get_minterp</c>
/// (0x005242a0) callers.</summary>
public MotionInterpreter Minterp { get; }
/// <summary>Retail <c>moveto_manager</c> (+0x4) — null until
/// <see cref="MakeMoveToManager"/> (or a type-6..9
/// <see cref="PerformMovement"/>) creates it.</summary>
public MoveToManager? MoveTo { get; private set; }
/// <summary>The acdream stand-in for retail's <c>physics_obj</c>/
/// <c>weenie_obj</c> backpointers (+0x8/+0xc): the creation recipe
/// <see cref="MakeMoveToManager"/> invokes. Set exactly once at the bind
/// site; the closure carries the MoveToManager's seam wiring (and the
/// StickTo/Unstick/MoveToComplete binds) that retail reads off
/// <c>CPhysicsObj</c>.</summary>
public Func<MoveToManager>? MoveToFactory { get; set; }
public MovementManager(MotionInterpreter minterp)
{
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
}
/// <summary>
/// Retail <c>MovementManager::MakeMoveToManager</c> (0x00524000):
/// lazy-construct <see cref="MoveTo"/> if null; no-op if already present.
/// No-op (instead of retail's unconditional create) when no factory has
/// been bound yet — acdream's seams arrive from the bind site, and every
/// consumer path runs after binding.
/// </summary>
public void MakeMoveToManager()
{
if (MoveTo is null && MoveToFactory is not null)
MoveTo = MoveToFactory();
}
/// <summary>
/// Retail <c>MovementManager::PerformMovement</c> (0x005240d0): the
/// type-1..9 two-way dispatch. <c>(type - 1) &gt; 8</c> (type 0
/// underflows unsigned) → 0x47; types 1-5 → <c>CMotionInterp::
/// PerformMovement</c> (return propagated); types 6-9 →
/// <see cref="MakeMoveToManager"/> + <c>MoveToManager::PerformMovement</c>
/// whose return is NOT propagated (@0052414f <c>return 0</c>) — the
/// facade reports <see cref="WeenieError.None"/> for that path.
/// </summary>
public WeenieError PerformMovement(MovementStruct mvs)
{
switch (mvs.Type)
{
case MovementType.RawCommand:
case MovementType.InterpretedCommand:
case MovementType.StopRawCommand:
case MovementType.StopInterpretedCommand:
case MovementType.StopCompletely:
return Minterp.PerformMovement(mvs);
case MovementType.MoveToObject:
case MovementType.MoveToPosition:
case MovementType.TurnToObject:
case MovementType.TurnToHeading:
MakeMoveToManager();
if (MoveTo is null)
// acdream-only guard: a type-6..9 event before the bind
// site set MoveToFactory (unreachable in production —
// EnsureRemoteMotionBindings / EnterPlayerModeNow bind
// before any route can fire). Retail would Create here.
return WeenieError.GeneralMovementFailure;
MoveTo.PerformMovement(mvs);
return WeenieError.None;
default:
return WeenieError.GeneralMovementFailure; // 0x47
}
}
/// <summary>Retail <c>MovementManager::UseTime</c> (0x005242f0): relay
/// to <see cref="MoveTo"/> ONLY (does not touch the interp, does not
/// lazy-create); no-op while null.</summary>
public void UseTime() => MoveTo?.UseTime();
/// <summary>Retail <c>MovementManager::HitGround</c> (0x00524300): fan
/// to BOTH children if present — <c>CMotionInterp::HitGround</c> FIRST
/// (the falling-pose exit re-apply), then <c>MoveToManager::HitGround</c>
/// (re-arms a moveto suspended by the airborne UseTime contact
/// gate).</summary>
public void HitGround()
{
Minterp.HitGround();
MoveTo?.HitGround();
}
/// <summary>Retail <c>MovementManager::HandleExitWorld</c> (0x00524350):
/// interp ONLY (drains <c>pending_motions</c>); does not touch
/// <see cref="MoveTo"/>.</summary>
public void HandleExitWorld() => Minterp.HandleExitWorld();
/// <summary>Retail <c>MovementManager::CancelMoveTo</c> (0x005241b0):
/// relay to <see cref="MoveTo"/>; no-op while null. The retail
/// <c>interrupt_current_movement → MovementManager::CancelMoveTo(0x36)</c>
/// chain lands here.</summary>
public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error);
/// <summary>Retail <c>MovementManager::HandleUpdateTarget</c>
/// (0x00524790): relay to <see cref="MoveTo"/>; no-op while null. The
/// <c>CPhysicsObj::HandleUpdateTarget</c> fan (0x00512bc0) calls this
/// leg first, then the PositionManager's (host order —
/// <c>EntityPhysicsHost.HandleUpdateTarget</c>).</summary>
public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info);
/// <summary>Retail <c>MovementManager::IsMovingTo</c> (0x00524260):
/// true iff <see cref="MoveTo"/> exists AND reports an armed
/// move.</summary>
public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false;
}

View file

@ -0,0 +1,472 @@
using AcDream.Core.Physics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R3-W1 — verbatim port of retail's <c>MovementParameters</c>
/// (<c>acclient.h:31453</c>, struct #3460; bitfield struct
/// <c>acclient.h:31423-31443</c>):
/// <code>
/// struct __cppobj MovementParameters : PackObj
/// {
/// union { unsigned int bitfield; ... } ___u1;
/// float distance_to_object;
/// float min_distance;
/// float desired_heading;
/// float speed;
/// float fail_distance;
/// float walk_run_threshhold;
/// unsigned int context_id;
/// HoldKey hold_key_to_apply;
/// unsigned int action_stamp;
/// };
/// </code>
///
/// <para>
/// The bitfield's absolute mask table is pinned in
/// <c>docs/research/2026-07-02-r3-motioninterp/W0-pins.md</c> §A4 (bit-for-bit
/// identical to ACE's <c>MovementParamFlags</c>):
/// </para>
/// <list type="table">
/// <item><term>0x1</term><description>CanWalk</description></item>
/// <item><term>0x2</term><description>CanRun</description></item>
/// <item><term>0x4</term><description>CanSidestep</description></item>
/// <item><term>0x8</term><description>CanWalkBackwards</description></item>
/// <item><term>0x10</term><description>CanCharge</description></item>
/// <item><term>0x20</term><description>FailWalk</description></item>
/// <item><term>0x40</term><description>UseFinalHeading</description></item>
/// <item><term>0x80</term><description>Sticky</description></item>
/// <item><term>0x100</term><description>MoveAway</description></item>
/// <item><term>0x200</term><description>MoveTowards</description></item>
/// <item><term>0x400</term><description>UseSpheres</description></item>
/// <item><term>0x800</term><description>SetHoldKey</description></item>
/// <item><term>0x1000</term><description>Autonomous</description></item>
/// <item><term>0x2000</term><description>ModifyRawState</description></item>
/// <item><term>0x4000</term><description>ModifyInterpretedState</description></item>
/// <item><term>0x8000</term><description>CancelMoveTo</description></item>
/// <item><term>0x10000</term><description>StopCompletely</description></item>
/// <item><term>0x20000</term><description>DisableJumpDuringLink</description></item>
/// </list>
///
/// <para>
/// Ctor default (raw 300510-300534, <c>0x00524380</c>):
/// <c>(bitfield &amp; 0xfffdee0f) | 0x1ee0f</c> → <c>0x1EE0F</c> sets
/// {CanWalk, CanRun, CanSidestep, CanWalkBackwards, MoveTowards, UseSpheres,
/// SetHoldKey, ModifyRawState, ModifyInterpretedState, CancelMoveTo,
/// StopCompletely}; clears {CanCharge, FailWalk, UseFinalHeading, Sticky,
/// MoveAway, Autonomous, DisableJumpDuringLink}.
/// Scalars: <c>min_distance=0</c>, <c>distance_to_object=0.6</c>,
/// <c>fail_distance=FLT_MAX</c>, <c>desired_heading=0</c>, <c>speed=1</c>,
/// <c>walk_run_threshhold=15</c> (NOT ACE's 1.0 — W0-pins A4 divergence trap),
/// <c>context_id=0</c>, <c>hold_key_to_apply=HoldKey.Invalid</c>,
/// <c>action_stamp=0</c>.
/// </para>
///
/// <para>
/// <b>ACE-divergence traps (W0-pins A4, do not copy):</b> ACE's
/// <c>MovementParameters</c> ctor sets <c>CanCharge = true</c>
/// (MovementParameters.cs:58) — retail's default has bit <c>0x10</c> CLEAR;
/// this port defaults <c>CanCharge = false</c>. ACE also changed
/// <c>Default_WalkRunThreshold</c> to 1.0 (L50) vs retail's literal 15.0
/// (@300519) — this port defaults <c>WalkRunThreshhold = 15f</c>.
/// </para>
///
/// <para>
/// Named bool properties per plan (no <c>ToBitfield()</c>/<c>FromBitfield()</c>
/// pair — the wire never carries this struct raw; <c>RawMotionState::Pack</c>
/// serializes the STATE, not this transient parameter block. If a future
/// slice needs the packed form, add the pair then with a cited call site).
/// </para>
/// </summary>
public sealed class MovementParameters
{
// ── bitfield flags (retail 0x1EE0F default; W0-pins A4) ───────────────
/// <summary>Mask 0x1 — default true.</summary>
public bool CanWalk { get; set; } = true;
/// <summary>Mask 0x2 — default true.</summary>
public bool CanRun { get; set; } = true;
/// <summary>Mask 0x4 — default true.</summary>
public bool CanSidestep { get; set; } = true;
/// <summary>Mask 0x8 — default true.</summary>
public bool CanWalkBackwards { get; set; } = true;
/// <summary>
/// Mask 0x10 — default FALSE. ACE-divergence trap (W0-pins A4): ACE's
/// ctor sets this true (MovementParameters.cs:58); retail's 0x1EE0F
/// default has bit 0x10 CLEAR. Do not "fix" this to true.
/// </summary>
public bool CanCharge { get; set; }
/// <summary>Mask 0x20 — default false.</summary>
public bool FailWalk { get; set; }
/// <summary>Mask 0x40 — default false.</summary>
public bool UseFinalHeading { get; set; }
/// <summary>Mask 0x80 — default false.</summary>
public bool Sticky { get; set; }
/// <summary>Mask 0x100 — default false.</summary>
public bool MoveAway { get; set; }
/// <summary>Mask 0x200 — default true.</summary>
public bool MoveTowards { get; set; } = true;
/// <summary>Mask 0x400 — default true.</summary>
public bool UseSpheres { get; set; } = true;
/// <summary>Mask 0x800 — default true. DoMotion @306188: byte1&amp;8
/// requests a <c>SetHoldKey</c> call before <c>adjust_motion</c>.</summary>
public bool SetHoldKey { get; set; } = true;
/// <summary>
/// Mask 0x1000 — default FALSE. Not the same virtual as
/// <c>IWeenieObject.IsThePlayer</c> (W0-pins A3) — this is the
/// per-call "was this an autonomous (locally-predicted) action?" flag.
/// </summary>
public bool Autonomous { get; set; }
/// <summary>Mask 0x2000 — default true. DoMotion @306213: byte1&amp;0x20
/// mirrors the applied motion into <c>RawMotionState</c> via
/// <c>ApplyMotion</c>/<c>RemoveMotion</c>.</summary>
public bool ModifyRawState { get; set; } = true;
/// <summary>Mask 0x4000 — default true. Mirrors into
/// <c>InterpretedMotionState</c>.</summary>
public bool ModifyInterpretedState { get; set; } = true;
/// <summary>Mask 0x8000 — default true. Bitfield high-byte sign bit;
/// DoMotion/StopMotion @306183/@305684: triggers
/// <c>interrupt_current_movement</c> before the rest of the call.</summary>
public bool CancelMoveTo { get; set; } = true;
/// <summary>Mask 0x10000 — default true.</summary>
public bool StopCompletelyFlag { get; set; } = true;
/// <summary>
/// Mask 0x20000 — default FALSE. DoInterpretedMotion @305597: when set,
/// forces the computed <c>jump_error_code</c> to <c>0x48</c> (A1: jump
/// BLOCKED by motion/position) regardless of what
/// <c>motion_allows_jump</c> would have said.
/// </summary>
public bool DisableJumpDuringLink { get; set; }
// ── scalar fields (retail ctor 0x00524380 defaults) ───────────────────
/// <summary>Retail default 0.6.</summary>
public float DistanceToObject { get; set; } = 0.6f;
/// <summary>Retail default 0.</summary>
public float MinDistance { get; set; }
/// <summary>Retail default 0.</summary>
public float DesiredHeading { get; set; }
/// <summary>Retail default 1.</summary>
public float Speed { get; set; } = 1f;
/// <summary>Retail default FLT_MAX.</summary>
public float FailDistance { get; set; } = float.MaxValue;
/// <summary>
/// Retail default 15.0 (@300519). ACE-divergence trap (W0-pins A4): ACE
/// changed <c>Default_WalkRunThreshold</c> to 1.0 — do not copy.
/// </summary>
public float WalkRunThreshhold { get; set; } = 15f;
/// <summary>Retail default 0.</summary>
public uint ContextId { get; set; }
/// <summary>Retail default <see cref="Physics.HoldKey.Invalid"/>.</summary>
public HoldKey HoldKeyToApply { get; set; } = HoldKey.Invalid;
/// <summary>Retail default 0.</summary>
public uint ActionStamp { get; set; }
// ── R4-V1: command-selection family (closes M2-mechanics) ─────────────
/// <summary>
/// Retail <c>MovementParameters::get_command</c> (<c>0x0052aa00</c>,
/// raw 307946-308012), VERBATIM per
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Picks the
/// motion command + moving-away flag from the towards/away bitfield
/// combination, THEN the walk-vs-run <see cref="HoldKey"/> cascade.
///
/// <para>
/// <b>Command pick</b> (mirrors <c>towards_and_away</c>'s bands but is
/// NOT identical — see the asymmetry note on <see cref="TowardsAndAway"/>):
/// <list type="bullet">
/// <item><description><c>MoveTowards &amp;&amp; MoveAway</c> → delegate
/// to <see cref="TowardsAndAway"/> (the three-band form).</description></item>
/// <item><description><c>MoveTowards</c> only (or neither flag set —
/// retail's <c>else if</c> falls through to the SAME branch): plain
/// towards — <c>dist &gt; DistanceToObject</c> → WalkForward,
/// !movingAway; else idle (cmd 0).</description></item>
/// <item><description><c>MoveAway</c> only: pure away —
/// <c>dist &lt; MinDistance</c> → WalkForward, movingAway=true (the
/// heading flips +180 via <see cref="GetDesiredHeading"/> — turn-around,
/// NOT WalkBackwards, unlike <see cref="TowardsAndAway"/>'s min-band);
/// else idle.</description></item>
/// </list>
/// </para>
///
/// <para>
/// <b>THE walk-vs-run rule</b> (confirms
/// <c>feedback_autowalk_cancharge_bit</c> — port RETAIL's version of
/// BOTH the fast-path ACE dropped and the threshold-close-walk pair):
/// <c>HoldKey.Run</c> ⇐ <c>CanCharge</c> set (the fast-path — wins
/// regardless of CanRun/CanWalk/distance), OR (<c>CanRun</c> set AND
/// (<c>CanWalk</c> clear OR <c>dist - DistanceToObject &gt;
/// WalkRunThreshhold</c>)). <c>HoldKey.None</c> (walk) ⇐ no CanRun, or
/// walk-capable within the threshold (INCLUSIVE ≤ — the raw's
/// <c>test ah,0x41</c> after the fcom is the not-greater-than reading,
/// §5c @308003).
/// </para>
/// </summary>
/// <param name="dist">Current distance-to-target (retail's
/// <c>GetCurrentDistance</c> result — center or cylinder distance per
/// <see cref="MoveToMath.CylinderDistance"/>).</param>
/// <param name="headingDiff">Heading-to-target minus current heading,
/// normalized [0,360) — UNUSED by <c>get_command</c> itself (the raw
/// signature carries it for parity with the caller's local; retail's
/// body never reads <c>arg3</c> in this build). Kept as a parameter for
/// call-site symmetry with <c>BeginMoveForward</c> (§4c), which computes
/// it immediately before calling <c>get_command</c>.</param>
/// <param name="motion">Chosen motion command id, or 0 if no movement
/// is needed (already in range).</param>
/// <param name="holdKey">Chosen hold key (walk vs run).</param>
/// <param name="movingAway">True if the chosen motion moves the mover
/// AWAY from the target (feeds <see cref="GetDesiredHeading"/> and the
/// arrival predicate's polarity).</param>
public void GetCommand(float dist, float headingDiff, out uint motion, out HoldKey holdKey, out bool movingAway)
{
_ = headingDiff; // retail's arg3 — unread in this build's body (§5c)
// ── command + moving_away pick ──────────────────────────────────
if (MoveTowards && MoveAway)
{
TowardsAndAway(dist, out motion, out movingAway);
}
else if (MoveAway && !MoveTowards)
{
// pure AWAY: dist < min_distance → WalkForward, moving away
// (turn-around; heading flips +180 via GetDesiredHeading).
if (dist < MinDistance)
{
motion = MotionCommand.WalkForward;
movingAway = true;
}
else
{
motion = 0u;
movingAway = false;
}
}
else
{
// plain TOWARDS (MoveTowards set, or neither flag set — retail's
// `else if ((flags & 0x100) == 0)` falls to the same label).
if (dist > DistanceToObject)
{
motion = MotionCommand.WalkForward;
movingAway = false;
}
else
{
motion = 0u;
movingAway = false;
}
}
// ── walk-vs-run HoldKey cascade ─────────────────────────────────
if (CanCharge)
{
// THE fast-path ACE dropped: can_charge short-circuits straight
// to Run regardless of CanRun/CanWalk/distance.
holdKey = HoldKey.Run;
return;
}
if (!CanRun)
{
holdKey = HoldKey.None;
return;
}
if (CanWalk && (dist - DistanceToObject) <= WalkRunThreshhold)
{
holdKey = HoldKey.None;
return;
}
holdKey = HoldKey.Run;
}
/// <summary>
/// Retail <c>MovementParameters::towards_and_away</c>
/// (<c>0x0052a9a0</c>, raw 307917-307942), VERBATIM per
/// r4-moveto-decomp.md §5d. Three bands:
/// <list type="bullet">
/// <item><description><c>dist &gt; DistanceToObject</c> → WalkForward,
/// towards (not moving away).</description></item>
/// <item><description><c>dist - MinDistance &lt; F_EPSILON</c> (inside
/// the min-distance band) → WalkBackward, moving away. NOTE the
/// asymmetry vs <see cref="GetCommand"/>'s pure-away branch: this backs
/// up with WalkBackwards (no turn-around), not WalkForward+heading-flip
/// (r4-moveto-decomp.md :656).</description></item>
/// <item><description>otherwise (strictly inside [MinDistance,
/// DistanceToObject]) → idle (cmd 0).</description></item>
/// </list>
/// </summary>
/// <param name="dist">Current distance-to-target.</param>
/// <param name="cmd">Chosen motion command, or 0 if already in the dead
/// band.</param>
/// <param name="movingAway">True only for the WalkBackward (min-band)
/// case.</param>
public void TowardsAndAway(float dist, out uint cmd, out bool movingAway)
{
const float epsilon = 0.000199999995f;
if (dist > DistanceToObject)
{
cmd = MotionCommand.WalkForward;
movingAway = false;
return;
}
if (dist - MinDistance < epsilon)
{
cmd = MotionCommand.WalkBackward;
movingAway = true;
return;
}
cmd = 0u;
movingAway = false;
}
/// <summary>
/// Retail <c>MovementParameters::get_desired_heading</c>
/// (<c>0x0052aad0</c>), PINNED by direct Ghidra decompile of
/// <c>patchmem.gpr</c> (fetched live during V0 — see
/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2,
/// ACE-shaped constants CONFIRMED exact, superseding the earlier
/// BN-garble-based "high confidence" pin):
/// <code>
/// __thiscall get_desired_heading(command, movingAway)
/// {
/// if (command == RunForward || command == WalkForward) {
/// if (!movingAway) return 0.0f;
/// } else {
/// if (command != WalkBackward) return 0.0f;
/// if (movingAway) return 0.0f;
/// }
/// return 180.0f;
/// }
/// </code>
/// Truth table: forward/run + towards → 0°; forward/run + away → 180°;
/// backward + towards → 180°; backward + away → 0°; any other command
/// → 0°. This is the heading OFFSET added to the raw heading-to-target
/// so an "away" walk faces away and an "away" backstep faces the
/// target.
/// </summary>
public float GetDesiredHeading(uint command, bool movingAway)
{
if (command == MotionCommand.RunForward || command == MotionCommand.WalkForward)
{
if (!movingAway) return 0f;
}
else
{
if (command != MotionCommand.WalkBackward) return 0f;
if (movingAway) return 0f;
}
return 180f;
}
// ── R4-V1: wire factory (closes M15/wire-exposure groundwork) ─────────
/// <summary>
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 7-dword
/// MoveTo wire form (<c>0x0052ac50</c>, 0x1c bytes, raw 308118-308205 —
/// r4-moveto-decomp.md §2g): <c>bitfield, distance_to_object,
/// min_distance, fail_distance, speed, walk_run_threshhold,
/// desired_heading</c>. Used by MoveToObject (type 6) and
/// MoveToPosition (type 7) wire payloads — the SAME field order
/// <c>UpdateMotion.TryParseMoveToPayload</c> already reads
/// (UpdateMotion.cs:328-341). The bitfield fully OVERWRITES every named
/// flag (UnPackNet does not merge with ctor defaults — every bit not
/// present in <paramref name="bitfield"/> resolves to false); the wire
/// bitfield carries <c>can_charge</c> (0x10), the walk-vs-run answer
/// consumed by <see cref="GetCommand"/> (cross-ref
/// <c>feedback_autowalk_cancharge_bit</c>).
/// </summary>
public static MovementParameters FromWire(
uint bitfield,
float distanceToObject,
float minDistance,
float failDistance,
float speed,
float walkRunThreshhold,
float desiredHeading)
{
var p = new MovementParameters();
ApplyBitfield(p, bitfield);
p.DistanceToObject = distanceToObject;
p.MinDistance = minDistance;
p.FailDistance = failDistance;
p.Speed = speed;
p.WalkRunThreshhold = walkRunThreshhold;
p.DesiredHeading = desiredHeading;
return p;
}
/// <summary>
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 3-dword
/// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): <c>bitfield,
/// speed, desired_heading</c>. Used by TurnToObject (type 8) and
/// TurnToHeading (type 9) wire payloads. Distance-related scalars
/// (<c>DistanceToObject</c>/<c>MinDistance</c>/<c>FailDistance</c>/
/// <c>WalkRunThreshhold</c>) are NOT on this wire form and keep the
/// <see cref="MovementParameters"/> ctor defaults — retail's UnPackNet
/// for this form only ever writes the three fields named here.
/// </summary>
public static MovementParameters FromWireTurnTo(
uint bitfield,
float speed,
float desiredHeading)
{
var p = new MovementParameters();
ApplyBitfield(p, bitfield);
p.Speed = speed;
p.DesiredHeading = desiredHeading;
return p;
}
/// <summary>
/// Decode the A4-pinned bitfield masks onto the named bool properties.
/// Shared by <see cref="FromWire"/>/<see cref="FromWireTurnTo"/> — the
/// wire bitfield always fully overwrites (retail's UnPackNet assigns
/// the raw dword straight into <c>this-&gt;bitfield</c>, no merge).
/// </summary>
private static void ApplyBitfield(MovementParameters p, uint bitfield)
{
p.CanWalk = (bitfield & 0x1u) != 0;
p.CanRun = (bitfield & 0x2u) != 0;
p.CanSidestep = (bitfield & 0x4u) != 0;
p.CanWalkBackwards = (bitfield & 0x8u) != 0;
p.CanCharge = (bitfield & 0x10u) != 0;
p.FailWalk = (bitfield & 0x20u) != 0;
p.UseFinalHeading = (bitfield & 0x40u) != 0;
p.Sticky = (bitfield & 0x80u) != 0;
p.MoveAway = (bitfield & 0x100u) != 0;
p.MoveTowards = (bitfield & 0x200u) != 0;
p.UseSpheres = (bitfield & 0x400u) != 0;
p.SetHoldKey = (bitfield & 0x800u) != 0;
p.Autonomous = (bitfield & 0x1000u) != 0;
p.ModifyRawState = (bitfield & 0x2000u) != 0;
p.ModifyInterpretedState = (bitfield & 0x4000u) != 0;
p.CancelMoveTo = (bitfield & 0x8000u) != 0;
p.StopCompletelyFlag = (bitfield & 0x10000u) != 0;
p.DisableJumpDuringLink = (bitfield & 0x20000u) != 0;
}
}

View file

@ -0,0 +1,102 @@
using System;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>PositionManager</c> facade (acclient.h:30952,
/// struct #3468; decomp 0x00555160-0x005553d0,
/// <c>r5-positionmanager-sticky-decomp.md</c>). A thin fan-out over three
/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's
/// <see cref="IPhysicsObjHost"/> (retail <c>CPhysicsObj::position_manager</c>,
/// lazily created).
///
/// <para><b>Interpolation note:</b> retail's <c>adjust_offset</c> chains
/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in
/// <see cref="RemoteMotionCombiner"/> (the R5-renamed remote-motion combiner,
/// formerly the misnamed <c>Physics.PositionManager</c>) and is NOT chained
/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39;
/// Constraint is structural — see <see cref="ConstraintManager"/>). Folding the
/// combiner in as the interp stage is a wiring-slice cleanup.</para>
/// </summary>
public sealed class PositionManager
{
private readonly IPhysicsObjHost _host;
// Lazily created (retail: sticky on first StickTo, constraint on first
// ConstrainTo — 0x00555230 / 0x00555280).
private StickyManager? _sticky;
private ConstraintManager? _constraint;
public PositionManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>Exposed for wiring/tests — the lazily-created sub-managers
/// (null until first use).</summary>
public StickyManager? Sticky => _sticky;
public ConstraintManager? Constraint => _constraint;
/// <summary>Retail <c>PositionManager::StickTo</c> (0x00555230) — lazily
/// create the <see cref="StickyManager"/> and begin following
/// <paramref name="objectId"/>.</summary>
public void StickTo(uint objectId, float radius, float height)
{
_sticky ??= new StickyManager(_host);
_sticky.StickTo(objectId, radius, height);
}
/// <summary>Retail <c>PositionManager::UnStick</c> (0x005551e0) — forward
/// to the sticky sub-manager if it exists.</summary>
public void UnStick() => _sticky?.UnStick();
/// <summary>Retail <c>PositionManager::GetStickyObjectID</c>
/// (0x00555270).</summary>
public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u;
/// <summary>Retail <c>PositionManager::ConstrainTo</c> (0x00555280) —
/// lazily create the <see cref="ConstraintManager"/> and arm the leash.
/// (Unused in acdream — no arming call site; see
/// <see cref="ConstraintManager"/>.)</summary>
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
{
_constraint ??= new ConstraintManager(_host);
_constraint.ConstrainTo(anchor, startDistance, maxDistance);
}
/// <summary>Retail <c>PositionManager::UnConstrain</c>
/// (0x005552b0).</summary>
public void UnConstrain() => _constraint?.UnConstrain();
/// <summary>Retail <c>PositionManager::IsFullyConstrained</c>
/// (0x005552c0) — false when no constraint sub-manager exists.</summary>
public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false;
/// <summary>
/// Retail <c>PositionManager::HandleUpdateTarget</c> (0x005553d0) — only
/// the sticky sub-manager cares about live target positions (interpolation
/// and constraint don't). Fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c>.
/// </summary>
public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info);
/// <summary>
/// Retail <c>PositionManager::adjust_offset</c> (0x00555190) — chains the
/// sub-managers' contributions into the SAME <paramref name="offset"/>
/// accumulator, in retail order. Retail runs Interpolation → Sticky →
/// Constraint; acdream's interpolation stays in the separate remote-motion
/// combiner (see class note), so this chains Sticky → Constraint only.
/// Constraint is LAST because it clamps the already-composed displacement.
/// </summary>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
_sticky?.AdjustOffset(offset, quantum);
_constraint?.AdjustOffset(offset, quantum);
}
/// <summary>
/// Retail <c>PositionManager::UseTime</c> (0x00555160) — per-tick pump.
/// Retail order Interpolation → Constraint → Sticky; acdream runs the two
/// owned managers (constraint's UseTime is a retail no-op, so effectively
/// just the sticky 1 s watchdog).
/// </summary>
public void UseTime() => _sticky?.UseTime();
}

View file

@ -0,0 +1,276 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — verbatim port of retail's <c>StickyManager</c> (acclient.h:31518,
/// struct #3466; decomp block 0x00555400-0x00555866,
/// <c>r5-positionmanager-sticky-decomp.md</c>). Makes the owning object
/// FOLLOW a target object at a bounded gap: each tick
/// <see cref="AdjustOffset"/> steers the mover horizontally toward the
/// target's live (or last-known) position and turns it to face the target,
/// speed- and turn-rate-limited. A 1-second watchdog (<see cref="UseTime"/>)
/// drops the stick if no target-position update arrives.
///
/// <para>Owned by <see cref="PositionManager"/> (lazily created on first
/// <see cref="PositionManager.StickTo"/>). Establishes its target-tracking
/// subscription through the owning <see cref="IPhysicsObjHost"/>'s
/// <c>set_target</c> (→ <see cref="TargetManager"/>); receives the target's
/// live position back through <see cref="HandleUpdateTarget"/>, fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>.</para>
///
/// <para>The dense x87 back half of retail's <c>adjust_offset</c> is decoded
/// against ACE's <c>StickyManager.cs</c> (the two constants
/// <see cref="StickyRadius"/>=0.3 and <see cref="StickyTime"/>=1.0 are ACE's,
/// verified against the retail mush structure — see the port-plan §2a).</para>
/// </summary>
public sealed class StickyManager
{
/// <summary>Retail <c>StickyRadius</c> const (ACE: 0.3f) — the desired
/// follow gap subtracted from the cylinder distance.</summary>
public const float StickyRadius = 0.3f;
/// <summary>Retail <c>StickyTime</c> const (ACE: 1.0f) — the one-shot grace
/// window: if no target update refreshes the stick within this many
/// seconds of <see cref="StickTo"/>, <see cref="UseTime"/> drops it.</summary>
public const float StickyTime = 1.0f;
/// <summary>Retail <c>get_max_speed</c> multiplier for the follow speed
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).</summary>
private const float FollowSpeedFactor = 5.0f;
/// <summary>Retail fallback follow speed when no motion interpreter exists
/// (ACE: 15.0f, i.e. the <c>MAX_VELOCITY</c> constant the mush loads).</summary>
private const float FallbackFollowSpeed = 15.0f;
private readonly IPhysicsObjHost _host;
/// <summary>+0x00 retail <c>target_id</c> — the object we are stuck to
/// (0 = not stuck).</summary>
public uint TargetId { get; private set; }
/// <summary>+0x04 retail <c>target_radius</c> — the target's cylinder
/// radius (from <c>CPartArray::GetRadius</c> of the stuck-to object).</summary>
public float TargetRadius { get; private set; }
/// <summary>+0x08 retail <c>target_position</c> — last-known target
/// position (from <see cref="HandleUpdateTarget"/>), used when the live
/// <c>GetObjectA</c> resolve fails.</summary>
public Position TargetPosition { get; private set; }
/// <summary>+0x54 retail <c>initialized</c> — false until the first
/// <c>Ok</c> target update arrives (gates <see cref="AdjustOffset"/> and
/// the <see cref="UseTime"/> timeout).</summary>
public bool Initialized { get; private set; }
/// <summary>+0x58 retail <c>sticky_timeout_time</c> — the wall-clock
/// deadline set once at <see cref="StickTo"/> time (now + 1 s).</summary>
public double StickyTimeoutTime { get; private set; }
public StickyManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>
/// Retail <c>StickyManager::UnStick</c> (0x00555400). No-op if not stuck;
/// otherwise the standard 4-step teardown: clear <see cref="TargetId"/> +
/// <see cref="Initialized"/>, tell the host to <c>clear_target</c> (drop
/// the voyeur subscription), then <c>interrupt_current_movement</c>.
/// </summary>
public void UnStick()
{
if (TargetId == 0)
return;
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
/// <summary>
/// Retail <c>StickyManager::StickTo</c> (0x00555710). Begin following
/// <paramref name="objectId"/>. If already stuck, tears the old stick down
/// first (same 4-step sequence as <see cref="UnStick"/>). Sets the 1 s
/// timeout deadline and registers as a voyeur of the target via the host's
/// <c>set_target(context=0, objectId, radius=0.5, quantum=0.5)</c> — the
/// live target position then arrives asynchronously through
/// <see cref="HandleUpdateTarget"/>.
/// </summary>
/// <param name="objectId">Retail <c>arg2</c> — target object id.</param>
/// <param name="targetRadius">Retail <c>arg3</c> — the target's cylinder
/// radius (feeds <see cref="AdjustOffset"/>'s distance math).</param>
/// <param name="targetHeight">Retail <c>arg4</c> — accepted for call-shape
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
/// the caller-side geometry only).</param>
public void StickTo(uint objectId, float targetRadius, float targetHeight)
{
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
if (TargetId != 0)
{
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
TargetRadius = targetRadius;
TargetId = objectId;
Initialized = false;
StickyTimeoutTime = _host.CurTime + StickyTime;
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s"));
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
_host.SetTarget(0, objectId, 0.5f, 0.5);
}
/// <summary>
/// Retail <c>StickyManager::UseTime</c> (0x00555610). The 1 s watchdog: if
/// <c>Timer::cur_time &gt;= sticky_timeout_time</c>, force-unstick (same
/// 4-step teardown). The deadline is set once in <see cref="StickTo"/> and
/// NOT refreshed by <see cref="HandleUpdateTarget"/> (retail + ACE) — a
/// stick survives at most 1 s of wall-clock unless re-issued.
/// </summary>
public void UseTime()
{
if (TargetId == 0)
return;
// Strictly AFTER the deadline (retail 0x00555626 `test ah,0x41` —
// C0|C3 clear = cur_time > timeout; ACE `>` too), not >=.
if (_host.CurTime > StickyTimeoutTime)
{
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
/// <summary>
/// Retail <c>StickyManager::HandleUpdateTarget</c> (0x00555780). The
/// target-position callback (fanned out from
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>).
/// Ignores updates whose <see cref="TargetInfo.ObjectId"/> doesn't match
/// our <see cref="TargetId"/>. On <see cref="TargetStatus.Ok"/>: cache the
/// target position and mark <see cref="Initialized"/>. On any other status
/// (lost/exit/teleport): tear the stick down (4-step).
/// </summary>
public void HandleUpdateTarget(TargetInfo info)
{
if (info.ObjectId != TargetId)
return;
if (info.Status == TargetStatus.Ok)
{
Initialized = true;
TargetPosition = info.TargetPosition;
return;
}
if (TargetId != 0)
{
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}"));
TargetId = 0;
Initialized = false;
_host.ClearTarget();
_host.InterruptCurrentMovement();
}
}
/// <summary>
/// Retail <c>StickyManager::adjust_offset</c> (0x00555430). Writes this
/// tick's follow steering into the shared <paramref name="offset"/>
/// accumulator: a speed-clamped horizontal position delta toward the
/// target plus a bounded turn to face it. No-op unless stuck AND
/// initialized. See port-plan §2a for the x87-mush decode.
/// </summary>
/// <param name="offset">The per-tick delta frame
/// (<see cref="PositionManager.AdjustOffset"/>'s shared accumulator).</param>
/// <param name="quantum">Elapsed time this tick, seconds.</param>
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
{
if (TargetId == 0 || !Initialized)
return;
var self = _host.Position;
var target = _host.GetObjectA(TargetId);
var targetPos = target != null ? target.Position : TargetPosition;
// offset = local-frame, Z-flattened vector from self to target.
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
local.Z = 0f;
offset.Origin = local;
// Signed horizontal cylinder distance past the 0.3 m stick gap.
float dist = MoveToMath.CylinderDistanceNoZ(
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
offset.Origin = Vector3.Zero;
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
float speed = 0f;
float? maxSpeed = _host.MinterpMaxSpeed;
if (maxSpeed.HasValue)
speed = maxSpeed.Value * FollowSpeedFactor;
if (speed < MoveToMath.Epsilon)
speed = FallbackFollowSpeed;
// Don't overshoot: clamp the per-tick step to the remaining (signed)
// distance — a negative dist inverts the direction (back off).
//
// DEEP-OVERLAP SIGN PIN (#171 gate-3, register AP row): ACE's literal
// line is only `if (delta >= |dist|) delta = dist;` — when the
// overlap is DEEPER than one tick's step, delta keeps its positive
// sign and steers TOWARD the target center, a runaway whose
// equilibrium is centers-coincident (gate-3 probe: 1661 deep-overlap
// ticks, all inward, monsters converged to centerDist≈0 — the
// "monster inside the player" report; retail side-by-side shows
// separation). ACE servers essentially never reach that branch
// (quantum ≥1/30 × speed ≈31 → threshold ~1 m); at render-rate
// quanta the threshold is ~0.13 m and pack jostle trips it
// constantly. The BN mush (0x00555554-0x00555597) is unreadable on
// exactly this compare; the sign-correct clamp below is the minimal
// interpretation consistent with the mush structure AND observed
// retail — identical to ACE everywhere except deep-overlap, where it
// backs off rate-limited instead of creeping inward.
float delta = speed * (float)quantum;
if (delta >= MathF.Abs(dist))
delta = dist;
else if (dist < 0f)
delta = -delta;
offset.Origin *= delta;
// Bounded turn to face the target (relative heading this tick).
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
float heading = targetHeading - curHeading;
if (MathF.Abs(heading) < MoveToMath.Epsilon)
heading = 0f;
if (heading < -MoveToMath.Epsilon)
heading += 360f;
offset.SetHeading(heading);
if (PhysicsDiagnostics.ProbeStickyEnabled)
Console.WriteLine(FormattableString.Invariant(
$"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}"));
}
}

View file

@ -0,0 +1,300 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
///
/// <list type="bullet">
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
/// registers this object as a voyeur ON a target; the target's per-tick
/// <see cref="HandleTargetting"/> pushes position updates back here via
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
/// MoveToManager + PositionManager (sticky) through
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
/// dead-reckoned update to any subscriber the object has drifted past the
/// subscriber's radius from.</item>
/// </list>
///
/// <para>This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow
/// polling the entity table). It is a faithful superset: the same
/// move-to tracking (distance &gt; radius → HandleUpdateTarget(Ok)) plus the
/// correct sticky, 10 s timeout, and exit/teleport event handling.</para>
///
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
/// </summary>
public sealed class TargetManager
{
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
/// the voyeur sweep runs at most this often.</summary>
public const double ThrottleSeconds = 0.5;
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
/// Undefined-status target with no update for this long is marked
/// TimedOut.</summary>
public const double StalenessSeconds = 10.0;
private readonly IPhysicsObjHost _host;
private TargetInfo? _targetInfo; // retail target_info (watcher role)
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
private double _lastUpdateTime; // retail last_update_time (throttle base)
public TargetManager(IPhysicsObjHost host)
=> _host = host ?? throw new ArgumentNullException(nameof(host));
/// <summary>The current watched-target info, or null when tracking
/// nothing.</summary>
public TargetInfo? TargetInfo => _targetInfo;
/// <summary>The subscriber table (null until the first
/// <see cref="AddVoyeur"/>).</summary>
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
/// <summary>Retail <c>get_target_quantum</c> — the current target's
/// quantum, 0 when tracking nothing.</summary>
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
// ── watcher role ───────────────────────────────────────────────────────
/// <summary>
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
/// Undefined) and subscribe as a voyeur ON the target
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
/// </summary>
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
{
ClearTarget();
if (objectId == 0)
{
// Clear/cancel: report a TimedOut update carrying the context,
// leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.)
var cleared = new TargetInfo(
ObjectId: 0, Status: TargetStatus.TimedOut,
TargetPosition: default, InterpolatedPosition: default,
ContextId: contextId);
_host.HandleUpdateTarget(cleared);
return;
}
_targetInfo = new TargetInfo(
ObjectId: objectId, Status: TargetStatus.Undefined,
TargetPosition: default, InterpolatedPosition: default,
ContextId: contextId, Radius: radius, Quantum: quantum,
LastUpdateTime: _host.CurTime);
var target = _host.GetObjectA(objectId);
target?.AddVoyeur(_host.Id, radius, quantum);
}
/// <summary>
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
/// current target's resend interval and re-register the voyeur subscription
/// on the target with the new quantum.
/// </summary>
public void SetTargetQuantum(double quantum)
{
if (_targetInfo is not { } ti)
return;
_targetInfo = ti with { Quantum = quantum };
var target = _host.GetObjectA(ti.ObjectId);
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
}
/// <summary>
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
/// </summary>
public void ClearTarget()
{
if (_targetInfo is not { } ti)
return;
var target = _host.GetObjectA(ti.ObjectId);
target?.RemoveVoyeur(_host.Id);
_targetInfo = null;
}
/// <summary>
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
/// handler when a target we watch sends us its position (via
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). Ignores updates
/// for anything but our current target. Copies the payload, stamps receipt
/// time, recomputes the self→target interpolated heading (falls back to +Z
/// when degenerate), fans the snapshot to the host, and drops the
/// subscription on an ExitWorld status.
/// </summary>
public void ReceiveUpdate(TargetInfo update)
{
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
return;
// Copy radius/quantum/positions/velocity/status from the wire; keep our
// object_id; stamp receipt time.
Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin
- _host.Position.Frame.Origin;
if (MoveToMath.NormalizeCheckSmall(ref interpHeading))
interpHeading = Vector3.UnitZ;
var updated = ti with
{
Radius = update.Radius,
Quantum = update.Quantum,
TargetPosition = update.TargetPosition,
InterpolatedPosition = update.InterpolatedPosition,
Velocity = update.Velocity,
Status = update.Status,
InterpolatedHeading = interpHeading,
LastUpdateTime = _host.CurTime,
};
_targetInfo = updated;
_host.HandleUpdateTarget(updated);
if (update.Status == TargetStatus.ExitWorld)
ClearTarget();
}
// ── watched role ───────────────────────────────────────────────────────
/// <summary>
/// Retail <c>TargetManager::AddVoyeur</c> (0x0051a830). A subscriber
/// registers to watch this object. If already subscribed, updates its
/// radius/quantum in place (no immediate send); otherwise creates the entry
/// and pushes an immediate initial snapshot (<c>Ok</c>).
/// </summary>
public void AddVoyeur(uint watcherId, float radius, double quantum)
{
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
if (_voyeurTable.TryGetValue(watcherId, out var existing))
{
existing.Radius = radius;
existing.Quantum = quantum;
return;
}
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum);
_voyeurTable[watcherId] = voyeur;
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
}
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
public bool RemoveVoyeur(uint watcherId)
=> _voyeurTable?.Remove(watcherId) ?? false;
/// <summary>
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
/// driver (no separate <c>UseTime</c>): self-throttled to
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
/// <see cref="CheckAndUpdateVoyeur"/>.
/// </summary>
public void HandleTargetting()
{
if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds)
return;
if (_targetInfo is { } ti)
{
if (ti.Status == TargetStatus.Undefined
&& ti.LastUpdateTime + StalenessSeconds < _host.CurTime)
{
var timedOut = ti with { Status = TargetStatus.TimedOut };
_targetInfo = timedOut;
_host.HandleUpdateTarget(timedOut);
}
}
if (_voyeurTable != null)
{
foreach (var voyeur in _voyeurTable.Values.ToList())
CheckAndUpdateVoyeur(voyeur);
}
_lastUpdateTime = _host.PhysicsTimerTime;
}
/// <summary>
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
/// position has drifted more than the voyeur's radius since the last send.
/// </summary>
public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)
{
Position newPos = GetInterpolatedPosition(voyeur.Quantum);
float drift = Vector3.Distance(
newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin);
if (drift > voyeur.Radius)
SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok);
}
/// <summary>
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
/// seconds using its current velocity.
/// </summary>
public Position GetInterpolatedPosition(double quantum)
{
var pos = _host.Position;
Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum;
return new Position(pos.ObjCellId, origin, pos.Frame.Orientation);
}
/// <summary>
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
/// this object's CURRENT authoritative position + the extrapolated
/// <paramref name="pos"/> + velocity + status, and deliver it to the
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
/// </summary>
public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)
{
voyeur.LastSentPosition = pos;
var info = new TargetInfo(
ObjectId: _host.Id,
Status: status,
TargetPosition: _host.Position, // current authoritative
InterpolatedPosition: pos, // the extrapolated position
ContextId: 0,
Radius: voyeur.Radius,
Quantum: voyeur.Quantum,
Velocity: _host.Velocity);
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
voyeurObj?.ReceiveTargetUpdate(info);
}
/// <summary>
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
/// with this object's CURRENT position — no distance gate.
/// </summary>
public void NotifyVoyeurOfEvent(TargetStatus status)
{
if (_voyeurTable == null)
return;
foreach (var voyeur in _voyeurTable.Values.ToList())
SendVoyeurUpdate(voyeur, _host.Position, status);
}
}

View file

@ -0,0 +1,36 @@
namespace AcDream.Core.Physics.Motion;
/// <summary>
/// R5 — port of retail's <c>TargettedVoyeurInfo</c> (acclient.h:52807,
/// struct #5801). One entry in a <see cref="TargetManager"/>'s voyeur table:
/// a subscriber watching THIS object, the send-on-move <see cref="Radius"/>
/// threshold it registered, its dead-reckoning <see cref="Quantum"/>, and the
/// <see cref="LastSentPosition"/> already delivered to it (the delta baseline
/// <c>CheckAndUpdateVoyeur</c> compares against). Mutable class (retail heap
/// record updated in place by <c>AddVoyeur</c>/<c>SendVoyeurUpdate</c>).
/// </summary>
public sealed class TargettedVoyeurInfo
{
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
public uint ObjectId { get; }
/// <summary>+0x04 retail <c>quantum</c> — the subscriber's dead-reckoning
/// lookahead horizon (seconds).</summary>
public double Quantum { get; set; }
/// <summary>+0x10 retail <c>radius</c> — the send-on-move threshold: an
/// update is pushed only when the tracked object drifts more than this from
/// <see cref="LastSentPosition"/>.</summary>
public float Radius { get; set; }
/// <summary>+0x14 retail <c>last_sent_position</c> — the position last
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
public Position LastSentPosition { get; set; }
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
{
ObjectId = objectId;
Radius = radius;
Quantum = quantum;
}
}

View file

@ -1,12 +1,8 @@
using System;
using System.Collections.Generic;
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
namespace AcDream.Core.Physics;
/// <summary>
/// Reconstructs the 32-bit retail <see cref="DRWMotionCommand"/> value from
/// a 16-bit wire value broadcast in <c>InterpretedMotionState.Commands[]</c>.
/// Reconstructs the 32-bit retail MotionCommand value from a 16-bit wire
/// value broadcast in <c>InterpretedMotionState.Commands[]</c>.
///
/// <para>
/// The server serializes MotionCommands as <c>u16</c> (ACE
@ -19,11 +15,19 @@ namespace AcDream.Core.Physics;
/// </para>
///
/// <para>
/// This is implemented as an eager lookup table built from all values of
/// <see cref="DRWMotionCommand"/> via reflection. If the wire value matches
/// more than one enum value (different class bits), we prefer the
/// lowest-class-numbered variant that has a non-zero class byte — roughly
/// matching retail priority (Action &lt; Modifier &lt; SubState &lt; Style).
/// As of the L.1b command-catalog slice, this static facade delegates to a
/// single shared <see cref="AceModernCommandCatalog"/> instance — the
/// runtime-default catalog built from the DatReaderWriter
/// <c>MotionCommand</c> enum (matches ACE + the local DATs). All ~10
/// existing runtime callers (<c>AnimationCommandRouter</c>,
/// <c>CombatAnimationPlanner</c>, 8x <c>GameWindow</c>) are unaffected by
/// this refactor — the public signature and behavior for the ACE/runtime
/// path is unchanged. A second catalog,
/// <see cref="Retail2013CommandCatalog"/>, is available for
/// conformance/reference work against the Sept 2013 EoR decomp's own
/// (differently-numbered) command table; callers that need that catalog
/// must construct it directly via the <see cref="IMotionCommandCatalog"/>
/// seam — this facade only ever returns ACE/runtime values.
/// </para>
///
/// <para>
@ -42,66 +46,26 @@ namespace AcDream.Core.Physics;
/// <c>docs/research/deepdives/r03-motion-animation.md</c> §3 — complete
/// command catalogue.
/// </description></item>
/// <item><description>
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> —
/// the ACE-vs-2013 catalog divergence that motivated the dual-catalog
/// seam (the shift begins at <c>SnowAngelState</c>, 0x43000115 -&gt;
/// 0x43000118, not at <c>AllegianceHometownRecall</c>).
/// </description></item>
/// </list>
/// </para>
/// </summary>
public static class MotionCommandResolver
{
// Lookup table built eagerly at type-init. Sparse: only values that
// appear in the DRW enum (which came from the generated protocol XML)
// are present. ~450 entries typical.
private static readonly Dictionary<ushort, uint> s_lookup = BuildLookup();
private static readonly AceModernCommandCatalog s_aceModern = new();
/// <summary>
/// Given a 16-bit wire value, return the full 32-bit MotionCommand
/// (class byte restored). Returns 0 if no matching enum value exists.
/// (class byte restored) per the ACE/runtime catalog. Returns 0 if no
/// matching value exists.
/// </summary>
public static uint ReconstructFullCommand(ushort wireCommand)
{
if (wireCommand == 0) return 0u;
s_lookup.TryGetValue(wireCommand, out var full);
return full;
}
private static Dictionary<ushort, uint> BuildLookup()
{
var result = new Dictionary<ushort, uint>(512);
var values = Enum.GetValues(typeof(DRWMotionCommand));
foreach (DRWMotionCommand v in values)
{
uint full = (uint)v;
ushort lo = (ushort)(full & 0xFFFFu);
if (lo == 0) continue; // Invalid / unmappable
// If a value with this low-16-bit already exists, keep the one
// with the lower class byte (Action=0x10 beats SubState=0x41
// beats Style=0x80). This matches retail: the server tends to
// emit Actions and ChatEmotes far more often than Styles, so
// the Action-class reconstruction is the common case.
if (!result.TryGetValue(lo, out var existing)
|| (full >> 24) < (existing >> 24))
{
result[lo] = full;
}
}
ApplyNamedRetailOverrides(result);
return result;
}
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> result)
{
// The generated DRW enum is shifted by three entries starting at
// AllegianceHometownRecall. The named Sept 2013 retail command_ids
// table is authoritative here:
// named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
// and command-name table lines 1068272-1068313.
//
// These values cover recall, offhand, attack 4-6, and fast/slow punch
// actions. Without the override, wire command 0x0170 reconstructs to
// IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
// animations route as UI commands and never play.
for (ushort lo = 0x016E; lo <= 0x0197; lo++)
result[lo] = 0x10000000u | lo;
return s_aceModern.ReconstructFullCommand(wireCommand);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
///
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
///
/// <list type="bullet">
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
/// OLDER object incarnation than the one we know is dropped before any
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
/// version exists; acdream adopts-and-applies instead — register row
/// AD-32 records the divergence.</item>
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
/// when its movement sequence is STRICTLY newer than the stored stamp
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
/// server-control gate — a movement sequence is consumed even when the
/// event is subsequently dropped for stale server control.</item>
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
/// dropped when the STORED server-control stamp is strictly newer than
/// the incoming one (a newer server-control era has already been seen);
/// equal passes and re-stamps.</item>
/// </list>
///
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
/// cross-checked against the decomp's branch structure.</para>
/// </summary>
public sealed class MotionSequenceGate
{
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
private bool _seeded;
/// <summary>
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
/// the numerically SMALLER value is the newer one (the counter wrapped).
/// </summary>
public static bool IsNewer(ushort oldStamp, ushort newStamp)
{
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
return newStamp < oldStamp;
return oldStamp < newStamp;
}
/// <summary>
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
/// block. Retail initializes <c>update_times</c> wholesale from the 9
/// u16s at the tail of PhysicsDesc (ACE
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
/// PhysicsTimeStamp enum order); without this, an entity whose movement
/// sequence is already past 0x8000 at spawn would have every subsequent
/// movement event judged stale against a zero stamp.
///
/// <para>The first Seed adopts the stamps wholesale (fresh object);
/// subsequent Seeds only move stamps FORWARD. This makes the #138
/// rehydrate path (which replays a RETAINED CreateObject through the
/// spawn handler) a no-op instead of a stamp regression, while a genuine
/// wire re-create (server sequences only advance) still seeds correctly.
/// Retail has no equivalent replay path — its objects keep their stamps
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
/// </summary>
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
if (!_seeded)
{
_instanceTs = instanceSeq;
_movementTs = movementSeq;
_serverControlTs = serverControlSeq;
_seeded = true;
return;
}
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
}
/// <summary>
/// Apply the retail three-stamp gate to an inbound movement event.
/// Returns true when the event should be applied (stamps updated),
/// false when it must be dropped as stale/duplicate/superseded.
/// </summary>
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
{
// Dispatch-level incarnation gate — runs before any other stamp is
// touched (retail drops at DispatchSmartBoxEvent, never reaching
// SetObjectMovement).
if (IsNewer(instanceSeq, _instanceTs))
return false;
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
// Gate 1: movement sequence must be strictly newer.
if (!IsNewer(_movementTs, movementSeq))
return false;
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
// Gate 2: drop when a newer server-control era has already been seen.
if (IsNewer(serverControlSeq, _serverControlTs))
return false;
_serverControlTs = serverControlSeq;
return true;
}
}

View file

@ -66,6 +66,12 @@ public enum TransientStateFlags : uint
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
// retail frames_stationary_fall carried across frames: transition() seeds fsf from
// these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf
// into them at the end of the frame (pc:282743/282749/282753).
StationaryFall = 0x00000010, // bit 4 — fsf == 1
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
}
@ -115,6 +121,22 @@ public sealed class PhysicsBody
/// </summary>
public Position CellPosition { get; private set; }
/// <summary>
/// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
/// <c>physics_obj-&gt;cell != 0</c> "placed in the world" truth (register
/// row). CMotionInterp's dispatch tails strip link animations for
/// DETACHED objects only (<c>if (cell == 0) RemoveLinkAnimations</c>,
/// raw @305627); the previous proxy — <see cref="CellPosition"/>.<c>ObjCellId
/// == 0</c> — was seeded ONLY by the local player's <see cref="SnapToCell"/>
/// (#145 machinery), so every REMOTE body read "detached" forever and
/// every dispatched transition link (door swings, walk↔run links) was
/// stripped the same tick it was appended. Set by <see cref="SnapToCell"/>
/// (local player placement) and by RemoteMotion construction (remotes
/// exist only for world entities). Default false = detached, matching
/// retail's pre-enter_world state.
/// </summary>
public bool InWorld { get; set; }
/// <summary>
/// Placement: set the world position AND seed <see cref="CellPosition"/> from the
/// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the
@ -137,6 +159,7 @@ public sealed class PhysicsBody
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
LandDefs.AdjustToOutside(ref cell, ref local);
CellPosition = new Position(cell, new CellFrame(local, Orientation));
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
}
// Mirror a world-position translation into the cell-relative frame. Velocity is
@ -162,9 +185,28 @@ public sealed class PhysicsBody
/// <summary>Orientation quaternion (struct offsets 0x600x80 column matrix).</summary>
public Quaternion Orientation { get; set; } = Quaternion.Identity;
/// <summary>World-space velocity (+0xE0/E4/E8).</summary>
/// <summary>World-space velocity (+0xE0/E4/E8) — retail m_velocityVector: the INTENDED
/// velocity that the integrator advances (gravity, friction, MaxVelocity clamp) and that
/// drives the collision sweep. Zeroed by handle_all_collisions when
/// <see cref="FramesStationaryFall"/> &gt; 1 (the "bleed on block").</summary>
public Vector3 Velocity { get; set; }
/// <summary>Retail cached_velocity — a SEPARATE field from <see cref="Velocity"/>: the
/// REALIZED velocity (resolved displacement / dt) written after each transition in
/// UpdateObjectInternal (0x005158cb-005158ff, pc:283693). Read only for reporting /
/// dead-reckoning / camera slope-align (get_velocity 0x005113c0); it is NEVER fed back
/// into <see cref="Velocity"/> or the integrator. Kept separate per the verified retail
/// two-velocity model — do not collapse the two.</summary>
public Vector3 CachedVelocity { get; set; }
/// <summary>Retail collision_info.frames_stationary_fall carried on the body between
/// frames. ValidateTransition increments it (0→1→2→3) when the sphere fails to advance and
/// resets it to 0 when it moves; at fsf≥2 an upward contact plane is manufactured;
/// handle_all_collisions zeros <see cref="Velocity"/> when fsf&gt;1 (the airborne-stuck
/// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
/// pc:272625-656; handle_all_collisions pc:282695.</summary>
public int FramesStationaryFall { get; set; }
/// <summary>World-space acceleration (+0xEC/F0/F4).</summary>
public Vector3 Acceleration { get; set; }
@ -231,6 +273,38 @@ public sealed class PhysicsBody
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
public double LastUpdateTime { get; set; }
/// <summary>
/// R3-W3 stub for retail <c>CPhysicsObj::IsFullyConstrained</c>
/// (0x0050f730), read by <c>CMotionInterp::jump_is_allowed</c> (raw
/// 305524-305525: <c>if (IsFullyConstrained(physics_obj) != 0) return
/// 0x47;</c>). Retail's body walks per-cell contact-plane constraints
/// (a mover pinned between opposing walkable surfaces / doorway
/// jamming); acdream has no equivalent constraint-tracking yet.
/// Register row: stubbed false (never fires) — a real port needs the
/// per-cell shadow-list contact accounting the physics digest tracks.
/// See docs/architecture/retail-divergence-register.md (added same
/// commit as this field).
/// </summary>
public bool IsFullyConstrained { get; set; }
/// <summary>
/// R3-W4 — retail <c>CPhysicsObj::last_move_was_autonomous</c>, read by
/// <c>CPhysicsObj::movement_is_autonomous</c> (0x0050eb30, decomp §7a
/// @276443: <c>return this-&gt;last_move_was_autonomous;</c>). Gates the
/// A3 dual-dispatch predicate in <c>MotionInterpreter.apply_current_movement</c>/
/// <c>ReportExhaustion</c>/<c>SetWeenieObject</c>/<c>SetPhysicsObject</c>:
/// true means the last motion on this body was locally-simulated
/// (player input / local prediction), false means it was a
/// server-driven dead-reckoning update. Set true at the local-player
/// input chokepoint (App layer — <c>PlayerMovementController</c>);
/// left false (the safe default — routes to
/// <c>apply_interpreted_movement</c>) for DR-applied remote updates.
/// <see cref="MotionInterpreter.LeaveGround"/> also sets this true
/// itself: retail's <c>set_local_velocity(&amp;var_c, 1)</c> call passes
/// the autonomous flag literal <c>1</c> (raw @305763-305765).
/// </summary>
public bool LastMoveWasAutonomous { get; set; }
// ── convenience helpers ────────────────────────────────────────────────
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
@ -310,10 +384,23 @@ public sealed class PhysicsBody
/// worldY = col0.y*localX + col1.y*localY + col2.y*localZ
/// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ
/// We replicate this as a Quaternion rotation, which is equivalent.
///
/// <para>
/// R3-W4: retail's <c>set_local_velocity</c> takes a second
/// <c>autonomous</c> arg (<c>CPhysicsObj::set_local_velocity</c>,
/// stores it to <see cref="LastMoveWasAutonomous"/> — read by
/// <c>CPhysicsObj::movement_is_autonomous</c>, the A3 dual-dispatch
/// predicate). Defaults to <c>false</c> to preserve every pre-W4 call
/// site's behavior (server/interpreted-driven callers never asserted
/// autonomy); <see cref="MotionInterpreter.LeaveGround"/> is the one
/// caller that passes <c>true</c> (raw @305763-305765,
/// <c>set_local_velocity(&amp;var_c, 1)</c>).
/// </para>
/// </summary>
public void set_local_velocity(Vector3 localVelocity)
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
{
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
LastMoveWasAutonomous = autonomous;
set_velocity(worldVelocity);
}
@ -435,11 +522,12 @@ public sealed class PhysicsBody
calc_friction(dt, velocityMag2);
// If velocity fell below the "small" threshold after friction, stop.
// Only apply when grounded — while airborne, gravity must accumulate
// even when velocity is near zero (e.g., at jump apex).
if (velocityMag2 - SmallVelocitySquared < 0.0002f
&& TransientState.HasFlag(TransientStateFlags.OnWalkable))
// Retail UpdatePhysicsInternal (0x005107be): zero velocity below 0.25 m/s
// UNCONDITIONALLY — NOT gated on OnWalkable. At the jump apex this zeros the
// residual horizontal drift; the unconditional `Velocity += Acceleration * dt`
// below immediately re-applies gravity, so the fall still accumulates. (The old
// OnWalkable gate was an acdream divergence; the verbatim rebuild removes it.)
if (velocityMag2 - SmallVelocitySquared < 0.0002f)
Velocity = Vector3.Zero;
// Euler integration: position += v*dt + 0.5*a*dt²

View file

@ -80,6 +80,20 @@ public static class PhysicsDiagnostics
public static bool ProbeCellSetEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1";
/// <summary>
/// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe.
/// One <c>[sticky]</c> line per StickyManager lifecycle event (STICK /
/// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed
/// <c>AdjustOffset</c> tick (guid, signed gap distance, applied delta,
/// heading delta), plus <c>[sticky-snap-skip]</c> lines at the NPC
/// UpdatePosition handler when a server hard-snap is suppressed because
/// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck
/// count); capture-session only. All lines carry the guid
/// (feedback_probe_identity_attribution).
/// </summary>
public static bool ProbeStickyEnabled { get; set; }
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1";
public static void LogCellSetBuild(
uint seedCellId,
System.Numerics.Vector3 sphereCenter,
@ -562,6 +576,7 @@ public static class PhysicsDiagnostics
ProbeCellEnabled = false;
ProbeBuildingEnabled = false;
ProbeCellSetEnabled = false;
ProbeStickyEnabled = false;
ProbeAutoWalkEnabled = false;
ProbeUseabilityFallbackEnabled= false;
DumpSteepRoofEnabled = false;

View file

@ -971,6 +971,11 @@ public sealed class PhysicsEngine
// (matches non-player movement, all targets collide).
transition.ObjectInfo.State |= moverFlags;
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
// in ValidateTransition runs for gravity movers (the player) and not floating props.
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
if (isOnGround)
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
@ -1043,6 +1048,19 @@ public sealed class PhysicsEngine
body.WalkableUp);
}
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
// doesn't wipe the seed.
if (body is not null)
{
transition.CollisionInfo.FramesStationaryFall =
body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 :
body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 :
body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0;
}
bool ok = transition.FindTransitionalPosition(this);
var sp = transition.SpherePath;
@ -1072,6 +1090,23 @@ public sealed class PhysicsEngine
body.ContactPlaneValid = false;
}
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
body.FramesStationaryFall = ci.FramesStationaryFall;
body.TransientState &= ~(TransientStateFlags.StationaryFall
| TransientStateFlags.StationaryStop
| TransientStateFlags.StationaryStuck);
body.TransientState |= ci.FramesStationaryFall switch
{
1 => TransientStateFlags.StationaryFall,
2 => TransientStateFlags.StationaryStop,
3 => TransientStateFlags.StationaryStuck,
_ => TransientStateFlags.None,
};
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
{
body.WalkablePolygonValid = true;
@ -1085,16 +1120,27 @@ public sealed class PhysicsEngine
body.WalkableVertices = null;
}
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
// Retail persists sliding state to the body ONLY on transition
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
// is unreachable when find_valid_position fails (the transition is
// discarded whole; the body keeps its prior state). #137 mechanism
// 2: an unconditional writeback here could persist a normal retail
// would discard.
if (ok)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
if (ci.SlidingNormalValid
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
body.SlidingNormal = ci.SlidingNormal;
body.TransientState |= TransientStateFlags.Sliding;
}
else
{
body.SlidingNormal = Vector3.Zero;
body.TransientState &= ~TransientStateFlags.Sliding;
}
}
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.

View file

@ -0,0 +1,78 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Verbatim port of the collision-response tail of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c>: the velocity decision that
/// <c>SetPositionInternal</c> (0x00515330) drives through
/// <c>handle_all_collisions</c> (0x00514780, pc:282647). Kept as a pure function over a
/// <see cref="PhysicsBody"/> + the resolve outcome so the whole decision is unit-testable
/// in Core, independent of the App per-frame loop. The transition INTERNALS
/// (<c>ResolveWithTransition</c> and below) are untouched.
/// </summary>
public static class PhysicsObjUpdate
{
/// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
/// <see cref="PhysicsBody.FramesStationaryFall"/>:
/// <list type="bullet">
/// <item>fsf ≤ 1 → reflect the into-surface component
/// (<c>v += -(v·n)(elasticity+1)·n</c>, pc:282712) when we should reflect and a
/// collision normal is valid; an INELASTIC mover zeros instead (pc:282720).</item>
/// <item>fsf &gt; 1 → <c>v = 0</c> entirely (pc:282729) — the "bleed on block" that
/// lets gravity resume so a blocked jump falls/glides off (fixes the #182
/// airborne-stuck wedge).</item>
/// </list>
/// The Stationary* transient-bit round-trip (pc:282737-758) is owned by the Core resolve
/// writeback (<c>PhysicsEngine.ResolveWithTransition</c>), not re-encoded here.
/// </summary>
/// <param name="body">The mover.</param>
/// <param name="collisionNormalValid">Was a wall/creature collision normal recorded this resolve.</param>
/// <param name="collisionNormal">Outward collision normal (points away from the surface).</param>
/// <param name="prevContact">Whether the body had Contact BEFORE this resolve committed
/// (retail arg3 — reserved for environment-collision reporting; unused today).</param>
/// <param name="prevOnWalkable">Whether the body was OnWalkable before this resolve (retail arg4).</param>
/// <param name="nowOnWalkable">Whether the body is OnWalkable after this resolve.</param>
public static void HandleAllCollisions(
PhysicsBody body,
bool collisionNormalValid, Vector3 collisionNormal,
bool prevContact, bool prevOnWalkable, bool nowOnWalkable)
{
// var_10_1 (pc:282653-282657): reflect UNLESS the mover stays on walkable ground
// (and is not sledding). This restores retail's broader rule — the AD-25 airborne-only
// suppression is retired: the landing-snap fragility it guarded is gone (landing state
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
// false), exactly as retail.
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
if (body.FramesStationaryFall <= 1)
{
if (shouldReflect && collisionNormalValid)
{
if (body.State.HasFlag(PhysicsStateFlags.Inelastic))
{
body.Velocity = Vector3.Zero; // pc:282720-282722
}
else
{
float dot = Vector3.Dot(body.Velocity, collisionNormal);
if (dot < 0f) // moving INTO the surface
{
float k = -(dot * (body.Elasticity + 1f)); // pc:282712
body.Velocity += collisionNormal * k;
}
}
}
}
else
{
body.Velocity = Vector3.Zero; // fsf>1 → THE BLEED (pc:282729)
}
_ = prevContact; // retail report_environment_collision(arg3) — weenie collision events, later.
}
}

View file

@ -43,6 +43,24 @@ public sealed class PlayerWeenie : IWeenieObject
public bool CanJump(float extent) => true; // burden/stamina checks deferred
/// <summary>
/// R3-W3 (W0-pins.md A3): the local player's weenie is THE player.
/// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c>
/// dual-dispatch gate — no consumer yet in W3.
/// </summary>
public bool IsThePlayer() => true;
/// <summary>
/// TS-5 (extended): stamina cost gating deferred pending stat plumbing —
/// always affordable, cost 0. Matches <see cref="CanJump"/>'s existing
/// always-true stance.
/// </summary>
public bool JumpStaminaCost(float extent, out int cost)
{
cost = 0;
return true;
}
/// <summary>
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
/// Capped at 4.5 when runSkill >= 800.

View file

@ -0,0 +1,322 @@
using AcDream.Core.Physics.Motion;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail hold-key enum (<c>acclient.h</c> <c>enum HoldKey</c>, line 3394).
/// </summary>
public enum HoldKey : uint
{
Invalid = 0x0,
None = 0x1,
Run = 0x2,
}
/// <summary>
/// One entry in <see cref="RawMotionState.Actions"/> — a discrete motion
/// event (jump, attack, etc.) layered on top of the continuous
/// forward/sidestep/turn axes.
///
/// <para>
/// Retail's in-memory action node (<c>RawMotionState::AddAction</c>,
/// 0x0051e840, and <c>InterpretedMotionState::AddAction</c>, 0x0051e9e0 —
/// both a 0x14-byte <c>LListData</c>-derived node) carries FOUR fields:
/// <c>motion</c> (+4), <c>speed</c> (+8), <c>action_stamp</c> (+0xc),
/// <c>autonomous</c> (+0x10). <see cref="Speed"/> is the in-memory-only
/// field the R3-W1 action FIFO needs for <c>ApplyMotion</c>'s velocity
/// bookkeeping; it is NOT part of the wire encoding.
/// </para>
///
/// <para>
/// Retail packs each action ON THE WIRE as <c>u16 command</c> then
/// <c>u16 ((stamp &amp; 0x7FFF) | (autonomous ? 0x8000 : 0))</c>
/// (<c>RawMotionState::Pack</c>, 0x0051ed10, decomp lines ~293998-294010)
/// — <see cref="Command"/>/<see cref="Stamp"/>/<see cref="Autonomous"/> are
/// exactly the packed triple; <see cref="RawMotionStatePacker"/> reads only
/// those three.
/// </para>
/// </summary>
public readonly record struct RawMotionAction(
ushort Command,
ushort Stamp,
bool Autonomous,
float Speed = 1f);
/// <summary>
/// R3-W1 — verbatim, full-field port of retail's <c>RawMotionState</c>
/// (<c>acclient.h</c> <c>RawMotionState::PackBitfield</c>, line 46474; ctor
/// <c>RawMotionState::RawMotionState</c>, 0x0051e7f0, decomp lines
/// 293344-293361; packer at <c>RawMotionState::Pack</c>, 0x0051ed10).
///
/// <para>
/// <b>R3-W1 fold (closes J2):</b> this type now ALSO serves as
/// <c>MotionInterpreter.RawState</c> — the former <c>LegacyRawMotionState</c>
/// (a flat 7-field struct with no action FIFO) is deleted. One raw-state
/// type end to end: the physics-side motion interpreter mutates it via
/// <see cref="ApplyMotion"/>/<see cref="RemoveMotion"/>/
/// <see cref="AddAction"/>/<see cref="RemoveAction"/>, and the SAME
/// instance's <see cref="Actions"/> is read by <see cref="RawMotionStatePacker"/>
/// when building the outbound wire packet. Previously the packer's action
/// list was always empty (register TS-24) because nothing populated it —
/// starting R3-W2 (the <c>add_to_queue</c>/<c>MotionDone</c> port),
/// <c>CMotionInterp</c> populates this list locally exactly like retail
/// does, so the packed bytes will start reflecting real queued actions.
/// W1 itself does not change what gets packed (no caller invokes
/// <see cref="AddAction"/> yet) — only adds the capability + tests.
/// </para>
///
/// <para>
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
/// <c>AcDream.Core.Physics</c> so the motion interpreter, which is
/// also Core.Physics, can populate it without taking a Core.Net dependency
/// (Code Structure Rule #2).
/// </para>
/// </summary>
public sealed class RawMotionState
{
/// <summary>Retail <c>current_holdkey</c> (ctor default HoldKey_None).</summary>
public HoldKey CurrentHoldKey { get; set; } = HoldKey.None;
/// <summary>Retail <c>current_style</c> (ctor default 0x8000003D, NonCombat).</summary>
public uint CurrentStyle { get; set; } = 0x8000003Du;
/// <summary>Retail <c>forward_command</c> (ctor default 0x41000003, Ready).</summary>
public uint ForwardCommand { get; set; } = 0x41000003u;
/// <summary>Retail <c>forward_holdkey</c> (ctor default HoldKey_Invalid).</summary>
public HoldKey ForwardHoldKey { get; set; } = HoldKey.Invalid;
/// <summary>Retail <c>forward_speed</c> (ctor default 1.0).</summary>
public float ForwardSpeed { get; set; } = 1.0f;
/// <summary>Retail <c>sidestep_command</c> (ctor default 0).</summary>
public uint SidestepCommand { get; set; }
/// <summary>Retail <c>sidestep_holdkey</c> (ctor default HoldKey_Invalid).</summary>
public HoldKey SidestepHoldKey { get; set; } = HoldKey.Invalid;
/// <summary>Retail <c>sidestep_speed</c> (ctor default 1.0).</summary>
public float SidestepSpeed { get; set; } = 1.0f;
/// <summary>Retail <c>turn_command</c> (ctor default 0).</summary>
public uint TurnCommand { get; set; }
/// <summary>Retail <c>turn_holdkey</c> (ctor default HoldKey_Invalid).</summary>
public HoldKey TurnHoldKey { get; set; } = HoldKey.Invalid;
/// <summary>Retail <c>turn_speed</c> (ctor default 1.0).</summary>
public float TurnSpeed { get; set; } = 1.0f;
private readonly List<RawMotionAction> _actions = new();
/// <summary>
/// Discrete action FIFO (retail <c>actions</c>, an <c>LListData</c>
/// tail-append queue). Retail packs <c>num_actions</c> (count, not the
/// values) into bits 11-15 of the flags dword, then emits each action's
/// command+stamp pair unconditionally after the continuous-axis fields
/// (<see cref="RawMotionStatePacker"/>).
///
/// <para>
/// Settable via object-initializer for test fixtures / the wire-packer
/// call sites that build a one-shot snapshot; assigning replaces the
/// whole FIFO. <see cref="AddAction"/>/<see cref="RemoveAction"/> are
/// the retail-faithful mutators for live FIFO discipline.
/// </para>
/// </summary>
public IReadOnlyList<RawMotionAction> Actions
{
get => _actions;
set
{
_actions.Clear();
_actions.AddRange(value);
}
}
/// <summary>
/// Retail defaults — a field bit is set in the packed flags dword ONLY
/// when the live value differs from these (<c>RawMotionState::Pack</c>,
/// 0x0051ed10). NEVER mutate this shared instance.
/// </summary>
public static readonly RawMotionState Default = new();
/// <summary>
/// <c>RawMotionState::AddAction</c> (0x0051e840, decomp 293365-293392):
/// unconditional tail-append of
/// <c>{motion, speed, action_stamp, autonomous}</c>.
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the action motion id.</param>
/// <param name="speed">Retail <c>arg3</c>.</param>
/// <param name="actionStamp">Retail <c>arg4</c>.</param>
/// <param name="autonomous">Retail <c>arg5</c> (nonzero = autonomous).</param>
public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
{
_actions.Add(new RawMotionAction(
Command: (ushort)motion,
Stamp: (ushort)actionStamp,
Autonomous: autonomous,
Speed: speed));
}
/// <summary>
/// <c>RawMotionState::RemoveAction</c> (0x0051e8a0, decomp 293396-293414):
/// pop the FIFO head unconditionally, returning its <c>motion</c> field
/// (0 when empty — retail returns <c>*(head+4)</c>, i.e. the stored
/// <c>arg2</c>/<c>Command</c>, widened; the C# port returns the widened
/// <see cref="RawMotionAction.Command"/> as a <c>uint</c>).
/// </summary>
public uint RemoveAction()
{
if (_actions.Count == 0)
return 0;
var head = _actions[0];
_actions.RemoveAt(0);
return head.Command;
}
/// <summary>
/// <c>RawMotionState::ApplyMotion</c> (0x0051eb60, decomp 293630-293703).
/// Verbatim dispatch, quoted from the raw named decomp:
/// <code>
/// if ((arg2 - 0x6500000d) &gt; 3) { // outside [0x6500000d, 0x65000010]
/// if ((arg2 &amp; 0x40000000) == 0) { // NOT forward-class
/// if (arg2 &gt;= 0) { // NOT style-class (high bit clear)
/// if ((arg2 &amp; 0x10000000) != 0)
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield&gt;&gt;0xc)&amp;1);
/// } else if (current_style != arg2) {
/// forward_command = 0x41000003; current_style = arg2;
/// }
/// } else if (arg2 != 0x44000007) { // forward-class, NOT RunForward
/// forward_command = arg2;
/// if (params.bitfield byte1 &amp; 8 != 0) { forward_holdkey = Invalid; forward_speed = params.speed; }
/// else { forward_holdkey = params.hold_key_to_apply; forward_speed = params.speed; }
/// }
/// // arg2 == 0x44000007 (RunForward) with the forward-class bit set: falls through, no write.
/// return;
/// }
/// switch (arg2) {
/// case 0x6500000d: case 0x6500000e: // TurnRight/TurnLeft
/// turn_command = arg2;
/// if (SetHoldKey bit) { turn_holdkey = Invalid; turn_speed = params.speed; }
/// else { turn_holdkey = params.hold_key_to_apply; turn_speed = params.speed; }
/// return;
/// case 0x6500000f: case 0x65000010: // SideStepRight/SideStepLeft
/// sidestep_command = arg2;
/// if (SetHoldKey bit) { sidestep_holdkey = Invalid; sidestep_speed = params.speed; }
/// else { sidestep_holdkey = params.hold_key_to_apply; sidestep_speed = params.speed; }
/// return;
/// }
/// </code>
/// The <c>arg2 == 0x44000007</c> (RunForward) no-write fallthrough on the
/// forward-class branch is a genuine retail quirk (RunForward bit
/// 0x40000000 IS set, but the `else if (arg2 != 0x44000007)` guard
/// excludes it) — ported verbatim, not "fixed".
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the ORIGINAL (pre-adjustment)
/// motion id per the DoMotion/StopMotion mirror-discipline callers.</param>
/// <param name="p">Retail <c>arg3</c> (<c>MovementParameters const*</c>).</param>
public void ApplyMotion(uint motion, MovementParameters p)
{
if (motion - 0x6500000du > 3u)
{
if ((motion & 0x40000000u) == 0)
{
if (motion < 0x80000000u) // arg2 >= 0 as signed int32
{
if ((motion & 0x10000000u) != 0)
AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
}
else if (CurrentStyle != motion)
{
ForwardCommand = 0x41000003u;
CurrentStyle = motion;
}
}
else if (motion != 0x44000007u)
{
ForwardCommand = motion;
if (p.SetHoldKey)
{
ForwardHoldKey = HoldKey.Invalid;
ForwardSpeed = p.Speed;
}
else
{
ForwardHoldKey = p.HoldKeyToApply;
ForwardSpeed = p.Speed;
}
}
return;
}
switch (motion)
{
case 0x6500000du: // TurnRight
case 0x6500000eu: // TurnLeft
TurnCommand = motion;
if (p.SetHoldKey)
{
TurnHoldKey = HoldKey.Invalid;
TurnSpeed = p.Speed;
}
else
{
TurnHoldKey = p.HoldKeyToApply;
TurnSpeed = p.Speed;
}
return;
case 0x6500000fu: // SideStepRight
case 0x65000010u: // SideStepLeft
SidestepCommand = motion;
if (p.SetHoldKey)
{
SidestepHoldKey = HoldKey.Invalid;
SidestepSpeed = p.Speed;
}
else
{
SidestepHoldKey = p.HoldKeyToApply;
SidestepSpeed = p.Speed;
}
return;
}
}
/// <summary>
/// <c>RawMotionState::RemoveMotion</c> (0x0051e6e0, decomp 293252-293288):
/// <code>
/// if ((arg2 - 0x6500000d) &gt; 3) {
/// if ((arg2 &amp; 0x40000000) == 0) {
/// if (arg2 &lt; 0 &amp;&amp; arg2 == current_style) current_style = 0x8000003d;
/// } else if (arg2 == forward_command) {
/// forward_command = 0x41000003; forward_speed = 1f;
/// }
/// return;
/// }
/// switch (arg2) {
/// case 0x6500000d: case 0x6500000e: turn_command = 0; return;
/// case 0x6500000f: case 0x65000010: sidestep_command = 0; return;
/// }
/// </code>
/// </summary>
/// <param name="motion">Retail <c>arg2</c> — the ORIGINAL motion id.</param>
public void RemoveMotion(uint motion)
{
if (motion - 0x6500000du > 3u)
{
if ((motion & 0x40000000u) == 0)
{
if (motion >= 0x80000000u && motion == CurrentStyle)
CurrentStyle = 0x8000003du;
}
else if (motion == ForwardCommand)
{
ForwardCommand = 0x41000003u;
ForwardSpeed = 1f;
}
return;
}
switch (motion)
{
case 0x6500000du:
case 0x6500000eu:
TurnCommand = 0;
return;
case 0x6500000fu:
case 0x65000010u:
SidestepCommand = 0;
return;
}
}
}

View file

@ -17,8 +17,16 @@ namespace AcDream.Core.Physics;
/// active locomotion cycle). We rotate that by the body's orientation
/// to get a world-space delta, then add the InterpolationManager's
/// world-space correction.
///
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
/// InterpolationManager-composition portion of retail's
/// <c>PositionManager::adjust_offset</c> — NOT the retail PositionManager
/// facade. The faithful facade (Sticky/Constraint, owned per entity) is
/// <see cref="Motion.PositionManager"/>. The name was freed to remove the
/// ambiguity that broke every file importing both
/// <c>AcDream.Core.Physics</c> and <c>AcDream.Core.Physics.Motion</c>.</para>
/// </summary>
public sealed class PositionManager
public sealed class RemoteMotionCombiner
{
/// <summary>
/// Compute the per-frame world-space delta to add to body.Position.

View file

@ -1,340 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Per-tick steering for server-controlled remote creatures while a
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
/// is the active locomotion source.
///
/// <para>
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
/// stabilizer. With the full MoveTo path payload now captured on
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
/// the body solver has the destination + heading + thresholds it needs to
/// run the retail per-tick loop instead of waiting for sparse
/// UpdatePosition snap corrections.
/// </para>
///
/// <para>
/// Retail references:
/// <list type="bullet">
/// <item><description>
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
/// per-tick driver. Computes heading-to-target, fires an aux
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| &gt; 20°, snaps
/// orientation when within tolerance, and tests arrival via
/// <c>dist &lt;= min_distance</c> (chase) or
/// <c>dist &gt;= distance_to_object</c> (flee).
/// </description></item>
/// <item><description>
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
/// MoveToManager itself does NOT touch the body. The body's actual
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
/// the body's orientation.
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Acdream port scope: minimum viable subset. We skip target re-tracking
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
/// StickTo, fail-distance progress detector, and the sphere-cylinder
/// distance variant — all server-side concerns the local body doesn't need
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
/// arrival detection via <c>min_distance</c>.
/// </para>
///
/// <para>
/// ACE divergence: ACE swaps the chase/flee arrival predicates
/// (<c>dist &lt;= DistanceToObject</c> vs retail's <c>dist &lt;= MinDistance</c>).
/// We follow retail.
/// </para>
/// </summary>
public static class RemoteMoveToDriver
{
/// <summary>
/// Heading tolerance below which we snap orientation directly to the
/// target heading (ACE's <c>set_heading(target, true)</c>
/// server-tic-rate fudge). Above tolerance we rotate at
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
/// </summary>
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
/// <summary>
/// Default angular rate for in-motion heading correction when delta
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
/// when the per-creature value differs, the future port can wire it
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
/// </summary>
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail base turn rate for the player Humanoid when turn_speed
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
/// HasOmega-cleared MotionData fallback documented in
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
/// </summary>
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
/// <summary>
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
/// command. Effect: running rotation is 50 % faster than walking.
/// </summary>
public const float RunTurnFactor = 1.5f;
/// <summary>
/// Retail-faithful local-player turn rate.
/// <list type="bullet">
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
/// ≈ 135°/s.</item>
/// </list>
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
/// access to the player's run/walk state (keyboard A/D, auto-walk
/// overlay turn-first). NPC/monster remotes that lack the
/// information continue to use the constant which equals
/// <c>BaseTurnRateRadPerSec</c>.
/// </summary>
public static float TurnRateFor(bool running)
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
: BaseTurnRateRadPerSec;
/// <summary>
/// Float-comparison slack for the arrival predicate. With
/// <c>min_distance == 0</c> in a chase packet, exact equality is
/// unreachable due to integration wobble; this epsilon prevents the
/// driver from over-shooting by a sub-meter and snap-flipping back.
/// </summary>
public const float ArrivalEpsilon = 0.05f;
/// <summary>
/// Maximum staleness (seconds) of the most recent MoveTo packet
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
/// during active chase; if no fresh packet arrives for this long,
/// the entity has likely either left our streaming view, switched
/// to a non-MoveTo motion the server's broadcast didn't reach us
/// for, or had its move cancelled server-side without our seeing
/// the cancel UM. In any of those cases, continuing to drive the
/// body toward a stale destination produces the "monster runs in
/// place after popping back into view" symptom (2026-04-28).
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
/// while still failing fast on real loss-of-state.
/// </summary>
public const double StaleDestinationSeconds = 1.5;
public enum DriveResult
{
/// <summary>Within arrival window — caller should zero velocity.</summary>
Arrived,
/// <summary>Steering active — caller should let
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
Steering,
}
/// <summary>
/// Steer body orientation toward <paramref name="destinationWorld"/>
/// and report whether the body has arrived or should keep running.
/// Pure function — emits the updated orientation via
/// <paramref name="newOrientation"/> (the input is not mutated; the
/// caller assigns the new value back to its body).
/// </summary>
/// <param name="minDistance">
/// <c>min_distance</c> from the wire's MovementParameters block —
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
/// </param>
/// <param name="distanceToObject">
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
/// threshold (default 0.6 m, the melee range). The actual arrival
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
/// when retail sends <c>min_distance</c> &gt; 0, ACE-compatible when
/// ACE puts the value in <c>distance_to_object</c> with
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
/// chase packets never arrive — the body keeps re-targeting around
/// the player at melee range and visibly oscillates between facings,
/// which is the user-reported "monster keeps running in different
/// directions when it should be attacking" symptom (2026-04-28).
/// </param>
public static DriveResult Drive(
Vector3 bodyPosition,
Quaternion bodyOrientation,
Vector3 destinationWorld,
float minDistance,
float distanceToObject,
float dt,
bool moveTowards,
out Quaternion newOrientation)
{
// Horizontal distance only — server owns Z, our body Z is
// hard-snapped to the latest UpdatePosition.
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
// MoveToManager.cs:476:
//
// chase (MoveTowards): dist <= distance_to_object
// flee (MoveAway): dist >= min_distance
//
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
// defensive guess; cross-checked with two independent research
// agents against the named retail decomp + ACE port + holtburger,
// the chase threshold is unambiguously DistanceToObject —
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
// at melee range, not run to zero.)
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
{
newOrientation = bodyOrientation;
return DriveResult.Arrived;
}
// Degenerate — already on target horizontally; preserve heading.
if (dist < 1e-4f)
{
newOrientation = bodyOrientation;
return DriveResult.Steering;
}
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
// World forward = Transform((0,1,0), orientation). Yaw extracted
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
var localForward = new Vector3(0f, 1f, 0f);
var worldForward = Vector3.Transform(localForward, bodyOrientation);
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
// Desired heading: face the target. (dx, dy) is the world-space
// offset to the target. With local-forward=+Y we want yaw such
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
// to yaw = atan2(-dx, dy).
float desiredYaw = MathF.Atan2(-dx, dy);
float delta = WrapPi(desiredYaw - currentYaw);
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
{
// ACE's set_heading(target, true) — sync to server-tic-rate.
// We have the same sparse-UP problem ACE does, so the same
// fudge applies.
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
}
else
{
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
// rotate at TurnRate clamped to dt, in the shorter direction.
float maxStep = TurnRateRadPerSec * dt;
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
// Apply incremental yaw around world +Z (preserving any
// server-supplied pitch/roll from the latest UpdatePosition).
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
}
return DriveResult.Steering;
}
/// <summary>
/// Convert a landblock-local Origin from a MoveTo packet
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
/// into acdream's render world space using the same arithmetic as
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
/// from the live-mode center.
/// </summary>
public static Vector3 OriginToWorld(
uint originCellId,
float originX,
float originY,
float originZ,
int liveCenterLandblockX,
int liveCenterLandblockY)
{
int lbX = (int)((originCellId >> 24) & 0xFFu);
int lbY = (int)((originCellId >> 16) & 0xFFu);
return new Vector3(
originX + (lbX - liveCenterLandblockX) * 192f,
originY + (lbY - liveCenterLandblockY) * 192f,
originZ);
}
/// <summary>
/// Cap horizontal velocity so the body lands exactly at
/// <paramref name="arrivalThreshold"/> rather than overshooting past
/// it during the final tick of approach. Without this clamp, a body
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
/// 60 fps) — visible as the creature "running slightly through" the
/// player it's about to attack (user-reported 2026-04-28).
///
/// <para>
/// The clamp is a strict scale-down of the horizontal component
/// (X/Y); the vertical component (Z) is left to gravity / terrain
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
/// no-op since fleeing has no overshoot risk — the body wants to
/// move AWAY from the destination.
/// </para>
/// </summary>
public static Vector3 ClampApproachVelocity(
Vector3 bodyPosition,
Vector3 currentVelocity,
Vector3 destinationWorld,
float arrivalThreshold,
float dt,
bool moveTowards)
{
if (!moveTowards || dt <= 0f) return currentVelocity;
float dx = destinationWorld.X - bodyPosition.X;
float dy = destinationWorld.Y - bodyPosition.Y;
float dist = MathF.Sqrt(dx * dx + dy * dy);
float remaining = MathF.Max(0f, dist - arrivalThreshold);
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
+ currentVelocity.Y * currentVelocity.Y);
if (vxy < 1e-3f) return currentVelocity;
float advance = vxy * dt;
if (advance <= remaining) return currentVelocity;
// Already inside or right at the threshold: zero horizontal
// velocity, keep Z. (The arrival predicate in Drive() should
// have fired this tick, but this is the belt-and-braces guard.)
if (remaining < 1e-3f)
return new Vector3(0f, 0f, currentVelocity.Z);
float scale = remaining / advance;
return new Vector3(
currentVelocity.X * scale,
currentVelocity.Y * scale,
currentVelocity.Z);
}
/// <summary>Wrap an angle in radians to [-π, π].</summary>
private static float WrapPi(float r)
{
const float TwoPi = MathF.PI * 2f;
r %= TwoPi;
if (r > MathF.PI) r -= TwoPi;
if (r < -MathF.PI) r += TwoPi;
return r;
}
}

View file

@ -0,0 +1,54 @@
namespace AcDream.Core.Physics;
/// <summary>
/// R4-V5 (#160 fix, 2026-07-03): the minimal weenie every REMOTE entity's
/// <see cref="MotionInterpreter"/> carries — standing in for retail's
/// per-object <c>ACCWeenieObject</c>, which every placed physics object has.
///
/// <para>
/// The load-bearing behavior is the RUN-RATE FALLBACK CHAIN:
/// <c>apply_run_to_command</c> (0x00527be0, raw 305062-305076) reads
/// <c>weenie ? (InqRunRate() ?: my_run_rate) : 1.0</c>. Remote weenies in
/// retail FAIL <c>InqRunRate</c> (no local skill data), landing on
/// <c>my_run_rate</c> — exactly the field the mt-6/7 unpack writes with the
/// wire's <c>MoveToRunRate</c> (M13). Our remote interps previously had NO
/// weenie at all, taking the degenerate <c>else 1.0</c> branch retail
/// reserves for detached objects — so an observer-side moveto dispatched
/// RunForward at speed 1.0 instead of the wire rate: the run cycle played
/// in slow motion and the chase velocity crawled (walk was unaffected —
/// walk speed isn't rate-scaled).
/// </para>
///
/// <para>
/// <see cref="IWeenieObject.IsThePlayer"/> stays default false — the A3
/// dual dispatch routes remotes through apply_interpreted_movement, ending
/// the fragile "null weenie counts as the player" reading of that gate.
/// <see cref="IWeenieObject.IsCreature"/> stays default true; static
/// animated objects (doors) also carry this weenie — retail would say
/// non-creature there, but their RemoteMotion bodies force-assert Contact,
/// so the creature ground-gate is satisfied either way (noted, not a
/// behavior difference today).
/// </para>
/// </summary>
public sealed class RemoteWeenie : IWeenieObject
{
/// <summary>Remote objects have no local skill data — fail, so the
/// retail fallback chain lands on <see cref="MotionInterpreter.MyRunRate"/>
/// (the wire-fed rate).</summary>
public bool InqRunRate(out float rate)
{
rate = 0f;
return false;
}
/// <summary>Remotes never locally compute jump arcs (server-broadcast
/// VectorUpdate velocities drive them).</summary>
public bool InqJumpVelocity(float extent, out float vz)
{
vz = 0f;
return false;
}
/// <summary>Remotes never locally initiate jumps.</summary>
public bool CanJump(float extent) => true;
}

View file

@ -0,0 +1,113 @@
namespace AcDream.Core.Physics;
/// <summary>
/// Conformance/reference <see cref="IMotionCommandCatalog"/> built from the
/// Sept 2013 EoR retail decomp's full <c>command_ids[0x198]</c> table — a
/// direct index from 16-bit wire low-word to the 32-bit MotionCommand the
/// 2013 client itself would reconstruct. NOT the runtime default (ACE / the
/// local DATs use a later-client numbering — see
/// <see cref="AceModernCommandCatalog"/>); this catalog exists to prove and
/// pin the 2013 decomp's behavior for conformance tests.
///
/// <para>
/// Provenance: <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>,
/// <c>uint32_t const command_ids[0x198]</c> at address <c>0x007c73e8</c>,
/// table body starting at source line 1017259
/// (<c>[0x000] = 0x80000000</c>) through line 1017667
/// (<c>[0x197] = 0x10000197</c>), closing brace at line 1017668. Extracted
/// programmatically (regex over <c>[0xNNN] = 0xVVVVVVVV</c>, not hand-typed)
/// and verified against the anchor values cited inline below.
/// </para>
///
/// <para>
/// <b>Caution — two look-alike tables exist elsewhere in the same file</b>
/// (around source lines 1017962 and 1068315, both also declared
/// <c>command_ids[0x198]</c>). Only the table at line 1017259 /
/// address 0x007c73e8 is used here; do not conflate with the others.
/// </para>
/// </summary>
public sealed class Retail2013CommandCatalog : IMotionCommandCatalog
{
public uint ReconstructFullCommand(ushort wireCommand)
{
return wireCommand < CommandIds.Length ? CommandIds[wireCommand] : 0u;
}
// acclient_2013_pseudo_c.txt:1017259-1017667, address 0x007c73e8.
// 0x198 (408) entries, indices 0x000..0x197, verbatim from the decomp.
// Anchors verified at extraction time:
// [0x000]=0x80000000 [0x003]=0x41000003 [0x005]=0x45000005
// [0x007]=0x44000007 [0x00d]=0x6500000d [0x150]=0x10000150
// [0x153]=0x09000153
private static readonly uint[] CommandIds =
{
0x80000000u, 0x85000001u, 0x85000002u, 0x41000003u, 0x40000004u, 0x45000005u,
0x45000006u, 0x44000007u, 0x40000008u, 0x40000009u, 0x4000000Au, 0x4000000Bu,
0x4000000Cu, 0x6500000Du, 0x6500000Eu, 0x6500000Fu, 0x65000010u, 0x40000011u,
0x41000012u, 0x41000013u, 0x41000014u, 0x40000015u, 0x40000016u, 0x40000017u,
0x40000018u, 0x40000019u, 0x4000001Au, 0x4000001Bu, 0x4000001Cu, 0x4000001Du,
0x4000001Eu, 0x4000001Fu, 0x40000020u, 0x40000021u, 0x40000022u, 0x40000023u,
0x40000024u, 0x40000025u, 0x40000026u, 0x40000027u, 0x40000028u, 0x40000029u,
0x4000002Au, 0x4000002Bu, 0x4000002Cu, 0x4000002Du, 0x4000002Eu, 0x4000002Fu,
0x40000030u, 0x40000031u, 0x40000032u, 0x40000033u, 0x40000034u, 0x40000035u,
0x40000036u, 0x40000037u, 0x40000038u, 0x40000039u, 0x2000003Au, 0x2500003Bu,
0x8000003Cu, 0x8000003Du, 0x8000003Eu, 0x8000003Fu, 0x80000040u, 0x80000041u,
0x80000042u, 0x80000043u, 0x80000044u, 0x80000045u, 0x80000046u, 0x80000047u,
0x80000048u, 0x80000049u, 0x1000004Au, 0x1000004Bu, 0x1300004Cu, 0x1000004Du,
0x1000004Eu, 0x1000004Fu, 0x10000050u, 0x10000051u, 0x10000052u, 0x10000053u,
0x10000054u, 0x10000055u, 0x10000056u, 0x10000057u, 0x10000058u, 0x10000059u,
0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, 0x1000005Eu, 0x1000005Fu,
0x10000060u, 0x10000061u, 0x10000062u, 0x10000063u, 0x10000064u, 0x10000065u,
0x10000066u, 0x10000067u, 0x10000068u, 0x10000069u, 0x1000006Au, 0x1000006Bu,
0x1000006Cu, 0x1000006Du, 0x1000006Eu, 0x1000006Fu, 0x10000070u, 0x10000071u,
0x10000072u, 0x10000073u, 0x10000074u, 0x10000075u, 0x10000076u, 0x10000077u,
0x10000078u, 0x13000079u, 0x1300007Au, 0x1300007Bu, 0x1300007Cu, 0x1300007Du,
0x1300007Eu, 0x1300007Fu, 0x13000080u, 0x13000081u, 0x13000082u, 0x13000083u,
0x13000084u, 0x13000085u, 0x13000086u, 0x13000087u, 0x13000088u, 0x13000089u,
0x1300008Au, 0x1300008Bu, 0x1300008Cu, 0x1300008Du, 0x1300008Eu, 0x1300008Fu,
0x13000090u, 0x13000091u, 0x13000092u, 0x13000093u, 0x13000094u, 0x13000095u,
0x13000096u, 0x13000097u, 0x13000098u, 0x13000099u, 0x1300009Au, 0x1200009Bu,
0x1000009Cu, 0x1000009Du, 0x1000009Eu, 0x1000009Fu, 0x100000A0u, 0x100000A1u,
0x080000A2u, 0x090000A3u, 0x090000A4u, 0x090000A5u, 0x090000A6u, 0x090000A7u,
0x090000A8u, 0x080000A9u, 0x090000AAu, 0x090000ABu, 0x090000ACu, 0x090000ADu,
0x090000AEu, 0x090000AFu, 0x090000B0u, 0x090000B1u, 0x0D0000B2u, 0x0D0000B3u,
0x0D0000B4u, 0x080000B5u, 0x080000B6u, 0x080000B7u, 0x090000B8u, 0x090000B9u,
0x0D0000BAu, 0x0D0000BBu, 0x0D0000BCu, 0x0D0000BDu, 0x0D0000BEu, 0x0D0000BFu,
0x090000C0u, 0x0C0000C1u, 0x090000C2u, 0x090000C3u, 0x090000C4u, 0x0D0000C5u,
0x090000C6u, 0x090000C7u, 0x090000C8u, 0x090000C9u, 0x130000CAu, 0x130000CBu,
0x130000CCu, 0x100000CDu, 0x100000CEu, 0x100000CFu, 0x100000D0u, 0x100000D1u,
0x100000D2u, 0x400000D3u, 0x120000D4u, 0x090000D5u, 0x090000D6u, 0x090000D7u,
0x090000D8u, 0x090000D9u, 0x090000DAu, 0x090000DBu, 0x090000DCu, 0x090000DDu,
0x090000DEu, 0x120000DFu, 0x400000E0u, 0x400000E1u, 0x100000E2u, 0x100000E3u,
0x400000E4u, 0x400000E5u, 0x400000E6u, 0x090000E7u, 0x800000E8u, 0x800000E9u,
0x430000EAu, 0x430000EBu, 0x430000ECu, 0x430000EDu, 0x430000EEu, 0x430000EFu,
0x430000F0u, 0x430000F1u, 0x430000F2u, 0x430000F3u, 0x430000F4u, 0x430000F5u,
0x430000F6u, 0x430000F7u, 0x430000F8u, 0x420000F9u, 0x430000FAu, 0x430000FBu,
0x430000FCu, 0x430000FDu, 0x090000FEu, 0x090000FFu, 0x09000100u, 0x09000101u,
0x09000102u, 0x09000103u, 0x09000104u, 0x09000105u, 0x09000106u, 0x09000107u,
0x09000108u, 0x09000109u, 0x0900010Au, 0x0900010Bu, 0x0900010Cu, 0x0900010Du,
0x1000010Eu, 0x0900010Fu, 0x09000110u, 0x09000111u, 0x09000112u, 0x09000113u,
0x09000114u, 0x43000115u, 0x13000116u, 0x43000117u, 0x43000118u, 0x43000119u,
0x0900011Au, 0x1000011Bu, 0x1000011Cu, 0x1000011Du, 0x1000011Eu, 0x1000011Fu,
0x10000120u, 0x10000121u, 0x10000122u, 0x10000123u, 0x10000124u, 0x10000125u,
0x10000126u, 0x10000127u, 0x10000128u, 0x10000129u, 0x1000012Au, 0x1000012Bu,
0x1000012Cu, 0x1000012Du, 0x1000012Eu, 0x1000012Fu, 0x10000130u, 0x10000131u,
0x13000132u, 0x40000133u, 0x40000134u, 0x40000135u, 0x40000136u, 0x10000137u,
0x80000138u, 0x80000139u, 0x4300013Au, 0x4300013Bu, 0x4300013Cu, 0x4300013Du,
0x4300013Eu, 0x4300013Fu, 0x43000140u, 0x43000141u, 0x43000142u, 0x43000143u,
0x43000144u, 0x43000145u, 0x43000146u, 0x13000147u, 0x13000148u, 0x13000149u,
0x1300014Au, 0x1300014Bu, 0x1300014Cu, 0x1300014Du, 0x1300014Eu, 0x1300014Fu,
0x10000150u, 0x09000151u, 0x09000152u, 0x09000153u, 0x09000154u, 0x09000155u,
0x09000156u, 0x09000157u, 0x09000158u, 0x09000159u, 0x0900015Au, 0x0900015Bu,
0x0900015Cu, 0x0900015Du, 0x0900015Eu, 0x0900015Fu, 0x09000160u, 0x09000161u,
0x10000162u, 0x10000163u, 0x10000164u, 0x09000165u, 0x09000166u, 0x09000167u,
0x09000168u, 0x09000169u, 0x0900016Au, 0x0900016Bu, 0x0900016Cu, 0x0900016Du,
0x1000016Eu, 0x1000016Fu, 0x10000170u, 0x10000171u, 0x10000172u, 0x10000173u,
0x10000174u, 0x10000175u, 0x10000176u, 0x10000177u, 0x10000178u, 0x10000179u,
0x1000017Au, 0x1000017Bu, 0x1000017Cu, 0x1000017Du, 0x1000017Eu, 0x1000017Fu,
0x10000180u, 0x10000181u, 0x10000182u, 0x10000183u, 0x10000184u, 0x10000185u,
0x10000186u, 0x10000187u, 0x10000188u, 0x10000189u, 0x1000018Au, 0x1000018Bu,
0x1000018Cu, 0x1000018Du, 0x1000018Eu, 0x1000018Fu, 0x10000190u, 0x10000191u,
0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u, 0x10000196u, 0x10000197u,
};
}

View file

@ -32,24 +32,10 @@ public static class ServerControlledLocomotion
public const float MinSpeedMod = 0.25f;
public const float MaxSpeedMod = 3.00f;
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
// (0x0052AA00) seeds forward motion before the next position update.
public static LocomotionCycle PlanMoveToStart(
float moveToSpeed = 1f,
float runRate = 1f,
bool canRun = true)
{
moveToSpeed = SanitizePositive(moveToSpeed);
runRate = SanitizePositive(runRate);
if (!canRun)
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
return new LocomotionCycle(
MotionCommand.RunForward,
moveToSpeed * runRate,
true);
}
// R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim
// MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces
// the cycle through the same sink every other motion uses).
// PlanFromVelocity below survives (M16 register row, retires in R6).
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
{

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@ -38,10 +39,22 @@ public static class ShadowShapeBuilder
/// every radius, height, and local offset.</param>
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
/// <param name="partPoseOverride">#175: per-part pose override for the
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
/// closed pose for doors). Retail collision tests each part's LIVE
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
/// table's default state, NOT the Setup's placement frame — the two
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
/// placement poses the panels AJAR at yaw 150°/30°, y 0.44 m; the
/// closed pose is straight). Null / short lists fall back to the
/// placement frame per part (entities with no motion table, and the
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
/// the setup too).</param>
public static IReadOnlyList<ShadowShape> FromSetup(
Setup setup,
float entScale,
Func<uint, bool> hasPhysicsBsp)
Func<uint, bool> hasPhysicsBsp,
IReadOnlyList<Frame>? partPoseOverride = null)
{
if (setup is null) throw new ArgumentNullException(nameof(setup));
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
@ -84,15 +97,21 @@ public static class ShadowShapeBuilder
}
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
// Pose priority per part: partPoseOverride (the motion-table
// default-state pose, #175) → placement frame → identity.
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
for (int i = 0; i < setup.Parts.Count; i++)
{
uint gfxId = (uint)setup.Parts[i];
if (!hasPhysicsBsp(gfxId)) continue;
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
? placementFrame.Frames[i]
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
Frame partFrame;
if (partPoseOverride is not null && i < partPoseOverride.Count)
partFrame = partPoseOverride[i];
else if (placementFrame is not null && i < placementFrame.Frames.Count)
partFrame = placementFrame.Frames[i];
else
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
// BSP radius default; caller substitutes the real BoundingSphere.Radius
// at registration time when available. Loose-but-safe broadphase value.
@ -111,6 +130,84 @@ public static class ShadowShapeBuilder
return result;
}
/// <summary>
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
/// stair runs, fences, rock clusters) from its per-part <see cref="MeshRef"/>s,
/// for registration via <see cref="ShadowObjectRegistry.RegisterMultiPart"/>
/// under the entity's SINGLE unique id.
///
/// <para>
/// Replaces the former per-part <c>Register(entity.Id * 256u + partIndex)</c>
/// (GameWindow.cs) whose <c>* 256u</c> OVERFLOWED uint32 for class-prefixed
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…): the overflow dropped
/// the prefix byte, so different-class entities sharing the low 24 bits
/// collided on one shadow part-id and <c>Register</c>'s deregister-then-insert
/// silently overwrote one entity's collision geometry — the #185 "invisible
/// wall half-way up the stairs" (rendered steps with no collision).
/// </para>
///
/// <para>
/// Retail anchor: a multi-part object is one <c>CPhysicsObj</c> + <c>CPartArray</c>;
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514ae0) → <c>CPartArray::AddPartsShadow</c>
/// walks the part array under the single object — no synthetic per-part id.
/// </para>
///
/// <para>
/// Each part's local transform comes from its <see cref="MeshRef.PartTransform"/>
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
/// <c>RegisterMultiPart</c> reconstructs the world placement identically
/// (<c>entityWorldPos + rotate(LocalPosition, entityWorldRot)</c>). Building
/// shells are excluded — they collide via the per-LandCell building channel
/// (<c>CSortCell::find_collisions</c>), not as shadow objects.
/// </para>
/// </summary>
/// <param name="meshRefs">The entity's per-part mesh references.</param>
/// <param name="isBuildingShell">True for <c>LandBlockInfo.Buildings[]</c> shells.</param>
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics (BSP +
/// bounding sphere). Production: <c>id => cache.GetGfxObj(id)</c>.</param>
public static List<ShadowShape> FromLandblockBspParts(
IReadOnlyList<MeshRef> meshRefs,
bool isBuildingShell,
Func<uint, GfxObjPhysics?> getGfxObj)
{
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
var shapes = new List<ShadowShape>();
// Building shells collide via the building channel (retail), not shadow objects.
if (isBuildingShell || meshRefs is null) return shapes;
foreach (var meshRef in meshRefs)
{
var phys = getGfxObj(meshRef.GfxObjId);
if (phys?.BSP?.Root is null) continue; // no physics BSP → nothing to collide
// PartTransform is root-relative; decompose to local pos/rot/scale.
if (!Matrix4x4.Decompose(meshRef.PartTransform,
out var pScale, out var pRot, out var pPos))
{
pScale = Vector3.One;
pRot = Quaternion.Identity;
pPos = new Vector3(meshRef.PartTransform.M41,
meshRef.PartTransform.M42,
meshRef.PartTransform.M43);
}
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
float localRadius = phys.BoundingSphere?.Radius ?? 1f;
shapes.Add(new ShadowShape(
GfxObjId: meshRef.GfxObjId,
LocalPosition: pPos,
LocalRotation: pRot,
Scale: partScale,
CollisionType: ShadowCollisionType.BSP,
Radius: localRadius * partScale,
CylHeight: 0f));
}
return shapes;
}
/// <summary>Resolve the placement frame in priority Resting → Default →
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
private static AnimationFrame? ResolvePlacementFrame(Setup setup)

File diff suppressed because it is too large Load diff

View file

@ -271,6 +271,129 @@ public static class RenderingDiagnostics
public static bool ProbeLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
/// <summary>
/// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
/// lived in set MEMBERSHIP, not counts). When true,
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
/// <c>[indoor-light]</c> line describing the point-light pool
/// (see <see cref="EmitIndoorLight"/>):
/// <code>
/// [indoor-light] pool=&lt;M&gt; cellLess=&lt;K&gt; registered=&lt;R&gt; capped=&lt;R-M&gt;
/// byCell=[0x&lt;id&gt;:&lt;count&gt;,...]
/// </code>
/// #176 correction (2026-07-06): the pool became retail's RESIDENT-cell
/// collection capped nearest-the-PLAYER — the earlier gaze-coupled scoping
/// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set,
/// <c>c500912b</c>) was the #176 flicker mechanism and was deleted.
/// A7.L1 (2026-07-09): visible-cell scoping is BACK, but sourced differently —
/// <c>LightManager.BuildPointLightSnapshot</c> now takes an optional
/// <c>visibleCells</c> filter that <c>GameWindow</c> feeds from LAST FRAME's
/// already-rendered <c>RetailPViewFrameResult.DrawableCells</c> (one frame of
/// latency, no independent re-flood, no callback threaded into DrawInside) —
/// fixes the Town Network starvation case (463 fixtures, a wall-adjacent
/// corridor's fixtures out-ranking the player's own room in raw Euclidean
/// distance) without reproducing the #176 mechanism. <c>byCell</c> shows which
/// cells' lights are pooled; <c>cellLess==pool</c> in a fixture-rich room still
/// means cell tagging FAILED (ParentCellId not flowing).
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
/// </summary>
public static bool ProbeIndoorLightEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
/// <summary>
/// #176 seam-floor flicker decisive probe (2026-07-06). RenderDoc pixel-history
/// is infeasible on this pipeline (RenderDoc does not support
/// GL_ARB_bindless_texture and hides it from the app → our mandatory-modern
/// startup gate throws), so this is the in-engine equivalent at draw
/// granularity: every draw route that can put geometry at the corridor seam
/// floor reports itself, plus the per-cell light sets ACTUALLY applied.
/// When set, four line families emit (all change-deduped, Console):
/// <list type="bullet">
/// <item><description><c>[seam-cell]</c> — <c>EnvCellRenderer.Render</c>
/// (opaque pass): per target cell — in-filter flag, per-gfx instance count +
/// transform translation (shows the +0.02 shell lift), per-batch
/// cull/translucency, and the cell's 8-light set RESOLVED to identities
/// (owner cell + intensity — raw snapshot indices shuffle when the pool
/// rebuilds, so identities are the stable signature). Two instances of one
/// (cell,gfx) = the runtime double-draw; light identities flipping with the
/// flood = the snapshot-scope mechanism.</description></item>
/// <item><description><c>[seam-snap]</c> — the point-light snapshot's HOT
/// subset (intensity ≥ 50: the portal purples; fixtures are ~12, the viewer
/// fill 2.25) with owner cells, emitted with the block.</description></item>
/// <item><description><c>[seam-ent]</c> — <c>WbDrawDispatcher</c>: any entity
/// parented to a target cell — position, culled/slot, resolved light set. A
/// floor-coincident entity (plate/static) would be the z-fight's second draw;
/// the player entity doubles as the probe's positive control.</description></item>
/// <item><description><c>[seam-mask]</c> — <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>:
/// every portal depth fan drawn in a target cell. A sealed dungeon must show
/// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
/// </list>
/// Value <c>1</c> = the default Facility Hub target set (corridor 0x8A020164 +
/// seam neighbors 0165/016E/017A + under-hall 011E + portal-light cells
/// 0118/0119); a comma-separated hex cell-id list overrides it. Throwaway
/// apparatus — strip when #176 closes. Initial state from
/// <c>ACDREAM_PROBE_SEAMDRAW</c>.
/// </summary>
public static bool ProbeSeamDrawEnabled { get; set; } =
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
/// <summary>Target cell ids for the #176 seam-draw probe (see
/// <see cref="ProbeSeamDrawEnabled"/>). Full 32-bit cell ids.</summary>
public static IReadOnlySet<uint> SeamDrawTargetCells { get; } =
ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
/// <summary>
/// #176 stripe-hunt shader isolation mode (<c>ACDREAM_LIGHT_DEBUG</c>) —
/// throwaway diagnostic, uploaded as <c>uLightDebug</c> by EnvCellRenderer +
/// WbDrawDispatcher each pass. 0 = off; 1 = ambient-only vertex lighting
/// (all point/sun contributions killed); 2 = DYNAMIC point lights killed
/// (the intensity-100 portal purples + the viewer fill off; statics stay);
/// 3 = raw vLit visualization in the fragment shader (texture ignored).
/// Discriminates lighting-driven stripes (gone at 1/2, visible in the field
/// at 3) from texture/per-pixel machinery (survive 1). Settable for a
/// future DebugPanel mirror.
/// </summary>
public static int LightDebugMode { get; set; } =
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), out var ldm) ? ldm : 0;
/// <summary>
/// #176 stripe-hunt isolation (<c>ACDREAM_CLIP_DEBUG=1</c>) — throwaway
/// diagnostic. When true, the EnvCell SHELL pass maps every instance to clip
/// slot 0 (the reserved no-clip region) instead of its cell's portal-slice
/// region — i.e. shells draw WHOLE, retail's shape (retail never clips cell
/// geometry; aperture exactness comes from the seal/punch depth writes +
/// far→near order, PView::DrawCells 0x005a4840). Discriminator for the
/// camera-against-wall stripe/hatch pattern: a knife-edge slice region (eye
/// on a portal plane / inside a wall) yields a clip plane near-parallel to
/// large surfaces → interpolated gl_ClipDistance ≈ 0 across them → per-pixel
/// sign dither = the hatch. Stripes gone with this on = the shell trim is
/// the mechanism. Entity/dispatcher clip routing is NOT affected.
/// </summary>
public static bool ClipDebugNoShellTrim { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_CLIP_DEBUG") == "1";
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving
/// grammar as <see cref="ParseDumpEntityIds"/>). Internal for unit tests.</summary>
internal static IReadOnlySet<uint> ParseSeamDrawTargets(string? raw)
{
// Default: the corridor + the coplanar-sweep seed neighbors + the under-hall
// + the two portal-weenie cells whose intensity-100 purple lights are the
// wedge's source (handoff 2026-07-06-176-seam-floor-zfight-handoff.md).
var defaults = new HashSet<uint>
{
0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au,
0x8A02011Eu, 0x8A020118u, 0x8A020119u,
};
if (string.IsNullOrWhiteSpace(raw)) return defaults;
var trimmed = raw.Trim();
if (trimmed == "1" || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase))
return defaults;
var parsed = ParseDumpEntityIds(raw);
return parsed.Count > 0 ? parsed : defaults;
}
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
// so launch.log stays readable under motion (the per-frame call is a no-op
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
@ -444,13 +567,73 @@ public static class RenderingDiagnostics
float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f;
Console.WriteLine(string.Format(ci,
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}",
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}",
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist));
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist,
ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0));
shown++;
}
}
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
private static long _lastIndoorLightEmitTicks;
/// <summary>
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
/// fires at most once per second. Called from
/// <c>LightManager.BuildPointLightSnapshot</c> — <paramref name="scopedSnapshot"/>
/// already reflects any last-frame visible-cell scoping (A7.L1, 2026-07-09).
/// </summary>
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
/// <param name="scopedSnapshot">The point-light pool just built.</param>
public static void EmitIndoorLight(
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
{
if (!ProbeIndoorLightEnabled) return;
long now = DateTime.UtcNow.Ticks;
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
return;
_lastIndoorLightEmitTicks = now;
int registeredLitPoints = 0;
foreach (var l in allRegistered)
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
int pool = scopedSnapshot.Count;
int cellLess = 0;
var hist = new Dictionary<uint, int>();
foreach (var l in scopedSnapshot)
{
if (l.CellId == 0) cellLess++;
hist.TryGetValue(l.CellId, out var c);
hist[l.CellId] = c + 1;
}
var sb = new StringBuilder(220);
sb.Append("[indoor-light] pool=").Append(pool);
sb.Append(" cellLess=").Append(cellLess);
sb.Append(" registered=").Append(registeredLitPoints);
// Lights dropped by the MaxGlobalLights nearest-player cap (0 in Hub-scale
// rooms for dynamics — statics beyond the 128th-nearest are out of range).
sb.Append(" capped=").Append(registeredLitPoints - pool);
sb.Append(" byCell=[");
const int MaxCells = 12;
int shown = 0;
foreach (var kv in hist)
{
if (shown >= MaxCells) { sb.Append(",..."); break; }
if (shown > 0) sb.Append(',');
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
shown++;
}
sb.Append(']');
Console.WriteLine(sb.ToString());
}
private static bool _probeEnvCellEnabled =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
@ -567,4 +750,21 @@ public static class RenderingDiagnostics
/// </summary>
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
=> renderRootResolved && IsEnvCellId(playerCellId);
/// <summary>
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
/// do NOT strip with session probes. The whole-frame GPU query is
/// self-disabled while <c>ACDREAM_WB_DIAG=1</c> (GL forbids nested
/// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
/// that flag — the 2026-06-23 "separate flags" measurement lesson).
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
/// </summary>
public static bool FrameProfEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
}

View file

@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Rendering;
/// <summary>
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
/// <c>Time</c> seconds, ticked every frame rather than retail's per-object
/// physics tick (behaviorally identical — both re-evaluate the same
/// elapsed/duration ratio every simulated step).
///
/// <para>
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
/// multiplier at the point of use.
/// </para>
///
/// <para>
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
/// ~0.2 ms, the target value is applied immediately with no ramp
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
/// is that exact retail constant.
/// </para>
/// </summary>
public sealed class TranslucencyFadeManager
{
/// <summary>
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
/// 0x00511730): a hook <c>Time</c> at or below this is applied
/// instantly, no ramp.
/// </summary>
public const float TranslucencyInstantEpsilon = 0.000199999995f;
private sealed class Fade
{
public float Elapsed;
public float Duration;
public float Start;
public float End;
}
// entityId -> partIndex -> in-flight ramp.
private readonly Dictionary<uint, Dictionary<uint, Fade>> _activeFades = new();
// entityId -> partIndex -> current committed translucency value
// ([0,1], retail scale). Persists after a fade completes so later
// frames keep reading the settled value.
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
/// <summary>
/// Start (or replace) a translucency ramp for one Setup part of one
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
/// immediately; otherwise the value ramps from
/// <paramref name="start"/> to <paramref name="end"/> across
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
/// </summary>
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
{
if (time <= TranslucencyInstantEpsilon)
{
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
activeForEntity.Remove(partIndex);
Commit(entityId, partIndex, end);
return;
}
if (!_activeFades.TryGetValue(entityId, out var fades))
{
fades = new Dictionary<uint, Fade>();
_activeFades[entityId] = fades;
}
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
// FPHook's first Execute call (at elapsed=0) already reports
// value=start — commit it now so a reader on the same frame the
// hook fired sees the ramp's starting value, not the stale prior one.
Commit(entityId, partIndex, start);
}
/// <summary>
/// Advance every in-flight fade by <paramref name="dt"/> seconds
/// (plain linear interpolation, no easing — matches
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
/// the bitwise-exact <c>End</c> value and stops advancing (retail
/// deletes the FPHook at that point); the committed value stays
/// readable via <see cref="TryGetCurrentValue"/>.
/// </summary>
public void AdvanceAll(float dt)
{
if (_activeFades.Count == 0) return;
List<uint>? emptyEntities = null;
foreach (var (entityId, fades) in _activeFades)
{
List<uint>? finished = null;
foreach (var (partIndex, fade) in fades)
{
fade.Elapsed += dt;
float t = fade.Duration <= TranslucencyInstantEpsilon
? 1f
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
Commit(entityId, partIndex, value);
if (t >= 1f)
{
finished ??= new List<uint>();
finished.Add(partIndex);
}
}
if (finished is not null)
{
foreach (var partIndex in finished)
fades.Remove(partIndex);
if (fades.Count == 0)
{
emptyEntities ??= new List<uint>();
emptyEntities.Add(entityId);
}
}
}
if (emptyEntities is not null)
foreach (var entityId in emptyEntities)
_activeFades.Remove(entityId);
}
/// <summary>
/// Read the current committed translucency value ([0,1], retail
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
/// if this part has never had a fade start (caller should fall back
/// to the dat-authored default — a no-op).
/// </summary>
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
{
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
return true;
value = 0f;
return false;
}
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
public void ClearEntity(uint entityId)
{
_activeFades.Remove(entityId);
_committed.Remove(entityId);
}
private void Commit(uint entityId, uint partIndex, float value)
{
if (!_committed.TryGetValue(entityId, out var parts))
{
parts = new Dictionary<uint, float>();
_committed[entityId] = parts;
}
parts[partIndex] = value;
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Numerics;
using AcDream.Core.Physics;
using DatReaderWriter.Types;
namespace AcDream.Core.Rendering;
/// <summary>
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
/// <see cref="TranslucencyFadeManager"/>. Retail's
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
/// this sink is that same one-line forward.
///
/// <para>
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
/// state, zero rendering coupling — and acdream's collision-passthrough
/// already applies server-authoritative via the <c>SetState</c> wire
/// message, independent of this hook firing client-side.
/// </para>
/// </summary>
public sealed class TranslucencyHookSink : IAnimationHookSink
{
private readonly TranslucencyFadeManager _fades;
public TranslucencyHookSink(TranslucencyFadeManager fades)
{
_fades = fades ?? throw new ArgumentNullException(nameof(fades));
}
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
{
if (hook is not TransparentPartHook tph) return;
_fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time);
}
}

View file

@ -158,17 +158,104 @@ public sealed class ParticleSystem : IParticleSystem
}
}
public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive()
{
foreach (var handle in _handleOrder)
{
if (!_byHandle.TryGetValue(handle, out var em))
continue;
/// <summary>
/// Enumerate every live particle across every active emitter as
/// (emitter, particle-index) pairs, in emitter-spawn order.
///
/// <para>
/// MP-Alloc (2026-07-05): this used to be a C# iterator block (a
/// compiler-generated heap-allocated state machine, `yield return`),
/// allocated fresh on every call. <see cref="ParticleRenderer.Draw"/>
/// calls this once per pass and there are up to ~11 passes per frame
/// (sky pre/post, scene, per-visible-cell, dynamics, unattached), so
/// this was 11 iterator allocations per frame even with zero particles
/// on screen. Returns a <see cref="LiveParticleEnumerable"/> struct
/// instead: `foreach` over it uses the struct enumerator directly (no
/// allocation), while LINQ / test callers that need
/// <see cref="IEnumerable{T}"/> (`.ToList()`, `.Single()`, etc.) still
/// work via the explicit interface implementation — those call sites
/// are test-only, not the per-frame render path this task targets.
/// </para>
/// </summary>
public LiveParticleEnumerable EnumerateLive() => new(this);
for (int i = 0; i < em.Particles.Length; i++)
/// <summary>
/// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the
/// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a
/// zero-allocation struct enumerator; falls back to a boxed iterator
/// only when consumed through the <see cref="IEnumerable{T}"/> surface
/// (LINQ, test helpers).
/// </summary>
public readonly struct LiveParticleEnumerable : IEnumerable<(ParticleEmitter Emitter, int Index)>
{
private readonly ParticleSystem _owner;
internal LiveParticleEnumerable(ParticleSystem owner) => _owner = owner;
public Enumerator GetEnumerator() => new(_owner);
IEnumerator<(ParticleEmitter Emitter, int Index)> IEnumerable<(ParticleEmitter Emitter, int Index)>.GetEnumerator()
=> EnumerateLiveBoxed(_owner).GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> EnumerateLiveBoxed(_owner).GetEnumerator();
private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner)
{
foreach (var handle in owner._handleOrder)
{
if (em.Particles[i].Alive)
yield return (em, i);
if (!owner._byHandle.TryGetValue(handle, out var em))
continue;
for (int i = 0; i < em.Particles.Length; i++)
{
if (em.Particles[i].Alive)
yield return (em, i);
}
}
}
/// <summary>Zero-allocation struct enumerator for the `foreach` fast path.</summary>
public struct Enumerator
{
private readonly ParticleSystem _owner;
private int _handleIdx;
private ParticleEmitter? _currentEmitter;
private int _particleIdx;
internal Enumerator(ParticleSystem owner)
{
_owner = owner;
_handleIdx = -1;
_currentEmitter = null;
_particleIdx = -1;
}
public (ParticleEmitter Emitter, int Index) Current => (_currentEmitter!, _particleIdx);
public bool MoveNext()
{
while (true)
{
if (_currentEmitter is not null)
{
for (_particleIdx++; _particleIdx < _currentEmitter.Particles.Length; _particleIdx++)
{
if (_currentEmitter.Particles[_particleIdx].Alive)
return true;
}
_currentEmitter = null;
}
_handleIdx++;
if (_handleIdx >= _owner._handleOrder.Count)
return false;
if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em))
continue;
_currentEmitter = em;
_particleIdx = -1;
}
}
}
}

View file

@ -0,0 +1,49 @@
namespace AcDream.Core.World;
/// <summary>
/// Packs a stable, collision-free id base for a landblock's dat-hydrated
/// EnvCell-interior entities.
///
/// <para>
/// <b>Why this exists (#190, 2026-07-09).</b> The id space is
/// <c>0x4X XXXXX</c> — the top NIBBLE fixed at 4, distinguishing interior
/// statics from live weenies (ids &lt; 0x40000000), procedural scenery
/// (0x80000000+, bit 31 set), and landblock stabs (0xC0000000+). Nothing
/// decodes a landblock's X/Y back out of an entity id — every consumer
/// (GameWindow.cs's <c>_isOutdoorMesh</c>/<c>_isLandblockStab</c>
/// classification) reads only a THRESHOLD or a full-byte PREFIX — so the
/// internal field widths below are free to change without touching any
/// consumer.
/// </para>
///
/// <para>
/// 28 remaining bits split X(8) / Y(8) / counter(12). X and Y stay at a
/// full byte each to match AC's 0-255 landblock grid — shrinking either
/// reintroduces #119's cross-landblock aliasing (two DIFFERENT landblocks
/// sharing one id space). The prior split (X8/Y8/counter8, "0x40XXYY##")
/// fixed #119 but left only 256 ids for ONE landblock's entire interior
/// static population. The Town Network hub (205 cells, one landblock)
/// already reached 248 before the #79/#93 A7.L1 light-carrier hydration
/// fix, which pushed it to 277 — PAST the 8-bit boundary, silently
/// aliasing into the NEXT landblock's reserved Y-byte (id 0x40000815 read
/// back as landblock Y=0x08, not the true Y=0x07): the exact #119 bug,
/// reincarnated by entity COUNT instead of a computation bug. Shrinking
/// the fixed prefix from a full byte (0x40) to its top nibble (0x4_) frees
/// 4 bits for the counter (8→12 bits, 256→4096 capacity) — ~15x headroom
/// over the observed count.
/// </para>
/// </summary>
public static class InteriorEntityIdAllocator
{
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
/// more interior entities than this would alias into the next Y-slot —
/// exactly the #190 bug, just at a higher threshold. Callers should log
/// loudly (never silently wrap) if this is ever exceeded.</summary>
public const uint MaxCounter = 0xFFFu;
/// <summary>The first id in this landblock's reserved range (counter=0).
/// Add a counter in <c>[0, MaxCounter]</c> to allocate a unique entity id
/// within the landblock.</summary>
public static uint Base(uint landblockX, uint landblockY)
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
}

View file

@ -0,0 +1,51 @@
namespace AcDream.Core.World;
/// <summary>
/// Should the streaming/render pipeline be allowed to run this frame?
///
/// <para>
/// <b>Why this exists (#192, 2026-07-09).</b> Login to a non-Holtburg position
/// sometimes showed stabs/scenery floating in the wrong place. Root cause: the
/// old gate opened as soon as <c>WorldSession</c> reached <c>InWorld</c> — but
/// <c>InWorld</c> fires immediately after the login handshake completes
/// (<c>WorldSession.cs:608</c>), BEFORE the player's own spawn <c>CreateObject</c>
/// (which carries their real position) has even arrived over the network. Any
/// landblock streamed in that window baked its world offset from
/// <c>GameWindow._liveCenterX/Y</c>'s startup placeholder (Holtburg, 0xA9B4) — a
/// real position, not a "not known yet" sentinel — because the streaming worker
/// reads that field fresh at build time with no way to know it's still a guess.
/// That stale-baked geometry still got applied once its build finished, landing
/// wherever the guess put it relative to whatever streamed in afterward using the
/// corrected center.
/// </para>
///
/// <para>
/// The fix is not "pick a better placeholder" — any placeholder racing against
/// the real answer reproduces the same bug. It's gating on whether the real
/// position is actually known yet (<paramref name="liveCenterKnown"/>, set true
/// exactly when the player's own spawn is processed), independent of session
/// state. <paramref name="liveInWorld"/> alone is deliberately no longer
/// sufficient in live mode.
/// </para>
/// </summary>
public static class StreamingReadinessGate
{
/// <param name="liveModeEnabled">Whether a live ACE session is configured at all.
/// False (offline / fly-camera) always streams — there is no real position to
/// wait for, so no race exists.</param>
/// <param name="chaseModeEverEntered">Latches true the first time chase mode
/// engages and stays true for the rest of the session — well past the login
/// race window.</param>
/// <param name="liveInWorld">The live session has completed the login handshake
/// (<c>WorldSession.State.InWorld</c>). Necessary but NOT sufficient on its
/// own — see class remarks.</param>
/// <param name="liveCenterKnown">The player's own spawn <c>CreateObject</c> has
/// been received and processed, so <c>_liveCenterX/Y</c> reflects a real,
/// server-confirmed position rather than the startup placeholder.</param>
public static bool ShouldStream(
bool liveModeEnabled, bool chaseModeEverEntered, bool liveInWorld, bool liveCenterKnown)
{
bool isWaitingForLogin = liveModeEnabled && !chaseModeEverEntered;
return !isWaitingForLogin || (liveInWorld && liveCenterKnown);
}
}

Some files were not shown because too many files have changed in this diff Show more