Skip to content

S7: relay-side avatar interest, ghost-free clients, opt-in mesh LOD - #789

Merged
transfix merged 17 commits into
feat/h1-facingfrom
feat/h1-s7
Oct 25, 2026
Merged

transfix merged 17 commits into
feat/h1-facingfrom
feat/h1-s7

Conversation

@transfix

@transfix transfix commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #788 (feat/h1-authority). H1's spatial interest and geometry LOD for the conference room: the relay decides, per receiver and by avatar distance, how much of every other participant it sends.

What it does

Relay (arisrv): a relay-authoritative, avatar-centric area of interest (room_aoi, room_aoi_relay).

  • Each room step the relay tiers every receiver→subject pair as inner / middle / outer from the server holon's poses (2 m enter/exit bands, upgrades immediate, a 1 s dwell per downgrade).
  • State goes through a deny-only per-peer filter on both transports' peer registries. It withholds another participant's intent, a non-inner subject's pose subtree, and a departed subject's pose and intent. Everything else delivers.
  • Non-inner poses reach a receiver as targeted pushes (push_snapshot_to): about 5 Hz while the subject moves, a 5 s refresh while it's still. The server_tick is re-stamped to the relay's current tick, and the pushes go out before the step's live writes.
  • Media gate: middle gets MJPEG camera thumbnails at 2 fps plus the mic, outer gets nothing. The gRPC stall drop (--media-stall-ms 40) bounds how long a publisher's thread waits on a slow receiver.
  • Presets. conference (inner 30/32 m) covers the 20 m room's 28.3 m diagonal, so the default room never tiers. hall (10/12, 16/18) is the default with --scene large, a 100 m plane with 24 LOD bunnies.
  • Control plane. The relay interest gate holds back forwards no room shard admits (the old ui.docs echo). The relay journal is trimmed after each drain with backfill off, so a late joiner bootstraps from the admit snapshot.

Clients (arigl/cyprty).

  • The shard is rooted at conf: ui.docs is no longer journaled or sent.
  • A peer has no body and no marker until its first pose (pose_known), so no invisible obstacle at the origin.
  • Pose leaves journal once per change.
  • Distance audio gain (hall only) and a voice.talking leaf.
  • read_pose takes only a whole sample.
  • A rejoining peer isn't placed at the spot it left.

LOD (cvcGL). LodPyramidCache builds one QEM pyramid per distinct mesh off the frame (≥ 4096 triangles; capped at 2M threaded and 60k on single-threaded wasm). room_view selects a rung per frame. In the default room nothing qualifies.

Switches. Every mechanism is off in the library by default and switched on by arisrv/arigl. arisrv --s7 off (or CVC_ARISRV_S7=0) is the S6 relay; --aoi, --relay-gate, --relay-journal, --media-stall-ms and --aoi-video-max switch the pieces individually. CVC_ARIGL_REPLICATE_ROOT=* and CVC_ARIGL_LOD=0 are the client-side A/B. The full table is in ARIADNE_CONFERENCE_ROADMAP §5h.1.

Also fixed, found along the way (pre-existing)

  • gRPC relay use-after-free on every disconnect: a server handler's stream died while the link probe, publish and relay forwards could still be writing to it. Under parallel load on Windows it crashed in 4 of 6 stress runs; after the fix, 1,050/1,050 pass.
  • A half-replicated pose (position in, orientation not yet) read as a zero quaternion. It is now ignored rather than reaching add_body.

Measured (arisrv_aoi_bench, catx-03; relay + N clients in one process, WS/gRPC mixed; media off)

S6 (--s7 off) 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
… late joiner bootstrap 309 kB 35 kB
hall, 12 in clusters (48/66 pairs outer) — per gRPC receiver 242 kb/s 162 kb/s
… late joiner bootstrap 491 kB 215 kB

With media on, the gate held back no frame in the 20 m room. In the hall it withheld 73% of camera and mic frames (12 in clusters). Caveats are in the doc: the bench is one CPU-bound thread at these sizes, and the WS 1 Hz full-tree re-broadcast still dominates a browser's state traffic in the hall (an incremental re-broadcast is a listed follow-up).

Testing

  • Windows (build-h1live, gRPC only): the S7 set passes 216/216.
    • Full suite: 4,822/4,826. The two NavPlantConvergence timeouts are the known long Jolt runs.
    • Two StreamBridge cases are load flakes: they use a transport this PR doesn't touch and pass 5/5 alone.
  • Linux (catx-03, WS + gRPC): the S7 set passes 236/236.
    • Full suite: 4,297/4,304. The other 7 are environmental:
      • no HDF5 or assimp in that prefix;
      • a CRLF copy of a grammar doc in the synced tree;
      • the long Jolt runs.
  • wasm: cyprty builds with every S7 source, including the Emscripten branch of the WS transport.
  • cvc.dll exports (gRPC build): 64,976 of 65,535. The AOI's internal bookkeeping uses file-local types, so its containers aren't exported.
  • New tests:
    • room_aoi_test, room_aoi_relay_test;
    • RoomRelayLoopbackTest: 10 end-to-end cases on loopback with mixed transports, not named Integration, so PR CI runs them;
    • new transport, holon, holon_sync, state-core and cvcGL LOD cases.
  • Loaded runners (2a7e5fc): the loopback tests no longer depend on the runner keeping real time.
    • Media is lossless there. gRPC's stall drop was already off, and the WebSocket backlog drop is now set_media_backlog_drop(bytes) (32 KB by default; the tests pass 0). Its skips count in total_media_backlog_dropped() and show in arisrv's 5 s AOI line as backlog. The default-room and middle-tier media checks are now exact.
    • ControlPlaneEgressDrops runs a fixed number of steps and frames and compares bytes per second of room time.
    • On catx-03, pinned to two cores beside four busy loops, they pass 30/30, 30/30 and 8/8. Before, the default-room test failed 5/10 and the egress test 2/3.
  • Adversarial review: 8 reviewers plus skeptics went over the whole diff. Every confirmed finding is fixed in 6dfcc24, most importantly:
    • a non-identifier pid could abort the relay;
    • unbounded media waits behind a slow gRPC receiver.

Known limits

  • The pre-Hello admit snapshot is unfiltered, so a late joiner sees current intents and outer poses once. Admission-scoped bootstrap stays with §5e.
  • A process that restarts under a live session's node id is ignored until the relay restarts (replication's seen-set). arigl takes a fresh id per launch; in-process reconnects work.
  • Behaviour change in the default room even with nothing tiered: other participants' intents and departed ghosts' poses are no longer forwarded. --s7 off restores S6.

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.
…, shard admits(); scaffold the S7 sources and tests
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.
- 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.
… 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.
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.
…arted 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.
Carries the master merge (#776 squash, #774, #775, #777, #780, #782,
#784, #785) up the H1 stack.
Carries the master merge (#776 squash, #774, #775, #777, #780, #782,
#784, #785) up the H1 stack.
…r 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.
Carries the master merge (#779 squash, #786, #787) up the H1 stack.
Carries the master merge (#779 squash, #786, #787) up the H1 stack.
Base automatically changed from feat/h1-authority to feat/h1-facing October 24, 2026 23:48
* 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
… 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.
#788 (feat/h1-authority) landed on feat/h1-facing as a squash whose tree is
identical to its head (9d0afec), which this branch already carries, so
this branch's tree stands unchanged.
transfix added a commit that referenced this pull request Oct 25, 2026
…61 names) (#793)

cvc.dll's export table came from WINDOWS_EXPORT_ALL_SYMBOLS, which exports every external
symbol in cvc's objects, and a PE export table holds at most 65,535 names. The gRPC build
of master exported 63,073 and S7 (#789) 65,122; that limit is why the WebSocket transport
stays off on Windows. 44,412 of master's names were in namespace std: the members of
std::vector<cvc::...>, std::_Tree<...> and std::function's _Func_impl for cvc's lambdas,
the STL's inline functions, std RTTI. No consumer imports them. Each is an implicit
instantiation or an inline function that the consumer compiles from the same header, and
the linker resolves it from the consumer's own objects: of the 2,194 names S7's 286
binaries import from cvc.dll, none is in std.

Under MSVC, cvc_msvc_export_def (CMake/CvcMsvcExportDef.cmake) now builds the table as a
pre-link step. `cmake -E __create_def` over $<TARGET_OBJECTS:cvc> writes the same file
WINDOWS_EXPORT_ALL_SYMBOLS did, byte for byte. cvc_def_filter, a small host tool on
dbghelp's UnDecorateSymbolName, then leaves out every symbol whose undecorated name is in
namespace std, and cvc.dll links with /DEF: on the result. cvc_igl and cvc_physics_jolt
are still private static archives that are not scanned. Cross-compiles, CMake older than
3.18 and MinGW keep WINDOWS_EXPORT_ALL_SYMBOLS.

The gRPC RelWithDebInfo build of master now exports 18,661 names, and cvc.dll shrinks from
56.7 MB to 42.0 MB. S7's export list through the same filter is 18,946, and a CVC_ENABLE_WS=ON +
libigl build, which could not link before (66,726 names), exports 21,861. The cvc_igl_packaging
test also checks that the generated table names no igl:: or Eigen:: symbol.
@transfix
transfix merged commit f25586d into feat/h1-facing Oct 25, 2026
6 checks passed
transfix added a commit that referenced this pull request Oct 25, 2026
S7 (#789) is now squashed into feat/h1-facing; merged with S7's head as the base, so only this branch's own changes apply. The tree is unchanged.
transfix added a commit that referenced this pull request Oct 25, 2026
…ysics (#783)

* 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.

* fix(ariadne): guard reconcile against stale/duplicate acks; pin tol + integrator tests

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.

* feat(ariadne): cvc::ariadne::holon — clock + avatar bundle on its own clock_driver (H1 slice 3)

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).

* fix(ariadne): holon review fixes — peer sample guard, explicit non-movable, replay/stale tests

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.

* feat(ariadne): holon <-> state-tree sync over scalar keys (H1 slice 4 core)

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.<room>.participants.<pid>, via
room_participant_path):

  <participant>.pose.pos/orient/vel.{...} + .pose.server_tick  server-written
  <participant>.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.

* feat(arisrv): server-authoritative holon loop + room-set-intent verb (H1 slice 4b)

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.

* ci: run the master-targeted matrix (base retargeted from feat/h1-pose-reconcile)

* H1 slice 5: the shared room scene (conf.rooms.<room>.scene.*) + arisrv seed

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.

* 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).

* H1 S6 (client): the room in 3D in arigl — room_view, WASD avatar, follow camera

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.

* 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.

* arisrv: reflow the seed comment to 100 columns

* 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.

* 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).

* 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().

* 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.

* 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.

* ci: run the master-targeted matrix (base retargeted to master after #773)

* 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.<pid>.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.

* arigl: single space before the <random> include comment (changed-lines format gate)

* 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.

* 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.

* 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).

* 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.

* 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.

* 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.

* 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.

* 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.<pid>.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.

* Holon authority: the server vetoes intents and can override avatars (#788)

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: relay-side avatar interest, ghost-free clients, opt-in mesh LOD (#789)

* 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.

* 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.
transfix added a commit that referenced this pull request Oct 25, 2026
)

* Bound writes: a relay client writes only its own participant, as itself

The relay applied and forwarded whatever a client sent: another
participant's intent, the server holon's poses, the scene, mutations or
media naming another origin, even a snapshot pushed at it.

state_inbound_gate (cvc/state), installed with
state_transport::set_inbound_gate on gRPC and WS, governs what a
transport takes from the connections it accepts. Dialed connections,
the bridge and the relay's own writes are not gated.

- Identity: a connection speaks only as the node id of its first Hello.
  One live connection holds a node id at a time. The first claim wins
  until it closes, or until it has been silent for 6 s (three missed
  link probes), when the next claim takes it over: a dropped network
  leaves the old stream open. The relay's own ids (its transport's and
  its shards') are never a client's.
- Origin: mutations, messages and media must name that id, and gRPC
  heartbeats must name it as their node. A client's SnapshotResponse is
  never applied, and a snapshot request is answered only for a
  connection speaking as itself.
- Departure: a close is that peer leaving only when the connection
  still held its id.
- Refused frames are not applied, forwarded or bridged; they are counted
  in total_inbound_rejected().

room_write_gate is the room's rule:
- a participant writes only inside conf.rooms.<room>.participants.<pid>,
  with value, touch or data ops;
- never the relay's keys there: pose, net.relay, and streams.<kind>.ready;
- only on canonical paths of at most 16 segments and 512 bytes (no empty
  or padded segment, which the tree would trim onto another key);
- and only for a pid that is one identifier.

Every client write today fits this rule.

arisrv --bind-writes on|off (default on; not an S7 switch) installs one
gate on both transports, and logs refusals as their count grows. The
bench mirrors it. The gate's holder map is file-local, so cvc.dll grows
only 71 names (65,198 of 65,535).

Tests:
- InboundGate{TakesOnlyWhatAPeerMayWriteAsItself, BindsANodeIdTo*,
  RefusesTheRelaysOwnId, HandsASilentHoldersIdOver} for gRPC and WS, and
  InboundGateSpansBothTransports;
- RoomWriteGate.* (the rule, binding, takeover);
- RoomRelayLoopbackTest.ClientWritesOnlyItsOwnParticipant: what the
  relay applies from a client that writes A's intent and pose, its own
  pose by normalising paths, the scene and the AOI, and A's intent as A.
  Mutants without the relay-id or canonical-path checks fail these.
- NoIntentOrUiDocsLeak now expects the echo refused by the write gate,
  and ControlPlaneEgressDrops's pre-S7 baseline turns bound writes off.

* Room token and default device identity (§5f), before usernames

Two credentials a peer can carry before there are accounts.

Room token -- who may join:
- arisrv takes it from --room-token-file PATH or CVC_ARISRV_ROOM_TOKEN,
  never from the command line. A token asked for must be a valid one: a
  flag with no path, an unreadable or blank file, or a set but empty
  variable is an error, not an open room.
- gRPC carries it as authorization: Bearer, now compared in constant
  time and counted in total_auth_rejected().
- WebSocket gains auth_config. A browser cannot set an Authorization
  header, so the client offers "cvc.v1,cvc-token.<token>" in
  Sec-WebSocket-Protocol. The server reads the upgrade request itself,
  refuses it with HTTP 401 unless the token is offered, and answers
  cvc.v1 (a browser requires the reply to name an offered subprotocol).
- Both checks run before admission, so a client without the token is
  sent nothing.
- arigl reads CVC_ARIADNE_ROOM_TOKEN. The browser page takes it from the
  invite link's URL fragment (#token=...), which never reaches a server
  or its logs.
- With no token configured the room is open, as before.

Each accepted WebSocket connection now does its handshake on its own
thread (10 s deadline, at most 64 at once). One client that connected
and sent nothing used to hold up every join, and stop().

Device id -- which device is this:
- Hello gains device_id (field 3), set with
  state_transport::set_device_id().
- default_device_id() is a salted SHA-256 of the OS machine id, the host
  name and the user. Under Emscripten it is "" unless CVC_DEVICE_ID is
  set; the page keeps a random id in localStorage, per page load where
  storage is blocked.
- A client sends scoped_device_id(base, relay URL), so two relays cannot
  match one device. A relay ignores an id that is not valid_device_id
  (1-64 of [A-Za-z0-9._-]).
- Link reports carry remote_device_id and holds_id.
- arisrv publishes participants.<pid>.device, a room-scoped 16-hex tag,
  from the link holding the pid (room_net write_link_devices).
  room_write_gate refuses a client's write to it.
- It is an identifier, not a credential: giving it authority needs a
  key the device keeps and the relay pins.

* ws: declare steady_ns before the accept loop uses it

The per-connection handshake stamps handshake_started_ns in accept_loop,
above steady_ns's definition: native builds with WebSocket failed to
compile (the wasm build skips accept_loop).

* Device keys: a device proves its id with a key the relay pins on first use (§5f)

The device id names a device; this gives it authority.

Keys (state_device_key.h, on OpenSSL's libcrypto):
- A device keeps one 32-byte seed: %LOCALAPPDATA%\cvc\device-key, or
  $XDG_CONFIG_HOME/cvc/device-key (~/.config/cvc/device-key), made on
  first use, owner-only on POSIX, and published in one step so two
  first launches agree on one seed. CVC_DEVICE_KEY_FILE names another
  file; CVC_DEVICE_KEY (64 hex) gives the seed itself, which is how the
  browser page passes the one it keeps in localStorage.
- For each relay it derives an Ed25519 key, HMAC-SHA256 of the seed and
  the relay URL, so two relays see unrelated keys.

The proof:
- A relay with a pin store puts a fresh 32-byte nonce in its Hello to
  each connection it accepts (Hello.nonce, field 4).
- The client answers with a DeviceProof (Frame field 12): its public
  key and a signature over the nonce, both node ids and its device id,
  each length-prefixed. One proof per challenge. A proof that arrives
  before the client's own Hello is held and checked when it comes.
- The relay verifies it and pins the key to the device id on first use
  (state_device_pins). A later proof under the pinned key verifies the
  link (state_link_report::device_verified); another key, or a bad
  signature, is refused and counted. A refused device stays in the
  room, without a tag. A store holds at most 65,536 pins.

The room:
- participants.<pid>.device is written only for a verified link that
  holds the pid, and cleared when the pid's holder proved no key, so a
  takeover does not keep the last device's tag.
- arisrv keeps one pin store for both transports, in memory or in a
  file with --device-pins PATH, and logs refused proofs.
- arigl loads the seed and signs for its relay; the page keeps the seed
  in localStorage (cvc.device_key) beside its device id.

Without OpenSSL nothing is derived or verified, and no device has a tag.

* Conference roster: a volume slider that holds still, and per-peer controls that follow the person

The Users window's volume slider sat in an auto-width table column. ImGui re-fits such a column to
its content every frame, and a slider in a cell took ~65% of it (ImGui's default item width), so
the column shrank by a third each frame until reserve_label_width's 48 px floor snapped it back: the
bar never held a width long enough to be dragged. The six fixed columns also already filled the
420 px window, which clipped the slider to a sliver.

- ImGuiBackend: a slider or int widget in a table cell now fills its cell, so an auto-width column
  settles at once (cvcgl_imgui_grid_slider: without this the column ends at 13 px, oscillating).
- conference_roster.ari: every roster column has a fixed width (vol 72 px), the window is 470 px,
  and the peer camera tiles move right with it. Each row's two name texts (plain and talking-green)
  are one group, so the cell exists on a frame where neither visibility check runs and the row no
  longer shifts left.
- Per-peer cam, mic and volume were keyed by roster ROW, and rows are sorted by pid, so a join or
  leave moved your settings onto someone else. on_tick now parks each row's controls under its pid
  (ctl.<pid>.*) before the rebuild and gives each row its own pid's back.

* Device keys: a device proves its id with a key the relay pins on first use (§5f) (#816)

The device id names a device; this gives it authority.

Keys (state_device_key.h, on OpenSSL's libcrypto):
- A device keeps one 32-byte seed: %LOCALAPPDATA%\cvc\device-key, or
  $XDG_CONFIG_HOME/cvc/device-key (~/.config/cvc/device-key), made on
  first use, owner-only on POSIX, and published in one step so two
  first launches agree on one seed. CVC_DEVICE_KEY_FILE names another
  file; CVC_DEVICE_KEY (64 hex) gives the seed itself, which is how the
  browser page passes the one it keeps in localStorage.
- For each relay it derives an Ed25519 key, HMAC-SHA256 of the seed and
  the relay URL, so two relays see unrelated keys.

The proof:
- A relay with a pin store puts a fresh 32-byte nonce in its Hello to
  each connection it accepts (Hello.nonce, field 4).
- The client answers with a DeviceProof (Frame field 12): its public
  key and a signature over the nonce, both node ids and its device id,
  each length-prefixed. One proof per challenge. A proof that arrives
  before the client's own Hello is held and checked when it comes.
- The relay verifies it and pins the key to the device id on first use
  (state_device_pins). A later proof under the pinned key verifies the
  link (state_link_report::device_verified); another key, or a bad
  signature, is refused and counted. A refused device stays in the
  room, without a tag. A store holds at most 65,536 pins.

The room:
- participants.<pid>.device is written only for a verified link that
  holds the pid, and cleared when the pid's holder proved no key, so a
  takeover does not keep the last device's tag.
- arisrv keeps one pin store for both transports, in memory or in a
  file with --device-pins PATH, and logs refused proofs.
- arigl loads the seed and signs for its relay; the page keeps the seed
  in localStorage (cvc.device_key) beside its device id.

Without OpenSSL nothing is derived or verified, and no device has a tag.

* Conference movement: the mover's own avatar stops jumping

Other people's view of a walking avatar was smooth, but the walker's own view jumped. arigl
predicts its avatar locally and replays its inputs on each server pose, so it was the only viewer
that saw the reconcile path, and three things fed that prediction the wrong history:

- The relay's ~1 Hz WebSocket re-broadcast sent a client its OWN intent back. The relay forgets a
  path when the client writes it, so the broadcast carried whatever the relay held; with the
  client's next write still in flight, that was the previous intent. The client applied it and
  predicted on it until the next key change or broadcast, while the server integrated the real one,
  so every 20 Hz pose snapped it back and the replay pushed it on again.
  - state_inbound_gate::sources (default false): whether a path is a peer's own. room_write_gate
    answers with its write rule, and broadcast_snapshot never sends such a path to its source.
  - arigl takes a tree intent only when it is not older than its last proposal
    (holon_sync read_current_intent; propose_intent now returns its stamp). A stamp of 0, the
    document's room-set-intent, is always taken.
- A clock realign wiped the input log. arigl moves its lead with the measured round trip, and any
  rise of ~50 ms seeked the clock and dropped the logged inputs, so the next pose snapped the
  avatar back by a round trip of walking. Within max_skew_ticks the client now slews its clock
  (holon_config::lead_slew, 5%) toward the target instead; the first alignment, joining, still
  seeks.
- The local avatar was drawn at its last whole 120 Hz quantum, so frames between quanta stood still
  and the rigidly following camera passed that judder to the whole view. display_pose now
  interpolates it between its last two quanta by the frame's alpha, as peers already are, and a
  reconcile shifts that interpolation with its correction.

Tests: RoomRelayLoopbackTest.ABroadcastNeverEchoesAClientsOwnIntent (the echo, end to end over WS;
fails without the broadcast skip), HolonTest.ALongerLeadIsSlewedIntoWithoutACorrection (a lead step
mid-walk: no correction slewing, one seeking), ClientSlewsTowardANewLead,
TheLocalAvatarIsShownBetweenQuanta, HolonSyncTest.ReadCurrentIntentRefusesAnEchoOfAnEarlierProposal
and RoomWriteGate.AParticipantSourcesWhatItWrites. Each fails with its fix removed.

* cvcgl_room_view: the local avatar's turn shows a quantum after it is taken

holon::display_pose now shows the local avatar between its last two quanta by the frame's alpha, so a turn taken on one quantum is drawn on the next. The visor check advances two quanta instead of one.

* Conference media: a fraction of the bandwidth, and an uplink that sheds instead of queueing

What each participant cost (the user's question; a 4-person room, every pair in view):
- Camera: 640x480 MJPEG at q70 and 30 fps, about 4-5 Mbit/s per receiver (17-22 KB a frame for a
  detailed scene with webcam-like noise), shown in ~300 px tiles.
- Mic: raw 32-bit float PCM at 48 kHz, 1.5 Mbit/s, sent even when muted or silent.
- So about 7.6 Mbit/s up and 23 Mbit/s down per client, and the link probe queued behind it.

Changes:
- The camera is captured at 320x240, 15 fps and q60: about 0.6 Mbit/s per receiver, ~8x less, for
  the same tiles. stream-capture-camera gains a `quality` option (1-100, default 70); it was always
  q70.
- arigl sends no mic chunk below -48 dBFS RMS more than 0.8 s after a louder one, so a silent or
  muted mic costs nothing (receivers already mix silence for a peer with no chunks, and its video
  runs free). A muted camera sends one frame a second instead of 15.
- A browser's send backlog is now WebSocket.bufferedAmount (it was always 0 there), so its uplink
  drops media at the 32 KB threshold instead of queueing it without bound behind every other frame,
  the link probe included.
- On every WebSocket link audio gets four times video's backlog allowance: a congested link sheds
  video first.

The roadmap records the new defaults (and the re-broadcast's no-echo rule from the movement fix).
Opus audio and per-receiver rungs are next.

* Per-viewer video rungs: the relay re-encodes each camera for each viewer's link

The user asked for per-viewer control of resolution and bitrate, adapted when a viewer's link
degrades, and asked whether senders must send several sizes. They need not: MJPEG frames stand
alone, so the relay makes the smaller sizes itself.

Rungs (cvc::media_rung_shaper, media_rungs.h):
- full (as sent), half and quarter (decoded at 1/2 or 1/4 size by libjpeg-turbo's DCT scaling and
  re-encoded), thumb (quarter at 2 fps) and off (no video; audio still flows).
- A variant is made once per frame and rung and shared by every viewer on it, across transports.
  Publishers upload one stream; old clients need no change.
- media_endpoint gains set_media_shaper, asked per receiver after the gate (the frame as sent, a
  variant, or nothing); the WS and gRPC fans encode each variant once per frame.
- cvc links libjpeg-turbo when the prefix has it (CVC_HAVE_TURBOJPEG); without it the smaller
  rungs send frames as is.

Adaptation (room_rung_controller, room_rungs.h), once a second over the link reports:
- per viewer, queueing (round trip above its minimum), the share of media frames dropped for it
  (new per-link media_sent / media_dropped counters) and what it was sent;
- down one rung after two bad seconds (queueing > 150 ms, > 5% dropped, or over budget), up one
  after ten good ones; a step up that fails early doubles the next wait (to 60 s).
- arisrv: --adapt on|off, --video-max RUNG, --video-floor RUNG, --viewer-kbps-max N; it logs each
  change and writes the rung to participants.<pid>.net.relay.video (relay-owned).

Camera off is a flag, not frames: a client whose camera is off sends no frames and publishes
participants.<pid>.video.off; viewers show "camera off" instead of the last frame they got.

The roster shows each person's rung as a dot before their name (green full, red reduced, black
none; hover for which) and "video in: ..." in My Camera. The dot is a new status_dot widget drawn
with ImGui's draw list, as the web build's font has no circle glyph.

Tests: MediaRungs.* (downscale sizes, shared variants, thumb pacing, audio untouched), RoomRungs.*
(down on queueing and drops, slow and wary up, the server's bounds), the WS shaper per receiver,
and RoomRelayLoopbackTest.AViewersVideoRungShapesWhatItGets on each transport.

* build(windows): cvc.dll stops exporting std:: symbols (63,073 -> 18,661 names) (#793)

cvc.dll's export table came from WINDOWS_EXPORT_ALL_SYMBOLS, which exports every external
symbol in cvc's objects, and a PE export table holds at most 65,535 names. The gRPC build
of master exported 63,073 and S7 (#789) 65,122; that limit is why the WebSocket transport
stays off on Windows. 44,412 of master's names were in namespace std: the members of
std::vector<cvc::...>, std::_Tree<...> and std::function's _Func_impl for cvc's lambdas,
the STL's inline functions, std RTTI. No consumer imports them. Each is an implicit
instantiation or an inline function that the consumer compiles from the same header, and
the linker resolves it from the consumer's own objects: of the 2,194 names S7's 286
binaries import from cvc.dll, none is in std.

Under MSVC, cvc_msvc_export_def (CMake/CvcMsvcExportDef.cmake) now builds the table as a
pre-link step. `cmake -E __create_def` over $<TARGET_OBJECTS:cvc> writes the same file
WINDOWS_EXPORT_ALL_SYMBOLS did, byte for byte. cvc_def_filter, a small host tool on
dbghelp's UnDecorateSymbolName, then leaves out every symbol whose undecorated name is in
namespace std, and cvc.dll links with /DEF: on the result. cvc_igl and cvc_physics_jolt
are still private static archives that are not scanned. Cross-compiles, CMake older than
3.18 and MinGW keep WINDOWS_EXPORT_ALL_SYMBOLS.

The gRPC RelWithDebInfo build of master now exports 18,661 names, and cvc.dll shrinks from
56.7 MB to 42.0 MB. S7's export list through the same filter is 18,946, and a CVC_ENABLE_WS=ON +
libigl build, which could not link before (66,726 names), exports 21,861. The cvc_igl_packaging
test also checks that the generated table names no igl:: or Eigen:: symbol.

(cherry picked from commit 1d0c87f)

* Video rungs: review fixes (bad JPEGs, variant cache, thumb pacing)

- jpeg_downscale refuses a JPEG over 4096 x 4096 before allocating for it:
  a frame's header names its own size, and one claiming 65535 x 65535 from
  any client would have the relay allocate gigabytes on a reader thread.
- A TurboJPEG handle a call failed on is replaced. A decompressor stopped
  partway through a bad header refuses every image after it ("two SOI
  markers"), so one malformed frame ended downscaling on that reader thread
  for good. The thread-local handles are now also destroyed at thread exit.
- write_media holds each variant's shared_ptr for the whole fan (WS and
  gRPC). Matched by address, a variant freed mid-fan could hand its address
  to another and a later receiver would get the wrong encoding.
- An unidentified receiver is shaped too, as "" (no rung: full).
- The thumb rung admits a frame a quarter of its interval early: the AOI
  tier may already pace that camera at the same 2 fps on its own schedule,
  and those frames arrive with a frame period of jitter, so a strict gap
  halved them.
- arisrv's room-debug report adds the shaper's totals.

Tests: the downscale test sends an oversized header and then a good frame
(which failed before the handle fix); the thumb test covers the early
frame; OnlyAViewersHoldingLinkCounts now checks a returning viewer starts
over at the best rung (it checked nothing); the WS shaper test claims only
what it checks, and asserts a shaped-out frame is not a link drop (the
controller would read its own off rung as congestion).

* Opus mics: 32 kbit/s instead of 1.5 Mbit/s

A mic sent as raw f32 at 48 kHz is 1.5 Mbit/s, the largest stream after
video. cvc::opus_encoder / opus_decoder (media_opus.h, over libopus) carry it
as Opus at 32 kbit/s: the sender re-chunks the mic's 1024-frame chunks into
20 ms (960-frame) packets, one media_frame each (codec "opus", a seq per
packet so a gap is a lost packet), and a new talk spurt resets the encoder.

A receiver decodes each peer with its own decoder (Opus is stateful),
conceals up to two lost packets, and cuts the result into mixer-sized
chunks: the mixer sums one chunk per peer, and a 960-frame packet in a
1024-frame mix would drift and click. A gap of over 0.4 s ends a peer's
talk spurt; a shorter delivery hitch keeps its samples.

The relay forwards a mic as sent, whatever its codec, and needs no libopus.
A client without libopus sends f32, which every client plays; it cannot
decode a peer's Opus, logs that once, and hears that peer as silence.
Older clients are not provided for.

libopus is optional. cvcpkg's autotools-built bundles ship no CMake config,
so it is found by header and library and linked through a synthesised
CvcOpus::opus, which cvcConfig.cmake recreates for a static consumer (as
PkgConfig::ZSTD). Under MSVC the search takes only the import library
(opus.lib, from cy-pca/cvcpkg#467, or libopus.dll.a): CMake would otherwise
take MinGW's libopus.a for `opus`, and its ___chkstk_ms calls do not link.
The libcvc recipe gains libopus on linux, macos and windows; wasm follows
once #467 publishes its bundle.

* conference_roster: ASCII status text (the browser's font draws a bullet or an em dash as ?)

The browser build's ImGui font is ASCII-only, so My Camera read "camera: ? on"
and a taken name "... is taken ? pick another". Plain "on" and a semicolon.

* recipe(libcvc): libopus for wasm too (cvcpkg libopus cvc.8)

cy-pca/cvcpkg#467 published a static wasm libopus, so the single-threaded
wasm libcvc gets Opus mics like the native ones. wasm-mt has no libopus
bundle, and Emscripten will not link a non-pthread archive into a -pthread
build, so it goes without (its mics stay f32).

* arigl: clang-format the video_off_ member comment

The Opus members inserted above it ended the aligned comment block, so
master's changed-lines check wants the single space back.
@transfix
transfix deleted the feat/h1-s7 branch October 29, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant