Pull to refresh

Локализация VN на примере Hoshizora no Memoria

Reading time 6 min
Views 5.7K
Только не говорите, что гики не читают VN. Итак, есть одна визуалка (профиль на vndb).
Весьма неплохая, кстати, рейтинг 8.06, советую почитать тем, кого не смущает некоторое количество хентая.
Ну, в общем, не об этом статья.
Есть английский патч, есть китайский. Русского нет. Несправедливо. Попробуем это исправить.
Понадобится:
  • WinHex. Куда же без него.
  • MadEdit. Опенсорсная альтернатива Винхексу, который я так и не смог заставить нормально отображать Shift-JIS. Вообще, согласно Вики, в мэде самая богатая поддержка языков и кодировок.
  • Для сбора локализации программка на C, поэтому какая-нибудь IDE. Я использовал Visual Studio, хотя особо специфичных вещей там нет, наверное, любая пойдет.



Исследование


Начнем новую игру, видим первое предложение - "俺は彼女が好きだった". В английском варианте — «I liked her». Запомним, это нам еще пригодится. Закрываем игру, идем в папку. Видим там пару подозрительных файлов - «Memoria.hcb» и «MemoriaEN.hcb» соответственно. Начнем пока с первого. Открываем хекс-редактором, видим первые 4 байта. На что-то весьма похожие. Сверяем с ..En.hcb, и убежаемся, что это ничто иное, как размер файла минус 2029 байт в Big Endian. Последние 2029 байт занимает код, который вызывается при закрытии игры (плавное затенение изображения и вывод прощального текста). В принципе, могла бы возникнуть необходимость это поменять, но в таком варианте локализации не требуется.

Теперь ищем фразу «I liked her».



Если выписать подряд несколько строк, становится видна некоторая закономерность:
  • Все строки начинаются на 0EXX, где XX — размер строки в байтах, включая нуль-символ
  • После строки — 06 XX XX XX XX. Эти 4 байта явно возрастают от строки к строке.

Предположим, что это опять какой-то оффсет. Что ж, проследуем по нему. Оказываемся сразу после фразы "俺は彼女が好きだった". Ну, почти. Первые пара символов превратились в какую-то кашу.



Первые 5 байт заменены на аналогичные 06 XX XX XX XX! По этому адресу как раз и скрывается начало «I liked her», а точнее — 0E. После строки начинается какая-то магия. Опытным путем было установлено, что последовательность 08 08 08 02 E3 89 04 00 02 7F 98 04 00 ждет клика мыши или нажатия клавиши — подтверждение перехода на следующую строку. Это еще пригодится.

Если обобщить, получается следующая структура:
В оригинальном скрипте:
0E<size, 1 байт><Текст, size байтов, в кодировке Shift-JIS ><13 байт, подтверждение, что играющий прочел текст>
<некоторые проверки, в зависимости от них <06 переход на следующую строку или на другую сюжетную ветку>>

В английском патче был использован классический трюк — если нужно вставить код в определенное место, но нельзя сбивать адресацию, то ставим jump на конец файла, там делаем свои дела, возвращаемся обратно.

Таким образом, 5 байт оригинальной строки меняются на <06><адрес чуть дальше исходного конца файла>
а там находится <0E><jump на ту 13-байтную проверку>

Выдирание текста


На скорую руку была написана утилитка, которая выдирает текст из скрипта
выдиратор
#include <conio.h>
#include <iostream>

void main()
{
	FILE *en;
	FILE *jp;
	FILE *out;
	en = fopen("MemoriaEN.hcb", "rb");
	jp = fopen("Memoria.hcb", "rb");
	out = fopen("out.txt", "w");
	fseek(en, 0x8c827, SEEK_SET);
	fseek(jp, 0x8c827, SEEK_SET);
	unsigned char todo_en[50];
	unsigned char todo_jp[50];
	unsigned char size_en;
	unsigned char size_jp;
	char str_en[255];
	char str_jp[255];
	unsigned int off_en;
	unsigned int off_en2;
	unsigned int off_en3;
	bool r = true;
	do 
	{
		fread(&todo_en, 1, 1, en);
		fread(&todo_jp, 1, 1, jp);
		if ((todo_en[0] )  != 6 || (todo_jp[0] ) != 14) break;

		fread(&size_jp, 1, 1, jp);
		fread(str_jp, size_jp, 1, jp);

		fread(&off_en, 1, 4, en);
		fseek(en, off_en, SEEK_SET);

		fread(&todo_en, 1, 1, en);
		if ((todo_en[0] )  != 14) break;
		fread(&size_en, 1, 1, en);
		fread(str_en, size_en, 1, en);

		//fwrite(str_jp, size_jp, 1, out);
		//fwrite((const char *) "\n", 1, 1, out);
		fwrite(str_en, size_en, 1, out);
		fwrite((const char *) "\n", 1, 1, out);
		fwrite((const char *) "\n", 1, 1, out);

		fread(&todo_en, 1, 1, en);
		if ((todo_en[0] )  != 6) break;

		fread(&off_en, 1, 4, en);
		if (off_en == 0x0045793e)
			break;
		off_en2 = ftell(en);
		fseek(en, off_en, SEEK_SET);
		fseek(jp, ftell(en), SEEK_SET);
		do
		{
			fread(&todo_jp, 1, 1, jp);
			fread(&todo_en, 1, 1, en);
			if (todo_en[0] == 6 &&  todo_jp[0] == 14) 
				break;
		} while (!feof(en));

		fseek(en, -1, SEEK_CUR);
		fseek(jp , ftell(en), SEEK_SET);
	} while (true);
	fclose(jp);
	fclose(en);
	fclose(out);
}


Код совершенно неоптимален, однако со своей работой справляется, за несколько секунд генерируя 2,5 Мегабайта(или Мибибайта?) текста. Если раскомментировать 2 строки в середине, соберется текст — японо — английская билингва.

Здесь я немного сжульничал, так как не стал подробно изучать формат проверок после текста, а адрес начала следующей строки получаю, сравнивая английский и японский скрипт.

Помещение текста обратно


Предположим, полученный текст мы как-то перевели, теперь неплохо бы его затолкать обратно.
Еще одна софтинка, код уже исправлен, описание ошибки — далее.

запихиватель
#include <conio.h>
#include <iostream>
unsigned char buff[0x7fffff];
unsigned int end;					//offset of russian strings
unsigned char s[1024];				
unsigned int retpos;				//return here after russian
unsigned char cl[13] = {0x08, 0x08,  0x08, 0x02 , 0xE3 , 0x89 , 0x04, 0x00, 0x02, 0x7F, 0x98, 0x04, 0x00};	//prompt for click
unsigned char ro = 250;				//max len of string
unsigned char todo_en;				//must be 0x06 or 0x14
unsigned char size_en;				//size of engish string
unsigned char jmp = 0x06;			//jumps to xx xx xx xx in LE
unsigned char stl = 0x0E;			//indicates new sring
int ts;								
int k;
unsigned char d;
int parts;
FILE *en;
FILE *trans;
FILE *text;
char str1[255];

void main()
{
	en = fopen("MemoriaENO.hcb", "rb");	//non-modifed english script
	text = fopen("text.txt", "r");		//translated strings
	trans = fopen("MemoriaEN.hcb", "wb");	//translated script

	rewind(trans);
	rewind(text);

	fseek(en, 0, SEEK_END);
	end = ftell(en);			//begin writing localization here

	rewind(en);

	fread(buff, end, 1, en);
	fwrite(buff, end, 1, trans);

	fseek(en, 0x45798e, SEEK_SET);

	do 
	{
		s[0] = '\0';
		ts = 0;
		do
		{
			fscanf(text, "%c", &s[ts]);
			ts++;
		} while (s[ts-1] != 0);
		fseek(text, 4, SEEK_CUR);
		//get new string, terminated with \0

		fseek(trans, ftell(en), SEEK_SET);
		fread(&todo_en, 1, 1, en);
		//if ((todo_en) != 14) break;

		fread(&size_en, 1, 1, en);
		fseek(en, size_en, SEEK_CUR);
		retpos = ftell(en);
		//english string begin + len = return here after russian

		fwrite(&jmp, 1, 1, trans);
		fwrite(&end, 4, 1, trans);
		//jump to the russian line

		fseek(trans, end, SEEK_SET);

		if (ts <= ro)
		{
			//if length of line < byte, just write it
			fwrite(&stl, 1, 1, trans);
			fwrite(&ts, 1, 1, trans);
			fwrite(s, ts, 1, trans);
			end += (ts + 2 + 5);
		} 
		else
		{
			//get maximum amount of words, which does not exceed byte
			//and write it
			end += (ts + 2 + 5);
			do
			{
				str1[0] = '\0';
				memcpy(str1, &s, ro);
				strrev(str1);
				d = strcspn(str1, " ");
				strrev(str1);
				str1[ro-d] = '\0';
				d = strlen(str1);
				memcpy(s, &s[d], ts - d);
				ts -= (d + 1);

				str1[d-1] = 0x00;
				fwrite(&stl, 1, 1, trans);
				fwrite(&d, 1, 1, trans);
				fwrite(&str1, d, 1, trans);
				fwrite(cl, 13, 1, trans);

				end += 15;
			} while (ts > ro);

			//write the remaining part of line
			s[ts] = 0x00;
			fwrite(&stl, 1, 1, trans);
			fwrite(&ts, 1, 1, trans);
			fwrite(&s, ts+1, 1, trans);
		} 
		fwrite(&jmp, 1, 1, trans);
		fwrite(&retpos, 4, 1, trans);
		//jump back to english

		fseek(en, 5, SEEK_CUR);
		if (retpos == 0x732aaf)
			break;
		//exit in case of last line

	} while (true);
	fclose(en);
	fclose(trans);
	fclose(text);
}



Очередной «шедевр» кода, конечно, но то, что нужно, делает, причем тоже за пару секунд.

Запускаем, собирается. Пытаемся поиграть с переводом. Первые несколько предложений нормально,
и вдруг — Runtime Error!
Снова берем хекс-редактор, ищем проблемное предложение. Вот оно что. На длину строки отведен 1 байт, перевод получился длиннее, в итоге размер переполняется, и игра считает, что текста не 300 байт, а 45. Соответственно, движок пытается «выполнить» как инструкцию какой-то символ в середине предложения, с понятным итогом.
Вспомним про те 13 байт. Итак, при превышении размера разбиваем текст на части, отдаем первую,
ждем клика, отдаем вторую, пока текст не кончился. Можно, конечно, перефразировать перевод, но лучше перестраховаться.

Итого



Устанавливаем в игре шрифт Segoe UI…



Правда, переносы строк далеко не всегда получаются оптимальными, иногда фраза рвется прямо в середине слова.
Игра поддерживает ручное указание места разрыва, для этого нужно в требуемом месте поместить тильду. Нужно бы модифицировать «запихиватель», чтобы он расставлял тильды. Также пока не переведены имена героев и меню. Имена несложно изменить прямо в скрипте, а вот насчет меню пока не уверен. Похоже, нужно сильнее раскопать ресурсы и перерисовать часть графики.

Я создал проект по переводу на Нотабеноиде, кто хочет — может поучаствовать, в одиночку это и за год не осилишь.
Нотабеноид.
Tags:
Hubs:
+15
Comments 10
Comments Comments 10

Articles