Skip to content

Commit 4d77f9b

Browse files
authored
Add FPS limiter (#217)
* Add FPS limiter * Add margin to fps settings * Fix styling
1 parent 9399452 commit 4d77f9b

File tree

12 files changed

+472
-34
lines changed

12 files changed

+472
-34
lines changed
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
using System.Reactive;
2+
using System.Reactive.Disposables;
3+
using System.Reactive.Linq;
4+
using System.Reactive.Subjects;
5+
6+
using H2MLauncher.Core.Game.Models;
7+
using H2MLauncher.Core.Services;
8+
using H2MLauncher.Core.Settings;
9+
using H2MLauncher.Core.Utilities;
10+
11+
using Microsoft.Extensions.Logging;
12+
using Microsoft.Extensions.Options;
13+
14+
using Nogic.WritableOptions;
15+
16+
using static H2MLauncher.Core.Services.GameDirectoryService;
17+
18+
namespace H2MLauncher.Core.Game;
19+
20+
public sealed class FpsLimiter : IDisposable
21+
{
22+
private readonly CompositeDisposable _disposables = [];
23+
24+
private readonly H2MCommunicationService _communicationService;
25+
private readonly IWritableOptions<H2MLauncherSettings> _options;
26+
private readonly ILogger<FpsLimiter> _logger;
27+
28+
private readonly IObservable<int> _cfgMaxFpsO;
29+
private readonly IObservable<H2MLauncherSettings> _settingsO;
30+
private readonly IObservable<bool> _applyLimitO;
31+
32+
/// <summary>
33+
/// Whether the game memory communication is running.
34+
/// </summary>
35+
private readonly BehaviorSubject<bool> _gameCommunicationRunningSubject;
36+
37+
/// <summary>
38+
/// Controls whether the limiter is started.
39+
/// </summary>
40+
private readonly BehaviorSubject<bool> _limiterActiveSubject = new(false);
41+
42+
/// <summary>
43+
/// Disposable subscriptions of the current limiter chains.
44+
/// </summary>
45+
private readonly SerialDisposable _limiterDisposable = new();
46+
47+
/// <summary>
48+
/// Holds the status of the FPS limiter.
49+
/// </summary>
50+
private readonly BehaviorSubject<FpsLimiterStatus> _limiterStatusSubject = new(FpsLimiterStatus.Idle);
51+
52+
/// <summary>
53+
/// Indicates whether the FPS limiter has currently applied the limited max FPS.
54+
/// </summary>
55+
private readonly BehaviorSubject<bool> _isLimitedSubject = new(false);
56+
57+
/// <summary>
58+
/// The current state of the FPS limiter.
59+
/// </summary>
60+
public IObservable<FpsLimiterState> StateO { get; }
61+
62+
public enum FpsLimiterStatus
63+
{
64+
/// <summary>
65+
/// Not attempting to limit FPS.
66+
/// </summary>
67+
Idle,
68+
69+
/// <summary>
70+
/// Limiter is successfully applying limits
71+
/// </summary>
72+
Active,
73+
74+
/// <summary>
75+
/// Limiter encountered an error and is not active
76+
/// </summary>
77+
Failed
78+
}
79+
80+
public record FpsLimiterState(FpsLimiterStatus Status, bool IsLimited, int CurrentMaxFps);
81+
82+
public FpsLimiter(
83+
GameDirectoryService gameDirectoryService,
84+
H2MCommunicationService communicationService,
85+
IWritableOptions<H2MLauncherSettings> options,
86+
ILogger<FpsLimiter> logger)
87+
{
88+
_communicationService = communicationService;
89+
_options = options;
90+
_logger = logger;
91+
92+
// Observable that emits the current settings
93+
_settingsO = CreateSettingsObservable(options);
94+
95+
// Observable that emits the current config
96+
_cfgMaxFpsO = CreateMaxFpsObservable(gameDirectoryService);
97+
98+
// Observable that emits whether the game is in main menu and the limit should be applied
99+
_applyLimitO = CreateLimitTriggerObservable(communicationService.GameCommunication);
100+
101+
// Observable that emits whether game memory communication is running
102+
_gameCommunicationRunningSubject = new(communicationService.GameCommunication.IsGameCommunicationRunning);
103+
104+
communicationService.GameCommunication.Started += OnGameCommunicationStarted;
105+
communicationService.GameCommunication.Stopped += OnGameCommunicationStopped;
106+
107+
// Switch whether the limiter is active (setting + game communication)
108+
_disposables.Add(
109+
Observable.CombineLatest(
110+
// Feature is enabled
111+
_settingsO
112+
.Select(settings => settings.FpsLimiterEnabled)
113+
.DistinctUntilChanged(),
114+
115+
// Whether game memory communication is running
116+
_gameCommunicationRunningSubject.DistinctUntilChanged())
117+
.Select(conditions => conditions.All(c => c == true)) // All conditions must be met
118+
.Do(_limiterActiveSubject.OnNext) // Push value to active subject
119+
.Where(active => active == true)
120+
.Subscribe(_ => StartLimiter())
121+
);
122+
123+
// Compute the state
124+
StateO = Observable.CombineLatest(
125+
_isLimitedSubject.DistinctUntilChanged(),
126+
_limiterStatusSubject.DistinctUntilChanged(),
127+
_cfgMaxFpsO,
128+
(isLimited, status, fps) => new FpsLimiterState(status, isLimited, fps));
129+
}
130+
131+
private void StartLimiter()
132+
{
133+
try
134+
{
135+
CompositeDisposable limiterDisposables = [];
136+
137+
// Emits when limiter is stopped
138+
IObservable<bool> stopLimiterO = _limiterActiveSubject
139+
.DistinctUntilChanged()
140+
.Where(active => active == false)
141+
.Take(1);
142+
143+
// Fps limit from settings
144+
IObservable<int> settingsMaxFpsO = _settingsO
145+
.Select(settings => settings.MaxFps)
146+
.DistinctUntilChanged();
147+
148+
// Dispose previous limiter and set new one
149+
_limiterDisposable.Disposable = limiterDisposables;
150+
151+
// Sync back user changes from config or game
152+
limiterDisposables.Add(Observable
153+
.CombineLatest(_isLimitedSubject.DistinctUntilChanged(), _cfgMaxFpsO, settingsMaxFpsO,
154+
(isLimited, cfgMaxFps, settingsMaxFps) => (isLimited, cfgMaxFps, settingsMaxFps))
155+
.Throttle(TimeSpan.FromMilliseconds(500)) // debounce a little to let updated settings reload
156+
.TakeUntil(stopLimiterO)
157+
.Finally(() => _logger.LogInformation("Max FPS config -> settings sync stopped."))
158+
.Subscribe((x) =>
159+
{
160+
if (x.settingsMaxFps == -1)
161+
{
162+
// First time activating this -> Update settings from current in game limit
163+
_logger.LogInformation(
164+
"Max FPS in settings not set, updating unlimited max fps in settings to {newFpsLimit}.",
165+
x.cfgMaxFps);
166+
}
167+
else if (!x.isLimited && x.cfgMaxFps != x.settingsMaxFps)
168+
{
169+
// The user changed max fps in game or config -> Update settings
170+
_logger.LogInformation(
171+
"Max FPS in config different than settings while limit not applied, " +
172+
"updating unlimited max fps in settings from {currentFpsLimit} to {newFpsLimit}.",
173+
x.settingsMaxFps,
174+
x.cfgMaxFps);
175+
}
176+
else
177+
{
178+
return;
179+
}
180+
181+
_options.Update((s) =>
182+
{
183+
return s with { MaxFps = x.cfgMaxFps };
184+
});
185+
}));
186+
187+
// Apply fps limit when in main menu
188+
limiterDisposables.Add(_applyLimitO
189+
.WithLatestFrom(_settingsO)
190+
.TakeUntil(stopLimiterO)
191+
.Finally(() => _logger.LogInformation("FPS limiter stopped."))
192+
.SelectMany(values => Observable.FromAsync(async () =>
193+
{
194+
(bool limited, H2MLauncherSettings settings) = values;
195+
196+
await ApplyFpsLimit(limited, settings);
197+
}))
198+
.Subscribe(
199+
_ => { },
200+
ex => // OnError for the whole chain if something unexpected happens
201+
{
202+
_logger.LogError(ex, "An unhandled error occurred in FPS limiter application chain.");
203+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
204+
},
205+
() => // OnCompleted when TakeUntil triggers
206+
{
207+
_logger.LogInformation("FPS limiter application command chain completed.");
208+
209+
// When the TakeUntil (stopLimiterO) triggers, it means the limiter is stopping.
210+
// If it stopped due to an error, FpsLimiterStatus.Failed would have been set.
211+
// If it stopped gracefully, we transition to Idle.
212+
if (_limiterStatusSubject.Value != FpsLimiterStatus.Failed)
213+
{
214+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Idle);
215+
}
216+
})
217+
);
218+
219+
// Subscription to reset FPS when limiter becomes inactive
220+
limiterDisposables.Add(
221+
stopLimiterO
222+
.WithLatestFrom(_settingsO, (_, settings) => settings)
223+
.SelectMany((H2MLauncherSettings settings) =>
224+
Observable.FromAsync(() => ResetFpsLimit(settings.MaxFps))
225+
)
226+
.Subscribe()
227+
);
228+
229+
_logger.LogInformation("FPS limiter successfully started.");
230+
}
231+
catch (Exception ex)
232+
{
233+
_logger.LogError(ex, "Error while starting FPS limiter.");
234+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
235+
_limiterDisposable.Disposable = null;
236+
}
237+
}
238+
239+
private async Task ApplyFpsLimit(bool limited, H2MLauncherSettings settings)
240+
{
241+
try
242+
{
243+
int maxFps = limited
244+
? settings.MaxFpsLimited
245+
: settings.MaxFps;
246+
247+
if (maxFps < 0)
248+
{
249+
_logger.LogInformation("Skipping applying FPS limit because it is unset.");
250+
return;
251+
}
252+
253+
_logger.LogDebug("Changing max FPS to {maxFps} (limited: {limited}).", maxFps, limited);
254+
255+
bool success = await ExecuteMaxFpsCommand(maxFps);
256+
if (success)
257+
{
258+
_logger.LogInformation("Successfully changed max FPS to {maxFps}.", maxFps);
259+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Active);
260+
_isLimitedSubject.OnNext(limited);
261+
}
262+
else
263+
{
264+
_logger.LogInformation("Failed to change max FPS to {maxFps}.", maxFps);
265+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
266+
}
267+
}
268+
catch (Exception ex)
269+
{
270+
_logger.LogError(ex, "Error while changing max FPS.");
271+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
272+
}
273+
}
274+
275+
private async Task ResetFpsLimit(int maxFps = 0)
276+
{
277+
_logger.LogInformation("FPS limiter has stopped. Attempting to reset game FPS limit to default.");
278+
279+
try
280+
{
281+
bool success = await ExecuteMaxFpsCommand(maxFps);
282+
if (success)
283+
{
284+
_logger.LogInformation("Successfully reset max FPS to {defaultMaxFps}.", maxFps);
285+
286+
// After reset, we are idle again if no other start conditions apply
287+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Idle);
288+
_isLimitedSubject.OnNext(false);
289+
}
290+
else
291+
{
292+
_logger.LogWarning("Failed to reset max FPS to {defaultMaxFps}.", maxFps);
293+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
294+
}
295+
}
296+
catch (Exception ex)
297+
{
298+
_logger.LogError(ex, "Error while attempting to reset FPS limit.");
299+
_limiterStatusSubject.OnNext(FpsLimiterStatus.Failed);
300+
}
301+
}
302+
303+
private Task<bool> ExecuteMaxFpsCommand(int maxFps)
304+
{
305+
return _communicationService.ExecuteCommandAsync(
306+
commands: [$"com_maxfps {maxFps}"],
307+
bringGameWindowToForeground: false);
308+
}
309+
310+
private void OnGameCommunicationStopped(Exception? obj)
311+
{
312+
_gameCommunicationRunningSubject.OnNext(false);
313+
}
314+
315+
private void OnGameCommunicationStarted(System.Diagnostics.Process obj)
316+
{
317+
_gameCommunicationRunningSubject.OnNext(true);
318+
}
319+
320+
private static IObservable<H2MLauncherSettings> CreateSettingsObservable(IOptionsMonitor<H2MLauncherSettings> options)
321+
{
322+
return Observable
323+
.Create<H2MLauncherSettings>(observer =>
324+
{
325+
return options.OnChange((settings, _) => observer.OnNext(settings)) ?? Disposable.Empty;
326+
})
327+
.StartWith(options.CurrentValue)
328+
.Replay(1)
329+
.RefCount();
330+
}
331+
332+
private static IObservable<int> CreateMaxFpsObservable(GameDirectoryService gameDirectoryService)
333+
{
334+
return Observable
335+
.FromEvent<ConfigChangedEventHandler, ConfigMpContent?>(
336+
(a) => (filePath, cfg) => a(cfg),
337+
(h) => gameDirectoryService.ConfigMpChanged += h,
338+
(h) => gameDirectoryService.ConfigMpChanged -= h
339+
)
340+
.StartWith(gameDirectoryService.CurrentConfigMp)
341+
.Where(cfg => cfg is not null)
342+
.Select(cfg => cfg!.MaxFps)
343+
.DistinctUntilChanged()
344+
.Replay(1)
345+
.RefCount();
346+
}
347+
348+
private static IObservable<bool> CreateLimitTriggerObservable(IGameCommunicationService gameCommunicationService)
349+
{
350+
return Observable
351+
.FromEvent<GameState>(
352+
(h) => gameCommunicationService.GameStateChanged += h,
353+
(h) => gameCommunicationService.GameStateChanged -= h)
354+
.StartWith(gameCommunicationService.CurrentGameState)
355+
.Select(s => s.IsInMainMenu)
356+
.DistinctUntilChanged()
357+
.Replay(1)
358+
.RefCount();
359+
}
360+
361+
public void Dispose()
362+
{
363+
_disposables.Dispose();
364+
_limiterDisposable.Dispose();
365+
366+
_limiterActiveSubject.Dispose();
367+
_gameCommunicationRunningSubject.Dispose();
368+
_limiterStatusSubject.Dispose();
369+
_isLimitedSubject.Dispose();
370+
371+
_communicationService.GameCommunication.Started -= OnGameCommunicationStarted;
372+
_communicationService.GameCommunication.Stopped -= OnGameCommunicationStopped;
373+
}
374+
}

H2MLauncher.Core/Game/GameDirectoryService.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@ public record ConfigMpContent(string? PlayerName, string? LastHostName, int MaxF
3333
public ConfigMpContent? CurrentConfigMp { get; private set; }
3434

3535

36-
public event Action<string, ConfigMpContent?>? ConfigMpChanged;
36+
public event ConfigChangedEventHandler? ConfigMpChanged;
3737

3838
public event Action<string?, IReadOnlyList<string>>? UsermapsChanged;
3939

4040
public event Action<string, string>? FastFileChanged;
4141

42+
public delegate void ConfigChangedEventHandler(string filePath, ConfigMpContent? config);
43+
4244

4345
public GameDirectoryService(IOptionsMonitor<H2MLauncherSettings> optionsMonitor, ILogger<GameDirectoryService> logger)
4446
{

H2MLauncher.Core/Game/H2MCommunicationService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ public Task<bool> Disconnect()
257257
return ExecuteCommandAsync(["disconnect"]);
258258
}
259259

260-
private async Task<bool> ExecuteCommandAsync(string[] commands, bool bringGameWindowToForeground = true)
260+
public async Task<bool> ExecuteCommandAsync(string[] commands, bool bringGameWindowToForeground = true)
261261
{
262262
Process? h2mModProcess = FindH2MModProcess();
263263
if (h2mModProcess == null)

0 commit comments

Comments
 (0)