fix(render): harden portal exit handoff
This commit is contained in:
parent
ddbd7e4096
commit
82e4b4cb6d
17 changed files with 896 additions and 119 deletions
323
AGENTS.md
323
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<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-215–AP-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 A–K 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, V0–V11 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 (~100–500 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. |
|
||||
|
|
|
|||
|
|
@ -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=<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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 >= 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
129
tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs
Normal file
129
tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue