From 955c61801391985554da7c00a9d20d81f0142d53 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 20:11:32 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20make=20locale-independence=20real,=20not?= =?UTF-8?q?=20assumed=20=E2=80=94=20parsing,=20casing,=20comparison?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the retail-text fix. "Green under sv-SE" is not the same as "runs on any locale", so this establishes the latter by running the suite under cultures chosen to break different things, and fixing what they broke. ar-SA found a genuine defect the Swedish runner cannot see: the resolution parser read "1920x-1" through the ambient culture, and ar-SA's negative sign is not ASCII '-', so the parse failed and the height silently became 0 instead of -1. Both copies of that parser (App settings targets and the UI settings store) now parse invariantly. Audited every remaining culture-sensitive operation in src/ rather than fixing only what a test happened to catch: - Numeric Parse/TryParse with no IFormatProvider: 11 sites, all reading MACHINE-readable input — env vars (ACDREAM_LIGHT_DEBUG, ACDREAM_NET_DROP_*, streaming/quality knobs), CLI arguments, "1920x1080" settings keys, a chat command's price argument, and the launcher's bake thread count, which is handed straight to a child process command line. All pinned to InvariantCulture. - ToUpper()/ToLower() with no culture: none. The Turkish-I class was already clean, and tr-TR confirms it. - StartsWith/EndsWith/IndexOf(string) with no StringComparison: one — ChatInputParser's "@" prefix test, which is a culture-sensitive comparison for a single ASCII character. Now the ordinal char overload. Verified: 13,958 tests pass identically under the machine default, sv-SE, tr-TR, ar-SA, and de-DE. (The two launcher test assemblies are excluded from this run only because a running acdream-launcher.exe holds its own binary; the one launcher change here is the thread-count parse.) Dates remain on the current culture by intent, unchanged from the previous commit. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/Rendering/DisplayModeCatalog.cs | 7 +++++-- src/AcDream.App/Settings/RuntimeSettingsTargets.cs | 10 ++++++++-- src/AcDream.App/Streaming/StreamingDiagnostics.cs | 6 +++++- src/AcDream.App/UI/ClientCommandController.cs | 4 +++- src/AcDream.Cli/Program.cs | 5 +++-- src/AcDream.Core.Net/NetDiagnostics.cs | 6 +++++- src/AcDream.Core/Rendering/RenderingDiagnostics.cs | 9 ++++++++- .../ViewModels/FirstRunInstallerViewModel.cs | 12 ++++++++++-- src/AcDream.Runtime/Chat/ChatInputParser.cs | 2 +- .../Panels/Settings/SettingsStore.cs | 13 +++++++++++-- .../Settings/QualityPreset.cs | 6 +++++- 11 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/AcDream.App/Rendering/DisplayModeCatalog.cs b/src/AcDream.App/Rendering/DisplayModeCatalog.cs index 986b8fb0..63df6aa9 100644 --- a/src/AcDream.App/Rendering/DisplayModeCatalog.cs +++ b/src/AcDream.App/Rendering/DisplayModeCatalog.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System; using System.Collections.Generic; using System.Linq; @@ -134,8 +135,10 @@ internal static class DisplayModeCatalog mode = default; string[] parts = spec.Split('x', 2); if (parts.Length == 2 - && int.TryParse(parts[0], out int w) - && int.TryParse(parts[1], out int h) + && int.TryParse( + parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out int w) + && int.TryParse( + parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int h) && w > 0 && h > 0) { diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index 2f90d34d..9f59ca23 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Linq; using AcDream.App.Audio; using AcDream.App.Net; @@ -206,9 +207,14 @@ internal sealed class SilkRuntimeDisplayWindowTarget : IRuntimeDisplayWindowTarg if (string.IsNullOrWhiteSpace(spec)) return false; string[] parts = spec.Split('x', 2); + // Invariant: a resolution spec is a machine-readable settings key, not + // localized input. Parsing it with the ambient culture made "1920x-1" + // fail outright under ar-SA, whose negative sign is not ASCII "-". return parts.Length == 2 - && int.TryParse(parts[0], out width) - && int.TryParse(parts[1], out height) + && int.TryParse( + parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out width) + && int.TryParse( + parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out height) && width > 0 && height > 0; } diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs index 5102b484..25f09017 100644 --- a/src/AcDream.App/Streaming/StreamingDiagnostics.cs +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System; namespace AcDream.App.Streaming; @@ -73,5 +74,8 @@ internal static class StreamingDiagnostics /// at the parser rather than let the two halves of one commit disagree. /// internal static int? ParseRadius(string? raw) => - int.TryParse(raw, out int value) && value >= 1 ? value : null; + int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) + && value >= 1 + ? value + : null; } diff --git a/src/AcDream.App/UI/ClientCommandController.cs b/src/AcDream.App/UI/ClientCommandController.cs index d58ee029..a4cf2145 100644 --- a/src/AcDream.App/UI/ClientCommandController.cs +++ b/src/AcDream.App/UI/ClientCommandController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AcDream.Core.Physics; using AcDream.Core.Ui; using AcDream.Core.Social; @@ -724,7 +725,8 @@ public sealed class ClientCommandController uint maximumPrice = 0u; foreach (string part in parts) { - if (uint.TryParse(part, out uint price)) + if (uint.TryParse( + part, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint price)) { if (price == 0) { diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index f91fc215..191acbb4 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Diagnostics; using AcDream.Cli; using DatReaderWriter; @@ -96,7 +97,7 @@ if (args.Length >= 1 && args[0] == "probe") { // probe if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe "); return 2; } - return VitalsMockup.Probe(args[1], int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5])); + return VitalsMockup.Probe(args[1], int.Parse(args[2], CultureInfo.InvariantCulture), int.Parse(args[3], CultureInfo.InvariantCulture), int.Parse(args[4], CultureInfo.InvariantCulture), int.Parse(args[5], CultureInfo.InvariantCulture)); } if (args.Length >= 1 && args[0] == "crop") @@ -104,7 +105,7 @@ if (args.Length >= 1 && args[0] == "crop") // crop if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop "); return 2; } return VitalsMockup.Crop(args[1], - int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), int.Parse(args[6]), args[7]); + int.Parse(args[2], CultureInfo.InvariantCulture), int.Parse(args[3], CultureInfo.InvariantCulture), int.Parse(args[4], CultureInfo.InvariantCulture), int.Parse(args[5], CultureInfo.InvariantCulture), int.Parse(args[6], CultureInfo.InvariantCulture), args[7]); } if (args.Length >= 1 && args[0] == "dump-edges") diff --git a/src/AcDream.Core.Net/NetDiagnostics.cs b/src/AcDream.Core.Net/NetDiagnostics.cs index dfd1e74f..46bb571e 100644 --- a/src/AcDream.Core.Net/NetDiagnostics.cs +++ b/src/AcDream.Core.Net/NetDiagnostics.cs @@ -1,3 +1,4 @@ +using System.Globalization; namespace AcDream.Core.Net; /// @@ -74,6 +75,8 @@ public static class NetDiagnostics public static int NetDropSeed { get; set; } = int.TryParse( Environment.GetEnvironmentVariable("ACDREAM_NET_DROP_SEED"), + NumberStyles.Integer, + CultureInfo.InvariantCulture, out int seed) ? seed : 1; @@ -87,7 +90,8 @@ public static class NetDiagnostics Environment.GetEnvironmentVariable("ACDREAM_NET_DROP_DIR")); internal static int ParseDropPercent(string? value) => - int.TryParse(value, out int percent) && percent is >= 0 and <= 100 + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int percent) + && percent is >= 0 and <= 100 ? percent : 0; diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index ae40f920..4340318b 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System; using System.Collections.Generic; using System.Text; @@ -355,7 +356,13 @@ public static class RenderingDiagnostics /// future DebugPanel mirror. /// public static int LightDebugMode { get; set; } = - int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), out var ldm) ? ldm : 0; + int.TryParse( + Environment.GetEnvironmentVariable("ACDREAM_LIGHT_DEBUG"), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int ldm) + ? ldm + : 0; /// /// #176 stripe-hunt isolation (ACDREAM_CLIP_DEBUG=1) — throwaway diff --git a/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs b/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs index 6d4ca733..4dbfbb35 100644 --- a/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs +++ b/src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.ComponentModel; using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; @@ -90,7 +91,9 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable } public bool IsThreadCountValid => - int.TryParse(ThreadsText, out int threads) && threads > 0; + int.TryParse( + ThreadsText, NumberStyles.Integer, CultureInfo.InvariantCulture, out int threads) + && threads > 0; public string ThreadCountValidation => IsThreadCountValid ? "Worker count is valid." @@ -324,7 +327,12 @@ public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable private async Task StartAsync() { - if (!int.TryParse(ThreadsText, out int threads) || threads <= 0) + if (!int.TryParse( + ThreadsText, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int threads) + || threads <= 0) { return; } diff --git a/src/AcDream.Runtime/Chat/ChatInputParser.cs b/src/AcDream.Runtime/Chat/ChatInputParser.cs index 24546e74..6c05892a 100644 --- a/src/AcDream.Runtime/Chat/ChatInputParser.cs +++ b/src/AcDream.Runtime/Chat/ChatInputParser.cs @@ -213,7 +213,7 @@ public static class ChatInputParser // (e.g. @acehelp, @tele, @die), pass the literal @-prefixed // text through to the default channel so ACE's CommandManager // server-side handler intercepts it. - if (trimmed.StartsWith("@")) + if (trimmed.StartsWith('@')) { string substituted = "/" + trimmed.Substring(1); string verb = ExtractVerb(substituted); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs index 81230fa9..acaa247d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/SettingsStore.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System; using System.Collections.Generic; using System.IO; @@ -632,8 +633,16 @@ public sealed class SettingsStore height = 0; int separator = key.IndexOf('x', StringComparison.OrdinalIgnoreCase); return separator > 0 - && int.TryParse(key.AsSpan(0, separator), out width) - && int.TryParse(key.AsSpan(separator + 1), out height) + && int.TryParse( + key.AsSpan(0, separator), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out width) + && int.TryParse( + key.AsSpan(separator + 1), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out height) && width > 0 && height > 0; } diff --git a/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs b/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs index e215d66d..2d1a0164 100644 --- a/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs +++ b/src/AcDream.UI.Abstractions/Settings/QualityPreset.cs @@ -1,3 +1,4 @@ +using System.Globalization; namespace AcDream.UI.Abstractions.Settings; /// @@ -62,6 +63,9 @@ public readonly record struct QualitySettings( private static int TryParseEnvInt(string name, int defaultValue) { var s = System.Environment.GetEnvironmentVariable(name); - return s is not null && int.TryParse(s, out var v) ? v : defaultValue; + return s is not null + && int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v) + ? v + : defaultValue; } }