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. + } + } +}