fix(inventory): reset a split result's movement timestamps so recovery cannot throw (#314)

Found by C4 route 6's integration tests (1b484937) and split out of that
zero-production closure per the standing split-on-discovery rule.

PendingSplitToWorldProjection.BuildSpawn zeroes the top-level
MovementSequence/ServerControlSequence, but its Physics.Timestamps `with`
block overrode only Position/Teleport/ForcePosition/Instance — leaving
Timestamps.Movement and .ServerControlledMove at the SOURCE item's values.
RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent requires the
PhysicsDesc timestamps and their flattened projections to agree, so the
synthetic spawn failed the predicate and TryRecoverUnknownPosition threw
`CreateObject 0x… has inconsistent instance or parent projections` instead of
completing the canonical create-placement transaction.

Reachable in ordinary play: retail's per-object update_times channels are
monotonic and do not reset when an item re-enters a container, so any item
that ever had world presence — dropped once, picked back up, then split —
carries nonzero values in exactly those two fields. The split pile then never
appears.

Fix is the honest value, not a placation of the predicate: a fresh split GUID
has no movement history by construction, so both channels are zero in both
projections. Deliberately NOT fixed by loosening
HasConsistentCreateIdentityAndParent — the predicate was right and the
producer was wrong.

The route-6 test that documented the throw
(SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering) is
renamed to …_StillRecovers and now pins the fix. It asserts more than "no
throw": the result's movement channels must be ZERO in both projections, so
the test cannot pass against a lenient-predicate workaround. Sabotage-verified
in both directions — restoring the old BuildSpawn reproduces the exact
original InvalidOperationException.

Notable for the campaign record: this is a crash in the precise mechanism
route 6's scoping cited as EVIDENCE that drops already converge on the
canonical transaction. Reading the code said the path converges; driving it
said it throws. The zero-production route was still correct — and building its
tests anyway is what found this.

Complete Release suite 11,020 passed / 4 skipped / 0 failed, unchanged from
1b484937 (the test flipped its assertion rather than being added). Neither
known flake fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-04 16:48:41 +02:00
parent 1b484937b6
commit daef7c9835
2 changed files with 59 additions and 16 deletions

View file

@ -179,12 +179,28 @@ internal sealed class PendingSplitToWorldProjection
Position = update.Position,
Parent = null,
Velocity = update.Velocity,
// #314: every timestamp this record projects to the flattened
// spawn fields below MUST be reset here in the same breath.
// HasConsistentCreateIdentityAndParent
// (RuntimeEntityObjectLifetime) rejects a create whose
// PhysicsDesc timestamps disagree with their flattened
// projections, and the `source with { … }` below zeroes
// MovementSequence/ServerControlSequence. Omitting the
// matching Movement/ServerControlledMove resets here left a
// split whose SOURCE carried nonzero movement stamps — any
// item dropped once, picked back up, and split again —
// failing that predicate and throwing instead of completing
// the canonical create-placement transaction. A fresh split
// GUID has no movement history by construction, so zero is
// the honest value, not a placation of the predicate.
Timestamps = sourcePhysics.Timestamps with
{
Position = update.PositionSequence,
Teleport = update.TeleportSequence,
ForcePosition = update.ForcePositionSequence,
Instance = update.InstanceSequence,
Movement = 0,
ServerControlledMove = 0,
},
}
: null;