Repository navigation
Add driver log telemetry over multicast - #12
Merged
Merged
Conversation
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
self-requested a review
August 6, 2026 17:49
Alitzlan
approved these changes
Aug 6, 2026
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
force-pushed
the
dev/driver-command-logging
branch
from
August 6, 2026 18:04
c7bd47e to
ede531a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, anddetail, serialized and validatedin one place. A receiver can filter, order, and render any event — including one
it does not recognize — without knowing its shape. An unknown
eventname parsessuccessfully 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".
sequenceorders,timestamp_msdates. UDP loses, duplicates, and reordersdatagrams. 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
go below it, and the shared floor was preventing that.
none were actually out of range: clamping was compared against the wrong value.
launches, since joining the group changes where log rows come from.
Documentation
docs/protocol.mdand its Chinese translation gain the event schema, theordering 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— threeindependent listener processes each receive the probe.