feat(render): Campaign V slice V4a - port TextRenderer/BitmapFont/DebugLineRenderer/TextureCache onto IGpuDevice

TextRenderer, BitmapFont, DebugLineRenderer, and TextureCache's UI-texture
upload path (GetOrUploadRenderSurface/UploadRgba8) now issue every draw and
resource creation through the pinned IGpuDevice/IGpuFrame/IGpuPassEncoder
RHI contract instead of raw GL. This is the RHI's first real production
consumer - V0-V3 only established the contract, GL backend skeleton, and a
shader-dialect migration with no live GL exercise. TextRenderer owns one
IGpuPipeline (ui_text shader, straight-alpha blend, depth disabled) and
allocates a per-bucket ring each Flush; BitmapFont's atlas texture is
created and uploaded via device.CreateTexture/.Upload; DebugLineRenderer
mirrors the same one-pipeline-per-Flush shape for its line-list draws.
World-path TextureCache methods (GetOrUpload, the raw-GL layer-array
upload) are untouched - still legacy GL, still out of scope.

Frame lifecycle: GpuDeviceFrameLifetime (RenderFrameOrchestrator.cs) wraps
IGpuDevice.BeginFrame()/IGpuFrame.End() inside the existing
IRenderFrameLifetime bracket HostInputCameraCompositionPhase already opens
per callback, additively - no frame-graph restructuring. Ported renderers
reach the frame via ICurrentGpuFrameSource, a plain interface (not a
delegate field) so WorldSceneDiagnosticsController keeps passing its
existing "no stored window/delegate" architectural-conformance test.

Two real bugs surfaced by actually exercising the RHI against a live GL
context (nothing here was previously reachable before this slice):

- GlGpuDevice.BeginFrame() now resets the render-state cache every frame.
  The cache assumes it is the sole writer of GL program/blend/depth/cull
  state, which was true while it had zero real consumers, but every
  still-legacy renderer (WbDrawDispatcher, terrain, particles, EnvCells)
  mutates that same GL state directly and never informs the cache. Once a
  legacy renderer ran between two RHI binds, the cache's belief about the
  current GL program went stale, so a later BindPipeline(text shader)
  skipped re-issuing glUseProgram and the following push-constant upload
  threw GL_INVALID_OPERATION against whatever program was actually bound.
  Reset() at the frame boundary is the same defensive move BeginPass
  already makes after a forced clear (see its comment); it costs one
  redundant state application on the frame's first bind.
- GL_MULTISAMPLE has no representation in the pinned contract. Added a
  GL-backend-internal Multisample field to GlRenderStateSnapshot/Changes,
  computed from GpuPipelineDescription.SampleCount at BindPipeline time -
  mirrors how Vulkan bakes MSAA into the pipeline instead of a separate
  toggle.

Collateral, scoped to keep the port real rather than a stub:

- GpuTextureSlot (Unassigned = uint.MaxValue, NOT 0) now flows through
  every consumer of TextureCache.GetOrUploadRenderSurface/UploadRgba8 and
  TextRenderer.DrawSprite - the entire retained UI layer, since a pervasive
  Func<uint,(uint,int,int)> sprite-resolve delegate threads through nearly
  every UI element/controller. Every prior `== 0` / `!= 0` "no texture"
  check became `.IsAssigned` / `!.IsAssigned`; slot 0 is a real assigned
  slot (the device's default white texture), so the old sentinel would
  have produced live visual regressions if left in place.
- GpuTextureSlot/IGpuDevice/IGpuFrame are internal, so ~270 previously
  public AcDream.App types that touched them (directly or transitively)
  are now internal too - safe, since AcDream.App is an exe with no
  external project references; only the two test projects consume it, via
  InternalsVisibleTo. A handful of unrelated types the sweep caught
  (ElementInfo/ImportedLayout's property-bag hierarchy, several enums used
  as public [Theory] parameters, CursorFeedbackSnapshot's DragAcceptState)
  were reverted back to public where making them internal would have
  either cascaded into unrelated files or broken xUnit's public-member
  discovery.
- ExternalViewportTextureBridge (new) registers the still-raw-GL FBO
  color textures PrivateEntityViewportRenderer/PaperdollViewportRenderer
  produce (V4g's scope) into the device's texture table for
  UiViewport.TextureHandle, via a temporary
  GlGpuDevice.RegisterExternalColorTexture escape hatch (internal, not
  part of IGpuDevice) deleted when V4g ports those viewports.
- TextRenderGlStateScope.cs and its test deleted: the pipeline description
  now bakes what it used to restore by hand.
- ResourceCleanupGroupTests/GlTextureOwnershipTests: the two source-text
  conformance tests keyed to TextRenderer's old multi-resource
  construction shape (Shader + per-flight FrameBufferSet array + white
  texture + tracked VAO/VBO, all via ResourceCleanupGroup) no longer apply
  - that shape is gone, replaced by one IGpuPipeline created through
    IGpuDevice. The construction-order test is deleted; the checked-commit
    texture-creation check now targets GlGpuTexture (which already used
    the same GlResourceCommand.CreateName primitive before this slice).

Gates:
- dotnet build -c Release: 0 warnings, 0 errors (AcDream.App has
  TreatWarningsAsErrors).
- dotnet test tests/AcDream.App.Tests -c Release: 3,840 passed / 3
  skipped (was 3,843/3 entering this slice - net 3 fewer tests:
  TextRendererFailureSafetyTests.cs deleted (2, tested the now-deleted
  TextRenderGlStateScope) plus the one retired ResourceCleanupGroupTests
  method). Full solution: 8,908 passed / 5 skipped across all nine test
  projects.
- Offline pixel gate (tools/run-offline-pixel-gate.ps1, parent ec414d60
  vs this commit): differing fraction 0.318% (1,791/563,200 compared
  pixels), above the 0.001 threshold. Investigated pixel-by-pixel rather
  than waved through: a diff heatmap plus 4x crops at the differing
  clusters show zero differences anywhere in the retained UI, terrain,
  scenery, or static meshes - every differing pixel sits on continuously-
  animated ambient content (flying-insect sprites over the swamp, foliage
  sparkle/dew glints) whose exact phase depends on elapsed wall-clock
  time, the same category the gate's own sky-masking rationale already
  documents and the campaign doc's coverage table explicitly excludes
  ("Not covered - particles"). Confirming evidence: two same-commit
  captures at HEAD compare clean against each other (0.0025%), and two
  same-commit captures at the parent compare clean against each other
  (0.0044%) - only base-vs-head is consistently elevated, which is what
  frame-pacing drift from genuinely new per-frame RHI work (BeginFrame,
  ring resets, the render-state reset above) would produce against a
  fixed wall-clock capture deadline, not a rendering defect. Recommend a
  quick user visual check of this capture pair alongside the automated
  result, matching how V2c's particle work was already handled in this
  campaign (flagged for user visual confirmation rather than blocked on
  an automated gate that cannot cover animated content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 18:22:08 +02:00
parent ec414d60cd
commit ceec3bc440
334 changed files with 3660 additions and 3840 deletions

View file

@ -1,16 +1,16 @@
namespace AcDream.App.Streaming;
namespace AcDream.App.Streaming;
/// <summary>Result of the per-frame dungeon streaming-gate decision.</summary>
/// <param name="InsideDungeon">Passed to <see cref="StreamingController.Tick"/> collapse
/// <param name="InsideDungeon">Passed to <see cref="StreamingController.Tick"/> — collapse
/// streaming to the single dungeon landblock.</param>
/// <param name="ObserverLandblockKey">When non-null, override the streaming observer to this
/// landblock key (the cell id's high 16 bits, 0xXXYY). Null leaves the caller's observer as-is.</param>
public readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey);
internal readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey);
/// <summary>
/// AP-36: the dungeon streaming gate (#133 FPS). When the player stands in a SEALED
/// EnvCell (an indoor cell that doesn't see outside), streaming collapses to the single
/// dungeon landblock — AC dungeons have no adjacent landblocks, so the normal 25×25
/// dungeon landblock — AC dungeons have no adjacent landblocks, so the normal 25×25
/// window would pull in ~129 unrelated ocean-grid dungeons. The trigger is the player's
/// CURRENT cell (<c>CellGraph.CurrCell</c>, set the moment the player is placed), and the
/// observer is pinned to that cell's OWN landblock (the cell id high 16 bits) because a
@ -19,7 +19,7 @@ public readonly record struct DungeonGateResult(bool InsideDungeon, uint? Observ
/// <para>Extracted from <c>GameWindow.OnUpdate</c> as a pure function so the
/// teleport-hold rule (below) is unit-testable without the GL/dat/network stack.</para>
/// </summary>
public static class DungeonStreamingGate
internal static class DungeonStreamingGate
{
/// <summary>
/// Decide the streaming gate from the player's current cell.
@ -33,11 +33,11 @@ public static class DungeonStreamingGate
bool isTeleportHold, bool currCellIsSealedDungeon, uint currCellId)
{
// #145/#138: during a teleport hold the player is NOT yet placed, so CurrCell is
// the frozen SOURCE cell where the player IS, not where they're going. Streaming
// the frozen SOURCE cell — where the player IS, not where they're going. Streaming
// must follow the DESTINATION, which the PortalSpace observer pin already does, so
// the source-cell gate is suppressed. Otherwise a teleport OUT of a dungeon keeps
// streaming collapsed on the source dungeon (CurrCell still sealed) the outdoor
// destination never hydrates → the TAS transit holds 600 frames → force-snap to
// streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor
// destination never hydrates → the TAS transit holds 600 frames → force-snap to
// ocean. A teleport INTO a dungeon is handled explicitly upstream by
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
// holds it through the hold), so suppressing the gate here doesn't regress it.

View file

@ -1,4 +1,4 @@
using AcDream.Core.World;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -8,7 +8,7 @@ namespace AcDream.App.Streaming;
/// asynchronously from the state transition, so a failed renderer cleanup
/// cannot keep the old landblock logically resident.
/// </summary>
public sealed record GpuLandblockRetirement(
internal sealed record GpuLandblockRetirement(
uint LandblockId,
LandblockRetirementKind Kind,
IReadOnlyList<WorldEntity> Entities);
@ -24,7 +24,7 @@ internal sealed record GpuWorldRecenterRetirement(
int SpatialOperationCount,
Exception? ObserverFailure);
public enum LandblockRetirementKind
internal enum LandblockRetirementKind
{
Full,
NearLayer,

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
@ -56,7 +56,7 @@ internal sealed record GpuLandblockSpatialPublication(
/// Threading: not thread-safe. All calls must happen on the render thread.
/// </remarks>
/// </summary>
public sealed class GpuWorldState : ILiveEntitySpatialQuery
internal sealed class GpuWorldState : ILiveEntitySpatialQuery
{
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntityScriptActivator? _entityScriptActivator;
@ -194,7 +194,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
&& IsLiveEntityProjectionResident(key);
/// <summary>
/// Try to grab the loaded record for a landblock useful for callers
/// Try to grab the loaded record for a landblock — useful for callers
/// that need to enumerate entities before the landblock is dropped
/// (e.g. unregistering dynamic lights on a RemoveLandblock).
/// </summary>
@ -1053,7 +1053,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
}
/// <summary>
/// Mark a server-GUID as persistent this entity survives landblock unloads
/// Mark a server-GUID as persistent — this entity survives landblock unloads
/// and gets re-parked as pending for its current canonical landblock.
/// </summary>
public void MarkPersistent(uint serverGuid)
@ -1093,7 +1093,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu;
// Fast path: already drawn in the correct loaded bucket nothing to do
// Fast path: already drawn in the correct loaded bucket → nothing to do
// (avoids per-frame list churn for a settled, stationary entity).
bool hasCurrent = _projectionLocations.TryGetValue(
entity,
@ -1110,17 +1110,17 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
return;
}
// Remove the entity from wherever it currently lives a loaded bucket
// OR a pending bucket then re-append to its current landblock.
// Remove the entity from wherever it currently lives — a loaded bucket
// OR a pending bucket — then re-append to its current landblock.
//
// The projection-location index is the 2026-07-03 fix for the cold-spawn /
// run-out "invisible player" bug: a persistent (server-spawned) entity
// that spawned into a not-yet-loaded landblock sits in _pendingByLandblock,
// and the old code scanned ONLY _loaded so it silently no-op'd and left
// and the old code scanned ONLY _loaded — so it silently no-op'd and left
// the player stranded, hidden, even after its landblock finished loading
// (the AddLandblock pending-drain had already run empty before the churn
// re-parked the player, and the player is excluded from the server-object
// re-hydrate so RebucketLiveEntity was the ONLY path that could recover it,
// re-hydrate — so RebucketLiveEntity was the ONLY path that could recover it,
// and it couldn't reach a pending entity). The index now reaches either
// residency class in O(1). Re-appending routes the entity
// to _loaded (drawn) when its landblock is loaded, or back to pending to
@ -1274,8 +1274,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// landblock often hasn't streamed in yet, so the player lands in
// _pendingByLandblock. If that same landblock is then unloaded (a
// streaming churn / re-teleport before it finishes loading), the
// pending entry was silently dropped here violating the
// "persistent survives unload" contract and making the avatar
// pending entry was silently dropped here — violating the
// "persistent ⇒ survives unload" contract and making the avatar
// vanish after a couple round-trips. Rescue them so DrainRescued
// re-parks them at the next valid landblock.
if (_pendingByLandblock.TryGetValue(canonical, out var pendingForLb))
@ -1580,7 +1580,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// world withdrawal, or logical teardown. It owns no renderer/script
/// lifecycle and is safe for a still-live incarnation.
///
/// Safe to call with a server guid that's not currently present no-op.
/// Safe to call with a server guid that's not currently present — no-op.
/// </summary>
public void RemoveLiveEntityProjection(uint serverGuid)
{
@ -1707,7 +1707,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (_loaded.ContainsKey(canonicalLandblockId))
{
// Hot path append directly to the render-thread-owned mutable
// Hot path — append directly to the render-thread-owned mutable
// resident bucket and flat view.
AddLoadedProjection(canonicalLandblockId, entity, key);
if (probePersistent)
@ -1715,7 +1715,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
return;
}
// Cold path landblock not yet loaded. Park the entity in the
// Cold path — landblock not yet loaded. Park the entity in the
// pending bucket; AddLandblock will pick it up when the streamer
// delivers the matching landblock.
if (!_pendingByLandblock.TryGetValue(canonicalLandblockId, out var bucket))
@ -1737,8 +1737,8 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// <summary>
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses NearFar hysteresis.
/// Per Phase A.5 spec §4.4.
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// Only dat-static entity layers demote. Live server projections retain
@ -1798,7 +1798,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
// for this landblock BEFORE we drop the entity list. The cache stores
// canonical landblock ids (the dispatcher's _walkScratch carries
// entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
// C.1.5b: stop DefaultScript for each dat-hydrated entity about to
// be dropped. Demote-tier entities are always atlas-tier (ServerGuid==0
@ -1852,14 +1852,14 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
/// <summary>
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the FarNear promotion case (terrain already loaded;
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF)
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
@ -1913,7 +1913,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
if (!parkIfMissing)
return null;
// Park as pending same pattern as live projections for not-yet-loaded LBs.
// Park as pending — same pattern as live projections for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
@ -2023,7 +2023,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery
private readonly HashSet<uint> _persistentInFlatProbe = new();
// TEMP (#138-B): log when a persistent (player) entity enters/leaves the
// drawn flat view. Transition-gated low volume (fires at teleport
// drawn flat view. Transition-gated → low volume (fires at teleport
// boundaries, not every rebuild). Strip with EntityVanishProbe.
private void ProbeFlatViewTransitions()
{

View file

@ -1,4 +1,4 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -8,7 +8,7 @@ namespace AcDream.App.Streaming;
/// until it posts the corresponding completion; the render thread then applies
/// the landblock and its cell transaction together.
/// </summary>
public sealed record LandblockBuild(
internal sealed record LandblockBuild(
LoadedLandblock Landblock,
EnvCellLandblockBuild? EnvCells = null,
LandblockBuildOrigin Origin = default,

View file

@ -1,4 +1,4 @@
using DatReaderWriter;
using DatReaderWriter;
using AcDream.Content;
namespace AcDream.App.Streaming;
@ -14,7 +14,7 @@ namespace AcDream.App.Streaming;
/// <c>CObjCell::init_objects</c> (0x0052B420).
/// See <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </summary>
public sealed class LandblockBuildFactory
internal sealed class LandblockBuildFactory
{
private readonly IDatReaderWriter _dats;
private readonly IPreparedCollisionSource _preparedCollisions;
@ -51,7 +51,7 @@ public sealed class LandblockBuildFactory
///
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
/// LandBlockInfo + scenery + interior hydration. They return only the
/// LandBlock heightmap dat record + an empty entity list enough for
/// LandBlock heightmap dat record + an empty entity list — enough for
/// terrain-mesh build on the next phase. Near-tier loads run the full
/// path. This replaces Bug A's post-load entity strip in
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
@ -70,7 +70,7 @@ public sealed class LandblockBuildFactory
// gate through mesh/bounds hydration prevents another consumer from
// interleaving reader cursor/cache state with this build.
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
// contention signal large only when the render thread is hammering the
// contention signal — large only when the render thread is hammering the
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
// cost). Identical work in both branches; the probe branch only adds the
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
@ -123,7 +123,7 @@ public sealed class LandblockBuildFactory
{
uint landblockId = request.LandblockId;
// ISSUE #54: far-tier early-out heightmap only, empty entities.
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
// worker-thread cost per far-tier LB from ~tens of ms to a single
@ -203,7 +203,7 @@ public sealed class LandblockBuildFactory
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU no GL calls.
/// landblock on the worker thread. Pure CPU — no GL calls.
///
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
@ -305,7 +305,7 @@ public sealed class LandblockBuildFactory
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
// it uses the same AC2D render split-direction formula the
// — it uses the same AC2D render split-direction formula the
// TerrainModernRenderer uses for the visible terrain mesh. This
// guarantees trees are placed on the SAME Z height the player
// walks on. If physics hasn't registered this landblock yet,
@ -313,14 +313,14 @@ public sealed class LandblockBuildFactory
var worldPx = localX + lbOffset.X;
var worldPy = localY + lbOffset.Y;
// 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
// 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 before the full old-window
// recenter retirement converges planting scenery at the old location's
// could only return null (→ this same own-heightmap) or a STALE neighbor's
// height — the previous location's terrain before the full old-window
// recenter retirement converges — 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).
@ -335,7 +335,7 @@ public sealed class LandblockBuildFactory
if (_dumpSceneryZ)
{
// groundZ now always comes from THIS landblock's own heightmap (the
// global physics query was removed see the trees-in-sky fix above).
// global physics query was removed — see the trees-in-sky fix above).
string source = "heightmap";
foreach (var mr in meshRefs)
{
@ -422,7 +422,7 @@ public sealed class LandblockBuildFactory
/// <summary>
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU no GL calls.
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
/// Portal cells and drawable shell placements are accumulated in the
/// transaction-local <paramref name="envCellBuild"/>. The render thread
@ -448,28 +448,28 @@ public sealed class LandblockBuildFactory
(lbY - origin.CenterY) * 192f,
0f);
// Per-landblock id namespace see AcDream.Core.World.InteriorEntityIdAllocator
// Per-landblock id namespace — see AcDream.Core.World.InteriorEntityIdAllocator
// for the full bit layout + history. Distinct from scenery (0x80000000+) and
// landblock stabs (0xC0000000+, ids from LandblockLoader).
//
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
// resolves to 0x40YYFF00 the landblock X byte DISCARDED. Every landblock in a
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
// map Y-row produced the same id base, so interior statics collided across
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
// entity's batches to the other (the cache hint at bucket-draw time was the
// player's landblock, identical for both) the session-sticky "broken stairs +
// player's landblock, identical for both) — the session-sticky "broken stairs +
// water barrel".
//
// #190 (2026-07-09): the fix above LEFT a documented residual "counter overflow
// #190 (2026-07-09): the fix above LEFT a documented residual — "counter overflow
// past 0xFF still bleeds into the lbY byte." That residual manifested for real:
// the Town Network hub (205 cells, one landblock) reached 277 interior entities
// after the #79/#93 A7.L1 light-carrier hydration fix, aliasing into the NEXT
// landblock's Y-byte (entity script/particle tracking is keyed on entity.Id
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
// directly — EntityScriptActivator — with no landblock-hint disambiguation, so
// the fountain's water-spray script silently stopped firing). Widened the
// counter budget 8→12 bits (256→4096); see InteriorEntityIdAllocator's doc for
// counter budget 8â†12 bits (256â†4096); see InteriorEntityIdAllocator's doc for
// why this is safe (nothing decodes X/Y back out of an entity id).
uint interiorLbX = (landblockId >> 24) & 0xFFu;
uint interiorLbY = (landblockId >> 16) & 0xFFu;
@ -484,7 +484,7 @@ public sealed class LandblockBuildFactory
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
// MUST exist in the cell dat a null here is always a read anomaly.
// MUST exist in the cell dat — a null here is always a read anomaly.
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
continue;
}
@ -498,7 +498,7 @@ public sealed class LandblockBuildFactory
{
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
// a null Environment means this cell's WALLS are silently never
// registered while its static objects still draw the exact
// registered while its static objects still draw — the exact
// white-walls geometry signature.
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
}
@ -510,14 +510,14 @@ public sealed class LandblockBuildFactory
// drawable-geometry predicate; the actual shell placement is now owned
// by this streaming job's EnvCellLandblockBuild transaction.
// Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below they have real GfxObj MeshRefs that work
// as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer receives only the completed shell transaction.
// Transforms needed by the portal-visibility cell (unlifted) AND the
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
// shared with every draw-space consumer of portal polygons (OutsideView
// gate, seal/punch fans) PortalVisibilityBuilder.ShellDrawLiftZ (#130).
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
@ -532,9 +532,9 @@ public sealed class LandblockBuildFactory
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
// to the cells beyond otherwise the flood lookup-misses the unregistered
// to the cells beyond — otherwise the flood lookup-misses the unregistered
// neighbour and the grey clear shows through the opening (#133: ramp
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; the cell-build transaction reads portals, NOT
@ -558,7 +558,7 @@ public sealed class LandblockBuildFactory
foreach (var stab in envCell.StaticObjects)
{
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
// targeted stabs. This is the MOMENT MeshRefs are constructed
// targeted stabs. This is the MOMENT MeshRefs are constructed —
// a degraded dat read here (setup null / placement frames short /
// part GfxObj null) permanently corrupts the entity (H-A), and
// nothing downstream ever rebuilds it. Inert when the set is empty.
@ -568,7 +568,7 @@ public sealed class LandblockBuildFactory
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it only the
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
// WorldBuilder editor shows it at the origin. acdream's render path came from
// WB (no distance LOD), so without this skip it draws the marker forever (the
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
@ -581,7 +581,7 @@ public sealed class LandblockBuildFactory
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
// #79/#93 (2026-07-09): a Setup-sourced stab whose sole visual part is a
// runtime-hidden marker (#136) flattens to zero mesh refs even though its
// Setup carries real Lights a "light attach point" fixture (e.g. the Town
// Setup carries real Lights — a "light attach point" fixture (e.g. the Town
// Network fountain room's ceiling light, Setup 0x02000365). Track the dat
// Setup's Lights.Count here so the meshRefs==0 gate below doesn't also drop
// the entity that otherwise carries those lights to the static
@ -618,7 +618,7 @@ public sealed class LandblockBuildFactory
{
// #136: skip an editor-only marker PART (retail hides it at runtime
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
// part GfxObj 0x010028CA is such a marker skipping it empties
// part GfxObj 0x010028CA is such a marker — skipping it empties
// meshRefs and the whole stab drops below.
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
continue;
@ -652,7 +652,7 @@ public sealed class LandblockBuildFactory
// Stabs inside EnvCells are already in landblock-local coordinates
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
// be wrong see Phase 2d comment in the pre-streaming preload.
// be wrong — see Phase 2d comment in the pre-streaming preload.
var worldPos = stab.Frame.Origin + lbOffset;
var worldRot = stab.Frame.Orientation;

View file

@ -1,11 +1,11 @@
namespace AcDream.App.Streaming;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable world-origin center captured by the update thread when a
/// landblock load is admitted. Worker construction and render publication use
/// this same value, so a later recenter cannot combine two coordinate frames.
/// </summary>
public readonly record struct LandblockBuildOrigin
internal readonly record struct LandblockBuildOrigin
{
public LandblockBuildOrigin(int centerX, int centerY)
{
@ -29,7 +29,7 @@ public readonly record struct LandblockBuildOrigin
/// <see cref="Generation"/> is carried for matching and diagnostics only;
/// residency policy remains owned by <see cref="StreamingController"/>.
/// </summary>
public readonly record struct LandblockBuildRequest(
internal readonly record struct LandblockBuildRequest(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation,

View file

@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// publication. The render publisher commits buildings and EnvCells between
/// these stages without recomputing the captured origin.
/// </summary>
public sealed class LandblockPhysicsPublication
internal sealed class LandblockPhysicsPublication
{
internal LandblockPhysicsPublication(
object owner,
@ -72,7 +72,7 @@ public sealed class LandblockPhysicsPublication
/// Cumulative update-thread diagnostics for physics publication and removal.
/// Durations are <see cref="Stopwatch"/> ticks.
/// </summary>
public readonly record struct LandblockPhysicsPublisherDiagnostics(
internal readonly record struct LandblockPhysicsPublisherDiagnostics(
long BeginCount,
long CompleteCount,
long BasePublishTicks,
@ -103,7 +103,7 @@ public readonly record struct LandblockPhysicsPublisherDiagnostics(
/// use one multipart shadow owner or the mutually exclusive Setup
/// cylinder/sphere fallback.
/// </remarks>
public sealed class LandblockPhysicsPublisher
internal sealed class LandblockPhysicsPublisher
{
private readonly object _receiptOwner = new();
private readonly RuntimePhysicsState _physics;
@ -953,7 +953,7 @@ public sealed class LandblockPhysicsPublisher
string sampleText = string.Join(",", samples.Select(
value => $"0x{value:X8}"));
Console.WriteLine(
$" {missingCount} scenery entities had no visual bounds cached. " +
$" → {missingCount} scenery entities had no visual bounds cached. " +
$"Samples: {sampleText}");
}
}

View file

@ -1,4 +1,4 @@
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Wb;
using AcDream.App.Rendering.Scene;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -10,7 +10,7 @@ namespace AcDream.App.Streaming;
/// Keeping this projection on the pipeline prevents the composition root from
/// retaining each concrete publication owner independently.
/// </summary>
public readonly record struct LandblockPresentationDiagnostics(
internal readonly record struct LandblockPresentationDiagnostics(
LandblockRenderPublisherDiagnostics Render,
LandblockPhysicsPublisherDiagnostics Physics,
LandblockStaticPresentationDiagnostics Statics);
@ -37,7 +37,7 @@ internal readonly record struct LandblockPublicationAdvance(
/// graph; the internal delegate constructor exists only for hermetic policy and
/// retry characterization tests.
/// </remarks>
public sealed class LandblockPresentationPipeline
internal sealed class LandblockPresentationPipeline
{
private enum PublicationKind : byte
{

View file

@ -1,4 +1,4 @@
using AcDream.Core.Lighting;
using AcDream.Core.Lighting;
using AcDream.Core.Rendering;
using AcDream.Core.World;
@ -16,7 +16,7 @@ namespace AcDream.App.Streaming;
/// detaches spatial reachability first, then advances these concrete owners
/// from the retained ticket without replaying successful stages or entities.
/// </remarks>
public sealed class LandblockPresentationRetirementOwner
internal sealed class LandblockPresentationRetirementOwner
{
private readonly LandblockRenderPublisher _render;
private readonly LandblockPhysicsPublisher _physics;

View file

@ -1,4 +1,4 @@
using System.Collections.ObjectModel;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Numerics;
using AcDream.App.Rendering;
@ -13,7 +13,7 @@ namespace AcDream.App.Streaming;
/// origin between <see cref="LandblockRenderPublisher.BeginPublication"/> and
/// <see cref="LandblockRenderPublisher.CompletePublication"/>.
/// </summary>
public sealed class LandblockRenderPublication
internal sealed class LandblockRenderPublication
{
private readonly IReadOnlyDictionary<uint, LoadedCell> _visibilityCells;
@ -64,7 +64,7 @@ public sealed class LandblockRenderPublication
/// Times use <see cref="Stopwatch"/> ticks so callers can aggregate without
/// losing sub-millisecond precision.
/// </summary>
public readonly record struct LandblockRenderPublisherDiagnostics(
internal readonly record struct LandblockRenderPublisherDiagnostics(
long BeginCount,
long CompleteCount,
long TerrainPublishTicks,
@ -90,7 +90,7 @@ public readonly record struct LandblockRenderPublisherDiagnostics(
/// WorldBuilder one-job/one-EnvCell-transaction seam documented in
/// <c>docs/architecture/worldbuilder-inventory.md</c>.
/// </remarks>
public sealed class LandblockRenderPublisher
internal sealed class LandblockRenderPublisher
{
private readonly object _receiptOwner = new();
private readonly Action<uint, LandblockMeshData, Vector3> _publishTerrain;

View file

@ -1,9 +1,9 @@
using AcDream.Core.World;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
[Flags]
public enum LandblockRetirementStage : ushort
internal enum LandblockRetirementStage : ushort
{
None = 0,
MeshReferences = 1 << 0,
@ -31,7 +31,7 @@ internal enum LandblockRetirementOperationResult : byte
/// One retryable landblock-retirement ledger. Successful owner operations are
/// committed once; a later attempt resumes at the exact failed owner/entity.
/// </summary>
public sealed class LandblockRetirementTicket
internal sealed class LandblockRetirementTicket
{
private readonly Dictionary<LandblockRetirementStage, int> _entityCursors = new();
private readonly Dictionary<LandblockRetirementStage, Exception> _failures = new();
@ -240,7 +240,7 @@ public sealed class LandblockRetirementTicket
/// owner callback cannot recursively replay its active stage or mutate the
/// pending-ticket map while it is being enumerated.
/// </summary>
public sealed class LandblockRetirementCoordinator
internal sealed class LandblockRetirementCoordinator
{
private enum BudgetedAdvanceResult : byte
{

View file

@ -1,4 +1,4 @@
using AcDream.Core.Lighting;
using AcDream.Core.Lighting;
using AcDream.Core.Plugins;
using AcDream.Core.Rendering;
using AcDream.Core.World;
@ -11,7 +11,7 @@ namespace AcDream.App.Streaming;
/// publication. The physics publisher calls the light/translucency stage at
/// the existing per-object point immediately before ordinary collision.
/// </summary>
public sealed class LandblockStaticPresentationPublication
internal sealed class LandblockStaticPresentationPublication
{
private readonly Dictionary<uint, WorldEntity> _entities;
private readonly Dictionary<uint, WorldEntitySnapshot> _snapshots;
@ -63,7 +63,7 @@ public sealed class LandblockStaticPresentationPublication
public IReadOnlyDictionary<uint, WorldEntitySnapshot> Snapshots => _snapshots;
}
public readonly record struct LandblockStaticPresentationDiagnostics(
internal readonly record struct LandblockStaticPresentationDiagnostics(
long BeginCount,
long CompleteCount,
long LightReplacementCount,
@ -87,7 +87,7 @@ public readonly record struct LandblockStaticPresentationDiagnostics(
/// logical spawn per retained static ID, while reapply only refreshes current
/// state.
/// </remarks>
public sealed class LandblockStaticPresentationPublisher
internal sealed class LandblockStaticPresentationPublisher
{
private readonly object _receiptOwner = new();
private readonly LightingHookSink _lighting;

View file

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(
internal sealed record Load(
uint LandblockId,
LandblockStreamJobKind Kind,
ulong Generation = 0,
@ -21,7 +21,7 @@ public abstract record LandblockStreamJob(uint LandblockId)
public LandblockBuildRequest Request =>
new(LandblockId, Kind, Generation, Origin);
}
public sealed record Unload(
internal sealed record Unload(
uint LandblockId,
ulong Generation = 0) : LandblockStreamJob(LandblockId);
@ -30,10 +30,10 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// priority queues, keeping Unloads. Posted by
/// <see cref="LandblockStreamer.ClearPendingLoads"/> when the player enters a
/// dungeon and the in-flight outdoor/neighbor window load must be cancelled
/// (#133 FPS dungeons have no adjacent landblocks). LandblockId is 0 by
/// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by
/// convention; readers pattern-match on the type.
/// </summary>
public sealed record ClearLoads() : LandblockStreamJob(0);
internal sealed record ClearLoads() : LandblockStreamJob(0);
}
/// <summary>
@ -49,7 +49,7 @@ public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
/// is built off the render thread on the streaming worker.
/// </summary>
public sealed record Loaded(
internal sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LandblockBuild Build,
@ -78,7 +78,7 @@ public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
/// GpuWorldState still merges only the entity layer so live entities already
/// attached to the landblock are preserved.
/// </summary>
public sealed record Promoted(
internal sealed record Promoted(
uint LandblockId,
LandblockBuild Build,
LandblockMeshData MeshData,
@ -98,20 +98,20 @@ public abstract record LandblockStreamResult(uint LandblockId, ulong Generation)
public IReadOnlyList<WorldEntity> Entities => Landblock.Entities;
}
public sealed record Failed(
internal sealed record Failed(
uint LandblockId,
string Error,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
public sealed record Unloaded(
internal sealed record Unloaded(
uint LandblockId,
ulong Generation = 0) : LandblockStreamResult(LandblockId, Generation);
/// <summary>
/// The worker loop itself crashed with an unhandled exception. Not tied
/// to a specific landblock distinguished from <see cref="Failed"/>
/// to a specific landblock — distinguished from <see cref="Failed"/>
/// because consumers typically route this to a fatal-log path rather
/// than retrying a single landblock later. LandblockId is 0 by
/// convention; readers should pattern-match on the type, not the id.
/// </summary>
public sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
internal sealed record WorkerCrashed(string Error) : LandblockStreamResult(0, 0);
}

View file

@ -1,4 +1,4 @@
using System.Runtime.CompilerServices;
using System.Runtime.CompilerServices;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using AcDream.Core.World;
@ -12,7 +12,7 @@ namespace AcDream.App.Streaming;
/// the managed heap. Exact array payloads and logical retained entries are
/// charged consistently so admission has a deterministic unit.
/// </summary>
public readonly record struct LandblockStreamCostEstimate(
internal readonly record struct LandblockStreamCostEstimate(
StreamingWorkCost Work,
long TerrainPayloadBytes,
int Entities,
@ -26,7 +26,7 @@ public readonly record struct LandblockStreamCostEstimate(
int PhysicsSetups,
int PhysicsGfxObjects);
public static class LandblockStreamResultCost
internal static class LandblockStreamResultCost
{
private const int ReferenceChargeBytes = 8;
private const int DictionaryEntryChargeBytes = 16;

View file

@ -1,11 +1,11 @@
namespace AcDream.App.Streaming;
namespace AcDream.App.Streaming;
/// <summary>
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// </summary>
public enum LandblockStreamTier
internal enum LandblockStreamTier
{
Far,
Near,
@ -15,7 +15,7 @@ public enum LandblockStreamTier
/// What work the streaming worker should perform for a given job. Distinct
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
/// reads only the entity layer (terrain mesh already loaded), while
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// </summary>
public enum LandblockStreamJobKind
{
@ -23,6 +23,6 @@ public enum LandblockStreamJobKind
LoadFar,
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
LoadNear,
/// <summary>Read LandBlockInfo + scenery only terrain already loaded for this LB.</summary>
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
PromoteToNear,
}

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
@ -28,12 +28,12 @@ namespace AcDream.App.Streaming;
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
/// <c>DatCollection.Get&lt;T&gt;</c> calls. Both factory closures passed at
/// construction acquire that lock before reading dats. The worker never
/// touches <c>DatCollection</c> directly it only calls the factories.
/// touches <c>DatCollection</c> directly — it only calls the factories.
/// </para>
///
/// <para>
/// Unloads pass through the outbox as <see cref="LandblockStreamResult.Unloaded"/>
/// records so the render thread can release GPU state on the next drain
/// records so the render thread can release GPU state on the next drain —
/// the streamer never touches GPU resources directly.
/// </para>
///
@ -43,7 +43,7 @@ namespace AcDream.App.Streaming;
/// methods are thread-safe.
/// </remarks>
/// </summary>
public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
internal sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{
/// <summary>
/// Default drain batch size. Tuned to cap GPU upload work the render
@ -78,7 +78,7 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
{
_loadLandblock = loadLandblock;
_supportsRequestOrigin = supportsRequestOrigin;
// Default: no mesh build (returns null Failed result). Production
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
@ -131,7 +131,7 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
}
/// <summary>
/// Back-compat overload wraps a kind-agnostic factory so existing test code
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
/// ignores the kind and calls the factory once per LB regardless of tier.
/// New production code should use <see cref="CreateForRequests"/>.
@ -239,7 +239,7 @@ public sealed class LandblockStreamer : IDisposable, ILandblockCompletionSource
/// <see cref="LandblockStreamJob.ClearLoads"/> control job which the worker
/// honours at read time, dropping all pending Loads from both priority
/// queues (Unloads survive). Used on the dungeon-entry edge to abort the
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
/// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never
/// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still
/// complete; the StreamingController's collapsed-sweep unloads those few.
/// </summary>

View file

@ -1,4 +1,4 @@
using System.Diagnostics;
using System.Diagnostics;
namespace AcDream.App.Streaming;
@ -7,7 +7,7 @@ namespace AcDream.App.Streaming;
/// channel. Peek plus read lets the update thread price a payload before
/// adopting it into the scheduler.
/// </summary>
public interface ILandblockCompletionSource
internal interface ILandblockCompletionSource
{
int BacklogCount { get; }
bool TryPeek(out LandblockStreamResult? result);

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using AcDream.App.Rendering.Wb;
@ -17,7 +17,7 @@ namespace AcDream.App.Streaming;
/// Threading: not thread-safe. All calls must happen on the render thread.
/// </remarks>
/// </summary>
public sealed class StreamingController
internal sealed class StreamingController
: IStreamingFrameBackend,
IWorldRevealStreamingScheduler
{
@ -88,14 +88,14 @@ public sealed class StreamingController
// True while streaming is collapsed to the single dungeon landblock the
// player stands in (the dungeon gate, #133 FPS). AC dungeons have NO
// adjacent landblocks neighbors are unrelated ocean-grid dungeons that
// are never visible, so we stop loading the 25×25 window entirely.
// adjacent landblocks — neighbors are unrelated ocean-grid dungeons that
// are never visible, so we stop loading the 25×25 window entirely.
private bool _collapsed;
// The dungeon landblock id we collapsed onto. Once collapsed we key the
// gate on this STABLE landblock, not the per-frame insideDungeon signal:
// CurrCell can momentarily resolve to null/outdoor mid-frame, and gating
// expand on that flicker thrashes collapseexpand (reload storms + a light
// expand on that flicker thrashes collapse↔expand (reload storms + a light
// leak). We only expand when the observer actually moves to a different
// landblock (teleport/portal out).
private uint _collapsedCenter;
@ -610,16 +610,16 @@ public sealed class StreamingController
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// are landblock coordinates (0..255) of the current viewer the camera
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
///
/// <para>Two-tier model (Phase A.5 T13):</para>
/// <list type="bullet">
/// <item><see cref="TwoTierDiff.ToLoadFar"/> enqueue LoadFar (terrain only, no entities)</item>
/// <item><see cref="TwoTierDiff.ToLoadNear"/> enqueue LoadNear (terrain + entities)</item>
/// <item><see cref="TwoTierDiff.ToPromote"/> enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
/// <item><see cref="TwoTierDiff.ToDemote"/> drop entities on render thread immediately (terrain stays)</item>
/// <item><see cref="TwoTierDiff.ToUnload"/> enqueue full unload</item>
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy, bool insideDungeon = false)
@ -699,15 +699,15 @@ public sealed class StreamingController
if (_collapsed)
{
// Hysteresis. Cases:
// - Still in the SAME dungeon landblock hold (sweep stragglers).
// - Still in the SAME dungeon landblock → hold (sweep stragglers).
// - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon)
// re-collapse onto it.
// → re-collapse onto it.
// - CurrCell flickered null but the player hasn't gone anywhere: the
// observer landblock reverts to the position-derived value, which for a
// dungeon is only ever the ADJACENT off-by-one landblock (negative cell-
// local Y). Hold never expand on an adjacent flicker.
// local Y). Hold — never expand on an adjacent flicker.
// - Genuinely left to a DISTANT landblock (portal/teleport out, always far
// from the ocean-grid dungeon block) expand.
// from the ocean-grid dungeon block) → expand.
if (insideDungeon && centerId != _collapsedCenter)
EnterDungeonCollapse(observerCx, observerCy, centerId);
else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1)
@ -910,23 +910,23 @@ public sealed class StreamingController
/// <summary>
/// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
/// <see cref="Tick"/> has a chance to bootstrap the full 25×25 window. Called
/// from the login / teleport spawn path the instant the streaming center is
/// recentered onto a SEALED dungeon landblock.
///
/// <para>The per-frame <c>insideDungeon</c> gate keys on the physics
/// <c>CurrCell</c>, which is only set once the player is PLACED and placement
/// <c>CurrCell</c>, which is only set once the player is PLACED — and placement
/// waits for the dungeon landblock to hydrate. So for the whole hydration window
/// (tens of seconds for a ~200-cell dungeon) the gate reads false and
/// <see cref="NormalTick"/> would enqueue the ~24 unrelated ocean-grid neighbor
/// dungeons (+ ~19k entities each); the collapse then only mops them up after
/// placement. That mop-up is the 10high FPS ramp users see at a dungeon login.</para>
/// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login.</para>
///
/// <para>Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never
/// enqueued. On teleport nothing is enqueued at all (this fires before the next
/// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the
/// frame-1 NormalTick (before the player's spawn arrives) and is immediately
/// cancelled by <c>_clearPendingLoads</c> here cheap outdoor terrain, not the
/// cancelled by <c>_clearPendingLoads</c> here — cheap outdoor terrain, not the
/// ocean-grid dungeons, and a handful of already-dequeued loads get swept next
/// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a
/// re-sent spawn or a same-frame double call costs nothing. Render-thread only,
@ -954,7 +954,7 @@ public sealed class StreamingController
}
/// <summary>
/// Outdoor / building-interior streaming the original two-tier model.
/// Outdoor / building-interior streaming — the original two-tier model.
/// </summary>
private void NormalTick(int observerCx, int observerCy)
{
@ -1012,11 +1012,11 @@ public sealed class StreamingController
/// <summary>
/// Dungeon-entry edge: cancel the in-flight window load, unload every
/// resident neighbor, and pin streaming to the player's single dungeon
/// landblock. Retail-faithful AC dungeons have no adjacent landblocks
/// landblock. Retail-faithful — AC dungeons have no adjacent landblocks
/// (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a dungeon);
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
/// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and
/// their thousands of emitters (#133 FPS). Unloading them also tears down
/// their lights, shrinking the static-light set toward retail's 40.
/// their lights, shrinking the static-light set toward retail's ≤40.
/// </summary>
private void EnterDungeonCollapse(int cx, int cy, uint centerId)
{
@ -1049,7 +1049,7 @@ public sealed class StreamingController
/// <summary>
/// While collapsed, unload any landblock that finished loading after the
/// collapse edge a Load the worker had already dequeued before the
/// collapse edge — a Load the worker had already dequeued before the
/// <see cref="LandblockStreamer.ClearPendingLoads"/> control job took
/// effect. At steady state only the dungeon landblock is resident, so this
/// is a no-op.
@ -1057,7 +1057,7 @@ public sealed class StreamingController
private void SweepCollapsed()
{
// Always preserve the true dungeon landblock (_collapsedCenter), never the
// per-frame observer landblock a CurrCell flicker must not unload the dungeon.
// per-frame observer landblock — a CurrCell flicker must not unload the dungeon.
foreach (var id in _state.LoadedLandblockIds)
if (id != _collapsedCenter) EnqueueUnload(id);
}

View file

@ -1,11 +1,11 @@
namespace AcDream.App.Streaming;
namespace AcDream.App.Streaming;
/// <summary>
/// Reports whether a streaming queue/retirement mutation crossed its commit
/// point before a callback failed. Retry ledgers use this to avoid submitting
/// the same generation operation twice after a post-commit diagnostic error.
/// </summary>
public sealed class StreamingMutationException : Exception
internal sealed class StreamingMutationException : Exception
{
public StreamingMutationException(
string message,

View file

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
@ -7,9 +7,9 @@ namespace AcDream.App.Streaming;
/// <summary>
/// Pure data type describing the set of landblocks currently considered
/// "visible" by the streaming system. Given a center landblock (x, y) and
/// a radius, builds the set of landblock IDs in the (2r+1)×(2r+1) window.
/// a radius, builds the set of landblock IDs in the (2r+1)×(2r+1) window.
/// </summary>
public sealed class StreamingRegion
internal sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
@ -17,7 +17,7 @@ public sealed class StreamingRegion
public int NearRadius { get; }
public int FarRadius { get; }
// Strictly the (2r+1)×(2r+1) window (clamped to world bounds).
// Strictly the (2r+1)×(2r+1) window (clamped to world bounds).
private readonly HashSet<uint> _visible = new();
// Everything currently loaded: window + hysteresis-retained landblocks.
@ -42,7 +42,7 @@ public sealed class StreamingRegion
/// <c>LandblockLoader</c>.
///
/// <para>
/// This set is strictly the (2r+1)×(2r+1) window; it does NOT include
/// This set is strictly the (2r+1)×(2r+1) window; it does NOT include
/// hysteresis-retained landblocks outside the window. Use
/// <see cref="Resident"/> to enumerate everything actually loaded.
/// </para>
@ -89,7 +89,7 @@ public sealed class StreamingRegion
/// <summary>
/// Encode a landblock at (lbX, lbY) into the AC dat id form. Always uses
/// the <c>0xFFFF</c> terminator (LandBlock = terrain). The earlier
/// version of this method used <c>0xFFFE</c> by mistake that's the
/// version of this method used <c>0xFFFE</c> by mistake — that's the
/// LandBlockInfo id, and asking <c>LandblockLoader.Load</c> to read a
/// LandBlock at the LandBlockInfo coords corrupts the dat reader's
/// buffer position, returning a half-populated <c>LandBlock.Height[]</c>
@ -135,7 +135,7 @@ public sealed class StreamingRegion
/// <summary>
/// Call once after <see cref="ComputeFirstTickDiff"/> to seed
/// <c>_tierResidence</c> with the initial window. Every LB in the inner
/// ring (Chebyshev NearRadius) is marked Near; everything else Far.
/// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far.
/// </summary>
public void MarkResidentFromBootstrap()
{
@ -191,8 +191,8 @@ public sealed class StreamingRegion
}
/// <summary>
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Nearâ†Far demote; FarRadius+2 for Farâ†null
/// unload. Requires <see cref="MarkResidentFromBootstrap"/> (or a prior
/// call to this method) to have seeded <c>_tierResidence</c>.
/// </summary>
@ -214,7 +214,7 @@ public sealed class StreamingRegion
var toDemote = new List<uint>();
var toUnload = new List<uint>();
// Pass 1: walk new far window emit ToLoadFar / ToLoadNear / ToPromote.
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet<uint>();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
@ -231,18 +231,18 @@ public sealed class StreamingRegion
if (!_tierResidence.TryGetValue(id, out var current))
{
// Not resident at all fresh load.
// Not resident at all — fresh load.
if (inNear) toLoadNear.Add(id);
else toLoadFar.Add(id);
_tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
}
else if (current == TierResidence.Far && inNear)
{
// Was Far, now inside Near ring promote.
// Was Far, now inside Near ring — promote.
toPromote.Add(id);
_tierResidence[id] = TierResidence.Near;
}
// Near→Near and Far→Far are no-ops.
// Nearâ†Near and Farâ†Far are no-ops.
}
}
@ -259,7 +259,7 @@ public sealed class StreamingRegion
if (newCenterIds.Contains(id))
{
// Still in the far window — only Near→Far demote possible here.
// Still in the far window — only Nearâ†Far demote possible here.
if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
{
if (distance > nearUnloadThreshold)
@ -271,7 +271,7 @@ public sealed class StreamingRegion
continue;
}
// Outside the new window demote / unload by threshold.
// Outside the new window — demote / unload by threshold.
if (current == TierResidence.Near)
{
if (distance > nearUnloadThreshold)
@ -336,7 +336,7 @@ public sealed class StreamingRegion
toUnload.Add(id);
}
// Update resident: (oldResident newVisible) toUnload.
// Update resident: (oldResident ∪ newVisible) ∖ toUnload.
_resident.UnionWith(_visible);
foreach (var id in toUnload)
_resident.Remove(id);
@ -352,7 +352,7 @@ public sealed class StreamingRegion
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
/// set; the caller hands them to <c>LandblockStreamer</c> as jobs.
/// </summary>
public readonly record struct RegionDiff(
internal readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);

View file

@ -1,11 +1,11 @@
using System.Diagnostics;
using System.Diagnostics;
namespace AcDream.App.Streaming;
/// <summary>
/// Immutable, validated per-frame streaming work profile.
/// </summary>
public readonly record struct StreamingWorkBudget
internal readonly record struct StreamingWorkBudget
{
public StreamingWorkBudget(
TimeSpan maxUpdateTime,
@ -75,7 +75,7 @@ public readonly record struct StreamingWorkBudget
/// <summary>
/// Conservative, known cost of one atomic streaming operation.
/// </summary>
public readonly record struct StreamingWorkCost(
internal readonly record struct StreamingWorkCost(
int CompletionAdmissions = 0,
long AdoptedCpuBytes = 0,
int EntityOperations = 0,
@ -117,14 +117,14 @@ public readonly record struct StreamingWorkCost(
left > long.MaxValue - right ? long.MaxValue : left + right;
}
public enum StreamingWorkAdmission : byte
internal enum StreamingWorkAdmission : byte
{
Admitted,
OversizedProgress,
Yielded,
}
public enum StreamingWorkLimit : byte
internal enum StreamingWorkLimit : byte
{
None,
Time,
@ -144,7 +144,7 @@ internal enum StreamingWorkLane : byte
/// <summary>
/// Immutable observation of one frame's streaming work.
/// </summary>
public readonly record struct StreamingWorkMeterSnapshot(
internal readonly record struct StreamingWorkMeterSnapshot(
StreamingWorkCost Used,
StreamingWorkCost DestinationUsed,
StreamingWorkCost NonDestinationUsed,
@ -163,7 +163,7 @@ public readonly record struct StreamingWorkMeterSnapshot(
/// <summary>
/// Read-only streaming scheduler facts published to lifecycle artifacts.
/// </summary>
public readonly record struct StreamingWorkDiagnostics(
internal readonly record struct StreamingWorkDiagnostics(
StreamingWorkMeterSnapshot LastFrame,
long LifetimeFrameOverrunCount,
long LifetimeOversizedProgressCount,
@ -187,7 +187,7 @@ public readonly record struct StreamingWorkDiagnostics(
/// Single-thread, frame-scoped admission meter. Callers reserve a known cost
/// before an atomic operation and complete or fail that reservation afterward.
/// </summary>
public sealed class StreamingWorkMeter
internal sealed class StreamingWorkMeter
{
private readonly StreamingWorkBudget _budget;
private readonly Func<long> _timestamp;

View file

@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
namespace AcDream.App.Streaming;
@ -7,7 +7,7 @@ namespace AcDream.App.Streaming;
/// These values limit work per frame; they never reduce the amount or
/// quality of content that eventually becomes resident.
/// </summary>
public sealed record StreamingWorkBudgetOptions(
internal sealed record StreamingWorkBudgetOptions(
double MaxUpdateMilliseconds,
int MaxCompletionAdmissions,
long MaxAdoptedCpuBytes,

View file

@ -1,15 +1,15 @@
using System.Collections.Generic;
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/> for the two-tier model.
/// Five disjoint lists describe what changed since the previous Tick. Per
/// Phase A.5 spec §4.2.
/// Phase A.5 spec §4.2.
/// </summary>
public readonly record struct TwoTierDiff(
internal readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities first-tick or teleport)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (entities only)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)

View file

@ -1,4 +1,4 @@
using AcDream.App.Audio;
using AcDream.App.Audio;
using AcDream.Core.Selection;
using AcDream.Runtime.Entities;
using AcDream.Runtime.World;
@ -10,7 +10,7 @@ namespace AcDream.App.Streaming;
/// A hard login/portal reveal boundary makes the prior world unavailable
/// immediately while its physical owners retire over later frames.
/// </summary>
public interface IWorldGenerationAvailability
internal interface IWorldGenerationAvailability
{
bool IsWorldAvailable { get; }
long QuiescedGeneration { get; }