Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,73 @@ jobs:

- name: Test
run: pytest -v

# ---------------------------------------------------------------------
# Does a game actually work against what we build?
#
# The unit suite only ever exercised Python source, which is how a
# registry pointer no game could read shipped in three consecutive
# releases. This job builds the native binaries, registers them exactly
# as the app does at runtime, and then performs a real game's discovery
# sequence against the result: open the NaturalPoint subkey, read Path,
# LoadLibrary by plain concatenation, verify NP_GetSignature, pull pose
# data. If any step breaks, the build fails.
# ---------------------------------------------------------------------
integration:
name: Game-discovery smoke test
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
pip install -e .

- name: Install MinGW-w64
run: choco install -y mingw

- name: Build NPClient stub + TrackIR shim
shell: pwsh
run: |
$env:Path = "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin;" + $env:Path
pwsh ./npclient-vendor/build.ps1

- name: Register exactly as the app does at runtime
shell: pwsh
env:
OPENFOV_BIN_DIR: ${{ github.workspace }}\resources\bin
run: |
python -c "from openfov.output.npclient_bootstrap import ensure_registered; ensure_registered()"

- name: A game must be able to find and use us
shell: pwsh
run: |
python tools/verify_install.py --samples 2
if ($LASTEXITCODE -ne 0) { throw "A game could not use this build" }

- name: Negative control — the smoke test must actually detect breakage
shell: pwsh
run: |
# Recreate the 0.2.1 layout. If verify_install still passes here,
# the check is vacuous and we want to know immediately.
Remove-Item -Path 'HKCU:\Software\NaturalPoint' -Recurse -Force -ErrorAction SilentlyContinue
New-Item -Path 'HKCU:\Software\NaturalPoint\NATURALPOINT' -Force | Out-Null
Set-ItemProperty -Path 'HKCU:\Software\NaturalPoint\NATURALPOINT' `
-Name 'NPClient Location' -Value "${{ github.workspace }}\resources\bin"

python tools/verify_install.py --samples 1
$rc = $LASTEXITCODE
if ($rc -eq 0) { throw "verify_install passed on a known-broken layout" }
Write-Host "Negative control OK - breakage detected (exit $rc)."

# A non-zero exit here is the SUCCESS condition, but GitHub's pwsh
# wrapper appends `exit $LASTEXITCODE`, so without resetting it the
# step fails on its own passing result. Be explicit.
exit 0
36 changes: 34 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ jobs:
run: |
choco install -y mingw

- name: Build NPClient stub + TrackIR dummy
- name: Build NPClient stub + TrackIR shim
shell: pwsh
run: |
$env:Path = "C:\ProgramData\chocolatey\lib\mingw\tools\install\mingw64\bin;" + $env:Path
pwsh ./npclient-vendor/build.ps1
# -Version feeds TrackIR.exe's version resource. Without it the
# shim would ship stamped with build.ps1's local default, and a
# binary whose version never matches the release it came from is
# both confusing to support and a mild antivirus signal.
pwsh ./npclient-vendor/build.ps1 -Version "${{ steps.version.outputs.version }}.0"

- name: Verify NPClient exports
shell: pwsh
Expand Down Expand Up @@ -111,6 +115,34 @@ jobs:
Write-Host "Installer: $($installer.FullName)"
Write-Host "Size: $($installer.Length) bytes"

# Install what we just built and confirm a game could actually use
# it. v0.1.0 through v0.2.1 all shipped with head tracking that
# reached no game at all, because nothing here ever exercised the
# installed artifact — only Python source. This is that gate.
- name: Verify the built installer works for a game
shell: pwsh
run: |
$installer = Get-ChildItem installer/Output/OpenFOV-*-setup.exe | Select-Object -First 1
Write-Host "Installing $($installer.Name) silently..."
$p = Start-Process -FilePath $installer.FullName -Wait -PassThru `
-ArgumentList '/VERYSILENT','/ALLUSERS','/NORESTART','/SUPPRESSMSGBOXES'
if ($p.ExitCode -ne 0) { throw "Installer exited $($p.ExitCode)" }

$binDir = "C:\Program Files\OpenFOV\resources\bin"
foreach ($f in @('NPClient64.dll','TrackIR.exe')) {
if (-not (Test-Path (Join-Path $binDir $f))) {
throw "$f missing from the install — the bundle did not ship it"
}
}

# Register against the *installed* binaries using the same code
# path the app runs on launch, then perform a real game's lookup.
$env:OPENFOV_BIN_DIR = $binDir
python -c "from openfov.output.npclient_bootstrap import ensure_registered; ensure_registered()"
if ($LASTEXITCODE -ne 0) { throw "ensure_registered failed" }
python tools/verify_install.py --samples 2
if ($LASTEXITCODE -ne 0) { throw "A game could not use this installer" }

- name: Upload installer artifact
uses: actions/upload-artifact@v4
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ desktop.ini
openfov.log
openfov.log.*
config.local.toml

# Generated by npclient-vendor/build.ps1 before each windres run.
npclient-vendor/trackir_version.h
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,96 @@
All notable changes to OpenFOV are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [0.2.2] — iRacing head tracking actually works

### Fixed
- **Games could never find OpenFOV's NPClient DLL.** This is the root cause
behind every "OpenFOV doesn't do anything in iRacing" report, and it has
been present since the very first release. Two defects in the same
registry write, both required for a game to find us
([#12](https://github.com/epalosh/openfov/issues/12)):
- The directory was written 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`.
- The value had no trailing separator. Games concatenate the DLL name
directly onto it (`reg_value + "NPClient64.dll"`), so even a correctly
placed value resolved to `...\resources\binNPClient64.dll`. We now emit
the same form opentrack does: forward slashes, guaranteed trailing `/`.

Both failures were silent — no error, no log line, no in-game message.
Upgrading is enough; the stale value from older installs is removed
automatically on next launch.
- iRacing's program-profile ID corrected to **14101** (was a guessed 1001),
measured from what the sim writes into `FT_SharedMem`.

### Added
- `verify_registration()` — after registering, OpenFOV now reads the key
back *the way a game reads it* and confirms the result resolves to a real
`NPClient64.dll`, logging a loud error if not. Previously the app wrote a
value, read back its own value, and reported success while no game could
find anything.
- Regression tests pinning the registry layout: the trailing-separator and
forward-slash invariants (all platforms), plus Windows round-trip tests
asserting the value lands in the subkey a game reads and *not* on the
parent key. The old suite deferred this to "an integration test on
Windows CI" that never existed, which is why the bug shipped three times.

### Fixed — antivirus false positives
- **Microsoft Defender classified the bundled `TrackIR.exe` as
`Trojan:Win32/Ravartar!rfn` (Severe) and quarantined it** out of
`resources\bin\`, which silently broke head tracking for the games that
need it. Three separate things made it look like malware, all now
addressed:
- Its whole body was `for(;;) Sleep(INFINITE);`. A tiny, stripped,
unsigned executable whose entry point sleeps forever is the classic
sandbox-evasion stub. It is now an ordinary Win32 program with a
message-only window and a real message pump. Still 0% CPU at idle.
- It shipped with no version resource and `-Wl,--strip-all`. It now
carries honest version metadata (identifying OpenFOV, not NaturalPoint)
and is no longer stripped.
- OpenFOV launched it `CREATE_SUSPENDED`, walked a thread snapshot, and
called `ResumeThread` — the textbook process-injection fingerprint.
Replaced with `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_JOB_LIST`, which
gives the same no-orphans guarantee atomically at process creation.

On the machine that reproducibly quarantined the old build, Defender now
reports no threats on the rebuilt one. That is one machine and one
definition set, not a guarantee.
- The helper is no longer launched at all unless the active game needs it.
Most titles — iRacing included — find OpenFOV purely through the NPClient
registry key and never look at the process list. See
`GameProfile.requires_trackir_process` (default off).

### Added — you can now tell whether it's actually working
- The main window distinguishes **"a game is running"** from **"a game is
reading your head tracking"**. Those are different claims, and conflating
them is why a completely dead output path looked healthy for three
releases. The new indicator is real end-to-end proof: NPClient's
`NP_RegisterProgramProfileID` writes `GameId` and never `GameId2`, while
OpenFOV always writes both, so an inequality can only have come from a
game that loaded our DLL and called in.
- `tools/verify_install.py` runs a real game's discovery sequence against an
install and exits non-zero, with a specific reason, if a game could not
use it. CI and the release workflow both run it — the release build
installs the actual installer first — plus a negative control that fails
the build if the check ever stops detecting known breakage.

### Fixed — first-run friction
- The camera picker listed a single webcam twice (`1400: USB Video Device`
and `700: USB Video Device` — the same device via two capture backends,
labelled with an internal index). Now one entry per physical device, by
friendly name.
- The wizard's calibrate page was a dead end when no face was detected:
Calibrate disabled → Next disabled → Cancel the only way out. Added
**Skip for now**.
- Cancelling the wizard no longer discards the camera you just picked.

### Known issues
- The installer is still **unsigned**, so Windows will show *"Unknown
publisher"* / SmartScreen warnings until code signing is in place.

## [0.2.1] — Head-tracking feel + game-contention performance

> First build actually published by CI. v0.2.0 was tagged but never
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,28 @@ To recenter your view at any time, press **F9**.

## Troubleshooting

There are known issues with getting OpenFOV connected to iRacing on some
users' setups. We are working on a solution! In the meantime, see the "Issues"
tab for discussion on this, and how you might be able to fix the issue manually
until a new release is dropped. ETA before 6/14. Thank you for your patience!
**"OpenFOV tracks my head but iRacing doesn't move."** Releases up to and
including **0.2.1** wrote the NaturalPoint registry pointer in the wrong
place, so no game could ever locate OpenFOV's `NPClient64.dll` — silently,
with no error. Update to the latest release; it corrects the key and cleans
up the stale one on first launch.

To confirm it is registered correctly, this key must exist and the path must
end in a slash:

```
HKEY_CURRENT_USER\Software\NaturalPoint\NATURALPOINT\NPClient Location
Path = C:/Program Files/OpenFOV/resources/bin/
```

`%APPDATA%\OpenFOV\openfov.log` now records this on every launch — look for
`NPClient registration verified`. If it says `BROKEN` or `MISSING`, attach
the log to an issue.

**Antivirus.** Some Defender definitions flag the bundled `TrackIR.exe` as
malware (a false positive — it does nothing but sleep) and quarantine it out
of `resources\bin\`. If tracking stopped working after an AV scan, check
that folder still contains `NPClient64.dll` and `TrackIR.exe`.

## Architecture

Expand Down
4 changes: 2 additions & 2 deletions build/nuitka_build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
Where to write the standalone folder. Defaults to dist/.

.PARAMETER Version
Version string baked into the exe metadata. Default 0.2.1.0.
Version string baked into the exe metadata. Default 0.2.2.0.

.NOTES
On CI (windows-latest), Python 3.12 + Nuitka 2.7+ should already be
Expand All @@ -30,7 +30,7 @@
[CmdletBinding()]
param(
[string]$OutDir = "",
[string]$Version = "0.2.1.0",
[string]$Version = "0.2.2.0",
[string]$PythonExe = ""
)

Expand Down
2 changes: 1 addition & 1 deletion installer/openfov.iss
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
#ifndef MyAppVersion
; Keep in sync with pyproject.toml [project.version]. CI can override
; with `iscc /DMyAppVersion=1.2.3 installer/openfov.iss`.
#define MyAppVersion "0.2.1"
#define MyAppVersion "0.2.2"
#endif

[Setup]
Expand Down
40 changes: 35 additions & 5 deletions npclient-vendor/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
param(
[string]$OutDir = (Join-Path $PSScriptRoot "..\resources\bin"),
[string]$Compiler32 = "i686-w64-mingw32-gcc",
[string]$Compiler64 = "x86_64-w64-mingw32-gcc"
[string]$Compiler64 = "x86_64-w64-mingw32-gcc",
[string]$Version = "0.2.2.0"
)

$ErrorActionPreference = "Stop"
Expand Down Expand Up @@ -102,12 +103,41 @@ $out64 = Join-Path $OutDir "NPClient64.dll"
-o $out64 $src $def
if ($LASTEXITCODE -ne 0) { throw "64-bit NPClient64.dll build failed (exit $LASTEXITCODE)" }

Write-Host "[3/3] TrackIR.exe (64-bit dummy) ..."
Write-Host "[3/3] TrackIR.exe (64-bit presence shim) ..."
$trcOut = Join-Path $OutDir "TrackIR.exe"
& $gcc64 -m64 -mwindows -O2 -Os `
"-Wl,--strip-all" `
-o $trcOut $trcSrc

# Version resource. An unsigned, stripped, version-info-free executable is
# itself an antivirus heuristic signal -- the 0.2.1 shim had none and
# Defender classified it Trojan:Win32/Ravartar!rfn. We generate the defines
# into a header rather than passing them to windres on the command line,
# because a comma-separated FILEVERSION and a quoted string do not survive
# PowerShell -> windres argument passing reliably.
$verParts = ($Version -split '\.')
while ($verParts.Count -lt 4) { $verParts += '0' }
$verCsv = ($verParts[0..3] -join ',')
$verStr = ($verParts[0..3] -join '.')
$verHeader = Join-Path $here "trackir_version.h"
@(
"/* Generated by build.ps1 -- do not edit, do not commit. */",
"#define OPENFOV_VERSION_CSV $verCsv",
"#define OPENFOV_VERSION_STR `"$verStr`""
) | Set-Content -Path $verHeader -Encoding ascii
Write-Host " version resource: $verStr"

$windres = Get-Tool "windres"
if (-not $windres) { $windres = Get-Tool "x86_64-w64-mingw32-windres" -Required }

$resObj = Join-Path $OutDir "trackir_res.o"
& $windres --input (Join-Path $here "trackir.rc") --output $resObj --output-format=coff `
--include-dir $here
if ($LASTEXITCODE -ne 0) { throw "windres failed on trackir.rc (exit $LASTEXITCODE)" }

# NOTE: deliberately NOT stripped, and -municode because we use wWinMain.
# Symbols cost ~10 KB and buy a materially less suspicious binary.
& $gcc64 -m64 -mwindows -municode -O2 `
-o $trcOut $trcSrc $resObj
if ($LASTEXITCODE -ne 0) { throw "TrackIR.exe build failed (exit $LASTEXITCODE)" }
Remove-Item $resObj -ErrorAction SilentlyContinue

Write-Host ""
Write-Host "Build outputs:"
Expand Down
Loading
Loading