From 15e320490d3cd21b0caa24222d03691e754323e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 22 Jun 2026 22:40:18 +0200 Subject: [PATCH] fix(world+streaming): trees-in-sky Z, O(1) anim, teleport near-ring + immediate unloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three apparatus-confirmed fixes from the world-load/FPS deep-dive (all live-verified). 1. trees-in-sky — scenery ground-Z now samples THIS landblock's OWN heightmap (TerrainSurface.SampleZFromHeightmap, lock-step with the physics terrain) instead of the global PhysicsEngine.SampleTerrainZ query. At build time the landblock isn't registered in physics yet, so that query could only return null OR a STALE neighbour's height — the previous location's terrain, still registered after a teleport recenter — planting scenery at the old altitude (+250..500m, confirmed via the [scenery-z-stale] probe). Own-heightmap is correct in every case; the query is removed. (GameWindow.BuildSceneryEntitiesForStreaming) 2. FPS per-hop — TickAnimations recovered each animated entity's server guid via an O(N) ReferenceEquals reverse scan over ALL _entitiesByServerGuid (which never evicts, so N climbs every teleport — the drops-with-each-hop sink). Replaced with ae.Entity.ServerGuid: O(1), exact-equivalent (the dict key IS entity.ServerGuid). (GameWindow.TickAnimations) 3. teleport arrival + bulk floating terrain — two streaming fixes: - Near-ring eager-apply: a teleport applies the destination's 3x3 surroundings (StreamingController.PriorityRadius) and holds the fade until they're resident (PhysicsEngine.IsNeighborhoodTerrainResident), so the player arrives in a loaded, collidable world instead of one landblock in the void. - Immediate unloads: DrainAndApply no longer throttles UNLOADS at the per-frame load budget — they're cheap (free GPU buffers, no upload). A teleport produced ~600 unloads draining at 4/frame, leaving the previous region resident for seconds (floating terrain) and accumulating across rapid hops (951 resident vs a 625 window). Only GPU-upload LOADS are metered now. Cut out-of-window resident 650 -> 63 and resident 951 -> 688 (live-verified via [resid-audit]). Includes gated-off diagnostic probes (ACDREAM_PROBE_SCENERY_FRAME / _BLDG_REACH / _STREAM_RESID) used to root-cause the above — zero-cost when unset, same pattern as the committed tp-probe. The pre-existing teleport-induced "terrain arcs in the sky" (present in the dd2eb8b baseline too, with NONE of this work) are a SEPARATE bug — investigated next. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 152 ++++++++++++++++-- .../Streaming/StreamingController.cs | 101 +++++++----- src/AcDream.Core/Physics/CellTransit.cs | 21 +++ src/AcDream.Core/Physics/PhysicsDataCache.cs | 6 + .../Physics/PhysicsDiagnostics.cs | 135 ++++++++++++++++ src/AcDream.Core/Physics/PhysicsEngine.cs | 28 ++++ 6 files changed, 382 insertions(+), 61 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8c1ac48e..329e4ad7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -805,6 +805,18 @@ public sealed class GameWindow : IDisposable private int _liveCenterY; private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id + // Trees-in-sky probe (2026-06-22 — REMOVABLE apparatus, see + // PhysicsDiagnostics.ProbeSceneryFrameEnabled). The streaming worker records + // here, per landblock, the _liveCenter it baked scenery against at BUILD time; + // ApplyLoadedTerrainLocked reads it back to compare against the APPLY-time + // center. Written/read only when the probe is on, so this stays empty in + // normal play. + private readonly System.Collections.Concurrent.ConcurrentDictionary + _sceneryBuildCenterByLandblock = new(); + + // Floating-terrain probe throttle (REMOVABLE, see PhysicsDiagnostics.ProbeStreamResidEnabled). + private int _residAuditTick; + // K-fix1 (2026-04-26): cached at startup so per-frame branches are // single-flag reads instead of env-var lookups. True iff // ACDREAM_LIVE=1 was set when the window came up. @@ -5476,8 +5488,14 @@ public sealed class GameWindow : IDisposable _teleportHoldSeconds = 0f; _teleportForced = false; if (_streamingController is not null) + { _streamingController.PriorityLandblockId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); + // Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not + // just the one landblock the player stands on — so they arrive in a loaded, + // collidable world instead of a single landblock in the void. + _streamingController.PriorityRadius = TeleportNearRingRadius; + } AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "AIM", p.LandblockId, $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}"); @@ -5504,6 +5522,14 @@ public sealed class GameWindow : IDisposable // Now rarely fires because priority-apply makes residency fast. private const float TeleportMaxHoldSeconds = 10f; + // 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply + // (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination — + // the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive + // standing on loaded ground with wall collision and a non-empty immediate view. The far + // ring (out to the streaming window) still drains at the per-frame budget after the fade + // lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade. + private const int TeleportNearRingRadius = 1; + // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player // body's cell-relative CellPosition. This is the ONE place the streaming center // (_liveCenter) is allowed to touch the physics frame — at the placement seam, @@ -5523,10 +5549,14 @@ public sealed class GameWindow : IDisposable /// /// worldReady for the TAS transit: is the player's teleport destination resident so we /// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell - /// struct hydrating (#135); outdoor gates on the destination terrain landblock being - /// registered (priority-applied). An impossible claim (indoor cell id outside the dat's - /// NumCells) returns true so the TAS stops holding and the forced placement surfaces the - /// failure loudly rather than holding forever. + /// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around + /// the player, ) being registered — not just the + /// single destination landblock — so the fade only lifts onto a loaded, collidable world + /// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no + /// "only one landblock loaded"). The streaming controller priority-applies that same ring, + /// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells) + /// returns true so the TAS stops holding and the forced placement surfaces the failure + /// loudly rather than holding forever. /// private bool TeleportWorldReady(uint destCell) { @@ -5534,7 +5564,7 @@ public sealed class GameWindow : IDisposable bool indoor = (destCell & 0xFFFFu) >= 0x0100u; return indoor ? _physicsEngine.IsSpawnCellReady(destCell) - : _physicsEngine.IsLandblockTerrainResident(destCell); + : _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius); } // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only @@ -6003,6 +6033,13 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); + // Trees-in-sky probe (REMOVABLE): stamp the streaming center this scenery + // was baked against. ApplyLoadedTerrainLocked compares it to the apply-time + // center; a delta is the frame split that floats trees. Capture is the exact + // _liveCenter used in lbOffset above (the worker-thread read). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled) + _sceneryBuildCenterByLandblock[lb.LandblockId] = (_liveCenterX, _liveCenterY); + // Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF // where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23). // Both must go into our ID so landblocks don't collide. @@ -6012,6 +6049,7 @@ public sealed class GameWindow : IDisposable uint lbYByte = (lb.LandblockId >> 16) & 0xFFu; uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8); uint localIndex = 0; + bool loggedStaleZ = false; // trees-in-sky probe: one [scenery-z-stale] line per lb foreach (var spawn in spawns) { @@ -6074,11 +6112,36 @@ public sealed class GameWindow : IDisposable // fall back to the local bilinear sample. var worldPx = localX + lbOffset.X; var worldPy = localY + lbOffset.Y; - float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy); - float groundZ = maybePhysicsZ - ?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + // FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS + // landblock's OWN heightmap — the same triangle-aware Z the player walks on + // (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48), + // scoped to the landblock being built. The former global + // _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at + // build time this landblock is NOT registered in physics yet, so that query + // could only return null (→ this same own-heightmap) or a STALE neighbor's + // height — the previous location's terrain, still registered after a teleport + // recenter (which drops only the single stale CENTER landblock, GameWindow + // :5444) until streaming unloads it — planting scenery at the old location's + // altitude (trees-in-sky, deltaZ up to +500m; confirmed via the + // [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every + // case, so the global query is removed (also drops its per-spawn cost). + float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); float finalZ = groundZ + spawn.LocalPosition.Z; + // Trees-in-sky probe (REMOVABLE — strip after verification). Proves the fix: + // compute what the OLD global physics query WOULD have returned and compare + // to the own-heightmap groundZ we now use. A large divergence is the stale- + // neighbor altitude the old code floated scenery to — and no longer does. + if (!loggedStaleZ + && AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled + && _physicsEngine.SampleTerrainZ(worldPx, worldPy) is { } wouldBePhysicsZ + && System.MathF.Abs(wouldBePhysicsZ - groundZ) > 5f) + { + AcDream.Core.Physics.PhysicsDiagnostics.LogSceneryZStale( + lb.LandblockId, worldPx, worldPy, wouldBePhysicsZ, groundZ); + loggedStaleZ = true; + } + // Issue #48 diagnostic. One log line per (spawn, rendered-mesh) // disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2 // (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0). @@ -6086,7 +6149,9 @@ public sealed class GameWindow : IDisposable // line by world coords + gfx id, the data picks the hypothesis. if (_options.DumpSceneryZ) { - string source = maybePhysicsZ.HasValue ? "physics" : "bilinear"; + // groundZ now always comes from THIS landblock's own heightmap (the + // global physics query was removed — see the trees-in-sky fix above). + string source = "heightmap"; foreach (var mr in meshRefs) { var dgfx = _dats.Get(mr.GfxObjId); @@ -6627,6 +6692,33 @@ public sealed class GameWindow : IDisposable (lbY - _liveCenterY) * 192f, 0f); + // Trees-in-sky probe (REMOVABLE): compare the streaming center this + // landblock's SCENERY was baked against (worker, build time) to the center + // its TERRAIN is being placed against here (render, apply time). A non-zero + // deltaLB is the frame split that floats scenery off its terrain. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeSceneryFrameEnabled) + { + var bc = _sceneryBuildCenterByLandblock.TryGetValue(lb.LandblockId, out var c) + ? c : (_liveCenterX, _liveCenterY); + AcDream.Core.Physics.PhysicsDiagnostics.LogSceneryFrame( + lb.LandblockId, bc.Item1, bc.Item2, _liveCenterX, _liveCenterY); + _sceneryBuildCenterByLandblock.TryRemove(lb.LandblockId, out _); + } + + // Floating-terrain probe (REMOVABLE): this landblock is being APPLIED at the + // current center; if its Chebyshev distance from the center exceeds the streaming + // window, it's an in-flight load from a PREVIOUS center applying against the new + // one — it will render far off at the horizon (a floating "terrain trace"). + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeStreamResidEnabled + && _streamingController is not null) + { + int applyCheby = System.Math.Max( + System.Math.Abs(lbX - _liveCenterX), System.Math.Abs(lbY - _liveCenterY)); + if (applyCheby > _streamingController.FarRadius) + AcDream.Core.Physics.PhysicsDiagnostics.LogApplyOob( + lb.LandblockId, applyCheby, _streamingController.FarRadius, origin.X, origin.Y); + } + // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named // two-tier entry point. Delegates to AddLandblock internally; both // paths share one GPU upload path. @@ -7575,6 +7667,25 @@ public sealed class GameWindow : IDisposable foreach (var entity in rescued) _worldState.AppendLiveEntity(centerLb, entity); } + + // Floating-terrain probe (REMOVABLE): ~2×/sec, list resident landblocks sitting + // OUTSIDE the streaming window (Chebyshev > FarRadius from the current center) — + // stale old-location terrain that never unloaded. Same thread as the Tick above, + // so LoadedLandblockIds is stable. Zero cost when the probe is off. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeStreamResidEnabled + && (++_residAuditTick % 30 == 0)) + { + var oob = new List(); + foreach (var id in _worldState.LoadedLandblockIds) + { + int lx = (int)((id >> 24) & 0xFFu), ly = (int)((id >> 16) & 0xFFu); + if (System.Math.Max(System.Math.Abs(lx - _liveCenterX), System.Math.Abs(ly - _liveCenterY)) + > _streamingController.FarRadius) + oob.Add(id); + } + AcDream.Core.Physics.PhysicsDiagnostics.LogResidentAudit( + _liveCenterX, _liveCenterY, _worldState.LoadedLandblockIds.Count, oob); + } } // Drain pending live-session traffic AFTER streaming so any incoming @@ -7615,7 +7726,10 @@ public sealed class GameWindow : IDisposable case AcDream.Core.World.TeleportAnimEvent.Place: PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); if (_streamingController is not null) + { _streamingController.PriorityLandblockId = 0u; + _streamingController.PriorityRadius = 0; + } break; case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: if (_playerController is not null) @@ -9247,14 +9361,18 @@ public sealed class GameWindow : IDisposable { var ae = kv.Value; - // Locate the server guid for this entity once per tick — needed - // for dead-reckoning. O(N) reverse lookup; for player populations - // < 100 the cost is negligible. - uint serverGuid = 0; - foreach (var esg in _entitiesByServerGuid) - { - if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } - } + // The server guid is carried on the entity itself + // (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at + // construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid` + // and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly — + // O(1) — instead of the former reverse ReferenceEquals scan over ALL of + // _entitiesByServerGuid, which made this loop O(animated × all-entities). + // That super-linear cost is the FPS-drops-per-teleport sink: world + // entities accumulate without bound (pruned only on DeleteObject, and ACE + // does not re-send / clear KnownObjects across a teleport), so N climbs + // every hop. ServerGuid defaults to 0 for entities not server-tracked, + // exactly matching the old scan's miss case. + uint serverGuid = ae.Entity.ServerGuid; // ── Dead-reckoning: smooth position between UpdatePosition bursts. // The server broadcasts UpdatePosition at ~5-10Hz for distant diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index f2119b2e..28203d55 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -107,6 +107,21 @@ public sealed class StreamingController /// public uint PriorityLandblockId { get; set; } + /// + /// 2026-06-22: the radius (in landblocks, Chebyshev) around + /// that eager-applies + /// ahead of the per-frame budget. 0 (default) = only the single center landblock — the + /// original priority behaviour. A teleport sets this to the near ring so the player's + /// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not + /// just the one landblock they stand on. Without it, only the destination landblock + /// applies immediately and everything around it drains at , + /// so the player arrives to a near-empty world (the "Fort Tethana only one landblock + /// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the + /// transient walk-through-walls). The far ring still drains at the budget. The eager + /// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden. + /// + public int PriorityRadius { get; set; } + // Completions that were drained past a priority item get buffered here // so they still apply over subsequent frames without loss. private readonly List _deferredApply = new(); @@ -293,6 +308,17 @@ public sealed class StreamingController return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by)); } + /// + /// True when is the priority center or within + /// landblocks of it (Chebyshev) — i.e. inside the teleport + /// near ring that eager-applies. With the default radius 0 + /// this reduces to an exact match on (the original + /// single-landblock priority behaviour). + /// + private bool IsWithinPriorityRing(uint id) + => PriorityLandblockId != 0u + && ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius; + /// /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full /// two-tier window at the new center and unload anything resident from the @@ -319,62 +345,49 @@ public sealed class StreamingController } /// - /// Drain up to N completions per frame so a big diff doesn't spike GPU - /// upload time. Remaining completions wait for the next frame. - /// - /// - /// When is set (non-zero), the priority - /// landblock is applied immediately even if it sits past position N in the - /// outbox. Non-priority completions drained past it are buffered in - /// and applied over subsequent frames - /// (no loss, no GPU spike). - /// + /// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the + /// expensive part, so they are metered at to avoid a + /// GPU-upload spike; the overflow buffers in and drains over + /// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied + /// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and + /// metering them at the load rate left the previous location's terrain resident for seconds + /// (rendering at its old world position as "floating terrain at the horizon"), and rapid + /// hops accumulated them faster than they cleared — a runaway resident count (951 observed + /// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring + /// (, applied behind the fade) likewise bypass the budget so the + /// player materialises in a loaded world. /// private void DrainAndApply() { - // --- Step 1: priority hunt FIRST and UNCONDITIONALLY (when a destination is set). - // The teleport destination must apply the frame the worker finishes building it, - // ahead of any deferred/outbox backlog — otherwise a big dungeon-exit expand - // (hundreds of completions) buries it and the arrival times out into the skybox. - // CRITICAL: this must NOT be gated behind the per-frame budget. The prior version - // ran the deferred drain first and returned when the budget hit 0, so once - // _deferredApply held >= MaxCompletionsPerFrame items the hunt (and the outbox - // drain) were skipped entirely — the destination never priority-applied. The hunt - // drains the outbox in bounded chunks; the priority is applied on match, every - // non-priority item is buffered in _deferredApply (no loss). - if (PriorityLandblockId != 0u) + // --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring + // loads immediately; defer every other (budget-metered) load. Draining the whole + // outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush + // promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on + // freeing them. The drain cap must NOT be gated behind the per-frame load budget + // (the prior version returned once the budget hit 0, stranding the outbox). + const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame + int iter = 0; + while (iter++ < MaxDrainIterations) { - const int MaxHuntIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame - int hunted = 0; - while (hunted < MaxHuntIterations) + var chunk = _drainCompletions(MaxCompletionsPerFrame); + if (chunk.Count == 0) break; + foreach (var result in chunk) { - var chunk = _drainCompletions(MaxCompletionsPerFrame); - if (chunk.Count == 0) break; - foreach (var result in chunk) - { - hunted++; - if (ResultLandblockId(result) == PriorityLandblockId) - ApplyResult(result); // applied immediately, ahead of the budget - else - _deferredApply.Add(result); - } + if (result is LandblockStreamResult.Unloaded + || IsWithinPriorityRing(ResultLandblockId(result))) + ApplyResult(result); // free (unload) or behind-the-fade near ring + else + _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 } } - // --- Step 2: apply up to MaxCompletionsPerFrame this frame — the deferred backlog - // first (FIFO), then fresh outbox completions. Caps GPU upload per frame so a big - // diff doesn't spike. While a priority is set, Step 1 has already moved the outbox - // into _deferredApply, so this drains that backlog at the budget rate. + // --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so + // earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't + // spike. _deferredApply now only ever holds loads — unloads were applied in step 1. int budget = MaxCompletionsPerFrame; int i = 0; while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; } if (i > 0) _deferredApply.RemoveRange(0, i); - budget -= i; - if (budget <= 0) return; - - var drained = _drainCompletions(budget); - foreach (var result in drained) - ApplyResult(result); } /// diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index a5977dd3..61c7efc6 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -859,6 +859,27 @@ public static class CellTransit if (PhysicsDiagnostics.ProbeCellSetEnabled) PhysicsDiagnostics.LogCellSetBuild(currentCellId, worldSphereCenter, candidates); + // Lost-collision probe (REMOVABLE): in a landblock that HAS cached buildings, + // report which of them are reachable from the player's swept candidate set. + // reachable=NO while standing at a wall ⇒ the building IS cached but the + // post-teleport cell resolution excludes its landcell (#145); empty cachedInLb + // ⇒ the landblock's CacheBuilding never ran. One line per walk, near buildings. + if (PhysicsDiagnostics.ProbeBuildingReachEnabled) + { + uint prefix = currentCellId & 0xFFFF0000u; + var cachedInLb = new List(); + foreach (var bid in cache.BuildingIds) + if ((bid & 0xFFFF0000u) == prefix) cachedInLb.Add(bid); + var inSet = new List(); + for (int ci = 0; ci < candidates.Count; ci++) + { + uint cid = candidates.OrderedIds[ci]; + if ((cid & 0xFFFFu) < 0x0100u && cache.GetBuilding(cid) is not null) + inSet.Add(cid); + } + PhysicsDiagnostics.LogBuildingReach(currentCellId, candidates.Count, cachedInLb, inSet); + } + // THE PICK — verbatim CObjCell::find_cell_list containing-cell pick // (pseudo_c:308788-308825): iterate the array IN ORDER from index 0; for each // cell, point_in_cell; set the running result on ANY containing cell; diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 7218e016..b3f1044e 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -441,6 +441,12 @@ public sealed class PhysicsDataCache public void CacheBuilding(uint landcellId, IReadOnlyList portals, Matrix4x4 worldTransform, uint modelId = 0u) { + // Lost-collision probe (REMOVABLE): record every cache attempt + whether it + // actually took (first-wins) so we can see if/when a landblock's buildings + // get cached after a teleport. + AcDream.Core.Physics.PhysicsDiagnostics.LogBuildingCache( + landcellId, cached: !_buildings.ContainsKey(landcellId)); + if (_buildings.ContainsKey(landcellId)) return; Matrix4x4.Invert(worldTransform, out var inverse); _buildings[landcellId] = new BuildingPhysics diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 1412249c..d75e81ec 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -448,6 +448,138 @@ public static class PhysicsDiagnostics $"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}")); } + /// + /// Trees-in-sky frame-split probe (2026-06-22 — REMOVABLE apparatus). The + /// streaming worker bakes each landblock's SCENERY world position against the + /// _liveCenter that is current when it BUILDS the landblock, while the + /// render thread positions that landblock's TERRAIN against the + /// _liveCenter that is current when it APPLIES it. A teleport recenters + /// _liveCenter between those two moments, so a landblock built before the + /// recenter and applied after it has its scenery offset from its terrain by + /// deltaLB × 192m in XY — the "arc of trees floating in the sky" after a + /// teleport. When enabled, BuildSceneryEntitiesForStreaming records the + /// build-time center and ApplyLoadedTerrainLocked emits one + /// [scenery-frame] line per landblock comparing build vs apply center; a + /// non-zero deltaLB on a misplaced landblock confirms the mechanism. + /// Initial state from ACDREAM_PROBE_SCENERY_FRAME=1. Zero cost when off. + /// + public static bool ProbeSceneryFrameEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_SCENERY_FRAME") == "1"; + + /// + /// One [scenery-frame] line. Self-guards on + /// . A non-zero deltaLB means this + /// landblock's scenery was baked against a different streaming center than its + /// terrain was applied against — the trees-in-sky frame split. + /// + public static void LogSceneryFrame( + uint landblockId, int buildCx, int buildCy, int applyCx, int applyCy) + { + if (!ProbeSceneryFrameEnabled) return; + int dx = buildCx - applyCx, dy = buildCy - applyCy; + Console.WriteLine(System.FormattableString.Invariant( + $"[scenery-frame] lb=0x{landblockId:X8} build=({buildCx},{buildCy}) apply=({applyCx},{applyCy}) deltaLB=({dx},{dy}) offsetM=({dx * 192f:F1},{dy * 192f:F1})")); + } + + /// + /// One [scenery-z-stale] line (self-guards on + /// ). Emitted when, at scenery build, the + /// physics-engine terrain sample (SampleTerrainZ, which searches ALL + /// registered landblocks) disagrees materially with the landblock's OWN + /// heightmap. That divergence means the physics query resolved the world point + /// into a STALE neighbor landblock — the previous location's terrain, still + /// registered after a teleport recenter (which removes only the single stale + /// center landblock) until streaming unloads it — and returned ITS height. The + /// scenery is then planted at the old location's altitude: trees-in-sky. The + /// own-heightmap value () is the correct Z. + /// + public static void LogSceneryZStale( + uint landblockId, float worldX, float worldY, float physicsZ, float ownZ) + { + if (!ProbeSceneryFrameEnabled) return; + Console.WriteLine(System.FormattableString.Invariant( + $"[scenery-z-stale] lb=0x{landblockId:X8} world=({worldX:F1},{worldY:F1}) physicsZ={physicsZ:F2} ownZ={ownZ:F2} deltaZ={physicsZ - ownZ:F2}")); + } + + /// + /// Lost-building-collision probe (2026-06-22 — REMOVABLE apparatus). Building + /// WALL collision is the dat-baked PhysicsDataCache.CacheBuilding channel + /// (first-wins, never removed), reached at resolve time only when a candidate + /// landcell in the player's swept cell-walk hits GetBuilding(cellId). When + /// enabled: [bldg-cache] logs each CacheBuilding (CACHED vs first-wins + /// skip) so we can see whether/when a landblock's buildings cache; and + /// [bldg-reach] logs, per cell-walk in a landblock that HAS cached + /// buildings, which of them landed in the player's candidate set + /// (reachable=YES/NO). NO while standing at a wall ⇒ the building is cached + /// but the player's post-teleport cell resolution excludes it (#145 family); + /// empty cachedInLb ⇒ the landblock's CacheBuilding never ran (apply + /// backlog). Initial state from ACDREAM_PROBE_BLDG_REACH=1. + /// + public static bool ProbeBuildingReachEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_BLDG_REACH") == "1"; + + public static void LogBuildingCache(uint landcellId, bool cached) + { + if (!ProbeBuildingReachEnabled) return; + Console.WriteLine(System.FormattableString.Invariant( + $"[bldg-cache] landcell=0x{landcellId:X8} {(cached ? "CACHED" : "skip(first-wins)")}")); + } + + public static void LogBuildingReach( + uint seedCell, int candidateCount, + IReadOnlyCollection cachedInLb, IReadOnlyCollection inCandidateSet) + { + if (!ProbeBuildingReachEnabled) return; + if (cachedInLb.Count == 0) return; // only interesting in a landblock with buildings + Console.WriteLine(System.FormattableString.Invariant( + $"[bldg-reach] seed=0x{seedCell:X8} cands={candidateCount} cachedInLb=[{HexList(cachedInLb)}] inSet=[{HexList(inCandidateSet)}] reachable={(inCandidateSet.Count > 0 ? "YES" : "NO")}")); + } + + private static string HexList(IReadOnlyCollection ids) + { + var sb = new System.Text.StringBuilder(); + bool first = true; + foreach (var id in ids) + { + if (!first) sb.Append(','); + sb.Append(System.FormattableString.Invariant($"0x{id:X8}")); + first = false; + } + return sb.ToString(); + } + + /// + /// Floating-terrain probe (2026-06-22 — REMOVABLE apparatus). The thin terrain + /// "traces" at the horizon after rapid multi-hop teleporting are landblocks rendering + /// at a STALE world position — their Chebyshev distance from the current streaming + /// center exceeds the window (FarRadius), so their world origin is off by the teleport + /// distance. When enabled: [apply-oob] fires when a landblock is APPLIED while + /// out-of-window (an in-flight load from a PREVIOUS center, applied against the new one); + /// [resid-audit] (throttled) lists every RESIDENT landblock sitting out-of-window + /// (un-cleared old-location terrain that never unloaded). A persistent non-zero + /// outOfWindow count is the floating terrain; whether it arrives via apply-oob or + /// lingers as residents tells us which mechanism. Initial state from + /// ACDREAM_PROBE_STREAM_RESID=1. + /// + public static bool ProbeStreamResidEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_STREAM_RESID") == "1"; + + public static void LogApplyOob(uint landblockId, int cheby, int farRadius, float ox, float oy) + { + if (!ProbeStreamResidEnabled) return; + Console.WriteLine(System.FormattableString.Invariant( + $"[apply-oob] lb=0x{landblockId:X8} cheby={cheby} (>{farRadius}) origin=({ox:F0},{oy:F0}) — applied OUTSIDE the streaming window (in-flight stale-location apply)")); + } + + public static void LogResidentAudit( + int centerX, int centerY, int residentTotal, IReadOnlyCollection outOfWindow) + { + if (!ProbeStreamResidEnabled) return; + if (outOfWindow.Count == 0) return; // only interesting when stale terrain is resident + Console.WriteLine(System.FormattableString.Invariant( + $"[resid-audit] center=({centerX},{centerY}) resident={residentTotal} outOfWindow={outOfWindow.Count} oob=[{HexList(outOfWindow)}]")); + } + /// /// A6.P3 issue #98 step-walk investigation (2026-05-23). When true, /// emits one [step-walk] line at selected points in the transition @@ -575,6 +707,9 @@ public static class PhysicsDiagnostics ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; ProbeTeleportEnabled = false; + ProbeSceneryFrameEnabled = false; + ProbeBuildingReachEnabled = false; + ProbeStreamResidEnabled = false; // Side-channel fields LastBspHitPoly = null; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 292eeec4..e240fec0 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -45,6 +45,34 @@ public sealed class PhysicsEngine return false; } + /// + /// True once EVERY in-bounds landblock within Chebyshev of the + /// landblock covering has had its terrain registered. + /// This is the teleport "surroundings are loaded" gate: holding the fade until the player's + /// own landblock AND its immediate neighbours are resident means they arrive standing on a + /// loaded, collidable world (their cell-walk can root into neighbour cells) instead of a + /// single landblock floating in the void. Off-map neighbours (coords outside 0..254) are + /// skipped — they never load, so requiring them would hang the hold until the wall-clock + /// timeout. radius 0 is equivalent to . + /// + public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius) + { + var resident = new HashSet(); + foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u); + + int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); + int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); + for (int dx = -radius; dx <= radius; dx++) + for (int dy = -radius; dy <= radius; dy++) + { + int nx = cx + dx, ny = cy + dy; + if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip + uint prefix = ((uint)nx << 24) | ((uint)ny << 16); + if (!resident.Contains(prefix)) return false; + } + return true; + } + /// /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system.