ROOT CAUSE of the "giant ball with spikes" terrain corruption that the previous two hotfix attempts (lock + synchronous loading) failed to address. Threading was a red herring all along. AC dat conventions: 0xAAAA0xFFFF — LandBlock dat (terrain heightmap) 0xAAAA0xFFFE — LandBlockInfo dat (static-object metadata) WorldView.NeighborLandblockIds correctly uses 0xFFFF. My StreamingRegion.EncodeLandblockId from Phase A.1 Task 1 used 0xFFFE by mistake. Every streaming load was therefore calling LandblockLoader.Load with the LandBlockInfo id, which makes DatCollection ask DatBinReader to read a LandBlock from the LandBlockInfo file. The reader's internal buffer position lands in the middle of the wrong file's bytes, ReadBytesInternal asks for an out-of-range slice, throws ArgumentOutOfRangeException, and the landblocks that DON'T throw return half-populated LandBlock objects whose Height[] arrays contain garbage. Garbage Z values render as the spike pattern. The kicker: my Task-1 review fix added a test (Constructor_SmallRadius_IDsMatchEncodingRule) that asserted Assert.Contains(0x1234FFFEu, region.Visible). The test was passing because it pinned the wrong value. I literally codified the bug. Fix: change EncodeLandblockId's terminator from 0xFFFEu to 0xFFFFu and update the test to assert 0x1234FFFFu. The XML doc on Visible now explicitly explains the 0xFFFF/0xFFFE distinction so this can't recur. The previous two hotfixes (_datLock inc991fb2, synchronous streamer in531c9f9) stay in place — _datLock is defensive belt-and-suspenders that documents which entry points read dats, and synchronous loading is correct-by-default until we decide whether to reintroduce background loading (Phase A.3 may make it unnecessary anyway). 212 tests green. With this fix the streaming should actually work. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
119 lines
4.1 KiB
C#
119 lines
4.1 KiB
C#
using AcDream.App.Streaming;
|
||
using Xunit;
|
||
|
||
namespace AcDream.Core.Tests.Streaming;
|
||
|
||
public class StreamingRegionTests
|
||
{
|
||
[Fact]
|
||
public void Constructor_Radius2_Produces25Landblocks()
|
||
{
|
||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||
|
||
Assert.Equal(25, region.Visible.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_NearOrigin_ClampsToWorldEdge()
|
||
{
|
||
// Center at (0, 0) with radius 2: only the +X / +Y quadrant is
|
||
// in-bounds. That's a 3×3 subset of the 5×5 window = 9 landblocks.
|
||
var region = new StreamingRegion(cx: 0, cy: 0, radius: 2);
|
||
|
||
Assert.Equal(9, region.Visible.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_NearFarEdge_ClampsToWorldEdge()
|
||
{
|
||
var region = new StreamingRegion(cx: 0xFF, cy: 0xFF, radius: 2);
|
||
|
||
Assert.Equal(9, region.Visible.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void RecenterTo_SamePosition_EmptyDiff()
|
||
{
|
||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||
|
||
var diff = region.RecenterTo(50, 50);
|
||
|
||
Assert.Empty(diff.ToLoad);
|
||
Assert.Empty(diff.ToUnload);
|
||
}
|
||
|
||
[Fact]
|
||
public void RecenterTo_SingleStepEast_LoadsColumn_NoUnloadsDueToHysteresis()
|
||
{
|
||
// Radius 2 → unload threshold is radius+2 = 4.
|
||
// Starting center (50,50) covers X in [48..52]. Step to (51,50):
|
||
// new coverage X in [49..53]. New column is x=53 (5 entries).
|
||
// Departing column x=48 is at distance |48-51| = 3, which is within
|
||
// the radius+2 threshold, so it stays loaded (hysteresis keeps radius+2).
|
||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||
|
||
var diff = region.RecenterTo(51, 50);
|
||
|
||
Assert.Equal(5, diff.ToLoad.Count);
|
||
Assert.Empty(diff.ToUnload);
|
||
// Visible is strictly the 5×5 window around (51, 50).
|
||
Assert.Equal(25, region.Visible.Count);
|
||
// Resident includes the hysteresis-retained x=48 column (5 entries)
|
||
// plus the full new window, for 30 total.
|
||
Assert.Equal(30, region.Resident.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void RecenterTo_ThreeStepEast_LoadsAndUnloadsColumns()
|
||
{
|
||
// Starting (50,50) covers X in [48..52]. Step to (53,50):
|
||
// new coverage X in [51..55]. New columns: x=53,54,55 (15 entries).
|
||
// x=48 is now 5 away, > radius+2 = 4 → unload. x=49 is 4 away, not > 4 → keep. x=50 is 3 away, not > 4 → keep.
|
||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||
|
||
var diff = region.RecenterTo(53, 50);
|
||
|
||
Assert.Equal(15, diff.ToLoad.Count);
|
||
Assert.Equal(5, diff.ToUnload.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void RecenterTo_LongTeleport_UnloadsEverythingLoadsEverything()
|
||
{
|
||
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
|
||
|
||
var diff = region.RecenterTo(200, 200);
|
||
|
||
Assert.Equal(25, diff.ToLoad.Count);
|
||
Assert.Equal(25, diff.ToUnload.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_NearXEdgeOnly_ClampsOnlyXAxis()
|
||
{
|
||
// cx=0, cy=50, radius=2:
|
||
// X is clamped to [0..2] (3 entries)
|
||
// Y is unclamped [48..52] (5 entries)
|
||
// Total = 3 × 5 = 15 landblocks.
|
||
var region = new StreamingRegion(cx: 0, cy: 50, radius: 2);
|
||
|
||
Assert.Equal(15, region.Visible.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void Constructor_SmallRadius_IDsMatchEncodingRule()
|
||
{
|
||
// Verify EncodeLandblockId emits the LandBlock terminator (0xFFFF),
|
||
// not the LandBlockInfo terminator (0xFFFE). The earlier version of
|
||
// this test pinned 0xFFFE and codified the bug that produced "ball
|
||
// of spikes" terrain in Phase A.1's first live run — LandblockLoader
|
||
// was being asked to read the LandBlockInfo file as a LandBlock,
|
||
// which corrupted the dat reader's buffer position and returned a
|
||
// half-populated heightmap. radius=0 at (0x12, 0x34) → exactly one
|
||
// entry, id = 0x1234FFFF.
|
||
var region = new StreamingRegion(cx: 0x12, cy: 0x34, radius: 0);
|
||
|
||
Assert.Single(region.Visible);
|
||
Assert.Contains(0x1234FFFFu, region.Visible);
|
||
}
|
||
}
|