Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (2026-10-29): 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.
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/v1.13.1.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions internal/app/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,21 +66,22 @@ 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)
for _, exe := range []string{g.Exe, `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, `C:\Emulators\src\portal\portal-controller.exe`} {
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)
}
}
Expand Down
8 changes: 7 additions & 1 deletion internal/app/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 6 additions & 2 deletions internal/launch/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions internal/launch/launch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/launch/track.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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++
}
Expand Down