Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196
# Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
217a4bad69
329 changed files with 81439 additions and 8499 deletions
|
|
@ -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>
|
||||
|
|
|
|||
213
src/AcDream.App/Diagnostics/FrameProfiler.cs
Normal file
213
src/AcDream.App/Diagnostics/FrameProfiler.cs
Normal 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);
|
||||
}
|
||||
69
src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
Normal file
69
src/AcDream.App/Diagnostics/FrameStatsBuffer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
87
src/AcDream.App/Diagnostics/GpuFrameTimer.cs
Normal file
87
src/AcDream.App/Diagnostics/GpuFrameTimer.cs
Normal 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
619
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal file
619
src/AcDream.App/Physics/RemotePhysicsUpdater.cs
Normal 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 >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 && serverGuid != 0 && serverGuid != _playerServerGuid
|
||||
/// && rm.LastServerPosTime > 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;
|
||||
}
|
||||
}
|
||||
149
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal file
149
src/AcDream.App/Rendering/EntityPhysicsHost.cs
Normal 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
|
|
@ -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<WorldEntity> 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<WorldEntity></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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
|
|
@ -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 (0x00456fcd–0x00457035): 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 0x00456fcd–0x00457035).
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ~1–2). 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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Meshing;
|
||||
using AcDream.Core.Rendering;
|
||||
using DatReaderWriter;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
25
src/AcDream.Bake/AcDream.Bake.csproj
Normal file
25
src/AcDream.Bake/AcDream.Bake.csproj
Normal 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>
|
||||
252
src/AcDream.Bake/BakeRunner.cs
Normal file
252
src/AcDream.Bake/BakeRunner.cs
Normal 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<T>() 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;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Bake/ConsoleErrorLogger.cs
Normal file
38
src/AcDream.Bake/ConsoleErrorLogger.cs
Normal 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() { }
|
||||
}
|
||||
78
src/AcDream.Bake/Program.cs
Normal file
78
src/AcDream.Bake/Program.cs
Normal 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;
|
||||
}
|
||||
29
src/AcDream.Content/AcDream.Content.csproj
Normal file
29
src/AcDream.Content/AcDream.Content.csproj
Normal 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>
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>();
|
||||
|
|
@ -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
|
||||
1172
src/AcDream.Content/MeshExtractor.cs
Normal file
1172
src/AcDream.Content/MeshExtractor.cs
Normal file
File diff suppressed because it is too large
Load diff
124
src/AcDream.Content/ObjectMeshData.cs
Normal file
124
src/AcDream.Content/ObjectMeshData.cs
Normal 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; }
|
||||
}
|
||||
36
src/AcDream.Content/Pak/Crc32.cs
Normal file
36
src/AcDream.Content/Pak/Crc32.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
519
src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs
Normal file
519
src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
137
src/AcDream.Content/Pak/PakFormat.cs
Normal file
137
src/AcDream.Content/Pak/PakFormat.cs
Normal 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]),
|
||||
};
|
||||
}
|
||||
}
|
||||
33
src/AcDream.Content/Pak/PakKey.cs
Normal file
33
src/AcDream.Content/Pak/PakKey.cs
Normal 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 << 56) | ((ulong)fileId << 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);
|
||||
}
|
||||
}
|
||||
222
src/AcDream.Content/Pak/PakReader.cs
Normal file
222
src/AcDream.Content/Pak/PakReader.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
119
src/AcDream.Content/Pak/PakWriter.cs
Normal file
119
src/AcDream.Content/Pak/PakWriter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/AcDream.Content/TextureKey.cs
Normal file
30
src/AcDream.Content/TextureKey.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
34
src/AcDream.Content/UploadFormats.cs
Normal file
34
src/AcDream.Content/UploadFormats.cs
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.0–1.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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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();
|
||||
|
|
|
|||
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal file
98
src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 -> <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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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->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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal file
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal 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;
|
||||
}
|
||||
101
src/AcDream.Core/Physics/AceModernCommandCatalog.cs
Normal file
101
src/AcDream.Core/Physics/AceModernCommandCatalog.cs
Normal 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 -> 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) < ChatEmote (0x12/0x13)
|
||||
/// < Modifier (0x20) < SubState (0x41/...) < ... < 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
|
|
@ -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() <= 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>&hitPoly</c> through). Phase 2
|
||||
/// binary-searches the bounds with the SAME iteration counter; window
|
||||
/// < 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
src/AcDream.Core/Physics/IMotionCommandCatalog.cs
Normal file
27
src/AcDream.Core/Physics/IMotionCommandCatalog.cs
Normal 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);
|
||||
}
|
||||
115
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal file
115
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal 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>>= 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 < 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 & 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,
|
||||
};
|
||||
}
|
||||
159
src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs
Normal file
159
src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs
Normal 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 < 0</c> test.</item>
|
||||
/// <item><see cref="SetAnimationId"/> clamps in retail's exact order
|
||||
/// (0x00525d60): high<0 → num−1; low≥num → num−1; high≥num → num−1;
|
||||
/// low>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];
|
||||
}
|
||||
}
|
||||
692
src/AcDream.Core/Physics/Motion/CMotionTable.cs
Normal file
692
src/AcDream.Core/Physics/Motion/CMotionTable.cs
Normal 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>>= 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;
|
||||
}
|
||||
}
|
||||
511
src/AcDream.Core/Physics/Motion/CSequence.cs
Normal file
511
src/AcDream.Core/Physics/Motion/CSequence.cs
Normal 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;
|
||||
}
|
||||
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal 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 < 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();
|
||||
}
|
||||
}
|
||||
82
src/AcDream.Core/Physics/Motion/FrameOps.cs
Normal file
82
src/AcDream.Core/Physics/Motion/FrameOps.cs
Normal 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|² < 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);
|
||||
}
|
||||
}
|
||||
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal 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->id</c> — this object's guid.</summary>
|
||||
uint Id { get; }
|
||||
|
||||
/// <summary>Retail <c>physics_obj->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->transient_state & 1</c> — the
|
||||
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
|
||||
bool InContact { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::get_minterp()->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);
|
||||
}
|
||||
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal 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);
|
||||
}
|
||||
30
src/AcDream.Core/Physics/Motion/MotionNode.cs
Normal file
30
src/AcDream.Core/Physics/Motion/MotionNode.cs
Normal 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);
|
||||
122
src/AcDream.Core/Physics/Motion/MotionState.cs
Normal file
122
src/AcDream.Core/Physics/Motion/MotionState.cs
Normal 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();
|
||||
}
|
||||
89
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal file
89
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal file
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal 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>&0x40000000 != 0 && &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 < 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->params->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);
|
||||
}
|
||||
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal 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 << 16) |
|
||||
/// (substate & 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<Animation>(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;
|
||||
}
|
||||
}
|
||||
1634
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
1634
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
File diff suppressed because it is too large
Load diff
306
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal file
306
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal 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) < F_EPSILON) d = 0;
|
||||
/// if (d < -F_EPSILON) d += 360;
|
||||
/// if (F_EPSILON < d && 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) > 180) greater = (b > a); // wrapped case: compare flipped
|
||||
/// else greater = (a > 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);
|
||||
}
|
||||
}
|
||||
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal file
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal 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<MoveToNode></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);
|
||||
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal 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) > 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;
|
||||
}
|
||||
472
src/AcDream.Core/Physics/Motion/MovementParameters.cs
Normal file
472
src/AcDream.Core/Physics/Motion/MovementParameters.cs
Normal 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 & 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&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&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 && 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 > DistanceToObject</c> → WalkForward,
|
||||
/// !movingAway; else idle (cmd 0).</description></item>
|
||||
/// <item><description><c>MoveAway</c> only: pure away —
|
||||
/// <c>dist < 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 >
|
||||
/// 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 > DistanceToObject</c> → WalkForward,
|
||||
/// towards (not moving away).</description></item>
|
||||
/// <item><description><c>dist - MinDistance < 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->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;
|
||||
}
|
||||
}
|
||||
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal 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();
|
||||
}
|
||||
276
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
276
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal 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 >= 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)}"));
|
||||
}
|
||||
}
|
||||
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal 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 > 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);
|
||||
}
|
||||
}
|
||||
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 < Modifier < SubState < 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 ->
|
||||
/// 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
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal file
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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->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 0x60–0x80 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"/> > 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>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->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(&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(&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²
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
78
src/AcDream.Core/Physics/PhysicsObjUpdate.cs
Normal file
78
src/AcDream.Core/Physics/PhysicsObjUpdate.cs
Normal 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 > 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
322
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
322
src/AcDream.Core/Physics/RawMotionState.cs
Normal 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 & 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) > 3) { // outside [0x6500000d, 0x65000010]
|
||||
/// if ((arg2 & 0x40000000) == 0) { // NOT forward-class
|
||||
/// if (arg2 >= 0) { // NOT style-class (high bit clear)
|
||||
/// if ((arg2 & 0x10000000) != 0)
|
||||
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&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 & 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) > 3) {
|
||||
/// if ((arg2 & 0x40000000) == 0) {
|
||||
/// if (arg2 < 0 && 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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| > 20°, snaps
|
||||
/// orientation when within tolerance, and tests arrival via
|
||||
/// <c>dist <= min_distance</c> (chase) or
|
||||
/// <c>dist >= 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 <= DistanceToObject</c> vs retail's <c>dist <= 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> > 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;
|
||||
}
|
||||
}
|
||||
54
src/AcDream.Core/Physics/RemoteWeenie.cs
Normal file
54
src/AcDream.Core/Physics/RemoteWeenie.cs
Normal 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;
|
||||
}
|
||||
113
src/AcDream.Core/Physics/Retail2013CommandCatalog.cs
Normal file
113
src/AcDream.Core/Physics/Retail2013CommandCatalog.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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=<M> cellLess=<K> registered=<R> capped=<R-M>
|
||||
/// byCell=[0x<id>:<count>,...]
|
||||
/// </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 ~1–2, 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";
|
||||
}
|
||||
|
|
|
|||
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
src/AcDream.Core/World/InteriorEntityIdAllocator.cs
Normal file
49
src/AcDream.Core/World/InteriorEntityIdAllocator.cs
Normal 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 < 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);
|
||||
}
|
||||
51
src/AcDream.Core/World/StreamingReadinessGate.cs
Normal file
51
src/AcDream.Core/World/StreamingReadinessGate.cs
Normal 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
Loading…
Add table
Add a link
Reference in a new issue