Имеется игра In Verbis Virtus с необычной механикой — творить заклинания с помощью микрофона.

Это не симулятор Амаяка Акопяна, это головоломка от первого лица с нетипичным управлением.
Для этого в игре используется библиотека распознавания речи Sphinx.

Задумка выглядит интересной, но реализация вышла так-себе (распознавание очень часто промахивается), да и кастовать после первых 20 минут откровенно надоедает.
О том, как это выглядит со стороны — вообще молчу.

Разработчики, к сожалению, не оставили возможности управления заклинаниями с клавиатуры, и я решил это исправить.

Первой мыслью было внести изменения в библиотеку Sphinx, поскольку она open-source. Однако я обнаружил, что существует куча версий этой библиотеки.

Попробовав три из них (примерно соответствующие времени выхода игры), я так и не нашёл нужную, поскольку в каждой были какие-либо отличия (как минимум по набору экспортируемых функций).

Поэтому я решил сделать враппер поверх оригинальной библиотеки из игры.

Для этого я воспользовался подходом, предложенным в статье Generating .DLL Wrappers.

Суть его в том, что можно обернуть любую библиотеку безо всякого знания о параметрах и типах экспортируемых функций, достаточно только их имён (которые можно извлечь хоть текстовым редактором).

Список экспорта создаётся с помощью def-файла вида:

EXPORTS
func1=_func1 @1
func2=_func2 @2

Сами обёртки функций имеют вид:

_declspec(naked) void _func1()
{
	__asm jmp dword ptr [procs + 1 * 4];
}

Таким образом устраняются проблемы с передачей аргументов и возвратом значений оригинальных функций.

Для начала требовалось немного реверс-инжиниринга. Я создал враппер с единственным дополнением — логированием имён вызываемых функций.

Так я определил где, когда и как отрабатывает основная логика библиотеки.

Выяснилось, что сначала набирается некоторое количество сырых отсчётов с микрофона функцией ps_process_raw(), а затем само решение принимается в функции ps_get_hyp().
Позже (слишком поздно) я всё-таки подумал, что сначала стоило бы взглянуть на документацию Sphinx (где всё это было описано).

Было решено добавить в функцию ps_process_raw() определение состояния клавиш, которые будут отвечать за заклинания.

Для этого нужно эти клавиши назначить. Сделаем это в DllMain(), вместе с получением адресов оригинальных функций. Вот эдак:

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
	HINSTANCE hinst_dll;

	if (fdwReason == DLL_PROCESS_ATTACH)
	{
		hinst_dll = LoadLibraryA("pocketsphinx_orig.dll");
		if (!hinst_dll)
			return 0;

		for (i = 0; i < 93; i++)
			procs[i] = GetProcAddress(hinst_dll, import_names[i]);

		for (i = 0; i < 256; i++)
		{
			_itoa(i, &buf[i][0], 10);
			GetPrivateProfileStringA("main", &buf[i][0], 0, &buf[i][0], MAX_PATH, ".\\settings.ini");
		}
		i = 0;
	}
	else if (fdwReason == DLL_PROCESS_DETACH)
		FreeLibrary(hinst_dll);

	return 1;
}

Файл settings.ini имеет вид:

[main]
49=String 1
50=String 2

Итого, в массиве buf будут лежать строки, соответствующие заклинаниям. Причём лежать будут по индексам, соответствующим нужным клавишам.

Определять состояние клавиш будем так:

void find_key()
{
	if(!i)
	{
		for (i = 0; i < 256; i++)
			if (buf[i][0])
				if (GetAsyncKeyState(i) >> 1)
				{
					i = (int)&buf[i][0];
					return;
				}
		if (i == 256)
			i = 0;
	}
}

Обёртка функции ps_process_raw() будет иметь вид:

 _declspec(naked) void _ps_process_raw()
{
	find_key();
	__asm jmp dword ptr [procs + 78 * 4];
}

То есть если в то время, когда надо кастовать в микрофон, пользователь нажал клавишу — в глобальной переменной i сохранился указатель на строку, соответствующую нажатой клавише.

Приготовления закончены, пора реализовывать основной функционал.

Требуется определить, была ли нажата пользователем кнопка заклинания, и если да — изменить возвращаемое значение в функции ps_get_hyp().

Для этого потребуется чуть-чуть манипуляций со стеком:

 _declspec(naked) void _ps_get_hyp()
{
	static unsigned int return_address;

	_asm
	{
		//save return address
		push eax
		mov eax, dword ptr [esp+4]
		mov return_address, eax
		pop eax
		
		//call original ps_get_hyp
		add esp, 4
		call dword ptr [procs + 22 * 4]
		sub esp, 4

		//replace result (if key was pressed)
		cmp i, 0
		je end
		mov eax, i
		xor ecx,ecx
		mov i, ecx

	end:
		//restore return address
		push eax
		mov eax, return_address
		mov dword ptr [esp+4], eax
		pop eax

		ret
	}
}

Основной функционал находится в куске с комментарием «replace result (if key was pressed)».
Если в глобальной переменной лежит указатель — подменяем возвращаемый результат и обнуляем глобальную переменную.

А если нет — то оставляем всё без изменений.

Таким образом, можно продолжать кастовать через микрофон, а можно и кнопками (они имеют приоритет). Цель достигнута.

Да, в решении есть кривые моменты.

Например передача указателя через глобальную переменную, да ещё и именуемую i (решил использовать её повторно после инициализации в DllMain).

Лазить по чужому стеку тоже как-то не принято (не придумал как сделать иначе).

Тем не менее, решение вполне рабочее. Основного кода менее 100 строк, по большей части всё тривиально.

Исходник
def-файл
Бинарник + файл настроек

Установка:

  • В папке \In Verbis Virtus\Binaries\Win32\ переименовать оригинальный pocketsphinx.dll в pocketsphinx_orig.dll
  • Положить рядом враппер pocketsphinx.dll
  • В папку \In Verbis Virtus\Binaries\Win32\UserCode положить settings.ini

Критика и предложения принимаются.

Комментарии (4)


  1. DrMefistO
    28.01.2019 21:35

    Неплохо!

    Конечно, лучше всё таки посмотреть количество принимаемых аргументов с помощью какого-нибудь декомпилятора типа иды, и написать через LoadLibrary/GetProcAddress, дёргая функции оригинальной дллки, передавая им то же самое, но не заморачиваясь со стеком. Заодно декомпилятор скажет cdecl там, stdcall, или какой-нибудь fastcall.


    1. Boroda1 Автор
      28.01.2019 22:23

      Продумывал такой вариант, однако не всегда можно автоматически определить тип функции и её параметры. Если она экспортируется в виде "?func1@a@@AAEXH@Z" — тогда да. А если экспортируется так же, как в получившемся у меня бинарнике — то, насколько я знаю, только вручную, анализируя ассемблер и поведение стека.
      В моём враппере 93 функции, из них залезть потребовалось только в 2. То есть 91 функцию мне пришлось бы разбирать вхолостую.


      1. DrMefistO
        28.01.2019 23:50

        Декомпилятор способен определить количество входных аргументов вполне без проблем. Они теперь умные, особенно у IDA.
        В интернете найти всё необходимое очень просто, а для того, чтобы воспользоваться, особенно если есть понимание что такое ассемблерный код, вообще много мозгов не потребуется)


      1. pwl
        29.01.2019 01:24

        Есть способ проще:
        github.com/cmusphinx/pocketsphinx/blob/master/src/libpocketsphinx/pocketsphinx.c

        int
        ps_process_raw(ps_decoder_t *ps,
                       int16 const *data,
                       size_t n_samples,
                       int no_search,
                       int full_utt)