Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.
Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
mirror the new partition logic
Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities
Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
6.2 KiB
C#
175 lines
6.2 KiB
C#
// Phase A8 — verify the WbDrawDispatcher EntitySet partition.
|
|
//
|
|
// The pure-data WalkEntitiesForTest helper iterates a flat entity list and
|
|
// returns the IDs that survive the EntitySet filter + visibleCellIds gate.
|
|
// EntitySet.IndoorOnly should include only entities with ParentCellId,
|
|
// EntitySet.OutdoorOnly only entities with null ParentCellId, and
|
|
// EntitySet.All (the default) should match the pre-A8 behavior.
|
|
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.Core.World;
|
|
using Xunit;
|
|
|
|
namespace AcDream.Core.Tests.Rendering.Wb;
|
|
|
|
public class WbDrawDispatcherEntitySetTests
|
|
{
|
|
private static WorldEntity Indoor(uint id, uint cellId) => new()
|
|
{
|
|
Id = id,
|
|
SourceGfxObjOrSetupId = 0x01000001u,
|
|
ParentCellId = cellId,
|
|
MeshRefs = new List<AcDream.Core.World.MeshRef>
|
|
{
|
|
new() { GfxObjId = 0x01000001u },
|
|
},
|
|
Position = Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
};
|
|
|
|
private static WorldEntity Outdoor(uint id) => new()
|
|
{
|
|
Id = id,
|
|
SourceGfxObjOrSetupId = 0x01000001u,
|
|
ParentCellId = null,
|
|
MeshRefs = new List<AcDream.Core.World.MeshRef>
|
|
{
|
|
new() { GfxObjId = 0x01000001u },
|
|
},
|
|
Position = Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
};
|
|
|
|
[Fact]
|
|
public void EntitySet_IndoorOnly_DropsOutdoorEntities()
|
|
{
|
|
var entities = new List<WorldEntity>
|
|
{
|
|
Indoor(0x10000001, 0xA9B40143),
|
|
Outdoor(0x10000002),
|
|
Indoor(0x10000003, 0xA9B40144),
|
|
};
|
|
|
|
var visible = new HashSet<uint> { 0xA9B40143u, 0xA9B40144u };
|
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
|
entities,
|
|
visibleCellIds: visible,
|
|
set: WbDrawDispatcher.EntitySet.IndoorOnly);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Contains(0x10000001u, result);
|
|
Assert.Contains(0x10000003u, result);
|
|
Assert.DoesNotContain(0x10000002u, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void EntitySet_OutdoorOnly_KeepsOnlyNullParentCellId()
|
|
{
|
|
var entities = new List<WorldEntity>
|
|
{
|
|
Indoor(0x10000001, 0xA9B40143),
|
|
Outdoor(0x10000002),
|
|
Outdoor(0x10000003),
|
|
};
|
|
|
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
|
entities,
|
|
visibleCellIds: null,
|
|
set: WbDrawDispatcher.EntitySet.OutdoorOnly);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Contains(0x10000002u, result);
|
|
Assert.Contains(0x10000003u, result);
|
|
Assert.DoesNotContain(0x10000001u, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void EntitySet_All_MatchesPreA8Behavior()
|
|
{
|
|
var entities = new List<WorldEntity>
|
|
{
|
|
Indoor(0x10000001, 0xA9B40143),
|
|
Outdoor(0x10000002),
|
|
Indoor(0x10000003, 0xA9B40999), // not in visibleCellIds
|
|
};
|
|
|
|
var visible = new HashSet<uint> { 0xA9B40143u };
|
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
|
entities,
|
|
visibleCellIds: visible,
|
|
set: WbDrawDispatcher.EntitySet.All);
|
|
|
|
// Pre-A8: visibleCellIds gates indoor entities, outdoor entities pass.
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Contains(0x10000001u, result);
|
|
Assert.Contains(0x10000002u, result);
|
|
Assert.DoesNotContain(0x10000003u, result);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Phase A8 fix (post-visual-verification): animated entities (player,
|
|
// NPCs, monsters) are live server-spawned objects with ParentCellId == null.
|
|
// Without these tests, they would be classified as outdoor scenery and
|
|
// stencil-gated by the OutdoorOnly pass — causing the character to
|
|
// disappear when the camera enters a building. Fix: animatedEntityIds
|
|
// overrides the ParentCellId-based partition. Animated entities always
|
|
// belong in the IndoorOnly pass, never in OutdoorOnly.
|
|
// ---------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId()
|
|
{
|
|
var entities = new List<WorldEntity>
|
|
{
|
|
Indoor(0x10000001, 0xA9B40143),
|
|
Outdoor(0x10000002), // static outdoor scenery
|
|
Outdoor(0x40000005), // animated (player/NPC)
|
|
};
|
|
|
|
var visible = new HashSet<uint> { 0xA9B40143u };
|
|
var animated = new HashSet<uint> { 0x40000005u };
|
|
|
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
|
entities,
|
|
visibleCellIds: visible,
|
|
set: WbDrawDispatcher.EntitySet.IndoorOnly,
|
|
animatedEntityIds: animated);
|
|
|
|
// Indoor entity passes via ParentCellId.HasValue.
|
|
// Outdoor scenery (0x10000002) fails — not animated, no ParentCellId.
|
|
// Animated entity (0x40000005) passes via animatedEntityIds override.
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Contains(0x10000001u, result);
|
|
Assert.Contains(0x40000005u, result);
|
|
Assert.DoesNotContain(0x10000002u, result);
|
|
}
|
|
|
|
[Fact]
|
|
public void EntitySet_OutdoorOnly_ExcludesAnimatedEntities()
|
|
{
|
|
var entities = new List<WorldEntity>
|
|
{
|
|
Outdoor(0x10000002), // static outdoor scenery
|
|
Outdoor(0x40000005), // animated (player/NPC)
|
|
Outdoor(0x40000006), // animated (NPC)
|
|
};
|
|
|
|
var animated = new HashSet<uint> { 0x40000005u, 0x40000006u };
|
|
|
|
var result = WbDrawDispatcher.WalkEntitiesForTest(
|
|
entities,
|
|
visibleCellIds: null,
|
|
set: WbDrawDispatcher.EntitySet.OutdoorOnly,
|
|
animatedEntityIds: animated);
|
|
|
|
// Only static outdoor scenery passes the OutdoorOnly partition.
|
|
// Animated entities are explicitly excluded so they don't get
|
|
// stencil-gated (they're drawn in the IndoorOnly pass instead).
|
|
Assert.Single(result);
|
|
Assert.Contains(0x10000002u, result);
|
|
Assert.DoesNotContain(0x40000005u, result);
|
|
Assert.DoesNotContain(0x40000006u, result);
|
|
}
|
|
}
|