fix(runtime): align portal and movement presentation

Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 08:48:27 +02:00
parent e95f55f25b
commit 124e046976
30 changed files with 1362 additions and 348 deletions

View file

@ -0,0 +1,51 @@
using System.Buffers.Binary;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Net.Messages;
using AcDream.Core.Net.Packets;
using AcDream.Core.Physics;
namespace AcDream.App.Tests.Input;
public sealed class LocalPlayerOutboundCombatStyleTests
{
[Theory]
[InlineData(0x8000003Cu)] // HandCombat
[InlineData(0x8000003Eu)] // Magic
[InlineData(0x8000003Fu)] // BowCombat
[InlineData(0x80000041u)] // CrossbowCombat
public void CaptureAndBuild_PreservesCanonicalRawCombatStyle(uint combatStyle)
{
var controller = new PlayerMovementController(new PhysicsEngine());
controller.Motion.RawState.CurrentStyle = combatStyle;
MovementResult movement = controller.CaptureMovementResult(
mouseLookEvent: false);
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
movement);
Assert.Equal(combatStyle, movement.CurrentStyle);
Assert.Equal(combatStyle, outbound.CurrentStyle);
var writer = new PacketWriter(16);
RawMotionStatePacker.Pack(writer, outbound);
byte[] bytes = writer.ToArray();
uint flags = BinaryPrimitives.ReadUInt32LittleEndian(bytes);
Assert.NotEqual(0u, flags & 0x2u);
Assert.Equal(
combatStyle,
BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(4)));
}
[Fact]
public void CaptureAndBuild_NonCombatRetainsRetailDefault()
{
var controller = new PlayerMovementController(new PhysicsEngine());
RawMotionState outbound = GameWindow.BuildOutboundRawMotionState(
controller.CaptureMovementResult(mouseLookEvent: false));
Assert.Equal(RawMotionState.Default.CurrentStyle, outbound.CurrentStyle);
}
}

View file

@ -12,6 +12,17 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PortalTunnelAssetTests
{
[Fact]
public void PortalViewport_PreservesColorWhileClearingDepthLikeRetail()
{
Assert.Equal(
Silk.NET.OpenGL.ClearBufferMask.DepthBufferBit,
PortalTunnelPresentation.RetailViewportClearMask);
Assert.False(
(PortalTunnelPresentation.RetailViewportClearMask
& Silk.NET.OpenGL.ClearBufferMask.ColorBufferBit) != 0);
}
[Fact]
public void InstalledDat_ResolvesRetailPortalSetupAndAnimation()
{
@ -261,7 +272,7 @@ public sealed class PortalTunnelAssetTests
[InlineData(45f)]
[InlineData(60f)]
[InlineData(90f)]
public void PortalTunnelCamera_UsesActiveSmartBoxFieldOfViewAndNearPlane(float degrees)
public void PortalTunnelCamera_UsesActiveSmartBoxProjectionClipRange(float degrees)
{
float expected = degrees * (MathF.PI / 180f);
var smartBoxProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView(
@ -275,6 +286,11 @@ public sealed class PortalTunnelAssetTests
Assert.Equal(expected, camera.FovRadians, precision: 5);
Assert.Equal(0.35f, camera.Near, precision: 5);
// Recovering a far plane from a float projection matrix loses a
// small amount of precision because M33 is extremely close to -1.
// The presentation must nevertheless inherit the active SmartBox
// range rather than fall back to its former private 50 m clip.
Assert.InRange(camera.Far, 4995f, 5005f);
}
private static string? ResolveDatDir()

View file

@ -0,0 +1,52 @@
using System.Numerics;
using AcDream.App.Rendering.Sky;
namespace AcDream.App.Tests.Rendering;
public sealed class SkyProjectionTests
{
[Theory]
[InlineData(60f)]
[InlineData(120f)]
[InlineData(179.885f)]
public void WithDepthRange_PreservesActiveFieldOfView(float degrees)
{
Matrix4x4 active = Matrix4x4.CreatePerspectiveFieldOfView(
degrees * MathF.PI / 180f,
16f / 9f,
0.1f,
5_000f);
Matrix4x4 sky = SkyProjection.WithDepthRange(active, 0.1f, 1_000_000f);
Assert.Equal(active.M11, sky.M11);
Assert.Equal(active.M22, sky.M22);
Assert.Equal(active.M34, sky.M34);
Assert.Equal(active.M44, sky.M44);
Assert.Equal(1_000_000f / (0.1f - 1_000_000f), sky.M33, precision: 6);
Assert.Equal(0.1f * 1_000_000f / (0.1f - 1_000_000f), sky.M43, precision: 6);
}
[Fact]
public void WithDepthRange_PreservesOffCenterProjectionTerms()
{
Matrix4x4 active = Matrix4x4.CreatePerspectiveOffCenter(
-0.8f, 1.2f, -0.5f, 0.7f, 0.1f, 5_000f);
Matrix4x4 sky = SkyProjection.WithDepthRange(active, 1f, 10_000f);
Assert.Equal(active.M11, sky.M11);
Assert.Equal(active.M22, sky.M22);
Assert.Equal(active.M31, sky.M31);
Assert.Equal(active.M32, sky.M32);
}
[Fact]
public void WithDepthRange_RejectsNonPerspectiveProjection()
{
Matrix4x4 orthographic = Matrix4x4.CreateOrthographic(10f, 10f, 0.1f, 100f);
Assert.Throws<ArgumentException>(() =>
SkyProjection.WithDepthRange(orthographic, 0.1f, 1_000f));
}
}

View file

@ -176,4 +176,32 @@ public sealed class RetailUiAutomationProbeTests
File.Delete(path);
}
}
[Fact]
public void ScriptRunner_command_routesThroughInjectedClientSubmitPath()
{
var (root, _, _, _, objects) = RootWithTwoItemLists();
var probe = new RetailUiAutomationProbe(root, objects);
var submitted = new List<string>();
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
File.WriteAllText(path, "command /ls");
try
{
var runner = new RetailUiAutomationScriptRunner(
probe,
path,
dumpOnStart: false,
submitCommand: submitted.Add);
runner.Tick(0.016);
Assert.Equal(["/ls"], submitted);
Assert.True(runner.Completed);
}
finally
{
File.Delete(path);
}
}
}

View file

@ -149,6 +149,65 @@ public class PlayerMovementControllerTests
Assert.True(result.Position.X > 96f + 2f, $"X={result.Position.X} should have moved forward");
}
[Fact]
public void Update_AttachedAnimationWithZeroRootDelta_DoesNotGlideOnForwardEdge()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource(_ => Vector3.Zero);
MovementResult result = controller.Update(
PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
Assert.Equal(start, result.Position);
Assert.Equal(0f, controller.BodyVelocity.X);
Assert.Equal(0f, controller.BodyVelocity.Y);
}
[Fact]
public void Update_AttachedAnimationRootDelta_DrivesGroundedBodyAtObjectScale()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.ObjectScale = 2f;
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.1f, 0f));
MovementResult result = controller.Update(
PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
// Local +Y is forward. Yaw 0 maps it to world +X; m_scale doubles
// the animation-authored 0.1 m displacement to 0.2 m.
Assert.Equal(start.X + 0.2f, result.Position.X, precision: 3);
Assert.Equal(start.Y, result.Position.Y, precision: 3);
}
[Fact]
public void Update_SubQuantumAnimationRootDeltas_AccumulateIntoOnePhysicsStep()
{
var controller = new PlayerMovementController(MakeFlatEngine());
var start = new Vector3(96f, 96f, 50f);
controller.SetPosition(start, 0x0001);
controller.Yaw = 0f;
controller.AttachAnimationRootMotionSource(_ => new Vector3(0f, 0.05f, 0f));
MovementResult first = controller.Update(
PhysicsBody.MinQuantum * 0.5f,
new MovementInput(Forward: true));
MovementResult second = controller.Update(
PhysicsBody.MinQuantum * 0.5f,
new MovementInput(Forward: true));
Assert.Equal(start, first.Position);
Assert.Equal(start.X + 0.1f, second.Position.X, precision: 3);
Assert.Equal(start.Y, second.Position.Y, precision: 3);
}
[Fact]
public void Update_SubQuantumFrame_InterpolatesRenderPositionWithoutAdvancingPhysicsPosition()
{

View file

@ -298,10 +298,10 @@ public sealed class AnimationSequencerCutoverTraceTests
// POST-adjust_motion substate (45000005, was 45000006) and
// CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns
// the state and retail's interpreted state is post-adjustment. The
// om=(-0.00,...) is IEEE negative zero from add_motion's
// zero-omega x negative-speed multiply (numerically equal to 0).
// vel/om=(-0.00,...) are IEEE negative zero from add_motion's
// zero-components x negative-speed multiply (numerically equal to 0).
Assert.Equal(
"105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
"105@-19.5^,101@-19.5* | frame=3.0 vel=(-0.00,-2.03,-0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65",
Describe(seq, loader));
}

View file

@ -1220,6 +1220,29 @@ public sealed class AnimationSequencerTests
Assert.Equal(new Vector3(0f, 4f, 0f), seq.CurrentVelocity);
}
[Fact]
public void CurrentVelocity_ZeroLocomotionMotionData_IsNotSynthesizedFromCommand()
{
// Retail add_motion @ 0x005224B0 writes MotionData.Velocity * speed
// unconditionally. It never substitutes CMotionInterp's body-speed
// constants into CSequence. The installed Humanoid table uses this
// zero-velocity shape for both Walk and Run.
const uint Style = 0x8000003Du;
const uint Motion = 0x40000007u;
const uint AnimId = 0x03000406u;
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
var setup = Fixtures.MakeSetup(1);
var mt = Fixtures.MakeMtable(Style, Motion, AnimId, framerate: 10f);
var loader = new FakeLoader();
loader.Register(AnimId, anim);
var seq = new AnimationSequencer(setup, mt, loader);
seq.SetCycle(Style, Motion, speedMod: 2f);
Assert.Equal(Vector3.Zero, seq.CurrentVelocity);
}
[Fact]
public void CurrentVelocity_ScaledBySpeedMod()
{
@ -1454,8 +1477,9 @@ public sealed class AnimationSequencerTests
{
// A RunForward motion with MotionData.Velocity = (0,4,0) should
// surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at
// 0.5×. The dead-reckoning integrator in TickAnimations reads
// CurrentVelocity each tick, so this has to be accurate.
// 0.5×.
// CSequence stores that literal DAT velocity for retail
// get_state_velocity consumers, so scaling must remain exact.
// R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class
// bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity).
const uint Style = 0x8000003Du;

View file

@ -0,0 +1,77 @@
using AcDream.Content.Vfx;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Pins the retail Humanoid motion table data consumed by CSequence root motion.
/// The packet-driven remote mover must use these values, not a velocity guessed
/// from the interpreted command.
/// </summary>
public sealed class HumanoidMotionTableRootMotionTests
{
private const uint HumanoidMotionTable = 0x09000001u;
private const uint HumanoidSetup = 0x02000001u;
private const uint NonCombatStyle = 0x8000003Du;
private const uint WalkForward = 0x40000005u;
private const uint RunForward = 0x40000007u;
private const uint Ready = 0x41000003u;
private const uint InterpretedWalkForward = 0x45000005u;
[Theory]
[InlineData(WalkForward)]
[InlineData(RunForward)]
public void RetailLocomotionCycle_ExposesItsLiteralMotionDataVelocity(uint motion)
{
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(HumanoidMotionTable));
int key = unchecked((int)((NonCombatStyle << 16) | (motion & 0x00FF_FFFFu)));
MotionData cycle = Assert.IsType<MotionData>(table.Cycles[key]);
Assert.Equal(System.Numerics.Vector3.Zero, cycle.Velocity);
}
[Fact]
public void RetailWalkSequence_ProducesAuthoredRootDisplacement()
{
string? datDir = ConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
Setup setup = Assert.IsType<Setup>(dats.Get<Setup>(HumanoidSetup));
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(HumanoidMotionTable));
var sequencer = new AnimationSequencer(
setup,
table,
new RetailAnimationLoader(dats));
sequencer.SetCycle(NonCombatStyle, Ready);
sequencer.SetCycle(NonCombatStyle, InterpretedWalkForward);
float distance = 0f;
for (int i = 0; i < 120; i++)
{
var root = new Frame
{
Origin = System.Numerics.Vector3.Zero,
Orientation = System.Numerics.Quaternion.Identity,
};
sequencer.Advance(1f / 60f, root);
distance += root.Origin.Length();
}
Assert.True(
distance > 1f,
$"Installed retail WalkForward should author root travel; got {distance:F3} m over 2 s.");
}
}

View file

@ -94,8 +94,9 @@ public class MotionTableDispatchSinkTests
Assert.Equal(Walk, seq.CurrentMotion);
Assert.Equal(2.0f, seq.CurrentSpeedMod);
// Locomotion velocity synthesis ran in the passthrough (AP-75).
Assert.Equal(MotionInterpreter.WalkAnimSpeed * 2.0f, seq.CurrentVelocity.Y, 3);
// add_motion preserves the literal zero MotionData velocity; body
// speed belongs to CMotionInterp, not CSequence.
Assert.Equal(Vector3.Zero, seq.CurrentVelocity);
}
[Fact]

View file

@ -10,7 +10,7 @@ namespace AcDream.Core.Tests.Physics;
// from PositionManager; see RemoteMotionCombiner's class doc).
//
// Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730).
// Pure-function combiner: animation root motion (seqVel × dt, rotated by
// Pure-function combiner: CSequence root-motion delta (rotated by
// body orientation) + InterpolationManager.AdjustOffset correction.
// ─────────────────────────────────────────────────────────────────────────────
@ -35,7 +35,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: Vector3.Zero,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@ -53,11 +53,11 @@ public sealed class RemoteMotionCombinerTests
var pm = Make();
var interp = EmptyInterp();
// seqVel = (0, 4, 0), dt = 0.1 → rootMotion = (0, 0.4, 0)
// CSequence accumulated (0, 0.4, 0) for this 0.1-second quantum.
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 4f, 0f),
rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f);
@ -83,7 +83,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 4f, 0f),
rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: ori,
interp: interp,
maxSpeed: 0f);
@ -110,7 +110,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: Vector3.Zero,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@ -146,7 +146,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 4f, 0f),
rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
@ -170,12 +170,12 @@ public sealed class RemoteMotionCombinerTests
// body-local +Y → world -X
Quaternion ori = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
// seqVel = (0, 1, 0), dt = 1 → rootMotionLocal = (0, 1, 0)
// CSequence accumulated rootMotionLocal = (0, 1, 0).
// after Transform by ori → (-1, 0, 0) approximately
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 1f, 0f),
rootMotionLocalDelta: new Vector3(0f, 1f, 0f),
ori: ori,
interp: interp,
maxSpeed: 0f);
@ -196,7 +196,7 @@ public sealed class RemoteMotionCombinerTests
// =========================================================================
[Fact]
public void ComputeOffset_SeqVelFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
public void ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope()
{
var pm = Make();
var interp = EmptyInterp(); // queue empty → fallback path runs
@ -213,7 +213,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 1.0,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(4f, 0f, 0f),
rootMotionLocalDelta: new Vector3(4f, 0f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
@ -225,7 +225,7 @@ public sealed class RemoteMotionCombinerTests
}
[Fact]
public void ComputeOffset_SeqVelFallback_FlatTerrainNormal_NoZChange()
public void ComputeOffset_RootMotionFallback_FlatTerrainNormal_NoZChange()
{
var pm = Make();
var interp = EmptyInterp();
@ -234,7 +234,7 @@ public sealed class RemoteMotionCombinerTests
Vector3 offset = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
seqVel: new Vector3(0f, 4f, 0f),
rootMotionLocalDelta: new Vector3(0f, 0.4f, 0f),
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 0f,
@ -244,4 +244,33 @@ public sealed class RemoteMotionCombinerTests
Assert.Equal(0.4f, offset.Y, precision: 4);
Assert.Equal(0f, offset.Z, precision: 4);
}
[Fact]
public void ComputeOffset_QueueHeadReached_WithLiteralZeroRootMotion_DoesNotOvershoot()
{
var pm = Make();
var interp = new InterpolationManager();
var target = new Vector3(0.4f, 0f, 0f);
interp.Enqueue(target, heading: 0f, isMovingTo: false);
Vector3 catchUp = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: Vector3.Zero,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
Vector3 reachedPosition = catchUp;
Vector3 afterReach = pm.ComputeOffset(
dt: 0.1,
currentBodyPosition: reachedPosition,
rootMotionLocalDelta: Vector3.Zero,
ori: Quaternion.Identity,
interp: interp,
maxSpeed: 4f);
Assert.Equal(target, reachedPosition);
Assert.Equal(Vector3.Zero, afterReach);
}
}

View file

@ -262,6 +262,70 @@ public sealed class TeleportAnimSequencerTests
Assert.Equal(expected, TeleportAnimSequencer.GetRetailAnimationLevel(t));
}
[Theory]
[InlineData(240f)]
[InlineData(700f)]
[InlineData(2000f)]
public void WorldFadeOut_DoesNotPublishTerminalProjectionOnOutgoingWorld(float framesPerSecond)
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false);
AssertOutgoingViewportRetiresBeforeTerminal(
seq,
TeleportAnimState.WorldFadeOut,
framesPerSecond);
}
[Theory]
[InlineData(240f)]
[InlineData(700f)]
[InlineData(2000f)]
public void TunnelFadeOut_DoesNotPublishTerminalProjectionOnFiniteTunnel(float framesPerSecond)
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true);
DriveSeconds(
seq,
TeleportAnimSequencer.MinContinue + 0.05f,
worldReady: true,
step: 1f / framesPerSecond);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
AssertOutgoingViewportRetiresBeforeTerminal(
seq,
TeleportAnimState.TunnelFadeOut,
framesPerSecond);
}
private static void AssertOutgoingViewportRetiresBeforeTerminal(
TeleportAnimSequencer seq,
TeleportAnimState outgoingState,
float framesPerSecond)
{
float dt = 1f / framesPerSecond;
bool transitioned = false;
for (int i = 0; i < (int)MathF.Ceiling(framesPerSecond * 1.1f); i++)
{
var (snapshot, _) = seq.Tick(dt, worldReady: true);
if (snapshot.State == outgoingState)
{
Assert.True(
snapshot.ViewPlaneBlend <= 1022f / 1024f,
$"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}.");
continue;
}
transitioned = true;
break;
}
Assert.True(transitioned, "The outgoing viewport did not retire.");
}
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
[Fact]
@ -314,14 +378,14 @@ public sealed class TeleportAnimSequencerTests
}
[Fact]
public void ViewPlaneBlend_IsOneAtEnd_OfWorldFadeOut()
public void ViewPlaneBlend_ReachesLastVisibleLevel_BeforeWorldFadeOutRetires()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0
// Drive to just BEFORE the transition (so we're still in WorldFadeOut)
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false);
Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
var (snap, _) = seq.Tick(0f, worldReady: false);
@ -329,6 +393,7 @@ public sealed class TeleportAnimSequencerTests
Assert.True(
snap.ViewPlaneBlend > 0.95f,
$"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}");
Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f);
}
[Fact]