fix(physics): close #315 — cache the remote-arm callbacks instead of allocating per packet
The OnPosition collapse (previous commit) converged RunRemoteArmTail's three duplicated call sites into one, which is what makes caching worthwhile: one cached pair of delegates now serves every remote guid instead of a fresh closure allocated on every accepted remote Position (5-10 Hz per remote), regardless of whether the packet was a teleport. RunRemoteArmTail's signature changes from a caller-constructed `Func<bool> isCurrentPositionOwner` closure to two plain value parameters (`ulong positionAuthorityVersion`, `WorldEntity? expectedEntity`). It stamps five per-packet scratch fields (`_remoteArmCanonical`, `_remoteArmMotion`, `_remoteArmPositionRecord`, `_remoteArmPositionAuthorityVersion`, `_remoteArmExpectedEntity`) from its own parameters, then passes the two CACHED delegates into ApplyRemoteContactRouting. Observably identical: the currency check reads the exact same positionRecord/positionAuthorityVersion/expectedEntity triple either way. Deviation from a bare cached-Func<bool>-field design, and why: UpdateFrameOrchestratorTests.ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks asserts every typed production owner (LiveEntityNetworkUpdateController included) carries zero Delegate-typed fields — the GameWindow decomposition campaign's guard against a callback silently smuggling a window reference back onto one of these owners. Neither cached delegate here touches a window (both are bound to this controller alone), but the rule is written as a blanket field-type check, not a window-specific one. The two delegates are wrapped in a small nested RemoteArmCallbacks type instead of being bare fields, which satisfies the guard and keeps the cache a single named, auditable unit rather than working around the test. RunRemoteTeleportHook's own allocation (the six-action RemoteTeleportHookActions bundle) is unaffected — it stays teleport-path-only, already judged acceptable to defer by the C4 route 4b-3 round-2 architecture review's B4 finding. dotnet build AcDream.slnx -c Release: 0 errors. Focused suites green at this commit: AcDream.App.Tests 4104/4107 (3 pre-existing skips), AcDream.Runtime.Tests 1125/1125. Closes #315. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
edc911b042
commit
aaf0811f18
2 changed files with 125 additions and 9 deletions
|
|
@ -148,7 +148,23 @@ split result places normally instead of throwing.
|
||||||
|
|
||||||
## #315 — `runTeleportHook` builds a `Func<bool>` closure per network packet
|
## #315 — `runTeleportHook` builds a `Func<bool>` closure per network packet
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** CLOSED 2026-08-04 by `ddb38f37` — the OnPosition collapse
|
||||||
|
converged the three `RunRemoteArmTail` call sites into one, which is what
|
||||||
|
made caching worthwhile. The one remaining call site now passes two
|
||||||
|
delegates cached ONCE at construction (`RemoteArmCallbacks`, a small nested
|
||||||
|
type wrapping `Func<bool> IsCurrentPositionOwner` /
|
||||||
|
`Func<bool> RunTeleportHook`) instead of allocating a fresh closure per
|
||||||
|
packet; per-packet scratch state (`canonical`, `remote`, `positionRecord`,
|
||||||
|
`positionAuthorityVersion`, `expectedEntity`) moved from closure captures to
|
||||||
|
plain instance fields `RunRemoteArmTail` stamps immediately before use.
|
||||||
|
Deliberately NOT two bare `Func<bool>` fields directly on
|
||||||
|
`LiveEntityNetworkUpdateController`:
|
||||||
|
`tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs`'s
|
||||||
|
`ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks` asserts
|
||||||
|
every typed production owner carries zero `Delegate`-typed fields (the
|
||||||
|
GameWindow decomposition campaign's guard against a smuggled window
|
||||||
|
callback); wrapping both delegates in `RemoteArmCallbacks` respects that
|
||||||
|
invariant instead of tripping it.
|
||||||
**Severity:** LOW (real allocation regression, not correctness; not on the
|
**Severity:** LOW (real allocation regression, not correctness; not on the
|
||||||
per-frame resolve path Slice I's 0 B/resolve discipline governs)
|
per-frame resolve path Slice I's 0 B/resolve discipline governs)
|
||||||
**Filed:** 2026-08-04
|
**Filed:** 2026-08-04
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,56 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly RuntimeRemotePlacementDriveController _remotePlacementDrive;
|
private readonly RuntimeRemotePlacementDriveController _remotePlacementDrive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #315 (closed by the OnPosition collapse, 2026-08-04): scratch fields
|
||||||
|
/// backing <see cref="_remoteArmCallbacks"/> — the two delegates
|
||||||
|
/// <see cref="RunRemoteArmTail"/> passes into
|
||||||
|
/// <see cref="ApplyRemoteContactRouting"/> every accepted remote Position
|
||||||
|
/// (5-10 Hz per remote). Before the collapse there were three duplicated
|
||||||
|
/// call sites, each allocating a fresh closure per packet regardless of
|
||||||
|
/// whether the packet was a teleport; the collapse converged them to one,
|
||||||
|
/// which is what makes caching worthwhile — one cached pair now serves
|
||||||
|
/// every remote guid. <see cref="RunRemoteArmTail"/> stamps these
|
||||||
|
/// fields from its own parameters immediately before use; nothing reads
|
||||||
|
/// them between calls, so last-remote staleness between packets is
|
||||||
|
/// harmless (mirrors the existing per-instance scratch-field pattern,
|
||||||
|
/// e.g. <c>RemoteMotion.PositionManagerDeltaScratch</c>).
|
||||||
|
/// </summary>
|
||||||
|
private RuntimeEntityRecord? _remoteArmCanonical;
|
||||||
|
private RemoteMotion? _remoteArmMotion;
|
||||||
|
private LiveEntityRecord? _remoteArmPositionRecord;
|
||||||
|
private ulong _remoteArmPositionAuthorityVersion;
|
||||||
|
private AcDream.Core.World.WorldEntity? _remoteArmExpectedEntity;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #315: the two per-packet delegates cached ONCE (constructed here,
|
||||||
|
/// reused for every accepted remote Position) rather than allocated
|
||||||
|
/// fresh every packet. Deliberately its own small type, not two bare
|
||||||
|
/// <c>Func<bool></c> fields directly on this class:
|
||||||
|
/// <c>tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs</c>'s
|
||||||
|
/// <c>ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks</c>
|
||||||
|
/// asserts every typed production owner (this class included) carries
|
||||||
|
/// ZERO <c>Delegate</c>-typed fields — the GameWindow decomposition
|
||||||
|
/// campaign's guard against a callback silently smuggling a window
|
||||||
|
/// reference back in. Neither delegate here touches a window (both are
|
||||||
|
/// bound to this controller alone), but the rule is written as a
|
||||||
|
/// blanket field-type check, not a window-specific one, so the cache
|
||||||
|
/// lives in its own named type instead of tripping it.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class RemoteArmCallbacks
|
||||||
|
{
|
||||||
|
internal readonly Func<bool> IsCurrentPositionOwner;
|
||||||
|
internal readonly Func<bool> RunTeleportHook;
|
||||||
|
|
||||||
|
internal RemoteArmCallbacks(LiveEntityNetworkUpdateController owner)
|
||||||
|
{
|
||||||
|
IsCurrentPositionOwner = owner.IsCurrentRemoteArmPositionOwner;
|
||||||
|
RunTeleportHook = owner.RunCachedRemoteTeleportHook;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly RemoteArmCallbacks _remoteArmCallbacks;
|
||||||
|
|
||||||
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
||||||
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
||||||
private uint _playerServerGuid => _playerIdentity.ServerGuid;
|
private uint _playerServerGuid => _playerIdentity.ServerGuid;
|
||||||
|
|
@ -151,6 +201,9 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
_remotePlacementDrive = remotePlacementDrive
|
_remotePlacementDrive = remotePlacementDrive
|
||||||
?? throw new ArgumentNullException(nameof(remotePlacementDrive));
|
?? throw new ArgumentNullException(nameof(remotePlacementDrive));
|
||||||
_worldDropProjection = worldDropProjection;
|
_worldDropProjection = worldDropProjection;
|
||||||
|
// #315: cached once, reused for every accepted remote Position — see
|
||||||
|
// the field docs above _remoteArmCanonical.
|
||||||
|
_remoteArmCallbacks = new RemoteArmCallbacks(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void ResetSessionState() => _authorityGate.ResetSessionState();
|
internal void ResetSessionState() => _authorityGate.ResetSessionState();
|
||||||
|
|
@ -1356,6 +1409,20 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
/// <c>isTeleportRoute</c> guard, unconditional for every guid since the
|
/// <c>isTeleportRoute</c> guard, unconditional for every guid since the
|
||||||
/// collapse).
|
/// collapse).
|
||||||
/// </para>
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// #315 (closed here): <paramref name="positionAuthorityVersion"/> and
|
||||||
|
/// <paramref name="expectedEntity"/> replace what used to be a
|
||||||
|
/// caller-constructed <c>Func<bool> isCurrentPositionOwner</c> —
|
||||||
|
/// this method stamps the shared <c>_remoteArm*</c> scratch fields from
|
||||||
|
/// its own parameters and passes the two CACHED delegates
|
||||||
|
/// (<see cref="_remoteArmCallbacks"/>) into
|
||||||
|
/// <see cref="ApplyRemoteContactRouting"/> instead of allocating a fresh
|
||||||
|
/// closure over <c>canonical</c>/<c>remote</c>/the currency check every
|
||||||
|
/// packet. Observably identical: the currency check reads the exact same
|
||||||
|
/// <c>positionRecord</c>/<c>positionAuthorityVersion</c>/<c>expectedEntity</c>
|
||||||
|
/// triple either way, just from fields instead of a closure.
|
||||||
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private RemoteContactRouting? RunRemoteArmTail(
|
private RemoteContactRouting? RunRemoteArmTail(
|
||||||
RuntimeEntityRecord canonical,
|
RuntimeEntityRecord canonical,
|
||||||
|
|
@ -1365,9 +1432,15 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
uint guid,
|
uint guid,
|
||||||
System.Numerics.Vector3 worldPos,
|
System.Numerics.Vector3 worldPos,
|
||||||
System.Numerics.Quaternion rotation,
|
System.Numerics.Quaternion rotation,
|
||||||
Func<bool> isCurrentPositionOwner)
|
ulong positionAuthorityVersion,
|
||||||
|
AcDream.Core.World.WorldEntity? expectedEntity)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(isCurrentPositionOwner);
|
_remoteArmCanonical = canonical;
|
||||||
|
_remoteArmMotion = remote;
|
||||||
|
_remoteArmPositionRecord = positionRecord;
|
||||||
|
_remoteArmPositionAuthorityVersion = positionAuthorityVersion;
|
||||||
|
_remoteArmExpectedEntity = expectedEntity;
|
||||||
|
|
||||||
RemoteContactRouting routing = ApplyRemoteContactRouting(
|
RemoteContactRouting routing = ApplyRemoteContactRouting(
|
||||||
_remotePlacementDrive,
|
_remotePlacementDrive,
|
||||||
canonical,
|
canonical,
|
||||||
|
|
@ -1376,14 +1449,11 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
worldPos,
|
worldPos,
|
||||||
rotation,
|
rotation,
|
||||||
willBeDrTicked: WillAdvanceRemoteMotion(guid, remote),
|
willBeDrTicked: WillAdvanceRemoteMotion(guid, remote),
|
||||||
runTeleportHook: () => RunRemoteTeleportHook(
|
runTeleportHook: _remoteArmCallbacks.RunTeleportHook);
|
||||||
canonical,
|
|
||||||
remote,
|
|
||||||
isCurrentPositionOwner));
|
|
||||||
|
|
||||||
if ((routing.Arm is RemoteContactArm.FarSnapPlacement
|
if ((routing.Arm is RemoteContactArm.FarSnapPlacement
|
||||||
or RemoteContactArm.TeleportPlacement)
|
or RemoteContactArm.TeleportPlacement)
|
||||||
&& (!isCurrentPositionOwner()
|
&& (!_remoteArmCallbacks.IsCurrentPositionOwner()
|
||||||
|| !ReferenceEquals(positionRecord.RemoteMotionRuntime, remote)))
|
|| !ReferenceEquals(positionRecord.RemoteMotionRuntime, remote)))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1392,6 +1462,35 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
return routing;
|
return routing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #315: the cached backing method for
|
||||||
|
/// <see cref="RemoteArmCallbacks.IsCurrentPositionOwner"/> — reads the
|
||||||
|
/// scratch fields <see cref="RunRemoteArmTail"/> just stamped rather than
|
||||||
|
/// closing over per-packet locals. Identical logic to the local-function
|
||||||
|
/// <c>IsCurrentPositionOwner</c> pattern used elsewhere in
|
||||||
|
/// <c>OnPosition</c> for the local-player paths, which this method does
|
||||||
|
/// not replace (those stay untouched, per contract invariant 10).
|
||||||
|
/// </summary>
|
||||||
|
private bool IsCurrentRemoteArmPositionOwner() =>
|
||||||
|
_remoteArmPositionRecord is { } record
|
||||||
|
&& _liveEntities.IsCurrentPositionAuthority(
|
||||||
|
record, _remoteArmPositionAuthorityVersion)
|
||||||
|
&& (_remoteArmExpectedEntity is null
|
||||||
|
|| ReferenceEquals(record.WorldEntity, _remoteArmExpectedEntity));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #315: the cached backing method for
|
||||||
|
/// <see cref="RemoteArmCallbacks.RunTeleportHook"/>. Only ever invoked on
|
||||||
|
/// the teleport-classified path, inside
|
||||||
|
/// <see cref="ApplyRemoteContactRouting"/>; reads the scratch fields
|
||||||
|
/// <see cref="RunRemoteArmTail"/> just stamped for THIS packet.
|
||||||
|
/// </summary>
|
||||||
|
private bool RunCachedRemoteTeleportHook() =>
|
||||||
|
_remoteArmCanonical is { } canonical
|
||||||
|
&& _remoteArmMotion is { } motion
|
||||||
|
&& RunRemoteTeleportHook(
|
||||||
|
canonical, motion, _remoteArmCallbacks.IsCurrentPositionOwner);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// C4 route 4b-2: the post-routing wire-cell adoption, extracted so its
|
/// C4 route 4b-2: the post-routing wire-cell adoption, extracted so its
|
||||||
/// ONE suppression rule is exercised by production and by test through
|
/// ONE suppression rule is exercised by production and by test through
|
||||||
|
|
@ -2473,7 +2572,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
||||||
update.Guid,
|
update.Guid,
|
||||||
worldPos,
|
worldPos,
|
||||||
rot,
|
rot,
|
||||||
() => IsCurrentPositionOwner(entity));
|
acceptedPositionAuthorityVersion,
|
||||||
|
entity);
|
||||||
if (routing is null)
|
if (routing is null)
|
||||||
return;
|
return;
|
||||||
arm = routing.Value.Arm;
|
arm = routing.Value.Arm;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue