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_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..48fe3469 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -186,6 +186,7 @@ func New(opts Options) (*Daemon, error) { // A game's device settings are left out of syncing (devicesettings.go). d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor + 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). @@ -274,6 +275,9 @@ func New(opts Options) (*Daemon, error) { return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID) }, 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 +463,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..e57992dc --- /dev/null +++ b/internal/daemon/detectsettings.go @@ -0,0 +1,290 @@ +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 + } + // Not while it is played: that is when the game writes its saves, and + // a file being read cannot be replaced (presets.readForJudging). Its + // turn comes at the next run after the session. + if !d.PlayingSince(game.ID).IsZero() { + continue + } + 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 + } + } + judged := make(map[string]bool, len(known)) + for p := range known { + judged[p] = true + } + found := db.DetectSettings(game.Name, game.AppID, game.SavePath, judged) + 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/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go new file mode 100644 index 00000000..2e1c576d --- /dev/null +++ b/internal/daemon/suggested_settings_test.go @@ -0,0 +1,113 @@ +package daemon + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "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) + } +} + +// A game's files are not read for detection while it is played — that is +// when it writes its saves, and a file being read cannot be replaced — and +// are once the session is over. +func TestDetectSettings_NotWhileTheGameIsPlayed(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" + if err := os.WriteFile(filepath.Join(dir, "videoconfig.txt"), []byte(settings), 0o666); err != nil { + t.Fatal(err) + } + game := store.Game{ID: "played", 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() + + start := time.Now() + d.sessions.tracker.Begin(game.ID, start) + d.detectSettingsDue(context.Background()) + if v, _ := d.Store.SettingsFiles(game.ID); len(v) != 0 { + t.Fatalf("detection ran while the game was played: %+v", v) + } + + d.sessions.tracker.Finish(game.ID, start.Add(time.Minute)) + d.detectSettingsDue(context.Background()) + if len(d.SuggestedSettings(game)) != 1 { + t.Errorf("after the session, detection did not run: %+v", d.SuggestedSettings(game)) + } +} diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 57a0a940..ea0f5af5 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -164,6 +164,10 @@ type Engine struct { // settings (presets/devicesettings.go), set by the daemon; nil leaves // them syncing. See IgnoreText. DeviceSettingsFor func(store.Game) []string + // 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. @@ -554,6 +558,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/presets/detectsettings.go b/internal/presets/detectsettings.go new file mode 100644 index 00000000..685635f5 --- /dev/null +++ b/internal/presets/detectsettings.go @@ -0,0 +1,388 @@ +package presets + +import ( + "bytes" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf16" + + "github.com/opensave/opensave/internal/fsx" +) + +// 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, judged map[string]bool) []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, judged) +} + +// 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, judged map[string]bool) []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 + } + // A file judged before is not opened again: every read of a save + // folder is a moment in which the game cannot replace that file + // (fsx.OpenShared), so what has a verdict is left alone. + if judged[rel] { + return nil + } + info, err := d.Info() + if err != nil || info.Size() > detectMaxSize { + return nil + } + b, err := readForJudging(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 +} + +// readForJudging reads a file to judge it, through a handle that lets the +// game delete it meanwhile (fsx.OpenShared), and no more than detectMaxSize +// of it. Replacing it by renaming another file over it is still refused while +// it is open — Windows allows that under no share mode — which is why files +// are read as rarely as they are: once each, and not while their game is +// played (daemon/detectsettings.go). +func readForJudging(path string) ([]byte, error) { + f, err := openForJudging(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, detectMaxSize+1)) +} + +// openForJudging is the open readForJudging reads through. +func openForJudging(path string) (*os.File, error) { return fsx.OpenShared(path) } diff --git a/internal/presets/detectsettings_test.go b/internal/presets/detectsettings_test.go new file mode 100644 index 00000000..f162d4c9 --- /dev/null +++ b/internal/presets/detectsettings_test.go @@ -0,0 +1,81 @@ +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"}, nil) + if len(got) != 1 || got[0].Path != "video.ini" { + t.Errorf("detected %+v, want only video.ini", got) + } +} + +// A file that already has a verdict is not opened again; the others still +// are judged. +func TestDetectIn_LeavesJudgedFilesAlone(t *testing.T) { + root := t.TempDir() + settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n" + for _, name := range []string{"videoconfig.txt", "display.cfg"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(settings), 0o666); err != nil { + t.Fatal(err) + } + } + got := detectIn(root, nil, map[string]bool{"videoconfig.txt": true}) + if len(got) != 1 || got[0].Path != "display.cfg" { + t.Errorf("detected %+v, want display.cfg alone: videoconfig.txt has a verdict already", got) + } +} diff --git a/internal/presets/detectsettings_windows_test.go b/internal/presets/detectsettings_windows_test.go new file mode 100644 index 00000000..9668356c --- /dev/null +++ b/internal/presets/detectsettings_windows_test.go @@ -0,0 +1,25 @@ +package presets + +import ( + "os" + "path/filepath" + "testing" +) + +// While detection holds a file open to judge it, the game can still delete +// it: the read goes through fsx.OpenShared, not os.Open, whose handle +// refuses deletion on Windows. +func TestOpenForJudging_LetsTheGameDeleteTheFile(t *testing.T) { + p := filepath.Join(t.TempDir(), "video.cfg") + if err := os.WriteFile(p, []byte("setting.fullscreen 1"), 0o666); err != nil { + t.Fatal(err) + } + f, err := openForJudging(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := os.Remove(p); err != nil { + t.Errorf("the game could not delete a file being judged: %v", err) + } +} diff --git a/internal/presets/devicesettings.go b/internal/presets/devicesettings.go index 08a5c2df..c362897b 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 != "" { 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") + } +}