---
title: "Game vs Sensor: Dual Inject DLLs in Peregrine"
description: "How Peregrine split its single monitoring DLL into Game and Sensor roles: separate build outputs, dual kernel inject profiles, auto-protect only on Game inject, and why cheats should not get the same DLL as the protected process."
date: 2026-07-24
category: "Anti-Cheat"
tags: ["windows", "anti-cheat", "dll-injection", "kernel", "architecture"]
---

This post covers Peregrine's switch from one injected monitoring DLL to a **multi-DLL, dual-profile** design. The same source tree builds **Game** and **Sensor** binaries. The kernel driver keeps two injection profiles (paths + target name lists). Only a successful **Game** inject auto-adds the PID to the protected set. Sensor inject instruments cheat and tool processes for outbound cross-process APIs without promoting them to protected games. For the older single-DLL picture, see the [architecture overview](/blog/peregrine-anatomy-of-an-anticheat) and [MinHook interception](/blog/peregrine-minhook-api-interception). Kernel timing still follows the [APC injection](/blog/peregrine-kernel-apc-injection) path (now OpenEDR-style **LoadLibraryExW**, not a custom shellcode stub).

## Why One DLL Was the Wrong Shape

The earlier design used a single `PeregrineDLL` for every inject target. That collapses two different jobs into one binary:

1. **Inside the protected game:** full API telemetry, HWBP/VEH protection, and "this PID is a game we care about."
2. **Inside a cheat or external tool:** watch outbound RPM/WPM/`OpenProcess` and similar calls toward the game.

Putting the Game DLL into a cheat process is the wrong security model. On successful inject the kernel used to treat the host like a protected target (auto `StateAddPid`). Protecting the cheat process is useless for game defense and pollutes ObCallback / thread / image notify policy with the wrong PIDs.

Putting a minimal "external only" DLL into the game loses in-process defenses such as debug-register protection on the VEH list.

The fix is explicit roles:

| | **Game** | **Sensor** |
| --- | --- | --- |
| Deploy names | `PeregrineGame_x64/x86.dll` | `PeregrineSensor_x64/x86.dll` |
| Inject into | Protected game process names | Cheat / tool process names |
| Hooks | Full external set + `NtSetContextThread` | Same external set; **no** `NtSetContextThread` |
| HWBP / VEH | Yes | No |
| Callstack on hooks | Yes | Yes |
| Auto `StateAddPid` | Yes (inject success) | **No** |
| IPC `hello` role | `"game"` | `"sensor"` |

## Compile-Time Role, One Source Tree

Both DLLs come from `PeregrineDLL` with a preprocessor role, not two forked codebases. `dllmain.cpp` documents the contract:

```cpp
// From: PeregrineDLL/dllmain.cpp
// Build roles (PEREGRINE_DLL_ROLE):
//   0 = Game   — full hooks + HWBP/VEH (inject into protected game)
//   1 = Sensor — external API hooks + callstack, no HWBP (inject into cheats)

#ifndef PEREGRINE_DLL_ROLE
#define PEREGRINE_DLL_ROLE 0
#endif
#define PEREGRINE_ROLE_GAME   0
#define PEREGRINE_ROLE_SENSOR 1
#if PEREGRINE_DLL_ROLE == PEREGRINE_ROLE_SENSOR
#define PEREGRINE_IS_SENSOR 1
#else
#define PEREGRINE_IS_SENSOR 0
#endif
```

The vcxproj maps MSBuild property `PeregrineRole` to that define and to distinct output names/dirs so Game and Sensor builds do not overwrite each other:

```xml
<!-- From: PeregrineDLL/PeregrineDLL.vcxproj -->
<!-- PeregrineRole=Game|Sensor (pass /p:PeregrineRole=Sensor for sensor DLL) -->
<PeregrineRole Condition="'$(PeregrineRole)'==''">Game</PeregrineRole>
<PeregrineRoleDefine Condition="'$(PeregrineRole)'=='Sensor'">PEREGRINE_DLL_ROLE=1</PeregrineRoleDefine>
<PeregrineRoleDefine Condition="'$(PeregrineRole)'!='Sensor'">PEREGRINE_DLL_ROLE=0</PeregrineRoleDefine>
<TargetName>Peregrine$(PeregrineRole)</TargetName>
```

`build_dll.bat` runs four MSBuild passes (Game/Sensor × x64/x86), then copies:

```
C:\Peregrine\
  PeregrineGame_x64.dll
  PeregrineGame_x86.dll
  PeregrineSensor_x64.dll
  PeregrineSensor_x86.dll
  PeregrineKernelComponent.sys
  ...
```

## What Differs Inside the DLL

Shared hooks cover the cross-process surface both roles care about: **ReadProcessMemory**, **WriteProcessMemory**, **NtReadVirtualMemory**, **NtWriteVirtualMemory**, **VirtualAllocEx**, **VirtualProtectEx**, **CreateRemoteThread**, **OpenProcess**. Each hook still runs `callstack_check` so private executable callers surface as anomalies [1].

Game-only code is gated with `#if !PEREGRINE_IS_SENSOR`:

```cpp
// From: PeregrineDLL/dllmain.cpp
#if !PEREGRINE_IS_SENSOR
    // Game only: debug register / VEH protection
    InstallHook(ntdll, NULL, "NtSetContextThread",
        (void**)&oNtSetContextThread, (void*)HookNtSetContextThread);
#endif

    callstack_init();
#if !PEREGRINE_IS_SENSOR
    hwbp_init();
#endif
```

Sensor builds therefore never install **NtSetContextThread** hooks and never arm DR0 on the VEH list. That avoids HWBP machinery inside processes that are not the protected game, while still reporting outbound memory APIs over the named pipe.

After init, both roles announce themselves with an explicit role field so the GUI can label events:

```cpp
// From: PeregrineDLL/dllmain.cpp
#if PEREGRINE_IS_SENSOR
    ipc_log_event("hello",
        "\"callerPID\":%lu,\"image\":\"%s\",\"role\":\"sensor\"", PID, baseName);
#else
    ipc_log_event("hello",
        "\"callerPID\":%lu,\"image\":\"%s\",\"role\":\"game\"", PID, baseName);
#endif
```

## Dual Kernel Profiles

The injection state is no longer one path list plus one target list. It is an array of profiles keyed by role:

```c
// From: PeregrineKernelComponent/ApcInjection.c
typedef struct _INJ_PROFILE {
    WCHAR  DllPathX64[INJ_MAX_PATH];
    USHORT DllPathX64Bytes;
    WCHAR  DllPathX86[INJ_MAX_PATH];
    USHORT DllPathX86Bytes;
    CHAR   Targets[INJ_MAX_TARGETS][INJ_MAX_NAME];
    ULONG  TargetCount;
} INJ_PROFILE;

typedef struct _INJ_PENDING {
    HANDLE Pid;
    UCHAR  Role;
} INJ_PENDING;

typedef struct _INJ_STATE {
    KSPIN_LOCK Lock;
    BOOLEAN    Enabled;
    INJ_PROFILE Profile[INJ_ROLE_COUNT];
    INJ_PENDING Pending[INJ_MAX_PENDING];
    // ... path buffers to free on process exit
} INJ_STATE;
```

Roles are `INJ_ROLE_GAME` (0) and `INJ_ROLE_SENSOR` (1). Public APIs take the role explicitly:

```c
// From: PeregrineKernelComponent/ApcInjection.h
NTSTATUS InjSetDllPath(_In_ UCHAR Role, _In_ BOOLEAN IsX86, ...);
NTSTATUS InjAddTarget(_In_ UCHAR Role, ...);
```

### Process create: match Game before Sensor

On **PsSetCreateProcessNotifyRoutineEx**, the driver extracts the image basename and calls `MatchTargetRole`. Game is preferred if the same name appears in both lists (a misconfiguration, but deterministic):

```c
// From: PeregrineKernelComponent/ApcInjection.c
/* Prefer Game over Sensor if the same name is listed twice. */
for (UCHAR role = 0; role < INJ_ROLE_COUNT && !hit; role++) {
    // compare basename against Profile[role].Targets
}
```

A hit stores `{ Pid, Role }` in the pending table. When **kernel32.dll** maps, `InjOnImageLoad` takes the pending entry **with its role**, picks the profile's x64 or x86 path (WoW64 via `syswow64` in the image path), and injects that DLL via the APC path [2].

### Inject success: protect only Game

```c
// From: PeregrineKernelComponent/ApcInjection.c
    if (NT_SUCCESS(st)) {
        /* Only Game inject auto-protects the PID (Sensor = cheat host). */
        if (role == INJ_ROLE_GAME)
            StateAddPid(ProcessId);
        RtlStringCchPrintfA(json, ARRAYSIZE(json),
            "{ \"event\": \"apc_inject\", \"pid\": %lu, \"tid\": %lu, "
            "\"status\": \"success\", \"role\": \"%s\" }",
            (ULONG)(ULONG_PTR)ProcessId, tid, roleStr);
```

**StateAddPid** is what feeds ObCallback handle monitoring, thread/image notify filtering, and the "protected game PIDs" set used by userland ETW-TI policy. Sensor hosts intentionally stay off that list: the anti-cheat watches them; it does not treat them as games under defense.

The `apc_inject` JSON event carries `"role": "game"|"sensor"` so the GUI log matches the kernel decision.

## IOCTL Command Map

GUI and driver still use the byte-packed command protocol on the Peregrine device [3]. Dual profiles extend the command IDs rather than inventing a second device:

| Cmd | Meaning |
| --- | --- |
| 8 | Set Game x64 DLL path |
| 9 | Set Game x86 DLL path |
| 10 | Add Game inject target basename |
| 11 | Enable / disable injection |
| 14 | Clear **all** targets (Game + Sensor) and disable |
| 15 | Set Sensor x64 DLL path |
| 16 | Set Sensor x86 DLL path |
| 17 | Add Sensor inject target basename |

```c
// From: PeregrineKernelComponent/Coms.c
    case 15: { // set Sensor x64 DLL path
        // ...
        InjSetDllPath(INJ_ROLE_SENSOR, FALSE, (const WCHAR*)(Data + 1), pathBytes);
        break;
    }
    // case 16: Sensor x86
    // case 17: Sensor target name
```

Rust mirrors the split on `DriverHandle`:

```rust
// From: peregrine-tauri/src-tauri/src/driver_comm.rs
    /// Game role x64 DLL path (IOCTL 8).
    pub fn set_dll_path_x64(&self, path: &str) -> Result<(), String> { /* cmd 8 */ }

    /// Sensor role x64 DLL path (IOCTL 15).
    pub fn set_sensor_dll_path_x64(&self, path: &str) -> Result<(), String> { /* cmd 15 */ }

    /// Add Sensor inject target basename (IOCTL 17).
    pub fn add_sensor_injection_target(&self, name: &str) -> Result<(), String> { /* cmd 17 */ }
```

Tauri commands expose separate `add_injection_target` (Game) and `add_sensor_injection_target` (Sensor). Both enable injection after adding a name. Connect discovers both path pairs under `C:\Peregrine` (or next to the EXE) and pushes all four paths into the driver.

## End-to-End Flow

```
GUI Connect
  → IOCTL 8/9  Game DLL paths
  → IOCTL 15/16 Sensor DLL paths
  → enable inject (11)

Operator: Game button  → IOCTL 10  "game.exe"
Operator: Sensor button → IOCTL 17 "cheat.exe"

game.exe starts
  → create notify: MatchTargetRole → pending role=game
  → kernel32 load → APC LoadLibraryExW(PeregrineGame_*.dll)
  → success → StateAddPid(game) + apc_inject role=game
  → DLL hello role=game + full hooks + HWBP

cheat.exe starts
  → pending role=sensor
  → APC LoadLibraryExW(PeregrineSensor_*.dll)
  → no StateAddPid
  → DLL hello role=sensor + external hooks + callstack

cheat RPM/WPM → game
  → Sensor hooks + IPC (and/or ObCallback + ETW-TI without any DLL)
```

Verified lab shape from the public tree: Game inject on `game.exe` yields `apc_inject` with `role=game`, HWBP arming, and auto-protect. Sensor inject on `cheat.exe` yields `role=sensor` without protect. External RPM/WPM from the cheat against the game shows up as Sensor IPC when the Sensor path is armed.

## How This Fits the Rest of the Stack

**ObCallbacks and kernel notifies** stay game-centric. Auto-protect on Game inject is what keeps that policy automatic instead of requiring a manual "add PID" after every launch [4].

**Module integrity** already knows Game DLL MinHook sites and can exclude them as self-hooks so disk-vs-memory `.text` hashes do not false-positive the anti-cheat's own patches [5]. Sensor hosts are not on the protected list, so integrity scans aimed at games do not treat cheat processes as the primary integrity surface.

**ETW Threat Intelligence** policy is also game-centric: log external ops where the target is a protected game PID; drop local game→game noise from MinHook/HWBP; drop system callers. Dual inject makes the process graph clearer: Sensor is optional in-process telemetry on the attacker side; ETW-TI and ObCallback still work without any Sensor inject [6].

**Kernel APC injection** remains one mechanism. Dual profiles only change *which path string* and *whether to protect* after success. The OpenEDR-style path still uses a RW path buffer and **LoadLibraryExW** as the APC normal routine, with no RX inject staging for the load itself [2].

## Design Tradeoffs

**Strengths**

- Correct privilege of policy: only games enter the protected PID set.
- Compile-time specialization without maintaining two full trees.
- Operator UX matches the model (Game vs Sensor target lists).
- Sensor inject is optional; external cheats still hit ObCallback / ETW-TI / userland scans.

**Costs and limits**

- Four DLL artifacts instead of two (x64/x86 × role), plus dual path config on every connect.
- Target name collision prefers Game; mis-listing a cheat as Game will protect the wrong process.
- Sensor still loads a full MinHook-based DLL into the cheat. A sophisticated tool can detect, unhook, or refuse to run with an unknown module present. Sensor is a telemetry convenience, not a hard boundary.
- Shared external hook list means Game still carries full RPM/WPM hooks for regression parity. A future split could slim Game further if in-process external hooks prove redundant next to Sensor + kernel signals.
- Clear inject (IOCTL 14) wipes **both** roles. There is no "clear Sensor only" command today.

## Relation to Earlier Posts

| Post | What changed with multi-DLL |
| --- | --- |
| [Anatomy](/blog/peregrine-anatomy-of-an-anticheat) | Diagram now has Game DLL + Sensor DLL, not one `PeregrineDLL` |
| [APC injection](/blog/peregrine-kernel-apc-injection) | Pending entries carry role; path comes from `Profile[role]`; auto-protect is role-gated |
| [MinHook](/blog/peregrine-minhook-api-interception) | Same hook pattern; Sensor omits `NtSetContextThread` / HWBP |
| [Callstack validation](/blog/peregrine-callstack-validation) | Enabled in both roles |
| [FakeVEH / HWBP](/blog/peregrine-fakeveh-detection) | Game role only |

The multi-DLL split is an architecture correction: **instrument the game for defense, instrument the cheat for offense telemetry, and never confuse the two at the protected-PID layer.**

---

*Drafted with LLM assistance from the [Peregrine Anti-Cheat](https://github.com/PatchRequest/PeregrineAntiCheat) source code, reviewed and verified against the actual implementation.*

## References

[1] PatchRequest, "Call Stack Validation," patchi.fyi. https://patchi.fyi/blog/peregrine-callstack-validation/

[2] Microsoft, "PsSetLoadImageNotifyRoutine," Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetloadimagenotifyroutine

[3] Microsoft, "Defining I/O Control Codes," Microsoft Learn. https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/defining-i-o-control-codes

[4] PatchRequest, "ObCallbacks," patchi.fyi. https://patchi.fyi/blog/peregrine-obcallbacks/

[5] PatchRequest, "Relocation-Aware Hashing," patchi.fyi. https://patchi.fyi/blog/peregrine-relocation-aware-hashing/

[6] PatchRequest, "PPL ETW Threat Intelligence," patchi.fyi. https://patchi.fyi/blog/peregrine-ppl-etw-threat-intelligence/

[7] PatchRequest, "Peregrine Anti-Cheat," GitHub. https://github.com/PatchRequest/PeregrineAntiCheat

[8] ComodoSecurity, "OpenEDR," GitHub. https://github.com/ComodoSecurity/openedr
