385 lines
12 KiB
C#
385 lines
12 KiB
C#
using System.Globalization;
|
|
|
|
namespace AcDream.Runtime.World;
|
|
|
|
/// <summary>
|
|
/// Canonical presentation-independent owner of one login/portal reveal
|
|
/// generation. The graphical or headless host supplies typed destination
|
|
/// readiness acknowledgements; Runtime owns which generation and destination
|
|
/// those acknowledgements are allowed to advance.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail ordering follows <c>SmartBox::TeleportPlayer @ 0x00453910</c>,
|
|
/// <c>SmartBox::UseTime @ 0x00455410</c>, and
|
|
/// <c>gmSmartBoxUI::EndTeleportAnimation @ 0x004D65A0</c>. Destination
|
|
/// materialization resumes simulation before the later normal-world viewport
|
|
/// edge.
|
|
/// </remarks>
|
|
public sealed class RuntimeWorldTransitState
|
|
: IRuntimePortalView
|
|
{
|
|
public static readonly TimeSpan RetailWaitCueDelay =
|
|
TimeSpan.FromSeconds(5);
|
|
|
|
private readonly Action<string> _log;
|
|
private RuntimePortalSnapshot _snapshot = RuntimePortalSnapshot.Idle;
|
|
private long _nextGeneration;
|
|
|
|
public RuntimeWorldTransitState(Action<string>? log = null)
|
|
{
|
|
_log = log ?? (_ => { });
|
|
}
|
|
|
|
public RuntimePortalSnapshot Snapshot => _snapshot;
|
|
public bool IsWorldSimulationAvailable =>
|
|
_snapshot.WorldSimulationAvailable;
|
|
public int DiagnosticFailureCount { get; private set; }
|
|
public Exception? LastDiagnosticFailure { get; private set; }
|
|
|
|
public long BeginReveal(
|
|
RuntimePortalKind kind,
|
|
uint destinationCell)
|
|
{
|
|
if (kind is RuntimePortalKind.None)
|
|
throw new ArgumentOutOfRangeException(nameof(kind));
|
|
if (destinationCell == 0u)
|
|
throw new ArgumentOutOfRangeException(nameof(destinationCell));
|
|
|
|
if (_snapshot.Generation != 0
|
|
&& !_snapshot.Cancelled
|
|
&& !_snapshot.WorldViewportObserved)
|
|
{
|
|
Log("superseded", _snapshot);
|
|
}
|
|
|
|
long generation = checked(++_nextGeneration);
|
|
int failures = _snapshot.InvariantFailureCount;
|
|
int materializations = _snapshot.PortalMaterializationCount;
|
|
_snapshot = new RuntimePortalSnapshot(
|
|
generation,
|
|
kind,
|
|
new RuntimeDestinationReadiness(
|
|
generation,
|
|
destinationCell,
|
|
IsIndoor: IsIndoor(destinationCell),
|
|
IsUnhydratable: false,
|
|
RequiredRenderRadius: 0,
|
|
IsRenderNeighborhoodReady: false,
|
|
AreCompositeTexturesReady: false,
|
|
IsCollisionReady: false),
|
|
Materialized: false,
|
|
Completed: false,
|
|
Cancelled: false,
|
|
WorldViewportObserved: false,
|
|
WorldSimulationAvailable: false,
|
|
InvariantFailureCount: failures,
|
|
WaitCueShown: false,
|
|
PortalMaterializationCount: materializations);
|
|
Log("begin", _snapshot);
|
|
return generation;
|
|
}
|
|
|
|
public bool AcknowledgeDestinationReadiness(
|
|
in RuntimeDestinationReadiness acknowledgement)
|
|
{
|
|
if (!ValidateActive(
|
|
acknowledgement.Generation,
|
|
acknowledgement.DestinationCell,
|
|
"readiness"))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (_snapshot.Readiness.IsReady)
|
|
{
|
|
if (_snapshot.Readiness == acknowledgement)
|
|
return false;
|
|
|
|
FailInvariant(
|
|
"readiness-changed-after-ready",
|
|
$"accepted={DescribeReadiness(_snapshot.Readiness)} "
|
|
+ $"actual={DescribeReadiness(acknowledgement)}");
|
|
return false;
|
|
}
|
|
|
|
bool isIndoor = IsIndoor(acknowledgement.DestinationCell);
|
|
int requiredRenderRadius = isIndoor ? 0 : 1;
|
|
if (acknowledgement.IsIndoor != isIndoor
|
|
|| acknowledgement.RequiredRenderRadius
|
|
!= requiredRenderRadius)
|
|
{
|
|
FailInvariant(
|
|
"invalid-readiness-shape",
|
|
$"indoor={acknowledgement.IsIndoor} "
|
|
+ $"expectedIndoor={isIndoor} "
|
|
+ $"radius={acknowledgement.RequiredRenderRadius} "
|
|
+ $"expectedRadius={requiredRenderRadius}");
|
|
return false;
|
|
}
|
|
|
|
if (_snapshot.Readiness == acknowledgement)
|
|
return false;
|
|
|
|
_snapshot = _snapshot with { Readiness = acknowledgement };
|
|
Log("readiness", _snapshot);
|
|
return true;
|
|
}
|
|
|
|
public bool AcknowledgeMaterialized(
|
|
long generation,
|
|
uint destinationCell)
|
|
{
|
|
if (!ValidateActive(generation, destinationCell, "materialized"))
|
|
return false;
|
|
if (_snapshot.Materialized)
|
|
return false;
|
|
if (!_snapshot.Readiness.IsReady)
|
|
{
|
|
FailInvariant("materialized-before-ready", null);
|
|
return false;
|
|
}
|
|
|
|
int count = _snapshot.PortalMaterializationCount;
|
|
if (_snapshot.Kind == RuntimePortalKind.Portal)
|
|
count = checked(count + 1);
|
|
_snapshot = _snapshot with
|
|
{
|
|
Materialized = true,
|
|
WorldSimulationAvailable = true,
|
|
PortalMaterializationCount = count,
|
|
};
|
|
Log("materialized", _snapshot);
|
|
return true;
|
|
}
|
|
|
|
public bool AcknowledgeWorldViewportVisible(long generation)
|
|
{
|
|
if (!ValidateGeneration(
|
|
generation,
|
|
"world-visible",
|
|
allowCompleted: true))
|
|
{
|
|
return false;
|
|
}
|
|
if (_snapshot.WorldViewportObserved)
|
|
return false;
|
|
if (!_snapshot.Readiness.IsReady)
|
|
{
|
|
FailInvariant("viewport-before-ready", null);
|
|
return false;
|
|
}
|
|
|
|
_snapshot = _snapshot with { WorldViewportObserved = true };
|
|
Log("world-visible", _snapshot);
|
|
return true;
|
|
}
|
|
|
|
public bool ObserveWait(long generation, TimeSpan elapsed)
|
|
{
|
|
if (generation == 0
|
|
|| generation != _snapshot.Generation
|
|
|| _snapshot.Cancelled
|
|
|| _snapshot.Completed
|
|
|| elapsed < RetailWaitCueDelay)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!_snapshot.WaitCueShown)
|
|
{
|
|
_snapshot = _snapshot with { WaitCueShown = true };
|
|
SafeLog(
|
|
$"[world-reveal] event=wait-cue "
|
|
+ $"elapsedMs={elapsed.TotalMilliseconds:F0} "
|
|
+ Describe(_snapshot));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public bool Complete(long generation)
|
|
{
|
|
if (!ValidateGeneration(
|
|
generation,
|
|
"complete",
|
|
allowCompleted: true))
|
|
{
|
|
return false;
|
|
}
|
|
if (_snapshot.Completed)
|
|
return false;
|
|
if (!_snapshot.Readiness.IsReady)
|
|
{
|
|
FailInvariant("complete-before-ready", null);
|
|
return false;
|
|
}
|
|
if (_snapshot.Kind == RuntimePortalKind.Portal
|
|
&& !_snapshot.Materialized)
|
|
{
|
|
FailInvariant("portal-complete-before-materialized", null);
|
|
return false;
|
|
}
|
|
|
|
_snapshot = _snapshot with
|
|
{
|
|
Completed = true,
|
|
WorldSimulationAvailable = true,
|
|
};
|
|
Log("complete", _snapshot);
|
|
return true;
|
|
}
|
|
|
|
public bool Cancel(long generation)
|
|
{
|
|
if (generation == 0
|
|
|| generation != _snapshot.Generation
|
|
|| _snapshot.Cancelled
|
|
|| _snapshot.Completed)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
_snapshot = _snapshot with
|
|
{
|
|
Cancelled = true,
|
|
WorldSimulationAvailable = true,
|
|
};
|
|
Log("cancel", _snapshot);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears session-visible state while retaining the monotonically
|
|
/// increasing generation counter so a delayed prior-session host
|
|
/// acknowledgement cannot match a future generation.
|
|
/// </summary>
|
|
public void ResetSession()
|
|
{
|
|
_snapshot = RuntimePortalSnapshot.Idle;
|
|
}
|
|
|
|
private bool ValidateActive(
|
|
long generation,
|
|
uint destinationCell,
|
|
string eventName)
|
|
{
|
|
if (!ValidateGeneration(
|
|
generation,
|
|
eventName,
|
|
allowCompleted: false))
|
|
{
|
|
return false;
|
|
}
|
|
if (destinationCell == 0u
|
|
|| destinationCell != _snapshot.DestinationCell)
|
|
{
|
|
FailInvariant(
|
|
$"{eventName}-destination-mismatch",
|
|
$"expected=0x{_snapshot.DestinationCell:X8} "
|
|
+ $"actual=0x{destinationCell:X8}");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool ValidateGeneration(
|
|
long generation,
|
|
string eventName,
|
|
bool allowCompleted)
|
|
{
|
|
if (generation == 0 || generation != _snapshot.Generation)
|
|
{
|
|
LogRejected(
|
|
$"{eventName}-generation-mismatch",
|
|
$"expected={_snapshot.Generation} actual={generation}");
|
|
return false;
|
|
}
|
|
if (_snapshot.Cancelled
|
|
|| (!allowCompleted && _snapshot.Completed))
|
|
{
|
|
LogRejected(
|
|
$"{eventName}-after-terminal",
|
|
$"completed={_snapshot.Completed} "
|
|
+ $"cancelled={_snapshot.Cancelled}");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private void FailInvariant(string reason, string? detail)
|
|
{
|
|
_snapshot = _snapshot with
|
|
{
|
|
InvariantFailureCount =
|
|
checked(_snapshot.InvariantFailureCount + 1),
|
|
};
|
|
string suffix =
|
|
string.IsNullOrWhiteSpace(detail) ? string.Empty : $" {detail}";
|
|
SafeLog(
|
|
$"[world-reveal] event=invariant-failure "
|
|
+ $"reason={reason}{suffix} {Describe(_snapshot)}");
|
|
}
|
|
|
|
private void LogRejected(string reason, string? detail)
|
|
{
|
|
string suffix =
|
|
string.IsNullOrWhiteSpace(detail) ? string.Empty : $" {detail}";
|
|
SafeLog(
|
|
$"[world-reveal] event=rejected "
|
|
+ $"reason={reason}{suffix} {Describe(_snapshot)}");
|
|
}
|
|
|
|
private void Log(string eventName, RuntimePortalSnapshot snapshot) =>
|
|
SafeLog($"[world-reveal] event={eventName} {Describe(snapshot)}");
|
|
|
|
private void SafeLog(string message)
|
|
{
|
|
try
|
|
{
|
|
_log(message);
|
|
}
|
|
catch (Exception error)
|
|
{
|
|
DiagnosticFailureCount =
|
|
checked(DiagnosticFailureCount + 1);
|
|
LastDiagnosticFailure = error;
|
|
}
|
|
}
|
|
|
|
private static string Describe(RuntimePortalSnapshot snapshot)
|
|
{
|
|
RuntimeDestinationReadiness ready = snapshot.Readiness;
|
|
return string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"generation={snapshot.Generation} kind={snapshot.Kind} "
|
|
+ $"cell=0x{ready.DestinationCell:X8} "
|
|
+ $"indoor={ready.IsIndoor} "
|
|
+ $"radius={ready.RequiredRenderRadius} "
|
|
+ $"unhydratable={ready.IsUnhydratable} "
|
|
+ $"render={ready.IsRenderNeighborhoodReady} "
|
|
+ $"composites={ready.AreCompositeTexturesReady} "
|
|
+ $"collision={ready.IsCollisionReady} ready={ready.IsReady} "
|
|
+ $"materialized={snapshot.Materialized} "
|
|
+ $"completed={snapshot.Completed} "
|
|
+ $"cancelled={snapshot.Cancelled} "
|
|
+ $"visible={snapshot.WorldViewportObserved} "
|
|
+ $"simulation={snapshot.WorldSimulationAvailable} "
|
|
+ $"failures={snapshot.InvariantFailureCount}");
|
|
}
|
|
|
|
private static string DescribeReadiness(
|
|
in RuntimeDestinationReadiness readiness) =>
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"indoor={readiness.IsIndoor},"
|
|
+ $"unhydratable={readiness.IsUnhydratable},"
|
|
+ $"radius={readiness.RequiredRenderRadius},"
|
|
+ $"render={readiness.IsRenderNeighborhoodReady},"
|
|
+ $"composites={readiness.AreCompositeTexturesReady},"
|
|
+ $"collision={readiness.IsCollisionReady}");
|
|
|
|
private static bool IsIndoor(uint cellId) =>
|
|
(cellId & 0xFFFFu) >= 0x0100u;
|
|
}
|