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/6] 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/6] 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/6] 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 468bff5771e6d846d4af3d1bd83d6360ebca2007 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:49:59 +0100 Subject: [PATCH 4/6] A game tracked without an App ID is linked to the game database once A folder tracked by hand, or found by the scan under a name of its own making, had no App ID, and so no cover art, launching, session detection or device settings. Once per game and folder (a mark), its folder is asked of the game database strictly (IdentifyFolderStrictly): it counts when it is the game's save location, lies inside it, or holds it at most one folder down - never a folder like LocalLow that holds many games' saves. A match gives it the App ID, and cover art if it had none; its name stays the user's. Asked as the game's settings are detected, so they follow from the App ID at once. An App ID someone takes away is not put back. Tests: the strict identification, and the link - once, not for a wide folder (which the plain IdentifyFolder would link), and not again. Co-Authored-By: Claude Opus 5.5 --- internal/daemon/detectsettings.go | 3 ++ internal/daemon/gamedb.go | 38 ++++++++++++++++ internal/daemon/link_gamedb_test.go | 70 +++++++++++++++++++++++++++++ internal/presets/gamedb.go | 24 +++++++++- internal/presets/gamedb_test.go | 33 ++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 internal/daemon/link_gamedb_test.go diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go index d67b4045..ec486993 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -51,6 +51,9 @@ func (d *Daemon) detectSettingsDue(ctx context.Context) { if ctx.Err() != nil { return } + // A game tracked without an App ID gets the game database's first, if + // its folder says which game it is: its settings come from there too. + game = d.linkToGameDatabase(game) mark := d.Store.Mark(settingsDetectMark(game.ID)) want := detectorVersion + "|" + game.SavePath if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want { diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go index 302ef470..25e93d47 100644 --- a/internal/daemon/gamedb.go +++ b/internal/daemon/gamedb.go @@ -1,6 +1,7 @@ package daemon import ( + "fmt" "os" "path/filepath" "strings" @@ -88,3 +89,40 @@ func (d *Daemon) markTracked(m *presets.GameMatch) { } } } + +func gameDBMark(gameID string) string { return "gamedb:" + gameID } + +// linkToGameDatabase gives a game tracked without an App ID the one the game +// database knows its folder by, strictly (IdentifyFolderStrictly): a folder +// tracked by hand, or found under a name of the scan's own making ("Pal +// (Epic/Unreal Save)"), gets cover art, launching, session detection and its +// device settings. The name is the user's and is left as it is. +// +// Once per game and folder (a mark): an App ID someone took away again is not +// put back at the next start. +func (d *Daemon) linkToGameDatabase(game store.Game) store.Game { + if game.AppID != "" || game.SavePath == "" { + return game + } + if d.Store.Mark(gameDBMark(game.ID)) == game.SavePath { + return game + } + _ = d.Store.SetMark(gameDBMark(game.ID), game.SavePath) + m, ok := d.gameDB().IdentifyFolderStrictly(game.SavePath) + if !ok || m.AppID == "" { + return game + } + game.AppID = m.AppID + if game.CoverURL == "" { + game.CoverURL = SteamCoverURL(m.AppID) + } + if err := d.Store.UpdateGame(game); err != nil { + return game + } + d.Log.Log("info", fmt.Sprintf("%q is %s in the game database: cover art, its settings and seeing it played come from there", game.Name, m.Name)) + d.exclusionsChanged(game) + if d.OnGameChanged != nil { + d.OnGameChanged(game.ID) + } + return game +} diff --git a/internal/daemon/link_gamedb_test.go b/internal/daemon/link_gamedb_test.go new file mode 100644 index 00000000..204fd004 --- /dev/null +++ b/internal/daemon/link_gamedb_test.go @@ -0,0 +1,70 @@ +package daemon + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/opensave/opensave/internal/presets" + "github.com/opensave/opensave/internal/store" +) + +// A game tracked without an App ID is given the game database's, once, when +// its folder plainly is that game's: not for a folder holding many games' +// saves, and not again after someone took it away. +func TestLinkToGameDatabase_OncePerFolder(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("the game database below uses Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + low := filepath.Join(home, "AppData", "LocalLow") + duck := filepath.Join(low, "Soda", "Duck") + if err := os.MkdirAll(filepath.Join(duck, "Saves"), 0o777); err != nil { + t.Fatal(err) + } + d := newTestDaemon(t) + dir := t.TempDir() + manifest := ` +Duck Game: + files: + "/AppData/LocalLow/Soda/Duck/Saves/*.sav": + tags: [save] + steam: + id: 3167020 +` + if err := os.WriteFile(filepath.Join(dir, "ludusavi-manifest.yaml"), []byte(manifest), 0o666); err != nil { + t.Fatal(err) + } + d.Scanner = &presets.Scanner{CacheFile: filepath.Join(dir, "steam-app-cache.json"), GOOS: "windows"} + + game := store.Game{ID: "duckov", Name: "Duckov", SavePath: duck, ActiveBranch: "main", MaxSnapshots: 5} + wide := store.Game{ID: "lowlow", Name: "Everything in LocalLow", SavePath: low, ActiveBranch: "main", MaxSnapshots: 5} + for _, g := range []store.Game{game, wide} { + if err := d.Store.CreateGame(g); err != nil { + t.Fatal(err) + } + } + + if got := d.linkToGameDatabase(game); got.AppID != "3167020" || got.Name != "Duckov" { + t.Fatalf("linked as %+v, want App ID 3167020 and the name left as it is", got) + } + if stored, _ := d.Store.GetGame(game.ID); stored.AppID != "3167020" || stored.CoverURL == "" { + t.Errorf("stored as %+v", stored) + } + if got := d.linkToGameDatabase(wide); got.AppID != "" { + t.Errorf("a folder holding many games' saves was linked to one: %q", got.AppID) + } + + // Someone takes the App ID away again: it stays away. + stored, _ := d.Store.GetGame(game.ID) + stored.AppID, stored.CoverURL = "", "" + if err := d.Store.UpdateGame(stored); err != nil { + t.Fatal(err) + } + if got := d.linkToGameDatabase(stored); got.AppID != "" { + t.Errorf("an App ID taken away was put back: %q", got.AppID) + } +} diff --git a/internal/presets/gamedb.go b/internal/presets/gamedb.go index 011c77d9..f81f66a1 100644 --- a/internal/presets/gamedb.go +++ b/internal/presets/gamedb.go @@ -149,6 +149,20 @@ func (ds *GameDB) IdentifyProgram(exe string) (GameMatch, bool) { // location in the game database is this folder, or holds it, or is held by it // (a folder picked one level too high). func (ds *GameDB) IdentifyFolder(folder string) (GameMatch, bool) { + return ds.identifyFolder(folder, 0) +} + +// IdentifyFolderStrictly is IdentifyFolder for naming a game nobody asked +// about: a folder holding the save location only counts when that is at most +// one folder further down ("Duckov" holding "DuckovSaves"), never a folder +// like Documents that holds many games'. +func (ds *GameDB) IdentifyFolderStrictly(folder string) (GameMatch, bool) { + return ds.identifyFolder(folder, 1) +} + +// identifyFolder: maxDepth limits how far below the folder a save location +// held by it may be, 0 for no limit. +func (ds *GameDB) identifyFolder(folder string, maxDepth int) (GameMatch, bool) { if ds == nil || folder == "" { return GameMatch{}, false } @@ -179,7 +193,15 @@ func (ds *GameDB) IdentifyFolder(folder string) (GameMatch, bool) { case under(expanded, folder): score = 2 // a folder inside the save location case under(folder, expanded): - score = 1 // the save location inside the folder picked + // The save location inside the folder picked, counted + // to the folder a file location is in. + rel, _ := relativeUnder(folder, expanded) + if path.Ext(path.Base(rel)) != "" { + rel = path.Dir(rel) + } + if maxDepth == 0 || strings.Count(rel, "/") < maxDepth { + score = 1 + } } if score > bestScore || (score == bestScore && score > 0 && better(g, best)) { best, bestScore = g, score diff --git a/internal/presets/gamedb_test.go b/internal/presets/gamedb_test.go index db9addd3..f4f152a0 100644 --- a/internal/presets/gamedb_test.go +++ b/internal/presets/gamedb_test.go @@ -67,3 +67,36 @@ Project Things: t.Error("an unrelated program was taken for a game") } } + +// Naming a game nobody asked about: a folder one level above its save +// location counts, a folder holding many games' does not. +func TestGameDB_IdentifyFolderStrictly(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("templates below use Windows placeholders") + } + home := t.TempDir() + t.Setenv("USERPROFILE", home) + t.Setenv("HOME", home) + low := filepath.Join(home, "AppData", "LocalLow") + if err := os.MkdirAll(filepath.Join(low, "Soda", "Duck", "Saves"), 0o777); err != nil { + t.Fatal(err) + } + sc := manifestScanner(t, ` +Duck Game: + files: + "/AppData/LocalLow/Soda/Duck/Saves/*.sav": + tags: [save] + steam: + id: 3167020 +`) + ds := sc.GameDB() + if m, ok := ds.IdentifyFolderStrictly(filepath.Join(low, "Soda", "Duck")); !ok || m.AppID != "3167020" { + t.Errorf("the folder above the save location: %+v %v", m, ok) + } + if _, ok := ds.IdentifyFolderStrictly(low); ok { + t.Error("a folder holding many games' saves was named after one") + } + if _, ok := ds.IdentifyFolder(low); !ok { + t.Error("asked about, a folder holding the save location further down is not recognised") + } +} 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 5/6] 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 7e04a2549deb88f3c403e921d8a356abdfb29ef8 Mon Sep 17 00:00:00 2001 From: ChakraFusion <112588066+ChakraFusion@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:12:20 +0100 Subject: [PATCH 6/6] game database link: suggested, and set only when confirmed Asked on #118 for files found by their contents, and it holds here too: through the App ID the game database's settings files are left out of syncing, so a wrong guess about which game a folder is would leave out files of the save without anyone knowing. The game a folder plainly is (IdentifyFolderStrictly) is now only suggested: the game's configuration tab says "This looks like ... in the game database" with Link and Not this game. Link sets the App ID, and cover art if there was none - the name stays the user's - and its device settings follow; Not this game leaves it as it is. Either answer, and an App ID taken away, is not asked about again for that folder. POST /api/games/{id}/gamedb-link {accept}; the game's JSON carries gameDBSuggestion. After Link the tab's form takes the App ID too, so saving it does not undo the link. The new text is in the catalogs. TestGameDBLink_SuggestedThenConfirmed: suggested and not set, a wide folder not suggested, confirming links, declining is not asked again, and an App ID taken away is not suggested again. Co-Authored-By: Claude Opus 5.5 --- cmd/opensave-app/frontend/src/locales/en.json | 5 ++ .../frontend/src/locales/zh-Hans.json | 5 ++ .../frontend/src/locales/zh-Hant.json | 5 ++ .../frontend/src/views/game/ConfigTab.svelte | 39 ++++++++ internal/api/routes.go | 1 + internal/api/routes_gamedb.go | 30 +++++++ internal/api/server.go | 5 +- internal/daemon/detectsettings.go | 6 +- internal/daemon/gamedb.go | 88 ++++++++++++++----- internal/daemon/link_gamedb_test.go | 65 ++++++++++---- 10 files changed, 208 insertions(+), 41 deletions(-) diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 78e99c90..cc8793ac 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -392,6 +392,11 @@ "game.config.sourceDatabase": "game database", "game.config.sourceDetected": "kept here by you — {reason}", "game.config.sourceSave": "syncs again — {reason}", + "game.config.dbSuggestion": "This looks like {name} in the game database. Linked, it gets cover art, launching, seeing it played, and the game database’s settings files kept on each device.", + "game.config.dbLink": "Link", + "game.config.dbNotThis": "Not this game", + "game.config.dbLinked": "Linked to {name}", + "game.config.dbNotLinked": "Left as it is", "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", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index d89442e8..976120d6 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -344,6 +344,11 @@ "game.config.sourceDatabase": "游戏数据库", "game.config.sourceDetected": "由你保留在本机 — {reason}", "game.config.sourceSave": "恢复同步 — {reason}", + "game.config.dbSuggestion": "这看起来是游戏数据库中的 {name}。关联后将获得封面、启动、游玩检测,并且游戏数据库列出的设置文件会在每台设备上各自保留。", + "game.config.dbLink": "关联", + "game.config.dbNotThis": "不是这个游戏", + "game.config.dbLinked": "已关联到 {name}", + "game.config.dbNotLinked": "保持不变", "game.config.suggestedSettings": "建议的设置文件", "game.config.suggestedHint": "根据内容判断,这些文件看起来是本设备的设置而非存档。在你决定之前它们会继续同步:选择保留在本设备后,每台设备将各自保留自己的版本。", "game.config.keepHere": "保留在本设备", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 87985612..110b7837 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -344,6 +344,11 @@ "game.config.sourceDatabase": "遊戲資料庫", "game.config.sourceDetected": "由你保留在本機 — {reason}", "game.config.sourceSave": "恢復同步 — {reason}", + "game.config.dbSuggestion": "這看起來是遊戲資料庫中的 {name}。連結後將取得封面、啟動、遊玩偵測,並且遊戲資料庫列出的設定檔案會在每臺裝置上各自保留。", + "game.config.dbLink": "連結", + "game.config.dbNotThis": "不是這個遊戲", + "game.config.dbLinked": "已連結到 {name}", + "game.config.dbNotLinked": "保持不變", "game.config.suggestedSettings": "建議的設定檔案", "game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。", "game.config.keepHere": "保留在本裝置", diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 71770080..6c328bf5 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -43,6 +43,21 @@ return run(done, () => api.post(`/api/games/${game.id}/suggested-settings`, { path, keepHere })); } + // The game in the game database this one looks like, for a game tracked + // without an App ID: linked only when confirmed here, as the App ID brings + // its settings files with it, left out of syncing. + $: dbSuggestion = game.gameDBSuggestion ?? null; + function answerLink(accept) { + const s = dbSuggestion; + if (!s) return; + const done = accept ? $t('game.config.dbLinked', { name: s.name }) : $t('game.config.dbNotLinked'); + return run(done, async () => { + await api.post(`/api/games/${game.id}/gamedb-link`, { accept }); + // The form holds the App ID as the tab opened; saving it would undo this. + if (accept) cfg.appId = s.appId; + }); + } + // Cover preview: a custom URL wins, else the proxied Steam art for the App // ID being edited. $: cover = @@ -140,6 +155,19 @@ {/if}
+ {#if dbSuggestion} +
+ {$t('game.config.dbSuggestion', { name: dbSuggestion.name })} + + + + +
+ {/if}
@@ -286,6 +314,17 @@ color: var(--text-dim); font-weight: 500; } + .db-suggestion { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 14px; + } + .db-suggestion .decide { + display: inline-flex; + gap: 6px; + } .device-settings .decide { display: inline-flex; gap: 6px; diff --git a/internal/api/routes.go b/internal/api/routes.go index 7b23135a..9fbdc5ea 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -49,6 +49,7 @@ func (s *Server) routes(r chi.Router) { 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.Post("/api/games/{gameId}/gamedb-link", s.handleAnswerGameDBLink) r.Delete("/api/games/{gameId}/roots/{root}", s.handleRemoveGameRoot) r.Delete("/api/games/{gameId}/alias/{aliasId}", s.handleUnlinkGame) diff --git a/internal/api/routes_gamedb.go b/internal/api/routes_gamedb.go index b1d9024c..36f22835 100644 --- a/internal/api/routes_gamedb.go +++ b/internal/api/routes_gamedb.go @@ -1,8 +1,11 @@ package api import ( + "errors" "net/http" + "github.com/go-chi/chi/v5" + "github.com/opensave/opensave/internal/daemon" "github.com/opensave/opensave/internal/presets" ) @@ -24,3 +27,30 @@ func (s *Server) handleGameDBIdentify(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"found": true, "game": m}) } + +// POST /api/games/{gameId}/gamedb-link {"accept": bool} — the answer to the +// game in the game database a game tracked without an App ID looks like. +func (s *Server) handleAnswerGameDBLink(w http.ResponseWriter, r *http.Request) { + game, err := s.Daemon.Store.GetGame(chi.URLParam(r, "gameId")) + if err != nil { + writeError(w, http.StatusNotFound, "Game not found.") + return + } + var body struct { + Accept bool `json:"accept"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "accept is required") + return + } + if err := s.Daemon.AnswerGameDBLink(game, body.Accept); err != nil { + if errors.Is(err, daemon.ErrNoGameDBSuggestion) { + 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 30de9e21..5d00fdfe 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -569,7 +569,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any { "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), + "suggestedSettings": s.Daemon.SuggestedSettings(g), + // The game in the game database a game tracked without an App ID + // looks like, to be confirmed (daemon.SuggestedGameDBLink). + "gameDBSuggestion": s.Daemon.SuggestedGameDBLink(g), "syncDeviceSettings": g.SyncDeviceSettings, "branches": branches, "createdAt": g.CreatedAt, diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go index ec486993..9f1af11d 100644 --- a/internal/daemon/detectsettings.go +++ b/internal/daemon/detectsettings.go @@ -51,9 +51,9 @@ func (d *Daemon) detectSettingsDue(ctx context.Context) { if ctx.Err() != nil { return } - // A game tracked without an App ID gets the game database's first, if - // its folder says which game it is: its settings come from there too. - game = d.linkToGameDatabase(game) + // A game tracked without an App ID: which game its folder says it is + // is suggested, to be confirmed on the game's page. + d.suggestGameDatabaseLink(game) mark := d.Store.Mark(settingsDetectMark(game.ID)) want := detectorVersion + "|" + game.SavePath if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want { diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go index 25e93d47..2abee312 100644 --- a/internal/daemon/gamedb.go +++ b/internal/daemon/gamedb.go @@ -1,6 +1,7 @@ package daemon import ( + "errors" "fmt" "os" "path/filepath" @@ -90,39 +91,86 @@ func (d *Daemon) markTracked(m *presets.GameMatch) { } } +// A game tracked without an App ID — a folder tracked by hand, or found by +// the scan under a name of its own making ("Pal (Epic/Unreal Save)") — has no +// cover art, launching, session detection or device settings. When its folder +// plainly is one game's in the game database, that is suggested on the game's +// page, and the App ID is set only when the user confirms it: through the +// App ID the database's settings files are left out of syncing, and a wrong +// guess would leave out files of the save without anyone knowing. + func gameDBMark(gameID string) string { return "gamedb:" + gameID } -// linkToGameDatabase gives a game tracked without an App ID the one the game -// database knows its folder by, strictly (IdentifyFolderStrictly): a folder -// tracked by hand, or found under a name of the scan's own making ("Pal -// (Epic/Unreal Save)"), gets cover art, launching, session detection and its -// device settings. The name is the user's and is left as it is. -// -// Once per game and folder (a mark): an App ID someone took away again is not -// put back at the next start. -func (d *Daemon) linkToGameDatabase(game store.Game) store.Game { +func gameDBSuggestionMark(gameID string) string { return "gamedb-suggestion:" + gameID } + +// GameDBSuggestion is the game in the game database a game tracked without an +// App ID looks like, waiting for the user's answer. +type GameDBSuggestion struct { + AppID string `json:"appId"` + Name string `json:"name"` +} + +// suggestGameDatabaseLink asks the game database about a game tracked without +// an App ID, strictly (IdentifyFolderStrictly), and records what it says as a +// suggestion. Once per game and folder (a mark): an answer is not asked for +// again, nor an App ID someone took away suggested again. +func (d *Daemon) suggestGameDatabaseLink(game store.Game) { if game.AppID != "" || game.SavePath == "" { - return game + return } if d.Store.Mark(gameDBMark(game.ID)) == game.SavePath { - return game + return } _ = d.Store.SetMark(gameDBMark(game.ID), game.SavePath) m, ok := d.gameDB().IdentifyFolderStrictly(game.SavePath) if !ok || m.AppID == "" { - return game + return } - game.AppID = m.AppID + if d.Store.SetMark(gameDBSuggestionMark(game.ID), m.AppID+"\n"+m.Name) != nil { + return + } + d.Log.Log("info", fmt.Sprintf("%q looks like %s in the game database — link it from the game's configuration tab for its cover art, settings and seeing it played", game.Name, m.Name)) + if d.OnGameChanged != nil { + d.OnGameChanged(game.ID) + } +} + +// SuggestedGameDBLink is the suggestion waiting for a game, if any. +func (d *Daemon) SuggestedGameDBLink(game store.Game) *GameDBSuggestion { + if game.AppID != "" { + return nil + } + appID, name, ok := strings.Cut(d.Store.Mark(gameDBSuggestionMark(game.ID)), "\n") + if !ok || appID == "" { + return nil + } + return &GameDBSuggestion{AppID: appID, Name: name} +} + +// ErrNoGameDBSuggestion is an answer for a game with no suggestion waiting. +var ErrNoGameDBSuggestion = errors.New("no game database link is suggested for this game") + +// AnswerGameDBLink records the user's answer to a game's suggestion: accept +// gives the game the App ID, and cover art if it had none — its name stays +// the user's — and its device settings follow; otherwise the suggestion goes, +// and is not made again for this folder. +func (d *Daemon) AnswerGameDBLink(game store.Game, accept bool) error { + s := d.SuggestedGameDBLink(game) + if s == nil { + return ErrNoGameDBSuggestion + } + _ = d.Store.SetMark(gameDBSuggestionMark(game.ID), "") + if !accept { + return nil + } + game.AppID = s.AppID if game.CoverURL == "" { - game.CoverURL = SteamCoverURL(m.AppID) + game.CoverURL = SteamCoverURL(s.AppID) } if err := d.Store.UpdateGame(game); err != nil { - return game + return err } - d.Log.Log("info", fmt.Sprintf("%q is %s in the game database: cover art, its settings and seeing it played come from there", game.Name, m.Name)) + d.Log.Log("info", fmt.Sprintf("%q is linked to %s in the game database: cover art, its settings and seeing it played come from there", game.Name, s.Name)) d.exclusionsChanged(game) - if d.OnGameChanged != nil { - d.OnGameChanged(game.ID) - } - return game + return nil } diff --git a/internal/daemon/link_gamedb_test.go b/internal/daemon/link_gamedb_test.go index 204fd004..04fab1e9 100644 --- a/internal/daemon/link_gamedb_test.go +++ b/internal/daemon/link_gamedb_test.go @@ -10,10 +10,12 @@ import ( "github.com/opensave/opensave/internal/store" ) -// A game tracked without an App ID is given the game database's, once, when -// its folder plainly is that game's: not for a folder holding many games' -// saves, and not again after someone took it away. -func TestLinkToGameDatabase_OncePerFolder(t *testing.T) { +// A game tracked without an App ID is not given the game database's on a +// guess: the game its folder plainly is gets suggested, and the App ID is set +// only when the user confirms it. A folder holding many games' saves is not +// suggested as one; an answer either way, and an App ID taken away, are not +// asked about again. +func TestGameDBLink_SuggestedThenConfirmed(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("the game database below uses Windows placeholders") } @@ -41,30 +43,59 @@ Duck Game: d.Scanner = &presets.Scanner{CacheFile: filepath.Join(dir, "steam-app-cache.json"), GOOS: "windows"} game := store.Game{ID: "duckov", Name: "Duckov", SavePath: duck, ActiveBranch: "main", MaxSnapshots: 5} + declined := store.Game{ID: "duckov2", Name: "Duckov copy", SavePath: duck, ActiveBranch: "main", MaxSnapshots: 5} wide := store.Game{ID: "lowlow", Name: "Everything in LocalLow", SavePath: low, ActiveBranch: "main", MaxSnapshots: 5} - for _, g := range []store.Game{game, wide} { + for _, g := range []store.Game{game, declined, wide} { if err := d.Store.CreateGame(g); err != nil { t.Fatal(err) } + d.suggestGameDatabaseLink(g) + } + reload := func(id string) store.Game { + g, _ := d.Store.GetGame(id) + return g + } + + // Suggested, not set. + s := d.SuggestedGameDBLink(reload(game.ID)) + if s == nil || s.AppID != "3167020" || s.Name != "Duck Game" { + t.Fatalf("suggestion = %+v, want Duck Game (3167020)", s) + } + if reload(game.ID).AppID != "" { + t.Fatal("the App ID was set before anyone confirmed it") + } + if d.SuggestedGameDBLink(reload(wide.ID)) != nil { + t.Error("a folder holding many games' saves was suggested as one") } - if got := d.linkToGameDatabase(game); got.AppID != "3167020" || got.Name != "Duckov" { - t.Fatalf("linked as %+v, want App ID 3167020 and the name left as it is", got) + // Confirmed: linked, the name left as it is. + if err := d.AnswerGameDBLink(reload(game.ID), true); err != nil { + t.Fatal(err) + } + if g := reload(game.ID); g.AppID != "3167020" || g.Name != "Duckov" || g.CoverURL == "" { + t.Errorf("after confirming: %+v", g) + } + + // Declined: nothing set, and not asked again. + if err := d.AnswerGameDBLink(reload(declined.ID), false); err != nil { + t.Fatal(err) } - if stored, _ := d.Store.GetGame(game.ID); stored.AppID != "3167020" || stored.CoverURL == "" { - t.Errorf("stored as %+v", stored) + d.suggestGameDatabaseLink(reload(declined.ID)) + if g := reload(declined.ID); g.AppID != "" || d.SuggestedGameDBLink(g) != nil { + t.Errorf("a declined link came back: %+v", g) } - if got := d.linkToGameDatabase(wide); got.AppID != "" { - t.Errorf("a folder holding many games' saves was linked to one: %q", got.AppID) + if err := d.AnswerGameDBLink(reload(declined.ID), true); err != ErrNoGameDBSuggestion { + t.Errorf("answering with nothing suggested: %v, want ErrNoGameDBSuggestion", err) } - // Someone takes the App ID away again: it stays away. - stored, _ := d.Store.GetGame(game.ID) - stored.AppID, stored.CoverURL = "", "" - if err := d.Store.UpdateGame(stored); err != nil { + // The App ID taken away again: not suggested again. + g := reload(game.ID) + g.AppID, g.CoverURL = "", "" + if err := d.Store.UpdateGame(g); err != nil { t.Fatal(err) } - if got := d.linkToGameDatabase(stored); got.AppID != "" { - t.Errorf("an App ID taken away was put back: %q", got.AppID) + d.suggestGameDatabaseLink(reload(game.ID)) + if d.SuggestedGameDBLink(reload(game.ID)) != nil { + t.Error("an App ID taken away was suggested again") } }