Skip to content

Stop antivirus from quarantining the shim, and gate releases on a real game test - #18

Merged
epalosh merged 8 commits into
mainfrom
fix/npclient-registry-layout
Aug 15, 2026
Merged

epalosh merged 8 commits into
mainfrom
fix/npclient-registry-layout

Conversation

@epalosh

@epalosh epalosh commented Aug 15, 2026

Copy link
Copy Markdown
Owner

What this fixes

Head tracking has never reached iRacing. Not since v0.2.1, since the initial commit. Every "OpenFOV doesn't do anything in my sim" report traces to two defects in a single registry write, and both had to be fixed — correcting either one alone still doesn't load:

  1. The directory was stored as a value named NPClient Location on HKCU\Software\NaturalPoint\NATURALPOINT. Games open ...\NATURALPOINT\NPClient Location as a subkey and read a value named Path inside it. That subkey never existed, so the lookup failed and no game ever called LoadLibrary.
  2. The value carried no trailing separator. Games concatenate the DLL name straight onto it (reg_value + "NPClient64.dll"), so even a correctly-placed value resolved to ...\resources\binNPClient64.dll.

Both failures were completely silent — no error, no log line, no in-game message. read_registry_path() read back our own value from the wrong location, so the app cheerfully self-reported success.

Fixes #12. (The reporter's EAC theory was reasonable but wrong — their tasklist /m check returns N/A because EAC blocks module enumeration, which makes a broken system indistinguishable from a blocked one.)

Verified against a live iRacing session

From a wiped registry, 58 seconds after the sim launched:

INFO openfov.runtime.pipeline: Head tracking is being read by a game (program-profile id 14101)

Also confirms iRacing's real program-profile ID is 14101; we had guessed 1001.

Antivirus

Microsoft Defender classified the bundled TrackIR.exe as Trojan:Win32/Ravartar!rfn (severity Severe) and quarantined it out of resources\bin\ — twice on a clean test machine. Three things made it look like malware:

  • Its entire body was for(;;) Sleep(INFINITE); — a tiny stripped unsigned PE whose entry point sleeps forever is the classic sandbox-evasion stub. Now an ordinary Win32 program: message-only window, real message pump, named event for cooperative shutdown. Still 0% CPU idle.
  • No version resource, built with -Wl,--strip-all. Now carries honest metadata (identifies OpenFOV, not NaturalPoint) and isn't stripped.
  • trackir_shim.py spawned it CREATE_SUSPENDED, walked a toolhelp thread snapshot, and called ResumeThread — the textbook process-injection fingerprint. Replaced with STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST: same no-orphans guarantee, atomically, no suspend/resume dance.

On the machine that reproducibly quarantined the old build, MpCmdRun -Scan now reports no threats. One machine, one definition set — not a guarantee.

The helper is also no longer launched unconditionally. iRacing and most titles find us purely through the registry key; only Falcon BMS and parts of MSFS check the process list. GameProfile.requires_trackir_process gates it, default off.

Why this shipped three times, and why it can't again

CI only ever ran ruff and pytest against Python source. Every unit test passed on every broken release.

tools/verify_install.py performs a real title's discovery sequence against an actual install — open the subkey, read Path, LoadLibrary by plain concatenation (deliberately not os.path.join, which would paper over the missing separator), verify NP_GetSignature against the blobs games embed, pull pose data. Non-zero exit means a game would not work, and the message names the failing step.

Both workflows now run it, and the release workflow installs the built installer first. Each also runs a negative control that recreates the 0.2.1 layout and fails the build if the check still passes — a smoke test nobody has watched fail is just one you believe in.

Confirmed locally that it catches each half independently: wrong key shape → fails at step 1; missing separator → fails at step 2, naming the path that doesn't exist.

Also

  • tests/conftest.py — the suite was mutating real machine state on Windows. test_output_manager.py claimed it avoided Windows-only paths, but OutputManager.start() rewrote the contributor's actual HKCU NaturalPoint key and spawned a real TrackIR.exe. Verified the fix by planting a marker in the real key and confirming it survives a full run.
  • You can now tell whether it's working. The main window distinguishes "a game is running" from "a game is reading your head tracking". NP_RegisterProgramProfileID writes GameId and never GameId2, while we always write both — so an inequality is proof a game loaded our DLL and called in.
  • Camera picker listed one webcam twice (1400: / 700: are the same device via MSMF and DirectShow). Now one entry per physical device, by friendly name.
  • Wizard's calibrate page was a dead end with no face detected — Calibrate disabled → Next disabled → Cancel the only exit. Added Skip for now. Cancelling also no longer discards the camera you just picked.

Review notes

  • resources/bin/TrackIR.exe 19 KB → 67 KB is the unstripped rebuild with a version resource, not bloat. Committed so a fresh clone doesn't get the AV-flagged binary.
  • trackir_shim.py is the largest single change. Its orphan-protection test was silently skipping on every machine before; it now actually runs.
  • 191 tests passing, ruff clean. Neither workflow has run on real GitHub Actions before this PR.

epalosh and others added 8 commits August 15, 2026 14:40
Head tracking has never reached iRacing. Two defects in the same registry
write, both required for a game to locate our NPClient64.dll:

1. The directory was stored as a VALUE named "NPClient Location" on
   HKCU\Software\NaturalPoint\NATURALPOINT. Games open
   ...\NATURALPOINT\NPClient Location as a SUBKEY and read a value named
   "Path" inside it. That subkey never existed, so the lookup failed and
   LoadLibrary was never called.

2. The value carried no trailing separator. Games concatenate the DLL name
   straight onto it (reg_value + "NPClient64.dll"), so even a correctly
   placed value resolved to ...\resources\binNPClient64.dll. We now emit
   the form opentrack does: forward slashes, guaranteed trailing '/'.

Both failures were silent. read_registry_path() read back our own value
from the wrong location, so the app self-reported success while no game
could see anything.

Verified against a live iRacing session: with the corrected key the sim
loads NPClient64.dll ~35s into startup and calls NP_RegisterProgramProfileID
(observed as an asymmetric GameId write into FT_SharedMem). With key shape
fixed but the trailing slash omitted, it still does not load - both halves
are load-bearing.

Also:
- iRacing's program-profile ID corrected to 14101 (was a guessed 1001),
  read from what the sim writes into FT_SharedMem.
- purge_legacy_value() removes the stale pre-0.2.2 value on upgrade.
- verify_registration() reads the key back the way a game does and confirms
  it resolves to a real DLL, logging loudly when it does not.
- Tests now pin the layout: separator/slash invariants everywhere, plus
  Windows round-trips asserting the value lands in the subkey a game reads
  and not on the parent key. The old suite deferred this to a Windows CI
  integration test that never existed, which is how this shipped three times.

Fixes #12

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_output_manager.py's docstring claimed it "deliberately doesn't
exercise the Windows-only paths (registry, TrackIR.exe child process)".
That was not true on Windows: OutputManager.start() calls
ensure_registered() and TrackIRShim.start(), so simply running the suite
repointed the contributor's real HKCU NaturalPoint key at their checkout
and spawned a real TrackIR.exe child.

Add an autouse conftest fixture that redirects the registry constants to a
throwaway HKCU subtree and points OPENFOV_BIN_DIR at an empty temp dir, so
the shim finds no binary to launch. Verified by planting a marker value in
the real key, running the suite, and confirming the marker survives with no
stray keys or processes left behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Defender quarantined the bundled TrackIR.exe as Trojan:Win32/Ravartar!rfn
(severity Severe) twice on a clean test machine today, deleting it out of
resources/bin. Three separate things made that binary look like malware,
and all three were avoidable:

* Its entire body was `for(;;) Sleep(INFINITE);` -- a tiny stripped
  unsigned PE whose entry point sleeps forever is the classic
  sandbox-evasion stub. It is now an ordinary Win32 program: a
  message-only window, a real MsgWaitForMultipleObjectsEx pump, and a
  named event for cooperative shutdown. Still 0% CPU at idle.
* It carried no version resource and was built with -Wl,--strip-all.
  Added trackir.rc (honest metadata -- it identifies OpenFOV, not
  NaturalPoint) and stopped stripping. 19 KB -> 67 KB, worth it.
* trackir_shim.py spawned it CREATE_SUSPENDED, walked a
  CreateToolhelp32Snapshot thread snapshot, and called ResumeThread --
  i.e. the textbook process-injection fingerprint. Replaced with
  STARTUPINFOEX + PROC_THREAD_ATTRIBUTE_JOB_LIST, which attaches the Job
  Object at creation: same no-orphans guarantee, atomically, without the
  suspend/resume dance.

Measured on the machine that quarantined the old build: MpCmdRun -Scan
now reports "found no threats" on the rebuilt binary. One machine and one
definition set, so not a guarantee -- but the old one was reproducibly
flagged there and this one is not.

The helper is also no longer launched unconditionally. Most titles,
iRacing included, find us purely through the NPClient registry key and
never look at the process list; only Falcon BMS and parts of MSFS need
it. GameProfile.requires_trackir_process now gates it and defaults False,
so the AV-sensitive component is off the default path entirely.

Separately: the app had no way to tell "a game is running" from "a game
is actually reading our head tracking", which is precisely why a
completely dead output path looked healthy for three releases.
FreeTrackWriter.detected_client_game_id() closes that gap. NPClient's
NP_RegisterProgramProfileID assigns GameId and never touches GameId2,
while we always write both, so an inequality between them is proof that a
game loaded our DLL, passed the signature check, and called in. The
pipeline polls it at 1 Hz and the main window now shows three
distinguishable states instead of implying success.

For that signal to exist we publish GameId=0 when the profile's
encryption key is all zeros (every profile we ship): there is no table to
hand over, and publishing the game's real ID would collide with what the
game itself writes and mask the signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repo tracks resources/bin/*, so leaving the old stripped TrackIR.exe
committed would hand every fresh clone the exact binary Defender
quarantines, regardless of the source fix. Rebuilt both with MinGW-w64
GCC 16.2.0 (UCRT).

TrackIR.exe 19,456 -> 66,989 bytes (version resource + unstripped).
NPClient64.dll unchanged in behaviour; verified after rebuild that it is
still x64, exports all 21 NP_* entries at the correct ordinals, imports
nothing beyond KERNEL32 + the UCRT apisets, and that NP_GetSignature
still returns the two 200-byte blobs iRacing compares against
byte-for-byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rd dead end

CI only ever ran ruff and pytest against Python source. That is exactly
how a registry pointer no game could read shipped three times: every unit
test passed on every one of those releases.

tools/verify_install.py performs a real title's discovery sequence against
an actual install -- open the NaturalPoint subkey, read Path, LoadLibrary
by plain string concatenation (deliberately not os.path.join, which would
paper over a missing trailing separator), verify NP_GetSignature against
the blobs games embed, then pull pose data. Non-zero exit means a game
would not work, and the message names the failing step.

Wired into both workflows: CI builds the native binaries, registers them
through the same ensure_registered() the app calls at launch, and runs the
check. The release workflow goes further and silently installs the built
installer first, so what gets published is what got verified.

Both jobs also run a negative control that recreates the 0.2.1 layout and
fails the build if verify_install still passes -- a smoke test nobody has
watched fail is just a smoke test you believe in.

Confirmed locally that it catches both halves of the original bug
independently: wrong key shape -> fails at step 1; correct key but no
trailing separator -> fails at step 2 with the resolved path that does not
exist.

Also, two things a first-time user hits before any of this matters:

* The camera picker listed one webcam twice, as "1400: USB Video Device"
  and "700: USB Video Device". Those are cv2.CAP_MSMF + ordinal and
  cv2.CAP_DSHOW + ordinal -- the same physical device enumerated once per
  capture backend, with an implementation detail as its label. Now
  deduplicated on USB device identity, preferring Media Foundation, shown
  by friendly name, and numbered only when two genuinely distinct devices
  share a product name.

* The wizard's calibrate page was a hard dead end without a detected face:
  Calibrate disabled -> Next disabled -> Cancel the only exit. Added "Skip
  for now", which leaves the neutral pose alone rather than zeroing it.
  Cancelling also no longer discards the camera the user just confirmed a
  live preview on -- it used to drop them into the main window pointed at
  camera 0, frequently not a webcam at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the antivirus hardening, the per-game gating of the TrackIR helper,
the end-to-end connection indicator, the install verification tool and its
CI gates, and the first-run camera/wizard fixes. Written for the users who
filed "it doesn't do anything in iRacing", so it leads with what was broken
rather than what changed internally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Release CI derives the version from the git tag and already passes it to
the Nuitka and Inno Setup builds, but called npclient-vendor/build.ps1
with no -Version. TrackIR.exe's new version resource would therefore have
shipped stamped with build.ps1's local default forever. A binary whose
version never matches the release it came from is awkward to support and
is itself a mild antivirus signal, so wire the tag version through.

Version bumped in the places CI does NOT derive from the tag:
pyproject.toml, src/openfov/__init__.py, and the local-build defaults in
installer/openfov.iss and build/nuitka_build.ps1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The step worked exactly as designed on the first real CI run: it recreated
the 0.2.1 registry layout, verify_install.py correctly rejected it, and the
step printed "Negative control OK - breakage is detected." Then it exited 1
and failed the job.

GitHub's pwsh wrapper appends `exit $LASTEXITCODE` to every step, and
verify_install.py's intentional non-zero exit was still the last exit code
recorded. So the one step whose success condition IS a non-zero exit was
guaranteed to fail. Capture the code, assert on it, and exit 0 explicitly.

Everything else in the job passed on windows-latest first time: choco
MinGW, the NPClient + hardened TrackIR build with its version resource,
ensure_registered(), and the positive gate -- "PASS: a game can locate,
load, and read head tracking from OpenFOV."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@epalosh
epalosh merged commit dc50fdc into main Aug 15, 2026
3 checks passed
@epalosh
epalosh deleted the fix/npclient-registry-layout branch August 15, 2026 21:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iRacing fails to load NPClient64.dll

1 participant