34: DirectX Overlay

概要

DirectXというAPIを使う事で、ゲーム画面上に自分のオリジナルのグラフィックを加える事ができます。
今回は、DirectXを理解し、DirectX Overlayを使ってゲーム画面上に現在の座標を表示する事を目標にやっていきます。
チート対象のゲームは、x86でチートの練習用に作られた、Pwn Adventure 3を使います。
Windows対象で、Visual Studio 2019DirectX9のSDKを使用しC++で記述する。 ソースコード内で、windowsのウィンドウを作成する箇所があるので、慣れてない方は 33回 の記事を見てからこっちを読むと良いかもです。
この記事は、この動画を参考にしています。完成形のソースコードは一番最後のソースコードです。

DirectXとは

Windowsで グラフィックス や ゲーム関連の処理 を高速に行うためのAPIです。よくゲームを作るのに使われてます。
DirectXの中には DirectX Audio, DirectX Input, DirectX Graphics など色々ありますが、 単にDirectXと言うとグラフィック関連の高速な処理を提供するAPIである DirectX Graphics (DirectX 3D) を指す事が多いです。
DirectXにはバージョンがあり、よく出てくるのが DirectX 9,10,11,12 で、バージョン間で結構仕様が変ったりします。 一番扱いやすいと言われているのが DirectX 9 で、ネット上に記事も多いので、DirectX 9 を使っていきます。

DirectXを使ったチートの種類

DirectXを使ったチートは、主にゲームにグラフィック関連で何か付け足すのが目的です。 その方法として大きく以下の二種類があります。 今回は、DirectX Overlay の方を実装していきます。

DirectX9 SDK のインストール

DirectXを使ったプログラムを書く場合は、まずSDKをダウンロードする必要があります。
自分はこれを言語はEnglishにしてダウンロードしました。

DirectX9 SDK を Visual Studio で使うための設定

まず、今回の DirectX Overlay 用のDLLのプロジェクトをいつも通り作成しましょう。 そして、ソリューションのプロパティ画面の 詳細 > 文字セット で、マルチバイト文字を使用するように設定しましょう。

Visual Studio で DirectX SDK を使うためには、DirectXを使う新しいプロジェクトを立ち上げる度に以下の3点を設定する必要があります。 インクルードディレクトリのパスの編集は、ソリューションのプロパティ画面の以下を押す事でできます。 <編集...> の所を押し、以下のようにパスを指定しましょう。 これで、DirectXのヘッダファイルを#includeできるようになりました。

続いて、ライブラリディレクトリを編集していきます。 同じく<編集...> の所を押し、以下のようにパスを指定しましょう。 これで、DirectXの実際の処理があるライブラリを#pragma comment等で見つけられるようになりました。

最後に、リンカーの依存ファイルにDirectXのライブラリを追加します。 同じく<編集...> の所を押し、以下のように入力します。
dllmain.cppに以下の用に書いて、赤線が出ず、ビルドが通ればインクルードディレクトリとライブラリディレクトリの編集は上手く行ってるはずです。 実行時に上手く行かなかった場合はリンカーの依存ファイルの設定をし忘れてるか上手く行ってないです。

透明なウィンドウの作成

では、まずは透明なウィンドウから作成していきます。 コードは以下になります。dllmain.cppに書きましょう。
#include "pch.h"
#include <d3d9.h>
#include <d3dx9.h>
#pragma comment(lib, "d3d9.lib")
#pragma comment(lib, "d3dx9.lib")

LPCSTR windowName = "DirectX9 Overlay Window for PwnAdventure3";
HWND hWnd;
int width, height;

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam) {
    switch (mes) {
    case WM_PAINT:
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    default:
        return DefWindowProc(hWnd, mes, wParam, lParam);
    }
}

DWORD WINAPI ThreadMain(LPVOID params) {
    WNDCLASSEX wcex;
    wcex.cbSize        = sizeof(WNDCLASSEX);
    wcex.style         = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc   = WndProc;
    wcex.cbClsExtra    = 0;
    wcex.cbWndExtra    = 0;
    wcex.hInstance     = (HINSTANCE)params;
    wcex.hIcon         = 0;
    wcex.hCursor       = LoadCursor(nullptr, IDC_ARROW);
    wcex.hbrBackground = CreateSolidBrush(RGB(0, 0, 0));
    wcex.lpszMenuName  = windowName;
    wcex.lpszClassName = windowName;
    wcex.hIconSm       = 0;

    RegisterClassEx(&wcex);
    hWnd = CreateWindowEx(WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED, windowName, windowName, WS_POPUP, 1, 1, width, height, NULL, NULL, (HINSTANCE)params, NULL);
    SetLayeredWindowAttributes(hWnd, RGB(0, 0, 0), 0, LWA_COLORKEY);

    ShowWindow(hWnd, SW_SHOWDEFAULT);

    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
        CreateThread(0, 0, ThreadMain, hModule, 0, 0);
    }
    return TRUE;
}
(このコードの意味が全体的によくわからない人は前回の33回の記事を読んでいただければ。)
基本的にはウィンドウを作成するだけのコードとあんま変わらないですが、透明化で大事なのは以下の2点です。

透明ウィンドウがゲームウィンドウと同じサイズ/位置になるようにする

ゲームウィンドウが移動されたり、拡大された時等のために、常にゲームウィンドウと同じサイズ/位置になるようにします。
#include "pch.h"
#include <d3d9.h>
#include <d3dx9.h>
#pragma comment(lib, "d3d9.lib")
#pragma comment(lib, "d3dx9.lib")

LPCSTR windowName = "DirectX9 Overlay Window for PwnAdventure3";
LPCSTR gameTitle = "PwnAdventure3 (32-bit, PCD3D_SM5)";
HWND hWnd, gameHWnd;
int width, height;

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam) {
    switch (mes) {
    case WM_PAINT:
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    default:
        return DefWindowProc(hWnd, mes, wParam, lParam);
    }
}

DWORD WINAPI ThreadMain(LPVOID params) {
    WNDCLASSEX wcex;
    wcex.cbSize        = sizeof(WNDCLASSEX);
    wcex.style         = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc   = WndProc;
    wcex.cbClsExtra    = 0;
    wcex.cbWndExtra    = 0;
    wcex.hInstance     = (HINSTANCE)params;
    wcex.hIcon         = 0;
    wcex.hCursor       = LoadCursor(nullptr, IDC_ARROW);
    wcex.hbrBackground = CreateSolidBrush(RGB(0, 0, 0));
    wcex.lpszMenuName  = windowName;
    wcex.lpszClassName = windowName;
    wcex.hIconSm       = 0;

    RegisterClassEx(&wcex);
    hWnd = CreateWindowEx(WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED, windowName, windowName, WS_POPUP, 1, 1, width, height, NULL, NULL, (HINSTANCE)params, NULL);
    SetLayeredWindowAttributes(hWnd, RGB(0, 0, 0), 0, LWA_COLORKEY);

    gameHWnd = FindWindowA(0, gameTitle);
    if (gameHWnd) {
        RECT rect;
        GetWindowRect(gameHWnd, &rect);
        width = rect.right - rect.left;
        height = rect.bottom - rect.top;
    }
    else return FALSE;

    ShowWindow(hWnd, SW_SHOWDEFAULT);

    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);

        RECT rect;
        GetWindowRect(gameHWnd, &rect);
        width = rect.right - rect.left;
        height = rect.bottom - rect.top;

        MoveWindow(hWnd, rect.left, rect.top, width, height, true);
    }

    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
        CreateThread(0, 0, ThreadMain, hModule, 0, 0);
    }
    return TRUE;
}
まず、FindWindowを使ってウィンドウの名前でゲームのウィンドウを探し、そのハンドルを取得します。 その後、GetWindowRectでゲームウィンドウの大きさや位置の情報を取得し、MoveWindow で逐次ゲームウィンドウと同じになるよう座標/サイズを合わせています。

DirectX9プログラミングの基本

DirectXで透明なウィンドウに座標をここから書いていきますが、その前にDirectX9で何か書く時の流れを説明します。
DirectX9のプログラムを書く場合の全体の流れは以下です。
  1. 初期化
    1. IDirect3D9を作る
      • Direct3Dのあらゆる機能を使うためのインターフェースの大元
    2. D3DPRESENT_PARAMETERSにDirectX関連の色んな設定を詰め込む
    3. IDirect3DDevice9を作る
      • これ作る時にD3DPRESENT_PARAMETERが必要
      • ビデオカードなど描画関連のデバイスを管理するインターフェース
      • ここに色んな描画に関する関数があるので、こっちを主に使う(IDirect3D9はIDirect3DDevice9を作るぐらいしか役目無い)
  2. 描画 (以下をメッセージループ内で繰り返します)
    1. 画面のクリア (Clear)
    2. 描画開始 (BeginScene)
    3. 描画処理 (ここに色々オリジナルの描画を書く)
    4. 描画終了 (EndScene)
    5. 次の描画に移動 (Present)

フロントバッファとバックバッファ

DirectXはフロントバッファとバックバッファを使用して描画してます。 フロントバッファだけだと、例えば 最初に丸を書いて、次に四角を書いて… とやると、 丸だけ書いてある画面が一度表示されてから、次に四角が現れるみたいな感じで、描画途中の情報が出ちゃいます。
よって、一度バックバッファに次の画面に表示する描画情報を入れてから、次のフレームになった時にバックバッファの内容をフロントバッファに移動する というように描画しており、これをスワップエフェクトと呼びます。

初期化のコード例

初期化は以下みたいな感じで行います(今回使うコードまんまです)。
IDirect3D9Ex* g_pD3D;
IDirect3DDevice9Ex* g_pD3DDev;

Direct3DCreate9Ex(D3D_SDK_VERSION, &g_pD3D);

D3DPRESENT_PARAMETERS d3dpp;
ZeroMemory(&d3dpp, sizeof(d3dpp));
d3dpp.BackBufferWidth = width;  // バックバッファの幅
d3dpp.BackBufferHeight = height;  // バックバッファの高さ
d3dpp.Windowed = true;  // 描きこみたいウィンドウがフルスクリーンじゃない時はtrue、フルスクリーンならfalse
d3dpp.hDeviceWindow = hWnd;  // 描き込み対象のウィンドウのハンドル
d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;  // スワップエフェクトの種類
d3dpp.MultiSampleQuality = D3DMULTISAMPLE_NONE;  // マルチサンプリング(エッジを滑らかに描画するためのグラフィック技法)の質
d3dpp.BackBufferFormat = D3DFMT_A8R8G8B8;  // 色のビット数など画面のフォーマットの情報
d3dpp.EnableAutoDepthStencil = TRUE;  // 深度ステンシルバッファ(3Dで奥行きの情報を持たせる。手前の物で隠れてる奥のものを描画しない)の有無
d3dpp.AutoDepthStencilFormat = D3DFMT_D16;  // 深度ステンシルバッファのフォーマット(16bitで奥行きを表現)

g_pD3D->CreateDeviceEx(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3dpp, 0, &g_pD3DDev);
また、CreateDeviceEx の引数の情報は以下の通りです。

描画のコード例

描画もまず初めに以下をメッセージループの中にいれるのが普通です(今回使うコードまんまです)。
g_pD3DDev->Clear(0, NULL, D3DCLEAR_TARGET, 0, 1.0f, 0);
g_pD3DDev->BeginScene();

// -- オリジナルの描画処理 --
    
g_pD3DDev->EndScene();
g_pD3DDev->Present(NULL, NULL, NULL, NULL);

プレイヤーの現在の座標をDirectX Overlayで表示する

ラストです。まず、20回 の記事からgeneral-movement.h、18回 の記事から general-cheats.h/general-cheats.cpp/player.h/player.cpp をコピーして持ってきましょう。
これらは単にPwnAdventure3のプレイヤーの座標を取得するために使うだけです。

できたら、dllmain.cppに以下のように書いて終了です。これが最終コードです
#include "pch.h"
#include "general-cheats.h"
#include "player.h"
#include <d3d9.h>
#include <d3dx9.h>
#pragma comment(lib, "d3d9.lib")
#pragma comment(lib, "d3dx9.lib")

Player* player;
LPCSTR windowName = "DirectX9 Overlay Window for PwnAdventure3";
LPCSTR gameTitle = "PwnAdventure3 (32-bit, PCD3D_SM5)";
HWND hWnd, gameHWnd;
int width, height;
ID3DXFont* font = 0;

void drawText(char* string, int x, int y, int a, int r, int g, int b) {
    RECT rect;
    rect.top = y;
    rect.left = x;
    font->DrawTextA(0, string, strlen(string), &rect, DT_NOCLIP, D3DCOLOR_ARGB(a, r, g, b));
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam) {
    switch (mes) {
    case WM_PAINT:
        break;
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    default:
        return DefWindowProc(hWnd, mes, wParam, lParam);
    }
}

DWORD WINAPI ThreadMain(LPVOID params) {
    player = new Player();

    WNDCLASSEX wcex;
    wcex.cbSize        = sizeof(WNDCLASSEX);
    wcex.style         = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc   = WndProc;
    wcex.cbClsExtra    = 0;
    wcex.cbWndExtra    = 0;
    wcex.hInstance     = (HINSTANCE)params;
    wcex.hIcon         = 0;
    wcex.hCursor       = LoadCursor(nullptr, IDC_ARROW);
    wcex.hbrBackground = CreateSolidBrush(RGB(0, 0, 0));
    wcex.lpszMenuName  = windowName;
    wcex.lpszClassName = windowName;
    wcex.hIconSm       = 0;

    RegisterClassEx(&wcex);
    hWnd = CreateWindowEx(WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED, windowName, windowName, WS_POPUP, 1, 1, width, height, NULL, NULL, (HINSTANCE)params, NULL);
    SetLayeredWindowAttributes(hWnd, RGB(0, 0, 0), 0, LWA_COLORKEY);

    gameHWnd = FindWindowA(0, gameTitle);
    if (gameHWnd) {
        RECT rect;
        GetWindowRect(gameHWnd, &rect);
        width = rect.right - rect.left;
        height = rect.bottom - rect.top;
    }
    else return FALSE;

    IDirect3D9Ex* g_pD3D;
    IDirect3DDevice9Ex* g_pD3DDev;
    Direct3DCreate9Ex(D3D_SDK_VERSION, &g_pD3D);

    D3DPRESENT_PARAMETERS d3dpp;
    ZeroMemory(&d3dpp, sizeof(d3dpp));
    d3dpp.BackBufferWidth = width;
    d3dpp.BackBufferHeight = height;
    d3dpp.Windowed = true;
    d3dpp.hDeviceWindow = hWnd;
    d3dpp.SwapEffect = D3DSWAPEFFECT_DISCARD;
    d3dpp.MultiSampleQuality = D3DMULTISAMPLE_NONE;
    d3dpp.BackBufferFormat = D3DFMT_A8R8G8B8;
    d3dpp.EnableAutoDepthStencil = TRUE;
    d3dpp.AutoDepthStencilFormat = D3DFMT_D16;

    g_pD3D->CreateDeviceEx(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3dpp, 0, &g_pD3DDev);

    D3DXCreateFont(g_pD3DDev, 20, 0, FW_BOLD, 1, false, DEFAULT_CHARSET, OUT_DEVICE_PRECIS, ANTIALIASED_QUALITY, DEFAULT_PITCH, "Comic Sans", &font);

    ShowWindow(hWnd, SW_SHOWDEFAULT);

    MSG msg;
    while (GetMessage(&msg, nullptr, 0, 0)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);

        RECT rect;
        GetWindowRect(gameHWnd, &rect);
        width = rect.right - rect.left;
        height = rect.bottom - rect.top;

        MoveWindow(hWnd, rect.left, rect.top, width, height, true);

        g_pD3DDev->Clear(0, NULL, D3DCLEAR_TARGET, 0, 1.0f, 0);
        g_pD3DDev->BeginScene();

        if (gameHWnd == GetForegroundWindow()) {
            Vector3 curLoc = player->GetCurrentLocation();
            char buf[200];
            snprintf(buf, 200, "x:%.2f  y:%.2f  z:%.2f", curLoc.x, curLoc.y, curLoc.z);
            drawText(buf, width / 50, height / 30, 255, 80, 220, 50);
        }

        g_pD3DDev->EndScene();
        g_pD3DDev->Present(NULL, NULL, NULL, NULL);
    }

    g_pD3DDev->Release();
    g_pD3D->Release();

    return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
        CreateThread(0, 0, ThreadMain, hModule, 0, 0);
    }
    return TRUE;
}
今回はプレイヤーの座標の情報を文字としてゲーム内に表示したいので、D3DXCreateFont を使ってフォントの調節をし、BeginSceneEndSceneの間で 座標を取得し、drawText を使って透明なウィンドウに描画しています。
drawText は自分で定義した関数で、中でID3DXFontオブジェクトの内のメソッドの一つである DrawTextA を呼び出してます。

動かしてみる

これでビルドし、このDLLをゲームにインジェクトすれば以下のように座標が表示されるはずです。 ウィンドウをリサイズ等しても、ちゃんと固定された位置で表示され続ける事も確認していただければ。