diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 6508084d..a427854a 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -1,4 +1,5 @@
using System.Buffers.Binary;
+using System.Diagnostics;
using System.Net;
using System.Threading.Channels;
using AcDream.Core.Combat;
@@ -590,22 +591,50 @@ public sealed class WorldSession : IDisposable
_netThread.Start();
}
+ // Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods
+ // a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures
+ // under _datLock on the render thread) monopolized the update loop for ~a minute and
+ // starved the streaming apply, so neighbor landblocks trickled in. A wall-clock budget
+ // self-adapts to the highly variable per-datagram cost (cheap UpdatePosition vs an
+ // expensive clothed CreateObject); the tail drains over the next few frames, FIFO
+ // preserved, nothing lost (the inbox channel is unbounded). ~4ms leaves the rest of a
+ // 60fps update frame for streaming + physics.
+ private static readonly long InboundBudgetTicks = Stopwatch.Frequency / 1000 * 4;
+
///
- /// Non-blocking pump. Drains any datagrams buffered by the background
- /// net thread (Phase A.3), decodes them, and fires events. Call once
- /// per game-loop frame. Returns the number of datagrams processed.
+ /// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3),
+ /// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain
+ /// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the
+ /// number of datagrams processed.
///
public int Tick()
{
int processed = 0;
+ long start = Stopwatch.GetTimestamp();
while (_inboundQueue.Reader.TryRead(out var bytes))
{
ProcessDatagram(bytes);
processed++;
+ // Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick
+ // (the net thread that feeds _inboundQueue starts at Transition(State.InWorld)).
+ // Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so
+ // deferring the tail only delays the tail's acks a few frames — within ACE's
+ // tolerance (holtburger defers acks on a flush cadence). The tail stays queued
+ // (unbounded channel, FIFO) and drains next frame.
+ if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks))
+ break;
}
return processed;
}
+ ///
+ /// Pure, testable decision for the per-frame inbound bound: stop draining only when
+ /// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the
+ /// gate logic is unit-tested without the ISAAC/decode/channel machinery.
+ ///
+ internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks)
+ => state == State.InWorld && nowTicks - startTicks >= budgetTicks;
+
///
/// Phase A.3: background receive loop. Runs on a dedicated daemon
/// thread started at the end of . Continuously
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs
new file mode 100644
index 00000000..aee35646
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs
@@ -0,0 +1,33 @@
+using AcDream.Core.Net;
+using Xunit;
+
+namespace AcDream.Core.Net.Tests;
+
+///
+/// #2 flood-timeslice: the per-frame inbound drain is bounded to a wall-clock budget once
+/// in-world so a CreateObject flood can't monopolize the update loop. These cover the pure
+/// gate decision (); the FIFO tail-drain is
+/// standard Channel behavior and is verified live.
+///
+public sealed class WorldSessionInboundBudgetTests
+{
+ [Fact]
+ public void InWorld_overBudget_stops()
+ => Assert.True(WorldSession.InboundBudgetExceeded(
+ WorldSession.State.InWorld, startTicks: 100, nowTicks: 250, budgetTicks: 100));
+
+ [Fact]
+ public void InWorld_atBudgetBoundary_stops()
+ => Assert.True(WorldSession.InboundBudgetExceeded(
+ WorldSession.State.InWorld, startTicks: 0, nowTicks: 100, budgetTicks: 100));
+
+ [Fact]
+ public void InWorld_underBudget_continues()
+ => Assert.False(WorldSession.InboundBudgetExceeded(
+ WorldSession.State.InWorld, startTicks: 0, nowTicks: 99, budgetTicks: 100));
+
+ [Fact]
+ public void NotInWorld_neverBounds_soTheHandshakeDrainsFully()
+ => Assert.False(WorldSession.InboundBudgetExceeded(
+ WorldSession.State.Disconnected, startTicks: 0, nowTicks: 1_000_000, budgetTicks: 100));
+}