Show the graphics card beside the levels

Its name and memory, with the other cards in the tooltip. Windows has them in the
registry. On Linux the name comes from the driver or pci.ids and the memory from
amdgpu or nvidia-smi; without either there is only the name. A card in a processor
says shared memory, as what it reports is a share set in the firmware.

No rating of the card against the settings: what a ride takes depends on the
textures of its scenery and vehicles, which are not known before it starts.
This commit is contained in:
maj00r
2026-11-01 20:54:01 +01:00
parent a7c9cdb97f
commit 42b2259b7c
13 changed files with 489 additions and 4 deletions

View File

@@ -68,6 +68,7 @@ public sealed class AppServices
Tapes = new HaslerTapes(files, paths, log, clock, Timetables,
() => Processes.IsRunning(Path.GetFileNameWithoutExtension(SettingsStore.ResolveExecutable())));
Gpus = new LinuxGpus(files, environment);
GraphicsCards = new GraphicsCards(files, environment, Gpus);
Displays = new Displays(files, environment);
StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables,
files, paths, Processes, random, log, Gpus, environment,
@@ -133,6 +134,8 @@ public sealed class AppServices
public LinuxGpus Gpus { get; }
public GraphicsCards GraphicsCards { get; }
public Displays Displays { get; }
public SettingsPaths SettingsPaths { get; }

View File

@@ -36,7 +36,9 @@ public enum GpuNote
/// <param name="Driver">The kernel driver: nvidia, amdgpu, i915...</param>
/// <param name="BootVga">The card the firmware started the display on.</param>
/// <param name="InternalPanel">The card has a laptop's built-in screen among its outputs (eDP, LVDS, DSI).</param>
public sealed record GpuDevice(string Slot, string PciId, string Driver, bool BootVga, bool InternalPanel = false)
/// <param name="MemoryBytes">Its memory, where the driver tells: amdgpu does.</param>
public sealed record GpuDevice(string Slot, string PciId, string Driver, bool BootVga, bool InternalPanel = false,
long MemoryBytes = 0)
{
public string Vendor => PciId.Split(':')[0].ToUpperInvariant() switch
{

View File

@@ -0,0 +1,25 @@
using System;
using System.Text.RegularExpressions;
namespace StarterNG.Domain.Settings;
/// <summary>A graphics card as the system names it.</summary>
/// <param name="Name">As the driver or the PCI list calls it.</param>
/// <param name="MemoryBytes">The memory it reports; 0 when the system does not tell.</param>
public sealed record GraphicsCard(string Name, long MemoryBytes)
{
private const long GiB = 1L << 30;
// Intel but for the discrete Arc A and B series; AMD's processor graphics, which
// are named by a number with an M, or not at all.
private static readonly Regex SharedMemoryName = new(
@"\bIntel\b(?!.*\bArc\b.*\b[AB]\d{3}\b)" +
@"|\bRadeon(\(TM\))? (\d{3}M\b|Graphics\b|Vega Series\b|(RX )?Vega (3|6|8|10|11)\b)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
/// <summary>A processor's card, sharing the computer's memory. No discrete card reports under 1 GB any more.</summary>
public bool SharedMemory => SharedMemoryName.IsMatch(Name) || MemoryBytes is > 0 and < GiB;
/// <summary>The memory as it is told apart on a box: 8546942976 bytes are 8 GB.</summary>
public int MemoryGb => (int)Math.Round(MemoryBytes / (double)GiB);
}

View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.Versioning;
using System.Security;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Settings;
namespace StarterNG.Infrastructure;
/// <summary>
/// The names and memory of the machine's graphics cards, the one a simulator
/// runs on first. Nothing on macOS, where the simulator does not run natively.
/// </summary>
public sealed class GraphicsCards
{
private static readonly string[] PciLists =
{
"/usr/share/hwdata/pci.ids", "/usr/share/misc/pci.ids", "/usr/share/pci.ids",
// The host's, from inside the Steam Linux Runtime container.
"/run/host/usr/share/hwdata/pci.ids", "/run/host/usr/share/misc/pci.ids"
};
private const string RegistryMachine = @"\Registry\Machine\";
private readonly IFileSystem _files;
private readonly IEnvironment _environment;
private readonly LinuxGpus _gpus;
private readonly Func<IReadOnlyList<string>> _nvidiaSmi;
public GraphicsCards(IFileSystem files, IEnvironment environment, LinuxGpus gpus,
Func<IReadOnlyList<string>>? nvidiaSmi = null)
{
_files = files;
_environment = environment;
_gpus = gpus;
_nvidiaSmi = nvidiaSmi ?? AskNvidiaSmi;
}
/// <summary>Takes a while on Linux, where the PCI list is read: not for the UI thread.</summary>
public IReadOnlyList<GraphicsCard> List()
{
IEnumerable<GraphicsCard> cards = Array.Empty<GraphicsCard>();
if (_environment.IsLinux)
cards = FromLinux();
else if (_environment.IsWindows && OperatingSystem.IsWindows())
cards = FromRegistry();
// A program goes to the discrete card, on Windows by its own request.
return cards.OrderBy(card => card.SharedMemory).ThenByDescending(card => card.MemoryBytes).ToList();
}
[SupportedOSPlatform("windows")]
private static IEnumerable<GraphicsCard> FromRegistry()
{
try
{
using var video = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"HARDWARE\DEVICEMAP\VIDEO");
if (video is null)
return Array.Empty<GraphicsCard>();
var cards = new List<GraphicsCard>();
foreach (string name in video.GetValueNames())
{
if (!name.StartsWith(@"\Device\Video", StringComparison.OrdinalIgnoreCase) ||
video.GetValue(name) is not string path ||
!path.StartsWith(RegistryMachine, StringComparison.OrdinalIgnoreCase))
continue;
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(path[RegistryMachine.Length..]);
// The system's basic display driver and remote desktops are no cards.
if (key?.GetValue("DriverDesc") is not string card ||
key.GetValue("MatchingDeviceId") is not string device ||
!device.StartsWith(@"pci\", StringComparison.OrdinalIgnoreCase))
continue;
long memory = MemoryIn(key.GetValue("HardwareInformation.qwMemorySize"));
if (memory == 0)
memory = MemoryIn(key.GetValue("HardwareInformation.MemorySize"));
cards.Add(new GraphicsCard(card, memory));
}
// A card is listed once for each of its outputs.
return cards.DistinctBy(card => card.Name).ToList();
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException or SecurityException)
{
return Array.Empty<GraphicsCard>();
}
}
private static long MemoryIn(object? value) => value switch
{
long qword => qword,
int dword => (uint)dword,
byte[] { Length: 8 } bytes => BitConverter.ToInt64(bytes),
byte[] { Length: 4 } bytes => BitConverter.ToUInt32(bytes),
_ => 0
};
private IEnumerable<GraphicsCard> FromLinux()
{
var gpus = _gpus.List();
if (gpus.Count == 0)
return Array.Empty<GraphicsCard>();
IReadOnlyList<string>? pciList = null;
var nvidiaMemory = gpus.Any(gpu => gpu.Driver == "nvidia")
? ParseNvidiaMemory(_nvidiaSmi())
: new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
var cards = new List<GraphicsCard>();
foreach (var gpu in gpus)
{
string? model = NvidiaModel(gpu.Slot);
if (model is null)
{
pciList ??= ReadPciList();
model = ModelInPciList(pciList, gpu.PciId);
}
string name = model is null ? $"{gpu.Vendor} {gpu.PciId}"
: model.StartsWith(gpu.Vendor, StringComparison.OrdinalIgnoreCase) ? model
: $"{gpu.Vendor} {model}";
long memory = gpu.MemoryBytes > 0 ? gpu.MemoryBytes : nvidiaMemory.GetValueOrDefault(gpu.Slot);
cards.Add(new GraphicsCard(name, memory));
}
return cards;
}
private string? NvidiaModel(string slot)
{
string path = $"/proc/driver/nvidia/gpus/{slot}/information";
try
{
if (!_files.FileExists(path))
return null;
foreach (string line in _files.ReadAllLines(path))
if (line.StartsWith("Model:", StringComparison.Ordinal))
return line["Model:".Length..].Trim();
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
return null;
}
private IReadOnlyList<string> ReadPciList()
{
foreach (string path in PciLists)
{
try
{
if (_files.FileExists(path))
return _files.ReadAllLines(path);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
}
return Array.Empty<string>();
}
/// <summary>What a pci.ids list calls a card: the marketing name in brackets, if it has one.</summary>
public static string? ModelInPciList(IEnumerable<string> lines, string pciId)
{
string[] ids = pciId.ToLowerInvariant().Split(':');
if (ids.Length != 2)
return null;
string vendorLine = ids[0] + " ";
string deviceLine = "\t" + ids[1] + " ";
bool inVendor = false;
foreach (string line in lines)
{
if (line.Length == 0 || line[0] == '#')
continue;
if (line[0] != '\t')
{
if (inVendor)
return null;
inVendor = line.StartsWith(vendorLine, StringComparison.Ordinal);
}
else if (inVendor && line.StartsWith(deviceLine, StringComparison.Ordinal))
{
string model = line[deviceLine.Length..].Trim();
int bracket = model.LastIndexOf('[');
return bracket >= 0 && model.EndsWith(']') ? model[(bracket + 1)..^1] : model;
}
}
return null;
}
/// <summary>Lines of "00000000:01:00.0, 8188": the PCI address, as /sys gives it with a shorter domain, and MiB.</summary>
public static IReadOnlyDictionary<string, long> ParseNvidiaMemory(IEnumerable<string> lines)
{
var memory = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase);
foreach (string line in lines)
{
string[] fields = line.Split(',', StringSplitOptions.TrimEntries);
if (fields.Length == 2 && fields[0].Length >= 12 && long.TryParse(fields[1], out long mib))
memory[fields[0][^12..]] = mib << 20;
}
return memory;
}
private static IReadOnlyList<string> AskNvidiaSmi()
{
var start = new ProcessStartInfo("nvidia-smi", "--query-gpu=pci.bus_id,memory.total --format=csv,noheader,nounits")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
try
{
using var process = Process.Start(start);
if (process is null)
return Array.Empty<string>();
process.ErrorDataReceived += (_, _) => { };
process.BeginErrorReadLine();
var output = process.StandardOutput.ReadToEndAsync();
if (!process.WaitForExit(3000))
{
process.Kill();
return Array.Empty<string>();
}
return output.GetAwaiter().GetResult().Split('\n', StringSplitOptions.RemoveEmptyEntries);
}
// Not installed, as it often is not inside the Steam Linux Runtime container.
catch (Exception e) when (e is Win32Exception or InvalidOperationException or IOException)
{
return Array.Empty<string>();
}
}
}

View File

@@ -61,7 +61,12 @@ public sealed class LinuxGpus
string bootVga = Path.Combine(device, "boot_vga");
bool boot = _files.FileExists(bootVga) && _files.ReadAllText(bootVga).Trim() == "1";
gpus.Add(new GpuDevice(slot, id, fields.GetValueOrDefault("DRIVER", ""), boot, withPanel.Contains(name)));
string vram = Path.Combine(device, "mem_info_vram_total");
long memory = _files.FileExists(vram) && long.TryParse(_files.ReadAllText(vram).Trim(), out long bytes)
? bytes
: 0;
gpus.Add(new GpuDevice(slot, id, fields.GetValueOrDefault("DRIVER", ""), boot, withPanel.Contains(name),
memory));
}
return gpus.OrderBy(g => g.Slot, StringComparer.Ordinal).ToList();

View File

@@ -96,8 +96,14 @@
<TextBlock x:Name="PresetStateText" Grid.Column="2" VerticalAlignment="Center" FontSize="12"
Foreground="{StaticResource CarbonFgDim}" />
</Grid>
<TextBlock FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [GfxPresetDesc], Source={x:Static starterNg:App.Loc}}" />
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="10">
<TextBlock x:Name="CardText" IsVisible="False" MaxWidth="320" VerticalAlignment="Center"
FontSize="12" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource CarbonFg}" />
<TextBlock Grid.Column="1" VerticalAlignment="Center" FontSize="12" TextWrapping="Wrap"
TextAlignment="Right" Foreground="{StaticResource CarbonFgDim}"
Text="{Binding [GfxPresetDesc], Source={x:Static starterNg:App.Loc}}" />
</Grid>
</StackPanel>
</Border>

View File

@@ -99,6 +99,8 @@ public partial class Settings : UserControl, ISettingsCapture
_keyRows.Changed += QueuePresetMarks;
BuildPresetButtons();
BuildMonitorRates();
ReadGraphicsCards();
App.Loc.LanguageChanged += UpdateCardText;
App.Loc.LanguageChanged += _keyRows.UpdateTexts;
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
@@ -625,6 +627,32 @@ public partial class Settings : UserControl, ISettingsCapture
_markingLevels = false;
}
// ── The player's graphics card, beside the levels ────────────────────────
private IReadOnlyList<GraphicsCard> _cards = Array.Empty<GraphicsCard>();
private async void ReadGraphicsCards()
{
var reader = AppServices.Current.GraphicsCards;
_cards = await Task.Run(() => reader.List());
UpdateCardText();
}
private static string CardLine(GraphicsCard card) =>
card.SharedMemory ? string.Format(App.Loc["GpuCardShared"], card.Name)
: card.MemoryBytes > 0 ? string.Format(App.Loc["GpuCard"], card.Name, card.MemoryGb)
: card.Name;
private void UpdateCardText()
{
CardText.IsVisible = _cards.Count > 0;
if (_cards.Count == 0)
return;
CardText.Text = CardLine(_cards[0]);
ToolTip.SetTip(CardText, string.Join(Environment.NewLine, _cards.Select(CardLine)));
}
// ── The screens' refresh rates, for the frame rate limit ─────────────────
private static readonly IBrush FgDimBrush = new SolidColorBrush(Color.Parse("#9098A0"));

View File

@@ -941,4 +941,6 @@
<String key="CabShadowsRange">Dosah zdrojů stínů v kabině</String>
<String key="UseGLES">Použít OpenGL ES</String>
<String key="UseGLESDesc">OpenGL ES místo OpenGL. Nefunguje s rendererem legacy.</String>
<String key="GpuCard">{0}, {1} GB</String>
<String key="GpuCardShared">{0}, sdílená paměť</String>
</Language>

View File

@@ -1193,4 +1193,6 @@
<String key="CabShadowsRangeDesc">Distance at which light sources still cast shadows inside the cab. Zero turns cab shadows off.</String>
<String key="UseGLES">Use OpenGL ES</String>
<String key="UseGLESDesc">OpenGL ES instead of OpenGL. Does not work with the legacy renderer.</String>
<String key="GpuCard">{0}, {1} GB</String>
<String key="GpuCardShared">{0}, shared memory</String>
</Language>

View File

@@ -941,4 +941,6 @@
<String key="CabShadowsRange">Vezetőállás árnyékforrásainak hatótávolsága</String>
<String key="UseGLES">OpenGL ES használata</String>
<String key="UseGLESDesc">OpenGL ES az OpenGL helyett. A legacy megjelenítővel nem működik.</String>
<String key="GpuCard">{0}, {1} GB</String>
<String key="GpuCardShared">{0}, megosztott memória</String>
</Language>

View File

@@ -1015,4 +1015,6 @@
<String key="CabShadowsRangeDesc">Odległość, z jakiej źródła światła nadal rzucają cień w kabinie. Zero wyłącza cienie w kabinie.</String>
<String key="UseGLES">Używaj OpenGL ES</String>
<String key="UseGLESDesc">OpenGL ES zamiast OpenGL. Nie działa z rendererem legacy.</String>
<String key="GpuCard">{0}, {1} GB</String>
<String key="GpuCardShared">{0}, pamięć wspólna</String>
</Language>

View File

@@ -941,4 +941,6 @@
<String key="CabShadowsRange">Дальность источников теней в кабине</String>
<String key="UseGLES">Использовать OpenGL ES</String>
<String key="UseGLESDesc">OpenGL ES вместо OpenGL. Не работает с рендерером legacy.</String>
<String key="GpuCard">{0}, {1} ГБ</String>
<String key="GpuCardShared">{0}, общая память</String>
</Language>