From 919ae7ab0f13008669b60ce76b2d8ba221badb30 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 3 Oct 2026 16:42:36 -0300 Subject: [PATCH 1/5] feat: show an FPS counter over the game from the session menu A new session-menu row draws a styled FPS counter through RTSS shared memory (compact or detailed), in its own OSD slot so MSI Afterburner's OSD is left untouched. The choice is saved and the slot is released when the session ends. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + docs/TESTING.md | 13 ++ src/ConsoleMode/Models/Models.cs | 2 + src/ConsoleMode/Resources/Strings.en-US.json | 4 + src/ConsoleMode/Resources/Strings.es-ES.json | 4 + src/ConsoleMode/Resources/Strings.pt-BR.json | 4 + src/ConsoleMode/Services/ConsoleEngine.cs | 6 + .../Services/LocalizationService.cs | 1 + src/ConsoleMode/Services/RtssOverlay.cs | 37 ++++++ src/ConsoleMode/Services/RtssService.cs | 112 ++++++++++++++++++ src/ConsoleMode/SessionMenuWindow.xaml | 12 ++ .../ViewModels/MainViewModel.SessionMenu.cs | 31 +++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 1 + .../ConsoleMode.Tests.csproj | 1 + tests/ConsoleMode.Tests/RtssOverlayTests.cs | 37 ++++++ 16 files changed, 269 insertions(+) create mode 100644 src/ConsoleMode/Services/RtssOverlay.cs create mode 100644 tests/ConsoleMode.Tests/RtssOverlayTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index d39999b..fc72ce5 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. ## [Unreleased] +### What's new +- Session menu: a new "FPS counter" row shows the FPS over the game through RivaTuner (RTSS), in Console Mode's style. A cycles Off, Compact (FPS only) and Detailed (API, FPS and frame time); the choice carries over to the next sessions and the counter goes away when you go back to the PC. If you already use MSI Afterburner, its OSD stays: ours takes its own slot in RTSS and shows alongside it, without touching Afterburner's. (#PR) ## [1.6.0-beta.6] ### Heads-up: beta version, not the stable one diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d911f..a9fceaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. ## [Unreleased] +### Novidades +- Menu da sessão: nova linha "Contador de FPS" mostra o FPS sobre o jogo pelo RivaTuner (RTSS), no estilo do Console Mode. A alterna entre Desligado, Compacto (só o FPS) e Detalhado (API, FPS e tempo de quadro); a escolha vale para as próximas sessões e o contador some ao voltar ao PC. Quem já usa o MSI Afterburner continua com o OSD dele: o nosso ocupa um espaço próprio no RTSS e aparece junto, sem mexer no dele. (#PR) ## [1.6.0-beta.6] ### Atenção: versão beta, não é a estável diff --git a/docs/TESTING.md b/docs/TESTING.md index 569fbcc..7f13914 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -376,3 +376,16 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (te - [ ] Tente usar uma combinação já atribuída e confirme que a mensagem de conflito aparece inteira e não corta os botões. - [ ] Reduza a altura da janela e confirme que o editor rola até **Voltar**, sem esconder os cartões. - [ ] Feche o editor durante uma captura e confirme que ela para; reabra-o e confirme que nenhum botão fica preso em captura. + +## Contador de FPS no menu da sessão (RTSS) + +Pré-requisito: RivaTuner Statistics Server instalado, com "Show On-Screen Display" ligado. + +- [ ] Sem o RTSS instalado, a linha "Contador de FPS" não aparece no menu da sessão. +- [ ] Na prévia (menu aberto fora de uma sessão), a linha não aparece. +- [ ] Em sessão, com um jogo aberto: A na linha alterna Desligado → Compacto → Detalhado → Desligado, e o contador muda no jogo na hora. +- [ ] Compacto mostra o FPS; Detalhado mostra a API (D3D11/D3D12/Vulkan), o FPS e o tempo de quadro em ms. +- [ ] Com o RTSS fechado, ligar o contador abre o RTSS e o contador aparece. +- [ ] Com o MSI Afterburner aberto e o OSD dele ligado: os dois aparecem, o do Afterburner primeiro; ao desligar o nosso, o do Afterburner continua igual. +- [ ] Voltar ao PC some com o nosso contador; o do Afterburner fica. +- [ ] A escolha fica salva: na próxima sessão o contador já aparece no estilo escolhido. diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index 7f06762..296e947 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -20,6 +20,8 @@ public sealed class AppConfig public string AudioDeviceName { get; set; } = ""; public bool AudioAutoSwitch { get; set; } public int FpsLimit { get; set; } + /// RtssOverlay style: "off" | "compact" | "detailed". Shown over the game during a session. + public string FpsOverlay { get; set; } = RtssOverlay.Off; public Dictionary MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase); public bool HdrEnable { get; set; } public bool VrrEnable { get; set; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 62526e5..343531f 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -226,6 +226,10 @@ "PlaynitePathInvalid": "Could not find Playnite.FullscreenApp.exe at {0}.", "PlaynitePathPickError": "Could not open the file picker: {0}", "LaunchXbox": "Xbox mode (experimental)", + "FpsOverlayCard": "FPS counter", + "FpsOverlayOff": "Off", + "FpsOverlayCompact": "Compact", + "FpsOverlayDetailed": "Detailed", "FpsNoLimit": "No limit", "FpsCustom": "Custom", "FpsAvailable": "Applies to all games while console mode is active. Uses RTSS.", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index fb9cc6a..7105b59 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -226,6 +226,10 @@ "PlaynitePathInvalid": "No se encontró Playnite.FullscreenApp.exe en {0}.", "PlaynitePathPickError": "No se pudo abrir el selector de archivos: {0}", "LaunchXbox": "Modo Xbox (experimental)", + "FpsOverlayCard": "Contador de FPS", + "FpsOverlayOff": "Desactivado", + "FpsOverlayCompact": "Compacto", + "FpsOverlayDetailed": "Detallado", "FpsNoLimit": "Sin límite", "FpsCustom": "Personalizado", "FpsAvailable": "Se aplica a todos los juegos mientras el modo consola está activo. Usa RTSS.", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index b09d8ee..0aa011d 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -226,6 +226,10 @@ "PlaynitePathInvalid": "Não achei o Playnite.FullscreenApp.exe em {0}.", "PlaynitePathPickError": "Não foi possível abrir o seletor de arquivos: {0}", "LaunchXbox": "Modo Xbox (experimental)", + "FpsOverlayCard": "Contador de FPS", + "FpsOverlayOff": "Desligado", + "FpsOverlayCompact": "Compacto", + "FpsOverlayDetailed": "Detalhado", "FpsNoLimit": "Sem limite", "FpsCustom": "Personalizado", "FpsAvailable": "Vale para todos os jogos enquanto o modo console estiver ativo. Usa o RTSS.", diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index 4d14c32..8127e61 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -177,6 +177,10 @@ public void Start(AppConfig config, MonitorInfo? focusInfo, Func LocalizationService.Get(nameof(VrrCard)); public string VrrDescription => LocalizationService.Get(nameof(VrrDescription)); public string FpsCard => LocalizationService.Get(nameof(FpsCard)); + public string FpsOverlayCard => LocalizationService.Get(nameof(FpsOverlayCard)); public string FpsPlaceholder => LocalizationService.Get(nameof(FpsPlaceholder)); public string AppSection => LocalizationService.Get(nameof(AppSection)); public string StartupCard => LocalizationService.Get(nameof(StartupCard)); diff --git a/src/ConsoleMode/Services/RtssOverlay.cs b/src/ConsoleMode/Services/RtssOverlay.cs new file mode 100644 index 0000000..a7088f7 --- /dev/null +++ b/src/ConsoleMode/Services/RtssOverlay.cs @@ -0,0 +1,37 @@ +namespace ConsoleMode.Services; + +/// +/// Our FPS counter on the game, drawn by RTSS. It lives in its own OSD slot, so another client +/// (MSI Afterburner keeps slot 0) shows next to it and is never touched: we only ever write and +/// clear the slot that carries . +/// +public static class RtssOverlay +{ + public const string Off = "off"; + public const string Compact = "compact"; + public const string Detailed = "detailed"; + + /// szOSDOwner of our slot. + public const string Owner = "ConsoleMode"; + + public static readonly string[] Styles = [Off, Compact, Detailed]; + + public static string Normalize(string? style) => + Styles.FirstOrDefault(s => string.Equals(s, style, StringComparison.OrdinalIgnoreCase)) ?? Off; + + /// Off → Compact → Detailed → Off (A on the menu row). + public static string Next(string? style) => Styles[(Array.IndexOf(Styles, Normalize(style)) + 1) % Styles.Length]; + + // RTSS hypertext: / define, … / … use; framerate, + // frametime and 3D API are filled in by RTSS for the game being drawn. + private const string Palette = ""; + private const string Fps = " FPS"; + + /// The OSD text for a style; empty for Off (the slot is released instead). + public static string Text(string? style) => Normalize(style) switch + { + Compact => Palette + Fps, + Detailed => Palette + "\n" + Fps + "\n ms", + _ => "" + }; +} diff --git a/src/ConsoleMode/Services/RtssService.cs b/src/ConsoleMode/Services/RtssService.cs index 664b2e1..d4a1aae 100644 --- a/src/ConsoleMode/Services/RtssService.cs +++ b/src/ConsoleMode/Services/RtssService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.IO.MemoryMappedFiles; using System.Text.Json; using ConsoleMode.Models; @@ -143,6 +144,117 @@ public void Restore(ConsoleRuntimeState state) } } + /// + /// Shows our FPS counter (RtssOverlay.Text) in our own OSD slot, or clears that slot when + /// the text is empty. Other clients' slots (Afterburner) are left as they are. + /// + public bool SetOverlay(string text) + { + if (string.IsNullOrEmpty(text)) { WriteOverlaySlot(null); return true; } + if (!EnsureRunning()) return false; + // Right after RTSS starts its shared memory may not be there yet. + for (var attempt = 0; attempt < 10; attempt++) + { + if (WriteOverlaySlot(text)) return true; + Thread.Sleep(300); + } + return false; + } + + // RTSSSharedMemoryV2 (RTSS SDK, RTSSSharedMemory.h): header offsets and OSD entry layout. + private const uint Signature = 0x52545353; // 'RTSS' + private const int OffVersion = 4, OffOsdEntrySize = 20, OffOsdArrOffset = 24, OffOsdArrSize = 28, OffOsdFrame = 32, OffBusy = 36; + private const int EntryOsd = 0, EntryOwner = 256, EntryOsdEx = 512, OsdSize = 256, OwnerSize = 256, OsdExSize = 4096; + + /// null = release our slot. Mirrors UpdateOSD/ReleaseOSD from the SDK sample. + private static unsafe bool WriteOverlaySlot(string? text) + { + try + { + using var map = MemoryMappedFile.OpenExisting("RTSSSharedMemoryV2", MemoryMappedFileRights.ReadWrite); + using var view = map.CreateViewAccessor(0, 0, MemoryMappedFileAccess.ReadWrite); + byte* mem = null; + view.SafeMemoryMappedViewHandle.AcquirePointer(ref mem); + try + { + mem += view.PointerOffset; + var version = *(uint*)(mem + OffVersion); + if (*(uint*)mem != Signature || version < 0x00020000) return false; + + var entrySize = *(uint*)(mem + OffOsdEntrySize); + var arrOffset = *(uint*)(mem + OffOsdArrOffset); + var arrSize = *(uint*)(mem + OffOsdArrSize); + var owner = System.Text.Encoding.ASCII.GetBytes(RtssOverlay.Owner); + var entries = mem + arrOffset; + + if (text is null) + { + // Slot 0 belongs to the primary client (Afterburner); third parties start at 1. + for (uint i = 1; i < arrSize; i++) + { + var entry = entries + i * entrySize; + if (!IsOurs(entry, owner)) continue; + new Span(entry, (int)entrySize).Clear(); + Interlocked.Increment(ref *(int*)(mem + OffOsdFrame)); + } + return true; + } + + // The slot we hold already, else the first free one. + byte* slot = null; + for (uint i = 1; i < arrSize && slot is null; i++) + if (IsOurs(entries + i * entrySize, owner)) slot = entries + i * entrySize; + for (uint i = 1; i < arrSize && slot is null; i++) + { + var entry = entries + i * entrySize; + if (entry[EntryOwner] != 0) continue; + owner.CopyTo(new Span(entry + EntryOwner, OwnerSize)); + slot = entry; + } + if (slot is null) return false; + + var bytes = System.Text.Encoding.ASCII.GetBytes(text); + var extended = version >= 0x00020007; + var target = new Span(slot + (extended ? EntryOsdEx : EntryOsd), extended ? OsdExSize : OsdSize); + // v2.14+: bit 0 of dwBusy locks the OSD while the renderer reads it. + var lockable = version >= 0x0002000e; + ref var busy = ref *(int*)(mem + OffBusy); + for (var spin = 0; lockable && (Interlocked.Or(ref busy, 1) & 1) != 0; spin++) + { + if (spin > 200) return false; + Thread.Sleep(1); + } + try + { + target.Clear(); + bytes.AsSpan(0, Math.Min(bytes.Length, target.Length - 1)).CopyTo(target); + } + finally + { + if (lockable) Interlocked.And(ref busy, ~1); + } + Interlocked.Increment(ref *(int*)(mem + OffOsdFrame)); + return true; + } + finally + { + view.SafeMemoryMappedViewHandle.ReleasePointer(); + } + } + catch (FileNotFoundException) + { + return false; // RTSS isn't running: nothing on screen to show or clear + } + catch (Exception ex) + { + AppLog.Write($"RTSS: não foi possível atualizar o contador de FPS: {ex.Message}"); + return false; + } + } + + private static unsafe bool IsOurs(byte* entry, byte[] owner) => + new ReadOnlySpan(entry + EntryOwner, owner.Length + 1).SequenceEqual([.. owner, (byte)0]); + private bool Backup(ConsoleRuntimeState state) { if (!AppPaths.HasRtssCli || !EnsureRunning()) return false; diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 02f7eb3..aefd5a1 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -237,6 +237,18 @@ + +