fix(ndi): resolve Processing.NDI.Lib.x64 via NDI_RUNTIME_DIR_V6 env var

The NDI 6 installer sets NDI_RUNTIME_DIR_V6 (and V5/V4 for back-compat) but does not always add the runtime directory to PATH, so a default DllImport(Processing.NDI.Lib.x64) failed with 0x8007007E (DllNotFoundException) on otherwise correctly installed machines, killing both TeamsISO.exe and TeamsISO.Console at preflight.

Add NdiNativeLibraryResolver, registered from NdiNative's static ctor, that resolves the DLL by trying NDI_RUNTIME_DIR_V6 / V5 / V4 in order, NativeLibrary.Load-ing the file from disk before the runtime falls back to its default search algorithm. Static-ctor registration (rather than [ModuleInitializer]) sidesteps CA2255 under TreatWarningsAsErrors and still guarantees the resolver is in place before the first P/Invoke fires.
This commit is contained in:
Zac Gaetano 2026-05-07 15:14:54 -04:00
parent 0f03c272ad
commit d14a33a0a3
2 changed files with 85 additions and 2 deletions

View file

@ -4,13 +4,30 @@ namespace TeamsISO.Engine.NdiInterop;
/// <summary>
/// P/Invoke declarations for the NewTek/Vizrt NDI SDK 6 native library.
/// On Windows the import target is <c>Processing.NDI.Lib.x64.dll</c>; the loader resolves it
/// from the NDI Runtime installation path or from the application directory.
/// On Windows the import target is <c>Processing.NDI.Lib.x64.dll</c>. Resolution of
/// the DLL is handled by <see cref="NdiNativeLibraryResolver"/>, which loads it from
/// the NDI Runtime installation directory exposed by the
/// <c>NDI_RUNTIME_DIR_V&lt;n&gt;</c> environment variables — the NDI installer sets
/// these but does not always add the runtime directory to <c>PATH</c>, so a default
/// loader-based resolution would fail with <c>0x8007007E</c> on otherwise correctly
/// installed machines.
/// </summary>
internal static class NdiNative
{
private const string LibName = "Processing.NDI.Lib.x64";
/// <summary>
/// Registers <see cref="NdiNativeLibraryResolver"/> with the runtime before any
/// P/Invoke against <see cref="LibName"/> fires. The static constructor is
/// guaranteed to run before the first access to any member of this type, which
/// includes the very first <c>[DllImport]</c> call site, so the resolver is
/// always in place when the loader needs it.
/// </summary>
static NdiNative()
{
NdiNativeLibraryResolver.Register();
}
// ---- Lifecycle ----
[DllImport(LibName, EntryPoint = "NDIlib_initialize", CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]

View file

@ -0,0 +1,66 @@
using System.Reflection;
using System.Runtime.InteropServices;
namespace TeamsISO.Engine.NdiInterop;
/// <summary>
/// Resolves the NDI native library (<c>Processing.NDI.Lib.x64.dll</c>) from the
/// NDI Runtime install directory exposed via <c>NDI_RUNTIME_DIR_V&lt;n&gt;</c>
/// environment variables.
///
/// The NDI installer creates these env vars but does not always add the runtime
/// directory to <c>PATH</c>, so a default <c>[DllImport("Processing.NDI.Lib.x64")]</c>
/// can fail with <c>0x8007007E</c> ("the specified module could not be found") on
/// otherwise correctly installed machines. This resolver patches that gap by
/// pre-loading the DLL from the install dir before the runtime falls back to its
/// default search algorithm.
///
/// The resolver is registered from the static constructor of <see cref="NdiNative"/>,
/// which guarantees it runs before the first P/Invoke on that type fires. On
/// non-Windows the resolver short-circuits; the assembly's P/Invokes are gated by
/// <c>[SupportedOSPlatform("windows")]</c> and won't fire there in any case.
/// </summary>
internal static class NdiNativeLibraryResolver
{
private const string NdiX64LibraryName = "Processing.NDI.Lib.x64";
private const string NdiX64LibraryFile = "Processing.NDI.Lib.x64.dll";
/// <summary>
/// NDI runtime install-dir environment variables, in preference order.
/// V6 is what TeamsISO is built against; V5/V4 are listed as graceful fallbacks
/// for installs that pre-date V6 — the runtime probe will still report a
/// version mismatch, but at least the DLL will load and the engine can surface
/// a clear alert instead of dying with DllNotFoundException.
/// </summary>
private static readonly string[] EnvVarFallbacks =
{
"NDI_RUNTIME_DIR_V6",
"NDI_RUNTIME_DIR_V5",
"NDI_RUNTIME_DIR_V4",
};
internal static void Register()
{
NativeLibrary.SetDllImportResolver(typeof(NdiNativeLibraryResolver).Assembly, Resolve);
}
private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != NdiX64LibraryName) return IntPtr.Zero;
if (!OperatingSystem.IsWindows()) return IntPtr.Zero;
foreach (var envVar in EnvVarFallbacks)
{
var dir = Environment.GetEnvironmentVariable(envVar);
if (string.IsNullOrEmpty(dir)) continue;
var path = Path.Combine(dir, NdiX64LibraryFile);
if (!File.Exists(path)) continue;
if (NativeLibrary.TryLoad(path, out var handle))
return handle;
}
// Fall through to default loader (PATH, app dir, etc.) — preserves the
// chance that someone added the NDI dir to PATH manually.
return IntPtr.Zero;
}
}