diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 3a0089fa..5db85dbc 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -2005,7 +2005,15 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, if rootsErr != nil { mirrorRoots = nil } - if _, err := snapshot.ZipRoots(game.SavePath, mirrorRoots, zipPath); err != nil { + var captured []store.CapturedFile + if e.Snapshots != nil { + // Unchanged files copied from the newest snapshot, not compressed + // again: seconds rather than minutes for a large save (reuse.go). + _, captured, err = e.Snapshots.ArchiveSaveTo(gameID, game.ActiveBranch, zipPath) + } else { + _, captured, err = snapshot.ZipRootsCapturing(game.SavePath, mirrorRoots, zipPath) + } + if err != nil { e.Log("warn", fmt.Sprintf("mirror snapshot zip failed: %v", err)) return } @@ -2014,7 +2022,7 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, return } - _ = e.Store.CreateSnapshot(store.Snapshot{ + if err := e.Store.CreateSnapshot(store.Snapshot{ ID: remoteSnap.ID, GameID: gameID, BranchName: game.ActiveBranch, @@ -2023,7 +2031,11 @@ func (e *Engine) recordMirrorSnapshot(gameID string, game store.Game, peer Peer, IsSystemAuto: true, ZipPath: zipPath, SizeBytes: info.Size(), - }) + }); err == nil { + // What it holds, as for any snapshot: without it the next snapshot + // here could not reuse this one either. + _ = e.Store.RecordSnapshotFiles(remoteSnap.ID, captured) + } } // humanBytes formats a byte count as a short human-readable string. diff --git a/internal/p2p/syncengine/mirror_snapshot_test.go b/internal/p2p/syncengine/mirror_snapshot_test.go new file mode 100644 index 00000000..31a1d2c4 --- /dev/null +++ b/internal/p2p/syncengine/mirror_snapshot_test.go @@ -0,0 +1,30 @@ +package syncengine + +import ( + "context" + "testing" +) + +// The snapshot recorded under the peer's id after a pull records what it +// holds, like any snapshot here: the next snapshot reuses it rather than +// compressing the whole save again. +func TestSync_TheSnapshotAfterAPullRecordsItsFiles(t *testing.T) { + env := setupEngine(t) + write(t, env.remoteDir, "save.dat", "remote progress") + write(t, env.remoteDir, "profile.dat", "remote profile") + env.transport.latestSnap = &SnapshotInfo{ID: "snap_777", Timestamp: "2026-06-01T00:00:00.000Z", Comment: "peer snap"} + + if _, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer); err != nil { + t.Fatal(err) + } + if _, err := env.store.GetSnapshot("snap_777"); err != nil { + t.Fatalf("mirror snapshot not recorded: %v", err) + } + files, err := env.store.SnapshotFiles("snap_777") + if err != nil { + t.Fatal(err) + } + if len(files) != 2 { + t.Errorf("the snapshot after the pull records %d file(s), want 2: %+v", len(files), files) + } +} diff --git a/internal/snapshot/reuse.go b/internal/snapshot/reuse.go index 90da079e..a69202c1 100644 --- a/internal/snapshot/reuse.go +++ b/internal/snapshot/reuse.go @@ -6,6 +6,7 @@ import ( "sync/atomic" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" ) // Snapshots that re-pack only what changed. @@ -122,3 +123,26 @@ func (r *reuseSource) reusable(filePath, entryName string, info os.FileInfo) (st } return prevHash, true } + +// ArchiveSaveTo archives a game's save — every location — into zipPath the way +// a snapshot here is archived: files unchanged since the branch's newest +// snapshot are copied from it rather than compressed again, and what it holds +// is returned for recording. For the snapshot recorded under the other +// device's id after a pull (syncengine recordMirrorSnapshot), which compressed +// the whole save from scratch: a quarter-million-file save sat at "100%" for +// minutes after its download had finished. +func (m *Manager) ArchiveSaveTo(gameID, branch, zipPath string) (skipped []string, captured []store.CapturedFile, err error) { + m.inFlight.Add() + defer m.inFlight.Done() + game, err := m.Store.GetGame(gameID) + if err != nil { + return nil, nil, err + } + extra, rootsErr := m.Store.GameRootPaths(gameID) + if rootsErr != nil { + extra = nil + } + reuse := m.openReuseSource(gameID, branch) + defer reuse.Close() + return zipRootsCapturing(game.SavePath, extra, zipPath, reuse) +} diff --git a/internal/snapshot/reuse_test.go b/internal/snapshot/reuse_test.go index 104a8efd..1e55145d 100644 --- a/internal/snapshot/reuse_test.go +++ b/internal/snapshot/reuse_test.go @@ -2,6 +2,7 @@ package snapshot import ( "fmt" + "path/filepath" "testing" ) @@ -67,3 +68,29 @@ func TestSnapshotReusesUnchangedFiles(t *testing.T) { t.Errorf("second snapshot records %d files, want 51", len(files)) } } + +// The archive written for a snapshot recorded after a pull reuses the +// unchanged files of the branch's newest snapshot too, and says what it holds. +func TestArchiveSaveToReusesUnchangedFiles(t *testing.T) { + env := setup(t) + for i := 0; i < 20; i++ { + write(t, env.saveDir, fmt.Sprintf("map/chunk_%02d.bin", i), fmt.Sprintf("chunk %d ........................", i)) + } + if _, err := env.mgr.Create("game1", "before the pull", false); err != nil { + t.Fatal(err) + } + // What a pull wrote: one chunk changed. + write(t, env.saveDir, "map/chunk_05.bin", "from the other device") + + zipPath := filepath.Join(t.TempDir(), "mirror.zip") + skipped, captured, err := env.mgr.ArchiveSaveTo("game1", "main", zipPath) + if err != nil || len(skipped) > 0 { + t.Fatalf("archive: skipped=%v err=%v", skipped, err) + } + if got := lastReused.Load(); got != 19 { + t.Errorf("copied %d unchanged entries, want 19", got) + } + if len(captured) != 20 { + t.Errorf("recorded %d files, want 20", len(captured)) + } +}