diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json
index 7da165a7..2157edb9 100644
--- a/cmd/opensave-app/frontend/src/locales/en.json
+++ b/cmd/opensave-app/frontend/src/locales/en.json
@@ -186,5 +186,30 @@
"home.welcome.intro": "Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:",
"home.welcome.scanForSaves": "Scan for saves",
"home.welcome.title": "Welcome to OpenSave",
- "home.welcome.trackManually": "Track a folder manually"
+ "home.welcome.trackManually": "Track a folder manually",
+ "home.trackGame": "Track game",
+ "home.trackGameTitle": "Find a game by name, or choose its program or save folder",
+ "home.addGame.recognisedAs": "Recognised as \"{name}\" — its cover art comes from the game database.",
+ "home.findGame.title": "Track a game from the game database",
+ "home.findGame.findByName": "Find by name",
+ "home.findGame.searchPlaceholder": "e.g. Crimson Desert",
+ "home.findGame.searching": "Searching…",
+ "home.findGame.noSavesHere": "no saves found on this PC",
+ "home.findGame.trackedAs": "tracked as \"{name}\"",
+ "home.findGame.nothingFound": "Nothing by that name in the game database.",
+ "home.findGame.chooseProgram": "Choose the game’s program…",
+ "home.findGame.chooseSaveFolder": "Choose its save folder…",
+ "home.findGame.programDialog": "Choose the game’s program",
+ "home.findGame.saveFolderDialog": "Choose the game’s save folder",
+ "home.findGame.saveFolderOf": "Save folder of \"{name}\"",
+ "home.findGame.theGame": "the game",
+ "home.findGame.notKnown": "Not in the game database — give it a name, and pick its save folder if it is not set.",
+ "home.findGame.saveFolder": "Save folder",
+ "home.findGame.noSavesYet": "No saves of it found on this PC yet. Choose where it saves, or play it once and track it then.",
+ "home.findGame.otherSaveFolder": "Choose another save folder…",
+ "home.findGame.program": "Program",
+ "home.findGame.pickAnother": "← Pick another game",
+ "home.findGame.already": "Already tracked here as \"{tracked}\". Tracking it again would sync the same files twice; link that entry to {name} instead, so it gets the game database’s name, App ID and cover art.",
+ "home.findGame.link": "Link \"{tracked}\" to {name}",
+ "home.findGame.linked": "\"{tracked}\" is now {name}"
}
diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-CN.json
index 12fbb543..b7399448 100644
--- a/cmd/opensave-app/frontend/src/locales/zh-CN.json
+++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json
@@ -165,5 +165,30 @@
"home.welcome.intro": "让你的游戏存档在所有设备间保持同步——无需账号,不被云端绑架。先找到你的存档:",
"home.welcome.scanForSaves": "扫描存档",
"home.welcome.title": "欢迎使用 OpenSave",
- "home.welcome.trackManually": "手动跟踪文件夹"
+ "home.welcome.trackManually": "手动跟踪文件夹",
+ "home.trackGame": "跟踪游戏",
+ "home.trackGameTitle": "按名称查找游戏,或选择其程序或存档文件夹",
+ "home.addGame.recognisedAs": "已识别为“{name}”——封面来自游戏数据库。",
+ "home.findGame.title": "从游戏数据库跟踪游戏",
+ "home.findGame.findByName": "按名称查找",
+ "home.findGame.searchPlaceholder": "例如:Crimson Desert",
+ "home.findGame.searching": "搜索中…",
+ "home.findGame.noSavesHere": "此电脑上未找到存档",
+ "home.findGame.trackedAs": "已作为“{name}”跟踪",
+ "home.findGame.nothingFound": "游戏数据库中没有这个名称的游戏。",
+ "home.findGame.chooseProgram": "选择游戏程序…",
+ "home.findGame.chooseSaveFolder": "选择其存档文件夹…",
+ "home.findGame.programDialog": "选择游戏程序",
+ "home.findGame.saveFolderDialog": "选择游戏的存档文件夹",
+ "home.findGame.saveFolderOf": "“{name}”的存档文件夹",
+ "home.findGame.theGame": "该游戏",
+ "home.findGame.notKnown": "不在游戏数据库中——请为它命名;如果尚未设置存档文件夹,请选择一个。",
+ "home.findGame.saveFolder": "存档文件夹",
+ "home.findGame.noSavesYet": "此电脑上尚未找到它的存档。请选择它的存档位置,或先玩一次再跟踪。",
+ "home.findGame.otherSaveFolder": "选择其他存档文件夹…",
+ "home.findGame.program": "程序",
+ "home.findGame.pickAnother": "← 选择其他游戏",
+ "home.findGame.already": "此处已作为“{tracked}”跟踪。再次跟踪会将相同的文件同步两次;请改为将该条目关联到 {name},使其获得游戏数据库中的名称、App ID 和封面。",
+ "home.findGame.link": "将“{tracked}”关联到 {name}",
+ "home.findGame.linked": "“{tracked}”现在是 {name}"
}
diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte
index de8ecef4..0287353d 100644
--- a/cmd/opensave-app/frontend/src/views/Home.svelte
+++ b/cmd/opensave-app/frontend/src/views/Home.svelte
@@ -5,6 +5,7 @@
import { gameStatus, conflictedIds } from '../lib/gamestatus.js';
import OfferedGames from './home/OfferedGames.svelte';
import AddGameCard from './home/AddGameCard.svelte';
+ import TrackGameDialog from './home/TrackGameDialog.svelte';
import ScanDialog from './home/ScanDialog.svelte';
import HomeSummary from './home/HomeSummary.svelte';
import LibraryGrid from './home/LibraryGrid.svelte';
@@ -31,6 +32,8 @@
// on the params object, so each request — the sidebar's +, Ctrl+K — opens
// it once, and not again on every re-render.
let showAdd = false;
+ // Tracking a game the game database knows (TrackGameDialog).
+ let showTrackGame = false;
let addPath = '';
let handledAdd = null;
$: if (params.add && params !== handledAdd) {
@@ -109,6 +112,7 @@
+
@@ -119,6 +123,10 @@
(showAdd = false)} />
{/if}
+{#if showTrackGame}
+ (showTrackGame = false)} />
+{/if}
+
{#if !$stateLoaded}
diff --git a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte
index 8c95beed..963f991d 100644
--- a/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte
+++ b/cmd/opensave-app/frontend/src/views/home/AddGameCard.svelte
@@ -21,6 +21,10 @@
export let path = '';
let name = '';
+ // The game the folder is a save location of, in the game database: its
+ // name and App ID (cover art, launching).
+ let appId = '';
+ let knownAs = '';
let typed = false; // once typed in, a late suggestion must not replace it
let adding = false;
let nameInput;
@@ -32,10 +36,24 @@
suggestedFor = p;
if (!p) return;
let guess = folderName(p);
+ appId = '';
+ knownAs = '';
try {
- guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess;
+ const id = await api.get(`/api/gamedb/identify?path=${encodeURIComponent(p)}`);
+ if (id.found && p === path) {
+ appId = id.game.appId ?? '';
+ knownAs = id.game.name;
+ guess = id.game.name;
+ }
} catch {
- // The folder's own name will do.
+ // Named from the path instead.
+ }
+ if (!knownAs) {
+ try {
+ guess = (await api.get(`/api/suggest-name?path=${encodeURIComponent(p)}`)).name || guess;
+ } catch {
+ // The folder's own name will do.
+ }
}
if (p !== path || typed) return;
name = guess;
@@ -65,7 +83,7 @@
if (!name.trim() || !path || adding) return;
adding = true;
try {
- const game = await api.post('/api/games', { name: name.trim(), savePath: path });
+ const game = await api.post('/api/games', { name: name.trim(), savePath: path, appId });
toast($t('common.nowTracking', { name: game.name ?? name }), 'success');
dispatch('close');
navigate('game', { gameId: game.id });
@@ -102,6 +120,7 @@
on:keydown={(e) => e.key === 'Enter' && add()}
/>
{$t('home.addGame.nameHint')}
+ {#if knownAs}{$t('home.addGame.recognisedAs', { name: knownAs })}{/if}
diff --git a/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte
new file mode 100644
index 00000000..127ffef8
--- /dev/null
+++ b/cmd/opensave-app/frontend/src/views/home/TrackGameDialog.svelte
@@ -0,0 +1,345 @@
+
+
+ dispatch('close')}>
+
+ {$t('home.findGame.saveFolder')}
+ {#if picked.savePaths?.length > 1}
+ {#each picked.savePaths as p}
+
+ {/each}
+ {:else if savePath}
+
{savePath}
+ {:else}
+
{$t('home.findGame.noSavesYet')}
+ {/if}
+ {#if canPick}
+
+ {/if}
+
+ {#if exePath}
+
+ {$t('home.findGame.program')}
+
{exePath}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 920b1a33..f943a131 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -29,6 +29,9 @@ func (s *Server) routes(r chi.Router) {
r.Get("/api/games", s.handleListGames)
r.Post("/api/games", s.handleTrackGame)
+ // The game database, for tracking a game by hand (daemon/gamedb.go).
+ r.Get("/api/gamedb/search", s.handleGameDBSearch)
+ r.Get("/api/gamedb/identify", s.handleGameDBIdentify)
r.Get("/api/suggest-name", s.handleSuggestName)
r.Post("/api/games/untrack-bulk", s.handleBulkUntrack)
@@ -344,6 +347,8 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
SavePath string `json:"savePath"`
AppID string `json:"appId"`
+ // ExePath: the game's program, when it was picked by it.
+ ExePath string `json:"exePath"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
@@ -354,7 +359,7 @@ func (s *Server) handleTrackGame(w http.ResponseWriter, r *http.Request) {
return
}
- game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID})
+ game, err := s.Daemon.TrackGame(store.Game{Name: body.Name, SavePath: body.SavePath, AppID: body.AppID, ExePath: body.ExePath})
if err != nil {
// Duplicates (id or path) are conflicts; anything else the daemon
// rejects is bad input.
diff --git a/internal/api/routes_gamedb.go b/internal/api/routes_gamedb.go
new file mode 100644
index 00000000..b1d9024c
--- /dev/null
+++ b/internal/api/routes_gamedb.go
@@ -0,0 +1,26 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/opensave/opensave/internal/presets"
+)
+
+// GET /api/gamedb/search?q= — games in the game database by name.
+func (s *Server) handleGameDBSearch(w http.ResponseWriter, r *http.Request) {
+ out := s.Daemon.SearchGames(r.URL.Query().Get("q"))
+ if out == nil {
+ out = []presets.GameMatch{}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"games": out})
+}
+
+// GET /api/gamedb/identify?path= — the game a program or folder belongs to.
+func (s *Server) handleGameDBIdentify(w http.ResponseWriter, r *http.Request) {
+ m, ok := s.Daemon.IdentifyGame(r.URL.Query().Get("path"))
+ if !ok {
+ writeJSON(w, http.StatusOK, map[string]any{"found": false})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"found": true, "game": m})
+}
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index 3d732f86..cd87c0e7 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -703,6 +703,12 @@ func (d *Daemon) TrackGame(game store.Game) (store.Game, error) {
return store.Game{}, err
}
game.SavePath = abs
+ // Nor a folder inside another game's, or holding one: the same files
+ // would be watched, snapshotted and synced twice, under two games that
+ // each think them theirs.
+ if other, ok := d.overlappingGame(abs); ok {
+ return store.Game{}, fmt.Errorf("%q already tracks %s, which this folder is inside or holds — track one or the other", other.Name, other.SavePath)
+ }
// Fresh track from the UI (no id supplied): derive the id from the name,
// but disambiguate collisions so a second save location for a same-named
diff --git a/internal/daemon/gamedb.go b/internal/daemon/gamedb.go
new file mode 100644
index 00000000..302ef470
--- /dev/null
+++ b/internal/daemon/gamedb.go
@@ -0,0 +1,90 @@
+package daemon
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/opensave/opensave/internal/presets"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// Tracking a game by hand, with what the game database knows about it: a
+// game found by name, or recognised from a program or folder chosen. See
+// presets/gamedb.go.
+
+func (d *Daemon) gameDB() *presets.GameDB {
+ if d.Scanner == nil {
+ return nil
+ }
+ return d.Scanner.GameDB()
+}
+
+// SearchGames finds games in the game database by name.
+func (d *Daemon) SearchGames(query string) []presets.GameMatch {
+ out := d.gameDB().Search(query, 20)
+ for i := range out {
+ d.markTracked(&out[i])
+ }
+ return out
+}
+
+// IdentifyGame says which game a program or folder belongs to: a program by
+// where it is installed, a folder by the game whose save location it is.
+func (d *Daemon) IdentifyGame(path string) (presets.GameMatch, bool) {
+ db := d.gameDB()
+ info, err := os.Stat(path)
+ if err != nil {
+ return presets.GameMatch{}, false
+ }
+ var m presets.GameMatch
+ var ok bool
+ switch {
+ case !info.IsDir() && strings.EqualFold(filepath.Ext(path), ".exe"):
+ m, ok = db.IdentifyProgram(path)
+ case !info.IsDir():
+ m, ok = db.IdentifyFolder(filepath.Dir(path)) // a save file: its folder
+ default:
+ m, ok = db.IdentifyFolder(path)
+ }
+ if ok {
+ d.markTracked(&m)
+ }
+ return m, ok
+}
+
+// overlappingGame is the tracked game whose folder holds path, or is inside
+// it, if there is one. The very same folder is not counted: TrackGame says
+// so in its own words.
+func (d *Daemon) overlappingGame(path string) (store.Game, bool) {
+ games, err := d.Store.ListGames()
+ if err != nil {
+ return store.Game{}, false
+ }
+ for _, g := range games {
+ if g.SavePath != "" && store.PathsNest(path, g.SavePath) {
+ return g, true
+ }
+ }
+ return store.Game{}, false
+}
+
+// markTracked fills in which tracked game, if any, a match already is.
+func (d *Daemon) markTracked(m *presets.GameMatch) {
+ games, err := d.Store.ListGames()
+ if err != nil {
+ return
+ }
+ for _, g := range games {
+ same := m.AppID != "" && g.AppID == m.AppID
+ for _, p := range m.SavePaths {
+ if g.SavePath != "" && store.PathsOverlap(p, g.SavePath) {
+ same = true
+ }
+ }
+ if same {
+ m.TrackedID, m.TrackedName = g.ID, g.Name
+ return
+ }
+ }
+}
diff --git a/internal/daemon/gamedb_test.go b/internal/daemon/gamedb_test.go
new file mode 100644
index 00000000..8a5fcde7
--- /dev/null
+++ b/internal/daemon/gamedb_test.go
@@ -0,0 +1,78 @@
+package daemon
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/opensave/opensave/internal/presets"
+ "github.com/opensave/opensave/internal/store"
+)
+
+// A folder inside a tracked game's folder, or holding it, is not tracked as
+// another game: the same files would be watched, snapshotted and synced twice.
+// The very same folder keeps its own refusal.
+func TestTrackGame_RefusesAFolderInsideAnother(t *testing.T) {
+ d := newTestDaemon(t)
+ outer := t.TempDir()
+ inner := filepath.Join(outer, "Saves")
+ if err := os.MkdirAll(inner, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.TrackGame(store.Game{Name: "Duckov", SavePath: inner}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.TrackGame(store.Game{Name: "Escape From Duckov", SavePath: outer}); err == nil {
+ t.Error("a folder holding a tracked game's was tracked as another game")
+ } else if !strings.Contains(err.Error(), "Duckov") {
+ t.Errorf("the refusal does not name the game: %v", err)
+ }
+ deeper := filepath.Join(inner, "slot1")
+ if err := os.MkdirAll(deeper, 0o777); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := d.TrackGame(store.Game{Name: "Duckov Slot", SavePath: deeper}); err == nil {
+ t.Error("a folder inside a tracked game's was tracked as another game")
+ }
+ if _, err := d.TrackGame(store.Game{Name: "Duckov Again", SavePath: inner}); err == nil || !strings.Contains(err.Error(), "already tracks this folder") {
+ t.Errorf("the same folder again: %v, want the usual \"already tracks this folder\"", err)
+ }
+ if _, err := d.TrackGame(store.Game{Name: "Unrelated", SavePath: t.TempDir()}); err != nil {
+ t.Errorf("an unrelated folder was refused: %v", err)
+ }
+}
+
+// A game picked from the game database says which entry here it already is:
+// one with its App ID, or one whose folder is, holds or is held by a save
+// location the database knows for it.
+func TestMarkTracked_NamesTheEntryAGameIsAlready(t *testing.T) {
+ d := newTestDaemon(t)
+ saves := t.TempDir()
+ g, err := d.TrackGame(store.Game{Name: "Duckov", SavePath: saves})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ byFolder := presets.GameMatch{Name: "Escape From Duckov", SavePaths: []string{filepath.Join(saves, "Saves")}}
+ d.markTracked(&byFolder)
+ if byFolder.TrackedID != g.ID || byFolder.TrackedName != "Duckov" {
+ t.Errorf("by folder: tracked as %q (%q), want %q", byFolder.TrackedID, byFolder.TrackedName, g.ID)
+ }
+
+ g.AppID = "3167020"
+ if err := d.Store.UpdateGame(g); err != nil {
+ t.Fatal(err)
+ }
+ byID := presets.GameMatch{Name: "Escape From Duckov", AppID: "3167020"}
+ d.markTracked(&byID)
+ if byID.TrackedID != g.ID {
+ t.Errorf("by App ID: tracked as %q, want %q", byID.TrackedID, g.ID)
+ }
+
+ other := presets.GameMatch{Name: "Something Else", AppID: "1", SavePaths: []string{t.TempDir()}}
+ d.markTracked(&other)
+ if other.TrackedID != "" {
+ t.Errorf("an unrelated game was marked as tracked: %q", other.TrackedID)
+ }
+}
diff --git a/internal/presets/gamedb.go b/internal/presets/gamedb.go
new file mode 100644
index 00000000..011c77d9
--- /dev/null
+++ b/internal/presets/gamedb.go
@@ -0,0 +1,294 @@
+package presets
+
+import (
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// Finding a game in the game database by what is on this device: a program
+// or a folder that was picked, or a name typed.
+// What it answers is everything tracking needs and a folder picked by hand
+// lacked: the game's name, its Steam App ID (cover art, launching, sessions),
+// and where its saves are on this device.
+
+// GameDB is the game database loaded once for any number of lookups. Nil
+// when there is no index; every method answers nothing on a nil GameDB.
+type GameDB struct {
+ sc *Scanner
+ all []indexedGame // every game, for looking one up by what is on disk
+ byID map[string]indexedGame
+ protonIx map[string][]string
+ bases func([]string) []string
+ blocked map[string]bool // folders no save location may be
+ // byInstall: games by install folder name, built when first asked.
+ byInstall map[string][]indexedGame
+}
+
+// GameDB loads the game database (the manifest index) for lookups.
+func (sc *Scanner) GameDB() *GameDB {
+ if sc == nil || sc.CacheFile == "" {
+ return nil
+ }
+ idx := sc.loadManifestIndex()
+ if len(idx) == 0 {
+ return nil
+ }
+ ds := &GameDB{sc: sc, all: idx, byID: map[string]indexedGame{}}
+ for _, g := range idx {
+ if g.SteamID != "" && len(g.Paths) > 0 {
+ ds.byID[g.SteamID] = g
+ }
+ }
+ if sc.goos() == "linux" {
+ ds.protonIx = sc.protonPrefixIndex()
+ }
+ if sc.goos() == "windows" {
+ ds.blocked = blockedRoots(windowsPathVars())
+ } else {
+ ds.blocked = linuxBlockedRoots(sc.linuxHome())
+ }
+ ds.bases = sc.installBaseCandidates()
+ return ds
+}
+
+// GameMatch is a game from the game database, as found on this device.
+type GameMatch struct {
+ Name string `json:"name"`
+ AppID string `json:"appId,omitempty"`
+ // SavePaths are the game's save locations that exist on this device,
+ // most likely first.
+ SavePaths []string `json:"savePaths"`
+ // InstallDir is where it is installed, when that is known.
+ InstallDir string `json:"installDir,omitempty"`
+ // TrackedID and TrackedName name the game already tracked here that
+ // this one is — its folder is, holds or is held by one of SavePaths, or it
+ // has the App ID. Filled in by the daemon.
+ TrackedID string `json:"trackedId,omitempty"`
+ TrackedName string `json:"trackedName,omitempty"`
+}
+
+// savePathsHere lists a game's save locations that exist on this device.
+func (ds *GameDB) savePathsHere(g indexedGame) []string {
+ if ds == nil || len(g.Paths) == 0 {
+ return nil
+ }
+ return expandGamePaths(g, ds.sc.ludusaviVarSets(g, ds.protonIx), ds.bases, ds.blocked)
+}
+
+func (ds *GameDB) matchOf(g indexedGame, installDir string) GameMatch {
+ return GameMatch{Name: g.Name, AppID: g.SteamID, SavePaths: ds.savePathsHere(g), InstallDir: installDir}
+}
+
+// Search finds games by name: the whole name first, then names that begin
+// with what was typed, then names that contain it.
+func (ds *GameDB) Search(query string, limit int) []GameMatch {
+ q := normalizeGameName(query)
+ if ds == nil || len(q) < 2 {
+ return nil
+ }
+ type hit struct {
+ g indexedGame
+ rank int
+ }
+ var hits []hit
+ for _, g := range ds.all {
+ n := normalizeGameName(g.Name)
+ switch {
+ case n == q:
+ hits = append(hits, hit{g, 0})
+ case strings.HasPrefix(n, q):
+ hits = append(hits, hit{g, 1})
+ case strings.Contains(n, q):
+ hits = append(hits, hit{g, 2})
+ }
+ }
+ sort.SliceStable(hits, func(i, j int) bool {
+ if hits[i].rank != hits[j].rank {
+ return hits[i].rank < hits[j].rank
+ }
+ return len(hits[i].g.Name) < len(hits[j].g.Name)
+ })
+ if len(hits) > limit {
+ hits = hits[:limit]
+ }
+ out := make([]GameMatch, 0, len(hits))
+ for _, h := range hits {
+ out = append(out, ds.matchOf(h.g, ""))
+ }
+ return out
+}
+
+// IdentifyProgram finds the game a program belongs to: by the Steam install
+// folder it is in, or by the name of a folder above it that is the game's
+// install folder in the game database ("G:\Games\Crimson Desert").
+func (ds *GameDB) IdentifyProgram(exe string) (GameMatch, bool) {
+ if ds == nil || exe == "" {
+ return GameMatch{}, false
+ }
+ exe = filepath.Clean(exe)
+ byAppID, _ := ds.sc.InstallDirs()
+ for appID, dir := range byAppID {
+ if dir != "" && isSubPath(dir, exe) {
+ if g, ok := ds.gameByID(appID); ok {
+ return ds.matchOf(g, dir), true
+ }
+ }
+ }
+ byFolder := ds.installNameIndex()
+ for dir := filepath.Dir(exe); dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
+ if gs := byFolder[installKey(filepath.Base(dir))]; len(gs) > 0 {
+ return ds.matchOf(pickGame(gs), dir), true
+ }
+ }
+ return GameMatch{}, false
+}
+
+// IdentifyFolder finds the game a save folder belongs to: the game whose save
+// location in the game database is this folder, or holds it, or is held by it
+// (a folder picked one level too high).
+func (ds *GameDB) IdentifyFolder(folder string) (GameMatch, bool) {
+ if ds == nil || folder == "" {
+ return GameMatch{}, false
+ }
+ folder = filepath.Clean(folder)
+ low := strings.ToLower(folder)
+ best, bestScore := indexedGame{}, 0
+ for _, g := range ds.all {
+ if len(g.Paths) == 0 {
+ continue
+ }
+ // An install folder is only worked out for a game whose install
+ // folder name is in the path at all: doing it for every game in the
+ // database would look on disk tens of thousands of times.
+ bases := func([]string) []string { return nil }
+ for _, inst := range g.Installs {
+ if inst != "" && strings.Contains(low, strings.ToLower(inst)) {
+ bases = ds.bases
+ break
+ }
+ }
+ for _, vars := range ds.sc.ludusaviVarSets(g, ds.protonIx) {
+ for _, tpl := range g.Paths {
+ for _, expanded := range expandTemplate(tpl, vars, bases(g.Installs), g.SteamID) {
+ score := 0
+ switch {
+ case sameUnder(folder, expanded):
+ score = 3
+ case under(expanded, folder):
+ score = 2 // a folder inside the save location
+ case under(folder, expanded):
+ score = 1 // the save location inside the folder picked
+ }
+ if score > bestScore || (score == bestScore && score > 0 && better(g, best)) {
+ best, bestScore = g, score
+ }
+ }
+ }
+ }
+ }
+ if bestScore == 0 {
+ return GameMatch{}, false
+ }
+ return ds.matchOf(best, ""), true
+}
+
+// sameUnder reports whether path is pattern (a template expanded, possibly
+// with wildcards), compared component by component.
+func sameUnder(path, pattern string) bool {
+ rel, ok := relativeUnder(path, pattern+string(filepath.Separator)+"x")
+ return ok && rel == "x"
+}
+
+// relativeUnder reports whether pattern (a path, possibly with wildcards)
+// lies strictly inside root, and the rest of it below root. Compared
+// component by component, case-insensitively, with either separator: the
+// game database writes Windows paths, which a Proton prefix holds on Linux.
+func relativeUnder(root, pattern string) (string, bool) {
+ split := func(p string) []string {
+ p = strings.ToLower(path.Clean(strings.ReplaceAll(p, `\`, "/")))
+ var parts []string
+ for _, c := range strings.Split(p, "/") {
+ if c != "" {
+ parts = append(parts, c)
+ }
+ }
+ return parts
+ }
+ r, p := split(root), split(pattern)
+ if len(r) == 0 || len(p) <= len(r) {
+ return "", false
+ }
+ for i, rc := range r {
+ if ok, err := path.Match(p[i], rc); err != nil || !ok {
+ return "", false
+ }
+ }
+ return strings.Join(p[len(r):], "/"), true
+}
+
+// under reports whether path lies strictly inside pattern.
+func under(pattern, path string) bool {
+ _, ok := relativeUnder(pattern, path)
+ return ok
+}
+
+// better prefers a game with a Steam App ID — cover art, launching — when two
+// match equally.
+func better(g, than indexedGame) bool {
+ return g.SteamID != "" && than.SteamID == ""
+}
+
+func pickGame(gs []indexedGame) indexedGame {
+ best := gs[0]
+ for _, g := range gs[1:] {
+ if better(g, best) {
+ best = g
+ }
+ }
+ return best
+}
+
+func (ds *GameDB) gameByID(appID string) (indexedGame, bool) {
+ if g, ok := ds.byID[appID]; ok {
+ return g, true
+ }
+ for _, g := range ds.all {
+ if g.SteamID == appID {
+ return g, true
+ }
+ }
+ return indexedGame{}, false
+}
+
+// installNameIndex maps install folder names, normalised, to the games that
+// use them, for names specific enough to say which game a folder is.
+func (ds *GameDB) installNameIndex() map[string][]indexedGame {
+ if ds.byInstall != nil {
+ return ds.byInstall
+ }
+ ds.byInstall = map[string][]indexedGame{}
+ for _, g := range ds.all {
+ for _, inst := range g.Installs {
+ n := installKey(inst)
+ if len(n) < 4 || genericInstallNames[n] {
+ continue
+ }
+ ds.byInstall[n] = append(ds.byInstall[n], g)
+ }
+ }
+ return ds.byInstall
+}
+
+var genericInstallNames = map[string]bool{
+ "game": true, "games": true, "data": true, "bin": true, "binaries": true, "launcher": true,
+ "client": true, "common": true, "steam": true, "app": true,
+}
+
+// installKey is a folder name as compared with the game database's install
+// folder names: normalised, and without spaces, so "Project Zomboid" is the
+// "ProjectZomboid" the database knows.
+func installKey(name string) string {
+ return strings.ReplaceAll(normalizeGameName(name), " ", "")
+}
diff --git a/internal/presets/gamedb_test.go b/internal/presets/gamedb_test.go
new file mode 100644
index 00000000..db9addd3
--- /dev/null
+++ b/internal/presets/gamedb_test.go
@@ -0,0 +1,69 @@
+package presets
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestGameDB_FindsGamesByNameFolderAndProgram(t *testing.T) {
+ if runtime.GOOS != "windows" {
+ t.Skip("templates below use Windows placeholders")
+ }
+ home := t.TempDir()
+ t.Setenv("USERPROFILE", home)
+ t.Setenv("HOME", home)
+ local := filepath.Join(home, "AppData", "Local")
+ t.Setenv("LOCALAPPDATA", local)
+ save := filepath.Join(local, "Pearl", "CD", "save")
+ if err := os.MkdirAll(filepath.Join(save, "123"), 0o777); err != nil {
+ t.Fatal(err)
+ }
+
+ sc := manifestScanner(t, `
+Pearl Game:
+ files:
+ "/Pearl/CD/save":
+ tags: [save]
+ installDir:
+ Pearl Game: {}
+ steam:
+ id: 3321460
+Project Things:
+ files:
+ "/Things/Saves":
+ tags: [save]
+ installDir:
+ ProjectThings: {}
+`)
+ ds := sc.GameDB()
+ if ds == nil {
+ t.Fatal("no index")
+ }
+
+ got := ds.Search("pearl", 5)
+ if len(got) != 1 || got[0].AppID != "3321460" || len(got[0].SavePaths) != 1 || got[0].SavePaths[0] != save {
+ t.Errorf("search = %+v", got)
+ }
+
+ for _, folder := range []string{save, filepath.Join(save, "123"), filepath.Join(local, "Pearl", "CD")} {
+ if m, ok := ds.IdentifyFolder(folder); !ok || m.Name != "Pearl Game" {
+ t.Errorf("folder %s: %+v %v", folder, m, ok)
+ }
+ }
+ if _, ok := ds.IdentifyFolder(filepath.Join(home, "Elsewhere")); ok {
+ t.Error("an unrelated folder was taken for a game")
+ }
+
+ m, ok := ds.IdentifyProgram(`G:\Games\Pearl Game\bin64\game.exe`)
+ if !ok || m.Name != "Pearl Game" || m.InstallDir != `G:\Games\Pearl Game` {
+ t.Errorf("program in a folder of its own = %+v %v", m, ok)
+ }
+ if m, ok := ds.IdentifyProgram(`D:\Stuff\Project Things\run.exe`); !ok || m.Name != "Project Things" {
+ t.Errorf("install folder written with a space the database leaves out = %+v %v", m, ok)
+ }
+ if _, ok := ds.IdentifyProgram(`C:\Tools\editor.exe`); ok {
+ t.Error("an unrelated program was taken for a game")
+ }
+}
diff --git a/internal/store/known_saves.go b/internal/store/known_saves.go
index 55f4a739..afdc8459 100644
--- a/internal/store/known_saves.go
+++ b/internal/store/known_saves.go
@@ -91,3 +91,9 @@ func (s *Store) ClearPendingSaves() error {
// sits inside the other, compared the way the store compares a game's
// locations — the same answer on every operating system.
func PathsOverlap(a, b string) bool { return overlaps(a, b) }
+
+// PathsNest reports whether one save folder sits inside the other, the two
+// not being the same folder.
+func PathsNest(a, b string) bool {
+ return overlaps(a, b) && normalizeLocationPath(a) != normalizeLocationPath(b)
+}