The idea of controlling an NVIDIA GPU programmatically had been sitting in the back of my mind for a while. Reading and writing monitor EDID directly, injecting custom resolutions, changing color space settings with a single line of code — I needed a tool that could push settings directly to the GPU on my own terms, without relying on GeForce Experience or the NVIDIA Control Panel.
The result is NvGpuController — an NVIDIA GPU control application built with C# WinForms (.NET 4.7.2). This series documents the process of building it from scratch. Part 1 covers why I chose NVAPI, how the project is structured, and how I bridged NVAPI’s unusual initialization mechanism into C# via P/Invoke.
There are a handful of public APIs for controlling NVIDIA GPUs.
DXGI / D3D API — Can enumerate display adapters and set display modes, but no support for reading/writing EDID or injecting custom timings.
Windows CCD API (SetDisplayConfig) — Handles resolution and refresh rate changes, but fine-grained control over custom timings is limited.
NVAPI — NVIDIA’s proprietary SDK. Covers everything: physical GPU info, EDID read/write, custom resolution injection, and color space control. The catch: Windows-only and NVIDIA-only.
Since the requirements were specifically “NVIDIA GPU + EDID + custom timings,” NVAPI was the only real option.
NVAPI doesn’t follow the typical Win32 API or COM interface model. The header files (.h) are publicly available, but the actual function symbols are not exported from the DLL. Instead, there is exactly one exported function — nvapi_QueryInterface — and every other function is obtained by passing a 32-bit integer ID to that QueryInterface to retrieve a function pointer.
This design lets NVIDIA swap out function implementations between driver updates without breaking the ABI. From the C# side, though, it means the usual [DllImport] approach won’t work — you need a different strategy.
The UI only knows about the business logic layer; the business logic layer only calls into the NVAPI wrapper. The UI never touches NvApiWrapper directly.
When working with NVAPI, the most important thing is being able to track which function returned which status code. Logger is a thread-safe static logger designed for exactly that.
The key piece is NvApiCall. It takes the NVAPI function name and the returned NvStatus integer, and records success or failure in a single line. On failure, the error code is printed in hex so it’s easy to cross-reference against the NVAPI documentation.
ConcurrentQueue is used because WinForms background threads — for example, GPU polling inside Task.Run — also need to write logs safely. The OnLog event lets the UI’s log viewer receive and display messages in real time.
In a WinForms app, P/Invoke code that passes a bad pointer or incorrectly sized struct can throw AccessViolationException or SEHException. If either hits the UI thread unhandled, the app simply dies.
Application.ThreadException catches exceptions on the UI thread; AppDomain.CurrentDomain.UnhandledException catches those on background threads. Both paths log to Logger, show a message box, and let the app keep running when the error isn’t fatal.
NVAPI is C-based. To use it from C#, every C struct must be mapped precisely using [StructLayout] attributes. Even a small discrepancy in memory layout will cause the driver to read garbage data, destabilizing the system.
NvStatus is declared as int. NVAPI functions return 0 on success and a negative value on failure. EndEnumeration(-7) is a signal that enumeration has completed — it’s not an error, it’s a loop termination condition.
Most NVAPI structs have a Version field in the first 4 bytes. This field encodes both the struct size and the API version number, and it must be set correctly for the driver to interpret the struct properly.
The pattern is: Version = (size) | (apiVersion << 16). This encoding is used consistently across all NVAPI structs. Providing a static Create() factory method prevents callers from making version calculation mistakes.
Depending on the driver version, support may require V1 (12 bytes), V3 (12 bytes), or V5 (16 bytes). Each is defined as a separate struct, and the appropriate one is selected at runtime.
Different DLLs are called depending on whether the process is 64-bit or 32-bit. IntPtr.Size == 8 means 64-bit. CallingConvention.Cdecl is required because NVAPI uses the C calling convention.
QueryInterface calls and delegate conversion are wrapped in a single method.
1
2
3
4
5
6
7
privatestatic T GetDelegate<T>(uint id)where T :class{ IntPtr ptr = QueryInterface(id);if(ptr == IntPtr.Zero)returnnull;return Marshal.GetDelegateForFunctionPointer(ptr,typeof(T))as T;}
If QueryInterface(id) returns IntPtr.Zero, the driver doesn’t support that function. In that case null is returned, and the wrapper methods check for null before calling, returning NvStatus.NoImplementation when the delegate isn’t available.
This is called once after a successful initialization. From that point on, all NVAPI calls go through cached delegates — no QueryInterface overhead on each call.
Most NVAPI functions accept a fixed-type struct, but NvAPI_DISP_ColorControl accepts different structs (V1/V3/V5) depending on the driver version. A fixed-type delegate won’t work here, so an IntPtr-based raw delegate is used instead.
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]privatedelegate NvStatus NvAPI_DISP_ColorControl_Raw_t(uint displayId, IntPtr colorData);publicstatic NvStatus DISP_ColorControlV5(uint displayId,ref NvColorControlV5 cc){// Initialize delegate on first callif(_DISP_ColorControl_Raw ==null){var ptr = QueryInterface(ID_DISP_ColorControl);if(ptr == IntPtr.Zero)return NvStatus.NoImplementation; _DISP_ColorControl_Raw = Marshal.GetDelegateForFunctionPointer( ptr,typeof(NvAPI_DISP_ColorControl_Raw_t))as NvAPI_DISP_ColorControl_Raw_t;}// Copy struct to unmanaged heap -> call NVAPI -> read back result IntPtr mem = Marshal.AllocHGlobal(16);try{ Marshal.StructureToPtr(cc, mem,false);var status = _DISP_ColorControl_Raw(displayId, mem); cc =(NvColorControlV5)Marshal.PtrToStructure( mem,typeof(NvColorControlV5)); Logger.NvApiCall($"ColorControl_v5({cc.Cmd}, depth={cc.ColorDepth})",(int)status);return status;}finally{ Marshal.FreeHGlobal(mem);// Must always free}}
Marshal.AllocHGlobal allocates 16 bytes on the unmanaged heap, the struct is copied in, and the pointer is passed to NVAPI. Once NVAPI fills the buffer, Marshal.PtrToStructure reads it back as a struct. The finally block ensures the memory is always freed.
V3 (12 bytes) and V1 (12 bytes) follow the same pattern — only the size differs.
Part 1 covered why I chose NVAPI, how the project is structured, and how to implement NVAPI’s QueryInterface-based initialization via C# P/Invoke.
Part 2 builds on this foundation to implement EDID reading and parsing. We’ll use NvAPI_GPU_GetEDID to retrieve raw bytes, then parse the EDID standard structure (Base Block, Extension Blocks) to extract the monitor name, supported resolutions, and timing information.
Series Overview
NVAPI Initialization and Project Architecture (this post)
EDID Reading and Parsing — Extracting Monitor Information
EDID Writing — Injecting Custom EDIDs
Custom Resolutions — TryCustomDisplay and Timing Calculations
Color Control — RGB/YUV, Bit Depth, and HDR Settings
NVIDIA GPU Controller Dev Log 2026 -
This article is part of a series.