Skip to content

sessions: see protected games and games installed anywhere; a safety net for the rest - #128

Draft
ChakraFusion wants to merge 18 commits into
Liquid-co:dev-publicfrom
ChakraFusion:pr/play-detect
Draft

ChakraFusion wants to merge 18 commits into
Liquid-co:dev-publicfrom
ChakraFusion:pr/play-detect

Conversation

@ChakraFusion

Copy link
Copy Markdown

Builds on #117 and #118. Their commits are here too until they merge (#118 comes in through merge commits). This PR's own change is four commits: protected games and the safety net; wherever installed; native test paths; recognised games not held.

The rest of the play-sessions draft (#34), so that a session is seen for the games #117's hold is for:

Protected games. A game protected against tampering keeps its program path from everyone, so the session tracker, which matches programs by path, never saw a 20-minute session of Crimson Desert. A process whose path can't be read is now listed by its file name. That name is matched against the programs in the game's install folders, three folders deep, leaving out installers, crash reporters, redistributables, anti-cheat services and launchers.

Wherever installed. A game is found running from a folder named as its install folder in the game database (or, for a distinctive name, the game's own), not only from Steam's library. The folder it was seen running from is remembered (marks, from #118's migration), so the game is recognised after a restart.

A safety net for games still not seen. A save that keeps changing while no session is seen is handled as play. A change on its own is kept and synced after five minutes. A second change within ten minutes begins a stretch that isn't synced either way, with a checkpoint at most every 30 minutes. Once the save has stayed unchanged for ten minutes, it's kept ("After playing") and synced, as a session's end does. This is said once in the log per game.

Recognised games are not held:

  • A game is recognised once a session of it has been recorded, or the folder it ran from is known. While it's closed, a change to its save isn't play and is sent at once.
  • A change within two minutes of a session's end (the game writing as it closes) is also sent at once.

Options.SyncEveryChange turns the safety net off for the e2e harness, whose tests of syncing change a save several times a minute.

Behaviour change to note: for a game whose program isn't seen, a single change now reaches the other devices five minutes later than before. Games whose sessions are seen, which is most once they've been played here, are unaffected.

Tests (daemon, sessions):

Checks: the full unit and e2e suites pass locally.

🤖 Generated with Claude Code

ChakraFusion and others added 16 commits October 20, 2026 21:28
… ends

A game autosaves every few minutes, and each autosave was synced as it
came: one afternoon a device took eleven versions of a game in 26 minutes
from the device it was being played on, each with its own snapshot, and
the playing device snapshotted every autosave too. And a device took
other devices' versions of a game running on it, writing a save under the
game.

While a game is being played on a device (its play sessions):
- it is not synced either way there (ErrPlayingHere, quiet as ErrHeld);
- another device asking for it is answered "playing", for its manifest
  and for deletions, and says so once per session instead of failing; the
  answer contains SettlingMessage, so an older build takes it as busy;
- the watcher snapshots a change to it at most once per 30 minutes.
When the session ends, the existing "After playing" snapshot is taken
and the save goes to the other devices in one sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a session is open the game's save is now held back from syncing,
so a session that outlives the game holds the save with it. A process
named like the game stayed behind for fifteen minutes after one closed,
with under 1 MB and no processor time; a killed one left two that kept
their memory and used no processor time at all.

Processes are measured on Windows (memory, processor time; sessions.Proc).
One holding under 32 MB that used no processor time since the last poll
(sessions.Idle), or one that used none for a minute whatever it holds, is
not the game (daemon.activeProcesses). A game being played uses some all
the time; a paused one still draws its menu. One that cannot be measured
still counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ack what is a save

The game database names the settings files of the games it knows (Liquid-co#84).
For the rest, a file is now taken for a game's settings by its contents
(presets/detectsettings.go): the vocabulary of one machine's display -
resolution, vsync, shadow quality, upscaler - matched inside identifiers
split at case changes and separators ("_enableFrameGeneration",
"r_materialAniso"), in UTF-8 or UTF-16. Unreal Engine's Config/<platform>
folder is taken by convention.

Names only add weight, by how reliably the game database's own labels show
them to mean settings: over its 16,700 settings and 9,000 save entries,
"settings" names 1,521 settings files and 70 saves, ".ini" 1,831 and 137,
so a name never decides on its own. A name with a save word in it rules a
file out unless the contents are overwhelming; the game database's save
locations are never taken, and nor are logs, binaries, long files that are
only partly settings, or Syncthing's conflict copies.

A file taken for settings that changes like a save is given back for good
(verdict "save", which also overrules the game database):
- before it is left out, and at every later run, the game's snapshots are
  asked whether it changed in most of the snapshots in which the rest of
  the save changed (snapshot/history.go);
- while it is left out, every change here and every manifest another
  device sends count whether it changed when the rest of the save did;
  three such changes, outnumbering the times it stayed put, give it back
  (daemon/detectsettings.go).

Detected files and verdicts are kept per game (migration 0042:
settings_files, settings_observations, and marks for when each game was
last judged) and applied through the device settings, so hashes recorded
under the old list are carried over as before. The game's file list says
why each excluded file is excluded, and unticking one there syncs it. The
configuration tab's new text is in the catalogs (en, zh-Hans, zh-Hant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Asked in review: a game minimised in the background can throttle to
almost nothing, or be suspended, and Windows trims its memory as well.
Either rule - under 32 MB and no processor time since the last poll, or
no processor time for a minute - could end its session while it was still
running, and the save would be synced under it. There is one rule now: a
process that has used no processor time for five minutes is not the game,
whatever memory it holds. That still lets go of what a closed game left
behind well before the quarter of an hour one lingered. Memory is no
longer read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y-content

# Conflicts:
#	cmd/opensave-app/frontend/src/locales/en.json
#	cmd/opensave-app/frontend/src/locales/zh-Hans.json
#	cmd/opensave-app/frontend/src/locales/zh-Hant.json
Asked in review: builds of the earlier draft of this change made
settings_files and settings_observations (0043) and marks (0041) with the
same schema, and a database from one of them would not start under a
plain CREATE TABLE ("table already exists"), as happened with
sync_device_settings. IF NOT EXISTS for all three, and a test that opens a
database holding them with 0042 unrecorded; it fails without.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c until confirmed

Asked in review: a wrong guess would stop part of a save syncing without
anyone knowing, a bigger risk than the game database's listed files. So a
file found by its contents is now only suggested (verdict "suggested"): it
keeps syncing, and the game's configuration tab lists it with "Keep on
this device" and "Keep syncing". Keeping it on this device leaves it out
of syncing as the database's files are, and the give-back checks watch it
as before; keeping it syncing records "declined", and it is not suggested
again. A suggestion that the snapshots show changing with the save is
withdrawn. The game database's files stay automatic.

POST /api/games/{id}/suggested-settings {path, keepHere} answers one; the
game's JSON carries suggestedSettings. The tab's new text is in the
catalogs (en, zh-Hans, zh-Hant).

TestSuggestedSettings_SyncUntilConfirmed: detection suggests the settings
files and leaves them syncing, an answer each way does what it says, and
detecting again changes neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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, and while a session is open the game's save is held back from
syncing. So a session whose save has not changed for an hour is ended:
kept and synced as when the game closes. The process still there does not
start it again; the save changing again does, and so does the game being
started anew.

TestQuietSession_EndsAfterAnHourWithoutASave fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hour without a save ended any session, and so two it should not:
- a game saved only by hand, at save points, can go hours without a save
  while it is played, and its save would then be synced under it;
- a session begun from outside - the Deck plugin from Steam's launch,
  `opensave wrap` - ends when that says so, and knows.

Now a session begun from outside is never ended this way, and one seen
from running processes is ended only if, besides not saving for an hour,
the game's processes used under 1% of one core in it (some 36 seconds).
A game being played keeps drawing and uses far more; a crash reporter or
launcher helper left behind uses less. Measured in hour-long windows from
the session's start or the save's last change. A process that cannot be
measured is never taken as quiet. sessions.CPUOf sums a game's processes,
matched as Running matches them; Tracker.Marked says a session was begun
from outside.

Tests: a leftover that ticks over is let go, a game played for three hours
without saving keeps its session, and a session begun from outside is not
ended; the last two fail under the hour alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… play

A 20-minute session of Crimson Desert was never seen as one: its process
keeps its path from everyone (protected against tampering), so the
session tracker, which matches running programs by path, saw nothing,
and every autosave was snapshotted and synced as it came.

- A process whose path cannot be read is listed by its file name, and
  matched against the programs found in the game's install folders
  (Target.Programs, three folders deep, installers, crash reporters,
  redistributables and anti-cheat services left out).

- A save that keeps changing while no session is seen is handled as play
  (daemon/sessions.go, noteActivity): one change on its own is kept and
  synced at once, as before - a game that saves as it closes; a second
  within 10 minutes begins a stretch that is not synced either way, with a
  checkpoint at most every 30 minutes, and once the save has stayed
  unchanged for 10 minutes it is kept ("After playing") and synced, as a
  session's end does. Said once in the log, per game, when it happens.

Options.SyncEveryChange turns the stretch off for the e2e harness, whose
tests of syncing change a save several times a minute and expect every
change to travel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	internal/daemon/daemon.go
#	internal/p2p/syncengine/engine.go
A session of Crimson Desert installed at G:\Games\Crimson Desert was never
seen: session detection knew install folders only from Steam's libraries
and the folders games are usually kept in, and the game's entry read "not
found on this PC". Its program path was readable all along.

- A running program inside a folder named as one of the game's install
  folders is that game, wherever the folder is: the names the game
  database knows (Ludusavi's installDir, "Crimson Desert"), and the
  game's own name when it is two words or a long one - "Rust" is a folder
  in every Rust toolchain. Generic folder names never count.
- The folder a session was seen running from is remembered (marks), so
  the game is known to be installed there, and its programs can be told
  by name from then on.
- A change made while no session is seen is held from the first, not the
  second: it is kept and synced once the save has stayed unchanged for 5
  minutes, longer than the gap between Crimson Desert's first two
  autosaves (3.6 minutes), so an unseen session sends one version, at its
  end. A second change within 10 minutes makes it a stretch that ends
  after 10 minutes unchanged, as before.
- A change waiting out the watcher's debounce already counts as play: a
  sync landing in those two seconds sent one change on just before it was
  held (watcher.ChangePending).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TestRunning_ByInstallFolderName gave processes Windows paths; a process's
program is a native path, so the test now builds them for the system it
runs on. Found by CI on Linux.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The safety net for games whose program is not seen held every change
outside a session as possible play: on its own for five minutes, as a
stretch for ten after the last. That held every game, including one whose
sessions are seen here and is closed, and a change right after a session's
end - the game writing as it closes - which the session's end had just
sent.

A game is recognised once a session of it has been recorded or the folder
it ran from is known; while none of its sessions is open, a change to its
save is not play and is sent at once. So is one within two minutes of a
session's end. Only a game whose program is not seen is held as a
stretch.

TestActivity_ARecognisedGameIsNotHeldWhileClosed, and Liquid-co#117's
TestSessionEndsWhenOnlyAStubIsLeft, fail without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Asked about in Liquid-co#106's review for its own lookup: a large save must not be
read into memory snapshot by snapshot. ChangesLikeASave read every file of
each of a game's last 40 snapshots to tell whether the rest of the save
changed - some ten million rows for a quarter-million-file save, each time
detection ran - and for a snapshot without a recorded file list it
unpacked and hashed the whole archive. Now the database compares two
snapshots (store.SnapshotsDiffer, stopping at the first difference), only
the files being judged are read (SnapshotPathHashes), and a snapshot with
no recorded list is left out (SnapshotHasFiles). ArchiveFiles and filesOf,
used only here, are gone.

TestSnapshotsDiffer covers the comparison; TestChangesLikeASave passes
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 26, 2026

Copy link
Copy Markdown

@ChakraFusion is attempting to deploy a commit to the sivadaboi's projects Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant