diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json
index 9b7d6d99..cc8793ac 100644
--- a/cmd/opensave-app/frontend/src/locales/en.json
+++ b/cmd/opensave-app/frontend/src/locales/en.json
@@ -388,6 +388,21 @@
"game.config.deviceSettings": "Device settings",
"game.config.syncDeviceSettings": "Sync these settings between devices too",
"game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot.",
+ "game.config.deviceSettingsDetectedHint": "Settings files you confirm from the suggestions are kept on each device too. A file that turns out to change along with the save syncs again by itself; to sync one yourself, untick it under “Pick from your save folder” below.",
+ "game.config.sourceDatabase": "game database",
+ "game.config.sourceDetected": "kept here by you — {reason}",
+ "game.config.sourceSave": "syncs again — {reason}",
+ "game.config.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",
+ "game.config.keepSyncing": "Keep syncing",
+ "game.config.keptHere": "{file} is kept on this device now",
+ "game.config.keptSyncing": "{file} keeps syncing",
"notifications.clearAll": "Clear all",
"notifications.clearAllTitle": "Take these out of the bell. Activity still has them.",
"palette.renameGame": "Rename {game}"
diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json
index 7d92a282..976120d6 100644
--- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json
+++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json
@@ -340,6 +340,21 @@
"game.config.deviceSettings": "设备设置",
"game.config.syncDeviceSettings": "也在设备之间同步这些设置",
"game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。",
+ "game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。",
+ "game.config.sourceDatabase": "游戏数据库",
+ "game.config.sourceDetected": "由你保留在本机 — {reason}",
+ "game.config.sourceSave": "恢复同步 — {reason}",
+ "game.config.dbSuggestion": "这看起来是游戏数据库中的 {name}。关联后将获得封面、启动、游玩检测,并且游戏数据库列出的设置文件会在每台设备上各自保留。",
+ "game.config.dbLink": "关联",
+ "game.config.dbNotThis": "不是这个游戏",
+ "game.config.dbLinked": "已关联到 {name}",
+ "game.config.dbNotLinked": "保持不变",
+ "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 4752f44f..110b7837 100644
--- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json
+++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json
@@ -340,6 +340,21 @@
"game.config.deviceSettings": "裝置設定",
"game.config.syncDeviceSettings": "也在裝置之間同步這些設定",
"game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。",
+ "game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。",
+ "game.config.sourceDatabase": "遊戲資料庫",
+ "game.config.sourceDetected": "由你保留在本機 — {reason}",
+ "game.config.sourceSave": "恢復同步 — {reason}",
+ "game.config.dbSuggestion": "這看起來是遊戲資料庫中的 {name}。連結後將取得封面、啟動、遊玩偵測,並且遊戲資料庫列出的設定檔案會在每臺裝置上各自保留。",
+ "game.config.dbLink": "連結",
+ "game.config.dbNotThis": "不是這個遊戲",
+ "game.config.dbLinked": "已連結到 {name}",
+ "game.config.dbNotLinked": "保持不變",
+ "game.config.suggestedSettings": "建議的設定檔案",
+ "game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。",
+ "game.config.keepHere": "保留在本裝置",
+ "game.config.keepSyncing": "繼續同步",
+ "game.config.keptHere": "{file} 現在保留在本裝置",
+ "game.config.keptSyncing": "{file} 將繼續同步",
"notifications.clearAll": "清除全部",
"notifications.clearAllTitle": "將這些從鈴鐺移除,活動頁仍保留",
"palette.renameGame": "重新命名 {game}"
diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
index 8d2183a5..6c328bf5 100644
--- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
+++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte
@@ -32,8 +32,32 @@
// 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 }));
+ }
+
+ // 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 =
@@ -131,6 +155,19 @@
{/if}
+ {#if dbSuggestion}
+
@@ -180,6 +244,7 @@
{$t('game.config.syncDeviceSettings')}
{$t('game.config.deviceSettingsHint')}
+ {$t('game.config.deviceSettingsDetectedHint')}
{/if}
0} />
@@ -235,6 +300,36 @@
.device-settings code {
font-size: 0.9em;
}
+ .device-settings .source {
+ font-size: 0.82em;
+ color: var(--text-faint);
+ margin-left: 6px;
+ }
+ .device-settings .given-back code {
+ text-decoration: line-through;
+ }
+ /* A heading over a list, not a control: styled as the field labels are. */
+ .device-block .label {
+ font-size: 0.85rem;
+ color: var(--text-dim);
+ font-weight: 500;
+ }
+ .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;
+ margin-left: 8px;
+ }
.device-block {
margin-bottom: 4px !important;
}
diff --git a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte
index 13d14bd8..742d059f 100644
--- a/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte
+++ b/cmd/opensave-app/frontend/src/views/game/SyncIgnoreEditor.svelte
@@ -133,7 +133,10 @@
{#if f.location}{f.location} ›{/if}{f.path}
- {f.excluded ? "won't sync" : 'syncs'}
+
+ {#if !f.excluded}syncs{:else if f.source === 'database'}won't sync · settings (game database){:else if f.source === 'detected'}won't
+ sync · settings (detected){:else}won't sync{/if}
+
{/each}
@@ -141,6 +144,8 @@
Ticking a file writes the pattern for you, anchored so it can only ever mean that
one file. Unticking a file caught by a wildcard adds a ! exception
rather than deleting the wildcard.
+ Files marked settings are left out as this game's device settings; unticking
+ one syncs it after all.
{#if severalFolders}
A pattern applies to every one of this game's folders, so a
name that appears in two of them is excluded in both.
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 903646e5..9fbdc5ea 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -48,6 +48,8 @@ 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.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/routes_savefiles.go b/internal/api/routes_savefiles.go
index 3f7248ad..bfdba491 100644
--- a/internal/api/routes_savefiles.go
+++ b/internal/api/routes_savefiles.go
@@ -1,6 +1,7 @@
package api
import (
+ "errors"
"net/http"
"os"
"path/filepath"
@@ -8,6 +9,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
+ "github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/ignore"
)
@@ -38,6 +40,11 @@ type saveFile struct {
MtimeMs int64 `json:"mtimeMs"`
// Excluded reports whether the current rules stop this file syncing.
Excluded bool `json:"excluded"`
+ // Why: "rule" (the game's own exclusion list), "database" (the game
+ // database names it as the game's settings) or "detected" (found to be
+ // its settings by what it holds). Empty when it syncs.
+ Source string `json:"source,omitempty"`
+ Reason string `json:"reason,omitempty"`
}
// saveFileListCap bounds the response. A save folder with more files than
@@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
// With the game's device settings, which are left out the same way.
text := s.Daemon.IgnoreText(game)
rules := ignore.Parse(text)
+ ownRules := ignore.Parse(game.SyncIgnore)
+ var settings []daemon.DeviceSetting
+ if !game.SyncDeviceSettings {
+ settings = s.Daemon.DeviceSettings(game)
+ }
+ // which says what excludes a file the rules exclude: the game's own
+ // rules first, as they apply last; then the last device-settings entry
+ // that names it.
+ which := func(path string) (string, string) {
+ if !ownRules.Empty() && ownRules.Match(path) {
+ return "rule", ""
+ }
+ for i := len(settings) - 1; i >= 0; i-- {
+ if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) {
+ return settings[i].Source, settings[i].Reason
+ }
+ }
+ return "rule", ""
+ }
roots := []struct{ name, path string }{{"", game.SavePath}}
if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil {
@@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
for _, f := range files {
f.Location = root.name
f.Excluded = rules.Match(f.Path)
+ if f.Excluded && root.name == "" {
+ f.Source, f.Reason = which(f.Path)
+ } else if f.Excluded {
+ f.Source = "rule"
+ }
out = append(out, f)
}
}
@@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) {
}
return out, truncated
}
+
+// handleDecideSuggestedSettings answers a suggested settings file from the
+// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of
+// syncing as this device's settings, false keeps it syncing for good.
+func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) {
+ gameID := chi.URLParam(r, "gameId")
+ game, err := s.Daemon.Store.GetGame(gameID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "Game not found.")
+ return
+ }
+ var body struct {
+ Path string `json:"path"`
+ KeepHere bool `json:"keepHere"`
+ }
+ if err := readJSON(r, &body); err != nil || body.Path == "" {
+ writeError(w, http.StatusBadRequest, "path is required")
+ return
+ }
+ if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil {
+ if errors.Is(err, daemon.ErrNotSuggested) {
+ writeError(w, http.StatusConflict, err.Error())
+ return
+ }
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ s.BroadcastGamesUpdate()
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d40a280..5d00fdfe 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -566,7 +566,13 @@ 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),
+ // 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/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..9f1af11d
--- /dev/null
+++ b/internal/daemon/detectsettings.go
@@ -0,0 +1,283 @@
+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
+ }
+ // 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 {
+ if t, err := time.Parse(time.RFC3339, at); err == nil && time.Since(t) < detectEvery {
+ continue
+ }
+ }
+ d.DetectSettings(game)
+ _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339))
+ }
+}
+
+// DetectSettings judges a game's files, records what is new, and re-asks the
+// snapshots about everything taken for settings so far.
+func (d *Daemon) DetectSettings(game store.Game) {
+ if d.Scanner == nil || game.SavePath == "" || SaveFolderMissing(game.SavePath) {
+ return
+ }
+ d.devMu.Lock()
+ db := d.devDBLocked()
+ d.devMu.Unlock()
+
+ known := map[string]store.SettingsFile{}
+ if verdicts, err := d.Store.SettingsFiles(game.ID); err == nil {
+ for _, v := range verdicts {
+ known[v.Path] = v
+ }
+ }
+ found := db.DetectSettings(game.Name, game.AppID, game.SavePath)
+ var ask []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; !ok {
+ ask = append(ask, f.Path)
+ }
+ }
+ for p, v := range known {
+ if v.Verdict == store.VerdictSettings || v.Verdict == store.VerdictSuggested {
+ ask = append(ask, p)
+ }
+ }
+ saves := d.Snapshots.ChangesLikeASave(game.ID, ask)
+
+ // What is found here is only suggested: it keeps syncing until someone
+ // confirms it in the game's configuration tab (store.VerdictSuggested).
+ // Only what was confirmed is left out, and given back if it changes like a
+ // save; a suggestion that does is withdrawn the same way.
+ excluded, suggested := false, false
+ var offered, given []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; ok {
+ continue
+ }
+ v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSuggested, Reason: f.Reason}
+ if why, isSave := saves[f.Path]; isSave {
+ v.Verdict, v.Reason = store.VerdictSave, why
+ } else {
+ offered = append(offered, f.Path)
+ }
+ if d.Store.SetSettingsFile(v) == nil {
+ suggested = true
+ }
+ }
+ for p, v := range known {
+ why, isSave := saves[p]
+ if !isSave || (v.Verdict != store.VerdictSettings && v.Verdict != store.VerdictSuggested) {
+ continue
+ }
+ was := v.Verdict
+ v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0
+ if d.Store.SetSettingsFile(v) != nil {
+ continue
+ }
+ if was == store.VerdictSettings {
+ excluded = true
+ given = append(given, p+" ("+why+")")
+ } else {
+ suggested = true
+ }
+ }
+ if suggested {
+ d.forgetVerdicts(game.ID)
+ if d.OnGameChanged != nil {
+ d.OnGameChanged(game.ID)
+ }
+ }
+ if excluded {
+ d.exclusionsChanged(game)
+ }
+ if len(offered) > 0 && !game.SyncDeviceSettings {
+ d.Log.Log("info", fmt.Sprintf("%q: %s %s like this device's settings rather than the save. %s still syncing; "+
+ "to keep %s on each device instead, confirm %s in the game's configuration tab.",
+ game.Name, strings.Join(offered, ", "), lookLike(len(offered)), itTheyAre(len(offered)), itThem(len(offered)), itThem(len(offered))))
+ }
+ if len(given) > 0 {
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// exclusionsChanged carries a game's recorded hashes over to its exclusion
+// list as it is now (adoptExclusionView), after detection changed it.
+func (d *Daemon) exclusionsChanged(game store.Game) {
+ d.forgetVerdicts(game.ID)
+ if fresh, err := d.Store.GetGame(game.ID); err == nil {
+ game = fresh
+ }
+ d.adoptExclusionView([]store.Game{game})
+}
+
+// observeSettings is told what a device holds of a game — another device's
+// manifest as it arrives, or this device's own at an automatic snapshot — and
+// keeps count, for every file left out as this game's settings, of how often
+// it changed when the rest of the save did. peerID is "" for this device.
+func (d *Daemon) observeSettings(game store.Game, peerID string, m delta.Manifest) {
+ if game.SyncDeviceSettings {
+ return
+ }
+ settings := d.deviceSettingsFor(game)
+ if len(settings) == 0 {
+ return
+ }
+ asSettings := ignore.Parse(strings.Join(settings, "\n"))
+ var watched []string
+ rest := delta.Manifest{Files: map[string]delta.FileEntry{}}
+ for p, f := range m.Files {
+ if asSettings.Match(p) {
+ watched = append(watched, p)
+ } else {
+ rest.Files[p] = f
+ }
+ }
+ if len(watched) == 0 {
+ return
+ }
+ user := ignore.Parse(game.SyncIgnore)
+ for p := range rest.Files {
+ if delta.NeverSynced(p) || (!user.Empty() && user.Match(p)) {
+ delete(rest.Files, p)
+ }
+ }
+ restHash := rest.ManifestHash()
+
+ // The rest of the save, as last seen from this device.
+ r := d.Store.GetSettingsObservation(game.ID, peerID, "")
+ saveMoved := r.LastHash != "" && r.LastHash != restHash
+ first := r.LastHash == ""
+ r.LastHash = restHash
+ _ = d.Store.SaveSettingsObservation(r)
+
+ var given []string
+ for _, p := range watched {
+ o := d.Store.GetSettingsObservation(game.ID, peerID, p)
+ h := m.Files[p].Hash
+ if saveMoved && o.LastHash != "" {
+ if o.LastHash != h {
+ o.Moved++
+ } else {
+ o.Still++
+ }
+ }
+ o.LastHash = h
+ _ = d.Store.SaveSettingsObservation(o)
+ if first || !saveMoved {
+ continue
+ }
+ moved, still := 0, 0
+ if all, err := d.Store.SettingsObservations(game.ID, p); err == nil {
+ for _, x := range all {
+ moved += x.Moved
+ still += x.Still
+ }
+ }
+ if moved >= observeMinMoved && moved > still {
+ why := fmt.Sprintf("changed in %d of the %d times the save changed", moved, moved+still)
+ if d.Store.SetSettingsFile(store.SettingsFile{GameID: game.ID, Path: p, Verdict: store.VerdictSave, Reason: why}) == nil {
+ given = append(given, p+" ("+why+")")
+ }
+ }
+ }
+ if len(given) > 0 {
+ d.exclusionsChanged(game)
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// observeLocalSettings is observeSettings for this device's own save, at an
+// automatic snapshot.
+func (d *Daemon) observeLocalSettings(gameID string) {
+ game, err := d.Store.GetGame(gameID)
+ if err != nil {
+ return
+ }
+ m, err := delta.BuildManifest(filepath.Clean(game.SavePath))
+ if err != nil {
+ return
+ }
+ d.observeSettings(game, "", m)
+}
+
+func itThey(n int) string {
+ if n == 1 {
+ return "it is"
+ }
+ return "they are"
+}
+
+func lookLike(n int) string {
+ if n == 1 {
+ return "looks"
+ }
+ return "look"
+}
+
+func itTheyAre(n int) string {
+ if n == 1 {
+ return "It is"
+ }
+ return "They are"
+}
+
+func itThem(n int) string {
+ if n == 1 {
+ return "it"
+ }
+ return "them"
+}
diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go
index a50b12e7..b581ec86 100644
--- a/internal/daemon/devicesettings.go
+++ b/internal/daemon/devicesettings.go
@@ -1,6 +1,8 @@
package daemon
import (
+ "errors"
+
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/presets"
"github.com/opensave/opensave/internal/store"
@@ -23,29 +25,93 @@ import (
// is what the next start reads.
type devSettingsCache struct {
- loaded bool
- db *presets.DeviceSettings
+ loaded bool
+ db *presets.DeviceSettings
+ // patterns: the game database's, by game name, App ID and folder.
patterns map[string][]string
+ // verdicts: what was found here (detectsettings.go), by game ID.
+ verdicts map[string][]store.SettingsFile
}
-func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+// devDB returns the game database for device settings, read when first
+// needed. Called with devMu held. Not waiting for an index due a rebuild
+// (DeviceSettingsNoWait): this runs while the daemon starts.
+func (d *Daemon) devDBLocked() *presets.DeviceSettings {
+ if !d.dev.loaded {
+ d.dev.db = d.Scanner.DeviceSettingsNoWait()
+ d.dev.loaded = true
+ }
+ if d.dev.patterns == nil {
+ d.dev.patterns = map[string][]string{}
+ }
+ if d.dev.verdicts == nil {
+ d.dev.verdicts = map[string][]store.SettingsFile{}
+ }
+ return d.dev.db
+}
+
+// DeviceSetting is one entry of a game's device settings, for the window.
+type DeviceSetting struct {
+ Pattern string `json:"pattern"`
+ // Source: "database" (the game database names it), "detected" (found by
+ // what it holds), or "save" (shown to change with the save, so it syncs
+ // whatever named it).
+ Source string `json:"source"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// deviceSettingsList is every entry, in the order they apply: the game
+// database's, what was detected here, then what was shown to be a save —
+// as "!" lines, which bring a file back whatever named it before.
+func (d *Daemon) deviceSettingsList(game store.Game) []DeviceSetting {
if d.Scanner == nil {
return nil
}
key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath
d.devMu.Lock()
defer d.devMu.Unlock()
- if !d.dev.loaded {
- d.dev.db = d.Scanner.DeviceSettingsNoWait()
- d.dev.patterns = map[string][]string{}
- d.dev.loaded = true
+ db := d.devDBLocked()
+ pats, ok := d.dev.patterns[key]
+ if !ok {
+ pats = db.Patterns(game.Name, game.AppID, []string{game.SavePath})
+ d.dev.patterns[key] = pats
+ }
+ verdicts, ok := d.dev.verdicts[game.ID]
+ if !ok {
+ verdicts, _ = d.Store.SettingsFiles(game.ID)
+ d.dev.verdicts[game.ID] = verdicts
}
- if p, ok := d.dev.patterns[key]; ok {
- return p
+ var out []DeviceSetting
+ for _, p := range pats {
+ out = append(out, DeviceSetting{Pattern: p, Source: "database"})
}
- p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath})
- d.dev.patterns[key] = p
- return p
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSettings {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "detected", Reason: v.Reason})
+ }
+ }
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSave {
+ out = append(out, DeviceSetting{Pattern: "!/" + v.Path, Source: "save", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+ list := d.deviceSettingsList(game)
+ out := make([]string, 0, len(list))
+ for _, s := range list {
+ out = append(out, s.Pattern)
+ }
+ return out
+}
+
+// forgetVerdicts drops a game's cached verdicts after they changed.
+func (d *Daemon) forgetVerdicts(gameID string) {
+ d.devMu.Lock()
+ delete(d.dev.verdicts, gameID)
+ d.devMu.Unlock()
}
// IgnoreText is the exclusion list a game is synced under (see
@@ -57,10 +123,10 @@ func (d *Daemon) IgnoreText(game store.Game) string {
return d.P2P.Sync.IgnoreText(game)
}
-// DeviceSettings returns the patterns for a game's device settings, whether
-// or not it is set to sync them — what the window lists.
-func (d *Daemon) DeviceSettings(game store.Game) []string {
- return d.deviceSettingsFor(game)
+// DeviceSettings returns a game's device settings, whether or not it is set
+// to sync them — what the window lists.
+func (d *Daemon) DeviceSettings(game store.Game) []DeviceSetting {
+ return d.deviceSettingsList(game)
}
// adoptExclusionView carries a game's last-snapshot hash over to what the game
@@ -97,3 +163,49 @@ func (d *Daemon) adoptExclusionView(games []store.Game) {
}
}
}
+
+// SuggestedSettings is what was found here by what it holds and still waits
+// for an answer in the game's configuration tab. Each keeps syncing until it
+// is confirmed (store.VerdictSuggested).
+func (d *Daemon) SuggestedSettings(game store.Game) []DeviceSetting {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return nil
+ }
+ var out []DeviceSetting
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSuggested {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "suggested", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+// ErrNotSuggested is an answer about a file that is not waiting for one.
+var ErrNotSuggested = errors.New("that file is not a suggested settings file")
+
+// DecideSuggestedSettings records the answer to a suggestion: keepHere leaves
+// the file out of syncing as this game's settings from now on, like the game
+// database's (and gives it back if it then changes like a save); otherwise it
+// keeps syncing, and is not suggested again.
+func (d *Daemon) DecideSuggestedSettings(game store.Game, path string, keepHere bool) error {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return err
+ }
+ for _, v := range verdicts {
+ if v.Path != path || v.Verdict != store.VerdictSuggested {
+ continue
+ }
+ v.Verdict, v.AtMs = store.VerdictDeclined, 0
+ if keepHere {
+ v.Verdict = store.VerdictSettings
+ }
+ if err := d.Store.SetSettingsFile(v); err != nil {
+ return err
+ }
+ d.exclusionsChanged(game)
+ return nil
+ }
+ return ErrNotSuggested
+}
diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go
index 2b94c1a4..b3ae2637 100644
--- a/internal/daemon/devicesettings_test.go
+++ b/internal/daemon/devicesettings_test.go
@@ -1,11 +1,13 @@
package daemon
import (
+ "fmt"
"os"
"path/filepath"
"testing"
"github.com/opensave/opensave/internal/delta"
+ "github.com/opensave/opensave/internal/ignore"
"github.com/opensave/opensave/internal/store"
"github.com/opensave/opensave/internal/watcher"
)
@@ -49,7 +51,7 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) {
delta.InvalidateRoot(changed.SavePath)
d.devMu.Lock()
- d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}}
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
for _, g := range []store.Game{same, changed} {
d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"}
}
@@ -82,3 +84,55 @@ func mustGame(t *testing.T, d *Daemon, id string) store.Game {
}
return g
}
+
+// A file taken for settings that another device's copy shows changing with
+// every change of the save is given back to syncing; one that stays put while
+// the save moves stays left out.
+func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ game := store.Game{ID: "g", Name: "g", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"progress.cfg", "video.cfg"} {
+ if err := d.Store.SetSettingsFile(store.SettingsFile{GameID: "g", Path: p, Verdict: store.VerdictSettings, Reason: "test"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+ if !excluded("progress.cfg") || !excluded("video.cfg") {
+ t.Fatal("setup: the detected files are not left out")
+ }
+
+ manifest := func(i int, video string) delta.Manifest {
+ return delta.Manifest{Files: map[string]delta.FileEntry{
+ "slot1.sav": {Hash: fmt.Sprint("save", i)},
+ "progress.cfg": {Hash: fmt.Sprint("progress", i)},
+ "video.cfg": {Hash: video},
+ }}
+ }
+ d.observeSettings(game, "peerA", manifest(0, "1080p"))
+ for i := 1; i <= 2; i++ {
+ d.observeSettings(game, "peerA", manifest(i, "1080p"))
+ }
+ if !excluded("progress.cfg") {
+ t.Fatal("given back after two changes: too eager")
+ }
+ d.observeSettings(game, "peerA", manifest(3, "1080p"))
+ if excluded("progress.cfg") {
+ t.Error("a file that changed with the save three times out of three is still left out")
+ }
+ if !excluded("video.cfg") {
+ t.Error("a settings file that stayed put was given back")
+ }
+ v, _ := d.Store.SettingsFiles("g")
+ for _, f := range v {
+ if f.Path == "progress.cfg" && f.Verdict != store.VerdictSave {
+ t.Errorf("verdict %q, want save", f.Verdict)
+ }
+ }
+}
diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go
index 302ef470..2abee312 100644
--- a/internal/daemon/gamedb.go
+++ b/internal/daemon/gamedb.go
@@ -1,6 +1,8 @@
package daemon
import (
+ "errors"
+ "fmt"
"os"
"path/filepath"
"strings"
@@ -88,3 +90,87 @@ 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 }
+
+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
+ }
+ if d.Store.Mark(gameDBMark(game.ID)) == game.SavePath {
+ return
+ }
+ _ = d.Store.SetMark(gameDBMark(game.ID), game.SavePath)
+ m, ok := d.gameDB().IdentifyFolderStrictly(game.SavePath)
+ if !ok || m.AppID == "" {
+ return
+ }
+ 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(s.AppID)
+ }
+ if err := d.Store.UpdateGame(game); err != nil {
+ return err
+ }
+ 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)
+ return nil
+}
diff --git a/internal/daemon/link_gamedb_test.go b/internal/daemon/link_gamedb_test.go
new file mode 100644
index 00000000..04fab1e9
--- /dev/null
+++ b/internal/daemon/link_gamedb_test.go
@@ -0,0 +1,101 @@
+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 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")
+ }
+ 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}
+ 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, 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")
+ }
+
+ // 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)
+ }
+ 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 err := d.AnswerGameDBLink(reload(declined.ID), true); err != ErrNoGameDBSuggestion {
+ t.Errorf("answering with nothing suggested: %v, want ErrNoGameDBSuggestion", err)
+ }
+
+ // 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)
+ }
+ d.suggestGameDatabaseLink(reload(game.ID))
+ if d.SuggestedGameDBLink(reload(game.ID)) != nil {
+ t.Error("an App ID taken away was suggested again")
+ }
+}
diff --git a/internal/daemon/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go
new file mode 100644
index 00000000..73bbc16a
--- /dev/null
+++ b/internal/daemon/suggested_settings_test.go
@@ -0,0 +1,79 @@
+package daemon
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/opensave/opensave/internal/ignore"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// A file found by what it holds is only suggested: it keeps syncing until
+// someone confirms it in the game's configuration tab, and a file someone
+// chose to keep syncing is not suggested again (asked in review on #118).
+func TestSuggestedSettings_SyncUntilConfirmed(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"
+ for name, content := range map[string]string{
+ "slot1.sav": "progress",
+ "videoconfig.txt": settings,
+ "display.cfg": settings,
+ } {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ }
+ game := store.Game{ID: "g", Name: "No Such Game In The Database", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+
+ d.DetectSettings(game)
+ suggested := map[string]bool{}
+ for _, s := range d.SuggestedSettings(game) {
+ suggested[s.Pattern] = true
+ }
+ if !suggested["/videoconfig.txt"] || !suggested["/display.cfg"] {
+ t.Fatalf("setup: the settings files were not suggested: %v", suggested)
+ }
+ if suggested["/slot1.sav"] {
+ t.Error("the save was suggested as settings")
+ }
+ if excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Fatal("a suggested file was left out of syncing before anyone confirmed it")
+ }
+
+ if err := d.DecideSuggestedSettings(game, "videoconfig.txt", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", false); err != nil {
+ t.Fatal(err)
+ }
+ if !excluded("videoconfig.txt") {
+ t.Error("a confirmed settings file still syncs")
+ }
+ if excluded("display.cfg") {
+ t.Error("a file chosen to keep syncing was left out")
+ }
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("answered files are still suggested: %v", d.SuggestedSettings(game))
+ }
+
+ // Detection runs again: neither answer is undone, nothing suggested anew.
+ d.DetectSettings(game)
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("a file already answered was suggested again: %v", d.SuggestedSettings(game))
+ }
+ if !excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Error("running detection again changed what was answered")
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", true); err != ErrNotSuggested {
+ t.Errorf("answering a file that is not suggested: %v, want ErrNotSuggested", err)
+ }
+}
diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go
index 5db85dbc..674a85e6 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/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")
+ }
+}
diff --git a/internal/snapshot/history.go b/internal/snapshot/history.go
new file mode 100644
index 00000000..916f0884
--- /dev/null
+++ b/internal/snapshot/history.go
@@ -0,0 +1,74 @@
+package snapshot
+
+import "fmt"
+
+// How often files changed along with a game's save, read from its
+// snapshots: a settings file is changed now and then, when someone changes a
+// setting; a save changes with nearly every session. A file taken for a
+// game's settings that changes like a save is not its settings
+// (daemon/detectsettings.go).
+//
+// Read from the recorded file lists only, and compared by the database
+// (store.SnapshotsDiffer): a save can hold a quarter of a million files, and
+// reading each snapshot's whole list — or, for one taken before lists were
+// recorded, its whole archive — to find whether the rest of it changed was
+// tens of millions of rows each time it was asked. A snapshot with no recorded
+// list is left out.
+
+const (
+ historySnapshots = 40 // the most recent ones are read
+ historyMinSaves = 4 // changes of the save needed before anything is said
+)
+
+// ChangesLikeASave returns, for each of paths (relative to the main save
+// folder) that changed in more than half of the snapshots in which the rest
+// of the save changed, why it is judged a save. Paths with too little history
+// to tell are not in it.
+func (m *Manager) ChangesLikeASave(gameID string, paths []string) map[string]string {
+ out := map[string]string{}
+ if len(paths) == 0 {
+ return out
+ }
+ snaps, err := m.Store.AllSnapshotsOfGame(gameID)
+ if err != nil {
+ return out
+ }
+ if len(snaps) > historySnapshots {
+ snaps = snaps[len(snaps)-historySnapshots:]
+ }
+
+ saves := 0
+ moved := map[string]int{}
+ prevID, prev := "", map[string]string(nil)
+ for _, s := range snaps {
+ if !m.Store.SnapshotHasFiles(s.ID) {
+ continue
+ }
+ hashes, err := m.Store.SnapshotPathHashes(s.ID, "", paths)
+ if err != nil {
+ continue
+ }
+ if prevID != "" {
+ // Only where the rest of the save changed is there anything to
+ // compare the watched files with.
+ if differ, err := m.Store.SnapshotsDiffer(prevID, s.ID, "", paths); err == nil && differ {
+ saves++
+ for _, p := range paths {
+ if prev[p] != hashes[p] {
+ moved[p]++
+ }
+ }
+ }
+ }
+ prevID, prev = s.ID, hashes
+ }
+ if saves < historyMinSaves {
+ return out
+ }
+ for p, n := range moved {
+ if 2*n > saves {
+ out[p] = fmt.Sprintf("changed in %d of the %d snapshots in which the save changed", n, saves)
+ }
+ }
+ return out
+}
diff --git a/internal/snapshot/history_test.go b/internal/snapshot/history_test.go
new file mode 100644
index 00000000..1c7ea812
--- /dev/null
+++ b/internal/snapshot/history_test.go
@@ -0,0 +1,52 @@
+package snapshot
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// A file that changes with every change of the save is a save; one changed
+// once among many is settings; too little history says nothing.
+func TestChangesLikeASave(t *testing.T) {
+ env := setup(t)
+ write := func(name, data string) {
+ if err := os.WriteFile(filepath.Join(env.saveDir, name), []byte(data), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ }
+ snap := func() {
+ if _, err := env.mgr.Create("game1", "", false); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("graphics.cfg", "resolution=1080")
+ write("progress.cfg", "level=0")
+ write("slot1.sav", "0")
+ snap()
+ ask := []string{"graphics.cfg", "progress.cfg"}
+ for i := 1; i <= 3; i++ {
+ write("slot1.sav", strings.Repeat("x", i))
+ write("progress.cfg", "level="+strings.Repeat("1", i))
+ snap()
+ }
+ if got := env.mgr.ChangesLikeASave("game1", ask); len(got) != 0 {
+ t.Fatalf("3 changes of the save already judged: %v", got)
+ }
+ for i := 4; i <= 6; i++ {
+ write("slot1.sav", strings.Repeat("x", i))
+ write("progress.cfg", "level="+strings.Repeat("1", i))
+ if i == 5 {
+ write("graphics.cfg", "resolution=2160")
+ }
+ snap()
+ }
+ got := env.mgr.ChangesLikeASave("game1", ask)
+ if _, ok := got["progress.cfg"]; !ok {
+ t.Errorf("a file that changed with every save is not judged a save: %v", got)
+ }
+ if _, ok := got["graphics.cfg"]; ok {
+ t.Errorf("a file changed once in six saves is judged a save: %v", got)
+ }
+}
diff --git a/internal/store/marks.go b/internal/store/marks.go
new file mode 100644
index 00000000..13b7376b
--- /dev/null
+++ b/internal/store/marks.go
@@ -0,0 +1,22 @@
+package store
+
+import "fmt"
+
+// Mark returns the value a one-time step was taken for, empty if never.
+// See migrations/0042_settings_files.sql.
+func (s *Store) Mark(name string) string {
+ var v string
+ if err := s.db.Get(&v, `SELECT value FROM marks WHERE name = ?`, name); err != nil {
+ return ""
+ }
+ return v
+}
+
+// SetMark records that a one-time step was taken for value.
+func (s *Store) SetMark(name, value string) error {
+ if _, err := s.db.Exec(`INSERT INTO marks (name, value) VALUES (?, ?)
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value`, name, value); err != nil {
+ return fmt.Errorf("set mark %s: %w", name, err)
+ }
+ return nil
+}
diff --git a/internal/store/migrations/0042_settings_files.sql b/internal/store/migrations/0042_settings_files.sql
new file mode 100644
index 00000000..08a22e4f
--- /dev/null
+++ b/internal/store/migrations/0042_settings_files.sql
@@ -0,0 +1,45 @@
+-- IF NOT EXISTS throughout: builds of an earlier draft of this change made
+-- these same tables under other numbers (settings_files and
+-- settings_observations in 0043, marks in 0041), and a database from one of
+-- them must still start.
+
+-- Files in a game's main save folder judged to be its settings rather than its
+-- save, by what they hold (presets/detectsettings.go), and files shown to be a
+-- save after all — by changing with the save, in this device's snapshots or in
+-- what another device sent (daemon/detectsettings.go).
+--
+-- verdict 'settings' leaves the file out of syncing, as the game database's
+-- settings files are. verdict 'save' keeps it syncing for good: detection never
+-- takes it again, and a game database entry that named it is overruled.
+-- path is relative to the main save folder, slash-separated.
+CREATE TABLE IF NOT EXISTS settings_files (
+ game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
+ path TEXT NOT NULL,
+ verdict TEXT NOT NULL,
+ reason TEXT NOT NULL DEFAULT '',
+ at_ms INTEGER NOT NULL,
+ PRIMARY KEY (game_id, path)
+);
+
+-- What another device's copy of an excluded settings file did, sync by sync:
+-- moved counts syncs in which the save changed and so did that copy, still
+-- those in which the save changed and it did not. A settings file is changed
+-- now and then; a save that was taken for one changes with every session.
+CREATE TABLE IF NOT EXISTS settings_observations (
+ game_id TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
+ peer_id TEXT NOT NULL,
+ path TEXT NOT NULL,
+ last_hash TEXT NOT NULL DEFAULT '',
+ moved INTEGER NOT NULL DEFAULT 0,
+ still INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (game_id, peer_id, path)
+);
+
+-- One-time steps this device has already taken, by name, with the value they
+-- were taken for; a step whose value changes in a later build runs again.
+-- Here: when each game's files were last judged, by which detector, for
+-- which save folder (daemon/detectsettings.go).
+CREATE TABLE IF NOT EXISTS marks (
+ name TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
diff --git a/internal/store/settings_files.go b/internal/store/settings_files.go
new file mode 100644
index 00000000..2c485516
--- /dev/null
+++ b/internal/store/settings_files.go
@@ -0,0 +1,90 @@
+package store
+
+import (
+ "fmt"
+ "time"
+)
+
+// Verdicts on a file in a game's save folder (migrations/0042_settings_files.sql).
+//
+// A file found by what it holds is only suggested: it keeps syncing until
+// someone confirms it in the game's configuration tab (settings) or keeps it
+// syncing (declined, never suggested again). A wrong guess would otherwise
+// stop part of a save syncing without anyone knowing.
+const (
+ VerdictSuggested = "suggested"
+ VerdictSettings = "settings"
+ VerdictDeclined = "declined"
+ VerdictSave = "save"
+)
+
+// SettingsFile is a verdict on one file of a game's main save folder.
+type SettingsFile struct {
+ GameID string `db:"game_id" json:"-"`
+ Path string `db:"path" json:"path"`
+ Verdict string `db:"verdict" json:"verdict"`
+ Reason string `db:"reason" json:"reason"`
+ AtMs int64 `db:"at_ms" json:"atMs"`
+}
+
+// SettingsFiles returns every verdict recorded for a game.
+func (s *Store) SettingsFiles(gameID string) ([]SettingsFile, error) {
+ var out []SettingsFile
+ if err := s.db.Select(&out, `SELECT * FROM settings_files WHERE game_id = ? ORDER BY path`, gameID); err != nil {
+ return nil, fmt.Errorf("list settings files of %s: %w", gameID, err)
+ }
+ return out, nil
+}
+
+// SetSettingsFile records a verdict, replacing any earlier one.
+func (s *Store) SetSettingsFile(f SettingsFile) error {
+ if f.AtMs == 0 {
+ f.AtMs = time.Now().UnixMilli()
+ }
+ if _, err := s.db.NamedExec(`INSERT INTO settings_files (game_id, path, verdict, reason, at_ms)
+ VALUES (:game_id, :path, :verdict, :reason, :at_ms)
+ ON CONFLICT(game_id, path) DO UPDATE SET verdict = excluded.verdict, reason = excluded.reason, at_ms = excluded.at_ms`, f); err != nil {
+ return fmt.Errorf("set settings file %s/%s: %w", f.GameID, f.Path, err)
+ }
+ return nil
+}
+
+// SettingsObservation is what one device's copy of an excluded settings file
+// has done so far.
+type SettingsObservation struct {
+ GameID string `db:"game_id"`
+ PeerID string `db:"peer_id"`
+ Path string `db:"path"`
+ LastHash string `db:"last_hash"`
+ Moved int `db:"moved"`
+ Still int `db:"still"`
+}
+
+// SettingsObservations returns what every device's copy of path has done.
+func (s *Store) SettingsObservations(gameID, path string) ([]SettingsObservation, error) {
+ var out []SettingsObservation
+ if err := s.db.Select(&out, `SELECT * FROM settings_observations WHERE game_id = ? AND path = ?`, gameID, path); err != nil {
+ return nil, fmt.Errorf("list settings observations: %w", err)
+ }
+ return out, nil
+}
+
+// GetSettingsObservation returns one device's record ({} when none).
+func (s *Store) GetSettingsObservation(gameID, peerID, path string) SettingsObservation {
+ var o SettingsObservation
+ if err := s.db.Get(&o, `SELECT * FROM settings_observations WHERE game_id = ? AND peer_id = ? AND path = ?`, gameID, peerID, path); err != nil {
+ return SettingsObservation{GameID: gameID, PeerID: peerID, Path: path}
+ }
+ return o
+}
+
+// SaveSettingsObservation writes one device's record.
+func (s *Store) SaveSettingsObservation(o SettingsObservation) error {
+ if _, err := s.db.NamedExec(`INSERT INTO settings_observations (game_id, peer_id, path, last_hash, moved, still)
+ VALUES (:game_id, :peer_id, :path, :last_hash, :moved, :still)
+ ON CONFLICT(game_id, peer_id, path) DO UPDATE SET last_hash = excluded.last_hash,
+ moved = excluded.moved, still = excluded.still`, o); err != nil {
+ return fmt.Errorf("save settings observation: %w", err)
+ }
+ return nil
+}
diff --git a/internal/store/settings_files_migration_test.go b/internal/store/settings_files_migration_test.go
new file mode 100644
index 00000000..e96747f0
--- /dev/null
+++ b/internal/store/settings_files_migration_test.go
@@ -0,0 +1,36 @@
+package store
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+// A database from a build of the earlier draft of this change already holds
+// settings_files, settings_observations and marks, made under other migration
+// numbers, and has never run 0042. It must still open: a plain CREATE TABLE
+// stopped the daemon with "table already exists", as sync_device_settings
+// once did (asked in review on #118).
+func TestSettingsFilesMigrationOpensOverTheDraftsTables(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "opensave.db")
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // As the draft's build left it: the tables there, 0042 not recorded.
+ if _, err := s.db.Exec(`DELETE FROM schema_migrations WHERE name = '0042_settings_files.sql'`); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetMark("kept", "from the draft's build"); err != nil {
+ t.Fatal(err)
+ }
+ s.Close()
+
+ s, err = Open(path)
+ if err != nil {
+ t.Fatalf("a database holding the draft's tables did not open: %v", err)
+ }
+ defer s.Close()
+ if got := s.Mark("kept"); got != "from the draft's build" {
+ t.Errorf("what the draft's build recorded is gone: %q", got)
+ }
+}
diff --git a/internal/store/snapshot_files.go b/internal/store/snapshot_files.go
index 8e349878..38c834ef 100644
--- a/internal/store/snapshot_files.go
+++ b/internal/store/snapshot_files.go
@@ -1,6 +1,10 @@
package store
-import "fmt"
+import (
+ "fmt"
+
+ "github.com/jmoiron/sqlx"
+)
// CapturedFile is one file inside a snapshot: which save location it belongs
// to, its path within that location, and the hash of its contents.
@@ -132,3 +136,69 @@ func (s *Store) SnapshotRootFiles(snapshotID, root string) (map[string]string, e
}
return files, nil
}
+
+// AllSnapshotsOfGame lists every snapshot of a game, on every branch, oldest
+// first.
+func (s *Store) AllSnapshotsOfGame(gameID string) ([]Snapshot, error) {
+ var out []Snapshot
+ if err := s.db.Select(&out, `SELECT * FROM snapshots WHERE game_id = ? ORDER BY timestamp`, gameID); err != nil {
+ return nil, fmt.Errorf("list snapshots of %s: %w", gameID, err)
+ }
+ return out, nil
+}
+
+// SnapshotHasFiles reports whether a snapshot's file list is recorded:
+// snapshots taken before these records existed have none.
+func (s *Store) SnapshotHasFiles(snapshotID string) bool {
+ var n int
+ err := s.db.Get(&n, `SELECT EXISTS (SELECT 1 FROM snapshot_files WHERE snapshot_id = ?)`, snapshotID)
+ return err == nil && n == 1
+}
+
+// SnapshotPathHashes returns the content hashes a snapshot recorded for the
+// given paths of one save location; a path it did not hold is absent.
+func (s *Store) SnapshotPathHashes(snapshotID, root string, paths []string) (map[string]string, error) {
+ out := map[string]string{}
+ if len(paths) == 0 {
+ return out, nil
+ }
+ query, args, err := sqlx.In(`SELECT path, hash FROM snapshot_files WHERE snapshot_id = ? AND root = ? AND path IN (?)`,
+ snapshotID, root, paths)
+ if err != nil {
+ return nil, err
+ }
+ var rows []CapturedFile
+ if err := s.db.Select(&rows, s.db.Rebind(query), args...); err != nil {
+ return nil, fmt.Errorf("read snapshot files of %s: %w", snapshotID, err)
+ }
+ for _, r := range rows {
+ out[r.Path] = r.Hash
+ }
+ return out, nil
+}
+
+// SnapshotsDiffer reports whether two snapshots differ in one save location,
+// leaving out the paths in except: a file one holds that the other does not,
+// at the same content. Answered by the database, which stops at the first
+// difference — a save of a quarter-million files is never read into memory
+// to compare two snapshots of it.
+func (s *Store) SnapshotsDiffer(a, b, root string, except []string) (bool, error) {
+ except = append(append([]string{}, except...), "") // never empty for IN
+ query, args, err := sqlx.In(`SELECT EXISTS (
+ SELECT 1 FROM snapshot_files x WHERE x.snapshot_id = ? AND x.root = ? AND x.path NOT IN (?)
+ AND NOT EXISTS (SELECT 1 FROM snapshot_files y
+ WHERE y.snapshot_id = ? AND y.root = x.root AND y.path = x.path AND y.hash = x.hash)
+ ) OR EXISTS (
+ SELECT 1 FROM snapshot_files x WHERE x.snapshot_id = ? AND x.root = ? AND x.path NOT IN (?)
+ AND NOT EXISTS (SELECT 1 FROM snapshot_files y
+ WHERE y.snapshot_id = ? AND y.root = x.root AND y.path = x.path AND y.hash = x.hash)
+ )`, a, root, except, b, b, root, except, a)
+ if err != nil {
+ return false, err
+ }
+ var differ bool
+ if err := s.db.Get(&differ, s.db.Rebind(query), args...); err != nil {
+ return false, fmt.Errorf("compare snapshots %s and %s: %w", a, b, err)
+ }
+ return differ, nil
+}
diff --git a/internal/store/snapshots_differ_test.go b/internal/store/snapshots_differ_test.go
new file mode 100644
index 00000000..5a799645
--- /dev/null
+++ b/internal/store/snapshots_differ_test.go
@@ -0,0 +1,49 @@
+package store
+
+import "testing"
+
+// Two snapshots compared by the database: identical, one file changed, one
+// changed only in a path left out, and one file held by one side alone.
+func TestSnapshotsDiffer(t *testing.T) {
+ s := openTestStore(t)
+ if err := s.CreateGame(Game{ID: "g", Name: "g", SavePath: t.TempDir(), ActiveBranch: "main"}); err != nil {
+ t.Fatal(err)
+ }
+ record := func(id string, files map[string]string) {
+ t.Helper()
+ if err := s.CreateSnapshot(Snapshot{ID: id, GameID: "g", BranchName: "main", Timestamp: "2026-10-26T00:00:00.000Z", ZipPath: id + ".zip"}); err != nil {
+ t.Fatal(err)
+ }
+ var cf []CapturedFile
+ for p, h := range files {
+ cf = append(cf, CapturedFile{Path: p, Hash: h})
+ }
+ if err := s.RecordSnapshotFiles(id, cf); err != nil {
+ t.Fatal(err)
+ }
+ }
+ record("a", map[string]string{"slot1.sav": "1", "video.cfg": "x"})
+ record("same", map[string]string{"slot1.sav": "1", "video.cfg": "x"})
+ record("saved", map[string]string{"slot1.sav": "2", "video.cfg": "x"})
+ record("cfgOnly", map[string]string{"slot1.sav": "1", "video.cfg": "y"})
+ record("more", map[string]string{"slot1.sav": "1", "video.cfg": "x", "slot2.sav": "9"})
+
+ for _, c := range []struct {
+ b string
+ want bool
+ }{{"same", false}, {"saved", true}, {"cfgOnly", false}, {"more", true}} {
+ got, err := s.SnapshotsDiffer("a", c.b, "", []string{"video.cfg"})
+ if err != nil || got != c.want {
+ t.Errorf("a vs %s, video.cfg left out: %v %v, want %v", c.b, got, err, c.want)
+ }
+ }
+ if got, _ := s.SnapshotsDiffer("a", "cfgOnly", "", nil); !got {
+ t.Error("with nothing left out, a changed file was not a difference")
+ }
+ if h, _ := s.SnapshotPathHashes("cfgOnly", "", []string{"video.cfg", "missing.cfg"}); h["video.cfg"] != "y" || len(h) != 1 {
+ t.Errorf("path hashes = %v", h)
+ }
+ if !s.SnapshotHasFiles("a") || s.SnapshotHasFiles("no-such-snapshot") {
+ t.Error("SnapshotHasFiles")
+ }
+}