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_savefiles.go b/internal/api/routes_savefiles.go
index 3f7248ad..bfdba491 100644
--- a/internal/api/routes_savefiles.go
+++ b/internal/api/routes_savefiles.go
@@ -1,6 +1,7 @@
package api
import (
+ "errors"
"net/http"
"os"
"path/filepath"
@@ -8,6 +9,7 @@ import (
"strings"
"github.com/go-chi/chi/v5"
+ "github.com/opensave/opensave/internal/daemon"
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/ignore"
)
@@ -38,6 +40,11 @@ type saveFile struct {
MtimeMs int64 `json:"mtimeMs"`
// Excluded reports whether the current rules stop this file syncing.
Excluded bool `json:"excluded"`
+ // Why: "rule" (the game's own exclusion list), "database" (the game
+ // database names it as the game's settings) or "detected" (found to be
+ // its settings by what it holds). Empty when it syncs.
+ Source string `json:"source,omitempty"`
+ Reason string `json:"reason,omitempty"`
}
// saveFileListCap bounds the response. A save folder with more files than
@@ -67,6 +74,25 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
// With the game's device settings, which are left out the same way.
text := s.Daemon.IgnoreText(game)
rules := ignore.Parse(text)
+ ownRules := ignore.Parse(game.SyncIgnore)
+ var settings []daemon.DeviceSetting
+ if !game.SyncDeviceSettings {
+ settings = s.Daemon.DeviceSettings(game)
+ }
+ // which says what excludes a file the rules exclude: the game's own
+ // rules first, as they apply last; then the last device-settings entry
+ // that names it.
+ which := func(path string) (string, string) {
+ if !ownRules.Empty() && ownRules.Match(path) {
+ return "rule", ""
+ }
+ for i := len(settings) - 1; i >= 0; i-- {
+ if settings[i].Source != "save" && ignore.Parse(settings[i].Pattern).Match(path) {
+ return settings[i].Source, settings[i].Reason
+ }
+ }
+ return "rule", ""
+ }
roots := []struct{ name, path string }{{"", game.SavePath}}
if extra, rootsErr := s.Daemon.Store.ListGameRoots(gameID); rootsErr == nil {
@@ -96,6 +122,11 @@ func (s *Server) handleGameSaveFiles(w http.ResponseWriter, r *http.Request) {
for _, f := range files {
f.Location = root.name
f.Excluded = rules.Match(f.Path)
+ if f.Excluded && root.name == "" {
+ f.Source, f.Reason = which(f.Path)
+ } else if f.Excluded {
+ f.Source = "rule"
+ }
out = append(out, f)
}
}
@@ -204,3 +235,33 @@ func listSaveFiles(root string, limit int) ([]saveFile, bool) {
}
return out, truncated
}
+
+// handleDecideSuggestedSettings answers a suggested settings file from the
+// game's configuration tab: {"path": "...", "keepHere": true} leaves it out of
+// syncing as this device's settings, false keeps it syncing for good.
+func (s *Server) handleDecideSuggestedSettings(w http.ResponseWriter, r *http.Request) {
+ gameID := chi.URLParam(r, "gameId")
+ game, err := s.Daemon.Store.GetGame(gameID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "Game not found.")
+ return
+ }
+ var body struct {
+ Path string `json:"path"`
+ KeepHere bool `json:"keepHere"`
+ }
+ if err := readJSON(r, &body); err != nil || body.Path == "" {
+ writeError(w, http.StatusBadRequest, "path is required")
+ return
+ }
+ if err := s.Daemon.DecideSuggestedSettings(game, body.Path, body.KeepHere); err != nil {
+ if errors.Is(err, daemon.ErrNotSuggested) {
+ writeError(w, http.StatusConflict, err.Error())
+ return
+ }
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ s.BroadcastGamesUpdate()
+ writeJSON(w, http.StatusOK, map[string]bool{"success": true})
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 4d40a280..30de9e21 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -566,7 +566,10 @@ func (s *Server) gamePayload(g store.Game) map[string]any {
"syncIgnore": g.SyncIgnore,
// The files the game database names as this game's settings, left
// out of syncing unless syncDeviceSettings (daemon/devicesettings.go).
- "deviceSettings": s.Daemon.DeviceSettings(g),
+ "deviceSettings": s.Daemon.DeviceSettings(g),
+ // Found by what they hold, and waiting to be confirmed; they keep
+ // syncing meanwhile (daemon.SuggestedSettings).
+ "suggestedSettings": s.Daemon.SuggestedSettings(g),
"syncDeviceSettings": g.SyncDeviceSettings,
"branches": branches,
"createdAt": g.CreatedAt,
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 8abd9a26..48fe3469 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -186,6 +186,7 @@ func New(opts Options) (*Daemon, error) {
// A game's device settings are left out of syncing (devicesettings.go).
d.P2P.Sync.DeviceSettingsFor = d.deviceSettingsFor
+ d.P2P.Sync.ObserveSettings = d.observeSettings
// A restore or a branch switch rewrites a save folder; nothing syncing it
// may read it half-way (syncengine/settle.go).
@@ -274,6 +275,9 @@ func New(opts Options) (*Daemon, error) {
return d.P2P != nil && d.P2P.Sync != nil && d.P2P.Sync.WritingNow(gameID)
},
OnChanged: func(gameID string) {
+ // How the files left out as its settings changed with it
+ // (detectsettings.go).
+ d.observeLocalSettings(gameID)
// An emptied save is noticed as it happens, and said on screen,
// even with no other device online to hold it back from.
_, _ = d.P2P.Sync.CheckHold(gameID, false)
@@ -459,6 +463,29 @@ func (d *Daemon) Start() error {
}
})
+ // Find each game's settings by what they hold (detectsettings.go): a few
+ // minutes after start, then hourly for whatever is due.
+ d.P2P.GoSync(func(ctx context.Context) {
+ first := time.NewTimer(newGameFirstScan)
+ defer first.Stop()
+ select {
+ case <-ctx.Done():
+ return
+ case <-first.C:
+ d.detectSettingsDue(ctx)
+ }
+ ticker := time.NewTicker(time.Hour)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ d.detectSettingsDue(ctx)
+ }
+ }
+ })
+
// Look for newly installed games: a few minutes after start, then every
// hour. See newgames.go.
d.P2P.GoSync(func(ctx context.Context) {
diff --git a/internal/daemon/detectsettings.go b/internal/daemon/detectsettings.go
new file mode 100644
index 00000000..e57992dc
--- /dev/null
+++ b/internal/daemon/detectsettings.go
@@ -0,0 +1,290 @@
+package daemon
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/opensave/opensave/internal/delta"
+ "github.com/opensave/opensave/internal/ignore"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// Finding a game's settings by what they hold, and giving back what turns out
+// to be a save.
+//
+// Detection (presets/detectsettings.go) takes a file for settings by its
+// contents. Before anything is left out, and again at every later run, the
+// game's snapshots are asked how the file behaved (snapshot/history.go). And
+// as long as a file is left out, how it behaves is watched: in this device's
+// automatic snapshots, and in what every other device sends (observeSettings).
+// A settings file is changed now and then; a save changes with the sessions
+// played. One that changes along with the rest of the save in most of them is
+// a save after all: it syncs again, for good, whatever named it — this
+// detection or the game database — and the log says which and why.
+//
+// Errors cost little either way. A settings file taken for a save syncs, as
+// every file did before. A save taken for settings is still in every
+// snapshot, and comes back to syncing at the latest when it has been seen to
+// change with the save a few times.
+
+// detectorVersion moves when detection changes, so every game is judged again.
+const detectorVersion = "1"
+
+const (
+ observeMinMoved = 3 // changes along with the save before a file is given back
+ detectEvery = 24 * time.Hour
+)
+
+func settingsDetectMark(gameID string) string { return "settings_detect:" + gameID }
+
+// detectSettingsDue runs detection for every game not judged under this
+// detector and folder yet, or not for a day. In the background, from Start.
+func (d *Daemon) detectSettingsDue(ctx context.Context) {
+ games, err := d.Store.ListGames()
+ if err != nil {
+ return
+ }
+ for _, game := range games {
+ if ctx.Err() != nil {
+ return
+ }
+ // Not while it is played: that is when the game writes its saves, and
+ // a file being read cannot be replaced (presets.readForJudging). Its
+ // turn comes at the next run after the session.
+ if !d.PlayingSince(game.ID).IsZero() {
+ continue
+ }
+ mark := d.Store.Mark(settingsDetectMark(game.ID))
+ want := detectorVersion + "|" + game.SavePath
+ if prev, at, ok := strings.Cut(mark, "@"); ok && prev == want {
+ if t, err := time.Parse(time.RFC3339, at); err == nil && time.Since(t) < detectEvery {
+ continue
+ }
+ }
+ d.DetectSettings(game)
+ _ = d.Store.SetMark(settingsDetectMark(game.ID), want+"@"+time.Now().UTC().Format(time.RFC3339))
+ }
+}
+
+// DetectSettings judges a game's files, records what is new, and re-asks the
+// snapshots about everything taken for settings so far.
+func (d *Daemon) DetectSettings(game store.Game) {
+ if d.Scanner == nil || game.SavePath == "" || SaveFolderMissing(game.SavePath) {
+ return
+ }
+ d.devMu.Lock()
+ db := d.devDBLocked()
+ d.devMu.Unlock()
+
+ known := map[string]store.SettingsFile{}
+ if verdicts, err := d.Store.SettingsFiles(game.ID); err == nil {
+ for _, v := range verdicts {
+ known[v.Path] = v
+ }
+ }
+ judged := make(map[string]bool, len(known))
+ for p := range known {
+ judged[p] = true
+ }
+ found := db.DetectSettings(game.Name, game.AppID, game.SavePath, judged)
+ var ask []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; !ok {
+ ask = append(ask, f.Path)
+ }
+ }
+ for p, v := range known {
+ if v.Verdict == store.VerdictSettings || v.Verdict == store.VerdictSuggested {
+ ask = append(ask, p)
+ }
+ }
+ saves := d.Snapshots.ChangesLikeASave(game.ID, ask)
+
+ // What is found here is only suggested: it keeps syncing until someone
+ // confirms it in the game's configuration tab (store.VerdictSuggested).
+ // Only what was confirmed is left out, and given back if it changes like a
+ // save; a suggestion that does is withdrawn the same way.
+ excluded, suggested := false, false
+ var offered, given []string
+ for _, f := range found {
+ if _, ok := known[f.Path]; ok {
+ continue
+ }
+ v := store.SettingsFile{GameID: game.ID, Path: f.Path, Verdict: store.VerdictSuggested, Reason: f.Reason}
+ if why, isSave := saves[f.Path]; isSave {
+ v.Verdict, v.Reason = store.VerdictSave, why
+ } else {
+ offered = append(offered, f.Path)
+ }
+ if d.Store.SetSettingsFile(v) == nil {
+ suggested = true
+ }
+ }
+ for p, v := range known {
+ why, isSave := saves[p]
+ if !isSave || (v.Verdict != store.VerdictSettings && v.Verdict != store.VerdictSuggested) {
+ continue
+ }
+ was := v.Verdict
+ v.Verdict, v.Reason, v.AtMs = store.VerdictSave, why, 0
+ if d.Store.SetSettingsFile(v) != nil {
+ continue
+ }
+ if was == store.VerdictSettings {
+ excluded = true
+ given = append(given, p+" ("+why+")")
+ } else {
+ suggested = true
+ }
+ }
+ if suggested {
+ d.forgetVerdicts(game.ID)
+ if d.OnGameChanged != nil {
+ d.OnGameChanged(game.ID)
+ }
+ }
+ if excluded {
+ d.exclusionsChanged(game)
+ }
+ if len(offered) > 0 && !game.SyncDeviceSettings {
+ d.Log.Log("info", fmt.Sprintf("%q: %s %s like this device's settings rather than the save. %s still syncing; "+
+ "to keep %s on each device instead, confirm %s in the game's configuration tab.",
+ game.Name, strings.Join(offered, ", "), lookLike(len(offered)), itTheyAre(len(offered)), itThem(len(offered)), itThem(len(offered))))
+ }
+ if len(given) > 0 {
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// exclusionsChanged carries a game's recorded hashes over to its exclusion
+// list as it is now (adoptExclusionView), after detection changed it.
+func (d *Daemon) exclusionsChanged(game store.Game) {
+ d.forgetVerdicts(game.ID)
+ if fresh, err := d.Store.GetGame(game.ID); err == nil {
+ game = fresh
+ }
+ d.adoptExclusionView([]store.Game{game})
+}
+
+// observeSettings is told what a device holds of a game — another device's
+// manifest as it arrives, or this device's own at an automatic snapshot — and
+// keeps count, for every file left out as this game's settings, of how often
+// it changed when the rest of the save did. peerID is "" for this device.
+func (d *Daemon) observeSettings(game store.Game, peerID string, m delta.Manifest) {
+ if game.SyncDeviceSettings {
+ return
+ }
+ settings := d.deviceSettingsFor(game)
+ if len(settings) == 0 {
+ return
+ }
+ asSettings := ignore.Parse(strings.Join(settings, "\n"))
+ var watched []string
+ rest := delta.Manifest{Files: map[string]delta.FileEntry{}}
+ for p, f := range m.Files {
+ if asSettings.Match(p) {
+ watched = append(watched, p)
+ } else {
+ rest.Files[p] = f
+ }
+ }
+ if len(watched) == 0 {
+ return
+ }
+ user := ignore.Parse(game.SyncIgnore)
+ for p := range rest.Files {
+ if delta.NeverSynced(p) || (!user.Empty() && user.Match(p)) {
+ delete(rest.Files, p)
+ }
+ }
+ restHash := rest.ManifestHash()
+
+ // The rest of the save, as last seen from this device.
+ r := d.Store.GetSettingsObservation(game.ID, peerID, "")
+ saveMoved := r.LastHash != "" && r.LastHash != restHash
+ first := r.LastHash == ""
+ r.LastHash = restHash
+ _ = d.Store.SaveSettingsObservation(r)
+
+ var given []string
+ for _, p := range watched {
+ o := d.Store.GetSettingsObservation(game.ID, peerID, p)
+ h := m.Files[p].Hash
+ if saveMoved && o.LastHash != "" {
+ if o.LastHash != h {
+ o.Moved++
+ } else {
+ o.Still++
+ }
+ }
+ o.LastHash = h
+ _ = d.Store.SaveSettingsObservation(o)
+ if first || !saveMoved {
+ continue
+ }
+ moved, still := 0, 0
+ if all, err := d.Store.SettingsObservations(game.ID, p); err == nil {
+ for _, x := range all {
+ moved += x.Moved
+ still += x.Still
+ }
+ }
+ if moved >= observeMinMoved && moved > still {
+ why := fmt.Sprintf("changed in %d of the %d times the save changed", moved, moved+still)
+ if d.Store.SetSettingsFile(store.SettingsFile{GameID: game.ID, Path: p, Verdict: store.VerdictSave, Reason: why}) == nil {
+ given = append(given, p+" ("+why+")")
+ }
+ }
+ }
+ if len(given) > 0 {
+ d.exclusionsChanged(game)
+ d.Log.Log("warn", fmt.Sprintf("%q: %s changed along with the save, so %s part of the save after all — syncing again.",
+ game.Name, strings.Join(given, ", "), itThey(len(given))))
+ }
+}
+
+// observeLocalSettings is observeSettings for this device's own save, at an
+// automatic snapshot.
+func (d *Daemon) observeLocalSettings(gameID string) {
+ game, err := d.Store.GetGame(gameID)
+ if err != nil {
+ return
+ }
+ m, err := delta.BuildManifest(filepath.Clean(game.SavePath))
+ if err != nil {
+ return
+ }
+ d.observeSettings(game, "", m)
+}
+
+func itThey(n int) string {
+ if n == 1 {
+ return "it is"
+ }
+ return "they are"
+}
+
+func lookLike(n int) string {
+ if n == 1 {
+ return "looks"
+ }
+ return "look"
+}
+
+func itTheyAre(n int) string {
+ if n == 1 {
+ return "It is"
+ }
+ return "They are"
+}
+
+func itThem(n int) string {
+ if n == 1 {
+ return "it"
+ }
+ return "them"
+}
diff --git a/internal/daemon/devicesettings.go b/internal/daemon/devicesettings.go
index a50b12e7..b581ec86 100644
--- a/internal/daemon/devicesettings.go
+++ b/internal/daemon/devicesettings.go
@@ -1,6 +1,8 @@
package daemon
import (
+ "errors"
+
"github.com/opensave/opensave/internal/delta"
"github.com/opensave/opensave/internal/presets"
"github.com/opensave/opensave/internal/store"
@@ -23,29 +25,93 @@ import (
// is what the next start reads.
type devSettingsCache struct {
- loaded bool
- db *presets.DeviceSettings
+ loaded bool
+ db *presets.DeviceSettings
+ // patterns: the game database's, by game name, App ID and folder.
patterns map[string][]string
+ // verdicts: what was found here (detectsettings.go), by game ID.
+ verdicts map[string][]store.SettingsFile
}
-func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+// devDB returns the game database for device settings, read when first
+// needed. Called with devMu held. Not waiting for an index due a rebuild
+// (DeviceSettingsNoWait): this runs while the daemon starts.
+func (d *Daemon) devDBLocked() *presets.DeviceSettings {
+ if !d.dev.loaded {
+ d.dev.db = d.Scanner.DeviceSettingsNoWait()
+ d.dev.loaded = true
+ }
+ if d.dev.patterns == nil {
+ d.dev.patterns = map[string][]string{}
+ }
+ if d.dev.verdicts == nil {
+ d.dev.verdicts = map[string][]store.SettingsFile{}
+ }
+ return d.dev.db
+}
+
+// DeviceSetting is one entry of a game's device settings, for the window.
+type DeviceSetting struct {
+ Pattern string `json:"pattern"`
+ // Source: "database" (the game database names it), "detected" (found by
+ // what it holds), or "save" (shown to change with the save, so it syncs
+ // whatever named it).
+ Source string `json:"source"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// deviceSettingsList is every entry, in the order they apply: the game
+// database's, what was detected here, then what was shown to be a save —
+// as "!" lines, which bring a file back whatever named it before.
+func (d *Daemon) deviceSettingsList(game store.Game) []DeviceSetting {
if d.Scanner == nil {
return nil
}
key := game.Name + "\x00" + game.AppID + "\x00" + game.SavePath
d.devMu.Lock()
defer d.devMu.Unlock()
- if !d.dev.loaded {
- d.dev.db = d.Scanner.DeviceSettingsNoWait()
- d.dev.patterns = map[string][]string{}
- d.dev.loaded = true
+ db := d.devDBLocked()
+ pats, ok := d.dev.patterns[key]
+ if !ok {
+ pats = db.Patterns(game.Name, game.AppID, []string{game.SavePath})
+ d.dev.patterns[key] = pats
+ }
+ verdicts, ok := d.dev.verdicts[game.ID]
+ if !ok {
+ verdicts, _ = d.Store.SettingsFiles(game.ID)
+ d.dev.verdicts[game.ID] = verdicts
}
- if p, ok := d.dev.patterns[key]; ok {
- return p
+ var out []DeviceSetting
+ for _, p := range pats {
+ out = append(out, DeviceSetting{Pattern: p, Source: "database"})
}
- p := d.dev.db.Patterns(game.Name, game.AppID, []string{game.SavePath})
- d.dev.patterns[key] = p
- return p
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSettings {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "detected", Reason: v.Reason})
+ }
+ }
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSave {
+ out = append(out, DeviceSetting{Pattern: "!/" + v.Path, Source: "save", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+func (d *Daemon) deviceSettingsFor(game store.Game) []string {
+ list := d.deviceSettingsList(game)
+ out := make([]string, 0, len(list))
+ for _, s := range list {
+ out = append(out, s.Pattern)
+ }
+ return out
+}
+
+// forgetVerdicts drops a game's cached verdicts after they changed.
+func (d *Daemon) forgetVerdicts(gameID string) {
+ d.devMu.Lock()
+ delete(d.dev.verdicts, gameID)
+ d.devMu.Unlock()
}
// IgnoreText is the exclusion list a game is synced under (see
@@ -57,10 +123,10 @@ func (d *Daemon) IgnoreText(game store.Game) string {
return d.P2P.Sync.IgnoreText(game)
}
-// DeviceSettings returns the patterns for a game's device settings, whether
-// or not it is set to sync them — what the window lists.
-func (d *Daemon) DeviceSettings(game store.Game) []string {
- return d.deviceSettingsFor(game)
+// DeviceSettings returns a game's device settings, whether or not it is set
+// to sync them — what the window lists.
+func (d *Daemon) DeviceSettings(game store.Game) []DeviceSetting {
+ return d.deviceSettingsList(game)
}
// adoptExclusionView carries a game's last-snapshot hash over to what the game
@@ -97,3 +163,49 @@ func (d *Daemon) adoptExclusionView(games []store.Game) {
}
}
}
+
+// SuggestedSettings is what was found here by what it holds and still waits
+// for an answer in the game's configuration tab. Each keeps syncing until it
+// is confirmed (store.VerdictSuggested).
+func (d *Daemon) SuggestedSettings(game store.Game) []DeviceSetting {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return nil
+ }
+ var out []DeviceSetting
+ for _, v := range verdicts {
+ if v.Verdict == store.VerdictSuggested {
+ out = append(out, DeviceSetting{Pattern: "/" + v.Path, Source: "suggested", Reason: v.Reason})
+ }
+ }
+ return out
+}
+
+// ErrNotSuggested is an answer about a file that is not waiting for one.
+var ErrNotSuggested = errors.New("that file is not a suggested settings file")
+
+// DecideSuggestedSettings records the answer to a suggestion: keepHere leaves
+// the file out of syncing as this game's settings from now on, like the game
+// database's (and gives it back if it then changes like a save); otherwise it
+// keeps syncing, and is not suggested again.
+func (d *Daemon) DecideSuggestedSettings(game store.Game, path string, keepHere bool) error {
+ verdicts, err := d.Store.SettingsFiles(game.ID)
+ if err != nil {
+ return err
+ }
+ for _, v := range verdicts {
+ if v.Path != path || v.Verdict != store.VerdictSuggested {
+ continue
+ }
+ v.Verdict, v.AtMs = store.VerdictDeclined, 0
+ if keepHere {
+ v.Verdict = store.VerdictSettings
+ }
+ if err := d.Store.SetSettingsFile(v); err != nil {
+ return err
+ }
+ d.exclusionsChanged(game)
+ return nil
+ }
+ return ErrNotSuggested
+}
diff --git a/internal/daemon/devicesettings_test.go b/internal/daemon/devicesettings_test.go
index 2b94c1a4..b3ae2637 100644
--- a/internal/daemon/devicesettings_test.go
+++ b/internal/daemon/devicesettings_test.go
@@ -1,11 +1,13 @@
package daemon
import (
+ "fmt"
"os"
"path/filepath"
"testing"
"github.com/opensave/opensave/internal/delta"
+ "github.com/opensave/opensave/internal/ignore"
"github.com/opensave/opensave/internal/store"
"github.com/opensave/opensave/internal/watcher"
)
@@ -49,7 +51,7 @@ func TestAdoptExclusionView_CarriesTheLastSnapshotHashOver(t *testing.T) {
delta.InvalidateRoot(changed.SavePath)
d.devMu.Lock()
- d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}}
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
for _, g := range []store.Game{same, changed} {
d.dev.patterns[g.Name+"\x00"+g.AppID+"\x00"+g.SavePath] = []string{"/graphics.xml"}
}
@@ -82,3 +84,55 @@ func mustGame(t *testing.T, d *Daemon, id string) store.Game {
}
return g
}
+
+// A file taken for settings that another device's copy shows changing with
+// every change of the save is given back to syncing; one that stays put while
+// the save moves stays left out.
+func TestObserveSettings_GivesBackWhatChangesLikeASave(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ game := store.Game{ID: "g", Name: "g", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ for _, p := range []string{"progress.cfg", "video.cfg"} {
+ if err := d.Store.SetSettingsFile(store.SettingsFile{GameID: "g", Path: p, Verdict: store.VerdictSettings, Reason: "test"}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+ if !excluded("progress.cfg") || !excluded("video.cfg") {
+ t.Fatal("setup: the detected files are not left out")
+ }
+
+ manifest := func(i int, video string) delta.Manifest {
+ return delta.Manifest{Files: map[string]delta.FileEntry{
+ "slot1.sav": {Hash: fmt.Sprint("save", i)},
+ "progress.cfg": {Hash: fmt.Sprint("progress", i)},
+ "video.cfg": {Hash: video},
+ }}
+ }
+ d.observeSettings(game, "peerA", manifest(0, "1080p"))
+ for i := 1; i <= 2; i++ {
+ d.observeSettings(game, "peerA", manifest(i, "1080p"))
+ }
+ if !excluded("progress.cfg") {
+ t.Fatal("given back after two changes: too eager")
+ }
+ d.observeSettings(game, "peerA", manifest(3, "1080p"))
+ if excluded("progress.cfg") {
+ t.Error("a file that changed with the save three times out of three is still left out")
+ }
+ if !excluded("video.cfg") {
+ t.Error("a settings file that stayed put was given back")
+ }
+ v, _ := d.Store.SettingsFiles("g")
+ for _, f := range v {
+ if f.Path == "progress.cfg" && f.Verdict != store.VerdictSave {
+ t.Errorf("verdict %q, want save", f.Verdict)
+ }
+ }
+}
diff --git a/internal/daemon/suggested_settings_test.go b/internal/daemon/suggested_settings_test.go
new file mode 100644
index 00000000..2e1c576d
--- /dev/null
+++ b/internal/daemon/suggested_settings_test.go
@@ -0,0 +1,113 @@
+package daemon
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/opensave/opensave/internal/ignore"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// A file found by what it holds is only suggested: it keeps syncing until
+// someone confirms it in the game's configuration tab, and a file someone
+// chose to keep syncing is not suggested again (asked in review on #118).
+func TestSuggestedSettings_SyncUntilConfirmed(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"
+ for name, content := range map[string]string{
+ "slot1.sav": "progress",
+ "videoconfig.txt": settings,
+ "display.cfg": settings,
+ } {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ }
+ game := store.Game{ID: "g", Name: "No Such Game In The Database", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+ excluded := func(p string) bool { return ignore.Parse(d.IgnoreText(game)).Match(p) }
+
+ d.DetectSettings(game)
+ suggested := map[string]bool{}
+ for _, s := range d.SuggestedSettings(game) {
+ suggested[s.Pattern] = true
+ }
+ if !suggested["/videoconfig.txt"] || !suggested["/display.cfg"] {
+ t.Fatalf("setup: the settings files were not suggested: %v", suggested)
+ }
+ if suggested["/slot1.sav"] {
+ t.Error("the save was suggested as settings")
+ }
+ if excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Fatal("a suggested file was left out of syncing before anyone confirmed it")
+ }
+
+ if err := d.DecideSuggestedSettings(game, "videoconfig.txt", true); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", false); err != nil {
+ t.Fatal(err)
+ }
+ if !excluded("videoconfig.txt") {
+ t.Error("a confirmed settings file still syncs")
+ }
+ if excluded("display.cfg") {
+ t.Error("a file chosen to keep syncing was left out")
+ }
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("answered files are still suggested: %v", d.SuggestedSettings(game))
+ }
+
+ // Detection runs again: neither answer is undone, nothing suggested anew.
+ d.DetectSettings(game)
+ if len(d.SuggestedSettings(game)) != 0 {
+ t.Errorf("a file already answered was suggested again: %v", d.SuggestedSettings(game))
+ }
+ if !excluded("videoconfig.txt") || excluded("display.cfg") {
+ t.Error("running detection again changed what was answered")
+ }
+ if err := d.DecideSuggestedSettings(game, "display.cfg", true); err != ErrNotSuggested {
+ t.Errorf("answering a file that is not suggested: %v, want ErrNotSuggested", err)
+ }
+}
+
+// A game's files are not read for detection while it is played — that is
+// when it writes its saves, and a file being read cannot be replaced — and
+// are once the session is over.
+func TestDetectSettings_NotWhileTheGameIsPlayed(t *testing.T) {
+ d := newTestDaemon(t)
+ dir := t.TempDir()
+ settings := "setting.fullscreen 1\nsetting.mat_vsync 0\nsetting.gamma 1\nsetting.defaultres 1920\nsetting.ssao 1\n"
+ if err := os.WriteFile(filepath.Join(dir, "videoconfig.txt"), []byte(settings), 0o666); err != nil {
+ t.Fatal(err)
+ }
+ game := store.Game{ID: "played", Name: "No Such Game In The Database", SavePath: dir, ActiveBranch: "main", MaxSnapshots: 5}
+ if err := d.Store.CreateGame(game); err != nil {
+ t.Fatal(err)
+ }
+ d.devMu.Lock()
+ d.dev = devSettingsCache{loaded: true, patterns: map[string][]string{}, verdicts: map[string][]store.SettingsFile{}}
+ d.devMu.Unlock()
+
+ start := time.Now()
+ d.sessions.tracker.Begin(game.ID, start)
+ d.detectSettingsDue(context.Background())
+ if v, _ := d.Store.SettingsFiles(game.ID); len(v) != 0 {
+ t.Fatalf("detection ran while the game was played: %+v", v)
+ }
+
+ d.sessions.tracker.Finish(game.ID, start.Add(time.Minute))
+ d.detectSettingsDue(context.Background())
+ if len(d.SuggestedSettings(game)) != 1 {
+ t.Errorf("after the session, detection did not run: %+v", d.SuggestedSettings(game))
+ }
+}
diff --git a/internal/p2p/syncengine/engine.go b/internal/p2p/syncengine/engine.go
index 57a0a940..ea0f5af5 100644
--- a/internal/p2p/syncengine/engine.go
+++ b/internal/p2p/syncengine/engine.go
@@ -164,6 +164,10 @@ type Engine struct {
// settings (presets/devicesettings.go), set by the daemon; nil leaves
// them syncing. See IgnoreText.
DeviceSettingsFor func(store.Game) []string
+ // ObserveSettings is told what another device holds of a game, as its
+ // manifest arrives, to watch how the files left out as the game's
+ // settings change (daemon/detectsettings.go). Set by the daemon.
+ ObserveSettings func(game store.Game, peerID string, remote delta.Manifest)
}
// New creates an Engine.
@@ -554,6 +558,11 @@ func (e *Engine) SyncWithPeer(ctx context.Context, gameID string, peer Peer) (Re
// would propagate a deletion of the very file being protected.
ignoreRules := e.rulesFor(gameID)
unfilteredLocal, unfilteredRemote := localManifest, remoteData.Manifest
+ // How the files left out as the game's settings change on the other
+ // device (daemon/detectsettings.go).
+ if e.ObserveSettings != nil && len(unfilteredRemote.Files) > 0 {
+ e.ObserveSettings(game, peer.ID, unfilteredRemote)
+ }
// Filtered always, not only with rules: launcher files (delta.NeverSynced)
// are left out the same way.
localManifest = filterManifest(localManifest, ignoreRules)
diff --git a/internal/presets/detectsettings.go b/internal/presets/detectsettings.go
new file mode 100644
index 00000000..685635f5
--- /dev/null
+++ b/internal/presets/detectsettings.go
@@ -0,0 +1,388 @@
+package presets
+
+import (
+ "bytes"
+ "io"
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "unicode/utf16"
+
+ "github.com/opensave/opensave/internal/fsx"
+)
+
+// Detecting a game's settings files by what they hold, for games the game
+// database does not cover (DeviceSettings covers the ones it does).
+//
+// What tells a device's settings from a save is its contents: resolution,
+// vsync, shadow quality, upscaler — the vocabulary of one machine's display,
+// which no save has a use for. Names were measured against the Ludusavi
+// manifest's own labels (16,700 settings entries, 9,000 save entries) and do
+// not tell them apart on their own: "settings" names 1,521 settings files and
+// 70 saves, ".ini" 1,831 and 137, ".json" 359 and 216. So a name only adds
+// weight to what the contents say, by how reliably the manifest shows it to
+// mean settings; a word that names saves rules a file out.
+//
+// What this misses stays syncing, which is how every file synced before; what
+// it wrongly takes is caught by how a save behaves — it changes with every
+// session — and given back (daemon/detectsettings.go).
+
+// DetectedSettings is one file found to be a game's settings.
+type DetectedSettings struct {
+ Path string // relative to the save folder, slash-separated
+ Reason string
+}
+
+const (
+ detectMaxSize = 256 << 10
+ detectMaxFiles = 4000 // text-like files read per folder, at most
+)
+
+var detectExts = map[string]bool{
+ "ini": true, "cfg": true, "conf": true, "config": true, "xml": true, "json": true, "lua": true,
+ "txt": true, "yaml": true, "yml": true, "toml": true, "vdf": true, "set": true, "settings": true,
+ "opt": true, "prefs": true, "local": true, "properties": true,
+}
+
+// Terms of a machine's display and graphics, as sequences of words. Matched
+// in identifiers split at case changes and separators, so
+// "_enableFrameGeneration", "r_materialAniso" and "Shadow Quality" all count.
+var strongTerms = [][]string{
+ {"resolution"}, {"fullscreen"}, {"windowed"}, {"borderless"}, {"vsync"}, {"v", "sync"},
+ {"antialiasing"}, {"anti", "aliasing"}, {"aniso"}, {"anisotropic"}, {"anisotropy"},
+ {"msaa"}, {"fxaa"}, {"taa"}, {"smaa"}, {"ssao"}, {"hbao"}, {"ssr"}, {"bloom"}, {"gamma"},
+ {"brightness"}, {"hdr"}, {"dlss"}, {"fsr"}, {"xess"}, {"upscaling"}, {"upscaler"},
+ {"monitor"}, {"adapter"}, {"gpu"}, {"vram"}, {"tessellation"}, {"raytracing"},
+ {"ray", "tracing"}, {"fov"}, {"field", "view"}, {"lod"}, {"framerate"}, {"frame", "rate"},
+ {"refreshrate"}, {"refresh", "rate"}, {"frame", "generation"}, {"frame", "limit"},
+ {"fps", "limit"}, {"max", "fps"}, {"texture", "quality"}, {"texture", "filtering"},
+ {"shadow", "quality"}, {"shadow", "resolution"}, {"motion", "blur"}, {"depth", "field"},
+ {"view", "distance"}, {"draw", "distance"}, {"render", "scale"}, {"resolution", "scale"},
+ {"display", "mode"}, {"window", "mode"}, {"screen", "mode"}, {"ambient", "occlusion"},
+ {"screen", "width"}, {"screen", "height"}, {"quality", "preset"}, {"graphics", "quality"},
+ {"film", "grain"}, {"lens", "flare"}, {"volumetric"}, {"scalability"}, {"supersampling"},
+ {"sharpening"}, {"chromatic", "aberration"}, {"vignette"}, {"directx"}, {"vulkan"},
+ {"dx11"}, {"dx12"}, {"d3d11"}, {"d3d12"},
+}
+
+var weakTerms = []string{
+ "width", "height", "quality", "preset", "driver", "spec", "shadows", "shadow", "texture",
+ "textures", "video", "display", "graphics", "render", "screen", "fps", "sync", "filtering",
+}
+
+// Words in a file name, weighted by how reliably the Ludusavi manifest shows
+// them to mean settings rather than a save.
+var nameWeights = map[string]float64{
+ "options": 1.5, "option": 1.5, "graphics": 1.5, "graphic": 1.5, "video": 1.5, "display": 1.5,
+ "render": 1.5, "engine": 1.5, "scalability": 1.5, "gameusersettings": 1.5, "screen": 1,
+ "settings": 0.5, "setting": 0.5, "config": 0.5, "configuration": 0.5, "cfg": 0.5,
+ "prefs": 0.5, "preferences": 0.5,
+}
+
+// Words that name saves somewhere in the manifest often enough that a file
+// carrying one is never taken, whatever it holds. Logs are not settings
+// either, though leaving them out would harm nothing.
+var saveWords = map[string]bool{
+ "save": true, "saves": true, "savegame": true, "savedata": true, "slot": true, "progress": true,
+ "autosave": true, "quicksave": true, "world": true, "player": true, "character": true,
+ "campaign": true, "level": true, "levels": true, "achievement": true, "achievements": true,
+ "achievments": true, "stats": true, "unlock": true, "unlocks": true, "inventory": true,
+ "profile": true, "career": true, "highscore": true, "highscores": true, "score": true,
+ "scores": true, "records": true, "besttimes": true, "gamedata": true, "log": true,
+ "logs": true, "crash": true, "output": true, "history": true,
+}
+
+var identRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9]*`)
+var camelRe = regexp.MustCompile(`[A-Z]?[a-z]+|[A-Z]+(?:[^a-z]|$)|[0-9]+`)
+
+// words splits text into lowercase words: identifiers, then at case changes
+// ("FrameGeneration" → frame generation), keeping a whole short identifier
+// too ("dx11", "vsync").
+func words(text string) []string {
+ var out []string
+ for _, id := range identRe.FindAllString(text, -1) {
+ parts := camelRe.FindAllString(id, -1)
+ low := strings.ToLower(id)
+ if len(parts) > 1 && len(low) <= 6 {
+ out = append(out, low)
+ }
+ for _, p := range parts {
+ p = strings.ToLower(p)
+ if p == "of" || p == "the" || p == "b" || p == "r" || p == "e" {
+ continue // filler, and engine prefixes like r_ and bEnable
+ }
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// textOf returns a file's text, decoding UTF-16, or false for binary.
+func textOf(b []byte) (string, bool) {
+ switch {
+ case bytes.HasPrefix(b, []byte{0xFF, 0xFE}), bytes.HasPrefix(b, []byte{0xFE, 0xFF}):
+ big := b[0] == 0xFE
+ b = b[2:]
+ u := make([]uint16, len(b)/2)
+ for i := range u {
+ if big {
+ u[i] = uint16(b[2*i])<<8 | uint16(b[2*i+1])
+ } else {
+ u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
+ }
+ }
+ return string(utf16.Decode(u)), true
+ case len(b) >= 4 && b[1] == 0 && b[3] == 0 && b[0] != 0 && b[2] != 0:
+ // UTF-16LE without a mark, as some engines write their XML.
+ u := make([]uint16, len(b)/2)
+ for i := range u {
+ u[i] = uint16(b[2*i+1])<<8 | uint16(b[2*i])
+ }
+ return string(utf16.Decode(u)), true
+ case bytes.IndexByte(b, 0) >= 0:
+ return "", false
+ }
+ return string(b), true
+}
+
+// contentScore counts the distinct display terms a text holds.
+func contentScore(text string) (strong int, score float64) {
+ ws := words(text)
+ present := make(map[string]bool, len(ws))
+ pairs := make(map[[2]string]bool, len(ws))
+ for i, w := range ws {
+ present[w] = true
+ if i+1 < len(ws) {
+ pairs[[2]string{w, ws[i+1]}] = true
+ }
+ }
+ for _, t := range strongTerms {
+ if (len(t) == 1 && present[t[0]]) || (len(t) == 2 && pairs[[2]string{t[0], t[1]}]) {
+ strong++
+ }
+ }
+ weak := 0.0
+ for _, w := range weakTerms {
+ if present[w] {
+ weak += 0.5
+ }
+ }
+ if weak > 2 {
+ weak = 2
+ }
+ return strong, float64(strong) + weak
+}
+
+// nameSignal weighs a file's name: the support it lends, and whether a word
+// in it names saves.
+func nameSignal(rel string) (weight float64, saveWord, strongName bool) {
+ base := path.Base(rel)
+ ext := strings.ToLower(strings.TrimPrefix(path.Ext(base), "."))
+ stem := strings.TrimSuffix(base, path.Ext(base))
+ ws := words(stem)
+ low := strings.ToLower(stem)
+ for w := range nameWeights {
+ if len(w) >= 6 && strings.Contains(low, w) {
+ ws = append(ws, w)
+ }
+ }
+ seen := map[string]bool{}
+ for _, w := range ws {
+ if seen[w] {
+ continue
+ }
+ seen[w] = true
+ if saveWords[w] {
+ saveWord = true
+ }
+ weight += nameWeights[w]
+ if nameWeights[w] >= 1.5 {
+ strongName = true
+ }
+ }
+ if strings.Contains(low, "save") || strings.Contains(low, "slot") || ext == "log" {
+ saveWord = true
+ }
+ if ext == "ini" || ext == "cfg" || ext == "conf" {
+ weight += 0.5
+ }
+ if weight > 2 {
+ weight = 2
+ }
+ return weight, saveWord, strongName
+}
+
+// unrealSettings reports a file in an Unreal Engine game's settings folder,
+// Saved/Config/