acdream/src/AcDream.App/Rendering/PortalTunnelPresentation.cs
Erik 823936ec31 fix(streaming): preserve portal destination ownership
Detach old-world spatial ownership atomically, prioritize destination retirement dependencies, and reveal the viewport at the retail transition edge. Give private paperdoll views independent mesh ownership and retain dormant ACE entities so portal revisits preserve server objects without extending active GPU lifetimes.
2026-07-25 08:35:12 +02:00

427 lines
14 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.App.UI;
using AcDream.Content.Vfx;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion;
using AcDream.Core.World;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// The retail portal-space CreatureMode scene owned by
/// <c>gmSmartBoxUI</c> (<c>PostInit 0x004D6B60</c>,
/// <c>UseTime 0x004D6E30</c>). It is a synthetic DAT Setup animated at
/// 40 frames/second and drawn as a replacement 3-D viewport beneath the
/// retained gameplay UI.
/// </summary>
public sealed class PortalTunnelPresentation : IDisposable
{
public const uint SetupClientEnum = 0x10000001u;
public const uint AnimationClientEnum = 0x10000002u;
public const uint ClientEnumCategory = 7u;
// UIViewportObject::DrawContent @ 0x006950A5 calls
// RenderDeviceD3D::Clear(4, RGBAColor_Black, 1). Clear @ 0x0059FD30
// maps retail flag 4 to D3DCLEAR_ZBUFFER only: portal space deliberately
// preserves the black color target established by SceneTool::BeginScene's
// whole-frame Clear(7) while replacing the hidden world viewport.
internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit;
private const uint SyntheticEntityId = 0xFFFF_FF01u;
private const uint SyntheticLandblockId = 0u;
private const float RotationDurationMin = 0.6f;
private const float RotationDurationMax = 1.8f;
private static readonly HashSet<uint> AnimatedIds = new() { SyntheticEntityId };
private readonly GL _gl;
private readonly WbDrawDispatcher _dispatcher;
private readonly SceneLightingUboBinding _lightUbo;
private readonly Setup _setup;
private readonly uint _setupDid;
private readonly uint _animationDid;
private readonly CSequence _sequence;
private readonly PortalAnimationHookQueue _animationHooks;
private readonly WorldEntity _entity;
private readonly PortalTunnelCamera _camera = new();
private readonly Random _random;
private readonly Action<string?>? _displayNotice;
private IDisposable? _displayNoticeLifetime;
private readonly SyntheticEntityMeshReferenceOwner _meshReferences;
private bool _visible;
private double _rotationElapsed;
private double _rotationDuration;
private float _rotationStartAngle;
private float _rotationEndAngle;
private float _rotationCurrentAngle;
private bool _waitCueVisible;
private bool _disposeRequested;
private bool _disposing;
private bool _disposed;
private PortalTunnelPresentation(
GL gl,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
Setup setup,
uint setupDid,
uint animationDid,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
Random random,
Action<string?>? displayNotice,
IDisposable? displayNoticeLifetime)
{
_gl = gl;
_dispatcher = dispatcher;
_lightUbo = lightUbo;
_setup = setup;
_setupDid = setupDid;
_animationDid = animationDid;
_sequence = new CSequence(animationLoader);
_animationHooks = new PortalAnimationHookQueue(hookSink, SyntheticEntityId);
_sequence.HookObj = _animationHooks;
_random = random;
_displayNotice = displayNotice;
_displayNoticeLifetime = displayNoticeLifetime;
_entity = new WorldEntity
{
Id = SyntheticEntityId,
SourceGfxObjOrSetupId = setupDid,
Position = Vector3.Zero,
Rotation = Quaternion.Identity,
MeshRefs = SetupMesh.Flatten(setup),
};
_meshReferences = new SyntheticEntityMeshReferenceOwner(
meshAdapter,
setup.Parts.Select(part => (ulong)(uint)part));
}
/// <summary>
/// Resolve retail's enum-mapped portal Setup and animation through the
/// installed DATs. Retail dereferences this scene unconditionally; missing
/// required assets therefore fail startup with an actionable diagnostic.
/// No substitute tunnel is fabricated.
/// </summary>
public static PortalTunnelPresentation CreateRequired(
GL gl,
IDatReaderWriter dats,
IAnimationLoader animationLoader,
IAnimationHookSink hookSink,
WbDrawDispatcher dispatcher,
SceneLightingUboBinding lightUbo,
IWbMeshAdapter meshAdapter,
Action<string?>? displayNotice = null,
IDisposable? displayNoticeLifetime = null,
Random? random = null)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(dats);
ArgumentNullException.ThrowIfNull(animationLoader);
ArgumentNullException.ThrowIfNull(hookSink);
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(lightUbo);
ArgumentNullException.ThrowIfNull(meshAdapter);
uint setupDid = RetailDataIdResolver.Resolve(dats, SetupClientEnum, ClientEnumCategory);
uint animationDid = RetailDataIdResolver.Resolve(dats, AnimationClientEnum, ClientEnumCategory);
Setup? setup = setupDid == 0u ? null : dats.Get<Setup>(setupDid);
Animation? animation = animationDid == 0u
? null
: animationLoader.LoadAnimation(animationDid);
EnsureRequiredAssets(
setupDid,
setup is not null,
animationDid,
animation is not null);
return new PortalTunnelPresentation(
gl,
dispatcher,
lightUbo,
meshAdapter,
setup!,
setupDid,
animationDid,
animationLoader,
hookSink,
random ?? Random.Shared,
displayNotice,
displayNoticeLifetime);
}
internal static void EnsureRequiredAssets(
uint setupDid,
bool setupLoaded,
uint animationDid,
bool animationLoaded)
{
if (setupLoaded && animationLoaded)
return;
throw new InvalidOperationException(
"[portal-space] required retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setupLoaded ? "ok" : "missing")}), "
+ $"animation=0x{animationDid:X8} ({(animationLoaded ? "ok" : "missing")})");
}
public bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid;
public uint AnimationDid => _animationDid;
/// <summary>
/// Publishes the synthetic scene's mesh ownership after the presentation
/// itself has been stored by <c>GameWindow</c>. A partial acquisition stays
/// reachable and is resumed or released by the same lifetime owner.
/// </summary>
internal void PrepareResources()
{
ThrowIfDisposed();
_meshReferences.Acquire();
}
/// <summary>
/// Retail <c>set_sequence_animation(anim, clear=1, low=1, fps=40)</c>
/// on the edge where portal space becomes visible.
/// </summary>
public void Enter()
{
ThrowIfDisposed();
_animationHooks.Clear();
_sequence.ClearAnimations();
_sequence.AppendAnimation(new AnimData
{
AnimId = (QualifiedDataId<Animation>)_animationDid,
LowFrame = 1,
HighFrame = -1,
Framerate = TeleportAnimSequencer.TunnelFramesPerSecond,
});
_rotationElapsed = 0.0;
_rotationDuration = 0.0;
_rotationStartAngle = 0f;
_rotationEndAngle = 0f;
_rotationCurrentAngle = 0f;
_camera.DirectionDegrees = 0f;
SetWaitCue(false);
_visible = true;
RebuildPose();
}
/// <summary>Hide the CreatureMode viewport and clear its sequence.</summary>
public void Exit()
{
if (_disposed)
return;
_visible = false;
SetWaitCue(false);
_animationHooks.Clear();
_sequence.ClearAnimations();
}
public void Tick(float dt)
{
if (!_visible || dt < 0f)
return;
_sequence.Update(dt, frame: null);
RebuildPose();
_animationHooks.Drain(Vector3.Zero);
TickRotation(dt);
}
public void SetWaitCue(bool visible)
{
if (_waitCueVisible == visible)
return;
_waitCueVisible = visible;
_displayNotice?.Invoke(
visible ? "In Portal Space - Please Wait..." : null);
}
/// <summary>
/// Draw retail portal space into the active viewport. The caller suppresses
/// the normal world viewport while this scene is visible, then draws the
/// retained UI above it.
/// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{
if (!_visible || width <= 0 || height <= 0 || _entity.MeshRefs.Count == 0)
return;
_camera.Aspect = width / (float)height;
_camera.UseSmartBoxFov(smartBoxProjection);
using var scope = new GLStateScope(_gl);
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
_gl.Clear(RetailViewportClearMask);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_gl.Disable(EnableCap.Blend);
UploadRetailLight();
// The dispatcher is shared with the world pass. Portal space is its
// own CreatureMode scene: it has no world-cell clip routing and no
// world point lights, only the distant light installed above.
_dispatcher.ClearClipRouting();
_dispatcher.SetSceneLights(null);
var entries = new (uint, Vector3, Vector3, IReadOnlyList<WorldEntity>, IReadOnlyDictionary<uint, WorldEntity>?)[]
{
(SyntheticLandblockId,
new Vector3(-16f, -16f, -16f),
new Vector3(16f, 16f, 16f),
new WorldEntity[] { _entity },
null),
};
_dispatcher.Draw(
_camera,
entries,
frustum: null,
neverCullLandblockId: SyntheticLandblockId,
visibleCellIds: null,
animatedEntityIds: AnimatedIds);
}
private void TickRotation(float dt)
{
_rotationElapsed += dt;
if (_rotationElapsed >= _rotationDuration)
{
_rotationCurrentAngle = _rotationEndAngle;
_rotationElapsed = 0.0;
_rotationDuration = NextDouble(RotationDurationMin, RotationDurationMax);
_rotationStartAngle = _rotationCurrentAngle;
_rotationEndAngle = (float)NextDouble(0.0, 360.0);
if (_waitCueVisible)
_displayNotice?.Invoke("In Portal Space - Please Wait...");
}
else
{
float t = _rotationDuration <= 0.0
? 1f
: (float)(_rotationElapsed / _rotationDuration);
float level = TeleportAnimSequencer.GetRetailAnimationLevel(t) / 1024f;
_rotationCurrentAngle = _rotationStartAngle
+ ((_rotationEndAngle - _rotationStartAngle) * level);
}
_camera.DirectionDegrees = _rotationCurrentAngle;
}
private double NextDouble(double min, double max) => min + (_random.NextDouble() * (max - min));
private void RebuildPose()
{
AnimationFrame? frame = _sequence.GetCurrAnimframe();
_entity.MeshRefs = SetupMesh.Flatten(_setup, frame);
}
private void UploadRetailLight()
{
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, -1.9f, 0.65f));
_lightUbo.Upload(new SceneLightingUbo
{
Light0 = new UboLight
{
PosAndKind = Vector4.Zero,
DirAndRange = new Vector4(direction, 1e9f),
ColorAndIntensity = new Vector4(1f, 1f, 1f, 2f),
ConeAngleEtc = Vector4.Zero,
},
CellAmbient = new Vector4(0.3f, 0.3f, 0.3f, 1f),
FogParams = new Vector4(1e9f, 1e9f, 0f, 0f),
FogColor = Vector4.Zero,
CameraAndTime = new Vector4(PortalTunnelCamera.RetailEye, 0f),
});
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
/// <summary>
/// Retail stages animation hooks while advancing the sequence, then drains
/// them after the object's current pose has been committed. Portal space
/// has no live-world record, so it owns this small equivalent queue and
/// forwards its DAT-authored hooks through the shared hook router.
/// </summary>
private sealed class PortalAnimationHookQueue : IAnimHookQueue
{
private readonly IAnimationHookSink _sink;
private readonly uint _ownerId;
private readonly List<AnimationHook> _pending = new();
public PortalAnimationHookQueue(IAnimationHookSink sink, uint ownerId)
{
_sink = sink;
_ownerId = ownerId;
}
public void AddAnimHook(AnimationHook hook) => _pending.Add(hook);
public void AddAnimDoneHook()
{
// The tunnel animation is retail's cyclic tail, so AnimDone is
// not observable during the portal-space presentation.
}
public void Drain(Vector3 worldPosition)
{
for (int i = 0; i < _pending.Count; i++)
_sink.OnHook(_ownerId, worldPosition, _pending[i]);
_pending.Clear();
}
public void Clear() => _pending.Clear();
}
public void Dispose()
{
if (_disposed || _disposing)
return;
_disposeRequested = true;
_disposing = true;
try
{
_visible = false;
SetWaitCue(false);
_animationHooks.Clear();
_sequence.ClearAnimations();
_meshReferences.Dispose();
if (_meshReferences.IsDisposed)
{
_displayNoticeLifetime?.Dispose();
_displayNoticeLifetime = null;
_disposed = true;
}
}
finally
{
_disposing = false;
}
}
}