fix(app+core): Phase B.3 — Setup.StepUpHeight + scenery road exclusion

StepUpHeight: when Tab enters player mode, read Setup.StepUpHeight from the
player entity's dat and apply it to the controller (fallback 2f for non-Setup
entities or when the dat value is zero). Previously hardcoded to 5.0 which
made step-up too permissive.

Road exclusion: SceneryGenerator now skips terrain vertices where bits 0-1 of
the raw terrain word are non-zero. These bits encode the road type (GetRoad()
in ACViewer's Landblock.cs). Trees, rocks and bushes will no longer be placed
on road surfaces.

Added SceneryGenerator.IsRoadVertex(ushort) public helper + 9 unit tests
(theory + fact) verifying the road-bit convention matches TerrainInfo.Road.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 18:27:36 +02:00
parent 8252523b8b
commit 768a9a0619
2 changed files with 89 additions and 1 deletions

View file

@ -198,6 +198,18 @@ public sealed class GameWindow : IDisposable
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
// Read the real step height from the player's Setup dat.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
: 2f; // default human step height
}
else
{
_playerController.StepUpHeight = 2f; // default human step height
}
// Derive initial cell ID from the entity's world position.
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
@ -1323,6 +1335,7 @@ public sealed class GameWindow : IDisposable
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable);
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is not null && lbInfo.NumCells > 0)
@ -1364,11 +1377,33 @@ public sealed class GameWindow : IDisposable
}
cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids));
// Extract portal planes from this EnvCell's CellPortals.
// CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons),
// NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells.
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly))
continue;
if (poly.VertexIds.Count < 3)
continue;
// VertexIds are short; worldVerts keys are ushort.
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[0], out var v0)) continue;
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[1], out var v1)) continue;
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[2], out var v2)) continue;
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
v0, v1, v2,
portal.OtherCellId, // target cell (0xFFFF = outdoor)
envCellId & 0xFFFFu, // owner cell (low 16 bits)
(ushort)portal.Flags));
}
}
}
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
Array.Empty<AcDream.Core.Physics.PortalPlane>(), origin.X, origin.Y);
portalPlanes, origin.X, origin.Y);
}
// Upload every GfxObj referenced by this landblock's entities.

View file

@ -0,0 +1,53 @@
using AcDream.Core.World;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.World;
/// <summary>
/// Tests for SceneryGenerator road-exclusion logic.
/// The full Generate() pipeline requires real dat files (Region, Scene, etc.)
/// so road-check behavior is tested via the internal IsRoadVertex helper,
/// which is the single gate that guards against placing trees on roads.
/// </summary>
public class SceneryGeneratorTests
{
// Terrain word layout (ushort):
// bits 0-1 = Road (non-zero → on a road)
// bits 2-6 = TerrainType
// bits 11-15 = SceneType
[Theory]
[InlineData(0x0000, false)] // no road bits
[InlineData(0x0001, true)] // road bit 0 set
[InlineData(0x0002, true)] // road bit 1 set
[InlineData(0x0003, true)] // both road bits set
[InlineData(0x007C, false)] // terrain type bits only, no road
[InlineData(0xF800, false)] // scenery bits only, no road
[InlineData(0xF803, true)] // road + scenery bits
public void IsRoadVertex_CorrectlyIdentifiesRoadBits(ushort raw, bool expectedIsRoad)
{
Assert.Equal(expectedIsRoad, SceneryGenerator.IsRoadVertex(raw));
}
[Fact]
public void IsRoadVertex_ZeroTerrain_IsNotRoad()
{
// A fully blank terrain entry (no type, no road, no scene) is not a road.
Assert.False(SceneryGenerator.IsRoadVertex(0));
}
[Fact]
public void IsRoadVertex_MatchesTerrainInfoRoadProperty()
{
// Verify that IsRoadVertex agrees with the typed TerrainInfo.Road property
// for a sample of raw values, ensuring the bit convention is consistent.
for (ushort raw = 0; raw < 4; raw++)
{
TerrainInfo ti = raw;
bool expectedFromStruct = ti.Road != 0;
bool actual = SceneryGenerator.IsRoadVertex(raw);
Assert.True(actual == expectedFromStruct,
$"raw=0x{raw:X4}: IsRoadVertex={actual} but TerrainInfo.Road={ti.Road}");
}
}
}