using System.Numerics; using System.Runtime.InteropServices; using AcDream.App.Rendering.Scene; namespace AcDream.App.Rendering.Walk; /// /// Campaign FW3.2b-2: the production over /// the retained scene () and the FW3.1 /// . Rebuilt facts per frame via /// : /// /// /// Cell statics — on /// demand, one arena segment per distinct cell per frame (a cell can be /// visited once by the root flood OR once per admitting look-in portal; the /// per-frame cache keeps the copy single). /// Outdoor statics — ONE /// sweep bucketed by landscape cell id /// ((lb & 0xFFFF0000) | (cellX*8 + cellY + 1) from the record's /// world position — the same encoding /// computes), EXCLUDING building shells (they draw at their building's own /// shell turn, retail CPhysicsPart::Draw(parts, 0) @0x0059f331, not /// at the cell's DrawObjCell turn). /// Building shells — the same sweep's IsBuildingShell records /// bucketed by Source.BuildingShellAnchorCellId; a /// maps to its anchor via its first /// non-exit portal's destination (the SAME rule /// LandblockLoader used to author the anchor). /// /// /// The tuple landblock id handed to the classifier is the frame's player /// landblock — the packed path's own convention for every /// RenderFrameEntityDrawRequest. /// /// Campaign FW3.4a: , , /// and used to materialize their result /// with _cellScratch[..count] / [.. bucket] — a FRESH /// RenderProjectionRecord[] allocation per distinct cell/anchor per /// frame. At a town-density frame (dozens of cells) that was the single /// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation /// regression (1.9 MB/frame p50). replaces it: a /// grow-only buffer, reset to length 0 once per frame in /// , that every materialization call /// s its records into instead of snapshotting a /// new array — after the arena reaches its steady-state size (a few frames /// of warmup, same shape as /'s /// existing grow-on-demand pattern), zero further heap allocation occurs /// here. Every segment is /// STRICTLY per-frame scratch — nothing holds one across a frame boundary /// (the driver/populator consume it immediately, matching /// 's existing lifetime contract) — so reusing the /// same backing array's memory next frame is safe. /// internal sealed class WalkProductionWorldData : IWalkFrameWorldData { private readonly WalkBuildingRegistry _buildings; private RenderSceneQuery _scene; private uint _tupleLandblockId; private int _renderCenterLbX; private int _renderCenterLbY; private readonly Dictionary _cellCache = new(); private readonly Dictionary> _outdoorByCell = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); private readonly Dictionary _shellMaterialized = new(); private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; // Campaign FW3.4a: the per-frame, grow-only materialization arena — see // this type's own doc comment. private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096]; private int _arenaLength; internal WalkProductionWorldData(WalkBuildingRegistry buildings) { _buildings = buildings ?? throw new ArgumentNullException(nameof(buildings)); } /// Rebuilds the frame's outdoor/shell buckets and clears the /// per-cell cache. Call once per frame before the driver runs. /// / /// are the streaming recenter origin: record positions are /// RENDER-ORIGIN-RELATIVE (each landblock's entities carry /// (lbX − CenterX)·192 offsets), so mapping a position back to /// its TRUE landblock byte needs the center added back — the first /// connected gate of the FW3.2b-2 cutover shipped without this and most /// outdoor scenery landed in garbage buckets no walk turn ever reads. internal void BeginFrame( RenderSceneQuery scene, uint tupleLandblockId, int renderCenterLbX, int renderCenterLbY) { _scene = scene; _tupleLandblockId = tupleLandblockId; _renderCenterLbX = renderCenterLbX; _renderCenterLbY = renderCenterLbY; _cellCache.Clear(); _outdoorMaterialized.Clear(); _shellMaterialized.Clear(); _arenaLength = 0; foreach (List bucket in _outdoorByCell.Values) bucket.Clear(); foreach (List bucket in _shellsByAnchor.Values) bucket.Clear(); // CopyIndexTo THROWS on an undersized destination (ArchRenderScene // validates up front — the first connected gate run of the FW3.2b-2 // cutover crashed on exactly this at Aerlinthe's 5,040 outdoor // statics), so presize from the query's own index counts. int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic); if (required > _sweepScratch.Length) { _sweepScratch = new RenderProjectionRecord[ Math.Max(required, _sweepScratch.Length * 2)]; } int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch); for (int i = 0; i < count; i++) { ref readonly RenderProjectionRecord record = ref _sweepScratch[i]; if (record.EntityPayload.IsBuildingShell) { uint anchor = record.Source.BuildingShellAnchorCellId; if (!_shellsByAnchor.TryGetValue(anchor, out List? shells)) _shellsByAnchor[anchor] = shells = new List(); shells.Add(record); continue; } uint cellId = LandscapeCellId( record.Transform.Position, _renderCenterLbX, _renderCenterLbY); if (!_outdoorByCell.TryGetValue(cellId, out List? bucket)) _outdoorByCell[cellId] = bucket = new List(); bucket.Add(record); } } /// The landscape cell owning a RENDER-ORIGIN-RELATIVE position /// — retail's 24 m cell grid inside the 192 m landblock, producing the /// same TRUE (lb & 0xFFFF0000) | (cellX*8 + cellY + 1) encoding /// the walk's landscape turn emits: the relative block index /// (floor(p/192)) plus the streaming center recovers the true /// landblock byte, because entity positions carry /// (lbX − CenterX)·192 world offsets /// (LandblockBuildFactory's worldOffset). internal static uint LandscapeCellId( Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY) { int relBlockX = (int)MathF.Floor(relativePosition.X / 192f); int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f); float localX = relativePosition.X - relBlockX * 192f; float localY = relativePosition.Y - relBlockY * 192f; int cellX = Math.Clamp((int)(localX / 24f), 0, 7); int cellY = Math.Clamp((int)(localY / 24f), 0, 7); uint landblock = ((uint)(byte)(renderCenterLbX + relBlockX) << 24) | ((uint)(byte)(renderCenterLbY + relBlockY) << 16); return landblock | (uint)(cellX * 8 + cellY + 1); } public WalkFrameStaticRecords GetCellStatics(uint cellId) { if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; // Same up-front-validation contract as CopyIndexTo: presize from the // query's own count rather than probing with an undersized span. int required = _scene.GetCellStaticCount(cellId); if (required > _cellScratch.Length) { _cellScratch = new RenderProjectionRecord[ Math.Max(required, _cellScratch.Length * 2)]; } int count = _scene.CopyCellStaticsTo(cellId, _cellScratch); WalkFrameStaticRecords records = count == 0 ? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId } : new WalkFrameStaticRecords( AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId); _cellCache[cellId] = records; return records; } public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) { if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached)) return cached; WalkFrameStaticRecords records = _outdoorByCell.TryGetValue(cellId, out List? bucket) && bucket.Count > 0 ? new WalkFrameStaticRecords( AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; _outdoorMaterialized[cellId] = records; return records; } public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) { uint anchor = AnchorCellId(building); if (anchor == 0) return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached)) return cached; WalkFrameStaticRecords records = _shellsByAnchor.TryGetValue(anchor, out List? shells) && shells.Count > 0 ? new WalkFrameStaticRecords( AppendToArena(CollectionsMarshal.AsSpan(shells)), _tupleLandblockId) : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; _shellMaterialized[anchor] = records; return records; } /// Copies into at /// its current length, growing the arena first if needed (doubling, or /// exactly enough for an unusually large sweep — the same growth shape /// / already use), /// and returns the segment the copy landed in. A prior frame's growth can /// leave an earlier-returned segment pointing at a retired backing array /// — harmless, since that array's content stays valid and nothing reads /// a segment across a frame boundary (see this type's own doc /// comment). private ArraySegment AppendToArena( ReadOnlySpan source) { if (source.Length == 0) return ArraySegment.Empty; int required = _arenaLength + source.Length; if (required > _arena.Length) { var grown = new RenderProjectionRecord[Math.Max(required, _arena.Length * 2)]; Array.Copy(_arena, grown, _arenaLength); _arena = grown; } source.CopyTo(_arena.AsSpan(_arenaLength, source.Length)); var segment = new ArraySegment(_arena, _arenaLength, source.Length); _arenaLength += source.Length; return segment; } /// The building's authored shell anchor: its first non-exit /// portal's destination cell — the SAME rule LandblockLoader used /// when it stamped BuildingShellAnchorCellId on the shell entity. internal static uint AnchorCellId(WalkBuilding building) { foreach (ref readonly WalkBldPortal portal in building.Portals.AsSpan()) { if (portal.OtherCellId != 0xFFFFFFFFu) return portal.OtherCellId; } return 0; } public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building) { if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry)) { throw new InvalidOperationException( $"walk building 0x{building.PositionCellId:X8} has no committed registry entry"); } return entry.WorldTransform; } }