feat(net+ui): Phase G.1 — server time sync + debug controls

WorldSession now surfaces the server's PortalYearTicks via a new
ServerTimeUpdated event, fired from two sources per r12 §12:
  1. Initial ConnectRequest handshake (ConnectRequestServerTime field
     of the optional block — seeds the clock on login).
  2. Every subsequent packet carrying the TimeSync header flag
     (0x01000000) — keeps the client clock within one TimeSync period
     of authoritative server time.

GameWindow subscribes the event into WorldTimeService.SyncFromServer,
so the day/night cycle + keyframe interpolation runs from real server
time in live mode. Offline mode (ACDREAM_LIVE=0) still uses the
seeded-to-noon fallback from OnLoad.

DebugOverlay now exposes sky + weather + lighting state:
  time  0.50  Midsong    (day fraction + hour name)
  wx    Clear parts 0   (weather kind + live particle count)
  lit   1/4            (active / registered lights)

F7 cycles a debug time override through
  (none → midnight → dawn → noon → dusk → none)
F10 cycles weather through
  (Clear → Overcast → Rain → Snow → Storm).

These keybinds satisfy the visual-verification tier so a user can
flip through every state from the running client without touching
the code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:51:03 +02:00
parent 7b9a66c9ea
commit cd89e9a498
3 changed files with 116 additions and 3 deletions

View file

@ -101,7 +101,14 @@ public sealed class DebugOverlay
int StreamingRadius, int StreamingRadius,
float MouseSensitivity, float MouseSensitivity,
float ChaseDistance, float ChaseDistance,
bool RmbOrbit); bool RmbOrbit,
// Phase G.1/G.2 — sky + weather + lighting
string HourName = "",
float DayFraction = 0f,
string Weather = "Clear",
int ActiveLights = 0,
int RegisteredLights = 0,
int ParticleCount = 0);
public DebugOverlay(TextRenderer text, BitmapFont font) public DebugOverlay(TextRenderer text, BitmapFont font)
{ {
@ -205,6 +212,10 @@ public sealed class DebugOverlay
($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White), ($"lb {s.LandblocksVisible,3}/{s.LandblocksTotal,3} visible", White),
($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White), ($"ent {s.EntityCount,4} anim {s.AnimatedCount,3}", White),
($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White), ($"coll {s.ShadowObjectCount,5} radius {s.StreamingRadius}", White),
// Phase G: sky + weather + dynamic lighting surface.
($"time {s.DayFraction,5:F2} {s.HourName}", Cyan),
($"wx {s.Weather,-8} parts {s.ParticleCount,5}", Cyan),
($"lit {s.ActiveLights}/{s.RegisteredLights} ", Cyan),
}; };
float pad = 10f; float pad = 10f;
@ -277,6 +288,8 @@ public sealed class DebugOverlay
("F4", "toggle debug HUD info panel"), ("F4", "toggle debug HUD info panel"),
("F5", "toggle stats panel"), ("F5", "toggle stats panel"),
("F6", "toggle compass"), ("F6", "toggle compass"),
("F7", "cycle time-of-day override (none/midnight/dawn/noon/dusk)"),
("F10", "cycle weather (clear/overcast/rain/snow/storm)"),
("F", "toggle fly camera"), ("F", "toggle fly camera"),
("Tab", "toggle player mode (requires login)"), ("Tab", "toggle player mode (requires login)"),
("W A S D", "move (player mode) / fly"), ("W A S D", "move (player mode) / fly"),
@ -388,7 +401,7 @@ public sealed class DebugOverlay
private void DrawHintBar(Vector2 screenSize) private void DrawHintBar(Vector2 screenSize)
{ {
string hint = "F1 help F2 wireframes F3 dump F4/F5/F6 panels F8/F9 sens Tab player Hold RMB orbit Wheel zoom"; string hint = "F1 help F2 wires F3 dump F4/F5/F6 panels F7 time F8/F9 sens F10 weather Tab player RMB orbit Wheel zoom";
float w = _font.MeasureWidth(hint); float w = _font.MeasureWidth(hint);
float pad = 10f; float pad = 10f;
float y = screenSize.Y - _font.LineHeight - pad; float y = screenSize.Y - _font.LineHeight - pad;

View file

@ -184,6 +184,12 @@ public sealed class GameWindow : IDisposable
AcDream.Core.World.WeatherKind.Clear; AcDream.Core.World.WeatherKind.Clear;
private double _weatherAccum; private double _weatherAccum;
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
// range of the real values so the first press hits index 0 of the
// cycle table cleanly.
private int _timeDebugStep = 0;
private int _weatherDebugStep = 0;
// Phase B.2: player movement mode. // Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera; private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
@ -370,6 +376,51 @@ public sealed class GameWindow : IDisposable
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}"); _debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
} }
} }
else if (key == Key.F7)
{
// Phase G.1: cycle debug time-of-day overrides. Useful for
// visually verifying the sun arc + keyframe transitions
// without waiting 30+ real-time hours. Cycle order:
// clear debug → 0.0 (midnight) → 0.25 (dawn)
// → 0.5 (noon) → 0.75 (dusk) → clear
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null, // server time
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugOverlay?.Toast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugOverlay?.Toast("Time override cleared");
}
}
else if (key == Key.F10)
{
// Phase G.1: cycle weather kinds manually. Useful for
// testing the rain/snow particle systems + storm/light
// fog without waiting for the daily RNG to hit.
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugOverlay?.Toast($"Weather = {kinds[_weatherDebugStep]}");
}
else if (key == Key.F8 || key == Key.F9) else if (key == Key.F8 || key == Key.F9)
{ {
// Adjust whichever mode's sensitivity is currently active. // Adjust whichever mode's sensitivity is currently active.
@ -824,6 +875,11 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted; _liveSession.TeleportStarted += OnTeleportStarted;
// Phase G.1: keep the client's day/night clock in sync with
// server time. Fires once from ConnectRequest (initial seed)
// and repeatedly on TimeSync-flagged packets.
_liveSession.ServerTimeUpdated += ticks => WorldTime.SyncFromServer(ticks);
// Phase F.1-H.1: wire every parsed GameEvent into the right // Phase F.1-H.1: wire every parsed GameEvent into the right
// Core state class (chat, combat, spellbook, items). After // Core state class (chat, combat, spellbook, items). After
// this one call, server-sent ChannelBroadcast / damage // this one call, server-sent ChannelBroadcast / damage
@ -3238,6 +3294,8 @@ public sealed class GameWindow : IDisposable
else else
activeSens = _sensOrbit; activeSens = _sensOrbit;
// Phase G: pull sky + weather + lighting state for the overlay.
var dayCal = WorldTime.CurrentCalendar;
var snapshot = new DebugOverlay.Snapshot( var snapshot = new DebugOverlay.Snapshot(
Fps: (float)_lastFps, Fps: (float)_lastFps,
FrameTimeMs: (float)_lastFrameMs, FrameTimeMs: (float)_lastFrameMs,
@ -3260,7 +3318,13 @@ public sealed class GameWindow : IDisposable
StreamingRadius: _streamingRadius, StreamingRadius: _streamingRadius,
MouseSensitivity: activeSens, MouseSensitivity: activeSens,
ChaseDistance: _chaseCamera?.Distance ?? 0f, ChaseDistance: _chaseCamera?.Distance ?? 0f,
RmbOrbit: _rmbHeld); RmbOrbit: _rmbHeld,
HourName: dayCal.Hour.ToString(),
DayFraction: (float)WorldTime.DayFraction,
Weather: Weather.Kind.ToString(),
ActiveLights: Lighting.ActiveCount,
RegisteredLights: Lighting.RegisteredCount,
ParticleCount: _particleSystem?.ActiveParticleCount ?? 0);
_debugOverlay.Update((float)deltaSeconds); _debugOverlay.Update((float)deltaSeconds);
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y); var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);

View file

@ -109,6 +109,21 @@ public sealed class WorldSession : IDisposable
/// </summary> /// </summary>
public event Action<HearSpeech.Parsed>? SpeechHeard; public event Action<HearSpeech.Parsed>? SpeechHeard;
/// <summary>
/// Phase G.1: latest server Portal Year tick count. Seeded from the
/// ConnectRequest handshake (r12 §1.3 — server sends absolute game
/// time as a double) and refreshed on every TimeSync-flagged packet.
/// Subscribers feed this into <c>WorldTimeService.SyncFromServer</c>
/// so client-local day/night stays in lockstep with the server clock.
/// </summary>
public event Action<double>? ServerTimeUpdated;
/// <summary>
/// Latest server tick count from <see cref="ServerTimeUpdated"/>
/// events. 0 until the handshake completes.
/// </summary>
public double LastServerTimeTicks { get; private set; }
/// <summary> /// <summary>
/// Allow re-sending LoginComplete after a portal teleport. The normal /// Allow re-sending LoginComplete after a portal teleport. The normal
/// _loginCompleteSent latch prevents duplicate sends on the initial spawn /// _loginCompleteSent latch prevents duplicate sends on the initial spawn
@ -224,6 +239,14 @@ public sealed class WorldSession : IDisposable
// Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay // Step 3: seed ISAAC, send ConnectResponse to port+1, with 200ms race delay
var opt = cr.Optional; var opt = cr.Optional;
// Phase G.1: server's initial PortalYearTicks (r12 §1.3) lives
// in the ConnectRequest optional section. Publish it to
// subscribers so WorldTimeService.SyncFromServer can seed the
// client clock.
LastServerTimeTicks = opt.ConnectRequestServerTime;
ServerTimeUpdated?.Invoke(opt.ConnectRequestServerTime);
byte[] serverSeedBytes = new byte[4]; byte[] serverSeedBytes = new byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed); BinaryPrimitives.WriteUInt32LittleEndian(serverSeedBytes, opt.ConnectRequestServerSeed);
byte[] clientSeedBytes = new byte[4]; byte[] clientSeedBytes = new byte[4];
@ -393,6 +416,19 @@ public sealed class WorldSession : IDisposable
SendAck(serverHeader.Sequence); SendAck(serverHeader.Sequence);
} }
// Phase G.1: propagate TimeSync-flagged server time to anyone who
// needs it (sky/day-night lerp in particular). Server sends this
// periodically — no explicit opcode, just the header flag.
if ((serverHeader.Flags & PacketHeaderFlags.TimeSync) != 0)
{
double t = dec.Packet!.Optional.TimeSync;
if (t > 0)
{
LastServerTimeTicks = t;
ServerTimeUpdated?.Invoke(t);
}
}
foreach (var frag in dec.Packet!.Fragments) foreach (var frag in dec.Packet!.Fragments)
{ {
var body = _assembler.Ingest(frag, out _); var body = _assembler.Ingest(frag, out _);