diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 9b7d6d99..78e99c90 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -388,6 +388,16 @@ "game.config.deviceSettings": "Device settings", "game.config.syncDeviceSettings": "Sync these settings between devices too", "game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot.", + "game.config.deviceSettingsDetectedHint": "Settings files you confirm from the suggestions are kept on each device too. A file that turns out to change along with the save syncs again by itself; to sync one yourself, untick it under “Pick from your save folder” below.", + "game.config.sourceDatabase": "game database", + "game.config.sourceDetected": "kept here by you — {reason}", + "game.config.sourceSave": "syncs again — {reason}", + "game.config.suggestedSettings": "Suggested settings files", + "game.config.suggestedHint": "These look like this device’s settings rather than the save, by what they hold. They keep syncing until you decide: keep one on this device, and each device keeps its own.", + "game.config.keepHere": "Keep on this device", + "game.config.keepSyncing": "Keep syncing", + "game.config.keptHere": "{file} is kept on this device now", + "game.config.keptSyncing": "{file} keeps syncing", "notifications.clearAll": "Clear all", "notifications.clearAllTitle": "Take these out of the bell. Activity still has them.", "palette.renameGame": "Rename {game}" diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index 7d92a282..d89442e8 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -340,6 +340,16 @@ "game.config.deviceSettings": "设备设置", "game.config.syncDeviceSettings": "也在设备之间同步这些设置", "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。", + "game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。", + "game.config.sourceDatabase": "游戏数据库", + "game.config.sourceDetected": "由你保留在本机 — {reason}", + "game.config.sourceSave": "恢复同步 — {reason}", + "game.config.suggestedSettings": "建议的设置文件", + "game.config.suggestedHint": "根据内容判断,这些文件看起来是本设备的设置而非存档。在你决定之前它们会继续同步:选择保留在本设备后,每台设备将各自保留自己的版本。", + "game.config.keepHere": "保留在本设备", + "game.config.keepSyncing": "继续同步", + "game.config.keptHere": "{file} 现在保留在本设备", + "game.config.keptSyncing": "{file} 将继续同步", "notifications.clearAll": "清除全部", "notifications.clearAllTitle": "将这些从铃铛移除,活动页仍保留", "palette.renameGame": "重命名 {game}" diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 040b7edb..34cdca92 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -340,6 +340,16 @@ "game.config.deviceSettings": "裝置設定", "game.config.syncDeviceSettings": "也在裝置之間同步這些設定", "game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。", + "game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。", + "game.config.sourceDatabase": "遊戲資料庫", + "game.config.sourceDetected": "由你保留在本機 — {reason}", + "game.config.sourceSave": "恢復同步 — {reason}", + "game.config.suggestedSettings": "建議的設定檔案", + "game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。", + "game.config.keepHere": "保留在本裝置", + "game.config.keepSyncing": "繼續同步", + "game.config.keptHere": "{file} 現在保留在本裝置", + "game.config.keptSyncing": "{file} 將繼續同步", "notifications.clearAll": "清除全部", "notifications.clearAllTitle": "將這些從鈴鐺移除,活動頁仍保留", "palette.renameGame": "重新命名 {game}" diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 8d2183a5..71770080 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -32,8 +32,17 @@ // The files the game database names as this game's settings rather than its // save (graphics, resolution): left out of syncing unless asked for. $: deviceSettings = game.deviceSettings ?? []; + // Files found by what they hold: suggested, and still syncing, until + // answered here. A wrong guess must not quietly stop part of a save syncing. + $: suggested = game.suggestedSettings ?? []; let locations = []; + function decide(s, keepHere) { + const path = s.pattern.replace(/^\//, ''); + const done = $t(keepHere ? 'game.config.keptHere' : 'game.config.keptSyncing', { file: path }); + return run(done, () => api.post(`/api/games/${game.id}/suggested-settings`, { path, keepHere })); + } + // Cover preview: a custom URL wins, else the proxied Steam art for the App // ID being edited. $: cover = @@ -166,12 +175,39 @@ can't push them out. 0 = keep forever (the default). + {#if suggested.length > 0} +
+ {$t('game.config.suggestedSettings')} + +
+ {$t('game.config.suggestedHint')} + {/if} {#if deviceSettings.length > 0}
@@ -180,6 +216,7 @@ {$t('game.config.syncDeviceSettings')} {$t('game.config.deviceSettingsHint')} + {$t('game.config.deviceSettingsDetectedHint')} {/if} 0} /> @@ -235,6 +272,25 @@ .device-settings code { font-size: 0.9em; } + .device-settings .source { + font-size: 0.82em; + color: var(--text-faint); + margin-left: 6px; + } + .device-settings .given-back code { + text-decoration: line-through; + } + /* A heading over a list, not a control: styled as the field labels are. */ + .device-block .label { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; + } + .device-settings .decide { + display: inline-flex; + gap: 6px; + margin-left: 8px; + } .device-block { margin-bottom: 4px !important; } diff --git a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte index 13d14bd8..742d059f 100644 --- a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte +++ b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte @@ -133,7 +133,10 @@ {#if f.location}{f.location} ›{/if}{f.path} - {f.excluded ? "won't sync" : 'syncs'} + + {#if !f.excluded}syncs{:else if f.source === 'database'}won't sync · settings (game database){:else if f.source === 'detected'}won't + sync · settings (detected){:else}won't sync{/if} + {/each} @@ -141,6 +144,8 @@ Ticking a file writes the pattern for you, anchored so it can only ever mean that one file. Unticking a file caught by a wildcard adds a ! exception rather than deleting the wildcard. + Files marked settings are left out as this game's device settings; unticking + one syncs it after all. {#if severalFolders} A pattern applies to every one of this game's folders, so a name that appears in two of them is excluded in both. diff --git a/internal/api/routes.go b/internal/api/routes.go index 903646e5..7b23135a 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -48,6 +48,7 @@ func (s *Server) routes(r chi.Router) { r.Post("/api/games/{gameId}/link", s.handleLinkGame) r.Get("/api/games/{gameId}/roots", s.handleListGameRoots) r.Post("/api/games/{gameId}/roots", s.handleAddGameRoot) + r.Post("/api/games/{gameId}/suggested-settings", s.handleDecideSuggestedSettings) r.Delete("/api/games/{gameId}/roots/{root}", s.handleRemoveGameRoot) r.Delete("/api/games/{gameId}/alias/{aliasId}", s.handleUnlinkGame) diff --git a/internal/api/routes_peers.go b/internal/api/routes_peers.go index 4eb9b594..256cd05b 100644 --- a/internal/api/routes_peers.go +++ b/internal/api/routes_peers.go @@ -243,13 +243,15 @@ func (s *Server) handleProbePeer(w http.ResponseWriter, r *http.Request) { } // syncReason names why a sync did not happen, for a client to act on rather -// than parse the message: "paused" (this device paused syncing), "held" (the +// than parse the message: "paused" (this device paused syncing), "playing" (the game is being played here; it syncs when the session ends), "held" (the // game's save was emptied here and waits for an answer), "offline" (no other // device answered), or "error" for anything else. func syncReason(err error) string { switch { case errors.Is(err, syncengine.ErrPaused): return "paused" + case errors.Is(err, syncengine.ErrPlayingHere): + return "playing" case errors.Is(err, syncengine.ErrHeld): return "held" case errors.Is(err, p2p.ErrNoPeersOnline): diff --git a/internal/api/routes_savefiles.go b/internal/api/routes_savefiles.go index 3f7248ad..bfdba491 100644 --- a/internal/api/routes_savefiles.go +++ b/internal/api/routes_savefiles.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "strings" "github.com/go-chi/chi/v5" + "github.com/opensave/opensave/internal/daemon" "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/ignore" ) @@ -38,6 +40,11 @@ type saveFile struct { MtimeMs int64 `json:"mtimeMs"` // Excluded reports whether the current rules stop this file syncing. Excluded bool `json:"excluded"` + // Why: "rule" (the game's own exclusion list), "database" (the game + // database names it as the game's settings) or "detected" (found to be + // its settings by what it holds). Empty when it syncs. + Source string `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` } // saveFileListCap bounds the response. A save folder with more files than @@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { // With the game's device settings, which are left out the same way. text := s.Daemon.IgnoreText(game) rules := ignore.Parse(text) + ownRules := ignore.Parse(game.SyncIgnore) + var settings []daemon.DeviceSetting + if !game.SyncDeviceSettings { + settings = s.Daemon.DeviceSettings(game) + } + // which says what excludes a file the rules exclude: the game's own + // rules first, as they apply last; then the last device-settings entry + // that names it. + which := func(path string) (string, string) { + if !ownRules.Empty() && ownRules.Match(path) { + return "rule", "" + } + for i := len(settings) - 1; i >= 0; i-- { + if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) { + return settings[i].Source, settings[i].Reason + } + } + return "rule", "" + } roots := []struct{ name, path string }{{"", game.SavePath}} if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil { @@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) { for _, f := range files { f.Location = root.name f.Excluded = rules.Match(f.Path) + if f.Excluded && root.name == "" { + f.Source, f.Reason = which(f.Path) + } else if f.Excluded { + f.Source = "rule" + } out = append(out, f) } } @@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) { } return out, truncated } + +// handleDecideSuggestedSettings answers a suggested settings file from the +// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of +// syncing as this device's settings, false keeps it syncing for good. +func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) { + gameID := chi.URLParam(r, "gameId") + game, err := s.Daemon.Store.GetGame(gameID) + if err != nil { + writeError(w, http.StatusNotFound, "Game not found.") + return + } + var body struct { + Path string `json:"path"` + KeepHere bool `json:"keepHere"` + } + if err := readJSON(r, &body); err != nil || body.Path == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil { + if errors.Is(err, daemon.ErrNotSuggested) { + writeError(w, http.StatusConflict, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.BroadcastGamesUpdate() + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} diff --git a/internal/api/server.go b/internal/api/server.go index 4d40a280..30de9e21 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -566,7 +566,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any { "syncIgnore": g.SyncIgnore, // The files the game database names as this game's settings, left // out of syncing unless syncDeviceSettings (daemon/devicesettings.go). - "deviceSettings": s.Daemon.DeviceSettings(g), + "deviceSettings": s.Daemon.DeviceSettings(g), + // Found by what they hold, and waiting to be confirmed; they keep + // syncing meanwhile (daemon.SuggestedSettings). + "suggestedSettings": s.Daemon.SuggestedSettings(g), "syncDeviceSettings": g.SyncDeviceSettings, "branches": branches, "createdAt": g.CreatedAt, diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 8abd9a26..0ba0729f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -38,6 +38,11 @@ type Options struct { // DisableDiscovery skips UDP LAN discovery (tests pair peers manually // and don't want broadcast traffic). DisableDiscovery bool + // SyncEveryChange keeps and syncs every change made here as it comes, + // never holding a stretch of changes back as play when no session is seen + // (sessions.go, noteActivity). For tests of syncing itself, which change a + // save several times a minute and expect each change to travel. + SyncEveryChange bool } // Daemon is the assembled core. Access subsystems directly for operations @@ -186,6 +191,10 @@ func New(opts Options) (*Daemon, error) { // A game's device settings are left out of syncing (devicesettings.go). d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor + // A game being played here is synced when its session ends + // (syncengine/playing.go). + d.P2P.Sync.Playing = d.playingHere + d.P2P.Sync.ObserveSettings = d.observeSettings // A restore or a branch switch rewrites a save folder; nothing syncing it // may read it half-way (syncengine/settle.go). @@ -273,7 +282,11 @@ func New(opts Options) (*Daemon, error) { SyncWritingNow: func(gameID string) bool { return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID) }, + HoldSnapshot: d.holdSnapshotWhilePlaying, OnChanged: func(gameID string) { + // How the files left out as its settings changed with it + // (detectsettings.go). + d.observeLocalSettings(gameID) // An emptied save is noticed as it happens, and said on screen, // even with no other device online to hold it back from. _, _ = d.P2P.Sync.CheckHold(gameID, false) @@ -459,6 +472,29 @@ func (d *Daemon) Start() error { } }) + // Find each game's settings by what they hold (detectsettings.go): a few + // minutes after start, then hourly for whatever is due. + d.P2P.GoSync(func(ctx context.Context) { + first := time.NewTimer(newGameFirstScan) + defer first.Stop() + select { + case <-ctx.Done(): + return + case <-first.C: + d.detectSettingsDue(ctx) + } + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + d.detectSettingsDue(ctx) + } + } + }) + // Look for newly installed games: a few minutes after start, then every // hour. See newgames.go. d.P2P.GoSync(func(ctx context.Context) { diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go new file mode 100644 index 00000000..d67b4045 --- /dev/null +++ b/internal/daemon/detectsettings.go @@ -0,0 +1,280 @@ +package daemon + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +// Finding a game's settings by what they hold, and giving back what turns out +// to be a save. +// +// Detection (presets/detectsettings.go) takes a file for settings by its +// contents. Before anything is left out, and again at every later run, the +// game's snapshots are asked how the file behaved (snapshot/history.go). And +// as long as a file is left out, how it behaves is watched: in this device's +// automatic snapshots, and in what every other device sends (observeSettings). +// A settings file is changed now and then; a save changes with the sessions +// played. One that changes along with the rest of the save in most of them is +// a save after all: it syncs again, for good, whatever named it — this +// detection or the game database — and the log says which and why. +// +// Errors cost little either way. A settings file taken for a save syncs, as +// every file did before. A save taken for settings is still in every +// snapshot, and comes back to syncing at the latest when it has been seen to +// change with the save a few times. + +// detectorVersion moves when detection changes, so every game is judged again. +const detectorVersion = "1" + +const ( + observeMinMoved = 3 // changes along with the save before a file is given back + detectEvery = 24 * time.Hour +) + +func settingsDetectMark(gameID string) string { return "settings_detect:" + gameID } + +// detectSettingsDue runs detection for every game not judged under this +// detector and folder yet, or not for a day. In the background, from Start. +func (d *Daemon) detectSettingsDue(ctx context.Context) { + games, err := d.Store.ListGames() + if err != nil { + return + } + for _, game := range games { + if ctx.Err() != nil { + return + } + mark := d.Store.Mark(settingsDetectMark(game.ID)) + want := detectorVersion + "|" + game.SavePath + if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want { + if t, err := time.Parse(time.RFC3339, at); err == nil && time.Since(t) < detectEvery { + continue + } + } + d.DetectSettings(game) + _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339)) + } +} + +// DetectSettings judges a game's files, records what is new, and re-asks the +// snapshots about everything taken for settings so far. +func (d *Daemon) DetectSettings(game store.Game) { + if d.Scanner == nil || game.SavePath == "" || SaveFolderMissing(game.SavePath) { + return + } + d.devMu.Lock() + db := d.devDBLocked() + d.devMu.Unlock() + + known := map[string]store.SettingsFile{} + if verdicts, err := d.Store.SettingsFiles(game.ID); err == nil { + for _, v := range verdicts { + known[v.Path] = v + } + } + found := db.DetectSettings(game.Name, game.AppID, game.SavePath) + var ask []string + for _, f := range found { + if _, ok := known[f.Path]; !ok { + ask = append(ask, f.Path) + } + } + for p, v := range known { + if v.Verdict == store.VerdictSettings || v.Verdict == store.VerdictSuggested { + ask = append(ask, p) + } + } + saves := d.Snapshots.ChangesLikeASave(game.ID, ask) + + // What is found here is only suggested: it keeps syncing until someone + // confirms it in the game's configuration tab (store.VerdictSuggested). + // Only what was confirmed is left out, and given back if it changes like a + // save; a suggestion that does is withdrawn the same way. + excluded, suggested := false, false + var offered, given []string + for _, f := range found { + if _, ok := known[f.Path]; ok { + continue + } + v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSuggested, Reason: f.Reason} + if why, isSave := saves[f.Path]; isSave { + v.Verdict, v.Reason = store.VerdictSave, why + } else { + offered = append(offered, f.Path) + } + if d.Store.SetSettingsFile(v) == nil { + suggested = true + } + } + for p, v := range known { + why, isSave := saves[p] + if !isSave || (v.Verdict != store.VerdictSettings && v.Verdict != store.VerdictSuggested) { + continue + } + was := v.Verdict + v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0 + if d.Store.SetSettingsFile(v) != nil { + continue + } + if was == store.VerdictSettings { + excluded = true + given = append(given, p+" ("+why+")") + } else { + suggested = true + } + } + if suggested { + d.forgetVerdicts(game.ID) + if d.OnGameChanged != nil { + d.OnGameChanged(game.ID) + } + } + if excluded { + d.exclusionsChanged(game) + } + if len(offered) > 0 && !game.SyncDeviceSettings { + d.Log.Log("info", fmt.Sprintf("%q: %s %s like this device's settings rather than the save. %s still syncing; "+ + "to keep %s on each device instead, confirm %s in the game's configuration tab.", + game.Name, strings.Join(offered, ", "), lookLike(len(offered)), itTheyAre(len(offered)), itThem(len(offered)), itThem(len(offered)))) + } + if len(given) > 0 { + d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.", + game.Name, strings.Join(given, ", "), itThey(len(given)))) + } +} + +// exclusionsChanged carries a game's recorded hashes over to its exclusion +// list as it is now (adoptExclusionView), after detection changed it. +func (d *Daemon) exclusionsChanged(game store.Game) { + d.forgetVerdicts(game.ID) + if fresh, err := d.Store.GetGame(game.ID); err == nil { + game = fresh + } + d.adoptExclusionView([]store.Game{game}) +} + +// observeSettings is told what a device holds of a game — another device's +// manifest as it arrives, or this device's own at an automatic snapshot — and +// keeps count, for every file left out as this game's settings, of how often +// it changed when the rest of the save did. peerID is "" for this device. +func (d *Daemon) observeSettings(game store.Game, peerID string, m delta.Manifest) { + if game.SyncDeviceSettings { + return + } + settings := d.deviceSettingsFor(game) + if len(settings) == 0 { + return + } + asSettings := ignore.Parse(strings.Join(settings, "\n")) + var watched []string + rest := delta.Manifest{Files: map[string]delta.FileEntry{}} + for p, f := range m.Files { + if asSettings.Match(p) { + watched = append(watched, p) + } else { + rest.Files[p] = f + } + } + if len(watched) == 0 { + return + } + user := ignore.Parse(game.SyncIgnore) + for p := range rest.Files { + if delta.NeverSynced(p) || (!user.Empty() && user.Match(p)) { + delete(rest.Files, p) + } + } + restHash := rest.ManifestHash() + + // The rest of the save, as last seen from this device. + r := d.Store.GetSettingsObservation(game.ID, peerID, "") + saveMoved := r.LastHash != "" && r.LastHash != restHash + first := r.LastHash == "" + r.LastHash = restHash + _ = d.Store.SaveSettingsObservation(r) + + var given []string + for _, p := range watched { + o := d.Store.GetSettingsObservation(game.ID, peerID, p) + h := m.Files[p].Hash + if saveMoved && o.LastHash != "" { + if o.LastHash != h { + o.Moved++ + } else { + o.Still++ + } + } + o.LastHash = h + _ = d.Store.SaveSettingsObservation(o) + if first || !saveMoved { + continue + } + moved, still := 0, 0 + if all, err := d.Store.SettingsObservations(game.ID, p); err == nil { + for _, x := range all { + moved += x.Moved + still += x.Still + } + } + if moved >= observeMinMoved && moved > still { + why := fmt.Sprintf("changed in %d of the %d times the save changed", moved, moved+still) + if d.Store.SetSettingsFile(store.SettingsFile{GameID: game.ID, Path: p, Verdict: store.VerdictSave, Reason: why}) == nil { + given = append(given, p+" ("+why+")") + } + } + } + if len(given) > 0 { + d.exclusionsChanged(game) + d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.", + game.Name, strings.Join(given, ", "), itThey(len(given)))) + } +} + +// observeLocalSettings is observeSettings for this device's own save, at an +// automatic snapshot. +func (d *Daemon) observeLocalSettings(gameID string) { + game, err := d.Store.GetGame(gameID) + if err != nil { + return + } + m, err := delta.BuildManifest(filepath.Clean(game.SavePath)) + if err != nil { + return + } + d.observeSettings(game, "", m) +} + +func itThey(n int) string { + if n == 1 { + return "it is" + } + return "they are" +} + +func lookLike(n int) string { + if n == 1 { + return "looks" + } + return "look" +} + +func itTheyAre(n int) string { + if n == 1 { + return "It is" + } + return "They are" +} + +func itThem(n int) string { + if n == 1 { + return "it" + } + return "them" +} diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go index a50b12e7..b581ec86 100644 --- a/internal/daemon/devicesettings.go +++ b/internal/daemon/devicesettings.go @@ -1,6 +1,8 @@ package daemon import ( + "errors" + "github.com/opensave/opensave/internal/delta" "github.com/opensave/opensave/internal/presets" "github.com/opensave/opensave/internal/store" @@ -23,29 +25,93 @@ import ( // is what the next start reads. type devSettingsCache struct { - loaded bool - db *presets.DeviceSettings + loaded bool + db *presets.DeviceSettings + // patterns: the game database's, by game name, App ID and folder. patterns map[string][]string + // verdicts: what was found here (detectsettings.go), by game ID. + verdicts map[string][]store.SettingsFile } -func (d *Daemon) deviceSettingsFor(game store.Game) []string { +// devDB returns the game database for device settings, read when first +// needed. Called with devMu held. Not waiting for an index due a rebuild +// (DeviceSettingsNoWait): this runs while the daemon starts. +func (d *Daemon) devDBLocked() *presets.DeviceSettings { + if !d.dev.loaded { + d.dev.db = d.Scanner.DeviceSettingsNoWait() + d.dev.loaded = true + } + if d.dev.patterns == nil { + d.dev.patterns = map[string][]string{} + } + if d.dev.verdicts == nil { + d.dev.verdicts = map[string][]store.SettingsFile{} + } + return d.dev.db +} + +// DeviceSetting is one entry of a game's device settings, for the window. +type DeviceSetting struct { + Pattern string `json:"pattern"` + // Source: "database" (the game database names it), "detected" (found by + // what it holds), or "save" (shown to change with the save, so it syncs + // whatever named it). + Source string `json:"source"` + Reason string `json:"reason,omitempty"` +} + +// deviceSettingsList is every entry, in the order they apply: the game +// database's, what was detected here, then what was shown to be a save — +// as "!" lines, which bring a file back whatever named it before. +func (d *Daemon) deviceSettingsList(game store.Game) []DeviceSetting { if d.Scanner == nil { return nil } key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath d.devMu.Lock() defer d.devMu.Unlock() - if !d.dev.loaded { - d.dev.db = d.Scanner.DeviceSettingsNoWait() - d.dev.patterns = map[string][]string{} - d.dev.loaded = true + db := d.devDBLocked() + pats, ok := d.dev.patterns[key] + if !ok { + pats = db.Patterns(game.Name, game.AppID, []string{game.SavePath}) + d.dev.patterns[key] = pats + } + verdicts, ok := d.dev.verdicts[game.ID] + if !ok { + verdicts, _ = d.Store.SettingsFiles(game.ID) + d.dev.verdicts[game.ID] = verdicts } - if p, ok := d.dev.patterns[key]; ok { - return p + var out []DeviceSetting + for _, p := range pats { + out = append(out, DeviceSetting{Pattern: p, Source: "database"}) } - p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath}) - d.dev.patterns[key] = p - return p + for _, v := range verdicts { + if v.Verdict == store.VerdictSettings { + out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "detected", Reason: v.Reason}) + } + } + for _, v := range verdicts { + if v.Verdict == store.VerdictSave { + out = append(out, DeviceSetting{Pattern: "!/" + v.Path, Source: "save", Reason: v.Reason}) + } + } + return out +} + +func (d *Daemon) deviceSettingsFor(game store.Game) []string { + list := d.deviceSettingsList(game) + out := make([]string, 0, len(list)) + for _, s := range list { + out = append(out, s.Pattern) + } + return out +} + +// forgetVerdicts drops a game's cached verdicts after they changed. +func (d *Daemon) forgetVerdicts(gameID string) { + d.devMu.Lock() + delete(d.dev.verdicts, gameID) + d.devMu.Unlock() } // IgnoreText is the exclusion list a game is synced under (see @@ -57,10 +123,10 @@ func (d *Daemon) IgnoreText(game store.Game) string { return d.P2P.Sync.IgnoreText(game) } -// DeviceSettings returns the patterns for a game's device settings, whether -// or not it is set to sync them — what the window lists. -func (d *Daemon) DeviceSettings(game store.Game) []string { - return d.deviceSettingsFor(game) +// DeviceSettings returns a game's device settings, whether or not it is set +// to sync them — what the window lists. +func (d *Daemon) DeviceSettings(game store.Game) []DeviceSetting { + return d.deviceSettingsList(game) } // adoptExclusionView carries a game's last-snapshot hash over to what the game @@ -97,3 +163,49 @@ func (d *Daemon) adoptExclusionView(games []store.Game) { } } } + +// SuggestedSettings is what was found here by what it holds and still waits +// for an answer in the game's configuration tab. Each keeps syncing until it +// is confirmed (store.VerdictSuggested). +func (d *Daemon) SuggestedSettings(game store.Game) []DeviceSetting { + verdicts, err := d.Store.SettingsFiles(game.ID) + if err != nil { + return nil + } + var out []DeviceSetting + for _, v := range verdicts { + if v.Verdict == store.VerdictSuggested { + out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "suggested", Reason: v.Reason}) + } + } + return out +} + +// ErrNotSuggested is an answer about a file that is not waiting for one. +var ErrNotSuggested = errors.New("that file is not a suggested settings file") + +// DecideSuggestedSettings records the answer to a suggestion: keepHere leaves +// the file out of syncing as this game's settings from now on, like the game +// database's (and gives it back if it then changes like a save); otherwise it +// keeps syncing, and is not suggested again. +func (d *Daemon) DecideSuggestedSettings(game store.Game, path string, keepHere bool) error { + verdicts, err := d.Store.SettingsFiles(game.ID) + if err != nil { + return err + } + for _, v := range verdicts { + if v.Path != path || v.Verdict != store.VerdictSuggested { + continue + } + v.Verdict, v.AtMs = store.VerdictDeclined, 0 + if keepHere { + v.Verdict = store.VerdictSettings + } + if err := d.Store.SetSettingsFile(v); err != nil { + return err + } + d.exclusionsChanged(game) + return nil + } + return ErrNotSuggested +} diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go index 2b94c1a4..b3ae2637 100644 --- a/internal/daemon/devicesettings_test.go +++ b/internal/daemon/devicesettings_test.go @@ -1,11 +1,13 @@ package daemon import ( + "fmt" "os" "path/filepath" "testing" "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/ignore" "github.com/opensave/opensave/internal/store" "github.com/opensave/opensave/internal/watcher" ) @@ -49,7 +51,7 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) { delta.InvalidateRoot(changed.SavePath) d.devMu.Lock() - d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}} + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}} for _, g := range []store.Game{same, changed} { d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"} } @@ -82,3 +84,55 @@ func mustGame(t *testing.T, d *Daemon, id string) store.Game { } return g } + +// A file taken for settings that another device's copy shows changing with +// every change of the save is given back to syncing; one that stays put while +// the save moves stays left out. +func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) { + d := newTestDaemon(t) + dir := t.TempDir() + game := store.Game{ID: "g", Name: "g", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5} + if err := d.Store.CreateGame(game); err != nil { + t.Fatal(err) + } + for _, p := range []string{"progress.cfg", "video.cfg"} { + if err := d.Store.SetSettingsFile(store.SettingsFile{GameID: "g", Path: p, Verdict: store.VerdictSettings, Reason: "test"}); err != nil { + t.Fatal(err) + } + } + d.devMu.Lock() + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}} + d.devMu.Unlock() + excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) } + if !excluded("progress.cfg") || !excluded("video.cfg") { + t.Fatal("setup: the detected files are not left out") + } + + manifest := func(i int, video string) delta.Manifest { + return delta.Manifest{Files: map[string]delta.FileEntry{ + "slot1.sav": {Hash: fmt.Sprint("save", i)}, + "progress.cfg": {Hash: fmt.Sprint("progress", i)}, + "video.cfg": {Hash: video}, + }} + } + d.observeSettings(game, "peerA", manifest(0, "1080p")) + for i := 1; i <= 2; i++ { + d.observeSettings(game, "peerA", manifest(i, "1080p")) + } + if !excluded("progress.cfg") { + t.Fatal("given back after two changes: too eager") + } + d.observeSettings(game, "peerA", manifest(3, "1080p")) + if excluded("progress.cfg") { + t.Error("a file that changed with the save three times out of three is still left out") + } + if !excluded("video.cfg") { + t.Error("a settings file that stayed put was given back") + } + v, _ := d.Store.SettingsFiles("g") + for _, f := range v { + if f.Path == "progress.cfg" && f.Verdict != store.VerdictSave { + t.Errorf("verdict %q, want save", f.Verdict) + } + } +} diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go index 80854eba..e45d3711 100644 --- a/internal/daemon/sessions.go +++ b/internal/daemon/sessions.go @@ -42,6 +42,29 @@ type sessionState struct { list func() ([]sessions.Proc, error) mu sync.Mutex + // cpu: each process's processor time at the last poll, and when it last + // used any (activeProcesses). + cpu map[int]procSeen + // endedAt: when each game's last session ended (noteActivity). + endedAt map[string]time.Time + // saveChanged: when each playing game's save last changed; quiet: where + // each one's quiet stretch began; quietEnded: games whose session was + // ended as quiet, by the save as it was then, while their process is still + // there (endQuietSessions). + saveChanged map[string]time.Time + quiet map[string]quietWindow + quietEnded map[string]string + // checkpoint is when each playing game last had a snapshot taken while + // it was played (holdSnapshotWhilePlaying). + checkpoint map[string]time.Time + // programs: what programsIn found in each install folder. + programs map[string]programList + // active: stretches of changes no session accounts for (noteActivity); + // warned: games already said to change with no program seen. + active map[string]*activity + warned map[string]bool + // lastChange: when each game's save last changed here. + lastChange map[string]time.Time // startHash is each playing game's save as it was when play began, to // tell afterwards whether the session changed it. startHash map[string]string @@ -79,19 +102,24 @@ func (d *Daemon) PlayingSince(gameID string) time.Time { } // PollSessions looks at what is running once. -func (d *Daemon) PollSessions() { +func (d *Daemon) PollSessions() { d.pollSessionsAt(time.Now()) } + +func (d *Daemon) pollSessionsAt(now time.Time) { targets, learn, err := d.sessionTargets() if err != nil || len(targets) == 0 { - d.sessions.tracker.Poll(nil, time.Now()) + d.sessions.tracker.Poll(nil, now) return } procs, err := d.sessions.list() if err != nil { return } + procs = d.activeProcessesAt(procs, now) running := sessions.Running(procs, targets) + d.rememberInstallFolders(procs, targets, running) d.notePrograms(procs, targets, learn, running) - d.sessions.tracker.Poll(running, time.Now()) + d.endQuietSessions(running, sessions.CPUOf(procs, targets), now) + d.sessions.tracker.Poll(running, now) } func (d *Daemon) runSessions(ctx context.Context) { @@ -145,9 +173,16 @@ func (d *Daemon) sessionTargets() ([]sessions.Target, map[string]bool, error) { } } dir, viaSteam := installFolder(g, byAppID, byFolder) + if dir == "" { + dir = d.knownInstallFolder(g.ID) + } + t.FolderNames = d.installFolderNames(g) if dir != "" { t.Dirs = append(t.Dirs, dir) } + for _, dir := range t.Dirs { + t.Programs = append(t.Programs, d.programsIn(dir)...) + } // Steam launches the games it has installed; the rest need a // program to start them, and none was given. if dir != "" && !viaSteam && exe == "" { @@ -197,6 +232,9 @@ func (d *Daemon) InstallState(g store.Game) string { if dir, _ := installFolder(g, byAppID, byFolder); dir != "" { return InstallFound } + if d.knownInstallFolder(g.ID) != "" { + return InstallFound + } if g.AppID != "" && steam { return InstallNotFound } @@ -362,9 +400,18 @@ func (d *Daemon) sessionStarted(gameID string, at time.Time) { } func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { + // The session's end keeps the save and syncs it; a stretch of changes + // it overlapped would otherwise hold the sync back for quietAfter more. + d.endActivity(gameID) d.sessions.mu.Lock() startHash := d.sessions.startHash[gameID] delete(d.sessions.startHash, gameID) + delete(d.sessions.checkpoint, gameID) + delete(d.sessions.saveChanged, gameID) + if d.sessions.endedAt == nil { + d.sessions.endedAt = map[string]time.Time{} + } + d.sessions.endedAt[gameID] = ended seen := d.sessions.seen[gameID] delete(d.sessions.seen, gameID) d.sessions.mu.Unlock() @@ -398,11 +445,13 @@ func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) { return } // Onward to the other devices, so it is there on the next one picked up. - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + // Bound to the P2P engine's lifecycle, as the watcher's syncs are, so + // shutdown cancels it. + d.P2P.GoSync(func(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() _, _ = d.P2P.SyncGame(ctx, gameID) - }() + }) } // markSessionEnd keeps the save as a session left it. The watcher has usually @@ -459,3 +508,544 @@ func (d *Daemon) endOpenSessions() { } } } + +// checkpointEvery is how often a game being played gets a snapshot of its +// save: one per autosave pushed the snapshots worth keeping out, and none +// until the session ends leaves nothing behind a crash. +const checkpointEvery = 30 * time.Minute + +// holdSnapshotWhilePlaying tells the watcher not to snapshot a change to a +// game being played — in a session, or changing with no session seen +// (noteActivity) — unless checkpointEvery has passed since play began or +// since the last checkpoint. The end of the session, or of the stretch of +// changes, keeps the save as it was left. +func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool { + // Every change made here counts, session seen or not (noteActivity). + start, stretch := d.noteActivity(gameID) + since := d.PlayingSince(gameID) + if since.IsZero() { + if !stretch { + return false + } + since = start + } + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + if d.sessions.checkpoint == nil { + d.sessions.checkpoint = map[string]time.Time{} + } + now := time.Now() + if d.sessions.saveChanged == nil { + d.sessions.saveChanged = map[string]time.Time{} + } + d.sessions.saveChanged[gameID] = now // the game is saving: being played (endQuietSessions) + if !checkpointDue(since, d.sessions.checkpoint[gameID], now) { + return true + } + d.sessions.checkpoint[gameID] = now + return false +} + +// checkpointDue says whether checkpointEvery has passed since play began, or +// since the last checkpoint when there was one. +func checkpointDue(since, last, now time.Time) bool { + if last.Before(since) { + last = since + } + return now.Sub(last) >= checkpointEvery +} + +// activeProcesses leaves out the processes doing nothing. A game leaves one +// behind when it closes or is killed — a crash handler, a stub of the same +// name — and it kept a session open for as long as it lingered: fifteen +// minutes after one game had closed. While a session is open the game's save +// is held back from syncing (syncengine/playing.go), so this is what lets it +// go once the game is gone. +func (d *Daemon) activeProcesses(procs []sessions.Proc) []sessions.Proc { + return d.activeProcessesAt(procs, time.Now()) +} + +// procSeen is a process's processor time at the last poll, and when it was +// last seen using any. +type procSeen struct { + cpu time.Duration + busy time.Time +} + +// idleFor is how long a process may use no processor time at all before it +// is no longer the game, however much or little memory it holds. A game +// being played uses some all the time — a paused one still draws its menu — +// but one minimised in the background can throttle to almost nothing, or be +// suspended, and Windows trims its memory too. Ending its session then would +// sync the save under it, which is what the session is there to stop (asked +// in review on #117). Five minutes still lets go of what a closed game left +// behind well before the quarter of an hour one lingered. +const idleFor = 5 * time.Minute + +// busyCPU is the processor time between two polls that counts as using any. +const busyCPU = 20 * time.Millisecond + +func (d *Daemon) activeProcessesAt(procs []sessions.Proc, now time.Time) []sessions.Proc { + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + prev := d.sessions.cpu + next := make(map[int]procSeen, len(procs)) + out := procs[:0:0] + for _, p := range procs { + before, seen := prev[p.PID] + if !p.Measured { + out = append(out, p) // cannot be told: counted + continue + } + s := procSeen{cpu: p.CPU, busy: before.busy} + if !seen || p.CPU-before.cpu >= busyCPU { + s.busy = now // new, or working + } + next[p.PID] = s + if now.Sub(s.busy) > idleFor { + continue // doing nothing for long enough not to be the game + } + out = append(out, p) + } + d.sessions.cpu = next + return out +} + +// quietSessionLimit is how long a session may go without its save changing, +// and its game's processes all but idle, before it is taken as over. +// +// While a game is played its save is held back from syncing +// (syncengine/playing.go), so a session that never ended would hold the save +// for good. A process left behind that still ticks over now and then — a +// crash reporter, a launcher's helper — counts as running however long it +// lingers: activeProcesses only lets go of one doing nothing at all. +// +// A game being played may well not save for an hour — one saved by hand, at +// save points — but it keeps drawing, and uses far more processor time than +// quietCPUShare. What a game left behind uses less. Ended, the session is kept +// and synced as when the game closes. +// +// Never a session begun from outside (Tracker.Marked: the Deck plugin, `opensave +// wrap`): what began it says when it ends, and knows. +const quietSessionLimit = time.Hour + +// quietCPUShare is the share of one processor core, at most, that a game's +// processes may have used over quietSessionLimit for its session to be taken +// as over: 1%, some 36 seconds in an hour. +const quietCPUShare = 100 // as a divisor + +// quietWindow is where a game's quiet stretch began: the processor time its +// processes had used by then, and when. +type quietWindow struct { + cpu time.Duration + at time.Time +} + +// endQuietSessions ends the sessions whose save has not changed, and whose +// processes have all but idled, for quietSessionLimit; and keeps them from +// starting again at once. running is the games seen running this poll, and +// cpu their processes' processor time (sessions.CPUOf); a game ended this way +// is taken out of running while its save is still as it was then. Called +// before the tracker polls. +// +// Measured in windows of quietSessionLimit from the session's start or its +// save's last change: at the end of one, the session is over if the game was +// all but idle in it; otherwise a new window begins. +func (d *Daemon) endQuietSessions(running map[string]int, cpu map[string]sessions.GameCPU, now time.Time) { + playing := d.sessions.tracker.Playing() + lastHash := func(gameID string) string { + if g, err := d.Store.GetGame(gameID); err == nil { + return g.LastManifestHash + } + return "" + } + + var end []string + d.sessions.mu.Lock() + if d.sessions.quietEnded == nil { + d.sessions.quietEnded = map[string]string{} + } + if d.sessions.quiet == nil { + d.sessions.quiet = map[string]quietWindow{} + } + for gameID, hash := range d.sessions.quietEnded { + if _, still := running[gameID]; !still { + delete(d.sessions.quietEnded, gameID) // gone: its next start is a session again + } else if lastHash(gameID) != hash { + delete(d.sessions.quietEnded, gameID) // saved since: being played after all + } else { + delete(running, gameID) + } + } + for gameID := range d.sessions.quiet { + if _, ok := playing[gameID]; !ok { + delete(d.sessions.quiet, gameID) + } + } + for gameID, since := range playing { + g, seen := cpu[gameID] + if d.sessions.tracker.Marked(gameID) || !seen || !g.Measured { + delete(d.sessions.quiet, gameID) // begun from outside, or cannot be told + continue + } + w, has := d.sessions.quiet[gameID] + if !has { + d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: since} // the first window: from the start + continue + } + if d.sessions.saveChanged[gameID].After(w.at) || g.CPU < w.cpu { + d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: now} // saved, or a new process: a new window + continue + } + span := now.Sub(w.at) + if span < quietSessionLimit { + continue + } + if g.CPU-w.cpu >= span/quietCPUShare { + d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: now} // busy: being played + continue + } + delete(d.sessions.quiet, gameID) + d.sessions.quietEnded[gameID] = lastHash(gameID) + delete(running, gameID) + end = append(end, gameID) + } + d.sessions.mu.Unlock() + + for _, gameID := range end { + name := gameID + if g, err := d.Store.GetGame(gameID); err == nil { + name = g.Name + } + d.Log.Log("info", fmt.Sprintf("%q has neither saved nor done anything for %s while seen running: taken as no longer played, so its save is kept and synced", + name, quietSessionLimit)) + d.sessions.tracker.Finish(gameID, now) + } +} + +// Programs that come with a game without being it: installers, crash +// reporters, redistributables, anti-cheat services. A process known only by +// name is never matched by one of these — every game ships the same few. +var notTheGame = []string{ + "unins", "setup", "install", "crash", "report", "redist", "vcredist", "vc_redist", "dxsetup", + "directx", "prereq", "launcher", "easyanticheat", "eac", "battleye", "beservice", "update", + "patch", "config", "settings", "helper", "service", "cef", "webhelper", "dotnet", "physx", +} + +// programsIn lists the programs in a game's install folder by file name, +// lower-case, for a process known only by name (sessions.Proc.Name). Read at +// most every installDirsFresh, three folders deep. +func (d *Daemon) programsIn(dir string) []string { + d.sessions.mu.Lock() + if c, ok := d.sessions.programs[dir]; ok && time.Since(c.at) < installDirsFresh { + d.sessions.mu.Unlock() + return c.names + } + d.sessions.mu.Unlock() + + var names []string + root := filepath.Clean(dir) + depth := strings.Count(root, string(filepath.Separator)) + _ = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error { + if err != nil { + return nil + } + if e.IsDir() { + if strings.Count(filepath.Clean(p), string(filepath.Separator))-depth >= 3 { + return filepath.SkipDir + } + return nil + } + name := strings.ToLower(e.Name()) + if !strings.HasSuffix(name, ".exe") || len(names) >= 64 { + return nil + } + for _, w := range notTheGame { + if strings.Contains(name, w) { + return nil + } + } + names = append(names, name) + return nil + }) + + d.sessions.mu.Lock() + if d.sessions.programs == nil { + d.sessions.programs = map[string]programList{} + } + d.sessions.programs[dir] = programList{at: time.Now(), names: names} + d.sessions.mu.Unlock() + return names +} + +type programList struct { + at time.Time + names []string +} + +// quietAfter is how long a game's save must stay unchanged before a stretch +// of changes no session accounts for is over: longer than the gap between +// two autosaves of the games measured (Crimson Desert: up to six minutes). +const quietAfter = 10 * time.Minute + +// activity is a stretch of changes to a game's save while no session of it +// was seen: a game whose program cannot be told apart, or is not installed +// where this device looks. It is handled as a session — not synced either +// way, a checkpoint at most every checkpointEvery — and when the save has +// stayed unchanged for quietAfter, it is kept and synced as a session's end +// would be. +type activity struct { + start, last time.Time + timer *time.Timer + // changes counts the changes in it; a change within quietAfter of the + // one before, even one already sent, counts as a second. + changes int +} + +// firstChangeWait is how long a change on its own is held before it is kept +// and synced: long enough that the next autosave of a game being played +// arrives first (Crimson Desert: 3.6 minutes between its first two), so a +// session not seen still sends one version, at its end. A game that saves +// once, as it closes, reaches the other devices this much later — when its +// session is not seen; the end of a session seen is sent at once. +const firstChangeWait = 5 * time.Minute + +// quietFor is how long the save must stay unchanged to end the stretch. +func (a *activity) quietFor() time.Duration { + if a.changes <= 1 { + return firstChangeWait + } + return quietAfter +} + +// noteActivity records a change to a game's save made here (not by a sync) +// and returns when the stretch it belongs to began, and whether it is held +// as one. Every change is: a change on its own is kept and synced once the +// save has stayed unchanged for firstChangeWait, a second within quietAfter +// of the one before makes it a stretch that ends after quietAfter unchanged. +// Only SyncEveryChange sends each change as it comes. +func (d *Daemon) noteActivity(gameID string) (time.Time, bool) { + now := time.Now() + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + if d.sessions.active == nil { + d.sessions.active = map[string]*activity{} + } + if d.sessions.lastChange == nil { + d.sessions.lastChange = map[string]time.Time{} + } + prev, seen := d.sessions.lastChange[gameID] + d.sessions.lastChange[gameID] = now + if d.opts.SyncEveryChange { + return now, false + } + // A game whose sessions are seen here, while none is open: a change is + // not play, and is sent at once (recognised). So is one just after a + // session's end: the game writing as it closes. + if d.sessions.tracker.Playing()[gameID].IsZero() && d.sessions.active[gameID] == nil && + (now.Sub(d.sessions.endedAt[gameID]) < sessionEndGrace || d.recognisedLocked(gameID)) { + return now, false + } + a := d.sessions.active[gameID] + if a == nil { + a = &activity{start: now, changes: 1} + if seen && now.Sub(prev) < quietAfter { + a.start, a.changes = prev, 2 + } + d.sessions.active[gameID] = a + a.timer = time.AfterFunc(a.quietFor(), func() { d.activityQuiet(gameID) }) + } else { + a.changes++ + a.timer.Reset(a.quietFor()) + } + a.last = now + if a.changes == 2 && d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] { + if d.sessions.warned == nil { + d.sessions.warned = map[string]bool{} + } + d.sessions.warned[gameID] = true + name := gameID + if g, err := d.Store.GetGame(gameID); err == nil { + name = g.Name + } + go d.Log.Log("info", fmt.Sprintf("%q is changing its save, but its program is not seen running here — "+ + "handled as being played: kept and synced once it has not changed for %s", name, spokenLength(quietAfter))) + } + return a.start, true +} + +// changingNow reports a game in a stretch of changes (noteActivity). +func (d *Daemon) changingNow(gameID string) bool { + d.sessions.mu.Lock() + defer d.sessions.mu.Unlock() + _, ok := d.sessions.active[gameID] + return ok +} + +// endActivity forgets a stretch of changes; a session's end takes over. +func (d *Daemon) endActivity(gameID string) { + d.sessions.mu.Lock() + if a := d.sessions.active[gameID]; a != nil { + a.timer.Stop() + delete(d.sessions.active, gameID) + } + d.sessions.mu.Unlock() +} + +// activityQuiet ends a stretch of changes once the save has stayed unchanged +// for quietAfter: the save as it was left is kept and goes to the other +// devices, as at the end of a session. +func (d *Daemon) activityQuiet(gameID string) { + d.sessions.mu.Lock() + a := d.sessions.active[gameID] + if a == nil { + d.sessions.mu.Unlock() + return + } + if wait := a.quietFor() - time.Since(a.last); wait > 0 { + a.timer.Reset(wait) + d.sessions.mu.Unlock() + return + } + delete(d.sessions.active, gameID) + delete(d.sessions.checkpoint, gameID) + start, last, stretch := a.start, a.last, a.changes > 1 + d.sessions.mu.Unlock() + + if !d.PlayingSince(gameID).IsZero() { + return // a session is open after all; its end keeps the save + } + game, err := d.Store.GetGame(gameID) + if err != nil { + return + } + if hash, err := d.currentContentHash(game); err == nil && hash != game.LastManifestHash { + comment := "Auto backup" + if last.Sub(start) >= shortestSession { + comment = fmt.Sprintf("After playing (%s)", spokenLength(last.Sub(start))) + } + if err := d.markSessionEnd(game, hash, start, comment); err != nil { + d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was left: %v", game.Name, err)) + return + } + } + if stretch { + d.Log.Log("info", fmt.Sprintf("%q has not changed its save for %s; sending it to your other devices", + game.Name, spokenLength(quietAfter))) + } + if d.OnGameChanged != nil { + d.OnGameChanged(gameID) + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + _, _ = d.P2P.SyncGame(ctx, gameID) + }() +} + +// Games installed where no library lists them — a folder of the user's own, +// "G:\Games\Crimson Desert" — are found by the name of their install folder: +// the names the game database knows it by, and the game's own name when it +// is specific enough not to be a folder of something else. Once a session +// has been seen that way, the folder is remembered, so the game is known to +// be installed and its programs can be told by name (programsIn). + +// genericFolders are names no game's folder is recognised by alone. +var genericFolders = map[string]bool{ + "games": true, "game": true, "steam": true, "steamapps": true, "common": true, "program files": true, + "program files x86": true, "bin": true, "bin64": true, "win64": true, "binaries": true, "launcher": true, + "data": true, "x64": true, "app": true, "client": true, "epic games": true, "gog games": true, +} + +func (d *Daemon) installFolderNames(g store.Game) []string { + var names []string + add := func(n string) { + n = sessions.FolderName(n) + if n == "" || genericFolders[n] { + return + } + for _, have := range names { + if have == n { + return + } + } + names = append(names, n) + } + d.devMu.Lock() + db := d.devDBLocked() + d.devMu.Unlock() + for _, n := range db.InstallNames(g.Name, g.AppID) { + if len(sessions.FolderName(n)) >= 4 { + add(n) + } + } + // The game's own name only when it could hardly name anything else: + // two words, or one long one. "Rust" is a folder in every Rust toolchain. + if n := sessions.FolderName(g.Name); strings.Contains(n, " ") && len(n) >= 6 || len(n) >= 10 { + add(g.Name) + } + return names +} + +func installFolderMark(gameID string) string { return "installdir:" + gameID } + +// knownInstallFolder is the install folder a session of the game was seen +// running from, when it is still there. +func (d *Daemon) knownInstallFolder(gameID string) string { + dir := d.Store.Mark(installFolderMark(gameID)) + if dir == "" { + return "" + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "" + } + return dir +} + +// rememberInstallFolders records, for each game found running by the name of +// its install folder, which folder that was. +func (d *Daemon) rememberInstallFolders(procs []sessions.Proc, targets []sessions.Target, running map[string]int) { + for _, t := range targets { + pid, ok := running[t.GameID] + if !ok || len(t.Dirs) > 0 { + continue + } + for _, p := range procs { + if p.PID != pid { + continue + } + if dir := sessions.FolderOf(p, t); dir != "" && d.Store.Mark(installFolderMark(t.GameID)) != dir { + _ = d.Store.SetMark(installFolderMark(t.GameID), dir) + } + } + } +} + +// playingHere says whether a game counts as being played on this device, for +// the sync engine (syncengine/playing.go): a session is open, a stretch of +// changes is (noteActivity) — or one is about to be: a change made here waits +// out the watcher's debounce. A sync landing in that gap would otherwise +// send the change on the moment before it is held. +func (d *Daemon) playingHere(gameID string) bool { + if !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) { + return true + } + return !d.opts.SyncEveryChange && d.Watcher != nil && d.Watcher.ChangePending(gameID) +} + +// sessionEndGrace is how long after a session ends a change to its save is +// still the session's — the game writing as it closes — and sent at once. +const sessionEndGrace = 2 * time.Minute + +// recognisedLocked reports a game whose sessions this device sees: one has +// been recorded, or the folder it ran from is known. While no session of such +// a game is open, a change to its save is not play, and is sent at once; +// only a game whose program is not seen is held as a stretch (noteActivity). +// Called with sessions.mu held. +func (d *Daemon) recognisedLocked(gameID string) bool { + if dir := d.Store.Mark(installFolderMark(gameID)); dir != "" { + return true + } + st, err := d.Store.PlayStatsFor(gameID) + return err == nil && st.Sessions > 0 +} diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go index 362c27c7..79912fb3 100644 --- a/internal/daemon/sessions_test.go +++ b/internal/daemon/sessions_test.go @@ -193,3 +193,265 @@ func TestSpokenLength(t *testing.T) { } } } + +// While a game is played, a change to its save is snapshotted once per +// checkpointEvery, not at every autosave; and it is not synced either way. +func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "autosaving") + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 7, Exe: g.ExePath}}, nil } + d.PollSessions() + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: not playing") + } + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Error("an autosave right after the game started was snapshotted") + } + if !d.P2P.Sync.PlayingHere(g.ID) { + t.Error("the sync engine does not know the game is being played") + } + // Checkpoints: half an hour after play began, then half an hour after + // the last one. + start := time.Now() + for _, c := range []struct { + last time.Time + after time.Duration + want bool + }{ + {time.Time{}, 10 * time.Minute, false}, + {time.Time{}, checkpointEvery, true}, + {start.Add(40 * time.Minute), 50 * time.Minute, false}, + {start.Add(40 * time.Minute), 70 * time.Minute, true}, + } { + if got := checkpointDue(start, c.last, start.Add(c.after)); got != c.want { + t.Errorf("checkpoint due %v after start (last %v) = %v, want %v", c.after, c.last, got, c.want) + } + } +} + +// A process doing nothing does not keep a session open, and with it the +// game's save held back from syncing. +func TestSessionEndsWhenOnlyAStubIsLeft(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "stub") + running := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, CPU: time.Second} + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{running}, nil } + d.PollSessions() + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: the game was not seen running") + } + // The game closed; a stub of the same program lingers, doing nothing, and + // has for longer than idleFor. + stub := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, CPU: time.Second} + d.sessions.mu.Lock() + d.sessions.cpu[9] = procSeen{cpu: time.Second, busy: time.Now().Add(-idleFor - time.Minute)} + d.sessions.mu.Unlock() + d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{stub}, nil } + d.PollSessions() + since := d.PlayingSince(g.ID) + d.sessions.tracker.Finish(g.ID, since.Add(time.Hour)) // past the grace the tracker allows + d.PollSessions() + if !d.PlayingSince(g.ID).IsZero() { + t.Fatal("a lingering stub keeps the session open") + } + if d.holdSnapshotWhilePlaying(g.ID) { + t.Error("a change after the session ended was held") + } +} + +// A process that has used no processor time for idleFor is no longer the game, +// whatever it holds: what a killed game leaves behind. One quiet for less — +// a game minimised in the background, throttled or suspended — still is, so +// its session and the hold on its save go on. One working stays. +func TestActiveProcesses_QuietForLongIsNotTheGame(t *testing.T) { + d := newTestDaemon(t) + t0 := time.Now() + left := func(cpu time.Duration) sessions.Proc { + return sessions.Proc{PID: 1, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`, Measured: true, CPU: cpu} + } + playing := func(cpu time.Duration) sessions.Proc { + return sessions.Proc{PID: 2, Exe: `G:\Games\Other\game.exe`, Measured: true, CPU: cpu} + } + count := func(ps []sessions.Proc) map[int]bool { + m := map[int]bool{} + for _, p := range ps { + m[p.PID] = true + } + return m + } + got := count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(time.Second)}, t0)) + if !got[1] || !got[2] { + t.Fatalf("first seen: %v, want both", got) + } + got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(2 * time.Second)}, t0.Add(30*time.Second))) + if !got[1] || !got[2] { + t.Errorf("half a minute in: %v, want both", got) + } + // Minimised and throttled: quiet for minutes, and still the game. + for _, at := range []time.Duration{70 * time.Second, 4 * time.Minute} { + got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(3 * time.Second)}, t0.Add(at))) + if !got[1] { + t.Errorf("quiet for %s: no longer counted, so a game in the background would lose its session", at) + } + } + got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(4 * time.Second)}, t0.Add(idleFor+10*time.Second))) + if got[1] { + t.Error("a process doing nothing for longer than idleFor still counts as the game") + } + if !got[2] { + t.Error("a game using the processor was dropped") + } +} + +// quietGame runs one game process whose processor time grows by perPoll at +// every poll. +func quietGame(t *testing.T, d *Daemon, id string, perPoll time.Duration) store.Game { + t.Helper() + g, _ := sessionGame(t, d, id) + cpu := time.Duration(0) + d.sessions.list = func() ([]sessions.Proc, error) { + cpu += perPoll + return []sessions.Proc{{PID: 7, Exe: g.ExePath, Measured: true, CPU: cpu}}, nil + } + return g +} + +// What a game left behind, still ticking over now and then, holds a session +// open, and with it the hold on the save. An hour without a save, all but +// idle, and the session is taken as over; the process still there does not +// start it again, and the save changing again does. +func TestQuietSession_ALeftoverThatTicksOverIsLetGo(t *testing.T) { + d := newTestDaemon(t) + g := quietGame(t, d, "leftover", 30*time.Millisecond) // busy enough to count, every poll + t0 := time.Now() + for i := 0; i <= 5; i++ { + d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute)) + } + if d.PlayingSince(g.ID).IsZero() { + t.Fatal("setup: the session ended before the hour") + } + d.pollSessionsAt(t0.Add(61 * time.Minute)) + if !d.PlayingSince(g.ID).IsZero() { + t.Fatal("an hour without a save, all but idle, still holds the save") + } + d.pollSessionsAt(t0.Add(62 * time.Minute)) + if !d.PlayingSince(g.ID).IsZero() { + t.Error("the process still running started the session again at once") + } + if err := d.Store.SetLastManifestHash(g.ID, "a save made since"); err != nil { + t.Fatal(err) + } + d.pollSessionsAt(t0.Add(63 * time.Minute)) + if d.PlayingSince(g.ID).IsZero() { + t.Error("the game saved again but no session started") + } +} + +// A game saved only by hand can go hours without a save while it is played. +// It keeps drawing, so its session goes on. +func TestQuietSession_AGamePlayedWithoutSavingKeepsItsSession(t *testing.T) { + d := newTestDaemon(t) + g := quietGame(t, d, "manual-saves", time.Minute) // a minute of processor time per ten + t0 := time.Now() + for i := 0; i <= 18; i++ { // three hours + d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute)) + } + if d.PlayingSince(g.ID).IsZero() { + t.Error("a game being played without saving lost its session") + } +} + +// A session begun from outside — the Deck plugin, from Steam's launch — ends +// when that says so, however quiet the game is. +func TestQuietSession_ASessionBegunFromOutsideIsNotEnded(t *testing.T) { + d := newTestDaemon(t) + g := quietGame(t, d, "deck", 30*time.Millisecond) + t0 := time.Now() + d.sessions.tracker.Begin(g.ID, t0) + for i := 0; i <= 12; i++ { + d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute)) + } + if d.PlayingSince(g.ID).IsZero() { + t.Error("a session begun by the Deck plugin was ended as quiet") + } +} + +// A game whose program is not seen: a change is held, not synced, and kept +// once the save has stayed unchanged for firstChangeWait; a second soon after +// makes it a stretch, which ends after quietAfter unchanged. +func TestActivityWithoutASessionIsHandledAsPlay(t *testing.T) { + d := newTestDaemon(t) + g, dir := sessionGame(t, d, "unseen") + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("a single change was not held") + } + d.sessions.mu.Lock() + if q := d.sessions.active[g.ID].quietFor(); q != firstChangeWait { + t.Errorf("a single change waits %v, want %v", q, firstChangeWait) + } + d.sessions.mu.Unlock() + if !d.holdSnapshotWhilePlaying(g.ID) { + t.Fatal("a second change soon after was not held") + } + d.sessions.mu.Lock() + if q := d.sessions.active[g.ID].quietFor(); q != quietAfter { + t.Errorf("a stretch waits %v, want %v", q, quietAfter) + } + d.sessions.mu.Unlock() + if !d.changingNow(g.ID) || !d.P2P.Sync.PlayingHere(g.ID) { + t.Fatal("a game changing its save is not handled as being played") + } + before := len(snapComments(t, d, g.ID)) + if err := os.WriteFile(filepath.Join(dir, "slot1.sav"), []byte("left like this"), 0o666); err != nil { + t.Fatal(err) + } + + // Quiet for longer than quietAfter. + d.sessions.mu.Lock() + d.sessions.active[g.ID].last = time.Now().Add(-quietAfter - time.Second) + d.sessions.mu.Unlock() + d.activityQuiet(g.ID) + if d.changingNow(g.ID) || d.P2P.Sync.PlayingHere(g.ID) { + t.Error("still handled as being played after the save went quiet") + } + if got := snapComments(t, d, g.ID); len(got) != before+1 { + t.Errorf("snapshots %q: want one more, keeping the save as it was left", got) + } +} + +// The names a game's install folder is recognised by: never a generic folder, +// and the game's own name only when it could hardly be another folder's. +func TestInstallFolderNames(t *testing.T) { + d := newTestDaemon(t) + for name, want := range map[string]bool{"Crimson Desert": true, "Rust": false, "Games": false, "Satisfactory": true} { + got := d.installFolderNames(store.Game{ID: name, Name: name}) + if (len(got) > 0) != want { + t.Errorf("%q: folder names %v, want some = %v", name, got, want) + } + } +} + +// A game whose sessions this device sees is not held while it is closed: a +// change then is not play, and is sent at once. Nor is one just after a +// session's end, the game writing as it closes. Only a game whose program is +// not seen is held as a stretch. +func TestActivity_ARecognisedGameIsNotHeldWhileClosed(t *testing.T) { + d := newTestDaemon(t) + g, _ := sessionGame(t, d, "recognised") + start := time.Now() + d.sessions.tracker.Begin(g.ID, start) + d.sessions.tracker.Finish(g.ID, start.Add(40*time.Minute)) + if d.holdSnapshotWhilePlaying(g.ID) || d.changingNow(g.ID) { + t.Error("a change just after the session's end was held") + } + st, _ := d.Store.PlayStatsFor(g.ID) + if st.Sessions == 0 { + t.Skip("the session was too short to be recorded here") + } + d.sessions.mu.Lock() + d.sessions.endedAt[g.ID] = start.Add(-time.Hour) // long ago + d.sessions.mu.Unlock() + if d.holdSnapshotWhilePlaying(g.ID) || d.changingNow(g.ID) { + t.Error("a change to a recognised game, closed, was held as play") + } +} diff --git a/internal/daemon/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go new file mode 100644 index 00000000..73bbc16a --- /dev/null +++ b/internal/daemon/suggested_settings_test.go @@ -0,0 +1,79 @@ +package daemon + +import ( + "os" + "path/filepath" + "testing" + + "github.com/opensave/opensave/internal/ignore" + "github.com/opensave/opensave/internal/store" +) + +// A file found by what it holds is only suggested: it keeps syncing until +// someone confirms it in the game's configuration tab, and a file someone +// chose to keep syncing is not suggested again (asked in review on #118). +func TestSuggestedSettings_SyncUntilConfirmed(t *testing.T) { + d := newTestDaemon(t) + dir := t.TempDir() + settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n" + for name, content := range map[string]string{ + "slot1.sav": "progress", + "videoconfig.txt": settings, + "display.cfg": settings, + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o666); err != nil { + t.Fatal(err) + } + } + game := store.Game{ID: "g", Name: "No Such Game In The Database", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5} + if err := d.Store.CreateGame(game); err != nil { + t.Fatal(err) + } + d.devMu.Lock() + d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}} + d.devMu.Unlock() + excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) } + + d.DetectSettings(game) + suggested := map[string]bool{} + for _, s := range d.SuggestedSettings(game) { + suggested[s.Pattern] = true + } + if !suggested["/videoconfig.txt"] || !suggested["/display.cfg"] { + t.Fatalf("setup: the settings files were not suggested: %v", suggested) + } + if suggested["/slot1.sav"] { + t.Error("the save was suggested as settings") + } + if excluded("videoconfig.txt") || excluded("display.cfg") { + t.Fatal("a suggested file was left out of syncing before anyone confirmed it") + } + + if err := d.DecideSuggestedSettings(game, "videoconfig.txt", true); err != nil { + t.Fatal(err) + } + if err := d.DecideSuggestedSettings(game, "display.cfg", false); err != nil { + t.Fatal(err) + } + if !excluded("videoconfig.txt") { + t.Error("a confirmed settings file still syncs") + } + if excluded("display.cfg") { + t.Error("a file chosen to keep syncing was left out") + } + if len(d.SuggestedSettings(game)) != 0 { + t.Errorf("answered files are still suggested: %v", d.SuggestedSettings(game)) + } + + // Detection runs again: neither answer is undone, nothing suggested anew. + d.DetectSettings(game) + if len(d.SuggestedSettings(game)) != 0 { + t.Errorf("a file already answered was suggested again: %v", d.SuggestedSettings(game)) + } + if !excluded("videoconfig.txt") || excluded("display.cfg") { + t.Error("running detection again changed what was answered") + } + if err := d.DecideSuggestedSettings(game, "display.cfg", true); err != ErrNotSuggested { + t.Errorf("answering a file that is not suggested: %v, want ErrNotSuggested", err) + } +} diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go index ba2142ff..abbea2b4 100644 --- a/internal/p2p/routes.go +++ b/internal/p2p/routes.go @@ -683,6 +683,11 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusNotFound, syncengine.HeldMessage) return } + // Being played here: synced when the session ends (syncengine/playing.go). + if e.Sync.PlayingHere(game.ID) { + jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage) + return + } // Never describe a save a sync here is writing: part-way through, it is a // mixture no device holds, and the asker would judge it as a save that had // moved (syncengine/settle.go). Held still while it is read; a write in @@ -811,6 +816,10 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusNotFound, "Game not found.") return } + if e.Sync.PlayingHere(game.ID) { + jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage) + return + } base, ok := e.resolveServeRoot(gameID, game, body.Root) if !ok { jsonError(w, http.StatusNotFound, "This device has no save location named "+strconv.Quote(body.Root)+" for that game.") diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 57a0a940..601ab54f 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -164,6 +164,17 @@ type Engine struct { // settings (presets/devicesettings.go), set by the daemon; nil leaves // them syncing. See IgnoreText. DeviceSettingsFor func(store.Game) []string + // Playing reports whether a game is being played on this device + // (playing.go). Set by the daemon from its play sessions. + Playing func(gameID string) bool + // playingMu guards playingSaid: per game and peer, whether its "playing" + // answer was said already this session (playing.go). + playingMu sync.Mutex + playingSaid map[string]bool + // ObserveSettings is told what another device holds of a game, as its + // manifest arrives, to watch how the files left out as the game's + // settings change (daemon/detectsettings.go). Set by the daemon. + ObserveSettings func(game store.Game, peerID string, remote delta.Manifest) } // New creates an Engine. @@ -263,6 +274,10 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer if e.Paused != nil && e.Paused() { return nil, ErrPaused } + // A game being played here is synced when the session ends (playing.go). + if e.PlayingHere(gameID) { + return nil, ErrPlayingHere + } // A save emptied here is not synced until someone says whether that was // meant (hold.go). held, err := e.checkHoldSettled(ctx, gameID) @@ -371,7 +386,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer // divergence from the NEXT sync, causing the peer to silently overwrite // its own changes instead of detecting the conflict and asking. switch res.Status { - case "conflict", "error", "unwritable": + case "conflict", "error", "unwritable", "peer_playing": case "peer_missing", "peer_awaiting_folder", "peer_holding": // The two devices talked and finished, which is what the // per-device stamp has always recorded. But nothing of THIS game @@ -465,6 +480,12 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re if isAwaitingFolder(err) { return Result{Status: "peer_awaiting_folder", PeerID: peer.ID, PeerName: peer.Name}, nil } + // Being played there: its save comes when the session ends, sent by + // that device. Before isSettling, whose message this one contains. + if isPlaying(err) { + e.notePeerPlaying(gameID, peer, game.Name) + return Result{Status: "peer_playing", PeerID: peer.ID, PeerName: peer.Name}, nil + } // The peer is still writing this game for a sync of its own, and // would otherwise have described a save half-way between two states // (settle.go). SyncGame asks again once it has finished. @@ -482,6 +503,7 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re } return Result{}, fmt.Errorf("fetch remote manifest: %w", err) } + e.peerNotPlaying(gameID, peer.ID) // 2. Branch alignment: local follows the remote's active branch. // @@ -554,6 +576,11 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re // would propagate a deletion of the very file being protected. ignoreRules := e.rulesFor(gameID) unfilteredLocal, unfilteredRemote := localManifest, remoteData.Manifest + // How the files left out as the game's settings change on the other + // device (daemon/detectsettings.go). + if e.ObserveSettings != nil && len(unfilteredRemote.Files) > 0 { + e.ObserveSettings(game, peer.ID, unfilteredRemote) + } // Filtered always, not only with rules: launcher files (delta.NeverSynced) // are left out the same way. localManifest = filterManifest(localManifest, ignoreRules) diff --git a/internal/p2p/syncengine/playing.go b/internal/p2p/syncengine/playing.go new file mode 100644 index 00000000..5c689617 --- /dev/null +++ b/internal/p2p/syncengine/playing.go @@ -0,0 +1,74 @@ +package syncengine + +import ( + "fmt" + "strings" +) + +// A game being played. +// +// A game autosaves every few minutes, and each autosave is a change of its +// save. Synced as they come, every one of them reached every other device as +// a version of its own, and each of those took a snapshot of it: eleven of +// one game in 26 minutes on a device nobody was sitting at. And this device +// took other devices' versions of a game running here, writing a save under +// a game that was still using it. +// +// So while a game is played here (Playing, from the daemon's sessions), it +// is not synced either way. Another device asking for it is told so, and +// asks again later; nothing is taken for it. When the session ends, the save +// as it was left goes to the other devices in one sync (daemon/sessions.go). + +// PlayingMessage is what a device answers when asked for a game being played +// on it. It contains SettlingMessage, so a device on a build that predates +// this takes it as "busy, ask again" too. +const PlayingMessage = "This device is playing this game right now; its save follows when the session ends. " + SettlingMessage + +const playingMark = "is playing this game right now" + +// isPlaying reports whether an error from a peer is its "playing" answer. +func isPlaying(err error) bool { + return err != nil && strings.Contains(err.Error(), playingMark) +} + +// playingErr is SyncGame declining to sync a game being played here. It +// counts as ErrHeld for callers that only want to know it was not an error. +type playingErr struct{} + +func (playingErr) Error() string { + return "it is being played on this device; it syncs when the session ends" +} + +func (playingErr) Is(target error) bool { return target == ErrHeld || target == ErrPlayingHere } + +// ErrPlayingHere is SyncGame declining to sync a game being played here. +var ErrPlayingHere error = playingErr{} + +// PlayingHere reports whether a game is being played on this device. +func (e *Engine) PlayingHere(gameID string) bool { + return e.Playing != nil && e.Playing(gameID) +} + +// notePeerPlaying says once per session that a peer is playing a game: the +// reconcile asks every minute, and each "playing" answer is the same news. +func (e *Engine) notePeerPlaying(gameID string, peer Peer, gameName string) { + key := gameID + "\x00" + peer.ID + e.playingMu.Lock() + said := e.playingSaid[key] + if e.playingSaid == nil { + e.playingSaid = map[string]bool{} + } + e.playingSaid[key] = true + e.playingMu.Unlock() + if !said { + e.Log("info", fmt.Sprintf("%s is playing %q right now; its save follows when the session ends", peer.Name, gameName)) + } +} + +// peerNotPlaying forgets that a peer was playing a game, once it answers +// normally again: its next session is said again. +func (e *Engine) peerNotPlaying(gameID, peerID string) { + e.playingMu.Lock() + delete(e.playingSaid, gameID+"\x00"+peerID) + e.playingMu.Unlock() +} diff --git a/internal/p2p/syncengine/playing_test.go b/internal/p2p/syncengine/playing_test.go new file mode 100644 index 00000000..90112fb3 --- /dev/null +++ b/internal/p2p/syncengine/playing_test.go @@ -0,0 +1,49 @@ +package syncengine + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +// A game being played here is not synced either way until the session ends: +// nothing is taken for it, and callers are not told it failed. +func TestPlaying_NotSyncedWhilePlayedHere(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "slot1.sav", "being played") + write(t, env.remoteDir, "slot1.sav", "another device's") + playing := true + env.engine.Playing = func(string) bool { return playing } + + _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer}) + if !errors.Is(err, ErrPlayingHere) || !errors.Is(err, ErrHeld) { + t.Fatalf("err = %v, want ErrPlayingHere, quiet as ErrHeld", err) + } + if got, _ := os.ReadFile(filepath.Join(env.localDir, "slot1.sav")); string(got) != "being played" { + t.Fatalf("the save of a running game was replaced: %q", got) + } + + playing = false + if _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer}); err != nil { + t.Fatalf("after the session: %v", err) + } +} + +// Another device playing the game answers "playing": nothing is compared or +// stamped, and asking does not count as a failure. +func TestPlaying_PeerPlayingIsWaitedFor(t *testing.T) { + env := setupEngine(t) + write(t, env.localDir, "slot1.sav", "here") + env.transport.manifestErr = errors.New(PlayingMessage) + + res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer) + if err != nil || res.Status != "peer_playing" { + t.Fatalf("status = %q, %v; want peer_playing", res.Status, err) + } + // A device on an older build reads the same answer as "busy". + if !isSettling(errors.New(PlayingMessage)) { + t.Error("the playing answer is not recognisable as busy by older builds") + } +} diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go index 9a899c0c..24acf679 100644 --- a/internal/p2p/wanclient_handlers.go +++ b/internal/p2p/wanclient_handlers.go @@ -616,6 +616,9 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str if w.engine.holdingBack(game) { return 404, map[string]string{"error": syncengine.HeldMessage} } + if w.engine.Sync.PlayingHere(game.ID) { + return 503, map[string]string{"error": syncengine.PlayingMessage} + } // As on the LAN: a save a sync here is writing is not described // (syncengine/settle.go). Each request runs on its own goroutine, so the // wait does not hold up the relay traffic that finishes the write. @@ -702,6 +705,9 @@ func (w *WanClient) serveDeleteFile(route string, rawBody json.RawMessage, fromP if err != nil { return 404, map[string]string{"error": "Game not found."} } + if w.engine.Sync.PlayingHere(game.ID) { + return 503, map[string]string{"error": syncengine.PlayingMessage} + } if !delta.IsSafePath(game.SavePath, body.RelPath) { return 403, map[string]string{"error": "invalid path"} } diff --git a/internal/presets/detectsettings.go b/internal/presets/detectsettings.go new file mode 100644 index 00000000..5fe85f2b --- /dev/null +++ b/internal/presets/detectsettings.go @@ -0,0 +1,361 @@ +package presets + +import ( + "bytes" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf16" +) + +// Detecting a game's settings files by what they hold, for games the game +// database does not cover (DeviceSettings covers the ones it does). +// +// What tells a device's settings from a save is its contents: resolution, +// vsync, shadow quality, upscaler — the vocabulary of one machine's display, +// which no save has a use for. Names were measured against the Ludusavi +// manifest's own labels (16,700 settings entries, 9,000 save entries) and do +// not tell them apart on their own: "settings" names 1,521 settings files and +// 70 saves, ".ini" 1,831 and 137, ".json" 359 and 216. So a name only adds +// weight to what the contents say, by how reliably the manifest shows it to +// mean settings; a word that names saves rules a file out. +// +// What this misses stays syncing, which is how every file synced before; what +// it wrongly takes is caught by how a save behaves — it changes with every +// session — and given back (daemon/detectsettings.go). + +// DetectedSettings is one file found to be a game's settings. +type DetectedSettings struct { + Path string // relative to the save folder, slash-separated + Reason string +} + +const ( + detectMaxSize = 256 << 10 + detectMaxFiles = 4000 // text-like files read per folder, at most +) + +var detectExts = map[string]bool{ + "ini": true, "cfg": true, "conf": true, "config": true, "xml": true, "json": true, "lua": true, + "txt": true, "yaml": true, "yml": true, "toml": true, "vdf": true, "set": true, "settings": true, + "opt": true, "prefs": true, "local": true, "properties": true, +} + +// Terms of a machine's display and graphics, as sequences of words. Matched +// in identifiers split at case changes and separators, so +// "_enableFrameGeneration", "r_materialAniso" and "Shadow Quality" all count. +var strongTerms = [][]string{ + {"resolution"}, {"fullscreen"}, {"windowed"}, {"borderless"}, {"vsync"}, {"v", "sync"}, + {"antialiasing"}, {"anti", "aliasing"}, {"aniso"}, {"anisotropic"}, {"anisotropy"}, + {"msaa"}, {"fxaa"}, {"taa"}, {"smaa"}, {"ssao"}, {"hbao"}, {"ssr"}, {"bloom"}, {"gamma"}, + {"brightness"}, {"hdr"}, {"dlss"}, {"fsr"}, {"xess"}, {"upscaling"}, {"upscaler"}, + {"monitor"}, {"adapter"}, {"gpu"}, {"vram"}, {"tessellation"}, {"raytracing"}, + {"ray", "tracing"}, {"fov"}, {"field", "view"}, {"lod"}, {"framerate"}, {"frame", "rate"}, + {"refreshrate"}, {"refresh", "rate"}, {"frame", "generation"}, {"frame", "limit"}, + {"fps", "limit"}, {"max", "fps"}, {"texture", "quality"}, {"texture", "filtering"}, + {"shadow", "quality"}, {"shadow", "resolution"}, {"motion", "blur"}, {"depth", "field"}, + {"view", "distance"}, {"draw", "distance"}, {"render", "scale"}, {"resolution", "scale"}, + {"display", "mode"}, {"window", "mode"}, {"screen", "mode"}, {"ambient", "occlusion"}, + {"screen", "width"}, {"screen", "height"}, {"quality", "preset"}, {"graphics", "quality"}, + {"film", "grain"}, {"lens", "flare"}, {"volumetric"}, {"scalability"}, {"supersampling"}, + {"sharpening"}, {"chromatic", "aberration"}, {"vignette"}, {"directx"}, {"vulkan"}, + {"dx11"}, {"dx12"}, {"d3d11"}, {"d3d12"}, +} + +var weakTerms = []string{ + "width", "height", "quality", "preset", "driver", "spec", "shadows", "shadow", "texture", + "textures", "video", "display", "graphics", "render", "screen", "fps", "sync", "filtering", +} + +// Words in a file name, weighted by how reliably the Ludusavi manifest shows +// them to mean settings rather than a save. +var nameWeights = map[string]float64{ + "options": 1.5, "option": 1.5, "graphics": 1.5, "graphic": 1.5, "video": 1.5, "display": 1.5, + "render": 1.5, "engine": 1.5, "scalability": 1.5, "gameusersettings": 1.5, "screen": 1, + "settings": 0.5, "setting": 0.5, "config": 0.5, "configuration": 0.5, "cfg": 0.5, + "prefs": 0.5, "preferences": 0.5, +} + +// Words that name saves somewhere in the manifest often enough that a file +// carrying one is never taken, whatever it holds. Logs are not settings +// either, though leaving them out would harm nothing. +var saveWords = map[string]bool{ + "save": true, "saves": true, "savegame": true, "savedata": true, "slot": true, "progress": true, + "autosave": true, "quicksave": true, "world": true, "player": true, "character": true, + "campaign": true, "level": true, "levels": true, "achievement": true, "achievements": true, + "achievments": true, "stats": true, "unlock": true, "unlocks": true, "inventory": true, + "profile": true, "career": true, "highscore": true, "highscores": true, "score": true, + "scores": true, "records": true, "besttimes": true, "gamedata": true, "log": true, + "logs": true, "crash": true, "output": true, "history": true, +} + +var identRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`) +var camelRe = regexp.MustCompile(`[A-Z]?[a-z]+|[A-Z]+(?:[^a-z]|$)|[0-9]+`) + +// words splits text into lowercase words: identifiers, then at case changes +// ("FrameGeneration" → frame generation), keeping a whole short identifier +// too ("dx11", "vsync"). +func words(text string) []string { + var out []string + for _, id := range identRe.FindAllString(text, -1) { + parts := camelRe.FindAllString(id, -1) + low := strings.ToLower(id) + if len(parts) > 1 && len(low) <= 6 { + out = append(out, low) + } + for _, p := range parts { + p = strings.ToLower(p) + if p == "of" || p == "the" || p == "b" || p == "r" || p == "e" { + continue // filler, and engine prefixes like r_ and bEnable + } + out = append(out, p) + } + } + return out +} + +// textOf returns a file's text, decoding UTF-16, or false for binary. +func textOf(b []byte) (string, bool) { + switch { + case bytes.HasPrefix(b, []byte{0xFF, 0xFE}), bytes.HasPrefix(b, []byte{0xFE, 0xFF}): + big := b[0] == 0xFE + b = b[2:] + u := make([]uint16, len(b)/2) + for i := range u { + if big { + u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1]) + } else { + u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i]) + } + } + return string(utf16.Decode(u)), true + case len(b) >= 4 && b[1] == 0 && b[3] == 0 && b[0] != 0 && b[2] != 0: + // UTF-16LE without a mark, as some engines write their XML. + u := make([]uint16, len(b)/2) + for i := range u { + u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i]) + } + return string(utf16.Decode(u)), true + case bytes.IndexByte(b, 0) >= 0: + return "", false + } + return string(b), true +} + +// contentScore counts the distinct display terms a text holds. +func contentScore(text string) (strong int, score float64) { + ws := words(text) + present := make(map[string]bool, len(ws)) + pairs := make(map[[2]string]bool, len(ws)) + for i, w := range ws { + present[w] = true + if i+1 < len(ws) { + pairs[[2]string{w, ws[i+1]}] = true + } + } + for _, t := range strongTerms { + if (len(t) == 1 && present[t[0]]) || (len(t) == 2 && pairs[[2]string{t[0], t[1]}]) { + strong++ + } + } + weak := 0.0 + for _, w := range weakTerms { + if present[w] { + weak += 0.5 + } + } + if weak > 2 { + weak = 2 + } + return strong, float64(strong) + weak +} + +// nameSignal weighs a file's name: the support it lends, and whether a word +// in it names saves. +func nameSignal(rel string) (weight float64, saveWord, strongName bool) { + base := path.Base(rel) + ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), ".")) + stem := strings.TrimSuffix(base, path.Ext(base)) + ws := words(stem) + low := strings.ToLower(stem) + for w := range nameWeights { + if len(w) >= 6 && strings.Contains(low, w) { + ws = append(ws, w) + } + } + seen := map[string]bool{} + for _, w := range ws { + if seen[w] { + continue + } + seen[w] = true + if saveWords[w] { + saveWord = true + } + weight += nameWeights[w] + if nameWeights[w] >= 1.5 { + strongName = true + } + } + if strings.Contains(low, "save") || strings.Contains(low, "slot") || ext == "log" { + saveWord = true + } + if ext == "ini" || ext == "cfg" || ext == "conf" { + weight += 0.5 + } + if weight > 2 { + weight = 2 + } + return weight, saveWord, strongName +} + +// unrealSettings reports a file in an Unreal Engine game's settings folder, +// Saved/Config/: the engine writes a machine's settings there and +// nothing else, often as files too short to judge by content. +func unrealSettings(rel string) bool { + segs := strings.Split(strings.ToLower(rel), "/") + if len(segs) < 2 || !strings.HasSuffix(segs[len(segs)-1], ".ini") { + return false + } + for i := 0; i+1 < len(segs)-1; i++ { + if segs[i] == "config" && (strings.HasPrefix(segs[i+1], "windows") || strings.HasPrefix(segs[i+1], "linux") || + strings.HasPrefix(segs[i+1], "mac")) { + return true + } + } + return false +} + +// judgeSettings decides one file from its path and bytes. +func judgeSettings(rel string, b []byte) (bool, string) { + weight, saveWord, strongName := nameSignal(rel) + if saveWord && !strongName { + return false, "" + } + if unrealSettings(rel) && !saveWord { + return true, "Unreal Engine settings folder" + } + text, ok := textOf(b) + if !ok { + return false, "" + } + strong, score := contentScore(text) + switch { + case saveWord: + // "user_engine_option_save.xml": a strong settings word and a save + // word together. Taken only on overwhelming contents. + if strong >= 6 && score+weight >= 8 { + return true, "display settings in its contents" + } + case len(b) > 32<<10: + // A long file holds a lot besides; a save with a settings section is + // long. Only a file that is mostly settings is taken. + if strong >= 6 { + return true, "display settings in its contents" + } + case strong >= 3 && score+weight >= 4: + return true, "display settings in its contents" + case strong >= 2 && weight >= 1.5 && score+weight >= 4: + return true, "display settings in its contents, and its name" + } + return false, "" +} + +// DetectSettings finds the settings files in root by what they hold. The +// game's save locations in the game database (by name or App ID) are never +// taken, whatever they hold. +func (ds *DeviceSettings) DetectSettings(name, appID, root string) []DetectedSettings { + var savePats []string + if ds != nil { + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if ok { + for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) { + for _, tpl := range g.Paths { + for _, expanded := range expandTemplate(tpl, vars, ds.bases(g.Installs), g.SteamID) { + if rel, ok := relativeUnder(root, expanded); ok { + savePats = append(savePats, rel) + } + } + } + } + } + } + return detectIn(root, savePats) +} + +// isSaveLocation reports whether rel is, or is inside, one of the game +// database's save locations (relative patterns). A location naming a folder +// covers what is in it; one with a file extension names that file. +func isSaveLocation(rel string, savePats []string) bool { + segs := strings.Split(strings.ToLower(rel), "/") + for _, p := range savePats { + ps := strings.Split(p, "/") + if len(ps) > len(segs) { + continue + } + match := true + for i, pc := range ps { + if ok, err := path.Match(pc, segs[i]); err != nil || !ok { + match = false + break + } + } + if !match { + continue + } + if len(ps) < len(segs) || path.Ext(ps[len(ps)-1]) != "" { + return true + } + } + return false +} + +func detectIn(root string, savePats []string) []DetectedSettings { + var out []DetectedSettings + read := 0 + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if p != root && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(p), ".")) + if !detectExts[ext] || read >= detectMaxFiles { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + if strings.Contains(strings.ToLower(rel), ".sync-conflict-") || isSaveLocation(rel, savePats) { + return nil + } + info, err := d.Info() + if err != nil || info.Size() > detectMaxSize { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + read++ + if ok, reason := judgeSettings(rel, b); ok { + out = append(out, DetectedSettings{Path: rel, Reason: reason}) + } + return nil + }) + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} diff --git a/internal/presets/detectsettings_test.go b/internal/presets/detectsettings_test.go new file mode 100644 index 00000000..f499e375 --- /dev/null +++ b/internal/presets/detectsettings_test.go @@ -0,0 +1,65 @@ +package presets + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf16" +) + +func utf16le(s string) []byte { + u := utf16.Encode([]rune(s)) + b := []byte{0xFF, 0xFE} + for _, r := range u { + b = append(b, byte(r), byte(r>>8)) + } + return b +} + +func TestJudgeSettings(t *testing.T) { + crimson := ` + + +` + doom := "configVersion 9\nr_materialAniso \"16\"\nr_mode \"18\"\nr_fullscreen \"1\"\nr_swapInterval \"1\"\nr_lodScale \"1\"\nr_gamma \"1.2\"\n" + cases := []struct { + name string + path string + data []byte + want bool + }{ + {"display settings", "videoconfig.txt", []byte("setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"), true}, + {"camelCase identifiers, save word outweighed", "user_engine_option_save.xml", []byte(crimson), true}, + {"engine abbreviations", "DOOMEternalConfig.local", []byte(doom), true}, + {"UTF-16", "GraphicsConfig.xml", utf16le(`11233`), true}, + {"Unreal settings folder", "Saved/Config/WindowsNoEditor/Input.ini", []byte("[/Script/Engine.InputSettings]\n"), true}, + {"save word, weak settings", "savegame.json", []byte(`{"fullscreen":true,"vsync":true,"resolution":"1080p","gold":12}`), false}, + {"progress in a settings-like file", "config.cfg", []byte("level 4\ngold 120\nhealth 80\n"), false}, + {"a log mentioning the GPU", "output_log.txt", []byte("GPU: RTX; resolution 1920; vsync on; fullscreen; monitor 1; hdr off"), false}, + {"binary", "graphics.cfg", []byte{0x01, 0x00, 0x02, 0x00, 0x03}, false}, + {"long save with a settings section", "state.json", []byte(`{"fullscreen":1,"vsync":1,"resolution":1,"gamma":1,"hdr":0,` + strings.Repeat(`"item":1,`, 5000) + `"x":0}`), false}, + } + for _, c := range cases { + if got, _ := judgeSettings(c.path, c.data); got != c.want { + t.Errorf("%s: %s judged settings=%v, want %v", c.name, c.path, got, c.want) + } + } +} + +// The game database's save locations are never taken, whatever they hold. +func TestDetectIn_SaveLocationsAreNeverTaken(t *testing.T) { + root := t.TempDir() + settings := "fullscreen=1\nvsync=1\nresolution=1920x1080\nshadow_quality=3\nanti_aliasing=2\n" + for _, p := range []string{"video.ini", "slots/1/video.ini", "keep/video.ini"} { + full := filepath.Join(root, filepath.FromSlash(p)) + _ = os.MkdirAll(filepath.Dir(full), 0o777) + if err := os.WriteFile(full, []byte(settings), 0o666); err != nil { + t.Fatal(err) + } + } + got := detectIn(root, []string{"slots", "keep/video.ini"}) + if len(got) != 1 || got[0].Path != "video.ini" { + t.Errorf("detected %+v, want only video.ini", got) + } +} diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index 08a5c2df..3c4372a7 100644 --- a/internal/presets/devicesettings.go +++ b/internal/presets/devicesettings.go @@ -49,7 +49,7 @@ func (sc *Scanner) deviceSettings(idx []indexedGame) *DeviceSettings { } ds := &DeviceSettings{sc: sc, byID: map[string]indexedGame{}, byName: map[string]indexedGame{}} for _, g := range idx { - if len(g.Config) == 0 { + if len(g.Config) == 0 && len(g.Paths) == 0 { continue } if g.SteamID != "" { @@ -107,3 +107,19 @@ func coversEverything(rel string) bool { first := strings.SplitN(rel, "/", 2)[0] return strings.Trim(first, "*") == "" || first == "*.*" } + +// InstallNames returns the names the game database knows a game's install +// folder by ("Crimson Desert"), found by App ID or name. +func (ds *DeviceSettings) InstallNames(name, appID string) []string { + if ds == nil { + return nil + } + g, ok := ds.byID[appID] + if !ok || appID == "" { + g, ok = ds.byName[normalizeGameName(stripNameSuffixes(name))] + } + if !ok { + return nil + } + return append([]string(nil), g.Installs...) +} diff --git a/internal/sessions/procs_windows.go b/internal/sessions/procs_windows.go index 1fb4ceb8..1790d6cb 100644 --- a/internal/sessions/procs_windows.go +++ b/internal/sessions/procs_windows.go @@ -3,14 +3,17 @@ package sessions import ( + "time" "unsafe" "golang.org/x/sys/windows" ) // listProcesses walks the process table and asks each process for its full -// program path. A process this user may not look at (a service, another -// user's) is skipped: no game of theirs is one of ours. +// program path and how much processor time it has used. A process whose path +// cannot be read is listed by its file name alone (Proc.Name): a game +// protected against tampering keeps its path from everyone, and matching by +// name within its install folder's programs is still exact enough. func listProcesses() ([]Proc, error) { snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) if err != nil { @@ -27,8 +30,12 @@ func listProcesses() ([]Proc, error) { buf := make([]uint16, windows.MAX_LONG_PATH) for { if pid := entry.ProcessID; pid > 4 { - if exe := imagePath(pid, buf); exe != "" { - out = append(out, Proc{PID: int(pid), Exe: exe}) + if p, ok := inspect(pid, buf); ok { + out = append(out, p) + } else if name := windows.UTF16ToString(entry.ExeFile[:]); name != "" { + // Its path is not ours to read: a game protected against + // tampering, run elevated, or another user's. Known by name. + out = append(out, Proc{PID: int(pid), Name: name}) } } if err := windows.Process32Next(snap, &entry); err != nil { @@ -38,15 +45,26 @@ func listProcesses() ([]Proc, error) { return out, nil } -func imagePath(pid uint32, buf []uint16) string { +func inspect(pid uint32, buf []uint16) (Proc, bool) { h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { - return "" + return Proc{}, false } defer windows.CloseHandle(h) n := uint32(len(buf)) if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &n); err != nil { - return "" + return Proc{}, false } - return windows.UTF16ToString(buf[:n]) + p := Proc{PID: int(pid), Exe: windows.UTF16ToString(buf[:n])} + + var creation, exit, kernel, user windows.Filetime + timesOK := windows.GetProcessTimes(h, &creation, &exit, &kernel, &user) == nil + if timesOK { + p.Measured = true + // FILETIME counts 100-nanosecond intervals. + ticks := (uint64(kernel.HighDateTime)<<32 | uint64(kernel.LowDateTime)) + + (uint64(user.HighDateTime)<<32 | uint64(user.LowDateTime)) + p.CPU = time.Duration(ticks * 100) + } + return p, true } diff --git a/internal/sessions/sessions.go b/internal/sessions/sessions.go index 1f47e5c3..a4755d7b 100644 --- a/internal/sessions/sessions.go +++ b/internal/sessions/sessions.go @@ -25,6 +25,7 @@ import ( "strings" "sync" "time" + "unicode" ) // Proc is a running process, as much of it as matching needs. @@ -32,8 +33,18 @@ type Proc struct { PID int // Exe is the program's full path, where the system says. Exe string + // Name is the program's file name, where the system gives no path: a + // game protected against tampering lets nobody read where it runs from, + // but the process list still says what it is called. + Name string // Args is the command line, where the system says (Linux). Args []string + // Measured says CPU was read (Windows): the processor time the program has + // used so far. A game being played uses some all the time; what a game + // leaves behind when it closes — a crash handler, a stub of the same name — + // uses none (daemon.activeProcesses). + Measured bool + CPU time.Duration // SteamAppID is the Steam app the process was started for, where Steam // tagged it (Linux: SteamAppId in the environment, or AppId=… on the // command line of Steam's launcher). @@ -49,6 +60,14 @@ type Target struct { // Dirs are the game's install folders: anything running from inside one // is the game. Dirs []string + // FolderNames are names the game's install folder goes by, for a game + // installed where no library lists it ("G:GamesCrimson Desert"): + // a program running from inside a folder of one of these names is the + // game. Normalised with FolderName. + FolderNames []string + // Programs are the file names of the programs in the install folders, for + // a process known only by name (Proc.Name). Lower-case. + Programs []string } // Running reports which targets have a process running, from a list of @@ -74,6 +93,20 @@ func matches(p Proc, t Target) bool { if p.Exe != "" && t.Exe != "" && samePath(p.Exe, t.Exe) { return true } + if p.Exe == "" && p.Name != "" { + name := strings.ToLower(p.Name) + if t.Exe != "" && strings.EqualFold(filepath.Base(t.Exe), name) { + return true + } + for _, prog := range t.Programs { + if prog == name { + return true + } + } + } + if p.Exe != "" && FolderOf(p, t) != "" { + return true + } for _, dir := range t.Dirs { if dir == "" { continue @@ -252,3 +285,74 @@ func (t *Tracker) Finish(gameID string, at time.Time) { // List returns the processes running now. Where the system cannot say, it // returns none and an error, and nothing is ever seen playing. func List() ([]Proc, error) { return listProcesses() } + +// GameCPU is the processor time a game's running processes have used so far, +// summed, and whether every one of them could be measured. +type GameCPU struct { + CPU time.Duration + Measured bool +} + +// CPUOf sums, for each target with a process running, the processor time its +// processes have used, matched as Running matches them. +func CPUOf(procs []Proc, targets []Target) map[string]GameCPU { + out := map[string]GameCPU{} + for _, t := range targets { + for _, p := range procs { + if !matches(p, t) { + continue + } + g, seen := out[t.GameID] + if !seen { + g.Measured = true + } + g.CPU += p.CPU + g.Measured = g.Measured && p.Measured + out[t.GameID] = g + } + } + return out +} + +// Marked reports whether a game's session was begun from outside (Begin) — +// the Deck plugin, `opensave wrap` — and so ends only when told. +func (t *Tracker) Marked(gameID string) bool { + t.mu.Lock() + defer t.mu.Unlock() + s, ok := t.playing[gameID] + return ok && s.marked +} + +// FolderName normalises a folder or game name for comparing the two: lower +// case, letters, digits and single spaces. +func FolderName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case unicode.IsLetter(r) || unicode.IsDigit(r): + b.WriteRune(r) + default: + b.WriteRune(' ') + } + } + return strings.Join(strings.Fields(b.String()), " ") +} + +// FolderOf returns the folder, among those above a process's program, that +// is named as one of a target's install folders (Target.FolderNames), or "". +func FolderOf(p Proc, t Target) string { + if p.Exe == "" || len(t.FolderNames) == 0 { + return "" + } + dir := filepath.Dir(p.Exe) + for dir != "" && dir != filepath.Dir(dir) { + name := FolderName(filepath.Base(dir)) + for _, want := range t.FolderNames { + if name == want { + return dir + } + } + dir = filepath.Dir(dir) + } + return "" +} diff --git a/internal/sessions/sessions_test.go b/internal/sessions/sessions_test.go index c4311de4..b3adb1aa 100644 --- a/internal/sessions/sessions_test.go +++ b/internal/sessions/sessions_test.go @@ -105,3 +105,42 @@ func TestListFindsThisProcess(t *testing.T) { t.Errorf("this process (%d, %s) was not found among %d: %v", os.Getpid(), self, len(procs), running) } } + +// A game protected against tampering keeps its path from everyone; its +// process is still recognised by name, among its install folder's programs. +func TestRunning_ProtectedProcessByName(t *testing.T) { + target := Target{GameID: "cd", Dirs: []string{`C:\Games\Crimson Desert`}, Programs: []string{"crimsondesert.exe"}} + procs := []Proc{{PID: 11, Name: "explorer.exe"}, {PID: 12, Name: "CrimsonDesert.exe"}} + if got := Running(procs, []Target{target}); got["cd"] != 12 { + t.Errorf("Running = %v, want the protected process recognised by name", got) + } + if got := Running([]Proc{{PID: 13, Name: "game.exe"}}, []Target{target}); len(got) != 0 { + t.Errorf("a program not in the game's folder was taken for it: %v", got) + } +} + +// A game installed in a folder no library lists is found by the folder's +// name, wherever that folder is. +func TestRunning_ByInstallFolderName(t *testing.T) { + // Paths as this system writes them: a process's program is a native path. + root := string(filepath.Separator) + if runtime.GOOS == "windows" { + root = `G:\` + } + games := filepath.Join(root, "Games") + target := Target{GameID: "cd", FolderNames: []string{FolderName("Crimson Desert")}} + procs := []Proc{ + {PID: 5, Exe: filepath.Join(root, "Windows", "explorer.exe")}, + {PID: 6, Exe: filepath.Join(games, "Crimson Desert", "bin64", "CrimsonDesert.exe")}, + } + if got := Running(procs, []Target{target}); got["cd"] != 6 { + t.Fatalf("Running = %v, want the game found by its folder", got) + } + if dir := FolderOf(procs[1], target); dir != filepath.Join(games, "Crimson Desert") { + t.Errorf("FolderOf = %q", dir) + } + other := []Proc{{PID: 7, Exe: filepath.Join(games, "Crimson Desert Tools", "editor.exe")}} + if got := Running(other, []Target{target}); len(got) != 0 { + t.Errorf("a folder whose name only begins the same was taken for the game: %v", got) + } +} diff --git a/internal/snapshot/history.go b/internal/snapshot/history.go new file mode 100644 index 00000000..916f0884 --- /dev/null +++ b/internal/snapshot/history.go @@ -0,0 +1,74 @@ +package snapshot + +import "fmt" + +// How often files changed along with a game's save, read from its +// snapshots: a settings file is changed now and then, when someone changes a +// setting; a save changes with nearly every session. A file taken for a +// game's settings that changes like a save is not its settings +// (daemon/detectsettings.go). +// +// Read from the recorded file lists only, and compared by the database +// (store.SnapshotsDiffer): a save can hold a quarter of a million files, and +// reading each snapshot's whole list — or, for one taken before lists were +// recorded, its whole archive — to find whether the rest of it changed was +// tens of millions of rows each time it was asked. A snapshot with no recorded +// list is left out. + +const ( + historySnapshots = 40 // the most recent ones are read + historyMinSaves = 4 // changes of the save needed before anything is said +) + +// ChangesLikeASave returns, for each of paths (relative to the main save +// folder) that changed in more than half of the snapshots in which the rest +// of the save changed, why it is judged a save. Paths with too little history +// to tell are not in it. +func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]string { + out := map[string]string{} + if len(paths) == 0 { + return out + } + snaps, err := m.Store.AllSnapshotsOfGame(gameID) + if err != nil { + return out + } + if len(snaps) > historySnapshots { + snaps = snaps[len(snaps)-historySnapshots:] + } + + saves := 0 + moved := map[string]int{} + prevID, prev := "", map[string]string(nil) + for _, s := range snaps { + if !m.Store.SnapshotHasFiles(s.ID) { + continue + } + hashes, err := m.Store.SnapshotPathHashes(s.ID, "", paths) + if err != nil { + continue + } + if prevID != "" { + // Only where the rest of the save changed is there anything to + // compare the watched files with. + if differ, err := m.Store.SnapshotsDiffer(prevID, s.ID, "", paths); err == nil && differ { + saves++ + for _, p := range paths { + if prev[p] != hashes[p] { + moved[p]++ + } + } + } + } + prevID, prev = s.ID, hashes + } + if saves < historyMinSaves { + return out + } + for p, n := range moved { + if 2*n > saves { + out[p] = fmt.Sprintf("changed in %d of the %d snapshots in which the save changed", n, saves) + } + } + return out +} diff --git a/internal/snapshot/history_test.go b/internal/snapshot/history_test.go new file mode 100644 index 00000000..1c7ea812 --- /dev/null +++ b/internal/snapshot/history_test.go @@ -0,0 +1,52 @@ +package snapshot + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A file that changes with every change of the save is a save; one changed +// once among many is settings; too little history says nothing. +func TestChangesLikeASave(t *testing.T) { + env := setup(t) + write := func(name, data string) { + if err := os.WriteFile(filepath.Join(env.saveDir, name), []byte(data), 0o666); err != nil { + t.Fatal(err) + } + } + snap := func() { + if _, err := env.mgr.Create("game1", "", false); err != nil { + t.Fatal(err) + } + } + write("graphics.cfg", "resolution=1080") + write("progress.cfg", "level=0") + write("slot1.sav", "0") + snap() + ask := []string{"graphics.cfg", "progress.cfg"} + for i := 1; i <= 3; i++ { + write("slot1.sav", strings.Repeat("x", i)) + write("progress.cfg", "level="+strings.Repeat("1", i)) + snap() + } + if got := env.mgr.ChangesLikeASave("game1", ask); len(got) != 0 { + t.Fatalf("3 changes of the save already judged: %v", got) + } + for i := 4; i <= 6; i++ { + write("slot1.sav", strings.Repeat("x", i)) + write("progress.cfg", "level="+strings.Repeat("1", i)) + if i == 5 { + write("graphics.cfg", "resolution=2160") + } + snap() + } + got := env.mgr.ChangesLikeASave("game1", ask) + if _, ok := got["progress.cfg"]; !ok { + t.Errorf("a file that changed with every save is not judged a save: %v", got) + } + if _, ok := got["graphics.cfg"]; ok { + t.Errorf("a file changed once in six saves is judged a save: %v", got) + } +} diff --git a/internal/store/marks.go b/internal/store/marks.go new file mode 100644 index 00000000..13b7376b --- /dev/null +++ b/internal/store/marks.go @@ -0,0 +1,22 @@ +package store + +import "fmt" + +// Mark returns the value a one-time step was taken for, empty if never. +// See migrations/0042_settings_files.sql. +func (s *Store) Mark(name string) string { + var v string + if err := s.db.Get(&v, `SELECT value FROM marks WHERE name = ?`, name); err != nil { + return "" + } + return v +} + +// SetMark records that a one-time step was taken for value. +func (s *Store) SetMark(name, value string) error { + if _, err := s.db.Exec(`INSERT INTO marks (name, value) VALUES (?, ?) + ON CONFLICT(name) DO UPDATE SET value = excluded.value`, name, value); err != nil { + return fmt.Errorf("set mark %s: %w", name, err) + } + return nil +} diff --git a/internal/store/migrations/0042_settings_files.sql b/internal/store/migrations/0042_settings_files.sql new file mode 100644 index 00000000..08a22e4f --- /dev/null +++ b/internal/store/migrations/0042_settings_files.sql @@ -0,0 +1,45 @@ +-- IF NOT EXISTS throughout: builds of an earlier draft of this change made +-- these same tables under other numbers (settings_files and +-- settings_observations in 0043, marks in 0041), and a database from one of +-- them must still start. + +-- Files in a game's main save folder judged to be its settings rather than its +-- save, by what they hold (presets/detectsettings.go), and files shown to be a +-- save after all — by changing with the save, in this device's snapshots or in +-- what another device sent (daemon/detectsettings.go). +-- +-- verdict 'settings' leaves the file out of syncing, as the game database's +-- settings files are. verdict 'save' keeps it syncing for good: detection never +-- takes it again, and a game database entry that named it is overruled. +-- path is relative to the main save folder, slash-separated. +CREATE TABLE IF NOT EXISTS settings_files ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + path TEXT NOT NULL, + verdict TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + at_ms INTEGER NOT NULL, + PRIMARY KEY (game_id, path) +); + +-- What another device's copy of an excluded settings file did, sync by sync: +-- moved counts syncs in which the save changed and so did that copy, still +-- those in which the save changed and it did not. A settings file is changed +-- now and then; a save that was taken for one changes with every session. +CREATE TABLE IF NOT EXISTS settings_observations ( + game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, + peer_id TEXT NOT NULL, + path TEXT NOT NULL, + last_hash TEXT NOT NULL DEFAULT '', + moved INTEGER NOT NULL DEFAULT 0, + still INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (game_id, peer_id, path) +); + +-- One-time steps this device has already taken, by name, with the value they +-- were taken for; a step whose value changes in a later build runs again. +-- Here: when each game's files were last judged, by which detector, for +-- which save folder (daemon/detectsettings.go). +CREATE TABLE IF NOT EXISTS marks ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/internal/store/settings_files.go b/internal/store/settings_files.go new file mode 100644 index 00000000..2c485516 --- /dev/null +++ b/internal/store/settings_files.go @@ -0,0 +1,90 @@ +package store + +import ( + "fmt" + "time" +) + +// Verdicts on a file in a game's save folder (migrations/0042_settings_files.sql). +// +// A file found by what it holds is only suggested: it keeps syncing until +// someone confirms it in the game's configuration tab (settings) or keeps it +// syncing (declined, never suggested again). A wrong guess would otherwise +// stop part of a save syncing without anyone knowing. +const ( + VerdictSuggested = "suggested" + VerdictSettings = "settings" + VerdictDeclined = "declined" + VerdictSave = "save" +) + +// SettingsFile is a verdict on one file of a game's main save folder. +type SettingsFile struct { + GameID string `db:"game_id" json:"-"` + Path string `db:"path" json:"path"` + Verdict string `db:"verdict" json:"verdict"` + Reason string `db:"reason" json:"reason"` + AtMs int64 `db:"at_ms" json:"atMs"` +} + +// SettingsFiles returns every verdict recorded for a game. +func (s *Store) SettingsFiles(gameID string) ([]SettingsFile, error) { + var out []SettingsFile + if err := s.db.Select(&out, `SELECT * FROM settings_files WHERE game_id = ? ORDER BY path`, gameID); err != nil { + return nil, fmt.Errorf("list settings files of %s: %w", gameID, err) + } + return out, nil +} + +// SetSettingsFile records a verdict, replacing any earlier one. +func (s *Store) SetSettingsFile(f SettingsFile) error { + if f.AtMs == 0 { + f.AtMs = time.Now().UnixMilli() + } + if _, err := s.db.NamedExec(`INSERT INTO settings_files (game_id, path, verdict, reason, at_ms) + VALUES (:game_id, :path, :verdict, :reason, :at_ms) + ON CONFLICT(game_id, path) DO UPDATE SET verdict = excluded.verdict, reason = excluded.reason, at_ms = excluded.at_ms`, f); err != nil { + return fmt.Errorf("set settings file %s/%s: %w", f.GameID, f.Path, err) + } + return nil +} + +// SettingsObservation is what one device's copy of an excluded settings file +// has done so far. +type SettingsObservation struct { + GameID string `db:"game_id"` + PeerID string `db:"peer_id"` + Path string `db:"path"` + LastHash string `db:"last_hash"` + Moved int `db:"moved"` + Still int `db:"still"` +} + +// SettingsObservations returns what every device's copy of path has done. +func (s *Store) SettingsObservations(gameID, path string) ([]SettingsObservation, error) { + var out []SettingsObservation + if err := s.db.Select(&out, `SELECT * FROM settings_observations WHERE game_id = ? AND path = ?`, gameID, path); err != nil { + return nil, fmt.Errorf("list settings observations: %w", err) + } + return out, nil +} + +// GetSettingsObservation returns one device's record ({} when none). +func (s *Store) GetSettingsObservation(gameID, peerID, path string) SettingsObservation { + var o SettingsObservation + if err := s.db.Get(&o, `SELECT * FROM settings_observations WHERE game_id = ? AND peer_id = ? AND path = ?`, gameID, peerID, path); err != nil { + return SettingsObservation{GameID: gameID, PeerID: peerID, Path: path} + } + return o +} + +// SaveSettingsObservation writes one device's record. +func (s *Store) SaveSettingsObservation(o SettingsObservation) error { + if _, err := s.db.NamedExec(`INSERT INTO settings_observations (game_id, peer_id, path, last_hash, moved, still) + VALUES (:game_id, :peer_id, :path, :last_hash, :moved, :still) + ON CONFLICT(game_id, peer_id, path) DO UPDATE SET last_hash = excluded.last_hash, + moved = excluded.moved, still = excluded.still`, o); err != nil { + return fmt.Errorf("save settings observation: %w", err) + } + return nil +} diff --git a/internal/store/settings_files_migration_test.go b/internal/store/settings_files_migration_test.go new file mode 100644 index 00000000..e96747f0 --- /dev/null +++ b/internal/store/settings_files_migration_test.go @@ -0,0 +1,36 @@ +package store + +import ( + "path/filepath" + "testing" +) + +// A database from a build of the earlier draft of this change already holds +// settings_files, settings_observations and marks, made under other migration +// numbers, and has never run 0042. It must still open: a plain CREATE TABLE +// stopped the daemon with "table already exists", as sync_device_settings +// once did (asked in review on #118). +func TestSettingsFilesMigrationOpensOverTheDraftsTables(t *testing.T) { + path := filepath.Join(t.TempDir(), "opensave.db") + s, err := Open(path) + if err != nil { + t.Fatal(err) + } + // As the draft's build left it: the tables there, 0042 not recorded. + if _, err := s.db.Exec(`DELETE FROM schema_migrations WHERE name = '0042_settings_files.sql'`); err != nil { + t.Fatal(err) + } + if err := s.SetMark("kept", "from the draft's build"); err != nil { + t.Fatal(err) + } + s.Close() + + s, err = Open(path) + if err != nil { + t.Fatalf("a database holding the draft's tables did not open: %v", err) + } + defer s.Close() + if got := s.Mark("kept"); got != "from the draft's build" { + t.Errorf("what the draft's build recorded is gone: %q", got) + } +} diff --git a/internal/store/snapshot_files.go b/internal/store/snapshot_files.go index 8e349878..38c834ef 100644 --- a/internal/store/snapshot_files.go +++ b/internal/store/snapshot_files.go @@ -1,6 +1,10 @@ package store -import "fmt" +import ( + "fmt" + + "github.com/jmoiron/sqlx" +) // CapturedFile is one file inside a snapshot: which save location it belongs // to, its path within that location, and the hash of its contents. @@ -132,3 +136,69 @@ func (s *Store) SnapshotRootFiles(snapshotID, root string) (map[string]string, e } return files, nil } + +// AllSnapshotsOfGame lists every snapshot of a game, on every branch, oldest +// first. +func (s *Store) AllSnapshotsOfGame(gameID string) ([]Snapshot, error) { + var out []Snapshot + if err := s.db.Select(&out, `SELECT * FROM snapshots WHERE game_id = ? ORDER BY timestamp`, gameID); err != nil { + return nil, fmt.Errorf("list snapshots of %s: %w", gameID, err) + } + return out, nil +} + +// SnapshotHasFiles reports whether a snapshot's file list is recorded: +// snapshots taken before these records existed have none. +func (s *Store) SnapshotHasFiles(snapshotID string) bool { + var n int + err := s.db.Get(&n, `SELECT EXISTS (SELECT 1 FROM snapshot_files WHERE snapshot_id = ?)`, snapshotID) + return err == nil && n == 1 +} + +// SnapshotPathHashes returns the content hashes a snapshot recorded for the +// given paths of one save location; a path it did not hold is absent. +func (s *Store) SnapshotPathHashes(snapshotID, root string, paths []string) (map[string]string, error) { + out := map[string]string{} + if len(paths) == 0 { + return out, nil + } + query, args, err := sqlx.In(`SELECT path, hash FROM snapshot_files WHERE snapshot_id = ? AND root = ? AND path IN (?)`, + snapshotID, root, paths) + if err != nil { + return nil, err + } + var rows []CapturedFile + if err := s.db.Select(&rows, s.db.Rebind(query), args...); err != nil { + return nil, fmt.Errorf("read snapshot files of %s: %w", snapshotID, err) + } + for _, r := range rows { + out[r.Path] = r.Hash + } + return out, nil +} + +// SnapshotsDiffer reports whether two snapshots differ in one save location, +// leaving out the paths in except: a file one holds that the other does not, +// at the same content. Answered by the database, which stops at the first +// difference — a save of a quarter-million files is never read into memory +// to compare two snapshots of it. +func (s *Store) SnapshotsDiffer(a, b, root string, except []string) (bool, error) { + except = append(append([]string{}, except...), "") // never empty for IN + query, args, err := sqlx.In(`SELECT EXISTS ( + SELECT 1 FROM snapshot_files x WHERE x.snapshot_id = ? AND x.root = ? AND x.path NOT IN (?) + AND NOT EXISTS (SELECT 1 FROM snapshot_files y + WHERE y.snapshot_id = ? AND y.root = x.root AND y.path = x.path AND y.hash = x.hash) + ) OR EXISTS ( + SELECT 1 FROM snapshot_files x WHERE x.snapshot_id = ? AND x.root = ? AND x.path NOT IN (?) + AND NOT EXISTS (SELECT 1 FROM snapshot_files y + WHERE y.snapshot_id = ? AND y.root = x.root AND y.path = x.path AND y.hash = x.hash) + )`, a, root, except, b, b, root, except, a) + if err != nil { + return false, err + } + var differ bool + if err := s.db.Get(&differ, s.db.Rebind(query), args...); err != nil { + return false, fmt.Errorf("compare snapshots %s and %s: %w", a, b, err) + } + return differ, nil +} diff --git a/internal/store/snapshots_differ_test.go b/internal/store/snapshots_differ_test.go new file mode 100644 index 00000000..5a799645 --- /dev/null +++ b/internal/store/snapshots_differ_test.go @@ -0,0 +1,49 @@ +package store + +import "testing" + +// Two snapshots compared by the database: identical, one file changed, one +// changed only in a path left out, and one file held by one side alone. +func TestSnapshotsDiffer(t *testing.T) { + s := openTestStore(t) + if err := s.CreateGame(Game{ID: "g", Name: "g", SavePath: t.TempDir(), ActiveBranch: "main"}); err != nil { + t.Fatal(err) + } + record := func(id string, files map[string]string) { + t.Helper() + if err := s.CreateSnapshot(Snapshot{ID: id, GameID: "g", BranchName: "main", Timestamp: "2026-10-26T00:00:00.000Z", ZipPath: id + ".zip"}); err != nil { + t.Fatal(err) + } + var cf []CapturedFile + for p, h := range files { + cf = append(cf, CapturedFile{Path: p, Hash: h}) + } + if err := s.RecordSnapshotFiles(id, cf); err != nil { + t.Fatal(err) + } + } + record("a", map[string]string{"slot1.sav": "1", "video.cfg": "x"}) + record("same", map[string]string{"slot1.sav": "1", "video.cfg": "x"}) + record("saved", map[string]string{"slot1.sav": "2", "video.cfg": "x"}) + record("cfgOnly", map[string]string{"slot1.sav": "1", "video.cfg": "y"}) + record("more", map[string]string{"slot1.sav": "1", "video.cfg": "x", "slot2.sav": "9"}) + + for _, c := range []struct { + b string + want bool + }{{"same", false}, {"saved", true}, {"cfgOnly", false}, {"more", true}} { + got, err := s.SnapshotsDiffer("a", c.b, "", []string{"video.cfg"}) + if err != nil || got != c.want { + t.Errorf("a vs %s, video.cfg left out: %v %v, want %v", c.b, got, err, c.want) + } + } + if got, _ := s.SnapshotsDiffer("a", "cfgOnly", "", nil); !got { + t.Error("with nothing left out, a changed file was not a difference") + } + if h, _ := s.SnapshotPathHashes("cfgOnly", "", []string{"video.cfg", "missing.cfg"}); h["video.cfg"] != "y" || len(h) != 1 { + t.Errorf("path hashes = %v", h) + } + if !s.SnapshotHasFiles("a") || s.SnapshotHasFiles("no-such-snapshot") { + t.Error("SnapshotHasFiles") + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index a5477f3f..e8cb36e7 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -131,6 +131,11 @@ type Callbacks struct { // SyncWritingNow reports whether a sync is writing the game at this // moment: the watcher looks at the burst once it has finished. May be nil. SyncWritingNow func(gameID string) bool + // HoldSnapshot reports whether a change should not be snapshotted now: + // the game is being played, and a checkpoint was taken recently. The + // change is not recorded as captured either, so the next one, or the end + // of the session, still sees it. May be nil. + HoldSnapshot func(gameID string) bool } // syncWriting reports whether a sync is or was just writing a game @@ -226,6 +231,9 @@ type gameWatch struct { // OpenSave applied itself (owntouch), anything else, or both. Read and // written only by the event loop. ownEvents, otherEvents bool + // pendingChange is set while a change made here (not by a sync) waits + // out the debounce, for ChangePending: read from other goroutines. + pendingChange atomic.Bool // foreignFolders: a folder queued for watching since the last rescan was // not one OpenSave created, or the watch started on a busy folder. The // rescan that follows registration counts as someone else's change only @@ -571,6 +579,15 @@ func (e *Engine) Unwatch(gameID string) { } } +// ChangePending reports whether a change made here to a game's save is +// waiting out the debounce: seen, and not yet judged. +func (e *Engine) ChangePending(gameID string) bool { + e.mu.Lock() + gw, ok := e.games[gameID] + e.mu.Unlock() + return ok && gw.pendingChange.Load() +} + // Stop shuts down every watch goroutine. func (e *Engine) Stop() { if e.stopCatch != nil { @@ -757,6 +774,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { gw.ownEvents = true } else { gw.otherEvents = true + gw.pendingChange.Store(true) } // New subdirectory in directory mode: extend the watch — on // registerFolders, never here. See registerFolders. @@ -790,6 +808,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { // folders they were: what OpenSave created, it also filled. if gw.foreignFolders { gw.otherEvents = true + gw.pendingChange.Store(true) } else { gw.ownEvents = true } @@ -831,6 +850,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { gw.ownEvents = true } else { gw.otherEvents = true + gw.pendingChange.Store(true) } } else { e.log("warn", fmt.Sprintf("watching %q: %v", gw.gameID, err)) @@ -880,6 +900,7 @@ func (e *Engine) run(ctx context.Context, gw *gameWatch) { } ownOnly := gw.ownEvents && !gw.otherEvents gw.ownEvents, gw.otherEvents = false, false + gw.pendingChange.Store(false) e.handleChangeFrom(ctx, gw, ownOnly) } } @@ -979,6 +1000,9 @@ func (e *Engine) handleChangeFrom(ctx context.Context, gw *gameWatch, ownOnly bo } return } + if e.cb.HoldSnapshot != nil && e.cb.HoldSnapshot(gw.gameID) { + return // being played: kept by the session (a checkpoint, or its end) + } for attempt := 1; attempt <= snapshotMaxRetries; attempt++ { err = e.cb.CreateSnapshot(gw.gameID) diff --git a/testutil/harness.go b/testutil/harness.go index 8d27aa6f..9610ca09 100644 --- a/testutil/harness.go +++ b/testutil/harness.go @@ -112,7 +112,7 @@ func NewTestDaemon(t *testing.T, name string) *TestDaemon { if err := os.MkdirAll(home, 0o777); err != nil { t.Fatal(err) } - d, err := daemon.New(daemon.Options{HomeOverride: home, DisableDiscovery: true}) + d, err := daemon.New(daemon.Options{HomeOverride: home, DisableDiscovery: true, SyncEveryChange: true}) if err != nil { t.Fatalf("daemon.New(%s): %v", name, err) }