From f5b174facff781a3e1bb688b7394a1baa21930a9 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:37:26 +0200 Subject: [PATCH 1/5] device settings: find a game's settings by what they hold, and give back what is a save The game database names the settings files of the games it knows (#84). For the rest, a file is now taken for a game's settings by its contents (presets/detectsettings.go): the vocabulary of one machine's display - resolution, vsync, shadow quality, upscaler - matched inside identifiers split at case changes and separators ("_enableFrameGeneration", "r_materialAniso"), in UTF-8 or UTF-16. Unreal Engine's Config/ folder is taken by convention. Names only add weight, by how reliably the game database's own labels show them to mean settings: over its 16,700 settings and 9,000 save entries, "settings" names 1,521 settings files and 70 saves, ".ini" 1,831 and 137, so a name never decides on its own. A name with a save word in it rules a file out unless the contents are overwhelming; the game database's save locations are never taken, and nor are logs, binaries, long files that are only partly settings, or Syncthing's conflict copies. A file taken for settings that changes like a save is given back for good (verdict "save", which also overrules the game database): - before it is left out, and at every later run, the game's snapshots are asked whether it changed in most of the snapshots in which the rest of the save changed (snapshot/history.go); - while it is left out, every change here and every manifest another device sends count whether it changed when the rest of the save did; three such changes, outnumbering the times it stayed put, give it back (daemon/detectsettings.go). Detected files and verdicts are kept per game (migration 0042: settings_files, settings_observations, and marks for when each game was last judged) and applied through the device settings, so hashes recorded under the old list are carried over as before. The game's file list says why each excluded file is excluded, and unticking one there syncs it. The configuration tab's new text is in the catalogs (en, zh-Hans, zh-Hant). Co-Authored-By: Claude Opus 5.5 --- cmd/opensave-app/frontend/src/locales/en.json | 4 + .../frontend/src/locales/zh-Hans.json | 4 + .../frontend/src/locales/zh-Hant.json | 4 + .../frontend/src/views/game/ConfigTab.svelte | 18 +- .../src/views/game/SyncIgnoreEditor.svelte | 7 +- internal/api/routes_savefiles.go | 30 ++ internal/daemon/daemon.go | 27 ++ internal/daemon/detectsettings.go | 249 ++++++++++++ internal/daemon/devicesettings.go | 96 ++++- internal/daemon/devicesettings_test.go | 56 ++- internal/p2p/syncengine/engine.go | 9 + internal/presets/detectsettings.go | 361 ++++++++++++++++++ internal/presets/detectsettings_test.go | 65 ++++ internal/presets/devicesettings.go | 2 +- internal/snapshot/archivefiles.go | 68 ++++ internal/snapshot/history.go | 103 +++++ internal/snapshot/history_test.go | 52 +++ internal/store/marks.go | 22 ++ .../store/migrations/0042_settings_files.sql | 40 ++ internal/store/settings_files.go | 83 ++++ internal/store/snapshot_files.go | 10 + 21 files changed, 1289 insertions(+), 21 deletions(-) create mode 100644 internal/daemon/detectsettings.go create mode 100644 internal/presets/detectsettings.go create mode 100644 internal/presets/detectsettings_test.go create mode 100644 internal/snapshot/archivefiles.go create mode 100644 internal/snapshot/history.go create mode 100644 internal/snapshot/history_test.go create mode 100644 internal/store/marks.go create mode 100644 internal/store/migrations/0042_settings_files.sql create mode 100644 internal/store/settings_files.go diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 37e34a1f..6e7349ea 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -232,6 +232,10 @@ "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": "Others are found by what they hold. 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": "detected — {reason}", + "game.config.sourceSave": "syncs again — {reason}", "common.close": "Close", "common.dismiss": "Dismiss", "common.loading": "Loading", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index 61697d1a..f028fcc0 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -205,6 +205,10 @@ "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}", "common.close": "关闭", "common.dismiss": "忽略", "common.loading": "加载中", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 4db04909..827d27f6 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -205,6 +205,10 @@ "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}", "common.close": "關閉", "common.dismiss": "忽略", "common.loading": "載入中", diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 8d2183a5..041374fc 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -170,8 +170,13 @@
    - {#each deviceSettings as p} -
  • {p.replace(/^\//, '')}
  • + {#each deviceSettings as s} +
  • + {s.pattern.replace(/^!?\//, '')} + + {#if s.source === 'database'}{$t('game.config.sourceDatabase')}{:else if s.source === 'detected'}{$t('game.config.sourceDetected', { reason: s.reason })}{:else}{$t('game.config.sourceSave', { reason: s.reason })}{/if} + +
  • {/each}
@@ -180,6 +185,7 @@ {$t('game.config.syncDeviceSettings')} {$t('game.config.deviceSettingsHint')} + {$t('game.config.deviceSettingsDetectedHint')} {/if} 0} /> @@ -235,6 +241,14 @@ .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; + } .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_savefiles.go b/internal/api/routes_savefiles.go index 3f7248ad..fa9e54b4 100644 --- a/internal/api/routes_savefiles.go +++ b/internal/api/routes_savefiles.go @@ -8,6 +8,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 +39,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 +73,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 +121,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) } } 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..4c656959 --- /dev/null +++ b/internal/daemon/detectsettings.go @@ -0,0 +1,249 @@ +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 { + ask = append(ask, p) + } + } + saves := d.Snapshots.ChangesLikeASave(game.ID, ask) + + changed := false + var taken, given []string + for _, f := range found { + if _, ok := known[f.Path]; ok { + continue + } + v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSettings, Reason: f.Reason} + if why, isSave := saves[f.Path]; isSave { + v.Verdict, v.Reason = store.VerdictSave, why + } else { + taken = append(taken, f.Path) + } + if d.Store.SetSettingsFile(v) == nil { + changed = true + } + } + for p, v := range known { + if why, isSave := saves[p]; isSave && v.Verdict == store.VerdictSettings { + v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0 + if d.Store.SetSettingsFile(v) == nil { + changed = true + given = append(given, p+" ("+why+")") + } + } + } + if !changed { + return + } + d.exclusionsChanged(game) + if len(taken) > 0 && !game.SyncDeviceSettings { + d.Log.Log("info", fmt.Sprintf("%q: %s %s this device's settings, not the save — each device keeps its own. "+ + "Untick it under the game's \"Files that shouldn't sync\" to sync it after all.", + game.Name, strings.Join(taken, ", "), isAre(len(taken)))) + } + 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 isAre(n int) string { + if n == 1 { + return "is" + } + return "are" +} + +func itThey(n int) string { + if n == 1 { + return "it is" + } + return "they are" +} diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go index a50b12e7..071be9e1 100644 --- a/internal/daemon/devicesettings.go +++ b/internal/daemon/devicesettings.go @@ -23,29 +23,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 + } + var out []DeviceSetting + for _, p := range pats { + out = append(out, DeviceSetting{Pattern: p, Source: "database"}) + } + 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}) + } } - if p, ok := d.dev.patterns[key]; ok { - return p + 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) } - p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath}) - d.dev.patterns[key] = p - return p + 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 +121,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 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/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go index 3a0089fa..558b647f 100644 --- a/internal/p2p/syncengine/engine.go +++ b/internal/p2p/syncengine/engine.go @@ -160,6 +160,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. @@ -545,6 +549,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..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..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/archivefiles.go b/internal/snapshot/archivefiles.go new file mode 100644 index 00000000..ce6ff9b0 --- /dev/null +++ b/internal/snapshot/archivefiles.go @@ -0,0 +1,68 @@ +package snapshot + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "strings" + + "github.com/opensave/opensave/internal/delta" + "github.com/opensave/opensave/internal/store" +) + +// ArchiveFiles reads a snapshot's archive and lists its files with their +// content hashes, as the snapshot would have recorded them. +func ArchiveFiles(zipPath string) ([]store.CapturedFile, error) { + path, done, err := OpenArchive(zipPath) + if err != nil { + return nil, err + } + defer done() + r, err := zip.OpenReader(path) + if err != nil { + return nil, err + } + defer r.Close() + out := make([]store.CapturedFile, 0, len(r.File)) + for _, f := range r.File { + if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, delta.TmpSuffix) { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%s: %w", f.Name, err) + } + h := sha256.New() + _, err = io.Copy(h, rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("%s: %w", f.Name, err) + } + c := store.CapturedFile{Path: f.Name, Hash: hex.EncodeToString(h.Sum(nil))} + if rest, ok := strings.CutPrefix(f.Name, RootPrefix); ok { + if root, p, ok := strings.Cut(rest, "/"); ok { + c.Root, c.Path = root, p + } + } + out = append(out, c) + } + return out, nil +} + +// filesOf is what a snapshot holds, from its recorded file list — read from +// its archive once, and recorded, when it has none. +func (m *Manager) filesOf(snap store.Snapshot) ([]store.CapturedFile, error) { + if files, err := m.Store.SnapshotFiles(snap.ID); err == nil && len(files) > 0 { + return files, nil + } + files, err := ArchiveFiles(snap.ZipPath) + if err != nil { + return nil, err + } + if len(files) > 0 { + _ = m.Store.RecordSnapshotFiles(snap.ID, files) + } + return files, nil +} diff --git a/internal/snapshot/history.go b/internal/snapshot/history.go new file mode 100644 index 00000000..b6ebe914 --- /dev/null +++ b/internal/snapshot/history.go @@ -0,0 +1,103 @@ +package snapshot + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" +) + +// 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). + +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 + } + watched := make(map[string]bool, len(paths)) + for _, p := range paths { + watched[p] = true + } + snaps, err := m.Store.AllSnapshotsOfGame(gameID) + if err != nil { + return out + } + if len(snaps) > historySnapshots { + snaps = snaps[len(snaps)-historySnapshots:] + } + + // Per snapshot, the hashes of the watched files and one value for the + // rest of the save. + type state struct { + watched map[string]string + rest string + } + var states []state + for _, s := range snaps { + files, err := m.filesOf(s) + if err != nil || len(files) == 0 { + continue + } + st := state{watched: map[string]string{}} + var rest []string + for _, f := range files { + if f.Root != "" { + continue + } + if watched[f.Path] { + st.watched[f.Path] = f.Hash + } else { + rest = append(rest, f.Path+"\x00"+f.Hash) + } + } + st.rest = restKey(rest) + states = append(states, st) + } + + saves := 0 + moved := map[string]int{} + for i := 1; i < len(states); i++ { + a, b := states[i-1], states[i] + if a.rest == b.rest { + continue // the save did not change: nothing to compare with + } + saves++ + for p := range watched { + if a.watched[p] != b.watched[p] { + moved[p]++ + } + } + } + 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 +} + +func restKey(entries []string) string { + sort.Strings(entries) + h := sha256.New() + for _, e := range entries { + h.Write([]byte(e)) + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil)) +} 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..e9767858 --- /dev/null +++ b/internal/store/migrations/0042_settings_files.sql @@ -0,0 +1,40 @@ +-- 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 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 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 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..94794a57 --- /dev/null +++ b/internal/store/settings_files.go @@ -0,0 +1,83 @@ +package store + +import ( + "fmt" + "time" +) + +// Verdicts on a file in a game's save folder (migrations/0042_settings_files.sql). +const ( + VerdictSettings = "settings" + 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/snapshot_files.go b/internal/store/snapshot_files.go index 8e349878..c2e07a76 100644 --- a/internal/store/snapshot_files.go +++ b/internal/store/snapshot_files.go @@ -132,3 +132,13 @@ 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 +} From cb5c9e74ada5a4af0a7c3909feade4189b0d9f82 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:53:44 +0100 Subject: [PATCH 2/5] store: migration 0042 opens over the earlier draft's tables Asked in review: builds of the earlier draft of this change made settings_files and settings_observations (0043) and marks (0041) with the same schema, and a database from one of them would not start under a plain CREATE TABLE ("table already exists"), as happened with sync_device_settings. IF NOT EXISTS for all three, and a test that opens a database holding them with 0042 unrecorded; it fails without. Co-Authored-By: Claude Opus 5.5 --- .../store/migrations/0042_settings_files.sql | 11 ++++-- .../store/settings_files_migration_test.go | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 internal/store/settings_files_migration_test.go diff --git a/internal/store/migrations/0042_settings_files.sql b/internal/store/migrations/0042_settings_files.sql index e9767858..08a22e4f 100644 --- a/internal/store/migrations/0042_settings_files.sql +++ b/internal/store/migrations/0042_settings_files.sql @@ -1,3 +1,8 @@ +-- 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 @@ -7,7 +12,7 @@ -- 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 settings_files ( +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, @@ -20,7 +25,7 @@ CREATE TABLE settings_files ( -- 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 settings_observations ( +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, @@ -34,7 +39,7 @@ CREATE TABLE settings_observations ( -- 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 marks ( +CREATE TABLE IF NOT EXISTS marks ( name TEXT PRIMARY KEY, value TEXT NOT NULL ); 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) + } +} From 1a4c278426a582c1bba746df3c2037f871be29d2 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:00:41 +0100 Subject: [PATCH 3/5] device settings: files found by what they hold are suggested, and sync until confirmed Asked in review: a wrong guess would stop part of a save syncing without anyone knowing, a bigger risk than the game database's listed files. So a file found by its contents is now only suggested (verdict "suggested"): it keeps syncing, and the game's configuration tab lists it with "Keep on this device" and "Keep syncing". Keeping it on this device leaves it out of syncing as the database's files are, and the give-back checks watch it as before; keeping it syncing records "declined", and it is not suggested again. A suggestion that the snapshots show changing with the save is withdrawn. The game database's files stay automatic. POST /api/games/{id}/suggested-settings {path, keepHere} answers one; the game's JSON carries suggestedSettings. The tab's new text is in the catalogs (en, zh-Hans, zh-Hant). TestSuggestedSettings_SyncUntilConfirmed: detection suggests the settings files and leaves them syncing, an answer each way does what it says, and detecting again changes neither. Co-Authored-By: Claude Opus 5.5 --- cmd/opensave-app/frontend/src/locales/en.json | 10 ++- .../frontend/src/locales/zh-Hans.json | 10 ++- .../frontend/src/locales/zh-Hant.json | 10 ++- .../frontend/src/views/game/ConfigTab.svelte | 42 ++++++++++ internal/api/routes.go | 1 + internal/api/routes_savefiles.go | 31 +++++++ internal/api/server.go | 5 +- internal/daemon/detectsettings.go | 81 +++++++++++++------ internal/daemon/devicesettings.go | 48 +++++++++++ internal/daemon/suggested_settings_test.go | 79 ++++++++++++++++++ internal/store/settings_files.go | 11 ++- 11 files changed, 294 insertions(+), 34 deletions(-) create mode 100644 internal/daemon/suggested_settings_test.go diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 3b7db35f..78e99c90 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -388,10 +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": "Others are found by what they hold. 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.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": "detected — {reason}", + "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 f84005f7..d89442e8 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -340,10 +340,16 @@ "game.config.deviceSettings": "设备设置", "game.config.syncDeviceSettings": "也在设备之间同步这些设置", "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。", - "game.config.deviceSettingsDetectedHint": "其他文件是根据其内容识别的。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。", + "game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。", "game.config.sourceDatabase": "游戏数据库", - "game.config.sourceDetected": "已识别 — {reason}", + "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 3881d69e..87985612 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -340,10 +340,16 @@ "game.config.deviceSettings": "裝置設定", "game.config.syncDeviceSettings": "也在裝置之間同步這些設定", "game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。", - "game.config.deviceSettingsDetectedHint": "其他檔案是根據其內容識別的。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。", + "game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。", "game.config.sourceDatabase": "遊戲資料庫", - "game.config.sourceDetected": "已識別 — {reason}", + "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 041374fc..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,6 +175,28 @@ can't push them out. 0 = keep forever (the default). + {#if suggested.length > 0} +
+ {$t('game.config.suggestedSettings')} +
    + {#each suggested as s (s.pattern)} +
  • + {s.pattern.replace(/^\//, '')} + {s.reason} + + + + +
  • + {/each} +
+
+ {$t('game.config.suggestedHint')} + {/if} {#if deviceSettings.length > 0}
@@ -249,6 +280,17 @@ .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/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 fa9e54b4..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" @@ -234,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/detectsettings.go b/internal/daemon/detectsettings.go index 4c656959..d67b4045 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -87,45 +87,62 @@ func (d *Daemon) DetectSettings(game store.Game) { } } for p, v := range known { - if v.Verdict == store.VerdictSettings { + if v.Verdict == store.VerdictSettings || v.Verdict == store.VerdictSuggested { ask = append(ask, p) } } saves := d.Snapshots.ChangesLikeASave(game.ID, ask) - changed := false - var taken, given []string + // 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.VerdictSettings, Reason: f.Reason} + 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 { - taken = append(taken, f.Path) + offered = append(offered, f.Path) } if d.Store.SetSettingsFile(v) == nil { - changed = true + suggested = true } } for p, v := range known { - if why, isSave := saves[p]; isSave && v.Verdict == store.VerdictSettings { - v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0 - if d.Store.SetSettingsFile(v) == nil { - changed = true - given = append(given, p+" ("+why+")") - } + 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 !changed { - return + if suggested { + d.forgetVerdicts(game.ID) + if d.OnGameChanged != nil { + d.OnGameChanged(game.ID) + } + } + if excluded { + d.exclusionsChanged(game) } - d.exclusionsChanged(game) - if len(taken) > 0 && !game.SyncDeviceSettings { - d.Log.Log("info", fmt.Sprintf("%q: %s %s this device's settings, not the save — each device keeps its own. "+ - "Untick it under the game's \"Files that shouldn't sync\" to sync it after all.", - game.Name, strings.Join(taken, ", "), isAre(len(taken)))) + 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.", @@ -234,16 +251,30 @@ func (d *Daemon) observeLocalSettings(gameID string) { d.observeSettings(game, "", m) } -func isAre(n int) string { +func itThey(n int) string { if n == 1 { - return "is" + return "it is" } - return "are" + return "they are" } -func itThey(n int) string { +func lookLike(n int) string { if n == 1 { - return "it is" + return "looks" } - return "they are" + 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 071be9e1..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" @@ -161,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/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/store/settings_files.go b/internal/store/settings_files.go index 94794a57..2c485516 100644 --- a/internal/store/settings_files.go +++ b/internal/store/settings_files.go @@ -6,9 +6,16 @@ import ( ) // 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 ( - VerdictSettings = "settings" - VerdictSave = "save" + VerdictSuggested = "suggested" + VerdictSettings = "settings" + VerdictDeclined = "declined" + VerdictSave = "save" ) // SettingsFile is a verdict on one file of a game's main save folder. From 899ccced6107fd790165fec96378d0ca0584a853 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:06:51 +0100 Subject: [PATCH 4/5] device settings: the snapshot history is compared by the database Asked about in #106's review for its own lookup: a large save must not be read into memory snapshot by snapshot. ChangesLikeASave read every file of each of a game's last 40 snapshots to tell whether the rest of the save changed - some ten million rows for a quarter-million-file save, each time detection ran - and for a snapshot without a recorded file list it unpacked and hashed the whole archive. Now the database compares two snapshots (store.SnapshotsDiffer, stopping at the first difference), only the files being judged are read (SnapshotPathHashes), and a snapshot with no recorded list is left out (SnapshotHasFiles). ArchiveFiles and filesOf, used only here, are gone. TestSnapshotsDiffer covers the comparison; TestChangesLikeASave passes unchanged. Co-Authored-By: Claude Opus 5.5 --- internal/snapshot/archivefiles.go | 68 --------------------- internal/snapshot/history.go | 81 ++++++++----------------- internal/store/snapshot_files.go | 62 ++++++++++++++++++- internal/store/snapshots_differ_test.go | 49 +++++++++++++++ 4 files changed, 136 insertions(+), 124 deletions(-) delete mode 100644 internal/snapshot/archivefiles.go create mode 100644 internal/store/snapshots_differ_test.go diff --git a/internal/snapshot/archivefiles.go b/internal/snapshot/archivefiles.go deleted file mode 100644 index ce6ff9b0..00000000 --- a/internal/snapshot/archivefiles.go +++ /dev/null @@ -1,68 +0,0 @@ -package snapshot - -import ( - "archive/zip" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "strings" - - "github.com/opensave/opensave/internal/delta" - "github.com/opensave/opensave/internal/store" -) - -// ArchiveFiles reads a snapshot's archive and lists its files with their -// content hashes, as the snapshot would have recorded them. -func ArchiveFiles(zipPath string) ([]store.CapturedFile, error) { - path, done, err := OpenArchive(zipPath) - if err != nil { - return nil, err - } - defer done() - r, err := zip.OpenReader(path) - if err != nil { - return nil, err - } - defer r.Close() - out := make([]store.CapturedFile, 0, len(r.File)) - for _, f := range r.File { - if f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/") || strings.HasSuffix(f.Name, delta.TmpSuffix) { - continue - } - rc, err := f.Open() - if err != nil { - return nil, fmt.Errorf("%s: %w", f.Name, err) - } - h := sha256.New() - _, err = io.Copy(h, rc) - rc.Close() - if err != nil { - return nil, fmt.Errorf("%s: %w", f.Name, err) - } - c := store.CapturedFile{Path: f.Name, Hash: hex.EncodeToString(h.Sum(nil))} - if rest, ok := strings.CutPrefix(f.Name, RootPrefix); ok { - if root, p, ok := strings.Cut(rest, "/"); ok { - c.Root, c.Path = root, p - } - } - out = append(out, c) - } - return out, nil -} - -// filesOf is what a snapshot holds, from its recorded file list — read from -// its archive once, and recorded, when it has none. -func (m *Manager) filesOf(snap store.Snapshot) ([]store.CapturedFile, error) { - if files, err := m.Store.SnapshotFiles(snap.ID); err == nil && len(files) > 0 { - return files, nil - } - files, err := ArchiveFiles(snap.ZipPath) - if err != nil { - return nil, err - } - if len(files) > 0 { - _ = m.Store.RecordSnapshotFiles(snap.ID, files) - } - return files, nil -} diff --git a/internal/snapshot/history.go b/internal/snapshot/history.go index b6ebe914..916f0884 100644 --- a/internal/snapshot/history.go +++ b/internal/snapshot/history.go @@ -1,17 +1,19 @@ package snapshot -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "sort" -) +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 @@ -27,10 +29,6 @@ func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]str if len(paths) == 0 { return out } - watched := make(map[string]bool, len(paths)) - for _, p := range paths { - watched[p] = true - } snaps, err := m.Store.AllSnapshotsOfGame(gameID) if err != nil { return out @@ -39,47 +37,30 @@ func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]str snaps = snaps[len(snaps)-historySnapshots:] } - // Per snapshot, the hashes of the watched files and one value for the - // rest of the save. - type state struct { - watched map[string]string - rest string - } - var states []state + saves := 0 + moved := map[string]int{} + prevID, prev := "", map[string]string(nil) for _, s := range snaps { - files, err := m.filesOf(s) - if err != nil || len(files) == 0 { + if !m.Store.SnapshotHasFiles(s.ID) { continue } - st := state{watched: map[string]string{}} - var rest []string - for _, f := range files { - if f.Root != "" { - continue - } - if watched[f.Path] { - st.watched[f.Path] = f.Hash - } else { - rest = append(rest, f.Path+"\x00"+f.Hash) - } - } - st.rest = restKey(rest) - states = append(states, st) - } - - saves := 0 - moved := map[string]int{} - for i := 1; i < len(states); i++ { - a, b := states[i-1], states[i] - if a.rest == b.rest { - continue // the save did not change: nothing to compare with + hashes, err := m.Store.SnapshotPathHashes(s.ID, "", paths) + if err != nil { + continue } - saves++ - for p := range watched { - if a.watched[p] != b.watched[p] { - moved[p]++ + 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 @@ -91,13 +72,3 @@ func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]str } return out } - -func restKey(entries []string) string { - sort.Strings(entries) - h := sha256.New() - for _, e := range entries { - h.Write([]byte(e)) - h.Write([]byte{0}) - } - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/internal/store/snapshot_files.go b/internal/store/snapshot_files.go index c2e07a76..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. @@ -142,3 +146,59 @@ func (s *Store) AllSnapshotsOfGame(gameID string) ([]Snapshot, error) { } 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") + } +} From 169a41dc0535ddf5835285d090637b9b206e9a9f Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:21:31 +0100 Subject: [PATCH 5/5] device settings: files are read for detection as rarely as possible A game cannot replace a file another program has open - Windows refuses the rename whatever share mode the reader asked for - and many games save by writing a new file and renaming it over the old. Detection read every candidate file at each run, with os.ReadFile, whose handle on Windows also keeps the game from deleting it. Now: - it reads through fsx.OpenShared, so deleting is never blocked; - a file that already has a verdict is not opened again: after the first run, only new files are read; - it does not run while a session of the game is open, when the game writes its saves; that game's turn comes after the session. Tests: a judged file is left alone; while a file is being judged the game can delete it (Windows; fails with os.Open); no detection while the game is played, and detection after it (fails without the check). Co-Authored-By: Claude Opus 5.5 --- internal/daemon/detectsettings.go | 12 ++++++- internal/daemon/suggested_settings_test.go | 34 ++++++++++++++++++ internal/presets/detectsettings.go | 35 ++++++++++++++++--- internal/presets/detectsettings_test.go | 18 +++++++++- .../presets/detectsettings_windows_test.go | 25 +++++++++++++ 5 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 internal/presets/detectsettings_windows_test.go diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go index d67b4045..e57992dc 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -51,6 +51,12 @@ func (d *Daemon) detectSettingsDue(ctx context.Context) { 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 { @@ -79,7 +85,11 @@ func (d *Daemon) DetectSettings(game store.Game) { known[v.Path] = v } } - found := db.DetectSettings(game.Name, game.AppID, game.SavePath) + 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 { diff --git a/internal/daemon/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go index 73bbc16a..2e1c576d 100644 --- a/internal/daemon/suggested_settings_test.go +++ b/internal/daemon/suggested_settings_test.go @@ -1,9 +1,11 @@ package daemon import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/opensave/opensave/internal/ignore" "github.com/opensave/opensave/internal/store" @@ -77,3 +79,35 @@ func TestSuggestedSettings_SyncUntilConfirmed(t *testing.T) { 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/presets/detectsettings.go b/internal/presets/detectsettings.go index 5fe85f2b..685635f5 100644 --- a/internal/presets/detectsettings.go +++ b/internal/presets/detectsettings.go @@ -2,6 +2,7 @@ package presets import ( "bytes" + "io" "io/fs" "os" "path" @@ -10,6 +11,8 @@ import ( "sort" "strings" "unicode/utf16" + + "github.com/opensave/opensave/internal/fsx" ) // Detecting a game's settings files by what they hold, for games the game @@ -268,7 +271,7 @@ func judgeSettings(rel string, b []byte) (bool, string) { // 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 { +func (ds *DeviceSettings) DetectSettings(name, appID, root string, judged map[string]bool) []DetectedSettings { var savePats []string if ds != nil { g, ok := ds.byID[appID] @@ -287,7 +290,7 @@ func (ds *DeviceSettings) DetectSettings(name, appID, root string) []DetectedSet } } } - return detectIn(root, savePats) + return detectIn(root, savePats, judged) } // isSaveLocation reports whether rel is, or is inside, one of the game @@ -317,7 +320,7 @@ func isSaveLocation(rel string, savePats []string) bool { return false } -func detectIn(root string, savePats []string) []DetectedSettings { +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 { @@ -342,11 +345,17 @@ func detectIn(root string, savePats []string) []DetectedSettings { 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 := os.ReadFile(p) + b, err := readForJudging(p) if err != nil { return nil } @@ -359,3 +368,21 @@ func detectIn(root string, savePats []string) []DetectedSettings { 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 index f499e375..f162d4c9 100644 --- a/internal/presets/detectsettings_test.go +++ b/internal/presets/detectsettings_test.go @@ -58,8 +58,24 @@ func TestDetectIn_SaveLocationsAreNeverTaken(t *testing.T) { t.Fatal(err) } } - got := detectIn(root, []string{"slots", "keep/video.ini"}) + 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) + } +}