From fc72112a89bee66374dadf87a9021b51ec388f78 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 10 Oct 2026 12:17:20 -0300 Subject: [PATCH 1/3] feat: put the FPS counter on a card with sizes and PC stats The counter gets a dark rounded card (RTSS layer with a rounded fill, can be turned off), three sizes, and new styles: FPS only, Compact (FPS plus CPU, GPU and RAM usage on one line) and Detailed (frame time, API, CPU clock, GPU clock and temperature, VRAM and RAM). Custom can pick every PC item too. CPU, GPU and memory come from Windows' performance counters and the display driver (D3DKMT), the sources Task Manager uses: nothing to install and no admin. Styles that show them are rewritten every second by a ticker that stops as soon as the style changes or the session ends. FPS only keeps the "compact" id, so configs saved by 1.6.0 with Compact still show just the FPS. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + README.md | 2 +- README.pt-BR.md | 2 +- docs/TESTING.md | 19 +- src/ConsoleMode/Models/Models.cs | 2 +- src/ConsoleMode/Native/GpuKmt.cs | 128 ++++++++++++ src/ConsoleMode/Native/PerfCounters.cs | 105 ++++++++++ src/ConsoleMode/Native/SystemMemory.cs | 32 +++ src/ConsoleMode/Resources/Strings.en-US.json | 20 +- src/ConsoleMode/Resources/Strings.es-ES.json | 20 +- src/ConsoleMode/Resources/Strings.pt-BR.json | 20 +- src/ConsoleMode/Services/HardwareMonitor.cs | 58 ++++++ src/ConsoleMode/Services/HardwareStats.cs | 90 ++++++++ .../Services/LocalizationService.cs | 12 ++ src/ConsoleMode/Services/RtssOverlay.cs | 197 +++++++++++++++--- src/ConsoleMode/Services/RtssService.cs | 58 +++++- .../ViewModels/MainViewModel.FpsOverlay.cs | 66 +++++- .../ViewModels/MainViewModel.SessionMenu.cs | 2 +- src/ConsoleMode/Views/SettingsView.xaml | 39 +++- .../ConsoleMode.Tests.csproj | 1 + tests/ConsoleMode.Tests/RtssOverlayTests.cs | 195 +++++++++++++++-- 22 files changed, 1006 insertions(+), 66 deletions(-) create mode 100644 src/ConsoleMode/Native/GpuKmt.cs create mode 100644 src/ConsoleMode/Native/PerfCounters.cs create mode 100644 src/ConsoleMode/Native/SystemMemory.cs create mode 100644 src/ConsoleMode/Services/HardwareMonitor.cs create mode 100644 src/ConsoleMode/Services/HardwareStats.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index a0dd267..8c60c52 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 +- Revamped FPS counter: the numbers sit on a dark rounded card (it can be turned off) in three sizes, Small, Medium and Large. New styles: FPS only, Compact (FPS plus CPU, GPU and RAM usage on one line) and Detailed (FPS, frame time and API, plus CPU clock, GPU clock and temperature, VRAM and RAM). In Custom, the PC items can be picked one by one too. The data comes from Windows and the graphics driver, nothing to install; anyone already on "Compact" keeps seeing just the FPS. ## [1.6.0] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index bf01bf8..0e8dd65 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 +- Contador de FPS repaginado: os números ficam num card escuro e arredondado (dá para desligar) em três tamanhos, Pequeno, Médio e Grande. Novos estilos: Apenas FPS, Compacto (FPS e uso de CPU, GPU e RAM numa linha) e Detalhado (FPS, tempo de quadro e API, mais clock da CPU, clock e temperatura da GPU, VRAM e RAM). No Personalizado, os itens do PC também podem ser escolhidos um a um. Os dados vêm do Windows e do driver de vídeo, sem instalar nada; quem já usava "Compacto" continua vendo só o FPS. ## [1.6.0] ### Novidades diff --git a/README.md b/README.md index 98b474e..4c79e8f 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ You pick the controller shortcuts yourself: none is on at first, the app asks th - Hide the other screens by **disconnecting** them, **black overlays** or **DDC/CI**, all with Windows' own APIs: no third-party display or audio tool is bundled - Per-screen **resolution and refresh rate**, **HDR**, **VRR** and an **FPS limit** (RTSS) while you play -- **FPS counter** over the game (RTSS): compact, detailed or your own layout +- **FPS counter** over the game (RTSS) on a card in three sizes: FPS only, compact (CPU, GPU and RAM usage), detailed (clocks, GPU temperature and memory) or your own layout - Audio goes to the TV (or any output) and comes back afterwards - [Turns the TV on and switches it to the PC's input](docs/GUIDE.md#tv-control) (Google TV / Android TV over the network), and can put it in standby afterwards - Launches **Steam Big Picture**, **Playnite fullscreen** or **Xbox** mode, and sends Steam to the tray when you go back to the PC diff --git a/README.pt-BR.md b/README.pt-BR.md index abc4a2b..4b28aea 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -44,7 +44,7 @@ Os atalhos do controle são você quem escolhe: nenhum vem ligado, o app pergunt - Esconde as outras telas **desconectando**, com **cortinas pretas** ou por **DDC/CI**, tudo pelas APIs do próprio Windows: nenhuma ferramenta de terceiros para telas ou áudio vem embutida - **Resolução e Hz** por tela, **HDR**, **VRR** e **limite de FPS** (RTSS) enquanto você joga -- **Contador de FPS** sobre o jogo (RTSS): compacto, detalhado ou do seu jeito +- **Contador de FPS** sobre o jogo (RTSS) num card em três tamanhos: apenas FPS, compacto (uso de CPU, GPU e RAM), detalhado (clocks, temperatura da GPU e memória) ou do seu jeito - O áudio vai para a TV (ou qualquer saída) e volta depois - [Liga a TV e troca para a entrada do PC](docs/GUIDE.pt-BR.md#controle-da-tv) (Google TV / Android TV pela rede), e pode colocá-la em espera no fim - Abre **Steam Big Picture**, **Playnite em tela cheia** ou o **Modo Xbox**, e manda a Steam para a bandeja ao voltar ao PC diff --git a/docs/TESTING.md b/docs/TESTING.md index 99f71e3..ae1457b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -385,7 +385,7 @@ Pré-requisito: RivaTuner Statistics Server instalado, com "Show On-Screen Displ - [ ] Sem o RTSS instalado, a linha "Contador de FPS" não aparece no menu da sessão e o cartão em Ajustes fica desabilitado. - [ ] Na prévia (menu aberto fora de uma sessão), a linha não aparece. - [ ] Config nova: o estilo é "Afterburner (não mexer)" e o OSD do Afterburner aparece normal na sessão. -- [ ] Em sessão, A na linha alterna Afterburner → Desligado → Compacto → Detalhado → Personalizado → Afterburner, e o jogo muda na hora. +- [ ] Em sessão, A na linha alterna Afterburner → Desligado → Apenas FPS → Compacto → Detalhado → Personalizado → Afterburner, e o jogo muda na hora. - [ ] Desligado: nada na tela, nem o nosso nem o do Afterburner (no máximo um piscar rápido do Afterburner de vez em quando). - [ ] Compacto / Detalhado / Personalizado: só o nosso aparece; o do Afterburner fica escondido. - [ ] Afterburner: o nosso some e o do Afterburner volta em até 1 s. @@ -395,3 +395,20 @@ Pré-requisito: RivaTuner Statistics Server instalado, com "Show On-Screen Displ - [ ] Voltar ao PC: o nosso some e o do Afterburner volta. - [ ] A escolha e o layout ficam salvos para a próxima sessão. - [ ] Com Compacto (ou outro estilo nosso) já salvo em Ajustes, iniciar a sessão: o contador aparece e o do Afterburner some sem precisar mexer na linha do menu. + +### Card, tamanhos e dados do PC (1.6.1) + +- [ ] Apenas FPS / Compacto / Detalhado aparecem dentro de um card escuro, translúcido e de cantos arredondados, com uma folga em volta do texto; o card acompanha o tamanho do texto (não cobre a tela toda nem o OSD de outro programa). +- [ ] O card fica na posição do OSD escolhida no RTSS (canto superior esquerdo, direito etc.), não num lugar fixo. +- [ ] "Fundo em card" desligado: o texto volta a ficar direto sobre o jogo, sem fundo. +- [ ] Tamanho Pequeno / Médio / Grande muda o tamanho de todo o contador na hora; no Grande o texto continua nítido e as unidades (FPS, %, GHz) ficam alinhadas na base dos números. +- [ ] Compacto: uma linha com FPS, CPU %, GPU % e RAM %; os números não fazem o texto "pular" quando mudam de 2 para 3 dígitos. +- [ ] Detalhado: FPS, tempo de quadro e API na primeira linha; CPU (uso e clock), GPU (uso, clock e °C), VRAM (usada / total) e RAM (usada / total) em linhas alinhadas. +- [ ] Os valores batem com o Gerenciador de Tarefas (CPU %, velocidade, GPU %, memória dedicada, temperatura da GPU) e atualizam a cada ~1 s. +- [ ] No primeiro segundo os valores aparecem como "--" e depois são preenchidos. +- [ ] Notebook com GPU integrada + dedicada: os dados de GPU são da placa que está rodando o jogo. +- [ ] GPU cujo driver não informa temperatura ou clock: esses itens simplesmente não aparecem (sem "0 MHz"). +- [ ] Personalizado: marcar/desmarcar cada item do PC (uso/clock da CPU, RAM, VRAM, uso/clock/temperatura da GPU) muda o contador na hora; com "Tudo em uma linha" ligado, fica tudo numa linha. +- [ ] Config salva na 1.6.0 com "Compacto": depois de atualizar, aparece "Apenas FPS" e o jogo mostra só o FPS. +- [ ] Voltar ao PC com Compacto/Detalhado: o contador some e não volta sozinho segundos depois. +- [ ] Uso de CPU do Console Mode com Detalhado ligado continua baixo (Gerenciador de Tarefas, < 1%). diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index 6fbd8de..c557109 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -20,7 +20,7 @@ public sealed class AppConfig public string AudioDeviceName { get; set; } = ""; public bool AudioAutoSwitch { get; set; } public int FpsLimit { get; set; } - /// RtssOverlay style: "external" | "off" | "compact" | "detailed" | "custom". Applied over the game during a session. + /// RtssOverlay style: "external" | "off" | "compact" (FPS only) | "stats" | "detailed" | "custom". Applied over the game during a session. public string FpsOverlay { get; set; } = RtssOverlay.External; public FpsOverlayLayout FpsOverlayLayout { get; set; } = new(); public Dictionary MonitorModes { get; set; } = new(StringComparer.OrdinalIgnoreCase); diff --git a/src/ConsoleMode/Native/GpuKmt.cs b/src/ConsoleMode/Native/GpuKmt.cs new file mode 100644 index 0000000..8bd37c6 --- /dev/null +++ b/src/ConsoleMode/Native/GpuKmt.cs @@ -0,0 +1,128 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Native; + +/// +/// GPU temperature, engine clock and VRAM size straight from the display driver (D3DKMT, WDDM 2.4+), +/// the source of Task Manager's GPU temperature: no vendor library, driver or admin needed. +/// Drivers that don't report a value leave it 0, read here as null. +/// +public static class GpuKmt +{ + private const int KmtqaiGetSegmentSize = 3; + private const int KmtqaiNodePerfData = 61; + private const int KmtqaiAdapterPerfData = 62; + + [StructLayout(LayoutKind.Sequential)] + private struct OpenAdapterFromLuid + { + public uint LuidLow; + public int LuidHigh; + public uint Adapter; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CloseAdapter + { + public uint Adapter; + } + + [StructLayout(LayoutKind.Sequential)] + private struct QueryAdapterInfo + { + public uint Adapter; + public int Type; + public nint Data; + public uint DataSize; + } + + // d3dkmthk.h: D3DKMT_SEGMENTSIZEINFO, D3DKMT_NODE_PERFDATA, D3DKMT_ADAPTER_PERFDATA (ULONGLONGs are 8-aligned). + [StructLayout(LayoutKind.Sequential)] + private struct SegmentSizeInfo + { + public ulong DedicatedVideoMemorySize; + public ulong DedicatedSystemMemorySize; + public ulong SharedSystemMemorySize; + } + + [StructLayout(LayoutKind.Sequential)] + private struct NodePerfData + { + public uint NodeOrdinal; + public uint PhysicalAdapterIndex; + public ulong Frequency; + public ulong MaxFrequency; + public ulong MaxFrequencyOc; + public uint Voltage; + public uint VoltageMax; + public uint VoltageMaxOc; + public ulong MaxTransitionLatency; + } + + [StructLayout(LayoutKind.Sequential)] + private struct AdapterPerfData + { + public uint PhysicalAdapterIndex; + public ulong MemoryFrequency; + public ulong MaxMemoryFrequency; + public ulong MaxMemoryFrequencyOc; + public ulong MemoryBandwidth; + public ulong PcieBandwidth; + public uint FanRpm; + public uint Power; + public uint Temperature; + public byte PowerStateOverride; + } + + [DllImport("gdi32.dll")] + private static extern int D3DKMTOpenAdapterFromLuid(ref OpenAdapterFromLuid open); + + [DllImport("gdi32.dll")] + private static extern int D3DKMTCloseAdapter(ref CloseAdapter close); + + [DllImport("gdi32.dll")] + private static extern int D3DKMTQueryAdapterInfo(ref QueryAdapterInfo query); + + public readonly record struct Reading(double? TemperatureC, double? ClockMhz, double? DedicatedMb); + + /// One read of the adapter (LUID as in the GPU performance counters) and its engine . + public static Reading Read(uint luidLow, int luidHigh, uint node) + { + var open = new OpenAdapterFromLuid { LuidLow = luidLow, LuidHigh = luidHigh }; + try + { + if (D3DKMTOpenAdapterFromLuid(ref open) != 0) return default; + } + catch (EntryPointNotFoundException) + { + return default; + } + try + { + var adapter = new AdapterPerfData(); + var engine = new NodePerfData { NodeOrdinal = node }; + var segments = new SegmentSizeInfo(); + double? temperature = Query(open.Adapter, KmtqaiAdapterPerfData, ref adapter) && adapter.Temperature > 0 + ? adapter.Temperature / 10.0 : null; + double? clock = Query(open.Adapter, KmtqaiNodePerfData, ref engine) && engine.Frequency > 0 + ? engine.Frequency / 1_000_000.0 : null; + double? dedicated = Query(open.Adapter, KmtqaiGetSegmentSize, ref segments) && segments.DedicatedVideoMemorySize > 0 + ? segments.DedicatedVideoMemorySize / (1024.0 * 1024.0) : null; + return new Reading(temperature, clock, dedicated); + } + finally + { + var close = new CloseAdapter { Adapter = open.Adapter }; + D3DKMTCloseAdapter(ref close); + } + } + + private static unsafe bool Query(uint adapter, int type, ref T data) where T : unmanaged + { + fixed (T* p = &data) + { + var query = new QueryAdapterInfo { Adapter = adapter, Type = type, Data = (nint)p, DataSize = (uint)sizeof(T) }; + return D3DKMTQueryAdapterInfo(ref query) == 0; + } + } +} diff --git a/src/ConsoleMode/Native/PerfCounters.cs b/src/ConsoleMode/Native/PerfCounters.cs new file mode 100644 index 0000000..0ea839f --- /dev/null +++ b/src/ConsoleMode/Native/PerfCounters.cs @@ -0,0 +1,105 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Native; + +/// +/// Windows performance counters (PDH), the same source Task Manager reads. Counter paths are the +/// English names (PdhAddEnglishCounter), so they work on a Windows in any language. +/// +public sealed class PerfCounters : IDisposable +{ + private const uint PdhFmtDouble = 0x00000200; + private const uint PdhFmtNoCap100 = 0x00008000; + private const int PdhMoreData = unchecked((int)0x800007D2); + + [DllImport("pdh.dll", CharSet = CharSet.Unicode)] + private static extern int PdhOpenQueryW(string? dataSource, nint userData, out nint query); + + [DllImport("pdh.dll", CharSet = CharSet.Unicode)] + private static extern int PdhAddEnglishCounterW(nint query, string path, nint userData, out nint counter); + + [DllImport("pdh.dll")] + private static extern int PdhCollectQueryData(nint query); + + [DllImport("pdh.dll")] + private static extern int PdhGetFormattedCounterValue(nint counter, uint format, out uint type, out FmtCounterValue value); + + [DllImport("pdh.dll", CharSet = CharSet.Unicode)] + private static extern int PdhGetFormattedCounterArrayW(nint counter, uint format, ref uint bufferSize, out uint itemCount, nint buffer); + + [DllImport("pdh.dll")] + private static extern int PdhCloseQuery(nint query); + + [StructLayout(LayoutKind.Sequential)] + private struct FmtCounterValue + { + public uint Status; + public double Value; + } + + [StructLayout(LayoutKind.Sequential)] + private struct FmtCounterValueItem + { + public nint Name; + public FmtCounterValue Value; + } + + private nint _query; + + public PerfCounters() + { + if (PdhOpenQueryW(null, 0, out _query) != 0) _query = 0; + } + + /// 0 when the counter doesn't exist on this machine (old Windows, no GPU driver…). + public nint Add(string path) => + _query != 0 && PdhAddEnglishCounterW(_query, path, 0, out var counter) == 0 ? counter : 0; + + /// Takes a sample; rates (usage %) need two, so the first read after this returns null. + public void Collect() + { + if (_query != 0) PdhCollectQueryData(_query); + } + + public double? Read(nint counter) + { + if (counter == 0) return null; + return PdhGetFormattedCounterValue(counter, PdhFmtDouble | PdhFmtNoCap100, out _, out var value) == 0 && value.Status == 0 + ? value.Value : null; + } + + /// Every instance of a wildcard counter (…(*)\…) with its value. + public List<(string Instance, double Value)> ReadAll(nint counter) + { + var result = new List<(string, double)>(); + if (counter == 0) return result; + uint size = 0; + if (PdhGetFormattedCounterArrayW(counter, PdhFmtDouble | PdhFmtNoCap100, ref size, out _, 0) != PdhMoreData || size == 0) + return result; + var buffer = Marshal.AllocHGlobal((int)size); + try + { + if (PdhGetFormattedCounterArrayW(counter, PdhFmtDouble | PdhFmtNoCap100, ref size, out var count, buffer) != 0) + return result; + var itemSize = Marshal.SizeOf(); + for (var i = 0; i < count; i++) + { + var item = Marshal.PtrToStructure(buffer + i * itemSize); + if (item.Value.Status == 0 && Marshal.PtrToStringUni(item.Name) is { } name) + result.Add((name, item.Value.Value)); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + } + return result; + } + + public void Dispose() + { + if (_query == 0) return; + PdhCloseQuery(_query); + _query = 0; + } +} diff --git a/src/ConsoleMode/Native/SystemMemory.cs b/src/ConsoleMode/Native/SystemMemory.cs new file mode 100644 index 0000000..c5b4c10 --- /dev/null +++ b/src/ConsoleMode/Native/SystemMemory.cs @@ -0,0 +1,32 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Native; + +public static class SystemMemory +{ + [StructLayout(LayoutKind.Sequential)] + private struct MemoryStatusEx + { + public uint Length; + public uint MemoryLoad; + public ulong TotalPhys; + public ulong AvailPhys; + public ulong TotalPageFile; + public ulong AvailPageFile; + public ulong TotalVirtual; + public ulong AvailVirtual; + public ulong AvailExtendedVirtual; + } + + [DllImport("kernel32.dll")] + private static extern bool GlobalMemoryStatusEx(ref MemoryStatusEx status); + + /// RAM in use and installed, in MB; nulls if Windows doesn't answer. + public static (double? UsedMb, double? TotalMb) Read() + { + var status = new MemoryStatusEx { Length = (uint)Marshal.SizeOf() }; + if (!GlobalMemoryStatusEx(ref status) || status.TotalPhys == 0) return (null, null); + const double mb = 1024 * 1024; + return ((status.TotalPhys - status.AvailPhys) / mb, status.TotalPhys / mb); + } +} diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 62f6435..f20a9a7 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -231,7 +231,7 @@ "FpsOverlayOff": "Off (hide everything)", "FpsOverlayCustom": "Custom", "FpsOverlayDescription": "Shows the FPS over the game during a session, through RTSS. Except on \"Afterburner\", MSI Afterburner's OSD is hidden while console mode is on.", - "FpsOverlayItems": "Items", + "FpsOverlayItems": "Game items", "FpsOverlayShowApi": "API (D3D/Vulkan)", "FpsOverlayShowFps": "FPS", "FpsOverlayShowFrameTime": "Frame time (ms)", @@ -243,7 +243,25 @@ "FpsOverlayColorYellow": "Yellow", "FpsOverlayColorOrange": "Orange", "FpsOverlayCompact": "Compact", + "FpsOverlayCompactHint": "FPS, CPU, GPU and RAM", "FpsOverlayDetailed": "Detailed", + "FpsOverlayDetailedHint": "clocks, temperature and memory", + "FpsOverlayFpsOnly": "FPS only", + "FpsOverlayPcItems": "PC items", + "FpsOverlayPcItemsDescription": "Read from Windows and the graphics driver, nothing to install. GPU temperature and clock only show if the driver reports them.", + "FpsOverlayShowCpu": "CPU usage", + "FpsOverlayShowCpuClock": "CPU clock", + "FpsOverlayShowGpu": "GPU usage", + "FpsOverlayShowGpuClock": "GPU clock", + "FpsOverlayShowGpuTemp": "GPU temperature", + "FpsOverlayShowVram": "VRAM", + "FpsOverlayShowRam": "RAM", + "FpsOverlaySize": "Size", + "FpsOverlaySizeSmall": "Small", + "FpsOverlaySizeMedium": "Medium", + "FpsOverlaySizeLarge": "Large", + "FpsOverlayCardBackground": "Card background", + "FpsOverlayCardBackgroundDescription": "A dark rounded card behind the numbers, so they read well over any scene.", "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 f76fb90..c1e4d7c 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -231,7 +231,7 @@ "FpsOverlayOff": "Desactivado (oculta todo)", "FpsOverlayCustom": "Personalizado", "FpsOverlayDescription": "Muestra los FPS sobre el juego durante la sesión, mediante RTSS. Salvo en \"Afterburner\", el OSD de MSI Afterburner se oculta mientras el modo consola esté activo.", - "FpsOverlayItems": "Elementos", + "FpsOverlayItems": "Elementos del juego", "FpsOverlayShowApi": "API (D3D/Vulkan)", "FpsOverlayShowFps": "FPS", "FpsOverlayShowFrameTime": "Tiempo de fotograma (ms)", @@ -243,7 +243,25 @@ "FpsOverlayColorYellow": "Amarillo", "FpsOverlayColorOrange": "Naranja", "FpsOverlayCompact": "Compacto", + "FpsOverlayCompactHint": "FPS, CPU, GPU y RAM", "FpsOverlayDetailed": "Detallado", + "FpsOverlayDetailedHint": "relojes, temperatura y memoria", + "FpsOverlayFpsOnly": "Solo FPS", + "FpsOverlayPcItems": "Elementos del PC", + "FpsOverlayPcItemsDescription": "Se leen de Windows y del controlador de vídeo, sin instalar nada. La temperatura y el reloj de la GPU solo aparecen si el controlador los informa.", + "FpsOverlayShowCpu": "Uso de CPU", + "FpsOverlayShowCpuClock": "Reloj de CPU", + "FpsOverlayShowGpu": "Uso de GPU", + "FpsOverlayShowGpuClock": "Reloj de GPU", + "FpsOverlayShowGpuTemp": "Temperatura de GPU", + "FpsOverlayShowVram": "VRAM", + "FpsOverlayShowRam": "RAM", + "FpsOverlaySize": "Tamaño", + "FpsOverlaySizeSmall": "Pequeño", + "FpsOverlaySizeMedium": "Mediano", + "FpsOverlaySizeLarge": "Grande", + "FpsOverlayCardBackground": "Fondo de tarjeta", + "FpsOverlayCardBackgroundDescription": "Una tarjeta oscura y redondeada detrás de los números, para leerlos bien en cualquier escena.", "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 5b20c6b..576ece4 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -231,7 +231,7 @@ "FpsOverlayOff": "Desligado (esconde tudo)", "FpsOverlayCustom": "Personalizado", "FpsOverlayDescription": "Mostra o FPS sobre o jogo durante a sessão, pelo RTSS. Fora de \"Afterburner\", o OSD do MSI Afterburner fica escondido enquanto o modo console estiver ativo.", - "FpsOverlayItems": "Itens", + "FpsOverlayItems": "Itens do jogo", "FpsOverlayShowApi": "API (D3D/Vulkan)", "FpsOverlayShowFps": "FPS", "FpsOverlayShowFrameTime": "Tempo de quadro (ms)", @@ -243,7 +243,25 @@ "FpsOverlayColorYellow": "Amarelo", "FpsOverlayColorOrange": "Laranja", "FpsOverlayCompact": "Compacto", + "FpsOverlayCompactHint": "FPS, CPU, GPU e RAM", "FpsOverlayDetailed": "Detalhado", + "FpsOverlayDetailedHint": "clocks, temperatura e memória", + "FpsOverlayFpsOnly": "Apenas FPS", + "FpsOverlayPcItems": "Itens do PC", + "FpsOverlayPcItemsDescription": "Lidos do Windows e do driver de vídeo, sem instalar nada. Temperatura e clock da GPU só aparecem se o driver informar.", + "FpsOverlayShowCpu": "Uso da CPU", + "FpsOverlayShowCpuClock": "Clock da CPU", + "FpsOverlayShowGpu": "Uso da GPU", + "FpsOverlayShowGpuClock": "Clock da GPU", + "FpsOverlayShowGpuTemp": "Temperatura da GPU", + "FpsOverlayShowVram": "VRAM", + "FpsOverlayShowRam": "RAM", + "FpsOverlaySize": "Tamanho", + "FpsOverlaySizeSmall": "Pequeno", + "FpsOverlaySizeMedium": "Médio", + "FpsOverlaySizeLarge": "Grande", + "FpsOverlayCardBackground": "Fundo em card", + "FpsOverlayCardBackgroundDescription": "Um cartão escuro e arredondado atrás dos números, para ler bem em qualquer cena.", "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/HardwareMonitor.cs b/src/ConsoleMode/Services/HardwareMonitor.cs new file mode 100644 index 0000000..76286dd --- /dev/null +++ b/src/ConsoleMode/Services/HardwareMonitor.cs @@ -0,0 +1,58 @@ +using ConsoleMode.Native; + +namespace ConsoleMode.Services; + +/// +/// CPU, GPU and memory for the FPS counter (RtssOverlay), from Windows' own counters and the display +/// driver: nothing to install and no admin. Sample() is meant to be called about once a second; +/// usages need two samples, so the first one has them empty. +/// +public sealed class HardwareMonitor : IDisposable +{ + private readonly PerfCounters _pdh = new(); + private readonly nint _cpuUsage; + private readonly nint _cpuPerformance; + private readonly nint _cpuFrequency; + private readonly nint _gpuEngines; + private readonly nint _gpuDedicated; + + public HardwareMonitor() + { + // "% Processor Utility" is what Task Manager shows (it counts turbo); older systems lack it. + _cpuUsage = _pdh.Add(@"\Processor Information(_Total)\% Processor Utility"); + if (_cpuUsage == 0) _cpuUsage = _pdh.Add(@"\Processor Information(_Total)\% Processor Time"); + // Effective clock = base frequency × % performance, Task Manager's "Speed". + _cpuPerformance = _pdh.Add(@"\Processor Information(_Total)\% Processor Performance"); + _cpuFrequency = _pdh.Add(@"\Processor Information(_Total)\Processor Frequency"); + _gpuEngines = _pdh.Add(@"\GPU Engine(*)\Utilization Percentage"); + _gpuDedicated = _pdh.Add(@"\GPU Adapter Memory(*)\Dedicated Usage"); + _pdh.Collect(); + } + + public HardwareSample Sample() + { + _pdh.Collect(); + + var frequency = _pdh.Read(_cpuFrequency); + var performance = _pdh.Read(_cpuPerformance); + var dedicated = _pdh.ReadAll(_gpuDedicated); + var gpu = GpuCounters.Busiest(_pdh.ReadAll(_gpuEngines), dedicated); + var kmt = gpu is { } a ? GpuKmt.Read(a.LuidLow, a.LuidHigh, a.Node) : default; + var (ramUsed, ramTotal) = SystemMemory.Read(); + + return new HardwareSample + { + CpuUsage = _pdh.Read(_cpuUsage) is { } cpu ? Math.Clamp(cpu, 0, 100) : null, + CpuClockMhz = frequency is > 0 && performance is > 0 ? frequency * performance / 100 : null, + GpuUsage = gpu?.Usage, + GpuClockMhz = kmt.ClockMhz, + GpuTempC = kmt.TemperatureC, + VramUsedMb = gpu is { } g ? GpuCounters.DedicatedMb(dedicated, g.Key) : null, + VramTotalMb = kmt.DedicatedMb, + RamUsedMb = ramUsed, + RamTotalMb = ramTotal + }; + } + + public void Dispose() => _pdh.Dispose(); +} diff --git a/src/ConsoleMode/Services/HardwareStats.cs b/src/ConsoleMode/Services/HardwareStats.cs new file mode 100644 index 0000000..8a2e1aa --- /dev/null +++ b/src/ConsoleMode/Services/HardwareStats.cs @@ -0,0 +1,90 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ConsoleMode.Services; + +/// One reading of the PC for the FPS counter (HardwareMonitor). null = not available on this machine. +public sealed record HardwareSample +{ + public double? CpuUsage { get; init; } + public double? CpuClockMhz { get; init; } + public double? GpuUsage { get; init; } + public double? GpuClockMhz { get; init; } + public double? GpuTempC { get; init; } + public double? VramUsedMb { get; init; } + public double? VramTotalMb { get; init; } + public double? RamUsedMb { get; init; } + public double? RamTotalMb { get; init; } +} + +/// +/// Reads the "GPU Engine" / "GPU Adapter Memory" performance counter instances the way Task Manager +/// does: an adapter's usage is its busiest engine, each engine summed over every process. +/// +public static partial class GpuCounters +{ + /// The adapter the game runs on (the busiest one) and the engine node to read its clock from. + public readonly record struct Adapter(string Key, uint LuidLow, int LuidHigh, uint Node, double Usage); + + /// + /// : "\GPU Engine(*)\Utilization Percentage" instances, e.g. + /// pid_1234_luid_0x00000000_0x0000D1E4_phys_0_eng_0_engtype_3D. : + /// "\GPU Adapter Memory(*)\Dedicated Usage" (luid_…_phys_0), the tie-break when every GPU is idle. + /// + public static Adapter? Busiest(IEnumerable<(string Instance, double Value)> engines, IEnumerable<(string Instance, double Value)>? dedicated = null) + { + var perEngine = new Dictionary<(string Key, uint Node), (double Sum, bool Is3D)>(); + foreach (var (instance, value) in engines) + { + var m = EngineInstance().Match(instance); + if (!m.Success) continue; + var key = (AdapterKey(m), uint.Parse(m.Groups["node"].Value, CultureInfo.InvariantCulture)); + var is3D = string.Equals(m.Groups["type"].Value, "3D", StringComparison.OrdinalIgnoreCase); + perEngine[key] = perEngine.TryGetValue(key, out var e) ? (e.Sum + value, e.Is3D || is3D) : (value, is3D); + } + if (perEngine.Count == 0) return null; + + var memory = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (instance, value) in dedicated ?? []) + { + var m = AdapterInstance().Match(instance); + if (m.Success) memory[AdapterKey(m)] = value; + } + + return perEngine.GroupBy(e => e.Key.Key) + .Select(g => + { + // The clock is read from the 3D engine (the one games load), else the busiest one. + var node = g.OrderByDescending(e => e.Value.Is3D).ThenBy(e => e.Key.Node).First().Key.Node; + var m = AdapterInstance().Match(g.Key); + return new Adapter(g.Key, Hex(m.Groups["low"].Value), (int)Hex(m.Groups["high"].Value), node, + Math.Clamp(g.Max(e => e.Value.Sum), 0, 100)); + }) + .OrderByDescending(a => a.Usage) + .ThenByDescending(a => memory.GetValueOrDefault(a.Key)) + .First(); + } + + /// "\GPU Adapter Memory(*)\Dedicated Usage" of one adapter, in MB. + public static double? DedicatedMb(IEnumerable<(string Instance, double Value)> dedicated, string adapterKey) + { + foreach (var (instance, value) in dedicated) + { + var m = AdapterInstance().Match(instance); + if (m.Success && string.Equals(AdapterKey(m), adapterKey, StringComparison.OrdinalIgnoreCase)) + return value / (1024 * 1024); + } + return null; + } + + private static string AdapterKey(Match m) => + $"luid_0x{m.Groups["high"].Value}_0x{m.Groups["low"].Value}_phys_{m.Groups["phys"].Value}".ToLowerInvariant(); + + private static uint Hex(string value) => uint.Parse(value, NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + [GeneratedRegex(@"luid_0x(?[0-9A-Fa-f]{8})_0x(?[0-9A-Fa-f]{8})_phys_(?\d+)_eng_(?\d+)_engtype_(?.*)$")] + private static partial Regex EngineInstance(); + + [GeneratedRegex(@"luid_0x(?[0-9A-Fa-f]{8})_0x(?[0-9A-Fa-f]{8})_phys_(?\d+)")] + private static partial Regex AdapterInstance(); +} diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index e7e75a3..06cf5b6 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -157,6 +157,18 @@ internal void NotifyAll() public string FpsOverlayShowFrameTime => LocalizationService.Get(nameof(FpsOverlayShowFrameTime)); public string FpsOverlaySingleLine => LocalizationService.Get(nameof(FpsOverlaySingleLine)); public string FpsOverlayColor => LocalizationService.Get(nameof(FpsOverlayColor)); + public string FpsOverlayPcItems => LocalizationService.Get(nameof(FpsOverlayPcItems)); + public string FpsOverlayPcItemsDescription => LocalizationService.Get(nameof(FpsOverlayPcItemsDescription)); + public string FpsOverlayShowCpu => LocalizationService.Get(nameof(FpsOverlayShowCpu)); + public string FpsOverlayShowCpuClock => LocalizationService.Get(nameof(FpsOverlayShowCpuClock)); + public string FpsOverlayShowGpu => LocalizationService.Get(nameof(FpsOverlayShowGpu)); + public string FpsOverlayShowGpuClock => LocalizationService.Get(nameof(FpsOverlayShowGpuClock)); + public string FpsOverlayShowGpuTemp => LocalizationService.Get(nameof(FpsOverlayShowGpuTemp)); + public string FpsOverlayShowVram => LocalizationService.Get(nameof(FpsOverlayShowVram)); + public string FpsOverlayShowRam => LocalizationService.Get(nameof(FpsOverlayShowRam)); + public string FpsOverlaySize => LocalizationService.Get(nameof(FpsOverlaySize)); + public string FpsOverlayCardBackground => LocalizationService.Get(nameof(FpsOverlayCardBackground)); + public string FpsOverlayCardBackgroundDescription => LocalizationService.Get(nameof(FpsOverlayCardBackgroundDescription)); 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 index 00a26b3..3a38c15 100644 --- a/src/ConsoleMode/Services/RtssOverlay.cs +++ b/src/ConsoleMode/Services/RtssOverlay.cs @@ -1,29 +1,50 @@ +using System.Globalization; +using System.Text; using System.Text.RegularExpressions; namespace ConsoleMode.Services; -/// What the custom FPS counter shows and how (Settings → FPS counter → Custom). +/// +/// How our FPS counter looks (Settings → FPS counter). Size, card and color apply to every style; +/// the items and the one-line layout are what Custom shows. +/// public sealed class FpsOverlayLayout { public bool ShowApi { get; set; } = true; public bool ShowFps { get; set; } = true; public bool ShowFrameTime { get; set; } = true; - /// Everything on one line instead of one item per line. + public bool ShowCpu { get; set; } + public bool ShowCpuClock { get; set; } + public bool ShowGpu { get; set; } + public bool ShowGpuClock { get; set; } + public bool ShowGpuTemp { get; set; } + public bool ShowVram { get; set; } + public bool ShowRam { get; set; } + /// Everything on one line instead of one row per group. public bool SingleLine { get; set; } /// RRGGBB of the numbers; one of . public string Color { get; set; } = RtssOverlay.Colors[0]; + /// One of . + public string Size { get; set; } = RtssOverlay.Small; + /// Draw the counter on a translucent rounded card instead of straight over the game. + public bool Card { get; set; } = true; } /// /// Our FPS counter on the game, drawn by RTSS in its own OSD slot (). /// Every style except takes the screen: MSI Afterburner's OSD is hidden /// while it is on, so "Off" really means nothing on screen. External leaves Afterburner/RTSS alone. +/// FPS and frame time come from RTSS itself; CPU, GPU and memory from a , +/// so styles that show them are rewritten every second (). /// public static partial class RtssOverlay { public const string External = "external"; public const string Off = "off"; - public const string Compact = "compact"; + /// Only the FPS. Its id is "compact", the name this style had up to 1.6.0, so saved configs keep it. + public const string FpsOnly = "compact"; + /// FPS plus CPU, GPU and RAM usage on one line. + public const string Compact = "stats"; public const string Detailed = "detailed"; public const string Custom = "custom"; @@ -31,11 +52,16 @@ public static partial class RtssOverlay public const string Owner = "ConsoleMode"; /// Menu order; External first, the default, so nobody's Afterburner disappears unasked. - public static readonly string[] Styles = [External, Off, Compact, Detailed, Custom]; + public static readonly string[] Styles = [External, Off, FpsOnly, Compact, Detailed, Custom]; /// White, blue, green, yellow, orange. public static readonly string[] Colors = ["FFFFFF", "4CC2FF", "6FE07A", "FFD54A", "FF8A3D"]; + public const string Small = "small"; + public const string Medium = "medium"; + public const string Large = "large"; + public static readonly string[] Sizes = [Small, Medium, Large]; + public static string Normalize(string? style) => Styles.FirstOrDefault(s => string.Equals(s, style, StringComparison.OrdinalIgnoreCase)) ?? External; @@ -49,38 +75,151 @@ public static string Normalize(string? style) => public static string NormalizeColor(string? color) => color is not null && HexColor().IsMatch(color) ? color.ToUpperInvariant() : Colors[0]; - // RTSS hypertext: / define, … / … use; framerate, - // frametime and 3D API are filled in by RTSS for the game being drawn. - private const string Api = ""; - private const string Fps = " FPS"; - private const string FrameTime = " ms"; + public static string NormalizeSize(string? size) => + Sizes.FirstOrDefault(s => string.Equals(s, size, StringComparison.OrdinalIgnoreCase)) ?? Small; + + /// Whether the style shows CPU/GPU/memory, which RTSS doesn't know: the text then needs a fresh sample every second. + public static bool NeedsHardware(string? style, FpsOverlayLayout? layout = null) => + (ItemsOf(Normalize(style), layout) & PcItems) != 0; + + [Flags] + private enum Item + { + None = 0, + Fps = 1, FrameTime = 2, Api = 4, + Cpu = 8, CpuClock = 16, Gpu = 32, GpuClock = 64, GpuTemp = 128, Vram = 256, Ram = 512 + } + + private const Item PcItems = Item.Cpu | Item.CpuClock | Item.Gpu | Item.GpuClock | Item.GpuTemp | Item.Vram | Item.Ram; - /// The OSD text for a style; empty for Off and External (nothing of ours on screen). - public static string Text(string? style, FpsOverlayLayout? layout = null) + private static Item ItemsOf(string style, FpsOverlayLayout? layout) { - string[] items; - var color = Colors[0]; - var separator = "\n"; - switch (Normalize(style)) + switch (style) { - case Compact: - items = [Fps]; - break; - case Detailed: - items = [Api, Fps, FrameTime]; - break; + case FpsOnly: return Item.Fps; + case Compact: return Item.Fps | Item.Cpu | Item.Gpu | Item.Ram; + case Detailed: return Item.Fps | Item.FrameTime | Item.Api | PcItems; case Custom: layout ??= new FpsOverlayLayout(); - items = [.. new[] { (layout.ShowApi, Api), (layout.ShowFps, Fps), (layout.ShowFrameTime, FrameTime) } - .Where(i => i.Item1).Select(i => i.Item2)]; - if (items.Length == 0) items = [Fps]; // nothing ticked still shows something - color = NormalizeColor(layout.Color); - if (layout.SingleLine) separator = " "; - break; + var items = Item.None; + if (layout.ShowFps) items |= Item.Fps; + if (layout.ShowFrameTime) items |= Item.FrameTime; + if (layout.ShowApi) items |= Item.Api; + if (layout.ShowCpu) items |= Item.Cpu; + if (layout.ShowCpuClock) items |= Item.CpuClock; + if (layout.ShowGpu) items |= Item.Gpu; + if (layout.ShowGpuClock) items |= Item.GpuClock; + if (layout.ShowGpuTemp) items |= Item.GpuTemp; + if (layout.ShowVram) items |= Item.Vram; + if (layout.ShowRam) items |= Item.Ram; + return items == Item.None ? Item.Fps : items; // nothing ticked still shows something default: - return ""; + return Item.None; + } + } + + // RTSS hypertext (SDK sample and OverlayEditor): / / define a + // variable, … / … / … use it ( and go back to 100% / default color). + // Sizes are percent, negative = sits on the baseline; widths are in characters, negative = right + // aligned, so numbers don't make the text jump. framerate, frametime and 3D API + // are filled in by RTSS. The card is a layer () with padding () whose background is a + // rounded bar (, 0,0 = the whole layer) drawn in C3; \b puts the cursor back on its origin. + private const string Value = "C0", Label = "C1", Accent = "C2", CardFill = "C3"; + private const string ValueSize = "S0", UnitSize = "S1", LabelSize = "S2", FpsSize = "S3"; + private const string LabelColor = "9AA4B2", AccentColor = "4CC2FF", CardColor = "C0101418"; + private const string Missing = "--"; + + /// + /// The OSD text for a style; empty for Off and External (nothing of ours on screen). + /// null = not measured yet: hardware values show as "--". + /// The text is Latin-1 (°), what RTSS's slot holds. + /// + public static string Text(string? style, FpsOverlayLayout? layout = null, HardwareSample? sample = null) + { + style = Normalize(style); + var items = ItemsOf(style, layout); + if (items == Item.None) return ""; + layout ??= new FpsOverlayLayout(); + var singleLine = style switch + { + FpsOnly or Compact => true, + Custom => layout.SingleLine, + _ => false + }; + + var scale = NormalizeSize(layout.Size) switch { Medium => 1.5, Large => 2.0, _ => 1.0 }; + var rows = new List(); + var game = new List(); + if (items.HasFlag(Item.Fps)) game.Add(Run(FpsSize, Value, "") + Run(UnitSize, Label, " FPS")); + if (items.HasFlag(Item.FrameTime)) game.Add(Run(ValueSize, Value, "") + Run(UnitSize, Label, " ms")); + if (items.HasFlag(Item.Api)) game.Add(Run(LabelSize, Accent, "")); + if (game.Count > 0) rows.Add(string.Join(Gap, game)); + + // Compact is one short line: RAM as a percentage, like the other two. + var ramAsPercent = style == Compact; + AddRow(rows, "CPU", singleLine, + items.HasFlag(Item.Cpu) ? Number(sample, sample?.CpuUsage, "F0", 3, "%") : null, + items.HasFlag(Item.CpuClock) ? Number(sample, sample?.CpuClockMhz / 1000, "F1", 3, " GHz") : null); + AddRow(rows, "GPU", singleLine, + items.HasFlag(Item.Gpu) ? Number(sample, sample?.GpuUsage, "F0", 3, "%") : null, + items.HasFlag(Item.GpuClock) ? Number(sample, sample?.GpuClockMhz, "F0", 4, " MHz") : null, + items.HasFlag(Item.GpuTemp) ? Number(sample, sample?.GpuTempC, "F0", 3, "°C") : null); + AddRow(rows, "VRAM", singleLine, + items.HasFlag(Item.Vram) ? Memory(sample, sample?.VramUsedMb, sample?.VramTotalMb, false) : null); + AddRow(rows, "RAM", singleLine, + items.HasFlag(Item.Ram) ? Memory(sample, sample?.RamUsedMb, sample?.RamTotalMb, ramAsPercent) : null); + if (rows.Count == 0) rows.Add(Run(FpsSize, Value, "") + Run(UnitSize, Label, " FPS")); + + var header = new StringBuilder() + .Append($"<{Value}={NormalizeColor(layout.Color)}><{Label}={LabelColor}><{Accent}={AccentColor}><{CardFill}={CardColor}>") + .Append($"<{ValueSize}={Percent(100, scale)}><{UnitSize}=-{Percent(55, scale)}><{LabelSize}=-{Percent(70, scale)}>") + .Append($"<{FpsSize}={Percent(singleLine ? 100 : 130, scale)}>") + .Append(""); + if (layout.Card) + { + var pad = (int)Math.Round(6 * scale); + header.Append($"<{CardFill}>\b"); } - return $"" + string.Join(separator, items); + return header + string.Join(singleLine ? Gap + Gap : "\n", rows); + } + + private static string Gap => Run(LabelSize, Label, " "); + + private static string Run(string size, string color, string text) => $"<{size}><{color}>{text}"; + + private static string Percent(int percent, double scale) => + ((int)Math.Round(percent * scale)).ToString(CultureInfo.InvariantCulture); + + /// "CPU 45% 4.2 GHz": the label column is fixed-width when stacked so the values line up. + private static void AddRow(List rows, string label, bool singleLine, params string?[] values) + { + var shown = values.Where(v => !string.IsNullOrEmpty(v)).ToList(); + if (shown.Count == 0) return; + var name = singleLine ? Run(LabelSize, Label, label + " ") : $"<{LabelSize}><{Label}>{label}"; + rows.Add(name + string.Join(Gap, shown)); + } + + /// A right-aligned value in a box of characters, then its unit. "" = not available here. + private static string Number(HardwareSample? sample, double? value, string format, int width, string unit) + { + string text; + if (sample is null) text = Missing; + else if (value is { } v && double.IsFinite(v)) text = v.ToString(format, CultureInfo.InvariantCulture); + else return ""; + var box = width >= 4 ? "A1" : "A0"; + return $"<{ValueSize}><{Value}><{box}>{text}" + Run(UnitSize, Label, unit); + } + + /// "5.2 / 8.0 GB", or a percentage on the Compact line. + private static string Memory(HardwareSample? sample, double? usedMb, double? totalMb, bool asPercent) + { + if (asPercent) + return Number(sample, usedMb is { } u && totalMb is > 0 ? u / totalMb * 100 : null, "F0", 3, "%"); + if (sample is null) return Number(null, null, "F1", 3, " GB"); + if (usedMb is not { } used) return ""; + var text = Number(sample, used / 1024, "F1", 3, ""); + var total = totalMb is > 0 ? " / " + (totalMb.Value / 1024).ToString("F0", CultureInfo.InvariantCulture) : ""; + return text + Run(UnitSize, Label, total + " GB"); } [GeneratedRegex("^[0-9A-Fa-f]{6}$")] diff --git a/src/ConsoleMode/Services/RtssService.cs b/src/ConsoleMode/Services/RtssService.cs index 0a5e15a..40a14d1 100644 --- a/src/ConsoleMode/Services/RtssService.cs +++ b/src/ConsoleMode/Services/RtssService.cs @@ -150,6 +150,58 @@ public void Restore(ConsoleRuntimeState state) /// meanwhile (see HideAfterburnerLoop): an empty text then means nothing on screen at all. /// public bool SetOverlay(string text, bool hideOthers) + { + StopTicker(); + return ShowOverlay(text, hideOthers); + } + + /// + /// Applies a style from . Styles with CPU/GPU/memory are rewritten every + /// second from a until the next SetOverlay/ApplyOverlay. + /// + public bool ApplyOverlay(string? style, FpsOverlayLayout? layout) + { + var generation = StopTicker(); + var text = RtssOverlay.Text(style, layout); + if (!ShowOverlay(text, RtssOverlay.HidesOthers(style))) return false; + if (text.Length > 0 && RtssOverlay.NeedsHardware(style, layout)) + new Thread(() => TickOverlay(generation, style, layout)) { IsBackground = true, Name = "RTSS counter" }.Start(); + return true; + } + + private readonly object _slotLock = new(); + private int _overlayGeneration; + + /// Ends the running ticker (its next write sees a newer generation) and returns the new generation. + private int StopTicker() + { + lock (_slotLock) return ++_overlayGeneration; + } + + private void TickOverlay(int generation, string? style, FpsOverlayLayout? layout) + { + try + { + using var monitor = new HardwareMonitor(); + while (Volatile.Read(ref _overlayGeneration) == generation) + { + Thread.Sleep(1000); + var text = RtssOverlay.Text(style, layout, monitor.Sample()); + // Checked under the lock so a session that just ended can't get the counter back. + lock (_slotLock) + { + if (_overlayGeneration != generation) return; + WriteOverlaySlot(text); + } + } + } + catch (Exception ex) + { + AppLog.Write($"RTSS: o contador parou de atualizar CPU/GPU: {ex.Message}"); + } + } + + private bool ShowOverlay(string text, bool hideOthers) { if (string.IsNullOrEmpty(text)) { @@ -174,10 +226,6 @@ public bool SetOverlay(string text, bool hideOthers) return false; } - /// Applies a style from . - public bool ApplyOverlay(string? style, FpsOverlayLayout? layout) => - SetOverlay(RtssOverlay.Text(style, layout), RtssOverlay.HidesOthers(style)); - private volatile bool _hideAfterburner; private Thread? _afterburnerHider; @@ -292,7 +340,7 @@ private static unsafe bool WriteOverlaySlot(string? text) } if (slot is null) return false; - var bytes = System.Text.Encoding.ASCII.GetBytes(text); + var bytes = System.Text.Encoding.Latin1.GetBytes(text); // RTSS's font has ° (0xB0) 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. diff --git a/src/ConsoleMode/ViewModels/MainViewModel.FpsOverlay.cs b/src/ConsoleMode/ViewModels/MainViewModel.FpsOverlay.cs index 2bfe222..dca8ffa 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.FpsOverlay.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.FpsOverlay.cs @@ -7,41 +7,73 @@ namespace ConsoleMode.ViewModels; // The FPS counter drawn by RTSS over the game (RtssOverlay): picked in Settings or cycled from -// the session menu; the custom layout is set in Settings. Changes during a session show at once. +// the session menu; size, card, color and the custom items are set in Settings. Changes during a +// session show at once. public partial class MainViewModel { public ObservableCollection FpsOverlayOptions { get; } = []; public ObservableCollection FpsOverlayColorOptions { get; } = []; + public ObservableCollection FpsOverlaySizeOptions { get; } = []; [ObservableProperty] private ComboOption? _selectedFpsOverlay; [ObservableProperty] private ComboOption? _selectedFpsOverlayColor; + [ObservableProperty] private ComboOption? _selectedFpsOverlaySize; [ObservableProperty] private bool _overlayShowApi = true; [ObservableProperty] private bool _overlayShowFps = true; [ObservableProperty] private bool _overlayShowFrameTime = true; + [ObservableProperty] private bool _overlayShowCpu; + [ObservableProperty] private bool _overlayShowCpuClock; + [ObservableProperty] private bool _overlayShowGpu; + [ObservableProperty] private bool _overlayShowGpuClock; + [ObservableProperty] private bool _overlayShowGpuTemp; + [ObservableProperty] private bool _overlayShowVram; + [ObservableProperty] private bool _overlayShowRam; [ObservableProperty] private bool _overlaySingleLine; + [ObservableProperty] private bool _overlayCard = true; public bool IsRtssInstalled => Engine.Rtss.IsInstalled; public bool IsFpsOverlayCustom => FpsOverlayStyle == RtssOverlay.Custom; + /// The style draws something of ours, so size, card and color mean something. + public bool IsFpsOverlayDrawn => RtssOverlay.Text(FpsOverlayStyle).Length > 0; public string FpsOverlayStatusText => LocalizationService.Get(IsRtssInstalled ? "FpsOverlayDescription" : "FpsUnavailable"); private string FpsOverlayStyle => RtssOverlay.Normalize(SelectedFpsOverlay?.Value); + private string FpsOverlayShortName => SelectedFpsOverlay is null ? "" : LocalizationService.Get(StyleKey(FpsOverlayStyle)); + private FpsOverlayLayout ReadFpsOverlayLayout() => new() { ShowApi = OverlayShowApi, ShowFps = OverlayShowFps, ShowFrameTime = OverlayShowFrameTime, + ShowCpu = OverlayShowCpu, + ShowCpuClock = OverlayShowCpuClock, + ShowGpu = OverlayShowGpu, + ShowGpuClock = OverlayShowGpuClock, + ShowGpuTemp = OverlayShowGpuTemp, + ShowVram = OverlayShowVram, + ShowRam = OverlayShowRam, SingleLine = OverlaySingleLine, - Color = RtssOverlay.NormalizeColor(SelectedFpsOverlayColor?.Value) + Color = RtssOverlay.NormalizeColor(SelectedFpsOverlayColor?.Value), + Size = RtssOverlay.NormalizeSize(SelectedFpsOverlaySize?.Value), + Card = OverlayCard }; private void BuildFpsOverlayOptions() { var style = FpsOverlayStyle; var color = RtssOverlay.NormalizeColor(SelectedFpsOverlayColor?.Value); + var size = RtssOverlay.NormalizeSize(SelectedFpsOverlaySize?.Value); FpsOverlayOptions.Clear(); + // Settings says what Compact and Detailed show; the session menu tile keeps the short name. foreach (var s in RtssOverlay.Styles) - FpsOverlayOptions.Add(new ComboOption { Text = LocalizationService.Get(StyleKey(s)), Value = s }); + FpsOverlayOptions.Add(new ComboOption + { + Text = s is RtssOverlay.Compact or RtssOverlay.Detailed + ? $"{LocalizationService.Get(StyleKey(s))} ({LocalizationService.Get(StyleKey(s) + "Hint")})" + : LocalizationService.Get(StyleKey(s)), + Value = s + }); SelectedFpsOverlay = FpsOverlayOptions.First(o => o.Value == style); string[] names = ["White", "Blue", "Green", "Yellow", "Orange"]; @@ -49,12 +81,19 @@ private void BuildFpsOverlayOptions() for (var i = 0; i < RtssOverlay.Colors.Length; i++) FpsOverlayColorOptions.Add(new ComboOption { Text = LocalizationService.Get("FpsOverlayColor" + names[i]), Value = RtssOverlay.Colors[i] }); SelectedFpsOverlayColor = FpsOverlayColorOptions.First(o => o.Value == color); + + string[] sizes = ["Small", "Medium", "Large"]; + FpsOverlaySizeOptions.Clear(); + for (var i = 0; i < RtssOverlay.Sizes.Length; i++) + FpsOverlaySizeOptions.Add(new ComboOption { Text = LocalizationService.Get("FpsOverlaySize" + sizes[i]), Value = RtssOverlay.Sizes[i] }); + SelectedFpsOverlaySize = FpsOverlaySizeOptions.First(o => o.Value == size); OnPropertyChanged(nameof(FpsOverlayStatusText)); } private static string StyleKey(string style) => style switch { RtssOverlay.Off => "FpsOverlayOff", + RtssOverlay.FpsOnly => "FpsOverlayFpsOnly", RtssOverlay.Compact => "FpsOverlayCompact", RtssOverlay.Detailed => "FpsOverlayDetailed", RtssOverlay.Custom => "FpsOverlayCustom", @@ -66,23 +105,42 @@ private void LoadFpsOverlay(AppConfig config) var layout = config.FpsOverlayLayout ?? new FpsOverlayLayout(); SelectedFpsOverlay = FpsOverlayOptions.FirstOrDefault(o => o.Value == RtssOverlay.Normalize(config.FpsOverlay)) ?? FpsOverlayOptions.FirstOrDefault(); SelectedFpsOverlayColor = FpsOverlayColorOptions.FirstOrDefault(o => o.Value == RtssOverlay.NormalizeColor(layout.Color)) ?? FpsOverlayColorOptions.FirstOrDefault(); + SelectedFpsOverlaySize = FpsOverlaySizeOptions.FirstOrDefault(o => o.Value == RtssOverlay.NormalizeSize(layout.Size)) ?? FpsOverlaySizeOptions.FirstOrDefault(); OverlayShowApi = layout.ShowApi; OverlayShowFps = layout.ShowFps; OverlayShowFrameTime = layout.ShowFrameTime; + OverlayShowCpu = layout.ShowCpu; + OverlayShowCpuClock = layout.ShowCpuClock; + OverlayShowGpu = layout.ShowGpu; + OverlayShowGpuClock = layout.ShowGpuClock; + OverlayShowGpuTemp = layout.ShowGpuTemp; + OverlayShowVram = layout.ShowVram; + OverlayShowRam = layout.ShowRam; OverlaySingleLine = layout.SingleLine; + OverlayCard = layout.Card; } partial void OnSelectedFpsOverlayChanged(ComboOption? value) => FpsOverlayChanged(); partial void OnSelectedFpsOverlayColorChanged(ComboOption? value) => FpsOverlayChanged(); + partial void OnSelectedFpsOverlaySizeChanged(ComboOption? value) => FpsOverlayChanged(); partial void OnOverlayShowApiChanged(bool value) => FpsOverlayChanged(); partial void OnOverlayShowFpsChanged(bool value) => FpsOverlayChanged(); partial void OnOverlayShowFrameTimeChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowCpuChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowCpuClockChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowGpuChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowGpuClockChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowGpuTempChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowVramChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayShowRamChanged(bool value) => FpsOverlayChanged(); partial void OnOverlaySingleLineChanged(bool value) => FpsOverlayChanged(); + partial void OnOverlayCardChanged(bool value) => FpsOverlayChanged(); private void FpsOverlayChanged() { OnPropertyChanged(nameof(IsFpsOverlayCustom)); - SessionOverlayText = SelectedFpsOverlay?.Text ?? ""; + OnPropertyChanged(nameof(IsFpsOverlayDrawn)); + SessionOverlayText = FpsOverlayShortName; if (_applying) return; SettingChanged(); if (!IsConsoleActive || !IsRtssInstalled) return; diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index 3d4be07..2f9c977 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -193,7 +193,7 @@ private async Task LoadSessionValuesAsync() SessionAudioText = audioName; VolumePercent = volume ?? -1; SessionFpsText = state.FpsLimit > 0 ? $"{state.FpsLimit} FPS" : LocalizationService.Get("FpsNoLimit"); - SessionOverlayText = SelectedFpsOverlay?.Text ?? ""; + SessionOverlayText = FpsOverlayShortName; OnPropertyChanged(nameof(IsFpsMenuAvailable)); OnPropertyChanged(nameof(IsFpsOverlayAvailable)); } diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index cf4c24a..49804b1 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -186,7 +186,7 @@ + IsEnabled="{x:Bind ViewModel.IsRtssInstalled, Mode=OneWay}" IsExpanded="{x:Bind ViewModel.IsFpsOverlayDrawn, Mode=OneWay}"> @@ -195,23 +195,46 @@ SelectedItem="{x:Bind ViewModel.SelectedFpsOverlay, Mode=TwoWay}" DisplayMemberPath="Text"/> + + + + + + + + + - + + + + + + + + + + + + - - - diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index aa66235..b778ed0 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/tests/ConsoleMode.Tests/RtssOverlayTests.cs b/tests/ConsoleMode.Tests/RtssOverlayTests.cs index 646c097..2fd5d71 100644 --- a/tests/ConsoleMode.Tests/RtssOverlayTests.cs +++ b/tests/ConsoleMode.Tests/RtssOverlayTests.cs @@ -4,11 +4,18 @@ namespace ConsoleMode.Tests; public class RtssOverlayTests { + private static readonly HardwareSample Pc = new() + { + CpuUsage = 45.4, CpuClockMhz = 4321, GpuUsage = 97.6, GpuClockMhz = 1905, GpuTempC = 66.8, + VramUsedMb = 5324, VramTotalMb = 8192, RamUsedMb = 15155, RamTotalMb = 32768 + }; + [Theory] [InlineData(null, "off")] [InlineData("external", "off")] [InlineData("off", "compact")] - [InlineData("compact", "detailed")] + [InlineData("compact", "stats")] + [InlineData("stats", "detailed")] [InlineData("detailed", "custom")] [InlineData("custom", "external")] [InlineData("bogus", "off")] @@ -22,6 +29,16 @@ public void Default_leaves_afterburner_alone() Assert.False(RtssOverlay.HidesOthers(null)); } + // 1.6.0 saved FPS-only as "compact": it must stay FPS-only, not grow into the new Compact. + [Fact] + public void A_saved_compact_from_1_6_0_stays_fps_only() + { + Assert.Equal(RtssOverlay.FpsOnly, RtssOverlay.Normalize("compact")); + var text = RtssOverlay.Text("compact", null, Pc); + Assert.Contains("", text); + Assert.DoesNotContain("CPU", text); + } + [Fact] public void Off_draws_nothing_and_hides_afterburner() { @@ -33,32 +50,128 @@ public void Off_draws_nothing_and_hides_afterburner() public void External_draws_nothing() => Assert.Equal("", RtssOverlay.Text(RtssOverlay.External)); [Theory] - [InlineData("compact")] - [InlineData("detailed")] - [InlineData("custom")] - public void Styles_show_the_live_framerate_and_fit_the_rtss_slot(string style) + [InlineData("compact", "small")] + [InlineData("stats", "medium")] + [InlineData("detailed", "large")] + [InlineData("custom", "large")] + public void Styles_show_the_live_framerate_and_fit_the_rtss_slot(string style, string size) { - var text = RtssOverlay.Text(style); - Assert.Contains("", text); - Assert.True(text.Length < 4096); - Assert.All(text, c => Assert.True(c < 128, "the RTSS slot is ASCII")); + var all = new FpsOverlayLayout + { + ShowCpu = true, ShowCpuClock = true, ShowGpu = true, ShowGpuClock = true, ShowGpuTemp = true, + ShowVram = true, ShowRam = true, Size = size + }; + foreach (var sample in new[] { null, Pc }) + { + var text = RtssOverlay.Text(style, all, sample); + Assert.Contains("", text); + Assert.True(text.Length < 4096); + Assert.All(text, c => Assert.True(c < 256, "the RTSS slot is Latin-1")); + } } [Fact] - public void Detailed_adds_frametime_and_api() + public void Compact_is_one_line_of_usages() { - var text = RtssOverlay.Text(RtssOverlay.Detailed); + var text = RtssOverlay.Text(RtssOverlay.Compact, null, Pc); + Assert.DoesNotContain("\n", text); + Assert.Contains("CPU", text); + Assert.Contains(">45<", text); + Assert.Contains(">98<", text); + Assert.Contains(">46<", text); // RAM as a percentage + Assert.DoesNotContain("GHz", text); + } + + [Fact] + public void Detailed_has_clocks_temperature_memory_frametime_and_api() + { + var text = RtssOverlay.Text(RtssOverlay.Detailed, null, Pc); Assert.Contains("", text); Assert.Contains("", text); + Assert.Contains(">4.3<", text); + Assert.Contains(" GHz", text); + Assert.Contains(">1905<", text); + Assert.Contains(">67<", text); + Assert.Contains("°C", text); + Assert.Contains(">5.2<", text); + Assert.Contains(" / 8 GB", text); + Assert.Contains(">14.8<", text); + Assert.Contains("\n", text); + } + + [Fact] + public void Numbers_use_a_dot_whatever_the_windows_language() + { + var culture = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("pt-BR"); + try + { + Assert.Contains(">4.3<", RtssOverlay.Text(RtssOverlay.Detailed, null, Pc)); + } + finally + { + Thread.CurrentThread.CurrentCulture = culture; + } + } + + [Fact] + public void Before_the_first_sample_values_show_dashes() + { + var text = RtssOverlay.Text(RtssOverlay.Compact); + Assert.Contains(">--<", text); + Assert.Contains("CPU", text); + } + + [Fact] + public void What_this_pc_cannot_report_is_left_out() + { + var text = RtssOverlay.Text(RtssOverlay.Detailed, null, Pc with { GpuTempC = null, GpuClockMhz = null, VramUsedMb = null }); + Assert.DoesNotContain("°C", text); + Assert.DoesNotContain("MHz", text); + Assert.DoesNotContain("VRAM", text); + Assert.Contains("GPU", text); + } + + [Fact] + public void Card_draws_a_rounded_background_that_can_be_turned_off() + { + var card = RtssOverlay.Text(RtssOverlay.FpsOnly); + Assert.Contains("")] + [InlineData("medium", "")] + [InlineData("large", "")] + [InlineData("huge", "")] + public void Size_scales_every_text(string size, string expected) => + Assert.Contains(expected, RtssOverlay.Text(RtssOverlay.Detailed, new FpsOverlayLayout { Size = size })); + + [Fact] + public void Only_styles_with_pc_data_need_a_refresh() + { + Assert.False(RtssOverlay.NeedsHardware(RtssOverlay.FpsOnly)); + Assert.False(RtssOverlay.NeedsHardware(RtssOverlay.Off)); + Assert.True(RtssOverlay.NeedsHardware(RtssOverlay.Compact)); + Assert.True(RtssOverlay.NeedsHardware(RtssOverlay.Detailed)); + Assert.False(RtssOverlay.NeedsHardware(RtssOverlay.Custom, new FpsOverlayLayout())); + Assert.True(RtssOverlay.NeedsHardware(RtssOverlay.Custom, new FpsOverlayLayout { ShowGpuTemp = true })); } [Fact] public void Custom_shows_only_what_is_ticked_on_one_line_in_its_color() { var text = RtssOverlay.Text(RtssOverlay.Custom, - new FpsOverlayLayout { ShowApi = false, ShowFps = true, ShowFrameTime = true, SingleLine = true, Color = "6fe07a" }); + new FpsOverlayLayout { ShowApi = false, ShowFps = true, ShowFrameTime = true, ShowGpu = true, SingleLine = true, Color = "6fe07a" }, Pc); Assert.DoesNotContain("", text); Assert.Contains("", text); + Assert.Contains("GPU", text); + Assert.DoesNotContain("CPU", text); Assert.DoesNotContain("\n", text); Assert.StartsWith("", text); } @@ -78,3 +191,61 @@ public void Custom_with_nothing_ticked_still_shows_the_fps() public void Colors_from_the_config_cannot_break_the_tags(string? color, string expected) => Assert.Equal(expected, RtssOverlay.NormalizeColor(color)); } + +public class GpuCountersTests +{ + private const string Dgpu = "luid_0x00000000_0x0000D1E4_phys_0"; + private const string Igpu = "luid_0x00000000_0x0000A2B1_phys_0"; + + [Fact] + public void Picks_the_busiest_gpu_summing_every_process_per_engine() + { + var gpu = GpuCounters.Busiest( + [ + ($"pid_100_{Dgpu}_eng_0_engtype_3D", 40), + ($"pid_200_{Dgpu}_eng_0_engtype_3D", 35), + ($"pid_100_{Dgpu}_eng_3_engtype_Copy", 5), + ($"pid_300_{Igpu}_eng_0_engtype_3D", 20), + ($"pid_300_{Igpu}_eng_1_engtype_VideoDecode", 60), + ]); + + Assert.NotNull(gpu); + Assert.Equal(Dgpu, gpu.Value.Key, ignoreCase: true); + Assert.Equal(75, gpu.Value.Usage, 3); + Assert.Equal(0xD1E4u, gpu.Value.LuidLow); + Assert.Equal(0, gpu.Value.LuidHigh); + Assert.Equal(0u, gpu.Value.Node); + } + + [Fact] + public void Usage_is_capped_and_the_clock_is_read_from_the_3d_engine() + { + var gpu = GpuCounters.Busiest( + [ + ($"pid_1_{Dgpu}_eng_2_engtype_Compute", 70), + ($"pid_2_{Dgpu}_eng_2_engtype_Compute", 50), + ($"pid_1_{Dgpu}_eng_5_engtype_3D", 10), + ]); + + Assert.Equal(100, gpu!.Value.Usage); + Assert.Equal(5u, gpu.Value.Node); + } + + [Fact] + public void Idle_gpus_fall_back_to_the_one_with_the_most_vram_in_use() + { + var gpu = GpuCounters.Busiest( + [($"pid_1_{Igpu}_eng_0_engtype_3D", 0), ($"pid_1_{Dgpu}_eng_0_engtype_3D", 0)], + [(Igpu, 300.0 * 1024 * 1024), (Dgpu, 1500.0 * 1024 * 1024)]); + + Assert.Equal(Dgpu, gpu!.Value.Key, ignoreCase: true); + Assert.Equal(1500, GpuCounters.DedicatedMb([(Igpu, 1.0), (Dgpu, 1500.0 * 1024 * 1024)], gpu.Value.Key)); + } + + [Fact] + public void No_gpu_counters_means_no_gpu() + { + Assert.Null(GpuCounters.Busiest([])); + Assert.Null(GpuCounters.Busiest([("garbage", 50)])); + } +} From 3537d819cf5f472a3c48e1f0aec8cc0d69f213e5 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 10 Oct 2026 12:39:31 -0300 Subject: [PATCH 2/3] feat: tune the FPS counter from the session menu and pin it to a corner A on the session menu's FPS counter row now opens a list of its settings (style, size, position, card, color and, in Custom, the line layout and each item) instead of only cycling the style. A changes the focused setting and the list stays open on that row. The new Position setting keeps RTSS's OSD position by default or pins the counter to a screen corner with RTSS's sticky layer positions. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- docs/TESTING.md | 6 +- src/ConsoleMode/Resources/Strings.en-US.json | 7 ++ src/ConsoleMode/Resources/Strings.es-ES.json | 7 ++ src/ConsoleMode/Resources/Strings.pt-BR.json | 7 ++ .../Services/LocalizationService.cs | 1 + src/ConsoleMode/Services/RtssOverlay.cs | 27 ++++- src/ConsoleMode/SessionMenuWindow.xaml | 2 +- src/ConsoleMode/SessionMenuWindow.xaml.cs | 6 +- .../ViewModels/MainViewModel.FpsOverlay.cs | 99 ++++++++++++++++++- .../ViewModels/MainViewModel.SessionMenu.cs | 15 +++ src/ConsoleMode/Views/SettingsView.xaml | 6 ++ tests/ConsoleMode.Tests/RtssOverlayTests.cs | 40 ++++++-- 14 files changed, 203 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 8c60c52..d2ceea2 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,7 +4,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new -- Revamped FPS counter: the numbers sit on a dark rounded card (it can be turned off) in three sizes, Small, Medium and Large. New styles: FPS only, Compact (FPS plus CPU, GPU and RAM usage on one line) and Detailed (FPS, frame time and API, plus CPU clock, GPU clock and temperature, VRAM and RAM). In Custom, the PC items can be picked one by one too. The data comes from Windows and the graphics driver, nothing to install; anyone already on "Compact" keeps seeing just the FPS. +- Revamped FPS counter: the numbers sit on a dark rounded card (it can be turned off) in three sizes, Small, Medium and Large. You can pick the screen corner it shows in (or keep RTSS's position) and tune it all from the session menu itself: A on "FPS counter" opens the list with style, size, position, background, color and, in Custom, the items. New styles: FPS only, Compact (FPS plus CPU, GPU and RAM usage on one line) and Detailed (FPS, frame time and API, plus CPU clock, GPU clock and temperature, VRAM and RAM). In Custom, the PC items can be picked one by one too. The data comes from Windows and the graphics driver, nothing to install; anyone already on "Compact" keeps seeing just the FPS. ## [1.6.0] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8dd65..e68f4ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades -- Contador de FPS repaginado: os números ficam num card escuro e arredondado (dá para desligar) em três tamanhos, Pequeno, Médio e Grande. Novos estilos: Apenas FPS, Compacto (FPS e uso de CPU, GPU e RAM numa linha) e Detalhado (FPS, tempo de quadro e API, mais clock da CPU, clock e temperatura da GPU, VRAM e RAM). No Personalizado, os itens do PC também podem ser escolhidos um a um. Os dados vêm do Windows e do driver de vídeo, sem instalar nada; quem já usava "Compacto" continua vendo só o FPS. +- Contador de FPS repaginado: os números ficam num card escuro e arredondado (dá para desligar) em três tamanhos, Pequeno, Médio e Grande. Dá para escolher o canto da tela onde ele aparece (ou deixar a posição do RTSS) e ajustar tudo pelo próprio menu da sessão: A em "Contador de FPS" abre a lista com estilo, tamanho, posição, fundo, cor e, no Personalizado, os itens. Novos estilos: Apenas FPS, Compacto (FPS e uso de CPU, GPU e RAM numa linha) e Detalhado (FPS, tempo de quadro e API, mais clock da CPU, clock e temperatura da GPU, VRAM e RAM). No Personalizado, os itens do PC também podem ser escolhidos um a um. Os dados vêm do Windows e do driver de vídeo, sem instalar nada; quem já usava "Compacto" continua vendo só o FPS. ## [1.6.0] ### Novidades diff --git a/docs/TESTING.md b/docs/TESTING.md index ae1457b..0e4c4d0 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -385,7 +385,8 @@ Pré-requisito: RivaTuner Statistics Server instalado, com "Show On-Screen Displ - [ ] Sem o RTSS instalado, a linha "Contador de FPS" não aparece no menu da sessão e o cartão em Ajustes fica desabilitado. - [ ] Na prévia (menu aberto fora de uma sessão), a linha não aparece. - [ ] Config nova: o estilo é "Afterburner (não mexer)" e o OSD do Afterburner aparece normal na sessão. -- [ ] Em sessão, A na linha alterna Afterburner → Desligado → Apenas FPS → Compacto → Detalhado → Personalizado → Afterburner, e o jogo muda na hora. +- [ ] Em sessão, A na linha "Contador de FPS" abre a lista de ajustes do contador. A em "Estilo" alterna Afterburner → Desligado → Apenas FPS → Compacto → Detalhado → Personalizado → Afterburner, e o jogo muda na hora; a lista continua aberta com o foco na mesma linha. +- [ ] Na lista do contador, Tamanho, Posição, Fundo em card e Cor mudam o contador na hora; com Personalizado aparecem "Tudo em uma linha" e os itens (com ✓ nos ligados), e A liga/desliga cada um. B fecha a lista e volta para a linha do contador. Os ajustes ficam salvos e aparecem iguais em Ajustes. - [ ] Desligado: nada na tela, nem o nosso nem o do Afterburner (no máximo um piscar rápido do Afterburner de vez em quando). - [ ] Compacto / Detalhado / Personalizado: só o nosso aparece; o do Afterburner fica escondido. - [ ] Afterburner: o nosso some e o do Afterburner volta em até 1 s. @@ -399,7 +400,8 @@ Pré-requisito: RivaTuner Statistics Server instalado, com "Show On-Screen Displ ### Card, tamanhos e dados do PC (1.6.1) - [ ] Apenas FPS / Compacto / Detalhado aparecem dentro de um card escuro, translúcido e de cantos arredondados, com uma folga em volta do texto; o card acompanha o tamanho do texto (não cobre a tela toda nem o OSD de outro programa). -- [ ] O card fica na posição do OSD escolhida no RTSS (canto superior esquerdo, direito etc.), não num lugar fixo. +- [ ] Posição "A do RTSS": o card fica na posição do OSD escolhida no RTSS. +- [ ] Posição em cada um dos quatro cantos: o contador vai para aquele canto da tela, com e sem o card, e não sai da tela nos cantos da direita e de baixo. - [ ] "Fundo em card" desligado: o texto volta a ficar direto sobre o jogo, sem fundo. - [ ] Tamanho Pequeno / Médio / Grande muda o tamanho de todo o contador na hora; no Grande o texto continua nítido e as unidades (FPS, %, GHz) ficam alinhadas na base dos números. - [ ] Compacto: uma linha com FPS, CPU %, GPU % e RAM %; os números não fazem o texto "pular" quando mudam de 2 para 3 dígitos. diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index f20a9a7..8ca8ba1 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -260,6 +260,13 @@ "FpsOverlaySizeSmall": "Small", "FpsOverlaySizeMedium": "Medium", "FpsOverlaySizeLarge": "Large", + "FpsOverlayStyle": "Style", + "FpsOverlayPosition": "Position", + "FpsOverlayPositionRtss": "RTSS's", + "FpsOverlayPositionTopLeft": "Top left corner", + "FpsOverlayPositionTopRight": "Top right corner", + "FpsOverlayPositionBottomLeft": "Bottom left corner", + "FpsOverlayPositionBottomRight": "Bottom right corner", "FpsOverlayCardBackground": "Card background", "FpsOverlayCardBackgroundDescription": "A dark rounded card behind the numbers, so they read well over any scene.", "FpsNoLimit": "No limit", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index c1e4d7c..58f21d0 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -260,6 +260,13 @@ "FpsOverlaySizeSmall": "Pequeño", "FpsOverlaySizeMedium": "Mediano", "FpsOverlaySizeLarge": "Grande", + "FpsOverlayStyle": "Estilo", + "FpsOverlayPosition": "Posición", + "FpsOverlayPositionRtss": "La de RTSS", + "FpsOverlayPositionTopLeft": "Esquina superior izquierda", + "FpsOverlayPositionTopRight": "Esquina superior derecha", + "FpsOverlayPositionBottomLeft": "Esquina inferior izquierda", + "FpsOverlayPositionBottomRight": "Esquina inferior derecha", "FpsOverlayCardBackground": "Fondo de tarjeta", "FpsOverlayCardBackgroundDescription": "Una tarjeta oscura y redondeada detrás de los números, para leerlos bien en cualquier escena.", "FpsNoLimit": "Sin límite", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index 576ece4..86b127f 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -260,6 +260,13 @@ "FpsOverlaySizeSmall": "Pequeno", "FpsOverlaySizeMedium": "Médio", "FpsOverlaySizeLarge": "Grande", + "FpsOverlayStyle": "Estilo", + "FpsOverlayPosition": "Posição", + "FpsOverlayPositionRtss": "A do RTSS", + "FpsOverlayPositionTopLeft": "Canto superior esquerdo", + "FpsOverlayPositionTopRight": "Canto superior direito", + "FpsOverlayPositionBottomLeft": "Canto inferior esquerdo", + "FpsOverlayPositionBottomRight": "Canto inferior direito", "FpsOverlayCardBackground": "Fundo em card", "FpsOverlayCardBackgroundDescription": "Um cartão escuro e arredondado atrás dos números, para ler bem em qualquer cena.", "FpsNoLimit": "Sem limite", diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 06cf5b6..5df3b47 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -167,6 +167,7 @@ internal void NotifyAll() public string FpsOverlayShowVram => LocalizationService.Get(nameof(FpsOverlayShowVram)); public string FpsOverlayShowRam => LocalizationService.Get(nameof(FpsOverlayShowRam)); public string FpsOverlaySize => LocalizationService.Get(nameof(FpsOverlaySize)); + public string FpsOverlayPosition => LocalizationService.Get(nameof(FpsOverlayPosition)); public string FpsOverlayCardBackground => LocalizationService.Get(nameof(FpsOverlayCardBackground)); public string FpsOverlayCardBackgroundDescription => LocalizationService.Get(nameof(FpsOverlayCardBackgroundDescription)); public string FpsPlaceholder => LocalizationService.Get(nameof(FpsPlaceholder)); diff --git a/src/ConsoleMode/Services/RtssOverlay.cs b/src/ConsoleMode/Services/RtssOverlay.cs index 3a38c15..5f2f445 100644 --- a/src/ConsoleMode/Services/RtssOverlay.cs +++ b/src/ConsoleMode/Services/RtssOverlay.cs @@ -28,6 +28,8 @@ public sealed class FpsOverlayLayout public string Size { get; set; } = RtssOverlay.Small; /// Draw the counter on a translucent rounded card instead of straight over the game. public bool Card { get; set; } = true; + /// One of : where RTSS puts its OSD, or a corner of the screen. + public string Position { get; set; } = RtssOverlay.PositionRtss; } /// @@ -62,12 +64,17 @@ public static partial class RtssOverlay public const string Large = "large"; public static readonly string[] Sizes = [Small, Medium, Large]; + public const string PositionRtss = "rtss"; + public const string TopLeft = "top-left"; + public const string TopRight = "top-right"; + public const string BottomLeft = "bottom-left"; + public const string BottomRight = "bottom-right"; + /// The RTSS position first (the default): it keeps whatever the user set in RTSS. + public static readonly string[] Positions = [PositionRtss, TopLeft, TopRight, BottomLeft, BottomRight]; + public static string Normalize(string? style) => Styles.FirstOrDefault(s => string.Equals(s, style, StringComparison.OrdinalIgnoreCase)) ?? External; - /// A on the session menu row walks and wraps around. - public static string Next(string? style) => Styles[(Array.IndexOf(Styles, Normalize(style)) + 1) % Styles.Length]; - /// Whether this style keeps Afterburner's OSD off the screen. public static bool HidesOthers(string? style) => Normalize(style) != External; @@ -78,6 +85,9 @@ public static string NormalizeColor(string? color) => public static string NormalizeSize(string? size) => Sizes.FirstOrDefault(s => string.Equals(s, size, StringComparison.OrdinalIgnoreCase)) ?? Small; + public static string NormalizePosition(string? position) => + Positions.FirstOrDefault(p => string.Equals(p, position, StringComparison.OrdinalIgnoreCase)) ?? PositionRtss; + /// Whether the style shows CPU/GPU/memory, which RTSS doesn't know: the text then needs a fresh sample every second. public static bool NeedsHardware(string? style, FpsOverlayLayout? layout = null) => (ItemsOf(Normalize(style), layout) & PcItems) != 0; @@ -175,11 +185,22 @@ public static string Text(string? style, FpsOverlayLayout? layout = null, Hardwa .Append($"<{ValueSize}={Percent(100, scale)}><{UnitSize}=-{Percent(55, scale)}><{LabelSize}=-{Percent(70, scale)}>") .Append($"<{FpsSize}={Percent(singleLine ? 100 : 130, scale)}>") .Append(""); + // A corner is a sticky position ( top left, top right, bottom left, bottom + // right of RTSS's 3x3 screen grid), which ignores the OSD position set in RTSS; it needs a layer. + var corner = NormalizePosition(layout.Position) switch + { + TopLeft => "", TopRight => "", BottomLeft => "", BottomRight => "", _ => "" + }; + header.Append(corner); if (layout.Card) { var pad = (int)Math.Round(6 * scale); header.Append($"<{CardFill}>\b"); } + else if (corner.Length > 0) + { + header.Append(""); + } return header + string.Join(singleLine ? Gap + Gap : "\n", rows); } diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index aefd5a1..312ee60 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -237,7 +237,7 @@ -