Skip to content

Add driver log telemetry over multicast - #12

Merged
anyapipira merged 11 commits into
mainfrom
dev/driver-command-logging
Aug 7, 2026
Merged

anyapipira merged 11 commits into
mainfrom
dev/driver-command-logging

Conversation

@anyapipira

Copy link
Copy Markdown
Owner

Adds a driver-side telemetry channel that reports what the driver actually did,
so an external tool can observe the driver without polling it or guessing from
its own send calls.

What this adds

The driver multicasts one JSON datagram per event on 239.255.39.71:39571,
loopback only, TTL 0. Multiple local processes can subscribe at once, and the
driver sends whether or not anyone is listening.

Two event types today: command_processed (a pose datagram the driver parsed)
and haptic_vibration (a pulse SteamVR asked a virtual controller to play,
which closes #9). Virtual controllers previously had no haptic component at
all, so that had to be created before the event could exist.

Design decisions worth review

One envelope for every event. All events share version, event,
sequence, timestamp_ms, suppressed, and detail, serialized and validated
in one place. A receiver can filter, order, and render any event — including one
it does not recognize — without knowing its shape. An unknown event name parses
successfully as an unknown event rather than failing, because the group is meant
to grow and receivers must be able to skip what they do not handle.

Reporting only on change. A sender holding a pose re-sends the same command
at the stream rate. Reporting each one produced roughly 60 reports per second
with nothing happening. The driver now reports only when a command differs from
the last one it reported, and carries the absorbed count in suppressed, so
"pose held" stays distinguishable from "sender stopped".

sequence orders, timestamp_ms dates. UDP loses, duplicates, and reorders
datagrams. A single counter shared across event types and sender threads makes
all three recoverable, and a gap stays visible even to a receiver filtering for
one event type. The timestamp is a wall-clock reading and is explicitly not
usable for ordering: it can step backwards across an NTP correction, and two
events can share a millisecond. A regression test asserts that an event numbered
later but stamped earlier still sorts last.

The driver stamps as late as it can, but cannot do so atomically with the send
without locking two hot paths against each other. A window therefore remains in
which two events leave in the opposite order to their numbers. That is documented
rather than hidden, and receivers are told to reorder within a bounded window;
the companion UI walks back up to 64 rows.

Also in this branch

  • Only the HMD is clamped to the ground plane. Hip and foot trackers legitimately
    go below it, and the shared floor was preventing that.
  • Fixed whole-body rotation raising a Y-limit annotation on every device when
    none were actually out of range: clamping was compared against the wrong value.
  • The UI's Monitor driver commands switch now starts off and persists across
    launches, since joining the group changes where log rows come from.

Documentation

docs/protocol.md and its Chinese translation gain the event schema, the
ordering and stability contracts, and a Writing A Receiver guide: the four
socket steps in order with the consequence of skipping each (all four fail
silently), a worked example, and a troubleshooting checklist. The example was
extracted back out of the committed document and executed against live multicast
traffic to confirm what is published actually runs.

Verification

  • .\scripts\build_driver.ps1 — UI and driver both build.
  • ctest --test-dir build -C Release — passing.
  • .\scripts\listen_driver_log.ps1 -Validate -ListenerCount 3 — three
    independent listener processes each receive the probe.
  • New tests were confirmed to fail without their fix before being kept.

The UI streams at 60 Hz whether or not anything moves, so reporting every
processed datagram flooded the log with byte-identical rows while a pose was
merely held.

The driver now compares each accepted command against the last one it reported
via SamePoseCommand and suppresses exact repeats. Comparison covers the present
device set, the clamped set, and every present device's pose and controller
inputs; arrival time is excluded. It is exact rather than epsilon-based, since a
held pose is re-serialized from unchanged state and arrives identical, while a
tolerance would swallow slow deliberate motion. Repeated datagrams that fail
validation are matched by their bytes instead, because a rejection leaves no
trustworthy parsed state.

This is a change filter, not a rate limit: distinct commands are still reported
individually, so a listener recording a moving sequence receives every frame.
The count of absorbed repeats travels on the next report's optional suppressed
field, keeping a held pose distinguishable from a stalled sender. The field is
optional on parse, so the logging protocol stays at version 1.
Every device shared a 0 m floor, which flattened legitimate poses: a foot
passing under the floor plane, a hip in a floor move, or a solved motion whose
origin sits above the ground were all snapped back to zero.

Only the HMD needs the floor, since it is the play-space head and placing it
below the ground plane puts the view underground. The shared kMinDeviceY is
replaced by a per-device rule: MinDeviceY(index) returns kMinHmdY (0 m) for the
HMD and kMinTrackerY (-30 m, the shared position range already enforced on X and
Z) for the other five. ClampDeviceY now takes the device it is clamping, so the
UI, the protocol parser, the .nya loader, and the driver all apply the same
rule. VirtualDeviceDefinition carries its DeviceIndex so the driver sources the
floor from core rather than re-deriving it.

The rig's vertical move generalizes accordingly. Its floor was expressed as the
lowest device reaching zero; it is now the tightest shift any device can still
absorb, which in practice is the HMD, so the rig stops descending when the head
reaches the ground plane while the feet may already be below it.
Rotating the whole rig lit the Y-limit annotation on every device box even
though all of them sat well inside their Y range.

y_clamped is meant to say that the safety clamp overrode what the gesture asked
for, but the rig rotation compared the final Y against the pre-rotation Y.
Rotating about the HMD pivot moves devices vertically by design, so any rotation
set the flag on all five non-pivot devices. Single-device drags had the same
defect: they compared against the pre-drag Y, so any gesture that legitimately
moved a device vertically reported a clamp.

Each site now compares the clamped value against the value actually requested at
that point: the rotated Y for rig rotation, the pre-clamp Y for single-device
drags, and the pre-clamp mirrored Y for symmetric mirroring. ClampedDragY
absorbs an overshoot into the drag accumulator, so its return value cannot show
that a bound was hit; it now reports that separately. A whole-rig vertical move
stops every device as a unit rather than overriding any one of them, so it
deliberately sets no per-device flag.
External tools want to observe the haptic pulses SteamVR sends to the virtual
controllers, so they can drive indicators or forward them to physical hardware.
Nothing observed them before: the controllers advertised no haptic output, so
SteamVR had nothing to route.

The two controllers now expose an /output/haptic component, the way a real Index
controller does, and the input profile declares it. RunFrame drains SteamVR's
event queue and reports each VREvent_Input_HapticVibration as a haptic_vibration
event carrying the controller, duration, frequency, and amplitude. The driver has
no motor and plays nothing back; it only observes. Values are passed through as
SteamVR supplied them, with bounds that reject nonsense rather than assert a
policy.

Reports ride the existing driver log multicast group and port, so a listener
joins one group to observe both event types. That makes the group polymorphic, so
readers now dispatch on the event field through ParseDriverLogBytes instead of
assuming a command report; an unknown event name is rejected rather than misread
as another shape. Sequence numbers are counted per stream because the two are
produced independently.

The multicast sender is extracted into DriverLogSender and shared by both paths.
Each path keeps its own instance, so the RunFrame thread never shares a socket
with the UDP receive thread. Reporting stays best-effort and non-blocking on both.

haptic_log_enabled gates the reporting and defaults to true. The component is
always created, so the switch never changes what SteamVR or a game sees.

Resolves #9.
The group carries more than one event type, but only the discriminator was
shared. Each event type re-derived version, event and sequence for itself, so
nothing kept them agreeing; the dispatcher parsed a datagram once to read its
event name and the type parser then parsed it again; sequence counted per stream,
so one field name meant two different things; and detail existed only on command
events, which forced the same haptic summary to be hand-formatted separately in
the UI and in the reference listener.

Every event now shares DriverLogEnvelope: version, event, sequence, suppressed
and detail, written by one AppendEnvelope and validated by one ParseEnvelope, so
a new event type cannot drift from the others. sequence is global across all
events and both sender threads, so it has a single meaning and orders the whole
group. detail is present on every event, letting a receiver render one without
decoding its type-specific fields; the duplicated haptic formatting is gone.
ParseDriverLogBytes parses a datagram once and dispatches on the already-parsed
root.

An event name this build does not know now parses as DriverLogEventType::Unknown
with a valid envelope instead of failing. Receivers filter on event and must be
able to skip types added later; treating those as corrupt would make the group
impossible to extend. A malformed envelope is still rejected whatever the event
name claims.

docs/protocol.md and its translation publish this as a contract: the envelope
table, the event registry, and explicit compatibility rules covering unknown
events, unknown fields, field immutability within a version, absent optionals,
and the fact that detail is human-readable only and must never be parsed.

DriverCommandLogConfig collapsed into DriverLogSenderConfig, which it duplicated.
UDP reorders, duplicates, and drops datagrams, and sequence numbers existed to
make that recoverable, but nothing used them: the log appended driver events in
arrival order, so a late datagram was shown out of order and a duplicated one was
shown twice.

The exposure was not only the network. Both reporters took their sequence number
at the top of the reporting function and sent at the bottom, with the summary
building and serialization in between. Two threads report on this group, so every
instruction in that gap was a window in which the two could leave in the opposite
order to their numbers, before UDP was involved at all. Both now take the number
immediately before sending. That narrows the window to the serialization call
rather than closing it: numbering and sending cannot be made atomic without a
lock across the UDP receive thread and SteamVR's RunFrame thread, which is not a
trade worth making for telemetry.

UdpLog now places a sequenced entry after every driver event that precedes it and
before every one that follows, within a bounded window, so the log reads in the
order the driver produced the events. A repeated sequence is dropped as a
duplicate. Rows this process logged itself carry no sequence and stop the walk,
so a late driver event never jumps ahead of local history, and one stray
datagram cannot rewrite old rows. Coalescing is skipped for a late arrival, which
would otherwise collapse it into the newest row and drop the row it belongs
behind. Haptic events go through the same placement, so both event types share
one order.

docs/protocol.md and its translation state what receivers can rely on: order by
sequence rather than arrival, deduplicate repeats, treat gaps as loss, and expect
no retransmission or acknowledgement.
Joining the driver log multicast group is a monitoring choice, not something a
fresh install should do on its own: while joined, driver reports become the
source of truth for successful sends and the UI suppresses its own rows, which
is a behavior change a user should opt into.

The switch now starts off. Its state was already persisted through the existing
UI preferences file, so a user who turns it on keeps it on across launches with
no new configuration; only the default and the fallback used for a malformed
value changed.
The facts a receiver author needs were present but not usable as a guide: the
socket requirements sat in one prose sentence, the only example was the bundled
PowerShell listener, and nothing explained why the non-obvious steps matter.
Every one of them fails silently — the socket still opens and binds, and no
datagram ever arrives — so a reader who misses one has nothing to debug from.

Adds a Writing A Receiver section: the four socket steps in order with the
consequence of skipping each, a worked example that handles both event types,
skips an unknown one and reports loss from the sequence numbering, and a
checklist for the case that actually happens, which is a healthy socket
receiving nothing.

The example was run against real multicast traffic rather than written from
memory. The published code was extracted back out of the document and executed
to confirm it parses all three event shapes, and two independent copies were run
at once to confirm the SO_REUSEADDR guidance is right.

Both READMEs now link to it, so someone building an external tool can find it
without reading the protocol reference end to end.
@Alitzlan
Alitzlan self-requested a review August 6, 2026 17:49
Every driver log event said which order it happened in but never when. A
receiver could order and deduplicate events, yet could not measure latency,
correlate against SteamVR's own log, or tell how much real time a suppressed
run of held poses covered.

Add `timestamp_ms` to the shared envelope: milliseconds since the Unix epoch,
UTC, taken from the wall clock alongside the sequence number. Because it rides
in the envelope it arrives on every event type, including ones a build does not
recognize.

The timestamp answers "when" and never "in what order". The wall clock can step
backwards across an NTP correction, a manual change, or a VM resume, and two
events can share a millisecond, so ordering, deduplication, and loss detection
still use `sequence` alone. The documentation and tests both state this, and a
regression test asserts that an event numbered later but stamped earlier still
sorts last.

DriverLogSender::Stamp now fills in both fields together, so a new event type
cannot ship numbered but untimed. The field is optional on the wire and parses
as zero when absent, which keeps a sender that predates it valid.

The companion UI and the bundled listener now date a row by the driver's
reading rather than by arrival, falling back to arrival time when none is
supplied.
@anyapipira
anyapipira force-pushed the dev/driver-command-logging branch from c7bd47e to ede531a Compare August 6, 2026 18:04
@anyapipira anyapipira closed this Aug 7, 2026
@anyapipira anyapipira reopened this Aug 7, 2026
@anyapipira
anyapipira merged commit 9a66f9d into main Aug 7, 2026
2 of 4 checks passed
@anyapipira
anyapipira deleted the dev/driver-command-logging branch August 7, 2026 06:15
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.

[Feature] Expose virtual-controller haptic events over loopback UDP

2 participants