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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ obj/
*_wpftmp.csproj

# Build Output
/artifacts/
publish/
publish-minimal/
publish-small/
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ That is the whole fix. Everything below is optional.
- Restores corrections automatically on startup, display changes, resume, and driver or game ramp resets.
- Provides a perceptual night mode built on CAT16 chromatic adaptation, with fixed-time or sun-position schedules, a manual override, perceptually uniform mired-space fades, and an Ultra Night profile for maximum blue suppression.
- Adds per-game Game Lab profiles with calibrated shadow visibility, Reference, Competitive Clarity, Cinematic HDR, and spectral Night Ops intents, multi-display window targeting, and a Gameplay Lock that holds schedule-derived output steady during play.
- Offers an opt-in "Apply while the app is running, even without focus" setting under each profile's Advanced controls. Choose "One specific display" for apps such as MPV on a second monitor. Focused profiles take priority; otherwise the first matching running profile is used. Background app starts and exits are detected within about three seconds.
- Checks the Windows-visible game signal chain at launch, including SDR/HDR expectation, DXGI color space, output bit depth, HDR headroom, and measured-calibration status. The dashboard keeps a live receipt of the active intent and every finding.
- Auto-disables night mode for chosen applications while they are in the foreground, either on every monitor or only on the displays the application's window covers. Gamma correction and installed calibration profiles stay active.
- Supports SDR and HDR colorimeter calibration through ArgyllCMS, including native Windows MHC2 profile installation and verification.
Expand Down
4 changes: 3 additions & 1 deletion docs/gamer-mode-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ That boundary matters. Anti-cheat systems see an ordinary desktop color-manageme
3. Gloam captures a session record for each selected display and runs the signal checks.
4. The profile contributes gamma, linear-light shadow visibility, and its night policy to the normal calibration settings.
5. The existing coalescer applies only the newest state. The ramp guard continues to restore corrections after a driver or game clears them.
6. Leaving the game clears the session and restores the monitor's ordinary settings.
6. Leaving the game clears the session and restores the monitor's ordinary settings, unless the profile uses "Apply while the app is running, even without focus".

The optional running policy checks enabled profiles every two seconds and verifies their executable paths. It retains the profile until the app exits, including while minimized when targeting one specific display or all displays. Window targeting uses the running process's main window bounds. A focused profile takes priority; otherwise the first matching running profile in saved order owns the session. Repeated checks preserve the captured Gameplay Lock state. Pause and the emergency hotkey bypass running profiles too.

`Win + Shift + G` opens a topmost Game Lab window and keeps the game that yielded focus as the policy target. A newly added profile can therefore activate before the console closes. Field edits are coalesced for 180 ms, persisted, and sent through the foreground resolver. Running executable choices refresh every 2.5 seconds without resetting typed text or an open picker. Repeated focus events preserve the original session time and locked color temperature.

Expand Down
7 changes: 7 additions & 0 deletions docs/release-notes/v1.9.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Gloam 1.9.3

- Keep an app's profile active while you work on another screen. In the profile's Advanced controls, enable **Apply while the app is running** and choose its target display.
- Find your Gloam version in the tray menu, Dashboard, and Game Lab.
- Gameplay Lock stays steady during background checks, and your usual display settings return when the app closes.

Thanks for the suggestion in [#9](https://github.com/halideworks/gloam/issues/9)!
7 changes: 5 additions & 2 deletions src/Gloam.Core/GamerMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ public sealed class GamerProfileRule
public string? ExecutablePath { get; set; }
public string DisplayName { get; set; } = string.Empty;
public bool Enabled { get; set; } = true;
public bool ApplyWhileRunning { get; set; }
public GamerPictureIntent PictureIntent { get; set; } = GamerPictureIntent.CompetitiveClarity;
public GamerDisplayScope DisplayScope { get; set; } = GamerDisplayScope.WindowDisplays;
public string? MonitorDevicePath { get; set; }
Expand Down Expand Up @@ -184,6 +185,7 @@ public sealed class GamerProfileRule
ExecutablePath = ExecutablePath,
DisplayName = DisplayName,
Enabled = Enabled,
ApplyWhileRunning = ApplyWhileRunning,
PictureIntent = PictureIntent,
DisplayScope = DisplayScope,
MonitorDevicePath = MonitorDevicePath,
Expand Down Expand Up @@ -239,7 +241,7 @@ public GamerProfileRule Sanitized()
return copy;
}

public bool SemanticallyEquals(GamerProfileRule? other)
public bool SemanticallyEquals(GamerProfileRule? other, bool includeRecency = true)
{
if (other == null) return false;
GamerProfileRule a = Sanitized();
Expand All @@ -248,6 +250,7 @@ public bool SemanticallyEquals(GamerProfileRule? other)
&& string.Equals(a.ExecutablePath, b.ExecutablePath, StringComparison.OrdinalIgnoreCase)
&& string.Equals(a.DisplayName, b.DisplayName, StringComparison.Ordinal)
&& a.Enabled == b.Enabled
&& a.ApplyWhileRunning == b.ApplyWhileRunning
&& a.PictureIntent == b.PictureIntent
&& a.DisplayScope == b.DisplayScope
&& string.Equals(a.MonitorDevicePath, b.MonitorDevicePath, StringComparison.OrdinalIgnoreCase)
Expand All @@ -265,7 +268,7 @@ public bool SemanticallyEquals(GamerProfileRule? other)
&& a.PaperWhiteNits == b.PaperWhiteNits
&& a.PeakNits == b.PeakNits
&& a.BlackLevelNits == b.BlackLevelNits
&& a.LastUsedUtc == b.LastUsedUtc;
&& (!includeRecency || a.LastUsedUtc == b.LastUsedUtc);
}

public bool IsHdrCapable() =>
Expand Down
108 changes: 94 additions & 14 deletions src/Gloam.Core/GamerModeCoordinator.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
Expand All @@ -8,6 +9,8 @@

namespace Gloam.Core
{
public sealed record RunningAppObservation(string AppName, string? ExecutablePath, Dxgi.RECT? Bounds);

public sealed record GamerPolicyChange(
string ForegroundApp,
int NightBlockedDisplayCount,
Expand All @@ -26,6 +29,8 @@ public sealed class GamerModeCoordinator : IDisposable
private readonly SettingsManager _settings;
private readonly GammaApplyService _applyService;
private readonly Func<IReadOnlyList<MonitorInfo>> _monitorSnapshot;
private readonly Func<IReadOnlyList<RunningAppObservation>> _runningAppSnapshot;
private readonly Timer _runningAppTimer;
private readonly LatestValueCoalescer<string, ForegroundObservation> _foregroundCoalescer;
private readonly object _lastLock = new();
private readonly object _evaluationLock = new();
Expand All @@ -46,11 +51,13 @@ private sealed record ForegroundObservation(
public GamerModeCoordinator(
SettingsManager settings,
GammaApplyService applyService,
Func<IReadOnlyList<MonitorInfo>> monitorSnapshot)
Func<IReadOnlyList<MonitorInfo>> monitorSnapshot,
Func<IReadOnlyList<RunningAppObservation>>? runningAppSnapshot = null)
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_applyService = applyService ?? throw new ArgumentNullException(nameof(applyService));
_monitorSnapshot = monitorSnapshot ?? throw new ArgumentNullException(nameof(monitorSnapshot));
_runningAppSnapshot = runningAppSnapshot ?? (() => CaptureRunningApps(_settings.GamerProfiles));
_foregroundCoalescer = new LatestValueCoalescer<string, ForegroundObservation>(
(_, observation, token) =>
{
Expand All @@ -62,6 +69,8 @@ public GamerModeCoordinator(
});
_settings.GamerSettingsChanged += OnGamerSettingsChanged;
_settings.MonitorProfileChanged += OnMonitorProfileChanged;
_runningAppTimer = new Timer(_ => RefreshRunningApps(), null,
TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
}

public event Action<GamerPolicyChange>? PolicyChanged;
Expand Down Expand Up @@ -107,28 +116,70 @@ public void ObserveForeground(string appName, string? executablePath, Dxgi.RECT?
: TimeSpan.FromMilliseconds(250);

var observation = new ForegroundObservation(appName, executablePath, appBounds, delay);
lock (_lastLock) _last = observation;
_foregroundCoalescer.Submit(ForegroundKey, observation);
lock (_lastLock)
{
_last = observation;
_foregroundCoalescer.Submit(ForegroundKey, observation);
}
}

/// <summary>Re-runs the latest foreground decision without focus churn.</summary>
public void ReevaluateLatest(bool immediate = false)
{
if (Volatile.Read(ref _disposed) != 0) return;
ForegroundObservation observation;
lock (_lastLock) observation = _last;
if (immediate)
{
_foregroundCoalescer.Cancel(ForegroundKey);
Evaluate(observation with { SettleDelay = TimeSpan.Zero });
}
else
lock (_lastLock)
{
_foregroundCoalescer.Submit(ForegroundKey, observation);
observation = _last;
if (immediate)
_foregroundCoalescer.Cancel(ForegroundKey);
else
{
_foregroundCoalescer.Submit(ForegroundKey, observation);
return;
}
}
Evaluate(observation);
}

public void CancelPendingForeground() => _foregroundCoalescer.Cancel(ForegroundKey);

internal void RefreshRunningApps()
{
if (Enabled && _settings.GamerProfiles.Any(profile => profile.Enabled && profile.ApplyWhileRunning))
ReevaluateLatest();
}

internal static IReadOnlyList<RunningAppObservation> CaptureRunningApps(IEnumerable<GamerProfileRule> profiles)
{
var apps = new List<RunningAppObservation>();
foreach (string appName in profiles
.Where(profile => profile.Enabled && profile.ApplyWhileRunning &&
GamerExecutableSafety.IsSafeProfileTarget(profile.AppName, profile.DisplayName))
.Select(profile => profile.AppName).Distinct(StringComparer.OrdinalIgnoreCase))
{
foreach (Process process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(appName)))
{
using (process)
{
try
{
string? path = process.MainModule?.FileName;
IntPtr window = process.MainWindowHandle;
Dxgi.RECT? bounds = window != IntPtr.Zero && User32.GetWindowRect(window, out var rect)
? rect : null;
apps.Add(new RunningAppObservation(appName, path, bounds));
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException)
{
// Exited or inaccessible processes cannot prove executable identity.
}
}
}
}
return apps;
}

/// <summary>
/// In-memory fail-safe used by the panic hotkey. It works even if the settings
/// directory is unavailable, ensuring a saved profile cannot re-arm itself after
Expand Down Expand Up @@ -161,6 +212,7 @@ private void Evaluate(ForegroundObservation observation)
{
lock (_evaluationLock)
{
if (Volatile.Read(ref _disposed) != 0) return;
IReadOnlyList<MonitorInfo> activeMonitors =
_monitorSnapshot() ?? Array.Empty<MonitorInfo>();
AppExclusionRule? exclusion = _settings.ExcludedApps.FirstOrDefault(rule =>
Expand All @@ -184,10 +236,16 @@ private void Evaluate(ForegroundObservation observation)
GamerProfileRule? profile = null;
if (Enabled)
{
profile = _settings.GamerProfiles.FirstOrDefault(candidate =>
IReadOnlyList<GamerProfileRule> profiles = _settings.GamerProfiles;
IReadOnlyList<RunningAppObservation> runningApps = profiles.Any(candidate =>
candidate.Enabled && candidate.ApplyWhileRunning)
? _runningAppSnapshot() : Array.Empty<RunningAppObservation>();
profile = profiles.FirstOrDefault(candidate =>
candidate.Enabled &&
GamerExecutableSafety.IsSafeProfileTarget(candidate.AppName, candidate.DisplayName) &&
MatchesForeground(candidate, observation.AppName, observation.ExecutablePath));
MatchesForeground(candidate, observation.AppName, observation.ExecutablePath) &&
(!candidate.ApplyWhileRunning || runningApps.Any(app =>
MatchesForeground(candidate, app.AppName, app.ExecutablePath))));
if (profile != null)
{
foreach (MonitorInfo monitor in activeMonitors)
Expand All @@ -196,13 +254,33 @@ private void Evaluate(ForegroundObservation observation)
assignments.Add(new GamerSessionAssignment(monitor, profile));
}
}
if (assignments.Count == 0)
{
foreach (GamerProfileRule candidate in profiles.Where(candidate =>
candidate.Enabled && candidate.ApplyWhileRunning))
{
foreach (RunningAppObservation app in runningApps.Where(app =>
MatchesForeground(candidate, app.AppName, app.ExecutablePath)))
{
foreach (MonitorInfo monitor in activeMonitors)
{
if (TargetsMonitor(candidate, monitor, app.Bounds, activeMonitors.Count) &&
assignments.All(assignment => assignment.Monitor.HMonitor != monitor.HMonitor))
assignments.Add(new GamerSessionAssignment(monitor, candidate));
}
}
if (assignments.Count == 0) continue;
profile = candidate;
break;
}
}
}

bool blockChanged = _applyService.UpdateBlockedMonitors(blocked);
bool gamerChanged = _applyService.UpdateActiveGamerSessions(assignments);
if (gamerChanged && profile != null && assignments.Count > 0)
{
string activatedApp = observation.AppName;
string activatedApp = profile.AppName;
_ = Task.Run(() => _settings.MarkGamerProfileUsed(activatedApp, DateTime.UtcNow));
}

Expand Down Expand Up @@ -286,7 +364,9 @@ public void Dispose()
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_settings.GamerSettingsChanged -= OnGamerSettingsChanged;
_settings.MonitorProfileChanged -= OnMonitorProfileChanged;
_runningAppTimer.Dispose();
_foregroundCoalescer.Dispose();
lock (_evaluationLock) { }
}
}
}
4 changes: 2 additions & 2 deletions src/Gloam.Core/GammaApplyService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public bool UpdateActiveGamerSessions(IEnumerable<GamerSessionAssignment>? assig
int signalKey = GamerSignalKey(monitor, monitorProfile, calibrationStatus);

if (_activeGamerSessions.TryGetValue(monitor.HMonitor, out var previous) &&
previous.Profile.SemanticallyEquals(profile) &&
previous.Profile.SemanticallyEquals(profile, includeRecency: false) &&
previous.SignalKey == signalKey &&
string.Equals(previous.Monitor.MonitorDevicePath, monitor.MonitorDevicePath,
StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -264,7 +264,7 @@ private static bool GamerSessionMapsEqual(
foreach (var pair in right)
{
if (!left.TryGetValue(pair.Key, out var existing)) return false;
if (!existing.Profile.SemanticallyEquals(pair.Value.Profile) ||
if (!existing.Profile.SemanticallyEquals(pair.Value.Profile, includeRecency: false) ||
existing.SignalKey != pair.Value.SignalKey ||
!string.Equals(existing.Monitor.MonitorDevicePath, pair.Value.Monitor.MonitorDevicePath,
StringComparison.OrdinalIgnoreCase))
Expand Down
5 changes: 4 additions & 1 deletion src/Gloam/DashboardWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
<Border Width="1" Height="16" Background="{DynamicResource ThemeTextDim}" Opacity="0.5" Margin="12,0"/>
<TextBlock x:Name="WindowSectionTitle" Text="Dashboard" FontFamily="{StaticResource DisplayFont}" FontWeight="Bold"
FontSize="12" Foreground="{DynamicResource ThemeTextDim}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding AppVersion}" FontFamily="{StaticResource DisplayFont}"
<TextBlock x:Name="AppVersionLabel" Text="{Binding AppVersion}" FontFamily="{StaticResource DisplayFont}"
FontSize="11" Foreground="{DynamicResource ThemeTextDim}" Opacity="0.7"
VerticalAlignment="Center" Margin="10,0,0,0" ToolTip="App version"/>
</StackPanel>
Expand Down Expand Up @@ -910,6 +910,9 @@
ItemsSource="{Binding DataContext.GamerMode.AvailableDisplays, RelativeSource={RelativeSource AncestorType=Window}}"
DisplayMemberPath="Label" SelectedValuePath="DevicePath"
SelectedValue="{Binding MonitorDevicePath, Mode=TwoWay}"/>
<CheckBox x:Name="ApplyWhileRunningBox" Content="Apply while the app is running, even without focus"
IsChecked="{Binding ApplyWhileRunning, Mode=TwoWay}" Margin="0,10,0,0"
ToolTip="Use One specific display to keep the profile active on a chosen monitor, including when the app is minimized. A focused profile takes priority; otherwise the first matching running profile is used."/>
<Grid Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
Expand Down
Loading