From 6a15dd063c9b9a61b98a06fc937e71ee8a355a40 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 19 Aug 2026 19:56:09 +0200 Subject: [PATCH] fix: retail text and golden-string tests must not follow the machine's locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 170's Windows gate went red on 37 tests across four assemblies while the same commit passed 14,370/0 locally. The failures were all one family: Expected: "You have 1 500p" <- built with the machine's culture Actual: "You have 1,500p" <- production, correctly invariant The runner is Swedish; this dev box is not. These tests had been passing on CI only because that machine's registry locale had been pinned by hand — machine state, which came undone (almost certainly the reboot after today's hang). Re-pinning it would be a workaround on one machine for a defect in the repo, so this fixes the repo instead. Two genuinely different bugs were hiding in that one symptom. 1. TESTS that build an expected string with the ambient culture and compare it to invariant production output, and test-side recording sinks whose traces are compared against literal golden strings. Those only ever passed on a machine that happens to format like the invariant culture. Pinned to InvariantCulture: the vendor purse/cost expectations, and the motion-funnel, animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack trace sinks. 2. PRODUCTION that formats player-visible retail text with the ambient culture. This one matters beyond CI: retail is a US client, so it shows "2.50", "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this alpha is literally Swedish. Converted 76 sites to InvariantCulture across the item/creature appraisal formatters, the character stat panel's buff and vitae parentheticals, the appraisal and link-status controllers, the chat /framerate and /location output, the camera sensitivity toast, the time-override toast, the F3 dump, the sky diagnostics, and the world-frame invariant-failure message. DATES are deliberately left on the current culture (CharacterController's birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on them is a UX decision, not a retail-fidelity one. Apparatus, so the next occurrence is reproducible instead of mysterious: tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to every test assembly, linked in through a new tests/Directory.Build.props. Unset — what CI and everyone runs — it changes nothing. ACDREAM_TEST_CULTURE=sv-SE dotnet test ... reproduced all 37 CI failures on this machine plus 6 more the runner's own locale does not surface (the Unicode-minus family), and drove the fix. Verified both ways on the full solution under the release-gate filter: default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE 14,370 passed / 0 failed. Co-Authored-By: Claude Opus 5 --- .../RuntimeDiagnosticCommandController.cs | 9 +- .../Input/CameraPointerInputController.cs | 5 +- .../UI/Layout/AppraisalUiController.cs | 4 +- .../UI/Layout/CharacterController.cs | 4 +- .../UI/Layout/CharacterStatController.cs | 13 +- .../UI/Layout/CreatureAppraisalRows.cs | 10 +- .../UI/Layout/ItemAppraisalTextFormatter.cs | 130 +++++++++--------- .../UI/Layout/LinkStatusUiController.cs | 4 +- src/AcDream.App/World/LiveWorldOriginState.cs | 11 +- .../World/RuntimeWorldEnvironmentState.cs | 17 ++- .../Panels/Chat/ChatVM.cs | 9 +- .../FramebufferResizeControllerTests.cs | 5 +- .../Rendering/RuntimeResourceSlotTests.cs | 11 +- .../UI/Layout/VendorUiControllerTests.cs | 47 +++++-- .../AnimationSequencerCutoverTraceTests.cs | 15 +- .../Physics/MotionInterpreterFunnelTests.cs | 8 +- .../RuntimeSimulationFixtureHostTests.cs | 6 +- .../Support/NoWindowGameRuntimeHost.cs | 6 +- tests/Directory.Build.props | 15 ++ tests/TestCultureInitializer.cs | 54 ++++++++ 20 files changed, 271 insertions(+), 112 deletions(-) create mode 100644 tests/Directory.Build.props create mode 100644 tests/TestCultureInitializer.cs diff --git a/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs index 4307e166..00bbf26d 100644 --- a/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs +++ b/src/AcDream.App/Diagnostics/RuntimeDiagnosticCommandController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; @@ -233,13 +234,17 @@ internal sealed class NearbyWorldDiagnosticDumper int landblockX = centerX + (int)MathF.Floor(position.X / 192f); int landblockY = centerY + (int)MathF.Floor(position.Y / 192f); - _log( + // Invariant: a diagnostic dump is pasted into issues and compared + // against other machines' dumps, so its numbers must not change shape + // with the reporter's locale. + _log(string.Create( + CultureInfo.InvariantCulture, $"=== F3 DEBUG DUMP ===\n" + $" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" + $" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " + $"local=({position.X - (landblockX - centerX) * 192f:F2}," + $"{position.Y - (landblockY - centerY) * 192f:F2})\n" + - $" total shadow objects: {_source.TotalShadowObjects}"); + $" total shadow objects: {_source.TotalShadowObjects}")); List visibleNearby = _source.WorldEntities .Where(entity => HorizontalDistanceSquared(entity.Position, position) diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index 8da0a466..aa2672da 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using AcDream.App.Rendering; using AcDream.Core.Rendering; @@ -343,7 +344,9 @@ internal sealed class CameraPointerInputController else _orbitSensitivity = next; - return $"{mode} sens {next:F3}x"; + // Invariant: player-visible toast; a decimal-comma culture would + // otherwise render "Orbit sens 1,200x". + return string.Create(CultureInfo.InvariantCulture, $"{mode} sens {next:F3}x"); } /// diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index a3052bd7..b5b9a109 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -682,7 +682,7 @@ public sealed class AppraisalUiController : IRetainedPanelController SetText( CreatureLevelValueId, level > 0 - ? level.ToString(CultureInfo.CurrentCulture) + ? level.ToString(CultureInfo.InvariantCulture) : "???"); if (character) @@ -933,7 +933,7 @@ public sealed class AppraisalUiController : IRetainedPanelController if (string.IsNullOrWhiteSpace(name)) name = _itemNames.ResolveAppropriateName(obj); return obj.StackSize > 1 - ? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}" + ? $"{obj.StackSize.ToString(CultureInfo.InvariantCulture)} {name}" : name; } diff --git a/src/AcDream.App/UI/Layout/CharacterController.cs b/src/AcDream.App/UI/Layout/CharacterController.cs index 6c60dc15..eb5faa75 100644 --- a/src/AcDream.App/UI/Layout/CharacterController.cs +++ b/src/AcDream.App/UI/Layout/CharacterController.cs @@ -293,7 +293,7 @@ public static class CharacterController } catch (ArgumentOutOfRangeException) { - return unixSeconds.ToString(CultureInfo.CurrentCulture); + return unixSeconds.ToString(CultureInfo.InvariantCulture); } } @@ -308,7 +308,7 @@ public static class CharacterController : NumericValue(values[Math.Min(i, values.Length - 1)]); body.Append(ResolvePlural(tokens[i], pluralValue)); if (i < values.Length) - body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture)); + body.Append(Convert.ToString(values[i], CultureInfo.InvariantCulture)); } } diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index bbda95dc..3f0f232e 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Numerics; using AcDream.App.UI; @@ -1369,11 +1370,15 @@ public static class CharacterStatController /// prefix on an increase (the natural %d has no leading plus), the /// natural minus sign on a decrease (no prefix string set in that /// branch), nothing when the delta is zero. + /// Invariant, not the machine's culture: retail's minus is the + /// ASCII '-', and several European cultures (sv-SE among them) render a + /// negative integer with U+2212 MINUS SIGN, so a Swedish player would + /// otherwise read "(−20)" where retail shows "(-20)". private static string FormatBuffDelta(int delta) => delta switch { 0 => string.Empty, - > 0 => $" (+{delta})", - _ => $" ({delta})", + > 0 => string.Create(CultureInfo.InvariantCulture, $" (+{delta})"), + _ => string.Create(CultureInfo.InvariantCulture, $" ({delta})"), }; /// Retail " (%d)" vitae-specific parenthetical @@ -1381,7 +1386,9 @@ public static class CharacterStatController /// while vitae is an active penalty (modifier < 0) — vitae never /// grants a bonus, so no "+" case exists. private static string FormatVitaeDelta(int vitaeModifier) => - vitaeModifier < 0 ? $" ({vitaeModifier})" : string.Empty; + vitaeModifier < 0 + ? string.Create(CultureInfo.InvariantCulture, $" ({vitaeModifier})") + : string.Empty; /// Build the retail footer-title text for the current selection: /// "{Name}: {value}" with the vitae + buff-delta parentheticals appended diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index a9eec101..c150ee3d 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -152,7 +152,7 @@ public static class CreatureAppraisalRows properties.Ints.TryGetValue(id, out int value) ? value : 0; private static string Number(int value) => - value.ToString(CultureInfo.CurrentCulture); + value.ToString(CultureInfo.InvariantCulture); private static CreatureAppraisalRow Primary( string label, @@ -163,7 +163,7 @@ public static class CreatureAppraisalRows => new( label, value is > 0 - ? value.Value.ToString(CultureInfo.CurrentCulture) + ? value.Value.ToString(CultureInfo.InvariantCulture) : Unknown, Style(enchantmentBit, profile, success)); @@ -183,12 +183,12 @@ public static class CreatureAppraisalRows if (success) { value = showPercent - ? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)" - : $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}"; + ? $"{current.Value.ToString(CultureInfo.InvariantCulture)}/{maximum.Value.ToString(CultureInfo.InvariantCulture)} ({percent.ToString(CultureInfo.InvariantCulture)} %)" + : $"{current.Value.ToString(CultureInfo.InvariantCulture)}/{maximum.Value.ToString(CultureInfo.InvariantCulture)}"; } else if (showPercent) { - value = $"{percent.ToString(CultureInfo.CurrentCulture)} %"; + value = $"{percent.ToString(CultureInfo.InvariantCulture)} %"; } } diff --git a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs index 4ebaaaf4..10184cb1 100644 --- a/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs +++ b/src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs @@ -101,7 +101,7 @@ public static class ItemAppraisalTextFormatter if (properties.Ints.TryGetValue(171u, out int tinkers)) { report.Line( - $"This item has been tinkered {tinkers.ToString(CultureInfo.CurrentCulture)} " + $"This item has been tinkered {tinkers.ToString(CultureInfo.InvariantCulture)} " + (tinkers == 1 ? "time." : "times.")); } @@ -124,15 +124,15 @@ public static class ItemAppraisalTextFormatter 10); report.Line( $"Workmanship: {WorkmanshipAdjective(workmanshipBand)} " - + $"({average.ToString("0.00", CultureInfo.CurrentCulture)})"); + + $"({average.ToString("0.00", CultureInfo.InvariantCulture)})"); report.Paragraph( - $"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items."); + $"Salvaged from {salvagedItems.ToString(CultureInfo.InvariantCulture)} items."); } else { report.Line( $"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} " - + $"({workmanship.ToString(CultureInfo.CurrentCulture)})"); + + $"({workmanship.ToString(CultureInfo.InvariantCulture)})"); } } @@ -172,7 +172,7 @@ public static class ItemAppraisalTextFormatter .Where(pair => properties.Ints.TryGetValue(pair.Property, out int value) && value > 0) .Select(pair => - $"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}") + $"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.InvariantCulture)}") .ToArray(); bool ratingsShown = ratings.Length != 0; if (ratingsShown) @@ -180,7 +180,7 @@ public static class ItemAppraisalTextFormatter if (properties.GetInt(379u) is int vitality && vitality > 0) { report.Line( - $"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality."); + $"This item adds {vitality.ToString(CultureInfo.InvariantCulture)} Vitality."); ratingsShown = true; } @@ -218,7 +218,7 @@ public static class ItemAppraisalTextFormatter { if (properties.Ints.TryGetValue(28u, out int shieldLevel)) report.Line( - $"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}", + $"Base Shield Level: {shieldLevel.ToString(CultureInfo.InvariantCulture)}", EnchantmentStyle( appraisal.ArmorEnchantments, 0x0001u)); @@ -248,8 +248,8 @@ public static class ItemAppraisalTextFormatter (1d - weapon.DamageVariance) * weapon.Damage; damage = weapon.Damage - minimumDamage > 0.0002d ? $"{FormatRetailDamage(minimumDamage)}" - + $" - {weapon.Damage.ToString(CultureInfo.CurrentCulture)}" - : weapon.Damage.ToString(CultureInfo.CurrentCulture); + + $" - {weapon.Damage.ToString(CultureInfo.InvariantCulture)}" + : weapon.Damage.ToString(CultureInfo.InvariantCulture); if (!launcher) { damage += TryDamageTypeName(weapon.DamageType, out string? type) @@ -272,7 +272,7 @@ public static class ItemAppraisalTextFormatter if (elementalBonus > 0) report.Line( $"Elemental Damage Bonus: " - + $"{elementalBonus.ToString(CultureInfo.CurrentCulture)}, " + + $"{elementalBonus.ToString(CultureInfo.InvariantCulture)}, " + $"{DamageTypeName(weapon.DamageType)}."); if (launcher) @@ -296,7 +296,7 @@ public static class ItemAppraisalTextFormatter weapon.WeaponTime == uint.MaxValue ? "Speed: Unknown" : $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} " - + $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})", + + $"({weapon.WeaponTime.ToString(CultureInfo.InvariantCulture)})", EnchantmentStyle( appraisal.WeaponEnchantments, 0x0004u)); @@ -321,7 +321,7 @@ public static class ItemAppraisalTextFormatter ? (int)Math.Ceiling(rawRange) : (int)rawRange - (int)rawRange % 5; report.Line( - $"Range: {range.ToString(CultureInfo.CurrentCulture)} yds." + $"Range: {range.ToString(CultureInfo.InvariantCulture)} yds." + (weapon.MaxVelocityEstimated != 0u ? " (based on STRENGTH 100)" : string.Empty)); @@ -362,7 +362,7 @@ public static class ItemAppraisalTextFormatter // Retail's first armor string begins with a literal '\n' before // AddItemInfo adds its ordinary separator. report.Paragraph( - $"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}", + $"Armor Level: {armorLevel.ToString(CultureInfo.InvariantCulture)}", EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u)); ShowProtection( report, "Slashing", armorLevel, armor.SlashingProtection, @@ -443,7 +443,7 @@ public static class ItemAppraisalTextFormatter double effective = armorLevel * modifier; report.Line( $"{damageType}: {quality} " - + $"({effective.ToString("0", CultureInfo.CurrentCulture)})", + + $"({effective.ToString("0", CultureInfo.InvariantCulture)})", style); } @@ -503,7 +503,7 @@ public static class ItemAppraisalTextFormatter ordinary.Select(raw => { return resolveSpell(raw)?.Name - ?? $"Spell {raw.ToString(CultureInfo.CurrentCulture)}"; + ?? $"Spell {raw.ToString(CultureInfo.InvariantCulture)}"; })); report.Paragraph($"Spells: {names}"); } @@ -523,7 +523,7 @@ public static class ItemAppraisalTextFormatter report.BlankLine(); report.Line( $"You can only carry " - + $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} " + + $"{carryLimit.ToString("N0", CultureInfo.InvariantCulture)} " + "of these items."); } @@ -539,7 +539,7 @@ public static class ItemAppraisalTextFormatter if (cleave > 1) { report.Line( - $"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc."); + $"Cleave: {cleave.ToString(CultureInfo.InvariantCulture)} enemies in front arc."); report.BlankLine(); } @@ -663,19 +663,19 @@ public static class ItemAppraisalTextFormatter { report.Paragraph(minimum == maximum ? $"Restricted to characters of Level " - + $"{minimum.ToString(CultureInfo.CurrentCulture)}." + + $"{minimum.ToString(CultureInfo.InvariantCulture)}." : $"Restricted to characters of Levels " - + $"{minimum.ToString(CultureInfo.CurrentCulture)} to " - + $"{maximum.ToString(CultureInfo.CurrentCulture)}."); + + $"{minimum.ToString(CultureInfo.InvariantCulture)} to " + + $"{maximum.ToString(CultureInfo.InvariantCulture)}."); } else if (minimum > 0) report.Paragraph( $"Restricted to characters of Level " - + $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater."); + + $"{minimum.ToString(CultureInfo.InvariantCulture)} or greater."); else if (maximum > 0) report.Paragraph( $"Restricted to characters of Level " - + $"{maximum.ToString(CultureInfo.CurrentCulture)} or below."); + + $"{maximum.ToString(CultureInfo.InvariantCulture)} or below."); string destination = properties.GetString(38u); if (!string.IsNullOrWhiteSpace(destination)) @@ -741,7 +741,7 @@ public static class ItemAppraisalTextFormatter { report.Line( $"Wield requires {quality} " - + $"{difficulty.ToString(CultureInfo.CurrentCulture)}"); + + $"{difficulty.ToString(CultureInfo.InvariantCulture)}"); } } } @@ -790,11 +790,11 @@ public static class ItemAppraisalTextFormatter if (level > 0) report.Line( - $"Use requires level {level.ToString(CultureInfo.CurrentCulture)}."); + $"Use requires level {level.ToString(CultureInfo.InvariantCulture)}."); if (skill > 0 && difficulty > 0) report.Line( $"Use requires {UsageSkillName(skill)} of at least " - + $"{difficulty.ToString(CultureInfo.CurrentCulture)}."); + + $"{difficulty.ToString(CultureInfo.InvariantCulture)}."); if (specializedSkill > 0) report.Line( $"Use requires specialized {UsageSkillName(specializedSkill)}."); @@ -829,11 +829,11 @@ public static class ItemAppraisalTextFormatter style); report.Line( - $"Item Level: {displayedLevel.ToString(CultureInfo.CurrentCulture)} / " - + $"{maximum.ToString(CultureInfo.CurrentCulture)}"); + $"Item Level: {displayedLevel.ToString(CultureInfo.InvariantCulture)} / " + + $"{maximum.ToString(CultureInfo.InvariantCulture)}"); report.Line( - $"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / " - + $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}"); + $"Item XP: {experience.ToString("N0", CultureInfo.InvariantCulture)} / " + + $"{nextExperience.ToString("N0", CultureInfo.InvariantCulture)}"); report.BlankLine(); if (properties.GetInt(352u) == 2) @@ -871,19 +871,19 @@ public static class ItemAppraisalTextFormatter int skill = properties.GetInt(176u); if (skillLevel > 0 && skill > 0) requirements.Add( - $"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}"); + $"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.InvariantCulture)}"); int attributeLevel = properties.GetInt(258u); int attribute = properties.GetInt(257u); if (attributeLevel > 0 && attribute > 0) requirements.Add( $"{PrimaryAttributeName(attribute)}: " - + $"{attributeLevel.ToString(CultureInfo.CurrentCulture)}"); + + $"{attributeLevel.ToString(CultureInfo.InvariantCulture)}"); int secondaryLevel = properties.GetInt(260u); int secondary = properties.GetInt(259u); if (secondaryLevel > 0 && secondary > 0) requirements.Add( $"{SecondaryAttributeName(secondary)}: " - + $"{secondaryLevel.ToString(CultureInfo.CurrentCulture)}"); + + $"{secondaryLevel.ToString(CultureInfo.InvariantCulture)}"); if (requirements.Count != 0) report.Line($"Activation requires {string.Join(", ", requirements)}"); @@ -903,7 +903,7 @@ public static class ItemAppraisalTextFormatter { if (value > 0) requirements.Add( - $"{name}: {value.ToString(CultureInfo.CurrentCulture)}"); + $"{name}: {value.ToString(CultureInfo.InvariantCulture)}"); } /// Appraisal_ShowCasterData @ 0x004B1B10. @@ -955,13 +955,13 @@ public static class ItemAppraisalTextFormatter string? boostText = properties.GetInt(89u) switch { 2 => $"{(boost >= 0 ? "Restores" : "Depletes")} " - + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " + + $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} " + "Health when used.", 4 => $"{(boost >= 0 ? "Restores" : "Depletes")} " - + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " + + $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} " + "Stamina when consumed.", 6 => $"{(boost >= 0 ? "Restores" : "Depletes")} " - + $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} " + + $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} " + "Mana when used.", _ => null, }; @@ -976,11 +976,11 @@ public static class ItemAppraisalTextFormatter if (properties.Ints.TryGetValue(90u, out int healingBonus)) report.Paragraph( - $"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.CurrentCulture)}"); + $"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.InvariantCulture)}"); if (properties.Floats.TryGetValue(100u, out double healKitModifier)) report.Line( $"Restoration Bonus: " - + $"{(healKitModifier * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + + $"{(healKitModifier * 100d).ToString("0", CultureInfo.InvariantCulture)}%"); } /// @@ -1002,21 +1002,21 @@ public static class ItemAppraisalTextFormatter { if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0) report.Paragraph( - $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} " - + $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.InvariantCulture)} " + + $"items and {obj.ContainersCapacity.ToString(CultureInfo.InvariantCulture)} containers."); else if (obj.ItemsCapacity > 0) report.Paragraph( - $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items."); + $"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.InvariantCulture)} items."); else if (obj.ContainersCapacity > 0) report.Paragraph( - $"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers."); + $"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.InvariantCulture)} containers."); int pages = properties.GetInt(175u); int pagesUsed = properties.GetInt(174u); if (pages > 0) report.Paragraph( - $"{pagesUsed.ToString(CultureInfo.CurrentCulture)} of " - + $"{pages.ToString(CultureInfo.CurrentCulture)} pages full."); + $"{pagesUsed.ToString(CultureInfo.InvariantCulture)} of " + + $"{pages.ToString(CultureInfo.InvariantCulture)} pages full."); } // Appraisal_ShowLockAppraiseInfo @ 0x004B2790 does not present lock @@ -1034,7 +1034,7 @@ public static class ItemAppraisalTextFormatter { report.Paragraph( $"Bonus to Lockpick Skill: " - + $"{resistance.ToString("+0;-0;0", CultureInfo.CurrentCulture)}"); + + $"{resistance.ToString("+0;-0;0", CultureInfo.InvariantCulture)}"); } return; } @@ -1057,7 +1057,7 @@ public static class ItemAppraisalTextFormatter { report.Paragraph( $"The lock looks {difficulty} to pick " - + $"(Resistance {resistance.ToString(CultureInfo.CurrentCulture)})."); + + $"(Resistance {resistance.ToString(CultureInfo.InvariantCulture)})."); } } @@ -1071,14 +1071,14 @@ public static class ItemAppraisalTextFormatter PropertyBundle properties = appraisal.Properties; if (properties.Ints.TryGetValue(107u, out int storedMana)) report.Line( - $"Stored Mana: {storedMana.ToString(CultureInfo.CurrentCulture)}"); + $"Stored Mana: {storedMana.ToString(CultureInfo.InvariantCulture)}"); if (properties.Floats.TryGetValue(87u, out double efficiency)) report.Line( - $"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + $"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.InvariantCulture)}%"); if (properties.Floats.TryGetValue(137u, out double destruction)) report.Line( $"Chance of Destruction: " - + $"{(destruction * 100d).ToString("0", CultureInfo.CurrentCulture)}%"); + + $"{(destruction * 100d).ToString("0", CultureInfo.InvariantCulture)}%"); } /// Appraisal_ShowRemainingUses @ 0x004B3A20. @@ -1090,7 +1090,7 @@ public static class ItemAppraisalTextFormatter PropertyBundle properties = appraisal.Properties; if (properties.Ints.TryGetValue(193u, out int keys)) report.Line( - $"Contains {keys.ToString(CultureInfo.CurrentCulture)} " + $"Contains {keys.ToString(CultureInfo.InvariantCulture)} " + (keys == 1 ? "key." : "keys.")); if (properties.GetBool(63u)) @@ -1102,7 +1102,7 @@ public static class ItemAppraisalTextFormatter if (properties.Ints.TryGetValue(92u, out int uses)) { report.Line( - $"Number of uses remaining: {uses.ToString(CultureInfo.CurrentCulture)}"); + $"Number of uses remaining: {uses.ToString(CultureInfo.InvariantCulture)}"); return; } @@ -1144,7 +1144,7 @@ public static class ItemAppraisalTextFormatter } int rare = properties.GetInt(17u); if (rare > 0) - report.Paragraph($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}"); + report.Paragraph($"Rare #{rare.ToString(CultureInfo.InvariantCulture)}"); } /// Appraisal_ShowMagicInfo @ 0x004B2E10. @@ -1178,25 +1178,25 @@ public static class ItemAppraisalTextFormatter { if (properties.Ints.TryGetValue(106u, out int spellcraft)) report.Line( - $"Spellcraft: {spellcraft.ToString(CultureInfo.CurrentCulture)}."); + $"Spellcraft: {spellcraft.ToString(CultureInfo.InvariantCulture)}."); if (properties.Ints.TryGetValue(107u, out int currentMana) && properties.Ints.TryGetValue(108u, out int maximumMana)) report.Line( - $"Mana: {currentMana.ToString(CultureInfo.CurrentCulture)} / " - + $"{maximumMana.ToString(CultureInfo.CurrentCulture)}."); + $"Mana: {currentMana.ToString(CultureInfo.InvariantCulture)} / " + + $"{maximumMana.ToString(CultureInfo.InvariantCulture)}."); if (properties.Floats.TryGetValue(5u, out double manaRate) && Math.Abs(manaRate) > 0.000001d) { int seconds = (int)Math.Round(1d / manaRate); report.Line( - $"Mana Cost: 1 point per {seconds.ToString(CultureInfo.CurrentCulture)} " + $"Mana Cost: 1 point per {seconds.ToString(CultureInfo.InvariantCulture)} " + (seconds == 1 ? "second." : "seconds.")); } else if (properties.Ints.TryGetValue(117u, out int manaCost)) { string manaCostText = - $"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}."; + $"Mana Cost: {manaCost.ToString(CultureInfo.InvariantCulture)}."; if (manaCost > 0) { manaCostText += @@ -1227,7 +1227,7 @@ public static class ItemAppraisalTextFormatter { string name = !string.IsNullOrWhiteSpace(metadata?.Name) ? metadata.Name - : $"Spell {id.ToString(CultureInfo.CurrentCulture)}"; + : $"Spell {id.ToString(CultureInfo.InvariantCulture)}"; text.Append("\n~ ").Append(name).Append(": "); if (!string.IsNullOrWhiteSpace(metadata?.Description)) text.Append(metadata.Description); @@ -1293,7 +1293,7 @@ public static class ItemAppraisalTextFormatter if (!string.IsNullOrWhiteSpace(gemName)) { suffix = - $", set with {gemCount.ToString(CultureInfo.CurrentCulture)} " + $", set with {gemCount.ToString(CultureInfo.InvariantCulture)} " + gemName; } } @@ -1324,7 +1324,7 @@ public static class ItemAppraisalTextFormatter private static string FormatRetailDamage(double damage) => damage.ToString( damage > 10d ? "G4" : "G3", - CultureInfo.CurrentCulture); + CultureInfo.InvariantCulture); private static bool TryDamageTypeName(uint type, out string? name) { @@ -1652,7 +1652,7 @@ public static class ItemAppraisalTextFormatter => FormatSignedPercent(modifier - 1d); private static string FormatSignedPercent(double modifier) - => modifier.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture); + => modifier.ToString("+0%;-0%;0%", CultureInfo.InvariantCulture); /// /// Port of AppraisalSystem::LockpickSuccessPercentToString @@ -1698,7 +1698,7 @@ public static class ItemAppraisalTextFormatter private static string DamageTypeName(uint type) => TryDamageTypeName(type, out string? name) ? name! - : $"type {type.ToString(CultureInfo.CurrentCulture)}"; + : $"type {type.ToString(CultureInfo.InvariantCulture)}"; private static string WeaponSubtype(int type) => type switch { @@ -1774,7 +1774,7 @@ public static class ItemAppraisalTextFormatter 52 => "Dirty Fighting", 53 => "Challenge", 54 => "Summoning", - _ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}", + _ => $"Skill {skill.ToString(CultureInfo.InvariantCulture)}", }; private static string UsageSkillName(int skill) @@ -1793,7 +1793,7 @@ public static class ItemAppraisalTextFormatter 4 => "Coordination", 5 => "Focus", 6 => "Self", - _ => $"Attribute {attribute.ToString(CultureInfo.CurrentCulture)}", + _ => $"Attribute {attribute.ToString(CultureInfo.InvariantCulture)}", }; private static string SecondaryAttributeName(int attribute) => attribute switch @@ -1804,7 +1804,7 @@ public static class ItemAppraisalTextFormatter 4 => "Stamina", 5 => "Max Mana", 6 => "Mana", - _ => $"Vital {attribute.ToString(CultureInfo.CurrentCulture)}", + _ => $"Vital {attribute.ToString(CultureInfo.InvariantCulture)}", }; private sealed class RetailReportBuilder diff --git a/src/AcDream.App/UI/Layout/LinkStatusUiController.cs b/src/AcDream.App/UI/Layout/LinkStatusUiController.cs index 4d461fc1..8bf42ea2 100644 --- a/src/AcDream.App/UI/Layout/LinkStatusUiController.cs +++ b/src/AcDream.App/UI/Layout/LinkStatusUiController.cs @@ -100,13 +100,13 @@ public sealed class LinkStatusUiController : IRetainedPanelController string ping = value.RoundTripSeconds is double seconds && double.IsFinite(seconds) && seconds >= 0d - ? (seconds * 1000d).ToString("F0", CultureInfo.CurrentCulture) + ? (seconds * 1000d).ToString("F0", CultureInfo.InvariantCulture) : "????"; string body = _strings.Description + _strings.Legend + _strings.DisconnectWarning + _strings.PacketLossPrefix - + value.PacketLossPercentage.ToString("F2", CultureInfo.CurrentCulture) + + value.PacketLossPercentage.ToString("F2", CultureInfo.InvariantCulture) + _strings.PingPrefix + ping; _lines = IndicatorDetailText.Shape(_mainText, body); diff --git a/src/AcDream.App/World/LiveWorldOriginState.cs b/src/AcDream.App/World/LiveWorldOriginState.cs index 7a36b2fa..1e8a4f35 100644 --- a/src/AcDream.App/World/LiveWorldOriginState.cs +++ b/src/AcDream.App/World/LiveWorldOriginState.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; namespace AcDream.App.World; @@ -126,13 +127,17 @@ internal sealed class LiveWorldOriginState if (transitInFlight) return false; - throw new InvalidOperationException( - "World-frame owners disagree: Runtime centre " + // Invariant: an invariant-failure message is read from logs and crash + // reports by whoever is diagnosing it, so its numbers must not depend + // on the reporter's locale (sv-SE renders -192 with U+2212). + throw new InvalidOperationException(string.Create( + CultureInfo.InvariantCulture, + $"World-frame owners disagree: Runtime centre " + $"({runtimeCenterX},{runtimeCenterY}) vs streamed origin " + $"({CenterX},{CenterY}) while projecting landblock " + $"0x{projectingLandblockId:X8}. That offsets the entity by " + $"({(runtimeCenterX - CenterX) * 192f:F0}m," - + $"{(runtimeCenterY - CenterY) * 192f:F0}m) from its geometry."); + + $"{(runtimeCenterY - CenterY) * 192f:F0}m) from its geometry.")); } /// diff --git a/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs index d681a826..309eca2e 100644 --- a/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs +++ b/src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AcDream.Core.World; namespace AcDream.Runtime.World; @@ -185,10 +186,11 @@ public sealed class RuntimeWorldEnvironmentState if (definition.DayGroups.Count > 0) { - _log( + _log(string.Create( + CultureInfo.InvariantCulture, $"sky: loaded Region 0x13000000 — {definition.DayGroups.Count} day groups, " + $"SkyDesc.TickSize={definition.SourceTickSize} (throttle, not rate), " - + $"LightTickSize={definition.LightTickSize}"); + + $"LightTickSize={definition.LightTickSize}")); RefreshDayGroup(); } @@ -285,10 +287,11 @@ public sealed class RuntimeWorldEnvironmentState Weather.SetKindFromDayGroupName(group.Name); _revision++; - _log( + _log(string.Create( + CultureInfo.InvariantCulture, $"sky: PY{absoluteYear} day{dayOfYear} → DayGroup[{index}] \"{group.Name}\" " + $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjectCount} objects, " - + $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})"); + + $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})")); } public string CycleTimeOfDay() @@ -308,7 +311,11 @@ public sealed class RuntimeWorldEnvironmentState if (selection.HasValue) { WorldTime.SetDebugTime(selection.Value); - return $"Time override = {selection.Value:F2}"; + // Invariant: this toast is player-visible text, and a culture + // with a decimal comma would render it "Time override = 0,00". + return string.Create( + CultureInfo.InvariantCulture, + $"Time override = {selection.Value:F2}"); } WorldTime.ClearDebugTime(); diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index 09adc96e..3aca8999 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Combat; @@ -207,7 +208,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback var fps = FpsProvider?.Invoke(); ShowSystemMessage(fps is null ? "Framerate: (provider unavailable)" - : $"Framerate: {fps.Value:F1} FPS"); + : string.Create( + CultureInfo.InvariantCulture, + $"Framerate: {fps.Value:F1} FPS")); } /// @@ -221,7 +224,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback var pos = PositionProvider?.Invoke(); ShowSystemMessage(pos is null ? "Location: (provider unavailable)" - : $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})"); + : string.Create( + CultureInfo.InvariantCulture, + $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})")); } /// diff --git a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs index c47cb158..8681a8b3 100644 --- a/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/FramebufferResizeControllerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AcDream.App.Input; using AcDream.App.Rendering; @@ -81,7 +82,9 @@ public sealed class FramebufferResizeControllerTests private sealed class Camera(List calls) : IFramebufferCameraTarget { - public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}"); + public void SetAspect(float aspect) => calls.Add( + // Invariant: compared against literal golden strings. + string.Create(CultureInfo.InvariantCulture, $"camera:{aspect:F3}")); } private sealed class DevTools(List calls) : IFramebufferDevToolsTarget diff --git a/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs index 22229c41..fdbb2779 100644 --- a/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RuntimeResourceSlotTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using AcDream.App.Rendering; using AcDream.App.Update; @@ -278,7 +279,11 @@ public sealed class RuntimeResourceSlotTests private sealed class RecordingUpdateRoot(List calls) : IGameUpdateFrameRoot { - public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}"); + public void Tick(UpdateFrameInput input) => calls.Add( + // Invariant: compared against literal golden strings. + string.Create( + CultureInfo.InvariantCulture, + $"update:{input.HostDeltaSeconds}")); } private sealed class RecordingRenderRoot(List calls) : IGameRenderFrameRoot @@ -286,7 +291,9 @@ public sealed class RuntimeResourceSlotTests public RenderFrameOutcome Render(RenderFrameInput input) { calls.Add( - $"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"); + string.Create( + CultureInfo.InvariantCulture, + $"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}")); return default; } } diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 5a001d08..d37c6585 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Collections.Generic; using System.Linq; using AcDream.App.UI; @@ -511,7 +512,9 @@ public sealed class VendorUiControllerTests // to the singular name UNCHANGED (not an invented "Arrows" + "s"). Assert.Equal("100 Arrows", GetText(h.ItemNameText)); Assert.Equal( - $"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)", + string.Create( + CultureInfo.InvariantCulture, + $"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"), GetText(h.ItemCostText)); } @@ -539,7 +542,9 @@ public sealed class VendorUiControllerTests Assert.Equal("Bread", GetText(h.ItemNameText)); Assert.Equal( - $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)", + string.Create( + CultureInfo.InvariantCulture, + $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"), GetText(h.ItemCostText)); } @@ -587,7 +592,9 @@ public sealed class VendorUiControllerTests // selection. Assert.Equal("Arrows", GetText(h.ItemNameText)); Assert.Equal( - $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)", + string.Create( + CultureInfo.InvariantCulture, + $"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"), GetText(h.ItemCostText)); // Player drags the slider to 40 AFTER selecting -- no re-click, no @@ -597,7 +604,9 @@ public sealed class VendorUiControllerTests // SellPrice = ceil(2.0*10*40 - 0.1) = 800. Assert.Equal("40 Arrows", GetText(h.ItemNameText)); Assert.Equal( - $"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)", + string.Create( + CultureInfo.InvariantCulture, + $"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"), GetText(h.ItemCostText)); h.BuyButton.OnClick!.Invoke(); @@ -2540,14 +2549,22 @@ public sealed class VendorUiControllerTests // Before staging: zero staged count/value, still grammatically // plural ("0 items"), purse text is live regardless of staging. Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText)); - Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText)); + Assert.Equal( + string.Create( + CultureInfo.InvariantCulture, + $"You have {Harness.DefaultPlayerCoinValue:N0}p"), + GetText(h.BuyPurseText)); h.AddButton.OnClick!.Invoke(); // ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1 // = 750, ceil(750 - 0.1) = 750. Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText)); - Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText)); + Assert.Equal( + string.Create( + CultureInfo.InvariantCulture, + $"You have {Harness.DefaultPlayerCoinValue:N0}p"), + GetText(h.BuyPurseText)); h.BuyClearListButton.OnClick!.Invoke(); @@ -2565,7 +2582,11 @@ public sealed class VendorUiControllerTests { var h = new Harness(); h.State.Apply(VendorGuid, Profile(), Array.Empty()); - Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText)); + Assert.Equal( + string.Create( + CultureInfo.InvariantCulture, + $"You have {Harness.DefaultPlayerCoinValue:N0}p"), + GetText(h.BuyPurseText)); var bundle = new PropertyBundle(); bundle.Ints[(uint)PropertyInt.CoinValue] = 42; @@ -2584,7 +2605,11 @@ public sealed class VendorUiControllerTests MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText)); - Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText)); + Assert.Equal( + string.Create( + CultureInfo.InvariantCulture, + $"You have {Harness.DefaultPlayerCoinValue:N0}p"), + GetText(h.SellPurseText)); h.Controller.HandleDropRelease( h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); @@ -2592,7 +2617,11 @@ public sealed class VendorUiControllerTests // ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value // 100 * quantity 1 = 100, floor(100 + 0.1) = 100. Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText)); - Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText)); + Assert.Equal( + string.Create( + CultureInfo.InvariantCulture, + $"You have {Harness.DefaultPlayerCoinValue:N0}p"), + GetText(h.SellPurseText)); h.SellClearListButton.OnClick!.Invoke(); diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs index 53db8f14..c3ef3272 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Numerics; using System.Text; @@ -184,16 +185,20 @@ public sealed class AnimationSequencerCutoverTraceTests if (!first) sb.Append(','); first = false; uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim); - sb.Append($"{id:X}@{n.Value.Framerate:F1}"); + // Invariant throughout: this trace is compared against literal + // golden strings, so it must not follow the machine's locale. + sb.Append(CultureInfo.InvariantCulture, $"{id:X}@{n.Value.Framerate:F1}"); if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*'); if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^'); } var v = core.Velocity; var o = core.Omega; - sb.Append($" | frame={core.FrameNumber:F1}"); - sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"); - sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})"); - sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}"); + sb.Append(CultureInfo.InvariantCulture, $" | frame={core.FrameNumber:F1}"); + sb.Append(CultureInfo.InvariantCulture, $" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"); + sb.Append(CultureInfo.InvariantCulture, $" om=({o.X:F2},{o.Y:F2},{o.Z:F2})"); + sb.Append( + CultureInfo.InvariantCulture, + $" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}"); return sb.ToString(); } diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs index 7c3cb617..68ebe722 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Globalization; using AcDream.Core.Physics; using Xunit; @@ -21,7 +22,12 @@ public class MotionInterpreterFunnelTests public readonly List Calls = new(); public bool ApplyMotion(uint motion, float speed) { - Calls.Add($"DIM {motion:x8}@{speed:F2}"); + // Invariant: this trace is compared against literal golden + // strings, so it must not follow the machine's locale (sv-SE + // renders 1.00 as "1,00" and -1.20 with a Unicode minus). + Calls.Add(string.Create( + CultureInfo.InvariantCulture, + $"DIM {motion:x8}@{speed:F2}")); // R3-W5: a style/stance id (>= 0x80000000, i.e. negative as // int32) has no locomotion MotionData entry in the dat — retail's // real CMotionTable::DoObjectMotion genuinely fails for it diff --git a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs index deb747fe..7004d476 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeSimulationFixtureHostTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Numerics; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -251,7 +252,10 @@ public sealed class RuntimeSimulationFixtureHostTests public bool SendAttack(AttackHeight height, float power) { - Trace.Add($"attack:{height}:{power:0.0}"); + // Invariant: compared against literal golden traces. + Trace.Add(string.Create( + CultureInfo.InvariantCulture, + $"attack:{height}:{power:0.0}")); return true; } diff --git a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs index 8074f6a4..15092fdd 100644 --- a/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs +++ b/tests/AcDream.Runtime.Tests/Support/NoWindowGameRuntimeHost.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Numerics; using AcDream.Core.Combat; @@ -748,7 +749,10 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable Trace.Add("attack:prepare"); public bool SendAttack(AttackHeight height, float power) { - Trace.Add($"attack:{height}:{power:0.0}"); + // Invariant: compared against literal golden traces. + Trace.Add(string.Create( + CultureInfo.InvariantCulture, + $"attack:{height}:{power:0.0}")); return true; } public void SendCancelAttack() { } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 00000000..0339fa32 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/tests/TestCultureInitializer.cs b/tests/TestCultureInitializer.cs new file mode 100644 index 00000000..c8790f55 --- /dev/null +++ b/tests/TestCultureInitializer.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace AcDream.Tests; + +/// +/// Lets a test run pin the ambient culture, so a locale-dependent failure can +/// be reproduced on any machine instead of only on one with the right Windows +/// locale. +/// +/// Why this exists. On 2026-08-19 the Windows CI runner went red on +/// 37 tests across four assemblies with failures like +/// Expected: "You have 1 500p" / Actual: "You have 1,500p". The +/// production code was right — it formats retail text with +/// , so every player sees retail's +/// comma. The TESTS were wrong: they built their expected strings with +/// $"{value:N0}", which uses the machine's current culture, so they only +/// passed on a machine that happens to format like the invariant culture. The +/// runner is Swedish (space as the group separator), and the tests had been +/// passing there only because its registry locale had been pinned by hand — +/// a machine-state fix that silently came undone. +/// +/// The expectations are fixed to be invariant. This knob is the +/// apparatus that makes such a break reproducible next time: +/// ACDREAM_TEST_CULTURE=sv-SE dotnet test ... runs the suite as the +/// Swedish runner sees it. Unset (the default, and what CI runs) changes +/// nothing at all. +/// +internal static class TestCultureInitializer +{ + internal const string CultureVariable = "ACDREAM_TEST_CULTURE"; + + [ModuleInitializer] + internal static void Initialize() + { + string? requested = Environment.GetEnvironmentVariable(CultureVariable); + if (string.IsNullOrWhiteSpace(requested)) + { + return; + } + + try + { + var culture = CultureInfo.GetCultureInfo(requested); + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + // A typo in an opt-in diagnostic must not fail an unrelated suite; + // the run simply keeps the machine's own culture. + } + } +}