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;
}
}
}