diff --git a/Assets/Prefabs/PaintHudPanel.prefab b/Assets/Prefabs/PaintHudPanel.prefab index 7dd8edc..e9307f6 100644 --- a/Assets/Prefabs/PaintHudPanel.prefab +++ b/Assets/Prefabs/PaintHudPanel.prefab @@ -3527,7 +3527,7 @@ RectTransform: m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 0, y: 0} + m_SizeDelta: {x: 100, y: 0} m_Pivot: {x: 0.5, y: 1} --- !u!114 &6115439559885354559 MonoBehaviour: @@ -3542,10 +3542,10 @@ MonoBehaviour: m_Name: m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.VerticalLayoutGroup m_Padding: - m_Left: 6 - m_Right: 6 - m_Top: 6 - m_Bottom: 6 + m_Left: 4 + m_Right: 4 + m_Top: 4 + m_Bottom: 4 m_ChildAlignment: 0 m_Spacing: 3 m_ChildForceExpandWidth: 1 diff --git a/Assets/Scripts/MeshPainter.Backend.cs b/Assets/Scripts/MeshPainter.Backend.cs index 6e77258..eb12a5e 100644 --- a/Assets/Scripts/MeshPainter.Backend.cs +++ b/Assets/Scripts/MeshPainter.Backend.cs @@ -35,6 +35,19 @@ public partial class MeshPainter Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this); } + /// + /// Tears down the active backend and rebuilds it from externally supplied canonical maps (load / + /// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for + /// the new content. The input arrays are never mutated (SetupBackend clones its seed). + /// + private void ReseedCanonical(Color32[][] canonical) + { + if (_gpuActive) TeardownGpu(); + else TeardownCpu(); + SetupBackend(useGpu, canonical); + _hasLastScreen = false; + } + /// Reads a map from the active backend into canonical (material) layout. private Color32[] ReadCanonical(int mi) { diff --git a/Assets/Scripts/MeshPainter.Net.cs b/Assets/Scripts/MeshPainter.Net.cs index 46c5f7c..0a6759e 100644 --- a/Assets/Scripts/MeshPainter.Net.cs +++ b/Assets/Scripts/MeshPainter.Net.cs @@ -155,6 +155,10 @@ public partial class MeshPainter PaintStamp3D(s.P, s.R, s.U, s.V, s.N); EndCpuStroke(); } + + // Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same + // history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner). + MarkStrokePainted(); } // --- Late-join snapshot: the whole painted state as a compressed blob --- @@ -210,8 +214,12 @@ public partial class MeshPainter return true; } - /// Decompresses a snapshot and re-seeds the active backend with it. Caller must ensure IsPaintReady. - public void ApplyPaintSnapshot(byte[] compressed) + /// + /// Decompresses a snapshot, re-seeds the active backend with it, and makes it this peer's sole history + /// baseline under the authority's (so subsequent checkpoint/restore + /// commands line up). Caller must ensure IsPaintReady. + /// + public void ApplyPaintSnapshot(byte[] compressed, int baselineId) { if (!_setupValid || compressed == null || compressed.Length == 0) return; @@ -226,10 +234,8 @@ public partial class MeshPainter // Re-seed exactly like SwitchBackend, but with externally supplied maps, so the island/dilation // seeding path runs and the edge padding is rebuilt correctly for the loaded content. - if (_gpuActive) TeardownGpu(); - else TeardownCpu(); - SetupBackend(useGpu, canonical); - _hasLastScreen = false; + ReseedCanonical(canonical); + SetHistoryBaseline(canonical, baselineId); } private Color32[][] DecodeSnapshot(byte[] compressed) diff --git a/Assets/Scripts/MeshPainter.Persistence.cs b/Assets/Scripts/MeshPainter.Persistence.cs index 1ddb08b..10dde16 100644 --- a/Assets/Scripts/MeshPainter.Persistence.cs +++ b/Assets/Scripts/MeshPainter.Persistence.cs @@ -121,10 +121,11 @@ public partial class MeshPainter // Re-seed exactly like SwitchBackend / ApplyPaintSnapshot, so the island/dilation seeding path // runs and the edge padding is rebuilt correctly for the loaded content. - if (_gpuActive) TeardownGpu(); - else TeardownCpu(); - SetupBackend(useGpu, canonical); - _hasLastScreen = false; + ReseedCanonical(canonical); + + // A load replaces the whole painting and is NOT undoable: the loaded maps become the new history + // baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast. + ResetHistoryToCurrent(canonical); return true; } diff --git a/Assets/Scripts/MeshPainter.Undo.cs b/Assets/Scripts/MeshPainter.Undo.cs new file mode 100644 index 0000000..1d5df5a --- /dev/null +++ b/Assets/Scripts/MeshPainter.Undo.cs @@ -0,0 +1,264 @@ +using System.Collections.Generic; +using UnityEngine; +#if ENABLE_INPUT_SYSTEM +using UnityEngine.InputSystem; +#endif + +public partial class MeshPainter +{ + // --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer --- + // + // One stroke (mouse-down -> mouse-up) = one undo step. Each entry is the full canonical map set AFTER + // that edit, restored by re-seeding the backend (same path as load / late-join snapshot). The baseline + // at index 0 is the material seed (or a loaded / snapshotted baseline). + // + // Networked design: every replica keeps the SAME history, so undo/redo replicates as a tiny command + // instead of re-broadcasting the whole painting. The owner assigns each committed stroke a monotonic + // CHECKPOINT ID; remotes replay the same stamps and capture history at the same boundaries (driven by a + // network checkpoint marker) under the owner's id. Undo/redo carries the TARGET id; a peer that holds + // that id restores locally, a peer that doesn't (a late joiner asked to undo past its join) falls back + // to a snapshot. PlayerPaintSync drives all of this through OnHistoryCheckpoint / OnHistoryRestore. + // + // Memory: consecutive states differ only in the maps a stroke actually painted, so unchanged maps are + // shared by reference with the previous entry (copy-on-write). This is safe because both backends + // CLONE their seed input (SeedCpuPixels / SeedGpuMap) and ReadCanonical returns fresh arrays - nothing + // ever writes back into a stored state array. + + [Header("Undo")] + [Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " + + "the oldest kept state is the undo floor. Higher = more undo depth but more memory (only the " + + "maps a stroke changed are stored per step).")] + [Min(1)] + public int maxUndoSteps = 20; + + /// + /// Fires on the owner when a stroke is committed as a new history entry, carrying its checkpoint id. + /// PlayerPaintSync forwards a marker so every remote captures the same entry under the same id. No + /// subscriber = no-op (single-player). + /// + public event System.Action OnHistoryCheckpoint; + + /// + /// Fires on the owner when the painting is restored to a history entry (undo / redo), carrying that + /// entry's checkpoint id. PlayerPaintSync forwards the id so peers reproduce the move locally (or fetch + /// a snapshot if they lack that id). No subscriber = no-op (single-player). + /// + public event System.Action OnHistoryRestore; + + private List _undoStates; // index 0 = oldest kept state; null until InitUndoHistory ran + private List _undoIds; // parallel checkpoint id per state (owner-assigned, peer-shared) + private int _undoIndex; // the state the painter currently shows + private int _nextCheckpointId; // owner: next id to assign; remotes set ids from the network + + private bool _strokeActive; // a stroke has painted >=1 stamp and is not yet committed to history + private int _strokeDirtyMaps; // bitmask of map indices touched by the current stroke + + /// The checkpoint id of the state currently shown. Used as the baseline tag when snapshotting. + public int CurrentCheckpointId => + (_undoIds != null && _undoIndex >= 0 && _undoIndex < _undoIds.Count) ? _undoIds[_undoIndex] : 0; + + /// Seeds the history with the material baseline (id 0). Called once after the backend is set up. + private void InitUndoHistory() + { + if (_seedCanonical == null) return; + SetSingleBaseline(_seedCanonical, 0); + _nextCheckpointId = 1; + } + + /// + /// Rebuilds the whole history to a single baseline entry holding (per-map null + /// falls back to the live backend), tagged . Clears all undo/redo. Used by the + /// material seed, a load (owner) and an applied snapshot (remote). Takes ownership of the passed arrays. + /// + private void SetSingleBaseline(Color32[][] maps, int id) + { + if (_undoStates == null) _undoStates = new List(Mathf.Max(2, maxUndoSteps + 1)); + if (_undoIds == null) _undoIds = new List(Mathf.Max(2, maxUndoSteps + 1)); + _undoStates.Clear(); + _undoIds.Clear(); + + int n = textureSize * textureSize; + var baseline = new Color32[MAP_COUNT][]; + for (int mi = 0; mi < MAP_COUNT; mi++) + { + var s = (maps != null) ? maps[mi] : null; + baseline[mi] = (s != null && s.Length == n) ? s : ReadCanonical(mi); + } + _undoStates.Add(baseline); + _undoIds.Add(id); + _undoIndex = 0; + _strokeActive = false; + _strokeDirtyMaps = 0; + } + + /// Owner-only: replace the whole history with the current maps as the sole baseline (used by load). + private int ResetHistoryToCurrent(Color32[][] maps) + { + int id = _nextCheckpointId++; + SetSingleBaseline(maps, id); + return id; + } + + /// Remote-only: make the just-applied snapshot maps the sole history baseline under the authority's id. + public void SetHistoryBaseline(Color32[][] maps, int baselineId) + { + if (!_setupValid) return; + SetSingleBaseline(maps, baselineId); + } + + /// Marks that the current stroke painted a stamp (called per committed stamp, local or replicated). + private void MarkStrokePainted() + { + _strokeActive = true; + if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO; + if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO; + if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM; + } + + /// + /// Owner: commits an in-progress stroke as one undo step and announces its checkpoint id (no-op if + /// nothing was painted). Remotes use RemoteCommitCheckpoint instead. + /// + private void EndStrokeIfActive() + { + if (!_strokeActive) return; + + // Stroke held but nothing actually painted (no active channel / no stamp landed): drop it without + // consuming a checkpoint id, so the owner only ever announces ids it really holds an entry for. + if (_strokeDirtyMaps == 0) + { + _strokeActive = false; + return; + } + + int id = _nextCheckpointId++; + CaptureUndoState(_strokeDirtyMaps, id); + _strokeActive = false; + _strokeDirtyMaps = 0; + OnHistoryCheckpoint?.Invoke(id); + } + + /// + /// Remote: commits the stamps replayed since the last checkpoint as one undo step under the owner's id, + /// keeping this peer's history in lockstep. Always records the id - even if (rarely) no stamp landed + /// here for the stroke - so every replica's history stays 1:1 aligned with the owner's and a later + /// undo/redo to this id reproduces locally instead of forcing a snapshot fallback. + /// + public void RemoteCommitCheckpoint(int id) + { + if (!_setupValid) return; + CaptureUndoState(_strokeDirtyMaps, id, forceEntry: true); + _strokeActive = false; + _strokeDirtyMaps = 0; + } + + /// + /// Snapshots the current maps as a new history entry under , dropping any redo + /// branch. With an entry is added even when nothing changed (it then + /// shares every map with the previous entry via COW) - used by remote checkpoints to keep ids aligned. + /// + private void CaptureUndoState(int dirtyMaps, int id, bool forceEntry = false) + { + if (_undoStates == null) return; + if (dirtyMaps == 0 && !forceEntry) return; + + // A fresh edit invalidates everything ahead of the cursor (the redo branch). + if (_undoIndex < _undoStates.Count - 1) + { + int from = _undoIndex + 1; + int n = _undoStates.Count - from; + _undoStates.RemoveRange(from, n); + _undoIds.RemoveRange(from, n); + } + + Color32[][] prev = _undoStates[_undoIndex]; + var state = new Color32[MAP_COUNT][]; + for (int mi = 0; mi < MAP_COUNT; mi++) + { + bool changed = (dirtyMaps & (1 << mi)) != 0; + state[mi] = changed ? ReadCanonical(mi) : prev[mi]; // COW: share maps the stroke didn't touch + } + + _undoStates.Add(state); + _undoIds.Add(id); + _undoIndex = _undoStates.Count - 1; + + // Bound memory: keep at most maxUndoSteps edits beyond the oldest kept state. + int maxEntries = Mathf.Max(1, maxUndoSteps) + 1; + while (_undoStates.Count > maxEntries) + { + _undoStates.RemoveAt(0); + _undoIds.RemoveAt(0); + _undoIndex--; + } + } + + /// Owner: steps back one edit and announces the restore. Returns false if already at the floor. + public bool Undo() + { + if (_undoStates == null || _undoIndex <= 0) return false; + _undoIndex--; + RestoreCurrentState(); + return true; + } + + /// Owner: steps forward one edit (after an undo). Returns false if already at the newest state. + public bool Redo() + { + if (_undoStates == null || _undoIndex >= _undoStates.Count - 1) return false; + _undoIndex++; + RestoreCurrentState(); + return true; + } + + private void RestoreCurrentState() + { + ReseedCanonical(_undoStates[_undoIndex]); + _strokeActive = false; + _strokeDirtyMaps = 0; + OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id + } + + /// + /// Remote: restores to the history entry tagged . Returns false (and does nothing) + /// if this peer doesn't hold that id - the caller then falls back to fetching a snapshot. + /// + public bool RemoteRestoreTo(int id) + { + if (_undoStates == null || _undoIds == null) return false; + int idx = _undoIds.IndexOf(id); + if (idx < 0) return false; + + _undoIndex = idx; + ReseedCanonical(_undoStates[idx]); + _strokeActive = false; + _strokeDirtyMaps = 0; + return true; + } + + /// Reads Ctrl+Z / Ctrl+Y this frame. Modifier-gated so plain Z/Y never trigger it. + private void GetUndoRedoInput(out bool undo, out bool redo) + { + undo = false; + redo = false; +#if ENABLE_INPUT_SYSTEM + var kb = Keyboard.current; + if (kb != null) + { + if (kb.leftCtrlKey.isPressed || kb.rightCtrlKey.isPressed) + { + undo = kb.zKey.wasPressedThisFrame; + redo = kb.yKey.wasPressedThisFrame; + } + return; + } +#endif +#if ENABLE_LEGACY_INPUT_MANAGER + if (Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl)) + { + undo = Input.GetKeyDown(KeyCode.Z); + redo = Input.GetKeyDown(KeyCode.Y); + } +#endif + } +} diff --git a/Assets/Scripts/MeshPainter.Undo.cs.meta b/Assets/Scripts/MeshPainter.Undo.cs.meta new file mode 100644 index 0000000..8068925 --- /dev/null +++ b/Assets/Scripts/MeshPainter.Undo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d7335962dd88b844bdaac92a7cb8d85 \ No newline at end of file diff --git a/Assets/Scripts/MeshPainter.cs b/Assets/Scripts/MeshPainter.cs index ac92f74..611c11b 100644 --- a/Assets/Scripts/MeshPainter.cs +++ b/Assets/Scripts/MeshPainter.cs @@ -303,6 +303,7 @@ public partial class MeshPainter : MonoBehaviour // HUD library (MeshPainter.Persistence), never auto-loaded on start. SetupBackend(useGpu, _seedCanonical); ConfigureMaterialPbr(); + InitUndoHistory(); // baseline = material seed, captured before any stroke EnsureProceduralMask(); ResolveActiveMask(); @@ -592,10 +593,22 @@ public partial class MeshPainter : MonoBehaviour // Painting disabled by the game mode (e.g. player-control mode): do nothing. if (!PaintingEnabled) { + EndStrokeIfActive(); // finalize an in-progress stroke if the mode was left mid-paint _hasLastScreen = false; return; } + // Undo / redo (Ctrl+Z / Ctrl+Y): only here, since PaintingEnabled is true only on the local + // owner in paint mode. Commit any in-progress stroke first so it becomes its own history step. + GetUndoRedoInput(out bool wantUndo, out bool wantRedo); + if (wantUndo || wantRedo) + { + EndStrokeIfActive(); + if (wantUndo) Undo(); + else Redo(); + return; + } + if (updateColliderEveryFrame && _skinned) { BakeColliderMesh(); @@ -617,6 +630,7 @@ public partial class MeshPainter : MonoBehaviour // entirely (no painting, no brush preview ring while active). if (pipette) { + EndStrokeIfActive(); // switching to the eyedropper ends any active stroke if (pressed) { if (!_pickHeld) PickAtScreen(screenPos); @@ -635,6 +649,7 @@ public partial class MeshPainter : MonoBehaviour if (!pressed) { + EndStrokeIfActive(); // mouse released -> commit the stroke as one undo step _hasLastScreen = false; return; } @@ -717,6 +732,7 @@ public partial class MeshPainter : MonoBehaviour else PaintStamp3D(pLocal, radiusLocal, u, v, nLocal); EmitStamp(pLocal, radiusLocal, u, v, nLocal); + MarkStrokePainted(); // a stamp landed -> this stroke is undoable } /// diff --git a/Assets/Scripts/PlayerPaintSync.cs b/Assets/Scripts/PlayerPaintSync.cs index 8fa9338..1dea744 100644 --- a/Assets/Scripts/PlayerPaintSync.cs +++ b/Assets/Scripts/PlayerPaintSync.cs @@ -11,11 +11,21 @@ using UnityEngine; /// commands (~tens of bytes each), so two players see each other paint live without streaming the /// 12 MB of paint maps. /// -/// Late join: a client that connects AFTER painting happened also 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 stamps that arrive during -/// the transfer, and after applying the snapshot replays the buffered stamps newer than the snapshot's -/// cutoff sequence - so no stroke is lost or double-applied across the join window. +/// 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. /// /// 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 @@ -26,10 +36,16 @@ using UnityEngine; 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 decimated to this to bound the RPC size; local " + - "painting stays full fidelity.")] + "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; @@ -39,7 +55,19 @@ public class PlayerPaintSync : NetworkBehaviour [SerializeField] private int snapshotChunksPerFrame = 8; private MeshPainter _painter; - private readonly List _pending = new List(); + + // 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 @@ -47,17 +75,28 @@ public class PlayerPaintSync : NetworkBehaviour private readonly NetworkVariable _emissionIntensity = new(1f, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner); - // Monotonic batch counter on the owner; on every peer, the highest batch sequence accounted for - // (applied to a remote replica / sent by the owner). Used as the snapshot's cutoff marker. + // 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-stamp buffering until the baseline is applied --- - private bool _awaitingSnapshot; // buffer live stamps instead of applying them + // --- 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; // batches <= this are already baked into the 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<(int seq, PaintStampNet[] stamps)> _bufferedBatches = new List<(int, PaintStampNet[])>(); + private readonly List _buffered = new List(); private void Awake() => _painter = GetComponentInChildren(true); @@ -69,6 +108,8 @@ public class PlayerPaintSync : NetworkBehaviour if (IsOwner) { _painter.OnStampCommitted += OnStampCommitted; + _painter.OnHistoryCheckpoint += OnLocalCheckpoint; + _painter.OnHistoryRestore += OnLocalRestore; } else { @@ -84,6 +125,7 @@ public class PlayerPaintSync : NetworkBehaviour if (!IsServer) { _awaitingSnapshot = true; + LogSync("late joiner: requesting baseline snapshot"); RequestSnapshotRpc(); } } @@ -92,10 +134,34 @@ public class PlayerPaintSync : NetworkBehaviour public override void OnNetworkDespawn() { if (_painter && IsOwner) + { _painter.OnStampCommitted -= OnStampCommitted; + _painter.OnHistoryCheckpoint -= OnLocalCheckpoint; + _painter.OnHistoryRestore -= OnLocalRestore; + } _emissionIntensity.OnValueChanged -= OnEmissionChanged; } + // 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; @@ -103,17 +169,19 @@ public class PlayerPaintSync : NetworkBehaviour // ---- Live stamp stream (owner -> server -> remotes) ---- - private void OnStampCommitted(PaintStampNet stamp) => _pending.Add(stamp); + 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 stamps + // 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); - FlushBufferedBatches(); + _painter.ApplyPaintSnapshot(_snapBuffer, _snapBaselineId); + LogSync($"applied snapshot baseline {_snapBaselineId}; replaying buffered events"); + FlushBuffered(); _snapBuffer = null; _awaitingSnapshot = false; } @@ -126,31 +194,88 @@ public class PlayerPaintSync : NetworkBehaviour if (_painter && _emissionIntensity.Value != _painter.emissionIntensity) _emissionIntensity.Value = _painter.emissionIntensity; - if (_pending.Count == 0) + 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 seq = ++_seq; - _seqHorizon = seq; - SubmitStampsServerRpc(seq, BuildBatch()); - _pending.Clear(); + 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"); } - /// Returns this frame's stamps, uniformly decimated to maxStampsPerFrame if a fast stroke overran it. - private PaintStampNet[] BuildBatch() - { - int count = _pending.Count; - if (count <= maxStampsPerFrame) - return _pending.ToArray(); - - var outp = new PaintStampNet[maxStampsPerFrame]; - for (int i = 0; i < maxStampsPerFrame; i++) - outp[i] = _pending[(int)((long)i * count / maxStampsPerFrame)]; - return outp; - } + // ---- 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) { @@ -162,8 +287,7 @@ public class PlayerPaintSync : NetworkBehaviour if (_awaitingSnapshot) { - // Defer until the baseline is applied; the cutoff sorts out which ones to keep. - _bufferedBatches.Add((seq, stamps)); + _buffered.Add(new NetEvent { seq = seq, kind = NetKind.Stamps, stamps = stamps }); return; } @@ -171,19 +295,115 @@ public class PlayerPaintSync : NetworkBehaviour _painter.ApplyNetworkStamp(stamps[i]); } - private void FlushBufferedBatches() + [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 < _bufferedBatches.Count; b++) + for (int b = 0; b < _buffered.Count; b++) { - var (seq, stamps) = _bufferedBatches[b]; - if (seq <= _snapCapturedSeq) continue; // already inside the snapshot - for (int i = 0; i < stamps.Length; i++) - _painter.ApplyNetworkStamp(stamps[i]); + 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; + } + } } } - _bufferedBatches.Clear(); + _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) ---- @@ -195,16 +415,19 @@ public class PlayerPaintSync : NetworkBehaviour return; // Everything the captured maps already contain: the owner's send counter for our own avatar, - // otherwise the last batch this server replica has applied. + // 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; - StartCoroutine(SendSnapshot(new[] { p.Receive.SenderClientId }, data, capturedSeq)); + 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)); } - private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq) + private IEnumerator SendSnapshot(ulong[] targets, byte[] data, int capturedSeq, int baselineId) { var send = new ClientRpcParams { Send = new ClientRpcSendParams { TargetClientIds = targets } }; - BeginSnapshotClientRpc(data.Length, capturedSeq, send); + BeginSnapshotClientRpc(data.Length, capturedSeq, baselineId, send); int sentThisFrame = 0; for (int off = 0; off < data.Length; off += snapshotChunkBytes) @@ -225,12 +448,14 @@ public class PlayerPaintSync : NetworkBehaviour } [ClientRpc] - private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, ClientRpcParams p = default) + private void BeginSnapshotClientRpc(int totalBytes, int capturedSeq, int baselineId, ClientRpcParams p = default) { _snapBuffer = new byte[totalBytes]; _snapCapturedSeq = capturedSeq; + _snapBaselineId = baselineId; _snapReadyToApply = false; _awaitingSnapshot = true; + LogSync($"receiving snapshot ({totalBytes} bytes, cutoff {capturedSeq}, baseline {baselineId})"); } [ClientRpc] @@ -252,14 +477,18 @@ public class PlayerPaintSync : NetworkBehaviour // ---- Load re-sync (owner -> server -> every other peer) ---- // - // Loading a saved painting replaces the owner's whole map set at once (no stamps), 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. A - // cutoff sequence drops any in-flight/buffered stamps already superseded by the loaded baseline. + // 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() @@ -267,14 +496,20 @@ public class PlayerPaintSync : NetworkBehaviour if (!IsSpawned || !IsOwner || !_painter) return; if (!_painter.TryCaptureSnapshot(out byte[] data)) return; - int cutoff = ++_seq; // every stamp up to here is now superseded by the loaded baseline + // 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; - StartCoroutine(UploadSnapshot(data, 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) + private IEnumerator UploadSnapshot(byte[] data, int cutoff, int baselineId, bool broadcast, ulong target) { - BeginUploadServerRpc(data.Length, cutoff); + BeginUploadServerRpc(data.Length, cutoff, baselineId, broadcast, target); int sentThisFrame = 0; for (int off = 0; off < data.Length; off += snapshotChunkBytes) @@ -295,10 +530,13 @@ public class PlayerPaintSync : NetworkBehaviour } [ServerRpc] - private void BeginUploadServerRpc(int totalBytes, int cutoff) + 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] @@ -314,11 +552,12 @@ public class PlayerPaintSync : NetworkBehaviour { if (_uploadBuffer == null) return; - // Fan out to every connected peer except the owner (who already loaded it locally). The host, - // when it is not the owner, is included by its client id and applies it like any other remote. - ulong[] targets = OtherClientIds(OwnerClientId); + // 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)); + StartCoroutine(SendSnapshot(targets, _uploadBuffer, _uploadCutoff, _uploadBaselineId)); _uploadBuffer = null; }