Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sources/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
<PackageVersion Include="NuGet.ProjectModel" Version="7.3.1" />
<PackageVersion Include="NuGet.Protocol" Version="7.3.1" />
<PackageVersion Include="NuGet.Resolver" Version="7.3.1" />
<PackageVersion Include="Sentry" Version="6.8.0" />
<PackageVersion Include="Silk.NET.Assimp" Version="$(SilkVersion)" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="Stride.GNU.Getopt" Version="3.0.0" />
Expand Down
126 changes: 104 additions & 22 deletions sources/core/Stride.Core.Design/Windows/AppHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,26 @@ public static string BuildErrorMessage(Exception exception, string? header = nul
return body.ToString();
}

/// <summary>
/// Only fields useful for diagnosing graphics issues; the full Win32_VideoController dump
/// leaks machine-identifying values such as SystemName and PNPDeviceID.
/// </summary>
private const string VideoControllerQuery =
"SELECT Name,AdapterCompatibility,DriverVersion,DriverDate," +
"CurrentHorizontalResolution,CurrentVerticalResolution,CurrentBitsPerPixel,CurrentRefreshRate " +
"FROM Win32_VideoController";

public static void WriteVideoConfig(StringBuilder writer)
{
try
{
var i = 0;
var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_VideoController");
foreach (var managementObject in searcher.Get().OfType<ManagementObject>())
foreach (var properties in QueryVideoControllers())
{
writer.AppendLine($"GPU {++i}");
foreach (var property in managementObject.Properties)
foreach (var (name, value) in properties)
{
writer.AppendLine($" {property.Name}: {property.Value}");
writer.AppendLine($" {name}: {value}");
}
}
}
Expand All @@ -55,39 +63,113 @@ public static void WriteVideoConfig(StringBuilder writer)
}

public static Dictionary<string, string> GetVideoConfig()
{
return OperatingSystem.IsWindows() ? GetVideoConfigWindows() : [];

@Jklawreszuk Jklawreszuk Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're calling OS-specific code. I know GameStudio isn't cross-platform yet, but it's worth having these checks somewhere in place for the future. But its just a nitpick 😅️

@Jklawreszuk Jklawreszuk Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, now that I think about it, we could move AppHelper to a new folder called "Desktop" (or Core?). The same thing goes with FileLock.cs (its fully xplat now)

}

private static Dictionary<string, string> GetVideoConfigWindows()
{
var result = new Dictionary<string, string>();
if (OperatingSystem.IsWindows())
GetVideoConfigWindows(result);
try
{
var deviceId = 0;
foreach (var properties in QueryVideoControllers())
{
foreach (var (name, value) in properties)
{
result.Add($"GPU{deviceId}.{name}", value);
}
deviceId++;
}
}
catch (Exception)
{
// ignored
}

return result;
}

private static void GetVideoConfigWindows(Dictionary<string, string> result)
public static string GetCpuName()
{
try
{
var searcher = new ManagementObjectSearcher("SELECT * FROM Win32_VideoController");
int deviceId = 0;
foreach (var managementObject in searcher.Get().OfType<ManagementObject>())
if (OperatingSystem.IsWindows())
{
foreach (var property in managementObject.Properties)
{
if (property.Value == null) continue;

result.Add($"GPU{deviceId}.{property.Name}", property.Value.ToString());
}
deviceId++;
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0");
return (key?.GetValue("ProcessorNameString") as string)?.Trim();
}
}
catch (Exception)
{
// ignored
}

return null;
}

public static Dictionary<string, string> GetMemoryInfo()
{
var result = new Dictionary<string, string>();
try
{
var gcInfo = GC.GetGCMemoryInfo();
result.Add("Memory.WorkingSet", FormatMegabytes(Environment.WorkingSet));
result.Add("Memory.ManagedHeap", FormatMegabytes(GC.GetTotalMemory(false)));
result.Add("Memory.HeapCommitted", FormatMegabytes(gcInfo.TotalCommittedBytes));
result.Add("Memory.SystemLoad", FormatMegabytes(gcInfo.MemoryLoadBytes));
result.Add("Memory.SystemTotal", FormatMegabytes(gcInfo.TotalAvailableMemoryBytes));
}
catch (Exception)
{
// ignored
}

return result;
}

private static string FormatMegabytes(long bytes) => $"{bytes / (1024 * 1024)} MB";

private static IEnumerable<List<(string Name, string Value)>> QueryVideoControllers()
{
if (!OperatingSystem.IsWindows())
yield break;

var searcher = new ManagementObjectSearcher(VideoControllerQuery);
foreach (var managementObject in searcher.Get().OfType<ManagementObject>())
{
var properties = new List<(string Name, string Value)>();

AddProperty(properties, managementObject, "Name");
AddProperty(properties, managementObject, "AdapterCompatibility");
AddProperty(properties, managementObject, "DriverVersion");

if (managementObject.GetPropertyValue("DriverDate") is string driverDate)
{
try
{
driverDate = ManagementDateTimeConverter.ToDateTime(driverDate).ToString("yyyy-MM-dd");
}
catch (Exception)
{
// Keep the raw DMTF string
}
properties.Add(("DriverDate", driverDate));
}

if (managementObject.GetPropertyValue("CurrentHorizontalResolution") is uint width
&& managementObject.GetPropertyValue("CurrentVerticalResolution") is uint height)
{
var mode = $"{width} x {height}";
if (managementObject.GetPropertyValue("CurrentBitsPerPixel") is uint bitsPerPixel)
mode += $", {bitsPerPixel} bpp";
if (managementObject.GetPropertyValue("CurrentRefreshRate") is uint refreshRate)
mode += $", {refreshRate} Hz";
properties.Add(("DisplayMode", mode));
}

yield return properties;
}
}

private static void AddProperty(List<(string Name, string Value)> properties, ManagementObject managementObject, string name)
{
if (managementObject.GetPropertyValue(name) is { } value)
properties.Add((name, value.ToString()));
}
}
139 changes: 139 additions & 0 deletions sources/editor/Stride.Editor.CrashReport/CrashReportAnonymizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.

using System;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace Stride.Editor.CrashReport;

/// <summary>
/// Strips the user name and profile path from crash report text before it leaves the machine.
/// It also makes paths easier to copy and paste between machines.
/// </summary>
public static class CrashReportAnonymizer
{
public static void Scrub(CrashReportData report)
{
for (var i = 0; i < report.Data.Count; i++)
{
report.Data[i] = (report.Data[i].Item1, Scrub(report.Data[i].Item2));
}
}

public static string Scrub(string text)
{
if (string.IsNullOrEmpty(text))
return text;

var userProfile = Environment.GetEnvironmentVariable("USERPROFILE");
if (!string.IsNullOrEmpty(userProfile))
text = Regex.Replace(text, Regex.Escape(userProfile), "%USERPROFILE%", RegexOptions.IgnoreCase);

var userName = Environment.GetEnvironmentVariable("USERNAME");
if (!string.IsNullOrEmpty(userName))
text = Regex.Replace(text, $@"\b{Regex.Escape(userName)}\b", "%USERNAME%", RegexOptions.IgnoreCase);

return text;
}

/// <summary>
/// Masks the profile path and user name inside a region of a binary buffer (e.g. minidump strings and
/// memory ranges), in both ASCII and UTF-16. Binary offsets cannot shift, so matches are overwritten
/// in place with same-length 'x' runs.
/// </summary>
public static void Scrub(byte[] buffer, int offset, int length)
{
Scrub(buffer, offset, length,
Environment.GetEnvironmentVariable("USERPROFILE"),
Environment.GetEnvironmentVariable("USERNAME"));
}

public static void Scrub(byte[] buffer, int offset, int length, string userProfile, string userName)
{
if (!string.IsNullOrEmpty(userProfile))
MaskAllEncodings(buffer, offset, length, userProfile, requireBoundary: false);
if (!string.IsNullOrEmpty(userName))
MaskAllEncodings(buffer, offset, length, userName, requireBoundary: true);
}

private static void MaskAllEncodings(byte[] buffer, int offset, int length, string text, bool requireBoundary)
{
if (text.All(char.IsAscii))
{
Mask(buffer, offset, length, text, 1, requireBoundary);
Mask(buffer, offset, length, text, 2, requireBoundary);
return;
}

// Non-ASCII: per-character case folding does not survive multi-byte encodings, so match the exact
// byte patterns of the realistic casings in each encoding instead
foreach (var variant in new[] { text, text.ToLowerInvariant(), text.ToUpperInvariant() }.Distinct())
{
MaskPattern(buffer, offset, length, Encoding.Unicode.GetBytes(variant), 2, requireBoundary);
MaskPattern(buffer, offset, length, Encoding.UTF8.GetBytes(variant), 1, requireBoundary);
if (variant.All(c => c <= 0xFF))
MaskPattern(buffer, offset, length, Encoding.Latin1.GetBytes(variant), 1, requireBoundary);
}
}

private static void MaskPattern(byte[] buffer, int offset, int length, byte[] pattern, int stride, bool requireBoundary)
{
var end = Math.Min(offset + length, buffer.Length);
for (var i = Math.Max(offset, 0); i + pattern.Length <= end; i++)
{
var match = true;
for (var j = 0; j < pattern.Length && match; j++)
match = buffer[i + j] == pattern[j];
if (!match)
continue;
if (requireBoundary && (IsWordChar(buffer, i - stride, stride) || IsWordChar(buffer, i + pattern.Length, stride)))
continue;

for (var j = 0; j < pattern.Length; j++)
buffer[i + j] = (byte)(stride == 2 && j % 2 == 1 ? 0 : 'x');
i += pattern.Length - 1;
}
}

/// <summary>Case-insensitive search and mask at the given character stride (1 = ASCII, 2 = UTF-16).</summary>
private static void Mask(byte[] buffer, int offset, int length, string text, int stride, bool requireBoundary)
{
var end = Math.Min(offset + length, buffer.Length);
for (var i = Math.Max(offset, 0); i + text.Length * stride <= end; i++)
{
var match = true;
for (var j = 0; j < text.Length && match; j++)
{
var c = (char)buffer[i + j * stride];
if (stride == 2 && buffer[i + j * stride + 1] != 0)
match = false;
else
match = char.ToLowerInvariant(c) == char.ToLowerInvariant(text[j]);
}
if (!match)
continue;
if (requireBoundary && (IsWordChar(buffer, i - stride, stride) || IsWordChar(buffer, i + text.Length * stride, stride)))
continue;

for (var j = 0; j < text.Length; j++)
{
buffer[i + j * stride] = (byte)'x';
if (stride == 2)
buffer[i + j * stride + 1] = 0;
}
i += text.Length * stride - 1;
}
}

private static bool IsWordChar(byte[] buffer, int index, int stride)
{
if (index < 0 || index + stride > buffer.Length)
return false;
if (stride == 2 && buffer[index + 1] != 0)
return false;
var c = (char)buffer[index];
return char.IsLetterOrDigit(c) || c == '_';
}
}
Loading
Loading