Sensa Haptics: Internals¶
How a virtual controller's rumble becomes a Razer Sensa HD effect: the Razer Sensa row, the Interhaptics bindings, the provider bring-up and its retry sentinel, the worker and the host that runs it, and the level Step 2 hands it.
The user-facing page is Mice, Keyboards and Vendor Rows. This one is for whoever has to change the code. The row runs through the hub and the link pass that the haptic mice and the lighting rows use, and its level is kept the way a haptic mouse's is. Peripheral Outputs Internals covers both.
| File | Role |
|---|---|
PadForge.App/Services/SensaHapticsService.cs |
The bindings and the worker |
PadForge.App/Common/Input/Peripherals/PeripheralOutputRow.cs |
The Razer Sensa row |
PadForge.App/Common/Input/InputManager.PeripheralRows.cs |
Step 1 phase 1m: the row opens and retires |
PadForge.App/Common/Input/Peripherals/PeripheralLinker.cs |
Presence, and the row's Interhaptics path |
PadForge.App/Common/Input/Peripherals/PeripheralOutputHost.cs |
SensaLifecycle: the worker runs while the row is assigned or Identify buzzes it |
PadForge.App/Common/Input/Peripherals/PeripheralOutputs.cs |
The row's level and the backend state |
PadForge.App/Common/Input/InputManager.Step2.UpdateInputStates.cs |
ApplyForceFeedback, which sets the level |
PadForge.App/Common/Input/Peripherals/PeripheralRouteText.cs |
The Force Feedback tab's line |
PadForge.App/Services/PeripheralSwitchMigration.cs |
The retired Dashboard switch becomes an assignment |
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 |
PadForge.Tests/PeripheralOutputsTests.cs |
The row, its level, its route line and the migration |
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 the bring-up and per-tick calls here mirror it function for function. Teardown differs: Unity's quit path cleans the providers, clears the active and inactive events, then quits, while this worker calls StopAllEvents, ProviderClean, and Quit. 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.
The Razer Sensa row¶
The engine targets body parts, never one device, so one row stands for every Sensa HD device at once. PeripheralOutputRow (PeripheralOutputRow.cs line 58) with kind RazerSensa is a device with no inputs: name Razer Sensa, path razersensa://local, vendor ID 0x5046, product ID 0x5345, device type PeripheralHaptics, and an id hashed from pfrazersensa (PeripheralOutputRow.IdentityFor (PeripheralOutputRow.cs line 124)), so an assignment of it survives across sessions.
Step 1's phase 1m opens the row while InputManager.PeripheralRowWanted (InputManager.PeripheralRows.cs line 27) holds: Synapse's Chroma runtime is installed and the process is x64 (PlatformSupport.SensaAvailable). PeripheralLinker.Build (PeripheralLinker.cs line 500) gives it the one Interhaptics path, sensa, as a vendor row. The user assigns it to a virtual controller like any device, and it gets the Force Feedback tab a gamepad gets, with the controller's rumble reaching it through its own settings.
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 (SensaHapticsService.Worker (SensaHapticsService.cs line 269)) 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 row's level. 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 first attempt is a tested fact: SensaHapticsTests.Service_StartsTheEngineAndDegradesWithoutRuntime asserts it is at least one. No test times the 30-second interval.
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. SensaHapticsService.Start (SensaHapticsService.cs line 225) is a no-op while _thread is set. In a process that cannot load the engine (PlatformSupport.SensaAvailable is true only for x64), Start reports Unsupported on the caller's thread and starts no worker. SensaHapticsService.Stop (SensaHapticsService.cs line 239) sets the stop flag of that Start's own WorkerRun, joins for StopJoinMs (3000), and drops the thread and the run regardless, so a worker still inside ProviderInit can outlive its service. Each Start makes its own WorkerRun, so a Start that follows a Stop whose join gave up leaves the old worker stopped.
That outliving worker is the predecessor-join rule. Its finally stops the engine's events, cleans the provider 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);
PadForge.Engine.SdlDiagLog.WriteLine(
$"SENSA predecessor join timed out after {_predecessorJoinMs} ms, worker quitting");
return; // finally reports Stopped.
}
Volatile.Write(ref _engineStarted, 1);
The predecessor's teardown lands before the successor inits, never after. SensaHapticsService.s_lastWorker (SensaHapticsService.cs line 120) is static because the engine it protects is process-wide.
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 starting its engine, because starting over a live predecessor is the exact handoff fault the join exists to prevent: that predecessor's Har.Quit would tear the engine down underneath it. An unbounded join blocked every later worker and leaked one thread per start.
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. Service_GivesUpOnAWedgedPredecessorInsteadOfBlockingForever keeps the first worker parked past a shortened deadline and asserts the second quits without starting its engine or making a provider attempt, and reports Stopped.
The finally runs on every exit path, faults included (SENSA worker fault: {type}), and reports Stopped last.
The host runs it¶
PeripheralOutputHost.SensaLifecycle (PeripheralOutputHost.cs line 716) decides when a worker exists. It runs on every link pass, every 500 ms or sooner when devices change or Identify begins or ends:
- The row is linked (online, with its
sensapath), it is assigned (SettingsManager.SlotOrders.GetIdentityPlayerNumberanswers above 0) or Identify buzzes it (PeripheralOutputs.IsIdentifying), and no worker runs: the host creates a service, subscribes its report, starts it, and logsPERIPHERAL Sensa worker started (row assigned)orPERIPHERAL Sensa worker started (Identify). - A worker whose thread ended on its own while the row stays assigned, because
HAR.dlldid not load,AddParametricEffectfailed, or a straggling predecessor kept the slot, is stopped and started again afterSensaRestartMs(30000), the worker's own provider retry, so a missing engine does not spin. The state readsWaitingmeanwhile, and the host logsPERIPHERAL Sensa worker ended on its own, starting again later. - The row is neither assigned nor buzzed by Identify, or goes offline: the host stops the worker (
PERIPHERAL Sensa worker stopped (row unassigned)), which stops the engine's events and releases the provider, and the state goes toIdle.
Only the current worker reports. The report closure drops a state from a service the host has already replaced, and the host unsubscribes it before it disposes a worker, so a stopped worker's last word never lands after its successor's. The closure maps Active to BackendState.Connected and every other state to Waiting through PeripheralOutputs.SetBackendState (PeripheralOutputs.cs line 375).
The row's level¶
Step 2 writes the level. The row is a haptic peripheral (PeripheralOutputs.TakesHaptics (PeripheralOutputs.cs line 269)), so InputManager.ApplyForceFeedback (InputManager.Step2.UpdateInputStates.cs line 713) makes its force-feedback cache on first use, runs each slot's rumble through the row's own Force Feedback settings on that slot, takes the strongest across the slots it is on, and folds trigger rumble into the motors. On a change it hands the result to PeripheralOutputs.SetMotors (PeripheralOutputs.cs line 466), which stores the stronger motor over 65535. Identify's pulse train writes the level too, for a row no controller holds (InputService.IdentifyDevice). The engine's silence edges (stop, focus suspend and the crash quiesce) zero the level through PeripheralOutputs.SilenceHaptics (PeripheralOutputs.cs line 515), and the next Step 2 pass sends again the level a game still asks for. The crash path then waits for the worker to render the zero (SensaHapticsService.WaitForSilence), inside the 250 ms PanicQuiesceOutputs gives the peripheral host. The worker marks a tick as in flight before it reads the level and records the level it rendered after the render, so the wait ends on a rendered zero with no tick in flight, at once when no worker runs, or when its time runs out, and it returns whether the worker went quiet.
The worker reads it through SensaHapticsService.RowAmplitude (SensaHapticsService.cs line 163), which is PeripheralOutputs.AmplitudeOf for the row's id, every tickMs (default 16), clamps it to 0..1, 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.
The status line¶
The Force Feedback tab's line comes from PeripheralRouteText.Haptics (PeripheralRouteText.cs line 30):
| Condition | Line |
|---|---|
The row has its path and the state is not Waiting |
Pad_ForceFeedback_RouteSensa: "Rumble plays on your Razer Sensa HD devices through Razer Synapse." |
The row has its path and the state is Waiting |
Pad_ForceFeedback_RouteSensaWaiting: "Waiting for Sensa HD Haptics in Razer Synapse 4. Set the device’s Haptic Source to Sensa HD Games in Synapse." |
| The row's record says haptics and the row has no path, in an x64 process | Pad_ForceFeedback_RouteSensaWaiting |
| The same in an ARM64 process | Common_NotAvailableOnArm64 |
The first three rows hold while the engine runs (PeripheralOutputs.Running) and the tab's slot is live (PeripheralOutputs.SlotLive), and the line is empty otherwise. The ARM64 row shows whatever the engine or the slot does. PeripheralRouteText.Haptics(UserDevice, bool, bool) takes the platform's answer as an argument, which is how the tests reach the ARM64 row.
SensaServiceState (SensaHapticsService.cs line 10) keeps its four values, Stopped, WaitingForRuntime, Active and Unsupported, and the host folds them into the backend state as above. An ARM64 process never opens the row, so its line comes from the record alone.
Persistence and migration¶
There is no switch. The row is a device like any other: its assignments and its Force Feedback settings per virtual controller persist with the settings and ride profiles as entries.
The Dashboard's Razer Sensa section and its switch are gone. AppSettingsData.EnableSensaHaptics (SettingsService.cs line 7025) and the profile's bool? opinion, ProfileData.EnableSensaHaptics (SettingsService.cs line 8064), are read once by PeripheralSwitchMigration and never written, since ShouldSerializeEnableSensaHaptics returns false on both. PeripheralSwitchMigration.Switches (PeripheralSwitchMigration.cs line 49) lists the switch, and PeripheralSwitchMigration.Run (PeripheralSwitchMigration.cs line 193) turns one that was on into an assignment of the Razer Sensa row:
- In the live settings when the active profile's opinion, else the global value, was on.
- In the default profile's stored state, while a named profile is active, and in each stored profile, each by its own opinion, else the global value.
- In a profile file exported before #494 and imported on its own (
PeripheralSwitchMigration.MigrateImported(PeripheralSwitchMigration.csline 63)), by its own opinion.
The row goes to the first created virtual controller in display order, the slot with the smallest displayed player number (PeripheralSwitchMigration.FirstDisplayedSlot (PeripheralSwitchMigration.cs line 70)). A topology with no created slot gets nothing. The row's record is created offline when the file has none (PeripheralSwitchMigration.NewRowRecord (PeripheralSwitchMigration.cs line 170)), so the assignment exists before the row first opens. Every opinion is cleared once read, so the migration runs once per file. Where the engine cannot load, the switch is not Available: nothing is assigned and the switch only clears.
Diag lines¶
| Line | When |
|---|---|
PERIPHERAL Sensa worker started (row assigned) |
The host started a worker for an assigned row |
PERIPHERAL Sensa worker started (Identify) |
Identify buzzes the row while no controller holds it |
PERIPHERAL Sensa worker stopped (row unassigned) |
The row left its last virtual controller, or its Identify train ended |
PERIPHERAL Sensa worker ended on its own, starting again later |
The worker ended while the row stayed assigned or buzzed by Identify |
SENSA predecessor join timed out after {n} ms, worker quitting |
The predecessor join hit its deadline |
SENSA worker: calling HAR.Init |
Worker entry, after the predecessor join |
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 |
CFG #494 migrated a global switch: RazerSensa row assigned to slot {n} |
The migration assigned the row in the live settings |
CFG #494 migrated a profile switch: RazerSensa row assigned to slot {n} in '{profile}' |
The migration assigned the row in a stored or imported profile |
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 |
The provider calls do not throw, with or without Synapse. When ProviderInit succeeds, ProviderClean returns true |
Service_StartsTheEngineAndDegradesWithoutRuntime |
The engine starts, a state is reported before Stopped (WaitingForRuntime on a bench without Synapse), and at least one provider attempt is made, with the level from an injected source |
WaitForSilence_ReturnsOnceTheZeroIsRendered |
The crash wait ends once the worker has rendered the zero, and answers false while a level still renders |
WaitForSilence_AnswersAtOnceAfterAStopWhoseJoinGaveUp |
With a tick held inside its level read and Stop's join given up, the crash wait answers at once |
WaitForSilence_HoldsForATickThatReadItsLevelBeforeTheQuiesce |
The crash wait holds for a tick that read its level before the quiesce, until that tick has ended and a later one has rendered the zero |
Service_ReportsUnsupportedAndStartsNoWorkerWhereTheEngineCannotLoad |
The ARM64 branch: Unsupported raised once on the caller's thread, no worker |
Service_NextWorkerWaitsForAStragglingPredecessor |
The predecessor-join rule |
Service_GivesUpOnAWedgedPredecessorInsteadOfBlockingForever |
The bounded join: the successor quits without starting its engine |
Service_AStartAfterATimedOutStop_LeavesTheOldWorkerStopped |
A Start after a Stop whose join gave up leaves the old worker stopped, and the successor runs its own bring-up once that worker has ended |
TheWorkerStreamsTheRowsLevel_AndTheGlobalSwitchIsGone |
Source pins: the worker reads the row's level, the Step 5 lane and the Dashboard card are gone, the switch's two legs are read-only, and the host tests the row's assignment for the worker |
TheKoreanStringsSpellHaptic |
The Korean Sensa strings spell haptic the way every other Korean string does |
PeripheralOutputsTests.cs covers the row's side:
| Test | What it proves |
|---|---|
PeripheralLinkingTests.TheSensaRowTakesInterhapticsOnlyWhereTheEngineRuns |
The row's sensa path, and none without the engine |
PeripheralLinkingTests.TheSensaRowOpensOnlyWithSynapseAndTheEngine |
Phase 1m's condition |
PeripheralLinkingTests.TheVendorRowsKeepTheirIdentities_AndTheirTypeFollowsTheirOutput |
The row's id, product GUID and device type |
PeripheralOutputsFacadeTests.TheSensaWorkerStreamsItsRowsLevel |
RowAmplitude reads what SetMotors stored |
PeripheralOutputHostTests.IdentifyRunsTheSensaWorker_ForARowNoControllerHolds, IdentifyStartsTheSensaWorkerAtOnce_NotAtTheNextTimedPass |
Identify starts the worker for a row no controller holds, at once and not at the next timed pass, and a linked row with no Identify starts none |
PeripheralOutputsFacadeTests.TheForceFeedbackLineNamesTheDeviceItsRowReaches |
The Sensa lines, the ARM64 one included |
PeripheralSwitchMigrationTests |
The switch's migration: the ruling slot, each profile's own value, the live settings, the default topology, a row this PC cannot open, imported profiles |
PeripheralWiringTests.Step2HandsAHapticRowItsLevel_TheSoleWriterWay, EverySilenceEdgeSilencesThePeripheralsToo, ThePanicPathWaitsForTheSensaZero |
Step 2's branch, the silence edges, and the crash path's wait for the worker after GameSense's, by source |
Live rendering on Sensa hardware was not verified by the maintainer.
Last updated for PadForge 5.2.0.