電車でＧｏ！コントローラー変換器のマスコンとブレーキの情報を自作アプリケーションで受け取る

[電車でGo！コントローラー変換器](https://autotraintas.hariko.com/) (以下、「変換器」) は、パソコンに接続した家庭用ゲーム機向けの電車でGO！シリーズ用コントローラーからマスコンやブレーキの操作情報を取得し、この情報を対象アプリケーションのメモリに書き込んだりキーボード操作に変換したりすることで、パソコン上でコントローラーを使用した電車の運転シミュレーションができるようにすることを目的としたアプリケーションである。

変換器自体にもマスコンやブレーキの操作をキーボード操作として出力する機能はあるが、以下の理由などでマスコンやブレーキの情報を直接取得したくなる可能性がある。

* 変換器では対応していない複雑・特殊なキーボード操作に変換したい
* 変換器からの操作情報を取りこぼすことにより状態がズレる可能性を避けたい
* せっかく情報があるんだから、余計な変換を挟まずに直接取得したい

しかし、変換器のマニュアル・ドキュメント・UIでは、具体的に対象アプリケーションをどのように検出し、マスコンやブレーキの情報をどこに書き込むか、という情報は見つからなかった。
([ClassNameを使うようにしているという情報](https://x.com/autotraintas/status/1719915808737177679)は出ているが、ほとんどの具体的なClassNameは不明である)
また、書き込む位置などを設定する機能も見当たらなかった。

そこで、**ドキュメントなどに解析・逆コンパイル・リバースエンジニアリング・ダンプなどを禁止するような記述が見当たらないことを確認したうえで**変換器を逆コンパイルし、アプリケーションの検出方法と情報を書き込む位置を調べた。
そして、これらに合わせてアプリケーションを自作し、変換器から情報を受け取ってみた。

# 変換器の解析

## 変換器の逆コンパイル

変換器は、.NET Framework を利用して作られていることが公表されている。
.NET Framework 製のアプリケーションは、精度の高い逆コンパイルがしやすいことが知られている。

[ILSpy 8.2](https://github.com/icsharpcode/ILSpy/releases/tag/v8.2) を用いて変換器 Ver3.70 の逆コンパイルを行った。
Windows 環境で Assets から `ILSpy_binaries_8.2.0.7535-x64.zip` をダウンロードして解凍し、`ILSpy.exe` を実行する。
必要なランタイムのインストールを要求された場合は、インストールする。
起動したら、「File → Open...」メニューから変換器のプログラム `denconv370.exe` を選択して開く。
すると、Assemblies に denconv370 が現れるので、ツリーを展開していく。
調査の結果、対象アプリケーションを検出するためのクラス名や、マスコンやブレーキの情報を書き込むアドレスの情報が含まれている関数を発見できた。

:::note info
[dnSpy](https://github.com/dnSpy/dnSpy) v6.1.8 では、該当の関数の逆コンパイルをしようとするとメモリが数十GB以上消費され、数十分待っても逆コンパイル結果が得られなかった。
ILSpy 8.2 では、同じ関数の逆コンパイル結果が10秒ほどで得られた。
:::

## 処理内容の調査

発見した関数では、大きく分けて以下の処理を行っている。

* 対象アプリケーションの選択
* UIの処理 (コントローラー入力の状態表示、モードに応じたUI要素の表示など)
* マスコン・ブレーキ・他各種ボタンの変換処理
* アプリケーションの検出と情報のやり取り

今回重要なのは「対象アプリケーションの選択」と「アプリケーションの検出と情報のやり取り」である。

### 対象アプリケーションの選択

選択されている対象アプリケーションの名前 (文字列) に対応するゲームID (数値) の値を取得している。

この部分を見ることで、対象アプリケーションとゲームIDの値の関係がわかる。

### アプリケーションの検出と情報のやり取り

「対象アプリケーションの選択」の部分で設定されているゲームIDの値を用いて、対象アプリケーションごとの処理を行っている。

まず、クラス名を用いて対象アプリケーションのウィンドウを探す。
ウィンドウが見つかったら、そのウィンドウに対応するプロセスを開き、ハンドル (マスコン) とブレーキの情報を対象のアプリケーションのプロセスのメモリ上の特定のアドレスに書き込む。
書き込むサイズは、ハンドル (マスコン)・ブレーキともに1バイトである。

一部の対象アプリケーションに関しては、情報を書き込むだけでなく逆に対象アプリケーションのメモリから何らかの情報を取得しているようであるが、今回はこの取得への対応は行わない。
もし、情報を取得されることに対応したければ、またあらためて解析を行えばいいだろう。

# アプリケーションの作成

## 対応する変換器のモードを決める

まずは、今回作成するアプリケーションが変換器から情報をもらう際、変換器で「制御するソフト」として何を選択した状態に対応するかを決める。
今回は、以下の理由で「通勤編」に対応することにした。

* 検出用のクラス名が半角英数字からのみなり、マルチバイト文字によるトラブルを起こしにくい
* 検出用のクラス名が `"class name"` などの一般的なものではない
* ある程度新しく、削除される可能性が低いと推測できる

:::note warn
「削除される可能性が低いと推測」したが、ドキュメントをよく見ると「通勤編体験版」に関しては削除対象の候補として挙げられていた。
:::

変換器 Ver3.70 が対応している「通勤編」には、「初版」「本格版」「体験版」がある。
以下のパラメータは、これらの3種類に共通である。

|パラメータ|値|
|---|---|
|検出用のクラス名|`"Dengo3"`|
|ハンドル (マスコン) の情報を書き込むサイズ|1バイト|
|ブレーキの情報を書き込むサイズ|1バイト|

ハンドル (マスコン) やブレーキの情報を書き込む先のアドレスはそれぞれの種類ごとに異なっており、以下である。

|種類|ハンドル (マスコン)<br>書き込み先アドレス|ブレーキ<br>書き込み先アドレス|
|---|:-:|:-:|
|通勤編初版-pat02/20020312|5163900 (0x4ecb7c)|5163808 (0x4ecb20)|
|通勤編本格版/20060202～up01/20110121|5253596 (0x5029dc)|5253504 (0x502980)|
|通勤編（体験版）|5136428 (0x4e602c)|5136336 (0x4e5fd0)|

さらに、これらの種類で情報取得のための読み込みが行われている最大のアドレスは 5896428 (0x59f8ec) である。
(正確には、このアドレスから4バイトを読み込んでいるため、5896431 (0x59f8ef) が最大である)

## アドレスを指定してメモリを確保する

変換器が読み書きするメモリアドレスがわかったので、自作アプリケーションで情報を受け取れるようにこれに合わせてメモリを確保する。
[VirtualAlloc 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/memoryapi/nf-memoryapi-virtualalloc)を用いることで、開始アドレスとサイズを指定してメモリの範囲を確保できる。

```c
LPVOID VirtualAlloc(
  LPVOID lpAddress,
  SIZE_T dwSize,
  DWORD  flAllocationType,
  DWORD  flProtect
);
```

`lpAddress` は、確保するメモリの範囲の始点となるアドレスを指定する。
このアドレスがシステムで決まっている単位の倍数でない場合は、調整が行われる。
この単位は、[GetSystemInfo 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/sysinfoapi/nf-sysinfoapi-getsysteminfo)で取得できる [SYSTEM\_INFO 構造体](https://learn.microsoft.com/ja-jp/windows/win32/api/sysinfoapi/ns-sysinfoapi-system_info) の `dwAllocationGranularity` メンバで確認できる。

`dwSize` は、確保するメモリの範囲のサイズ (バイト数) を指定する。

`flAllocationType` は、メモリ割り当ての種類を指定する。
`MEM_RESERVE | MEM_COMMIT` を指定することで、仮想メモリ上の範囲を予約し、同時にその範囲に実際の記憶域を割り当てることができる。

`flProtect` は、確保するメモリに対するアクセス権限を指定する。
`PAGE_READWRITE` を指定することで、読み書きができるようになる。

実験を行った結果、今回確保するメモリの範囲の確保は32ビットアプリケーションではうまくいかなかったが、**64ビットアプリケーションではうまくいった。**
メモリの範囲の確保に成功したら、該当する範囲のアドレスであればアドレスを直接指定してアクセスができる。
たとえば、

```c
#define HANDLE_STANDARD 0x4ecb7c
```

のようにアドレスを定数として定義し、

```c
*(volatile unsigned char*)HANDLE_STANDARD
```

のようにして指定のアドレスを持つ1バイトにアクセス (読み書き) することができる。
ここに自作アプリケーションから書き込みを行っても変換器により上書きされ、書き込んだ値が変換器に伝わるわけではないが、無効な値を書き込んでおくことで変換器との連携がとれているか (変換器から有効な値が書き込まれるか) の判定を行うことができる。

## ウィンドウを作成する

自作アプリケーションを変換器から検出してもらうため、指定のクラス名を持ったウィンドウを作成する。

まず、[WNDCLASSEX 構造体](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/ns-winuser-wndclassexa)にクラス名を含むウィンドウクラスの情報を格納し、[RegisterClassEx 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-registerclassexa)でウィンドウクラスを登録する。

```c
const char* windowClassName = "Dengo3";
WNDCLASSEXA wndClass;
wndClass.cbSize = sizeof(wndClass);
wndClass.style = 0;
wndClass.lpfnWndProc = wndProc;
wndClass.cbClsExtra = 0;
wndClass.cbWndExtra = 0;
wndClass.hInstance  = hInstance;
wndClass.hIcon = NULL;
wndClass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndClass.hbrBackground = (HBRUSH)COLOR_WINDOW;
wndClass.lpszMenuName = NULL;
wndClass.lpszClassName = windowClassName;
wndClass.hIconSm = NULL;
ATOM wndClassAtom = RegisterClassExA(&wndClass);
```

次に、登録したクラス名を用いて [CreateWindow 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-createwindowa)でウィンドウを作成する。

```c
const char* windowTitle = "DenConv receiver";
HWND createdWindow = CreateWindowA(
	windowClassName,
	windowTitle,
	(WS_OVERLAPPEDWINDOW | WS_VISIBLE) & ~(WS_THICKFRAME | WS_MAXIMIZEBOX),
	CW_USEDEFAULT,
	CW_USEDEFAULT,
	300,
	300,
	NULL,
	NULL,
	hInstance,
	NULL
);
```

ウィンドウの位置は自動とし、サイズを指定している。
また、ウィンドウスタイルは一旦 `WS_OVERLAPPEDWINDOW` (基本的なスタイル全部入り) を指定し、`WS_THICKFRAME` (サイズ変更可能) および `WS_MAXIMIZEBOX` (最大化可能) を後から抜くことでサイズ固定のウィンドウを作成している。
なお、`WS_VISIBLE` も指定しないとウィンドウが表示されない。

## マスコンとブレーキの情報をポーリングする

タイマーを設定し、変換器が書き込むマスコンとブレーキの情報をポーリングする。

まず、ウィンドウプロシージャ内に情報を管理する用の変数を用意する。
`Prev` がつく変数を (変化したことを検出するための) 前回の値に、`Current` がつく変数を最新の値に用いる。
`static` を指定することで、ウィンドウプロシージャの呼び出しを超えて情報を保持する。

```c
static int standardHandlePrev, standardBrakePrev;
static int standardHandleCurrent, standardBrakeCurrent;
```

ウィンドウの作成時に `WM_CREATE` メッセージが来るので、このメッセージが来たら前回の値を初期化し、ポーリング用のタイマーをセットする。
前回の値は、初回の処理が必ず行われるよう、1バイトの整数としてはあり得ない値に設定しておく。
[SetTimer 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-settimer)の第4引数に `NULL` を指定することで、だいたい指定した時間間隔で `WM_TIMER` メッセージを送ってくれるようになる。

```c
standardHandlePrev = 300;
standardBrakePrev = 300;
SetTimer(hWnd, 0, 20, NULL);
```

`WM_TIMER` メッセージが来たら、最新の値を取得し、前回の値と比較する。
最新の値が前回の値と異なっていたら、前回の値を更新し、新しい値を用いた処理を行う。
今回は、[InvalidateRect 関数](https://learn.microsoft.com/ja-jp/windows/win32/api/winuser/nf-winuser-invalidaterect)を呼び出し、描画の更新を行う。

```c
standardHandleCurrent = *(volatile unsigned char*)HANDLE_STANDARD;
standardBrakeCurrent = *(volatile unsigned char*)BRALE_STANDARD;
if (standardHandleCurrent != standardHandlePrev ||
standardBrakeCurrent != standardBrakePrev) {
	standardHandlePrev = standardHandleCurrent;
	standardBrakePrev = standardBrakeCurrent;
	InvalidateRect(hWnd, NULL, FALSE);
}
```

# 完成したアプリケーション

<details><summary>ソースコード</summary>

```c:receiver.c
#include <Windows.h>

#define ALLOCATE_MIN 0x4e0000
#define ALLOCATE_MAX 0x5a0000

#define HANDLE_STANDARD 0x4ecb7c
#define BRALE_STANDARD 0x4ecb20
#define HANDLE_AUTHENTIC 0x5029dc
#define BRAKE_AUTHENTIC 0x502980
#define HANDLE_TRIAL 0x4e602c
#define BRAKE_TRIAL 0x4e5fd0

#define GUIDE_MESSAGE "\x81\x75\x90\xa7\x8c\xe4\x82\xb7\x82\xe9\x83\x5c\x83\x74\x83\x67\x81\x76\x82\xc5"
#define GUIDE_MESSAGE2 "\x81\x75\x92\xca\x8b\xce\x95\xd2\x81\x76\x82\xf0\x91\x49\x91\xf0\x82\xb5\x82\xc4\x82\xad\x82\xbe\x82\xb3\x82\xa2"
#define GUIDE_MESSAGE3 "\x28\x8f\x89\x94\xc5\x81\x45\x96\x7b\x8a\x69\x94\xc5\x81\x45\x91\xcc\x8c\xb1\x94\xc5\x91\xce\x89\x9e\x29"

#define TEXT_STANDARD "\x8f\x89\x94\xc5"
#define TEXT_AUTHENTIC "\x96\x7b\x8a\x69\x94\xc5"
#define TEXT_TRIAL "\x91\xcc\x8c\xb1\x94\xc5"

#define TEXT_HANDLE "\x83\x6e\x83\x93\x83\x68\x83\x8b"
#define TEXT_BRAKE "\x83\x75\x83\x8c\x81\x5b\x83\x4c"

void stringOut(HDC hDC, int x, int y, const char* str) {
	TextOutA(hDC, x, y, str, lstrlenA(str));
	
}

void drawInfo(HDC hDC, int x, int y, const char* label1, const char* label2, int value) {
	char buf[1024];
	wsprintfA(buf, "0x%02X", value);
	stringOut(hDC, x, y, label1);
	stringOut(hDC, x + 100, y, label2);
	stringOut(hDC, x + 200, y, buf);
}

LRESULT WINAPI wndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
	static int standardHandlePrev, standardBrakePrev;
	static int authenticHandlePrev, authenticBrakePrev;
	static int trialHandlePrev, trialBrakePrev;
	static int standardHandleCurrent, standardBrakeCurrent;
	static int authenticHandleCurrent, authenticBrakeCurrent;
	static int trialHandleCurrent, trialBrakeCurrent;
	switch (uMsg) {
		case WM_CREATE:
			standardHandlePrev = 300;
			standardBrakePrev = 300;
			authenticHandlePrev = 300;
			authenticBrakePrev = 300;
			trialHandlePrev = 300;
			trialBrakePrev = 300;
			SetTimer(hWnd, 0, 20, NULL);
			return 0;
		case WM_TIMER:
			standardHandleCurrent = *(volatile unsigned char*)HANDLE_STANDARD;
			standardBrakeCurrent = *(volatile unsigned char*)BRALE_STANDARD;
			authenticHandleCurrent = *(volatile unsigned char*)HANDLE_AUTHENTIC;
			authenticBrakeCurrent = *(volatile unsigned char*)BRAKE_AUTHENTIC;
			trialHandleCurrent = *(volatile unsigned char*)HANDLE_TRIAL;
			trialBrakeCurrent = *(volatile unsigned char*)BRAKE_TRIAL;
			if (standardHandleCurrent != standardHandlePrev ||
			standardBrakeCurrent != standardBrakePrev ||
			authenticHandleCurrent != authenticHandlePrev ||
			authenticBrakeCurrent != authenticBrakePrev ||
			trialHandleCurrent != trialHandlePrev ||
			trialBrakeCurrent != trialBrakePrev) {
				standardHandlePrev = standardHandleCurrent;
				standardBrakePrev = standardBrakeCurrent;
				authenticHandlePrev = authenticHandleCurrent;
				authenticBrakePrev = authenticBrakeCurrent;
				trialHandlePrev = trialHandleCurrent;
				trialBrakePrev = trialBrakeCurrent;
				InvalidateRect(hWnd, NULL, FALSE);
			}
			return 0;
		case WM_PAINT:
			{
				PAINTSTRUCT ps;
				HDC hDC = BeginPaint(hWnd, &ps);
				FillRect(hDC, &ps.rcPaint, (HBRUSH)(COLOR_WINDOW + 1));
				stringOut(hDC, 20, 10, GUIDE_MESSAGE);
				stringOut(hDC, 20, 35, GUIDE_MESSAGE2);
				stringOut(hDC, 20, 60, GUIDE_MESSAGE3);
				drawInfo(hDC, 20, 90, TEXT_STANDARD, TEXT_HANDLE, standardHandleCurrent);
				drawInfo(hDC, 20, 115, TEXT_STANDARD, TEXT_BRAKE, standardBrakeCurrent);
				drawInfo(hDC, 20, 145, TEXT_AUTHENTIC, TEXT_HANDLE, authenticHandleCurrent);
				drawInfo(hDC, 20, 170, TEXT_AUTHENTIC, TEXT_BRAKE, authenticBrakeCurrent);
				drawInfo(hDC, 20, 200, TEXT_TRIAL, TEXT_HANDLE, trialHandleCurrent);
				drawInfo(hDC, 20, 225, TEXT_TRIAL, TEXT_BRAKE, trialBrakeCurrent);
				EndPaint(hWnd, &ps);
			}
			return 0;
	}
	return DefWindowProc(hWnd, uMsg, wParam, lParam);
}

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd) {
	SYSTEM_INFO systemInfo;
	GetSystemInfo(&systemInfo);
	UINT_PTR requestAddress = (UINT_PTR)ALLOCATE_MIN / systemInfo.dwAllocationGranularity * systemInfo.dwAllocationGranularity;
	LPVOID allocatedAddress = VirtualAlloc(
		(LPVOID)requestAddress,
		(UINT_PTR)ALLOCATE_MAX - requestAddress,
		MEM_RESERVE | MEM_COMMIT,
		PAGE_READWRITE
	);
	if (!allocatedAddress) {
		MessageBoxA(NULL, "failed to allocate memory for receiving information", "error", MB_OK | MB_ICONSTOP | MB_TOPMOST);
		return 1;
	}
	*(volatile unsigned char*)HANDLE_STANDARD = 0xff;
	*(volatile unsigned char*)BRALE_STANDARD = 0xff;
	*(volatile unsigned char*)HANDLE_AUTHENTIC = 0xff;
	*(volatile unsigned char*)BRAKE_AUTHENTIC = 0xff;
	*(volatile unsigned char*)HANDLE_TRIAL = 0xff;
	*(volatile unsigned char*)BRAKE_TRIAL = 0xff;

	const char* windowClassName = "Dengo3";
	const char* windowTitle = "DenConv receiver";
	WNDCLASSEXA wndClass;
	wndClass.cbSize = sizeof(wndClass);
	wndClass.style = 0;
	wndClass.lpfnWndProc = wndProc;
	wndClass.cbClsExtra = 0;
	wndClass.cbWndExtra = 0;
	wndClass.hInstance  = hInstance;
	wndClass.hIcon = NULL;
	wndClass.hCursor = LoadCursor(NULL, IDC_ARROW);
	wndClass.hbrBackground = (HBRUSH)COLOR_WINDOW;
	wndClass.lpszMenuName = NULL;
	wndClass.lpszClassName = windowClassName;
	wndClass.hIconSm = NULL;
	ATOM wndClassAtom = RegisterClassExA(&wndClass);
	if (!wndClassAtom) {
		MessageBoxA(NULL, "failed to register window class", "error", MB_OK | MB_ICONSTOP | MB_TOPMOST);
		return 1;
	}
	HWND createdWindow = CreateWindowA(
		windowClassName,
		windowTitle,
		(WS_OVERLAPPEDWINDOW | WS_VISIBLE) & ~(WS_THICKFRAME | WS_MAXIMIZEBOX),
		CW_USEDEFAULT,
		CW_USEDEFAULT,
		300,
		300,
		NULL,
		NULL,
		hInstance,
		NULL
	);
	if (!createdWindow) {
		MessageBoxA(NULL, "failed to create window", "error", MB_OK | MB_ICONSTOP | MB_TOPMOST);
		return 1;
	}
	BOOL getMessageResult;
	MSG message;
	while ((getMessageResult = GetMessage(&message, createdWindow, 0, 0)) && getMessageResult != -1) {
		TranslateMessage(&message);
		DispatchMessage(&message);
	}
	return 0;
}
```

</details>

clang 17.0.6 を用い、以下のコマンドでコンパイルを行った。

```
clang -o receiver_x64 -m64 -O2 receiver.c -mwindows
```

以下のように、変換器から情報を受け取れている。
マスコンの情報は、0～5の整数で送られるようである。(0が切、5が最大)
ブレーキの情報は、0～9の整数で送られるようである。(0が解除、8が常用最大、9が非常)

![変換器と自作アプリケーションの連携](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/99656/a5bc0fe9-ff98-7d32-0c3a-e4f6de012925.png)

(この画像には今回作成したアプリケーションのバイナリをzipアーカイブとして埋め込んであり、画像のデータをそのままzipアーカイブとして展開可能である)

# まとめ

* 変換器を ILSpy で逆コンパイルし、ある関数の中身を読むことで、変換器が制御するソフトを検出するために用いるウィンドウクラス名と、変換器がマスコンやブレーキの情報を制御するソフトのメモリ空間に書き込む際の書き込み先アドレスがわかった
* 調査結果に基づいて自作アプリケーションのウィンドウクラス名を設定し、書き込まれるメモリを確保しておくことで、自作アプリケーションで変換器からマスコンやブレーキの情報を受け取ることができた

# 今後の課題

* 渡される情報を利用する有用なアプリケーションを開発する
* 制御するソフトから変換器に渡す (変換器が読み取る) 情報について調査を行い、意味のある情報を渡せるようにする