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 <noreply@anthropic.com>
335 lines
10 KiB
C#
335 lines
10 KiB
C#
using System.Globalization;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.Runtime.World;
|
|
|
|
public sealed record RuntimeWorldDayGroupDefinition(
|
|
string Name,
|
|
float ChanceOfOccur,
|
|
int SkyObjectCount,
|
|
SkyStateProvider Sky)
|
|
{
|
|
public string Name { get; } = Name ?? string.Empty;
|
|
public SkyStateProvider Sky { get; } =
|
|
Sky ?? throw new ArgumentNullException(nameof(Sky));
|
|
}
|
|
|
|
public sealed class RuntimeWorldEnvironmentDefinition
|
|
{
|
|
private readonly RuntimeWorldDayGroupDefinition[] _dayGroups;
|
|
|
|
public RuntimeWorldEnvironmentDefinition(
|
|
double originOffsetTicks,
|
|
double sourceTickSize,
|
|
double lightTickSize,
|
|
IEnumerable<RuntimeWorldDayGroupDefinition>? dayGroups,
|
|
int? forcedDayGroupIndex = null)
|
|
{
|
|
if (!double.IsFinite(originOffsetTicks))
|
|
throw new ArgumentOutOfRangeException(nameof(originOffsetTicks));
|
|
if (!double.IsFinite(sourceTickSize))
|
|
throw new ArgumentOutOfRangeException(nameof(sourceTickSize));
|
|
if (!double.IsFinite(lightTickSize))
|
|
throw new ArgumentOutOfRangeException(nameof(lightTickSize));
|
|
|
|
OriginOffsetTicks = originOffsetTicks;
|
|
SourceTickSize = sourceTickSize;
|
|
LightTickSize = lightTickSize;
|
|
_dayGroups = dayGroups?.ToArray() ?? [];
|
|
if (forcedDayGroupIndex is < 0
|
|
|| forcedDayGroupIndex >= _dayGroups.Length)
|
|
{
|
|
forcedDayGroupIndex = null;
|
|
}
|
|
ForcedDayGroupIndex = forcedDayGroupIndex;
|
|
}
|
|
|
|
public double OriginOffsetTicks { get; }
|
|
public double SourceTickSize { get; }
|
|
public double LightTickSize { get; }
|
|
public IReadOnlyList<RuntimeWorldDayGroupDefinition> DayGroups => _dayGroups;
|
|
public int? ForcedDayGroupIndex { get; }
|
|
}
|
|
|
|
public enum RuntimeEnvironmentEffectKind
|
|
{
|
|
Unknown,
|
|
FogOverride,
|
|
SoundCue,
|
|
}
|
|
|
|
public enum RuntimeEnvironmentSoundCue
|
|
{
|
|
Roar = 0x65,
|
|
Bell = 0x66,
|
|
Chant1 = 0x67,
|
|
Chant2 = 0x68,
|
|
DarkWhispers1 = 0x69,
|
|
DarkWhispers2 = 0x6A,
|
|
DarkLaugh = 0x6B,
|
|
DarkWind = 0x6C,
|
|
DarkSpeech = 0x6D,
|
|
Drums = 0x6E,
|
|
GhostSpeak = 0x6F,
|
|
Breathing = 0x70,
|
|
Howl = 0x71,
|
|
LostSouls = 0x72,
|
|
Squeal = 0x75,
|
|
Thunder1 = 0x76,
|
|
Thunder2 = 0x77,
|
|
Thunder3 = 0x78,
|
|
Thunder4 = 0x79,
|
|
Thunder5 = 0x7A,
|
|
Thunder6 = 0x7B,
|
|
}
|
|
|
|
public readonly record struct RuntimeEnvironmentEffect(
|
|
RuntimeEnvironmentEffectKind Kind,
|
|
uint RawValue,
|
|
EnvironOverride FogOverride = EnvironOverride.None,
|
|
RuntimeEnvironmentSoundCue? SoundCue = null);
|
|
|
|
public readonly record struct RuntimeWorldEnvironmentSnapshot(
|
|
long Revision,
|
|
bool IsInitialized,
|
|
int ActiveDayGroupIndex,
|
|
long ActiveDayIndex,
|
|
WeatherKind Weather,
|
|
EnvironOverride EnvironOverride);
|
|
|
|
public readonly record struct RuntimeWorldEnvironmentOwnershipSnapshot(
|
|
bool IsInitialized,
|
|
int DayGroupDefinitionCount,
|
|
int ActiveDayGroupCount);
|
|
|
|
public interface IRuntimeWorldEnvironmentView
|
|
{
|
|
RuntimeWorldEnvironmentSnapshot Snapshot { get; }
|
|
RuntimeWorldEnvironmentOwnershipSnapshot Ownership { get; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Instance-scoped owner of the retail world clock, deterministic day-group
|
|
/// choice, weather label, and AdminEnvirons state. Raw DAT objects and sky
|
|
/// drawing remain graphical-host projections.
|
|
/// </summary>
|
|
public sealed class RuntimeWorldEnvironmentState
|
|
: IRuntimeWorldEnvironmentView
|
|
{
|
|
private static readonly WeatherKind[] DebugWeatherKinds =
|
|
[
|
|
WeatherKind.Clear,
|
|
WeatherKind.Overcast,
|
|
WeatherKind.Rain,
|
|
WeatherKind.Snow,
|
|
WeatherKind.Storm,
|
|
];
|
|
|
|
private readonly Action<string> _log;
|
|
private RuntimeWorldEnvironmentDefinition? _definition;
|
|
private long _activeDayIndex = long.MinValue;
|
|
private int _timeDebugStep;
|
|
private int _weatherDebugStep;
|
|
private long _revision;
|
|
|
|
public RuntimeWorldEnvironmentState(
|
|
TimeProvider? timeProvider = null,
|
|
Action<string>? log = null,
|
|
Action<string>? timeSyncDiagnostic = null)
|
|
{
|
|
WorldTime = new WorldTimeService(
|
|
SkyStateProvider.Default(),
|
|
new DerethCalendar(),
|
|
timeProvider,
|
|
timeSyncDiagnostic);
|
|
Weather = new WeatherSystem();
|
|
_log = log ?? (_ => { });
|
|
}
|
|
|
|
public WorldTimeService WorldTime { get; }
|
|
public WeatherSystem Weather { get; }
|
|
public int ActiveDayGroupIndex { get; private set; } = -1;
|
|
public bool IsInitialized => _definition is not null;
|
|
|
|
public RuntimeWorldEnvironmentSnapshot Snapshot => new(
|
|
_revision,
|
|
IsInitialized,
|
|
ActiveDayGroupIndex,
|
|
_activeDayIndex,
|
|
Weather.Kind,
|
|
Weather.Override);
|
|
|
|
public RuntimeWorldEnvironmentOwnershipSnapshot Ownership =>
|
|
CaptureOwnership();
|
|
|
|
public RuntimeWorldEnvironmentOwnershipSnapshot CaptureOwnership() =>
|
|
new(
|
|
IsInitialized,
|
|
_definition?.DayGroups.Count ?? 0,
|
|
ActiveDayGroupIndex >= 0 ? 1 : 0);
|
|
|
|
public void Initialize(RuntimeWorldEnvironmentDefinition definition)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(definition);
|
|
if (_definition is not null)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The world environment is a one-shot Runtime lifetime owner.");
|
|
}
|
|
|
|
_definition = definition;
|
|
WorldTime.Calendar.SetOriginOffset(definition.OriginOffsetTicks);
|
|
WorldTime.TickSize = 1.0;
|
|
ActiveDayGroupIndex = -1;
|
|
_activeDayIndex = long.MinValue;
|
|
_revision++;
|
|
|
|
if (definition.DayGroups.Count > 0)
|
|
{
|
|
_log(string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"sky: loaded Region 0x13000000 — {definition.DayGroups.Count} day groups, "
|
|
+ $"SkyDesc.TickSize={definition.SourceTickSize} (throttle, not rate), "
|
|
+ $"LightTickSize={definition.LightTickSize}"));
|
|
RefreshDayGroup();
|
|
}
|
|
|
|
// Preserve the accepted retail-port initialization order: select the
|
|
// initial group, then seed the offline clock at noon.
|
|
WorldTime.SyncFromServer(DerethDateTime.DayTicks / 16.0);
|
|
_revision++;
|
|
}
|
|
|
|
public void SynchronizeFromServer(double ticks)
|
|
{
|
|
WorldTime.SyncFromServer(ticks);
|
|
_revision++;
|
|
if (IsInitialized)
|
|
RefreshDayGroup();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Port of the state distinction in
|
|
/// <c>CPlayerSystem::Handle_Admin__Environs @ 0x0055DE20</c>.
|
|
/// Graphical audio playback consumes the returned typed sound cue.
|
|
/// </summary>
|
|
public RuntimeEnvironmentEffect ApplyAdminEnvirons(uint changeType)
|
|
{
|
|
if (changeType <= 0x06u)
|
|
{
|
|
var value = (EnvironOverride)changeType;
|
|
Weather.Override = value;
|
|
_revision++;
|
|
_log($"live: AdminEnvirons fog override = {value}");
|
|
return new RuntimeEnvironmentEffect(
|
|
RuntimeEnvironmentEffectKind.FogOverride,
|
|
changeType,
|
|
value);
|
|
}
|
|
|
|
RuntimeEnvironmentSoundCue? cue =
|
|
Enum.IsDefined(typeof(RuntimeEnvironmentSoundCue), (int)changeType)
|
|
? (RuntimeEnvironmentSoundCue)(int)changeType
|
|
: null;
|
|
if (cue is null)
|
|
{
|
|
_log(
|
|
$"live: AdminEnvirons sound cue = Unknown(0x{changeType:X2}) "
|
|
+ $"(0x{changeType:X2}) — audio binding pending");
|
|
return new RuntimeEnvironmentEffect(
|
|
RuntimeEnvironmentEffectKind.Unknown,
|
|
changeType);
|
|
}
|
|
|
|
_log(
|
|
$"live: AdminEnvirons sound cue = {cue}Sound "
|
|
+ $"(0x{changeType:X2}) — audio binding pending");
|
|
return new RuntimeEnvironmentEffect(
|
|
RuntimeEnvironmentEffectKind.SoundCue,
|
|
changeType,
|
|
SoundCue: cue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the ordered day group with the exact
|
|
/// <c>SkyDesc::CalcPresentDayGroup @ 0x00500E10</c> integer/float
|
|
/// algorithm.
|
|
/// </summary>
|
|
public void RefreshDayGroup()
|
|
{
|
|
RuntimeWorldEnvironmentDefinition? definition = _definition;
|
|
if (definition is null)
|
|
return;
|
|
if (definition.DayGroups.Count == 0)
|
|
return;
|
|
|
|
double ticks = WorldTime.NowTicks;
|
|
DerethCalendar calendar = WorldTime.Calendar;
|
|
int absoluteYear = calendar.AbsoluteYear(ticks);
|
|
int dayOfYear = calendar.DayOfYear(ticks);
|
|
int daysPerYear =
|
|
DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear;
|
|
long dayIndex = (long)absoluteYear * daysPerYear + dayOfYear;
|
|
int index = SkyDayGroupSelector.SelectIndex(
|
|
definition.DayGroups.Count,
|
|
absoluteYear,
|
|
daysPerYear,
|
|
dayOfYear,
|
|
definition.ForcedDayGroupIndex);
|
|
|
|
if (dayIndex == _activeDayIndex && index == ActiveDayGroupIndex)
|
|
return;
|
|
|
|
_activeDayIndex = dayIndex;
|
|
ActiveDayGroupIndex = index;
|
|
RuntimeWorldDayGroupDefinition group = definition.DayGroups[index];
|
|
WorldTime.SetProvider(group.Sky);
|
|
Weather.SetKindFromDayGroupName(group.Name);
|
|
_revision++;
|
|
|
|
_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})"));
|
|
}
|
|
|
|
public string CycleTimeOfDay()
|
|
{
|
|
_timeDebugStep = (_timeDebugStep + 1) % 5;
|
|
float? selection = _timeDebugStep switch
|
|
{
|
|
0 => null,
|
|
1 => 0.0f,
|
|
2 => 0.25f,
|
|
3 => 0.5f,
|
|
4 => 0.75f,
|
|
_ => null,
|
|
};
|
|
|
|
_revision++;
|
|
if (selection.HasValue)
|
|
{
|
|
WorldTime.SetDebugTime(selection.Value);
|
|
// 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();
|
|
return "Time override cleared";
|
|
}
|
|
|
|
public string CycleWeather()
|
|
{
|
|
_weatherDebugStep =
|
|
(_weatherDebugStep + 1) % DebugWeatherKinds.Length;
|
|
WeatherKind kind = DebugWeatherKinds[_weatherDebugStep];
|
|
Weather.ForceWeather(kind);
|
|
_revision++;
|
|
return $"Weather = {kind}";
|
|
}
|
|
|
|
}
|