Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ Standard protobuf `optional` fields provide per-field presence natively — unch

### Server → Client

**PLAYER_JOINED** (ch0, reliable, broadcast to interest set)
- Sent when a subject first enters the observer's interest set
- Carries `user_id`, `profile_version`, full `PlayerState`, and the subject's `realm` (its AoI partition)

**STATE_FULL** (ch0, reliable)
- Full snapshot of a subject's state
- Sent on zone entry or in response to RESYNC_REQUEST
Expand All @@ -181,6 +185,7 @@ Standard protobuf `optional` fields provide per-field presence natively — unch

**TELEPORT** (ch0, reliable, broadcast to interest set)
- Server-authoritative teleport position with server_tick
- Carries the subject's `realm` (a teleport may move the peer to a different realm)
- Receiver clears interpolation buffer and snaps to position

---
Expand Down
8 changes: 4 additions & 4 deletions docs/ai-agent-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Defense-in-depth, all local, all fail-closed. Each has a dedicated class under `

Players outside 100m (`SpatialAreaOfInterestOptions.MaxRadius`) receive no updates.

**Realm partitioning.** `PeerSnapshot.Realm` gates visibility inside the area-of-interest collectors: observers only see subjects whose `Realm` string matches exactly. A single Pulse instance transparently hosts multiple realms that never see each other.
**Realm partitioning.** `PeerSnapshot.Realm` gates visibility inside the area-of-interest collectors: observers only see subjects whose `Realm` string matches exactly. A single Pulse instance transparently hosts multiple realms that never see each other. The subject's realm rides along on `PlayerJoined` (on entry) and `Teleported` (a teleport may cross realms), so the client always knows which realm a peer belongs to.

**Snapshot history:** Server keeps a small rolling ring of snapshots per subject (`SnapshotBoard`). `RESYNC_REQUEST` default response is `STATE_FULL`. When `Peers.ResyncWithDelta` is enabled, the server first attempts a targeted delta from the client's `knownSeq` baseline and falls back to `STATE_FULL` when that seq has been evicted from the ring.

Expand All @@ -78,7 +78,7 @@ Players outside 100m (`SpatialAreaOfInterestOptions.MaxRadius`) receive no updat

**Stale-view sweep.** Every `SWEEP_INTERVAL` (≈100 ticks, ~5 s) `PeerSimulation.SweepStaleViews` prunes observer views for no-longer-visible subjects and emits `PlayerLeft`. Bounds memory and closes the same-wallet-reconnect "two views" window.

**Self-mirror.** When `Peers.SelfMirrorEnabled=true`, each peer receives its own state as if from another peer under `SELF_MIRROR_WALLET_ID` at tier `SelfMirrorTier`. Client-side animation testing aid.
**Self-mirror.** When `Peers.SelfMirrorEnabled=true`, each peer receives its own state as if from another peer under `SELF_MIRROR_WALLET_ID` at tier `SelfMirrorTier`. Client-side animation testing aid. The self-mirror is injected outside the AoI, so it re-applies the same realm invariant itself: a peer with no realm yet (legacy connect, before its first teleport) is not mirrored until a realm is set.

---

Expand All @@ -103,14 +103,14 @@ Proto-level names: see the `ClientMessage` / `ServerMessage` `oneof message` in
| Message | Channel | Description |
| --- | --- | --- |
| `Handshake` | 0 (reliable) | Auth accept/reject response. On reject, followed by `enet_peer_disconnect_later`. |
| `PlayerJoined` | 0 (reliable, broadcast) | A peer entered the observer's interest set. |
| `PlayerJoined` | 0 (reliable, broadcast) | A peer entered the observer's interest set. Carries `UserId, ProfileVersion, State` and the subject's `Realm` (the AoI partition it belongs to). |
| `PlayerLeft` | 0 (reliable, broadcast) | A peer left the observer's interest set (distance, realm change, disconnect, or stale-view sweep). |
| `PlayerStateFull` | 0 (reliable) | Full snapshot of a subject. Sent on zone entry or in response to `Resync`. |
| `PlayerStateDelta` | 1 (unreliable sequenced) | Delta from `last_sent_snapshot`. Optional-field presence suppresses unchanged fields. State flags always present. |
| `PlayerProfileVersionsAnnounced` | 0 (reliable, broadcast) | Fan-out of `ProfileAnnouncement` to observers. |
| `EmoteStarted` | 0 (reliable, broadcast) | `SubjectId, Sequence, ServerTick, EmoteId, PlayerState`. Full `PlayerState` sent reliably because no further position updates arrive during the emote. |
| `EmoteStopped` | 0 (reliable, broadcast) | `SubjectId, Sequence, ServerTick, Reason, PlayerState`. Reason = completed (one-shot duration expired) or cancelled (client `EmoteStop`). `PlayerState` lets the client snap to the correct position on resume. |
| `Teleported` | 0 (reliable, broadcast) | Authoritative position + `ServerTick`. Client clears interpolation buffer and snaps. |
| `Teleported` | 0 (reliable, broadcast) | Authoritative position + `ServerTick` + the subject's `Realm` (a teleport may move the peer to a different realm). Client clears interpolation buffer and snaps. |

---

Expand Down
23 changes: 20 additions & 3 deletions src/DCLPulse/Peers/Simulation/PeerSimulation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@ public void SimulateTick(Dictionary<PeerIndex, PeerState> peers, uint tickCounte

collector.Clear();
areaOfInterest.GetVisibleSubjects(observerId, in observerSnapshot, collector);

if (selfMirrorEnabled)
collector.Add(observerId, selfMirrorTier);
AddSelfMirror(observerId, in observerSnapshot);

string? observerWallet = identityBoard.GetWalletIdByPeerIndex(observerId);

Expand All @@ -172,6 +170,23 @@ public void RemoveObserver(PeerIndex observerId)
observerViews.Remove(observerId);
}

/// <summary>
/// Inject the observer as its own subject so it receives its own state under
/// <see cref="SELF_MIRROR_WALLET_ID" /> (a client-side animation-testing aid).
/// <para />
/// This bypasses the AoI, so it must re-apply the AoI's realm invariant itself: a peer
/// with no realm yet (legacy connect, before its first teleport) is invisible to every
/// observer — itself included — so it is not mirrored until a realm is set. Without this
/// guard the self-mirror would emit a <c>PlayerJoined</c> carrying a realm-less snapshot.
/// </summary>
private void AddSelfMirror(PeerIndex observerId, in PeerSnapshot observerSnapshot)
{
if (!selfMirrorEnabled || observerSnapshot.Realm == null)
return;

collector.Add(observerId, selfMirrorTier);
}

// ── Per-subject orchestration ───────────────────────────────────

private void ProcessVisibleSubjects(
Expand Down Expand Up @@ -308,6 +323,7 @@ private PeerToPeerView HandleNewSubject(
UserId = userId,
ProfileVersion = profileVersion,
State = CreateFullState(subjectId, latestSnapshot),
Realm = latestSnapshot.Realm ?? string.Empty,
},
}, PacketMode.RELIABLE));

Expand Down Expand Up @@ -613,6 +629,7 @@ private void SendTeleport(PeerIndex observerId, ref PeerToPeerView view, PeerInd
Sequence = snapshot.Seq,
ServerTick = snapshot.ServerTick,
State = CreatePlayerState(snapshot),
Realm = snapshot.Realm ?? string.Empty,
},
}, PacketMode.RELIABLE);

Expand Down
12 changes: 12 additions & 0 deletions src/DCLPulseTests/PeerSimulationTests.PlayerJoined.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ public void PlayerJoined_ContainsFullState()
Assert.That(state.State.JumpCount, Is.EqualTo(2));
}

[Test]
public void PlayerJoined_CarriesRealm()
{
snapshotBoard.Publish(subject, TestSnapshots.Make(seq: 5, realm: "genesis"));
SetVisibleSubjects((subject, PeerViewSimulationTier.TIER_0));

simulation.SimulateTick(peers, tickCounter: 0);

OutgoingMessage msg = DrainSingleMessage();
Assert.That(msg.Message.PlayerJoined.Realm, Is.EqualTo("genesis"));
}

[Test]
public void PlayerJoined_NotSentOnSubsequentTicks()
{
Expand Down
20 changes: 19 additions & 1 deletion src/DCLPulseTests/PeerSimulationTests.SelfMirror.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ private void PublishSnapshot(PeerIndex peer, uint seq, Vector3? position = null)
{
snapshotBoard.SetActive(peer);

// A realm is required for the self-mirror (and AoI) to surface the peer — the peer sets
// one at handshake or via its first teleport. Carried forward onto later snapshots.
snapshotBoard.Publish(peer, TestSnapshots.Make(
seq: seq, serverTick: seq * 10, position: position ?? Vector3.Zero));
seq: seq, serverTick: seq * 10, position: position ?? Vector3.Zero, realm: "genesis"));
}

private void PublishEmoteSnapshot(PeerIndex peer, uint seq, string emoteId = "wave",
Expand Down Expand Up @@ -219,6 +221,22 @@ public void SelfMirror_MirrorsProfileAnnouncements()
Assert.That(profileMsg.Message.PlayerProfileVersionAnnounced.Version, Is.EqualTo(42));
}

[Test]
public void SelfMirror_NotSentWhenObserverHasNoRealm()
{
// Legacy connect flow: the peer authenticates without an initial realm and hasn't
// teleported yet, so its latest snapshot has Realm == null. Such a peer is invisible
// to everyone in the AoI — the self-mirror must honour the same invariant.
snapshotBoard.ClearActive(observer);
snapshotBoard.SetActive(observer);
snapshotBoard.Publish(observer, TestSnapshots.Make(seq: 1, realm: null));

SetVisibleSubjects();
simulation.SimulateTick(peers, tickCounter: 0);

Assert.That(messagePipe.TryReadOutgoingMessage(out _), Is.False);
}

[Test]
public void SelfMirror_DisabledByDefault_SkipsSelf()
{
Expand Down
20 changes: 20 additions & 0 deletions src/DCLPulseTests/PeerSimulationTests.Teleport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ public void Teleport_BroadcastedToObserver_WhenSubjectTeleports()
Assert.That(teleportMsg.Message.Teleported.State.PositionZQuantized, Is.EqualTo(12f).Within(PlayerState.PositionZQuantizedStep));
}

[Test]
public void Teleport_CarriesRealm()
{
SetVisibleSubjects((subject, PeerViewSimulationTier.TIER_0));
simulation.SimulateTick(peers, tickCounter: 0);
DrainAllMessages(); // consume PlayerJoined

snapshotBoard.SetActive(subject);
snapshotBoard.Publish(subject, TestSnapshots.Make(
seq: 2, serverTick: 20,
position: new Vector3(10, 20, 12),
animationFlags: PlayerAnimationFlags.Grounded,
isTeleport: true, realm: "crossgate"));
simulation.SimulateTick(peers, tickCounter: 1);

List<OutgoingMessage> messages = DrainAllMessages();
OutgoingMessage teleportMsg = messages.First(m => m.Message.MessageCase == ServerMessage.MessageOneofCase.Teleported);
Assert.That(teleportMsg.Message.Teleported.Realm, Is.EqualTo("crossgate"));
}

[Test]
public void Teleport_ReplacesStateDelta()
{
Expand Down
69 changes: 34 additions & 35 deletions src/Protocol/Generated/PulseClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,41 @@ static PulseClientReflection() {
byte[] descriptorData = global::System.Convert.FromBase64String(
string.Concat(
"CiVkZWNlbnRyYWxhbmQvcHVsc2UvcHVsc2VfY2xpZW50LnByb3RvEhJkZWNl",
"bnRyYWxhbmQucHVsc2UaIWRlY2VudHJhbGFuZC9jb21tb24vdmVjdG9ycy5w",
"cm90bxolZGVjZW50cmFsYW5kL3B1bHNlL3B1bHNlX3NoYXJlZC5wcm90bxoh",
"ZGVjZW50cmFsYW5kL2NvbW1vbi9vcHRpb25zLnByb3RvIpUBChBIYW5kc2hh",
"a2VSZXF1ZXN0EhIKCmF1dGhfY2hhaW4YASABKAwSFwoPcHJvZmlsZV92ZXJz",
"aW9uGAIgASgFEkIKDWluaXRpYWxfc3RhdGUYAyABKAsyJi5kZWNlbnRyYWxh",
"bmQucHVsc2UuUGxheWVySW5pdGlhbFN0YXRlSACIAQFCEAoOX2luaXRpYWxf",
"c3RhdGUikwIKElBsYXllckluaXRpYWxTdGF0ZRIuCgVzdGF0ZRgBIAEoCzIf",
"LmRlY2VudHJhbGFuZC5wdWxzZS5QbGF5ZXJTdGF0ZRIVCghlbW90ZV9pZBgC",
"IAEoCUgAiAEBEh4KEWVtb3RlX2R1cmF0aW9uX21zGAMgASgNSAGIAQESIgoV",
"ZW1vdGVfc3RhcnRfb2Zmc2V0X21zGAQgASgNSAKIAQESDQoFcmVhbG0YBSAB",
"KAkSFwoKZW1vdGVfbWFzaxgGIAEoBUgDiAEBQgsKCV9lbW90ZV9pZEIUChJf",
"ZW1vdGVfZHVyYXRpb25fbXNCGAoWX2Vtb3RlX3N0YXJ0X29mZnNldF9tc0IN",
"CgtfZW1vdGVfbWFzayItChpQcm9maWxlVmVyc2lvbkFubm91bmNlbWVudBIP",
"Cgd2ZXJzaW9uGAEgASgFIkIKEFBsYXllclN0YXRlSW5wdXQSLgoFc3RhdGUY",
"ASABKAsyHy5kZWNlbnRyYWxhbmQucHVsc2UuUGxheWVyU3RhdGUiNgoNUmVz",
"eW5jUmVxdWVzdBISCgpzdWJqZWN0X2lkGAEgASgNEhEKCWtub3duX3NlcRgC",
"IAEoDSKbAQoKRW1vdGVTdGFydBIQCghlbW90ZV9pZBgBIAEoCRIYCgtkdXJh",
"dGlvbl9tcxgCIAEoDUgAiAEBEjUKDHBsYXllcl9zdGF0ZRgDIAEoCzIfLmRl",
"Y2VudHJhbGFuZC5wdWxzZS5QbGF5ZXJTdGF0ZRIRCgRtYXNrGAQgASgFSAGI",
"AQFCDgoMX2R1cmF0aW9uX21zQgcKBV9tYXNrIgsKCUVtb3RlU3RvcCKZAQoP",
"VGVsZXBvcnRSZXF1ZXN0EhQKDHBhcmNlbF9pbmRleBgBIAEoBRIfCgpwb3Np",
"dGlvbl94GAIgASgNQguKtRgHFQAAgEEYCBIfCgpwb3NpdGlvbl95GAMgASgN",
"QguKtRgHFQAASEMYDRIfCgpwb3NpdGlvbl96GAQgASgNQguKtRgHFQAAgEEY",
"CBINCgVyZWFsbRgFIAEoCSK2AwoNQ2xpZW50TWVzc2FnZRI5CgloYW5kc2hh",
"a2UYASABKAsyJC5kZWNlbnRyYWxhbmQucHVsc2UuSGFuZHNoYWtlUmVxdWVz",
"dEgAEjUKBWlucHV0GAIgASgLMiQuZGVjZW50cmFsYW5kLnB1bHNlLlBsYXll",
"clN0YXRlSW5wdXRIABIzCgZyZXN5bmMYAyABKAsyIS5kZWNlbnRyYWxhbmQu",
"cHVsc2UuUmVzeW5jUmVxdWVzdEgAEk4KFHByb2ZpbGVfYW5ub3VuY2VtZW50",
"GAQgASgLMi4uZGVjZW50cmFsYW5kLnB1bHNlLlByb2ZpbGVWZXJzaW9uQW5u",
"b3VuY2VtZW50SAASNQoLZW1vdGVfc3RhcnQYBSABKAsyHi5kZWNlbnRyYWxh",
"bmQucHVsc2UuRW1vdGVTdGFydEgAEjMKCmVtb3RlX3N0b3AYBiABKAsyHS5k",
"ZWNlbnRyYWxhbmQucHVsc2UuRW1vdGVTdG9wSAASNwoIdGVsZXBvcnQYByAB",
"KAsyIy5kZWNlbnRyYWxhbmQucHVsc2UuVGVsZXBvcnRSZXF1ZXN0SABCCQoH",
"bWVzc2FnZWIGcHJvdG8z"));
"bnRyYWxhbmQucHVsc2UaJWRlY2VudHJhbGFuZC9wdWxzZS9wdWxzZV9zaGFy",
"ZWQucHJvdG8aIWRlY2VudHJhbGFuZC9jb21tb24vb3B0aW9ucy5wcm90byKV",
"AQoQSGFuZHNoYWtlUmVxdWVzdBISCgphdXRoX2NoYWluGAEgASgMEhcKD3By",
"b2ZpbGVfdmVyc2lvbhgCIAEoBRJCCg1pbml0aWFsX3N0YXRlGAMgASgLMiYu",
"ZGVjZW50cmFsYW5kLnB1bHNlLlBsYXllckluaXRpYWxTdGF0ZUgAiAEBQhAK",
"Dl9pbml0aWFsX3N0YXRlIpMCChJQbGF5ZXJJbml0aWFsU3RhdGUSLgoFc3Rh",
"dGUYASABKAsyHy5kZWNlbnRyYWxhbmQucHVsc2UuUGxheWVyU3RhdGUSFQoI",
"ZW1vdGVfaWQYAiABKAlIAIgBARIeChFlbW90ZV9kdXJhdGlvbl9tcxgDIAEo",
"DUgBiAEBEiIKFWVtb3RlX3N0YXJ0X29mZnNldF9tcxgEIAEoDUgCiAEBEg0K",
"BXJlYWxtGAUgASgJEhcKCmVtb3RlX21hc2sYBiABKAVIA4gBAUILCglfZW1v",
"dGVfaWRCFAoSX2Vtb3RlX2R1cmF0aW9uX21zQhgKFl9lbW90ZV9zdGFydF9v",
"ZmZzZXRfbXNCDQoLX2Vtb3RlX21hc2siLQoaUHJvZmlsZVZlcnNpb25Bbm5v",
"dW5jZW1lbnQSDwoHdmVyc2lvbhgBIAEoBSJCChBQbGF5ZXJTdGF0ZUlucHV0",
"Ei4KBXN0YXRlGAEgASgLMh8uZGVjZW50cmFsYW5kLnB1bHNlLlBsYXllclN0",
"YXRlIjYKDVJlc3luY1JlcXVlc3QSEgoKc3ViamVjdF9pZBgBIAEoDRIRCglr",
"bm93bl9zZXEYAiABKA0imwEKCkVtb3RlU3RhcnQSEAoIZW1vdGVfaWQYASAB",
"KAkSGAoLZHVyYXRpb25fbXMYAiABKA1IAIgBARI1CgxwbGF5ZXJfc3RhdGUY",
"AyABKAsyHy5kZWNlbnRyYWxhbmQucHVsc2UuUGxheWVyU3RhdGUSEQoEbWFz",
"axgEIAEoBUgBiAEBQg4KDF9kdXJhdGlvbl9tc0IHCgVfbWFzayILCglFbW90",
"ZVN0b3AimQEKD1RlbGVwb3J0UmVxdWVzdBIUCgxwYXJjZWxfaW5kZXgYASAB",
"KAUSHwoKcG9zaXRpb25feBgCIAEoDUILirUYBxUAAIBBGAgSHwoKcG9zaXRp",
"b25feRgDIAEoDUILirUYBxUAAEhDGA0SHwoKcG9zaXRpb25fehgEIAEoDUIL",
"irUYBxUAAIBBGAgSDQoFcmVhbG0YBSABKAkitgMKDUNsaWVudE1lc3NhZ2US",
"OQoJaGFuZHNoYWtlGAEgASgLMiQuZGVjZW50cmFsYW5kLnB1bHNlLkhhbmRz",
"aGFrZVJlcXVlc3RIABI1CgVpbnB1dBgCIAEoCzIkLmRlY2VudHJhbGFuZC5w",
"dWxzZS5QbGF5ZXJTdGF0ZUlucHV0SAASMwoGcmVzeW5jGAMgASgLMiEuZGVj",
"ZW50cmFsYW5kLnB1bHNlLlJlc3luY1JlcXVlc3RIABJOChRwcm9maWxlX2Fu",
"bm91bmNlbWVudBgEIAEoCzIuLmRlY2VudHJhbGFuZC5wdWxzZS5Qcm9maWxl",
"VmVyc2lvbkFubm91bmNlbWVudEgAEjUKC2Vtb3RlX3N0YXJ0GAUgASgLMh4u",
"ZGVjZW50cmFsYW5kLnB1bHNlLkVtb3RlU3RhcnRIABIzCgplbW90ZV9zdG9w",
"GAYgASgLMh0uZGVjZW50cmFsYW5kLnB1bHNlLkVtb3RlU3RvcEgAEjcKCHRl",
"bGVwb3J0GAcgASgLMiMuZGVjZW50cmFsYW5kLnB1bHNlLlRlbGVwb3J0UmVx",
"dWVzdEgAQgkKB21lc3NhZ2ViBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Decentraland.Common.VectorsReflection.Descriptor, global::Decentraland.Pulse.PulseSharedReflection.Descriptor, global::Decentraland.Common.OptionsReflection.Descriptor, },
new pbr::FileDescriptor[] { global::Decentraland.Pulse.PulseSharedReflection.Descriptor, global::Decentraland.Common.OptionsReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] {
new pbr::GeneratedClrTypeInfo(typeof(global::Decentraland.Pulse.HandshakeRequest), global::Decentraland.Pulse.HandshakeRequest.Parser, new[]{ "AuthChain", "ProfileVersion", "InitialState" }, new[]{ "InitialState" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Decentraland.Pulse.PlayerInitialState), global::Decentraland.Pulse.PlayerInitialState.Parser, new[]{ "State", "EmoteId", "EmoteDurationMs", "EmoteStartOffsetMs", "Realm", "EmoteMask" }, new[]{ "EmoteId", "EmoteDurationMs", "EmoteStartOffsetMs", "EmoteMask" }, null, null, null),
Expand Down
Loading
Loading