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; /// /// The retail portal-space CreatureMode scene owned by /// gmSmartBoxUI (PostInit 0x004D6B60, /// UseTime 0x004D6E30). It is a synthetic DAT Setup animated at /// 40 frames/second and drawn as a replacement 3-D viewport beneath the /// retained gameplay UI. /// /// Campaign V slice V6m. 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 /// . The RHI arm opens a pass of its own against /// the backbuffer and publishes it on for the /// span of the draw, exactly as the two offscreen viewports do /// (, slice V6l). /// public sealed class PortalTunnelPresentation : IDisposable { public const uint SetupClientEnum = 0x10000001u; public const uint AnimationClientEnum = 0x10000002u; public const uint ClientEnumCategory = 7u; /// /// The colour the RHI arm's pass loads with, and the one value that makes /// its Clear load-op equivalent to GL's depth-only clear. /// /// 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 DontCare into the multisampled /// scratch, so a second multisampled pass declaring Load 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: /// RenderFrameFoundation.PortalViewportVisible and this scene's own /// are the same value, read once at the top of the /// frame, and WorldSceneRenderer returns without drawing when it is /// set. So whenever this scene draws, the backbuffer holds exactly the /// opaque black SceneTool::BeginScene @ 0x0043DAD0 establishes and /// nothing else — and clearing to that same black changes no pixel. /// 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 AnimatedIds = new() { SyntheticEntityId }; /// /// The world pass scope this presentation's own pass publishes into for /// the span of the draw. 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. /// private readonly IWorldPassScope _scope; /// The frame this presentation's own pass is opened on. 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? _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? 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)); } /// /// 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. /// /// Internal since slice V6m: the RHI arm's two seams — /// and — /// are internal RHI contracts, and the type itself keeps its visibility. /// Composition is the only caller. /// internal static PortalTunnelPresentation CreateRequired( IWorldPassScope scope, ICurrentGpuFrameSource frames, IDatReaderWriter dats, IAnimationLoader animationLoader, IAnimationHookSink hookSink, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo, IWbMeshAdapter meshAdapter, Action? 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(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; /// /// Publishes the synthetic scene's mesh ownership after the presentation /// itself has been stored by GameWindow. A partial acquisition stays /// reachable and is resumed or released by the same lifetime owner. /// internal void PrepareResources() { ThrowIfDisposed(); _meshReferences.Acquire(); } /// /// Retail set_sequence_animation(anim, clear=1, low=1, fps=40) /// on the edge where portal space becomes visible. /// public void Enter() { ThrowIfDisposed(); _animationHooks.Clear(); _sequence.ClearAnimations(); _sequence.AppendAnimation(new AnimData { AnimId = (QualifiedDataId)_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(); } /// Hide the CreatureMode viewport and clear its sequence. 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); } /// /// The hold-delay-gated arm/disarm LocalPlayerTeleportController /// still drives every frame from RuntimeWorldTransitState.ObserveWait /// (own telemetry: RuntimePortalSnapshot.WaitCueShown). This is /// deliberately NOT the retail cue-emission path — see /// '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 PortalWaitNoticeController's overwrite-only slot — /// the SpewBox now targets has no "hide" /// concept; a line disappears when its own timeout elapses /// (SpewBoxState.DefaultLifetime), exactly like retail's /// gmSpewBoxUI. / reset this /// same bookkeeping flag directly. /// public void SetWaitCue(bool visible) => _waitCueVisible = visible; /// /// 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. /// 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(); } /// /// 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 for why /// re-clearing colour is exact) and is published as the scope's for the /// span of the draw. /// 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, IReadOnlyDictionary?)[] { (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); /// /// 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. /// private sealed class PortalAnimationHookQueue : IAnimHookQueue { private readonly IAnimationHookSink _sink; private readonly uint _ownerId; private readonly List _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; } } }