diff --git a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md index 0d94789db..f61331e4b 100644 --- a/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md +++ b/docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md @@ -273,7 +273,10 @@ grants for the *client-local* document sandbox. 1. **Connection↔identity binding** — admission maps a connection to a `pid`; intents/writes derive authority from the connection, not a self-asserted `origin_node_id`. 2. **Admission hook** — at join/snapshot: allocate `pid`, grant scope, check capacity/ban/role, and - gate what subtree a peer may pull (snapshot currently serves everything). + gate what subtree a peer may pull (snapshot currently serves everything). *S7 (§5h.1) shapes the gRPC + snapshot reply and the WebSocket broadcast by the requester's registry filter, but that is delivery + policy, not access control — a peer with no filter still gets everything — and the admit snapshot, + sent before Hello, is still unfiltered.* 3. **Server-mediated write path** — the intent channel + validator + sole-writer discipline (§5d). 4. **Roster primitive** — immediate-children enumeration, `pid` allocation, presence/heartbeat reaping of participant subtrees (drive `expireAt`). @@ -338,12 +341,15 @@ bundles them yet. *(No client-prediction/reconcile/rollback loop exists today — it is new; distributed state resolves last-writer with vector clocks, not predict/rollback.)* * **LOD (realtime for large scenes).** A replica is a *scoped, decimated* view, not a full copy: **spatial - interest** (extend the shard's `add_interest` from `"conf"` to `scene.` near the camera) picks - *what*; **geometry LOD** (landed, PR #249+: `lod/select.h` rung/budget solver, `lod/pyramid.h`, QEM - `geometry/simplify.h`, `lod/tiles.h`, HDF5 `lod/store.h`, `LodGraphicsNode`) picks *how much*; reconcile - fixes what was skipped. **Physics LOD is new** (zero hits today) — a sim-detail ladder (collision-hull - decimation, substep/rate reduction, freeze-to-kinematic for distant/subordinate bodies) + re-hydrate when - a body re-enters interest. + interest** picks *what*; **geometry LOD** (landed, PR #249+: `lod/select.h` rung/budget solver, + `lod/pyramid.h`, QEM `geometry/simplify.h`, `lod/tiles.h`, HDF5 `lod/store.h`, `LodGraphicsNode`) picks + *how much*; reconcile fixes what was skipped. **H1's spatial interest is built (S7, §5h.1)** and it is + *not* the `scene.`-near-the-camera filter this bullet first proposed: it is a **relay-authoritative, + avatar-centric** area of interest over the *participants* subtree (the camera is never replicated, §5g, + so the relay centres interest on the authoritative avatar pose it already holds), enforced outbound per + receiver by the relay. Cell interest over `scene.*` moves to H3 (§5h.1 says why). **Physics LOD is new** + (zero hits today) — a sim-detail ladder (collision-hull decimation, substep/rate reduction, + freeze-to-kinematic for distant/subordinate bodies) + re-hydrate when a body re-enters interest. * **Composition / holarchy.** A server may **subdelegate a sub-volume** `V` to a subordinate server running a child holon (its own clients/streams/clock); the parent becomes a **client of the child that keeps ultimate authority**. Two axes: **scene-graph** — mount the child's replicated scene as a frame node at @@ -358,24 +364,284 @@ bundles them yet. dropped` to the tree; replicate them and the replica tracks the remote authoritative tick — `seek_tick` on hard correction, `set_scale` nudge toward the remote `realized_scale()` for drift (time dilation), server-set `sim.mode/speed` replicated for **shared pause/slowmo** (local pause stays local). -* **Hard prerequisite.** `data()` object replication is a known DISTRIBUTED_STATE gap (§52: only scalar - value/child changes journal; `set_data` is a no-op pending a codec). Replicating live physics/scene - *state objects* rides on closing it — so it leads the phasing. +* **Hard prerequisite — LANDED (this bullet was stale).** It read: "`data()` object replication is a known + DISTRIBUTED_STATE gap (§52: only scalar value/child changes journal; `set_data` is a no-op pending a + codec)." That path has landed on all four transports — codec-encoded `set_data` (inline or a + content-addressed `blob_ref`), snapshot capture + replay, and an automatically driven + `state_data_hydrator` (DISTRIBUTED_STATE_ROADMAP.md Phase 10, status corrected 2026-10-22). Its residual + items (WebSocket late-joiner blob hydration, the async hydrator install guard, oversized `value()` + offload) are tracked there. It no longer gates the phasing; H1 replicates scalar leaves only. **Phasing.** -- **H0 — substrate:** close `data()` object replication (DISTRIBUTED_STATE §52); spatial-interest filter; - confirm the deterministic physics path (`det_math.h`). +- **H0 — substrate:** close `data()` object replication (DISTRIBUTED_STATE §52 — **LANDED**, see the + prerequisite bullet above); spatial-interest filter (**LANDED in S7** as the per-peer delivery + predicate on `state_peer_registry`, §5h.1); confirm the deterministic physics path (`det_math.h`). - **H1 — single holon** (the §5g scene + avatars, formalized): bundle `{clock,physics,scene}`; server-authoritative; client replica with prediction + reconcile; pose propose→veto→override; geometry - LOD + spatial interest; **per-camera `RenderView` + `VisibilityMask`** (both already exist — - `src/cvcGL/RenderView.cpp`, §9.5 — so H1 *uses* them, it does not build them). + LOD + spatial interest (**S7, §5h.1**: avatar-centric AOI on the relay + per-frame mesh LOD in + `room_view`). *(Corrected: this item used to say H1 uses a "per-camera `RenderView` + + `VisibilityMask`, both already exist". They exist, but they drive the separate `cvc::gl::Node` graph + (`nodes.h` / `traversal.h`), which neither `SceneGraph` nor `room_view` uses; arigl has one view and + one camera, and LOD selects from that renderer's camera through `SceneGraph::selectLOD`. Per-camera + views wait for a PiP or minimap — CVCGL-UI-DSL-ROADMAP.md §9.5, stage C.)* - **H2 — physics/sim LOD + re-hydrate; cross-node clock tracking** (seek/scale, shared pause). - **H3 — subdelegation / holarchy:** spatial→path delegation on the lease manager; parent-as-authority- client + override primitive; live-remote-holon scene mount at copy/LOD; residency/paging driver - (VISIBILITY-AND-LOD HLOD §4.2 — designed, not landed). + (VISIBILITY-AND-LOD HLOD §4.2 — designed, not landed). **Scene spatial interest lands here** with the + paging driver (`scene.cells._`, per-cell revs, per-cell realize/teardown, the cell index shared + with `lod/tiles.h`'s partition), as does client- or camera-declared interest built on S7's registry + filter (§5h.1). - **H4 — physics boundary composition:** authority handoff at `∂V`, then cross-boundary kinematic proxies *(research-grade; last)*. +#### 5h.1 S7 — room spatial interest + geometry LOD (built) + +H1's interest and LOD slice. `room_relay_integration_test` (suite `RoomRelayLoopbackTest`, 10 cases: +a relay built as arisrv builds it, real clients on loopback, WS and gRPC mixed in one room) pins the +behaviour end to end, and `arisrv_aoi_bench` measured it (**Measured**, below). + +**What it is.** A **relay-authoritative, avatar-centric area of interest**. Each room step the relay +places every participant from the server holon's authoritative pose and decides, per ordered pair +(receiver *r*, subject *q*) and from their XY distance, how much of *q* the client *r* gets. **The camera +is never replicated** (§5g), so interest follows the avatar, not where a client looks, and no client can +widen or narrow it. All filtering is relay-side, outbound and per receiver; client shards keep +`add_interest("conf")` + enforcement as defense in depth. Code: `room_aoi.h` (pure tiers, immutable +snapshot, filter, media decisions, push schedule, presets, audio curve) and `room_aoi_relay.h` (the +driver + `relay_room_step`), under `inc/cvc/ariadne/conference/`. + +| Tier | Subject's pose | Camera | Mic | +|---|---|---|---| +| **inner** | live, every room step (~20 Hz) | full rate | yes | +| **middle** | relay **pushes**: 5 Hz while it moves, a 5 s refresh while still | **MJPEG thumbnails at 2 fps** | yes | +| **outer** | relay pushes, as for middle | none | none | + +Tiers have hysteresis: 2 m enter/exit bands; an upgrade is immediate (outer→inner in one step); a +downgrade moves one tier, and only after the distance has stayed past the exit radius for 1 s. + +**Presets** (`aoi_params`; a default-constructed one *is* the conference preset): + +| Preset | inner enter/exit | middle enter/exit | Client audio | Default for | +|---|---|---|---|---| +| `conference` | 30 / 32 m | 40 / 44 m | gain 1 (the fade at 38–40 m is never reached) | the seeded 20 m room | +| `hall` | 10 / 12 m | 16 / 18 m | `min(1, 10/d)`, fading to 0 over 14–16 m | `arisrv --scene large` | + +Shared defaults: 1 s downgrade dwell; pushes every 24 ticks at the 120 Hz holon quantum (5 Hz; with one +6-tick room step of jitter that fits `holon_config::max_peer_span_ticks` = 30, so a pushed peer never +stutters); a 600-tick (5 s) refresh; 2 fps thumbnails; `video_full_max` 0 (every inner camera at full +rate); a 2 m audio fade. The conference radii exist so the 20 m room is provably unchanged: the plane is +[-10,10]², its diagonal 28.3 m, so every pair is inner at first sight and stays inner (pinned by +`RoomAoiTest.ConferencePresetCoversDefaultRoom`). The relay publishes the active parameters once under +`conf.rooms..aoi.*`; clients read them about once a second for their audio gain. An absent +subtree (an older relay) means gain 1; with `--aoi off` or `--s7 off` the relay writes them disabled +(`aoi.enabled` 0), also gain 1 — so a client that lived through an earlier relay's AOI and kept its +leaves follows the restarted relay. The fade ends at `middle_enter`, the *upgrade* +radius, so the relay's cut (outer) and its resume (back to middle) both happen in silence. + +**State: a deny-only predicate that fails open.** Per receiver, the relay installs an +`aoi_state_filter` on both transports' peer registries. Under `conf.rooms..participants.` +it denies only: another participant's `intent` (only the relay and its owner read it); *q*'s whole +`pose` subtree, `server_tick` included, unless the pair is inner; and a departed *q*'s `pose` and +`intent` (the soft-left ghost). Everything else delivers — the receiver's own subtree, +presence/profile/net/streams/voice of every participant (the roster stays room-wide, §5b; a departed +one's `net` and `voice` too, so an in-process reconnect's writes made before its rejoin lands are never +withheld), `scene`, `server`, `aoi`, unknown pids and unknown leaf families — so a new family is never +dropped silently. The relay remembers the most recent 1024 departures; an older ghost falls back to the +unknown-pid rule. A pid that is no C identifier (the room tree takes any child name; a registry entry +cannot) is filtered as a subject but never registered as a receiver — no connection can carry it. + +**Pose pushes.** A non-inner subject's pose reaches the receiver as a targeted snapshot +(`state_transport::push_snapshot_to`) carrying its whole pose subtree, with `server_tick` re-stamped to +the relay holon's *current* tick and written last: the tree's heartbeat tick for a still subject can be +~120 ticks old, and a client re-aligns its whole clock (and rubber-bands) on more than 24 ticks of skew. +A pair entering inner, and a new pair that starts middle or outer, is pushed at once. `relay_room_step` +sends every push **before** the step's `drain_local` + publish, so on each connection a push precedes +that tick's live writes. + +**Media gate — MJPEG-only thumbnails.** The driver installs `media_endpoint::set_media_gate` on both +relay transports; the router's fan-out asks it once per receiver per frame. Outer drops everything. Only +stream `camera` in codec `mjpeg` is ever thumbnailed (MJPEG is intra-only, so any subset of frames +decodes): always in middle, and in inner only under the opt-in `--aoi-video-max K` cap (nearest *K*, +swapped only past a 1 m margin). Every other stream and codec — the mic, H.264, an MJPEG screen-share +under another stream id — is delivered in inner and middle (fail open). The first frame after a tier +change goes out at once. A node's own uplink (`publish_media_frame`) is never gated, and an unidentified +receiver always gets the frame. The nearest-*K* cap is off by default because `conference_roster.ari` +tiles `cam_` in roster order, not by distance, so a cap would put visible tiles at 2 fps. + +**gRPC media stall drop.** `--media-stall-ms 40`: the gRPC relay skips a receiver whose in-flight media +write started more than 40 ms ago (a full flow-control window), and one whose stream a frame cannot get +within 40 ms, rather than queue the publisher's reader thread behind it — the counterpart of the +WebSocket leg's 32 KB backlog drop. It covers every gRPC media write, the relay's own uplink included, +and the media gate is asked only once the stream is held, so a dropped frame spends no thumbnail. (A +write that starts and then blocks still holds its own thread until the 3 s watchdog; only an +asynchronous write path would avoid that.) + +**Transport and journal changes** (detail in DISTRIBUTED_STATE_ROADMAP.md, "Landed for the conference +relay"): a deny-only per-peer delivery predicate on `state_peer_registry`; write-once connection +identity (fixes a `remote_node_id` data race; a second Hello is ignored); the registry honoured on every +room-state send (relay forward loops, the WS ~1 Hz broadcast per connection, the gRPC snapshot +responder); `push_snapshot_to`; the **relay interest gate** (when on, a forwarded frame that no local +same-cluster shard `admits()` is withheld from both the relay fan and the bridge; delegation ops still +pass); and the +**journal trim** (relay backfill off, `trim_published()` after each drain, `replay_after` by binary +search), because the admit snapshot already carries every relay-local leaf. + +**Client changes.** +* **Shard rooted at `"conf"`** (`state_cluster_shard(app, "conf", node, "conf")`): `ui.docs.*` — + marker matrices at 30 Hz while avatars move, camera/HUD state, ping leaves — is no longer journaled, + drained or sent. That ends the `ui.docs` echo through the relay and the client journal that grew from + it; unsolicited-snapshot ingest now also honours enforced interest on both transports. +* **`pose_known` (no origin ghost).** `client_pump` registers a peer that has no replicated pose with + `holon::add_participant(…, pose_known = false)`: no Jolt body (it used to be an invisible obstacle at + the origin, inside the reference box) and no marker or label until its first sample, which creates a + kinematic body there. One's own avatar appears at its first pose with no glide from the origin. + `holon::clock_realigns()` counts clock seeks, and arigl logs each one. +* **Pose leaves journal once per change** (`write_pose`: touch only for a new, empty or forced leaf), + halving pose egress, backfill and journal growth; `write_intent` and the existing `room_*` leaves keep + always-touch (their re-send semantics rely on it). +* **Audio.** Per-peer gain is `aoi_audio_gain` over the XY distance (once both poses are known). A peer's + audio clock is followed only while its audio is fresh (0.5 s), so a gated or muted mic no longer + freezes that peer's video. Each client writes a changed-only `participants..voice.talking` leaf + (one mutation per change), which lights the name of a peer whose audio is stale or gated; no `.ari` + change. +* **Mesh LOD in `room_view`.** It owns a `LodPyramidCache` that outlives whole-scene re-realizes, + realizes with `RealizeOptions{&cache}`, and runs `SceneGraph::selectLOD` from its one camera every + frame (`balanced` natively, `aggressive` under `__EMSCRIPTEN__`). Eligible: a file/URI mesh of ≥ 4096 + triangles with no texture, shader, clip, body or children. Physics never sees a rung (`room_statics` + keeps primitives only). Builds run on one `async_lane` (deferred to one per frame on single-threaded + wasm), one per distinct mesh, capped at 2M triangles threaded and 60k single-threaded — so the deployed + single-threaded cyprty keeps the 69,473-triangle bunny at rung 0 and logs the refusal once. +* **`--scene large`**: a 100 m plane, the reference box, and 24 `x.bunny` meshes on a 5×5 lattice 20 m + apart (the centre cell, which holds the box and the spawn ring, is skipped), each fitted ~1.7 m tall + by new `source.fit.{height, up_y}` scene leaves (a fit, not a transform scale, so LOD errors are in + metres); one pyramid serves all 24. + +**Switches.** In the library every relay mechanism is **opt-in and off by default** (no filter or gate +until one is installed, `set_relay_requires_interest(false)`, backfill on and no trim, +`set_media_stall_drop(0)`, `RealizeOptions{}` makes no LOD node, and `relay_room_step(…, nullptr, …, +false)` is the old drain-and-publish exactly); arisrv and arigl turn them on. + +| Switch | Default | Effect | +|---|---|---| +| `arisrv --s7 on\|off` / `CVC_ARISRV_S7=0\|1\|on\|off` | on | **master kill switch**: off forces `--aoi off`, gate off, journal keep, stall drop 0 — the pre-S7 relay (plus the disabled `aoi.*` leaves) | +| `--aoi off\|conference\|hall` / `CVC_ARISRV_AOI` | conference (hall with `--scene large`) | off: no filters, pushes or media gate; `aoi.enabled` 0 | +| `--aoi-video-max K` | 0 (all) | opt-in nearest-*K* full-rate cameras; the rest of the inner peers as thumbnails | +| `--relay-gate on\|off` | on | off: forward every client frame | +| `--relay-journal trim\|keep` | trim | keep: journal backfill on connect, no trim | +| `--media-stall-ms N` | 40 | gRPC stall drop; 0 = off | +| `--scene default\|large` | default | the seeded scene; not an S7 switch (honoured under `--s7 off`) | +| `CVC_ARISRV_ROOM_DEBUG=1` | off | adds an `[arisrv] aoi` stats line every ~5 s | +| `CVC_ARIGL_REPLICATE_ROOT` (arigl) | unset = `conf` | empty or `*`: replicate the whole tree (the S6 A/B) | +| `CVC_ARIGL_LOD=0` (arigl) | LOD on | every room mesh at full detail (the LOD A/B) | + +An invalid arisrv value is a usage error (exit 2); arisrv prints the effective configuration at start. +Always on, with no switch: `pose_known`, single pose journaling, the write-once identity, the registry +checks (a no-op without a filter), interest-checked snapshot ingest (a no-op without enforcement), the +client's audio-clock staleness and its `voice.talking` leaf. The A/B "before" is `--s7 off` with +`CVC_ARIGL_REPLICATE_ROOT=*`, which is S6 apart from those. + +**The default 20 m room.** It *will* show: no origin ghost or invisible obstacle at join, and one's own +avatar at its first pose (arigl frames the camera there); less control-plane traffic while walking (the +`ui.docs` echo gone, poses single-journaled, others' intents and departed ghosts' poses withheld); +joins that no longer replay the relay's whole journal (admit snapshot, no backfill), with relay journal +and drain cost flat — a join still grows with the soft-left ghosts' subtrees the room tree keeps until +Phase-2 reaping, as in S6; and a peer's video that keeps running when its mic is cut. It will **not** show tiering, pose +pushes, thumbnails, audio rolloff or LOD — every pair is inner, and the plane (2 triangles) and box (12) +are far below the 4096-triangle LOD minimum. Media is unchanged, apart from the gRPC stall drop for a +receiver whose writes are stuck. `arisrv --scene large` (hall) is the demo for all of those. + +**Compatibility.** A pre-S7 client on an S7 relay keeps uplinking the `ui.docs` echo (the gate withholds +it from everyone else), plays every peer at gain 1, draws the large scene's bunnies unfitted (~15 cm, +on their side) and pose-less peers at the origin. An S7 client on a pre-S7 relay finds no `aoi.*` +subtree and plays at gain 1. + +**Known limits.** +* The admit snapshot a connection gets before its Hello is unfiltered (there is no identity to filter + by yet), so a late joiner sees every participant's *current* intent once, and the non-inner poses + and ghost leftovers the live filters would withhold. Live traffic after that is filtered. +* A process that restarts under the node id of a live session has its writes ignored until the relay + restarts (replication drops an `(origin, sequence)` it has seen), which is why arigl takes a fresh id + per launch. An in-process reconnect keeps its id and its sequence, and works: the relay withholds the + departed subject's pose meanwhile, and a client takes the pose subtree a peer left behind for no pose + until a newer sample (`holon::left_tick`). +* The WebSocket leg's ~1 Hz re-broadcast was a full tree per connection, filtered but otherwise + unchanged by S7, and in the hall it dominated a browser's state traffic (**Measured**). It now sends + each connection a diff (see the deferred note below). + +**Measured** (`arisrv_aoi_bench` on catx-03, 24 cores: the relay plus N synthetic clients in one +process on loopback, WS and gRPC alternating, 10 s windows; `--s7 off` is the S6 relay and clients). +Media off, so the state plane alone: + +| | S6 | S7 | +|---|---|---| +| 20 m room, 8 walking — relay egress | 6.4 Mb/s | 3.3 Mb/s | +| … others' intents, per receiver | 40.6 kb/s | 0 | +| … a late joiner's bootstrap | 309 kB | 35 kB | +| hall, 12 in clusters (48 of 66 pairs outer) — per gRPC receiver | 242 kb/s | 162 kb/s | +| … a late joiner's bootstrap | 491 kB | 215 kB | +| hall, 12 standing still — per WS receiver (the 1 Hz re-broadcast) | 847 kb/s | 768 kb/s | + +**WS re-broadcast diffs.** Same bench, run on sandipaws: a Windows static Release build, the S7 +base and the diff change built in one tree, 12 in clusters in the hall, media off, 20 s windows. +The ranges cover two runs each: + +| | full tree (S7) | diffs | +|---|---|---| +| 12 walking — per WS receiver, link in | 196–254 kb/s | 39–45 kb/s | +| … snapshot entries it applied | 316–409 /s | 36–44 /s | +| … relay egress | 1.4–1.8 Mb/s | 0.46–0.54 Mb/s | +| 12 standing still — per WS receiver, link in | 298 kb/s | 30.5 kb/s | +| … snapshot entries it applied | 489 /s | 15 /s | +| a late joiner's bootstrap | 203–223 kB | 105–120 kB | + +A gRPC receiver never got the re-broadcast, and its rate did not change (32–42 kb/s walking, 23 kb/s +standing). A browser now costs within ~7 kb/s of a native client. The remainder is each path touched +live since the last call, sent once more, plus the receiver's own writes echoed back. The late joiner +converged on its first check in both builds. + +With media on: in the 20 m room the gate denied no frame (4 and 8 clients: every pair inner); in the +hall it withheld 2,688 of 3,695 camera and mic frames (73%) for 12 in clusters, and sent 157 MJPEG +thumbnails for 16 walking across the tiers. No run showed an origin ghost; clock re-aligns stayed at +0–3 per run in both modes. Caveats: the bench is one thread, CPU-bound at these sizes (room-step p99 +35–130 ms), so its media rates follow the loop rate and are compared by counts, not rates; and its +reconcile-correction sum varies two-fold between identical runs, so it says nothing either way. + +**Deferred, with destinations.** +* Scene spatial interest (`scene.cells._`, per-cell revs and realize) → **H3**. The scene is a + handful of objects written once (2 in the default room, 26 in `--scene large`), so there is no + bandwidth to win, and a client's physics statics must stay complete for its prediction to match the + server's. +* Client- or camera-declared interest (a fly camera far from the avatar; a client want-list plus a relay + clamp) and a grid-hash AOI broad phase for N > ~200 → **H3**, on S7's registry filter. +* Admission-scoped bootstrap (the admit snapshot goes out before Hello, unfiltered) → **§5e item 2**. + Bootstrap ghost trim and hard reaping of departed subtrees → **Phase 2 / §5e item 4**. +* A video subscription matching visible cam tiles, simulcast/resolution tiers, inter codecs, stereo + panning, an "out of range" tile placeholder; peer-staleness hiding (reconciled with the 5 s outer + refresh) → **Phase 7**. +* Interest as access control, spoofed media `origin_node_id`, forwarding of authority-rejected writes → + **§5e items 1–2 / Phase 8**. +* Avatar frustum/screen-size culling, label nearest-*K* caps and the `cvc::vis` ViewParams adapter → + **VISIBILITY-AND-LOD §4.1** follow-up (inert at room scale). Per-camera `RenderView`/`VisibilityMask` + → **CVCGL-UI-DSL stage C**. +* The LOD budget solver over `LodGraphicsNode`s, relay-baked `cvch5` pyramids and networked LOD + streaming (wasm has no HDF5) → **H3**; physics/sim LOD and re-hydrate on interest entry → **H2**; + per-object diff realize in `room_view` → with per-cell revs (**H3**); an `.ari` `lod:` key and LOD for + `AriRuntime` documents → **§5i**. +* `LodGraphicsNode::select` compares mesh-unit errors with world distances, so a transform-scaled LOD + node coarsens at the wrong distance (the large scene avoids it with `fit`) → LOD follow-up. +* Client journal trim, WebSocket `request_snapshot` and a gRPC periodic snapshot → + DISTRIBUTED_STATE_ROADMAP.md (known gaps); a WS `send_backlog` on Windows-hosted relays (`SIOCOUTQ` is + Linux-only) → transport follow-up. ~~An incremental WS re-broadcast in place of the ~1 Hz full + tree~~ **done**: `broadcast_snapshot` now sends each connection only the entries it does not already + hold. Per connection it keeps a digest of what the last snapshot frame carried for each path, and + forgets a path whenever any other frame touching it is written to that peer or arrives from it + (a live mutation, a push, the peer's own write). A missed live re-fan is still healed on the next + call, and an unchanged tree costs nothing. A client gets the whole tree only when it asks + (`state_transport_ws::request_full_refresh`, a SnapshotRequest) — for state it dropped on its own + side, which the relay cannot see. There is no periodic full snapshot. The broadcast still snapshots + and digests the whole tree once per call; caching that → only if the N = 64 bench shows the ~1 Hz + broadcast costing > 20 ms/s. Merging the visor into the body mesh (2N → N avatar actors) → perf + follow-up. + --- ### 5i. DSL as a first-class holon citizen (programmability & modding) @@ -613,7 +879,8 @@ browser, then the browser itself, then the MVP. video conference. * **Phase 7 — scale & robustness (§7).** Interactive toward ~200 (simulcast/active-speaker selection, off-screen pause, pool/thread/bandwidth budgets); the **broadcast→thousands** regime (cascaded/edge - SFUs); reconnection/presence/heartbeat; TLS end-to-end. + SFUs); reconnection/presence/heartbeat; TLS end-to-end. *S7's per-receiver media gate (§5h.1) is the + first selection hook on the relay: distance tiers, MJPEG thumbnails, a gRPC stall drop.* * **Phase 8 — mesh + auth hardening + WASI.** `mode: mesh` (WebRTC peer / native serve+pull) with the **DC++-style reverse-connect** for NAT (§6g); connection-bound identity + distributed `write_policy` for mesh authz; identity (OIDC via SSO / revocable txwtf-style JWT); the **WASI server** build (fill @@ -659,8 +926,8 @@ mesh works first, auth hardens later (§5, Phase 8); **WASI server committed** ( [`STATE_LIFETIME_AND_ATOMICITY.md`](STATE_LIFETIME_AND_ATOMICITY.md). * UI DSL (status refreshed in #764; `input_text`/widgets landed): [`CVCGL-UI-DSL-ROADMAP.md`](CVCGL-UI-DSL-ROADMAP.md). * World clock / sim pacing (holon driver, §5h): [`WORLD-CLOCK-SYNC-ROADMAP.md`](WORLD-CLOCK-SYNC-ROADMAP.md). -* LOD — geometry (holon §5h; **doc stale — landed PR #249+**): - [`VISIBILITY-AND-LOD-ROADMAP.md`](VISIBILITY-AND-LOD-ROADMAP.md). +* LOD — geometry (holon §5h; **doc stale — landed PR #249+**; what the room uses since S7 is in its + §4.1 status note): [`VISIBILITY-AND-LOD-ROADMAP.md`](VISIBILITY-AND-LOD-ROADMAP.md). * Physics (holon §5h): [`NAV-VEHICLE-PHYSICS-ROADMAP.md`](NAV-VEHICLE-PHYSICS-ROADMAP.md). --- @@ -673,7 +940,8 @@ though it shipped in **#636** — now corrected). The statuses below are **verif **Landed (several still listed open/planned in the docs — refresh needed):** * `input_text` / `input_int` widgets (§3.5/§6) — `src/cvc/ariadne/loader.cpp:650` (#636). -* Multi-view **`RenderView` / `VisibilityMask`** (§9.5) — `src/cvcGL/RenderView.cpp`. +* Multi-view **`RenderView` / `VisibilityMask`** (§9.5) — `src/cvcGL/RenderView.cpp`. They drive the + `cvc::gl::Node` graph, not `SceneGraph`, so the conference's `room_view` does not use them (§5h, H1). * **Light** scene node (§9.1) — `src/cvcGL/LightNode.cpp` + loader `"light"`. * **Clipboard** (§17.8) — `src/cvcGL/Clipboard*.{cpp,mm}`. * `state_exec` **async-scheduler parity** (§7.1/§7.7) — `receive_message`/`sleep`/`yield_frame` in diff --git a/docs/roadmap/DISTRIBUTED_STATE_ROADMAP.md b/docs/roadmap/DISTRIBUTED_STATE_ROADMAP.md index cec2174f7..f6548f04a 100644 --- a/docs/roadmap/DISTRIBUTED_STATE_ROADMAP.md +++ b/docs/roadmap/DISTRIBUTED_STATE_ROADMAP.md @@ -1,6 +1,6 @@ # Distributed State Synchronization Roadmap -Date: May 22, 2026 (status snapshot corrected 2026-08-11) +Date: May 22, 2026 (status snapshot corrected 2026-08-11; conference-relay primitives added 2026-10-24) ## Status Snapshot @@ -49,6 +49,109 @@ later that week. Also present on `master` today: - **Fork-based multi-process IPC integration tests**, exercising real cross-process replication rather than in-process fan-out. +## Landed for the conference relay (S7, 2026-10) + +The Ariadne conference relay's spatial interest (`ARIADNE_CONFERENCE_ROADMAP.md` +§5h.1) needed per-peer, outbound filtering in this layer. Each piece below is +opt-in, or a no-op until a caller installs something; the policy (who gets +what) stays with the caller, here `room_aoi_relay`. None of it is access +control: a peer with no filter still receives everything. + +- **Per-peer delivery predicate** (`state_peer_registry.h`). A + `state_delivery_filter` (`bool deliver(const std::string &path) const + noexcept`) is installed per known peer with `set_filter(node_id, filter)` + (false for an unknown peer; `nullptr` clears). `should_deliver` now passes + only when the peer's subscription prefixes match **and** its filter + delivers; an unknown peer still delivers everything (the back-compat + default). The `shared_ptr` is copied under the registry lock and evaluated + outside it, and a displaced filter is released after the lock drops. + `view_of(node_id)` returns a `delivery_view` (`known`, `subscriptions`, + `filter`, `deliver()`, `filtered()`) so a batch sender takes one lock per + connection, not per entry. `add_peer` on an existing id keeps its filter; + `remove_peer` drops it. Filters must be immutable once installed and must + never call back into the registry or a transport. The conference filter is + **deny-only and fails open** — a new leaf family can never be dropped + silently, which a prefix whitelist would do — and that is the recommended + shape. +- **Write-once connection identity** (gRPC + WS). The reader thread writes a + connection's peer id once, at its first Hello, and publishes it with a + release/acquire `identified` flag; every off-thread reader goes through + `peer_id()` (`nullptr` until then, or when that Hello named no node: + "unknown, deliver"), and a later Hello is ignored. (On gRPC a Hello with an + empty node id does not count as the first.) This fixes a data race: the + reader thread wrote `remote_node_id` while `publish()` read it unlocked. +- **The registry on every room-state send.** Before S7 only a transport's own + publish paths (`publish()`, `publish_message()`) consulted it, and the + `set_relay` forwards ignored it. Now so do both `set_relay` forward loops + (and so the bridge leg, which goes through the destination transport's + `publish()`), the gRPC snapshot responder (by the requester's view), and + the WebSocket ~1 Hz `broadcast_snapshot` (each connection gets only the + entries its view passes; since then the broadcast is also a per-connection + diff, see `state_transport_ws.h`). A skip is counted by + `note_delivery_filtered`. The admit + snapshot (`send_snapshot_locked`) stays unfiltered by design: it goes out + before the peer's Hello. On the client side, unsolicited-snapshot ingest + now skips entries a shard with enforced interest would reject, so a + relay's whole-tree snapshot no longer writes outside the client's interest. +- **Targeted snapshot push** — `state_transport::push_snapshot_to(node_id, + entries)`. gRPC and WS write one final, `request_id` 0 `SnapshotResponse` + holding exactly `entries` to every live connection identified as + `node_id` and return the number of connections written (0, with nothing + encoded, when none match or `entries` is empty; WS also skips a connection + with more than 2 MB of send backlog). The registry is not consulted: the + caller already decided. Called from the same thread as `publish()`, a push + lands ahead of later live writes on that connection. inproc/ipc keep the + base default (0). Clients apply it as any unsolicited snapshot, which never + blocks later last-writer-wins writes. +- **Relay interest gate** — `set_relay_requires_interest(bool)` on both + transports (default off), with `total_relay_withheld()`. When on, a + forwarded mutation or message that no local same-cluster shard `admits()` + / `admits_message()` (the ingest gate, exposed on `state_cluster_shard`: + a delegation op always passes, anything else when its path is of interest) + is withheld from **both** the relay fan and the bridge. Ingest is + unchanged, and with no matching local shard everything is forwarded. +- **Journal trim and O(new) replay.** `state_change_journal::replay_after` + is a binary search (sequences are strictly increasing because `append` + assigns them under the lock), and `trim_through(sequence)` erases a + prefix while numbering continues. `state_cluster_shard::trim_published()` + trims through the publish cursor, so nothing undrained is lost. It is for a + node whose joiners bootstrap from the snapshot instead of a journal backfill + — set `set_backfill_on_connect(false)` with it, or late joiners miss the + trimmed writes; a rewind into the trimmed range replays only what survived. + arisrv does both on its relay (`--relay-journal trim`); clients are not + trimmed. +- **Rooted client shard.** The fourth `state_cluster_shard` constructor + argument, `root_path`, scopes what the adapter journals to one subtree. + arigl now passes `"conf"` (`CVC_ARIGL_REPLICATE_ROOT` overrides it; empty or + `*` is the whole tree), so per-document `ui.docs.*` state is never + journaled, drained or sent. Interest enforcement still filters what comes + in. +- **Media gate and gRPC stall drop** (`media_endpoint.h`, both transports). + `set_media_gate(media_gate_fn)`, where `media_gate_fn` is + `std::function`, is asked once per identified receiver per frame on + the router fan-out (`fan_media`); an unidentified receiver always gets the + frame, a node's own uplink (`publish_media_frame`) is never gated, and an + empty gate is the old full fan-out. Denials count in `total_media_gated()`. + The gate runs on the publisher's reader thread with no lock held, so it + must be cheap and must not throw. gRPC adds `set_media_stall_drop(ms)` + (0 = off, the library default): a receiver whose in-flight media write + started longer ago than that, or whose stream a frame cannot get within + it (`write_mu` is a timed mutex), is skipped (`total_media_stall_dropped()`), + the counterpart of the WS leg's backlog drop. That drop is now + `set_media_backlog_drop(bytes)` (32 KB by default, 0 = off), and its skips + count in `total_media_backlog_dropped()`. arisrv's 5 s AOI line prints the + count as `backlog`. The gate is asked only once the stream is held, so a + dropped frame spends no thumbnail. The relay loopback tests turn both drops + off, so they don't shed a frame on a loaded runner. +- **Accepted-stream lifetime (gRPC).** A server handler's stream dies when + `Channel()` returns, while publish, relay forwards and the link probe write + to it from other threads. The handler now cancels the stream and takes + `write_mu` once before returning; every write runs under `write_mu` and + checks `alive` there, so none is in flight or starts after the stream goes + (it was a use-after-free on every disconnect, seen as crashes in + `CompletionQueue::Pluck` under parallel load on Windows). + ## Known gaps in the "landed" surface These are implemented on the receive side or in isolation, and are easy to @@ -64,7 +167,14 @@ mistake for finished: guard, the legacy oversized-`value()` resolver). - **Backpressure and slow-peer isolation are `inproc`-only.** The bounded outbox, drop policies and quarantine live in `state_transport_inproc`; the - IPC and gRPC transports publish synchronously with no bounded queue. + IPC and gRPC transports publish synchronously with no bounded queue. (The + gRPC media stall drop above covers media frames only. The WebSocket leg's + backlog skips read `SIOCOUTQ`, which is Linux-only, so they never fire on a + Windows-hosted relay.) +- **Snapshot coverage differs by transport.** WebSocket has no + `request_snapshot` (`WS_STATE_TRANSPORT.md` Phase 2), and gRPC has no + counterpart of the WS ~1 Hz `broadcast_snapshot`, so a gRPC client has no + periodic recovery backstop (it can still call `request_snapshot`). - **`distributed_state_config::blob_store_path` is not read.** `join()` unconditionally constructs a `memory_state_blob_store`, so the field — which `USAGE.md` documents as selecting filesystem persistence — currently has no @@ -81,7 +191,11 @@ mistake for finished: to `master`. The journal is also **in-memory only** (`std::vector`), so a -process restart loses all replay history; there is no durable/WAL log. +process restart loses all replay history; there is no durable/WAL log. Since +S7 a node can trim it (`trim_published()`, above), but only a node that turns +backfill off may. A client's journal is not trimmed and still grows with its +own writes: its dial-time backfill re-sends what it wrote while disconnected, +so client compaction has to preserve that and is still open. ## Related: state_exec DSL Engine diff --git a/docs/roadmap/VISIBILITY-AND-LOD-ROADMAP.md b/docs/roadmap/VISIBILITY-AND-LOD-ROADMAP.md index 9a91bd736..219a1366f 100644 --- a/docs/roadmap/VISIBILITY-AND-LOD-ROADMAP.md +++ b/docs/roadmap/VISIBILITY-AND-LOD-ROADMAP.md @@ -1,7 +1,7 @@ # Visibility, Culling & LOD — Design & Roadmap **Module:** `cvc::vis` (headless, in `inc/cvc/vis/` + `src/cvc/vis/`) plus a thin VTK-facing adapter in `cvcGL`. -**Status:** **LANDED (PR #249+).** The geometry/scene LOD described here is implemented — `inc/cvc/lod/select.h` (rung selection + hysteresis + the budget `solve()`), `lod/pyramid.h`, `cvc/geometry/simplify.h` (native QEM), `lod/tiles.h` (partition + tiled pyramids), `lod/store.h` (HDF5 `scene.cvch5`), the cvcGL `LodGraphicsNode`, and `cvc::vis` culling. The design-notes framing below predates the implementation — read it as rationale, not a TODO. **Still open:** physics LOD (none yet) and the runtime residency/paging driver (HLOD §4.2) — tracked in `ARIADNE_CONFERENCE_ROADMAP.md` §5h (H2/H3). +**Status:** **LANDED (PR #249+).** The geometry/scene LOD described here is implemented — `inc/cvc/lod/select.h` (rung selection + hysteresis + the budget `solve()`), `lod/pyramid.h`, `cvc/geometry/simplify.h` (native QEM), `lod/tiles.h` (partition + tiled pyramids), `lod/store.h` (HDF5 `scene.cvch5`), the cvcGL `LodGraphicsNode`, and `cvc::vis` culling. The design-notes framing below predates the implementation — read it as rationale, not a TODO; the §4.1 status note lists which `cvc::vis` and cvcGL files actually exist, and how the Ariadne room uses LOD. **Still open:** physics LOD (none yet) and the runtime residency/paging driver (HLOD §4.2) — tracked in `ARIADNE_CONFERENCE_ROADMAP.md` §5h (H2/H3). **Provenance markers used throughout:** **[M]** measured on the target machine (Release, 32-core CUDA box, GTX 1650, 1280×800, vsync **off**), **[D]** derived arithmetically from measured values, **[E]** estimated. --- @@ -967,6 +967,17 @@ src/cvcGL/examples/ `src/cvcGL/CMakeLists.txt` uses `file(GLOB CVCGL_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp")` (verified, line 34), so **every cvcGL-side source file lands with no CMake edit at all** — which keeps us off line 24 (`CVCGL_VTK_COMPONENTS`) and lines 281–292 (the examples block), both hot for PR #223. +**Status note (2026-10-24, verified against the tree after the conference's S7).** The manifest above is the design, not the tree: + +- **`cvc::vis`** has `types.h`, `scene_view.h`, `view_params.h`, `visible_set.h`, `grid_index.h` and `reference.h` (plus `mesh_tiler.h`, which the manifest does not list), with sources for `types`, `grid_index`, `reference` and `mesh_tiler` and the `vis_frustum` / `vis_grid` / `vis_oracle` / `vis_tiler` tests. The cull pipeline, stages and seeds, `spatial_index` / `linear_index` / `quadtree_index`, `terrain_field`, `portal_graph`, `lod.h` / `hlod.h`, `regime.h`, `registry.h` and `manifest.h` are not there. +- **The cvcGL adapter does not exist**: no `ViewParams`, `VisCuller`, `CullAction`, `BatchedScene` or `SwayShader`, and no cvcGL source includes `cvc/vis/`. What cvcGL has is LOD *selection* on the `cvc::lod` side: `LodGraphicsNode` and `SceneGraph::selectLOD`, fed by `make_view_params(renderer, …)` in `LodGraphicsNode.h` (a `cvc::lod::view_params`, not a `cvc::vis` one), and, new in S7, `LodPyramidCache` (`inc/cvc/gl/LodPyramidCache.h`) plus `realize_scene`'s `RealizeOptions`. + +**How the Ariadne room uses it** (S7; `ARIADNE_CONFERENCE_ROADMAP.md` §5h.1). `room_view` owns one `LodPyramidCache` for its lifetime, so a whole-scene re-realize re-attaches a cached ladder with no QEM pass. The cache key is `lod::content_hash` at 1 mm mixed with the mesh-build `pyramid_params`; a miss draws the mesh as rung 0 at once and builds one ladder per key on a single `async_lane` with `build_mesh_pyramid(…, pool = nullptr)` — never a `thread_pool` (its `std::thread` aborts on non-pthread wasm) and never `store.h` (HDF5, absent on wasm). Without threads the lane is deferred, one build per `poll()` on the render thread, so the build cap is 60k triangles there (2M threaded): the 69,473-triangle bunny stays at rung 0 on the deployed single-threaded cyprty, and the refusal is logged once. `realize_scene(…, RealizeOptions{&cache})` makes a node a `LodGraphicsNode` only for a file/URI mesh of at least `lod_min_tris` (4096) with no texture, shader, clip, body or children, styling every rung with `apply_node_material`. Each frame `room_view` runs `selectLOD` from its single camera (`preset_view(balanced)` natively, `aggressive` under `__EMSCRIPTEN__`) and invalidates the shadow bake when a rung changes; `room_view::set_lod(false, …)` (arigl: `CVC_ARIGL_LOD=0`) pins rung 0. Physics never sees a rung. The default room's plane (2 triangles) and box (12) get no LOD node at all; `arisrv --scene large` seeds 24 bunnies for it. + +**Not used by the room, and why.** `RenderView` / `VisibilityMask` (`src/cvcGL/RenderView.cpp`) drive the separate `cvc::gl::Node` graph (`nodes.h` / `traversal.h`), not `SceneGraph`, and arigl has one view and one camera; the conference roadmap's claim that H1 uses them is corrected there, and per-camera views wait for a PiP or minimap (`CVCGL-UI-DSL-ROADMAP.md` §9.5, stage C). No `cvc::vis` culling runs in the room: at N ≤ 64 avatars VTK's own per-prop frustum culling is enough. + +**Follow-ups owned here:** the cvcGL `ViewParams` adapter (`vtkRenderer` → `cvc::vis::view_params`), avatar frustum / screen-size culling and nearest-*K* label caps (all inert at room scale); and a world-scale fix in `LodGraphicsNode::select`, which compares a ladder's mesh-unit errors with world distances, so a transform-scaled node coarsens at the wrong distance (the large demo scene sizes its bunnies with a replicated fit instead). The `lod::solve` budget glue (`preset_budget`) over `LodGraphicsNode`s goes to H3 with the residency driver. + ### 4.2 Core headers ```cpp diff --git a/examples/ariadne/documents/conference_roster.ari b/examples/ariadne/documents/conference_roster.ari index 57a367dc1..2c09c8f8d 100644 --- a/examples/ariadne/documents/conference_roster.ari +++ b/examples/ariadne/documents/conference_roster.ari @@ -21,6 +21,27 @@ meta: author: cvc min_libcvc: 3.6.0 +# The room's LOOK. The relay owns what is IN the room (conf.rooms..scene: the ground, the +# reference box; the avatars), and every client draws it under this document's lighting, so the +# rig lives here, not in the server. A three-point rig staged over the 20 m floor: the key spot casts +# real-time shadows from the scene objects and every avatar (re-baked each frame, so a walking +# avatar's shadow walks with it); fill, back and environment keep the shaded sides readable. +scene: + lights: + - light: room_rig + rig: three_point + stage: { center: [0, 0, 1], radius: 10 } + key: { intensity: 0.9, azimuth: -35, elevation: 55, cone: 48 } + fill: 0.25 + back: 0.25 + warmth: 0.25 + environment: 0.15 + ambient: 0.12 + shadows: + enabled: true + resolution: 2048 + update_interval: 1 + menubar: - menu: Room items: diff --git a/inc/cvc/ariadne/avatar_physics.h b/inc/cvc/ariadne/avatar_physics.h new file mode 100644 index 000000000..76577411c --- /dev/null +++ b/inc/cvc/ariadne/avatar_physics.h @@ -0,0 +1,129 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#ifndef __CVC_ARIADNE_AVATAR_PHYSICS_H__ +#define __CVC_ARIADNE_AVATAR_PHYSICS_H__ + +#include +#include // avatar_intent +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace physics { +class world; +} // namespace physics +namespace ariadne { + +// ---------------- +// cvc::ariadne::avatar_world (H1 physics) +// ---------------- +// The room's avatars as rigid bodies in a Jolt cvc::physics::world: each avatar is a dynamic box +// with a mass, under gravity, colliding with the other avatars and with the room's static objects +// (the ground it stands on, the reference cube, ...). The holon steps it once per quantum: the +// server with every avatar dynamic (the authority), a client with its OWN avatar dynamic and each +// peer a kinematic body held at the pose it is shown at (so the client predicts bumping into it; +// what the peer does in return is the server's call). +// +// An intent DRIVES its avatar (drive()): the horizontal velocity eases toward the intent's at up to +// avatar_body::max_accel, and the body is held upright facing the intent's yaw. Everything else -- +// gravity, a push from a heavier avatar, stopping at the cube -- is the solver's. The avatar's +// material is frictionless (the drive is its grip on the floor), so a still avatar shoved by +// another slides and the drive brings it back to rest. +// +// Poses in and out are the avatar's FEET (body_state.position at floor level, as holon_sync +// replicates and room_view draws); the box's centre is half its height above them. +// +// The world runs in cvc::physics' cross-platform determinism mode, so the server (Linux) and a +// client (Windows, wasm) stepping the same inputs from the same state agree -- what client +// prediction relies on. Creation order is part of a Jolt run, so statics are always created first, +// in the order given. + +// An avatar's physical body. +struct avatar_body { + double mass = 70.0; // kg + double width = 0.5; // m, the box's x and y edge + double height = 1.7; // m + double max_accel = 12.0; // m/s^2: how fast the drive changes the horizontal velocity +}; + +// A static object avatars collide with (the room scene's ground, boxes, spheres). +struct avatar_static { + enum class kind { box, sphere }; + kind type = kind::box; + cvc::physics::vec3 center; + cvc::physics::quat rotation; + cvc::physics::vec3 half_extents{0.5, 0.5, 0.5}; // box + double radius = 0.5; // sphere +}; + +class avatar_world { +public: + // Throws cvc::unsupported_exception when this build has no Jolt backend. + avatar_world(double fixed_dt, const avatar_body &body); + ~avatar_world(); + avatar_world(const avatar_world &) = delete; + avatar_world &operator=(const avatar_world &) = delete; + + // Replace the static objects. Avatars are re-created after them (same pids, same states), so the + // world's creation order stays statics-then-avatars. + void set_statics(const std::vector &statics); + + // Add an avatar standing at `feet` (dynamic: simulated; kinematic: placed by the host). + void add_avatar(const std::string &pid, const cvc::physics::body_state &feet, bool dynamic); + void remove_avatar(const std::string &pid); + bool has_avatar(const std::string &pid) const; + + // Before a step: drive a dynamic avatar from its intent (see above). + void drive(const std::string &pid, const avatar_intent &in); + // Teleport an avatar (feet pose + velocities): a reconcile snap, a respawn. + void place(const std::string &pid, const cvc::physics::body_state &feet); + // A kinematic avatar: arrive at this feet pose by the end of the next step. + void move_kinematic(const std::string &pid, const cvc::physics::pose &feet); + // A dynamic avatar's mass (kg, > 0) from the next step on; it survives set_statics. A kinematic + // avatar has no mass (a no-op). + void set_mass(const std::string &pid, double kg); + + void step(); + + // An avatar's state, at its feet. Throws std::out_of_range for an unknown pid. + cvc::physics::body_state state(const std::string &pid) const; + + const avatar_body &body() const { return _body; } + +private: + struct avatar { + std::uint32_t id = 0; // cvc::physics::body_id value + bool dynamic = true; + double mass = 0.0; // kg; avatar_body::mass until set_mass + }; + std::uint32_t create_avatar(const cvc::physics::body_state &feet, bool dynamic, double mass); + + avatar_body _body; + double _dt; + std::unique_ptr _world; + std::uint32_t _avatar_material = 0; + std::vector _statics; + std::map _avatars; +}; + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_AVATAR_PHYSICS_H__ diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h index a35947323..e4c6c0365 100644 --- a/inc/cvc/ariadne/avatar_sim.h +++ b/inc/cvc/ariadne/avatar_sim.h @@ -42,18 +42,20 @@ namespace ariadne { // slice 3+). The integrator is a linear stub; slice 3 swaps it for // physics::world save/restore stepping behind the same predict/reconcile shape. -// The per-tick input that drives an avatar: a desired world-space linear velocity. -// Purely linear (no orientation integration) so the stub needs no trig and is -// trivially bit-reproducible across platforms. +// The per-tick input that drives an avatar: a desired world-space linear velocity and +// the direction the avatar faces. The position integration is purely linear (no trig), +// so it stays bit-reproducible across platforms; the facing only sets the rotation, +// which reconcile does not compare (its tolerance is positional). struct avatar_intent { cvc::physics::vec3 move; // desired linear velocity, world metres/second + double yaw = 0.0; // facing: radians about +Z from +X (Z-up), chosen by the user }; // A deterministic, headless kinematic avatar: integrate an avatar_intent over // fixed-dt quanta into a cvc::physics::body_state. position += velocity*dt and -// velocity = move; rotation is carried unchanged. Pure double arithmetic, so the -// same intents from the same start at the same dt reproduce BIT-FOR-BIT -- the -// property prediction and reconciliation both rely on. +// velocity = move; the rotation is the intent's facing (yaw about +Z). The position +// is pure double arithmetic, so the same intents from the same start at the same dt +// reproduce it BIT-FOR-BIT -- the property prediction and reconciliation rely on. class avatar_sim { public: avatar_sim() = default; @@ -87,8 +89,11 @@ class predicted_avatar { explicit predicted_avatar(const cvc::physics::body_state &start) : _sim(start) {} // Apply the local player's intent for `tick` and advance the prediction. Ticks - // are expected monotonically increasing (the clock's quanta). + // are expected monotonically increasing (the clock's quanta). The log keeps at + // most kMaxLog unacknowledged entries (the oldest drop): with no acks for that + // long the link is gone, and the next ack snaps + replays whatever is left. void predict(std::uint64_t tick, const avatar_intent &in, double dt); + static constexpr std::size_t kMaxLog = 2400; // 20 s at the default 120 Hz // Reconcile against an authoritative state valid as of `server_tick`. Returns // true if a hard correction (snap + replay) was applied, false if the prediction diff --git a/inc/cvc/ariadne/conference/room_aoi.h b/inc/cvc/ariadne/conference/room_aoi.h new file mode 100644 index 000000000..940f294be --- /dev/null +++ b/inc/cvc/ariadne/conference/room_aoi.h @@ -0,0 +1,275 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#ifndef __CVC_ARIADNE_CONFERENCE_ROOM_AOI_H__ +#define __CVC_ARIADNE_CONFERENCE_ROOM_AOI_H__ + +#include +#include +#include // state_delivery_filter +#include +#include +#include +#include +#include +#include + +namespace cvc { +class app; + +namespace ariadne { +namespace conference { + +// ---------------- +// Spatial area of interest (AOI) in the shared room +// ---------------- +// The relay decides, for every ordered pair (receiver r, subject q) of present participants, how +// much of q the client r needs, from their distance in the room's XY plane: +// +// inner everything: q's live pose stream, full video, audio +// middle q's pose as relay PUSHES at a reduced cadence, camera thumbnails, audio +// outer q's pose as pushes, no media at all +// +// Tiers have hysteresis (fast attack, slow release): an upgrade happens at once when the distance +// falls inside the better tier's enter radius (outer can go straight to inner), while a downgrade +// moves ONE tier and only after the distance has stayed beyond the current tier's exit radius for +// downgrade_dwell_s. A pair pacing along a boundary therefore flips at most about once per dwell. +// +// STATE is filtered per receiver by a DENY-only predicate (aoi_state_filter, installed on the +// transports' peer registries), and it fails open: an unknown participant, an unknown leaf family, +// a receiver the relay has not placed yet and any path outside the participants subtree all +// deliver, so a new leaf family is never dropped silently. Under +// conf.rooms..participants....., a receiver r is denied only: +// - another participant's intent (q != r): only the relay and the intent's owner read it +// - q's whole pose subtree, server_tick included, unless tier(r, q) == inner (non-inner pairs +// get the pose as pushes instead, so a receiver never sees a fresh tick over stale leaves) +// - a departed q's pose and intent (its soft-left subtree lingers as a ghost; its net and voice +// leaves deliver, as a present participant's do, so a same-pid reconnect's writes are never +// withheld). The relay remembers the most recent room_aoi::kMaxDeparted departures; an older +// ghost falls back to the unknown-pid rule and delivers. +// The receiver's own subtree always delivers, as do the presence, profile, net, streams and voice +// of present participants (the roster stays room-wide), the bare participants. node, and the +// room's scene, server and aoi subtrees. +// +// MEDIA is decided per frame (aoi_snapshot::media): +// inner delivered; with video_full_max > 0, a camera outside the receiver's nearest +// video_full_max subjects (swapped only past video_rank_margin_m) is a thumbnail +// middle a camera in mjpeg is a thumbnail (MJPEG is intra-only, so any subset of its frames +// decodes; the relay's gate sends thumb_fps of them); any other video codec is +// delivered (never decimated) and so is the mic +// outer dropped +// An unknown receiver or publisher is delivered. +// +// POSE PUSHES (room_aoi::take_pose_pushes) carry a non-inner subject's whole pose subtree to a +// receiver: immediately when a pair enters inner and for a new pair that starts middle or outer; +// every aoi_pose_push_interval_ticks while the subject moves; and every aoi_pose_refresh_ticks as a +// refresh while it stands still. enabled = false delivers everything and pushes nothing. +// +// THREADING: room_aoi runs on the relay's main loop only. An aoi_snapshot is immutable once +// published and an aoi_state_filter only reads one, so both are safe on any thread at once; +// deliver_path, media and tier are noexcept and allocate nothing. + +// Not near/far: (minwindef.h) defines both as macros. +enum class aoi_tier : std::uint8_t { inner, middle, outer }; + +struct aoi_params { + bool enabled = true; // false: deliver everything, push nothing + std::string preset = "conference"; + // Tier radii (metres, XY distance). Each enter radius is at most its exit radius, and + // inner_exit_m is at most middle_enter_m. The defaults are the conference preset. + double inner_enter_m = 30.0; + double inner_exit_m = 32.0; + double middle_enter_m = 40.0; + double middle_exit_m = 44.0; + double downgrade_dwell_s = 1.0; + double pose_push_hz = 5.0; // pushes to non-inner receivers while the subject moves; 0 = off + double pose_refresh_s = 5.0; // pushes to non-inner receivers while it stands still; 0 = off + double thumb_fps = 2.0; // the relay's thumbnail rate for a decimated camera + double video_rank_margin_m = 1.0; + double audio_fade_m = 2.0; // the client's gain reaches 0 at middle_enter_m over this distance + int video_full_max = 0; // 0 = every inner camera at full rate + bool audio_rolloff = false; // the client's gain falls off as inner_enter_m / d past inner_enter_m +}; + +// The 20 m standup room: every pair on the [-10,10]^2 plane (diagonal 28.3 m) is inner at first +// sight and stays inner, so pose, video and audio are exactly what they were without AOI. +// 30/32 m inner, 40/44 m middle, no rolloff. +aoi_params conference_aoi_params(); + +// A hall to spread out in: 10/12 m inner (the 4 m spawn ring keeps every joiner inner), 16/18 m +// middle, and a 1/d audio rolloff that fades to silence at 16 m. +aoi_params hall_aoi_params(); + +// The tier at first sight: the best tier whose enter radius holds D_M. +aoi_tier initial_tier(double d_m, const aoi_params &p) noexcept; + +// One hysteresis step from CUR at distance D_M and time NOW_S (seconds, >= 0). BEYOND_SINCE_S is +// the pair's dwell timer, owned by the caller: negative while the distance is within the current +// tier's exit radius (start every pair at -1), otherwise when it went beyond. +aoi_tier step_tier(aoi_tier cur, double d_m, double now_s, double &beyond_since_s, + const aoi_params &p) noexcept; + +// The client's playback gain for a peer D_M away: 1 when !p.enabled; with audio_rolloff, +// (std::min)(1, inner_enter_m / d); either way times a linear fade from 1 at +// middle_enter_m - audio_fade_m to 0 at middle_enter_m. The fade ends at the UPGRADE threshold, so +// both the relay's cut (outer) and its resume (back to middle) happen in silence. +float aoi_audio_gain(double d_m, const aoi_params &p) noexcept; + +// The pose push cadence in holon ticks at TICK_HZ (the holon's quantum, 120 Hz by default): +// round(tick_hz / pose_push_hz) and round(pose_refresh_s * tick_hz). 0 = that push is off. +std::uint64_t aoi_pose_push_interval_ticks(const aoi_params &p, double tick_hz = 120.0) noexcept; +std::uint64_t aoi_pose_refresh_ticks(const aoi_params &p, double tick_hz = 120.0) noexcept; + +enum class aoi_media : std::uint8_t { deliver, thumbnail, drop }; + +// One room's AOI decisions at one moment. Built by room_aoi and never changed after it is +// published, so any thread may query it while the relay builds the next one. +class aoi_snapshot { +public: + // An empty snapshot knows no participant, so it delivers everything. + aoi_snapshot() = default; + + // Whether RECEIVER gets the state node at PATH (the rules above). + bool deliver_path(const std::string &receiver, const std::string &path) const noexcept; + + // What RECEIVER gets of PUBLISHER's STREAM_ID ("camera" | "mic") frames in CODEC. + aoi_media media(const std::string &receiver, const std::string &publisher, + const std::string &stream_id, const std::string &codec) const noexcept; + + // The pair's tier; inner for oneself and for any pair this snapshot does not know. + aoi_tier tier(const std::string &receiver, const std::string &subject) const noexcept; + + // 0 for an empty snapshot, then +1 per snapshot a room_aoi publishes. + std::uint64_t generation() const noexcept; + + // False when built from aoi_params::enabled == false (everything delivers). + bool enabled() const noexcept; + + // The present participants, in pid order. + const std::vector &participants() const noexcept; + +private: + friend class room_aoi; + + static constexpr std::size_t npos = static_cast(-1); + + // The index of PID in _pids, or npos. + std::size_t index_of(std::string_view pid) const noexcept; + bool is_departed(std::string_view pid) const noexcept; + + bool _enabled = true; + bool _video_capped = false; // video_full_max > 0: _ranked applies to inner cameras + std::uint64_t _generation = 0; + std::string _prefix; // conf.rooms..participants. (with the trailing separator) + std::vector _pids; // present, sorted + std::vector _tiers; // aoi_tier, _pids.size()^2, row = receiver, column = subject + std::vector _ranked; // same layout: 1 = in the receiver's full-video set + std::vector _departed; // sorted +}; + +// The registry filter for one receiver's connection: a snapshot's deliver_path bound to RECEIVER. +// A null snapshot delivers everything. +class aoi_state_filter final : public cvc::state_delivery_filter { +public: + aoi_state_filter(std::shared_ptr s, std::string receiver); + bool deliver(const std::string &path) const noexcept override; + +private: + std::shared_ptr _snap; + std::string _receiver; +}; + +// The subjects whose whole pose subtree is due to RECEIVER now. +struct aoi_push { + std::string receiver; + std::vector subjects; // in pid order +}; + +// The relay's AOI state for one room: per-pair tiers with their dwell timers, each receiver's +// full-video set, the pose push schedule, and the latest published snapshot. Main loop only. +class room_aoi { +public: + struct position { + std::string pid; + double x = 0.0; + double y = 0.0; + }; + + room_aoi(std::string room, aoi_params p); + ~room_aoi(); + room_aoi(room_aoi &&) noexcept; + room_aoi &operator=(room_aoi &&) noexcept; + room_aoi(const room_aoi &) = delete; + room_aoi &operator=(const room_aoi &) = delete; + + // Take the present participants and their positions (a pid listed twice counts once, at its + // first position): new pairs start at initial_tier, existing ones step_tier at NOW_S, and anyone + // no longer present becomes departed (a rejoin under the same pid clears that). O(N^2). Returns + // true when the decisions changed, in which case snapshot() is a new one; otherwise snapshot() + // returns the same object as before. + bool update(const std::vector &present, std::uint64_t now_tick, double now_s); + + // The pose pushes due at NOW_TICK (the relay holon's tick), one entry per receiver with any due, + // in pid order. POSE_VERSION[q] is the tick at which q last moved (a missing q counts as 0). A + // pair is due at once after update() made it enter inner, or created it middle or outer; a + // non-inner pair is also due when q's version changed since that pair's last push and the push + // interval has passed, or when the refresh interval has passed. Inner pairs are otherwise never + // due (the live stream carries them). Records the pushes as sent; nothing when !enabled. + std::vector take_pose_pushes(std::uint64_t now_tick, + const std::map &pose_version); + + std::shared_ptr snapshot() const; + + // Everyone who was present at some update and is not now, in pid order: the most recent + // kMaxDeparted of them (arigl takes a fresh pid per launch, so departures never stop coming). + const std::set &departed() const; + static constexpr std::size_t kMaxDeparted = 1024; + + const aoi_params ¶ms() const noexcept; + const std::string &room() const noexcept; + +private: + // The present members and their per-pair state. Defined in room_aoi.cpp over file-local types, + // so its containers are not exported from cvc.dll (whose export table is nearly full). + struct book; + + void publish(); + void depart(const std::string &pid); + + std::string _room; + aoi_params _p; + std::string _prefix; + std::uint64_t _generation = 0; + std::unique_ptr _book; + std::set _departed; + std::shared_ptr _snap; +}; + +// conf.rooms..aoi +std::string room_aoi_path(const std::string &room_id); + +// Replicate P under conf.rooms..aoi. (one leaf per aoi_params field, named after it) +// so clients can derive their audio gain. A leaf that already holds its value is not rewritten. +void write_aoi_params(cvc::app &app, const std::string &room, const aoi_params &p); + +// Read them back. Returns false (OUT untouched) if they were never written; a missing leaf keeps +// OUT's value. +bool read_aoi_params(cvc::app &app, const std::string &room, aoi_params &out); + +} // namespace conference +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_CONFERENCE_ROOM_AOI_H__ diff --git a/inc/cvc/ariadne/conference/room_aoi_relay.h b/inc/cvc/ariadne/conference/room_aoi_relay.h new file mode 100644 index 000000000..243c89e44 --- /dev/null +++ b/inc/cvc/ariadne/conference/room_aoi_relay.h @@ -0,0 +1,154 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#ifndef __CVC_ARIADNE_CONFERENCE_ROOM_AOI_RELAY_H__ +#define __CVC_ARIADNE_CONFERENCE_ROOM_AOI_RELAY_H__ + +#include +#include +#include +#include // body_state +#include +#include +#include +#include +#include + +namespace cvc { +class app; +class media_endpoint; +class state_cluster_shard; +class state_transport; + +namespace ariadne { +class holon; + +namespace conference { + +// ---------------- +// The relay's area-of-interest driver +// ---------------- +// Runs a room_aoi against the relay's SERVER holon and applies its decisions to the relay's +// transports, once per room step (step(), or relay_room_step below): +// +// STATE every present participant is a peer in every added transport's registry, subscribed +// to "conf", with an aoi_state_filter bound to the latest snapshot; a departed one is +// unregistered again (unknown peers deliver everything). Transports consult the registry +// on every room-state send -- publish, relay forward, snapshot reply, broadcast -- so +// the filter applies to the relay's own writes and to the clients' alike. A peer the +// registry knew before this driver registered it keeps its entry; only the filter is +// installed and later cleared. +// POSES the pushes room_aoi says are due go to each receiver as ONE targeted snapshot frame +// (state_transport::push_snapshot_to, on every transport: only the one holding the +// receiver's connection writes) of the subjects' pose subtrees, intermediate nodes left +// out. Each subject's server_tick is re-stamped with the holon's CURRENT tick and is +// its last entry: the tree's tick of a subject standing still can be a heartbeat old, +// and a client re-aligns its whole clock on a stale one. The other leaves equal the +// current state to within write_pose's 1e-4, as server_pump has just written them. +// MEDIA a gate on every added media endpoint asks the snapshot per frame and receiver +// (aoi_snapshot::media): deliver, drop, or a thumbnail -- the first frame once the +// pair's decision becomes a thumbnail, then thumb_fps of them. +// +// A snapshot is rebuilt only when room_aoi reports a change, and the registries and gate are +// updated only then. aoi_params::enabled = false makes the driver a pass-through: nothing is +// registered, every frame is delivered and nothing is pushed. +// +// LIFETIME: the shard, the transports and the media endpoints must outlive the driver. Its +// destructor clears the gates it installed and the registrations and filters it made. The gate +// closure owns the state it reads (never the driver), so a reader thread still inside a copy of +// it when the driver goes away finishes safely, and from then on that copy delivers everything. +// +// THREADING: construction, add_*, step, take_stats and destruction on the relay's main loop (the +// thread that writes the room tree and calls publish()). The gate runs on publisher reader +// threads: it reads an immutable snapshot through a mutex-guarded pointer, keeps its thumbnail +// timers under a mutex of their own, and holds no lock across a socket write. +class room_aoi_relay { +public: + struct stats { + std::size_t inner = 0; // present pairs (unordered) in each tier, as of now + std::size_t middle = 0; + std::size_t outer = 0; + std::uint64_t pushes = 0; // push frames written (one per receiving connection) + std::uint64_t push_entries = 0; // snapshot entries in them + std::uint64_t media_full = 0; // frames the gate passed to a receiver whole + std::uint64_t media_thumb = 0; // thumbnail frames it passed + std::uint64_t media_dropped = 0; // frames it withheld: outer, or decimated to the thumb rate + std::uint64_t state_filtered = 0; // registry deliveries filtered for registered participants + }; + + room_aoi_relay(cvc::app &app, cvc::state_cluster_shard &shard, std::string room, aoi_params p); + ~room_aoi_relay(); + + room_aoi_relay(const room_aoi_relay &) = delete; + room_aoi_relay &operator=(const room_aoi_relay &) = delete; + + // A transport whose registry gets the participants' filters and which carries the pushes. One + // added after a snapshot exists gets that snapshot's filters at once. Null or a repeat: ignored. + void add_transport(cvc::state_transport *tp); + + // An endpoint whose fan-out the media gate decides (replacing any gate it had). Null or a + // repeat: ignored. + void add_media_endpoint(cvc::media_endpoint *ep); + + // One room step, after server_pump has written this step's poses and BEFORE the step's drain + // (relay_room_step keeps that order): read every participant's position and motion from SERVER + // (no tree walk), update the AOI, re-install filters and the gate's snapshot if it changed, + // then write the pushes that are due. NOW_S: seconds on a steady clock, >= 0. + void step(const cvc::ariadne::holon &server, double now_s); + + // Counters since the last call (the tier counts are a reading of the current snapshot). + stats take_stats(); + + std::shared_ptr snapshot() const; + const aoi_params ¶ms() const noexcept; + +private: + struct gate_state; + // Each added transport's registrations and the subjects' pose versions. Defined in + // room_aoi_relay.cpp over file-local types, so its containers are not exported from cvc.dll + // (whose export table is nearly full). + struct book; + + void apply(const std::shared_ptr &snap); + void push_poses(std::uint64_t tick); + + cvc::app &_app; + cvc::state_cluster_shard &_shard; + room_aoi _aoi; + std::vector _endpoints; + std::unique_ptr _book; + std::uint64_t _pushes = 0; + std::uint64_t _push_entries = 0; + std::shared_ptr _gate; +}; + +// The relay's per-step fan-out of its OWN room-tree writes (poses, roster retractions, stream +// markers, net stats, the scene): AOI->step first, so every push precedes this step's live writes +// on each connection; then ONE drain_local of SHARD, each mutation published to every plane in +// order (publish writes only to that transport's connections and never crosses the bridge, so +// each client gets it once, and two transports sharing the shard's one publish cursor both send +// it); then, with TRIM_JOURNAL, the published journal is erased (shard.trim_published) -- for a +// relay whose late joiners bootstrap from the admit snapshot, i.e. with backfill on connect off. +// With AOI null and TRIM_JOURNAL false it is exactly that drain and publish, nothing else (SERVER +// and NOW_S are then unused). Returns the number of mutations drained. Main loop only. +std::size_t relay_room_step(cvc::state_cluster_shard &shard, + const std::vector &planes, room_aoi_relay *aoi, + const cvc::ariadne::holon &server, double now_s, bool trim_journal); + +} // namespace conference +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_CONFERENCE_ROOM_AOI_RELAY_H__ diff --git a/inc/cvc/ariadne/conference/room_net.h b/inc/cvc/ariadne/conference/room_net.h index 7d280853b..00db5d279 100644 --- a/inc/cvc/ariadne/conference/room_net.h +++ b/inc/cvc/ariadne/conference/room_net.h @@ -73,6 +73,25 @@ bool read_server_net(cvc::app &app, const std::string &room, server_net &out); std::string format_rtt(double rtt_ms); std::string format_rate(double kbps); +// ---------------- +// Voice activity in the shared room tree +// ---------------- +// conf.rooms..participants..voice.talking "1" / "0" +// whether the participant is talking, as its OWN client measures its mic -- written by that +// client (like its .net), so a peer whose audio the relay does not send it (out of range in +// the room) can still light that participant's name. Absent = not talking. +// +// The write is changed-only and journals ONE mutation per change: a repeated value writes nothing, +// and a changed one sets the value without the touch() that would first re-send the old one. +// FORCE is for a writer that tracks its own flips (arigl): the tree may already hold TALKING +// without the relay having it -- a periodic re-broadcast can echo a stale copy of the client's own +// leaf back -- so a forced write of an unchanged value re-sends it (one touch) instead of nothing. +void write_participant_voice(cvc::app &app, const std::string &room, const std::string &pid, + bool talking, bool force = false); +// False (TALKING untouched) when the leaf was never written. +bool read_participant_voice(cvc::app &app, const std::string &room, const std::string &pid, + bool &talking); + } // namespace conference } // namespace ariadne } // namespace cvc diff --git a/inc/cvc/ariadne/conference/room_scene.h b/inc/cvc/ariadne/conference/room_scene.h index 22d067769..23313f38d 100644 --- a/inc/cvc/ariadne/conference/room_scene.h +++ b/inc/cvc/ariadne/conference/room_scene.h @@ -38,12 +38,15 @@ namespace conference { // conf.rooms..scene.rev bumped by every scene write // conf.rooms..scene.env.background.{gradient, top.{r,g,b}, bottom.{r,g,b}} // conf.rooms..scene.env.chrome the SceneGraph diagnostic chrome (1/0) +// conf.rooms..scene.env.shadows.{enabled, resolution} real-time shadow maps // conf.rooms..scene.env.lights..{present, kind, rig, azimuth, elevation, intensity, // cone, color.{r,g,b}, pos.{x,y,z}, target.{x,y,z}} // conf.rooms..scene.objects..{present, source.{uri, primitive, plane_size, // box.{x,y,z}, radius}, transform.{pos,rot,scale}.{x,y,z}, // material.{color.{r,g,b}, ambient, diffuse, specular, // specular_power}, visible} +// conf.rooms..scene.objects..source.fit.{height, up_y} only for an object that has +// (or once had) a fit: -- a mesh asset stood on the ground // // The schema is the .ari scene's own (cvc::ariadne::Scene / SceneNode / SceneLight) restricted to // the fields a shared room needs, so a client turns the live subtree back into a Scene and hands it @@ -64,6 +67,12 @@ struct room_scene_env { float background_top[3] = {0.09f, 0.10f, 0.12f}; float background_bottom[3] = {0.02f, 0.02f, 0.03f}; bool chrome = true; + // Real-time shadows from the shadow-casting lights (directional / spot; a fill never casts), + // re-baked every frame so moving avatars cast moving shadows. `shadow_resolution` is the map + // size in pixels (bigger = crisper, costlier). Off = the room says nothing about shadows, so a + // client keeps whatever its document set (the conference document turns them on). + bool shadows = false; + int shadow_resolution = 2048; // Each light's explicit fields (kind, pos, target, cone, azimuth, elevation, intensity, color) // or a bare rig: preset name. Rig tuning knobs (stage/key/fill/...) are not replicated. std::vector lights; @@ -76,8 +85,10 @@ void write_scene_env(cvc::app &app, const std::string &room_id, const room_scene bool read_scene_env(cvc::app &app, const std::string &room_id, room_scene_env &env); // Write (add or replace) one object. Replicated fields: id, the source (a URI in source_file, or a -// plane/box/sphere source_primitive with its size), the transform, the material colour/ambient/ -// diffuse (+ specular when has_specular), and visible_default. type is always "geometry". +// plane/box/sphere source_primitive with its size), the mesh's fit (has_fit, fit_height, fit_up_y; +// see SceneNode), the transform, the material colour/ambient/diffuse (+ specular when +// has_specular), and visible_default. type is always "geometry". An object without a fit writes +// no fit leaves, unless it is replacing one that had a fit, which it clears. void write_scene_object(cvc::app &app, const std::string &room_id, const cvc::ariadne::SceneNode &obj); @@ -97,8 +108,9 @@ std::uint64_t scene_revision(cvc::app &app, const std::string &room_id); cvc::ariadne::Scene read_room_scene(cvc::app &app, const std::string &room_id); // The MVP environment the server seeds a room with (§5g "ground + grid + a reference object"): a -// gradient background, the diagnostic chrome off, a directional sun, a 20 m ground plane and a 1 m -// reference box standing on it at the origin. Z is up, as in every .ari scene. +// gradient background, the diagnostic chrome off, a 20 m ground plane and a 1 m reference box +// standing on it at the origin. Z is up, as in every .ari scene. No lights and no shadows: the +// room's look is the client document's (conference_roster.ari declares its light rig + shadows). room_scene_env default_scene_env(); std::vector default_scene_objects(); @@ -106,6 +118,23 @@ std::vector default_scene_objects(); // restarted relay does not clobber a scene already in its tree. Returns true if it seeded. bool seed_default_scene(cvc::app &app, const std::string &room_id); +// The large demo scene (arisrv --scene large): room to spread out, and meshes for LOD to work on. +// A SIDE_M ground plane (centred on the origin, like the default's), the default reference box, +// and the embedded Stanford bunny (source.uri "x.bunny", 69,473 triangles) fitted about 1.7 m tall +// at the centre of every cell of a GRID x GRID lattice over the plane -- 20 m apart at the defaults +// -- except a cell centred on the origin (an odd GRID's middle one), which is the reference box's +// and the avatars' spawn ring's. The bunnies are named "bunny__" (cell column, row). They +// are meshes, so they have no physics (room_statics keeps only the plane and the box), and every +// one is the same asset, so a client builds one LOD pyramid for them all. Throws +// std::invalid_argument unless SIDE_M is finite and > 0 and GRID >= 1. The environment is +// default_scene_env(). +std::vector large_demo_scene_objects(double side_m = 100.0, int grid = 5); + +// Seed ROOM-ID with the large demo scene if it has none yet, exactly as seed_default_scene does +// with the default one (idempotent; true if it seeded). +bool seed_large_demo_scene(cvc::app &app, const std::string &room_id, double side_m = 100.0, + int grid = 5); + } // namespace conference } // namespace ariadne } // namespace cvc diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index 9b3e918f0..e762b5757 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -17,14 +17,18 @@ #define __CVC_ARIADNE_HOLON_H__ #include +#include #include #include #include #include #include #include +#include #include +#include #include +#include #include namespace cvc { @@ -70,6 +74,32 @@ struct holon_config { // CLIENT: how far (in ticks) the replica's clock may drift from the server's tick before // apply_authoritative re-aligns it. 60 = 0.5 s at the default 120 Hz. See apply_authoritative. std::uint64_t max_skew_ticks = 60; + // CLIENT with a lead (set_lead_ticks > 0): how far the replica may fall BEHIND its lead target + // (late inputs: the server would apply them after their tick, a correction each time) or run + // AHEAD of it (only delays what peers see) before it re-aligns. + std::uint64_t lead_slack_behind_ticks = 6; + std::uint64_t lead_slack_ahead_ticks = 24; + // CLIENT: the longest walk between two peer samples, in ticks. A server that publishes only + // what changed (write_pose's heartbeat) leaves a long gap before a peer's first sample after + // standing still; walking that sample in over the whole gap would leave the peer far behind. + std::uint64_t max_peer_span_ticks = 30; + // SERVER: how far ahead of its own tick a stamped intent may be (queue_intent); a stamp + // further out (a client whose clock is off) takes effect at once instead of waiting. + std::uint64_t max_intent_lead_ticks = 240; + // Rigid-body avatars in a Jolt world (avatar_world): mass, inertia, gravity, collisions with + // each other and the room's statics (set_statics). Off = the kinematic avatar_sim (no + // collisions). A build without the Jolt backend falls back to avatar_sim with a warning + // (physics() reports which is running). + bool physics = false; + avatar_body body; // with physics: every avatar's body + // SERVER with physics: an avatar that fell off the world (below this z) respawns where it + // joined. + double respawn_below_z = -10.0; + // The VETO (sanitize_intent): the fastest an intent may ask an avatar to move horizontally (m/s; + // a faster ask is scaled down to it), and whether it may ask to move vertically at all (avatars + // walk; gravity, not the client, owns z). + double max_speed = 4.0; + bool allow_vertical = false; }; class holon { @@ -95,34 +125,116 @@ class holon { // Register a participant. `local` marks the CLIENT's own avatar (predicted); on a // server every participant is authoritative, and on a client a non-local // participant is an interpolated peer. + // + // POSE_KNOWN = false (CLIENT): `start` is a placeholder, not a pose -- the host saw the + // participant in the roster before its first authoritative sample. It is a member like any + // other, but pose_known() stays false until apply_authoritative brings that sample, so a + // renderer can leave it undrawn. With physics a non-local one has no kinematic body until then + // (one held at the placeholder would be an invisible obstacle, at the origin, inside the room's + // reference object); the first sample creates it there. The local avatar keeps its body (its + // prediction steps it). A server ignores the flag: it is the authority, so every pose it holds + // is known. void add_participant(const std::string &pid, bool local = false, - const cvc::physics::body_state &start = cvc::physics::body_state{}); + const cvc::physics::body_state &start = cvc::physics::body_state{}, + bool pose_known = true); + // CLIENT: the newest server tick seen from `pid` is remembered when it is removed (left_tick). void remove_participant(const std::string &pid); bool has_participant(const std::string &pid) const; + // CLIENT: the newest server tick seen from `pid` before it was last removed, until a sample of + // it is applied again; 0 otherwise. The pose subtree a departed peer leaves behind in the room + // tree carries a tick no newer, so the host (client_pump) can tell it from a pose for its rejoin. + std::uint64_t left_tick(const std::string &pid) const; // Every registered pid, in pid order. std::vector participants() const; + // Whether `pid`'s pose is real: it was added with pose_known, or a sample has arrived since. + // False for an unknown pid. + bool pose_known(const std::string &pid) const; + // Whether `pid` has a rigid body in the physics world. False without physics, for an unknown + // pid, and for a client peer whose pose is not known yet. + bool has_body(const std::string &pid) const; + // Set a participant's latest intent. Server: the validated input it integrates - // into the authoritative pose. Client: only the LOCAL avatar's own input (a - // non-local pid is ignored -- a client cannot drive a peer). + // into the authoritative pose, from the next quantum on (any queued stamped + // intents are dropped). Client: only the LOCAL avatar's own input (a non-local + // pid is ignored -- a client cannot drive a peer). Either way the intent passes + // sanitize_intent first. void set_intent(const std::string &pid, const avatar_intent &in); + // The server's VETO over a proposed intent (§5h propose -> veto -> override): a + // non-finite move is no move, a non-finite yaw faces +X, the vertical is dropped + // unless allow_vertical, the horizontal speed is capped at max_speed and the yaw + // is wrapped to [-pi, pi]. An honest client applies the same rule to its own + // prediction, so it agrees with what the server will integrate (no correction); + // a client that asks for more is overruled on reconcile. + avatar_intent sanitize_intent(const avatar_intent &in) const; + // SERVER: how many intents the veto has changed. + std::uint64_t vetoes() const noexcept { return _vetoes; } + + // SERVER OVERRIDES (§5h): the authority acts on an avatar at will, and the result + // replicates like any authoritative pose (the owning client reconciles to it). + // Teleport `pid` to `feet` (pose + velocities). False for an unknown pid or on a + // client holon. + bool override_state(const std::string &pid, const cvc::physics::body_state &feet); + // Re-mass `pid` (kg, finite and > 0). Physics only: false without a world, for an + // unknown pid, a bad mass, or on a client holon. + bool set_mass(const std::string &pid, double kg); + + // SERVER: a participant's intent STAMPED with the tick the client applied it from + // in its prediction. It takes effect at that tick -- so the authoritative pose + // matches the prediction exactly when it arrives in time (the client runs ahead by + // its lead, set_lead_ticks) -- or at once when it arrives late (that tick is + // already integrated) or stamped too far ahead (max_intent_lead_ticks). Re-queueing + // the stamp already queued last is a no-op, so a host may re-read the tree every + // frame. A client holon ignores this. + void queue_intent(const std::string &pid, const avatar_intent &in, std::uint64_t tick); + + // Whether avatars are rigid bodies in a Jolt world (holon_config::physics, and a build that + // has the backend). + bool physics() const noexcept { return _world != nullptr; } + + // With physics: the room's static objects (the ground, the cube, ...), from the room scene at + // `revision`. Avatars stand still until the first set_statics (there is nothing to stand on). + void set_statics(const std::vector &statics, std::uint64_t revision); + std::uint64_t statics_revision() const noexcept { return _statics_rev; } + + // CLIENT: run the replica's clock `ticks` AHEAD of the server's (default 0: in step + // with the samples). With a lead, an intent the client applies at its tick T reaches + // the server before the server integrates T, so the server's pose agrees with the + // prediction and reconcile has nothing to correct. The host sets it from the measured + // round trip plus a margin (one way for the sample, one way for the intent). + void set_lead_ticks(std::uint64_t ticks) { _lead = ticks; } + std::uint64_t lead_ticks() const { return _lead; } + // CLIENT ONLY: an authoritative state for `pid`, valid as of `server_tick`, // arrived via replication. The local avatar reconciles to it (snap + replay on // divergence); a peer takes it as its newest interpolation sample. No-op on a // server holon or an unknown pid. // + // A sample at or below the newest one already seen for `pid` is ignored outright + // (a re-read of an unchanged pose is not news, and must not move the clock). The + // first one makes the pose known (pose_known), and gives a peer registered without + // one its kinematic body, at the sample. + // // Reconcile compares ticks, so the replica's clock must count the server's ticks. // The two clocks start independently (a client joins a server that has run for // hours), so a sample more than max_skew_ticks from the replica's tick -- the first // one after joining, or one after a backgrounded browser tab stalled the frame - // loop -- re-aligns the replica: its clock seeks to server_tick and the local - // avatar's unacknowledged input log is dropped (its ticks no longer mean anything; - // its state is kept). Between re-alignments both clocks run from wall time at the - // same rate. + // loop -- re-aligns the replica: its clock seeks to server_tick (+ the lead) and the + // local avatar's unacknowledged input log is dropped (its ticks no longer mean + // anything; its state is kept). With a lead the target is server_tick + lead and the + // tolerance the lead slack (holon_config). Between re-alignments both clocks run + // from wall time at the same rate. void apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, std::uint64_t server_tick); + // CLIENT: how many times apply_authoritative has re-aligned the replica's clock (each seek also + // drops the local avatar's unacknowledged inputs: a visible jump). The first sample after + // joining is one; after that the count should stay flat, and a rising one means samples keep + // arriving with ticks far from the replica's (a stalled frame loop, or a sender stamping stale + // ticks). + std::uint64_t clock_realigns() const noexcept { return _realigns; } + // Advance the holon one frame. SERVER: drives its clock from wall time (wall_dt<0) // or an injected dt, then integrates every authoritative avatar over the quanta // that fell due. CLIENT: drives its clock to predict its own avatar over the due @@ -152,8 +264,19 @@ class holon { private: struct participant { bool local = false; + // CLIENT: its pose is real (added with one, or a sample has arrived); see add_participant. + bool pose_known = true; avatar_sim sim; // server authoritative integrator avatar_intent intent; // latest intent (server input / client-local input) + // SERVER: stamped intents waiting for their tick (queue_intent), in tick order. + std::deque> queued; + bool have_stamp = false; // a stamped intent has been queued (last_stamp is meaningful) + std::uint64_t last_stamp = 0; // the stamp queued last (re-queueing it is a no-op) + // CLIENT: the newest sample tick seen for this participant. A host re-reads an unchanged pose + // every frame (and a server publishing only changes leaves a still avatar's tick old for up to + // a heartbeat), so a sample already seen must not count as a clock reading. + bool have_seen = false; + std::uint64_t last_seen = 0; predicted_avatar predicted; // client-local prediction + reconcile cvc::physics::vec3 correction{0.0, 0.0, 0.0}; // reconcile jumps not yet taken // peer interpolation: the last two authoritative samples + a monotonic guard @@ -164,8 +287,24 @@ class holon { cvc::physics::body_state prev, newest; std::uint64_t newest_arrival_tick = 0; // this clock's tick when `newest` arrived std::uint64_t sample_span = 0; // server ticks from prev to newest (0 = one sample) + // physics: where it joined (a SERVER respawn point), and the CLIENT-local avatar's + // prediction log -- predicted_avatar's, over the Jolt world instead of avatar_sim. + cvc::physics::body_state spawn; + struct logged { + std::uint64_t tick = 0; + avatar_intent in; + cvc::physics::body_state after; // its state after the step at `tick` + }; + std::deque log; + bool acked = false; + std::uint64_t last_acked = 0; }; + // physics: one quantum of the world (the drive, the peers' kinematic poses, the step). + void world_step_client(const std::string &self, const avatar_intent &in); + bool world_reconcile(const std::string &pid, participant &p, const cvc::physics::body_state &auth, + std::uint64_t server_tick); + void on_step(const cvc::clock_driver::clock_frame &f); void on_present(const cvc::clock_driver::clock_frame &f); @@ -174,7 +313,16 @@ class holon { cvc::world_clock _clock; // declared before _driver: _driver references it cvc::clock_driver _driver; std::map _participants; - double _alpha = 0.0; // the last frame's interpolation alpha, for display_pose + // CLIENT: pid -> the newest tick seen before it left (remove_participant), until a sample of it + // is applied; at most kMaxLeft of them (a client sees a fresh pid per launch of every peer). + std::map _left; + static constexpr std::size_t kMaxLeft = 1024; + double _alpha = 0.0; // the last frame's interpolation alpha, for display_pose + std::uint64_t _lead = 0; // CLIENT: ticks ahead of the server (set_lead_ticks) + std::unique_ptr _world; // physics (holon_config::physics + a Jolt build) + std::uint64_t _vetoes = 0; // SERVER: intents sanitize_intent changed + std::uint64_t _realigns = 0; // CLIENT: clock seeks in apply_authoritative + std::uint64_t _statics_rev = 0; }; } // namespace ariadne diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index 5ca3f375a..a8fb7f822 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -17,10 +17,13 @@ #define __CVC_ARIADNE_HOLON_SYNC_H__ #include +#include // avatar_static #include +#include // holon_config #include #include #include +#include namespace cvc { @@ -43,6 +46,8 @@ class holon; // .pose.vel.ang.{x,y,z} // .pose.server_tick the tick the pose is valid at // .intent.move.{x,y,z} proposed, CLIENT-written +// .intent.yaw facing (radians about +Z), CLIENT-written +// .intent.tick the client tick it applies from (0 = at once) // // Scalar leaves (not a typed body_state data() object) are used on purpose: they // already journal and replicate through the existing "conf" interest filter with @@ -61,9 +66,17 @@ class holon; // the §5e/mesh increment (H2); H1 is loopback-trusted sole-writer-by-convention. // Write a participant's AUTHORITATIVE pose (server side): the body_state's pose + -// velocity leaves and the server_tick, each replicated (touch-before-value). +// velocity leaves and the server_tick, each replicated. With HEARTBEAT_TICKS > 0 only +// the leaves whose value changed are written, and the server_tick only when something +// changed or HEARTBEAT_TICKS have passed since it was last written: an avatar standing +// still costs one replicated write per heartbeat, not fourteen per relay tick. 0 is the +// forced rewrite: every leaf every call, unchanged ones too (what re-hydrates a peer +// that missed them). Each written leaf journals ONE mutation: a changed value alone, a +// new (or still empty) leaf touched first so the state adapter observes it before its +// first value, and a forced unchanged leaf a touch. void write_pose(cvc::app &app, const std::string &room, const std::string &pid, - const cvc::physics::body_state &state, std::uint64_t server_tick); + const cvc::physics::body_state &state, std::uint64_t server_tick, + std::uint64_t heartbeat_ticks = 0); // Read a participant's authoritative pose. Returns false (leaving out_* untouched) // if the pose subtree is absent or malformed; true with out_state / out_server_tick @@ -71,19 +84,33 @@ void write_pose(cvc::app &app, const std::string &room, const std::string &pid, bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, cvc::physics::body_state &out_state, std::uint64_t &out_server_tick); -// Write a participant's PROPOSED intent (client side -- its OWN avatar only). +// Write a participant's PROPOSED intent (client side -- its OWN avatar only). TICK is +// the client tick its prediction applies it from (holon::queue_intent); 0 = untimed, +// the server applies it at once. void write_intent(cvc::app &app, const std::string &room, const std::string &pid, - const avatar_intent &intent); + const avatar_intent &intent, std::uint64_t tick = 0); -// Read a participant's proposed intent (server side). Returns false if absent. +// Read a participant's proposed intent (server side). Returns false if absent. When +// OUT_TICK is given it receives the intent's stamp (0 = untimed). bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, - avatar_intent &out_intent); + avatar_intent &out_intent, std::uint64_t *out_tick = nullptr); + +// The holon configuration every conference room uses, on the relay and in every client (they must +// agree: the client predicts with the server's physics): rigid-body avatars in a Jolt world. +holon_config room_holon_config(); + +// The room scene's objects as physics statics (holon::set_statics): the ground plane as a thin +// slab under z = its height, boxes as boxes, spheres as spheres, each at its transform (Euler +// degrees, applied X then Y then Z). Objects with no physical shape (a mesh file) are skipped. +std::vector room_statics(cvc::app &app, const std::string &room); // Drive one authoritative frame of a SERVER holon against the replicated room // tree: for every present participant (room_roster), add it to the holon if new, -// read its proposed .intent, feed it to the holon; advance the holon one frame; -// then write every participant's authoritative .pose + .server_tick back to the -// tree (which replicates to the clients). The caller supplies the server holon +// read its proposed .intent, feed it to the holon (a stamped one queued for its tick, +// holon::queue_intent; an untimed one at once); advance the holon one frame; then +// write every participant's authoritative .pose + .server_tick back to the tree +// (which replicates to the clients) -- what changed, plus a heartbeat of about a +// second while nothing does (write_pose). The caller supplies the server holon // and calls this once per relay tick. A participant who left (room_leave) is dropped // from the holon, so a rejoin respawns on the spawn ring. wall_dt // is forwarded to holon::advance: the default (-1) measures real elapsed wall time @@ -93,16 +120,19 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d // Drive one frame of a CLIENT holon against the replicated room tree (H1 slice 6), the mirror of // server_pump: register every present participant (room_roster) -- SELF_PID as the local, // predicted avatar, everyone else as an interpolated peer, each starting at its replicated pose -// when one exists -- drop the ones that left, apply each replicated authoritative .pose (the holon -// ignores a sample it has already seen, so re-reading an unchanged pose every frame is harmless), -// then advance the holon one frame. Read the result with h.display_pose(pid). wall_dt as in -// server_pump. +// when one exists, else pose-less until its first sample (holon::pose_known false: no peer body, +// nothing to draw) -- drop the ones that left, apply each replicated authoritative .pose (the +// holon ignores a sample it has already seen, so re-reading an unchanged pose every frame is +// harmless), then advance the holon one frame. Read the result with h.display_pose(pid). wall_dt +// as in server_pump. With physics, both pumps (re)load the holon's statics from the room scene +// whenever its revision changes. void client_pump(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, double wall_dt = -1.0); -// Propose the local avatar's intent (client side): the client holon predicts with it at once and -// it is written to .intent for the server to validate. Call it when the intent CHANGES (each -// call is a replicated write), not every frame. +// Propose the local avatar's intent (client side): the client holon predicts with it from its next +// tick, and it is written to .intent stamped with that tick, for the server to validate and +// apply at the same tick (see holon::set_lead_ticks). Call it when the intent CHANGES (each call is +// a replicated write), not every frame. void propose_intent(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, const avatar_intent &intent); diff --git a/inc/cvc/gl/CameraController.h b/inc/cvc/gl/CameraController.h index 008167e55..137c84735 100644 --- a/inc/cvc/gl/CameraController.h +++ b/inc/cvc/gl/CameraController.h @@ -193,6 +193,14 @@ class CameraController : public cvc::state_object { void mouseLook(int dxPixels, int dyPixels); void mouseWheel(double steps); void beginDrag(); + // Primary-button press / release positions (VTK display coordinates: origin bottom-left), from + // the attached interactor or fed by a host. A release within 4 px of its press is a CLICK -- the + // drag it also began barely moved the camera -- and takeClick() returns the latest click's + // position once (then clears it), so a host can act on clicks in the scene (e.g. "face there") + // while drags keep orbiting. A click the UI overlay swallowed never reaches here. + void notePointerDown(int x, int y); + void notePointerUp(int x, int y); + bool takeClick(int &x, int &y); void endDrag(); // The app context this controller was built with. Together with the inherited diff --git a/inc/cvc/gl/ImGuiOverlay.h b/inc/cvc/gl/ImGuiOverlay.h index c248615ba..6147cd8bf 100644 --- a/inc/cvc/gl/ImGuiOverlay.h +++ b/inc/cvc/gl/ImGuiOverlay.h @@ -98,6 +98,12 @@ class ImGuiOverlay { // Render(); just call ImGui::* inside it. void setDrawCallback(std::function draw); + // Annotations that belong to the SCENE rather than the UI -- e.g. names over + // 3D objects. Called every rendered frame like the draw callback, but also + // while the panels are hidden (the toggle, a phone's default), and BEFORE it, + // so draw into ImGui::GetBackgroundDrawList() and the UI's windows cover it. + void setSceneDrawCallback(std::function draw); + // The overlay's Dear ImGui context (nullptr if libcvc was built without // CVC_ENABLE_IMGUI or setup failed). The overlay CREATES the context inside // libcvc, so a program in ANOTHER binary module (a demo .exe that links its own diff --git a/inc/cvc/gl/LodPyramidCache.h b/inc/cvc/gl/LodPyramidCache.h new file mode 100644 index 000000000..19e11ad26 --- /dev/null +++ b/inc/cvc/gl/LodPyramidCache.h @@ -0,0 +1,173 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// LodPyramidCache.h -- mesh LOD ladders for LodGraphicsNodes, built once per +// distinct mesh, off the render thread where the build has threads. +// +// attach(node, mesh) gives a LodGraphicsNode its ladder. A mesh whose pyramid +// is already cached gets it at once (setPyramid: no QEM pass). Any other mesh is +// shown at full detail straight away as a one-rung ladder (setBase), and its +// pyramid is built in the background; poll(), called once per frame, hands the +// finished pyramid to every node still waiting for it (appendRungs, which keeps +// each node's rung 0 -- its node, its style and its uploaded buffers). Meshes +// are keyed by key_of(): cvc::lod::content_hash at a 1 mm lattice mixed with the +// pyramid parameters, so two reads of one asset -- two nodes, or the same node +// re-realized after a scene change -- share one build and one cached pyramid, +// whatever their vertex or triangle order. (Two meshes the hash cannot tell +// apart, e.g. ones that differ only below the 1 mm lattice, share the first +// one's pyramid: its rung 0 is what both draw.) +// +// --- Builds ------------------------------------------------------------------ +// +// Builds run on one lazily created cvc::async_lane, one at a time, in attach +// order, each a plain cvc::lod::build_mesh_pyramid(mesh, params, nullptr). No +// thread_pool is ever made or borrowed: starting a std::thread aborts on +// Emscripten without pthreads, and app::computePool() is shared with per-frame +// work. Where the build has no threads (async_lane_has_threads false) the lane +// is deferred and poll() runs at most ONE build per call, on the render thread +// -- so a build's QEM pass is a one-off stall of that frame. max_build_tris +// bounds it: a larger mesh keeps its single rung, never builds, and leaves a +// warning (take_warnings). default_max_build_tris() is 2M triangles with +// threads and 60k without. +// +// A build works on a deep copy of its mesh and shares nothing with the scene +// while it runs; its result is read only after its done flag (release/acquire). +// +// --- Lifetime ---------------------------------------------------------------- +// +// Cached pyramids live as long as the cache (no eviction): keep it beside the +// scene for as long as that scene can be re-realized, so a re-realize re-attaches +// from the cache. The cache holds nodes weakly: a node destroyed before its +// build lands is skipped, and the build still lands in the cache for the next +// attach. Destroying the cache cancels every build still queued (each returns +// at once) and waits for the one running, if any -- and on wasm with pthreads +// that wait blocks the browser main thread, so destroy it there only at exit. +// +// Threading: every member function runs on the scene's owner thread (the one +// that populates and renders it), like the nodes it feeds. + +#ifndef __CVC_GL_LOD_PYRAMID_CACHE_H__ +#define __CVC_GL_LOD_PYRAMID_CACHE_H__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace gl { + +class LodGraphicsNode; + +class LodPyramidCache { +public: + // `params` shapes every pyramid this cache builds. A mesh with more than + // `max_build_tris` triangles (quads count as two) is never built. `mode` + // picks the lane: threaded (the default) builds on a worker thread; deferred + // builds one mesh per poll() on the caller, which is what a build without + // threads gets whatever is asked for. + explicit LodPyramidCache(cvc::lod::pyramid_params params = cvc::lod::pyramid_params(), + std::uint64_t max_build_tris = default_max_build_tris(), + cvc::lane_mode mode = cvc::lane_mode::threaded); + // Cancels the queued builds, then waits for the running one (see the header). + ~LodPyramidCache(); + + LodPyramidCache(const LodPyramidCache &) = delete; + LodPyramidCache &operator=(const LodPyramidCache &) = delete; + LodPyramidCache(LodPyramidCache &&) = delete; + LodPyramidCache &operator=(LodPyramidCache &&) = delete; + + // Give `node` the ladder for `mesh`, replacing whatever rungs it had. Returns + // true on a cache hit (the whole pyramid is attached now). Otherwise `mesh` + // is attached as rung 0 and the node waits for the pyramid, which one build + // per key produces -- an attach of a mesh already being built joins that + // build. A node attached again stops waiting for its earlier mesh. Populate a + // waiting node only through the cache: appendRungs would replace a rung 0 + // that is no longer the mesh it waited for. A null node does nothing. + bool attach(const std::shared_ptr &node, const cvc::geometry &mesh); + + // Once per frame: run one queued build when the lane is deferred, move every + // finished pyramid into the cache, and append its rungs to the nodes still + // waiting for it. Returns how many builds finished during this call (a + // failed one included: its nodes keep their one rung, with a warning). + std::size_t poll(); + + // Builds started over the cache's life (one per distinct key). + std::size_t builds() const { return m_builds; } + // Pyramids held. + std::size_t cached() const { return m_cache.size(); } + // Builds started whose results poll() has not taken in yet. + std::size_t in_flight() const { return m_pending.size(); } + // Wall time of the most recently landed build, in milliseconds (0 before one). + double last_build_ms() const { return m_lastBuildMs; } + + // Non-fatal issues since the last call -- a mesh over the build cap (once per + // mesh), a build that threw -- in the order they happened; clears them. + std::vector take_warnings(); + + // The cache key of `mesh` under `params`: lod::content_hash(mesh, 1 mm), the + // geometry type, and every pyramid_params field a mesh build reads. + static std::uint64_t key_of(const cvc::geometry &mesh, const cvc::lod::pyramid_params ¶ms); + // 2'000'000 triangles where async_lane_has_threads, 60'000 where it is not. + static std::uint64_t default_max_build_tris(); + +private: + struct build_job; // the lane <-> owner handoff (LodPyramidCache.cpp) + + struct pending_build { + std::shared_ptr job; + cvc::geometry base; // the waiting nodes' rung 0 (a copy sharing its storage) + std::vector> waiters; + }; + + // key_of, memoized by an exact (bit-for-bit, order-dependent) fingerprint of + // the arrays content_hash reads: a mesh read again with the same bytes skips + // the canonical sort, which dominates attach(). + std::uint64_t key_for(const cvc::geometry &mesh); + // Stop `node` waiting for any build; drop waiters whose node is gone. + void forget(const LodGraphicsNode *node); + cvc::async_lane &lane(); + + const cvc::lod::pyramid_params m_params; + const std::uint64_t m_maxBuildTris; + const cvc::lane_mode m_mode; + + std::unordered_map m_cache; + std::unordered_map m_pending; + std::unordered_set m_unbuilt; // keys whose build failed + std::unordered_set m_overCap; // triangle counts warned as over the cap + std::unordered_map m_keys; // fingerprint -> key_of + std::vector m_warnings; + std::size_t m_builds = 0; + double m_lastBuildMs = 0.0; + // Declared last, so it is destroyed (drained and joined) before what its + // jobs' results land in. + std::unique_ptr m_lane; +}; + +} // namespace gl +} // namespace cvc + +#endif // __CVC_GL_LOD_PYRAMID_CACHE_H__ diff --git a/inc/cvc/gl/ariadne/room_view.h b/inc/cvc/gl/ariadne/room_view.h index 038ba4a61..1f54062c7 100644 --- a/inc/cvc/gl/ariadne/room_view.h +++ b/inc/cvc/gl/ariadne/room_view.h @@ -12,14 +12,30 @@ // step with the replicated room), posed every frame at holon::display_pose: predicted for the // local avatar, a smooth walk between samples for a peer. A reconcile jump on the local avatar // (holon::take_correction) is eased out over `correction_tau`, so a server correction reads -// as a glide rather than a jump. -// * Third-person controls: intent_from_keys turns held WASD / arrow keys into an intent relative -// to the camera's heading, and self_position is where the host keeps the camera's orbit centre. +// as a glide rather than a jump. A participant whose pose is not known yet +// (holon::pose_known: in the roster, no sample so far) has no marker and no label, rather +// than one standing at the origin; the local avatar's marker appears at its first pose, not +// gliding in from the origin it was predicted at before that. +// * Each marker carries a dark visor on its front face, so its facing (the pose rotation, from +// the participant's intent.yaw) reads at a glance; each peer's display name floats above it +// (draw_labels, from the host's ImGuiOverlay scene-draw callback). +// * Third-person controls: intent_from_keys turns held WASD / arrow keys into forward / back / +// strafe relative to the avatar's FACING; the host turns the avatar by a click on the scene +// (pick_ground -> face that point). self_position is where the host keeps the orbit centre. +// * The room's mesh objects (a URI source with enough triangles to gain from it; see +// RealizeOptions) are realized as LOD ladders: full detail at once, the coarser rungs once a +// background build lands, and each frame one rung per object, chosen from the view's camera +// (SceneGraph::selectLOD). Their pyramids are kept for the room_view's whole life, so a +// re-realize on a revision change attaches them again without rebuilding, and one asset shown +// by many objects is built once. The default room's plane and box are a handful of triangles: +// there, LOD does nothing. // // Owner/render thread, like the SceneGraph it draws into. +#include #include #include // avatar_intent +#include // quality_preset #include // vec3 #include #include @@ -33,16 +49,21 @@ class holon; } // namespace ariadne namespace gl { class SceneRenderer; +class LodPyramidCache; +struct lod_stats; namespace ariadne { struct RealizedScene; -// The local avatar's intent from the held movement keys, relative to the camera's heading on a -// Z-up ground: forward is the view direction (eye -> focal) flattened onto the ground, right is -// forward x up. Diagonals are normalized (never faster than `speed`); opposing keys cancel. A -// camera looking straight down has no heading, so forward is then +Y. +// The local avatar's intent from the held movement keys, relative to its facing YAW (radians about +// +Z from +X, Z-up): W/S walk forward/back along the facing, A/D strafe left/right of it (right is +// forward x up). Diagonals are normalized (never faster than `speed`); opposing keys cancel. The +// intent carries `yaw`, so the avatar keeps facing it while it walks or strafes. cvc::ariadne::avatar_intent intent_from_keys(bool forward, bool back, bool left, bool right, - const double eye[3], const double focal[3], - double speed); + double yaw, double speed); + +// The camera's heading on a Z-up ground as a yaw (radians about +Z from +X): the view direction +// (eye -> focal) flattened onto the ground. A camera looking straight down has none: +Y (pi/2). +double heading_yaw(const double eye[3], const double focal[3]); class room_view { public: @@ -50,19 +71,52 @@ class room_view { // the state prefix realize_scene resolves binds against (the host's, e.g. sg.getStatePrefix()). room_view(SceneRenderer &view, cvc::app &app, std::string room, std::string self_pid, std::string bind_prefix); - ~room_view(); // removes every node it created + // Removes every node it created, then waits for the LOD build still running, if any (queued + // ones are cancelled). + ~room_view(); room_view(const room_view &) = delete; room_view &operator=(const room_view &) = delete; // Once per frame, after client_pump advanced the holon: re-realize the room scene if its - // revision changed, add/remove avatar markers to match the holon's participants, and pose them. - // wall_dt (seconds) paces the correction easing. + // revision changed, hand finished LOD builds to the objects waiting for them, add/remove avatar + // markers to match the holon's participants and pose them, then choose each LOD object's rung + // from the camera as it stands now (so call it before rendering the frame). wall_dt (seconds) + // paces the correction easing. void update(cvc::ariadne::holon &h, double wall_dt); + // Per-frame LOD for the room's mesh objects. ENABLED is the view SceneGraph's own switch + // (SceneGraph::setLODEnabled), so it covers every LOD node of that scene: off pins each one to + // rung 0, full detail -- the A/B toggle. update() runs the selection pass either way; while off, + // the pass is what holds the pin. PRESET is the screen-error budget (cvc::lod::preset_view). + // Until this is called the switch is as the host left it (on, by default) and the preset is + // balanced natively, aggressive under Emscripten (the wasm profile). + void set_lod(bool enabled, cvc::lod::quality_preset preset); + + // What the last update()'s LOD selection did (SceneGraph::selectLOD's stats: the rung histogram, + // the rung changes, drawn vs full-detail triangles). All zero when that update had no LOD object + // to select, or no camera to select from yet. + const cvc::gl::lod_stats &lod_stats() const; + + // LOD pyramid builds started over this view's life: one per distinct mesh, however many objects + // show it and however often the scene is re-realized. + std::size_t lod_builds() const; + // Where the local avatar is shown (the eased display position, at the marker's feet). False - // until the holon has the local avatar. + // until the holon has the local avatar with a known pose. bool self_position(double out[3]) const; + // The point on the horizontal plane z = GROUND_Z under display pixel (X, Y) (VTK display + // coordinates, origin bottom-left, as CameraController::takeClick reports). False when the ray + // through the pixel misses the plane (parallel to it, or the plane is behind the camera). + bool pick_ground(int x, int y, double ground_z, double out[3]) const; + + // Draw each peer's display name above its marker, into the current ImGui frame's background + // draw list -- over the 3D view, under the UI's windows (call it from inside the frame, e.g. + // ImGuiOverlay::setSceneDrawCallback, which runs even while the panels are hidden). Names come + // from the room roster; a peer with no name shows its pid. Skips markers behind the camera. A + // no-op without CVC_ENABLE_IMGUI. + void draw_labels() const; + // The room scene revision currently realized (0 = none yet). std::uint64_t realized_revision() const { return _realized_rev; } @@ -83,15 +137,25 @@ class room_view { void sync_scene(); void clear_scene(); void sync_avatars(cvc::ariadne::holon &h, double wall_dt); + void sync_names(); + void select_lod(); SceneRenderer &_view; cvc::app &_app; std::string _room, _self, _prefix; std::uint64_t _realized_rev = 0; - std::unique_ptr _realized; // owns the scene's light rigs while shown - std::vector _scene_ids; // the realized room nodes + lights, to remove - std::map _avatars; // pid -> marker created - cvc::physics::vec3 _self_offset{0.0, 0.0, 0.0}; // easing offset on the local avatar + std::unique_ptr _realized; // owns the scene's light rigs while shown + std::vector _scene_ids; // the realized room nodes + lights, to remove + // The mesh objects' pyramids, across re-realizes. It holds the nodes weakly and starts its build + // thread only when the first mesh needs a build. + std::unique_ptr _lod_cache; + std::unique_ptr _lod_stats; // the last update's selection + cvc::lod::quality_preset _lod_preset; + std::map _avatars; // pid -> marker created + std::map _names; // pid -> display name (the roster), for labels + std::map _heads; // pid -> where its label goes (world) + int _names_age = 0; // frames until _names is re-read + cvc::physics::vec3 _self_offset{0.0, 0.0, 0.0}; // easing offset on the local avatar bool _have_self = false; double _self_pos[3] = {0.0, 0.0, 0.0}; std::vector _warnings; diff --git a/inc/cvc/gl/ariadne/scene_realize.h b/inc/cvc/gl/ariadne/scene_realize.h index 9184c0565..2085ba85f 100644 --- a/inc/cvc/gl/ariadne/scene_realize.h +++ b/inc/cvc/gl/ariadne/scene_realize.h @@ -20,6 +20,7 @@ // pointer), so it can never dangle into a torn-down node. #include // RealizedScene clock-drive wall-dt timestamp +#include // RealizeOptions::lod_min_tris #include // SceneVisibilityBinding #include // LoadResult / CustomRequirement (verify_scene_customs) #include // SceneClock (RealizedScene holds the resolved clock by value) @@ -54,6 +55,8 @@ namespace gl { class SceneGraph; class GraphicsNode; class GeometryNode; // ShaderPreset applies to a GeometryNode (forward-decl: used only by reference) +class LodGraphicsNode; +class LodPyramidCache; class VolRenNode; class VolSliceNode; @@ -76,6 +79,10 @@ struct RealizedScene { std::vector> rigs; std::vector> volren_ticks; std::vector> volslice_ticks; + // The geometry nodes realized as LOD ladders (RealizeOptions::lod), held weakly like the + // tickers. Empty without the option, so a host runs a SceneGraph::selectLOD pass per frame only + // when this is not. + std::vector> lod_nodes; // Per-frame closures a CUSTOM node type registered (register_scene_node_type) that // needs servicing — run by tick_scene after the built-in tickers. A closure should // capture a weak_ptr to its node (not the node), matching the volren/volslice @@ -161,6 +168,28 @@ RealizedScene realize_scene(SceneGraph &sg, const cvc::ariadne::Scene &scene, const std::string &bind_prefix, std::vector *warnings = nullptr); +// Opt-in realize behaviour. The defaults are the four-argument realize_scene, exactly. +struct RealizeOptions { + // Realize a mesh-file geometry node as a LodGraphicsNode whose ladder comes from this cache + // (LodPyramidCache::attach: full detail at once, the coarser rungs once their build lands on a + // later poll()). Null = never. A node qualifies only when its source is a file or URI (not a + // primitive or a heightfield), it has no material texture, shader, clip, body or children, and + // the mesh it reads has at least `lod_min_tris` triangles; everything else realizes exactly as + // without the option. A qualifying node keeps its id, registration (top level) or nesting, its + // transform and its `visible:`; every rung gets the node's material (apply_node_material). The + // cache must outlive the realized nodes' builds -- keep it beside the scene, across re-realizes, + // so a realize of the same meshes again needs no build. LOD is a render proxy only: such a node + // has no body, and nothing that reads geometry for physics or navigation sees a rung. + LodPyramidCache *lod = nullptr; + // Below this a ladder saves little and costs about 1.5x the resident buffers. + std::uint64_t lod_min_tris = 4096; +}; + +// realize_scene with options; the LOD nodes it creates are listed in RealizedScene::lod_nodes. +RealizedScene realize_scene(SceneGraph &sg, const cvc::ariadne::Scene &scene, + const std::string &bind_prefix, std::vector *warnings, + const RealizeOptions &options); + // --- extensibility: custom scene node types ---------------------------------- // // A realizer for a CUSTOM scene node `type` (a type: the built-ins don't handle). @@ -203,6 +232,11 @@ cvc::geometry fit_to_ground(const cvc::geometry &raw, bool up_y, float height); bool read_node_geometry(SceneGraph &sg, const cvc::ariadne::SceneNode &node, cvc::geometry &geom, std::vector *warnings); +// A geometry node's `material:` block -- single colour, colour, ambient, diffuse, and specular when +// stated -- as the built-in geometry node applies it (and every rung of a LOD node). A node with no +// material block is left as it is. +void apply_node_material(GeometryNode &g, const cvc::ariadne::SceneNode &node); + // A dim³ signed distance field of the mesh at `mesh_uri` (fit applied first when `apply_fit`), // framed to the mesh extents padded 10% and cubic. Negative inside. Throws on a build without // CVC_ENABLE_SDF or on an unreadable mesh. diff --git a/inc/cvc/media/media_endpoint.h b/inc/cvc/media/media_endpoint.h index 9f30cf152..addb26bff 100644 --- a/inc/cvc/media/media_endpoint.h +++ b/inc/cvc/media/media_endpoint.h @@ -13,6 +13,7 @@ #include #include +#include namespace cvc { @@ -51,6 +52,17 @@ class media_endpoint { // A short, stable protocol tag for diagnostics and routing policy: "ws", "grpc", "webrtc", ... virtual const char *media_protocol() const noexcept = 0; + + // Per-receiver EGRESS gate: fan_media asks it once per receiving connection, with the node_id + // that connection identified as (Hello), and skips the connection when it returns false. A + // connection that has not identified yet always receives. Asked on the publisher's reader + // thread, so it must be thread-safe and non-blocking (no tree walk, no lock held across a + // socket write). It never gates a node's OWN uplink (publish_media_frame). Setting it replaces + // any previous gate; {} (the default) is today's full fan-out. Default: ignored (an endpoint + // without per-receiver egress, e.g. a test fake). + using media_gate_fn = + std::function; + virtual void set_media_gate(media_gate_fn gate) { (void)gate; } }; } // namespace cvc diff --git a/inc/cvc/state/state_change_journal.h b/inc/cvc/state/state_change_journal.h index 57773756d..853ead4a4 100644 --- a/inc/cvc/state/state_change_journal.h +++ b/inc/cvc/state/state_change_journal.h @@ -67,6 +67,9 @@ class state_change_journal { state_mutation append(const state_mutation &mutation); std::vector snapshot() const; std::vector replay_after(std::uint64_t sequence) const; + // Erase every entry with sequence <= `sequence`; returns how many were erased. Sequences keep + // counting up, so a later replay_after into the trimmed range returns only what survived. + std::size_t trim_through(std::uint64_t sequence); void clear(); std::size_t size() const; diff --git a/inc/cvc/state/state_cluster_shard.h b/inc/cvc/state/state_cluster_shard.h index 9e6076ca0..c1e1d05aa 100644 --- a/inc/cvc/state/state_cluster_shard.h +++ b/inc/cvc/state/state_cluster_shard.h @@ -172,6 +172,15 @@ class state_cluster_shard { // local mutations whose journal sequence is > `sequence`. void rewind_publish_cursor(std::uint64_t sequence); + // Erase the journal up to the publish cursor (opt-in; returns the + // count erased). For a node whose late joiners bootstrap from a + // snapshot rather than a journal backfill (a relay with + // set_backfill_on_connect(false)): it keeps the journal, and so + // every drain, at O(new). A rewind into the trimmed range replays + // only what survived, and replay_local returns only post-trim + // entries. + std::size_t trim_published(); + // Strictness toggle: when true, ingest_remote rejects mutations // whose paths resolve to an authority entry whose cluster_id // differs from this shard's cluster_id. Default false. @@ -292,6 +301,14 @@ class state_cluster_shard { std::vector interests() const; bool path_is_of_interest(const std::string &path) const; + // The ingest gate, exposed so a relay can ask "would this shard + // take it?" before re-fanning a frame: a delegation op always + // passes (as in ingest_remote), anything else when its path is of + // interest. admits_message adds ingest_remote_message's cluster + // check. + bool admits(const state_mutation &m) const; + bool admits_message(const state_message &m) const; + void set_enforce_interest(bool enforce) noexcept; bool enforce_interest() const noexcept; diff --git a/inc/cvc/state/state_peer_registry.h b/inc/cvc/state/state_peer_registry.h index 3298c25c2..5fbce1f1d 100644 --- a/inc/cvc/state/state_peer_registry.h +++ b/inc/cvc/state/state_peer_registry.h @@ -13,6 +13,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,21 @@ namespace cvc { +// ---------------- +// cvc::state_delivery_filter +// ---------------- +// A per-peer DENY predicate layered on top of the subscription prefixes: +// a delivery to a peer passes only when its prefixes match AND its filter +// (if any) delivers the path. Filters are immutable once installed (swap a +// new one in with set_filter), are evaluated outside the registry lock, and +// must never call back into the registry or a transport; deliver() may run +// on any transport thread at once. +class state_delivery_filter { +public: + virtual ~state_delivery_filter() = default; + virtual bool deliver(const std::string &path) const noexcept = 0; +}; + // ---------------- // cvc::state_peer_registry // ---------------- @@ -36,6 +52,14 @@ namespace cvc { // prefixes [a, b.c] receives delivery for paths "a", "a.x.y", // "b.c", "b.c.d" but not "b" or "z". // +// Delivery filter: +// set_filter() layers a state_delivery_filter over a known peer's +// prefixes (both must pass). add_peer on an existing id keeps it; +// remove_peer drops it. A filter the registry lets go of (replaced, +// removed or cleared) is released outside the lock, and one that a +// concurrent should_deliver() is still evaluating stays alive until +// that call returns. +// // Membership: // add_peer/remove_peer manage entries. note_seen() bumps a // liveness timestamp; callers may use it for heartbeat-driven @@ -55,6 +79,19 @@ class state_peer_registry { std::uint64_t mutations_delivered = 0; std::uint64_t messages_delivered = 0; std::uint64_t deliveries_filtered = 0; + std::shared_ptr filter; + }; + + // One peer's delivery rule, copied out under a single lock so a batch + // path (a snapshot frame of many entries) does not lock per entry. + struct delivery_view { + bool known = false; // false: an unknown peer, which delivers everything + std::vector subscriptions; + std::shared_ptr filter; + // Same answer as should_deliver() at the moment the view was taken. + bool deliver(const std::string &path) const noexcept; + // True when this view can deny anything (known with prefixes or a filter). + bool filtered() const noexcept; }; state_peer_registry(); @@ -64,7 +101,7 @@ class state_peer_registry { // Insert or replace a peer entry. The cluster_id and endpoint // overwrite prior values; the subscription set replaces prior - // subscriptions. + // subscriptions. An existing peer's delivery filter is kept. void add_peer(std::string node_id, std::string cluster_id, std::string endpoint = std::string(), std::vector subscriptions = {}); @@ -78,10 +115,18 @@ class state_peer_registry { // Returns true if the registry knows this peer. bool has_peer(const std::string &node_id) const; + // Install (or, with nullptr, clear) a known peer's delivery filter. + // Returns false if the peer is unknown. + bool set_filter(const std::string &node_id, std::shared_ptr f); + + // The peer's delivery rule as of now (known=false for an unknown peer). + delivery_view view_of(const std::string &node_id) const; + // Returns true iff a delivery to `node_id` for `path` would pass - // the subscription filter. Unknown peers return true (a transport - // that has not registered subscriptions for an active connection - // should still deliver — this is the back-compat default). + // the subscription filter and the peer's delivery filter. Unknown + // peers return true (a transport that has not registered + // subscriptions for an active connection should still deliver — + // this is the back-compat default). bool should_deliver(const std::string &node_id, const std::string &path) const; // Heartbeat: update last_seen_ns to `now_ns` (caller-provided diff --git a/inc/cvc/state/state_transport.h b/inc/cvc/state/state_transport.h index a0598a1bd..94926cd79 100644 --- a/inc/cvc/state/state_transport.h +++ b/inc/cvc/state/state_transport.h @@ -202,6 +202,22 @@ class state_transport { return false; } + // Targeted push: write `entries` as ONE unsolicited, final snapshot frame (request_id 0) to + // every live connection whose peer identified (Hello) as `node_id`, bypassing the peer + // registry -- the caller already decided what this peer gets. Clients apply an unsolicited + // snapshot like a reply. A relay uses it to backfill or refresh exactly the leaves a delivery + // filter withholds from the live stream. Returns the number of connections written; 0 (before + // encoding anything) when no connection has that id. + // + // Called on the same thread as publish(), so per-connection FIFO orders a push ahead of the + // publishes that follow it. Default: not supported (returns 0). + virtual std::size_t push_snapshot_to(const std::string &node_id, + const std::vector &entries) { + (void)node_id; + (void)entries; + return 0; + } + // -------- Transport bridge (wasm interop) -------- // // Link this transport to a sibling of a DIFFERENT backend — e.g. a gRPC transport and a diff --git a/inc/cvc/state/state_transport_grpc.h b/inc/cvc/state/state_transport_grpc.h index 4c26fc836..17bec7829 100644 --- a/inc/cvc/state/state_transport_grpc.h +++ b/inc/cvc/state/state_transport_grpc.h @@ -164,10 +164,26 @@ class state_transport_grpc final : public state_transport, public media_endpoint // connection is also forwarded to every OTHER connection, so clients that only connect to this // (server) node still see each other's room-tree writes. Off by default — a leaf node applies a // received frame to its local shards and never re-sends it. Enable only on a relay/server; a - // forwarded frame keeps its origin_node_id, and the origin connection is skipped (no echo). + // forwarded frame keeps its origin_node_id, and the origin connection is skipped (no echo). Each + // target's peer registry entry applies to the forward exactly as it does to publish(). void set_relay(bool enable) noexcept { _relay.store(enable, std::memory_order_relaxed); } bool relay() const noexcept { return _relay.load(std::memory_order_relaxed); } + // Relay interest gate (off by default). When on, a frame received from a connection is re-sent + // (to the other connections AND across the bridge) only if some local same-cluster shard would + // take it: state_cluster_shard::admits() for a mutation, so delegation ops always pass, and + // admits_message() for a message. A relay without such a shard forwards everything, as before. + // Ingest into the local shards is unchanged; each withheld frame is counted. Meant for a relay + // whose clients all enforce the relay's interest, where a withheld frame is one no client would + // apply. Thread-safe; a flip applies to the frames received after it. + void set_relay_requires_interest(bool on) noexcept { + _relay_requires_interest.store(on, std::memory_order_relaxed); + } + bool relay_requires_interest() const noexcept { + return _relay_requires_interest.load(std::memory_order_relaxed); + } + std::uint64_t total_relay_withheld() const noexcept { return _relay_withheld.load(); } + // Invoked (on a server RPC thread) when a peer's stream ENDS -- it closed or dropped its // connection -- with that peer's node id (from its Hello). The same contract as // state_transport_ws::set_on_connection_closed, so a relay retracts a departed native client from @@ -189,10 +205,30 @@ class state_transport_grpc final : public state_transport, public media_endpoint void fan_media(const media_frame &f, const void *exclude = nullptr) override; void set_media_sink(media_sink_fn sink) override; const char *media_protocol() const noexcept override { return "grpc"; } + // Per-receiver egress gate for fan_media (see media_endpoint::set_media_gate). The gate is copied + // once per frame and asked once per identified receiver; a receiver that has not sent its Hello + // always gets the frame, and publish_media_frame is never gated. {} restores full fan-out. + void set_media_gate(media_gate_fn gate) override; + + // Stall drop (0 = off, the default): skip a receiver whose write in progress started more than + // `ms` ago, and one whose stream this frame cannot get within `ms`, instead of queueing behind + // it. A healthy buffered write takes microseconds, so one blocked that long means the peer's + // flow-control window is full; dropping the frame keeps the publisher's reader thread (and every + // other receiver) moving, each frame waiting at most `ms` per such receiver. Media is + // loss-tolerant; the existing 3 s write watchdog still closes a peer that stays stuck. Applies to + // every media write, own uplink included (the counterpart of the WS transport's backlog drop). + // The media gate is asked only once the stream is held, so a dropped frame uses no thumbnail. + void set_media_stall_drop(std::chrono::milliseconds ms) noexcept; + // True when a write is in flight (`write_started_ns` != 0) and has run longer than `stall_ns`; + // `stall_ns` 0 never stalls. Steady-clock nanoseconds, the base of the connection's write clock. + static bool media_write_stalled(std::uint64_t write_started_ns, std::uint64_t now_ns, + std::uint64_t stall_ns) noexcept; std::uint64_t total_media_sent() const noexcept { return _media_sent.load(); } std::uint64_t total_media_received() const noexcept { return _media_recv.load(); } std::uint64_t total_media_forwarded() const noexcept { return _media_fwd.load(); } + std::uint64_t total_media_gated() const noexcept { return _media_gated.load(); } + std::uint64_t total_media_stall_dropped() const noexcept { return _media_stall_dropped.load(); } // state_transport interface. void register_shard(state_cluster_shard *shard) override; @@ -203,8 +239,16 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::size_t pump_all() override; void flush() override; bool fetch_chunk(const std::string &digest, chunk_callback on_chunk) override; + // The answering side filters its reply by the requester's peer registry entry (the rule its live + // traffic gets; a requester that has not sent its Hello gets everything). bool request_snapshot(const std::string &cluster_id, const std::string &path_prefix, snapshot_callback on_entries) override; + // Writes `entries` as one unsolicited final SnapshotResponse (request_id 0) to each live + // connection whose Hello named `node_id`, unfiltered by the peer registry. Returns the number of + // connections written: 0, with nothing encoded, when none matches or `entries` is empty. A write + // to a peer that stopped reading is bounded by the write watchdog. + std::size_t push_snapshot_to(const std::string &node_id, + const std::vector &entries) override; // Diagnostics. std::size_t shard_count() const; @@ -274,8 +318,14 @@ class state_transport_grpc final : public state_transport, public media_endpoint private: // Encode `m` to a Frame and write it to every live connection except `exclude` (a connection*; - // nullptr = all). Returns the number written. Backs publish_media_frame and fan_media. - std::size_t write_media(const media_frame &m, const void *exclude); + // nullptr = all), consulting the media gate when `gated`. Returns the number written. Backs + // publish_media_frame (ungated) and fan_media (gated). + std::size_t write_media(const media_frame &m, const void *exclude, bool gated); + // The relay interest gate for one received frame, given the local same-cluster shards that just + // ingested it (see set_relay_requires_interest). + bool relay_admits(const state_mutation &m, + const std::vector &shards) const; + bool relay_admits(const state_message &m, const std::vector &shards) const; void send_hello_locked(connection &c); void send_backfill_locked(connection &c, const std::vector &shards); // Relay-only: push the full CURRENT snapshot of each shard to a newly admitted connection, so a @@ -302,6 +352,8 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::atomic _backfill_on_connect{true}; std::atomic _relay{false}; + std::atomic _relay_requires_interest{false}; + std::atomic _relay_withheld{0}; connection_closed_fn _on_conn_closed; // set before start(); read on RPC threads std::atomic _backfilled{0}; @@ -316,6 +368,13 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::atomic _media_sent{0}; std::atomic _media_recv{0}; std::atomic _media_fwd{0}; + // The media gate, swapped whole under _media_gate_mu and copied out once per frame: a reader + // thread keeps calling its own copy while another thread replaces the slot. + mutable std::mutex _media_gate_mu; + std::shared_ptr _media_gate; + std::atomic _media_gated{0}; + std::atomic _media_stall_ns{0}; + std::atomic _media_stall_dropped{0}; state_blob_store *_blob_store = nullptr; diff --git a/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index 17bc2a284..253643adf 100644 --- a/inc/cvc/state/state_transport_ws.h +++ b/inc/cvc/state/state_transport_ws.h @@ -110,10 +110,26 @@ class state_transport_ws final : public state_transport, public media_endpoint { // process over the SAME shards; a small bridge (Phase 2) relays frames between // the two transports' connection sets so WS and gRPC clients share the room. // Off by default — a leaf client applies a received frame locally and never - // re-sends it. + // re-sends it. Each forward honours the target peer's registry entry + // (subscriptions + delivery filter), exactly as publish() does. void set_relay(bool enable) noexcept { _relay.store(enable, std::memory_order_relaxed); } bool relay() const noexcept { return _relay.load(std::memory_order_relaxed); } + // Relay interest gate (opt-in; default off). When on, a Mutation/Message received from a + // connection is re-fanned -- to the other connections AND across the bridge -- only when some + // registered same-cluster shard would admit it (state_cluster_shard::admits / admits_message: + // the path is of interest, and a delegation op always passes). A relay whose clients all + // enforce the relay's own interest set thereby stops forwarding frames no client would apply. + // A relay with no matching shard still forwards everything, and ingest is unchanged. Each + // withheld frame is counted in total_relay_withheld(). + void set_relay_requires_interest(bool on) noexcept { + _relay_requires_interest.store(on, std::memory_order_relaxed); + } + bool relay_requires_interest() const noexcept { + return _relay_requires_interest.load(std::memory_order_relaxed); + } + std::uint64_t total_relay_withheld() const noexcept { return _relay_withheld.load(); } + // Invoked (on a server reader thread) when a peer CLOSES its connection, with that peer's node id // (from its Hello). A relay uses this to retract the departed participant from the room so the // roster does not accumulate ghosts of closed browser tabs. Not called during stop(), nor for a @@ -128,13 +144,34 @@ class state_transport_ws final : public state_transport, public media_endpoint { // server's main loop. No-op under wasm. Returns how many connections were erased this call. std::size_t reap_idle(std::chrono::milliseconds timeout); - // Relay reliability: push the full current shard snapshot to EVERY connection (not just a new - // one, the way admit does). A client-to-client live re-fan is single-shot with no retry, so a - // join that races an already-connected peer's socket can be missed and never recovers; calling + // Relay reliability: bring EVERY connection up to date with the current shard snapshot (not just + // a new one, the way admit does). A client-to-client live re-fan is single-shot with no retry, so + // a join that races an already-connected peer's socket can be missed and never recovers; calling // this periodically on the server's main loop lets every client re-converge on the room/scene - // tree within one interval (idempotent bulk apply). Returns how many connections it wrote to. + // tree within one interval (idempotent bulk apply). Returns how many connections it wrote to: a + // connection that already holds everything it is delivered gets no frame and is not counted. + // + // It sends DIFFS. Per connection, the transport remembers what the last snapshot frame (the admit + // snapshot or a broadcast) carried for each path, and forgets a path whenever any other frame + // touching it is written to that peer (a live mutation, a push) or arrives from it (the peer's + // own write). A broadcast then carries only the entries whose current content differs from what + // the peer was last sent, plus the forgotten ones, so an unchanged tree costs nothing and a + // missed live re-fan is still healed on the next call. A peer gets the whole tree it is delivered + // only on a full refresh: once, when it asks (request_full_refresh) -- for state it dropped on + // its own side, which the relay cannot see. + // + // Each connection's frame honours the peer registry: it holds only the entries its + // state_peer_registry::delivery_view passes. Shard snapshots are taken once per call. std::size_t broadcast_snapshot(); + // Client side of the full refresh: ask every connected relay to send this node the whole tree it + // is delivered on its next broadcast_snapshot, not just the diff. For a client that may have + // dropped state the relay believes it holds -- frames that arrived while no shard was registered, + // a shard re-registered or given a wider interest set, a local write that was never published. + // Sends a SnapshotRequest (the whole tree; a path prefix is not honoured yet). Returns the + // connections written. + std::size_t request_full_refresh(); + // ── Media plane (bring-up: media over this WS control channel) ── // publish_media_frame sends a frame to this transport's connections: a browser CLIENT has one // connection (the relay), so it pushes its camera/mic UP; a relay's media_router fans each @@ -155,10 +192,31 @@ class state_transport_ws final : public state_transport, public media_endpoint { void fan_media(const media_frame &f, const void *exclude = nullptr) override; void set_media_sink(media_sink_fn sink) override; const char *media_protocol() const noexcept override { return "ws"; } + // Per-receiver egress gate (see media_endpoint::set_media_gate). fan_media asks it once per + // identified receiving connection; a connection that has not sent its Hello always receives, + // and publish_media_frame (this node's own uplink) is never gated. Thread-safe; the gate is + // swapped whole and a fan already in progress finishes with the gate it started with. + void set_media_gate(media_gate_fn gate) override; + + // Backlog drop (kDefaultMediaBacklogDrop; 0 = off): skip a connection whose OS send buffer + // already holds more than `bytes` unsent, rather than let the synchronous write block behind a + // peer that cannot keep pace. Media is loss-tolerant, and a small threshold keeps a slow peer's + // buffer from crowding out the state writes queued behind its video. Applies to every media + // write, own uplink included (the counterpart of state_transport_grpc::set_media_stall_drop). + // Linux only: the backlog is read with SIOCOUTQ and is 0 elsewhere. The gate is asked after + // this check, so a dropped frame uses no thumbnail. Thread-safe. + void set_media_backlog_drop(std::size_t bytes) noexcept; + static constexpr std::size_t kDefaultMediaBacklogDrop = 32 * 1024; std::uint64_t total_media_sent() const noexcept { return _media_sent.load(); } std::uint64_t total_media_received() const noexcept { return _media_recv.load(); } std::uint64_t total_media_forwarded() const noexcept { return _media_fwd.load(); } + // Receiver deliveries the media gate denied (one per frame per denied connection). + std::uint64_t total_media_gated() const noexcept { return _media_gated.load(); } + // Receiver deliveries the backlog drop skipped (one per frame per backed-up connection). + std::uint64_t total_media_backlog_dropped() const noexcept { + return _media_backlog_dropped.load(); + } // Backfill on connect (default true): replay each registered shard's journaled // local-origin mutations to a newly admitted connection BEFORE any live traffic, @@ -178,6 +236,14 @@ class state_transport_ws final : public state_transport, public media_endpoint { void flush() override; // fetch_chunk() / request_snapshot() use the base defaults for now (Phase 2). + // One unsolicited, final snapshot frame of exactly `entries` to each live connection identified + // as `node_id` (see state_transport::push_snapshot_to); the registry is not consulted. A + // connection with more than 2 MB of unsent backlog is skipped -- the caller's next push or the + // periodic broadcast_snapshot heals it. Returns the connections written; 0, with nothing + // encoded, when none matches or `entries` is empty. + std::size_t push_snapshot_to(const std::string &node_id, + const std::vector &entries) override; + // ── Diagnostics ── std::size_t shard_count() const; std::size_t connection_count() const; @@ -190,6 +256,8 @@ class state_transport_ws final : public state_transport, public media_endpoint { std::uint64_t total_received_messages() const noexcept { return _recv_messages.load(); } std::uint64_t total_delivered() const noexcept { return _delivered.load(); } std::uint64_t total_backfilled() const noexcept { return _backfilled.load(); } + // Snapshot entries broadcast_snapshot has written, summed over connections. + std::uint64_t total_broadcast_entries() const noexcept { return _broadcast_entries.load(); } // Wait until at least `target` MUTATION frames have been received (Hello frames // are not counted) or `timeout` elapses; returns the count reached. @@ -219,8 +287,9 @@ class state_transport_ws final : public state_transport, public media_endpoint { private: // Encode `m` to a Frame and write it to every live connection except `exclude` (a connection*; - // nullptr = all). Returns the number written. Backs both publish_media_frame and fan_media. - std::size_t write_media(const media_frame &m, const void *exclude); + // nullptr = all). With `gated`, the media gate (when one is set) is asked per identified receiver + // first. Returns the number written. Backs publish_media_frame (ungated) and fan_media (gated). + std::size_t write_media(const media_frame &m, const void *exclude, bool gated); void send_hello_locked(connection &c); void send_backfill_locked(connection &c, const std::vector &shards); // Relay-only: push the full CURRENT snapshot of each shard to a newly admitted connection, so a @@ -257,13 +326,24 @@ class state_transport_ws final : public state_transport, public media_endpoint { std::atomic _backfill_on_connect{true}; std::atomic _relay{false}; + std::atomic _relay_requires_interest{false}; + std::atomic _relay_withheld{0}; // inbound frames the interest gate kept local connection_closed_fn _on_conn_closed; // set by a relay; invoked on a peer close (see above) media_sink_fn _media_sink; // media_router ingress (relay) OR set_on_media leaf sugar; see above + // The media egress gate: replaced whole under _media_gate_mu, and copied once per fanned frame so + // the per-receiver loop (and its socket writes) runs without the lock. Null = no gate. + mutable std::mutex _media_gate_mu; + std::shared_ptr _media_gate; + std::atomic _media_sent{ 0}; // MediaFrames this transport sent (publish_media_frame) - std::atomic _media_recv{0}; // MediaFrames received from peers - std::atomic _media_fwd{0}; // MediaFrames a relay fanned to other connections + std::atomic _media_recv{0}; // MediaFrames received from peers + std::atomic _media_fwd{0}; // MediaFrames a relay fanned to other connections + std::atomic _media_gated{0}; // receiver deliveries the media gate denied + // Receiver deliveries the backlog drop skipped, and its threshold in bytes (0 = off). + std::atomic _media_backlog_dropped{0}; + std::atomic _media_backlog_drop{kDefaultMediaBacklogDrop}; std::atomic _published{0}; std::atomic _sent_frames{0}; @@ -272,6 +352,10 @@ class state_transport_ws final : public state_transport, public media_endpoint { std::atomic _recv_messages{0}; std::atomic _delivered{0}; std::atomic _backfilled{0}; + std::atomic _broadcast_entries{0}; + // broadcast_snapshot's call count: a connection's remembered paths carry the last call that + // delivered them, so a path the peer no longer gets (or that left the tree) is dropped. + std::atomic _broadcast_round{0}; // wait_for_received infrastructure (received MUTATION frames). mutable std::mutex _recv_mu; diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 4179e5fce..b326006f4 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -65,8 +65,9 @@ // ws://|wss:// (WebSocket, what browsers dial; the host page sets wss:///…) or grpc:// // (native gRPC). register_conference supplies the room verbs and the shard/transport are wired here // in the host; both transports carry the canonical media_frame and interop via the relay's router. +#include // the relay's AOI params -> peers' audio gain #include // room_join (re-assert after a reconnect) -#include // publish our link quality to the room +#include // our link quality + voice activity to the room #include // the client replica of the room holon (avatars, H1) #include // client_pump / propose_intent over the room tree #include // frame_ptr — forward the local camera/mic streams @@ -75,6 +76,7 @@ #include // look up the local camera/mic streams by token #include // one-advance-per-frame fan-out over the world_clock #include // app.world_clock().t() — the common media timestamp +#include // lod_stats — the room's LOD selection, logged #include // the room scene + avatar markers in the 3D view #include // list_audio_capture_devices — mic picker #include // list_cameras — camera picker @@ -94,6 +96,7 @@ #include #include #include +#include #endif #ifdef __EMSCRIPTEN__ #include // single-thread wasm: flush the publisher at frame cadence @@ -313,11 +316,20 @@ struct ConferenceSession { std::unique_ptr holon_; std::unique_ptr room_view_; cvc::gl::CameraController *cam_ = nullptr; - cvc::ariadne::avatar_intent last_intent_; // the last intent proposed (propose only on change) - bool intent_proposed_ = false; // proposed once yet (the first frame always proposes) + cvc::ariadne::avatar_intent last_intent_; // the intent our avatar runs on (predicted with) + cvc::ariadne::avatar_intent last_user_; // what the keys + facing asked for last frame std::vector drawn_pids_; // avatars drawn at the last log (log on change) + std::uint64_t logged_realigns_ = 0; // holon clock re-alignments at the last log + std::size_t logged_lod_builds_ = 0; // LOD pyramid builds at the last log + std::vector logged_lod_rungs_; // the LOD rung histogram at the last log + double next_lod_log_s_ = 0.0; // steady_s() before which the histogram is not logged bool framed_ = false; // camera framed on our avatar once double orbit_center_[3] = {0.0, 0.0, 0.0}; + // Our facing (radians about +Z from +X): WASD walk/strafe relative to it, a click on the scene + // turns it toward the clicked ground point. Until the first click it follows the camera's heading + // while no movement key is held (and holds still while one is), so W walks "into" the screen. + double facing_yaw_ = 0.0; + bool facing_chosen_ = false; // the user clicked to face (the facing is theirs from then on) static constexpr double kWalkSpeed = 2.0; // m/s static constexpr double kEyeHeight = 1.2; // orbit centre above the avatar's feet (m) @@ -351,7 +363,8 @@ struct ConferenceSession { double shown_pts = -1.0; // pts of the frame currently injected into video_proxy std::deque> achunks; // decoded f32 chunks awaiting the mixer double last_audio_pts = -1.0; // world-clock stamp of this peer's last audio chunk - bool has_audio = false; // once true, video display follows this peer's audio clock + double last_audio_wall = -1.0; // now_wall_ when that chunk arrived (-1: none yet) + bool has_audio = false; // video display follows this peer's audio clock while it is fresh // Per-peer LOCAL controls (this client's mix only — never sent to the peer, never change their // capture). Driven by the user-list window; applied in mix_audio / sync_and_show. float volume = 1.0f; // playback gain in our mix (slider; 1 = unity) @@ -361,6 +374,7 @@ struct ConferenceSession { }; std::map peers_; // keyed by origin pid double self_talk_until_ = 0.0; // wall time until which WE count as "talking" + bool voice_talking_ = false; // our voice.talking leaf's value (absent = not talking) double now_wall_ = 0.0; // wall-clock seconds, refreshed once per pump std::vector cam_devices_, mic_devices_; // cached device names (index = device index) bool devices_enumerated_ = false; // devices listed yet (deferred past startup) @@ -386,6 +400,19 @@ struct ConferenceSession { static constexpr double kTalkHold = 0.60; // keep "talking" lit this long (s) after the last // loud chunk — bridges brief pauses between words and // jittery delivery (wall time, so frame-rate indep.) + // A peer's audio is FRESH while its last mic chunk is younger than this (wall s; about 23 chunks + // of 21 ms). Only fresh audio paces its video and says whether it is talking: once the chunks + // stop (its mic muted or cut, or the relay sending us none of it, out of range in the room) its + // video runs free and its name follows the peer's own voice.talking leaf. + static constexpr double kAudioFreshS = 0.5; + bool audio_fresh(const peer_media &pm) const { + return pm.has_audio && now_wall_ - pm.last_audio_wall < kAudioFreshS; + } + + // The relay's AOI params (conf.rooms..aoi, re-read about once a second by net_step), which + // shape each peer's audio by distance (aoi_audio_gain). Empty when the relay publishes none (an + // older relay, or one run with --aoi off): every peer then plays at unity. + std::optional aoi_; // The state key carrying a peer's video-proxy token, so the .ari can bind that peer's // stream_view. @@ -455,12 +482,27 @@ struct ConferenceSession { nodeId += digits[rd() % 36]; } gla::register_conference(app, backend); // room-join/roster + streaming + stream_view - shard = std::make_unique(app, "conf", nodeId); // Replicate ONLY the shared room tree (conf.rooms.*), never this client's per-document UI state - // (ui.docs.*). Without this the shard's adapter observes from the root, so a plain (state-set) - // the .ari makes — above all conf.self, the display name bound to the Name field — would - // replicate to every peer and they would all show the same name (two clients "collide on the - // name"). The interest filter is dot-segment aware: "conf" matches conf.rooms.* but not + // (ui.docs.*). The shard is rooted there, so its adapter observes and journals nothing outside + // conf: every room write (room-join, presence, intent, net, voice) is under it, while the UI + // state -- the scene's avatar marker matrices, rewritten at the publisher's rate while anyone + // walks, the camera and HUD, the ping leaves -- is never journaled, drained or sent (and the + // journal no longer grows with it). A shard observing from the root would also send a plain + // (state-set) the .ari makes, above all conf.self, the display name bound to the Name field, so + // every peer would show the same name (two clients "collide on the name"). + // CVC_ARIGL_REPLICATE_ROOT overrides the root; set empty (or "*") it observes the whole tree + // again, for an A/B against that. + std::string replicate_root = "conf"; + if (const char *rootEnv = std::getenv("CVC_ARIGL_REPLICATE_ROOT")) { + replicate_root = rootEnv; + if (replicate_root == "*") + replicate_root.clear(); + std::fprintf(stderr, "arigl: conference: replicating %s (CVC_ARIGL_REPLICATE_ROOT)\n", + replicate_root.empty() ? "the whole state tree" : replicate_root.c_str()); + } + shard = std::make_unique(app, "conf", nodeId, replicate_root); + // The interest filter stays as well, for what arrives: only conf.* is applied, whatever the + // relay sends. It is dot-segment aware: "conf" matches conf.rooms.* but not // ui.docs.ari.conf.self. shard->add_interest("conf"); shard->set_enforce_interest(true); @@ -573,6 +615,13 @@ struct ConferenceSession { if (now < next_net_s_) return; next_net_s_ = now + 1.0; + // The relay's AOI params, for the audio gain (mix_audio). Read whether or not the link is up: + // they are in our replica of the room tree either way. + if (cvc::ariadne::conference::aoi_params ap; + cvc::ariadne::conference::read_aoi_params(*app_, room_, ap)) + aoi_ = ap; + else + aoi_.reset(); const std::vector links = tp_->link_reports(); const cvc::state_link_report *relay = nullptr; for (const cvc::state_link_report &r : links) @@ -598,10 +647,27 @@ struct ConferenceSession { st(prefix_ + ".self.ping").value(cvc::ariadne::conference::format_rtt(s.rtt_ms)); st(prefix_ + ".self.down").value(cvc::ariadne::conference::format_rate(s.in_kbps)); st(prefix_ + ".self.up").value(cvc::ariadne::conference::format_rate(s.out_kbps)); + if (holon_ && s.rtt_ms > 0.0) + set_lead(s.rtt_ms / 1000.0); if (now < rejoin_until_s_) cvc::ariadne::conference::room_join(*app_, room_, pid_, st(prefix_ + ".conf.self").value()); } + // Run our holon's clock ahead of the relay's by the round trip plus a margin (holon:: + // set_lead_ticks): a sample takes one way to reach us and our intent one way back, so an intent + // we apply at our tick T then reaches the relay before it integrates T, and the relay's pose + // agrees with our prediction -- no correction. The margin covers the frame our write waits to + // flush, the relay's room step (~50 ms) and jitter. Small RTT changes leave the lead alone (a + // change re-aligns the clock, which costs a correction). + void set_lead(double rtt_s) { + const double dt = holon_->clock().fixed_dt(); + const auto lead = static_cast(std::ceil((rtt_s + kLeadMarginS) / dt)); + const std::uint64_t cur = holon_->lead_ticks(); + if (lead > cur + 2 || lead + 6 < cur) + holon_->set_lead_ticks(lead); + } + static constexpr double kLeadMarginS = 0.06; + void reconnect(double now) { next_reconnect_s_ = now + 3.0; std::fprintf(stderr, "arigl: conference: lost the relay link; reconnecting to %s\n", @@ -620,15 +686,35 @@ struct ConferenceSession { } // Window mode: show the room in 3D in `view` — the server's shared scene and an avatar per - // participant — with third-person controls: WASD / arrow keys walk our avatar relative to the - // camera's heading, and `cam` (Orbit) keeps orbiting it. A no-op for a document with no relay. - void attach_view(cvc::gl::SceneRenderer &view, cvc::gl::CameraController &cam) { + // participant, each peer named — with third-person controls: W/S walk our avatar forward/back + // along its facing and A/D strafe, a click on the scene turns it to face the clicked point, and + // `cam` (Orbit) keeps orbiting it. A no-op for a document with no relay. + void attach_view(cvc::gl::SceneRenderer &view, cvc::gl::CameraController &cam, + cvc::gl::ImGuiOverlay &ui) { if (!active || !driver_ || !app_) return; - holon_ = std::make_unique(cvc::ariadne::holon_role::client); + holon_ = std::make_unique(cvc::ariadne::holon_role::client, + cvc::ariadne::room_holon_config()); + set_lead(0.06); // until the first round trip is measured (net_step) room_view_ = std::make_unique(view, *app_, room_, pid_, view.scene().getStatePrefix()); + // CVC_ARIGL_LOD=0 draws every room mesh at full detail (the LOD A/B), keeping the platform's + // usual screen-error budget for when it is switched back on. + if (const char *lod = std::getenv("CVC_ARIGL_LOD"); lod && std::string(lod) == "0") { +#ifdef __EMSCRIPTEN__ + room_view_->set_lod(false, cvc::lod::quality_preset::aggressive); +#else + room_view_->set_lod(false, cvc::lod::quality_preset::balanced); +#endif + std::fprintf(stderr, "arigl: room: LOD off (CVC_ARIGL_LOD=0): meshes at full detail\n"); + } cam_ = &cam; + // Peers' names over their markers: a scene annotation, so it shows with the panels hidden too + // and sits under the document's windows. + ui.setSceneDrawCallback([this] { + if (room_view_) + room_view_->draw_labels(); + }); driver_->add_frame_observer(kOrderRoom, [this](const cvc::clock_driver::clock_frame &f) { step_room(f); }); } @@ -636,31 +722,69 @@ struct ConferenceSession { void step_room(const cvc::clock_driver::clock_frame &f) { if (!holon_ || !room_view_ || !cam_) return; + // A click on the scene (not a drag, and not one the UI took) turns us to face the ground point + // under it. Only once we are in the room: the pick needs our position. + int cx = 0, cy = 0; + double self[3]; + if (cam_->takeClick(cx, cy) && room_view_->self_position(self)) { + double g[3]; + if (room_view_->pick_ground(cx, cy, self[2], g) && + std::hypot(g[0] - self[0], g[1] - self[1]) > 0.05) { // a click on our own feet: no turn + facing_yaw_ = std::atan2(g[1] - self[1], g[0] - self[0]); + facing_chosen_ = true; + } + } // Keys -> intent, only in Orbit: in Fly the same keys move the camera instead. The camera's // held set is dropped while the UI has the keyboard, so typing a name never walks. cvc::ariadne::avatar_intent in; - if (cam_->mode() == cvc::gl::CameraController::Mode::Orbit) { + const auto held = [this](const char *a, const char *b) { + return cam_->keyHeld(a) || cam_->keyHeld(b); + }; + const bool walking = + held("w", "Up") || held("s", "Down") || held("a", "Left") || held("d", "Right"); + if (!facing_chosen_ && !walking) { + // No facing of the user's own yet: track where the camera looks, so the first W walks into + // the screen. A default, not an ask: mirrored into last_user_ so it proposes nothing. double eye[3], focal[3], up[3]; cam_->getPose(eye, focal, up); - const auto held = [this](const char *a, const char *b) { - return cam_->keyHeld(a) || cam_->keyHeld(b); - }; + facing_yaw_ = gla::heading_yaw(eye, focal); + last_user_.yaw = facing_yaw_; + } + in.yaw = facing_yaw_; + if (cam_->mode() == cvc::gl::CameraController::Mode::Orbit) { in = gla::intent_from_keys(held("w", "Up"), held("s", "Down"), held("a", "Left"), - held("d", "Right"), eye, focal, kWalkSpeed); + held("d", "Right"), facing_yaw_, kWalkSpeed); } - // Propose on the first frame even when standing still, so the tree never holds a walk an - // earlier session under this id proposed; after that, only on change (a replicated write). - if (!intent_proposed_ || in.move.x != last_intent_.move.x || in.move.y != last_intent_.move.y || - in.move.z != last_intent_.move.z) { + // Propose when the USER changed what they ask for (a key, a click to face) -- a replicated + // write, so only on change. Otherwise run on whatever intent the tree holds for us: the + // document may drive the avatar itself with (room-set-intent ...), and our prediction must + // follow it rather than overrule it. (A stale walk under our id cannot linger: the node id is + // fresh per launch and the relay zeroes a departed participant's intent.) + const auto differs = [](const cvc::ariadne::avatar_intent &a, + const cvc::ariadne::avatar_intent &b) { + return a.move.x != b.move.x || a.move.y != b.move.y || a.move.z != b.move.z || a.yaw != b.yaw; + }; + if (differs(in, last_user_)) { cvc::ariadne::propose_intent(*holon_, *app_, room_, pid_, in); last_intent_ = in; - intent_proposed_ = true; + last_user_ = in; + } else if (cvc::ariadne::avatar_intent tree; + cvc::ariadne::read_intent(*app_, room_, pid_, tree) && differs(tree, last_intent_)) { + last_intent_ = tree; } // Re-assert it locally every frame: our avatar joins the holon only once our room-join is in // the tree, so an intent proposed before then must still reach the prediction. holon_->set_intent(pid_, last_intent_); cvc::ariadne::client_pump(*holon_, *app_, room_, pid_, f.wall_dt); + // Each re-alignment of our clock to the relay's drops our unacknowledged input (a visible + // jump). The first sample after joining is one; any after that is worth a line. + if (holon_->clock_realigns() != logged_realigns_) { + logged_realigns_ = holon_->clock_realigns(); + std::fprintf(stderr, "arigl: room: clock re-aligned to the relay (%llu)\n", + static_cast(logged_realigns_)); + } room_view_->update(*holon_, f.wall_dt); + log_lod(); // Log who is drawn whenever that changes (the browser page shows stderr in its footer and the // console): the quickest check that a client sees the room the relay holds. const std::vector drawn = room_view_->avatar_pids(); @@ -691,6 +815,28 @@ struct ConferenceSession { } } + // The room's LOD at work, for a scene that has mesh objects (the default room has none, so this + // never prints there): a line per new pyramid build, and the rung histogram with drawn against + // full-detail triangles when it changes, at most every 5 s. + void log_lod() { + if (room_view_->lod_builds() != logged_lod_builds_) { + logged_lod_builds_ = room_view_->lod_builds(); + std::fprintf(stderr, "arigl: room: %zu LOD pyramid build(s) started\n", logged_lod_builds_); + } + const cvc::gl::lod_stats &s = room_view_->lod_stats(); + const double now = steady_s(); + if (s.nodes == 0 || s.rung_nodes == logged_lod_rungs_ || now < next_lod_log_s_) + return; + logged_lod_rungs_ = s.rung_nodes; + next_lod_log_s_ = now + 5.0; + std::string rungs; + for (std::size_t k = 0; k < s.rung_nodes.size(); ++k) + rungs += (k ? " " : "") + std::to_string(s.rung_nodes[k]); + std::fprintf(stderr, "arigl: room: LOD %d object(s), per rung [%s], %llu of %llu tris drawn\n", + s.nodes, rungs.c_str(), static_cast(s.drawn_tris), + static_cast(s.full_tris)); + } + // One advance per frame. The driver advances the per-app world_clock from wall time (the single // media stamp, frame().t) and fires the conference's per-frame steps as the ordered observers // install_observers() registered. Called from the input pump (run mode) and directly per frame @@ -742,11 +888,13 @@ struct ConferenceSession { // Route peers' inbound frames (queued by the reader thread) into per-peer buffers; also // measures each peer's mic level. driver_->add_frame_observer(kOrderDrain, [this](const cf &) { drain_inbox(); }); - // Sum all peers' queued audio into the single mixed output (per-peer gain, skip muted). + // Sum all peers' queued audio into the single mixed output (per-peer volume times the room's + // distance gain, skip muted). driver_->add_frame_observer(kOrderMix, [this](const cf &) { mix_audio(); }); // Advance each peer's video to its audio clock (skip locally video-muted). driver_->add_frame_observer(kOrderShow, [this](const cf &) { sync_and_show(); }); - // Decay talk levels + publish media..talking for the name highlight. + // Decay talk levels + publish media..talking for the name highlight (and our own + // voice.talking to the room). driver_->add_frame_observer(kOrderActivity, [this](const cf &) { publish_activity(); }); } @@ -837,17 +985,40 @@ struct ConferenceSession { // Publish each participant's boolean "talking" to media..talking (self included) — true // while its talk-hold has not expired; the .ari copies it into peer..talking for the name - // highlight. + // highlight. Ours also goes to the room tree as our voice.talking leaf, on change only, and a + // peer whose audio is not fresh here (the relay sends us none of it, or its mic is off) is lit + // by that leaf of its own instead -- so a peer out of earshot still lights up while it talks. void publish_activity() { if (!app_) return; auto &st = cvc::state::instance(*app_); - auto set_talk = [&](const std::string &pid, double until) { - st(prefix_ + "." + media_token_key(pid, "talking")).value(until > now_wall_ ? "1" : "0"); + auto set_talk = [&](const std::string &pid, bool on) { + st(prefix_ + "." + media_token_key(pid, "talking")).value(on ? "1" : "0"); + }; + const bool self_on = self_talk_until_ > now_wall_; + set_talk(pid_, self_on); + if (self_on != voice_talking_) { + // Forced: our tree may hold self_on already, echoed back stale by the relay's re-broadcast. + cvc::ariadne::conference::write_participant_voice(*app_, room_, pid_, self_on, + /*force=*/true); + voice_talking_ = self_on; + } + const auto lit = [&](const std::string &pid, const peer_media *pm) { + if (pm && pm->talk_until > now_wall_) + return true; + if (pm && audio_fresh(*pm)) + return false; // its own audio says it is quiet + bool leaf = false; + return cvc::ariadne::conference::read_participant_voice(*app_, room_, pid, leaf) && leaf; }; - set_talk(pid_, self_talk_until_); for (auto &kv : peers_) - set_talk(kv.first, kv.second.talk_until); + set_talk(kv.first, lit(kv.first, &kv.second)); + // Participants that have sent us no media at all (out of range since they joined) are in the + // room's holon, not in peers_. + if (holon_) + for (const std::string &pid : holon_->participants()) + if (pid != pid_ && peers_.find(pid) == peers_.end()) + set_talk(pid, lit(pid, nullptr)); } void stop() { @@ -870,6 +1041,17 @@ struct ConferenceSession { // thread.) ~ConferenceSession() { stop(); } + // PID's audio gain for its distance from us in the room: aoi_audio_gain over the XY distance from + // where our avatar is drawn to where PID's is. Unity until the relay has published its AOI params + // and both poses are known (a peer not placed yet has no distance to fade by). + float room_gain(const std::string &pid) const { + if (!aoi_ || !holon_ || !holon_->pose_known(pid) || !holon_->pose_known(pid_)) + return 1.0f; + const cvc::physics::vec3 a = holon_->display_pose(pid_).position; + const cvc::physics::vec3 b = holon_->display_pose(pid).position; + return cvc::ariadne::conference::aoi_audio_gain(std::hypot(b.x - a.x, b.y - a.y), *aoi_); + } + // Mix: pop one chunk from each peer that has audio queued, sum them (silence for an empty peer), // clamp, and play to the single output. Produce mixed chunks while the output is below ~100 ms so // it stays fed without running far ahead; stop when no peer has a chunk (output drains to @@ -878,20 +1060,27 @@ struct ConferenceSession { if (!mix_out_ || mix_frames_ == 0 || mix_rate_ <= 0 || mix_ch_ <= 0) return; const double bps = static_cast(mix_rate_) * static_cast(mix_ch_) * 4.0; + // Each peer's gain this pump, in peers_ order: its local volume (the user's slider) times its + // distance gain in the room. + std::vector gains; + gains.reserve(peers_.size()); + for (const auto &kv : peers_) + gains.push_back(kv.second.audio_muted ? 0.0f : kv.second.volume * room_gain(kv.first)); std::vector mixed; for (int guard = 0; guard < 64; ++guard) { // bound work per pump if (bps > 0.0 && static_cast(mix_out_->queued_bytes()) / bps >= 0.10) break; mixed.assign(mix_frames_, 0.0f); bool any = false; + std::size_t gi = 0; for (auto &kv : peers_) { peer_media &pm = kv.second; + const float g = gains[gi++]; if (pm.achunks.empty()) continue; const std::vector &c = pm.achunks.front(); - if (!pm.audio_muted && pm.volume > 0.0f) { // locally muted -> consume but do not mix + if (g > 0.0f) { // muted, silent or out of earshot -> consume but do not mix const std::size_t n = std::min(c.size(), mixed.size()); - const float g = pm.volume; // per-peer local volume (user slider) for (std::size_t i = 0; i < n; ++i) mixed[i] += c[i] * g; } @@ -1014,6 +1203,7 @@ struct ConferenceSession { pm.achunks.pop_front(); } pm.last_audio_pts = m.pts_seconds; + pm.last_audio_wall = now_wall_; pm.has_audio = true; } else { // "camera" (video) if (!pm.video_proxy && m.width > 0 && m.height > 0) { @@ -1046,8 +1236,9 @@ struct ConferenceSession { // A/V sync: display the newest buffered video frame the peer's audio has reached. The audio // currently at the speaker has pts (last-enqueued pts - queued seconds); both streams were // stamped with the sender's world clock, so showing the frame at that pts lines the lips to the - // sound. With no audio from a peer the target is +inf, so the freshest frame shows immediately - // (no added delay). + // sound. With no fresh audio from a peer (none yet, or none for kAudioFreshS: a muted or cut mic) + // the target is +inf, so the freshest frame shows immediately (no added delay) instead of waiting + // on an audio clock that has stopped. void sync_and_show() { for (auto &kv : peers_) { peer_media &pm = kv.second; @@ -1060,7 +1251,7 @@ struct ConferenceSession { // This peer's audio latency = what sits in its mixer queue + what is queued in the shared // output. Show the video frame whose pts the audio has reached: last_audio_pts minus that. double target = 1e18; - if (pm.has_audio && mix_out_ && mix_rate_ > 0 && mix_ch_ > 0 && mix_frames_ > 0) { + if (audio_fresh(pm) && mix_out_ && mix_rate_ > 0 && mix_ch_ > 0 && mix_frames_ > 0) { const double bps = static_cast(mix_rate_) * static_cast(mix_ch_) * 4.0; const double out_s = bps > 0.0 ? static_cast(mix_out_->queued_bytes()) / bps : 0.0; const double chunk_s = static_cast(mix_frames_) / static_cast(mix_ch_) / @@ -1091,7 +1282,8 @@ struct ConferenceSession { "document locally.\n"); return false; } - void attach_view(cvc::gl::SceneRenderer &, cvc::gl::CameraController &) {} + void attach_view(cvc::gl::SceneRenderer &, cvc::gl::CameraController &, cvc::gl::ImGuiOverlay &) { + } void pump() {} void stop() {} #endif @@ -1166,7 +1358,7 @@ int run_window(const ari::HostOptions &o) { // locally (no relay) and say why, rather than letting the exception abort main. try { if (conf.setup(app, sg.getStatePrefix(), rt.backend())) - conf.attach_view(view, cam); // the room in 3D behind the conference windows + conf.attach_view(view, cam, ui); // the room in 3D behind the conference windows } catch (const std::exception &e) { std::fprintf(stderr, "arigl: conference setup failed (%s); running the document locally\n", e.what()); diff --git a/src/arigl/web/cyprty.html b/src/arigl/web/cyprty.html index b26f90e21..083d21ea5 100644 --- a/src/arigl/web/cyprty.html +++ b/src/arigl/web/cyprty.html @@ -46,7 +46,8 @@

cyberpcangel · cyprty

Ariadne conference — the browser joins the room and shows who is present, - live with native clients · Tab orbit/fly · drag to look · + live with native clients · WASD walk/strafe · click the floor to + face it · drag to orbit · libcvc
diff --git a/src/arisrv/CMakeLists.txt b/src/arisrv/CMakeLists.txt index 42ecd322e..b51a88d5d 100644 --- a/src/arisrv/CMakeLists.txt +++ b/src/arisrv/CMakeLists.txt @@ -43,3 +43,14 @@ endif() include("${CMAKE_CURRENT_LIST_DIR}/../../CMake/CvcInstallRpath.cmake") cvc_set_install_rpath(arisrv bin) install(TARGETS arisrv RUNTIME DESTINATION bin COMPONENT arisrv) + +# `arisrv_aoi_bench`: a headless load bench for the relay's area of interest — a relay built as +# arisrv builds it plus N synthetic clients in one process on loopback, reporting what each +# receiver got by kind, per-link rates, the journal, a late joiner's bootstrap and the probes. A +# development tool, built in-tree only (never by the standalone relay recipe) and not installed. It +# runs on whichever transports the build has (the parent adds this directory only with one). +if(NOT CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR AND (CVC_ENABLE_GRPC OR CVC_ENABLE_WS)) + add_executable(arisrv_aoi_bench aoi_bench.cpp) + target_compile_features(arisrv_aoi_bench PRIVATE cxx_std_17) + target_link_libraries(arisrv_aoi_bench PRIVATE cvc) +endif() diff --git a/src/arisrv/aoi_bench.cpp b/src/arisrv/aoi_bench.cpp new file mode 100644 index 000000000..d7557be7b --- /dev/null +++ b/src/arisrv/aoi_bench.cpp @@ -0,0 +1,1271 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. +*/ + +// arisrv_aoi_bench — a headless load bench for the relay's area of interest. One process runs a +// relay assembled as arisrv assembles it (the same switches, defaults and room step) and N +// synthetic clients assembled as arigl assembles them (a shard rooted at the room tree, a client +// holon, a camera and mic at their real rates, the avatar-marker echo into ui.docs, intents +// proposed as they walk, a net-stats leaf each second), all on loopback. After a warmup it +// measures, per receiver, what arrived by kind; per relay link, the bit rates out; the relay's +// journal and room-step time; a late joiner's bootstrap; and the probes that must hold whatever +// the traffic (clock re-alignments, avatars drawn at the origin before their pose, how far an +// outer peer's pose trails the relay's, how long a pair that turns inner waits for a fresh pose, +// how much reconcile corrected the clients' own avatars). +// +// arisrv_aoi_bench --clients 16 --layout clusters --scene large --aoi hall --seconds 20 +// arisrv_aoi_bench --clients 4 --layout spawn --s7 off --json before.json +// +// --s7 off is the relay and clients from before S7: no area of interest, no relay gate, a kept +// journal with backfill on connect, no media stall drop, and clients that observe their whole +// tree (so the echo goes up). Byte counts of state frames are approximate (path + value + a fixed +// frame overhead); the link totals are exact. + +#include +#include +#include +#include +#include +#include +#include +#include // avatar_intent +#include +#include +#include // room_join, room_leave, room_roster +#include // the per-participant and relay net leaves +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if defined(CVC_ENABLE_GRPC) +#include +#endif +#if defined(CVC_ENABLE_WS) +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ra = cvc::ariadne::conference; +using cvc::ariadne::holon; +using cvc::ariadne::holon_role; + +namespace { + +constexpr const char *kRoom = "standup"; +constexpr const char *kRelayId = "server"; +constexpr double kRoomStepS = 0.05; // arisrv's room step +constexpr double kFrameS = 1.0 / 30.0; // a client frame (arigl's publisher rate) +constexpr double kCameraHz = 30.0; // the synthetic camera +constexpr double kMicHz = 48000.0 / 1024.0; // one 1024-sample f32 mono chunk at 48 kHz +constexpr std::size_t kMicBytes = 1024 * 4; // 1024 f32 samples +constexpr std::size_t kFrameOverhead = 48; // an estimate of a state frame's non-path bytes + +double now_s() { + static const auto epoch = std::chrono::steady_clock::now(); + return std::chrono::duration(std::chrono::steady_clock::now() - epoch).count(); +} + +double xy_distance(const cvc::physics::vec3 &a, const cvc::physics::vec3 &b) { + return std::hypot(a.x - b.x, a.y - b.y); +} + +enum class link_kind { ws, grpc }; + +const char *kind_name(link_kind k) { return k == link_kind::ws ? "ws" : "grpc"; } + +bool kind_built(link_kind k) { +#if defined(CVC_ENABLE_WS) + if (k == link_kind::ws) + return true; +#endif +#if defined(CVC_ENABLE_GRPC) + if (k == link_kind::grpc) + return true; +#endif + (void)k; + return false; +} + +const std::string &participants_prefix() { + static const std::string p = ra::room_participant_path(kRoom, ""); + return p; +} + +// The participant (SUBJECT) and the family under it ("pose", "intent", ...) of a room leaf; +// false for a path outside the room's participants. +bool split_participant_path(const std::string &path, std::string &subject, std::string &family) { + const std::string &p = participants_prefix(); + if (path.compare(0, p.size(), p) != 0) + return false; + const std::size_t a = p.size(); + const std::size_t b = path.find('.', a); + if (b == std::string::npos) + return false; + subject = path.substr(a, b - a); + const std::size_t c = path.find('.', b + 1); + family = path.substr(b + 1, c == std::string::npos ? std::string::npos : c - b - 1); + return true; +} + +bool ends_with(const std::string &s, const std::string &suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +double percentile(std::vector v, double q) { + if (v.empty()) + return 0.0; + std::sort(v.begin(), v.end()); + const std::size_t i = static_cast(q * static_cast(v.size() - 1) + 0.5); + return v[(std::min)(i, v.size() - 1)]; +} + +struct counter { + std::uint64_t frames = 0; + std::uint64_t bytes = 0; + void add(std::uint64_t b) { + ++frames; + bytes += b; + } + counter operator-(const counter &o) const { return {frames - o.frames, bytes - o.bytes}; } +}; + +// What one client's shard applied and its transport delivered, counted on the reader thread. A +// live mutation carries its origin and journal sequence; a snapshot entry (admit, push, the +// WebSocket re-broadcast) carries neither. +struct receive_counts { + counter live_pose, live_intent, live_other; // live mutations: pose leaves, intents, the rest + counter snapshot; // snapshot entries + std::uint64_t pushed_ticks = 0; // snapshot entries for a pose's server_tick (one per push) + counter camera, mic; // media frames, payload bytes +}; + +class client_tap { +public: + void on_applied(const cvc::state_mutation &m) { + if (m.op != cvc::state_mutation_op::set_value) + return; + const bool is_live = !m.origin_node_id.empty(); + const std::uint64_t bytes = m.path.size() + m.string_value.size() + kFrameOverhead; + std::string subject, family; + const bool participant = split_participant_path(m.path, subject, family); + const bool tick = participant && family == "pose" && ends_with(m.path, ".pose.server_tick"); + std::lock_guard lk(_mu); + if (tick) { + std::uint64_t &hi = _max_tick[subject]; + hi = (std::max)( + hi, static_cast(std::strtoull(m.string_value.c_str(), nullptr, 10))); + } + if (!is_live) { + _c.snapshot.add(bytes); + if (tick) + ++_c.pushed_ticks; + } else if (participant && family == "pose") { + _c.live_pose.add(bytes); + } else if (participant && family == "intent") { + _c.live_intent.add(bytes); + } else { + _c.live_other.add(bytes); + } + } + void on_media(const cvc::media_frame &f) { + std::lock_guard lk(_mu); + (f.stream_id == "camera" ? _c.camera : _c.mic).add(f.payload.size()); + } + receive_counts counts() const { + std::lock_guard lk(_mu); + return _c; + } + std::uint64_t max_tick(const std::string &subject) const { + std::lock_guard lk(_mu); + const auto it = _max_tick.find(subject); + return it == _max_tick.end() ? 0 : it->second; + } + +private: + mutable std::mutex _mu; + receive_counts _c; + std::map _max_tick; +}; + +receive_counts operator-(const receive_counts &a, const receive_counts &b) { + receive_counts d; + d.live_pose = a.live_pose - b.live_pose; + d.live_intent = a.live_intent - b.live_intent; + d.live_other = a.live_other - b.live_other; + d.snapshot = a.snapshot - b.snapshot; + d.pushed_ticks = a.pushed_ticks - b.pushed_ticks; + d.camera = a.camera - b.camera; + d.mic = a.mic - b.mic; + return d; +} + +struct bench_config { + int clients = 4; + std::string layout = "spawn"; // spawn|corners|clusters|ring|walk + std::string scene = "default"; + std::string aoi; // off|conference|hall; empty: arisrv's default for the scene + int video_max = 0; + double seconds = 20.0; + double warmup = 5.0; + std::size_t video_bytes = 4096; + std::string transport; // ws|grpc|mixed; empty: mixed when both are built + bool s7 = true; + bool media = true; + bool move = true; + long media_stall_ms = 40; + std::string json; +}; + +// The relay, as arisrv builds and steps it. +class bench_relay { +public: + explicit bench_relay(const bench_config &cfg) { + gate = cfg.s7; + trim = cfg.s7; + shard = std::make_unique(app, "conf", kRelayId); + shard->add_interest("conf"); + shard->set_enforce_interest(true); + shard->attach(); + shard->install_as_default(); + const auto on_departed = [this](const std::string &node_id) { + std::lock_guard lk(_mu); + _departed.push_back(node_id); + }; +#if defined(CVC_ENABLE_GRPC) + grpc.set_on_connection_closed(on_departed); + grpc.set_relay_requires_interest(gate); + grpc.set_backfill_on_connect(!trim); + grpc.set_media_stall_drop(std::chrono::milliseconds(cfg.s7 ? cfg.media_stall_ms : 0)); + grpc.start("127.0.0.1:0", kRelayId, "conf"); + grpc.set_relay(true); + grpc.register_shard(shard.get()); + planes.push_back(&grpc); + router.add_endpoint(&grpc); +#endif +#if defined(CVC_ENABLE_WS) + ws.set_on_connection_closed(on_departed); + ws.set_relay_requires_interest(gate); + ws.set_backfill_on_connect(!trim); + ws.start("127.0.0.1:0", kRelayId, "conf"); + ws.set_relay(true); + ws.register_shard(shard.get()); + planes.push_back(&ws); + router.add_endpoint(&ws); +#endif +#if defined(CVC_ENABLE_GRPC) && defined(CVC_ENABLE_WS) + grpc.set_bridge(&ws); + ws.set_bridge(&grpc); +#endif + if (cfg.scene == "large") + ra::seed_large_demo_scene(app, kRoom); + else + ra::seed_default_scene(app, kRoom); + if (cfg.s7 && cfg.aoi != "off") { + params = cfg.aoi == "hall" ? ra::hall_aoi_params() : ra::conference_aoi_params(); + params.video_full_max = cfg.video_max; + aoi = std::make_unique(app, *shard, kRoom, params); + for (cvc::state_transport *tp : planes) + aoi->add_transport(tp); +#if defined(CVC_ENABLE_GRPC) + aoi->add_media_endpoint(&grpc); +#endif +#if defined(CVC_ENABLE_WS) + aoi->add_media_endpoint(&ws); +#endif + ra::write_aoi_params(app, kRoom, params); + } + } + + ~bench_relay() { + aoi.reset(); +#if defined(CVC_ENABLE_GRPC) && defined(CVC_ENABLE_WS) + grpc.set_bridge(nullptr); + ws.set_bridge(nullptr); +#endif +#if defined(CVC_ENABLE_WS) + ws.unregister_shard(shard.get()); + ws.stop(); +#endif +#if defined(CVC_ENABLE_GRPC) + grpc.unregister_shard(shard.get()); + grpc.stop(); +#endif + shard->detach(); + } + + bench_relay(const bench_relay &) = delete; + bench_relay &operator=(const bench_relay &) = delete; + + // One room step of arisrv's loop: departures, server_pump, relay_room_step; and, on their own + // ~1 s cadences, the WebSocket re-broadcast and the relay's net leaves. A participant listed in + // place_on_join is moved there on the step that adds it, before the area of interest sees it. + void step() { + std::vector gone; + { + std::lock_guard lk(_mu); + gone.swap(_departed); + } + for (const std::string &nid : gone) { + ra::room_leave(app, kRoom, nid); + cvc::ariadne::write_intent(app, kRoom, nid, cvc::ariadne::avatar_intent{}); + } + cvc::ariadne::server_pump(server, app, kRoom); + // Joins land on transport threads, so a pump can add a newcomer the pass before it never saw: + // place newcomers and re-pump until a pass finds none. + for (int pass = 0; pass < 8; ++pass) { + bool moved = false; + for (const std::string &pid : server.participants()) { + if (!_known.insert(pid).second) + continue; + const auto it = place_on_join.find(pid); + if (it != place_on_join.end()) { + cvc::physics::body_state s; + s.position = {it->second.first, it->second.second, 0.0}; + s.active = true; + server.override_state(pid, s); + moved = true; + } + } + if (!moved) + break; + cvc::ariadne::server_pump(server, app, kRoom); + } + const double t0 = now_s(); + ra::relay_room_step(*shard, planes, aoi.get(), server, now_s(), trim); + step_ms.push_back((now_s() - t0) * 1000.0); + journal_max = (std::max)(journal_max, shard->journal().size()); + const double t = now_s(); +#if defined(CVC_ENABLE_WS) + if (t >= _next_broadcast) { + ws.broadcast_snapshot(); + _next_broadcast = t + 1.0; + } +#endif + if (t >= _next_net) { + _next_net = t + 1.0; + write_net(t); + } + } + + std::vector links() const { + std::vector out; + for (const cvc::state_transport *tp : planes) + for (cvc::state_link_report &r : tp->link_reports()) + out.push_back(std::move(r)); + return out; + } + std::uint64_t withheld() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_relay_withheld(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_relay_withheld(); +#endif + return n; + } + std::uint64_t media_gated() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_media_gated(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_media_gated(); +#endif + return n; + } + std::uint64_t stall_dropped() const { +#if defined(CVC_ENABLE_GRPC) + return grpc.total_media_stall_dropped(); +#else + return 0; +#endif + } + std::uint64_t backfilled() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_backfilled(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_backfilled(); +#endif + return n; + } + ra::aoi_tier tier(const std::string &receiver, const std::string &subject) const { + const auto snap = aoi ? aoi->snapshot() : nullptr; + return snap ? snap->tier(receiver, subject) : ra::aoi_tier::inner; + } + + bool gate = false, trim = false; + ra::aoi_params params; + cvc::app app; + std::unique_ptr shard; +#if defined(CVC_ENABLE_GRPC) + cvc::state_transport_grpc grpc; +#endif +#if defined(CVC_ENABLE_WS) + cvc::state_transport_ws ws; +#endif + cvc::media_router router; + holon server{holon_role::server, cvc::ariadne::room_holon_config()}; + std::unique_ptr aoi; + std::vector planes; + std::map> place_on_join; + std::vector step_ms; + std::size_t journal_max = 0; + +private: + // arisrv's ~1 s link stats: each participant's link as the relay sees it, and the totals. + void write_net(double t) { + const std::vector ls = links(); + const auto rates = _meter.update(ls, t); + std::set present; + for (const auto &p : ra::room_roster(app, kRoom)) + present.insert(p.pid); + ra::server_net total; + for (const cvc::state_link_report &r : ls) { + const auto it = rates.find(cvc::state_link_rate_meter::key_of(r)); + const double in_kbps = it == rates.end() ? 0.0 : it->second.in_bps * 8.0 / 1000.0; + const double out_kbps = it == rates.end() ? 0.0 : it->second.out_bps * 8.0 / 1000.0; + total.in_kbps += in_kbps; + total.out_kbps += out_kbps; + ++total.links; + if (present.count(r.remote_node_id)) + ra::write_relay_net(app, kRoom, r.remote_node_id, {r.stats.rtt_ms, in_kbps, out_kbps}); + } + ra::write_server_net(app, kRoom, total); + } + + std::mutex _mu; + std::vector _departed; + std::set _known; + cvc::state_link_rate_meter _meter; + double _next_broadcast = 0.0, _next_net = 1.0; +}; + +// A synthetic participant, as arigl runs one. +class bench_client { +public: + bench_client(std::string id, link_kind k, const bench_config &cfg) + : pid(std::move(id)), kind(k), _cfg(cfg) { + // Rooted at the room tree; the client from before S7 observed its whole tree. + shard = std::make_unique(app, "conf", pid, cfg.s7 ? "conf" : ""); + shard->add_interest("conf"); + shard->set_enforce_interest(true); + shard->attach(); + shard->adapter().set_on_remote_applied( + [this](const cvc::state_mutation &m) { tap.on_applied(m); }); + } + ~bench_client() { + disconnect(); + shard->adapter().set_on_remote_applied({}); + shard->detach(); + } + bench_client(const bench_client &) = delete; + bench_client &operator=(const bench_client &) = delete; + + bool connect(bench_relay &r) { + const auto on_media = [this](const cvc::media_frame &f) { tap.on_media(f); }; +#if defined(CVC_ENABLE_GRPC) + if (kind == link_kind::grpc) { + _grpc = std::make_unique(); + _grpc->start("127.0.0.1:0", pid, "conf"); + _grpc->register_shard(shard.get()); + _grpc->set_on_media(on_media); + tp = _grpc.get(); + return _grpc->connect_to_peer(r.grpc.listen_address(), std::chrono::milliseconds(8000)); + } +#endif +#if defined(CVC_ENABLE_WS) + if (kind == link_kind::ws) { + _ws = std::make_unique(); + _ws->register_shard(shard.get()); + _ws->set_on_media(on_media); + tp = _ws.get(); + return _ws->connect_to_peer("ws://" + r.ws.listen_address(), std::chrono::milliseconds(8000)); + } +#endif + (void)r; + (void)on_media; + return false; + } + + void disconnect() { +#if defined(CVC_ENABLE_GRPC) + if (_grpc) { + _grpc->stop(); + _grpc->unregister_shard(shard.get()); + _grpc.reset(); + } +#endif +#if defined(CVC_ENABLE_WS) + if (_ws) { + _ws->stop(); + _ws->unregister_shard(shard.get()); + _ws.reset(); + } +#endif + tp = nullptr; + } + + // Where this participant stands and how it moves: along one axis AMPLITUDE either side of + // (ANCHOR_X, ANCHOR_Y), or (ROAM) between pseudo-random waypoints inside +-BOUND. + void set_motion(double ax, double ay, bool along_y, bool roam, double bound, unsigned seed) { + _ax = ax; + _ay = ay; + _along_y = along_y; + _roam = roam; + _bound = bound; + _rng = seed * 2654435761u + 1u; + _target_x = ax; + _target_y = ay; + } + + void join() { + ra::room_join(app, kRoom, pid, pid); + if (tp) + tp->pump_all(); + } + + // One client frame: walk (proposing an intent when it changes), the holon against the room, + // probes, the avatar-marker echo, the ~1 s net leaf, then the frame's writes sent. + void frame(bool moving, bool measuring) { + if (moving) + drive(); + cvc::ariadne::client_pump(h, app, kRoom, pid); + if (h.has_participant(pid)) { + const cvc::physics::vec3 c = h.take_correction(pid); + if (measuring) + corrections_m += std::sqrt(cvc::physics::dot(c, c)); + } + if (measuring) + for (const std::string &q : h.participants()) + if (q != pid && h.has_body(q) && !h.pose_known(q)) + ++ghost_frames; // a body held at a placeholder before the first pose + write_echo(); + const double t = now_s(); + if (tp && t >= _next_net) { + _next_net = t + 1.0; + for (const cvc::state_link_report &r : tp->link_reports()) + ra::write_participant_net(app, kRoom, pid, {r.stats.rtt_ms, -1.0, -1.0}); + } + if (tp) + tp->pump_all(); + } + + void set_media(bool on) { + _media = on; + _next_cam = _next_mic = now_s(); + } + void send_due_media(double now) { + if (!_media || !tp) + return; + if (now >= _next_cam) { + send("camera", "mjpeg", _cfg.video_bytes, cam_sent++); + _next_cam += 1.0 / kCameraHz; + if (_next_cam < now) + _next_cam = now + 1.0 / kCameraHz; + } + if (now >= _next_mic) { + send("mic", "f32", kMicBytes, mic_sent++); + _next_mic += 1.0 / kMicHz; + if (_next_mic < now) + _next_mic = now + 1.0 / kMicHz; + } + } + + bool tree_pose(const std::string &q, cvc::physics::body_state &s, std::uint64_t &tick) { + return cvc::ariadne::read_pose(app, kRoom, q, s, tick); + } + cvc::state_link_stats::counters link() const { + cvc::state_link_stats::counters c; + if (tp) + for (const cvc::state_link_report &r : tp->link_reports()) { + c.bytes_in += r.stats.bytes_in; + c.bytes_out += r.stats.bytes_out; + c.frames_in += r.stats.frames_in; + c.frames_out += r.stats.frames_out; + } + return c; + } + + const std::string pid; + const link_kind kind; + cvc::app app; + client_tap tap; + std::unique_ptr shard; + holon h{holon_role::client, cvc::ariadne::room_holon_config()}; + cvc::state_transport *tp = nullptr; + std::uint64_t cam_sent = 0, mic_sent = 0; + double corrections_m = 0.0; // reconcile jumps of our own avatar while measuring, summed + std::uint64_t ghost_frames = 0; // peer-frames with a body but no pose while measuring + +private: + double rand01() { + _rng = _rng * 1664525u + 1013904223u; + return static_cast(_rng >> 8) / static_cast(1u << 24); + } + + void drive() { + if (!h.has_participant(pid) || !h.pose_known(pid)) + return; + const cvc::physics::vec3 p = h.state(pid).position; + constexpr double kPatrolSpeed = 1.5, kRoamSpeed = 2.0, kAmplitude = 1.0; + cvc::ariadne::avatar_intent in; + if (_roam) { + // A new waypoint once this one is reached, or after 10 s (one behind the reference box, or + // another avatar, may never be reached). + const double t = now_s(); + if (std::hypot(_target_x - p.x, _target_y - p.y) < 0.5 || t >= _target_until) { + _target_x = (2.0 * rand01() - 1.0) * _bound; + _target_y = (2.0 * rand01() - 1.0) * _bound; + _target_until = t + 10.0; + } + const double dx = _target_x - p.x, dy = _target_y - p.y, d = std::hypot(dx, dy); + if (d > 1e-6) + in.move = {kRoamSpeed * dx / d, kRoamSpeed * dy / d, 0.0}; + } else { + if (((_along_y ? p.y - _ay : p.x - _ax) * _dir) >= kAmplitude) + _dir = -_dir; + in.move = {_along_y ? 0.0 : _dir * kPatrolSpeed, _along_y ? _dir * kPatrolSpeed : 0.0, 0.0}; + } + in.yaw = (in.move.x != 0.0 || in.move.y != 0.0) ? std::atan2(in.move.y, in.move.x) : _last.yaw; + // Each proposal is a replicated write: only when the walk changes (a waypoint, a turn). + const bool same = _proposed && std::fabs(in.move.x - _last.move.x) < 0.05 && + std::fabs(in.move.y - _last.move.y) < 0.05; + if (same) + return; + cvc::ariadne::propose_intent(h, app, kRoom, pid, in); + _last = in; + _proposed = true; + } + + void send(const char *stream, const char *codec, std::size_t bytes, std::uint64_t seq) { + cvc::media_frame f; + f.origin_node_id = pid; + f.stream_id = stream; + f.codec = codec; + if (std::strcmp(stream, "camera") == 0) { + f.width = 640; + f.height = 480; + } else { + f.sample_rate = 48000; + f.channels = 1; + } + f.seq = static_cast(seq); + f.pts_seconds = now_s(); + f.payload.assign(bytes, static_cast(0x5a)); +#if defined(CVC_ENABLE_GRPC) + if (_grpc) + _grpc->publish_media_frame(f); +#endif +#if defined(CVC_ENABLE_WS) + if (_ws) + _ws->publish_media_frame(f); +#endif + } + + // room_view's per-frame body and visor marker matrices for each shown avatar, into the + // document's UI tree. A shard rooted at the room tree never observes them. + void write_echo() { + char buf[32]; + for (const std::string &q : h.participants()) { + if (!h.pose_known(q)) + continue; + const cvc::physics::pose p = h.display_pose(q); + const cvc::physics::quat &r = p.rotation; + double m[16] = {}; + m[0] = 1 - 2 * (r.y * r.y + r.z * r.z); + m[1] = 2 * (r.x * r.y + r.w * r.z); + m[2] = 2 * (r.x * r.z - r.w * r.y); + m[4] = 2 * (r.x * r.y - r.w * r.z); + m[5] = 1 - 2 * (r.x * r.x + r.z * r.z); + m[6] = 2 * (r.y * r.z + r.w * r.x); + m[8] = 2 * (r.x * r.z + r.w * r.y); + m[9] = 2 * (r.y * r.z - r.w * r.x); + m[10] = 1 - 2 * (r.x * r.x + r.y * r.y); + m[12] = p.position.x; + m[13] = p.position.y; + m[14] = p.position.z + 0.85; + m[15] = 1.0; + for (const char *part : {"avatar_", "visor_"}) { + std::string v; + for (double d : m) { + std::snprintf(buf, sizeof buf, "%.17g ", d); + v += buf; + } + app.root()(std::string("ui.docs.ari.room.") + part + q + ".matrix").value(v); + } + } + } + + const bench_config &_cfg; +#if defined(CVC_ENABLE_GRPC) + std::unique_ptr _grpc; +#endif +#if defined(CVC_ENABLE_WS) + std::unique_ptr _ws; +#endif + bool _media = false; + double _next_cam = 0.0, _next_mic = 0.0, _next_net = 0.0; + double _ax = 0.0, _ay = 0.0, _bound = 8.0, _dir = 1.0, _target_x = 0.0, _target_y = 0.0; + double _target_until = 0.0; + bool _along_y = false, _roam = false, _proposed = false; + unsigned _rng = 1; + cvc::ariadne::avatar_intent _last; +}; + +// The probes the main loop samples while measuring. +struct probes { + std::vector outer_error_m; // a non-inner subject's pose in a receiver's tree vs relay's + std::vector entry_ms; // a pair turning inner until the receiver has a fresh pose + std::size_t entries_unresolved = 0; + struct pending { + bench_client *receiver = nullptr; + std::string subject; + double t = 0.0; + std::uint64_t tick = 0; + }; + std::vector waiting; + std::map, ra::aoi_tier> tiers; + std::uint64_t generation = 0; +}; + +// After a room step: pairs whose tier just turned inner start waiting for a fresh pose, and every +// non-inner pair's pose error is sampled. +void probe_step(bench_relay &r, const std::vector &cs, probes &pr) { + const auto snap = r.aoi ? r.aoi->snapshot() : nullptr; + if (!snap) + return; + if (snap->generation() != pr.generation) { + pr.generation = snap->generation(); + for (bench_client *rc : cs) + for (bench_client *q : cs) { + if (q == rc) + continue; + const ra::aoi_tier t = snap->tier(rc->pid, q->pid); + const auto key = std::make_pair(rc->pid, q->pid); + const auto it = pr.tiers.find(key); + if (it != pr.tiers.end() && it->second != ra::aoi_tier::inner && t == ra::aoi_tier::inner) + pr.waiting.push_back({rc, q->pid, now_s(), r.server.tick()}); + pr.tiers[key] = t; + } + } + for (bench_client *rc : cs) + for (bench_client *q : cs) { + if (q == rc || snap->tier(rc->pid, q->pid) == ra::aoi_tier::inner) + continue; + cvc::physics::body_state rs, ps; + std::uint64_t rt = 0, pt = 0; + if (cvc::ariadne::read_pose(r.app, kRoom, q->pid, rs, rt) && rc->tree_pose(q->pid, ps, pt)) + pr.outer_error_m.push_back(xy_distance(rs.position, ps.position)); + } +} + +// After a round of frames: a waiting pair resolves once its receiver holds a pose of the subject +// at least as fresh as the tick of the step that turned it inner. +void probe_frame(probes &pr) { + const double t = now_s(); + for (auto it = pr.waiting.begin(); it != pr.waiting.end();) { + if (it->receiver->tap.max_tick(it->subject) >= it->tick) { + pr.entry_ms.push_back((t - it->t) * 1000.0); + it = pr.waiting.erase(it); + } else { + ++it; + } + } +} + +// Run the room in real time for SECONDS (a room step every 50 ms, a client frame every 1/30 s, +// media when due), with the probes when MEASURING; returns early, true, once UNTIL holds. +bool run_room(bench_relay &r, const std::vector &cs, double seconds, bool moving, + bool measuring, probes *pr, const std::function &until = {}) { + const double t0 = now_s(); + double next_step = t0, next_frame = t0; + for (;;) { + const double t = now_s(); + if (t >= next_step) { + r.step(); + if (measuring && pr) + probe_step(r, cs, *pr); + next_step += kRoomStepS; + if (next_step < t) + next_step = t + kRoomStepS; + } + if (t >= next_frame) { + for (bench_client *c : cs) + c->frame(moving, measuring); + if (measuring && pr) + probe_frame(*pr); + next_frame += kFrameS; + if (next_frame < t) + next_frame = t + kFrameS; + if (until && until()) + return true; + } + for (bench_client *c : cs) + c->send_due_media(t); + if (t - t0 >= seconds) + return until ? until() : true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +bool settled(bench_relay &r, const std::vector &cs) { + const auto snap = r.aoi ? r.aoi->snapshot() : nullptr; + for (bench_client *c : cs) { + if (!r.server.has_participant(c->pid)) + return false; + if (r.aoi) { + if (!snap) + return false; + const auto &ps = snap->participants(); + if (std::find(ps.begin(), ps.end(), c->pid) == ps.end()) + return false; + } + for (bench_client *q : cs) + if (!c->h.has_participant(q->pid) || !c->h.pose_known(q->pid)) + return false; + } + return true; +} + +// Each participant's anchor for LAYOUT; ROAM for the walk layout. +struct placement { + bool placed = false; // false: where the server spawns it (its 4 m ring) + double x = 0.0, y = 0.0; + bool along_y = false; +}; + +std::vector layout_of(const bench_config &cfg) { + std::vector out(static_cast(cfg.clients)); + const double pi = 3.14159265358979323846; + for (int i = 0; i < cfg.clients; ++i) { + placement &p = out[static_cast(i)]; + p.along_y = i % 2 == 1; + if (cfg.layout == "corners") { + // The default room's corners (+-8, +-8), each further one a metre in toward the centre. + const double s = (std::max)(1.0, 8.0 - static_cast(i / 4)); + p.placed = true; + p.x = (i % 4 == 0 || i % 4 == 3) ? s : -s; + p.y = (i % 4 < 2) ? s : -s; + } else if (cfg.layout == "clusters") { + // Clusters of four within 2 m, centres 30 m apart on a grid around the origin. + const int k = i / 4, cols = 2; + const double cx = (k % cols) * 30.0 - 15.0, cy = (k / cols) * 30.0 - 15.0; + p.placed = true; + p.x = cx + ((i % 2) ? 0.7 : -0.7); + p.y = cy + ((i % 4 < 2) ? 0.7 : -0.7); + } else if (cfg.layout == "ring") { + const double a = 2.0 * pi * i / cfg.clients; + p.placed = true; + p.x = 7.5 * std::cos(a); + p.y = 7.5 * std::sin(a); + } + } + return out; +} + +bool check_choice(const char *flag, const std::string &v, std::initializer_list ok) { + std::string all; + for (const char *o : ok) { + if (v == o) + return true; + if (!all.empty()) + all += '|'; + all += o; + } + std::fprintf(stderr, "arisrv_aoi_bench: %s expects %s, got '%s'\n", flag, all.c_str(), v.c_str()); + return false; +} + +void usage() { + std::fprintf(stderr, + "usage: arisrv_aoi_bench [--clients N] [--layout spawn|corners|clusters|ring|walk]\n" + " [--scene default|large] [--aoi off|conference|hall] [--video-max K]\n" + " [--seconds S] [--warmup W] [--video-bytes B] [--transport ws|grpc|mixed]\n" + " [--s7 on|off] [--media on|off] [--move on|off] [--media-stall-ms N]\n" + " [--json out.json]\n"); +} + +std::string json_str(const std::string &s) { + std::string out = "\""; + for (char c : s) { + if (c == '"' || c == '\\') + out += '\\'; + out += c; + } + return out + "\""; +} + +std::string json_counter(const counter &c, double secs) { + char buf[160]; + std::snprintf(buf, sizeof buf, + "{\"frames\": %llu, \"bytes\": %llu, \"fps\": %.2f, \"bps\": %.0f}", + static_cast(c.frames), static_cast(c.bytes), + c.frames / secs, c.bytes * 8.0 / secs); + return buf; +} + +} // namespace + +int main(int argc, char **argv) { + bench_config cfg; + std::string s7 = "on", media = "on", move = "on"; + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + const auto next = [&]() -> const char * { + if (i + 1 >= argc) { + std::fprintf(stderr, "arisrv_aoi_bench: %s needs a value\n", a); + std::exit(2); + } + return argv[++i]; + }; + if (!std::strcmp(a, "--clients")) + cfg.clients = std::atoi(next()); + else if (!std::strcmp(a, "--layout")) + cfg.layout = next(); + else if (!std::strcmp(a, "--scene")) + cfg.scene = next(); + else if (!std::strcmp(a, "--aoi")) + cfg.aoi = next(); + else if (!std::strcmp(a, "--video-max")) + cfg.video_max = (std::max)(0, std::atoi(next())); + else if (!std::strcmp(a, "--seconds")) + cfg.seconds = std::atof(next()); + else if (!std::strcmp(a, "--warmup")) + cfg.warmup = std::atof(next()); + else if (!std::strcmp(a, "--video-bytes")) + cfg.video_bytes = static_cast((std::max)(1L, std::atol(next()))); + else if (!std::strcmp(a, "--transport")) + cfg.transport = next(); + else if (!std::strcmp(a, "--s7")) + s7 = next(); + else if (!std::strcmp(a, "--media")) + media = next(); + else if (!std::strcmp(a, "--move")) + move = next(); + else if (!std::strcmp(a, "--media-stall-ms")) + cfg.media_stall_ms = (std::max)(0L, std::atol(next())); + else if (!std::strcmp(a, "--json")) + cfg.json = next(); + else if (!std::strcmp(a, "--help") || !std::strcmp(a, "-h")) { + usage(); + return 0; + } else { + std::fprintf(stderr, "arisrv_aoi_bench: unknown argument '%s'\n", a); + usage(); + return 2; + } + } + if (cfg.aoi.empty()) + cfg.aoi = cfg.scene == "large" ? "hall" : "conference"; // arisrv's default + if (cfg.transport.empty()) + cfg.transport = kind_built(link_kind::ws) && kind_built(link_kind::grpc) ? "mixed" + : kind_built(link_kind::ws) ? "ws" + : "grpc"; + if (!check_choice("--layout", cfg.layout, {"spawn", "corners", "clusters", "ring", "walk"}) || + !check_choice("--scene", cfg.scene, {"default", "large"}) || + !check_choice("--aoi", cfg.aoi, {"off", "conference", "hall"}) || + !check_choice("--transport", cfg.transport, {"ws", "grpc", "mixed"}) || + !check_choice("--s7", s7, {"on", "off"}) || !check_choice("--media", media, {"on", "off"}) || + !check_choice("--move", move, {"on", "off"})) + return 2; + cfg.s7 = s7 == "on"; + cfg.media = media == "on"; + cfg.move = move == "on"; + if (!cfg.s7) + cfg.aoi = "off"; + if (cfg.clients < 1 || !(cfg.seconds > 0.0) || cfg.warmup < 0.0) { + std::fprintf(stderr, "arisrv_aoi_bench: --clients >= 1, --seconds > 0, --warmup >= 0\n"); + return 2; + } + std::vector kinds; + if (cfg.transport == "ws" || cfg.transport == "mixed") + kinds.push_back(link_kind::ws); + if (cfg.transport == "grpc" || cfg.transport == "mixed") + kinds.push_back(link_kind::grpc); + for (link_kind k : kinds) + if (!kind_built(k)) { + std::fprintf(stderr, "arisrv_aoi_bench: this build has no %s transport\n", kind_name(k)); + return 2; + } + + std::printf("[bench] %d client(s) | layout %s | scene %s | S7 %s | aoi %s (full-rate cameras %d)" + " | %s | media %s (%zu B video) | move %s | warmup %.1f s, measure %.1f s\n", + cfg.clients, cfg.layout.c_str(), cfg.scene.c_str(), cfg.s7 ? "on" : "off", + cfg.aoi.c_str(), cfg.video_max, cfg.transport.c_str(), cfg.media ? "on" : "off", + cfg.video_bytes, cfg.move ? "on" : "off", cfg.warmup, cfg.seconds); + std::fflush(stdout); + + bench_relay relay(cfg); + std::vector> own; + std::vector cs; + const std::vector places = layout_of(cfg); + const double bound = cfg.scene == "large" ? 40.0 : 8.0; + for (int i = 0; i < cfg.clients; ++i) { + char id[16]; + std::snprintf(id, sizeof id, "c%02d", i); + own.push_back( + std::make_unique(id, kinds[static_cast(i) % kinds.size()], cfg)); + cs.push_back(own.back().get()); + const placement &p = places[static_cast(i)]; + if (p.placed) + relay.place_on_join[id] = {p.x, p.y}; + } + for (bench_client *c : cs) { + if (!c->connect(relay)) { + std::fprintf(stderr, "arisrv_aoi_bench: %s could not reach the relay\n", c->pid.c_str()); + return 1; + } + c->join(); + } + if (!run_room(relay, cs, 30.0, false, false, nullptr, [&] { return settled(relay, cs); })) { + std::fprintf(stderr, "arisrv_aoi_bench: the room never settled\n"); + return 1; + } + // Each one's walk around where it stands (or, for the walk layout, all over the scene). + for (std::size_t i = 0; i < cs.size(); ++i) { + const cvc::physics::vec3 at = relay.server.state(cs[i]->pid).position; + cs[i]->set_motion(at.x, at.y, places[i].along_y, cfg.layout == "walk", bound, + static_cast(i + 1)); + } + for (bench_client *c : cs) + c->set_media(cfg.media); + run_room(relay, cs, cfg.warmup, cfg.move, false, nullptr); + + // The measured window. + std::map> link0; // key -> (in, out) + for (const cvc::state_link_report &r : relay.links()) + link0[cvc::state_link_rate_meter::key_of(r)] = {r.stats.bytes_in, r.stats.bytes_out}; + std::vector rc0; + std::vector cl0; + std::vector realign0; + for (bench_client *c : cs) { + rc0.push_back(c->tap.counts()); + cl0.push_back(c->link()); + realign0.push_back(c->h.clock_realigns()); + } + const std::uint64_t withheld0 = relay.withheld(), gated0 = relay.media_gated(); + const std::uint64_t stalled0 = relay.stall_dropped(); + if (relay.aoi) + relay.aoi->take_stats(); + relay.step_ms.clear(); + relay.journal_max = 0; + probes pr; + const double t0 = now_s(); + run_room(relay, cs, cfg.seconds, cfg.move, true, &pr); + const double secs = now_s() - t0; + pr.entries_unresolved = pr.waiting.size(); + ra::room_aoi_relay::stats st; + if (relay.aoi) + st = relay.aoi->take_stats(); + const std::size_t journal_end = relay.shard->journal().size(); + + // A late joiner: connects (it never joins) and bootstraps; its bytes once its tree holds every + // participant's pose (to within half a metre of the relay's: they keep walking) and the scene. + for (bench_client *c : cs) + c->set_media(false); + const std::uint64_t backfilled0 = relay.backfilled(); + bench_client late("late", kinds[cs.size() % kinds.size()], cfg); + const double tj = now_s(); + bool joined = late.connect(relay); + const auto converged = [&] { + for (bench_client *q : cs) { + cvc::physics::body_state rs, ls; + std::uint64_t rt = 0, lt = 0; + if (!cvc::ariadne::read_pose(relay.app, kRoom, q->pid, rs, rt) || + !late.tree_pose(q->pid, ls, lt) || xy_distance(rs.position, ls.position) > 0.5) + return false; + } + return ra::scene_revision(late.app, kRoom) == ra::scene_revision(relay.app, kRoom); + }; + joined = joined && run_room(relay, cs, 10.0, cfg.move, false, nullptr, converged); + const double join_s = now_s() - tj; + const std::uint64_t join_bytes = late.link().bytes_in; + const std::uint64_t join_backfilled = relay.backfilled() - backfilled0; + + // The report. + std::uint64_t realigns = 0, ghosts = 0; + double corrections = 0.0; + std::string jr; + std::printf("[bench] measured %.2f s\n", secs); + std::printf("[bench] receiver link in kb/s | pose live fps | intent | other | snapshot " + "(pushed ticks) | camera fps | mic fps | realigns\n"); + for (std::size_t i = 0; i < cs.size(); ++i) { + bench_client &c = *cs[i]; + const receive_counts d = c.tap.counts() - rc0[i]; + const std::uint64_t in_bytes = c.link().bytes_in - cl0[i].bytes_in; + const std::uint64_t ra_n = c.h.clock_realigns() - realign0[i]; + realigns += ra_n; + ghosts += c.ghost_frames; + corrections += c.corrections_m; + std::printf( + "[bench] %-6s %-4s %9.1f | %7.1f %6.1fk | %6.1f | %5.1f | %6.1f (%llu) | %10.1f | " + "%7.1f | %llu\n", + c.pid.c_str(), kind_name(c.kind), in_bytes * 8.0 / 1000.0 / secs, d.live_pose.frames / secs, + d.live_pose.bytes / 1000.0 / secs, d.live_intent.frames / secs, d.live_other.frames / secs, + d.snapshot.frames / secs, static_cast(d.pushed_ticks), + d.camera.frames / secs, d.mic.frames / secs, static_cast(ra_n)); + char head[256]; + std::snprintf(head, sizeof head, + " {\"pid\": %s, \"transport\": \"%s\", \"in_bytes\": %llu, \"in_bps\": %.0f, " + "\"realigns\": %llu, \"corrections_m\": %.4f, \"ghost_frames\": %llu,\n", + json_str(c.pid).c_str(), kind_name(c.kind), + static_cast(in_bytes), in_bytes * 8.0 / secs, + static_cast(ra_n), c.corrections_m, + static_cast(c.ghost_frames)); + jr += head; + jr += " \"live_pose\": " + json_counter(d.live_pose, secs) + + ", \"live_intent\": " + json_counter(d.live_intent, secs) + + ",\n \"live_other\": " + json_counter(d.live_other, secs) + + ", \"snapshot\": " + json_counter(d.snapshot, secs) + + ",\n \"pushed_ticks\": " + std::to_string(d.pushed_ticks) + + ", \"camera\": " + json_counter(d.camera, secs) + + ", \"mic\": " + json_counter(d.mic, secs) + "}"; + jr += i + 1 < cs.size() ? ",\n" : "\n"; + } + + double out_total = 0.0, in_total = 0.0; + std::string jl; + std::printf("[bench] relay links (out / in kb/s):\n"); + const std::vector links = relay.links(); + for (std::size_t i = 0; i < links.size(); ++i) { + const cvc::state_link_report &r = links[i]; + const auto it = link0.find(cvc::state_link_rate_meter::key_of(r)); + const std::uint64_t in0 = it == link0.end() ? 0 : it->second.first; + const std::uint64_t out0 = it == link0.end() ? 0 : it->second.second; + const double out_bps = (r.stats.bytes_out - out0) * 8.0 / secs; + const double in_bps = (r.stats.bytes_in - in0) * 8.0 / secs; + out_total += out_bps; + in_total += in_bps; + std::printf("[bench] %-4s %-8s %10.1f / %8.1f\n", r.transport.c_str(), + r.remote_node_id.c_str(), out_bps / 1000.0, in_bps / 1000.0); + char buf[200]; + std::snprintf( + buf, sizeof buf, + " {\"transport\": \"%s\", \"peer\": %s, \"out_bps\": %.0f, \"in_bps\": %.0f}%s\n", + r.transport.c_str(), json_str(r.remote_node_id).c_str(), out_bps, in_bps, + i + 1 < links.size() ? "," : ""); + jl += buf; + } + const double step_p50 = percentile(relay.step_ms, 0.5), + step_p99 = percentile(relay.step_ms, 0.99); + const double step_max = + relay.step_ms.empty() ? 0.0 : *std::max_element(relay.step_ms.begin(), relay.step_ms.end()); + const double err_max = pr.outer_error_m.empty() + ? 0.0 + : *std::max_element(pr.outer_error_m.begin(), pr.outer_error_m.end()); + std::printf("[bench] relay out %.1f kb/s, in %.1f kb/s | journal %zu (max %zu) | room step ms " + "p50 %.3f p99 %.3f max %.3f\n", + out_total / 1000.0, in_total / 1000.0, journal_end, relay.journal_max, step_p50, + step_p99, step_max); + std::printf("[bench] aoi pairs inner %zu middle %zu outer %zu | pushes %llu (%llu entries) | " + "media full %llu thumb %llu dropped %llu | state filtered %llu | relay withheld %llu " + "| media gated %llu, stalled %llu\n", + st.inner, st.middle, st.outer, static_cast(st.pushes), + static_cast(st.push_entries), + static_cast(st.media_full), + static_cast(st.media_thumb), + static_cast(st.media_dropped), + static_cast(st.state_filtered), + static_cast(relay.withheld() - withheld0), + static_cast(relay.media_gated() - gated0), + static_cast(relay.stall_dropped() - stalled0)); + std::printf("[bench] late join: %s in %.2f s, %llu bytes, %llu backfilled\n", + joined ? "converged" : "did NOT converge", join_s, + static_cast(join_bytes), + static_cast(join_backfilled)); + std::printf("[bench] probes: realigns %llu | origin ghost frames %llu | outer-peer error m max " + "%.3f p95 %.3f (%zu samples) | inner entry ms p50 %.1f p95 %.1f (%zu, %zu " + "unresolved) | reconcile corrections %.3f m\n", + static_cast(realigns), static_cast(ghosts), + err_max, percentile(pr.outer_error_m, 0.95), pr.outer_error_m.size(), + percentile(pr.entry_ms, 0.5), percentile(pr.entry_ms, 0.95), pr.entry_ms.size(), + pr.entries_unresolved, corrections); + std::fflush(stdout); + + if (!cfg.json.empty()) { + std::FILE *f = std::fopen(cfg.json.c_str(), "w"); + if (!f) { + std::fprintf(stderr, "arisrv_aoi_bench: cannot write %s\n", cfg.json.c_str()); + return 1; + } + std::fprintf(f, + "{\n \"config\": {\"clients\": %d, \"layout\": %s, \"scene\": %s, \"aoi\": %s, " + "\"video_max\": %d, \"seconds\": %.2f, \"warmup\": %.2f, \"video_bytes\": %zu, " + "\"transport\": %s, \"s7\": %s, \"media\": %s, \"move\": %s, " + "\"media_stall_ms\": %ld},\n", + cfg.clients, json_str(cfg.layout).c_str(), json_str(cfg.scene).c_str(), + json_str(cfg.aoi).c_str(), cfg.video_max, cfg.seconds, cfg.warmup, cfg.video_bytes, + json_str(cfg.transport).c_str(), cfg.s7 ? "true" : "false", + cfg.media ? "true" : "false", cfg.move ? "true" : "false", + cfg.s7 ? cfg.media_stall_ms : 0L); + std::fprintf(f, " \"measured_seconds\": %.3f,\n \"receivers\": [\n%s ],\n", secs, + jr.c_str()); + std::fprintf(f, + " \"relay\": {\"out_bps\": %.0f, \"in_bps\": %.0f, \"journal\": %zu, " + "\"journal_max\": %zu,\n \"step_ms\": {\"p50\": %.4f, \"p99\": %.4f, \"max\": " + "%.4f},\n", + out_total, in_total, journal_end, relay.journal_max, step_p50, step_p99, step_max); + std::fprintf(f, + " \"aoi\": {\"inner\": %zu, \"middle\": %zu, \"outer\": %zu, \"pushes\": %llu, " + "\"push_entries\": %llu, \"media_full\": %llu, \"media_thumb\": %llu, " + "\"media_dropped\": %llu, \"state_filtered\": %llu},\n", + st.inner, st.middle, st.outer, static_cast(st.pushes), + static_cast(st.push_entries), + static_cast(st.media_full), + static_cast(st.media_thumb), + static_cast(st.media_dropped), + static_cast(st.state_filtered)); + std::fprintf(f, + " \"relay_withheld\": %llu, \"media_gated\": %llu, " + "\"media_stall_dropped\": %llu,\n \"links\": [\n%s ]},\n", + static_cast(relay.withheld() - withheld0), + static_cast(relay.media_gated() - gated0), + static_cast(relay.stall_dropped() - stalled0), jl.c_str()); + std::fprintf(f, + " \"late_join\": {\"converged\": %s, \"seconds\": %.3f, \"bytes\": %llu, " + "\"backfilled\": %llu},\n", + joined ? "true" : "false", join_s, static_cast(join_bytes), + static_cast(join_backfilled)); + std::fprintf(f, + " \"probes\": {\"realigns\": %llu, \"origin_ghost_frames\": %llu, " + "\"outer_error_m\": {\"max\": %.4f, \"p95\": %.4f, \"samples\": %zu}, " + "\"inner_entry_ms\": {\"p50\": %.2f, \"p95\": %.2f, \"count\": %zu, " + "\"unresolved\": %zu}, \"correction_sum_m\": %.4f}\n}\n", + static_cast(realigns), static_cast(ghosts), + err_max, percentile(pr.outer_error_m, 0.95), pr.outer_error_m.size(), + percentile(pr.entry_ms, 0.5), percentile(pr.entry_ms, 0.95), pr.entry_ms.size(), + pr.entries_unresolved, corrections); + std::fclose(f); + std::printf("[bench] wrote %s\n", cfg.json.c_str()); + } + // Clients first (the relay retracts each as its connection closes), then the relay. + late.disconnect(); + own.clear(); + return 0; +} diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 3fa45e01c..84eafbe04 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -14,23 +14,30 @@ // (roster + stream descriptors) between all clients, star-topology; // - the MEDIA plane — a stream_relay watches the room tree and fans out each published stream // (ingest the publisher's pushed frames, serve them to every subscriber). +// The room tree, and the camera/mic frames the state transports carry (the media_router's fan-out), +// go through the room's AREA OF INTEREST (room_aoi_relay): how much of each participant every +// other one receives, by their distance in the room. The stream_relay plane (--media-listen: +// room-publish / room-subscribe streams) is not gated by it. // Core libcvc + gRPC only — NO cvcGL/VTK/GL — so it runs on a plain headless VM. // // arisrv --room standup --state-listen 0.0.0.0:7700 --media-listen 0.0.0.0:7701 +#include #include #include #include #include #include -#include // std::getenv (CVC_ARISRV_ROOM_DEBUG) +#include // std::getenv (CVC_ARISRV_ROOM_DEBUG, CVC_ARISRV_S7, CVC_ARISRV_AOI) #include -#include // room_leave (retract a departed peer) +#include // aoi_params, the presets, write_aoi_params +#include // room_aoi_relay, relay_room_step +#include // room_leave (retract a departed peer) #include // room_relay_tick #include // per-participant + room link-quality stats #include // seed_default_scene (the shared 3D scene) -#include // server-authoritative avatar holon (H1) -#include // server_pump: intent -> holon -> authoritative pose +#include // server-authoritative avatar holon (H1) +#include // server_pump: intent -> holon -> authoritative pose #include #include #include // protocol-agnostic media hub (WS + gRPC + future protocols) @@ -40,6 +47,8 @@ #if CVC_ENABLE_WS #include // browser (wasm) clients join over WebSocket, bridged to gRPC #endif +#include +#include #include #include #include @@ -80,6 +89,32 @@ void install_crash_trace() { #else void install_crash_trace() {} #endif + +// Whether V is one of OK; otherwise a usage error for FLAG on stderr. +bool check_choice(const char *flag, const std::string &v, std::initializer_list ok) { + std::string all; + for (const char *o : ok) { + if (v == o) + return true; + if (!all.empty()) + all += '|'; + all += o; + } + std::fprintf(stderr, "[arisrv] %s expects %s, got '%s'\n", flag, all.c_str(), v.c_str()); + return false; +} + +// A whole non-negative decimal integer, or a usage error naming FLAG. +bool check_count(const char *flag, const std::string &v, long &out) { + char *end = nullptr; + const long n = v.empty() ? -1 : std::strtol(v.c_str(), &end, 10); + if (n < 0 || !end || *end != '\0') { + std::fprintf(stderr, "[arisrv] %s expects a non-negative integer, got '%s'\n", flag, v.c_str()); + return false; + } + out = n; + return true; +} } // namespace int main(int argc, char **argv) { @@ -88,6 +123,30 @@ int main(int argc, char **argv) { #if CVC_ENABLE_WS std::string wsListen = "0.0.0.0:7702"; // WebSocket control plane for browser (wasm) clients #endif + // Spatial interest (S7). Each mechanism has its own switch and is on here, though the library + // leaves every one of them off by default; --s7 off (or CVC_ARISRV_S7=0) turns them all off at + // once, which is exactly the relay as it was before them. The scene choice is not one of them. + // --aoi off|conference|hall the area-of-interest preset (or CVC_ARISRV_AOI). Default + // conference, under which the 20 m room is unchanged; hall with + // --scene large + // --aoi-video-max K cameras each receiver gets at full rate, nearest first; the + // rest of its inner peers as thumbnails (default 0 = all) + // --relay-gate on|off forward a client's frame only when the room-tree shard takes it + // --relay-journal trim|keep trim: a joiner bootstraps from the admit snapshot instead of a + // journal backfill, and the journal is erased as it is published + // --media-stall-ms N gRPC: skip a receiver whose media write has been stuck N ms + // (default 40; 0 = off) + // --scene default|large the seeded scene (large: a 100 m plane of LOD meshes) + const char *s7_env = std::getenv("CVC_ARISRV_S7"); + const char *aoi_env = std::getenv("CVC_ARISRV_AOI"); + // CVC_ARISRV_S7: 0/off or 1/on (unset: on). + std::string s7Arg = !s7_env || !*s7_env ? "on" + : !std::strcmp(s7_env, "0") ? "off" + : !std::strcmp(s7_env, "1") ? "on" + : s7_env; + std::string aoiMode = (aoi_env && *aoi_env) ? aoi_env : ""; // empty: the scene's default + std::string relayGateArg = "on", relayJournal = "trim", scene = "default"; + std::string aoiVideoMaxArg = "0", mediaStallArg = "40"; for (int i = 1; i < argc; ++i) { const char *a = argv[i]; auto next = [&](const char *d) { return (i + 1 < argc) ? argv[++i] : d; }; @@ -103,7 +162,40 @@ int main(int argc, char **argv) { else if (!std::strcmp(a, "--ws-listen")) wsListen = next(wsListen.c_str()); #endif + else if (!std::strcmp(a, "--s7")) + s7Arg = next("on"); + else if (!std::strcmp(a, "--aoi")) + aoiMode = next("conference"); + else if (!std::strcmp(a, "--aoi-video-max")) + aoiVideoMaxArg = next(""); + else if (!std::strcmp(a, "--relay-gate")) + relayGateArg = next("on"); + else if (!std::strcmp(a, "--relay-journal")) + relayJournal = next("trim"); + else if (!std::strcmp(a, "--media-stall-ms")) + mediaStallArg = next(""); + else if (!std::strcmp(a, "--scene")) + scene = next("default"); } + if (aoiMode.empty()) + aoiMode = scene == "large" ? "hall" : "conference"; + if (!check_choice("--s7", s7Arg, {"on", "off"}) || + !check_choice("--aoi", aoiMode, {"off", "conference", "hall"}) || + !check_choice("--relay-gate", relayGateArg, {"on", "off"}) || + !check_choice("--relay-journal", relayJournal, {"trim", "keep"}) || + !check_choice("--scene", scene, {"default", "large"})) + return 2; + long aoiVideoMaxN = 0, mediaStallMs = 40; + if (!check_count("--aoi-video-max", aoiVideoMaxArg, aoiVideoMaxN) || + !check_count("--media-stall-ms", mediaStallArg, mediaStallMs)) + return 2; + const int aoiVideoMax = static_cast((std::min)(aoiVideoMaxN, 1000000L)); + const bool s7 = s7Arg == "on"; + if (!s7) + aoiMode = "off"; + const bool relayGate = s7 && relayGateArg == "on"; + const bool trimJournal = s7 && relayJournal == "trim"; + const long stallMs = s7 ? mediaStallMs : 0L; cvc::app app; @@ -123,6 +215,14 @@ int main(int argc, char **argv) { // room tree into app.root() so the media relay below can watch it. cvc::state_transport_grpc state_tp; state_tp.set_on_connection_closed(on_departed); + // The S7 switches (each one's library default when off), set before the first client connects. + // The gate withholds a forwarded frame the shard below would not take; every client enforces the + // same "conf" interest, so none of them would have applied it. Without backfill a joiner takes + // the relay's own writes from the admit snapshot, which holds every one of them, so the journal + // can be trimmed as it is published (relay_room_step). + state_tp.set_relay_requires_interest(relayGate); + state_tp.set_backfill_on_connect(!trimJournal); + state_tp.set_media_stall_drop(std::chrono::milliseconds(stallMs)); state_tp.start(stateListen, nodeId, "conf"); state_tp.set_relay(true); cvc::state_cluster_shard shard(app, "conf", nodeId); @@ -144,7 +244,9 @@ int main(int argc, char **argv) { // stream-descriptor mutations cross both ways now; request_snapshot over WS for a late joiner's // full bootstrap is Phase 2 (a browser joining a populated room sees peers that join AFTER it). cvc::state_transport_ws ws_tp; - ws_tp.set_on_connection_closed(on_departed); // a closed browser tab leaves like a native client + ws_tp.set_on_connection_closed(on_departed); // a closed browser tab leaves like a native client + ws_tp.set_relay_requires_interest(relayGate); // as on the gRPC side, both legs of the bridge + ws_tp.set_backfill_on_connect(!trimJournal); ws_tp.start(wsListen, nodeId, "conf"); ws_tp.set_relay(true); ws_tp.register_shard(&shard); @@ -170,6 +272,28 @@ int main(int argc, char **argv) { mrouter.add_endpoint(&ws_tp); // browser (wasm) WebSocket clients #endif + // The room's area of interest: every room step it places the participants from the server holon + // below and, per receiver, filters the room tree each transport sends (other participants' + // intents, the poses of peers out of reach), pushes those poses at a reduced cadence instead, + // and gates the media fan-out (thumbnails for a middle peer's camera, nothing from an outer + // one). Declared after the transports and the router, so it is gone before they are; it is also + // reset explicitly at shutdown, while they still run. + cvc::ariadne::conference::aoi_params aoiParams; + std::unique_ptr aoi_relay; + if (aoiMode != "off") { + aoiParams = aoiMode == "hall" ? cvc::ariadne::conference::hall_aoi_params() + : cvc::ariadne::conference::conference_aoi_params(); + aoiParams.video_full_max = aoiVideoMax; + aoi_relay = + std::make_unique(app, shard, room, aoiParams); + aoi_relay->add_transport(&state_tp); + aoi_relay->add_media_endpoint(&state_tp); +#if CVC_ENABLE_WS + aoi_relay->add_transport(&ws_tp); + aoi_relay->add_media_endpoint(&ws_tp); +#endif + } + install_crash_trace(); std::signal(SIGINT, on_signal); std::signal(SIGTERM, on_signal); @@ -179,6 +303,11 @@ int main(int argc, char **argv) { std::printf("[arisrv] websocket (browser) clients: ws=%s (bridged to the gRPC room)\n", ws_tp.listen_address().c_str()); #endif + std::printf("[arisrv] S7 %s | aoi %s (full-rate cameras: %d, 0 = all) | relay gate %s | journal " + "%s | grpc media stall drop %ld ms | scene %s\n", + s7 ? "on" : "off (the pre-S7 relay)", aoiMode.c_str(), aoiVideoMax, + relayGate ? "on" : "off", trimJournal ? "trim (no backfill)" : "keep (backfill)", + stallMs, scene.c_str()); std::fflush(stdout); std::set relayed; @@ -189,13 +318,42 @@ int main(int argc, char **argv) { #if CVC_ENABLE_WS int reapticks = 0; // cadence for reaping ghost (navigated-away) connections #endif - // The server-authoritative avatar holon for this room (H1): each tick it reads + // The server-authoritative avatar holon for this room (H1): each room step it reads // every participant's proposed intent from the replicated tree, integrates it on - // its own clock, and writes the authoritative pose back (fanned to clients by the - // drain_local block below). The relay loop paces ~5 Hz, so the holon catches up - // several quanta per tick and clients interpolate between the ~5 Hz pose samples; - // a smoother authoritative rate is a later tuning (tick the holon on its own). - cvc::ariadne::holon server_holon(cvc::ariadne::holon_role::server); + // its own clock (a stamped intent at the tick the client applied it), and writes + // the authoritative pose back -- what changed, plus a ~1 s heartbeat -- fanned to + // clients by room_step. The relay loop paces ~5 Hz; the room steps + // kRoomSubsteps times per pass (~20 Hz), so a pose sample is at most ~50 ms old + // and an intent waits at most that long before the server integrates it. + cvc::ariadne::holon server_holon(cvc::ariadne::holon_role::server, + cvc::ariadne::room_holon_config()); + std::printf("[arisrv] avatar physics: %s\n", + server_holon.physics() ? "Jolt rigid bodies" : "kinematic (no Jolt in this build)"); + constexpr int kRoomSubsteps = 4; + constexpr auto kRoomStep = std::chrono::milliseconds(50); // x kRoomSubsteps = the loop's 200 ms + // Fan the relay's OWN room-tree writes -- the room_leave retractions, room_relay_tick's + // per-stream "ready" markers, the holon's poses -- out to EVERY client on BOTH transports. + // The shard's publish cursor is shared by the two transports, so a plain state_tp.pump_all() + // would drain it and the WebSocket side would send nothing (browser rosters would never drop a + // closed tab). So relay_room_step drains the local journal ONCE and publishes each mutation to + // every plane: publish() writes only to its own connections and never crosses the bridge, so + // each client gets it exactly once with no echo. drain_local yields only our local writes, never + // the clients' replicated mutations (those ride set_relay forwarding), so this cannot loop. (With + // one plane that is state_tp.pump_all().) The AOI's pose pushes go out first, so on every + // connection they precede the step's live writes; with --relay-journal trim the published + // journal is erased after. With no AOI and no trim it is that drain and publish alone. +#if CVC_ENABLE_WS + const std::vector planes{&state_tp, &ws_tp}; +#else + const std::vector planes{&state_tp}; +#endif + const auto room_start = std::chrono::steady_clock::now(); + const auto room_step = [&] { + const double now_s = + std::chrono::duration(std::chrono::steady_clock::now() - room_start).count(); + cvc::ariadne::conference::relay_room_step(shard, planes, aoi_relay.get(), server_holon, now_s, + trimJournal); + }; const char *room_debug_env = std::getenv("CVC_ARISRV_ROOM_DEBUG"); const bool room_debug = room_debug_env && *room_debug_env && std::strcmp(room_debug_env, "0") != 0; @@ -209,15 +367,26 @@ int main(int argc, char **argv) { int net_ticks = 0; const auto net_start = std::chrono::steady_clock::now(); // The shared 3D scene (H1 slice 5, roadmap §5g): the relay is its sole writer and seeds the MVP - // environment (ground + a reference box under a sun) once. The writes are local shard mutations, - // so the first pass of the loop below fans them to the clients, and late joiners get them in the - // snapshot. - if (cvc::ariadne::conference::seed_default_scene(app, room)) { + // environment (ground + a reference box under a sun) once -- or, with --scene large, a 100 m + // plane with a grid of meshes to walk between (what the hall preset and mesh LOD are for). The + // writes are local shard mutations, so the first pass of the loop below fans them to the + // clients, and late joiners get them in the snapshot. + const bool seeded = scene == "large" ? cvc::ariadne::conference::seed_large_demo_scene(app, room) + : cvc::ariadne::conference::seed_default_scene(app, room); + if (seeded) { const auto rev = cvc::ariadne::conference::scene_revision(app, room); - std::printf("[arisrv] seeded the shared scene for room '%s' (rev %llu)\n", room.c_str(), - static_cast(rev)); + std::printf("[arisrv] seeded the shared %s scene for room '%s' (rev %llu)\n", scene.c_str(), + room.c_str(), static_cast(rev)); std::fflush(stdout); } + // The AOI's parameters, once, under conf.rooms..aoi: clients derive their audio gain from + // them. With --aoi off (and --s7 off) they are written disabled, so every peer plays at full + // gain -- also on a client that lived through an earlier relay's AOI and kept its leaves. + { + cvc::ariadne::conference::aoi_params published = aoiParams; + published.enabled = aoi_relay != nullptr; + cvc::ariadne::conference::write_aoi_params(app, room, published); + } while (!g_stop.load()) { #if CVC_ENABLE_WS // Reap ghosts (~every second): a tab that navigated or reloaded away without a clean WebSocket @@ -230,7 +399,8 @@ int main(int argc, char **argv) { // is single-shot with no retry, so a join that races an already-connected peer's socket is // otherwise lost for good (an established client never sees a later joiner). This idempotent // re-sync makes every client re-converge within ~1s — and is the reliable push the - // server-authoritative room+scene tree rides on. + // server-authoritative room+scene tree rides on. It sends each client only what it does not + // already hold (a diff), and the whole tree only to a client that asks for a full refresh. ws_tp.broadcast_snapshot(); } #endif @@ -304,8 +474,9 @@ int main(int argc, char **argv) { } if (room_debug && ++room_debug_ticks % 25 == 0) { const auto roster = cvc::ariadne::conference::room_roster(app, room); - std::printf("[arisrv] room '%s': %zu present, holon tick %llu\n", room.c_str(), roster.size(), - static_cast(server_holon.tick())); + std::printf("[arisrv] room '%s': %zu present, holon tick %llu, %llu intent(s) vetoed\n", + room.c_str(), roster.size(), static_cast(server_holon.tick()), + static_cast(server_holon.vetoes())); for (const auto &p : roster) { cvc::physics::body_state s; std::uint64_t t = 0; @@ -316,30 +487,38 @@ int main(int argc, char **argv) { has_pose ? "" : "none", s.position.x, s.position.y, has_intent ? "" : "none", in.move.x, in.move.y); } - std::fflush(stdout); - } + // The AOI over the same ~5 s: the pairs in each tier now, the pose pushes and the media + // frames the gate passed whole, as thumbnails or not at all, and the room-tree deliveries + // the registries filtered; then the transports' own S7 counters (cumulative) and the + // relay journal's size (about 0 while it is trimmed). + unsigned long long ws_withheld = 0, ws_gated = 0, ws_backlog = 0; #if CVC_ENABLE_WS - // Fan the relay's OWN room-tree writes — the room_leave retractions queued above and - // room_relay_tick's per-stream "ready" markers — out to EVERY client on BOTH transports. The - // shard's publish cursor is shared by the two transports, so a plain state_tp.pump_all() would - // drain it and the WebSocket side would send nothing (browser rosters would never drop a closed - // tab). So drain the local journal ONCE and publish each mutation to both transports: publish() - // writes only to its own connections and never crosses the bridge, so each client gets it - // exactly once with no echo. drain_local yields only our local writes, never the clients' - // replicated mutations (those ride set_relay forwarding), so this cannot loop. - { - auto pending = shard.drain_local(); - for (const auto &m : pending) { - state_tp.publish(m); - ws_tp.publish(m); - } - } -#else - // Publish the relay's own room-tree writes (per-stream "ready" markers from room_relay_tick) to - // the connected clients. drain_local pumps only our local writes, never the clients' replicated - // mutations (those ride set_relay forwarding), so this cannot echo. - state_tp.pump_all(); + ws_withheld = static_cast(ws_tp.total_relay_withheld()); + ws_gated = static_cast(ws_tp.total_media_gated()); + ws_backlog = static_cast(ws_tp.total_media_backlog_dropped()); #endif + cvc::ariadne::conference::room_aoi_relay::stats st; + if (aoi_relay) + st = aoi_relay->take_stats(); + std::printf( + "[arisrv] aoi %s: pairs inner %zu middle %zu outer %zu | pushes %llu (%llu entries) | " + "media full %llu thumb %llu dropped %llu | state filtered %llu | relay withheld grpc " + "%llu ws %llu | media gated grpc %llu ws %llu, stalled %llu, backlog %llu | " + "journal %zu\n", + aoiMode.c_str(), st.inner, st.middle, st.outer, + static_cast(st.pushes), + static_cast(st.push_entries), + static_cast(st.media_full), + static_cast(st.media_thumb), + static_cast(st.media_dropped), + static_cast(st.state_filtered), + static_cast(state_tp.total_relay_withheld()), ws_withheld, + static_cast(state_tp.total_media_gated()), ws_gated, + static_cast(state_tp.total_media_stall_dropped()), ws_backlog, + shard.journal().size()); + std::fflush(stdout); + } + room_step(); const std::size_t n = relay.stream_count(); const bool heartbeat = (n > 0 && ++ticks % 25 == 0); // ~every 5s while gRPC media is live if (n != last || heartbeat) { @@ -376,11 +555,21 @@ int main(int argc, char **argv) { std::fflush(stdout); } } - std::this_thread::sleep_for(std::chrono::milliseconds(200)); + // The rest of this pass's room steps (the first ran above): advance the holon and fan its + // poses between the loop's slower housekeeping. + for (int i = 1; i < kRoomSubsteps && !g_stop.load(); ++i) { + std::this_thread::sleep_for(kRoomStep); + cvc::ariadne::server_pump(server_holon, app, room); + room_step(); + } + std::this_thread::sleep_for(kRoomStep); } std::printf("[arisrv] shutting down (relayed %zu stream(s), %llu frames forwarded)\n", relay.stream_count(), static_cast(relay.frames_forwarded())); + // The AOI first, while the transports still run: it clears the media gates and the registry + // filters it installed. + aoi_relay.reset(); relay.stop(); media_tp.stop(); #if CVC_ENABLE_WS diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index a32cdb68c..325309bfb 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1312,12 +1312,13 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/net/http_client.h) # the retained Widget tree, the Runtime + reconcile boundary, and direct # cvc::state binding, walking a pluggable Backend. It touches NO VTK/ImGui/GL — # the ImGui-over-VTK backend lives in cvcGL (src/cvcGL/ariadne). Roadmap §16.1. -list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/conference/room_scene.cpp ariadne/conference/room_net.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp ariadne/avatar_sim.cpp ariadne/holon.cpp ariadne/holon_sync.cpp) +list(APPEND SOURCE_FILES ariadne/ariadne.cpp ariadne/loader.cpp ariadne/uri.cpp ariadne/uri_state.cpp ariadne/uri_http.cpp ariadne/uri_http_cache.cpp ariadne/net_intrinsics.cpp ariadne/stream_intrinsics.cpp ariadne/conference/room_intrinsics.cpp ariadne/conference/room_scene.cpp ariadne/conference/room_net.cpp ariadne/conference/room_aoi.cpp ariadne/conference/room_aoi_relay.cpp ariadne/state_io.cpp ariadne/ftxui_backend.cpp ariadne/term_graphics.cpp ariadne/tvision_backend.cpp ariadne/app_runtime.cpp ariadne/doc_args.cpp ariadne/host_cli.cpp ariadne/host_signals.cpp ariadne/requires.cpp ariadne/host_info.cpp ariadne/avatar_sim.cpp ariadne/avatar_physics.cpp ariadne/holon.cpp ariadne/holon_sync.cpp) list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h ../../inc/cvc/ariadne/ariadne.h ../../inc/cvc/ariadne/avatar_sim.h + ../../inc/cvc/ariadne/avatar_physics.h ../../inc/cvc/ariadne/holon.h ../../inc/cvc/ariadne/holon_sync.h ../../inc/cvc/ariadne/app_runtime.h @@ -1337,6 +1338,8 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/conference/room_media_intrinsics.h ../../inc/cvc/ariadne/conference/room_scene.h ../../inc/cvc/ariadne/conference/room_net.h + ../../inc/cvc/ariadne/conference/room_aoi.h + ../../inc/cvc/ariadne/conference/room_aoi_relay.h ../../inc/cvc/ariadne/state_io.h ../../inc/cvc/ariadne/scene.h ../../inc/cvc/ariadne/value.h diff --git a/src/cvc/ariadne/avatar_physics.cpp b/src/cvc/ariadne/avatar_physics.cpp new file mode 100644 index 000000000..528c4fe36 --- /dev/null +++ b/src/cvc/ariadne/avatar_physics.cpp @@ -0,0 +1,185 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { + +namespace { + +namespace ph = cvc::physics; + +ph::body_id bid(std::uint32_t v) { + ph::body_id b; + b.value = v; + return b; +} + +ph::quat yaw_quat(double yaw) { + return ph::quat{std::cos(0.5 * yaw), 0.0, 0.0, std::sin(0.5 * yaw)}; +} + +// The yaw of a rotation about +Z (the only rotation an avatar is ever given). +double quat_yaw(const ph::quat &q) { + return std::atan2(2.0 * (q.w * q.z + q.x * q.y), 1.0 - 2.0 * (q.y * q.y + q.z * q.z)); +} + +} // namespace + +avatar_world::avatar_world(double fixed_dt, const avatar_body &body) : _body(body), _dt(fixed_dt) { + ph::world_config cfg; + cfg.backend = ph::backend::jolt; + cfg.fixed_dt = fixed_dt; + cfg.determinism = ph::determinism::cross_platform; + cfg.contacts = ph::contact_reporting::none; + cfg.collision_steps = 1; + cfg.max_bodies = 4096; + cfg.max_body_pairs = 8192; + cfg.max_contact_constraints = 8192; + cfg.temp_allocator_bytes = 4u << 20; + _world = std::make_unique(cfg); // throws without a Jolt backend + // Frictionless: the drive is the avatar's grip on the floor (see the header), and a box dragged + // across the floor with friction would tip and stutter. + ph::material m; + m.name = "avatar"; + m.friction = 0.0f; + m.restitution = 0.0f; + _avatar_material = _world->add_material(m); +} + +avatar_world::~avatar_world() = default; + +std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynamic, double mass) { + ph::body_desc d; + d.shape = ph::box{ph::vec3{0.5 * _body.width, 0.5 * _body.width, 0.5 * _body.height}}; + d.motion = dynamic ? ph::motion_type::dynamic : ph::motion_type::kinematic; + d.position = feet.position + ph::vec3{0.0, 0.0, 0.5 * _body.height}; + d.rotation = feet.rotation; + d.linear_velocity = feet.linear_velocity; + d.material = static_cast(_avatar_material); + d.mass = dynamic ? mass : 0.0; + d.linear_damping = 0.0; + d.angular_damping = 0.0; + d.allow_sleeping = false; // an idle avatar must still be pushable and respond to its intent + return _world->add_body(d).value; +} + +void avatar_world::set_statics(const std::vector &statics) { + // Keep the avatars' states, clear the world, rebuild statics-then-avatars (creation order is + // part of a deterministic Jolt run). + std::map kept; + for (const auto &kv : _avatars) { + kept[kv.first] = state(kv.first); + _world->remove_body(bid(kv.second.id)); + } + for (std::uint32_t id : _statics) + _world->remove_body(bid(id)); + _statics.clear(); + for (const avatar_static &s : statics) { + ph::body_desc d; + if (s.type == avatar_static::kind::sphere) + d.shape = ph::sphere{s.radius}; + else + d.shape = ph::box{s.half_extents}; + d.motion = ph::motion_type::static_; + d.position = s.center; + d.rotation = s.rotation; + _statics.push_back(_world->add_body(d).value); + } + for (auto &kv : _avatars) + kv.second.id = create_avatar(kept[kv.first], kv.second.dynamic, kv.second.mass); +} + +void avatar_world::add_avatar(const std::string &pid, const ph::body_state &feet, bool dynamic) { + remove_avatar(pid); + avatar a; + a.dynamic = dynamic; + a.mass = _body.mass; + a.id = create_avatar(feet, dynamic, a.mass); + _avatars[pid] = a; +} + +void avatar_world::remove_avatar(const std::string &pid) { + auto it = _avatars.find(pid); + if (it == _avatars.end()) + return; + _world->remove_body(bid(it->second.id)); + _avatars.erase(it); +} + +bool avatar_world::has_avatar(const std::string &pid) const { return _avatars.count(pid) != 0; } + +void avatar_world::drive(const std::string &pid, const avatar_intent &in) { + const avatar &a = _avatars.at(pid); + if (!a.dynamic) + return; + ph::body_state s = _world->state(bid(a.id)); + // Ease the horizontal velocity toward the intent's, at most max_accel * dt per quantum; gravity + // (and whatever the solver does) keeps the vertical. + const double dvx = in.move.x - s.linear_velocity.x, dvy = in.move.y - s.linear_velocity.y; + const double dv = std::sqrt(dvx * dvx + dvy * dvy); + const double cap = _body.max_accel * _dt; + const double k = (dv > cap && dv > 0.0) ? cap / dv : 1.0; + s.linear_velocity.x += dvx * k; + s.linear_velocity.y += dvy * k; + // Upright, facing the intent: a pushed box must not tip over. + s.rotation = yaw_quat(in.yaw); + s.angular_velocity = ph::vec3{0.0, 0.0, 0.0}; + _world->set_state(bid(a.id), s); +} + +void avatar_world::place(const std::string &pid, const ph::body_state &feet) { + const avatar &a = _avatars.at(pid); + ph::body_state s = feet; + s.position = feet.position + ph::vec3{0.0, 0.0, 0.5 * _body.height}; + // Only a facing about +Z survives (a replicated pose is never tilted, but a tilted one must not + // stand the box on its edge). + s.rotation = yaw_quat(quat_yaw(feet.rotation)); + s.angular_velocity = ph::vec3{0.0, 0.0, 0.0}; + _world->set_state(bid(a.id), s); +} + +void avatar_world::move_kinematic(const std::string &pid, const ph::pose &feet) { + const avatar &a = _avatars.at(pid); + if (a.dynamic) + return; + _world->move_kinematic(bid(a.id), feet.position + ph::vec3{0.0, 0.0, 0.5 * _body.height}, + feet.rotation); +} + +void avatar_world::set_mass(const std::string &pid, double kg) { + avatar &a = _avatars.at(pid); + if (!a.dynamic) + return; + ph::body_settings s = _world->get_body(bid(a.id)); + s.mass = kg; + _world->set_body(bid(a.id), s); + a.mass = kg; +} + +void avatar_world::step() { _world->step(); } + +ph::body_state avatar_world::state(const std::string &pid) const { + ph::body_state s = _world->state(bid(_avatars.at(pid).id)); + s.position = s.position - ph::vec3{0.0, 0.0, 0.5 * _body.height}; + return s; +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp index 0478d6f4a..0ab3ccb62 100644 --- a/src/cvc/ariadne/avatar_sim.cpp +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -24,6 +24,8 @@ void avatar_sim::step(const avatar_intent &in, double dt) { // double arithmetic (vec3 + and *), no trig, so it is bit-reproducible. _state.linear_velocity = in.move; _state.position = _state.position + in.move * dt; + // Facing: a rotation about +Z (Z-up). Trig here is fine: reconcile compares positions only. + _state.rotation = cvc::physics::quat{std::cos(0.5 * in.yaw), 0.0, 0.0, std::sin(0.5 * in.yaw)}; _state.active = true; } @@ -34,6 +36,8 @@ void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, doub e.in = in; e.predicted = _sim.state(); _log.push_back(e); + if (_log.size() > kMaxLog) + _log.pop_front(); } bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, diff --git a/src/cvc/ariadne/conference/room_aoi.cpp b/src/cvc/ariadne/conference/room_aoi.cpp new file mode 100644 index 000000000..fe613ef8f --- /dev/null +++ b/src/cvc/ariadne/conference/room_aoi.cpp @@ -0,0 +1,602 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#include +#include +#include +#include +#include +#include +#include // cvc::state, SEPARATOR, findDescendant +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace conference { + +namespace { + +// room_aoi's bookkeeping (room_aoi::book): file-local, see room_aoi.h. +struct pair_state { + aoi_tier tier = aoi_tier::inner; + double beyond_since_s = -1.0; + double d_m = 0.0; + bool push_due = false; // a full pose push is owed at the next take_pose_pushes + std::uint64_t last_push_tick = 0; + std::uint64_t last_version = 0; +}; +struct member { + double x = 0.0; + double y = 0.0; + std::map subjects; // every other present participant + std::set ranked; // the full-video set (video_full_max > 0) +}; + +bool rerank(const aoi_params &p, member &r); + +struct departure { + std::string pid; +}; + +constexpr std::size_t kNpos = std::string_view::npos; + +// A path BASE., joined with cvc::state::SEPARATOR. +std::string sub(const std::string &base, std::initializer_list segs) { + std::string s = base; + for (const char *seg : segs) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; +} + +// conf.rooms..participants. -- room_participant_path(room, q) is this followed by q. +std::string participants_prefix(const std::string &room) { + return sub("conf", {"rooms", room.c_str(), "participants"}) + cvc::state::SEPARATOR; +} + +// The segment of PATH that starts at POS and runs to the next separator or the end. POS moves past +// that separator, or to kNpos when the segment was the last one. +std::string_view next_segment(std::string_view path, std::size_t &pos) noexcept { + if (pos == kNpos || pos > path.size()) + return {}; + const std::string_view sep(cvc::state::SEPARATOR); + const std::size_t end = path.find(sep, pos); + const std::string_view seg = path.substr(pos, end == kNpos ? kNpos : end - pos); + pos = end == kNpos ? kNpos : end + sep.size(); + return seg; +} + +double exit_radius(aoi_tier t, const aoi_params &p) noexcept { + return t == aoi_tier::inner ? p.inner_exit_m : p.middle_exit_m; +} + +// SECONDS of holon ticks at TICK_HZ, at least 1; 0 when SECONDS is not a positive finite span. +std::uint64_t to_ticks(double seconds, double tick_hz) noexcept { + const double t = seconds * tick_hz; + if (!(t > 0.0) || !std::isfinite(t)) + return 0; + const long long n = std::llround((std::min)(t, 1e15)); + return (std::max)(std::uint64_t{1}, static_cast(n)); +} + +// Round-trip-safe double text (17 significant digits exactly recover a binary64). +std::string fmt(double d) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", d); + return std::string(buf); +} + +// touch-before-value, so a just-created node's first write is not lost to the distributed-state +// adapter's attach race (the room tree's set_replicated idiom). A leaf that already holds VAL is +// not rewritten, so re-applying the same parameters (a relay restart) adds no replication traffic. +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state *n = app.root().findDescendant(path); + if (n && n->value() == val) + return; + cvc::state &leaf = n ? *n : app.root()(path); + leaf.touch(); + leaf.value(val); +} + +cvc::state *find(cvc::app &app, const std::string &path) { return app.root().findDescendant(path); } +std::string get_s(cvc::app &app, const std::string &path, const std::string &def) { + cvc::state *n = find(app, path); + return n ? n->value() : def; +} +double get_d(cvc::app &app, const std::string &path, double def) { + cvc::state *n = find(app, path); + return n && !n->value().empty() ? std::strtod(n->value().c_str(), nullptr) : def; +} +int get_i(cvc::app &app, const std::string &path, int def) { + cvc::state *n = find(app, path); + return n && !n->value().empty() ? static_cast(std::strtol(n->value().c_str(), nullptr, 10)) + : def; +} +bool get_b(cvc::app &app, const std::string &path, bool def) { + cvc::state *n = find(app, path); + return n && !n->value().empty() ? n->value() == "1" : def; +} + +} // namespace + +// ---------------- presets and per-pair rules ---------------- + +aoi_params conference_aoi_params() { + aoi_params p; + p.preset = "conference"; + p.inner_enter_m = 30.0; + p.inner_exit_m = 32.0; + p.middle_enter_m = 40.0; + p.middle_exit_m = 44.0; + p.audio_rolloff = false; + return p; +} + +aoi_params hall_aoi_params() { + aoi_params p; + p.preset = "hall"; + p.inner_enter_m = 10.0; + p.inner_exit_m = 12.0; + p.middle_enter_m = 16.0; + p.middle_exit_m = 18.0; + p.audio_rolloff = true; + return p; +} + +aoi_tier initial_tier(double d_m, const aoi_params &p) noexcept { + if (d_m <= p.inner_enter_m) + return aoi_tier::inner; + if (d_m <= p.middle_enter_m) + return aoi_tier::middle; + return aoi_tier::outer; +} + +aoi_tier step_tier(aoi_tier cur, double d_m, double now_s, double &beyond_since_s, + const aoi_params &p) noexcept { + // Upgrades are immediate, as far as the enter radii allow (outer straight to inner). + const aoi_tier best = initial_tier(d_m, p); + if (best < cur) { + beyond_since_s = -1.0; + return best; + } + if (cur == aoi_tier::outer || !(d_m > exit_radius(cur, p))) { + beyond_since_s = -1.0; // back within the exit radius: the dwell starts over + return cur; + } + if (beyond_since_s < 0.0) + beyond_since_s = now_s; + if (now_s - beyond_since_s < p.downgrade_dwell_s) + return cur; + // One tier per dwell: the next tier's own dwell starts now if the pair is beyond its exit too. + const aoi_tier next = cur == aoi_tier::inner ? aoi_tier::middle : aoi_tier::outer; + beyond_since_s = next != aoi_tier::outer && d_m > exit_radius(next, p) ? now_s : -1.0; + return next; +} + +float aoi_audio_gain(double d_m, const aoi_params &p) noexcept { + if (!p.enabled || !(d_m > 0.0)) + return 1.0f; + double g = 1.0; + if (p.audio_rolloff && p.inner_enter_m > 0.0 && d_m > p.inner_enter_m) + g = p.inner_enter_m / d_m; + if (p.audio_fade_m > 0.0) + g *= (std::max)(0.0, (std::min)(1.0, (p.middle_enter_m - d_m) / p.audio_fade_m)); + else if (d_m >= p.middle_enter_m) + g = 0.0; + return static_cast(g); +} + +std::uint64_t aoi_pose_push_interval_ticks(const aoi_params &p, double tick_hz) noexcept { + return p.pose_push_hz > 0.0 ? to_ticks(1.0 / p.pose_push_hz, tick_hz) : 0; +} + +std::uint64_t aoi_pose_refresh_ticks(const aoi_params &p, double tick_hz) noexcept { + return to_ticks(p.pose_refresh_s, tick_hz); +} + +// ---------------- aoi_snapshot ---------------- + +std::size_t aoi_snapshot::index_of(std::string_view pid) const noexcept { + const auto it = std::lower_bound( + _pids.begin(), _pids.end(), pid, + [](const std::string &a, std::string_view b) { return std::string_view(a) < b; }); + return it != _pids.end() && std::string_view(*it) == pid + ? static_cast(it - _pids.begin()) + : npos; +} + +bool aoi_snapshot::is_departed(std::string_view pid) const noexcept { + return std::binary_search(_departed.begin(), _departed.end(), pid, + [](std::string_view a, std::string_view b) { return a < b; }); +} + +bool aoi_snapshot::deliver_path(const std::string &receiver, + const std::string &path) const noexcept { + if (!_enabled || _prefix.empty()) + return true; + const std::string_view p(path); + if (p.size() <= _prefix.size() || p.substr(0, _prefix.size()) != _prefix) + return true; // outside this room's participants: scene, server, aoi, other rooms + std::size_t pos = _prefix.size(); + const std::string_view q = next_segment(p, pos); + if (q.empty() || q == receiver) + return true; // malformed, or the receiver's own subtree + if (pos == kNpos) + return true; // the bare participants. node + const std::string_view family = next_segment(p, pos); + const bool pose = family == "pose"; + const bool intent = family == "intent"; + if (is_departed(q)) + return !(pose || intent); + const std::size_t qi = index_of(q); + if (qi == npos) + return true; // a pid this snapshot has never seen + if (intent) + return false; + if (pose) { + const std::size_t ri = index_of(receiver); + return ri == npos || static_cast(_tiers[ri * _pids.size() + qi]) == aoi_tier::inner; + } + return true; +} + +aoi_media aoi_snapshot::media(const std::string &receiver, const std::string &publisher, + const std::string &stream_id, + const std::string &codec) const noexcept { + if (!_enabled || receiver == publisher) + return aoi_media::deliver; + const std::size_t ri = index_of(receiver); + const std::size_t qi = index_of(publisher); + if (ri == npos || qi == npos) + return aoi_media::deliver; + const std::size_t cell = ri * _pids.size() + qi; + const aoi_tier t = static_cast(_tiers[cell]); + if (t == aoi_tier::outer) + return aoi_media::drop; + // Only an intra-only camera can be decimated; anything else is all or nothing. + if (stream_id != "camera" || codec != "mjpeg") + return aoi_media::deliver; + if (t == aoi_tier::middle) + return aoi_media::thumbnail; + return _video_capped && !_ranked[cell] ? aoi_media::thumbnail : aoi_media::deliver; +} + +aoi_tier aoi_snapshot::tier(const std::string &receiver, + const std::string &subject) const noexcept { + const std::size_t ri = index_of(receiver); + const std::size_t qi = index_of(subject); + if (ri == npos || qi == npos || ri == qi) + return aoi_tier::inner; + return static_cast(_tiers[ri * _pids.size() + qi]); +} + +std::uint64_t aoi_snapshot::generation() const noexcept { return _generation; } + +bool aoi_snapshot::enabled() const noexcept { return _enabled; } + +const std::vector &aoi_snapshot::participants() const noexcept { return _pids; } + +// ---------------- aoi_state_filter ---------------- + +aoi_state_filter::aoi_state_filter(std::shared_ptr s, std::string receiver) + : _snap(std::move(s)), _receiver(std::move(receiver)) {} + +bool aoi_state_filter::deliver(const std::string &path) const noexcept { + return !_snap || _snap->deliver_path(_receiver, path); +} + +// ---------------- room_aoi ---------------- + +struct room_aoi::book { + std::map members; + std::map departed_age; // departure order -> pid, oldest first + std::uint64_t departures = 0; +}; + +room_aoi::room_aoi(std::string room, aoi_params p) + : _room(std::move(room)), _p(std::move(p)), _prefix(participants_prefix(_room)), + _book(std::make_unique()) { + auto s = std::make_shared(); + s->_enabled = _p.enabled; + s->_video_capped = _p.video_full_max > 0; + s->_prefix = _prefix; + _snap = std::move(s); +} + +room_aoi::~room_aoi() = default; +room_aoi::room_aoi(room_aoi &&) noexcept = default; +room_aoi &room_aoi::operator=(room_aoi &&) noexcept = default; + +bool room_aoi::update(const std::vector &present, std::uint64_t now_tick, double now_s) { + std::map here; // emplace keeps a repeated pid's first entry + for (const position &pos : present) + if (!pos.pid.empty()) + here.emplace(pos.pid, &pos); + + bool changed = false; + for (auto it = _book->members.begin(); it != _book->members.end();) { + if (here.count(it->first)) { + ++it; + continue; + } + for (auto &kv : _book->members) { + kv.second.subjects.erase(it->first); + kv.second.ranked.erase(it->first); + } + depart(it->first); + it = _book->members.erase(it); + changed = true; + } + for (const auto &kv : here) { + const auto [it, fresh] = _book->members.try_emplace(kv.first); + if (fresh) { + if (_departed.erase(kv.first)) + for (auto a = _book->departed_age.begin(); a != _book->departed_age.end(); ++a) + if (a->second.pid == kv.first) { + _book->departed_age.erase(a); + break; + } + changed = true; + } + it->second.x = kv.second->x; + it->second.y = kv.second->y; + } + + // Each unordered pair is stepped once and mirrored, so tier(r, q) == tier(q, r) always. + for (auto a = _book->members.begin(); a != _book->members.end(); ++a) { + for (auto b = std::next(a); b != _book->members.end(); ++b) { + const double d = std::hypot(b->second.x - a->second.x, b->second.y - a->second.y); + const auto [ab_it, fresh] = a->second.subjects.try_emplace(b->first); + pair_state &ab = ab_it->second; + pair_state &ba = b->second.subjects[a->first]; + ab.d_m = d; + if (fresh) { + ab.tier = initial_tier(d, _p); + ab.beyond_since_s = -1.0; + // A receiver meeting a non-inner subject has none of its pose: send it now rather than + // leave it at the origin until the refresh. + ab.push_due = ab.tier != aoi_tier::inner; + ab.last_push_tick = now_tick; + ab.last_version = 0; + ba = ab; + changed = true; + continue; + } + const aoi_tier t = step_tier(ab.tier, d, now_s, ab.beyond_since_s, _p); + if (t != ab.tier) { + // Entering inner turns the live pose stream back on; the push brings the whole subtree + // current first, so the receiver never pairs a live tick with leaves it missed. + if (t == aoi_tier::inner) + ab.push_due = ba.push_due = true; + ab.tier = t; + changed = true; + } + ba.tier = ab.tier; + ba.beyond_since_s = ab.beyond_since_s; + ba.d_m = d; + } + } + + if (_p.video_full_max > 0) + for (auto &kv : _book->members) + changed = rerank(_p, kv.second) || changed; + + if (changed) + publish(); + return changed; +} + +namespace { + +bool rerank(const aoi_params &p, member &r) { + const std::size_t k = static_cast(p.video_full_max); + // A non-negative margin, so a swap always moves the set strictly nearer and the loop ends. + const double margin = (std::max)(0.0, p.video_rank_margin_m); + // The inner subjects, nearest first; subjects iterate in pid order, so ties stay in pid order. + std::vector> inner; + for (const auto &kv : r.subjects) + if (kv.second.tier == aoi_tier::inner) + inner.emplace_back(kv.second.d_m, &kv.first); + std::stable_sort(inner.begin(), inner.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + + // Keep the members that are still inner, fill up with the nearest others, then swap a member + // out only for an outsider nearer by more than the margin (no flapping on near-equal ranks). + std::set next; + for (const auto &c : inner) + if (r.ranked.count(*c.second)) + next.insert(*c.second); + for (const auto &c : inner) { + if (next.size() >= k) + break; + next.insert(*c.second); + } + for (;;) { + const std::pair *outsider = nullptr; + const std::pair *farthest = nullptr; + for (const auto &c : inner) + if (!next.count(*c.second)) { + outsider = &c; + break; + } + for (auto it = inner.rbegin(); it != inner.rend(); ++it) + if (next.count(*it->second)) { + farthest = &*it; + break; + } + if (!outsider || !farthest || !(outsider->first + margin < farthest->first)) + break; + next.erase(*farthest->second); + next.insert(*outsider->second); + } + if (next == r.ranked) + return false; + r.ranked = std::move(next); + return true; +} + +} // namespace + +void room_aoi::publish() { + auto s = std::make_shared(); + s->_enabled = _p.enabled; + s->_video_capped = _p.video_full_max > 0; + s->_generation = ++_generation; + s->_prefix = _prefix; + const std::size_t n = _book->members.size(); + s->_pids.reserve(n); + for (const auto &kv : _book->members) + s->_pids.push_back(kv.first); // std::map order: sorted, as index_of requires + s->_tiers.assign(n * n, static_cast(aoi_tier::inner)); + s->_ranked.assign(n * n, 0); + std::size_t ri = 0; + for (const auto &kv : _book->members) { + for (const auto &sv : kv.second.subjects) { + const std::size_t qi = s->index_of(sv.first); + if (qi == aoi_snapshot::npos) + continue; + s->_tiers[ri * n + qi] = static_cast(sv.second.tier); + s->_ranked[ri * n + qi] = kv.second.ranked.count(sv.first) ? 1 : 0; + } + ++ri; + } + s->_departed.assign(_departed.begin(), _departed.end()); + _snap = std::move(s); +} + +std::vector +room_aoi::take_pose_pushes(std::uint64_t now_tick, + const std::map &pose_version) { + std::vector out; + if (!_p.enabled) + return out; + const std::uint64_t every = aoi_pose_push_interval_ticks(_p); + const std::uint64_t refresh = aoi_pose_refresh_ticks(_p); + for (auto &rm : _book->members) { + aoi_push push; + for (auto &qs : rm.second.subjects) { + pair_state &ps = qs.second; + const auto v_it = pose_version.find(qs.first); + const std::uint64_t v = v_it == pose_version.end() ? 0 : v_it->second; + if (now_tick < ps.last_push_tick) + ps.last_push_tick = now_tick; // a tick that went back: count the intervals from now + bool due = ps.push_due; + if (!due && ps.tier == aoi_tier::inner) { + // The live stream keeps an inner receiver current, so it counts as a push every step and + // a later downgrade's schedule starts from here. + ps.last_push_tick = now_tick; + ps.last_version = v; + continue; + } + if (!due) { + const std::uint64_t since = now_tick - ps.last_push_tick; + due = (every != 0 && v != ps.last_version && since >= every) || + (refresh != 0 && since >= refresh); + } + if (!due) + continue; + push.subjects.push_back(qs.first); + ps.push_due = false; + ps.last_push_tick = now_tick; + ps.last_version = v; + } + if (!push.subjects.empty()) { + push.receiver = rm.first; + out.push_back(std::move(push)); + } + } + return out; +} + +std::shared_ptr room_aoi::snapshot() const { return _snap; } + +const std::set &room_aoi::departed() const { return _departed; } + +void room_aoi::depart(const std::string &pid) { + if (!_departed.insert(pid).second) + return; + _book->departed_age.emplace(++_book->departures, departure{pid}); + while (_departed.size() > kMaxDeparted) { + const auto oldest = _book->departed_age.begin(); + _departed.erase(oldest->second.pid); + _book->departed_age.erase(oldest); + } +} + +const aoi_params &room_aoi::params() const noexcept { return _p; } + +const std::string &room_aoi::room() const noexcept { return _room; } + +// ---------------- replicated parameters ---------------- + +std::string room_aoi_path(const std::string &room_id) { + return sub("conf", {"rooms", room_id.c_str(), "aoi"}); +} + +void write_aoi_params(cvc::app &app, const std::string &room, const aoi_params &p) { + const std::string base = room_aoi_path(room); + set_replicated(app, sub(base, {"preset"}), p.preset); + set_replicated(app, sub(base, {"inner_enter_m"}), fmt(p.inner_enter_m)); + set_replicated(app, sub(base, {"inner_exit_m"}), fmt(p.inner_exit_m)); + set_replicated(app, sub(base, {"middle_enter_m"}), fmt(p.middle_enter_m)); + set_replicated(app, sub(base, {"middle_exit_m"}), fmt(p.middle_exit_m)); + set_replicated(app, sub(base, {"downgrade_dwell_s"}), fmt(p.downgrade_dwell_s)); + set_replicated(app, sub(base, {"pose_push_hz"}), fmt(p.pose_push_hz)); + set_replicated(app, sub(base, {"pose_refresh_s"}), fmt(p.pose_refresh_s)); + set_replicated(app, sub(base, {"thumb_fps"}), fmt(p.thumb_fps)); + set_replicated(app, sub(base, {"video_rank_margin_m"}), fmt(p.video_rank_margin_m)); + set_replicated(app, sub(base, {"audio_fade_m"}), fmt(p.audio_fade_m)); + set_replicated(app, sub(base, {"video_full_max"}), std::to_string(p.video_full_max)); + set_replicated(app, sub(base, {"audio_rolloff"}), p.audio_rolloff ? "1" : "0"); + // Written LAST and read as the presence check: the journal replicates in write order, so a + // reader that sees it also sees the fields of that first write. + set_replicated(app, sub(base, {"enabled"}), p.enabled ? "1" : "0"); +} + +bool read_aoi_params(cvc::app &app, const std::string &room, aoi_params &out) { + const std::string base = room_aoi_path(room); + if (!find(app, sub(base, {"enabled"}))) + return false; + aoi_params p = out; + p.preset = get_s(app, sub(base, {"preset"}), p.preset); + p.inner_enter_m = get_d(app, sub(base, {"inner_enter_m"}), p.inner_enter_m); + p.inner_exit_m = get_d(app, sub(base, {"inner_exit_m"}), p.inner_exit_m); + p.middle_enter_m = get_d(app, sub(base, {"middle_enter_m"}), p.middle_enter_m); + p.middle_exit_m = get_d(app, sub(base, {"middle_exit_m"}), p.middle_exit_m); + p.downgrade_dwell_s = get_d(app, sub(base, {"downgrade_dwell_s"}), p.downgrade_dwell_s); + p.pose_push_hz = get_d(app, sub(base, {"pose_push_hz"}), p.pose_push_hz); + p.pose_refresh_s = get_d(app, sub(base, {"pose_refresh_s"}), p.pose_refresh_s); + p.thumb_fps = get_d(app, sub(base, {"thumb_fps"}), p.thumb_fps); + p.video_rank_margin_m = get_d(app, sub(base, {"video_rank_margin_m"}), p.video_rank_margin_m); + p.audio_fade_m = get_d(app, sub(base, {"audio_fade_m"}), p.audio_fade_m); + p.video_full_max = get_i(app, sub(base, {"video_full_max"}), p.video_full_max); + p.audio_rolloff = get_b(app, sub(base, {"audio_rolloff"}), p.audio_rolloff); + p.enabled = get_b(app, sub(base, {"enabled"}), p.enabled); + out = std::move(p); + return true; +} + +} // namespace conference +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/ariadne/conference/room_aoi_relay.cpp b/src/cvc/ariadne/conference/room_aoi_relay.cpp new file mode 100644 index 000000000..f7b18e2c1 --- /dev/null +++ b/src/cvc/ariadne/conference/room_aoi_relay.cpp @@ -0,0 +1,453 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +#include +#include +#include +#include +#include // room_participant_path +#include +#include +#include +#include +#include // SEPARATOR, findDescendant +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace conference { + +namespace { + +using snapshot_entry = cvc::state_transport::snapshot_entry; + +// A registered participant receives only the room tree, as every conference client's own +// enforced interest does. +const char *const kRoomTreePrefix = "conf"; + +// write_pose's change threshold (m, m/s, quaternion units): a subject whose state moved less than +// this since its last version wrote nothing new to its leaves either. +constexpr double kPoseEpsilon = 1e-4; + +bool differs(double a, double b) { return std::fabs(a - b) > kPoseEpsilon; } + +bool moved(const cvc::physics::body_state &a, const cvc::physics::body_state &b) { + return differs(a.position.x, b.position.x) || differs(a.position.y, b.position.y) || + differs(a.position.z, b.position.z) || differs(a.rotation.w, b.rotation.w) || + differs(a.rotation.x, b.rotation.x) || differs(a.rotation.y, b.rotation.y) || + differs(a.rotation.z, b.rotation.z) || differs(a.linear_velocity.x, b.linear_velocity.x) || + differs(a.linear_velocity.y, b.linear_velocity.y) || + differs(a.linear_velocity.z, b.linear_velocity.z) || + differs(a.angular_velocity.x, b.angular_velocity.x) || + differs(a.angular_velocity.y, b.angular_velocity.y) || + differs(a.angular_velocity.z, b.angular_velocity.z); +} + +// PID's pose subtree as push entries: its leaves as the relay's tree holds them, without the +// valueless intermediate nodes (pose, pose.pos, pose.vel, ...: a leaf's path recreates them), and +// the server_tick last, re-stamped with STAMP. Empty when the subject has no pose leaves yet. +std::vector pose_entries(cvc::app &app, const cvc::state_cluster_shard &shard, + const std::string &room, const std::string &pid, + const std::string &stamp) { + const std::string base = room_participant_path(room, pid) + cvc::state::SEPARATOR + "pose"; + std::vector out; + // shard.snapshot() would create a missing subtree's root node. + if (!app.root().findDescendant(base)) + return out; + const std::string tick_path = base + cvc::state::SEPARATOR + "server_tick"; + snapshot_entry tick; + tick.path = tick_path; + for (snapshot_entry &e : shard.snapshot(base)) { + if (e.path == tick_path) + tick = std::move(e); + else if (!e.string_value.empty() || !e.data_type_name.empty()) + out.push_back(std::move(e)); + } + if (out.empty()) + return out; + tick.string_value = stamp; + out.push_back(std::move(tick)); + return out; +} + +} // namespace + +// ---------------- the media gate's state ---------------- + +namespace { + +// One (receiver, publisher, stream)'s thumbnail schedule (file-local, see room_aoi_relay.h). +struct gate_thumb { + std::string receiver; + std::string publisher; + std::string stream_id; + std::string codec; + std::chrono::steady_clock::time_point due; // the next frame at or after this goes out +}; + +} // namespace + +// What the gate closure owns: the snapshot it decides by, the thumbnail timers, and its counters. +// The driver swaps the snapshot in on the main loop; publisher reader threads read it. +struct room_aoi_relay::gate_state { + explicit gate_state(double thumb_fps) { + if (std::isfinite(thumb_fps) && thumb_fps > 0.0) { + _thumbs_on = true; + _thumb_every = std::chrono::duration_cast( + std::chrono::duration(1.0 / thumb_fps)); + } + } + + // Null (once the driver is gone) delivers everything. + std::shared_ptr load() const { + std::lock_guard lk(_mu); + return _snap; + } + void store(std::shared_ptr s) { + { + std::lock_guard lk(_mu); + _snap.swap(s); + } + // The previous snapshot is released here, outside the lock. + } + + // Whether RECEIVER gets F. A failure (an allocation in the timer map) delivers. + bool admit(const cvc::media_frame &f, const std::string &receiver) noexcept { + try { + const std::shared_ptr s = load(); + if (!s) + return true; + switch (s->media(receiver, f.origin_node_id, f.stream_id, f.codec)) { + case aoi_media::deliver: + full.fetch_add(1, std::memory_order_relaxed); + return true; + case aoi_media::thumbnail: + if (thumbnail_due(f, receiver)) { + thumbs.fetch_add(1, std::memory_order_relaxed); + return true; + } + break; + case aoi_media::drop: + break; + } + dropped.fetch_add(1, std::memory_order_relaxed); + return false; + } catch (...) { + return true; + } + } + + // Forget the timers of the pairs whose decision under S is no longer a thumbnail, so the next + // time one becomes a thumbnail its first frame goes out at once. Called on the main loop with + // each new snapshot. + void sweep(const aoi_snapshot &s) { + std::lock_guard lk(_thumb_mu); + for (auto it = _thumbs.begin(); it != _thumbs.end();) { + const thumb &t = it->second; + if (s.media(t.receiver, t.publisher, t.stream_id, t.codec) == aoi_media::thumbnail) + ++it; + else + it = _thumbs.erase(it); + } + } + + std::atomic full{0}; + std::atomic thumbs{0}; + std::atomic dropped{0}; + +private: + using thumb = gate_thumb; + + // A pair's first thumbnail frame goes out at once; after that one per thumb interval, on the + // interval's own schedule (so a 30 fps camera at 2 fps sends two a second), restarting from now + // when the frames fell more than an interval behind it. + bool thumbnail_due(const cvc::media_frame &f, const std::string &receiver) { + if (!_thumbs_on) + return false; + const auto now = std::chrono::steady_clock::now(); + std::string key = receiver; + key.push_back('\0'); + key += f.origin_node_id; + key.push_back('\0'); + key += f.stream_id; + std::lock_guard lk(_thumb_mu); + const auto it = _thumbs.find(key); + if (it == _thumbs.end()) { + _thumbs.emplace(std::move(key), + thumb{receiver, f.origin_node_id, f.stream_id, f.codec, now + _thumb_every}); + return true; + } + thumb &t = it->second; + if (now < t.due) + return false; + t.due += _thumb_every; + if (t.due <= now) + t.due = now + _thumb_every; + return true; + } + + mutable std::mutex _mu; + std::shared_ptr _snap; + bool _thumbs_on = false; // thumb_fps > 0; otherwise a thumbnailed camera is dropped + std::chrono::steady_clock::duration _thumb_every{}; + std::mutex _thumb_mu; + std::map _thumbs; // receiver \0 publisher \0 stream_id +}; + +// ---------------- room_aoi_relay ---------------- + +namespace { + +// The driver's bookkeeping (room_aoi_relay::book): file-local, see room_aoi_relay.h. +struct plane { + cvc::state_transport *tp = nullptr; + std::set added; // pids this driver registered (removed again on departure) + std::set filtered; // pids carrying this driver's filter + std::map filtered_seen; // deliveries_filtered at the last stats +}; + +struct filter_ref { + std::shared_ptr filter; +}; +using filter_map = std::map; + +// One filter per present participant, bound to SNAP; none when SNAP is disabled. +filter_map filters_of(const std::shared_ptr &snap) { + filter_map out; + if (!snap || !snap->enabled()) + return out; + for (const std::string &pid : snap->participants()) + // The room tree takes any child name, but a registry entry needs a C identifier (add_peer + // throws otherwise). No connection can identify as such a pid either (a shard's own node id is + // validated), so there is no receiver to filter: skip it. As a subject it is still filtered + // for everyone else. + if (cvc::state::isValidStateName(pid)) + out.emplace(pid, filter_ref{std::make_shared(snap, pid)}); + return out; +} + +void release(plane &pl, const std::string &pid) { + pl.filtered_seen.erase(pid); + cvc::state_peer_registry ® = pl.tp->peers(); + if (pl.added.erase(pid)) { + reg.remove_peer(pid); + pl.filtered.erase(pid); + } else if (pl.filtered.erase(pid)) { + reg.set_filter(pid, nullptr); + } +} + +void install(plane &pl, const filter_map &filters, const std::string &cluster_id, + const std::set &departed) { + cvc::state_peer_registry ® = pl.tp->peers(); + for (const auto &kv : filters) { + if (!reg.has_peer(kv.first)) { + reg.add_peer(kv.first, cluster_id, std::string(), {kRoomTreePrefix}); + pl.added.insert(kv.first); + } + reg.set_filter(kv.first, kv.second.filter); + pl.filtered.insert(kv.first); + } + for (const std::string &pid : departed) + release(pl, pid); +} + +} // namespace + +struct room_aoi_relay::book { + std::vector planes; + std::map pose_ref; // the pose at its last version + std::map pose_version; // tick of the last move > 1e-4 +}; + +room_aoi_relay::room_aoi_relay(cvc::app &app, cvc::state_cluster_shard &shard, std::string room, + aoi_params p) + : _app(app), _shard(shard), _aoi(std::move(room), std::move(p)), + _book(std::make_unique()), + _gate(std::make_shared(_aoi.params().thumb_fps)) { + _gate->store(_aoi.snapshot()); +} + +room_aoi_relay::~room_aoi_relay() { + // The gates first: from here no new frame consults this driver, and a reader thread still + // inside a copy of the gate delivers everything once the snapshot is gone. + for (cvc::media_endpoint *ep : _endpoints) + ep->set_media_gate({}); + _gate->store(nullptr); + for (plane &pl : _book->planes) { + cvc::state_peer_registry ® = pl.tp->peers(); + for (const std::string &pid : pl.added) + reg.remove_peer(pid); + for (const std::string &pid : pl.filtered) + if (!pl.added.count(pid)) + reg.set_filter(pid, nullptr); + } +} + +void room_aoi_relay::add_transport(cvc::state_transport *tp) { + if (!tp) + return; + for (const plane &pl : _book->planes) + if (pl.tp == tp) + return; + _book->planes.emplace_back(); + _book->planes.back().tp = tp; + install(_book->planes.back(), filters_of(_aoi.snapshot()), _shard.cluster_id(), _aoi.departed()); +} + +void room_aoi_relay::add_media_endpoint(cvc::media_endpoint *ep) { + if (!ep) + return; + for (const cvc::media_endpoint *e : _endpoints) + if (e == ep) + return; + _endpoints.push_back(ep); + // The closure owns the gate state, never `this`. + ep->set_media_gate([st = _gate](const cvc::media_frame &f, const std::string &receiver) { + return st->admit(f, receiver); + }); +} + +void room_aoi_relay::step(const cvc::ariadne::holon &server, double now_s) { + const std::uint64_t tick = server.tick(); + const std::vector pids = server.participants(); + const std::set here(pids.begin(), pids.end()); + std::vector present; + present.reserve(pids.size()); + for (const std::string &pid : pids) { + const cvc::physics::body_state s = server.state(pid); + present.push_back({pid, s.position.x, s.position.y}); + // A subject's version moves when its state does, by write_pose's measure, against the state + // at its last version (so a slow drift still counts once it adds up). + const auto [it, fresh] = _book->pose_ref.try_emplace(pid, s); + if (fresh || moved(it->second, s)) { + it->second = s; + _book->pose_version[pid] = tick; + } + } + for (auto it = _book->pose_ref.begin(); it != _book->pose_ref.end();) { + if (here.count(it->first)) { + ++it; + continue; + } + _book->pose_version.erase(it->first); + it = _book->pose_ref.erase(it); + } + if (_aoi.update(present, tick, now_s)) + apply(_aoi.snapshot()); + // Every step, so the inner pairs' bookkeeping keeps up with the live stream. + push_poses(tick); +} + +void room_aoi_relay::apply(const std::shared_ptr &snap) { + _gate->store(snap); + _gate->sweep(*snap); + // One filter per participant, shared by every registry. + const filter_map filters = filters_of(snap); + for (plane &pl : _book->planes) + install(pl, filters, _shard.cluster_id(), _aoi.departed()); +} + +void room_aoi_relay::push_poses(std::uint64_t tick) { + const std::vector due = _aoi.take_pose_pushes(tick, _book->pose_version); + if (due.empty() || _book->planes.empty()) + return; + const std::string stamp = std::to_string(static_cast(tick)); + std::map> subtrees; // each subject read once a step + for (const aoi_push &p : due) { + std::vector entries; + for (const std::string &q : p.subjects) { + auto it = subtrees.find(q); + if (it == subtrees.end()) + it = subtrees.emplace(q, pose_entries(_app, _shard, _aoi.room(), q, stamp)).first; + entries.insert(entries.end(), it->second.begin(), it->second.end()); + } + if (entries.empty()) + continue; + for (plane &pl : _book->planes) { + const std::size_t n = pl.tp->push_snapshot_to(p.receiver, entries); + _pushes += n; + _push_entries += n * entries.size(); + } + } +} + +room_aoi_relay::stats room_aoi_relay::take_stats() { + stats st; + const std::shared_ptr snap = _aoi.snapshot(); + const std::vector &ps = snap->participants(); + for (std::size_t i = 0; i < ps.size(); ++i) + for (std::size_t j = i + 1; j < ps.size(); ++j) + switch (snap->tier(ps[i], ps[j])) { + case aoi_tier::inner: + ++st.inner; + break; + case aoi_tier::middle: + ++st.middle; + break; + case aoi_tier::outer: + ++st.outer; + break; + } + st.pushes = std::exchange(_pushes, 0); + st.push_entries = std::exchange(_push_entries, 0); + st.media_full = _gate->full.exchange(0, std::memory_order_relaxed); + st.media_thumb = _gate->thumbs.exchange(0, std::memory_order_relaxed); + st.media_dropped = _gate->dropped.exchange(0, std::memory_order_relaxed); + // The registries count per peer since registration; report what was added since the last call. + for (plane &pl : _book->planes) + for (const cvc::state_peer_registry::peer &peer : pl.tp->peers().snapshot()) { + if (!pl.filtered.count(peer.node_id)) + continue; + std::uint64_t &seen = pl.filtered_seen[peer.node_id]; + st.state_filtered += peer.deliveries_filtered >= seen ? peer.deliveries_filtered - seen + : peer.deliveries_filtered; + seen = peer.deliveries_filtered; + } + return st; +} + +std::shared_ptr room_aoi_relay::snapshot() const { return _aoi.snapshot(); } + +const aoi_params &room_aoi_relay::params() const noexcept { return _aoi.params(); } + +// ---------------- the relay's room step ---------------- + +std::size_t relay_room_step(cvc::state_cluster_shard &shard, + const std::vector &planes, room_aoi_relay *aoi, + const cvc::ariadne::holon &server, double now_s, bool trim_journal) { + if (aoi) + aoi->step(server, now_s); + const std::vector pending = shard.drain_local(); + for (const cvc::state_mutation &m : pending) + for (cvc::state_transport *tp : planes) + if (tp) + tp->publish(m); + if (trim_journal) + shard.trim_published(); + return pending.size(); +} + +} // namespace conference +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/ariadne/conference/room_intrinsics.cpp b/src/cvc/ariadne/conference/room_intrinsics.cpp index 361a2fb81..53642954a 100644 --- a/src/cvc/ariadne/conference/room_intrinsics.cpp +++ b/src/cvc/ariadne/conference/room_intrinsics.cpp @@ -8,6 +8,7 @@ License version 2.1 as published by the Free Software Foundation. */ +#include // atan2 (room-set-intent facing) #include #include // strtod (room-set-intent numeric args) #include // register_action_intrinsics @@ -323,12 +324,17 @@ void register_room_intrinsics(cvc::app &app) { if (!tbl) return err("room-set-intent: internal: room-table slot unavailable"); if (args.size() < 4) - return err("room-set-intent: (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z)"); + return err("room-set-intent: (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z [YAW])"); room_handle *rh = tbl->find(handle_token(args[0])); if (!rh) return err("room-set-intent: ROOM must be a room-join handle (needs the caller's pid)"); cvc::ariadne::avatar_intent in; in.move = {get_num(&args[1]), get_num(&args[2]), get_num(&args[3])}; + // The facing: YAW (radians about +Z from +X) when given, else the way it walks. + if (args.size() >= 5) + in.yaw = get_num(&args[4]); + else if (in.move.x != 0.0 || in.move.y != 0.0) + in.yaw = std::atan2(in.move.y, in.move.x); cvc::ariadne::write_intent(app, rh->room_id, rh->self_pid, in); return se::make_dict({{"ok", se::value_t(true)}}); }); diff --git a/src/cvc/ariadne/conference/room_net.cpp b/src/cvc/ariadne/conference/room_net.cpp index 114d0841b..b81fa0b73 100644 --- a/src/cvc/ariadne/conference/room_net.cpp +++ b/src/cvc/ariadne/conference/room_net.cpp @@ -55,6 +55,30 @@ void set_replicated(cvc::app &app, const std::string &path, const std::string &v n.value(val); } +// Changed-only with ONE journaled mutation per change, for a leaf that flips often. touch() fires +// valueChanged before value() fires it again, so set_replicated's touch would first re-send the +// leaf's OLD value; it is only needed while the leaf is new or still empty (the attach race above). +// A valued leaf is already observed: its first value was set after the touch that attached it, or +// it arrived through replication (applying a remote node attaches it too). +// FORCE re-sends an unchanged value (one touch) rather than writing nothing. +void set_changed_once(cvc::app &app, const std::string &path, const std::string &val, + bool force = false) { + cvc::state *n = app.root().findDescendant(path); + const std::string cur = n ? n->value() : std::string(); + if (n && cur == val) { + if (force && !cur.empty()) + n->touch(); + return; + } + if (!cur.empty()) { + n->value(val); + return; + } + cvc::state &m = app.root()(path); + m.touch(); + m.value(val); +} + double read_double(cvc::app &app, const std::string &path, double def) { cvc::state *n = app.root().findDescendant(path); return n && !n->value().empty() ? std::strtod(n->value().c_str(), nullptr) : def; @@ -83,6 +107,10 @@ std::string server_net_path(const std::string &room) { return sub("conf", {"rooms", room.c_str(), "server", "net"}); } +std::string participant_talking(const std::string &room, const std::string &pid) { + return sub(room_participant_path(room, pid), {"voice", "talking"}); +} + } // namespace void write_participant_net(cvc::app &app, const std::string &room, const std::string &pid, @@ -122,6 +150,21 @@ bool read_server_net(cvc::app &app, const std::string &room, server_net &out) { return true; } +void write_participant_voice(cvc::app &app, const std::string &room, const std::string &pid, + bool talking, bool force) { + set_changed_once(app, participant_talking(room, pid), talking ? "1" : "0", force); +} + +bool read_participant_voice(cvc::app &app, const std::string &room, const std::string &pid, + bool &talking) { + cvc::state *n = app.root().findDescendant(participant_talking(room, pid)); + const std::string v = n ? n->value() : std::string(); + if (v.empty()) + return false; + talking = v == "1"; + return true; +} + std::string format_rtt(double rtt_ms) { if (rtt_ms < 0.0) return "--"; diff --git a/src/cvc/ariadne/conference/room_scene.cpp b/src/cvc/ariadne/conference/room_scene.cpp index ad2398850..a29e7f120 100644 --- a/src/cvc/ariadne/conference/room_scene.cpp +++ b/src/cvc/ariadne/conference/room_scene.cpp @@ -13,6 +13,8 @@ Lesser General Public License for more details. */ +#include +#include #include #include #include @@ -170,6 +172,8 @@ void write_scene_env(cvc::app &app, const std::string &room_id, const room_scene set_rgb(app, sub(e, {"background", "top"}), env.background_top); set_rgb(app, sub(e, {"background", "bottom"}), env.background_bottom); set_b(app, sub(e, {"chrome"}), env.chrome); + set_b(app, sub(e, {"shadows", "enabled"}), env.shadows); + set_replicated(app, sub(e, {"shadows", "resolution"}), std::to_string(env.shadow_resolution)); std::set written; for (const cvc::ariadne::SceneLight &l : env.lights) { check_id(l.id); @@ -191,6 +195,10 @@ bool read_scene_env(cvc::app &app, const std::string &room_id, room_scene_env &e get_rgb(app, sub(e, {"background", "top"}), out.background_top); get_rgb(app, sub(e, {"background", "bottom"}), out.background_bottom); out.chrome = get_b(app, sub(e, {"chrome"}), true); + out.shadows = get_b(app, sub(e, {"shadows", "enabled"}), false); + const std::string res = get_s(app, sub(e, {"shadows", "resolution"})); + if (!res.empty()) + out.shadow_resolution = std::max(256, std::atoi(res.c_str())); for (const std::string &id : child_ids(app, lights_path(room_id))) { const std::string base = sub(lights_path(room_id), {id.c_str()}); if (get_b(app, sub(base, {"present"}), false)) @@ -209,6 +217,13 @@ void write_scene_object(cvc::app &app, const std::string &room_id, set_f(app, sub(o, {"source", "plane_size"}), obj.plane_size); set_v3(app, sub(o, {"source", "box"}), obj.box_size); set_f(app, sub(o, {"source", "radius"}), obj.sphere_radius); + // Only an object with a fit -- or one replacing an object that had one, to clear it -- writes + // these, so every other object keeps exactly the leaves it always had. Empty height = no fit. + const std::string fit = sub(o, {"source", "fit"}); + if (obj.has_fit || find(app, sub(fit, {"height"}))) { + set_replicated(app, sub(fit, {"height"}), obj.has_fit ? fmt(obj.fit_height) : ""); + set_b(app, sub(fit, {"up_y"}), obj.has_fit && obj.fit_up_y); + } set_v3(app, sub(o, {"transform", "pos"}), obj.position); set_v3(app, sub(o, {"transform", "rot"}), obj.rotation); set_v3(app, sub(o, {"transform", "scale"}), obj.scale); @@ -247,6 +262,11 @@ std::vector read_scene_objects(cvc::app &app, const std n.plane_size = get_f(app, sub(o, {"source", "plane_size"}), n.plane_size); get_v3(app, sub(o, {"source", "box"}), n.box_size); n.sphere_radius = get_f(app, sub(o, {"source", "radius"}), n.sphere_radius); + if (!get_s(app, sub(o, {"source", "fit", "height"})).empty()) { + n.has_fit = true; + n.fit_height = get_f(app, sub(o, {"source", "fit", "height"}), n.fit_height); + n.fit_up_y = get_b(app, sub(o, {"source", "fit", "up_y"}), false); + } n.has_transform = true; get_v3(app, sub(o, {"transform", "pos"}), n.position); get_v3(app, sub(o, {"transform", "rot"}), n.rotation); @@ -279,6 +299,16 @@ cvc::ariadne::Scene read_room_scene(cvc::app &app, const std::string &room_id) { s.lights = env.lights; s.has_chrome = true; s.chrome_visible = env.chrome; + // Shadows only when the ROOM turns them on: a room that leaves them off must not switch off the + // ones the client's document enabled (the conference document owns the look). + if (env.shadows) { + s.has_shadows = true; + s.shadows_enabled = true; + s.has_shadow_resolution = true; + s.shadow_resolution = env.shadow_resolution; + s.has_shadow_interval = true; + s.shadow_interval = 1; // avatars move every frame: re-bake every frame + } s.has_background = true; s.background_gradient = env.background_gradient; for (int i = 0; i < 3; ++i) { @@ -296,13 +326,9 @@ room_scene_env default_scene_env() { env.background_bottom[0] = 0.05f, env.background_bottom[1] = 0.06f, env.background_bottom[2] = 0.08f; env.chrome = false; // the SceneGraph chrome is a debug overlay (bbox, axes, labels), not decor - cvc::ariadne::SceneLight sun; - sun.id = "sun"; - sun.kind = "directional"; - sun.azimuth = -35.0f; - sun.elevation = 55.0f; - sun.intensity = 1.2f; - env.lights.push_back(sun); + // No lights and no shadows: the room's look -- its light rig and real-time shadows -- is the + // client document's (examples/ariadne/documents/conference_roster.ari, scene:), so every client + // draws the shared objects under the rig its document declares. return env; } @@ -336,6 +362,53 @@ bool seed_default_scene(cvc::app &app, const std::string &room_id) { return true; } +std::vector large_demo_scene_objects(double side_m, int grid) { + if (!(std::isfinite(side_m) && side_m > 0.0)) + throw std::invalid_argument("large demo scene: side_m must be finite and > 0"); + if (grid < 1) + throw std::invalid_argument("large demo scene: grid must be >= 1"); + std::vector out = default_scene_objects(); // {ground, ref} + out[0].plane_size = static_cast(side_m); + + cvc::ariadne::SceneNode bunny; + bunny.source_file = "x.bunny"; // the embedded Stanford bunny: no asset to ship + bunny.has_fit = true; // a Y-up mesh about 15 cm tall, stood up Z-up and scaled + bunny.fit_up_y = true; + bunny.fit_height = 1.7f; // metres along its largest extent; it stands about that tall + bunny.color[0] = 0.78f, bunny.color[1] = 0.74f, bunny.color[2] = 0.66f; + bunny.ambient = 0.3f; + bunny.diffuse = 0.8f; + bunny.has_specular = true; + bunny.specular = 0.2f; + bunny.specular_power = 16.0f; + + const double spacing = side_m / grid; + for (int j = 0; j < grid; ++j) + for (int i = 0; i < grid; ++i) { + // The middle cell of an odd grid sits on the origin: the reference box's. + if (grid % 2 == 1 && i == grid / 2 && j == grid / 2) + continue; + cvc::ariadne::SceneNode b = bunny; + b.id = "bunny_" + std::to_string(i) + "_" + std::to_string(j); + b.position[0] = static_cast(-0.5 * side_m + (i + 0.5) * spacing); + b.position[1] = static_cast(-0.5 * side_m + (j + 0.5) * spacing); + out.push_back(std::move(b)); + } + return out; +} + +bool seed_large_demo_scene(cvc::app &app, const std::string &room_id, double side_m, int grid) { + // Validate before the revision check, so a bad argument throws whether or not the room is + // already seeded. + const std::vector objects = large_demo_scene_objects(side_m, grid); + if (scene_revision(app, room_id) != 0) + return false; + write_scene_env(app, room_id, default_scene_env()); + for (const cvc::ariadne::SceneNode &n : objects) + write_scene_object(app, room_id, n); + return true; +} + } // namespace conference } // namespace ariadne } // namespace cvc diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index 7ca50f2f2..101036456 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -13,8 +13,13 @@ Lesser General Public License for more details. */ +#include +#include #include +#include #include +#include +#include namespace cvc { namespace ariadne { @@ -30,6 +35,16 @@ constexpr int kOrderPresent = 10; holon::holon(holon_role role, holon_config cfg) : _role(role), _cfg(cfg), _clock(cfg.clock), _driver(_clock) { + if (_cfg.physics) { + try { + _world = std::make_unique(_clock.fixed_dt(), _cfg.body); + } catch (const std::exception &e) { + std::fprintf(stderr, + "holon: no avatar physics in this build (%s); avatars move kinematically and " + "do not collide\n", + e.what()); + } + } _driver.add_step_observer(kOrderIntegrate, [this](const cvc::clock_driver::clock_frame &f) { on_step(f); }); _driver.add_frame_observer(kOrderPresent, @@ -37,22 +52,64 @@ holon::holon(holon_role role, holon_config cfg) } void holon::add_participant(const std::string &pid, bool local, - const cvc::physics::body_state &start) { + const cvc::physics::body_state &start, bool pose_known) { participant p; p.local = local; + p.pose_known = pose_known || _role == holon_role::server; p.sim.set_state(start); p.predicted = predicted_avatar(start); p.newest = start; p.prev = start; + p.spawn = start; _participants[pid] = p; + // In the world: the server simulates everyone, a client its own avatar; a client's peers are + // kinematic, held where they are shown -- once there is somewhere to show them (a peer whose + // pose is not known yet gets its body with its first sample, apply_authoritative). + if (!_world) + return; + const bool dynamic = _role == holon_role::server || local; + if (dynamic || p.pose_known) + _world->add_avatar(pid, start, dynamic); + else + _world->remove_avatar(pid); // re-registered pose-less: no body left at its old pose +} + +void holon::remove_participant(const std::string &pid) { + const auto it = _participants.find(pid); + if (it != _participants.end() && _role == holon_role::client && it->second.have_seen) { + _left[pid] = it->second.last_seen; + if (_left.size() > kMaxLeft) + _left.erase(_left.begin() == _left.find(pid) ? std::next(_left.begin()) : _left.begin()); + } + _participants.erase(pid); + if (_world) + _world->remove_avatar(pid); +} + +void holon::set_statics(const std::vector &statics, std::uint64_t revision) { + _statics_rev = revision; + if (_world) + _world->set_statics(statics); } -void holon::remove_participant(const std::string &pid) { _participants.erase(pid); } +std::uint64_t holon::left_tick(const std::string &pid) const { + const auto it = _left.find(pid); + return it == _left.end() ? 0 : it->second; +} bool holon::has_participant(const std::string &pid) const { return _participants.find(pid) != _participants.end(); } +bool holon::pose_known(const std::string &pid) const { + auto it = _participants.find(pid); + return it != _participants.end() && it->second.pose_known; +} + +bool holon::has_body(const std::string &pid) const { + return _world && _participants.count(pid) && _world->has_avatar(pid); +} + std::vector holon::participants() const { std::vector out; out.reserve(_participants.size()); @@ -61,6 +118,34 @@ std::vector holon::participants() const { return out; } +avatar_intent holon::sanitize_intent(const avatar_intent &in) const { + avatar_intent out = in; + const cvc::physics::vec3 &m = in.move; + if (!std::isfinite(m.x) || !std::isfinite(m.y) || !std::isfinite(m.z)) + out.move = cvc::physics::vec3{0.0, 0.0, 0.0}; + if (!_cfg.allow_vertical) + out.move.z = 0.0; + const double h2 = out.move.x * out.move.x + out.move.y * out.move.y; + if (h2 > _cfg.max_speed * _cfg.max_speed) { + const double k = _cfg.max_speed / std::sqrt(h2); + out.move.x *= k; + out.move.y *= k; + } + constexpr double kPi = 3.14159265358979323846; + if (!std::isfinite(out.yaw)) + out.yaw = 0.0; + else if (out.yaw > kPi || out.yaw < -kPi) + out.yaw = std::remainder(out.yaw, 2.0 * kPi); + return out; +} + +namespace { +bool same_intent(const avatar_intent &a, const avatar_intent &b) { + // Bitwise-ish: a NaN in `a` differs from whatever sanitize_intent made of it. + return a.move.x == b.move.x && a.move.y == b.move.y && a.move.z == b.move.z && a.yaw == b.yaw; +} +} // namespace + void holon::set_intent(const std::string &pid, const avatar_intent &in) { auto it = _participants.find(pid); if (it == _participants.end()) @@ -68,7 +153,59 @@ void holon::set_intent(const std::string &pid, const avatar_intent &in) { // A client may drive only its OWN (local) avatar; it cannot move a peer. if (_role == holon_role::client && !it->second.local) return; - it->second.intent = in; + const avatar_intent ok = sanitize_intent(in); + if (_role == holon_role::server && !same_intent(in, ok)) + ++_vetoes; + it->second.intent = ok; + it->second.queued.clear(); +} + +bool holon::override_state(const std::string &pid, const cvc::physics::body_state &feet) { + if (_role != holon_role::server) + return false; + auto it = _participants.find(pid); + if (it == _participants.end()) + return false; + if (_world) + _world->place(pid, feet); + else + it->second.sim.set_state(feet); + return true; +} + +bool holon::set_mass(const std::string &pid, double kg) { + if (_role != holon_role::server || !_world || !(kg > 0.0) || !std::isfinite(kg)) + return false; + if (!_participants.count(pid)) + return false; + _world->set_mass(pid, kg); + return true; +} + +void holon::queue_intent(const std::string &pid, const avatar_intent &in, std::uint64_t tick) { + if (_role != holon_role::server) + return; + auto it = _participants.find(pid); + if (it == _participants.end()) + return; + participant &p = it->second; + if (p.have_stamp && tick == p.last_stamp) + return; // already queued (the host re-reads an unchanged intent) + p.have_stamp = true; + p.last_stamp = tick; + // The quantum it can still take effect at: its own tick if that is still to come, else the + // next one (late), and never before what is already queued (stamps from a client whose clock + // moved backwards keep their arrival order). Stamped beyond the allowed lead: at once. + const std::uint64_t next = _clock.tick() + 1; + std::uint64_t at = tick < next ? next : tick; + if (at > next + _cfg.max_intent_lead_ticks) + at = next; + if (!p.queued.empty() && at < p.queued.back().first) + at = p.queued.back().first; + const avatar_intent ok = sanitize_intent(in); + if (!same_intent(in, ok)) + ++_vetoes; + p.queued.emplace_back(at, ok); } void holon::apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, @@ -78,33 +215,48 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body auto it = _participants.find(pid); if (it == _participants.end()) return; + if (it->second.have_seen && server_tick <= it->second.last_seen) + return; // seen already (an unchanged pose re-read), or older + it->second.have_seen = true; + it->second.last_seen = server_tick; + it->second.pose_known = true; + _left.erase(pid); // a real sample: whatever it left behind before is superseded // Count the server's ticks (see the header): re-align whenever the replica is more // than max_skew_ticks away -- on the first sample when the client joined a running // server, and after a stall. A replica already within the skew is left alone, so its // logged inputs still line up with the server's ticks and replay correctly. const std::uint64_t now = _clock.tick(); - const std::uint64_t skew = now > server_tick ? now - server_tick : server_tick - now; - if (skew > _cfg.max_skew_ticks) { - _clock.seek_tick(server_tick); + const std::uint64_t target = server_tick + _lead; + const bool behind = now < target; + const std::uint64_t skew = behind ? target - now : now - target; + const std::uint64_t tol = _lead == 0 ? _cfg.max_skew_ticks + : behind ? _cfg.lead_slack_behind_ticks + : _cfg.lead_slack_ahead_ticks; + if (skew > tol) { + ++_realigns; + _clock.seek_tick(target); for (auto &kv : _participants) { participant &q = kv.second; if (q.local) { // The logged inputs' ticks are on the old timeline: keep the state only. q.predicted = predicted_avatar(q.predicted.state()); + q.log.clear(); + q.acked = false; } else { // Shift the walk's start by the same jump, so the peer carries on from where // it is shown instead of restarting the walk (a visible step back). - q.newest_arrival_tick = server_tick > now - ? q.newest_arrival_tick + (server_tick - now) - : (q.newest_arrival_tick > now - server_tick - ? q.newest_arrival_tick - (now - server_tick) - : 0); + q.newest_arrival_tick = target > now ? q.newest_arrival_tick + (target - now) + : (q.newest_arrival_tick > now - target + ? q.newest_arrival_tick - (now - target) + : 0); } } } participant &p = it->second; - if (p.local) { + if (p.local && _world) { + world_reconcile(pid, p, auth, server_tick); + } else if (p.local) { const cvc::physics::vec3 before = p.predicted.state().position; if (p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol)) p.correction = p.correction + (p.predicted.state().position - before); @@ -116,6 +268,10 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body // collapse the window (prev == newest) and freeze it. if (p.have_sample && server_tick <= p.last_sample_tick) return; + // A peer registered before its pose was known has no body yet: its first sample is the first + // place it can be held (world_step_client keeps it at its shown pose from here on). + if (_world && !_world->has_avatar(pid)) + _world->add_avatar(pid, auth, /*dynamic=*/false); if (!p.have_sample) { p.newest = auth; p.prev = auth; @@ -130,23 +286,107 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body p.prev.position = shown.position; p.prev.rotation = shown.rotation; p.newest = auth; - p.sample_span = server_tick - p.last_sample_tick; + p.sample_span = std::min(server_tick - p.last_sample_tick, _cfg.max_peer_span_ticks); } p.last_sample_tick = server_tick; p.newest_arrival_tick = _clock.tick(); } } +void holon::world_step_client(const std::string &self, const avatar_intent &in) { + // Peers are kinematic: hold each where it is shown, so we bump into what we see. + for (const auto &kv : _participants) + if (!kv.second.local && _world->has_avatar(kv.first)) + _world->move_kinematic(kv.first, display_pose(kv.first)); + _world->drive(self, in); + _world->step(); +} + +bool holon::world_reconcile(const std::string &pid, participant &p, + const cvc::physics::body_state &auth, std::uint64_t server_tick) { + // predicted_avatar::reconcile over the world: a stale or duplicate ack is a no-op; a prediction + // within tolerance at server_tick holds; otherwise snap to the authority and replay the logged + // inputs after it (the peers held where they are shown now). + if (p.acked && server_tick <= p.last_acked) + return false; + p.acked = true; + p.last_acked = server_tick; + const double tol2 = _cfg.reconcile_tol * _cfg.reconcile_tol; + for (const participant::logged &e : p.log) + if (e.tick == server_tick) { + const cvc::physics::vec3 d = e.after.position - auth.position; + if (cvc::physics::dot(d, d) <= tol2) { + while (!p.log.empty() && p.log.front().tick <= server_tick) + p.log.pop_front(); + return false; + } + break; + } + const cvc::physics::vec3 before = _world->state(pid).position; + _world->place(pid, auth); + while (!p.log.empty() && p.log.front().tick <= server_tick) + p.log.pop_front(); + for (participant::logged &e : p.log) { + world_step_client(pid, e.in); + e.after = _world->state(pid); + } + p.correction = p.correction + (_world->state(pid).position - before); + return true; +} + void holon::on_step(const cvc::clock_driver::clock_frame &f) { const double dt = f.fixed_dt; const int steps = f.step.steps; + if (_world) { + // Rigid bodies. Nothing moves until the room's statics exist (there is nothing to stand on). + if (_statics_rev == 0) + return; + for (int i = 0; i < steps; ++i) { + const std::uint64_t tick = f.step.first_tick + 1 + static_cast(i); + if (_role == holon_role::server) { + for (auto &kv : _participants) { + participant &p = kv.second; + while (!p.queued.empty() && p.queued.front().first <= tick) { + p.intent = p.queued.front().second; + p.queued.pop_front(); + } + _world->drive(kv.first, p.intent); + } + _world->step(); + for (auto &kv : _participants) + if (_world->state(kv.first).position.z < _cfg.respawn_below_z) + _world->place(kv.first, kv.second.spawn); // fell off the world + } else { + const auto self = std::find_if(_participants.begin(), _participants.end(), + [](const auto &kv) { return kv.second.local; }); + if (self == _participants.end()) + continue; + world_step_client(self->first, self->second.intent); + participant::logged e; + e.tick = tick; + e.in = self->second.intent; + e.after = _world->state(self->first); + self->second.log.push_back(e); + if (self->second.log.size() > predicted_avatar::kMaxLog) + self->second.log.pop_front(); + } + } + return; + } for (auto &kv : _participants) { participant &p = kv.second; if (_role == holon_role::server) { - // Authoritative integration: advance by the latest intent over every quantum - // that fell due this frame. - for (int i = 0; i < steps; ++i) + // Authoritative integration: advance over every quantum that fell due this + // frame, each by the intent in force at its tick (a stamped intent takes over + // at the tick it was queued for). + for (int i = 0; i < steps; ++i) { + const std::uint64_t tick = f.step.first_tick + 1 + static_cast(i); + while (!p.queued.empty() && p.queued.front().first <= tick) { + p.intent = p.queued.front().second; + p.queued.pop_front(); + } p.sim.step(p.intent, dt); + } } else if (p.local) { // Client prediction: predict the local avatar one quantum at a time, tagging // each with its tick so reconcile can replay the right suffix. @@ -165,6 +405,8 @@ void holon::advance(double wall_dt) { _driver.advance(wall_dt); } cvc::physics::body_state holon::state(const std::string &pid) const { const participant &p = _participants.at(pid); + if (_world && (_role == holon_role::server || p.local)) + return _world->state(pid); if (_role == holon_role::server) return p.sim.state(); if (p.local) @@ -174,6 +416,10 @@ cvc::physics::body_state holon::state(const std::string &pid) const { cvc::physics::pose holon::display_pose(const std::string &pid) const { const participant &p = _participants.at(pid); + if (_world && (_role == holon_role::server || p.local)) { + const cvc::physics::body_state s = _world->state(pid); + return cvc::physics::pose{s.position, s.rotation}; + } if (_role == holon_role::server) { const cvc::physics::body_state &s = p.sim.state(); return cvc::physics::pose{s.position, s.rotation}; diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index bbb91603d..892b9c81d 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -13,10 +13,12 @@ Lesser General Public License for more details. */ +#include #include #include #include #include // room_participant_path, room_roster +#include // read_scene_objects, scene_revision #include // cvc::ariadne::holon (server_pump) #include #include @@ -53,7 +55,9 @@ std::string leaf(const std::string &room, const std::string &pid, } // touch-before-value, so a just-created node's first write is not lost to the -// distributed-state adapter's attach race (the room tree's set_replicated idiom). +// distributed-state adapter's attach race (the room tree's set_replicated idiom). Every call +// re-sends the leaf, changed or not, which is what an intent wants: proposing it again reaches a +// relay whose copy has diverged. Poses use set_pose_leaf instead. void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { cvc::state &n = app.root()(path); n.touch(); @@ -67,70 +71,204 @@ double read_double(cvc::app &app, const std::string &path) { bool node_present(cvc::app &app, const std::string &path) { return app.root().findDescendant(path) != nullptr; } +bool leaf_valued(cvc::app &app, const std::string &path) { + cvc::state *n = app.root().findDescendant(path); + return n && !n->value().empty(); +} + +// A pose leaf write that journals ONE mutation, where set_replicated journals two for a leaf whose +// value changes (touch fires valueChanged, then value() fires it again), doubling pose egress, +// backfill and journal growth. A leaf that is new, or exists but has never held a value, may not +// be observed by the distributed-state adapter yet (creating a node signals nothing; the adapter +// attaches it on the childChanged a touch emits), so it keeps touch-before-value and its value is +// the one write that journals. A leaf with a value is observed: a new value alone journals. FORCE +// re-sends an unchanged leaf with a touch (one mutation carrying the current value, for a peer +// that missed it); without it an unchanged leaf costs nothing. +void set_pose_leaf(cvc::app &app, const std::string &path, const std::string &val, bool force) { + cvc::state *n = app.root().findDescendant(path); + const std::string cur = n ? n->value() : std::string(); + if (cur.empty()) { + cvc::state &m = n ? *n : app.root()(path); + m.touch(); + m.value(val); + } else if (cur != val) { + n->value(val); + } else if (force) { + n->touch(); + } +} + +// set_pose_leaf unless the leaf already holds V to within kPoseEpsilon; true when it wrote. A +// resting rigid body jitters in its last float bits every step; that is not news worth a write. +constexpr double kPoseEpsilon = 1e-4; // m, m/s, quaternion units +bool set_if_changed(cvc::app &app, const std::string &path, double v) { + if (cvc::state *n = app.root().findDescendant(path); + n && !n->value().empty() && + std::fabs(std::strtod(n->value().c_str(), nullptr) - v) <= kPoseEpsilon) + return false; + set_pose_leaf(app, path, fmt(v), /*force=*/false); + return true; +} } // namespace void write_pose(cvc::app &app, const std::string &room, const std::string &pid, - const cvc::physics::body_state &s, std::uint64_t server_tick) { - set_replicated(app, leaf(room, pid, {"pose", "pos", "x"}), fmt(s.position.x)); - set_replicated(app, leaf(room, pid, {"pose", "pos", "y"}), fmt(s.position.y)); - set_replicated(app, leaf(room, pid, {"pose", "pos", "z"}), fmt(s.position.z)); - set_replicated(app, leaf(room, pid, {"pose", "orient", "w"}), fmt(s.rotation.w)); - set_replicated(app, leaf(room, pid, {"pose", "orient", "x"}), fmt(s.rotation.x)); - set_replicated(app, leaf(room, pid, {"pose", "orient", "y"}), fmt(s.rotation.y)); - set_replicated(app, leaf(room, pid, {"pose", "orient", "z"}), fmt(s.rotation.z)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "x"}), fmt(s.linear_velocity.x)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "y"}), fmt(s.linear_velocity.y)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "lin", "z"}), fmt(s.linear_velocity.z)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "x"}), fmt(s.angular_velocity.x)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "y"}), fmt(s.angular_velocity.y)); - set_replicated(app, leaf(room, pid, {"pose", "vel", "ang", "z"}), fmt(s.angular_velocity.z)); - set_replicated(app, leaf(room, pid, {"pose", "server_tick"}), - std::to_string(static_cast(server_tick))); + const cvc::physics::body_state &s, std::uint64_t server_tick, + std::uint64_t heartbeat_ticks) { + // Heartbeat 0 is the forced rewrite: every leaf goes out (once), changed or not. + const bool all = heartbeat_ticks == 0; + bool changed = false; + const auto put = [&](std::initializer_list parts, double v) { + const std::string path = leaf(room, pid, parts); + if (all) + set_pose_leaf(app, path, fmt(v), /*force=*/true), changed = true; + else + changed = set_if_changed(app, path, v) || changed; + }; + put({"pose", "pos", "x"}, s.position.x); + put({"pose", "pos", "y"}, s.position.y); + put({"pose", "pos", "z"}, s.position.z); + put({"pose", "orient", "w"}, s.rotation.w); + put({"pose", "orient", "x"}, s.rotation.x); + put({"pose", "orient", "y"}, s.rotation.y); + put({"pose", "orient", "z"}, s.rotation.z); + put({"pose", "vel", "lin", "x"}, s.linear_velocity.x); + put({"pose", "vel", "lin", "y"}, s.linear_velocity.y); + put({"pose", "vel", "lin", "z"}, s.linear_velocity.z); + put({"pose", "vel", "ang", "x"}, s.angular_velocity.x); + put({"pose", "vel", "ang", "y"}, s.angular_velocity.y); + put({"pose", "vel", "ang", "z"}, s.angular_velocity.z); + const std::string tick_path = leaf(room, pid, {"pose", "server_tick"}); + if (!changed) { + // Nothing moved: re-stamp only on the heartbeat (clients re-align their clocks on it). + cvc::state *t = app.root().findDescendant(tick_path); + const std::uint64_t last = t ? std::strtoull(t->value().c_str(), nullptr, 10) : 0; + if (t && server_tick >= last && server_tick - last < heartbeat_ticks) + return; + } + set_pose_leaf(app, tick_path, std::to_string(static_cast(server_tick)), all); } bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, cvc::physics::body_state &out, std::uint64_t &out_server_tick) { - if (!node_present(app, leaf(room, pid, {"pose", "pos", "x"}))) - return false; // the pose subtree has not been written/replicated yet - out.position = {read_double(app, leaf(room, pid, {"pose", "pos", "x"})), - read_double(app, leaf(room, pid, {"pose", "pos", "y"})), - read_double(app, leaf(room, pid, {"pose", "pos", "z"}))}; - out.rotation = {read_double(app, leaf(room, pid, {"pose", "orient", "w"})), - read_double(app, leaf(room, pid, {"pose", "orient", "x"})), - read_double(app, leaf(room, pid, {"pose", "orient", "y"})), - read_double(app, leaf(room, pid, {"pose", "orient", "z"}))}; - out.linear_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "lin", "x"})), - read_double(app, leaf(room, pid, {"pose", "vel", "lin", "y"})), - read_double(app, leaf(room, pid, {"pose", "vel", "lin", "z"}))}; - out.angular_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "ang", "x"})), - read_double(app, leaf(room, pid, {"pose", "vel", "ang", "y"})), - read_double(app, leaf(room, pid, {"pose", "vel", "ang", "z"}))}; - out.active = true; - cvc::state *t = app.root().findDescendant(leaf(room, pid, {"pose", "server_tick"})); + // A sample is whole once its position, orientation and tick have all arrived: the leaves + // replicate one by one (live mutations, or a push's entries), so a reader can catch a subtree + // half applied -- a position with no orientation yet, which is no rotation at all, or no tick. + const std::string tick_path = leaf(room, pid, {"pose", "server_tick"}); + if (!leaf_valued(app, leaf(room, pid, {"pose", "pos", "x"})) || + !leaf_valued(app, leaf(room, pid, {"pose", "orient", "w"})) || !leaf_valued(app, tick_path)) + return false; // the pose subtree has not been (wholly) written/replicated yet + cvc::physics::body_state s; + s.position = {read_double(app, leaf(room, pid, {"pose", "pos", "x"})), + read_double(app, leaf(room, pid, {"pose", "pos", "y"})), + read_double(app, leaf(room, pid, {"pose", "pos", "z"}))}; + s.rotation = {read_double(app, leaf(room, pid, {"pose", "orient", "w"})), + read_double(app, leaf(room, pid, {"pose", "orient", "x"})), + read_double(app, leaf(room, pid, {"pose", "orient", "y"})), + read_double(app, leaf(room, pid, {"pose", "orient", "z"}))}; + const double qn = std::sqrt(s.rotation.w * s.rotation.w + s.rotation.x * s.rotation.x + + s.rotation.y * s.rotation.y + s.rotation.z * s.rotation.z); + if (!std::isfinite(qn) || qn < 1e-6) + return false; // malformed, or caught between its orientation leaves + s.rotation = {s.rotation.w / qn, s.rotation.x / qn, s.rotation.y / qn, s.rotation.z / qn}; + s.linear_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "lin", "x"})), + read_double(app, leaf(room, pid, {"pose", "vel", "lin", "y"})), + read_double(app, leaf(room, pid, {"pose", "vel", "lin", "z"}))}; + s.angular_velocity = {read_double(app, leaf(room, pid, {"pose", "vel", "ang", "x"})), + read_double(app, leaf(room, pid, {"pose", "vel", "ang", "y"})), + read_double(app, leaf(room, pid, {"pose", "vel", "ang", "z"}))}; + s.active = true; + cvc::state *t = app.root().findDescendant(tick_path); + out = s; out_server_tick = t ? static_cast(std::strtoull(t->value().c_str(), nullptr, 10)) : 0; return true; } void write_intent(cvc::app &app, const std::string &room, const std::string &pid, - const avatar_intent &intent) { + const avatar_intent &intent, std::uint64_t tick) { set_replicated(app, leaf(room, pid, {"intent", "move", "x"}), fmt(intent.move.x)); set_replicated(app, leaf(room, pid, {"intent", "move", "y"}), fmt(intent.move.y)); set_replicated(app, leaf(room, pid, {"intent", "move", "z"}), fmt(intent.move.z)); + set_replicated(app, leaf(room, pid, {"intent", "yaw"}), fmt(intent.yaw)); + set_replicated(app, leaf(room, pid, {"intent", "tick"}), + std::to_string(static_cast(tick))); } -bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, - avatar_intent &out) { +bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, avatar_intent &out, + std::uint64_t *out_tick) { if (!node_present(app, leaf(room, pid, {"intent", "move", "x"}))) return false; out.move = {read_double(app, leaf(room, pid, {"intent", "move", "x"})), read_double(app, leaf(room, pid, {"intent", "move", "y"})), read_double(app, leaf(room, pid, {"intent", "move", "z"}))}; + out.yaw = read_double(app, leaf(room, pid, {"intent", "yaw"})); + if (out_tick) { + cvc::state *t = app.root().findDescendant(leaf(room, pid, {"intent", "tick"})); + *out_tick = t ? std::strtoull(t->value().c_str(), nullptr, 10) : 0; + } return true; } +holon_config room_holon_config() { + holon_config cfg; + cfg.physics = true; + cfg.max_speed = 4.0; // twice the walk; room to add a run without loosening the veto + return cfg; +} + +std::vector room_statics(cvc::app &app, const std::string &room) { + constexpr double kDeg = 3.14159265358979323846 / 180.0; + const auto axis_quat = [](double rad, int axis) { + cvc::physics::quat q{std::cos(0.5 * rad), 0.0, 0.0, 0.0}; + (axis == 0 ? q.x : axis == 1 ? q.y : q.z) = std::sin(0.5 * rad); + return q; + }; + const auto mul = [](const cvc::physics::quat &a, const cvc::physics::quat &b) { + return cvc::physics::quat{a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z, + a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y, + a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x, + a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w}; + }; + std::vector out; + for (const cvc::ariadne::SceneNode &n : conference::read_scene_objects(app, room)) { + avatar_static s; + s.center = {n.position[0], n.position[1], n.position[2]}; + // X, then Y, then Z: q = qz * qy * qx. + s.rotation = mul(axis_quat(n.rotation[2] * kDeg, 2), + mul(axis_quat(n.rotation[1] * kDeg, 1), axis_quat(n.rotation[0] * kDeg, 0))); + if (n.source_primitive == "plane") { + // A slab 0.2 m thick whose top face is the plane. + constexpr double kSlab = 0.1; + s.half_extents = {0.5 * n.plane_size * n.scale[0], 0.5 * n.plane_size * n.scale[1], kSlab}; + s.center.z -= kSlab; + } else if (n.source_primitive == "box") { + s.half_extents = {0.5 * n.box_size[0] * n.scale[0], 0.5 * n.box_size[1] * n.scale[1], + 0.5 * n.box_size[2] * n.scale[2]}; + } else if (n.source_primitive == "sphere") { + s.type = avatar_static::kind::sphere; + s.radius = n.sphere_radius * std::max({n.scale[0], n.scale[1], n.scale[2]}); + } else { + continue; // a mesh file: no physical shape yet + } + out.push_back(s); + } + return out; +} + +namespace { +// Physics: reload the statics when the room scene changed (its revision moved). +void sync_statics(holon &h, cvc::app &app, const std::string &room) { + if (!h.physics()) + return; + const std::uint64_t rev = conference::scene_revision(app, room); + if (rev != 0 && rev != h.statics_revision()) + h.set_statics(room_statics(app, room), rev); +} +} // namespace + void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt) { + sync_statics(h, app, room); // The present participants, straight from the replicated room tree. const std::vector roster = conference::room_roster(app, room); // Add any newcomer -- spawned on a 4 m ring around the room's origin (where the seeded @@ -146,8 +284,13 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d h.add_participant(p.pid, /*local=*/false, start); } avatar_intent in; - if (read_intent(app, room, p.pid, in)) - h.set_intent(p.pid, in); + std::uint64_t stamp = 0; + if (read_intent(app, room, p.pid, in, &stamp)) { + if (stamp != 0) + h.queue_intent(p.pid, in, stamp); // applied at its tick (no-op when already queued) + else + h.set_intent(p.pid, in); // untimed (a DSL verb, a departure's reset): at once + } } // Drop anyone who left (room_leave): the holon stops integrating them, and a later rejoin under // the same id respawns them on the ring rather than wherever a stale intent carried them. @@ -160,22 +303,39 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d // One authoritative frame. h.advance(wall_dt); // Publish each participant's authoritative pose (these are local writes on the - // relay's shard; the caller fans them to the clients). + // relay's shard; the caller fans them to the clients): what changed, and a + // heartbeat of about a second for an avatar standing still. + const std::uint64_t heartbeat = + std::max(1, static_cast(1.0 / h.clock().fixed_dt())); for (const conference::room_participant &p : roster) - write_pose(app, room, p.pid, h.state(p.pid), h.tick()); + write_pose(app, room, p.pid, h.state(p.pid), h.tick(), heartbeat); } void client_pump(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, double wall_dt) { + sync_statics(h, app, room); const std::vector roster = conference::room_roster(app, room); std::set present; for (const conference::room_participant &p : roster) { present.insert(p.pid); cvc::physics::body_state s; std::uint64_t server_tick = 0; - const bool have = read_pose(app, room, p.pid, s, server_tick); + bool have = read_pose(app, room, p.pid, s, server_tick); + // A peer back after leaving (an in-process reconnect keeps its pid) still has the pose subtree + // it left in our tree, which the relay withholds updates to while it is departed: until a + // newer sample arrives that is no pose -- registering it from that would show it where it left + // and seek our clock back to the old tick. "Newer" allows for a restarted relay, whose ticks + // start again near zero: only a tick within kLeftoverTicks at or below the one it left at is + // the leftover. + constexpr std::uint64_t kLeftoverTicks = 1200; // 10 s at the 120 Hz holon quantum + const std::uint64_t left = h.left_tick(p.pid); + if (have && left != 0 && server_tick <= left && left - server_tick <= kLeftoverTicks) + have = false; + // A participant the roster lists before its pose has replicated is registered pose-less: no + // body and no marker at the placeholder origin until its first sample (holon::pose_known). if (!h.has_participant(p.pid)) - h.add_participant(p.pid, /*local=*/p.pid == self_pid, have ? s : cvc::physics::body_state{}); + h.add_participant(p.pid, /*local=*/p.pid == self_pid, have ? s : cvc::physics::body_state{}, + /*pose_known=*/have); if (have) h.apply_authoritative(p.pid, s, server_tick); } @@ -187,8 +347,9 @@ void client_pump(holon &h, cvc::app &app, const std::string &room, const std::st void propose_intent(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, const avatar_intent &intent) { + // The holon predicts with it from its next quantum; stamp it with that tick. h.set_intent(self_pid, intent); - write_intent(app, room, self_pid, intent); + write_intent(app, room, self_pid, intent, h.tick() + 1); } } // namespace ariadne diff --git a/src/cvc/state/state_change_journal.cpp b/src/cvc/state/state_change_journal.cpp index a4843c632..90d366fb0 100644 --- a/src/cvc/state/state_change_journal.cpp +++ b/src/cvc/state/state_change_journal.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -81,13 +82,22 @@ std::vector state_change_journal::snapshot() const { std::vector state_change_journal::replay_after(std::uint64_t sequence) const { std::lock_guard lock(_mutex); - std::vector replay; - for (const state_mutation &mutation : _mutations) { - if (mutation.sequence > sequence) { - replay.push_back(mutation); - } - } - return replay; + // append() assigns strictly increasing sequences, so the tail after `sequence` is found by + // binary search: a drain that runs every frame costs O(new), not O(journal). + const auto first = + std::upper_bound(_mutations.begin(), _mutations.end(), sequence, + [](std::uint64_t s, const state_mutation &m) { return s < m.sequence; }); + return std::vector(first, _mutations.end()); +} + +std::size_t state_change_journal::trim_through(std::uint64_t sequence) { + std::lock_guard lock(_mutex); + const auto last = + std::upper_bound(_mutations.begin(), _mutations.end(), sequence, + [](std::uint64_t s, const state_mutation &m) { return s < m.sequence; }); + const auto n = static_cast(last - _mutations.begin()); + _mutations.erase(_mutations.begin(), last); + return n; } void state_change_journal::clear() { diff --git a/src/cvc/state/state_cluster_shard.cpp b/src/cvc/state/state_cluster_shard.cpp index 9859a83ce..dda66e6db 100644 --- a/src/cvc/state/state_cluster_shard.cpp +++ b/src/cvc/state/state_cluster_shard.cpp @@ -441,6 +441,10 @@ void state_cluster_shard::rewind_publish_cursor(std::uint64_t sequence) { _publish_cursor = sequence; } +std::size_t state_cluster_shard::trim_published() { + return _adapter->journal().trim_through(published_cursor()); +} + void state_cluster_shard::publish_delegation(const std::string &path_prefix, const std::string &cluster_id, const std::string &endpoint, @@ -553,6 +557,16 @@ bool state_cluster_shard::path_is_of_interest(const std::string &path) const { return false; } +bool state_cluster_shard::admits(const state_mutation &m) const { + return is_delegation_op(m.op) || path_is_of_interest(m.path); +} + +bool state_cluster_shard::admits_message(const state_message &m) const { + if (!m.cluster_id.empty() && m.cluster_id != _cluster_id) + return false; + return path_is_of_interest(m.path); +} + void state_cluster_shard::set_enforce_interest(bool enforce) noexcept { std::lock_guard lk(_mutex); _enforce_interest = enforce; diff --git a/src/cvc/state/state_peer_registry.cpp b/src/cvc/state/state_peer_registry.cpp index 9d42f52f2..98b92d4e0 100644 --- a/src/cvc/state/state_peer_registry.cpp +++ b/src/cvc/state/state_peer_registry.cpp @@ -48,7 +48,7 @@ void state_peer_registry::add_peer(std::string node_id, std::string cluster_id, if (!cluster_id.empty() && !state::isValidStateName(cluster_id)) throw std::invalid_argument("cluster_id '" + cluster_id + "' violates C identifier rules"); std::lock_guard lk(_mu); - auto &p = _peers[node_id]; + auto &p = _peers[node_id]; // an existing entry keeps its filter p.node_id = std::move(node_id); p.cluster_id = std::move(cluster_id); p.endpoint = std::move(endpoint); @@ -56,8 +56,14 @@ void state_peer_registry::add_peer(std::string node_id, std::string cluster_id, } bool state_peer_registry::remove_peer(const std::string &node_id) { + std::shared_ptr displaced; // released after _mu (see set_filter) std::lock_guard lk(_mu); - return _peers.erase(node_id) > 0; + auto it = _peers.find(node_id); + if (it == _peers.end()) + return false; + displaced = std::move(it->second.filter); + _peers.erase(it); + return true; } bool state_peer_registry::set_subscriptions(const std::string &node_id, @@ -75,13 +81,58 @@ bool state_peer_registry::has_peer(const std::string &node_id) const { return _peers.find(node_id) != _peers.end(); } -bool state_peer_registry::should_deliver(const std::string &node_id, - const std::string &path) const { +bool state_peer_registry::set_filter(const std::string &node_id, + std::shared_ptr f) { + // Declared before the lock, so the filter this replaces is released after _mu is: when the + // registry holds its last reference, its destructor (caller code) would otherwise run under the + // lock every sender takes in should_deliver. + std::shared_ptr displaced; + std::lock_guard lk(_mu); + auto it = _peers.find(node_id); + if (it == _peers.end()) + return false; + displaced = std::exchange(it->second.filter, std::move(f)); + return true; +} + +state_peer_registry::delivery_view state_peer_registry::view_of(const std::string &node_id) const { + delivery_view v; std::lock_guard lk(_mu); auto it = _peers.find(node_id); if (it == _peers.end()) - return true; // unknown peer => back-compat default - return any_prefix_matches(it->second.subscriptions, path); + return v; + v.known = true; + v.subscriptions = it->second.subscriptions; + v.filter = it->second.filter; + return v; +} + +bool state_peer_registry::delivery_view::deliver(const std::string &path) const noexcept { + if (!known) + return true; + if (!any_prefix_matches(subscriptions, path)) + return false; + return !filter || filter->deliver(path); +} + +bool state_peer_registry::delivery_view::filtered() const noexcept { + return known && (!subscriptions.empty() || filter); +} + +bool state_peer_registry::should_deliver(const std::string &node_id, + const std::string &path) const { + std::shared_ptr f; + { + std::lock_guard lk(_mu); + auto it = _peers.find(node_id); + if (it == _peers.end()) + return true; // unknown peer => back-compat default + if (!any_prefix_matches(it->second.subscriptions, path)) + return false; + f = it->second.filter; + } + // The filter is immutable; evaluate it outside the lock. + return !f || f->deliver(path); } bool state_peer_registry::note_seen(const std::string &node_id, std::uint64_t now_ns) { @@ -129,8 +180,9 @@ std::vector state_peer_registry::snapshot() const { } void state_peer_registry::clear() { + std::unordered_map dropped; // filters released after _mu (see set_filter) std::lock_guard lk(_mu); - _peers.clear(); + _peers.swap(dropped); } } // namespace cvc diff --git a/src/cvc/state/state_transport_grpc.cpp b/src/cvc/state/state_transport_grpc.cpp index 28f814847..24b8339c8 100644 --- a/src/cvc/state/state_transport_grpc.cpp +++ b/src/cvc/state/state_transport_grpc.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -214,6 +215,68 @@ cvc::media_frame decode_media(const pb::MediaFrame &in) { return m; } +// Encode `entries` into `rsp`, skipping each one `view` (when given) would not deliver. The single +// SnapshotEntry encoding behind the admit snapshot, the request responder and the targeted push. +// Returns the number of entries encoded. +std::size_t fill_snapshot_response(pb::SnapshotResponse *rsp, + const std::vector &entries, + const state_peer_registry::delivery_view *view) { + std::size_t n = 0; + for (const auto &e : entries) { + if (view && !view->deliver(e.path)) + continue; + auto *pe = rsp->add_entries(); + pe->set_path(e.path); + pe->set_string_value(e.string_value); + pe->set_comment(e.comment); + pe->set_hidden(e.hidden); + pe->set_read_only(e.read_only); + for (const auto &p : e.properties) + (*pe->mutable_properties())[p.first] = p.second; + pe->set_type_name(e.type_name); + pe->set_origin_node_id(e.origin_node_id); + pe->set_sequence(e.sequence); + pe->set_data_type_name(e.data_type_name); + if (e.data_payload.kind != state_payload_kind::none) + set_pb_payload(pe->mutable_data_payload(), e.data_payload); + ++n; + } + return n; +} + +// The inverse of fill_snapshot_response, for a received SnapshotResponse. +std::vector read_snapshot_entries(const pb::SnapshotResponse &sr) { + std::vector entries; + entries.reserve(sr.entries_size()); + for (const auto &e : sr.entries()) { + state_transport::snapshot_entry se; + se.path = e.path(); + se.string_value = e.string_value(); + se.comment = e.comment(); + se.hidden = e.hidden(); + se.read_only = e.read_only(); + for (const auto &p : e.properties()) + se.properties[p.first] = p.second; + se.type_name = e.type_name(); + se.origin_node_id = e.origin_node_id(); + se.sequence = e.sequence(); + se.data_type_name = e.data_type_name(); + if (e.has_data_payload()) + read_pb_payload(e.data_payload(), se.data_payload); + entries.push_back(std::move(se)); + } + return entries; +} + +// The registry's verdict on sending `path` to a connection whose peer is `pid`; nullptr (a peer +// that has not identified yet) gets everything. A refusal is counted against the peer. +bool registry_allows(state_peer_registry &peers, const std::string *pid, const std::string &path) { + if (pid == nullptr || peers.should_deliver(*pid, path)) + return true; + peers.note_delivery_filtered(*pid); + return false; +} + } // namespace // Connection abstracts a single bidirectional stream. Server-side @@ -224,14 +287,36 @@ cvc::media_frame decode_media(const pb::MediaFrame &in) { // // write_locked() exists for admit_connection(), which has to keep // write_mu held across several frames; write_raw is the underlying -// stream write and must only be called with write_mu held. +// stream write and must only be called with write_mu held. It is a +// timed mutex so a media frame can give up on a receiver whose stream +// stays busy (the stall drop, state_transport_grpc::write_media). class state_transport_grpc::connection { public: - std::mutex write_mu; + std::timed_mutex write_mu; std::function write_raw; std::atomic alive{true}; + // The peer's identity, from the first Hello that names one. Written once, on this connection's + // reader thread, before `identified` is released; a later Hello is ignored, so neither string + // changes after that. Any other thread reads them only through peer_id(). std::string remote_node_id; std::string remote_cluster_id; + std::atomic identified{false}; + + // The peer's node id once its Hello has arrived; nullptr before (an unknown peer, which the + // registry delivers everything to). + const std::string *peer_id() const noexcept { + return identified.load(std::memory_order_acquire) ? &remote_node_id : nullptr; + } + + // Reader thread only. A Hello without a node id identifies nothing. + void on_hello(const pb::Hello &h) { + if (identified.load(std::memory_order_relaxed) || h.node_id().empty()) + return; + remote_node_id = h.node_id(); + remote_cluster_id = h.cluster_id(); + identified.store(true, std::memory_order_release); + } + // Link quality (state_link_stats): traffic both ways + the Ping/Pong round trip. `accepted` marks // the server end of the stream; opened/last-rx are steady-clock ns for the report's age / idle. state_link_stats link; @@ -264,7 +349,7 @@ class state_transport_grpc::connection { // Write only if no other write holds the stream right now (the link probe never waits). bool try_write(const pb::Frame &f) { - std::unique_lock lk(write_mu, std::try_to_lock); + std::unique_lock lk(write_mu, std::try_to_lock); return lk.owns_lock() && write_locked(f); } @@ -277,7 +362,7 @@ class state_transport_grpc::connection { } bool write(const pb::Frame &f) { - std::lock_guard lk(write_mu); + std::lock_guard lk(write_mu); return write_locked(f); } @@ -331,8 +416,7 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { if (_owner->on_link_frame(*conn, in)) continue; if (in.has_hello()) { - conn->remote_node_id = in.hello().node_id(); - conn->remote_cluster_id = in.hello().cluster_id(); + conn->on_hello(in.hello()); } else if (in.has_mutation()) { _owner->on_inbound_mutation(decode_mutation(in.mutation()), conn.get()); _owner->increment_recv_mutations(); @@ -355,26 +439,8 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { in.snapshot_request().request_id()); } else if (in.has_snapshot_response()) { const auto &sr = in.snapshot_response(); - std::vector entries; - entries.reserve(sr.entries_size()); - for (const auto &e : sr.entries()) { - state_transport::snapshot_entry se; - se.path = e.path(); - se.string_value = e.string_value(); - se.comment = e.comment(); - se.hidden = e.hidden(); - se.read_only = e.read_only(); - for (const auto &p : e.properties()) - se.properties[p.first] = p.second; - se.type_name = e.type_name(); - se.origin_node_id = e.origin_node_id(); - se.sequence = e.sequence(); - se.data_type_name = e.data_type_name(); - if (e.has_data_payload()) - read_pb_payload(e.data_payload(), se.data_payload); - entries.push_back(std::move(se)); - } - _owner->on_inbound_snapshot_response(sr.request_id(), entries, sr.final()); + _owner->on_inbound_snapshot_response(sr.request_id(), read_snapshot_entries(sr), + sr.final()); } else if (in.has_heartbeat()) { _owner->on_inbound_heartbeat(in.heartbeat().node_id(), in.heartbeat().cluster_id()); } @@ -385,8 +451,14 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { std::lock_guard lk(conn->cancel_mu); conn->cancel = nullptr; // ctx dies with this handler } + // So does STREAM, which other threads write to (publish, the relay's forwards, the link + // probe): fail any Write still blocked on it, then wait out the one in progress. Every write + // runs under write_mu and checks `alive` there, so none starts after this. + ctx->TryCancel(); + { std::lock_guard wl(conn->write_mu); } _owner->unregister_connection(conn.get()); - _owner->notify_connection_closed(conn->remote_node_id); + const std::string *pid = conn->peer_id(); + _owner->notify_connection_closed(pid ? *pid : std::string()); return grpc::Status::OK; } @@ -542,8 +614,7 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, if (on_link_frame(*conn, in)) continue; if (in.has_hello()) { - conn->remote_node_id = in.hello().node_id(); - conn->remote_cluster_id = in.hello().cluster_id(); + conn->on_hello(in.hello()); } else if (in.has_mutation()) { on_inbound_mutation(decode_mutation(in.mutation()), conn.get()); increment_recv_mutations(); @@ -566,26 +637,7 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, in.snapshot_request().request_id()); } else if (in.has_snapshot_response()) { const auto &sr = in.snapshot_response(); - std::vector entries; - entries.reserve(sr.entries_size()); - for (const auto &e : sr.entries()) { - snapshot_entry se; - se.path = e.path(); - se.string_value = e.string_value(); - se.comment = e.comment(); - se.hidden = e.hidden(); - se.read_only = e.read_only(); - for (const auto &p : e.properties()) - se.properties[p.first] = p.second; - se.type_name = e.type_name(); - se.origin_node_id = e.origin_node_id(); - se.sequence = e.sequence(); - se.data_type_name = e.data_type_name(); - if (e.has_data_payload()) - read_pb_payload(e.data_payload(), se.data_payload); - entries.push_back(std::move(se)); - } - on_inbound_snapshot_response(sr.request_id(), entries, sr.final()); + on_inbound_snapshot_response(sr.request_id(), read_snapshot_entries(sr), sr.final()); } else if (in.has_heartbeat()) { on_inbound_heartbeat(in.heartbeat().node_id(), in.heartbeat().cluster_id()); } @@ -703,15 +755,14 @@ state_transport::publish_stats state_transport_grpc::publish(const state_mutatio for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; - if (!c->remote_node_id.empty() && !_peers.should_deliver(c->remote_node_id, m.path)) { - _peers.note_delivery_filtered(c->remote_node_id); + const std::string *pid = c->peer_id(); + if (!registry_allows(_peers, pid, m.path)) continue; - } if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.delivered; - if (!c->remote_node_id.empty()) - _peers.note_mutation_delivered(c->remote_node_id); + if (pid) + _peers.note_mutation_delivered(*pid); } } @@ -748,7 +799,7 @@ void state_transport_grpc::flush() { for (auto &c : conns) { if (!c) continue; - std::lock_guard wlk(c->write_mu); + std::lock_guard wlk(c->write_mu); (void)wlk; } } @@ -794,15 +845,14 @@ state_transport_grpc::publish_message(const state_message &m) { for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; - if (!c->remote_node_id.empty() && !_peers.should_deliver(c->remote_node_id, m.path)) { - _peers.note_delivery_filtered(c->remote_node_id); + const std::string *pid = c->peer_id(); + if (!registry_allows(_peers, pid, m.path)) continue; - } if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.peers; - if (!c->remote_node_id.empty()) - _peers.note_message_delivered(c->remote_node_id); + if (pid) + _peers.note_message_delivered(*pid); } } return stats; @@ -828,10 +878,19 @@ void state_transport_grpc::on_inbound_mutation(const state_mutation &m, connecti if (r.applied) _delivered.fetch_add(1, std::memory_order_relaxed); } + const bool relay = _relay.load(std::memory_order_relaxed); + if (!relay && !_bridge) + return; + // The interest gate withholds the frame from the relay fan AND the bridge alike. + if (!relay_admits(m, peers)) { + _relay_withheld.fetch_add(1, std::memory_order_relaxed); + return; + } // Relay (star topology): re-send this mutation to every OTHER connection so clients that only // reach this server still converge with each other. The origin connection is skipped (no echo); // origin_node_id is preserved so duplicate suppression / vector clocks still work end-to-end. - if (_relay.load(std::memory_order_relaxed)) { + // Each target's registry entry applies, as it does to publish(). + if (relay) { pb::Frame frame; encode_mutation(m, frame.mutable_mutation()); std::vector> conns; @@ -842,12 +901,15 @@ void state_transport_grpc::on_inbound_mutation(const state_mutation &m, connecti for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (!registry_allows(_peers, c->peer_id(), m.path)) + continue; if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } } // Bridge to a sibling transport (e.g. the relay's WebSocket side) so its connections get this - // mutation — the WS<->gRPC bridge that puts browser (wasm) and native clients in one room. + // mutation — the WS<->gRPC bridge that puts browser (wasm) and native clients in one room. The + // sibling's publish() applies its own registry. if (_bridge) _bridge->publish(m); } @@ -869,8 +931,15 @@ void state_transport_grpc::on_inbound_message(const state_message &m, connection } for (auto *peer : peers) (void)peer->ingest_remote_message(m); + const bool relay = _relay.load(std::memory_order_relaxed); + if (!relay && !_bridge) + return; + if (!relay_admits(m, peers)) { + _relay_withheld.fetch_add(1, std::memory_order_relaxed); + return; + } // Relay (star topology): forward the message to every OTHER connection (see on_inbound_mutation). - if (_relay.load(std::memory_order_relaxed)) { + if (relay) { pb::Frame frame; encode_message(m, frame.mutable_message()); std::vector> conns; @@ -881,6 +950,8 @@ void state_transport_grpc::on_inbound_message(const state_message &m, connection for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (!registry_allows(_peers, c->peer_id(), m.path)) + continue; if (c->write(frame)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } @@ -890,7 +961,24 @@ void state_transport_grpc::on_inbound_message(const state_message &m, connection _bridge->publish_message(m); } -std::size_t state_transport_grpc::write_media(const media_frame &m, const void *exclude) { +bool state_transport_grpc::relay_admits(const state_mutation &m, + const std::vector &shards) const { + if (!_relay_requires_interest.load(std::memory_order_relaxed) || shards.empty()) + return true; + return std::any_of(shards.begin(), shards.end(), + [&m](const state_cluster_shard *s) { return s->admits(m); }); +} + +bool state_transport_grpc::relay_admits(const state_message &m, + const std::vector &shards) const { + if (!_relay_requires_interest.load(std::memory_order_relaxed) || shards.empty()) + return true; + return std::any_of(shards.begin(), shards.end(), + [&m](const state_cluster_shard *s) { return s->admits_message(m); }); +} + +std::size_t state_transport_grpc::write_media(const media_frame &m, const void *exclude, + bool gated) { pb::Frame frame; pb::MediaFrame *mf = frame.mutable_media(); mf->set_origin_node_id(m.origin_node_id); @@ -909,13 +997,47 @@ std::size_t state_transport_grpc::write_media(const media_frame &m, const void * std::lock_guard lk(_conns_mu); conns = _conns; } + // One copy of the gate for the whole frame: a concurrent set_media_gate swaps the slot, never + // the function this loop is calling. + std::shared_ptr gate; + if (gated) { + std::lock_guard lk(_media_gate_mu); + gate = _media_gate; + } + const std::uint64_t stall_ns = _media_stall_ns.load(std::memory_order_relaxed); // Media is unreplicated, un-bridged (it crosses protocols via the media_router), and carries no // path. `exclude` (a connection*) skips the origin peer when a relay fans; nullptr sends to all. std::size_t n = 0; for (auto &c : conns) { if (!c || c.get() == exclude || !c->alive.load() || !c->can_write()) continue; - if (c->write(frame)) { + std::unique_lock lk(c->write_mu, std::defer_lock); + if (stall_ns != 0) { + // A write that has already run past the window: drop at once. Otherwise wait for the stream + // at most the window, so this (publisher's reader) thread never queues behind a slow + // receiver for longer -- a write that starts and then blocks on flow control would hold + // every frame behind it until the 3 s watchdog. + const auto started = + static_cast(c->write_started_ns.load(std::memory_order_relaxed)); + if ((started != 0 && + media_write_stalled(started, static_cast(link_now_ns()), stall_ns)) || + !lk.try_lock_for(std::chrono::nanoseconds(stall_ns))) { + _media_stall_dropped.fetch_add(1, std::memory_order_relaxed); + continue; + } + } else { + lk.lock(); + } + // The gate once the stream is ours, so a frame the stall drop discards never uses up a + // thumbnail slot. + if (gate) { + const std::string *pid = c->peer_id(); + if (pid && !(*gate)(m, *pid)) { + _media_gated.fetch_add(1, std::memory_order_relaxed); + continue; + } + } + if (c->write_locked(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++n; } @@ -924,11 +1046,34 @@ std::size_t state_transport_grpc::write_media(const media_frame &m, const void * } void state_transport_grpc::publish_media_frame(const media_frame &m) { - _media_sent.fetch_add(write_media(m, nullptr), std::memory_order_relaxed); + _media_sent.fetch_add(write_media(m, nullptr, /*gated=*/false), std::memory_order_relaxed); } void state_transport_grpc::fan_media(const media_frame &f, const void *exclude) { - _media_fwd.fetch_add(write_media(f, exclude), std::memory_order_relaxed); + _media_fwd.fetch_add(write_media(f, exclude, /*gated=*/true), std::memory_order_relaxed); +} + +void state_transport_grpc::set_media_gate(media_gate_fn gate) { + std::shared_ptr next; + if (gate) + next = std::make_shared(std::move(gate)); + { + std::lock_guard lk(_media_gate_mu); + _media_gate.swap(next); + } + // `next` now holds the previous gate; it is released here, outside the lock (a reader thread + // mid-frame keeps its own copy alive). +} + +void state_transport_grpc::set_media_stall_drop(std::chrono::milliseconds ms) noexcept { + const auto ns = std::chrono::duration_cast(ms).count(); + _media_stall_ns.store(ns > 0 ? static_cast(ns) : 0, std::memory_order_relaxed); +} + +bool state_transport_grpc::media_write_stalled(std::uint64_t write_started_ns, std::uint64_t now_ns, + std::uint64_t stall_ns) noexcept { + return stall_ns != 0 && write_started_ns != 0 && now_ns > write_started_ns && + now_ns - write_started_ns > stall_ns; } void state_transport_grpc::set_media_sink(media_sink_fn sink) { _media_sink = std::move(sink); } @@ -970,7 +1115,7 @@ void state_transport_grpc::admit_connection(const std::shared_ptr &c // the wire. If a live mutation went out first, the older values // replayed behind it would land on the peer afterwards and clobber // the newer one (the seen-set only suppresses exact repeats). - std::lock_guard wlk(conn->write_mu); + std::lock_guard wlk(conn->write_mu); register_connection(conn); send_hello_locked(*conn); if (backfill && !shards.empty()) @@ -1020,6 +1165,9 @@ void state_transport_grpc::send_snapshot_locked(connection &c, // The receiver applies each entry via on_inbound_snapshot_response's no-waiter path // (ingest_snapshot_ entry, a bulk initial load). Mirrors // state_transport_ws::send_snapshot_locked. + // + // Unfiltered by design: this goes out before the peer's Hello, so there is no identity to look up + // in the registry; the receiver keeps only what its own interest takes. for (auto *s : shards) { if (s == nullptr) continue; @@ -1030,22 +1178,7 @@ void state_transport_grpc::send_snapshot_locked(connection &c, auto *rsp = f.mutable_snapshot_response(); rsp->set_request_id(0); rsp->set_final(true); - for (const auto &e : snap) { - auto *pe = rsp->add_entries(); - pe->set_path(e.path); - pe->set_string_value(e.string_value); - pe->set_comment(e.comment); - pe->set_hidden(e.hidden); - pe->set_read_only(e.read_only); - for (const auto &p : e.properties) - (*pe->mutable_properties())[p.first] = p.second; - pe->set_type_name(e.type_name); - pe->set_origin_node_id(e.origin_node_id); - pe->set_sequence(e.sequence); - pe->set_data_type_name(e.data_type_name); - if (e.data_payload.kind != state_payload_kind::none) - set_pb_payload(pe->mutable_data_payload(), e.data_payload); - } + fill_snapshot_response(rsp, snap, /*view=*/nullptr); if (!c.write_locked(f)) return; // stream died mid-snapshot; the reader will reap it _sent_frames.fetch_add(1, std::memory_order_relaxed); @@ -1078,7 +1211,8 @@ std::vector state_transport_grpc::link_reports() const { continue; state_link_report r; r.transport = "grpc"; - r.remote_node_id = c->remote_node_id; + if (const std::string *pid = c->peer_id()) + r.remote_node_id = *pid; r.accepted = c->accepted; r.age_s = static_cast(now - c->opened_ns) * 1e-9; r.idle_s = static_cast(now - c->last_rx_ns.load()) * 1e-9; @@ -1138,8 +1272,9 @@ void state_transport_grpc::ping_loop() { for (auto &c : conns) { const std::int64_t started = c ? c->write_started_ns.load(std::memory_order_relaxed) : 0; if (started != 0 && now - started > kWriteStallNs && c->alive.load()) { + const std::string *pid = c->peer_id(); std::fprintf(stderr, "[grpc] write to '%s' blocked > 3 s (peer not reading): closing\n", - c->remote_node_id.c_str()); + pid ? pid->c_str() : ""); c->abort_io(); } } @@ -1295,44 +1430,23 @@ void state_transport_grpc::on_inbound_snapshot_request(connection *conn, std::vector all; for (auto *peer : peers) { auto snap = peer->snapshot(path_prefix); - for (auto &se : snap) { - snapshot_entry te; - te.path = std::move(se.path); - te.string_value = std::move(se.string_value); - te.comment = std::move(se.comment); - te.hidden = se.hidden; - te.read_only = se.read_only; - te.properties = std::move(se.properties); - te.type_name = std::move(se.type_name); - te.origin_node_id = std::move(se.origin_node_id); - te.sequence = se.sequence; - te.data_type_name = std::move(se.data_type_name); - te.data_payload = std::move(se.data_payload); - all.push_back(std::move(te)); - } + all.insert(all.end(), std::make_move_iterator(snap.begin()), + std::make_move_iterator(snap.end())); } + // The requester sees what its registry entry lets its live traffic see (a delivery policy, not + // access control). A requester that has not identified gets everything. + state_peer_registry::delivery_view view; + if (conn != nullptr) + if (const std::string *pid = conn->peer_id()) + view = _peers.view_of(*pid); + // Build response frame. pb::Frame f; auto *rsp = f.mutable_snapshot_response(); rsp->set_request_id(request_id); rsp->set_final(true); - for (const auto &e : all) { - auto *pe = rsp->add_entries(); - pe->set_path(e.path); - pe->set_string_value(e.string_value); - pe->set_comment(e.comment); - pe->set_hidden(e.hidden); - pe->set_read_only(e.read_only); - for (const auto &p : e.properties) - (*pe->mutable_properties())[p.first] = p.second; - pe->set_type_name(e.type_name); - pe->set_origin_node_id(e.origin_node_id); - pe->set_sequence(e.sequence); - pe->set_data_type_name(e.data_type_name); - if (e.data_payload.kind != state_payload_kind::none) - set_pb_payload(pe->mutable_data_payload(), e.data_payload); - } + fill_snapshot_response(rsp, all, view.filtered() ? &view : nullptr); if (conn && conn->alive.load() && conn->can_write()) { conn->write(f); @@ -1362,6 +1476,8 @@ void state_transport_grpc::on_inbound_snapshot_response(std::uint64_t request_id // the WS relay path, so a native client joining a populated room sees the peers already present. // Applied defensively: a relay snapshot carries its whole shard, including per-client read-only // ariadne ui.docs.* state the joiner already owns, which the local tree is EXPECTED to reject. + // A shard that enforces interest takes from a snapshot only what it would take live (a no-op + // for a shard that does not enforce). if (!matched && !entries.empty()) { std::vector shards; { @@ -1370,7 +1486,7 @@ void state_transport_grpc::on_inbound_snapshot_response(std::uint64_t request_id } for (const auto &e : entries) for (auto *s : shards) { - if (s == nullptr) + if (s == nullptr || !s->path_is_of_interest(e.path)) continue; try { s->ingest_snapshot_entry(e); @@ -1444,6 +1560,43 @@ bool state_transport_grpc::request_snapshot(const std::string &cluster_id, return result; } +std::size_t state_transport_grpc::push_snapshot_to(const std::string &node_id, + const std::vector &entries) { + if (entries.empty()) + return 0; + std::vector> targets; + { + std::lock_guard lk(_conns_mu); + for (const auto &c : _conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + const std::string *pid = c->peer_id(); + if (pid && *pid == node_id) + targets.push_back(c); + } + } + if (targets.empty()) + return 0; + + // The same frame shape as the admit snapshot, so the client applies it through the same + // unsolicited-snapshot path. Unfiltered: the caller chose `entries` for this peer. + pb::Frame f; + auto *rsp = f.mutable_snapshot_response(); + rsp->set_request_id(0); + rsp->set_final(true); + fill_snapshot_response(rsp, entries, /*view=*/nullptr); + + // A synchronous write per connection: a publish() on this thread afterwards lands behind it. + std::size_t n = 0; + for (auto &c : targets) { + if (c->write(f)) { + _sent_frames.fetch_add(1, std::memory_order_relaxed); + ++n; + } + } + return n; +} + void state_transport_grpc::heartbeat_loop() { while (_running.load()) { auto sleep_end = std::chrono::steady_clock::now() + _heartbeat_interval; diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 1bb191225..4dfb5e182 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #ifndef __EMSCRIPTEN__ @@ -211,6 +212,98 @@ state_message decode_message(const pb::Message &in) { return m; } +// Append one entry to `rsp`. The admit snapshot, the periodic broadcast and the targeted push all +// encode through here, so their entry bytes agree. +void add_snapshot_entry(pb::SnapshotResponse *rsp, const state_transport::snapshot_entry &e) { + auto *pe = rsp->add_entries(); + pe->set_path(e.path); + pe->set_string_value(e.string_value); + pe->set_comment(e.comment); + pe->set_hidden(e.hidden); + pe->set_read_only(e.read_only); + for (const auto &p : e.properties) + (*pe->mutable_properties())[p.first] = p.second; + pe->set_type_name(e.type_name); + pe->set_origin_node_id(e.origin_node_id); + pe->set_sequence(e.sequence); + pe->set_data_type_name(e.data_type_name); + if (e.data_payload.kind != state_payload_kind::none) + set_pb_payload(pe->mutable_data_payload(), e.data_payload); +} + +// Encode all of `snap` into `rsp` as one unsolicited, final snapshot (request_id 0). +void fill_snapshot_response(pb::SnapshotResponse *rsp, + const std::vector &snap) { + rsp->set_request_id(0); + rsp->set_final(true); + for (const auto &e : snap) + add_snapshot_entry(rsp, e); +} + +// FNV-1a 64 over length-prefixed fields: everything an entry carries except its path (the key it +// is remembered under), so two digests match only when a receiver would end up with the same node. +class entry_digest { +public: + explicit entry_digest(const state_transport::snapshot_entry &e) { + str(e.string_value); + str(e.comment); + u64((e.hidden ? 1u : 0u) | (e.read_only ? 2u : 0u)); + u64(e.properties.size()); + for (const auto &p : e.properties) { + str(p.first); + str(p.second); + } + str(e.type_name); + str(e.origin_node_id); + u64(e.sequence); + str(e.data_type_name); + const state_payload &pl = e.data_payload; + u64(static_cast(pl.kind)); + if (pl.kind == state_payload_kind::inline_bytes) { + u64(pl.inline_bytes.size()); + bytes(pl.inline_bytes.data(), pl.inline_bytes.size()); + } else if (pl.kind == state_payload_kind::blob) { + str(pl.blob.digest); + u64(pl.blob.size_bytes); + str(pl.blob.codec); + } + } + std::uint64_t value() const noexcept { return _h; } + +private: + void bytes(const void *data, std::size_t n) { + const auto *b = static_cast(data); + for (std::size_t i = 0; i < n; ++i) { + _h ^= b[i]; + _h *= 1099511628211ull; + } + } + void u64(std::uint64_t v) { + for (int i = 0; i < 8; ++i) { + const unsigned char b = static_cast(v >> (8 * i)); + bytes(&b, 1); + } + } + void str(const std::string &s) { + u64(s.size()); + bytes(s.data(), s.size()); + } + std::uint64_t _h = 14695981039346656037ull; +}; + +// What one connection holds of a tree path, as far as the relay knows: the digest of the content +// the last snapshot frame carried for it, and the broadcast round that last delivered the path. +// File-local, so the per-connection map stays out of a DLL's export table. +struct held_entry { + std::uint64_t digest = 0; + std::uint64_t round = 0; +}; + +// A mutation that can change nodes below its path, not just the node at it. +bool touches_subtree(state_mutation_op op) { + return op == state_mutation_op::reset_node || op == state_mutation_op::delete_subtree; +} + } // namespace #ifndef __EMSCRIPTEN__ @@ -219,6 +312,25 @@ namespace beast = boost::beast; namespace websocket = boost::beast::websocket; namespace net = boost::asio; using tcp = boost::asio::ip::tcp; + +// How long one write may block before its connection is closed (see +// connection::write_started_ns). +constexpr std::int64_t kWriteStallNs = 3'000'000'000; + +// Make a write blocked on `s` fail after kWriteStallNs, so it releases the connection's write lock. +// Elsewhere the probe thread's watchdog does that with shutdown(), which wakes a send() blocked in +// another thread; Winsock's shutdown() does not, so on Windows the socket times the send out itself +// (SO_SNDTIMEO), and the write fails with WSAETIMEDOUT. A timed-out socket is unusable afterwards, +// which is fine: the failed write marks the connection dead. +void bound_blocking_writes(tcp::socket &s) { +#ifdef _WIN32 + const DWORD ms = static_cast(kWriteStallNs / 1'000'000); + ::setsockopt(s.native_handle(), SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&ms), + sizeof(ms)); +#else + (void)s; +#endif +} } // namespace #endif @@ -229,8 +341,19 @@ class state_transport_ws::connection { public: std::mutex write_mu; std::atomic alive{true}; + // The peer's identity from its FIRST Hello. The connection's reader (the one thread that parses + // its inbound frames) writes both strings once and then sets `identified` (release); a later + // Hello is ignored, so they never change after that. Every other thread reads through peer_id(). std::string remote_node_id; std::string remote_cluster_id; + std::atomic identified{false}; + // The peer's node id, or nullptr before its Hello (or when that Hello named no node). nullptr + // means "unknown", which delivers everything, as an unregistered peer does. + const std::string *peer_id() const noexcept { + if (!identified.load(std::memory_order_acquire) || remote_node_id.empty()) + return nullptr; + return &remote_node_id; + } // Steady-clock ns of the last inbound frame. A live conference client streams media continuously, // so a peer that navigated away (its socket not cleanly closed) stops updating this — the relay's // reaper uses it to drop the ghost. 0 until the first frame; set to "now" when a connection opens @@ -245,8 +368,20 @@ class state_transport_ws::connection { // (a frozen tab, a dead network) makes a write block once its TCP buffer is full -- while holding // write_mu, so every other writer to it (the relay's main loop included) would wait forever. The // probe thread's watchdog closes a connection whose write has been blocked too long, which fails - // the write and frees the lock (state_transport_ws::ping_loop). + // the write and frees the lock (state_transport_ws::ping_loop); on Windows the socket's own send + // timeout fails it (bound_blocking_writes). std::atomic write_started_ns{0}; + // What this peer holds, path by path, for broadcast_snapshot's diff (see held_entry): a snapshot + // frame records what it carried; any other frame touching the path, written to the peer or + // received from it, forgets it. Guarded by held_mu, which is never held across a socket write (a + // reader thread forgets its peer's own writes, and must not wait behind a blocked write). A + // WRITER also holds write_mu from its record/forget through its write, so the record always + // describes the frames in the order the peer receives them. Lock order: write_mu, then held_mu. + std::mutex held_mu; + std::unordered_map held; + // Set when the peer sends a SnapshotRequest: its next broadcast carries everything it is + // delivered, not just the diff. + std::atomic full_refresh{false}; #ifndef __EMSCRIPTEN__ std::unique_ptr> ws; @@ -278,7 +413,8 @@ class state_transport_ws::connection { return true; } - // Unblock any read or write in progress and mark the connection dead (the write watchdog). + // Unblock any read or write in progress and mark the connection dead (the write watchdog). On + // Windows shutdown() does not wake a blocked send(); bound_blocking_writes times that one out. void abort_io() { alive.store(false); if (ws) { @@ -321,6 +457,38 @@ class state_transport_ws::connection { return lk.owns_lock() && write_locked(f); } + // The peer's copy of `m.path` (and, for a subtree op, of everything below it) is no longer + // known, so the next broadcast sends it whatever the tree holds there. + void forget(const state_mutation &m) { + std::lock_guard lk(held_mu); + if (!touches_subtree(m.op)) { + held.erase(m.path); + return; + } + for (auto it = held.begin(); it != held.end();) + it = state_peer_registry::prefix_matches(m.path, it->first) ? held.erase(it) : std::next(it); + } + + // Write a frame carrying the live mutation `m`, forgetting what the peer held at its path. + bool write_mutation(const pb::Frame &f, const state_mutation &m) { + std::lock_guard lk(write_mu); + forget(m); + return write_locked(f); + } + + // Write a snapshot frame carrying `entries` outside a broadcast (a targeted push), forgetting + // each entry's path. + bool write_entries(const pb::Frame &f, + const std::vector &entries) { + std::lock_guard lk(write_mu); + { + std::lock_guard hk(held_mu); + for (const auto &e : entries) + held.erase(e.path); + } + return write_locked(f); + } + // Bytes still unsent in this connection's OS send buffer (Linux only; 0 elsewhere). A large value // means the peer is not draining fast enough and the next write would BLOCK. The fan paths check // this before writing and skip a backed-up connection — dropping a loss-tolerant media frame, or @@ -452,6 +620,7 @@ void state_transport_ws::accept_loop() { conn->accepted = true; conn->ws = std::make_unique>(std::move(sock)); conn->ws->read_message_max(kMaxWsMessageBytes); + bound_blocking_writes(conn->ws->next_layer()); conn->ws->accept(ec); // server-side WebSocket handshake if (ec) continue; @@ -486,6 +655,7 @@ bool state_transport_ws::connect_to_peer(const std::string &url, return false; conn->ws = std::make_unique>(std::move(sock)); conn->ws->read_message_max(kMaxWsMessageBytes); + bound_blocking_writes(conn->ws->next_layer()); conn->ws->handshake(host, target.empty() ? std::string("/") : target, ec); if (ec) return false; @@ -520,8 +690,8 @@ void state_transport_ws::read_loop(std::shared_ptr conn) { // A peer closed this connection (not a relay shutdown): notify the owner so a relay can retract // the departed participant from the room. _running is cleared by stop(), so this fires only on a // real peer drop, and only when the peer identified itself (sent a Hello). - if (_running.load() && _on_conn_closed && !conn->remote_node_id.empty()) - _on_conn_closed(conn->remote_node_id); + if (const std::string *pid = conn->peer_id(); pid && _running.load() && _on_conn_closed) + _on_conn_closed(*pid); conn->reader_done.store(true, std::memory_order_release); // reap_idle may now join + erase this } @@ -714,8 +884,13 @@ void state_transport_ws::deliver_inbound(connection *c, const void *data, std::s return; } if (in.has_hello()) { - c->remote_node_id = in.hello().node_id(); - c->remote_cluster_id = in.hello().cluster_id(); + // Write-once identity: only the first Hello counts (see connection::identified). This thread + // is the only writer, so the relaxed check cannot race another Hello. + if (!c->identified.load(std::memory_order_relaxed)) { + c->remote_node_id = in.hello().node_id(); + c->remote_cluster_id = in.hello().cluster_id(); + c->identified.store(true, std::memory_order_release); + } } else if (in.has_mutation()) { on_inbound_mutation(decode_mutation(in.mutation()), c); _recv_mutations.fetch_add(1, std::memory_order_relaxed); @@ -724,6 +899,10 @@ void state_transport_ws::deliver_inbound(connection *c, const void *data, std::s } else if (in.has_message()) { on_inbound_message(decode_message(in.message()), c); _recv_messages.fetch_add(1, std::memory_order_relaxed); + } else if (in.has_snapshot_request()) { + // The peer asks for a full refresh (request_full_refresh): its next broadcast carries the whole + // tree it is delivered. Answered there, on the relay's own loop, not on this reader thread. + c->full_refresh.store(true, std::memory_order_relaxed); } else if (in.has_snapshot_response()) { // A relay pushed its full current state (request_id 0) so this late-joining client sees the // peers already in the room. Apply each entry as an initial-sync bootstrap. The conference uses @@ -753,6 +932,11 @@ void state_transport_ws::deliver_inbound(connection *c, const void *data, std::s for (auto *s : shards) { if (s == nullptr) continue; + // Honour the shard's enforced interest set as live ingest does: a relay snapshot carries + // its whole tree, which would otherwise plant nodes this client never asked for. A shard + // that does not enforce interest takes every entry, as before. + if (!s->path_is_of_interest(se.path)) + continue; // One bootstrap entry the local tree rejects must not take down the whole client: apply // each defensively so the rest of the roster still loads. A relay snapshot carries its // whole shard, which includes per-client read-only UI state the joiner already owns (e.g. @@ -868,8 +1052,9 @@ state_transport::publish_stats state_transport_ws::publish(const state_mutation for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; - if (!c->remote_node_id.empty() && !_peers.should_deliver(c->remote_node_id, m.path)) { - _peers.note_delivery_filtered(c->remote_node_id); + const std::string *pid = c->peer_id(); + if (pid && !_peers.should_deliver(*pid, m.path)) { + _peers.note_delivery_filtered(*pid); continue; } // Don't block the caller (the arisrv main loop drains local writes here) on a backed-up peer — @@ -877,11 +1062,11 @@ state_transport::publish_stats state_transport_ws::publish(const state_mutation // re-delivers. if (c->send_backlog() > 2 * 1024 * 1024) continue; - if (c->write(frame)) { + if (c->write_mutation(frame, m)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.delivered; - if (!c->remote_node_id.empty()) - _peers.note_mutation_delivered(c->remote_node_id); + if (pid) + _peers.note_mutation_delivered(*pid); } } @@ -930,21 +1115,30 @@ state_transport::publish_message_stats state_transport_ws::publish_message(const for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; - if (!c->remote_node_id.empty() && !_peers.should_deliver(c->remote_node_id, m.path)) { - _peers.note_delivery_filtered(c->remote_node_id); + const std::string *pid = c->peer_id(); + if (pid && !_peers.should_deliver(*pid, m.path)) { + _peers.note_delivery_filtered(*pid); continue; } if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++stats.peers; - if (!c->remote_node_id.empty()) - _peers.note_message_delivered(c->remote_node_id); + if (pid) + _peers.note_message_delivered(*pid); } } return stats; } -std::size_t state_transport_ws::write_media(const media_frame &m, const void *exclude) { +std::size_t state_transport_ws::write_media(const media_frame &m, const void *exclude, bool gated) { + // One copy of the gate per frame: a concurrent set_media_gate swaps the slot, never the object + // this fan is calling, and no lock is held across the socket writes below. + std::shared_ptr gate; + if (gated) { + std::lock_guard lk(_media_gate_mu); + gate = _media_gate; + } + pb::Frame frame; pb::MediaFrame *mf = frame.mutable_media(); mf->set_origin_node_id(m.origin_node_id); @@ -965,18 +1159,31 @@ std::size_t state_transport_ws::write_media(const media_frame &m, const void *ex } // Media is unreplicated, un-bridged, and not subject to the peer path-filter (it carries no // path). `exclude` (a connection*) skips the origin peer when a relay fans; nullptr sends to all. + const std::size_t backlog_drop = _media_backlog_drop.load(std::memory_order_relaxed); std::size_t n = 0; for (auto &c : conns) { if (!c || c.get() == exclude || !c->alive.load() || !c->can_write()) continue; // Media is loss-tolerant: if this peer's send buffer is backed up (it can't keep pace with the // video), DROP the frame rather than let the synchronous write block the relay. A LOW threshold - // is deliberate — it keeps each peer's buffer small so the small, must-deliver STATE writes - // (roster join/leave + the periodic re-broadcast below) are never crowded out or deferred. - // Dropping video to a slow peer is invisible; a stalled roster makes clients unable to SEE each - // other at all. - if (c->send_backlog() > 32 * 1024) + // (32 KB unless set_media_backlog_drop says otherwise) is deliberate — it keeps each peer's + // buffer small so the small, must-deliver STATE writes (roster join/leave + the periodic + // re-broadcast below) are never crowded out or deferred. Dropping video to a slow peer is + // invisible; a stalled roster makes clients unable to SEE each other at all. + if (backlog_drop != 0 && c->send_backlog() > backlog_drop) { + _media_backlog_dropped.fetch_add(1, std::memory_order_relaxed); continue; + } + // Per-receiver egress gate, asked last so a frame the backlog drop skips never counts as sent + // in the gate's own bookkeeping (e.g. a thumbnail cadence). An unidentified receiver is + // ungated: the gate has no id to decide on, and "unknown" delivers everywhere else too. + if (gate) { + const std::string *pid = c->peer_id(); + if (pid && !(*gate)(m, *pid)) { + _media_gated.fetch_add(1, std::memory_order_relaxed); + continue; + } + } if (c->write(frame)) { _sent_frames.fetch_add(1, std::memory_order_relaxed); ++n; @@ -986,18 +1193,36 @@ std::size_t state_transport_ws::write_media(const media_frame &m, const void *ex } // A browser client has one connection (the relay), so publish_media_frame pushes its camera/mic UP; -// a relay publishing locally (uncommon) would send to all its connections. +// a relay publishing locally (uncommon) would send to all its connections. A node's own uplink is +// never gated. void state_transport_ws::publish_media_frame(const ws_media_frame &m) { - _media_sent.fetch_add(write_media(m, nullptr), std::memory_order_relaxed); + _media_sent.fetch_add(write_media(m, nullptr, /*gated=*/false), std::memory_order_relaxed); } -// Router egress: fan one peer's frame to every OTHER connection (the origin is `exclude`). +// Router egress: fan one peer's frame to every OTHER connection (the origin is `exclude`), each +// receiver subject to the media gate. void state_transport_ws::fan_media(const media_frame &f, const void *exclude) { - _media_fwd.fetch_add(write_media(f, exclude), std::memory_order_relaxed); + _media_fwd.fetch_add(write_media(f, exclude, /*gated=*/true), std::memory_order_relaxed); } void state_transport_ws::set_media_sink(media_sink_fn sink) { _media_sink = std::move(sink); } +void state_transport_ws::set_media_gate(media_gate_fn gate) { + std::shared_ptr next; + if (gate) + next = std::make_shared(std::move(gate)); + { + std::lock_guard lk(_media_gate_mu); + _media_gate.swap(next); + } + // `next` now holds the previous gate; it is released here, outside the lock (a fan still using + // its own copy keeps it alive until that fan finishes). +} + +void state_transport_ws::set_media_backlog_drop(std::size_t bytes) noexcept { + _media_backlog_drop.store(bytes, std::memory_order_relaxed); +} + void state_transport_ws::set_on_media(media_frame_fn cb) { if (!cb) { _media_sink = {}; @@ -1065,10 +1290,28 @@ void state_transport_ws::on_inbound_mutation(const state_mutation &m, connection if (r.applied) _delivered.fetch_add(1, std::memory_order_relaxed); } + // The origin wrote this path itself, so what it holds there is no longer what it was last sent: + // the next broadcast re-sends the tree's value, which also corrects a write this relay rejected. + if (origin != nullptr) + origin->forget(m); + const bool relay = _relay.load(std::memory_order_relaxed); + if (!relay && _bridge == nullptr) + return; // a leaf: applied locally, never re-sent + // Interest gate (opt-in): re-fan only what some local same-cluster shard admits -- its interest + // set covers the path, or it is a delegation op, which ingest always takes. When every client + // enforces the relay's interest, a withheld frame is one no client would apply. Asked after + // ingest_remote has returned, so admits() takes only the shard's leaf lock. With no matching + // shard (a pure forwarding relay) everything is forwarded. + if (_relay_requires_interest.load(std::memory_order_relaxed) && !peers.empty() && + std::none_of(peers.begin(), peers.end(), + [&m](const state_cluster_shard *s) { return s->admits(m); })) { + _relay_withheld.fetch_add(1, std::memory_order_relaxed); + return; // neither the relay loop nor the bridge + } // Relay (star topology): re-send to every OTHER connection so clients that only reach this server // converge with each other. origin is skipped (no echo); origin_node_id is preserved so the - // receiver's dedup makes any round-trip a no-op. - if (_relay.load(std::memory_order_relaxed)) { + // receiver's dedup makes any round-trip a no-op. Each target's registry entry is honoured. + if (relay) { pb::Frame frame; encode_mutation(m, frame.mutable_mutation()); std::vector> conns; @@ -1079,10 +1322,14 @@ void state_transport_ws::on_inbound_mutation(const state_mutation &m, connection for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (const std::string *pid = c->peer_id(); pid && !_peers.should_deliver(*pid, m.path)) { + _peers.note_delivery_filtered(*pid); + continue; + } if (c->send_backlog() > 2 * 1024 * 1024) continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops // it - if (c->write(frame)) + if (c->write_mutation(frame, m)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } } @@ -1108,7 +1355,17 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * } for (auto *peer : peers) (void)peer->ingest_remote_message(m); - if (_relay.load(std::memory_order_relaxed)) { + const bool relay = _relay.load(std::memory_order_relaxed); + if (!relay && _bridge == nullptr) + return; + // Interest gate (opt-in), as in on_inbound_mutation. + if (_relay_requires_interest.load(std::memory_order_relaxed) && !peers.empty() && + std::none_of(peers.begin(), peers.end(), + [&m](const state_cluster_shard *s) { return s->admits_message(m); })) { + _relay_withheld.fetch_add(1, std::memory_order_relaxed); + return; + } + if (relay) { pb::Frame frame; encode_message(m, frame.mutable_message()); std::vector> conns; @@ -1119,6 +1376,10 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * for (auto &c : conns) { if (!c || c.get() == origin || !c->alive.load() || !c->can_write()) continue; + if (const std::string *pid = c->peer_id(); pid && !_peers.should_deliver(*pid, m.path)) { + _peers.note_delivery_filtered(*pid); + continue; + } if (c->send_backlog() > 2 * 1024 * 1024) continue; // don't block the relay on a backed-up peer; the ~1s roster broadcast backstops // it @@ -1144,7 +1405,8 @@ std::vector state_transport_ws::link_reports() const { continue; state_link_report r; r.transport = "ws"; - r.remote_node_id = c->remote_node_id; + if (const std::string *pid = c->peer_id()) + r.remote_node_id = *pid; r.accepted = c->accepted; r.age_s = static_cast(now - c->opened_ns) * 1e-9; r.idle_s = static_cast(now - c->last_rx_ns.load()) * 1e-9; @@ -1184,9 +1446,6 @@ void state_transport_ws::send_pings_if_due() { void state_transport_ws::ping_loop() { #ifndef __EMSCRIPTEN__ - // How long one write may block before its connection is closed (see - // connection::write_started_ns). - constexpr std::int64_t kWriteStallNs = 3'000'000'000; while (_running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); send_pings_if_due(); @@ -1199,8 +1458,9 @@ void state_transport_ws::ping_loop() { for (auto &c : conns) { const std::int64_t started = c ? c->write_started_ns.load(std::memory_order_relaxed) : 0; if (started != 0 && now - started > kWriteStallNs && c->alive.load()) { + const std::string *pid = c->peer_id(); std::fprintf(stderr, "[ws] write to '%s' blocked > 3 s (peer not reading): closing\n", - c->remote_node_id.c_str()); + pid ? pid->c_str() : ""); c->abort_io(); } } @@ -1272,7 +1532,10 @@ void state_transport_ws::send_snapshot_locked(connection &c, // those entries are REMOTE-origin, so send_backfill_locked's replay_local() yields nothing. Push // the full current snapshot instead, as an unsolicited SnapshotResponse (request_id 0), so a late // joiner bootstraps the peers already present. The receiver applies each entry via - // ingest_snapshot_entry (a bulk initial load, no conflict gate). + // ingest_snapshot_entry (a bulk initial load, no conflict gate). Deliberately UNFILTERED: this + // goes out before the peer's Hello, so there is no identity to look a registry entry up by. + // What it carried is recorded as the peer's starting point, so the first broadcast is a diff. + const std::uint64_t round = _broadcast_round.load(std::memory_order_relaxed); for (auto *s : shards) { if (s == nullptr) continue; @@ -1280,73 +1543,51 @@ void state_transport_ws::send_snapshot_locked(connection &c, if (snap.empty()) continue; pb::Frame f; - auto *rsp = f.mutable_snapshot_response(); - rsp->set_request_id(0); - rsp->set_final(true); - for (const auto &e : snap) { - auto *pe = rsp->add_entries(); - pe->set_path(e.path); - pe->set_string_value(e.string_value); - pe->set_comment(e.comment); - pe->set_hidden(e.hidden); - pe->set_read_only(e.read_only); - for (const auto &p : e.properties) - (*pe->mutable_properties())[p.first] = p.second; - pe->set_type_name(e.type_name); - pe->set_origin_node_id(e.origin_node_id); - pe->set_sequence(e.sequence); - pe->set_data_type_name(e.data_type_name); - if (e.data_payload.kind != state_payload_kind::none) - set_pb_payload(pe->mutable_data_payload(), e.data_payload); - } + fill_snapshot_response(f.mutable_snapshot_response(), snap); if (!c.write_locked(f)) return; // stream died mid-snapshot; the reader will reap it _sent_frames.fetch_add(1, std::memory_order_relaxed); + std::lock_guard hk(c.held_mu); + for (const auto &e : snap) + c.held[e.path] = held_entry{entry_digest(e).value(), round}; } } std::size_t state_transport_ws::broadcast_snapshot() { - // Build each shard's snapshot frame ONCE, then write it to every live connection. The receivers - // apply it idempotently (ingest_snapshot_entry is a bulk load), so re-sending the same - // roster/scene each interval is a no-op for a client that is already converged and a recovery for - // one that missed a live re-fan. Uses connection::write() (locks per connection) — do NOT hold a - // connection write lock here. Snapshotting the shard reads the tree under its own lock. + // Snapshot each shard ONCE, then bring every live connection up to date with it. A connection is + // sent only the entries it does not already hold (connection::held): those whose content differs + // from what its last snapshot frame carried, and those forgotten since because another frame + // touched them. So a path that changed and went out live is sent once more -- the relay cannot + // tell whether the live frame was applied -- and an unchanged tree sends nothing. The receivers + // apply each entry idempotently (ingest_snapshot_entry is a bulk load), which makes this a + // recovery for a client that missed a live re-fan. Uses each connection's write lock for the + // comparison and the write together -- do NOT hold one here. Snapshotting the shard reads the + // tree under its own lock. std::vector shards; { std::lock_guard lk(_shards_mu); shards = _shards; } - std::vector frames; + struct shard_snapshot { + std::vector entries; + std::vector digests; // entry_digest of each entry, shared by every connection + }; + std::vector snaps; for (auto *s : shards) { if (s == nullptr) continue; - const auto snap = s->snapshot(); - if (snap.empty()) + shard_snapshot ss; + ss.entries = s->snapshot(); + if (ss.entries.empty()) continue; - pb::Frame f; - auto *rsp = f.mutable_snapshot_response(); - rsp->set_request_id(0); - rsp->set_final(true); - for (const auto &e : snap) { - auto *pe = rsp->add_entries(); - pe->set_path(e.path); - pe->set_string_value(e.string_value); - pe->set_comment(e.comment); - pe->set_hidden(e.hidden); - pe->set_read_only(e.read_only); - for (const auto &p : e.properties) - (*pe->mutable_properties())[p.first] = p.second; - pe->set_type_name(e.type_name); - pe->set_origin_node_id(e.origin_node_id); - pe->set_sequence(e.sequence); - pe->set_data_type_name(e.data_type_name); - if (e.data_payload.kind != state_payload_kind::none) - set_pb_payload(pe->mutable_data_payload(), e.data_payload); - } - frames.push_back(std::move(f)); + ss.digests.reserve(ss.entries.size()); + for (const auto &e : ss.entries) + ss.digests.push_back(entry_digest(e).value()); + snaps.push_back(std::move(ss)); } - if (frames.empty()) + if (snaps.empty()) return 0; + const std::uint64_t round = _broadcast_round.fetch_add(1, std::memory_order_relaxed) + 1; std::vector> conns; { std::lock_guard lk(_conns_mu); @@ -1357,10 +1598,11 @@ std::size_t state_transport_ws::broadcast_snapshot() { // signal for "clients can't see each other" — a skipped peer is one the periodic roster never // reaches. static const bool dbg = std::getenv("CVC_WS_BROADCAST_DEBUG") != nullptr; - std::size_t wrote = 0, skipped = 0, max_backlog = 0; + std::size_t wrote = 0, skipped = 0, max_backlog = 0, filtered = 0, entries = 0, refreshed = 0; for (auto &c : conns) { if (!c || !c->alive.load() || !c->can_write()) continue; + const std::string *pid = c->peer_id(); const std::size_t bl = c->send_backlog(); if (bl > max_backlog) max_backlog = bl; @@ -1374,24 +1616,137 @@ std::size_t state_transport_ws::broadcast_snapshot() { if (bl > 2 * 1024 * 1024) { ++skipped; if (dbg) - std::fprintf(stderr, "[ws-bcast] SKIP node='%s' backlog=%zuKB\n", c->remote_node_id.c_str(), + std::fprintf(stderr, "[ws-bcast] SKIP node='%s' backlog=%zuKB\n", pid ? pid->c_str() : "", bl / 1024); continue; } + // One registry lookup per connection; the view then answers every entry without the lock. + state_peer_registry::delivery_view view; + if (pid) + view = _peers.view_of(*pid); + if (view.filtered()) + ++filtered; + // Under write_mu from the comparison through the write: a live frame for one of these paths + // lands wholly before (and is compared against) or wholly after (and forgets) this frame. + std::lock_guard wlk(c->write_mu); + std::vector frames; // one per shard with anything for this peer + std::size_t n = 0; + { + std::lock_guard hk(c->held_mu); + if (c->full_refresh.exchange(false, std::memory_order_relaxed)) { + c->held.clear(); + ++refreshed; + } + for (const auto &ss : snaps) { + pb::Frame f; + auto *rsp = f.mutable_snapshot_response(); + rsp->set_request_id(0); + rsp->set_final(true); + for (std::size_t i = 0; i < ss.entries.size(); ++i) { + const snapshot_entry &e = ss.entries[i]; + if (!view.deliver(e.path)) + continue; + // Keyed by path alone, as the receiver applies every entry to every shard it has. + auto [it, fresh] = c->held.try_emplace(e.path); + it->second.round = round; + if (!fresh && it->second.digest == ss.digests[i]) + continue; // the peer holds this already + it->second.digest = ss.digests[i]; + add_snapshot_entry(rsp, e); + } + if (rsp->entries_size() > 0) { + n += static_cast(rsp->entries_size()); + frames.push_back(std::move(f)); + } + } + // A path this round did not deliver left the tree or the peer's view: drop it, so it is sent + // again if it comes back. + for (auto it = c->held.begin(); it != c->held.end();) + it = it->second.round == round ? std::next(it) : c->held.erase(it); + } bool ok = true; - for (const auto &f : frames) - ok = c->write(f) && ok; - if (ok) { + for (const auto &f : frames) { + if (!c->write_locked(f)) { + ok = false; + break; + } + } + if (!ok) { + // The stream died (write_locked marked it dead); hold nothing for it. + std::lock_guard hk(c->held_mu); + c->held.clear(); + continue; + } + if (!frames.empty()) { ++wrote; + entries += n; _sent_frames.fetch_add(frames.size(), std::memory_order_relaxed); + _broadcast_entries.fetch_add(n, std::memory_order_relaxed); } } if (dbg) - std::fprintf(stderr, "[ws-bcast] wrote=%zu skipped=%zu maxbacklog=%zuKB conns=%zu frames=%zu\n", - wrote, skipped, max_backlog / 1024, conns.size(), frames.size()); + std::fprintf(stderr, + "[ws-bcast] wrote=%zu skipped=%zu filtered=%zu refreshed=%zu entries=%zu " + "maxbacklog=%zuKB conns=%zu shards=%zu\n", + wrote, skipped, filtered, refreshed, entries, max_backlog / 1024, conns.size(), + snaps.size()); return wrote; } +std::size_t state_transport_ws::request_full_refresh() { + pb::Frame f; + f.mutable_snapshot_request()->set_cluster_id(_cluster_id); + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + std::size_t n = 0; + for (auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + if (c->write(f)) { + _sent_frames.fetch_add(1, std::memory_order_relaxed); + ++n; + } + } + return n; +} + +std::size_t state_transport_ws::push_snapshot_to(const std::string &node_id, + const std::vector &entries) { + if (node_id.empty() || entries.empty()) + return 0; + std::vector> targets; + { + std::lock_guard lk(_conns_mu); + for (const auto &c : _conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + if (const std::string *pid = c->peer_id(); pid && *pid == node_id) + targets.push_back(c); + } + } + if (targets.empty()) + return 0; // the peer is on another transport (or gone): nothing to encode + pb::Frame f; + fill_snapshot_response(f.mutable_snapshot_response(), entries); + std::size_t n = 0; + for (auto &c : targets) { + // Never block the caller (the relay's main loop) on a peer that is not draining; the caller's + // next push or the periodic broadcast covers what this one would have carried. + if (c->send_backlog() > 2 * 1024 * 1024) + continue; + // The caller chose these entries, so they need not match the tree: forget the peer's copy of + // each path, and a broadcast that delivers one sends the tree's value there. + if (c->write_entries(f, entries)) { + _sent_frames.fetch_add(1, std::memory_order_relaxed); + ++n; + } + } + return n; +} + void state_transport_ws::unregister_connection(connection *conn) { std::lock_guard lk(_conns_mu); _conns.erase( @@ -1419,8 +1774,9 @@ std::size_t state_transport_ws::reap_idle(std::chrono::milliseconds timeout) { continue; const std::int64_t last = c->last_rx_ns.load(std::memory_order_relaxed); if (last != 0 && (now - last) > to_ns) { + const std::string *pid = c->peer_id(); std::fprintf(stderr, "[ws] reaping idle connection node='%s' idle=%.1fs\n", - c->remote_node_id.c_str(), static_cast(now - last) / 1.0e9); + pid ? pid->c_str() : "", static_cast(now - last) / 1.0e9); beast::error_code ec; c->ws->next_layer().shutdown(tcp::socket::shutdown_both, ec); c->ws->next_layer().close(ec); diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index ba073cc02..877adcbc8 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -112,6 +112,8 @@ add_executable(holon_test holon_test.cpp) add_executable(holon_sync_test holon_sync_test.cpp) add_executable(state_link_stats_test state_link_stats_test.cpp) add_executable(room_scene_test room_scene_test.cpp) +add_executable(room_aoi_test room_aoi_test.cpp) +add_executable(room_aoi_relay_test room_aoi_relay_test.cpp) add_executable(physics_codec_test physics_codec_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) @@ -173,6 +175,11 @@ endif() if(CVC_ENABLE_WS) add_executable(state_transport_ws_test state_transport_ws_test.cpp) endif() +# The S7 relay end to end on loopback (spatial interest, media gate, pushes): its WebSocket cases +# need CVC_ENABLE_WS, its gRPC cases CVC_ENABLE_GRPC. +if(CVC_ENABLE_GRPC OR CVC_ENABLE_WS) + add_executable(room_relay_integration_test room_relay_integration_test.cpp) +endif() add_executable(voxels_test voxels_test.cpp) add_executable(volume_test volume_test.cpp) add_executable(volren_math_test volren_math_test.cpp) @@ -317,6 +324,8 @@ set(TEST_TARGETS holon_sync_test state_link_stats_test room_scene_test + room_aoi_test + room_aoi_relay_test physics_codec_test physics_field_test physics_vehicle_test @@ -1714,6 +1723,20 @@ target_link_libraries(room_scene_test GTest::gtest_main ) +target_link_libraries(room_aoi_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + +target_link_libraries(room_aoi_relay_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_codec_test PRIVATE cvc @@ -1939,6 +1962,17 @@ if(TARGET state_transport_ws_test) list(APPEND TEST_TARGETS state_transport_ws_test) endif() +if(TARGET room_relay_integration_test) + target_link_libraries(room_relay_integration_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main + ) + target_compile_features(room_relay_integration_test PRIVATE cxx_std_17) + list(APPEND TEST_TARGETS room_relay_integration_test) +endif() + if(TARGET state_stream_transport_grpc_test) target_link_libraries(state_stream_transport_grpc_test PRIVATE @@ -2128,6 +2162,8 @@ target_compile_features(holon_test PRIVATE cxx_std_17) target_compile_features(holon_sync_test PRIVATE cxx_std_17) target_compile_features(state_link_stats_test PRIVATE cxx_std_17) target_compile_features(room_scene_test PRIVATE cxx_std_17) +target_compile_features(room_aoi_test PRIVATE cxx_std_17) +target_compile_features(room_aoi_relay_test PRIVATE cxx_std_17) target_compile_features(physics_codec_test PRIVATE cxx_std_17) target_compile_features(physics_field_test PRIVATE cxx_std_17) target_compile_features(physics_vehicle_test PRIVATE cxx_std_17) @@ -2592,6 +2628,8 @@ cvc_discover_tests(holon_test) cvc_discover_tests(holon_sync_test) cvc_discover_tests(state_link_stats_test) cvc_discover_tests(room_scene_test) +cvc_discover_tests(room_aoi_test) +cvc_discover_tests(room_aoi_relay_test) cvc_discover_tests(physics_codec_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) @@ -2631,6 +2669,11 @@ endif() if(TARGET state_transport_ws_test) cvc_discover_tests(state_transport_ws_test) endif() +if(TARGET room_relay_integration_test) + # Its suite is RoomRelayLoopbackTest, not *Integration*: CI's PR and recipe runs exclude names + # matching "Integration" (the slow CLI suites), and these are S7's only end-to-end checks. + cvc_discover_tests(room_relay_integration_test) +endif() if(TARGET state_stream_transport_grpc_test) cvc_discover_tests(state_stream_transport_grpc_test) endif() diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index ff0268473..030919e3a 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -273,3 +274,353 @@ TEST(HolonSyncTest, ClientPumpPredictsSelfAndWalksPeersSmoothly) { s->detach(); } } + +// client_pump registers whoever the roster lists, but one whose pose has not replicated yet is +// pose-less: a member with nothing to draw and, with physics, no peer body at the placeholder +// origin. Its first replicated pose makes it known, where the server put it. +TEST(HolonSyncTest, ClientPumpMarksPoseLessPeersUnknown) { + cvc::app server_app, a_app, b_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard as(a_app, "conf", "A"); + cvc::state_cluster_shard bs(b_app, "conf", "B"); + for (cvc::state_cluster_shard *s : {&ss, &as, &bs}) { + s->attach(); + tp.register_shard(s); + } + const std::string room = "r"; + cvc::ariadne::conference::room_join(a_app, room, "A"); + cvc::ariadne::conference::room_join(b_app, room, "B"); + tp.pump_all(); + + holon a(holon_role::client, cvc::ariadne::room_holon_config()); + const double dt = a.clock().fixed_dt(); + cvc::ariadne::client_pump(a, a_app, room, "A", dt); // the roster is here, no pose yet + ASSERT_TRUE(a.has_participant("A")); + ASSERT_TRUE(a.has_participant("B")) << "membership does not wait for a pose"; + EXPECT_FALSE(a.pose_known("A")); + EXPECT_FALSE(a.pose_known("B")); + if (a.physics()) { + EXPECT_TRUE(a.has_body("A")) << "the local avatar is predicted from the start"; + EXPECT_FALSE(a.has_body("B")) << "no peer body at the placeholder origin"; + } + + // The server spawns both on its ring and publishes their poses. + holon server(holon_role::server); + cvc::ariadne::server_pump(server, server_app, room, dt); + tp.pump_all(); + cvc::ariadne::client_pump(a, a_app, room, "A", dt); + EXPECT_TRUE(a.pose_known("A")); + EXPECT_TRUE(a.pose_known("B")); + if (a.physics()) + EXPECT_TRUE(a.has_body("B")) << "a kinematic body, at its first sample"; + const vec3 shown = a.display_pose("B").position, spawned = server.state("B").position; + EXPECT_DOUBLE_EQ(shown.x, spawned.x); + EXPECT_DOUBLE_EQ(shown.y, spawned.y); + EXPECT_GT(std::hypot(shown.x, shown.y), 3.0) << "on the spawn ring, not at the origin"; + + // A client that joins once the poses have replicated knows every one from its first pump. + holon b(holon_role::client); + cvc::ariadne::client_pump(b, b_app, room, "B", dt); + EXPECT_TRUE(b.pose_known("A")); + EXPECT_TRUE(b.pose_known("B")); + + for (cvc::state_cluster_shard *s : {&ss, &as, &bs}) { + tp.unregister_shard(s); + s->detach(); + } +} + +// A peer that leaves and comes back under the same pid (arigl reconnects in-process) still has +// the pose subtree it left in our tree, and the relay sends nothing newer while it is departed. +// That leftover is no pose for its rejoin: registering it from it would show the peer where it +// left and seek our clock back to the old tick. A newer sample makes it known; so does the low +// tick of a restarted relay, whose clock starts again near zero. +TEST(HolonSyncTest, RejoinDoesNotTakeTheLeftoverPose) { + cvc::app app; + const std::string room = "r"; + cvc::ariadne::conference::room_join(app, room, "A"); + cvc::ariadne::conference::room_join(app, room, "B"); + const auto at = [](double x, double y) { + cvc::physics::body_state s; + s.position = {x, y, 0.0}; + return s; + }; + holon a(holon_role::client, cvc::ariadne::room_holon_config()); + const double dt = a.clock().fixed_dt(); + cvc::ariadne::write_pose(app, room, "B", at(5.0, 0.0), 5000); + cvc::ariadne::client_pump(a, app, room, "A", dt); + ASSERT_TRUE(a.pose_known("B")); + EXPECT_EQ(a.left_tick("B"), 0u); + + cvc::ariadne::conference::room_leave(app, room, "B"); + cvc::ariadne::client_pump(a, app, room, "A", dt); + ASSERT_FALSE(a.has_participant("B")); + EXPECT_EQ(a.left_tick("B"), 5000u); + + // Back: the leftover (tick 5000) is no pose, frame after frame, and moves no clock. + cvc::ariadne::conference::room_join(app, room, "B"); + const std::uint64_t realigns = a.clock_realigns(); + for (int i = 0; i < 3; ++i) { + cvc::ariadne::client_pump(a, app, room, "A", dt); + ASSERT_TRUE(a.has_participant("B")); + EXPECT_FALSE(a.pose_known("B")) << "frame " << i; + if (a.physics()) + EXPECT_FALSE(a.has_body("B")) << "no body where it left"; + } + EXPECT_EQ(a.clock_realigns(), realigns) << "no seek back to the old tick"; + + // The relay respawns it: a newer sample makes it known, where it is now. + cvc::ariadne::write_pose(app, room, "B", at(-4.0, 1.0), 5600); + cvc::ariadne::client_pump(a, app, room, "A", dt); + ASSERT_TRUE(a.pose_known("B")); + EXPECT_DOUBLE_EQ(a.display_pose("B").position.x, -4.0); + EXPECT_EQ(a.left_tick("B"), 0u) << "forgotten once a real sample is applied"; + + // Left again, and the relay restarts: its first tick is far below the old one, so it is new. + cvc::ariadne::conference::room_leave(app, room, "B"); + cvc::ariadne::client_pump(a, app, room, "A", dt); + ASSERT_EQ(a.left_tick("B"), 5600u); + cvc::ariadne::conference::room_join(app, room, "B"); + cvc::ariadne::write_pose(app, room, "B", at(2.0, 2.0), 300); + cvc::ariadne::client_pump(a, app, room, "A", dt); + EXPECT_TRUE(a.pose_known("B")) << "a restarted relay's low tick is not the leftover"; +} + +// ── journaling: one mutation per written pose leaf ────────────────────────── + +namespace { +// Publish what SHARD journaled since its last drain to the transport's other shards; returns how +// many mutations that was (what the writes since then cost on the wire). +std::size_t publish_drained(cvc::state_transport_inproc &tp, cvc::state_cluster_shard &shard) { + const std::vector drained = shard.drain_local(); + for (const cvc::state_mutation &m : drained) + tp.publish(m); + return drained.size(); +} +} // namespace + +// A pose has 14 leaves (13 doubles and the server_tick). Each one written journals ONE mutation: a +// changed leaf its value, not a touch and then the value. +TEST(HolonSyncTest, ChangedOnlyPoseWritesOneMutationPerChangedLeaf) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + const std::string room = "r", pid = "A"; + constexpr std::uint64_t kHeartbeat = 120; + body_state s; + s.position = vec3{1.0, 2.0, 0.0}; + s.linear_velocity = vec3{0.5, 0.0, 0.0}; + body_state got; + std::uint64_t tick = 0; + + // A fresh pose: every leaf, once, and it converges on the client. + cvc::ariadne::write_pose(server_app, room, pid, s, 100, kHeartbeat); + EXPECT_EQ(publish_drained(tp, ss), 14u); + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, got, tick)); + EXPECT_EQ(tick, 100u); + expect_pos_bit_eq(got.position, s.position); + expect_pos_bit_eq(got.linear_velocity, s.linear_velocity); + + // One leaf moved: that leaf and the tick. + s.position.x = 1.5; + cvc::ariadne::write_pose(server_app, room, pid, s, 110, kHeartbeat); + EXPECT_EQ(publish_drained(tp, ss), 2u); + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, got, tick)); + EXPECT_EQ(tick, 110u); + expect_pos_bit_eq(got.position, s.position); + + // Unchanged within the heartbeat: nothing. + cvc::ariadne::write_pose(server_app, room, pid, s, 150, kHeartbeat); + EXPECT_EQ(publish_drained(tp, ss), 0u); + + // Unchanged a heartbeat on: the tick alone. + cvc::ariadne::write_pose(server_app, room, pid, s, 230, kHeartbeat); + EXPECT_EQ(publish_drained(tp, ss), 1u); + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, got, tick)); + EXPECT_EQ(tick, 230u); + expect_pos_bit_eq(got.position, s.position); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} + +// Heartbeat 0 is the forced rewrite: every leaf goes out again, changed or not, once each -- so a +// peer that saw none of the earlier writes converges from that one write. +TEST(HolonSyncTest, ForcedPoseRewritesEveryLeafOnce) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + const std::string room = "r", pid = "A"; + body_state s; + s.position = vec3{-2.0, 0.5, 0.0}; + s.rotation = cvc::physics::quat{0.5, -0.5, 0.5, -0.5}; + + cvc::ariadne::write_pose(server_app, room, pid, s, 100); + EXPECT_EQ(publish_drained(tp, ss), 14u) << "a fresh pose"; + cvc::ariadne::write_pose(server_app, room, pid, s, 100); + EXPECT_EQ(publish_drained(tp, ss), 14u) << "unchanged, even the tick: each leaf re-sent once"; + s.position.y = -1.0; + cvc::ariadne::write_pose(server_app, room, pid, s, 101); + EXPECT_EQ(publish_drained(tp, ss), 14u) << "one moved: still once per leaf, not twice for it"; + + // A peer that joins now (an inproc transport replays nothing) is re-hydrated by the next one. + cvc::app late_app; + cvc::state_cluster_shard ls(late_app, "conf", "late"); + ls.attach(); + tp.register_shard(&ls); + cvc::ariadne::write_pose(server_app, room, pid, s, 102); + EXPECT_EQ(publish_drained(tp, ss), 14u); + body_state got; + std::uint64_t tick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(late_app, room, pid, got, tick)); + EXPECT_EQ(tick, 102u); + expect_pos_bit_eq(got.position, s.position); + EXPECT_EQ(bits(got.rotation.w), bits(s.rotation.w)); + EXPECT_EQ(bits(got.rotation.z), bits(s.rotation.z)); + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, got, tick)); + EXPECT_EQ(tick, 102u); + expect_pos_bit_eq(got.position, s.position); + + for (cvc::state_cluster_shard *sh : {&ss, &cs, &ls}) { + tp.unregister_shard(sh); + sh->detach(); + } +} + +// A leaf can exist, empty, before its first write -- made by a lookup that creates nodes +// (state::operator()), which signals nothing, so the state adapter is not observing it yet. Its +// first value must still journal: it is touched first, which attaches it. +TEST(HolonSyncTest, PreCreatedEmptyLeafStillReplicates) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + const std::string room = "r", pid = "A"; + const std::string sep = cvc::state::SEPARATOR; + const std::string x_path = cvc::ariadne::conference::room_participant_path(room, pid) + sep + + "pose" + sep + "pos" + sep + "x"; + server_app.root()(x_path); // created after the shard attached: present, empty, unobserved + ASSERT_NE(server_app.root().findDescendant(x_path), nullptr); + + body_state s; + s.position = vec3{3.25, -1.0, 0.0}; + cvc::ariadne::write_pose(server_app, room, pid, s, 7, /*heartbeat_ticks=*/120); + EXPECT_EQ(publish_drained(tp, ss), 14u) << "the pre-created leaf journaled too"; + body_state got; + std::uint64_t tick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, got, tick)); + EXPECT_EQ(tick, 7u); + expect_pos_bit_eq(got.position, s.position); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} + +// ── timing: stamped intents, changed-only poses ───────────────────────────── + +TEST(HolonSyncTest, IntentStampRoundTrips) { + cvc::app app; + avatar_intent in{vec3{1.0, 0.0, 0.0}}; + in.yaw = 0.75; + cvc::ariadne::write_intent(app, "r", "A", in, 1234); + avatar_intent out; + std::uint64_t stamp = 0; + ASSERT_TRUE(cvc::ariadne::read_intent(app, "r", "A", out, &stamp)); + EXPECT_EQ(stamp, 1234u); + EXPECT_DOUBLE_EQ(out.yaw, 0.75); + cvc::ariadne::write_intent(app, "r", "A", in); // untimed + ASSERT_TRUE(cvc::ariadne::read_intent(app, "r", "A", out, &stamp)); + EXPECT_EQ(stamp, 0u); +} + +TEST(HolonSyncTest, ProposeIntentStampsTheNextTick) { + cvc::app app; + holon h(holon_role::client); + h.add_participant("A", /*local=*/true); + for (int i = 0; i < 5; ++i) + h.advance(h.clock().fixed_dt()); + cvc::ariadne::propose_intent(h, app, "r", "A", avatar_intent{vec3{1.0, 0.0, 0.0}}); + avatar_intent out; + std::uint64_t stamp = 0; + ASSERT_TRUE(cvc::ariadne::read_intent(app, "r", "A", out, &stamp)); + EXPECT_EQ(stamp, h.tick() + 1); +} + +TEST(HolonSyncTest, WritePoseWithAHeartbeatWritesOnlyChanges) { + cvc::app app; + body_state s; + s.position = vec3{1.0, 2.0, 0.0}; + cvc::ariadne::write_pose(app, "r", "A", s, 100, /*heartbeat_ticks=*/120); + body_state out; + std::uint64_t tick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 100u); + + // Unchanged, within the heartbeat: nothing is written (not even the tick). + cvc::ariadne::write_pose(app, "r", "A", s, 150, 120); + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 100u); + // Unchanged, a heartbeat later: the tick is re-stamped. + cvc::ariadne::write_pose(app, "r", "A", s, 220, 120); + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 220u); + // Moved: the changed leaf and the tick are written. + s.position.x = 1.5; + cvc::ariadne::write_pose(app, "r", "A", s, 230, 120); + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 230u); + EXPECT_DOUBLE_EQ(out.position.x, 1.5); + EXPECT_DOUBLE_EQ(out.position.y, 2.0); +} + +// server_pump queues a stamped intent for its tick rather than applying it on read. +TEST(HolonSyncTest, ServerPumpAppliesAStampedIntentAtItsTick) { + cvc::app app; + const std::string room = "r"; + cvc::ariadne::conference::room_join(app, room, "A"); + holon server(holon_role::server); + const double dt = server.clock().fixed_dt(); + cvc::ariadne::server_pump(server, app, room, dt); // registers A (tick 1) + const vec3 start = server.state("A").position; + cvc::ariadne::write_intent(app, room, "A", avatar_intent{vec3{2.0, 0.0, 0.0}}, server.tick() + 5); + for (int i = 0; i < 4; ++i) // ticks 2-5: before the stamp + cvc::ariadne::server_pump(server, app, room, dt); + EXPECT_DOUBLE_EQ(server.state("A").position.x, start.x) << "not before its tick"; + cvc::ariadne::server_pump(server, app, room, dt); // tick 6: the stamp + EXPECT_DOUBLE_EQ(server.state("A").position.x, start.x + 2.0 * dt); +} + +// The room scene's objects become physics statics: the floor as a slab under z = 0, the cube as +// a box standing on it. +TEST(HolonSyncTest, RoomStaticsComeFromTheRoomScene) { + cvc::app app; + ASSERT_TRUE(cvc::ariadne::conference::seed_default_scene(app, "r")); + const std::vector st = cvc::ariadne::room_statics(app, "r"); + ASSERT_EQ(st.size(), 2u); + const cvc::ariadne::avatar_static &ground = st[0].half_extents.x > 5.0 ? st[0] : st[1]; + const cvc::ariadne::avatar_static &cube = st[0].half_extents.x > 5.0 ? st[1] : st[0]; + EXPECT_DOUBLE_EQ(ground.half_extents.x, 10.0); + EXPECT_NEAR(ground.center.z + ground.half_extents.z, 0.0, 1e-9) << "the slab's top is the floor"; + EXPECT_DOUBLE_EQ(cube.half_extents.z, 0.5); + EXPECT_DOUBLE_EQ(cube.center.z, 0.5); + EXPECT_TRUE(cvc::ariadne::room_holon_config().physics); +} diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index 8409eb3c0..5ac85345e 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -16,6 +16,7 @@ // a server override snaps the client); a client never drives a peer; and a peer is // interpolated through the holon's frame alpha, never overshooting. +#include #include #include #include @@ -23,6 +24,8 @@ #include #include #include +#include +#include using cvc::ariadne::avatar_intent; using cvc::ariadne::avatar_sim; @@ -304,3 +307,483 @@ TEST(HolonTest, TakeCorrectionReportsReconcileJumpsOnce) { EXPECT_DOUBLE_EQ(client.take_correction("B").y, 0.0) << "a peer has no corrections"; EXPECT_DOUBLE_EQ(client.take_correction("nobody").y, 0.0); } + +// ── stamped intents: the server applies each at the tick the client did ───── + +TEST(HolonTest, ServerAppliesAStampedIntentAtItsTick) { + holon server(holon_role::server); + server.add_participant("A"); + const double dt = server.clock().fixed_dt(); + const auto frames = [&](int n) { + for (int i = 0; i < n; ++i) + server.advance(dt); + }; + frames(10); + ASSERT_EQ(server.tick(), 10u); + + // Stamped for tick 15: ticks 11-14 still stand, 15-20 walk at 2 m/s. + server.queue_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}, 15); + server.queue_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}, 15); // re-read: a no-op + frames(10); + ASSERT_EQ(server.tick(), 20u); + avatar_sim ref; + for (int i = 0; i < 6; ++i) + ref.step(avatar_intent{vec3{2.0, 0.0, 0.0}}, dt); + expect_pos_bit_eq(server.state("A").position, ref.state().position); + + // A late stamp (its tick already integrated) takes effect at the next tick. + server.queue_intent("A", avatar_intent{vec3{0.0, 0.0, 0.0}}, 12); + server.advance(dt); + expect_pos_bit_eq(server.state("A").position, ref.state().position); + + // A stamp far beyond the allowed lead (a client whose clock is off) also applies at once. + server.queue_intent("A", avatar_intent{vec3{0.0, 3.0, 0.0}}, 1'000'000); + server.advance(dt); + EXPECT_DOUBLE_EQ(server.state("A").position.y, 3.0 * dt); + + // set_intent (untimed) drops anything still queued. + server.queue_intent("A", avatar_intent{vec3{9.0, 0.0, 0.0}}, server.tick() + 5); + server.set_intent("A", avatar_intent{vec3{0.0, 0.0, 0.0}}); + const vec3 before = server.state("A").position; + frames(10); + expect_pos_bit_eq(server.state("A").position, before); +} + +// A client that runs AHEAD of the server by the round trip has its stamped intents reach the +// server before their tick, so the server's poses agree with its prediction exactly: across a +// walk with several key changes, reconcile corrects nothing. The same exchange with no lead (the +// intents arrive late) needs corrections -- the latency the lead removes. +namespace { +double total_correction(std::uint64_t lead) { + constexpr int kDelay = 6; // frames (= ticks) each way + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", /*local=*/true); + client.set_lead_ticks(lead); + const double dt = server.clock().fixed_dt(); + struct intent_msg { + int at; + avatar_intent in; + std::uint64_t stamp; + }; + struct sample_msg { + int at; + body_state s; + std::uint64_t tick; + }; + std::vector up; + std::vector down; + double corrected = 0.0; + avatar_intent cur; + for (int f = 0; f < 400; ++f) { + // Keys: walk +x, strafe, stop, walk -y, stop. + avatar_intent want = cur; + if (f == 60) + want.move = vec3{2.0, 0.0, 0.0}; + if (f == 110) + want.move = vec3{0.0, 2.0, 0.0}; + if (f == 150) + want.move = vec3{0.0, 0.0, 0.0}; + if (f == 200) + want.move = vec3{0.0, -2.0, 0.0}; + if (f == 260) + want.move = vec3{0.0, 0.0, 0.0}; + if (want.move.x != cur.move.x || want.move.y != cur.move.y) { + cur = want; + client.set_intent("A", cur); + up.push_back({f + kDelay, cur, client.tick() + 1}); + } + client.advance(dt); + for (auto it = up.begin(); it != up.end();) + if (it->at <= f) { + server.queue_intent("A", it->in, it->stamp); + it = up.erase(it); + } else { + ++it; + } + server.advance(dt); + down.push_back({f + kDelay, server.state("A"), server.tick()}); + for (auto it = down.begin(); it != down.end();) + if (it->at <= f) { + client.apply_authoritative("A", it->s, it->tick); + it = down.erase(it); + } else { + ++it; + } + const vec3 c = client.take_correction("A"); + if (f >= 60) // after the first sample aligned the clocks + corrected += std::fabs(c.x) + std::fabs(c.y) + std::fabs(c.z); + } + return corrected; +} +} // namespace + +TEST(HolonTest, ALeadingClientNeedsNoCorrections) { + EXPECT_EQ(total_correction(/*lead=*/2 * 6 + 2), 0.0); + EXPECT_GT(total_correction(/*lead=*/0), 0.1) << "without the lead the intents land late"; +} + +TEST(HolonTest, ClientAlignsAheadOfTheServerByItsLead) { + holon client(holon_role::client); + client.add_participant("A", /*local=*/true); + client.set_lead_ticks(14); + body_state s; + client.apply_authoritative("A", s, 100); + EXPECT_EQ(client.tick(), 114u); + // Within the slack it is left alone; falling further behind its target re-aligns. + client.apply_authoritative("A", s, 104); // target 118, 4 behind + EXPECT_EQ(client.tick(), 114u); + client.apply_authoritative("A", s, 120); // target 134, 20 behind + EXPECT_EQ(client.tick(), 134u); +} + +// Every clock seek drops the local avatar's unacknowledged inputs, so after the one at join a +// client should see none; clock_realigns counts them, so a host can tell when it does. +TEST(HolonTest, RealignCounterCountsSeeks) { + holon client(holon_role::client); + client.add_participant("A", /*local=*/true); + client.add_participant("B", /*local=*/false); + client.set_lead_ticks(14); + EXPECT_EQ(client.clock_realigns(), 0u); + body_state s; + client.apply_authoritative("A", s, 100); // joining: the clock seeks to 114 + ASSERT_EQ(client.tick(), 114u); + EXPECT_EQ(client.clock_realigns(), 1u) << "the alignment at join counts"; + const double dt = client.clock().fixed_dt(); + for (int i = 0; i < 30; ++i) + client.advance(dt); + ASSERT_EQ(client.tick(), 144u); + + // Target 112 + 14 = 126, 18 ticks behind the replica: within the slack, no seek. + client.apply_authoritative("A", s, 112); + EXPECT_EQ(client.tick(), 144u); + EXPECT_EQ(client.clock_realigns(), 1u) << "an in-tolerance sample is not a re-alignment"; + + // A sample stamped more than lead_slack_ahead_ticks (24) behind -- a peer whose tick a + // heartbeat left old -- seeks the clock back, and counts. + client.apply_authoritative("B", s, 105); // target 119, 25 behind + EXPECT_EQ(client.tick(), 119u); + EXPECT_EQ(client.clock_realigns(), 2u); + client.apply_authoritative("B", s, 105); // already seen: not a sample, not a seek + EXPECT_EQ(client.clock_realigns(), 2u); +} + +TEST(HolonTest, PeerSampleSpanIsCapped) { + holon_config cfg; + cfg.max_peer_span_ticks = 30; + holon client(holon_role::client, cfg); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + body_state s0, s1; + s1.position = vec3{1.0, 0.0, 0.0}; + client.apply_authoritative("B", s0, 10); + client.apply_authoritative("B", s1, 250); // after a long still spell (a heartbeat gap) + for (int i = 0; i < 31; ++i) + client.advance(dt); + EXPECT_GT(client.display_pose("B").position.x, 0.99) << "walked in over 30 ticks, not 240"; +} + +// ── physics: rigid-body avatars in a Jolt world ───────────────────────────── + +namespace { +holon_config physics_config() { + holon_config cfg; + cfg.physics = true; + return cfg; +} +// A 20 m floor (a slab whose top is z = 0) and a 1 m cube at the origin, as the default room. +std::vector room_statics() { + cvc::ariadne::avatar_static ground; + ground.center = vec3{0.0, 0.0, -0.1}; + ground.half_extents = vec3{10.0, 10.0, 0.1}; + cvc::ariadne::avatar_static cube; + cube.center = vec3{0.0, 0.0, 0.5}; + cube.half_extents = vec3{0.5, 0.5, 0.5}; + return {ground, cube}; +} +body_state at(double x, double y, double z = 0.0) { + body_state s; + s.position = vec3{x, y, z}; + return s; +} +void run(holon &h, int frames) { + for (int i = 0; i < frames; ++i) + h.advance(h.clock().fixed_dt()); +} +} // namespace + +TEST(HolonPhysicsTest, AnAvatarStandsOnTheFloorAndFallsOntoIt) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(4.0, 0.0, 1.0)); // a metre up + run(server, 240); // 2 s + EXPECT_NEAR(server.state("A").position.z, 0.0, 0.02) << "gravity brought it down onto the floor"; + EXPECT_NEAR(server.state("A").position.x, 4.0, 1e-3) << "and it stayed where it landed"; +} + +TEST(HolonPhysicsTest, AnAvatarAcceleratesWithInertia) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(4.0, 0.0)); + run(server, 12); + server.set_intent("A", avatar_intent{vec3{0.0, 2.0, 0.0}}); + run(server, 6); // 50 ms at max_accel 12: ~0.6 m/s, not 2 + const double v = server.state("A").linear_velocity.y; + EXPECT_GT(v, 0.3); + EXPECT_LT(v, 1.0) << "mass and a finite drive: no instant top speed"; + run(server, 60); + EXPECT_NEAR(server.state("A").linear_velocity.y, 2.0, 0.05) << "then it reaches the intent's"; +} + +TEST(HolonPhysicsTest, AnAvatarStopsAtTheCube) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(-3.0, 0.0)); + server.set_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + run(server, 360); // 3 s: far enough to have walked through it + const vec3 p = server.state("A").position; + EXPECT_LT(p.x, -0.5 - 0.25 + 0.02) << "its front face stops at the cube's"; + EXPECT_GT(p.x, -0.85); + EXPECT_NEAR(p.y, 0.0, 0.05); + EXPECT_NEAR(p.z, 0.0, 0.02) << "upright on the floor, not climbing or tipping"; +} + +TEST(HolonPhysicsTest, AvatarsCollideAndPushEachOther) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(-6.0, 5.0)); + server.add_participant("B", false, at(-4.0, 5.0)); // standing still, in A's way + server.set_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + run(server, 240); // 2 s + const vec3 a = server.state("A").position, b = server.state("B").position; + EXPECT_GE(b.x - a.x, 0.5 - 0.02) << "they never pass through each other"; + EXPECT_GT(b.x, -4.0 + 0.5) << "A shoved B out of its way"; + EXPECT_NEAR(a.z, 0.0, 0.02); + EXPECT_NEAR(b.z, 0.0, 0.02); +} + +TEST(HolonPhysicsTest, AnAvatarThatFallsOffTheWorldRespawns) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(9.0, 0.0)); + server.set_intent("A", avatar_intent{vec3{3.0, 0.0, 0.0}}); + run(server, 120); // walks off the edge at x = 10 + EXPECT_LT(server.state("A").position.z, -0.5) << "it fell"; + server.set_intent("A", avatar_intent{}); + run(server, 360); + const vec3 p = server.state("A").position; + EXPECT_NEAR(p.x, 9.0, 0.05) << "and came back where it joined"; + EXPECT_GT(p.z, -0.05); +} + +// The leading client again, now with Jolt on both sides and the cube in the walk: the client's +// world predicts the stop at the cube exactly as the server simulates it, so reconcile has nothing +// to correct. +TEST(HolonPhysicsTest, ClientPredictionMatchesTheServersPhysics) { + holon server(holon_role::server, physics_config()); + holon client(holon_role::client, physics_config()); + if (!server.physics() || !client.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + server.set_statics(room_statics(), 1); + client.set_statics(room_statics(), 1); + server.add_participant("A", false, at(-3.0, 0.2)); + client.add_participant("A", true, at(-3.0, 0.2)); + constexpr int kDelay = 6; + client.set_lead_ticks(2 * kDelay + 2); + const double dt = server.clock().fixed_dt(); + struct msg { + int at; + avatar_intent in; + std::uint64_t tick; + body_state s; + }; + std::vector up, down; + avatar_intent cur; + double corrected = 0.0; + for (int f = 0; f < 600; ++f) { + avatar_intent want = cur; + if (f == 60) + want.move = vec3{2.0, 0.0, 0.0}; // into the cube + if (f == 300) + want.move = vec3{0.0, 2.0, 0.0}; // then along its face + if (f == 420) + want.move = vec3{0.0, 0.0, 0.0}; + if (want.move.x != cur.move.x || want.move.y != cur.move.y) { + cur = want; + client.set_intent("A", cur); + up.push_back({f + kDelay, cur, client.tick() + 1, {}}); + } + client.advance(dt); + for (auto it = up.begin(); it != up.end();) + if (it->at <= f) { + server.queue_intent("A", it->in, it->tick); + it = up.erase(it); + } else { + ++it; + } + server.advance(dt); + down.push_back({f + kDelay, {}, server.tick(), server.state("A")}); + for (auto it = down.begin(); it != down.end();) + if (it->at <= f) { + client.apply_authoritative("A", it->s, it->tick); + it = down.erase(it); + } else { + ++it; + } + const vec3 c = client.take_correction("A"); + if (f >= 60) + corrected += std::fabs(c.x) + std::fabs(c.y) + std::fabs(c.z); + } + EXPECT_LT(server.state("A").position.x, -0.5) << "the server stopped it at the cube"; + EXPECT_LT(corrected, 1e-3) << "and the client predicted the same"; + EXPECT_NEAR(client.state("A").position.x, server.state("A").position.x, 0.05); +} + +// A peer the host registered before its pose arrived (client_pump does, from the roster) is a +// member with nowhere to be yet: no kinematic body at its placeholder -- at the origin that would +// be an invisible obstacle inside the reference cube -- until its first sample puts one there. +// The placeholder here stands in the local avatar's path, so its absence is visible. +TEST(HolonPhysicsTest, PoseLessPeerHasNoBodyUntilFirstSample) { + holon client(holon_role::client, physics_config()); + client.add_participant("A", /*local=*/true, at(-3.0, 5.0)); + client.add_participant("B", /*local=*/false, at(0.0, 5.0), /*pose_known=*/false); + EXPECT_TRUE(client.has_participant("B")) << "a member like any other"; + EXPECT_TRUE(client.pose_known("A")); + EXPECT_FALSE(client.pose_known("B")); + EXPECT_FALSE(client.pose_known("nobody")); + if (!client.physics()) + GTEST_SKIP() << "no Jolt backend in this build"; + client.set_statics(room_statics(), 1); + EXPECT_TRUE(client.has_body("A")) << "the local avatar is predicted from the start"; + EXPECT_FALSE(client.has_body("B")) << "no body at the placeholder"; + EXPECT_FALSE(client.has_body("nobody")); + + // A walks through where the placeholder is: nothing there to stop it. + client.set_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + run(client, 300); // 2.5 s: about 4.8 m + const double walked = client.state("A").position.x; + EXPECT_GT(walked, 1.0) << "on past the placeholder"; + + // B's first sample, 1.5 m ahead of A: a kinematic body at the sample, which A walks into. + const double bx = walked + 1.5; + client.apply_authoritative("B", at(bx, 5.0), client.tick()); + EXPECT_TRUE(client.pose_known("B")); + EXPECT_TRUE(client.has_body("B")); + run(client, 240); + const vec3 a = client.state("A").position; + EXPECT_LT(a.x, bx - 0.5 + 0.02) << "A's front face stops at B's"; + EXPECT_GT(a.x, bx - 0.6); + EXPECT_NEAR(a.y, 5.0, 0.05); +} + +// ── authority: the server's veto and overrides (§5h propose -> veto -> override) ── + +TEST(HolonAuthorityTest, TheVetoCapsSpeedDropsTheVerticalAndRejectsNonFinite) { + holon_config cfg; + cfg.max_speed = 3.0; + holon server(holon_role::server, cfg); + const double nan = std::nan(""); + avatar_intent fast{vec3{30.0, 40.0, 5.0}}; + fast.yaw = 7.0; // more than a turn + const avatar_intent a = server.sanitize_intent(fast); + EXPECT_NEAR(a.move.x, 1.8, 1e-12) << "scaled down to max_speed, direction kept"; + EXPECT_NEAR(a.move.y, 2.4, 1e-12); + EXPECT_EQ(a.move.z, 0.0) << "avatars walk; gravity owns z"; + EXPECT_NEAR(a.yaw, 7.0 - 2.0 * 3.14159265358979323846, 1e-12) << "wrapped to [-pi, pi]"; + avatar_intent bad{vec3{nan, 1.0, 0.0}}; + bad.yaw = std::numeric_limits::infinity(); + const avatar_intent b = server.sanitize_intent(bad); + EXPECT_EQ(b.move.x, 0.0); + EXPECT_EQ(b.move.y, 0.0) << "a non-finite move is no move at all"; + EXPECT_EQ(b.yaw, 0.0); + const avatar_intent ok{vec3{1.0, 1.0, 0.0}}; + const avatar_intent c = server.sanitize_intent(ok); + EXPECT_TRUE(c.move.x == 1.0 && c.move.y == 1.0 && c.yaw == 0.0) + << "an honest intent passes as is"; + + // The server integrates the vetoed intent, and counts the veto. + server.add_participant("A"); + const double dt = server.clock().fixed_dt(); + server.set_intent("A", avatar_intent{vec3{100.0, 0.0, 0.0}}); + for (int i = 0; i < 120; ++i) + server.advance(dt); + EXPECT_NEAR(server.state("A").position.x, 3.0, 1e-6) << "1 s at the 3 m/s cap, not 100"; + server.queue_intent("A", bad, server.tick() + 1); + EXPECT_EQ(server.vetoes(), 2u); +} + +// A client that asks for more than the cap predicts with the vetoed intent too, so it still agrees +// with the server: the veto costs an honest client nothing, and a client running a modified +// prediction is simply overruled on reconcile. +TEST(HolonAuthorityTest, AClientPredictsTheVetoedIntent) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", true); + const double dt = server.clock().fixed_dt(); + const avatar_intent greedy{vec3{50.0, 0.0, 0.0}}; + server.set_intent("A", greedy); + client.set_intent("A", greedy); + for (int f = 1; f <= 60; ++f) { + server.advance(dt); + client.advance(dt); + client.apply_authoritative("A", server.state("A"), server.tick()); + } + const vec3 c = client.take_correction("A"); + EXPECT_EQ(c.x, 0.0); + EXPECT_DOUBLE_EQ(client.state("A").position.x, server.state("A").position.x); + EXPECT_NEAR(server.state("A").position.x, 0.5 * 4.0, 1e-6) << "0.5 s at the default 4 m/s cap"; +} + +TEST(HolonAuthorityTest, TheServerTeleportsAnAvatarAndTheClientFollows) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", true); + const double dt = server.clock().fixed_dt(); + server.advance(dt); + client.advance(dt); + EXPECT_FALSE(client.override_state("A", at(9.0, 9.0))) << "only the authority overrides"; + ASSERT_TRUE(server.override_state("A", at(6.0, -2.0))); + EXPECT_FALSE(server.override_state("nobody", at(0.0, 0.0))); + server.advance(dt); + client.advance(dt); + client.apply_authoritative("A", server.state("A"), server.tick()); + EXPECT_DOUBLE_EQ(client.state("A").position.x, 6.0) << "the owner reconciles to the override"; + EXPECT_DOUBLE_EQ(client.state("A").position.y, -2.0); +} + +TEST(HolonAuthorityTest, AReMassedAvatarIsHarderToPush) { + // How far a 70 kg walker shoves a still avatar of mass `kg` in 2 s. + const auto shove = [](double kg) { + holon server(holon_role::server, physics_config()); + if (!server.physics()) + return -1.0; + server.set_statics(room_statics(), 1); + server.add_participant("A", false, at(-6.0, 5.0)); + server.add_participant("B", false, at(-4.0, 5.0)); + EXPECT_TRUE(server.set_mass("B", kg)); + server.set_intent("A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + run(server, 240); + return server.state("B").position.x - (-4.0); + }; + const double light = shove(70.0), heavy = shove(2000.0); + if (light < 0.0) + GTEST_SKIP() << "no Jolt backend in this build"; + EXPECT_GT(light, 0.5) << "an equal mass is shoved aside"; + EXPECT_LT(heavy, 0.5 * light) << "a heavy one barely gives way"; + holon client(holon_role::client, physics_config()); + client.add_participant("A", true); + EXPECT_FALSE(client.set_mass("A", 10.0)) << "only the authority re-masses"; +} diff --git a/src/cvc/tests/room_aoi_relay_test.cpp b/src/cvc/tests/room_aoi_relay_test.cpp new file mode 100644 index 000000000..bd8858655 --- /dev/null +++ b/src/cvc/tests/room_aoi_relay_test.cpp @@ -0,0 +1,618 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +// The relay's area-of-interest driver against transports that only record what they are asked: +// the participants' filters land in every registry and leave with the participant, a pose push +// carries the holon's current tick as its last entry and goes out before the step's live writes, +// the media gate thumbnails a middle camera, teardown takes everything back while a reader's copy +// of the gate stays safe to call, and with no driver the room step is the plain drain-and-publish +// fan-out the relay always did. + +#include +#include +#include +#include +#include +#include // room_join, room_leave, room_participant_path +#include +#include // server_pump +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ra = cvc::ariadne::conference; +using cvc::ariadne::holon; +using cvc::ariadne::holon_role; +using snapshot_entry = cvc::state_transport::snapshot_entry; + +namespace { + +// A transport and media endpoint that does nothing but record: each publish and push goes into a +// log shared with its siblings (so a test sees the order of calls across planes), the mutations +// and pushes are kept, and the last media gate it was given is held like a real endpoint holds +// it. push_snapshot_to reports one connection written for a pid in `connected`, as the transport +// holding that participant's connection would. +class recording_transport final : public cvc::state_transport, public cvc::media_endpoint { +public: + struct push { + std::string node_id; + std::vector entries; + }; + + recording_transport(std::string name, std::vector &log) + : _name(std::move(name)), _log(log) {} + + const std::string &name() const { return _name; } + + std::vector published; + std::vector pushes; + std::set connected; + media_gate_fn gate; + int gate_sets = 0; + + void register_shard(cvc::state_cluster_shard *s) override { _shards.push_back(s); } + void unregister_shard(cvc::state_cluster_shard *s) override { + for (auto it = _shards.begin(); it != _shards.end();) + it = *it == s ? _shards.erase(it) : it + 1; + } + publish_stats publish(const cvc::state_mutation &m) override { + _log.push_back(_name + " publish " + m.path); + published.push_back(m); + return {}; + } + std::size_t pump_shard(cvc::state_cluster_shard &s) override { + const std::vector pending = s.drain_local(); + for (const cvc::state_mutation &m : pending) + publish(m); + return pending.size(); + } + std::size_t pump_all() override { + std::size_t n = 0; + for (cvc::state_cluster_shard *s : _shards) + n += pump_shard(*s); + return n; + } + void flush() override {} + std::size_t push_snapshot_to(const std::string &node_id, + const std::vector &entries) override { + _log.push_back(_name + " push " + node_id); + pushes.push_back({node_id, entries}); + return connected.count(node_id) ? 1 : 0; + } + + void fan_media(const cvc::media_frame &, const void *) override {} + void set_media_sink(media_sink_fn) override {} + const char *media_protocol() const noexcept override { return "recording"; } + void set_media_gate(media_gate_fn g) override { + gate = std::move(g); + ++gate_sets; + } + + // The last push to NODE_ID, or null. + const push *push_to(const std::string &node_id) const { + for (auto it = pushes.rbegin(); it != pushes.rend(); ++it) + if (it->node_id == node_id) + return &*it; + return nullptr; + } + +private: + std::string _name; + std::vector &_log; + std::vector _shards; +}; + +struct spot { + const char *pid; + double x; + double y; +}; + +// The relay's side of a room as arisrv runs it: an app, a whole-tree shard that takes only the +// room tree, and a server holon (kinematic, so a participant left alone stands exactly still). +struct relay_room { + cvc::app app; + cvc::state_cluster_shard shard{app, "conf", "server"}; + holon server{holon_role::server}; + const std::string room = "r"; + + relay_room() { + shard.add_interest("conf"); + shard.set_enforce_interest(true); + shard.attach(); + } + ~relay_room() { shard.detach(); } + + // Advance the room TICKS holon ticks (at most 60, the holon's catch-up per advance) and write + // the poses, as one arisrv room step does. + void pump(int ticks = 1) { + cvc::ariadne::server_pump(server, app, room, ticks * server.clock().fixed_dt()); + } + + // Join each participant and stand it at its spot: the first pump adds the newcomers on the + // spawn ring, the second writes the poses the overrides put them at. + void place(std::initializer_list where) { + for (const spot &s : where) + ra::room_join(app, room, s.pid); + pump(); + for (const spot &s : where) + move(s); + pump(); + } + + void move(const spot &s) { + cvc::physics::body_state b; + b.position = {s.x, s.y, 0.0}; + ASSERT_TRUE(server.override_state(s.pid, b)) << s.pid; + } + + // conf.rooms.r.participants.. + std::string leaf(const std::string &pid, std::initializer_list rest) const { + std::string s = ra::room_participant_path(room, pid); + for (const char *seg : rest) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; + } +}; + +cvc::media_frame frame(const char *publisher, const char *stream_id, const char *codec) { + cvc::media_frame f; + f.origin_node_id = publisher; + f.stream_id = stream_id; + f.codec = codec; + return f; +} + +std::size_t first_with(const std::vector &log, const std::string &word) { + for (std::size_t i = 0; i < log.size(); ++i) + if (log[i].find(word) != std::string::npos) + return i; + return log.size(); +} + +std::size_t last_with(const std::vector &log, const std::string &word) { + for (std::size_t i = log.size(); i-- > 0;) + if (log[i].find(word) != std::string::npos) + return i; + return log.size(); +} + +} // namespace + +// ── registries ────────────────────────────────────────────────────────────── + +// Hall preset: A and B 4 m apart (inner), C 30 m from both (outer). +TEST(RoomAoiRelayTest, FiltersInstalledOnEveryTransport) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 30.0}}); + std::vector log; + // Declared before the driver: what it is given must outlive it. + recording_transport grpc("grpc", log), ws("ws", log), late("late", log); + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::hall_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_transport(&ws); + aoi.add_media_endpoint(&grpc); + aoi.add_media_endpoint(&ws); + ra::relay_room_step(rr.shard, {&grpc, &ws}, &aoi, rr.server, 0.0, false); + + for (recording_transport *tp : {&grpc, &ws}) { + SCOPED_TRACE(tp->name()); + cvc::state_peer_registry ® = tp->peers(); + for (const char *pid : {"A", "B", "C"}) { + const cvc::state_peer_registry::delivery_view v = reg.view_of(pid); + EXPECT_TRUE(v.known) << pid; + EXPECT_EQ(v.subscriptions, std::vector{"conf"}) << pid; + EXPECT_NE(v.filter, nullptr) << pid; + } + // The snapshot's rules, through the registry every send consults. + EXPECT_TRUE(reg.should_deliver("A", rr.leaf("B", {"pose", "pos", "x"}))) << "inner"; + EXPECT_FALSE(reg.should_deliver("A", rr.leaf("C", {"pose", "pos", "x"}))) << "outer"; + EXPECT_FALSE(reg.should_deliver("A", rr.leaf("C", {"pose", "server_tick"}))); + EXPECT_TRUE(reg.should_deliver("A", rr.leaf("A", {"intent", "move", "x"}))) << "its own"; + EXPECT_FALSE(reg.should_deliver("A", rr.leaf("B", {"intent", "move", "x"}))); + EXPECT_TRUE(reg.should_deliver("A", rr.leaf("C", {"presence", "joined"}))) << "the roster"; + EXPECT_FALSE(reg.should_deliver("A", "ui.docs.x")) << "registered for the room tree only"; + EXPECT_TRUE(reg.should_deliver("peer-server", rr.leaf("C", {"pose", "pos", "x"}))) + << "a connection the driver never registered still gets everything"; + EXPECT_TRUE(static_cast(tp->gate)); + } + + // A transport added later gets the current filters at once. + aoi.add_transport(&late); + aoi.add_transport(&late); // a repeat is ignored + EXPECT_NE(late.peers().view_of("C").filter, nullptr); + EXPECT_EQ(late.peers().size(), 3u); + + const ra::room_aoi_relay::stats st = aoi.take_stats(); + EXPECT_EQ(st.inner, 1u); + EXPECT_EQ(st.middle, 0u); + EXPECT_EQ(st.outer, 2u); +} + +TEST(RoomAoiRelayTest, DepartedPidRemovedFromRegistries) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 3.0}}); + std::vector log; + recording_transport grpc("grpc", log), ws("ws", log); + // Something else registered B on the gRPC side before the driver started. + grpc.peers().add_peer("B", "conf", "grpc://b"); + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::conference_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_transport(&ws); + const std::vector planes{&grpc, &ws}; + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.0, false); + for (recording_transport *tp : {&grpc, &ws}) + for (const char *pid : {"A", "B", "C"}) + ASSERT_NE(tp->peers().view_of(pid).filter, nullptr) << tp->name() << " " << pid; + EXPECT_TRUE(grpc.peers().view_of("B").subscriptions.empty()) << "its registration is kept"; + + ra::room_leave(rr.app, rr.room, "B"); + ra::room_leave(rr.app, rr.room, "C"); + rr.pump(); // the holon drops them + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.05, false); + + for (recording_transport *tp : {&grpc, &ws}) { + EXPECT_FALSE(tp->peers().has_peer("C")) << tp->name(); + EXPECT_NE(tp->peers().view_of("A").filter, nullptr) << tp->name(); + } + EXPECT_FALSE(ws.peers().has_peer("B")); + // The registration the driver did not make stays, without the driver's filter. + const cvc::state_peer_registry::delivery_view b = grpc.peers().view_of("B"); + EXPECT_TRUE(b.known); + EXPECT_EQ(b.filter, nullptr); + + // A rejoin is registered and filtered again. + ra::room_join(rr.app, rr.room, "C"); + rr.pump(); + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.1, false); + for (recording_transport *tp : {&grpc, &ws}) + EXPECT_NE(tp->peers().view_of("C").filter, nullptr) << tp->name(); +} + +// The room tree takes any child name, so a client can join under a pid that is no C identifier +// ("user-1"); the registry refuses such a node id (add_peer throws). The driver must neither throw +// out of the room step -- that would take the relay down -- nor leave the valid pids unfiltered. +TEST(RoomAoiRelayTest, InvalidPidIsSkippedNotFatal) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}}); + ra::room_join(rr.app, rr.room, "user-1"); + rr.pump(); + ASSERT_TRUE(rr.server.has_participant("user-1")); + std::vector log; + recording_transport grpc("grpc", log), ws("ws", log); + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::conference_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_transport(&ws); + ASSERT_NO_THROW(ra::relay_room_step(rr.shard, {&grpc, &ws}, &aoi, rr.server, 0.0, false)); + for (recording_transport *tp : {&grpc, &ws}) { + SCOPED_TRACE(tp->name()); + EXPECT_FALSE(tp->peers().has_peer("user-1")); + EXPECT_NE(tp->peers().view_of("A").filter, nullptr); + EXPECT_NE(tp->peers().view_of("B").filter, nullptr); + // As a subject it is filtered like anyone else. + EXPECT_FALSE(tp->peers().should_deliver("A", rr.leaf("user-1", {"intent", "move", "x"}))); + } + const auto snap = aoi.snapshot(); + EXPECT_EQ(snap->participants().size(), 3u); +} + +// ── pose pushes ───────────────────────────────────────────────────────────── + +// C stands still 30 m away, so write_pose leaves its leaves and server_tick alone until the +// heartbeat (about a second) and the tree's tick falls behind the holon's. The push C owes its new +// outer receivers carries the holon's tick instead, after the leaves. +TEST(RoomAoiRelayTest, PushCarriesFreshServerTickLast) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 30.0}}); + rr.pump(59); + rr.pump(59); + const std::string c_pose = rr.leaf("C", {"pose"}); + const std::string c_tick = rr.leaf("C", {"pose", "server_tick"}); + cvc::state *tree_tick = rr.app.root().findDescendant(c_tick); + ASSERT_NE(tree_tick, nullptr); + const std::string tree_value = tree_tick->value(); + ASSERT_GE(rr.server.tick() - std::strtoull(tree_value.c_str(), nullptr, 10), 100u); + + std::vector log; + recording_transport grpc("grpc", log), ws("ws", log); + grpc.connected = {"A", "B", "C"}; + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::hall_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_transport(&ws); + ra::relay_room_step(rr.shard, {&grpc, &ws}, &aoi, rr.server, 0.0, true); + const std::string now = std::to_string(static_cast(rr.server.tick())); + + const recording_transport::push *to_a = grpc.push_to("A"); + ASSERT_NE(to_a, nullptr) << "a new outer pair is pushed at once"; + ASSERT_EQ(to_a->entries.size(), 14u) << "C's 13 pose leaves and its tick, no intermediate nodes"; + EXPECT_EQ(to_a->entries.back().path, c_tick); + EXPECT_EQ(to_a->entries.back().string_value, now); + for (std::size_t i = 0; i + 1 < to_a->entries.size(); ++i) { + const snapshot_entry &e = to_a->entries[i]; + EXPECT_EQ(e.path.rfind(c_pose + cvc::state::SEPARATOR, 0), 0u) << e.path; + EXPECT_NE(e.path, c_tick); + EXPECT_FALSE(e.string_value.empty()) << e.path; + if (e.path == rr.leaf("C", {"pose", "pos", "y"})) + EXPECT_DOUBLE_EQ(std::strtod(e.string_value.c_str(), nullptr), 30.0); + } + EXPECT_EQ(tree_tick->value(), tree_value) << "the relay's own tree keeps its tick"; + + // C gets both of its new outer subjects in one frame, each one's tick after its own leaves. + const recording_transport::push *to_c = grpc.push_to("C"); + ASSERT_NE(to_c, nullptr); + ASSERT_EQ(to_c->entries.size(), 28u); + EXPECT_EQ(to_c->entries[13].path, rr.leaf("A", {"pose", "server_tick"})); + EXPECT_EQ(to_c->entries[13].string_value, now); + EXPECT_EQ(to_c->entries[27].path, rr.leaf("B", {"pose", "server_tick"})); + EXPECT_EQ(to_c->entries[27].string_value, now); + // B is inner with A, so B is owed C alone. + const recording_transport::push *to_b = grpc.push_to("B"); + ASSERT_NE(to_b, nullptr); + EXPECT_EQ(to_b->entries.size(), 14u); + // Every plane is asked; only the one holding the connection writes. + EXPECT_NE(ws.push_to("A"), nullptr); + EXPECT_EQ(grpc.pushes.size(), 3u); + + const ra::room_aoi_relay::stats st = aoi.take_stats(); + EXPECT_EQ(st.pushes, 3u); + EXPECT_EQ(st.push_entries, 14u + 14u + 28u); + EXPECT_EQ(aoi.take_stats().pushes, 0u) << "counters restart at each take"; +} + +TEST(RoomAoiRelayTest, PushesPrecedeDrainPublishes) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 30.0}}); + std::vector log; + recording_transport grpc("grpc", log), ws("ws", log); + grpc.connected = {"A", "B"}; + ws.connected = {"C"}; + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::hall_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_transport(&ws); + const std::vector planes{&grpc, &ws}; + + // The first step owes the new outer pairs their pushes and drains the joins and poses. + const std::size_t drained = ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.0, false); + EXPECT_GT(drained, 0u); + ASSERT_LT(first_with(log, " push "), log.size()); + ASSERT_LT(first_with(log, " publish "), log.size()); + EXPECT_LT(last_with(log, " push "), first_with(log, " publish ")); + // Each drained mutation went to both planes, once each, in drain order. + ASSERT_EQ(grpc.published.size(), drained); + ASSERT_EQ(ws.published.size(), drained); + for (std::size_t i = 0; i < drained; ++i) + EXPECT_EQ(grpc.published[i].sequence, ws.published[i].sequence); + + // C walks while outer: past the push interval its move is pushed, still ahead of the live + // writes of the same step. + log.clear(); + rr.move({"C", 0.0, 31.0}); + rr.pump(40); + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.4, false); + ASSERT_NE(ws.push_to("A"), nullptr); + ASSERT_LT(first_with(log, " push "), log.size()); + ASSERT_LT(first_with(log, " publish "), log.size()); + EXPECT_LT(last_with(log, " push "), first_with(log, " publish ")); +} + +// ── the room step without a driver ────────────────────────────────────────── + +// Two identical rooms: one fanned the way the relay did before (drain once, publish each mutation +// to both planes), the other through relay_room_step with no driver and no trim. Same calls, same +// mutations, same journal; nothing registered, pushed or gated. One plane matches a transport's +// own pump_all. +TEST(RoomAoiRelayTest, NullAoiEqualsFanLocalWrites) { + relay_room before, after; + std::vector log_before, log_after; + recording_transport b1("grpc", log_before), b2("ws", log_before); + recording_transport a1("grpc", log_after), a2("ws", log_after); + for (int i = 0; i < 3; ++i) { + for (relay_room *rr : {&before, &after}) { + if (i == 0) + rr->place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}}); + else + rr->move({"A", -2.0, static_cast(i)}); + rr->pump(6); + } + for (const cvc::state_mutation &m : before.shard.drain_local()) { + b1.publish(m); + b2.publish(m); + } + ra::relay_room_step(after.shard, {&a1, &a2}, nullptr, after.server, 0.0, false); + } + EXPECT_EQ(log_after, log_before); + ASSERT_EQ(a1.published.size(), b1.published.size()); + ASSERT_GT(a1.published.size(), 0u); + for (std::size_t i = 0; i < a1.published.size(); ++i) { + const cvc::state_mutation &x = a1.published[i], &y = b1.published[i]; + EXPECT_EQ(x.path, y.path); + EXPECT_EQ(x.op, y.op); + EXPECT_EQ(x.string_value, y.string_value); + EXPECT_EQ(x.type_name, y.type_name); + EXPECT_EQ(x.sequence, y.sequence); + EXPECT_EQ(x.origin_node_id, y.origin_node_id); + } + EXPECT_EQ(after.shard.journal().size(), before.shard.journal().size()) << "nothing trimmed"; + EXPECT_GT(after.shard.journal().size(), 0u); + EXPECT_TRUE(a1.pushes.empty()); + EXPECT_TRUE(a2.pushes.empty()); + EXPECT_EQ(a1.peers().size(), 0u); + EXPECT_EQ(a2.peers().size(), 0u); + EXPECT_EQ(a1.gate_sets, 0); + + // One plane: the transport's own pump_all of the one shard. + relay_room one, two; + std::vector log_pump, log_step; + recording_transport p("grpc", log_pump), q("grpc", log_step); + p.register_shard(&one.shard); + for (relay_room *rr : {&one, &two}) + rr->place({{"A", 1.0, 1.0}}); + const std::size_t pumped = p.pump_all(); + EXPECT_EQ(ra::relay_room_step(two.shard, {&q}, nullptr, two.server, 0.0, false), pumped); + EXPECT_EQ(log_step, log_pump); + p.unregister_shard(&one.shard); +} + +TEST(RoomAoiRelayTest, TrimJournalErasesOnlyWhatWasPublished) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}}); + std::vector log; + recording_transport grpc("grpc", log); + const std::size_t first = ra::relay_room_step(rr.shard, {&grpc}, nullptr, rr.server, 0.0, true); + EXPECT_GT(first, 0u); + EXPECT_EQ(rr.shard.journal().size(), 0u); + rr.move({"A", -2.0, 1.0}); + rr.pump(); + const std::size_t second = ra::relay_room_step(rr.shard, {&grpc}, nullptr, rr.server, 0.05, true); + EXPECT_GT(second, 0u) << "writes after a trim still drain"; + EXPECT_EQ(grpc.published.size(), first + second); + EXPECT_EQ(rr.shard.journal().size(), 0u); + EXPECT_GT(grpc.published.back().sequence, grpc.published.front().sequence); +} + +// ── media gate ────────────────────────────────────────────────────────────── + +// Hall preset: C 14 m from A and B (middle). Its camera reaches them as thumbnails, its mic in +// full; walking in turns the camera to full rate, and walking back out sends the first thumbnail +// at once rather than waiting out the interval of the last one. +TEST(RoomAoiRelayTest, MiddleCameraIsThumbnailedAndResumesAtOnce) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 14.0}}); + std::vector log; + recording_transport grpc("grpc", log); + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, ra::hall_aoi_params()); + aoi.add_transport(&grpc); + aoi.add_media_endpoint(&grpc); + const std::vector planes{&grpc}; + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 0.0, false); + ASSERT_TRUE(static_cast(grpc.gate)); + const cvc::media_frame cam = frame("C", "camera", "mjpeg"); + const cvc::media_frame mic = frame("C", "mic", "f32"); + + // A burst of 30 frames, far inside the 0.5 s thumbnail interval: the first goes, the rest wait. + const auto t0 = std::chrono::steady_clock::now(); + int cams = 0, mics = 0, inner_cams = 0; + for (int i = 0; i < 30; ++i) { + cams += grpc.gate(cam, "A") ? 1 : 0; + mics += grpc.gate(mic, "A") ? 1 : 0; + inner_cams += grpc.gate(frame("B", "camera", "mjpeg"), "A") ? 1 : 0; + } + if (std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(400)) + EXPECT_EQ(cams, 1); + EXPECT_GE(cams, 1); + EXPECT_EQ(mics, 30) << "a middle peer is heard in full"; + EXPECT_EQ(inner_cams, 30) << "an inner peer is seen in full"; + EXPECT_TRUE(grpc.gate(cam, "C")) << "oneself"; + + // In (an upgrade is immediate): full rate. + rr.move({"C", 0.0, 5.0}); + rr.pump(); + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 1.0, false); + for (int i = 0; i < 5; ++i) + EXPECT_TRUE(grpc.gate(cam, "A")); + // Back out (a downgrade waits out the 1 s dwell): a thumbnail again, the first one at once. + rr.move({"C", 0.0, 14.0}); + rr.pump(); + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 2.0, false); + EXPECT_TRUE(grpc.gate(cam, "A")) << "still inner inside the dwell"; + ra::relay_room_step(rr.shard, planes, &aoi, rr.server, 3.5, false); + ASSERT_EQ(aoi.snapshot()->tier("A", "C"), ra::aoi_tier::middle); + EXPECT_TRUE(grpc.gate(cam, "A")) << "the first thumbnail after the change"; + EXPECT_FALSE(grpc.gate(cam, "A")); + + const ra::room_aoi_relay::stats st = aoi.take_stats(); + EXPECT_GE(st.media_thumb, 2u); + EXPECT_GE(st.media_dropped, 1u); + EXPECT_GE(st.media_full, 60u); + EXPECT_EQ(st.middle, 2u); + EXPECT_EQ(st.inner, 1u); +} + +// enabled = false: a pass-through that registers nothing, pushes nothing and gates nothing. +TEST(RoomAoiRelayTest, DisabledParamsArePassThrough) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 30.0}}); + std::vector log; + recording_transport grpc("grpc", log); + grpc.connected = {"A", "B", "C"}; + ra::aoi_params off = ra::hall_aoi_params(); + off.enabled = false; + ra::room_aoi_relay aoi(rr.app, rr.shard, rr.room, off); + aoi.add_transport(&grpc); + aoi.add_media_endpoint(&grpc); + for (int i = 0; i < 3; ++i) { + ra::relay_room_step(rr.shard, {&grpc}, &aoi, rr.server, 0.25 * i, true); + rr.pump(30); + } + EXPECT_EQ(grpc.peers().size(), 0u); + EXPECT_TRUE(grpc.pushes.empty()); + ASSERT_TRUE(static_cast(grpc.gate)); + EXPECT_TRUE(grpc.gate(frame("C", "camera", "mjpeg"), "A")); + EXPECT_FALSE(grpc.published.empty()); +} + +// ── teardown ──────────────────────────────────────────────────────────────── + +TEST(RoomAoiRelayTest, TeardownClearsGatesAndFilters) { + relay_room rr; + rr.place({{"A", -2.0, 0.0}, {"B", 2.0, 0.0}, {"C", 0.0, 30.0}}); + std::vector log; + recording_transport grpc("grpc", log), ws("ws", log); + grpc.peers().add_peer("B", "conf", "grpc://b"); + auto aoi = std::make_unique(rr.app, rr.shard, rr.room, ra::hall_aoi_params()); + aoi->add_transport(&grpc); + aoi->add_transport(&ws); + aoi->add_media_endpoint(&grpc); + aoi->add_media_endpoint(&ws); + aoi->add_media_endpoint(&ws); // a repeat is ignored + ra::relay_room_step(rr.shard, {&grpc, &ws}, aoi.get(), rr.server, 0.0, false); + EXPECT_EQ(ws.gate_sets, 1); + + // A publisher's reader thread copied the gate just before the driver went away. + const cvc::media_endpoint::media_gate_fn held = grpc.gate; + ASSERT_TRUE(static_cast(held)); + const cvc::media_frame cam = frame("C", "camera", "mjpeg"); + EXPECT_FALSE(held(cam, "A")) << "C is outer to A"; + EXPECT_TRUE(held(frame("B", "camera", "mjpeg"), "A")) << "B is inner to A"; + + aoi.reset(); + for (recording_transport *tp : {&grpc, &ws}) { + SCOPED_TRACE(tp->name()); + EXPECT_FALSE(static_cast(tp->gate)) << "the gate is cleared"; + EXPECT_EQ(tp->gate_sets, 2); + EXPECT_FALSE(tp->peers().has_peer("A")); + EXPECT_FALSE(tp->peers().has_peer("C")); + } + EXPECT_FALSE(ws.peers().has_peer("B")); + const cvc::state_peer_registry::delivery_view b = grpc.peers().view_of("B"); + EXPECT_TRUE(b.known) << "a registration the driver did not make stays"; + EXPECT_EQ(b.filter, nullptr); + + // The reader's copy still runs, and now delivers everything. + EXPECT_TRUE(held(cam, "A")); + EXPECT_TRUE(held(frame("A", "mic", "f32"), "C")); +} diff --git a/src/cvc/tests/room_aoi_test.cpp b/src/cvc/tests/room_aoi_test.cpp new file mode 100644 index 000000000..314e2e46a --- /dev/null +++ b/src/cvc/tests/room_aoi_test.cpp @@ -0,0 +1,728 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +// The room's spatial area of interest: per-pair tiers with hysteresis, the snapshot's deny-only +// state rules and per-frame media decisions, the pose push schedule, the presets (the conference +// one must leave the default room exactly as it was), the client audio curve, and the replicated +// parameters. +// +// comes first, in its own preprocessor block so include sorting can never move it +// after the header it guards: its minwindef.h defines near and far, which is why the tiers are +// inner / middle / outer. The guards are the ones the library uses around . +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + +#include +#include +#include +#include +#include // room_participant_path +#include // default_scene_objects: the room to cover +#include // holon_config: the peer span pushes must fit +#include // room_holon_config +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ra = cvc::ariadne::conference; +using ra::aoi_media; +using ra::aoi_tier; + +namespace { + +const std::string kRoom = "r1"; + +ra::room_aoi::position at(const char *pid, double x, double y = 0.0) { return {pid, x, y}; } + +// conf.rooms.r1.participants.[....] +std::string ppath(const std::string &pid, std::initializer_list segs = {}) { + std::string s = ra::room_participant_path(kRoom, pid); + for (const char *seg : segs) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; +} + +// conf.rooms.r1[....] +std::string rpath(std::initializer_list segs) { + std::string s = + std::string("conf") + cvc::state::SEPARATOR + "rooms" + cvc::state::SEPARATOR + kRoom; + for (const char *seg : segs) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; +} + +using push_map = std::map>; +push_map as_map(const std::vector &pushes) { + push_map m; + for (const ra::aoi_push &p : pushes) + m[p.receiver] = p.subjects; + return m; +} + +// The hall preset with a at the origin and, along +x: b inner (5 m), c middle (14 m) and d outer +// (40 m) from a; e (1 m) joins and then leaves, so it is departed. Among themselves b-c is 9 m +// (inner), b-d 35 m and c-d 26 m (outer). +void place_abcde(ra::room_aoi &aoi) { + aoi.update({at("a", 0), at("b", 5), at("c", 14), at("d", 40), at("e", 1)}, 0, 0.0); + aoi.update({at("a", 0), at("b", 5), at("c", 14), at("d", 40)}, 6, 0.05); +} + +} // namespace + +// ── tiers ─────────────────────────────────────────────────────────────────── + +TEST(RoomAoiTest, InitialTierUsesEnterRadii) { + const ra::aoi_params h = ra::hall_aoi_params(); + EXPECT_EQ(ra::initial_tier(0.0, h), aoi_tier::inner); + EXPECT_EQ(ra::initial_tier(10.0, h), aoi_tier::inner); + EXPECT_EQ(ra::initial_tier(11.0, h), aoi_tier::middle) << "inside inner's exit, not its enter"; + EXPECT_EQ(ra::initial_tier(16.0, h), aoi_tier::middle); + EXPECT_EQ(ra::initial_tier(17.0, h), aoi_tier::outer) << "inside middle's exit, not its enter"; + EXPECT_EQ(ra::initial_tier(1000.0, h), aoi_tier::outer); + + const ra::aoi_params c = ra::conference_aoi_params(); + EXPECT_EQ(ra::initial_tier(30.0, c), aoi_tier::inner); + EXPECT_EQ(ra::initial_tier(31.0, c), aoi_tier::middle); + EXPECT_EQ(ra::initial_tier(40.0, c), aoi_tier::middle); + EXPECT_EQ(ra::initial_tier(41.0, c), aoi_tier::outer); +} + +TEST(RoomAoiTest, StepTierHysteresisTable) { + const ra::aoi_params h = ra::hall_aoi_params(); // 10/12 inner, 16/18 middle, 1 s dwell + struct row { + aoi_tier cur; + double d_m, now_s, beyond_in; + aoi_tier want; + double beyond_out; + const char *what; + }; + const row rows[] = { + {aoi_tier::inner, 11.0, 0.0, -1.0, aoi_tier::inner, -1.0, "inside the exit band"}, + {aoi_tier::inner, 12.5, 0.0, -1.0, aoi_tier::inner, 0.0, "beyond exit: the dwell starts"}, + {aoi_tier::inner, 12.5, 0.5, 0.0, aoi_tier::inner, 0.0, "still dwelling"}, + {aoi_tier::inner, 12.5, 1.0, 0.0, aoi_tier::middle, -1.0, "dwell passed: one tier down"}, + {aoi_tier::inner, 11.5, 0.6, 0.0, aoi_tier::inner, -1.0, "back inside: the dwell resets"}, + {aoi_tier::inner, 30.0, 1.0, 0.0, aoi_tier::middle, 1.0, "one tier; middle's dwell starts"}, + {aoi_tier::middle, 30.0, 1.5, 1.0, aoi_tier::middle, 1.0, "middle to outer waits"}, + {aoi_tier::middle, 30.0, 2.0, 1.0, aoi_tier::outer, -1.0, "middle to outer after its dwell"}, + {aoi_tier::middle, 17.0, 5.0, -1.0, aoi_tier::middle, -1.0, "inside middle's exit"}, + {aoi_tier::middle, 20.0, 0.0, -1.0, aoi_tier::middle, 0.0, "beyond middle's exit"}, + {aoi_tier::middle, 20.0, 0.99, 0.0, aoi_tier::middle, 0.0, "just short of the dwell"}, + {aoi_tier::middle, 20.0, 1.0, 0.0, aoi_tier::outer, -1.0, "the dwell passed"}, + {aoi_tier::middle, 9.0, 0.7, 0.5, aoi_tier::inner, -1.0, "upgrades are immediate"}, + {aoi_tier::middle, 10.0, 0.0, -1.0, aoi_tier::inner, -1.0, "the enter radius is inclusive"}, + {aoi_tier::outer, 5.0, 0.0, -1.0, aoi_tier::inner, -1.0, "outer to inner in one step"}, + {aoi_tier::outer, 15.0, 0.0, -1.0, aoi_tier::middle, -1.0, "outer to middle"}, + {aoi_tier::outer, 17.0, 0.0, -1.0, aoi_tier::outer, -1.0, "outside middle's enter"}, + {aoi_tier::outer, 1000.0, 9.0, -1.0, aoi_tier::outer, -1.0, "outer has no exit"}, + }; + for (const row &r : rows) { + double beyond = r.beyond_in; + EXPECT_EQ(ra::step_tier(r.cur, r.d_m, r.now_s, beyond, h), r.want) << r.what; + EXPECT_DOUBLE_EQ(beyond, r.beyond_out) << r.what; + } + + // A pair pacing across inner's exit never leaves inner while each excursion is shorter than + // the dwell. + aoi_tier t = aoi_tier::inner; + double beyond = -1.0; + for (int i = 0; i < 100; ++i) { + const double now = 0.1 * i; + t = ra::step_tier(t, (i / 5) % 2 ? 12.5 : 11.5, now, beyond, h); // 0.5 s out, 0.5 s in + EXPECT_EQ(t, aoi_tier::inner) << "t=" << now; + } +} + +TEST(RoomAoiTest, UpdateRebuildsSnapshotOnlyOnChange) { + ra::room_aoi aoi(kRoom, ra::hall_aoi_params()); + EXPECT_EQ(aoi.snapshot()->generation(), 0u); + EXPECT_FALSE(aoi.update({}, 0, 0.0)) << "nobody here: nothing to decide"; + + EXPECT_TRUE(aoi.update({at("a", 0), at("b", 5)}, 0, 0.0)); + const std::shared_ptr s1 = aoi.snapshot(); + EXPECT_EQ(s1->generation(), 1u); + EXPECT_EQ(s1->tier("a", "b"), aoi_tier::inner); + EXPECT_EQ(s1->participants(), (std::vector{"a", "b"})); + + // The same positions, and a move that stays inside the tier, rebuild nothing. + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 5)}, 6, 0.05)); + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 11.5)}, 12, 0.1)); + EXPECT_EQ(aoi.snapshot().get(), s1.get()); + + // Beyond inner's exit nothing changes until the dwell has passed. + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 14)}, 18, 0.2)); + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 14)}, 96, 0.9)); + EXPECT_EQ(aoi.snapshot().get(), s1.get()); + EXPECT_TRUE(aoi.update({at("a", 0), at("b", 14)}, 144, 1.25)); + const std::shared_ptr s2 = aoi.snapshot(); + EXPECT_NE(s2.get(), s1.get()); + EXPECT_EQ(s2->generation(), 2u); + EXPECT_EQ(s2->tier("a", "b"), aoi_tier::middle); + EXPECT_EQ(s2->tier("b", "a"), aoi_tier::middle); + EXPECT_EQ(s1->tier("a", "b"), aoi_tier::inner) << "a published snapshot never changes"; + + // A join and a leave each rebuild; the leaver is departed until it rejoins. + EXPECT_TRUE(aoi.update({at("a", 0), at("b", 14), at("c", 1)}, 150, 1.3)); + EXPECT_EQ(aoi.snapshot()->generation(), 3u); + EXPECT_TRUE(aoi.update({at("a", 0), at("b", 14)}, 156, 1.35)); + EXPECT_EQ(aoi.snapshot()->generation(), 4u); + EXPECT_EQ(aoi.departed(), (std::set{"c"})); + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 14)}, 162, 1.4)); + EXPECT_TRUE(aoi.update({at("a", 0), at("b", 14), at("c", 1)}, 168, 1.45)); + EXPECT_TRUE(aoi.departed().empty()); + + // A pid listed twice counts once, at its first position. + EXPECT_FALSE(aoi.update({at("a", 0), at("b", 14), at("c", 1), at("c", 100)}, 174, 1.5)); + EXPECT_EQ(aoi.snapshot()->tier("a", "c"), aoi_tier::inner); + // A NEWCOMER listed twice starts at its first position's tier at once (no dwell to hide it): + // 1 m is inner, 100 m would be outer. + EXPECT_TRUE( + aoi.update({at("a", 0), at("b", 14), at("c", 1), at("d", 1), at("d", 100)}, 180, 1.55)); + EXPECT_EQ(aoi.snapshot()->tier("a", "d"), aoi_tier::inner); + ra::room_aoi fresh(kRoom, ra::hall_aoi_params()); + EXPECT_TRUE(fresh.update({at("a", 0), at("d", 100), at("d", 1)}, 0, 0.0)); + EXPECT_EQ(fresh.snapshot()->tier("a", "d"), aoi_tier::outer); +} + +// arigl takes a fresh pid every launch, so departures never stop on a long-lived relay: the +// departed set keeps only the most recent kMaxDeparted, and a rejoin leaves it. +TEST(RoomAoiTest, DepartedSetIsBounded) { + ra::room_aoi aoi(kRoom, ra::hall_aoi_params()); + const std::size_t cap = ra::room_aoi::kMaxDeparted; + std::uint64_t tick = 0; + double t = 0.0; + for (std::size_t i = 0; i < cap + 5; ++i) { + const std::string pid = "p" + std::to_string(i); + aoi.update({at("a", 0), at(pid.c_str(), 1)}, tick += 6, t += 0.05); + aoi.update({at("a", 0)}, tick += 6, t += 0.05); + } + EXPECT_EQ(aoi.departed().size(), cap); + EXPECT_EQ(aoi.departed().count("p0"), 0u) << "the oldest are forgotten"; + EXPECT_EQ(aoi.departed().count("p4"), 0u); + EXPECT_EQ(aoi.departed().count("p5"), 1u); + EXPECT_EQ(aoi.departed().count("p" + std::to_string(cap + 4)), 1u); + // A forgotten ghost falls back to the unknown-pid rule; a remembered one is still withheld. + const auto s = aoi.snapshot(); + EXPECT_TRUE(s->deliver_path("a", ppath("p0", {"pose", "pos", "x"}))); + EXPECT_FALSE(s->deliver_path("a", ppath("p5", {"pose", "pos", "x"}))); + // A rejoin leaves the set (and its age entry with it: the next departures evict others). + aoi.update({at("a", 0), at("p5", 1)}, tick += 6, t += 0.05); + EXPECT_EQ(aoi.departed().count("p5"), 0u); + EXPECT_EQ(aoi.departed().size(), cap - 1); + aoi.update({at("a", 0)}, tick += 6, t += 0.05); + EXPECT_EQ(aoi.departed().count("p5"), 1u); + EXPECT_EQ(aoi.departed().size(), cap); + EXPECT_EQ(aoi.departed().count("p6"), 1u) << "p5 re-departed is the newest, nothing evicted"; +} + +// ── state delivery ────────────────────────────────────────────────────────── + +TEST(RoomAoiTest, DeliverPathRules) { + ra::room_aoi aoi(kRoom, ra::hall_aoi_params()); + place_abcde(aoi); + const std::shared_ptr s = aoi.snapshot(); + ASSERT_EQ(s->tier("a", "b"), aoi_tier::inner); + ASSERT_EQ(s->tier("a", "c"), aoi_tier::middle); + ASSERT_EQ(s->tier("a", "d"), aoi_tier::outer); + ASSERT_EQ(aoi.departed(), (std::set{"e"})); + + const auto yes = [&](const std::string &receiver, const std::string &path) { + EXPECT_TRUE(s->deliver_path(receiver, path)) << receiver << " <- " << path; + }; + const auto no = [&](const std::string &receiver, const std::string &path) { + EXPECT_FALSE(s->deliver_path(receiver, path)) << receiver << " <- " << path; + }; + + // The receiver's whole own subtree, intent included. + yes("a", ppath("a", {"pose", "pos", "x"})); + yes("a", ppath("a", {"pose", "server_tick"})); + yes("a", ppath("a", {"intent", "move", "x"})); + yes("a", ppath("a", {"net", "rtt_ms"})); + + // Pose: only an inner subject's, and then its whole subtree. + yes("a", ppath("b", {"pose", "pos", "x"})); + yes("a", ppath("b", {"pose", "server_tick"})); + yes("a", ppath("b", {"pose"})); + no("a", ppath("c", {"pose", "pos", "x"})); + no("a", ppath("c", {"pose", "vel", "lin", "x"})); + no("a", ppath("c", {"pose", "server_tick"})); + no("a", ppath("c", {"pose"})); + no("a", ppath("d", {"pose", "orient", "w"})); + no("a", ppath("d", {"pose", "server_tick"})); + yes("c", ppath("b", {"pose", "pos", "x"})); // b and c are 9 m apart + no("d", ppath("a", {"pose", "pos", "x"})); // tiers are symmetric + + // Another participant's intent, whatever its tier. + no("a", ppath("b", {"intent", "move", "x"})); + no("a", ppath("b", {"intent", "tick"})); + no("a", ppath("b", {"intent"})); + no("a", ppath("d", {"intent", "yaw"})); + + // The roster stays room-wide: presence, profile, net, voice and streams of everyone present. + for (const char *q : {"b", "c", "d"}) { + yes("a", ppath(q, {"presence", "joined"})); + yes("a", ppath(q, {"profile", "name"})); + yes("a", ppath(q, {"net", "rtt_ms"})); + yes("a", ppath(q, {"net", "relay", "in_kbps"})); + yes("a", ppath(q, {"voice", "talking"})); + yes("a", ppath(q, {"streams", "camera", "token"})); + } + + // A departed subject's ghost: pose and intent are withheld; the rest is not (net and voice + // too deliver, so a same-pid reconnect's writes made before its rejoin lands are never lost). + no("a", ppath("e", {"pose", "pos", "x"})); + no("a", ppath("e", {"pose", "server_tick"})); + no("a", ppath("e", {"intent", "move", "x"})); + yes("a", ppath("e", {"net", "rtt_ms"})); + yes("a", ppath("e", {"net", "relay", "rtt_ms"})); + yes("a", ppath("e", {"voice", "talking"})); + yes("a", ppath("e", {"presence", "joined"})); + yes("a", ppath("e", {"profile", "name"})); + yes("a", ppath("e", {"streams", "camera", "endpoint"})); + // ... but a departed pid reconnecting under the same id still gets its own (zeroed) intent. + yes("e", ppath("e", {"intent", "move", "x"})); + yes("e", ppath("e", {"pose", "pos", "x"})); + + // Room-level subtrees. + yes("a", rpath({"scene", "rev"})); + yes("a", rpath({"scene", "objects", "ref", "present"})); + yes("a", rpath({"server", "net", "links"})); + yes("a", rpath({"aoi", "inner_enter_m"})); + + // A pid this snapshot never saw delivers everything. + yes("a", ppath("zz", {"pose", "pos", "x"})); + yes("a", ppath("zz", {"intent", "move", "x"})); + + // Bare nodes, other rooms and malformed paths deliver. + yes("a", ppath("d")); + yes("a", ppath("e")); + yes("a", rpath({"participants"})); + yes("a", rpath({})); + yes("a", "conf"); + yes("a", ""); + yes("a", ra::room_participant_path("r2", "d") + cvc::state::SEPARATOR + "pose"); + yes("a", ra::room_participant_path("r10", "d") + cvc::state::SEPARATOR + "pose"); + yes("a", ppath("", {"pose"})); + yes("a", ppath("d") + cvc::state::SEPARATOR); + yes("a", rpath({"participantsd", "pose"})); + yes("a", ppath("d", {"posex"})); + yes("a", ppath("d", {"poses", "x"})); + yes("a", "x" + ppath("d", {"pose"})); + yes("a", ppath("d", {"intentional"})); + + // A receiver the relay has not placed: tiers are unknown, so pose delivers. + yes("zz", ppath("d", {"pose", "pos", "x"})); + + // The registry filter is the snapshot bound to one receiver. + const ra::aoi_state_filter f(s, "a"); + EXPECT_FALSE(f.deliver(ppath("c", {"pose", "pos", "x"}))); + EXPECT_TRUE(f.deliver(ppath("b", {"pose", "pos", "x"}))); + const ra::aoi_state_filter none(nullptr, "a"); + EXPECT_TRUE(none.deliver(ppath("c", {"pose", "pos", "x"}))); + + cvc::state_peer_registry reg; + reg.add_peer("a", "conf"); + ASSERT_TRUE(reg.set_filter("a", std::make_shared(s, "a"))); + EXPECT_FALSE(reg.should_deliver("a", ppath("c", {"pose", "pos", "x"}))); + EXPECT_TRUE(reg.should_deliver("a", ppath("b", {"pose", "pos", "x"}))); + EXPECT_TRUE(reg.should_deliver("a", ppath("c", {"profile", "name"}))); + + // An empty snapshot knows no one and delivers everything. + const ra::aoi_snapshot empty{}; + EXPECT_EQ(empty.generation(), 0u); + EXPECT_TRUE(empty.deliver_path("a", ppath("c", {"pose", "pos", "x"}))); + EXPECT_TRUE(empty.deliver_path("a", ppath("c", {"intent", "move", "x"}))); + EXPECT_EQ(empty.media("a", "d", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(empty.tier("a", "d"), aoi_tier::inner); +} + +// ── media ─────────────────────────────────────────────────────────────────── + +TEST(RoomAoiTest, MediaActions) { + EXPECT_EQ(ra::conference_aoi_params().video_full_max, 0) << "the video cap is off by default"; + EXPECT_EQ(ra::hall_aoi_params().video_full_max, 0); + { + ra::room_aoi aoi(kRoom, ra::hall_aoi_params()); + place_abcde(aoi); + const std::shared_ptr s = aoi.snapshot(); + // inner: everything. + EXPECT_EQ(s->media("a", "b", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "b", "mic", "f32"), aoi_media::deliver); + // middle: an mjpeg camera is a thumbnail; nothing else is decimated. + EXPECT_EQ(s->media("a", "c", "camera", "mjpeg"), aoi_media::thumbnail); + EXPECT_EQ(s->media("a", "c", "camera", "h264"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "c", "screen", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "c", "mic", "f32"), aoi_media::deliver); + // outer: nothing. + EXPECT_EQ(s->media("a", "d", "camera", "mjpeg"), aoi_media::drop); + EXPECT_EQ(s->media("a", "d", "camera", "h264"), aoi_media::drop); + EXPECT_EQ(s->media("a", "d", "mic", "f32"), aoi_media::drop); + EXPECT_EQ(s->media("d", "a", "mic", "f32"), aoi_media::drop); + // Unknown ends, a departed publisher and oneself: delivered. + EXPECT_EQ(s->media("a", "zz", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("zz", "d", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "e", "mic", "f32"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "a", "camera", "mjpeg"), aoi_media::deliver); + } + { + // The opt-in cap: a's six nearest inner cameras at full rate, the rest thumbnails, and a + // member gives way only to an outsider more than video_rank_margin_m (1 m) nearer. + ra::aoi_params p = ra::hall_aoi_params(); + p.video_full_max = 6; + ra::room_aoi aoi(kRoom, p); + std::vector ps{at("a", 0)}; + const char *const ids[] = {"s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"}; + for (int i = 0; i < 8; ++i) + ps.push_back(at(ids[i], 1.0 + i)); // s is i metres from a: all inner + const auto move_s7 = [&](double x, std::uint64_t tick) { + for (ra::room_aoi::position &q : ps) + if (q.pid == "s7") + q.x = x; + aoi.update(ps, tick, 0.05 * static_cast(tick) / 6.0); + return aoi.snapshot(); + }; + std::shared_ptr s = move_s7(7.0, 0); + for (const char *q : {"s1", "s2", "s3", "s4", "s5", "s6"}) + EXPECT_EQ(s->media("a", q, "camera", "mjpeg"), aoi_media::deliver) << q; + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::thumbnail); + EXPECT_EQ(s->media("a", "s8", "camera", "mjpeg"), aoi_media::thumbnail); + EXPECT_EQ(s->media("a", "s7", "mic", "f32"), aoi_media::deliver) << "the mic is on/off only"; + EXPECT_EQ(s->media("a", "s7", "camera", "h264"), aoi_media::deliver) << "mjpeg-only"; + + s = move_s7(5.5, 6); // 0.5 m nearer than s6: within the margin, no swap + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::thumbnail); + EXPECT_EQ(s->media("a", "s6", "camera", "mjpeg"), aoi_media::deliver); + + s = move_s7(4.9, 12); // 1.1 m nearer than s6: swapped in + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "s6", "camera", "mjpeg"), aoi_media::thumbnail); + + s = move_s7(6.5, 18); // now 0.5 m farther than s6: keeps its place + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "s6", "camera", "mjpeg"), aoi_media::thumbnail); + + s = move_s7(7.5, 24); // 1.5 m farther than s6: gives way + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::thumbnail); + EXPECT_EQ(s->media("a", "s6", "camera", "mjpeg"), aoi_media::deliver); + + ps.erase(ps.begin() + 1); // s1 leaves: the nearest outsider (s7) fills the slot + s = move_s7(7.5, 30); + ASSERT_EQ(aoi.departed(), (std::set{"s1"})); + EXPECT_EQ(s->media("a", "s7", "camera", "mjpeg"), aoi_media::deliver); + EXPECT_EQ(s->media("a", "s8", "camera", "mjpeg"), aoi_media::thumbnail); + } +} + +// ── pose pushes ───────────────────────────────────────────────────────────── + +TEST(RoomAoiTest, PosePushSchedule) { + ra::room_aoi aoi(kRoom, ra::hall_aoi_params()); // 24-tick pushes, 600-tick refresh + // a-c 14 m (middle), a-d 40 m and c-d 26 m (outer). + ASSERT_TRUE(aoi.update({at("a", 0), at("c", 14), at("d", 40)}, 0, 0.0)); + + // Every new non-inner pair is pushed at once, so nobody waits for the refresh. + EXPECT_EQ(as_map(aoi.take_pose_pushes(0, {})), + (push_map{{"a", {"c", "d"}}, {"c", {"a", "d"}}, {"d", {"a", "c"}}})); + EXPECT_TRUE(aoi.take_pose_pushes(1, {}).empty()); + + // A moving subject is pushed every 24 ticks, and only when it moved since its last push. + EXPECT_TRUE(aoi.take_pose_pushes(10, {{"c", 5}}).empty()); + EXPECT_EQ(as_map(aoi.take_pose_pushes(24, {{"c", 5}})), (push_map{{"a", {"c"}}, {"d", {"c"}}})); + EXPECT_TRUE(aoi.take_pose_pushes(30, {{"c", 5}}).empty()); + EXPECT_TRUE(aoi.take_pose_pushes(40, {{"c", 40}}).empty()); + EXPECT_EQ(as_map(aoi.take_pose_pushes(48, {{"c", 40}})), (push_map{{"a", {"c"}}, {"d", {"c"}}})); + + // A still subject is refreshed every 600 ticks. + EXPECT_TRUE(aoi.take_pose_pushes(599, {{"c", 40}}).empty()); + EXPECT_EQ(as_map(aoi.take_pose_pushes(600, {{"c", 40}})), + (push_map{{"a", {"d"}}, {"c", {"a", "d"}}, {"d", {"a"}}})); + + // Entering inner pushes both directions at once; d still gets c's motion at the cadence. + ASSERT_TRUE(aoi.update({at("a", 0), at("c", 5), at("d", 40)}, 606, 5.05)); + ASSERT_EQ(aoi.snapshot()->tier("a", "c"), aoi_tier::inner); + EXPECT_EQ(as_map(aoi.take_pose_pushes(606, {{"c", 606}})), + (push_map{{"a", {"c"}}, {"c", {"a"}}, {"d", {"c"}}})); + + // An inner pair is never pushed: the live stream carries it. + EXPECT_EQ(as_map(aoi.take_pose_pushes(700, {{"c", 700}})), (push_map{{"d", {"c"}}})); + + // A new pair that starts inner is not pushed; one that starts outer is. + ASSERT_TRUE(aoi.update({at("a", 0), at("c", 5), at("d", 40), at("e", 1)}, 710, 6.0)); + EXPECT_EQ(as_map(aoi.take_pose_pushes(710, {{"c", 700}})), + (push_map{{"d", {"e"}}, {"e", {"d"}}})); + + // A departed subject or receiver is never pushed. + ASSERT_TRUE(aoi.update({at("a", 0), at("c", 5), at("e", 1)}, 720, 6.1)); + EXPECT_TRUE(aoi.take_pose_pushes(720, {{"c", 720}}).empty()); + EXPECT_TRUE(aoi.take_pose_pushes(1400, {{"c", 720}}).empty()) << "all inner: no refresh"; + + // A downgrade resumes the cadence from the last live sample. + EXPECT_FALSE(aoi.update({at("a", 0), at("c", 14), at("e", 1)}, 1406, 6.5)); // dwell starts + ASSERT_TRUE(aoi.update({at("a", 0), at("c", 14), at("e", 1)}, 1526, 7.5)); + ASSERT_EQ(aoi.snapshot()->tier("a", "c"), aoi_tier::middle); + ASSERT_EQ(aoi.snapshot()->tier("e", "c"), aoi_tier::middle); // 13 m + EXPECT_EQ(as_map(aoi.take_pose_pushes(1526, {{"c", 1526}})), + (push_map{{"a", {"c"}}, {"e", {"c"}}})); + EXPECT_TRUE(aoi.take_pose_pushes(1527, {{"c", 1527}}).empty()); +} + +TEST(RoomAoiTest, PushIntervalFitsPeerSpan) { + // A peer walks between two of its samples over at most max_peer_span_ticks. A push due every + // interval leaves up to one relay room step late (arisrv steps the room at ~20 Hz), so the + // interval plus that step must fit in the span or a walking peer stutters. + for (const cvc::ariadne::holon_config &cfg : + {cvc::ariadne::holon_config{}, cvc::ariadne::room_holon_config()}) { + const double tick_hz = 1.0 / cfg.clock.fixed_dt; + EXPECT_DOUBLE_EQ(tick_hz, 120.0) << "room_aoi schedules pushes in 120 Hz ticks"; + const auto step_ticks = static_cast(std::llround(0.05 * tick_hz)); + EXPECT_EQ(step_ticks, 6u); + for (const ra::aoi_params &p : {ra::conference_aoi_params(), ra::hall_aoi_params()}) { + const std::uint64_t every = ra::aoi_pose_push_interval_ticks(p, tick_hz); + EXPECT_EQ(every, static_cast(std::llround(tick_hz / p.pose_push_hz))); + EXPECT_EQ(every, 24u); + EXPECT_LE(every + step_ticks, cfg.max_peer_span_ticks) << p.preset; + EXPECT_EQ(ra::aoi_pose_refresh_ticks(p, tick_hz), 600u) << p.preset; + } + } + ra::aoi_params off; + off.pose_push_hz = 0.0; + off.pose_refresh_s = 0.0; + EXPECT_EQ(ra::aoi_pose_push_interval_ticks(off), 0u); + EXPECT_EQ(ra::aoi_pose_refresh_ticks(off), 0u); +} + +// ── presets and the audio curve ───────────────────────────────────────────── + +TEST(RoomAoiTest, AudioGainCurve) { + const ra::aoi_params h = ra::hall_aoi_params(); + for (double d : {0.0, 1.0, 5.0, 10.0}) + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(d, h), 1.0f) << d; + EXPECT_NEAR(ra::aoi_audio_gain(12.0, h), 10.0 / 12.0, 1e-6); + EXPECT_NEAR(ra::aoi_audio_gain(14.0, h), 10.0 / 14.0, 1e-6) << "the fade starts at 14 m"; + EXPECT_NEAR(ra::aoi_audio_gain(15.0, h), 10.0 / 15.0 * 0.5, 1e-6); + for (double d : {16.0, 17.0, 18.0, 30.0}) + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(d, h), 0.0f) << "silent from middle_enter: " << d; + float prev = 1.0f; + for (int i = 0; i <= 400; ++i) { + const float g = ra::aoi_audio_gain(0.05 * i, h); + EXPECT_LE(g, prev) << "monotone at " << 0.05 * i; + prev = g; + } + + const ra::aoi_params c = ra::conference_aoi_params(); + for (int i = 0; i <= 152; ++i) + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(0.25 * i, c), 1.0f) << 0.25 * i; + EXPECT_NEAR(ra::aoi_audio_gain(39.0, c), 0.5, 1e-6); + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(40.0, c), 0.0f); + + ra::aoi_params off = h; + off.enabled = false; + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(30.0, off), 1.0f); +} + +TEST(RoomAoiTest, ConferencePresetCoversDefaultRoom) { + // The seeded room's ground plane is the floor everyone walks on. + const cvc::ariadne::SceneNode *ground = nullptr; + const std::vector objs = ra::default_scene_objects(); + for (const cvc::ariadne::SceneNode &n : objs) + if (n.source_primitive == "plane") + ground = &n; + ASSERT_NE(ground, nullptr); + const double hx = 0.5 * ground->plane_size * ground->scale[0]; + const double hy = 0.5 * ground->plane_size * ground->scale[1]; + EXPECT_DOUBLE_EQ(hx, 10.0); + EXPECT_DOUBLE_EQ(hy, 10.0); + + // Every pair of points on a 0.5 m lattice over the plane is inner at first sight ... + const ra::aoi_params p = ra::conference_aoi_params(); + std::vector> pts; + const int nx = static_cast(std::llround(2.0 * hx / 0.5)); + const int ny = static_cast(std::llround(2.0 * hy / 0.5)); + for (int i = 0; i <= nx; ++i) + for (int j = 0; j <= ny; ++j) + pts.emplace_back(ground->position[0] - hx + 0.5 * i, ground->position[1] - hy + 0.5 * j); + std::size_t not_inner = 0; + for (std::size_t i = 0; i < pts.size(); ++i) + for (std::size_t j = i + 1; j < pts.size(); ++j) + if (ra::initial_tier(std::hypot(pts[j].first - pts[i].first, pts[j].second - pts[i].second), + p) != aoi_tier::inner) + ++not_inner; + EXPECT_EQ(not_inner, 0u); + + // ... and stays inner: the diagonal never passes inner's exit radius. + const double diagonal = std::hypot(2.0 * hx, 2.0 * hy); + aoi_tier t = aoi_tier::inner; + double beyond = -1.0; + for (int i = 0; i < 200; ++i) + t = ra::step_tier(t, diagonal, 0.05 * i, beyond, p); + EXPECT_EQ(t, aoi_tier::inner); + + // So avatars at the corners and the centre get everything, live, and nothing is pushed. + ra::room_aoi aoi(kRoom, p); + aoi.update({at("a", -hx, -hy), at("b", hx, hy), at("c", -hx, hy), at("d", hx, -hy), at("o", 0)}, + 0, 0.0); + const std::shared_ptr s = aoi.snapshot(); + for (const char *r : {"a", "b", "c", "d", "o"}) + for (const char *q : {"a", "b", "c", "d", "o"}) { + EXPECT_EQ(s->tier(r, q), aoi_tier::inner) << r << " " << q; + EXPECT_TRUE(s->deliver_path(r, ppath(q, {"pose", "pos", "x"}))) << r << " " << q; + EXPECT_EQ(s->media(r, q, "camera", "mjpeg"), aoi_media::deliver) << r << " " << q; + EXPECT_EQ(s->media(r, q, "mic", "f32"), aoi_media::deliver) << r << " " << q; + } + EXPECT_TRUE(aoi.take_pose_pushes(0, {}).empty()); + EXPECT_TRUE(aoi.take_pose_pushes(600, {{"a", 300}}).empty()); + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(diagonal, p), 1.0f); +} + +// ── replicated parameters ─────────────────────────────────────────────────── + +TEST(RoomAoiTest, ParamsRoundTrip) { + EXPECT_EQ(ra::room_aoi_path(kRoom), rpath({"aoi"})); + cvc::app app; + ra::aoi_params out = ra::conference_aoi_params(); + out.preset = "untouched"; + EXPECT_FALSE(ra::read_aoi_params(app, kRoom, out)); + EXPECT_EQ(out.preset, "untouched"); + + ra::aoi_params in = ra::hall_aoi_params(); + in.enabled = false; + in.inner_enter_m = 10.1; + in.downgrade_dwell_s = 0.75; + in.pose_push_hz = 7.5; + in.pose_refresh_s = 2.5; + in.thumb_fps = 3.25; + in.video_rank_margin_m = 1.5; + in.audio_fade_m = 2.5; + in.video_full_max = 6; + ra::write_aoi_params(app, kRoom, in); + EXPECT_NE(app.root().findDescendant(rpath({"aoi", "inner_enter_m"})), nullptr); + EXPECT_NE(app.root().findDescendant(rpath({"aoi", "audio_rolloff"})), nullptr); + + const auto expect_same = [](const ra::aoi_params &a, const ra::aoi_params &b) { + EXPECT_EQ(a.enabled, b.enabled); + EXPECT_EQ(a.preset, b.preset); + EXPECT_EQ(a.inner_enter_m, b.inner_enter_m); + EXPECT_EQ(a.inner_exit_m, b.inner_exit_m); + EXPECT_EQ(a.middle_enter_m, b.middle_enter_m); + EXPECT_EQ(a.middle_exit_m, b.middle_exit_m); + EXPECT_EQ(a.downgrade_dwell_s, b.downgrade_dwell_s); + EXPECT_EQ(a.pose_push_hz, b.pose_push_hz); + EXPECT_EQ(a.pose_refresh_s, b.pose_refresh_s); + EXPECT_EQ(a.thumb_fps, b.thumb_fps); + EXPECT_EQ(a.video_rank_margin_m, b.video_rank_margin_m); + EXPECT_EQ(a.audio_fade_m, b.audio_fade_m); + EXPECT_EQ(a.video_full_max, b.video_full_max); + EXPECT_EQ(a.audio_rolloff, b.audio_rolloff); + }; + ra::aoi_params got = ra::conference_aoi_params(); + ASSERT_TRUE(ra::read_aoi_params(app, kRoom, got)); + expect_same(got, in); + + // Rewriting the same values changes nothing; a changed field reads back. + ra::write_aoi_params(app, kRoom, in); + in.video_full_max = 0; + in.enabled = true; + ra::write_aoi_params(app, kRoom, in); + got = ra::aoi_params{}; + ASSERT_TRUE(ra::read_aoi_params(app, kRoom, got)); + expect_same(got, in); + + // Each room has its own. + ra::aoi_params other; + EXPECT_FALSE(ra::read_aoi_params(app, "r2", other)); + + // The default parameters are the conference preset. + expect_same(ra::aoi_params{}, ra::conference_aoi_params()); +} + +TEST(RoomAoiTest, ParamsReplicateToAClient) { + cvc::app server_app, client_app; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard ss(server_app, "conf", "server"); + cvc::state_cluster_shard cs(client_app, "conf", "client"); + ss.attach(); + cs.attach(); + tp.register_shard(&ss); + tp.register_shard(&cs); + + ra::write_aoi_params(server_app, kRoom, ra::hall_aoi_params()); + tp.pump_all(); + ra::aoi_params got; + ASSERT_TRUE(ra::read_aoi_params(client_app, kRoom, got)); + EXPECT_EQ(got.preset, "hall"); + EXPECT_EQ(got.inner_enter_m, 10.0); + EXPECT_EQ(got.middle_enter_m, 16.0); + EXPECT_TRUE(got.audio_rolloff); + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(15.0, got), ra::aoi_audio_gain(15.0, ra::hall_aoi_params())); +} + +// ── the kill switch ───────────────────────────────────────────────────────── + +TEST(RoomAoiTest, DisabledDeliversAll) { + ra::aoi_params p = ra::hall_aoi_params(); + p.enabled = false; + p.video_full_max = 1; + ra::room_aoi aoi(kRoom, p); + place_abcde(aoi); + const std::shared_ptr s = aoi.snapshot(); + EXPECT_FALSE(s->enabled()); + for (const char *q : {"b", "c", "d", "e"}) { + EXPECT_TRUE(s->deliver_path("a", ppath(q, {"pose", "pos", "x"}))) << q; + EXPECT_TRUE(s->deliver_path("a", ppath(q, {"pose", "server_tick"}))) << q; + EXPECT_TRUE(s->deliver_path("a", ppath(q, {"intent", "move", "x"}))) << q; + EXPECT_TRUE(s->deliver_path("a", ppath(q, {"net", "rtt_ms"}))) << q; + EXPECT_TRUE(s->deliver_path("a", ppath(q, {"voice", "talking"}))) << q; + EXPECT_EQ(s->media("a", q, "camera", "mjpeg"), aoi_media::deliver) << q; + EXPECT_EQ(s->media("a", q, "mic", "f32"), aoi_media::deliver) << q; + } + EXPECT_TRUE(ra::aoi_state_filter(s, "a").deliver(ppath("d", {"pose", "pos", "x"}))); + EXPECT_TRUE(aoi.take_pose_pushes(0, {}).empty()); + EXPECT_TRUE(aoi.take_pose_pushes(24, {{"c", 10}, {"d", 10}}).empty()); + EXPECT_TRUE(aoi.take_pose_pushes(600, {}).empty()); + EXPECT_FLOAT_EQ(ra::aoi_audio_gain(30.0, p), 1.0f); +} + +// ── header hygiene ────────────────────────────────────────────────────────── + +// was included ahead of room_aoi.h at the top of this file, so this TU compiling at +// all is the test: a tier or media name that collided with one of its macros would not. +TEST(RoomAoiTest, CompilesAfterWindowsH) { + EXPECT_NE(aoi_tier::inner, aoi_tier::middle); + EXPECT_NE(aoi_tier::middle, aoi_tier::outer); + EXPECT_NE(aoi_media::deliver, aoi_media::thumbnail); + EXPECT_NE(aoi_media::thumbnail, aoi_media::drop); + EXPECT_EQ(ra::initial_tier((std::min)(1.0, 2.0), ra::aoi_params{}), aoi_tier::inner); +} diff --git a/src/cvc/tests/room_intrinsics_test.cpp b/src/cvc/tests/room_intrinsics_test.cpp index acd12a22d..2c46c46c4 100644 --- a/src/cvc/tests/room_intrinsics_test.cpp +++ b/src/cvc/tests/room_intrinsics_test.cpp @@ -14,7 +14,9 @@ // harness from state_transport_inproc_test (two apps + two shards + an inproc transport + // pump_all); no gRPC and no Runtime/backend are needed because the model is pure replicated state. +#include #include +#include #include #include #include @@ -35,6 +37,19 @@ const conf::room_participant *by_pid(const std::vector & return nullptr; } +// Publish what SHARD journaled since its last drain to the transport's other shards, keeping the +// drained mutations in OUT; returns how many there were (what the writes since then cost on the +// wire). +std::size_t publish_drained(cvc::state_transport_inproc &tp, cvc::state_cluster_shard &shard, + std::vector *out = nullptr) { + const std::vector drained = shard.drain_local(); + for (const cvc::state_mutation &m : drained) + tp.publish(m); + if (out) + *out = drained; + return drained.size(); +} + } // namespace // The model works standalone (no cluster): a join writes a participant subtree that the roster @@ -128,3 +143,79 @@ TEST(RoomIntrinsics, LeaveRetractsParticipantAcrossNodes) { sA.detach(); sB.detach(); } + +// A participant's voice.talking leaf costs one mutation per change: writing the value it already +// holds journals nothing, and a flip journals the new value alone (not a touch that re-sends the +// old one first). A is a client whose shard is rooted at the room tree, B a whole-tree relay; the +// leaf reaches B either way. +TEST(RoomIntrinsics, VoiceLeafRoundTripsChangedOnly) { + cvc::app aA, aB; + cvc::state_transport_inproc tp; + cvc::state_cluster_shard sA(aA, "C", "A", "conf"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sA.attach(); + sB.attach(); + tp.register_shard(&sA); + tp.register_shard(&sB); + const std::string leaf = conf::room_participant_path("standup", "A") + ".voice.talking"; + + // Never written: absent, and the out-parameter is left alone. + bool talking = true; + EXPECT_FALSE(conf::read_participant_voice(aA, "standup", "A", talking)); + EXPECT_TRUE(talking); + + // The first write, twice: one mutation, carrying the value. + conf::write_participant_voice(aA, "standup", "A", true); + conf::write_participant_voice(aA, "standup", "A", true); + std::vector sent; + ASSERT_EQ(publish_drained(tp, sA, &sent), 1u); + EXPECT_EQ(sent[0].path, leaf); + EXPECT_EQ(sent[0].string_value, "1"); + talking = false; + ASSERT_TRUE(conf::read_participant_voice(aA, "standup", "A", talking)); + EXPECT_TRUE(talking); + talking = false; + ASSERT_TRUE(conf::read_participant_voice(aB, "standup", "A", talking)) << "replicated to B"; + EXPECT_TRUE(talking); + + // The same value again: nothing to send. + conf::write_participant_voice(aA, "standup", "A", true); + EXPECT_EQ(publish_drained(tp, sA), 0u); + + // A flip: the new value, once. + conf::write_participant_voice(aA, "standup", "A", false); + conf::write_participant_voice(aA, "standup", "A", false); + ASSERT_EQ(publish_drained(tp, sA, &sent), 1u); + EXPECT_EQ(sent[0].path, leaf); + EXPECT_EQ(sent[0].string_value, "0"); + talking = true; + ASSERT_TRUE(conf::read_participant_voice(aB, "standup", "A", talking)); + EXPECT_FALSE(talking) << "B sees A stop talking"; + + // And back: still one per change. + conf::write_participant_voice(aA, "standup", "A", true); + EXPECT_EQ(publish_drained(tp, sA), 1u); + ASSERT_TRUE(conf::read_participant_voice(aB, "standup", "A", talking)); + EXPECT_TRUE(talking); + + // A forced write re-sends an unchanged value: the relay's re-broadcast can echo a stale copy of + // A's own leaf back, so A's tree may already hold the value A flips to without B having it. + cvc::state::instance(aB)(leaf).value(std::string("0")); // B lost the last flip + conf::write_participant_voice(aA, "standup", "A", true, /*force=*/true); + ASSERT_EQ(publish_drained(tp, sA, &sent), 1u); + EXPECT_EQ(sent[0].path, leaf); + EXPECT_EQ(sent[0].string_value, "1"); + ASSERT_TRUE(conf::read_participant_voice(aB, "standup", "A", talking)); + EXPECT_TRUE(talking) << "the forced re-send heals B"; + + // The leaf sits beside the roster, not in it: no presence, so no participant appears. + EXPECT_TRUE(conf::room_roster(aB, "standup").empty()); + bool other = true; + EXPECT_FALSE(conf::read_participant_voice(aB, "standup", "B", other)) << "B never wrote one"; + EXPECT_TRUE(other); + + tp.unregister_shard(&sA); + tp.unregister_shard(&sB); + sA.detach(); + sB.detach(); +} diff --git a/src/cvc/tests/room_relay_integration_test.cpp b/src/cvc/tests/room_relay_integration_test.cpp new file mode 100644 index 000000000..cf0a6ce28 --- /dev/null +++ b/src/cvc/tests/room_relay_integration_test.cpp @@ -0,0 +1,1466 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. +*/ + +// The room's spatial interest end to end, on loopback. A relay is assembled the way arisrv +// assembles it -- a whole-tree shard enforcing "conf", relay transports with the interest gate on +// and journal backfill off, a server holon, a media router and the room's area-of-interest driver, +// stepped every 50 ms by relay_room_step -- and a few clients the way arigl assembles them: a +// shard rooted at the room tree, a client holon pumped from it every frame, a synthetic camera and +// mic at their real rates. Each case moves the avatars through the server holon (the authority) +// and checks what every client actually received, as its shard applied it: live pose mutations +// against pushed snapshot entries, intents, frames that should never have reached it, media by +// publisher. +// +// Clients take turns over the transports this build has (WebSocket and/or gRPC), so in a build +// with both every room mixes them and its traffic crosses the relay's bridge and media router. +// The assertions are on the mechanism (no live frame for an outer peer, a fresh tick on every +// push, every sent frame accounted for); the few rates checked have wide bounds, said where they +// are, so a loaded runner does not fail them. + +#include +#include +#include +#include +#include +#include // std::strtoull +#include // avatar_intent +#include +#include +#include // room_join, room_leave, room_participant_path +#include // seed_default_scene, seed_large_demo_scene +#include +#include // server_pump, client_pump, read_pose, write_intent +#include +#include +#include +#include +#include +#include +#include +#if defined(CVC_ENABLE_GRPC) +#include +#endif +#if defined(CVC_ENABLE_WS) +#include +#endif +#include +#include +#include +#include // std::next +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ra = cvc::ariadne::conference; +using cvc::ariadne::holon; +using cvc::ariadne::holon_role; + +namespace { + +constexpr const char *kRoom = "standup"; +constexpr const char *kRelayId = "server"; +constexpr double kRoomStepS = 0.05; // arisrv's room step +constexpr double kFrameS = 1.0 / 30.0; // a client frame (arigl's publisher rate) +constexpr double kCameraHz = 30.0; // the synthetic camera +constexpr double kMicHz = 48000.0 / 1024.0; // one 1024-sample f32 mono chunk at 48 kHz +constexpr std::size_t kCameraBytes = 4096; // small, so loopback never saturates +constexpr std::size_t kMicBytes = 1024 * 4; // 1024 f32 samples +// Media is lossless here. gRPC's stall drop is off by default, and the relay and every client turn +// off the WebSocket backlog drop. On Linux that drop sheds a frame whenever a starved receiver's +// socket holds more than 32 KB, so a loaded runner used to miss a few percent of the frames. A +// slow runner now delays frames but sheds none, and the media counts measure the gate alone. + +double now_s() { + static const auto epoch = std::chrono::steady_clock::now(); + return std::chrono::duration(std::chrono::steady_clock::now() - epoch).count(); +} + +double xy_distance(const cvc::physics::vec3 &a, const cvc::physics::vec3 &b) { + return std::hypot(a.x - b.x, a.y - b.y); +} + +enum class link_kind { ws, grpc }; + +// The transports this build has, in the order clients take them. The test is only built when it +// has at least one. +std::vector link_kinds() { + std::vector k; +#if defined(CVC_ENABLE_WS) + k.push_back(link_kind::ws); +#endif +#if defined(CVC_ENABLE_GRPC) + k.push_back(link_kind::grpc); +#endif + return k; +} + +// conf.rooms..participants. -- every participant leaf is under it. +const std::string &participants_prefix() { + static const std::string p = ra::room_participant_path(kRoom, ""); + return p; +} + +// The participant (SUBJECT) and the family under it ("pose", "intent", "presence", ...) of a room +// leaf; false for a path outside the room's participants. +bool split_participant_path(const std::string &path, std::string &subject, std::string &family) { + const std::string &p = participants_prefix(); + if (path.compare(0, p.size(), p) != 0) + return false; + const std::size_t a = p.size(); + const std::size_t b = path.find('.', a); + if (b == std::string::npos) + return false; + subject = path.substr(a, b - a); + const std::size_t c = path.find('.', b + 1); + family = path.substr(b + 1, c == std::string::npos ? std::string::npos : c - b - 1); + return true; +} + +bool ends_with(const std::string &s, const std::string &suffix) { + return s.size() >= suffix.size() && + s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// What one client's shard applied, counted on the transport's reader thread as it happened. A +// live mutation carries the origin node and the sequence it was journaled with there; a snapshot +// entry (the admit snapshot, a targeted push, the WebSocket re-broadcast) carries neither, so the +// two are told apart without looking at frames. +class client_tap { +public: + void on_applied(const cvc::state_mutation &m) { + if (m.op != cvc::state_mutation_op::set_value) + return; + std::string subject, family; + if (!split_participant_path(m.path, subject, family)) + return; + const bool is_live = !m.origin_node_id.empty(); + std::lock_guard lk(_mu); + if (family == "pose") { + const bool tick = ends_with(m.path, ".pose.server_tick"); + if (tick) { + std::uint64_t &hi = _max_tick[subject]; + hi = (std::max)( + hi, static_cast(std::strtoull(m.string_value.c_str(), nullptr, 10))); + } + if (is_live) { + ++_live_pose[subject]; + if (m.origin_node_id == kRelayId) + _live_pose_seq[subject].insert(m.sequence); + } else if (tick) { + ++_pushed_ticks[subject]; // every push carries its subject's tick, last + } + } else if (family == "intent") { + ++_intents[subject]; + } + } + + void on_media(const cvc::media_frame &f) { + std::lock_guard lk(_mu); + ++_media[{f.origin_node_id, f.stream_id}]; + } + + // Live pose mutations applied for SUBJECT. + std::uint64_t live_pose(const std::string &subject) const { return get(_live_pose, subject); } + // Snapshot entries applied for SUBJECT's server_tick: one per push (or admit / re-broadcast). + std::uint64_t pushed_ticks(const std::string &subject) const { + return get(_pushed_ticks, subject); + } + // The newest server_tick applied for SUBJECT, live or pushed. The tree's own leaf can step back + // (a WebSocket re-broadcast carries the relay tree's tick, which for a subject standing still is + // its last heartbeat, older than a push), so freshness is read from here. + std::uint64_t max_tick(const std::string &subject) const { return get(_max_tick, subject); } + // Intent leaves applied for SUBJECT, live or from a snapshot. + std::uint64_t intents(const std::string &subject) const { return get(_intents, subject); } + std::uint64_t media(const std::string &publisher, const std::string &stream) const { + std::lock_guard lk(_mu); + const auto it = _media.find({publisher, stream}); + return it == _media.end() ? 0 : it->second; + } + // How many of SEQUENCES (relay journal sequences) arrived live for SUBJECT. + std::size_t live_pose_received(const std::string &subject, + const std::vector &sequences) const { + std::lock_guard lk(_mu); + const auto it = _live_pose_seq.find(subject); + if (it == _live_pose_seq.end()) + return 0; + std::size_t n = 0; + for (std::uint64_t s : sequences) + n += it->second.count(s); + return n; + } + +private: + std::uint64_t get(const std::map &m, const std::string &k) const { + std::lock_guard lk(_mu); + const auto it = m.find(k); + return it == m.end() ? 0 : it->second; + } + + mutable std::mutex _mu; + std::map _live_pose, _pushed_ticks, _max_tick, _intents; + std::map> _live_pose_seq; + std::map, std::uint64_t> _media; +}; + +struct relay_options { + bool use_aoi = true; // the room's area-of-interest driver (arisrv --aoi) + ra::aoi_params aoi = ra::conference_aoi_params(); + bool gate = true; // the relay interest gate (--relay-gate) + bool trim = true; // trim the journal and no backfill on connect (--relay-journal trim) + bool large_scene = false; // a 100 m plane instead of the 20 m room (where hall has room) + bool ws_broadcast = true; // arisrv's ~1 Hz WebSocket re-broadcast of the room tree +}; + +// The relay, as arisrv builds it, stepped by the test. +class test_relay { +public: + explicit test_relay(relay_options o) : opt(std::move(o)) { + shard = std::make_unique(app, "conf", kRelayId); + shard->add_interest("conf"); + shard->set_enforce_interest(true); + shard->attach(); + shard->install_as_default(); + shard->adapter().set_on_local_mutation([this](const cvc::state_mutation &m) { note_local(m); }); + // A closed connection is queued here (transport threads) and retracted on the next step. + const auto on_departed = [this](const std::string &node_id) { + std::lock_guard lk(_mu); + _departed.push_back(node_id); + }; +#if defined(CVC_ENABLE_GRPC) + grpc.set_on_connection_closed(on_departed); + grpc.set_relay_requires_interest(opt.gate); + grpc.set_backfill_on_connect(!opt.trim); + grpc.start("127.0.0.1:0", kRelayId, "conf"); + grpc.set_relay(true); + grpc.register_shard(shard.get()); + planes.push_back(&grpc); + router.add_endpoint(&grpc); +#endif +#if defined(CVC_ENABLE_WS) + ws.set_on_connection_closed(on_departed); + ws.set_relay_requires_interest(opt.gate); + ws.set_backfill_on_connect(!opt.trim); + ws.set_media_backlog_drop(0); // lossless media: the note by the frame sizes above + ws.start("127.0.0.1:0", kRelayId, "conf"); + ws.set_relay(true); + ws.register_shard(shard.get()); + planes.push_back(&ws); + router.add_endpoint(&ws); +#endif +#if defined(CVC_ENABLE_GRPC) && defined(CVC_ENABLE_WS) + grpc.set_bridge(&ws); + ws.set_bridge(&grpc); +#endif + // grid 1: the large scene's plane and reference box, without the bunnies (meshes, no physics). + if (opt.large_scene) + ra::seed_large_demo_scene(app, kRoom, 100.0, 1); + else + ra::seed_default_scene(app, kRoom); + if (opt.use_aoi) { + aoi = std::make_unique(app, *shard, kRoom, opt.aoi); + for (cvc::state_transport *tp : planes) + aoi->add_transport(tp); +#if defined(CVC_ENABLE_GRPC) + aoi->add_media_endpoint(&grpc); +#endif +#if defined(CVC_ENABLE_WS) + aoi->add_media_endpoint(&ws); +#endif + ra::write_aoi_params(app, kRoom, opt.aoi); + } + } + + ~test_relay() { + // arisrv's shutdown order: the driver first, while the transports still run. + aoi.reset(); +#if defined(CVC_ENABLE_GRPC) && defined(CVC_ENABLE_WS) + grpc.set_bridge(nullptr); + ws.set_bridge(nullptr); +#endif +#if defined(CVC_ENABLE_WS) + ws.unregister_shard(shard.get()); + ws.stop(); +#endif +#if defined(CVC_ENABLE_GRPC) + grpc.unregister_shard(shard.get()); + grpc.stop(); +#endif + shard->adapter().set_on_local_mutation({}); + shard->detach(); + } + + test_relay(const test_relay &) = delete; + test_relay &operator=(const test_relay &) = delete; + + // One room step, as arisrv's loop runs it: retract departures (zeroing the leaver's intent), + // server_pump, relay_room_step, and the ~1 Hz WebSocket re-broadcast. A participant listed in + // place_on_join is moved there on the step that adds it, before the area of interest first + // sees it, and its pose re-written, so it appears there and never on the spawn ring. + void step() { + std::vector gone; + { + std::lock_guard lk(_mu); + gone.swap(_departed); + } + for (const std::string &nid : gone) { + ra::room_leave(app, kRoom, nid); + cvc::ariadne::write_intent(app, kRoom, nid, cvc::ariadne::avatar_intent{}); + } + cvc::ariadne::server_pump(server, app, kRoom); + // The clients' joins land on transport threads, so a pump can add a newcomer that the pass + // before it never saw: place newcomers and re-pump until a pass finds none, so the area of + // interest below never sees one on the spawn ring. + for (int pass = 0; pass < 8; ++pass) { + bool moved = false; + for (const std::string &pid : server.participants()) { + if (!_known.insert(pid).second) + continue; + joined_at[pid] = now_s(); + const auto it = place_on_join.find(pid); + if (it != place_on_join.end()) { + place(pid, it->second.first, it->second.second); + moved = true; + } + } + if (!moved) + break; + cvc::ariadne::server_pump(server, app, kRoom); + } + for (auto it = _known.begin(); it != _known.end();) + it = server.has_participant(*it) ? std::next(it) : _known.erase(it); + ra::relay_room_step(*shard, planes, aoi.get(), server, now_s(), opt.trim); + ++steps; +#if defined(CVC_ENABLE_WS) + if (opt.ws_broadcast && now_s() >= _next_broadcast) { + ws.broadcast_snapshot(); + _next_broadcast = now_s() + 1.0; + } +#endif + } + + // The authority moves PID: teleported to (X, Y) standing still, or walking at (VX, VY) m/s. + void place(const std::string &pid, double x, double y) { + cvc::physics::body_state s; + s.position = {x, y, 0.0}; + s.active = true; + server.override_state(pid, s); + walk(pid, 0.0, 0.0); + } + void walk(const std::string &pid, double vx, double vy) { + cvc::ariadne::avatar_intent in; + in.move = {vx, vy, 0.0}; + double &yaw = _yaw[pid]; + if (vx != 0.0 || vy != 0.0) + yaw = std::atan2(vy, vx); + in.yaw = yaw; // a stop keeps the facing + server.set_intent(pid, in); + } + + cvc::physics::vec3 position(const std::string &pid) const { return server.state(pid).position; } + bool tree_pose(const std::string &pid, cvc::physics::body_state &s, std::uint64_t &tick) { + return cvc::ariadne::read_pose(app, kRoom, pid, s, tick); + } + ra::aoi_tier tier(const std::string &receiver, const std::string &subject) const { + const auto snap = aoi ? aoi->snapshot() : nullptr; + return snap ? snap->tier(receiver, subject) : ra::aoi_tier::inner; + } + + // The relay journal's last sequence; the relay's own pose writes for PID journaled in + // (AFTER, THROUGH]; and how many pose writes it has journaled in all. + std::uint64_t journal_sequence() const { return shard->journal().last_sequence(); } + std::vector pose_sequences(const std::string &pid, std::uint64_t after, + std::uint64_t through) const { + std::lock_guard lk(_mu); + std::vector out; + const auto it = _pose_seq.find(pid); + if (it != _pose_seq.end()) + for (std::uint64_t s : it->second) + if (s > after && s <= through) + out.push_back(s); + return out; + } + std::uint64_t pose_writes() const { + std::lock_guard lk(_mu); + return _pose_writes; + } + + // Totals over both planes. + std::uint64_t egress_bytes() const { + std::uint64_t b = 0; + for (const cvc::state_transport *tp : planes) + for (const cvc::state_link_report &r : tp->link_reports()) + b += r.stats.bytes_out; + return b; + } + std::uint64_t withheld() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_relay_withheld(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_relay_withheld(); +#endif + return n; + } + std::uint64_t media_gated() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_media_gated(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_media_gated(); +#endif + return n; + } + std::uint64_t backfilled() const { + std::uint64_t n = 0; +#if defined(CVC_ENABLE_GRPC) + n += grpc.total_backfilled(); +#endif +#if defined(CVC_ENABLE_WS) + n += ws.total_backfilled(); +#endif + return n; + } + + relay_options opt; + cvc::app app; + std::unique_ptr shard; +#if defined(CVC_ENABLE_GRPC) + cvc::state_transport_grpc grpc; +#endif +#if defined(CVC_ENABLE_WS) + cvc::state_transport_ws ws; +#endif + cvc::media_router router; // after the transports: it goes first, clearing their sinks + holon server{holon_role::server, cvc::ariadne::room_holon_config()}; + std::unique_ptr aoi; + std::vector planes; + std::map> place_on_join; + std::map joined_at; // when the server holon (and so the AOI) first had it + std::uint64_t steps = 0; + +private: + void note_local(const cvc::state_mutation &m) { + std::string subject, family; + if (m.op != cvc::state_mutation_op::set_value || + !split_participant_path(m.path, subject, family) || family != "pose") + return; + std::lock_guard lk(_mu); + _pose_seq[subject].push_back(m.sequence); + ++_pose_writes; + } + + mutable std::mutex _mu; + std::vector _departed; + std::map> _pose_seq; + std::uint64_t _pose_writes = 0; + std::set _known; + std::map _yaw; + double _next_broadcast = 0.0; +}; + +// A client, as arigl builds one: its shard rooted at ROOT ("conf"; "" is a client from before +// S7, which observes and sends its whole tree), interest "conf" enforced, a client holon. +class test_client { +public: + test_client(std::string id, link_kind k, std::string root = "conf") + : pid(std::move(id)), kind(k) { + shard = std::make_unique(app, "conf", pid, std::move(root)); + shard->add_interest("conf"); + shard->set_enforce_interest(true); + shard->attach(); + shard->adapter().set_on_remote_applied( + [this](const cvc::state_mutation &m) { tap.on_applied(m); }); + } + ~test_client() { + disconnect(); + shard->adapter().set_on_remote_applied({}); + shard->detach(); + } + test_client(const test_client &) = delete; + test_client &operator=(const test_client &) = delete; + + bool connect(test_relay &r) { + const auto on_media = [this](const cvc::media_frame &f) { tap.on_media(f); }; +#if defined(CVC_ENABLE_GRPC) + if (kind == link_kind::grpc) { + _grpc = std::make_unique(); + _grpc->start("127.0.0.1:0", pid, "conf"); // gRPC must start() even to dial + _grpc->register_shard(shard.get()); + _grpc->set_on_media(on_media); + tp = _grpc.get(); + return _grpc->connect_to_peer(r.grpc.listen_address(), std::chrono::milliseconds(4000)); + } +#endif +#if defined(CVC_ENABLE_WS) + if (kind == link_kind::ws) { + _ws = std::make_unique(); + _ws->register_shard(shard.get()); + _ws->set_on_media(on_media); + _ws->set_media_backlog_drop(0); // its uplink is lossless too + tp = _ws.get(); + return _ws->connect_to_peer("ws://" + r.ws.listen_address(), std::chrono::milliseconds(4000)); + } +#endif + (void)r; + (void)on_media; + return false; + } + + void disconnect() { +#if defined(CVC_ENABLE_GRPC) + if (_grpc) { + _grpc->stop(); + _grpc->unregister_shard(shard.get()); + _grpc.reset(); + } +#endif +#if defined(CVC_ENABLE_WS) + if (_ws) { + _ws->stop(); + _ws->unregister_shard(shard.get()); + _ws.reset(); + } +#endif + tp = nullptr; + } + + void join() { + ra::room_join(app, kRoom, pid, pid); + if (tp) + tp->pump_all(); + } + + // One client frame: the holon against the replicated room, the marker echo when on, then this + // frame's own writes sent. + void frame() { + cvc::ariadne::client_pump(h, app, kRoom, pid); + if (echo) + write_echo(); + if (tp) + tp->pump_all(); + } + + void set_media(bool on) { + _media = on; + _next_cam = _next_mic = now_s(); + } + // The camera and mic frames due by NOW (seconds), at their real rates; a client that fell + // behind skips the backlog rather than bursting it. + void send_due_media(double now) { + if (!_media || !tp) + return; + if (now >= _next_cam) { + send("camera", "mjpeg", kCameraBytes, cam_sent++); + _next_cam += 1.0 / kCameraHz; + if (_next_cam < now) + _next_cam = now + 1.0 / kCameraHz; + } + if (now >= _next_mic) { + send("mic", "f32", kMicBytes, mic_sent++); + _next_mic += 1.0 / kMicHz; + if (_next_mic < now) + _next_mic = now + 1.0 / kMicHz; + } + } + + bool tree_pose(const std::string &q, cvc::physics::body_state &s, std::uint64_t &tick) { + return cvc::ariadne::read_pose(app, kRoom, q, s, tick); + } + std::uint64_t bytes_in() const { + std::uint64_t b = 0; + if (tp) + for (const cvc::state_link_report &r : tp->link_reports()) + b += r.stats.bytes_in; + return b; + } + + const std::string pid; + const link_kind kind; + cvc::app app; + client_tap tap; + std::unique_ptr shard; + holon h{holon_role::client, cvc::ariadne::room_holon_config()}; + cvc::state_transport *tp = nullptr; + bool echo = false; // write arigl's avatar marker matrices into ui.docs every frame + std::uint64_t cam_sent = 0, mic_sent = 0; + +private: + void send(const char *stream, const char *codec, std::size_t bytes, std::uint64_t seq) { + cvc::media_frame f; + f.origin_node_id = pid; + f.stream_id = stream; + f.codec = codec; + if (std::string(stream) == "camera") { + f.width = 640; + f.height = 480; + } else { + f.sample_rate = 48000; + f.channels = 1; + } + f.seq = static_cast(seq); + f.pts_seconds = now_s(); + f.payload.assign(bytes, static_cast(0x5a)); +#if defined(CVC_ENABLE_GRPC) + if (_grpc) + _grpc->publish_media_frame(f); +#endif +#if defined(CVC_ENABLE_WS) + if (_ws) + _ws->publish_media_frame(f); +#endif + } + + // room_view's per-frame write of each shown avatar's body and visor marker matrices into the + // document's UI tree: a moving avatar rewrites both every frame. A shard rooted at the room + // tree never observes it; one observing the whole tree journals and sends every write. + void write_echo() { + char buf[32]; + for (const std::string &q : h.participants()) { + if (!h.pose_known(q)) + continue; + const cvc::physics::pose p = h.display_pose(q); + const cvc::physics::quat &r = p.rotation; + // Column-major, as the renderer's 4x4 is: the rotation's columns, then the translation. + double m[16] = {}; + m[0] = 1 - 2 * (r.y * r.y + r.z * r.z); + m[1] = 2 * (r.x * r.y + r.w * r.z); + m[2] = 2 * (r.x * r.z - r.w * r.y); + m[4] = 2 * (r.x * r.y - r.w * r.z); + m[5] = 1 - 2 * (r.x * r.x + r.z * r.z); + m[6] = 2 * (r.y * r.z + r.w * r.x); + m[8] = 2 * (r.x * r.z + r.w * r.y); + m[9] = 2 * (r.y * r.z - r.w * r.x); + m[10] = 1 - 2 * (r.x * r.x + r.y * r.y); + m[12] = p.position.x; + m[13] = p.position.y; + m[14] = p.position.z + 0.85; // the marker's centre, half an avatar up + m[15] = 1.0; + for (const char *part : {"avatar_", "visor_"}) { + std::string v; + for (double d : m) { + std::snprintf(buf, sizeof buf, "%.17g ", d); + v += buf; + } + app.root()(std::string("ui.docs.ari.room.") + part + q + ".matrix").value(v); + } + } + } + +#if defined(CVC_ENABLE_GRPC) + std::unique_ptr _grpc; +#endif +#if defined(CVC_ENABLE_WS) + std::unique_ptr _ws; +#endif + bool _media = false; + double _next_cam = 0.0, _next_mic = 0.0; +}; + +using client_list = std::vector>; + +// Clients for PIDS, taking the build's transports in turn. +client_list make_clients(std::initializer_list pids, + const std::string &root = "conf") { + const std::vector kinds = link_kinds(); + client_list out; + for (const char *p : pids) + out.push_back(std::make_unique(p, kinds[out.size() % kinds.size()], root)); + return out; +} +std::vector ptrs(const client_list &cs) { + std::vector out; + for (const auto &c : cs) + out.push_back(c.get()); + return out; +} + +// Run the room in real time for up to SECONDS: every 50 ms SCRIPT (given the seconds since the +// start) and then a room step, every 1/30 s a frame of each client, and each client's media when +// due. With UNTIL it returns true as soon as UNTIL holds (checked after each round of frames) and +// false if it never did; without, it runs the whole time. A runner that falls behind skips ahead +// instead of bursting steps. +bool run_room(test_relay &r, const std::vector &cs, double seconds, + const std::function &script = {}, + const std::function &until = {}) { + const double t0 = now_s(); + double next_step = t0, next_frame = t0; + for (;;) { + const double t = now_s(); + if (t >= next_step) { + if (script) + script(t - t0); + r.step(); + next_step += kRoomStepS; + if (next_step < t) + next_step = t + kRoomStepS; + } + if (t >= next_frame) { + for (test_client *c : cs) + c->frame(); + next_frame += kFrameS; + if (next_frame < t) + next_frame = t + kFrameS; + if (until && until()) + return true; + } + for (test_client *c : cs) + c->send_due_media(t); + if (t - t0 >= seconds) + return until ? until() : true; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} + +// Every client is in the server holon and (with an AOI) in its snapshot, with the driver's filter +// on every plane's registry; and every client holds a known pose for every other one. +bool settled(test_relay &r, const std::vector &cs) { + const auto snap = r.aoi ? r.aoi->snapshot() : nullptr; + for (test_client *c : cs) { + if (!r.server.has_participant(c->pid)) + return false; + if (r.aoi) { + if (!snap) + return false; + const auto &ps = snap->participants(); + if (std::find(ps.begin(), ps.end(), c->pid) == ps.end()) + return false; + for (const cvc::state_transport *tp : r.planes) + if (!tp->peers().view_of(c->pid).filter) + return false; + } + for (test_client *q : cs) + if (!c->h.has_participant(q->pid) || !c->h.pose_known(q->pid)) + return false; + } + return true; +} + +// Connect and join every client, then run the room until it settles. +void bring_up(test_relay &r, const std::vector &cs) { + for (test_client *c : cs) { + ASSERT_TRUE(c->connect(r)) << c->pid << " could not reach the relay"; + c->join(); + } + ASSERT_TRUE(run_room(r, cs, 8.0, {}, [&] { return settled(r, cs); })) << "the room never settled"; +} + +// A walk to and fro along one axis, AMPLITUDE either side of ANCHOR, at SPEED m/s. +struct patrol { + double anchor = 0.0; + double amplitude = 1.0; + double speed = 1.5; + bool along_y = false; + double dir = 1.0; + + void drive(test_relay &r, const std::string &pid) { + const cvc::physics::vec3 p = r.position(pid); + if (((along_y ? p.y : p.x) - anchor) * dir >= amplitude) + dir = -dir; + r.walk(pid, along_y ? 0.0 : dir * speed, along_y ? dir * speed : 0.0); + } +}; + +// --------------------------------------------------------------------------------------------- +// The default room under the conference preset: every pair is inner, so the area of interest +// must change nothing a client receives. + +struct default_room_run { + std::size_t pose_expected = 0; // other subjects' relay pose writes, summed over receivers + std::size_t pose_missing = 0; // ... that never arrived live + double media = 0.0; // the least-served (receiver, publisher, stream): got / sent + std::uint64_t gated = 0; // receiver deliveries the media gates denied + ra::room_aoi_relay::stats aoi; +}; + +// N=4 standing on the spawn ring or at the corners (+-8, +-8: 16 to 22.6 m apart), each walking a +// metre either side of where it stands, with camera and mic on for a second. +default_room_run run_default_room(bool use_aoi, bool corners) { + default_room_run out; + relay_options o; + o.use_aoi = use_aoi; + test_relay r(o); + const client_list own = make_clients({"A", "B", "C", "D"}); + const std::vector cs = ptrs(own); + if (corners) + r.place_on_join = { + {"A", {8.0, 8.0}}, {"B", {-8.0, 8.0}}, {"C", {-8.0, -8.0}}, {"D", {8.0, -8.0}}}; + bring_up(r, cs); + if (::testing::Test::HasFatalFailure()) + return out; + std::map walks; + for (test_client *c : cs) + walks[c->pid].anchor = r.position(c->pid).x; + const auto script = [&](double) { + for (auto &kv : walks) + kv.second.drive(r, kv.first); + }; + run_room(r, cs, 0.3, script); + const std::uint64_t s0 = r.journal_sequence(); + for (test_client *c : cs) + c->set_media(true); + run_room(r, cs, 1.0, script); + for (test_client *c : cs) { + c->set_media(false); + r.walk(c->pid, 0.0, 0.0); + } + const std::uint64_t s1 = r.journal_sequence(); + + const auto tally = [&] { + out.pose_expected = out.pose_missing = 0; + out.media = 1.0; + for (test_client *rc : cs) + for (test_client *q : cs) { + if (q == rc) + continue; + const std::vector want = r.pose_sequences(q->pid, s0, s1); + out.pose_expected += want.size(); + out.pose_missing += want.size() - rc->tap.live_pose_received(q->pid, want); + for (const char *stream : {"camera", "mic"}) { + const std::uint64_t sent = std::string(stream) == "camera" ? q->cam_sent : q->mic_sent; + if (sent > 0) + out.media = + (std::min)(out.media, static_cast(rc->tap.media(q->pid, stream)) / sent); + } + } + return out.pose_missing == 0 && out.media >= 1.0; + }; + // Everything the window sent lands (the room keeps running, as the relay would). A loaded runner + // may take a while to drain it; the wait ends as soon as it has. + run_room(r, cs, 8.0, {}, tally); + tally(); + out.gated = r.media_gated(); + if (r.aoi) + out.aoi = r.aoi->take_stats(); + return out; +} + +} // namespace + +// The 20 m room's every pair is inner under the conference preset, so live pose, full video and +// full audio reach every receiver exactly as without the area of interest: each receiver gets +// every other subject's pose mutation live and every camera and mic frame, while the gate passes +// every frame whole and the driver never pushes. +TEST(RoomRelayLoopbackTest, DefaultRoomUnchangedUnderConferencePreset) { + for (const bool corners : {false, true}) { + SCOPED_TRACE(corners ? "corners" : "spawn ring"); + const default_room_run off = run_default_room(false, corners); + ASSERT_FALSE(HasFatalFailure()); + const default_room_run on = run_default_room(true, corners); + ASSERT_FALSE(HasFatalFailure()); + std::printf("[ room ] default room (%s): pose %zu/%zu missing off, %zu/%zu on; media %.3f off, " + "%.3f on\n", + corners ? "corners" : "spawn", off.pose_missing, off.pose_expected, on.pose_missing, + on.pose_expected, off.media, on.media); + EXPECT_GT(off.pose_expected, 0u); + EXPECT_GT(on.pose_expected, 0u); + EXPECT_EQ(off.pose_missing, 0u); + EXPECT_EQ(on.pose_missing, 0u) << "an inner pair's live pose is never withheld"; + EXPECT_EQ(off.media, 1.0) << "every frame lands: media is lossless here"; + EXPECT_EQ(on.media, 1.0) << "the gate passes every inner frame"; + EXPECT_EQ(on.gated, 0u) << "the gate denies no inner receiver"; + EXPECT_EQ(on.aoi.inner, 6u); + EXPECT_EQ(on.aoi.middle, 0u); + EXPECT_EQ(on.aoi.outer, 0u); + EXPECT_GT(on.aoi.media_full, 0u); + EXPECT_EQ(on.aoi.media_thumb, 0u); + EXPECT_EQ(on.aoi.media_dropped, 0u); + EXPECT_EQ(on.aoi.pushes, 0u) << "pairs that start inner are never pushed"; + } +} + +// Hall preset: C walks 30 m from B (34 from A). B and A get no live pose frame for C, but a +// targeted push about every 24 ticks while C moves, which keeps their view of C walking; they get +// none of C's video or audio, while their own (inner) media still flows. +TEST(RoomRelayLoopbackTest, OuterPeerGetsPushesNotLiveFrames) { + relay_options o; + o.aoi = ra::hall_aoi_params(); + o.large_scene = true; + test_relay r(o); + const client_list own = make_clients({"A", "B", "C"}); + const std::vector cs = ptrs(own); + test_client &A = *cs[0], &B = *cs[1], &C = *cs[2]; + r.place_on_join = {{"A", {-2.0, 10.0}}, {"B", {2.0, 10.0}}, {"C", {32.0, 10.0}}}; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + ASSERT_EQ(r.tier("B", "C"), ra::aoi_tier::outer); + ASSERT_EQ(r.tier("A", "C"), ra::aoi_tier::outer); + ASSERT_EQ(r.tier("A", "B"), ra::aoi_tier::inner); + + const std::uint64_t live_b = B.tap.live_pose("C"), live_a = A.tap.live_pose("C"); + const std::uint64_t pushed_b = B.tap.pushed_ticks("C"); + A.set_media(true); + C.set_media(true); + patrol walk; + walk.anchor = 10.0; + walk.amplitude = 1.5; + walk.speed = 2.0; + walk.along_y = true; + double max_error = 0.0; + std::set seen; // C's y in B's tree, in millimetres + constexpr double kSeconds = 2.0; + run_room(r, cs, kSeconds, [&](double) { + cvc::physics::body_state rs, bs; + std::uint64_t rt = 0, bt = 0; + if (r.tree_pose("C", rs, rt) && B.tree_pose("C", bs, bt)) { + max_error = (std::max)(max_error, xy_distance(rs.position, bs.position)); + seen.insert(std::llround(bs.position.y * 1000.0)); + } + walk.drive(r, "C"); + }); + A.set_media(false); + C.set_media(false); + r.walk("C", 0.0, 0.0); + run_room(r, cs, 0.3); // what is in flight lands + + EXPECT_EQ(B.tap.live_pose("C"), live_b) << "an outer peer's live pose frames are withheld"; + EXPECT_EQ(A.tap.live_pose("C"), live_a); + // About 5 a second while C walks (one every 24 ticks). A loaded runner runs fewer room steps, + // so the bounds are wide: 1.5 to 12 a second. + const std::uint64_t pushes = B.tap.pushed_ticks("C") - pushed_b; + EXPECT_GE(pushes, 3u); + EXPECT_LE(pushes, 24u); + EXPECT_GE(seen.size(), 3u) << "the pushes keep B's view of C moving"; + // At 2 m/s and a push every 0.2 s B's view trails by about 0.4 m. + EXPECT_LE(max_error, 1.5); + for (test_client *rc : {&A, &B}) { + EXPECT_EQ(rc->tap.media("C", "camera"), 0u) << rc->pid; + EXPECT_EQ(rc->tap.media("C", "mic"), 0u) << rc->pid; + } + EXPECT_GT(C.cam_sent, 20u); + EXPECT_GT(B.tap.media("A", "camera"), 0u) << "the inner peer's media still flows"; + EXPECT_GT(B.tap.media("A", "mic"), 0u); +} + +// C stands still, outer for B (hall preset), so B holds C's pose only from pushes, while B walks +// to it. On the step the pair turns inner the relay pushes C's whole pose stamped with its CURRENT +// tick, ahead of that step's live writes: within two steps B's tree has C exactly as the relay +// does, with a tick at least that fresh, and B's replica never re-aligns its clock (a stale tick +// would make it seek, and rubber-band its own avatar). +TEST(RoomRelayLoopbackTest, StillOuterPeerReentryNoRealignNoFreeze) { + relay_options o; + o.aoi = ra::hall_aoi_params(); + o.large_scene = true; + test_relay r(o); + const client_list own = make_clients({"B", "C"}); + const std::vector cs = ptrs(own); + test_client &B = *cs[0]; + // 18 m apart: outer (beyond middle_enter 16 m), about two seconds' walk from inner (10 m). + r.place_on_join = {{"B", {14.0, 10.0}}, {"C", {32.0, 10.0}}}; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + ASSERT_EQ(r.tier("B", "C"), ra::aoi_tier::outer); + run_room(r, cs, 0.5); // the replicas' clocks settle on the relay's first samples + const std::uint64_t realigns = B.h.clock_realigns(); + + r.walk("B", 4.0, 0.0); // the veto's cap + bool entered = false; + std::uint64_t entered_tick = 0, entered_step = 0, pushed_before = 0, matched_step = 0; + std::uint64_t pushed_last = B.tap.pushed_ticks("C"); // as of before the latest step + const auto fresh = [&] { + if (!entered) + return false; + cvc::physics::body_state rs, bs; + std::uint64_t rt = 0, bt = 0; + if (B.tap.max_tick("C") < entered_tick || !r.tree_pose("C", rs, rt) || + !B.tree_pose("C", bs, bt)) + return false; + if (std::fabs(rs.position.x - bs.position.x) > 1e-4 || + std::fabs(rs.position.y - bs.position.y) > 1e-4 || + std::fabs(rs.position.z - bs.position.z) > 1e-4) + return false; + matched_step = r.steps; + return true; + }; + ASSERT_TRUE(run_room( + r, cs, 8.0, + [&](double) { + // Runs before each step: the previous step's snapshot, and the tick it pushed with. That + // step's push may have landed already, so the count to beat is the one from before it. + if (!entered && r.tier("B", "C") == ra::aoi_tier::inner) { + entered = true; + entered_tick = r.server.tick(); + entered_step = r.steps; + pushed_before = pushed_last; + r.walk("B", 0.0, 0.0); + } + pushed_last = B.tap.pushed_ticks("C"); + }, + fresh)) + << (entered ? "B never got C's fresh pose" : "B never reached C"); + // A step is 50 ms and loopback delivers in milliseconds. + EXPECT_LE(matched_step - entered_step, 2u); + EXPECT_GT(B.tap.pushed_ticks("C"), pushed_before) << "the entry pose came as a push"; + + run_room(r, cs, 0.5); // inner now: live writes + EXPECT_EQ(B.h.clock_realigns(), realigns) << "a pushed tick never re-aligns the replica"; + EXPECT_TRUE(B.h.pose_known("C")); + cvc::physics::body_state rs; + std::uint64_t rt = 0; + ASSERT_TRUE(r.tree_pose("C", rs, rt)); + EXPECT_LE(xy_distance(B.h.state("C").position, rs.position), 1e-4) << "C is not frozen elsewhere"; +} + +// Hall preset, C 13 m from B: middle. B gets C's camera as a thumbnail -- the first frame at +// once, then about 2 a second -- and every mic chunk; A, 17 m from C and so outer, gets neither. +TEST(RoomRelayLoopbackTest, MiddleGetsThumbnailsAndAudio) { + relay_options o; + o.aoi = ra::hall_aoi_params(); + o.large_scene = true; + test_relay r(o); + const client_list own = make_clients({"A", "B", "C"}); + const std::vector cs = ptrs(own); + test_client &A = *cs[0], &B = *cs[1], &C = *cs[2]; + // B-C 13 m (middle: inside middle_enter 16, beyond inner_enter 10); A-C 17 m (outer). + r.place_on_join = {{"A", {-2.0, 10.0}}, {"B", {2.0, 10.0}}, {"C", {15.0, 10.0}}}; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + ASSERT_EQ(r.tier("B", "C"), ra::aoi_tier::middle); + ASSERT_EQ(r.tier("A", "C"), ra::aoi_tier::outer); + + constexpr double kSeconds = 2.0; + C.set_media(true); + run_room(r, cs, kSeconds); + C.set_media(false); + // Every chunk lands (the room keeps running meanwhile). + EXPECT_TRUE(run_room(r, cs, 8.0, {}, [&] { return B.tap.media("C", "mic") >= C.mic_sent; })); + + EXPECT_GT(C.cam_sent, 30u); + EXPECT_EQ(B.tap.media("C", "mic"), C.mic_sent) << "a middle peer's audio is never thinned"; + // 2 +/- 1 fps over the window, plus the first frame that goes at once. The gate's timer runs on + // wall time, so the bounds are those of the rate, not of a frame count. + const std::uint64_t thumbs = B.tap.media("C", "camera"); + EXPECT_GE(thumbs, 2u); + EXPECT_LE(thumbs, 7u); + EXPECT_EQ(A.tap.media("C", "camera"), 0u); + EXPECT_EQ(A.tap.media("C", "mic"), 0u); + ASSERT_TRUE(r.aoi); + EXPECT_GE(r.aoi->take_stats().media_thumb, 2u); +} + +// C joins 30 m from A and B (hall preset): the pairs start outer, and the relay pushes C's pose to +// each of them on the very step it first sees C -- not at the 5 s refresh -- so they show C at its +// spot within a step plus a round trip, never at a placeholder: until that push C has no body in +// their holons and no live pose frame ever arrives. +TEST(RoomRelayLoopbackTest, NewOuterPairPushedImmediately) { + relay_options o; + o.aoi = ra::hall_aoi_params(); + o.large_scene = true; + test_relay r(o); + client_list own = make_clients({"A", "B", "C"}); + test_client &A = *own[0], &B = *own[1], &C = *own[2]; + r.place_on_join = {{"A", {-2.0, 10.0}}, {"B", {2.0, 10.0}}, {"C", {32.0, 10.0}}}; + std::vector cs{&A, &B}; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + + ASSERT_TRUE(C.connect(r)); + C.join(); + cs.push_back(&C); + bool ghost = false; + double arrived = -1.0; + ASSERT_TRUE(run_room(r, cs, 4.0, {}, [&] { + for (test_client *rc : {&A, &B}) + if (rc->h.has_participant("C") && !rc->h.pose_known("C") && rc->h.has_body("C")) + ghost = true; + if (arrived < 0.0 && A.h.pose_known("C") && B.h.pose_known("C")) + arrived = now_s(); + return arrived >= 0.0; + })); + ASSERT_EQ(r.joined_at.count("C"), 1u); + EXPECT_EQ(r.tier("B", "C"), ra::aoi_tier::outer); + EXPECT_FALSE(ghost) << "no body for C before its first pose"; + // A step plus a round trip; the refresh would be 5 s. + EXPECT_LT(arrived - r.joined_at["C"], 1.0); + cvc::physics::body_state rs; + std::uint64_t rt = 0; + ASSERT_TRUE(r.tree_pose("C", rs, rt)); + EXPECT_NEAR(rs.position.x, 32.0, 0.05); + for (test_client *rc : {&A, &B}) { + cvc::physics::body_state s; + std::uint64_t t = 0; + ASSERT_TRUE(rc->tree_pose("C", s, t)) << rc->pid; + EXPECT_LE(xy_distance(s.position, rs.position), 1e-4) << rc->pid; + EXPECT_GE(rc->tap.pushed_ticks("C"), 1u) << rc->pid; + EXPECT_EQ(rc->tap.live_pose("C"), 0u) << rc->pid; + } +} + +// Default room, conference preset. A walks by proposing an intent; D is a client from before S7 +// whose shard observes its whole tree, so its avatar-marker echo under ui.docs goes up to the relay +// every frame. The relay applies A's intent, but no other client ever receives it (only the +// participant's own intent is its business), and the relay's interest gate withholds D's ui.docs +// writes, so not one of them reaches another client's shard. +TEST(RoomRelayLoopbackTest, NoIntentOrUiDocsLeak) { + test_relay r(relay_options{}); + const std::vector kinds = link_kinds(); + client_list own; + own.push_back(std::make_unique("A", kinds[0 % kinds.size()])); + own.push_back(std::make_unique("B", kinds[1 % kinds.size()])); + own.push_back(std::make_unique("D", kinds[2 % kinds.size()], "")); + const std::vector cs = ptrs(own); + test_client &A = *cs[0], &B = *cs[1], &D = *cs[2]; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + + const std::uint64_t filtered_a = A.shard->total_remote_filtered_out(); + const std::uint64_t filtered_b = B.shard->total_remote_filtered_out(); + const std::uint64_t withheld = r.withheld(); + const std::uint64_t live_a = B.tap.live_pose("A"); + cvc::ariadne::avatar_intent walk; + walk.move = {0.5, 0.0, 0.0}; + cvc::ariadne::write_intent(A.app, kRoom, "A", walk); + D.echo = true; + run_room(r, cs, 1.0); + + cvc::ariadne::avatar_intent got; + ASSERT_TRUE(cvc::ariadne::read_intent(r.app, kRoom, "A", got)) << "the relay has A's intent"; + EXPECT_DOUBLE_EQ(got.move.x, 0.5); + EXPECT_GT(B.tap.live_pose("A"), live_a) << "A walks, and B sees it"; + for (test_client *rc : {&B, &D}) { + EXPECT_FALSE(cvc::ariadne::read_intent(rc->app, kRoom, "A", got)) << rc->pid; + EXPECT_EQ(rc->tap.intents("A"), 0u) << rc->pid; + } + EXPECT_GT(r.withheld(), withheld) << "the relay gate withheld D's echo"; + EXPECT_EQ(B.shard->total_remote_filtered_out(), filtered_b) << "no ui.docs frame reached B"; + EXPECT_EQ(A.shard->total_remote_filtered_out(), filtered_a) << "no ui.docs frame reached A"; + EXPECT_EQ(B.app.root().findDescendant("ui.docs"), nullptr); + EXPECT_EQ(r.app.root().findDescendant("ui.docs"), nullptr) << "ingest keeps only the room"; +} + +// A walks (an intent it proposed), then its connection drops. The relay retracts it and zeroes +// its intent; a fresh process connecting under the same pid bootstraps the zeroed intent -- its +// own subtree is never filtered, and a departed pid is no longer a registered peer -- so it would +// not resume the walk, and B never saw A's intent at all. +// +// It does not JOIN under that pid: replication drops a write whose (origin, sequence) it has seen, +// so a process restarting under the same node id has its writes ignored until the relay restarts +// (which is why arigl gives every launch a random node-id suffix). The relaunch joins as arigl's +// would, under a fresh id, and the room settles around it after the departure. +TEST(RoomRelayLoopbackTest, ReconnectUnderSamePidGetsZeroedIntent) { + test_relay r(relay_options{}); + client_list own = make_clients({"A", "B"}); + test_client &B = *own[1]; + std::vector cs = ptrs(own); + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + + cvc::ariadne::avatar_intent walk; + walk.move = {0.6, 0.0, 0.0}; + cvc::ariadne::write_intent(own[0]->app, kRoom, "A", walk); + const auto relay_intent_x = [&](double &x) { + cvc::ariadne::avatar_intent in; + if (!cvc::ariadne::read_intent(r.app, kRoom, "A", in)) + return false; + x = in.move.x; + return true; + }; + double x = 0.0; + ASSERT_TRUE(run_room(r, cs, 3.0, {}, [&] { return relay_intent_x(x) && x > 0.5; })); + run_room(r, cs, 0.3); + + const link_kind kind = own[0]->kind; + own[0].reset(); // the connection closes + cs = {&B}; + ASSERT_TRUE(run_room(r, cs, 4.0, {}, [&] { + return !r.server.has_participant("A") && relay_intent_x(x) && x == 0.0; + })) << "the relay retracts A and zeroes its intent"; + + test_client again("A", kind); + ASSERT_TRUE(again.connect(r)); + cvc::ariadne::avatar_intent got; + ASSERT_TRUE(run_room(r, cs, 3.0, {}, [&] { + return cvc::ariadne::read_intent(again.app, kRoom, "A", got); + })) << "the bootstrap carries A's own intent"; + EXPECT_EQ(got.move.x, 0.0) << "zeroed, not the walk proposed before the drop"; + + test_client relaunch("A_2", kind); + ASSERT_TRUE(relaunch.connect(r)); + relaunch.join(); + cs.push_back(&relaunch); + ASSERT_TRUE(run_room(r, cs, 8.0, {}, [&] { return settled(r, cs); })); + run_room(r, cs, 0.5); + EXPECT_FALSE(r.server.has_participant("A")); + const cvc::physics::vec3 v = r.server.state("A_2").linear_velocity; + EXPECT_LT(std::hypot(v.x, v.y), 0.05) << "the relaunch stands where it spawned"; + EXPECT_EQ(B.tap.intents("A"), 0u) << "B never received A's intent"; + EXPECT_EQ(B.tap.intents("A_2"), 0u); +} + +namespace { + +struct late_join_run { + std::uint64_t pose_writes = 0; // relay pose writes between the early and late joiners + std::size_t journal = 0; // the relay journal's size when the late ones came + std::vector early; // bytes in to a joiner before the writes, per transport + std::vector late; // ... and to one after them, on the same transport + std::uint64_t backfilled = 0; // journal mutations the relay replayed to the late ones +}; + +// Connect a client that never joins the room and wait until its tree holds the room as the relay +// does (both poses, with their ticks, and the scene); false if it never does. +bool join_and_converge(test_relay &r, const std::vector &cs, test_client &j) { + if (!j.connect(r)) + return false; + const auto converged = [&] { + for (const char *q : {"A", "B"}) { + cvc::physics::body_state rs, js; + std::uint64_t rt = 0, jt = 0; + if (!r.tree_pose(q, rs, rt) || !j.tree_pose(q, js, jt) || jt != rt || + xy_distance(rs.position, js.position) > 1e-4) + return false; + } + return ra::scene_revision(j.app, kRoom) == ra::scene_revision(r.app, kRoom); + }; + const bool ok = run_room(r, cs, 4.0, {}, converged); + run_room(r, cs, 0.1); // anything still in flight for the join lands + return ok; +} + +// One joiner per transport (their framing differs) connects to a room where A and B stand; A and +// B then make at least 2000 pose writes on the relay; then another joiner per transport connects. +// TRIM: the relay as arisrv runs it (journal trimmed, no backfill on connect); otherwise it keeps +// its journal and backfills each joiner with it. The joiners stay connected (a departure would add +// a retraction to the room), and none joins it. +late_join_run run_late_join(bool trim) { + late_join_run out; + relay_options o; + o.trim = trim; + o.ws_broadcast = false; // only the joins' own bytes + test_relay r(o); + const client_list own = make_clients({"A", "B"}); + const std::vector cs = ptrs(own); + r.place_on_join = {{"A", {-3.0, 2.0}}, {"B", {3.0, 2.0}}}; + bring_up(r, cs); + if (::testing::Test::HasFatalFailure()) + return out; + const std::vector kinds = link_kinds(); + client_list joiners; + for (std::size_t k = 0; k < kinds.size(); ++k) { + joiners.push_back(std::make_unique("E" + std::to_string(k), kinds[k])); + EXPECT_TRUE(join_and_converge(r, cs, *joiners.back())) << "an early joiner never converged"; + out.early.push_back(joiners.back()->bytes_in()); + } + + // Both teleport a centimetre along a diagonal each step, stepping as fast as the relay can: two + // changed pose leaves each per step. + const std::uint64_t w0 = r.pose_writes(); + for (int k = 0; r.pose_writes() - w0 < 2000 && k < 20000; ++k) { + const double d = 0.01 * (k % 100); + r.place("A", -3.0 + d, 2.0 + d); + r.place("B", 3.0 - d, 2.0 + d); + r.step(); + if (k % 10 == 0) + for (test_client *c : cs) + c->frame(); + } + run_room(r, cs, 0.3); + out.pose_writes = r.pose_writes() - w0; + out.journal = r.shard->journal().size(); + + const std::uint64_t backfilled = r.backfilled(); + for (std::size_t k = 0; k < kinds.size(); ++k) { + joiners.push_back(std::make_unique("L" + std::to_string(k), kinds[k])); + EXPECT_TRUE(join_and_converge(r, cs, *joiners.back())) << "a late joiner never converged"; + out.late.push_back(joiners.back()->bytes_in()); + } + out.backfilled = r.backfilled() - backfilled; + return out; +} + +} // namespace + +// After 2000 pose writes the trimmed relay journal is about empty, and a late joiner bootstraps +// from the admit snapshot alone: nothing is backfilled, and it costs what a joiner that came +// before those writes cost -- the room as it is now, not its history, which a relay that keeps its +// journal replays to every joiner. +TEST(RoomRelayLoopbackTest, LateJoinerBootstrapsFromSnapshotOnly) { + const late_join_run keep = run_late_join(false); + ASSERT_FALSE(HasFatalFailure()); + const late_join_run trim = run_late_join(true); + ASSERT_FALSE(HasFatalFailure()); + const std::vector kinds = link_kinds(); + ASSERT_EQ(keep.early.size(), kinds.size()); + ASSERT_EQ(keep.late.size(), kinds.size()); + ASSERT_EQ(trim.early.size(), kinds.size()); + ASSERT_EQ(trim.late.size(), kinds.size()); + EXPECT_GE(trim.pose_writes, 2000u); + EXPECT_GE(keep.pose_writes, 2000u); + EXPECT_LE(trim.journal, 64u) << "the journal is erased as it is published"; + EXPECT_GE(keep.journal, 2000u); + EXPECT_EQ(trim.backfilled, 0u) << "the joiners bootstrap from the admit snapshot alone"; + EXPECT_GE(keep.backfilled, 2000u * kinds.size()); + for (std::size_t k = 0; k < kinds.size(); ++k) { + const char *name = kinds[k] == link_kind::ws ? "ws" : "grpc"; + std::printf("[ room ] %s join bytes before / after %llu pose writes: kept journal %llu / %llu; " + "trimmed %llu / %llu\n", + name, static_cast(trim.pose_writes), + static_cast(keep.early[k]), + static_cast(keep.late[k]), + static_cast(trim.early[k]), + static_cast(trim.late[k])); + SCOPED_TRACE(name); + EXPECT_GT(trim.early[k], 0u); + // The same room either time (the poses moved, so their digits differ a little). + EXPECT_LE(trim.late[k], trim.early[k] + trim.early[k] / 4 + 8 * 1024) + << "join bytes do not grow with the room's history"; + // At least ~50 bytes for each of the 2000 replayed writes. + EXPECT_GE(keep.late[k], keep.early[k] + 100u * 1000u); + } +} + +namespace { + +// STEPS room steps and the client frames between them on one timeline that never skips: a step +// every kRoomStepS and a frame of each client every kFrameS, each run when due, or at once when +// the runner is late. Every run therefore has arigl's 30 frames to arisrv's 20 steps. run_room +// skips ahead instead, so a starved loop runs fewer client frames per step, which shrinks the +// echo's share of the egress. SCRIPT runs before each step. +void run_paced(test_relay &r, const std::vector &cs, int steps, + const std::function &script) { + const double t0 = now_s(); + int step = 0; + long frame = 0; + while (step < steps) { + const double step_due = t0 + step * kRoomStepS; + const double frame_due = t0 + static_cast(frame) * kFrameS; + const bool stepping = step_due <= frame_due; + while (now_s() < (stepping ? step_due : frame_due)) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + if (stepping) { + script(now_s() - t0); + r.step(); + ++step; + } else { + for (test_client *c : cs) + c->frame(); + ++frame; + } + } +} + +// N=3 walking, each client echoing its avatar markers into ui.docs every frame as arigl does. +// S7: the relay as arisrv runs it and clients rooted at the room tree; otherwise the relay and +// clients from before it (no area of interest, no gate, a kept journal; whole-tree clients). +// Returns the relay's egress per second of room time: the bytes for a fixed number of steps and +// frames, however long a loaded runner takes over them. The ~1 Hz WebSocket re-broadcast is left +// out: it is the same room tree either way and would only dilute the comparison. +double run_control_plane(bool s7) { + relay_options o; + o.use_aoi = s7; + o.gate = s7; + o.trim = s7; + o.ws_broadcast = false; + test_relay r(o); + const client_list own = make_clients({"A", "B", "C"}, s7 ? "conf" : ""); + const std::vector cs = ptrs(own); + r.place_on_join = {{"A", {-4.0, 3.0}}, {"B", {4.0, 3.0}}, {"C", {0.0, -5.0}}}; + bring_up(r, cs); + if (::testing::Test::HasFatalFailure()) + return 0.0; + std::map walks; + for (test_client *c : cs) { + walks[c->pid].anchor = r.position(c->pid).x; + c->echo = true; + } + const auto script = [&](double) { + for (auto &kv : walks) + kv.second.drive(r, kv.first); + }; + run_room(r, cs, 0.5, script); + constexpr int kSteps = 30; // 1.5 s of room time + const std::uint64_t b0 = r.egress_bytes(); + run_paced(r, cs, kSteps, script); + // The relay forwards on its transport threads: let the window's last writes go out. + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + return static_cast(r.egress_bytes() - b0) / (kSteps * kRoomStepS); +} + +} // namespace + +// The echo is what the relay fanned for every walking avatar before S7: a client rooted at the +// room tree never sends it, and the relay's gate would withhold it from an older one. With it gone +// (and other participants' intents filtered), relay egress while N=3 walk is at most a third. +TEST(RoomRelayLoopbackTest, ControlPlaneEgressDrops) { + const double off = run_control_plane(false); + ASSERT_FALSE(HasFatalFailure()); + const double on = run_control_plane(true); + ASSERT_FALSE(HasFatalFailure()); + std::printf( + "[ room ] relay egress per room second, N=3 walking: %.1f kB before S7, %.1f with it\n", + off / 1000.0, on / 1000.0); + EXPECT_GT(on, 0.0); + EXPECT_LE(on, off / 3.0); +} + +// Hall preset with no downgrade dwell: every 100 ms C and D swap between beside B and 38 m away, +// so pairs flip between inner, middle and outer at 10 Hz while all four stream camera and mic and +// the WebSocket re-broadcast runs. Nothing crashes or deadlocks, and once everyone stands together +// again every receiver gets every publisher's media and every pose as the relay holds it. +TEST(RoomRelayLoopbackTest, ChurnSmoke) { + relay_options o; + o.aoi = ra::hall_aoi_params(); + o.aoi.downgrade_dwell_s = 0.0; + o.large_scene = true; + test_relay r(o); + const client_list own = make_clients({"A", "B", "C", "D"}); + const std::vector cs = ptrs(own); + r.place_on_join = { + {"A", {-2.0, 10.0}}, {"B", {2.0, 10.0}}, {"C", {6.0, 10.0}}, {"D", {40.0, 12.0}}}; + ASSERT_NO_FATAL_FAILURE(bring_up(r, cs)); + ASSERT_TRUE(r.aoi); + const std::uint64_t gen0 = r.aoi->snapshot()->generation(); + for (test_client *c : cs) + c->set_media(true); + run_room(r, cs, 3.0, [&](double t) { + const bool flip = static_cast(t / 0.1) % 2 == 1; + r.place("C", flip ? 40.0 : 6.0, 10.0); + r.place("D", flip ? 6.0 : 40.0, 12.0); + }); + EXPECT_GE(r.aoi->snapshot()->generation() - gen0, 10u) << "the tiers flipped"; + + // Together again, all within 10 m of each other: inner. + r.place("C", 5.0, 10.0); + r.place("D", 0.0, 13.0); + run_room(r, cs, 0.3); + std::map, std::uint64_t> before; + for (test_client *rc : cs) + for (test_client *q : cs) + if (q != rc) + before[{rc->pid, q->pid}] = rc->tap.media(q->pid, "camera"); + run_room(r, cs, 1.0); + for (test_client *c : cs) + c->set_media(false); + for (test_client *rc : cs) + for (test_client *q : cs) + if (q != rc) { + EXPECT_EQ(r.tier(rc->pid, q->pid), ra::aoi_tier::inner) << rc->pid << " " << q->pid; + const std::uint64_t was = before[{rc->pid, q->pid}]; + EXPECT_GT(rc->tap.media(q->pid, "camera"), was + 10) + << rc->pid << " gets " << q->pid << "'s camera at full rate again"; + } + const auto converged = [&] { + for (test_client *rc : cs) + for (test_client *q : cs) { + cvc::physics::body_state rs, ps; + std::uint64_t rt = 0, pt = 0; + if (!r.tree_pose(q->pid, rs, rt) || !rc->tree_pose(q->pid, ps, pt) || + xy_distance(rs.position, ps.position) > 1e-4) + return false; + } + return true; + }; + EXPECT_TRUE(run_room(r, cs, 3.0, {}, converged)) << "every pose converges after the churn"; +} diff --git a/src/cvc/tests/room_scene_test.cpp b/src/cvc/tests/room_scene_test.cpp index 8fef743a1..91a04e9a2 100644 --- a/src/cvc/tests/room_scene_test.cpp +++ b/src/cvc/tests/room_scene_test.cpp @@ -15,17 +15,25 @@ // H1 slice 5: the shared room scene (conf.rooms..scene.*). The server-authoritative scene // round-trips through scalar leaves back into the .ari Scene types, removal is soft, seeding is -// idempotent, and the seeded scene replicates to a client over the existing "conf" shard. +// idempotent, and the seeded scene replicates to a client over the existing "conf" shard. The +// large demo scene seeds mesh objects that physics never sees, and leaves the default one as it +// was. +#include #include #include +#include // room_statics #include #include +#include #include #include #include +#include +#include #include #include +#include #include namespace rs = cvc::ariadne::conference; @@ -44,6 +52,34 @@ const SceneNode *find_node(const std::vector &v, const std::string &i return &n; return nullptr; } +const SceneLight *find_light(const std::vector &v, const std::string &id) { + for (const SceneLight &l : v) + if (l.id == id) + return &l; + return nullptr; +} +// The leaf paths under PATH, relative to it ("source.box.x", ...). +std::set leaves_under(cvc::app &app, const std::string &path) { + std::set all, leaves; + cvc::state *n = app.root().findDescendant(path); + if (!n) + return leaves; + const std::string prefix = path + cvc::state::SEPARATOR; + for (const std::string &full : n->children()) + if (full.rfind(prefix, 0) == 0) + all.insert(full.substr(prefix.size())); + for (const std::string &p : all) { + bool inner = false; + for (const std::string &q : all) + inner = inner || q.rfind(p + cvc::state::SEPARATOR, 0) == 0; + if (!inner) + leaves.insert(p); + } + return leaves; +} +std::string object_path(const std::string &room, const std::string &id) { + return rs::room_scene_path(room) + ".objects." + id; +} } // namespace // ── one app: the schema round-trips ───────────────────────────────────────── @@ -75,6 +111,8 @@ TEST(RoomSceneTest, EnvRoundTrips) { spot.pos[0] = 1.0f, spot.pos[1] = 2.0f, spot.pos[2] = 3.0f; spot.cone = 30.0f; env.lights = {sun, spot}; + env.shadows = true; + env.shadow_resolution = 4096; rs::write_scene_env(app, "r", env); rs::room_scene_env out; @@ -83,6 +121,8 @@ TEST(RoomSceneTest, EnvRoundTrips) { expect_v3_eq(out.background_top, env.background_top); expect_v3_eq(out.background_bottom, env.background_bottom); EXPECT_FALSE(out.chrome); + EXPECT_TRUE(out.shadows); + EXPECT_EQ(out.shadow_resolution, 4096); ASSERT_EQ(out.lights.size(), 2u); const SceneLight &s = out.lights[1].id == "sun" ? out.lights[1] : out.lights[0]; const SceneLight &p = out.lights[1].id == "sun" ? out.lights[0] : out.lights[1]; @@ -101,6 +141,9 @@ TEST(RoomSceneTest, EnvRoundTrips) { TEST(RoomSceneTest, LightDroppedFromEnvIsSoftRemoved) { cvc::app app; rs::room_scene_env env = rs::default_scene_env(); + SceneLight sun; + sun.id = "sun"; + env.lights.push_back(sun); SceneLight fill; fill.id = "fill"; fill.kind = "fill"; @@ -111,8 +154,9 @@ TEST(RoomSceneTest, LightDroppedFromEnvIsSoftRemoved) { rs::room_scene_env out; ASSERT_TRUE(rs::read_scene_env(app, "r", out)); - ASSERT_EQ(out.lights.size(), 1u); - EXPECT_EQ(out.lights[0].id, "sun"); + ASSERT_EQ(out.lights.size(), 1u); // "fill" is gone + EXPECT_TRUE(find_light(out.lights, "sun")); + EXPECT_FALSE(find_light(out.lights, "fill")); } TEST(RoomSceneTest, ObjectRoundTrips) { @@ -235,8 +279,8 @@ TEST(RoomSceneTest, ReadRoomSceneIsARealizableAriadneScene) { ASSERT_NE(ref, nullptr); EXPECT_EQ(ref->source_primitive, "box"); EXPECT_FLOAT_EQ(ref->position[2], 0.5f); // stands on the ground - ASSERT_EQ(s.lights.size(), 1u); - EXPECT_EQ(s.lights[0].id, "sun"); + EXPECT_TRUE(s.lights.empty()) << "the light rig is the client document's, not the room's"; + EXPECT_FALSE(s.has_shadows) << "a room that leaves shadows off says nothing about them"; EXPECT_TRUE(s.has_chrome); EXPECT_FALSE(s.chrome_visible) << "the debug overlay is off in a conference room"; EXPECT_TRUE(s.has_background); @@ -263,7 +307,7 @@ TEST(RoomSceneTest, SeededSceneReplicatesToAClientAndTracksEdits) { ASSERT_EQ(got.nodes.size(), 2u); ASSERT_NE(find_node(got.nodes, "ground"), nullptr); ASSERT_NE(find_node(got.nodes, "ref"), nullptr); - ASSERT_EQ(got.lights.size(), 1u); + EXPECT_TRUE(got.lights.empty()); // The server moves the reference object and removes the ground; the client follows. SceneNode moved = rs::default_scene_objects()[1]; @@ -278,3 +322,206 @@ TEST(RoomSceneTest, SeededSceneReplicatesToAClientAndTracksEdits) { EXPECT_EQ(objs[0].id, "ref"); EXPECT_FLOAT_EQ(objs[0].position[0], 4.0f); } + +// ── a mesh's fit: ─────────────────────────────────────────────────────────── + +TEST(RoomSceneTest, FitRoundTripsAndClears) { + cvc::app app; + SceneNode m; + m.id = "m"; + m.source_file = "x.bunny"; + m.has_fit = true; + m.fit_up_y = true; + m.fit_height = 2.5f; + rs::write_scene_object(app, "r", m); + std::vector out = rs::read_scene_objects(app, "r"); + ASSERT_EQ(out.size(), 1u); + EXPECT_TRUE(out[0].has_fit); + EXPECT_TRUE(out[0].fit_up_y); + EXPECT_FLOAT_EQ(out[0].fit_height, 2.5f); + + // Rewritten without a fit: cleared, not left behind. + m.has_fit = false; + rs::write_scene_object(app, "r", m); + out = rs::read_scene_objects(app, "r"); + ASSERT_EQ(out.size(), 1u); + EXPECT_FALSE(out[0].has_fit); + + // An object that never had a fit writes no fit leaves at all. + SceneNode p; + p.id = "p"; + p.source_primitive = "plane"; + rs::write_scene_object(app, "r", p); + EXPECT_EQ(app.root().findDescendant(object_path("r", "p") + ".source.fit"), nullptr); + out = rs::read_scene_objects(app, "r"); + const SceneNode *plane = find_node(out, "p"); + ASSERT_NE(plane, nullptr); + EXPECT_FALSE(plane->has_fit); +} + +// ── the large demo scene (--scene large) ───────────────────────────────────── + +TEST(RoomSceneTest, LargeDemoSceneSeedsMeshes) { + cvc::app app; + ASSERT_TRUE(rs::seed_large_demo_scene(app, "r")); + EXPECT_FALSE(rs::seed_large_demo_scene(app, "r")) << "idempotent"; + EXPECT_FALSE(rs::seed_default_scene(app, "r")) << "a seeded room is never re-seeded"; + + // 100 m plane, the reference box, and a bunny in every cell of the 5 x 5 lattice but the + // middle one, which is the box's. + const std::vector objs = rs::read_scene_objects(app, "r"); + ASSERT_EQ(objs.size(), 26u); + EXPECT_EQ(rs::scene_revision(app, "r"), 27u); // env + 26 objects + const SceneNode *ground = find_node(objs, "ground"); + ASSERT_NE(ground, nullptr); + EXPECT_EQ(ground->source_primitive, "plane"); + EXPECT_FLOAT_EQ(ground->plane_size, 100.0f); + EXPECT_FALSE(ground->has_fit); + const SceneNode *ref = find_node(objs, "ref"); + ASSERT_NE(ref, nullptr); + EXPECT_EQ(ref->source_primitive, "box"); + EXPECT_FLOAT_EQ(ref->position[2], 0.5f); + EXPECT_FALSE(ref->has_fit); + + std::set> cells; + for (const SceneNode &n : objs) { + if (n.id == "ground" || n.id == "ref") + continue; + EXPECT_EQ(n.id.rfind("bunny_", 0), 0u) << n.id; + EXPECT_EQ(n.source_file, "x.bunny") << n.id; + EXPECT_TRUE(n.source_primitive.empty()) << n.id; + // The size comes from the fit (baked into the mesh), not a transform scale, so the mesh's + // own units are metres. + EXPECT_TRUE(n.has_fit && n.fit_up_y) << n.id; + EXPECT_FLOAT_EQ(n.fit_height, 1.7f) << n.id; + expect_v3_eq(n.scale, SceneNode().scale); + EXPECT_FLOAT_EQ(n.position[2], 0.0f) << n.id << ": the fit stands it on the ground"; + // Cell centres 20 m apart, inside the plane, never the origin. + const double gx = (n.position[0] + 40.0) / 20.0, gy = (n.position[1] + 40.0) / 20.0; + EXPECT_NEAR(gx, std::round(gx), 1e-4) << n.id; + EXPECT_NEAR(gy, std::round(gy), 1e-4) << n.id; + EXPECT_TRUE(gx > -0.5 && gx < 4.5 && gy > -0.5 && gy < 4.5) << n.id; + EXPECT_FALSE(std::lround(gx) == 2 && std::lround(gy) == 2) << n.id; + EXPECT_TRUE(n.visible_default); + cells.insert({std::lround(gx), std::lround(gy)}); + } + EXPECT_EQ(cells.size(), 24u) << "one bunny per cell"; + + // A realizable Scene, in the default room's environment. + const cvc::ariadne::Scene s = rs::read_room_scene(app, "r"); + EXPECT_EQ(s.nodes.size(), 26u); + EXPECT_TRUE(s.lights.empty()); + EXPECT_FALSE(s.has_shadows); + EXPECT_TRUE(s.has_chrome && !s.chrome_visible); + EXPECT_TRUE(s.background_gradient); +} + +TEST(RoomSceneTest, LargeDemoSceneLattice) { + // An even grid has no middle cell: every cell gets a bunny. + const std::vector even = rs::large_demo_scene_objects(60.0, 2); + ASSERT_EQ(even.size(), 2u + 4u); + EXPECT_FLOAT_EQ(even[0].plane_size, 60.0f); + for (std::size_t k = 2; k < even.size(); ++k) { + EXPECT_FLOAT_EQ(std::fabs(even[k].position[0]), 15.0f); + EXPECT_FLOAT_EQ(std::fabs(even[k].position[1]), 15.0f); + } + EXPECT_NE(find_node(even, "bunny_1_0"), nullptr); + // Odd: the middle is the box's. One cell: no bunny at all. + EXPECT_EQ(rs::large_demo_scene_objects(60.0, 3).size(), 2u + 8u); + EXPECT_EQ(find_node(rs::large_demo_scene_objects(60.0, 3), "bunny_1_1"), nullptr); + EXPECT_EQ(rs::large_demo_scene_objects(10.0, 1).size(), 2u); + + EXPECT_THROW(rs::large_demo_scene_objects(0.0, 5), std::invalid_argument); + EXPECT_THROW(rs::large_demo_scene_objects(std::numeric_limits::quiet_NaN(), 5), + std::invalid_argument); + EXPECT_THROW(rs::large_demo_scene_objects(100.0, 0), std::invalid_argument); + cvc::app app; + EXPECT_THROW(rs::seed_large_demo_scene(app, "r", -1.0), std::invalid_argument); + EXPECT_EQ(rs::scene_revision(app, "r"), 0u) << "a refused seed writes nothing"; +} + +// Physics statics are the primitives only: the bunnies are render meshes (and LOD proxies on the +// clients), so the server's and every client's prediction world stay the plane and the box. +TEST(RoomSceneTest, RoomStaticsIgnoreMeshes) { + cvc::app app; + ASSERT_TRUE(rs::seed_large_demo_scene(app, "r")); + const std::vector st = cvc::ariadne::room_statics(app, "r"); + ASSERT_EQ(st.size(), 2u) << "the plane and the box; the 24 bunnies have no physical shape"; + const cvc::ariadne::avatar_static &ground = st[0].half_extents.x > 5.0 ? st[0] : st[1]; + const cvc::ariadne::avatar_static &cube = st[0].half_extents.x > 5.0 ? st[1] : st[0]; + EXPECT_EQ(ground.type, cvc::ariadne::avatar_static::kind::box); + EXPECT_DOUBLE_EQ(ground.half_extents.x, 50.0); + EXPECT_DOUBLE_EQ(ground.half_extents.y, 50.0); + EXPECT_NEAR(ground.center.z + ground.half_extents.z, 0.0, 1e-9) << "the slab's top is the floor"; + EXPECT_EQ(cube.type, cvc::ariadne::avatar_static::kind::box); + EXPECT_DOUBLE_EQ(cube.half_extents.x, 0.5); + EXPECT_DOUBLE_EQ(cube.center.z, 0.5); +} + +// The default seed is what it always was: the same two objects, the same leaves (no fit), the +// same revision -- the live 20 m room does not change with the large scene's arrival. +TEST(RoomSceneTest, DefaultSceneUnchanged) { + cvc::app app; + ASSERT_TRUE(rs::seed_default_scene(app, "r")); + EXPECT_EQ(rs::scene_revision(app, "r"), 3u); + const std::vector objs = rs::read_scene_objects(app, "r"); + ASSERT_EQ(objs.size(), 2u); + const SceneNode *ground = find_node(objs, "ground"); + const SceneNode *ref = find_node(objs, "ref"); + ASSERT_NE(ground, nullptr); + ASSERT_NE(ref, nullptr); + EXPECT_EQ(ground->source_primitive, "plane"); + EXPECT_FLOAT_EQ(ground->plane_size, 20.0f); + EXPECT_EQ(ref->source_primitive, "box"); + expect_v3_eq(ref->box_size, rs::default_scene_objects()[1].box_size); + EXPECT_FLOAT_EQ(ref->position[2], 0.5f); + EXPECT_FALSE(ground->has_fit || ref->has_fit); + + const std::set expected = {"material.ambient", + "material.color.b", + "material.color.g", + "material.color.r", + "material.diffuse", + "material.specular", + "material.specular_power", + "present", + "source.box.x", + "source.box.y", + "source.box.z", + "source.plane_size", + "source.primitive", + "source.radius", + "source.uri", + "transform.pos.x", + "transform.pos.y", + "transform.pos.z", + "transform.rot.x", + "transform.rot.y", + "transform.rot.z", + "transform.scale.x", + "transform.scale.y", + "transform.scale.z", + "visible"}; + EXPECT_EQ(leaves_under(app, object_path("r", "ground")), expected); + EXPECT_EQ(leaves_under(app, object_path("r", "ref")), expected); + // The large scene's ground and box are the default's, bar the plane's size. + const std::vector large = rs::large_demo_scene_objects(); + ASSERT_GE(large.size(), 2u); + EXPECT_EQ(large[0].id, "ground"); + EXPECT_EQ(large[1].id, "ref"); + expect_v3_eq(large[1].position, ref->position); + expect_v3_eq(large[1].color, ref->color); +} + +// A room that turns shadows on asserts them (re-baked every frame, for moving avatars). +TEST(RoomSceneTest, RoomShadowsAreAssertedOnlyWhenOn) { + cvc::app app; + rs::room_scene_env env = rs::default_scene_env(); + env.shadows = true; + env.shadow_resolution = 4096; + rs::write_scene_env(app, "r", env); + const cvc::ariadne::Scene s = rs::read_room_scene(app, "r"); + EXPECT_TRUE(s.has_shadows && s.shadows_enabled); + EXPECT_TRUE(s.has_shadow_resolution && s.shadow_resolution == 4096); + EXPECT_TRUE(s.has_shadow_interval && s.shadow_interval == 1); +} diff --git a/src/cvc/tests/state_change_journal_test.cpp b/src/cvc/tests/state_change_journal_test.cpp index 8e25cc54c..5a423ea3f 100644 --- a/src/cvc/tests/state_change_journal_test.cpp +++ b/src/cvc/tests/state_change_journal_test.cpp @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -99,6 +101,74 @@ TEST(StateChangeJournalTest, ClearDropsMutationsButKeepsMonotonicSequence) { EXPECT_EQ(journal.last_sequence(), 3u); } +TEST(StateChangeJournalTest, ReplayAfterMatchesLinearScan) { + // replay_after binary-searches the sequence; a linear scan of snapshot() is the reference. + // Occasional trims keep the journal from always starting at sequence 1. + state_change_journal journal("node_a"); + std::mt19937 rng(20261024u); + std::uniform_int_distribution appends_per_round(0, 8); + for (int round = 0; round < 200; ++round) { + for (int i = appends_per_round(rng); i > 0; --i) + journal.append(make_value_mutation("p." + std::to_string(round), std::to_string(i))); + if (round % 7 == 3) + journal.trim_through( + std::uniform_int_distribution(0, journal.last_sequence())(rng)); + + const std::vector all = journal.snapshot(); + std::uniform_int_distribution pick_cursor(0, journal.last_sequence() + 2); + for (int probe = 0; probe < 8; ++probe) { + const std::uint64_t cursor = pick_cursor(rng); + std::vector want; + for (const state_mutation &m : all) + if (m.sequence > cursor) + want.push_back(m.sequence); + std::vector got; + for (const state_mutation &m : journal.replay_after(cursor)) + got.push_back(m.sequence); + ASSERT_EQ(got, want) << "cursor " << cursor << " in round " << round; + } + } + + journal.append(make_value_mutation("p.last", "1")); // the last round may have trimmed it all + EXPECT_EQ(journal.replay_after(0).size(), journal.size()); + EXPECT_TRUE(journal.replay_after(journal.last_sequence()).empty()); + EXPECT_TRUE(journal.replay_after((std::numeric_limits::max)()).empty()); +} + +TEST(StateChangeJournalTest, TrimThroughDropsPrefix) { + state_change_journal journal("node_a"); + for (int i = 1; i <= 5; ++i) + journal.append(make_value_mutation("t", std::to_string(i))); + + EXPECT_EQ(journal.trim_through(0), 0u); // no entry is at or below 0 + EXPECT_EQ(journal.size(), 5u); + + EXPECT_EQ(journal.trim_through(2), 2u); + std::vector left = journal.snapshot(); + ASSERT_EQ(left.size(), 3u); + EXPECT_EQ(left.front().sequence, 3u); + EXPECT_EQ(left.front().string_value, "3"); + EXPECT_EQ(left.back().sequence, 5u); + EXPECT_EQ(journal.trim_through(2), 0u); // already gone + + // A cursor inside the trimmed range replays only the survivors. + EXPECT_EQ(journal.replay_after(1).size(), 3u); + EXPECT_EQ(journal.replay_after(3).size(), 2u); + + // Sequences keep counting from where they were. + EXPECT_EQ(journal.last_sequence(), 5u); + EXPECT_EQ(journal.append(make_value_mutation("t", "6")).sequence, 6u); + + // Trimming past the end empties the journal and still leaves the numbering alone. + EXPECT_EQ(journal.trim_through(100), 4u); + EXPECT_EQ(journal.size(), 0u); + EXPECT_TRUE(journal.replay_after(0).empty()); + EXPECT_EQ(journal.last_sequence(), 6u); + state_mutation next = journal.append(make_value_mutation("t", "7")); + EXPECT_EQ(next.sequence, 7u); + EXPECT_EQ(next.mutation_id, "node_a:7"); +} + TEST(StateChangeJournalTest, PayloadHelpersRepresentInlineAndBlobData) { std::vector bytes = {1, 2, 3, 4}; state_payload inline_payload = state_payload::inline_data(bytes); diff --git a/src/cvc/tests/state_cluster_shard_test.cpp b/src/cvc/tests/state_cluster_shard_test.cpp index 96e698ee1..be957c23f 100644 --- a/src/cvc/tests/state_cluster_shard_test.cpp +++ b/src/cvc/tests/state_cluster_shard_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -181,6 +182,114 @@ TEST(StateClusterShardTest, RewindPublishCursorReemits) { EXPECT_EQ(d1.size(), d2.size()); } +TEST(StateClusterShardTest, TrimPublishedKeepsDrainOrdered) { + cvc::app a; + cvc::state_cluster_shard sh(a, "clusterA", "nodeA"); + sh.attach(); + const auto set = [&](const char *v) { cvc::state::instance(a)("tp.path").value(std::string(v)); }; + set("seed"); // the first set on a fresh child may land before the adapter observes it + set("a"); + set("b"); + + // Nothing drained yet: a trim keeps every pending entry. + const std::size_t pending = sh.journal().size(); + ASSERT_GE(pending, 2u); + EXPECT_EQ(sh.trim_published(), 0u); + EXPECT_EQ(sh.journal().size(), pending); + + // After a partial drain, a trim drops only what was handed out, and the rest drains next. + auto first = sh.drain_local(1); + ASSERT_EQ(first.size(), 1u); + EXPECT_EQ(sh.trim_published(), 1u); + EXPECT_EQ(sh.journal().size(), pending - 1); + auto rest = sh.drain_local(); + ASSERT_EQ(rest.size(), pending - 1); + EXPECT_GT(rest.front().sequence, first.back().sequence); + EXPECT_EQ(rest.back().string_value, "b"); + + const std::uint64_t cursor = sh.published_cursor(); + EXPECT_EQ(sh.trim_published(), pending - 1); + EXPECT_EQ(sh.journal().size(), 0u); + EXPECT_EQ(sh.published_cursor(), cursor); // a trim never moves the cursor + EXPECT_TRUE(sh.drain_local().empty()); + + // Writes after the trim drain in order, numbered past everything trimmed. + set("c"); + set("d"); + set("e"); + auto after = sh.drain_local(); + ASSERT_EQ(after.size(), 3u); + EXPECT_GT(after[0].sequence, cursor); + EXPECT_GT(after[1].sequence, after[0].sequence); + EXPECT_GT(after[2].sequence, after[1].sequence); + EXPECT_EQ(after[0].string_value, "c"); + EXPECT_EQ(after[1].string_value, "d"); + EXPECT_EQ(after[2].string_value, "e"); + + // A late joiner's backfill and a rewind into the trimmed range see only the survivors. + auto replay = sh.replay_local(0); + ASSERT_EQ(replay.size(), 3u); + EXPECT_EQ(replay.front().sequence, after.front().sequence); + EXPECT_EQ(replay.back().sequence, after.back().sequence); + sh.rewind_publish_cursor(0); + auto again = sh.drain_local(); + ASSERT_EQ(again.size(), 3u); + EXPECT_EQ(again.front().string_value, "c"); + EXPECT_EQ(sh.published_cursor(), after.back().sequence); +} + +// A client shard rooted at the room tree ("conf") observes only that subtree: UI state written +// elsewhere in the same app (marker matrices under ui.docs, rewritten every frame while avatars +// move) is never journaled, so it is never drained, sent or relayed. +TEST(StateClusterShardTest, RootedShardJournalsOnlyItsSubtree) { + const std::string marker = "ui.docs.ari.avatar_x.matrix"; + const std::string joined = "conf.rooms.r.participants.p.presence.joined"; + const auto in_room = [](const std::string &path) { + return path == "conf" || path.compare(0, 5, "conf.") == 0; + }; + + cvc::app client_app, relay_app; + cvc::state_transport_inproc t; + cvc::state_cluster_shard client(client_app, "conf", "client", "conf"); + cvc::state_cluster_shard relay(relay_app, "conf", "relay"); + client.attach(); + relay.attach(); + t.register_shard(&client); + t.register_shard(&relay); + + // Two writes per leaf: the first on a fresh child may land before the adapter observes it. + cvc::state &tree = cvc::state::instance(client_app); + tree(marker).value(std::string("1 0 0 0")); + tree(marker).value(std::string("0 1 0 0")); + tree(joined).value(std::string("0")); + tree(joined).value(std::string("1")); + tree(marker).value(std::string("0 0 1 0")); + + bool joined_journaled = false; + for (const auto &m : client.journal().snapshot()) { + EXPECT_TRUE(in_room(m.path)) << "journaled outside the root: " << m.path; + joined_journaled = joined_journaled || (m.path == joined && m.string_value == "1"); + } + EXPECT_TRUE(joined_journaled); + + EXPECT_GE(t.pump_all(), 1u); + cvc::state *got = cvc::state::instance(relay_app).findDescendant(joined); + ASSERT_NE(got, nullptr); + EXPECT_EQ(got->value(), "1"); + EXPECT_EQ(cvc::state::instance(relay_app).findDescendant("ui.docs.ari"), nullptr); + + // The same marker writes on an unrooted shard are journaled: rooting is what keeps them home. + cvc::app other_app; + cvc::state_cluster_shard unrooted(other_app, "conf", "other"); + unrooted.attach(); + cvc::state::instance(other_app)(marker).value(std::string("1 0 0 0")); + cvc::state::instance(other_app)(marker).value(std::string("0 1 0 0")); + bool marker_journaled = false; + for (const auto &m : unrooted.journal().snapshot()) + marker_journaled = marker_journaled || m.path == marker; + EXPECT_TRUE(marker_journaled); +} + TEST(StateClusterShardTest, IngestRemoteScopedToRootPath) { cvc::app a; cvc::state_cluster_shard sh(a, "clusterA", "nodeA"); diff --git a/src/cvc/tests/state_interest_filter_test.cpp b/src/cvc/tests/state_interest_filter_test.cpp index a78b76c9e..7faf46b04 100644 --- a/src/cvc/tests/state_interest_filter_test.cpp +++ b/src/cvc/tests/state_interest_filter_test.cpp @@ -219,6 +219,60 @@ TEST(StateInterestFilterTest, IngestRemoteMessageRespectsFilter) { EXPECT_EQ(sh.total_remote_filtered_out(), 1u); } +// A relay asks admits() / admits_message() whether its own shard would take a frame before +// re-fanning it, so each answer must match what ingest then does with the same frame. +TEST(StateInterestFilterTest, AdmitsMirrorsIngestGate) { + cvc::app a; + cvc::state_cluster_shard sh(a, "clusterA", "nodeA"); + sh.attach(); + sh.set_enforce_interest(true); + sh.add_interest("conf"); + + const auto check = [&](const cvc::state_mutation &m, bool want) { + EXPECT_EQ(sh.admits(m), want) << m.path; + auto r = sh.ingest_remote(m); + EXPECT_EQ(r.applied, want) << m.path << ": " << r.reject_reason; + EXPECT_EQ(r.rejected, !want) << m.path; + }; + + check(make_set_value("nodeB", 1, "conf.rooms.r.y", "v"), true); + check(make_set_value("nodeB", 2, "ui.docs.x", "v"), false); + check(make_set_value("nodeB", 3, "conference.x", "v"), false); // dot-segment boundary + + // Delegation ops pass whatever their path, as on ingest's control-plane fast path. + cvc::state_mutation d = make_set_value("nodeB", 4, "ui.docs", ""); + d.op = cvc::state_mutation_op::delegate_subtree; + d.string_value = "clusterB"; + check(d, true); + cvc::state_mutation rv = make_set_value("nodeB", 5, "ui.docs", ""); + rv.op = cvc::state_mutation_op::revoke_delegation; + check(rv, true); + + // With enforcement off everything is admitted, as ingest then takes everything. + sh.set_enforce_interest(false); + check(make_set_value("nodeB", 6, "ui.docs.x", "v"), true); + + // Messages: the cluster check, then interest. + sh.set_enforce_interest(true); + const auto check_message = [&](const std::string &id, const std::string &cluster, + const std::string &path, bool want) { + cvc::state_message msg; + msg.cluster_id = cluster; + msg.origin_node_id = "nodeB"; + msg.message_id = id; + msg.path = path; + EXPECT_EQ(sh.admits_message(msg), want) << id; + EXPECT_EQ(sh.ingest_remote_message(msg), want) << id; + }; + check_message("m1", "clusterA", "conf.event", true); + check_message("m2", "clusterA", "ui.event", false); + check_message("m3", "clusterB", "conf.event", false); + check_message("m4", "", "conf.event", true); // an unstamped message is this cluster's + sh.set_enforce_interest(false); + check_message("m5", "clusterA", "ui.event", true); + check_message("m6", "clusterB", "ui.event", false); // the cluster check is not interest +} + TEST(StateInterestFilterTest, DelegationOpsBypassInterestFilter) { // Control-plane mutations are routing metadata, not value // writes; they must always flow regardless of interest set. diff --git a/src/cvc/tests/state_peer_registry_test.cpp b/src/cvc/tests/state_peer_registry_test.cpp index 9d08c545b..da6069a3a 100644 --- a/src/cvc/tests/state_peer_registry_test.cpp +++ b/src/cvc/tests/state_peer_registry_test.cpp @@ -3,11 +3,40 @@ Phase 5 — state_peer_registry tests. */ +#include #include #include +#include +#include +#include +#include +#include +#include +#include using cvc::state_peer_registry; +namespace { + +// Denies every path under one prefix. The prefix is owned by the filter, so evaluating a filter +// that was already freed reads freed memory (which a sanitizer build reports). +class deny_prefix_filter final : public cvc::state_delivery_filter { +public: + explicit deny_prefix_filter(std::string prefix) : _prefix(std::move(prefix)) {} + bool deliver(const std::string &path) const noexcept override { + return !state_peer_registry::prefix_matches(_prefix, path); + } + +private: + std::string _prefix; +}; + +std::shared_ptr deny(const std::string &prefix) { + return std::make_shared(prefix); +} + +} // namespace + TEST(StatePeerRegistry, PrefixMatchesEmptyMatchesAll) { EXPECT_TRUE(state_peer_registry::prefix_matches("", "anything")); EXPECT_TRUE(state_peer_registry::prefix_matches("", "")); @@ -96,3 +125,222 @@ TEST(StatePeerRegistry, ClearWipesAll) { EXPECT_EQ(0u, r.size()); EXPECT_FALSE(r.has_peer("a")); } + +// ---- Delivery filter ---- + +TEST(StatePeerRegistry, FilterDeniesAfterPrefixesAllow) { + state_peer_registry r; + r.add_peer("n1", "c", "", {"conf"}); + ASSERT_TRUE(r.set_filter("n1", deny("conf.rooms.r.participants.q.pose"))); + + // Both rules must pass: the prefix admits all of conf, the filter then takes q's pose back out. + EXPECT_TRUE(r.should_deliver("n1", "conf.rooms.r.participants.q.presence.joined")); + EXPECT_FALSE(r.should_deliver("n1", "conf.rooms.r.participants.q.pose")); + EXPECT_FALSE(r.should_deliver("n1", "conf.rooms.r.participants.q.pose.x")); + EXPECT_TRUE(r.should_deliver("n1", "conf.rooms.r.participants.q.posex")); + // A path the prefixes refuse stays refused although the filter would pass it. + EXPECT_FALSE(r.should_deliver("n1", "ui.docs.x")); + + // A filter also denies on a match-all peer (no prefixes). + r.add_peer("n2", "c", ""); + ASSERT_TRUE(r.set_filter("n2", deny("ui"))); + EXPECT_FALSE(r.should_deliver("n2", "ui.docs.x")); + EXPECT_TRUE(r.should_deliver("n2", "conf.x")); + + // Clearing the filter leaves the prefixes in force. + ASSERT_TRUE(r.set_filter("n1", nullptr)); + EXPECT_TRUE(r.should_deliver("n1", "conf.rooms.r.participants.q.pose.x")); + EXPECT_FALSE(r.should_deliver("n1", "ui.docs.x")); +} + +TEST(StatePeerRegistry, UnknownPeerStillDelivers) { + state_peer_registry r; + r.add_peer("n1", "c", "", {"conf"}); + ASSERT_TRUE(r.set_filter("n1", deny("conf"))); + + // Another peer's rules never leak onto an unregistered id. + EXPECT_TRUE(r.should_deliver("n2", "conf.x")); + EXPECT_TRUE(r.should_deliver("n2", "ui.docs.x")); + const state_peer_registry::delivery_view v = r.view_of("n2"); + EXPECT_FALSE(v.known); + EXPECT_FALSE(v.filtered()); + EXPECT_TRUE(v.subscriptions.empty()); + EXPECT_EQ(v.filter, nullptr); + EXPECT_TRUE(v.deliver("conf.x")); + EXPECT_TRUE(v.deliver("")); +} + +TEST(StatePeerRegistry, SetFilterOnUnknownPeerFails) { + state_peer_registry r; + EXPECT_FALSE(r.set_filter("ghost", deny("a"))); + EXPECT_FALSE(r.set_filter("ghost", nullptr)); + // The failed call registers nothing and leaves nothing behind for a later add_peer. + EXPECT_FALSE(r.has_peer("ghost")); + EXPECT_EQ(0u, r.size()); + EXPECT_TRUE(r.should_deliver("ghost", "a.x")); + r.add_peer("ghost", "c", ""); + EXPECT_TRUE(r.should_deliver("ghost", "a.x")); + EXPECT_EQ(r.view_of("ghost").filter, nullptr); +} + +TEST(StatePeerRegistry, AddPeerAgainKeepsFilter) { + state_peer_registry r; + r.add_peer("n1", "c", "addr1", {"a"}); + const auto f = deny("a.secret"); + ASSERT_TRUE(r.set_filter("n1", f)); + + // A re-add (a reconnect re-announcing the peer) replaces the prefixes, cluster and endpoint... + r.add_peer("n1", "c2", "addr2", {"a", "b"}); + EXPECT_TRUE(r.should_deliver("n1", "b.x")); + // ...and keeps the filter. + EXPECT_FALSE(r.should_deliver("n1", "a.secret.k")); + EXPECT_TRUE(r.should_deliver("n1", "a.pub")); + + auto snap = r.snapshot(); + ASSERT_EQ(1u, snap.size()); + EXPECT_EQ("c2", snap[0].cluster_id); + EXPECT_EQ("addr2", snap[0].endpoint); + EXPECT_EQ(f, snap[0].filter); + EXPECT_EQ(f, r.view_of("n1").filter); +} + +TEST(StatePeerRegistry, RemovePeerDropsFilter) { + state_peer_registry r; + r.add_peer("n1", "c", "", {"a"}); + std::weak_ptr watch; + { + auto f = deny("a.secret"); + watch = f; + ASSERT_TRUE(r.set_filter("n1", std::move(f))); + } + EXPECT_FALSE(r.should_deliver("n1", "a.secret")); + + EXPECT_TRUE(r.remove_peer("n1")); + EXPECT_TRUE(watch.expired()); // the registry held the last reference + EXPECT_TRUE(r.should_deliver("n1", "a.secret")); + + // Coming back under the same id starts without a filter. + r.add_peer("n1", "c", "", {"a"}); + EXPECT_TRUE(r.should_deliver("n1", "a.secret")); + EXPECT_EQ(r.view_of("n1").filter, nullptr); + + // Replacing and clearing release the old filter too. + ASSERT_TRUE(r.set_filter("n1", deny("a.x"))); + std::weak_ptr replaced = r.view_of("n1").filter; + ASSERT_TRUE(r.set_filter("n1", deny("a.y"))); + EXPECT_TRUE(replaced.expired()); + std::weak_ptr cleared = r.view_of("n1").filter; + r.clear(); + EXPECT_TRUE(cleared.expired()); +} + +TEST(StatePeerRegistry, ViewOfMatchesShouldDeliver) { + state_peer_registry r; + r.add_peer("open", "c", ""); + r.add_peer("subs", "c", "", {"conf", "ui.docs.a"}); + r.add_peer("filt", "c", ""); + ASSERT_TRUE(r.set_filter("filt", deny("conf.x"))); + r.add_peer("both", "c", "", {"conf"}); + ASSERT_TRUE(r.set_filter("both", deny("conf.rooms.r.participants.p1"))); + + struct expected_view { + const char *id; + bool known; + bool filtered; + }; + const expected_view peers[] = {{"open", true, false}, + {"subs", true, true}, + {"filt", true, true}, + {"both", true, true}, + {"unknown", false, false}}; + + // Paths are a stem near the rules' boundaries plus a random tail, so every rule both passes and + // refuses some of them. + const char *stems[] = {"", + "conf", + "conf.x", + "conf.xy", + "conf.rooms.r.participants.p1", + "conf.rooms.r.participants.p10", + "conf.rooms.r.participants.p2", + "ui", + "ui.docs.a", + "ui.docs.ab", + "scene"}; + const char *tails[] = {"pose", "x", "joined", "matrix", "p1"}; + std::mt19937 rng(20261024u); + std::uniform_int_distribution pick_stem(0, static_cast(std::size(stems)) - 1); + std::uniform_int_distribution pick_tail(0, static_cast(std::size(tails)) - 1); + std::uniform_int_distribution tail_len(0, 3); + std::vector paths; + for (int i = 0; i < 2000; ++i) { + std::string p = stems[pick_stem(rng)]; + for (int k = tail_len(rng); k > 0; --k) + p += (p.empty() ? "" : ".") + std::string(tails[pick_tail(rng)]); + paths.push_back(std::move(p)); + } + + for (const expected_view &e : peers) { + const state_peer_registry::delivery_view v = r.view_of(e.id); + EXPECT_EQ(v.known, e.known) << e.id; + EXPECT_EQ(v.filtered(), e.filtered) << e.id; + std::size_t delivered = 0; + for (const std::string &p : paths) { + const bool want = r.should_deliver(e.id, p); + ASSERT_EQ(v.deliver(p), want) << e.id << " '" << p << "'"; + delivered += want ? 1 : 0; + } + if (e.filtered) { + EXPECT_GT(delivered, 0u) << e.id; + EXPECT_LT(delivered, paths.size()) << e.id; + } else { + EXPECT_EQ(delivered, paths.size()) << e.id; + } + } +} + +TEST(StatePeerRegistry, ConcurrentSetFilterAndShouldDeliver) { + // One thread swaps the peer's filter as fast as it can while another asks for deliveries. + // Paths the swap cannot affect must answer the same every time, and a filter replaced + // mid-evaluation must stay alive until the reader is done with it. + constexpr int kIterations = 100000; + state_peer_registry r; + r.add_peer("n1", "c", "", {"conf"}); + + std::atomic go{false}; + std::atomic wrong{0}; + std::thread writer([&]() { + while (!go.load(std::memory_order_acquire)) { + } + // Odd iterations install a fresh deny filter, even ones clear it; the last one is odd. + for (int i = 0; i < kIterations; ++i) + r.set_filter("n1", (i % 2 == 1) ? deny("conf.secret") : nullptr); + }); + std::thread reader([&]() { + while (!go.load(std::memory_order_acquire)) { + } + for (int i = 0; i < kIterations; ++i) { + bool pub; + bool outside; + if (i % 2 == 0) { + pub = r.should_deliver("n1", "conf.pub.x"); + outside = r.should_deliver("n1", "ui.docs.x"); + (void)r.should_deliver("n1", "conf.secret.x"); // either answer; it must not crash + } else { + const state_peer_registry::delivery_view v = r.view_of("n1"); + pub = v.deliver("conf.pub.x"); + outside = v.deliver("ui.docs.x"); + (void)v.deliver("conf.secret.x"); + } + if (!pub || outside) + wrong.fetch_add(1, std::memory_order_relaxed); + } + }); + go.store(true, std::memory_order_release); + writer.join(); + reader.join(); + + EXPECT_EQ(wrong.load(), 0); + EXPECT_FALSE(r.should_deliver("n1", "conf.secret.x")); + EXPECT_TRUE(r.should_deliver("n1", "conf.pub.x")); +} diff --git a/src/cvc/tests/state_transport_grpc_test.cpp b/src/cvc/tests/state_transport_grpc_test.cpp index 32bc51009..e1572ffcf 100644 --- a/src/cvc/tests/state_transport_grpc_test.cpp +++ b/src/cvc/tests/state_transport_grpc_test.cpp @@ -8,14 +8,19 @@ License version 2.1 as published by the Free Software Foundation. */ +#include #include #include #include +#include #include #include #include +#include +#include #include #include +#include #include #include #include @@ -1220,3 +1225,532 @@ TEST(StateTransportGrpcTest, RelayForwardsBetweenStarClients) { sA.detach(); sB.detach(); } + +// ---------------------------------------------------------------------------- +// Per-peer delivery on a relay: connection identity, the relay interest gate, registry-filtered +// forwards and snapshots, targeted snapshot pushes, and the media egress gate. +// ---------------------------------------------------------------------------- + +namespace { + +// Wait until `t` has a live link identified (its Hello read) as each of `ids`. +bool wait_identified(const cvc::state_transport_grpc &t, const std::vector &ids, + std::chrono::milliseconds to) { + return wait_until( + [&] { + const auto links = t.link_reports(); + return std::all_of(ids.begin(), ids.end(), [&](const std::string &id) { + return std::any_of(links.begin(), links.end(), [&](const cvc::state_link_report &l) { + return l.remote_node_id == id; + }); + }); + }, + to); +} + +// The value at `path`, or "" when the node does not exist (looked up without creating it). +std::string leaf(cvc::app &a, const std::string &path) { + cvc::state *n = cvc::state::instance(a).findDescendant(path); + return n ? n->value() : std::string(""); +} + +// Write `v` at a fresh `path` so that it is journaled: the first set on a new child is not +// observed, so it is set twice (as in RelayForwardsBetweenStarClients). +void write_fresh(cvc::app &a, const std::string &path, const std::string &v) { + cvc::state::instance(a)(path).value(std::string("init")); + cvc::state::instance(a)(path).value(v); +} + +cvc::state_mutation value_mutation(const std::string &origin, std::uint64_t seq, + const std::string &path, const std::string &value) { + cvc::state_mutation m; + m.cluster_id = "C"; + m.tree_id = "default"; + m.origin_node_id = origin; + m.sequence = seq; + m.mutation_id = origin + ":" + std::to_string(seq); + m.path = path; + m.op = cvc::state_mutation_op::set_value; + m.type_name = "std::string"; + m.string_value = value; + return m; +} + +cvc::state_transport::snapshot_entry leaf_entry(const std::string &path, const std::string &value) { + cvc::state_transport::snapshot_entry e; + e.path = path; + e.string_value = value; + e.type_name = "std::string"; + return e; +} + +// Denies one path prefix (and everything under it); delivers the rest. +class deny_prefix_filter final : public cvc::state_delivery_filter { +public: + explicit deny_prefix_filter(std::string prefix) : _prefix(std::move(prefix)) {} + bool deliver(const std::string &path) const noexcept override { + return !cvc::state_peer_registry::prefix_matches(_prefix, path); + } + +private: + std::string _prefix; +}; + +std::uint64_t filtered_count(const cvc::state_transport_grpc &t, const std::string &node_id) { + for (const auto &p : t.peers().snapshot()) + if (p.node_id == node_id) + return p.deliveries_filtered; + return 0; +} + +} // namespace + +// A connection's peer is unknown until a Hello names it. A client whose Hello carries no node id +// stays unknown: its link reports no id and the registry cannot filter it, so it gets everything. +// A client whose Hello names it reports that name, and its registry entry filters publish() from +// then on. +TEST(StateTransportGrpcTest, PeerIdUnknownUntilHello) { + cvc::app aN, aB; + cvc::state_transport_grpc tR, tN, tB; + tR.start("127.0.0.1:0", "R", "C"); + tN.start("127.0.0.1:0"); // no node id: its Hello names nobody + tB.start("127.0.0.1:0", "B", "C"); + tR.peers().add_peer("B", "C", "", {"room"}); + + cvc::state_cluster_shard sN(aN, "C", "N"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sN.attach(); + sB.attach(); + tN.register_shard(&sN); + tB.register_shard(&sB); + + ASSERT_TRUE(tN.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_until([&] { return tR.connection_count() == 2; }, std::chrono::seconds(3))); + ASSERT_TRUE(wait_identified(tR, {"B"}, std::chrono::seconds(3))); + { + const auto links = tR.link_reports(); + ASSERT_EQ(links.size(), 2u); + EXPECT_EQ( + std::count_if(links.begin(), links.end(), + [](const cvc::state_link_report &l) { return l.remote_node_id.empty(); }), + 1) + << "the unnamed client's link reports no peer id"; + } + + // other.k is outside B's subscription; room.k (published after it) is inside. + tR.publish(value_mutation("R", 1, "other.k", "o")); + tR.publish(value_mutation("R", 2, "room.k", "r")); + + EXPECT_TRUE(wait_until([&] { return leaf(aB, "room.k") == "r"; }, std::chrono::seconds(3))); + EXPECT_TRUE(wait_until([&] { return leaf(aN, "room.k") == "r" && leaf(aN, "other.k") == "o"; }, + std::chrono::seconds(3))) + << "an unknown peer gets everything"; + EXPECT_EQ(leaf(aB, "other.k"), "") << "B's subscription filters what precedes room.k"; + EXPECT_GE(filtered_count(tR, "B"), 1u); + + tN.stop(); + tB.stop(); + tR.stop(); +} + +// With the interest gate on, a relay whose shard enforces "conf" withholds a client's ui.docs.* +// write from the other clients (no shard of its own would take it) while conf.* still flows; with +// the gate off it forwards both, as before. +TEST(StateTransportGrpcTest, RelayInterestGateWithholdsOutOfInterestPaths) { + cvc::app aR, aA, aB; + cvc::state_transport_grpc tR, tA, tB; + tR.start("127.0.0.1:0", "R", "C"); + tR.set_relay(true); + tR.set_relay_requires_interest(true); + EXPECT_TRUE(tR.relay_requires_interest()); + tA.start("127.0.0.1:0", "A", "C"); + tB.start("127.0.0.1:0", "B", "C"); + + cvc::state_cluster_shard sR(aR, "C", "R"); + sR.add_interest("conf"); + sR.set_enforce_interest(true); + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sR.attach(); + sA.attach(); + sB.attach(); + tR.register_shard(&sR); + tA.register_shard(&sA); + tB.register_shard(&sB); + + ASSERT_TRUE(tA.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_until([&] { return tR.connection_count() == 2; }, std::chrono::seconds(3))); + + // ui.docs.x goes first on A's stream, so once conf.rooms.r.y reaches B the relay has already + // decided ui.docs.x. + write_fresh(aA, "ui.docs.x", "u1"); + write_fresh(aA, "conf.rooms.r.y", "c1"); + EXPECT_GT(tA.pump_all(), 0u); + EXPECT_TRUE( + wait_until([&] { return leaf(aB, "conf.rooms.r.y") == "c1"; }, std::chrono::seconds(3))); + EXPECT_EQ(leaf(aB, "ui.docs.x"), "") << "the relay withholds an out-of-interest write"; + EXPECT_EQ(leaf(aR, "conf.rooms.r.y"), "c1") << "ingest is unchanged"; + const std::uint64_t withheld = tR.total_relay_withheld(); + EXPECT_GE(withheld, 1u); + + tR.set_relay_requires_interest(false); + cvc::state::instance(aA)("ui.docs.x").value(std::string("u2")); + cvc::state::instance(aA)("conf.rooms.r.y").value(std::string("c2")); + EXPECT_GT(tA.pump_all(), 0u); + EXPECT_TRUE(wait_until( + [&] { return leaf(aB, "ui.docs.x") == "u2" && leaf(aB, "conf.rooms.r.y") == "c2"; }, + std::chrono::seconds(3))) + << "with the gate off the relay forwards everything"; + EXPECT_EQ(tR.total_relay_withheld(), withheld); + + tA.stop(); + tB.stop(); + tR.stop(); +} + +// The gate keeps the ingest exemption for delegation ops: a delegation over an out-of-interest +// subtree still reaches the other client, while a plain write under it is withheld. +TEST(StateTransportGrpcTest, RelayInterestGateForwardsDelegationOps) { + cvc::app aR, aA, aB; + cvc::state_transport_grpc tR, tA, tB; + tR.start("127.0.0.1:0", "R", "C"); + tR.set_relay(true); + tR.set_relay_requires_interest(true); + tA.start("127.0.0.1:0", "A", "C"); + tB.start("127.0.0.1:0", "B", "C"); + + cvc::state_cluster_shard sR(aR, "C", "R"); + sR.add_interest("conf"); + sR.set_enforce_interest(true); + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sR.attach(); + sA.attach(); + sB.attach(); + tR.register_shard(&sR); + tA.register_shard(&sA); + tB.register_shard(&sB); + + ASSERT_TRUE(tA.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_until([&] { return tR.connection_count() == 2; }, std::chrono::seconds(3))); + + // A plain write under ui.docs is withheld (conf.rooms.r.y, written after it, marks the point by + // which the relay has decided it)... + write_fresh(aA, "ui.docs.x", "u1"); + write_fresh(aA, "conf.rooms.r.y", "c1"); + EXPECT_GT(tA.pump_all(), 0u); + EXPECT_TRUE( + wait_until([&] { return leaf(aB, "conf.rooms.r.y") == "c1"; }, std::chrono::seconds(3))); + EXPECT_EQ(leaf(aB, "ui.docs.x"), ""); + const std::uint64_t withheld = tR.total_relay_withheld(); + EXPECT_GE(withheld, 1u); + + // ...but a delegation of that subtree is forwarded. + sA.publish_delegation("ui.docs", "C2"); + cvc::state::instance(aA)("conf.rooms.r.y").value(std::string("c2")); + EXPECT_GT(tA.pump_all(), 0u); + EXPECT_TRUE( + wait_until([&] { return leaf(aB, "conf.rooms.r.y") == "c2"; }, std::chrono::seconds(3))); + EXPECT_EQ(sB.total_delegations_applied(), 1u) << "the delegation op is forwarded"; + EXPECT_EQ(tR.total_relay_withheld(), withheld); + + tA.stop(); + tB.stop(); + tR.stop(); +} + +// A relay forward consults each target's registry entry: B's delivery filter denies x.secret, so +// B gets only x.pub, while C (no entry) gets both. +TEST(StateTransportGrpcTest, RelayForwardHonorsPeerFilter) { + cvc::app aA, aB, aC; + cvc::state_transport_grpc tR, tA, tB, tC; + tR.start("127.0.0.1:0", "R", "C"); + tR.set_relay(true); + tA.start("127.0.0.1:0", "A", "C"); + tB.start("127.0.0.1:0", "B", "C"); + tC.start("127.0.0.1:0", "C", "C"); + + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C"); + sA.attach(); + sB.attach(); + sC.attach(); + tA.register_shard(&sA); + tB.register_shard(&sB); + tC.register_shard(&sC); + + ASSERT_TRUE(tA.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"A", "B", "C"}, std::chrono::seconds(3))); + tR.peers().add_peer("B", "C"); + ASSERT_TRUE(tR.peers().set_filter("B", std::make_shared("x.secret"))); + + write_fresh(aA, "x.secret", "s1"); + write_fresh(aA, "x.pub", "p1"); + EXPECT_GT(tA.pump_all(), 0u); + + EXPECT_TRUE(wait_until([&] { return leaf(aB, "x.pub") == "p1"; }, std::chrono::seconds(3))); + EXPECT_TRUE(wait_until([&] { return leaf(aC, "x.pub") == "p1" && leaf(aC, "x.secret") == "s1"; }, + std::chrono::seconds(3))); + EXPECT_EQ(leaf(aB, "x.secret"), "") << "B's filter denies x.secret on the forward"; + EXPECT_GE(filtered_count(tR, "B"), 1u); + + tA.stop(); + tB.stop(); + tC.stop(); + tR.stop(); +} + +// The snapshot responder answers with what the requester's registry entry lets it see. +TEST(StateTransportGrpcTest, SnapshotRequestFilteredByRequester) { + cvc::app aR; + cvc::state_transport_grpc tR, tB, tC; + tR.set_backfill_on_connect(false); + tR.start("127.0.0.1:0", "R", "C"); + tB.start("127.0.0.1:0", "B", "C"); + tC.start("127.0.0.1:0", "C", "C"); + + cvc::state_cluster_shard sR(aR, "C", "R"); + sR.attach(); + tR.register_shard(&sR); + // The snapshot reads the tree directly, so a single set suffices. + cvc::state::instance(aR)("x.secret").value(std::string("s")); + cvc::state::instance(aR)("x.pub").value(std::string("p")); + + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"B", "C"}, std::chrono::seconds(3))); + tR.peers().add_peer("B", "C"); + ASSERT_TRUE(tR.peers().set_filter("B", std::make_shared("x.secret"))); + + const auto paths_from = [](cvc::state_transport_grpc &t) { + std::mutex mu; + std::vector paths; + const bool ok = t.request_snapshot( + "C", "x", [&](const std::vector &es, bool) { + std::lock_guard lk(mu); + for (const auto &e : es) + paths.push_back(e.path); + }); + EXPECT_TRUE(ok); + std::lock_guard lk(mu); + return paths; + }; + const auto has = [](const std::vector &v, const char *p) { + return std::find(v.begin(), v.end(), p) != v.end(); + }; + + const auto gotB = paths_from(tB); + EXPECT_TRUE(has(gotB, "x.pub")); + EXPECT_FALSE(has(gotB, "x.secret")) << "B's filter applies to its snapshot"; + const auto gotC = paths_from(tC); + EXPECT_TRUE(has(gotC, "x.pub")); + EXPECT_TRUE(has(gotC, "x.secret")) << "an unfiltered requester gets everything"; + + tB.stop(); + tC.stop(); + tR.stop(); +} + +// push_snapshot_to writes only to the connection identified as the named peer. +TEST(StateTransportGrpcTest, PushSnapshotToReachesOnlyNamedPeer) { + cvc::app aB, aC; + cvc::state_transport_grpc tR, tB, tC; + tR.start("127.0.0.1:0", "R", "C"); + tB.start("127.0.0.1:0", "B", "C"); + tC.start("127.0.0.1:0", "C", "C"); + + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C"); + sB.attach(); + sC.attach(); + tB.register_shard(&sB); + tC.register_shard(&sC); + + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"B", "C"}, std::chrono::seconds(3))); + + const std::vector forB = {leaf_entry("pushed.k", "v")}; + EXPECT_EQ(tR.push_snapshot_to("B", forB), 1u); + EXPECT_EQ(tR.push_snapshot_to("nobody", forB), 0u) << "an unknown peer gets nothing"; + EXPECT_EQ(tR.push_snapshot_to("B", {}), 0u) << "an empty push writes nothing"; + // C's own push, sent after B's, marks the point by which anything for C has landed. + EXPECT_EQ(tR.push_snapshot_to("C", {leaf_entry("pushed.c", "c")}), 1u); + + EXPECT_TRUE(wait_until([&] { return leaf(aB, "pushed.k") == "v"; }, std::chrono::seconds(3))); + EXPECT_TRUE(wait_until([&] { return leaf(aC, "pushed.c") == "c"; }, std::chrono::seconds(3))); + EXPECT_EQ(leaf(aC, "pushed.k"), ""); + EXPECT_EQ(leaf(aB, "pushed.c"), ""); + + tB.stop(); + tC.stop(); + tR.stop(); +} + +// A push and the publishes that follow it on the same thread keep their order on the wire: a +// snapshot entry bypasses the receiver's conflict gate, so a push overtaken by the newer live write +// would leave the old value behind. +TEST(StateTransportGrpcTest, PushThenPublishKeepsOrder) { + cvc::app aB; + cvc::state_transport_grpc tR, tB; + tR.start("127.0.0.1:0", "R", "C"); + tB.start("127.0.0.1:0", "B", "C"); + + cvc::state_cluster_shard sB(aB, "C", "B"); + sB.attach(); + tB.register_shard(&sB); + + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"B"}, std::chrono::seconds(3))); + + ASSERT_EQ(tR.push_snapshot_to("B", {leaf_entry("room.p", "v1")}), 1u); + tR.publish(value_mutation("R", 1, "room.p", "v2")); + tR.publish(value_mutation("R", 2, "room.done", "1")); + + EXPECT_TRUE(wait_until([&] { return leaf(aB, "room.done") == "1"; }, std::chrono::seconds(3))); + EXPECT_EQ(leaf(aB, "room.p"), "v2"); + + tB.stop(); + tR.stop(); +} + +// The media gate on the relay's fan-out: a receiver it denies is skipped while the others still get +// the frame, and a node's own uplink is never gated. +TEST(StateTransportGrpcTest, MediaGateOnGrpc) { + cvc::state_transport_grpc tR, tA, tB, tC; + tR.start("127.0.0.1:0", "R", "C"); + tR.set_media_stall_drop(std::chrono::milliseconds(40)); // healthy loopback writes never trip it + // Declared after tR so it destructs first, clearing tR's media sink while tR is still alive. + cvc::media_router mr; + mr.add_endpoint(&tR); + tA.start("127.0.0.1:0", "A", "C"); + tB.start("127.0.0.1:0", "B", "C"); + tC.start("127.0.0.1:0", "C", "C"); + + std::mutex mu; + std::vector seqB, seqC; + std::atomic gotA{0}; + tA.set_on_media([&](const cvc::media_frame &) { gotA.fetch_add(1); }); + tB.set_on_media([&](const cvc::media_frame &f) { + std::lock_guard lk(mu); + seqB.push_back(f.seq); + }); + tC.set_on_media([&](const cvc::media_frame &f) { + std::lock_guard lk(mu); + seqC.push_back(f.seq); + }); + + ASSERT_TRUE(tA.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"A", "B", "C"}, std::chrono::seconds(3))); + + tR.set_media_gate([](const cvc::media_frame &, const std::string &rx) { return rx != "B"; }); + // A gate that denies everything on A's own transport: its uplink must ignore it. + tA.set_media_gate([](const cvc::media_frame &, const std::string &) { return false; }); + + cvc::media_frame m; + m.origin_node_id = "A"; + m.stream_id = "camera"; + m.codec = "mjpeg"; + m.width = 64; + m.height = 48; + m.seq = 1; + m.payload = {0xff, 0xd8, 0x01, 0xff, 0xd9}; + tA.publish_media_frame(m); + EXPECT_TRUE(wait_until( + [&] { + std::lock_guard lk(mu); + return !seqC.empty(); + }, + std::chrono::seconds(3))); + EXPECT_EQ(tR.total_media_gated(), 1u) << "B is the only receiver denied"; + + // Clearing the gate restores full fan-out. B's stream is FIFO, so once seq 2 arrives a seq 1 + // sent to B would already be there. + tR.set_media_gate({}); + m.seq = 2; + tA.publish_media_frame(m); + EXPECT_TRUE(wait_until( + [&] { + std::lock_guard lk(mu); + return seqB.size() == 1 && seqC.size() == 2; + }, + std::chrono::seconds(3))); + { + std::lock_guard lk(mu); + EXPECT_EQ(seqB, std::vector{2}); + } + EXPECT_EQ(tA.total_media_sent(), 2u) << "the uplink is never gated"; + EXPECT_EQ(tR.total_media_gated(), 1u); + EXPECT_EQ(tR.total_media_stall_dropped(), 0u); + EXPECT_EQ(gotA.load(), 0) << "the origin is still skipped"; + + tA.stop(); + tB.stop(); + tC.stop(); + tR.stop(); +} + +TEST(StateTransportGrpcTest, MediaWriteStalled) { + using T = cvc::state_transport_grpc; + constexpr std::uint64_t kMs = 1'000'000; + constexpr std::uint64_t now = 10'000 * kMs; + EXPECT_FALSE(T::media_write_stalled(0, now, 40 * kMs)) << "no write in flight"; + EXPECT_FALSE(T::media_write_stalled(now - 39 * kMs, now, 40 * kMs)); + EXPECT_TRUE(T::media_write_stalled(now - 41 * kMs, now, 40 * kMs)); + EXPECT_FALSE(T::media_write_stalled(now - 41 * kMs, now, 0)) << "0 turns the drop off"; +} + +// A client shard that enforces interest takes from an unsolicited (relay admit) snapshot only what +// it would take live; a shard that does not enforce still takes everything. +TEST(StateTransportGrpcTest, ClientSnapshotIngestHonorsEnforcedInterest) { + cvc::app aR, aB, aC; + cvc::state_transport_grpc tR, tB, tC; + tR.set_backfill_on_connect(false); // the admit snapshot alone carries the relay's state + tR.start("127.0.0.1:0", "R", "C"); + tR.set_relay(true); + tB.start("127.0.0.1:0", "B", "C"); + tC.start("127.0.0.1:0", "C", "C"); + + cvc::state_cluster_shard sR(aR, "C", "R"); + sR.attach(); + tR.register_shard(&sR); + cvc::state::instance(aR)("conf.x").value(std::string("c")); + cvc::state::instance(aR)("ui.y").value(std::string("u")); + + cvc::state_cluster_shard sB(aB, "C", "B"); + sB.add_interest("conf"); + sB.set_enforce_interest(true); + cvc::state_cluster_shard sC(aC, "C", "C"); + sB.attach(); + sC.attach(); + tB.register_shard(&sB); + tC.register_shard(&sC); + + ASSERT_TRUE(tB.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(tR.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(tR, {"B", "C"}, std::chrono::seconds(3))); + // The relay reads a Hello only after the admit snapshot is written, so a push now lands behind + // it: once the push is applied, so is the admit snapshot. + ASSERT_EQ(tR.push_snapshot_to("B", {leaf_entry("conf.done", "1")}), 1u); + ASSERT_EQ(tR.push_snapshot_to("C", {leaf_entry("conf.done", "1")}), 1u); + EXPECT_TRUE( + wait_until([&] { return leaf(aB, "conf.done") == "1" && leaf(aC, "conf.done") == "1"; }, + std::chrono::seconds(3))); + + EXPECT_EQ(leaf(aB, "conf.x"), "c"); + EXPECT_EQ(leaf(aB, "ui.y"), "") << "outside B's enforced interest"; + EXPECT_EQ(leaf(aC, "conf.x"), "c"); + EXPECT_EQ(leaf(aC, "ui.y"), "u"); + + tB.stop(); + tC.stop(); + tR.stop(); +} diff --git a/src/cvc/tests/state_transport_ws_test.cpp b/src/cvc/tests/state_transport_ws_test.cpp index 03863305b..6c486b309 100644 --- a/src/cvc/tests/state_transport_ws_test.cpp +++ b/src/cvc/tests/state_transport_ws_test.cpp @@ -27,7 +27,11 @@ #if defined(CVC_ENABLE_GRPC) #include #endif +#include +#include #include +#include +#include #include #include #include @@ -61,6 +65,80 @@ template bool wait_until(Pred pred, std::chrono::milliseconds to std::string ws(const std::string &hostport) { return "ws://" + hostport; } +// Spin until `t` reports at least `n` links whose peer has identified itself (sent its Hello). A +// registry rule or a push keyed by node id only applies to a connection once this holds. +bool wait_identified(const cvc::state_transport &t, std::size_t n, std::chrono::milliseconds to) { + return wait_until( + [&] { + std::size_t k = 0; + for (const auto &r : t.link_reports()) + if (!r.remote_node_id.empty()) + ++k; + return k >= n; + }, + to); +} + +// Bytes this end has written on each link, keyed by the peer's node id. +std::map bytes_out_by_peer(const cvc::state_transport &t) { + std::map out; + for (const auto &r : t.link_reports()) + out[r.remote_node_id] = r.stats.bytes_out; + return out; +} + +std::string value_at(cvc::app &a, const std::string &path) { + return cvc::state::instance(a)(path).value(); +} + +// The first set on a fresh child is not observed, so seed it before the value that must replicate. +void write_value(cvc::app &a, const std::string &path, const std::string &v) { + cvc::state::instance(a)(path).value(std::string("seed")); + cvc::state::instance(a)(path).value(v); +} + +cvc::state_transport::snapshot_entry entry(const std::string &path, const std::string &v) { + cvc::state_transport::snapshot_entry e; + e.path = path; + e.string_value = v; + return e; +} + +// A delivery filter that denies one subtree. +class deny_prefix_filter final : public cvc::state_delivery_filter { +public: + explicit deny_prefix_filter(std::string prefix) : _prefix(std::move(prefix)) {} + bool deliver(const std::string &path) const noexcept override { + return !cvc::state_peer_registry::prefix_matches(_prefix, path); + } + +private: + std::string _prefix; +}; + +std::uint64_t deliveries_filtered(const cvc::state_transport &t, const std::string &node_id) { + for (const auto &p : t.peers().snapshot()) + if (p.node_id == node_id) + return p.deliveries_filtered; + return 0; +} + +// Protobuf wire bytes of one length-delimited field (payloads under 128 bytes). +std::string pb_field(int field, const std::string &bytes) { + std::string out; + out.push_back(static_cast((field << 3) | 2)); + out.push_back(static_cast(bytes.size())); + out += bytes; + return out; +} + +// A raw cvc.transport.v1.Frame carrying a Hello (Frame.hello = 1; Hello.node_id = 1, +// Hello.cluster_id = 2), and one carrying an empty Ping (Frame.ping = 10). +std::string hello_frame(const std::string &node_id, const std::string &cluster_id) { + return pb_field(1, pb_field(1, node_id) + pb_field(2, cluster_id)); +} +std::string ping_frame() { return pb_field(10, std::string()); } + } // namespace TEST(StateTransportWsTest, StartStopBindsEphemeralPort) { @@ -456,6 +534,564 @@ TEST(StateTransportWsTest, RelayBackfillsLateJoinerWithExistingState) { S.stop(); } +// A connection's identity is the node id of its FIRST Hello: unknown before it, and a later Hello +// naming someone else changes nothing -- neither the link report nor which connection a push +// addressed to that id reaches. +TEST(StateTransportWsTest, SecondHelloIsIgnored) { + namespace beast = boost::beast; + namespace asio = boost::asio; + cvc::state_transport_ws S; + S.set_ping_interval(std::chrono::milliseconds(0)); // the raw peer's frames are the only traffic + S.start("127.0.0.1:0", "S", "C"); + const std::string addr = S.listen_address(); + const std::string port = addr.substr(addr.rfind(':') + 1); + + asio::io_context ioc; + asio::ip::tcp::resolver resolver(ioc); + asio::ip::tcp::socket sock(ioc); + asio::connect(sock, resolver.resolve("127.0.0.1", port)); + beast::websocket::stream peer(std::move(sock)); + peer.handshake("127.0.0.1", "/"); + ASSERT_TRUE( + wait_until([&] { return S.connection_count() == 1; }, std::chrono::milliseconds(2000))); + ASSERT_EQ(S.link_reports().size(), 1u); + EXPECT_EQ(S.link_reports()[0].remote_node_id, "") << "no identity before the Hello"; + EXPECT_EQ(S.push_snapshot_to("first", {entry("k", "v")}), 0u); + + peer.binary(true); + peer.write(asio::buffer(hello_frame("first", "C"))); + peer.write(asio::buffer(hello_frame("second", "C"))); + peer.write(asio::buffer(ping_frame())); + // One reader parses a connection's frames in order and counts each as it starts on it, so a + // third inbound frame means the second Hello has been fully handled. + ASSERT_TRUE(wait_until( + [&] { + const auto links = S.link_reports(); + return links.size() == 1 && links[0].stats.frames_in >= 3; + }, + std::chrono::milliseconds(2000))); + const auto links = S.link_reports(); + ASSERT_EQ(links.size(), 1u); + EXPECT_EQ(links[0].remote_node_id, "first"); + EXPECT_EQ(S.push_snapshot_to("second", {entry("k", "v")}), 0u); + EXPECT_EQ(S.push_snapshot_to("first", {entry("k", "v")}), 1u); + + beast::error_code ec; + peer.next_layer().close(ec); + S.stop(); +} + +// The opt-in relay interest gate: a frame no local shard admits is neither re-fanned nor bridged. +// The relay's shard takes only "conf" (as a conference relay's does), so A's ui.docs.* write stays +// at the relay while its conf.* write reaches B; with the gate off the same write is forwarded. +TEST(StateTransportWsTest, RelayInterestGateWithholdsOutOfInterestPaths) { + cvc::app aS, aA, aB; + cvc::state_transport_ws S, tA, tB; + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + EXPECT_FALSE(S.relay_requires_interest()) << "off by default"; + S.set_relay_requires_interest(true); + EXPECT_TRUE(S.relay_requires_interest()); + + cvc::state_cluster_shard sS(aS, "C", "S"); + sS.add_interest("conf"); + sS.set_enforce_interest(true); + // B mirrors everything, so whatever it lacks was withheld by the relay, not refused by B. + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sS.attach(); + sA.attach(); + sB.attach(); + S.register_shard(&sS); + tA.register_shard(&sA); + tB.register_shard(&sB); + + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE( + wait_until([&] { return S.connection_count() >= 2; }, std::chrono::milliseconds(2000))); + + write_value(aA, "ui.docs.x", "echo"); + write_value(aA, "conf.rooms.r.y", "joined"); + EXPECT_GT(tA.pump_all(), 0u); + tA.flush(); + // A's frames reach the relay, and B, in order over one connection each: once conf.* has landed, + // a forwarded ui.docs.* would already have landed before it. + ASSERT_TRUE(wait_until([&] { return value_at(aB, "conf.rooms.r.y") == "joined"; }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aB, "ui.docs.x"), ""); + EXPECT_GE(S.total_relay_withheld(), 1u); + EXPECT_EQ(value_at(aS, "conf.rooms.r.y"), "joined") << "ingest is unchanged"; + + S.set_relay_requires_interest(false); + const std::uint64_t withheld = S.total_relay_withheld(); + cvc::state::instance(aA)("ui.docs.x").value(std::string("echo2")); + EXPECT_GT(tA.pump_all(), 0u); + tA.flush(); + EXPECT_TRUE(wait_until([&] { return value_at(aB, "ui.docs.x") == "echo2"; }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(S.total_relay_withheld(), withheld); + + tA.stop(); + tB.stop(); + S.stop(); +} + +// The gate keeps ingest's delegation exemption: a delegate op on a path outside the relay's +// interest still reaches the other clients, while an ordinary write to that subtree does not. +TEST(StateTransportWsTest, RelayInterestGateForwardsDelegationOps) { + cvc::app aS, aA, aB; + cvc::state_transport_ws S, tA, tB; + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + S.set_relay_requires_interest(true); + + cvc::state_cluster_shard sS(aS, "C", "S"); + sS.add_interest("conf"); + sS.set_enforce_interest(true); + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sS.attach(); + sA.attach(); + sB.attach(); + S.register_shard(&sS); + tA.register_shard(&sA); + tB.register_shard(&sB); + + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE( + wait_until([&] { return S.connection_count() >= 2; }, std::chrono::milliseconds(2000))); + + write_value(aA, "ui.docs.x", "echo"); + sA.publish_delegation("ui.docs.owned", "other-cluster", "", /*lease=*/0); + write_value(aA, "conf.k", "after"); + EXPECT_GT(tA.pump_all(), 0u); + tA.flush(); + ASSERT_TRUE(wait_until([&] { return value_at(aB, "conf.k") == "after"; }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(sB.total_delegations_applied(), 1u) << "the delegation op was forwarded"; + EXPECT_EQ(sS.total_delegations_applied(), 1u); + EXPECT_EQ(value_at(aB, "ui.docs.x"), "") << "the ordinary write was withheld"; + EXPECT_GE(S.total_relay_withheld(), 1u); + + tA.stop(); + tB.stop(); + S.stop(); +} + +// The relay's re-fan honours each target's registry entry: B's delivery filter denies x.secret, so +// B receives only x.pub, while C (unregistered) receives both. +TEST(StateTransportWsTest, RelayForwardHonorsPeerFilter) { + cvc::app aA, aB, aC; + cvc::state_transport_ws S, tA, tB, tC; + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); // no shard: a pure forwarding relay + + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C1"); + sA.attach(); + sB.attach(); + sC.attach(); + tA.register_shard(&sA); + tB.register_shard(&sB); + tC.register_shard(&sC); + + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 3, std::chrono::milliseconds(2000))); + S.peers().add_peer("B", "C"); + ASSERT_TRUE(S.peers().set_filter("B", std::make_shared("x.secret"))); + + write_value(aA, "x.secret", "s"); + write_value(aA, "x.pub", "p"); + EXPECT_GT(tA.pump_all(), 0u); + tA.flush(); + ASSERT_TRUE(wait_until( + [&] { + return value_at(aC, "x.secret") == "s" && value_at(aC, "x.pub") == "p" && + value_at(aB, "x.pub") == "p"; + }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aB, "x.secret"), ""); + EXPECT_GE(deliveries_filtered(S, "B"), 1u); + + tA.stop(); + tB.stop(); + tC.stop(); + S.stop(); +} + +// The periodic broadcast is filtered per connection. B's filter denies room.secret, so its frame +// omits it; C (unregistered) and D (registered with no rule) are unfiltered and hold the same, so +// they get the same frame -- byte-for-byte, and larger than B's. +TEST(StateTransportWsTest, BroadcastSnapshotIsPerConnectionFiltered) { + cvc::app aS, aB, aC, aD; + cvc::state_transport_ws S, tB, tC, tD; + // No link probe at either end: the broadcast is the only traffic the byte counts below see. + for (auto *t : {&S, &tB, &tC, &tD}) + t->set_ping_interval(std::chrono::milliseconds(0)); + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + + cvc::state_cluster_shard sS(aS, "C", "S"); + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C1"); + cvc::state_cluster_shard sD(aD, "C", "D"); + sS.attach(); + sB.attach(); + sC.attach(); + sD.attach(); + S.register_shard(&sS); + tB.register_shard(&sB); + tC.register_shard(&sC); + tD.register_shard(&sD); + + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tD.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 3, std::chrono::milliseconds(2000))); + S.peers().add_peer("B", "C"); + ASSERT_TRUE(S.peers().set_filter("B", std::make_shared("room.secret"))); + S.peers().add_peer("D", "C"); + + // Relay-local writes made after everyone identified, and never pumped: only the broadcast can + // carry them. + write_value(aS, "room.secret", "s"); + write_value(aS, "room.pub", "p"); + // A connection's admit frames go out under its write lock, which flush() waits for, so the + // broadcast's bytes stand alone. + S.flush(); + const auto before = bytes_out_by_peer(S); + EXPECT_EQ(S.broadcast_snapshot(), 3u); + const auto after = bytes_out_by_peer(S); + EXPECT_EQ(S.broadcast_snapshot(), 0u) << "every peer already holds what it is delivered"; + // A push queued behind the broadcast on B's connection: once it lands, B has applied the whole + // broadcast frame. + ASSERT_EQ(S.push_snapshot_to("B", {entry("room.sentinel", "1")}), 1u); + ASSERT_TRUE(wait_until( + [&] { + return value_at(aB, "room.sentinel") == "1" && value_at(aC, "room.secret") == "s" && + value_at(aC, "room.pub") == "p" && value_at(aD, "room.secret") == "s" && + value_at(aD, "room.pub") == "p"; + }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aB, "room.pub"), "p"); + EXPECT_EQ(value_at(aB, "room.secret"), ""); + + const std::uint64_t sent_b = after.at("B") - before.at("B"); + const std::uint64_t sent_c = after.at("C1") - before.at("C1"); + const std::uint64_t sent_d = after.at("D") - before.at("D"); + EXPECT_GT(sent_c, 0u); + EXPECT_EQ(sent_c, sent_d) << "unfiltered connections share one frame"; + EXPECT_LT(sent_b, sent_c) << "the filtered connection's frame omits the denied entry"; + + tB.stop(); + tC.stop(); + tD.stop(); + S.stop(); +} + +// The broadcast sends diffs, and must still converge a client that lost frames. Each way to lose +// one, and what heals it: +// 1. a change no live frame carried (a missed re-fan): the next diff carries it, and only it; +// 2. a live frame that reached the client, then a change back to the value the relay last +// broadcast that the client missed: the live frame made the relay forget what the client held +// there, so the diff re-sends it even though the tree matches the last broadcast again; +// 3. a frame the client dropped itself (no shard registered when it arrived): the relay wrote it, +// so no diff re-sends it, and only the client's full-refresh request heals it; +// 4. the client's own write, overridden on the relay without a live frame: its write made the +// relay forget what it held there, so the diff sends the tree's value back. +TEST(StateTransportWsTest, BroadcastDiffConvergesAClientThatDroppedFrames) { + using ms = std::chrono::milliseconds; + cvc::app aS, aB; + cvc::state_transport_ws S, tB; + for (auto *t : {&S, &tB}) + t->set_ping_interval(ms(0)); // only the frames below cross the link + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + + cvc::state_cluster_shard sS(aS, "C", "S"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sS.attach(); + sB.attach(); + S.register_shard(&sS); + tB.register_shard(&sB); + write_value(aS, "room.k", "v0"); + EXPECT_GT(S.pump_all(), 0u); // no connection yet: later pumps carry only later writes + const auto set = [](cvc::app &a, const std::string &v) { + cvc::state::instance(a)("room.k").value(v); + }; + + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), ms(2000))); + ASSERT_TRUE(wait_identified(S, 1, ms(2000))); + S.flush(); // B's admit snapshot is out + ASSERT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v0"; }, ms(3000))); + EXPECT_EQ(S.broadcast_snapshot(), 0u) << "B holds what its admit snapshot carried"; + + // 1. + set(aS, "v1"); // never pumped + std::uint64_t e0 = S.total_broadcast_entries(); + EXPECT_EQ(S.broadcast_snapshot(), 1u); + EXPECT_EQ(S.total_broadcast_entries() - e0, 1u) << "only the changed entry"; + ASSERT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v1"; }, ms(3000))); + EXPECT_EQ(S.broadcast_snapshot(), 0u); + + // 2. + set(aS, "v2"); + EXPECT_GT(S.pump_all(), 0u); + ASSERT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v2"; }, ms(3000))); + set(aS, "v1"); // never pumped: the tree is back to what the last broadcast carried + EXPECT_EQ(S.broadcast_snapshot(), 1u); + ASSERT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v1"; }, ms(3000))); + + // 3. + tB.unregister_shard(&sB); + set(aS, "v3"); // never pumped + const std::uint64_t r0 = tB.total_received_frames(); + EXPECT_EQ(S.broadcast_snapshot(), 1u); + // B reads its frames in order on one thread, so once the push behind the diff has arrived, B is + // done with the diff -- which it dropped, having no shard. + ASSERT_EQ(S.push_snapshot_to("B", {entry("room.sentinel", "1")}), 1u); + ASSERT_TRUE(wait_until([&] { return tB.total_received_frames() >= r0 + 2; }, ms(3000))); + tB.register_shard(&sB); + EXPECT_EQ(S.broadcast_snapshot(), 0u) << "the relay believes B holds v3"; + EXPECT_EQ(value_at(aB, "room.k"), "v1"); + ASSERT_EQ(tB.request_full_refresh(), 1u); + e0 = S.total_broadcast_entries(); + ASSERT_TRUE(wait_until([&] { return S.broadcast_snapshot() == 1u; }, ms(3000))); + EXPECT_GE(S.total_broadcast_entries() - e0, 2u) << "the whole tree: room and room.k"; + ASSERT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v3"; }, ms(3000))); + EXPECT_EQ(S.broadcast_snapshot(), 0u) << "back to diffs"; + + // 4. + set(aB, "b4"); + EXPECT_GT(tB.pump_all(), 0u); + ASSERT_TRUE(wait_until([&] { return value_at(aS, "room.k") == "b4"; }, ms(3000))); + set(aS, "v3"); // never pumped: the relay overrides B's write with what it last sent B + EXPECT_EQ(S.broadcast_snapshot(), 1u); + EXPECT_TRUE(wait_until([&] { return value_at(aB, "room.k") == "v3"; }, ms(3000))); + + tB.stop(); + S.stop(); +} + +// push_snapshot_to writes only to the connection that identified as the named node, regardless of +// the registry (the caller decided), and reports how many connections it wrote. +TEST(StateTransportWsTest, PushSnapshotToReachesOnlyNamedPeer) { + cvc::app aB, aC; + cvc::state_transport_ws S, tB, tC; + S.start("127.0.0.1:0", "S", "C"); + + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C1"); + sB.attach(); + sC.attach(); + tB.register_shard(&sB); + tC.register_shard(&sC); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 2, std::chrono::milliseconds(2000))); + // B's registry entry would deny push.* to the live stream; a push bypasses it. + S.peers().add_peer("B", "C", std::string(), {"other"}); + + EXPECT_EQ(S.push_snapshot_to("nobody", {entry("push.k", "v")}), 0u); + EXPECT_EQ(S.push_snapshot_to("B", {}), 0u); + EXPECT_EQ(S.push_snapshot_to("B", {entry("push.k", "for-b")}), 1u); + EXPECT_EQ(S.push_snapshot_to("C1", {entry("push.c", "for-c")}), 1u); + ASSERT_TRUE(wait_until( + [&] { return value_at(aB, "push.k") == "for-b" && value_at(aC, "push.c") == "for-c"; }, + std::chrono::milliseconds(3000))); + // C's push was written after B's, on the same thread: had B's frame also gone to C, C would have + // applied it first. + EXPECT_EQ(value_at(aC, "push.k"), ""); + EXPECT_EQ(value_at(aB, "push.c"), ""); + + tB.stop(); + tC.stop(); + S.stop(); +} + +// A push and the publishes after it share the connection's FIFO, so a pushed value never lands on +// top of a newer live write -- and a push after the last live write wins. +TEST(StateTransportWsTest, PushThenPublishKeepsOrder) { + cvc::app aS, aB; + cvc::state_transport_ws S, tB; + S.start("127.0.0.1:0", "S", "C"); + cvc::state_cluster_shard sS(aS, "C", "S"); + cvc::state_cluster_shard sB(aB, "C", "B"); + sS.attach(); + sB.attach(); + S.register_shard(&sS); + tB.register_shard(&sB); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 1, std::chrono::milliseconds(2000))); + + write_value(aS, "p", "live-start"); + S.pump_all(); + for (int i = 0; i < 20; ++i) { + const std::string n = std::to_string(i); + ASSERT_EQ(S.push_snapshot_to("B", {entry("p", "push-" + n)}), 1u); + cvc::state::instance(aS)("p").value("live-" + n); + EXPECT_GT(S.pump_all(), 0u); + } + ASSERT_TRUE( + wait_until([&] { return value_at(aB, "p") == "live-19"; }, std::chrono::milliseconds(3000))); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + EXPECT_EQ(value_at(aB, "p"), "live-19") << "no pushed value arrived after the newer live write"; + + ASSERT_EQ(S.push_snapshot_to("B", {entry("p", "push-last")}), 1u); + EXPECT_TRUE(wait_until([&] { return value_at(aB, "p") == "push-last"; }, + std::chrono::milliseconds(3000))); + + tB.stop(); + S.stop(); +} + +// The media egress gate is asked once per identified receiver of a fanned frame: the receiver it +// denies is skipped, the others still receive, the origin is never asked, and the relay's own +// uplink (publish_media_frame) is never gated. Clearing the gate restores the full fan-out. +TEST(StateTransportWsTest, RelayMediaGateSkipsDeniedReceiver) { + std::mutex mu; + std::vector asked; + std::vector gotB; + std::atomic gotC{0}; + cvc::app aA, aB, aC; + cvc::state_transport_ws S, tA, tB, tC; + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + cvc::media_router mr; + mr.add_endpoint(&S); + S.set_media_gate([&](const cvc::media_frame &, const std::string &receiver) { + std::lock_guard lk(mu); + asked.push_back(receiver); + return receiver != "B"; + }); + + cvc::state_cluster_shard sA(aA, "C", "A"); + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C1"); + sA.attach(); + sB.attach(); + sC.attach(); + tA.register_shard(&sA); + tB.register_shard(&sB); + tC.register_shard(&sC); + tB.set_on_media([&](const cvc::ws_media_frame &m) { + std::lock_guard lk(mu); + gotB.push_back(m.seq); + }); + tC.set_on_media([&](const cvc::ws_media_frame &) { gotC.fetch_add(1); }); + + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 3, std::chrono::milliseconds(2000))); + + cvc::ws_media_frame m; + m.origin_node_id = "A"; + m.stream_id = "camera"; + m.codec = "mjpeg"; + m.seq = 1; + m.payload = {0xff, 0xd8, 0x01, 0xff, 0xd9}; + tA.publish_media_frame(m); + ASSERT_TRUE(wait_until([&] { return gotC.load() >= 1; }, std::chrono::milliseconds(3000))); + // Wait for the gate's decision on every receiver (C's delivery does not order B's). + ASSERT_TRUE(wait_until( + [&] { + std::lock_guard lk(mu); + return asked.size() >= 2; + }, + std::chrono::milliseconds(3000))); + // Counted just after the gate returns, so wait for it rather than racing the fanning thread. + EXPECT_TRUE( + wait_until([&] { return S.total_media_gated() == 1; }, std::chrono::milliseconds(3000))); + + cvc::ws_media_frame own = m; + own.origin_node_id = "S"; + own.seq = 2; + S.publish_media_frame(own); + ASSERT_TRUE(wait_until( + [&] { + std::lock_guard lk(mu); + return !gotB.empty(); + }, + std::chrono::milliseconds(3000))); + { + std::lock_guard lk(mu); + EXPECT_EQ(gotB, std::vector{2}) << "B gets the relay's own frame, not A's"; + std::vector sorted = asked; + std::sort(sorted.begin(), sorted.end()); + EXPECT_EQ(sorted, (std::vector{"B", "C1"})) << "once per receiver, never A"; + } + + S.set_media_gate({}); + m.seq = 3; + tA.publish_media_frame(m); + EXPECT_TRUE(wait_until( + [&] { + std::lock_guard lk(mu); + return gotB.size() == 2 && gotB[1] == 3; + }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(S.total_media_gated(), 1u); + + tA.stop(); + tB.stop(); + tC.stop(); + S.stop(); +} + +// A client whose shard enforces an interest set applies only the snapshot entries inside it, as +// live ingest does: the relay's whole-tree admit snapshot (and a push) no longer plants ui.docs.* +// nodes in a client that takes only conf.*. A client that does not enforce still takes them all. +TEST(StateTransportWsTest, ClientSnapshotIngestHonorsEnforcedInterest) { + cvc::app aS, aB, aC; + cvc::state_transport_ws S, tB, tC; + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); // the admit snapshot + S.set_backfill_on_connect(false); // ... and nothing else carries the relay-local writes + + cvc::state_cluster_shard sS(aS, "C", "S"); + cvc::state_cluster_shard sB(aB, "C", "B"); + cvc::state_cluster_shard sC(aC, "C", "C1"); + sB.add_interest("conf"); + sB.set_enforce_interest(true); + sS.attach(); + sB.attach(); + sC.attach(); + S.register_shard(&sS); + tB.register_shard(&sB); + tC.register_shard(&sC); + + write_value(aS, "ui.docs.b", "relay-ui"); + write_value(aS, "conf.a", "relay-conf"); + + ASSERT_TRUE(tB.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(tC.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(S, 2, std::chrono::milliseconds(2000))); + // Queued behind the admit snapshot on B's connection; its last entry landing means B has + // processed both frames. + ASSERT_EQ(S.push_snapshot_to("B", {entry("ui.docs.c", "pushed-ui"), entry("conf.z", "z")}), 1u); + ASSERT_TRUE( + wait_until([&] { return value_at(aB, "conf.z") == "z"; }, std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aB, "conf.a"), "relay-conf"); + EXPECT_EQ(value_at(aB, "ui.docs.b"), ""); + EXPECT_EQ(value_at(aB, "ui.docs.c"), ""); + + EXPECT_TRUE(wait_until( + [&] { + return value_at(aC, "ui.docs.b") == "relay-ui" && value_at(aC, "conf.a") == "relay-conf"; + }, + std::chrono::milliseconds(3000))); + + tB.stop(); + tC.stop(); + S.stop(); +} + #if defined(CVC_ENABLE_GRPC) // The WS<->gRPC bridge (the wasm-interop payoff): a relay runs a gRPC acceptor AND a WebSocket // acceptor over the SAME shard, bridged both ways. A native WS client and a native gRPC client each @@ -521,4 +1157,117 @@ TEST(StateTransportWsTest, BridgeWsAndGrpcClientsShareARoom) { W.stop(); G.stop(); } + +// The interest gate covers the bridge leg in both directions: with the relay's shard taking only +// "conf", a client's ui.docs.* write is neither re-fanned nor bridged to the other protocol, while +// its conf.* write crosses. +TEST(StateTransportWsTest, BridgeLegHonoursGate) { + cvc::app aRelay, aWs, aGrpc; + cvc::state_transport_grpc G; + cvc::state_transport_ws W; + G.start("127.0.0.1:0", "relay", "C"); + W.start("127.0.0.1:0", "relay", "C"); + G.set_relay(true); + W.set_relay(true); + G.set_relay_requires_interest(true); + W.set_relay_requires_interest(true); + G.set_bridge(&W); + W.set_bridge(&G); + cvc::state_cluster_shard sR(aRelay, "C", "relay"); + sR.add_interest("conf"); + sR.set_enforce_interest(true); + sR.attach(); + G.register_shard(&sR); + W.register_shard(&sR); + + // Neither client enforces interest, so what one lacks was withheld by the relay. + cvc::state_transport_ws wc; + cvc::state_cluster_shard sWs(aWs, "C", "wsclient"); + sWs.attach(); + wc.register_shard(&sWs); + ASSERT_TRUE(wc.connect_to_peer(ws(W.listen_address()), std::chrono::milliseconds(2000))); + cvc::state_transport_grpc gc; + gc.start("127.0.0.1:0", "grpcclient", "C"); + cvc::state_cluster_shard sGrpc(aGrpc, "C", "grpcclient"); + sGrpc.attach(); + gc.register_shard(&sGrpc); + ASSERT_TRUE(gc.connect_to_peer(G.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_until([&] { return G.connection_count() >= 1 && W.connection_count() >= 1; }, + std::chrono::milliseconds(2000))); + + // WS -> gRPC. The WS reader withholds or bridges each frame in order, onto one gRPC stream. + write_value(aWs, "ui.docs.w", "from-ws"); + write_value(aWs, "conf.w", "from-ws"); + wc.pump_all(); + wc.flush(); + ASSERT_TRUE(wait_until([&] { return value_at(aGrpc, "conf.w") == "from-ws"; }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aGrpc, "ui.docs.w"), ""); + EXPECT_GE(W.total_relay_withheld(), 1u); + + // gRPC -> WS. + write_value(aGrpc, "ui.docs.g", "from-grpc"); + write_value(aGrpc, "conf.g", "from-grpc"); + gc.pump_all(); + gc.flush(); + ASSERT_TRUE(wait_until([&] { return value_at(aWs, "conf.g") == "from-grpc"; }, + std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aWs, "ui.docs.g"), ""); + EXPECT_GE(G.total_relay_withheld(), 1u); + + wc.stop(); + gc.stop(); + W.stop(); + G.stop(); +} + +// The bridge leg goes through the destination transport's publish(), so it honours THAT +// transport's registry: the WS client's filter denies x.secret, which a gRPC client writes. +TEST(StateTransportWsTest, BridgeLegHonorsDestinationRegistry) { + cvc::app aRelay, aWs, aGrpc; + cvc::state_transport_grpc G; + cvc::state_transport_ws W; + G.start("127.0.0.1:0", "relay", "C"); + W.start("127.0.0.1:0", "relay", "C"); + G.set_relay(true); + W.set_relay(true); + G.set_bridge(&W); + W.set_bridge(&G); + cvc::state_cluster_shard sR(aRelay, "C", "relay"); + sR.attach(); + G.register_shard(&sR); + W.register_shard(&sR); + + cvc::state_transport_ws wc; + cvc::state_cluster_shard sWs(aWs, "C", "wsclient"); + sWs.attach(); + wc.register_shard(&sWs); + ASSERT_TRUE(wc.connect_to_peer(ws(W.listen_address()), std::chrono::milliseconds(2000))); + cvc::state_transport_grpc gc; + gc.start("127.0.0.1:0", "grpcclient", "C"); + cvc::state_cluster_shard sGrpc(aGrpc, "C", "grpcclient"); + sGrpc.attach(); + gc.register_shard(&sGrpc); + ASSERT_TRUE(gc.connect_to_peer(G.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE( + wait_until([&] { return G.connection_count() >= 1; }, std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_identified(W, 1, std::chrono::milliseconds(2000))); + W.peers().add_peer("wsclient", "C"); + ASSERT_TRUE(W.peers().set_filter("wsclient", std::make_shared("x.secret"))); + + write_value(aGrpc, "x.secret", "s"); + write_value(aGrpc, "x.pub", "p"); + gc.pump_all(); + gc.flush(); + ASSERT_TRUE( + wait_until([&] { return value_at(aWs, "x.pub") == "p"; }, std::chrono::milliseconds(3000))); + EXPECT_EQ(value_at(aWs, "x.secret"), ""); + EXPECT_EQ(value_at(aRelay, "x.secret"), "s") << "the relay itself still ingested it"; + EXPECT_GE(deliveries_filtered(W, "wsclient"), 1u); + + wc.stop(); + gc.stop(); + W.stop(); + G.stop(); +} #endif // CVC_ENABLE_GRPC diff --git a/src/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index 305d6a45a..3423a784b 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -434,6 +434,13 @@ add_executable(cvcgl_lod_node test/cvcgl_lod_node.cpp) target_link_libraries(cvcgl_lod_node PRIVATE cvcGL) add_test(NAME cvcgl_lod_node COMMAND cvcgl_lod_node) +# LodPyramidCache: one QEM pyramid build per (mesh content, params) on an async lane (deferred, +# one build per poll, where wasm has no threads), rung 0 drawn before the build lands, the build +# cap, and a prompt teardown with builds still queued. Offscreen. +add_executable(cvcgl_lod_cache test/cvcgl_lod_cache.cpp) +target_link_libraries(cvcgl_lod_cache PRIVATE cvcGL) +add_test(NAME cvcgl_lod_cache COMMAND cvcgl_lod_cache) + # GraphicsNode::setMetadata's read-only state mirror: it follows the node (it # used to keep the first value forever, each later write throwing a swallowed # read_only_error), stays locked to other writers, costs nothing for an diff --git a/src/cvcGL/CameraController.cpp b/src/cvcGL/CameraController.cpp index dafc00079..0741969db 100644 --- a/src/cvcGL/CameraController.cpp +++ b/src/cvcGL/CameraController.cpp @@ -12,6 +12,7 @@ #include #include #include +#include // std::abs(int): click slop #include #include #include @@ -151,12 +152,24 @@ class CvcCameraInteractorStyle : public vtkInteractorStyle { m_controller->mouseLook(x - lx, y - ly); } void OnLeftButtonDown() override { - if (m_controller) - m_controller->beginDrag(); + if (!m_controller) + return; + if (this->Interactor) { + int x, y; + this->Interactor->GetEventPosition(x, y); + m_controller->notePointerDown(x, y); + } + m_controller->beginDrag(); } void OnLeftButtonUp() override { - if (m_controller) - m_controller->endDrag(); + if (!m_controller) + return; + if (this->Interactor) { + int x, y; + this->Interactor->GetEventPosition(x, y); + m_controller->notePointerUp(x, y); + } + m_controller->endDrag(); } // Middle button = pan, the standard "move the scene" drag. void OnMiddleButtonDown() override { @@ -248,6 +261,11 @@ struct CameraController::Impl { // runtime (not state) std::set held; bool dragging = false; + // Click detection (notePointerDown/Up, takeClick). + bool pointerDown = false; + int pressX = 0, pressY = 0; + bool clickPending = false; + int clickX = 0, clickY = 0; bool panning = false; // middle-button drag, or primary drag on touch bool primaryDragPans = false; double poseMirrorAccum = 0.0; @@ -1069,6 +1087,35 @@ void CameraController::setPrimaryDragPans(bool on) { bool CameraController::primaryDragPans() const { return m_impl->primaryDragPans; } void CameraController::beginDrag() { m_impl->dragging = true; } + +void CameraController::notePointerDown(int x, int y) { + m_impl->pointerDown = true; + m_impl->pressX = x; + m_impl->pressY = y; +} + +void CameraController::notePointerUp(int x, int y) { + Impl &s = *m_impl; + if (!s.pointerDown) + return; + s.pointerDown = false; + constexpr int kClickSlop = 4; // px: farther than this from the press is a drag, not a click + if (std::abs(x - s.pressX) <= kClickSlop && std::abs(y - s.pressY) <= kClickSlop) { + s.clickPending = true; + s.clickX = x; + s.clickY = y; + } +} + +bool CameraController::takeClick(int &x, int &y) { + Impl &s = *m_impl; + if (!s.clickPending) + return false; + s.clickPending = false; + x = s.clickX; + y = s.clickY; + return true; +} void CameraController::endDrag() { m_impl->dragging = false; } // ---- tunables ---- diff --git a/src/cvcGL/ImGuiOverlay.cpp b/src/cvcGL/ImGuiOverlay.cpp index 2b275dd66..1576afdca 100644 --- a/src/cvcGL/ImGuiOverlay.cpp +++ b/src/cvcGL/ImGuiOverlay.cpp @@ -312,6 +312,7 @@ struct ImGuiOverlay::Impl { vtkWeakPointer interactor; vtkSmartPointer beginCmd, renderCmd, interceptCmd; std::function draw; + std::function sceneDraw; // setSceneDrawCallback: every frame, panels or not ImGuiContext *ctx = nullptr; bool ready = false; // ImGui context created bool glReady = false; // GL backend initialized (deferred to first render) @@ -393,6 +394,8 @@ struct ImGuiOverlay::Impl { if (shortSide < 700.0f) panelsOpen = false; } + if (sceneDraw) // scene annotations: under the UI, and shown with the panels hidden too + sceneDraw(); // The toggle is drawn FIRST so it is always reachable, even when the panels // it hides would have covered this corner. if (showToggle) { @@ -716,6 +719,9 @@ ImGuiOverlay::~ImGuiOverlay() { } void ImGuiOverlay::setDrawCallback(std::function draw) { m_impl->draw = std::move(draw); } +void ImGuiOverlay::setSceneDrawCallback(std::function draw) { + m_impl->sceneDraw = std::move(draw); +} ImGuiContext *ImGuiOverlay::imguiContext() const { return m_impl->ctx; } void ImGuiOverlay::setUiScale(float scale) { @@ -787,6 +793,7 @@ ImGuiOverlay::~ImGuiOverlay() = default; void ImGuiOverlay::setUiFontPath(const std::string &) {} void ImGuiOverlay::setUiFontMemory(const void *, int, bool) {} void ImGuiOverlay::setDrawCallback(std::function) {} +void ImGuiOverlay::setSceneDrawCallback(std::function) {} ImGuiContext *ImGuiOverlay::imguiContext() const { return nullptr; } // Every method in this block exists so consumers never need an #ifdef (see the // file header). attachCamera was the one omission: the nav demos call it diff --git a/src/cvcGL/LodPyramidCache.cpp b/src/cvcGL/LodPyramidCache.cpp new file mode 100644 index 000000000..db46fe0bd --- /dev/null +++ b/src/cvcGL/LodPyramidCache.cpp @@ -0,0 +1,292 @@ +/* + Copyright 2026 The University of Texas at Austin + + This file is part of libcvc. + + libcvc is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License version 2.1 as published by the Free Software Foundation. + + libcvc is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include +#include +#include +#include +#include +#include +#include // content_hash +#include +#include +#include + +namespace cvc { +namespace gl { + +namespace { + +// The position lattice of the cache key: 1 mm, for metre-scale scenes. +constexpr double kHashQuantumM = 0.001; + +// 64-bit word hashing: each word goes through murmur3's finalizer, then FNV-1a's +// xor-multiply. In-memory only (nothing persists it), so word-at-a-time is +// enough and keeps a 70k-triangle fingerprint well under a millisecond. +struct word_hash { + std::uint64_t h = 14695981039346656037ull; + + void word(std::uint64_t w) { + w ^= w >> 33; + w *= 0xff51afd7ed558ccdull; + w ^= w >> 33; + h = (h ^ w) * 1099511628211ull; + } + void real(double d) { + std::uint64_t w = 0; + std::memcpy(&w, &d, sizeof(w)); + word(w); + } + template void reals(const C &c) { + word(c.size()); + for (const auto &e : c) + for (double d : e) + real(d); + } + template void indices(const C &c) { + word(c.size()); + for (const auto &e : c) + for (std::uint64_t i : e) + word(i); + } +}; + +// Exact, order-dependent fingerprint of every array content_hash reads, plus +// the geometry type key_of mixes in. Equal fingerprints mean byte-equal arrays +// (up to a 64-bit collision), hence an equal key_of -- never the reverse. +std::uint64_t fingerprint(const cvc::geometry &g) { + word_hash h; + h.word(static_cast(g.get_geometry_type())); + h.reals(g.const_points()); + h.reals(g.const_normals()); + h.reals(g.const_colors()); + h.reals(g.const_uvs()); + h.reals(g.const_tangents()); + h.indices(g.const_tris()); + h.indices(g.const_quads()); + return h.h; +} + +std::uint64_t triangles(const cvc::geometry &g) { return g.num_tris() + 2 * g.num_quads(); } + +} // namespace + +// The lane <-> owner handoff. The lane writes `result`, `error` and `ms`, then +// sets `done` (release); the owner reads them only after seeing it (acquire). +// `src` is a deep copy no one else holds, released on the lane once used. +struct LodPyramidCache::build_job { + std::shared_ptr src; + cvc::lod::pyramid_params params; + std::uint64_t tris = 0; + std::atomic cancel{false}; + std::atomic done{false}; + cvc::lod::mesh_pyramid result; + std::string error; + double ms = 0.0; + + // Runs on the lane (the worker thread, or the poll()ing thread when + // deferred). Never throws: whatever happens is reported through the job. + void run() { + const auto t0 = std::chrono::steady_clock::now(); + if (!cancel.load(std::memory_order_acquire)) { + try { + // pool = nullptr: serial, and no thread is started for it. + result = cvc::lod::build_mesh_pyramid(*src, params, nullptr); + } catch (const std::exception &e) { + error = e.what(); + } catch (...) { + error = "unknown error"; + } + } + src.reset(); + ms = std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); + done.store(true, std::memory_order_release); + } +}; + +LodPyramidCache::LodPyramidCache(cvc::lod::pyramid_params params, std::uint64_t max_build_tris, + cvc::lane_mode mode) + : m_params(std::move(params)), m_maxBuildTris(max_build_tris), + m_mode(cvc::async_lane_has_threads ? mode : cvc::lane_mode::deferred) {} + +LodPyramidCache::~LodPyramidCache() { + // Cancel first: stop() runs every job still queued (on the worker, or here + // for a deferred lane), and a cancelled one returns without building. Only a + // build already running is waited for. + for (auto &kv : m_pending) + kv.second.job->cancel.store(true, std::memory_order_release); + if (m_lane) + m_lane->stop(); +} + +std::uint64_t LodPyramidCache::default_max_build_tris() { + // With threads the QEM pass runs beside the render loop and the cap only + // bounds memory; without them it runs ON a frame, so the cap bounds that stall. + return cvc::async_lane_has_threads ? 2000000u : 60000u; +} + +std::uint64_t LodPyramidCache::key_of(const cvc::geometry &mesh, + const cvc::lod::pyramid_params &p) { + word_hash h; + h.word(cvc::lod::content_hash(mesh, kHashQuantumM)); + // content_hash sees triangles only; the type is what GeometryNode draws them as. + h.word(static_cast(mesh.get_geometry_type())); + h.word(static_cast(static_cast(p.max_rungs))); + h.real(p.mesh_ratio); + h.word(p.mesh_min_tris); + h.word((p.preserve_boundary ? 1u : 0u) | (p.weld_seams ? 2u : 0u) | + (p.recompute_normals ? 4u : 0u)); + h.real(p.seam_epsilon); + return h.h; +} + +std::uint64_t LodPyramidCache::key_for(const cvc::geometry &mesh) { + const std::uint64_t f = fingerprint(mesh); + const auto it = m_keys.find(f); + if (it != m_keys.end()) + return it->second; + const std::uint64_t key = key_of(mesh, m_params); + m_keys.emplace(f, key); + return key; +} + +cvc::async_lane &LodPyramidCache::lane() { + if (!m_lane) { + try { + m_lane = std::make_unique("lod", m_mode); + } catch (const std::system_error &e) { + // No worker thread to be had: build on poll() instead, as a build + // without threads always does. + m_warnings.push_back(std::string("LodPyramidCache: no build thread (") + e.what() + + "); building one mesh per poll() instead"); + m_lane = std::make_unique("lod", cvc::lane_mode::deferred); + } + } + return *m_lane; +} + +void LodPyramidCache::forget(const LodGraphicsNode *node) { + for (auto &kv : m_pending) { + std::vector> &w = kv.second.waiters; + w.erase(std::remove_if(w.begin(), w.end(), + [node](const std::weak_ptr &p) { + const std::shared_ptr n = p.lock(); + return !n || n.get() == node; + }), + w.end()); + } +} + +bool LodPyramidCache::attach(const std::shared_ptr &node, + const cvc::geometry &mesh) { + if (!node) + return false; + // Whatever the node waited for before, it shows `mesh` now. + forget(node.get()); + // Over the cap it is never built, so never in the cache either: no content key needed (the + // canonical hash sorts the whole mesh, which a re-realize would redo for every such node). + const std::uint64_t tris = triangles(mesh); + if (tris > m_maxBuildTris) { + node->setBase(mesh); + if (m_overCap.insert(tris).second) + m_warnings.push_back("LodPyramidCache: a " + std::to_string(tris) + + "-triangle mesh is over the " + std::to_string(m_maxBuildTris) + + "-triangle LOD build cap; it draws at full detail only"); + return false; + } + const std::uint64_t key = key_for(mesh); + + const auto hit = m_cache.find(key); + if (hit != m_cache.end()) { + node->setPyramid(hit->second); + return true; + } + + node->setBase(mesh); // full detail at once; the coarser rungs follow + const auto pending = m_pending.find(key); + if (pending != m_pending.end()) { + pending->second.waiters.push_back(node); + return false; + } + if (m_unbuilt.count(key)) + return false; // its build failed, and was warned about then + + cvc::async_lane &builder = lane(); // before any bookkeeping: making it may throw + auto job = std::make_shared(); + auto src = std::make_shared(); + src->copy(mesh, /*deepCopy=*/true); // nothing shared with the scene while it builds + job->src = std::move(src); + job->params = m_params; + job->tris = tris; + pending_build &p = m_pending[key]; + p.job = job; + p.base = mesh; // shares the storage of the node's rung 0 + p.waiters.push_back(node); + ++m_builds; + // The job captures only its handoff struct: the last reference to it may drop + // on the lane, and nothing in it outlives that. + if (!builder.submit([job]() { job->run(); })) { + job->error = "the build lane is stopped"; + job->done.store(true, std::memory_order_release); + } + return false; +} + +std::size_t LodPyramidCache::poll() { + if (m_lane && !m_lane->threaded()) + m_lane->run_deferred(1); // one build per frame: it runs on this thread + std::size_t finished = 0; + for (auto it = m_pending.begin(); it != m_pending.end();) { + pending_build &p = it->second; + if (!p.job->done.load(std::memory_order_acquire)) { + ++it; + continue; + } + ++finished; + m_lastBuildMs = p.job->ms; + if (!p.job->error.empty() || p.job->result.rungs.empty()) { + m_unbuilt.insert(it->first); + m_warnings.push_back("LodPyramidCache: building the LOD pyramid of a " + + std::to_string(p.job->tris) + "-triangle mesh failed (" + + (p.job->error.empty() ? std::string("no rungs") : p.job->error) + + "); it draws at full detail only"); + } else { + cvc::lod::mesh_pyramid &pyr = m_cache[it->first]; + pyr = std::move(p.job->result); + // Rung 0 was built from a deep copy of `base`: hold `base` itself, which + // shares the waiting nodes' storage, so appendRungs recognises it by + // pointer and the cache keeps no second copy of the finest mesh. + pyr.rungs[0] = p.base; + for (const std::weak_ptr &w : p.waiters) + if (const std::shared_ptr node = w.lock()) + node->appendRungs(pyr); + } + it = m_pending.erase(it); + } + return finished; +} + +std::vector LodPyramidCache::take_warnings() { + std::vector out; + out.swap(m_warnings); + return out; +} + +} // namespace gl +} // namespace cvc diff --git a/src/cvcGL/ariadne/room_view.cpp b/src/cvcGL/ariadne/room_view.cpp index f55191c70..75539a2c9 100644 --- a/src/cvcGL/ariadne/room_view.cpp +++ b/src/cvcGL/ariadne/room_view.cpp @@ -2,10 +2,13 @@ #include #include +#include // room_roster #include #include #include #include +#include // lod_stats, make_view_params +#include #include #include #include @@ -13,6 +16,9 @@ #include #include #include +#ifdef CVC_ENABLE_IMGUI +#include +#endif namespace cvc { namespace gl { @@ -25,6 +31,25 @@ namespace rs = cvc::ariadne::conference; // An avatar marker: a 0.5 x 0.5 x 1.7 m box standing on the pose (its base at the pose's z). constexpr float kAvatarWidth = 0.5f; constexpr float kAvatarHeight = 1.7f; +// Its visor: a dark slab across the front (+X in the avatar's frame) at head height, standing +// proud of the face so it reads from behind and the side too. Centred at kVisorOffset from the +// marker's centre. +constexpr float kVisorSize[3] = {0.14f, 0.40f, 0.16f}; +constexpr double kVisorOffset[3] = {0.5 * kAvatarWidth, 0.0, 0.5 * kAvatarHeight - 0.32}; +// A label floats this far above the pose (above the marker's top). +constexpr double kLabelHeight = kAvatarHeight + 0.35; + +// The LOD error budget until set_lod says otherwise: in the browser, the wasm profile, which spends +// less of a smaller triangle and fill-rate budget. +#ifdef __EMSCRIPTEN__ +constexpr cvc::lod::quality_preset kDefaultLodPreset = cvc::lod::quality_preset::aggressive; +#else +constexpr cvc::lod::quality_preset kDefaultLodPreset = cvc::lod::quality_preset::balanced; +#endif + +std::string visor_node_id(const std::string &pid) { + return room_view::avatar_node_id(pid) + "_visor"; +} // A stable, distinct colour per peer (a hue from the pid's hash); the local avatar is gold. void avatar_color(const std::string &pid, bool self, float out[3]) { @@ -78,21 +103,15 @@ void pose_matrix(const cvc::physics::quat &q, double tx, double ty, double tz, d } // namespace cvc::ariadne::avatar_intent intent_from_keys(bool forward, bool back, bool left, bool right, - const double eye[3], const double focal[3], - double speed) { - double fx = focal[0] - eye[0], fy = focal[1] - eye[1]; - const double fl = std::hypot(fx, fy); - if (fl < 1e-9) { // looking straight down (or up): no heading - fx = 0.0, fy = 1.0; - } else { - fx /= fl, fy /= fl; - } + double yaw, double speed) { + const double fx = std::cos(yaw), fy = std::sin(yaw); const double rx = fy, ry = -fx; // forward x up(0,0,1) const double f = (forward ? 1.0 : 0.0) - (back ? 1.0 : 0.0); const double r = (right ? 1.0 : 0.0) - (left ? 1.0 : 0.0); double mx = f * fx + r * rx, my = f * fy + r * ry; const double ml = std::hypot(mx, my); cvc::ariadne::avatar_intent in; + in.yaw = yaw; if (ml > 1e-9) { mx *= speed / ml, my *= speed / ml; in.move = cvc::physics::vec3{mx, my, 0.0}; @@ -100,16 +119,27 @@ cvc::ariadne::avatar_intent intent_from_keys(bool forward, bool back, bool left, return in; } +double heading_yaw(const double eye[3], const double focal[3]) { + const double fx = focal[0] - eye[0], fy = focal[1] - eye[1]; + if (std::hypot(fx, fy) < 1e-9) // looking straight down (or up): no heading + return 0.5 * 3.14159265358979323846; + return std::atan2(fy, fx); +} + room_view::room_view(SceneRenderer &view, cvc::app &app, std::string room, std::string self_pid, std::string bind_prefix) : _view(view), _app(app), _room(std::move(room)), _self(std::move(self_pid)), - _prefix(std::move(bind_prefix)) {} + _prefix(std::move(bind_prefix)), _lod_cache(std::make_unique()), + _lod_stats(std::make_unique()), _lod_preset(kDefaultLodPreset) {} room_view::~room_view() { clear_scene(); SceneGraph &sg = _view.scene(); - for (const auto &kv : _avatars) + for (const auto &kv : _avatars) { sg.removeGraphics(avatar_node_id(kv.first)); + sg.removeGraphics(visor_node_id(kv.first)); + } + // _lod_cache goes with the members: it cancels its queued builds and waits for a running one. } std::vector room_view::take_warnings() { @@ -118,9 +148,53 @@ std::vector room_view::take_warnings() { return w; } +void room_view::set_lod(bool enabled, cvc::lod::quality_preset preset) { + _lod_preset = preset; + _view.scene().setLODEnabled(enabled); +} + +const cvc::gl::lod_stats &room_view::lod_stats() const { return *_lod_stats; } + +std::size_t room_view::lod_builds() const { return _lod_cache->builds(); } + void room_view::update(cvc::ariadne::holon &h, double wall_dt) { sync_scene(); + // Builds that landed since the last frame reach their objects before this frame's selection. + // Where builds have no thread this also runs one queued build, here, on the render thread. + _lod_cache->poll(); + // A mesh over the build cap (the bunny on single-threaded wasm) or a failed build says so here. + for (std::string &w : _lod_cache->take_warnings()) + _warnings.push_back(std::move(w)); sync_avatars(h, wall_dt); + sync_names(); + select_lod(); +} + +void room_view::select_lod() { + vtkRenderer *r = _view.renderer(); + // Nothing to select without LOD objects. Nor before the renderer has a camera: make_view_params + // never creates one, so it would select from the preset's default eye (the origin). The ladders + // draw rung 0 until a selection is made. + if (!_realized || _realized->lod_nodes.empty() || !r || !r->IsActiveCameraCreated()) { + _lod_stats->reset(); + return; + } + SceneGraph &sg = _view.scene(); + const cvc::lod::view_params view = make_view_params(r, cvc::lod::preset_view(_lod_preset)); + // A rung switch is geometry appearing and disappearing: a shadow map baked every Nth frame must + // be re-baked, or the old rung goes on casting. + if (sg.selectLOD(view, _lod_stats.get()) > 0) + sg.invalidateShadowBake(); +} + +void room_view::sync_names() { + // Names change rarely (a join, a rename): re-read the roster a few times a second, not per frame. + if (_names_age-- > 0) + return; + _names_age = 15; + _names.clear(); + for (const rs::room_participant &p : rs::room_roster(_app, _room)) + _names[p.pid] = p.name; } void room_view::clear_scene() { @@ -142,7 +216,12 @@ void room_view::sync_scene() { for (cvc::ariadne::SceneLight &l : scene.lights) l.id = object_node_id(l.id); SceneGraph &sg = _view.scene(); - _realized = std::make_unique(realize_scene(sg, scene, _prefix, &_warnings)); + // Mesh objects become LOD ladders from the long-lived cache: a revision change re-realizes + // every object, and the cache turns that into a re-attach rather than a rebuild. + RealizeOptions options; + options.lod = _lod_cache.get(); + _realized = + std::make_unique(realize_scene(sg, scene, _prefix, &_warnings, options)); _scene_ids = _realized->created; for (const cvc::ariadne::SceneLight &l : scene.lights) if (l.rig.empty()) // a rig's lights belong to (and leave with) its StageLighting @@ -166,13 +245,20 @@ void room_view::sync_scene() { void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { SceneGraph &sg = _view.scene(); - const std::vector pids = h.participants(); + // The participants to draw: one whose pose is not known yet (registered from the roster before + // its first sample) has nowhere to stand but the placeholder origin, so it gets no marker. + std::vector pids; + for (const std::string &pid : h.participants()) + if (h.pose_known(pid)) + pids.push_back(pid); const std::set present(pids.begin(), pids.end()); - // Departed participants: drop their markers. + // Departed participants (or one re-registered pose-less under the same pid): drop their markers. for (auto it = _avatars.begin(); it != _avatars.end();) { if (!present.count(it->first)) { sg.removeGraphics(avatar_node_id(it->first)); + sg.removeGraphics(visor_node_id(it->first)); + _heads.erase(it->first); it = _avatars.erase(it); } else { ++it; @@ -196,8 +282,20 @@ void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { n.ambient = 0.3f; n.diffuse = 0.8f; one.nodes.push_back(n); - realize_scene(sg, one, _prefix, &_warnings); // a plain geometry node: nothing to keep + cvc::ariadne::SceneNode v = n; // the visor: posed with the marker every frame below + v.id = visor_node_id(pid); + v.box_size[0] = kVisorSize[0], v.box_size[1] = kVisorSize[1], v.box_size[2] = kVisorSize[2]; + v.color[0] = 0.08f, v.color[1] = 0.09f, v.color[2] = 0.12f; + one.nodes.push_back(v); + realize_scene(sg, one, _prefix, &_warnings); // plain geometry nodes: nothing to keep _avatars[pid] = true; + if (self) { + // Its first pose is where it starts, not a correction to ease out: drop the jump from the + // placeholder it was predicted at before the first sample, so the marker appears there + // instead of gliding in from the origin. + h.take_correction(pid); + _self_offset = cvc::physics::vec3{0.0, 0.0, 0.0}; + } } cvc::physics::pose p = h.display_pose(pid); @@ -212,12 +310,21 @@ void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { _self_pos[0] = p.position.x, _self_pos[1] = p.position.y, _self_pos[2] = p.position.z; _have_self = true; } - if (std::shared_ptr node = sg.getGraphics(id)) { - double m[16]; - // The box is centred on its origin: lift it by half its height to stand on the pose. - pose_matrix(p.rotation, p.position.x, p.position.y, p.position.z + 0.5 * kAvatarHeight, m); + double m[16]; + // The box is centred on its origin: lift it by half its height to stand on the pose. + pose_matrix(p.rotation, p.position.x, p.position.y, p.position.z + 0.5 * kAvatarHeight, m); + if (std::shared_ptr node = sg.getGraphics(id)) node->setPoseMatrix(m); + if (std::shared_ptr visor = sg.getGraphics(visor_node_id(pid))) { + // The same rotation, its centre moved by the offset in the avatar's frame (R * offset). + double vm[16]; + std::copy(m, m + 16, vm); + for (int r = 0; r < 3; ++r) + vm[4 * r + 3] += m[4 * r] * kVisorOffset[0] + m[4 * r + 1] * kVisorOffset[1] + + m[4 * r + 2] * kVisorOffset[2]; + visor->setPoseMatrix(vm); } + _heads[pid] = cvc::physics::vec3{p.position.x, p.position.y, p.position.z + kLabelHeight}; } } @@ -235,6 +342,59 @@ bool room_view::self_position(double out[3]) const { return true; } +bool room_view::pick_ground(int x, int y, double ground_z, double out[3]) const { + vtkRenderer *r = _view.renderer(); + if (!r) + return false; + // The pixel's ray: its points on the near (depth 0) and far (depth 1) planes, in world space. + double ends[2][3]; + for (int i = 0; i < 2; ++i) { + r->SetDisplayPoint(static_cast(x), static_cast(y), static_cast(i)); + r->DisplayToWorld(); + const double *w = r->GetWorldPoint(); + if (std::fabs(w[3]) < 1e-12) + return false; + ends[i][0] = w[0] / w[3], ends[i][1] = w[1] / w[3], ends[i][2] = w[2] / w[3]; + } + const double dz = ends[1][2] - ends[0][2]; + if (std::fabs(dz) < 1e-12) // parallel to the ground + return false; + const double t = (ground_z - ends[0][2]) / dz; + if (t < 0.0) // the plane is behind the camera + return false; + for (int k = 0; k < 3; ++k) + out[k] = ends[0][k] + t * (ends[1][k] - ends[0][k]); + out[2] = ground_z; + return true; +} + +void room_view::draw_labels() const { +#ifdef CVC_ENABLE_IMGUI + vtkRenderer *r = _view.renderer(); + if (!r || !ImGui::GetCurrentContext()) + return; + const float h = ImGui::GetIO().DisplaySize.y; // the window's height in display pixels + ImDrawList *dl = ImGui::GetBackgroundDrawList(); // under the UI's windows + for (const auto &kv : _heads) { + if (kv.first == _self) // you are the gold one; your own name would only cover the view + continue; + const cvc::physics::vec3 &w = kv.second; + r->SetWorldPoint(w.x, w.y, w.z, 1.0); + r->WorldToDisplay(); + const double *d = r->GetDisplayPoint(); + if (d[2] < 0.0 || d[2] > 1.0) // behind the camera (or past the far plane) + continue; + auto nm = _names.find(kv.first); + const std::string &text = (nm == _names.end() || nm->second.empty()) ? kv.first : nm->second; + const ImVec2 sz = ImGui::CalcTextSize(text.c_str()); + // VTK's display origin is bottom-left, ImGui's top-left. + const ImVec2 at(static_cast(d[0]) - 0.5f * sz.x, h - static_cast(d[1]) - sz.y); + dl->AddText(ImVec2(at.x + 1.0f, at.y + 1.0f), IM_COL32(0, 0, 0, 200), text.c_str()); + dl->AddText(at, IM_COL32(255, 255, 255, 235), text.c_str()); + } +#endif +} + } // namespace ariadne } // namespace gl } // namespace cvc diff --git a/src/cvcGL/ariadne/scene_realize.cpp b/src/cvcGL/ariadne/scene_realize.cpp index 175d09380..924a33913 100644 --- a/src/cvcGL/ariadne/scene_realize.cpp +++ b/src/cvcGL/ariadne/scene_realize.cpp @@ -13,6 +13,8 @@ #include #include #include +#include // RealizeOptions::lod: a mesh node as a LOD ladder +#include #include #include #include @@ -507,9 +509,49 @@ bool read_node_geometry_and_field(SceneGraph &sg, const cvc::ariadne::SceneNode std::shared_ptr &field, std::vector *warnings); +// RealizeOptions::lod: whether geometry node `n`, whose mesh is `geom`, realizes as a LOD ladder. +bool lod_eligible(const cvc::ariadne::SceneNode &n, const cvc::geometry &geom, + const RealizeOptions &options) { + if (!options.lod) + return false; + // A mesh asset only. A primitive is a handful of triangles, and a heightfield is the terrain + // bodies collide with and custom nodes sample (out.height_fields). + if (n.has_heightfield || !n.source_primitive.empty() || n.source_file.empty()) + return false; + // Nothing a ladder cannot carry: a texture, shader or clip is set up on one GeometryNode, a body + // is made from the node's own GeometryNode (realize_physics), and children would nest under a + // node whose rungs come and go. + if (!n.material_texture.empty() || n.shader.present || n.clip.present || n.body.present || + !n.children.empty()) + return false; + return geom.num_tris() + 2 * geom.num_quads() >= options.lod_min_tris; +} + +// A geometry node realized as a LOD ladder, created where its GeometryNode would have been: under +// `parent`, or at the top level with sg.addGraphics' last-wins registration. Every rung gets the +// node's material; the cache shows `geom` at once and adds the coarser rungs when they are built. +std::shared_ptr +realize_lod_geometry(SceneGraph &sg, const cvc::ariadne::SceneNode &n, const cvc::geometry &geom, + GraphicsNode *parent, LodPyramidCache &cache) { + std::shared_ptr lod; + if (parent) { + lod = parent->addGraphicsChild(n.id); + } else { + if (sg.hasGraphics(n.id)) // last-wins parity with sg.addGraphics (see volren) + sg.removeGraphics(n.id); + lod = sg.getGraphicsRoot()->addGraphicsChild(n.id); + sg.registerGraphics(n.id, lod); // name-map parity + grid enclosure for a top-level node + } + // Set before the first rung exists, so no rung ever draws unstyled. The style lives as long as + // the node and the Scene may not (a room scene is read into a temporary): it holds a copy. + lod->setRungStyle([spec = n](GeometryNode &g) { apply_node_material(g, spec); }); + cache.attach(lod, geom); + return lod; +} + void realize_node(SceneGraph &sg, const cvc::ariadne::SceneNode &n, const std::string &bind_prefix, RealizedScene &out, GraphicsNode *parent, std::vector &bodies, - std::vector *warnings) { + const RealizeOptions &options, std::vector *warnings) { std::shared_ptr node; if (n.type == "geometry") { @@ -519,40 +561,40 @@ void realize_node(SceneGraph &sg, const cvc::ariadne::SceneNode &n, const std::s return; if (field) out.height_fields[n.id] = field; // the grid this node draws, for bodies and custom nodes - std::shared_ptr g = - parent ? parent->createChild(n.id, geom) - : std::dynamic_pointer_cast(sg.addGraphics(n.id, geom)); - if (g && n.has_material) { - g->setUseSingleColor(n.use_single_color); - g->setColor(n.color[0], n.color[1], n.color[2]); - g->setAmbient(n.ambient); - g->setDiffuse(n.diffuse); - if (n.has_specular) { - g->setSpecular(n.specular); - g->setSpecularPower(n.specular_power); - } - } - // A heightfield's per-vertex band colours are there to be shown: switch the node to per-vertex - // colour (overriding the material single-colour default) when the field supplied them. - if (g && n.has_heightfield && !n.heightfield.colors.empty()) - g->setUseSingleColor(false); - // material: { texture: } — resolve the URI, read the image, sample it through the mesh's - // UVs. Warn (never throw) on an unresolved URI or an unreadable image so the rest of the scene - // still realizes; a mesh with no UVs simply shows the texture's (0,0) texel. - if (g && !n.material_texture.empty()) { - cvc::ariadne::ResolvedFile tex = cvc::ariadne::resolve_to_file(n.material_texture); - if (!tex.ok) { - warn(warnings, "ari: scene node '" + n.id + "': texture " + tex.error); - } else { - try { - g->setTexture(cvc::read_image(tex.path)); - } catch (const std::exception &e) { - warn(warnings, - "ari: scene node '" + n.id + "': texture '" + n.material_texture + "': " + e.what()); + if (lod_eligible(n, geom, options)) { + std::shared_ptr lod = + realize_lod_geometry(sg, n, geom, parent, *options.lod); + out.lod_nodes.push_back(lod); + node = lod; + } else { + std::shared_ptr g = + parent ? parent->createChild(n.id, geom) + : std::dynamic_pointer_cast(sg.addGraphics(n.id, geom)); + if (g) + apply_node_material(*g, n); + // A heightfield's per-vertex band colours are there to be shown: switch the node to + // per-vertex colour (overriding the material single-colour default) when the field supplied + // them. + if (g && n.has_heightfield && !n.heightfield.colors.empty()) + g->setUseSingleColor(false); + // material: { texture: } — resolve the URI, read the image, sample it through the + // mesh's UVs. Warn (never throw) on an unresolved URI or an unreadable image so the rest of + // the scene still realizes; a mesh with no UVs simply shows the texture's (0,0) texel. + if (g && !n.material_texture.empty()) { + cvc::ariadne::ResolvedFile tex = cvc::ariadne::resolve_to_file(n.material_texture); + if (!tex.ok) { + warn(warnings, "ari: scene node '" + n.id + "': texture " + tex.error); + } else { + try { + g->setTexture(cvc::read_image(tex.path)); + } catch (const std::exception &e) { + warn(warnings, "ari: scene node '" + n.id + "': texture '" + n.material_texture + + "': " + e.what()); + } } } + node = g; } - node = g; } else if (n.type == "volume") { if (n.source_file.empty()) { warn(warnings, "ari: scene node '" + n.id + "' (volume) has no source.file"); @@ -690,7 +732,7 @@ void realize_node(SceneGraph &sg, const cvc::ariadne::SceneNode &n, const std::s // Children nest UNDER this node, so each child's transform is local to it. for (const auto &c : n.children) - realize_node(sg, c, bind_prefix, out, node.get(), bodies, warnings); + realize_node(sg, c, bind_prefix, out, node.get(), bodies, options, warnings); } LightNode::Kind light_kind(const std::string &k) { @@ -891,6 +933,19 @@ bool read_node_geometry(SceneGraph &sg, const cvc::ariadne::SceneNode &n, cvc::g return read_node_geometry_and_field(sg, n, geom, field, warnings); } +void apply_node_material(GeometryNode &g, const cvc::ariadne::SceneNode &n) { + if (!n.has_material) + return; + g.setUseSingleColor(n.use_single_color); + g.setColor(n.color[0], n.color[1], n.color[2]); + g.setAmbient(n.ambient); + g.setDiffuse(n.diffuse); + if (n.has_specular) { + g.setSpecular(n.specular); + g.setSpecularPower(n.specular_power); + } +} + // A dim³ signed-distance-field volume of a mesh, framed like the volume demos' bunny_sdf: the grid // spans the mesh extents padded 10% (cubic). Lets a volren/volslice node declare // `source: { sdf: { mesh:, dim: } }` instead of a pre-baked volume file. @@ -1228,6 +1283,12 @@ void realize_physics(SceneGraph &sg, const cvc::ariadne::ScenePhysics &spec, RealizedScene realize_scene(SceneGraph &sg, const cvc::ariadne::Scene &scene, const std::string &bind_prefix, std::vector *warnings) { + return realize_scene(sg, scene, bind_prefix, warnings, RealizeOptions()); +} + +RealizedScene realize_scene(SceneGraph &sg, const cvc::ariadne::Scene &scene, + const std::string &bind_prefix, std::vector *warnings, + const RealizeOptions &options) { RealizedScene out; // §9 time: carry the app + the scene's clock declaration so tick_scene can drive // app.world_clock() each frame (a no-op unless the scene declared a `clock:` block). @@ -1244,7 +1305,7 @@ RealizedScene realize_scene(SceneGraph &sg, const cvc::ariadne::Scene &scene, out.created.reserve(scene.nodes.size()); std::vector bodies; for (const auto &n : scene.nodes) { - realize_node(sg, n, bind_prefix, out, /*parent=*/nullptr, bodies, warnings); + realize_node(sg, n, bind_prefix, out, /*parent=*/nullptr, bodies, options, warnings); out.created.push_back(n.id); } // physics: and the nodes' body:s, once every node (and every terrain field) exists. A physics diff --git a/src/cvcGL/test/cvcgl_ariadne_realize.cpp b/src/cvcGL/test/cvcgl_ariadne_realize.cpp index 37581295d..357d38a6b 100644 --- a/src/cvcGL/test/cvcgl_ariadne_realize.cpp +++ b/src/cvcGL/test/cvcgl_ariadne_realize.cpp @@ -19,6 +19,8 @@ #include #include #include +#include // LOD test: RealizeOptions realizes mesh nodes as ladders +#include #include // clip test: the low-memory mapper honours no planes #include #include @@ -33,10 +35,12 @@ #include #include #include +#include // LOD test: a rung's material #include #include #include // clip test: the planes handed to a node's renderer #include +#include #include using cvc::ariadne::Scene; @@ -1109,6 +1113,232 @@ int main() { cvc::gl::setLowMemoryMapperPolicy(cvc::gl::LowMemoryMapperPolicy::Auto); } + // ── LOD: RealizeOptions realizes a mesh-file node as a LodGraphicsNode, and only such a node ── + { + using cvc::gl::GeometryNode; + using cvc::gl::LodGraphicsNode; + using cvc::gl::LodPyramidCache; + // The embedded bunny (69,473 triangles), stood up 1.7 m tall, in an orange material. + auto bunny = [](const char *id) { + SceneNode b; + b.id = id; + b.type = "geometry"; + b.source_file = "x.bunny"; + b.has_fit = true; + b.fit_up_y = true; + b.fit_height = 1.7f; + b.has_material = true; + b.color[0] = 0.9f; + b.color[1] = 0.4f; + b.color[2] = 0.1f; + b.ambient = 0.3f; + b.diffuse = 0.6f; + b.has_specular = true; + b.specular = 0.25f; + b.specular_power = 12.0f; + return b; + }; + const SceneNode spec = bunny("spec"); + // The node's material, on a rung or a plain GeometryNode. + auto material_matches = [&spec](const std::shared_ptr &g) { + vtkActor *a = g ? vtkActor::SafeDownCast(g->prop()) : nullptr; + if (!a) + return false; + const double *c = a->GetProperty()->GetColor(); + return approx(c[0], spec.color[0]) && approx(c[1], spec.color[1]) && + approx(c[2], spec.color[2]) && approx(a->GetProperty()->GetAmbient(), spec.ambient) && + approx(a->GetProperty()->GetDiffuse(), spec.diffuse) && + approx(a->GetProperty()->GetSpecular(), spec.specular); + }; + auto is_lod = [](SceneGraph &sg, const std::string &id) { + return std::dynamic_pointer_cast(sg.getGraphics(id)) != nullptr; + }; + auto is_geometry = [](SceneGraph &sg, const std::string &id) { + return std::dynamic_pointer_cast(sg.getGraphics(id)) != nullptr; + }; + + printf("== LOD: a bunny becomes a LodGraphicsNode with RealizeOptions ==\n"); + { + // Deferred, so the build runs exactly when this block polls. + LodPyramidCache cache(cvc::lod::pyramid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + cvc::gl::ariadne::RealizeOptions opts; + opts.lod = &cache; + SceneGraph sg(app, "lodr"); + Scene scene; + SceneNode top = bunny("bunny"); + top.has_transform = true; + top.position[0] = 5.0f; + scene.nodes.push_back(top); + SceneNode grp; // a group holding a second, hidden bunny + grp.id = "grp"; + grp.type = "group"; + grp.has_transform = true; + grp.position[1] = 10.0f; + SceneNode inner = bunny("inner"); + inner.visible_default = false; + grp.children.push_back(inner); + scene.nodes.push_back(grp); + std::vector warnings; + auto realized = cvc::gl::ariadne::realize_scene(sg, scene, "lodr", &warnings, opts); + sg.processEvents(); + + auto lod = std::dynamic_pointer_cast(sg.getGraphics("bunny")); + chk(lod != nullptr, "LOD: the bunny is a LodGraphicsNode, registered under its id"); + auto g = sg.getGraphics("grp"); + auto nested = + g ? std::dynamic_pointer_cast(g->findChildByName("inner")) : nullptr; + chk(nested != nullptr, "LOD: a nested bunny is a LodGraphicsNode under its parent"); + chk(realized.lod_nodes.size() == 2 && realized.lod_nodes[0].lock() == lod && + realized.lod_nodes[1].lock() == nested, + "LOD: both are listed in lod_nodes"); + chk(warnings.empty(), "LOD: no warnings"); + if (lod && nested) { + chk(lod->rungCount() == 1 && lod->rungTriangles(0) == 69473, + "LOD: the full bunny draws at once, as rung 0"); + chk(cache.builds() == 1 && cache.in_flight() == 1, + "LOD: one build for the two nodes (one mesh)"); + chk(material_matches(lod->rung(0)), "LOD: rung 0 wears the node's material"); + const double origin[3] = {0, 0, 0}; + double world[3] = {0, 0, 0}; + lod->localToWorld(origin, world); + double nestedWorld[3] = {0, 0, 0}; + nested->localToWorld(origin, nestedWorld); + chk(approx(world[0], 5.0) && approx(nestedWorld[1], 10.0), + "LOD: the node's transform applies (and composes under its parent)"); + chk(lod->isVisible() && !nested->isVisible() && nested->rung(0) && + !nested->rung(0)->prop()->GetVisibility(), + "LOD: visible: false hides the ladder"); + + chk(cache.poll() == 1, "LOD: one poll runs the deferred build"); + chk(lod->rungCount() > 1 && nested->rungCount() == lod->rungCount(), + "LOD: both nodes get the coarser rungs"); + bool all = true; + for (int k = 0; k < lod->rungCount(); ++k) + all = all && material_matches(lod->rung(k)) && material_matches(nested->rung(k)); + chk(all, "LOD: every rung wears the node's material"); + int shown = 0; + for (int k = 0; k < nested->rungCount(); ++k) + shown += nested->rung(k)->prop()->GetVisibility() ? 1 : 0; + chk(shown == 0, "LOD: the hidden node's new rungs stay hidden"); + } + + // A re-realize of the same scene (a room's whole-scene rev bump) needs no build. + SceneGraph again(app, "lodr2"); + auto realized2 = cvc::gl::ariadne::realize_scene(again, scene, "lodr2", nullptr, opts); + auto lod2 = std::dynamic_pointer_cast(again.getGraphics("bunny")); + chk(lod2 && lod2->rungCount() > 1 && cache.builds() == 1 && cache.in_flight() == 0, + "LOD: realizing the same meshes again attaches the cached ladder"); + (void)realized2; + } + + printf("== LOD: ineligible nodes stay GeometryNodes ==\n"); + { + LodPyramidCache cache(cvc::lod::pyramid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + cvc::gl::ariadne::RealizeOptions opts; + opts.lod = &cache; + // A mesh file under lod_min_tris: a 12-triangle cube. + const std::string smallPath = "ari_realize_lod_small.off"; + { + cvc::geometry cube(app); + for (int i = 0; i < 8; ++i) { + const double x = (i & 1) ? 0.5 : -0.5, y = (i & 2) ? 0.5 : -0.5, z = (i & 4) ? 0.5 : -0.5; + cube.points().push_back({x, y, z}); + } + const int faces[12][3] = {{0, 1, 3}, {0, 3, 2}, {4, 6, 7}, {4, 7, 5}, {0, 4, 5}, {0, 5, 1}, + {2, 3, 7}, {2, 7, 6}, {0, 2, 6}, {0, 6, 4}, {1, 5, 7}, {1, 7, 3}}; + for (const auto &f : faces) + cube.tris().push_back({std::uint64_t(f[0]), std::uint64_t(f[1]), std::uint64_t(f[2])}); + cvc::write_geometry(cube, smallPath); + } + // (The texture makes the node ineligible whether or not the image is readable.) + const std::string texPath = "ari_realize_lod_tex.png"; + try { + cvc::write_image(cvc::image(4, 4), texPath); + } catch (const std::exception &) { + } + + SceneGraph sg(app, "lodi"); + Scene scene; + SceneNode box; // a primitive + box.id = "box"; + box.type = "geometry"; + box.source_primitive = "box"; + scene.nodes.push_back(box); + SceneNode hf; // a heightfield + hf.id = "hf"; + hf.type = "geometry"; + hf.has_heightfield = true; + hf.heightfield.size = 10.0f; + hf.heightfield.resolution = 128; // ~32k triangles: big enough, but terrain + scene.nodes.push_back(hf); + SceneNode tex = bunny("tex"); // a texture + tex.material_texture = texPath; + scene.nodes.push_back(tex); + SceneNode shd = bunny("shd"); // a shader + shd.shader.present = true; + scene.nodes.push_back(shd); + SceneNode clip = bunny("clip"); // a clip + clip.clip.present = true; + clip.clip.planes.push_back(SceneClipPlane()); + scene.nodes.push_back(clip); + SceneNode body = bunny("body"); // a body + body.has_transform = true; + body.position[0] = 20.0f; + body.body.present = true; + body.body.motion = "static"; + body.body.shape = "box"; + scene.nodes.push_back(body); + SceneNode parent = bunny("parent"); // children + SceneNode kid; + kid.id = "kid"; + kid.type = "group"; + parent.children.push_back(kid); + scene.nodes.push_back(parent); + SceneNode small; // under lod_min_tris + small.id = "small"; + small.type = "geometry"; + small.source_file = smallPath; + scene.nodes.push_back(small); + std::vector warnings; + auto realized = cvc::gl::ariadne::realize_scene(sg, scene, "lodi", &warnings, opts); + std::remove(smallPath.c_str()); + std::remove(texPath.c_str()); + + bool allPlain = true; + for (const char *id : {"box", "hf", "tex", "shd", "clip", "body", "parent", "small"}) { + const bool plain = is_geometry(sg, id) && !is_lod(sg, id); + if (!plain) + printf(" '%s' is not a plain GeometryNode\n", id); + allPlain = allPlain && plain; + } + chk(allPlain, "LOD: a primitive, a heightfield, a textured, shaded, clipped, bodied or " + "parent mesh, and one under lod_min_tris, all stay GeometryNodes"); + chk(realized.lod_nodes.empty() && cache.builds() == 0, "LOD: ... and none is built"); + chk(material_matches(std::dynamic_pointer_cast(sg.getGraphics("shd"))), + "LOD: the plain path still applies the material (apply_node_material)"); + chk(realized.body("body").valid(), "LOD: the bodied mesh still gets its body"); + } + + printf("== LOD: the four-argument realize_scene never makes a LOD node ==\n"); + { + Scene scene; + scene.nodes.push_back(bunny("bunny")); + SceneGraph sg(app, "lod4"); + auto realized = cvc::gl::ariadne::realize_scene(sg, scene, "lod4"); + chk(is_geometry(sg, "bunny") && !is_lod(sg, "bunny") && realized.lod_nodes.empty(), + "LOD: four arguments -> a plain GeometryNode"); + chk(material_matches(std::dynamic_pointer_cast(sg.getGraphics("bunny"))), + "LOD: ... wearing the node's material, as before"); + SceneGraph sg2(app, "lod5"); + auto realized2 = cvc::gl::ariadne::realize_scene(sg2, scene, "lod5", nullptr, + cvc::gl::ariadne::RealizeOptions()); + chk(is_geometry(sg2, "bunny") && !is_lod(sg2, "bunny") && realized2.lod_nodes.empty(), + "LOD: default RealizeOptions (no cache) -> a plain GeometryNode too"); + } + } + #ifdef CVC_ENABLE_LIBIGL // ── mesh_lab / fe_lab: a request key runs a job off the render thread; a tick applies it ────── cvc::gl::ariadne::register_cvcgl_extensions(app); diff --git a/src/cvcGL/test/cvcgl_lod_cache.cpp b/src/cvcGL/test/cvcgl_lod_cache.cpp new file mode 100644 index 000000000..f18a7a1a2 --- /dev/null +++ b/src/cvcGL/test/cvcgl_lod_cache.cpp @@ -0,0 +1,566 @@ +// cvcgl_lod_cache -- LodPyramidCache: mesh LOD ladders built once per distinct +// mesh, off the frame, and handed to the LodGraphicsNodes that wait for them. +// +// * one build per mesh -- two nodes showing two reads of the same asset share +// one build, both get the rungs, and a later node is a cache hit; +// * the key -- content, not storage or triangle order, plus every mesh-build +// parameter; +// * progressive attach -- rung 0 draws the moment a node is attached, and the +// landing build keeps that very rung-0 node; +// * deferred lanes (what a build without threads gets) -- exactly one build +// per poll(), on the polling thread, and nothing between polls; +// * teardown -- destroying the cache with builds queued cancels them instead +// of running them; +// * the build cap -- a mesh over it keeps its one rung and says so, once; +// * waiting nodes are held weakly, and a re-attached node stops waiting for +// its earlier mesh; +// * a FEATURE-ON ring of bunnies, one build for all of them, coarsening with +// distance. +// +// All of that is headless. The last section renders offscreen -- rung 0 on +// screen before the build lands, a styled coarse rung after -- and skips itself +// when a plain control node cannot be rasterised, unless CVC_REQUIRE_RENDER=1 +// makes that a failure. +// +// NOT assert(): these tests build Release (NDEBUG), where assert() compiles to +// nothing and a "passing" run would prove nothing. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // fit_to_ground +#include +#include +#include +#include +#include +#include +#include +#include + +using cvc::gl::GeometryNode; +using cvc::gl::lod_stats; +using cvc::gl::LodGraphicsNode; +using cvc::gl::LodPyramidCache; +using cvc::gl::SceneGraph; +using cvc::gl::SceneRenderer; + +namespace { + +int g_failures = 0; +void check_impl(bool ok, const char *expr, int line) { + if (!ok) { + std::printf(" FAIL line %d: %s\n", line, expr); + ++g_failures; + } +} +#define CHECK(cond) check_impl(static_cast(cond), #cond, __LINE__) + +constexpr double kPi = 3.14159265358979323846; + +double ms_since(std::chrono::steady_clock::time_point t0) { + return std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); +} + +// An n x n heightfield over a `size` square at (x0, y0), with a gentle smooth +// relief so decimation has detail to remove: 2 n^2 triangles. A different +// (x0, y0) is a different mesh to the cache. +cvc::geometry grid_mesh(cvc::app &app, double x0, double y0, double size, int n) { + cvc::geometry g(app); + cvc::geometry::points_t &P = g.points(); + for (int j = 0; j <= n; ++j) + for (int i = 0; i <= n; ++i) { + const double u = double(i) / n, v = double(j) / n; + const double z = + size * (0.06 * std::sin(7.0 * u) * std::cos(5.0 * v) + 0.03 * std::sin(17.0 * (u + v))); + P.push_back({x0 + size * u, y0 + size * v, z}); + } + cvc::geometry::tris_t &T = g.tris(); + for (int j = 0; j < n; ++j) + for (int i = 0; i < n; ++i) { + const std::uint64_t a = std::uint64_t(j) * (n + 1) + i, b = a + 1, c = a + (n + 1), d = c + 1; + T.push_back({a, b, d}); + T.push_back({a, d, c}); + } + g.set_geometry_type(cvc::geometry::SURFACE_TRI); + return g; +} + +// The embedded Stanford bunny (69,473 triangles), stood up Z-up and 1.7 m tall +// the way a scene's `fit:` does, so its rung errors are in metres. Each call is +// a fresh read with its own storage. +bool g_bunny_ok = true; +cvc::geometry bunny(cvc::app &) { + try { + return cvc::gl::ariadne::fit_to_ground(cvc::read_geometry("x.bunny"), true, 1.7f); + } catch (const std::exception &e) { + std::printf(" cannot read the embedded bunny: %s\n", e.what()); + g_bunny_ok = false; + return cvc::geometry(); + } +} + +// Poll until every build has landed; false on a timeout. +bool wait_for_builds(LodPyramidCache &cache, double timeout_s = 240.0) { + const auto t0 = std::chrono::steady_clock::now(); + for (;;) { + cache.poll(); + if (cache.in_flight() == 0) + return true; + if (ms_since(t0) > 1000.0 * timeout_s) + return false; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } +} + +// Which rung actor is visible (-1 none, -2 several). +int visible_rung(const LodGraphicsNode &lod) { + int which = -1; + for (int k = 0; k < lod.rungCount(); ++k) + if (lod.rung(k)->prop()->GetVisibility()) + which = which == -1 ? k : -2; + return which; +} + +cvc::lod::view_params view_at(double x, double y, double z) { + cvc::lod::view_params v = cvc::lod::preset_view(cvc::lod::quality_preset::balanced); + v.eye[0] = x; + v.eye[1] = y; + v.eye[2] = z; + return v; +} + +std::shared_ptr add_lod(SceneGraph &sg, const std::string &name) { + return sg.getGraphicsRoot()->addGraphicsChild(name); +} + +cvc::lod::pyramid_params grid_params() { + cvc::lod::pyramid_params p; + p.preserve_boundary = false; // a grid patch is nearly all boundary otherwise + return p; +} + +// --- one build per mesh -------------------------------------------------------- + +void test_same_mesh_builds_once(cvc::app &app, LodPyramidCache &cache) { + std::printf("same mesh builds once\n"); + SceneGraph sg(app, "lcsame"); + auto a = add_lod(sg, "a"); + auto b = add_lod(sg, "b"); + const cvc::geometry m1 = bunny(app); + const cvc::geometry m2 = bunny(app); // the same asset read again: its own storage + CHECK(g_bunny_ok && m1.num_tris() == 69473); + CHECK(LodPyramidCache::default_max_build_tris() >= m1.num_tris()); // this build builds it + + CHECK(!cache.attach(a, m1)); // a miss: rung 0 now, the rest when built + CHECK(!cache.attach(b, m2)); // joins the build already started for m1 + CHECK(cache.builds() == 1 && cache.in_flight() == 1 && cache.cached() == 0); + CHECK(a->rungCount() == 1 && b->rungCount() == 1); + + const auto t0 = std::chrono::steady_clock::now(); + CHECK(wait_for_builds(cache)); + std::printf(" bunny pyramid: %.0f ms on the lane (%.0f ms to land)\n", cache.last_build_ms(), + ms_since(t0)); + CHECK(cache.builds() == 1 && cache.cached() == 1 && cache.in_flight() == 0); + CHECK(a->rungCount() > 1 && b->rungCount() == a->rungCount()); + CHECK(a->rungTriangles(a->rungCount() - 1) < a->rungTriangles(0)); + + // A later node with a third read of the bunny is a hit: the whole ladder at + // once, no build. + auto c = add_lod(sg, "c"); + CHECK(cache.attach(c, bunny(app))); + CHECK(c->rungCount() == a->rungCount()); + CHECK(cache.builds() == 1 && cache.in_flight() == 0); + CHECK(cache.poll() == 0); + CHECK(cache.take_warnings().empty()); +} + +// --- the key --------------------------------------------------------------------- + +void test_keys(cvc::app &app) { + std::printf("keys: content + mesh-build params\n"); + const cvc::geometry g = grid_mesh(app, 0.0, 0.0, 10.0, 16); + const cvc::lod::pyramid_params p = cvc::lod::pyramid_params(); + const std::uint64_t k = LodPyramidCache::key_of(g, p); + CHECK(LodPyramidCache::key_of(g, p) == k); + + // Content, not storage or order: a deep copy, and the same triangles listed + // backwards, are the same mesh. + cvc::geometry deep(app); + deep.copy(g, /*deepCopy=*/true); + CHECK(LodPyramidCache::key_of(deep, p) == k); + cvc::geometry reversed = deep; + std::reverse(reversed.tris().begin(), reversed.tris().end()); + CHECK(LodPyramidCache::key_of(reversed, p) == k); + + // A centimetre away is another mesh. + CHECK(LodPyramidCache::key_of(grid_mesh(app, 0.01, 0.0, 10.0, 16), p) != k); + + // Every parameter a mesh build reads is in the key... + auto differs = [&](void (*edit)(cvc::lod::pyramid_params &)) { + cvc::lod::pyramid_params q = p; + edit(q); + return LodPyramidCache::key_of(g, q) != k; + }; + CHECK(differs([](cvc::lod::pyramid_params &q) { q.max_rungs = 2; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.mesh_ratio = 0.5; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.mesh_min_tris = 128; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.preserve_boundary = false; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.weld_seams = false; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.seam_epsilon = 0.01; })); + CHECK(differs([](cvc::lod::pyramid_params &q) { q.recompute_normals = true; })); + // ...and the volume/image ones are not. + CHECK(!differs([](cvc::lod::pyramid_params &q) { + q.vol_factor = 3; + q.img_factor = 4; + q.img_world_per_texel = 2.0; + })); + + // Two caches with different params build their own pyramids of one mesh. + cvc::lod::pyramid_params coarse = p; + coarse.mesh_ratio = 0.2; + LodPyramidCache c1(p, LodPyramidCache::default_max_build_tris(), cvc::lane_mode::deferred); + LodPyramidCache c2(coarse, LodPyramidCache::default_max_build_tris(), cvc::lane_mode::deferred); + SceneGraph sg(app, "lckeys"); + CHECK(!c1.attach(add_lod(sg, "a"), g)); + CHECK(!c2.attach(add_lod(sg, "b"), g)); + CHECK(c1.builds() == 1 && c2.builds() == 1); +} + +// --- progressive attach -------------------------------------------------------- + +void test_base_shown_before_build(cvc::app &app) { + std::printf("rung 0 before the build\n"); + // Deferred, so the build cannot land behind the test's back. + LodPyramidCache cache(grid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + SceneGraph sg(app, "lcbase"); + auto lod = add_lod(sg, "lod"); + const cvc::geometry g = grid_mesh(app, 0.0, 0.0, 10.0, 32); + CHECK(!cache.attach(lod, g)); + CHECK(lod->rungCount() == 1 && lod->activeRung() == 0 && visible_rung(*lod) == 0); + CHECK(lod->rungTriangles(0) == g.num_tris()); + CHECK(lod->rung(0)->getGeometry() && lod->rung(0)->getGeometry()->num_points() == g.num_points()); + CHECK(cache.in_flight() == 1 && cache.cached() == 0); + // A deferred lane has no thread: nothing builds between polls. + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + CHECK(lod->rungCount() == 1 && cache.in_flight() == 1); + // A selection on a one-rung ladder has nowhere to go. + CHECK(sg.selectLOD(view_at(0.0, 0.0, 1.0e6)) == 0 && visible_rung(*lod) == 0); +} + +void test_rung0_kept_by_append(cvc::app &app) { + std::printf("the landing build keeps rung 0\n"); + LodPyramidCache cache(grid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + SceneGraph sg(app, "lckeep"); + auto lod = add_lod(sg, "lod"); + int styled = 0; + lod->setRungStyle([&styled](GeometryNode &g) { + ++styled; + g.setMetadata("styled", true); + }); + const cvc::geometry g = grid_mesh(app, 0.0, 0.0, 10.0, 32); + cache.attach(lod, g); + const std::shared_ptr rung0 = lod->rung(0); + vtkProp *const actor0 = rung0->prop(); + CHECK(styled == 1); + + CHECK(cache.poll() == 1); + CHECK(lod->rungCount() > 1); + CHECK(lod->rung(0) == rung0 && lod->rung(0)->prop() == actor0); // same node, same actor + CHECK(styled == lod->rungCount()); // only the new rungs were styled, each once + for (int k = 0; k < lod->rungCount(); ++k) + CHECK(lod->rung(k)->hasMetadata("styled")); + CHECK(lod->rungError(0) == 0.0 && lod->rungError(1) > 0.0); + CHECK(lod->rungTriangles(1) < lod->rungTriangles(0)); + + // The cached rung 0 is the mesh the node drew: a hit draws the same triangles. + auto hit = add_lod(sg, "hit"); + CHECK(cache.attach(hit, g)); + CHECK(hit->rungCount() == lod->rungCount()); + CHECK(hit->rungTriangles(0) == lod->rungTriangles(0)); +} + +// --- deferred lanes ------------------------------------------------------------ + +void test_deferred_one_build_per_poll(cvc::app &app) { + std::printf("deferred: one build per poll\n"); + LodPyramidCache cache(grid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + SceneGraph sg(app, "lcdefer"); + std::vector> nodes; + for (int i = 0; i < 3; ++i) { + nodes.push_back(add_lod(sg, "n" + std::to_string(i))); + CHECK(!cache.attach(nodes.back(), grid_mesh(app, 20.0 * i, 0.0, 10.0, 32))); + } + CHECK(cache.builds() == 3 && cache.in_flight() == 3 && cache.cached() == 0); + for (int k = 1; k <= 3; ++k) { + CHECK(cache.poll() == 1); + CHECK(cache.in_flight() == std::size_t(3 - k) && cache.cached() == std::size_t(k)); + // In attach order: the k-th node has its rungs, the next one not yet. + CHECK(nodes[k - 1]->rungCount() > 1); + if (k < 3) + CHECK(nodes[k]->rungCount() == 1); + } + CHECK(cache.poll() == 0 && cache.builds() == 3); +} + +// --- teardown -------------------------------------------------------------------- + +void test_destroy_with_queued_builds(cvc::app &app) { + std::printf("teardown with builds queued\n"); + const int N = 8; + const cvc::lod::pyramid_params p = grid_params(); + std::vector meshes; + for (int i = 0; i < N; ++i) + meshes.push_back(grid_mesh(app, 100.0 * i, 0.0, 50.0, 96)); // 18,432 triangles each + // What one of those builds costs here, now. + auto t0 = std::chrono::steady_clock::now(); + const cvc::lod::mesh_pyramid probe = cvc::lod::build_mesh_pyramid(meshes[0], p, nullptr); + const double one = ms_since(t0); + CHECK(probe.rungs.size() > 1); + + for (const cvc::lane_mode mode : {cvc::lane_mode::deferred, cvc::lane_mode::threaded}) { + const bool deferred = mode == cvc::lane_mode::deferred; + SceneGraph sg(app, deferred ? "lcdown0" : "lcdown1"); + std::vector> nodes; + auto cache = + std::make_unique(p, LodPyramidCache::default_max_build_tris(), mode); + for (int i = 0; i < N; ++i) { + nodes.push_back(add_lod(sg, "n" + std::to_string(i))); + cache->attach(nodes.back(), meshes[i]); + } + CHECK(cache->in_flight() == std::size_t(N)); + t0 = std::chrono::steady_clock::now(); + cache.reset(); + const double down = ms_since(t0); + std::printf(" %s: destroyed with %d builds queued in %.1f ms (one build: %.1f ms)\n", + deferred ? "deferred" : "threaded", N, down, one); + // Deferred, nothing has run and nothing may: well under one build. Threaded, + // at most the build the worker had already started, never the queue. + CHECK(deferred ? down < one : down < 0.5 * N * one); + for (const auto &n : nodes) + CHECK(n->rungCount() == 1); // no build landed on a dead cache + } +} + +// --- the build cap --------------------------------------------------------------- + +void test_over_cap(cvc::app &app) { + std::printf("build cap\n"); + CHECK(LodPyramidCache::default_max_build_tris() == + (cvc::async_lane_has_threads ? 2000000u : 60000u)); + LodPyramidCache cache(grid_params(), /*max_build_tris=*/1000, cvc::lane_mode::deferred); + SceneGraph sg(app, "lccap"); + const cvc::geometry big = grid_mesh(app, 0.0, 0.0, 10.0, 32); // 2,048 triangles + auto lod = add_lod(sg, "big"); + CHECK(!cache.attach(lod, big)); + CHECK(lod->rungCount() == 1 && visible_rung(*lod) == 0); + CHECK(cache.builds() == 0 && cache.in_flight() == 0 && cache.cached() == 0); + std::vector w = cache.take_warnings(); + CHECK(w.size() == 1); + if (!w.empty()) { + std::printf(" warning: %s\n", w[0].c_str()); + CHECK(w[0].find("2048") != std::string::npos && w[0].find("1000") != std::string::npos); + } + CHECK(cache.take_warnings().empty()); // taken + CHECK(cache.poll() == 0 && lod->rungCount() == 1); + + // The same mesh again: one rung, no second warning, still no build. + auto again = add_lod(sg, "again"); + CHECK(!cache.attach(again, big)); + CHECK(again->rungCount() == 1 && cache.take_warnings().empty() && cache.builds() == 0); + + // Under the cap builds as usual. + auto small = add_lod(sg, "small"); + CHECK(!cache.attach(small, grid_mesh(app, 0.0, 0.0, 10.0, 16))); // 512 triangles + CHECK(cache.builds() == 1 && cache.poll() == 1 && small->rungCount() > 1); +} + +// --- waiting nodes ------------------------------------------------------------- + +void test_waiters(cvc::app &app) { + std::printf("waiting nodes: weak, and re-attach forgets\n"); + LodPyramidCache cache(grid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + // A node destroyed before its build lands: the build still lands, for later. + { + auto gone = std::make_shared(app, "test.gone", "gone"); + cache.attach(gone, grid_mesh(app, 0.0, 0.0, 10.0, 32)); + } + CHECK(cache.in_flight() == 1); + CHECK(cache.poll() == 1 && cache.cached() == 1); + + // A node attached to B, then to C, shows C: B's landing passes it by. + SceneGraph sg(app, "lcwait"); + auto lod = add_lod(sg, "lod"); + const cvc::geometry b = grid_mesh(app, 20.0, 0.0, 10.0, 32); + const cvc::geometry c = grid_mesh(app, 40.0, 0.0, 10.0, 32); + cache.attach(lod, b); + cache.attach(lod, c); + CHECK(cache.builds() == 3 && cache.in_flight() == 2); + auto shows_c = [&]() { + const cvc::geometry *r0 = lod->rung(0) ? lod->rung(0)->getGeometry() : nullptr; + return r0 && r0->num_points() == c.num_points() && + r0->const_points()[0][0] == c.const_points()[0][0]; + }; + CHECK(shows_c()); + CHECK(cache.poll() == 1); // B + CHECK(lod->rungCount() == 1 && shows_c()); + CHECK(cache.poll() == 1); // C + CHECK(lod->rungCount() > 1 && shows_c()); + CHECK(cache.cached() == 3); +} + +// --- FEATURE-ON: a ring of bunnies ----------------------------------------------- + +void test_bunny_ring(cvc::app &app, LodPyramidCache &cache) { + std::printf("feature-on: 25 bunnies on a 60 m ring\n"); + if (!g_bunny_ok) { + CHECK(false); + return; + } + SceneGraph sg(app, "lcring"); + const cvc::geometry b = bunny(app); + const int N = 25; + const double radius = 30.0; + std::vector> nodes; + for (int i = 0; i < N; ++i) { + auto n = add_lod(sg, "bunny" + std::to_string(i)); + const double a = 2.0 * kPi * i / N; + n->setPosition(radius * std::cos(a), radius * std::sin(a), 0.0); + cache.attach(n, b); + nodes.push_back(n); + } + CHECK(wait_for_builds(cache)); + CHECK(cache.builds() == 1); // the first test's build, for every instance + + // From eye height at the centre, every bunny is 30 m away. + lod_stats st; + sg.selectLOD(view_at(0.0, 0.0, 1.7), &st); + CHECK(st.nodes == N && st.hidden == 0); + CHECK(st.full_tris == std::uint64_t(N) * b.num_tris()); + CHECK(st.drawn_tris < st.full_tris); + std::printf(" centre: %llu of %llu tris (%.3f)\n", (unsigned long long)st.drawn_tris, + (unsigned long long)st.full_tris, double(st.drawn_tris) / double(st.full_tris)); + + // Beside the first bunny: it alone goes back to full detail. + sg.selectLOD(view_at(radius + 1.0, 0.0, 1.0), &st); + CHECK(nodes[0]->selectedRung() == 0); + CHECK(nodes[N / 2]->selectedRung() > 0); + CHECK(st.drawn_tris < st.full_tris && st.drawn_tris >= b.num_tris()); +} + +// --- offscreen renders ------------------------------------------------------------- + +long red_pixels(SceneRenderer &sr, long *lit = nullptr) { + long red = 0, any = 0; + const std::vector px = sr.frameRGB(); + for (std::size_t i = 0; i + 2 < px.size(); i += 3) { + const int r = px[i], g = px[i + 1], b = px[i + 2]; + if (r + g + b > 60) + ++any; + if (r > 150 && g < 80 && b < 80) + ++red; + } + if (lit) + *lit = any; + return red; +} + +void flat_red(GeometryNode &g) { + g.setColor(1.0, 0.0, 0.0); + g.setAmbient(1.0); // flat: no dependence on the lights + g.setDiffuse(0.0); + g.setSpecular(0.0); +} + +// Asked of a CONTROL -- a plain GeometryNode in a scene of its own -- never of +// the LOD node under test, whose regression would otherwise look like "no GL". +bool can_rasterise(cvc::app &app) { + SceneGraph sg(app, "lccontrol"); + sg.setDiagnosticChromeVisible(false); + auto node = sg.getGraphicsRoot()->addGraphicsChild("control"); + node->setGeometry(grid_mesh(app, -2.0, -2.0, 4.0, 4)); + flat_red(*node); + SceneRenderer sr(sg, 64, 64, /*offscreen=*/true); + sr.setBackground(0.0, 0.0, 0.0); + sr.setCamera(0, -6, 6, 0, 0, 0, 0, 0, 1, 40.0, 0.5, 100.0); + long lit = 0; + red_pixels(sr, &lit); + return lit > 0; +} + +void test_offscreen(cvc::app &app) { + std::printf("offscreen render\n"); + if (!can_rasterise(app)) { + const char *require = std::getenv("CVC_REQUIRE_RENDER"); + if (require && *require && std::string(require) != "0") { + std::printf(" CVC_REQUIRE_RENDER is set, but this build did not rasterise\n"); + CHECK(false); + } else { + std::printf(" skipped: this build did not rasterise (the plain control drew nothing)\n"); + } + return; + } + // The default params pin the patch's border, so every rung covers the same pixels. + LodPyramidCache cache(cvc::lod::pyramid_params(), LodPyramidCache::default_max_build_tris(), + cvc::lane_mode::deferred); + SceneGraph sg(app, "lcrender"); + sg.setDiagnosticChromeVisible(false); + auto lod = add_lod(sg, "lod"); + lod->setRungStyle(flat_red); + cache.attach(lod, grid_mesh(app, -2.0, -2.0, 4.0, 32)); + SceneRenderer sr(sg, 64, 64, /*offscreen=*/true); + sr.setBackground(0.0, 0.0, 0.0); + sr.setCamera(0, -6, 6, 0, 0, 0, 0, 0, 1, 40.0, 0.5, 100.0); + + // Before the build: rung 0 is on screen. + const long before = red_pixels(sr); + CHECK(before > 0); + // After it, with the selection forced coarse: a styled coarse rung draws in + // its place, covering about the same pixels (the border is pinned; only the + // relief inside it is coarser). + CHECK(cache.poll() == 1 && lod->rungCount() > 1); + sg.selectLOD(view_at(0.0, 0.0, 1.0e6)); + CHECK(lod->selectedRung() > 0 && visible_rung(*lod) == lod->selectedRung()); + const long after = red_pixels(sr); + std::printf(" red pixels: rung 0 %ld, rung %d %ld\n", before, lod->selectedRung(), after); + CHECK(after > 0 && std::abs(after - before) * 5 <= before); +} + +} // namespace + +int main() { + std::setvbuf(stdout, nullptr, _IONBF, 0); + cvc::app app; + app.properties("system.log_verbosity", "0"); + // One threaded cache for the bunny, so the ring reuses the first test's build. + LodPyramidCache bunnies; + test_same_mesh_builds_once(app, bunnies); + test_keys(app); + test_base_shown_before_build(app); + test_rung0_kept_by_append(app); + test_deferred_one_build_per_poll(app); + test_destroy_with_queued_builds(app); + test_over_cap(app); + test_waiters(app); + test_bunny_ring(app, bunnies); + test_offscreen(app); + std::printf("%s: cvcgl_lod_cache (%d check%s failed)\n", g_failures == 0 ? "PASS" : "FAIL", + g_failures, g_failures == 1 ? "" : "s"); + return g_failures == 0 ? 0 : 1; +} diff --git a/src/cvcGL/test/cvcgl_room_view.cpp b/src/cvcGL/test/cvcgl_room_view.cpp index d9596b51d..821499f85 100644 --- a/src/cvcGL/test/cvcgl_room_view.cpp +++ b/src/cvcGL/test/cvcgl_room_view.cpp @@ -1,27 +1,43 @@ // H1 slice 6: cvc::gl::ariadne::room_view — the conference room in 3D, offscreen. The room scene // from the replicated tree is realized (and re-realized on a revision change), avatar markers // follow a client holon's participants and poses, a local reconcile jump is eased (no visible -// step), the third-person key -> intent mapping is camera-relative, and teardown removes every -// node room_view made. +// step), the key -> intent mapping walks and strafes relative to the avatar's facing, the visor +// turns with the facing, a pixel picks the ground point under it, a participant whose pose is not +// known yet has no marker (and the local avatar appears at its first pose without gliding in), the +// room's mesh objects are LOD ladders whose rung follows the camera every frame (one build for one +// asset, kept across a re-realize; the default room has none), and teardown removes every node +// room_view made. +#include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include +#include +#include #include +#include +#include +#include #include +#include using cvc::ariadne::avatar_intent; using cvc::ariadne::holon; using cvc::ariadne::holon_role; +using cvc::gl::LodGraphicsNode; using cvc::gl::SceneGraph; using cvc::gl::SceneRenderer; +using cvc::gl::ariadne::heading_yaw; using cvc::gl::ariadne::intent_from_keys; using cvc::gl::ariadne::room_view; using cvc::physics::body_state; @@ -45,26 +61,56 @@ static bool node_xyz(SceneGraph &sg, const std::string &id, double out[3]) { return true; } +static std::shared_ptr lod_node(SceneGraph &sg, const std::string &id) { + return std::dynamic_pointer_cast(sg.getGraphics(id)); +} + +// Aim the view's camera (what room_view's LOD selection reads) from EYE at FOCAL, Z up. +static void look(SceneRenderer &view, const double eye[3], const double focal[3]) { + vtkCamera *cam = view.renderer()->GetActiveCamera(); + cam->SetPosition(eye[0], eye[1], eye[2]); + cam->SetFocalPoint(focal[0], focal[1], focal[2]); + cam->SetViewUp(0.0, 0.0, 1.0); +} + int main() { cvc::app app; app.properties("system.log_verbosity", "0"); - // ── keys -> intent, relative to the camera's heading ───────────────────── + // ── keys -> intent, relative to the avatar's facing ────────────────────── { - const double eye[3] = {-10, 0, 5}, focal[3] = {0, 0, 0}; // looking along +x - avatar_intent w = intent_from_keys(true, false, false, false, eye, focal, 2.0); - chk(near(w.move.x, 2.0) && near(w.move.y, 0.0) && near(w.move.z, 0.0), - "W walks along the view"); - avatar_intent d = intent_from_keys(false, false, false, true, eye, focal, 2.0); - chk(near(d.move.x, 0.0) && near(d.move.y, -2.0), "D strafes right of the view (-y here)"); - avatar_intent wd = intent_from_keys(true, false, false, true, eye, focal, 2.0); + const double kPi = 3.14159265358979323846; + avatar_intent w = intent_from_keys(true, false, false, false, 0.0, 2.0); // facing +x + chk(near(w.move.x, 2.0) && near(w.move.y, 0.0) && near(w.move.z, 0.0) && near(w.yaw, 0.0), + "W walks along the facing, and the intent carries it"); + avatar_intent d = intent_from_keys(false, false, false, true, 0.0, 2.0); + chk(near(d.move.x, 0.0) && near(d.move.y, -2.0), "D strafes right of the facing (-y here)"); + avatar_intent s = intent_from_keys(false, true, false, false, 0.5 * kPi, 2.0); // facing +y + chk(near(s.move.x, 0.0) && near(s.move.y, -2.0) && near(s.yaw, 0.5 * kPi), + "S backs away, still facing the same way"); + avatar_intent a = intent_from_keys(false, false, true, false, 0.5 * kPi, 2.0); + chk(near(a.move.x, -2.0) && near(a.move.y, 0.0), "A strafes left of the facing (-x here)"); + avatar_intent wd = intent_from_keys(true, false, false, true, 0.0, 2.0); chk(near(std::hypot(wd.move.x, wd.move.y), 2.0) && wd.move.x > 0 && wd.move.y < 0, "a diagonal is normalized, not faster"); - avatar_intent ws = intent_from_keys(true, true, false, false, eye, focal, 2.0); - chk(near(ws.move.x, 0.0) && near(ws.move.y, 0.0), "opposing keys cancel"); + avatar_intent ws = intent_from_keys(true, true, false, false, 1.0, 2.0); + chk(near(ws.move.x, 0.0) && near(ws.move.y, 0.0) && near(ws.yaw, 1.0), + "opposing keys cancel, and standing still keeps the facing"); + const double eye[3] = {-10, 0, 5}, focal[3] = {0, 0, 0}; + chk(near(heading_yaw(eye, focal), 0.0), "heading_yaw: the view along +x is yaw 0"); const double top[3] = {0, 0, 10}, down[3] = {0, 0, 0}; - avatar_intent td = intent_from_keys(true, false, false, false, top, down, 1.0); - chk(near(td.move.y, 1.0) && near(td.move.x, 0.0), "straight down: forward is +Y"); + chk(near(heading_yaw(top, down), 0.5 * kPi), "heading_yaw: straight down is +Y"); + } + + // ── the facing reaches the avatar's rotation ───────────────────────────── + { + cvc::ariadne::avatar_sim sim; + avatar_intent in; + in.yaw = 0.5 * 3.14159265358979323846; + sim.step(in, 1.0 / 60.0); + const cvc::physics::quat q = sim.state().rotation; + chk(near(q.w, std::sqrt(0.5)) && near(q.z, std::sqrt(0.5)) && near(q.x, 0.0) && near(q.y, 0.0), + "the sim faces the intent's yaw (a turn about +Z)"); } // ── the camera's held keys are what the host reads for the avatar ───────── @@ -85,6 +131,15 @@ int main() { SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); holon h(holon_role::client); rs::seed_default_scene(app, "r"); + // The default room has no lights (its rig is the client document's); a room MAY carry its own, + // and room_view realizes them like the objects. + { + rs::room_scene_env env = rs::default_scene_env(); + cvc::ariadne::SceneLight sun; + sun.id = "sun"; + env.lights.push_back(sun); + rs::write_scene_env(app, "r", env); + } { room_view rv(view, app, "r", "A", sg.getStatePrefix()); rv.update(h, 1.0 / 60.0); @@ -125,6 +180,34 @@ int main() { "the local avatar's marker stands at its pose"); chk(node_xyz(sg, "avatar_B", pb) && near(pb[1], 4.0), "a peer's marker stands at its pose"); chk(rv.self_position(self) && near(self[0], 4.0), "self_position is the local avatar"); + double va[3]; + chk(node_xyz(sg, "avatar_A_visor", va) && near(va[0], 4.25, 1e-4) && near(va[1], 0.0) && + va[2] > 1.0, + "the visor is on the front (+x at yaw 0), at head height"); + + // Pick: aim the camera straight down at (4, 0) and the centre pixel is that ground point. + vtkCamera *cam = view.renderer()->GetActiveCamera(); + cam->SetPosition(4.0, 0.0, 20.0); + cam->SetFocalPoint(4.0, 0.0, 0.0); + cam->SetViewUp(0.0, 1.0, 0.0); + view.renderer()->ResetCameraClippingRange(); + int *vs = view.renderer()->GetSize(); + double g[3]; + chk(rv.pick_ground(vs[0] / 2, vs[1] / 2, 0.0, g) && near(g[0], 4.0, 0.2) && + near(g[1], 0.0, 0.2) && near(g[2], 0.0), + "pick_ground: the centre pixel lands under the camera"); + double g2[3]; + chk(rv.pick_ground(vs[0] - 1, vs[1] / 2, 0.0, g2) && g2[0] > g[0] + 0.5, + "pick_ground: a pixel to the right lands further along +x"); + + // Turn A to face +y: the visor swings round to its +y side. + avatar_intent turn; + turn.yaw = 0.5 * 3.14159265358979323846; + h.set_intent("A", turn); + h.advance(dt); + rv.update(h, dt); + chk(node_xyz(sg, "avatar_A_visor", va) && near(va[0], 4.0, 1e-4) && near(va[1], 0.25, 1e-4), + "the visor turns with the facing"); // A server correction teleports A by +3 m in y: the marker must not jump this frame, // then glides to the corrected pose. @@ -150,10 +233,238 @@ int main() { h.remove_participant("B"); h.advance(dt); rv.update(h, dt); - chk(sg.getGraphics("avatar_B") == nullptr, "a departed peer's marker is removed"); + chk(sg.getGraphics("avatar_B") == nullptr && sg.getGraphics("avatar_B_visor") == nullptr, + "a departed peer's marker (and visor) is removed"); chk(sg.getGraphics("avatar_A") != nullptr, "the others stay"); } - chk(sg.getGraphics("avatar_A") == nullptr, "teardown removes the avatar markers"); + chk(sg.getGraphics("avatar_A") == nullptr && sg.getGraphics("avatar_A_visor") == nullptr, + "teardown removes the avatar markers"); + } + + // ── no marker for a peer whose pose is not known yet ────────────────────── + { + SceneGraph sg(app, "room_pose_unknown"); + SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); + holon h(holon_role::client); + body_state a0; + a0.position = vec3{4.0, 0.0, 0.0}; + h.add_participant("A", /*local=*/true, a0); + // In the roster before its first sample (client_pump): its start is a placeholder origin. + h.add_participant("C", /*local=*/false, body_state{}, /*pose_known=*/false); + const double dt = h.clock().fixed_dt(); + { + room_view rv(view, app, "empty", "A", sg.getStatePrefix()); + h.advance(dt); + rv.update(h, dt); + chk(sg.getGraphics("avatar_C") == nullptr && sg.getGraphics("avatar_C_visor") == nullptr, + "no marker for a peer whose pose is not known (not one at the origin)"); + const std::vector shown = rv.avatar_pids(); + chk(shown.size() == 1 && shown[0] == "A", "only the participants with a pose are drawn"); + + body_state c1; + c1.position = vec3{-3.0, 2.0, 0.0}; + h.apply_authoritative("C", c1, h.tick()); + h.advance(dt); + rv.update(h, dt); + double pc[3]; + chk(node_xyz(sg, "avatar_C", pc) && near(pc[0], -3.0) && near(pc[1], 2.0) && + sg.getGraphics("avatar_C_visor") != nullptr, + "its marker appears at its first sample"); + } + chk(sg.getGraphics("avatar_C") == nullptr, "teardown removes it too"); + } + + // ── the local avatar appears at its first pose, without a glide ─────────── + { + SceneGraph sg(app, "room_self_first"); + SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); + holon h(holon_role::client); + h.add_participant("A", /*local=*/true, body_state{}, /*pose_known=*/false); + const double dt = h.clock().fixed_dt(); + { + room_view rv(view, app, "empty", "A", sg.getStatePrefix()); + h.advance(dt); + rv.update(h, dt); + double self[3]; + chk(sg.getGraphics("avatar_A") == nullptr && !rv.self_position(self), + "no self marker (and no self_position) before the first pose"); + + // The first sample, from a server that has run a while: the replica re-aligns its clock and + // snaps the avatar from the origin to it -- a reconcile jump, but not one to ease out. + body_state first; + first.position = vec3{2.0, -3.0, 0.0}; + h.apply_authoritative("A", first, 500); + h.advance(dt); + rv.update(h, dt); + double pa[3]; + chk(node_xyz(sg, "avatar_A", pa) && near(pa[0], 2.0) && near(pa[1], -3.0), + "the self marker appears at its first pose, not on its way from the origin"); + chk(rv.self_position(self) && near(self[0], 2.0) && near(self[1], -3.0), + "self_position is there from the start"); + for (int i = 0; i < 30; ++i) { + h.advance(dt); + rv.update(h, dt); + } + chk(node_xyz(sg, "avatar_A", pa) && near(pa[0], 2.0) && near(pa[1], -3.0), + "and it stays there (no glide)"); + } + } + + // ── the default room has no LOD objects ─────────────────────────────────── + { + SceneGraph sg(app, "room_lod_default"); + SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); + holon h(holon_role::client); + rs::seed_default_scene(app, "lod_default"); + const double eye[3] = {0.0, -10.0, 2.0}, focal[3] = {0.0, 0.0, 0.0}; + look(view, eye, focal); // a camera to select from: what keeps the stats at zero is the room + { + room_view rv(view, app, "lod_default", "A", sg.getStatePrefix()); + rv.update(h, 1.0 / 60.0); + chk(sg.getGraphics("room_ground") && sg.getGraphics("room_ref") && + sg.getAllGraphicsOfType().empty(), + "the default room has no LOD objects (its plane and box are a handful of triangles)"); + chk(std::dynamic_pointer_cast(sg.getGraphics("room_ground")) && + std::dynamic_pointer_cast(sg.getGraphics("room_ref")), + "both are plain geometry nodes"); + chk(rv.lod_builds() == 0 && rv.lod_stats().nodes == 0 && rv.lod_stats().changes == 0, + "nothing is built and nothing selected"); + } + } + + // ── the room's meshes are LOD ladders, chosen from the camera each frame ── + { + SceneGraph sg(app, "room_lod"); + // 800 px tall: the view the LOD presets' pixel budgets are stated for. + SceneRenderer view(sg, 800, 800, /*offscreen=*/true, "main"); + holon h(holon_role::client); + // A 40 m ground, the reference box and four bunnies (one asset, 69,473 triangles, fitted + // 1.7 m tall), one per 20 m cell: bunny__ stands at (-10 + 20 i, -10 + 20 j). + chk(rs::seed_large_demo_scene(app, "lod", 40.0, 2), "seeded a room with four bunnies"); + const char *const ids[4] = {"room_bunny_0_0", "room_bunny_1_0", "room_bunny_0_1", + "room_bunny_1_1"}; + // Near: the eye inside bunny_1_1's bounds, about 28 m from bunny_0_0. Far: a kilometre off. + const double near_eye[3] = {10.0, 10.0, 0.85}, near_focal[3] = {0.0, 0.0, 0.85}; + const double far_eye[3] = {0.0, -1000.0, 300.0}, origin[3] = {0.0, 0.0, 0.0}; + const double dt = 1.0 / 60.0; + { + room_view rv(view, app, "lod", "A", sg.getStatePrefix()); + look(view, near_eye, near_focal); + rv.update(h, dt); + std::vector> lods; + const auto fetch = [&] { + lods.clear(); + for (const char *id : ids) + if (std::shared_ptr n = lod_node(sg, id)) + lods.push_back(n); + return lods.size() == 4; + }; + const auto all = [&](auto pred) { + for (const std::shared_ptr &n : lods) + if (!pred(*n)) + return false; + return !lods.empty(); + }; + const bool have = fetch(); + chk(have, "every bunny is a LOD object, under its room id"); + chk(sg.getGraphics("room_ref") && !lod_node(sg, "room_ref"), "the box stays a plain node"); + chk(all([](const LodGraphicsNode &n) { return n.rungCount() >= 1; }), + "each draws its full-detail mesh at once"); + chk(rv.lod_builds() == 1, "the four share one build (one asset)"); + for (const std::string &w : rv.take_warnings()) + chk(false, "no realize or LOD warning (the bunny is under the native build cap): " + w); + + // The build runs on the cache's lane; update() hands its rungs to the bunnies when it lands. + const auto t0 = std::chrono::steady_clock::now(); + const auto laddered = [&] { + return all([](const LodGraphicsNode &n) { return n.rungCount() > 1; }); + }; + while (have && !laddered() && + std::chrono::steady_clock::now() - t0 < std::chrono::seconds(240)) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + rv.update(h, dt); + } + printf( + " bunny pyramid landed after %.0f ms\n", + std::chrono::duration(std::chrono::steady_clock::now() - t0).count()); + chk(laddered(), "the build lands: every bunny gets the coarser rungs"); + chk(rv.lod_builds() == 1, "from that one build"); + + if (have && laddered()) { + const cvc::gl::lod_stats &st = rv.lod_stats(); // refreshed in place by every update + const std::uint64_t full = lods[0]->rungTriangles(0); + rv.update(h, dt); + chk(lods[3]->selectedRung() == 0, "near: the bunny the eye stands in draws full detail"); + chk(lods[0]->selectedRung() > 0, "and the one 28 m away a coarser rung, the same frame"); + chk(st.nodes == 4 && st.full_tris == 4 * full && st.drawn_tris < st.full_tris && + st.drawn_tris >= full, + "lod_stats: four nodes, fewer triangles drawn than at full detail"); + const std::uint64_t near_drawn = st.drawn_tris; + + look(view, far_eye, origin); + rv.update(h, dt); + chk(all([](const LodGraphicsNode &n) { return n.selectedRung() > 0; }) && st.changes >= 1, + "far: the next update coarsens every bunny"); + chk(st.drawn_tris < near_drawn, "drawing fewer triangles than from near"); + + // The preset is the selection's budget. At a distance where rung 1 of bunny_1_1 costs 1.8 + // px, aggressive (3.5 px) affords it and pristine (1 px) does not. + const cvc::bounding_box bb = lods[3]->getWorldBoundingBox(); + const double c[3] = {0.5 * (bb.minx + bb.maxx), 0.5 * (bb.miny + bb.maxy), + 0.5 * (bb.minz + bb.maxz)}; + const double r = 0.5 * std::sqrt((bb.maxx - bb.minx) * (bb.maxx - bb.minx) + + (bb.maxy - bb.miny) * (bb.maxy - bb.miny) + + (bb.maxz - bb.minz) * (bb.maxz - bb.minz)); + const double k = cvc::lod::k_px(cvc::gl::make_view_params(view.renderer())); + const double d = lods[3]->rungError(1) * k / 1.8; + const double mid_eye[3] = {c[0] + r + d, c[1], c[2]}; + look(view, mid_eye, c); + rv.set_lod(true, cvc::lod::quality_preset::aggressive); + rv.update(h, dt); + const int aggressive = lods[3]->selectedRung(); + rv.set_lod(true, cvc::lod::quality_preset::pristine); + rv.update(h, dt); + const int pristine = lods[3]->selectedRung(); + chk(aggressive >= 1 && pristine == 0, + "set_lod's preset is the budget: aggressive coarsens where pristine keeps full detail"); + rv.set_lod(true, cvc::lod::quality_preset::balanced); + look(view, far_eye, origin); + rv.update(h, dt); + + // Off: everything back to full detail, and kept there while the camera stays far. + rv.set_lod(false, cvc::lod::quality_preset::balanced); + rv.update(h, dt); + const auto full_detail = [](const LodGraphicsNode &n) { + return n.activeRung() == 0 && n.selectedRung() == 0; + }; + chk(all(full_detail) && st.changes == 4 && st.drawn_tris == st.full_tris, + "set_lod(false) pins every bunny to rung 0 (each a change, for the shadow re-bake)"); + rv.update(h, dt); + chk(all(full_detail) && st.changes == 0, "and keeps them there, frame after frame"); + rv.set_lod(true, cvc::lod::quality_preset::balanced); + rv.update(h, dt); + chk(all([](const LodGraphicsNode &n) { return n.selectedRung() > 0; }), + "set_lod(true) resumes the selection"); + + // A revision bump re-realizes every object: new nodes, attached from the cache. + const std::uint64_t rev0 = rv.realized_revision(); + const int rungs = lods[0]->rungCount(); + const std::weak_ptr old = lods[0]; + lods.clear(); // the test's own references would keep the old nodes alive + rs::remove_scene_object(app, "lod", "ref"); + rv.update(h, dt); + chk(rv.realized_revision() != rev0 && sg.getGraphics("room_ref") == nullptr, + "a revision bump re-realizes the room"); + const std::shared_ptr before = old.lock(), again = lod_node(sg, ids[0]); + chk(again && again != before, "as new LOD objects"); + chk(fetch() && all([rungs](const LodGraphicsNode &n) { return n.rungCount() == rungs; }), + "each with the whole ladder at once, from the cache"); + chk(rv.lod_builds() == 1, "and no second build"); + chk(all([](const LodGraphicsNode &n) { return n.selectedRung() > 0; }) && st.nodes == 4, + "selected in the same update that realized them"); + } + } + chk(sg.getAllGraphicsOfType().empty(), "teardown removes the LOD objects"); } printf("%s (%d failure%s)\n", fails ? "FAILED" : "OK", fails, fails == 1 ? "" : "s");