Merge branch 'main' into claude/peaceful-visvesvaraya-e0a196
# Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
217a4bad69
329 changed files with 81439 additions and 8499 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using AcDream.Core.Physics;
|
||||
using Drw = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Combat;
|
||||
|
||||
|
|
@ -200,109 +201,146 @@ public enum CombatAnimationKind
|
|||
Death,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The full 32-bit retail MotionCommand values the planner classifies, each
|
||||
/// derived DIRECTLY from <see cref="Drw"/> (Chorizite.DatReaderWriter — the
|
||||
/// same enum ACE broadcasts and that the local DAT MotionTables key on) so
|
||||
/// the values can never drift from the wire again.
|
||||
///
|
||||
/// <para>
|
||||
/// History (ISSUES #159): these were previously hand-transcribed from the
|
||||
/// Sept 2013 EoR decomp's <c>command_ids</c> table, which numbers the whole
|
||||
/// late-combat block (<c>Offhand*</c> / <c>Attack4-6</c> / <c>Punch*</c>)
|
||||
/// THREE lower than ACE/DRW — a contiguous low-word "+3" shift that begins
|
||||
/// around <c>SnowAngelState</c> (0x115). Against a live ACE server those
|
||||
/// commands silently misclassified: the resolver returned the correct ACE
|
||||
/// value (e.g. wire 0x0173 -> <c>0x10000173</c> <c>OffhandSlashHigh</c>),
|
||||
/// but the planner's constant held the 2013 value (<c>0x10000170</c>), so no
|
||||
/// match. <c>Reload</c> was worse — hardcoded <c>0x100000D4</c>, a value
|
||||
/// absent from DRW entirely; the real ACE/DRW <c>Reload</c> is the
|
||||
/// <c>0x40000016</c> SubState. Deriving from the enum by name fixes the whole
|
||||
/// block at once and drops ~80 magic numbers.
|
||||
/// See <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class CombatAnimationMotionCommands
|
||||
{
|
||||
public const uint HandCombat = 0x8000003Cu;
|
||||
public const uint SwordCombat = 0x8000003Eu;
|
||||
public const uint BowCombat = 0x8000003Fu;
|
||||
public const uint SwordShieldCombat = 0x80000040u;
|
||||
public const uint CrossbowCombat = 0x80000041u;
|
||||
public const uint SlingCombat = 0x80000043u;
|
||||
public const uint TwoHandedSwordCombat = 0x80000044u;
|
||||
public const uint TwoHandedStaffCombat = 0x80000045u;
|
||||
public const uint DualWieldCombat = 0x80000046u;
|
||||
public const uint ThrownWeaponCombat = 0x80000047u;
|
||||
public const uint Magic = 0x80000049u;
|
||||
public const uint AtlatlCombat = 0x8000013Bu;
|
||||
public const uint ThrownShieldCombat = 0x8000013Cu;
|
||||
// Combat stances (Style class 0x80). Already correct before #159.
|
||||
public const uint HandCombat = (uint)Drw.HandCombat;
|
||||
public const uint SwordCombat = (uint)Drw.SwordCombat;
|
||||
public const uint BowCombat = (uint)Drw.BowCombat;
|
||||
public const uint SwordShieldCombat = (uint)Drw.SwordShieldCombat;
|
||||
public const uint CrossbowCombat = (uint)Drw.CrossbowCombat;
|
||||
public const uint SlingCombat = (uint)Drw.SlingCombat;
|
||||
public const uint TwoHandedSwordCombat = (uint)Drw.TwoHandedSwordCombat;
|
||||
public const uint TwoHandedStaffCombat = (uint)Drw.TwoHandedStaffCombat;
|
||||
public const uint DualWieldCombat = (uint)Drw.DualWieldCombat;
|
||||
public const uint ThrownWeaponCombat = (uint)Drw.ThrownWeaponCombat;
|
||||
public const uint Magic = (uint)Drw.Magic;
|
||||
public const uint AtlatlCombat = (uint)Drw.AtlatlCombat;
|
||||
public const uint ThrownShieldCombat = (uint)Drw.ThrownShieldCombat;
|
||||
|
||||
public const uint FallDown = 0x10000050u;
|
||||
public const uint Twitch1 = 0x10000051u;
|
||||
public const uint Twitch2 = 0x10000052u;
|
||||
public const uint Twitch3 = 0x10000053u;
|
||||
public const uint Twitch4 = 0x10000054u;
|
||||
public const uint StaggerBackward = 0x10000055u;
|
||||
public const uint StaggerForward = 0x10000056u;
|
||||
public const uint Sanctuary = 0x10000057u;
|
||||
public const uint ThrustMed = 0x10000058u;
|
||||
public const uint ThrustLow = 0x10000059u;
|
||||
public const uint ThrustHigh = 0x1000005Au;
|
||||
public const uint SlashHigh = 0x1000005Bu;
|
||||
public const uint SlashMed = 0x1000005Cu;
|
||||
public const uint SlashLow = 0x1000005Du;
|
||||
public const uint BackhandHigh = 0x1000005Eu;
|
||||
public const uint BackhandMed = 0x1000005Fu;
|
||||
public const uint BackhandLow = 0x10000060u;
|
||||
public const uint Shoot = 0x10000061u;
|
||||
public const uint AttackHigh1 = 0x10000062u;
|
||||
public const uint AttackMed1 = 0x10000063u;
|
||||
public const uint AttackLow1 = 0x10000064u;
|
||||
public const uint AttackHigh2 = 0x10000065u;
|
||||
public const uint AttackMed2 = 0x10000066u;
|
||||
public const uint AttackLow2 = 0x10000067u;
|
||||
public const uint AttackHigh3 = 0x10000068u;
|
||||
public const uint AttackMed3 = 0x10000069u;
|
||||
public const uint AttackLow3 = 0x1000006Au;
|
||||
// Hit reactions / staggers (Action class 0x10, unshifted).
|
||||
public const uint FallDown = (uint)Drw.FallDown;
|
||||
public const uint Twitch1 = (uint)Drw.Twitch1;
|
||||
public const uint Twitch2 = (uint)Drw.Twitch2;
|
||||
public const uint Twitch3 = (uint)Drw.Twitch3;
|
||||
public const uint Twitch4 = (uint)Drw.Twitch4;
|
||||
public const uint StaggerBackward = (uint)Drw.StaggerBackward;
|
||||
public const uint StaggerForward = (uint)Drw.StaggerForward;
|
||||
public const uint Sanctuary = (uint)Drw.Sanctuary;
|
||||
|
||||
public const uint MissileAttack1 = 0x100000D0u;
|
||||
public const uint MissileAttack2 = 0x100000D1u;
|
||||
public const uint MissileAttack3 = 0x100000D2u;
|
||||
public const uint CastSpell = 0x400000D3u;
|
||||
public const uint Reload = 0x100000D4u;
|
||||
public const uint UseMagicStaff = 0x400000E0u;
|
||||
public const uint UseMagicWand = 0x400000E1u;
|
||||
// Single melee attacks (Action class 0x10, unshifted).
|
||||
public const uint ThrustMed = (uint)Drw.ThrustMed;
|
||||
public const uint ThrustLow = (uint)Drw.ThrustLow;
|
||||
public const uint ThrustHigh = (uint)Drw.ThrustHigh;
|
||||
public const uint SlashHigh = (uint)Drw.SlashHigh;
|
||||
public const uint SlashMed = (uint)Drw.SlashMed;
|
||||
public const uint SlashLow = (uint)Drw.SlashLow;
|
||||
public const uint BackhandHigh = (uint)Drw.BackhandHigh;
|
||||
public const uint BackhandMed = (uint)Drw.BackhandMed;
|
||||
public const uint BackhandLow = (uint)Drw.BackhandLow;
|
||||
|
||||
public const uint DoubleSlashLow = 0x1000011Fu;
|
||||
public const uint DoubleSlashMed = 0x10000120u;
|
||||
public const uint DoubleSlashHigh = 0x10000121u;
|
||||
public const uint TripleSlashLow = 0x10000122u;
|
||||
public const uint TripleSlashMed = 0x10000123u;
|
||||
public const uint TripleSlashHigh = 0x10000124u;
|
||||
public const uint DoubleThrustLow = 0x10000125u;
|
||||
public const uint DoubleThrustMed = 0x10000126u;
|
||||
public const uint DoubleThrustHigh = 0x10000127u;
|
||||
public const uint TripleThrustLow = 0x10000128u;
|
||||
public const uint TripleThrustMed = 0x10000129u;
|
||||
public const uint TripleThrustHigh = 0x1000012Au;
|
||||
// Missile attacks (Action 0x10) + reload (SubState 0x40, NOT the dead
|
||||
// 2013 0x100000D4 — the real DRW Reload is 0x40000016).
|
||||
public const uint Shoot = (uint)Drw.Shoot;
|
||||
public const uint MissileAttack1 = (uint)Drw.MissileAttack1;
|
||||
public const uint MissileAttack2 = (uint)Drw.MissileAttack2;
|
||||
public const uint MissileAttack3 = (uint)Drw.MissileAttack3;
|
||||
public const uint Reload = (uint)Drw.Reload;
|
||||
|
||||
public const uint OffhandSlashHigh = 0x10000170u;
|
||||
public const uint OffhandSlashMed = 0x10000171u;
|
||||
public const uint OffhandSlashLow = 0x10000172u;
|
||||
public const uint OffhandThrustHigh = 0x10000173u;
|
||||
public const uint OffhandThrustMed = 0x10000174u;
|
||||
public const uint OffhandThrustLow = 0x10000175u;
|
||||
public const uint OffhandDoubleSlashLow = 0x10000176u;
|
||||
public const uint OffhandDoubleSlashMed = 0x10000177u;
|
||||
public const uint OffhandDoubleSlashHigh = 0x10000178u;
|
||||
public const uint OffhandTripleSlashLow = 0x10000179u;
|
||||
public const uint OffhandTripleSlashMed = 0x1000017Au;
|
||||
public const uint OffhandTripleSlashHigh = 0x1000017Bu;
|
||||
public const uint OffhandDoubleThrustLow = 0x1000017Cu;
|
||||
public const uint OffhandDoubleThrustMed = 0x1000017Du;
|
||||
public const uint OffhandDoubleThrustHigh = 0x1000017Eu;
|
||||
public const uint OffhandTripleThrustLow = 0x1000017Fu;
|
||||
public const uint OffhandTripleThrustMed = 0x10000180u;
|
||||
public const uint OffhandTripleThrustHigh = 0x10000181u;
|
||||
public const uint OffhandKick = 0x10000182u;
|
||||
public const uint AttackHigh4 = 0x10000183u;
|
||||
public const uint AttackMed4 = 0x10000184u;
|
||||
public const uint AttackLow4 = 0x10000185u;
|
||||
public const uint AttackHigh5 = 0x10000186u;
|
||||
public const uint AttackMed5 = 0x10000187u;
|
||||
public const uint AttackLow5 = 0x10000188u;
|
||||
public const uint AttackHigh6 = 0x10000189u;
|
||||
public const uint AttackMed6 = 0x1000018Au;
|
||||
public const uint AttackLow6 = 0x1000018Bu;
|
||||
public const uint PunchFastHigh = 0x1000018Cu;
|
||||
public const uint PunchFastMed = 0x1000018Du;
|
||||
public const uint PunchFastLow = 0x1000018Eu;
|
||||
public const uint PunchSlowHigh = 0x1000018Fu;
|
||||
public const uint PunchSlowMed = 0x10000190u;
|
||||
public const uint PunchSlowLow = 0x10000191u;
|
||||
public const uint OffhandPunchFastHigh = 0x10000192u;
|
||||
public const uint OffhandPunchFastMed = 0x10000193u;
|
||||
public const uint OffhandPunchFastLow = 0x10000194u;
|
||||
public const uint OffhandPunchSlowHigh = 0x10000195u;
|
||||
public const uint OffhandPunchSlowMed = 0x10000196u;
|
||||
public const uint OffhandPunchSlowLow = 0x10000197u;
|
||||
// Creature attacks 1-6 (Action class 0x10). 1-3 are the unshifted low
|
||||
// block (0x62-0x6A); 4-6 live in the shifted late block (0x186+).
|
||||
public const uint AttackHigh1 = (uint)Drw.AttackHigh1;
|
||||
public const uint AttackMed1 = (uint)Drw.AttackMed1;
|
||||
public const uint AttackLow1 = (uint)Drw.AttackLow1;
|
||||
public const uint AttackHigh2 = (uint)Drw.AttackHigh2;
|
||||
public const uint AttackMed2 = (uint)Drw.AttackMed2;
|
||||
public const uint AttackLow2 = (uint)Drw.AttackLow2;
|
||||
public const uint AttackHigh3 = (uint)Drw.AttackHigh3;
|
||||
public const uint AttackMed3 = (uint)Drw.AttackMed3;
|
||||
public const uint AttackLow3 = (uint)Drw.AttackLow3;
|
||||
public const uint AttackHigh4 = (uint)Drw.AttackHigh4;
|
||||
public const uint AttackMed4 = (uint)Drw.AttackMed4;
|
||||
public const uint AttackLow4 = (uint)Drw.AttackLow4;
|
||||
public const uint AttackHigh5 = (uint)Drw.AttackHigh5;
|
||||
public const uint AttackMed5 = (uint)Drw.AttackMed5;
|
||||
public const uint AttackLow5 = (uint)Drw.AttackLow5;
|
||||
public const uint AttackHigh6 = (uint)Drw.AttackHigh6;
|
||||
public const uint AttackMed6 = (uint)Drw.AttackMed6;
|
||||
public const uint AttackLow6 = (uint)Drw.AttackLow6;
|
||||
|
||||
// Spell casts (SubState class 0x40).
|
||||
public const uint CastSpell = (uint)Drw.CastSpell;
|
||||
public const uint UseMagicStaff = (uint)Drw.UseMagicStaff;
|
||||
public const uint UseMagicWand = (uint)Drw.UseMagicWand;
|
||||
|
||||
// Multi-strike melee (Action class 0x10, unshifted 0x11F-0x12A).
|
||||
public const uint DoubleSlashLow = (uint)Drw.DoubleSlashLow;
|
||||
public const uint DoubleSlashMed = (uint)Drw.DoubleSlashMed;
|
||||
public const uint DoubleSlashHigh = (uint)Drw.DoubleSlashHigh;
|
||||
public const uint TripleSlashLow = (uint)Drw.TripleSlashLow;
|
||||
public const uint TripleSlashMed = (uint)Drw.TripleSlashMed;
|
||||
public const uint TripleSlashHigh = (uint)Drw.TripleSlashHigh;
|
||||
public const uint DoubleThrustLow = (uint)Drw.DoubleThrustLow;
|
||||
public const uint DoubleThrustMed = (uint)Drw.DoubleThrustMed;
|
||||
public const uint DoubleThrustHigh = (uint)Drw.DoubleThrustHigh;
|
||||
public const uint TripleThrustLow = (uint)Drw.TripleThrustLow;
|
||||
public const uint TripleThrustMed = (uint)Drw.TripleThrustMed;
|
||||
public const uint TripleThrustHigh = (uint)Drw.TripleThrustHigh;
|
||||
|
||||
// Offhand strikes (Action class 0x10, shifted late block 0x173+).
|
||||
public const uint OffhandSlashHigh = (uint)Drw.OffhandSlashHigh;
|
||||
public const uint OffhandSlashMed = (uint)Drw.OffhandSlashMed;
|
||||
public const uint OffhandSlashLow = (uint)Drw.OffhandSlashLow;
|
||||
public const uint OffhandThrustHigh = (uint)Drw.OffhandThrustHigh;
|
||||
public const uint OffhandThrustMed = (uint)Drw.OffhandThrustMed;
|
||||
public const uint OffhandThrustLow = (uint)Drw.OffhandThrustLow;
|
||||
public const uint OffhandDoubleSlashLow = (uint)Drw.OffhandDoubleSlashLow;
|
||||
public const uint OffhandDoubleSlashMed = (uint)Drw.OffhandDoubleSlashMed;
|
||||
public const uint OffhandDoubleSlashHigh = (uint)Drw.OffhandDoubleSlashHigh;
|
||||
public const uint OffhandTripleSlashLow = (uint)Drw.OffhandTripleSlashLow;
|
||||
public const uint OffhandTripleSlashMed = (uint)Drw.OffhandTripleSlashMed;
|
||||
public const uint OffhandTripleSlashHigh = (uint)Drw.OffhandTripleSlashHigh;
|
||||
public const uint OffhandDoubleThrustLow = (uint)Drw.OffhandDoubleThrustLow;
|
||||
public const uint OffhandDoubleThrustMed = (uint)Drw.OffhandDoubleThrustMed;
|
||||
public const uint OffhandDoubleThrustHigh = (uint)Drw.OffhandDoubleThrustHigh;
|
||||
public const uint OffhandTripleThrustLow = (uint)Drw.OffhandTripleThrustLow;
|
||||
public const uint OffhandTripleThrustMed = (uint)Drw.OffhandTripleThrustMed;
|
||||
public const uint OffhandTripleThrustHigh = (uint)Drw.OffhandTripleThrustHigh;
|
||||
public const uint OffhandKick = (uint)Drw.OffhandKick;
|
||||
|
||||
// Punches (Action class 0x10, shifted late block).
|
||||
public const uint PunchFastHigh = (uint)Drw.PunchFastHigh;
|
||||
public const uint PunchFastMed = (uint)Drw.PunchFastMed;
|
||||
public const uint PunchFastLow = (uint)Drw.PunchFastLow;
|
||||
public const uint PunchSlowHigh = (uint)Drw.PunchSlowHigh;
|
||||
public const uint PunchSlowMed = (uint)Drw.PunchSlowMed;
|
||||
public const uint PunchSlowLow = (uint)Drw.PunchSlowLow;
|
||||
public const uint OffhandPunchFastHigh = (uint)Drw.OffhandPunchFastHigh;
|
||||
public const uint OffhandPunchFastMed = (uint)Drw.OffhandPunchFastMed;
|
||||
public const uint OffhandPunchFastLow = (uint)Drw.OffhandPunchFastLow;
|
||||
public const uint OffhandPunchSlowHigh = (uint)Drw.OffhandPunchSlowHigh;
|
||||
public const uint OffhandPunchSlowMed = (uint)Drw.OffhandPunchSlowMed;
|
||||
public const uint OffhandPunchSlowLow = (uint)Drw.OffhandPunchSlowLow;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ public static class LightInfoLoader
|
|||
uint ownerId,
|
||||
Vector3 entityPosition,
|
||||
Quaternion entityRotation,
|
||||
bool isDynamic = false)
|
||||
bool isDynamic = false,
|
||||
uint cellId = 0)
|
||||
{
|
||||
var results = new List<LightSource>();
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) return results;
|
||||
|
|
@ -89,6 +90,7 @@ public static class LightInfoLoader
|
|||
Range = info.Falloff * (isDynamic ? 1.5f : 1.3f),
|
||||
ConeAngle = info.ConeAngle,
|
||||
OwnerId = ownerId,
|
||||
CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177)
|
||||
IsLit = true,
|
||||
IsDynamic = isDynamic,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -176,8 +176,28 @@ public sealed class LightManager
|
|||
public const int MaxLightsPerObject = 8;
|
||||
|
||||
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
|
||||
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
|
||||
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
|
||||
/// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is
|
||||
/// collected from ALL RESIDENT EnvCells (<c>CEnvCell::add_dynamic_lights</c>
|
||||
/// 0x0052d410 walks the static <c>CEnvCell::visible_cell_table</c> — the
|
||||
/// loaded-cell registry that <c>add_visible_cell</c> 0x0052de40 fills from each
|
||||
/// activated cell + its dat visible-cell list; NOT the per-frame portal flood)
|
||||
/// and capped nearest-THE-PLAYER (<c>Render::insert_light</c> 0x0054d1b0 sorts
|
||||
/// by distance to <c>Render::player_pos</c>) with small caps (7 dynamic + 40
|
||||
/// static, <c>0x0081ec94/98</c>). Two prior acdream models both flickered
|
||||
/// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all
|
||||
/// registered lights (chase-boom swing churned the eviction boundary), then
|
||||
/// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal
|
||||
/// purples entered/left the pool as the camera turned — the seam-floor
|
||||
/// blink; probe: [seam-blk]/[seam-snap]). Current model: all registered
|
||||
/// (=resident) lit lights optionally FILTERED by last frame's rendered
|
||||
/// visible-cell set (A7.L1, 2026-07-09 — <see cref="BuildPointLightSnapshot"/>'s
|
||||
/// <c>visibleCells</c> param; fixes Town Network starvation without
|
||||
/// reproducing c500912b — see that method's doc), then dynamics-first nearest-
|
||||
/// player, capped here. 128 is wider than retail's 40+7 — a documented backstop
|
||||
/// that in a properly cell-scoped room only ever evicts far-out-of-range
|
||||
/// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc
|
||||
/// work. The 1024 uncap remains refuted (striped-floor artifact + the unported
|
||||
/// static 1/d³ fixture curve, A7 fix #2). Register row AP-85.</summary>
|
||||
public const int MaxGlobalLights = 128;
|
||||
|
||||
private readonly List<LightSource> _pointSnapshot = new();
|
||||
|
|
@ -190,28 +210,100 @@ public sealed class LightManager
|
|||
/// </summary>
|
||||
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
|
||||
|
||||
// Pool-sort state for BuildPointLightSnapshot: the comparison delegate is
|
||||
// cached (allocated once) and reads the anchor from a field so the per-frame
|
||||
// over-cap sort allocates nothing beyond List.Sort's own wrapper — the same
|
||||
// profile as the previous static-lambda sort (MP-Alloc discipline).
|
||||
private Vector3 _poolAnchor;
|
||||
private Comparison<LightSource>? _poolComparison;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
|
||||
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
|
||||
/// path; unlit torches contribute nothing). When more than
|
||||
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
|
||||
/// most relevant lights survive the cap. Call once per frame before
|
||||
/// per-object selection.
|
||||
/// Rebuild <see cref="PointSnapshot"/> from ALL registered lit point/spot
|
||||
/// lights — retail's per-frame collection over the RESIDENT-cell registry.
|
||||
/// The sun and unlit lights are excluded (the sun is global ambient-path;
|
||||
/// unlit torches contribute nothing).
|
||||
/// <para>
|
||||
/// Retail anchors (#176 corrected reading, 2026-07-06):
|
||||
/// <c>CEnvCell::add_dynamic_lights</c> (0x0052d410) walks the WHOLE static
|
||||
/// <c>CEnvCell::visible_cell_table</c> — the resident-EnvCell registry that
|
||||
/// <c>CEnvCell::add_visible_cell</c> (0x0052de40) populates from each activated
|
||||
/// cell plus its dat visible-cell list (it <c>DBObj::Get</c>-loads absent cells;
|
||||
/// entries leave only via the flush machinery). It is NOT the per-frame portal
|
||||
/// flood: camera gaze cannot remove a cell from it. acdream's <c>_all</c>
|
||||
/// (register at hydration, unregister at unload) is that resident set, so the
|
||||
/// collection is simply every registered lit light. The under-room portal
|
||||
/// purples reaching the corridor's pool is retail-correct (cdb: retail applies
|
||||
/// them to every Hub cell) — the faceted purple wedge is faithful.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When more than <see cref="MaxGlobalLights"/> qualify, DYNAMICS are kept
|
||||
/// first (retail's dynamic lights live in their own 7-slot pool —
|
||||
/// <c>Render::add_dynamic_light</c> 0x0054d420 — and never compete with
|
||||
/// statics), then the nearest THE PLAYER (<c>Render::insert_light</c>
|
||||
/// 0x0054d1b0 insertion-sorts by squared distance to <c>Render::player_pos</c>,
|
||||
/// set from <c>player->m_position</c>, SmartBox 0x00453d3a, with the
|
||||
/// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function
|
||||
/// of PLAYER position and light registration ONLY — camera rotation/position
|
||||
/// cannot change it (both prior camera-ANCHORED pools — nearest-camera cap;
|
||||
/// <c>c500912b</c>'s camera-seeded re-flood — produced the #176 seam-floor
|
||||
/// purple blink by making the SORT itself camera-dependent). The optional
|
||||
/// <paramref name="visibleCells"/> candidacy FILTER (A7.L1) does not change
|
||||
/// this: it narrows the input set before the player-anchored sort runs, using
|
||||
/// a value the caller captured from last frame's already-rendered draw list,
|
||||
/// not a fresh camera-seeded computation performed here. Call once per frame
|
||||
/// before per-object selection.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
|
||||
/// <param name="playerWorldPos">The player's world position (render position;
|
||||
/// callers pass the camera position only when no player exists — retail's
|
||||
/// player/viewer branch).</param>
|
||||
/// <param name="visibleCells">
|
||||
/// A7.L1 (2026-07-09) — optional visible-cell scoping. When non-null, a light
|
||||
/// is a candidate only if it is cell-less (<c>CellId == 0</c> — the viewer fill,
|
||||
/// always in scope) or its <c>CellId</c> is in this set. Fixes the Town Network
|
||||
/// starvation case (463 registered fixtures): the player-nearest cap sorts by
|
||||
/// raw Euclidean distance, which is not a reliable proxy for "same room" in a
|
||||
/// dense, maze-like hub — a fixture on the other side of a wall can be
|
||||
/// geometrically closer than the player's own room's torches and win the cap,
|
||||
/// leaving the visible room dark. Scoping candidacy to the frame's actual
|
||||
/// visible cells (the render already computes this — callers pass last frame's
|
||||
/// <c>RetailPViewFrameResult.DrawableCells</c>, one frame of latency, to avoid
|
||||
/// re-threading a mid-render callback) removes those from contention before the
|
||||
/// cap ever applies. The distance-sort anchor stays the PLAYER either way — this
|
||||
/// parameter only narrows candidacy, it does not change the sort (the #176
|
||||
/// correction: CAMERA anchoring, not cell scoping itself, caused the earlier
|
||||
/// seam-floor flicker regression, c500912b). Null (the default) preserves the
|
||||
/// legacy unscoped behavior — outdoor / no-clipRoot callers pass null.
|
||||
/// </param>
|
||||
public void BuildPointLightSnapshot(Vector3 playerWorldPos, IReadOnlySet<uint>? visibleCells = null)
|
||||
{
|
||||
_pointSnapshot.Clear();
|
||||
foreach (var light in _all)
|
||||
{
|
||||
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
|
||||
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
|
||||
if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) continue;
|
||||
_pointSnapshot.Add(light);
|
||||
}
|
||||
if (_pointSnapshot.Count > MaxGlobalLights)
|
||||
{
|
||||
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
|
||||
_poolAnchor = playerWorldPos;
|
||||
_poolComparison ??= (a, b) =>
|
||||
{
|
||||
// Dynamics-first mirrors retail's separate dynamic pool; ties by
|
||||
// player distance mirror insert_light's player-nearest sort.
|
||||
if (a.IsDynamic != b.IsDynamic) return a.IsDynamic ? -1 : 1;
|
||||
float da = (a.WorldPosition - _poolAnchor).LengthSquared();
|
||||
float db = (b.WorldPosition - _poolAnchor).LengthSquared();
|
||||
return da.CompareTo(db);
|
||||
};
|
||||
_pointSnapshot.Sort(_poolComparison);
|
||||
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
|
||||
}
|
||||
|
||||
// A7.L1 SET-COMPOSITION probe. Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1;
|
||||
// the flag check keeps it zero-cost off.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled)
|
||||
AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(_all, _pointSnapshot);
|
||||
}
|
||||
|
||||
// ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ──────────────
|
||||
|
|
@ -333,4 +425,75 @@ public sealed class LightManager
|
|||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-CELL light selection — retail <c>minimize_envcell_lighting</c> (0x0054c170).
|
||||
/// Unlike <see cref="SelectForObject"/> (per-object sphere-overlap cull), retail enables
|
||||
/// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of
|
||||
/// <c>config_hardware_light</c>: the same 4 intensity-100 portal lights are applied to
|
||||
/// every Facility Hub cell, every frame). So here: ALL dynamic lights are added
|
||||
/// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach —
|
||||
/// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights
|
||||
/// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the
|
||||
/// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the
|
||||
/// floor lighting FLAP (#176). Objects keep <see cref="SelectForObject"/>
|
||||
/// (retail minimize_object_lighting).
|
||||
/// </summary>
|
||||
public static int SelectForCell(
|
||||
IReadOnlyList<LightSource> snapshot,
|
||||
Vector3 center,
|
||||
float radius,
|
||||
Span<int> outIndices)
|
||||
{
|
||||
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
|
||||
if (cap <= 0) return 0;
|
||||
|
||||
int count = 0;
|
||||
|
||||
// 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to
|
||||
// every cell — stable regardless of the cell's relation to each light).
|
||||
for (int li = 0; li < snapshot.Count && count < cap; li++)
|
||||
if (snapshot[li].IsDynamic)
|
||||
outIndices[count++] = li;
|
||||
|
||||
// 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere,
|
||||
// insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed).
|
||||
int staticStart = count;
|
||||
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
|
||||
for (int li = 0; li < snapshot.Count; li++)
|
||||
{
|
||||
var light = snapshot[li];
|
||||
if (light.IsDynamic) continue; // dynamics already added
|
||||
float reach = light.Range + radius;
|
||||
float dsq = (light.WorldPosition - center).LengthSquared();
|
||||
if (dsq >= reach * reach) continue;
|
||||
|
||||
if (count < cap)
|
||||
{
|
||||
int j = count;
|
||||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
count++;
|
||||
}
|
||||
else if (staticStart < cap && dsq < keptDistSq[cap - 1])
|
||||
{
|
||||
int j = cap - 1;
|
||||
while (j > staticStart && keptDistSq[j - 1] > dsq)
|
||||
{
|
||||
keptDistSq[j] = keptDistSq[j - 1];
|
||||
outIndices[j] = outIndices[j - 1];
|
||||
j--;
|
||||
}
|
||||
keptDistSq[j] = dsq;
|
||||
outIndices[j] = li;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ public sealed class LightSource
|
|||
public float Range = 10f; // metres, hard cutoff
|
||||
public float ConeAngle = 0f; // radians, Spot only
|
||||
public uint OwnerId; // attached entity id; 0 = world-global
|
||||
public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun).
|
||||
// Retail carries this on the RenderLight (insert_light arg6, +0x6c) for the
|
||||
// cross-cell block-offset distance math. #176 correction (2026-07-06): it is
|
||||
// NOT a pool filter — retail collects from ALL resident cells
|
||||
// (CEnvCell::visible_cell_table = the loaded-cell registry, not the flood);
|
||||
// acdream keeps the tag for probes ([indoor-light]/[seam-*]) + future parity.
|
||||
public bool IsLit = true; // SetLightHook latch
|
||||
public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5);
|
||||
// false = static dat-baked bake (1/d³, range×1.3)
|
||||
|
|
|
|||
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal file
33
src/AcDream.Core/Meshing/EntityHydrationRules.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// Should a hydrated stab/entity survive when its visual mesh flattens to
|
||||
/// zero drawable parts?
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this exists (#79/#93, 2026-07-09).</b> A dat-authored "light attach
|
||||
/// point" is a Setup whose sole purpose is to carry a <c>Setup.Lights</c>
|
||||
/// entry — its own visual part is commonly a #136-class runtime-hidden
|
||||
/// marker (degrades to nothing at any real distance, matching retail's
|
||||
/// editor-only placement markers). Retail's light registration
|
||||
/// (<c>CObjCell::add_light</c>, populated at <c>CEnvCell::UnPack</c>) is
|
||||
/// entirely independent of a fixture's own mesh visibility — a mesh-less
|
||||
/// carrier still lights the room. The Town Network fountain room's only
|
||||
/// light (Setup 0x02000365, a ceiling fixture 5 m above the fountain) never
|
||||
/// registered because the mesh-empty gate treated "nothing to draw" as
|
||||
/// "nothing exists," dropping the entity — and its Lights — before the
|
||||
/// light-registration pass ever saw it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class EntityHydrationRules
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the entity should still be added to the landblock's entity
|
||||
/// set even with zero mesh refs, because it has dat-authored lights to
|
||||
/// register. An entity with any mesh is always kept (unchanged from the
|
||||
/// pre-existing gate); the entity is dropped only when it has neither
|
||||
/// geometry to draw nor lights to register.
|
||||
/// </summary>
|
||||
public static bool ShouldKeepEntity(int meshRefCount, int setupLightCount)
|
||||
=> meshRefCount > 0 || setupLightCount > 0;
|
||||
}
|
||||
101
src/AcDream.Core/Physics/AceModernCommandCatalog.cs
Normal file
101
src/AcDream.Core/Physics/AceModernCommandCatalog.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime-default <see cref="IMotionCommandCatalog"/>. Built from the
|
||||
/// <see cref="DRWMotionCommand"/> enum (generated from the protocol XML;
|
||||
/// mirrors ACE's <c>MotionCommand</c> enum and matches the values actually
|
||||
/// found in the local DAT <c>MotionTable</c> records). Use this catalog
|
||||
/// while talking to ACE — it is what
|
||||
/// <see cref="MotionCommandResolver.ReconstructFullCommand"/> delegates to.
|
||||
///
|
||||
/// <para>
|
||||
/// Reconstruction is a flat <c>wireLow16 -> full32</c> lookup built once
|
||||
/// at construction. When more than one enum value shares the same low 16
|
||||
/// bits (a true class collision — class byte differs, low word doesn't),
|
||||
/// the LOWER class byte wins: Action (0x10) < ChatEmote (0x12/0x13)
|
||||
/// < Modifier (0x20) < SubState (0x41/...) < ... < Style (0x80).
|
||||
/// See <see cref="ResolveClassPriority"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>No magic per-range override.</b> The original
|
||||
/// <c>MotionCommandResolver.ApplyNamedRetailOverrides</c> force-mapped
|
||||
/// wire 0x016E-0x0197 to <c>0x10000000 | lo</c> on the theory that the
|
||||
/// generated DRW enum was "shifted." Verified false: building the lookup
|
||||
/// straight from <see cref="DRWMotionCommand"/> (Chorizite.DatReaderWriter
|
||||
/// 2.1.7, 409 distinct values, zero same-low16 collisions) already resolves
|
||||
/// <c>LifestoneRecall</c> (0x0153), <c>MarketplaceRecall</c> (0x0166),
|
||||
/// <c>AllegianceHometownRecall</c> (0x0171), and <c>OffhandSlashHigh</c>
|
||||
/// (0x0173) to their correct Action-class full values with no override.
|
||||
/// The override loop is deleted in this slice (it was masking nothing; the
|
||||
/// enum was already correct). See
|
||||
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AceModernCommandCatalog : IMotionCommandCatalog
|
||||
{
|
||||
private readonly Dictionary<ushort, uint> _lookup;
|
||||
|
||||
public AceModernCommandCatalog()
|
||||
{
|
||||
_lookup = BuildLookup();
|
||||
}
|
||||
|
||||
public uint ReconstructFullCommand(ushort wireCommand)
|
||||
{
|
||||
if (wireCommand == 0) return 0u;
|
||||
_lookup.TryGetValue(wireCommand, out var full);
|
||||
return full;
|
||||
}
|
||||
|
||||
private static Dictionary<ushort, uint> BuildLookup()
|
||||
{
|
||||
var byLow = new Dictionary<ushort, List<uint>>(512);
|
||||
foreach (DRWMotionCommand v in Enum.GetValues(typeof(DRWMotionCommand)))
|
||||
{
|
||||
uint full = (uint)v;
|
||||
ushort lo = (ushort)(full & 0xFFFFu);
|
||||
if (lo == 0) continue; // Invalid / unmappable
|
||||
|
||||
if (!byLow.TryGetValue(lo, out var list))
|
||||
byLow[lo] = list = new List<uint>(1);
|
||||
if (!list.Contains(full))
|
||||
list.Add(full);
|
||||
}
|
||||
|
||||
var result = new Dictionary<ushort, uint>(byLow.Count);
|
||||
foreach (var (lo, candidates) in byLow)
|
||||
{
|
||||
result[lo] = candidates.Count == 1
|
||||
? candidates[0]
|
||||
: ResolveClassPriority(candidates);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Given a set of full 32-bit MotionCommand values that all share the
|
||||
/// same low 16 bits, return the one whose class byte (bits 24-31) is
|
||||
/// numerically lowest — retail's class priority (Action 0x10 beats
|
||||
/// Modifier 0x20 beats SubState 0x41 beats Style 0x80, etc). Exposed
|
||||
/// as a static so it can be exercised directly by tests even when the
|
||||
/// live enum has no real collisions to exercise it through
|
||||
/// <see cref="BuildLookup"/>.
|
||||
/// </summary>
|
||||
public static uint ResolveClassPriority(IReadOnlyList<uint> candidates)
|
||||
{
|
||||
uint best = candidates[0];
|
||||
for (int i = 1; i < candidates.Count; i++)
|
||||
{
|
||||
uint candidate = candidates[i];
|
||||
if ((candidate >> 24) < (best >> 24))
|
||||
best = candidate;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -573,15 +573,25 @@ public static class BSPQuery
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Polygon.adjust_sphere_to_poly — compute parametric contact time.
|
||||
/// CPolygon::adjust_sphere_to_poly (0x00538170, pc:321999) — the parametric
|
||||
/// time along <paramref name="movement"/> at which the sphere SURFACE
|
||||
/// touches the polygon's plane, approaching from the side the start
|
||||
/// position is on.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns 1.0 if the sphere currently intersects the polygon (needs further
|
||||
/// back-off), or the parametric time [0,1] of first contact along the movement
|
||||
/// vector. Used by adjust_to_plane binary-search loop.
|
||||
/// Returns 1.0 when the start center is already within one radius of the
|
||||
/// plane (no forward touch time computable — 0x005381a4), 0.0 when the
|
||||
/// movement is parallel to the plane (|dot| ≤ 2e-4 — 0x005381d2), else
|
||||
/// <c>(±radius − dpPos) / dpMove</c> with the sign chosen by the start's
|
||||
/// plane side (0x005381ea). UNCLAMPED, matching retail.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: Polygon.cs adjust_sphere_to_poly.</para>
|
||||
/// <para>
|
||||
/// ⚠️ Do NOT re-import ACE's Polygon.cs version — its early-out and its
|
||||
/// ±radius selection (`movement.LengthSquared() <= r²`) are misdecodes
|
||||
/// of the retail x87 flow; see
|
||||
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md (#180).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static float AdjustSphereToPoly(
|
||||
ResolvedPolygon poly,
|
||||
|
|
@ -589,19 +599,14 @@ public static class BSPQuery
|
|||
Vector3 curPos,
|
||||
Vector3 movement)
|
||||
{
|
||||
Vector3 cp = Vector3.Zero;
|
||||
if (PolygonHitsSpherePrecise(
|
||||
poly.Plane, poly.Vertices,
|
||||
checkPos.Center, checkPos.Radius,
|
||||
ref cp))
|
||||
return 1f;
|
||||
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
||||
if (MathF.Abs(dpPos) < checkPos.Radius) return 1f;
|
||||
|
||||
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
||||
float dpMove = Vector3.Dot(movement, poly.Plane.Normal);
|
||||
if (MathF.Abs(dpMove) < PhysicsGlobals.EPSILON) return 0f;
|
||||
if (MathF.Abs(dpMove) <= PhysicsGlobals.EPSILON) return 0f;
|
||||
|
||||
float t = (-checkPos.Radius - dpPos) / dpMove;
|
||||
return Math.Clamp(t, 0f, 1f);
|
||||
float r = dpPos < 0f ? -checkPos.Radius : checkPos.Radius;
|
||||
return (r - dpPos) / dpMove;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1104,15 +1109,32 @@ public static class BSPQuery
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// BSPTree.adjust_to_plane — binary-search for non-penetrating sphere position.
|
||||
/// BSPTREE::adjust_to_plane (0x00539bf0, pc:323440) — find the closest
|
||||
/// non-penetrating position along <c>[curPos → checkPos]</c> and commit it
|
||||
/// into <paramref name="checkPos"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Runs up to 15 forward iterations until touching, then up to 15 binary-search
|
||||
/// iterations to narrow the touch point. Modifies checkPos.Center in place.
|
||||
/// Returns false if convergence fails.
|
||||
/// Two known-time bounds: <c>clearTime</c> (retail var_50, init 0.0 — the
|
||||
/// start is clear) and <c>hitTime</c> (retail var_48, init 1.0 — the check
|
||||
/// position hit). Phase 1 walks plane-touch times from
|
||||
/// <see cref="AdjustSphereToPoly"/>, re-testing the whole tree at each —
|
||||
/// the tree test can surface a DIFFERENT blocking polygon, which feeds the
|
||||
/// next iteration (retail passes <c>&hitPoly</c> through). Phase 2
|
||||
/// binary-searches the bounds with the SAME iteration counter; window
|
||||
/// < 0.02 is CONVERGED, and the final center commits at the last
|
||||
/// known-clear time (0x00539de1). The only failure exit is Phase-1
|
||||
/// exhaustion (15 iterations without a clear touch — 0x00539ce9).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPTree.cs adjust_to_plane.</para>
|
||||
/// <para>
|
||||
/// ⚠️ Do NOT re-import ACE's BSPTree.cs version — its Phase-1 branch is
|
||||
/// inverted and its convergence exit returns false, making the function
|
||||
/// always-fail (dead on the server; PERFECT_CLIP is a client camera flag).
|
||||
/// That dead shape is what quantized PathClipped camera stops to whole
|
||||
/// transition steps: the #180 sawtooth AND the original #176 strobe
|
||||
/// (pulledIn 0.27 ↔ 0.53 = 1-step vs 2-step backoff). Pseudocode + decode:
|
||||
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool AdjustToPlane(
|
||||
PhysicsBSPNode root,
|
||||
|
|
@ -1124,53 +1146,62 @@ public static class BSPQuery
|
|||
{
|
||||
var movement = checkPos.Center - curPos;
|
||||
|
||||
double lowerTime = 0.0;
|
||||
double upperTime = 1.0;
|
||||
double clearTime = 0.0; // retail var_50 — known-clear
|
||||
double hitTime = 1.0; // retail var_48 — known-hit
|
||||
int i = 0;
|
||||
|
||||
const int MaxIter = 15;
|
||||
|
||||
// Phase 1: step forward until non-intersecting.
|
||||
for (int i = 0; i < MaxIter; i++)
|
||||
// Phase 1 (0x00539c4d): walk plane-touch times.
|
||||
while (true)
|
||||
{
|
||||
float touchTime = AdjustSphereToPoly(hitPoly, checkPos, curPos, movement);
|
||||
|
||||
if (touchTime == 1f)
|
||||
{
|
||||
checkPos.Center = curPos + movement * (float)touchTime;
|
||||
// Start already within one radius of the plane — no projectable touch
|
||||
// time; fall through to the pure [0,1] binary search (0x00539c61).
|
||||
if (touchTime == 1f) break;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
{
|
||||
lowerTime = touchTime;
|
||||
break;
|
||||
}
|
||||
upperTime = touchTime;
|
||||
}
|
||||
|
||||
if (i == MaxIter - 1) return false;
|
||||
}
|
||||
|
||||
// Phase 2: binary-search.
|
||||
for (int j = 0; j < MaxIter; j++)
|
||||
{
|
||||
double average = (lowerTime + upperTime) * 0.5;
|
||||
checkPos.Center = curPos + movement * (float)average;
|
||||
checkPos.Center = curPos + movement * touchTime;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
upperTime = (lowerTime + upperTime) * 0.5;
|
||||
else
|
||||
lowerTime = (lowerTime + upperTime) * 0.5;
|
||||
{
|
||||
clearTime = touchTime; // touch position clear → refine toward the hit
|
||||
break;
|
||||
}
|
||||
|
||||
if (upperTime - lowerTime < 0.02)
|
||||
return false;
|
||||
// Still blocked — possibly by a different polygon; the next plane
|
||||
// adjustment targets it (retail's &hitPoly write-back, 0x00539cd3).
|
||||
if (hp2 is not null) hitPoly = hp2;
|
||||
|
||||
i++;
|
||||
hitTime = touchTime;
|
||||
if (i >= MaxIter) return false; // the only failure exit (0x00539ce9)
|
||||
}
|
||||
|
||||
// Phase 2 (0x00539ddb): binary search; the counter CONTINUES from Phase 1.
|
||||
while (i < MaxIter)
|
||||
{
|
||||
double avg = (clearTime + hitTime) * 0.5;
|
||||
checkPos.Center = curPos + movement * (float)avg;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
clearTime = avg;
|
||||
else
|
||||
hitTime = avg;
|
||||
|
||||
if (hitTime - clearTime < 0.02) break; // converged (0x00539dca)
|
||||
i++;
|
||||
}
|
||||
|
||||
// Commit the last known-clear position (0x00539de1). Phase 2 always
|
||||
// succeeds, converged or not — retail has no failure path here.
|
||||
checkPos.Center = curPos + movement * (float)clearTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1399,26 +1430,35 @@ public static class BSPQuery
|
|||
|
||||
// -------------------------------------------------------------------------
|
||||
// slide_sphere — BSPTree level
|
||||
// ACE: BSPTree.cs slide_sphere
|
||||
// Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch
|
||||
// at 0x0053a697). ACE: BSPTree.cs:310-316.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// BSPTree.slide_sphere — apply sliding collision response.
|
||||
/// BSPTree.slide_sphere — dispatch the real sphere-level slide
|
||||
/// (<c>CSphere::slide_sphere</c> 0x00537440, ported as
|
||||
/// <see cref="Transition.SlideSphereInternal"/>): slide IN-FRAME along the
|
||||
/// crease between the collision normal and the contact plane, applied to
|
||||
/// sphere 0's check position (ACE BSPTree.cs:315 —
|
||||
/// <c>GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// Sets the sliding normal on CollisionInfo so the outer transition loop
|
||||
/// applies a wall-slide projection.
|
||||
/// #137 mechanism 2 (2026-07-06): this was a stub that set
|
||||
/// <c>CollisionInfo.SlidingNormal</c> and returned Slid. Retail's BSP layer
|
||||
/// never writes the sliding normal — its only in-transition writer is
|
||||
/// <c>CTransition::validate_transition</c> (0x0050ac21) — so the stub's
|
||||
/// leaked normal survived to the body writeback and absorbed the next
|
||||
/// frame's exactly-anti-parallel offset: the Facility Hub corridor
|
||||
/// phantom's dead-stop half (ISSUES #137).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere.</para>
|
||||
/// </summary>
|
||||
/// <param name="worldNormal">Collision normal already in world space (the
|
||||
/// call sites apply L2W; ACE globalizes inside slide_sphere instead).</param>
|
||||
private static TransitionState SlideSphere(
|
||||
Transition transition,
|
||||
Vector3 collisionNormal)
|
||||
{
|
||||
transition.CollisionInfo.SetSlidingNormal(collisionNormal);
|
||||
return TransitionState.Slid;
|
||||
}
|
||||
Vector3 worldNormal)
|
||||
=> transition.SlideSphereInternal(
|
||||
worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// collide_with_pt — BSPTree level
|
||||
|
|
@ -1894,11 +1934,14 @@ public static class BSPQuery
|
|||
if (engine is not null && !path.StepUp && !path.StepDown)
|
||||
return StepSphereUp(transition, worldNormal, engine);
|
||||
|
||||
// No engine OR step-up/step-down already in progress — fall
|
||||
// back to wall-slide.
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// No engine OR step-up/step-down already in progress — the
|
||||
// real slide response. Retail: a blocked step-up funnels to
|
||||
// SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE
|
||||
// SpherePath.cs:316); the slide records the collision normal
|
||||
// itself and never writes the sliding normal (#137 mechanism
|
||||
// 2 — the old SetSlidingNormal stub here leaked a normal that
|
||||
// wedged the next frame's forward offset).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 0 didn't fully hit. Per retail, the head-sphere test AND
|
||||
|
|
@ -1937,9 +1980,10 @@ public static class BSPQuery
|
|||
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||
|
||||
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||
collisions.SetCollisionNormal(worldNormal);
|
||||
collisions.SetSlidingNormal(worldNormal);
|
||||
return TransitionState.Slid;
|
||||
// Retail head-sphere full hit → BSPTREE::slide_sphere
|
||||
// (0x0053a697; ACE BSPTree.cs:202) — the real in-frame
|
||||
// slide, no sliding-normal write (#137 mechanism 2).
|
||||
return SlideSphere(transition, worldNormal);
|
||||
}
|
||||
|
||||
// Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide.
|
||||
|
|
|
|||
|
|
@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
|
|||
/// (animation-hook frame = damage frame for melee / thrown).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="TransparentPartHook"/> → #188
|
||||
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
|
||||
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ReplaceObjectHook"/>,
|
||||
/// <see cref="TransparentHook"/>,
|
||||
/// <see cref="TransparentHook"/> (whole-object variant — NOT yet
|
||||
/// ported, #188 scope was the per-part case only),
|
||||
/// <see cref="LuminousHook"/>,
|
||||
/// <see cref="DiffuseHook"/>,
|
||||
/// <see cref="ScaleHook"/>,
|
||||
|
|
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
|
|||
/// <see cref="SetOmegaHook"/>,
|
||||
/// <see cref="TextureVelocityHook"/>,
|
||||
/// <see cref="SetLightHook"/> →
|
||||
/// GfxObjMesh / renderer state mutations on the target entity.
|
||||
/// GfxObjMesh / renderer state mutations on the target entity
|
||||
/// (<see cref="SetLightHook"/> is the one exception already wired,
|
||||
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
||||
|
|
|
|||
27
src/AcDream.Core/Physics/IMotionCommandCatalog.cs
Normal file
27
src/AcDream.Core/Physics/IMotionCommandCatalog.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a full 32-bit retail MotionCommand from the 16-bit wire
|
||||
/// value broadcast in <c>InterpretedMotionState.Commands[]</c> (the server
|
||||
/// truncates the class byte — see <see cref="MotionCommandResolver"/> for
|
||||
/// why that byte must be restored before routing).
|
||||
///
|
||||
/// <para>
|
||||
/// Two implementations exist because the wire-numbering used by ACE / the
|
||||
/// local DATs and the wire-numbering used by the Sept 2013 EoR retail
|
||||
/// decomp diverge for ~130 command names (a contiguous low-word "+3" shift
|
||||
/// starting at <c>SnowAngelState</c>). See
|
||||
/// <see cref="AceModernCommandCatalog"/> (runtime default) and
|
||||
/// <see cref="Retail2013CommandCatalog"/> (conformance/reference), and
|
||||
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> for the
|
||||
/// full divergence analysis.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IMotionCommandCatalog
|
||||
{
|
||||
/// <summary>
|
||||
/// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value.
|
||||
/// Returns 0 if no entry matches.
|
||||
/// </summary>
|
||||
uint ReconstructFullCommand(ushort wireCommand);
|
||||
}
|
||||
115
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal file
115
src/AcDream.Core/Physics/InterpretedMotionFunnel.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// L.2g S2 — supporting types for the inbound CMotionInterp funnel
|
||||
/// (deviation DEV-1). Spec:
|
||||
/// docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md; decomp:
|
||||
/// MovementManager::unpack_movement 0x00524440,
|
||||
/// CMotionInterp::move_to_interpreted_state 0x005289c0,
|
||||
/// apply_interpreted_movement 0x00528600, DoInterpretedMotion 0x00528360 —
|
||||
/// dispatch order validated against a LIVE retail-observer cdb trace
|
||||
/// (tools/cdb/l2g-observer.cdb).
|
||||
/// </summary>
|
||||
public interface IInterpretedMotionSink
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>CPhysicsObj::DoInterpretedMotion → … →
|
||||
/// CMotionTable::GetObjectSequence</c> backend — called for every motion
|
||||
/// that passes <c>contact_allows_move</c>, in retail dispatch order
|
||||
/// (style → forward-or-Falling → sidestep → turn → actions). R2-Q5: the
|
||||
/// production implementation is <c>Motion.MotionTableDispatchSink</c>,
|
||||
/// which dispatches straight into the entity's motion-table stack
|
||||
/// (<c>PerformMovement</c> → GetObjectSequence + is_allowed decide) —
|
||||
/// no sink-side axis pick or fallback chain.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// R3-W5: retail's own <c>CPhysicsObj::DoInterpretedMotion</c> RETURNS a
|
||||
/// result (<c>result == 0</c> = the motion table found a cycle for this
|
||||
/// id; nonzero = <c>MotionTableManagerError.MotionFailed</c>/no-table) —
|
||||
/// <c>CMotionInterp::DoInterpretedMotion</c>'s caller (raw 305591-305610)
|
||||
/// gates BOTH the <c>add_to_queue</c> call AND the
|
||||
/// <c>InterpretedMotionState::ApplyMotion</c> state-write on that result.
|
||||
/// This matters concretely for the very first dispatch of every
|
||||
/// <c>apply_interpreted_movement</c> call — the STYLE/stance id
|
||||
/// (<c>current_style</c>, always <c>>= 0x80000000</c>) has no
|
||||
/// locomotion <c>MotionData</c> entry in the dat, so
|
||||
/// <c>CMotionTable::DoObjectMotion</c> genuinely fails for it; if that
|
||||
/// failure isn't propagated, the state-write's negative-motion branch
|
||||
/// (<see cref="InterpretedMotionState.ApplyMotion"/>: <c>arg2 < 0 →
|
||||
/// forward_command = 0x41000003</c>) clobbers <c>ForwardCommand</c>
|
||||
/// BEFORE the very next line reads it for the real forward dispatch —
|
||||
/// confirmed regression against the 183-case live retail-observer trace
|
||||
/// (<c>RetailObserverTraceConformanceTests</c>) when this was <c>void</c>.
|
||||
/// Return <c>true</c> when the underlying <c>PerformMovement</c> call
|
||||
/// succeeded (<c>MotionTableManagerError.Success</c>).
|
||||
/// </returns>
|
||||
bool ApplyMotion(uint motion, float speed);
|
||||
|
||||
/// <summary>
|
||||
/// <c>StopInterpretedMotion</c> notification for an axis the incoming
|
||||
/// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional
|
||||
/// (no contact gate) per apply_interpreted_movement.
|
||||
/// </summary>
|
||||
/// <returns>Same result-propagation rationale as <see cref="ApplyMotion"/>
|
||||
/// — retail's <c>CPhysicsObj::StopInterpretedMotion</c> also returns a
|
||||
/// result that gates the post-stop <c>add_to_queue</c> + state-removal.</returns>
|
||||
bool StopMotion(uint motion);
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 wedge fix — retail <c>CPhysicsObj::StopCompletely_Internal</c>
|
||||
/// (0x0050ead0) → <c>CPartArray::StopCompletelyInternal</c> (0x00518890)
|
||||
/// → <c>MotionTableManager::PerformMovement(MovementStruct{type=5})</c>:
|
||||
/// the ANIMATION-side full stop dispatched from the middle of
|
||||
/// <c>CMotionInterp::StopCompletely</c> (raw @00527e90). The manager
|
||||
/// queues its pending_animations entry UNCONDITIONALLY for type 5, and
|
||||
/// that entry's AnimationDone → MotionDone is the matched pop for the A9
|
||||
/// pending_motions node <c>StopCompletely</c> enqueues right after this
|
||||
/// call — without it the A9 node is an orphan and <c>MotionsPending</c>
|
||||
/// never drains at idle (the 2026-07-03 moveto wedge). Default
|
||||
/// implementation returns <c>true</c> (the null-sink "no animation
|
||||
/// backend" posture — bare tests and sink-less interps are unaffected).
|
||||
/// </summary>
|
||||
bool StopCompletely() => true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the inbound action list (retail <c>MotionItem</c> /
|
||||
/// <c>InterpretedMotionState.actions</c>): a one-shot command with the
|
||||
/// 15-bit server action stamp + autonomy bit
|
||||
/// (<c>u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))</c>).
|
||||
/// </summary>
|
||||
public readonly record struct InboundMotionAction(
|
||||
uint Command, int Stamp, bool Autonomous, float Speed);
|
||||
|
||||
/// <summary>
|
||||
/// A fully-decoded inbound <c>InterpretedMotionState</c>: wire fields where
|
||||
/// present, retail UnPack defaults where absent
|
||||
/// (<c>InterpretedMotionState::UnPack</c> 0x0051f400 /
|
||||
/// ctor 0x0051e8d0). A flags=0 "empty" UM decodes to exactly
|
||||
/// <see cref="Default"/> — a retail-verbatim full stop.
|
||||
/// </summary>
|
||||
public struct InboundInterpretedState
|
||||
{
|
||||
public uint CurrentStyle;
|
||||
public uint ForwardCommand;
|
||||
public float ForwardSpeed;
|
||||
public uint SideStepCommand;
|
||||
public float SideStepSpeed;
|
||||
public uint TurnCommand;
|
||||
public float TurnSpeed;
|
||||
public IReadOnlyList<InboundMotionAction>? Actions;
|
||||
|
||||
public static InboundInterpretedState Default() => new()
|
||||
{
|
||||
CurrentStyle = 0x8000003Du,
|
||||
ForwardCommand = 0x41000003u,
|
||||
ForwardSpeed = 1.0f,
|
||||
SideStepCommand = 0u,
|
||||
SideStepSpeed = 1.0f,
|
||||
TurnCommand = 0u,
|
||||
TurnSpeed = 1.0f,
|
||||
Actions = null,
|
||||
};
|
||||
}
|
||||
159
src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs
Normal file
159
src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
using System;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R1-P1 — verbatim port of retail's <c>AnimSequenceNode</c> (Phase R plan
|
||||
/// `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`, stage R1;
|
||||
/// oracle `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md`
|
||||
/// §25-28).
|
||||
///
|
||||
/// One node of a <c>CSequence</c>'s animation list: a resolved dat
|
||||
/// <see cref="Animation"/> plus a playable frame window
|
||||
/// (<see cref="LowFrame"/>..<see cref="HighFrame"/>) and a signed
|
||||
/// <see cref="Framerate"/> whose SIGN is retail's playback-direction flag.
|
||||
///
|
||||
/// Retail semantics preserved exactly (gap-map items G1/G2/G16/G18):
|
||||
/// <list type="bullet">
|
||||
/// <item>The boundary pair (<see cref="GetStartingFrame"/> /
|
||||
/// <see cref="GetEndingFrame"/>) is direction-aware and returns BARE
|
||||
/// integers — retail has NO epsilon here (0x00525c80/0x00525cb0). ACE's
|
||||
/// <c>PhysicsGlobals.EPSILON</c> subtraction compensates for ACE's own
|
||||
/// <c>float FrameNumber</c> and must not be copied (P0-pins.md).</item>
|
||||
/// <item><see cref="MultiplyFramerate"/> SWAPS <see cref="LowFrame"/> and
|
||||
/// <see cref="HighFrame"/> when the factor is negative (0x00525be0) —
|
||||
/// coupled with the boundary pair's <c>framerate < 0</c> test.</item>
|
||||
/// <item><see cref="SetAnimationId"/> clamps in retail's exact order
|
||||
/// (0x00525d60): high<0 → num−1; low≥num → num−1; high≥num → num−1;
|
||||
/// low>high → high=low.</item>
|
||||
/// <item><see cref="GetPosFrame(int)"/> returns null out of range
|
||||
/// (retail; ACE's identity-frame return is an ACE-ism).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Unlike the legacy <c>AnimationSequencer.AnimNode</c>, retail nodes carry
|
||||
/// NO per-node IsLooping/Velocity/Omega — loop membership is list structure
|
||||
/// (<c>first_cyclic</c>) and physics accumulators live on the sequence
|
||||
/// (G16).
|
||||
/// </summary>
|
||||
public sealed class AnimSequenceNode
|
||||
{
|
||||
/// <summary>Resolved dat animation, or null (id 0 / missing).</summary>
|
||||
public Animation? Anim { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Frames per second; NEGATIVE means reverse playback (retail's
|
||||
/// direction flag). Default 30f (0x00525d30).
|
||||
/// </summary>
|
||||
public float Framerate = 30f;
|
||||
|
||||
/// <summary>Inclusive window low bound. Default −1 (0x00525d30).</summary>
|
||||
public int LowFrame = -1;
|
||||
|
||||
/// <summary>Inclusive window high bound; −1 = "to the end" sentinel
|
||||
/// resolved by <see cref="SetAnimationId"/>. Default −1.</summary>
|
||||
public int HighFrame = -1;
|
||||
|
||||
public bool HasAnim => Anim is not null;
|
||||
|
||||
/// <summary>Default ctor — retail defaults (0x00525d30).</summary>
|
||||
public AnimSequenceNode()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ctor from a MotionData <see cref="AnimData"/> entry (0x00525f90):
|
||||
/// copy framerate/low/high, then resolve + clamp via
|
||||
/// <see cref="SetAnimationId"/>.
|
||||
/// </summary>
|
||||
public AnimSequenceNode(AnimData animData, IAnimationLoader loader)
|
||||
{
|
||||
Framerate = animData.Framerate;
|
||||
LowFrame = animData.LowFrame;
|
||||
HighFrame = animData.HighFrame;
|
||||
SetAnimationId((uint)animData.AnimId, loader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the dat animation and clamp the frame window
|
||||
/// (0x00525d60). The clamp block runs only when an animation resolved;
|
||||
/// order is retail-exact.
|
||||
/// </summary>
|
||||
public void SetAnimationId(uint animId, IAnimationLoader loader)
|
||||
{
|
||||
Anim = animId == 0 ? null : loader.LoadAnimation(animId);
|
||||
|
||||
if (Anim is null)
|
||||
return;
|
||||
|
||||
int numFrames = Anim.PartFrames.Count;
|
||||
|
||||
if (HighFrame < 0)
|
||||
HighFrame = numFrames - 1;
|
||||
if (LowFrame >= numFrames)
|
||||
LowFrame = numFrames - 1;
|
||||
if (HighFrame >= numFrames)
|
||||
HighFrame = numFrames - 1;
|
||||
if (LowFrame > HighFrame)
|
||||
HighFrame = LowFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Direction-aware starting boundary (0x00525c80): reverse playback
|
||||
/// starts at <c>high_frame + 1</c>, forward at <c>low_frame</c>.
|
||||
/// BARE int — no epsilon (G1).
|
||||
/// </summary>
|
||||
public int GetStartingFrame() => Framerate < 0f ? HighFrame + 1 : LowFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Direction-aware ending boundary (0x00525cb0): reverse ends at
|
||||
/// <c>low_frame</c>, forward at <c>high_frame + 1</c>. BARE int.
|
||||
/// </summary>
|
||||
public int GetEndingFrame() => Framerate < 0f ? LowFrame : HighFrame + 1;
|
||||
|
||||
/// <summary>
|
||||
/// Scale playback rate (0x00525be0): a NEGATIVE factor swaps
|
||||
/// low/high before multiplying — the swapped fields plus the
|
||||
/// now-negative framerate are how retail encodes reversed windows.
|
||||
/// </summary>
|
||||
public void MultiplyFramerate(float factor)
|
||||
{
|
||||
if (factor < 0f)
|
||||
{
|
||||
(LowFrame, HighFrame) = (HighFrame, LowFrame);
|
||||
}
|
||||
Framerate *= factor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Root-motion frame at a double position (0x005247b0): floor then the
|
||||
/// int overload.
|
||||
/// </summary>
|
||||
public Frame? GetPosFrame(double frameNumber)
|
||||
=> GetPosFrame((int)Math.Floor(frameNumber));
|
||||
|
||||
/// <summary>
|
||||
/// Root-motion frame by index (0x00525c10): null when no animation,
|
||||
/// index out of range, or the animation carries no PosFrames.
|
||||
/// </summary>
|
||||
public Frame? GetPosFrame(int index)
|
||||
{
|
||||
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
|
||||
return null;
|
||||
if (Anim.PosFrames is null || index >= Anim.PosFrames.Count)
|
||||
return null;
|
||||
return Anim.PosFrames[index];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skeletal part frame by index — same bounds discipline as
|
||||
/// <see cref="GetPosFrame(int)"/>.
|
||||
/// </summary>
|
||||
public AnimationFrame? GetPartFrame(int index)
|
||||
{
|
||||
if (Anim is null || index < 0 || index >= Anim.PartFrames.Count)
|
||||
return null;
|
||||
return Anim.PartFrames[index];
|
||||
}
|
||||
}
|
||||
692
src/AcDream.Core/Physics/Motion/CMotionTable.cs
Normal file
692
src/AcDream.Core/Physics/Motion/CMotionTable.cs
Normal file
|
|
@ -0,0 +1,692 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
// Alias the DatReaderWriter enum so it doesn't clash with
|
||||
// AcDream.Core.Physics.MotionCommand (a static class of uint constants) —
|
||||
// same convention as AnimationSequencerTests.cs.
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// R2-Q2 — verbatim port of retail's CMotionTable (the motion-selection
|
||||
// dispatcher). Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md
|
||||
// (all §-references below point there); ambiguity pins:
|
||||
// docs/research/2026-07-02-r2-motiontable/Q0-pins.md (A1-A5).
|
||||
//
|
||||
// Scope (r2-port-plan.md §3 Q2): pure motion-SELECTION logic — resolves which
|
||||
// MotionData(s) to append to a CSequence and updates a MotionState in place.
|
||||
// Does NOT own a pending-animation queue (that is MotionTableManager, Q3) and
|
||||
// does NOT drive per-tick advance (CSequence.Update, R1). This file re-homes
|
||||
// the adapter's already-field-validated GetLink (AnimationSequencer.cs
|
||||
// :953-997) verbatim per the keep-list (r2-port-plan.md §2) and adds every
|
||||
// other CMotionTable/free-function member the decomp documents.
|
||||
//
|
||||
// Command-word class bits (decomp §1 "Motion-id class bits", §15):
|
||||
// 0x80000000 style-class (top bit set; the low 31 bits + top bit read as
|
||||
// a NEGATIVE int32 — retail's `(int32_t)ebx_1 < 0` test)
|
||||
// 0x40000000 cycle-class (cyclic/looping base state, `this->cycles`)
|
||||
// 0x20000000 modifier-class (physics-only overlay, `this->modifiers`)
|
||||
// 0x10000000 action-class (one-shot, `add_action` FIFO)
|
||||
// 0x41000003 Ready — the "stop / default state" sentinel (decomp §15)
|
||||
//
|
||||
// DatReaderWriter.DBObjs.MotionTable field map (verified via reflection,
|
||||
// package Chorizite.DatReaderWriter 2.1.7, 2026-07-02):
|
||||
// DefaultStyle : MotionCommand → this->default_style
|
||||
// StyleDefaults : Dictionary<MotionCommand,MotionCommand> → this->style_defaults
|
||||
// Cycles : Dictionary<int,MotionData> → this->cycles
|
||||
// Modifiers : Dictionary<int,MotionData> → this->modifiers
|
||||
// Links : Dictionary<int,MotionCommandData> → this->links
|
||||
// (MotionCommandData.MotionData : Dictionary<int,MotionData> = the inner
|
||||
// per-target-substate hash)
|
||||
// MotionData.Bitfield : byte (A5 CONFIRMED present — bit0=clear-mods-on-
|
||||
// entry, bit1=substate-gated for is_allowed)
|
||||
// MotionData.Anims : List<AnimData> — retail's `num_anims`/`anims[]` pair;
|
||||
// Anims.Count IS num_anims (A3 — no separate packed-byte field exists on
|
||||
// the managed type; the decomp's "packed byte at MotionData+0x10" is
|
||||
// this same count, just read through the raw struct layout).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>CMotionTable</c> (0x00522xxx-0x00523xxx region) — the
|
||||
/// motion-selection dispatcher. Wraps a loaded <see cref="MotionTable"/> DBObj
|
||||
/// and resolves <c>(style, substate, speed)</c> requests into MotionData
|
||||
/// chains appended to a <see cref="CSequence"/>, mutating a
|
||||
/// <see cref="MotionState"/> in place.
|
||||
/// </summary>
|
||||
public sealed class CMotionTable
|
||||
{
|
||||
private readonly MotionTable _table;
|
||||
|
||||
public CMotionTable(MotionTable table)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
_table = table;
|
||||
}
|
||||
|
||||
// ── free functions (decomp §2) ──────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>same_sign</c> 0x00522260 (@298253). True when <paramref name="a"/>
|
||||
/// and <paramref name="b"/> are on the same side of zero (0 counts as
|
||||
/// non-negative, matching the decomp's <c>>= 0f</c> reading).
|
||||
/// </summary>
|
||||
internal static bool SameSign(float a, float b) => (a >= 0f) == (b >= 0f);
|
||||
|
||||
/// <summary>
|
||||
/// <c>change_cycle_speed</c> 0x00522290 (@298276): rescale
|
||||
/// <paramref name="sequence"/>'s cyclic-tail framerate by
|
||||
/// <c>newSpeed/oldSpeed</c>. Ported VERBATIM including the A4-#2 gap: when
|
||||
/// <paramref name="oldSpeed"/> is ~0 and <paramref name="newSpeed"/> is
|
||||
/// NOT ~0, retail's fabsl branch structure suppresses the rescale
|
||||
/// entirely (no zeroing, no scaling — a silent no-op). Only when BOTH are
|
||||
/// ~0 does retail explicitly zero the framerate.
|
||||
/// </summary>
|
||||
internal static void ChangeCycleSpeed(CSequence sequence, MotionData? cyclic, float oldSpeed, float newSpeed)
|
||||
{
|
||||
const float Epsilon = 0.000199999995f; // ~0.0002f, verbatim retail constant
|
||||
|
||||
if (MathF.Abs(oldSpeed) > Epsilon)
|
||||
{
|
||||
sequence.MultiplyCyclicAnimationFramerate(newSpeed / oldSpeed);
|
||||
return;
|
||||
}
|
||||
|
||||
// oldSpeed ~ 0: only the "newSpeed also ~0" leg does anything (zero
|
||||
// the framerate). The "newSpeed NOT ~0" leg is a silent no-op —
|
||||
// retail's own gap, kept verbatim (Q0-pins A4-#2).
|
||||
if (MathF.Abs(newSpeed) <= Epsilon)
|
||||
{
|
||||
sequence.MultiplyCyclicAnimationFramerate(0f);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>add_motion</c> 0x005224b0 (@298437): UNCONDITIONALLY sets
|
||||
/// <paramref name="sequence"/>'s velocity/omega to
|
||||
/// <c>motion.Velocity/Omega * speedMod</c> (replace, not accumulate — a
|
||||
/// dat-silent MotionData carries a zero Vector3, so "unconditional
|
||||
/// replace" and "replace with zero" are the same call; G17 core), then
|
||||
/// appends every AnimData in <paramref name="motion"/> speed-scaled
|
||||
/// (framerate only, retail <c>AnimData::operator*</c>).
|
||||
/// </summary>
|
||||
internal static void AddMotion(CSequence sequence, MotionData? motion, float speedMod)
|
||||
{
|
||||
if (motion is null)
|
||||
return;
|
||||
|
||||
sequence.SetVelocity(motion.Velocity * speedMod);
|
||||
sequence.SetOmega(motion.Omega * speedMod);
|
||||
|
||||
foreach (var ad in motion.Anims)
|
||||
{
|
||||
sequence.AppendAnimation(new AnimData
|
||||
{
|
||||
AnimId = ad.AnimId,
|
||||
LowFrame = ad.LowFrame,
|
||||
HighFrame = ad.HighFrame,
|
||||
Framerate = ad.Framerate * speedMod,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>combine_motion</c> 0x00522580 (@298472): ADDS
|
||||
/// <paramref name="motion"/>'s velocity/omega (scaled by
|
||||
/// <paramref name="speedMod"/>) onto the sequence's existing physics.
|
||||
/// Never touches <c>anims</c> — used for modifier overlays where the base
|
||||
/// cycle's animation frames stay untouched.
|
||||
/// </summary>
|
||||
internal static void CombineMotion(CSequence sequence, MotionData? motion, float speedMod)
|
||||
{
|
||||
if (motion is null)
|
||||
return;
|
||||
|
||||
sequence.CombinePhysics(motion.Velocity * speedMod, motion.Omega * speedMod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>subtract_motion</c> 0x00522600 (@298492): inverse of
|
||||
/// <see cref="CombineMotion"/> — used when REMOVING a modifier's
|
||||
/// physics contribution.
|
||||
/// </summary>
|
||||
internal static void SubtractMotion(CSequence sequence, MotionData? motion, float speedMod)
|
||||
{
|
||||
if (motion is null)
|
||||
return;
|
||||
|
||||
sequence.SubtractPhysics(motion.Velocity * speedMod, motion.Omega * speedMod);
|
||||
}
|
||||
|
||||
// ── members ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>is_allowed</c> 0x005226c0 (@298526): a bitfield-bit1 ("gated")
|
||||
/// cycle is only reusable when the current substate matches the
|
||||
/// candidate, OR the current substate already equals the owning style's
|
||||
/// default substate. Null <paramref name="candidate"/> is never allowed.
|
||||
/// </summary>
|
||||
public bool IsAllowed(uint candidateSubstate, MotionData? candidate, MotionState state)
|
||||
{
|
||||
if (candidate is null)
|
||||
return false;
|
||||
|
||||
if ((candidate.Bitfield & 2) != 0)
|
||||
{
|
||||
uint substate = state.Substate;
|
||||
if (candidateSubstate != substate)
|
||||
{
|
||||
uint defaultSubstate = LookupStyleDefault(state.Style);
|
||||
return defaultSubstate == substate;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>get_link</c> 0x00522710 (@298552). Re-homed verbatim from the
|
||||
/// working adapter (<c>AnimationSequencer.GetLink</c>, field-validated —
|
||||
/// the reversed-key branch fixed the Ready→WalkBackward "left leg
|
||||
/// twitches" glitch) per Q0-pins A1: EITHER speed negative routes through
|
||||
/// the swapped-key branch (link stored FROM <paramref name="toSubstate"/>
|
||||
/// TO <paramref name="fromSubstate"/>), falling back to the style's
|
||||
/// default-substate hop; otherwise the forward branch (link FROM
|
||||
/// <paramref name="fromSubstate"/> TO <paramref name="toSubstate"/>),
|
||||
/// falling back to the style-level catch-all (unstyled outer key).
|
||||
/// </summary>
|
||||
public MotionData? GetLink(uint fromStyle, uint fromSubstate, float fromSubstateMod, uint toSubstate, float toSubstateMod)
|
||||
{
|
||||
if (toSubstateMod < 0f || fromSubstateMod < 0f)
|
||||
{
|
||||
// Reversed-direction path: link FROM toSubstate TO fromSubstate.
|
||||
int reversedKey = (int)((fromStyle << 16) | (toSubstate & 0xFFFFFFu));
|
||||
if (_table.Links.TryGetValue(reversedKey, out var revLink)
|
||||
&& revLink.MotionData.TryGetValue((int)fromSubstate, out var revResult))
|
||||
{
|
||||
return revResult;
|
||||
}
|
||||
|
||||
// Style-defaults fallback (decomp §4 second predicate block, "else" branch).
|
||||
uint defaultSubstate = LookupStyleDefault(fromStyle);
|
||||
int subKey = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu));
|
||||
if (_table.Links.TryGetValue(subKey, out var subLink)
|
||||
&& subLink.MotionData.TryGetValue((int)defaultSubstate, out var subResult))
|
||||
{
|
||||
return subResult;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Forward-direction path: link FROM fromSubstate TO toSubstate.
|
||||
int outerKey1 = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu));
|
||||
if (_table.Links.TryGetValue(outerKey1, out var cmd1)
|
||||
&& cmd1.MotionData.TryGetValue((int)toSubstate, out var result1))
|
||||
{
|
||||
return result1;
|
||||
}
|
||||
|
||||
// Fallback: style-level catch-all (unstyled outer key, decomp §4 "else if" branch).
|
||||
int outerKey2 = (int)(fromStyle << 16);
|
||||
if (_table.Links.TryGetValue(outerKey2, out var cmd2)
|
||||
&& cmd2.MotionData.TryGetValue((int)toSubstate, out var result2))
|
||||
{
|
||||
return result2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>GetObjectSequence</c> 0x00522860 (@298636) — the FULL dispatcher.
|
||||
/// See decomp §5 for the annotated retail source; the branch structure
|
||||
/// below mirrors it 1:1 (comments cite the corresponding decomp block).
|
||||
/// </summary>
|
||||
/// <param name="motion">Requested target substate/motion id.</param>
|
||||
/// <param name="state">In/out current motion state.</param>
|
||||
/// <param name="sequence">Sequence to build/mutate.</param>
|
||||
/// <param name="speed">Requested speed_mod for the new substate.</param>
|
||||
/// <param name="outTicks">OUT: tick count of appended animation, minus 1.</param>
|
||||
/// <param name="stopCall">false = normal "do motion"; true = re-invoked
|
||||
/// from <see cref="StopSequenceMotion"/>.</param>
|
||||
public bool GetObjectSequence(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks, bool stopCall)
|
||||
{
|
||||
outTicks = 0;
|
||||
|
||||
uint style = state.Style;
|
||||
if (style == 0)
|
||||
return false;
|
||||
|
||||
uint substate = state.Substate;
|
||||
if (substate == 0)
|
||||
return false;
|
||||
|
||||
uint styleDefault = LookupStyleDefault(style); // var_c
|
||||
|
||||
uint target = motion; // ebx_1 working copy
|
||||
|
||||
// ---- FAST PATH: requesting the style default while already a
|
||||
// modifier-class substate, not a stop-call. ----
|
||||
if (target == styleDefault && !stopCall && (substate & 0x20000000) != 0)
|
||||
return true;
|
||||
|
||||
float requestedSpeed = speed; // ebp_1
|
||||
|
||||
// ================================================================
|
||||
// BRANCH 1: target interpreted as NEGATIVE int32 — style-change request.
|
||||
// ================================================================
|
||||
if ((int)target < 0)
|
||||
{
|
||||
if (style == target)
|
||||
return true; // already in that style
|
||||
|
||||
uint currentStyleDefault = LookupStyleDefault(style); // eax_1
|
||||
|
||||
MotionData? exitLink = null; // var_4_1
|
||||
if (substate != currentStyleDefault)
|
||||
{
|
||||
exitLink = GetLink(style, substate, state.SubstateMod, currentStyleDefault, requestedSpeed);
|
||||
}
|
||||
|
||||
uint targetStyleDefault = LookupStyleDefault(target); // arg7_style_default
|
||||
if (_table.StyleDefaults.ContainsKey((DRWMotionCommand)target))
|
||||
{
|
||||
MotionData? newCycle = LookupCycle(target, targetStyleDefault); // eax_5
|
||||
|
||||
if (newCycle is not null)
|
||||
{
|
||||
if ((newCycle.Bitfield & 1) != 0)
|
||||
state.ClearModifiers();
|
||||
|
||||
// link FROM current style's default substate TO target style's default substate
|
||||
MotionData? directOrHop1 = GetLink(style, styleDefault, state.SubstateMod, target, requestedSpeed); // arg2
|
||||
MotionData? hop2 = null; // var_10_1
|
||||
|
||||
if (directOrHop1 is null && target != style)
|
||||
{
|
||||
// DOUBLE-HOP VIA default_style.
|
||||
directOrHop1 = GetLink(style, styleDefault, 1f, (uint)_table.DefaultStyle, 1f);
|
||||
uint defaultStyleDefaultSubstate = LookupStyleDefault((uint)_table.DefaultStyle);
|
||||
hop2 = GetLink((uint)_table.DefaultStyle, defaultStyleDefaultSubstate, 1f, target, 1f);
|
||||
}
|
||||
|
||||
sequence.ClearPhysics();
|
||||
sequence.RemoveCyclicAnims();
|
||||
AddMotion(sequence, exitLink, requestedSpeed);
|
||||
AddMotion(sequence, directOrHop1, requestedSpeed);
|
||||
AddMotion(sequence, hop2, requestedSpeed);
|
||||
AddMotion(sequence, newCycle, requestedSpeed);
|
||||
|
||||
state.Substate = targetStyleDefault;
|
||||
state.Style = target;
|
||||
state.SubstateMod = speed;
|
||||
ReModify(sequence, state);
|
||||
|
||||
uint numAnims2 = (uint)(exitLink?.Anims.Count ?? 0);
|
||||
uint ecx20 = (uint)(directOrHop1?.Anims.Count ?? 0);
|
||||
uint numAnims1 = (uint)(hop2?.Anims.Count ?? 0);
|
||||
outTicks = (uint)newCycle.Anims.Count + numAnims1 + ecx20 + numAnims2 - 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// else: fall through to the class-bit blocks below with target still negative.
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BRANCH 2: target has the CYCLE-CLASS bit (0x40000000) set.
|
||||
// ================================================================
|
||||
if ((target & 0x40000000) != 0)
|
||||
{
|
||||
uint substateId = target & 0xFFFFFFu;
|
||||
MotionData? cyclic = LookupCycle(style, substateId); // eax_24
|
||||
|
||||
if (cyclic is null)
|
||||
{
|
||||
// No cycle for THIS style at that id -> retry under the
|
||||
// table-wide default_style (decomp §5 "label_522ae6" retry —
|
||||
// unconditional, no style==0 guard in retail).
|
||||
cyclic = LookupCycle((uint)_table.DefaultStyle, substateId);
|
||||
}
|
||||
|
||||
if (cyclic is not null && IsAllowed(target, cyclic, state))
|
||||
{
|
||||
|
||||
// ---- FAST RE-SPEED PATH ----
|
||||
if (target == substate
|
||||
&& SameSign(requestedSpeed, state.SubstateMod)
|
||||
&& sequence.HasAnims())
|
||||
{
|
||||
ChangeCycleSpeed(sequence, cyclic, state.SubstateMod, requestedSpeed);
|
||||
SubtractMotion(sequence, cyclic, state.SubstateMod);
|
||||
CombineMotion(sequence, cyclic, requestedSpeed);
|
||||
state.SubstateMod = speed;
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((cyclic.Bitfield & 1) != 0)
|
||||
state.ClearModifiers();
|
||||
|
||||
MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_34
|
||||
bool sameSignDirect = directLink is not null && SameSign(requestedSpeed, state.SubstateMod);
|
||||
|
||||
MotionData? hop2 = null; // var_10_1
|
||||
MotionData? linkOrHop1 = directLink; // arg2
|
||||
|
||||
if (directLink is null || !sameSignDirect)
|
||||
{
|
||||
uint styleDefaultSubstate = LookupStyleDefault(style);
|
||||
linkOrHop1 = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f);
|
||||
hop2 = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed);
|
||||
}
|
||||
|
||||
sequence.ClearPhysics();
|
||||
sequence.RemoveCyclicAnims();
|
||||
|
||||
if (hop2 is null)
|
||||
{
|
||||
float signedSpeed = (state.SubstateMod == 0f || SameSign(state.SubstateMod, speed))
|
||||
? speed : -speed;
|
||||
AddMotion(sequence, linkOrHop1, signedSpeed);
|
||||
}
|
||||
else
|
||||
{
|
||||
AddMotion(sequence, linkOrHop1, state.SubstateMod);
|
||||
AddMotion(sequence, hop2, requestedSpeed);
|
||||
}
|
||||
|
||||
AddMotion(sequence, cyclic, requestedSpeed);
|
||||
|
||||
// Leaving a modifier-class substate for something else: re-register
|
||||
// the OLD substate as a still-active modifier unless the style's
|
||||
// default substate IS the new target.
|
||||
uint oldSubstate = state.Substate;
|
||||
if (oldSubstate != target && (oldSubstate & 0x20000000) != 0)
|
||||
{
|
||||
uint styleDefaultSubstate2 = LookupStyleDefault(style);
|
||||
if (styleDefaultSubstate2 != target)
|
||||
state.AddModifierNoCheck(oldSubstate, state.SubstateMod);
|
||||
}
|
||||
|
||||
state.SubstateMod = speed;
|
||||
state.Substate = target;
|
||||
ReModify(sequence, state);
|
||||
|
||||
uint ecx45 = (uint)(linkOrHop1?.Anims.Count ?? 0);
|
||||
uint numAnims1b = (uint)(hop2?.Anims.Count ?? 0);
|
||||
outTicks = (uint)cyclic.Anims.Count + numAnims1b + ecx45 - 1;
|
||||
return true;
|
||||
}
|
||||
// is_allowed rejected (or no cyclic resolved) -> fall through.
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BRANCH 3: target has the ACTION-CLASS bit (0x10000000) set.
|
||||
// ================================================================
|
||||
if ((target & 0x10000000) != 0)
|
||||
{
|
||||
MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_57
|
||||
if (baseCycle is not null)
|
||||
{
|
||||
MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_60
|
||||
if (directLink is not null)
|
||||
{
|
||||
state.AddAction(target, requestedSpeed);
|
||||
sequence.ClearPhysics();
|
||||
sequence.RemoveCyclicAnims();
|
||||
AddMotion(sequence, directLink, requestedSpeed);
|
||||
AddMotion(sequence, baseCycle, state.SubstateMod);
|
||||
ReModify(sequence, state);
|
||||
outTicks = (uint)directLink.Anims.Count;
|
||||
return true;
|
||||
}
|
||||
|
||||
// No direct link -> route through the style default (double-hop out-and-back).
|
||||
uint styleDefaultSubstate = LookupStyleDefault(style);
|
||||
MotionData? outHop = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); // eax_66
|
||||
if (outHop is not null)
|
||||
{
|
||||
MotionData? actionLink = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); // eax_68
|
||||
if (actionLink is not null)
|
||||
{
|
||||
MotionData? baseCycleRefetch = LookupCycle(style, substate & 0xFFFFFFu); // eax_69 (same key, re-fetched)
|
||||
if (baseCycleRefetch is not null)
|
||||
{
|
||||
MotionData? returnHop = GetLink(style, styleDefaultSubstate, 1f, substate, state.SubstateMod);
|
||||
|
||||
state.AddAction(target, requestedSpeed);
|
||||
sequence.ClearPhysics();
|
||||
sequence.RemoveCyclicAnims();
|
||||
AddMotion(sequence, outHop, 1f);
|
||||
AddMotion(sequence, actionLink, requestedSpeed);
|
||||
AddMotion(sequence, returnHop, 1f);
|
||||
AddMotion(sequence, baseCycleRefetch, state.SubstateMod);
|
||||
ReModify(sequence, state);
|
||||
|
||||
// A4-#1: outTicks = outHop + actionLink [+ returnHop] ONLY —
|
||||
// never the base cycle, never double-counted (ACE's bug, not retail's).
|
||||
uint ticks = (uint)outHop.Anims.Count + (uint)actionLink.Anims.Count;
|
||||
if (returnHop is not null)
|
||||
ticks += (uint)returnHop.Anims.Count;
|
||||
outTicks = ticks;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// BRANCH 4: target has the MODIFIER-CLASS bit (0x20000000) set.
|
||||
// ================================================================
|
||||
if ((target & 0x20000000) != 0)
|
||||
{
|
||||
MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_81
|
||||
|
||||
if (baseCycle is not null && (baseCycle.Bitfield & 1) == 0)
|
||||
{
|
||||
uint modKey = target & 0xFFFFFFu;
|
||||
MotionData? modifierStyled = LookupModifier(style, modKey, styleSpecific: true); // eax_85
|
||||
MotionData? modifierGlobal = null; // eax_87
|
||||
if (modifierStyled is null)
|
||||
modifierGlobal = LookupModifier(style, modKey, styleSpecific: false);
|
||||
MotionData? modifierData = modifierStyled ?? modifierGlobal;
|
||||
|
||||
if (modifierStyled is not null || modifierGlobal is not null)
|
||||
{
|
||||
bool added = state.AddModifier(target, requestedSpeed);
|
||||
if (!added)
|
||||
{
|
||||
// Toggle: already active (or == current substate) -> stop then re-add.
|
||||
StopSequenceMotion(target, 1f, state, sequence, out _);
|
||||
added = state.AddModifier(target, requestedSpeed);
|
||||
}
|
||||
if (added)
|
||||
{
|
||||
CombineMotion(sequence, modifierData, requestedSpeed);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>re_modify</c> 0x005222e0 (@298300): after installing a new base
|
||||
/// cycle/substate, replay every previously-active modifier back onto the
|
||||
/// sequence by re-invoking <see cref="GetObjectSequence"/> for each one.
|
||||
/// Q0-pins A4-#5: the retail copy-ctor snapshot exists ONLY as a
|
||||
/// loop-termination bound (both the live list and the snapshot pop one
|
||||
/// entry per iteration; the loop ends when the snapshot empties) — the
|
||||
/// C# port deep-copies via <c>MotionState(MotionState other)</c>, pops
|
||||
/// both in lockstep, and terminates on the snapshot's emptiness.
|
||||
/// </summary>
|
||||
public void ReModify(CSequence sequence, MotionState state)
|
||||
{
|
||||
if (!state.Modifiers.Any())
|
||||
return;
|
||||
|
||||
var snapshot = new MotionState(state);
|
||||
|
||||
do
|
||||
{
|
||||
var head = state.Modifiers.First();
|
||||
uint motion = head.Motion;
|
||||
float speedMod = head.SpeedMod;
|
||||
state.RemoveModifier(head);
|
||||
|
||||
var snapshotHead = snapshot.Modifiers.First();
|
||||
snapshot.RemoveModifier(snapshotHead);
|
||||
|
||||
GetObjectSequence(motion, state, sequence, speedMod, out _, stopCall: false);
|
||||
} while (snapshot.Modifiers.Any());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>StopSequenceMotion</c> 0x00522fc0 (@298954): two independent stop
|
||||
/// mechanisms. Stopping the active cycle re-drives
|
||||
/// <see cref="GetObjectSequence"/> toward the style's default substate
|
||||
/// (i.e. "return to idle"). Stopping a modifier just unwinds its physics
|
||||
/// contribution and removes it from the chain — no animation
|
||||
/// re-sequencing needed since modifiers don't touch <c>anims[]</c>.
|
||||
/// Action-class ids are NOT handled here (decomp §7 note — actions
|
||||
/// complete via the MotionTableManager tick-countdown, Q3 scope).
|
||||
/// </summary>
|
||||
public bool StopSequenceMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks)
|
||||
{
|
||||
outTicks = 0;
|
||||
|
||||
// Case A: stopping the CYCLE-class substate we are currently in.
|
||||
if ((motion & 0x40000000) != 0 && motion == state.Substate)
|
||||
{
|
||||
uint styleDefaultSubstate = LookupStyleDefault(state.Style);
|
||||
GetObjectSequence(styleDefaultSubstate, state, sequence, 1f, out outTicks, stopCall: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Case B: stopping a MODIFIER-class id.
|
||||
if ((motion & 0x20000000) != 0)
|
||||
{
|
||||
foreach (var node in state.Modifiers)
|
||||
{
|
||||
if (node.Motion != motion)
|
||||
continue;
|
||||
|
||||
uint modKey = motion & 0xFFFFFFu;
|
||||
MotionData? modData = LookupModifier(state.Style, modKey, styleSpecific: true);
|
||||
modData ??= LookupModifier(state.Style, modKey, styleSpecific: false);
|
||||
|
||||
if (modData is not null)
|
||||
{
|
||||
SubtractMotion(sequence, modData, node.SpeedMod);
|
||||
state.RemoveModifier(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
break; // matching motion id found but no MotionData anywhere -> give up
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>SetDefaultState</c> 0x005230a0 (@299004): reset a MotionState to
|
||||
/// the motion table's baseline (<c>default_style</c> + that style's
|
||||
/// default substate), clearing all modifiers/actions and installing the
|
||||
/// base cyclic animation for that (style,substate) fresh via
|
||||
/// <see cref="CSequence.ClearAnimations"/> (hard reset, not just
|
||||
/// <c>RemoveCyclicAnims</c>).
|
||||
/// </summary>
|
||||
public bool SetDefaultState(MotionState state, CSequence sequence, out uint outTicks)
|
||||
{
|
||||
outTicks = 0;
|
||||
|
||||
if (!_table.StyleDefaults.TryGetValue(_table.DefaultStyle, out var defaultSubstateCmd))
|
||||
return false;
|
||||
|
||||
uint defaultSubstate = (uint)defaultSubstateCmd;
|
||||
|
||||
state.ClearModifiers();
|
||||
state.ClearActions();
|
||||
|
||||
MotionData? cyclic = LookupCycle((uint)_table.DefaultStyle, defaultSubstate);
|
||||
if (cyclic is null)
|
||||
return false;
|
||||
|
||||
state.Style = (uint)_table.DefaultStyle;
|
||||
state.Substate = defaultSubstate;
|
||||
state.SubstateMod = 1f;
|
||||
outTicks = (uint)cyclic.Anims.Count - 1;
|
||||
|
||||
sequence.ClearPhysics();
|
||||
sequence.ClearAnimations();
|
||||
AddMotion(sequence, cyclic, state.SubstateMod);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary><c>DoObjectMotion</c> 0x00523e90 (@300045): thin wrapper — "do" ==
|
||||
/// <see cref="GetObjectSequence"/> with the stop-flag forced to false.</summary>
|
||||
public bool DoObjectMotion(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks)
|
||||
=> GetObjectSequence(motion, state, sequence, speed, out outTicks, stopCall: false);
|
||||
|
||||
/// <summary><c>StopObjectMotion</c> 0x00523ec0 (@300053): thin wrapper —
|
||||
/// tailcall straight into <see cref="StopSequenceMotion"/>.</summary>
|
||||
public bool StopObjectMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks)
|
||||
=> StopSequenceMotion(motion, speed, state, sequence, out outTicks);
|
||||
|
||||
/// <summary>
|
||||
/// <c>StopObjectCompletely</c> 0x00523ed0 (@300062): the "return to idle
|
||||
/// from everything" call — strips every active modifier first (each one
|
||||
/// individually going through the same subtract-and-unlink path as a
|
||||
/// single <see cref="StopObjectMotion"/> call), then stops the base
|
||||
/// cycle/substate itself (which re-drives <see cref="GetObjectSequence"/>
|
||||
/// toward the style's default substate). Q0-pins A4-#4: return =
|
||||
/// <c>finalStopOk ? true : anyModifierStopOk</c>, ported verbatim.
|
||||
/// </summary>
|
||||
public bool StopObjectCompletely(MotionState state, CSequence sequence, out uint outTicks)
|
||||
{
|
||||
outTicks = 0;
|
||||
bool anyModifierStopOk = false;
|
||||
|
||||
// Stop EVERY currently-active modifier first (list shrinks each
|
||||
// iteration because StopSequenceMotion unlinks the node it stops).
|
||||
while (state.Modifiers.Any())
|
||||
{
|
||||
var node = state.Modifiers.First();
|
||||
float speedMod = node.SpeedMod;
|
||||
if (StopSequenceMotion(node.Motion, speedMod, state, sequence, out outTicks))
|
||||
anyModifierStopOk = true;
|
||||
else
|
||||
break; // defensive: avoid infinite loop if a stop can't unlink (shouldn't happen)
|
||||
}
|
||||
|
||||
// Finally stop the base cycle/substate itself.
|
||||
if (StopSequenceMotion(state.Substate, state.SubstateMod, state, sequence, out outTicks))
|
||||
return true;
|
||||
|
||||
return anyModifierStopOk;
|
||||
}
|
||||
|
||||
// ── private lookup helpers ──────────────────────────────────────────
|
||||
|
||||
private uint LookupStyleDefault(uint style)
|
||||
=> _table.StyleDefaults.TryGetValue((DRWMotionCommand)style, out var def) ? (uint)def : 0u;
|
||||
|
||||
private MotionData? LookupCycle(uint style, uint substate)
|
||||
{
|
||||
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
|
||||
return _table.Cycles.TryGetValue(key, out var data) ? data : null;
|
||||
}
|
||||
|
||||
private MotionData? LookupModifier(uint style, uint modKey, bool styleSpecific)
|
||||
{
|
||||
int key = styleSpecific ? (int)((style << 16) | modKey) : (int)modKey;
|
||||
return _table.Modifiers.TryGetValue(key, out var data) ? data : null;
|
||||
}
|
||||
}
|
||||
511
src/AcDream.Core/Physics/Motion/CSequence.cs
Normal file
511
src/AcDream.Core/Physics/Motion/CSequence.cs
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R1-P4 host seam standing in for retail's <c>CPhysicsObj.anim_hooks</c>
|
||||
/// SmartArray + the global <c>AnimDoneHook</c> singleton
|
||||
/// (<c>add_anim_hook</c> 0x00514c20; <c>process_hooks</c> 0x00511550 drains
|
||||
/// once per physics tick — the drain point stays with the host until R6
|
||||
/// places it per retail's UpdateObjectInternal order).
|
||||
/// </summary>
|
||||
public interface IAnimHookQueue
|
||||
{
|
||||
/// <summary>Queue a matched AnimFrame hook (already direction-filtered
|
||||
/// by <c>execute_hooks</c>).</summary>
|
||||
void AddAnimHook(DatReaderWriter.Types.AnimationHook hook);
|
||||
|
||||
/// <summary>Queue the global animation-done hook — retail's
|
||||
/// <c>AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
|
||||
/// CPartArray::AnimationDone(1)</c> chain (R2 consumes it as
|
||||
/// MotionDone).</summary>
|
||||
void AddAnimDoneHook();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
|
||||
/// surgery (Phase R plan stage R1; oracle
|
||||
/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17,
|
||||
/// §20, §24). The per-tick advance (`update`/`update_internal`/
|
||||
/// `apply_physics`/hook dispatch) lands in R1-P3/P4.
|
||||
///
|
||||
/// Structure: a doubly-linked animation-node list with two cursors —
|
||||
/// <see cref="CurrAnim"/> (the node currently playing) and
|
||||
/// <see cref="FirstCyclic"/> (where the looping tail begins; everything
|
||||
/// before it is one-shot "link" animation). Retail invariant (G10):
|
||||
/// <c>append_animation</c> slides <c>first_cyclic</c> to the JUST-APPENDED
|
||||
/// node on EVERY call — the cyclic tail is always exactly the last node
|
||||
/// appended so far.
|
||||
///
|
||||
/// Physics accumulators (<see cref="Velocity"/>/<see cref="Omega"/>) live on
|
||||
/// the SEQUENCE, not per node (G16); retail replaces them via
|
||||
/// <c>set_velocity/set_omega</c> and algebraically blends via
|
||||
/// <c>combine_physics/subtract_physics</c> (the R2 fast path's mechanism).
|
||||
///
|
||||
/// Divergence register (rows added with this commit):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>frame_number</c> is x87 <c>long double</c> in retail
|
||||
/// (acclient.h:30747); C# <c>double</c> is the closest available (G15).</item>
|
||||
/// <item>The intrusive DLList is a managed <see cref="LinkedList{T}"/>;
|
||||
/// node identity semantics preserved via <see cref="LinkedListNode{T}"/>
|
||||
/// references.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class CSequence
|
||||
{
|
||||
private readonly LinkedList<AnimSequenceNode> _animList = new(); // anim_list (DLList)
|
||||
private LinkedListNode<AnimSequenceNode>? _firstCyclic; // first_cyclic
|
||||
private LinkedListNode<AnimSequenceNode>? _currAnim; // curr_anim
|
||||
private readonly IAnimationLoader _loader;
|
||||
|
||||
/// <summary>Fractional frame position within <see cref="CurrAnim"/>.
|
||||
/// Retail x87 long double → double (register row, G15).</summary>
|
||||
public double FrameNumber;
|
||||
|
||||
/// <summary>Sequence root-motion velocity accumulator (body-local).</summary>
|
||||
public Vector3 Velocity;
|
||||
|
||||
/// <summary>Sequence angular-velocity accumulator.</summary>
|
||||
public Vector3 Omega;
|
||||
|
||||
/// <summary>Static pose used when no animation node is active
|
||||
/// (<c>placement_frame</c>, §16).</summary>
|
||||
public AnimationFrame? PlacementFrame { get; private set; }
|
||||
|
||||
public uint PlacementFrameId { get; private set; }
|
||||
|
||||
public CSequence(IAnimationLoader loader) => _loader = loader;
|
||||
|
||||
// ── inspection surface (adapter + tests) ────────────────────────────
|
||||
|
||||
public AnimSequenceNode? CurrAnim => _currAnim?.Value;
|
||||
public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value;
|
||||
public int Count => _animList.Count;
|
||||
|
||||
/// <summary><c>has_anims</c> (0x00524bd0).</summary>
|
||||
public bool HasAnims() => _animList.Count > 0;
|
||||
|
||||
/// <summary>TEST SEAM: reposition curr_anim by list index (retail state
|
||||
/// reached via update_internal, which lands in P4).</summary>
|
||||
public void SetCurrAnimForTest(int index)
|
||||
{
|
||||
var n = _animList.First;
|
||||
for (int i = 0; i < index && n != null; i++) n = n.Next;
|
||||
_currAnim = n;
|
||||
}
|
||||
|
||||
// ── append_animation (0x00525510, §24) ──────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Append a MotionData anim entry. A node whose dat animation fails to
|
||||
/// resolve is discarded. <c>first_cyclic</c> slides to the appended
|
||||
/// node on EVERY call; <c>curr_anim</c> seeds to the head (with
|
||||
/// <c>frame_number = get_starting_frame()</c>) only when it was null.
|
||||
/// </summary>
|
||||
public void AppendAnimation(AnimData animData)
|
||||
{
|
||||
var node = new AnimSequenceNode(animData, _loader);
|
||||
if (!node.HasAnim)
|
||||
return; // retail deletes the node — discard
|
||||
|
||||
_animList.AddLast(node);
|
||||
_firstCyclic = _animList.Last;
|
||||
|
||||
if (_currAnim is null)
|
||||
{
|
||||
_currAnim = _animList.First;
|
||||
FrameNumber = _currAnim!.Value.GetStartingFrame();
|
||||
}
|
||||
}
|
||||
|
||||
// ── clear family (§3-§5) ────────────────────────────────────────────
|
||||
|
||||
/// <summary><c>clear</c> (0x005255b0): full wipe INCLUDING the placement
|
||||
/// fields — the raw body resets them (the "2-instruction clear" note in
|
||||
/// the gap map was wrong; raw decomp is authority).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
ClearAnimations();
|
||||
ClearPhysics();
|
||||
PlacementFrame = null;
|
||||
PlacementFrameId = 0;
|
||||
}
|
||||
|
||||
/// <summary><c>clear_animations</c> (0x00524dc0): delete every node,
|
||||
/// null both cursors, zero <c>frame_number</c>.</summary>
|
||||
public void ClearAnimations()
|
||||
{
|
||||
_animList.Clear();
|
||||
_firstCyclic = null;
|
||||
_currAnim = null;
|
||||
FrameNumber = 0.0;
|
||||
}
|
||||
|
||||
/// <summary><c>clear_physics</c> (0x00524d50).</summary>
|
||||
public void ClearPhysics()
|
||||
{
|
||||
Velocity = Vector3.Zero;
|
||||
Omega = Vector3.Zero;
|
||||
}
|
||||
|
||||
// ── remove family (§6-§8) ───────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>remove_cyclic_anims</c> (0x00524e40): delete <c>first_cyclic</c>
|
||||
/// → tail. A removed <c>curr_anim</c> snaps BACK to the previous node
|
||||
/// with <c>frame_number = prev.get_ending_frame()</c> (or 0.0 when the
|
||||
/// list emptied). Afterwards <c>first_cyclic</c> = new tail (or null).
|
||||
/// </summary>
|
||||
public void RemoveCyclicAnims()
|
||||
{
|
||||
var node = _firstCyclic;
|
||||
while (node is not null)
|
||||
{
|
||||
var next = node.Next;
|
||||
if (ReferenceEquals(_currAnim, node))
|
||||
{
|
||||
var prev = node.Previous;
|
||||
_currAnim = prev;
|
||||
FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame();
|
||||
}
|
||||
_animList.Remove(node);
|
||||
node = next;
|
||||
}
|
||||
_firstCyclic = _animList.Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>remove_link_animations(count)</c> (0x00524be0): delete up to
|
||||
/// <paramref name="count"/> predecessors of <c>first_cyclic</c>. A
|
||||
/// removed <c>curr_anim</c> snaps FORWARD to <c>first_cyclic</c> with
|
||||
/// <c>frame_number = get_starting_frame()</c>.
|
||||
/// </summary>
|
||||
public void RemoveLinkAnimations(int count)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var prev = _firstCyclic?.Previous;
|
||||
if (prev is null)
|
||||
break;
|
||||
|
||||
if (ReferenceEquals(_currAnim, prev))
|
||||
{
|
||||
_currAnim = _firstCyclic;
|
||||
if (_firstCyclic is not null)
|
||||
FrameNumber = _firstCyclic.Value.GetStartingFrame();
|
||||
}
|
||||
_animList.Remove(prev);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>remove_all_link_animations</c> (0x00524ca0): loop until
|
||||
/// <c>first_cyclic</c> has no predecessor.</summary>
|
||||
public void RemoveAllLinkAnimations()
|
||||
{
|
||||
while (_firstCyclic?.Previous is not null)
|
||||
RemoveLinkAnimations(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>apricot</c> (0x00524b40; the PDB-verified retail name): trim
|
||||
/// consumed nodes from the head, bounded by BOTH <c>curr_anim</c>
|
||||
/// (stop — still live) and <c>first_cyclic</c> (defensive bound —
|
||||
/// never delete into the cyclic tail). Called after every update (§22).
|
||||
/// </summary>
|
||||
public void Apricot()
|
||||
{
|
||||
var head = _animList.First;
|
||||
if (head is null || ReferenceEquals(head, _currAnim))
|
||||
return;
|
||||
|
||||
while (!ReferenceEquals(head, _firstCyclic))
|
||||
{
|
||||
_animList.Remove(head!);
|
||||
head = _animList.First;
|
||||
if (head is null || ReferenceEquals(head, _currAnim))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── physics accumulators (§10-§13) ──────────────────────────────────
|
||||
|
||||
public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880
|
||||
public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0
|
||||
public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0
|
||||
public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900
|
||||
|
||||
// ── multiply_cyclic_animation_fr (0x00524940, §14) ──────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Scale the framerate of every node from <c>first_cyclic</c> to the
|
||||
/// tail. Framerates ONLY (G13) — retail rescales the sequence
|
||||
/// velocity/omega separately via <c>change_cycle_speed</c>'s
|
||||
/// <c>subtract_motion</c>/<c>combine_motion</c> composite (R2).
|
||||
/// </summary>
|
||||
public void MultiplyCyclicAnimationFramerate(float factor)
|
||||
{
|
||||
for (var n = _firstCyclic; n is not null; n = n.Next)
|
||||
n.Value.MultiplyFramerate(factor);
|
||||
}
|
||||
|
||||
// ── placement + accessors (§15-§17) ─────────────────────────────────
|
||||
|
||||
/// <summary><c>set_placement_frame</c> (0x005249b0).</summary>
|
||||
public void SetPlacementFrame(AnimationFrame? frame, uint id)
|
||||
{
|
||||
PlacementFrame = frame;
|
||||
PlacementFrameId = id;
|
||||
}
|
||||
|
||||
/// <summary><c>get_curr_animframe</c> (0x00524970): the floored current
|
||||
/// part frame, or the placement frame when no node is active.</summary>
|
||||
public AnimationFrame? GetCurrAnimframe()
|
||||
{
|
||||
if (_currAnim is null)
|
||||
return PlacementFrame;
|
||||
return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber));
|
||||
}
|
||||
|
||||
/// <summary><c>get_curr_frame_number</c> (0x005249d0).</summary>
|
||||
public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber);
|
||||
|
||||
// ── apply_physics (0x00524ab0, §19) ─────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Accumulated-physics root motion: advance <paramref name="frame"/> by
|
||||
/// <see cref="Velocity"/>/<see cref="Omega"/> over a signed quantum —
|
||||
/// MAGNITUDE from <paramref name="quantum"/>, SIGN from
|
||||
/// <paramref name="signSource"/> (retail copysign semantics; call sites
|
||||
/// pass 1/framerate as magnitude and the signed elapsed time as sign).
|
||||
/// </summary>
|
||||
public void ApplyPhysics(Frame frame, double quantum, double signSource)
|
||||
{
|
||||
double signed = Math.Abs(quantum);
|
||||
if (signSource < 0.0)
|
||||
signed = -signed;
|
||||
|
||||
float sq = (float)signed;
|
||||
frame.Origin += Velocity * sq;
|
||||
FrameOps.Rotate(frame, Omega * sq);
|
||||
}
|
||||
|
||||
// ── R1-P4: the frame-advance core ───────────────────────────────────
|
||||
|
||||
/// <summary>Host hook queue (<c>hook_obj</c>); null = hooks dropped
|
||||
/// (objects without a physics host).</summary>
|
||||
public IAnimHookQueue? HookObj;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CSequence::update</c> (0x00525b80, §22): non-empty list →
|
||||
/// <see cref="UpdateInternal"/> then <see cref="Apricot"/>; empty list
|
||||
/// with a Frame → accumulated-physics free motion.
|
||||
/// </summary>
|
||||
public void Update(double timeElapsed, Frame? frame)
|
||||
{
|
||||
if (_animList.Count > 0 && _currAnim is not null)
|
||||
{
|
||||
UpdateInternal(timeElapsed, frame);
|
||||
Apricot();
|
||||
}
|
||||
else if (frame is not null)
|
||||
{
|
||||
ApplyPhysics(frame, timeElapsed, timeElapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CSequence::update_internal</c> (0x005255d0, §21 — ACE-verified
|
||||
/// skeleton, P0-pins.md): advance <see cref="FrameNumber"/> by
|
||||
/// framerate·dt; on overshoot clamp to the RAW high/low frame, compute
|
||||
/// the leftover time, and mark animDone; fire the pose/physics/hook
|
||||
/// triple for EVERY crossed integer frame (ascending forward,
|
||||
/// descending reverse); queue the global AnimDoneHook when the list
|
||||
/// HEAD is no longer the cyclic tail (G5's structural gate); advance to
|
||||
/// the next node and LOOP with the carried leftover (P0 pin).
|
||||
/// Iterative (retail while-true), NO safety cap (G4), NO boundary
|
||||
/// epsilon (G1/G3).
|
||||
/// </summary>
|
||||
public void UpdateInternal(double timeElapsed, Frame? frame)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_currAnim is null)
|
||||
return;
|
||||
|
||||
var currAnim = _currAnim.Value;
|
||||
double framerate = currAnim.Framerate;
|
||||
double frametime = framerate * timeElapsed;
|
||||
int lastFrame = (int)Math.Floor(FrameNumber);
|
||||
|
||||
FrameNumber += frametime;
|
||||
double frameTimeElapsed = 0.0;
|
||||
bool animDone = false;
|
||||
|
||||
if (frametime > 0.0)
|
||||
{
|
||||
if (currAnim.HighFrame < Math.Floor(FrameNumber))
|
||||
{
|
||||
double frameOffset = FrameNumber - currAnim.HighFrame - 1.0;
|
||||
if (frameOffset < 0.0)
|
||||
frameOffset = 0.0;
|
||||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||||
frameTimeElapsed = frameOffset / framerate;
|
||||
FrameNumber = currAnim.HighFrame;
|
||||
animDone = true;
|
||||
}
|
||||
while (Math.Floor(FrameNumber) > lastFrame)
|
||||
{
|
||||
if (frame is not null)
|
||||
{
|
||||
var pos = currAnim.GetPosFrame(lastFrame);
|
||||
if (pos is not null)
|
||||
FrameOps.Combine(frame, pos);
|
||||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||||
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
|
||||
}
|
||||
ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1);
|
||||
lastFrame++;
|
||||
}
|
||||
}
|
||||
else if (frametime < 0.0)
|
||||
{
|
||||
if (currAnim.LowFrame > Math.Floor(FrameNumber))
|
||||
{
|
||||
double frameOffset = FrameNumber - currAnim.LowFrame;
|
||||
if (frameOffset > 0.0)
|
||||
frameOffset = 0.0;
|
||||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||||
frameTimeElapsed = frameOffset / framerate;
|
||||
FrameNumber = currAnim.LowFrame;
|
||||
animDone = true;
|
||||
}
|
||||
while (Math.Floor(FrameNumber) < lastFrame)
|
||||
{
|
||||
if (frame is not null)
|
||||
{
|
||||
var pos = currAnim.GetPosFrame(lastFrame);
|
||||
if (pos is not null)
|
||||
FrameOps.Subtract1(frame, pos);
|
||||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||||
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
|
||||
}
|
||||
ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1);
|
||||
lastFrame--;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon)
|
||||
ApplyPhysics(frame, timeElapsed, timeElapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!animDone)
|
||||
return;
|
||||
|
||||
// AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) —
|
||||
// fire only when the consumed head is not already the cyclic
|
||||
// tail (retail 0x00525943-0x00525968).
|
||||
if (HookObj is not null
|
||||
&& _animList.First is not null
|
||||
&& !ReferenceEquals(_animList.First, _firstCyclic))
|
||||
{
|
||||
HookObj.AddAnimDoneHook();
|
||||
}
|
||||
|
||||
AdvanceToNextAnimation(timeElapsed, frame);
|
||||
timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CSequence::advance_to_next_animation</c> (0x005252b0, §23): four
|
||||
/// pose operations per transition — un-apply the outgoing node's pose
|
||||
/// (subtract1 + residual physics), step (forward wraps to
|
||||
/// <c>first_cyclic</c>; REVERSE wraps to the LIST TAIL — asymmetric by
|
||||
/// design), reseed <see cref="FrameNumber"/> from the incoming node's
|
||||
/// direction-aware boundary, apply the incoming pose (combine +
|
||||
/// physics). Pose ops run in BOTH directions (ACE's framerate-sign
|
||||
/// gates are ACE-isms; the decomp is unconditional apart from the
|
||||
/// degenerate-framerate guard).
|
||||
/// </summary>
|
||||
public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
|
||||
{
|
||||
if (_currAnim is null)
|
||||
return;
|
||||
|
||||
var outgoing = _currAnim.Value;
|
||||
|
||||
// (a) un-apply the outgoing node's pose at the CURRENT FrameNumber.
|
||||
if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon)
|
||||
{
|
||||
var pos = outgoing.GetPosFrame((int)FrameNumber);
|
||||
if (pos is not null)
|
||||
FrameOps.Subtract1(frame, pos);
|
||||
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
|
||||
}
|
||||
|
||||
// (b) step; (c) reseed FrameNumber from the incoming boundary.
|
||||
if (timeElapsed >= 0.0)
|
||||
{
|
||||
_currAnim = _currAnim.Next ?? _firstCyclic;
|
||||
if (_currAnim is null)
|
||||
return;
|
||||
FrameNumber = _currAnim.Value.GetStartingFrame();
|
||||
}
|
||||
else
|
||||
{
|
||||
_currAnim = _currAnim.Previous ?? _animList.Last;
|
||||
if (_currAnim is null)
|
||||
return;
|
||||
FrameNumber = _currAnim.Value.GetEndingFrame();
|
||||
}
|
||||
|
||||
// (d) apply the incoming node's pose at the new FrameNumber.
|
||||
var incoming = _currAnim.Value;
|
||||
if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon)
|
||||
{
|
||||
var pos = incoming.GetPosFrame((int)FrameNumber);
|
||||
if (pos is not null)
|
||||
FrameOps.Combine(frame, pos);
|
||||
ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CSequence::execute_hooks</c> (0x00524830, §18): QUEUE the part
|
||||
/// frame's hooks whose direction is Both (0) or matches the playback
|
||||
/// direction (+1 forward / −1 backward) into the host. Null part frame
|
||||
/// guarded (retail has a latent null-deref here — documented safe
|
||||
/// divergence, G18).
|
||||
/// </summary>
|
||||
private void ExecuteHooks(AnimationFrame? partFrame, int direction)
|
||||
{
|
||||
if (partFrame is null || HookObj is null)
|
||||
return;
|
||||
|
||||
foreach (var hook in partFrame.Hooks)
|
||||
{
|
||||
if (hook is null)
|
||||
continue;
|
||||
int dir = (int)hook.Direction;
|
||||
if (dir == 0 || dir == direction)
|
||||
HookObj.AddAnimHook(hook);
|
||||
}
|
||||
}
|
||||
|
||||
// ── internal cursors for P4 (update_internal operates on nodes) ─────
|
||||
|
||||
internal LinkedListNode<AnimSequenceNode>? CurrAnimNode
|
||||
{
|
||||
get => _currAnim;
|
||||
set => _currAnim = value;
|
||||
}
|
||||
|
||||
internal LinkedListNode<AnimSequenceNode>? FirstCyclicNode => _firstCyclic;
|
||||
internal LinkedList<AnimSequenceNode> AnimList => _animList;
|
||||
}
|
||||
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
120
src/AcDream.Core/Physics/Motion/ConstraintManager.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — verbatim port of retail's <c>ConstraintManager</c> (acclient.h:31529,
|
||||
/// struct #3467; decomp 0x00556090-0x005562xx,
|
||||
/// <c>r5-constraintmanager-decomp.md</c>). The server-position <b>rubber-band
|
||||
/// leash</b>: while the owning object is in ground contact it progressively
|
||||
/// resists movement past a start→max distance band from a pinned anchor, and
|
||||
/// hard-clamps at the max. Read as a jump gate by
|
||||
/// <c>CMotionInterp::jump_is_allowed</c> (block jump while
|
||||
/// <see cref="IsFullyConstrained"/>).
|
||||
///
|
||||
/// <para><b>Arming is UNPORTED in acdream (R5).</b> Retail arms the leash ONLY
|
||||
/// from <c>SmartBox::HandleReceivedPosition</c> (on every inbound server
|
||||
/// position packet) with two constants from
|
||||
/// <c>CPhysicsObj::GetStart/MaxConstraintDistance</c> whose values BN elided
|
||||
/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation
|
||||
/// is not SmartBox, so nothing calls <see cref="ConstrainTo"/> — the leash
|
||||
/// stays disarmed and <see cref="IsFullyConstrained"/> stays false, matching
|
||||
/// register TS-35's current stub behavior. The class is ported for structural
|
||||
/// completeness of <see cref="PositionManager"/>; the leash-arming port + the
|
||||
/// two unknown constants are a deferred issue (port-plan §Constraint scope).</para>
|
||||
/// </summary>
|
||||
public sealed class ConstraintManager
|
||||
{
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
/// <summary>+0x04 retail <c>is_constrained</c>.</summary>
|
||||
public bool IsConstrained { get; private set; }
|
||||
|
||||
/// <summary>+0x08 retail <c>constraint_pos_offset</c> — recomputed every
|
||||
/// <see cref="AdjustOffset"/> as the LENGTH of that tick's step (NOT the
|
||||
/// distance to the anchor — see the ACE correctness note, port-plan §2b);
|
||||
/// the next tick's contact branch compares it against start/max.</summary>
|
||||
public float ConstraintPosOffset { get; private set; }
|
||||
|
||||
/// <summary>+0x0c retail <c>constraint_pos</c> — the leash anchor. Stored
|
||||
/// by <see cref="ConstrainTo"/>, never read by <see cref="AdjustOffset"/>
|
||||
/// (retail + ACE — write-only in this class).</summary>
|
||||
public Position ConstraintPos { get; private set; }
|
||||
|
||||
/// <summary>+0x48 retail <c>constraint_distance_start</c> — the near edge
|
||||
/// of the brake band.</summary>
|
||||
public float ConstraintDistanceStart { get; private set; }
|
||||
|
||||
/// <summary>+0x4c retail <c>constraint_distance_max</c> — the far edge
|
||||
/// (full clamp).</summary>
|
||||
public float ConstraintDistanceMax { get; private set; }
|
||||
|
||||
public ConstraintManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::ConstrainTo</c> (0x00556240). Pin the leash
|
||||
/// anchor and band; initialize <see cref="ConstraintPosOffset"/> to the
|
||||
/// CURRENT distance from anchor to the mover (the leash starts already
|
||||
/// extended to wherever the object is, not at zero).
|
||||
/// </summary>
|
||||
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||
{
|
||||
IsConstrained = true;
|
||||
ConstraintPos = anchor;
|
||||
ConstraintDistanceStart = startDistance;
|
||||
ConstraintDistanceMax = maxDistance;
|
||||
ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>ConstraintManager::UnConstrain</c> (0x005560c0) —
|
||||
/// clears the constrained flag only (leaves the band/anchor/offset stale
|
||||
/// until the next <see cref="ConstrainTo"/>).</summary>
|
||||
public void UnConstrain() => IsConstrained = false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::IsFullyConstrained</c> (0x005560d0):
|
||||
/// <c>constraint_distance_max * 0.9 < constraint_pos_offset</c> — the
|
||||
/// object counts as fully constrained once it has strained past 90 % of the
|
||||
/// max leash. Read by <c>jump_is_allowed</c> to block jumps. Always false
|
||||
/// while the leash is disarmed (acdream never arms it — see class note).
|
||||
/// </summary>
|
||||
public bool IsFullyConstrained()
|
||||
=> ConstraintDistanceMax * 0.9f < ConstraintPosOffset;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ConstraintManager::adjust_offset</c> (0x00556180). The last
|
||||
/// stage of <see cref="PositionManager.AdjustOffset"/>'s chain — clamps the
|
||||
/// ALREADY-composed per-tick <paramref name="offset"/> (interp + sticky)
|
||||
/// while grounded, then records its length for the next tick. No-op unless
|
||||
/// <see cref="IsConstrained"/>. See port-plan §2b.
|
||||
/// </summary>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
_ = quantum; // retail body doesn't use the quantum directly.
|
||||
if (!IsConstrained)
|
||||
return;
|
||||
|
||||
if (_host.InContact) // transient_state & 1 — clamp only while grounded.
|
||||
{
|
||||
if (ConstraintPosOffset < ConstraintDistanceMax)
|
||||
{
|
||||
if (ConstraintPosOffset > ConstraintDistanceStart)
|
||||
{
|
||||
// Linear brake taper: 1.0 just past start → 0.0 at max.
|
||||
float taper = (ConstraintDistanceMax - ConstraintPosOffset)
|
||||
/ (ConstraintDistanceMax - ConstraintDistanceStart);
|
||||
offset.Origin *= taper;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
offset.Origin = Vector3.Zero; // past max — fully pinned.
|
||||
}
|
||||
}
|
||||
|
||||
// Unconditional (grounded OR airborne): track this tick's step length.
|
||||
ConstraintPosOffset = offset.Origin.Length();
|
||||
}
|
||||
}
|
||||
82
src/AcDream.Core/Physics/Motion/FrameOps.cs
Normal file
82
src/AcDream.Core/Physics/Motion/FrameOps.cs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R1-P3 — verbatim ports of retail's <c>Frame</c> rotation helpers used by
|
||||
/// the CSequence physics path (oracle: raw decomp reads 2026-07-02).
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Frame::grotate</c> (0x005357a0, pc:319782): build the
|
||||
/// axis-angle quaternion from a GLOBAL rotation vector (angle = |v|,
|
||||
/// axis = v/|v|, half-angle sin/cos) and PREMULTIPLY it onto the frame's
|
||||
/// orientation — the incremental rotation is applied in world space.
|
||||
/// Rotations with |v|² < F_EPSILON² are skipped.</item>
|
||||
/// <item><c>Frame::rotate</c> (0x004525b0, pc:91477): map the LOCAL
|
||||
/// rotation vector through the frame's local→global rotation
|
||||
/// (m_fl2gv — our quaternion), then <c>grotate</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class FrameOps
|
||||
{
|
||||
/// <summary>Retail F_EPSILON (0.000199999995f); grotate gates on its
|
||||
/// square against |v|².</summary>
|
||||
public const float FEpsilon = 0.000199999995f;
|
||||
|
||||
/// <summary><c>Frame::grotate</c> — incremental WORLD-space rotation.</summary>
|
||||
public static void GRotate(Frame frame, Vector3 rotationGlobal)
|
||||
{
|
||||
float magSq = rotationGlobal.LengthSquared();
|
||||
if (magSq < FEpsilon * FEpsilon)
|
||||
return;
|
||||
|
||||
float angle = MathF.Sqrt(magSq);
|
||||
float invMag = 1f / angle;
|
||||
float half = angle * 0.5f;
|
||||
float s = MathF.Sin(half);
|
||||
float c = MathF.Cos(half);
|
||||
|
||||
// Retail's set_rotate receives the raw Hamilton product r ⊗ q
|
||||
// (r = the new axis-angle quat, q = the current orientation) —
|
||||
// rotation applied in GLOBAL space. System.Numerics
|
||||
// Quaternion.Multiply(r, q) is that product with
|
||||
// Transform(v, r*q) == r-applied-after-q. set_rotate renormalizes.
|
||||
var r = new Quaternion(
|
||||
rotationGlobal.X * s * invMag,
|
||||
rotationGlobal.Y * s * invMag,
|
||||
rotationGlobal.Z * s * invMag,
|
||||
c);
|
||||
frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation));
|
||||
}
|
||||
|
||||
/// <summary><c>Frame::rotate</c> — LOCAL rotation vector, mapped to
|
||||
/// global through the orientation, then <see cref="GRotate"/>.</summary>
|
||||
public static void Rotate(Frame frame, Vector3 rotationLocal)
|
||||
=> GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation));
|
||||
|
||||
/// <summary>
|
||||
/// <c>Frame::combine</c> as used by the CSequence pose path (ACE
|
||||
/// <c>AFrame.Combine</c>, verified against the pre-R1 acdream port):
|
||||
/// apply the pose — origin += rotate(pos.Origin by orientation), then
|
||||
/// orientation ∘= pos.Orientation.
|
||||
/// </summary>
|
||||
public static void Combine(Frame frame, Frame pos)
|
||||
{
|
||||
frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation);
|
||||
frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Frame::subtract1</c> — un-apply the pose: orientation ∘=
|
||||
/// conj(pos.Orientation) FIRST, then origin −= rotate(pos.Origin by the
|
||||
/// UPDATED orientation) (inverse order of <see cref="Combine"/>).
|
||||
/// </summary>
|
||||
public static void Subtract1(Frame frame, Frame pos)
|
||||
{
|
||||
frame.Orientation = Quaternion.Normalize(
|
||||
frame.Orientation * Quaternion.Conjugate(pos.Orientation));
|
||||
frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation);
|
||||
}
|
||||
}
|
||||
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
100
src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 seam — the acdream stand-in for retail's <c>CPhysicsObj</c> as seen BY
|
||||
/// its owned managers. Retail's <c>StickyManager</c> / <c>ConstraintManager</c>
|
||||
/// / <c>TargetManager</c> each hold a raw <c>physics_obj</c> pointer and call
|
||||
/// back through it (position/velocity/radius accessors, target-tracking
|
||||
/// registration, the <c>HandleUpdateTarget</c> fan-out) and — for the voyeur
|
||||
/// system — resolve OTHER physics objects via <c>CObjectMaint::GetObjectA</c>
|
||||
/// and drive their <c>add_voyeur</c> / <c>receive_target_update</c> /
|
||||
/// <c>remove_voyeur</c> entry points. This interface is that back-pointer.
|
||||
///
|
||||
/// <para>The App layer implements one host per entity (a remote
|
||||
/// <c>RemoteMotion</c> or the local player), wiring the accessors to the live
|
||||
/// <see cref="PhysicsBody"/> and the <see cref="MoveToManager"/> /
|
||||
/// <see cref="PositionManager"/> / <see cref="TargetManager"/> it owns.
|
||||
/// <see cref="GetObjectA"/> is backed by the App's live entity table
|
||||
/// (<c>_entitiesByServerGuid</c>), giving the voyeur round-trip its
|
||||
/// cross-entity delivery path.</para>
|
||||
/// </summary>
|
||||
public interface IPhysicsObjHost
|
||||
{
|
||||
/// <summary>Retail <c>physics_obj->id</c> — this object's guid.</summary>
|
||||
uint Id { get; }
|
||||
|
||||
/// <summary>Retail <c>physics_obj->m_position</c> — world-space cell +
|
||||
/// frame (acdream seams carry WORLD space; see the MoveToManager binding
|
||||
/// note).</summary>
|
||||
Position Position { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::get_velocity</c>.</summary>
|
||||
Vector3 Velocity { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::GetRadius</c> — the mover's cylinder
|
||||
/// radius.</summary>
|
||||
float Radius { get; }
|
||||
|
||||
/// <summary>Retail <c>physics_obj->transient_state & 1</c> — the
|
||||
/// CONTACT bit (ConstraintManager's grounded gate).</summary>
|
||||
bool InContact { get; }
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::get_minterp()->get_max_speed()</c> —
|
||||
/// the mover's max locomotion speed, or <c>null</c> if it has no motion
|
||||
/// interpreter yet (StickyManager falls back to a 15.0 constant).</summary>
|
||||
float? MinterpMaxSpeed { get; }
|
||||
|
||||
/// <summary>Retail <c>Timer::cur_time</c> — the wall/game clock (seconds).
|
||||
/// Drives the sticky 1 s timeout and target 10 s staleness deadlines.</summary>
|
||||
double CurTime { get; }
|
||||
|
||||
/// <summary>Retail <c>PhysicsTimer::curr_time</c> — the physics-tick clock
|
||||
/// (seconds). Drives <c>TargetManager::HandleTargetting</c>'s 0.5 s
|
||||
/// throttle. Retail uses a DIFFERENT clock here than <see cref="CurTime"/>;
|
||||
/// acdream may bind both to the same source.</summary>
|
||||
double PhysicsTimerTime { get; }
|
||||
|
||||
/// <summary>Retail <c>CObjectMaint::GetObjectA(id)</c> — resolve another
|
||||
/// physics object by guid, or <c>null</c> if not currently known/visible.
|
||||
/// The cross-entity seam for the voyeur round-trip and sticky live-target
|
||||
/// resolve.</summary>
|
||||
IPhysicsObjHost? GetObjectA(uint id);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::HandleUpdateTarget</c> — fans a
|
||||
/// <see cref="TargetInfo"/> to this host's <see cref="MoveToManager"/>
|
||||
/// (move-to steering) AND <see cref="PositionManager"/> (sticky follow).
|
||||
/// Called from <see cref="TargetManager.ReceiveUpdate"/> and the timeout
|
||||
/// path.</summary>
|
||||
void HandleUpdateTarget(TargetInfo info);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::interrupt_current_movement</c> →
|
||||
/// <c>MovementManager::CancelMoveTo(0x36)</c>.</summary>
|
||||
void InterruptCurrentMovement();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::set_target(ctx, objId, radius,
|
||||
/// quantum)</c> → <see cref="TargetManager.SetTarget"/> (lazily creating
|
||||
/// the TargetManager). Called by <c>StickyManager::StickTo</c> and
|
||||
/// <c>MoveToManager</c>'s object-move entry points.</summary>
|
||||
void SetTarget(uint contextId, uint objectId, float radius, double quantum);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::clear_target</c> →
|
||||
/// <see cref="TargetManager.ClearTarget"/>.</summary>
|
||||
void ClearTarget();
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::receive_target_update</c> →
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/>. The inbound side a SENDER's
|
||||
/// <c>SendVoyeurUpdate</c> tail-calls on the watcher.</summary>
|
||||
void ReceiveTargetUpdate(TargetInfo info);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::add_voyeur(id, radius, quantum)</c> →
|
||||
/// <see cref="TargetManager.AddVoyeur"/> (lazily creating the
|
||||
/// TargetManager). Called on the TARGET when a watcher subscribes.</summary>
|
||||
void AddVoyeur(uint watcherId, float radius, double quantum);
|
||||
|
||||
/// <summary>Retail <c>CPhysicsObj::remove_voyeur(id)</c> →
|
||||
/// <see cref="TargetManager.RemoveVoyeur"/>. Called on the TARGET when a
|
||||
/// watcher unsubscribes.</summary>
|
||||
void RemoveVoyeur(uint watcherId);
|
||||
}
|
||||
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
40
src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// Mutable stand-in for retail's <c>Frame</c> when it is used as the per-tick
|
||||
/// <b>delta accumulator</b> that <c>PositionManager::adjust_offset</c> and its
|
||||
/// three sub-managers (Interpolation / Sticky / Constraint) write into
|
||||
/// (retail arg2, e.g. <c>StickyManager::adjust_offset</c> 0x00555430,
|
||||
/// <c>ConstraintManager::adjust_offset</c> 0x00556180). acdream's
|
||||
/// <see cref="CellFrame"/> is an immutable record — retail's per-tick math
|
||||
/// mutates <c>m_fOrigin</c> in place across the interp→sticky→constraint chain
|
||||
/// (each sub-manager composes on top of the previous one's write), so the
|
||||
/// accumulator needs a mutable shape.
|
||||
///
|
||||
/// <para><see cref="Origin"/> = retail <c>m_fOrigin</c> (the accumulated
|
||||
/// position delta, in the mover's LOCAL frame after
|
||||
/// <c>Position::globaltolocalvec</c>). <see cref="Orientation"/> carries the
|
||||
/// heading retail's <c>Frame::set_heading</c> writes; read/write it as a
|
||||
/// compass heading via <see cref="GetHeading"/> / <see cref="SetHeading"/>
|
||||
/// (P5 convention, degrees).</para>
|
||||
/// </summary>
|
||||
public sealed class MotionDeltaFrame
|
||||
{
|
||||
/// <summary>Retail <c>m_fOrigin</c> — the accumulated per-tick position
|
||||
/// delta (mover-local frame).</summary>
|
||||
public Vector3 Origin;
|
||||
|
||||
/// <summary>Retail <c>Frame</c> rotation — carries the
|
||||
/// <c>Frame::set_heading</c> output.</summary>
|
||||
public Quaternion Orientation = Quaternion.Identity;
|
||||
|
||||
/// <summary>Retail <c>Frame::get_heading</c> (P5 compass degrees).</summary>
|
||||
public float GetHeading() => MoveToMath.GetHeading(Orientation);
|
||||
|
||||
/// <summary>Retail <c>Frame::set_heading(headingDeg)</c> — pure
|
||||
/// yaw-about-Z setter (P5 compass degrees).</summary>
|
||||
public void SetHeading(float headingDeg) =>
|
||||
Orientation = MoveToMath.SetHeading(Orientation, headingDeg);
|
||||
}
|
||||
30
src/AcDream.Core/Physics/Motion/MotionNode.cs
Normal file
30
src/AcDream.Core/Physics/Motion/MotionNode.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R3-W1 — verbatim port of retail's <c>CMotionInterp::MotionNode</c>
|
||||
/// (<c>acclient.h:53293</c>, struct #5857):
|
||||
/// <code>
|
||||
/// struct __cppobj CMotionInterp::MotionNode : LListData
|
||||
/// {
|
||||
/// unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr)
|
||||
/// unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag
|
||||
/// unsigned int jump_error_code; // +0xc
|
||||
/// };
|
||||
/// </code>
|
||||
/// This is the node type queued onto <c>CMotionInterp::pending_motions</c>
|
||||
/// by <c>add_to_queue</c> (0x00527b80, docs/research/2026-07-02-r3-motioninterp/
|
||||
/// r3-motioninterp-decomp.md §1a) and consumed head-first, unconditionally,
|
||||
/// by <c>MotionDone</c> (0x00527ec0, §1c) / <c>HandleExitWorld</c> (0x00527f30,
|
||||
/// §1d). W1 ports only the shape — the queue itself (<c>PendingMotions</c>,
|
||||
/// <c>add_to_queue</c>, <c>MotionDone</c>) is R3-W2 scope (r3-port-plan.md §3).
|
||||
/// </summary>
|
||||
/// <param name="ContextId">Retail <c>context_id</c> (+4) — the
|
||||
/// <c>MovementParameters.ContextId</c> that produced this node.</param>
|
||||
/// <param name="Motion">Retail <c>motion</c> (+8) — the applied motion id.
|
||||
/// Bit <c>0x10000000</c> marks an "action-class" motion; <c>MotionDone</c>
|
||||
/// only pops the state action-FIFO head when this bit is set on the queue
|
||||
/// head it is consuming.</param>
|
||||
/// <param name="JumpErrorCode">Retail <c>jump_error_code</c> (+0xc) — the
|
||||
/// <c>motion_allows_jump</c> result computed at enqueue time. Peeked by
|
||||
/// <c>jump_is_allowed</c>'s pending-head short-circuit (W0-pins A2).</param>
|
||||
public readonly record struct MotionNode(uint ContextId, uint Motion, uint JumpErrorCode);
|
||||
122
src/AcDream.Core/Physics/Motion/MotionState.cs
Normal file
122
src/AcDream.Core/Physics/Motion/MotionState.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// One entry of a <see cref="MotionState"/> chain — retail's
|
||||
/// <c>MotionList</c> node (12 bytes: motion + speed_mod + next;
|
||||
/// acclient.h, r2-motiontable-decomp.md §14). One struct, two independent
|
||||
/// chain disciplines: the modifier chain is a PUSH-FRONT STACK, the action
|
||||
/// chain a TAIL-APPEND FIFO.
|
||||
/// </summary>
|
||||
public sealed class MotionEntry
|
||||
{
|
||||
public uint Motion;
|
||||
public float SpeedMod = 1f;
|
||||
|
||||
public MotionEntry(uint motion, float speedMod)
|
||||
{
|
||||
Motion = motion;
|
||||
SpeedMod = speedMod;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q1 — verbatim port of retail's <c>MotionState</c> (acclient.h:31081;
|
||||
/// r2-motiontable-decomp.md §13): the motion-table-facing state block —
|
||||
/// current style / substate / substate_mod plus the modifier stack and the
|
||||
/// action FIFO that <c>CMotionTable::GetObjectSequence</c>,
|
||||
/// <c>StopSequenceMotion</c>, <c>re_modify</c>, and
|
||||
/// <c>MotionTableManager::AnimationDone</c> read and write.
|
||||
/// </summary>
|
||||
public sealed class MotionState
|
||||
{
|
||||
/// <summary>Current style/stance command word (0 = unset; ctor 0x00525fd0).</summary>
|
||||
public uint Style;
|
||||
|
||||
/// <summary>Current base substate command word (0 = unset).</summary>
|
||||
public uint Substate;
|
||||
|
||||
/// <summary>Speed modifier of the base substate (default 1.0).</summary>
|
||||
public float SubstateMod = 1f;
|
||||
|
||||
private readonly LinkedList<MotionEntry> _modifiers = new(); // modifier_head — push-front stack
|
||||
private readonly LinkedList<MotionEntry> _actions = new(); // action_head/tail — FIFO
|
||||
|
||||
public MotionState()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deep copy (retail copy ctor 0x00526790; Q0-pins A4-#5): both chains
|
||||
/// CLONED — <c>re_modify</c>'s snapshot is a termination bound, never
|
||||
/// shared state.
|
||||
/// </summary>
|
||||
public MotionState(MotionState other)
|
||||
{
|
||||
Style = other.Style;
|
||||
Substate = other.Substate;
|
||||
SubstateMod = other.SubstateMod;
|
||||
foreach (var m in other._modifiers)
|
||||
_modifiers.AddLast(new MotionEntry(m.Motion, m.SpeedMod));
|
||||
foreach (var a in other._actions)
|
||||
_actions.AddLast(new MotionEntry(a.Motion, a.SpeedMod));
|
||||
}
|
||||
|
||||
/// <summary>Modifier chain in retail order (newest first — push-front).</summary>
|
||||
public IEnumerable<MotionEntry> Modifiers => _modifiers;
|
||||
|
||||
/// <summary>Action FIFO in retail order (oldest first).</summary>
|
||||
public IEnumerable<MotionEntry> Actions => _actions;
|
||||
|
||||
/// <summary><c>add_modifier_no_check</c> (0x00525ff0): unconditional
|
||||
/// push-front.</summary>
|
||||
public void AddModifierNoCheck(uint motion, float speedMod)
|
||||
=> _modifiers.AddFirst(new MotionEntry(motion, speedMod));
|
||||
|
||||
/// <summary>
|
||||
/// <c>add_modifier</c> (0x00526340): refuse when the motion is already
|
||||
/// a modifier (caller must stop-then-re-add to refresh) or already IS
|
||||
/// the current base substate.
|
||||
/// </summary>
|
||||
public bool AddModifier(uint motion, float speedMod)
|
||||
{
|
||||
for (var n = _modifiers.First; n is not null; n = n.Next)
|
||||
if (n.Value.Motion == motion)
|
||||
return false;
|
||||
|
||||
if (Substate == motion)
|
||||
return false;
|
||||
|
||||
AddModifierNoCheck(motion, speedMod);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary><c>remove_modifier</c> (0x00526040) — by node identity
|
||||
/// (retail's node+predecessor pair collapses in a managed list).</summary>
|
||||
public void RemoveModifier(MotionEntry entry)
|
||||
{
|
||||
_modifiers.Remove(entry);
|
||||
}
|
||||
|
||||
/// <summary><c>clear_modifiers</c> (0x00526070).</summary>
|
||||
public void ClearModifiers() => _modifiers.Clear();
|
||||
|
||||
/// <summary><c>add_action</c> (0x005260a0): tail append.</summary>
|
||||
public void AddAction(uint motion, float speedMod)
|
||||
=> _actions.AddLast(new MotionEntry(motion, speedMod));
|
||||
|
||||
/// <summary><c>remove_action_head</c> (0x00526120): pop the FIFO head,
|
||||
/// returning its motion id (0 when empty).</summary>
|
||||
public uint RemoveActionHead()
|
||||
{
|
||||
var head = _actions.First;
|
||||
if (head is null)
|
||||
return 0;
|
||||
_actions.RemoveFirst();
|
||||
return head.Value.Motion;
|
||||
}
|
||||
|
||||
/// <summary><c>clear_actions</c> (0x005260f0).</summary>
|
||||
public void ClearActions() => _actions.Clear();
|
||||
}
|
||||
89
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal file
89
src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R2-Q5 — the <see cref="IInterpretedMotionSink"/> that dispatches the
|
||||
/// CMotionInterp funnel's retail-ordered motion events STRAIGHT into the
|
||||
/// entity's motion-table stack via
|
||||
/// <see cref="AnimationSequencer.PerformMovement"/> (lazy initialize_state +
|
||||
/// <c>MotionTableManager::PerformMovement</c> 0x0051c0b0).
|
||||
///
|
||||
/// <para>
|
||||
/// This replaces the App-side <c>RemoteMotionSink</c> (deleted): no axis
|
||||
/// collection, no single-cycle priority pick, no Commit pass, no HasCycle
|
||||
/// probe, no Run→Walk→Ready missing-cycle chain — retail's
|
||||
/// <c>GetObjectSequence</c> (0x00522860) + <c>is_allowed</c> decide what
|
||||
/// plays. Forward locomotion installs the substate cycle (Branch 2);
|
||||
/// sidesteps resolve by the dat (cycle when authored, else modifier);
|
||||
/// turns are Branch-4 physics-only modifiers blended over the substate via
|
||||
/// <c>combine_motion</c>/<c>re_modify</c> — the composition the retired
|
||||
/// AP-73 row approximated with one cycle.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The <see cref="TurnApplied"/>/<see cref="TurnStopped"/> callbacks are the
|
||||
/// interim ObservedOmega seam: the App seeds the remote body's angular
|
||||
/// velocity from the wire turn so rotation starts the same tick (retail
|
||||
/// rotates the body from the sequence omega inside the per-tick
|
||||
/// <c>apply_physics</c> chain — R6 scope; register row). They fire BEFORE
|
||||
/// the dispatch so a consumer sees the seed even if the dat lacks the
|
||||
/// modifier entry.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MotionTableDispatchSink : IInterpretedMotionSink
|
||||
{
|
||||
private readonly AnimationSequencer _sequencer;
|
||||
|
||||
/// <summary>Turn-class dispatch observed: (motion, signedSpeed) —
|
||||
/// TurnLeft arrives either as the explicit 0x0E command or as
|
||||
/// TurnRight + negative speed (adjust_motion wire convention).</summary>
|
||||
public Action<uint, float>? TurnApplied { get; set; }
|
||||
|
||||
/// <summary>Turn-class stop observed.</summary>
|
||||
public Action? TurnStopped { get; set; }
|
||||
|
||||
public MotionTableDispatchSink(AnimationSequencer sequencer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sequencer);
|
||||
_sequencer = sequencer;
|
||||
}
|
||||
|
||||
private static bool IsTurn(uint motion)
|
||||
=> (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E;
|
||||
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnApplied?.Invoke(motion, speed);
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
||||
public bool StopMotion(uint motion)
|
||||
{
|
||||
if (IsTurn(motion))
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f));
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 wedge fix — retail <c>CPartArray::StopCompletelyInternal</c>
|
||||
/// (0x00518890): <c>MotionTableManager::PerformMovement(type 5)</c>.
|
||||
/// The manager stops everything and queues its Ready-sentinel
|
||||
/// pending_animations entry UNCONDITIONALLY — the completable partner
|
||||
/// of the interp's A9 pending_motions node. A full stop ends any turn
|
||||
/// cycle, so the ObservedOmega seam is notified like
|
||||
/// <see cref="StopMotion"/>'s turn-class branch.
|
||||
/// </summary>
|
||||
public bool StopCompletely()
|
||||
{
|
||||
TurnStopped?.Invoke();
|
||||
|
||||
uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely());
|
||||
return result == MotionTableManagerError.Success;
|
||||
}
|
||||
}
|
||||
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal file
508
src/AcDream.Core/Physics/Motion/MotionTableManager.cs
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// R2-Q3 — verbatim port of retail's MotionTableManager (the pending-animation
|
||||
// queue + tick-countdown completion machinery sitting ABOVE CMotionTable).
|
||||
// Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11
|
||||
// (all addresses/line numbers below point there); ambiguity pins:
|
||||
// docs/research/2026-07-02-r2-motiontable/Q0-pins.md; plan:
|
||||
// docs/research/2026-07-02-r2-motiontable/r2-port-plan.md §3 Q3 + §4 (the
|
||||
// MotionDone -> R3 boundary contract).
|
||||
//
|
||||
// Struct layout (acclient.h:57614/31092/31097; decomp §11 offset map):
|
||||
// physics_obj @0x0, table @0x4, state @0x8 (24 bytes), animation_counter
|
||||
// @0x20, pending_animations {head_,tail_} @0x24/0x28. sizeof == 0x2c.
|
||||
// C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target
|
||||
// as an injectable seam (IMotionDoneSink, part A of this file) since R3 binds
|
||||
// the real MotionInterpreter/MovementManager consumer.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// The R2 seam standing in for retail's <c>CPhysicsObj::MotionDone</c> — the
|
||||
/// callback <see cref="MotionTableManager.AnimationDone"/> and
|
||||
/// <see cref="MotionTableManager.CheckForCompletedMotions"/> fire for every
|
||||
/// pending-queue entry whose tick countdown reaches zero. R2 leaves this
|
||||
/// consumer-injectable (register row: MotionDone observed-not-consumed,
|
||||
/// r2-port-plan.md §4); R3 binds it to <c>MotionInterpreter.MotionDone</c>
|
||||
/// (pops <c>CMotionInterp.pending_motions</c>, recomputes IsAnimating).
|
||||
/// </summary>
|
||||
public interface IMotionDoneSink
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>CPhysicsObj::MotionDone(motion, success)</c>. <paramref name="success"/>
|
||||
/// is the CALLER's flag: true from the real per-tick animation-done hook
|
||||
/// (<see cref="MotionTableManager.AnimationDone"/> invoked with
|
||||
/// <c>success:true</c>), false from the enter/exit-world drains,
|
||||
/// hardcoded true from <see cref="MotionTableManager.CheckForCompletedMotions"/>.
|
||||
/// </summary>
|
||||
void MotionDone(uint motion, bool success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One <c>pending_animations</c> queue node — retail's
|
||||
/// <c>MotionTableManager::AnimNode</c> (acclient.h:57614; 16 bytes:
|
||||
/// dllist_next/prev + motion + num_anims). <c>NumAnims</c> doubles as a
|
||||
/// RELATIVE tick-duration countdown once queued (not an absolute deadline —
|
||||
/// decomp §11 <c>AnimationDone</c> note), not an anim-array length.
|
||||
///
|
||||
/// Register note (reusing the R1 AD-34 wording): retail's intrusive DLList
|
||||
/// is ported as a managed <see cref="LinkedList{T}"/> of <see cref="PendingMotion"/>;
|
||||
/// node identity semantics (the tail-anchored backward scan in
|
||||
/// <see cref="MotionTableManager.RemoveRedundantLinks"/>, the in-place
|
||||
/// truncation in <see cref="MotionTableManager.TruncateAnimationList"/>) are
|
||||
/// preserved via <see cref="LinkedListNode{T}"/> references rather than raw
|
||||
/// prev/next pointers.
|
||||
/// </summary>
|
||||
public sealed class PendingMotion
|
||||
{
|
||||
public uint Motion;
|
||||
public uint NumAnims;
|
||||
|
||||
public PendingMotion(uint motion, uint numAnims)
|
||||
{
|
||||
Motion = motion;
|
||||
NumAnims = numAnims;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>MotionTableManager</c> (0x0051bxxx region) — owns the
|
||||
/// pending-animation queue and the tick-countdown completion machinery that
|
||||
/// sits ABOVE <see cref="CMotionTable"/>'s pure selection logic.
|
||||
/// <see cref="PerformMovement"/> is the single chokepoint between a wire-level
|
||||
/// <see cref="MovementStruct"/> and the motion-table state machine (decomp
|
||||
/// §11 <c>PerformMovement</c> 0x0051c0b0).
|
||||
/// </summary>
|
||||
public sealed class MotionTableManager
|
||||
{
|
||||
// Motion-id class bits (decomp §1 / §15).
|
||||
private const uint CycleClassBit = 0x40000000u;
|
||||
private const uint ModifierClassBit = 0x20000000u;
|
||||
private const uint ActionClassBit = 0x10000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
|
||||
/// CYCLE-class tail scan (decomp §11/§15 literal "0xb0000000"). Bit
|
||||
/// decomposition: <c>0xb0000000 == 0x80000000 (style) | 0x20000000
|
||||
/// (modifier) | 0x10000000 (action)</c> — i.e. STYLE|MODIFIER|ACTION,
|
||||
/// deliberately EXCLUDING the cycle-class bit (0x40000000) itself. The
|
||||
/// decomp's own prose gloss ("cycle|action|..." mask) is imprecise; the
|
||||
/// literal hex constant (ported verbatim below) is the ground truth —
|
||||
/// an intervening node of a DIFFERENT cycle does not block a cycle-tail
|
||||
/// collapse (two cycles naturally supersede via the base-cycle rebuild
|
||||
/// mechanism), but an intervening style-change/modifier/action does.
|
||||
/// </summary>
|
||||
private const uint CycleTailBlockMask = 0xb0000000u;
|
||||
|
||||
/// <summary>
|
||||
/// Combined block-mask used by <see cref="RemoveRedundantLinks"/>'s
|
||||
/// STYLE-class tail scan (decomp §11/§15 literal "0x70000000"). Bit
|
||||
/// decomposition: <c>0x70000000 == 0x40000000 (cycle) | 0x20000000
|
||||
/// (modifier) | 0x10000000 (action)</c> — CYCLE|MODIFIER|ACTION,
|
||||
/// deliberately EXCLUDING the style-class top bit (0x80000000) itself
|
||||
/// (the match test is already exact-equality on the full style id, so a
|
||||
/// DIFFERENT style in between doesn't need a separate block check here).
|
||||
/// </summary>
|
||||
private const uint StyleTailBlockMask = 0x70000000u;
|
||||
|
||||
/// <summary>Sentinel "stop-completely / default-state-installed" motion id
|
||||
/// (decomp §15 "0x41000003").</summary>
|
||||
public const uint ReadySentinel = 0x41000003u;
|
||||
|
||||
private readonly CMotionTable? _table;
|
||||
private readonly MotionState _state;
|
||||
private readonly CSequence _sequence;
|
||||
private readonly IMotionDoneSink _sink;
|
||||
private readonly LinkedList<PendingMotion> _pendingAnimations = new(); // pending_animations
|
||||
private int _animationCounter; // animation_counter (@0x20)
|
||||
|
||||
/// <summary>Current motion state — retail's embedded <c>state</c> member
|
||||
/// (@0x8, 24 bytes). Exposed for callers that need to read style/substate.</summary>
|
||||
public MotionState State => _state;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Create</c> 0x0051bc50 (@290510). Retail's static factory
|
||||
/// zero-initializes <c>physics_obj</c>/<c>table</c>, placement-constructs
|
||||
/// <c>state</c>, zeros <c>animation_counter</c>, and nulls
|
||||
/// <c>pending_animations</c>'s head/tail — all of which the C# field
|
||||
/// initializers below already give for free. <paramref name="table"/> may
|
||||
/// be null (retail: "no motion table loaded" — <see cref="PerformMovement"/>
|
||||
/// returns error 7 in that case, matching <c>SetMotionTableID</c> never
|
||||
/// having been called).
|
||||
/// </summary>
|
||||
public MotionTableManager(CMotionTable? table, MotionState state, CSequence sequence, IMotionDoneSink sink)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
ArgumentNullException.ThrowIfNull(sequence);
|
||||
ArgumentNullException.ThrowIfNull(sink);
|
||||
|
||||
_table = table;
|
||||
_state = state;
|
||||
_sequence = sequence;
|
||||
_sink = sink;
|
||||
}
|
||||
|
||||
/// <summary>Read-only inspection surface for tests: the pending queue in
|
||||
/// head-to-tail order.</summary>
|
||||
public IEnumerable<PendingMotion> PendingAnimations => _pendingAnimations;
|
||||
|
||||
/// <summary>Read-only inspection surface for tests: the current tick
|
||||
/// countdown accumulator.</summary>
|
||||
public int AnimationCounter => _animationCounter;
|
||||
|
||||
// ── add_to_queue / remove_redundant_links / truncate_animation_list ────
|
||||
|
||||
/// <summary>
|
||||
/// <c>add_to_queue</c> 0x0051bfe0 (@290854): append a new node to the
|
||||
/// tail of <c>pending_animations</c>, then opportunistically call
|
||||
/// <see cref="RemoveRedundantLinks"/> to collapse any now-superseded
|
||||
/// earlier entries.
|
||||
/// </summary>
|
||||
public void AddToQueue(uint motion, uint ticks)
|
||||
{
|
||||
_pendingAnimations.AddLast(new PendingMotion(motion, ticks));
|
||||
RemoveRedundantLinks();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>remove_redundant_links</c> 0x0051bf20 (@290771): retail's
|
||||
/// TAIL-ANCHORED SINGLE SCAN (ported verbatim — NOT ACE's restructured
|
||||
/// outer loop, per r2-port-plan.md §3 Q3).
|
||||
///
|
||||
/// 1. Skip backward over trailing zero-<c>NumAnims</c> nodes (already
|
||||
/// neutered / instant entries). If the list bottoms out, return.
|
||||
/// 2. If the effective tail's motion is CYCLE-class-and-not-modifier-class
|
||||
/// (<c>&0x40000000 != 0 && &0x20000000 == 0</c>): scan backward for an
|
||||
/// EARLIER node with the SAME motion id AND non-zero <c>NumAnims</c>.
|
||||
/// Blocked (abort, no truncation) by any intervening non-zero node
|
||||
/// whose motion matches the 0xb0000000 class mask.
|
||||
/// 3. Else if the effective tail's motion is STYLE-class
|
||||
/// (<c>(int)motion < 0</c>): same backward scan, EXACT match (no
|
||||
/// additional class requirement on the match itself), blocked by any
|
||||
/// intervening non-zero node matching the WIDER 0x70000000 class mask.
|
||||
/// 4. On a match, <see cref="TruncateAnimationList"/> from the matched
|
||||
/// node's successor through the tail (matched node itself untouched).
|
||||
/// </summary>
|
||||
public void RemoveRedundantLinks()
|
||||
{
|
||||
var tail = _pendingAnimations.Last;
|
||||
if (tail is null)
|
||||
return;
|
||||
|
||||
// Step 1: skip trailing zero-tick nodes.
|
||||
while (tail is not null && tail.Value.NumAnims == 0)
|
||||
{
|
||||
tail = tail.Previous;
|
||||
}
|
||||
if (tail is null)
|
||||
return;
|
||||
|
||||
uint motion = tail.Value.Motion;
|
||||
|
||||
if ((motion & CycleClassBit) != 0 && (motion & ModifierClassBit) == 0)
|
||||
{
|
||||
// CYCLE-class (not modifier-class) tail: match = same motion AND
|
||||
// non-zero NumAnims; block = non-zero AND matches 0xb0000000.
|
||||
var scan = tail.Previous;
|
||||
LinkedListNode<PendingMotion>? matched = null;
|
||||
while (scan is not null)
|
||||
{
|
||||
if (scan.Value.Motion == motion && scan.Value.NumAnims != 0)
|
||||
{
|
||||
matched = scan;
|
||||
break;
|
||||
}
|
||||
if (scan.Value.NumAnims != 0 && (scan.Value.Motion & CycleTailBlockMask) != 0)
|
||||
return; // blocked by an intervening "important" non-zero node
|
||||
scan = scan.Previous;
|
||||
}
|
||||
if (matched is not null)
|
||||
TruncateAnimationList(matched);
|
||||
}
|
||||
else if ((int)motion < 0)
|
||||
{
|
||||
// STYLE-class tail: exact-equality match; block mask is wider
|
||||
// (0x70000000) with no additional match-side class requirement.
|
||||
var scan = tail.Previous;
|
||||
LinkedListNode<PendingMotion>? matched = null;
|
||||
while (scan is not null)
|
||||
{
|
||||
if (scan.Value.Motion == motion)
|
||||
{
|
||||
matched = scan;
|
||||
break;
|
||||
}
|
||||
if (scan.Value.NumAnims != 0 && (scan.Value.Motion & StyleTailBlockMask) != 0)
|
||||
return;
|
||||
scan = scan.Previous;
|
||||
}
|
||||
if (matched is not null)
|
||||
TruncateAnimationList(matched);
|
||||
}
|
||||
// else: modifier-class or action-class tail -> no redundancy scan (retail: neither branch taken).
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>truncate_animation_list</c> 0x0051bca0 (@290533): walk
|
||||
/// <c>pending_animations.tail_</c> BACKWARD toward (but not including)
|
||||
/// <paramref name="stopAtExclusive"/>, zeroing each node's
|
||||
/// <c>NumAnims</c> tick countdown IN PLACE (nodes stay queued — retail
|
||||
/// does NOT unlink them here) and accumulating the total ticks removed,
|
||||
/// then strips that many ticks' worth of link animations from the live
|
||||
/// <see cref="CSequence"/> via <see cref="CSequence.RemoveLinkAnimations"/>.
|
||||
/// </summary>
|
||||
private void TruncateAnimationList(LinkedListNode<PendingMotion> stopAtExclusive)
|
||||
{
|
||||
uint removedTicks = 0;
|
||||
var node = _pendingAnimations.Last;
|
||||
while (!ReferenceEquals(node, stopAtExclusive))
|
||||
{
|
||||
if (node is null)
|
||||
return; // stopAtExclusive wasn't actually in the list -> abort quietly
|
||||
|
||||
removedTicks += node.Value.NumAnims;
|
||||
node.Value.NumAnims = 0;
|
||||
node = node.Previous;
|
||||
}
|
||||
|
||||
_sequence.RemoveLinkAnimations((int)removedTicks);
|
||||
}
|
||||
|
||||
// ── AnimationDone / CheckForCompletedMotions / UseTime ─────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>AnimationDone</c> 0x0051bce0 (@290558): advance the animation clock
|
||||
/// by one tick and fire <see cref="IMotionDoneSink.MotionDone"/> for every
|
||||
/// queued motion whose relative-duration countdown has elapsed.
|
||||
/// <c>NumAnims</c> on a queue node is a RELATIVE tick-duration (not an
|
||||
/// absolute deadline) — subtracted from the running counter after firing,
|
||||
/// forming a decrementing countdown chain (one <c>AnimationDone</c> call
|
||||
/// can pop MULTIPLE queued entries via counter rollover).
|
||||
/// </summary>
|
||||
/// <param name="success">Passed straight through to
|
||||
/// <see cref="IMotionDoneSink.MotionDone"/> for every entry popped this
|
||||
/// call.</param>
|
||||
public void AnimationDone(bool success)
|
||||
{
|
||||
var head = _pendingAnimations.First;
|
||||
if (head is null)
|
||||
return;
|
||||
|
||||
_animationCounter += 1;
|
||||
|
||||
while (head is not null && head.Value.NumAnims <= _animationCounter)
|
||||
{
|
||||
if ((head.Value.Motion & ActionClassBit) != 0)
|
||||
_state.RemoveActionHead();
|
||||
|
||||
_sink.MotionDone(head.Value.Motion, success);
|
||||
_animationCounter -= (int)head.Value.NumAnims;
|
||||
|
||||
_pendingAnimations.RemoveFirst();
|
||||
head = _pendingAnimations.First;
|
||||
}
|
||||
|
||||
// Drained-list counter reset: avoid drift once the queue is empty.
|
||||
if (_animationCounter != 0 && head is null)
|
||||
_animationCounter = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>CheckForCompletedMotions</c> 0x0051be00 (@290645): pop every head
|
||||
/// node ALREADY at <c>NumAnims == 0</c> (zero-tick entries, or ones
|
||||
/// neutered by <see cref="TruncateAnimationList"/>). Unlike
|
||||
/// <see cref="AnimationDone"/>: no counter increment, no counter
|
||||
/// decrement, success is hardcoded <c>true</c>.
|
||||
/// </summary>
|
||||
public void CheckForCompletedMotions()
|
||||
{
|
||||
var head = _pendingAnimations.First;
|
||||
if (head is null)
|
||||
return;
|
||||
|
||||
while (head is not null && head.Value.NumAnims == 0)
|
||||
{
|
||||
if ((head.Value.Motion & ActionClassBit) != 0)
|
||||
_state.RemoveActionHead();
|
||||
|
||||
_sink.MotionDone(head.Value.Motion, true);
|
||||
|
||||
_pendingAnimations.RemoveFirst();
|
||||
head = _pendingAnimations.First;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>UseTime</c> 0x0051bfd0 (@290845): per-frame entry point,
|
||||
/// tailcalls <see cref="CheckForCompletedMotions"/>.</summary>
|
||||
public void UseTime() => CheckForCompletedMotions();
|
||||
|
||||
// ── initialize_state / HandleEnterWorld / HandleExitWorld ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>initialize_state</c> 0x0051c030 (@290875): install the motion
|
||||
/// table's baseline state (<see cref="CMotionTable.SetDefaultState"/>) and
|
||||
/// queue the <see cref="ReadySentinel"/> ("0x41000003" — initial
|
||||
/// default-state-installed marker) with the resulting tick count, then
|
||||
/// opportunistically collapse redundant links.
|
||||
/// </summary>
|
||||
public void InitializeState()
|
||||
{
|
||||
uint outTicks = 0;
|
||||
if (_table is not null)
|
||||
{
|
||||
_table.SetDefaultState(_state, _sequence, out outTicks);
|
||||
}
|
||||
|
||||
AddToQueue(ReadySentinel, outTicks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>HandleEnterWorld</c> 0x0051bdd0 (@290634): strip any stale link
|
||||
/// animations from the live sequence
|
||||
/// (<see cref="CSequence.RemoveAllLinkAnimations"/>), then fully drain
|
||||
/// <c>pending_animations</c> exactly like <see cref="HandleExitWorld"/> —
|
||||
/// each drained entry fires <see cref="IMotionDoneSink.MotionDone"/> with
|
||||
/// <c>success:false</c> via repeated <see cref="AnimationDone"/> calls
|
||||
/// (decomp: <c>while (head_ != 0) AnimationDone(this, 0)</c>).
|
||||
/// </summary>
|
||||
public void HandleEnterWorld()
|
||||
{
|
||||
_sequence.RemoveAllLinkAnimations();
|
||||
DrainQueue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>HandleExitWorld</c> 0x0051bda0 (@290625): fully drain
|
||||
/// <c>pending_animations</c>, signaling "failure/aborted"
|
||||
/// (<c>success:false</c>) for every entry via repeated
|
||||
/// <see cref="AnimationDone"/> calls.
|
||||
/// </summary>
|
||||
public void HandleExitWorld() => DrainQueue();
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_pendingAnimations.First is not null)
|
||||
AnimationDone(false);
|
||||
}
|
||||
|
||||
// ── PerformMovement ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// <c>PerformMovement</c> 0x0051c0b0 (@290906) — the single chokepoint
|
||||
/// between a wire-level <see cref="MovementStruct"/> (interpreted
|
||||
/// command / stop / stop-completely) and the motion-table state machine.
|
||||
/// Error codes: <c>7</c> = no motion table loaded; <c>0x43</c> =
|
||||
/// DoObjectMotion/StopObjectMotion returned failure; <c>0</c> = success.
|
||||
/// Unhandled <see cref="MovementType"/>s (RawCommand, StopRawCommand,
|
||||
/// MoveToObject/Position, TurnToObject/Heading) are NOT MotionTableManager's
|
||||
/// job — decomp's BN artifact returns the CSequence pointer reinterpreted
|
||||
/// as a code (§11 note: "likely dead/unreachable... never consulted"); the
|
||||
/// C# port returns <see cref="MotionTableManagerError.NotHandled"/> instead
|
||||
/// of leaking a pointer value, since no caller in this codebase depends on
|
||||
/// that BN quirk.
|
||||
/// </summary>
|
||||
public uint PerformMovement(MotionTableMovement movement)
|
||||
{
|
||||
if (_table is null)
|
||||
return MotionTableManagerError.NoTable; // 7
|
||||
|
||||
uint outTicks;
|
||||
|
||||
switch (movement.Type)
|
||||
{
|
||||
case MovementType.InterpretedCommand:
|
||||
if (_table.DoObjectMotion(movement.Motion, _state, _sequence, movement.Speed, out outTicks))
|
||||
{
|
||||
AddToQueue(movement.Motion, outTicks);
|
||||
return MotionTableManagerError.Success; // 0
|
||||
}
|
||||
return MotionTableManagerError.MotionFailed; // 0x43
|
||||
|
||||
case MovementType.StopInterpretedCommand:
|
||||
if (_table.StopObjectMotion(movement.Motion, movement.Speed, _state, _sequence, out outTicks))
|
||||
{
|
||||
AddToQueue(ReadySentinel, outTicks);
|
||||
return MotionTableManagerError.Success;
|
||||
}
|
||||
return MotionTableManagerError.MotionFailed;
|
||||
|
||||
case MovementType.StopCompletely:
|
||||
_table.StopObjectCompletely(_state, _sequence, out outTicks);
|
||||
AddToQueue(ReadySentinel, outTicks); // UNCONDITIONAL — queued regardless of return value.
|
||||
return MotionTableManagerError.Success;
|
||||
|
||||
default:
|
||||
// RawCommand, StopRawCommand, and the MoveTo*/TurnTo* types are
|
||||
// not MotionTableManager's job (decomp §11 note).
|
||||
return MotionTableManagerError.NotHandled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>PerformMovement</c> error/result codes (decomp §15 "PerformMovement
|
||||
/// error codes"). Named constants standing in for retail's raw hex return
|
||||
/// values, kept as plain <see cref="uint"/> to match
|
||||
/// <see cref="MotionTableManager.PerformMovement"/>'s retail-verbatim return
|
||||
/// type.
|
||||
/// </summary>
|
||||
public static class MotionTableManagerError
|
||||
{
|
||||
/// <summary>0 — success.</summary>
|
||||
public const uint Success = 0u;
|
||||
/// <summary>7 — no motion table loaded.</summary>
|
||||
public const uint NoTable = 7u;
|
||||
/// <summary>0x43 — DoObjectMotion/StopObjectMotion returned failure.</summary>
|
||||
public const uint MotionFailed = 0x43u;
|
||||
/// <summary>
|
||||
/// C#-port-only sentinel for the "unhandled MovementType" default case.
|
||||
/// Retail's BN decompile shows this leaking the CSequence pointer
|
||||
/// reinterpreted as a return code (decomp §11 note, "likely dead/
|
||||
/// unreachable in practice"); no known caller depends on that value, so
|
||||
/// the port returns this distinguishable constant instead of fabricating
|
||||
/// a pointer-shaped number.
|
||||
/// </summary>
|
||||
public const uint NotHandled = 0xFFFFFFFFu;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal retail-verbatim <c>MovementStruct</c> subset (acclient.h:38069)
|
||||
/// needed by <see cref="MotionTableManager.PerformMovement"/>: just the
|
||||
/// dispatch type, the motion id, and the speed scalar
|
||||
/// (<c>arg2->params->speed</c>). Defined here rather than reusing
|
||||
/// <c>AcDream.Core.Physics.MotionInterpreter.MovementStruct</c> because that
|
||||
/// type serves a different (CMotionInterp-level) call site with fields
|
||||
/// (<c>ObjectId</c>, <c>Position</c>, <c>Autonomous</c>,
|
||||
/// <c>ModifyInterpretedState/RawState</c>) MotionTableManager never reads —
|
||||
/// CLAUDE.md/plan instruction: do not modify MotionInterpreter.cs.
|
||||
/// <see cref="AcDream.Core.Physics.MovementType"/> IS reused (its 5 values
|
||||
/// match retail's <c>MovementTypes::Type</c> 1:1 for the cases
|
||||
/// MotionTableManager handles).
|
||||
/// </summary>
|
||||
public readonly struct MotionTableMovement
|
||||
{
|
||||
public readonly MovementType Type;
|
||||
public readonly uint Motion;
|
||||
public readonly float Speed;
|
||||
|
||||
public MotionTableMovement(MovementType type, uint motion, float speed)
|
||||
{
|
||||
Type = type;
|
||||
Motion = motion;
|
||||
Speed = speed;
|
||||
}
|
||||
|
||||
public static MotionTableMovement Interpreted(uint motion, float speed) =>
|
||||
new(MovementType.InterpretedCommand, motion, speed);
|
||||
|
||||
public static MotionTableMovement StopInterpreted(uint motion, float speed) =>
|
||||
new(MovementType.StopInterpretedCommand, motion, speed);
|
||||
|
||||
public static MotionTableMovement StopCompletely() =>
|
||||
new(MovementType.StopCompletely, 0u, 1f);
|
||||
}
|
||||
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
65
src/AcDream.Core/Physics/Motion/MotionTablePose.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle
|
||||
/// entity's parts hold (retail: <c>CMotionTable::SetDefaultState</c>
|
||||
/// 0x005230a0 installs <c>StyleDefaults[DefaultStyle]</c>'s cycle; the parts
|
||||
/// then sit at that animation's frames — the live <c>CPhysicsPart</c> pose
|
||||
/// collision tests against). Used as the BSP shadow-shape part-pose override
|
||||
/// at server-entity registration (doors: the CLOSED pose).
|
||||
///
|
||||
/// <para>
|
||||
/// Cycle key arithmetic mirrors <see cref="CMotionTable"/>'s
|
||||
/// <c>LookupCycle</c> (CMotionTable.cs:683): <c>(style << 16) |
|
||||
/// (substate & 0xFFFFFF)</c> — the raw dat <c>Cycles</c> dictionary is
|
||||
/// keyed by the COMBINED word, not the bare style (the first cut of this
|
||||
/// helper looked up the bare style, always missed, and silently fell back
|
||||
/// to placement frames — the #175 "not fixed" report).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MotionTablePose
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the default-state pose frames. Returns null (callers fall
|
||||
/// back to placement frames) when the table has no default-style
|
||||
/// substate, no matching cycle, or no animation. A pose covering FEWER
|
||||
/// parts than the Setup is returned as-is —
|
||||
/// <see cref="ShadowShapeBuilder"/> falls back to the placement frame
|
||||
/// PER PART beyond the override's length (e.g. a door anim that poses
|
||||
/// only the panel parts, not the BSP-less frame header).
|
||||
/// </summary>
|
||||
/// <param name="mt">The raw dat motion table (wire MotionTableId).</param>
|
||||
/// <param name="loadAnimation">Animation loader (production:
|
||||
/// <c>id => dats.Get<Animation>(id)</c>).</param>
|
||||
public static IReadOnlyList<Frame>? DefaultStatePartFrames(
|
||||
MotionTable mt,
|
||||
Func<uint, Animation?> loadAnimation)
|
||||
{
|
||||
if (mt is null) return null;
|
||||
|
||||
// SetDefaultState: StyleDefaults[DefaultStyle] → the default substate.
|
||||
if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd))
|
||||
return null;
|
||||
|
||||
// LookupCycle key (CMotionTable.cs:683 — same wrap semantics).
|
||||
uint style = (uint)mt.DefaultStyle;
|
||||
uint substate = (uint)defaultSubstateCmd;
|
||||
int key = (int)((style << 16) | (substate & 0xFFFFFFu));
|
||||
|
||||
if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0)
|
||||
return null;
|
||||
|
||||
var animRef = cycle.Anims[0];
|
||||
var anim = loadAnimation(animRef.AnimId);
|
||||
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||||
|
||||
int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1);
|
||||
var frames = anim.PartFrames[idx].Frames;
|
||||
return frames.Count > 0 ? frames : null;
|
||||
}
|
||||
}
|
||||
1634
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
1634
src/AcDream.Core/Physics/Motion/MoveToManager.cs
Normal file
File diff suppressed because it is too large
Load diff
306
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal file
306
src/AcDream.Core/Physics/Motion/MoveToMath.cs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager
|
||||
/// port: <c>heading_diff</c>, <c>heading_greater</c>, <c>Position::heading</c>
|
||||
/// / <c>Frame::get_heading</c> / <c>Frame::set_heading</c>, and
|
||||
/// <c>Position::cylinder_distance</c>. No GL/App dependency — Core-only,
|
||||
/// per the Code Structure Rules. NAME WATCH: this file (not
|
||||
/// <c>MoveToManager.cs</c>, per r4-port-plan.md §3 "New code target") is the
|
||||
/// R4-V1 deliverable; the manager itself is R4-V2.
|
||||
/// </summary>
|
||||
public static class MoveToMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Universal heading/distance epsilon (same literal as R3's A5/A6 —
|
||||
/// r4-moveto-decomp.md §12 constants inventory).
|
||||
/// </summary>
|
||||
public const float Epsilon = 0.000199999995f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>heading_diff</c> (<c>0x00528fb0</c>, free function, raw
|
||||
/// 306327-306347), PINNED by direct disassembly of the PDB-matched
|
||||
/// retail binary (ghidra-confirmations.md §P3 — the strongest evidence
|
||||
/// tier in the R4 pin set):
|
||||
/// <code>
|
||||
/// d = h1 - h2;
|
||||
/// if (fabs(h1 - h2) < F_EPSILON) d = 0;
|
||||
/// if (d < -F_EPSILON) d += 360;
|
||||
/// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror
|
||||
/// return d;
|
||||
/// </code>
|
||||
/// The mirror gates on the turn command NOT being TurnRight
|
||||
/// (0x6500000d) — TurnLeft (and any other command) measures the
|
||||
/// COMPLEMENTARY angle. This CONTRADICTS r4-moveto-decomp.md §5g's
|
||||
/// "arg3 UNUSED" claim, which the Ghidra disassembly pin overrides
|
||||
/// (V0-pins.md §P3 adjudication). Call sites: <c>BeginTurnToHeading</c>
|
||||
/// passes the CONSTANT TurnRight (mirror explicitly disabled — the
|
||||
/// direction pick stays the ≤180 test elsewhere); <c>HandleTurnToHeading</c>
|
||||
/// passes the LIVE <c>current_command</c> (can be TurnLeft).
|
||||
/// </summary>
|
||||
/// <param name="h1">First heading, degrees.</param>
|
||||
/// <param name="h2">Second heading, degrees.</param>
|
||||
/// <param name="turnCmd">The active turn command id — gates the mirror.</param>
|
||||
/// <returns>Normalized heading difference, degrees.</returns>
|
||||
public static float HeadingDiff(float h1, float h2, uint turnCmd)
|
||||
{
|
||||
float d = h1 - h2;
|
||||
if (MathF.Abs(h1 - h2) < Epsilon)
|
||||
{
|
||||
d = 0f;
|
||||
}
|
||||
if (d < -Epsilon)
|
||||
{
|
||||
d += 360f;
|
||||
}
|
||||
if (Epsilon < d && turnCmd != MotionCommand.TurnRight)
|
||||
{
|
||||
d = 360f - d;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>heading_greater</c> (<c>00528f60</c>, free function, raw
|
||||
/// 306281-306323), verbatim per r4-moveto-decomp.md §5f:
|
||||
/// <code>
|
||||
/// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped
|
||||
/// else greater = (a > b);
|
||||
/// if (turnCmd == TurnRight) return greater;
|
||||
/// return !greater; // TurnLeft (and any other cmd): inverted
|
||||
/// </code>
|
||||
/// "Has the turn passed the target heading" — direction-aware,
|
||||
/// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is
|
||||
/// <c>== TurnRight</c> (not <c>!= TurnRight</c> as in
|
||||
/// <see cref="HeadingDiff"/>'s mirror) — every OTHER command inverts,
|
||||
/// not just TurnLeft specifically.
|
||||
/// </summary>
|
||||
public static bool HeadingGreater(float a, float b, uint turnCmd)
|
||||
{
|
||||
bool greater = MathF.Abs(a - b) > 180f
|
||||
? b > a
|
||||
: a > b;
|
||||
|
||||
return turnCmd == MotionCommand.TurnRight ? greater : !greater;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::heading(from, to)</c> (<c>0x005a9520</c>, raw
|
||||
/// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 =
|
||||
/// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360).
|
||||
/// <code>
|
||||
/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360
|
||||
/// </code>
|
||||
/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270.
|
||||
/// Horizontal (X/Y) only — Z (height) does not participate, matching
|
||||
/// retail's compass-heading semantics. An in-tree twin of this formula
|
||||
/// already exists at <c>SceneryHelpers.cs:75</c> (render-side,
|
||||
/// independently verified — not reused directly to keep this file
|
||||
/// GL-free per the Code Structure Rules, but the formula is identical).
|
||||
/// </summary>
|
||||
public static float PositionHeading(Vector3 from, Vector3 to)
|
||||
{
|
||||
float dx = to.X - from.X;
|
||||
float dy = to.Y - from.Y;
|
||||
float headingDeg = 450f - MathF.Atan2(dy, dx) * (180f / MathF.PI);
|
||||
headingDeg %= 360f;
|
||||
if (headingDeg < 0f) headingDeg += 360f;
|
||||
return headingDeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Frame::get_heading</c> (<c>0x00535760</c>, raw 319781) —
|
||||
/// extracts the compass heading (P5 convention) from a body orientation
|
||||
/// quaternion. <b>The packer-reuse trap (V0-pins §P5 correction):</b>
|
||||
/// acdream's outbound packer (<c>GameWindow.YawToAcQuaternion</c>) is
|
||||
/// wire-correct at the QUATERNION level but its internal scalar
|
||||
/// intermediate (<c>headingDeg = 180 - yawDeg</c>) is holtburger's
|
||||
/// SHIFTED convention, not retail's. This method uses the CORRECT
|
||||
/// scalar bridge derived from acdream's own body convention
|
||||
/// (<c>PlayerMovementController.cs:1022-1025</c>: <c>Orientation =
|
||||
/// AxisAngle(Z, Yaw - PI/2)</c>, local-forward = +Y, Yaw=0 faces +X):
|
||||
/// world-forward = <c>(cos Yaw, sin Yaw)</c>, so
|
||||
/// <c>YawDeg = atan2Deg(forward.Y, forward.X)</c> and
|
||||
/// <c>heading = (90 - YawDeg) mod 360</c> — the exact inverse of
|
||||
/// <see cref="SetHeading"/>. Identity quaternion (Yaw=PI/2, i.e. facing
|
||||
/// +Y/North) → heading 0, matching P5's "identity quaternion faces
|
||||
/// heading 0" pin.
|
||||
/// </summary>
|
||||
public static float GetHeading(Quaternion orientation)
|
||||
{
|
||||
var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), orientation);
|
||||
float yawDeg = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI);
|
||||
float headingDeg = 90f - yawDeg;
|
||||
headingDeg %= 360f;
|
||||
if (headingDeg < 0f) headingDeg += 360f;
|
||||
return headingDeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Frame::set_heading</c> (<c>0x00535e40</c>, raw
|
||||
/// 320055-320066) — builds a body orientation quaternion facing
|
||||
/// <paramref name="headingDeg"/> (P5 compass convention), preserving
|
||||
/// acdream's body-orientation convention (rotation about world Z only;
|
||||
/// <paramref name="baseOrientation"/>'s pitch/roll, if any, is
|
||||
/// discarded — matching retail's <c>set_heading</c>, which is a pure
|
||||
/// yaw-about-Z setter). Exact inverse of <see cref="GetHeading"/>:
|
||||
/// <c>YawDeg = 90 - headingDeg</c>, then <c>Orientation =
|
||||
/// AxisAngle(Z, Yaw - PI/2)</c> per
|
||||
/// <c>PlayerMovementController.cs:1025</c>'s convention.
|
||||
/// </summary>
|
||||
/// <param name="baseOrientation">Unused beyond signature parity with
|
||||
/// the render-side <c>SceneryHelpers.SetHeading</c> twin — retail's
|
||||
/// <c>set_heading</c> is a pure yaw-about-Z setter with no dependency
|
||||
/// on the prior orientation's roll/pitch component in the body-frame
|
||||
/// convention this port uses.</param>
|
||||
/// <param name="headingDeg">Desired compass heading, degrees.</param>
|
||||
public static Quaternion SetHeading(Quaternion baseOrientation, float headingDeg)
|
||||
{
|
||||
_ = baseOrientation;
|
||||
float yawDeg = 90f - headingDeg;
|
||||
float yaw = yawDeg * (MathF.PI / 180f);
|
||||
return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: the scalar leg of <see cref="GetHeading"/> for bodies whose
|
||||
/// authoritative facing is a yaw ANGLE rather than a quaternion (the
|
||||
/// local player: <c>PlayerMovementController.Yaw</c>, radians, Yaw=0
|
||||
/// faces +X, re-synced into the body quaternion every Update). Same P5
|
||||
/// bridge: <c>heading = (90 - yawDeg) mod 360</c>.
|
||||
/// </summary>
|
||||
public static float HeadingFromYaw(float yawRad)
|
||||
{
|
||||
float headingDeg = 90f - yawRad * (180f / MathF.PI);
|
||||
headingDeg %= 360f;
|
||||
if (headingDeg < 0f) headingDeg += 360f;
|
||||
return headingDeg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5: exact inverse of <see cref="HeadingFromYaw"/> — the
|
||||
/// <c>set_heading</c> seam for yaw-authoritative bodies (the local
|
||||
/// player's heading snap must write <c>Yaw</c>, NOT the body
|
||||
/// quaternion, which the controller re-derives from Yaw every frame).
|
||||
/// Returns radians wrapped to [-π, π] matching the controller's own
|
||||
/// wrap discipline.
|
||||
/// </summary>
|
||||
public static float YawFromHeading(float headingDeg)
|
||||
{
|
||||
float yaw = (90f - headingDeg) * (MathF.PI / 180f);
|
||||
while (yaw > MathF.PI) yaw -= 2f * MathF.PI;
|
||||
while (yaw < -MathF.PI) yaw += 2f * MathF.PI;
|
||||
return yaw;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::cylinder_distance</c>, the pure-math shape
|
||||
/// consumed by <c>MoveToManager::GetCurrentDistance</c>
|
||||
/// (<c>005291b0</c>, r4-moveto-decomp.md §5a) when <c>use_spheres</c>
|
||||
/// (wire bit 0x400) is set — object moves use edge-to-edge cylinder
|
||||
/// distance; position moves use plain center (Euclidean) distance
|
||||
/// instead (not ported here — <c>Vector3.Distance</c> already covers
|
||||
/// it). BN garbles the x87 plumbing in the raw, so the exact
|
||||
/// radius-combination arithmetic is not directly visible; ported per
|
||||
/// the PDB argument ORDER (own radius/height/position, target
|
||||
/// radius/height/position) with the standard cylinder-distance shape:
|
||||
/// planar (X/Y) center distance minus the sum of both radii, clamped
|
||||
/// at zero (overlapping cylinders report 0, never negative). Height is
|
||||
/// accepted for signature parity with the retail call (own/target
|
||||
/// height feed the caller's contact-plane logic elsewhere) but does
|
||||
/// NOT participate in this edge-to-edge planar computation — matching
|
||||
/// retail's use of cylinder_distance purely for the horizontal arrival
|
||||
/// gate.
|
||||
/// </summary>
|
||||
public static float CylinderDistance(
|
||||
float ownRadius, float ownHeight, Vector3 ownPos,
|
||||
float targetRadius, float targetHeight, Vector3 targetPos)
|
||||
{
|
||||
_ = ownHeight;
|
||||
_ = targetHeight;
|
||||
|
||||
float dx = targetPos.X - ownPos.X;
|
||||
float dy = targetPos.Y - ownPos.Y;
|
||||
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
float edgeDist = centerDist - ownRadius - targetRadius;
|
||||
return edgeDist > 0f ? edgeDist : 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::cylinder_distance_no_z</c> — the <b>signed</b>
|
||||
/// horizontal (X/Y) edge-to-edge distance between two cylinders:
|
||||
/// <c>centerDist − ownRadius − targetRadius</c>. Unlike
|
||||
/// <see cref="CylinderDistance"/> (the arrival-gate variant, which CLAMPS at
|
||||
/// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE
|
||||
/// value. <c>StickyManager::adjust_offset</c> (0x00555430) relies on the
|
||||
/// sign: when the follower is inside the desired 0.3 m stick gap the
|
||||
/// distance goes negative, the per-tick delta inverts, and the mover backs
|
||||
/// off to restore the gap (ACE StickyManager.cs:156).
|
||||
/// </summary>
|
||||
public static float CylinderDistanceNoZ(
|
||||
float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos)
|
||||
{
|
||||
float dx = targetPos.X - ownPos.X;
|
||||
float dy = targetPos.Y - ownPos.Y;
|
||||
float centerDist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
return centerDist - ownRadius - targetRadius;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>AC1Legacy::Vector3::normalize_check_small</c> — normalize
|
||||
/// <paramref name="v"/> in place, returning <c>true</c> if the vector was
|
||||
/// too small to normalize (near-zero) and leaving it unchanged in that
|
||||
/// case. Consumed by <c>StickyManager::adjust_offset</c> (don't chase
|
||||
/// jitter when already at the target) and
|
||||
/// <see cref="TargetManager.ReceiveUpdate"/> (interpolated-heading
|
||||
/// fallback). A public shared twin of the private helper in
|
||||
/// <c>ParticleSystem</c>; same 1e-8 near-zero length guard.
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> = too small (left unchanged); <c>false</c> =
|
||||
/// normalized.</returns>
|
||||
public static bool NormalizeCheckSmall(ref Vector3 v)
|
||||
{
|
||||
float length = v.Length();
|
||||
if (length < 1e-8f)
|
||||
return true;
|
||||
v /= length;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::globaltolocalvec</c> — rotate a WORLD-space vector
|
||||
/// into a frame's LOCAL coordinates by the inverse of the frame's
|
||||
/// orientation. Consumed by <c>StickyManager::adjust_offset</c>
|
||||
/// (0x00555430) to express the self→target offset in the mover's own frame
|
||||
/// before flattening Z and steering. Pure rotation (no translation) —
|
||||
/// <paramref name="worldVec"/> is a direction/offset, not a point.
|
||||
/// </summary>
|
||||
public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec)
|
||||
=> Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation));
|
||||
|
||||
/// <summary>
|
||||
/// Landblock-local wire origin → world space (verbatim relocation from
|
||||
/// the deleted <c>RemoteMoveToDriver.OriginToWorld</c>, R4-V4): MoveTo /
|
||||
/// TurnTo packets carry positions block-local; acdream's streaming world
|
||||
/// re-centers on a live-center landblock grid.
|
||||
/// </summary>
|
||||
public static Vector3 OriginToWorld(
|
||||
uint originCellId,
|
||||
float originX,
|
||||
float originY,
|
||||
float originZ,
|
||||
int liveCenterLandblockX,
|
||||
int liveCenterLandblockY)
|
||||
{
|
||||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
originX + (lbX - liveCenterLandblockX) * 192f,
|
||||
originY + (lbY - liveCenterLandblockY) * 192f,
|
||||
originZ);
|
||||
}
|
||||
}
|
||||
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal file
47
src/AcDream.Core/Physics/Motion/MoveToNode.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R4-V2 — verbatim port of retail's <c>MoveToManager::MovementNode</c>
|
||||
/// (<c>acclient.h:57702</c>, struct #6350):
|
||||
/// <code>
|
||||
/// struct __cppobj MoveToManager::MovementNode : DLListData
|
||||
/// { // +0 dllist_next, +4 dllist_prev (DLListData)
|
||||
/// MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued
|
||||
/// float heading; // +0xc — only meaningful for type 9
|
||||
/// };
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>NAME WATCH (r4-port-plan.md §3 "New code target"):</b> named
|
||||
/// <c>MoveToNode</c>, NOT <c>MovementNode</c>, to avoid colliding with R2's
|
||||
/// <see cref="MotionNode"/> (the UNRELATED <c>CMotionInterp::pending_motions</c>
|
||||
/// node — a different queue on a different class; see the AD-34 register
|
||||
/// wording on both node types' shared "managed collection standing in for
|
||||
/// retail's intrusive DLList/LList" pattern).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail allocates these with <c>operator new(0x10)</c> and links them onto
|
||||
/// <c>MoveToManager::pending_actions</c> (a <c>DLList</c> — doubly-linked,
|
||||
/// unlike <c>CMotionInterp</c>'s singly-linked <c>LList</c>) via
|
||||
/// <c>DLListBase::InsertAfter</c> (tail-append; r4-moveto-decomp.md §4a).
|
||||
/// Ported as a managed <see cref="System.Collections.Generic.LinkedList{T}"/>
|
||||
/// of this value type — same pattern as <see cref="MotionNode"/>'s port
|
||||
/// (AD-34 wording): node identity semantics preserved via
|
||||
/// <c>LinkedListNode<MoveToNode></c> references rather than raw
|
||||
/// prev/next pointers, FIFO order preserved via tail-append +
|
||||
/// <c>RemoveFirst</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="Type">Retail <c>type</c> (+8) — only
|
||||
/// <see cref="MovementType.MoveToPosition"/> (7) and
|
||||
/// <see cref="MovementType.TurnToHeading"/> (9) are ever queued
|
||||
/// (r4-moveto-decomp.md §4a: <c>AddMoveToPositionNode</c> /
|
||||
/// <c>AddTurnToHeadingNode</c> are the only two producers;
|
||||
/// <see cref="MoveToManager.BeginNextNode"/>'s dispatch is a defensive
|
||||
/// <c>if/if</c>, not a full switch — an unknown type stalls rather than
|
||||
/// throwing, matching the raw's shape).</param>
|
||||
/// <param name="Heading">Retail <c>heading</c> (+0xc) — only meaningful for
|
||||
/// <see cref="MovementType.TurnToHeading"/> nodes; zero/unused for
|
||||
/// <see cref="MovementType.MoveToPosition"/> nodes.</param>
|
||||
public readonly record struct MoveToNode(MovementType Type, float Heading);
|
||||
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
171
src/AcDream.Core/Physics/Motion/MovementManager.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5-V5 — retail <c>MovementManager</c> (acclient.h <c>/* 3463 */</c>, 0x10
|
||||
/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's
|
||||
/// two managers. Decomp extract:
|
||||
/// <c>docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md</c>.
|
||||
///
|
||||
/// <code>
|
||||
/// struct MovementManager {
|
||||
/// CMotionInterp *motion_interpreter; // +0x0 → Minterp
|
||||
/// MoveToManager *moveto_manager; // +0x4 → MoveTo (lazy)
|
||||
/// CPhysicsObj *physics_obj; // +0x8 ┐ carried by the children +
|
||||
/// CWeenieObject *weenie_obj; // +0xc ┘ the MoveToFactory closure
|
||||
/// };
|
||||
/// </code>
|
||||
///
|
||||
/// <para><b>Construction mapping.</b> Retail lazily creates BOTH children
|
||||
/// (<c>CMotionInterp::Create</c> + <c>enter_default_state</c> at every entry
|
||||
/// point; <c>MoveToManager::Create</c> via <see cref="MakeMoveToManager"/>).
|
||||
/// acdream constructs the interp eagerly at entity construction
|
||||
/// (<c>RemoteMotion</c> / <c>PlayerMovementController</c> ctors — the lazy
|
||||
/// window is never observable; register TS-38 already covers the
|
||||
/// <c>Initted</c> side of this) and hands it to this ctor. The moveto side
|
||||
/// keeps retail's lazy <see cref="MakeMoveToManager"/> mechanism: retail
|
||||
/// constructs from the <c>physics_obj</c>/<c>weenie_obj</c> backpointers;
|
||||
/// acdream's <see cref="MoveToFactory"/> closure is the stand-in for those
|
||||
/// two fields, carrying the full seam wiring (set once at the bind site —
|
||||
/// <c>EnsureRemoteMotionBindings</c> / <c>EnterPlayerModeNow</c> / the chase
|
||||
/// harness — which then calls <see cref="MakeMoveToManager"/> immediately,
|
||||
/// preserving the pre-facade eager timing; the ctor is side-effect-free so
|
||||
/// the timing is unobservable either way).</para>
|
||||
///
|
||||
/// <para><b>Deliberately NOT absorbed here</b> (the R5-V5 slice keeps these
|
||||
/// at their current owners): <c>unpack_movement</c> 0x00524440 ≡ the
|
||||
/// GameWindow UM path + <c>RouteServerMoveTo</c> (Core.Net wire types stay
|
||||
/// out of Core.Physics); <c>move_to_interpreted_state</c> 0x00524170 ≡ the
|
||||
/// funnel's <c>MotionInterpreter.MoveToInterpretedState</c> call sites;
|
||||
/// <c>MotionDone</c> 0x005242d0 ≡ the sequencer's <c>MotionDoneTarget</c>
|
||||
/// seam (register AD-36); <c>LeaveGround</c> 0x00524320's moveto half is a
|
||||
/// COMDAT no-op (see <c>PlayerMovementController</c>'s landing comment) so
|
||||
/// call sites keep invoking <c>Minterp.LeaveGround()</c> directly;
|
||||
/// <c>EnterDefaultState</c>/<c>HandleEnterWorld</c>/<c>ReportExhaustion</c>/
|
||||
/// <c>SetWeenieObject</c>/<c>Destroy</c> have no acdream caller yet —
|
||||
/// <c>get_minterp</c> 0x005242a0 ≡ the <see cref="Minterp"/> property.</para>
|
||||
///
|
||||
/// <para><b>PerformMovement's <c>set_active(1)</c> head</b>
|
||||
/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active
|
||||
/// transient bit at spawn (<c>RemoteMotion</c> ctor) and the pre-facade
|
||||
/// route never re-asserted it — status quo preserved (zero-behavior-change
|
||||
/// slice), not a new deviation.</para>
|
||||
/// </summary>
|
||||
public sealed class MovementManager
|
||||
{
|
||||
/// <summary>Retail <c>motion_interpreter</c> (+0x0). Always present in
|
||||
/// acdream (eager construction — see the class doc); direct child access
|
||||
/// for interp-specific ops mirrors retail's <c>get_minterp</c>
|
||||
/// (0x005242a0) callers.</summary>
|
||||
public MotionInterpreter Minterp { get; }
|
||||
|
||||
/// <summary>Retail <c>moveto_manager</c> (+0x4) — null until
|
||||
/// <see cref="MakeMoveToManager"/> (or a type-6..9
|
||||
/// <see cref="PerformMovement"/>) creates it.</summary>
|
||||
public MoveToManager? MoveTo { get; private set; }
|
||||
|
||||
/// <summary>The acdream stand-in for retail's <c>physics_obj</c>/
|
||||
/// <c>weenie_obj</c> backpointers (+0x8/+0xc): the creation recipe
|
||||
/// <see cref="MakeMoveToManager"/> invokes. Set exactly once at the bind
|
||||
/// site; the closure carries the MoveToManager's seam wiring (and the
|
||||
/// StickTo/Unstick/MoveToComplete binds) that retail reads off
|
||||
/// <c>CPhysicsObj</c>.</summary>
|
||||
public Func<MoveToManager>? MoveToFactory { get; set; }
|
||||
|
||||
public MovementManager(MotionInterpreter minterp)
|
||||
{
|
||||
Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementManager::MakeMoveToManager</c> (0x00524000):
|
||||
/// lazy-construct <see cref="MoveTo"/> if null; no-op if already present.
|
||||
/// No-op (instead of retail's unconditional create) when no factory has
|
||||
/// been bound yet — acdream's seams arrive from the bind site, and every
|
||||
/// consumer path runs after binding.
|
||||
/// </summary>
|
||||
public void MakeMoveToManager()
|
||||
{
|
||||
if (MoveTo is null && MoveToFactory is not null)
|
||||
MoveTo = MoveToFactory();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementManager::PerformMovement</c> (0x005240d0): the
|
||||
/// type-1..9 two-way dispatch. <c>(type - 1) > 8</c> (type 0
|
||||
/// underflows unsigned) → 0x47; types 1-5 → <c>CMotionInterp::
|
||||
/// PerformMovement</c> (return propagated); types 6-9 →
|
||||
/// <see cref="MakeMoveToManager"/> + <c>MoveToManager::PerformMovement</c>
|
||||
/// whose return is NOT propagated (@0052414f <c>return 0</c>) — the
|
||||
/// facade reports <see cref="WeenieError.None"/> for that path.
|
||||
/// </summary>
|
||||
public WeenieError PerformMovement(MovementStruct mvs)
|
||||
{
|
||||
switch (mvs.Type)
|
||||
{
|
||||
case MovementType.RawCommand:
|
||||
case MovementType.InterpretedCommand:
|
||||
case MovementType.StopRawCommand:
|
||||
case MovementType.StopInterpretedCommand:
|
||||
case MovementType.StopCompletely:
|
||||
return Minterp.PerformMovement(mvs);
|
||||
|
||||
case MovementType.MoveToObject:
|
||||
case MovementType.MoveToPosition:
|
||||
case MovementType.TurnToObject:
|
||||
case MovementType.TurnToHeading:
|
||||
MakeMoveToManager();
|
||||
if (MoveTo is null)
|
||||
// acdream-only guard: a type-6..9 event before the bind
|
||||
// site set MoveToFactory (unreachable in production —
|
||||
// EnsureRemoteMotionBindings / EnterPlayerModeNow bind
|
||||
// before any route can fire). Retail would Create here.
|
||||
return WeenieError.GeneralMovementFailure;
|
||||
MoveTo.PerformMovement(mvs);
|
||||
return WeenieError.None;
|
||||
|
||||
default:
|
||||
return WeenieError.GeneralMovementFailure; // 0x47
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>MovementManager::UseTime</c> (0x005242f0): relay
|
||||
/// to <see cref="MoveTo"/> ONLY (does not touch the interp, does not
|
||||
/// lazy-create); no-op while null.</summary>
|
||||
public void UseTime() => MoveTo?.UseTime();
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HitGround</c> (0x00524300): fan
|
||||
/// to BOTH children if present — <c>CMotionInterp::HitGround</c> FIRST
|
||||
/// (the falling-pose exit re-apply), then <c>MoveToManager::HitGround</c>
|
||||
/// (re-arms a moveto suspended by the airborne UseTime contact
|
||||
/// gate).</summary>
|
||||
public void HitGround()
|
||||
{
|
||||
Minterp.HitGround();
|
||||
MoveTo?.HitGround();
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HandleExitWorld</c> (0x00524350):
|
||||
/// interp ONLY (drains <c>pending_motions</c>); does not touch
|
||||
/// <see cref="MoveTo"/>.</summary>
|
||||
public void HandleExitWorld() => Minterp.HandleExitWorld();
|
||||
|
||||
/// <summary>Retail <c>MovementManager::CancelMoveTo</c> (0x005241b0):
|
||||
/// relay to <see cref="MoveTo"/>; no-op while null. The retail
|
||||
/// <c>interrupt_current_movement → MovementManager::CancelMoveTo(0x36)</c>
|
||||
/// chain lands here.</summary>
|
||||
public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error);
|
||||
|
||||
/// <summary>Retail <c>MovementManager::HandleUpdateTarget</c>
|
||||
/// (0x00524790): relay to <see cref="MoveTo"/>; no-op while null. The
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> fan (0x00512bc0) calls this
|
||||
/// leg first, then the PositionManager's (host order —
|
||||
/// <c>EntityPhysicsHost.HandleUpdateTarget</c>).</summary>
|
||||
public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info);
|
||||
|
||||
/// <summary>Retail <c>MovementManager::IsMovingTo</c> (0x00524260):
|
||||
/// true iff <see cref="MoveTo"/> exists AND reports an armed
|
||||
/// move.</summary>
|
||||
public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false;
|
||||
}
|
||||
472
src/AcDream.Core/Physics/Motion/MovementParameters.cs
Normal file
472
src/AcDream.Core/Physics/Motion/MovementParameters.cs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R3-W1 — verbatim port of retail's <c>MovementParameters</c>
|
||||
/// (<c>acclient.h:31453</c>, struct #3460; bitfield struct
|
||||
/// <c>acclient.h:31423-31443</c>):
|
||||
/// <code>
|
||||
/// struct __cppobj MovementParameters : PackObj
|
||||
/// {
|
||||
/// union { unsigned int bitfield; ... } ___u1;
|
||||
/// float distance_to_object;
|
||||
/// float min_distance;
|
||||
/// float desired_heading;
|
||||
/// float speed;
|
||||
/// float fail_distance;
|
||||
/// float walk_run_threshhold;
|
||||
/// unsigned int context_id;
|
||||
/// HoldKey hold_key_to_apply;
|
||||
/// unsigned int action_stamp;
|
||||
/// };
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// The bitfield's absolute mask table is pinned in
|
||||
/// <c>docs/research/2026-07-02-r3-motioninterp/W0-pins.md</c> §A4 (bit-for-bit
|
||||
/// identical to ACE's <c>MovementParamFlags</c>):
|
||||
/// </para>
|
||||
/// <list type="table">
|
||||
/// <item><term>0x1</term><description>CanWalk</description></item>
|
||||
/// <item><term>0x2</term><description>CanRun</description></item>
|
||||
/// <item><term>0x4</term><description>CanSidestep</description></item>
|
||||
/// <item><term>0x8</term><description>CanWalkBackwards</description></item>
|
||||
/// <item><term>0x10</term><description>CanCharge</description></item>
|
||||
/// <item><term>0x20</term><description>FailWalk</description></item>
|
||||
/// <item><term>0x40</term><description>UseFinalHeading</description></item>
|
||||
/// <item><term>0x80</term><description>Sticky</description></item>
|
||||
/// <item><term>0x100</term><description>MoveAway</description></item>
|
||||
/// <item><term>0x200</term><description>MoveTowards</description></item>
|
||||
/// <item><term>0x400</term><description>UseSpheres</description></item>
|
||||
/// <item><term>0x800</term><description>SetHoldKey</description></item>
|
||||
/// <item><term>0x1000</term><description>Autonomous</description></item>
|
||||
/// <item><term>0x2000</term><description>ModifyRawState</description></item>
|
||||
/// <item><term>0x4000</term><description>ModifyInterpretedState</description></item>
|
||||
/// <item><term>0x8000</term><description>CancelMoveTo</description></item>
|
||||
/// <item><term>0x10000</term><description>StopCompletely</description></item>
|
||||
/// <item><term>0x20000</term><description>DisableJumpDuringLink</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Ctor default (raw 300510-300534, <c>0x00524380</c>):
|
||||
/// <c>(bitfield & 0xfffdee0f) | 0x1ee0f</c> → <c>0x1EE0F</c> sets
|
||||
/// {CanWalk, CanRun, CanSidestep, CanWalkBackwards, MoveTowards, UseSpheres,
|
||||
/// SetHoldKey, ModifyRawState, ModifyInterpretedState, CancelMoveTo,
|
||||
/// StopCompletely}; clears {CanCharge, FailWalk, UseFinalHeading, Sticky,
|
||||
/// MoveAway, Autonomous, DisableJumpDuringLink}.
|
||||
/// Scalars: <c>min_distance=0</c>, <c>distance_to_object=0.6</c>,
|
||||
/// <c>fail_distance=FLT_MAX</c>, <c>desired_heading=0</c>, <c>speed=1</c>,
|
||||
/// <c>walk_run_threshhold=15</c> (NOT ACE's 1.0 — W0-pins A4 divergence trap),
|
||||
/// <c>context_id=0</c>, <c>hold_key_to_apply=HoldKey.Invalid</c>,
|
||||
/// <c>action_stamp=0</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>ACE-divergence traps (W0-pins A4, do not copy):</b> ACE's
|
||||
/// <c>MovementParameters</c> ctor sets <c>CanCharge = true</c>
|
||||
/// (MovementParameters.cs:58) — retail's default has bit <c>0x10</c> CLEAR;
|
||||
/// this port defaults <c>CanCharge = false</c>. ACE also changed
|
||||
/// <c>Default_WalkRunThreshold</c> to 1.0 (L50) vs retail's literal 15.0
|
||||
/// (@300519) — this port defaults <c>WalkRunThreshhold = 15f</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Named bool properties per plan (no <c>ToBitfield()</c>/<c>FromBitfield()</c>
|
||||
/// pair — the wire never carries this struct raw; <c>RawMotionState::Pack</c>
|
||||
/// serializes the STATE, not this transient parameter block. If a future
|
||||
/// slice needs the packed form, add the pair then with a cited call site).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class MovementParameters
|
||||
{
|
||||
// ── bitfield flags (retail 0x1EE0F default; W0-pins A4) ───────────────
|
||||
|
||||
/// <summary>Mask 0x1 — default true.</summary>
|
||||
public bool CanWalk { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x2 — default true.</summary>
|
||||
public bool CanRun { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x4 — default true.</summary>
|
||||
public bool CanSidestep { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x8 — default true.</summary>
|
||||
public bool CanWalkBackwards { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Mask 0x10 — default FALSE. ACE-divergence trap (W0-pins A4): ACE's
|
||||
/// ctor sets this true (MovementParameters.cs:58); retail's 0x1EE0F
|
||||
/// default has bit 0x10 CLEAR. Do not "fix" this to true.
|
||||
/// </summary>
|
||||
public bool CanCharge { get; set; }
|
||||
|
||||
/// <summary>Mask 0x20 — default false.</summary>
|
||||
public bool FailWalk { get; set; }
|
||||
|
||||
/// <summary>Mask 0x40 — default false.</summary>
|
||||
public bool UseFinalHeading { get; set; }
|
||||
|
||||
/// <summary>Mask 0x80 — default false.</summary>
|
||||
public bool Sticky { get; set; }
|
||||
|
||||
/// <summary>Mask 0x100 — default false.</summary>
|
||||
public bool MoveAway { get; set; }
|
||||
|
||||
/// <summary>Mask 0x200 — default true.</summary>
|
||||
public bool MoveTowards { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x400 — default true.</summary>
|
||||
public bool UseSpheres { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x800 — default true. DoMotion @306188: byte1&8
|
||||
/// requests a <c>SetHoldKey</c> call before <c>adjust_motion</c>.</summary>
|
||||
public bool SetHoldKey { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Mask 0x1000 — default FALSE. Not the same virtual as
|
||||
/// <c>IWeenieObject.IsThePlayer</c> (W0-pins A3) — this is the
|
||||
/// per-call "was this an autonomous (locally-predicted) action?" flag.
|
||||
/// </summary>
|
||||
public bool Autonomous { get; set; }
|
||||
|
||||
/// <summary>Mask 0x2000 — default true. DoMotion @306213: byte1&0x20
|
||||
/// mirrors the applied motion into <c>RawMotionState</c> via
|
||||
/// <c>ApplyMotion</c>/<c>RemoveMotion</c>.</summary>
|
||||
public bool ModifyRawState { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x4000 — default true. Mirrors into
|
||||
/// <c>InterpretedMotionState</c>.</summary>
|
||||
public bool ModifyInterpretedState { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x8000 — default true. Bitfield high-byte sign bit;
|
||||
/// DoMotion/StopMotion @306183/@305684: triggers
|
||||
/// <c>interrupt_current_movement</c> before the rest of the call.</summary>
|
||||
public bool CancelMoveTo { get; set; } = true;
|
||||
|
||||
/// <summary>Mask 0x10000 — default true.</summary>
|
||||
public bool StopCompletelyFlag { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Mask 0x20000 — default FALSE. DoInterpretedMotion @305597: when set,
|
||||
/// forces the computed <c>jump_error_code</c> to <c>0x48</c> (A1: jump
|
||||
/// BLOCKED by motion/position) regardless of what
|
||||
/// <c>motion_allows_jump</c> would have said.
|
||||
/// </summary>
|
||||
public bool DisableJumpDuringLink { get; set; }
|
||||
|
||||
// ── scalar fields (retail ctor 0x00524380 defaults) ───────────────────
|
||||
|
||||
/// <summary>Retail default 0.6.</summary>
|
||||
public float DistanceToObject { get; set; } = 0.6f;
|
||||
|
||||
/// <summary>Retail default 0.</summary>
|
||||
public float MinDistance { get; set; }
|
||||
|
||||
/// <summary>Retail default 0.</summary>
|
||||
public float DesiredHeading { get; set; }
|
||||
|
||||
/// <summary>Retail default 1.</summary>
|
||||
public float Speed { get; set; } = 1f;
|
||||
|
||||
/// <summary>Retail default FLT_MAX.</summary>
|
||||
public float FailDistance { get; set; } = float.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Retail default 15.0 (@300519). ACE-divergence trap (W0-pins A4): ACE
|
||||
/// changed <c>Default_WalkRunThreshold</c> to 1.0 — do not copy.
|
||||
/// </summary>
|
||||
public float WalkRunThreshhold { get; set; } = 15f;
|
||||
|
||||
/// <summary>Retail default 0.</summary>
|
||||
public uint ContextId { get; set; }
|
||||
|
||||
/// <summary>Retail default <see cref="Physics.HoldKey.Invalid"/>.</summary>
|
||||
public HoldKey HoldKeyToApply { get; set; } = HoldKey.Invalid;
|
||||
|
||||
/// <summary>Retail default 0.</summary>
|
||||
public uint ActionStamp { get; set; }
|
||||
|
||||
// ── R4-V1: command-selection family (closes M2-mechanics) ─────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementParameters::get_command</c> (<c>0x0052aa00</c>,
|
||||
/// raw 307946-308012), VERBATIM per
|
||||
/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Picks the
|
||||
/// motion command + moving-away flag from the towards/away bitfield
|
||||
/// combination, THEN the walk-vs-run <see cref="HoldKey"/> cascade.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Command pick</b> (mirrors <c>towards_and_away</c>'s bands but is
|
||||
/// NOT identical — see the asymmetry note on <see cref="TowardsAndAway"/>):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>MoveTowards && MoveAway</c> → delegate
|
||||
/// to <see cref="TowardsAndAway"/> (the three-band form).</description></item>
|
||||
/// <item><description><c>MoveTowards</c> only (or neither flag set —
|
||||
/// retail's <c>else if</c> falls through to the SAME branch): plain
|
||||
/// towards — <c>dist > DistanceToObject</c> → WalkForward,
|
||||
/// !movingAway; else idle (cmd 0).</description></item>
|
||||
/// <item><description><c>MoveAway</c> only: pure away —
|
||||
/// <c>dist < MinDistance</c> → WalkForward, movingAway=true (the
|
||||
/// heading flips +180 via <see cref="GetDesiredHeading"/> — turn-around,
|
||||
/// NOT WalkBackwards, unlike <see cref="TowardsAndAway"/>'s min-band);
|
||||
/// else idle.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>THE walk-vs-run rule</b> (confirms
|
||||
/// <c>feedback_autowalk_cancharge_bit</c> — port RETAIL's version of
|
||||
/// BOTH the fast-path ACE dropped and the threshold-close-walk pair):
|
||||
/// <c>HoldKey.Run</c> ⇐ <c>CanCharge</c> set (the fast-path — wins
|
||||
/// regardless of CanRun/CanWalk/distance), OR (<c>CanRun</c> set AND
|
||||
/// (<c>CanWalk</c> clear OR <c>dist - DistanceToObject >
|
||||
/// WalkRunThreshhold</c>)). <c>HoldKey.None</c> (walk) ⇐ no CanRun, or
|
||||
/// walk-capable within the threshold (INCLUSIVE ≤ — the raw's
|
||||
/// <c>test ah,0x41</c> after the fcom is the not-greater-than reading,
|
||||
/// §5c @308003).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="dist">Current distance-to-target (retail's
|
||||
/// <c>GetCurrentDistance</c> result — center or cylinder distance per
|
||||
/// <see cref="MoveToMath.CylinderDistance"/>).</param>
|
||||
/// <param name="headingDiff">Heading-to-target minus current heading,
|
||||
/// normalized [0,360) — UNUSED by <c>get_command</c> itself (the raw
|
||||
/// signature carries it for parity with the caller's local; retail's
|
||||
/// body never reads <c>arg3</c> in this build). Kept as a parameter for
|
||||
/// call-site symmetry with <c>BeginMoveForward</c> (§4c), which computes
|
||||
/// it immediately before calling <c>get_command</c>.</param>
|
||||
/// <param name="motion">Chosen motion command id, or 0 if no movement
|
||||
/// is needed (already in range).</param>
|
||||
/// <param name="holdKey">Chosen hold key (walk vs run).</param>
|
||||
/// <param name="movingAway">True if the chosen motion moves the mover
|
||||
/// AWAY from the target (feeds <see cref="GetDesiredHeading"/> and the
|
||||
/// arrival predicate's polarity).</param>
|
||||
public void GetCommand(float dist, float headingDiff, out uint motion, out HoldKey holdKey, out bool movingAway)
|
||||
{
|
||||
_ = headingDiff; // retail's arg3 — unread in this build's body (§5c)
|
||||
|
||||
// ── command + moving_away pick ──────────────────────────────────
|
||||
if (MoveTowards && MoveAway)
|
||||
{
|
||||
TowardsAndAway(dist, out motion, out movingAway);
|
||||
}
|
||||
else if (MoveAway && !MoveTowards)
|
||||
{
|
||||
// pure AWAY: dist < min_distance → WalkForward, moving away
|
||||
// (turn-around; heading flips +180 via GetDesiredHeading).
|
||||
if (dist < MinDistance)
|
||||
{
|
||||
motion = MotionCommand.WalkForward;
|
||||
movingAway = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
motion = 0u;
|
||||
movingAway = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// plain TOWARDS (MoveTowards set, or neither flag set — retail's
|
||||
// `else if ((flags & 0x100) == 0)` falls to the same label).
|
||||
if (dist > DistanceToObject)
|
||||
{
|
||||
motion = MotionCommand.WalkForward;
|
||||
movingAway = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
motion = 0u;
|
||||
movingAway = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── walk-vs-run HoldKey cascade ─────────────────────────────────
|
||||
if (CanCharge)
|
||||
{
|
||||
// THE fast-path ACE dropped: can_charge short-circuits straight
|
||||
// to Run regardless of CanRun/CanWalk/distance.
|
||||
holdKey = HoldKey.Run;
|
||||
return;
|
||||
}
|
||||
if (!CanRun)
|
||||
{
|
||||
holdKey = HoldKey.None;
|
||||
return;
|
||||
}
|
||||
if (CanWalk && (dist - DistanceToObject) <= WalkRunThreshhold)
|
||||
{
|
||||
holdKey = HoldKey.None;
|
||||
return;
|
||||
}
|
||||
holdKey = HoldKey.Run;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementParameters::towards_and_away</c>
|
||||
/// (<c>0x0052a9a0</c>, raw 307917-307942), VERBATIM per
|
||||
/// r4-moveto-decomp.md §5d. Three bands:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>dist > DistanceToObject</c> → WalkForward,
|
||||
/// towards (not moving away).</description></item>
|
||||
/// <item><description><c>dist - MinDistance < F_EPSILON</c> (inside
|
||||
/// the min-distance band) → WalkBackward, moving away. NOTE the
|
||||
/// asymmetry vs <see cref="GetCommand"/>'s pure-away branch: this backs
|
||||
/// up with WalkBackwards (no turn-around), not WalkForward+heading-flip
|
||||
/// (r4-moveto-decomp.md :656).</description></item>
|
||||
/// <item><description>otherwise (strictly inside [MinDistance,
|
||||
/// DistanceToObject]) → idle (cmd 0).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="dist">Current distance-to-target.</param>
|
||||
/// <param name="cmd">Chosen motion command, or 0 if already in the dead
|
||||
/// band.</param>
|
||||
/// <param name="movingAway">True only for the WalkBackward (min-band)
|
||||
/// case.</param>
|
||||
public void TowardsAndAway(float dist, out uint cmd, out bool movingAway)
|
||||
{
|
||||
const float epsilon = 0.000199999995f;
|
||||
|
||||
if (dist > DistanceToObject)
|
||||
{
|
||||
cmd = MotionCommand.WalkForward;
|
||||
movingAway = false;
|
||||
return;
|
||||
}
|
||||
if (dist - MinDistance < epsilon)
|
||||
{
|
||||
cmd = MotionCommand.WalkBackward;
|
||||
movingAway = true;
|
||||
return;
|
||||
}
|
||||
cmd = 0u;
|
||||
movingAway = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>MovementParameters::get_desired_heading</c>
|
||||
/// (<c>0x0052aad0</c>), PINNED by direct Ghidra decompile of
|
||||
/// <c>patchmem.gpr</c> (fetched live during V0 — see
|
||||
/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2,
|
||||
/// ACE-shaped constants CONFIRMED exact, superseding the earlier
|
||||
/// BN-garble-based "high confidence" pin):
|
||||
/// <code>
|
||||
/// __thiscall get_desired_heading(command, movingAway)
|
||||
/// {
|
||||
/// if (command == RunForward || command == WalkForward) {
|
||||
/// if (!movingAway) return 0.0f;
|
||||
/// } else {
|
||||
/// if (command != WalkBackward) return 0.0f;
|
||||
/// if (movingAway) return 0.0f;
|
||||
/// }
|
||||
/// return 180.0f;
|
||||
/// }
|
||||
/// </code>
|
||||
/// Truth table: forward/run + towards → 0°; forward/run + away → 180°;
|
||||
/// backward + towards → 180°; backward + away → 0°; any other command
|
||||
/// → 0°. This is the heading OFFSET added to the raw heading-to-target
|
||||
/// so an "away" walk faces away and an "away" backstep faces the
|
||||
/// target.
|
||||
/// </summary>
|
||||
public float GetDesiredHeading(uint command, bool movingAway)
|
||||
{
|
||||
if (command == MotionCommand.RunForward || command == MotionCommand.WalkForward)
|
||||
{
|
||||
if (!movingAway) return 0f;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (command != MotionCommand.WalkBackward) return 0f;
|
||||
if (movingAway) return 0f;
|
||||
}
|
||||
return 180f;
|
||||
}
|
||||
|
||||
// ── R4-V1: wire factory (closes M15/wire-exposure groundwork) ─────────
|
||||
|
||||
/// <summary>
|
||||
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 7-dword
|
||||
/// MoveTo wire form (<c>0x0052ac50</c>, 0x1c bytes, raw 308118-308205 —
|
||||
/// r4-moveto-decomp.md §2g): <c>bitfield, distance_to_object,
|
||||
/// min_distance, fail_distance, speed, walk_run_threshhold,
|
||||
/// desired_heading</c>. Used by MoveToObject (type 6) and
|
||||
/// MoveToPosition (type 7) wire payloads — the SAME field order
|
||||
/// <c>UpdateMotion.TryParseMoveToPayload</c> already reads
|
||||
/// (UpdateMotion.cs:328-341). The bitfield fully OVERWRITES every named
|
||||
/// flag (UnPackNet does not merge with ctor defaults — every bit not
|
||||
/// present in <paramref name="bitfield"/> resolves to false); the wire
|
||||
/// bitfield carries <c>can_charge</c> (0x10), the walk-vs-run answer
|
||||
/// consumed by <see cref="GetCommand"/> (cross-ref
|
||||
/// <c>feedback_autowalk_cancharge_bit</c>).
|
||||
/// </summary>
|
||||
public static MovementParameters FromWire(
|
||||
uint bitfield,
|
||||
float distanceToObject,
|
||||
float minDistance,
|
||||
float failDistance,
|
||||
float speed,
|
||||
float walkRunThreshhold,
|
||||
float desiredHeading)
|
||||
{
|
||||
var p = new MovementParameters();
|
||||
ApplyBitfield(p, bitfield);
|
||||
p.DistanceToObject = distanceToObject;
|
||||
p.MinDistance = minDistance;
|
||||
p.FailDistance = failDistance;
|
||||
p.Speed = speed;
|
||||
p.WalkRunThreshhold = walkRunThreshhold;
|
||||
p.DesiredHeading = desiredHeading;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory for the retail <c>MovementParameters::UnPackNet</c> 3-dword
|
||||
/// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): <c>bitfield,
|
||||
/// speed, desired_heading</c>. Used by TurnToObject (type 8) and
|
||||
/// TurnToHeading (type 9) wire payloads. Distance-related scalars
|
||||
/// (<c>DistanceToObject</c>/<c>MinDistance</c>/<c>FailDistance</c>/
|
||||
/// <c>WalkRunThreshhold</c>) are NOT on this wire form and keep the
|
||||
/// <see cref="MovementParameters"/> ctor defaults — retail's UnPackNet
|
||||
/// for this form only ever writes the three fields named here.
|
||||
/// </summary>
|
||||
public static MovementParameters FromWireTurnTo(
|
||||
uint bitfield,
|
||||
float speed,
|
||||
float desiredHeading)
|
||||
{
|
||||
var p = new MovementParameters();
|
||||
ApplyBitfield(p, bitfield);
|
||||
p.Speed = speed;
|
||||
p.DesiredHeading = desiredHeading;
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the A4-pinned bitfield masks onto the named bool properties.
|
||||
/// Shared by <see cref="FromWire"/>/<see cref="FromWireTurnTo"/> — the
|
||||
/// wire bitfield always fully overwrites (retail's UnPackNet assigns
|
||||
/// the raw dword straight into <c>this->bitfield</c>, no merge).
|
||||
/// </summary>
|
||||
private static void ApplyBitfield(MovementParameters p, uint bitfield)
|
||||
{
|
||||
p.CanWalk = (bitfield & 0x1u) != 0;
|
||||
p.CanRun = (bitfield & 0x2u) != 0;
|
||||
p.CanSidestep = (bitfield & 0x4u) != 0;
|
||||
p.CanWalkBackwards = (bitfield & 0x8u) != 0;
|
||||
p.CanCharge = (bitfield & 0x10u) != 0;
|
||||
p.FailWalk = (bitfield & 0x20u) != 0;
|
||||
p.UseFinalHeading = (bitfield & 0x40u) != 0;
|
||||
p.Sticky = (bitfield & 0x80u) != 0;
|
||||
p.MoveAway = (bitfield & 0x100u) != 0;
|
||||
p.MoveTowards = (bitfield & 0x200u) != 0;
|
||||
p.UseSpheres = (bitfield & 0x400u) != 0;
|
||||
p.SetHoldKey = (bitfield & 0x800u) != 0;
|
||||
p.Autonomous = (bitfield & 0x1000u) != 0;
|
||||
p.ModifyRawState = (bitfield & 0x2000u) != 0;
|
||||
p.ModifyInterpretedState = (bitfield & 0x4000u) != 0;
|
||||
p.CancelMoveTo = (bitfield & 0x8000u) != 0;
|
||||
p.StopCompletelyFlag = (bitfield & 0x10000u) != 0;
|
||||
p.DisableJumpDuringLink = (bitfield & 0x20000u) != 0;
|
||||
}
|
||||
}
|
||||
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
102
src/AcDream.Core/Physics/Motion/PositionManager.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>PositionManager</c> facade (acclient.h:30952,
|
||||
/// struct #3468; decomp 0x00555160-0x005553d0,
|
||||
/// <c>r5-positionmanager-sticky-decomp.md</c>). A thin fan-out over three
|
||||
/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's
|
||||
/// <see cref="IPhysicsObjHost"/> (retail <c>CPhysicsObj::position_manager</c>,
|
||||
/// lazily created).
|
||||
///
|
||||
/// <para><b>Interpolation note:</b> retail's <c>adjust_offset</c> chains
|
||||
/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in
|
||||
/// <see cref="RemoteMotionCombiner"/> (the R5-renamed remote-motion combiner,
|
||||
/// formerly the misnamed <c>Physics.PositionManager</c>) and is NOT chained
|
||||
/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39;
|
||||
/// Constraint is structural — see <see cref="ConstraintManager"/>). Folding the
|
||||
/// combiner in as the interp stage is a wiring-slice cleanup.</para>
|
||||
/// </summary>
|
||||
public sealed class PositionManager
|
||||
{
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
// Lazily created (retail: sticky on first StickTo, constraint on first
|
||||
// ConstrainTo — 0x00555230 / 0x00555280).
|
||||
private StickyManager? _sticky;
|
||||
private ConstraintManager? _constraint;
|
||||
|
||||
public PositionManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>Exposed for wiring/tests — the lazily-created sub-managers
|
||||
/// (null until first use).</summary>
|
||||
public StickyManager? Sticky => _sticky;
|
||||
public ConstraintManager? Constraint => _constraint;
|
||||
|
||||
/// <summary>Retail <c>PositionManager::StickTo</c> (0x00555230) — lazily
|
||||
/// create the <see cref="StickyManager"/> and begin following
|
||||
/// <paramref name="objectId"/>.</summary>
|
||||
public void StickTo(uint objectId, float radius, float height)
|
||||
{
|
||||
_sticky ??= new StickyManager(_host);
|
||||
_sticky.StickTo(objectId, radius, height);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>PositionManager::UnStick</c> (0x005551e0) — forward
|
||||
/// to the sticky sub-manager if it exists.</summary>
|
||||
public void UnStick() => _sticky?.UnStick();
|
||||
|
||||
/// <summary>Retail <c>PositionManager::GetStickyObjectID</c>
|
||||
/// (0x00555270).</summary>
|
||||
public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u;
|
||||
|
||||
/// <summary>Retail <c>PositionManager::ConstrainTo</c> (0x00555280) —
|
||||
/// lazily create the <see cref="ConstraintManager"/> and arm the leash.
|
||||
/// (Unused in acdream — no arming call site; see
|
||||
/// <see cref="ConstraintManager"/>.)</summary>
|
||||
public void ConstrainTo(Position anchor, float startDistance, float maxDistance)
|
||||
{
|
||||
_constraint ??= new ConstraintManager(_host);
|
||||
_constraint.ConstrainTo(anchor, startDistance, maxDistance);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>PositionManager::UnConstrain</c>
|
||||
/// (0x005552b0).</summary>
|
||||
public void UnConstrain() => _constraint?.UnConstrain();
|
||||
|
||||
/// <summary>Retail <c>PositionManager::IsFullyConstrained</c>
|
||||
/// (0x005552c0) — false when no constraint sub-manager exists.</summary>
|
||||
public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::HandleUpdateTarget</c> (0x005553d0) — only
|
||||
/// the sticky sub-manager cares about live target positions (interpolation
|
||||
/// and constraint don't). Fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c>.
|
||||
/// </summary>
|
||||
public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info);
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::adjust_offset</c> (0x00555190) — chains the
|
||||
/// sub-managers' contributions into the SAME <paramref name="offset"/>
|
||||
/// accumulator, in retail order. Retail runs Interpolation → Sticky →
|
||||
/// Constraint; acdream's interpolation stays in the separate remote-motion
|
||||
/// combiner (see class note), so this chains Sticky → Constraint only.
|
||||
/// Constraint is LAST because it clamps the already-composed displacement.
|
||||
/// </summary>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
_sticky?.AdjustOffset(offset, quantum);
|
||||
_constraint?.AdjustOffset(offset, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>PositionManager::UseTime</c> (0x00555160) — per-tick pump.
|
||||
/// Retail order Interpolation → Constraint → Sticky; acdream runs the two
|
||||
/// owned managers (constraint's UseTime is a retail no-op, so effectively
|
||||
/// just the sticky 1 s watchdog).
|
||||
/// </summary>
|
||||
public void UseTime() => _sticky?.UseTime();
|
||||
}
|
||||
276
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
276
src/AcDream.Core/Physics/Motion/StickyManager.cs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — verbatim port of retail's <c>StickyManager</c> (acclient.h:31518,
|
||||
/// struct #3466; decomp block 0x00555400-0x00555866,
|
||||
/// <c>r5-positionmanager-sticky-decomp.md</c>). Makes the owning object
|
||||
/// FOLLOW a target object at a bounded gap: each tick
|
||||
/// <see cref="AdjustOffset"/> steers the mover horizontally toward the
|
||||
/// target's live (or last-known) position and turns it to face the target,
|
||||
/// speed- and turn-rate-limited. A 1-second watchdog (<see cref="UseTime"/>)
|
||||
/// drops the stick if no target-position update arrives.
|
||||
///
|
||||
/// <para>Owned by <see cref="PositionManager"/> (lazily created on first
|
||||
/// <see cref="PositionManager.StickTo"/>). Establishes its target-tracking
|
||||
/// subscription through the owning <see cref="IPhysicsObjHost"/>'s
|
||||
/// <c>set_target</c> (→ <see cref="TargetManager"/>); receives the target's
|
||||
/// live position back through <see cref="HandleUpdateTarget"/>, fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>.</para>
|
||||
///
|
||||
/// <para>The dense x87 back half of retail's <c>adjust_offset</c> is decoded
|
||||
/// against ACE's <c>StickyManager.cs</c> (the two constants
|
||||
/// <see cref="StickyRadius"/>=0.3 and <see cref="StickyTime"/>=1.0 are ACE's,
|
||||
/// verified against the retail mush structure — see the port-plan §2a).</para>
|
||||
/// </summary>
|
||||
public sealed class StickyManager
|
||||
{
|
||||
/// <summary>Retail <c>StickyRadius</c> const (ACE: 0.3f) — the desired
|
||||
/// follow gap subtracted from the cylinder distance.</summary>
|
||||
public const float StickyRadius = 0.3f;
|
||||
|
||||
/// <summary>Retail <c>StickyTime</c> const (ACE: 1.0f) — the one-shot grace
|
||||
/// window: if no target update refreshes the stick within this many
|
||||
/// seconds of <see cref="StickTo"/>, <see cref="UseTime"/> drops it.</summary>
|
||||
public const float StickyTime = 1.0f;
|
||||
|
||||
/// <summary>Retail <c>get_max_speed</c> multiplier for the follow speed
|
||||
/// (ACE: ×5 — the follower catches up faster than a normal walk/run).</summary>
|
||||
private const float FollowSpeedFactor = 5.0f;
|
||||
|
||||
/// <summary>Retail fallback follow speed when no motion interpreter exists
|
||||
/// (ACE: 15.0f, i.e. the <c>MAX_VELOCITY</c> constant the mush loads).</summary>
|
||||
private const float FallbackFollowSpeed = 15.0f;
|
||||
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
/// <summary>+0x00 retail <c>target_id</c> — the object we are stuck to
|
||||
/// (0 = not stuck).</summary>
|
||||
public uint TargetId { get; private set; }
|
||||
|
||||
/// <summary>+0x04 retail <c>target_radius</c> — the target's cylinder
|
||||
/// radius (from <c>CPartArray::GetRadius</c> of the stuck-to object).</summary>
|
||||
public float TargetRadius { get; private set; }
|
||||
|
||||
/// <summary>+0x08 retail <c>target_position</c> — last-known target
|
||||
/// position (from <see cref="HandleUpdateTarget"/>), used when the live
|
||||
/// <c>GetObjectA</c> resolve fails.</summary>
|
||||
public Position TargetPosition { get; private set; }
|
||||
|
||||
/// <summary>+0x54 retail <c>initialized</c> — false until the first
|
||||
/// <c>Ok</c> target update arrives (gates <see cref="AdjustOffset"/> and
|
||||
/// the <see cref="UseTime"/> timeout).</summary>
|
||||
public bool Initialized { get; private set; }
|
||||
|
||||
/// <summary>+0x58 retail <c>sticky_timeout_time</c> — the wall-clock
|
||||
/// deadline set once at <see cref="StickTo"/> time (now + 1 s).</summary>
|
||||
public double StickyTimeoutTime { get; private set; }
|
||||
|
||||
public StickyManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::UnStick</c> (0x00555400). No-op if not stuck;
|
||||
/// otherwise the standard 4-step teardown: clear <see cref="TargetId"/> +
|
||||
/// <see cref="Initialized"/>, tell the host to <c>clear_target</c> (drop
|
||||
/// the voyeur subscription), then <c>interrupt_current_movement</c>.
|
||||
/// </summary>
|
||||
public void UnStick()
|
||||
{
|
||||
if (TargetId == 0)
|
||||
return;
|
||||
|
||||
if (PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}"));
|
||||
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::StickTo</c> (0x00555710). Begin following
|
||||
/// <paramref name="objectId"/>. If already stuck, tears the old stick down
|
||||
/// first (same 4-step sequence as <see cref="UnStick"/>). Sets the 1 s
|
||||
/// timeout deadline and registers as a voyeur of the target via the host's
|
||||
/// <c>set_target(context=0, objectId, radius=0.5, quantum=0.5)</c> — the
|
||||
/// live target position then arrives asynchronously through
|
||||
/// <see cref="HandleUpdateTarget"/>.
|
||||
/// </summary>
|
||||
/// <param name="objectId">Retail <c>arg2</c> — target object id.</param>
|
||||
/// <param name="targetRadius">Retail <c>arg3</c> — the target's cylinder
|
||||
/// radius (feeds <see cref="AdjustOffset"/>'s distance math).</param>
|
||||
/// <param name="targetHeight">Retail <c>arg4</c> — accepted for call-shape
|
||||
/// parity but UNUSED in the body (matches retail + ACE; the height feeds
|
||||
/// the caller-side geometry only).</param>
|
||||
public void StickTo(uint objectId, float targetRadius, float targetHeight)
|
||||
{
|
||||
_ = targetHeight; // retail/ACE: arg4 is read nowhere in this body.
|
||||
|
||||
if (TargetId != 0)
|
||||
{
|
||||
// Inlined 4-step teardown of the previous stick (retail 0x00555716).
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
|
||||
TargetRadius = targetRadius;
|
||||
TargetId = objectId;
|
||||
Initialized = false;
|
||||
StickyTimeoutTime = _host.CurTime + StickyTime;
|
||||
|
||||
if (PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s"));
|
||||
|
||||
// set_target(context_id=0, objectId, radius=0.5, quantum=0.5).
|
||||
_host.SetTarget(0, objectId, 0.5f, 0.5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::UseTime</c> (0x00555610). The 1 s watchdog: if
|
||||
/// <c>Timer::cur_time >= sticky_timeout_time</c>, force-unstick (same
|
||||
/// 4-step teardown). The deadline is set once in <see cref="StickTo"/> and
|
||||
/// NOT refreshed by <see cref="HandleUpdateTarget"/> (retail + ACE) — a
|
||||
/// stick survives at most 1 s of wall-clock unless re-issued.
|
||||
/// </summary>
|
||||
public void UseTime()
|
||||
{
|
||||
if (TargetId == 0)
|
||||
return;
|
||||
|
||||
// Strictly AFTER the deadline (retail 0x00555626 `test ah,0x41` —
|
||||
// C0|C3 clear = cur_time > timeout; ACE `>` too), not >=.
|
||||
if (_host.CurTime > StickyTimeoutTime)
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}"));
|
||||
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::HandleUpdateTarget</c> (0x00555780). The
|
||||
/// target-position callback (fanned out from
|
||||
/// <c>CPhysicsObj::HandleUpdateTarget</c> → <see cref="PositionManager"/>).
|
||||
/// Ignores updates whose <see cref="TargetInfo.ObjectId"/> doesn't match
|
||||
/// our <see cref="TargetId"/>. On <see cref="TargetStatus.Ok"/>: cache the
|
||||
/// target position and mark <see cref="Initialized"/>. On any other status
|
||||
/// (lost/exit/teleport): tear the stick down (4-step).
|
||||
/// </summary>
|
||||
public void HandleUpdateTarget(TargetInfo info)
|
||||
{
|
||||
if (info.ObjectId != TargetId)
|
||||
return;
|
||||
|
||||
if (info.Status == TargetStatus.Ok)
|
||||
{
|
||||
Initialized = true;
|
||||
TargetPosition = info.TargetPosition;
|
||||
return;
|
||||
}
|
||||
|
||||
if (TargetId != 0)
|
||||
{
|
||||
if (PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}"));
|
||||
|
||||
TargetId = 0;
|
||||
Initialized = false;
|
||||
_host.ClearTarget();
|
||||
_host.InterruptCurrentMovement();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>StickyManager::adjust_offset</c> (0x00555430). Writes this
|
||||
/// tick's follow steering into the shared <paramref name="offset"/>
|
||||
/// accumulator: a speed-clamped horizontal position delta toward the
|
||||
/// target plus a bounded turn to face it. No-op unless stuck AND
|
||||
/// initialized. See port-plan §2a for the x87-mush decode.
|
||||
/// </summary>
|
||||
/// <param name="offset">The per-tick delta frame
|
||||
/// (<see cref="PositionManager.AdjustOffset"/>'s shared accumulator).</param>
|
||||
/// <param name="quantum">Elapsed time this tick, seconds.</param>
|
||||
public void AdjustOffset(MotionDeltaFrame offset, double quantum)
|
||||
{
|
||||
if (TargetId == 0 || !Initialized)
|
||||
return;
|
||||
|
||||
var self = _host.Position;
|
||||
var target = _host.GetObjectA(TargetId);
|
||||
var targetPos = target != null ? target.Position : TargetPosition;
|
||||
|
||||
// offset = local-frame, Z-flattened vector from self to target.
|
||||
Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset
|
||||
Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset);
|
||||
local.Z = 0f;
|
||||
offset.Origin = local;
|
||||
|
||||
// Signed horizontal cylinder distance past the 0.3 m stick gap.
|
||||
float dist = MoveToMath.CylinderDistanceNoZ(
|
||||
_host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius;
|
||||
|
||||
if (MoveToMath.NormalizeCheckSmall(ref offset.Origin))
|
||||
offset.Origin = Vector3.Zero;
|
||||
|
||||
// Follow speed = 5× own max locomotion speed (catch up), fallback 15.
|
||||
float speed = 0f;
|
||||
float? maxSpeed = _host.MinterpMaxSpeed;
|
||||
if (maxSpeed.HasValue)
|
||||
speed = maxSpeed.Value * FollowSpeedFactor;
|
||||
if (speed < MoveToMath.Epsilon)
|
||||
speed = FallbackFollowSpeed;
|
||||
|
||||
// Don't overshoot: clamp the per-tick step to the remaining (signed)
|
||||
// distance — a negative dist inverts the direction (back off).
|
||||
//
|
||||
// DEEP-OVERLAP SIGN PIN (#171 gate-3, register AP row): ACE's literal
|
||||
// line is only `if (delta >= |dist|) delta = dist;` — when the
|
||||
// overlap is DEEPER than one tick's step, delta keeps its positive
|
||||
// sign and steers TOWARD the target center, a runaway whose
|
||||
// equilibrium is centers-coincident (gate-3 probe: 1661 deep-overlap
|
||||
// ticks, all inward, monsters converged to centerDist≈0 — the
|
||||
// "monster inside the player" report; retail side-by-side shows
|
||||
// separation). ACE servers essentially never reach that branch
|
||||
// (quantum ≥1/30 × speed ≈31 → threshold ~1 m); at render-rate
|
||||
// quanta the threshold is ~0.13 m and pack jostle trips it
|
||||
// constantly. The BN mush (0x00555554-0x00555597) is unreadable on
|
||||
// exactly this compare; the sign-correct clamp below is the minimal
|
||||
// interpretation consistent with the mush structure AND observed
|
||||
// retail — identical to ACE everywhere except deep-overlap, where it
|
||||
// backs off rate-limited instead of creeping inward.
|
||||
float delta = speed * (float)quantum;
|
||||
if (delta >= MathF.Abs(dist))
|
||||
delta = dist;
|
||||
else if (dist < 0f)
|
||||
delta = -delta;
|
||||
offset.Origin *= delta;
|
||||
|
||||
// Bounded turn to face the target (relative heading this tick).
|
||||
float curHeading = MoveToMath.GetHeading(self.Frame.Orientation);
|
||||
float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin);
|
||||
float heading = targetHeading - curHeading;
|
||||
if (MathF.Abs(heading) < MoveToMath.Epsilon)
|
||||
heading = 0f;
|
||||
if (heading < -MoveToMath.Epsilon)
|
||||
heading += 360f;
|
||||
offset.SetHeading(heading);
|
||||
|
||||
if (PhysicsDiagnostics.ProbeStickyEnabled)
|
||||
Console.WriteLine(FormattableString.Invariant(
|
||||
$"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}"));
|
||||
}
|
||||
}
|
||||
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
300
src/AcDream.Core/Physics/Motion/TargetManager.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>TargetManager</c> (acclient.h:31024, struct #3484;
|
||||
/// decomp 0x0051a370-0x0051ad90, <c>r5-targetmanager-decomp.md</c>). A
|
||||
/// peer-to-peer <b>voyeur subscription</b> system with two roles per object:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Watcher</b> (<see cref="TargetInfo"/>): <see cref="SetTarget"/>
|
||||
/// registers this object as a voyeur ON a target; the target's per-tick
|
||||
/// <see cref="HandleTargetting"/> pushes position updates back here via
|
||||
/// <see cref="ReceiveUpdate"/>, which fans them to the owning host's
|
||||
/// MoveToManager + PositionManager (sticky) through
|
||||
/// <see cref="IPhysicsObjHost.HandleUpdateTarget"/>.</item>
|
||||
/// <item><b>Watched</b> (<see cref="VoyeurTable"/>): other objects'
|
||||
/// <see cref="AddVoyeur"/> subscribe to THIS object; each tick
|
||||
/// <see cref="HandleTargetting"/> → <see cref="CheckAndUpdateVoyeur"/> sends a
|
||||
/// dead-reckoned update to any subscriber the object has drifted past the
|
||||
/// subscriber's radius from.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow
|
||||
/// polling the entity table). It is a faithful superset: the same
|
||||
/// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the
|
||||
/// correct sticky, 10 s timeout, and exit/teleport event handling.</para>
|
||||
///
|
||||
/// <para>Owned 1:1 by an <see cref="IPhysicsObjHost"/> (retail
|
||||
/// <c>CPhysicsObj::target_manager</c>, lazily created on first
|
||||
/// <c>set_target</c>/<c>add_voyeur</c>). The two throttle constants
|
||||
/// (<see cref="ThrottleSeconds"/>=0.5, <see cref="StalenessSeconds"/>=10) are
|
||||
/// ACE's, verified against the retail x87 mush — port-plan §2d.</para>
|
||||
/// </summary>
|
||||
public sealed class TargetManager
|
||||
{
|
||||
/// <summary>Retail <c>HandleTargetting</c> per-tick throttle (ACE: 0.5s) —
|
||||
/// the voyeur sweep runs at most this often.</summary>
|
||||
public const double ThrottleSeconds = 0.5;
|
||||
|
||||
/// <summary>Retail target-info staleness timeout (ACE: 10.0s) — an
|
||||
/// Undefined-status target with no update for this long is marked
|
||||
/// TimedOut.</summary>
|
||||
public const double StalenessSeconds = 10.0;
|
||||
|
||||
private readonly IPhysicsObjHost _host;
|
||||
|
||||
private TargetInfo? _targetInfo; // retail target_info (watcher role)
|
||||
private Dictionary<uint, TargettedVoyeurInfo>? _voyeurTable; // retail voyeur_table (watched role)
|
||||
private double _lastUpdateTime; // retail last_update_time (throttle base)
|
||||
|
||||
public TargetManager(IPhysicsObjHost host)
|
||||
=> _host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
|
||||
/// <summary>The current watched-target info, or null when tracking
|
||||
/// nothing.</summary>
|
||||
public TargetInfo? TargetInfo => _targetInfo;
|
||||
|
||||
/// <summary>The subscriber table (null until the first
|
||||
/// <see cref="AddVoyeur"/>).</summary>
|
||||
public IReadOnlyDictionary<uint, TargettedVoyeurInfo>? VoyeurTable => _voyeurTable;
|
||||
|
||||
/// <summary>Retail <c>get_target_quantum</c> — the current target's
|
||||
/// quantum, 0 when tracking nothing.</summary>
|
||||
public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0;
|
||||
|
||||
// ── watcher role ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SetTarget</c> (0x0051ac30). Tear down any
|
||||
/// existing target, then: if <paramref name="objectId"/> is 0, synthesize a
|
||||
/// TimedOut clear-update to the host and leave <see cref="TargetInfo"/>
|
||||
/// null; otherwise create a fresh <see cref="TargetInfo"/> (status
|
||||
/// Undefined) and subscribe as a voyeur ON the target
|
||||
/// (<c>target.add_voyeur(self.id, radius, quantum)</c>). The target's live
|
||||
/// position arrives asynchronously via <see cref="ReceiveUpdate"/>.
|
||||
/// </summary>
|
||||
public void SetTarget(uint contextId, uint objectId, float radius, double quantum)
|
||||
{
|
||||
ClearTarget();
|
||||
|
||||
if (objectId == 0)
|
||||
{
|
||||
// Clear/cancel: report a TimedOut update carrying the context,
|
||||
// leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.)
|
||||
var cleared = new TargetInfo(
|
||||
ObjectId: 0, Status: TargetStatus.TimedOut,
|
||||
TargetPosition: default, InterpolatedPosition: default,
|
||||
ContextId: contextId);
|
||||
_host.HandleUpdateTarget(cleared);
|
||||
return;
|
||||
}
|
||||
|
||||
_targetInfo = new TargetInfo(
|
||||
ObjectId: objectId, Status: TargetStatus.Undefined,
|
||||
TargetPosition: default, InterpolatedPosition: default,
|
||||
ContextId: contextId, Radius: radius, Quantum: quantum,
|
||||
LastUpdateTime: _host.CurTime);
|
||||
|
||||
var target = _host.GetObjectA(objectId);
|
||||
target?.AddVoyeur(_host.Id, radius, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SetTargetQuantum</c> (0x0051a4a0). Update the
|
||||
/// current target's resend interval and re-register the voyeur subscription
|
||||
/// on the target with the new quantum.
|
||||
/// </summary>
|
||||
public void SetTargetQuantum(double quantum)
|
||||
{
|
||||
if (_targetInfo is not { } ti)
|
||||
return;
|
||||
|
||||
_targetInfo = ti with { Quantum = quantum };
|
||||
var target = _host.GetObjectA(ti.ObjectId);
|
||||
target?.AddVoyeur(_host.Id, ti.Radius, quantum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::ClearTarget</c> (0x0051a7e0). Unsubscribe from
|
||||
/// the current target's voyeur table and drop <see cref="TargetInfo"/>.
|
||||
/// </summary>
|
||||
public void ClearTarget()
|
||||
{
|
||||
if (_targetInfo is not { } ti)
|
||||
return;
|
||||
|
||||
var target = _host.GetObjectA(ti.ObjectId);
|
||||
target?.RemoveVoyeur(_host.Id);
|
||||
_targetInfo = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::ReceiveUpdate</c> (0x0051a930). The inbound
|
||||
/// handler when a target we watch sends us its position (via
|
||||
/// <c>SendVoyeurUpdate</c> → <c>receive_target_update</c>). Ignores updates
|
||||
/// for anything but our current target. Copies the payload, stamps receipt
|
||||
/// time, recomputes the self→target interpolated heading (falls back to +Z
|
||||
/// when degenerate), fans the snapshot to the host, and drops the
|
||||
/// subscription on an ExitWorld status.
|
||||
/// </summary>
|
||||
public void ReceiveUpdate(TargetInfo update)
|
||||
{
|
||||
if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId)
|
||||
return;
|
||||
|
||||
// Copy radius/quantum/positions/velocity/status from the wire; keep our
|
||||
// object_id; stamp receipt time.
|
||||
Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin
|
||||
- _host.Position.Frame.Origin;
|
||||
if (MoveToMath.NormalizeCheckSmall(ref interpHeading))
|
||||
interpHeading = Vector3.UnitZ;
|
||||
|
||||
var updated = ti with
|
||||
{
|
||||
Radius = update.Radius,
|
||||
Quantum = update.Quantum,
|
||||
TargetPosition = update.TargetPosition,
|
||||
InterpolatedPosition = update.InterpolatedPosition,
|
||||
Velocity = update.Velocity,
|
||||
Status = update.Status,
|
||||
InterpolatedHeading = interpHeading,
|
||||
LastUpdateTime = _host.CurTime,
|
||||
};
|
||||
_targetInfo = updated;
|
||||
|
||||
_host.HandleUpdateTarget(updated);
|
||||
|
||||
if (update.Status == TargetStatus.ExitWorld)
|
||||
ClearTarget();
|
||||
}
|
||||
|
||||
// ── watched role ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::AddVoyeur</c> (0x0051a830). A subscriber
|
||||
/// registers to watch this object. If already subscribed, updates its
|
||||
/// radius/quantum in place (no immediate send); otherwise creates the entry
|
||||
/// and pushes an immediate initial snapshot (<c>Ok</c>).
|
||||
/// </summary>
|
||||
public void AddVoyeur(uint watcherId, float radius, double quantum)
|
||||
{
|
||||
_voyeurTable ??= new Dictionary<uint, TargettedVoyeurInfo>();
|
||||
|
||||
if (_voyeurTable.TryGetValue(watcherId, out var existing))
|
||||
{
|
||||
existing.Radius = radius;
|
||||
existing.Quantum = quantum;
|
||||
return;
|
||||
}
|
||||
|
||||
var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum);
|
||||
_voyeurTable[watcherId] = voyeur;
|
||||
SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>TargetManager::RemoveVoyeur</c> (0x0051ad90).</summary>
|
||||
public bool RemoveVoyeur(uint watcherId)
|
||||
=> _voyeurTable?.Remove(watcherId) ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::HandleTargetting</c> (0x0051aa90). THE per-tick
|
||||
/// driver (no separate <c>UseTime</c>): self-throttled to
|
||||
/// <see cref="ThrottleSeconds"/>, promotes a stale target to TimedOut after
|
||||
/// <see cref="StalenessSeconds"/>, then sweeps every subscriber through
|
||||
/// <see cref="CheckAndUpdateVoyeur"/>.
|
||||
/// </summary>
|
||||
public void HandleTargetting()
|
||||
{
|
||||
if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds)
|
||||
return;
|
||||
|
||||
if (_targetInfo is { } ti)
|
||||
{
|
||||
if (ti.Status == TargetStatus.Undefined
|
||||
&& ti.LastUpdateTime + StalenessSeconds < _host.CurTime)
|
||||
{
|
||||
var timedOut = ti with { Status = TargetStatus.TimedOut };
|
||||
_targetInfo = timedOut;
|
||||
_host.HandleUpdateTarget(timedOut);
|
||||
}
|
||||
}
|
||||
|
||||
if (_voyeurTable != null)
|
||||
{
|
||||
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||
CheckAndUpdateVoyeur(voyeur);
|
||||
}
|
||||
|
||||
_lastUpdateTime = _host.PhysicsTimerTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::CheckAndUpdateVoyeur</c> (0x0051a650). Push an
|
||||
/// update to <paramref name="voyeur"/> only if this object's dead-reckoned
|
||||
/// position has drifted more than the voyeur's radius since the last send.
|
||||
/// </summary>
|
||||
public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)
|
||||
{
|
||||
Position newPos = GetInterpolatedPosition(voyeur.Quantum);
|
||||
float drift = Vector3.Distance(
|
||||
newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin);
|
||||
if (drift > voyeur.Radius)
|
||||
SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::GetInterpolatedPosition</c> (0x0051a5e0).
|
||||
/// Dead-reckon this object's position forward by <paramref name="quantum"/>
|
||||
/// seconds using its current velocity.
|
||||
/// </summary>
|
||||
public Position GetInterpolatedPosition(double quantum)
|
||||
{
|
||||
var pos = _host.Position;
|
||||
Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum;
|
||||
return new Position(pos.ObjCellId, origin, pos.Frame.Orientation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::SendVoyeurUpdate</c> (0x0051a4f0). Record the
|
||||
/// sent position on the voyeur, build a <see cref="TargetInfo"/> carrying
|
||||
/// this object's CURRENT authoritative position + the extrapolated
|
||||
/// <paramref name="pos"/> + velocity + status, and deliver it to the
|
||||
/// subscriber's <see cref="ReceiveUpdate"/> (via its host).
|
||||
/// </summary>
|
||||
public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)
|
||||
{
|
||||
voyeur.LastSentPosition = pos;
|
||||
|
||||
var info = new TargetInfo(
|
||||
ObjectId: _host.Id,
|
||||
Status: status,
|
||||
TargetPosition: _host.Position, // current authoritative
|
||||
InterpolatedPosition: pos, // the extrapolated position
|
||||
ContextId: 0,
|
||||
Radius: voyeur.Radius,
|
||||
Quantum: voyeur.Quantum,
|
||||
Velocity: _host.Velocity);
|
||||
|
||||
var voyeurObj = _host.GetObjectA(voyeur.ObjectId);
|
||||
voyeurObj?.ReceiveTargetUpdate(info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>TargetManager::NotifyVoyeurOfEvent</c> (0x0051a6f0). Broadcast
|
||||
/// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber
|
||||
/// with this object's CURRENT position — no distance gate.
|
||||
/// </summary>
|
||||
public void NotifyVoyeurOfEvent(TargetStatus status)
|
||||
{
|
||||
if (_voyeurTable == null)
|
||||
return;
|
||||
|
||||
foreach (var voyeur in _voyeurTable.Values.ToList())
|
||||
SendVoyeurUpdate(voyeur, _host.Position, status);
|
||||
}
|
||||
}
|
||||
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
36
src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
namespace AcDream.Core.Physics.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// R5 — port of retail's <c>TargettedVoyeurInfo</c> (acclient.h:52807,
|
||||
/// struct #5801). One entry in a <see cref="TargetManager"/>'s voyeur table:
|
||||
/// a subscriber watching THIS object, the send-on-move <see cref="Radius"/>
|
||||
/// threshold it registered, its dead-reckoning <see cref="Quantum"/>, and the
|
||||
/// <see cref="LastSentPosition"/> already delivered to it (the delta baseline
|
||||
/// <c>CheckAndUpdateVoyeur</c> compares against). Mutable class (retail heap
|
||||
/// record updated in place by <c>AddVoyeur</c>/<c>SendVoyeurUpdate</c>).
|
||||
/// </summary>
|
||||
public sealed class TargettedVoyeurInfo
|
||||
{
|
||||
/// <summary>+0x00 retail <c>object_id</c> — the subscriber's guid.</summary>
|
||||
public uint ObjectId { get; }
|
||||
|
||||
/// <summary>+0x04 retail <c>quantum</c> — the subscriber's dead-reckoning
|
||||
/// lookahead horizon (seconds).</summary>
|
||||
public double Quantum { get; set; }
|
||||
|
||||
/// <summary>+0x10 retail <c>radius</c> — the send-on-move threshold: an
|
||||
/// update is pushed only when the tracked object drifts more than this from
|
||||
/// <see cref="LastSentPosition"/>.</summary>
|
||||
public float Radius { get; set; }
|
||||
|
||||
/// <summary>+0x14 retail <c>last_sent_position</c> — the position last
|
||||
/// delivered to this subscriber (updated by <c>SendVoyeurUpdate</c>).</summary>
|
||||
public Position LastSentPosition { get; set; }
|
||||
|
||||
public TargettedVoyeurInfo(uint objectId, float radius, double quantum)
|
||||
{
|
||||
ObjectId = objectId;
|
||||
Radius = radius;
|
||||
Quantum = quantum;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs the 32-bit retail <see cref="DRWMotionCommand"/> value from
|
||||
/// a 16-bit wire value broadcast in <c>InterpretedMotionState.Commands[]</c>.
|
||||
/// Reconstructs the 32-bit retail MotionCommand value from a 16-bit wire
|
||||
/// value broadcast in <c>InterpretedMotionState.Commands[]</c>.
|
||||
///
|
||||
/// <para>
|
||||
/// The server serializes MotionCommands as <c>u16</c> (ACE
|
||||
|
|
@ -19,11 +15,19 @@ namespace AcDream.Core.Physics;
|
|||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// This is implemented as an eager lookup table built from all values of
|
||||
/// <see cref="DRWMotionCommand"/> via reflection. If the wire value matches
|
||||
/// more than one enum value (different class bits), we prefer the
|
||||
/// lowest-class-numbered variant that has a non-zero class byte — roughly
|
||||
/// matching retail priority (Action < Modifier < SubState < Style).
|
||||
/// As of the L.1b command-catalog slice, this static facade delegates to a
|
||||
/// single shared <see cref="AceModernCommandCatalog"/> instance — the
|
||||
/// runtime-default catalog built from the DatReaderWriter
|
||||
/// <c>MotionCommand</c> enum (matches ACE + the local DATs). All ~10
|
||||
/// existing runtime callers (<c>AnimationCommandRouter</c>,
|
||||
/// <c>CombatAnimationPlanner</c>, 8x <c>GameWindow</c>) are unaffected by
|
||||
/// this refactor — the public signature and behavior for the ACE/runtime
|
||||
/// path is unchanged. A second catalog,
|
||||
/// <see cref="Retail2013CommandCatalog"/>, is available for
|
||||
/// conformance/reference work against the Sept 2013 EoR decomp's own
|
||||
/// (differently-numbered) command table; callers that need that catalog
|
||||
/// must construct it directly via the <see cref="IMotionCommandCatalog"/>
|
||||
/// seam — this facade only ever returns ACE/runtime values.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -42,66 +46,26 @@ namespace AcDream.Core.Physics;
|
|||
/// <c>docs/research/deepdives/r03-motion-animation.md</c> §3 — complete
|
||||
/// command catalogue.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md</c> —
|
||||
/// the ACE-vs-2013 catalog divergence that motivated the dual-catalog
|
||||
/// seam (the shift begins at <c>SnowAngelState</c>, 0x43000115 ->
|
||||
/// 0x43000118, not at <c>AllegianceHometownRecall</c>).
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MotionCommandResolver
|
||||
{
|
||||
// Lookup table built eagerly at type-init. Sparse: only values that
|
||||
// appear in the DRW enum (which came from the generated protocol XML)
|
||||
// are present. ~450 entries typical.
|
||||
private static readonly Dictionary<ushort, uint> s_lookup = BuildLookup();
|
||||
private static readonly AceModernCommandCatalog s_aceModern = new();
|
||||
|
||||
/// <summary>
|
||||
/// Given a 16-bit wire value, return the full 32-bit MotionCommand
|
||||
/// (class byte restored). Returns 0 if no matching enum value exists.
|
||||
/// (class byte restored) per the ACE/runtime catalog. Returns 0 if no
|
||||
/// matching value exists.
|
||||
/// </summary>
|
||||
public static uint ReconstructFullCommand(ushort wireCommand)
|
||||
{
|
||||
if (wireCommand == 0) return 0u;
|
||||
s_lookup.TryGetValue(wireCommand, out var full);
|
||||
return full;
|
||||
}
|
||||
|
||||
private static Dictionary<ushort, uint> BuildLookup()
|
||||
{
|
||||
var result = new Dictionary<ushort, uint>(512);
|
||||
var values = Enum.GetValues(typeof(DRWMotionCommand));
|
||||
foreach (DRWMotionCommand v in values)
|
||||
{
|
||||
uint full = (uint)v;
|
||||
ushort lo = (ushort)(full & 0xFFFFu);
|
||||
if (lo == 0) continue; // Invalid / unmappable
|
||||
|
||||
// If a value with this low-16-bit already exists, keep the one
|
||||
// with the lower class byte (Action=0x10 beats SubState=0x41
|
||||
// beats Style=0x80). This matches retail: the server tends to
|
||||
// emit Actions and ChatEmotes far more often than Styles, so
|
||||
// the Action-class reconstruction is the common case.
|
||||
if (!result.TryGetValue(lo, out var existing)
|
||||
|| (full >> 24) < (existing >> 24))
|
||||
{
|
||||
result[lo] = full;
|
||||
}
|
||||
}
|
||||
|
||||
ApplyNamedRetailOverrides(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyNamedRetailOverrides(Dictionary<ushort, uint> result)
|
||||
{
|
||||
// The generated DRW enum is shifted by three entries starting at
|
||||
// AllegianceHometownRecall. The named Sept 2013 retail command_ids
|
||||
// table is authoritative here:
|
||||
// named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658
|
||||
// and command-name table lines 1068272-1068313.
|
||||
//
|
||||
// These values cover recall, offhand, attack 4-6, and fast/slow punch
|
||||
// actions. Without the override, wire command 0x0170 reconstructs to
|
||||
// IssueSlashCommand instead of OffhandSlashHigh, so offhand swing
|
||||
// animations route as UI commands and never play.
|
||||
for (ushort lo = 0x016E; lo <= 0x0197; lo++)
|
||||
result[lo] = 0x10000000u | lo;
|
||||
return s_aceModern.ReconstructFullCommand(wireCommand);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal file
119
src/AcDream.Core/Physics/MotionSequenceGate.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
|
||||
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
|
||||
///
|
||||
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
|
||||
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
|
||||
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
|
||||
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
|
||||
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
|
||||
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
|
||||
/// OLDER object incarnation than the one we know is dropped before any
|
||||
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
|
||||
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
|
||||
/// version exists; acdream adopts-and-applies instead — register row
|
||||
/// AD-32 records the divergence.</item>
|
||||
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
|
||||
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
|
||||
/// when its movement sequence is STRICTLY newer than the stored stamp
|
||||
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
|
||||
/// server-control gate — a movement sequence is consumed even when the
|
||||
/// event is subsequently dropped for stale server control.</item>
|
||||
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
|
||||
/// dropped when the STORED server-control stamp is strictly newer than
|
||||
/// the incoming one (a newer server-control era has already been seen);
|
||||
/// equal passes and re-stamps.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
|
||||
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
|
||||
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
|
||||
/// cross-checked against the decomp's branch structure.</para>
|
||||
/// </summary>
|
||||
public sealed class MotionSequenceGate
|
||||
{
|
||||
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
|
||||
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
|
||||
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
|
||||
private bool _seeded;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
|
||||
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
|
||||
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
|
||||
/// the numerically SMALLER value is the newer one (the counter wrapped).
|
||||
/// </summary>
|
||||
public static bool IsNewer(ushort oldStamp, ushort newStamp)
|
||||
{
|
||||
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
|
||||
return newStamp < oldStamp;
|
||||
return oldStamp < newStamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
|
||||
/// block. Retail initializes <c>update_times</c> wholesale from the 9
|
||||
/// u16s at the tail of PhysicsDesc (ACE
|
||||
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
|
||||
/// PhysicsTimeStamp enum order); without this, an entity whose movement
|
||||
/// sequence is already past 0x8000 at spawn would have every subsequent
|
||||
/// movement event judged stale against a zero stamp.
|
||||
///
|
||||
/// <para>The first Seed adopts the stamps wholesale (fresh object);
|
||||
/// subsequent Seeds only move stamps FORWARD. This makes the #138
|
||||
/// rehydrate path (which replays a RETAINED CreateObject through the
|
||||
/// spawn handler) a no-op instead of a stamp regression, while a genuine
|
||||
/// wire re-create (server sequences only advance) still seeds correctly.
|
||||
/// Retail has no equivalent replay path — its objects keep their stamps
|
||||
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
|
||||
/// </summary>
|
||||
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
|
||||
{
|
||||
if (!_seeded)
|
||||
{
|
||||
_instanceTs = instanceSeq;
|
||||
_movementTs = movementSeq;
|
||||
_serverControlTs = serverControlSeq;
|
||||
_seeded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
|
||||
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
|
||||
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the retail three-stamp gate to an inbound movement event.
|
||||
/// Returns true when the event should be applied (stamps updated),
|
||||
/// false when it must be dropped as stale/duplicate/superseded.
|
||||
/// </summary>
|
||||
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
|
||||
{
|
||||
// Dispatch-level incarnation gate — runs before any other stamp is
|
||||
// touched (retail drops at DispatchSmartBoxEvent, never reaching
|
||||
// SetObjectMovement).
|
||||
if (IsNewer(instanceSeq, _instanceTs))
|
||||
return false;
|
||||
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
|
||||
|
||||
// Gate 1: movement sequence must be strictly newer.
|
||||
if (!IsNewer(_movementTs, movementSeq))
|
||||
return false;
|
||||
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
|
||||
|
||||
// Gate 2: drop when a newer server-control era has already been seen.
|
||||
if (IsNewer(serverControlSeq, _serverControlTs))
|
||||
return false;
|
||||
_serverControlTs = serverControlSeq;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -66,6 +66,12 @@ public enum TransientStateFlags : uint
|
|||
Contact = 0x00000001, // bit 0 — touching any surface
|
||||
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
|
||||
Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
|
||||
// retail frames_stationary_fall carried across frames: transition() seeds fsf from
|
||||
// these bits before the sweep (pc:280940-947); handle_all_collisions re-encodes fsf
|
||||
// into them at the end of the frame (pc:282743/282749/282753).
|
||||
StationaryFall = 0x00000010, // bit 4 — fsf == 1
|
||||
StationaryStop = 0x00000020, // bit 5 — fsf == 2
|
||||
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
|
||||
Active = 0x00000080, // bit 7 — object needs per-frame update
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +121,22 @@ public sealed class PhysicsBody
|
|||
/// </summary>
|
||||
public Position CellPosition { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 (the door-swing snap fix, 2026-07-03): the retail
|
||||
/// <c>physics_obj->cell != 0</c> "placed in the world" truth (register
|
||||
/// row). CMotionInterp's dispatch tails strip link animations for
|
||||
/// DETACHED objects only (<c>if (cell == 0) RemoveLinkAnimations</c>,
|
||||
/// raw @305627); the previous proxy — <see cref="CellPosition"/>.<c>ObjCellId
|
||||
/// == 0</c> — was seeded ONLY by the local player's <see cref="SnapToCell"/>
|
||||
/// (#145 machinery), so every REMOTE body read "detached" forever and
|
||||
/// every dispatched transition link (door swings, walk↔run links) was
|
||||
/// stripped the same tick it was appended. Set by <see cref="SnapToCell"/>
|
||||
/// (local player placement) and by RemoteMotion construction (remotes
|
||||
/// exist only for world entities). Default false = detached, matching
|
||||
/// retail's pre-enter_world state.
|
||||
/// </summary>
|
||||
public bool InWorld { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Placement: set the world position AND seed <see cref="CellPosition"/> from the
|
||||
/// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the
|
||||
|
|
@ -137,6 +159,7 @@ public sealed class PhysicsBody
|
|||
if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u)
|
||||
LandDefs.AdjustToOutside(ref cell, ref local);
|
||||
CellPosition = new Position(cell, new CellFrame(local, Orientation));
|
||||
InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell
|
||||
}
|
||||
|
||||
// Mirror a world-position translation into the cell-relative frame. Velocity is
|
||||
|
|
@ -162,9 +185,28 @@ public sealed class PhysicsBody
|
|||
/// <summary>Orientation quaternion (struct offsets 0x60–0x80 column matrix).</summary>
|
||||
public Quaternion Orientation { get; set; } = Quaternion.Identity;
|
||||
|
||||
/// <summary>World-space velocity (+0xE0/E4/E8).</summary>
|
||||
/// <summary>World-space velocity (+0xE0/E4/E8) — retail m_velocityVector: the INTENDED
|
||||
/// velocity that the integrator advances (gravity, friction, MaxVelocity clamp) and that
|
||||
/// drives the collision sweep. Zeroed by handle_all_collisions when
|
||||
/// <see cref="FramesStationaryFall"/> > 1 (the "bleed on block").</summary>
|
||||
public Vector3 Velocity { get; set; }
|
||||
|
||||
/// <summary>Retail cached_velocity — a SEPARATE field from <see cref="Velocity"/>: the
|
||||
/// REALIZED velocity (resolved displacement / dt) written after each transition in
|
||||
/// UpdateObjectInternal (0x005158cb-005158ff, pc:283693). Read only for reporting /
|
||||
/// dead-reckoning / camera slope-align (get_velocity 0x005113c0); it is NEVER fed back
|
||||
/// into <see cref="Velocity"/> or the integrator. Kept separate per the verified retail
|
||||
/// two-velocity model — do not collapse the two.</summary>
|
||||
public Vector3 CachedVelocity { get; set; }
|
||||
|
||||
/// <summary>Retail collision_info.frames_stationary_fall carried on the body between
|
||||
/// frames. ValidateTransition increments it (0→1→2→3) when the sphere fails to advance and
|
||||
/// resets it to 0 when it moves; at fsf≥2 an upward contact plane is manufactured;
|
||||
/// handle_all_collisions zeros <see cref="Velocity"/> when fsf>1 (the airborne-stuck
|
||||
/// bleed). Round-trips across frames via the Stationary* transient bits. validate_transition
|
||||
/// pc:272625-656; handle_all_collisions pc:282695.</summary>
|
||||
public int FramesStationaryFall { get; set; }
|
||||
|
||||
/// <summary>World-space acceleration (+0xEC/F0/F4).</summary>
|
||||
public Vector3 Acceleration { get; set; }
|
||||
|
||||
|
|
@ -231,6 +273,38 @@ public sealed class PhysicsBody
|
|||
/// <summary>Last simulation time used to compute dt (+0xD8).</summary>
|
||||
public double LastUpdateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W3 stub for retail <c>CPhysicsObj::IsFullyConstrained</c>
|
||||
/// (0x0050f730), read by <c>CMotionInterp::jump_is_allowed</c> (raw
|
||||
/// 305524-305525: <c>if (IsFullyConstrained(physics_obj) != 0) return
|
||||
/// 0x47;</c>). Retail's body walks per-cell contact-plane constraints
|
||||
/// (a mover pinned between opposing walkable surfaces / doorway
|
||||
/// jamming); acdream has no equivalent constraint-tracking yet.
|
||||
/// Register row: stubbed false (never fires) — a real port needs the
|
||||
/// per-cell shadow-list contact accounting the physics digest tracks.
|
||||
/// See docs/architecture/retail-divergence-register.md (added same
|
||||
/// commit as this field).
|
||||
/// </summary>
|
||||
public bool IsFullyConstrained { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// R3-W4 — retail <c>CPhysicsObj::last_move_was_autonomous</c>, read by
|
||||
/// <c>CPhysicsObj::movement_is_autonomous</c> (0x0050eb30, decomp §7a
|
||||
/// @276443: <c>return this->last_move_was_autonomous;</c>). Gates the
|
||||
/// A3 dual-dispatch predicate in <c>MotionInterpreter.apply_current_movement</c>/
|
||||
/// <c>ReportExhaustion</c>/<c>SetWeenieObject</c>/<c>SetPhysicsObject</c>:
|
||||
/// true means the last motion on this body was locally-simulated
|
||||
/// (player input / local prediction), false means it was a
|
||||
/// server-driven dead-reckoning update. Set true at the local-player
|
||||
/// input chokepoint (App layer — <c>PlayerMovementController</c>);
|
||||
/// left false (the safe default — routes to
|
||||
/// <c>apply_interpreted_movement</c>) for DR-applied remote updates.
|
||||
/// <see cref="MotionInterpreter.LeaveGround"/> also sets this true
|
||||
/// itself: retail's <c>set_local_velocity(&var_c, 1)</c> call passes
|
||||
/// the autonomous flag literal <c>1</c> (raw @305763-305765).
|
||||
/// </summary>
|
||||
public bool LastMoveWasAutonomous { get; set; }
|
||||
|
||||
// ── convenience helpers ────────────────────────────────────────────────
|
||||
|
||||
public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity);
|
||||
|
|
@ -310,10 +384,23 @@ public sealed class PhysicsBody
|
|||
/// worldY = col0.y*localX + col1.y*localY + col2.y*localZ
|
||||
/// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ
|
||||
/// We replicate this as a Quaternion rotation, which is equivalent.
|
||||
///
|
||||
/// <para>
|
||||
/// R3-W4: retail's <c>set_local_velocity</c> takes a second
|
||||
/// <c>autonomous</c> arg (<c>CPhysicsObj::set_local_velocity</c>,
|
||||
/// stores it to <see cref="LastMoveWasAutonomous"/> — read by
|
||||
/// <c>CPhysicsObj::movement_is_autonomous</c>, the A3 dual-dispatch
|
||||
/// predicate). Defaults to <c>false</c> to preserve every pre-W4 call
|
||||
/// site's behavior (server/interpreted-driven callers never asserted
|
||||
/// autonomy); <see cref="MotionInterpreter.LeaveGround"/> is the one
|
||||
/// caller that passes <c>true</c> (raw @305763-305765,
|
||||
/// <c>set_local_velocity(&var_c, 1)</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void set_local_velocity(Vector3 localVelocity)
|
||||
public void set_local_velocity(Vector3 localVelocity, bool autonomous = false)
|
||||
{
|
||||
var worldVelocity = Vector3.Transform(localVelocity, Orientation);
|
||||
LastMoveWasAutonomous = autonomous;
|
||||
set_velocity(worldVelocity);
|
||||
}
|
||||
|
||||
|
|
@ -435,11 +522,12 @@ public sealed class PhysicsBody
|
|||
|
||||
calc_friction(dt, velocityMag2);
|
||||
|
||||
// If velocity fell below the "small" threshold after friction, stop.
|
||||
// Only apply when grounded — while airborne, gravity must accumulate
|
||||
// even when velocity is near zero (e.g., at jump apex).
|
||||
if (velocityMag2 - SmallVelocitySquared < 0.0002f
|
||||
&& TransientState.HasFlag(TransientStateFlags.OnWalkable))
|
||||
// Retail UpdatePhysicsInternal (0x005107be): zero velocity below 0.25 m/s
|
||||
// UNCONDITIONALLY — NOT gated on OnWalkable. At the jump apex this zeros the
|
||||
// residual horizontal drift; the unconditional `Velocity += Acceleration * dt`
|
||||
// below immediately re-applies gravity, so the fall still accumulates. (The old
|
||||
// OnWalkable gate was an acdream divergence; the verbatim rebuild removes it.)
|
||||
if (velocityMag2 - SmallVelocitySquared < 0.0002f)
|
||||
Velocity = Vector3.Zero;
|
||||
|
||||
// Euler integration: position += v*dt + 0.5*a*dt²
|
||||
|
|
|
|||
|
|
@ -80,6 +80,20 @@ public static class PhysicsDiagnostics
|
|||
public static bool ProbeCellSetEnabled { get; set; }
|
||||
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe.
|
||||
/// One <c>[sticky]</c> line per StickyManager lifecycle event (STICK /
|
||||
/// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed
|
||||
/// <c>AdjustOffset</c> tick (guid, signed gap distance, applied delta,
|
||||
/// heading delta), plus <c>[sticky-snap-skip]</c> lines at the NPC
|
||||
/// UpdatePosition handler when a server hard-snap is suppressed because
|
||||
/// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck
|
||||
/// count); capture-session only. All lines carry the guid
|
||||
/// (feedback_probe_identity_attribution).
|
||||
/// </summary>
|
||||
public static bool ProbeStickyEnabled { get; set; }
|
||||
= Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1";
|
||||
|
||||
public static void LogCellSetBuild(
|
||||
uint seedCellId,
|
||||
System.Numerics.Vector3 sphereCenter,
|
||||
|
|
@ -562,6 +576,7 @@ public static class PhysicsDiagnostics
|
|||
ProbeCellEnabled = false;
|
||||
ProbeBuildingEnabled = false;
|
||||
ProbeCellSetEnabled = false;
|
||||
ProbeStickyEnabled = false;
|
||||
ProbeAutoWalkEnabled = false;
|
||||
ProbeUseabilityFallbackEnabled= false;
|
||||
DumpSteepRoofEnabled = false;
|
||||
|
|
|
|||
|
|
@ -971,6 +971,11 @@ public sealed class PhysicsEngine
|
|||
// (matches non-player movement, all targets collide).
|
||||
transition.ObjectInfo.State |= moverFlags;
|
||||
|
||||
// frames_stationary_fall gate input: retail reads the mover's GRAVITY state bit
|
||||
// (object_info.object->state & 0x400, pc:272625). Seed it from the body so the ladder
|
||||
// in ValidateTransition runs for gravity movers (the player) and not floating props.
|
||||
transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false;
|
||||
|
||||
if (isOnGround)
|
||||
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
||||
|
||||
|
|
@ -1043,6 +1048,19 @@ public sealed class PhysicsEngine
|
|||
body.WalkableUp);
|
||||
}
|
||||
|
||||
// Seed collision_info.frames_stationary_fall from the body's carried Stationary*
|
||||
// transient bits — retail transition() 0x00512dc0 seeds fsf from transient_state
|
||||
// 0x40/0x20/0x10 AFTER init_path and immediately BEFORE find_valid_position
|
||||
// (pc:280939-949). Placed here (post-InitPath) so InitPath's CollisionInfo reset
|
||||
// doesn't wipe the seed.
|
||||
if (body is not null)
|
||||
{
|
||||
transition.CollisionInfo.FramesStationaryFall =
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryStuck) ? 3 :
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryStop) ? 2 :
|
||||
body.TransientState.HasFlag(TransientStateFlags.StationaryFall) ? 1 : 0;
|
||||
}
|
||||
|
||||
bool ok = transition.FindTransitionalPosition(this);
|
||||
|
||||
var sp = transition.SpherePath;
|
||||
|
|
@ -1072,6 +1090,23 @@ public sealed class PhysicsEngine
|
|||
body.ContactPlaneValid = false;
|
||||
}
|
||||
|
||||
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
|
||||
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
|
||||
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
|
||||
// register) so the round-trip (seed→ladder→writeback→seed) is self-contained in Core.
|
||||
// handle_all_collisions (PhysicsObjUpdate) then only READS body.FramesStationaryFall.
|
||||
body.FramesStationaryFall = ci.FramesStationaryFall;
|
||||
body.TransientState &= ~(TransientStateFlags.StationaryFall
|
||||
| TransientStateFlags.StationaryStop
|
||||
| TransientStateFlags.StationaryStuck);
|
||||
body.TransientState |= ci.FramesStationaryFall switch
|
||||
{
|
||||
1 => TransientStateFlags.StationaryFall,
|
||||
2 => TransientStateFlags.StationaryStop,
|
||||
3 => TransientStateFlags.StationaryStuck,
|
||||
_ => TransientStateFlags.None,
|
||||
};
|
||||
|
||||
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
|
||||
{
|
||||
body.WalkablePolygonValid = true;
|
||||
|
|
@ -1085,16 +1120,27 @@ public sealed class PhysicsEngine
|
|||
body.WalkableVertices = null;
|
||||
}
|
||||
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
// Retail persists sliding state to the body ONLY on transition
|
||||
// SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at
|
||||
// 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's
|
||||
// final sliding_normal_valid at 0x005154e1 — and SetPositionInternal
|
||||
// is unreachable when find_valid_position fails (the transition is
|
||||
// discarded whole; the body keeps its prior state). #137 mechanism
|
||||
// 2: an unconditional writeback here could persist a normal retail
|
||||
// would discard.
|
||||
if (ok)
|
||||
{
|
||||
body.SlidingNormal = ci.SlidingNormal;
|
||||
body.TransientState |= TransientStateFlags.Sliding;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
if (ci.SlidingNormalValid
|
||||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||||
{
|
||||
body.SlidingNormal = ci.SlidingNormal;
|
||||
body.TransientState |= TransientStateFlags.Sliding;
|
||||
}
|
||||
else
|
||||
{
|
||||
body.SlidingNormal = Vector3.Zero;
|
||||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||||
}
|
||||
}
|
||||
|
||||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||||
|
|
|
|||
78
src/AcDream.Core/Physics/PhysicsObjUpdate.cs
Normal file
78
src/AcDream.Core/Physics/PhysicsObjUpdate.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Verbatim port of the collision-response tail of retail
|
||||
/// <c>CPhysicsObj::UpdateObjectInternal</c>: the velocity decision that
|
||||
/// <c>SetPositionInternal</c> (0x00515330) drives through
|
||||
/// <c>handle_all_collisions</c> (0x00514780, pc:282647). Kept as a pure function over a
|
||||
/// <see cref="PhysicsBody"/> + the resolve outcome so the whole decision is unit-testable
|
||||
/// in Core, independent of the App per-frame loop. The transition INTERNALS
|
||||
/// (<c>ResolveWithTransition</c> and below) are untouched.
|
||||
/// </summary>
|
||||
public static class PhysicsObjUpdate
|
||||
{
|
||||
/// <summary>
|
||||
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
|
||||
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
|
||||
/// <see cref="PhysicsBody.FramesStationaryFall"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item>fsf ≤ 1 → reflect the into-surface component
|
||||
/// (<c>v += -(v·n)(elasticity+1)·n</c>, pc:282712) when we should reflect and a
|
||||
/// collision normal is valid; an INELASTIC mover zeros instead (pc:282720).</item>
|
||||
/// <item>fsf > 1 → <c>v = 0</c> entirely (pc:282729) — the "bleed on block" that
|
||||
/// lets gravity resume so a blocked jump falls/glides off (fixes the #182
|
||||
/// airborne-stuck wedge).</item>
|
||||
/// </list>
|
||||
/// The Stationary* transient-bit round-trip (pc:282737-758) is owned by the Core resolve
|
||||
/// writeback (<c>PhysicsEngine.ResolveWithTransition</c>), not re-encoded here.
|
||||
/// </summary>
|
||||
/// <param name="body">The mover.</param>
|
||||
/// <param name="collisionNormalValid">Was a wall/creature collision normal recorded this resolve.</param>
|
||||
/// <param name="collisionNormal">Outward collision normal (points away from the surface).</param>
|
||||
/// <param name="prevContact">Whether the body had Contact BEFORE this resolve committed
|
||||
/// (retail arg3 — reserved for environment-collision reporting; unused today).</param>
|
||||
/// <param name="prevOnWalkable">Whether the body was OnWalkable before this resolve (retail arg4).</param>
|
||||
/// <param name="nowOnWalkable">Whether the body is OnWalkable after this resolve.</param>
|
||||
public static void HandleAllCollisions(
|
||||
PhysicsBody body,
|
||||
bool collisionNormalValid, Vector3 collisionNormal,
|
||||
bool prevContact, bool prevOnWalkable, bool nowOnWalkable)
|
||||
{
|
||||
// var_10_1 (pc:282653-282657): reflect UNLESS the mover stays on walkable ground
|
||||
// (and is not sledding). This restores retail's broader rule — the AD-25 airborne-only
|
||||
// suppression is retired: the landing-snap fragility it guarded is gone (landing state
|
||||
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
|
||||
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
|
||||
// false), exactly as retail.
|
||||
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
|
||||
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
|
||||
|
||||
if (body.FramesStationaryFall <= 1)
|
||||
{
|
||||
if (shouldReflect && collisionNormalValid)
|
||||
{
|
||||
if (body.State.HasFlag(PhysicsStateFlags.Inelastic))
|
||||
{
|
||||
body.Velocity = Vector3.Zero; // pc:282720-282722
|
||||
}
|
||||
else
|
||||
{
|
||||
float dot = Vector3.Dot(body.Velocity, collisionNormal);
|
||||
if (dot < 0f) // moving INTO the surface
|
||||
{
|
||||
float k = -(dot * (body.Elasticity + 1f)); // pc:282712
|
||||
body.Velocity += collisionNormal * k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
body.Velocity = Vector3.Zero; // fsf>1 → THE BLEED (pc:282729)
|
||||
}
|
||||
|
||||
_ = prevContact; // retail report_environment_collision(arg3) — weenie collision events, later.
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,24 @@ public sealed class PlayerWeenie : IWeenieObject
|
|||
|
||||
public bool CanJump(float extent) => true; // burden/stamina checks deferred
|
||||
|
||||
/// <summary>
|
||||
/// R3-W3 (W0-pins.md A3): the local player's weenie is THE player.
|
||||
/// Feeds W4's <c>apply_current_movement</c>/<c>ReportExhaustion</c>
|
||||
/// dual-dispatch gate — no consumer yet in W3.
|
||||
/// </summary>
|
||||
public bool IsThePlayer() => true;
|
||||
|
||||
/// <summary>
|
||||
/// TS-5 (extended): stamina cost gating deferred pending stat plumbing —
|
||||
/// always affordable, cost 0. Matches <see cref="CanJump"/>'s existing
|
||||
/// always-true stance.
|
||||
/// </summary>
|
||||
public bool JumpStaminaCost(float extent, out int cost)
|
||||
{
|
||||
cost = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4
|
||||
/// Capped at 4.5 when runSkill >= 800.
|
||||
|
|
|
|||
322
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
322
src/AcDream.Core/Physics/RawMotionState.cs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
using AcDream.Core.Physics.Motion;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Retail hold-key enum (<c>acclient.h</c> <c>enum HoldKey</c>, line 3394).
|
||||
/// </summary>
|
||||
public enum HoldKey : uint
|
||||
{
|
||||
Invalid = 0x0,
|
||||
None = 0x1,
|
||||
Run = 0x2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in <see cref="RawMotionState.Actions"/> — a discrete motion
|
||||
/// event (jump, attack, etc.) layered on top of the continuous
|
||||
/// forward/sidestep/turn axes.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's in-memory action node (<c>RawMotionState::AddAction</c>,
|
||||
/// 0x0051e840, and <c>InterpretedMotionState::AddAction</c>, 0x0051e9e0 —
|
||||
/// both a 0x14-byte <c>LListData</c>-derived node) carries FOUR fields:
|
||||
/// <c>motion</c> (+4), <c>speed</c> (+8), <c>action_stamp</c> (+0xc),
|
||||
/// <c>autonomous</c> (+0x10). <see cref="Speed"/> is the in-memory-only
|
||||
/// field the R3-W1 action FIFO needs for <c>ApplyMotion</c>'s velocity
|
||||
/// bookkeeping; it is NOT part of the wire encoding.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail packs each action ON THE WIRE as <c>u16 command</c> then
|
||||
/// <c>u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))</c>
|
||||
/// (<c>RawMotionState::Pack</c>, 0x0051ed10, decomp lines ~293998-294010)
|
||||
/// — <see cref="Command"/>/<see cref="Stamp"/>/<see cref="Autonomous"/> are
|
||||
/// exactly the packed triple; <see cref="RawMotionStatePacker"/> reads only
|
||||
/// those three.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct RawMotionAction(
|
||||
ushort Command,
|
||||
ushort Stamp,
|
||||
bool Autonomous,
|
||||
float Speed = 1f);
|
||||
|
||||
/// <summary>
|
||||
/// R3-W1 — verbatim, full-field port of retail's <c>RawMotionState</c>
|
||||
/// (<c>acclient.h</c> <c>RawMotionState::PackBitfield</c>, line 46474; ctor
|
||||
/// <c>RawMotionState::RawMotionState</c>, 0x0051e7f0, decomp lines
|
||||
/// 293344-293361; packer at <c>RawMotionState::Pack</c>, 0x0051ed10).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>R3-W1 fold (closes J2):</b> this type now ALSO serves as
|
||||
/// <c>MotionInterpreter.RawState</c> — the former <c>LegacyRawMotionState</c>
|
||||
/// (a flat 7-field struct with no action FIFO) is deleted. One raw-state
|
||||
/// type end to end: the physics-side motion interpreter mutates it via
|
||||
/// <see cref="ApplyMotion"/>/<see cref="RemoveMotion"/>/
|
||||
/// <see cref="AddAction"/>/<see cref="RemoveAction"/>, and the SAME
|
||||
/// instance's <see cref="Actions"/> is read by <see cref="RawMotionStatePacker"/>
|
||||
/// when building the outbound wire packet. Previously the packer's action
|
||||
/// list was always empty (register TS-24) because nothing populated it —
|
||||
/// starting R3-W2 (the <c>add_to_queue</c>/<c>MotionDone</c> port),
|
||||
/// <c>CMotionInterp</c> populates this list locally exactly like retail
|
||||
/// does, so the packed bytes will start reflecting real queued actions.
|
||||
/// W1 itself does not change what gets packed (no caller invokes
|
||||
/// <see cref="AddAction"/> yet) — only adds the capability + tests.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in
|
||||
/// <c>AcDream.Core.Physics</c> so the motion interpreter, which is
|
||||
/// also Core.Physics, can populate it without taking a Core.Net dependency
|
||||
/// (Code Structure Rule #2).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RawMotionState
|
||||
{
|
||||
/// <summary>Retail <c>current_holdkey</c> (ctor default HoldKey_None).</summary>
|
||||
public HoldKey CurrentHoldKey { get; set; } = HoldKey.None;
|
||||
/// <summary>Retail <c>current_style</c> (ctor default 0x8000003D, NonCombat).</summary>
|
||||
public uint CurrentStyle { get; set; } = 0x8000003Du;
|
||||
/// <summary>Retail <c>forward_command</c> (ctor default 0x41000003, Ready).</summary>
|
||||
public uint ForwardCommand { get; set; } = 0x41000003u;
|
||||
/// <summary>Retail <c>forward_holdkey</c> (ctor default HoldKey_Invalid).</summary>
|
||||
public HoldKey ForwardHoldKey { get; set; } = HoldKey.Invalid;
|
||||
/// <summary>Retail <c>forward_speed</c> (ctor default 1.0).</summary>
|
||||
public float ForwardSpeed { get; set; } = 1.0f;
|
||||
/// <summary>Retail <c>sidestep_command</c> (ctor default 0).</summary>
|
||||
public uint SidestepCommand { get; set; }
|
||||
/// <summary>Retail <c>sidestep_holdkey</c> (ctor default HoldKey_Invalid).</summary>
|
||||
public HoldKey SidestepHoldKey { get; set; } = HoldKey.Invalid;
|
||||
/// <summary>Retail <c>sidestep_speed</c> (ctor default 1.0).</summary>
|
||||
public float SidestepSpeed { get; set; } = 1.0f;
|
||||
/// <summary>Retail <c>turn_command</c> (ctor default 0).</summary>
|
||||
public uint TurnCommand { get; set; }
|
||||
/// <summary>Retail <c>turn_holdkey</c> (ctor default HoldKey_Invalid).</summary>
|
||||
public HoldKey TurnHoldKey { get; set; } = HoldKey.Invalid;
|
||||
/// <summary>Retail <c>turn_speed</c> (ctor default 1.0).</summary>
|
||||
public float TurnSpeed { get; set; } = 1.0f;
|
||||
|
||||
private readonly List<RawMotionAction> _actions = new();
|
||||
|
||||
/// <summary>
|
||||
/// Discrete action FIFO (retail <c>actions</c>, an <c>LListData</c>
|
||||
/// tail-append queue). Retail packs <c>num_actions</c> (count, not the
|
||||
/// values) into bits 11-15 of the flags dword, then emits each action's
|
||||
/// command+stamp pair unconditionally after the continuous-axis fields
|
||||
/// (<see cref="RawMotionStatePacker"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// Settable via object-initializer for test fixtures / the wire-packer
|
||||
/// call sites that build a one-shot snapshot; assigning replaces the
|
||||
/// whole FIFO. <see cref="AddAction"/>/<see cref="RemoveAction"/> are
|
||||
/// the retail-faithful mutators for live FIFO discipline.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawMotionAction> Actions
|
||||
{
|
||||
get => _actions;
|
||||
set
|
||||
{
|
||||
_actions.Clear();
|
||||
_actions.AddRange(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail defaults — a field bit is set in the packed flags dword ONLY
|
||||
/// when the live value differs from these (<c>RawMotionState::Pack</c>,
|
||||
/// 0x0051ed10). NEVER mutate this shared instance.
|
||||
/// </summary>
|
||||
public static readonly RawMotionState Default = new();
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawMotionState::AddAction</c> (0x0051e840, decomp 293365-293392):
|
||||
/// unconditional tail-append of
|
||||
/// <c>{motion, speed, action_stamp, autonomous}</c>.
|
||||
/// </summary>
|
||||
/// <param name="motion">Retail <c>arg2</c> — the action motion id.</param>
|
||||
/// <param name="speed">Retail <c>arg3</c>.</param>
|
||||
/// <param name="actionStamp">Retail <c>arg4</c>.</param>
|
||||
/// <param name="autonomous">Retail <c>arg5</c> (nonzero = autonomous).</param>
|
||||
public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous)
|
||||
{
|
||||
_actions.Add(new RawMotionAction(
|
||||
Command: (ushort)motion,
|
||||
Stamp: (ushort)actionStamp,
|
||||
Autonomous: autonomous,
|
||||
Speed: speed));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawMotionState::RemoveAction</c> (0x0051e8a0, decomp 293396-293414):
|
||||
/// pop the FIFO head unconditionally, returning its <c>motion</c> field
|
||||
/// (0 when empty — retail returns <c>*(head+4)</c>, i.e. the stored
|
||||
/// <c>arg2</c>/<c>Command</c>, widened; the C# port returns the widened
|
||||
/// <see cref="RawMotionAction.Command"/> as a <c>uint</c>).
|
||||
/// </summary>
|
||||
public uint RemoveAction()
|
||||
{
|
||||
if (_actions.Count == 0)
|
||||
return 0;
|
||||
var head = _actions[0];
|
||||
_actions.RemoveAt(0);
|
||||
return head.Command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawMotionState::ApplyMotion</c> (0x0051eb60, decomp 293630-293703).
|
||||
/// Verbatim dispatch, quoted from the raw named decomp:
|
||||
/// <code>
|
||||
/// if ((arg2 - 0x6500000d) > 3) { // outside [0x6500000d, 0x65000010]
|
||||
/// if ((arg2 & 0x40000000) == 0) { // NOT forward-class
|
||||
/// if (arg2 >= 0) { // NOT style-class (high bit clear)
|
||||
/// if ((arg2 & 0x10000000) != 0)
|
||||
/// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1);
|
||||
/// } else if (current_style != arg2) {
|
||||
/// forward_command = 0x41000003; current_style = arg2;
|
||||
/// }
|
||||
/// } else if (arg2 != 0x44000007) { // forward-class, NOT RunForward
|
||||
/// forward_command = arg2;
|
||||
/// if (params.bitfield byte1 & 8 != 0) { forward_holdkey = Invalid; forward_speed = params.speed; }
|
||||
/// else { forward_holdkey = params.hold_key_to_apply; forward_speed = params.speed; }
|
||||
/// }
|
||||
/// // arg2 == 0x44000007 (RunForward) with the forward-class bit set: falls through, no write.
|
||||
/// return;
|
||||
/// }
|
||||
/// switch (arg2) {
|
||||
/// case 0x6500000d: case 0x6500000e: // TurnRight/TurnLeft
|
||||
/// turn_command = arg2;
|
||||
/// if (SetHoldKey bit) { turn_holdkey = Invalid; turn_speed = params.speed; }
|
||||
/// else { turn_holdkey = params.hold_key_to_apply; turn_speed = params.speed; }
|
||||
/// return;
|
||||
/// case 0x6500000f: case 0x65000010: // SideStepRight/SideStepLeft
|
||||
/// sidestep_command = arg2;
|
||||
/// if (SetHoldKey bit) { sidestep_holdkey = Invalid; sidestep_speed = params.speed; }
|
||||
/// else { sidestep_holdkey = params.hold_key_to_apply; sidestep_speed = params.speed; }
|
||||
/// return;
|
||||
/// }
|
||||
/// </code>
|
||||
/// The <c>arg2 == 0x44000007</c> (RunForward) no-write fallthrough on the
|
||||
/// forward-class branch is a genuine retail quirk (RunForward bit
|
||||
/// 0x40000000 IS set, but the `else if (arg2 != 0x44000007)` guard
|
||||
/// excludes it) — ported verbatim, not "fixed".
|
||||
/// </summary>
|
||||
/// <param name="motion">Retail <c>arg2</c> — the ORIGINAL (pre-adjustment)
|
||||
/// motion id per the DoMotion/StopMotion mirror-discipline callers.</param>
|
||||
/// <param name="p">Retail <c>arg3</c> (<c>MovementParameters const*</c>).</param>
|
||||
public void ApplyMotion(uint motion, MovementParameters p)
|
||||
{
|
||||
if (motion - 0x6500000du > 3u)
|
||||
{
|
||||
if ((motion & 0x40000000u) == 0)
|
||||
{
|
||||
if (motion < 0x80000000u) // arg2 >= 0 as signed int32
|
||||
{
|
||||
if ((motion & 0x10000000u) != 0)
|
||||
AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous);
|
||||
}
|
||||
else if (CurrentStyle != motion)
|
||||
{
|
||||
ForwardCommand = 0x41000003u;
|
||||
CurrentStyle = motion;
|
||||
}
|
||||
}
|
||||
else if (motion != 0x44000007u)
|
||||
{
|
||||
ForwardCommand = motion;
|
||||
if (p.SetHoldKey)
|
||||
{
|
||||
ForwardHoldKey = HoldKey.Invalid;
|
||||
ForwardSpeed = p.Speed;
|
||||
}
|
||||
else
|
||||
{
|
||||
ForwardHoldKey = p.HoldKeyToApply;
|
||||
ForwardSpeed = p.Speed;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion)
|
||||
{
|
||||
case 0x6500000du: // TurnRight
|
||||
case 0x6500000eu: // TurnLeft
|
||||
TurnCommand = motion;
|
||||
if (p.SetHoldKey)
|
||||
{
|
||||
TurnHoldKey = HoldKey.Invalid;
|
||||
TurnSpeed = p.Speed;
|
||||
}
|
||||
else
|
||||
{
|
||||
TurnHoldKey = p.HoldKeyToApply;
|
||||
TurnSpeed = p.Speed;
|
||||
}
|
||||
return;
|
||||
case 0x6500000fu: // SideStepRight
|
||||
case 0x65000010u: // SideStepLeft
|
||||
SidestepCommand = motion;
|
||||
if (p.SetHoldKey)
|
||||
{
|
||||
SidestepHoldKey = HoldKey.Invalid;
|
||||
SidestepSpeed = p.Speed;
|
||||
}
|
||||
else
|
||||
{
|
||||
SidestepHoldKey = p.HoldKeyToApply;
|
||||
SidestepSpeed = p.Speed;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RawMotionState::RemoveMotion</c> (0x0051e6e0, decomp 293252-293288):
|
||||
/// <code>
|
||||
/// if ((arg2 - 0x6500000d) > 3) {
|
||||
/// if ((arg2 & 0x40000000) == 0) {
|
||||
/// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d;
|
||||
/// } else if (arg2 == forward_command) {
|
||||
/// forward_command = 0x41000003; forward_speed = 1f;
|
||||
/// }
|
||||
/// return;
|
||||
/// }
|
||||
/// switch (arg2) {
|
||||
/// case 0x6500000d: case 0x6500000e: turn_command = 0; return;
|
||||
/// case 0x6500000f: case 0x65000010: sidestep_command = 0; return;
|
||||
/// }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
/// <param name="motion">Retail <c>arg2</c> — the ORIGINAL motion id.</param>
|
||||
public void RemoveMotion(uint motion)
|
||||
{
|
||||
if (motion - 0x6500000du > 3u)
|
||||
{
|
||||
if ((motion & 0x40000000u) == 0)
|
||||
{
|
||||
if (motion >= 0x80000000u && motion == CurrentStyle)
|
||||
CurrentStyle = 0x8000003du;
|
||||
}
|
||||
else if (motion == ForwardCommand)
|
||||
{
|
||||
ForwardCommand = 0x41000003u;
|
||||
ForwardSpeed = 1f;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (motion)
|
||||
{
|
||||
case 0x6500000du:
|
||||
case 0x6500000eu:
|
||||
TurnCommand = 0;
|
||||
return;
|
||||
case 0x6500000fu:
|
||||
case 0x65000010u:
|
||||
SidestepCommand = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,16 @@ namespace AcDream.Core.Physics;
|
|||
/// active locomotion cycle). We rotate that by the body's orientation
|
||||
/// to get a world-space delta, then add the InterpolationManager's
|
||||
/// world-space correction.
|
||||
///
|
||||
/// <para><b>Renamed R5</b> (was <c>PositionManager</c>): this class is only the
|
||||
/// InterpolationManager-composition portion of retail's
|
||||
/// <c>PositionManager::adjust_offset</c> — NOT the retail PositionManager
|
||||
/// facade. The faithful facade (Sticky/Constraint, owned per entity) is
|
||||
/// <see cref="Motion.PositionManager"/>. The name was freed to remove the
|
||||
/// ambiguity that broke every file importing both
|
||||
/// <c>AcDream.Core.Physics</c> and <c>AcDream.Core.Physics.Motion</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class PositionManager
|
||||
public sealed class RemoteMotionCombiner
|
||||
{
|
||||
/// <summary>
|
||||
/// Compute the per-frame world-space delta to add to body.Position.
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-tick steering for server-controlled remote creatures while a
|
||||
/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet
|
||||
/// is the active locomotion source.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo"
|
||||
/// stabilizer. With the full MoveTo path payload now captured on
|
||||
/// <see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>,
|
||||
/// the body solver has the destination + heading + thresholds it needs to
|
||||
/// run the retail per-tick loop instead of waiting for sparse
|
||||
/// UpdatePosition snap corrections.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail references:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::HandleMoveToPosition</c> (<c>0x00529d80</c>) — the
|
||||
/// per-tick driver. Computes heading-to-target, fires an aux
|
||||
/// <c>TurnLeft</c>/<c>TurnRight</c> command when |delta| > 20°, snaps
|
||||
/// orientation when within tolerance, and tests arrival via
|
||||
/// <c>dist <= min_distance</c> (chase) or
|
||||
/// <c>dist >= distance_to_object</c> (flee).
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>MoveToManager::_DoMotion</c> / <c>_StopMotion</c> route turn
|
||||
/// commands through <c>CMotionInterp::DoInterpretedMotion</c> — i.e.
|
||||
/// MoveToManager itself does NOT touch the body. The body's actual
|
||||
/// velocity comes from <c>CMotionInterp::apply_current_movement</c>
|
||||
/// reading <c>InterpretedState.ForwardCommand = RunForward</c> and
|
||||
/// emitting <c>velocity.Y = RunAnimSpeed × speedMod</c>, transformed by
|
||||
/// the body's orientation.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Acdream port scope: minimum viable subset. We skip target re-tracking
|
||||
/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/
|
||||
/// StickTo, fail-distance progress detector, and the sphere-cylinder
|
||||
/// distance variant — all server-side concerns the local body doesn't need
|
||||
/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance
|
||||
/// (with ACE's <c>set_heading(true)</c> snap-on-aligned fudge), and
|
||||
/// arrival detection via <c>min_distance</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// ACE divergence: ACE swaps the chase/flee arrival predicates
|
||||
/// (<c>dist <= DistanceToObject</c> vs retail's <c>dist <= MinDistance</c>).
|
||||
/// We follow retail.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RemoteMoveToDriver
|
||||
{
|
||||
/// <summary>
|
||||
/// Heading tolerance below which we snap orientation directly to the
|
||||
/// target heading (ACE's <c>set_heading(target, true)</c>
|
||||
/// server-tic-rate fudge). Above tolerance we rotate at
|
||||
/// <see cref="TurnRateRadPerSec"/>. Retail value (line 307251 of
|
||||
/// <c>acclient_2013_pseudo_c.txt</c>) is 20°.
|
||||
/// </summary>
|
||||
public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Default angular rate for in-motion heading correction when delta
|
||||
/// exceeds <see cref="HeadingSnapToleranceRad"/>. Picked to match
|
||||
/// ACE's <c>TurnSpeed</c> default of <c>π/2</c> rad/s for monsters;
|
||||
/// when the per-creature value differs, the future port can wire it
|
||||
/// in via the <c>TurnSpeed</c> field on InterpretedMotionState.
|
||||
/// </summary>
|
||||
public const float TurnRateRadPerSec = MathF.PI / 2.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail base turn rate for the player Humanoid when turn_speed
|
||||
/// scalar = 1.0. Convention default <c>omega.z = ±π/2 rad/s</c>
|
||||
/// derived from <c>add_motion</c> at <c>0x005224b0</c> + the
|
||||
/// HasOmega-cleared MotionData fallback documented in
|
||||
/// <c>AnimationSequencer.cs:734-741</c>. ~90°/s.
|
||||
/// </summary>
|
||||
public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail's <c>run_turn_factor</c> constant at <c>0x007c8914</c>
|
||||
/// (PDB-named). <c>CMotionInterp::apply_run_to_command</c>
|
||||
/// equivalent (decomp <c>0x00527be0</c>, line 305098 of
|
||||
/// <c>acclient_2013_pseudo_c.txt</c>) multiplies <c>turn_speed</c>
|
||||
/// by 1.5 when <c>HoldKey.Run</c> is active on a TurnRight/TurnLeft
|
||||
/// command. Effect: running rotation is 50 % faster than walking.
|
||||
/// </summary>
|
||||
public const float RunTurnFactor = 1.5f;
|
||||
|
||||
/// <summary>
|
||||
/// Retail-faithful local-player turn rate.
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Walking</b>: <c>BaseTurnRateRadPerSec</c> ≈ 90°/s.</item>
|
||||
/// <item><b>Running</b>: <c>BaseTurnRateRadPerSec × RunTurnFactor</c>
|
||||
/// ≈ 135°/s.</item>
|
||||
/// </list>
|
||||
/// Replaces the fixed <c>TurnRateRadPerSec</c> for paths that have
|
||||
/// access to the player's run/walk state (keyboard A/D, auto-walk
|
||||
/// overlay turn-first). NPC/monster remotes that lack the
|
||||
/// information continue to use the constant which equals
|
||||
/// <c>BaseTurnRateRadPerSec</c>.
|
||||
/// </summary>
|
||||
public static float TurnRateFor(bool running)
|
||||
=> running ? BaseTurnRateRadPerSec * RunTurnFactor
|
||||
: BaseTurnRateRadPerSec;
|
||||
|
||||
/// <summary>
|
||||
/// Float-comparison slack for the arrival predicate. With
|
||||
/// <c>min_distance == 0</c> in a chase packet, exact equality is
|
||||
/// unreachable due to integration wobble; this epsilon prevents the
|
||||
/// driver from over-shooting by a sub-meter and snap-flipping back.
|
||||
/// </summary>
|
||||
public const float ArrivalEpsilon = 0.05f;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum staleness (seconds) of the most recent MoveTo packet
|
||||
/// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz
|
||||
/// during active chase; if no fresh packet arrives for this long,
|
||||
/// the entity has likely either left our streaming view, switched
|
||||
/// to a non-MoveTo motion the server's broadcast didn't reach us
|
||||
/// for, or had its move cancelled server-side without our seeing
|
||||
/// the cancel UM. In any of those cases, continuing to drive the
|
||||
/// body toward a stale destination produces the "monster runs in
|
||||
/// place after popping back into view" symptom (2026-04-28).
|
||||
/// 1.5 s gives us comfortable margin over the ~1 s emit cadence
|
||||
/// while still failing fast on real loss-of-state.
|
||||
/// </summary>
|
||||
public const double StaleDestinationSeconds = 1.5;
|
||||
|
||||
public enum DriveResult
|
||||
{
|
||||
/// <summary>Within arrival window — caller should zero velocity.</summary>
|
||||
Arrived,
|
||||
/// <summary>Steering active — caller should let
|
||||
/// <c>apply_current_movement</c> set body velocity from the cycle.</summary>
|
||||
Steering,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steer body orientation toward <paramref name="destinationWorld"/>
|
||||
/// and report whether the body has arrived or should keep running.
|
||||
/// Pure function — emits the updated orientation via
|
||||
/// <paramref name="newOrientation"/> (the input is not mutated; the
|
||||
/// caller assigns the new value back to its body).
|
||||
/// </summary>
|
||||
/// <param name="minDistance">
|
||||
/// <c>min_distance</c> from the wire's MovementParameters block —
|
||||
/// retail's <c>HandleMoveToPosition</c> chase-arrival threshold.
|
||||
/// </param>
|
||||
/// <param name="distanceToObject">
|
||||
/// <c>distance_to_object</c> from the wire — ACE's chase-arrival
|
||||
/// threshold (default 0.6 m, the melee range). The actual arrival
|
||||
/// gate is <c>max(minDistance, distanceToObject)</c>: retail-faithful
|
||||
/// when retail sends <c>min_distance</c> > 0, ACE-compatible when
|
||||
/// ACE puts the value in <c>distance_to_object</c> with
|
||||
/// <c>min_distance == 0</c>. Without this, ACE's <c>min_distance==0</c>
|
||||
/// chase packets never arrive — the body keeps re-targeting around
|
||||
/// the player at melee range and visibly oscillates between facings,
|
||||
/// which is the user-reported "monster keeps running in different
|
||||
/// directions when it should be attacking" symptom (2026-04-28).
|
||||
/// </param>
|
||||
public static DriveResult Drive(
|
||||
Vector3 bodyPosition,
|
||||
Quaternion bodyOrientation,
|
||||
Vector3 destinationWorld,
|
||||
float minDistance,
|
||||
float distanceToObject,
|
||||
float dt,
|
||||
bool moveTowards,
|
||||
out Quaternion newOrientation)
|
||||
{
|
||||
// Horizontal distance only — server owns Z, our body Z is
|
||||
// hard-snapped to the latest UpdatePosition.
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Arrival predicate per retail MoveToManager::HandleMoveToPosition
|
||||
// (acclient_2013_pseudo_c.txt:307289-307320) and ACE
|
||||
// MoveToManager.cs:476:
|
||||
//
|
||||
// chase (MoveTowards): dist <= distance_to_object
|
||||
// flee (MoveAway): dist >= min_distance
|
||||
//
|
||||
// (My earlier <c>max(MinDistance, DistanceToObject)</c> was a
|
||||
// defensive guess; cross-checked with two independent research
|
||||
// agents against the named retail decomp + ACE port + holtburger,
|
||||
// the chase threshold is unambiguously DistanceToObject —
|
||||
// MinDistance is the FLEE arrival threshold. ACE's wire defaults
|
||||
// give MinDistance=0, DistanceToObject=0.6 — the body should stop
|
||||
// at melee range, not run to zero.)
|
||||
float arrivalThreshold = moveTowards ? distanceToObject : minDistance;
|
||||
if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Arrived;
|
||||
}
|
||||
|
||||
// Degenerate — already on target horizontally; preserve heading.
|
||||
if (dist < 1e-4f)
|
||||
{
|
||||
newOrientation = bodyOrientation;
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
// Body's local-forward is +Y (see MotionInterpreter.get_state_velocity
|
||||
// at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed).
|
||||
// World forward = Transform((0,1,0), orientation). Yaw extracted
|
||||
// via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity.
|
||||
var localForward = new Vector3(0f, 1f, 0f);
|
||||
var worldForward = Vector3.Transform(localForward, bodyOrientation);
|
||||
float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y);
|
||||
|
||||
// Desired heading: face the target. (dx, dy) is the world-space
|
||||
// offset to the target. With local-forward=+Y we want yaw such
|
||||
// that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves
|
||||
// to yaw = atan2(-dx, dy).
|
||||
float desiredYaw = MathF.Atan2(-dx, dy);
|
||||
float delta = WrapPi(desiredYaw - currentYaw);
|
||||
|
||||
if (MathF.Abs(delta) <= HeadingSnapToleranceRad)
|
||||
{
|
||||
// ACE's set_heading(target, true) — sync to server-tic-rate.
|
||||
// We have the same sparse-UP problem ACE does, so the same
|
||||
// fudge applies.
|
||||
newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Retail BeginTurnToHeading / HandleMoveToPosition aux turn:
|
||||
// rotate at TurnRate clamped to dt, in the shorter direction.
|
||||
float maxStep = TurnRateRadPerSec * dt;
|
||||
float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
|
||||
// Apply incremental yaw around world +Z (preserving any
|
||||
// server-supplied pitch/roll from the latest UpdatePosition).
|
||||
var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step);
|
||||
newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation);
|
||||
}
|
||||
|
||||
return DriveResult.Steering;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a landblock-local Origin from a MoveTo packet
|
||||
/// (<see cref="AcDream.Core.Net.Messages.CreateObject.MoveToPathData"/>)
|
||||
/// into acdream's render world space using the same arithmetic as
|
||||
/// <c>OnLivePositionUpdated</c>: shift by the landblock-grid offset
|
||||
/// from the live-mode center.
|
||||
/// </summary>
|
||||
public static Vector3 OriginToWorld(
|
||||
uint originCellId,
|
||||
float originX,
|
||||
float originY,
|
||||
float originZ,
|
||||
int liveCenterLandblockX,
|
||||
int liveCenterLandblockY)
|
||||
{
|
||||
int lbX = (int)((originCellId >> 24) & 0xFFu);
|
||||
int lbY = (int)((originCellId >> 16) & 0xFFu);
|
||||
return new Vector3(
|
||||
originX + (lbX - liveCenterLandblockX) * 192f,
|
||||
originY + (lbY - liveCenterLandblockY) * 192f,
|
||||
originZ);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cap horizontal velocity so the body lands exactly at
|
||||
/// <paramref name="arrivalThreshold"/> rather than overshooting past
|
||||
/// it during the final tick of approach. Without this clamp, a body
|
||||
/// running at <c>RunAnimSpeed × speedMod ≈ 4 m/s</c> can overshoot
|
||||
/// the 0.6 m arrival window by up to one tick's advance (~6 cm at
|
||||
/// 60 fps) — visible as the creature "running slightly through" the
|
||||
/// player it's about to attack (user-reported 2026-04-28).
|
||||
///
|
||||
/// <para>
|
||||
/// The clamp is a strict scale-down of the horizontal component
|
||||
/// (X/Y); the vertical component (Z) is left to gravity / terrain
|
||||
/// handling. <paramref name="moveTowards"/> false (flee branch) is a
|
||||
/// no-op since fleeing has no overshoot risk — the body wants to
|
||||
/// move AWAY from the destination.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static Vector3 ClampApproachVelocity(
|
||||
Vector3 bodyPosition,
|
||||
Vector3 currentVelocity,
|
||||
Vector3 destinationWorld,
|
||||
float arrivalThreshold,
|
||||
float dt,
|
||||
bool moveTowards)
|
||||
{
|
||||
if (!moveTowards || dt <= 0f) return currentVelocity;
|
||||
|
||||
float dx = destinationWorld.X - bodyPosition.X;
|
||||
float dy = destinationWorld.Y - bodyPosition.Y;
|
||||
float dist = MathF.Sqrt(dx * dx + dy * dy);
|
||||
float remaining = MathF.Max(0f, dist - arrivalThreshold);
|
||||
|
||||
float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X
|
||||
+ currentVelocity.Y * currentVelocity.Y);
|
||||
if (vxy < 1e-3f) return currentVelocity;
|
||||
|
||||
float advance = vxy * dt;
|
||||
if (advance <= remaining) return currentVelocity;
|
||||
|
||||
// Already inside or right at the threshold: zero horizontal
|
||||
// velocity, keep Z. (The arrival predicate in Drive() should
|
||||
// have fired this tick, but this is the belt-and-braces guard.)
|
||||
if (remaining < 1e-3f)
|
||||
return new Vector3(0f, 0f, currentVelocity.Z);
|
||||
|
||||
float scale = remaining / advance;
|
||||
return new Vector3(
|
||||
currentVelocity.X * scale,
|
||||
currentVelocity.Y * scale,
|
||||
currentVelocity.Z);
|
||||
}
|
||||
|
||||
/// <summary>Wrap an angle in radians to [-π, π].</summary>
|
||||
private static float WrapPi(float r)
|
||||
{
|
||||
const float TwoPi = MathF.PI * 2f;
|
||||
r %= TwoPi;
|
||||
if (r > MathF.PI) r -= TwoPi;
|
||||
if (r < -MathF.PI) r += TwoPi;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
54
src/AcDream.Core/Physics/RemoteWeenie.cs
Normal file
54
src/AcDream.Core/Physics/RemoteWeenie.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// R4-V5 (#160 fix, 2026-07-03): the minimal weenie every REMOTE entity's
|
||||
/// <see cref="MotionInterpreter"/> carries — standing in for retail's
|
||||
/// per-object <c>ACCWeenieObject</c>, which every placed physics object has.
|
||||
///
|
||||
/// <para>
|
||||
/// The load-bearing behavior is the RUN-RATE FALLBACK CHAIN:
|
||||
/// <c>apply_run_to_command</c> (0x00527be0, raw 305062-305076) reads
|
||||
/// <c>weenie ? (InqRunRate() ?: my_run_rate) : 1.0</c>. Remote weenies in
|
||||
/// retail FAIL <c>InqRunRate</c> (no local skill data), landing on
|
||||
/// <c>my_run_rate</c> — exactly the field the mt-6/7 unpack writes with the
|
||||
/// wire's <c>MoveToRunRate</c> (M13). Our remote interps previously had NO
|
||||
/// weenie at all, taking the degenerate <c>else 1.0</c> branch retail
|
||||
/// reserves for detached objects — so an observer-side moveto dispatched
|
||||
/// RunForward at speed 1.0 instead of the wire rate: the run cycle played
|
||||
/// in slow motion and the chase velocity crawled (walk was unaffected —
|
||||
/// walk speed isn't rate-scaled).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="IWeenieObject.IsThePlayer"/> stays default false — the A3
|
||||
/// dual dispatch routes remotes through apply_interpreted_movement, ending
|
||||
/// the fragile "null weenie counts as the player" reading of that gate.
|
||||
/// <see cref="IWeenieObject.IsCreature"/> stays default true; static
|
||||
/// animated objects (doors) also carry this weenie — retail would say
|
||||
/// non-creature there, but their RemoteMotion bodies force-assert Contact,
|
||||
/// so the creature ground-gate is satisfied either way (noted, not a
|
||||
/// behavior difference today).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RemoteWeenie : IWeenieObject
|
||||
{
|
||||
/// <summary>Remote objects have no local skill data — fail, so the
|
||||
/// retail fallback chain lands on <see cref="MotionInterpreter.MyRunRate"/>
|
||||
/// (the wire-fed rate).</summary>
|
||||
public bool InqRunRate(out float rate)
|
||||
{
|
||||
rate = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Remotes never locally compute jump arcs (server-broadcast
|
||||
/// VectorUpdate velocities drive them).</summary>
|
||||
public bool InqJumpVelocity(float extent, out float vz)
|
||||
{
|
||||
vz = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Remotes never locally initiate jumps.</summary>
|
||||
public bool CanJump(float extent) => true;
|
||||
}
|
||||
113
src/AcDream.Core/Physics/Retail2013CommandCatalog.cs
Normal file
113
src/AcDream.Core/Physics/Retail2013CommandCatalog.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance/reference <see cref="IMotionCommandCatalog"/> built from the
|
||||
/// Sept 2013 EoR retail decomp's full <c>command_ids[0x198]</c> table — a
|
||||
/// direct index from 16-bit wire low-word to the 32-bit MotionCommand the
|
||||
/// 2013 client itself would reconstruct. NOT the runtime default (ACE / the
|
||||
/// local DATs use a later-client numbering — see
|
||||
/// <see cref="AceModernCommandCatalog"/>); this catalog exists to prove and
|
||||
/// pin the 2013 decomp's behavior for conformance tests.
|
||||
///
|
||||
/// <para>
|
||||
/// Provenance: <c>docs/research/named-retail/acclient_2013_pseudo_c.txt</c>,
|
||||
/// <c>uint32_t const command_ids[0x198]</c> at address <c>0x007c73e8</c>,
|
||||
/// table body starting at source line 1017259
|
||||
/// (<c>[0x000] = 0x80000000</c>) through line 1017667
|
||||
/// (<c>[0x197] = 0x10000197</c>), closing brace at line 1017668. Extracted
|
||||
/// programmatically (regex over <c>[0xNNN] = 0xVVVVVVVV</c>, not hand-typed)
|
||||
/// and verified against the anchor values cited inline below.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Caution — two look-alike tables exist elsewhere in the same file</b>
|
||||
/// (around source lines 1017962 and 1068315, both also declared
|
||||
/// <c>command_ids[0x198]</c>). Only the table at line 1017259 /
|
||||
/// address 0x007c73e8 is used here; do not conflate with the others.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class Retail2013CommandCatalog : IMotionCommandCatalog
|
||||
{
|
||||
public uint ReconstructFullCommand(ushort wireCommand)
|
||||
{
|
||||
return wireCommand < CommandIds.Length ? CommandIds[wireCommand] : 0u;
|
||||
}
|
||||
|
||||
// acclient_2013_pseudo_c.txt:1017259-1017667, address 0x007c73e8.
|
||||
// 0x198 (408) entries, indices 0x000..0x197, verbatim from the decomp.
|
||||
// Anchors verified at extraction time:
|
||||
// [0x000]=0x80000000 [0x003]=0x41000003 [0x005]=0x45000005
|
||||
// [0x007]=0x44000007 [0x00d]=0x6500000d [0x150]=0x10000150
|
||||
// [0x153]=0x09000153
|
||||
private static readonly uint[] CommandIds =
|
||||
{
|
||||
0x80000000u, 0x85000001u, 0x85000002u, 0x41000003u, 0x40000004u, 0x45000005u,
|
||||
0x45000006u, 0x44000007u, 0x40000008u, 0x40000009u, 0x4000000Au, 0x4000000Bu,
|
||||
0x4000000Cu, 0x6500000Du, 0x6500000Eu, 0x6500000Fu, 0x65000010u, 0x40000011u,
|
||||
0x41000012u, 0x41000013u, 0x41000014u, 0x40000015u, 0x40000016u, 0x40000017u,
|
||||
0x40000018u, 0x40000019u, 0x4000001Au, 0x4000001Bu, 0x4000001Cu, 0x4000001Du,
|
||||
0x4000001Eu, 0x4000001Fu, 0x40000020u, 0x40000021u, 0x40000022u, 0x40000023u,
|
||||
0x40000024u, 0x40000025u, 0x40000026u, 0x40000027u, 0x40000028u, 0x40000029u,
|
||||
0x4000002Au, 0x4000002Bu, 0x4000002Cu, 0x4000002Du, 0x4000002Eu, 0x4000002Fu,
|
||||
0x40000030u, 0x40000031u, 0x40000032u, 0x40000033u, 0x40000034u, 0x40000035u,
|
||||
0x40000036u, 0x40000037u, 0x40000038u, 0x40000039u, 0x2000003Au, 0x2500003Bu,
|
||||
0x8000003Cu, 0x8000003Du, 0x8000003Eu, 0x8000003Fu, 0x80000040u, 0x80000041u,
|
||||
0x80000042u, 0x80000043u, 0x80000044u, 0x80000045u, 0x80000046u, 0x80000047u,
|
||||
0x80000048u, 0x80000049u, 0x1000004Au, 0x1000004Bu, 0x1300004Cu, 0x1000004Du,
|
||||
0x1000004Eu, 0x1000004Fu, 0x10000050u, 0x10000051u, 0x10000052u, 0x10000053u,
|
||||
0x10000054u, 0x10000055u, 0x10000056u, 0x10000057u, 0x10000058u, 0x10000059u,
|
||||
0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, 0x1000005Eu, 0x1000005Fu,
|
||||
0x10000060u, 0x10000061u, 0x10000062u, 0x10000063u, 0x10000064u, 0x10000065u,
|
||||
0x10000066u, 0x10000067u, 0x10000068u, 0x10000069u, 0x1000006Au, 0x1000006Bu,
|
||||
0x1000006Cu, 0x1000006Du, 0x1000006Eu, 0x1000006Fu, 0x10000070u, 0x10000071u,
|
||||
0x10000072u, 0x10000073u, 0x10000074u, 0x10000075u, 0x10000076u, 0x10000077u,
|
||||
0x10000078u, 0x13000079u, 0x1300007Au, 0x1300007Bu, 0x1300007Cu, 0x1300007Du,
|
||||
0x1300007Eu, 0x1300007Fu, 0x13000080u, 0x13000081u, 0x13000082u, 0x13000083u,
|
||||
0x13000084u, 0x13000085u, 0x13000086u, 0x13000087u, 0x13000088u, 0x13000089u,
|
||||
0x1300008Au, 0x1300008Bu, 0x1300008Cu, 0x1300008Du, 0x1300008Eu, 0x1300008Fu,
|
||||
0x13000090u, 0x13000091u, 0x13000092u, 0x13000093u, 0x13000094u, 0x13000095u,
|
||||
0x13000096u, 0x13000097u, 0x13000098u, 0x13000099u, 0x1300009Au, 0x1200009Bu,
|
||||
0x1000009Cu, 0x1000009Du, 0x1000009Eu, 0x1000009Fu, 0x100000A0u, 0x100000A1u,
|
||||
0x080000A2u, 0x090000A3u, 0x090000A4u, 0x090000A5u, 0x090000A6u, 0x090000A7u,
|
||||
0x090000A8u, 0x080000A9u, 0x090000AAu, 0x090000ABu, 0x090000ACu, 0x090000ADu,
|
||||
0x090000AEu, 0x090000AFu, 0x090000B0u, 0x090000B1u, 0x0D0000B2u, 0x0D0000B3u,
|
||||
0x0D0000B4u, 0x080000B5u, 0x080000B6u, 0x080000B7u, 0x090000B8u, 0x090000B9u,
|
||||
0x0D0000BAu, 0x0D0000BBu, 0x0D0000BCu, 0x0D0000BDu, 0x0D0000BEu, 0x0D0000BFu,
|
||||
0x090000C0u, 0x0C0000C1u, 0x090000C2u, 0x090000C3u, 0x090000C4u, 0x0D0000C5u,
|
||||
0x090000C6u, 0x090000C7u, 0x090000C8u, 0x090000C9u, 0x130000CAu, 0x130000CBu,
|
||||
0x130000CCu, 0x100000CDu, 0x100000CEu, 0x100000CFu, 0x100000D0u, 0x100000D1u,
|
||||
0x100000D2u, 0x400000D3u, 0x120000D4u, 0x090000D5u, 0x090000D6u, 0x090000D7u,
|
||||
0x090000D8u, 0x090000D9u, 0x090000DAu, 0x090000DBu, 0x090000DCu, 0x090000DDu,
|
||||
0x090000DEu, 0x120000DFu, 0x400000E0u, 0x400000E1u, 0x100000E2u, 0x100000E3u,
|
||||
0x400000E4u, 0x400000E5u, 0x400000E6u, 0x090000E7u, 0x800000E8u, 0x800000E9u,
|
||||
0x430000EAu, 0x430000EBu, 0x430000ECu, 0x430000EDu, 0x430000EEu, 0x430000EFu,
|
||||
0x430000F0u, 0x430000F1u, 0x430000F2u, 0x430000F3u, 0x430000F4u, 0x430000F5u,
|
||||
0x430000F6u, 0x430000F7u, 0x430000F8u, 0x420000F9u, 0x430000FAu, 0x430000FBu,
|
||||
0x430000FCu, 0x430000FDu, 0x090000FEu, 0x090000FFu, 0x09000100u, 0x09000101u,
|
||||
0x09000102u, 0x09000103u, 0x09000104u, 0x09000105u, 0x09000106u, 0x09000107u,
|
||||
0x09000108u, 0x09000109u, 0x0900010Au, 0x0900010Bu, 0x0900010Cu, 0x0900010Du,
|
||||
0x1000010Eu, 0x0900010Fu, 0x09000110u, 0x09000111u, 0x09000112u, 0x09000113u,
|
||||
0x09000114u, 0x43000115u, 0x13000116u, 0x43000117u, 0x43000118u, 0x43000119u,
|
||||
0x0900011Au, 0x1000011Bu, 0x1000011Cu, 0x1000011Du, 0x1000011Eu, 0x1000011Fu,
|
||||
0x10000120u, 0x10000121u, 0x10000122u, 0x10000123u, 0x10000124u, 0x10000125u,
|
||||
0x10000126u, 0x10000127u, 0x10000128u, 0x10000129u, 0x1000012Au, 0x1000012Bu,
|
||||
0x1000012Cu, 0x1000012Du, 0x1000012Eu, 0x1000012Fu, 0x10000130u, 0x10000131u,
|
||||
0x13000132u, 0x40000133u, 0x40000134u, 0x40000135u, 0x40000136u, 0x10000137u,
|
||||
0x80000138u, 0x80000139u, 0x4300013Au, 0x4300013Bu, 0x4300013Cu, 0x4300013Du,
|
||||
0x4300013Eu, 0x4300013Fu, 0x43000140u, 0x43000141u, 0x43000142u, 0x43000143u,
|
||||
0x43000144u, 0x43000145u, 0x43000146u, 0x13000147u, 0x13000148u, 0x13000149u,
|
||||
0x1300014Au, 0x1300014Bu, 0x1300014Cu, 0x1300014Du, 0x1300014Eu, 0x1300014Fu,
|
||||
0x10000150u, 0x09000151u, 0x09000152u, 0x09000153u, 0x09000154u, 0x09000155u,
|
||||
0x09000156u, 0x09000157u, 0x09000158u, 0x09000159u, 0x0900015Au, 0x0900015Bu,
|
||||
0x0900015Cu, 0x0900015Du, 0x0900015Eu, 0x0900015Fu, 0x09000160u, 0x09000161u,
|
||||
0x10000162u, 0x10000163u, 0x10000164u, 0x09000165u, 0x09000166u, 0x09000167u,
|
||||
0x09000168u, 0x09000169u, 0x0900016Au, 0x0900016Bu, 0x0900016Cu, 0x0900016Du,
|
||||
0x1000016Eu, 0x1000016Fu, 0x10000170u, 0x10000171u, 0x10000172u, 0x10000173u,
|
||||
0x10000174u, 0x10000175u, 0x10000176u, 0x10000177u, 0x10000178u, 0x10000179u,
|
||||
0x1000017Au, 0x1000017Bu, 0x1000017Cu, 0x1000017Du, 0x1000017Eu, 0x1000017Fu,
|
||||
0x10000180u, 0x10000181u, 0x10000182u, 0x10000183u, 0x10000184u, 0x10000185u,
|
||||
0x10000186u, 0x10000187u, 0x10000188u, 0x10000189u, 0x1000018Au, 0x1000018Bu,
|
||||
0x1000018Cu, 0x1000018Du, 0x1000018Eu, 0x1000018Fu, 0x10000190u, 0x10000191u,
|
||||
0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u, 0x10000196u, 0x10000197u,
|
||||
};
|
||||
}
|
||||
|
|
@ -32,24 +32,10 @@ public static class ServerControlledLocomotion
|
|||
public const float MinSpeedMod = 0.25f;
|
||||
public const float MaxSpeedMod = 3.00f;
|
||||
|
||||
// Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command
|
||||
// (0x0052AA00) seeds forward motion before the next position update.
|
||||
public static LocomotionCycle PlanMoveToStart(
|
||||
float moveToSpeed = 1f,
|
||||
float runRate = 1f,
|
||||
bool canRun = true)
|
||||
{
|
||||
moveToSpeed = SanitizePositive(moveToSpeed);
|
||||
runRate = SanitizePositive(runRate);
|
||||
|
||||
if (!canRun)
|
||||
return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true);
|
||||
|
||||
return new LocomotionCycle(
|
||||
MotionCommand.RunForward,
|
||||
moveToSpeed * runRate,
|
||||
true);
|
||||
}
|
||||
// R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim
|
||||
// MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces
|
||||
// the cycle through the same sink every other motion uses).
|
||||
// PlanFromVelocity below survives (M16 register row, retires in R6).
|
||||
|
||||
public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
|
@ -38,10 +39,22 @@ public static class ShadowShapeBuilder
|
|||
/// every radius, height, and local offset.</param>
|
||||
/// <param name="hasPhysicsBsp">Predicate: does the GfxObj with this id
|
||||
/// have a non-null PhysicsBSP? Production: <c>id => cache.GetGfxObj(id)?.BSP?.Root is not null</c>.</param>
|
||||
/// <param name="partPoseOverride">#175: per-part pose override for the
|
||||
/// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the
|
||||
/// closed pose for doors). Retail collision tests each part's LIVE
|
||||
/// <c>CPhysicsPart</c> pose, which for an idle entity is the motion
|
||||
/// table's default state, NOT the Setup's placement frame — the two
|
||||
/// differ on e.g. the Facility Hub double door (Setup 0x02000C9D:
|
||||
/// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the
|
||||
/// closed pose is straight). Null / short lists fall back to the
|
||||
/// placement frame per part (entities with no motion table, and the
|
||||
/// CylSphere/Sphere shapes, are unaffected — retail poses those from
|
||||
/// the setup too).</param>
|
||||
public static IReadOnlyList<ShadowShape> FromSetup(
|
||||
Setup setup,
|
||||
float entScale,
|
||||
Func<uint, bool> hasPhysicsBsp)
|
||||
Func<uint, bool> hasPhysicsBsp,
|
||||
IReadOnlyList<Frame>? partPoseOverride = null)
|
||||
{
|
||||
if (setup is null) throw new ArgumentNullException(nameof(setup));
|
||||
if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp));
|
||||
|
|
@ -84,15 +97,21 @@ public static class ShadowShapeBuilder
|
|||
}
|
||||
|
||||
// 3. Parts — one BSP shape per part with a non-null PhysicsBSP.
|
||||
// Pose priority per part: partPoseOverride (the motion-table
|
||||
// default-state pose, #175) → placement frame → identity.
|
||||
AnimationFrame? placementFrame = ResolvePlacementFrame(setup);
|
||||
for (int i = 0; i < setup.Parts.Count; i++)
|
||||
{
|
||||
uint gfxId = (uint)setup.Parts[i];
|
||||
if (!hasPhysicsBsp(gfxId)) continue;
|
||||
|
||||
Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
|
||||
? placementFrame.Frames[i]
|
||||
: new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
Frame partFrame;
|
||||
if (partPoseOverride is not null && i < partPoseOverride.Count)
|
||||
partFrame = partPoseOverride[i];
|
||||
else if (placementFrame is not null && i < placementFrame.Frames.Count)
|
||||
partFrame = placementFrame.Frames[i];
|
||||
else
|
||||
partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity };
|
||||
|
||||
// BSP radius default; caller substitutes the real BoundingSphere.Radius
|
||||
// at registration time when available. Loose-but-safe broadphase value.
|
||||
|
|
@ -111,6 +130,84 @@ public static class ShadowShapeBuilder
|
|||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #185: build BSP shapes for a landblock-baked multi-part entity (buildings,
|
||||
/// stair runs, fences, rock clusters) from its per-part <see cref="MeshRef"/>s,
|
||||
/// for registration via <see cref="ShadowObjectRegistry.RegisterMultiPart"/>
|
||||
/// under the entity's SINGLE unique id.
|
||||
///
|
||||
/// <para>
|
||||
/// Replaces the former per-part <c>Register(entity.Id * 256u + partIndex)</c>
|
||||
/// (GameWindow.cs) whose <c>* 256u</c> OVERFLOWED uint32 for class-prefixed
|
||||
/// landblock ids (<c>0x40</c>/<c>0x80</c>/<c>0xC0</c>…): the overflow dropped
|
||||
/// the prefix byte, so different-class entities sharing the low 24 bits
|
||||
/// collided on one shadow part-id and <c>Register</c>'s deregister-then-insert
|
||||
/// silently overwrote one entity's collision geometry — the #185 "invisible
|
||||
/// wall half-way up the stairs" (rendered steps with no collision).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail anchor: a multi-part object is one <c>CPhysicsObj</c> + <c>CPartArray</c>;
|
||||
/// <c>CPhysicsObj::add_shadows_to_cells</c> (0x00514ae0) → <c>CPartArray::AddPartsShadow</c>
|
||||
/// walks the part array under the single object — no synthetic per-part id.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Each part's local transform comes from its <see cref="MeshRef.PartTransform"/>
|
||||
/// (root-relative), decomposed to LocalPosition/LocalRotation/Scale;
|
||||
/// <c>RegisterMultiPart</c> reconstructs the world placement identically
|
||||
/// (<c>entityWorldPos + rotate(LocalPosition, entityWorldRot)</c>). Building
|
||||
/// shells are excluded — they collide via the per-LandCell building channel
|
||||
/// (<c>CSortCell::find_collisions</c>), not as shadow objects.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="meshRefs">The entity's per-part mesh references.</param>
|
||||
/// <param name="isBuildingShell">True for <c>LandBlockInfo.Buildings[]</c> shells.</param>
|
||||
/// <param name="getGfxObj">Resolves a GfxObj id to its cached physics (BSP +
|
||||
/// bounding sphere). Production: <c>id => cache.GetGfxObj(id)</c>.</param>
|
||||
public static List<ShadowShape> FromLandblockBspParts(
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
bool isBuildingShell,
|
||||
Func<uint, GfxObjPhysics?> getGfxObj)
|
||||
{
|
||||
if (getGfxObj is null) throw new ArgumentNullException(nameof(getGfxObj));
|
||||
|
||||
var shapes = new List<ShadowShape>();
|
||||
// Building shells collide via the building channel (retail), not shadow objects.
|
||||
if (isBuildingShell || meshRefs is null) return shapes;
|
||||
|
||||
foreach (var meshRef in meshRefs)
|
||||
{
|
||||
var phys = getGfxObj(meshRef.GfxObjId);
|
||||
if (phys?.BSP?.Root is null) continue; // no physics BSP → nothing to collide
|
||||
|
||||
// PartTransform is root-relative; decompose to local pos/rot/scale.
|
||||
if (!Matrix4x4.Decompose(meshRef.PartTransform,
|
||||
out var pScale, out var pRot, out var pPos))
|
||||
{
|
||||
pScale = Vector3.One;
|
||||
pRot = Quaternion.Identity;
|
||||
pPos = new Vector3(meshRef.PartTransform.M41,
|
||||
meshRef.PartTransform.M42,
|
||||
meshRef.PartTransform.M43);
|
||||
}
|
||||
|
||||
float partScale = pScale.X > 0f ? pScale.X : 1f; // AC objects are uniformly scaled
|
||||
float localRadius = phys.BoundingSphere?.Radius ?? 1f;
|
||||
|
||||
shapes.Add(new ShadowShape(
|
||||
GfxObjId: meshRef.GfxObjId,
|
||||
LocalPosition: pPos,
|
||||
LocalRotation: pRot,
|
||||
Scale: partScale,
|
||||
CollisionType: ShadowCollisionType.BSP,
|
||||
Radius: localRadius * partScale,
|
||||
CylHeight: 0f));
|
||||
}
|
||||
|
||||
return shapes;
|
||||
}
|
||||
|
||||
/// <summary>Resolve the placement frame in priority Resting → Default →
|
||||
/// first available. Mirrors <c>SetupMesh.Flatten</c>'s convention.</summary>
|
||||
private static AnimationFrame? ResolvePlacementFrame(Setup setup)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -271,6 +271,129 @@ public static class RenderingDiagnostics
|
|||
public static bool ProbeLightEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the
|
||||
/// <c>[light]</c> counts could not provide (the #176/#177 discriminator: the bug
|
||||
/// lived in set MEMBERSHIP, not counts). When true,
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> emits ONE rate-limited
|
||||
/// <c>[indoor-light]</c> line describing the point-light pool
|
||||
/// (see <see cref="EmitIndoorLight"/>):
|
||||
/// <code>
|
||||
/// [indoor-light] pool=<M> cellLess=<K> registered=<R> capped=<R-M>
|
||||
/// byCell=[0x<id>:<count>,...]
|
||||
/// </code>
|
||||
/// #176 correction (2026-07-06): the pool became retail's RESIDENT-cell
|
||||
/// collection capped nearest-the-PLAYER — the earlier gaze-coupled scoping
|
||||
/// (rebuilding the pool from a freshly re-flooded CAMERA-seeded set,
|
||||
/// <c>c500912b</c>) was the #176 flicker mechanism and was deleted.
|
||||
/// A7.L1 (2026-07-09): visible-cell scoping is BACK, but sourced differently —
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> now takes an optional
|
||||
/// <c>visibleCells</c> filter that <c>GameWindow</c> feeds from LAST FRAME's
|
||||
/// already-rendered <c>RetailPViewFrameResult.DrawableCells</c> (one frame of
|
||||
/// latency, no independent re-flood, no callback threaded into DrawInside) —
|
||||
/// fixes the Town Network starvation case (463 fixtures, a wall-adjacent
|
||||
/// corridor's fixtures out-ranking the player's own room in raw Euclidean
|
||||
/// distance) without reproducing the #176 mechanism. <c>byCell</c> shows which
|
||||
/// cells' lights are pooled; <c>cellLess==pool</c> in a fixture-rich room still
|
||||
/// means cell tagging FAILED (ParentCellId not flowing).
|
||||
/// Output-only, inert when off. Initial state from <c>ACDREAM_PROBE_INDOOR_LIGHT=1</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeIndoorLightEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1";
|
||||
|
||||
/// <summary>
|
||||
/// #176 seam-floor flicker decisive probe (2026-07-06). RenderDoc pixel-history
|
||||
/// is infeasible on this pipeline (RenderDoc does not support
|
||||
/// GL_ARB_bindless_texture and hides it from the app → our mandatory-modern
|
||||
/// startup gate throws), so this is the in-engine equivalent at draw
|
||||
/// granularity: every draw route that can put geometry at the corridor seam
|
||||
/// floor reports itself, plus the per-cell light sets ACTUALLY applied.
|
||||
/// When set, four line families emit (all change-deduped, Console):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>[seam-cell]</c> — <c>EnvCellRenderer.Render</c>
|
||||
/// (opaque pass): per target cell — in-filter flag, per-gfx instance count +
|
||||
/// transform translation (shows the +0.02 shell lift), per-batch
|
||||
/// cull/translucency, and the cell's 8-light set RESOLVED to identities
|
||||
/// (owner cell + intensity — raw snapshot indices shuffle when the pool
|
||||
/// rebuilds, so identities are the stable signature). Two instances of one
|
||||
/// (cell,gfx) = the runtime double-draw; light identities flipping with the
|
||||
/// flood = the snapshot-scope mechanism.</description></item>
|
||||
/// <item><description><c>[seam-snap]</c> — the point-light snapshot's HOT
|
||||
/// subset (intensity ≥ 50: the portal purples; fixtures are ~1–2, the viewer
|
||||
/// fill 2.25) with owner cells, emitted with the block.</description></item>
|
||||
/// <item><description><c>[seam-ent]</c> — <c>WbDrawDispatcher</c>: any entity
|
||||
/// parented to a target cell — position, culled/slot, resolved light set. A
|
||||
/// floor-coincident entity (plate/static) would be the z-fight's second draw;
|
||||
/// the player entity doubles as the probe's positive control.</description></item>
|
||||
/// <item><description><c>[seam-mask]</c> — <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>:
|
||||
/// every portal depth fan drawn in a target cell. A sealed dungeon must show
|
||||
/// ZERO (seals fire only for OtherCellId==0xFFFF) — any line is a finding.</description></item>
|
||||
/// </list>
|
||||
/// Value <c>1</c> = the default Facility Hub target set (corridor 0x8A020164 +
|
||||
/// seam neighbors 0165/016E/017A + under-hall 011E + portal-light cells
|
||||
/// 0118/0119); a comma-separated hex cell-id list overrides it. Throwaway
|
||||
/// apparatus — strip when #176 closes. Initial state from
|
||||
/// <c>ACDREAM_PROBE_SEAMDRAW</c>.
|
||||
/// </summary>
|
||||
public static bool ProbeSeamDrawEnabled { get; set; } =
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||
|
||||
/// <summary>Target cell ids for the #176 seam-draw probe (see
|
||||
/// <see cref="ProbeSeamDrawEnabled"/>). Full 32-bit cell ids.</summary>
|
||||
public static IReadOnlySet<uint> SeamDrawTargetCells { get; } =
|
||||
ParseSeamDrawTargets(Environment.GetEnvironmentVariable("ACDREAM_PROBE_SEAMDRAW"));
|
||||
|
||||
/// <summary>
|
||||
/// #176 stripe-hunt shader isolation mode (<c>ACDREAM_LIGHT_DEBUG</c>) —
|
||||
/// throwaway diagnostic, uploaded as <c>uLightDebug</c> by EnvCellRenderer +
|
||||
/// WbDrawDispatcher each pass. 0 = off; 1 = ambient-only vertex lighting
|
||||
/// (all point/sun contributions killed); 2 = DYNAMIC point lights killed
|
||||
/// (the intensity-100 portal purples + the viewer fill off; statics stay);
|
||||
/// 3 = raw vLit visualization in the fragment shader (texture ignored).
|
||||
/// Discriminates lighting-driven stripes (gone at 1/2, visible in the field
|
||||
/// at 3) from texture/per-pixel machinery (survive 1). Settable for a
|
||||
/// future DebugPanel mirror.
|
||||
/// </summary>
|
||||
public static int LightDebugMode { get; set; } =
|
||||
int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), out var ldm) ? ldm : 0;
|
||||
|
||||
/// <summary>
|
||||
/// #176 stripe-hunt isolation (<c>ACDREAM_CLIP_DEBUG=1</c>) — throwaway
|
||||
/// diagnostic. When true, the EnvCell SHELL pass maps every instance to clip
|
||||
/// slot 0 (the reserved no-clip region) instead of its cell's portal-slice
|
||||
/// region — i.e. shells draw WHOLE, retail's shape (retail never clips cell
|
||||
/// geometry; aperture exactness comes from the seal/punch depth writes +
|
||||
/// far→near order, PView::DrawCells 0x005a4840). Discriminator for the
|
||||
/// camera-against-wall stripe/hatch pattern: a knife-edge slice region (eye
|
||||
/// on a portal plane / inside a wall) yields a clip plane near-parallel to
|
||||
/// large surfaces → interpolated gl_ClipDistance ≈ 0 across them → per-pixel
|
||||
/// sign dither = the hatch. Stripes gone with this on = the shell trim is
|
||||
/// the mechanism. Entity/dispatcher clip routing is NOT affected.
|
||||
/// </summary>
|
||||
public static bool ClipDebugNoShellTrim { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_CLIP_DEBUG") == "1";
|
||||
|
||||
/// <summary>Parse ACDREAM_PROBE_SEAMDRAW: "1"/"true"/empty → the default #176
|
||||
/// Facility Hub set; otherwise a comma-separated hex cell-id list (same forgiving
|
||||
/// grammar as <see cref="ParseDumpEntityIds"/>). Internal for unit tests.</summary>
|
||||
internal static IReadOnlySet<uint> ParseSeamDrawTargets(string? raw)
|
||||
{
|
||||
// Default: the corridor + the coplanar-sweep seed neighbors + the under-hall
|
||||
// + the two portal-weenie cells whose intensity-100 purple lights are the
|
||||
// wedge's source (handoff 2026-07-06-176-seam-floor-zfight-handoff.md).
|
||||
var defaults = new HashSet<uint>
|
||||
{
|
||||
0x8A020164u, 0x8A020165u, 0x8A02016Eu, 0x8A02017Au,
|
||||
0x8A02011Eu, 0x8A020118u, 0x8A020119u,
|
||||
};
|
||||
if (string.IsNullOrWhiteSpace(raw)) return defaults;
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed == "1" || trimmed.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
return defaults;
|
||||
var parsed = ParseDumpEntityIds(raw);
|
||||
return parsed.Count > 0 ? parsed : defaults;
|
||||
}
|
||||
|
||||
// Cell-change gate for EmitVis. The probe fires once per distinct root cell
|
||||
// so launch.log stays readable under motion (the per-frame call is a no-op
|
||||
// when the root is unchanged). Sentinel 0 = "no root yet" — the first real
|
||||
|
|
@ -444,13 +567,73 @@ public static class RenderingDiagnostics
|
|||
|
||||
float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f;
|
||||
Console.WriteLine(string.Format(ci,
|
||||
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}",
|
||||
"[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}",
|
||||
ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle,
|
||||
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist));
|
||||
ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist,
|
||||
ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0));
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
|
||||
// Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval).
|
||||
private static long _lastIndoorLightEmitTicks;
|
||||
|
||||
/// <summary>
|
||||
/// A7.L1 — emit ONE rate-limited <c>[indoor-light]</c> line describing the
|
||||
/// point-light pool: the SET COMPOSITION the <c>[light]</c> counts can't show.
|
||||
/// Cheap no-op when <see cref="ProbeIndoorLightEnabled"/> is false; otherwise
|
||||
/// fires at most once per second. Called from
|
||||
/// <c>LightManager.BuildPointLightSnapshot</c> — <paramref name="scopedSnapshot"/>
|
||||
/// already reflects any last-frame visible-cell scoping (A7.L1, 2026-07-09).
|
||||
/// </summary>
|
||||
/// <param name="allRegistered">Every registered light (<c>LightManager._all</c>).</param>
|
||||
/// <param name="scopedSnapshot">The point-light pool just built.</param>
|
||||
public static void EmitIndoorLight(
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> allRegistered,
|
||||
IReadOnlyList<AcDream.Core.Lighting.LightSource> scopedSnapshot)
|
||||
{
|
||||
if (!ProbeIndoorLightEnabled) return;
|
||||
|
||||
long now = DateTime.UtcNow.Ticks;
|
||||
if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks)
|
||||
return;
|
||||
_lastIndoorLightEmitTicks = now;
|
||||
|
||||
int registeredLitPoints = 0;
|
||||
foreach (var l in allRegistered)
|
||||
if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++;
|
||||
|
||||
int pool = scopedSnapshot.Count;
|
||||
int cellLess = 0;
|
||||
var hist = new Dictionary<uint, int>();
|
||||
foreach (var l in scopedSnapshot)
|
||||
{
|
||||
if (l.CellId == 0) cellLess++;
|
||||
hist.TryGetValue(l.CellId, out var c);
|
||||
hist[l.CellId] = c + 1;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder(220);
|
||||
sb.Append("[indoor-light] pool=").Append(pool);
|
||||
sb.Append(" cellLess=").Append(cellLess);
|
||||
sb.Append(" registered=").Append(registeredLitPoints);
|
||||
// Lights dropped by the MaxGlobalLights nearest-player cap (0 in Hub-scale
|
||||
// rooms for dynamics — statics beyond the 128th-nearest are out of range).
|
||||
sb.Append(" capped=").Append(registeredLitPoints - pool);
|
||||
sb.Append(" byCell=[");
|
||||
const int MaxCells = 12;
|
||||
int shown = 0;
|
||||
foreach (var kv in hist)
|
||||
{
|
||||
if (shown >= MaxCells) { sb.Append(",..."); break; }
|
||||
if (shown > 0) sb.Append(',');
|
||||
sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value);
|
||||
shown++;
|
||||
}
|
||||
sb.Append(']');
|
||||
Console.WriteLine(sb.ToString());
|
||||
}
|
||||
|
||||
private static bool _probeEnvCellEnabled =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1";
|
||||
|
||||
|
|
@ -567,4 +750,21 @@ public static class RenderingDiagnostics
|
|||
/// </summary>
|
||||
public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved)
|
||||
=> renderRootResolved && IsEnvCellId(playerCellId);
|
||||
|
||||
/// <summary>
|
||||
/// MP0 (2026-07-05) — master toggle for the permanent frame profiler
|
||||
/// (<c>AcDream.App.Diagnostics.FrameProfiler</c>): CPU frame time
|
||||
/// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution,
|
||||
/// per-frame allocation counters, reported as one <c>[frame-prof]</c>
|
||||
/// line every ~5 s. Permanent apparatus (every MP-track gate reads it) —
|
||||
/// do NOT strip with session probes. The whole-frame GPU query is
|
||||
/// self-disabled while <c>ACDREAM_WB_DIAG=1</c> (GL forbids nested
|
||||
/// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under
|
||||
/// that flag — the 2026-06-23 "separate flags" measurement lesson).
|
||||
/// Initial state from <c>ACDREAM_FRAME_PROF=1</c>; runtime-toggleable
|
||||
/// via the DebugPanel mirror (<c>DebugVM.FrameProf</c>).
|
||||
/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5.
|
||||
/// </summary>
|
||||
public static bool FrameProfEnabled { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1";
|
||||
}
|
||||
|
|
|
|||
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
|
||||
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
|
||||
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
|
||||
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
|
||||
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
|
||||
/// <c>Time</c> seconds, ticked every frame rather than retail's per-object
|
||||
/// physics tick (behaviorally identical — both re-evaluate the same
|
||||
/// elapsed/duration ratio every simulated step).
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
|
||||
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
|
||||
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
|
||||
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
|
||||
/// multiplier at the point of use.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
|
||||
/// ~0.2 ms, the target value is applied immediately with no ramp
|
||||
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
|
||||
/// is that exact retail constant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyFadeManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
|
||||
/// 0x00511730): a hook <c>Time</c> at or below this is applied
|
||||
/// instantly, no ramp.
|
||||
/// </summary>
|
||||
public const float TranslucencyInstantEpsilon = 0.000199999995f;
|
||||
|
||||
private sealed class Fade
|
||||
{
|
||||
public float Elapsed;
|
||||
public float Duration;
|
||||
public float Start;
|
||||
public float End;
|
||||
}
|
||||
|
||||
// entityId -> partIndex -> in-flight ramp.
|
||||
private readonly Dictionary<uint, Dictionary<uint, Fade>> _activeFades = new();
|
||||
|
||||
// entityId -> partIndex -> current committed translucency value
|
||||
// ([0,1], retail scale). Persists after a fade completes so later
|
||||
// frames keep reading the settled value.
|
||||
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
|
||||
|
||||
/// <summary>
|
||||
/// Start (or replace) a translucency ramp for one Setup part of one
|
||||
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
|
||||
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
|
||||
/// immediately; otherwise the value ramps from
|
||||
/// <paramref name="start"/> to <paramref name="end"/> across
|
||||
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
|
||||
/// </summary>
|
||||
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
|
||||
{
|
||||
if (time <= TranslucencyInstantEpsilon)
|
||||
{
|
||||
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
|
||||
activeForEntity.Remove(partIndex);
|
||||
Commit(entityId, partIndex, end);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_activeFades.TryGetValue(entityId, out var fades))
|
||||
{
|
||||
fades = new Dictionary<uint, Fade>();
|
||||
_activeFades[entityId] = fades;
|
||||
}
|
||||
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
|
||||
|
||||
// FPHook's first Execute call (at elapsed=0) already reports
|
||||
// value=start — commit it now so a reader on the same frame the
|
||||
// hook fired sees the ramp's starting value, not the stale prior one.
|
||||
Commit(entityId, partIndex, start);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance every in-flight fade by <paramref name="dt"/> seconds
|
||||
/// (plain linear interpolation, no easing — matches
|
||||
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
|
||||
/// the bitwise-exact <c>End</c> value and stops advancing (retail
|
||||
/// deletes the FPHook at that point); the committed value stays
|
||||
/// readable via <see cref="TryGetCurrentValue"/>.
|
||||
/// </summary>
|
||||
public void AdvanceAll(float dt)
|
||||
{
|
||||
if (_activeFades.Count == 0) return;
|
||||
|
||||
List<uint>? emptyEntities = null;
|
||||
foreach (var (entityId, fades) in _activeFades)
|
||||
{
|
||||
List<uint>? finished = null;
|
||||
foreach (var (partIndex, fade) in fades)
|
||||
{
|
||||
fade.Elapsed += dt;
|
||||
float t = fade.Duration <= TranslucencyInstantEpsilon
|
||||
? 1f
|
||||
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
|
||||
|
||||
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
|
||||
Commit(entityId, partIndex, value);
|
||||
|
||||
if (t >= 1f)
|
||||
{
|
||||
finished ??= new List<uint>();
|
||||
finished.Add(partIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (finished is not null)
|
||||
{
|
||||
foreach (var partIndex in finished)
|
||||
fades.Remove(partIndex);
|
||||
if (fades.Count == 0)
|
||||
{
|
||||
emptyEntities ??= new List<uint>();
|
||||
emptyEntities.Add(entityId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyEntities is not null)
|
||||
foreach (var entityId in emptyEntities)
|
||||
_activeFades.Remove(entityId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current committed translucency value ([0,1], retail
|
||||
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
|
||||
/// if this part has never had a fade start (caller should fall back
|
||||
/// to the dat-authored default — a no-op).
|
||||
/// </summary>
|
||||
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
|
||||
{
|
||||
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
|
||||
return true;
|
||||
value = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
|
||||
public void ClearEntity(uint entityId)
|
||||
{
|
||||
_activeFades.Remove(entityId);
|
||||
_committed.Remove(entityId);
|
||||
}
|
||||
|
||||
private void Commit(uint entityId, uint partIndex, float value)
|
||||
{
|
||||
if (!_committed.TryGetValue(entityId, out var parts))
|
||||
{
|
||||
parts = new Dictionary<uint, float>();
|
||||
_committed[entityId] = parts;
|
||||
}
|
||||
parts[partIndex] = value;
|
||||
}
|
||||
}
|
||||
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
|
||||
/// <see cref="TranslucencyFadeManager"/>. Retail's
|
||||
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
|
||||
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
|
||||
/// this sink is that same one-line forward.
|
||||
///
|
||||
/// <para>
|
||||
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
|
||||
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
|
||||
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
|
||||
/// state, zero rendering coupling — and acdream's collision-passthrough
|
||||
/// already applies server-authoritative via the <c>SetState</c> wire
|
||||
/// message, independent of this hook firing client-side.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TranslucencyHookSink : IAnimationHookSink
|
||||
{
|
||||
private readonly TranslucencyFadeManager _fades;
|
||||
|
||||
public TranslucencyHookSink(TranslucencyFadeManager fades)
|
||||
{
|
||||
_fades = fades ?? throw new ArgumentNullException(nameof(fades));
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
{
|
||||
if (hook is not TransparentPartHook tph) return;
|
||||
_fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time);
|
||||
}
|
||||
}
|
||||
|
|
@ -158,17 +158,104 @@ public sealed class ParticleSystem : IParticleSystem
|
|||
}
|
||||
}
|
||||
|
||||
public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive()
|
||||
{
|
||||
foreach (var handle in _handleOrder)
|
||||
{
|
||||
if (!_byHandle.TryGetValue(handle, out var em))
|
||||
continue;
|
||||
/// <summary>
|
||||
/// Enumerate every live particle across every active emitter as
|
||||
/// (emitter, particle-index) pairs, in emitter-spawn order.
|
||||
///
|
||||
/// <para>
|
||||
/// MP-Alloc (2026-07-05): this used to be a C# iterator block (a
|
||||
/// compiler-generated heap-allocated state machine, `yield return`),
|
||||
/// allocated fresh on every call. <see cref="ParticleRenderer.Draw"/>
|
||||
/// calls this once per pass and there are up to ~11 passes per frame
|
||||
/// (sky pre/post, scene, per-visible-cell, dynamics, unattached), so
|
||||
/// this was 11 iterator allocations per frame even with zero particles
|
||||
/// on screen. Returns a <see cref="LiveParticleEnumerable"/> struct
|
||||
/// instead: `foreach` over it uses the struct enumerator directly (no
|
||||
/// allocation), while LINQ / test callers that need
|
||||
/// <see cref="IEnumerable{T}"/> (`.ToList()`, `.Single()`, etc.) still
|
||||
/// work via the explicit interface implementation — those call sites
|
||||
/// are test-only, not the per-frame render path this task targets.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public LiveParticleEnumerable EnumerateLive() => new(this);
|
||||
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
/// <summary>
|
||||
/// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the
|
||||
/// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a
|
||||
/// zero-allocation struct enumerator; falls back to a boxed iterator
|
||||
/// only when consumed through the <see cref="IEnumerable{T}"/> surface
|
||||
/// (LINQ, test helpers).
|
||||
/// </summary>
|
||||
public readonly struct LiveParticleEnumerable : IEnumerable<(ParticleEmitter Emitter, int Index)>
|
||||
{
|
||||
private readonly ParticleSystem _owner;
|
||||
internal LiveParticleEnumerable(ParticleSystem owner) => _owner = owner;
|
||||
|
||||
public Enumerator GetEnumerator() => new(_owner);
|
||||
|
||||
IEnumerator<(ParticleEmitter Emitter, int Index)> IEnumerable<(ParticleEmitter Emitter, int Index)>.GetEnumerator()
|
||||
=> EnumerateLiveBoxed(_owner).GetEnumerator();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
|
||||
=> EnumerateLiveBoxed(_owner).GetEnumerator();
|
||||
|
||||
private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner)
|
||||
{
|
||||
foreach (var handle in owner._handleOrder)
|
||||
{
|
||||
if (em.Particles[i].Alive)
|
||||
yield return (em, i);
|
||||
if (!owner._byHandle.TryGetValue(handle, out var em))
|
||||
continue;
|
||||
|
||||
for (int i = 0; i < em.Particles.Length; i++)
|
||||
{
|
||||
if (em.Particles[i].Alive)
|
||||
yield return (em, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Zero-allocation struct enumerator for the `foreach` fast path.</summary>
|
||||
public struct Enumerator
|
||||
{
|
||||
private readonly ParticleSystem _owner;
|
||||
private int _handleIdx;
|
||||
private ParticleEmitter? _currentEmitter;
|
||||
private int _particleIdx;
|
||||
|
||||
internal Enumerator(ParticleSystem owner)
|
||||
{
|
||||
_owner = owner;
|
||||
_handleIdx = -1;
|
||||
_currentEmitter = null;
|
||||
_particleIdx = -1;
|
||||
}
|
||||
|
||||
public (ParticleEmitter Emitter, int Index) Current => (_currentEmitter!, _particleIdx);
|
||||
|
||||
public bool MoveNext()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (_currentEmitter is not null)
|
||||
{
|
||||
for (_particleIdx++; _particleIdx < _currentEmitter.Particles.Length; _particleIdx++)
|
||||
{
|
||||
if (_currentEmitter.Particles[_particleIdx].Alive)
|
||||
return true;
|
||||
}
|
||||
_currentEmitter = null;
|
||||
}
|
||||
|
||||
_handleIdx++;
|
||||
if (_handleIdx >= _owner._handleOrder.Count)
|
||||
return false;
|
||||
|
||||
if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em))
|
||||
continue;
|
||||
|
||||
_currentEmitter = em;
|
||||
_particleIdx = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
49
src/AcDream.Core/World/InteriorEntityIdAllocator.cs
Normal file
49
src/AcDream.Core/World/InteriorEntityIdAllocator.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Packs a stable, collision-free id base for a landblock's dat-hydrated
|
||||
/// EnvCell-interior entities.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this exists (#190, 2026-07-09).</b> The id space is
|
||||
/// <c>0x4X XXXXX</c> — the top NIBBLE fixed at 4, distinguishing interior
|
||||
/// statics from live weenies (ids < 0x40000000), procedural scenery
|
||||
/// (0x80000000+, bit 31 set), and landblock stabs (0xC0000000+). Nothing
|
||||
/// decodes a landblock's X/Y back out of an entity id — every consumer
|
||||
/// (GameWindow.cs's <c>_isOutdoorMesh</c>/<c>_isLandblockStab</c>
|
||||
/// classification) reads only a THRESHOLD or a full-byte PREFIX — so the
|
||||
/// internal field widths below are free to change without touching any
|
||||
/// consumer.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// 28 remaining bits split X(8) / Y(8) / counter(12). X and Y stay at a
|
||||
/// full byte each to match AC's 0-255 landblock grid — shrinking either
|
||||
/// reintroduces #119's cross-landblock aliasing (two DIFFERENT landblocks
|
||||
/// sharing one id space). The prior split (X8/Y8/counter8, "0x40XXYY##")
|
||||
/// fixed #119 but left only 256 ids for ONE landblock's entire interior
|
||||
/// static population. The Town Network hub (205 cells, one landblock)
|
||||
/// already reached 248 before the #79/#93 A7.L1 light-carrier hydration
|
||||
/// fix, which pushed it to 277 — PAST the 8-bit boundary, silently
|
||||
/// aliasing into the NEXT landblock's reserved Y-byte (id 0x40000815 read
|
||||
/// back as landblock Y=0x08, not the true Y=0x07): the exact #119 bug,
|
||||
/// reincarnated by entity COUNT instead of a computation bug. Shrinking
|
||||
/// the fixed prefix from a full byte (0x40) to its top nibble (0x4_) frees
|
||||
/// 4 bits for the counter (8→12 bits, 256→4096 capacity) — ~15x headroom
|
||||
/// over the observed count.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class InteriorEntityIdAllocator
|
||||
{
|
||||
/// <summary>Per-landblock counter budget (12 bits). A landblock hydrating
|
||||
/// more interior entities than this would alias into the next Y-slot —
|
||||
/// exactly the #190 bug, just at a higher threshold. Callers should log
|
||||
/// loudly (never silently wrap) if this is ever exceeded.</summary>
|
||||
public const uint MaxCounter = 0xFFFu;
|
||||
|
||||
/// <summary>The first id in this landblock's reserved range (counter=0).
|
||||
/// Add a counter in <c>[0, MaxCounter]</c> to allocate a unique entity id
|
||||
/// within the landblock.</summary>
|
||||
public static uint Base(uint landblockX, uint landblockY)
|
||||
=> 0x40000000u | ((landblockX & 0xFFu) << 20) | ((landblockY & 0xFFu) << 12);
|
||||
}
|
||||
51
src/AcDream.Core/World/StreamingReadinessGate.cs
Normal file
51
src/AcDream.Core/World/StreamingReadinessGate.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// Should the streaming/render pipeline be allowed to run this frame?
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Why this exists (#192, 2026-07-09).</b> Login to a non-Holtburg position
|
||||
/// sometimes showed stabs/scenery floating in the wrong place. Root cause: the
|
||||
/// old gate opened as soon as <c>WorldSession</c> reached <c>InWorld</c> — but
|
||||
/// <c>InWorld</c> fires immediately after the login handshake completes
|
||||
/// (<c>WorldSession.cs:608</c>), BEFORE the player's own spawn <c>CreateObject</c>
|
||||
/// (which carries their real position) has even arrived over the network. Any
|
||||
/// landblock streamed in that window baked its world offset from
|
||||
/// <c>GameWindow._liveCenterX/Y</c>'s startup placeholder (Holtburg, 0xA9B4) — a
|
||||
/// real position, not a "not known yet" sentinel — because the streaming worker
|
||||
/// reads that field fresh at build time with no way to know it's still a guess.
|
||||
/// That stale-baked geometry still got applied once its build finished, landing
|
||||
/// wherever the guess put it relative to whatever streamed in afterward using the
|
||||
/// corrected center.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// The fix is not "pick a better placeholder" — any placeholder racing against
|
||||
/// the real answer reproduces the same bug. It's gating on whether the real
|
||||
/// position is actually known yet (<paramref name="liveCenterKnown"/>, set true
|
||||
/// exactly when the player's own spawn is processed), independent of session
|
||||
/// state. <paramref name="liveInWorld"/> alone is deliberately no longer
|
||||
/// sufficient in live mode.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class StreamingReadinessGate
|
||||
{
|
||||
/// <param name="liveModeEnabled">Whether a live ACE session is configured at all.
|
||||
/// False (offline / fly-camera) always streams — there is no real position to
|
||||
/// wait for, so no race exists.</param>
|
||||
/// <param name="chaseModeEverEntered">Latches true the first time chase mode
|
||||
/// engages and stays true for the rest of the session — well past the login
|
||||
/// race window.</param>
|
||||
/// <param name="liveInWorld">The live session has completed the login handshake
|
||||
/// (<c>WorldSession.State.InWorld</c>). Necessary but NOT sufficient on its
|
||||
/// own — see class remarks.</param>
|
||||
/// <param name="liveCenterKnown">The player's own spawn <c>CreateObject</c> has
|
||||
/// been received and processed, so <c>_liveCenterX/Y</c> reflects a real,
|
||||
/// server-confirmed position rather than the startup placeholder.</param>
|
||||
public static bool ShouldStream(
|
||||
bool liveModeEnabled, bool chaseModeEverEntered, bool liveInWorld, bool liveCenterKnown)
|
||||
{
|
||||
bool isWaitingForLogin = liveModeEnabled && !chaseModeEverEntered;
|
||||
return !isWaitingForLogin || (liveInWorld && liveCenterKnown);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue