using DatReaderWriter; using DatReaderWriter.DBObjs; namespace AcDream.Core.World; public static class LandblockLoader { private const uint GfxObjMask = 0x01000000u; private const uint SetupMask = 0x02000000u; private const uint TypeMask = 0xFF000000u; /// /// Load a single landblock (heightmap + static objects) from the dats. /// /// Null if the landblock is missing from the cell dat. public static LoadedLandblock? Load(DatCollection dats, uint landblockId) { var block = dats.Get(landblockId); if (block is null) return null; var info = dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); var entities = info is null ? Array.Empty() : BuildEntitiesFromInfo(info, landblockId); var buildingTerrainCells = info is null ? null : BuildBuildingTerrainCells(info); return new LoadedLandblock(landblockId, block, entities, buildingTerrainCells); } /// /// Map LandBlockInfo.Buildings to 8x8 terrain mesh cells (cy * 8 + cx). /// Retail attaches each CBuildingObj to its outside landcell during /// CLandBlock::init_buildings; keep this signal separate from stabs so /// ordinary static props do not punch holes in terrain. /// public static IReadOnlySet BuildBuildingTerrainCells(LandBlockInfo info) { var result = new HashSet(); foreach (var building in info.Buildings) { int cx = Math.Clamp((int)(building.Frame.Origin.X / 24f), 0, 7); int cy = Math.Clamp((int)(building.Frame.Origin.Y / 24f), 0, 7); result.Add(cy * 8 + cx); } return result; } /// /// Pure mapping from a parsed LandBlockInfo to a list of WorldEntity. /// Each Stab and BuildingInfo becomes one entity. Unsupported id types /// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped. /// MeshRefs is left empty at this stage — Task 5 populates it. /// public static IReadOnlyList BuildEntitiesFromInfo(LandBlockInfo info, uint landblockId = 0) { var result = new List(info.Objects.Count + info.Buildings.Count); // When landblockId is non-zero, namespace stab Ids globally: // 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte // matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns // in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those. // // Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the // legacy starting-from-1 monotonic Ids — compatible with their assertions // which check uniqueness within a single landblock. // // Latent: if a landblock has >256 stabs (rare), nextId overflows the // low byte and bleeds into the lbY byte → cross-LB collision. Same // pattern + same limitation as scenery/interior. Document but don't // fix in this commit — out of scope for the Tier 1 cache bug fix. uint stabIdBase = landblockId == 0 ? 0u : 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8; uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u; foreach (var stab in info.Objects) { if (!IsSupported(stab.Id)) continue; var stabEntity = new WorldEntity { Id = nextId++, SourceGfxObjOrSetupId = stab.Id, Position = stab.Frame.Origin, Rotation = stab.Frame.Orientation, MeshRefs = Array.Empty(), }; stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction result.Add(stabEntity); } foreach (var building in info.Buildings) { if (!IsSupported(building.ModelId)) continue; var buildingEntity = new WorldEntity { Id = nextId++, SourceGfxObjOrSetupId = building.ModelId, Position = building.Frame.Origin, Rotation = building.Frame.Orientation, MeshRefs = Array.Empty(), }; buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction result.Add(buildingEntity); } return result; } private static bool IsSupported(uint id) { var type = id & TypeMask; return type == GfxObjMask || type == SetupMask; } }