User-observed regression 2026-04-23: acdream spawned rain particles when retail showed no rain at the same server tick. Root cause: my Phase 3e shortcut mapped DayGroup.Name = "Rainy" → WeatherKind.Rain → rain particle emitter. That's not what retail does. Parallel decompile research confirms: - Agent A (2026-04-23-physicsscript.md): PhysicsScript runtime lives at FUN_0051bed0 → FUN_0051bfb0, runs per PhysicsObj; sky calls it from NOWHERE. - Agent B (2026-04-23-sky-pes-wiring.md): FUN_00508010 (sky render loop) never reads SkyObject.DefaultPesObjectId — the field is dead at render time. Rain/snow particles in retail come from a separate camera-attached weather subsystem that has NOT yet been located. So the correct behavior is: DayGroup name should only drive fog/ambient tone (via keyframes, already in the Snapshot path), never spawn particle emitters. Any retail-faithful particle rain belongs to a future phase once we find the camera-attached weather subsystem driver. Change: MapDayGroupNameToKind now maps all weathery substrings (storm/snow/rain/cloud/overcast/dark/fog) → Overcast — fog-only visuals, no particle spawn. Clear names stay Clear. The Rain, Snow, Storm enum values remain and are still accessible via ForceWeather() for debug overrides. Tests updated (WeatherSystemTests): the name→kind theory now expects Overcast for Rainy/Snowy/Stormy variants. Also commits the four research docs from this session's parallel hunt: PhysicsScript dat+runtime, sky↔PES wiring (negative finding), lightning timer (negative finding — agent #3), fog on sky (positive: retail applies fog to sky geometry). NOTE on lightning: agent #3's research only ruled out the CLIENT-SIDE RANDOM TIMER hypothesis for lightning. User confirms retail does have visible lightning + thunder. A follow-up agent (#5, in flight as of this commit) is hunting the real mechanism — PlayScript opcode, SetLight PhysicsScript hooks, AdminEnvirons side effects, or the weather-volume draw. This commit does NOT attempt to port lightning. Build + 733 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
C#
150 lines
5.1 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.Core.World;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.World;
|
|
|
|
public sealed class WeatherSystemTests
|
|
{
|
|
[Fact]
|
|
public void Roll_Deterministic_ForSameDayIndex()
|
|
{
|
|
var a = new WeatherSystem();
|
|
var b = new WeatherSystem();
|
|
|
|
for (int d = 0; d < 100; d++)
|
|
{
|
|
a.Tick(0, d, 100f); // big dt to finish any transition
|
|
b.Tick(0, d, 100f);
|
|
Assert.Equal(a.Kind, b.Kind);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Roll_WeightsDominatedByClear()
|
|
{
|
|
// Clear should cover ~60% of the distribution. Sample many days
|
|
// and check the clear fraction is in a reasonable band.
|
|
var sys = new WeatherSystem();
|
|
int clear = 0;
|
|
for (int d = 0; d < 1000; d++)
|
|
{
|
|
sys.Tick(0, d, 100f);
|
|
if (sys.Kind == WeatherKind.Clear) clear++;
|
|
}
|
|
double frac = clear / 1000.0;
|
|
Assert.InRange(frac, 0.45, 0.75);
|
|
}
|
|
|
|
[Fact]
|
|
public void Transition_EasesAcrossTenSeconds()
|
|
{
|
|
// Force Storm, then Clear, sample snapshot fog distance mid-transition.
|
|
var sys = new WeatherSystem();
|
|
sys.ForceWeather(WeatherKind.Storm);
|
|
sys.Tick(0, 1, 100f); // finalize
|
|
|
|
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
|
var stormFog = sys.Snapshot(in kf);
|
|
Assert.Equal(WeatherKind.Storm, stormFog.Kind);
|
|
|
|
// Snapshot should have a small fog end (storm fog is dense).
|
|
Assert.True(stormFog.FogEnd < 120f, $"storm fog end too large: {stormFog.FogEnd}");
|
|
}
|
|
|
|
[Fact]
|
|
public void EnvironOverride_ForcesTintedFog()
|
|
{
|
|
var sys = new WeatherSystem();
|
|
sys.Override = EnvironOverride.RedFog;
|
|
|
|
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
|
var snap = sys.Snapshot(in kf);
|
|
|
|
Assert.Equal(EnvironOverride.RedFog, snap.Override);
|
|
// Red override means the R channel dominates.
|
|
Assert.True(snap.FogColor.X > snap.FogColor.Y);
|
|
Assert.True(snap.FogColor.X > snap.FogColor.Z);
|
|
}
|
|
|
|
[Fact]
|
|
public void Flash_DecaysOverTime()
|
|
{
|
|
var sys = new WeatherSystem();
|
|
sys.TriggerFlash();
|
|
|
|
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
|
var imm = sys.Snapshot(in kf);
|
|
Assert.True(imm.LightningFlash > 0.9f);
|
|
|
|
// After 1 second the flash should be mostly decayed.
|
|
sys.Tick(0, 0, 1.0f);
|
|
var later = sys.Snapshot(in kf);
|
|
Assert.True(later.LightningFlash < 0.1f,
|
|
$"lightning flash didn't decay: {later.LightningFlash}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_ClearKind_PassesThroughKeyframeFog()
|
|
{
|
|
var sys = new WeatherSystem();
|
|
sys.ForceWeather(WeatherKind.Clear);
|
|
sys.Tick(0, 0, 100f); // finish transition
|
|
|
|
var kf = SkyStateProvider.Default().Interpolate(0.5f);
|
|
var snap = sys.Snapshot(in kf);
|
|
|
|
// Clear passes the keyframe's fog color + distances through.
|
|
Assert.Equal(kf.FogStart, snap.FogStart, precision: 2);
|
|
Assert.Equal(kf.FogEnd, snap.FogEnd, precision: 2);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Sunny", WeatherKind.Clear)]
|
|
[InlineData("SUNNY", WeatherKind.Clear)]
|
|
[InlineData("Clear", WeatherKind.Clear)]
|
|
[InlineData("", WeatherKind.Clear)]
|
|
[InlineData(null, WeatherKind.Clear)]
|
|
// All "weathery" names map to Overcast. Retail does NOT spawn rain /
|
|
// snow / lightning from the DayGroup name — verified by the 2026-04-23
|
|
// PhysicsScript + sky-PES decompile audits (see WeatherState.cs). Any
|
|
// future particle rain must come from the camera-attached weather
|
|
// subsystem, NOT from name string matching.
|
|
[InlineData("Cloudy", WeatherKind.Overcast)]
|
|
[InlineData("Overcast", WeatherKind.Overcast)]
|
|
[InlineData("Dark skies", WeatherKind.Overcast)]
|
|
[InlineData("Fog", WeatherKind.Overcast)]
|
|
[InlineData("Rainy", WeatherKind.Overcast)]
|
|
[InlineData("heavy rain", WeatherKind.Overcast)]
|
|
[InlineData("Snowy", WeatherKind.Overcast)]
|
|
[InlineData("Blizzard", WeatherKind.Clear)] // no matcher — default
|
|
[InlineData("Stormy", WeatherKind.Overcast)]
|
|
[InlineData("Thunderstorm", WeatherKind.Overcast)]
|
|
public void SetKindFromDayGroupName_MapsRetailNames(string? name, WeatherKind expected)
|
|
{
|
|
var sys = new WeatherSystem();
|
|
sys.SetKindFromDayGroupName(name);
|
|
sys.Tick(0, 0, 100f); // finalize transition
|
|
Assert.Equal(expected, sys.Kind);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetKindFromDayGroupName_DisablesInternalRoll()
|
|
{
|
|
// Once driven externally, advancing dayIndex must NOT re-roll
|
|
// to a different kind via the internal RollKind hash.
|
|
var sys = new WeatherSystem();
|
|
sys.SetKindFromDayGroupName("Sunny");
|
|
sys.Tick(0, 0, 100f);
|
|
|
|
var clearKind = sys.Kind;
|
|
Assert.Equal(WeatherKind.Clear, clearKind);
|
|
|
|
for (int d = 1; d < 50; d++)
|
|
{
|
|
sys.Tick(0, d, 100f);
|
|
Assert.Equal(clearKind, sys.Kind); // stays put — no auto-roll
|
|
}
|
|
}
|
|
}
|