fix(render): harden portal exit handoff
Some checks failed
CI / linux-portable (push) Successful in 3m19s
CI / windows-gate (push) Failing after 6m43s
CI / release (push) Has been skipped

This commit is contained in:
Erik 2026-08-25 17:39:44 +02:00
parent ddbd7e4096
commit 82e4b4cb6d
17 changed files with 896 additions and 119 deletions

323
AGENTS.md
View file

@ -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 A1A6 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 S1S3
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<slots ghost gate)
`gmCharGenMainUI`'s six-page flow (Heritage / Profession / Skills /
Appearance with live 3D preview / Town / Summary with its own zoomed-out
viewport) → byte-exact 0xF656 with the 55-slot invariant → complete
0xF643 handling (roster append + retail log-straight-in; every rejection
dialog, incl. the corrected ground truth that retail shows NameDBDown
for Pending/Undef — the plan's original "retail swallows it" was
DISPROVEN at CC5's review) → the §LA1 `characterCreated`/`creationFailed`
launcher status cycle. `RandomizeCharacter` + sub-primitives are ported
(retail's ctor-time open-roll incl. the gender-flip quirk; humans-only
random heritage ids 1-4 — a real retail quirk). Plan + ledger:
`docs/plans/2026-08-15-character-creation-campaign.md`; connected gate
script: `docs/research/2026-08-16-campaign-cc-test-script.md` (launch:
launcher flow, or `ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`);
START at `claude-memory/project_character_creation_campaign_handoff.md`.
Register churn: AP-214/AP-225/TS-82/AD-101 retired; AP-211 updated;
AP-212 narrowed; AP-215AP-229 filed (AP-221 one-shot preview binding,
AP-222 spin-highlight no-op, AP-229 stacked-screens-vs-retail-teardown
are the ones a gate tester will meet). Known-flake set now also names
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate`
(full-solution parallel load only). Suites at `2176ba76`: full solution
14,426 / 4 skips, App 5257/3, Runtime 1735/0, Launcher.Core 324/0.
**Placement cutover — C4 COMPLETE 2026-08-05, merged to main.** Every
placement route now runs through the canonical residence + continuation-
executor owner. Routes landed this session: 4b-3 remote teleport/cell-less
(`6dc7ba51`), 6 drops (`1b484937`, zero production lines), 5 projectile
(`36255af0`), 7 child-cell propagation (`cd3129e9`), 3 portal
(`e0f96a55`), plus the `OnPosition` dual-tail collapse (`edc911b0`) that
retired the duplication behind three separate defects. Suite 11,027 →
**11,090 passed / 4 skipped / 0 failed**. Connected gates: routes 3, 6 and 7
user-passed 2026-08-05 with probe evidence; route 7's is THIN (one
`cause=propagate`) and 4b-3's `cause=cellless` case remains unrun with an
UNESTABLISHED trigger — route 7 invalidated its recorded recipe.
**C5 COMPLETE — the placement campaign is FULLY CLOSED (`addb5657`,
2026-08-07).** C5a deleted the legacy resolver outright and retired
AP-1/AP-145 (closing #318); C5b closed #275 and filed AP-147/AP-148; C5c's
closeout passed its 11,196-test automated gate and the owed connected-gate
batch USER-PASSED 2026-08-07. #280's portal-prefetch fix and its dual
review also landed (AP-149/150/151), and AP-22 retired 2026-08-06. Start
any new placement work at `claude-memory/project_placement_cutover_closed.md`
(probes deliberately NOT stripped; start at #331).
**Read `docs/research/2026-08-05-c4-closeout-handoff.md` before any
placement work.** Its seven process findings remain binding. The two that
cost the most that campaign: a contract asserting a mechanism that does not
exist caused three separate defects, and inferring a fact you can observe
made one fix strictly worse than the bug it replaced — it removed the
invariant failure while leaving the bug.
**Modern Runtime/performance status:** Slices AK of
`docs/plans/2026-07-24-modern-runtime-architecture.md` are complete. Slice L is
@ -566,9 +752,10 @@ The capped/RDP jump-presentation cadence alias is deferred as issue #235:
uncapped Release presentation is smooth, while physics, collision, and wire
truth remain correct.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #153, #116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L, and #225's
lifestone/particle alpha visual gate.
`docs/architecture/code-structure.md`. **Carried:** #116 (Campaign P P2),
remaining R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Runtime Slice L,
and #225's lifestone/particle alpha visual gate. #153 closed 2026-07-30
(Campaign P P5 ledger evidence chain).
Start structural work at `memory/project_gamewindow_decomposition.md` and
`docs/architecture/code-structure.md`; start
@ -578,6 +765,9 @@ render/streaming work at `claude-memory/project_render_pipeline_digest.md`.
Documentation entry point: [`docs/README.md`](docs/README.md).
For canonical state, read in this order:
- [`docs/plans/2026-07-29-network-transport-campaign.md`](docs/plans/2026-07-29-network-transport-campaign.md) — Campaign N, the retail reliable-transport port — **CLOSED 2026-07-29, user-accepted** (#260 closed; a real wire loss recovered live during the acceptance session). Still the SSOT for the transport mechanism, the ACE constraint table, and the landmine list — read it (or `claude-memory/project_network_transport_digest.md`) before touching anything under `src/AcDream.Core.Net/`.
- [`docs/plans/2026-07-27-vulkan-campaign.md`](docs/plans/2026-07-27-vulkan-campaign.md) — Campaign V, OpenGL → Vulkan — **CLOSED 2026-07-29**; the completed record of the RHI contract, V0V11 slices, and the GL deletion. Historical reference for `src/AcDream.App/Rendering/`.
- [`docs/ci-and-releases.md`](docs/ci-and-releases.md) — **the Gitea CI/release SSOT (2026-08-19)**: every push to main gates on two self-hosted runners (RARE-win / eriktestLinux) and publishes a Gitea Release the launcher installs from; payloads are release attachments, the `latest` release is the launcher's pointer, old releases are pruned to 5. Load-sensitive tests live in `Lane=Timing` (see `docs/release-gate.md`) — do NOT chase them individually.
- [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone
- [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md) — what's shipped, what's in flight, what's next
- [`docs/ISSUES.md`](docs/ISSUES.md) — open + recently closed bugs (tactical)
@ -1367,67 +1557,44 @@ governed by whether the previous shutdown was graceful or forced.
### Test character
`+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has
basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default
200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate`
for local motion prediction. **These are NOT synced to the server**
ACE's own character data is authoritative for broadcast motion. If you
see a speed/anim mismatch between local and observer views, the fix is
to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired
via `PlayerMovementController.ApplyServerRunRate`) or from
`PlayerDescription (0x0013)`.
basic stats. Run/jump skills arrive FROM the server and drive local motion
prediction (`LiveMovementStatsApplier``PlayerMovementController`); the
hardcoded fallbacks before the server speaks are 200 run / 300 jump. The
former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no
longer exist — see the Retired section of
[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim
mismatch between local and observer views, check the server sync path
(`UpdateMotion.ForwardSpeed` echo via
`PlayerMovementController.ApplyServerRunRate`, or
`PlayerDescription (0x0013)`).
### Diagnostic env vars
- `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_NO_AUDIO=1` — suppress OpenAL init for headless / driver-
broken setups.
- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per
`PhysicsEngine.ResolveWithTransition` call: input + target + output
position/cell, ok-vs-partial, grounded-in, contact-plane status,
wall normal if hit, **responsible entity guid**, env flag, walkable
polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via
the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per
`PlayerMovementController.CellId` change: old → new cell, world
position, reason tag (`resolver` / `teleport`). Low volume — only
fires on actual cell crossings. Runtime-toggleable via the same
DebugPanel section.
- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics
tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call),
`[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch),
`[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit).
Heavy under motion (~100500 lines/sec). Pair with retail's cdb
breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture
protocol. Runtime-toggleable via the DebugPanel.
- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility
decisions at frame boundaries. Used to converge the U.4c flap fix
(root indoor visibility at player's cell, not eye).
- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]`
lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown),
per-armed-tick steer lines (signed gap dist, applied delta, heading
delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site.
Heavy while a pack is stuck (~60 Hz × stuck count). Converged the
#171 residuals (the deep-overlap sign pin AP-82).
- `ACDREAM_CAPTURE_RESOLVE=<path>` — 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=<path>` / `ACDREAM_DUMP_GFXOBJS=<path>` — 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. |

View file

@ -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 2120).
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≈18 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=<csv-path>`; 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

View file

@ -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 2120 (`=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` |

View file

@ -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(

View file

@ -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");
}
/// <summary>

View file

@ -128,9 +128,10 @@ internal readonly record struct ProcessResourceDiagnostics(
int TrackedGpuTextures);
/// <summary>
/// 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.
/// </summary>
internal readonly record struct RenderFrameResourceDiagnosticsSnapshot(
VfxStreamResourceDiagnostics Vfx,

View file

@ -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
/// </summary>
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);
}
/// <summary>
/// Retail's <c>set_vdst</c> keeps <c>znear</c> 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.
/// </summary>
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);
}
/// <summary>
/// Decorate the active camera with the same projection returned by
/// <see cref="Apply(Matrix4x4)"/>. Retail's <c>Render::set_vdst</c> is

View file

@ -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;

View file

@ -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.</item>
/// <item>1 Hz progress — elapsed, the three flags, and the resident
/// landblock count, so a budget-paced linear drip is visually obvious in the
/// log.</item>
/// log. A paired <c>[reveal-resource]</c> snapshot attributes the cold render
/// barrier to mesh preparation/upload, arena growth, texture composites, or
/// process/GPU residency without sampling those owners every frame.</item>
/// <item><c>SUMMARY</c> once at the viewport reveal — the per-edge
/// timeline on one line.</item>
/// </list>
@ -31,6 +34,7 @@ namespace AcDream.App.Streaming;
internal sealed class RevealTimingProbe
{
private readonly Func<int>? _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<int>? loadedLandblockCount) =>
public RevealTimingProbe(
Func<int>? 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}";
}
}

View file

@ -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;
/// </summary>
internal static class StreamingDiagnostics
{
internal const int DefaultTunnelFreezeFrame = 72;
/// <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
@ -65,6 +68,20 @@ internal static class StreamingDiagnostics
public static bool ProbeRevealTiming { get; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1";
/// <summary>
/// #419 RenderDoc apparatus. When present, the teleport sequencer remains
/// in retail's stable <c>Tunnel</c> state even after the destination is
/// ready, and the portal-space animation stops once it reaches this frame.
/// <c>ACDREAM_PROBE_TUNNEL_FREEZE=1</c> selects the representative frame
/// <see cref="DefaultTunnelFreezeFrame"/>; an explicit frame in the range
/// 2..<see cref="TeleportAnimSequencer.TunnelEndFrame"/> 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.
/// </summary>
public static int? TunnelFreezeFrame { get; } = ParseTunnelFreezeFrame(
Environment.GetEnvironmentVariable("ACDREAM_PROBE_TUNNEL_FREEZE"));
/// <summary>
/// The floor is 1, not 0. An outdoor destination's acknowledgement must
/// carry <c>RequiredRenderRadius &gt;= 1</c> 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;
}
}

View file

@ -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<int>? loadedLandblockCount = null)
Func<int>? 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;

View file

@ -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 <paramref name="dt"/> seconds.
/// <paramref name="worldReady"/> = the complete destination-load barrier is
/// satisfied (Near-tier render meshes/textures plus collision residency).
/// <paramref name="holdInTunnel"/> is diagnostic apparatus: when true the
/// machine observes readiness but withholds the <c>Place</c> 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.
/// </summary>
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72)
Tick(
float dt,
bool worldReady,
int tunnelAnimationFrame = 72,
bool holdInTunnel = false)
{
var evts = new List<TeleportAnimEvent>();
@ -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
/// <summary>
/// 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.
/// </summary>
private bool OutgoingViewportReachedTerminalProjection()

View file

@ -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<CompiledCall> 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.");
}
/// <summary>
/// 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()
{

View file

@ -468,7 +468,7 @@ public sealed class LocalPlayerTeleportControllerTests
}
[Fact]
public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive()
public void ExitSound_HidesTunnelBeforeReleasingDestinationAndKeepsProtocolActive()
{
var order = new List<string>();
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<string> 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()

View file

@ -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;
}
}
}

View file

@ -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));
}

View file

@ -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]