Hide remote avatars until this client has actually applied a snapshot reaching the owner-published baseline cutoff, preventing the default grey/white material from flashing before a player's painting has loaded. - PlayerPaintSync: owner-authoritative _publishedCutoff NetworkVariable, receiver tracks _appliedCutoff; explicit blank publish for never-painted avatars so they still reveal. - NetworkPlayerSetup: cache _paintSync, hide body renderers on spawn, gate UpdateVisibility on PaintRevealReady. - MeshPainter.Pick: fur toggle now follows pickedHeight (own paint-mesh only), matching the Occlusion/Height pipette behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
689 lines
32 KiB
C#
689 lines
32 KiB
C#
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using UnityEngine;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<OutItem> _outgoing = new List<OutItem>();
|
|
|
|
// 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<float> _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<Vector3> _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<Vector3> _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<int> _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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<NetEvent> _buffered = new List<NetEvent>();
|
|
|
|
// 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;
|
|
|
|
private void Awake() => _painter = GetComponentInChildren<MeshPainter>(true);
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (!_painter)
|
|
return;
|
|
|
|
if (IsOwner)
|
|
{
|
|
_painter.OnStampCommitted += OnStampCommitted;
|
|
_painter.OnHistoryCheckpoint += OnLocalCheckpoint;
|
|
_painter.OnHistoryRestore += OnLocalRestore;
|
|
|
|
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;
|
|
|
|
// 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.
|
|
if (!IsServer)
|
|
{
|
|
_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 && _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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends the outgoing FIFO in order: runs of consecutive stamps are batched (capped per frame unless
|
|
/// <paramref name="unlimited"/>), 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.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Feeds a blob chunk-wise into sendChunk(offset, chunk), throttled to snapshotChunksPerFrame.</summary>
|
|
private IEnumerator SendChunked(byte[] data, System.Action<int, byte[]> 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);
|
|
}
|
|
|
|
/// <summary>Copies a received chunk into the reassembly buffer, ignoring out-of-bounds offsets.</summary>
|
|
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)
|
|
|
|
/// <summary>Owner-only: broadcast the just-loaded painting to all other peers. No-op offline / single-player.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>All connected client ids except the given one (server-side).</summary>
|
|
private ulong[] OtherClientIds(ulong except)
|
|
{
|
|
var ids = NetworkManager.ConnectedClientsIds;
|
|
var list = new List<ulong>(ids.Count);
|
|
for (int i = 0; i < ids.Count; i++)
|
|
if (ids[i] != except) list.Add(ids[i]);
|
|
return list.ToArray();
|
|
}
|
|
}
|