USER-DIRECTED deviation from retail (register row AD-109, same commit): retail presents the empty pre-player gameplay screen — black behind the retained UI — from the Enter click (CPlayerSystem::LogOnCharacter @0x0055F890 -> CM_Login::SendNotice_BeginEnterWorld @0x006AD810, UI mode 0x10000008) until CreatePlayer raises SmartBox::teleport_in_progress @0x00451C20 and gmSmartBoxUI::UseTime @0x004D6EAB begins TAS_TUNNEL. The user prefers the tunnel to cover that whole wait. - ILocalPlayerTeleportNetworkSink.ArmLoginTunnel: begins the login wormhole presentation at the Enter click, consuming the sequencer's begin-edge events SYNCHRONOUSLY (the Enter command blocks the update thread for the whole ServerReady round trip, so a deferred first tick would leave exactly the black window this deviation removes). The enter cue plays at the click: retail's own rule is cue-at-animation- begin (Sound_UI_EnterPortal @0x004D638E, unconditional inside BeginTeleportAnimation), and the animation begin moved to the click. - Armed pre-reveal pump: tunnel animates across the round trip (worldReady pinned false, sequencer holds in Tunnel); the hold clock accumulates from the click. - Adoption: the Runtime login reveal ADOPTS the running presentation (no re-Begin, no second cue); rejected EnterWorld (lifecycle back to AwaitingSelection) disarms and retires the tunnel. - Wired at the ONE host edge every entry route shares: ILiveSessionLifecycleHost.ApplySelectedCharacter (direct connect, roster Enter, enter-after-create) via LiveSessionSelectionBindings.ArmLoginTunnel (default no-op keeps headless and every existing construction site unchanged). - ILocalPlayerLoginLifecycleSource: typed seam (not a stored delegate — the frame-phase owner delegate-field guard) projecting the Runtime character-selection lifecycle for the disarm edge. - Frame contract update: [login-frames] over a login is tunnel -> world from the click — no void, and no black between click and world. Tests: 4 new armed-tunnel tests (arm/adopt/disarm/frame-shape); App suite live-DAT 5516 passed / 3 skipped (baseline 5512/3 + 4 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
339 lines
12 KiB
C#
339 lines
12 KiB
C#
using AcDream.App.Diagnostics;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering.Vfx;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.World;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
internal readonly record struct RenderFrameFoundation(
|
|
bool PortalViewportVisible,
|
|
SkyKeyframe Sky,
|
|
AtmosphereSnapshot Atmosphere)
|
|
{
|
|
public bool EnvironOverrideActive =>
|
|
Atmosphere.Override != EnvironOverride.None;
|
|
}
|
|
|
|
internal interface IRenderFrameBeginResources
|
|
{
|
|
void Begin(int gpuSlot);
|
|
}
|
|
|
|
internal interface IRenderFrameSlotSource
|
|
{
|
|
int CurrentSlot { get; }
|
|
}
|
|
|
|
internal interface IRenderFrameClearPhase
|
|
{
|
|
RenderFrameFoundation Clear();
|
|
}
|
|
|
|
internal interface IRenderFrameFoundationSource
|
|
{
|
|
RenderFrameFoundation Foundation { get; }
|
|
}
|
|
|
|
internal interface IRenderFrameLivePreparation
|
|
{
|
|
void Prepare(int gpuSlot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Owns the pre-world resource transaction after GPU-flight begin. The three
|
|
/// typed phases preserve resource begin, clear/state, then live publication
|
|
/// order without exposing a renderer service bag.
|
|
/// </summary>
|
|
internal sealed class RenderFrameResourceController :
|
|
IRenderFrameResourcePhase,
|
|
IRenderFrameFoundationSource
|
|
{
|
|
private readonly IRenderFrameSlotSource _frameSlots;
|
|
private readonly IRenderFrameBeginResources _resources;
|
|
private readonly IRenderFrameClearPhase _clear;
|
|
private readonly IRenderFrameLivePreparation _live;
|
|
|
|
public RenderFrameResourceController(
|
|
IRenderFrameSlotSource frameSlots,
|
|
IRenderFrameBeginResources resources,
|
|
IRenderFrameClearPhase clear,
|
|
IRenderFrameLivePreparation live)
|
|
{
|
|
_frameSlots = frameSlots
|
|
?? throw new ArgumentNullException(nameof(frameSlots));
|
|
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
|
|
_clear = clear ?? throw new ArgumentNullException(nameof(clear));
|
|
_live = live ?? throw new ArgumentNullException(nameof(live));
|
|
}
|
|
|
|
public RenderFrameFoundation Foundation { get; private set; }
|
|
|
|
public void Prepare(RenderFrameInput input)
|
|
{
|
|
int gpuSlot = _frameSlots.CurrentSlot;
|
|
_resources.Begin(gpuSlot);
|
|
Foundation = _clear.Clear();
|
|
_live.Prepare(gpuSlot);
|
|
}
|
|
}
|
|
|
|
/// <summary>Exact ordered begin calls for resources shared by the frame.</summary>
|
|
internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResources
|
|
{
|
|
private readonly TextureCache? _textures;
|
|
private readonly WbDrawDispatcher? _dispatcher;
|
|
private readonly EnvCellRenderer? _environmentCells;
|
|
private readonly PortalDepthMaskRenderer? _portalDepth;
|
|
private readonly ClipFrame? _clip;
|
|
private readonly TerrainModernRenderer? _terrain;
|
|
private readonly SceneLightingUboBinding? _lighting;
|
|
public RuntimeRenderFrameBeginResources(
|
|
TextureCache? textures,
|
|
WbDrawDispatcher? dispatcher,
|
|
EnvCellRenderer? environmentCells,
|
|
PortalDepthMaskRenderer? portalDepth,
|
|
ClipFrame? clip,
|
|
TerrainModernRenderer? terrain,
|
|
SceneLightingUboBinding? lighting)
|
|
{
|
|
_textures = textures;
|
|
_dispatcher = dispatcher;
|
|
_environmentCells = environmentCells;
|
|
_portalDepth = portalDepth;
|
|
_clip = clip;
|
|
_terrain = terrain;
|
|
_lighting = lighting;
|
|
}
|
|
|
|
public void Begin(int gpuSlot)
|
|
{
|
|
_textures?.BeginCompositeTextureFrame();
|
|
_textures?.TickCompositeTextureCache();
|
|
_dispatcher?.BeginFrame(gpuSlot);
|
|
_environmentCells?.BeginFrame(gpuSlot);
|
|
_portalDepth?.BeginFrame(gpuSlot);
|
|
// The world-HUD and retained-UI TextRenderers no longer take a
|
|
// per-slot begin: Campaign V slice V4a ported them onto the shared
|
|
// IGpuFrame ring (see GpuDeviceFrameLifetime), which is reset once
|
|
// per frame by IGpuDevice.BeginFrame() rather than per renderer.
|
|
_clip?.BeginFrame(gpuSlot);
|
|
_terrain?.BeginFrame(gpuSlot);
|
|
_lighting?.BeginFrame(gpuSlot);
|
|
}
|
|
}
|
|
|
|
internal interface IRenderFramePortalStateSource
|
|
{
|
|
bool IsPortalViewportVisible { get; }
|
|
|
|
uint ActiveDestinationCell { get; }
|
|
}
|
|
|
|
internal sealed class LocalPlayerTeleportRenderStateSource
|
|
: IRenderFramePortalStateSource
|
|
{
|
|
private readonly LocalPlayerTeleportController _teleport;
|
|
private readonly IRenderLoginStateSource _login;
|
|
|
|
public LocalPlayerTeleportRenderStateSource(
|
|
LocalPlayerTeleportController teleport,
|
|
IRenderLoginStateSource login)
|
|
{
|
|
_teleport = teleport ?? throw new ArgumentNullException(nameof(teleport));
|
|
_login = login ?? throw new ArgumentNullException(nameof(login));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The frame presents the portal-viewport shape (opaque black clear, no
|
|
/// world draw, retained UI on top) when the tunnel scene is visible OR
|
|
/// while a live login is still pre-world. The second arm is retail's
|
|
/// pre-player gameplay screen: after char-select Enter queues UI mode
|
|
/// 0x10000008 (<c>CM_Login::SendNotice_BeginEnterWorld @ 0x006AD810</c>
|
|
/// from <c>CPlayerSystem::LogOnCharacter @ 0x0055F890</c>), the SmartBox
|
|
/// has no player and draws NO world — the screen behind the UI is black
|
|
/// until <c>SmartBox::teleport_in_progress @ 0x00451C20</c> goes high at
|
|
/// CreatePlayer and <c>gmSmartBoxUI::UseTime @ 0x004D6EAB</c> begins the
|
|
/// tunnel in the same tick. The former sky-only "waiting for login"
|
|
/// backdrop had no retail counterpart and presented as the gate's
|
|
/// entry-edge VOID (2026-08-17). Both flags flip on the update thread
|
|
/// (the login activation tick flips ChaseModeEverEntered AND makes the
|
|
/// tunnel visible before the next render), so the cover → tunnel → world
|
|
/// sequence swaps atomically per frame.
|
|
///
|
|
/// <para>
|
|
/// Enter-click round (2026-08-17): with the click-armed login tunnel
|
|
/// (registered user-directed deviation — see
|
|
/// <c>ILocalPlayerTeleportNetworkSink.ArmLoginTunnel</c>) the tunnel
|
|
/// scene becomes visible AT the Enter click, so retail's bare-black
|
|
/// CreatePlayer window normally never presents; the second arm remains
|
|
/// the char-select backdrop and the fallback for any unarmed pre-world
|
|
/// frame.
|
|
/// </para>
|
|
/// </summary>
|
|
public bool IsPortalViewportVisible =>
|
|
_teleport.IsPortalViewportVisible || _login.IsWaitingForLogin;
|
|
|
|
public uint ActiveDestinationCell => _teleport.ActiveDestinationCell;
|
|
}
|
|
|
|
// Campaign V slice V11 deleted RuntimeRenderFrameClearPhase, the raw-GL
|
|
// IRenderFrameClearPhase implementation (glClearColor/glClear +
|
|
// IRenderFrameGlState.RestoreFrameDefaults): it had zero remaining
|
|
// construction sites — VulkanRenderFrameClearPhase (VulkanCompositionFramePhases.cs)
|
|
// is the sole production implementer, expressing the same atmosphere-clear
|
|
// and portal-viewport-black logic as a Vulkan pass load op instead.
|
|
|
|
internal interface IRenderLoginStateSource
|
|
{
|
|
bool IsWaitingForLogin { get; }
|
|
}
|
|
|
|
internal sealed class RenderLoginStateSource : IRenderLoginStateSource
|
|
{
|
|
private readonly bool _liveMode;
|
|
private readonly ILocalPlayerModeSource _mode;
|
|
|
|
public RenderLoginStateSource(bool liveMode, ILocalPlayerModeSource mode)
|
|
{
|
|
_liveMode = liveMode;
|
|
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
|
|
}
|
|
|
|
public bool IsWaitingForLogin => _liveMode && !_mode.ChaseModeEverEntered;
|
|
}
|
|
|
|
internal interface ILoginRevealCellSource
|
|
{
|
|
bool TryGet(out uint cellId);
|
|
}
|
|
|
|
internal sealed class LiveLoginRevealCellSource : ILoginRevealCellSource
|
|
{
|
|
private readonly LiveEntityRuntime _entities;
|
|
private readonly ILocalPlayerIdentitySource _identity;
|
|
|
|
public LiveLoginRevealCellSource(
|
|
LiveEntityRuntime entities,
|
|
ILocalPlayerIdentitySource identity)
|
|
{
|
|
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
|
|
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
|
|
}
|
|
|
|
public bool TryGet(out uint cellId)
|
|
{
|
|
cellId = 0;
|
|
if (!_entities.TryGetSnapshot(_identity.ServerGuid, out var playerSpawn)
|
|
|| playerSpawn.Position is not { } position)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
cellId = position.LandblockId;
|
|
return cellId != 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>Render-thread mesh publication, reveal evaluation, and VFX begin.</summary>
|
|
internal sealed class RuntimeRenderFrameLivePreparation
|
|
: IRenderFrameLivePreparation
|
|
{
|
|
private readonly TextureCache? _textures;
|
|
private readonly WbMeshAdapter? _meshes;
|
|
private readonly WorldRevealCoordinator? _worldReveal;
|
|
private readonly IRenderFramePortalStateSource _portal;
|
|
private readonly IRenderLoginStateSource _login;
|
|
private readonly ILoginRevealCellSource _loginCell;
|
|
private readonly ParticleRenderer? _particles;
|
|
private readonly FrameProfiler _profiler;
|
|
private readonly bool _diagnosticsEnabled;
|
|
private readonly RollingTimingSampleWindow _uploadTiming = new(256);
|
|
|
|
public RuntimeRenderFrameLivePreparation(
|
|
TextureCache? textures,
|
|
WbMeshAdapter? meshes,
|
|
WorldRevealCoordinator? worldReveal,
|
|
IRenderFramePortalStateSource portal,
|
|
IRenderLoginStateSource login,
|
|
ILoginRevealCellSource loginCell,
|
|
ParticleRenderer? particles,
|
|
FrameProfiler profiler,
|
|
bool diagnosticsEnabled)
|
|
{
|
|
_textures = textures;
|
|
_meshes = meshes;
|
|
_worldReveal = worldReveal;
|
|
_portal = portal ?? throw new ArgumentNullException(nameof(portal));
|
|
_login = login ?? throw new ArgumentNullException(nameof(login));
|
|
_loginCell = loginCell ?? throw new ArgumentNullException(nameof(loginCell));
|
|
_particles = particles;
|
|
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
|
|
_diagnosticsEnabled = diagnosticsEnabled;
|
|
}
|
|
|
|
public RollingTimingPercentiles UploadTiming => _uploadTiming.Snapshot();
|
|
|
|
public void Prepare(int gpuSlot)
|
|
{
|
|
_textures?.TickSurfaceHistogramDumpIfEnabled();
|
|
long start = _diagnosticsEnabled
|
|
? System.Diagnostics.Stopwatch.GetTimestamp()
|
|
: 0L;
|
|
using (var uploadStage = _profiler.BeginStage(FrameStage.Upload))
|
|
{
|
|
_meshes?.Tick();
|
|
uint revealCell = _portal.ActiveDestinationCell;
|
|
if (revealCell == 0
|
|
&& _login.IsWaitingForLogin
|
|
&& _loginCell.TryGet(out uint loginCell))
|
|
{
|
|
revealCell = loginCell;
|
|
}
|
|
|
|
if (revealCell != 0)
|
|
_worldReveal?.PrepareAndEvaluate(revealCell);
|
|
_particles?.BeginFrame(gpuSlot);
|
|
}
|
|
|
|
if (_diagnosticsEnabled)
|
|
{
|
|
_uploadTiming.PushStopwatchTicks(
|
|
System.Diagnostics.Stopwatch.GetTimestamp() - start);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Owns the render-time weather clock and deterministic day index.</summary>
|
|
internal sealed class RenderWeatherFrameController : IRenderWeatherFramePhase
|
|
{
|
|
private readonly WorldTimeService _worldTime;
|
|
private readonly WeatherSystem _weather;
|
|
private double _elapsedSeconds;
|
|
|
|
public RenderWeatherFrameController(
|
|
WorldTimeService worldTime,
|
|
WeatherSystem weather)
|
|
{
|
|
_worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime));
|
|
_weather = weather ?? throw new ArgumentNullException(nameof(weather));
|
|
}
|
|
|
|
internal double ElapsedSeconds => _elapsedSeconds;
|
|
|
|
public void Tick(double deltaSeconds)
|
|
{
|
|
DerethDateTime.Calendar calendar = _worldTime.CurrentCalendar;
|
|
int dayIndex = calendar.Year
|
|
* (DerethDateTime.DaysInAMonth * DerethDateTime.MonthsInAYear)
|
|
+ (int)calendar.Month * DerethDateTime.DaysInAMonth
|
|
+ (calendar.Day - 1);
|
|
_weather.Tick(
|
|
nowSeconds: _elapsedSeconds,
|
|
dayIndex,
|
|
dtSeconds: (float)deltaSeconds);
|
|
_elapsedSeconds += deltaSeconds;
|
|
}
|
|
}
|