Sensors and vibration: delivery, math and lifetime

CNA snapshot 009d40f5  ·  Deep Dives › Input, audio, media & services  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading modules/devices, the SDL3 sensor and haptic services and the Android math headers at 009d40f5; the two fragments were syntax-checked with g++ -fsyntax-only against TARGET headers and sharp-runtime next at 41b918c9 (not pinned by TARGET). Nothing was executed; no Android or iOS device and no desktop sensor hardware was exercised.

Microsoft::Devices::Sensors ports the Windows Phone 7 sensor model that XNA 4.0 shipped: stateful sensor objects that raise events from outside the game thread. This page gives the parts of that contract a game has to get right (which thread delivers a reading, in which units and with which timestamp), the exact compass and motion mathematics CNA uses on Android, the vibration semantics, the one open lifetime defect, and the limits of the evidence. It is for anyone using tilt, heading or phone-style vibration. The guide is Sensors and Tutorial 50; the locking, disposal and Android worker machinery is traced for maintainers on Devices and sensor lifetime. The CNA-only host utilities are a separate, optional module described on Host devices.

The sensor model

Every sensor derives from SensorBase<TReading> (SensorBase.hpp), which supplies CurrentValue, IsDataValid, TimeBetweenUpdates (default 2 ms), the CurrentValueChanged event and abstract Start/Stop. TimeBetweenUpdates is a software throttle: the platform exposes no rate control, so samples arriving sooner than the interval after the last accepted one are dropped. The comparison is made in 100-nanosecond ticks by converting the elapsed time down, which is what keeps TimeSpan::MaxValue from overflowing a signed 64-bit multiplication; the earlier reverse conversion overflowed and was found by UndefinedBehaviorSanitizer. The current value and IsDataValid are published together under one lock by SetCurrentValueAndMarkDataValid; with separate locks a reader could once observe IsDataValid true alongside the previous value.

TypeImplemented routeBoundary
AccelerometerThe selected platform's sensor service (SDL3 on desktop, Android and iOS)Web targets are refused by policy even where the browser has a sensor; also raises WP7's legacy ReadingChanged, after CurrentValueChanged
GyroscopeThe same serviceAngular velocity in radians per second, passed through; no legacy event
CompassAndroid NDK onlyElsewhere getIsSupportedProperty() is false and Start() throws SensorFailedException
MotionAndroid NDK sensor fusion onlySame as Compass

Accelerometer and Gyroscope report support only when the target is Android, iOS or desktop and a balanced probe of the platform (acquire the sensor subsystem, ask, release) finds the sensor. On desktop only the SDL3 platform has a sensor service: X11, Wayland, Win32, SDL2, Headless and Terminal return none, so the sensors report NotSupported there. Each sensor class allows at most ten live instances; the eleventh constructor throws.

Which thread delivers a reading

On desktop the SDL3 platform's sensor session installs an SDL event watch rather than a polling thread (Sdl3DeviceServices.cpp), so CurrentValueChanged runs on whichever thread pushes SDL_EVENT_SENSOR_UPDATE into SDL's queue. On Android, Compass and Motion run NDK sensor queues and ALooper workers (no JNI) on their own threads; the native route expects Android API level 24 or later. In neither case is the handler thread the game thread. Do not touch GraphicsDevice or other game-thread objects in a handler: copy the reading into a synchronized queue and consume it in Update.

Acceleration arrives from the platform in metres per second squared and is divided by 9.80665 to give XNA's units of g; angular velocity is already in radians per second. Each reading is stamped with the wall-clock DateTimeOffset::getUtcNowProperty() at dispatch, never with the platform's monotonic sensor time, so timestamps are comparable across sensors but move with clock adjustments.

Accelerometer accelerometer;
accelerometer.CurrentValueChanged.Add(
    [](System::Object*, const SensorReadingEventArgs<AccelerometerReading>& args) {
        const Vector3 acceleration = args.getSensorReadingProperty().getAccelerationProperty();
        Enqueue(acceleration);          // hand over to the game thread; do no drawing here
    });
accelerometer.Start();
// ... later ...
accelerometer.Stop();
accelerometer.Dispose();

Syntax-checked against the TARGET headers and sharp-runtime next at 41b918c9 for EventHandler::Add (not pinned by TARGET; not executed); Enqueue is the game's own synchronized hand-off.

Lifetime, concurrency and the one open defect

Disposal is claimed atomically: one racing caller performs the cleanup, the others wait until the terminal state is published, and a guard publishes that state even if cleanup throws. Accelerometer and Gyroscope track callbacks in flight per thread, so a handler may dispose its own sensor without waiting for itself. Compass and Motion use a two-phase start and stop (reserve under the owner's mutex, call the backend without it, then commit or roll back) with a counter of backend calls in flight, so a new Start waits for an orphaned start's cleanup while Stop can still supersede a start without waiting on it. The full contract and its tests are on Devices: shared state, event ordering and failure.

Why the sanitizer evidence matters here: the first rewrite of that lifecycle looked race-free under ordinary tests and manual review. ThreadSanitizer then found that Stop cleared a transition flag before an orphaned Start had finished its backend cleanup, letting a third Start run concurrently, and a stress test reproduced both that race and a use-after-free in the callback bookkeeping. The in-flight counter and its condition variable are the fix.

Exceptions must not cross SDL, Android C callbacks or raw thread entry points. A handler exception is caught at the callback boundary and recorded through a noexcept diagnostic channel (NativeDiagnosticSink) with the backend, operation, native error, device, timestamp and severity; the Android bridge distinguishes an exception's message from an unknown throw. In a release build that record is only counted and stored for tests, not logged.

⚠

Call the parameterless Dispose(). SensorBase declares Dispose(bool) protected, but Accelerometer, Gyroscope, Compass and Motion each redeclare it in a public section. An external Dispose(false) takes the finalizer branch: it marks the object disposed without stopping it, decrementing the instance count or releasing its platform lease. That is an open API and lifetime defect at this snapshot; see Known Issues.

Compass mathematics (Android)

The compass derives heading from Android's fused rotation-vector quaternion (AndroidCompassMath.hpp):

  • Normalization first. The quaternion is normalized in double precision before use, because the heading formulas are exact only for unit length; a non-finite or near-zero quaternion is rejected and the heading falls back to 0°.
  • Flat or upright. WP7's walkthrough chooses the heading formula by pose: upright when |gZ| < cos 45° and gY < -cos 45° in the fixed device frame. CNA derives those gravity components from the same quaternion (gY = 2(yz + xw), gZ = 1 - 2(x² + y²)) instead of subscribing a second accelerometer, then uses the upright or flat azimuth formula: flat (device lying face-up) is atan2(2(xy − zw), 1 − 2(x² + z²)) and upright is atan2(2(xz + yw), 2(yz − xw)), the R01/R11 and R02/R12 entries of the quaternion's rotation matrix; results are normalized to [0°, 360°). Both are the source's own derivations, not checked against a device.
  • Accuracy. Android's magnetometer accuracy maps to HeadingAccuracy as High 5°, Medium 15°, Low 20°, Unreliable or no contact 180°. The value for Low is exactly 20° so that it does not contradict WP7's rule that Calibrate fires when accuracy exceeds ±20°; Calibrate is raised for Unreliable and no-contact only, not for Low, to avoid event spam.
  • Freshness. Heading and magnetometer come from two independent streams; a sample is fused only while it is at most five times the requested interval old, with a floor of 500 ms.
  • No display remap. WP7 compass readings are defined in the device's fixed physical axes whatever the display orientation, so the compass applies no landscape remap, deliberately.
  • True heading. With no declination source, TrueHeading equals MagneticHeading.

Motion mathematics (Android)

Motion fuses an attitude source (the rotation vector, or the game rotation vector when the first is unavailable) with gravity, linear acceleration and the gyroscope; the magnetometer bridge only drives calibration. Six bridge objects exist per instance and at most five are started. The attitude quaternion is Android's, normalized and passed through: Android's rotation-vector quaternion and XNA's Quaternion use the same Hamilton convention in a right-handed frame, so no handedness correction exists to apply. Yaw, pitch and roll are derived from its rotation matrix as pitch = asin(clamp(-M32, -1, 1)), yaw = atan2(M31, M33), roll = atan2(M12, M22) (AndroidMotionMath.hpp). A fused reading is published only when the four latest samples lie within 500 ms of each other; the samples are not interpolated to one timestamp.

On Android a process-wide CNAEXT landscape remap, on by default and switchable with SetAndroidLandscapeRemapEnabled, converts the portrait-frame axes of the accelerometer, the gyroscope and Motion's gravity, device acceleration and rotation rate to XNA's landscape convention (for a device rotated 90° from portrait, portrait-top toward the landscape left, which is also the fallback when the rotation is unknown, (x, y, z) becomes (x, −y, z); for 270°, portrait-top toward the right, it becomes (−x, y, z)); real WP7 does not do this. It does not touch Attitude, and cannot: for both landscape rotations the remap is a reflection (diag(1, -1, 1) or diag(-1, 1, 1), determinant -1), not a rotation, and no quaternion can represent a reflection. CNA's own source records this as a finding for whoever next revisits the remap, not as a settled design.

VibrateController

VibrateController::getDefaultProperty() returns a process-lifetime singleton (VibrateController.cpp). Start(TimeSpan) accepts WP7's range of zero to five seconds and throws ArgumentOutOfRangeException outside it; the CNAEXT Start(TimeSpan, float) adds an intensity and StartLeftRight(large, small, duration) two motor strengths, all clamped to 0–1 with NaN treated as zero before any backend call. getIsSupportedProperty() and getDeviceNameProperty() are CNAEXT too.

  • The backend borrows the platform's haptics service and asks it for a default vibration device; gamepad haptic devices are excluded on purpose, so the phone-style API never fights GamePad::SetVibration for the same motor. How the platform picks differs: the SDL3 service returns the first non-gamepad haptic device that opens, whether or not it supports rumble (getIsSupportedProperty() then reports that one device's rumble support, so a rumble-less first device hides a capable second one), while the Linux evdev hub considers only non-gamepad nodes that support rumble. Independent left and right controller rumble belongs to GamePad.
  • StartLeftRight plays a two-motor effect only on a device that supports one; on a device without it nothing plays and there is no fallback to single-strength rumble. On Android, SDL's own haptic route has been reported to blend the two motor strengths into one vibration; that is SDL behaviour and was not verified for this page.
  • Start(duration, 0.0f) still starts a rumble, at zero strength; use Stop() to stop.
  • With no suitable device, Start is a silent no-op, as on a real device without a motor.
VibrateController* vibration = VibrateController::getDefaultProperty();
if (vibration->getIsSupportedProperty())
{
    vibration->StartLeftRight(1.0f, 0.3f, System::TimeSpan::FromSeconds(0.2));
}
// ... when the effect should end early:
vibration->Stop();

Syntax-checked against the TARGET headers (not executed). Shutdown order matters for a singleton that borrows a platform service: DevicesShutdownCoordinator::Shutdown(), called while the platform and its native services are still alive, releases the backend and makes the controller inert, and an atexit fallback covers programs that never call it. An older revision instead required the application to call the coordinator before its own SDL_Quit() and serialized sensor and haptic subsystem start-up through one global mutex; at this snapshot subsystem leases belong to each sensor class and to the platform, and production code relies on the fallback and on an identity guard, as described on Devices: VibrateController and process-exit order.

Evidence and what it does not cover

Focused tests cover the math, throttling, lifecycle, fake backends, re-entrancy and ThreadSanitizer stress. They are not hardware QA. The Android compass and motion code states in its own comments that its sign and zero-point conventions were never checked against a real device, the Android demo that the APK build uses is an older, shorter copy of the desktop demo, and no hardware QA report exists in the repository for the template CNA provides. Android behaviour is therefore source-verified and host-tested where fakes permit, not device-verified, and nothing on this page was executed.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.