Skip to content

Head Tracking: Internals

The OpenTrack UDP datagram, the FreeTrack 2.0 heap, the scaling into six axes, and the row that carries them.

The user-facing page is Head Tracking. This one is for whoever has to change the code.

File Role
PadForge.Engine/Common/HeadPose.cs Pure decoders and scaling, no Windows calls
PadForge.App/Common/Input/HeadTrackerDevice.cs The row: UDP receive thread, FreeTrack polling, silence, status. FreeTrackReader lives in the same file.
PadForge.App/Common/Input/HeadTrackingRuntime.cs Static mirror of the Dashboard controls the poll thread reads
PadForge.App/ViewModels/DashboardViewModel.cs The head-tracking properties (three input toggles, the OpenXR runtime picker, the UDP port, two shared ranges, six per-axis ranges), their reset commands, the Set Neutral command, and HeadTrackingStatus
PadForge.App/Services/InputService.cs BuildHeadTrackerStatus, UpdateHeadTrackingStatus, the Devices row status
PadForge.App/Services/SettingsService.cs AppSettingsData.HeadTracking*, ProfileData.EnableHeadTracking, EnableHeadTrackingFreeTrack, and format markers
PadForge.App/Services/WebControllerServer.cs EnsureInboundFirewallRule, shared with the web controller
PadForge.App/Common/Input/InputManager.Step1.UpdateDevices.cs Phase 1i: UpdateHeadTrackerDevice, RetireHeadTrackerRow, ShutdownHeadTrackerInputs

Identity

Member Value
Name Head Tracker (OpenTrack), or Head Tracker (OpenXR) when OpenXR is the only input on
DevicePath headtrack://opentrack
InstanceGuid MD5 of pfheadtrack:opentrack
ProductGuid MD5 of pfheadtrack-product
VID:PID 4854:4F54 ("HT", "OT")
InputDeviceType HeadTracker = 34
Axes Six AbsoluteAxis objects at InputIndex 0 to 5, named Head Yaw, Head Pitch, Head Roll, Head X, Head Y, Head Z, GUIDs X, Y, Z, Rx, Ry, Rz
Buttons, hats, rumble, gyro, touchpad None

The path is a URI scheme, so DeviceRowViewModel.IsInternalVirtual is true and the Devices page draws neither the Input Hiding nor the Input Mode section. CreateDefaultPadSetting auto-maps only gamepads, Bliss-Box ports with a placed controller, and touchpads or tablets on a PlayStation output, so the row starts unmapped by design.


Lifecycle: Phase 1i

UpdateHeadTrackerDevice runs on the poll thread after the handheld phase. Off with nothing to retire, it returns after five volatile field reads and takes no lock. Otherwise it retires the runtime row when every input is off, the user removed it or either VR controller row from the Devices page, or ConfigVersion no longer equals HeadTrackingRuntime.Version, and opens a fresh one from FromCurrentSettings while any input is enabled. There are three inputs since 4.5.0: UDP, FreeTrack and OpenXR. The OpenXR side is documented separately in OpenXR Input Internals. Stored assignments and mappings survive retirement.

FromCurrentSettings reads Version first and the settings after it. The setters bump Version last, so the other order could capture the new version with the old port and the reconfigured check would never fire again for that change.

HeadTrackingRuntime is the static mirror:

Member Default Clamp Reopens the row
Enabled false UDP input changed
UdpPort 4242 1 to 65535 Only while UDP is enabled
FreeTrackEnabled false Yes
OpenXrEnabled false Yes
OpenXrRuntimeManifest empty, the system default Only while OpenXR is enabled
RotationRangeDeg 90 1 to 180 No, read live every poll
TranslationRangeCm 30 1 to 500 No, read live every poll
SetAxisRange(axis, value) 0, follow the shared range 1 to 180 for a rotation axis, 1 to 500 for a translation axis No, read live every poll

Open opens only enabled inputs. UDP binds its socket, starts its receive thread, and queues the firewall rule. FreeTrack opens its mapping independently. OpenXR creates the two VR controller rows and starts its session thread. The row opens even when every source fails, so the status line can say why nothing arrives. Dispose runs on the poll thread when the sweep retires the row: close the socket first (that is what unblocks ReceiveFrom), a 50 ms courtesy join, then the FreeTrack reader, then the OpenXR source, whose stop joins its thread for up to 2 s, and the two controller rows.


Source one: OpenTrack UDP

OpenTrack's "UDP over network" output (proto-udp/ftnoir_protocol_ftn.cpp) sends sizeof(double[6]) per pose.

Fact Value
Datagram 48 bytes, six little-endian doubles
Order TX, TY, TZ, Yaw, Pitch, Roll (opentrack enum Axis)
Units Translation in centimeters, rotation in degrees
Signs Positive yaw moves OpenTrack's mouse output right, positive pitch moves it up (proto-mouse invert table)
Short datagram Dropped
Long datagram Up to 256 bytes, the first 48 are read and the rest ignored, as OpenTrack's own UDP tracker does. A longer one fails the 256-byte receive with MessageSize and is dropped.
NaN or infinity in any field The whole datagram is dropped (tracker-udp rule)

The socket is IPv4 UDP bound to IPAddress.Any on the port (HeadTrackerDevice.UdpBindAddress, which the tests set to loopback), with ExclusiveAddressUse so a second listener on the same port (OpenTrack's own UDP tracker) surfaces as a bind failure instead of stealing half the datagrams, and SIO_UDP_CONNRESET cleared so an ICMP port-unreachable does not throw out of the next receive (the DSU server's rule). The receive thread is PadForge.HeadTrackerUdp, a 256-byte buffer, and after ten consecutive socket errors it sleeps 50 ms between retries. A bind failure sets UdpBindFailed, and GetCurrentState tries the bind again every 3 seconds (UdpRetryMs, RetryUdpBind), one attempt at a time and never after Dispose, so closing the program that held the port brings the listener up without the row reopening.

The peer is recorded as address:port of the last sender, and a change of peer bumps StatusVersion.


Source two: FreeTrack 2.0 shared memory

FreeTrackReader is the client side of freetrackclient.c.

Fact Value
Mapping name FT_SharedMem
Size 108 bytes: FTData (92) + GameID + an 8-byte table + GameID2 (freetrackclient/fttypes.h)
Mutex FT_Mutext, taken with a zero-millisecond wait per read
Open MemoryMappedFile.CreateOrOpen, so launch order does not matter

TryDecodeFreeTrackHeap inverts OpenTrack's writer (proto-ft/ftnoir_protocol_ft.cpp):

Offset Field Into the pose
0 DataID, u32 Increments once per written pose
12 Yaw, float radians -yaw × 180/π
16 Pitch, float radians -pitch × 180/π
20 Roll, float radians roll × 180/π
24, 28, 32 X, Y, Z, float millimeters / 10

Non-finite floats reject the read. The heap is polled from GetCurrentState, on the poll thread, and only a DataID change is a pose. The first read is a baseline: the heap keeps the last pose of a previous run, and a stale mapping must not move the axes. The heap is never copied without the mutex. A mutex the reader could not open means it copies nothing, which is what the reference client's FTGetData does: its copy sits inside the wait test. A torn pose is worse than a stale one, and the silence timeout already reports a writer that stops. OpenTrack's own writer never takes FT_Mutext. It stores each field with an interlocked write (proto-ft/ftnoir_protocol_ft.cpp), so the mutex orders this read only against other clients that take it, such as a game's FTGetData, and cannot stop a torn pose from OpenTrack. The wait itself is zero rather than the reference's 16 ms, because the read happens inline on the input polling thread. Sixteen milliseconds is a game frame's budget, not a poll tick's, and waiting on another process's hold of the mutex stalled the whole poll loop. A contested tick reports no new pose and the last one stands. A mapping that fails to open sets FreeTrackFailed.

UDP and FreeTrack carry the same pose from the same tracker, so interleaving those two is harmless.


Scaling into axes

HeadPose.ToAxis(value, range):

f = clamp(value / range, -1, 1)
f >= 0: round(32768 + f × 32767)
f <  0: round(32768 + f × 32768)

So −range reads 0, rest reads 32768 (AxisCenter, the CustomInputState center, not the arithmetic midpoint), +range reads 65535, beyond either end clamps, and a non-positive range or a NaN reads rest.

FillAxesPerAxis applies the signs:

Axis Source Sign
0 Yaw pose[Yaw] as sent, right reads high
1 Pitch -pose[Pitch] stick orientation, up at the low end
2 Roll pose[Roll] as sent
3 X pose[TX] as sent, right reads high
4 Y -pose[TY] stick orientation, up at the low end
5 Z pose[TZ] as sent

Each axis's range comes from HeadTrackingRuntime.GetAxisRange on every poll: the axis's own pinned range when one is set, the shared rotation or translation range otherwise. A range edit applies without reopening the row.


Silence

SilenceMs = 1000. GetCurrentState compares the last pose's tick against now. Older than that, or no pose ever, and the axes read center and Source drops to None with a StatusVersion bump. The row stays attached, so mappings can be made before the tracker starts. A tracker that stops (OpenTrack closed, the camera lost the face) therefore recenters the stick one second after its last pose.


The status line

InputService.BuildHeadTrackerStatus turns the device into text, verbatim from Strings.resx:

Condition Text
Source is Udp Receiving over UDP from {peer}. plus FreeTrack shared memory is unavailable. when FreeTrackFailed
Source is FreeTrack Receiving from FreeTrack shared memory. plus UDP port {port} is in use by another program. when UDP is on and UdpBindFailed
Source is OpenXr Reading {runtime}
No pose, OpenXR the only input on The OpenXR state line below
No pose, UDP off FreeTrack shared memory is unavailable. when FreeTrackFailed, else Waiting for FreeTrack shared memory data., plus the OpenXR state line when OpenXR is on
No pose, UdpBindFailed UDP port {port} is in use by another program. plus The FreeTrack shared memory could not be opened either. when FreeTrackFailed
No pose, FreeTrackFailed Waiting for a tracker on UDP port {port}. The FreeTrack shared memory could not be opened.
No pose Waiting for a tracker on UDP port {port}. plus the OpenXR state line when OpenXR is on

The OpenXR state line follows OpenXrSourceState: No OpenXR runtime is installed (NoRuntime), The OpenXR runtime reports no headset (NoHeadset), This runtime cannot supply a background session (NotSupported), The OpenXR session failed. See the diagnostics log. (Failed), Waiting for {runtime} to report a tracked pose (Running before the first pose), The OpenXR runtime ended the session. Turn OpenXR headset input off and back on to reconnect. (Ended), and Starting the OpenXR session otherwise.

OpenXrHeadPoseSource.RunLoop sets Ended on XR_SESSION_STATE_EXITING, a lost instance (XR_ERROR_INSTANCE_LOST) or a failed event poll, unless Stop asked for it. The OpenXR specification asks an application in XR_SESSION_STATE_EXITING to end its XR experience and not restart it automatically. After a lost instance or a failed poll the loop creates no new instance either, because a new instance can start the runtime's processes again and a lost instance cannot be told from a runtime the user closed.

On a runtime other than SteamVR the loop recovers three cases:

  • When xrGetSystem answers XR_ERROR_FORM_FACTOR_UNAVAILABLE the headset is away. The state is NoHeadset, the instance stays, and the loop asks xrGetSystem again every 2 s.
  • On XR_SESSION_STATE_LOSS_PENDING the loop destroys the session, keeps the instance, shows Connecting, and creates a session again the same way. A session lost as it is created, when xrCreateReferenceSpace answers XR_ERROR_SESSION_LOST, is let go and asked for again the same way (OpenXrSessionCreate.SessionLost). On SteamVR that loss is final, as every loss is there.
  • On XrEventDataInstanceLossPending under a session the loop destroys the instance and asks for a new one after the loss time, then every 2 s while the runtime is away. The ask goes to the manifest the retired instance came from (IOpenXrLink.ManifestPath), never to the runtime choice. The runtime counts as away while it answers XR_ERROR_RUNTIME_UNAVAILABLE, and while its manifest or library is off disk, does not load or refuses the negotiation.

With no session standing, that event, a lost instance and a failed poll each end the source as Ended. On SteamVR none of the three recoveries runs (OpenXrSession.IsOpenVrClientLibrary), and OpenXrSession.TryCreate refuses to create an instance again on an OpenVR client library. SteamVR's runtime library is the OpenVR client library the OpenVR input shares in the same process, it reports its own quit to an instance with no session as XrEventDataInstanceLossPending, and it never sends XR_SESSION_STATE_LOSS_PENDING. Those three facts are read from the library, not seen in a run (OpenXR Input Internals). On SteamVR a missing headset is final and every loss ends as Ended.

A stop for good settles through OpenXrHeadPoseSource.SettledOn: Ended once a session has stood, Failed in place of NoHeadset, and the reason otherwise. Settle writes one line with what was read, OpenXR: {what} ({read}), so the source stops ({state}). An xrGetSystem error other than XR_ERROR_FORM_FACTOR_UNAVAILABLE is Failed, and a lost instance is Ended from any call of session creation (OpenXrSession.FailureOf). A refused xrBeginSession ends the source (OpenXrSession.EndOfBegin). A session state event or a reference space change that names another session is dropped, and so is one that arrives while no session stands (OpenXrSession.IsAboutThisSession). The live session's reference space change recaptures the neutral. The lines the link writes about an instance or a session (the controllers, a session that runs, an event storm) are written once until a pose arrives, like the source's own (OpenXrHeadPoseSource.SayFromLink). Every number in an OpenXR line is written in the invariant culture (OpenXrInterop.Number), so a log reads the same under every Windows regional format.

The same text lands in two places, each rebuilt only when StatusVersion moves. The Devices row's line comes from UpdateDevicesRawState, the preview loop, keyed on the device instance plus its version (a reopen restarts the version at 0, so a version-only key kept a retired device's line). The Dashboard's HeadTrackingStatus comes from UpdateHeadTrackingStatus on the dashboard tick and reads Stopped (Common_Stopped) while the feature is off, the engine is down, or the row has not opened.


The firewall rule

WebControllerServer.EnsureInboundFirewallRule("PadForge Head Tracking", "UDP", port) runs netsh advfirewall firewall delete rule name=... then add rule ... dir=in action=allow protocol=UDP localport=<port>. Delete-then-add needs no parsing of netsh's localized output, is idempotent, and clears the pile-up a port change used to leave behind. It is queued on the thread pool from Open only when UDP is enabled, since netsh can block for seconds, and it is best effort.


Settings and the profile leg

Global settings retain HeadTrackingEnabled for UDP and HeadTrackingFreeTrack for FreeTrack, with a HeadTrackingIndependentInputs format marker. Old files are loaded as UDP = old master and FreeTrack = old master AND old FreeTrack preference. The original DTO preference is retained long enough to migrate all stored profile opinions. New saves write independent values. Fresh settings have every input off.

Profiles retain nullable EnableHeadTracking for UDP and add nullable EnableHeadTrackingFreeTrack, plus their own format marker. Legacy explicit master opinions migrate once using the original FreeTrack preference. Null opinions remain null. An old standalone profile imported later uses the current FreeTrack preference. New user edits author only the changed input. Applying a null opinion leaves the current value alone. Save and export preserve both opinions and the marker. The port and ranges remain global.

Real UDP and uniquely named shared-memory fixtures verify all four input combinations, each failure beside a working input, resource release, and the status text. Assignment tests verify that adding a tracker leaves authored Any Device mappings intact.


Diagnostics lines

Line When
Head tracker: listening on UDP port <port> Bind succeeded, at open or on a retry
Head tracker: UDP port <port> bind failed: <message> Bind failed at open. A retry that fails writes nothing
Head tracker: FreeTrack shared memory open Mapping opened
Head tracker: FreeTrack mapping failed <message> Mapping failed
Head tracker: FreeTrack mutex failed <message> The mutex failed to open, so the mapping is closed
Head tracker: pose #<n> via <Udp|FreeTrack|OpenXr> yaw= pitch= roll= x= y= z= Poses 1, 2, 4, ... 64, then every 4096th

Tests

The tests in HeadTrackerTests.cs pin the UDP layout and order, the NaN and infinity drop, the short and long datagram rules, the FreeTrack offsets and sign inversions, ToAxis at rest, the ends, the clamp, and a bad range, the stick-orientation vertical axes, a UDP pose landing on the axes with its peer named, the one-second silence recenter, the FreeTrack baseline-then-DataID rule, an online row at rest before any pose, six named axes at indices 0 to 5, and the type ordinal pinned past SystemMotion.


Evidence status

Confirmed by reading and by the replay tests: the datagram layout, the heap offsets, the sign inversions of OpenTrack's FreeTrack writer, and the scaling. Not yet run against a live OpenTrack. Roll, X and Z pass through with the sign the tracker sends, and Y is flipped into stick orientation on the assumption that the tracker's positive Y is up. Whether those signs match a user's expectation on real hardware is unconfirmed. Sharing of the FT_SharedMem mapping between an elevated PadForge and a medium-integrity OpenTrack is reasoned from the reference client, not tested.



Last updated for PadForge 5.2.0.