diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs
new file mode 100644
index 0000000..9d2ef05
--- /dev/null
+++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs
@@ -0,0 +1,62 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Splits a frame's landblock entities into the three draw buckets the per-cell
+/// needs, using the SAME precedence as
+/// :
+///
+/// - ServerGuid != 0 (player / NPCs / items / doors) ⇒
+/// — drawn unclipped (depth only). These have no ParentCellId so they MUST be tested first.
+/// - ParentCellId in the visible set ⇒ [cell] — per-cell, portal-clipped.
+/// - ParentCellId == null (outdoor scenery / building shell) ⇒
+/// — drawn through the doorway, clipped to OutsideView.
+///
+/// A static whose ParentCellId is NOT in is dropped (its cell
+/// isn't drawn this frame). Entities with no MeshRefs are skipped. Pure; GL-free; unit-tested.
+///
+public static class InteriorEntityPartition
+{
+ public sealed class Result
+ {
+ public Dictionary> ByCell { get; } = new();
+ public List Outdoor { get; } = new();
+ public List LiveDynamic { get; } = new();
+ }
+
+ public static Result Partition(
+ HashSet visibleCells,
+ IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
+ IReadOnlyList Entities,
+ IReadOnlyDictionary? AnimatedById)> landblockEntries)
+ {
+ var result = new Result();
+ foreach (var entry in landblockEntries)
+ {
+ foreach (var e in entry.Entities)
+ {
+ if (e.MeshRefs.Count == 0) continue;
+
+ if (e.ServerGuid != 0) // live-dynamic — precedence first (no ParentCellId)
+ {
+ result.LiveDynamic.Add(e);
+ }
+ else if (e.ParentCellId is uint cell)
+ {
+ if (!visibleCells.Contains(cell)) continue; // its cell isn't drawn this frame
+ if (!result.ByCell.TryGetValue(cell, out var list))
+ result.ByCell[cell] = list = new List();
+ list.Add(e);
+ }
+ else // outdoor scenery / building shell
+ {
+ result.Outdoor.Add(e);
+ }
+ }
+ }
+ return result;
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs
new file mode 100644
index 0000000..507f4d0
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/InteriorEntityPartitionTests.cs
@@ -0,0 +1,82 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class InteriorEntityPartitionTests
+{
+ private const uint CellA = 0xA9B40170;
+ private const uint CellB = 0xA9B40171;
+
+ private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
+ {
+ Id = id,
+ ServerGuid = serverGuid,
+ SourceGfxObjOrSetupId = 0x01000001,
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ MeshRefs = new[] { new MeshRef(0x01000001, Matrix4x4.Identity) },
+ ParentCellId = parentCell,
+ };
+
+ private static IEnumerable<(uint, Vector3, Vector3, IReadOnlyList,
+ IReadOnlyDictionary?)> OneLb(uint lbId, params WorldEntity[] ents)
+ => new[] { (lbId, Vector3.Zero, Vector3.Zero, (IReadOnlyList)ents,
+ (IReadOnlyDictionary?)null) };
+
+ [Fact]
+ public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
+ {
+ var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
+ var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
+ var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
+ var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
+ var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
+
+ var visible = new HashSet { CellA, CellB };
+ var result = InteriorEntityPartition.Partition(
+ visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));
+
+ Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0)
+ Assert.Contains(livePlayer, result.LiveDynamic);
+ Assert.Contains(liveNpcInCell, result.LiveDynamic);
+
+ Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic)
+ Assert.Contains(staticA, result.ByCell[CellA]);
+ Assert.Single(result.ByCell[CellB]);
+ Assert.Contains(staticB, result.ByCell[CellB]);
+
+ Assert.Single(result.Outdoor);
+ Assert.Contains(scenery, result.Outdoor);
+ }
+
+ [Fact]
+ public void Static_InNonVisibleCell_IsDropped()
+ {
+ var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
+ var visible = new HashSet { CellA };
+ var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));
+
+ Assert.False(result.ByCell.ContainsKey(0xA9B40199));
+ Assert.Empty(result.Outdoor);
+ Assert.Empty(result.LiveDynamic);
+ }
+
+ [Fact]
+ public void EntityWithNoMeshRefs_IsSkipped()
+ {
+ var noMesh = new WorldEntity
+ {
+ Id = 9, ServerGuid = 0, SourceGfxObjOrSetupId = 0x01000001,
+ Position = Vector3.Zero, Rotation = Quaternion.Identity,
+ MeshRefs = System.Array.Empty(), ParentCellId = CellA,
+ };
+ var result = InteriorEntityPartition.Partition(
+ new HashSet { CellA }, OneLb(0xA9B4FFFF, noMesh));
+
+ Assert.False(result.ByCell.ContainsKey(CellA));
+ }
+}