From 8fd85838f91f655c37e26189cd522438137e79fe Mon Sep 17 00:00:00 2001 From: ApolloF Date: Thu, 29 Oct 2026 15:28:50 +0100 Subject: [PATCH] Track console games started in another copy of the emulator --- docs/CHANGELOG.md | 6 ++++++ docs/releases/v1.13.1.md | 3 +++ internal/app/console_test.go | 9 +++++---- internal/app/launch.go | 8 +++++++- internal/launch/launch.go | 8 ++++++-- internal/launch/launch_test.go | 31 +++++++++++++++++++++++++++++++ internal/launch/track.go | 8 ++++++++ 7 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 docs/releases/v1.13.1.md diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1316caa..7a0162f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,12 @@ What each version of Seaglass (WaterLauncher until 1.4) changed and why, newest Alongside Seaglass: [gamekit](https://github.com/ApolloF/gamekit) v0.1.0, Syncer 0.15.0 and DLSS Updater 1.4.1. +## 1.13.1 (unreleased): console games launched through another copy of the emulator + +A console game started through a boot script that runs its own copy of the emulator was never seen running: Skylanders Giants, started by a PowerShell script that asks a figure portal's controller to boot it, ran in the controller's RPCS3 (`C:\Emulators\PortalController\rpcs3`), not the one Seaglass found, so the session stayed *Starting* while the script ran and ended without counting playtime. + +- While a console game runs, a process whose program has the emulator's file name (`rpcs3.exe`, `Dolphin.exe`, `Cemu.exe`, from the scan, any case) is the game from any folder too (`launch.Plan.ExeNames`), next to the processes from the emulator's folder. Only ones that started after Play count, as before, so an emulator that was open already doesn't. The folder of a program picked in the launch options still isn't tracked, so a launcher that stays in the tray doesn't keep the session going. PC games are tracked as before. + ## 1.13.0 (unreleased): emulated games Console games played on emulators are a new library next to the PC games, so Seaglass can be the one launcher at the desk and on a stream. Nothing about PC games changes: console games get keys of their own (`emu:` and the file the emulator boots), so they never meet a PC game's folder. diff --git a/docs/releases/v1.13.1.md b/docs/releases/v1.13.1.md new file mode 100644 index 0000000..0315420 --- /dev/null +++ b/docs/releases/v1.13.1.md @@ -0,0 +1,3 @@ +**Fixes** + +- **Console games started through another copy of the emulator now count playtime.** If a game's launch options run a script that starts the game in a different copy of RPCS3, Dolphin or Cemu (a figure portal's controller with its own RPCS3, say), Seaglass didn't see the game running: it stayed on Starting and counted no playtime. Seaglass now knows the emulator by its name, wherever it's installed. An emulator that was already open when you pressed Play still doesn't count. diff --git a/internal/app/console_test.go b/internal/app/console_test.go index ec9ac9b..d3c505f 100644 --- a/internal/app/console_test.go +++ b/internal/app/console_test.go @@ -66,7 +66,8 @@ func TestConsoleExeAndTracking(t *testing.T) { if consoleExeOK(other) { t.Fatal("a program outside the emulator's folder isn't the game's") } - // Only the emulator's processes are the game, whatever started it: not + // Only the emulator's processes (from its folder, or by its program's + // name from any folder) are the game, whatever started it: not // PowerShell running a boot script, not a launcher that stays in the // tray (a figure portal's controller). c := ctlTestCore(t, false, nil, nil) @@ -74,13 +75,13 @@ func TestConsoleExeAndTracking(t *testing.T) { k := g k.UserExe, k.Exe = exe != g.Exe, exe p := c.plan(k) - if !slices.Equal(p.Dirs, []string{`C:\Emulators\RPCS3`}) || !p.FolderOnly || p.StarterTimeout < 10*time.Minute { - t.Errorf("%s: dirs %v, folder only %v, starter timeout %v", exe, p.Dirs, p.FolderOnly, p.StarterTimeout) + if !slices.Equal(p.Dirs, []string{`C:\Emulators\RPCS3`}) || !p.FolderOnly || !slices.Equal(p.ExeNames, []string{"rpcs3.exe"}) || p.StarterTimeout < 10*time.Minute { + t.Errorf("%s: dirs %v, folder only %v, exe names %v, starter timeout %v", exe, p.Dirs, p.FolderOnly, p.ExeNames, p.StarterTimeout) } } // A PC game's plan is as it was. pc := library.Game{ID: 2, Title: "G", Installed: true, Dir: `C:\G`, Exe: `C:\Tools\g.exe`, UserExe: true} - if p := c.plan(pc); !slices.Equal(p.Dirs, []string{`C:\G`, `C:\Tools`}) || p.FolderOnly || p.StarterTimeout != 0 { + if p := c.plan(pc); !slices.Equal(p.Dirs, []string{`C:\G`, `C:\Tools`}) || p.FolderOnly || p.ExeNames != nil || p.StarterTimeout != 0 { t.Errorf("pc game: %+v", p) } } diff --git a/internal/app/launch.go b/internal/app/launch.go index 24be95a..7054434 100644 --- a/internal/app/launch.go +++ b/internal/app/launch.go @@ -284,9 +284,15 @@ func (c *Core) plan(g library.Game) launch.Plan { dirs = append(dirs, filepath.Dir(g.Exe)) } folderOnly := false + var names []string var starter time.Duration if g.Console != nil { dirs, folderOnly = []string{g.Console.EmuDir}, true + // A boot script can start its own copy of the emulator from + // another folder: that's the game too. + if g.Console.Exe != "" { + names = []string{filepath.Base(g.Console.Exe)} + } // A boot script can take minutes to start the emulator (a figure // portal's controller first); it's waited for while it runs. starter = 15 * time.Minute @@ -296,7 +302,7 @@ func (c *Core) plan(g library.Game) launch.Plan { detect = 5 * time.Minute // stores update, sign in, build shader caches } return launch.Plan{ - GameID: g.ID, Title: title, Dirs: dirs, FolderOnly: folderOnly, StarterTimeout: starter, Before: before, After: after, DetectTimeout: detect, + GameID: g.ID, Title: title, Dirs: dirs, FolderOnly: folderOnly, ExeNames: names, StarterTimeout: starter, Before: before, After: after, DetectTimeout: detect, Start: func() (uint32, string, error) { var err error var pid int diff --git a/internal/launch/launch.go b/internal/launch/launch.go index 3ed1c3d..961a424 100644 --- a/internal/launch/launch.go +++ b/internal/launch/launch.go @@ -144,7 +144,11 @@ type Plan struct { // (the emulator, open already). While the started program still runs // (a boot script that's slow to start the emulator), the game is // waited for up to StarterTimeout rather than DetectTimeout. - FolderOnly bool + FolderOnly bool + // ExeNames are program file names (rpcs3.exe) that are the game from + // any folder: a boot script can start another copy of the emulator. + // With FolderOnly, ones that ran before the game was started aren't it. + ExeNames []string StarterTimeout time.Duration Before []Step // Start starts the game. It runs after the Before steps, which may @@ -374,7 +378,7 @@ func (m *Manager) follow(ctx context.Context, p Plan, pid uint32, before map[uin root = 0 } t := newTracker(UsableDirs(p.Dirs), root, uint32(os.Getpid()), m.image) - t.before = before + t.before, t.names = before, p.ExeNames timeout := p.DetectTimeout if timeout <= 0 { timeout = time.Minute diff --git a/internal/launch/launch_test.go b/internal/launch/launch_test.go index 9d1b1fe..4d10071 100644 --- a/internal/launch/launch_test.go +++ b/internal/launch/launch_test.go @@ -591,3 +591,34 @@ func TestFolderOnlyWaitsForASlowStarter(t *testing.T) { t.Errorf("polls %d, note %q; want it given up after the detect timeout", f.i, s.Note) } } + +// A boot script can start its own copy of the emulator from another +// folder (a figure portal's controller): a program with the emulator's +// name is the game too, but not one that was open already. +func TestFolderOnlyFollowsTheEmulatorByName(t *testing.T) { + const emuDir = `C:\Emulators\RPCS3` + open := platform.Proc{PID: 200, PPID: 1, Name: "rpcs3.exe"} + script := platform.Proc{PID: 100, PPID: 1, Name: "powershell.exe"} + emu := platform.Proc{PID: 300, PPID: 400, Name: "rpcs3.exe"} + f := &fakePC{ + paths: map[uint32]string{ + 100: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, + 200: `C:\Emulators\Other\rpcs3.exe`, + 300: `C:\Emulators\PortalController\rpcs3\RPCS3.exe`, + }, + frames: [][]platform.Proc{ + {open}, // before the game starts + {open, script}, {open, script, emu}, {open, emu}, {open, emu}, + {open}, {open}, {open}, {open}, {open}, {open}, + }, + } + m, r := newTest(f) + var played int64 + _ = m.Launch(context.Background(), Plan{Dirs: []string{emuDir}, FolderOnly: true, ExeNames: []string{"rpcs3.exe"}, DetectTimeout: time.Minute, + Start: func() (uint32, string, error) { return 100, "direct", nil }, + Played: func(s int64) { played += s }}) + s := wait(t, r) + if s.Phase != Ended || played != 4 || s.Note != "" { + t.Errorf("phase %s, played %d, note %q; want ended after the started emulator's 4 s", s.Phase, played, s.Note) + } +} diff --git a/internal/launch/track.go b/internal/launch/track.go index ac3a849..61b7207 100644 --- a/internal/launch/track.go +++ b/internal/launch/track.go @@ -53,6 +53,8 @@ type tracker struct { // before are the processes that ran before the game was started (id // to name), with Plan.FolderOnly: they aren't the game. before map[uint32]string + // names are exe file names that are the game from any folder. + names []string } func newTracker(dirs []string, root, self uint32, image func(uint32) (string, uint64, error)) *tracker { @@ -109,6 +111,12 @@ func (t *tracker) update(ps []platform.Proc) map[uint32]uint64 { break } } + for _, n := range t.names { + if strings.EqualFold(n, filepath.Base(path)) { + info.game = true + break + } + } } else { info.misses++ }