Interface control document
1. Identification
| Item | Value |
|---|---|
| Document | ICD-CMCTRL-001 rev 2 |
| Product | Game controller input module |
| Product versions covered | 0ea243c |
| Assembly | HEAT.GameControllerInput, net10.0 |
| Settings section | HEAT.GameControllerInput |
| Peers | Windows DirectInput 8 and XInput, via Vortice.DirectInput 3.8.3 and Vortice.XInput 3.8.3 |
| Host | HEAT Capture, HEAT.Capture.ModuleSDK 2.0.5 |
| Data model | SimulationFrame carrying one EntityState per device plus EntityEvent entries |
| Date | 2026-09-02 |
| Owner | VRAI Simulation |
2. Scope
This document defines the two Windows device APIs the module reads, the hidden top-level window it creates inside the host process to satisfy DirectInput, the enumeration and identity rules for each API, the polling behaviour, and the entities and events published to HEAT Capture.
The module is read-only with respect to the controllers. It never writes to a device: no rumble, no force feedback, no LED or configuration output.
The two APIs are not alternatives layered behind one abstraction. They are separate interfaces with different identity schemes, different button naming, different axis field names and different failure behaviour, and a single physical Xbox-class pad is normally visible on both. Sections 4 and 5 are therefore specified independently, and section 7.3 documents where their published output diverges.
3. Interface inventory
| ID | Name | Direction | Transport | Peer |
|---|---|---|---|---|
| IF-CMCTRL-01 | DirectInput device state | Device to module | DirectInput 8, non-exclusive background acquisition against a window handle | Any attached HID game controller |
| IF-CMCTRL-02 | XInput device state | Device to module | XInput GetCapabilities and GetState on player slots 0 to 3 | XInput-class pad |
| IF-CMCTRL-03 | Hidden Win32 input window | Module to operating system | RegisterClassW and CreateWindowExW inside the host process | Windows window manager |
| IF-CMCTRL-04 | Frame publication | Module to host | Module SDK publication path | HEAT Capture |
4. IF-CMCTRL-01 DirectInput device state
4.1 Enumeration and identity
Enumeration happens once, in InitialiseModule. The module creates a DirectInput 8 instance and,
for each DeviceType in the DeviceTypes setting, enumerates with
DeviceEnumerationFlags.AttachedOnly.
| Item | Value |
|---|---|
| Device categories | The DeviceTypes setting; default Gamepad, Joystick, Driving, Flight |
| Selection filter | If DeviceId parses as a GUID, only the device whose InstanceGuid equals it is kept. A value that does not parse, including empty, applies no filter and every enumerated device is kept. |
| Device identity | The DirectInput InstanceGuid |
| Device name | The DirectInput InstanceName, as reported by the driver |
| Button identity | GetObjects(DeviceObjectTypeFlags.Button) enumerated in order; the ordinal position of each object is used as the key |
| Log on discovery | DirectInput controller: {Name} ({Guid}) at information level |
Button identity depends on the enumeration ordinal matching the index of the same button in
JoystickState.Buttons. The module does not read each object’s offset or usage to confirm that
correspondence, so a device whose button objects enumerate in a different order than they appear in
the state array reports correct button names against the wrong physical buttons.
Because enumeration is bound to InitialiseModule, which the host calls once per module lifetime,
a controller connected after that call is never captured, however many capture sessions start
afterwards. There is no re-enumeration on capture start and no hot-plug detection.
4.2 Acquisition lifecycle
Acquisition happens on every capture start, in StartCapture, and only when at least one
DirectInput device was enumerated. The module creates the hidden window described in section 6 and
then, for each device in turn:
SetCooperativeLevel(hwnd, CooperativeLevel.NonExclusive | CooperativeLevel.Background)SetDataFormatwith theRawJoystickStatetype argumentAcquire()
Non-exclusive means the device stays available to every other application, including a simulator running alongside HEAT Capture. The module never takes exclusive ownership of a controller.
A per-device failure at any of the three steps logs Failed to acquire {Device} at error level and
that device is skipped; the remaining devices are still attempted. A successful acquisition seeds
the device’s retained state to null and, when DebugLogs is set, logs
Acquired DirectInput: {Device}.
If the hidden window cannot be created the module logs
Failed to create the input window; DirectInput devices unavailable at error level and no
DirectInput device is acquired. If no DirectInput device was acquired and there are also no XInput
devices, StartCapture logs No controller could be acquired and returns false, which fails the
capture start for this module.
Capture end cancels the poll loop, awaits its task, calls Unacquire() on every device with any
exception swallowed, then disposes the hidden window in that order, so the window outlives the
devices that cooperate with it. The retained device states and the previous-button-state map are
cleared. The enumeration itself, the device list and the device and button name maps are not
cleared, so a subsequent capture start reuses the devices found at initialisation.
InitialiseModule and StartCapture are both bounded by the SDK StartupTimeout. This module does
not override it, so the default of 5 seconds applies to enumeration, window creation and
acquisition together. See the Module SDK contract.
4.3 Authentication
None. Any attached HID game controller matching the configured device types is enumerated and
acquired. There is no pairing, no key and no allow-list beyond the optional single-GUID DeviceId
filter.
4.4 Polling and state format
A background task started at capture start runs a loop that polls every device, checks the stuck
detector described in 4.5, and then waits PollIntervalMs. The setting is re-read on every
iteration, so a change takes effect on the next wait without restarting the capture.
| Item | Value |
|---|---|
| Poll interval | PollIntervalMs, default 4 ms, approximately 250 polls per second |
| Read | device.Poll() followed by GetCurrentState using JoystickState, RawJoystickState and JoystickUpdate as type arguments |
| Retention | The result replaces the device’s retained snapshot; nothing is queued |
The poll loop only refreshes the retained snapshot. No event is derived at poll rate. Everything published is derived from the snapshot present at the moment of a capture cycle, so intermediate polls are discarded by design. Section 7.5 states the consequence.
The state fields read from JoystickState are the button array, X, Y, Z, RotationX,
RotationY, RotationZ, every element of Sliders, and every element of
PointOfViewControllers. No other field of the state is used.
The module sets no axis range property on the device. Axis normalisation therefore hard-codes a 0 to 65535 raw range with 32767 as centre. A device that reports a different raw range is normalised against the wrong centre and scale, with no warning.
4.5 Foreground gating of XInput-class pads
DirectInput does not deliver changing data for an XInput-class pad to a process that does not own
the foreground window, regardless of CooperativeLevel.Background. The module’s own repository
records the measurement: on an Xbox One pad with sticks moving throughout, five-second phases at
4 ms polls produced 322 samples with 1 distinct value while the window was invisible, 322 samples
with 1 distinct value while it was visible but not activated, and 322 samples with 322 distinct
values only while it was visible and in the foreground. The same probe against a T.Flight Hotas X,
an ordinary HID device, produced changing data in every phase including invisible, which is why the
DirectInput path is kept rather than removed.
A background capture host never owns the foreground window, so this interface cannot deliver live
data for an XInput-class pad. The module detects the symptom rather than leaving it silent. Once at
least 500 DirectInput samples have been taken and no more than one distinct
X, Y, RotationX, RotationY sample value has been seen, it emits one warning naming the
sample count and every enumerated device name, and advising that the XInput entry be selected for
that device instead. The warning is emitted at most once per capture session and the detector then
disarms.
5. IF-CMCTRL-02 XInput device state
This interface is present only when UseXInput is set, default true.
5.1 Enumeration and identity
Enumeration happens once, in InitialiseModule, alongside the DirectInput enumeration.
| Item | Value |
|---|---|
| Slots probed | User indices 0, 1, 2, 3 |
| Presence test | XInput.GetCapabilities(userIndex, DeviceQueryType.Any, out caps); a false return skips the slot |
| Device identity | A synthetic GUID per slot: first component userIndex + 1, second and third components zero, trailing bytes 0x58 0x49 0x4E 0x50, which spell XINP. Deterministic and disjoint from real DirectInput instance GUIDs. |
| Device name | XInput Gamepad (Player N) when caps.SubType is Gamepad, otherwise XInput Controller (Player N), with N = userIndex + 1 |
| Selection filter | If DeviceId parses as a GUID, the slot is kept only when its synthetic GUID equals it |
| Log on discovery | XInput controller: {Name} (User {Index}) at information level, with Index the zero-based user index |
XInput exposes no device GUID, serial number or driver-reported name, so identity is positional. A pad moved to a different player slot between sessions is a different entity with a different id and name. Nothing in the interface can tie the two together.
Because a single physical Xbox-class pad is visible to both APIs, the settings UI lists it twice and
DeviceId matches exactly one GUID. Selecting the DirectInput entry for such a pad filters the
XInput slot out, which is the combination section 4.5 warns about.
If neither API produced a device, InitialiseModule logs No game controllers found at warning
level and still returns true. The module then starts, publishes nothing, and section 7.1 applies.
5.2 Lifecycle
There is no connection or acquisition step. XInput needs no window handle and no cooperative level, which is why it works from a background host.
Capture start seeds each enumerated slot’s retained gamepad state to the default all-zero value and,
when DebugLogs is set, logs Ready XInput: {Name} (User {Index}). Capture end clears the retained
states and the previous-button map. The slot enumeration itself is retained across capture sessions.
The seeded all-zero state is indistinguishable from a real reading of a centred pad with no button pressed. Section 7.1 states what that means for a consumer.
5.3 Authentication
None. Any pad the XInput driver presents on slots 0 to 3 is read.
5.4 Polling and state format
The same background poll loop reads every enumerated slot with XInput.GetState(userIndex, out state) at PollIntervalMs cadence and stores state.Gamepad on success.
The fields read from Gamepad are Buttons, LeftThumbX, LeftThumbY, RightThumbX,
RightThumbY, LeftTrigger and RightTrigger. Gamepad reports no per-sample packet number or
timestamp to the module, so there is no way to tell a repeated identical reading from a stale one.
A false return from GetState, which is how XInput reports a disconnected slot, leaves the
previously retained state in place and is logged as XInput GetState failed for {Name} only when
DebugLogs is set. The module keeps publishing that retained state and its axis event for the rest
of the session. See section 8.
6. IF-CMCTRL-03 Hidden Win32 input window
DirectInput requires a top-level window handle for SetCooperativeLevel, even for non-exclusive
background acquisition. When the module is hosted out of process the host is launched with
CREATE_NO_WINDOW, so it has neither a main window nor a console window and there is no handle to
borrow. The module therefore creates one of its own, purely to satisfy that call.
| Item | Value |
|---|---|
| Window class | HEAT.GameControllerInput.HiddenWindow, registered once per process |
| Window style | WS_POPUP, zero size at position 0,0, no parent, never shown |
| Window title | HEAT Game Controller Input |
| Thread | A dedicated background thread named GameControllerInput.HiddenWindow running its own GetMessageW pump |
| Creation | Synchronous from the caller’s perspective: creation is awaited, and a RegisterClassW or CreateWindowExW failure surfaces as an exception carrying the Win32 error code |
| Teardown | WM_CLOSE is posted, DefWindowProc destroys the window, WM_DESTROY posts WM_QUIT, the pump exits, and the pump thread is joined with a 2 second bound |
Implications for the host process that a deployment needs to know:
- The host process acquires a registered window class and one top-level window it did not previously have. The class name string is intentionally leaked because the class lives for the process lifetime, and the window procedure delegate is held in a static field because Win32 retains the raw function pointer.
- A message pump thread runs for the duration of every capture session that has at least one DirectInput device. The thread is a background thread, so it does not keep the process alive.
- The window is created only when at least one DirectInput device was enumerated. A session with XInput devices only creates no window and starts no pump thread.
- The window is never made visible or activated, which is deliberate but is also exactly the condition under which section 4.5 applies.
7. IF-CMCTRL-04 Frame publication and data model reference
7.1 Frame and entities
HandleCapture returns null, publishing nothing for that cycle, only when both retained state
collections are empty. That is the case when no device was enumerated at all, and for a
DirectInput-only session it remains the case until the first successful poll, because a DirectInput
device’s retained state starts as null and a null state is skipped.
An XInput session is different: capture start seeds every slot with an all-zero gamepad state, so a
frame containing that entity, its axis event and no button event is published from the first capture
cycle onwards even if GetState has never once succeeded. A consumer cannot distinguish that seeded
frame from a real reading of a centred, untouched pad.
Each published frame carries:
| Item | Value |
|---|---|
| Simulation tick | A counter starting at 0, reset to 0 on every capture start, incremented once per capture cycle including cycles that publish nothing new |
| Event timestamp | DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), taken once per cycle and applied identically to every event in the frame |
| Entities | One EntityState per DirectInput device with a non-null retained state, plus one per enumerated XInput slot |
An entity carries only an identity, with no components:
| Field | Value |
|---|---|
Entity.Name | The DirectInput InstanceName, or the synthetic XInput Gamepad (Player N) / XInput Controller (Player N) |
Entity.Id | The 32-bit GetHashCode() of the device GUID, cast to an unsigned 64-bit value. A negative hash sign-extends, so ids for some devices appear as values above 2^63. Ids are stable for the same GUID across sessions and across processes. |
The entity is published every cycle whether or not the device produced anything, so an untouched controller still occupies a slot in every frame.
The SDK wraps the frame as a DataPacket whose TimestampUnixMs is taken at publication. That
value and the per-event timestamp in the frame are separate readings of the host clock taken at
slightly different moments in the same cycle.
7.2 Button events
Each button transition is one EntityEvent carrying a DataEvent with the entity id, the frame
timestamp and a ButtonPress payload of a button name and a state.
Edge computation compares the button bits in the state retained at this capture cycle against those retained at the previous capture cycle:
| Previous | Current | Internal state | Published state |
|---|---|---|---|
| not pressed | pressed | Down | Pressed |
| pressed | pressed | Hold | Hold |
| pressed | not pressed | Released | Released |
| not pressed | not pressed | Up | not published |
A button held across cycles therefore produces one Hold event per cycle for as long as it is held,
not one event per press. A consumer that wants rising edges only must filter on Pressed.
Button naming differs by API.
DirectInput: the button count is the number of enumerated button objects when that is non-zero,
otherwise the length of the state’s button array. The name is the driver-reported object name, with
Button {i} as the fallback when no name was enumerated for that ordinal.
XInput: a fixed 14-entry table, evaluated in this order.
| Bit | Published name |
|---|---|
A | A |
B | B |
X | X |
Y | Y |
LeftShoulder | LB |
RightShoulder | RB |
Back | Back |
Start | Start |
LeftThumb | LS |
RightThumb | RS |
DPadUp | DPad Up |
DPadDown | DPad Down |
DPadLeft | DPad Left |
DPadRight | DPad Right |
The XInput Guide button is not in the table and is never published. The analogue triggers are not buttons on this path; they appear only in the axis event.
7.3 Axis events
An axis event is one EntityEvent carrying a DataEvent with a JsonEvent whose event name is
ControllerAxes and whose data is a JSON object serialised as a string. The event is emitted with
the device’s current axis values whether or not any axis moved.
The two APIs publish under the same event name with disjoint field sets. A consumer must key its parsing off which entity the event belongs to; the two schemas share no field name.
DirectInput fields:
| Field | Source | Value |
|---|---|---|
x, y, z | X, Y, Z | (raw - 32767) / 32767, rounded to 4 decimal places |
rx, ry, rz | RotationX, RotationY, RotationZ | same normalisation |
slider0, slider1, … | each element of Sliders | same normalisation |
pov0, pov1, … | each element of PointOfViewControllers | -1 when the raw value is negative, otherwise the raw value divided by 100 |
DirectInput reports a point-of-view control in hundredths of a degree clockwise from forward and a
negative value when it is centred, so pov0 is degrees in the range 0 to 359, and -1 means
centred. It is not normalised to the -1 to 1 range the other fields use.
The axis fields are always all present for every axis the state structure carries, whether or not
the physical device has that axis. An axis the device does not report reads as its unset raw value
and is normalised like any other, so a device without a Z axis still publishes a z field.
XInput fields, always exactly these six:
| Field | Source | Value |
|---|---|---|
left_x, left_y | LeftThumbX, LeftThumbY | raw / 32767, rounded to 4 decimal places |
right_x, right_y | RightThumbX, RightThumbY | same normalisation |
left_trigger, right_trigger | LeftTrigger, RightTrigger | raw / 255, rounded to 4 decimal places |
XInput thumb values are signed 16-bit, so the negative extreme is a raw -32768 against a divisor of
32767. The exact quotient is -1.00003, which rounds to -1.0000 at the four decimal places used, so
the published range is -1 to 1 on both signs. No dead zone is applied on either path: the raw
resting jitter of a stick is published as-is, which is why an untouched pad can still produce a
changing ControllerAxes payload every cycle.
7.4 Axis suppression, per API
SendAxisData, default true, is documented as one switch for analogue data across all devices. It
is applied on one path only.
| Path | SendAxisData = true | SendAxisData = false |
|---|---|---|
| DirectInput | ControllerAxes published every cycle per device | No ControllerAxes event published |
| XInput | ControllerAxes published every cycle per device | ControllerAxes still published every cycle per device |
This is audit finding CTRL-01 and it is a property of this revision. The XInput publication is not
guarded by the setting, so clearing SendAxisData does not reduce XInput axis event volume at all.
For an XInput-only deployment the setting has no observable effect. The setting is read fresh from
the settings monitor on each cycle on the DirectInput path, so a change there takes effect on the
next cycle without restarting the capture.
TrackEdgeDetection, default true, is declared in the settings and is intended to gate button-state
computation. This revision never reads it: edge computation and button event publication are
unconditional on both paths. Clearing it changes nothing.
7.5 Timing and cadence
Two independent rates are involved and they do different things.
| Rate | Setting | Default | Effect |
|---|---|---|---|
| Device poll | PollIntervalMs | 4 ms | Refreshes the retained snapshot per device. Derives no events. |
| Publication and edge detection | SDK Interval | 1000 ms | Builds and publishes the frame, and computes every button edge and axis value. |
This module does not override the SDK Interval default, so the effective resolution of a button
event is 1000 ms unless the operator lowers it. Edge detection compares the snapshot at this capture
cycle against the snapshot at the previous capture cycle, not against the previous poll. A press and
release that both occur inside one Interval window are not delayed, they are never observed: the
button read as not pressed at both cycle boundaries, so no Pressed and no Released event exists.
The 4 ms poll rate does not change this; it only decides how fresh the value sampled at the cycle
boundary is.
A deployment that must catch brief taps has to lower Interval towards PollIntervalMs, accepting
the corresponding rise in frame and event volume. Raising PollIntervalMs has no effect on tap
capture.
7.6 Connection events
The module publishes no connection, disconnection or device-status event of any kind. Nothing enqueues one, and there is no event name reserved for one. Device presence is observable to a consumer only indirectly, by which entities appear in a frame, and that signal is weak: the entity set is fixed at initialisation and a device that stops responding continues to appear.
Device arrival and departure are visible only in the host log, through the discovery lines in 4.1 and 5.1 at initialisation and the poll error warnings in section 8 during capture.
8. Error and disconnect behaviour
| Condition | Detection | Behaviour |
|---|---|---|
| No device found by either API | Both enumerations empty at initialisation | Logs No game controllers found at warning level. InitialiseModule still returns true, so the module starts and publishes nothing. |
| Hidden window cannot be created | Exception from RegisterClassW or CreateWindowExW | Logs Failed to create the input window; DirectInput devices unavailable at error level. No DirectInput device is acquired. XInput is unaffected. |
| One DirectInput device fails to acquire | Exception from SetCooperativeLevel, SetDataFormat or Acquire | Logs Failed to acquire {Device} at error level. That device is skipped; the others are still attempted. |
| Nothing acquirable at all | No DirectInput device acquired and no XInput device enumerated | Logs No controller could be acquired and StartCapture returns false, failing the module’s capture start. |
| DirectInput poll failure, including unplug | Exception from Poll or GetCurrentState | An immediate Acquire() retry is attempted and any failure from it is swallowed, so the next loop iteration retries. Logs DirectInput poll error for {Device} at warning level on the first failure, and on every failure when DebugLogs is set. The retained snapshot is not cleared, so the last successful state keeps being published. |
| DirectInput polls cleanly but never changes | 500 or more samples with at most one distinct axis sample value | Logs one warning per session naming the sample count and every device name, advising selection of the XInput entry. See 4.5. |
| XInput slot disconnected | GetState returns false | Logged as XInput GetState failed for {Name} at warning level only when DebugLogs is set. The retained state is not cleared, so the last successful state and its ControllerAxes event keep being published for the rest of the session. |
| XInput poll exception | Exception from GetState | Logs XInput poll error for {Name} at warning level on the first failure, and on every failure when DebugLogs is set. |
Two properties of this table matter more than the individual rows.
Neither path clears its retained state on failure. A controller unplugged mid-session leaves its
last value in place and the module keeps republishing it, with no event and, on the XInput path with
default settings, no log line either. A consumer that treats a ControllerAxes payload as evidence
of a live device will be wrong for the remainder of the session.
The poll error counter that gates first-failure logging is shared between the two paths and is reset to zero on every successful DirectInput poll. With a healthy DirectInput device present, an XInput failure is therefore logged as a first failure repeatedly rather than once.
9. Units and conventions
| Concept | Convention |
|---|---|
| DirectInput axes and sliders | Dimensionless, nominally -1 to 1, centre 0, derived from an assumed 0 to 65535 raw range with centre 32767. Rounded to 4 decimal places. |
| DirectInput point of view | Degrees clockwise from forward, 0 to 359, or -1 for centred. Not normalised. |
| XInput thumbsticks | Dimensionless, -1 to 1, centre 0, from a signed 16-bit raw value divided by 32767. Rounded to 4 decimal places. |
| XInput triggers | Dimensionless, 0 to 1, from an unsigned 8-bit raw value divided by 255. Rounded to 4 decimal places. |
| Dead zone | None applied on either path. Raw resting jitter is published. |
| Button state | Three published values: Pressed, Hold, Released. Up is computed internally and never published. |
| Sign and axis direction | Whatever the device driver reports. The module applies no axis inversion and no per-device calibration. |
| Time base | Host wall clock, UTC Unix milliseconds, taken once per capture cycle. Neither API supplies a device timestamp, so none is carried. |
| Simulation tick | Per-session cycle counter from 0, not a time value |
10. Configuration affecting the interfaces
| Setting | Default | Interface effect |
|---|---|---|
DeviceId | not set | Restricts capture to the single device or XInput slot whose GUID matches. Read at initialisation only. |
DeviceTypes | Gamepad, Joystick, Driving, Flight | Which DirectInput categories are enumerated. No effect on XInput. Read at initialisation only. |
UseXInput | true | Whether IF-CMCTRL-02 exists at all. Read at initialisation only. |
PollIntervalMs | 4 | Device poll cadence. Re-read every loop iteration. Does not affect event resolution. |
SendAxisData | true | Suppresses the DirectInput ControllerAxes event when cleared. Does not suppress the XInput one; see 7.4. Re-read every cycle. |
TrackEdgeDetection | true | Declared but never read in this revision. No effect. |
DebugLogs | false | Promotes acquisition and readiness lines to information level and turns first-failure-only poll warnings into every-failure warnings. Also the only way XInput disconnects are logged. Re-read at each use. |
Generic SDK settings that change observable behaviour here:
| Setting | Interface effect |
|---|---|
Enabled | Whether the module runs at all |
Interval | Publication cadence and button edge resolution; see 7.5 |
OverrideNodeId, NodeId | The node id attached to the published packet |
Timeout is inherited and unused by this module. See
Manage capture modules for the generic settings
and the Module SDK contract for the lifecycle they belong to.
11. Compatibility and versioning
There is no version negotiation on either interface.
| Item | Constraint |
|---|---|
| Platform | Windows only. Both interfaces are Win32 APIs and IF-CMCTRL-03 calls user32.dll directly. |
| DirectInput | DirectInput 8 via Vortice.DirectInput 3.8.3 |
| XInput | Vortice.XInput 3.8.3, four player slots, fixed |
| Module SDK | HEAT.Capture.ModuleSDK 2.0.5 |
| Runtime | net10.0 |
The published contract that consumers depend on is the entity name, the entity id derivation, the
button name set and the ControllerAxes field names. DirectInput button names and the DirectInput
axis field set are driver-dependent and change with a device or driver change; the XInput button
table and the six XInput axis fields are fixed in the module and change only with a module release.
12. Security considerations
Both device interfaces are workstation-local and unauthenticated. Any local process can enumerate and read the same controllers, and the module’s own non-exclusive acquisition deliberately preserves that: acquiring a device here does not deny it to anything else.
The module reads only game controller devices of the configured categories. It does not acquire
keyboards or mice, and DirectInput keyboard and mouse types are not in the default DeviceTypes set.
The captured data is button and axis actuation, which is behavioural rather than identifying, but it
is a continuous record of an operator’s physical input and should be handled under the deployment’s
own data-handling policy.
IF-CMCTRL-03 adds a registered window class and a top-level window to the host process. The window
procedure handles only WM_DESTROY and forwards everything else to DefWindowProc, so it processes
no message content and offers no scriptable surface. The window class is registered process-wide and
its name is fixed, so another process can locate the window by class or title; it can then post
messages to it, and a WM_CLOSE from outside destroys the window and ends the pump thread, which
would leave the acquired DirectInput devices without the handle they were acquired against. Nothing
in the module detects or recovers from that.
13. Conformance
- Connect one DirectInput-only device, such as a flight stick, and one Xbox-class pad. Start a
capture session. Confirm the log lists each DirectInput device as
DirectInput controller: {Name} ({Guid})and each XInput slot asXInput controller: {Name} (User {Index}). - Confirm one entity per device appears in every frame, with the driver-reported name for the
DirectInput device and
XInput Gamepad (Player 1)orXInput Controller (Player 1)for the pad. - Press and hold a button on the DirectInput device for several capture cycles. Confirm one
Pressedevent, then oneHoldevent per cycle, then oneReleasedevent, with the driver-reported button name. - Press
Aon the XInput pad and confirm a button event namedA. - Move each analogue control in turn and confirm the
ControllerAxespayload for the DirectInput entity carriesx,y,z,rx,ry,rzplus asliderorpovfield per control the device has, and that the payload for the XInput entity carries exactlyleft_x,left_y,right_x,right_y,left_trigger,right_trigger. - Centre a hat switch and confirm
pov0reads-1, then hold it forward and confirm it reads a degree value rather than a fraction. - Clear
SendAxisData. Confirm the DirectInput entity stops publishingControllerAxesand the XInput entity does not. This is the expected outcome for this revision under CTRL-01, not a failure of the check. - Clear
TrackEdgeDetectionand confirm button events continue on both paths, which is the expected outcome for this revision. - Verify tap resolution against
Interval. WithIntervalat its 1000 ms default, tap a button briefly and confirm no button event is produced for that tap. LowerIntervalto 50 ms, repeat, and confirm the tap is captured. - Select the DirectInput entry in
DeviceIdfor the Xbox-class pad and start a capture. Within roughly two seconds of polling, confirm the one-shot warning about unchanging DirectInput samples appears and advises selecting the XInput entry. Confirm axis values do not change while the sticks are moved. - Unplug the DirectInput device mid-session. Confirm a
DirectInput poll errorwarning appears once, then confirm the entity is still present in subsequent frames with its last values and that no disconnection event is published. - Unplug the XInput pad mid-session with
DebugLogsclear. Confirm no log line appears and that the entity keeps publishing its last state andControllerAxesevent. SetDebugLogsand repeat to confirmXInput GetState failedis then logged. - Connect an additional controller while a capture session is running. Confirm it is not captured, and that it is still not captured after stopping and restarting the capture within the same host process.
- Confirm the entity id for a given device is identical across two separate capture sessions and two separate host process runs.
14. Revision history
| Rev | Date | Product version | Change |
|---|---|---|---|
| 1 | 2026-09-02 | 0ea243c | Initial issue. |
| 2 | 2026-09-02 | 0ea243c | Full issue against source. Added the DirectInput and XInput interfaces as separate specifications, the hidden input window interface, entity and event schemas with real names, the two-rate timing model, and the error and disconnect table. |