using System.Collections; using System.Collections.Generic; using Unity.Netcode; using UnityEngine; /// /// Replicates mesh painting across the network as brush-stamp COMMANDS (tens of bytes each), not /// pixels: the owner batches each frame's committed stamps and forwards them; remote avatars replay /// them through their own painter onto the identical mesh/UVs - live painting without streaming maps. /// /// Undo/redo replicates as commands too: every replica keeps the SAME per-stroke history (see /// MeshPainter.Undo), so the owner forwards per-stroke CHECKPOINT markers and undo/redo RESTORE ids. /// A peer that lacks the target id (undo past its join point) heals by pulling a fresh snapshot from /// the OWNER - the only guaranteed-correct source. Stamps are capped per frame with the overflow /// carried over (never dropped), and markers never overtake the stamps they finalize, so histories /// stay aligned and the heal path stays rare. /// /// Late join: on spawn the client pulls a compressed snapshot from the server (chunked, throttled), /// buffers live events during the transfer, and afterwards replays only those newer than the /// snapshot's cutoff sequence - nothing lost or double-applied across the join window. /// /// Respawn carry: a scene switch despawns the avatar, which would otherwise respawn blank. The owner /// parks a snapshot on the persistent MatchManager; the replacement avatar loads and rebroadcasts it /// (the manual-load path). The carry-broadcast and the replica's own late-join request can overlap, so /// transfers are id-tagged and cutoff-ordered - they never mix buffers or regress to older state. /// /// Emission intensity is NOT baked into the maps (a global material multiplier), so it rides its own /// owner-authoritative NetworkVariable instead of the stamp/snapshot path. /// [DisallowMultipleComponent] public class PlayerPaintSync : NetworkBehaviour { [Tooltip("Max brush stamps forwarded to the network per frame. Pathological fast strokes (hundreds " + "of interpolated sub-stamps in one frame) are capped to this to bound the RPC size; the " + "overflow is CARRIED OVER to the next frame (never dropped), so remotes replay the identical " + "stamp set and undo/redo history stays aligned. Local painting is always full fidelity.")] [SerializeField] private int maxStampsPerFrame = 64; [Tooltip("Log paint-sync events (stamps/checkpoints/restores sent & applied, snapshot transfers) to " + "the console with a per-peer tag. Handy for diagnosing undo/redo replication; turn off for " + "quiet play.")] [SerializeField] private bool logSync = true; [Tooltip("Snapshot transfer chunk payload (bytes). Kept under the transport MTU so each chunk is a " + "single packet (no extra fragmentation).")] [SerializeField] private int snapshotChunkBytes = 1200; [Tooltip("Snapshot chunks sent per frame. Throttled so the burst stays under the reliable window " + "(~32 in-flight) while live stamps keep flowing.")] [SerializeField] private int snapshotChunksPerFrame = 8; private MeshPainter _painter; // Single owner-side FIFO of everything to forward, in the exact order it was produced: brush stamps // interleaved with stroke checkpoints and undo/redo restores. Flushed in order in LateUpdate so a // checkpoint/restore never overtakes the stamps it refers to, and so the per-frame stamp cap can carry // overflow to the next frame without ever reordering or dropping a command. private enum OutKind { Stamp, Checkpoint, Restore } private struct OutItem { public OutKind kind; public PaintStampNet stamp; // Stamp only public int id; // Checkpoint / Restore only } private readonly List _outgoing = new List(); // Global emission-intensity multiplier - a per-player material value, not part of the painted maps. // The remote applies it to its painter; RefreshEmissionColor pushes it to the material per frame. private readonly NetworkVariable _emissionIntensity = new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); // Per-player camo knobs on the PlayerCamoLit material (x = HalfLambert, y = its brightness, // z = specular dampen) - material values like emission intensity, applied once the remote painter // is ready and on every change. The default mirrors Player.mat's authored camo so the brief // pre-publish spawn frame shows no flash. private readonly NetworkVariable _camo = new(new Vector3(0.5f, 0f, 0.1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); private bool _camoApplyPending; // remote: apply _camo.Value to the material once IsReady / on change // Painted-height knobs (x = occlusion AO threshold, y = relief normal intensity, z = parallax // intensity). Same treatment as the camo knobs; the default mirrors the shader defaults. private readonly NetworkVariable _height = new(new Vector3(0.2f, 1.4f, 1f), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); private bool _heightApplyPending; // remote: apply _height.Value to the material once IsReady / on change // Receiver-side reveal gate (owner-authoritative): the cutoff sequence of the owner's FIRST published // baseline - carry-loaded, manually loaded, or (for a never-painted avatar) an explicit blank publish. // -1 until the owner has established and published its painting. A remote body stays HIDDEN until this // client has actually APPLIED a snapshot whose cutoff reaches this value (see PaintRevealReady), so a // peer never flashes the default grey/white material, and a match-start carry never briefly shows the // pre-load blank maps (the flag can't outrun the chunked snapshot - the applied cutoff must catch up). private readonly NetworkVariable _publishedCutoff = new(-1, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); // Highest snapshot cutoff this replica has actually APPLIED (not merely begun receiving). Compared // against _publishedCutoff to decide the reveal; only ever raised. private int _appliedCutoff = int.MinValue; /// /// True once THIS client may render the avatar's body: the owner is always ready (it holds its own /// painting locally); a remote replica is ready only after it has applied a snapshot reaching the /// owner's published baseline. Read by NetworkPlayerSetup's per-client visibility pass. /// public bool PaintRevealReady => IsOwner || (_publishedCutoff.Value >= 0 && _appliedCutoff >= _publishedCutoff.Value); // Monotonic message counter on the owner (one per stamp batch / checkpoint / restore); on every peer, // the highest sequence accounted for (applied to a remote replica / sent by the owner). Used as the // snapshot's cutoff marker so the join window neither loses nor double-applies events. private int _seq; private int _seqHorizon; // --- Joining client: snapshot reassembly + live-event buffering until the baseline is applied --- private enum NetKind { Stamps, Checkpoint, Restore } private struct NetEvent { public int seq; public NetKind kind; public PaintStampNet[] stamps; // Stamps only public int id; // Checkpoint / Restore only } private bool _awaitingSnapshot; // buffer live events instead of applying them private byte[] _snapBuffer; // reassembled compressed snapshot private int _snapCapturedSeq; // events <= this are already baked into the snapshot private int _snapBaselineId; // checkpoint id the snapshot maps represent (history baseline) private bool _snapReadyToApply; // last chunk arrived; apply once the painter is ready private readonly List _buffered = new List(); // A scene-switch respawn delivers TWO snapshots at once (the replica's late-join request AND the // owner's carry broadcast); without isolation they'd share one reassembly buffer and decode a mixed // blob. Each transfer carries a unique id; only the active one fills the buffer and applies. private int _activeXferId; // Highest snapshot cutoff accepted so far: an OLDER transfer is dropped (a late blank late-join must // not overwrite the newer carry); EQUAL is accepted so the owner-snapshot heal path still works. private int _acceptedCutoffFloor = int.MinValue; // Owner-only: painting carried over from the just-despawned avatar (taken from MatchManager on // spawn), applied in LateUpdate once this fresh painter is ready. Null = nothing to carry. private byte[] _pendingCarry; // True when this receiver sits on a MatchStatue: a statue drives its painting explicitly (the server // loads the captured snapshot then broadcasts it), so the owner-carry take, the client auto snapshot- // request and the no-carry blank publish are all suppressed to keep them from racing that push. private bool _isStatue; private void Awake() { _painter = GetComponentInChildren(true); // Detected from the sibling component so no inspector flag can be forgotten on the prefab. var statue = GetComponentInChildren(true); _isStatue = statue; } public override void OnNetworkSpawn() { if (!_painter) return; if (IsOwner) { _painter.OnStampCommitted += OnStampCommitted; _painter.OnHistoryCheckpoint += OnLocalCheckpoint; _painter.OnHistoryRestore += OnLocalRestore; if (MatchManager.Instance && !_isStatue) _pendingCarry = MatchManager.Instance.TakeCarriedPaint(); } else { // Remote replica: never paint from this machine's mouse; paint only from replicated data. _painter.PaintingEnabled = false; // Skip the wasted base seed on the initial backend build: this reveal-gated body stays hidden // until the painting snapshot is applied (which re-seeds the backend), so seeding grey/white at // Start only stalls this observer's spawn frame. Set BEFORE the painter's Start (OnNetworkSpawn // runs first), so the deferral takes effect. _painter.DeferInitialSeed = true; // Track the owner's emission intensity; seed the current (late-join) value right away. _emissionIntensity.OnValueChanged += OnEmissionChanged; _painter.emissionIntensity = _emissionIntensity.Value; // Deferred to LateUpdate - the camo setters are a no-op before the painter's Start builds // the material instance. _camo.OnValueChanged += OnCamoChanged; _camoApplyPending = true; // Same deferred apply for the painted-height knobs. _height.OnValueChanged += OnHeightChanged; _heightApplyPending = true; // The host's replica already holds the full paint via the live stream; only a genuine // late joiner needs the baseline pulled over. A statue never pulls - the server pushes it. if (!IsServer && !_isStatue) { _awaitingSnapshot = true; if (logSync) LogSync("late joiner: requesting baseline snapshot"); RequestSnapshotRpc(); } } } public override void OnNetworkDespawn() { if (_painter && IsOwner) { // Park the painting for the avatar the scene switch spawns next. Captured here, the latest // possible point, so last-second strokes are included (a real disconnect clears the copy). if (MatchManager.Instance && _painter.TryCaptureSnapshot(out byte[] snap)) MatchManager.Instance.StoreCarriedPaint(snap); _painter.OnStampCommitted -= OnStampCommitted; _painter.OnHistoryCheckpoint -= OnLocalCheckpoint; _painter.OnHistoryRestore -= OnLocalRestore; } _emissionIntensity.OnValueChanged -= OnEmissionChanged; _camo.OnValueChanged -= OnCamoChanged; _height.OnValueChanged -= OnHeightChanged; } // Owner history events -> appended to the ordered outgoing FIFO, flushed in LateUpdate. private void OnLocalCheckpoint(int id) => _outgoing.Add(new OutItem { kind = OutKind.Checkpoint, id = id }); private void OnLocalRestore(int id) => _outgoing.Add(new OutItem { kind = OutKind.Restore, id = id }); // Per-peer log tag: which client this is and its role, so multi-window logs are easy to read. private string SyncTag { get { string role = IsOwner ? "owner" : (IsServer ? "host" : "client"); ulong id = NetworkManager ? NetworkManager.LocalClientId : 0; return $"[PaintSync c{id} {role}]"; } } // Call sites are guarded with `if (logSync)` so the interpolated message string is never // built when logging is off (some sites run per frame while stamps are flushing). private void LogSync(string msg) { if (logSync) Debug.Log($"{SyncTag} {msg}", this); } private void OnEmissionChanged(float previous, float current) { if (_painter) _painter.emissionIntensity = current; } // Apply on the next LateUpdate once the material is ready (the camo setters are a no-op before that, // and on a fresh spawn the OnValueChanged may arrive before the painter's Start has built the instance). private void OnCamoChanged(Vector3 previous, Vector3 current) => _camoApplyPending = true; private void OnHeightChanged(Vector3 previous, Vector3 current) => _heightApplyPending = true; // ---- Live stamp stream (owner -> server -> remotes) ---- private void OnStampCommitted(PaintStampNet stamp) => _outgoing.Add(new OutItem { kind = OutKind.Stamp, stamp = stamp }); private void LateUpdate() { // Apply a fully-received snapshot once the painter has finished setup, then flush the events // that arrived during the transfer. Deferred to here so apply+replay happen atomically in order. if (_snapReadyToApply && _painter && _painter.IsPaintReady) { _snapReadyToApply = false; _painter.ApplyPaintSnapshot(_snapBuffer, _snapBaselineId); if (_snapCapturedSeq > _appliedCutoff) _appliedCutoff = _snapCapturedSeq; // receiver-side reveal gate if (logSync) LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events"); FlushBuffered(); _snapBuffer = null; _awaitingSnapshot = false; } // Remote replica: apply the owner's camo once the material exists / on change (the owner drives // its own material via HUD + persistence). if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady) { _painter.CamoKnobs = _camo.Value; _camoApplyPending = false; } // Remote replica: apply the owner's painted-height knobs once the material exists / on change. if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady) { _painter.HeightKnobs = _height.Value; _heightApplyPending = false; } if (!IsOwner) return; // Restore a carried-over painting once the painter is ready, then propagate it like a manual // load. One-shot: cleared whether or not it took, so a failed carry never retries every frame. if (_pendingCarry != null && _painter && _painter.IsPaintReady) { byte[] carry = _pendingCarry; _pendingCarry = null; if (_painter.LoadPaintFromSnapshot(carry)) { if (logSync) LogSync($"carried painting onto respawned avatar ({carry.Length} bytes); rebroadcasting to peers"); BroadcastLoadedPaint(); } } // Establish + publish the reveal baseline once even when nothing was carried/loaded: a never-painted // avatar must still hand every peer an explicit (blank) snapshot, otherwise its body stays hidden // forever behind the receiver-side reveal gate. Carry/manual-load already publish (and set the // cutoff), so this only fires for the no-carry case. if (_publishedCutoff.Value < 0 && _pendingCarry == null && !_isStatue && _painter && _painter.IsPaintReady) BroadcastLoadedPaint(); // Delta-only publish: the NetworkVariable dirties only on an actual change. if (_painter && _emissionIntensity.Value != _painter.emissionIntensity) _emissionIntensity.Value = _painter.emissionIntensity; // Camo/height publish, delta-only and gated on IsReady so the pre-Start 0-read is never // published (it would briefly flatten remotes' lighting until the real value lands). if (_painter && _painter.IsReady) { Vector3 camo = _painter.CamoKnobs; if (_camo.Value != camo) _camo.Value = camo; Vector3 height = _painter.HeightKnobs; if (_height.Value != height) _height.Value = height; } FlushOutgoing(unlimited: false); } /// /// Sends the outgoing FIFO in order: runs of consecutive stamps are batched (capped per frame unless /// ), checkpoints/restores go out as tiny markers in their exact position. /// Each item gets its own monotonic sequence. With the per-frame cap reached, the unsent tail (stamps /// and any markers behind them) stays queued for the next frame - never reordered, never dropped. /// private void FlushOutgoing(bool unlimited) { if (_outgoing.Count == 0) return; int cap = Mathf.Max(1, maxStampsPerFrame); int sentStamps = 0; int consumed = 0; while (consumed < _outgoing.Count) { OutKind kind = _outgoing[consumed].kind; if (kind == OutKind.Stamp) { if (!unlimited && sentStamps >= cap) break; // per-frame budget spent; carry the remainder (this stamp on) to next frame int budget = unlimited ? int.MaxValue : (cap - sentStamps); int runEnd = consumed; while (runEnd < _outgoing.Count && _outgoing[runEnd].kind == OutKind.Stamp && (runEnd - consumed) < budget) runEnd++; int take = runEnd - consumed; var batch = new PaintStampNet[take]; for (int k = 0; k < take; k++) batch[k] = _outgoing[consumed + k].stamp; int seq = ++_seq; _seqHorizon = seq; SubmitStampsServerRpc(seq, batch); sentStamps += take; consumed = runEnd; } else { int id = _outgoing[consumed].id; int seq = ++_seq; _seqHorizon = seq; if (kind == OutKind.Restore) { SubmitRestoreServerRpc(seq, id); if (logSync) LogSync($"sent RESTORE to checkpoint {id} (seq {seq})"); } else { SubmitCheckpointServerRpc(seq, id); if (logSync) LogSync($"sent checkpoint {id} (seq {seq})"); } consumed++; } } if (consumed > 0) _outgoing.RemoveRange(0, consumed); if (!unlimited && _outgoing.Count > 0) if (logSync) LogSync($"stamp cap ({cap}) reached; {_outgoing.Count} command(s) carried over to next frame"); } // ---- Stamp / checkpoint / restore commands (owner -> server -> remotes) ---- [ServerRpc] private void SubmitStampsServerRpc(int seq, PaintStampNet[] stamps) => ApplyStampsClientRpc(seq, stamps); [ServerRpc] private void SubmitCheckpointServerRpc(int seq, int id) => ApplyCheckpointClientRpc(seq, id); [ServerRpc] private void SubmitRestoreServerRpc(int seq, int id) => ApplyRestoreClientRpc(seq, id); /// /// Common gate for the replicated commands: the owner already applied them locally, so only remotes /// (incl. the host's replica) proceed; during a snapshot transfer the event is buffered instead. /// Returns true when the caller should apply the event live. /// private bool GateRemoteEvent(NetEvent ev) { if (IsOwner || !_painter) return false; _seqHorizon = ev.seq; // server replica tracks this as the snapshot cutoff source if (_awaitingSnapshot) { _buffered.Add(ev); return false; } return true; } [ClientRpc] private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps) { if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps })) return; for (int i = 0; i < stamps.Length; i++) _painter.ApplyNetworkStamp(stamps[i]); } [ClientRpc] private void ApplyCheckpointClientRpc(int seq, int id) { if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id })) return; _painter.RemoteCommitCheckpoint(id); if (logSync) LogSync($"committed checkpoint {id} (seq {seq})"); } [ClientRpc] private void ApplyRestoreClientRpc(int seq, int id) { if (!GateRemoteEvent(new NetEvent { seq = seq, kind = NetKind.Restore, id = id })) return; // Reproduce the undo/redo from this peer's own history; if it doesn't hold that checkpoint (an // undo past this peer's join point), fetch a fresh snapshot of the resulting state instead. if (_painter.RemoteRestoreTo(id)) { if (logSync) LogSync($"reproduced RESTORE to checkpoint {id} (seq {seq})"); } else { if (logSync) LogSync($"cannot reproduce checkpoint {id} (not in local history, seq {seq}) -> owner snapshot fallback"); RequestSnapshotFallback(); } } private void FlushBuffered() { if (_painter) { for (int b = 0; b < _buffered.Count; b++) { var ev = _buffered[b]; if (ev.seq <= _snapCapturedSeq) continue; // already inside the snapshot baseline if (ev.kind == NetKind.Stamps) { for (int i = 0; i < ev.stamps.Length; i++) _painter.ApplyNetworkStamp(ev.stamps[i]); } else if (ev.kind == NetKind.Checkpoint) { _painter.RemoteCommitCheckpoint(ev.id); } else // Restore { if (!_painter.RemoteRestoreTo(ev.id)) { // Can't reproduce -> the fresh snapshot we request next supersedes the rest. if (logSync) LogSync($"buffered RESTORE to checkpoint {ev.id} not reproducible -> owner snapshot fallback"); _buffered.Clear(); RequestSnapshotFallback(); return; } } } } _buffered.Clear(); } /// /// A peer that can't reproduce a restore from its own history asks the OWNER (the only guaranteed /// correct source of the post-restore state) for a fresh snapshot addressed back to just this peer. /// Works uniformly for a remote client and for the host's own replica. /// private void RequestSnapshotFallback() { _awaitingSnapshot = true; RequestOwnerSnapshotRpc(); } [Rpc(SendTo.Owner, InvokePermission = RpcInvokePermission.Everyone)] private void RequestOwnerSnapshotRpc(RpcParams p = default) { // Runs on the owner. Drain any carried-over commands first so the snapshot's cutoff covers them // (they would otherwise replay on top of a snapshot that already contains them), then capture the // authoritative current maps and ship them to just the requester. if (!_painter) return; FlushOutgoing(unlimited: true); if (!_painter.TryCaptureSnapshot(out byte[] data)) return; ulong requester = p.Receive.SenderClientId; int baselineId = _painter.CurrentCheckpointId; if (logSync) LogSync($"serving fallback snapshot to client {requester} (cutoff {_seq}, baseline {baselineId}, {data.Length} bytes)"); StartCoroutine(UploadSnapshot(data, _seq, baselineId, broadcast: false, target: requester)); } // ---- Late-join snapshot transfer (server -> the one joining client) ---- [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Everyone)] private void RequestSnapshotRpc(RpcParams p = default) { if (!_painter || !_painter.TryCaptureSnapshot(out byte[] data)) return; // Cutoff = everything the captured maps already contain (owner: its send counter; otherwise the // last message this replica applied). The checkpoint id tags the baseline the joiner adopts. int capturedSeq = IsOwner ? _seq : _seqHorizon; int baselineId = _painter.CurrentCheckpointId; if (logSync) LogSync($"serving late-join snapshot to client {p.Receive.SenderClientId} (cutoff {capturedSeq}, baseline {baselineId}, {data.Length} bytes)"); StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq, baselineId)); } // Server-side monotonic id stamped on each outgoing snapshot transfer, so a replica that receives two // overlapping transfers (respawn: its late-join + the owner's carry) can tell them apart and isolate them. private int _xferOut; private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq, int baselineId) { var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = targets } }; int xferId = ++_xferOut; BeginSnapshotClientRpc(xferId, data.Length, capturedSeq, baselineId, send); yield return SendChunked(data, (off, chunk) => SnapshotChunkClientRpc(xferId, off, chunk, send)); EndSnapshotClientRpc(xferId, send); } /// Feeds a blob chunk-wise into sendChunk(offset, chunk), throttled to snapshotChunksPerFrame. private IEnumerator SendChunked(byte[] data, System.Action sendChunk) { int sentThisFrame = 0; for (int off = 0; off < data.Length; off += snapshotChunkBytes) { int len = Mathf.Min(snapshotChunkBytes, data.Length - off); var chunk = new byte[len]; System.Array.Copy(data, off, chunk, 0, len); sendChunk(off, chunk); if (++sentThisFrame >= snapshotChunksPerFrame) { sentThisFrame = 0; yield return null; // spread the burst so the reliable window does not overflow } } } [ClientRpc] private void BeginSnapshotClientRpc(int xferId, int totalBytes, int capturedSeq, int baselineId, ClientRpcParams p = default) { // Drop a transfer that carries older state than one already accepted/applied (a late blank late-join // racing the carry). Equal cutoff is allowed so the owner-snapshot heal still lands. if (capturedSeq < _acceptedCutoffFloor) { if (logSync) LogSync($"ignoring stale snapshot #{xferId} (cutoff {capturedSeq} < accepted {_acceptedCutoffFloor})"); return; } _acceptedCutoffFloor = capturedSeq; _activeXferId = xferId; _snapBuffer = new byte[totalBytes]; _snapCapturedSeq = capturedSeq; _snapBaselineId = baselineId; _snapReadyToApply = false; _awaitingSnapshot = true; if (logSync) LogSync($"receiving snapshot #{xferId} ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})"); } [ClientRpc] private void SnapshotChunkClientRpc(int xferId, int offset, byte[] chunk, ClientRpcParams p = default) { if (xferId != _activeXferId || _snapBuffer == null) return; // stale chunk from a superseded transfer CopyChunkInto(_snapBuffer, offset, chunk); } /// Copies a received chunk into the reassembly buffer, ignoring out-of-bounds offsets. private static void CopyChunkInto(byte[] buffer, int offset, byte[] chunk) { if (offset >= 0 && offset + chunk.Length <= buffer.Length) System.Array.Copy(chunk, 0, buffer, offset, chunk.Length); } [ClientRpc] private void EndSnapshotClientRpc(int xferId, ClientRpcParams p = default) { if (xferId != _activeXferId) return; // stale End from a superseded transfer; the active one still owns the buffer // Apply happens in LateUpdate (once the painter is ready) so apply + buffered replay stay ordered. _snapReadyToApply = _snapBuffer != null; if (!_snapReadyToApply) _awaitingSnapshot = false; } // ---- Load re-sync (owner -> server -> every other peer) ---- // // Loading a saved painting replaces the whole map set and clears undo history, so the live stream // cannot carry it. The owner uploads a snapshot to the server (chunked), which re-broadcasts it to // every other peer; the baseline id resets each peer's history and the cutoff sequence drops any // in-flight/buffered events the loaded baseline supersedes. private byte[] _uploadBuffer; // server-side reassembly of the owner's uploaded snapshot private int _uploadCutoff; private int _uploadBaselineId; private bool _uploadBroadcast; // true = fan out to every peer but the owner (load); false = single target private ulong _uploadTarget; // the single requester when !_uploadBroadcast (fallback heal) /// Owner-only: broadcast the just-loaded painting to all other peers. No-op offline / single-player. public void BroadcastLoadedPaint() { if (!IsSpawned || !IsOwner || !_painter) return; if (!_painter.TryCaptureSnapshot(out byte[] data)) return; // The loaded baseline replaces the whole painting and clears history, so any still-queued local // stamps/checkpoints from before the load are stale - drop them rather than stream them on top. _outgoing.Clear(); int cutoff = ++_seq; // every event up to here is now superseded by the loaded baseline _seqHorizon = cutoff; int baselineId = _painter.CurrentCheckpointId; // the load's fresh baseline id if (_publishedCutoff.Value < 0) _publishedCutoff.Value = cutoff; // first publish = the receiver-side reveal baseline if (logSync) LogSync($"broadcasting loaded painting to all peers (cutoff {cutoff}, baseline {baselineId}, {data.Length} bytes)"); StartCoroutine(UploadSnapshot(data, cutoff, baselineId, broadcast: true, target: 0)); } private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target) { BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target); yield return SendChunked(data, (off, chunk) => UploadChunkServerRpc(off, chunk)); EndUploadServerRpc(); } [ServerRpc] private void BeginUploadServerRpc(int totalBytes, int cutoff, int baselineId, bool broadcast, ulong target) { _uploadBuffer = totalBytes > 0 ? new byte[totalBytes] : null; _uploadCutoff = cutoff; _uploadBaselineId = baselineId; _uploadBroadcast = broadcast; _uploadTarget = target; } [ServerRpc] private void UploadChunkServerRpc(int offset, byte[] chunk) { if (_uploadBuffer == null) return; CopyChunkInto(_uploadBuffer, offset, chunk); } [ServerRpc] private void EndUploadServerRpc() { if (_uploadBuffer == null) return; // Load broadcast: every peer except the owner (who already loaded it locally). Fallback heal: // only the single requester. A non-owner host is included by its client id like any remote. ulong[] targets = _uploadBroadcast ? OtherClientIds(OwnerClientId) : new[] { _uploadTarget }; if (targets.Length > 0) StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff, _uploadBaselineId)); _uploadBuffer = null; } /// All connected client ids except the given one (server-side). private ulong[] OtherClientIds(ulong except) { var ids = NetworkManager.ConnectedClientsIds; var list = new List(ids.Count); for (int i = 0; i < ids.Count; i++) if (ids[i] != except) list.Add(ids[i]); return list.ToArray(); } }