using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using UnityEngine;
///
/// Replicates mesh painting across the network as brush-stamp commands (not pixels). The owner
/// captures every committed stamp from its , batches the frame's stamps and
/// forwards them; remote avatars replay each stamp through their own painter onto the identical
/// mesh/UVs. Position/animation come from the other sync components; this only carries the tiny paint
/// commands (~tens of bytes each), so two players see each other paint live without streaming the
/// 12 MB of paint maps.
///
/// Undo/redo replicates as commands too, not pixels: every replica keeps the SAME per-stroke history
/// (see MeshPainter.Undo), so the owner just forwards a per-stroke CHECKPOINT marker and an undo/redo
/// RESTORE id. Each peer reproduces the move from its own history. A peer that lacks the target id (a
/// late joiner asked to undo past its join) heals by pulling a fresh snapshot from the OWNER - the only
/// guaranteed-correct source of the post-restore state, so this works for a remote client and for the
/// host's own replica alike. Stamps are capped per frame but their overflow carries to the next frame
/// rather than being decimated, and checkpoint/restore markers never overtake the stamps they finalize,
/// so every replica's history stays aligned and the heal path stays a rare exception, not the norm.
///
/// Late join: a client that connects AFTER painting happened gets the accumulated result. On spawn it
/// pulls a one-time compressed snapshot of the whole map set from the server (chunked, throttled to
/// respect the transport's reliable window), buffers any live events that arrive during the transfer,
/// and after applying the snapshot replays the buffered events newer than the snapshot's cutoff
/// sequence - so no stroke is lost or double-applied across the join window. The applied snapshot also
/// becomes the joiner's history baseline.
///
/// Respawn carry: a scene switch (lobby <-> map) despawns the avatar and spawns a new prefab, which
/// would otherwise start blank. On despawn the owner parks a snapshot of its painting on the persistent
/// MatchManager; the replacement avatar's owner takes it on spawn and, once its painter is ready, loads it
/// and BroadcastLoadedPaint()s it - the exact path a manual load uses - so the painting follows the player
/// across the respawn for every peer. A real disconnect clears the parked copy, so sessions don't bleed.
/// On respawn this carry-broadcast and the replica's own late-join request both deliver a full snapshot at
/// once, so transfers are id-tagged (only the active one reassembles/applies) and cutoff-ordered (an older
/// snapshot never overwrites a newer one) - otherwise the two would share a buffer and decode corrupted.
///
/// Emission intensity is the one paint property that is NOT baked into the maps (it's a global
/// multiplier on the material's _EmissionColor), so it can't ride the stamp/snapshot path. It rides its
/// own owner-authoritative NetworkVariable; the remote painter's per-frame RefreshEmissionColor picks up
/// the applied field, and a late joiner gets the current value on spawn for free.
///
[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 (MeshPainter.emissionIntensity) — a per-player material value,
// not part of the painted maps. Owner-authoritative like the rest; the remote applies it to its
// painter, whose per-frame RefreshEmissionColor pushes it to the material (no extra material poke).
private readonly NetworkVariable _emissionIntensity =
new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
// Per-player camouflage knobs on the PlayerCamoLit material (x = HalfLambert, y = HalfLambert
// brightness, z = specular dampen). Exactly like emission intensity these are per-player material
// values, not painted into the maps, so they ride their own owner-authoritative NetworkVariable. The
// remote applies them to its material once its painter is ready and again on every change; a late
// joiner gets the current value on spawn for free. The default mirrors Player.mat's authored camo so
// the brief pre-publish spawn frame shows no flash (the owner re-publishes the real value once ready).
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 per-player material values as the camo knobs - not baked into the maps - so they
// ride their own owner-authoritative NetworkVariable and apply once the remote painter is ready. The
// default mirrors the PlayerCamoLit shader defaults (0.2 / 1.4 / 1).
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
// 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 full snapshots to the same replica at once: this replica's own
// late-join RequestSnapshotRpc AND the owner's carry BroadcastLoadedPaint. Without isolation they share
// one reassembly buffer and one End() trigger, so a half-filled/mixed Deflate blob gets decoded -> the
// "Corrupted data" warning, paint never applies. Each outgoing transfer carries a unique id; only the
// active one fills the buffer and applies, and a stale chunk/End from a superseded transfer is ignored.
private int _activeXferId;
// Highest snapshot cutoff accepted so far. A transfer carrying OLDER state is dropped, so a late blank
// late-join (server replica still empty at capture) can't overwrite the newer carry. Equal is accepted
// so the owner-snapshot heal path (same cutoff, but the diverged peer genuinely needs it) still works.
private int _acceptedCutoffFloor = int.MinValue;
// Owner-only: painting carried over from the avatar a scene switch just despawned (taken from the
// persistent MatchManager on spawn). Applied in LateUpdate once this fresh painter is ready, then
// rebroadcast to peers via the same path a manual load uses. Null = nothing to carry.
private byte[] _pendingCarry;
private void Awake() => _painter = GetComponentInChildren(true);
public override void OnNetworkSpawn()
{
if (!_painter)
return;
if (IsOwner)
{
_painter.OnStampCommitted += OnStampCommitted;
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
_painter.OnHistoryRestore += OnLocalRestore;
// Carry the painting from the avatar a scene switch just despawned (if any) onto this fresh one.
// Applied in LateUpdate once the painter has finished setup, then broadcast to peers.
if (MatchManager.Instance) _pendingCarry = MatchManager.Instance.TakeCarriedPaint();
}
else
{
// Remote replica: never paint from this machine's mouse; paint only from replicated data.
_painter.PaintingEnabled = false;
// Track the owner's emission intensity; seed the current (late-join) value right away.
_emissionIntensity.OnValueChanged += OnEmissionChanged;
_painter.emissionIntensity = _emissionIntensity.Value;
// Track the owner's camo knobs; apply the current (late-join) value once the material exists
// (deferred to LateUpdate - the setter is a no-op before the painter's Start builds the instance).
_camo.OnValueChanged += OnCamoChanged;
_camoApplyPending = true;
// Same deferred apply for the painted-height knobs.
_height.OnValueChanged += OnHeightChanged;
_heightApplyPending = true;
// The server (host) has been present since the start, so its replica already holds the full
// paint via the live stream; only a genuine late joiner needs the baseline pulled over.
if (!IsServer)
{
_awaitingSnapshot = true;
LogSync("late joiner: requesting baseline snapshot");
RequestSnapshotRpc();
}
}
}
public override void OnNetworkDespawn()
{
if (_painter && IsOwner)
{
// Park the current painting so the avatar this scene switch spawns next can restore it. A real
// disconnect clears the parked copy (MatchManager.ReturnToMainOnly), so a fresh session never
// inherits it. Captured here, the latest possible point, so last-second strokes are included.
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}]";
}
}
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);
LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events");
FlushBuffered();
_snapBuffer = null;
_awaitingSnapshot = false;
}
// Remote replica: apply the owner's camo onto this avatar's material once it exists / whenever it
// changed. The owner drives its own material via the HUD + persistence, so it never applies here.
if (!IsOwner && _camoApplyPending && _painter && _painter.IsReady)
{
Vector3 c = _camo.Value;
_painter.CamoHalfLambert = c.x;
_painter.CamoHalfLambertBrightness = c.y;
_painter.CamoSpecularDampen = c.z;
_camoApplyPending = false;
}
// Remote replica: apply the owner's painted-height knobs once the material exists / on change.
if (!IsOwner && _heightApplyPending && _painter && _painter.IsReady)
{
Vector3 h = _height.Value;
_painter.OcclusionThreshold = h.x;
_painter.HeightNormalIntensity = h.y;
_painter.ParallaxIntensity = h.z;
_heightApplyPending = false;
}
if (!IsOwner)
return;
// Restore a carried-over painting onto this fresh avatar once the painter is ready, then propagate
// it to peers exactly like a manual load (BroadcastLoadedPaint). One-shot: cleared whether or not it
// took, so a failed/empty carry never retries every frame.
if (_pendingCarry != null && _painter && _painter.IsPaintReady)
{
byte[] carry = _pendingCarry;
_pendingCarry = null;
if (_painter.LoadPaintFromSnapshot(carry))
{
LogSync($"carried painting onto respawned avatar ({carry.Length} bytes); rebroadcasting to peers");
BroadcastLoadedPaint();
}
}
// Publish the owner's global emission intensity when the HUD slider changes it (delta-only; the
// NetworkVariable dirties only on an actual change, so assigning the same value is a no-op).
if (_painter && _emissionIntensity.Value != _painter.emissionIntensity)
_emissionIntensity.Value = _painter.emissionIntensity;
// Publish the owner's camo knobs when the HUD/persistence changes them (delta-only; the
// NetworkVariable dirties only on an actual change). Gated on IsReady so the pre-Start 0-read is
// never published - that would briefly flatten remotes' lighting until the real value lands.
if (_painter && _painter.IsReady)
{
var camo = new Vector3(_painter.CamoHalfLambert, _painter.CamoHalfLambertBrightness, _painter.CamoSpecularDampen);
if (_camo.Value != camo)
_camo.Value = camo;
// Publish the painted-height knobs the same way (delta-only, ready-gated).
var height = new Vector3(_painter.OcclusionThreshold, _painter.HeightNormalIntensity, _painter.ParallaxIntensity);
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);
LogSync($"sent RESTORE to checkpoint {id} (seq {seq})");
}
else
{
SubmitCheckpointServerRpc(seq, id);
LogSync($"sent checkpoint {id} (seq {seq})");
}
consumed++;
}
}
if (consumed > 0)
_outgoing.RemoveRange(0, consumed);
if (!unlimited && _outgoing.Count > 0)
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);
[ClientRpc]
private void ApplyStampsClientRpc(int seq, PaintStampNet[] stamps)
{
// The owner already painted these locally; only remotes (incl. the host's replica) replay them.
if (IsOwner || !_painter)
return;
_seqHorizon = seq; // server replica tracks this as the snapshot cutoff source
if (_awaitingSnapshot)
{
_buffered.Add(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 (IsOwner || !_painter)
return;
_seqHorizon = seq;
if (_awaitingSnapshot)
{
_buffered.Add(new NetEvent { seq = seq, kind = NetKind.Checkpoint, id = id });
return;
}
_painter.RemoteCommitCheckpoint(id);
LogSync($"committed checkpoint {id} (seq {seq})");
}
[ClientRpc]
private void ApplyRestoreClientRpc(int seq, int id)
{
if (IsOwner || !_painter)
return;
_seqHorizon = seq;
if (_awaitingSnapshot)
{
_buffered.Add(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))
{
LogSync($"reproduced RESTORE to checkpoint {id} (seq {seq})");
}
else
{
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.
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 (which can't snapshot itself);
/// the previous host short-circuit silently dropped the heal and left the host diverged.
///
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;
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;
// Everything the captured maps already contain: the owner's send counter for our own avatar,
// otherwise the last message this server replica has applied. The maps represent the painter's
// current history entry, so its checkpoint id tags the baseline the joiner adopts.
int capturedSeq = IsOwner ? _seq : _seqHorizon;
int baselineId = _painter.CurrentCheckpointId;
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);
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);
SnapshotChunkClientRpc(xferId, off, chunk, send);
if (++sentThisFrame >= snapshotChunksPerFrame)
{
sentThisFrame = 0;
yield return null; // spread the burst so the reliable window does not overflow
}
}
EndSnapshotClientRpc(xferId, send);
}
[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)
{
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;
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
if (offset >= 0 && offset + chunk.Length <= _snapBuffer.Length)
System.Array.Copy(chunk, 0, _snapBuffer, 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 owner's whole map set at once (no stamps) and clears its undo
// history, so the live stream cannot carry it. The owner captures a snapshot, uploads it to the server
// chunked (same throttling as the download), and the server re-broadcasts it to every other peer
// through the existing snapshot client RPCs - including the host's own replica via its client-id
// target. The snapshot's baseline id makes every peer reset its history to the loaded baseline too. A
// cutoff sequence drops any in-flight/buffered events already superseded by the loaded baseline.
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
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);
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);
UploadChunkServerRpc(off, chunk);
if (++sentThisFrame >= snapshotChunksPerFrame)
{
sentThisFrame = 0;
yield return null; // spread the burst so the reliable window does not overflow
}
}
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;
if (offset >= 0 && offset + chunk.Length <= _uploadBuffer.Length)
System.Array.Copy(chunk, 0, _uploadBuffer, offset, chunk.Length);
}
[ServerRpc]
private void EndUploadServerRpc()
{
if (_uploadBuffer == null) return;
// Load broadcast: fan out to every connected peer except the owner (who already loaded it
// locally). Fallback heal: send only to the single requester. The host, when it is not the owner,
// is included by its client id and applies it like any other 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();
}
}