using System.Numerics; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.Core.World; /// /// Procedural scenery placement for a landblock. AC encodes "sparse decoration" /// (trees, bushes, rocks, fences, small props) NOT as explicit Stab entries on /// the LandBlockInfo but as per-terrain-vertex Scene/ObjectDesc references in /// the Region dat, placed pseudo-randomly via deterministic LCG math keyed on /// the vertex's global cell coordinates. Without this generator, any landblock /// rendered from dats is missing all of its natural scenery. /// /// Algorithm verified against the decompiled retail acclient.exe (Ghidra output): /// - Scene-selection hash: chunk_00530000.c line 1144 /// - Per-object frequency: chunk_00530000.c lines 1168-1174 /// - Displacement formula: chunk_005A0000.c lines 4858-4878 (FUN_005a6cc0) /// - Quadrant rotation: chunk_005A0000.c lines 4880-4902 /// - Object rotation hash: chunk_005A0000.c lines 4924-4926 (FUN_005a6e60) /// - Object scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() /// (scale hash constant 0x7f51=32593 not in dumped chunks; /// confirmed against ACViewer which matches all other constants) /// /// Key implementation note: the decompiled client computes each LCG value as a /// signed 32-bit int, then normalises with "if (val < 0) val += 2^32" before /// dividing by 2^32. This is equivalent to our unchecked((uint)(...)) cast. /// ACViewer's reference omits this cast and is subtly wrong for negative inputs. /// We deliberately match the decompiled client, not ACViewer. /// /// We deliberately skip the slope/road/building-overlap checks the original does; /// those prevent scenery from floating in roads or clipping buildings but /// require walkable-polygon lookups that we don't yet have. Accepting visual /// artifacts (trees inside roads, scenery clipping buildings) for a first pass /// and deferring the filters to a later phase. /// public static class SceneryGenerator { // AC landblock geometry — matches LandblockMesh. private const int VerticesPerSide = 9; private const float CellSize = 24.0f; private const float LandblockSize = 192.0f; // 8 cells * 24 units public readonly record struct ScenerySpawn( uint ObjectId, // GfxObj or Setup id Vector3 LocalPosition, // landblock-local world units Quaternion Rotation, float Scale); /// /// Generate all scenery entries for one landblock. Uses the bit-packed /// TerrainInfo Type (bits 2-6) and Scenery (bits 11-15) fields to index into /// Region.TerrainInfo.TerrainTypes[type].SceneTypes[scenery] → a SceneInfo /// index into Region.SceneInfo.SceneTypes[sceneInfo].Scenes. Each cell picks /// one scene via a pseudo-random hash of the cell's global coordinates, then /// iterates the scene's ObjectDesc entries with per-object frequency rolls. /// public static IReadOnlyList Generate( DatCollection dats, Region region, LandBlock block, uint landblockId, HashSet? buildingCells = null, float[]? heightTable = null) { var result = new List(); if (region.TerrainInfo?.TerrainTypes is null || region.SceneInfo?.SceneTypes is null) return result; uint blockX = (landblockId >> 24) * 8; // 8 cells per landblock uint blockY = ((landblockId >> 16) & 0xFFu) * 8; // RETAIL iterates 8×8 = 64 CELLS, not 9×9 = 81 vertices. // Decompiled FUN_005311a0 at chunk_00530000.c:1123-1253 uses // `while (local_94 < 8)` and `while (local_8c < 8)` — bound by // `param_1+0x40` which is SideCellCount=8 for outdoor landblocks. // The terrain word at each cell's SW corner drives that cell's scenery. for (int x = 0; x < CellsPerSide; x++) { for (int y = 0; y < CellsPerSide; y++) { int i = x * VerticesPerSide + y; ushort raw = block.Terrain[i]; uint terrainType = (uint)((raw >> 2) & 0x1F); // bits 2-6 uint sceneType = (uint)((raw >> 11) & 0x1F); // bits 11-15 // NOTE: retail does NOT skip based on this vertex's road bit. // The road test happens AFTER displacement via the 4-corner // polygonal OnRoad check (see below). Removing the // pre-displacement early-exit restores retail behavior. // Skip cells that contain buildings. if (buildingCells is not null && buildingCells.Contains(i)) continue; if (terrainType >= region.TerrainInfo.TerrainTypes.Count) continue; var sceneTypeList = region.TerrainInfo.TerrainTypes[(int)terrainType].SceneTypes; if (sceneType >= sceneTypeList.Count) continue; uint sceneInfo = sceneTypeList[(int)sceneType]; if (sceneInfo >= region.SceneInfo.SceneTypes.Count) continue; var scenes = region.SceneInfo.SceneTypes[(int)sceneInfo].Scenes; if (scenes.Count == 0) continue; uint cellX = (uint)x; uint cellY = (uint)y; uint globalCellX = cellX + blockX; uint globalCellY = cellY + blockY; // Scene-selection hash: picks one scene from the terrain's scene list. // Decompiled: chunk_00530000.c line 1144 // iVar5 = (iVar8 * 0x2a7f2b89 + 0x6c1ac587) * iVar9 + iVar8 * -0x421be3bd + 0x7f8cda01 // where iVar8=globalCellX, iVar9=globalCellY. uint cellMat = globalCellY * (712977289u * globalCellX + 1813693831u) - 1109124029u * globalCellX + 2139937281u; double offset = cellMat * 2.3283064e-10; int sceneIdx = (int)(scenes.Count * offset); if (sceneIdx >= scenes.Count || sceneIdx < 0) sceneIdx = 0; uint sceneId = (uint)scenes[sceneIdx]; var scene = dats.Get(sceneId); if (scene is null) continue; // Per-object hashes: roll frequency, compute displacement, scale, rotation. // Decompiled: chunk_00530000.c lines 1168-1174 // iStack_60 = iVar9 * 0x6c1ac587 → cellYMat // uStack_78 = iVar9 * iVar8 * 0x5111bfef + 0x70892fb7 → cellMat2 // iStack_64 = iVar8 * -0x421be3bd → cellXMat // initial: local_90 = uStack_78 * 0x5b67 (j=0 term) // per-loop: iStack_70 = (iStack_60 - local_90) + iStack_64; local_90 += uStack_78 // ⟹ iStack_70 = cellYMat - cellMat2 * (0x5b67 + j) + cellXMat uint cellXMat = unchecked(0u - 1109124029u * globalCellX); uint cellYMat = 1813693831u * globalCellY; uint cellMat2 = 1360117743u * globalCellX * globalCellY + 1888038839u; for (uint j = 0; j < scene.Objects.Count; j++) { var obj = scene.Objects[(int)j]; if (obj.WeenieObj != 0) continue; // Weenie entries are dynamic spawns, not static scenery // Frequency roll: chunk_00530000.c line 1174 + 1179 // (fVar1 * _DAT_007c6f10 < (float)piVar11[0x11]) → noise < obj.Frequency double noise = unchecked((uint)(cellXMat + cellYMat - cellMat2 * (23399u + j))) * 2.3283064e-10; if (noise >= obj.Frequency) continue; // Displacement: pseudo-random offset within the cell. var localPos = DisplaceObject(obj, globalCellX, globalCellY, j); float lx = cellX * CellSize + localPos.X; float ly = cellY * CellSize + localPos.Y; if (lx < 0 || ly < 0 || lx >= LandblockSize || ly >= LandblockSize) continue; // Retail post-displacement road check (FUN_00530d30). // Ported from ACViewer Landblock.OnRoad — uses the 4-corner // road bits of the containing cell plus the 5-unit road // half-width to test whether the displaced (lx,ly) lies on // the road ribbon. bool isOnRoad = IsOnRoad(block, lx, ly); if (isOnRoad) { continue; } // L-fix2 (2026-04-28): the extra cell-origin road-vertex // guard previously here is REMOVED. It wasn't in the // retail decomp — it was a heuristic added to widen // road margins visually. The proper retail post- // displacement road check (FUN_00530d30 port via // IsOnRoad above) already handles road exclusion. // The extra guard was over-suppressing — every cell // whose SW corner happened to touch a road vertex // had ALL of its scenery dropped, even when the // displaced position was well clear of the ribbon. // User reported missing trees they could see in // retail; this is the most likely cause. // Slope filter (ACME conformance fix 4e): compute terrain normal // Z-component at the displaced position and check against the // object's MinSlope/MaxSlope bounds. if (heightTable is not null && (obj.MinSlope > 0f || obj.MaxSlope < 1f)) { int sx = Math.Clamp((int)(lx / CellSize), 0, VerticesPerSide - 2); int sy = Math.Clamp((int)(ly / CellSize), 0, VerticesPerSide - 2); int sxR = sx + 1; int syU = sy + 1; float h00 = heightTable[block.Height[sx * VerticesPerSide + sy]]; float h10 = heightTable[block.Height[sxR * VerticesPerSide + sy]]; float h01 = heightTable[block.Height[sx * VerticesPerSide + syU]]; float dx = (h10 - h00) / CellSize; float dy = (h01 - h00) / CellSize; float nz = 1f / MathF.Sqrt(dx * dx + dy * dy + 1f); // normal Z component if (nz < obj.MinSlope || nz > obj.MaxSlope) continue; } // BaseLoc.Z offset: scenery-specific vertical offset from // the ground (e.g., flowers planted at -0.1m so they // don't float above grass). The renderer adds groundZ // later, so pass the BaseLoc.Z through as-is. float lz = obj.BaseLoc.Origin.Z; // Rotation: chunk_005A0000.c lines 4924-4931 (FUN_005a6e60) // Retail calls FUN_00425f10(baseLoc) to copy baseLoc.Orientation // into the frame, THEN calls AFrame::set_heading(degrees). // // set_heading uses yaw = -(450 - heading) % 360 before converting // to a quaternion, which introduces a 90° offset + sign flip // relative to a naive Z rotation. WorldBuilder's // SceneryHelpers.SetHeading reproduces this. // // For objects with Align != 0, retail uses FUN_005a6f60 to // align to the landcell polygon's normal instead of setting // heading from the noise. // // Composition: final = baseLoc.Orientation * headingQuat Quaternion rotation = obj.BaseLoc.Orientation; if (rotation.LengthSquared() < 0.0001f) rotation = Quaternion.Identity; if (obj.MaxRotation > 0f) { double rotNoise = unchecked((uint)(1813693831u * globalCellY - (j + 63127u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - 1109124029u * globalCellX)) * 2.3283064e-10; float degrees = (float)(rotNoise * obj.MaxRotation); // AFrame::set_heading transform — matches retail. float yawDeg = -((450f - degrees) % 360f); float yawRad = yawDeg * MathF.PI / 180f; var headingQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRad); rotation = headingQuat * rotation; } // Scale: ACViewer Physics/Common/ObjectDesc.cs ScaleObj() (confirmed matches pattern) // offset constant 0x7f51 = 32593 (not in dumped chunks; cross-verified via ACViewer) // same LCG structure as rotation/displacement; uint cast per decompiled normalisation float scale; if (obj.MinScale == obj.MaxScale) { scale = obj.MaxScale; } else { double scaleNoise = unchecked((uint)(1813693831u * globalCellY - (j + 32593u) * (1360117743u * globalCellY * globalCellX + 1888038839u) - 1109124029u * globalCellX)) * 2.3283064e-10; scale = (float)(Math.Pow(obj.MaxScale / obj.MinScale, scaleNoise) * obj.MinScale); } if (scale <= 0) scale = 1f; result.Add(new ScenerySpawn( ObjectId: obj.ObjectId, LocalPosition: new Vector3(lx, ly, lz), Rotation: rotation, Scale: scale)); } } } return result; } /// /// Returns true if the raw terrain word indicates a road vertex. /// Bits 0-1 of the terrain word encode the road type; any non-zero value /// means the vertex is on a road. Ported from ACViewer GetRoad(). /// public static bool IsRoadVertex(ushort raw) => (raw & 0x3u) != 0; /// /// Half-width of a road ribbon in world units — the road extends from each /// road vertex by this amount into the neighbor cells. Matches retail's /// `_DAT_007c9cc0 = 5.0f` in FUN_00530d30. /// private const float RoadHalfWidth = 5.0f; /// /// Retail-faithful post-displacement road test. Ported from ACViewer /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which is /// a direct port of FUN_00530d30 in the retail client. /// /// Examines the 4 corners of the cell containing (lx, ly) and, depending /// on how many are road vertices (0, 1, 2, 3, or 4), applies a polygonal /// test using the 5-unit road half-width to check if (lx, ly) lies on the /// road ribbon. Returns true if the point is on a road. /// /// /// Retail-faithful road ribbon test — direct port of ACViewer's /// Landblock.OnRoad (Physics/Common/Landblock.cs lines 300-398), which /// itself is a port of FUN_00530d30 in acclient.exe. /// /// Classifies the 4 corners of the cell containing (lx, ly) by road type /// (bits 0-1 of the terrain word) and applies a different geometric test /// based on which corners are road vertices. Road ribbons have a 5m /// half-width (TileLength - RoadWidth = 19m). /// private static bool IsOnRoad(LandBlock block, float lx, float ly) { int x = (int)MathF.Floor(lx / CellSize); int y = (int)MathF.Floor(ly / CellSize); // Clamp so we don't index past the 9x9 terrain grid x = Math.Clamp(x, 0, CellsPerSide - 1); y = Math.Clamp(y, 0, CellsPerSide - 1); float rMin = RoadHalfWidth; // 5 float rMax = CellSize - RoadHalfWidth; // 19 // Corner road bits (ACViewer convention): // r0 = (x0, y0) = SW // r1 = (x0, y1) = NW // r2 = (x1, y0) = SE // r3 = (x1, y1) = NE bool r0 = IsRoadVertex(block.Terrain[x * VerticesPerSide + y]); bool r1 = IsRoadVertex(block.Terrain[x * VerticesPerSide + (y + 1)]); bool r2 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + y]); bool r3 = IsRoadVertex(block.Terrain[(x + 1) * VerticesPerSide + (y + 1)]); if (!r0 && !r1 && !r2 && !r3) return false; float dx = lx - x * CellSize; float dy = ly - y * CellSize; if (r0) { if (r1) { if (r2) { if (r3) return true; return dx < rMin || dy < rMin; } else { if (r3) return dx < rMin || dy > rMax; return dx < rMin; } } else { if (r2) { if (r3) return dx > rMax || dy < rMin; return dy < rMin; } else { if (r3) return MathF.Abs(dx - dy) < rMin; return dx + dy < rMin; } } } else { if (r1) { if (r2) { if (r3) return dx > rMax || dy > rMax; return MathF.Abs(dx + dy - CellSize) < rMin; } else { if (r3) return dy > rMax; return CellSize + dx - dy < rMin; } } else { if (r2) { if (r3) return dx > rMax; return CellSize - dx + dy < rMin; } else { if (r3) return CellSize * 2f - dx - dy < rMin; return false; } } } } private const int CellsPerSide = 8; /// /// Pseudo-random displacement within a cell for a scenery object. Returns a /// Vector3 in local cell-offset space (the caller adds it to the cell corner /// to get landblock-local position). /// /// Verified against decompiled acclient.exe: chunk_005A0000.c lines 4844-4903 (FUN_005a6cc0). /// X offset constant 0xb2cd = 45773; Y offset constant 0x11c0f = 72719. /// Quadrant hash: line 4880; thresholds 0.25/0.5/0.75 map to _DAT_007c97cc/_DAT_007938b8/_DAT_0079c6dc. /// Decompiled normalises signed-int LCG results with "if (val < 0) val += 2^32"; our /// unchecked((uint)(...)) is exactly equivalent. /// private static Vector3 DisplaceObject(ObjectDesc obj, uint ix, uint iy, uint iq) { float x, y; var baseLoc = obj.BaseLoc.Origin; // X displacement: chunk_005A0000.c lines 4858-4866 // iVar4 = (param_3 * 0x6c1ac587 - (param_2 * param_3 * 0x5111bfef + 0x70892fb7) * (param_4 + 0xb2cd)) + param_2 * -0x421be3bd if (obj.DisplaceX <= 0) x = baseLoc.X; else x = (float)(unchecked((uint)(1813693831u * iy - (iq + 45773u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) * 2.3283064e-10 * obj.DisplaceX + baseLoc.X); // Y displacement: chunk_005A0000.c lines 4871-4878 (same structure, offset 0x11c0f = 72719) if (obj.DisplaceY <= 0) y = baseLoc.Y; else y = (float)(unchecked((uint)(1813693831u * iy - (iq + 72719u) * (1360117743u * iy * ix + 1888038839u) - 1109124029u * ix)) * 2.3283064e-10 * obj.DisplaceY + baseLoc.Y); float z = baseLoc.Z; // Quadrant selection: chunk_005A0000.c lines 4880-4902 // iVar4 = (param_3 * 0x6c1ac587 - (param_3 * 0x6f7bd965 + 0x421be3bd) * param_2) + -0x17fcedfd // 0x6f7bd965=1870387557, 0x421be3bd=1109124029, -0x17fcedfd → -402451965 (uint: 3892515331) double quadrant = unchecked((uint)(1813693831u * iy - ix * (1870387557u * iy + 1109124029u) - 402451965u)) * 2.3283064e-10; if (quadrant >= 0.75) return new Vector3(y, -x, z); if (quadrant >= 0.5) return new Vector3(-x, -y, z); if (quadrant >= 0.25) return new Vector3(-y, x, z); return new Vector3(x, y, z); } }