diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 9b7d6d99..78e99c90 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -388,6 +388,16 @@ "game.config.deviceSettings": "Device settings", "game.config.syncDeviceSettings": "Sync these settings between devices too", "game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot.", + "game.config.deviceSettingsDetectedHint": "Settings files you confirm from the suggestions are kept on each device too. A file that turns out to change along with the save syncs again by itself; to sync one yourself, untick it under “Pick from your save folder” below.", + "game.config.sourceDatabase": "game database", + "game.config.sourceDetected": "kept here by you — {reason}", + "game.config.sourceSave": "syncs again — {reason}", + "game.config.suggestedSettings": "Suggested settings files", + "game.config.suggestedHint": "These look like this device’s settings rather than the save, by what they hold. They keep syncing until you decide: keep one on this device, and each device keeps its own.", + "game.config.keepHere": "Keep on this device", + "game.config.keepSyncing": "Keep syncing", + "game.config.keptHere": "{file} is kept on this device now", + "game.config.keptSyncing": "{file} keeps syncing", "notifications.clearAll": "Clear all", "notifications.clearAllTitle": "Take these out of the bell. Activity still has them.", "palette.renameGame": "Rename {game}" diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index 7d92a282..d89442e8 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -340,6 +340,16 @@ "game.config.deviceSettings": "设备设置", "game.config.syncDeviceSettings": "也在设备之间同步这些设置", "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。", + "game.config.deviceSettingsDetectedHint": "你从建议中确认的设置文件也会在每台设备上各自保留。若某个文件被发现会随存档一起变化,它会自动恢复同步;如需手动同步某个文件,请在下方“Pick from your save folder”中取消勾选。", + "game.config.sourceDatabase": "游戏数据库", + "game.config.sourceDetected": "由你保留在本机 — {reason}", + "game.config.sourceSave": "恢复同步 — {reason}", + "game.config.suggestedSettings": "建议的设置文件", + "game.config.suggestedHint": "根据内容判断,这些文件看起来是本设备的设置而非存档。在你决定之前它们会继续同步:选择保留在本设备后,每台设备将各自保留自己的版本。", + "game.config.keepHere": "保留在本设备", + "game.config.keepSyncing": "继续同步", + "game.config.keptHere": "{file} 现在保留在本设备", + "game.config.keptSyncing": "{file} 将继续同步", "notifications.clearAll": "清除全部", "notifications.clearAllTitle": "将这些从铃铛移除,活动页仍保留", "palette.renameGame": "重命名 {game}" diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 040b7edb..34cdca92 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -340,6 +340,16 @@ "game.config.deviceSettings": "裝置設定", "game.config.syncDeviceSettings": "也在裝置之間同步這些設定", "game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。", + "game.config.deviceSettingsDetectedHint": "你從建議中確認的設定檔案也會在每臺裝置上各自保留。若某個檔案被發現會隨存檔一起變化,它會自動恢復同步;如需手動同步某個檔案,請在下方「Pick from your save folder」中取消勾選。", + "game.config.sourceDatabase": "遊戲資料庫", + "game.config.sourceDetected": "由你保留在本機 — {reason}", + "game.config.sourceSave": "恢復同步 — {reason}", + "game.config.suggestedSettings": "建議的設定檔案", + "game.config.suggestedHint": "根據內容判斷,這些檔案看起來是本裝置的設定而非存檔。在你決定之前它們會繼續同步:選擇保留在本裝置後,每臺裝置將各自保留自己的版本。", + "game.config.keepHere": "保留在本裝置", + "game.config.keepSyncing": "繼續同步", + "game.config.keptHere": "{file} 現在保留在本裝置", + "game.config.keptSyncing": "{file} 將繼續同步", "notifications.clearAll": "清除全部", "notifications.clearAllTitle": "將這些從鈴鐺移除,活動頁仍保留", "palette.renameGame": "重新命名 {game}" diff --git a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte index 8d2183a5..71770080 100644 --- a/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte +++ b/cmd/opensave-app/frontend/src/views/game/ConfigTab.svelte @@ -32,8 +32,17 @@ // The files the game database names as this game's settings rather than its // save (graphics, resolution): left out of syncing unless asked for. $: deviceSettings = game.deviceSettings ?? []; + // Files found by what they hold: suggested, and still syncing, until + // answered here. A wrong guess must not quietly stop part of a save syncing. + $: suggested = game.suggestedSettings ?? []; let locations = []; + function decide(s, keepHere) { + const path = s.pattern.replace(/^\//, ''); + const done = $t(keepHere ? 'game.config.keptHere' : 'game.config.keptSyncing', { file: path }); + return run(done, () => api.post(`/api/games/${game.id}/suggested-settings`, { path, keepHere })); + } + // Cover preview: a custom URL wins, else the proxied Steam art for the App // ID being edited. $: cover = @@ -166,12 +175,39 @@ can't push them out. 0 = keep forever (the default). + {#if suggested.length > 0} +
{s.pattern.replace(/^\//, '')}
+ {s.reason}
+
+
+
+
+ {p.replace(/^\//, '')}{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}
+
+ ! exception
rather than deleting the wildcard.
+ Files marked settings are left out as this game's device settings; unticking
+ one syncs it after all.
{#if severalFolders}
A pattern applies to every one of this game's folders, so a
name that appears in two of them is excluded in both.
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 903646e5..7b23135a 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -48,6 +48,7 @@ func (s *Server) routes(r chi.Router) {
r.Post("/api/games/{gameId}/link", s.handleLinkGame)
r.Get("/api/games/{gameId}/roots", s.handleListGameRoots)
r.Post("/api/games/{gameId}/roots", s.handleAddGameRoot)
+ r.Post("/api/games/{gameId}/suggested-settings", s.handleDecideSuggestedSettings)
r.Delete("/api/games/{gameId}/roots/{root}", s.handleRemoveGameRoot)
r.Delete("/api/games/{gameId}/alias/{aliasId}", s.handleUnlinkGame)
diff --git a/internal/api/routes_peers.go b/internal/api/routes_peers.go
index 4eb9b594..256cd05b 100644
--- a/internal/api/routes_peers.go
+++ b/internal/api/routes_peers.go
@@ -243,13 +243,15 @@ func (s *Server) handleProbePeer(w http.ResponseWriter, r *http.Request) {
}
// syncReason names why a sync did not happen, for a client to act on rather
-// than parse the message: "paused" (this device paused syncing), "held" (the
+// than parse the message: "paused" (this device paused syncing), "playing" (the game is being played here; it syncs when the session ends), "held" (the
// game's save was emptied here and waits for an answer), "offline" (no other
// device answered), or "error" for anything else.
func syncReason(err error) string {
switch {
case errors.Is(err, syncengine.ErrPaused):
return "paused"
+ case errors.Is(err, syncengine.ErrPlayingHere):
+ return "playing"
case errors.Is(err, syncengine.ErrHeld):
return "held"
case errors.Is(err, p2p.ErrNoPeersOnline):
diff --git a/internal/api/routes_savefiles.go b/internal/api/routes_savefiles.go
index 3f7248ad..bfdba491 100644
--- a/internal/api/routes_savefiles.go
+++ b/internal/api/routes_savefiles.go
@@ -1,6 +1,7 @@
package api
import (
+ "errors"
"net/http"
"os"
"path/filepath"
@@ -8,6 +9,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
+ "github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/ignore"
)
@@ -38,6 +40,11 @@ type saveFile struct {
MtimeMs int64 `json:"mtimeMs"`
// Excluded reports whether the current rules stop this file syncing.
Excluded bool `json:"excluded"`
+ // Why: "rule" (the game's own exclusion list), "database" (the game
+ // database names it as the game's settings) or "detected" (found to be
+ // its settings by what it holds). Empty when it syncs.
+ Source string `json:"source,omitempty"`
+ Reason string `json:"reason,omitempty"`
}
// saveFileListCap bounds the response. A save folder with more files than
@@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
// With the game's device settings, which are left out the same way.
text := s.Daemon.IgnoreText(game)
rules := ignore.Parse(text)
+ ownRules := ignore.Parse(game.SyncIgnore)
+ var settings []daemon.DeviceSetting
+ if !game.SyncDeviceSettings {
+ settings = s.Daemon.DeviceSettings(game)
+ }
+ // which says what excludes a file the rules exclude: the game's own
+ // rules first, as they apply last; then the last device-settings entry
+ // that names it.
+ which := func(path string) (string, string) {
+ if !ownRules.Empty() && ownRules.Match(path) {
+ return "rule", ""
+ }
+ for i := len(settings) - 1; i >= 0; i-- {
+ if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) {
+ return settings[i].Source, settings[i].Reason
+ }
+ }
+ return "rule", ""
+ }
roots := []struct{ name, path string }{{"", game.SavePath}}
if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil {
@@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
for _, f := range files {
f.Location = root.name
f.Excluded = rules.Match(f.Path)
+ if f.Excluded && root.name == "" {
+ f.Source, f.Reason = which(f.Path)
+ } else if f.Excluded {
+ f.Source = "rule"
+ }
out = append(out, f)
}
}
@@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) {
}
return out, truncated
}
+
+// handleDecideSuggestedSettings answers a suggested settings file from the
+// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of
+// syncing as this device's settings, false keeps it syncing for good.
+func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) {
+ gameID := chi.URLParam(r, "gameId")
+ game, err := s.Daemon.Store.GetGame(gameID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "Game not found.")
+ return
+ }
+ var body struct {
+ Path string `json:"path"`
+ KeepHere bool `json:"keepHere"`
+ }
+ if err := readJSON(r, &body); err != nil || body.Path == "" {
+ writeError(w, http.StatusBadRequest, "path is required")
+ return
+ }
+ if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil {
+ if errors.Is(err, daemon.ErrNotSuggested) {
+ writeError(w, http.StatusConflict, err.Error())
+ return
+ }
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ s.BroadcastGamesUpdate()
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d40a280..30de9e21 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -566,7 +566,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any {
"syncIgnore": g.SyncIgnore,
// The files the game database names as this game's settings, left
// out of syncing unless syncDeviceSettings (daemon/devicesettings.go).
- "deviceSettings": s.Daemon.DeviceSettings(g),
+ "deviceSettings": s.Daemon.DeviceSettings(g),
+ // Found by what they hold, and waiting to be confirmed; they keep
+ // syncing meanwhile (daemon.SuggestedSettings).
+ "suggestedSettings": s.Daemon.SuggestedSettings(g),
"syncDeviceSettings": g.SyncDeviceSettings,
"branches": branches,
"createdAt": g.CreatedAt,
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 8abd9a26..0ba0729f 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -38,6 +38,11 @@ type Options struct {
// DisableDiscovery skips UDP LAN discovery (tests pair peers manually
// and don't want broadcast traffic).
DisableDiscovery bool
+ // SyncEveryChange keeps and syncs every change made here as it comes,
+ // never holding a stretch of changes back as play when no session is seen
+ // (sessions.go, noteActivity). For tests of syncing itself, which change a
+ // save several times a minute and expect each change to travel.
+ SyncEveryChange bool
}
// Daemon is the assembled core. Access subsystems directly for operations
@@ -186,6 +191,10 @@ func New(opts Options) (*Daemon, error) {
// A game's device settings are left out of syncing (devicesettings.go).
d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor
+ // A game being played here is synced when its session ends
+ // (syncengine/playing.go).
+ d.P2P.Sync.Playing = d.playingHere
+ d.P2P.Sync.ObserveSettings = d.observeSettings
// A restore or a branch switch rewrites a save folder; nothing syncing it
// may read it half-way (syncengine/settle.go).
@@ -273,7 +282,11 @@ func New(opts Options) (*Daemon, error) {
SyncWritingNow: func(gameID string) bool {
return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID)
},
+ HoldSnapshot: d.holdSnapshotWhilePlaying,
OnChanged: func(gameID string) {
+ // How the files left out as its settings changed with it
+ // (detectsettings.go).
+ d.observeLocalSettings(gameID)
// An emptied save is noticed as it happens, and said on screen,
// even with no other device online to hold it back from.
_, _ = d.P2P.Sync.CheckHold(gameID, false)
@@ -459,6 +472,29 @@ func (d *Daemon) Start() error {
}
})
+ // Find each game's settings by what they hold (detectsettings.go): a few
+ // minutes after start, then hourly for whatever is due.
+ d.P2P.GoSync(func(ctx context.Context) {
+ first := time.NewTimer(newGameFirstScan)
+ defer first.Stop()
+ select {
+ case <-ctx.Done():
+ return
+ case <-first.C:
+ d.detectSettingsDue(ctx)
+ }
+ ticker := time.NewTicker(time.Hour)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ d.detectSettingsDue(ctx)
+ }
+ }
+ })
+
// Look for newly installed games: a few minutes after start, then every
// hour. See newgames.go.
d.P2P.GoSync(func(ctx context.Context) {
diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go
new file mode 100644
index 00000000..d67b4045
--- /dev/null
+++ b/internal/daemon/detectsettings.go
@@ -0,0 +1,280 @@
+package daemon
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/opensave/opensave/internal/delta"
+ "github.com/opensave/opensave/internal/ignore"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// Finding a game's settings by what they hold, and giving back what turns out
+// to be a save.
+//
+// Detection (presets/detectsettings.go) takes a file for settings by its
+// contents. Before anything is left out, and again at every later run, the
+// game's snapshots are asked how the file behaved (snapshot/history.go). And
+// as long as a file is left out, how it behaves is watched: in this device's
+// automatic snapshots, and in what every other device sends (observeSettings).
+// A settings file is changed now and then; a save changes with the sessions
+// played. One that changes along with the rest of the save in most of them is
+// a save after all: it syncs again, for good, whatever named it — this
+// detection or the game database — and the log says which and why.
+//
+// Errors cost little either way. A settings file taken for a save syncs, as
+// every file did before. A save taken for settings is still in every
+// snapshot, and comes back to syncing at the latest when it has been seen to
+// change with the save a few times.
+
+// detectorVersion moves when detection changes, so every game is judged again.
+const detectorVersion = "1"
+
+const (
+ observeMinMoved = 3 // changes along with the save before a file is given back
+ detectEvery = 24 * time.Hour
+)
+
+func settingsDetectMark(gameID string) string { return "settings_detect:" + gameID }
+
+// detectSettingsDue runs detection for every game not judged under this
+// detector and folder yet, or not for a day. In the background, from Start.
+func (d *Daemon) detectSettingsDue(ctx context.Context) {
+ games, err := d.Store.ListGames()
+ if err != nil {
+ return
+ }
+ for _, game := range games {
+ if ctx.Err() != nil {
+ return
+ }
+ mark := d.Store.Mark(settingsDetectMark(game.ID))
+ want := detectorVersion + "|" + game.SavePath
+ if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want {
+ if t, err := time.Parse(time.RFC3339, at); err == nil && time.Since(t) < detectEvery {
+ continue
+ }
+ }
+ d.DetectSettings(game)
+ _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339))
+ }
+}
+
+// DetectSettings judges a game's files, records what is new, and re-asks the
+// snapshots about everything taken for settings so far.
+func (d *Daemon) DetectSettings(game store.Game) {
+ if d.Scanner == nil || game.SavePath == "" || SaveFolderMissing(game.SavePath) {
+ return
+ }
+ d.devMu.Lock()
+ db := d.devDBLocked()
+ d.devMu.Unlock()
+
+ known := map[string]store.SettingsFile{}
+ if verdicts, err := d.Store.SettingsFiles(game.ID); err == nil {
+ for _, v := range verdicts {
+ known[v.Path] = v
+ }
+ }
+ found := db.DetectSettings(game.Name, game.AppID, game.SavePath)
+ var ask []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; !ok {
+ ask = append(ask, f.Path)
+ }
+ }
+ for p, v := range known {
+ if v.Verdict == store.VerdictSettings || v.Verdict == store.VerdictSuggested {
+ ask = append(ask, p)
+ }
+ }
+ saves := d.Snapshots.ChangesLikeASave(game.ID, ask)
+
+ // What is found here is only suggested: it keeps syncing until someone
+ // confirms it in the game's configuration tab (store.VerdictSuggested).
+ // Only what was confirmed is left out, and given back if it changes like a
+ // save; a suggestion that does is withdrawn the same way.
+ excluded, suggested := false, false
+ var offered, given []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; ok {
+ continue
+ }
+ v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSuggested, Reason: f.Reason}
+ if why, isSave := saves[f.Path]; isSave {
+ v.Verdict, v.Reason = store.VerdictSave, why
+ } else {
+ offered = append(offered, f.Path)
+ }
+ if d.Store.SetSettingsFile(v) == nil {
+ suggested = true
+ }
+ }
+ for p, v := range known {
+ why, isSave := saves[p]
+ if !isSave || (v.Verdict != store.VerdictSettings && v.Verdict != store.VerdictSuggested) {
+ continue
+ }
+ was := v.Verdict
+ v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0
+ if d.Store.SetSettingsFile(v) != nil {
+ continue
+ }
+ if was == store.VerdictSettings {
+ excluded = true
+ given = append(given, p+" ("+why+")")
+ } else {
+ suggested = true
+ }
+ }
+ if suggested {
+ d.forgetVerdicts(game.ID)
+ if d.OnGameChanged != nil {
+ d.OnGameChanged(game.ID)
+ }
+ }
+ if excluded {
+ d.exclusionsChanged(game)
+ }
+ if len(offered) > 0 && !game.SyncDeviceSettings {
+ d.Log.Log("info", fmt.Sprintf("%q: %s %s like this device's settings rather than the save. %s still syncing; "+
+ "to keep %s on each device instead, confirm %s in the game's configuration tab.",
+ game.Name, strings.Join(offered, ", "), lookLike(len(offered)), itTheyAre(len(offered)), itThem(len(offered)), itThem(len(offered))))
+ }
+ if len(given) > 0 {
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// exclusionsChanged carries a game's recorded hashes over to its exclusion
+// list as it is now (adoptExclusionView), after detection changed it.
+func (d *Daemon) exclusionsChanged(game store.Game) {
+ d.forgetVerdicts(game.ID)
+ if fresh, err := d.Store.GetGame(game.ID); err == nil {
+ game = fresh
+ }
+ d.adoptExclusionView([]store.Game{game})
+}
+
+// observeSettings is told what a device holds of a game — another device's
+// manifest as it arrives, or this device's own at an automatic snapshot — and
+// keeps count, for every file left out as this game's settings, of how often
+// it changed when the rest of the save did. peerID is "" for this device.
+func (d *Daemon) observeSettings(game store.Game, peerID string, m delta.Manifest) {
+ if game.SyncDeviceSettings {
+ return
+ }
+ settings := d.deviceSettingsFor(game)
+ if len(settings) == 0 {
+ return
+ }
+ asSettings := ignore.Parse(strings.Join(settings, "\n"))
+ var watched []string
+ rest := delta.Manifest{Files: map[string]delta.FileEntry{}}
+ for p, f := range m.Files {
+ if asSettings.Match(p) {
+ watched = append(watched, p)
+ } else {
+ rest.Files[p] = f
+ }
+ }
+ if len(watched) == 0 {
+ return
+ }
+ user := ignore.Parse(game.SyncIgnore)
+ for p := range rest.Files {
+ if delta.NeverSynced(p) || (!user.Empty() && user.Match(p)) {
+ delete(rest.Files, p)
+ }
+ }
+ restHash := rest.ManifestHash()
+
+ // The rest of the save, as last seen from this device.
+ r := d.Store.GetSettingsObservation(game.ID, peerID, "")
+ saveMoved := r.LastHash != "" && r.LastHash != restHash
+ first := r.LastHash == ""
+ r.LastHash = restHash
+ _ = d.Store.SaveSettingsObservation(r)
+
+ var given []string
+ for _, p := range watched {
+ o := d.Store.GetSettingsObservation(game.ID, peerID, p)
+ h := m.Files[p].Hash
+ if saveMoved && o.LastHash != "" {
+ if o.LastHash != h {
+ o.Moved++
+ } else {
+ o.Still++
+ }
+ }
+ o.LastHash = h
+ _ = d.Store.SaveSettingsObservation(o)
+ if first || !saveMoved {
+ continue
+ }
+ moved, still := 0, 0
+ if all, err := d.Store.SettingsObservations(game.ID, p); err == nil {
+ for _, x := range all {
+ moved += x.Moved
+ still += x.Still
+ }
+ }
+ if moved >= observeMinMoved && moved > still {
+ why := fmt.Sprintf("changed in %d of the %d times the save changed", moved, moved+still)
+ if d.Store.SetSettingsFile(store.SettingsFile{GameID: game.ID, Path: p, Verdict: store.VerdictSave, Reason: why}) == nil {
+ given = append(given, p+" ("+why+")")
+ }
+ }
+ }
+ if len(given) > 0 {
+ d.exclusionsChanged(game)
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// observeLocalSettings is observeSettings for this device's own save, at an
+// automatic snapshot.
+func (d *Daemon) observeLocalSettings(gameID string) {
+ game, err := d.Store.GetGame(gameID)
+ if err != nil {
+ return
+ }
+ m, err := delta.BuildManifest(filepath.Clean(game.SavePath))
+ if err != nil {
+ return
+ }
+ d.observeSettings(game, "", m)
+}
+
+func itThey(n int) string {
+ if n == 1 {
+ return "it is"
+ }
+ return "they are"
+}
+
+func lookLike(n int) string {
+ if n == 1 {
+ return "looks"
+ }
+ return "look"
+}
+
+func itTheyAre(n int) string {
+ if n == 1 {
+ return "It is"
+ }
+ return "They are"
+}
+
+func itThem(n int) string {
+ if n == 1 {
+ return "it"
+ }
+ return "them"
+}
diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go
index a50b12e7..b581ec86 100644
--- a/internal/daemon/devicesettings.go
+++ b/internal/daemon/devicesettings.go
@@ -1,6 +1,8 @@
package daemon
import (
+ "errors"
+
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/presets"
"github.com/opensave/opensave/internal/store"
@@ -23,29 +25,93 @@ import (
// is what the next start reads.
type devSettingsCache struct {
- loaded bool
- db *presets.DeviceSettings
+ loaded bool
+ db *presets.DeviceSettings
+ // patterns: the game database's, by game name, App ID and folder.
patterns map[string][]string
+ // verdicts: what was found here (detectsettings.go), by game ID.
+ verdicts map[string][]store.SettingsFile
}
-func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+// devDB returns the game database for device settings, read when first
+// needed. Called with devMu held. Not waiting for an index due a rebuild
+// (DeviceSettingsNoWait): this runs while the daemon starts.
+func (d *Daemon) devDBLocked() *presets.DeviceSettings {
+ if !d.dev.loaded {
+ d.dev.db = d.Scanner.DeviceSettingsNoWait()
+ d.dev.loaded = true
+ }
+ if d.dev.patterns == nil {
+ d.dev.patterns = map[string][]string{}
+ }
+ if d.dev.verdicts == nil {
+ d.dev.verdicts = map[string][]store.SettingsFile{}
+ }
+ return d.dev.db
+}
+
+// DeviceSetting is one entry of a game's device settings, for the window.
+type DeviceSetting struct {
+ Pattern string `json:"pattern"`
+ // Source: "database" (the game database names it), "detected" (found by
+ // what it holds), or "save" (shown to change with the save, so it syncs
+ // whatever named it).
+ Source string `json:"source"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// deviceSettingsList is every entry, in the order they apply: the game
+// database's, what was detected here, then what was shown to be a save —
+// as "!" lines, which bring a file back whatever named it before.
+func (d *Daemon) deviceSettingsList(game store.Game) []DeviceSetting {
if d.Scanner == nil {
return nil
}
key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath
d.devMu.Lock()
defer d.devMu.Unlock()
- if !d.dev.loaded {
- d.dev.db = d.Scanner.DeviceSettingsNoWait()
- d.dev.patterns = map[string][]string{}
- d.dev.loaded = true
+ db := d.devDBLocked()
+ pats, ok := d.dev.patterns[key]
+ if !ok {
+ pats = db.Patterns(game.Name, game.AppID, []string{game.SavePath})
+ d.dev.patterns[key] = pats
+ }
+ verdicts, ok := d.dev.verdicts[game.ID]
+ if !ok {
+ verdicts, _ = d.Store.SettingsFiles(game.ID)
+ d.dev.verdicts[game.ID] = verdicts
}
- if p, ok := d.dev.patterns[key]; ok {
- return p
+ var out []DeviceSetting
+ for _, p := range pats {
+ out = append(out, DeviceSetting{Pattern: p, Source: "database"})
}
- p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath})
- d.dev.patterns[key] = p
- return p
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSettings {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "detected", Reason: v.Reason})
+ }
+ }
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSave {
+ out = append(out, DeviceSetting{Pattern: "!/" + v.Path, Source: "save", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+ list := d.deviceSettingsList(game)
+ out := make([]string, 0, len(list))
+ for _, s := range list {
+ out = append(out, s.Pattern)
+ }
+ return out
+}
+
+// forgetVerdicts drops a game's cached verdicts after they changed.
+func (d *Daemon) forgetVerdicts(gameID string) {
+ d.devMu.Lock()
+ delete(d.dev.verdicts, gameID)
+ d.devMu.Unlock()
}
// IgnoreText is the exclusion list a game is synced under (see
@@ -57,10 +123,10 @@ func (d *Daemon) IgnoreText(game store.Game) string {
return d.P2P.Sync.IgnoreText(game)
}
-// DeviceSettings returns the patterns for a game's device settings, whether
-// or not it is set to sync them — what the window lists.
-func (d *Daemon) DeviceSettings(game store.Game) []string {
- return d.deviceSettingsFor(game)
+// DeviceSettings returns a game's device settings, whether or not it is set
+// to sync them — what the window lists.
+func (d *Daemon) DeviceSettings(game store.Game) []DeviceSetting {
+ return d.deviceSettingsList(game)
}
// adoptExclusionView carries a game's last-snapshot hash over to what the game
@@ -97,3 +163,49 @@ func (d *Daemon) adoptExclusionView(games []store.Game) {
}
}
}
+
+// SuggestedSettings is what was found here by what it holds and still waits
+// for an answer in the game's configuration tab. Each keeps syncing until it
+// is confirmed (store.VerdictSuggested).
+func (d *Daemon) SuggestedSettings(game store.Game) []DeviceSetting {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return nil
+ }
+ var out []DeviceSetting
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSuggested {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "suggested", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+// ErrNotSuggested is an answer about a file that is not waiting for one.
+var ErrNotSuggested = errors.New("that file is not a suggested settings file")
+
+// DecideSuggestedSettings records the answer to a suggestion: keepHere leaves
+// the file out of syncing as this game's settings from now on, like the game
+// database's (and gives it back if it then changes like a save); otherwise it
+// keeps syncing, and is not suggested again.
+func (d *Daemon) DecideSuggestedSettings(game store.Game, path string, keepHere bool) error {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return err
+ }
+ for _, v := range verdicts {
+ if v.Path != path || v.Verdict != store.VerdictSuggested {
+ continue
+ }
+ v.Verdict, v.AtMs = store.VerdictDeclined, 0
+ if keepHere {
+ v.Verdict = store.VerdictSettings
+ }
+ if err := d.Store.SetSettingsFile(v); err != nil {
+ return err
+ }
+ d.exclusionsChanged(game)
+ return nil
+ }
+ return ErrNotSuggested
+}
diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go
index 2b94c1a4..b3ae2637 100644
--- a/internal/daemon/devicesettings_test.go
+++ b/internal/daemon/devicesettings_test.go
@@ -1,11 +1,13 @@
package daemon
import (
+ "fmt"
"os"
"path/filepath"
"testing"
"github.com/opensave/opensave/internal/delta"
+ "github.com/opensave/opensave/internal/ignore"
"github.com/opensave/opensave/internal/store"
"github.com/opensave/opensave/internal/watcher"
)
@@ -49,7 +51,7 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) {
delta.InvalidateRoot(changed.SavePath)
d.devMu.Lock()
- d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}}
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
for _, g := range []store.Game{same, changed} {
d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"}
}
@@ -82,3 +84,55 @@ func mustGame(t *testing.T, d *Daemon, id string) store.Game {
}
return g
}
+
+// A file taken for settings that another device's copy shows changing with
+// every change of the save is given back to syncing; one that stays put while
+// the save moves stays left out.
+func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ game := store.Game{ID: "g", Name: "g", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"progress.cfg", "video.cfg"} {
+ if err := d.Store.SetSettingsFile(store.SettingsFile{GameID: "g", Path: p, Verdict: store.VerdictSettings, Reason: "test"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+ if !excluded("progress.cfg") || !excluded("video.cfg") {
+ t.Fatal("setup: the detected files are not left out")
+ }
+
+ manifest := func(i int, video string) delta.Manifest {
+ return delta.Manifest{Files: map[string]delta.FileEntry{
+ "slot1.sav": {Hash: fmt.Sprint("save", i)},
+ "progress.cfg": {Hash: fmt.Sprint("progress", i)},
+ "video.cfg": {Hash: video},
+ }}
+ }
+ d.observeSettings(game, "peerA", manifest(0, "1080p"))
+ for i := 1; i <= 2; i++ {
+ d.observeSettings(game, "peerA", manifest(i, "1080p"))
+ }
+ if !excluded("progress.cfg") {
+ t.Fatal("given back after two changes: too eager")
+ }
+ d.observeSettings(game, "peerA", manifest(3, "1080p"))
+ if excluded("progress.cfg") {
+ t.Error("a file that changed with the save three times out of three is still left out")
+ }
+ if !excluded("video.cfg") {
+ t.Error("a settings file that stayed put was given back")
+ }
+ v, _ := d.Store.SettingsFiles("g")
+ for _, f := range v {
+ if f.Path == "progress.cfg" && f.Verdict != store.VerdictSave {
+ t.Errorf("verdict %q, want save", f.Verdict)
+ }
+ }
+}
diff --git a/internal/daemon/sessions.go b/internal/daemon/sessions.go
index 80854eba..e45d3711 100644
--- a/internal/daemon/sessions.go
+++ b/internal/daemon/sessions.go
@@ -42,6 +42,29 @@ type sessionState struct {
list func() ([]sessions.Proc, error)
mu sync.Mutex
+ // cpu: each process's processor time at the last poll, and when it last
+ // used any (activeProcesses).
+ cpu map[int]procSeen
+ // endedAt: when each game's last session ended (noteActivity).
+ endedAt map[string]time.Time
+ // saveChanged: when each playing game's save last changed; quiet: where
+ // each one's quiet stretch began; quietEnded: games whose session was
+ // ended as quiet, by the save as it was then, while their process is still
+ // there (endQuietSessions).
+ saveChanged map[string]time.Time
+ quiet map[string]quietWindow
+ quietEnded map[string]string
+ // checkpoint is when each playing game last had a snapshot taken while
+ // it was played (holdSnapshotWhilePlaying).
+ checkpoint map[string]time.Time
+ // programs: what programsIn found in each install folder.
+ programs map[string]programList
+ // active: stretches of changes no session accounts for (noteActivity);
+ // warned: games already said to change with no program seen.
+ active map[string]*activity
+ warned map[string]bool
+ // lastChange: when each game's save last changed here.
+ lastChange map[string]time.Time
// startHash is each playing game's save as it was when play began, to
// tell afterwards whether the session changed it.
startHash map[string]string
@@ -79,19 +102,24 @@ func (d *Daemon) PlayingSince(gameID string) time.Time {
}
// PollSessions looks at what is running once.
-func (d *Daemon) PollSessions() {
+func (d *Daemon) PollSessions() { d.pollSessionsAt(time.Now()) }
+
+func (d *Daemon) pollSessionsAt(now time.Time) {
targets, learn, err := d.sessionTargets()
if err != nil || len(targets) == 0 {
- d.sessions.tracker.Poll(nil, time.Now())
+ d.sessions.tracker.Poll(nil, now)
return
}
procs, err := d.sessions.list()
if err != nil {
return
}
+ procs = d.activeProcessesAt(procs, now)
running := sessions.Running(procs, targets)
+ d.rememberInstallFolders(procs, targets, running)
d.notePrograms(procs, targets, learn, running)
- d.sessions.tracker.Poll(running, time.Now())
+ d.endQuietSessions(running, sessions.CPUOf(procs, targets), now)
+ d.sessions.tracker.Poll(running, now)
}
func (d *Daemon) runSessions(ctx context.Context) {
@@ -145,9 +173,16 @@ func (d *Daemon) sessionTargets() ([]sessions.Target, map[string]bool, error) {
}
}
dir, viaSteam := installFolder(g, byAppID, byFolder)
+ if dir == "" {
+ dir = d.knownInstallFolder(g.ID)
+ }
+ t.FolderNames = d.installFolderNames(g)
if dir != "" {
t.Dirs = append(t.Dirs, dir)
}
+ for _, dir := range t.Dirs {
+ t.Programs = append(t.Programs, d.programsIn(dir)...)
+ }
// Steam launches the games it has installed; the rest need a
// program to start them, and none was given.
if dir != "" && !viaSteam && exe == "" {
@@ -197,6 +232,9 @@ func (d *Daemon) InstallState(g store.Game) string {
if dir, _ := installFolder(g, byAppID, byFolder); dir != "" {
return InstallFound
}
+ if d.knownInstallFolder(g.ID) != "" {
+ return InstallFound
+ }
if g.AppID != "" && steam {
return InstallNotFound
}
@@ -362,9 +400,18 @@ func (d *Daemon) sessionStarted(gameID string, at time.Time) {
}
func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) {
+ // The session's end keeps the save and syncs it; a stretch of changes
+ // it overlapped would otherwise hold the sync back for quietAfter more.
+ d.endActivity(gameID)
d.sessions.mu.Lock()
startHash := d.sessions.startHash[gameID]
delete(d.sessions.startHash, gameID)
+ delete(d.sessions.checkpoint, gameID)
+ delete(d.sessions.saveChanged, gameID)
+ if d.sessions.endedAt == nil {
+ d.sessions.endedAt = map[string]time.Time{}
+ }
+ d.sessions.endedAt[gameID] = ended
seen := d.sessions.seen[gameID]
delete(d.sessions.seen, gameID)
d.sessions.mu.Unlock()
@@ -398,11 +445,13 @@ func (d *Daemon) sessionEnded(gameID string, started, ended time.Time) {
return
}
// Onward to the other devices, so it is there on the next one picked up.
- go func() {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ // Bound to the P2P engine's lifecycle, as the watcher's syncs are, so
+ // shutdown cancels it.
+ d.P2P.GoSync(func(ctx context.Context) {
+ ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
_, _ = d.P2P.SyncGame(ctx, gameID)
- }()
+ })
}
// markSessionEnd keeps the save as a session left it. The watcher has usually
@@ -459,3 +508,544 @@ func (d *Daemon) endOpenSessions() {
}
}
}
+
+// checkpointEvery is how often a game being played gets a snapshot of its
+// save: one per autosave pushed the snapshots worth keeping out, and none
+// until the session ends leaves nothing behind a crash.
+const checkpointEvery = 30 * time.Minute
+
+// holdSnapshotWhilePlaying tells the watcher not to snapshot a change to a
+// game being played — in a session, or changing with no session seen
+// (noteActivity) — unless checkpointEvery has passed since play began or
+// since the last checkpoint. The end of the session, or of the stretch of
+// changes, keeps the save as it was left.
+func (d *Daemon) holdSnapshotWhilePlaying(gameID string) bool {
+ // Every change made here counts, session seen or not (noteActivity).
+ start, stretch := d.noteActivity(gameID)
+ since := d.PlayingSince(gameID)
+ if since.IsZero() {
+ if !stretch {
+ return false
+ }
+ since = start
+ }
+ d.sessions.mu.Lock()
+ defer d.sessions.mu.Unlock()
+ if d.sessions.checkpoint == nil {
+ d.sessions.checkpoint = map[string]time.Time{}
+ }
+ now := time.Now()
+ if d.sessions.saveChanged == nil {
+ d.sessions.saveChanged = map[string]time.Time{}
+ }
+ d.sessions.saveChanged[gameID] = now // the game is saving: being played (endQuietSessions)
+ if !checkpointDue(since, d.sessions.checkpoint[gameID], now) {
+ return true
+ }
+ d.sessions.checkpoint[gameID] = now
+ return false
+}
+
+// checkpointDue says whether checkpointEvery has passed since play began, or
+// since the last checkpoint when there was one.
+func checkpointDue(since, last, now time.Time) bool {
+ if last.Before(since) {
+ last = since
+ }
+ return now.Sub(last) >= checkpointEvery
+}
+
+// activeProcesses leaves out the processes doing nothing. A game leaves one
+// behind when it closes or is killed — a crash handler, a stub of the same
+// name — and it kept a session open for as long as it lingered: fifteen
+// minutes after one game had closed. While a session is open the game's save
+// is held back from syncing (syncengine/playing.go), so this is what lets it
+// go once the game is gone.
+func (d *Daemon) activeProcesses(procs []sessions.Proc) []sessions.Proc {
+ return d.activeProcessesAt(procs, time.Now())
+}
+
+// procSeen is a process's processor time at the last poll, and when it was
+// last seen using any.
+type procSeen struct {
+ cpu time.Duration
+ busy time.Time
+}
+
+// idleFor is how long a process may use no processor time at all before it
+// is no longer the game, however much or little memory it holds. A game
+// being played uses some all the time — a paused one still draws its menu —
+// but one minimised in the background can throttle to almost nothing, or be
+// suspended, and Windows trims its memory too. Ending its session then would
+// sync the save under it, which is what the session is there to stop (asked
+// in review on #117). Five minutes still lets go of what a closed game left
+// behind well before the quarter of an hour one lingered.
+const idleFor = 5 * time.Minute
+
+// busyCPU is the processor time between two polls that counts as using any.
+const busyCPU = 20 * time.Millisecond
+
+func (d *Daemon) activeProcessesAt(procs []sessions.Proc, now time.Time) []sessions.Proc {
+ d.sessions.mu.Lock()
+ defer d.sessions.mu.Unlock()
+ prev := d.sessions.cpu
+ next := make(map[int]procSeen, len(procs))
+ out := procs[:0:0]
+ for _, p := range procs {
+ before, seen := prev[p.PID]
+ if !p.Measured {
+ out = append(out, p) // cannot be told: counted
+ continue
+ }
+ s := procSeen{cpu: p.CPU, busy: before.busy}
+ if !seen || p.CPU-before.cpu >= busyCPU {
+ s.busy = now // new, or working
+ }
+ next[p.PID] = s
+ if now.Sub(s.busy) > idleFor {
+ continue // doing nothing for long enough not to be the game
+ }
+ out = append(out, p)
+ }
+ d.sessions.cpu = next
+ return out
+}
+
+// quietSessionLimit is how long a session may go without its save changing,
+// and its game's processes all but idle, before it is taken as over.
+//
+// While a game is played its save is held back from syncing
+// (syncengine/playing.go), so a session that never ended would hold the save
+// for good. A process left behind that still ticks over now and then — a
+// crash reporter, a launcher's helper — counts as running however long it
+// lingers: activeProcesses only lets go of one doing nothing at all.
+//
+// A game being played may well not save for an hour — one saved by hand, at
+// save points — but it keeps drawing, and uses far more processor time than
+// quietCPUShare. What a game left behind uses less. Ended, the session is kept
+// and synced as when the game closes.
+//
+// Never a session begun from outside (Tracker.Marked: the Deck plugin, `opensave
+// wrap`): what began it says when it ends, and knows.
+const quietSessionLimit = time.Hour
+
+// quietCPUShare is the share of one processor core, at most, that a game's
+// processes may have used over quietSessionLimit for its session to be taken
+// as over: 1%, some 36 seconds in an hour.
+const quietCPUShare = 100 // as a divisor
+
+// quietWindow is where a game's quiet stretch began: the processor time its
+// processes had used by then, and when.
+type quietWindow struct {
+ cpu time.Duration
+ at time.Time
+}
+
+// endQuietSessions ends the sessions whose save has not changed, and whose
+// processes have all but idled, for quietSessionLimit; and keeps them from
+// starting again at once. running is the games seen running this poll, and
+// cpu their processes' processor time (sessions.CPUOf); a game ended this way
+// is taken out of running while its save is still as it was then. Called
+// before the tracker polls.
+//
+// Measured in windows of quietSessionLimit from the session's start or its
+// save's last change: at the end of one, the session is over if the game was
+// all but idle in it; otherwise a new window begins.
+func (d *Daemon) endQuietSessions(running map[string]int, cpu map[string]sessions.GameCPU, now time.Time) {
+ playing := d.sessions.tracker.Playing()
+ lastHash := func(gameID string) string {
+ if g, err := d.Store.GetGame(gameID); err == nil {
+ return g.LastManifestHash
+ }
+ return ""
+ }
+
+ var end []string
+ d.sessions.mu.Lock()
+ if d.sessions.quietEnded == nil {
+ d.sessions.quietEnded = map[string]string{}
+ }
+ if d.sessions.quiet == nil {
+ d.sessions.quiet = map[string]quietWindow{}
+ }
+ for gameID, hash := range d.sessions.quietEnded {
+ if _, still := running[gameID]; !still {
+ delete(d.sessions.quietEnded, gameID) // gone: its next start is a session again
+ } else if lastHash(gameID) != hash {
+ delete(d.sessions.quietEnded, gameID) // saved since: being played after all
+ } else {
+ delete(running, gameID)
+ }
+ }
+ for gameID := range d.sessions.quiet {
+ if _, ok := playing[gameID]; !ok {
+ delete(d.sessions.quiet, gameID)
+ }
+ }
+ for gameID, since := range playing {
+ g, seen := cpu[gameID]
+ if d.sessions.tracker.Marked(gameID) || !seen || !g.Measured {
+ delete(d.sessions.quiet, gameID) // begun from outside, or cannot be told
+ continue
+ }
+ w, has := d.sessions.quiet[gameID]
+ if !has {
+ d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: since} // the first window: from the start
+ continue
+ }
+ if d.sessions.saveChanged[gameID].After(w.at) || g.CPU < w.cpu {
+ d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: now} // saved, or a new process: a new window
+ continue
+ }
+ span := now.Sub(w.at)
+ if span < quietSessionLimit {
+ continue
+ }
+ if g.CPU-w.cpu >= span/quietCPUShare {
+ d.sessions.quiet[gameID] = quietWindow{cpu: g.CPU, at: now} // busy: being played
+ continue
+ }
+ delete(d.sessions.quiet, gameID)
+ d.sessions.quietEnded[gameID] = lastHash(gameID)
+ delete(running, gameID)
+ end = append(end, gameID)
+ }
+ d.sessions.mu.Unlock()
+
+ for _, gameID := range end {
+ name := gameID
+ if g, err := d.Store.GetGame(gameID); err == nil {
+ name = g.Name
+ }
+ d.Log.Log("info", fmt.Sprintf("%q has neither saved nor done anything for %s while seen running: taken as no longer played, so its save is kept and synced",
+ name, quietSessionLimit))
+ d.sessions.tracker.Finish(gameID, now)
+ }
+}
+
+// Programs that come with a game without being it: installers, crash
+// reporters, redistributables, anti-cheat services. A process known only by
+// name is never matched by one of these — every game ships the same few.
+var notTheGame = []string{
+ "unins", "setup", "install", "crash", "report", "redist", "vcredist", "vc_redist", "dxsetup",
+ "directx", "prereq", "launcher", "easyanticheat", "eac", "battleye", "beservice", "update",
+ "patch", "config", "settings", "helper", "service", "cef", "webhelper", "dotnet", "physx",
+}
+
+// programsIn lists the programs in a game's install folder by file name,
+// lower-case, for a process known only by name (sessions.Proc.Name). Read at
+// most every installDirsFresh, three folders deep.
+func (d *Daemon) programsIn(dir string) []string {
+ d.sessions.mu.Lock()
+ if c, ok := d.sessions.programs[dir]; ok && time.Since(c.at) < installDirsFresh {
+ d.sessions.mu.Unlock()
+ return c.names
+ }
+ d.sessions.mu.Unlock()
+
+ var names []string
+ root := filepath.Clean(dir)
+ depth := strings.Count(root, string(filepath.Separator))
+ _ = filepath.WalkDir(root, func(p string, e os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if e.IsDir() {
+ if strings.Count(filepath.Clean(p), string(filepath.Separator))-depth >= 3 {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ name := strings.ToLower(e.Name())
+ if !strings.HasSuffix(name, ".exe") || len(names) >= 64 {
+ return nil
+ }
+ for _, w := range notTheGame {
+ if strings.Contains(name, w) {
+ return nil
+ }
+ }
+ names = append(names, name)
+ return nil
+ })
+
+ d.sessions.mu.Lock()
+ if d.sessions.programs == nil {
+ d.sessions.programs = map[string]programList{}
+ }
+ d.sessions.programs[dir] = programList{at: time.Now(), names: names}
+ d.sessions.mu.Unlock()
+ return names
+}
+
+type programList struct {
+ at time.Time
+ names []string
+}
+
+// quietAfter is how long a game's save must stay unchanged before a stretch
+// of changes no session accounts for is over: longer than the gap between
+// two autosaves of the games measured (Crimson Desert: up to six minutes).
+const quietAfter = 10 * time.Minute
+
+// activity is a stretch of changes to a game's save while no session of it
+// was seen: a game whose program cannot be told apart, or is not installed
+// where this device looks. It is handled as a session — not synced either
+// way, a checkpoint at most every checkpointEvery — and when the save has
+// stayed unchanged for quietAfter, it is kept and synced as a session's end
+// would be.
+type activity struct {
+ start, last time.Time
+ timer *time.Timer
+ // changes counts the changes in it; a change within quietAfter of the
+ // one before, even one already sent, counts as a second.
+ changes int
+}
+
+// firstChangeWait is how long a change on its own is held before it is kept
+// and synced: long enough that the next autosave of a game being played
+// arrives first (Crimson Desert: 3.6 minutes between its first two), so a
+// session not seen still sends one version, at its end. A game that saves
+// once, as it closes, reaches the other devices this much later — when its
+// session is not seen; the end of a session seen is sent at once.
+const firstChangeWait = 5 * time.Minute
+
+// quietFor is how long the save must stay unchanged to end the stretch.
+func (a *activity) quietFor() time.Duration {
+ if a.changes <= 1 {
+ return firstChangeWait
+ }
+ return quietAfter
+}
+
+// noteActivity records a change to a game's save made here (not by a sync)
+// and returns when the stretch it belongs to began, and whether it is held
+// as one. Every change is: a change on its own is kept and synced once the
+// save has stayed unchanged for firstChangeWait, a second within quietAfter
+// of the one before makes it a stretch that ends after quietAfter unchanged.
+// Only SyncEveryChange sends each change as it comes.
+func (d *Daemon) noteActivity(gameID string) (time.Time, bool) {
+ now := time.Now()
+ d.sessions.mu.Lock()
+ defer d.sessions.mu.Unlock()
+ if d.sessions.active == nil {
+ d.sessions.active = map[string]*activity{}
+ }
+ if d.sessions.lastChange == nil {
+ d.sessions.lastChange = map[string]time.Time{}
+ }
+ prev, seen := d.sessions.lastChange[gameID]
+ d.sessions.lastChange[gameID] = now
+ if d.opts.SyncEveryChange {
+ return now, false
+ }
+ // A game whose sessions are seen here, while none is open: a change is
+ // not play, and is sent at once (recognised). So is one just after a
+ // session's end: the game writing as it closes.
+ if d.sessions.tracker.Playing()[gameID].IsZero() && d.sessions.active[gameID] == nil &&
+ (now.Sub(d.sessions.endedAt[gameID]) < sessionEndGrace || d.recognisedLocked(gameID)) {
+ return now, false
+ }
+ a := d.sessions.active[gameID]
+ if a == nil {
+ a = &activity{start: now, changes: 1}
+ if seen && now.Sub(prev) < quietAfter {
+ a.start, a.changes = prev, 2
+ }
+ d.sessions.active[gameID] = a
+ a.timer = time.AfterFunc(a.quietFor(), func() { d.activityQuiet(gameID) })
+ } else {
+ a.changes++
+ a.timer.Reset(a.quietFor())
+ }
+ a.last = now
+ if a.changes == 2 && d.PlayingSince(gameID).IsZero() && !d.sessions.warned[gameID] {
+ if d.sessions.warned == nil {
+ d.sessions.warned = map[string]bool{}
+ }
+ d.sessions.warned[gameID] = true
+ name := gameID
+ if g, err := d.Store.GetGame(gameID); err == nil {
+ name = g.Name
+ }
+ go d.Log.Log("info", fmt.Sprintf("%q is changing its save, but its program is not seen running here — "+
+ "handled as being played: kept and synced once it has not changed for %s", name, spokenLength(quietAfter)))
+ }
+ return a.start, true
+}
+
+// changingNow reports a game in a stretch of changes (noteActivity).
+func (d *Daemon) changingNow(gameID string) bool {
+ d.sessions.mu.Lock()
+ defer d.sessions.mu.Unlock()
+ _, ok := d.sessions.active[gameID]
+ return ok
+}
+
+// endActivity forgets a stretch of changes; a session's end takes over.
+func (d *Daemon) endActivity(gameID string) {
+ d.sessions.mu.Lock()
+ if a := d.sessions.active[gameID]; a != nil {
+ a.timer.Stop()
+ delete(d.sessions.active, gameID)
+ }
+ d.sessions.mu.Unlock()
+}
+
+// activityQuiet ends a stretch of changes once the save has stayed unchanged
+// for quietAfter: the save as it was left is kept and goes to the other
+// devices, as at the end of a session.
+func (d *Daemon) activityQuiet(gameID string) {
+ d.sessions.mu.Lock()
+ a := d.sessions.active[gameID]
+ if a == nil {
+ d.sessions.mu.Unlock()
+ return
+ }
+ if wait := a.quietFor() - time.Since(a.last); wait > 0 {
+ a.timer.Reset(wait)
+ d.sessions.mu.Unlock()
+ return
+ }
+ delete(d.sessions.active, gameID)
+ delete(d.sessions.checkpoint, gameID)
+ start, last, stretch := a.start, a.last, a.changes > 1
+ d.sessions.mu.Unlock()
+
+ if !d.PlayingSince(gameID).IsZero() {
+ return // a session is open after all; its end keeps the save
+ }
+ game, err := d.Store.GetGame(gameID)
+ if err != nil {
+ return
+ }
+ if hash, err := d.currentContentHash(game); err == nil && hash != game.LastManifestHash {
+ comment := "Auto backup"
+ if last.Sub(start) >= shortestSession {
+ comment = fmt.Sprintf("After playing (%s)", spokenLength(last.Sub(start)))
+ }
+ if err := d.markSessionEnd(game, hash, start, comment); err != nil {
+ d.Log.Log("warn", fmt.Sprintf("could not keep %q as it was left: %v", game.Name, err))
+ return
+ }
+ }
+ if stretch {
+ d.Log.Log("info", fmt.Sprintf("%q has not changed its save for %s; sending it to your other devices",
+ game.Name, spokenLength(quietAfter)))
+ }
+ if d.OnGameChanged != nil {
+ d.OnGameChanged(gameID)
+ }
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ defer cancel()
+ _, _ = d.P2P.SyncGame(ctx, gameID)
+ }()
+}
+
+// Games installed where no library lists them — a folder of the user's own,
+// "G:\Games\Crimson Desert" — are found by the name of their install folder:
+// the names the game database knows it by, and the game's own name when it
+// is specific enough not to be a folder of something else. Once a session
+// has been seen that way, the folder is remembered, so the game is known to
+// be installed and its programs can be told by name (programsIn).
+
+// genericFolders are names no game's folder is recognised by alone.
+var genericFolders = map[string]bool{
+ "games": true, "game": true, "steam": true, "steamapps": true, "common": true, "program files": true,
+ "program files x86": true, "bin": true, "bin64": true, "win64": true, "binaries": true, "launcher": true,
+ "data": true, "x64": true, "app": true, "client": true, "epic games": true, "gog games": true,
+}
+
+func (d *Daemon) installFolderNames(g store.Game) []string {
+ var names []string
+ add := func(n string) {
+ n = sessions.FolderName(n)
+ if n == "" || genericFolders[n] {
+ return
+ }
+ for _, have := range names {
+ if have == n {
+ return
+ }
+ }
+ names = append(names, n)
+ }
+ d.devMu.Lock()
+ db := d.devDBLocked()
+ d.devMu.Unlock()
+ for _, n := range db.InstallNames(g.Name, g.AppID) {
+ if len(sessions.FolderName(n)) >= 4 {
+ add(n)
+ }
+ }
+ // The game's own name only when it could hardly name anything else:
+ // two words, or one long one. "Rust" is a folder in every Rust toolchain.
+ if n := sessions.FolderName(g.Name); strings.Contains(n, " ") && len(n) >= 6 || len(n) >= 10 {
+ add(g.Name)
+ }
+ return names
+}
+
+func installFolderMark(gameID string) string { return "installdir:" + gameID }
+
+// knownInstallFolder is the install folder a session of the game was seen
+// running from, when it is still there.
+func (d *Daemon) knownInstallFolder(gameID string) string {
+ dir := d.Store.Mark(installFolderMark(gameID))
+ if dir == "" {
+ return ""
+ }
+ if info, err := os.Stat(dir); err != nil || !info.IsDir() {
+ return ""
+ }
+ return dir
+}
+
+// rememberInstallFolders records, for each game found running by the name of
+// its install folder, which folder that was.
+func (d *Daemon) rememberInstallFolders(procs []sessions.Proc, targets []sessions.Target, running map[string]int) {
+ for _, t := range targets {
+ pid, ok := running[t.GameID]
+ if !ok || len(t.Dirs) > 0 {
+ continue
+ }
+ for _, p := range procs {
+ if p.PID != pid {
+ continue
+ }
+ if dir := sessions.FolderOf(p, t); dir != "" && d.Store.Mark(installFolderMark(t.GameID)) != dir {
+ _ = d.Store.SetMark(installFolderMark(t.GameID), dir)
+ }
+ }
+ }
+}
+
+// playingHere says whether a game counts as being played on this device, for
+// the sync engine (syncengine/playing.go): a session is open, a stretch of
+// changes is (noteActivity) — or one is about to be: a change made here waits
+// out the watcher's debounce. A sync landing in that gap would otherwise
+// send the change on the moment before it is held.
+func (d *Daemon) playingHere(gameID string) bool {
+ if !d.PlayingSince(gameID).IsZero() || d.changingNow(gameID) {
+ return true
+ }
+ return !d.opts.SyncEveryChange && d.Watcher != nil && d.Watcher.ChangePending(gameID)
+}
+
+// sessionEndGrace is how long after a session ends a change to its save is
+// still the session's — the game writing as it closes — and sent at once.
+const sessionEndGrace = 2 * time.Minute
+
+// recognisedLocked reports a game whose sessions this device sees: one has
+// been recorded, or the folder it ran from is known. While no session of such
+// a game is open, a change to its save is not play, and is sent at once;
+// only a game whose program is not seen is held as a stretch (noteActivity).
+// Called with sessions.mu held.
+func (d *Daemon) recognisedLocked(gameID string) bool {
+ if dir := d.Store.Mark(installFolderMark(gameID)); dir != "" {
+ return true
+ }
+ st, err := d.Store.PlayStatsFor(gameID)
+ return err == nil && st.Sessions > 0
+}
diff --git a/internal/daemon/sessions_test.go b/internal/daemon/sessions_test.go
index 362c27c7..79912fb3 100644
--- a/internal/daemon/sessions_test.go
+++ b/internal/daemon/sessions_test.go
@@ -193,3 +193,265 @@ func TestSpokenLength(t *testing.T) {
}
}
}
+
+// While a game is played, a change to its save is snapshotted once per
+// checkpointEvery, not at every autosave; and it is not synced either way.
+func TestSessionHoldsSnapshotsAndSyncWhilePlaying(t *testing.T) {
+ d := newTestDaemon(t)
+ g, _ := sessionGame(t, d, "autosaving")
+ d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{{PID: 7, Exe: g.ExePath}}, nil }
+ d.PollSessions()
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Fatal("setup: not playing")
+ }
+ if !d.holdSnapshotWhilePlaying(g.ID) {
+ t.Error("an autosave right after the game started was snapshotted")
+ }
+ if !d.P2P.Sync.PlayingHere(g.ID) {
+ t.Error("the sync engine does not know the game is being played")
+ }
+ // Checkpoints: half an hour after play began, then half an hour after
+ // the last one.
+ start := time.Now()
+ for _, c := range []struct {
+ last time.Time
+ after time.Duration
+ want bool
+ }{
+ {time.Time{}, 10 * time.Minute, false},
+ {time.Time{}, checkpointEvery, true},
+ {start.Add(40 * time.Minute), 50 * time.Minute, false},
+ {start.Add(40 * time.Minute), 70 * time.Minute, true},
+ } {
+ if got := checkpointDue(start, c.last, start.Add(c.after)); got != c.want {
+ t.Errorf("checkpoint due %v after start (last %v) = %v, want %v", c.after, c.last, got, c.want)
+ }
+ }
+}
+
+// A process doing nothing does not keep a session open, and with it the
+// game's save held back from syncing.
+func TestSessionEndsWhenOnlyAStubIsLeft(t *testing.T) {
+ d := newTestDaemon(t)
+ g, _ := sessionGame(t, d, "stub")
+ running := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, CPU: time.Second}
+ d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{running}, nil }
+ d.PollSessions()
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Fatal("setup: the game was not seen running")
+ }
+ // The game closed; a stub of the same program lingers, doing nothing, and
+ // has for longer than idleFor.
+ stub := sessions.Proc{PID: 9, Exe: g.ExePath, Measured: true, CPU: time.Second}
+ d.sessions.mu.Lock()
+ d.sessions.cpu[9] = procSeen{cpu: time.Second, busy: time.Now().Add(-idleFor - time.Minute)}
+ d.sessions.mu.Unlock()
+ d.sessions.list = func() ([]sessions.Proc, error) { return []sessions.Proc{stub}, nil }
+ d.PollSessions()
+ since := d.PlayingSince(g.ID)
+ d.sessions.tracker.Finish(g.ID, since.Add(time.Hour)) // past the grace the tracker allows
+ d.PollSessions()
+ if !d.PlayingSince(g.ID).IsZero() {
+ t.Fatal("a lingering stub keeps the session open")
+ }
+ if d.holdSnapshotWhilePlaying(g.ID) {
+ t.Error("a change after the session ended was held")
+ }
+}
+
+// A process that has used no processor time for idleFor is no longer the game,
+// whatever it holds: what a killed game leaves behind. One quiet for less —
+// a game minimised in the background, throttled or suspended — still is, so
+// its session and the hold on its save go on. One working stays.
+func TestActiveProcesses_QuietForLongIsNotTheGame(t *testing.T) {
+ d := newTestDaemon(t)
+ t0 := time.Now()
+ left := func(cpu time.Duration) sessions.Proc {
+ return sessions.Proc{PID: 1, Exe: `G:\Games\Crimson Desert\bin64\CrimsonDesert.exe`, Measured: true, CPU: cpu}
+ }
+ playing := func(cpu time.Duration) sessions.Proc {
+ return sessions.Proc{PID: 2, Exe: `G:\Games\Other\game.exe`, Measured: true, CPU: cpu}
+ }
+ count := func(ps []sessions.Proc) map[int]bool {
+ m := map[int]bool{}
+ for _, p := range ps {
+ m[p.PID] = true
+ }
+ return m
+ }
+ got := count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(time.Second)}, t0))
+ if !got[1] || !got[2] {
+ t.Fatalf("first seen: %v, want both", got)
+ }
+ got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(2 * time.Second)}, t0.Add(30*time.Second)))
+ if !got[1] || !got[2] {
+ t.Errorf("half a minute in: %v, want both", got)
+ }
+ // Minimised and throttled: quiet for minutes, and still the game.
+ for _, at := range []time.Duration{70 * time.Second, 4 * time.Minute} {
+ got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(3 * time.Second)}, t0.Add(at)))
+ if !got[1] {
+ t.Errorf("quiet for %s: no longer counted, so a game in the background would lose its session", at)
+ }
+ }
+ got = count(d.activeProcessesAt([]sessions.Proc{left(5 * time.Second), playing(4 * time.Second)}, t0.Add(idleFor+10*time.Second)))
+ if got[1] {
+ t.Error("a process doing nothing for longer than idleFor still counts as the game")
+ }
+ if !got[2] {
+ t.Error("a game using the processor was dropped")
+ }
+}
+
+// quietGame runs one game process whose processor time grows by perPoll at
+// every poll.
+func quietGame(t *testing.T, d *Daemon, id string, perPoll time.Duration) store.Game {
+ t.Helper()
+ g, _ := sessionGame(t, d, id)
+ cpu := time.Duration(0)
+ d.sessions.list = func() ([]sessions.Proc, error) {
+ cpu += perPoll
+ return []sessions.Proc{{PID: 7, Exe: g.ExePath, Measured: true, CPU: cpu}}, nil
+ }
+ return g
+}
+
+// What a game left behind, still ticking over now and then, holds a session
+// open, and with it the hold on the save. An hour without a save, all but
+// idle, and the session is taken as over; the process still there does not
+// start it again, and the save changing again does.
+func TestQuietSession_ALeftoverThatTicksOverIsLetGo(t *testing.T) {
+ d := newTestDaemon(t)
+ g := quietGame(t, d, "leftover", 30*time.Millisecond) // busy enough to count, every poll
+ t0 := time.Now()
+ for i := 0; i <= 5; i++ {
+ d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute))
+ }
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Fatal("setup: the session ended before the hour")
+ }
+ d.pollSessionsAt(t0.Add(61 * time.Minute))
+ if !d.PlayingSince(g.ID).IsZero() {
+ t.Fatal("an hour without a save, all but idle, still holds the save")
+ }
+ d.pollSessionsAt(t0.Add(62 * time.Minute))
+ if !d.PlayingSince(g.ID).IsZero() {
+ t.Error("the process still running started the session again at once")
+ }
+ if err := d.Store.SetLastManifestHash(g.ID, "a save made since"); err != nil {
+ t.Fatal(err)
+ }
+ d.pollSessionsAt(t0.Add(63 * time.Minute))
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Error("the game saved again but no session started")
+ }
+}
+
+// A game saved only by hand can go hours without a save while it is played.
+// It keeps drawing, so its session goes on.
+func TestQuietSession_AGamePlayedWithoutSavingKeepsItsSession(t *testing.T) {
+ d := newTestDaemon(t)
+ g := quietGame(t, d, "manual-saves", time.Minute) // a minute of processor time per ten
+ t0 := time.Now()
+ for i := 0; i <= 18; i++ { // three hours
+ d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute))
+ }
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Error("a game being played without saving lost its session")
+ }
+}
+
+// A session begun from outside — the Deck plugin, from Steam's launch — ends
+// when that says so, however quiet the game is.
+func TestQuietSession_ASessionBegunFromOutsideIsNotEnded(t *testing.T) {
+ d := newTestDaemon(t)
+ g := quietGame(t, d, "deck", 30*time.Millisecond)
+ t0 := time.Now()
+ d.sessions.tracker.Begin(g.ID, t0)
+ for i := 0; i <= 12; i++ {
+ d.pollSessionsAt(t0.Add(time.Duration(i) * 10 * time.Minute))
+ }
+ if d.PlayingSince(g.ID).IsZero() {
+ t.Error("a session begun by the Deck plugin was ended as quiet")
+ }
+}
+
+// A game whose program is not seen: a change is held, not synced, and kept
+// once the save has stayed unchanged for firstChangeWait; a second soon after
+// makes it a stretch, which ends after quietAfter unchanged.
+func TestActivityWithoutASessionIsHandledAsPlay(t *testing.T) {
+ d := newTestDaemon(t)
+ g, dir := sessionGame(t, d, "unseen")
+ if !d.holdSnapshotWhilePlaying(g.ID) {
+ t.Fatal("a single change was not held")
+ }
+ d.sessions.mu.Lock()
+ if q := d.sessions.active[g.ID].quietFor(); q != firstChangeWait {
+ t.Errorf("a single change waits %v, want %v", q, firstChangeWait)
+ }
+ d.sessions.mu.Unlock()
+ if !d.holdSnapshotWhilePlaying(g.ID) {
+ t.Fatal("a second change soon after was not held")
+ }
+ d.sessions.mu.Lock()
+ if q := d.sessions.active[g.ID].quietFor(); q != quietAfter {
+ t.Errorf("a stretch waits %v, want %v", q, quietAfter)
+ }
+ d.sessions.mu.Unlock()
+ if !d.changingNow(g.ID) || !d.P2P.Sync.PlayingHere(g.ID) {
+ t.Fatal("a game changing its save is not handled as being played")
+ }
+ before := len(snapComments(t, d, g.ID))
+ if err := os.WriteFile(filepath.Join(dir, "slot1.sav"), []byte("left like this"), 0o666); err != nil {
+ t.Fatal(err)
+ }
+
+ // Quiet for longer than quietAfter.
+ d.sessions.mu.Lock()
+ d.sessions.active[g.ID].last = time.Now().Add(-quietAfter - time.Second)
+ d.sessions.mu.Unlock()
+ d.activityQuiet(g.ID)
+ if d.changingNow(g.ID) || d.P2P.Sync.PlayingHere(g.ID) {
+ t.Error("still handled as being played after the save went quiet")
+ }
+ if got := snapComments(t, d, g.ID); len(got) != before+1 {
+ t.Errorf("snapshots %q: want one more, keeping the save as it was left", got)
+ }
+}
+
+// The names a game's install folder is recognised by: never a generic folder,
+// and the game's own name only when it could hardly be another folder's.
+func TestInstallFolderNames(t *testing.T) {
+ d := newTestDaemon(t)
+ for name, want := range map[string]bool{"Crimson Desert": true, "Rust": false, "Games": false, "Satisfactory": true} {
+ got := d.installFolderNames(store.Game{ID: name, Name: name})
+ if (len(got) > 0) != want {
+ t.Errorf("%q: folder names %v, want some = %v", name, got, want)
+ }
+ }
+}
+
+// A game whose sessions this device sees is not held while it is closed: a
+// change then is not play, and is sent at once. Nor is one just after a
+// session's end, the game writing as it closes. Only a game whose program is
+// not seen is held as a stretch.
+func TestActivity_ARecognisedGameIsNotHeldWhileClosed(t *testing.T) {
+ d := newTestDaemon(t)
+ g, _ := sessionGame(t, d, "recognised")
+ start := time.Now()
+ d.sessions.tracker.Begin(g.ID, start)
+ d.sessions.tracker.Finish(g.ID, start.Add(40*time.Minute))
+ if d.holdSnapshotWhilePlaying(g.ID) || d.changingNow(g.ID) {
+ t.Error("a change just after the session's end was held")
+ }
+ st, _ := d.Store.PlayStatsFor(g.ID)
+ if st.Sessions == 0 {
+ t.Skip("the session was too short to be recorded here")
+ }
+ d.sessions.mu.Lock()
+ d.sessions.endedAt[g.ID] = start.Add(-time.Hour) // long ago
+ d.sessions.mu.Unlock()
+ if d.holdSnapshotWhilePlaying(g.ID) || d.changingNow(g.ID) {
+ t.Error("a change to a recognised game, closed, was held as play")
+ }
+}
diff --git a/internal/daemon/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go
new file mode 100644
index 00000000..73bbc16a
--- /dev/null
+++ b/internal/daemon/suggested_settings_test.go
@@ -0,0 +1,79 @@
+package daemon
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/opensave/opensave/internal/ignore"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// A file found by what it holds is only suggested: it keeps syncing until
+// someone confirms it in the game's configuration tab, and a file someone
+// chose to keep syncing is not suggested again (asked in review on #118).
+func TestSuggestedSettings_SyncUntilConfirmed(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"
+ for name, content := range map[string]string{
+ "slot1.sav": "progress",
+ "videoconfig.txt": settings,
+ "display.cfg": settings,
+ } {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ }
+ game := store.Game{ID: "g", Name: "No Such Game In The Database", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+
+ d.DetectSettings(game)
+ suggested := map[string]bool{}
+ for _, s := range d.SuggestedSettings(game) {
+ suggested[s.Pattern] = true
+ }
+ if !suggested["/videoconfig.txt"] || !suggested["/display.cfg"] {
+ t.Fatalf("setup: the settings files were not suggested: %v", suggested)
+ }
+ if suggested["/slot1.sav"] {
+ t.Error("the save was suggested as settings")
+ }
+ if excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Fatal("a suggested file was left out of syncing before anyone confirmed it")
+ }
+
+ if err := d.DecideSuggestedSettings(game, "videoconfig.txt", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", false); err != nil {
+ t.Fatal(err)
+ }
+ if !excluded("videoconfig.txt") {
+ t.Error("a confirmed settings file still syncs")
+ }
+ if excluded("display.cfg") {
+ t.Error("a file chosen to keep syncing was left out")
+ }
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("answered files are still suggested: %v", d.SuggestedSettings(game))
+ }
+
+ // Detection runs again: neither answer is undone, nothing suggested anew.
+ d.DetectSettings(game)
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("a file already answered was suggested again: %v", d.SuggestedSettings(game))
+ }
+ if !excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Error("running detection again changed what was answered")
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", true); err != ErrNotSuggested {
+ t.Errorf("answering a file that is not suggested: %v, want ErrNotSuggested", err)
+ }
+}
diff --git a/internal/p2p/routes.go b/internal/p2p/routes.go
index ba2142ff..abbea2b4 100644
--- a/internal/p2p/routes.go
+++ b/internal/p2p/routes.go
@@ -683,6 +683,11 @@ func (e *Engine) handleManifest(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, syncengine.HeldMessage)
return
}
+ // Being played here: synced when the session ends (syncengine/playing.go).
+ if e.Sync.PlayingHere(game.ID) {
+ jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage)
+ return
+ }
// Never describe a save a sync here is writing: part-way through, it is a
// mixture no device holds, and the asker would judge it as a save that had
// moved (syncengine/settle.go). Held still while it is read; a write in
@@ -811,6 +816,10 @@ func (e *Engine) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusNotFound, "Game not found.")
return
}
+ if e.Sync.PlayingHere(game.ID) {
+ jsonError(w, http.StatusServiceUnavailable, syncengine.PlayingMessage)
+ return
+ }
base, ok := e.resolveServeRoot(gameID, game, body.Root)
if !ok {
jsonError(w, http.StatusNotFound, "This device has no save location named "+strconv.Quote(body.Root)+" for that game.")
diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go
index 57a0a940..601ab54f 100644
--- a/internal/p2p/syncengine/engine.go
+++ b/internal/p2p/syncengine/engine.go
@@ -164,6 +164,17 @@ type Engine struct {
// settings (presets/devicesettings.go), set by the daemon; nil leaves
// them syncing. See IgnoreText.
DeviceSettingsFor func(store.Game) []string
+ // Playing reports whether a game is being played on this device
+ // (playing.go). Set by the daemon from its play sessions.
+ Playing func(gameID string) bool
+ // playingMu guards playingSaid: per game and peer, whether its "playing"
+ // answer was said already this session (playing.go).
+ playingMu sync.Mutex
+ playingSaid map[string]bool
+ // ObserveSettings is told what another device holds of a game, as its
+ // manifest arrives, to watch how the files left out as the game's
+ // settings change (daemon/detectsettings.go). Set by the daemon.
+ ObserveSettings func(game store.Game, peerID string, remote delta.Manifest)
}
// New creates an Engine.
@@ -263,6 +274,10 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer
if e.Paused != nil && e.Paused() {
return nil, ErrPaused
}
+ // A game being played here is synced when the session ends (playing.go).
+ if e.PlayingHere(gameID) {
+ return nil, ErrPlayingHere
+ }
// A save emptied here is not synced until someone says whether that was
// meant (hold.go).
held, err := e.checkHoldSettled(ctx, gameID)
@@ -371,7 +386,7 @@ func (e *Engine) SyncGame(ctx context.Context, gameID string, onlinePeers []Peer
// divergence from the NEXT sync, causing the peer to silently overwrite
// its own changes instead of detecting the conflict and asking.
switch res.Status {
- case "conflict", "error", "unwritable":
+ case "conflict", "error", "unwritable", "peer_playing":
case "peer_missing", "peer_awaiting_folder", "peer_holding":
// The two devices talked and finished, which is what the
// per-device stamp has always recorded. But nothing of THIS game
@@ -465,6 +480,12 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re
if isAwaitingFolder(err) {
return Result{Status: "peer_awaiting_folder", PeerID: peer.ID, PeerName: peer.Name}, nil
}
+ // Being played there: its save comes when the session ends, sent by
+ // that device. Before isSettling, whose message this one contains.
+ if isPlaying(err) {
+ e.notePeerPlaying(gameID, peer, game.Name)
+ return Result{Status: "peer_playing", PeerID: peer.ID, PeerName: peer.Name}, nil
+ }
// The peer is still writing this game for a sync of its own, and
// would otherwise have described a save half-way between two states
// (settle.go). SyncGame asks again once it has finished.
@@ -482,6 +503,7 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re
}
return Result{}, fmt.Errorf("fetch remote manifest: %w", err)
}
+ e.peerNotPlaying(gameID, peer.ID)
// 2. Branch alignment: local follows the remote's active branch.
//
@@ -554,6 +576,11 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re
// would propagate a deletion of the very file being protected.
ignoreRules := e.rulesFor(gameID)
unfilteredLocal, unfilteredRemote := localManifest, remoteData.Manifest
+ // How the files left out as the game's settings change on the other
+ // device (daemon/detectsettings.go).
+ if e.ObserveSettings != nil && len(unfilteredRemote.Files) > 0 {
+ e.ObserveSettings(game, peer.ID, unfilteredRemote)
+ }
// Filtered always, not only with rules: launcher files (delta.NeverSynced)
// are left out the same way.
localManifest = filterManifest(localManifest, ignoreRules)
diff --git a/internal/p2p/syncengine/playing.go b/internal/p2p/syncengine/playing.go
new file mode 100644
index 00000000..5c689617
--- /dev/null
+++ b/internal/p2p/syncengine/playing.go
@@ -0,0 +1,74 @@
+package syncengine
+
+import (
+ "fmt"
+ "strings"
+)
+
+// A game being played.
+//
+// A game autosaves every few minutes, and each autosave is a change of its
+// save. Synced as they come, every one of them reached every other device as
+// a version of its own, and each of those took a snapshot of it: eleven of
+// one game in 26 minutes on a device nobody was sitting at. And this device
+// took other devices' versions of a game running here, writing a save under
+// a game that was still using it.
+//
+// So while a game is played here (Playing, from the daemon's sessions), it
+// is not synced either way. Another device asking for it is told so, and
+// asks again later; nothing is taken for it. When the session ends, the save
+// as it was left goes to the other devices in one sync (daemon/sessions.go).
+
+// PlayingMessage is what a device answers when asked for a game being played
+// on it. It contains SettlingMessage, so a device on a build that predates
+// this takes it as "busy, ask again" too.
+const PlayingMessage = "This device is playing this game right now; its save follows when the session ends. " + SettlingMessage
+
+const playingMark = "is playing this game right now"
+
+// isPlaying reports whether an error from a peer is its "playing" answer.
+func isPlaying(err error) bool {
+ return err != nil && strings.Contains(err.Error(), playingMark)
+}
+
+// playingErr is SyncGame declining to sync a game being played here. It
+// counts as ErrHeld for callers that only want to know it was not an error.
+type playingErr struct{}
+
+func (playingErr) Error() string {
+ return "it is being played on this device; it syncs when the session ends"
+}
+
+func (playingErr) Is(target error) bool { return target == ErrHeld || target == ErrPlayingHere }
+
+// ErrPlayingHere is SyncGame declining to sync a game being played here.
+var ErrPlayingHere error = playingErr{}
+
+// PlayingHere reports whether a game is being played on this device.
+func (e *Engine) PlayingHere(gameID string) bool {
+ return e.Playing != nil && e.Playing(gameID)
+}
+
+// notePeerPlaying says once per session that a peer is playing a game: the
+// reconcile asks every minute, and each "playing" answer is the same news.
+func (e *Engine) notePeerPlaying(gameID string, peer Peer, gameName string) {
+ key := gameID + "\x00" + peer.ID
+ e.playingMu.Lock()
+ said := e.playingSaid[key]
+ if e.playingSaid == nil {
+ e.playingSaid = map[string]bool{}
+ }
+ e.playingSaid[key] = true
+ e.playingMu.Unlock()
+ if !said {
+ e.Log("info", fmt.Sprintf("%s is playing %q right now; its save follows when the session ends", peer.Name, gameName))
+ }
+}
+
+// peerNotPlaying forgets that a peer was playing a game, once it answers
+// normally again: its next session is said again.
+func (e *Engine) peerNotPlaying(gameID, peerID string) {
+ e.playingMu.Lock()
+ delete(e.playingSaid, gameID+"\x00"+peerID)
+ e.playingMu.Unlock()
+}
diff --git a/internal/p2p/syncengine/playing_test.go b/internal/p2p/syncengine/playing_test.go
new file mode 100644
index 00000000..90112fb3
--- /dev/null
+++ b/internal/p2p/syncengine/playing_test.go
@@ -0,0 +1,49 @@
+package syncengine
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// A game being played here is not synced either way until the session ends:
+// nothing is taken for it, and callers are not told it failed.
+func TestPlaying_NotSyncedWhilePlayedHere(t *testing.T) {
+ env := setupEngine(t)
+ write(t, env.localDir, "slot1.sav", "being played")
+ write(t, env.remoteDir, "slot1.sav", "another device's")
+ playing := true
+ env.engine.Playing = func(string) bool { return playing }
+
+ _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer})
+ if !errors.Is(err, ErrPlayingHere) || !errors.Is(err, ErrHeld) {
+ t.Fatalf("err = %v, want ErrPlayingHere, quiet as ErrHeld", err)
+ }
+ if got, _ := os.ReadFile(filepath.Join(env.localDir, "slot1.sav")); string(got) != "being played" {
+ t.Fatalf("the save of a running game was replaced: %q", got)
+ }
+
+ playing = false
+ if _, err := env.engine.SyncGame(context.Background(), "game1", []Peer{env.peer}); err != nil {
+ t.Fatalf("after the session: %v", err)
+ }
+}
+
+// Another device playing the game answers "playing": nothing is compared or
+// stamped, and asking does not count as a failure.
+func TestPlaying_PeerPlayingIsWaitedFor(t *testing.T) {
+ env := setupEngine(t)
+ write(t, env.localDir, "slot1.sav", "here")
+ env.transport.manifestErr = errors.New(PlayingMessage)
+
+ res, err := env.engine.SyncWithPeer(context.Background(), "game1", env.peer)
+ if err != nil || res.Status != "peer_playing" {
+ t.Fatalf("status = %q, %v; want peer_playing", res.Status, err)
+ }
+ // A device on an older build reads the same answer as "busy".
+ if !isSettling(errors.New(PlayingMessage)) {
+ t.Error("the playing answer is not recognisable as busy by older builds")
+ }
+}
diff --git a/internal/p2p/wanclient_handlers.go b/internal/p2p/wanclient_handlers.go
index 9a899c0c..24acf679 100644
--- a/internal/p2p/wanclient_handlers.go
+++ b/internal/p2p/wanclient_handlers.go
@@ -616,6 +616,9 @@ func (w *WanClient) serveManifest(route string, body json.RawMessage, peerID str
if w.engine.holdingBack(game) {
return 404, map[string]string{"error": syncengine.HeldMessage}
}
+ if w.engine.Sync.PlayingHere(game.ID) {
+ return 503, map[string]string{"error": syncengine.PlayingMessage}
+ }
// As on the LAN: a save a sync here is writing is not described
// (syncengine/settle.go). Each request runs on its own goroutine, so the
// wait does not hold up the relay traffic that finishes the write.
@@ -702,6 +705,9 @@ func (w *WanClient) serveDeleteFile(route string, rawBody json.RawMessage, fromP
if err != nil {
return 404, map[string]string{"error": "Game not found."}
}
+ if w.engine.Sync.PlayingHere(game.ID) {
+ return 503, map[string]string{"error": syncengine.PlayingMessage}
+ }
if !delta.IsSafePath(game.SavePath, body.RelPath) {
return 403, map[string]string{"error": "invalid path"}
}
diff --git a/internal/presets/detectsettings.go b/internal/presets/detectsettings.go
new file mode 100644
index 00000000..5fe85f2b
--- /dev/null
+++ b/internal/presets/detectsettings.go
@@ -0,0 +1,361 @@
+package presets
+
+import (
+ "bytes"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode/utf16"
+)
+
+// Detecting a game's settings files by what they hold, for games the game
+// database does not cover (DeviceSettings covers the ones it does).
+//
+// What tells a device's settings from a save is its contents: resolution,
+// vsync, shadow quality, upscaler — the vocabulary of one machine's display,
+// which no save has a use for. Names were measured against the Ludusavi
+// manifest's own labels (16,700 settings entries, 9,000 save entries) and do
+// not tell them apart on their own: "settings" names 1,521 settings files and
+// 70 saves, ".ini" 1,831 and 137, ".json" 359 and 216. So a name only adds
+// weight to what the contents say, by how reliably the manifest shows it to
+// mean settings; a word that names saves rules a file out.
+//
+// What this misses stays syncing, which is how every file synced before; what
+// it wrongly takes is caught by how a save behaves — it changes with every
+// session — and given back (daemon/detectsettings.go).
+
+// DetectedSettings is one file found to be a game's settings.
+type DetectedSettings struct {
+ Path string // relative to the save folder, slash-separated
+ Reason string
+}
+
+const (
+ detectMaxSize = 256 << 10
+ detectMaxFiles = 4000 // text-like files read per folder, at most
+)
+
+var detectExts = map[string]bool{
+ "ini": true, "cfg": true, "conf": true, "config": true, "xml": true, "json": true, "lua": true,
+ "txt": true, "yaml": true, "yml": true, "toml": true, "vdf": true, "set": true, "settings": true,
+ "opt": true, "prefs": true, "local": true, "properties": true,
+}
+
+// Terms of a machine's display and graphics, as sequences of words. Matched
+// in identifiers split at case changes and separators, so
+// "_enableFrameGeneration", "r_materialAniso" and "Shadow Quality" all count.
+var strongTerms = [][]string{
+ {"resolution"}, {"fullscreen"}, {"windowed"}, {"borderless"}, {"vsync"}, {"v", "sync"},
+ {"antialiasing"}, {"anti", "aliasing"}, {"aniso"}, {"anisotropic"}, {"anisotropy"},
+ {"msaa"}, {"fxaa"}, {"taa"}, {"smaa"}, {"ssao"}, {"hbao"}, {"ssr"}, {"bloom"}, {"gamma"},
+ {"brightness"}, {"hdr"}, {"dlss"}, {"fsr"}, {"xess"}, {"upscaling"}, {"upscaler"},
+ {"monitor"}, {"adapter"}, {"gpu"}, {"vram"}, {"tessellation"}, {"raytracing"},
+ {"ray", "tracing"}, {"fov"}, {"field", "view"}, {"lod"}, {"framerate"}, {"frame", "rate"},
+ {"refreshrate"}, {"refresh", "rate"}, {"frame", "generation"}, {"frame", "limit"},
+ {"fps", "limit"}, {"max", "fps"}, {"texture", "quality"}, {"texture", "filtering"},
+ {"shadow", "quality"}, {"shadow", "resolution"}, {"motion", "blur"}, {"depth", "field"},
+ {"view", "distance"}, {"draw", "distance"}, {"render", "scale"}, {"resolution", "scale"},
+ {"display", "mode"}, {"window", "mode"}, {"screen", "mode"}, {"ambient", "occlusion"},
+ {"screen", "width"}, {"screen", "height"}, {"quality", "preset"}, {"graphics", "quality"},
+ {"film", "grain"}, {"lens", "flare"}, {"volumetric"}, {"scalability"}, {"supersampling"},
+ {"sharpening"}, {"chromatic", "aberration"}, {"vignette"}, {"directx"}, {"vulkan"},
+ {"dx11"}, {"dx12"}, {"d3d11"}, {"d3d12"},
+}
+
+var weakTerms = []string{
+ "width", "height", "quality", "preset", "driver", "spec", "shadows", "shadow", "texture",
+ "textures", "video", "display", "graphics", "render", "screen", "fps", "sync", "filtering",
+}
+
+// Words in a file name, weighted by how reliably the Ludusavi manifest shows
+// them to mean settings rather than a save.
+var nameWeights = map[string]float64{
+ "options": 1.5, "option": 1.5, "graphics": 1.5, "graphic": 1.5, "video": 1.5, "display": 1.5,
+ "render": 1.5, "engine": 1.5, "scalability": 1.5, "gameusersettings": 1.5, "screen": 1,
+ "settings": 0.5, "setting": 0.5, "config": 0.5, "configuration": 0.5, "cfg": 0.5,
+ "prefs": 0.5, "preferences": 0.5,
+}
+
+// Words that name saves somewhere in the manifest often enough that a file
+// carrying one is never taken, whatever it holds. Logs are not settings
+// either, though leaving them out would harm nothing.
+var saveWords = map[string]bool{
+ "save": true, "saves": true, "savegame": true, "savedata": true, "slot": true, "progress": true,
+ "autosave": true, "quicksave": true, "world": true, "player": true, "character": true,
+ "campaign": true, "level": true, "levels": true, "achievement": true, "achievements": true,
+ "achievments": true, "stats": true, "unlock": true, "unlocks": true, "inventory": true,
+ "profile": true, "career": true, "highscore": true, "highscores": true, "score": true,
+ "scores": true, "records": true, "besttimes": true, "gamedata": true, "log": true,
+ "logs": true, "crash": true, "output": true, "history": true,
+}
+
+var identRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`)
+var camelRe = regexp.MustCompile(`[A-Z]?[a-z]+|[A-Z]+(?:[^a-z]|$)|[0-9]+`)
+
+// words splits text into lowercase words: identifiers, then at case changes
+// ("FrameGeneration" → frame generation), keeping a whole short identifier
+// too ("dx11", "vsync").
+func words(text string) []string {
+ var out []string
+ for _, id := range identRe.FindAllString(text, -1) {
+ parts := camelRe.FindAllString(id, -1)
+ low := strings.ToLower(id)
+ if len(parts) > 1 && len(low) <= 6 {
+ out = append(out, low)
+ }
+ for _, p := range parts {
+ p = strings.ToLower(p)
+ if p == "of" || p == "the" || p == "b" || p == "r" || p == "e" {
+ continue // filler, and engine prefixes like r_ and bEnable
+ }
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// textOf returns a file's text, decoding UTF-16, or false for binary.
+func textOf(b []byte) (string, bool) {
+ switch {
+ case bytes.HasPrefix(b, []byte{0xFF, 0xFE}), bytes.HasPrefix(b, []byte{0xFE, 0xFF}):
+ big := b[0] == 0xFE
+ b = b[2:]
+ u := make([]uint16, len(b)/2)
+ for i := range u {
+ if big {
+ u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1])
+ } else {
+ u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
+ }
+ }
+ return string(utf16.Decode(u)), true
+ case len(b) >= 4 && b[1] == 0 && b[3] == 0 && b[0] != 0 && b[2] != 0:
+ // UTF-16LE without a mark, as some engines write their XML.
+ u := make([]uint16, len(b)/2)
+ for i := range u {
+ u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
+ }
+ return string(utf16.Decode(u)), true
+ case bytes.IndexByte(b, 0) >= 0:
+ return "", false
+ }
+ return string(b), true
+}
+
+// contentScore counts the distinct display terms a text holds.
+func contentScore(text string) (strong int, score float64) {
+ ws := words(text)
+ present := make(map[string]bool, len(ws))
+ pairs := make(map[[2]string]bool, len(ws))
+ for i, w := range ws {
+ present[w] = true
+ if i+1 < len(ws) {
+ pairs[[2]string{w, ws[i+1]}] = true
+ }
+ }
+ for _, t := range strongTerms {
+ if (len(t) == 1 && present[t[0]]) || (len(t) == 2 && pairs[[2]string{t[0], t[1]}]) {
+ strong++
+ }
+ }
+ weak := 0.0
+ for _, w := range weakTerms {
+ if present[w] {
+ weak += 0.5
+ }
+ }
+ if weak > 2 {
+ weak = 2
+ }
+ return strong, float64(strong) + weak
+}
+
+// nameSignal weighs a file's name: the support it lends, and whether a word
+// in it names saves.
+func nameSignal(rel string) (weight float64, saveWord, strongName bool) {
+ base := path.Base(rel)
+ ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), "."))
+ stem := strings.TrimSuffix(base, path.Ext(base))
+ ws := words(stem)
+ low := strings.ToLower(stem)
+ for w := range nameWeights {
+ if len(w) >= 6 && strings.Contains(low, w) {
+ ws = append(ws, w)
+ }
+ }
+ seen := map[string]bool{}
+ for _, w := range ws {
+ if seen[w] {
+ continue
+ }
+ seen[w] = true
+ if saveWords[w] {
+ saveWord = true
+ }
+ weight += nameWeights[w]
+ if nameWeights[w] >= 1.5 {
+ strongName = true
+ }
+ }
+ if strings.Contains(low, "save") || strings.Contains(low, "slot") || ext == "log" {
+ saveWord = true
+ }
+ if ext == "ini" || ext == "cfg" || ext == "conf" {
+ weight += 0.5
+ }
+ if weight > 2 {
+ weight = 2
+ }
+ return weight, saveWord, strongName
+}
+
+// unrealSettings reports a file in an Unreal Engine game's settings folder,
+// Saved/Config/