diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index a08c243..1d2e849 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,9 @@ 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. 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. (#146) + ### Changes - The session menu no longer shows the "Record the last 30 s · coming soon" row, which never worked. (#147) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eadb1..f102a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ 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. 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. (#146) + ### Mudanças - O menu da sessão não mostra mais a linha "Gravar os últimos 30 s · em breve", que nunca funcionou. (#147) 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..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 → 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. @@ -395,3 +396,21 @@ 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). +- [ ] 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. +- [ ] 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 b65b537..eebeffe 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -229,7 +229,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)", @@ -241,7 +241,32 @@ "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", + "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", "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 9bcb9e4..d957a35 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -229,7 +229,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)", @@ -241,7 +241,32 @@ "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", + "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", "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 7af3d11..85d3ca2 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -229,7 +229,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)", @@ -241,7 +241,32 @@ "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", + "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", "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 cf4d259..c3ce7ba 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -157,6 +157,19 @@ 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 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)); 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..5f2f445 100644 --- a/src/ConsoleMode/Services/RtssOverlay.cs +++ b/src/ConsoleMode/Services/RtssOverlay.cs @@ -1,29 +1,52 @@ +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; + /// One of : where RTSS puts its OSD, or a corner of the screen. + public string Position { get; set; } = RtssOverlay.PositionRtss; } /// /// 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,17 +54,27 @@ 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 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; @@ -49,38 +82,165 @@ 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; + + 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; + + [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(""); + // 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"); } - return $"" + string.Join(separator, items); + else if (corner.Length > 0) + { + header.Append(""); + } + 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/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 6946138..fe5d934 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -237,7 +237,7 @@ -