From 72f454b1dde79b0cd5ddf900564c503d9536aeba Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:42:58 -0500 Subject: [PATCH 01/33] feat(ariadne): headless avatar predict/reconcile/interpolate (H1 slice 2) The one genuinely new mechanism H1 needs on top of the clock_driver + data() replication it already has: a participant's avatar is server-authoritative, so a client must move its own avatar without a round-trip (predict), correct when the authoritative state arrives (reconcile), and show peers lag-free without guessing ahead (interpolate). New cvc::ariadne (headless, engine-free, no GL / state tree / transport -- that plumbing is the holon, slice 3): - avatar_sim: a deterministic linear kinematic integrator over a fixed dt (position += velocity*dt, pure double arithmetic, no trig), so the same intents reproduce bit-for-bit -- what makes replay sound. A stub for the physics::world save/restore stepping slice 3 swaps in behind the same shape. - predicted_avatar: client prediction + server reconciliation. Applies local intents immediately, logs the unacknowledged ones; on an authoritative state for a past tick, keeps the prediction when it already matches within tolerance (no correction), else snaps to the authoritative state and REPLAYS the inputs issued after that tick, so in-flight player input is corrected-on-top, not dropped. - interpolate_peer: a non-local peer between its last two authoritative samples, alpha clamped to [0,1) so it never extrapolates past newest. avatar_sim_test (6 cases, all green): bit-reproducible stepping; a perfect prediction reconciles with no correction; prediction ahead of the server keeps inputs pending; a server override snaps + replays in-flight ticks (and really moves the avatar); a matching ack after a correction does not re-snap or jump backward; peer interpolation never overshoots (and clamps negative/NaN alpha to the previous sample). All comparisons are lib-step vs lib-step, so bit-equality holds without -ffp-contract=off. Branches off master (has #766 clock_driver + #767 H0). Independent of #768 (the physics codec); the MVP pose rides scalar keys, so this does not need the typed-object codec. Next: S3 wires this into a cvc::ariadne::holon on its own clock_driver. --- inc/cvc/ariadne/avatar_sim.h | 124 +++++++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/avatar_sim.cpp | 97 ++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 ++ src/cvc/tests/avatar_sim_test.cpp | 179 ++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/avatar_sim.h create mode 100644 src/cvc/ariadne/avatar_sim.cpp create mode 100644 src/cvc/tests/avatar_sim_test.cpp diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h new file mode 100644 index 000000000..db66e335e --- /dev/null +++ b/inc/cvc/ariadne/avatar_sim.h @@ -0,0 +1,124 @@ +/* + 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_SIM_H__ +#define __CVC_ARIADNE_AVATAR_SIM_H__ + +#include +#include +#include // cvc::physics::body_state +#include // cvc::physics::pose / interpolate +#include // cvc::physics::vec3 +#include + +namespace cvc { +namespace ariadne { + +// ---------------- +// cvc::ariadne — headless avatar prediction / reconciliation (H1 slice 2) +// ---------------- +// The one genuinely new mechanism H1 needs on top of the clock + replication it +// already has: a participant's avatar is server-authoritative, but a client must +// move its OWN avatar without waiting a round-trip, so it PREDICTS locally and +// RECONCILES when the server's authoritative state arrives; peers it never +// predicts, only interpolates between their last two authoritative samples. +// +// This is deliberately headless and engine-free: pure double arithmetic over a +// fixed timestep, no cvc::physics::world (Jolt), no GL, no state tree, no +// transport. That isolates the loop's correctness -- which is what is subtle -- +// from the plumbing (the holon owns the clock_driver and the state wiring; that is +// 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. +struct avatar_intent { + cvc::physics::vec3 move; // desired linear velocity, world metres/second +}; + +// 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. +class avatar_sim { +public: + avatar_sim() = default; + explicit avatar_sim(const cvc::physics::body_state &start) : _state(start) {} + + const cvc::physics::body_state &state() const { return _state; } + void set_state(const cvc::physics::body_state &s) { _state = s; } + + // Advance exactly one fixed quantum from `in`. + void step(const avatar_intent &in, double dt); + +private: + cvc::physics::body_state _state; +}; + +// Client-side prediction + server reconciliation for ONE locally-controlled +// avatar on a fixed-dt clock. The client applies its own intents immediately +// (predict), keeping a log of inputs the server has not yet acknowledged; when an +// authoritative state for a past tick arrives it reconciles: if the state the +// client predicted at that tick already matches the authoritative one within `tol` +// metres, it keeps its prediction (no visible correction); otherwise it snaps to +// the authoritative state and REPLAYS the inputs issued after that tick, so the +// current prediction is corrected without discarding the player's in-flight input. +// +// Deterministic: with matching inputs and dt the replay reproduces the prediction +// exactly, so a correct prediction reconciles to a bit-identical state and costs +// nothing; only a genuine divergence (a server override, a dropped step) snaps. +class predicted_avatar { +public: + predicted_avatar() = default; + 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). + void predict(std::uint64_t tick, const avatar_intent &in, double dt); + + // Reconcile against an authoritative state valid as of `server_tick` + // (expected monotonically non-decreasing across calls). Returns true if a hard + // correction (snap + replay) was applied, false if the prediction already held. + bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, + double dt, double tol); + + const cvc::physics::body_state &state() const { return _sim.state(); } + // Inputs issued but not yet acknowledged by the server (awaiting replay). + std::size_t pending() const { return _log.size(); } + +private: + struct logged { + std::uint64_t tick = 0; + avatar_intent in; + cvc::physics::body_state predicted; // state AFTER applying `in` at `tick` + }; + avatar_sim _sim; + std::deque _log; +}; + +// Interpolate a NON-local peer avatar between its last two authoritative samples, +// NEVER extrapolating: alpha is clamped to [0, 1), so the result never passes the +// newest sample (a one-step display lag is correct; extrapolation would overshoot +// on a late sample). Thin wrapper over cvc::physics::interpolate on the pose +// (position + rotation) of each body_state. +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha); + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_AVATAR_SIM_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index bf23c1a10..b295b3466 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,11 +1306,12 @@ 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/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) +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/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) 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/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp new file mode 100644 index 000000000..938106d7e --- /dev/null +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -0,0 +1,97 @@ +/* + 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 + +namespace cvc { +namespace ariadne { + +void avatar_sim::step(const avatar_intent &in, double dt) { + // Linear kinematic integration: the body moves at the requested velocity. Pure + // double arithmetic (vec3 + and *), no trig, so it is bit-reproducible. + _state.linear_velocity = in.move; + _state.position = _state.position + in.move * dt; + _state.active = true; +} + +void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, double dt) { + _sim.step(in, dt); + logged e; + e.tick = tick; + e.in = in; + e.predicted = _sim.state(); + _log.push_back(e); +} + +bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, + std::uint64_t server_tick, double dt, double tol) { + // What did we predict AT server_tick? (null if we never predicted that tick -- + // e.g. the server is ahead of us, or that input was already acknowledged.) + const logged *at = nullptr; + for (const auto &e : _log) + if (e.tick == server_tick) { + at = &e; + break; + } + + const double tol2 = tol * tol; + const auto within_tol = [&](const cvc::physics::body_state &a, + const cvc::physics::body_state &b) { + const cvc::physics::vec3 d = a.position - b.position; + return cvc::physics::dot(d, d) <= tol2; + }; + + if (at != nullptr && within_tol(at->predicted, authoritative)) { + // Prediction held: the server agrees with what we had at server_tick, so the + // inputs after it (which we already applied) still stand. Just drop the inputs + // the server has now acknowledged; keep predicting forward from our state. + while (!_log.empty() && _log.front().tick <= server_tick) + _log.pop_front(); + return false; + } + + // Diverged (a server override, a dropped/extra step, or we have no record of + // server_tick): adopt the authoritative state, then re-apply the inputs we + // issued AFTER server_tick so the player's in-flight motion is preserved on top + // of the correction rather than lost. + std::deque replay; + for (const auto &e : _log) + if (e.tick > server_tick) + replay.push_back(e); + + _sim.set_state(authoritative); + _log.clear(); + for (logged &e : replay) { + _sim.step(e.in, dt); + e.predicted = _sim.state(); + _log.push_back(e); + } + return true; +} + +cvc::physics::pose interpolate_peer(const cvc::physics::body_state &prev, + const cvc::physics::body_state &newest, double alpha) { + if (!(alpha >= 0.0)) // also catches NaN + alpha = 0.0; + if (alpha >= 1.0) + alpha = std::nextafter(1.0, 0.0); // never reach or pass the newest sample + const cvc::physics::pose a{prev.position, prev.rotation}; + const cvc::physics::pose b{newest.position, newest.rotation}; + return cvc::physics::interpolate(a, b, alpha); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 3d563ae9a..efb4348f7 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -107,6 +107,7 @@ add_executable(world_clock_test world_clock_test.cpp) add_executable(clock_driver_test clock_driver_test.cpp) add_executable(world_units_test world_units_test.cpp) add_executable(physics_world_test physics_world_test.cpp) +add_executable(avatar_sim_test avatar_sim_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) add_executable(physics_energy_test physics_energy_test.cpp) @@ -303,6 +304,7 @@ set(TEST_TARGETS clock_driver_test world_units_test physics_world_test + avatar_sim_test physics_field_test physics_vehicle_test physics_energy_test @@ -1640,6 +1642,13 @@ target_link_libraries(physics_world_test GTest::gtest_main ) +target_link_libraries(avatar_sim_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2038,6 +2047,7 @@ target_compile_features(world_clock_test PRIVATE cxx_std_17) target_compile_features(clock_driver_test PRIVATE cxx_std_17) target_compile_features(world_units_test PRIVATE cxx_std_17) target_compile_features(physics_world_test PRIVATE cxx_std_17) +target_compile_features(avatar_sim_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) target_compile_features(physics_energy_test PRIVATE cxx_std_17) @@ -2493,6 +2503,7 @@ cvc_discover_tests(world_clock_test) cvc_discover_tests(clock_driver_test) cvc_discover_tests(world_units_test) cvc_discover_tests(physics_world_test) +cvc_discover_tests(avatar_sim_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp new file mode 100644 index 000000000..626bd7adf --- /dev/null +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -0,0 +1,179 @@ +/* + 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. +*/ + +// Tests for cvc::ariadne avatar prediction / reconciliation (H1 slice 2). The +// properties worth pinning are the ones a real netcode loop lives or dies on: +// deterministic integration (so replay reproduces prediction), a perfect +// prediction that reconciles with NO correction, a server override that snaps the +// client and REPLAYS its in-flight inputs rather than dropping them, and a peer +// interpolation that never extrapolates past the newest sample. +// +// Every comparison here is lib-step vs lib-step (both the server reference and the +// client go through cvc::ariadne::avatar_sim::step), so bit-equality holds without +// -ffp-contract=off: there is no test-side recomputation of the lib's inline math. + +#include +#include +#include +#include +#include +#include +#include +#include + +using cvc::ariadne::avatar_intent; +using cvc::ariadne::avatar_sim; +using cvc::ariadne::interpolate_peer; +using cvc::ariadne::predicted_avatar; +using cvc::physics::body_state; +using cvc::physics::vec3; + +namespace { +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +// Bit-for-bit equality of two positions (exact; catches a 1-ulp replay drift). +void expect_pos_bit_eq(const vec3 &a, const vec3 &b) { + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} +constexpr double k120 = 1.0 / 120.0; + +// A varied, non-trivial intent for tick t (distinct components, not all equal). +avatar_intent intent_at(std::uint64_t t) { + const double f = static_cast(t); + return avatar_intent{vec3{0.1 * f, -0.05 * f, 0.2}}; +} +} // namespace + +// ── determinism ───────────────────────────────────────────────────────────── + +TEST(AvatarSimTest, StepIsBitReproducible) { + avatar_sim a, b; + for (std::uint64_t t = 1; t <= 200; ++t) { + const avatar_intent in = intent_at(t); + a.step(in, k120); + b.step(in, k120); + } + expect_pos_bit_eq(a.state().position, b.state().position); + expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); +} + +// ── perfect prediction costs nothing ──────────────────────────────────────── + +TEST(AvatarSimTest, PerfectPredictionReconcilesWithNoCorrection) { + avatar_sim server; // authoritative + predicted_avatar client; // predicts the same inputs + for (std::uint64_t t = 1; t <= 50; ++t) { + const avatar_intent in = intent_at(t); + client.predict(t, in, k120); + server.step(in, k120); + const bool corrected = client.reconcile(server.state(), t, k120, 1e-9); + EXPECT_FALSE(corrected) << "a matching prediction must not snap (t=" << t << ")"; + expect_pos_bit_eq(client.state().position, server.state().position); + } + EXPECT_EQ(client.pending(), 0u) << "all inputs acknowledged"; +} + +// Client runs AHEAD of the server (prediction under latency): it predicts several +// ticks, the server acknowledges older ticks with matching inputs. No correction, +// and the unacknowledged inputs stay pending. +TEST(AvatarSimTest, PredictionAheadOfServerKeepsPendingInputs) { + avatar_sim server; + predicted_avatar client; + std::vector issued; + for (std::uint64_t t = 1; t <= 5; ++t) { + const avatar_intent in = intent_at(t); + issued.push_back(in); + client.predict(t, in, k120); // client is now 5 ticks ahead + } + // Server has only integrated up to tick 2 so far. + for (std::uint64_t t = 1; t <= 2; ++t) + server.step(issued[t - 1], k120); + const bool corrected = client.reconcile(server.state(), 2, k120, 1e-9); + EXPECT_FALSE(corrected); + EXPECT_EQ(client.pending(), 3u) << "inputs for ticks 3,4,5 remain unacknowledged"; +} + +// ── divergence snaps + replays, preserving in-flight input ────────────────── + +TEST(AvatarSimTest, ServerOverrideSnapsAndReplaysInFlightInputs) { + body_state start; // origin + predicted_avatar client(start); + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; // moving +x at 1 m/s + for (std::uint64_t t = 1; t <= 5; ++t) + client.predict(t, mv, k120); // client predicts x = 5*k120 + + // The server overrides the avatar at tick 3 (a teleport the client could not + // predict), with the same ongoing velocity. + body_state auth; + auth.position = vec3{100.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + const bool corrected = client.reconcile(auth, /*server_tick=*/3, k120, 0.01); + EXPECT_TRUE(corrected) << "a divergent authoritative state must snap"; + + // Expected: adopt auth@3, then REPLAY the in-flight inputs for ticks 4 and 5. + avatar_sim ref(auth); + ref.step(mv, k120); + ref.step(mv, k120); + expect_pos_bit_eq(client.state().position, ref.state().position); + EXPECT_EQ(client.pending(), 2u) << "ticks 4,5 are still in flight (not yet acked)"; + // The correction is real: the client moved from ~0.04 m to ~100 m. + EXPECT_GT(client.state().position.x, 99.0); +} + +// A later matching ack after a correction settles cleanly (no second snap). +TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { + predicted_avatar client; + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 4; ++t) + client.predict(t, mv, k120); + body_state auth; + auth.position = vec3{50.0, 0.0, 0.0}; + auth.linear_velocity = mv.move; + ASSERT_TRUE(client.reconcile(auth, 2, k120, 0.01)); // snap at 2, replay 3,4 + const body_state after_snap = client.state(); + + // Now the server, having adopted the same post-snap trajectory, acks tick 3 + // with the state the client now holds for tick 3 (auth@2 + one replay step). + avatar_sim server(auth); + server.step(mv, k120); // server's tick-3 state + const bool corrected = client.reconcile(server.state(), 3, k120, 0.01); + EXPECT_FALSE(corrected) << "a matching ack after a correction must not re-snap"; + EXPECT_EQ(client.pending(), 1u) << "only tick 4 remains"; + // State advanced forward, never jumped backward. + EXPECT_GE(client.state().position.x, after_snap.position.x); +} + +// ── peer interpolation never extrapolates ─────────────────────────────────── + +TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) { + body_state prev, newest; + prev.position = vec3{0.0, 0.0, 0.0}; + prev.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + newest.position = vec3{10.0, 0.0, 0.0}; + newest.rotation = cvc::physics::quat{1.0, 0.0, 0.0, 0.0}; + + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, 0.0).position.x, 0.0); // alpha 0 = prev exactly + EXPECT_NEAR(interpolate_peer(prev, newest, 0.5).position.x, 5.0, 1e-12); + + // alpha >= 1 is clamped below 1: the result approaches but never reaches or + // passes the newest sample (no extrapolation / overshoot). + EXPECT_LT(interpolate_peer(prev, newest, 1.0).position.x, 10.0); + EXPECT_LT(interpolate_peer(prev, newest, 99.0).position.x, 10.0); + EXPECT_GT(interpolate_peer(prev, newest, 99.0).position.x, 9.0); + + // Negative / NaN alpha clamps to prev (no behind-the-start extrapolation). + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, -5.0).position.x, 0.0); + EXPECT_DOUBLE_EQ(interpolate_peer(prev, newest, std::nan("")).position.x, 0.0); +} From 2de2bb2e7c991a4fa7f8dda2fe743319dec5d1f4 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 12:56:19 -0500 Subject: [PATCH 02/33] fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the predict/reconcile mechanism surfaced three items, all addressed here: - [med] predicted_avatar::reconcile had no guard against a non-monotonic server_tick. A reordered or duplicate ack (tick <= one already reconciled) failed the lookup, fell into the diverged branch, and snapped the avatar back to a stale authoritative state while dropping the inputs between the two acks (a visible backward rubber-band). Real datagram transports reorder/duplicate, so this was a live latent hazard for S3's caller. Fix: track the highest reconciled server_tick and no-op (return false, touch nothing) on any ack at or below it. Test: StaleOrDuplicateAckIsIgnored. - [med] the held-vs-snap tolerance was never exercised at a nonzero distance — every "no correction" test had a bit-identical prediction (d==0), so a tol/tol2 or threshold-direction regression would ship green. Test: ReconcileHoldsWithinToleranceAndSnapsBeyond (0.5*tol held, 2*tol snaps). - [low] the determinism test was tautological (two in-process runs of a pure function). Replaced with IntegratesConstantVelocityToClosedForm: a closed-form n*v*dt check (EXPECT_NEAR, not a bit-exact golden, so FMA contraction across toolchains can't flip it) that catches a formula regression, plus a retained purity assertion. Noted for S3 (dismissed as out-of-scope for the headless stub): the tolerance is position-only (fine while the stub integrates no rotation; revisit when physics::world adds orientation), and _log is unbounded if a caller never acks (a real server acks every tick). avatar_sim_test now 8 cases, all green. --- inc/cvc/ariadne/avatar_sim.h | 12 +++- src/cvc/ariadne/avatar_sim.cpp | 10 ++++ src/cvc/tests/avatar_sim_test.cpp | 92 ++++++++++++++++++++++++++++--- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/inc/cvc/ariadne/avatar_sim.h b/inc/cvc/ariadne/avatar_sim.h index db66e335e..a35947323 100644 --- a/inc/cvc/ariadne/avatar_sim.h +++ b/inc/cvc/ariadne/avatar_sim.h @@ -90,9 +90,13 @@ class predicted_avatar { // are expected monotonically increasing (the clock's quanta). void predict(std::uint64_t tick, const avatar_intent &in, double dt); - // Reconcile against an authoritative state valid as of `server_tick` - // (expected monotonically non-decreasing across calls). Returns true if a hard - // correction (snap + replay) was applied, false if the prediction already held. + // Reconcile against an authoritative state valid as of `server_tick`. Returns + // true if a hard correction (snap + replay) was applied, false if the prediction + // already held OR the ack was ignored. A `server_tick` at or below the highest + // one already reconciled is a no-op (returns false, touches nothing): a + // duplicate or reordered/stale ack -- which real datagram transports produce -- + // must not snap the avatar back to an old authoritative state and discard + // progress, so only a strictly newer ack is acted on. bool reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol); @@ -108,6 +112,8 @@ class predicted_avatar { }; avatar_sim _sim; std::deque _log; + std::uint64_t _last_acked = 0; // highest server_tick reconciled; guards non-monotonic acks + bool _acked = false; // whether _last_acked is meaningful yet }; // Interpolate a NON-local peer avatar between its last two authoritative samples, diff --git a/src/cvc/ariadne/avatar_sim.cpp b/src/cvc/ariadne/avatar_sim.cpp index 938106d7e..0478d6f4a 100644 --- a/src/cvc/ariadne/avatar_sim.cpp +++ b/src/cvc/ariadne/avatar_sim.cpp @@ -38,6 +38,16 @@ void predicted_avatar::predict(std::uint64_t tick, const avatar_intent &in, doub bool predicted_avatar::reconcile(const cvc::physics::body_state &authoritative, std::uint64_t server_tick, double dt, double tol) { + // A stale, duplicate or reordered ack (<= the highest tick we have already + // reconciled) is a no-op: acting on it would fail the lookup below (that tick + // was pruned), fall into the diverged branch, and snap the avatar back to an old + // authoritative state while discarding the inputs between the two acks. Only a + // strictly newer ack advances the reconciliation. + if (_acked && server_tick <= _last_acked) + return false; + _last_acked = server_tick; + _acked = true; + // What did we predict AT server_tick? (null if we never predicted that tick -- // e.g. the server is ahead of us, or that input was already acknowledged.) const logged *at = nullptr; diff --git a/src/cvc/tests/avatar_sim_test.cpp b/src/cvc/tests/avatar_sim_test.cpp index 626bd7adf..a5b1a28b1 100644 --- a/src/cvc/tests/avatar_sim_test.cpp +++ b/src/cvc/tests/avatar_sim_test.cpp @@ -56,17 +56,35 @@ avatar_intent intent_at(std::uint64_t t) { } } // namespace -// ── determinism ───────────────────────────────────────────────────────────── - -TEST(AvatarSimTest, StepIsBitReproducible) { +// ── integration correctness + determinism ────────────────────────────────── + +TEST(AvatarSimTest, IntegratesConstantVelocityToClosedForm) { + // Closed form: after n fixed steps at constant velocity v the position is n*v*dt + // (to the rounding of the iterated sum) and the velocity equals the last intent. + // This catches a formula regression (sign, scale, a missing dt) that a + // "same function twice -> same answer" comparison never could. EXPECT_NEAR, not a + // bit-exact golden, so an FMA-contraction difference across toolchains (the + // #747/#750 class) can't flip it red. + avatar_sim sim; + const avatar_intent v{vec3{3.0, -2.0, 0.5}}; + const int n = 10; + for (int i = 0; i < n; ++i) + sim.step(v, k120); + EXPECT_NEAR(sim.state().position.x, n * 3.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.y, n * -2.0 * k120, 1e-12); + EXPECT_NEAR(sim.state().position.z, n * 0.5 * k120, 1e-12); + EXPECT_DOUBLE_EQ(sim.state().linear_velocity.x, 3.0); + EXPECT_TRUE(sim.state().active); + + // And it is a pure function of its inputs (no hidden/global state): a second + // instance fed the same varied intents reaches a bit-identical state -- the + // purity replay depends on. avatar_sim a, b; - for (std::uint64_t t = 1; t <= 200; ++t) { - const avatar_intent in = intent_at(t); - a.step(in, k120); - b.step(in, k120); + for (std::uint64_t t = 1; t <= 64; ++t) { + a.step(intent_at(t), k120); + b.step(intent_at(t), k120); } expect_pos_bit_eq(a.state().position, b.state().position); - expect_pos_bit_eq(a.state().linear_velocity, b.state().linear_velocity); } // ── perfect prediction costs nothing ──────────────────────────────────────── @@ -155,6 +173,64 @@ TEST(AvatarSimTest, AckAfterCorrectionDoesNotReSnap) { EXPECT_GE(client.state().position.x, after_snap.position.x); } +// The tolerance decides held-vs-snap: a sub-tolerance divergence is held (keep the +// prediction), a super-tolerance one snaps. Pins the squared-distance comparison +// (and its direction) at the boundary -- the d==0 "no correction" tests above +// exercise it only at zero distance. +TEST(AvatarSimTest, ReconcileHoldsWithinToleranceAndSnapsBeyond) { + const double tol = 0.1; + const avatar_intent still{vec3{0.0, 0.0, 0.0}}; // client predicts staying at origin + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{0.5 * tol, 0.0, 0.0}; // within tolerance + EXPECT_FALSE(c.reconcile(auth, 1, k120, tol)) << "sub-tolerance divergence must be held"; + EXPECT_NEAR(c.state().position.x, 0.0, 1e-12) << "held keeps the prediction, not the nudge"; + } + { + predicted_avatar c; + c.predict(1, still, k120); + body_state auth; + auth.position = vec3{2.0 * tol, 0.0, 0.0}; // beyond tolerance + EXPECT_TRUE(c.reconcile(auth, 1, k120, tol)) << "super-tolerance divergence must snap"; + EXPECT_NEAR(c.state().position.x, 2.0 * tol, 1e-12) << "snapped to authoritative"; + } +} + +// A stale / duplicate / reordered ack (server_tick at or below one already +// reconciled) is a no-op: it must not snap the avatar back to an old authoritative +// state nor drop progress. Guards the non-monotonic-ack hazard. +TEST(AvatarSimTest, StaleOrDuplicateAckIsIgnored) { + predicted_avatar client; + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; + for (std::uint64_t t = 1; t <= 10; ++t) + client.predict(t, mv, k120); + + // An accepted, matching ack at tick 7 (built by the identical step path, so it is + // held, not snapped): prunes ticks 1..7, leaving 8,9,10 pending. + avatar_sim ref; + for (int i = 0; i < 7; ++i) + ref.step(mv, k120); + ASSERT_FALSE(client.reconcile(ref.state(), 7, k120, 1e-9)); + const body_state after7 = client.state(); + ASSERT_EQ(client.pending(), 3u); + + // A reordered ack for an older tick 5 (far from the current state) must be + // ignored -- no snap backward, no loss. + body_state stale; + stale.position = vec3{5.0 * k120, 0.0, 0.0}; + stale.linear_velocity = mv.move; + EXPECT_FALSE(client.reconcile(stale, 5, k120, 1e-9)) << "stale/reordered ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); + + // A duplicate of the most recent ack (tick 7) is likewise ignored. + EXPECT_FALSE(client.reconcile(stale, 7, k120, 1e-9)) << "duplicate ack must be a no-op"; + expect_pos_bit_eq(client.state().position, after7.position); + EXPECT_EQ(client.pending(), 3u); +} + // ── peer interpolation never extrapolates ─────────────────────────────────── TEST(AvatarSimTest, PeerInterpolationNeverOvershoots) { From 6cbde4a93edd770d53943b749bfd31101331e28c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:14:03 -0500 Subject: [PATCH 03/33] =?UTF-8?q?feat(ariadne):=20cvc::ariadne::holon=20?= =?UTF-8?q?=E2=80=94=20clock=20+=20avatar=20bundle=20on=20its=20own=20cloc?= =?UTF-8?q?k=5Fdriver=20(H1=20slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The holon object the roadmap §5h names: a server-authoritative, client-replicable bundle of ONE world_clock and the participant avatars on it. The server drives the clock and owns every avatar's authoritative pose; a client runs a replica that predicts its own avatar (slice 2) and reconciles to the authoritative state, and interpolates peers. cvc::ariadne::holon (inc/cvc/ariadne/holon.h, src/cvc/ariadne/holon.cpp): - Owns its world_clock AND cvc::clock_driver BY VALUE (not the app-global clock), so two holons in one process never fight over time. - Drives avatar integration as a clock_driver STEP observer and records the presentation alpha as a FRAME observer — the same step-then-present order tick_scene uses, so a frame observer never reads a pose the step has not yet produced. - Per participant: server integrates the latest intent into the authoritative pose over the quanta that fell due; a client-local avatar predicts + reconciles (slice 2's predicted_avatar); a peer is driven by authoritative samples and interpolated at the frame alpha. A client may set an intent only on its OWN avatar, never a peer — the proposed/ authoritative split (the verb path that enforces it server-side is slice 4). Deliberately the headless clock + avatar-pose core: the Jolt cvc::physics::world (replacing the linear avatar_sim behind the same step shape) and the cvcGL scene_realize / physics_scene_binding rendering are later slices, so this stays deterministically testable with no GL / state tree / transport. holon_test (5 cases, all green): each holon owns a distinct clock (advancing one never ticks another); a server + client holon reproduce slice 2's convergence through the holon API (perfect prediction holds bit-for-bit; a server +y override snaps the client off its wrong +x prediction); a client cannot drive a peer; a peer interpolates through the frame alpha and never overshoots. Stacks on #770 (slice 2). The scalar-key state wiring that connects this to replication lands with slice 4 (the intent/pose verb path). --- inc/cvc/ariadne/holon.h | 134 +++++++++++++++++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/holon.cpp | 141 +++++++++++++++++++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 +++ src/cvc/tests/holon_test.cpp | 148 +++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/holon.h create mode 100644 src/cvc/ariadne/holon.cpp create mode 100644 src/cvc/tests/holon_test.cpp diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h new file mode 100644 index 000000000..bcc76f3c6 --- /dev/null +++ b/inc/cvc/ariadne/holon.h @@ -0,0 +1,134 @@ +/* + 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_HOLON_H__ +#define __CVC_ARIADNE_HOLON_H__ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { + +// ---------------- +// cvc::ariadne::holon (H1 slice 3) +// ---------------- +// A holon is a server-authoritative, client-replicable bundle of ONE world_clock +// and the participant avatars that live on it. It is the object the roadmap's §5h +// names: the server drives the clock and owns every avatar's authoritative pose; a +// client runs a replica -- it PREDICTS its own avatar locally (slice 2) and +// RECONCILES to the authoritative state when it arrives, and INTERPOLATES peers. +// +// This slice is the headless clock + avatar-pose core. The holon owns its own +// world_clock and cvc::clock_driver BY VALUE (never the app-global clock, so two +// holons in one process never fight over time), and it drives avatar integration +// as a clock_driver STEP observer and presentation as a FRAME observer -- the same +// step-then-present shape tick_scene uses. The shared SCENE geometry, the Jolt +// cvc::physics::world (replacing the linear avatar_sim behind the same shape), and +// the cvcGL rendering of all this layer on top in later slices; none of that is +// here, so the model stays headless and deterministically testable. +// +// Authority (slice 4 adds the verb path): a client NEVER writes another avatar's +// pose; it writes only its own intent, the server validates/vetoes/overrides and +// is the sole writer of the authoritative pose, and the client respects that on +// reconcile. This class embodies that split -- set_intent on a client only drives +// its own (local) avatar's prediction; apply_authoritative is how the server's +// word reaches the client. + +enum class holon_role { server, client }; + +struct holon_config { + cvc::world_clock::config clock; // the holon's own quantum / scale / mode + double reconcile_tol = 0.05; // metres; the client's snap threshold (slice 2) +}; + +class holon { +public: + explicit holon(holon_role role, holon_config cfg = holon_config{}); + + holon_role role() const noexcept { return _role; } + cvc::world_clock &clock() noexcept { return _clock; } + const cvc::world_clock &clock() const noexcept { return _clock; } + cvc::clock_driver &driver() noexcept { return _driver; } + std::uint64_t tick() const { return _clock.tick(); } + + // 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. + void add_participant(const std::string &pid, bool local = false, + const cvc::physics::body_state &start = cvc::physics::body_state{}); + void remove_participant(const std::string &pid); + bool has_participant(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). + void set_intent(const std::string &pid, const avatar_intent &in); + + // 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. + void apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, + std::uint64_t server_tick); + + // 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 + // quanta; peers are sample-driven (apply_authoritative) and only re-interpolated. + void advance(double wall_dt = -1.0); + + // A participant's authoritative body_state: the server's integrated state, the + // client-local avatar's current prediction, or a peer's newest sample. Throws + // std::out_of_range for an unknown pid. + cvc::physics::body_state state(const std::string &pid) const; + + // A participant's DISPLAY pose at the current frame's interpolation alpha: + // predicted for the local avatar, interpolated between the last two samples for a + // peer (never extrapolated), the integrated pose on a server. + cvc::physics::pose display_pose(const std::string &pid) const; + +private: + struct participant { + bool local = false; + avatar_sim sim; // server authoritative integrator + avatar_intent intent; // latest intent (server input / client-local input) + predicted_avatar predicted; // client-local prediction + reconcile + bool have_newest = false, have_prev = false; + cvc::physics::body_state prev, newest; // peer interpolation samples + }; + + void on_step(const cvc::clock_driver::clock_frame &f); + void on_present(const cvc::clock_driver::clock_frame &f); + + holon_role _role; + holon_config _cfg; + 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 +}; + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_HOLON_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index b295b3466..571ad8cb6 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,12 +1306,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/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) +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/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) 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/holon.h ../../inc/cvc/ariadne/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp new file mode 100644 index 000000000..d9e45c966 --- /dev/null +++ b/src/cvc/ariadne/holon.cpp @@ -0,0 +1,141 @@ +/* + 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 + +namespace cvc { +namespace ariadne { + +namespace { +// Observer order within a frame: integrate the sim (step phase), THEN record the +// presentation alpha (frame phase) -- matching tick_scene's advance -> follow -> +// present, so a frame observer never reads a pose the step observer has not yet +// produced this frame. +constexpr int kOrderIntegrate = 10; +constexpr int kOrderPresent = 10; +} // namespace + +holon::holon(holon_role role, holon_config cfg) + : _role(role), _cfg(cfg), _clock(cfg.clock), _driver(_clock) { + _driver.add_step_observer(kOrderIntegrate, + [this](const cvc::clock_driver::clock_frame &f) { on_step(f); }); + _driver.add_frame_observer(kOrderPresent, + [this](const cvc::clock_driver::clock_frame &f) { on_present(f); }); +} + +void holon::add_participant(const std::string &pid, bool local, + const cvc::physics::body_state &start) { + participant p; + p.local = local; + p.sim.set_state(start); + p.predicted = predicted_avatar(start); + p.newest = start; + p.prev = start; + _participants[pid] = p; +} + +void holon::remove_participant(const std::string &pid) { _participants.erase(pid); } + +bool holon::has_participant(const std::string &pid) const { + return _participants.find(pid) != _participants.end(); +} + +void holon::set_intent(const std::string &pid, const avatar_intent &in) { + auto it = _participants.find(pid); + if (it == _participants.end()) + return; + // 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; +} + +void holon::apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, + std::uint64_t server_tick) { + if (_role != holon_role::client) + return; // the server IS the authority; nothing to apply + auto it = _participants.find(pid); + if (it == _participants.end()) + return; + participant &p = it->second; + if (p.local) { + p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol); + } else { + // A peer: shift the interpolation window (prev <- old newest, newest <- auth). + if (!p.have_newest) { + p.newest = auth; + p.prev = auth; + p.have_newest = true; + } else { + p.prev = p.newest; + p.newest = auth; + p.have_prev = true; + } + } +} + +void holon::on_step(const cvc::clock_driver::clock_frame &f) { + const double dt = f.fixed_dt; + const int steps = f.step.steps; + 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) + 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. + for (int i = 0; i < steps; ++i) { + const std::uint64_t tick = f.step.first_tick + 1 + static_cast(i); + p.predicted.predict(tick, p.intent, dt); + } + } + // Peers integrate nothing: they are driven by apply_authoritative samples. + } +} + +void holon::on_present(const cvc::clock_driver::clock_frame &f) { _alpha = f.alpha; } + +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 (_role == holon_role::server) + return p.sim.state(); + if (p.local) + return p.predicted.state(); + return p.newest; +} + +cvc::physics::pose holon::display_pose(const std::string &pid) const { + const participant &p = _participants.at(pid); + if (_role == holon_role::server) { + const cvc::physics::body_state &s = p.sim.state(); + return cvc::physics::pose{s.position, s.rotation}; + } + if (p.local) { + const cvc::physics::body_state &s = p.predicted.state(); + return cvc::physics::pose{s.position, s.rotation}; + } + // Peer: interpolate between the last two authoritative samples at this frame's + // alpha (interpolate_peer clamps alpha to [0,1), so it never overshoots). + return interpolate_peer(p.prev, p.newest, _alpha); +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index efb4348f7..74fd2cafb 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -108,6 +108,7 @@ add_executable(clock_driver_test clock_driver_test.cpp) add_executable(world_units_test world_units_test.cpp) add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) +add_executable(holon_test holon_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) add_executable(physics_energy_test physics_energy_test.cpp) @@ -305,6 +306,7 @@ set(TEST_TARGETS world_units_test physics_world_test avatar_sim_test + holon_test physics_field_test physics_vehicle_test physics_energy_test @@ -1649,6 +1651,13 @@ target_link_libraries(avatar_sim_test GTest::gtest_main ) +target_link_libraries(holon_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2048,6 +2057,7 @@ target_compile_features(clock_driver_test PRIVATE cxx_std_17) target_compile_features(world_units_test PRIVATE cxx_std_17) target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) +target_compile_features(holon_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) target_compile_features(physics_energy_test PRIVATE cxx_std_17) @@ -2504,6 +2514,7 @@ cvc_discover_tests(clock_driver_test) cvc_discover_tests(world_units_test) cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) +cvc_discover_tests(holon_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp new file mode 100644 index 000000000..a6aed8875 --- /dev/null +++ b/src/cvc/tests/holon_test.cpp @@ -0,0 +1,148 @@ +/* + 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. +*/ + +// Tests for cvc::ariadne::holon (H1 slice 3): the server-authoritative, +// client-replicable clock + avatar-pose bundle. The properties worth pinning: +// each holon owns its OWN clock (two never share the app clock); a server holon +// and a client holon stepped in-process reproduce slice 2's prediction / +// reconciliation through the holon API (perfect prediction converges bit-for-bit; +// 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 +#include +#include +#include + +using cvc::ariadne::avatar_intent; +using cvc::ariadne::holon; +using cvc::ariadne::holon_role; +using cvc::physics::body_state; +using cvc::physics::vec3; + +namespace { +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +void expect_pos_bit_eq(const vec3 &a, const vec3 &b) { + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} +} // namespace + +// ── each holon owns its own clock ─────────────────────────────────────────── + +TEST(HolonTest, OwnsItsOwnClockNotTheAppClock) { + holon a(holon_role::server); + holon b(holon_role::server); + EXPECT_NE(&a.clock(), &b.clock()) << "each holon must own a distinct world_clock"; + const double dt = a.clock().fixed_dt(); + a.advance(dt); // advance a by one quantum + EXPECT_EQ(a.tick(), 1u); + EXPECT_EQ(b.tick(), 0u) << "advancing one holon must not tick another"; +} + +// ── server + client: perfect prediction converges bit-for-bit ─────────────── + +TEST(HolonTest, ServerClientPerfectPredictionConverges) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); // authoritative on the server + client.add_participant("A", /*local=*/true); // the client controls A, predicted + const double dt = server.clock().fixed_dt(); + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + + for (int frame = 1; frame <= 10; ++frame) { + server.set_intent("A", mv); + client.set_intent("A", mv); // the client predicts the same input it sends + server.advance(dt); + client.advance(dt); + // Replicate the authoritative state to the client and reconcile. + client.apply_authoritative("A", server.state("A"), server.tick()); + // A matching prediction holds (no snap) and is bit-identical to authoritative. + expect_pos_bit_eq(client.state("A").position, server.state("A").position); + } + EXPECT_GT(server.state("A").position.x, 0.0); // actually moved +} + +// ── server override snaps the client onto the authoritative trajectory ────── + +TEST(HolonTest, ServerOverrideReconcilesOnClient) { + holon server(holon_role::server); + holon client(holon_role::client); + server.add_participant("A"); + client.add_participant("A", /*local=*/true); + const double dt = server.clock().fixed_dt(); + const avatar_intent mv_client{vec3{2.0, 0.0, 0.0}}; // client thinks A moves +x + const avatar_intent mv_server{vec3{0.0, 2.0, 0.0}}; // server says A moves +y + + for (int frame = 1; frame <= 10; ++frame) { + client.set_intent("A", mv_client); + server.set_intent("A", mv_server); + client.advance(dt); + server.advance(dt); + } + // The client predicted +x; the authoritative state is +y. Reconcile snaps it. + client.apply_authoritative("A", server.state("A"), server.tick()); + expect_pos_bit_eq(client.state("A").position, server.state("A").position); + EXPECT_GT(client.state("A").position.y, 0.1) << "client adopted the server's +y motion"; + EXPECT_NEAR(client.state("A").position.x, 0.0, 1e-12) + << "the client's wrong +x was corrected away"; +} + +// ── a client cannot drive a peer ──────────────────────────────────────────── + +TEST(HolonTest, ClientCannotDriveAPeer) { + holon client(holon_role::client); + client.add_participant("peer", /*local=*/false); + client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); // should be ignored + const double dt = client.clock().fixed_dt(); + for (int i = 0; i < 5; ++i) + client.advance(dt); + // No authoritative samples arrived, so the peer stays at its start (origin); the + // ignored intent did not move it. + EXPECT_DOUBLE_EQ(client.state("peer").position.x, 0.0); +} + +// ── a peer is interpolated through the holon's frame alpha, no overshoot ──── + +TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { + holon client(holon_role::client); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + + body_state s1; + s1.position = vec3{0.0, 0.0, 0.0}; + body_state s2; + s2.position = vec3{10.0, 0.0, 0.0}; + client.apply_authoritative("B", s1, 1); + client.apply_authoritative("B", s2, 2); // window is now [s1, s2] + + // Advance half a quantum: no step falls due, but the frame alpha becomes ~0.5. + client.advance(0.5 * dt); + const double x_half = client.display_pose("B").position.x; + EXPECT_GT(x_half, 0.0); + EXPECT_LT(x_half, 10.0) << "interpolated strictly between the two samples"; + EXPECT_NEAR(x_half, 5.0, 1e-9); + + // A near-full-quantum frame approaches but never reaches/passes the newest sample. + holon c2(holon_role::client); + c2.add_participant("B", false); + c2.apply_authoritative("B", s1, 1); + c2.apply_authoritative("B", s2, 2); + c2.advance(0.999 * dt); + EXPECT_LT(c2.display_pose("B").position.x, 10.0) << "peer interpolation never overshoots newest"; +} From de5b1ac69ad21f16ccde931e9fc31def0a3d6f3c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:25:07 -0500 Subject: [PATCH 04/33] =?UTF-8?q?fix(ariadne):=20holon=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20peer=20sample=20guard,=20explicit=20non-movable,=20?= =?UTF-8?q?replay/stale=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the holon surfaced six items; addressed here: - [med] the peer interpolation path ignored server_tick while the local path guards against stale/duplicate/reordered acks. A reordered peer sample would rewind the interpolation window (prev <- newer, newest <- older) and jerk the peer backward; a duplicate would collapse it. apply_authoritative's peer branch now applies the same monotonic guard (drop a sample whose server_tick <= the newest applied). Test: PeerIgnoresStaleOrDuplicateSample. - [med] the convergence tests always acked the client's latest tick, so reconcile drained the whole log and the replay-suffix path (and on_step's tick numbering) was never exercised — an off-by-one would pass. Added ClientReplaysInFlightInputsOnServerCorrection: a divergent authoritative state for a PAST tick snaps the client and replays its inputs after that tick; the result is checked against a reference, so a wrong tick/replay-suffix fails. - [low/med] ClientCannotDriveAPeer passed vacuously (a peer is never intent-driven). Reframed as PeerIsSampleDrivenNotIntentDriven, stating what it actually proves; the set_intent guard stays (defensive). - [low] the holon was only implicitly non-movable (via world_clock's mutex). Made it explicit (deleted copy/move) so a future movable-clock refactor can't silently re-enable a move and dangle the observers' this. - [low] dropped the dead `have_prev` field; the peer window now tracks `have_sample` + `last_sample_tick`. - [low] strengthened the peer no-overshoot assertion with a lower bound (advanced most of the way, not frozen). No memory-safety/crash bugs were found. holon_test now 7 cases, green. --- inc/cvc/ariadne/holon.h | 18 +++++++++-- src/cvc/ariadne/holon.cpp | 16 ++++++--- src/cvc/tests/holon_test.cpp | 63 ++++++++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index bcc76f3c6..b83fac1be 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -65,6 +65,16 @@ class holon { public: explicit holon(holon_role role, holon_config cfg = holon_config{}); + // Non-copyable and non-movable: the clock_driver observers capture `this` and + // _driver holds a reference to _clock, so a moved holon would dangle both. This + // is already implicit (world_clock's mutex deletes the defaults), but the + // explicit deletes pin the invariant against a future refactor that makes the + // clock movable. + holon(const holon &) = delete; + holon &operator=(const holon &) = delete; + holon(holon &&) = delete; + holon &operator=(holon &&) = delete; + holon_role role() const noexcept { return _role; } cvc::world_clock &clock() noexcept { return _clock; } const cvc::world_clock &clock() const noexcept { return _clock; } @@ -113,8 +123,12 @@ class holon { avatar_sim sim; // server authoritative integrator avatar_intent intent; // latest intent (server input / client-local input) predicted_avatar predicted; // client-local prediction + reconcile - bool have_newest = false, have_prev = false; - cvc::physics::body_state prev, newest; // peer interpolation samples + // peer interpolation: the last two authoritative samples + a monotonic guard + // (mirroring predicted_avatar's stale/duplicate-ack rejection) so a reordered + // or duplicate peer sample never rewinds or freezes the interpolation window. + bool have_sample = false; + std::uint64_t last_sample_tick = 0; + cvc::physics::body_state prev, newest; }; void on_step(const cvc::clock_driver::clock_frame &f); diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index d9e45c966..05bae69ca 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -74,16 +74,22 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body if (p.local) { p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol); } else { - // A peer: shift the interpolation window (prev <- old newest, newest <- auth). - if (!p.have_newest) { + // A peer. Reject a stale/duplicate/reordered sample (server_tick <= the newest + // one applied), exactly as predicted_avatar does for the local path: otherwise + // an out-of-order sample would rewind the interpolation window (prev <- newer, + // newest <- older) and visibly jerk the peer backward, or a duplicate would + // collapse the window (prev == newest) and freeze it. + if (p.have_sample && server_tick <= p.last_sample_tick) + return; + if (!p.have_sample) { p.newest = auth; p.prev = auth; - p.have_newest = true; + p.have_sample = true; } else { - p.prev = p.newest; + p.prev = p.newest; // shift the window: prev <- old newest, newest <- auth p.newest = auth; - p.have_prev = true; } + p.last_sample_tick = server_tick; } } diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index a6aed8875..d6c176cdc 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -25,6 +25,7 @@ #include using cvc::ariadne::avatar_intent; +using cvc::ariadne::avatar_sim; using cvc::ariadne::holon; using cvc::ariadne::holon_role; using cvc::physics::body_state; @@ -103,20 +104,68 @@ TEST(HolonTest, ServerOverrideReconcilesOnClient) { << "the client's wrong +x was corrected away"; } -// ── a client cannot drive a peer ──────────────────────────────────────────── +// A server correction for a PAST tick snaps the client and replays the inputs it +// issued after that tick (slice 2's replay, driven through the holon). This also +// validates on_step's tick numbering: an off-by-one would replay the wrong suffix +// and the result would not match the reference. +TEST(HolonTest, ClientReplaysInFlightInputsOnServerCorrection) { + holon client(holon_role::client); + client.add_participant("A", /*local=*/true); + const double dt = client.clock().fixed_dt(); + const avatar_intent mv{vec3{1.0, 0.0, 0.0}}; // client predicts A moving +x + for (int frame = 1; frame <= 5; ++frame) { // predicts ticks 1..5 + client.set_intent("A", mv); + client.advance(dt); + } + // Authoritative state for tick 3 diverges (the server teleported A to +y). The + // client snaps to it and replays its in-flight inputs for ticks 4 and 5. + body_state auth3; + auth3.position = vec3{0.0, 100.0, 0.0}; + auth3.linear_velocity = mv.move; + client.apply_authoritative("A", auth3, /*server_tick=*/3); + + avatar_sim ref(auth3); // auth@3 + replay of the two post-tick-3 inputs + ref.step(mv, dt); + ref.step(mv, dt); + expect_pos_bit_eq(client.state("A").position, ref.state().position); + EXPECT_GT(client.state("A").position.y, 99.0) << "adopted the server's +y teleport"; + EXPECT_GT(client.state("A").position.x, 0.0) << "replayed the in-flight +x inputs on top"; +} -TEST(HolonTest, ClientCannotDriveAPeer) { +// ── a peer is sample-driven, not intent-driven ────────────────────────────── + +TEST(HolonTest, PeerIsSampleDrivenNotIntentDriven) { holon client(holon_role::client); client.add_participant("peer", /*local=*/false); - client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); // should be ignored + // set_intent on a non-local avatar is ignored (a client cannot drive a peer); + // a peer only moves via authoritative samples, so with none it stays at origin. + client.set_intent("peer", avatar_intent{vec3{9.0, 9.0, 9.0}}); const double dt = client.clock().fixed_dt(); for (int i = 0; i < 5; ++i) client.advance(dt); - // No authoritative samples arrived, so the peer stays at its start (origin); the - // ignored intent did not move it. EXPECT_DOUBLE_EQ(client.state("peer").position.x, 0.0); } +// A stale / duplicate / reordered peer sample (server_tick at or below the newest +// applied) is ignored -- the interpolation window must not rewind or collapse. +TEST(HolonTest, PeerIgnoresStaleOrDuplicateSample) { + holon client(holon_role::client); + client.add_participant("B", /*local=*/false); + body_state s4, s5; + s4.position = vec3{4.0, 0.0, 0.0}; + s5.position = vec3{5.0, 0.0, 0.0}; + client.apply_authoritative("B", s4, 4); + client.apply_authoritative("B", s5, 5); // window is [s4 -> s5] + // A reordered older sample (tick 4 again, different value) must be dropped. + body_state stale; + stale.position = vec3{-100.0, 0.0, 0.0}; + client.apply_authoritative("B", stale, 4); + const double dt = client.clock().fixed_dt(); + client.advance(0.5 * dt); // alpha ~0.5 over the UNCHANGED window [4 -> 5] + EXPECT_NEAR(client.display_pose("B").position.x, 4.5, 1e-9) + << "stale sample must not rewind the interpolation window"; +} + // ── a peer is interpolated through the holon's frame alpha, no overshoot ──── TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { @@ -144,5 +193,7 @@ TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { c2.apply_authoritative("B", s1, 1); c2.apply_authoritative("B", s2, 2); c2.advance(0.999 * dt); - EXPECT_LT(c2.display_pose("B").position.x, 10.0) << "peer interpolation never overshoots newest"; + const double x_near = c2.display_pose("B").position.x; + EXPECT_LT(x_near, 10.0) << "peer interpolation never overshoots newest"; + EXPECT_GT(x_near, 9.0) << "and it did advance most of the way (not frozen)"; } From cb6c0a32b111fdee2d4dbd9f9e8ac3a5be1584bc Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 13:37:58 -0500 Subject: [PATCH 05/33] feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires cvc::ariadne::holon to the replicated conference room tree over plain SCALAR leaf keys, under the participant subtree the conference already uses (conf.rooms..participants., via room_participant_path): .pose.pos/orient/vel.{...} + .pose.server_tick server-written .intent.move.{x,y,z} client-written cvc::ariadne holon_sync.{h,cpp}: write_pose/read_pose and write_intent/read_intent. Scalar leaves (not a typed body_state data() object) are deliberate — they already journal + replicate through the existing "conf" interest filter with zero transport changes, and sidestep the data()-object work (§52). Each double is written round-trip-safe (%.17g, portable across toolchains, no std::to_chars FP dependency) so a reader recovers it exactly. Writes use the room tree's touch-before-value idiom so a fresh node's first write is not lost to the distributed-state attach race. Authority (the proposed/authoritative path pair): a client writes only its own .intent.*; the server reads every participant's .intent, integrates it into the holon, and is the SOLE writer of .pose + .server_tick; the client reads .pose read-only and reconciles. Writes go through a C++ host (never a DSL state-set, since ui.docs.* is excluded from replication). H1 is loopback-trusted sole-writer-by-convention; the connection<->pid admission binding is the §5e/mesh increment. holon_sync_test (4 cases, green): pose + intent scalar round-trip recover binary64 exactly; read returns false when absent; and an END-TO-END over a real two-shard state_transport_inproc — the client proposes intent, it replicates to the server, the server holon integrates it and writes the authoritative pose, that replicates back, and the client reconciles and tracks the authoritative avatar bit-for-bit across 8 frames. Stacks on #772 (slice 3). The remaining live-integration piece (a C++ room-set-intent verb + arisrv hosting the server holon + a per-room client pump) is the follow-on that the live two-client cyprty test exercises. --- inc/cvc/ariadne/holon_sync.h | 83 +++++++++++++++++ src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/holon_sync.cpp | 130 +++++++++++++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 +++ src/cvc/tests/holon_sync_test.cpp | 143 ++++++++++++++++++++++++++++++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/holon_sync.h create mode 100644 src/cvc/ariadne/holon_sync.cpp create mode 100644 src/cvc/tests/holon_sync_test.cpp diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h new file mode 100644 index 000000000..c98559ac2 --- /dev/null +++ b/inc/cvc/ariadne/holon_sync.h @@ -0,0 +1,83 @@ +/* + 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_HOLON_SYNC_H__ +#define __CVC_ARIADNE_HOLON_SYNC_H__ + +#include +#include +#include +#include +#include + +namespace cvc { + +class app; + +namespace ariadne { + +// ---------------- +// cvc::ariadne — holon <-> state-tree sync over SCALAR keys (H1 slice 4) +// ---------------- +// Connects a cvc::ariadne::holon to the replicated conference room tree over plain +// SCALAR leaf keys, under the participant subtree the conference already uses +// (conf.rooms..participants., via room_participant_path): +// +// .pose.pos.{x,y,z} authoritative, SERVER-written +// .pose.orient.{w,x,y,z} +// .pose.vel.lin.{x,y,z} +// .pose.vel.ang.{x,y,z} +// .pose.server_tick the tick the pose is valid at +// .intent.move.{x,y,z} proposed, CLIENT-written +// +// 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 +// zero transport changes, and they sidestep the data()-object replication work +// (DISTRIBUTED_STATE §52). Each double is written round-trip-safe (17 significant +// digits), so a reader recovers it to within far less than the reconcile +// tolerance. +// +// AUTHORITY (the proposed/authoritative path pair): a client writes only its own +// .intent.*; the server reads every participant's .intent, integrates it into the +// holon, and is the SOLE writer of .pose + .server_tick. A client reads .pose +// read-only and reconciles. Writes go through a C++ host (these helpers / a verb), +// never a DSL (state-set), because the document chroot ui.docs.* is excluded from +// replication -- the same reason room-join is a C++ verb. The connection<->pid +// admission binding that would make "sole writer" cryptographically enforced is +// 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). +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); + +// 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 +// filled otherwise. +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). +void write_intent(cvc::app &app, const std::string &room, const std::string &pid, + const avatar_intent &intent); + +// Read a participant's proposed intent (server side). Returns false if absent. +bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, + avatar_intent &out_intent); + +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_HOLON_SYNC_H__ diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 571ad8cb6..0d5fd25cc 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1306,13 +1306,14 @@ 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/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) +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/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 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/holon.h + ../../inc/cvc/ariadne/holon_sync.h ../../inc/cvc/ariadne/app_runtime.h ../../inc/cvc/ariadne/doc_args.h ../../inc/cvc/ariadne/host_cli.h diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp new file mode 100644 index 000000000..1c16de303 --- /dev/null +++ b/src/cvc/ariadne/holon_sync.cpp @@ -0,0 +1,130 @@ +/* + 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 // room_participant_path +#include +#include +#include // vec3, quat +#include // cvc::state, SEPARATOR, findDescendant +#include +#include + +namespace cvc { +namespace ariadne { + +namespace { + +// Round-trip-safe double text (17 significant digits exactly recovers a binary64), +// portable across every CI toolchain (no std::to_chars floating-point dependency). +std::string fmt(double d) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.17g", d); + return std::string(buf); +} + +// A leaf path ., joined with cvc::state::SEPARATOR (never an +// embedded literal separator, so this holds whatever SEPARATOR is). +std::string leaf(const std::string &room, const std::string &pid, + std::initializer_list segs) { + std::string s = conference::room_participant_path(room, pid); + for (const char *seg : segs) { + s += cvc::state::SEPARATOR; + s += seg; + } + return s; +} + +// 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). +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state &n = app.root()(path); + n.touch(); + n.value(val); +} + +double read_double(cvc::app &app, const std::string &path) { + cvc::state *n = app.root().findDescendant(path); + return n ? std::strtod(n->value().c_str(), nullptr) : 0.0; +} +bool node_present(cvc::app &app, const std::string &path) { + return app.root().findDescendant(path) != nullptr; +} + +} // 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))); +} + +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"})); + 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) { + 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)); +} + +bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, + avatar_intent &out) { + 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"}))}; + return true; +} + +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 74fd2cafb..ad67d0b32 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -109,6 +109,7 @@ add_executable(world_units_test world_units_test.cpp) add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) add_executable(holon_test holon_test.cpp) +add_executable(holon_sync_test holon_sync_test.cpp) add_executable(physics_field_test physics_field_test.cpp) add_executable(physics_vehicle_test physics_vehicle_test.cpp) add_executable(physics_energy_test physics_energy_test.cpp) @@ -307,6 +308,7 @@ set(TEST_TARGETS physics_world_test avatar_sim_test holon_test + holon_sync_test physics_field_test physics_vehicle_test physics_energy_test @@ -1658,6 +1660,13 @@ target_link_libraries(holon_test GTest::gtest_main ) +target_link_libraries(holon_sync_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_vehicle_test PRIVATE cvc @@ -2058,6 +2067,7 @@ target_compile_features(world_units_test PRIVATE cxx_std_17) target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) target_compile_features(holon_test PRIVATE cxx_std_17) +target_compile_features(holon_sync_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) target_compile_features(physics_energy_test PRIVATE cxx_std_17) @@ -2515,6 +2525,7 @@ cvc_discover_tests(world_units_test) cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) cvc_discover_tests(holon_test) +cvc_discover_tests(holon_sync_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) cvc_discover_tests(physics_energy_test) diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp new file mode 100644 index 000000000..07b25b985 --- /dev/null +++ b/src/cvc/tests/holon_sync_test.cpp @@ -0,0 +1,143 @@ +/* + 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. +*/ + +// Tests for cvc::ariadne holon <-> state-tree sync over scalar keys (H1 slice 4): +// the pose/intent scalar-leaf round-trip, and an end-to-end where a participant's +// avatar is server-authoritative over REPLICATED scalar keys -- the client +// proposes intent, the server holon integrates it and writes the authoritative +// pose, and the client reconciles to it. No typed data() object, no DSL verb (the +// C++ verb + arisrv hosting are the live-integration follow-on): just the mapping +// and that it rides the existing conf.rooms.* replication. + +#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::physics::body_state; +using cvc::physics::vec3; + +namespace { +std::uint64_t bits(double d) { + std::uint64_t u = 0; + std::memcpy(&u, &d, sizeof(u)); + return u; +} +void expect_pos_bit_eq(const vec3 &a, const vec3 &b) { + EXPECT_EQ(bits(a.x), bits(b.x)); + EXPECT_EQ(bits(a.y), bits(b.y)); + EXPECT_EQ(bits(a.z), bits(b.z)); +} +} // namespace + +// ── scalar-leaf round-trip (one app) ──────────────────────────────────────── + +TEST(HolonSyncTest, PoseRoundTripsThroughScalarKeys) { + cvc::app app; + body_state s; + s.position = vec3{1.5, -2.25, 3.125}; + s.rotation = cvc::physics::quat{0.5, -0.5, 0.5, -0.5}; + s.linear_velocity = vec3{0.1, 0.2, 0.3}; + s.angular_velocity = vec3{-0.4, 0.5, -0.6}; + cvc::ariadne::write_pose(app, "r", "A", s, /*server_tick=*/42); + + body_state out; + std::uint64_t tick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(app, "r", "A", out, tick)); + EXPECT_EQ(tick, 42u); + // 17-sig-digit text recovers binary64 exactly. + expect_pos_bit_eq(out.position, s.position); + EXPECT_EQ(bits(out.rotation.w), bits(s.rotation.w)); + EXPECT_EQ(bits(out.rotation.z), bits(s.rotation.z)); + expect_pos_bit_eq(out.linear_velocity, s.linear_velocity); + expect_pos_bit_eq(out.angular_velocity, s.angular_velocity); +} + +TEST(HolonSyncTest, IntentRoundTripsThroughScalarKeys) { + cvc::app app; + const avatar_intent in{vec3{7.0, -8.0, 9.5}}; + cvc::ariadne::write_intent(app, "r", "A", in); + avatar_intent out; + ASSERT_TRUE(cvc::ariadne::read_intent(app, "r", "A", out)); + expect_pos_bit_eq(out.move, in.move); +} + +TEST(HolonSyncTest, ReadReturnsFalseWhenAbsent) { + cvc::app app; + body_state s; + std::uint64_t tick = 0; + avatar_intent in; + EXPECT_FALSE(cvc::ariadne::read_pose(app, "r", "ghost", s, tick)); + EXPECT_FALSE(cvc::ariadne::read_intent(app, "r", "ghost", in)); +} + +// ── end-to-end: server-authoritative pose over replicated scalar keys ─────── + +TEST(HolonSyncTest, ServerAuthoritativePoseReplicatesAndClientReconciles) { + 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); + + holon server(holon_role::server); + holon client(holon_role::client); + const std::string room = "r", pid = "A"; + server.add_participant(pid); // authoritative on the server + client.add_participant(pid, /*local=*/true); // the client predicts its own avatar + const double dt = server.clock().fixed_dt(); + const avatar_intent mv{vec3{2.0, 0.0, 0.0}}; + + for (int frame = 1; frame <= 8; ++frame) { + // Client proposes its intent; it replicates to the server, which integrates it. + client.set_intent(pid, mv); + cvc::ariadne::write_intent(client_app, room, pid, mv); + tp.pump_all(); + avatar_intent got; + ASSERT_TRUE(cvc::ariadne::read_intent(server_app, room, pid, got)) << "frame " << frame; + server.set_intent(pid, got); + + // Both advance one quantum. + server.advance(dt); + client.advance(dt); + + // The server writes the authoritative pose; it replicates; the client reconciles. + cvc::ariadne::write_pose(server_app, room, pid, server.state(pid), server.tick()); + tp.pump_all(); + body_state auth; + std::uint64_t stick = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, pid, auth, stick)) << "frame " << frame; + EXPECT_EQ(stick, server.tick()); + client.apply_authoritative(pid, auth, stick); + + // A perfect prediction holds; the client's avatar tracks the authoritative one. + expect_pos_bit_eq(client.state(pid).position, server.state(pid).position); + } + EXPECT_GT(server.state(pid).position.x, 0.0); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} From 8ee30c6bcb2c4de99cb0ff6d565851e8975eff1f Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Thu, 22 Oct 2026 14:08:12 -0500 Subject: [PATCH 06/33] feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the holon into the live relay so a participant's avatar becomes server-authoritative across the wire. - holon_sync: server_pump(holon, app, room, wall_dt=-1) — one authoritative frame against the replicated room tree: for every present participant (room_roster) add it to the holon if new, read its proposed .intent, advance the holon, and write each authoritative .pose + .server_tick. wall_dt defaults to measured real time (the relay); a test injects a fixed dt for a deterministic step count. - arisrv: construct one server holon for the room and call server_pump each relay tick, right after room_relay_tick, so the authoritative pose writes are local shard mutations the existing drain_local block fans to every client on both transports. (~5 Hz relay cadence -> the holon catches up several quanta per tick and clients interpolate; a smoother authoritative rate is a later tuning.) - room_intrinsics: a (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z) C++ verb so a client proposes only its OWN avatar's intent (pid from the room-join handle, so it cannot propose for a peer). Writes via the holon_sync intent keys; a C++ verb because the DSL chroot ui.docs.* is excluded from replication (same reason room-join is C++). holon_sync_test gains ServerPumpIntegratesRosterFromIntents: two participants join + propose intent on a client app, replicate, and server_pump integrates each from its own intent and writes authoritative poses that replicate back (A moves +x, B moves +y). 5 cases green; arisrv builds + links. Extends #773 (slice 4). The remaining client-side piece (arigl driving its local avatar's intent from input + rendering peer avatars from the replicated poses) is S5/S6 + the live two-client cyprty test. --- inc/cvc/ariadne/holon_sync.h | 13 +++ src/arisrv/main.cpp | 12 +++ .../ariadne/conference/room_intrinsics.cpp | 87 +++++++++++++------ src/cvc/ariadne/holon_sync.cpp | 22 ++++- src/cvc/tests/holon_sync_test.cpp | 54 ++++++++++++ 5 files changed, 161 insertions(+), 27 deletions(-) diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index c98559ac2..c11723250 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -28,6 +28,8 @@ class app; namespace ariadne { +class holon; + // ---------------- // cvc::ariadne — holon <-> state-tree sync over SCALAR keys (H1 slice 4) // ---------------- @@ -77,6 +79,17 @@ void write_intent(cvc::app &app, const std::string &room, const std::string &pid bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, avatar_intent &out_intent); +// 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 +// and calls this once per relay tick. A departed participant simply stops +// receiving intent/pose updates (reaping its holon slot is a follow-up). wall_dt +// is forwarded to holon::advance: the default (-1) measures real elapsed wall time +// (what the relay wants); a test injects a fixed dt for a deterministic step count. +void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt = -1.0); + } // namespace ariadne } // namespace cvc diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 8cc0ba4fd..89f449170 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -26,6 +26,8 @@ #include #include // room_leave (retract a departed peer) #include // room_relay_tick +#include // server-authoritative avatar holon (H1) +#include // server_pump: intent -> holon -> authoritative pose #include #include #include // protocol-agnostic media hub (WS + gRPC + future protocols) @@ -150,6 +152,13 @@ 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 + // 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); while (!g_stop.load()) { #if CVC_ENABLE_WS // Reap ghosts (~every second): a tab that navigated or reloaded away without a clean WebSocket @@ -182,6 +191,9 @@ int main(int argc, char **argv) { } #endif cvc::ariadne::conference::room_relay_tick(app, room, relay, relayed); + // Advance the authoritative avatar holon and write each participant's pose. The + // pose writes are local shard mutations, fanned to clients by the drain below. + cvc::ariadne::server_pump(server_holon, app, room); #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 diff --git a/src/cvc/ariadne/conference/room_intrinsics.cpp b/src/cvc/ariadne/conference/room_intrinsics.cpp index dce64fbf2..d8a013e42 100644 --- a/src/cvc/ariadne/conference/room_intrinsics.cpp +++ b/src/cvc/ariadne/conference/room_intrinsics.cpp @@ -8,8 +8,11 @@ License version 2.1 as published by the Free Software Foundation. */ +#include +#include // strtod (room-set-intent numeric args) #include // register_action_intrinsics #include +#include // write_intent (room-set-intent verb) #include // app.root() #include // cvc::state, SEPARATOR, findDescendant, children, value #include // default_for / local_node_id (the trust-all pid) @@ -73,6 +76,18 @@ std::string get_str(const se::value_t *v, const std::string &def = std::string() return *s; return def; } +// A number from a value_t arg (double / int64 / numeric string), for the pose/intent verbs. +double get_num(const se::value_t *v, double def = 0.0) { + if (!v) + return def; + if (const double *d = std::get_if(&v->v)) + return *d; + if (const std::int64_t *i = std::get_if(&v->v)) + return static_cast(*i); + if (const std::string *s = std::get_if(&v->v)) + return std::strtod(s->c_str(), nullptr); + return def; +} se::value_t err(const std::string &msg) { return se::make_dict({{"ok", se::value_t(false)}, {"error", se::value_t(msg)}}); } @@ -240,32 +255,30 @@ void register_room_intrinsics(cvc::app &app) { }); // (room-roster ROOM) -> list of { pid name present }. - se::builtins::register_fn(env, "room-roster", - [&app, doc](std::span args) -> se::value_t { - if (!doc) - return err("room-roster: no document scope"); - room_table *tbl = doc_rooms(doc); - if (!tbl) - return err("room-roster: internal: room-table slot unavailable"); - if (args.empty()) - return err("room-roster: missing room handle or room-id"); - // Accept a (room-join) handle OR a bare room-id string, so an - // N-party .ari can iterate with (room-roster (state-get - // "conf.room")). - room_handle *rh = tbl->find(handle_token(args[0])); - const std::string room_id = - rh ? rh->room_id : handle_token(args[0]); - if (room_id.empty()) - return err( - "room-roster: ROOM must be a room-join handle or room-id"); - std::vector list; - for (const room_participant &p : room_roster(app, room_id)) - list.push_back(se::make_dict({ - {"pid", se::value_t(p.pid)}, - {"name", se::value_t(p.name)}, - })); - return se::make_list(std::move(list)); - }); + se::builtins::register_fn( + env, "room-roster", [&app, doc](std::span args) -> se::value_t { + if (!doc) + return err("room-roster: no document scope"); + room_table *tbl = doc_rooms(doc); + if (!tbl) + return err("room-roster: internal: room-table slot unavailable"); + if (args.empty()) + return err("room-roster: missing room handle or room-id"); + // Accept a (room-join) handle OR a bare room-id string, so an + // N-party .ari can iterate with (room-roster (state-get + // "conf.room")). + room_handle *rh = tbl->find(handle_token(args[0])); + const std::string room_id = rh ? rh->room_id : handle_token(args[0]); + if (room_id.empty()) + return err("room-roster: ROOM must be a room-join handle or room-id"); + std::vector list; + for (const room_participant &p : room_roster(app, room_id)) + list.push_back(se::make_dict({ + {"pid", se::value_t(p.pid)}, + {"name", se::value_t(p.name)}, + })); + return se::make_list(std::move(list)); + }); // (room-leave ROOM) -> { ok #t/#f }. se::builtins::register_fn(env, "room-leave", @@ -285,6 +298,28 @@ void register_room_intrinsics(cvc::app &app) { tbl->erase(token); return se::make_dict({{"ok", se::value_t(true)}}); }); + + // (room-set-intent HANDLE MOVE-X MOVE-Y MOVE-Z) -> { ok #t/#f }. The caller + // PROPOSES its own avatar's desired motion by writing only its own .intent.* + // (the server holon is the sole writer of the authoritative .pose). The handle + // supplies the caller's pid, so a client cannot propose for another participant. + se::builtins::register_fn( + env, "room-set-intent", [&app, doc](std::span args) -> se::value_t { + if (!doc) + return err("room-set-intent: no document scope"); + room_table *tbl = doc_rooms(doc); + 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)"); + 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])}; + 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/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 1c16de303..f29479317 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -15,7 +15,8 @@ #include #include -#include // room_participant_path +#include // room_participant_path, room_roster +#include // cvc::ariadne::holon (server_pump) #include #include #include // vec3, quat @@ -126,5 +127,24 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, return true; } +void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt) { + // The present participants, straight from the replicated room tree. + const std::vector roster = conference::room_roster(app, room); + // Add any newcomer, then feed each one's latest proposed intent to the holon. + for (const conference::room_participant &p : roster) { + if (!h.has_participant(p.pid)) + h.add_participant(p.pid); + avatar_intent in; + if (read_intent(app, room, p.pid, in)) + h.set_intent(p.pid, in); + } + // 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). + for (const conference::room_participant &p : roster) + write_pose(app, room, p.pid, h.state(p.pid), h.tick()); +} + } // namespace ariadne } // namespace cvc diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index 07b25b985..8caadd29d 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -141,3 +142,56 @@ TEST(HolonSyncTest, ServerAuthoritativePoseReplicatesAndClientReconciles) { ss.detach(); cs.detach(); } + +// server_pump drives the whole roster: it reads each present participant's intent, +// integrates the server holon, and writes each authoritative pose -- the loop +// arisrv runs. (Injected dt so the step count is deterministic in a tight loop.) +TEST(HolonSyncTest, ServerPumpIntegratesRosterFromIntents) { + 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"; + + // Two participants join and propose intent on the client side. + cvc::ariadne::conference::room_join(client_app, room, "A"); + cvc::ariadne::conference::room_join(client_app, room, "B"); + cvc::ariadne::write_intent(client_app, room, "A", avatar_intent{vec3{1.0, 0.0, 0.0}}); + cvc::ariadne::write_intent(client_app, room, "B", avatar_intent{vec3{0.0, 2.0, 0.0}}); + tp.pump_all(); // replicate the roster + intents to the server + + holon server(holon_role::server); + const double dt = server.clock().fixed_dt(); + for (int frame = 0; frame < 5; ++frame) { + cvc::ariadne::server_pump(server, server_app, room, dt); + tp.pump_all(); + } + + // The server integrated each avatar from its own intent and wrote authoritative + // poses, tagged with the holon tick. + body_state pa, pb; + std::uint64_t ta = 0, tb = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "A", pa, ta)); + ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "B", pb, tb)); + EXPECT_GT(pa.position.x, 0.0) << "A moved +x per its intent"; + EXPECT_DOUBLE_EQ(pa.position.y, 0.0); + EXPECT_GT(pb.position.y, 0.0) << "B moved +y per its intent"; + EXPECT_DOUBLE_EQ(pb.position.x, 0.0); + EXPECT_EQ(ta, server.tick()); + + // And the authoritative poses replicate back to the client. + tp.pump_all(); + body_state ca; + std::uint64_t ct = 0; + ASSERT_TRUE(cvc::ariadne::read_pose(client_app, room, "A", ca, ct)); + EXPECT_GT(ca.position.x, 0.0); + + tp.unregister_shard(&ss); + tp.unregister_shard(&cs); + ss.detach(); + cs.detach(); +} From 197035ba89797e36f96647605a541e714e7fd55a Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 15:43:50 -0500 Subject: [PATCH 07/33] ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile) From 0713e7059ef8b7787e253cf1d70dc518dbec091b Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 16:04:03 -0500 Subject: [PATCH 08/33] H1 slice 5: the shared room scene (conf.rooms..scene.*) + arisrv seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-authoritative 3D scene every client renders from its own camera (ARIADNE_CONFERENCE_ROADMAP.md §5g). It lives in the replicated room tree as scalar leaves, like the S4 pose keys, so it rides the "conf" interest filter with no transport change. The schema is the .ari scene's own types: objects are SceneNodes (a URI or a plane/box/sphere primitive, transform, material, visible), the environment is background + chrome + SceneLights. read_room_scene() turns the live subtree back into a cvc::ariadne::Scene, so the client render (S6) is realize_scene() over it. scene.rev is written last by every write so a renderer re-realizes only on change. Removal is soft (present = 0), as room_leave is. arisrv seeds the MVP environment once (gradient background, grid chrome, a sun, a 20 m ground plane, a 1 m reference box); seeding is a no-op when the room already has a scene. room_scene_test: 9 gtests, including the seeded scene replicating to a client over two in-process shards and tracking a move + removal. --- inc/cvc/ariadne/conference/room_scene.h | 113 +++++++ src/arisrv/main.cpp | 11 + src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/conference/room_scene.cpp | 341 ++++++++++++++++++++++ src/cvc/tests/CMakeLists.txt | 11 + src/cvc/tests/room_scene_test.cpp | 280 ++++++++++++++++++ 6 files changed, 758 insertions(+), 1 deletion(-) create mode 100644 inc/cvc/ariadne/conference/room_scene.h create mode 100644 src/cvc/ariadne/conference/room_scene.cpp create mode 100644 src/cvc/tests/room_scene_test.cpp diff --git a/inc/cvc/ariadne/conference/room_scene.h b/inc/cvc/ariadne/conference/room_scene.h new file mode 100644 index 000000000..7c9ede1b0 --- /dev/null +++ b/inc/cvc/ariadne/conference/room_scene.h @@ -0,0 +1,113 @@ +/* + 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_SCENE_H__ +#define __CVC_ARIADNE_CONFERENCE_ROOM_SCENE_H__ + +#include +#include +#include +#include + +namespace cvc { +class app; + +namespace ariadne { +namespace conference { + +// ---------------- +// The shared room scene (ARIADNE_CONFERENCE_ROADMAP.md §5g, H1 slice 5) +// ---------------- +// A server-authoritative 3D scene every client renders from its OWN camera. It lives in the +// replicated room tree beside the participants, as SCALAR leaves (the holon_sync idiom: they ride +// the existing "conf" interest filter with no transport changes and sidestep data()-object +// replication): +// +// 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 grid/axis chrome (1/0) +// 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} +// +// 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 +// to the same realizer a document scene uses: realize_scene(sg, read_room_scene(app, room)). The +// TREE, not a document, is the source of truth. +// +// AUTHORITY: the server is the sole writer of scene.* (clients only read it), by the same +// loopback-trusted convention as .pose (H1); enforcement is §5e/H2. Removal is SOFT (present = 0) +// for the reason room_leave is: a hard subtree delete is resurrected by any peer that still holds +// it. Ids must not contain cvc::state::SEPARATOR (std::invalid_argument otherwise). + +// conf.rooms..scene +std::string room_scene_path(const std::string &room_id); + +// The room's environment: the view background, the diagnostic chrome, and its lights. +struct room_scene_env { + bool background_gradient = false; + float background_top[3] = {0.09f, 0.10f, 0.12f}; + float background_bottom[3] = {0.02f, 0.02f, 0.03f}; + bool chrome = true; + // 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; +}; + +// Write the environment. Lights present before but absent from env.lights are soft-removed. +void write_scene_env(cvc::app &app, const std::string &room_id, const room_scene_env &env); + +// Read the environment. Returns false (env untouched) if it was never written. +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". +void write_scene_object(cvc::app &app, const std::string &room_id, + const cvc::ariadne::SceneNode &obj); + +// Soft-remove one object (present = 0). A no-op if it was never written. +void remove_scene_object(cvc::app &app, const std::string &room_id, const std::string &id); + +// The present objects, in id order. +std::vector read_scene_objects(cvc::app &app, const std::string &room_id); + +// The scene revision: 0 when no scene was ever written, then +1 per write. A renderer re-reads and +// re-realizes only when it changes. +std::uint64_t scene_revision(cvc::app &app, const std::string &room_id); + +// The whole room scene as a cvc::ariadne::Scene (objects -> nodes, env -> lights / chrome / +// background), ready for cvc::gl::ariadne::realize_scene. Empty (any() == false) if nothing was +// written. +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 grid chrome on, 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. +room_scene_env default_scene_env(); +std::vector default_scene_objects(); + +// Seed ROOM-ID with the default scene if it has none yet (scene_revision == 0). Idempotent, so a +// 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); + +} // namespace conference +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_CONFERENCE_ROOM_SCENE_H__ diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 89f449170..b6ba15bed 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -26,6 +26,7 @@ #include #include // room_leave (retract a departed peer) #include // room_relay_tick +#include // seed_default_scene (the shared 3D scene) #include // server-authoritative avatar holon (H1) #include // server_pump: intent -> holon -> authoritative pose #include @@ -159,6 +160,16 @@ int main(int argc, char **argv) { // 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); + // The shared 3D scene (H1 slice 5, roadmap §5g): the relay is its sole writer and seeds the MVP + // environment (ground + grid + a reference box) 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)) { + 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::fflush(stdout); + } while (!g_stop.load()) { #if CVC_ENABLE_WS // Reap ghosts (~every second): a tab that navigated or reloaded away without a clean WebSocket diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index 577a1e347..e83ed2130 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1308,7 +1308,7 @@ 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/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/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 INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h @@ -1331,6 +1331,7 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/stream_intrinsics.h ../../inc/cvc/ariadne/conference/room_intrinsics.h ../../inc/cvc/ariadne/conference/room_media_intrinsics.h + ../../inc/cvc/ariadne/conference/room_scene.h ../../inc/cvc/ariadne/state_io.h ../../inc/cvc/ariadne/scene.h ../../inc/cvc/ariadne/value.h diff --git a/src/cvc/ariadne/conference/room_scene.cpp b/src/cvc/ariadne/conference/room_scene.cpp new file mode 100644 index 000000000..c2768c0c4 --- /dev/null +++ b/src/cvc/ariadne/conference/room_scene.cpp @@ -0,0 +1,341 @@ +/* + 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 // cvc::state, SEPARATOR, findDescendant, children +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace conference { + +namespace { + +// 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; +} + +void check_id(const std::string &id) { + if (id.empty() || id.find(cvc::state::SEPARATOR) != std::string::npos) + throw std::invalid_argument("room scene id must be non-empty and contain no '" + + std::string(cvc::state::SEPARATOR) + "': '" + id + "'"); +} + +// Shortest text that recovers the float exactly (9 significant digits round-trip a binary32). +std::string fmt(float f) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.9g", static_cast(f)); + 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). +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state &n = app.root()(path); + n.touch(); + n.value(val); +} +void set_f(cvc::app &app, const std::string &path, float f) { set_replicated(app, path, fmt(f)); } +void set_b(cvc::app &app, const std::string &path, bool b) { + set_replicated(app, path, b ? "1" : "0"); +} +void set_v3(cvc::app &app, const std::string &path, const float v[3], const char *a = "x", + const char *b = "y", const char *c = "z") { + set_f(app, sub(path, {a}), v[0]); + set_f(app, sub(path, {b}), v[1]); + set_f(app, sub(path, {c}), v[2]); +} +void set_rgb(cvc::app &app, const std::string &path, const float v[3]) { + set_v3(app, path, v, "r", "g", "b"); +} + +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; +} +float get_f(cvc::app &app, const std::string &path, float def) { + cvc::state *n = find(app, path); + return n && !n->value().empty() ? std::strtof(n->value().c_str(), nullptr) : 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; +} +void get_v3(cvc::app &app, const std::string &path, float v[3], const char *a = "x", + const char *b = "y", const char *c = "z") { + v[0] = get_f(app, sub(path, {a}), v[0]); + v[1] = get_f(app, sub(path, {b}), v[1]); + v[2] = get_f(app, sub(path, {c}), v[2]); +} +void get_rgb(cvc::app &app, const std::string &path, float v[3]) { + get_v3(app, path, v, "r", "g", "b"); +} + +// The immediate children of PATH (names only). cvc::state::children() is a DEEP pre-order walk of +// full paths, so keep the entries exactly one segment below PATH (as room_roster does). +std::vector child_ids(cvc::app &app, const std::string &path) { + std::vector out; + cvc::state *n = find(app, path); + if (!n) + return out; + const std::string prefix = path + cvc::state::SEPARATOR; + for (const std::string &full : n->children()) { + if (full.rfind(prefix, 0) != 0) + continue; + std::string rel = full.substr(prefix.size()); + if (rel.empty() || rel.find(cvc::state::SEPARATOR) != std::string::npos) + continue; + out.push_back(std::move(rel)); + } + return out; +} + +std::string env_path(const std::string &room) { return sub(room_scene_path(room), {"env"}); } +std::string lights_path(const std::string &room) { return sub(env_path(room), {"lights"}); } +std::string objects_path(const std::string &room) { + return sub(room_scene_path(room), {"objects"}); +} + +// Written LAST by every scene write, so a reader that sees the new revision also sees the content +// it covers (the journal replicates in write order). +void bump_revision(cvc::app &app, const std::string &room) { + set_replicated(app, sub(room_scene_path(room), {"rev"}), + std::to_string(static_cast(scene_revision(app, room) + 1))); +} + +void write_light(cvc::app &app, const std::string &base, const cvc::ariadne::SceneLight &l) { + set_replicated(app, sub(base, {"kind"}), l.kind); + set_replicated(app, sub(base, {"rig"}), l.rig); + set_f(app, sub(base, {"azimuth"}), l.azimuth); + set_f(app, sub(base, {"elevation"}), l.elevation); + set_f(app, sub(base, {"intensity"}), l.intensity); + set_f(app, sub(base, {"cone"}), l.cone); + set_rgb(app, sub(base, {"color"}), l.color); + set_v3(app, sub(base, {"pos"}), l.pos); + set_v3(app, sub(base, {"target"}), l.target); + set_b(app, sub(base, {"present"}), true); +} + +cvc::ariadne::SceneLight read_light(cvc::app &app, const std::string &base, const std::string &id) { + cvc::ariadne::SceneLight l; + l.id = id; + l.kind = get_s(app, sub(base, {"kind"}), l.kind); + l.rig = get_s(app, sub(base, {"rig"})); + l.azimuth = get_f(app, sub(base, {"azimuth"}), l.azimuth); + l.elevation = get_f(app, sub(base, {"elevation"}), l.elevation); + l.intensity = get_f(app, sub(base, {"intensity"}), l.intensity); + l.cone = get_f(app, sub(base, {"cone"}), l.cone); + get_rgb(app, sub(base, {"color"}), l.color); + get_v3(app, sub(base, {"pos"}), l.pos); + get_v3(app, sub(base, {"target"}), l.target); + return l; +} + +} // namespace + +std::string room_scene_path(const std::string &room_id) { + return sub("conf", {"rooms", room_id.c_str(), "scene"}); +} + +void write_scene_env(cvc::app &app, const std::string &room_id, const room_scene_env &env) { + const std::string e = env_path(room_id); + set_b(app, sub(e, {"background", "gradient"}), env.background_gradient); + 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); + std::set written; + for (const cvc::ariadne::SceneLight &l : env.lights) { + check_id(l.id); + write_light(app, sub(lights_path(room_id), {l.id.c_str()}), l); + written.insert(l.id); + } + for (const std::string &id : child_ids(app, lights_path(room_id))) + if (!written.count(id)) + set_b(app, sub(lights_path(room_id), {id.c_str(), "present"}), false); + bump_revision(app, room_id); +} + +bool read_scene_env(cvc::app &app, const std::string &room_id, room_scene_env &env) { + const std::string e = env_path(room_id); + if (!find(app, sub(e, {"chrome"}))) + return false; // never written (chrome is always written with the env) + room_scene_env out; + out.background_gradient = get_b(app, sub(e, {"background", "gradient"}), false); + 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); + 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)) + out.lights.push_back(read_light(app, base, id)); + } + env = std::move(out); + return true; +} + +void write_scene_object(cvc::app &app, const std::string &room_id, + const cvc::ariadne::SceneNode &obj) { + check_id(obj.id); + const std::string o = sub(objects_path(room_id), {obj.id.c_str()}); + set_replicated(app, sub(o, {"source", "uri"}), obj.source_file); + set_replicated(app, sub(o, {"source", "primitive"}), obj.source_primitive); + 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); + 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); + set_rgb(app, sub(o, {"material", "color"}), obj.color); + set_f(app, sub(o, {"material", "ambient"}), obj.ambient); + set_f(app, sub(o, {"material", "diffuse"}), obj.diffuse); + // Empty = unset, so a reader keeps GeometryNode's specular defaults (SceneNode::has_specular). + set_replicated(app, sub(o, {"material", "specular"}), obj.has_specular ? fmt(obj.specular) : ""); + set_replicated(app, sub(o, {"material", "specular_power"}), + obj.has_specular ? fmt(obj.specular_power) : ""); + set_b(app, sub(o, {"visible"}), obj.visible_default); + set_b(app, sub(o, {"present"}), true); + bump_revision(app, room_id); +} + +void remove_scene_object(cvc::app &app, const std::string &room_id, const std::string &id) { + check_id(id); + const std::string present = sub(objects_path(room_id), {id.c_str(), "present"}); + if (!find(app, present)) + return; + set_b(app, present, false); + bump_revision(app, room_id); +} + +std::vector read_scene_objects(cvc::app &app, const std::string &room_id) { + std::vector out; + for (const std::string &id : child_ids(app, objects_path(room_id))) { + const std::string o = sub(objects_path(room_id), {id.c_str()}); + if (!get_b(app, sub(o, {"present"}), false)) + continue; + cvc::ariadne::SceneNode n; + n.id = id; + n.type = "geometry"; + n.source_file = get_s(app, sub(o, {"source", "uri"})); + n.source_primitive = get_s(app, sub(o, {"source", "primitive"})); + 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); + n.has_transform = true; + get_v3(app, sub(o, {"transform", "pos"}), n.position); + get_v3(app, sub(o, {"transform", "rot"}), n.rotation); + get_v3(app, sub(o, {"transform", "scale"}), n.scale); + n.has_material = true; + get_rgb(app, sub(o, {"material", "color"}), n.color); + n.ambient = get_f(app, sub(o, {"material", "ambient"}), n.ambient); + n.diffuse = get_f(app, sub(o, {"material", "diffuse"}), n.diffuse); + if (!get_s(app, sub(o, {"material", "specular"})).empty()) { + n.has_specular = true; + n.specular = get_f(app, sub(o, {"material", "specular"}), n.specular); + n.specular_power = get_f(app, sub(o, {"material", "specular_power"}), n.specular_power); + } + n.visible_default = get_b(app, sub(o, {"visible"}), true); + out.push_back(std::move(n)); + } + return out; +} + +std::uint64_t scene_revision(cvc::app &app, const std::string &room_id) { + const std::string r = get_s(app, sub(room_scene_path(room_id), {"rev"})); + return r.empty() ? 0 : static_cast(std::strtoull(r.c_str(), nullptr, 10)); +} + +cvc::ariadne::Scene read_room_scene(cvc::app &app, const std::string &room_id) { + cvc::ariadne::Scene s; + s.nodes = read_scene_objects(app, room_id); + room_scene_env env; + if (read_scene_env(app, room_id, env)) { + s.lights = env.lights; + s.has_chrome = true; + s.chrome_visible = env.chrome; + s.has_background = true; + s.background_gradient = env.background_gradient; + for (int i = 0; i < 3; ++i) { + s.background_top[i] = env.background_top[i]; + s.background_bottom[i] = env.background_bottom[i]; + } + } + return s; +} + +room_scene_env default_scene_env() { + room_scene_env env; + env.background_gradient = true; + env.background_top[0] = 0.20f, env.background_top[1] = 0.24f, env.background_top[2] = 0.30f; + env.background_bottom[0] = 0.05f, env.background_bottom[1] = 0.06f, + env.background_bottom[2] = 0.08f; + env.chrome = true; + 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); + return env; +} + +std::vector default_scene_objects() { + cvc::ariadne::SceneNode ground; + ground.id = "ground"; + ground.source_primitive = "plane"; + ground.plane_size = 20.0f; // metres; spans [-10, 10]^2 in XY at z = 0 + ground.color[0] = 0.32f, ground.color[1] = 0.34f, ground.color[2] = 0.38f; + ground.ambient = 0.35f; + ground.diffuse = 0.85f; + + cvc::ariadne::SceneNode ref; + ref.id = "ref"; + ref.source_primitive = "box"; + ref.box_size[0] = ref.box_size[1] = ref.box_size[2] = 1.0f; + ref.position[2] = 0.5f; // centred box: stand it on the ground + ref.color[0] = 0.85f, ref.color[1] = 0.45f, ref.color[2] = 0.20f; + ref.has_specular = true; + ref.specular = 0.25f; + ref.specular_power = 24.0f; + return {ground, ref}; +} + +bool seed_default_scene(cvc::app &app, const std::string &room_id) { + if (scene_revision(app, room_id) != 0) + return false; + write_scene_env(app, room_id, default_scene_env()); + for (const cvc::ariadne::SceneNode &n : default_scene_objects()) + write_scene_object(app, room_id, n); + return true; +} + +} // namespace conference +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index 6be1e57a8..c9794452b 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -110,6 +110,7 @@ add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) add_executable(holon_test holon_test.cpp) add_executable(holon_sync_test holon_sync_test.cpp) +add_executable(room_scene_test room_scene_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) @@ -310,6 +311,7 @@ set(TEST_TARGETS avatar_sim_test holon_test holon_sync_test + room_scene_test physics_codec_test physics_field_test physics_vehicle_test @@ -1669,6 +1671,13 @@ target_link_libraries(holon_sync_test GTest::gtest_main ) +target_link_libraries(room_scene_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(physics_codec_test PRIVATE cvc @@ -2077,6 +2086,7 @@ target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) target_compile_features(holon_test PRIVATE cxx_std_17) target_compile_features(holon_sync_test PRIVATE cxx_std_17) +target_compile_features(room_scene_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) @@ -2536,6 +2546,7 @@ cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) cvc_discover_tests(holon_test) cvc_discover_tests(holon_sync_test) +cvc_discover_tests(room_scene_test) cvc_discover_tests(physics_codec_test) cvc_discover_tests(physics_field_test) cvc_discover_tests(physics_vehicle_test) diff --git a/src/cvc/tests/room_scene_test.cpp b/src/cvc/tests/room_scene_test.cpp new file mode 100644 index 000000000..bb0b98307 --- /dev/null +++ b/src/cvc/tests/room_scene_test.cpp @@ -0,0 +1,280 @@ +/* + 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. +*/ + +// 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. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rs = cvc::ariadne::conference; +using cvc::ariadne::SceneLight; +using cvc::ariadne::SceneNode; + +namespace { +void expect_v3_eq(const float a[3], const float b[3]) { + EXPECT_FLOAT_EQ(a[0], b[0]); + EXPECT_FLOAT_EQ(a[1], b[1]); + EXPECT_FLOAT_EQ(a[2], b[2]); +} +const SceneNode *find_node(const std::vector &v, const std::string &id) { + for (const SceneNode &n : v) + if (n.id == id) + return &n; + return nullptr; +} +} // namespace + +// ── one app: the schema round-trips ───────────────────────────────────────── + +TEST(RoomSceneTest, NothingWrittenReadsEmpty) { + cvc::app app; + rs::room_scene_env env; + EXPECT_FALSE(rs::read_scene_env(app, "r", env)); + EXPECT_TRUE(rs::read_scene_objects(app, "r").empty()); + EXPECT_EQ(rs::scene_revision(app, "r"), 0u); + EXPECT_FALSE(rs::read_room_scene(app, "r").any()); +} + +TEST(RoomSceneTest, EnvRoundTrips) { + cvc::app app; + rs::room_scene_env env; + env.background_gradient = true; + env.background_top[0] = 0.25f, env.background_top[1] = 0.5f, env.background_top[2] = 0.75f; + env.chrome = false; + SceneLight sun; + sun.id = "sun"; + sun.azimuth = -35.0f; + sun.elevation = 55.0f; + sun.intensity = 1.2f; + sun.color[2] = 0.9f; + SceneLight spot; + spot.id = "spot"; + spot.kind = "spot"; + spot.pos[0] = 1.0f, spot.pos[1] = 2.0f, spot.pos[2] = 3.0f; + spot.cone = 30.0f; + env.lights = {sun, spot}; + rs::write_scene_env(app, "r", env); + + rs::room_scene_env out; + ASSERT_TRUE(rs::read_scene_env(app, "r", out)); + EXPECT_TRUE(out.background_gradient); + expect_v3_eq(out.background_top, env.background_top); + expect_v3_eq(out.background_bottom, env.background_bottom); + EXPECT_FALSE(out.chrome); + 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]; + EXPECT_EQ(s.id, "sun"); + EXPECT_EQ(s.kind, "directional"); + EXPECT_FLOAT_EQ(s.azimuth, -35.0f); + EXPECT_FLOAT_EQ(s.elevation, 55.0f); + EXPECT_FLOAT_EQ(s.intensity, 1.2f); + expect_v3_eq(s.color, sun.color); + EXPECT_EQ(p.id, "spot"); + EXPECT_EQ(p.kind, "spot"); + expect_v3_eq(p.pos, spot.pos); + EXPECT_FLOAT_EQ(p.cone, 30.0f); +} + +TEST(RoomSceneTest, LightDroppedFromEnvIsSoftRemoved) { + cvc::app app; + rs::room_scene_env env = rs::default_scene_env(); + SceneLight fill; + fill.id = "fill"; + fill.kind = "fill"; + env.lights.push_back(fill); + rs::write_scene_env(app, "r", env); + env.lights.pop_back(); // rewrite without "fill" + rs::write_scene_env(app, "r", env); + + 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"); +} + +TEST(RoomSceneTest, ObjectRoundTrips) { + cvc::app app; + SceneNode box; + box.id = "crate"; + box.source_primitive = "box"; + box.box_size[0] = 2.0f, box.box_size[1] = 0.5f, box.box_size[2] = 1.25f; + box.position[0] = 3.0f, box.position[1] = -4.0f, box.position[2] = 0.625f; + box.rotation[2] = 45.0f; + box.scale[0] = 1.5f; + box.color[0] = 0.1f, box.color[1] = 0.2f, box.color[2] = 0.3f; + box.ambient = 0.4f; + box.diffuse = 0.6f; + box.visible_default = false; + + SceneNode mesh; + mesh.id = "bunny"; + mesh.source_file = "stanford.bunny"; + mesh.has_specular = true; + mesh.specular = 0.5f; + mesh.specular_power = 12.0f; + + rs::write_scene_object(app, "r", box); + rs::write_scene_object(app, "r", mesh); + const std::vector out = rs::read_scene_objects(app, "r"); + ASSERT_EQ(out.size(), 2u); + + const SceneNode *b = find_node(out, "crate"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->type, "geometry"); + EXPECT_EQ(b->source_primitive, "box"); + EXPECT_TRUE(b->source_file.empty()); + expect_v3_eq(b->box_size, box.box_size); + EXPECT_TRUE(b->has_transform); + expect_v3_eq(b->position, box.position); + expect_v3_eq(b->rotation, box.rotation); + expect_v3_eq(b->scale, box.scale); + EXPECT_TRUE(b->has_material); + expect_v3_eq(b->color, box.color); + EXPECT_FLOAT_EQ(b->ambient, 0.4f); + EXPECT_FLOAT_EQ(b->diffuse, 0.6f); + EXPECT_FALSE(b->has_specular); // unset stays unset (GeometryNode keeps its defaults) + EXPECT_FALSE(b->visible_default); + + const SceneNode *m = find_node(out, "bunny"); + ASSERT_NE(m, nullptr); + EXPECT_EQ(m->source_file, "stanford.bunny"); + EXPECT_TRUE(m->source_primitive.empty()); + EXPECT_TRUE(m->has_specular); + EXPECT_FLOAT_EQ(m->specular, 0.5f); + EXPECT_FLOAT_EQ(m->specular_power, 12.0f); + EXPECT_TRUE(m->visible_default); +} + +TEST(RoomSceneTest, RemoveIsSoftAndBumpsRevision) { + cvc::app app; + for (const SceneNode &n : rs::default_scene_objects()) + rs::write_scene_object(app, "r", n); + const std::uint64_t before = rs::scene_revision(app, "r"); + EXPECT_EQ(before, 2u); + + rs::remove_scene_object(app, "r", "ref"); + EXPECT_EQ(rs::scene_revision(app, "r"), before + 1); + const std::vector out = rs::read_scene_objects(app, "r"); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].id, "ground"); + + rs::remove_scene_object(app, "r", "never-written"); // a no-op, no revision bump + EXPECT_EQ(rs::scene_revision(app, "r"), before + 1); + + // Re-writing a soft-removed object brings it back. + rs::write_scene_object(app, "r", rs::default_scene_objects()[1]); + EXPECT_EQ(rs::read_scene_objects(app, "r").size(), 2u); +} + +TEST(RoomSceneTest, RejectsIdsContainingTheSeparator) { + cvc::app app; + SceneNode bad; + bad.id = "a.b"; + EXPECT_THROW(rs::write_scene_object(app, "r", bad), std::invalid_argument); + bad.id = ""; + EXPECT_THROW(rs::write_scene_object(app, "r", bad), std::invalid_argument); + EXPECT_THROW(rs::remove_scene_object(app, "r", "a.b"), std::invalid_argument); + rs::room_scene_env env; + SceneLight l; + l.id = "x.y"; + env.lights.push_back(l); + EXPECT_THROW(rs::write_scene_env(app, "r", env), std::invalid_argument); +} + +TEST(RoomSceneTest, SeedIsIdempotentAndNeverClobbers) { + cvc::app app; + ASSERT_TRUE(rs::seed_default_scene(app, "r")); + const std::uint64_t seeded = rs::scene_revision(app, "r"); + EXPECT_EQ(seeded, 3u); // env + ground + ref + EXPECT_EQ(rs::read_scene_objects(app, "r").size(), 2u); + + // The scene is edited after seeding; a relay restart's re-seed must not undo the edit. + rs::remove_scene_object(app, "r", "ref"); + EXPECT_FALSE(rs::seed_default_scene(app, "r")); + EXPECT_EQ(rs::scene_revision(app, "r"), seeded + 1); + EXPECT_EQ(rs::read_scene_objects(app, "r").size(), 1u); + + // Rooms are independent. + EXPECT_TRUE(rs::seed_default_scene(app, "other")); +} + +TEST(RoomSceneTest, ReadRoomSceneIsARealizableAriadneScene) { + cvc::app app; + ASSERT_TRUE(rs::seed_default_scene(app, "r")); + const cvc::ariadne::Scene s = rs::read_room_scene(app, "r"); + EXPECT_TRUE(s.any()); + ASSERT_EQ(s.nodes.size(), 2u); + const SceneNode *ground = find_node(s.nodes, "ground"); + ASSERT_NE(ground, nullptr); + EXPECT_EQ(ground->source_primitive, "plane"); + EXPECT_FLOAT_EQ(ground->plane_size, 20.0f); + const SceneNode *ref = find_node(s.nodes, "ref"); + 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.has_chrome); + EXPECT_TRUE(s.chrome_visible); + EXPECT_TRUE(s.has_background); + EXPECT_TRUE(s.background_gradient); +} + +// ── end-to-end: the server's scene replicates to a client ─────────────────── + +TEST(RoomSceneTest, SeededSceneReplicatesToAClientAndTracksEdits) { + 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); + + ASSERT_TRUE(rs::seed_default_scene(server_app, "r")); + tp.pump_all(); + + EXPECT_EQ(rs::scene_revision(client_app, "r"), rs::scene_revision(server_app, "r")); + const cvc::ariadne::Scene got = rs::read_room_scene(client_app, "r"); + 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); + + // The server moves the reference object and removes the ground; the client follows. + SceneNode moved = rs::default_scene_objects()[1]; + moved.position[0] = 4.0f; + rs::write_scene_object(server_app, "r", moved); + rs::remove_scene_object(server_app, "r", "ground"); + tp.pump_all(); + + EXPECT_EQ(rs::scene_revision(client_app, "r"), rs::scene_revision(server_app, "r")); + const std::vector objs = rs::read_scene_objects(client_app, "r"); + ASSERT_EQ(objs.size(), 1u); + EXPECT_EQ(objs[0].id, "ref"); + EXPECT_FLOAT_EQ(objs[0].position[0], 4.0f); +} From c6804d13cce12210babe9c74bd09ed7759a4bb52 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 17:06:05 -0500 Subject: [PATCH 09/33] H1 S6 (core): client_pump, clock alignment, sparse-sample peer walk, 5 Hz server fix What the live client needs from the holon, found by timing the headless loop like the relay (server pumped every 24 quanta, clients every quantum): - The server holon ran at a third of real time. arisrv advances it at ~5 Hz, 24 quanta due each time, and world_clock's default catch-up cap is 8, so 2/3 of every interval was dropped. holon_config now allows 60 quanta (0.5 s) per advance, sized for a relay or a throttled tab rather than a render loop. - A client replica's clock is aligned to the server's ticks. Reconcile compares ticks, and a client joins a server that has been running, so a sample more than max_skew_ticks away (60) seeks the replica's clock and drops the local avatar's stale input log. Without it, every ack replayed the whole input history (client ahead) or discarded predicted motion (client behind). - Peers walk across sparse samples. They were interpolated by the per-quantum alpha, so with 5 Hz samples a peer flicked between its last two samples 120 times a second. A peer now walks from where it is shown to the newest sample over the server's own interval between samples, timed on the replica's clock; re-alignment shifts the walk instead of restarting it. - holon::take_correction reports reconcile jumps once, for a renderer to ease. - client_pump (the mirror of server_pump: roster -> local/peer registration from the replicated pose, departures dropped, authoritative samples applied, one frame advanced) and propose_intent (predict + write .intent). - server_pump spawns newcomers on a 4 m golden-angle ring around the origin instead of inside the reference object. Tests: holon_test 10 (sparse-sample walk, re-alignment incl. walk continuity, take_correction), holon_sync_test 6 (client_pump end to end at relay timing: peer never steps back, self within a relay interval of the authority, replicas count the server's ticks, departures dropped; spawn ring). --- inc/cvc/ariadne/holon.h | 41 +++++++++++- inc/cvc/ariadne/holon_sync.h | 16 +++++ src/cvc/ariadne/holon.cpp | 70 +++++++++++++++++-- src/cvc/ariadne/holon_sync.cpp | 43 +++++++++++- src/cvc/tests/holon_sync_test.cpp | 84 +++++++++++++++++++++-- src/cvc/tests/holon_test.cpp | 107 ++++++++++++++++++++++++++++++ 6 files changed, 345 insertions(+), 16 deletions(-) diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index b83fac1be..9b3e918f0 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -25,6 +25,7 @@ #include #include #include +#include namespace cvc { namespace ariadne { @@ -57,8 +58,18 @@ namespace ariadne { enum class holon_role { server, client }; struct holon_config { + // A holon is advanced at its HOST's cadence, not a render loop's: arisrv pumps its server holon + // at ~5 Hz (24 quanta due per advance at 120 Hz) and a throttled browser tab may run at 10 fps. + // world_clock's default catch-up cap (8 quanta, sized for a 60+ Hz frame loop) would silently + // drop the rest -- running the room's avatars, and its tick, at a third of real time -- so a + // holon's clock allows 0.5 s of catch-up per advance. + holon_config() { clock.max_steps_per_advance = 60; } + cvc::world_clock::config clock; // the holon's own quantum / scale / mode double reconcile_tol = 0.05; // metres; the client's snap threshold (slice 2) + // 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; }; class holon { @@ -88,6 +99,8 @@ class holon { const cvc::physics::body_state &start = cvc::physics::body_state{}); void remove_participant(const std::string &pid); bool has_participant(const std::string &pid) const; + // Every registered pid, in pid order. + std::vector participants() 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 @@ -98,6 +111,15 @@ class holon { // 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. + // + // 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. void apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, std::uint64_t server_tick); @@ -112,23 +134,36 @@ class holon { // std::out_of_range for an unknown pid. cvc::physics::body_state state(const std::string &pid) const; - // A participant's DISPLAY pose at the current frame's interpolation alpha: - // predicted for the local avatar, interpolated between the last two samples for a - // peer (never extrapolated), the integrated pose on a server. + // A participant's DISPLAY pose this frame: predicted for the local avatar, the + // integrated pose on a server, and for a peer an interpolation between its last + // two samples (never extrapolated). Samples arrive far less often than quanta (the + // relay publishes ~5 Hz against a 120 Hz clock), so a peer is shown ONE SAMPLE + // INTERVAL behind: the newest sample's arrival starts a walk from `prev` to + // `newest` that takes as long as the server took between the two (their + // server_tick gap), measured on this holon's own clock (tick + alpha). cvc::physics::pose display_pose(const std::string &pid) const; + // CLIENT: the position jumps reconcile has applied to the local avatar `pid` + // (snap + replay on divergence) since the last call, summed, then cleared. A + // renderer eases this out so a correction reads as a glide rather than a jump; the + // holon itself stays exact. Zero for a peer, on a server, or for an unknown pid. + cvc::physics::vec3 take_correction(const std::string &pid); + private: struct participant { bool local = false; avatar_sim sim; // server authoritative integrator avatar_intent intent; // latest intent (server input / client-local input) 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 // (mirroring predicted_avatar's stale/duplicate-ack rejection) so a reordered // or duplicate peer sample never rewinds or freezes the interpolation window. bool have_sample = false; std::uint64_t last_sample_tick = 0; 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) }; void on_step(const cvc::clock_driver::clock_frame &f); diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index c11723250..d23d626f9 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -90,6 +90,22 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, // (what the relay wants); a test injects a fixed dt for a deterministic step count. void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt = -1.0); +// 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. +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. +void propose_intent(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, + const avatar_intent &intent); + } // namespace ariadne } // namespace cvc diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index 05bae69ca..7ca50f2f2 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -53,6 +53,14 @@ bool holon::has_participant(const std::string &pid) const { return _participants.find(pid) != _participants.end(); } +std::vector holon::participants() const { + std::vector out; + out.reserve(_participants.size()); + for (const auto &kv : _participants) + out.push_back(kv.first); + return out; +} + void holon::set_intent(const std::string &pid, const avatar_intent &in) { auto it = _participants.find(pid); if (it == _participants.end()) @@ -70,9 +78,36 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body auto it = _participants.find(pid); if (it == _participants.end()) return; + // 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); + 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()); + } 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); + } + } + } + participant &p = it->second; if (p.local) { - p.predicted.reconcile(auth, server_tick, _clock.fixed_dt(), _cfg.reconcile_tol); + 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); } else { // A peer. Reject a stale/duplicate/reordered sample (server_tick <= the newest // one applied), exactly as predicted_avatar does for the local path: otherwise @@ -84,12 +119,21 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body if (!p.have_sample) { p.newest = auth; p.prev = auth; + p.sample_span = 0; p.have_sample = true; } else { - p.prev = p.newest; // shift the window: prev <- old newest, newest <- auth + // Walk on from wherever the peer is SHOWN now (normally the old newest; earlier + // when this sample came before the last walk finished), so a sample that + // arrives early never makes the peer skip forward. + const cvc::physics::pose shown = display_pose(pid); + p.prev = p.newest; + p.prev.position = shown.position; + p.prev.rotation = shown.rotation; p.newest = auth; + p.sample_span = server_tick - p.last_sample_tick; } p.last_sample_tick = server_tick; + p.newest_arrival_tick = _clock.tick(); } } @@ -138,9 +182,25 @@ cvc::physics::pose holon::display_pose(const std::string &pid) const { const cvc::physics::body_state &s = p.predicted.state(); return cvc::physics::pose{s.position, s.rotation}; } - // Peer: interpolate between the last two authoritative samples at this frame's - // alpha (interpolate_peer clamps alpha to [0,1), so it never overshoots). - return interpolate_peer(p.prev, p.newest, _alpha); + // Peer: walk from prev to newest over the server's own interval between them, + // timed on this clock from the newest sample's arrival (interpolate_peer clamps + // the fraction to [0,1), so it never overshoots and holds at newest once done). + if (p.sample_span == 0) + return cvc::physics::pose{p.newest.position, p.newest.rotation}; + const std::uint64_t tick = _clock.tick(); + const double elapsed = + (tick >= p.newest_arrival_tick ? static_cast(tick - p.newest_arrival_tick) : 0.0) + + _alpha; + return interpolate_peer(p.prev, p.newest, elapsed / static_cast(p.sample_span)); +} + +cvc::physics::vec3 holon::take_correction(const std::string &pid) { + auto it = _participants.find(pid); + if (it == _participants.end()) + return cvc::physics::vec3{0.0, 0.0, 0.0}; + const cvc::physics::vec3 c = it->second.correction; + it->second.correction = cvc::physics::vec3{0.0, 0.0, 0.0}; + return c; } } // namespace ariadne diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index f29479317..60cbb3f55 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -13,6 +13,7 @@ Lesser General Public License for more details. */ +#include #include #include #include // room_participant_path, room_roster @@ -22,7 +23,9 @@ #include // vec3, quat #include // cvc::state, SEPARATOR, findDescendant #include +#include #include +#include namespace cvc { namespace ariadne { @@ -130,10 +133,18 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt) { // The present participants, straight from the replicated room tree. const std::vector roster = conference::room_roster(app, room); - // Add any newcomer, then feed each one's latest proposed intent to the holon. + // Add any newcomer -- spawned on a 4 m ring around the room's origin (where the seeded + // reference object stands), each at the next golden-angle step so joiners never stack + // -- then feed each one's latest proposed intent to the holon. for (const conference::room_participant &p : roster) { - if (!h.has_participant(p.pid)) - h.add_participant(p.pid); + if (!h.has_participant(p.pid)) { + constexpr double kSpawnRadius = 4.0; + constexpr double kGoldenAngle = 2.39996322972865332; // radians + const double a = kGoldenAngle * static_cast(h.participants().size()); + cvc::physics::body_state start; + start.position = {kSpawnRadius * std::cos(a), kSpawnRadius * std::sin(a), 0.0}; + 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); @@ -146,5 +157,31 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d write_pose(app, room, p.pid, h.state(p.pid), h.tick()); } +void client_pump(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, + double wall_dt) { + 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); + if (!h.has_participant(p.pid)) + h.add_participant(p.pid, /*local=*/p.pid == self_pid, have ? s : cvc::physics::body_state{}); + if (have) + h.apply_authoritative(p.pid, s, server_tick); + } + for (const std::string &pid : h.participants()) + if (!present.count(pid)) + h.remove_participant(pid); + h.advance(wall_dt); +} + +void propose_intent(holon &h, cvc::app &app, const std::string &room, const std::string &self_pid, + const avatar_intent &intent) { + h.set_intent(self_pid, intent); + write_intent(app, room, self_pid, intent); +} + } // namespace ariadne } // namespace cvc diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index 8caadd29d..343dd1e6b 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -16,6 +16,7 @@ // C++ verb + arisrv hosting are the live-integration follow-on): just the mapping // and that it rides the existing conf.rooms.* replication. +#include #include #include #include @@ -166,7 +167,15 @@ TEST(HolonSyncTest, ServerPumpIntegratesRosterFromIntents) { holon server(holon_role::server); const double dt = server.clock().fixed_dt(); - for (int frame = 0; frame < 5; ++frame) { + // The first pump registers both newcomers at their spawn points (then steps one quantum). + cvc::ariadne::server_pump(server, server_app, room, dt); + tp.pump_all(); + const vec3 a0 = server.state("A").position, b0 = server.state("B").position; + // Spawned on the 4 m ring around the origin, apart from each other. + EXPECT_NEAR(std::hypot(a0.x, a0.y), 4.0, 1.0 * dt + 1e-9); + EXPECT_NEAR(std::hypot(b0.x, b0.y), 4.0, 2.0 * dt + 1e-9); + EXPECT_GT(std::hypot(a0.x - b0.x, a0.y - b0.y), 1.0) << "joiners never stack"; + for (int frame = 1; frame < 5; ++frame) { cvc::ariadne::server_pump(server, server_app, room, dt); tp.pump_all(); } @@ -177,10 +186,10 @@ TEST(HolonSyncTest, ServerPumpIntegratesRosterFromIntents) { std::uint64_t ta = 0, tb = 0; ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "A", pa, ta)); ASSERT_TRUE(cvc::ariadne::read_pose(server_app, room, "B", pb, tb)); - EXPECT_GT(pa.position.x, 0.0) << "A moved +x per its intent"; - EXPECT_DOUBLE_EQ(pa.position.y, 0.0); - EXPECT_GT(pb.position.y, 0.0) << "B moved +y per its intent"; - EXPECT_DOUBLE_EQ(pb.position.x, 0.0); + EXPECT_GT(pa.position.x, a0.x) << "A moved +x per its intent"; + EXPECT_DOUBLE_EQ(pa.position.y, a0.y); + EXPECT_GT(pb.position.y, b0.y) << "B moved +y per its intent"; + EXPECT_DOUBLE_EQ(pb.position.x, b0.x); EXPECT_EQ(ta, server.tick()); // And the authoritative poses replicate back to the client. @@ -195,3 +204,68 @@ TEST(HolonSyncTest, ServerPumpIntegratesRosterFromIntents) { ss.detach(); cs.detach(); } + +// client_pump end to end, timed like the live relay: clients pump every quantum, the +// server every 24 (~5 Hz). Client A predicts its own avatar and stays near the +// authoritative one; client B sees A as a peer that moves forward smoothly. +TEST(HolonSyncTest, ClientPumpPredictsSelfAndWalksPeersSmoothly) { + 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 server(holon_role::server); + holon a(holon_role::client), b(holon_role::client); + const double dt = server.clock().fixed_dt(); + constexpr int kServerEvery = 24; + + cvc::ariadne::client_pump(a, a_app, room, "A", dt); // registers A (local) + B (peer) + cvc::ariadne::propose_intent(a, a_app, room, "A", avatar_intent{vec3{2.0, 0.0, 0.0}}); + tp.pump_all(); + + double last_peer_x = -1.0; + for (int frame = 1; frame <= 10 * kServerEvery; ++frame) { + if (frame % kServerEvery == 0) + cvc::ariadne::server_pump(server, server_app, room, kServerEvery * dt); + tp.pump_all(); + cvc::ariadne::client_pump(a, a_app, room, "A", dt); + cvc::ariadne::client_pump(b, b_app, room, "B", dt); + ASSERT_TRUE(b.has_participant("A")); + const double peer_x = b.display_pose("A").position.x; + EXPECT_GE(peer_x, last_peer_x) << "frame " << frame << ": B never sees A step back"; + last_peer_x = peer_x; + } + + const double server_x = server.state("A").position.x; + EXPECT_GT(server_x, 3.9) + << "the server moved A per its intent (2 m/s for 2 s), no dropped quanta"; + // A's own prediction stays within a relay interval of the authority. + EXPECT_NEAR(a.display_pose("A").position.x, server_x, 2.0 * kServerEvery * dt + 1e-9); + // B shows A at most one sample interval behind the authority. + EXPECT_NEAR(last_peer_x, server_x, 2.0 * kServerEvery * dt + 1e-9); + // Both replicas count the server's ticks. + EXPECT_LE(a.tick() > server.tick() ? a.tick() - server.tick() : server.tick() - a.tick(), + static_cast(kServerEvery)); + EXPECT_LE(b.tick() > server.tick() ? b.tick() - server.tick() : server.tick() - b.tick(), + static_cast(kServerEvery)); + + // A participant who leaves is dropped from the replicas. + cvc::ariadne::conference::room_leave(b_app, room, "B"); + tp.pump_all(); + cvc::ariadne::client_pump(a, a_app, room, "A", dt); + EXPECT_FALSE(a.has_participant("B")); + + for (cvc::state_cluster_shard *s : {&ss, &as, &bs}) { + tp.unregister_shard(s); + s->detach(); + } +} diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index d6c176cdc..8409eb3c0 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -27,6 +27,7 @@ using cvc::ariadne::avatar_intent; using cvc::ariadne::avatar_sim; using cvc::ariadne::holon; +using cvc::ariadne::holon_config; using cvc::ariadne::holon_role; using cvc::physics::body_state; using cvc::physics::vec3; @@ -197,3 +198,109 @@ TEST(HolonTest, PeerInterpolatesThroughFrameAlpha) { EXPECT_LT(x_near, 10.0) << "peer interpolation never overshoots newest"; EXPECT_GT(x_near, 9.0) << "and it did advance most of the way (not frozen)"; } + +// ── a peer walks smoothly across SPARSE samples (the live ~5 Hz relay) ────── +// Samples arrive every 24 quanta (5 Hz on a 120 Hz clock). The peer must walk from +// one to the next over those 24 quanta: monotonic, never past the newest sample, +// and actually moving (not held at the sample or flicking between the two). + +TEST(HolonTest, PeerWalksAcrossSparseSamples) { + holon client(holon_role::client); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + constexpr int kSpan = 24; + + double last_x = -1.0; + int moved_frames = 0; + for (int k = 0; k <= 4; ++k) { + body_state s; + s.position = vec3{static_cast(k), 0.0, 0.0}; // 1 m per sample + client.apply_authoritative("B", s, 100 + static_cast(k * kSpan)); + for (int f = 0; f < kSpan; ++f) { + client.advance(dt); + const double x = client.display_pose("B").position.x; + EXPECT_GE(x, last_x) << "sample " << k << " frame " << f << ": the peer never steps back"; + EXPECT_LE(x, static_cast(k)) << "never past the newest sample"; + if (x > last_x) + ++moved_frames; + last_x = x; + } + } + EXPECT_GT(last_x, 3.9) << "it walked (nearly) all the way to the last sample"; + EXPECT_GT(moved_frames, 4 * (kSpan - 2)) << "it moved on (nearly) every frame of each walk"; +} + +// ── a client replica counts the server's ticks ────────────────────────────── + +TEST(HolonTest, ClientRealignsItsClockWhenSkewed) { + holon_config cfg; + cfg.max_skew_ticks = 60; + holon client(holon_role::client, cfg); + client.add_participant("A", /*local=*/true); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + for (int i = 0; i < 500; ++i) // the replica has run on its own for a while + client.advance(dt); + ASSERT_EQ(client.tick(), 500u); + + // Joining a server whose clock is elsewhere: the first sample aligns to its tick. + body_state s; + s.position = vec3{3.0, 0.0, 0.0}; + client.apply_authoritative("A", s, 12); + EXPECT_EQ(client.tick(), 12u); + EXPECT_DOUBLE_EQ(client.state("A").position.x, 3.0) << "the local avatar adopts the sample"; + + // Within max_skew_ticks the clocks are left alone (they run at the same rate). + body_state b0, b1; + b1.position = vec3{1.0, 0.0, 0.0}; + client.apply_authoritative("B", b0, 12); + client.apply_authoritative("B", b1, 36); // B walks 0 -> 1 over 24 ticks + for (int i = 0; i < 10; ++i) + client.advance(dt); + client.apply_authoritative("A", s, 40); // 18 ticks away: no re-alignment + EXPECT_EQ(client.tick(), 22u); + const double b_mid = client.display_pose("B").position.x; + EXPECT_GT(b_mid, 0.3); + EXPECT_LT(b_mid, 0.6); + + // Past it (a stalled frame loop), the clock re-aligns -- and the peer's walk carries on + // from where it was shown rather than restarting. + client.apply_authoritative("A", s, 400); + EXPECT_EQ(client.tick(), 400u); + EXPECT_NEAR(client.display_pose("B").position.x, b_mid, 1e-12); +} + +// ── a reconcile jump is reported once, for the renderer to ease out ───────── + +TEST(HolonTest, TakeCorrectionReportsReconcileJumpsOnce) { + holon client(holon_role::client); + client.add_participant("A", /*local=*/true); + client.add_participant("B", /*local=*/false); + const double dt = client.clock().fixed_dt(); + for (int i = 0; i < 3; ++i) { + client.set_intent("A", avatar_intent{vec3{1.0, 0.0, 0.0}}); + client.advance(dt); + } + const vec3 before = client.state("A").position; + + // The prediction holds: no correction. + body_state held; + held.position = vec3{2.0 * dt, 0.0, 0.0}; // what A predicted at tick 2 + held.linear_velocity = vec3{1.0, 0.0, 0.0}; + client.apply_authoritative("A", held, 2); + const vec3 none = client.take_correction("A"); + EXPECT_DOUBLE_EQ(none.x, 0.0); + EXPECT_DOUBLE_EQ(none.y, 0.0); + + // The server overrides: the jump is reported, exactly, once. + body_state moved; + moved.position = vec3{0.0, 5.0, 0.0}; + client.apply_authoritative("A", moved, 3); + const vec3 after = client.state("A").position; + const vec3 c = client.take_correction("A"); + EXPECT_DOUBLE_EQ(c.x, after.x - before.x); + EXPECT_DOUBLE_EQ(c.y, after.y - before.y); + EXPECT_DOUBLE_EQ(client.take_correction("A").y, 0.0) << "taken, so cleared"; + 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); +} From 692ffe1f6e08e94f2bbd5129c45a857b24f9b5b4 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 17:21:52 -0500 Subject: [PATCH 10/33] =?UTF-8?q?H1=20S6=20(client):=20the=20room=20in=203?= =?UTF-8?q?D=20in=20arigl=20=E2=80=94=20room=5Fview,=20WASD=20avatar,=20fo?= =?UTF-8?q?llow=20camera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cvc::gl::ariadne::room_view draws the conference room into the window's SceneGraph: the server's shared scene (read_room_scene -> realize_scene, ids prefixed room_, re-realized when scene.rev changes, background applied as a view property) and one marker per participant of a client holon, posed each frame at holon::display_pose. The local avatar's reconcile jumps (holon::take_correction) are eased out over 0.12 s, so a server correction glides instead of jumping. intent_from_keys maps held WASD / arrow keys to an intent relative to the camera's heading on the Z-up ground (diagonals normalized, opposing keys cancel). arigl's ConferenceSession gains attach_view(view, cam) and one ordered frame observer right after the transport pump. Each frame it reads the keys (Orbit mode only, since in Fly the same keys move the camera; the camera's held set is dropped while ImGui has the keyboard, so typing a name never walks), proposes the intent only when it changes, runs client_pump, updates the room view, and keeps the camera's orbit centre on our avatar (framed once at first sight). The conference windows stay ImGui windows over the 3D view. CameraController::keyHeld exposes the held set the camera already keeps (native interactor and wasm alike). cvcgl_room_view (offscreen, 22 checks): key -> intent, keyHeld, scene realize + re-realize on removal + teardown, avatar markers follow the holon, a 3 m correction moves the marker one frame's share then glides onto the pose, departures and teardown remove markers. --- inc/cvc/gl/CameraController.h | 6 + inc/cvc/gl/ariadne/room_view.h | 101 +++++++++++++ src/arigl/main.cpp | 86 ++++++++++- src/cvcGL/CMakeLists.txt | 5 + src/cvcGL/CameraController.cpp | 4 + src/cvcGL/ariadne/room_view.cpp | 230 +++++++++++++++++++++++++++++ src/cvcGL/test/cvcgl_room_view.cpp | 161 ++++++++++++++++++++ 7 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 inc/cvc/gl/ariadne/room_view.h create mode 100644 src/cvcGL/ariadne/room_view.cpp create mode 100644 src/cvcGL/test/cvcgl_room_view.cpp diff --git a/inc/cvc/gl/CameraController.h b/inc/cvc/gl/CameraController.h index d226ef078..008167e55 100644 --- a/inc/cvc/gl/CameraController.h +++ b/inc/cvc/gl/CameraController.h @@ -226,6 +226,12 @@ class CameraController : public cvc::state_object { // camera would drift on its own. void releaseHeldKeys(); + // Whether `keySym` (a VTK key sym: "w", "Up", ...; a single letter matches either + // case) is held right now -- the same held set Fly navigation reads, so a host + // driving something else from the keys (an avatar in Orbit mode) sees exactly what + // the camera does, including the drop when a UI overlay takes the keyboard. + bool keyHeld(const std::string &keySym) const; + // Rebind a movement key (also settable via state "keys."). Actions: // "forward","backward","strafe_left","strafe_right","up","down","toggle_mode", // "sprint". Values are VTK key syms ("w","space","Control_L","Tab","Shift_L"). diff --git a/inc/cvc/gl/ariadne/room_view.h b/inc/cvc/gl/ariadne/room_view.h new file mode 100644 index 000000000..75ad46418 --- /dev/null +++ b/inc/cvc/gl/ariadne/room_view.h @@ -0,0 +1,101 @@ +#ifndef CVC_GL_ARIADNE_ROOM_VIEW_H +#define CVC_GL_ARIADNE_ROOM_VIEW_H + +// The conference room in 3D (ARIADNE_CONFERENCE_ROADMAP.md §5g, H1 slice 6): the client side of +// the server-authoritative shared scene and the avatars. +// +// * The room scene (conf.rooms..scene.*, cvc::ariadne::conference::room_scene) is realized +// into the viewer's SceneGraph with realize_scene, and re-realized whenever its revision +// changes. Its node and light ids are prefixed "room_" so they never collide with the hosting +// document's own scene. +// * One marker per participant of a CLIENT holon (cvc::ariadne::client_pump keeps the holon in +// 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. +// +// Owner/render thread, like the SceneGraph it draws into. + +#include +#include // avatar_intent +#include // vec3 +#include +#include +#include +#include + +namespace cvc { +class app; +namespace ariadne { +class holon; +} // namespace ariadne +namespace gl { +class SceneRenderer; +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. +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); + +class room_view { +public: + // Draws ROOM's scene and SELF_PID's view of the avatars into `view`'s SceneGraph. BIND_PREFIX is + // 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 + 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. + void update(cvc::ariadne::holon &h, double wall_dt); + + // Where the local avatar is shown (the eased display position, at the marker's feet). False + // until the holon has the local avatar. + bool self_position(double out[3]) const; + + // The room scene revision currently realized (0 = none yet). + std::uint64_t realized_revision() const { return _realized_rev; } + + // Non-fatal realize warnings since the last call (a missing mesh, ...), then cleared. + std::vector take_warnings(); + + // The SceneGraph node ids room_view uses, for hosts and tests. + static std::string object_node_id(const std::string &id) { return "room_" + id; } + static std::string avatar_node_id(const std::string &pid) { return "avatar_" + pid; } + + // Seconds over which a local reconcile correction is eased out (1/e). + double correction_tau = 0.12; + +private: + void sync_scene(); + void clear_scene(); + void sync_avatars(cvc::ariadne::holon &h, double wall_dt); + + 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 + bool _have_self = false; + double _self_pos[3] = {0.0, 0.0, 0.0}; + std::vector _warnings; +}; + +} // namespace ariadne +} // namespace gl +} // namespace cvc + +#endif // CVC_GL_ARIADNE_ROOM_VIEW_H diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index c7eef3de1..e830bbd6f 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -64,12 +64,15 @@ // 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 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 #include // stream::open — per-peer decode proxies #include // subscribe / publish_external #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 // the room scene + avatar markers in the 3D view #include // list_audio_capture_devices — mic picker #include // list_cameras — camera picker #include // open_audio_output — play each peer's mic @@ -283,6 +286,22 @@ struct ConferenceSession { std::string prefix_; // the document chroot prefix: host state access must prepend it so it reaches // the SAME keys the .ari's (state-get/-set) do (which are prefix-relative) + std::string room_; // the joined room id (conf.rooms.) + + // ── the room in 3D (H1 slice 6): the shared scene + avatars, third-person controls ── + // A client replica of the room holon, kept in step with the replicated room tree by client_pump + // each frame: our avatar predicted from our own intent, peers walked between the relay's pose + // samples. room_view draws the server's scene + one marker per participant into the window's + // SceneGraph; the camera orbits our avatar. Made by attach_view (window mode only); room_view_ + // is declared after holon_ and torn down first (stop()). + 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 framed_ = false; // camera framed on our avatar once + double orbit_center_[3] = {0.0, 0.0, 0.0}; + static constexpr double kWalkSpeed = 2.0; // m/s + static constexpr double kEyeHeight = 1.2; // orbit centre above the avatar's feet (m) // Publish: forward the local camera (latest) and mic (ring) streams. The .ari puts their tokens // in "cam_token" / "mic_token". We re-stamp each outgoing frame with a COMMON host clock (one @@ -371,6 +390,7 @@ struct ConferenceSession { // future step between these without renumbering. enum : int { kOrderPumpAll = 10, // drain/flush the state transport (replicated room tree) — first + kOrderRoom = 15, // the room in 3D: keys -> intent, client holon, scene + avatars, camera kOrderWall = 20, // refresh now_wall_ for the wall-time talk-hold kOrderControls = 30, // read the user-list window's per-peer local controls kOrderDevEnum = 40, // lazily enumerate camera/mic devices (one-shot) @@ -416,6 +436,7 @@ struct ConferenceSession { app_ = &app; pid_ = nodeId; prefix_ = prefix; + room_ = room; // Drive the world clock from wall time via the clock driver: live mode so advance() banks wall // time. The driver owns the single advance of this app's clock and fires the conference's // per-frame steps in order (install_observers); nothing else advances it. @@ -506,6 +527,64 @@ struct ConferenceSession { active = true; return true; } + // 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) { + if (!active || !driver_ || !app_) + return; + holon_ = std::make_unique(cvc::ariadne::holon_role::client); + room_view_ = + std::make_unique(view, *app_, room_, pid_, view.scene().getStatePrefix()); + cam_ = &cam; + driver_->add_frame_observer(kOrderRoom, + [this](const cvc::clock_driver::clock_frame &f) { step_room(f); }); + } + + void step_room(const cvc::clock_driver::clock_frame &f) { + if (!holon_ || !room_view_ || !cam_) + return; + // 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) { + 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); + }; + in = gla::intent_from_keys(held("w", "Up"), held("s", "Down"), held("a", "Left"), + held("d", "Right"), eye, focal, kWalkSpeed); + } + if (in.move.x != last_intent_.move.x || in.move.y != last_intent_.move.y || + in.move.z != last_intent_.move.z) { + cvc::ariadne::propose_intent(*holon_, *app_, room_, pid_, in); // a replicated write + last_intent_ = in; + } + // 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); + room_view_->update(*holon_, f.wall_dt); + for (const std::string &w : room_view_->take_warnings()) + std::fprintf(stderr, "arigl: room: %s\n", w.c_str()); + // Third person: frame our avatar once, then keep the orbit centre on it. + double p[3]; + if (room_view_->self_position(p)) { + if (!framed_) { + cam_->frameBounds(p[0] - 4.0, p[1] - 4.0, p[2], p[0] + 4.0, p[1] + 4.0, p[2] + 2.0); + framed_ = true; + } + const double c[3] = {p[0], p[1], p[2] + kEyeHeight}; + if (std::fabs(c[0] - orbit_center_[0]) + std::fabs(c[1] - orbit_center_[1]) + + std::fabs(c[2] - orbit_center_[2]) > + 1e-4) { + cam_->setOrbitCenter(c[0], c[1], c[2]); + orbit_center_[0] = c[0], orbit_center_[1] = c[1], orbit_center_[2] = c[2]; + } + } + } + // 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 @@ -664,7 +743,8 @@ struct ConferenceSession { } void stop() { - peers_.clear(); // close decode proxies before the transport/shard go away + room_view_.reset(); // remove the room's nodes while the SceneGraph is still alive + peers_.clear(); // close decode proxies before the transport/shard go away mix_out_.reset(); for (publisher &p : pubs_) p.sub.reset(); @@ -903,6 +983,7 @@ struct ConferenceSession { "document locally.\n"); return false; } + void attach_view(cvc::gl::SceneRenderer &, cvc::gl::CameraController &) {} void pump() {} void stop() {} #endif @@ -976,7 +1057,8 @@ int run_window(const ari::HostOptions &o) { // A conference-setup failure must not take down the runtime — fall back to running the document // locally (no relay) and say why, rather than letting the exception abort main. try { - conf.setup(app, sg.getStatePrefix(), rt.backend()); + if (conf.setup(app, sg.getStatePrefix(), rt.backend())) + conf.attach_view(view, cam); // 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/cvcGL/CMakeLists.txt b/src/cvcGL/CMakeLists.txt index cc597da3a..305d6a45a 100644 --- a/src/cvcGL/CMakeLists.txt +++ b/src/cvcGL/CMakeLists.txt @@ -811,6 +811,11 @@ add_executable(cvcgl_ariadne_realize test/cvcgl_ariadne_realize.cpp) target_link_libraries(cvcgl_ariadne_realize PRIVATE cvcGL) add_test(NAME cvcgl_ariadne_realize COMMAND cvcgl_ariadne_realize) +# H1 slice 6: the conference room in 3D (room scene realize + avatar markers + key -> intent). +add_executable(cvcgl_room_view test/cvcgl_room_view.cpp) +target_link_libraries(cvcgl_room_view PRIVATE cvcGL) +add_test(NAME cvcgl_room_view COMMAND cvcgl_room_view) + # The terminal extension (TerminalScene, roadmap §16.6): a scene rendered offscreen becomes the # terminal backend's background under the widgets, and a background drag turns the camera. add_executable(cvcgl_terminal_scene test/cvcgl_terminal_scene.cpp) diff --git a/src/cvcGL/CameraController.cpp b/src/cvcGL/CameraController.cpp index b58f18fb2..dafc00079 100644 --- a/src/cvcGL/CameraController.cpp +++ b/src/cvcGL/CameraController.cpp @@ -1099,6 +1099,10 @@ void CameraController::setPoseMirrorHz(double hz) { void CameraController::releaseHeldKeys() { m_impl->held.clear(); } +bool CameraController::keyHeld(const std::string &keySym) const { + return m_impl->held_has(normKey(keySym)); +} + void CameraController::setPointerCapture(bool on) { Impl &s = *m_impl; s.pointerCapture = on; diff --git a/src/cvcGL/ariadne/room_view.cpp b/src/cvcGL/ariadne/room_view.cpp new file mode 100644 index 000000000..0d3c7f19f --- /dev/null +++ b/src/cvcGL/ariadne/room_view.cpp @@ -0,0 +1,230 @@ +// The conference room in 3D (H1 slice 6) — see inc/cvc/gl/ariadne/room_view.h. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { +namespace gl { +namespace ariadne { + +namespace { + +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; + +// 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]) { + if (self) { + out[0] = 0.95f, out[1] = 0.75f, out[2] = 0.20f; + return; + } + std::uint32_t h = 2166136261u; // FNV-1a: the same pid colours the same on every client + for (unsigned char c : pid) + h = (h ^ c) * 16777619u; + const float hue = static_cast(h % 360u) / 60.0f; // [0, 6) + const float s = 0.55f, v = 0.85f, c = v * s; + const float x = c * (1.0f - std::fabs(std::fmod(hue, 2.0f) - 1.0f)); + float r = 0, g = 0, b = 0; + switch (static_cast(hue)) { + case 0: + r = c, g = x; + break; + case 1: + r = x, g = c; + break; + case 2: + g = c, b = x; + break; + case 3: + g = x, b = c; + break; + case 4: + r = x, b = c; + break; + default: + r = c, b = x; + break; + } + out[0] = r + (v - c), out[1] = g + (v - c), out[2] = b + (v - c); +} + +// Row-major 4x4 of a rotation quaternion and a translation. +void pose_matrix(const cvc::physics::quat &q, double tx, double ty, double tz, double m[16]) { + const double w = q.w, x = q.x, y = q.y, z = q.z; + m[0] = 1 - 2 * (y * y + z * z), m[1] = 2 * (x * y - w * z), m[2] = 2 * (x * z + w * y), m[3] = tx; + m[4] = 2 * (x * y + w * z), m[5] = 1 - 2 * (x * x + z * z), m[6] = 2 * (y * z - w * x), m[7] = ty; + m[8] = 2 * (x * z - w * y), m[9] = 2 * (y * z + w * x), m[10] = 1 - 2 * (x * x + y * y), + m[11] = tz; + m[12] = 0, m[13] = 0, m[14] = 0, m[15] = 1; +} + +} // 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; + } + 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; + if (ml > 1e-9) { + mx *= speed / ml, my *= speed / ml; + in.move = cvc::physics::vec3{mx, my, 0.0}; + } + return in; +} + +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)) {} + +room_view::~room_view() { + clear_scene(); + SceneGraph &sg = _view.scene(); + for (const auto &kv : _avatars) + sg.removeGraphics(avatar_node_id(kv.first)); +} + +std::vector room_view::take_warnings() { + std::vector w; + w.swap(_warnings); + return w; +} + +void room_view::update(cvc::ariadne::holon &h, double wall_dt) { + sync_scene(); + sync_avatars(h, wall_dt); +} + +void room_view::clear_scene() { + SceneGraph &sg = _view.scene(); + for (const std::string &id : _scene_ids) + sg.removeGraphics(id); + _scene_ids.clear(); + _realized.reset(); // after the nodes: ~StageLighting removes a rig's own lights +} + +void room_view::sync_scene() { + const std::uint64_t rev = rs::scene_revision(_app, _room); + if (rev == _realized_rev) + return; + clear_scene(); + cvc::ariadne::Scene scene = rs::read_room_scene(_app, _room); + for (cvc::ariadne::SceneNode &n : scene.nodes) + n.id = object_node_id(n.id); + 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)); + _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 + _scene_ids.push_back(l.id); + // The background is a view property (as AriRuntime applies a document's). + if (scene.has_background) { + const float *t = scene.background_top, *b = scene.background_bottom; + vtkRenderer *r = _view.renderer(); + if (scene.background_gradient && r) { + r->SetGradientBackground(true); + r->SetBackground(b[0], b[1], b[2]); // bottom + r->SetBackground2(t[0], t[1], t[2]); // top + } else { + if (r) + r->SetGradientBackground(false); + _view.setBackground(t[0], t[1], t[2]); + } + } + _realized_rev = rev; +} + +void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { + SceneGraph &sg = _view.scene(); + const std::vector pids = h.participants(); + const std::set present(pids.begin(), pids.end()); + + // Departed participants: drop their markers. + for (auto it = _avatars.begin(); it != _avatars.end();) { + if (!present.count(it->first)) { + sg.removeGraphics(avatar_node_id(it->first)); + it = _avatars.erase(it); + } else { + ++it; + } + } + if (!present.count(_self)) + _have_self = false; + + for (const std::string &pid : pids) { + const bool self = (pid == _self); + const std::string id = avatar_node_id(pid); + if (!_avatars.count(pid)) { + cvc::ariadne::Scene one; + cvc::ariadne::SceneNode n; + n.id = id; + n.source_primitive = "box"; + n.box_size[0] = n.box_size[1] = kAvatarWidth; + n.box_size[2] = kAvatarHeight; + n.has_material = true; + avatar_color(pid, self, n.color); + 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 + _avatars[pid] = true; + } + + cvc::physics::pose p = h.display_pose(pid); + if (self) { + // Ease the local avatar's reconcile corrections: absorb each jump into an offset + // (so the shown position does not move this frame), then decay the offset. + const cvc::physics::vec3 c = h.take_correction(pid); + _self_offset = _self_offset - c; + const double k = wall_dt > 0.0 ? std::exp(-wall_dt / std::max(correction_tau, 1e-6)) : 1.0; + _self_offset = cvc::physics::vec3{_self_offset.x * k, _self_offset.y * k, _self_offset.z * k}; + p.position = p.position + _self_offset; + _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); + node->setPoseMatrix(m); + } + } +} + +bool room_view::self_position(double out[3]) const { + if (!_have_self) + return false; + out[0] = _self_pos[0], out[1] = _self_pos[1], out[2] = _self_pos[2]; + return true; +} + +} // namespace ariadne +} // namespace gl +} // namespace cvc diff --git a/src/cvcGL/test/cvcgl_room_view.cpp b/src/cvcGL/test/cvcgl_room_view.cpp new file mode 100644 index 000000000..d9596b51d --- /dev/null +++ b/src/cvcGL/test/cvcgl_room_view.cpp @@ -0,0 +1,161 @@ +// 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. +#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::SceneGraph; +using cvc::gl::SceneRenderer; +using cvc::gl::ariadne::intent_from_keys; +using cvc::gl::ariadne::room_view; +using cvc::physics::body_state; +using cvc::physics::vec3; +namespace rs = cvc::ariadne::conference; + +static int fails = 0; +static void chk(bool ok, const std::string &w) { + printf(" %s %s\n", ok ? "PASS" : "FAIL", w.c_str()); + if (!ok) + ++fails; +} +static bool near(double a, double b, double tol = 1e-6) { return std::fabs(a - b) < tol; } + +static bool node_xyz(SceneGraph &sg, const std::string &id, double out[3]) { + std::shared_ptr n = sg.getGraphics(id); + if (!n) + return false; + const vtkMatrix4x4 *m = n->getTransform(); + out[0] = m->GetElement(0, 3), out[1] = m->GetElement(1, 3), out[2] = m->GetElement(2, 3); + return true; +} + +int main() { + cvc::app app; + app.properties("system.log_verbosity", "0"); + + // ── keys -> intent, relative to the camera's heading ───────────────────── + { + 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); + 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"); + 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"); + } + + // ── the camera's held keys are what the host reads for the avatar ───────── + { + cvc::gl::CameraController c(app, "room_keys.camera"); + c.keyDown("W"); + c.keyDown("Up"); + chk(c.keyHeld("w") && c.keyHeld("W") && c.keyHeld("Up"), "keyHeld sees held keys, any case"); + c.keyUp("w"); + chk(!c.keyHeld("w") && c.keyHeld("Up"), "and their release"); + c.releaseHeldKeys(); // what the UI overlay does when it takes the keyboard + chk(!c.keyHeld("Up"), "and the drop when a UI takes the keyboard"); + } + + // ── the room scene: realized, then re-realized on a revision change ─────── + { + SceneGraph sg(app, "room_scene"); + SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); + holon h(holon_role::client); + rs::seed_default_scene(app, "r"); + { + room_view rv(view, app, "r", "A", sg.getStatePrefix()); + rv.update(h, 1.0 / 60.0); + chk(rv.realized_revision() == rs::scene_revision(app, "r"), "realized the current revision"); + chk(sg.getGraphics("room_ground") != nullptr, "the ground is in the scene graph"); + chk(sg.getGraphics("room_ref") != nullptr, "the reference box is in the scene graph"); + chk(sg.getGraphics("room_sun") != nullptr, "the sun light is in the scene graph"); + double ref[3]; + chk(node_xyz(sg, "room_ref", ref) && near(ref[2], 0.5, 1e-4), "the box stands on the ground"); + + rs::remove_scene_object(app, "r", "ref"); + rv.update(h, 1.0 / 60.0); + chk(sg.getGraphics("room_ref") == nullptr, "a removed object leaves the graph"); + chk(sg.getGraphics("room_ground") != nullptr, "the rest is re-realized"); + chk(sg.getGraphics("room_sun") != nullptr, "the light too (not duplicated or lost)"); + } + chk(sg.getGraphics("room_ground") == nullptr && sg.getGraphics("room_sun") == nullptr, + "teardown removes the room's nodes and lights"); + } + + // ── avatars follow the holon; a local correction is eased ──────────────── + { + SceneGraph sg(app, "room_avatars"); + SceneRenderer view(sg, 128, 128, /*offscreen=*/true, "main"); + holon h(holon_role::client); + body_state a0, b0; + a0.position = vec3{4.0, 0.0, 0.0}; + b0.position = vec3{0.0, 4.0, 0.0}; + h.add_participant("A", /*local=*/true, a0); + h.add_participant("B", /*local=*/false, b0); + const double dt = h.clock().fixed_dt(); + { + room_view rv(view, app, "empty", "A", sg.getStatePrefix()); + h.advance(dt); + rv.update(h, dt); + double pa[3], pb[3], self[3]; + chk(node_xyz(sg, "avatar_A", pa) && near(pa[0], 4.0) && near(pa[2], 0.85, 1e-4), + "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"); + + // A server correction teleports A by +3 m in y: the marker must not jump this frame, + // then glides to the corrected pose. + double before[3]; + node_xyz(sg, "avatar_A", before); + body_state moved = a0; + moved.position.y = 3.0; + h.apply_authoritative("A", moved, h.tick() + 1); + h.advance(dt); + rv.update(h, dt); + double now[3]; + node_xyz(sg, "avatar_A", now); + // One frame's share of the 3 m correction (3 * (1 - exp(-dt / tau)) ~ 0.2 m), not the jump. + const double share = 3.0 * (1.0 - std::exp(-dt / rv.correction_tau)); + chk(near(now[1] - before[1], share, 1e-6), "a correction does not jump the marker"); + for (int i = 0; i < 120; ++i) { // 1 s >> correction_tau + h.advance(dt); + rv.update(h, dt); + } + node_xyz(sg, "avatar_A", now); + chk(near(now[1], 3.0, 1e-3), "and the marker glides onto the corrected pose"); + + 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_A") != nullptr, "the others stay"); + } + chk(sg.getGraphics("avatar_A") == nullptr, "teardown removes the avatar markers"); + } + + printf("%s (%d failure%s)\n", fails ? "FAILED" : "OK", fails, fails == 1 ? "" : "s"); + return fails ? 1 : 0; +} From 3a1231d2c054b0b5d300dbc0e14693f1079ae772 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 17:58:07 -0500 Subject: [PATCH 11/33] H1 S6: seed the room with the SceneGraph chrome off (live-test finding) The first live run (arisrv + two arigl clients over gRPC on Windows) rendered the seeded room with the SceneGraph's diagnostic chrome: the yellow bounding box, the axis gizmo and the "Max: (...)" label. Chrome is a debug overlay, not a floor grid, so the default room scene now leaves it off; the ground plane and the reference box carry the space. --- inc/cvc/ariadne/conference/room_scene.h | 4 ++-- src/arisrv/main.cpp | 2 +- src/cvc/ariadne/conference/room_scene.cpp | 2 +- src/cvc/tests/room_scene_test.cpp | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/inc/cvc/ariadne/conference/room_scene.h b/inc/cvc/ariadne/conference/room_scene.h index 7c9ede1b0..22d067769 100644 --- a/inc/cvc/ariadne/conference/room_scene.h +++ b/inc/cvc/ariadne/conference/room_scene.h @@ -37,7 +37,7 @@ 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 grid/axis chrome (1/0) +// conf.rooms..scene.env.chrome the SceneGraph diagnostic chrome (1/0) // 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, @@ -97,7 +97,7 @@ 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 grid chrome on, a directional sun, a 20 m ground plane and a 1 m +// 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. room_scene_env default_scene_env(); std::vector default_scene_objects(); diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index b6ba15bed..fce0bb3f1 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -161,7 +161,7 @@ int main(int argc, char **argv) { // a smoother authoritative rate is a later tuning (tick the holon on its own). cvc::ariadne::holon server_holon(cvc::ariadne::holon_role::server); // The shared 3D scene (H1 slice 5, roadmap §5g): the relay is its sole writer and seeds the MVP - // environment (ground + grid + a reference box) once. The writes are local shard mutations, so + // 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)) { diff --git a/src/cvc/ariadne/conference/room_scene.cpp b/src/cvc/ariadne/conference/room_scene.cpp index c2768c0c4..ad2398850 100644 --- a/src/cvc/ariadne/conference/room_scene.cpp +++ b/src/cvc/ariadne/conference/room_scene.cpp @@ -295,7 +295,7 @@ room_scene_env default_scene_env() { env.background_top[0] = 0.20f, env.background_top[1] = 0.24f, env.background_top[2] = 0.30f; env.background_bottom[0] = 0.05f, env.background_bottom[1] = 0.06f, env.background_bottom[2] = 0.08f; - env.chrome = true; + 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"; diff --git a/src/cvc/tests/room_scene_test.cpp b/src/cvc/tests/room_scene_test.cpp index bb0b98307..8fef743a1 100644 --- a/src/cvc/tests/room_scene_test.cpp +++ b/src/cvc/tests/room_scene_test.cpp @@ -238,7 +238,7 @@ TEST(RoomSceneTest, ReadRoomSceneIsARealizableAriadneScene) { ASSERT_EQ(s.lights.size(), 1u); EXPECT_EQ(s.lights[0].id, "sun"); EXPECT_TRUE(s.has_chrome); - EXPECT_TRUE(s.chrome_visible); + EXPECT_FALSE(s.chrome_visible) << "the debug overlay is off in a conference room"; EXPECT_TRUE(s.has_background); EXPECT_TRUE(s.background_gradient); } From 6c7bd05f848f1390380063dae3c2b54c065c9c3b Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 17:58:24 -0500 Subject: [PATCH 12/33] arisrv: reflow the seed comment to 100 columns --- src/arisrv/main.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index fce0bb3f1..583d54be1 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -161,8 +161,8 @@ int main(int argc, char **argv) { // a smoother authoritative rate is a later tuning (tick the holon on its own). cvc::ariadne::holon server_holon(cvc::ariadne::holon_role::server); // 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 + // 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)) { const auto rev = cvc::ariadne::conference::scene_revision(app, room); From 04b2b92c9b2e2edb09b0b3b34eab34aeb85f94b5 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 18:16:09 -0500 Subject: [PATCH 13/33] cyprty: ?relay= and ?name= page parameters ?relay=ws://host:port (or wss://) points the browser client at another arisrv -- a staging or local relay -- instead of the page origin's /cyprty-ws; anything that is not a ws(s):// URL is ignored. ?name= presets the display name (trimmed, at most 32 characters) instead of a random guest-xxxxx. --- src/arigl/web/cyprty.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/arigl/web/cyprty.html b/src/arigl/web/cyprty.html index 9a9fd414f..b26f90e21 100644 --- a/src/arigl/web/cyprty.html +++ b/src/arigl/web/cyprty.html @@ -80,12 +80,18 @@

cyberpcangel · cyprty

// The relay URL is derived from the PAGE ORIGIN — a wss:// upgrade of this https page's host, at // /cyprty-ws (Apache reverse-proxies that to the relay). So the deployed client connects to its - // own server automatically; a future build could let the user pick a different one. - const RELAY = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/cyprty-ws'; - const NAME = 'guest-' + Math.random().toString(36).slice(2, 7); + // own server automatically. ?relay=ws://host:port (or wss://) points the page at another relay — + // a staging or local arisrv — and ?name= presets the display name (otherwise a random guest-xxxxx). + const PARAMS = new URLSearchParams(location.search); + const RELAY_PARAM = PARAMS.get('relay'); + const RELAY = (RELAY_PARAM && /^wss?:\/\/[^\s]+$/.test(RELAY_PARAM)) + ? RELAY_PARAM + : (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/cyprty-ws'; + const NAME = (PARAMS.get('name') || '').trim().slice(0, 32) || + 'guest-' + Math.random().toString(36).slice(2, 7); // ?synthetic=1 drives the self-cam from a hardware-free MJPEG test pattern instead of the real // camera, so the conference (peer video included) can be tried in a browser with no camera. - const SYNTH = new URLSearchParams(location.search).get('synthetic'); + const SYNTH = PARAMS.get('synthetic'); // Browsers suspend an AudioContext created without a user gesture (autoplay policy). SDL's audio // output (how we play each peer's mic) is one such context, so without this a peer's audio never From f3281137684f793de04a39260bdc3b5394d502ef Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 18:35:25 -0500 Subject: [PATCH 14/33] H1: native and browser clients leave the room alike; arisrv room readout Running a native (gRPC) and a browser (WS) client in one room showed a native client that exits is never retracted: only the WebSocket transport reported closed connections, so the roster kept the ghost and the server holon kept integrating its last intent (a walker drifted ~240 m over ten minutes). - state_transport_grpc::set_on_connection_closed, the WS transport's contract: fired with the peer's node id (from its Hello) when its stream ends, never during stop(). Test: ConnectionClosedReportsThePeersNodeId. - arisrv queues departures from BOTH planes through one handler and applies them on the main loop for both (the apply step was WS-only), zeroing the departed participant's intent so a rejoin under the same id does not resume an old walk. Hooks are now registered before each transport starts. - server_pump drops holon participants who left the roster (the mirror of client_pump), so a rejoin respawns on the ring. - arigl proposes its intent on the first frame even when standing still. - CVC_ARISRV_ROOM_DEBUG=1 logs the room as the server holds it every ~5 s (each present participant's authoritative position and proposed intent). --- inc/cvc/ariadne/holon_sync.h | 4 +- inc/cvc/state/state_transport_grpc.h | 13 +++++ src/arigl/main.cpp | 8 ++- src/arisrv/main.cpp | 60 +++++++++++++++------ src/cvc/ariadne/holon_sync.cpp | 8 +++ src/cvc/state/state_transport_grpc.cpp | 7 +++ src/cvc/tests/holon_sync_test.cpp | 6 ++- src/cvc/tests/state_transport_grpc_test.cpp | 32 +++++++++++ 8 files changed, 118 insertions(+), 20 deletions(-) diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index d23d626f9..5ca3f375a 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -84,8 +84,8 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, // 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 -// and calls this once per relay tick. A departed participant simply stops -// receiving intent/pose updates (reaping its holon slot is a follow-up). wall_dt +// 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 // (what the relay wants); a test injects a fixed dt for a deterministic step count. void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_dt = -1.0); diff --git a/inc/cvc/state/state_transport_grpc.h b/inc/cvc/state/state_transport_grpc.h index 79def7cf9..846dbd5f7 100644 --- a/inc/cvc/state/state_transport_grpc.h +++ b/inc/cvc/state/state_transport_grpc.h @@ -18,6 +18,7 @@ #include // media_frame + the media_router plug-in surface #include #include +#include #include #include #include @@ -162,6 +163,14 @@ class state_transport_grpc final : public state_transport, public media_endpoint void set_relay(bool enable) noexcept { _relay.store(enable, std::memory_order_relaxed); } bool relay() const noexcept { return _relay.load(std::memory_order_relaxed); } + // 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 + // the room exactly as it does a closed browser tab. Not called during stop(), nor for a stream + // that never sent a Hello. The handler must be thread-safe. + using connection_closed_fn = std::function; + void set_on_connection_closed(connection_closed_fn cb) { _on_conn_closed = std::move(cb); } + // ── Media plane (carried as the Frame envelope's MediaFrame, same as the WS transport) ── // publish_media_frame sends a frame to this transport's connections; a relay's media_router fans // each received frame back out via fan_media(). set_on_media is the leaf subscribe sugar. Media @@ -242,6 +251,9 @@ class state_transport_grpc final : public state_transport, public media_endpoint // older ones being replayed. void admit_connection(const std::shared_ptr &conn); void unregister_connection(connection *conn); + // A server-side stream ended: fire the connection-closed handler for its peer (see + // set_on_connection_closed). + void notify_connection_closed(const std::string &node_id); void increment_recv_frames() noexcept { _recv_frames.fetch_add(1, std::memory_order_relaxed); } void increment_recv_mutations() noexcept { _recv_mutations.fetch_add(1, std::memory_order_relaxed); @@ -280,6 +292,7 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::atomic _backfill_on_connect{true}; std::atomic _relay{false}; + connection_closed_fn _on_conn_closed; // set before start(); read on RPC threads std::atomic _backfilled{0}; std::atomic _published{0}; diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index e830bbd6f..afed66d7f 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -298,6 +298,7 @@ struct ConferenceSession { 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) bool framed_ = false; // camera framed on our avatar once double orbit_center_[3] = {0.0, 0.0, 0.0}; static constexpr double kWalkSpeed = 2.0; // m/s @@ -556,10 +557,13 @@ struct ConferenceSession { in = gla::intent_from_keys(held("w", "Up"), held("s", "Down"), held("a", "Left"), held("d", "Right"), eye, focal, kWalkSpeed); } - if (in.move.x != last_intent_.move.x || in.move.y != last_intent_.move.y || + // 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) { - cvc::ariadne::propose_intent(*holon_, *app_, room_, pid_, in); // a replicated write + cvc::ariadne::propose_intent(*holon_, *app_, room_, pid_, in); last_intent_ = in; + intent_proposed_ = true; } // 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. diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 583d54be1..08e71dbaa 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -23,6 +23,7 @@ #include #include #include +#include // std::getenv (CVC_ARISRV_ROOM_DEBUG) #include #include // room_leave (retract a departed peer) #include // room_relay_tick @@ -74,9 +75,22 @@ int main(int argc, char **argv) { cvc::app app; + // Presence: when a client's connection ends -- a native (gRPC) client exits, or a browser (WS) + // tab closes -- retract it from the room so the roster and the avatar holon do not accumulate + // ghosts. Both transports report the departed node id the same way, on their own threads, so the + // id is only queued here; the main loop below applies room_leave (where every other room-tree + // write happens) and the retraction rides the normal pump + relay forwarding to the others. + std::mutex departed_mu; + std::vector departed; + const auto on_departed = [&](const std::string &node_id) { + std::lock_guard lk(departed_mu); + departed.push_back(node_id); + }; + // Room-tree (state) plane: serve + RELAY (forward frames between clients). The shard ingests the // 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); state_tp.start(stateListen, nodeId, "conf"); state_tp.set_relay(true); cvc::state_cluster_shard shard(app, "conf", nodeId); @@ -98,23 +112,12 @@ 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.start(wsListen, nodeId, "conf"); ws_tp.set_relay(true); ws_tp.register_shard(&shard); state_tp.set_bridge(&ws_tp); ws_tp.set_bridge(&state_tp); - - // Presence: when a browser (WS) client closes its tab, retract it from the room so the roster - // does not accumulate ghosts of closed tabs. The close fires on a WS reader thread, so just queue - // the departed node id here and apply room_leave on the main loop below (where every other - // room-tree write happens), then the retraction rides the normal pump + relay forwarding to the - // others. - std::mutex departed_mu; - std::vector departed; - ws_tp.set_on_connection_closed([&](const std::string &node_id) { - std::lock_guard lk(departed_mu); - departed.push_back(node_id); - }); #endif // Media plane: a stream_relay. Constructed BEFORE media_tp.start() (it installs the frame @@ -160,6 +163,10 @@ int main(int argc, char **argv) { // 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); + 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; + int room_debug_ticks = 0; // 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 @@ -185,8 +192,12 @@ int main(int argc, char **argv) { // server-authoritative room+scene tree rides on. ws_tp.broadcast_snapshot(); } - // Apply any queued departures (a browser tab closed) before pumping, so each retraction goes - // out to the remaining clients this tick. +#endif + // Apply any queued departures (a native client exited or a browser tab closed) before pumping, + // so each retraction goes out to the remaining clients this tick. The departed participant's + // proposed intent is zeroed too: the server holon drops them now (server_pump follows the + // roster), and a client that rejoins under the same id must not resume a walk it proposed + // before it left. { std::vector gone; { @@ -195,16 +206,35 @@ int main(int argc, char **argv) { } for (const std::string &nid : gone) { cvc::ariadne::conference::room_leave(app, room, nid); + cvc::ariadne::write_intent(app, room, nid, cvc::ariadne::avatar_intent{}); std::printf("[arisrv] participant left: %s\n", nid.c_str()); } if (!gone.empty()) std::fflush(stdout); } -#endif cvc::ariadne::conference::room_relay_tick(app, room, relay, relayed); // Advance the authoritative avatar holon and write each participant's pose. The // pose writes are local shard mutations, fanned to clients by the drain below. cvc::ariadne::server_pump(server_holon, app, room); + // CVC_ARISRV_ROOM_DEBUG=1: every ~5 s, log the room as the server holds it -- each present + // participant's authoritative position and its proposed intent -- so replication across the + // gRPC and WebSocket planes can be checked from the relay without a client. + 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())); + for (const auto &p : roster) { + cvc::physics::body_state s; + std::uint64_t t = 0; + cvc::ariadne::avatar_intent in; + const bool has_pose = cvc::ariadne::read_pose(app, room, p.pid, s, t); + const bool has_intent = cvc::ariadne::read_intent(app, room, p.pid, in); + std::printf("[arisrv] %-16s pos %s (%.2f, %.2f) intent %s (%.2f, %.2f)\n", p.pid.c_str(), + has_pose ? "" : "none", s.position.x, s.position.y, has_intent ? "" : "none", + in.move.x, in.move.y); + } + std::fflush(stdout); + } #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 diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 60cbb3f55..bbb91603d 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -149,6 +149,14 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d if (read_intent(app, room, p.pid, in)) h.set_intent(p.pid, in); } + // 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. + std::set present; + for (const conference::room_participant &p : roster) + present.insert(p.pid); + for (const std::string &pid : h.participants()) + if (!present.count(pid)) + h.remove_participant(pid); // One authoritative frame. h.advance(wall_dt); // Publish each participant's authoritative pose (these are local writes on the diff --git a/src/cvc/state/state_transport_grpc.cpp b/src/cvc/state/state_transport_grpc.cpp index 8c32fb463..82bc91fa9 100644 --- a/src/cvc/state/state_transport_grpc.cpp +++ b/src/cvc/state/state_transport_grpc.cpp @@ -332,6 +332,7 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { conn->alive.store(false); _owner->unregister_connection(conn.get()); + _owner->notify_connection_closed(conn->remote_node_id); return grpc::Status::OK; } @@ -535,6 +536,12 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, return true; } +void state_transport_grpc::notify_connection_closed(const std::string &node_id) { + // stop() clears _running before it cancels the streams, so a shutdown never reports departures. + if (_running.load() && _on_conn_closed && !node_id.empty()) + _on_conn_closed(node_id); +} + void state_transport_grpc::stop() { if (!_running.exchange(false)) return; diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index 343dd1e6b..ff0268473 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -258,11 +258,15 @@ TEST(HolonSyncTest, ClientPumpPredictsSelfAndWalksPeersSmoothly) { EXPECT_LE(b.tick() > server.tick() ? b.tick() - server.tick() : server.tick() - b.tick(), static_cast(kServerEvery)); - // A participant who leaves is dropped from the replicas. + // A participant who leaves is dropped from the replicas, and from the server holon. + ASSERT_TRUE(server.has_participant("B")); cvc::ariadne::conference::room_leave(b_app, room, "B"); tp.pump_all(); cvc::ariadne::client_pump(a, a_app, room, "A", dt); EXPECT_FALSE(a.has_participant("B")); + cvc::ariadne::server_pump(server, server_app, room, dt); + EXPECT_FALSE(server.has_participant("B")) << "the server stops integrating a departed avatar"; + EXPECT_TRUE(server.has_participant("A")); for (cvc::state_cluster_shard *s : {&ss, &as, &bs}) { tp.unregister_shard(s); diff --git a/src/cvc/tests/state_transport_grpc_test.cpp b/src/cvc/tests/state_transport_grpc_test.cpp index 4a279d002..f947561b2 100644 --- a/src/cvc/tests/state_transport_grpc_test.cpp +++ b/src/cvc/tests/state_transport_grpc_test.cpp @@ -529,6 +529,38 @@ TEST(StateTransportGrpcTest, BlobPayloadRoundTrip) { tB.stop(); } +// A relay learns when a peer's stream ends, with the peer's node id (the WS transport's contract), +// so it can retract a departed native client from the room; its own shutdown reports nothing. +TEST(StateTransportGrpcTest, ConnectionClosedReportsThePeersNodeId) { + cvc::state_transport_grpc relay, client; + std::mutex mu; + std::vector closed; + relay.set_on_connection_closed([&](const std::string &node_id) { + std::lock_guard lk(mu); + closed.push_back(node_id); + }); + relay.start("127.0.0.1:0", "relay", "C"); + client.start("127.0.0.1:0", "alice", "C"); + ASSERT_TRUE(client.connect_to_peer(relay.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_connected(client, relay, std::chrono::milliseconds(2000))); + + client.stop(); // alice leaves + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + for (;;) { + { + std::lock_guard lk(mu); + if (!closed.empty()) + break; + } + ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "no connection-closed report"; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + relay.stop(); + std::lock_guard lk(mu); + ASSERT_EQ(closed.size(), 1u) << "reported once, and not again on the relay's own stop()"; + EXPECT_EQ(closed[0], "alice"); +} + TEST(StateTransportGrpcTest, DisconnectAfterStop) { cvc::state_transport_grpc tA, tB; tA.start("127.0.0.1:0", "A", "C"); From a4b56a747da901779176a520bfbf0c9a44ceb6ab Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 18:52:51 -0500 Subject: [PATCH 15/33] H1 S6: frame the room on arrival, gold-free peer colours, log who is drawn From the mixed browser + native run: - The first-sight camera framed +-4 m around you, so in a narrow browser window the other participants (spawned 4-7 m away) were off-screen. Frame +-8 m: arriving at a party you should see who is there. - Peer colours skip the 30-75 degree hues, so no peer is drawn in a yellow that reads as your own gold avatar. - arigl logs "room: N avatar(s): ..." whenever the drawn set changes (the browser page shows stderr in its footer and console), via the new room_view::avatar_pids(). --- inc/cvc/gl/ariadne/room_view.h | 3 +++ src/arigl/main.cpp | 14 +++++++++++++- src/cvcGL/ariadne/room_view.cpp | 12 +++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/inc/cvc/gl/ariadne/room_view.h b/inc/cvc/gl/ariadne/room_view.h index 75ad46418..038ba4a61 100644 --- a/inc/cvc/gl/ariadne/room_view.h +++ b/inc/cvc/gl/ariadne/room_view.h @@ -69,6 +69,9 @@ class room_view { // Non-fatal realize warnings since the last call (a missing mesh, ...), then cleared. std::vector take_warnings(); + // The pids that have an avatar marker right now, in pid order. + std::vector avatar_pids() const; + // The SceneGraph node ids room_view uses, for hosts and tests. static std::string object_node_id(const std::string &id) { return "room_" + id; } static std::string avatar_node_id(const std::string &pid) { return "avatar_" + pid; } diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index afed66d7f..ca2079fc1 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -299,6 +299,7 @@ struct ConferenceSession { 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) + std::vector drawn_pids_; // avatars drawn at the last log (log on change) bool framed_ = false; // camera framed on our avatar once double orbit_center_[3] = {0.0, 0.0, 0.0}; static constexpr double kWalkSpeed = 2.0; // m/s @@ -570,13 +571,24 @@ struct ConferenceSession { holon_->set_intent(pid_, last_intent_); cvc::ariadne::client_pump(*holon_, *app_, room_, pid_, f.wall_dt); room_view_->update(*holon_, f.wall_dt); + // 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(); + if (drawn != drawn_pids_) { + std::string who; + for (const std::string &pid : drawn) + who += (who.empty() ? "" : ", ") + pid; + std::fprintf(stderr, "arigl: room: %zu avatar(s): %s\n", drawn.size(), who.c_str()); + drawn_pids_ = drawn; + } for (const std::string &w : room_view_->take_warnings()) std::fprintf(stderr, "arigl: room: %s\n", w.c_str()); // Third person: frame our avatar once, then keep the orbit centre on it. double p[3]; if (room_view_->self_position(p)) { if (!framed_) { - cam_->frameBounds(p[0] - 4.0, p[1] - 4.0, p[2], p[0] + 4.0, p[1] + 4.0, p[2] + 2.0); + // Frame the room around you, not just you: arriving at a party, you want to see who's here. + cam_->frameBounds(p[0] - 8.0, p[1] - 8.0, p[2], p[0] + 8.0, p[1] + 8.0, p[2] + 2.0); framed_ = true; } const double c[3] = {p[0], p[1], p[2] + kEyeHeight}; diff --git a/src/cvcGL/ariadne/room_view.cpp b/src/cvcGL/ariadne/room_view.cpp index 0d3c7f19f..f55191c70 100644 --- a/src/cvcGL/ariadne/room_view.cpp +++ b/src/cvcGL/ariadne/room_view.cpp @@ -35,7 +35,10 @@ void avatar_color(const std::string &pid, bool self, float out[3]) { std::uint32_t h = 2166136261u; // FNV-1a: the same pid colours the same on every client for (unsigned char c : pid) h = (h ^ c) * 16777619u; - const float hue = static_cast(h % 360u) / 60.0f; // [0, 6) + // A hue in [0, 360) that skips 30-75 degrees, the oranges and yellows near the local avatar's + // gold, so a peer is never mistaken for you. + const std::uint32_t deg = h % 315u; + const float hue = static_cast(deg < 30u ? deg : deg + 45u) / 60.0f; // [0, 6) const float s = 0.55f, v = 0.85f, c = v * s; const float x = c * (1.0f - std::fabs(std::fmod(hue, 2.0f) - 1.0f)); float r = 0, g = 0, b = 0; @@ -218,6 +221,13 @@ void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { } } +std::vector room_view::avatar_pids() const { + std::vector out; + for (const auto &kv : _avatars) + out.push_back(kv.first); + return out; +} + bool room_view::self_position(double out[3]) const { if (!_have_self) return false; From e700d9276bf97a67ea5c5cb37e74d5f357a55c9e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 19:08:02 -0500 Subject: [PATCH 16/33] arigl: a fresh conference node id per launch Replication's loop detection drops any mutation whose (origin node id, sequence) it has already seen. arigl derived its node id from the display name alone, so a client that restarted under the same name (a reloaded tab, a relaunched native client, or two people who both kept "guest") restarted its sequence at 1 and every write it made was dropped as a duplicate: it joined a room in which nobody, including the relay's roster, could see it, until the relay restarted. Found with a reloaded browser (?name=webby) and relaunched native clients. The id is now the sanitized name plus a random six-character suffix per launch, so each session is a new participant; the roster still shows the display name (profile.name), and the document identifies "me" by self.pid, which follows. --- src/arigl/main.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index ca2079fc1..c42230536 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -58,6 +58,7 @@ #include #include #include +#include // std::random_device — the conference node id's per-launch suffix #include // arigl info: the GL report, line by line #if CVC_ENABLE_WS || CVC_ENABLE_GRPC // Conference mode: a state transport joins a hosted room, chosen by the relay URL scheme — @@ -284,8 +285,8 @@ struct ConferenceSession { cvc::app *app_ = nullptr; std::string pid_; // this participant's node id (== the room-join pid; stamps our outgoing frames) std::string - prefix_; // the document chroot prefix: host state access must prepend it so it reaches - // the SAME keys the .ari's (state-get/-set) do (which are prefix-relative) + prefix_; // the document chroot prefix: host state access must prepend it so it reaches + // the SAME keys the .ari's (state-get/-set) do (which are prefix-relative) std::string room_; // the joined room id (conf.rooms.) // ── the room in 3D (H1 slice 6): the shared scene + avatars, third-person controls ── @@ -419,11 +420,24 @@ struct ConferenceSession { // shown in the roster) may be any text — e.g. a browser's random "guest-ab12" — and room-join // already carries it separately as the "name" attr, so derive a safe node id here rather than // passing the display name straight through (a hyphen in it otherwise throws). + // + // The id also gets a random per-launch suffix. Replication drops a write whose (origin node id, + // sequence) it has already seen, so a client that restarts under the SAME id (a reloaded tab, + // a relaunched native client, two people who both kept the name "guest") would have every + // write ignored until the relay restarts: it would join a room nobody can see it in. A fresh + // id per session makes each launch a new participant; the name in the roster is unchanged. std::string nodeId; for (char c : self) nodeId += std::isalnum(static_cast(c)) ? c : '_'; if (nodeId.empty() || std::isdigit(static_cast(nodeId.front()))) nodeId.insert(0, "n_"); + { + std::random_device rd; + const char *digits = "0123456789abcdefghijklmnopqrstuvwxyz"; + nodeId += '_'; + for (int i = 0; i < 6; ++i) + 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 From f24ce2f931d589cc8d8bfbdfea754ccdf6cdb8e6 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 19:18:35 -0500 Subject: [PATCH 17/33] arisrv: log the stack on a fatal signal (Linux/glibc) The relay runs unattended, and during the mixed-transport test it died once with "malloc(): unsorted double linked list corrupted" and nothing else to go on (no gdb on the builder, apport ignores unpackaged binaries, and an ASan build did not reproduce it). On SIGSEGV/SIGABRT/SIGBUS/SIGFPE/SIGILL it now writes the stack to stderr with backtrace_symbols_fd (no allocation; backtrace() is warmed up at startup so the handler never lazy-loads libgcc on a corrupted heap), then re-raises with the default action. --- src/arisrv/main.cpp | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 08e71dbaa..ccb4d77b0 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -45,9 +45,40 @@ #include #include +#if defined(__linux__) && defined(__GLIBC__) +#include // backtrace -- a fatal signal logs the relay's stack +#include // write, STDERR_FILENO +#endif + namespace { std::atomic g_stop{false}; void on_signal(int) { g_stop.store(true); } + +#if defined(__linux__) && defined(__GLIBC__) +// A relay runs unattended, so a crash must explain itself in its log: on SIGSEGV / SIGABRT (glibc's +// "malloc(): ... corrupted" aborts) / SIGBUS / SIGFPE / SIGILL, write the stack to stderr, then +// re-raise with the default action so the process still dies (and exits non-zero). Only +// async-signal-safe calls: backtrace_symbols_fd writes straight to the fd without allocating, and +// install_crash_trace() calls backtrace() once up front so its lazy libgcc load never happens +// inside the handler on a corrupted heap. +void on_fatal(int sig) { + static const char head[] = "\n[arisrv] FATAL signal; stack:\n"; + (void)!write(STDERR_FILENO, head, sizeof(head) - 1); + void *frames[64]; + const int n = backtrace(frames, 64); + backtrace_symbols_fd(frames, n, STDERR_FILENO); + std::signal(sig, SIG_DFL); + std::raise(sig); +} +void install_crash_trace() { + void *warm[1]; + (void)backtrace(warm, 1); + for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) + std::signal(sig, on_fatal); +} +#else +void install_crash_trace() {} +#endif } // namespace int main(int argc, char **argv) { @@ -138,6 +169,7 @@ int main(int argc, char **argv) { mrouter.add_endpoint(&ws_tp); // browser (wasm) WebSocket clients #endif + install_crash_trace(); std::signal(SIGINT, on_signal); std::signal(SIGTERM, on_signal); std::printf("[arisrv] relay up | room='%s' node='%s' | state=%s | media=%s\n", room.c_str(), From dc43597bfbf7294675afa6b3c7a58f292b8f8715 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 19:49:38 -0500 Subject: [PATCH 18/33] ci: run the master-targeted matrix (base retargeted to master after #773) From ef78731d7bff3f5cb95b8dc99612c47958b2b7f5 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 20:23:32 -0500 Subject: [PATCH 19/33] Conference link stats: Ping/Pong probes, per-link traffic, room net stats, compact roster Link probe + stats (both transports, every connection): - state_transport.proto: Ping/Pong frames. Each end pings every 2 s (state_transport::set_ping_interval); the other end answers at once and the sender measures the round trip on its own clock. Link-level only: never replicated, relayed or bridged. - state_link_stats (lock-free per-connection bytes/frames both ways, RTT EWMA / last / min) + state_link_rate_meter (cumulative reports -> bytes/s); state_transport::link_reports() on state_transport_ws and _grpc, so a relay's links to clients, a client's link to its relay and a server's links to peer (delegation) servers are all measured the same way. Background tabs no longer drop out: a browser answers the relay's Ping from its WebSocket onmessage event, which a backgrounded tab still receives, so the idle reaper keeps it; a ghost that cannot answer is still reaped. And arigl now redials a lost relay link every 3 s and re-asserts its presence afterwards. Room stats (room_net.h): each client publishes its own link quality as participants..net.{rtt_ms,in_kbps,out_kbps}; the relay publishes its view of each participant (net.relay.*) and its totals (server.net.*) and logs them (per link every ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each minute). (room-roster) entries carry the numbers and roster-cell text. conference_roster.ari: the Users window is a compact table -- one row per participant (name | ping | down | up | cam | mic | vol), each row a grid with the same fixed column widths, so the rows line up as columns; your own row shows your measured link. Peer camera tiles moved right of the wider window. Tests: state_link_stats_test (3), StateTransportWsTest.LinkProbeMeasures- RoundTripAtBothEnds + QuietClientAnsweringPingsIsNotReaped, StateTransportGrpc- Test.LinkProbeMeasuresRoundTripAtBothEnds; RelayReapsIdleConnection turns the probe off to keep testing a silent client. --- .../ariadne/documents/conference_roster.ari | 88 ++++++++--- inc/cvc/ariadne/conference/room_net.h | 80 ++++++++++ inc/cvc/state/state_link_stats.h | 98 ++++++++++++ inc/cvc/state/state_transport.h | 21 +++ inc/cvc/state/state_transport_grpc.h | 16 ++ inc/cvc/state/state_transport_ws.h | 11 ++ proto/state_transport.proto | 17 ++ src/arigl/main.cpp | 78 ++++++++++ src/arisrv/main.cpp | 51 ++++++ src/cvc/CMakeLists.txt | 5 +- .../ariadne/conference/room_intrinsics.cpp | 14 +- src/cvc/ariadne/conference/room_net.cpp | 146 ++++++++++++++++++ src/cvc/state/state_link_stats.cpp | 93 +++++++++++ src/cvc/state/state_transport_grpc.cpp | 102 +++++++++++- src/cvc/state/state_transport_ws.cpp | 102 +++++++++++- src/cvc/tests/CMakeLists.txt | 11 ++ src/cvc/tests/state_link_stats_test.cpp | 85 ++++++++++ src/cvc/tests/state_transport_grpc_test.cpp | 36 +++++ src/cvc/tests/state_transport_ws_test.cpp | 68 ++++++++ 19 files changed, 1092 insertions(+), 30 deletions(-) create mode 100644 inc/cvc/ariadne/conference/room_net.h create mode 100644 inc/cvc/state/state_link_stats.h create mode 100644 src/cvc/ariadne/conference/room_net.cpp create mode 100644 src/cvc/state/state_link_stats.cpp create mode 100644 src/cvc/tests/state_link_stats_test.cpp diff --git a/examples/ariadne/documents/conference_roster.ari b/examples/ariadne/documents/conference_roster.ari index eea9a1855..690b7e325 100644 --- a/examples/ariadne/documents/conference_roster.ari +++ b/examples/ariadne/documents/conference_roster.ari @@ -87,30 +87,58 @@ windows: bind: (if (= (state-get "mic_on") "1") "● on" "muted") # ── Users: the roster with per-peer local controls ── + # One compact ROW per participant, as a table of unit widgets: every row is its own grid with the + # SAME fixed column widths, so the rows line up as columns. ping / down / up are each person's + # link to the relay AS THEY MEASURE IT (published to the room once a second; "--" = not measured + # yet). cam / mic / vol are LOCAL controls (what YOU see and hear of them), never sent. - window: Users id: users pos: [390, 40] - size: [380, 540] + size: [410, 260] closable: true children: - text: "in the room" bind: self.count - separator - # You (first row): your name lights up GREEN while YOU talk. - - text: "you" - bind: conf.self - visible_when: (not (= (state-get "self.talking") "1")) - - text: "you" - bind: conf.self - color: [0.30, 1.0, 0.40] - visible_when: (= (state-get "self.talking") "1") - - separator - # Everyone else: one row per peer (peer.). The on_tick fills peer..{pid,name,cam,talking}. + - type: group + id: users_head + layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } + children: + - text: "name" + - text: "ping" + - text: "down" + - text: "up" + - text: "cam" + - text: "mic" + - text: "vol" + # You: your name lights up GREEN while YOU talk (text colour is static, so it is a visibility + # swap: only one of the two is visible, so they share one cell). + - type: group + id: users_you + layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } + children: + - text: "" + bind: conf.self + visible_when: (not (= (state-get "self.talking") "1")) + - text: "" + bind: conf.self + color: [0.30, 1.0, 0.40] + visible_when: (= (state-get "self.talking") "1") + - text: "" + bind: self.ping + - text: "" + bind: self.down + - text: "" + bind: self.up + - text: "" + - text: "" + - text: "(you)" + # Everyone else: one row per peer (peer.), filled by on_tick. - type: group repeat: (state-get "peer.count") + layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } children: - # Name — click to open + raise this peer's camera window (cam_). Two texts: the GREEN one - # shows only while they are talking (text colour is static, so "light up" is a visibility swap). + # Name: click to open + raise this peer's camera window (cam_); GREEN while talking. - text: "" bind: peer.{i}.name on_click: (begin (state-set "tree.cam_{i}.open" "1") (state-set "tree.cam_{i}.raise" "1")) @@ -120,24 +148,30 @@ windows: color: [0.30, 1.0, 0.40] on_click: (begin (state-set "tree.cam_{i}.open" "1") (state-set "tree.cam_{i}.raise" "1")) visible_when: (= (state-get "peer.{i}.talking") "1") + - text: "" + bind: peer.{i}.ping + - text: "" + bind: peer.{i}.down + - text: "" + bind: peer.{i}.up # Local mute toggles (checked = on). Default on: the host treats von/aon as muted only when - # explicitly "0", so an untouched peer is shown + heard. + # explicitly "0", so an untouched peer is shown + heard. "##" hides the label (the header + # names the column). - type: checkbox - label: "cam" + label: "##cam" bind: peer.{i}.von def: true - type: checkbox - label: "mic" + label: "##mic" bind: peer.{i}.aon def: true # Local volume in our mix (1 = unity, up to 2x boost). - type: slider_float - label: "vol" + label: "##vol" bind: peer.{i}.vol lo: 0 hi: 2 def: 1 - - separator # ── One camera window per OTHER participant, titled by their name ── - window: Peer @@ -163,16 +197,16 @@ on_tick: | ; every cam_ declares the SAME authored pos:, so without this the per-peer windows land ; exactly on top of one another and look like a single window — the #1 reason a user reports ; "I can't see the other cameras". Tile them in a 2-column grid to the RIGHT of the Users - ; window (which sits at x≈390–610): the roster is NEVER covered, and up to four cameras show + ; window (which sits at x≈390–800): the roster is NEVER covered, and up to four cameras show ; side-by-side without overlapping on a laptop screen. Seeded ONCE here, before any peer window ; exists, so each window's first-use geometry seed lands at its own spot; the user's drag then ; persists and wins. Covers 6 peers; a 7th+ falls back to the authored pos. - (state-set "tree.cam_0.geometry" "612,66,330,290") - (state-set "tree.cam_1.geometry" "952,66,330,290") - (state-set "tree.cam_2.geometry" "612,364,330,290") - (state-set "tree.cam_3.geometry" "952,364,330,290") - (state-set "tree.cam_4.geometry" "612,662,330,290") - (state-set "tree.cam_5.geometry" "952,662,330,290") + (state-set "tree.cam_0.geometry" "806,66,330,290") + (state-set "tree.cam_1.geometry" "1146,66,330,290") + (state-set "tree.cam_2.geometry" "806,364,330,290") + (state-set "tree.cam_3.geometry" "1146,364,330,290") + (state-set "tree.cam_4.geometry" "806,662,330,290") + (state-set "tree.cam_5.geometry" "1146,662,330,290") (state-set "cam_on" "1") ; on/off toggles start ON (streams open unmuted) (state-set "mic_on" "1") (room-join "" (state-get "conf.room") (dict "name" (state-get "conf.self"))) @@ -251,6 +285,10 @@ on_tick: | (state-get (str-concat "media." pid ".camera"))) (state-set (str-concat "peer." idx ".talking") (state-get (str-concat "media." pid ".talking"))) + ; their link quality, as THEY measure it (ping to the relay, bits/s down + up) + (state-set (str-concat "peer." idx ".ping") (get-attr p "ping")) + (state-set (str-concat "peer." idx ".down") (get-attr p "down")) + (state-set (str-concat "peer." idx ".up") (get-attr p "up")) (set idx (+ idx 1)) (set total (+ total 1)))))) (state-set "peer.count" (str idx)) diff --git a/inc/cvc/ariadne/conference/room_net.h b/inc/cvc/ariadne/conference/room_net.h new file mode 100644 index 000000000..7d280853b --- /dev/null +++ b/inc/cvc/ariadne/conference/room_net.h @@ -0,0 +1,80 @@ +/* + 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_NET_H__ +#define __CVC_ARIADNE_CONFERENCE_ROOM_NET_H__ + +#include + +namespace cvc { +class app; + +namespace ariadne { +namespace conference { + +// ---------------- +// Network quality in the shared room tree +// ---------------- +// What each end measures about its links (state_transport::link_reports: the Ping/Pong round trip +// and traffic both ways), published as scalar leaves beside the rest of the room so every +// participant can see everyone's connection quality: +// +// conf.rooms..participants..net.{rtt_ms, in_kbps, out_kbps} +// the participant's OWN measurement of its link to the relay -- written by that client (like +// its .intent), shown next to its name in everyone's roster +// conf.rooms..participants..net.relay.{rtt_ms, in_kbps, out_kbps} +// the RELAY's measurement of its link to that participant (in = what the relay receives from +// it) -- written by the relay, like .pose +// conf.rooms..server.net.{in_kbps, out_kbps, links} +// the relay's totals over all of its links +// +// Values are kilobits/second and milliseconds; negative = not measured yet. + +struct net_sample { + double rtt_ms = -1.0; + double in_kbps = -1.0; + double out_kbps = -1.0; +}; + +// A participant's own measurement (client side). +void write_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s); +bool read_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out); + +// The relay's measurement of its link to a participant (server side). +void write_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s); +bool read_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out); + +// The relay's totals. +struct server_net { + double in_kbps = 0.0; + double out_kbps = 0.0; + int links = 0; +}; +void write_server_net(cvc::app &app, const std::string &room, const server_net &s); +bool read_server_net(cvc::app &app, const std::string &room, server_net &out); + +// Compact display text for a roster cell: "42 ms" / "--"; "850k" / "1.2M" / "--" (bits/s). +std::string format_rtt(double rtt_ms); +std::string format_rate(double kbps); + +} // namespace conference +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_CONFERENCE_ROOM_NET_H__ diff --git a/inc/cvc/state/state_link_stats.h b/inc/cvc/state/state_link_stats.h new file mode 100644 index 000000000..0e7c47cf9 --- /dev/null +++ b/inc/cvc/state/state_link_stats.h @@ -0,0 +1,98 @@ +/* + 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_STATE_LINK_STATS_H__ +#define __CVC_STATE_LINK_STATS_H__ + +#include +#include +#include +#include +#include +#include + +namespace cvc { + +// ---------------- +// cvc::state_link_stats +// ---------------- +// The quality of ONE transport connection (a link): traffic both ways and the round-trip time +// measured by the transport's Ping/Pong probe (state_transport.proto). Every connection of +// state_transport_ws and state_transport_grpc owns one, so a client's link to its relay, a relay's +// link to each client, and a server's links to other servers (delegation peers included) are all +// measured the same way. Counters are lock-free: the reader thread notes inbound bytes, any writer +// notes outbound bytes, and the pinger notes round trips concurrently. +// +// RTT: rtt_ms is an exponentially weighted moving average (alpha 1/4) of the measured round trips, +// so one slow probe does not swing it; last and min are kept beside it. All negative until the +// first Pong. +class state_link_stats { +public: + void note_rx(std::size_t bytes) noexcept; + void note_tx(std::size_t bytes) noexcept; + // The next Ping's sequence number (and counts the Ping as sent). + std::uint64_t next_ping_seq() noexcept; + // A Pong arrived: rtt_ns is now minus the echoed sent_ns, on the sender's own clock. + void note_rtt(std::int64_t rtt_ns) noexcept; + + struct counters { + std::uint64_t bytes_in = 0, bytes_out = 0; + std::uint64_t frames_in = 0, frames_out = 0; + std::uint64_t pings_sent = 0, pongs_received = 0; + double rtt_ms = -1.0, rtt_last_ms = -1.0, rtt_min_ms = -1.0; + }; + counters read() const noexcept; + +private: + std::atomic _bytes_in{0}, _bytes_out{0}; + std::atomic _frames_in{0}, _frames_out{0}; + std::atomic _pings{0}, _pongs{0}; + // RTTs in microseconds (atomic integers; -1 = none yet). + std::atomic _rtt_ewma_us{-1}, _rtt_last_us{-1}, _rtt_min_us{-1}; +}; + +// One link as a transport reports it (state_transport::link_reports()). +struct state_link_report { + std::string transport; // "ws" | "grpc" + std::string remote_node_id; // the peer's node id (from its Hello); "" before the Hello + bool accepted = false; // this end ACCEPTED the connection (a relay's link to a client) + double age_s = 0.0; // since the connection opened + double idle_s = 0.0; // since the last inbound frame + state_link_stats::counters stats; +}; + +// Turns successive cumulative link reports into rates. update() is called periodically (say once a +// second) with the transports' current reports; it returns each link's bytes-per-second since the +// previous call, keyed like key_of(). A link seen for the first time reports 0 (no interval yet). +class state_link_rate_meter { +public: + struct rate { + double in_bps = 0.0, out_bps = 0.0; + }; + std::map update(const std::vector &reports, double now_s); + // ":" (+ ":in" for an accepted link), unique per live link. + static std::string key_of(const state_link_report &r); + +private: + struct sample { + std::uint64_t bytes_in = 0, bytes_out = 0; + double t = 0.0; + }; + std::map _last; +}; + +} // namespace cvc + +#endif // __CVC_STATE_LINK_STATS_H__ diff --git a/inc/cvc/state/state_transport.h b/inc/cvc/state/state_transport.h index cacee0375..a0598a1bd 100644 --- a/inc/cvc/state/state_transport.h +++ b/inc/cvc/state/state_transport.h @@ -11,10 +11,13 @@ #ifndef __CVC_STATE_TRANSPORT_H__ #define __CVC_STATE_TRANSPORT_H__ +#include +#include #include #include #include #include +#include #include #include #include @@ -110,6 +113,23 @@ class state_transport { state_peer_registry &peers() noexcept { return _peers; } const state_peer_registry &peers() const noexcept { return _peers; } + // -------- Link quality (Ping/Pong probes, per-connection traffic) -------- + // + // One report per live connection: bytes and frames both ways, and the round-trip time the + // transport's Ping/Pong probe measures (state_link_stats). A relay sees one link per client; a + // client sees its link to the relay; a server also sees its links to other servers. Transports + // without connections report none (the default). + virtual std::vector link_reports() const { return {}; } + + // How often each connection is pinged (default 2 s; 0 turns the probe off). The probe also keeps + // a quiet-but-connected peer from looking idle to a relay's reaper. + void set_ping_interval(std::chrono::milliseconds interval) noexcept { + _ping_interval_ms.store(interval.count(), std::memory_order_relaxed); + } + std::chrono::milliseconds ping_interval() const noexcept { + return std::chrono::milliseconds(_ping_interval_ms.load(std::memory_order_relaxed)); + } + // -------- Chunk fetch (volume/geometry streaming) -------- // // Callback-based chunk retrieval from remote peers. The @@ -202,6 +222,7 @@ class state_transport { protected: state_peer_registry _peers; state_transport *_bridge = nullptr; + std::atomic _ping_interval_ms{2000}; }; } // namespace cvc diff --git a/inc/cvc/state/state_transport_grpc.h b/inc/cvc/state/state_transport_grpc.h index 846dbd5f7..4c26fc836 100644 --- a/inc/cvc/state/state_transport_grpc.h +++ b/inc/cvc/state/state_transport_grpc.h @@ -27,6 +27,11 @@ #include namespace cvc { +namespace transport { +namespace v1 { +class Frame; // the generated envelope (proto/state_transport.proto), for on_link_frame +} // namespace v1 +} // namespace transport // ---------------- // cvc::state_transport_grpc @@ -204,6 +209,8 @@ class state_transport_grpc final : public state_transport, public media_endpoint // Diagnostics. std::size_t shard_count() const; std::size_t connection_count() const; + // One report per live connection (bytes/frames both ways, Ping/Pong round-trip time). + std::vector link_reports() const override; std::uint64_t total_published() const noexcept { return _published.load(); } std::uint64_t total_sent_frames() const noexcept { return _sent_frames.load(); } std::uint64_t total_received_frames() const noexcept { return _recv_frames.load(); } @@ -254,6 +261,9 @@ class state_transport_grpc final : public state_transport, public media_endpoint // A server-side stream ended: fire the connection-closed handler for its peer (see // set_on_connection_closed). void notify_connection_closed(const std::string &node_id); + // Note an inbound frame on `c` (link counters + liveness), and answer / measure a link probe. + // Returns true when `in` was a Ping or Pong (fully handled: never dispatched further). + bool on_link_frame(connection &c, const ::cvc::transport::v1::Frame &in); void increment_recv_frames() noexcept { _recv_frames.fetch_add(1, std::memory_order_relaxed); } void increment_recv_mutations() noexcept { _recv_mutations.fetch_add(1, std::memory_order_relaxed); @@ -334,6 +344,12 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::chrono::milliseconds _heartbeat_interval{0}; std::thread _heartbeat_thread; void heartbeat_loop(); + + // Link probe: every ping_interval(), send each live connection a Ping (state_transport.proto); + // the peer's Pong gives the round trip. Runs on its own thread from start() to stop(). + std::thread _ping_thread; + void ping_loop(); + void send_pings(); }; } // namespace cvc diff --git a/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index 8fa0fb068..17bc2a284 100644 --- a/inc/cvc/state/state_transport_ws.h +++ b/inc/cvc/state/state_transport_ws.h @@ -181,6 +181,8 @@ class state_transport_ws final : public state_transport, public media_endpoint { // ── Diagnostics ── std::size_t shard_count() const; std::size_t connection_count() const; + // One report per live connection (bytes/frames both ways, Ping/Pong round-trip time). + std::vector link_reports() const override; std::uint64_t total_published() const noexcept { return _published.load(); } std::uint64_t total_sent_frames() const noexcept { return _sent_frames.load(); } std::uint64_t total_received_frames() const noexcept { return _recv_frames.load(); } @@ -231,6 +233,15 @@ class state_transport_ws final : public state_transport, public media_endpoint { void accept_loop(); void read_loop(std::shared_ptr conn); + // Link probe: Ping every live connection once ping_interval() has passed since the last round. + // Native: a thread started with the first start() / connect_to_peer(). Browser (no threads): + // pump_all() calls it every frame. A Ping that arrives is answered at once in deliver_inbound -- + // in a browser that is the WebSocket onmessage event, which keeps firing in a backgrounded tab. + void send_pings_if_due(); + void ping_loop(); + std::atomic _next_ping_ns{0}; + std::atomic _ping_started{false}; + struct impl; std::unique_ptr _impl; diff --git a/proto/state_transport.proto b/proto/state_transport.proto index 727f3416c..2f6406262 100644 --- a/proto/state_transport.proto +++ b/proto/state_transport.proto @@ -150,6 +150,21 @@ message MediaFrame { uint32 channels = 10; // audio: channel count (0 for video) } +// Link-quality probe. Each end of a connection sends a Ping on a timer; the other end answers at +// once with a Pong echoing seq + sent_ns, and the sender measures the round trip on its OWN +// monotonic clock (no clock sync). A Ping/Pong is link-level: never replicated, never relayed or +// bridged. Answering also proves liveness, so a relay's idle reaper keeps a client that is quiet +// but still connected (a backgrounded browser tab answers from its WebSocket event, which is not +// timer-throttled) while a ghost that cannot answer is still reaped. +message Ping { + uint64 seq = 1; + int64 sent_ns = 2; // sender's steady-clock ns +} +message Pong { + uint64 seq = 1; + int64 sent_ns = 2; // the Ping's sent_ns, echoed +} + // Single bidirectional frame envelope. Both client and server write // frames; same-cluster routing happens above this layer. message Frame { @@ -163,6 +178,8 @@ message Frame { SnapshotResponse snapshot_response = 7; Heartbeat heartbeat = 8; MediaFrame media = 9; + Ping ping = 10; + Pong pong = 11; } } diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index c42230536..213a01201 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -65,6 +65,8 @@ // 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 // room_join (re-assert after a reconnect) +#include // publish our link quality 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 @@ -289,6 +291,19 @@ struct ConferenceSession { // the SAME keys the .ari's (state-get/-set) do (which are prefix-relative) std::string room_; // the joined room id (conf.rooms.) + // ── link quality + reconnect ── + // Once a second: read our link to the relay (state_transport::link_reports -- the Ping/Pong + // round trip and traffic both ways), publish it to the room as participants..net.* so every + // roster shows it next to our name, and show it in our own row (self.ping / self.down / self.up). + // If the link is gone (the relay restarted, the network dropped, or a long-backgrounded tab was + // reaped) dial the relay again every few seconds, and after reconnecting re-assert our presence + // for a few seconds (the relay marked us as left; our write must land after its snapshot). + std::string relay_url_; + bool relay_grpc_ = false; + bool ever_linked_ = false; + double next_net_s_ = 0.0, next_reconnect_s_ = 0.0, rejoin_until_s_ = 0.0; + cvc::state_link_rate_meter link_meter_; + // ── the room in 3D (H1 slice 6): the shared scene + avatars, third-person controls ── // A client replica of the room holon, kept in step with the replicated room tree by client_pump // each frame: our avatar predicted from our own intent, peers walked between the relay's pose @@ -393,6 +408,7 @@ struct ConferenceSession { // future step between these without renumbering. enum : int { kOrderPumpAll = 10, // drain/flush the state transport (replicated room tree) — first + kOrderNet = 12, // ~1 Hz: our link quality -> the room; reconnect a lost relay link kOrderRoom = 15, // the room in 3D: keys -> intent, client holon, scene + avatars, camera kOrderWall = 20, // refresh now_wall_ for the wall-time talk-hold kOrderControls = 30, // read the user-list window's per-peer local controls @@ -477,6 +493,8 @@ struct ConferenceSession { // the shared ops and send_media_/stop_ wrap the concrete's publish/stop. const std::string relayUrl(relay); const bool want_grpc = (relayUrl.rfind("grpc://", 0) == 0); + relay_url_ = relayUrl; + relay_grpc_ = want_grpc; bool ok = false; if (want_grpc) { #if CVC_ENABLE_GRPC @@ -543,6 +561,64 @@ struct ConferenceSession { active = true; return true; } + static double steady_s() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()) + .count(); + } + + void net_step() { + if (!app_ || !tp_) + return; + const double now = steady_s(); + if (now < next_net_s_) + return; + next_net_s_ = now + 1.0; + const std::vector links = tp_->link_reports(); + const cvc::state_link_report *relay = nullptr; + for (const cvc::state_link_report &r : links) + if (!r.accepted) { // the link WE dialed: the relay + relay = &r; + break; + } + auto &st = cvc::state::instance(*app_); + if (!relay) { + st(prefix_ + ".self.ping").value(std::string("--")); + if (ever_linked_ && now >= next_reconnect_s_) + reconnect(now); + return; + } + ever_linked_ = true; + const auto rates = link_meter_.update(links, now); + const auto it = rates.find(cvc::state_link_rate_meter::key_of(*relay)); + cvc::ariadne::conference::net_sample s; + s.rtt_ms = relay->stats.rtt_ms; + s.in_kbps = it == rates.end() ? 0.0 : it->second.in_bps * 8.0 / 1000.0; + s.out_kbps = it == rates.end() ? 0.0 : it->second.out_bps * 8.0 / 1000.0; + cvc::ariadne::conference::write_participant_net(*app_, room_, pid_, s); + 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 (now < rejoin_until_s_) + cvc::ariadne::conference::room_join(*app_, room_, pid_, st(prefix_ + ".conf.self").value()); + } + + void reconnect(double now) { + next_reconnect_s_ = now + 3.0; + std::fprintf(stderr, "arigl: conference: lost the relay link; reconnecting to %s\n", + relay_url_.c_str()); + bool ok = false; +#if CVC_ENABLE_GRPC + if (relay_grpc_ && grpc_) + ok = grpc_->connect_to_peer(relay_url_.substr(7), std::chrono::milliseconds(3000)); +#endif +#if CVC_ENABLE_WS + if (!relay_grpc_ && ws_) + ok = ws_->connect_to_peer(relay_url_, std::chrono::milliseconds(3000)); +#endif + if (ok) + rejoin_until_s_ = now + 6.0; // re-assert presence until the relay's snapshot has landed + } + // 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. @@ -633,6 +709,8 @@ struct ConferenceSession { // Drive the state transport first: flush replicated room-tree writes and service queued reads // so room-join/roster are in place before anything this frame reads them. driver_->add_frame_observer(kOrderPumpAll, [this](const cf &) { tp_->pump_all(); }); + // Our link quality to the room, and reconnecting a lost relay link (~1 Hz). + driver_->add_frame_observer(kOrderNet, [this](const cf &) { net_step(); }); // Wall-clock seconds for the talk-hold — deliberately wall time, not world time, so the hold is // frame-rate independent and does not freeze when the sim clock is paused or scaled. driver_->add_frame_observer(kOrderWall, [this](const cf &) { diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index ccb4d77b0..3fa45e01c 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -27,6 +27,7 @@ #include #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 @@ -199,6 +200,14 @@ int main(int argc, char **argv) { const bool room_debug = room_debug_env && *room_debug_env && std::strcmp(room_debug_env, "0") != 0; int room_debug_ticks = 0; + // Link stats (~1 s): every connection on both planes reports its traffic and Ping/Pong round + // trip (state_transport::link_reports); the rate meter turns the counters into rates. The relay + // publishes its view of each participant (net.relay.*) and its totals (server.net.*) into the + // room tree, and logs them: every link each ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each + // minute otherwise. Links that are not participants (peer servers, delegation) are logged too. + cvc::state_link_rate_meter link_meter; + 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 @@ -251,6 +260,48 @@ int main(int argc, char **argv) { // CVC_ARISRV_ROOM_DEBUG=1: every ~5 s, log the room as the server holds it -- each present // participant's authoritative position and its proposed intent -- so replication across the // gRPC and WebSocket planes can be checked from the relay without a client. + if (++net_ticks % 5 == 0) { + std::vector links = state_tp.link_reports(); +#if CVC_ENABLE_WS + for (cvc::state_link_report &r : ws_tp.link_reports()) + links.push_back(std::move(r)); +#endif + const double now_s = + std::chrono::duration(std::chrono::steady_clock::now() - net_start).count(); + const auto rates = link_meter.update(links, now_s); + cvc::ariadne::conference::server_net total; + std::set present; + for (const auto &p : cvc::ariadne::conference::room_roster(app, room)) + present.insert(p.pid); + for (const cvc::state_link_report &r : links) { + 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)) + cvc::ariadne::conference::write_relay_net(app, room, r.remote_node_id, + {r.stats.rtt_ms, in_kbps, out_kbps}); + } + cvc::ariadne::conference::write_server_net(app, room, total); + const bool detail = room_debug && net_ticks % 25 == 0; + if (detail || net_ticks % 300 == 0) { + std::printf("[arisrv] net: %d link(s) | in %.1f kb/s | out %.1f kb/s\n", total.links, + total.in_kbps, total.out_kbps); + if (detail) + for (const cvc::state_link_report &r : links) { + const auto it = rates.find(cvc::state_link_rate_meter::key_of(r)); + std::printf("[arisrv] %-4s %-16s %-7s rtt %6.1f ms (min %.1f) | in %7.1f kb/s | out " + "%7.1f kb/s | idle %.1fs\n", + r.transport.c_str(), r.remote_node_id.c_str(), + r.accepted ? "client" : "peer", r.stats.rtt_ms, r.stats.rtt_min_ms, + it == rates.end() ? 0.0 : it->second.in_bps * 8.0 / 1000.0, + it == rates.end() ? 0.0 : it->second.out_bps * 8.0 / 1000.0, r.idle_s); + } + std::fflush(stdout); + } + } 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(), diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index f78bc700e..a32cdb68c 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -42,6 +42,7 @@ set(INCLUDE_FILES ../../inc/cvc/state/state_message.h ../../inc/cvc/state/state_message_bus.h ../../inc/cvc/state/state_peer_registry.h + ../../inc/cvc/state/state_link_stats.h ../../inc/cvc/state/state_write_policy.h ../../inc/cvc/state/state_distributed_metrics.h ../../inc/cvc/state/state_delegation_manager.h @@ -293,6 +294,7 @@ set(SOURCE_FILES media/media_router.cpp state/state_message_bus.cpp state/state_peer_registry.cpp + state/state_link_stats.cpp state/state_write_policy.cpp state/state_distributed_metrics.cpp state/state_delegation_manager.cpp @@ -1310,7 +1312,7 @@ 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/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/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 INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h @@ -1334,6 +1336,7 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/conference/room_intrinsics.h ../../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/state_io.h ../../inc/cvc/ariadne/scene.h ../../inc/cvc/ariadne/value.h diff --git a/src/cvc/ariadne/conference/room_intrinsics.cpp b/src/cvc/ariadne/conference/room_intrinsics.cpp index d8a013e42..361a2fb81 100644 --- a/src/cvc/ariadne/conference/room_intrinsics.cpp +++ b/src/cvc/ariadne/conference/room_intrinsics.cpp @@ -12,6 +12,7 @@ #include // strtod (room-set-intent numeric args) #include // register_action_intrinsics #include +#include // the roster entries carry each link's quality #include // write_intent (room-set-intent verb) #include // app.root() #include // cvc::state, SEPARATOR, findDescendant, children, value @@ -271,12 +272,23 @@ void register_room_intrinsics(cvc::app &app) { const std::string room_id = rh ? rh->room_id : handle_token(args[0]); if (room_id.empty()) return err("room-roster: ROOM must be a room-join handle or room-id"); + // Each entry also carries the participant's self-reported link quality (room_net.h): + // raw numbers (negative = not measured yet) and roster-cell text. std::vector list; - for (const room_participant &p : room_roster(app, room_id)) + for (const room_participant &p : room_roster(app, room_id)) { + net_sample net; + read_participant_net(app, room_id, p.pid, net); list.push_back(se::make_dict({ {"pid", se::value_t(p.pid)}, {"name", se::value_t(p.name)}, + {"rtt_ms", se::value_t(net.rtt_ms)}, + {"in_kbps", se::value_t(net.in_kbps)}, + {"out_kbps", se::value_t(net.out_kbps)}, + {"ping", se::value_t(format_rtt(net.rtt_ms))}, + {"down", se::value_t(format_rate(net.in_kbps))}, + {"up", se::value_t(format_rate(net.out_kbps))}, })); + } return se::make_list(std::move(list)); }); diff --git a/src/cvc/ariadne/conference/room_net.cpp b/src/cvc/ariadne/conference/room_net.cpp new file mode 100644 index 000000000..114d0841b --- /dev/null +++ b/src/cvc/ariadne/conference/room_net.cpp @@ -0,0 +1,146 @@ +/* + 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 // room_participant_path +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace conference { + +namespace { + +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; +} + +// One decimal is plenty for display, and an unchanged value writes nothing new to replicate. +std::string fmt(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.1f", v); + 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 value that has not changed is not +// rewritten, so a steady link does not add replication traffic. +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state &n = app.root()(path); + if (n.value() == val) + return; + n.touch(); + n.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; +} + +void write_sample(cvc::app &app, const std::string &base, const net_sample &s) { + set_replicated(app, sub(base, {"rtt_ms"}), fmt(s.rtt_ms)); + set_replicated(app, sub(base, {"in_kbps"}), fmt(s.in_kbps)); + set_replicated(app, sub(base, {"out_kbps"}), fmt(s.out_kbps)); +} + +bool read_sample(cvc::app &app, const std::string &base, net_sample &out) { + if (!app.root().findDescendant(sub(base, {"rtt_ms"}))) + return false; + out.rtt_ms = read_double(app, sub(base, {"rtt_ms"}), -1.0); + out.in_kbps = read_double(app, sub(base, {"in_kbps"}), -1.0); + out.out_kbps = read_double(app, sub(base, {"out_kbps"}), -1.0); + return true; +} + +std::string participant_net(const std::string &room, const std::string &pid) { + return sub(room_participant_path(room, pid), {"net"}); +} + +std::string server_net_path(const std::string &room) { + return sub("conf", {"rooms", room.c_str(), "server", "net"}); +} + +} // namespace + +void write_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s) { + write_sample(app, participant_net(room, pid), s); +} + +bool read_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out) { + return read_sample(app, participant_net(room, pid), out); +} + +void write_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s) { + write_sample(app, sub(participant_net(room, pid), {"relay"}), s); +} + +bool read_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out) { + return read_sample(app, sub(participant_net(room, pid), {"relay"}), out); +} + +void write_server_net(cvc::app &app, const std::string &room, const server_net &s) { + const std::string base = server_net_path(room); + set_replicated(app, sub(base, {"in_kbps"}), fmt(s.in_kbps)); + set_replicated(app, sub(base, {"out_kbps"}), fmt(s.out_kbps)); + set_replicated(app, sub(base, {"links"}), std::to_string(s.links)); +} + +bool read_server_net(cvc::app &app, const std::string &room, server_net &out) { + const std::string base = server_net_path(room); + if (!app.root().findDescendant(sub(base, {"links"}))) + return false; + out.in_kbps = read_double(app, sub(base, {"in_kbps"}), 0.0); + out.out_kbps = read_double(app, sub(base, {"out_kbps"}), 0.0); + out.links = static_cast(read_double(app, sub(base, {"links"}), 0.0)); + return true; +} + +std::string format_rtt(double rtt_ms) { + if (rtt_ms < 0.0) + return "--"; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.0f ms", rtt_ms); + return std::string(buf); +} + +std::string format_rate(double kbps) { + if (kbps < 0.0) + return "--"; + char buf[32]; + if (kbps >= 1000.0) + std::snprintf(buf, sizeof(buf), "%.1fM", kbps / 1000.0); + else + std::snprintf(buf, sizeof(buf), "%.0fk", kbps); + return std::string(buf); +} + +} // namespace conference +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/state/state_link_stats.cpp b/src/cvc/state/state_link_stats.cpp new file mode 100644 index 000000000..8f74d5e0e --- /dev/null +++ b/src/cvc/state/state_link_stats.cpp @@ -0,0 +1,93 @@ +/* + 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 + +namespace cvc { + +void state_link_stats::note_rx(std::size_t bytes) noexcept { + _bytes_in.fetch_add(bytes, std::memory_order_relaxed); + _frames_in.fetch_add(1, std::memory_order_relaxed); +} + +void state_link_stats::note_tx(std::size_t bytes) noexcept { + _bytes_out.fetch_add(bytes, std::memory_order_relaxed); + _frames_out.fetch_add(1, std::memory_order_relaxed); +} + +std::uint64_t state_link_stats::next_ping_seq() noexcept { + return _pings.fetch_add(1, std::memory_order_relaxed) + 1; +} + +void state_link_stats::note_rtt(std::int64_t rtt_ns) noexcept { + if (rtt_ns < 0) + return; // a clock that went backwards: not a measurement + const std::int64_t us = rtt_ns / 1000; + _pongs.fetch_add(1, std::memory_order_relaxed); + _rtt_last_us.store(us, std::memory_order_relaxed); + std::int64_t mn = _rtt_min_us.load(std::memory_order_relaxed); + while ((mn < 0 || us < mn) && + !_rtt_min_us.compare_exchange_weak(mn, us, std::memory_order_relaxed)) { + } + // EWMA with alpha 1/4; the first sample seeds it. + std::int64_t avg = _rtt_ewma_us.load(std::memory_order_relaxed); + std::int64_t next; + do { + next = avg < 0 ? us : avg + (us - avg) / 4; + } while (!_rtt_ewma_us.compare_exchange_weak(avg, next, std::memory_order_relaxed)); +} + +state_link_stats::counters state_link_stats::read() const noexcept { + counters c; + c.bytes_in = _bytes_in.load(std::memory_order_relaxed); + c.bytes_out = _bytes_out.load(std::memory_order_relaxed); + c.frames_in = _frames_in.load(std::memory_order_relaxed); + c.frames_out = _frames_out.load(std::memory_order_relaxed); + c.pings_sent = _pings.load(std::memory_order_relaxed); + c.pongs_received = _pongs.load(std::memory_order_relaxed); + const auto ms = [](std::int64_t us) { return us < 0 ? -1.0 : static_cast(us) / 1000.0; }; + c.rtt_ms = ms(_rtt_ewma_us.load(std::memory_order_relaxed)); + c.rtt_last_ms = ms(_rtt_last_us.load(std::memory_order_relaxed)); + c.rtt_min_ms = ms(_rtt_min_us.load(std::memory_order_relaxed)); + return c; +} + +std::string state_link_rate_meter::key_of(const state_link_report &r) { + return r.transport + ":" + r.remote_node_id + (r.accepted ? ":in" : ""); +} + +std::map +state_link_rate_meter::update(const std::vector &reports, double now_s) { + std::map out; + std::map next; + for (const state_link_report &r : reports) { + const std::string key = key_of(r); + rate rt; + auto it = _last.find(key); + // A link whose counters went DOWN is a new connection under the same key: start over. + if (it != _last.end() && now_s > it->second.t && r.stats.bytes_in >= it->second.bytes_in && + r.stats.bytes_out >= it->second.bytes_out) { + const double dt = now_s - it->second.t; + rt.in_bps = static_cast(r.stats.bytes_in - it->second.bytes_in) / dt; + rt.out_bps = static_cast(r.stats.bytes_out - it->second.bytes_out) / dt; + } + out[key] = rt; + next[key] = sample{r.stats.bytes_in, r.stats.bytes_out, now_s}; + } + _last.swap(next); // links no longer reported are forgotten + return out; +} + +} // namespace cvc diff --git a/src/cvc/state/state_transport_grpc.cpp b/src/cvc/state/state_transport_grpc.cpp index 82bc91fa9..89f1aeb46 100644 --- a/src/cvc/state/state_transport_grpc.cpp +++ b/src/cvc/state/state_transport_grpc.cpp @@ -27,6 +27,14 @@ namespace { namespace pb = ::cvc::transport::v1; +// Steady-clock nanoseconds: the link probe's clock (a Ping carries it; the Pong echoes it back to +// the same process, so no cross-host clock sync is involved) and the link report's age / idle base. +inline std::int64_t link_now_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + // Largest gRPC message we accept/send, in bytes. The gRPC default receive cap is ~4 MiB, too small // for a large binary message payload (a 1080p RGBA frame is ~8 MiB); match the IPC transport's // 64 MiB frame cap so both wire paths agree on the ceiling. @@ -223,13 +231,23 @@ class state_transport_grpc::connection { std::atomic alive{true}; std::string remote_node_id; std::string remote_cluster_id; + // 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; + bool accepted = false; + std::int64_t opened_ns = 0; + std::atomic last_rx_ns{0}; bool can_write() const { return static_cast(write_raw); } bool write_locked(const pb::Frame &f) { if (!alive.load() || !write_raw) return false; - return write_raw(f); + const std::size_t bytes = f.ByteSizeLong(); + if (!write_raw(f)) + return false; + link.note_tx(bytes); + return true; } bool write(const pb::Frame &f) { @@ -270,6 +288,9 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { } auto conn = std::make_shared(); + conn->accepted = true; + conn->opened_ns = link_now_ns(); + conn->last_rx_ns.store(conn->opened_ns); auto *raw_stream = stream; conn->write_raw = [raw_stream](const pb::Frame &f) -> bool { return raw_stream->Write(f); }; @@ -280,6 +301,8 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { pb::Frame in; while (!ctx->IsCancelled() && stream->Read(&in)) { _owner->increment_recv_frames(); + 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(); @@ -414,6 +437,7 @@ void state_transport_grpc::start(const std::string &listen_addr, const std::stri if (_heartbeat_interval.count() > 0) { _heartbeat_thread = std::thread([this]() { heartbeat_loop(); }); } + _ping_thread = std::thread([this]() { ping_loop(); }); } void state_transport_grpc::set_heartbeat_interval(std::chrono::milliseconds interval) noexcept { @@ -474,10 +498,14 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, // makes it visible to stop(), which joins client_reader — so the thread // has to already be assigned or stop() could miss it and leave a // joinable std::thread to destruct. + conn->opened_ns = link_now_ns(); + conn->last_rx_ns.store(conn->opened_ns); conn->client_reader = std::thread([this, conn]() { pb::Frame in; while (conn->alive.load() && conn->client_stream->Read(&in)) { increment_recv_frames(); + 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(); @@ -567,6 +595,8 @@ void state_transport_grpc::stop() { if (_heartbeat_thread.joinable()) _heartbeat_thread.join(); + if (_ping_thread.joinable()) + _ping_thread.join(); for (auto &c : conns) { if (c->client_reader.joinable()) @@ -1000,6 +1030,76 @@ std::size_t state_transport_grpc::shard_count() const { return _shards.size(); } +std::vector state_transport_grpc::link_reports() const { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = link_now_ns(); + std::vector out; + for (const auto &c : conns) { + if (!c || !c->alive.load()) + continue; + state_link_report r; + r.transport = "grpc"; + r.remote_node_id = c->remote_node_id; + 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; + r.stats = c->link.read(); + out.push_back(std::move(r)); + } + return out; +} + +bool state_transport_grpc::on_link_frame(connection &c, const pb::Frame &in) { + c.link.note_rx(in.ByteSizeLong()); + c.last_rx_ns.store(link_now_ns(), std::memory_order_relaxed); + if (in.has_ping()) { + pb::Frame f; + f.mutable_pong()->set_seq(in.ping().seq()); + f.mutable_pong()->set_sent_ns(in.ping().sent_ns()); + if (c.write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + return true; + } + if (in.has_pong()) { + c.link.note_rtt(link_now_ns() - in.pong().sent_ns()); + return true; + } + return false; +} + +void state_transport_grpc::send_pings() { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + for (auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + pb::Frame f; + f.mutable_ping()->set_seq(c->link.next_ping_seq()); + f.mutable_ping()->set_sent_ns(link_now_ns()); + if (c->write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + } +} + +void state_transport_grpc::ping_loop() { + auto next = std::chrono::steady_clock::now(); + while (_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + const auto interval = ping_interval(); + if (interval.count() <= 0 || std::chrono::steady_clock::now() < next) + continue; + next = std::chrono::steady_clock::now() + interval; + send_pings(); + } +} + std::size_t state_transport_grpc::connection_count() const { std::lock_guard lk(_conns_mu); std::size_t n = 0; diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index dddaf4d35..1e5f273b8 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -236,6 +236,11 @@ class state_transport_ws::connection { // reaper uses it to drop the ghost. 0 until the first frame; set to "now" when a connection opens // so a brand-new, not-yet-speaking client is not reaped. std::atomic last_rx_ns{0}; + // Link quality (state_link_stats): traffic both ways + the Ping/Pong round trip. `accepted` marks + // the server end; opened_ns (steady-clock) is the report's age base, set at registration. + state_link_stats link; + bool accepted = false; + std::int64_t opened_ns = 0; #ifndef __EMSCRIPTEN__ std::unique_ptr> ws; @@ -258,6 +263,7 @@ class state_transport_ws::connection { alive.store(false); return false; } + link.note_tx(bytes.size()); return true; } #else @@ -276,8 +282,11 @@ class state_transport_ws::connection { std::string bytes; if (!f.SerializeToString(&bytes)) return false; - return emscripten_websocket_send_binary(em_socket, const_cast(bytes.data()), - static_cast(bytes.size())) >= 0; + if (emscripten_websocket_send_binary(em_socket, const_cast(bytes.data()), + static_cast(bytes.size())) < 0) + return false; + link.note_tx(bytes.size()); + return true; } #endif @@ -311,6 +320,7 @@ struct state_transport_ws::impl { net::io_context ioc; std::unique_ptr acceptor; std::thread accept_thread; + std::thread ping_thread; // link probe (ping_loop), joined by stop() std::string listen_addr_resolved; }; #else @@ -396,6 +406,8 @@ void state_transport_ws::start(const std::string &listen_addr, const std::string _running.store(true); _impl->accept_thread = std::thread([this]() { accept_loop(); }); + if (!_ping_started.exchange(true)) + _impl->ping_thread = std::thread([this]() { ping_loop(); }); } // Server accept loop: synchronous accept → WebSocket handshake → admit + spawn reader. Breaks when @@ -411,6 +423,7 @@ void state_transport_ws::accept_loop() { break; // woken by stop()'s loopback self-connect auto conn = std::make_shared(); + conn->accepted = true; conn->ws = std::make_unique>(std::move(sock)); conn->ws->read_message_max(kMaxWsMessageBytes); conn->ws->accept(ec); // server-side WebSocket handshake @@ -457,6 +470,8 @@ bool state_transport_ws::connect_to_peer(const std::string &url, conn->reader = std::thread([this, conn]() { read_loop(conn); }); _running.store(true); // so stop() tears the client connection down admit_connection(conn); + if (!_ping_started.exchange(true)) + _impl->ping_thread = std::thread([this]() { ping_loop(); }); return true; } @@ -524,6 +539,9 @@ void state_transport_ws::stop() { if (_impl->accept_thread.joinable()) _impl->accept_thread.join(); + if (_impl->ping_thread.joinable()) + _impl->ping_thread.join(); + _ping_started.store(false); for (auto &c : conns) if (c->reader.joinable()) c->reader.join(); @@ -651,9 +669,24 @@ static std::int64_t steady_ns() { void state_transport_ws::deliver_inbound(connection *c, const void *data, std::size_t len) { increment_recv_frames(); c->last_rx_ns.store(steady_ns(), std::memory_order_relaxed); // liveness for the reaper + c->link.note_rx(len); pb::Frame in; if (!in.ParseFromArray(data, static_cast(len))) return; + // Link probe: answer a Ping at once (in a browser this runs in the WebSocket onmessage event, + // which a backgrounded tab still receives), and measure a Pong against our own clock. + if (in.has_ping()) { + pb::Frame f; + f.mutable_pong()->set_seq(in.ping().seq()); + f.mutable_pong()->set_sent_ns(in.ping().sent_ns()); + if (c->write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + return; + } + if (in.has_pong()) { + c->link.note_rtt(steady_ns() - in.pong().sent_ns()); + return; + } if (in.has_hello()) { c->remote_node_id = in.hello().node_id(); c->remote_cluster_id = in.hello().cluster_id(); @@ -955,6 +988,9 @@ std::size_t state_transport_ws::pump_shard(state_cluster_shard &shard) { } std::size_t state_transport_ws::pump_all() { +#ifdef __EMSCRIPTEN__ + send_pings_if_due(); // no ping thread in a browser: the frame pump drives the probe +#endif std::vector snap; { std::lock_guard lk(_shards_mu); @@ -1069,7 +1105,69 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * _bridge->publish_message(m); } +std::vector state_transport_ws::link_reports() const { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = steady_ns(); + std::vector out; + for (const auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + state_link_report r; + r.transport = "ws"; + r.remote_node_id = c->remote_node_id; + 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; + r.stats = c->link.read(); + out.push_back(std::move(r)); + } + return out; +} + +void state_transport_ws::send_pings_if_due() { + const auto interval = ping_interval(); + if (interval.count() <= 0) + return; + const std::int64_t now = steady_ns(); + std::int64_t due = _next_ping_ns.load(std::memory_order_relaxed); + if (now < due || + !_next_ping_ns.compare_exchange_strong( + due, now + std::chrono::duration_cast(interval).count())) + return; + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + for (auto &c : conns) { + // Skip a connection that cannot send yet, and one whose send buffer is badly backed up (the + // write would block the prober behind it; its RTT would only measure the backlog anyway). + if (!c || !c->alive.load() || !c->can_write() || c->send_backlog() > (1u << 20)) + continue; + pb::Frame f; + f.mutable_ping()->set_seq(c->link.next_ping_seq()); + f.mutable_ping()->set_sent_ns(steady_ns()); + if (c->write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + } +} + +void state_transport_ws::ping_loop() { +#ifndef __EMSCRIPTEN__ + while (_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + send_pings_if_due(); + } +#endif +} + void state_transport_ws::register_connection(std::shared_ptr conn) { + if (conn->opened_ns == 0) + conn->opened_ns = steady_ns(); std::lock_guard lk(_conns_mu); _conns.push_back(std::move(conn)); } diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index c9794452b..2bf85e32c 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -110,6 +110,7 @@ add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) 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(physics_codec_test physics_codec_test.cpp) add_executable(physics_field_test physics_field_test.cpp) @@ -311,6 +312,7 @@ set(TEST_TARGETS avatar_sim_test holon_test holon_sync_test + state_link_stats_test room_scene_test physics_codec_test physics_field_test @@ -1671,6 +1673,13 @@ target_link_libraries(holon_sync_test GTest::gtest_main ) +target_link_libraries(state_link_stats_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(room_scene_test PRIVATE cvc @@ -2086,6 +2095,7 @@ target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) 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(physics_codec_test PRIVATE cxx_std_17) target_compile_features(physics_field_test PRIVATE cxx_std_17) @@ -2546,6 +2556,7 @@ cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) 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(physics_codec_test) cvc_discover_tests(physics_field_test) diff --git a/src/cvc/tests/state_link_stats_test.cpp b/src/cvc/tests/state_link_stats_test.cpp new file mode 100644 index 000000000..6ec294030 --- /dev/null +++ b/src/cvc/tests/state_link_stats_test.cpp @@ -0,0 +1,85 @@ +/* + 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. +*/ + +// cvc::state_link_stats / state_link_rate_meter: the per-connection counters and round-trip math +// behind the transports' link reports, without a network. + +#include +#include +#include + +using cvc::state_link_rate_meter; +using cvc::state_link_report; +using cvc::state_link_stats; + +TEST(StateLinkStatsTest, CountsTrafficBothWays) { + state_link_stats s; + s.note_rx(100); + s.note_rx(50); + s.note_tx(7); + const auto c = s.read(); + EXPECT_EQ(c.bytes_in, 150u); + EXPECT_EQ(c.frames_in, 2u); + EXPECT_EQ(c.bytes_out, 7u); + EXPECT_EQ(c.frames_out, 1u); +} + +TEST(StateLinkStatsTest, RoundTripEwmaMinAndLast) { + state_link_stats s; + EXPECT_LT(s.read().rtt_ms, 0.0) << "no measurement before the first Pong"; + EXPECT_EQ(s.next_ping_seq(), 1u); + EXPECT_EQ(s.next_ping_seq(), 2u); + s.note_rtt(40'000'000); // 40 ms seeds the average + s.note_rtt(80'000'000); // 80 ms moves it a quarter of the way: 50 ms + s.note_rtt(-5); // a clock that went backwards is ignored + const auto c = s.read(); + EXPECT_EQ(c.pings_sent, 2u); + EXPECT_EQ(c.pongs_received, 2u); + EXPECT_DOUBLE_EQ(c.rtt_ms, 50.0); + EXPECT_DOUBLE_EQ(c.rtt_last_ms, 80.0); + EXPECT_DOUBLE_EQ(c.rtt_min_ms, 40.0); +} + +TEST(StateLinkStatsTest, RateMeterTurnsCountersIntoRates) { + state_link_rate_meter m; + state_link_report r; + r.transport = "ws"; + r.remote_node_id = "alice"; + r.accepted = true; + r.stats.bytes_in = 1000; + r.stats.bytes_out = 4000; + const std::string key = state_link_rate_meter::key_of(r); + EXPECT_EQ(key, "ws:alice:in"); + + auto first = m.update({r}, 10.0); + EXPECT_DOUBLE_EQ(first[key].in_bps, 0.0) << "no interval yet"; + + r.stats.bytes_in = 3000; // +2000 in 2 s + r.stats.bytes_out = 4400; // +400 in 2 s + auto second = m.update({r}, 12.0); + EXPECT_DOUBLE_EQ(second[key].in_bps, 1000.0); + EXPECT_DOUBLE_EQ(second[key].out_bps, 200.0); + + // A reconnect under the same key restarts its counters: no negative or bogus rate. + r.stats.bytes_in = 10; + r.stats.bytes_out = 10; + auto third = m.update({r}, 13.0); + EXPECT_DOUBLE_EQ(third[key].in_bps, 0.0); + EXPECT_DOUBLE_EQ(third[key].out_bps, 0.0); + + // A link that is no longer reported is forgotten. + auto gone = m.update({}, 14.0); + EXPECT_TRUE(gone.empty()); +} diff --git a/src/cvc/tests/state_transport_grpc_test.cpp b/src/cvc/tests/state_transport_grpc_test.cpp index f947561b2..0b10b37a9 100644 --- a/src/cvc/tests/state_transport_grpc_test.cpp +++ b/src/cvc/tests/state_transport_grpc_test.cpp @@ -561,6 +561,42 @@ TEST(StateTransportGrpcTest, ConnectionClosedReportsThePeersNodeId) { EXPECT_EQ(closed[0], "alice"); } +// The link probe: both ends Ping, the other end Pongs, and each measures the round trip on its own +// clock. Each side reports one link with its peer's node id, traffic both ways, and an RTT -- the +// same report a relay gets for a client and a server gets for a peer (delegation) server. +TEST(StateTransportGrpcTest, LinkProbeMeasuresRoundTripAtBothEnds) { + cvc::state_transport_grpc relay, client; + relay.set_ping_interval(std::chrono::milliseconds(30)); + client.set_ping_interval(std::chrono::milliseconds(30)); + relay.start("127.0.0.1:0", "relay", "C"); + client.start("127.0.0.1:0", "alice", "C"); + ASSERT_TRUE(client.connect_to_peer(relay.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_connected(client, relay, std::chrono::milliseconds(2000))); + const auto measured = [](const cvc::state_transport_grpc &t) { + const auto links = t.link_reports(); + return links.size() == 1 && links[0].stats.pongs_received >= 2 && links[0].stats.rtt_ms >= 0.0; + }; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!(measured(relay) && measured(client))) { + ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "no round trip measured"; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + const auto server_end = relay.link_reports().at(0); + const auto client_end = client.link_reports().at(0); + EXPECT_EQ(server_end.transport, "grpc"); + EXPECT_TRUE(server_end.accepted); + EXPECT_EQ(server_end.remote_node_id, "alice"); + EXPECT_FALSE(client_end.accepted); + EXPECT_EQ(client_end.remote_node_id, "relay"); + for (const auto &r : {server_end, client_end}) { + EXPECT_GT(r.stats.bytes_in, 0u); + EXPECT_GT(r.stats.bytes_out, 0u); + EXPECT_LT(r.stats.rtt_ms, 1000.0) << "loopback round trip"; + } + client.stop(); + relay.stop(); +} + TEST(StateTransportGrpcTest, DisconnectAfterStop) { cvc::state_transport_grpc tA, tB; tA.start("127.0.0.1:0", "A", "C"); diff --git a/src/cvc/tests/state_transport_ws_test.cpp b/src/cvc/tests/state_transport_ws_test.cpp index b2b8d6433..5e31b8790 100644 --- a/src/cvc/tests/state_transport_ws_test.cpp +++ b/src/cvc/tests/state_transport_ws_test.cpp @@ -254,6 +254,9 @@ TEST(StateTransportWsTest, RelayFansMediaFrameToOtherClient) { TEST(StateTransportWsTest, RelayReapsIdleConnection) { cvc::app aA; cvc::state_transport_ws S, tA; + // No link probe here: a client that answers Pings is NOT idle (see QuietClientAnsweringPings...). + S.set_ping_interval(std::chrono::milliseconds(0)); + tA.set_ping_interval(std::chrono::milliseconds(0)); S.start("127.0.0.1:0", "S", "C"); S.set_relay(true); std::mutex mu; @@ -295,6 +298,71 @@ TEST(StateTransportWsTest, RelayReapsIdleConnection) { S.stop(); } +// The link probe: both ends Ping, the other end Pongs, and each measures the round trip on its own +// clock. Each side reports one link with its peer's node id, traffic both ways, and an RTT. +TEST(StateTransportWsTest, LinkProbeMeasuresRoundTripAtBothEnds) { + cvc::app aA; + cvc::state_transport_ws S, tA; + S.set_ping_interval(std::chrono::milliseconds(30)); + tA.set_ping_interval(std::chrono::milliseconds(30)); + S.start("127.0.0.1:0", "S", "C"); + cvc::state_cluster_shard sA(aA, "C", "A"); + sA.attach(); + tA.register_shard(&sA); + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + const auto measured = [](const cvc::state_transport_ws &t) { + const auto links = t.link_reports(); + return links.size() == 1 && links[0].stats.pongs_received >= 2 && links[0].stats.rtt_ms >= 0.0; + }; + ASSERT_TRUE( + wait_until([&] { return measured(S) && measured(tA); }, std::chrono::milliseconds(3000))); + const auto server = S.link_reports().at(0); + const auto client = tA.link_reports().at(0); + EXPECT_EQ(server.transport, "ws"); + EXPECT_TRUE(server.accepted); + EXPECT_EQ(server.remote_node_id, "A"); + EXPECT_FALSE(client.accepted); + EXPECT_EQ(client.remote_node_id, "S"); + for (const auto &r : {server, client}) { + EXPECT_GT(r.stats.bytes_in, 0u); + EXPECT_GT(r.stats.bytes_out, 0u); + EXPECT_GE(r.stats.rtt_min_ms, 0.0); + EXPECT_LT(r.stats.rtt_ms, 1000.0) << "loopback round trip"; + EXPECT_GE(r.age_s, 0.0); + } + tA.stop(); + S.stop(); +} + +// A client that sends nothing of its own but still answers the relay's Pings is alive, so the idle +// reaper keeps it -- the backgrounded-browser-tab case (its WebSocket onmessage still fires and +// answers, while its timers are throttled). A client that cannot answer is still reaped +// (RelayReapsIdleConnection). +TEST(StateTransportWsTest, QuietClientAnsweringPingsIsNotReaped) { + cvc::app aA; + cvc::state_transport_ws S, tA; + S.set_ping_interval(std::chrono::milliseconds(20)); + tA.set_ping_interval(std::chrono::milliseconds(0)); // the client never initiates anything + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + std::atomic reaped{0}; + S.set_on_connection_closed([&](const std::string &) { reaped.fetch_add(1); }); + cvc::state_cluster_shard sA(aA, "C", "A"); + sA.attach(); + tA.register_shard(&sA); + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE( + wait_until([&] { return S.connection_count() >= 1; }, std::chrono::milliseconds(2000))); + for (int i = 0; i < 20; ++i) { // ~600 ms of reaping with a 150 ms idle limit + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + S.reap_idle(std::chrono::milliseconds(150)); + } + EXPECT_EQ(reaped.load(), 0) << "a client answering Pings is not a ghost"; + EXPECT_EQ(S.connection_count(), 1u); + tA.stop(); + S.stop(); +} + // Late-joiner bootstrap: a relay that holds the room tree in its own shard pushes a full snapshot // to each newly admitted connection, so a client that joins AFTER others are present still sees // them (live set_relay forwarding only carries writes made after the client connects). This is what From 5cca3d898ddbca3c53487b68cde8059a24632882 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 20:33:55 -0500 Subject: [PATCH 20/33] state_transport_grpc_test: leave only after the relay has read the Hello ConnectionClosedReportsThePeersNodeId stopped the client as soon as both ends counted a connection. Cancelling a gRPC stream drops frames the relay has not read yet, so the relay sometimes never saw the client's Hello -- and a stream that never identified itself is, by contract, not reported. The link probe's extra threads shifted the timing enough to expose it (about 1 run in 5 after ThreeEndpointFanOut). Wait until the relay's link report names the client. 20/20 repeats of the reproducing sequence pass. --- src/cvc/tests/state_transport_grpc_test.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/cvc/tests/state_transport_grpc_test.cpp b/src/cvc/tests/state_transport_grpc_test.cpp index 0b10b37a9..32bc51009 100644 --- a/src/cvc/tests/state_transport_grpc_test.cpp +++ b/src/cvc/tests/state_transport_grpc_test.cpp @@ -543,6 +543,16 @@ TEST(StateTransportGrpcTest, ConnectionClosedReportsThePeersNodeId) { client.start("127.0.0.1:0", "alice", "C"); ASSERT_TRUE(client.connect_to_peer(relay.listen_address(), std::chrono::milliseconds(2000))); ASSERT_TRUE(wait_connected(client, relay, std::chrono::milliseconds(2000))); + // Leave only once the relay has READ alice's Hello: a cancelled stream drops frames the relay has + // not read yet, and a stream that never identified itself is (by contract) not reported. + const auto hello_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + for (;;) { + const auto links = relay.link_reports(); + if (!links.empty() && links[0].remote_node_id == "alice") + break; + ASSERT_LT(std::chrono::steady_clock::now(), hello_deadline) << "the relay never saw alice"; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } client.stop(); // alice leaves const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); From 5025996372c4973913d6bf10c69b0d3344632316 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 20:34:53 -0500 Subject: [PATCH 21/33] arigl: single space before the include comment (changed-lines format gate) --- src/arigl/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index c42230536..0138ec5e5 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -58,7 +58,7 @@ #include #include #include -#include // std::random_device — the conference node id's per-launch suffix +#include // std::random_device — the conference node id's per-launch suffix #include // arigl info: the GL report, line by line #if CVC_ENABLE_WS || CVC_ENABLE_GRPC // Conference mode: a state transport joins a hosted room, chosen by the relay URL scheme — From a4e5e156a42b860dac4022ac809a8ac746adbee5 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 20:49:46 -0500 Subject: [PATCH 22/33] conference_roster: widen the ping column (a congested link reads 4 digits) --- examples/ariadne/documents/conference_roster.ari | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/ariadne/documents/conference_roster.ari b/examples/ariadne/documents/conference_roster.ari index 690b7e325..57a367dc1 100644 --- a/examples/ariadne/documents/conference_roster.ari +++ b/examples/ariadne/documents/conference_roster.ari @@ -94,7 +94,7 @@ windows: - window: Users id: users pos: [390, 40] - size: [410, 260] + size: [420, 260] closable: true children: - text: "in the room" @@ -102,7 +102,7 @@ windows: - separator - type: group id: users_head - layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } children: - text: "name" - text: "ping" @@ -115,7 +115,7 @@ windows: # swap: only one of the two is visible, so they share one cell). - type: group id: users_you - layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } children: - text: "" bind: conf.self @@ -136,7 +136,7 @@ windows: # Everyone else: one row per peer (peer.), filled by on_tick. - type: group repeat: (state-get "peer.count") - layout: { kind: grid, col_widths: [118, 54, 50, 50, 34, 34, 0] } + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } children: # Name: click to open + raise this peer's camera window (cam_); GREEN while talking. - text: "" From e0e774afe84fb283d01361a550cf11a089e781c8 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 21:24:16 -0500 Subject: [PATCH 23/33] Transports: a peer that stops reading can no longer hang the relay; RTT median Production stalled for everyone after a browser tab froze: a write to that client blocked once its TCP buffers filled, while holding the connection's write lock, so the relay's main loop (and the idle reaper that would have dropped the client, which runs on it) waited on the lock forever. The backlog thresholds only make that less likely; nothing bounded it. - Write watchdog (WS + gRPC): each connection records when a write starts; the probe thread closes a connection whose write has been blocked > 3 s (socket shutdown / stream TryCancel), so the write fails and the lock is freed. The client redials (arigl reconnect) and gets a fresh snapshot. - The probe never waits: Pings use try_write (skip a busy connection) and skip one with more than 64 KB unsent. - gRPC: the server handler's cancel hook is cleared under a mutex before its ServerContext goes away. - state_link_stats: rtt_ms is now the median of the last 8 round trips; the moving average carried a 14.8 s probe (answered after a frozen tab woke) for minutes. Tests: StateTransportWsTest.WriteToAPeerThatStopsReadingIsAbortedNotHung (raw peer that handshakes and never reads; 32 MB media frames), StateLinkStatsTest. MedianShrugsOffAStall. --- inc/cvc/state/state_link_stats.h | 18 +++++--- src/cvc/state/state_link_stats.cpp | 23 +++++++--- src/cvc/state/state_transport_grpc.cpp | 55 ++++++++++++++++++++++- src/cvc/state/state_transport_ws.cpp | 47 ++++++++++++++++++- src/cvc/tests/state_link_stats_test.cpp | 23 +++++++--- src/cvc/tests/state_transport_ws_test.cpp | 50 +++++++++++++++++++++ 6 files changed, 195 insertions(+), 21 deletions(-) diff --git a/inc/cvc/state/state_link_stats.h b/inc/cvc/state/state_link_stats.h index 0e7c47cf9..1be9aa63a 100644 --- a/inc/cvc/state/state_link_stats.h +++ b/inc/cvc/state/state_link_stats.h @@ -16,10 +16,12 @@ #ifndef __CVC_STATE_LINK_STATS_H__ #define __CVC_STATE_LINK_STATS_H__ +#include #include #include #include #include +#include #include #include @@ -35,9 +37,10 @@ namespace cvc { // measured the same way. Counters are lock-free: the reader thread notes inbound bytes, any writer // notes outbound bytes, and the pinger notes round trips concurrently. // -// RTT: rtt_ms is an exponentially weighted moving average (alpha 1/4) of the measured round trips, -// so one slow probe does not swing it; last and min are kept beside it. All negative until the -// first Pong. +// RTT: rtt_ms is the MEDIAN of the last kRttWindow round trips, so a one-off stall (a probe that +// waited while a browser tab was frozen) does not distort it for long -- an average would carry a +// multi-second outlier for dozens of probes. Last and min are kept beside it. All negative until +// the first Pong. class state_link_stats { public: void note_rx(std::size_t bytes) noexcept; @@ -55,12 +58,17 @@ class state_link_stats { }; counters read() const noexcept; + static constexpr int kRttWindow = 8; + private: std::atomic _bytes_in{0}, _bytes_out{0}; std::atomic _frames_in{0}, _frames_out{0}; std::atomic _pings{0}, _pongs{0}; - // RTTs in microseconds (atomic integers; -1 = none yet). - std::atomic _rtt_ewma_us{-1}, _rtt_last_us{-1}, _rtt_min_us{-1}; + // RTTs in microseconds (-1 = none yet): the recent window (ring) for the median, last, and min. + mutable std::mutex _rtt_mu; + std::array _rtt_ring{}; + int _rtt_count = 0, _rtt_next = 0; + std::atomic _rtt_last_us{-1}, _rtt_min_us{-1}; }; // One link as a transport reports it (state_transport::link_reports()). diff --git a/src/cvc/state/state_link_stats.cpp b/src/cvc/state/state_link_stats.cpp index 8f74d5e0e..85dc152ae 100644 --- a/src/cvc/state/state_link_stats.cpp +++ b/src/cvc/state/state_link_stats.cpp @@ -13,6 +13,7 @@ Lesser General Public License for more details. */ +#include #include namespace cvc { @@ -41,12 +42,11 @@ void state_link_stats::note_rtt(std::int64_t rtt_ns) noexcept { while ((mn < 0 || us < mn) && !_rtt_min_us.compare_exchange_weak(mn, us, std::memory_order_relaxed)) { } - // EWMA with alpha 1/4; the first sample seeds it. - std::int64_t avg = _rtt_ewma_us.load(std::memory_order_relaxed); - std::int64_t next; - do { - next = avg < 0 ? us : avg + (us - avg) / 4; - } while (!_rtt_ewma_us.compare_exchange_weak(avg, next, std::memory_order_relaxed)); + std::lock_guard lk(_rtt_mu); + _rtt_ring[_rtt_next] = us; + _rtt_next = (_rtt_next + 1) % kRttWindow; + if (_rtt_count < kRttWindow) + ++_rtt_count; } state_link_stats::counters state_link_stats::read() const noexcept { @@ -58,7 +58,16 @@ state_link_stats::counters state_link_stats::read() const noexcept { c.pings_sent = _pings.load(std::memory_order_relaxed); c.pongs_received = _pongs.load(std::memory_order_relaxed); const auto ms = [](std::int64_t us) { return us < 0 ? -1.0 : static_cast(us) / 1000.0; }; - c.rtt_ms = ms(_rtt_ewma_us.load(std::memory_order_relaxed)); + { + std::lock_guard lk(_rtt_mu); + if (_rtt_count > 0) { + std::array w = _rtt_ring; + std::sort(w.begin(), w.begin() + _rtt_count); + const int n = _rtt_count; + const std::int64_t med = (n % 2) ? w[n / 2] : (w[n / 2 - 1] + w[n / 2]) / 2; + c.rtt_ms = ms(med); + } + } c.rtt_last_ms = ms(_rtt_last_us.load(std::memory_order_relaxed)); c.rtt_min_ms = ms(_rtt_min_us.load(std::memory_order_relaxed)); return c; diff --git a/src/cvc/state/state_transport_grpc.cpp b/src/cvc/state/state_transport_grpc.cpp index 89f1aeb46..28f814847 100644 --- a/src/cvc/state/state_transport_grpc.cpp +++ b/src/cvc/state/state_transport_grpc.cpp @@ -13,6 +13,7 @@ #include #include +#include // the write watchdog's report #include #include #include @@ -237,6 +238,14 @@ class state_transport_grpc::connection { bool accepted = false; std::int64_t opened_ns = 0; std::atomic last_rx_ns{0}; + // Steady-clock ns when the write in progress started; 0 when none is. A peer that stops reading + // makes a stream Write block on flow control while holding write_mu, so every other writer to it + // would wait forever; the probe thread's watchdog cancels a stream whose write has been blocked + // too long (state_transport_grpc::ping_loop). `cancel` cancels the stream; the server handler + // clears it (under cancel_mu) before its ServerContext goes away. + std::atomic write_started_ns{0}; + std::mutex cancel_mu; + std::function cancel; bool can_write() const { return static_cast(write_raw); } @@ -244,12 +253,29 @@ class state_transport_grpc::connection { if (!alive.load() || !write_raw) return false; const std::size_t bytes = f.ByteSizeLong(); - if (!write_raw(f)) + write_started_ns.store(link_now_ns(), std::memory_order_relaxed); + const bool ok = write_raw(f); + write_started_ns.store(0, std::memory_order_relaxed); + if (!ok) return false; link.note_tx(bytes); return true; } + // 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); + return lk.owns_lock() && write_locked(f); + } + + // Cancel the stream: a blocked Write fails and frees write_mu (the write watchdog). + void abort_io() { + alive.store(false); + std::lock_guard lk(cancel_mu); + if (cancel) + cancel(); + } + bool write(const pb::Frame &f) { std::lock_guard lk(write_mu); return write_locked(f); @@ -291,6 +317,7 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { conn->accepted = true; conn->opened_ns = link_now_ns(); conn->last_rx_ns.store(conn->opened_ns); + conn->cancel = [ctx] { ctx->TryCancel(); }; auto *raw_stream = stream; conn->write_raw = [raw_stream](const pb::Frame &f) -> bool { return raw_stream->Write(f); }; @@ -354,6 +381,10 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { } conn->alive.store(false); + { + std::lock_guard lk(conn->cancel_mu); + conn->cancel = nullptr; // ctx dies with this handler + } _owner->unregister_connection(conn.get()); _owner->notify_connection_closed(conn->remote_node_id); return grpc::Status::OK; @@ -500,6 +531,10 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, // joinable std::thread to destruct. conn->opened_ns = link_now_ns(); conn->last_rx_ns.store(conn->opened_ns); + conn->cancel = [ctx = conn->client_ctx] { + if (ctx) + ctx->TryCancel(); + }; conn->client_reader = std::thread([this, conn]() { pb::Frame in; while (conn->alive.load() && conn->client_stream->Read(&in)) { @@ -1083,15 +1118,31 @@ void state_transport_grpc::send_pings() { pb::Frame f; f.mutable_ping()->set_seq(c->link.next_ping_seq()); f.mutable_ping()->set_sent_ns(link_now_ns()); - if (c->write(f)) + if (c->try_write(f)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } } void state_transport_grpc::ping_loop() { + // How long one write may block before its stream is cancelled (connection::write_started_ns). + constexpr std::int64_t kWriteStallNs = 3'000'000'000; auto next = std::chrono::steady_clock::now(); while (_running.load()) { std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = link_now_ns(); + 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()) { + std::fprintf(stderr, "[grpc] write to '%s' blocked > 3 s (peer not reading): closing\n", + c->remote_node_id.c_str()); + c->abort_io(); + } + } const auto interval = ping_interval(); if (interval.count() <= 0 || std::chrono::steady_clock::now() < next) continue; diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index 1e5f273b8..1bb191225 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -241,6 +241,12 @@ class state_transport_ws::connection { state_link_stats link; bool accepted = false; std::int64_t opened_ns = 0; + // Steady-clock ns when the write in progress started; 0 when none is. A peer that stops reading + // (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). + std::atomic write_started_ns{0}; #ifndef __EMSCRIPTEN__ std::unique_ptr> ws; @@ -258,7 +264,12 @@ class state_transport_ws::connection { return false; beast::error_code ec; ws->binary(true); + write_started_ns.store(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); ws->write(net::buffer(bytes), ec); + write_started_ns.store(0, std::memory_order_relaxed); if (ec) { alive.store(false); return false; @@ -266,6 +277,15 @@ class state_transport_ws::connection { link.note_tx(bytes.size()); return true; } + + // Unblock any read or write in progress and mark the connection dead (the write watchdog). + void abort_io() { + alive.store(false); + if (ws) { + beast::error_code ec; + ws->next_layer().shutdown(tcp::socket::shutdown_both, ec); + } + } #else // Browser WebSocket: the handle, a back-pointer to the owning transport (for the C callbacks), // and an `open` flag — the browser cannot send until onopen has fired. There is no reader thread; @@ -295,6 +315,12 @@ class state_transport_ws::connection { return write_locked(f); } + // Write only if no other write holds the connection right now (the link probe never waits). + bool try_write(const pb::Frame &f) { + std::unique_lock lk(write_mu, std::try_to_lock); + return lk.owns_lock() && 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 @@ -1146,21 +1172,38 @@ void state_transport_ws::send_pings_if_due() { for (auto &c : conns) { // Skip a connection that cannot send yet, and one whose send buffer is badly backed up (the // write would block the prober behind it; its RTT would only measure the backlog anyway). - if (!c || !c->alive.load() || !c->can_write() || c->send_backlog() > (1u << 20)) + if (!c || !c->alive.load() || !c->can_write() || c->send_backlog() > (64u << 10)) continue; pb::Frame f; f.mutable_ping()->set_seq(c->link.next_ping_seq()); f.mutable_ping()->set_sent_ns(steady_ns()); - if (c->write(f)) + if (c->try_write(f)) _sent_frames.fetch_add(1, std::memory_order_relaxed); } } 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(); + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = steady_ns(); + 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()) { + std::fprintf(stderr, "[ws] write to '%s' blocked > 3 s (peer not reading): closing\n", + c->remote_node_id.c_str()); + c->abort_io(); + } + } } #endif } diff --git a/src/cvc/tests/state_link_stats_test.cpp b/src/cvc/tests/state_link_stats_test.cpp index 6ec294030..207c9116e 100644 --- a/src/cvc/tests/state_link_stats_test.cpp +++ b/src/cvc/tests/state_link_stats_test.cpp @@ -36,22 +36,35 @@ TEST(StateLinkStatsTest, CountsTrafficBothWays) { EXPECT_EQ(c.frames_out, 1u); } -TEST(StateLinkStatsTest, RoundTripEwmaMinAndLast) { +TEST(StateLinkStatsTest, RoundTripMedianMinAndLast) { state_link_stats s; EXPECT_LT(s.read().rtt_ms, 0.0) << "no measurement before the first Pong"; EXPECT_EQ(s.next_ping_seq(), 1u); EXPECT_EQ(s.next_ping_seq(), 2u); - s.note_rtt(40'000'000); // 40 ms seeds the average - s.note_rtt(80'000'000); // 80 ms moves it a quarter of the way: 50 ms + s.note_rtt(40'000'000); // 40 ms + s.note_rtt(80'000'000); // 80 ms: median of two = their mean, 60 ms s.note_rtt(-5); // a clock that went backwards is ignored - const auto c = s.read(); + auto c = s.read(); EXPECT_EQ(c.pings_sent, 2u); EXPECT_EQ(c.pongs_received, 2u); - EXPECT_DOUBLE_EQ(c.rtt_ms, 50.0); + EXPECT_DOUBLE_EQ(c.rtt_ms, 60.0); EXPECT_DOUBLE_EQ(c.rtt_last_ms, 80.0); EXPECT_DOUBLE_EQ(c.rtt_min_ms, 40.0); } +// One stall (a probe answered only after a frozen tab woke) barely moves the median, and leaves the +// window entirely after kRttWindow more samples. +TEST(StateLinkStatsTest, MedianShrugsOffAStall) { + state_link_stats s; + for (int i = 0; i < 4; ++i) + s.note_rtt(50'000'000); + s.note_rtt(14'800'000'000); // a 14.8 s stall + for (int i = 0; i < 3; ++i) + s.note_rtt(50'000'000); + EXPECT_DOUBLE_EQ(s.read().rtt_ms, 50.0) << "7 of 8 recent samples are 50 ms"; + EXPECT_DOUBLE_EQ(s.read().rtt_last_ms, 50.0); +} + TEST(StateLinkStatsTest, RateMeterTurnsCountersIntoRates) { state_link_rate_meter m; state_link_report r; diff --git a/src/cvc/tests/state_transport_ws_test.cpp b/src/cvc/tests/state_transport_ws_test.cpp index 5e31b8790..03863305b 100644 --- a/src/cvc/tests/state_transport_ws_test.cpp +++ b/src/cvc/tests/state_transport_ws_test.cpp @@ -13,6 +13,10 @@ // (the browser topology — clients that dial without a local acceptor, as a wasm peer will). #include +#include +#include +#include +#include #include #include #include @@ -363,6 +367,52 @@ TEST(StateTransportWsTest, QuietClientAnsweringPingsIsNotReaped) { S.stop(); } +// A peer that stops reading (a frozen browser tab, a dead network) must not hang the relay: a write +// to it blocks once its TCP buffers fill, holding the connection's write lock, so without the write +// watchdog every later writer to it -- the relay's main loop included -- waits forever (seen in +// production). The watchdog closes a connection whose write has been blocked > 3 s: the write +// returns and the connection is gone. +TEST(StateTransportWsTest, WriteToAPeerThatStopsReadingIsAbortedNotHung) { + namespace beast = boost::beast; + namespace asio = boost::asio; + cvc::state_transport_ws S; + S.set_ping_interval(std::chrono::milliseconds(0)); // the watchdog still runs; no probe 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); + + // A raw WebSocket peer: completes the handshake, then never reads. + 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))); + + std::atomic returned{false}; + std::thread writer([&] { + cvc::ws_media_frame m; + m.origin_node_id = "S"; + m.stream_id = "camera"; + m.codec = "mjpeg"; + m.payload.assign(std::size_t(32) << 20, 0x5a); // far beyond any socket buffer + S.publish_media_frame(m); + S.publish_media_frame(m); + returned.store(true); + }); + EXPECT_TRUE(wait_until([&] { return returned.load(); }, std::chrono::milliseconds(10000))) + << "the write to a non-reading peer never returned (the relay would be hung)"; + EXPECT_TRUE( + wait_until([&] { return S.connection_count() == 0; }, std::chrono::milliseconds(5000))) + << "the stalled connection was not closed"; + writer.join(); + beast::error_code ec; + peer.next_layer().close(ec); + S.stop(); +} + // Late-joiner bootstrap: a relay that holds the room tree in its own shard pushes a full snapshot // to each newly admitted connection, so a client that joins AFTER others are present still sees // them (live set_relay forwarding only carries writes made after the client connects). This is what From a279d0198d49fa3949dcf5ef23ca61853d38c49c Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 22:14:02 -0500 Subject: [PATCH 24/33] Room: click-to-face, name labels, stamped intents and a client clock lead Facing: W/S walk forward/back along the avatar's facing and A/D strafe; a click on the scene (not a drag) turns the avatar toward the clicked ground point. avatar_intent carries a yaw, replicated as intent.yaw, and the sim faces it; each marker gets a visor so facing is visible. The room-set-intent verb takes an optional YAW (default: face the walk). Labels: each peer's roster name is drawn above its marker, through a new ImGuiBackend frame hook so it runs in cvcGL's ImGui context. Timing: a proposed intent is stamped with the tick the client predicts it from (intent.tick); the server holon queues it and applies it at that tick. The client clock runs ahead of the server by the measured RTT plus a margin, so intents arrive before their tick and the authoritative pose matches the prediction. The relay steps the room at ~20 Hz and write_pose writes only changed leaves plus a ~1 s heartbeat. The holon now ignores a pose sample it has already seen (a re-read of an unchanged pose would otherwise drag the clock back), and peer walk spans are capped. --- inc/cvc/ariadne/avatar_sim.h | 19 ++- inc/cvc/ariadne/holon.h | 60 ++++++- inc/cvc/ariadne/holon_sync.h | 37 +++-- inc/cvc/gl/CameraController.h | 8 + inc/cvc/gl/ariadne/ImGuiBackend.h | 7 + inc/cvc/gl/ariadne/room_view.h | 38 ++++- src/arigl/main.cpp | 60 +++++-- src/arigl/web/cyprty.html | 3 +- src/arisrv/main.cpp | 66 ++++---- src/cvc/ariadne/avatar_sim.cpp | 4 + .../ariadne/conference/room_intrinsics.cpp | 8 +- src/cvc/ariadne/holon.cpp | 64 ++++++-- src/cvc/ariadne/holon_sync.cpp | 91 ++++++++--- src/cvc/tests/holon_sync_test.cpp | 74 +++++++++ src/cvc/tests/holon_test.cpp | 147 ++++++++++++++++++ src/cvcGL/CameraController.cpp | 55 ++++++- src/cvcGL/ariadne/ImGuiBackend.cpp | 11 +- src/cvcGL/ariadne/room_view.cpp | 128 +++++++++++++-- src/cvcGL/test/cvcgl_room_view.cpp | 84 ++++++++-- 19 files changed, 819 insertions(+), 145 deletions(-) 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/holon.h b/inc/cvc/ariadne/holon.h index 9b3e918f0..1b4f93e45 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -23,8 +23,10 @@ #include #include #include +#include #include #include +#include #include namespace cvc { @@ -70,6 +72,18 @@ 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; }; class holon { @@ -103,23 +117,45 @@ class holon { std::vector participants() 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). void set_intent(const std::string &pid, const avatar_intent &in); + // 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); + + // 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). + // // 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); @@ -154,6 +190,15 @@ class holon { bool local = false; 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 @@ -174,7 +219,8 @@ 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 + 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) }; } // namespace ariadne diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index 5ca3f375a..63a355d1b 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -43,6 +43,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 +63,14 @@ 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 (touch-before-value). 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 writes every leaf every call. 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 +78,24 @@ 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); // 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 @@ -100,9 +112,10 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d 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/ariadne/ImGuiBackend.h b/inc/cvc/gl/ariadne/ImGuiBackend.h index 5dbc81b64..44098be67 100644 --- a/inc/cvc/gl/ariadne/ImGuiBackend.h +++ b/inc/cvc/gl/ariadne/ImGuiBackend.h @@ -108,6 +108,12 @@ class ImGuiBackend : public cvc::ariadne::Backend { // rt.set_backend(this). `rt` must outlive the overlay's callback. void install(cvc::ariadne::Runtime &rt, ImGuiOverlay &overlay); + // Extra per-frame overlay drawing, run inside the ImGui frame right after the document renders + // (in registration order) -- e.g. name labels over a 3D scene. The hook runs in cvcGL's ImGui + // context, so it must call cvcGL code that draws (raw ImGui:: calls from another binary module + // would hit a separate, uninitialised context). + void add_frame_hook(std::function hook); + // Make `ctx` the current Dear ImGui context of the module this backend runs in (libcvcGL), for a // host that created the context itself rather than through ImGuiOverlay. Where imgui is a static // library linked into both the host and libcvcGL (the cvcpkg Windows imgui), each copy keeps its @@ -157,6 +163,7 @@ class ImGuiBackend : public cvc::ariadne::Backend { // Host-registered raw-ImGui draw fns for custom widget types (register_widget). std::unordered_map m_customWidgets; + std::vector> m_frameHooks; // add_frame_hook, run after the document // Host-published images (Kind::Image). Each keeps its source cvc::image + a lazily-created GL // texture; `dirty` forces a re-upload after set_image. Textures are process-lifetime (freed at diff --git a/inc/cvc/gl/ariadne/room_view.h b/inc/cvc/gl/ariadne/room_view.h index 038ba4a61..5fbcc1016 100644 --- a/inc/cvc/gl/ariadne/room_view.h +++ b/inc/cvc/gl/ariadne/room_view.h @@ -13,8 +13,12 @@ // 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. +// * 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, run inside the host's ImGui frame). +// * 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. // // Owner/render thread, like the SceneGraph it draws into. @@ -36,13 +40,16 @@ class SceneRenderer; 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: @@ -63,6 +70,17 @@ class room_view { // until the holon has the local avatar. 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 foreground + // (call it from inside the frame, e.g. an ImGuiBackend frame hook). 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,6 +101,7 @@ class room_view { void sync_scene(); void clear_scene(); void sync_avatars(cvc::ariadne::holon &h, double wall_dt); + void sync_names(); SceneRenderer &_view; cvc::app &_app; @@ -91,6 +110,9 @@ class room_view { 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 + 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}; diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 4179e5fce..54aa8a227 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -318,6 +318,10 @@ struct ConferenceSession { std::vector drawn_pids_; // avatars drawn at the last log (log on change) 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. Starts as the camera's heading when we are framed, + // so W first walks "into" the screen. + double facing_yaw_ = 0.0; static constexpr double kWalkSpeed = 2.0; // m/s static constexpr double kEyeHeight = 1.2; // orbit centre above the avatar's feet (m) @@ -598,10 +602,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 +641,23 @@ 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::ImGuiBackend &backend) { if (!active || !driver_ || !app_) return; holon_ = std::make_unique(cvc::ariadne::holon_role::client); + 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()); cam_ = &cam; + // Peers' names over their markers, drawn inside the ImGui frame (after the document's windows). + backend.add_frame_hook([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,22 +665,31 @@ 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]); + } // 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; + in.yaw = facing_yaw_; if (cam_->mode() == cvc::gl::CameraController::Mode::Orbit) { - 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); }; 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) { + in.move.z != last_intent_.move.z || in.yaw != last_intent_.yaw) { cvc::ariadne::propose_intent(*holon_, *app_, room_, pid_, in); last_intent_ = in; intent_proposed_ = true; @@ -680,6 +718,9 @@ struct ConferenceSession { // Frame the room around you, not just you: arriving at a party, you want to see who's here. cam_->frameBounds(p[0] - 8.0, p[1] - 8.0, p[2], p[0] + 8.0, p[1] + 8.0, p[2] + 2.0); framed_ = true; + double eye[3], focal[3], up[3]; + cam_->getPose(eye, focal, up); + facing_yaw_ = gla::heading_yaw(eye, focal); // face into the screen, so W walks away } const double c[3] = {p[0], p[1], p[2] + kEyeHeight}; if (std::fabs(c[0] - orbit_center_[0]) + std::fabs(c[1] - orbit_center_[1]) + @@ -1091,7 +1132,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::ImGuiBackend &) { + } void pump() {} void stop() {} #endif @@ -1166,7 +1208,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, rt.backend()); // 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/main.cpp b/src/arisrv/main.cpp index 3fa45e01c..74b7cc4fd 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -189,13 +189,37 @@ 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). + // 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 fan_local_writes. 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); + 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. +#if CVC_ENABLE_WS + // 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. + const auto fan_local_writes = [&] { + auto pending = shard.drain_local(); + for (const auto &m : pending) { + state_tp.publish(m); + ws_tp.publish(m); + } + }; +#else + // pump_all publishes only our local writes, never the clients' replicated mutations (those ride + // set_relay forwarding), so this cannot echo. + const auto fan_local_writes = [&] { state_tp.pump_all(); }; +#endif 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; @@ -318,28 +342,7 @@ int main(int argc, char **argv) { } std::fflush(stdout); } -#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(); -#endif + fan_local_writes(); 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,7 +379,14 @@ 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); + fan_local_writes(); + } + std::this_thread::sleep_for(kRoomStep); } std::printf("[arisrv] shutting down (relayed %zu stream(s), %llu frames forwarded)\n", 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_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/holon.cpp b/src/cvc/ariadne/holon.cpp index 7ca50f2f2..d15fbbb55 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -13,6 +13,7 @@ Lesser General Public License for more details. */ +#include #include #include @@ -69,6 +70,30 @@ void holon::set_intent(const std::string &pid, const avatar_intent &in) { if (_role == holon_role::client && !it->second.local) return; it->second.intent = in; + it->second.queued.clear(); +} + +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; + p.queued.emplace_back(at, in); } void holon::apply_authoritative(const std::string &pid, const cvc::physics::body_state &auth, @@ -78,14 +103,23 @@ 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; // 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) { + _clock.seek_tick(target); for (auto &kv : _participants) { participant &q = kv.second; if (q.local) { @@ -94,11 +128,10 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body } 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); } } } @@ -130,7 +163,7 @@ 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(); @@ -143,10 +176,17 @@ void holon::on_step(const cvc::clock_driver::clock_frame &f) { 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. diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index bbb91603d..aca009c6f 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -13,6 +13,7 @@ Lesser General Public License for more details. */ +#include #include #include #include @@ -68,25 +69,50 @@ bool node_present(cvc::app &app, const std::string &path) { return app.root().findDescendant(path) != nullptr; } +// set_replicated unless the leaf already holds VAL; true when it wrote. +bool set_if_changed(cvc::app &app, const std::string &path, const std::string &val) { + if (cvc::state *n = app.root().findDescendant(path); n && n->value() == val) + return false; + set_replicated(app, path, val); + 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) { + 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_replicated(app, path, fmt(v)), changed = true; + else + changed = set_if_changed(app, path, fmt(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_replicated(app, tick_path, std::to_string(static_cast(server_tick))); } bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, @@ -114,19 +140,27 @@ bool read_pose(cvc::app &app, const std::string &room, const std::string &pid, } 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; } @@ -146,8 +180,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,9 +199,12 @@ 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, @@ -187,8 +229,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/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index ff0268473..a9bb33b7a 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -273,3 +273,77 @@ TEST(HolonSyncTest, ClientPumpPredictsSelfAndWalksPeersSmoothly) { s->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); +} diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index 8409eb3c0..961c49320 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,7 @@ #include #include #include +#include using cvc::ariadne::avatar_intent; using cvc::ariadne::avatar_sim; @@ -304,3 +306,148 @@ 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); +} + +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"; +} 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/ariadne/ImGuiBackend.cpp b/src/cvcGL/ariadne/ImGuiBackend.cpp index a43cb1b66..dc97dbe87 100644 --- a/src/cvcGL/ariadne/ImGuiBackend.cpp +++ b/src/cvcGL/ariadne/ImGuiBackend.cpp @@ -732,7 +732,16 @@ ariadne::CustomEdit ImGuiBackend::custom_widget(const char *type, const std::str void ImGuiBackend::install(cvc::ariadne::Runtime &rt, ImGuiOverlay &overlay) { cvc::ariadne::Runtime *r = &rt; - overlay.setDrawCallback([r] { r->render(); }); + overlay.setDrawCallback([this, r] { + r->render(); + for (const auto &hook : m_frameHooks) + if (hook) + hook(); + }); +} + +void ImGuiBackend::add_frame_hook(std::function hook) { + m_frameHooks.push_back(std::move(hook)); } void ImGuiBackend::setImGuiContext(ImGuiContext *ctx) { diff --git a/src/cvcGL/ariadne/room_view.cpp b/src/cvcGL/ariadne/room_view.cpp index f55191c70..099f8430c 100644 --- a/src/cvcGL/ariadne/room_view.cpp +++ b/src/cvcGL/ariadne/room_view.cpp @@ -2,6 +2,7 @@ #include #include +#include // room_roster #include #include #include @@ -13,6 +14,9 @@ #include #include #include +#ifdef CVC_ENABLE_IMGUI +#include +#endif namespace cvc { namespace gl { @@ -25,6 +29,17 @@ 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; + +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 +93,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,6 +109,13 @@ 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)), @@ -108,8 +124,10 @@ room_view::room_view(SceneRenderer &view, cvc::app &app, std::string room, std:: 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)); + } } std::vector room_view::take_warnings() { @@ -121,6 +139,17 @@ std::vector room_view::take_warnings() { void room_view::update(cvc::ariadne::holon &h, double wall_dt) { sync_scene(); sync_avatars(h, wall_dt); + sync_names(); +} + +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() { @@ -173,6 +202,8 @@ void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { 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,7 +227,12 @@ 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; } @@ -212,12 +248,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 +280,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::GetForegroundDrawList(); + 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/test/cvcgl_room_view.cpp b/src/cvcGL/test/cvcgl_room_view.cpp index d9596b51d..dbc1fd1cd 100644 --- a/src/cvcGL/test/cvcgl_room_view.cpp +++ b/src/cvcGL/test/cvcgl_room_view.cpp @@ -1,8 +1,9 @@ // 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, and teardown removes every node +// room_view made. #include #include #include @@ -15,13 +16,16 @@ #include #include #include +#include #include +#include using cvc::ariadne::avatar_intent; using cvc::ariadne::holon; using cvc::ariadne::holon_role; 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; @@ -49,22 +53,40 @@ 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 ───────── @@ -125,6 +147,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 +200,12 @@ 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"); } printf("%s (%d failure%s)\n", fails ? "FAILED" : "OK", fails, fails == 1 ? "" : "s"); From fd70013935cf3b0c677c8ac8f3e8990e5f73522e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 22:32:57 -0500 Subject: [PATCH 25/33] Room: name labels are a scene annotation, drawn even with the panels hidden ImGuiOverlay gains setSceneDrawCallback: run every frame, panels open or not, before the UI, so labels drawn into the background draw list sit under the document's windows. This replaces the ImGuiBackend frame hook, whose callback only ran while the panels were open (a phone starts with them hidden). --- inc/cvc/gl/ImGuiOverlay.h | 6 ++++++ inc/cvc/gl/ariadne/ImGuiBackend.h | 7 ------- inc/cvc/gl/ariadne/room_view.h | 11 ++++++----- src/arigl/main.cpp | 11 ++++++----- src/cvcGL/ImGuiOverlay.cpp | 7 +++++++ src/cvcGL/ariadne/ImGuiBackend.cpp | 11 +---------- src/cvcGL/ariadne/room_view.cpp | 4 ++-- 7 files changed, 28 insertions(+), 29 deletions(-) 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/ariadne/ImGuiBackend.h b/inc/cvc/gl/ariadne/ImGuiBackend.h index 44098be67..5dbc81b64 100644 --- a/inc/cvc/gl/ariadne/ImGuiBackend.h +++ b/inc/cvc/gl/ariadne/ImGuiBackend.h @@ -108,12 +108,6 @@ class ImGuiBackend : public cvc::ariadne::Backend { // rt.set_backend(this). `rt` must outlive the overlay's callback. void install(cvc::ariadne::Runtime &rt, ImGuiOverlay &overlay); - // Extra per-frame overlay drawing, run inside the ImGui frame right after the document renders - // (in registration order) -- e.g. name labels over a 3D scene. The hook runs in cvcGL's ImGui - // context, so it must call cvcGL code that draws (raw ImGui:: calls from another binary module - // would hit a separate, uninitialised context). - void add_frame_hook(std::function hook); - // Make `ctx` the current Dear ImGui context of the module this backend runs in (libcvcGL), for a // host that created the context itself rather than through ImGuiOverlay. Where imgui is a static // library linked into both the host and libcvcGL (the cvcpkg Windows imgui), each copy keeps its @@ -163,7 +157,6 @@ class ImGuiBackend : public cvc::ariadne::Backend { // Host-registered raw-ImGui draw fns for custom widget types (register_widget). std::unordered_map m_customWidgets; - std::vector> m_frameHooks; // add_frame_hook, run after the document // Host-published images (Kind::Image). Each keeps its source cvc::image + a lazily-created GL // texture; `dirty` forces a re-upload after set_image. Textures are process-lifetime (freed at diff --git a/inc/cvc/gl/ariadne/room_view.h b/inc/cvc/gl/ariadne/room_view.h index 5fbcc1016..e2117ad45 100644 --- a/inc/cvc/gl/ariadne/room_view.h +++ b/inc/cvc/gl/ariadne/room_view.h @@ -15,7 +15,7 @@ // as a glide rather than a jump. // * 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, run inside the host's ImGui frame). +// (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. @@ -75,10 +75,11 @@ class room_view { // 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 foreground - // (call it from inside the frame, e.g. an ImGuiBackend frame hook). 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. + // 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). diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 54aa8a227..d49a43074 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -645,7 +645,7 @@ struct ConferenceSession { // 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::ImGuiBackend &backend) { + cvc::gl::ImGuiOverlay &ui) { if (!active || !driver_ || !app_) return; holon_ = std::make_unique(cvc::ariadne::holon_role::client); @@ -653,8 +653,9 @@ struct ConferenceSession { room_view_ = std::make_unique(view, *app_, room_, pid_, view.scene().getStatePrefix()); cam_ = &cam; - // Peers' names over their markers, drawn inside the ImGui frame (after the document's windows). - backend.add_frame_hook([this] { + // 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(); }); @@ -1132,7 +1133,7 @@ struct ConferenceSession { "document locally.\n"); return false; } - void attach_view(cvc::gl::SceneRenderer &, cvc::gl::CameraController &, cvc::gl::ImGuiBackend &) { + void attach_view(cvc::gl::SceneRenderer &, cvc::gl::CameraController &, cvc::gl::ImGuiOverlay &) { } void pump() {} void stop() {} @@ -1208,7 +1209,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, rt.backend()); // 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/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/ariadne/ImGuiBackend.cpp b/src/cvcGL/ariadne/ImGuiBackend.cpp index dc97dbe87..a43cb1b66 100644 --- a/src/cvcGL/ariadne/ImGuiBackend.cpp +++ b/src/cvcGL/ariadne/ImGuiBackend.cpp @@ -732,16 +732,7 @@ ariadne::CustomEdit ImGuiBackend::custom_widget(const char *type, const std::str void ImGuiBackend::install(cvc::ariadne::Runtime &rt, ImGuiOverlay &overlay) { cvc::ariadne::Runtime *r = &rt; - overlay.setDrawCallback([this, r] { - r->render(); - for (const auto &hook : m_frameHooks) - if (hook) - hook(); - }); -} - -void ImGuiBackend::add_frame_hook(std::function hook) { - m_frameHooks.push_back(std::move(hook)); + overlay.setDrawCallback([r] { r->render(); }); } void ImGuiBackend::setImGuiContext(ImGuiContext *ctx) { diff --git a/src/cvcGL/ariadne/room_view.cpp b/src/cvcGL/ariadne/room_view.cpp index 099f8430c..f9dd6296e 100644 --- a/src/cvcGL/ariadne/room_view.cpp +++ b/src/cvcGL/ariadne/room_view.cpp @@ -311,8 +311,8 @@ void room_view::draw_labels() const { 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::GetForegroundDrawList(); + 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; From 76ff3126554e5439fce3c1e1d850f7a84c4b0992 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 22:46:56 -0500 Subject: [PATCH 26/33] Room: the conference document declares the light rig and real-time shadows conference_roster.ari gains a scene: block: a three-point StageLighting rig staged over the 20 m floor whose key spot casts shadows from the scene objects and every avatar, re-baked each frame so a moving avatar's shadow moves with it. The relay's default room env no longer carries a sun: the room owns what is in it, the client document owns how it looks. The room env can still turn shadows on (scene.env.shadows.{enabled, resolution}, replicated); read_room_scene asserts them only when a room does, so a room that leaves them off never switches off the document's. --- .../ariadne/documents/conference_roster.ari | 21 +++++++++++ inc/cvc/ariadne/conference/room_scene.h | 12 +++++- src/cvc/ariadne/conference/room_scene.cpp | 27 ++++++++++---- src/cvc/tests/room_scene_test.cpp | 37 ++++++++++++++++--- src/cvcGL/test/cvcgl_room_view.cpp | 9 +++++ 5 files changed, 92 insertions(+), 14 deletions(-) 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/conference/room_scene.h b/inc/cvc/ariadne/conference/room_scene.h index 22d067769..e9cc18c3f 100644 --- a/inc/cvc/ariadne/conference/room_scene.h +++ b/inc/cvc/ariadne/conference/room_scene.h @@ -38,6 +38,7 @@ 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, @@ -64,6 +65,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; @@ -97,8 +104,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(); diff --git a/src/cvc/ariadne/conference/room_scene.cpp b/src/cvc/ariadne/conference/room_scene.cpp index ad2398850..5943918db 100644 --- a/src/cvc/ariadne/conference/room_scene.cpp +++ b/src/cvc/ariadne/conference/room_scene.cpp @@ -13,6 +13,7 @@ Lesser General Public License for more details. */ +#include #include #include #include @@ -170,6 +171,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 +194,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)) @@ -279,6 +286,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 +313,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; } diff --git a/src/cvc/tests/room_scene_test.cpp b/src/cvc/tests/room_scene_test.cpp index 8fef743a1..46788c25c 100644 --- a/src/cvc/tests/room_scene_test.cpp +++ b/src/cvc/tests/room_scene_test.cpp @@ -44,6 +44,12 @@ 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; +} } // namespace // ── one app: the schema round-trips ───────────────────────────────────────── @@ -75,6 +81,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 +91,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 +111,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 +124,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 +249,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 +277,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 +292,16 @@ TEST(RoomSceneTest, SeededSceneReplicatesToAClientAndTracksEdits) { EXPECT_EQ(objs[0].id, "ref"); EXPECT_FLOAT_EQ(objs[0].position[0], 4.0f); } + +// 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/cvcGL/test/cvcgl_room_view.cpp b/src/cvcGL/test/cvcgl_room_view.cpp index dbc1fd1cd..f61acfa7c 100644 --- a/src/cvcGL/test/cvcgl_room_view.cpp +++ b/src/cvcGL/test/cvcgl_room_view.cpp @@ -107,6 +107,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); From a10692bb13717ff867724c95f1722ac4f7c24cf2 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 23:19:04 -0500 Subject: [PATCH 27/33] Room: rigid-body avatars in a Jolt world Avatars are now Jolt bodies (cvc::physics::world, cross-platform determinism): a dynamic box with a mass, under gravity, colliding with each other and with the room's statics (the floor slab, the cube, any box or sphere in the room scene). An intent drives its avatar: the horizontal velocity eases toward the intent's at up to max_accel, and the body is held upright at its facing; pushes, stops and falls are the solver's. An avatar that falls off the world respawns where it joined. The relay's holon simulates every avatar. A client's holon predicts its own avatar in a local world with each peer as a kinematic body at the pose it is shown, and on a divergence snaps to the authority and replays its logged intents through that world. holon_sync builds the statics from the room scene (room_statics) and reloads them when the scene revision changes; room_holon_config() is the configuration the relay and every client share. A build without the Jolt backend keeps the kinematic avatar_sim path, with a warning. write_pose's changed-only test is now numeric (1e-4): a resting rigid body jitters in its last float bits every step, which is not a change. --- inc/cvc/ariadne/avatar_physics.h | 125 +++++++++++++++++++++ inc/cvc/ariadne/holon.h | 42 ++++++- inc/cvc/ariadne/holon_sync.h | 15 ++- src/arigl/main.cpp | 3 +- src/arisrv/main.cpp | 5 +- src/cvc/CMakeLists.txt | 3 +- src/cvc/ariadne/avatar_physics.cpp | 174 +++++++++++++++++++++++++++++ src/cvc/ariadne/holon.cpp | 118 ++++++++++++++++++- src/cvc/ariadne/holon_sync.cpp | 73 +++++++++++- src/cvc/tests/holon_sync_test.cpp | 17 +++ src/cvc/tests/holon_test.cpp | 166 +++++++++++++++++++++++++++ 11 files changed, 728 insertions(+), 13 deletions(-) create mode 100644 inc/cvc/ariadne/avatar_physics.h create mode 100644 src/cvc/ariadne/avatar_physics.cpp diff --git a/inc/cvc/ariadne/avatar_physics.h b/inc/cvc/ariadne/avatar_physics.h new file mode 100644 index 000000000..764549feb --- /dev/null +++ b/inc/cvc/ariadne/avatar_physics.h @@ -0,0 +1,125 @@ +/* + 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); + + 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; + }; + std::uint32_t create_avatar(const cvc::physics::body_state &feet, bool dynamic); + + 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/holon.h b/inc/cvc/ariadne/holon.h index 1b4f93e45..7a60be1f8 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -17,6 +17,7 @@ #define __CVC_ARIADNE_HOLON_H__ #include +#include #include #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -84,6 +86,15 @@ struct holon_config { // 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; }; class holon { @@ -131,6 +142,15 @@ class holon { // 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 @@ -209,8 +229,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); @@ -219,8 +255,10 @@ 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 - std::uint64_t _lead = 0; // CLIENT: ticks ahead of the server (set_lead_ticks) + 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 _statics_rev = 0; }; } // namespace ariadne diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index 63a355d1b..9dca18cd4 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 { @@ -89,6 +92,15 @@ void write_intent(cvc::app &app, const std::string &room, const std::string &pid bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, 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 (a stamped one queued for its tick, @@ -108,7 +120,8 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d // 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. +// 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); diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index d49a43074..e7ab553a0 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -648,7 +648,8 @@ struct ConferenceSession { 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()); diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index 74b7cc4fd..f267ab77c 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -196,7 +196,10 @@ int main(int argc, char **argv) { // clients by fan_local_writes. 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::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 diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index a32cdb68c..1995b5895 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/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 diff --git a/src/cvc/ariadne/avatar_physics.cpp b/src/cvc/ariadne/avatar_physics.cpp new file mode 100644 index 000000000..e635f959f --- /dev/null +++ b/src/cvc/ariadne/avatar_physics.cpp @@ -0,0 +1,174 @@ +/* + 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) { + 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 ? _body.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); +} + +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.id = create_avatar(feet, dynamic); + _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::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/holon.cpp b/src/cvc/ariadne/holon.cpp index d15fbbb55..0e0c60fb8 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -15,7 +15,9 @@ #include #include +#include #include +#include namespace cvc { namespace ariadne { @@ -31,6 +33,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, @@ -45,10 +57,25 @@ void holon::add_participant(const std::string &pid, bool local, 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. + if (_world) + _world->add_avatar(pid, start, _role == holon_role::server || local); +} + +void holon::remove_participant(const std::string &pid) { + _participants.erase(pid); + if (_world) + _world->remove_avatar(pid); } -void holon::remove_participant(const std::string &pid) { _participants.erase(pid); } +void holon::set_statics(const std::vector &statics, std::uint64_t revision) { + _statics_rev = revision; + if (_world) + _world->set_statics(statics); +} bool holon::has_participant(const std::string &pid) const { return _participants.find(pid) != _participants.end(); @@ -125,6 +152,8 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body 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). @@ -137,7 +166,9 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body } 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); @@ -170,9 +201,86 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body } } +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) { @@ -205,6 +313,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) @@ -214,6 +324,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 aca009c6f..89214f6db 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -18,6 +18,7 @@ #include #include #include // room_participant_path, room_roster +#include // read_scene_objects, scene_revision #include // cvc::ariadne::holon (server_pump) #include #include @@ -69,11 +70,15 @@ bool node_present(cvc::app &app, const std::string &path) { return app.root().findDescendant(path) != nullptr; } -// set_replicated unless the leaf already holds VAL; true when it wrote. -bool set_if_changed(cvc::app &app, const std::string &path, const std::string &val) { - if (cvc::state *n = app.root().findDescendant(path); n && n->value() == val) +// set_replicated 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_replicated(app, path, val); + set_replicated(app, path, fmt(v)); return true; } @@ -89,7 +94,7 @@ void write_pose(cvc::app &app, const std::string &room, const std::string &pid, if (all) set_replicated(app, path, fmt(v)), changed = true; else - changed = set_if_changed(app, path, fmt(v)) || changed; + changed = set_if_changed(app, path, v) || changed; }; put({"pose", "pos", "x"}, s.position.x); put({"pose", "pos", "y"}, s.position.y); @@ -164,7 +169,64 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, return true; } +holon_config room_holon_config() { + holon_config cfg; + cfg.physics = true; + 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 @@ -209,6 +271,7 @@ void server_pump(holon &h, cvc::app &app, const std::string &room, double wall_d 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) { diff --git a/src/cvc/tests/holon_sync_test.cpp b/src/cvc/tests/holon_sync_test.cpp index a9bb33b7a..c0f3a4f83 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 @@ -347,3 +348,19 @@ TEST(HolonSyncTest, ServerPumpAppliesAStampedIntentAtItsTick) { 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 961c49320..873862763 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -451,3 +451,169 @@ TEST(HolonTest, PeerSampleSpanIsCapped) { 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); +} From 15ad0e12f129867a25f353b79fe887b5a9d31b05 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Fri, 23 Oct 2026 23:43:34 -0500 Subject: [PATCH 28/33] arigl: propose the user's intent only when they change it; follow the document's otherwise step_room proposed the key/facing intent on the first frame and again when framing set the initial facing, which overwrote an intent the document set with (room-set-intent ...) -- a scripted walker never walked. Propose only when the user's own ask changes (a key, a click to face); otherwise run the prediction on the intent the tree holds for us. The first-frame proposal guarded against a stale walk under a reused node id, which per-launch ids and the relay's departure reset already rule out. --- src/arigl/main.cpp | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index e7ab553a0..6225d7855 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -313,8 +313,8 @@ 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) bool framed_ = false; // camera framed on our avatar once double orbit_center_[3] = {0.0, 0.0, 0.0}; @@ -688,13 +688,22 @@ struct ConferenceSession { in = gla::intent_from_keys(held("w", "Up"), held("s", "Down"), held("a", "Left"), 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 || in.yaw != last_intent_.yaw) { + // 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. @@ -723,6 +732,7 @@ struct ConferenceSession { double eye[3], focal[3], up[3]; cam_->getPose(eye, focal, up); facing_yaw_ = gla::heading_yaw(eye, focal); // face into the screen, so W walks away + last_user_.yaw = facing_yaw_; // a default, not the user's ask: no proposal until they act } const double c[3] = {p[0], p[1], p[2] + kEyeHeight}; if (std::fabs(c[0] - orbit_center_[0]) + std::fabs(c[1] - orbit_center_[1]) + From ecb22a4def741627763181465201fecccce8a6e1 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sat, 24 Oct 2026 00:44:05 -0500 Subject: [PATCH 29/33] arigl: until the user clicks to face, W walks into the screen The initial facing was the camera heading captured when the camera was framed on our avatar, which is not the view the user ends up with: in the browser the first W walked toward the camera. Until the user clicks to face, the facing now follows the live camera heading while no movement key is held (and holds while one is), mirrored into the last user intent so orbiting while idle proposes nothing. --- src/arigl/main.cpp | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 6225d7855..91c9ad4aa 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -319,9 +319,10 @@ struct ConferenceSession { 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. Starts as the camera's heading when we are framed, - // so W first walks "into" the screen. + // 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) @@ -674,17 +675,29 @@ struct ConferenceSession { 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 + 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; + 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); + facing_yaw_ = gla::heading_yaw(eye, focal); + last_user_.yaw = facing_yaw_; + } in.yaw = facing_yaw_; 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); - }; in = gla::intent_from_keys(held("w", "Up"), held("s", "Down"), held("a", "Left"), held("d", "Right"), facing_yaw_, kWalkSpeed); } @@ -729,10 +742,6 @@ struct ConferenceSession { // Frame the room around you, not just you: arriving at a party, you want to see who's here. cam_->frameBounds(p[0] - 8.0, p[1] - 8.0, p[2], p[0] + 8.0, p[1] + 8.0, p[2] + 2.0); framed_ = true; - double eye[3], focal[3], up[3]; - cam_->getPose(eye, focal, up); - facing_yaw_ = gla::heading_yaw(eye, focal); // face into the screen, so W walks away - last_user_.yaw = facing_yaw_; // a default, not the user's ask: no proposal until they act } const double c[3] = {p[0], p[1], p[2] + kEyeHeight}; if (std::fabs(c[0] - orbit_center_[0]) + std::fabs(c[1] - orbit_center_[1]) + From 51c32482e087e2c1bfe79db6829f36105b59af95 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sat, 24 Oct 2026 00:59:45 -0500 Subject: [PATCH 30/33] Conference link stats: Ping/Pong per connection, background tabs stay, compact roster with ping + bandwidth (#781) * Conference link stats: Ping/Pong probes, per-link traffic, room net stats, compact roster Link probe + stats (both transports, every connection): - state_transport.proto: Ping/Pong frames. Each end pings every 2 s (state_transport::set_ping_interval); the other end answers at once and the sender measures the round trip on its own clock. Link-level only: never replicated, relayed or bridged. - state_link_stats (lock-free per-connection bytes/frames both ways, RTT EWMA / last / min) + state_link_rate_meter (cumulative reports -> bytes/s); state_transport::link_reports() on state_transport_ws and _grpc, so a relay's links to clients, a client's link to its relay and a server's links to peer (delegation) servers are all measured the same way. Background tabs no longer drop out: a browser answers the relay's Ping from its WebSocket onmessage event, which a backgrounded tab still receives, so the idle reaper keeps it; a ghost that cannot answer is still reaped. And arigl now redials a lost relay link every 3 s and re-asserts its presence afterwards. Room stats (room_net.h): each client publishes its own link quality as participants..net.{rtt_ms,in_kbps,out_kbps}; the relay publishes its view of each participant (net.relay.*) and its totals (server.net.*) and logs them (per link every ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each minute). (room-roster) entries carry the numbers and roster-cell text. conference_roster.ari: the Users window is a compact table -- one row per participant (name | ping | down | up | cam | mic | vol), each row a grid with the same fixed column widths, so the rows line up as columns; your own row shows your measured link. Peer camera tiles moved right of the wider window. Tests: state_link_stats_test (3), StateTransportWsTest.LinkProbeMeasures- RoundTripAtBothEnds + QuietClientAnsweringPingsIsNotReaped, StateTransportGrpc- Test.LinkProbeMeasuresRoundTripAtBothEnds; RelayReapsIdleConnection turns the probe off to keep testing a silent client. * state_transport_grpc_test: leave only after the relay has read the Hello ConnectionClosedReportsThePeersNodeId stopped the client as soon as both ends counted a connection. Cancelling a gRPC stream drops frames the relay has not read yet, so the relay sometimes never saw the client's Hello -- and a stream that never identified itself is, by contract, not reported. The link probe's extra threads shifted the timing enough to expose it (about 1 run in 5 after ThreeEndpointFanOut). Wait until the relay's link report names the client. 20/20 repeats of the reproducing sequence pass. * conference_roster: widen the ping column (a congested link reads 4 digits) * Transports: a peer that stops reading can no longer hang the relay; RTT median Production stalled for everyone after a browser tab froze: a write to that client blocked once its TCP buffers filled, while holding the connection's write lock, so the relay's main loop (and the idle reaper that would have dropped the client, which runs on it) waited on the lock forever. The backlog thresholds only make that less likely; nothing bounded it. - Write watchdog (WS + gRPC): each connection records when a write starts; the probe thread closes a connection whose write has been blocked > 3 s (socket shutdown / stream TryCancel), so the write fails and the lock is freed. The client redials (arigl reconnect) and gets a fresh snapshot. - The probe never waits: Pings use try_write (skip a busy connection) and skip one with more than 64 KB unsent. - gRPC: the server handler's cancel hook is cleared under a mutex before its ServerContext goes away. - state_link_stats: rtt_ms is now the median of the last 8 round trips; the moving average carried a 14.8 s probe (answered after a frozen tab woke) for minutes. Tests: StateTransportWsTest.WriteToAPeerThatStopsReadingIsAbortedNotHung (raw peer that handshakes and never reads; 32 MB media frames), StateLinkStatsTest. MedianShrugsOffAStall. --- .../ariadne/documents/conference_roster.ari | 88 +++++++--- inc/cvc/ariadne/conference/room_net.h | 80 +++++++++ inc/cvc/state/state_link_stats.h | 106 ++++++++++++ inc/cvc/state/state_transport.h | 21 +++ inc/cvc/state/state_transport_grpc.h | 16 ++ inc/cvc/state/state_transport_ws.h | 11 ++ proto/state_transport.proto | 17 ++ src/arigl/main.cpp | 78 +++++++++ src/arisrv/main.cpp | 51 ++++++ src/cvc/CMakeLists.txt | 5 +- .../ariadne/conference/room_intrinsics.cpp | 14 +- src/cvc/ariadne/conference/room_net.cpp | 146 +++++++++++++++++ src/cvc/state/state_link_stats.cpp | 102 ++++++++++++ src/cvc/state/state_transport_grpc.cpp | 153 +++++++++++++++++- src/cvc/state/state_transport_ws.cpp | 145 ++++++++++++++++- src/cvc/tests/CMakeLists.txt | 11 ++ src/cvc/tests/state_link_stats_test.cpp | 98 +++++++++++ src/cvc/tests/state_transport_grpc_test.cpp | 46 ++++++ src/cvc/tests/state_transport_ws_test.cpp | 118 ++++++++++++++ 19 files changed, 1276 insertions(+), 30 deletions(-) create mode 100644 inc/cvc/ariadne/conference/room_net.h create mode 100644 inc/cvc/state/state_link_stats.h create mode 100644 src/cvc/ariadne/conference/room_net.cpp create mode 100644 src/cvc/state/state_link_stats.cpp create mode 100644 src/cvc/tests/state_link_stats_test.cpp diff --git a/examples/ariadne/documents/conference_roster.ari b/examples/ariadne/documents/conference_roster.ari index eea9a1855..57a367dc1 100644 --- a/examples/ariadne/documents/conference_roster.ari +++ b/examples/ariadne/documents/conference_roster.ari @@ -87,30 +87,58 @@ windows: bind: (if (= (state-get "mic_on") "1") "● on" "muted") # ── Users: the roster with per-peer local controls ── + # One compact ROW per participant, as a table of unit widgets: every row is its own grid with the + # SAME fixed column widths, so the rows line up as columns. ping / down / up are each person's + # link to the relay AS THEY MEASURE IT (published to the room once a second; "--" = not measured + # yet). cam / mic / vol are LOCAL controls (what YOU see and hear of them), never sent. - window: Users id: users pos: [390, 40] - size: [380, 540] + size: [420, 260] closable: true children: - text: "in the room" bind: self.count - separator - # You (first row): your name lights up GREEN while YOU talk. - - text: "you" - bind: conf.self - visible_when: (not (= (state-get "self.talking") "1")) - - text: "you" - bind: conf.self - color: [0.30, 1.0, 0.40] - visible_when: (= (state-get "self.talking") "1") - - separator - # Everyone else: one row per peer (peer.). The on_tick fills peer..{pid,name,cam,talking}. + - type: group + id: users_head + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } + children: + - text: "name" + - text: "ping" + - text: "down" + - text: "up" + - text: "cam" + - text: "mic" + - text: "vol" + # You: your name lights up GREEN while YOU talk (text colour is static, so it is a visibility + # swap: only one of the two is visible, so they share one cell). + - type: group + id: users_you + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } + children: + - text: "" + bind: conf.self + visible_when: (not (= (state-get "self.talking") "1")) + - text: "" + bind: conf.self + color: [0.30, 1.0, 0.40] + visible_when: (= (state-get "self.talking") "1") + - text: "" + bind: self.ping + - text: "" + bind: self.down + - text: "" + bind: self.up + - text: "" + - text: "" + - text: "(you)" + # Everyone else: one row per peer (peer.), filled by on_tick. - type: group repeat: (state-get "peer.count") + layout: { kind: grid, col_widths: [118, 64, 50, 50, 34, 34, 0] } children: - # Name — click to open + raise this peer's camera window (cam_). Two texts: the GREEN one - # shows only while they are talking (text colour is static, so "light up" is a visibility swap). + # Name: click to open + raise this peer's camera window (cam_); GREEN while talking. - text: "" bind: peer.{i}.name on_click: (begin (state-set "tree.cam_{i}.open" "1") (state-set "tree.cam_{i}.raise" "1")) @@ -120,24 +148,30 @@ windows: color: [0.30, 1.0, 0.40] on_click: (begin (state-set "tree.cam_{i}.open" "1") (state-set "tree.cam_{i}.raise" "1")) visible_when: (= (state-get "peer.{i}.talking") "1") + - text: "" + bind: peer.{i}.ping + - text: "" + bind: peer.{i}.down + - text: "" + bind: peer.{i}.up # Local mute toggles (checked = on). Default on: the host treats von/aon as muted only when - # explicitly "0", so an untouched peer is shown + heard. + # explicitly "0", so an untouched peer is shown + heard. "##" hides the label (the header + # names the column). - type: checkbox - label: "cam" + label: "##cam" bind: peer.{i}.von def: true - type: checkbox - label: "mic" + label: "##mic" bind: peer.{i}.aon def: true # Local volume in our mix (1 = unity, up to 2x boost). - type: slider_float - label: "vol" + label: "##vol" bind: peer.{i}.vol lo: 0 hi: 2 def: 1 - - separator # ── One camera window per OTHER participant, titled by their name ── - window: Peer @@ -163,16 +197,16 @@ on_tick: | ; every cam_ declares the SAME authored pos:, so without this the per-peer windows land ; exactly on top of one another and look like a single window — the #1 reason a user reports ; "I can't see the other cameras". Tile them in a 2-column grid to the RIGHT of the Users - ; window (which sits at x≈390–610): the roster is NEVER covered, and up to four cameras show + ; window (which sits at x≈390–800): the roster is NEVER covered, and up to four cameras show ; side-by-side without overlapping on a laptop screen. Seeded ONCE here, before any peer window ; exists, so each window's first-use geometry seed lands at its own spot; the user's drag then ; persists and wins. Covers 6 peers; a 7th+ falls back to the authored pos. - (state-set "tree.cam_0.geometry" "612,66,330,290") - (state-set "tree.cam_1.geometry" "952,66,330,290") - (state-set "tree.cam_2.geometry" "612,364,330,290") - (state-set "tree.cam_3.geometry" "952,364,330,290") - (state-set "tree.cam_4.geometry" "612,662,330,290") - (state-set "tree.cam_5.geometry" "952,662,330,290") + (state-set "tree.cam_0.geometry" "806,66,330,290") + (state-set "tree.cam_1.geometry" "1146,66,330,290") + (state-set "tree.cam_2.geometry" "806,364,330,290") + (state-set "tree.cam_3.geometry" "1146,364,330,290") + (state-set "tree.cam_4.geometry" "806,662,330,290") + (state-set "tree.cam_5.geometry" "1146,662,330,290") (state-set "cam_on" "1") ; on/off toggles start ON (streams open unmuted) (state-set "mic_on" "1") (room-join "" (state-get "conf.room") (dict "name" (state-get "conf.self"))) @@ -251,6 +285,10 @@ on_tick: | (state-get (str-concat "media." pid ".camera"))) (state-set (str-concat "peer." idx ".talking") (state-get (str-concat "media." pid ".talking"))) + ; their link quality, as THEY measure it (ping to the relay, bits/s down + up) + (state-set (str-concat "peer." idx ".ping") (get-attr p "ping")) + (state-set (str-concat "peer." idx ".down") (get-attr p "down")) + (state-set (str-concat "peer." idx ".up") (get-attr p "up")) (set idx (+ idx 1)) (set total (+ total 1)))))) (state-set "peer.count" (str idx)) diff --git a/inc/cvc/ariadne/conference/room_net.h b/inc/cvc/ariadne/conference/room_net.h new file mode 100644 index 000000000..7d280853b --- /dev/null +++ b/inc/cvc/ariadne/conference/room_net.h @@ -0,0 +1,80 @@ +/* + 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_NET_H__ +#define __CVC_ARIADNE_CONFERENCE_ROOM_NET_H__ + +#include + +namespace cvc { +class app; + +namespace ariadne { +namespace conference { + +// ---------------- +// Network quality in the shared room tree +// ---------------- +// What each end measures about its links (state_transport::link_reports: the Ping/Pong round trip +// and traffic both ways), published as scalar leaves beside the rest of the room so every +// participant can see everyone's connection quality: +// +// conf.rooms..participants..net.{rtt_ms, in_kbps, out_kbps} +// the participant's OWN measurement of its link to the relay -- written by that client (like +// its .intent), shown next to its name in everyone's roster +// conf.rooms..participants..net.relay.{rtt_ms, in_kbps, out_kbps} +// the RELAY's measurement of its link to that participant (in = what the relay receives from +// it) -- written by the relay, like .pose +// conf.rooms..server.net.{in_kbps, out_kbps, links} +// the relay's totals over all of its links +// +// Values are kilobits/second and milliseconds; negative = not measured yet. + +struct net_sample { + double rtt_ms = -1.0; + double in_kbps = -1.0; + double out_kbps = -1.0; +}; + +// A participant's own measurement (client side). +void write_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s); +bool read_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out); + +// The relay's measurement of its link to a participant (server side). +void write_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s); +bool read_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out); + +// The relay's totals. +struct server_net { + double in_kbps = 0.0; + double out_kbps = 0.0; + int links = 0; +}; +void write_server_net(cvc::app &app, const std::string &room, const server_net &s); +bool read_server_net(cvc::app &app, const std::string &room, server_net &out); + +// Compact display text for a roster cell: "42 ms" / "--"; "850k" / "1.2M" / "--" (bits/s). +std::string format_rtt(double rtt_ms); +std::string format_rate(double kbps); + +} // namespace conference +} // namespace ariadne +} // namespace cvc + +#endif // __CVC_ARIADNE_CONFERENCE_ROOM_NET_H__ diff --git a/inc/cvc/state/state_link_stats.h b/inc/cvc/state/state_link_stats.h new file mode 100644 index 000000000..1be9aa63a --- /dev/null +++ b/inc/cvc/state/state_link_stats.h @@ -0,0 +1,106 @@ +/* + 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_STATE_LINK_STATS_H__ +#define __CVC_STATE_LINK_STATS_H__ + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cvc { + +// ---------------- +// cvc::state_link_stats +// ---------------- +// The quality of ONE transport connection (a link): traffic both ways and the round-trip time +// measured by the transport's Ping/Pong probe (state_transport.proto). Every connection of +// state_transport_ws and state_transport_grpc owns one, so a client's link to its relay, a relay's +// link to each client, and a server's links to other servers (delegation peers included) are all +// measured the same way. Counters are lock-free: the reader thread notes inbound bytes, any writer +// notes outbound bytes, and the pinger notes round trips concurrently. +// +// RTT: rtt_ms is the MEDIAN of the last kRttWindow round trips, so a one-off stall (a probe that +// waited while a browser tab was frozen) does not distort it for long -- an average would carry a +// multi-second outlier for dozens of probes. Last and min are kept beside it. All negative until +// the first Pong. +class state_link_stats { +public: + void note_rx(std::size_t bytes) noexcept; + void note_tx(std::size_t bytes) noexcept; + // The next Ping's sequence number (and counts the Ping as sent). + std::uint64_t next_ping_seq() noexcept; + // A Pong arrived: rtt_ns is now minus the echoed sent_ns, on the sender's own clock. + void note_rtt(std::int64_t rtt_ns) noexcept; + + struct counters { + std::uint64_t bytes_in = 0, bytes_out = 0; + std::uint64_t frames_in = 0, frames_out = 0; + std::uint64_t pings_sent = 0, pongs_received = 0; + double rtt_ms = -1.0, rtt_last_ms = -1.0, rtt_min_ms = -1.0; + }; + counters read() const noexcept; + + static constexpr int kRttWindow = 8; + +private: + std::atomic _bytes_in{0}, _bytes_out{0}; + std::atomic _frames_in{0}, _frames_out{0}; + std::atomic _pings{0}, _pongs{0}; + // RTTs in microseconds (-1 = none yet): the recent window (ring) for the median, last, and min. + mutable std::mutex _rtt_mu; + std::array _rtt_ring{}; + int _rtt_count = 0, _rtt_next = 0; + std::atomic _rtt_last_us{-1}, _rtt_min_us{-1}; +}; + +// One link as a transport reports it (state_transport::link_reports()). +struct state_link_report { + std::string transport; // "ws" | "grpc" + std::string remote_node_id; // the peer's node id (from its Hello); "" before the Hello + bool accepted = false; // this end ACCEPTED the connection (a relay's link to a client) + double age_s = 0.0; // since the connection opened + double idle_s = 0.0; // since the last inbound frame + state_link_stats::counters stats; +}; + +// Turns successive cumulative link reports into rates. update() is called periodically (say once a +// second) with the transports' current reports; it returns each link's bytes-per-second since the +// previous call, keyed like key_of(). A link seen for the first time reports 0 (no interval yet). +class state_link_rate_meter { +public: + struct rate { + double in_bps = 0.0, out_bps = 0.0; + }; + std::map update(const std::vector &reports, double now_s); + // ":" (+ ":in" for an accepted link), unique per live link. + static std::string key_of(const state_link_report &r); + +private: + struct sample { + std::uint64_t bytes_in = 0, bytes_out = 0; + double t = 0.0; + }; + std::map _last; +}; + +} // namespace cvc + +#endif // __CVC_STATE_LINK_STATS_H__ diff --git a/inc/cvc/state/state_transport.h b/inc/cvc/state/state_transport.h index cacee0375..a0598a1bd 100644 --- a/inc/cvc/state/state_transport.h +++ b/inc/cvc/state/state_transport.h @@ -11,10 +11,13 @@ #ifndef __CVC_STATE_TRANSPORT_H__ #define __CVC_STATE_TRANSPORT_H__ +#include +#include #include #include #include #include +#include #include #include #include @@ -110,6 +113,23 @@ class state_transport { state_peer_registry &peers() noexcept { return _peers; } const state_peer_registry &peers() const noexcept { return _peers; } + // -------- Link quality (Ping/Pong probes, per-connection traffic) -------- + // + // One report per live connection: bytes and frames both ways, and the round-trip time the + // transport's Ping/Pong probe measures (state_link_stats). A relay sees one link per client; a + // client sees its link to the relay; a server also sees its links to other servers. Transports + // without connections report none (the default). + virtual std::vector link_reports() const { return {}; } + + // How often each connection is pinged (default 2 s; 0 turns the probe off). The probe also keeps + // a quiet-but-connected peer from looking idle to a relay's reaper. + void set_ping_interval(std::chrono::milliseconds interval) noexcept { + _ping_interval_ms.store(interval.count(), std::memory_order_relaxed); + } + std::chrono::milliseconds ping_interval() const noexcept { + return std::chrono::milliseconds(_ping_interval_ms.load(std::memory_order_relaxed)); + } + // -------- Chunk fetch (volume/geometry streaming) -------- // // Callback-based chunk retrieval from remote peers. The @@ -202,6 +222,7 @@ class state_transport { protected: state_peer_registry _peers; state_transport *_bridge = nullptr; + std::atomic _ping_interval_ms{2000}; }; } // namespace cvc diff --git a/inc/cvc/state/state_transport_grpc.h b/inc/cvc/state/state_transport_grpc.h index 846dbd5f7..4c26fc836 100644 --- a/inc/cvc/state/state_transport_grpc.h +++ b/inc/cvc/state/state_transport_grpc.h @@ -27,6 +27,11 @@ #include namespace cvc { +namespace transport { +namespace v1 { +class Frame; // the generated envelope (proto/state_transport.proto), for on_link_frame +} // namespace v1 +} // namespace transport // ---------------- // cvc::state_transport_grpc @@ -204,6 +209,8 @@ class state_transport_grpc final : public state_transport, public media_endpoint // Diagnostics. std::size_t shard_count() const; std::size_t connection_count() const; + // One report per live connection (bytes/frames both ways, Ping/Pong round-trip time). + std::vector link_reports() const override; std::uint64_t total_published() const noexcept { return _published.load(); } std::uint64_t total_sent_frames() const noexcept { return _sent_frames.load(); } std::uint64_t total_received_frames() const noexcept { return _recv_frames.load(); } @@ -254,6 +261,9 @@ class state_transport_grpc final : public state_transport, public media_endpoint // A server-side stream ended: fire the connection-closed handler for its peer (see // set_on_connection_closed). void notify_connection_closed(const std::string &node_id); + // Note an inbound frame on `c` (link counters + liveness), and answer / measure a link probe. + // Returns true when `in` was a Ping or Pong (fully handled: never dispatched further). + bool on_link_frame(connection &c, const ::cvc::transport::v1::Frame &in); void increment_recv_frames() noexcept { _recv_frames.fetch_add(1, std::memory_order_relaxed); } void increment_recv_mutations() noexcept { _recv_mutations.fetch_add(1, std::memory_order_relaxed); @@ -334,6 +344,12 @@ class state_transport_grpc final : public state_transport, public media_endpoint std::chrono::milliseconds _heartbeat_interval{0}; std::thread _heartbeat_thread; void heartbeat_loop(); + + // Link probe: every ping_interval(), send each live connection a Ping (state_transport.proto); + // the peer's Pong gives the round trip. Runs on its own thread from start() to stop(). + std::thread _ping_thread; + void ping_loop(); + void send_pings(); }; } // namespace cvc diff --git a/inc/cvc/state/state_transport_ws.h b/inc/cvc/state/state_transport_ws.h index 8fa0fb068..17bc2a284 100644 --- a/inc/cvc/state/state_transport_ws.h +++ b/inc/cvc/state/state_transport_ws.h @@ -181,6 +181,8 @@ class state_transport_ws final : public state_transport, public media_endpoint { // ── Diagnostics ── std::size_t shard_count() const; std::size_t connection_count() const; + // One report per live connection (bytes/frames both ways, Ping/Pong round-trip time). + std::vector link_reports() const override; std::uint64_t total_published() const noexcept { return _published.load(); } std::uint64_t total_sent_frames() const noexcept { return _sent_frames.load(); } std::uint64_t total_received_frames() const noexcept { return _recv_frames.load(); } @@ -231,6 +233,15 @@ class state_transport_ws final : public state_transport, public media_endpoint { void accept_loop(); void read_loop(std::shared_ptr conn); + // Link probe: Ping every live connection once ping_interval() has passed since the last round. + // Native: a thread started with the first start() / connect_to_peer(). Browser (no threads): + // pump_all() calls it every frame. A Ping that arrives is answered at once in deliver_inbound -- + // in a browser that is the WebSocket onmessage event, which keeps firing in a backgrounded tab. + void send_pings_if_due(); + void ping_loop(); + std::atomic _next_ping_ns{0}; + std::atomic _ping_started{false}; + struct impl; std::unique_ptr _impl; diff --git a/proto/state_transport.proto b/proto/state_transport.proto index 727f3416c..2f6406262 100644 --- a/proto/state_transport.proto +++ b/proto/state_transport.proto @@ -150,6 +150,21 @@ message MediaFrame { uint32 channels = 10; // audio: channel count (0 for video) } +// Link-quality probe. Each end of a connection sends a Ping on a timer; the other end answers at +// once with a Pong echoing seq + sent_ns, and the sender measures the round trip on its OWN +// monotonic clock (no clock sync). A Ping/Pong is link-level: never replicated, never relayed or +// bridged. Answering also proves liveness, so a relay's idle reaper keeps a client that is quiet +// but still connected (a backgrounded browser tab answers from its WebSocket event, which is not +// timer-throttled) while a ghost that cannot answer is still reaped. +message Ping { + uint64 seq = 1; + int64 sent_ns = 2; // sender's steady-clock ns +} +message Pong { + uint64 seq = 1; + int64 sent_ns = 2; // the Ping's sent_ns, echoed +} + // Single bidirectional frame envelope. Both client and server write // frames; same-cluster routing happens above this layer. message Frame { @@ -163,6 +178,8 @@ message Frame { SnapshotResponse snapshot_response = 7; Heartbeat heartbeat = 8; MediaFrame media = 9; + Ping ping = 10; + Pong pong = 11; } } diff --git a/src/arigl/main.cpp b/src/arigl/main.cpp index 0138ec5e5..4179e5fce 100644 --- a/src/arigl/main.cpp +++ b/src/arigl/main.cpp @@ -65,6 +65,8 @@ // 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 // room_join (re-assert after a reconnect) +#include // publish our link quality 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 @@ -289,6 +291,19 @@ struct ConferenceSession { // the SAME keys the .ari's (state-get/-set) do (which are prefix-relative) std::string room_; // the joined room id (conf.rooms.) + // ── link quality + reconnect ── + // Once a second: read our link to the relay (state_transport::link_reports -- the Ping/Pong + // round trip and traffic both ways), publish it to the room as participants..net.* so every + // roster shows it next to our name, and show it in our own row (self.ping / self.down / self.up). + // If the link is gone (the relay restarted, the network dropped, or a long-backgrounded tab was + // reaped) dial the relay again every few seconds, and after reconnecting re-assert our presence + // for a few seconds (the relay marked us as left; our write must land after its snapshot). + std::string relay_url_; + bool relay_grpc_ = false; + bool ever_linked_ = false; + double next_net_s_ = 0.0, next_reconnect_s_ = 0.0, rejoin_until_s_ = 0.0; + cvc::state_link_rate_meter link_meter_; + // ── the room in 3D (H1 slice 6): the shared scene + avatars, third-person controls ── // A client replica of the room holon, kept in step with the replicated room tree by client_pump // each frame: our avatar predicted from our own intent, peers walked between the relay's pose @@ -393,6 +408,7 @@ struct ConferenceSession { // future step between these without renumbering. enum : int { kOrderPumpAll = 10, // drain/flush the state transport (replicated room tree) — first + kOrderNet = 12, // ~1 Hz: our link quality -> the room; reconnect a lost relay link kOrderRoom = 15, // the room in 3D: keys -> intent, client holon, scene + avatars, camera kOrderWall = 20, // refresh now_wall_ for the wall-time talk-hold kOrderControls = 30, // read the user-list window's per-peer local controls @@ -477,6 +493,8 @@ struct ConferenceSession { // the shared ops and send_media_/stop_ wrap the concrete's publish/stop. const std::string relayUrl(relay); const bool want_grpc = (relayUrl.rfind("grpc://", 0) == 0); + relay_url_ = relayUrl; + relay_grpc_ = want_grpc; bool ok = false; if (want_grpc) { #if CVC_ENABLE_GRPC @@ -543,6 +561,64 @@ struct ConferenceSession { active = true; return true; } + static double steady_s() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()) + .count(); + } + + void net_step() { + if (!app_ || !tp_) + return; + const double now = steady_s(); + if (now < next_net_s_) + return; + next_net_s_ = now + 1.0; + const std::vector links = tp_->link_reports(); + const cvc::state_link_report *relay = nullptr; + for (const cvc::state_link_report &r : links) + if (!r.accepted) { // the link WE dialed: the relay + relay = &r; + break; + } + auto &st = cvc::state::instance(*app_); + if (!relay) { + st(prefix_ + ".self.ping").value(std::string("--")); + if (ever_linked_ && now >= next_reconnect_s_) + reconnect(now); + return; + } + ever_linked_ = true; + const auto rates = link_meter_.update(links, now); + const auto it = rates.find(cvc::state_link_rate_meter::key_of(*relay)); + cvc::ariadne::conference::net_sample s; + s.rtt_ms = relay->stats.rtt_ms; + s.in_kbps = it == rates.end() ? 0.0 : it->second.in_bps * 8.0 / 1000.0; + s.out_kbps = it == rates.end() ? 0.0 : it->second.out_bps * 8.0 / 1000.0; + cvc::ariadne::conference::write_participant_net(*app_, room_, pid_, s); + 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 (now < rejoin_until_s_) + cvc::ariadne::conference::room_join(*app_, room_, pid_, st(prefix_ + ".conf.self").value()); + } + + void reconnect(double now) { + next_reconnect_s_ = now + 3.0; + std::fprintf(stderr, "arigl: conference: lost the relay link; reconnecting to %s\n", + relay_url_.c_str()); + bool ok = false; +#if CVC_ENABLE_GRPC + if (relay_grpc_ && grpc_) + ok = grpc_->connect_to_peer(relay_url_.substr(7), std::chrono::milliseconds(3000)); +#endif +#if CVC_ENABLE_WS + if (!relay_grpc_ && ws_) + ok = ws_->connect_to_peer(relay_url_, std::chrono::milliseconds(3000)); +#endif + if (ok) + rejoin_until_s_ = now + 6.0; // re-assert presence until the relay's snapshot has landed + } + // 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. @@ -633,6 +709,8 @@ struct ConferenceSession { // Drive the state transport first: flush replicated room-tree writes and service queued reads // so room-join/roster are in place before anything this frame reads them. driver_->add_frame_observer(kOrderPumpAll, [this](const cf &) { tp_->pump_all(); }); + // Our link quality to the room, and reconnecting a lost relay link (~1 Hz). + driver_->add_frame_observer(kOrderNet, [this](const cf &) { net_step(); }); // Wall-clock seconds for the talk-hold — deliberately wall time, not world time, so the hold is // frame-rate independent and does not freeze when the sim clock is paused or scaled. driver_->add_frame_observer(kOrderWall, [this](const cf &) { diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index ccb4d77b0..3fa45e01c 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -27,6 +27,7 @@ #include #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 @@ -199,6 +200,14 @@ int main(int argc, char **argv) { const bool room_debug = room_debug_env && *room_debug_env && std::strcmp(room_debug_env, "0") != 0; int room_debug_ticks = 0; + // Link stats (~1 s): every connection on both planes reports its traffic and Ping/Pong round + // trip (state_transport::link_reports); the rate meter turns the counters into rates. The relay + // publishes its view of each participant (net.relay.*) and its totals (server.net.*) into the + // room tree, and logs them: every link each ~5 s with CVC_ARISRV_ROOM_DEBUG, a totals line each + // minute otherwise. Links that are not participants (peer servers, delegation) are logged too. + cvc::state_link_rate_meter link_meter; + 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 @@ -251,6 +260,48 @@ int main(int argc, char **argv) { // CVC_ARISRV_ROOM_DEBUG=1: every ~5 s, log the room as the server holds it -- each present // participant's authoritative position and its proposed intent -- so replication across the // gRPC and WebSocket planes can be checked from the relay without a client. + if (++net_ticks % 5 == 0) { + std::vector links = state_tp.link_reports(); +#if CVC_ENABLE_WS + for (cvc::state_link_report &r : ws_tp.link_reports()) + links.push_back(std::move(r)); +#endif + const double now_s = + std::chrono::duration(std::chrono::steady_clock::now() - net_start).count(); + const auto rates = link_meter.update(links, now_s); + cvc::ariadne::conference::server_net total; + std::set present; + for (const auto &p : cvc::ariadne::conference::room_roster(app, room)) + present.insert(p.pid); + for (const cvc::state_link_report &r : links) { + 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)) + cvc::ariadne::conference::write_relay_net(app, room, r.remote_node_id, + {r.stats.rtt_ms, in_kbps, out_kbps}); + } + cvc::ariadne::conference::write_server_net(app, room, total); + const bool detail = room_debug && net_ticks % 25 == 0; + if (detail || net_ticks % 300 == 0) { + std::printf("[arisrv] net: %d link(s) | in %.1f kb/s | out %.1f kb/s\n", total.links, + total.in_kbps, total.out_kbps); + if (detail) + for (const cvc::state_link_report &r : links) { + const auto it = rates.find(cvc::state_link_rate_meter::key_of(r)); + std::printf("[arisrv] %-4s %-16s %-7s rtt %6.1f ms (min %.1f) | in %7.1f kb/s | out " + "%7.1f kb/s | idle %.1fs\n", + r.transport.c_str(), r.remote_node_id.c_str(), + r.accepted ? "client" : "peer", r.stats.rtt_ms, r.stats.rtt_min_ms, + it == rates.end() ? 0.0 : it->second.in_bps * 8.0 / 1000.0, + it == rates.end() ? 0.0 : it->second.out_bps * 8.0 / 1000.0, r.idle_s); + } + std::fflush(stdout); + } + } 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(), diff --git a/src/cvc/CMakeLists.txt b/src/cvc/CMakeLists.txt index f78bc700e..a32cdb68c 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -42,6 +42,7 @@ set(INCLUDE_FILES ../../inc/cvc/state/state_message.h ../../inc/cvc/state/state_message_bus.h ../../inc/cvc/state/state_peer_registry.h + ../../inc/cvc/state/state_link_stats.h ../../inc/cvc/state/state_write_policy.h ../../inc/cvc/state/state_distributed_metrics.h ../../inc/cvc/state/state_delegation_manager.h @@ -293,6 +294,7 @@ set(SOURCE_FILES media/media_router.cpp state/state_message_bus.cpp state/state_peer_registry.cpp + state/state_link_stats.cpp state/state_write_policy.cpp state/state_distributed_metrics.cpp state/state_delegation_manager.cpp @@ -1310,7 +1312,7 @@ 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/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/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 INCLUDE_FILES ../../inc/cvc/ariadne/widget.h ../../inc/cvc/ariadne/backend.h @@ -1334,6 +1336,7 @@ list(APPEND INCLUDE_FILES ../../inc/cvc/ariadne/conference/room_intrinsics.h ../../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/state_io.h ../../inc/cvc/ariadne/scene.h ../../inc/cvc/ariadne/value.h diff --git a/src/cvc/ariadne/conference/room_intrinsics.cpp b/src/cvc/ariadne/conference/room_intrinsics.cpp index d8a013e42..361a2fb81 100644 --- a/src/cvc/ariadne/conference/room_intrinsics.cpp +++ b/src/cvc/ariadne/conference/room_intrinsics.cpp @@ -12,6 +12,7 @@ #include // strtod (room-set-intent numeric args) #include // register_action_intrinsics #include +#include // the roster entries carry each link's quality #include // write_intent (room-set-intent verb) #include // app.root() #include // cvc::state, SEPARATOR, findDescendant, children, value @@ -271,12 +272,23 @@ void register_room_intrinsics(cvc::app &app) { const std::string room_id = rh ? rh->room_id : handle_token(args[0]); if (room_id.empty()) return err("room-roster: ROOM must be a room-join handle or room-id"); + // Each entry also carries the participant's self-reported link quality (room_net.h): + // raw numbers (negative = not measured yet) and roster-cell text. std::vector list; - for (const room_participant &p : room_roster(app, room_id)) + for (const room_participant &p : room_roster(app, room_id)) { + net_sample net; + read_participant_net(app, room_id, p.pid, net); list.push_back(se::make_dict({ {"pid", se::value_t(p.pid)}, {"name", se::value_t(p.name)}, + {"rtt_ms", se::value_t(net.rtt_ms)}, + {"in_kbps", se::value_t(net.in_kbps)}, + {"out_kbps", se::value_t(net.out_kbps)}, + {"ping", se::value_t(format_rtt(net.rtt_ms))}, + {"down", se::value_t(format_rate(net.in_kbps))}, + {"up", se::value_t(format_rate(net.out_kbps))}, })); + } return se::make_list(std::move(list)); }); diff --git a/src/cvc/ariadne/conference/room_net.cpp b/src/cvc/ariadne/conference/room_net.cpp new file mode 100644 index 000000000..114d0841b --- /dev/null +++ b/src/cvc/ariadne/conference/room_net.cpp @@ -0,0 +1,146 @@ +/* + 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 // room_participant_path +#include +#include +#include +#include +#include + +namespace cvc { +namespace ariadne { +namespace conference { + +namespace { + +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; +} + +// One decimal is plenty for display, and an unchanged value writes nothing new to replicate. +std::string fmt(double v) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.1f", v); + 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 value that has not changed is not +// rewritten, so a steady link does not add replication traffic. +void set_replicated(cvc::app &app, const std::string &path, const std::string &val) { + cvc::state &n = app.root()(path); + if (n.value() == val) + return; + n.touch(); + n.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; +} + +void write_sample(cvc::app &app, const std::string &base, const net_sample &s) { + set_replicated(app, sub(base, {"rtt_ms"}), fmt(s.rtt_ms)); + set_replicated(app, sub(base, {"in_kbps"}), fmt(s.in_kbps)); + set_replicated(app, sub(base, {"out_kbps"}), fmt(s.out_kbps)); +} + +bool read_sample(cvc::app &app, const std::string &base, net_sample &out) { + if (!app.root().findDescendant(sub(base, {"rtt_ms"}))) + return false; + out.rtt_ms = read_double(app, sub(base, {"rtt_ms"}), -1.0); + out.in_kbps = read_double(app, sub(base, {"in_kbps"}), -1.0); + out.out_kbps = read_double(app, sub(base, {"out_kbps"}), -1.0); + return true; +} + +std::string participant_net(const std::string &room, const std::string &pid) { + return sub(room_participant_path(room, pid), {"net"}); +} + +std::string server_net_path(const std::string &room) { + return sub("conf", {"rooms", room.c_str(), "server", "net"}); +} + +} // namespace + +void write_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s) { + write_sample(app, participant_net(room, pid), s); +} + +bool read_participant_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out) { + return read_sample(app, participant_net(room, pid), out); +} + +void write_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + const net_sample &s) { + write_sample(app, sub(participant_net(room, pid), {"relay"}), s); +} + +bool read_relay_net(cvc::app &app, const std::string &room, const std::string &pid, + net_sample &out) { + return read_sample(app, sub(participant_net(room, pid), {"relay"}), out); +} + +void write_server_net(cvc::app &app, const std::string &room, const server_net &s) { + const std::string base = server_net_path(room); + set_replicated(app, sub(base, {"in_kbps"}), fmt(s.in_kbps)); + set_replicated(app, sub(base, {"out_kbps"}), fmt(s.out_kbps)); + set_replicated(app, sub(base, {"links"}), std::to_string(s.links)); +} + +bool read_server_net(cvc::app &app, const std::string &room, server_net &out) { + const std::string base = server_net_path(room); + if (!app.root().findDescendant(sub(base, {"links"}))) + return false; + out.in_kbps = read_double(app, sub(base, {"in_kbps"}), 0.0); + out.out_kbps = read_double(app, sub(base, {"out_kbps"}), 0.0); + out.links = static_cast(read_double(app, sub(base, {"links"}), 0.0)); + return true; +} + +std::string format_rtt(double rtt_ms) { + if (rtt_ms < 0.0) + return "--"; + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.0f ms", rtt_ms); + return std::string(buf); +} + +std::string format_rate(double kbps) { + if (kbps < 0.0) + return "--"; + char buf[32]; + if (kbps >= 1000.0) + std::snprintf(buf, sizeof(buf), "%.1fM", kbps / 1000.0); + else + std::snprintf(buf, sizeof(buf), "%.0fk", kbps); + return std::string(buf); +} + +} // namespace conference +} // namespace ariadne +} // namespace cvc diff --git a/src/cvc/state/state_link_stats.cpp b/src/cvc/state/state_link_stats.cpp new file mode 100644 index 000000000..85dc152ae --- /dev/null +++ b/src/cvc/state/state_link_stats.cpp @@ -0,0 +1,102 @@ +/* + 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 + +namespace cvc { + +void state_link_stats::note_rx(std::size_t bytes) noexcept { + _bytes_in.fetch_add(bytes, std::memory_order_relaxed); + _frames_in.fetch_add(1, std::memory_order_relaxed); +} + +void state_link_stats::note_tx(std::size_t bytes) noexcept { + _bytes_out.fetch_add(bytes, std::memory_order_relaxed); + _frames_out.fetch_add(1, std::memory_order_relaxed); +} + +std::uint64_t state_link_stats::next_ping_seq() noexcept { + return _pings.fetch_add(1, std::memory_order_relaxed) + 1; +} + +void state_link_stats::note_rtt(std::int64_t rtt_ns) noexcept { + if (rtt_ns < 0) + return; // a clock that went backwards: not a measurement + const std::int64_t us = rtt_ns / 1000; + _pongs.fetch_add(1, std::memory_order_relaxed); + _rtt_last_us.store(us, std::memory_order_relaxed); + std::int64_t mn = _rtt_min_us.load(std::memory_order_relaxed); + while ((mn < 0 || us < mn) && + !_rtt_min_us.compare_exchange_weak(mn, us, std::memory_order_relaxed)) { + } + std::lock_guard lk(_rtt_mu); + _rtt_ring[_rtt_next] = us; + _rtt_next = (_rtt_next + 1) % kRttWindow; + if (_rtt_count < kRttWindow) + ++_rtt_count; +} + +state_link_stats::counters state_link_stats::read() const noexcept { + counters c; + c.bytes_in = _bytes_in.load(std::memory_order_relaxed); + c.bytes_out = _bytes_out.load(std::memory_order_relaxed); + c.frames_in = _frames_in.load(std::memory_order_relaxed); + c.frames_out = _frames_out.load(std::memory_order_relaxed); + c.pings_sent = _pings.load(std::memory_order_relaxed); + c.pongs_received = _pongs.load(std::memory_order_relaxed); + const auto ms = [](std::int64_t us) { return us < 0 ? -1.0 : static_cast(us) / 1000.0; }; + { + std::lock_guard lk(_rtt_mu); + if (_rtt_count > 0) { + std::array w = _rtt_ring; + std::sort(w.begin(), w.begin() + _rtt_count); + const int n = _rtt_count; + const std::int64_t med = (n % 2) ? w[n / 2] : (w[n / 2 - 1] + w[n / 2]) / 2; + c.rtt_ms = ms(med); + } + } + c.rtt_last_ms = ms(_rtt_last_us.load(std::memory_order_relaxed)); + c.rtt_min_ms = ms(_rtt_min_us.load(std::memory_order_relaxed)); + return c; +} + +std::string state_link_rate_meter::key_of(const state_link_report &r) { + return r.transport + ":" + r.remote_node_id + (r.accepted ? ":in" : ""); +} + +std::map +state_link_rate_meter::update(const std::vector &reports, double now_s) { + std::map out; + std::map next; + for (const state_link_report &r : reports) { + const std::string key = key_of(r); + rate rt; + auto it = _last.find(key); + // A link whose counters went DOWN is a new connection under the same key: start over. + if (it != _last.end() && now_s > it->second.t && r.stats.bytes_in >= it->second.bytes_in && + r.stats.bytes_out >= it->second.bytes_out) { + const double dt = now_s - it->second.t; + rt.in_bps = static_cast(r.stats.bytes_in - it->second.bytes_in) / dt; + rt.out_bps = static_cast(r.stats.bytes_out - it->second.bytes_out) / dt; + } + out[key] = rt; + next[key] = sample{r.stats.bytes_in, r.stats.bytes_out, now_s}; + } + _last.swap(next); // links no longer reported are forgotten + return out; +} + +} // namespace cvc diff --git a/src/cvc/state/state_transport_grpc.cpp b/src/cvc/state/state_transport_grpc.cpp index 82bc91fa9..28f814847 100644 --- a/src/cvc/state/state_transport_grpc.cpp +++ b/src/cvc/state/state_transport_grpc.cpp @@ -13,6 +13,7 @@ #include #include +#include // the write watchdog's report #include #include #include @@ -27,6 +28,14 @@ namespace { namespace pb = ::cvc::transport::v1; +// Steady-clock nanoseconds: the link probe's clock (a Ping carries it; the Pong echoes it back to +// the same process, so no cross-host clock sync is involved) and the link report's age / idle base. +inline std::int64_t link_now_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + // Largest gRPC message we accept/send, in bytes. The gRPC default receive cap is ~4 MiB, too small // for a large binary message payload (a 1080p RGBA frame is ~8 MiB); match the IPC transport's // 64 MiB frame cap so both wire paths agree on the ceiling. @@ -223,13 +232,48 @@ class state_transport_grpc::connection { std::atomic alive{true}; std::string remote_node_id; std::string remote_cluster_id; + // 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; + bool accepted = false; + std::int64_t opened_ns = 0; + std::atomic last_rx_ns{0}; + // Steady-clock ns when the write in progress started; 0 when none is. A peer that stops reading + // makes a stream Write block on flow control while holding write_mu, so every other writer to it + // would wait forever; the probe thread's watchdog cancels a stream whose write has been blocked + // too long (state_transport_grpc::ping_loop). `cancel` cancels the stream; the server handler + // clears it (under cancel_mu) before its ServerContext goes away. + std::atomic write_started_ns{0}; + std::mutex cancel_mu; + std::function cancel; bool can_write() const { return static_cast(write_raw); } bool write_locked(const pb::Frame &f) { if (!alive.load() || !write_raw) return false; - return write_raw(f); + const std::size_t bytes = f.ByteSizeLong(); + write_started_ns.store(link_now_ns(), std::memory_order_relaxed); + const bool ok = write_raw(f); + write_started_ns.store(0, std::memory_order_relaxed); + if (!ok) + return false; + link.note_tx(bytes); + return true; + } + + // 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); + return lk.owns_lock() && write_locked(f); + } + + // Cancel the stream: a blocked Write fails and frees write_mu (the write watchdog). + void abort_io() { + alive.store(false); + std::lock_guard lk(cancel_mu); + if (cancel) + cancel(); } bool write(const pb::Frame &f) { @@ -270,6 +314,10 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { } auto conn = std::make_shared(); + conn->accepted = true; + conn->opened_ns = link_now_ns(); + conn->last_rx_ns.store(conn->opened_ns); + conn->cancel = [ctx] { ctx->TryCancel(); }; auto *raw_stream = stream; conn->write_raw = [raw_stream](const pb::Frame &f) -> bool { return raw_stream->Write(f); }; @@ -280,6 +328,8 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { pb::Frame in; while (!ctx->IsCancelled() && stream->Read(&in)) { _owner->increment_recv_frames(); + 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(); @@ -331,6 +381,10 @@ class StateTransportServiceImpl final : public pb::StateTransport::Service { } conn->alive.store(false); + { + std::lock_guard lk(conn->cancel_mu); + conn->cancel = nullptr; // ctx dies with this handler + } _owner->unregister_connection(conn.get()); _owner->notify_connection_closed(conn->remote_node_id); return grpc::Status::OK; @@ -414,6 +468,7 @@ void state_transport_grpc::start(const std::string &listen_addr, const std::stri if (_heartbeat_interval.count() > 0) { _heartbeat_thread = std::thread([this]() { heartbeat_loop(); }); } + _ping_thread = std::thread([this]() { ping_loop(); }); } void state_transport_grpc::set_heartbeat_interval(std::chrono::milliseconds interval) noexcept { @@ -474,10 +529,18 @@ bool state_transport_grpc::connect_to_peer(const std::string &target, // makes it visible to stop(), which joins client_reader — so the thread // has to already be assigned or stop() could miss it and leave a // joinable std::thread to destruct. + conn->opened_ns = link_now_ns(); + conn->last_rx_ns.store(conn->opened_ns); + conn->cancel = [ctx = conn->client_ctx] { + if (ctx) + ctx->TryCancel(); + }; conn->client_reader = std::thread([this, conn]() { pb::Frame in; while (conn->alive.load() && conn->client_stream->Read(&in)) { increment_recv_frames(); + 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(); @@ -567,6 +630,8 @@ void state_transport_grpc::stop() { if (_heartbeat_thread.joinable()) _heartbeat_thread.join(); + if (_ping_thread.joinable()) + _ping_thread.join(); for (auto &c : conns) { if (c->client_reader.joinable()) @@ -1000,6 +1065,92 @@ std::size_t state_transport_grpc::shard_count() const { return _shards.size(); } +std::vector state_transport_grpc::link_reports() const { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = link_now_ns(); + std::vector out; + for (const auto &c : conns) { + if (!c || !c->alive.load()) + continue; + state_link_report r; + r.transport = "grpc"; + r.remote_node_id = c->remote_node_id; + 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; + r.stats = c->link.read(); + out.push_back(std::move(r)); + } + return out; +} + +bool state_transport_grpc::on_link_frame(connection &c, const pb::Frame &in) { + c.link.note_rx(in.ByteSizeLong()); + c.last_rx_ns.store(link_now_ns(), std::memory_order_relaxed); + if (in.has_ping()) { + pb::Frame f; + f.mutable_pong()->set_seq(in.ping().seq()); + f.mutable_pong()->set_sent_ns(in.ping().sent_ns()); + if (c.write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + return true; + } + if (in.has_pong()) { + c.link.note_rtt(link_now_ns() - in.pong().sent_ns()); + return true; + } + return false; +} + +void state_transport_grpc::send_pings() { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + for (auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + pb::Frame f; + f.mutable_ping()->set_seq(c->link.next_ping_seq()); + f.mutable_ping()->set_sent_ns(link_now_ns()); + if (c->try_write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + } +} + +void state_transport_grpc::ping_loop() { + // How long one write may block before its stream is cancelled (connection::write_started_ns). + constexpr std::int64_t kWriteStallNs = 3'000'000'000; + auto next = std::chrono::steady_clock::now(); + while (_running.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = link_now_ns(); + 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()) { + std::fprintf(stderr, "[grpc] write to '%s' blocked > 3 s (peer not reading): closing\n", + c->remote_node_id.c_str()); + c->abort_io(); + } + } + const auto interval = ping_interval(); + if (interval.count() <= 0 || std::chrono::steady_clock::now() < next) + continue; + next = std::chrono::steady_clock::now() + interval; + send_pings(); + } +} + std::size_t state_transport_grpc::connection_count() const { std::lock_guard lk(_conns_mu); std::size_t n = 0; diff --git a/src/cvc/state/state_transport_ws.cpp b/src/cvc/state/state_transport_ws.cpp index dddaf4d35..1bb191225 100644 --- a/src/cvc/state/state_transport_ws.cpp +++ b/src/cvc/state/state_transport_ws.cpp @@ -236,6 +236,17 @@ class state_transport_ws::connection { // reaper uses it to drop the ghost. 0 until the first frame; set to "now" when a connection opens // so a brand-new, not-yet-speaking client is not reaped. std::atomic last_rx_ns{0}; + // Link quality (state_link_stats): traffic both ways + the Ping/Pong round trip. `accepted` marks + // the server end; opened_ns (steady-clock) is the report's age base, set at registration. + state_link_stats link; + bool accepted = false; + std::int64_t opened_ns = 0; + // Steady-clock ns when the write in progress started; 0 when none is. A peer that stops reading + // (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). + std::atomic write_started_ns{0}; #ifndef __EMSCRIPTEN__ std::unique_ptr> ws; @@ -253,13 +264,28 @@ class state_transport_ws::connection { return false; beast::error_code ec; ws->binary(true); + write_started_ns.store(std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(), + std::memory_order_relaxed); ws->write(net::buffer(bytes), ec); + write_started_ns.store(0, std::memory_order_relaxed); if (ec) { alive.store(false); return false; } + link.note_tx(bytes.size()); return true; } + + // Unblock any read or write in progress and mark the connection dead (the write watchdog). + void abort_io() { + alive.store(false); + if (ws) { + beast::error_code ec; + ws->next_layer().shutdown(tcp::socket::shutdown_both, ec); + } + } #else // Browser WebSocket: the handle, a back-pointer to the owning transport (for the C callbacks), // and an `open` flag — the browser cannot send until onopen has fired. There is no reader thread; @@ -276,8 +302,11 @@ class state_transport_ws::connection { std::string bytes; if (!f.SerializeToString(&bytes)) return false; - return emscripten_websocket_send_binary(em_socket, const_cast(bytes.data()), - static_cast(bytes.size())) >= 0; + if (emscripten_websocket_send_binary(em_socket, const_cast(bytes.data()), + static_cast(bytes.size())) < 0) + return false; + link.note_tx(bytes.size()); + return true; } #endif @@ -286,6 +315,12 @@ class state_transport_ws::connection { return write_locked(f); } + // Write only if no other write holds the connection right now (the link probe never waits). + bool try_write(const pb::Frame &f) { + std::unique_lock lk(write_mu, std::try_to_lock); + return lk.owns_lock() && 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 @@ -311,6 +346,7 @@ struct state_transport_ws::impl { net::io_context ioc; std::unique_ptr acceptor; std::thread accept_thread; + std::thread ping_thread; // link probe (ping_loop), joined by stop() std::string listen_addr_resolved; }; #else @@ -396,6 +432,8 @@ void state_transport_ws::start(const std::string &listen_addr, const std::string _running.store(true); _impl->accept_thread = std::thread([this]() { accept_loop(); }); + if (!_ping_started.exchange(true)) + _impl->ping_thread = std::thread([this]() { ping_loop(); }); } // Server accept loop: synchronous accept → WebSocket handshake → admit + spawn reader. Breaks when @@ -411,6 +449,7 @@ void state_transport_ws::accept_loop() { break; // woken by stop()'s loopback self-connect auto conn = std::make_shared(); + conn->accepted = true; conn->ws = std::make_unique>(std::move(sock)); conn->ws->read_message_max(kMaxWsMessageBytes); conn->ws->accept(ec); // server-side WebSocket handshake @@ -457,6 +496,8 @@ bool state_transport_ws::connect_to_peer(const std::string &url, conn->reader = std::thread([this, conn]() { read_loop(conn); }); _running.store(true); // so stop() tears the client connection down admit_connection(conn); + if (!_ping_started.exchange(true)) + _impl->ping_thread = std::thread([this]() { ping_loop(); }); return true; } @@ -524,6 +565,9 @@ void state_transport_ws::stop() { if (_impl->accept_thread.joinable()) _impl->accept_thread.join(); + if (_impl->ping_thread.joinable()) + _impl->ping_thread.join(); + _ping_started.store(false); for (auto &c : conns) if (c->reader.joinable()) c->reader.join(); @@ -651,9 +695,24 @@ static std::int64_t steady_ns() { void state_transport_ws::deliver_inbound(connection *c, const void *data, std::size_t len) { increment_recv_frames(); c->last_rx_ns.store(steady_ns(), std::memory_order_relaxed); // liveness for the reaper + c->link.note_rx(len); pb::Frame in; if (!in.ParseFromArray(data, static_cast(len))) return; + // Link probe: answer a Ping at once (in a browser this runs in the WebSocket onmessage event, + // which a backgrounded tab still receives), and measure a Pong against our own clock. + if (in.has_ping()) { + pb::Frame f; + f.mutable_pong()->set_seq(in.ping().seq()); + f.mutable_pong()->set_sent_ns(in.ping().sent_ns()); + if (c->write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + return; + } + if (in.has_pong()) { + c->link.note_rtt(steady_ns() - in.pong().sent_ns()); + return; + } if (in.has_hello()) { c->remote_node_id = in.hello().node_id(); c->remote_cluster_id = in.hello().cluster_id(); @@ -955,6 +1014,9 @@ std::size_t state_transport_ws::pump_shard(state_cluster_shard &shard) { } std::size_t state_transport_ws::pump_all() { +#ifdef __EMSCRIPTEN__ + send_pings_if_due(); // no ping thread in a browser: the frame pump drives the probe +#endif std::vector snap; { std::lock_guard lk(_shards_mu); @@ -1069,7 +1131,86 @@ void state_transport_ws::on_inbound_message(const state_message &m, connection * _bridge->publish_message(m); } +std::vector state_transport_ws::link_reports() const { + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = steady_ns(); + std::vector out; + for (const auto &c : conns) { + if (!c || !c->alive.load() || !c->can_write()) + continue; + state_link_report r; + r.transport = "ws"; + r.remote_node_id = c->remote_node_id; + 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; + r.stats = c->link.read(); + out.push_back(std::move(r)); + } + return out; +} + +void state_transport_ws::send_pings_if_due() { + const auto interval = ping_interval(); + if (interval.count() <= 0) + return; + const std::int64_t now = steady_ns(); + std::int64_t due = _next_ping_ns.load(std::memory_order_relaxed); + if (now < due || + !_next_ping_ns.compare_exchange_strong( + due, now + std::chrono::duration_cast(interval).count())) + return; + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + for (auto &c : conns) { + // Skip a connection that cannot send yet, and one whose send buffer is badly backed up (the + // write would block the prober behind it; its RTT would only measure the backlog anyway). + if (!c || !c->alive.load() || !c->can_write() || c->send_backlog() > (64u << 10)) + continue; + pb::Frame f; + f.mutable_ping()->set_seq(c->link.next_ping_seq()); + f.mutable_ping()->set_sent_ns(steady_ns()); + if (c->try_write(f)) + _sent_frames.fetch_add(1, std::memory_order_relaxed); + } +} + +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(); + std::vector> conns; + { + std::lock_guard lk(_conns_mu); + conns = _conns; + } + const std::int64_t now = steady_ns(); + 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()) { + std::fprintf(stderr, "[ws] write to '%s' blocked > 3 s (peer not reading): closing\n", + c->remote_node_id.c_str()); + c->abort_io(); + } + } + } +#endif +} + void state_transport_ws::register_connection(std::shared_ptr conn) { + if (conn->opened_ns == 0) + conn->opened_ns = steady_ns(); std::lock_guard lk(_conns_mu); _conns.push_back(std::move(conn)); } diff --git a/src/cvc/tests/CMakeLists.txt b/src/cvc/tests/CMakeLists.txt index c9794452b..2bf85e32c 100644 --- a/src/cvc/tests/CMakeLists.txt +++ b/src/cvc/tests/CMakeLists.txt @@ -110,6 +110,7 @@ add_executable(physics_world_test physics_world_test.cpp) add_executable(avatar_sim_test avatar_sim_test.cpp) 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(physics_codec_test physics_codec_test.cpp) add_executable(physics_field_test physics_field_test.cpp) @@ -311,6 +312,7 @@ set(TEST_TARGETS avatar_sim_test holon_test holon_sync_test + state_link_stats_test room_scene_test physics_codec_test physics_field_test @@ -1671,6 +1673,13 @@ target_link_libraries(holon_sync_test GTest::gtest_main ) +target_link_libraries(state_link_stats_test + PRIVATE + cvc + GTest::gtest + GTest::gtest_main +) + target_link_libraries(room_scene_test PRIVATE cvc @@ -2086,6 +2095,7 @@ target_compile_features(physics_world_test PRIVATE cxx_std_17) target_compile_features(avatar_sim_test PRIVATE cxx_std_17) 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(physics_codec_test PRIVATE cxx_std_17) target_compile_features(physics_field_test PRIVATE cxx_std_17) @@ -2546,6 +2556,7 @@ cvc_discover_tests(physics_world_test) cvc_discover_tests(avatar_sim_test) 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(physics_codec_test) cvc_discover_tests(physics_field_test) diff --git a/src/cvc/tests/state_link_stats_test.cpp b/src/cvc/tests/state_link_stats_test.cpp new file mode 100644 index 000000000..207c9116e --- /dev/null +++ b/src/cvc/tests/state_link_stats_test.cpp @@ -0,0 +1,98 @@ +/* + 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. +*/ + +// cvc::state_link_stats / state_link_rate_meter: the per-connection counters and round-trip math +// behind the transports' link reports, without a network. + +#include +#include +#include + +using cvc::state_link_rate_meter; +using cvc::state_link_report; +using cvc::state_link_stats; + +TEST(StateLinkStatsTest, CountsTrafficBothWays) { + state_link_stats s; + s.note_rx(100); + s.note_rx(50); + s.note_tx(7); + const auto c = s.read(); + EXPECT_EQ(c.bytes_in, 150u); + EXPECT_EQ(c.frames_in, 2u); + EXPECT_EQ(c.bytes_out, 7u); + EXPECT_EQ(c.frames_out, 1u); +} + +TEST(StateLinkStatsTest, RoundTripMedianMinAndLast) { + state_link_stats s; + EXPECT_LT(s.read().rtt_ms, 0.0) << "no measurement before the first Pong"; + EXPECT_EQ(s.next_ping_seq(), 1u); + EXPECT_EQ(s.next_ping_seq(), 2u); + s.note_rtt(40'000'000); // 40 ms + s.note_rtt(80'000'000); // 80 ms: median of two = their mean, 60 ms + s.note_rtt(-5); // a clock that went backwards is ignored + auto c = s.read(); + EXPECT_EQ(c.pings_sent, 2u); + EXPECT_EQ(c.pongs_received, 2u); + EXPECT_DOUBLE_EQ(c.rtt_ms, 60.0); + EXPECT_DOUBLE_EQ(c.rtt_last_ms, 80.0); + EXPECT_DOUBLE_EQ(c.rtt_min_ms, 40.0); +} + +// One stall (a probe answered only after a frozen tab woke) barely moves the median, and leaves the +// window entirely after kRttWindow more samples. +TEST(StateLinkStatsTest, MedianShrugsOffAStall) { + state_link_stats s; + for (int i = 0; i < 4; ++i) + s.note_rtt(50'000'000); + s.note_rtt(14'800'000'000); // a 14.8 s stall + for (int i = 0; i < 3; ++i) + s.note_rtt(50'000'000); + EXPECT_DOUBLE_EQ(s.read().rtt_ms, 50.0) << "7 of 8 recent samples are 50 ms"; + EXPECT_DOUBLE_EQ(s.read().rtt_last_ms, 50.0); +} + +TEST(StateLinkStatsTest, RateMeterTurnsCountersIntoRates) { + state_link_rate_meter m; + state_link_report r; + r.transport = "ws"; + r.remote_node_id = "alice"; + r.accepted = true; + r.stats.bytes_in = 1000; + r.stats.bytes_out = 4000; + const std::string key = state_link_rate_meter::key_of(r); + EXPECT_EQ(key, "ws:alice:in"); + + auto first = m.update({r}, 10.0); + EXPECT_DOUBLE_EQ(first[key].in_bps, 0.0) << "no interval yet"; + + r.stats.bytes_in = 3000; // +2000 in 2 s + r.stats.bytes_out = 4400; // +400 in 2 s + auto second = m.update({r}, 12.0); + EXPECT_DOUBLE_EQ(second[key].in_bps, 1000.0); + EXPECT_DOUBLE_EQ(second[key].out_bps, 200.0); + + // A reconnect under the same key restarts its counters: no negative or bogus rate. + r.stats.bytes_in = 10; + r.stats.bytes_out = 10; + auto third = m.update({r}, 13.0); + EXPECT_DOUBLE_EQ(third[key].in_bps, 0.0); + EXPECT_DOUBLE_EQ(third[key].out_bps, 0.0); + + // A link that is no longer reported is forgotten. + auto gone = m.update({}, 14.0); + EXPECT_TRUE(gone.empty()); +} diff --git a/src/cvc/tests/state_transport_grpc_test.cpp b/src/cvc/tests/state_transport_grpc_test.cpp index f947561b2..32bc51009 100644 --- a/src/cvc/tests/state_transport_grpc_test.cpp +++ b/src/cvc/tests/state_transport_grpc_test.cpp @@ -543,6 +543,16 @@ TEST(StateTransportGrpcTest, ConnectionClosedReportsThePeersNodeId) { client.start("127.0.0.1:0", "alice", "C"); ASSERT_TRUE(client.connect_to_peer(relay.listen_address(), std::chrono::milliseconds(2000))); ASSERT_TRUE(wait_connected(client, relay, std::chrono::milliseconds(2000))); + // Leave only once the relay has READ alice's Hello: a cancelled stream drops frames the relay has + // not read yet, and a stream that never identified itself is (by contract) not reported. + const auto hello_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + for (;;) { + const auto links = relay.link_reports(); + if (!links.empty() && links[0].remote_node_id == "alice") + break; + ASSERT_LT(std::chrono::steady_clock::now(), hello_deadline) << "the relay never saw alice"; + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } client.stop(); // alice leaves const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); @@ -561,6 +571,42 @@ TEST(StateTransportGrpcTest, ConnectionClosedReportsThePeersNodeId) { EXPECT_EQ(closed[0], "alice"); } +// The link probe: both ends Ping, the other end Pongs, and each measures the round trip on its own +// clock. Each side reports one link with its peer's node id, traffic both ways, and an RTT -- the +// same report a relay gets for a client and a server gets for a peer (delegation) server. +TEST(StateTransportGrpcTest, LinkProbeMeasuresRoundTripAtBothEnds) { + cvc::state_transport_grpc relay, client; + relay.set_ping_interval(std::chrono::milliseconds(30)); + client.set_ping_interval(std::chrono::milliseconds(30)); + relay.start("127.0.0.1:0", "relay", "C"); + client.start("127.0.0.1:0", "alice", "C"); + ASSERT_TRUE(client.connect_to_peer(relay.listen_address(), std::chrono::milliseconds(2000))); + ASSERT_TRUE(wait_connected(client, relay, std::chrono::milliseconds(2000))); + const auto measured = [](const cvc::state_transport_grpc &t) { + const auto links = t.link_reports(); + return links.size() == 1 && links[0].stats.pongs_received >= 2 && links[0].stats.rtt_ms >= 0.0; + }; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!(measured(relay) && measured(client))) { + ASSERT_LT(std::chrono::steady_clock::now(), deadline) << "no round trip measured"; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + const auto server_end = relay.link_reports().at(0); + const auto client_end = client.link_reports().at(0); + EXPECT_EQ(server_end.transport, "grpc"); + EXPECT_TRUE(server_end.accepted); + EXPECT_EQ(server_end.remote_node_id, "alice"); + EXPECT_FALSE(client_end.accepted); + EXPECT_EQ(client_end.remote_node_id, "relay"); + for (const auto &r : {server_end, client_end}) { + EXPECT_GT(r.stats.bytes_in, 0u); + EXPECT_GT(r.stats.bytes_out, 0u); + EXPECT_LT(r.stats.rtt_ms, 1000.0) << "loopback round trip"; + } + client.stop(); + relay.stop(); +} + TEST(StateTransportGrpcTest, DisconnectAfterStop) { cvc::state_transport_grpc tA, tB; tA.start("127.0.0.1:0", "A", "C"); diff --git a/src/cvc/tests/state_transport_ws_test.cpp b/src/cvc/tests/state_transport_ws_test.cpp index b2b8d6433..03863305b 100644 --- a/src/cvc/tests/state_transport_ws_test.cpp +++ b/src/cvc/tests/state_transport_ws_test.cpp @@ -13,6 +13,10 @@ // (the browser topology — clients that dial without a local acceptor, as a wasm peer will). #include +#include +#include +#include +#include #include #include #include @@ -254,6 +258,9 @@ TEST(StateTransportWsTest, RelayFansMediaFrameToOtherClient) { TEST(StateTransportWsTest, RelayReapsIdleConnection) { cvc::app aA; cvc::state_transport_ws S, tA; + // No link probe here: a client that answers Pings is NOT idle (see QuietClientAnsweringPings...). + S.set_ping_interval(std::chrono::milliseconds(0)); + tA.set_ping_interval(std::chrono::milliseconds(0)); S.start("127.0.0.1:0", "S", "C"); S.set_relay(true); std::mutex mu; @@ -295,6 +302,117 @@ TEST(StateTransportWsTest, RelayReapsIdleConnection) { S.stop(); } +// The link probe: both ends Ping, the other end Pongs, and each measures the round trip on its own +// clock. Each side reports one link with its peer's node id, traffic both ways, and an RTT. +TEST(StateTransportWsTest, LinkProbeMeasuresRoundTripAtBothEnds) { + cvc::app aA; + cvc::state_transport_ws S, tA; + S.set_ping_interval(std::chrono::milliseconds(30)); + tA.set_ping_interval(std::chrono::milliseconds(30)); + S.start("127.0.0.1:0", "S", "C"); + cvc::state_cluster_shard sA(aA, "C", "A"); + sA.attach(); + tA.register_shard(&sA); + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + const auto measured = [](const cvc::state_transport_ws &t) { + const auto links = t.link_reports(); + return links.size() == 1 && links[0].stats.pongs_received >= 2 && links[0].stats.rtt_ms >= 0.0; + }; + ASSERT_TRUE( + wait_until([&] { return measured(S) && measured(tA); }, std::chrono::milliseconds(3000))); + const auto server = S.link_reports().at(0); + const auto client = tA.link_reports().at(0); + EXPECT_EQ(server.transport, "ws"); + EXPECT_TRUE(server.accepted); + EXPECT_EQ(server.remote_node_id, "A"); + EXPECT_FALSE(client.accepted); + EXPECT_EQ(client.remote_node_id, "S"); + for (const auto &r : {server, client}) { + EXPECT_GT(r.stats.bytes_in, 0u); + EXPECT_GT(r.stats.bytes_out, 0u); + EXPECT_GE(r.stats.rtt_min_ms, 0.0); + EXPECT_LT(r.stats.rtt_ms, 1000.0) << "loopback round trip"; + EXPECT_GE(r.age_s, 0.0); + } + tA.stop(); + S.stop(); +} + +// A client that sends nothing of its own but still answers the relay's Pings is alive, so the idle +// reaper keeps it -- the backgrounded-browser-tab case (its WebSocket onmessage still fires and +// answers, while its timers are throttled). A client that cannot answer is still reaped +// (RelayReapsIdleConnection). +TEST(StateTransportWsTest, QuietClientAnsweringPingsIsNotReaped) { + cvc::app aA; + cvc::state_transport_ws S, tA; + S.set_ping_interval(std::chrono::milliseconds(20)); + tA.set_ping_interval(std::chrono::milliseconds(0)); // the client never initiates anything + S.start("127.0.0.1:0", "S", "C"); + S.set_relay(true); + std::atomic reaped{0}; + S.set_on_connection_closed([&](const std::string &) { reaped.fetch_add(1); }); + cvc::state_cluster_shard sA(aA, "C", "A"); + sA.attach(); + tA.register_shard(&sA); + ASSERT_TRUE(tA.connect_to_peer(ws(S.listen_address()), std::chrono::milliseconds(2000))); + ASSERT_TRUE( + wait_until([&] { return S.connection_count() >= 1; }, std::chrono::milliseconds(2000))); + for (int i = 0; i < 20; ++i) { // ~600 ms of reaping with a 150 ms idle limit + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + S.reap_idle(std::chrono::milliseconds(150)); + } + EXPECT_EQ(reaped.load(), 0) << "a client answering Pings is not a ghost"; + EXPECT_EQ(S.connection_count(), 1u); + tA.stop(); + S.stop(); +} + +// A peer that stops reading (a frozen browser tab, a dead network) must not hang the relay: a write +// to it blocks once its TCP buffers fill, holding the connection's write lock, so without the write +// watchdog every later writer to it -- the relay's main loop included -- waits forever (seen in +// production). The watchdog closes a connection whose write has been blocked > 3 s: the write +// returns and the connection is gone. +TEST(StateTransportWsTest, WriteToAPeerThatStopsReadingIsAbortedNotHung) { + namespace beast = boost::beast; + namespace asio = boost::asio; + cvc::state_transport_ws S; + S.set_ping_interval(std::chrono::milliseconds(0)); // the watchdog still runs; no probe 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); + + // A raw WebSocket peer: completes the handshake, then never reads. + 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))); + + std::atomic returned{false}; + std::thread writer([&] { + cvc::ws_media_frame m; + m.origin_node_id = "S"; + m.stream_id = "camera"; + m.codec = "mjpeg"; + m.payload.assign(std::size_t(32) << 20, 0x5a); // far beyond any socket buffer + S.publish_media_frame(m); + S.publish_media_frame(m); + returned.store(true); + }); + EXPECT_TRUE(wait_until([&] { return returned.load(); }, std::chrono::milliseconds(10000))) + << "the write to a non-reading peer never returned (the relay would be hung)"; + EXPECT_TRUE( + wait_until([&] { return S.connection_count() == 0; }, std::chrono::milliseconds(5000))) + << "the stalled connection was not closed"; + writer.join(); + beast::error_code ec; + peer.next_layer().close(ec); + S.stop(); +} + // Late-joiner bootstrap: a relay that holds the room tree in its own shard pushes a full snapshot // to each newly admitted connection, so a client that joins AFTER others are present still sees // them (live set_relay forwarding only carries writes made after the client connects). This is what From e5fe0ebd70999e497c7a43627c7f2dbf1c118e81 Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sat, 24 Oct 2026 18:48:53 -0500 Subject: [PATCH 31/33] Holon authority: the server vetoes intents and can override avatars (#788) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roadmap §5h's propose -> veto -> override, now that avatars are rigid bodies (an unchecked intent is a real abuse vector: asking for 1000 m/s would hurl an avatar through its peers). Veto: holon::sanitize_intent caps the horizontal speed at max_speed (scaled, direction kept), drops the vertical unless allow_vertical, turns a non-finite move into no move and a non-finite yaw into +X, and wraps the yaw to [-pi, pi]. Every intent passes it on the way in -- set_intent and queue_intent on the server, set_intent on a client -- so an honest client predicts exactly what the server will integrate, and one that asks for more is overruled on reconcile. The server counts its vetoes (arisrv's room log prints them). The conference cap is 4 m/s, twice the walk. Override: holon::override_state teleports an avatar and holon::set_mass re-masses it (physics; avatar_world::set_mass, kept across set_statics). Both are the authority's alone (false on a client), and the result replicates like any authoritative pose. --- inc/cvc/ariadne/avatar_physics.h | 6 +- inc/cvc/ariadne/holon.h | 28 +++++++- src/arisrv/main.cpp | 5 +- src/cvc/ariadne/avatar_physics.cpp | 19 ++++-- src/cvc/ariadne/holon.cpp | 61 ++++++++++++++++- src/cvc/ariadne/holon_sync.cpp | 1 + src/cvc/tests/holon_test.cpp | 102 +++++++++++++++++++++++++++++ 7 files changed, 212 insertions(+), 10 deletions(-) diff --git a/inc/cvc/ariadne/avatar_physics.h b/inc/cvc/ariadne/avatar_physics.h index 764549feb..76577411c 100644 --- a/inc/cvc/ariadne/avatar_physics.h +++ b/inc/cvc/ariadne/avatar_physics.h @@ -96,6 +96,9 @@ class avatar_world { 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(); @@ -108,8 +111,9 @@ class avatar_world { 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); + std::uint32_t create_avatar(const cvc::physics::body_state &feet, bool dynamic, double mass); avatar_body _body; double _dt; diff --git a/inc/cvc/ariadne/holon.h b/inc/cvc/ariadne/holon.h index 7a60be1f8..f42ed1e2a 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -95,6 +95,11 @@ struct holon_config { // 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 { @@ -130,9 +135,29 @@ class holon { // Set a participant's latest intent. Server: the validated input it integrates // 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). + // 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 @@ -258,6 +283,7 @@ class holon { 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 _statics_rev = 0; }; diff --git a/src/arisrv/main.cpp b/src/arisrv/main.cpp index f267ab77c..4d868ad35 100644 --- a/src/arisrv/main.cpp +++ b/src/arisrv/main.cpp @@ -331,8 +331,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; diff --git a/src/cvc/ariadne/avatar_physics.cpp b/src/cvc/ariadne/avatar_physics.cpp index e635f959f..528c4fe36 100644 --- a/src/cvc/ariadne/avatar_physics.cpp +++ b/src/cvc/ariadne/avatar_physics.cpp @@ -65,7 +65,7 @@ avatar_world::avatar_world(double fixed_dt, const avatar_body &body) : _body(bod avatar_world::~avatar_world() = default; -std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynamic) { +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; @@ -73,7 +73,7 @@ std::uint32_t avatar_world::create_avatar(const ph::body_state &feet, bool dynam d.rotation = feet.rotation; d.linear_velocity = feet.linear_velocity; d.material = static_cast(_avatar_material); - d.mass = dynamic ? _body.mass : 0.0; + 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 @@ -103,14 +103,15 @@ void avatar_world::set_statics(const std::vector &statics) { _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.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.id = create_avatar(feet, dynamic); + a.mass = _body.mass; + a.id = create_avatar(feet, dynamic, a.mass); _avatars[pid] = a; } @@ -162,6 +163,16 @@ void avatar_world::move_kinematic(const std::string &pid, const ph::pose &feet) 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 { diff --git a/src/cvc/ariadne/holon.cpp b/src/cvc/ariadne/holon.cpp index 0e0c60fb8..59c78a342 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -14,6 +14,7 @@ */ #include +#include #include #include #include @@ -89,6 +90,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()) @@ -96,10 +125,35 @@ 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; @@ -120,7 +174,10 @@ void holon::queue_intent(const std::string &pid, const avatar_intent &in, std::u at = next; if (!p.queued.empty() && at < p.queued.back().first) at = p.queued.back().first; - p.queued.emplace_back(at, in); + 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, diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 89214f6db..5413c770b 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -172,6 +172,7 @@ bool read_intent(cvc::app &app, const std::string &room, const std::string &pid, 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; } diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index 873862763..1d7b26498 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include using cvc::ariadne::avatar_intent; @@ -617,3 +618,104 @@ TEST(HolonPhysicsTest, ClientPredictionMatchesTheServersPhysics) { 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); } + +// ── 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"; +} From f25586dd11f9b551de8dc1c283965c859ee9e38e Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 25 Oct 2026 00:51:12 -0500 Subject: [PATCH 32/33] S7: relay-side avatar interest, ghost-free clients, opt-in mesh LOD (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Holon authority: the server vetoes intents and can override avatars Roadmap §5h's propose -> veto -> override, now that avatars are rigid bodies (an unchecked intent is a real abuse vector: asking for 1000 m/s would hurl an avatar through its peers). Veto: holon::sanitize_intent caps the horizontal speed at max_speed (scaled, direction kept), drops the vertical unless allow_vertical, turns a non-finite move into no move and a non-finite yaw into +X, and wraps the yaw to [-pi, pi]. Every intent passes it on the way in -- set_intent and queue_intent on the server, set_intent on a client -- so an honest client predicts exactly what the server will integrate, and one that asks for more is overruled on reconcile. The server counts its vetoes (arisrv's room log prints them). The conference cap is 4 m/s, twice the walk. Override: holon::override_state teleports an avatar and holon::set_mass re-masses it (physics; avatar_world::set_mass, kept across set_statics). Both are the authority's alone (false on a client), and the result replicates like any authoritative pose. * S7 groundwork: per-peer delivery filter, journal trim + O(new) replay, shard admits(); scaffold the S7 sources and tests * S7 groundwork: push_snapshot_to and media gate hooks on the transport interfaces * S7: relay-side avatar interest, ghost-free clients, opt-in mesh LOD Relay (arisrv): - room_aoi: per-receiver distance tiers (inner/middle/outer) with a downgrade dwell, conference and hall presets; the conference preset's inner radius covers the whole 20 m room. - room_aoi_relay: installs a per-peer delivery filter (deny another participant's intent, a non-inner subject's pose subtree, a departed subject's leaves), pushes non-inner poses at 5 Hz with a fresh server_tick before the drain, and gates media (middle: 2 fps camera thumbnails + audio, outer: nothing). - arisrv flags: --aoi, --aoi-video-max, --relay-gate, --relay-journal, --media-stall-ms, --scene large, and --s7 off to restore S6 behaviour. Transports and state core: - write-once peer identity on gRPC and WS connections; the relay interest gate (opt-in) uses shard admits(); forwards, snapshot replies and the WS broadcast honour the peer registry; push_snapshot_to; media gate and gRPC stall drop. - journal trim after each drain on the relay. Clients: - the arigl shard is rooted at "conf" (CVC_ARIGL_REPLICATE_ROOT restores the whole tree); pose leaves are journaled once per change; a peer gets no body and no marker until its first pose; distance audio gain and a voice.talking leaf. LOD: - LodPyramidCache builds one QEM pyramid per distinct mesh off the frame; realize_scene takes RealizeOptions; room_view selects LOD each frame; seed_large_demo_scene adds a 100 m hall with a grid of bunnies. Tests: room_aoi, room_aoi_relay, the loopback room relay integration test, new transport/holon/state cases, cvcgl_lod_cache and room_view LOD cases; arisrv_aoi_bench measures per-receiver traffic. * S7: read only whole pose samples; fix two integration-test races - read_pose now requires the position, orientation and tick leaves and a non-degenerate quaternion (normalised). The leaves replicate one by one, so a reader could catch a subtree half applied; with peers now given a body only at their first sample, that zero quaternion reached add_body and threw. - The test relay (and the bench) place newcomers until a pass finds none: a join landing between the two pumps used to leave the AOI seeing a participant on the spawn ring first. - The reconnect test no longer rejoins under the same node id, which replication's (origin, sequence) seen-set cannot accept; it checks the zeroed-intent bootstrap under that id, then joins a relaunch under a fresh id as arigl does. * gRPC transport: an accepted stream is never written after its handler returns The server handler's ServerReaderWriter dies when Channel() returns, but publish, the relay's forwards and the link probe write to it from other threads. The handler only cleared `alive`, so a write already past that check kept using the freed stream: under parallel load on Windows the star-relay tests crashed in CompletionQueue::Pluck from send_pings (access violations, heap corruption, aborts in 4 of 6 runs). The handler now cancels the stream, failing any Write blocked on it, then takes write_mu once: every write runs under write_mu and checks `alive` there, so none is in progress or starts after the stream goes. * S7: keep the AOI's bookkeeping out of cvc.dll's export table WINDOWS_EXPORT_ALL_SYMBOLS exports every std:: container instantiated over a cvc type, and S7 left cvc.dll at 65,414 of its 65,535 export slots (gRPC build). room_aoi's members and pair state, the relay driver's per-transport registrations and pose versions, and the media gate's thumbnail timers are now file-local types behind a pimpl, and aoi_snapshot stores its tiers as uint8_t, so their instantiations are no longer exported: 64,941 names (473 freed). Behaviour is unchanged. * S7 review fixes: no relay crash on odd pids, bounded media waits, departed and rejoin handling From an adversarial review of the S7 branch: - A participant pid that is no C identifier (the room tree takes any child name) made state_peer_registry::add_peer throw out of the room step and abort the relay; such a pid is now filtered as a subject but never registered as a receiver (no connection can carry it). - gRPC media: write_mu is a timed mutex and a media frame waits at most the stall window for a receiver's stream, instead of only sampling the in-flight write's start once and then queueing unbounded; the gate is asked once the stream is held, so a dropped frame spends no thumbnail. - A departed subject keeps only pose and intent withheld (S6 forwarded its net and voice, and an in-process reconnect's voice writes landed in the departed window), and the departed set is capped at the most recent 1024. - A client no longer takes the pose subtree a departed peer left behind as the pose of its rejoin (no body at the old spot, no clock seek back); a restarted relay's low ticks still count as new. - voice.talking: arigl forces its flips, so a stale echo of its own leaf from the WS re-broadcast cannot swallow the next one. - arisrv always writes the AOI params (disabled with --aoi off / --s7 off), so a client that kept an earlier relay's leaves restores gain 1; numeric flags and CVC_ARISRV_S7 are validated (usage error, exit 2). - LodPyramidCache skips the content hash for a mesh over the build cap. - The loopback suite is RoomRelayLoopbackTest: CI excludes names matching "Integration" from PR and recipe runs. * S7 docs: measured bench results, known limits, and the review's corrections * S7 loopback tests: lossless media and paced egress, so a loaded runner can't fail them DefaultRoomUnchangedUnderConferencePreset failed about half its runs on a loaded Linux runner. The cause was the WebSocket transport's backlog drop, not the gate. On Linux that drop sheds a media frame whenever a starved receiver's socket holds more than 32 KB unsent, so with the AOI on or off a few percent of frames never arrived (0.914 to 0.972 against the 0.98 floor). - The backlog drop is now state_transport_ws::set_media_backlog_drop(bytes), still 32 KB by default, with 0 turning it off. Its skips are counted in total_media_backlog_dropped(), and arisrv's 5 s AOI line prints them as "backlog". - The loopback test relay and clients turn the drop off, as gRPC's stall drop already is. Media is then lossless on loopback, and the default-room and middle-tier media checks are exact (every frame lands) instead of a 2% tolerance. - ControlPlaneEgressDrops compared per-second rates. run_room skips ahead when behind, so a starved loop ran fewer client frames per room step, which shrinks the echo's share of the pre-S7 egress. The ratio fell from about 4.3 to 2.3, under the required 3. The measurement now runs a fixed 30 steps with exactly 3 client frames per 2 steps, and compares bytes per second of room time. Tested on catx-03, the tests pinned to two cores beside four busy loops: - the default-room test, 30/30 runs (5/10 before); - the middle-tier media test, 30/30 (it hadn't failed yet, but the same drop could cost it a mic chunk); - the egress test, 8/8 (1/3 before; ratio 4.2 to 4.4 with or without contention). On the loaded Windows box the egress test passes 5/5 (0/5 before), and the S7 set there passes 134/134. * WS re-broadcast: per-connection diffs, full tree only on request (#791) * WS broadcast_snapshot: send each connection a diff, full tree only on request The relay's ~1 Hz WebSocket re-broadcast wrote every connection the whole room tree each call. In the hall (large scene) that was most of a browser's state traffic. It exists to heal a client that missed a live re-fan, so it now sends only what each connection does not already hold. Per connection the transport keeps, for each path, a digest of the content the last snapshot frame (the admit snapshot or a broadcast) carried. It forgets a path whenever any other frame touching it is written to that peer (a live mutation, a targeted push) or arrives from it (the peer's own write). A broadcast then sends the entries whose digest differs, plus the forgotten ones; an unchanged tree sends nothing. Because a live frame forgets the path, a change the client missed after one it received is re-sent even when the tree is back to what the last broadcast carried. A peer's own write is re-sent with the tree's value, which also corrects a write the relay overrode. The bookkeeping sits under its own mutex, which is never held across a socket write (a reader thread forgets its peer's writes). A writer holds write_mu from its record or forget through its write, so the record matches the order the peer receives frames. The whole tree goes to a peer only when it asks: the new client call state_transport_ws::request_full_refresh() sends a SnapshotRequest, and that peer's next broadcast carries everything it is delivered. That covers state a client dropped on its own side, which the relay cannot see. There is no periodic full snapshot. The admit snapshot seeds the record, so a new connection's first broadcast is already a diff. The per-connection registry filtering (delivery_view) and the 2 MB backlog skip are unchanged. A skipped connection keeps its record, so its next broadcast carries everything that changed meanwhile. Tests: BroadcastDiffConvergesAClientThatDroppedFrames covers a missed re-fan, a missed change after a received live frame, a frame the client dropped itself (healed only by request_full_refresh), and the client's own overridden write. BroadcastSnapshotIsPerConnectionFiltered now also checks that a second broadcast with nothing changed writes nothing. * Roadmap 5h.1: measured WS re-broadcast diffs against the full tree * WS transport: a write to a peer that stops reading no longer hangs on Windows (#794) A peer that stops reading makes the relay's synchronous WebSocket write block once the socket buffers fill, while it holds the connection's write lock. The probe thread's watchdog handles that by calling shutdown() on the socket after kWriteStallNs (3 s), which wakes the blocked send() on Linux. Winsock's shutdown() does not wake a send() blocked in another thread, so on Windows the write never returned. StateTransportWsTest. WriteToAPeerThatStopsReadingIsAbortedNotHung hung forever. The watchdog did fire ("blocked > 3 s ... closing"). On Windows every connection's socket now gets SO_SNDTIMEO = kWriteStallNs, set right after it is created (accepted or dialed). A blocked send then fails with WSAETIMEDOUT at the same 3 s limit, the write returns an error, and the connection is marked dead and releases its lock. No other thread closes the socket. kWriteStallNs moves out of ping_loop so both share it. Other platforms are unchanged: bound_blocking_writes is a no-op there. Windows (static WS+gRPC build, MSVC 14.44): the test passes in ~3.1 s, the whole state_transport_ws_test suite is 23/23 twice with nothing excluded, and room_relay_integration_test is 10/10. --- docs/roadmap/ARIADNE_CONFERENCE_ROADMAP.md | 306 +++- docs/roadmap/DISTRIBUTED_STATE_ROADMAP.md | 120 +- docs/roadmap/VISIBILITY-AND-LOD-ROADMAP.md | 13 +- inc/cvc/ariadne/conference/room_aoi.h | 275 ++++ inc/cvc/ariadne/conference/room_aoi_relay.h | 154 ++ inc/cvc/ariadne/conference/room_net.h | 19 + inc/cvc/ariadne/conference/room_scene.h | 25 +- inc/cvc/ariadne/holon.h | 42 +- inc/cvc/ariadne/holon_sync.h | 24 +- inc/cvc/gl/LodPyramidCache.h | 173 ++ inc/cvc/gl/ariadne/room_view.h | 61 +- inc/cvc/gl/ariadne/scene_realize.h | 34 + inc/cvc/media/media_endpoint.h | 11 + inc/cvc/state/state_change_journal.h | 3 + inc/cvc/state/state_cluster_shard.h | 17 + inc/cvc/state/state_peer_registry.h | 53 +- inc/cvc/state/state_transport.h | 16 + inc/cvc/state/state_transport_grpc.h | 65 +- inc/cvc/state/state_transport_ws.h | 102 +- src/arigl/main.cpp | 167 +- src/arisrv/CMakeLists.txt | 11 + src/arisrv/aoi_bench.cpp | 1271 ++++++++++++++ src/arisrv/main.cpp | 235 ++- src/cvc/CMakeLists.txt | 4 +- src/cvc/ariadne/conference/room_aoi.cpp | 602 +++++++ src/cvc/ariadne/conference/room_aoi_relay.cpp | 453 +++++ src/cvc/ariadne/conference/room_net.cpp | 43 + src/cvc/ariadne/conference/room_scene.cpp | 60 + src/cvc/ariadne/holon.cpp | 43 +- src/cvc/ariadne/holon_sync.cpp | 102 +- src/cvc/state/state_change_journal.cpp | 24 +- src/cvc/state/state_cluster_shard.cpp | 14 + src/cvc/state/state_peer_registry.cpp | 66 +- src/cvc/state/state_transport_grpc.cpp | 389 +++-- src/cvc/state/state_transport_ws.cpp | 540 ++++-- src/cvc/tests/CMakeLists.txt | 43 + src/cvc/tests/holon_sync_test.cpp | 260 +++ src/cvc/tests/holon_test.cpp | 68 + src/cvc/tests/room_aoi_relay_test.cpp | 618 +++++++ src/cvc/tests/room_aoi_test.cpp | 728 ++++++++ src/cvc/tests/room_intrinsics_test.cpp | 91 + src/cvc/tests/room_relay_integration_test.cpp | 1466 +++++++++++++++++ src/cvc/tests/room_scene_test.cpp | 222 ++- src/cvc/tests/state_change_journal_test.cpp | 70 + src/cvc/tests/state_cluster_shard_test.cpp | 109 ++ src/cvc/tests/state_interest_filter_test.cpp | 54 + src/cvc/tests/state_peer_registry_test.cpp | 248 +++ src/cvc/tests/state_transport_grpc_test.cpp | 534 ++++++ src/cvc/tests/state_transport_ws_test.cpp | 749 +++++++++ src/cvcGL/CMakeLists.txt | 7 + src/cvcGL/LodPyramidCache.cpp | 292 ++++ src/cvcGL/ariadne/room_view.cpp | 70 +- src/cvcGL/ariadne/scene_realize.cpp | 129 +- src/cvcGL/test/cvcgl_ariadne_realize.cpp | 230 +++ src/cvcGL/test/cvcgl_lod_cache.cpp | 566 +++++++ src/cvcGL/test/cvcgl_room_view.cpp | 252 ++- 56 files changed, 11938 insertions(+), 405 deletions(-) create mode 100644 inc/cvc/ariadne/conference/room_aoi.h create mode 100644 inc/cvc/ariadne/conference/room_aoi_relay.h create mode 100644 inc/cvc/gl/LodPyramidCache.h create mode 100644 src/arisrv/aoi_bench.cpp create mode 100644 src/cvc/ariadne/conference/room_aoi.cpp create mode 100644 src/cvc/ariadne/conference/room_aoi_relay.cpp create mode 100644 src/cvc/tests/room_aoi_relay_test.cpp create mode 100644 src/cvc/tests/room_aoi_test.cpp create mode 100644 src/cvc/tests/room_relay_integration_test.cpp create mode 100644 src/cvcGL/LodPyramidCache.cpp create mode 100644 src/cvcGL/test/cvcgl_lod_cache.cpp 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/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 e9cc18c3f..23313f38d 100644 --- a/inc/cvc/ariadne/conference/room_scene.h +++ b/inc/cvc/ariadne/conference/room_scene.h @@ -45,6 +45,8 @@ namespace conference { // 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 @@ -83,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); @@ -114,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 f42ed1e2a..e762b5757 100644 --- a/inc/cvc/ariadne/holon.h +++ b/inc/cvc/ariadne/holon.h @@ -125,13 +125,35 @@ 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, from the next quantum on (any queued stamped // intents are dropped). Client: only the LOCAL avatar's own input (a non-local @@ -190,7 +212,9 @@ class holon { // 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). + // (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 @@ -204,6 +228,13 @@ class holon { 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 @@ -233,6 +264,8 @@ 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. @@ -280,10 +313,15 @@ class holon { cvc::world_clock _clock; // declared before _driver: _driver references it cvc::clock_driver _driver; std::map _participants; + // 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; }; diff --git a/inc/cvc/ariadne/holon_sync.h b/inc/cvc/ariadne/holon_sync.h index 9dca18cd4..a8fb7f822 100644 --- a/inc/cvc/ariadne/holon_sync.h +++ b/inc/cvc/ariadne/holon_sync.h @@ -66,11 +66,14 @@ 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). 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 writes every leaf every call. +// 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, std::uint64_t heartbeat_ticks = 0); @@ -117,11 +120,12 @@ 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. With physics, both pumps (re)load the holon's statics from the room scene whenever -// its revision changes. +// 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); 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 e2117ad45..1f54062c7 100644 --- a/inc/cvc/gl/ariadne/room_view.h +++ b/inc/cvc/gl/ariadne/room_view.h @@ -12,18 +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. +// 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 @@ -37,6 +49,8 @@ class holon; } // namespace ariadne namespace gl { class SceneRenderer; +class LodPyramidCache; +struct lod_stats; namespace ariadne { struct RealizedScene; @@ -57,17 +71,38 @@ 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 @@ -103,18 +138,24 @@ class room_view { 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 - std::map _names; // pid -> display name (the roster), for labels + 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 + 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..43c63821a 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,16 @@ 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 91c9ad4aa..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 @@ -316,6 +319,10 @@ struct ConferenceSession { 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 @@ -356,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) @@ -366,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) @@ -391,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. @@ -460,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); @@ -578,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) @@ -654,6 +698,16 @@ struct ConferenceSession { 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. @@ -722,7 +776,15 @@ struct ConferenceSession { // 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(); @@ -753,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 @@ -804,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(); }); } @@ -899,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() { @@ -932,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 @@ -940,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; } @@ -1076,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) { @@ -1108,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; @@ -1122,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_) / 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 4d868ad35..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; @@ -193,7 +322,7 @@ int main(int argc, char **argv) { // every participant's proposed intent from the replicated tree, integrates it on // 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 fan_local_writes. The relay loop paces ~5 Hz; the room steps + // 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, @@ -204,25 +333,27 @@ int main(int argc, char **argv) { 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. -#if CVC_ENABLE_WS // 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. - const auto fan_local_writes = [&] { - auto pending = shard.drain_local(); - for (const auto &m : pending) { - state_tp.publish(m); - ws_tp.publish(m); - } - }; + // 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 - // pump_all publishes only our local writes, never the clients' replicated mutations (those ride - // set_relay forwarding), so this cannot echo. - const auto fan_local_writes = [&] { state_tp.pump_all(); }; + 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; @@ -236,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 @@ -257,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 @@ -344,9 +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); } + // 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 + 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); } - fan_local_writes(); + 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) { @@ -388,13 +560,16 @@ int main(int argc, char **argv) { for (int i = 1; i < kRoomSubsteps && !g_stop.load(); ++i) { std::this_thread::sleep_for(kRoomStep); cvc::ariadne::server_pump(server_holon, app, room); - fan_local_writes(); + 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 1995b5895..325309bfb 100644 --- a/src/cvc/CMakeLists.txt +++ b/src/cvc/CMakeLists.txt @@ -1312,7 +1312,7 @@ 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/avatar_physics.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 @@ -1338,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/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_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 5943918db..a29e7f120 100644 --- a/src/cvc/ariadne/conference/room_scene.cpp +++ b/src/cvc/ariadne/conference/room_scene.cpp @@ -14,6 +14,7 @@ */ #include +#include #include #include #include @@ -216,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); @@ -254,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); @@ -349,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 59c78a342..101036456 100644 --- a/src/cvc/ariadne/holon.cpp +++ b/src/cvc/ariadne/holon.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace cvc { namespace ariadne { @@ -51,9 +52,10 @@ 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; @@ -61,12 +63,24 @@ void holon::add_participant(const std::string &pid, bool local, 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. - if (_world) - _world->add_avatar(pid, start, _role == holon_role::server || local); + // 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); @@ -78,10 +92,24 @@ void holon::set_statics(const std::vector &statics, std::uint64_t _world->set_statics(statics); } +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()); @@ -191,6 +219,8 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body 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 @@ -203,6 +233,7 @@ void holon::apply_authoritative(const std::string &pid, const cvc::physics::body : 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; @@ -237,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; diff --git a/src/cvc/ariadne/holon_sync.cpp b/src/cvc/ariadne/holon_sync.cpp index 5413c770b..892b9c81d 100644 --- a/src/cvc/ariadne/holon_sync.cpp +++ b/src/cvc/ariadne/holon_sync.cpp @@ -55,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(); @@ -69,8 +71,34 @@ 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(); +} -// set_replicated unless the leaf already holds V to within kPoseEpsilon; true when it wrote. A +// 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) { @@ -78,7 +106,7 @@ bool set_if_changed(cvc::app &app, const std::string &path, double v) { n && !n->value().empty() && std::fabs(std::strtod(n->value().c_str(), nullptr) - v) <= kPoseEpsilon) return false; - set_replicated(app, path, fmt(v)); + set_pose_leaf(app, path, fmt(v), /*force=*/false); return true; } @@ -87,12 +115,13 @@ bool set_if_changed(cvc::app &app, const std::string &path, double v) { 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, 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_replicated(app, path, fmt(v)), changed = true; + set_pose_leaf(app, path, fmt(v), /*force=*/true), changed = true; else changed = set_if_changed(app, path, v) || changed; }; @@ -117,28 +146,40 @@ void write_pose(cvc::app &app, const std::string &room, const std::string &pid, if (t && server_tick >= last && server_tick - last < heartbeat_ticks) return; } - set_replicated(app, tick_path, std::to_string(static_cast(server_tick))); + 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; @@ -279,9 +320,22 @@ void client_pump(holon &h, cvc::app &app, const std::string &room, const std::st 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); } diff --git a/src/cvc/state/state_change_journal.cpp b/src/cvc/state/state_change_journal.cpp index a4843c632..81a94b7a3 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 c0f3a4f83..030919e3a 100644 --- a/src/cvc/tests/holon_sync_test.cpp +++ b/src/cvc/tests/holon_sync_test.cpp @@ -275,6 +275,266 @@ TEST(HolonSyncTest, ClientPumpPredictsSelfAndWalksPeersSmoothly) { } } +// 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) { diff --git a/src/cvc/tests/holon_test.cpp b/src/cvc/tests/holon_test.cpp index 1d7b26498..5ac85345e 100644 --- a/src/cvc/tests/holon_test.cpp +++ b/src/cvc/tests/holon_test.cpp @@ -438,6 +438,37 @@ TEST(HolonTest, ClientAlignsAheadOfTheServerByItsLead) { 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; @@ -619,6 +650,43 @@ TEST(HolonPhysicsTest, ClientPredictionMatchesTheServersPhysics) { 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) { 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 46788c25c..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; @@ -50,6 +58,28 @@ const SceneLight *find_light(const std::vector &v, const std::string 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 ───────────────────────────────────────── @@ -293,6 +323,196 @@ TEST(RoomSceneTest, SeededSceneReplicatesToAClientAndTracksEdits) { 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; 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/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 f9dd6296e..75539a2c9 100644 --- a/src/cvcGL/ariadne/room_view.cpp +++ b/src/cvcGL/ariadne/room_view.cpp @@ -7,6 +7,8 @@ #include #include #include +#include // lod_stats, make_view_params +#include #include #include #include @@ -37,6 +39,14 @@ constexpr double kVisorOffset[3] = {0.5 * kAvatarWidth, 0.0, 0.5 * kAvatarHeight // 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"; } @@ -119,7 +129,8 @@ double heading_yaw(const double eye[3], const double focal[3]) { 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(); @@ -128,6 +139,7 @@ room_view::~room_view() { 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() { @@ -136,10 +148,43 @@ 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() { @@ -171,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 @@ -195,10 +245,15 @@ 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)); @@ -234,6 +289,13 @@ void room_view::sync_avatars(cvc::ariadne::holon &h, double wall_dt) { 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); 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 f61acfa7c..821499f85 100644 --- a/src/cvcGL/test/cvcgl_room_view.cpp +++ b/src/cvcGL/test/cvcgl_room_view.cpp @@ -2,20 +2,31 @@ // 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 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, and teardown removes every node +// 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 @@ -23,6 +34,7 @@ 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; @@ -49,6 +61,18 @@ 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"); @@ -217,6 +241,232 @@ int main() { "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"); return fails ? 1 : 0; } From 591cc28d89d4c9e4afdb03bfb70444527452124a Mon Sep 17 00:00:00 2001 From: Joe Rivera Date: Sun, 25 Oct 2026 02:41:52 -0500 Subject: [PATCH 33/33] clang-format the S7 lines #783's changed-lines check flags media_endpoint.h's media_gate_fn and state_change_journal.cpp's two upper_bound calls, as clang-format 18.1.8 lays them out. Formatting only. --- inc/cvc/media/media_endpoint.h | 3 ++- src/cvc/state/state_change_journal.cpp | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/inc/cvc/media/media_endpoint.h b/inc/cvc/media/media_endpoint.h index 43c63821a..addb26bff 100644 --- a/inc/cvc/media/media_endpoint.h +++ b/inc/cvc/media/media_endpoint.h @@ -60,7 +60,8 @@ class media_endpoint { // 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; + using media_gate_fn = + std::function; virtual void set_media_gate(media_gate_fn gate) { (void)gate; } }; diff --git a/src/cvc/state/state_change_journal.cpp b/src/cvc/state/state_change_journal.cpp index 81a94b7a3..90d366fb0 100644 --- a/src/cvc/state/state_change_journal.cpp +++ b/src/cvc/state/state_change_journal.cpp @@ -84,17 +84,17 @@ std::vector state_change_journal::replay_after(std::uint64_t seq // 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; }); + 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 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;