Sensa Haptics: Internals¶
How controller rumble becomes a Razer Sensa HD effect: the Interhaptics bindings, the provider bring-up and its retry sentinel, the worker lifecycle, and the rumble lane that feeds it.
The user-facing page is Lightbar Mirrors and Sensa Haptics. This one is for whoever has to change the code.
| File | Role |
|---|---|
PadForge.App/Services/SensaHapticsService.cs |
The bindings, the worker, the publish surface |
PadForge.App/Common/Input/InputManager.Step5.VirtualDevices.cs |
UpdateSensaLane, the per-tick feed |
PadForge.App/Common/Input/InputManager.cs |
The call site, after UpdateRumbleAudioLane in the poll loop |
PadForge.App/Services/InputService.cs |
StartSensaIfEnabled, StopSensaService, status marshaling |
PadForge.App/PadForge.App.csproj |
The vendored engine and provider |
PadForge.App/Resources/Interhaptics/x64/ |
HAR.dll, Interhaptics.RazerProvider.dll |
PadForge.Tests/SensaHapticsTests.cs |
The bench, which runs the real engine |
Why the engine layer¶
Razer's game-facing surface (the WYVRN SDK) plays only pre-authored named clips and carries no amplitude channel. One layer down is public: the Interhaptics Core SDK, whose parametric API takes an amplitude at runtime. The shipping Unity integration, WyvrnOfficial/Interhaptics_Unity_CoreSDK, carries the proven call order, and every native call here mirrors it function for function. The Unity reference makes every call from one thread, and so does this worker.
The two DLLs are vendored from that repository's Runtime/Plugins/x64, the same pair every Unity title embedding the SDK redistributes. They ship unmodified inside the executable under the Wyvrn EULA (see the README's third-party section). The csproj includes them as Content with Link so they land beside the executable, conditioned on the files existing, and the service P/Invokes them lazily so a missing pair degrades to a diag line.
Bindings¶
The Har class mirrors HAR.Native.cs from the Unity SDK verbatim: same names, same signatures, default marshaling. The DLL name is HAR.
| Export | Signature | Used for |
|---|---|---|
Init |
bool () |
Engine up |
Quit |
void () |
Engine down |
AddParametricEffect |
int (double[] amplitude, int amplitudeSize, double[] pitch, int pitchSize, double freqMin, double freqMax, double[] transient, int transientSize, bool isLooping) |
Creates the one effect. Returns -1 on failure. |
AddTargetToEventMarshal |
void (int id, CommandData[] target, int size) |
Targets the effect at the whole body |
SetEventIntensity |
void (int id, double intensity) |
The live rumble amplitude |
PlayEvent |
void (int id, double vibrationOffset, double textureOffset, double stiffnessOffset) |
Starts the effect |
ComputeAllEvents |
void (double curTime) |
Advances the engine clock |
StopAllEvents |
void () |
Teardown |
The Provider class carries the Razer provider trio plus the render call, names verbatim from RazerSensaProvider.cs. The DLL name is Interhaptics.RazerProvider.
| Export | Signature |
|---|---|
ProviderInit |
bool () |
ProviderIsPresent |
bool () |
ProviderClean |
bool () |
ProviderRenderHaptics |
void () |
The provider is a thin bridge to Synapse's installed Interhaptics runtime: it locates RzInterHaptics.dll through the registry and signals the mixer's global event (a strings-level read of the shipped DLL). Without Synapse, ProviderInit fails cleanly.
CommandData is Interhaptics.HapticBodyMapping.CommandData, three int enums laid out sequentially and blittable:
[StructLayout(LayoutKind.Sequential)]
internal struct CommandData
{
public int Sign; // Operator: Plus = 1
public int Group; // GroupID: All = 0
public int Side; // LateralFlag: Global = 0
}
Call order¶
The worker follows the Unity integration's HapticDeviceManager and HAR.PlayParametricHapticEffect:
| Step | Calls |
|---|---|
| 1. Engine up | Har.Init(). HAR.dll runs with no Razer device present. A DllNotFoundException or EntryPointNotFoundException is caught and logged, and the worker returns. |
| 2. The effect | AddParametricEffect({0, 1, 1, 1}, 4, null, 0, 65.0, 300.0, null, 0, true): a looping constant envelope whose amplitude pairs are time-value (hold 1.0 across a one-second loop), the Unity reference's default 65 to 300 Hz band, no pitch, no transients. Then AddTargetToEventMarshal(id, [Plus, All, Global], 1), SetEventIntensity(id, 0.0), and PlayEvent(id, -clock.Elapsed.TotalSeconds, 0.0, 0.0). The negative-now offset aligns the effect clock with the ComputeAllEvents time argument. |
| 3. Provider bring-up | ProviderInit() on the retry cadence below. |
| 4. Per tick | Read the published amplitude. If it changed, SetEventIntensity(id, amp). Then ComputeAllEvents(clock.Elapsed.TotalSeconds), and only when the provider is up and ProviderIsPresent() is true, ProviderRenderHaptics(). Sleep tickMs. |
| 5. Teardown | StopAllEvents(), ProviderClean() if the provider was up, Quit(). |
Rendering gates on both init and presence. ProviderIsPresent answers true even when ProviderInit failed (bench-measured), and the Unity reference never queries presence for a failed-init provider, so its value there is undefined.
Provider arming and the retry sentinel¶
The provider is retried every retryMs (default 30000) while it is down. The sentinel is seeded one full interval in the past:
The original code seeded long.MinValue. TickCount64 - long.MinValue overflows negative, the >= _retryMs test never passed, and the retry block silently never entered while the worker looked healthy in its tick loop. A live stack dump found it after log lines only bracketed the hang. ProviderInitAttempts counts every attempt so the cadence is a tested fact: SensaHapticsTests.Service_ArmsPublisherAndDegradesWithoutRuntime asserts it is at least one.
BeforeProviderInit is an internal static hook that runs on the worker immediately before ProviderInit, so a test can hold a worker inside the bring-up window.
On success the worker reports Active. The provider stays up for the life of the worker. There is no liveness probe against Synapse after that.
Worker lifecycle¶
The worker is a dedicated background Thread named SensaHaptics, not a task. Start is a no-op while _thread is set. Stop sets _stop, joins for 3000 ms, and nulls _thread regardless, so a worker still inside ProviderInit can outlive its service.
That outliving worker is the predecessor-join rule. Its finally disarms the publisher, zeroes the amplitude, and calls Har.Quit, and if it ran under the next instance's engine it would tear that engine down. So every worker's first act is:
var prev = Interlocked.Exchange(ref s_lastWorker, Thread.CurrentThread);
if (prev != null && prev != Thread.CurrentThread && prev.IsAlive
&& !prev.Join(_predecessorJoinMs))
{
Interlocked.CompareExchange(ref s_lastWorker, prev, Thread.CurrentThread);
return; // finally reports Stopped.
}
Volatile.Write(ref s_publisherArmed, 1);
The predecessor's teardown lands before the successor arms and inits, never after. s_lastWorker is static because the publisher flag it protects is.
The join is bounded at 10 seconds (DefaultPredecessorJoinMs). That is long past any bring-up the provider completes and short enough that a wedged one costs a single wait. On the deadline the successor hands the slot back to the straggler and quits without arming, because arming over a live predecessor is the exact handoff fault the join exists to prevent: that predecessor's finally would then disarm the publisher underneath it. An unbounded join blocked every later worker and leaked one thread per enable.
SensaHapticsTests.Service_NextWorkerWaitsForAStragglingPredecessor holds a worker in BeforeProviderInit, stops it, starts a second service, and asserts the second worker waits for the first to exit.
| Static | Purpose |
|---|---|
s_amplitudeBits |
The published amplitude, 0..1, as float bits |
s_publisherArmed |
Nonzero while a worker runs |
s_lastWorker |
The most recent worker thread |
The finally runs on every exit path, faults included (SENSA worker fault: {type}), and reports Stopped last.
Rumble to haptic¶
InputManager.UpdateSensaLane runs once per poll tick inside the non-idle loop, right after UpdateRumbleAudioLane, and after UpdateVirtualDevices so a slot destroyed this tick publishes zero the same tick. Its first line is the cheap exit:
For every slot up to MaxPads it takes the slot's inbound rumble pack (GetInboundRumblePack) and max-merges it with the live VibrationStates[slot] through LfeOutputState.MaxMerge, the same authority the rumble-to-audio lane reads, so test rumble counts. The pack is four ushort voices:
| Bits | Voice |
|---|---|
| 0-15 | Left motor |
| 16-31 | Right motor |
| 32-47 | Left trigger motor |
| 48-63 | Right trigger motor |
PackToAmplitude returns the loudest voice divided by 65535. The lane keeps the maximum across slots and calls PublishAmplitude, which clamps to 0..1 and stores the float bits with one volatile write. SensaHapticsTests.PackToAmplitude_TakesTheLoudestVoice and PublishAmplitude_Clamps pin both.
The worker reads the bits every tickMs (default 16) and calls SetEventIntensity only on change. Intensity is the whole translation: one effect, one target, one amplitude. There is no stereo targeting and no pitch mapping.
Ownership and persistence¶
InputService.StartSensaIfEnabled runs at engine start and on toggle-on, returning when the toggle is off, _inputManager is null, or a service is live. StopSensaService disposes the service at engine stop and on toggle-off, and writes Common_Stopped to SensaStatus. StateChanged is marshaled through _dispatcher.BeginInvoke:
| Enum | Dashboard string |
|---|---|
SensaServiceState.Active |
Dashboard_SensaActive |
SensaServiceState.WaitingForRuntime |
Dashboard_SensaWaiting |
SensaServiceState.Stopped |
Common_Stopped |
SensaServiceState.Unsupported |
Common_NotAvailableOnArm64 |
Persistence follows the lightbar mirrors leg for leg: AppSettings.EnableSensaHaptics (global bool, default false), ProfileData.EnableSensaHaptics (bool?, null = no opinion, applied by SettingsService.ApplyProfileServiceToggles, authored by OnDashboardServiceToggleChanged), and the MainWindow Dashboard autosave allowlist. See Lightbar Mirrors Internals for the rule and its test.
Diag lines¶
| Line | When |
|---|---|
SENSA start? enabled=... engine=... live=... |
Every start attempt |
SENSA worker: calling HAR.Init |
Worker entry |
SENSA HAR.dll not found |
DllNotFoundException on Init |
SENSA HAR entry point missing |
EntryPointNotFoundException on Init |
SENSA HAR.Init => {bool} |
After Init |
SENSA HAR.Init failed or HAR.dll missing |
Worker returning without an engine |
SENSA AddParametricEffect returned -1 |
Effect creation failed |
SENSA provider init => {bool} |
Every provider attempt |
SENSA worker fault: {exception type} |
Any unhandled exception in the worker |
Tests¶
SensaHapticsTests.cs executes the real Razer-shipped engine end to end with no device present. This is the strongest hardware-free evidence available, and preferable to a mock wherever a vendor engine is separable from its device bridge.
| Test | What it proves |
|---|---|
RealEngine_FullLifecycle |
Init, effect creation, targeting, intensity, compute, and Quit against the shipped HAR.dll |
RealEngine_SurvivesReinit |
Init after Quit, the engine-restart path |
Provider_DegradesCleanlyWithoutSynapse |
ProviderInit returns false with no runtime, no exception |
PackToAmplitude_TakesTheLoudestVoice |
The four-voice max |
PublishAmplitude_Clamps |
The 0..1 clamp |
Service_ArmsPublisherAndDegradesWithoutRuntime |
Publisher armed while running, WaitingForRuntime reported, at least one provider attempt |
Service_NextWorkerWaitsForAStragglingPredecessor |
The predecessor-join rule |
FeedAndSiblingContracts |
The Step 5 call site and the persistence legs |
Live rendering on Sensa hardware was not verified by the maintainer.
Last updated for PadForge 4.5.2.