Dirt Rally – Joystick zum Schalten

Dirt Rally ist eine bekannte Autorennsimulation. Die teils sehr anspruchsvollen Kurse zu meistern ist ohne Lenkrad quasi unmöglich. Praktischerweise hatte ich ein altes Lenkrad herumstehen um ein wenig Dirt Rally zu spielen. Mein altes Lenkrad besteht nur aus dem Steuerrad und zwei Fußpedalen, es kommt daher ohne einen zusätzlichen Schalthebel aus. Praktischer und realistischer wäre es doch wenn man sich irgendwie einen Schalthebel aus einem alten Joystick bauen kann! Und genau dieses Projekt mit all seinen auftretenden Problemen soll in diesem Blogpost beschrieben werden.

Die Grundidee besteht aus einem externen Programm welches im Hintergrund mit dem Joystick kommuniziert. Sobald eine Schaltbewegung (= Joystick nach vorne/hinten und wieder in die Ruhelage) detektiert wird soll das Programm den Tastendruck zum Hoch- bzw. Runter schalten an Dirt Rally senden. Zum Auslesen des Joysticks gibt es ein nettes Tutorial mit dem Managed DirectX Framework mit dem das gewünschte Verhalten schnell implementiert werden konnte. Dabei wird die Y-Achse des Joysticks abgerufen ob sich der Wert auf 0 (= Vorne) oder 65535 ( = Hinten) befindet. Falls dieser Zustand erreicht wurde und wieder verlassen wird findet das „Schalten“ statt.

Joystick1

Nun ging es nur noch darum die Taste zum Hoch- bzw. Runterschalten an Dirt zu senden. Dazu gibt es verschiedene Wege wie z.B. SendKeys unter .NET oder die nativen Methoden wie SendMessage/SendInput . Problematisch war dass selbst nach mehreren Stunden des Experimentierens keine Tasteneingabe von diesem externen Programm an Dirt Rally gelangte. Ich weiß bis heute nicht warum das Programm nicht auf die Signale reagierte. Die Vermutung ist dass direkt mit dem Tastaturtreiber kommuniziert wird. Aber dieses Verhalten für den Blogpost ist es eigentlich sehr positiv denn sonst wäre er nie entstanden. Man musste sich also einen anderen Weg überlegen Tastatureingaben an Dirt Rally zu schicken.

Um Tastatureingaben an einen Prozess zu senden muss sich dieser in der Regel im Vordergrund befinden. Viele MMO-Bots können dennoch im Hintergrund laufen und bedienen sich dabei einer Hooking-Technik die wir auch einsetzen werden. Dabei hookt man sich in die Funktion zum Abrufen des aktuellen Joystick/Lenkrad/Tastaturzustands der DirectInput-Klassen und simuliert das Drücken von Tasten indem man den abgerufenen Zustand verändert bevor er wieder dem Spiel übergeben wird. Konkret es dabei um die GetDeviceState-Methode welche sich universal auf alle DirectX Eingabegeräte bezieht. Um diese Methode in eine Umleitung zu unserem eigenen Code zu schicken müssen wir den Funktionszeiger ändern, der sich wie bei jedem Interface in der VTable befindet. Über diese VTables und das Hooking wurde bereits ausführlich im Siedler 3 Post zu Interfaces berichtet, das dort beschriebene Wissen wird im folgenden vorausgesetzt.

Ziel ist es an das DirectInputDevice zu kommen um die VTable des Objekts zu verändern. Daher fangen wir das InputDevice am besten direkt da ab wo es erzeugt wird und zwar in der Methode DirectInput8Create .

BOOL APIENTRY DllMain( HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved)
{
	switch(ul_reason_for_call)
	{
	case DLL_PROCESS_ATTACH:
		oDirectInput8Create = (tDirectInput8Create)DetourFunction((PBYTE) DetourFindFunction("DINPUT8.DLL","DirectInput8Create"),(PBYTE)hkDirectInput8Create);

Mit dem obigen Code hooken wir die DirectInput8Create-Methode und leiten sie auf eine Funktion in der DLL „hkDirectInput8Create“ um. Das DirectInput Objekt selbst besitzt allerdings noch nicht die Method die wir brauchen, es ist vielmehr ein Manager mit dem wir konkrete Eingabegeräte erzeugen können. Wir interessieren uns also vielmehr für die über diesen Manager erzeugten Eingabegeräte.  Die VTable-Methode CreateDevice welche die fertigen Eingabegeräte zurück liefert wird also gehookt um das Eingabegerät abzufangen. Diese befindet sich an dritter Stelle im DirectInput Interface, hat also den Adressenoffset 0x0C.

HRESULT APIENTRY hkDirectInput8Create(HINSTANCE hinst, DWORD dwVersion, REFIID riidltf, LPVOID* ppvOut, LPUNKNOWN punkOuter)
{
	// Call original Create Function
	HRESULT hRes = oDirectInput8Create( hinst,  dwVersion,  riidltf, ppvOut, punkOuter);
	// Resolve VTABLE Pointer (two times deref)
	DWORD dwFuncTable = (DWORD)*((DWORD*)*ppvOut);
	// In case the memory region is read only make it writeable
	DWORD oldprot;
	VirtualProtect((LPVOID)dwFuncTable, 0x10, PAGE_EXECUTE_READWRITE, &oldprot);
 
	// Is the VTable Entry 0x0C (CreateDevice) already hooked ?
	if((DWORD)hkDirectInput8CreateDevice == *((DWORD*)(dwFuncTable + 0x0C))) return hRes;
 
	// Save the original VTable entry and replace it
	oDirectInput8CreateDevice = (tDirectInput8CreateDevice)*((DWORD*)(dwFuncTable + 0x0C));
	*((DWORD*)(dwFuncTable + 0x0C)) = (DWORD)hkDirectInput8CreateDevice;
 
	return hRes;
}

Jetzt wird im Programm gewartet bis die angeschlossenen Eingabegeräte erzeugt werden. Die CreateDevice-Methode bekommt im Parameter eine sog. GUID übergeben, also Zahlenwerte die eine eindeutige Identifizierung des zu erzeugenden Geräts möglich machen. Dadurch kann man eigentlich zwischen Joystick und Lenkrad anhand der GUID unterscheiden. Problematisch ist, dass das erzeugte Eingabegerät jeweils in die selbe Variable gespeichert wird. Konkret ausgedrückt: Es kann nicht ohne weiteres zwischen dem Eingabegerät für Joystick und Lenkrad unterschieden werden, da diese in die selbe (temporäre) Variable gespeichert werden. Wir hooken also erstmal die GetDeviceState Methode.

HRESULT WINAPI hkDirectInput8CreateDevice(DWORD d1, REFGUID rguid,LPDIRECTINPUTDEVICE * lplpDirectInputDevice,LPUNKNOWN pUnkOuter)
{
	// Call original CreateDevice function
	HRESULT hr = oDirectInput8CreateDevice(d1,rguid, lplpDirectInputDevice, pUnkOuter);
	// Resolve VTABLE Pointer (two times deref)
	DWORD dwKeybTable = *(DWORD*)(*(DWORD*)lplpDirectInputDevice);
	// In case the memory region is read only make it writeable
	DWORD oldprot;
	VirtualProtect((LPVOID)dwKeybTable, 0x2C, PAGE_EXECUTE_READWRITE, &oldprot);
 
	add_log("Hook call in CreateDevice with rguid: %d", rguid.Data1);
	// Is the VTable Entry 0x024 (GetDeviceState) already hooked ?
	if((DWORD)hkDirectInput8GetDeviceState == *((DWORD*)(dwKeybTable+0x24))) return hr;
	// Save the original VTable entry and replace it
	oDirectInput8GetDeviceState = (tDirectInput8GetDeviceState)*((DWORD*)(dwKeybTable + 0x24));
	*((DWORD*)(dwKeybTable + 0x24)) = (DWORD)hkDirectInput8GetDeviceState;

Dieser Code unterscheidet sich nur unwesentlich vom obigen Code. Auch hier wird die VTable gehookt, aber dieses mal mit der wirklich interessanten Funktion „GetDeviceState“ welche sich an Offset 0x24 befindet.

Damit wird unser Code jedes mal aufgerufen wenn Dirt Rally den Zustand von einem der beiden Eingabegeräte abfragt wird. Nur wissen wir nicht ob aktuell der Zustand vom Lenkrad oder vom Joystick abgerufen wird. Wenn wir später die Eingaben verändern wollen, so wollen wir das nur für das Lenkrad machen. Daher ist es nötig den Joystick vom Lenkrad zu unterscheiden. Der Joystick besitzt einen „Schubregler“ welchen das Lenkrad nicht hat. Die Idee ist nun dass wenn der Joystick abgerufen wird die abgerufen Daten einen Wert für diesen Schubregler besitzen. Im Falle des Lenkrads ist dieser Schubreglerwert einfach 0. Nachdem man einmalig dieser Unterscheidung gemacht hat sind die Zeiger der beiden Eingabegeräte bekannt und man kann in Zukunft unterscheiden welches Gerät aktuell angesprochen wird.

HRESULT WINAPI hkDirectInput8GetDeviceState(DWORD d1,  DWORD cbData, LPVOID lpvData )
{
	HRESULT hr = oDirectInput8GetDeviceState(d1,cbData, lpvData);
 
	// FIND THE RIGHT POINTERS
	BYTE* rawData = (BYTE*)lpvData;
	if (pJoystickDevice == 0x00 || pWheelDevice == 0x00)
	{
		add_log("Searching for right pointers");
		if (rawData[24] == 0xff) // We have the joystick ...
		{
			add_log("Joystick device is the one with pointer: 0x%x", d1);
			pJoystickDevice = d1;
		}
		else // we have the wheel
		{
			add_log("Wheel device is the one with pointer: 0x%x", d1);
			pWheelDevice = d1;
		}
 
		return hr;
	}

Von GetDeviceState wird eine DIJOYSTATE-Struktur zurückgeliefert. Diese enthält die Zustände der Achsen, als auch die Zustande der Slider und Buttons. Im Falle des Joysticks interessieren wir uns für den Schubregler, welcher an Offset 24 (rglSlider) zu finden ist. Falls dieser am Anschlag ist, hat die Struktur an dieser Stelle eine 0xff als Wert. Andernfalls handelt es sich um das Lenkrad.

Dirt Rally wurde so eingestellt, dass mit den hinteren Lenkradknöpfen Hoch- bzw. Runtergeschaltet werden kann. Durch mehrmaliges abrufen und ausgeben der DIJOYSTATE-Struktur für das Lenkrad wurden diese hinteren Knöpfe zu Button 5 und 6 ermittelt. Nur wie sagt man dem Spiel nun, dass einer der Buttons gedrückt ist ? Der MSDN-Dokumentation entnehmen wir: „The high-order bit of the byte is set if the corresponding button is down“. Übersetzt heißt dieser Satz: Wenn das MSB (Most Significant Bit) gesetzt ist: 10000000b = 0x80 . Wenn man den Wert des Buttons auf 0x80 setzt wird er gedrückt, beim ändern des Werts auf 0 wird der Button wieder „losgelassen“. Zum drücken reicht also:

// Parse the retrieved data to the structure
DIJOYSTATE* joyState = (DIJOYSTATE *)lpvData;
if (d1 == pWheelDevice)
{
   // Set gear up pressed
   joyState->rgbButtons[4] = 0x80;
}

Damit können wir die Buttons des Lenkrads vom Code aus „drücken“. Jetzt muss das Schalten nur noch über den Joystick gesteuert werden. Und die Achsendaten des Joysticks haben wir ja glücklicherweise auch schon bereits abgerufen und müssen nur noch den Code vom anfänglichen C#-Programm implementieren:

// The Joystick is queried
if (d1 == pJoystickDevice)
{
    // Y-Axis is the front/back movement
    add_log("Joystick y pos: %d", joyState->lY);
 
    // Is at front ?
    if (joyState->lY == 0)
    {
        IsGearingUp = true;
        add_log("Gearing up , joystick is at front");
    }
    // Not at front (anymore)
    else
    {
        if (IsGearingUp)
        {
            IsGearingUp = false;
            add_log("Joystick released from gear up, sending gear up");
            SendKeyDInput(KEY_GEARUP, 100);
        }
    }
 
    // Is at back?
    if (joyState->lY == 65535)
    {
        IsGearingDown = true;
        add_log("Gearing down , joystick is at back");
    }
    // Not at back (anymore)
    else
    {
        if (IsGearingDown)
        {
            IsGearingDown = false;
            add_log("Joystick released from gear down, sending gear down");
            SendKeyDInput(KEY_GEARDOWN, 100);
        }
    }
}

Damit ist durch Antippen des Joysticks nach Vorne bzw. Hinten ein Gangwechsel möglich. Und das Spiel macht direkt noch mehr Spass !

0

So, what do you think ?