Skip to content

Instantly share code, notes, and snippets.

@RJNY
Last active August 4, 2026 15:27
Show Gist options
  • Select an option

  • Save RJNY/c9dc94276d079dd8beccf71dd8675276 to your computer and use it in GitHub Desktop.

Select an option

Save RJNY/c9dc94276d079dd8beccf71dd8675276 to your computer and use it in GitHub Desktop.
Auto Sleep on disconnect

Apollo: Freeze game & sleep on disconnect

Note

This won't work using the "Virtual Desktop". It must be an App (Steam, Playnite, a game, etc)

Freeze your game and put the host to sleep when you disconnect. Resume un-freezes the game.

Problem

On disconnect, the game is still running, consuming upward of 200w. The original script freezes the game in focus, bringing idle wattage to 50w~. While this is better, it's still running and doesn't let the computer sleep.

I want console-grade behavior: when I disconnect, the game freezes and the host drops to ~3W sleep; when I reconnect (Wake-on-LAN), the game resumes. Similar to streaming on a PS5 or XBOX.

Solution

Force the power action: use SetSuspendState instead of waiting for Windows to idle out. That bypasses the Steam Streaming Speakers power request that otherwise blocks idle sleep. The action is configurable via PowerAction at the top of pause.ahk, POWER_SLEEP (S3, the default) or POWER_HIBERNATE (S4). See "Sleep vs hibernate" below.

TL;DR

On disconnect: freeze the game, then put the host to sleep (S3 by default; set PowerAction := POWER_HIBERNATE for S4).

On reconnect (WoL): un-freeze the game.

On quit: stay awake by default, or sleep too if you set SleepOnQuit := true.

Switching devices: set SleepDelaySec to hold the host awake for a while after a disconnect, so you can pick up on another device before it sleeps.

Keep a shortcut to resume.ahk on your desktop to recover a frozen game by hand.

Requirements

Setup

Same as the original: auto-pause-resume-games

Extract the scripts wherever you like and wire them in Apollo under configuration:

  • Command Preparations, Do: {empty} Undo: resume.ahk
  • State Commands, Do: resume.ahk, Undo: pause.ahk
image

[Optional] host setup for optimal sleep

These aren't strictly required, but without them the PC may not stay asleep.

  1. Stop random wakes: keep magic-packet wake (so Wake-on-LAN still works), turn off pattern-match wake:

    # find your adapter name first: Get-NetAdapterPowerManagement
    Set-NetAdapterPowerManagement -Name "Ethernet" -WakeOnPattern Disabled

    Check what woke it last with powercfg /lastwake.

  2. Prevent hibernate (S4) if you prefer to always quick wake host PC: by default Windows promotes S3 -> S4 after an idle timeout, so a long disconnect can silently fall into hibernate. Set "Hibernate after" to Never to stay in the power state you chose:

    powercfg /change hibernate-timeout-ac 0

    Disable Fast Startup too. Confirm S3 is available with powercfg /a. If you choose to keep hibernate on, Wake-on-LAN can take about 45 seconds for a full reconnect. This all boils down to personal preference.

  3. Set your power button to sleep/hibernate In the event that you accidentally wake your host pc with a mouse or keyboard, the game is still frozen, but won't allow your PC to sleep again. pressing the power button to sleep is a quick remedy for these events to sleep it again.

Config

Everything is at the top of pause.ahk:

Variable Default What it does
PowerAction POWER_SLEEP Which power state to drop into on disconnect. See "Sleep vs hibernate" below.
DebounceMs 3000 How long to wait before sleeping, to check whether you quit instead of disconnecting. A quit shows up inside a second, so 3s is a comfortable margin. 1s or less may cause sleep-loop soft locks
SleepOnQuit false true sleeps on a quit as well as a disconnect, and skips the wait above.
SleepDelaySec 0 Extra seconds to stay awake after a disconnect so you can reconnect from another device. 0 sleeps as soon as the disconnect is confirmed.
ExcludedProcesses shells, Steam, launchers Processes that are never frozen, so the script doesn't suspend Explorer or Playnite when they happen to be in focus.

Moving between devices

By default the host sleeps a few seconds after you disconnect. If you want to swap between handhelds and need a grace period, set:

global SleepDelaySec := 60

The game still freezes right away, but the host waits a minute before sleeping. Reconnect from any device inside that minute and the game thaws and the sleep is cancelled.

Important

Don't raise DebounceMs for this: that timer is distinct for a reason. Because session quits fire both disconnect and quit events, there needs to be a delay to catch that sequence. Adjusting this without knowing what you're doing can cause a sleep-loop soft lock. You can force restart your computer, but you may lose your save state. SleepDelaySec exists as a separate variable for you to experiment with.

Sleep vs hibernate

Set PowerAction at the top of pause.ahk:

Value State Trade-off
POWER_SLEEP S3 ~3W, wakes in ~2s, state held in powered RAM. Default. Requires the host to stay plugged in and not fall through to S4 (see host setup #2).
POWER_HIBERNATE S4 ~0W, survives power loss, and wakes via Wake-on-LAN from a full power-off. Powers the discrete GPU down. Requires powercfg /hibernate on (otherwise it silently falls back to S3).
POWER_NONE - Do the freeze but change no power state (useful for testing).

Common issues

Missing-PID error on resume. The game crashed sometime during the sleep/wake cycle, so resume can't find its PID (the process is already gone) and reports the error. The common cause is a GPU driver timeout on resume that a fragile game doesn't survive, not the freeze itself. See "Sleep vs hibernate" for the caveat and the host-side fix.

I woke the PC by hand and the game is frozen. Run resume.ahk (keep a desktop shortcut for exactly this). It releases the process from suspend.

The PC slept when I quit the session. Two possible causes. First, resume.ahk may not be on the Command Preparations Undo slot, or may be pointing at a path that doesn't exist; without it, pause.ahk can't tell a quit from a disconnect. Second, SleepOnQuit may be set to true in pause.ahk, which makes a quit sleep the host on purpose; set it back to false if you only want disconnects to sleep.

The PC won't stay asleep, or wakes right after sleeping. That's Wake on Pattern Match on your network card. See the host setup above.

I want the PC to sleep on quit too, not just on disconnect. Set SleepOnQuit := true at the top of pause.ahk. By default it's false, so a quit leaves the host awake and only a disconnect sleeps it.

Forza Horizon Keeps crashing on wake I know. I have no fix.

A Special Thanks

A special thanks to ClassicOldSong for pioneering the original script. Without his script and Apollo plumbing, I never would have even considered this as a solution. All work here is heavily influenced by his original solution.

#Requires AutoHotkey v2.0
#SingleInstance Force
DetectHiddenWindows true
; Variables (don't touch!)
global POWER_SLEEP := 0
global POWER_HIBERNATE := 1
global POWER_NONE := 2
; USER CONFIG
global PowerAction := POWER_SLEEP ; POWER_SLEEP (S3), POWER_HIBERNATE (S4) or POWER_NONE
global DebounceMs := 3000 ; how long to wait for a quit after a disconnect (prevents sleep-loop soft lock)
global SleepOnQuit := false ; true sleeps on quit too, skipping the wait
global SleepDelaySec := 0 ; extra seconds awake before sleeping (helpful for device switching or quick breaks)
global ExcludedProcesses := Map( ; never suspended these apps
"explorer.exe", true,
"csrss.exe", true,
"winlogon.exe", true,
"svchost.exe", true,
"dwm.exe", true,
"cmd.exe", true,
"chrome.exe", true,
"powershell.exe", true,
"sublime_text.exe", true,
"sublime_merge.exe", true,
"steam.exe", true,
"steamwebhelper.exe", true,
"openconsole.exe", true,
"windowsterminal.exe", true,
"playnite.desktopapp.exe", true,
"playnite.fullscreenapp.exe", true,
)
; ------------------------
global StateFile := A_Temp "\AutoPauseResume.state"
global ResumeFile := A_Temp "\AutoPauseResume.resume"
global DisableFile := A_Temp "\AutoPauseResume.disable"
global LogFile := A_Temp "\AutoPauseResume.log"
; Holds a PID only while a process is frozen but not yet written to the state file.
global UnrecordedSuspendPid := 0
global WarnedMalformedMarker := false
OnExit(ThawUnrecorded)
; Without this an error opens a dialog no one can see, and the script hangs holding a
; frozen game.
OnError(FatalError)
Main()
ExitApp
Main()
{
global DisableFile, PowerAction, POWER_NONE
Log("pause: invoked (APOLLO_APP_STATUS=" (EnvGet("APOLLO_APP_STATUS") || "<empty>") ")")
if FileExist(DisableFile)
{
Log("pause: DISABLE file present; doing nothing")
return
}
game := ForegroundGame()
if QuitSignalled()
return
if game
SuspendGame(game)
if PowerAction = POWER_NONE
{
Log("pause: PowerAction=none; not changing power state")
return
}
if ReconnectedInTime()
return
SleepHost()
}
; A quit runs resume.ahk about a second after this hook. A plain disconnect never does,
; and nothing else tells them apart.
QuitSignalled()
{
global DebounceMs, SleepOnQuit
if SleepOnQuit
return false
start := A_Now
Log("pause: waiting " DebounceMs "ms for a quit/reconnect signal")
Sleep(DebounceMs)
stamp := ResumeStampSince(start)
if stamp = ""
{
Log("pause: no quit signal within " DebounceMs "ms; treating as a real disconnect")
return false
}
Log("pause: QUIT/reconnect detected (resume stamp " stamp " >= start " start "); not sleeping")
return true
}
; The marker's timestamp if it was stamped at or after `since`, otherwise "".
; A malformed marker must never decide anything, and must never throw.
ResumeStampSince(since)
{
global ResumeFile, WarnedMalformedMarker
stamp := ""
try stamp := Trim(FileRead(ResumeFile), " `t`r`n")
if !RegExMatch(stamp, "^\d{14}$")
{
if stamp != "" && !WarnedMalformedMarker
{
WarnedMalformedMarker := true
Log("pause: ignoring malformed resume marker '" stamp "'")
}
return ""
}
fresh := false
try fresh := DateDiff(stamp, since, "Seconds") >= 0
return fresh ? stamp : ""
}
; {pid, name} of the foreground process, or "" if there is nothing worth suspending.
; Read before the debounce, because a disconnect can steal focus while we wait.
ForegroundGame()
{
global ExcludedProcesses
if !WinExist("A")
{
Log("pause: no active window; nothing to suspend")
return ""
}
pid := WinGetPID("A")
name := pid ? ProcessGetName(pid) : ""
if !name
{
Log("pause: could not identify the foreground process; nothing to suspend")
return ""
}
if ExcludedProcesses.Has(StrLower(name))
{
Log("pause: foreground process '" name "' is excluded; nothing to suspend")
return ""
}
return { pid: pid, name: name }
}
SuspendGame(game)
{
global StateFile, UnrecordedSuspendPid
if !ProcessMatches(game.pid, game.name)
{
Log("pause: '" game.name "' (PID " game.pid ") is gone; nothing to suspend")
return
}
if FileExist(StateFile)
FileDelete(StateFile)
if !CallNtProcessFunc(game.pid, "NtSuspendProcess")
{
Log("pause: failed to suspend '" game.name "' (PID " game.pid ")")
return
}
UnrecordedSuspendPid := game.pid
try
{
FileAppend(game.pid "|" game.name, StateFile)
UnrecordedSuspendPid := 0
Log("pause: suspended '" game.name "' (PID " game.pid ")")
}
catch as e
{
; Nothing could thaw it later.
CallNtProcessFunc(game.pid, "NtResumeProcess")
UnrecordedSuspendPid := 0
Log("pause: state write failed (" e.Message "); resumed, not tracking")
}
}
; Separate from the debounce on purpose. The debounce has to sit out its whole window
; because silence is what proves it was a disconnect; this one gives up the moment a
; reconnect lands.
ReconnectedInTime()
{
global SleepDelaySec
if SleepDelaySec <= 0
return false
POLL_MS := 500
start := A_Now
deadline := A_TickCount + (SleepDelaySec * 1000)
Log("pause: holding " SleepDelaySec "s for a reconnect before sleeping")
while A_TickCount < deadline
{
Sleep(POLL_MS)
if ResumeStampSince(start) != ""
{
Log("pause: reconnected inside the " SleepDelaySec "s window; not sleeping")
return true
}
}
Log("pause: no reconnect within " SleepDelaySec "s")
return false
}
; Forced, so a held audio sink or fullscreen window cannot block it. POWER_HIBERNATE
; falls back to S3 unless `powercfg /hibernate on`.
SleepHost()
{
global PowerAction, POWER_HIBERNATE
FORCE := 1, ALLOW_WAKE_EVENTS := 0
hibernate := (PowerAction = POWER_HIBERNATE) ? 1 : 0
Log("pause: " (hibernate ? "hibernating host (S4)" : "sleeping host (S3)"))
DllCall("PowrProf\SetSuspendState", "Int", hibernate, "Int", FORCE, "Int", ALLOW_WAKE_EVENTS)
}
; PIDs get reused, so check the name too.
ProcessMatches(pid, name)
{
return ProcessExist(pid) && StrLower(ProcessGetName(pid)) = StrLower(name)
}
CallNtProcessFunc(targetPid, funcName)
{
PROCESS_SUSPEND_RESUME := 0x0800
hProc := DllCall("OpenProcess", "UInt", PROCESS_SUSPEND_RESUME, "Int", 0, "UInt", targetPid, "Ptr")
if !hProc
return false
pFunc := DllCall("GetProcAddress", "Ptr", DllCall("GetModuleHandle", "Str", "ntdll.dll", "Ptr"), "AStr", funcName, "Ptr")
if !pFunc
{
DllCall("CloseHandle", "Ptr", hProc)
return false
}
status := DllCall(pFunc, "Ptr", hProc, "Int")
DllCall("CloseHandle", "Ptr", hProc)
return status >= 0
}
; Thaw a process we froze but never recorded, since nothing else can.
ThawUnrecorded(*)
{
global UnrecordedSuspendPid
if UnrecordedSuspendPid
CallNtProcessFunc(UnrecordedSuspendPid, "NtResumeProcess")
}
FatalError(err, mode)
{
Log("pause: FATAL " Type(err) ": " (HasProp(err, "Message") ? err.Message : "?")
. (HasProp(err, "Line") ? " (line " err.Line ")" : ""))
ThawUnrecorded()
ExitApp(1)
}
Log(msg)
{
global LogFile
try FileAppend(FormatTime(, "yyyy-MM-dd HH:mm:ss") " " msg "`n", LogFile)
}
#Requires AutoHotkey v2.0
#SingleInstance Off
global StateFile := A_Temp "\AutoPauseResume.state"
global ResumeFile := A_Temp "\AutoPauseResume.resume"
global LogFile := A_Temp "\AutoPauseResume.log"
; Without this an error opens a dialog no one can see, and the game stays frozen.
OnError(FatalError)
Main()
ExitApp
Main()
{
StampResumeMarker()
pid := 0, name := ""
if !ReadTrackedProcess(&pid, &name)
return
if !ProcessMatches(pid, name)
{
Log("resume: PID " pid " is gone or reused; nothing to resume")
return
}
ok := CallNtProcessFunc(pid, "NtResumeProcess")
Log(ok
? "resume: resumed '" name "' (PID " pid ")"
: "resume: failed to resume '" name "' (PID " pid ")")
}
; Stamped before any early return: a quit with nothing suspended must still cancel the
; pending sleep.
StampResumeMarker()
{
global ResumeFile
try
{
if FileExist(ResumeFile)
FileDelete(ResumeFile)
FileAppend(A_Now, ResumeFile)
}
}
; Consumes the state file pause.ahk left behind. False if there is nothing usable in it.
ReadTrackedProcess(&pid, &name)
{
global StateFile
if !FileExist(StateFile)
return false
raw := ""
try raw := Trim(FileRead(StateFile), " `t`r`n")
try FileDelete(StateFile)
parts := StrSplit(raw, "|")
if parts.Length < 2 || !IsInteger(parts[1])
return false
pid := Integer(parts[1]), name := parts[2]
return true
}
; PIDs get reused, so check the name too.
ProcessMatches(pid, name)
{
return ProcessExist(pid) && StrLower(ProcessGetName(pid)) = StrLower(name)
}
CallNtProcessFunc(targetPid, funcName)
{
PROCESS_SUSPEND_RESUME := 0x0800
hProc := DllCall("OpenProcess", "UInt", PROCESS_SUSPEND_RESUME, "Int", 0, "UInt", targetPid, "Ptr")
if !hProc
return false
pFunc := DllCall("GetProcAddress", "Ptr", DllCall("GetModuleHandle", "Str", "ntdll.dll", "Ptr"), "AStr", funcName, "Ptr")
if !pFunc
{
DllCall("CloseHandle", "Ptr", hProc)
return false
}
status := DllCall(pFunc, "Ptr", hProc, "Int")
DllCall("CloseHandle", "Ptr", hProc)
return status >= 0
}
FatalError(err, mode)
{
Log("resume: FATAL " Type(err) ": " (HasProp(err, "Message") ? err.Message : "?")
. (HasProp(err, "Line") ? " (line " err.Line ")" : ""))
ExitApp(1)
}
Log(msg)
{
global LogFile
try FileAppend(FormatTime(, "yyyy-MM-dd HH:mm:ss") " " msg "`n", LogFile)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment