diff --git a/AGENTS.md b/AGENTS.md index dd7f5549..5ac7376d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,8 +132,194 @@ user-accepted, including exact response flags, independent examination window, inscription transaction, complete creature/item/spell reports, favorite-spell press/right-click behavior, modern scarab/prismatic formula, DAT component icons, foreground stacking, and authored 310 x 400 extent. -Resume at Slice 4 equipped-child world picking, then vendor browse and -authoritative transactions. +Slice 4 equipped-child world picking passed its two-client Coldeve gate and +was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor +experience — browse, staged buying, selling, walk-to-use, the authored +panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE +(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed +two latent client-wide crashers (#348 cursor-handle exhaustion, #350 +render-ledger overflow).** **Campaign P — physics retail-feel parity +(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31 +— final user matrix accepted.** Every physics-scope gap from the +2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800 +sentinel — ACE's >=800 is a misread; never re-import), the #265/#166 +landing-momentum + bounce family +(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae +panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide +— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod +parse). See the plan doc for the retired-row ledger. **Campaign A — audio +retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is +CODE-COMPLETE 2026-08-08** with slices A1–A6 landed and listening-gate +rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and +the formal plan-status flip. **Campaign CH — chat & interface-text retail +parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED +USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail +colors, the SpewBox with retail's two-plane glyph outlines, working side +channels, the 152-verb command registry, the CH6 window shell (floating +windows, all-corner resize, opacity), and verbatim /help. Carried tail: +#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1–S3 +polish items. **Campaign OP — the retail four-tab Options panel +(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE +2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat / +Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are +acdream's ONE in-client settings surface (design D1): F11/toolbar open the +authored tab host; `RuntimeCharacterOptionsState` + the 53-id +`CharacterOptionTable` own option storage; retail's wire split ships exactly +(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1` +PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`); +headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED); +OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the +`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/ +combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix +batch (#372 both halves, #374, #375, #378–#382, #385) is USER-PASSED; the +OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif +row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT +tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4, +OS-localized fallback, register AD-96; the `InitiateBinding` capture- +instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`, +live-verified no-crash). **STILL OWED: the full §OP3–§OP6 script sections +and §OP8's visual re-check** — script +`docs/research/2026-08-11-campaign-op-test-script.md`, launch with +`ACDREAM_RETAIL_UI=1`. Tail: +#371, #373, AP-198/199/201/202/203. START at +`claude-memory/project_settings_options_digest.md`. + +**Campaign FA — the retail social panel (Fellowship & Allegiance) +(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is +CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social +panel (Friends / Allegiance / Fellowship / Squelch, host slot +`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only — +Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel +recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire +(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR +tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and +the truncated XP-share table), two session-scoped Runtime owners +(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at +generation reset — D2 corrected), and the authored panels through +`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners. +**The fellowship two-session flow is PROVEN over the live wire** — FA6's +automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/ +`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips +`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round → +narrow re-review; the reviews caught what tests can't (retail's 4th tree +rule, the D2 reset-lifetime inversion, the D6 server-side invite filter, +a seam-map entry that would have re-introduced a fixed bug). OWED: the +user's connected gates (§FA3-§FA6 of +`docs/research/2026-08-12-campaign-fa-test-script.md`, several +`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is +deferred/disabled because ACE returns NOTHING to the `0x001D` swear at +0.005 m (no confirmation, no tree update, no error; needs ACE-console +disambiguation — the swear CODE is done+reviewed, only its automated +two-session proof is unverified; register AD-87). Tail: #383 (installed- +DAT vs committed-fixture drift, found at FA3). START at +`claude-memory/project_fellowship_allegiance_campaign.md`. + +**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all +USER-PASSED.** The social panel's connected gate rounds closed (border-only +move cursor, amber row selection, wrapped empty-state text, composed +confirmation sentences via the new `DatStringResolver.ResolveTemplate` +StringTable-interleave port, the refused-drop SpewBox notice via the +`InventoryTransactionState.RequestFailed` seam, live friends +Online/Offline through the authored row state machine + the new UiText +per-state string swap). Same block: powerbar mode captions +(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by +combat mode), release-edge airborne jump refusal (supersedes CH round-1's +press-edge report), and **SECURE TRADE SHIPPED + two-client user gate +PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`), +full `0x1F6`–`0x208` wire, `RuntimeTradeState` as the third sibling +J-owner, both retail open paths, staged-item trading marker +(`ClientObject.TradeState` now live), cancel text. START at +`claude-memory/project_secure_trade.md`; the deferred-Func lesson is +`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register: +AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired. +Filed: #393 (texture-detail options, post-M4). + +**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia +launcher/installer/updater (Windows+Linux) + the retail character- +management screen, driven autonomously under a user-set goal: Fable +plans, Sonnet implements, Opus dual-lens reviews (architectural + +retail-faithful). Spec: +`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan + +ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at +`claude-memory/project_launcher_direction.md`. Key recon corrections +already binding: retail's select screen (`gmCharacterManagementUI`) has +NO 3D preview (chargen-only machinery); UI Studio no longer exists +(deleted at Campaign V — ignore stale memory/docs claims otherwise); +App `Program.cs` has no subcommand dispatch (the `--session-config` flag +is additive). +LA0 through LA11's automated scope are review-closed. The launcher composer is now +compiled into both host test suites, and Launcher.Core runs in the portable +Windows/Ubuntu CI closure. The self-contained Avalonia launcher, +transactional two-host plugin lifetime, shared login-command route, +Runtime-owned retail selection state, authored DAT character screen, and +crash-safe verified installer plus atomic cross-platform updater/self-updater +are integrated. Windows group-isolated Headless stop, isolated update fixtures, +strict status/redaction evidence, and the exact Windows/Ubuntu operator script +are landed; the integrated preflight passes 32/32 commands and 14,012 tests / +5 skips. Only the connected/visual/real-DAT user gate remains before shipment. + +**Campaign CC — retail character creation (CLOSED USER-ACCEPTED +2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as +one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches +A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and +PASSED. **Milestone: the first live character ever created by acdream +against ACE landed mid-round.** The gate round's own harvest hardened +shared surfaces well beyond chargen: authored text margins (P0x23-26), +the authored Unselected/Selected state pair + per-state label color, +un-consumed Type-12 media children (frames/scrollbars client-wide), +single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the +dialog-always-on-top re-raise (the invisible-modal input blackhole), a +truthful client crash self-report + bounded stderr capture (#405-#407 +fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create +button (retail's exact `UpdateButtons` roster` — live capture of every player-side - `PhysicsEngine.ResolveWithTransition` call. Each call appends one - JSON Lines record with full inputs, PhysicsBody snapshot before AND - after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag - — NPC / remote DR calls don't pollute. Pairs with the trajectory - replay harness comparison tests to diff captured vs harness state - per field — the first divergence pinpoints missing apparatus state. - Capture is OFF when the env var is unset (one null-check cost per - call). -- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump - resolved cell/GfxObj polygon tables as JSON when ids cache. Used - for harness fixture extraction. +Every environment variable and command-line argument the client reads — +what it does, its exact value shape, and **what else it changes about the +run** — is documented in +[`docs/launch-options.md`](docs/launch-options.md). That file is the single +source of truth for every probe we have and how to turn one on, and it is +enforced by `LaunchOptionsDocumentationTests`: a flag without a documented +row fails the build, and so does a documented row whose read site was +deleted. **Any future probe that stays in the code gets its row there in +the same commit — no exceptions.** + +The binding rules: + +- **Every probe and dump is OFF by default.** Nothing that prints, records, + or costs performance may activate without its env var explicitly set + (`=1`). The only default-on flags are retail *behaviors* wearing an + A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, + `ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0` + disables); that set is frozen by `LaunchOptionsDocumentationTests` — + never add a default-on diagnostic. +- **Read the side-effects column before any measurement.** Flags that look + inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame + diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a + streaming window production never uses. +- **A temporary probe dies with its investigation.** Add the row when you + add the probe; delete both in the commit that fixes the issue. ### Outbound motion wire format (acdream → ACE) @@ -1478,8 +1645,8 @@ already-running ACE session via the handshake race. ## Reference repos: cross-check the relevant ones -The `references/` tree holds **six** vendored projects (ACE, ACViewer, -WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in +The `references/` tree holds **five** vendored projects (ACE, ACViewer, +WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in some areas and disagree in others. Before committing to an approach, **cross-reference at least two of them** for the domain you're working in — the per-domain hierarchy in the next section tells you which to @@ -1488,7 +1655,7 @@ the relevant references is almost always the truth. The user has repeatedly had to remind me about this when I narrowly searched one ref and missed obvious answers in another. -The six references: +The five references: - **`references/ACE/`** — ACEmulator server. Authority on the wire protocol (packet framing, ISAAC, game message opcodes, serialization @@ -1538,15 +1705,15 @@ The six references: the message-builder layer. ACE shows what the server expects; holtburger shows what a real client actually sends. -- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference, - fixed-function OpenGL, but has the **real AC terrain split formula** - (`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, - `0x519B8F25`) which differs from WorldBuilder's physics-path formula. - Also has the complete `0xF61C` movement packet format with flag bits - and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does - NOT do client-side terrain Z — it sends movement keys to the server - and uses the server's authoritative Z. See - `docs/research/2026-04-12-movement-deep-dive.md` for the full analysis. +**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo +and the sixth entry in this list; it is no longer vendored under +`references/` and must not be re-cloned. Everything we took from it is +already written down and still stands: the terrain split formula +`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, +`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a +client need not compute terrain Z itself. The historical analysis lives in +`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed +is in `docs/research/retail-ui/`. Cite those, not the repo. ### Reference hierarchy by domain @@ -1571,9 +1738,9 @@ decompiled client code and would have fixed it in minutes. | **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. | | **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. | | **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. | -| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | -| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | -| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | +| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | +| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | +| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. | | **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. | | **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. | | **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. | diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fb3beb76..24cec938 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1443,7 +1443,9 @@ died there. Distinct from the LU5 UX work. ## #419 — Portal-tunnel rim polygon visible (FOV-coupled) + ring flash at exit (camera dolly vs retail's view-plane animation) -**Status:** OPEN (filed 2026-08-17, user screenshot + FOV experiment). +**Status:** ✅ FIXED / OWNER-ACCEPTED 2026-08-25 — the centered disk, 16:9 +faceted outer rim, and destination lower-viewport hole are gone in the live +owner-pak gate. **Symptom:** the tunnel's low-poly mouth shows as a faceted polygon silhouette against black, scaling with the Config FOV slider (barely visible at minimum FOV); a brief ring flash remains at exit even at @@ -1541,6 +1543,69 @@ interior there ⇒ our pipeline, rings there ⇒ shared dat interpretation); (4) a retail side-by-side screenshot for ground truth (brightness included). Fix only against that evidence. +**2026-08-25 apparatus closeout:** step (1) is implemented as +`ACDREAM_PROBE_TUNNEL_FREEZE=1` (authored frame 72) or `=N` (frame 2–120). +The diagnostic withholds the TAS_TUNNEL transition even after world-ready, +then stops the tunnel sequence and roll at the requested frame and emits one +`[tunnel-freeze] ... state=held` marker. It intentionally prevents placement, +reveal, and login completion until cancellation/process exit; it is a capture +tool, not a shipping behavior or a performance mode. Step (2)'s frozen +RenderDoc capture (`portal-frozen-frame1178.rdc`) confirmed the authored finite +mesh and production portal pass were actually drawing; it did not support a +cull/material/asset substitution. Step (3) was attempted, but ACViewer was not +a trustworthy renderer oracle for this path and was discarded rather than used +as fix evidence. Step (4)'s direct retail capture showed the radial tunnel +field swapping straight to the destination world, with no small centered disk. + +**Root cause and final fix (2026-08-25):** the exit defect was a two-part +presentation-boundary error, not tunnel content. First, our outgoing viewport +remained eligible through retail animation-table level 1022 even though paired +initial 40 fps captures appeared to establish level 1013 as the last sample +whose radial field covered the measured center rings; level 1016 and later +exposed a small finite-mesh disk. `TeleportAnimSequencer` initially retired the +outgoing viewport after quantized level 1013. Second, +`LocalPlayerTeleportPresentation.Tick` +published the `WorldFadeIn` terminal projection while the tunnel could still be +visible, and both teleport/login event handlers called the synchronous +destination-release suffix before `ExitTunnel`. A slow suffix could therefore +hold the invalid tunnel/projection combination on screen. The presentation now +hides portal space before publishing any snapshot whose `ShowTunnel` is false, +and both handlers enforce retail's order: hide portal, reveal/release world, +then play `Sound_UI_ExitPortal`. The later `ExitTunnel` call remains idempotent. + +**Acceptance evidence:** the 40 fps frame-sequence verifier fails the pre-fix +owner-pak capture on the exact disk (`radial coverage=0.053`). Three consecutive +fixed launches, using `artifacts/owner-gate/acdream-v5.pak` with the installed +DATs and the same ACE login route, swap directly to world and pass at minimum +tail coverage `0.800`, `0.747`, and `0.736`. Focused regressions cover the 1001 +table boundary, tunnel-retire-before-view-plane publication, and both +teleport/login host-call orders. No portal mesh, shader, sampler, lighting, or +camera-position change is part of the fix. + +**16:9 perimeter correction (2026-08-25):** the center-ring verifier missed +the same mesh boundary crossing the sides of a full-width viewport. The owner +capture and the automated 1280x720 sequence agree: level 1001 is the last +fully covered tunnel sample, level 1006 begins exposing the outer perimeter, +and the old level-1013 handoff can hold the complete faceted rim while the +world viewport is installed. The sequencer cutoff is therefore 1001, still in +the retail quantized table domain, and the gate now measures an unobstructed +left-edge rail in addition to center rings. This changes only which authored +tunnel sample is held for the atomic swap; it does not enlarge or repaint the +mesh. + +**Residual found by the owner:** the direct swap still leaves a large blue +lower-viewport region during the first `WorldFadeIn` samples. This is not a +missing tunnel texture and the title's `lb 0/0` is not a terrain-residency +count. The destination is resident; its finite terrain is projected with +`M22=0.001` and `znear=0.1`, so lower-screen ground rays meet the terrain before +the Vulkan near plane and only a thin horizon strip survives. The legacy retail +landscape visibly supplies coverage at this singular endpoint. The modern +renderer adaptation scales the near plane with view-plane distance only while +the world viewport owns `WorldFadeOut`/`WorldFadeIn`; tunnel and ordinary-world +near planes remain unchanged. The lower-viewport coverage gate passes the +owner-pak live capture, and the owner accepted the final in-game transition on +2026-08-25 as "Perfect!". + ## #418 — Login world load takes ~27 s: publication advances at a flat 32 blocks/s **Status:** IN-PROGRESS 2026-08-17 — producer half landed (this commit's @@ -1706,6 +1771,19 @@ attributed to the cold render-thread barrier); portal-hold gate-ready render-thread upload/registration phase (t≈1–8 s, concurrent) — budgets are exonerated three times over.** +**2026-08-25 attribution checkpoint:** `ACDREAM_PROBE_REVEAL_TIMING=1` now +pairs each reveal timing run with low-frequency `[reveal-resource]` snapshots +at begin, readiness edges, one-second progress intervals, and summary. The +snapshots borrow the canonical render owners and report mesh/atlas residency, +global and per-frame upload counts/bytes, buffer/texture/copy work, staging +high-water, mesh-arena capacity/migration, prepared-package probe/read results, +composite backlog, CPU mesh cache, and managed/committed/tracked GPU memory. +Use it with `ACDREAM_FRAME_PROF=1` and +`ACDREAM_FRAME_HISTORY=`; the next cold login comparison can now +distinguish decode/cache fill, upload/staging, +registration/composite debt, arena growth, and process-memory growth without +adding a per-frame diagnostic tax. + ## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff **Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same diff --git a/docs/launch-options.md b/docs/launch-options.md index 5c1d0e3c..109ebc76 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -258,7 +258,8 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | | `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | | `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 | -| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized) with elapsed ms, 1 Hz progress, and one `SUMMARY` line at viewport reveal | print-only; doc comment: "never constructed unless the probe env is set, changes no behavior, and costs one branch per `Evaluate` poll otherwise" (i.e. genuinely near-zero cost when off — confirmed, `RevealTimingProbe` object itself is null when disabled) | unset (off) | `StreamingDiagnostics.ProbeRevealTiming` (`StreamingDiagnostics.cs:65-66`), consumed by `LandblockPresentationPipeline.cs:69`, `PublicationTimingProbe.cs:34`, `RevealTimingProbe.cs` (construction gated) | +| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized), 1 Hz progress, and one `SUMMARY` line at viewport reveal; paired low-frequency `[reveal-resource]` snapshots report mesh staging/uploads/arena state, prepared-asset activity, composite warmup/uploads, managed memory, and tracked GPU residency | print-only; the probe object and render-resource sampler are not constructed when unset. When enabled, canonical resource owners are sampled only at begin, readiness edges, 1 Hz progress, and summary—not every frame. Use with `ACDREAM_FRAME_PROF=1` / `ACDREAM_FRAME_HISTORY` for per-frame CPU/GPU/alloc timing. | unset (off) | `StreamingDiagnostics.ProbeRevealTiming`, `RevealTimingProbe`, `RuntimeRenderFrameResourceDiagnosticsSource`, `PublicationTimingProbe` | +| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` | | `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | | `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | | `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 14fbe652..5f934e36 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -415,6 +415,23 @@ internal sealed class SessionPlayerCompositionPhase ? revealMeshes.SetDestinationRevealUploadPriority : static _ => { }, foundation.TextureCache.SetDestinationRevealUploadPriority); + IRenderFrameResourceDiagnosticsSource? revealResourceDiagnostics = + StreamingDiagnostics.ProbeRevealTiming + ? new RuntimeRenderFrameResourceDiagnosticsSource( + particles: null, + particleBindings: null, + worldDispatcher: revealDispatcher, + environmentCells: null, + particleRenderer: null, + uiTextRenderer: null, + portalDepthMask: null, + clipFrame: null, + terrain: null, + lighting: null, + meshes: foundation.MeshAdapter, + textures: foundation.TextureCache, + preparedAssets: content.PreparedAssets) + : null; var worldReveal = new WorldRevealCoordinator( live.WorldTransit, // #280: read the radii LIVE from the streaming controller rather @@ -450,7 +467,8 @@ internal sealed class SessionPlayerCompositionPhase worldQuiescence, streaming, revealRenderResources, - () => live.WorldState.LoadedLandblockCount); + () => live.WorldState.LoadedLandblockCount, + revealResourceDiagnostics); Fault(SessionPlayerCompositionPoint.WorldRevealCreated); return CompleteSessionPlayer( diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index ba4614cf..c1f0fea3 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; using AcDream.App.UI; using AcDream.Content.Vfx; using AcDream.Core.Lighting; @@ -96,6 +97,7 @@ public sealed class PortalTunnelPresentation : IDisposable private float _rotationEndAngle; private float _rotationCurrentAngle; private bool _waitCueVisible; + private bool _probeFreezeReached; private bool _disposeRequested; private bool _disposing; private bool _disposed; @@ -256,6 +258,7 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; _waitCueVisible = false; + _probeFreezeReached = false; _visible = true; RebuildPose(); } @@ -267,6 +270,7 @@ public sealed class PortalTunnelPresentation : IDisposable return; _visible = false; _waitCueVisible = false; + _probeFreezeReached = false; _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -275,11 +279,25 @@ public sealed class PortalTunnelPresentation : IDisposable { if (!_visible || dt < 0f) return; + if (_probeFreezeReached) + return; _sequence.Update(dt, frame: null); RebuildPose(); _animationHooks.Drain(Vector3.Zero); TickRotation(dt); + + if (StreamingDiagnostics.TunnelFreezeFrame is not { } freezeFrame + || CurrentAnimationFrame < freezeFrame) + { + return; + } + + _probeFreezeReached = true; + Console.WriteLine( + $"[tunnel-freeze] frame={CurrentAnimationFrame} " + + $"target={freezeFrame} setup=0x{_setupDid:X8} " + + $"animation=0x{_animationDid:X8} state=held"); } /// diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs index 7af1b277..b9a331fe 100644 --- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -128,9 +128,10 @@ internal readonly record struct ProcessResourceDiagnostics( int TrackedGpuTextures); /// -/// Immutable resource facts captured only when explicit UI-probe dumping is enabled. -/// Grouping keeps the diagnostics controller independent from every canonical renderer, -/// VFX, mesh, texture, and process owner used to produce the values. +/// Immutable resource facts captured only by an explicit low-frequency +/// diagnostic such as UI-probe dumping or reveal timing. Grouping keeps the +/// consumers independent from every canonical renderer, VFX, mesh, texture, +/// and process owner used to produce the values. /// internal readonly record struct RenderFrameResourceDiagnosticsSnapshot( VfxStreamResourceDiagnostics Vfx, diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs index 01c50711..f3958091 100644 --- a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs +++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs @@ -17,6 +17,7 @@ public sealed class TeleportViewPlaneController public const float TransitionViewPlaneDistance = 0.001f; private float _gameViewPlaneDistance = 1f; + private TeleportAnimState _state = TeleportAnimState.Off; private readonly ProjectionOverrideCamera _projectionCamera = new(); public bool Enabled { get; private set; } @@ -36,6 +37,7 @@ public sealed class TeleportViewPlaneController _gameViewPlaneDistance = distance; CurrentViewPlaneDistance = distance; + _state = TeleportAnimState.Off; Enabled = false; } @@ -48,6 +50,7 @@ public sealed class TeleportViewPlaneController /// public void Update(TeleportAnimSnapshot snapshot) { + _state = snapshot.State; switch (snapshot.State) { case TeleportAnimState.WorldFadeOut: @@ -80,6 +83,7 @@ public sealed class TeleportViewPlaneController public void Reset() { + _state = TeleportAnimState.Off; Enabled = false; CurrentViewPlaneDistance = _gameViewPlaneDistance; } @@ -110,13 +114,35 @@ public sealed class TeleportViewPlaneController float distance = MathF.Max(CurrentViewPlaneDistance, TransitionViewPlaneDistance); float fov = 2f * MathF.Atan(1f / distance); - float near = MathF.Max(0.1f, distance * 0.25f); + float near = WorldTransitionNearPlane(distance); if (near >= far) near = MathF.Min(0.1f, far * 0.5f); return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far); } + /// + /// Retail's set_vdst keeps znear at 0.1 m below a view-plane + /// distance of 0.4. The legacy landscape path still supplied a complete + /// projected screen at the singular teleport endpoint. Vulkan clips the + /// finite resident terrain geometrically: at distance 0.001, every lower- + /// viewport ground ray hits the terrain before 0.1 m and the authored sky + /// is exposed underneath it. Scale the near plane with the transition only + /// while the world viewport owns the frame. This preserves the exact retail + /// X/Y warp, the tunnel projection, and the ordinary game projection while + /// giving the modern terrain path the coverage retail visibly produced. + /// + private float WorldTransitionNearPlane(float distance) + { + if (_state is TeleportAnimState.WorldFadeOut or TeleportAnimState.WorldFadeIn + && distance < 0.4f) + { + return MathF.Max(0.0001f, distance * 0.25f); + } + + return MathF.Max(0.1f, distance * 0.25f); + } + /// /// Decorate the active camera with the same projection returned by /// . Retail's Render::set_vdst is diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 6fc79add..480e3f7a 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -555,7 +555,20 @@ internal sealed class LocalPlayerTeleportPresentation var (snapshot, events) = _animation.Tick( deltaSeconds, worldReady, - CurrentTunnelFrame); + CurrentTunnelFrame, + holdInTunnel: StreamingDiagnostics.TunnelFreezeFrame.HasValue); + + // Retail hides portal space before publishing the WorldFadeIn view + // plane (gmSmartBoxUI::UseTime @ 0x004D73D3). Keep that viewport + // swap inside the presentation update: the controller's exit event + // performs host work, and allowing the terminal projection to escape + // while the tunnel remains visible exposes the finite portal mesh as + // a one-frame faceted disk. Hiding first is also safe if a render + // boundary lands between these two writes: the destination world is + // already materialized and receives the old outgoing projection. + if (!snapshot.ShowTunnel && _tunnel.IsVisible) + _tunnel.Exit(); + _viewPlane.Update(snapshot); return (snapshot, events); } @@ -1228,16 +1241,19 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination - // cell blocking at the exact portal/world viewport swap. - // LoginComplete remains one WorldFadeIn second later. + // Retail first hides portal space, then shows the world, + // and only afterwards plays Sound_UI_ExitPortal + // (gmSmartBoxUI::UseTime @ 0x004D73D3..0x004D7405). + // ExitTunnel is idempotent: the production presentation + // normally retired it before publishing this snapshot, + // while this call enforces the same order for every host. + _presentation.ExitTunnel(); + if (!IsCurrentLifetime(generation, sequence)) + return; _worldReveal.RevealWorldViewport(); if (!IsCurrentLifetime(generation, sequence)) return; _presentation.PlayExitCue(); - if (!IsCurrentLifetime(generation, sequence)) - return; - _presentation.ExitTunnel(); if (!IsCurrentLifetime(generation, sequence)) return; break; @@ -1707,17 +1723,16 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // Release destination cell blocking at the exact - // portal/world viewport swap — same edge as the teleport - // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with - // Sound_UI_ExitPortal @ 0x004D7405. + // Identical retail viewport order to the teleport pump: + // hide portal, show/release the destination world, then + // play Sound_UI_ExitPortal @ 0x004D7405. + _presentation.ExitTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; _worldReveal.RevealWorldViewport(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; _presentation.PlayExitCue(); - if (!IsCurrentLoginLifetime(generation, revealGeneration)) - return; - _presentation.ExitTunnel(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; break; diff --git a/src/AcDream.App/Streaming/RevealTimingProbe.cs b/src/AcDream.App/Streaming/RevealTimingProbe.cs index 3dd942bb..97f1f051 100644 --- a/src/AcDream.App/Streaming/RevealTimingProbe.cs +++ b/src/AcDream.App/Streaming/RevealTimingProbe.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AcDream.App.Rendering; using AcDream.Runtime; namespace AcDream.App.Streaming; @@ -20,7 +21,9 @@ namespace AcDream.App.Streaming; /// shape. /// 1 Hz progress — elapsed, the three flags, and the resident /// landblock count, so a budget-paced linear drip is visually obvious in the -/// log. +/// log. A paired [reveal-resource] snapshot attributes the cold render +/// barrier to mesh preparation/upload, arena growth, texture composites, or +/// process/GPU residency without sampling those owners every frame. /// SUMMARY once at the viewport reveal — the per-edge /// timeline on one line. /// @@ -31,6 +34,7 @@ namespace AcDream.App.Streaming; internal sealed class RevealTimingProbe { private readonly Func? _loadedLandblockCount; + private readonly IRenderFrameResourceDiagnosticsSource? _renderResources; private readonly Stopwatch _clock = new(); private long _generation; private string _kind = ""; @@ -49,8 +53,13 @@ internal sealed class RevealTimingProbe private long _lastProgressMs; private int _framesSinceProgress; - public RevealTimingProbe(Func? loadedLandblockCount) => + public RevealTimingProbe( + Func? loadedLandblockCount, + IRenderFrameResourceDiagnosticsSource? renderResources = null) + { _loadedLandblockCount = loadedLandblockCount; + _renderResources = renderResources; + } public void Begin( string kind, @@ -80,6 +89,7 @@ internal sealed class RevealTimingProbe + $"cell=0x{destinationCell:X8} window={window.NearRadius}/" + $"{window.FarRadius} landblocks={_windowLandblocks} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"); + EmitResourceSnapshot("begin", elapsedMilliseconds: 0); } public void Observe( @@ -91,37 +101,56 @@ internal sealed class RevealTimingProbe _framesSinceProgress++; long elapsed = _clock.ElapsedMilliseconds; + string? resourceCheckpoint = null; if (!_render && readiness.IsRenderNeighborhoodReady) { _render = true; _renderMs = elapsed; Edge("render-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "render-ready"); } if (!_composites && readiness.AreCompositeTexturesReady) { _composites = true; _compositesMs = elapsed; Edge("composites-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "composites-ready"); } if (!_collision && readiness.IsCollisionReady) { _collision = true; _collisionMs = elapsed; Edge("collision-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "collision-ready"); } if (!_gateReady && readiness.IsReady) { _gateReady = true; _gateReadyMs = elapsed; Edge("gate-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "gate-ready"); } if (!_materialized && portal.Materialized) { _materialized = true; _materializedMs = elapsed; Edge("materialized", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "materialized"); } + if (resourceCheckpoint is not null) + EmitResourceSnapshot(resourceCheckpoint, elapsed); + if (!_summarized && portal.WorldViewportObserved) { _summarized = true; @@ -132,6 +161,7 @@ internal sealed class RevealTimingProbe + $"gateReadyMs={_gateReadyMs} " + $"materializedMs={_materializedMs} " + $"landblocks={_windowLandblocks}"); + EmitResourceSnapshot("summary", elapsed); return; } @@ -151,6 +181,7 @@ internal sealed class RevealTimingProbe + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks} " + $"frames={_framesSinceProgress}"); + EmitResourceSnapshot("progress", elapsed); PublicationTimingProbe.EmitStreamingTickWindow(); _framesSinceProgress = 0; } @@ -162,4 +193,77 @@ internal sealed class RevealTimingProbe + $"elapsedMs={elapsed} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks}"); + + private void EmitResourceSnapshot(string checkpoint, long elapsedMilliseconds) + { + if (_renderResources is null) + return; + + RenderFrameResourceDiagnosticsSnapshot snapshot = + _renderResources.Capture(); + Console.WriteLine(FormatResourceLine( + checkpoint, + _kind, + _generation, + elapsedMilliseconds, + snapshot)); + } + + private static string AppendCheckpoint(string? current, string next) => + current is null ? next : current + "+" + next; + + internal static string FormatResourceLine( + string checkpoint, + string kind, + long generation, + long elapsedMilliseconds, + in RenderFrameResourceDiagnosticsSnapshot snapshot) + { + MeshStreamResourceDiagnostics mesh = snapshot.Mesh; + TextureStreamResourceDiagnostics textures = snapshot.Textures; + ProcessResourceDiagnostics process = snapshot.Process; + return $"[reveal-resource] checkpoint={checkpoint} kind={kind} " + + $"gen={generation} elapsedMs={elapsedMilliseconds} " + + $"meshData={mesh.RenderData} meshAtlases={mesh.AtlasArrays} " + + $"meshUnusedLru={mesh.UnusedLru} meshBytes={mesh.EstimatedBytes} " + + $"globalUploads={mesh.GlobalUploadCount} " + + $"globalUploadBytes={mesh.GlobalUploadedBytes} " + + $"frameUploads={mesh.FrameUploadCount} " + + $"frameUploadBytes={mesh.FrameUploadBytes} " + + $"frameArrayBytes={mesh.FrameArrayAllocationBytes} " + + $"frameMipmapBytes={mesh.FrameMipmapBytes} " + + $"frameBufferUploadBytes={mesh.FrameBufferUploadBytes} " + + $"frameBufferAllocationBytes={mesh.FrameBufferAllocationBytes} " + + $"frameBufferCopyBytes={mesh.FrameBufferCopyBytes} " + + $"frameNewArrays={mesh.FrameNewArrayCount} " + + $"frameNewBuffers={mesh.FrameNewBufferCount} " + + $"frameStaleDiscards={mesh.FrameStaleDiscardCount} " + + $"frameMipmapArrays={mesh.FrameMipmapArrayCount} " + + $"staged={mesh.StagedUploadBacklog} " + + $"stagedBytes={mesh.StagedUploadBytes} " + + $"stagingHighWater={(mesh.StagingAtHighWater ? 1 : 0)} " + + $"cpuMeshCache={mesh.CpuMeshCacheCount} " + + $"cpuMeshCacheBytes={mesh.CpuMeshCacheBytes} " + + $"arenaCapacityBytes={mesh.GlobalCapacityBytes} " + + $"arenaPhysicalBytes={mesh.GlobalPhysicalCapacityBytes} " + + $"arenaMigrating={(mesh.GlobalMigrationInProgress ? 1 : 0)} " + + $"prepared={mesh.PreparedProbes}/{mesh.PreparedReads}/" + + $"{mesh.PreparedLoaded}/{mesh.PreparedMissing}/" + + $"{mesh.PreparedCorrupt} " + + $"ownedTextures={textures.OwnedBindlessTextures} " + + $"textureOwners={textures.TextureOwners} " + + $"composites={textures.CachedCompositeTextures} " + + $"unownedComposites={textures.CachedUnownedComposites} " + + $"unownedCompositeBytes={textures.CachedUnownedCompositeBytes} " + + $"compositeAtlases={textures.CompositeAtlases} " + + $"compositeAtlasBytes={textures.CompositeAtlasBytes} " + + $"compositePending={textures.CompositeWarmupPending} " + + $"frameCompositeUploads={textures.FrameCompositeUploadCount} " + + $"frameCompositeUploadBytes={textures.FrameCompositeUploadBytes} " + + $"managedBytes={process.ManagedBytes} " + + $"managedCommittedBytes={process.ManagedCommittedBytes} " + + $"trackedGpuBytes={process.TrackedGpuBytes} " + + $"trackedGpuBuffers={process.TrackedGpuBuffers} " + + $"trackedGpuTextures={process.TrackedGpuTextures}"; + } } diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs index 25f09017..f1b9dc00 100644 --- a/src/AcDream.App/Streaming/StreamingDiagnostics.cs +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -1,5 +1,6 @@ using System.Globalization; using System; +using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -11,6 +12,8 @@ namespace AcDream.App.Streaming; /// internal static class StreamingDiagnostics { + internal const int DefaultTunnelFreezeFrame = 72; + /// /// #280 A/B measurement probe. When set, the outdoor reveal gate uses this /// landblock radius instead of the derived streaming window, so the same @@ -65,6 +68,20 @@ internal static class StreamingDiagnostics public static bool ProbeRevealTiming { get; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1"; + /// + /// #419 RenderDoc apparatus. When present, the teleport sequencer remains + /// in retail's stable Tunnel state even after the destination is + /// ready, and the portal-space animation stops once it reaches this frame. + /// ACDREAM_PROBE_TUNNEL_FREEZE=1 selects the representative frame + /// ; an explicit frame in the range + /// 2.. may be supplied + /// instead. This intentionally prevents placement and viewport reveal + /// until the transition is cancelled or the process exits. It is + /// inspection apparatus, not a user setting. + /// + public static int? TunnelFreezeFrame { get; } = ParseTunnelFreezeFrame( + Environment.GetEnvironmentVariable("ACDREAM_PROBE_TUNNEL_FREEZE")); + /// /// The floor is 1, not 0. An outdoor destination's acknowledgement must /// carry RequiredRenderRadius >= 1 or @@ -78,4 +95,23 @@ internal static class StreamingDiagnostics && value >= 1 ? value : null; + + internal static int? ParseTunnelFreezeFrame(string? raw) + { + if (string.Equals(raw, "1", StringComparison.Ordinal) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + { + return DefaultTunnelFreezeFrame; + } + + return int.TryParse( + raw, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int frame) + && frame >= 2 + && frame <= TeleportAnimSequencer.TunnelEndFrame + ? frame + : null; + } } diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index 8e74ab1e..689825b5 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -1,3 +1,4 @@ +using AcDream.App.Rendering; using AcDream.Runtime; using AcDream.Runtime.World; @@ -85,7 +86,8 @@ internal sealed class WorldRevealCoordinator WorldGenerationQuiescence? quiescence = null, IWorldRevealStreamingScheduler? streaming = null, IWorldRevealRenderResourceScheduler? renderResources = null, - Func? loadedLandblockCount = null) + Func? loadedLandblockCount = null, + IRenderFrameResourceDiagnosticsSource? renderResourceDiagnostics = null) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _readiness = new WorldRevealReadinessBarrier( @@ -101,7 +103,11 @@ internal sealed class WorldRevealCoordinator _streaming = streaming; _renderResources = renderResources; if (StreamingDiagnostics.ProbeRevealTiming) - _timing = new RevealTimingProbe(loadedLandblockCount); + { + _timing = new RevealTimingProbe( + loadedLandblockCount, + renderResourceDiagnostics); + } } public RuntimePortalSnapshot Snapshot => _transit.Snapshot; diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 1af2ce57..30c2ee7d 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -53,7 +53,13 @@ public sealed class TeleportAnimSequencer public const int TunnelEndFrame = 120; public const float ExitWindowLow = FadeTime + 0.1f; public const float ExitWindowHigh = FadeTime + 0.3f; - private const short LastVisibleOutgoingAnimationLevel = 1022; + // The retail viewport swap happens before the finite portal mesh reaches + // the final near-180-degree samples. Full-width 1280x720 captures establish + // level 1001 as the last radial field that covers the viewport; 1006 starts + // exposing the mesh perimeter and 1013 can leave its complete faceted rim + // on screen. Keep the cutoff in the quantized retail table domain so + // refresh rate cannot move the swap. + internal const short LastVisibleOutgoingAnimationLevel = 1001; // UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine // samples into a 0..1024 easing table. @@ -102,10 +108,18 @@ public sealed class TeleportAnimSequencer /// Advance the machine by seconds. /// = the complete destination-load barrier is /// satisfied (Near-tier render meshes/textures plus collision residency). + /// is diagnostic apparatus: when true the + /// machine observes readiness but withholds the Place edge and stays + /// in the stable tunnel state. The default is false and retail behavior is + /// unchanged. /// Returns the current snapshot + edge-triggered events fired THIS tick. /// public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72) + Tick( + float dt, + bool worldReady, + int tunnelAnimationFrame = 72, + bool holdInTunnel = false) { var evts = new List(); @@ -133,7 +147,7 @@ public sealed class TeleportAnimSequencer case TeleportAnimState.Tunnel: // Hold here until worldReady (EndTeleportAnimation analogue). - if (worldReady) + if (worldReady && !holdInTunnel) { evts.Add(TeleportAnimEvent.Place); Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); @@ -183,10 +197,9 @@ public sealed class TeleportAnimSequencer /// /// Retire an outgoing viewport before the first quantized projection that - /// exposes the finite tunnel boundary. Retail's whole-frame black clear and - /// frame-paced D3D presentation show level 1022 as the last tunnel sample; - /// paired captures show no 1023/1024 portal frame before the world swap. - /// An uncapped modern loop can otherwise publish those sub-20 ms samples + /// exposes the finite tunnel boundary. Full-width captures show 1001 as + /// the last covered sample and 1006 as the first divergent faceted edge. + /// An uncapped modern loop can otherwise publish those terminal samples /// and let the desktop compositor hold one for a complete display refresh. /// private bool OutgoingViewportReachedTerminalProjection() diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs index 47f548bb..99702c74 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs @@ -1,4 +1,7 @@ +using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.Tests.Architecture; using AcDream.App.UI; using AcDream.Content; using AcDream.Content.Vfx; @@ -12,6 +15,30 @@ namespace AcDream.App.Tests.Rendering; public sealed class PortalTunnelAssetTests { + [Fact] + public void PortalWorldHandoff_HidesTunnelBeforePublishingWorldFadeInProjection() + { + MethodInfo tick = typeof(LocalPlayerTeleportPresentation).GetMethod( + nameof(LocalPlayerTeleportPresentation.Tick), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("Presentation Tick method missing."); + IReadOnlyList calls = CompiledCallGraph.Read(tick); + int hide = CompiledCallGraph.IndexOf( + calls, + typeof(PortalTunnelPresentation), + nameof(PortalTunnelPresentation.Exit)); + int publish = CompiledCallGraph.IndexOf( + calls, + typeof(TeleportViewPlaneController), + nameof(TeleportViewPlaneController.Update)); + + Assert.True(hide >= 0, "Presentation Tick no longer retires portal space."); + Assert.True(publish >= 0, "Presentation Tick no longer publishes its view plane."); + Assert.True( + hide < publish, + "Portal space must retire before the WorldFadeIn projection is visible to rendering."); + } + /// /// Campaign V slice V6m. The RHI arm cannot inherit a bound framebuffer, so /// its pass re-establishes the colour the frame already cleared. That is @@ -189,7 +216,7 @@ public sealed class PortalTunnelAssetTests TeleportViewPlaneController.TransitionViewPlaneDistance, wideProjection.M22, precision: 5); - Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5); + Assert.Equal(0.00025f, wideProjection.M43 / wideProjection.M33, precision: 7); controller.Update(new TeleportAnimSnapshot( TeleportAnimState.Off, @@ -201,6 +228,37 @@ public sealed class PortalTunnelAssetTests Assert.Equal(baseProjection, controller.Apply(baseProjection)); } + [Fact] + public void TeleportViewPlane_TunnelKeepsRetailNearPlaneWhileWorldUsesCoverageNearPlane() + { + var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + 16f / 9f, + 0.1f, + 5000f); + var controller = new TeleportViewPlaneController(); + controller.Begin(baseProjection); + + controller.Update(new TeleportAnimSnapshot( + TeleportAnimState.TunnelFadeOut, + ViewPlaneBlend: 1f, + ShowTunnel: true, + ShowPleaseWait: false)); + System.Numerics.Matrix4x4 tunnelProjection = controller.Apply(baseProjection); + + controller.Update(new TeleportAnimSnapshot( + TeleportAnimState.WorldFadeIn, + ViewPlaneBlend: 1f, + ShowTunnel: false, + ShowPleaseWait: false)); + System.Numerics.Matrix4x4 worldProjection = controller.Apply(baseProjection); + + Assert.Equal(0.1f, tunnelProjection.M43 / tunnelProjection.M33, precision: 5); + Assert.Equal(0.00025f, worldProjection.M43 / worldProjection.M33, precision: 7); + Assert.Equal(tunnelProjection.M11, worldProjection.M11); + Assert.Equal(tunnelProjection.M22, worldProjection.M22); + } + [Fact] public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer() { diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index c1e8f5d1..f176db45 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -468,7 +468,7 @@ public sealed class LocalPlayerTeleportControllerTests } [Fact] - public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive() + public void ExitSound_HidesTunnelBeforeReleasingDestinationAndKeepsProtocolActive() { var order = new List(); var harness = new Harness(order: order); @@ -481,6 +481,8 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); + Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end")); + Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue")); Assert.False(harness.Presentation.IsPortalViewportVisible); Assert.True(harness.Controller.IsActive); Assert.False(harness.Reveal.Snapshot.Completed); @@ -1589,10 +1591,13 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); Assert.False(harness.Placement.Called); - // Viewport swap: reservation release + exit cue + tunnel retire + // Viewport swap: tunnel retire, reservation release, then exit cue // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405). + order.Clear(); harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); harness.Controller.Tick(0.016f); + Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end")); + Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue")); Assert.Equal(["enter", "exit"], harness.Presentation.Cues); Assert.False(harness.Presentation.IsPortalViewportVisible); Assert.Single(harness.Streaming.ReservationEnds); @@ -2126,9 +2131,17 @@ public sealed class LocalPlayerTeleportControllerTests public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick"); public readonly List Cues = new(); public void PlayEnterCue() => Cues.Add("enter"); - public void PlayExitCue() => Cues.Add("exit"); + public void PlayExitCue() + { + Cues.Add("exit"); + _order.Add("exit-cue"); + } public void EnterTunnel() => IsPortalViewportVisible = true; - public void ExitTunnel() => IsPortalViewportVisible = false; + public void ExitTunnel() + { + IsPortalViewportVisible = false; + _order.Add("presentation-exit"); + } public void SetWaitCue(bool visible) => WaitCueValues.Add(visible); public void Reset() diff --git a/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs b/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs new file mode 100644 index 00000000..c66baaf9 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs @@ -0,0 +1,129 @@ +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.Runtime; + +namespace AcDream.App.Tests.Streaming; + +public sealed class RevealTimingProbeTests +{ + [Fact] + public void ResourceSampling_IsLowFrequencyAndCoalescesSamePollEdges() + { + var resources = new CountingResourceSource(); + var probe = new RevealTimingProbe( + loadedLandblockCount: static () => 4, + renderResources: resources); + probe.Begin( + kind: "Login", + generation: 2, + destinationCell: 0x1234_0001u, + window: new StreamingRevealWindow(NearRadius: 1, FarRadius: 2)); + + probe.Observe( + default, + RuntimePortalSnapshot.Idle with { Generation = 2 }); + probe.Observe( + new WorldRevealReadinessSnapshot( + DestinationCell: 0x1234_0001u, + IsIndoor: false, + IsUnhydratable: false, + RequiredRenderRadius: 2, + RequiredNearRadius: 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true), + RuntimePortalSnapshot.Idle with + { + Generation = 2, + Materialized = true, + WorldViewportObserved = true, + }); + + // Begin, one coalesced readiness-edge snapshot, and summary. The + // ordinary false poll does not touch the renderer resource owners. + Assert.Equal(3, resources.CaptureCount); + } + + [Fact] + public void ResourceLineCarriesColdMeshTextureAndProcessAttribution() + { + MeshStreamResourceDiagnostics mesh = + default(MeshStreamResourceDiagnostics) with + { + RenderData = 12, + AtlasArrays = 3, + EstimatedBytes = 4_000, + GlobalUploadCount = 7, + GlobalUploadedBytes = 8_000, + FrameUploadCount = 2, + FrameUploadBytes = 900, + StagedUploadBacklog = 5, + StagedUploadBytes = 6_000, + GlobalCapacityBytes = 10_000, + GlobalPhysicalCapacityBytes = 20_000, + GlobalMigrationInProgress = true, + PreparedReads = 21, + PreparedLoaded = 20, + }; + TextureStreamResourceDiagnostics textures = + default(TextureStreamResourceDiagnostics) with + { + OwnedBindlessTextures = 17, + TextureOwners = 15, + CachedCompositeTextures = 9, + CachedUnownedComposites = 2, + CachedUnownedCompositeBytes = 700, + CompositeAtlases = 2, + CompositeAtlasBytes = 30_000, + CompositeWarmupPending = 4, + FrameCompositeUploadCount = 3, + FrameCompositeUploadBytes = 1_200, + }; + ProcessResourceDiagnostics process = new( + ManagedBytes: 40_000, + ManagedCommittedBytes: 50_000, + TrackedGpuBytes: 60_000, + TrackedGpuBuffers: 11, + TrackedGpuTextures: 13); + var snapshot = new RenderFrameResourceDiagnosticsSnapshot( + default, + default, + mesh, + textures, + process); + + string line = RevealTimingProbe.FormatResourceLine( + "render-ready", + "Login", + generation: 2, + elapsedMilliseconds: 3456, + snapshot); + + Assert.StartsWith( + "[reveal-resource] checkpoint=render-ready kind=Login gen=2 elapsedMs=3456", + line); + Assert.Contains("meshData=12", line); + Assert.Contains("globalUploads=7", line); + Assert.Contains("staged=5", line); + Assert.Contains("arenaMigrating=1", line); + Assert.Contains("prepared=0/21/20/0/0", line); + Assert.Contains("ownedTextures=17", line); + Assert.Contains("unownedCompositeBytes=700", line); + Assert.Contains("compositePending=4", line); + Assert.Contains("frameCompositeUploadBytes=1200", line); + Assert.Contains("managedBytes=40000", line); + Assert.Contains("trackedGpuBytes=60000", line); + } + + private sealed class CountingResourceSource : + IRenderFrameResourceDiagnosticsSource + { + public int CaptureCount { get; private set; } + + public RenderFrameResourceDiagnosticsSnapshot Capture() + { + CaptureCount++; + return default; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs new file mode 100644 index 00000000..093524cf --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs @@ -0,0 +1,23 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class StreamingDiagnosticsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("0", null)] + [InlineData("garbage", null)] + [InlineData("1", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("true", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("TRUE", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("2", 2)] + [InlineData("72", 72)] + [InlineData("120", 120)] + [InlineData("121", null)] + public void TunnelFreezeParser_AcceptsTheDefaultAliasOrAnAuthoredFrame( + string? raw, + int? expected) => + Assert.Equal(expected, StreamingDiagnostics.ParseTunnelFreezeFrame(raw)); +} diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index ef0820d9..7f9a8525 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -168,6 +168,31 @@ public sealed class TeleportAnimSequencerTests Assert.Contains(TeleportAnimEvent.Place, evts); } + [Fact] + public void DiagnosticHold_KeepsReadyPortalInStableTunnelUntilReleased() + { + var seq = new TeleportAnimSequencer(); + seq.Begin(TeleportEntryKind.Portal); + + var (_, heldEvents) = seq.Tick( + dt: 30f, + worldReady: true, + tunnelAnimationFrame: 72, + holdInTunnel: true); + + Assert.Equal(TeleportAnimState.Tunnel, seq.State); + Assert.DoesNotContain(TeleportAnimEvent.Place, heldEvents); + + var (_, releasedEvents) = seq.Tick( + dt: 0f, + worldReady: true, + tunnelAnimationFrame: 72, + holdInTunnel: false); + + Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); + Assert.Contains(TeleportAnimEvent.Place, releasedEvents); + } + // --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut --- [Fact] @@ -301,6 +326,44 @@ public sealed class TeleportAnimSequencerTests framesPerSecond); } + [Fact] + public void TunnelFadeOut_SwapsAfterLastFullViewportCaptureLevel() + { + Assert.Equal((short)1001, TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + var seq = new TeleportAnimSequencer(); + seq.Begin(TeleportEntryKind.Portal); + seq.Tick(0f, worldReady: false); + seq.Tick(0.016f, worldReady: true); + seq.Tick( + TeleportAnimSequencer.MinContinue, + worldReady: true, + tunnelAnimationFrame: 72); + Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); + + float lastSafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + == TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + float firstUnsafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + > TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + var (lastSafe, _) = seq.Tick(lastSafeTime, worldReady: true); + Assert.Equal(TeleportAnimState.TunnelFadeOut, lastSafe.State); + Assert.Equal( + TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f, + lastSafe.ViewPlaneBlend); + + var (swapped, events) = seq.Tick( + firstUnsafeTime - lastSafeTime, + worldReady: true); + Assert.Equal(TeleportAnimState.WorldFadeIn, swapped.State); + Assert.False(swapped.ShowTunnel); + Assert.Contains(TeleportAnimEvent.PlayExitSound, events); + } + private static void AssertOutgoingViewportRetiresBeforeTerminal( TeleportAnimSequencer seq, TeleportAnimState outgoingState, @@ -314,7 +377,8 @@ public sealed class TeleportAnimSequencerTests if (snapshot.State == outgoingState) { Assert.True( - snapshot.ViewPlaneBlend <= 1022f / 1024f, + snapshot.ViewPlaneBlend + <= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f, $"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}."); continue; } @@ -384,8 +448,13 @@ public sealed class TeleportAnimSequencerTests seq.Begin(TeleportEntryKind.Logout); seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0 - // Drive to just BEFORE the transition (so we're still in WorldFadeOut) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false); + float lastSafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + == TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + // Drive directly to the last full-viewport quantized table level. + seq.Tick(lastSafeTime, worldReady: false); Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); var (snap, _) = seq.Tick(0f, worldReady: false); @@ -393,7 +462,9 @@ public sealed class TeleportAnimSequencerTests Assert.True( snap.ViewPlaneBlend > 0.95f, $"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}"); - Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f); + Assert.True( + snap.ViewPlaneBlend + <= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f); } [Fact]