fix(streaming): derive the portal reveal window from the live streaming radii (#280)
The user watched far terrain visibly assemble after portal space exits.
The reveal gate was NOT missing a hold — Slice E's hold mechanism is
correct and already in place. The hold was measuring the wrong domain:
it opened at a hardcoded 3x3 landblock neighbourhood (~192 m) while the
visible world extends to the fog end (~2,189 m at the shipped High
preset, inside a 2,304 m Far window). An 11.4:1 ratio.
Retail's equivalent ratio is 1:1 BY CONSTRUCTION. `LScape` owns one
`mid_width x mid_width` array of `CLandBlock*` (`LScape::SetMidRadius`
@0x00504C00, `LScape::update_block` @0x005063A0), `mid_radius` is
assigned directly from the user's `Render.LandscapeDrawDistance`
preference (`SmartBox::SetRegion` @0x004531F0; values
`Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25},
default 8 — both byte-verified against the PDB-paired 2013 binary), and
that same square is simultaneously the prefetched set
(`LScape::PreFetchCells` @0x00505660), the drawn set (`block_draw_list`
over the same array), and the set the simulation blocks on
(`CellManager::blocking_for_cells`). There is no retail configuration in
which the client streams farther than it gates, because there is only
one number.
So the fix derives rather than duplicates. Four coupled parts, which is
why this is one commit and not four — D1 without D2 hangs the client and
D2 without D1 is dead code:
D1 `WorldRevealReadinessBarrier` takes a live `Func<StreamingRevealWindow>`
and stops being static: outdoor requires `FarRadius`, indoor still 0
(retail's `CEnvCell::PreFetchCells` @0x0052D1E0 arm). Read per
evaluation, never captured — the radii are runtime mutable through
Settings, and retail's answer to a mid-hold radius change is to reset,
re-radius, and re-arm the blocking prefetch at the NEW value
(`SmartBox::set_mid_radius` @0x00453180). `OutdoorNeighborhoodRadius`
is deleted; there is no constant left to drift.
D2 `StreamingController.IsRenderNeighborhoodResident` becomes tiered,
because acdream's loaded landscape is: inside `NearRadius`,
`IsNearTier && IsRenderReady`; out to `FarRadius`, `IsRenderReady` only.
Without this the fix cannot work at all — nothing outside the Near ring
is ever promoted, so any radius above `NearRadius` was unsatisfiable and
would have held the reveal forever. Proof obligation P1 (a Far-tier
landblock genuinely satisfies `IsRenderReady`) is now a test driven
through the real `PublicationKind.Far` pipeline against a real
`LandblockSpawnAdapter`, not an inference.
D7 `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived
`indoor ? 0 : 1` and failed `invalid-readiness-shape` on any other
value, so changing the radius alone would have looked like "the fix
hangs the client". It is now a SHAPE invariant (`indoor => 0`,
`outdoor => >= 1`). Runtime does not own the graphical host's streaming
configuration and must not learn it; plumbing App radii into Runtime to
preserve the strict equality is exactly the assert-a-mechanism-that-does-
not-exist failure C5b was built to stop. Both non-graphical producers
keep emitting their centre-ring token and stay legal, annotated in place.
D6 `PhysicsEngine.IsNeighborhoodTerrainResident` rebuilt a full-map
`HashSet` on every call, every frame of every hold. At radius 1 that was
invisible; at radius 12 (625 ring members) it violates Slice I1's
0 B/resolve standard. Now an engine-owned scratch set, cleared in place;
measured at 0 bytes over 1,000 warmed radius-12 queries.
Also: the destination reservation opens at exactly the gate's radius and
reopens on the same generation when the radius changes mid-hold (retail
has one square for both, and no concept of prioritising an inner ring
differently). Composite warmup deliberately stays `NearRadius`-scoped —
the composite domain is entity-scoped and Far builds carry no entities,
so widening it would walk the outer window to warm nothing.
`ACDREAM_PROBE_REVEAL_RADIUS` is a measurement probe in a diagnostic
owner (CLAUDE.md rule 5) so the connected route can be run A/B on one
binary; it is NOT a user-facing prefetch knob, since a low setting would
reintroduce the decoupling this slice exists to close.
Register: AD-2 amended with the derived window, the two-tier split, and
the four new retail anchors. AP-149 FILED for the residual this does not
close — the outer ring accepts terrain-only publication where retail
requires LandBlockInfo and every building EnvCell, so a distant building
can still pop in at Far-ring distances. Do not let a later closeout
claim parity.
Docs: `ACDREAM_STREAM_RADIUS`'s CLAUDE.md description was wrong on every
clause (the default is unset, not 2; it forces `NearRadius`; it is
silently discarded by any Settings save) — corrected, since that is the
file every session reads. `reference_two_tier_streaming.md` corrected in
four ways, including "Far tier = terrain only": Far also publishes
terrain COLLISION, which is precisely what makes this fix viable.
#280's issue text had the right conclusion from a wrong premise (it
names a view-distance setting acdream does not have) — corrected, and
the missing Viewing Distance option filed separately as #326, with #327
(DDD progress readout) and #328 (hardcoded 5000 f far plane vs retail's
byte-verified 4000) filed alongside.
Expect LONGER holds and the "In Portal Space - Please Wait..." cue on
recalls MORE often. That is convergence toward retail, not away from it:
retail emits the byte-identical string for the whole duration of a
blocked prefetch and polls at 5 s intervals. The failure condition is
non-convergence, not duration.
Gates: Release build 0 errors. Complete suite 11,178 passed / 4 skipped
/ 0 failed, against a re-measured 11,142 / 4 / 0 baseline at 9ee9c1a1 —
+36, reconciled exactly as 36 new tests (App +23, Runtime +10, Core +3),
zero deleted, zero newly skipped. Nine discriminating tests
sabotage-verified in both directions. The connected/visual gate is
batched into C5's matrix; its recipe, its three positive artifacts, and
its required recall leg are written into the campaign plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9ee9c1a1a6
commit
3aab05b0cc
23 changed files with 2502 additions and 86 deletions
19
CLAUDE.md
19
CLAUDE.md
|
|
@ -1422,8 +1422,23 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
|
|||
- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid,
|
||||
stance, cmd, speed) + resulting `SetCycle` call. Massive for remote-
|
||||
animation debugging.
|
||||
- `ACDREAM_STREAM_RADIUS=N` — tune landblock visible-window radius
|
||||
(default 2 = 5×5).
|
||||
- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override
|
||||
(`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the
|
||||
shipped radii come from the quality preset
|
||||
(`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring
|
||||
inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only
|
||||
ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is
|
||||
silently discarded by any later Settings `ApplyQuality`
|
||||
(`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it
|
||||
unset for any measurement or gate run** — with it set you are measuring a
|
||||
different window than production. Per-axis overrides
|
||||
`ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`)
|
||||
are the modern spelling.
|
||||
- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe
|
||||
(`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal
|
||||
gate to landblock radius N instead of the derived streaming window, so the
|
||||
same binary can run a route once with the pre-#280 behaviour (`=1`) and once
|
||||
without. Not a user setting; not surfaced in Settings; not persisted.
|
||||
- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver-
|
||||
broken setups.
|
||||
- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion
|
||||
|
|
|
|||
|
|
@ -1684,14 +1684,63 @@ it. Do #297 FIRST — #298 depends on it.
|
|||
entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were
|
||||
verified in the connected client; focused effect, projectile, and
|
||||
cell-transition tests cover the race. Landed at `f24532ad`.
|
||||
- **#280 — OPEN — portal reveal can expose an incompletely streamed distant
|
||||
landscape.** User-observed 2026-08-03: after some recalls, the nearby
|
||||
destination is playable but terrain near the far end of the view continues
|
||||
visibly building after portal space exits. The current outdoor reveal gate
|
||||
is explicitly only `WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius =
|
||||
1` (a 3x3 landblock neighborhood), while the normal configured view extends
|
||||
substantially farther; this permits the world viewport to open before its
|
||||
visible static destination is complete.
|
||||
- **#280 — FIXED (2026-08-05), pending the batched C5c connected/visual gate —
|
||||
portal reveal could expose an incompletely streamed distant landscape.**
|
||||
User-observed 2026-08-03: after some recalls, the nearby destination is
|
||||
playable but terrain near the far end of the view continues visibly building
|
||||
after portal space exits. **Premise correction (the original text named a
|
||||
setting that does not exist):** acdream has no "configured view distance" —
|
||||
`grep -rniE "viewdistance|view_distance|LandscapeDrawDistance|DrawDistance"`
|
||||
over `src/` returns nothing. The correct premise is the configured
|
||||
*streaming/fog* window, `QualitySettings.FarRadius`: at the shipped `High`
|
||||
preset the user sees terrain out to the fog end
|
||||
(`FarRadius * 192 m * 0.95` ≈ 2,189 m) inside a 2,304 m Far window, while the
|
||||
outdoor reveal gate opened at a hardcoded radius-1 3×3 neighbourhood
|
||||
(≈192 m) — an 11.4:1 ratio where retail's is 1:1 by construction, because
|
||||
retail's prefetched, loaded and drawn squares are literally the same array
|
||||
(`LScape::mid_radius`; `LScape::PreFetchCells` @0x00505660). The conclusion
|
||||
in the original text was right; the premise was not.
|
||||
**Fix correction:** raising the constant alone could not work, twice over.
|
||||
`StreamingController.IsRenderNeighborhoodResident` demanded `IsNearTier` for
|
||||
every ring member, so any radius above `NearRadius` was unsatisfiable and
|
||||
would have held the reveal forever; and
|
||||
`RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived and
|
||||
asserted `indoor ? 0 : 1`, so a changed radius failed
|
||||
`invalid-readiness-shape` and the reveal never opened. The landed change is a
|
||||
radius derivation **and** a tier-aware predicate **and** an invariant
|
||||
loosening, plus the forced allocation fix in
|
||||
`PhysicsEngine.IsNeighborhoodTerrainResident`. Contract:
|
||||
`docs/research/2026-08-05-280-contract.md`. Residual filed as register row
|
||||
AP-149 (the outer ring accepts terrain-only publication where retail requires
|
||||
LandBlockInfo and every building EnvCell).
|
||||
- **#326 — OPEN — acdream has no Viewing Distance option.** Retail exposes one
|
||||
user-facing landscape-extent preference,
|
||||
`Render.LandscapeDrawDistance` — a six-position enum
|
||||
(`Render_LandscapeDrawDistance_Values` @0x007CA988 = 3/5/8/11/15/25, labels
|
||||
VeryLow/…/Extreme, **default 8**, both byte-verified), registered at
|
||||
`UserPreferences::RegisterPreference` @0x0054ECBE and pushed into
|
||||
`SmartBox::set_mid_radius` @0x004531D0. acdream's structural analogue is the
|
||||
quality preset's `NearRadius`/`FarRadius` pair, which is not separately
|
||||
user-controllable. Split out of #280 deliberately (§3/§14 of that contract):
|
||||
#280 derives its reveal window from whatever feeds the streaming radii, so
|
||||
this feature lands by changing what feeds them and #280's derivation keeps
|
||||
working untouched.
|
||||
- **#327 — OPEN — acdream has no analogue of retail's DDD prefetch progress
|
||||
readout.** While `CellManager::blocking_for_cells` is latched, retail reports
|
||||
`ECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total)` @0x00692870
|
||||
into `gmPowerbarUI::RecvNotice_RuntimeDDDStatus` @0x004DA5C0, which drives a
|
||||
powerbar progress bar with an "N of M" cell count (string id
|
||||
`ID_Powerbar_DDDModeText`). acdream shows only the centered
|
||||
"In Portal Space - Please Wait..." cue. #280 makes reveal holds longer and
|
||||
more frequent, which makes the missing readout more noticeable.
|
||||
- **#328 — OPEN — the camera far plane is a hardcoded 5000 f in four camera
|
||||
classes.** `RetailChaseCamera.cs`, `ChaseCamera.cs`, `FlyCamera.cs`,
|
||||
`OrbitCamera.cs` each hardcode it with no config path. Retail's
|
||||
`Render::zfar` is statically initialised to **4000.0** (byte-verified at
|
||||
`0x0081EC88`), and the only writers are `GameSky::Draw` @0x00507055 /
|
||||
@0x005070EE, which temporarily multiply by 4 for the skybox and restore.
|
||||
Independent of #280 — in both clients the landscape horizon is the landblock
|
||||
window, not the frustum — but it is an uncited divergence.
|
||||
- **#281 — DONE (2026-08-03) — the stabilization commits left the automated
|
||||
suites red, and the world-frame contract they introduced had no coverage.**
|
||||
The 2026-08-03 handoff recorded "six selected fixture failures". A measured
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -64,6 +64,16 @@ Remaining campaign work, in order:
|
|||
conversion remains a trigger-conditioned carry, not a completed item.**
|
||||
3. Resolve #280 with retail's configured destination-prefetch window so the
|
||||
portal viewport never reveals visibly constructing far terrain.
|
||||
**DONE 2026-08-05 (implementation + suite); the connected/visual gate is
|
||||
batched into C5's matrix. Shape correction: retail has NO separate prefetch
|
||||
window** — it has one landscape square (`LScape::mid_radius`) that is
|
||||
simultaneously the loaded, drawn and blocked-on set, and whose configured
|
||||
value is `Render.LandscapeDrawDistance`. acdream now derives its reveal
|
||||
window from the live streaming radii (`QualitySettings.FarRadius`) and made
|
||||
the render-completeness predicate tier-aware so the outer rings can satisfy
|
||||
it. Contract: [`2026-08-05-280-contract.md`](../research/2026-08-05-280-contract.md).
|
||||
Residual filed as AP-149; the missing user-facing Viewing Distance option is
|
||||
filed separately as #326 and is explicitly NOT part of #280.
|
||||
4. Run C5's complete Release suite, lifecycle/reconnect route, latest-binary
|
||||
nine-stop soak, two-client observation, and the remaining #269 slope-glide
|
||||
visual check. A pass from `01f4791e` is evidence for that fix, not a
|
||||
|
|
@ -597,6 +607,25 @@ same commit) → docs/handoff commit. No workarounds; no fused slices.
|
|||
physics probe family strip (`REMOTE_LANDING`/`REMOTE_SLIDE`/`PARK`/
|
||||
`REMOTE_TELEPORT`/`CHILD_CELL`/`LOCAL_TELEPORT`) — after, never before,
|
||||
the four owed gates consume them.
|
||||
**#280's connected gate rides this matrix (added 2026-08-05).** Release,
|
||||
`ACDREAM_RETAIL_UI=1`, `ACDREAM_STREAM_RADIUS` **UNSET** (it forces
|
||||
`NearRadius` and only raises `FarRadius`, so a run with it set measures a
|
||||
different window than production). Run the route TWICE on the same binary —
|
||||
once with `ACDREAM_PROBE_REVEAL_RADIUS=1` (reproduces the pre-#280 gate) and
|
||||
once without — and report BOTH. The user-facing observable is an ABSENCE, so
|
||||
the pass criteria are three positive artifacts per stop, all from existing
|
||||
machinery: (1) a `world-visible` checkpoint JSON whose
|
||||
`StreamingWork.NearBacklog` / `.FarBacklog` / `.DestinationBacklog` /
|
||||
`.PendingPublications` are zero for the destination window at the moment the
|
||||
viewport opened; (2) a hold-duration pair — **the post-fix hold is EXPECTED
|
||||
to be LONGER**, and a hold that is not longer means the gate did not widen
|
||||
and the run proves nothing; (3) a paired screenshot per stop, where the
|
||||
pre-fix run is the one that shows the defect. `wait world-visible 30000` in
|
||||
`tools/connected-world-lifecycle.route.txt` is the convergence ceiling — a
|
||||
trip is a failure, a longer pass is not. **The reported repro was a RECALL,
|
||||
not `/teleloc`: the matrix needs a lifestone/recall leg**, and it must
|
||||
include a first-login stop, because login shares the same barrier and its
|
||||
gate widened too.
|
||||
|
||||
After C5: AP-22 (authored collision shapes), then AD-10 (remote
|
||||
contact-plane projection), then the campaign's final matrix and ledger
|
||||
|
|
|
|||
1205
docs/research/2026-08-05-280-contract.md
Normal file
1205
docs/research/2026-08-05-280-contract.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -370,6 +370,15 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
foundation.TextureCache.SetDestinationRevealUploadPriority);
|
||||
var worldReveal = new WorldRevealCoordinator(
|
||||
live.WorldTransit,
|
||||
// #280: read the radii LIVE from the streaming controller rather
|
||||
// than capturing them here. They are runtime mutable through
|
||||
// Settings (StreamingController.ReconfigureRadii), and retail
|
||||
// re-arms the blocking prefetch at the new radius on a mid-hold
|
||||
// change (SmartBox::set_mid_radius @0x00453180).
|
||||
() => StreamingDiagnostics.ApplyRevealRadiusOverride(
|
||||
new StreamingRevealWindow(
|
||||
streaming.NearRadius,
|
||||
streaming.FarRadius)),
|
||||
streaming.IsRenderNeighborhoodResident,
|
||||
d.PhysicsEngine.IsSpawnCellReady,
|
||||
d.PhysicsEngine.IsNeighborhoodTerrainResident,
|
||||
|
|
|
|||
|
|
@ -217,17 +217,38 @@ public sealed class StreamingController
|
|||
internal bool IsCollapsedToDungeon => _collapsed;
|
||||
|
||||
/// <summary>
|
||||
/// True once every in-bounds landblock in the requested Chebyshev ring has
|
||||
/// crossed the render-thread publication barrier. Worker completion and
|
||||
/// True once every in-bounds landblock in the requested Chebyshev window
|
||||
/// has crossed the render-thread publication barrier. Worker completion and
|
||||
/// world-state registration are not sufficient: all static GfxObj and
|
||||
/// EnvCell shell meshes must have completed their render-thread upload.
|
||||
/// Portal-space exit uses this alongside physics residency so the world
|
||||
/// cannot be revealed while its render slots are still absent.
|
||||
///
|
||||
/// <para>
|
||||
/// #280: the window is TIERED, because acdream's loaded landscape is.
|
||||
/// Members inside <paramref name="nearRadius"/> must be Near-tier — the
|
||||
/// full publication retail's <c>LScape::PreFetchCells</c> @0x00505660
|
||||
/// demands (terrain record, LandBlockInfo, building EnvCells). Members out
|
||||
/// to <paramref name="farRadius"/> need only their Far-tier terrain
|
||||
/// publication to be drawable, which is what the user actually sees out
|
||||
/// there. Requiring Near tier across the whole window would be
|
||||
/// unsatisfiable: nothing outside the Near ring is ever promoted, so the
|
||||
/// reveal would hold forever.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsRenderNeighborhoodResident(uint cellOrLandblockId, int radius)
|
||||
public bool IsRenderNeighborhoodResident(
|
||||
uint cellOrLandblockId,
|
||||
int nearRadius,
|
||||
int farRadius)
|
||||
{
|
||||
if (radius < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(radius));
|
||||
if (nearRadius < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(nearRadius));
|
||||
if (farRadius < nearRadius)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(farRadius),
|
||||
"Far radius must be greater than or equal to near radius.");
|
||||
}
|
||||
|
||||
// LandDefs::InboundValidCellId validates both map axes and the low-word
|
||||
// class (outdoor cell, EnvCell, or canonical landblock sentinel).
|
||||
|
|
@ -235,18 +256,26 @@ public sealed class StreamingController
|
|||
return false;
|
||||
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++)
|
||||
for (int dx = -farRadius; dx <= farRadius; dx++)
|
||||
for (int dy = -farRadius; dy <= farRadius; dy++)
|
||||
{
|
||||
int nx = cx + dx;
|
||||
int ny = cy + dy;
|
||||
// Match PhysicsEngine.IsNeighborhoodTerrainResident: the outer
|
||||
// 0xFF coordinate has no loadable neighbour beyond it.
|
||||
// 0xFF coordinate has no loadable neighbour beyond it. Retail skips
|
||||
// the same way (LScape::PreFetchCells' >= 0x7F8 bounds test).
|
||||
if (nx < 0 || nx > 254 || ny < 0 || ny > 254)
|
||||
continue;
|
||||
|
||||
uint canonical = ((uint)nx << 24) | ((uint)ny << 16) | 0xFFFFu;
|
||||
if (!_state.IsNearTier(canonical) || !_state.IsRenderReady(canonical))
|
||||
// GpuWorldState.IsRenderReady already implies IsLoaded; a Far-tier
|
||||
// publication registers with the spawn adapter and an empty mesh
|
||||
// set, so this is a real drawability test out there, not a stamp.
|
||||
if (!_state.IsRenderReady(canonical))
|
||||
return false;
|
||||
bool isInnerRing = Math.Abs(dx) <= nearRadius
|
||||
&& Math.Abs(dy) <= nearRadius;
|
||||
if (isInnerRing && !_state.IsNearTier(canonical))
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +720,7 @@ public sealed class StreamingController
|
|||
_destinationReservation is not null
|
||||
&& !IsRenderNeighborhoodResident(
|
||||
DestinationLandblockId,
|
||||
Math.Min(NearRadius, DestinationRadius),
|
||||
DestinationRadius);
|
||||
if (!ConvergePendingPublications(
|
||||
preferDestination: destinationPublicationIncomplete))
|
||||
|
|
|
|||
56
src/AcDream.App/Streaming/StreamingDiagnostics.cs
Normal file
56
src/AcDream.App/Streaming/StreamingDiagnostics.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic owner for the streaming/reveal probe family (CLAUDE.md Code
|
||||
/// Structure Rules §5 — one static class per subsystem, typed properties read
|
||||
/// from the environment once at startup, never per-call-site
|
||||
/// <c>GetEnvironmentVariable</c> reads).
|
||||
/// </summary>
|
||||
internal static class StreamingDiagnostics
|
||||
{
|
||||
/// <summary>
|
||||
/// #280 A/B measurement probe. When set, the outdoor reveal gate uses this
|
||||
/// landblock radius instead of the derived streaming window, so the same
|
||||
/// binary can run the connected route once with the pre-fix behaviour
|
||||
/// (<c>ACDREAM_PROBE_REVEAL_RADIUS=1</c>, the old
|
||||
/// <c>OutdoorNeighborhoodRadius</c>) and once without. It is NOT a user
|
||||
/// setting: it is not surfaced in Settings, not persisted, and not part of
|
||||
/// <c>RuntimeOptions</c>. A shipped prefetch knob would re-expose exactly
|
||||
/// the reveal-window/visible-window decoupling #280 exists to close.
|
||||
/// Unset or unparseable leaves the derivation in charge.
|
||||
/// </summary>
|
||||
public static int? RevealRadiusOverride { get; } =
|
||||
ParseRadius(
|
||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_RADIUS"));
|
||||
|
||||
/// <summary>
|
||||
/// Applies <see cref="RevealRadiusOverride"/> to a live streaming window.
|
||||
/// </summary>
|
||||
public static StreamingRevealWindow ApplyRevealRadiusOverride(
|
||||
StreamingRevealWindow window) =>
|
||||
ApplyRevealRadiusOverride(window, RevealRadiusOverride);
|
||||
|
||||
/// <summary>
|
||||
/// Pure override application, separated so the clamp is testable without
|
||||
/// mutating process-wide diagnostic state. The near radius is clamped to
|
||||
/// the (possibly overridden) far radius: the gate's inner arm demands
|
||||
/// Near-tier publication, so it can never exceed the outer arm.
|
||||
/// </summary>
|
||||
public static StreamingRevealWindow ApplyRevealRadiusOverride(
|
||||
StreamingRevealWindow window,
|
||||
int? overrideRadius)
|
||||
{
|
||||
if (overrideRadius is not { } radius)
|
||||
return window;
|
||||
|
||||
int far = Math.Max(0, radius);
|
||||
return new StreamingRevealWindow(
|
||||
Math.Clamp(window.NearRadius, 0, far),
|
||||
far);
|
||||
}
|
||||
|
||||
private static int? ParseRadius(string? raw) =>
|
||||
int.TryParse(raw, out int value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
|
@ -43,7 +43,15 @@ internal sealed class WorldRevealCoordinator
|
|||
in WorldGenerationQuiescenceEdge quiescenceEdge)
|
||||
{
|
||||
public RuntimeWorldHostProjectionToken Token { get; } = token;
|
||||
public int RequiredRenderRadius { get; } = requiredRenderRadius;
|
||||
|
||||
/// <summary>
|
||||
/// The radius this projection's streaming reservation is currently
|
||||
/// open at. Mutable because the quality preset can be changed
|
||||
/// mid-hold; retail re-arms the blocking prefetch at the new radius
|
||||
/// (<c>SmartBox::set_mid_radius</c> @0x00453180) rather than finishing
|
||||
/// the old hold at the old one.
|
||||
/// </summary>
|
||||
public int RequiredRenderRadius { get; set; } = requiredRenderRadius;
|
||||
public WorldGenerationQuiescenceEdge QuiescenceEdge { get; } =
|
||||
quiescenceEdge;
|
||||
public bool QuiescenceCommitted { get; set; }
|
||||
|
|
@ -65,7 +73,8 @@ internal sealed class WorldRevealCoordinator
|
|||
|
||||
public WorldRevealCoordinator(
|
||||
RuntimeWorldTransitState transit,
|
||||
Func<uint, int, bool> isRenderNeighborhoodReady,
|
||||
Func<StreamingRevealWindow> revealWindow,
|
||||
Func<uint, int, int, bool> isRenderNeighborhoodReady,
|
||||
Func<uint, bool> isSpawnCellReady,
|
||||
Func<uint, int, bool> isTerrainNeighborhoodReady,
|
||||
Func<bool> areCompositeTexturesReady,
|
||||
|
|
@ -78,6 +87,7 @@ internal sealed class WorldRevealCoordinator
|
|||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_readiness = new WorldRevealReadinessBarrier(
|
||||
revealWindow,
|
||||
isRenderNeighborhoodReady,
|
||||
isSpawnCellReady,
|
||||
isTerrainNeighborhoodReady,
|
||||
|
|
@ -164,8 +174,7 @@ internal sealed class WorldRevealCoordinator
|
|||
|
||||
_hostProjections.Add(new HostProjection(
|
||||
token,
|
||||
WorldRevealReadinessBarrier.RequiredRenderRadius(
|
||||
destinationCell),
|
||||
_readiness.RequiredRenderRadius(destinationCell),
|
||||
quiescenceEdge));
|
||||
RetryPendingHostWork();
|
||||
}
|
||||
|
|
@ -184,6 +193,7 @@ internal sealed class WorldRevealCoordinator
|
|||
{
|
||||
RetryPendingHostWork();
|
||||
WorldRevealReadinessSnapshot snapshot = _readiness.Evaluate(destinationCell);
|
||||
ReconcileDestinationReservationRadius(snapshot);
|
||||
RuntimePortalSnapshot portal = _transit.Snapshot;
|
||||
if (portal.Generation != 0)
|
||||
{
|
||||
|
|
@ -499,6 +509,39 @@ internal sealed class WorldRevealCoordinator
|
|||
.DestinationReservationReleased);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the open destination reservation on the same square the gate is
|
||||
/// measuring after a mid-hold quality change. Retail's
|
||||
/// <c>SmartBox::set_mid_radius</c> @0x00453180 resets the cell manager,
|
||||
/// re-radiuses the landscape, and re-arms the blocking prefetch at the NEW
|
||||
/// value; acdream's equivalent is to close and reopen the reservation on
|
||||
/// the same reveal generation. <c>EndDestinationReservation</c> already
|
||||
/// refuses a generation mismatch, so the reopen is generation-safe.
|
||||
/// </summary>
|
||||
private void ReconcileDestinationReservationRadius(
|
||||
in WorldRevealReadinessSnapshot snapshot)
|
||||
{
|
||||
if (_streaming is null || !snapshot.HasDestination)
|
||||
return;
|
||||
|
||||
HostProjection? host = FindCurrentHostProjection();
|
||||
if (host is null
|
||||
|| !host.StreamingRegistered
|
||||
|| host.StreamingReleased
|
||||
|| host.Token.DestinationCell != snapshot.DestinationCell
|
||||
|| host.RequiredRenderRadius == snapshot.RequiredRenderRadius)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_streaming.EndDestinationReservation(host.Token.Generation);
|
||||
_streaming.BeginDestinationReservation(
|
||||
host.Token.Generation,
|
||||
host.Token.DestinationCell,
|
||||
snapshot.RequiredRenderRadius);
|
||||
host.RequiredRenderRadius = snapshot.RequiredRenderRadius;
|
||||
}
|
||||
|
||||
private void WithdrawHostForReplacement()
|
||||
{
|
||||
HostProjection? host = FindCurrentHostProjection();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
namespace AcDream.App.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// The live landblock window the reveal gate measures. Retail has exactly one
|
||||
/// landscape square — <c>LScape::mid_radius</c>, assigned directly from the
|
||||
/// user's <c>Render.LandscapeDrawDistance</c> preference
|
||||
/// (<c>SmartBox::SetRegion</c> @0x004531F0;
|
||||
/// <c>Render_LandscapeDrawDistance_Values</c> @0x007CA988 = {3,5,8,11,15,25},
|
||||
/// default 8) — and that same square is simultaneously the loaded set, the
|
||||
/// drawn set, and the set <c>LScape::PreFetchCells</c> @0x00505660 blocks on.
|
||||
/// acdream splits the loaded landscape into a Near tier (full publication) and
|
||||
/// a Far tier (terrain render + terrain collision), so the reveal window is a
|
||||
/// pair rather than a single number; <see cref="FarRadius"/> is the structural
|
||||
/// analogue of retail's <c>mid_radius</c> because it bounds everything the
|
||||
/// user can see (fog end is <c>FarRadius * 192 m * 0.95</c>).
|
||||
/// </summary>
|
||||
internal readonly record struct StreamingRevealWindow(
|
||||
int NearRadius,
|
||||
int FarRadius);
|
||||
|
||||
/// <summary>
|
||||
/// One evaluation of the destination domains guarded by
|
||||
/// <see cref="WorldRevealReadinessBarrier"/>. Keeping the individual facts in
|
||||
|
|
@ -11,6 +29,7 @@ internal readonly record struct WorldRevealReadinessSnapshot(
|
|||
bool IsIndoor,
|
||||
bool IsUnhydratable,
|
||||
int RequiredRenderRadius,
|
||||
int RequiredNearRadius,
|
||||
bool IsRenderNeighborhoodReady,
|
||||
bool AreCompositeTexturesReady,
|
||||
bool IsCollisionReady)
|
||||
|
|
@ -32,12 +51,21 @@ internal readonly record struct WorldRevealReadinessSnapshot(
|
|||
/// (<c>SmartBox::UseTime</c>, 0x00455410). acdream loads those domains
|
||||
/// asynchronously, so login and portal arrival must share this explicit
|
||||
/// equivalent of retail's single blocking-cell edge.
|
||||
///
|
||||
/// <para>
|
||||
/// #280: the outdoor gate is DERIVED from the live streaming window, never
|
||||
/// hardcoded. Retail cannot stream farther than it gates because it has only
|
||||
/// one number (<c>LScape::SetMidRadius</c> @0x00504C00 sets
|
||||
/// <c>mid_width = radius * 2 + 1</c> and <c>LScape::update_block</c>
|
||||
/// @0x005063A0 allocates exactly that array). A fixed reveal radius under a
|
||||
/// configurable streaming window reintroduces the decoupling the moment the
|
||||
/// preset changes, which is the whole defect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class WorldRevealReadinessBarrier
|
||||
{
|
||||
internal const int OutdoorNeighborhoodRadius = 1;
|
||||
|
||||
private readonly Func<uint, int, bool> _isRenderNeighborhoodReady;
|
||||
private readonly Func<StreamingRevealWindow> _revealWindow;
|
||||
private readonly Func<uint, int, int, bool> _isRenderNeighborhoodReady;
|
||||
private readonly Func<uint, bool> _isSpawnCellReady;
|
||||
private readonly Func<uint, int, bool> _isTerrainNeighborhoodReady;
|
||||
private readonly Func<bool> _areCompositeTexturesReady;
|
||||
|
|
@ -46,7 +74,8 @@ internal sealed class WorldRevealReadinessBarrier
|
|||
private readonly Func<uint, bool> _isSpawnClaimUnhydratable;
|
||||
|
||||
public WorldRevealReadinessBarrier(
|
||||
Func<uint, int, bool> isRenderNeighborhoodReady,
|
||||
Func<StreamingRevealWindow> revealWindow,
|
||||
Func<uint, int, int, bool> isRenderNeighborhoodReady,
|
||||
Func<uint, bool> isSpawnCellReady,
|
||||
Func<uint, int, bool> isTerrainNeighborhoodReady,
|
||||
Func<bool> areCompositeTexturesReady,
|
||||
|
|
@ -54,6 +83,8 @@ internal sealed class WorldRevealReadinessBarrier
|
|||
Action invalidateCompositeTextures,
|
||||
Func<uint, bool> isSpawnClaimUnhydratable)
|
||||
{
|
||||
_revealWindow = revealWindow
|
||||
?? throw new ArgumentNullException(nameof(revealWindow));
|
||||
_isRenderNeighborhoodReady = isRenderNeighborhoodReady
|
||||
?? throw new ArgumentNullException(nameof(isRenderNeighborhoodReady));
|
||||
_isSpawnCellReady = isSpawnCellReady
|
||||
|
|
@ -86,9 +117,20 @@ internal sealed class WorldRevealReadinessBarrier
|
|||
if (destinationCell == 0 || _isSpawnClaimUnhydratable(destinationCell))
|
||||
return;
|
||||
|
||||
int radius = RequiredRenderRadius(destinationCell);
|
||||
if (_isRenderNeighborhoodReady(destinationCell, radius))
|
||||
_prepareCompositeTextures(destinationCell, radius);
|
||||
StreamingRevealWindow required = RequiredWindow(destinationCell);
|
||||
if (_isRenderNeighborhoodReady(
|
||||
destinationCell,
|
||||
required.NearRadius,
|
||||
required.FarRadius))
|
||||
{
|
||||
// D3: the composite domain is entity-scoped
|
||||
// (WbDrawDispatcher.IsCompositeWarmupCandidate filters entities by
|
||||
// Chebyshev landblock radius) and Far-tier builds carry no
|
||||
// entities at all (LandblockBuildFactory), so the honest composite
|
||||
// domain is the NEAR radius. Widening it over Far rings would walk
|
||||
// the whole outer window to warm nothing.
|
||||
_prepareCompositeTextures(destinationCell, required.NearRadius);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -110,38 +152,76 @@ internal sealed class WorldRevealReadinessBarrier
|
|||
return default;
|
||||
|
||||
bool isIndoor = IsIndoor(destinationCell);
|
||||
int radius = RequiredRenderRadius(destinationCell);
|
||||
StreamingRevealWindow required = RequiredWindow(destinationCell);
|
||||
if (_isSpawnClaimUnhydratable(destinationCell))
|
||||
{
|
||||
return new WorldRevealReadinessSnapshot(
|
||||
destinationCell,
|
||||
isIndoor,
|
||||
IsUnhydratable: true,
|
||||
radius,
|
||||
required.FarRadius,
|
||||
required.NearRadius,
|
||||
IsRenderNeighborhoodReady: false,
|
||||
AreCompositeTexturesReady: false,
|
||||
IsCollisionReady: false);
|
||||
}
|
||||
|
||||
bool renderReady = _isRenderNeighborhoodReady(destinationCell, radius);
|
||||
bool renderReady = _isRenderNeighborhoodReady(
|
||||
destinationCell,
|
||||
required.NearRadius,
|
||||
required.FarRadius);
|
||||
bool compositesReady = renderReady && _areCompositeTexturesReady();
|
||||
bool collisionReady = renderReady && compositesReady
|
||||
&& (isIndoor
|
||||
? _isSpawnCellReady(destinationCell)
|
||||
: _isTerrainNeighborhoodReady(destinationCell, radius));
|
||||
: _isTerrainNeighborhoodReady(
|
||||
destinationCell,
|
||||
required.FarRadius));
|
||||
|
||||
return new WorldRevealReadinessSnapshot(
|
||||
destinationCell,
|
||||
isIndoor,
|
||||
IsUnhydratable: false,
|
||||
radius,
|
||||
required.FarRadius,
|
||||
required.NearRadius,
|
||||
renderReady,
|
||||
compositesReady,
|
||||
collisionReady);
|
||||
}
|
||||
|
||||
internal static int RequiredRenderRadius(uint destinationCell) =>
|
||||
IsIndoor(destinationCell) ? 0 : OutdoorNeighborhoodRadius;
|
||||
/// <summary>
|
||||
/// The window this destination must satisfy, read LIVE from the streaming
|
||||
/// configuration on every call. Retail's answer to a mid-hold radius
|
||||
/// change is <c>SmartBox::set_mid_radius</c> @0x00453180: reset the cell
|
||||
/// manager, re-radius the landscape, and re-arm the blocking prefetch at
|
||||
/// the NEW value — never finish the old hold at the old radius.
|
||||
///
|
||||
/// <para>
|
||||
/// Indoor destinations take retail's EnvCell arm
|
||||
/// (<c>CEnvCell::PreFetchCells</c> @0x0052D1E0), which walks the cell's own
|
||||
/// visible-cell graph rather than the landscape square, so the outdoor
|
||||
/// ring is not required and the radius is zero.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal StreamingRevealWindow RequiredWindow(uint destinationCell)
|
||||
{
|
||||
if (IsIndoor(destinationCell))
|
||||
return new StreamingRevealWindow(0, 0);
|
||||
|
||||
StreamingRevealWindow window = _revealWindow();
|
||||
int far = Math.Max(0, window.FarRadius);
|
||||
int near = Math.Clamp(window.NearRadius, 0, far);
|
||||
return new StreamingRevealWindow(near, far);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The outdoor gate's outer radius — retail's <c>mid_radius</c>. Callers
|
||||
/// that reserve destination streaming capacity must use exactly this value
|
||||
/// so the prefetch square and the blocked square stay identical, as they
|
||||
/// are in retail by construction.
|
||||
/// </summary>
|
||||
internal int RequiredRenderRadius(uint destinationCell) =>
|
||||
RequiredWindow(destinationCell).FarRadius;
|
||||
|
||||
private static bool IsIndoor(uint cellId) => (cellId & 0xFFFFu) >= 0x0100u;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ public sealed class PhysicsEngine
|
|||
_collisionWorld.Current.LandblockFreeSlots;
|
||||
private readonly TransitionScratchArena? _transitionScratch;
|
||||
|
||||
// #280 (D6): reusable landblock-prefix scratch for
|
||||
// IsNeighborhoodTerrainResident. Physics is single-threaded per engine and
|
||||
// this method is a leaf, so one instance-owned set is safe and keeps the
|
||||
// per-frame reveal gate allocation-free.
|
||||
private readonly HashSet<uint> _terrainResidencyScratch = new();
|
||||
|
||||
public PhysicsEngine()
|
||||
: this(reuseTransitionScratch: true)
|
||||
{
|
||||
|
|
@ -128,7 +134,17 @@ public sealed class PhysicsEngine
|
|||
/// </summary>
|
||||
public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius)
|
||||
{
|
||||
var resident = new HashSet<uint>();
|
||||
// #280 (D6): this runs every frame for the whole duration of a reveal
|
||||
// hold, and the hold's radius is now the streaming Far radius (12 at
|
||||
// the shipped High preset = 625 ring members) instead of 1. Building a
|
||||
// fresh HashSet per call would allocate on every frame of every hold,
|
||||
// against Slice I1's 0 B standard. The scratch set is owned by this
|
||||
// engine, cleared and refilled in place, so a warmed call allocates
|
||||
// nothing; the prefix-masked membership semantics are unchanged
|
||||
// (callers register landblocks under canonical, cell-resolved, or bare
|
||||
// ids and this gate has always compared on the high 16 bits).
|
||||
HashSet<uint> resident = _terrainResidencyScratch;
|
||||
resident.Clear();
|
||||
foreach ((uint key, _) in _landblocks)
|
||||
resident.Add(key & 0xFFFF0000u);
|
||||
|
||||
|
|
|
|||
|
|
@ -970,6 +970,12 @@ internal sealed class HeadlessSessionWorldProjection
|
|||
// concept, derive the real predicate here instead of leaving
|
||||
// this hardcoded.
|
||||
IsUnhydratable: false,
|
||||
// #280: this "centre ring" token is NOT the graphical host's
|
||||
// derived reveal radius and must not be made to track it. This
|
||||
// host has no streaming window, no render publication and no
|
||||
// composites, so there is nothing wider for a radius to mean
|
||||
// here. Runtime validates only the SHAPE (indoor => 0,
|
||||
// outdoor => >= 1), which this satisfies by construction.
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
|
|
|
|||
|
|
@ -773,6 +773,12 @@ public sealed class RuntimeLiveEntitySessionController
|
|||
destination.CellId,
|
||||
indoor,
|
||||
IsUnhydratable: false,
|
||||
// #280: this "centre ring" token is NOT the graphical host's
|
||||
// derived reveal radius and must not be made to track it. This
|
||||
// host has no streaming window, no render publication and no
|
||||
// composites, so there is nothing wider for a radius to mean
|
||||
// here. Runtime validates only the SHAPE (indoor => 0,
|
||||
// outdoor => >= 1), which this satisfies by construction.
|
||||
RequiredRenderRadius: indoor ? 0 : 1,
|
||||
IsRenderNeighborhoodReady: true,
|
||||
AreCompositeTexturesReady: true,
|
||||
|
|
|
|||
|
|
@ -569,18 +569,30 @@ public sealed class RuntimeWorldTransitState
|
|||
if (_snapshot.Readiness.IsReady)
|
||||
return false;
|
||||
|
||||
// #280: this is a SHAPE invariant, not a value invariant. Runtime owns
|
||||
// the canonical reveal lifetime; it does not own — and must never
|
||||
// learn — the graphical host's streaming configuration. The outdoor
|
||||
// radius is now derived from that host's live Near/Far window
|
||||
// (WorldRevealReadinessBarrier.RequiredWindow), so re-encoding a
|
||||
// literal here would assert a mechanism that no longer exists and fail
|
||||
// every graphical acknowledgement. What remains genuinely invariant,
|
||||
// and is still worth failing on, is the shape: an indoor destination
|
||||
// takes retail's EnvCell arm and requires no landscape ring at all,
|
||||
// while an outdoor destination must require at least its own
|
||||
// landblock's ring. The no-window host has no streaming window, so its
|
||||
// "centre ring" token (indoor ? 0 : 1) stays legal by construction.
|
||||
bool isIndoor = IsIndoor(acknowledgement.DestinationCell);
|
||||
int requiredRenderRadius = isIndoor ? 0 : 1;
|
||||
if (acknowledgement.IsIndoor != isIndoor
|
||||
|| acknowledgement.RequiredRenderRadius
|
||||
!= requiredRenderRadius)
|
||||
bool radiusShapeValid = isIndoor
|
||||
? acknowledgement.RequiredRenderRadius == 0
|
||||
: acknowledgement.RequiredRenderRadius >= 1;
|
||||
if (acknowledgement.IsIndoor != isIndoor || !radiusShapeValid)
|
||||
{
|
||||
FailInvariant(
|
||||
"invalid-readiness-shape",
|
||||
$"indoor={acknowledgement.IsIndoor} "
|
||||
+ $"expectedIndoor={isIndoor} "
|
||||
+ $"radius={acknowledgement.RequiredRenderRadius} "
|
||||
+ $"expectedRadius={requiredRenderRadius}");
|
||||
+ $"expectedRadius={(isIndoor ? "0" : ">=1")}");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -692,7 +692,8 @@ public sealed class CurrentGameRuntimeAdapterTests
|
|||
Clock = new UpdateFrameClock(_gameRuntime.Clock);
|
||||
WorldReveal = new WorldRevealCoordinator(
|
||||
WorldTransit,
|
||||
static (_, _) => true,
|
||||
static () => new StreamingRevealWindow(1, 1),
|
||||
static (_, _, _) => true,
|
||||
static _ => true,
|
||||
static (_, _) => true,
|
||||
static () => true,
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Assert.Equal(1, harness.Mode.EnterPortalCount);
|
||||
Assert.Equal(Matrix4x4.Identity, harness.Presentation.BeginProjection);
|
||||
Assert.Equal(
|
||||
(1L, 0x20210001u, WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius),
|
||||
(1L, 0x20210001u, harness.RevealWindow.FarRadius),
|
||||
Assert.Single(harness.Streaming.Reservations));
|
||||
Assert.True(harness.Reveal.Snapshot.IsActive);
|
||||
Assert.Equal(RuntimePortalKind.Portal, harness.Reveal.Snapshot.Kind);
|
||||
|
|
@ -890,6 +890,14 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
public readonly FakePresentation Presentation;
|
||||
public readonly RuntimeWorldTransitState Transit;
|
||||
public readonly WorldRevealCoordinator Reveal;
|
||||
|
||||
/// <summary>
|
||||
/// The live streaming window this harness's reveal barrier derives its
|
||||
/// outdoor radius from (#280). Assertions read this rather than a
|
||||
/// literal so the test cannot silently re-encode the value under test.
|
||||
/// </summary>
|
||||
public StreamingRevealWindow RevealWindow { get; set; } =
|
||||
new(NearRadius: 1, FarRadius: 1);
|
||||
public readonly LocalPlayerTeleportController Controller;
|
||||
public readonly RuntimeEntityObjectLifetime Lifetime;
|
||||
public readonly RuntimeAcceptedPositionDriveController AcceptedPositionDrive;
|
||||
|
|
@ -918,7 +926,8 @@ public sealed class LocalPlayerTeleportControllerTests
|
|||
Transit = new RuntimeWorldTransitState(order.Add);
|
||||
Reveal = new WorldRevealCoordinator(
|
||||
Transit,
|
||||
isRenderNeighborhoodReady: (_, _) => worldReady,
|
||||
revealWindow: () => RevealWindow,
|
||||
isRenderNeighborhoodReady: (_, _, _) => worldReady,
|
||||
isSpawnCellReady: _ => worldReady,
|
||||
isTerrainNeighborhoodReady: (_, _) => worldReady,
|
||||
areCompositeTexturesReady: () => worldReady,
|
||||
|
|
|
|||
|
|
@ -23,11 +23,11 @@ public sealed class StreamingControllerReadinessTests
|
|||
AddPublished(state, 0x12 + dx, 0x36 + dy);
|
||||
}
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 1));
|
||||
|
||||
AddPublished(state, 0x13, 0x37);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x12360022u, 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -37,8 +37,8 @@ public sealed class StreamingControllerReadinessTests
|
|||
StreamingController controller = CreateController(state);
|
||||
AddPublished(state, 0x8C, 0x04);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x8C0401ADu, 0, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(0x8D0401ADu, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -51,7 +51,7 @@ public sealed class StreamingControllerReadinessTests
|
|||
AddPublished(state, 1, 0);
|
||||
AddPublished(state, 1, 1);
|
||||
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(0x00000001u, 1, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -60,7 +60,7 @@ public sealed class StreamingControllerReadinessTests
|
|||
StreamingController controller = CreateController(new GpuWorldState());
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1));
|
||||
() => controller.IsRenderNeighborhoodResident(0x1236FFFFu, -1, -1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -73,7 +73,7 @@ public sealed class StreamingControllerReadinessTests
|
|||
{
|
||||
StreamingController controller = CreateController(new GpuWorldState());
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(cellId, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -97,11 +97,11 @@ public sealed class StreamingControllerReadinessTests
|
|||
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
||||
new[] { envCellGeometryId });
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
meshes.ReadyIds.Add(0x01000010ul);
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -120,9 +120,9 @@ public sealed class StreamingControllerReadinessTests
|
|||
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
|
||||
Assert.True(state.IsNearTier(id));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -137,15 +137,15 @@ public sealed class StreamingControllerReadinessTests
|
|||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
tier: LandblockStreamTier.Far);
|
||||
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
|
||||
state.AddEntitiesToExistingLandblock(
|
||||
id,
|
||||
Array.Empty<WorldEntity>(),
|
||||
new[] { envCellGeometryId });
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.False(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
meshes.ReadyIds.Add(envCellGeometryId);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -170,7 +170,7 @@ public sealed class StreamingControllerReadinessTests
|
|||
new LoadedLandblock(id, new LandBlock(), new[] { entity }),
|
||||
new[] { envCellGeometryId },
|
||||
LandblockStreamTier.Near);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
|
|
@ -178,7 +178,7 @@ public sealed class StreamingControllerReadinessTests
|
|||
|
||||
Assert.True(state.IsNearTier(id));
|
||||
Assert.Contains(entity, state.Entities);
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0));
|
||||
Assert.True(controller.IsRenderNeighborhoodResident(id, 0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -412,6 +412,200 @@ public sealed class StreamingControllerReadinessTests
|
|||
Assert.Empty(appliedBuilds[1].Landblock.Entities);
|
||||
}
|
||||
|
||||
// ── #280: the tiered reveal window ───────────────────────────────────
|
||||
//
|
||||
// Retail loads, draws, and blocks on ONE square (LScape::mid_radius,
|
||||
// LScape::PreFetchCells @0x00505660). acdream's loaded landscape is
|
||||
// tiered, so the equivalent predicate is tiered: full publication inside
|
||||
// the Near ring, terrain publication out to the Far radius. Requiring Near
|
||||
// tier across the whole window is unsatisfiable — nothing outside the Near
|
||||
// ring is ever promoted — and holds the reveal forever.
|
||||
|
||||
/// <summary>
|
||||
/// Case (a), the discriminating one: an INNER-ring member that only has
|
||||
/// its Far-tier terrain must still fail. If this passes while the near arm
|
||||
/// has been loosened into the far arm, the gate silently stops requiring
|
||||
/// scenery/statics at the destination itself.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TieredWindow_InnerRingFarTierMemberIsNotResident()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
AddPublished(
|
||||
state,
|
||||
0x12 + dx,
|
||||
0x36 + dy,
|
||||
dx == 1 && dy == 0
|
||||
? LandblockStreamTier.Far
|
||||
: LandblockStreamTier.Near);
|
||||
}
|
||||
|
||||
Assert.False(
|
||||
controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case (b): an OUTER-ring member at Far tier satisfies the gate. This is
|
||||
/// the half that makes a derived Far-radius reveal window reachable at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TieredWindow_OuterRingFarTierMemberIsResident()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
bool inner = Math.Abs(dx) <= 1 && Math.Abs(dy) <= 1;
|
||||
AddPublished(
|
||||
state,
|
||||
0x12 + dx,
|
||||
0x36 + dy,
|
||||
inner ? LandblockStreamTier.Near : LandblockStreamTier.Far);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case (c): an absent outer-ring member still fails. The outer arm is a
|
||||
/// real publication test, not a rubber stamp.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TieredWindow_AbsentOuterRingMemberIsNotResident()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
{
|
||||
if (dx == 2 && dy == -2)
|
||||
continue;
|
||||
bool inner = Math.Abs(dx) <= 1 && Math.Abs(dy) <= 1;
|
||||
AddPublished(
|
||||
state,
|
||||
0x12 + dx,
|
||||
0x36 + dy,
|
||||
inner ? LandblockStreamTier.Near : LandblockStreamTier.Far);
|
||||
}
|
||||
|
||||
Assert.False(
|
||||
controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2));
|
||||
|
||||
AddPublished(state, 0x14, 0x34, LandblockStreamTier.Far);
|
||||
|
||||
Assert.True(
|
||||
controller.IsRenderNeighborhoodResident(0x12360022u, 1, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A far radius below the near radius is a caller bug, not something to
|
||||
/// clamp silently inside the predicate — the barrier clamps before it gets
|
||||
/// here.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TieredWindow_RejectsAFarRadiusBelowTheNearRadius()
|
||||
{
|
||||
StreamingController controller = CreateController(new GpuWorldState());
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(
|
||||
() => controller.IsRenderNeighborhoodResident(0x1236FFFFu, 3, 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail parity (LScape::PreFetchCells' <c>>= 0x7F8</c> bounds skip): a
|
||||
/// map-corner destination converges because the out-of-bounds members are
|
||||
/// skipped, not required. Exercised at a WIDE window, which is where the
|
||||
/// derived radius now puts the gate.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TieredWindow_MapCornerDestinationConvergesAtAWideRadius()
|
||||
{
|
||||
var state = new GpuWorldState();
|
||||
StreamingController controller = CreateController(state);
|
||||
|
||||
for (int x = 0; x <= 4; x++)
|
||||
for (int y = 0; y <= 4; y++)
|
||||
{
|
||||
bool inner = x <= 2 && y <= 2;
|
||||
AddPublished(
|
||||
state,
|
||||
x,
|
||||
y,
|
||||
inner ? LandblockStreamTier.Near : LandblockStreamTier.Far);
|
||||
}
|
||||
|
||||
Assert.True(
|
||||
controller.IsRenderNeighborhoodResident(0x00000001u, 2, 4));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #280 proof obligation P1 — the whole fix rests on this. A Far-tier
|
||||
/// publication must satisfy <see cref="GpuWorldState.IsRenderReady"/>; if
|
||||
/// it did not, the tiered gate's outer arm could never be satisfied and
|
||||
/// the reveal would hang. Driven through the real presentation pipeline's
|
||||
/// <c>PublicationKind.Far</c> path against a real
|
||||
/// <see cref="LandblockSpawnAdapter"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FarPublication_IsRenderReadyThroughTheRealPipeline()
|
||||
{
|
||||
const uint landblockId = 0x1236FFFFu;
|
||||
var meshes = new ReadinessMeshAdapter();
|
||||
var state = new GpuWorldState(new LandblockSpawnAdapter(meshes));
|
||||
var pipeline = new LandblockPresentationPipeline(
|
||||
publishBeforeSpatialCommit: (_, _) => { },
|
||||
state);
|
||||
|
||||
// A Near-shaped completion that the streaming window has since demoted
|
||||
// to Far: entities and physics payload are stripped by PublishAsFar,
|
||||
// which is exactly the shape an outer-ring landblock is published in.
|
||||
var entity = new WorldEntity
|
||||
{
|
||||
Id = 1,
|
||||
ServerGuid = 0,
|
||||
SourceGfxObjOrSetupId = 0x01000010u,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(0x01000010u, System.Numerics.Matrix4x4.Identity)],
|
||||
};
|
||||
var source = new LandblockBuild(
|
||||
new LoadedLandblock(landblockId, new LandBlock(), new[] { entity }));
|
||||
var mesh = new AcDream.Core.Terrain.LandblockMeshData(
|
||||
Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
|
||||
Array.Empty<uint>());
|
||||
var accepted = new LandblockStreamResult.Loaded(
|
||||
landblockId,
|
||||
LandblockStreamTier.Near,
|
||||
source,
|
||||
mesh);
|
||||
|
||||
pipeline.PublishAsFar(accepted, source, mesh);
|
||||
|
||||
Assert.True(state.IsLoaded(landblockId));
|
||||
Assert.False(state.IsNearTier(landblockId));
|
||||
// The Far registration carries an EMPTY desired mesh set, so it is
|
||||
// render-ready without any IWbMeshAdapter upload ever completing —
|
||||
// note ReadinessMeshAdapter.ReadyIds is untouched below.
|
||||
Assert.Empty(meshes.ReadyIds);
|
||||
Assert.True(state.IsRenderReady(landblockId));
|
||||
|
||||
// ...and the tier check is therefore the SOLE thing keeping it out of
|
||||
// the inner arm, which is what makes D2's split meaningful rather than
|
||||
// decorative.
|
||||
StreamingController controller = CreateController(state);
|
||||
Assert.False(
|
||||
controller.IsRenderNeighborhoodResident(landblockId, 0, 0));
|
||||
}
|
||||
|
||||
private static StreamingController CreateController(GpuWorldState state)
|
||||
=> new(
|
||||
(_, _) => { },
|
||||
|
|
@ -422,10 +616,16 @@ public sealed class StreamingControllerReadinessTests
|
|||
nearRadius: 1,
|
||||
farRadius: 2);
|
||||
|
||||
private static void AddPublished(GpuWorldState state, int x, int y)
|
||||
private static void AddPublished(
|
||||
GpuWorldState state,
|
||||
int x,
|
||||
int y,
|
||||
LandblockStreamTier tier = LandblockStreamTier.Near)
|
||||
{
|
||||
uint id = ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
|
||||
state.AddLandblock(new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
state.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
tier: tier);
|
||||
}
|
||||
|
||||
private sealed class ReadinessMeshAdapter : IWbMeshAdapter
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ public sealed class WorldRevealCoordinatorTests
|
|||
public int InvalidateCount { get; private set; }
|
||||
public int PrepareCount { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The live streaming window (#280). Mutable so a test can change the
|
||||
/// quality preset mid-hold, which retail answers by re-arming the
|
||||
/// blocking prefetch at the NEW radius
|
||||
/// (<c>SmartBox::set_mid_radius</c> @0x00453180).
|
||||
/// </summary>
|
||||
public StreamingRevealWindow Window { get; set; } =
|
||||
new(NearRadius: 1, FarRadius: 1);
|
||||
|
||||
public WorldRevealCoordinator Build(
|
||||
List<string>? logs = null,
|
||||
IWorldRevealStreamingScheduler? streaming = null,
|
||||
|
|
@ -30,7 +39,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
Transit = transit;
|
||||
return new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => RenderReady,
|
||||
revealWindow: () => Window,
|
||||
isRenderNeighborhoodReady: (_, _, _) => RenderReady,
|
||||
isSpawnCellReady: _ => CollisionReady,
|
||||
isTerrainNeighborhoodReady: (_, _) => CollisionReady,
|
||||
areCompositeTexturesReady: () => CompositesReady,
|
||||
|
|
@ -195,7 +205,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
audio);
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => true,
|
||||
revealWindow: () => new StreamingRevealWindow(1, 1),
|
||||
isRenderNeighborhoodReady: (_, _, _) => true,
|
||||
isSpawnCellReady: _ => true,
|
||||
isTerrainNeighborhoodReady: (_, _) => true,
|
||||
areCompositeTexturesReady: () => true,
|
||||
|
|
@ -241,7 +252,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
var scheduler = new RecordingDestinationScheduler();
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => true,
|
||||
revealWindow: () => new StreamingRevealWindow(1, 1),
|
||||
isRenderNeighborhoodReady: (_, _, _) => true,
|
||||
isSpawnCellReady: _ => true,
|
||||
isTerrainNeighborhoodReady: (_, _) => true,
|
||||
areCompositeTexturesReady: () => true,
|
||||
|
|
@ -301,7 +313,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
var scheduler = new RecordingDestinationScheduler();
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => true,
|
||||
revealWindow: () => new StreamingRevealWindow(1, 1),
|
||||
isRenderNeighborhoodReady: (_, _, _) => true,
|
||||
isSpawnCellReady: _ => true,
|
||||
isTerrainNeighborhoodReady: (_, _) => true,
|
||||
areCompositeTexturesReady: () => true,
|
||||
|
|
@ -360,7 +373,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
var scheduler = new RecordingDestinationScheduler();
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => false,
|
||||
revealWindow: () => new StreamingRevealWindow(1, 1),
|
||||
isRenderNeighborhoodReady: (_, _, _) => false,
|
||||
isSpawnCellReady: _ => false,
|
||||
isTerrainNeighborhoodReady: (_, _) => false,
|
||||
areCompositeTexturesReady: () => false,
|
||||
|
|
@ -591,7 +605,8 @@ public sealed class WorldRevealCoordinatorTests
|
|||
audio);
|
||||
var coordinator = new WorldRevealCoordinator(
|
||||
transit,
|
||||
isRenderNeighborhoodReady: (_, _) => true,
|
||||
revealWindow: () => new StreamingRevealWindow(1, 1),
|
||||
isRenderNeighborhoodReady: (_, _, _) => true,
|
||||
isSpawnCellReady: _ => true,
|
||||
isTerrainNeighborhoodReady: (_, _) => true,
|
||||
areCompositeTexturesReady: () => true,
|
||||
|
|
@ -731,6 +746,86 @@ public sealed class WorldRevealCoordinatorTests
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #280 (D4/§9-10): retail has ONE square — the prefetched set and the
|
||||
/// blocked-on set are literally the same array. So the destination
|
||||
/// reservation must open at exactly the radius the gate measures, asserted
|
||||
/// against the fake window's own input rather than a literal.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(3, 8)]
|
||||
[InlineData(4, 12)]
|
||||
[InlineData(5, 15)]
|
||||
public void DestinationReservation_OpensAtTheDerivedGateRadius(
|
||||
int nearRadius,
|
||||
int farRadius)
|
||||
{
|
||||
const uint outdoorCell = 0x11340021u;
|
||||
var streaming = new RecordingDestinationScheduler();
|
||||
var state = new State
|
||||
{
|
||||
Window = new StreamingRevealWindow(nearRadius, farRadius),
|
||||
};
|
||||
WorldRevealCoordinator coordinator = state.Build(streaming: streaming);
|
||||
|
||||
long generation = coordinator.BeginLogin(outdoorCell);
|
||||
|
||||
Assert.Equal(
|
||||
(generation, outdoorCell, state.Window.FarRadius),
|
||||
Assert.Single(streaming.Begins));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorDestinationReservation_OpensAtTheEnvCellArmsZeroRadius()
|
||||
{
|
||||
const uint indoorCell = 0x11340100u;
|
||||
var streaming = new RecordingDestinationScheduler();
|
||||
var state = new State { Window = new StreamingRevealWindow(4, 12) };
|
||||
WorldRevealCoordinator coordinator = state.Build(streaming: streaming);
|
||||
|
||||
long generation = coordinator.BeginLogin(indoorCell);
|
||||
|
||||
Assert.Equal(
|
||||
(generation, indoorCell, 0),
|
||||
Assert.Single(streaming.Begins));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P6, reservation half: the radii are runtime mutable through Settings.
|
||||
/// Retail's <c>SmartBox::set_mid_radius</c> @0x00453180 resets, re-radiuses
|
||||
/// and re-arms the blocking prefetch at the NEW value rather than
|
||||
/// finishing the old hold at the old one; acdream's equivalent closes and
|
||||
/// reopens the reservation on the SAME reveal generation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MidHoldRadiusChange_ReopensTheReservationOnTheSameGeneration()
|
||||
{
|
||||
const uint outdoorCell = 0x11340021u;
|
||||
var streaming = new RecordingDestinationScheduler();
|
||||
var state = new State { Window = new StreamingRevealWindow(3, 8) };
|
||||
WorldRevealCoordinator coordinator = state.Build(streaming: streaming);
|
||||
|
||||
long generation = coordinator.BeginLogin(outdoorCell);
|
||||
coordinator.Evaluate(outdoorCell);
|
||||
Assert.Equal(
|
||||
(generation, outdoorCell, 8),
|
||||
Assert.Single(streaming.Begins));
|
||||
Assert.Empty(streaming.Ends);
|
||||
|
||||
state.Window = new StreamingRevealWindow(5, 15);
|
||||
coordinator.Evaluate(outdoorCell);
|
||||
|
||||
Assert.Equal(
|
||||
[(generation, outdoorCell, 8), (generation, outdoorCell, 15)],
|
||||
streaming.Begins);
|
||||
Assert.Equal([generation], streaming.Ends);
|
||||
|
||||
// Idempotent: an unchanged window does not churn the reservation.
|
||||
coordinator.Evaluate(outdoorCell);
|
||||
Assert.Equal(2, streaming.Begins.Count);
|
||||
Assert.Single(streaming.Ends);
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderResourceScheduler
|
||||
: IWorldRevealRenderResourceScheduler
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,240 @@
|
|||
using AcDream.App.Streaming;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Streaming;
|
||||
|
||||
/// <summary>
|
||||
/// #280 end-to-end: the derived reveal window, the tiered render predicate,
|
||||
/// the terrain-residency gate, the destination reservation, and the loosened
|
||||
/// Runtime shape invariant, wired together with the REAL
|
||||
/// <see cref="StreamingController"/>, <see cref="GpuWorldState"/>, and
|
||||
/// <see cref="PhysicsEngine"/> rather than fakes.
|
||||
///
|
||||
/// <para>
|
||||
/// This is the test that fails if any single piece is missing: raising the
|
||||
/// radius without the tier-aware predicate hangs forever (T2), and loosening
|
||||
/// neither the Runtime invariant nor the predicate makes
|
||||
/// <c>AcknowledgeDestinationReadiness</c> fail
|
||||
/// <c>invalid-readiness-shape</c> (T1).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldRevealDerivedWindowIntegrationTests
|
||||
{
|
||||
private const int CenterX = 0x40;
|
||||
private const int CenterY = 0x40;
|
||||
private const uint DestinationCell = (uint)CenterX << 24
|
||||
| (uint)CenterY << 16
|
||||
| 0x0021u;
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, 2)]
|
||||
[InlineData(2, 4)]
|
||||
public void OutdoorReveal_HoldsUntilTheWholeDerivedWindowIsPublished(
|
||||
int nearRadius,
|
||||
int farRadius)
|
||||
{
|
||||
var world = new GpuWorldState();
|
||||
var physics = new PhysicsEngine();
|
||||
var transit = new RuntimeWorldTransitState();
|
||||
var streaming = new RecordingReservations();
|
||||
StreamingController controller = CreateController(
|
||||
world,
|
||||
nearRadius,
|
||||
farRadius);
|
||||
WorldRevealCoordinator coordinator = CreateCoordinator(
|
||||
transit,
|
||||
controller,
|
||||
physics,
|
||||
streaming);
|
||||
|
||||
long generation = coordinator.BeginLogin(DestinationCell);
|
||||
|
||||
// The reservation opens on the same square the gate measures.
|
||||
Assert.Equal(
|
||||
(generation, DestinationCell, farRadius),
|
||||
Assert.Single(streaming.Begins));
|
||||
|
||||
// Publish the Near window only. Pre-#280 this was the entire gate; it
|
||||
// must no longer be enough.
|
||||
for (int radius = 0; radius <= nearRadius; radius++)
|
||||
PublishRing(world, physics, radius, LandblockStreamTier.Near);
|
||||
Assert.False(coordinator.Evaluate(DestinationCell).IsReady);
|
||||
|
||||
// Fill the outer rings with Far-tier terrain publication, one ring at
|
||||
// a time, so the hold is proven to track the outer boundary and not
|
||||
// just the first missing member.
|
||||
for (int radius = nearRadius + 1; radius < farRadius; radius++)
|
||||
{
|
||||
PublishRing(world, physics, radius, LandblockStreamTier.Far);
|
||||
Assert.False(coordinator.Evaluate(DestinationCell).IsReady);
|
||||
}
|
||||
|
||||
PublishRing(world, physics, farRadius, LandblockStreamTier.Far);
|
||||
WorldRevealReadinessSnapshot ready =
|
||||
coordinator.Evaluate(DestinationCell);
|
||||
|
||||
Assert.True(ready.IsReady);
|
||||
Assert.Equal(farRadius, ready.RequiredRenderRadius);
|
||||
Assert.Equal(nearRadius, ready.RequiredNearRadius);
|
||||
// The loosened Runtime shape invariant accepted the derived radius.
|
||||
Assert.Equal(0, transit.Snapshot.InvariantFailureCount);
|
||||
Assert.True(transit.Snapshot.IsReady);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P7/§9-8: login and portal share the barrier, so first login gets the
|
||||
/// same widened gate. Asserted through the production readiness predicate
|
||||
/// the login auto-entry context calls.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void LoginReveal_UsesTheSameWidenedGateAsPortalArrival()
|
||||
{
|
||||
const int nearRadius = 1;
|
||||
const int farRadius = 3;
|
||||
var world = new GpuWorldState();
|
||||
var physics = new PhysicsEngine();
|
||||
var transit = new RuntimeWorldTransitState();
|
||||
StreamingController controller = CreateController(
|
||||
world,
|
||||
nearRadius,
|
||||
farRadius);
|
||||
WorldRevealCoordinator coordinator = CreateCoordinator(
|
||||
transit,
|
||||
controller,
|
||||
physics,
|
||||
streaming: null);
|
||||
|
||||
coordinator.BeginLogin(DestinationCell);
|
||||
for (int radius = 0; radius < farRadius; radius++)
|
||||
{
|
||||
PublishRing(
|
||||
world,
|
||||
physics,
|
||||
radius,
|
||||
radius <= nearRadius
|
||||
? LandblockStreamTier.Near
|
||||
: LandblockStreamTier.Far);
|
||||
}
|
||||
|
||||
Assert.False(coordinator.Evaluate(DestinationCell).IsReady);
|
||||
|
||||
PublishRing(world, physics, farRadius, LandblockStreamTier.Far);
|
||||
|
||||
Assert.True(coordinator.Evaluate(DestinationCell).IsReady);
|
||||
Assert.Equal(0, transit.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indoor destinations keep retail's EnvCell arm: no landscape ring at
|
||||
/// all, regardless of how wide the streaming window is.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IndoorReveal_IgnoresTheStreamingWindowEntirely()
|
||||
{
|
||||
const uint indoorCell = (uint)CenterX << 24
|
||||
| (uint)CenterY << 16
|
||||
| 0x0100u;
|
||||
var world = new GpuWorldState();
|
||||
var physics = new PhysicsEngine();
|
||||
var transit = new RuntimeWorldTransitState();
|
||||
StreamingController controller = CreateController(world, 2, 6);
|
||||
WorldRevealCoordinator coordinator = CreateCoordinator(
|
||||
transit,
|
||||
controller,
|
||||
physics,
|
||||
streaming: null);
|
||||
|
||||
coordinator.BeginLogin(indoorCell);
|
||||
Assert.False(coordinator.Evaluate(indoorCell).IsReady);
|
||||
|
||||
// Only the destination's own landblock, at Near tier.
|
||||
PublishRing(world, physics, 0, LandblockStreamTier.Near);
|
||||
WorldRevealReadinessSnapshot snapshot = coordinator.Evaluate(indoorCell);
|
||||
|
||||
Assert.True(snapshot.IsRenderNeighborhoodReady);
|
||||
Assert.Equal(0, snapshot.RequiredRenderRadius);
|
||||
Assert.Equal(0, transit.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
private sealed class RecordingReservations : IWorldRevealStreamingScheduler
|
||||
{
|
||||
public List<(long Generation, uint Cell, int Radius)> Begins { get; } = [];
|
||||
public List<long> Ends { get; } = [];
|
||||
|
||||
public void BeginDestinationReservation(
|
||||
long revealGeneration,
|
||||
uint destinationCell,
|
||||
int requiredRenderRadius) =>
|
||||
Begins.Add((revealGeneration, destinationCell, requiredRenderRadius));
|
||||
|
||||
public void EndDestinationReservation(long revealGeneration) =>
|
||||
Ends.Add(revealGeneration);
|
||||
}
|
||||
|
||||
private static WorldRevealCoordinator CreateCoordinator(
|
||||
RuntimeWorldTransitState transit,
|
||||
StreamingController controller,
|
||||
PhysicsEngine physics,
|
||||
IWorldRevealStreamingScheduler? streaming) =>
|
||||
new(
|
||||
transit,
|
||||
() => new StreamingRevealWindow(
|
||||
controller.NearRadius,
|
||||
controller.FarRadius),
|
||||
controller.IsRenderNeighborhoodResident,
|
||||
physics.IsSpawnCellReady,
|
||||
physics.IsNeighborhoodTerrainResident,
|
||||
() => true,
|
||||
(_, _) => { },
|
||||
() => { },
|
||||
_ => false,
|
||||
streaming: streaming);
|
||||
|
||||
private static StreamingController CreateController(
|
||||
GpuWorldState state,
|
||||
int nearRadius,
|
||||
int farRadius) =>
|
||||
new(
|
||||
(_, _, _) => { },
|
||||
(_, _) => { },
|
||||
_ => Array.Empty<LandblockStreamResult>(),
|
||||
(_, _) => { },
|
||||
state,
|
||||
nearRadius: nearRadius,
|
||||
farRadius: farRadius);
|
||||
|
||||
private static void PublishRing(
|
||||
GpuWorldState world,
|
||||
PhysicsEngine physics,
|
||||
int radius,
|
||||
LandblockStreamTier tier)
|
||||
{
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
{
|
||||
if (Math.Abs(dx) != radius && Math.Abs(dy) != radius)
|
||||
continue;
|
||||
|
||||
uint id = ((uint)(CenterX + dx) << 24)
|
||||
| ((uint)(CenterY + dy) << 16)
|
||||
| 0xFFFFu;
|
||||
world.AddLandblock(
|
||||
new LoadedLandblock(id, new LandBlock(), Array.Empty<WorldEntity>()),
|
||||
tier: tier);
|
||||
// The Far tier publishes terrain COLLISION as well as terrain
|
||||
// render (LandblockPhysicsPublisher, reached for
|
||||
// PublicationKind.Far), which is what makes the outer arm of the
|
||||
// reveal gate satisfiable on the collision side too.
|
||||
physics.AddLandblock(
|
||||
id,
|
||||
new TerrainSurface(new byte[81], new float[256]),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,13 +15,24 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
public int Preparations;
|
||||
public uint PreparedCell;
|
||||
public int PreparedRadius = -1;
|
||||
public int RenderRadius = -1;
|
||||
public int RenderNearRadius = -1;
|
||||
public int RenderFarRadius = -1;
|
||||
public int TerrainRadius = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The live streaming window the barrier derives from. Mutable so a
|
||||
/// test can prove the radii are re-read per evaluation rather than
|
||||
/// captured at construction (#280 T6 / P6).
|
||||
/// </summary>
|
||||
public StreamingRevealWindow Window { get; set; } =
|
||||
new(NearRadius: 1, FarRadius: 1);
|
||||
|
||||
public WorldRevealReadinessBarrier Build() => new(
|
||||
isRenderNeighborhoodReady: (cell, radius) =>
|
||||
revealWindow: () => Window,
|
||||
isRenderNeighborhoodReady: (cell, nearRadius, farRadius) =>
|
||||
{
|
||||
RenderRadius = radius;
|
||||
RenderNearRadius = nearRadius;
|
||||
RenderFarRadius = farRadius;
|
||||
return RenderReady;
|
||||
},
|
||||
isSpawnCellReady: _ => SpawnCellReady,
|
||||
|
|
@ -52,28 +63,103 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
Assert.Equal(1, state.Invalidations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorReveal_JoinsNearRenderTexturesAndTerrain()
|
||||
/// <summary>
|
||||
/// #280: the outdoor gate is DERIVED from the live streaming window, never
|
||||
/// a constant. Every assertion below references the fake window's own
|
||||
/// input — a test asserting a literal radius would reproduce the exact
|
||||
/// defect class this slice removes.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(3, 8)]
|
||||
[InlineData(5, 15)]
|
||||
[InlineData(4, 12)]
|
||||
public void OutdoorRequiredWindow_IsTheLiveStreamingWindow(
|
||||
int nearRadius,
|
||||
int farRadius)
|
||||
{
|
||||
const uint outdoorCell = 0x11340021u;
|
||||
var state = new State();
|
||||
var window = new StreamingRevealWindow(nearRadius, farRadius);
|
||||
var state = new State { Window = window };
|
||||
var barrier = state.Build();
|
||||
|
||||
Assert.False(barrier.IsReady(outdoorCell));
|
||||
Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.RenderRadius);
|
||||
|
||||
Assert.Equal(window.FarRadius, state.RenderFarRadius);
|
||||
Assert.Equal(window.NearRadius, state.RenderNearRadius);
|
||||
Assert.Equal(window.FarRadius, barrier.RequiredRenderRadius(outdoorCell));
|
||||
Assert.Equal(window, barrier.RequiredWindow(outdoorCell));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3, 8)]
|
||||
[InlineData(5, 15)]
|
||||
public void IndoorRequiredWindow_IsZeroRegardlessOfTheStreamingWindow(
|
||||
int nearRadius,
|
||||
int farRadius)
|
||||
{
|
||||
const uint indoorCell = 0x11340100u;
|
||||
var state = new State
|
||||
{
|
||||
Window = new StreamingRevealWindow(nearRadius, farRadius),
|
||||
};
|
||||
var barrier = state.Build();
|
||||
|
||||
Assert.Equal(
|
||||
new StreamingRevealWindow(0, 0),
|
||||
barrier.RequiredWindow(indoorCell));
|
||||
Assert.Equal(0, barrier.RequiredRenderRadius(indoorCell));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// P6: the radii are runtime mutable through Settings, and retail re-arms
|
||||
/// the blocking prefetch at the NEW radius mid-hold
|
||||
/// (<c>SmartBox::set_mid_radius</c> @0x00453180). A window captured at
|
||||
/// construction would only misbehave when someone opens Settings during a
|
||||
/// portal, so it must be proven live.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiredWindow_IsRereadOnEveryEvaluationWithoutReconstruction()
|
||||
{
|
||||
const uint outdoorCell = 0x11340021u;
|
||||
var state = new State { Window = new StreamingRevealWindow(3, 8) };
|
||||
var barrier = state.Build();
|
||||
|
||||
barrier.Evaluate(outdoorCell);
|
||||
Assert.Equal(8, state.RenderFarRadius);
|
||||
|
||||
state.Window = new StreamingRevealWindow(5, 15);
|
||||
WorldRevealReadinessSnapshot second = barrier.Evaluate(outdoorCell);
|
||||
|
||||
Assert.Equal(state.Window.FarRadius, state.RenderFarRadius);
|
||||
Assert.Equal(state.Window.NearRadius, state.RenderNearRadius);
|
||||
Assert.Equal(state.Window.FarRadius, second.RequiredRenderRadius);
|
||||
Assert.Equal(state.Window.NearRadius, second.RequiredNearRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutdoorReveal_JoinsRenderTexturesAndTerrainOverTheDerivedWindow()
|
||||
{
|
||||
const uint outdoorCell = 0x11340021u;
|
||||
var state = new State { Window = new StreamingRevealWindow(4, 12) };
|
||||
var barrier = state.Build();
|
||||
|
||||
Assert.False(barrier.IsReady(outdoorCell));
|
||||
Assert.Equal(state.Window.FarRadius, state.RenderFarRadius);
|
||||
|
||||
state.RenderReady = true;
|
||||
barrier.Prepare(outdoorCell);
|
||||
Assert.Equal(1, state.Preparations);
|
||||
Assert.Equal(outdoorCell, state.PreparedCell);
|
||||
Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.PreparedRadius);
|
||||
// D3: the composite domain is entity-scoped and Far-tier builds carry
|
||||
// no entities, so composites warm over the NEAR radius only.
|
||||
Assert.Equal(state.Window.NearRadius, state.PreparedRadius);
|
||||
|
||||
state.CompositeReady = true;
|
||||
Assert.False(barrier.IsReady(outdoorCell));
|
||||
|
||||
state.TerrainReady = true;
|
||||
Assert.True(barrier.IsReady(outdoorCell));
|
||||
Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, state.TerrainRadius);
|
||||
Assert.Equal(state.Window.FarRadius, state.TerrainRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -82,6 +168,7 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
const uint indoorCell = 0x11340100u;
|
||||
var state = new State
|
||||
{
|
||||
Window = new StreamingRevealWindow(4, 12),
|
||||
RenderReady = true,
|
||||
CompositeReady = true,
|
||||
TerrainReady = true,
|
||||
|
|
@ -95,7 +182,8 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
|
||||
state.SpawnCellReady = true;
|
||||
Assert.True(barrier.IsReady(indoorCell));
|
||||
Assert.Equal(0, state.RenderRadius);
|
||||
Assert.Equal(0, state.RenderNearRadius);
|
||||
Assert.Equal(0, state.RenderFarRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -119,7 +207,7 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
|
||||
Assert.True(barrier.IsReady(0x113401FFu));
|
||||
Assert.Equal(0, state.Preparations);
|
||||
Assert.Equal(-1, state.RenderRadius);
|
||||
Assert.Equal(-1, state.RenderFarRadius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -128,6 +216,7 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
const uint outdoorCell = 0x11340021u;
|
||||
var state = new State
|
||||
{
|
||||
Window = new StreamingRevealWindow(4, 12),
|
||||
RenderReady = true,
|
||||
CompositeReady = true,
|
||||
TerrainReady = true,
|
||||
|
|
@ -137,7 +226,8 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
|
||||
Assert.Equal(outdoorCell, snapshot.DestinationCell);
|
||||
Assert.False(snapshot.IsIndoor);
|
||||
Assert.Equal(WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius, snapshot.RequiredRenderRadius);
|
||||
Assert.Equal(state.Window.FarRadius, snapshot.RequiredRenderRadius);
|
||||
Assert.Equal(state.Window.NearRadius, snapshot.RequiredNearRadius);
|
||||
Assert.True(snapshot.IsRenderNeighborhoodReady);
|
||||
Assert.True(snapshot.AreCompositeTexturesReady);
|
||||
Assert.True(snapshot.IsCollisionReady);
|
||||
|
|
@ -163,4 +253,41 @@ public sealed class WorldRevealReadinessBarrierTests
|
|||
Assert.False(snapshot.IsReady);
|
||||
Assert.Equal(-1, state.TerrainRadius);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The near arm demands Near-tier publication and can therefore never
|
||||
/// exceed the outer arm. A misconfigured window must clamp rather than
|
||||
/// hand <c>IsRenderNeighborhoodResident</c> an argument it rejects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RequiredWindow_ClampsANearRadiusThatExceedsTheFarRadius()
|
||||
{
|
||||
var state = new State { Window = new StreamingRevealWindow(9, 4) };
|
||||
var barrier = state.Build();
|
||||
|
||||
Assert.Equal(
|
||||
new StreamingRevealWindow(4, 4),
|
||||
barrier.RequiredWindow(0x11340021u));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// D5: the probe reproduces the pre-fix gate on the same binary so the
|
||||
/// connected route's A/B pair is a real comparison. It is a measurement
|
||||
/// override, never a user-facing prefetch knob (§3, T11).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RevealRadiusOverride_ReplacesTheDerivedWindowAndClampsTheNearArm()
|
||||
{
|
||||
var window = new StreamingRevealWindow(4, 12);
|
||||
|
||||
Assert.Equal(
|
||||
window,
|
||||
StreamingDiagnostics.ApplyRevealRadiusOverride(window, null));
|
||||
Assert.Equal(
|
||||
new StreamingRevealWindow(1, 1),
|
||||
StreamingDiagnostics.ApplyRevealRadiusOverride(window, 1));
|
||||
Assert.Equal(
|
||||
new StreamingRevealWindow(4, 25),
|
||||
StreamingDiagnostics.ApplyRevealRadiusOverride(window, 25));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ public sealed class LiveEntityWorldOriginCoordinatorTests
|
|||
|
||||
private static WorldRevealCoordinator Reveal() => new(
|
||||
new RuntimeWorldTransitState(),
|
||||
(_, _) => true,
|
||||
() => new StreamingRevealWindow(1, 1),
|
||||
(_, _, _) => true,
|
||||
_ => true,
|
||||
(_, _) => true,
|
||||
() => true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #280 (D6/P8). <see cref="PhysicsEngine.IsNeighborhoodTerrainResident"/> is
|
||||
/// the collision half of the reveal gate and runs once per frame for the whole
|
||||
/// duration of a hold. Before #280 that hold measured a 3x3 neighbourhood; it
|
||||
/// now measures the streaming Far window (12 at the shipped High preset =
|
||||
/// 625 members). Rebuilding a landblock-prefix <c>HashSet</c> per call would
|
||||
/// allocate on every frame of every hold, against Slice I1's 0 B standard.
|
||||
/// </summary>
|
||||
public sealed class NeighborhoodTerrainResidencyTests
|
||||
{
|
||||
private const int FarRadius = 12;
|
||||
|
||||
[Fact]
|
||||
public void WarmedNeighborhoodQuery_AtTheFarRadius_AllocatesNothing()
|
||||
{
|
||||
PhysicsEngine engine = EngineWithWindow(0x80, 0x80, FarRadius);
|
||||
uint center = Canonical(0x80, 0x80);
|
||||
|
||||
// Warm the JIT, the scratch set's buckets/entries arrays, and the
|
||||
// dictionary enumerator before measuring.
|
||||
for (int i = 0; i < 64; i++)
|
||||
Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius));
|
||||
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (int i = 0; i < 1_000; i++)
|
||||
Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius));
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.True(
|
||||
allocated == 0,
|
||||
$"1,000 radius-{FarRadius} residency queries allocated "
|
||||
+ $"{allocated:N0} bytes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeighborhoodQuery_StillRejectsAMissingOuterRingMember()
|
||||
{
|
||||
PhysicsEngine engine = EngineWithWindow(0x80, 0x80, FarRadius);
|
||||
uint center = Canonical(0x80, 0x80);
|
||||
|
||||
Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius));
|
||||
|
||||
engine.RemoveLandblock(Canonical(0x80 + FarRadius, 0x80));
|
||||
|
||||
Assert.False(engine.IsNeighborhoodTerrainResident(center, FarRadius));
|
||||
Assert.True(engine.IsNeighborhoodTerrainResident(center, FarRadius - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gate has always compared on the high 16 bits, so a landblock
|
||||
/// registered under a cell-resolved id satisfies the same ring member as
|
||||
/// one registered under the canonical sentinel. D6 replaced the per-call
|
||||
/// set construction, not that masking rule.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void NeighborhoodQuery_KeepsPrefixMaskedMembership()
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
AddTerrain(engine, (0x40u << 24) | (0x40u << 16) | 0x0021u);
|
||||
|
||||
Assert.True(
|
||||
engine.IsNeighborhoodTerrainResident(Canonical(0x40, 0x40), 0));
|
||||
Assert.False(
|
||||
engine.IsNeighborhoodTerrainResident(Canonical(0x41, 0x40), 0));
|
||||
}
|
||||
|
||||
private static PhysicsEngine EngineWithWindow(int cx, int cy, int radius)
|
||||
{
|
||||
var engine = new PhysicsEngine();
|
||||
for (int dx = -radius; dx <= radius; dx++)
|
||||
for (int dy = -radius; dy <= radius; dy++)
|
||||
AddTerrain(engine, Canonical(cx + dx, cy + dy));
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static uint Canonical(int x, int y) =>
|
||||
((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
|
||||
|
||||
private static void AddTerrain(PhysicsEngine engine, uint landblockId)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
var heightTable = new float[256];
|
||||
engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +225,97 @@ public sealed class RuntimeWorldTransitStateTests
|
|||
Assert.Equal(1, state.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #280 (D7): the readiness invariant is a SHAPE check, not a value check.
|
||||
/// Runtime must not know the graphical host's streaming configuration, so
|
||||
/// it cannot re-derive the outdoor radius — that value is now the App's
|
||||
/// live Far radius and legitimately varies with the quality preset and
|
||||
/// with runtime Settings changes. Every radius the two non-graphical
|
||||
/// producers emit today (<c>indoor ? 0 : 1</c>) stays legal.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(5)]
|
||||
[InlineData(8)]
|
||||
[InlineData(12)]
|
||||
[InlineData(15)]
|
||||
[InlineData(25)]
|
||||
public void OutdoorReadinessShape_AcceptsAnyDerivedStreamingRadius(
|
||||
int requiredRenderRadius)
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
long generation = state.BeginLoginReveal(OutdoorCell);
|
||||
RuntimeDestinationReadiness acknowledgement =
|
||||
Ready(generation, OutdoorCell) with
|
||||
{
|
||||
RequiredRenderRadius = requiredRenderRadius,
|
||||
};
|
||||
|
||||
Assert.True(state.AcknowledgeDestinationReadiness(acknowledgement));
|
||||
|
||||
Assert.Equal(0, state.Snapshot.InvariantFailureCount);
|
||||
Assert.Equal(
|
||||
requiredRenderRadius,
|
||||
state.Snapshot.Readiness.RequiredRenderRadius);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shape is still a real invariant in both directions: an outdoor
|
||||
/// claim must require at least its own landblock's ring, and an indoor
|
||||
/// claim takes retail's EnvCell arm and must require none.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OutdoorReadinessShape_RejectsAZeroRadius()
|
||||
{
|
||||
var state = new RuntimeWorldTransitState();
|
||||
long generation = state.BeginLoginReveal(OutdoorCell);
|
||||
RuntimeDestinationReadiness invalid =
|
||||
Ready(generation, OutdoorCell) with { RequiredRenderRadius = 0 };
|
||||
|
||||
Assert.False(state.AcknowledgeDestinationReadiness(invalid));
|
||||
|
||||
Assert.False(state.Snapshot.IsReady);
|
||||
Assert.Equal(1, state.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorReadinessShape_RejectsANonZeroRadius()
|
||||
{
|
||||
const uint indoorCell = 0x11340100u;
|
||||
var state = new RuntimeWorldTransitState();
|
||||
long generation = state.BeginLoginReveal(indoorCell);
|
||||
RuntimeDestinationReadiness invalid =
|
||||
Ready(generation, indoorCell) with
|
||||
{
|
||||
IsIndoor = true,
|
||||
RequiredRenderRadius = 1,
|
||||
};
|
||||
|
||||
Assert.False(state.AcknowledgeDestinationReadiness(invalid));
|
||||
|
||||
Assert.False(state.Snapshot.IsReady);
|
||||
Assert.Equal(1, state.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IndoorReadinessShape_AcceptsTheZeroRadiusEnvCellArm()
|
||||
{
|
||||
const uint indoorCell = 0x11340100u;
|
||||
var state = new RuntimeWorldTransitState();
|
||||
long generation = state.BeginLoginReveal(indoorCell);
|
||||
RuntimeDestinationReadiness acknowledgement =
|
||||
Ready(generation, indoorCell) with
|
||||
{
|
||||
IsIndoor = true,
|
||||
RequiredRenderRadius = 0,
|
||||
};
|
||||
|
||||
Assert.True(state.AcknowledgeDestinationReadiness(acknowledgement));
|
||||
|
||||
Assert.Equal(0, state.Snapshot.InvariantFailureCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AcceptedReadiness_IsGenerationSticky()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue