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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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