Item 2: retail's portal-space "In Portal Space..." notice is the SpewBox (ECM_UI::SendNotice_DisplayStringInfo(0x1A,...) -> AddTextToScroll(str, 0x1A, 1, 0), hardcoded to the SpewBox per the decomp), not a dedicated centered overlay. PortalWaitNoticeController and its lease are deleted; PortalTunnelPresentation's per-rotation-segment cadence now writes straight into RuntimeCommunicationState.AddText(ClientLocal) -- the SpewBox's own dedupe-at-index-0 handles the repetition exactly as retail's does. Register row AP-184 records the surface fix and the AP-178 scope extension. Items 4+5: /help text was partially fabricated -- the user caught the "/help death" meta-message. Generalized tools/pdb-extract/sweep_weenie_strings.py to decode narrow PStringBase<char> literals (the ClientCommunicationSystem::Help* family's shape) alongside its original UTF-16LE support, then swept every HelpXxxGroup function's exact byte extent against the PDB-paired acclient.exe. 4 of 7 group topics (death/status/text/allegiances) are now complete verbatim listings; the other 3 (channels/chatting/commands) keep an honest UNVERIFIED note citing HelpStupidChannelHack @0x0056f290 (a genuinely undecodable BN-mislabeled-fragment mechanism) instead of the old fabricated sentinel. 7 of ~35 channel one-liners are also now verbatim. ISSUES.md #364 tracks the remainder; RetailCommandHelpTableTests.cs pins every result byte-exact. Item 1: jump-in-air refusal still silent live is NOT reproduced and NOT speculatively fixed. Exhaustive static re-audit found the mechanism correct by construction (single-writer OnWalkable, exactly-once-per-frame Update()/Capture(), no interfering edge-history resets). A live headless repro (new jump-probe bot policy, real ACE connect) was blocked -- probeaccount2 has no character, and the graphical client already owned testaccount this session so the task's own fallback rule forbade using it. Two temporary probes are left behind ACDREAM_PROBE_JUMP=1 (blocked entirely in Headless by the existing multi-session static-state guard -- graphical-only for the next round). Item 3 confirmed fixed, no regression. Item 6 (resize: no diagonal cursors, cannot grow Y from bottom-right) folded into CH6a's existing scope. Full Release suite: 12,267 passed / 4 skipped / 0 failed (up from 12,221/4/0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
519 lines
20 KiB
C#
519 lines
20 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering.Gpu;
|
|
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;
|
|
|
|
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.
|
|
///
|
|
/// <para><b>Campaign V slice V6m.</b> This was the last raw-GL world-adjacent
|
|
/// renderer, and its GL arm was deleted at slice V11. Nothing about the scene
|
|
/// changed — the same synthetic Setup, the same 40 fps sequence, the same
|
|
/// rotation and the same distant light, through the same
|
|
/// <see cref="WbDrawDispatcher"/>. The RHI arm opens a pass of its own against
|
|
/// the backbuffer and publishes it on <see cref="IWorldPassScope"/> for the
|
|
/// span of the draw, exactly as the two offscreen viewports do
|
|
/// (<see cref="PrivateEntityViewportRenderer"/>, slice V6l).</para>
|
|
/// </summary>
|
|
public sealed class PortalTunnelPresentation : IDisposable
|
|
{
|
|
public const uint SetupClientEnum = 0x10000001u;
|
|
public const uint AnimationClientEnum = 0x10000002u;
|
|
public const uint ClientEnumCategory = 7u;
|
|
|
|
/// <summary>
|
|
/// The colour the RHI arm's pass loads with, and the one value that makes
|
|
/// its <c>Clear</c> load-op equivalent to GL's depth-only clear.
|
|
///
|
|
/// <para>Retail preserves the colour target rather than re-establishing it,
|
|
/// and so does the GL arm. A Vulkan pass cannot inherit an image the way a
|
|
/// bound framebuffer can under MSAA — the frame's world pass RESOLVES into
|
|
/// the swapchain image and stores <c>DontCare</c> into the multisampled
|
|
/// scratch, so a second multisampled pass declaring <c>Load</c> would load
|
|
/// undefined contents (plan §5.5.12 item 5). Re-clearing is exact instead of
|
|
/// approximate because of an invariant the frame graph enforces:
|
|
/// <c>RenderFrameFoundation.PortalViewportVisible</c> and this scene's own
|
|
/// <see cref="IsVisible"/> are the same value, read once at the top of the
|
|
/// frame, and <c>WorldSceneRenderer</c> returns without drawing when it is
|
|
/// set. So whenever this scene draws, the backbuffer holds exactly the
|
|
/// opaque black <c>SceneTool::BeginScene @ 0x0043DAD0</c> establishes and
|
|
/// nothing else — and clearing to that same black changes no pixel.</para>
|
|
/// </summary>
|
|
internal static readonly Vector4 RetailPortalSpaceClearColor = new(0f, 0f, 0f, 1f);
|
|
|
|
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 };
|
|
|
|
/// <summary>
|
|
/// The world pass scope this presentation's own pass publishes into for
|
|
/// the span of the draw. <see cref="WbDrawDispatcher"/> borrows its pass
|
|
/// from the scope rather than opening one, so a presentation that opens a
|
|
/// pass of its own has to publish it there. The raw-GL arm this used to be
|
|
/// optional for was deleted at Campaign V slice V11.
|
|
/// </summary>
|
|
private readonly IWorldPassScope _scope;
|
|
|
|
/// <summary>The frame this presentation's own pass is opened on.</summary>
|
|
private readonly ICurrentGpuFrameSource _frames;
|
|
|
|
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 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(
|
|
IWorldPassScope scope,
|
|
ICurrentGpuFrameSource frames,
|
|
WbDrawDispatcher dispatcher,
|
|
SceneLightingUboBinding lightUbo,
|
|
IWbMeshAdapter meshAdapter,
|
|
Setup setup,
|
|
uint setupDid,
|
|
uint animationDid,
|
|
IAnimationLoader animationLoader,
|
|
IAnimationHookSink hookSink,
|
|
Random random,
|
|
Action<string>? displayNotice)
|
|
{
|
|
_scope = scope;
|
|
_frames = frames;
|
|
_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;
|
|
|
|
_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.
|
|
///
|
|
/// <para>Internal since slice V6m: the RHI arm's two seams —
|
|
/// <see cref="IWorldPassScope"/> and <see cref="ICurrentGpuFrameSource"/> —
|
|
/// are internal RHI contracts, and the type itself keeps its visibility.
|
|
/// Composition is the only caller.</para>
|
|
/// </summary>
|
|
internal static PortalTunnelPresentation CreateRequired(
|
|
IWorldPassScope scope,
|
|
ICurrentGpuFrameSource frames,
|
|
IDatReaderWriter dats,
|
|
IAnimationLoader animationLoader,
|
|
IAnimationHookSink hookSink,
|
|
WbDrawDispatcher dispatcher,
|
|
SceneLightingUboBinding lightUbo,
|
|
IWbMeshAdapter meshAdapter,
|
|
Action<string>? displayNotice = null,
|
|
Random? random = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(scope);
|
|
ArgumentNullException.ThrowIfNull(frames);
|
|
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(
|
|
scope,
|
|
frames,
|
|
dispatcher,
|
|
lightUbo,
|
|
meshAdapter,
|
|
setup!,
|
|
setupDid,
|
|
animationDid,
|
|
animationLoader,
|
|
hookSink,
|
|
random ?? Random.Shared,
|
|
displayNotice);
|
|
}
|
|
|
|
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;
|
|
_waitCueVisible = false;
|
|
_visible = true;
|
|
RebuildPose();
|
|
}
|
|
|
|
/// <summary>Hide the CreatureMode viewport and clear its sequence.</summary>
|
|
public void Exit()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_visible = false;
|
|
_waitCueVisible = 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The hold-delay-gated arm/disarm <c>LocalPlayerTeleportController</c>
|
|
/// still drives every frame from <c>RuntimeWorldTransitState.ObserveWait</c>
|
|
/// (own telemetry: <c>RuntimePortalSnapshot.WaitCueShown</c>). This is
|
|
/// deliberately NOT the retail cue-emission path — see
|
|
/// <see cref="TickRotation"/>'s unconditional per-segment write (item D,
|
|
/// #329). Kept only so the controller's own hold bookkeeping still has
|
|
/// somewhere to land; it is pure bookkeeping now (Campaign CH user-gate
|
|
/// round 2, item 2). The former text-clear invoke made sense only for
|
|
/// the deleted <c>PortalWaitNoticeController</c>'s overwrite-only slot —
|
|
/// the SpewBox <see cref="_displayNotice"/> now targets has no "hide"
|
|
/// concept; a line disappears when its own timeout elapses
|
|
/// (<c>SpewBoxState.DefaultLifetime</c>), exactly like retail's
|
|
/// <c>gmSpewBoxUI</c>. <see cref="Enter"/>/<see cref="Exit"/> reset this
|
|
/// same bookkeeping flag directly.
|
|
/// </summary>
|
|
public void SetWaitCue(bool visible) => _waitCueVisible = visible;
|
|
|
|
/// <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);
|
|
|
|
DrawRhi();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every capability the raw-GL arm used to set by hand (deleted at
|
|
/// Campaign V slice V11) is baked into the dispatcher's pipelines, and the
|
|
/// pass sets its own full-attachment viewport, so what remains is the
|
|
/// pass itself: a backbuffer pass at the world's sample count that clears
|
|
/// colour and depth (see <see cref="RetailPortalSpaceClearColor"/> for why
|
|
/// re-clearing colour is exact) and is published as the scope's for the
|
|
/// span of the draw.
|
|
/// </summary>
|
|
private void DrawRhi()
|
|
{
|
|
IGpuFrame frame = _frames.CurrentFrame
|
|
?? throw new InvalidOperationException(
|
|
"Portal space requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
|
|
|
using IGpuPassEncoder encoder = frame.BeginPass(
|
|
GpuPassDescription.BackbufferClear(
|
|
"portal-space",
|
|
RetailPortalSpaceClearColor,
|
|
_scope.SampleCount));
|
|
// Published AFTER the pass opens and BEFORE the light upload: publishing
|
|
// resets the frame-global sections, and the distant light installed below
|
|
// is the one this scene wants rather than the world's.
|
|
using IDisposable publication = _scope.Publish(encoder);
|
|
DrawScene();
|
|
}
|
|
|
|
private void DrawScene()
|
|
{
|
|
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);
|
|
// Campaign CH user-gate round 1 (item D, #329): retail's
|
|
// gmSmartBoxUI::UseTime @0x004D6E30 emits
|
|
// ECM_UI::SendNotice_DisplayStringInfo(0x1a, "In Portal Space -
|
|
// Please Wait...") in the else arm of the rotation-segment-
|
|
// expiry test at 0x004D6FCD UNCONDITIONALLY -- every time a
|
|
// segment expires, with no hold/threshold check anywhere in
|
|
// that decompiled function. acdream's own RotationDurationMin/
|
|
// Max already match retail's RandDouble(0.6, 1.8) segment
|
|
// window decoded at 0x004D6FE6; the only bug was gating this
|
|
// call on `_waitCueVisible`, which only ever became true after
|
|
// RuntimeWorldTransitState.RetailWaitCueDelay's invented 5-
|
|
// second hold -- a threshold most local transits never reach,
|
|
// so the cue silently never fired. This write is deliberately
|
|
// independent of `_waitCueVisible`/SetWaitCue (see that
|
|
// method's own doc comment): LocalPlayerTeleportController
|
|
// still drives SetWaitCue every frame from its own hold-delay
|
|
// bookkeeping, but that call is pure bookkeeping now, so it
|
|
// never fights this unconditional per-segment write.
|
|
// Campaign CH user-gate round 2, item 2: this now targets the
|
|
// SpewBox (RuntimeCommunicationState.AddText, ClientLocal),
|
|
// retail's real destination for this notice
|
|
// (ECM_UI::SendNotice_DisplayStringInfo(0x1A, ...) ->
|
|
// AddTextToScroll(str, 0x1A, 1, 0), hardcoded to the SpewBox —
|
|
// docs/research/2026-08-09-chat-retail-interface-text.md
|
|
// §1.1/§4.2), not the former dedicated centered-overlay
|
|
// controller. Neither Enter/Exit/Dispose nor SetWaitCue clears
|
|
// anything any more — the SpewBox has no "hide" concept, a line
|
|
// simply times out (SpewBoxState.DefaultLifetime) exactly like
|
|
// retail's gmSpewBoxUI, and its own dedupe-at-index-0
|
|
// (SpewBoxState.Tick) collapses this call's per-segment
|
|
// repetition into one refreshed line, same as retail.
|
|
_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;
|
|
_waitCueVisible = false;
|
|
_animationHooks.Clear();
|
|
_sequence.ClearAnimations();
|
|
_meshReferences.Dispose();
|
|
if (_meshReferences.IsDisposed)
|
|
{
|
|
_disposed = true;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_disposing = false;
|
|
}
|
|
}
|
|
}
|