[NES] Пишем редактор уровней для Prince of Persia. Глава пятая. Отражение

    Глава первая, Глава вторая, Глава третья, Глава четвертая, Глава пятая, Эпилог

    Disclaimer

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

    В ночь с пятницы на субботу я снова открыл отладчик, RAM Filter и начал искать…


    Появление на свет

    Началось все с того, что мне прислали модификацию игры, сделанную в редакторе, с вопросом: Почему виснет при входе в одну из комнат?
    Комната выглядела в редакторе вполне обычно. Проверка правильности работы редактора показала, что все в порядке: что попросили, то и сохранил. Я попробовал переместить объекты, которые поставил автор модификации в комнате, и обнаружил, что зависание пропало, но в комнате появился двойник, который, к тому же, еще и имел наглость атаковать:

    То есть наличие объектов в комнате как-то влияет на наличие/отсутствие двойника. Со стражниками все ясно — их расстановка прописана прямо в заголовке уровня, а вот про двойника нет ни байта информации. Массивы данных для 4, 5 и 6 уровней ничем не отличались от всех остальных по своей сути. Полностью скопированный пятый уровень, скажем, в первый, не «вызывал» двойника из недр кода движка, а значит это как-то вшито в сам движок. Надо было понять, что меняется, если мы входим в комнату с двойником.

    Я начал изучать пятый уровень, так как там двойник выполнял больше всего действий: если нажать на кнопку, которая открывает выход, он появлялся, выпивал драгоценное содержимое бутылки, и убегал. RAM Filter выявил аномальную активность при появлении двойника в памяти в районе адресов $0400-$0410: при входе в комнату взводился флаг в ячейке $0401, после нажатия на кнопку дополнительно взводился флаг в ячейке $0402, а затем, после того как тот убегал за пределы комнаты, ячейка $0401 обнулялась и больше не менялась. Значит, будем изучать, что тут происходит.

    Наличие дополнительного персонажа (стражник, скелет или двойник) в комнате в NES версии вызывает замедление работы движка, и на эту особенность стоит обратить внимание.
    Запускаем игру, ставим в $0401 единицу и действительно начинаем наблюдать замедление работы движка. Более того, нас снова начинает «атаковать» двойник.
    Раз ячейка $0401 отвечает за наличие/отсутствие оного, то идем в отладчик, задаем точку останова:

    В поле Condition задаем условие: аккумулятор при записи в ячейку не должен содержать 0. Изменение ячейки может происходить и через индексные регистры, но зачастую это производится именно через аккумулятор. Останов приводит нас сюда:
    $A1D0:A9 00	LDA #$00
    $A1D2:8D 01 04	STA $0401 = #$00
    $A1D5:A5 51	LDA $0051 = #$17
    $A1D7:8D DE 04	STA $04DE = #$17
    $A1DA:AD 02 04	LDA $0402 = #$00
    $A1DD:D0 4D	BNE $A22C
    $A1DF:A9 3D	LDA #$3D
    $A1E1:85 2F	STA $002F = #$2D
    $A1E3:A9 A2	LDA #$A2
    $A1E5:85 30	STA $0030 = #$A2
    $A1E7:A5 70	LDA $0070 = #$04
    $A1E9:C9 05	CMP #$05
    $A1EB:D0 04	BNE $A1F1
    $A1ED:A5 51	LDA $0051 = #$17
    $A1EF:F0 2D	BEQ $A21E
    $A1F1:A9 4D	LDA #$4D
    $A1F3:85 2F	STA $002F = #$2D
    $A1F5:A9 A2	LDA #$A2
    $A1F7:85 30	STA $0030 = #$A2
    $A1F9:AD FD 04	LDA $04FD = #$00
    $A1FC:D0 0C	BNE $A20A
    $A1FE:A5 70	LDA $0070 = #$04
    $A200:C9 03	CMP #$03
    $A202:D0 06	BNE $A20A
    $A204:A5 51	LDA $0051 = #$17
    $A206:C9 03	CMP #$03
    $A208:F0 14	BEQ $A21E
    $A20A:A9 2D	LDA #$2D
    $A20C:85 2F	STA $002F = #$2D
    $A20E:A9 A2	LDA #$A2
    $A210:85 30	STA $0030 = #$A2
    $A212:A5 70	LDA $0070 = #$04
    $A214:C9 04	CMP #$04
    $A216:D0 14	BNE $A22C
    $A218:A5 51	LDA $0051 = #$17
    $A21A:C9 17	CMP #$17
    $A21C:D0 0E	BNE $A22C
    $A21E:A9 01	LDA #$01
    $A220:8D 01 04	STA $0401 = #$00      ;; <<< останов
    $A223:8D E0 06	STA $06E0 = #$00
    $A226:20 AD F2	JSR $F2AD
    $A229:20 85 DB	JSR $DB85
    $A22C:60	RTS
    


    Эта простыня довольно сложна для понимания, поэтому переведу ее в псевдокод, обозвав ячейки следующим образом:
    — $70 — LEVEL;
    — $51 — ROOM;
    — $401 — MIRROR_FLAG.
    С остальными позже разберемся:
    char sub_A1D0()
    {
    	MIRROR_FLAG = 0;
    	$04DE = ROOM;
    	if ( $0402 ) goto label_A22C;
    	$2F = #3D; $30 = #A2;
    
    	if ( LEVEL != #05 ) goto label_A1F1;
    	if ( !ROOM ) goto label_A21E;
    
    label_A1F1:
    	$2F = #4D; $30 = #A2;
    
    	if ( $04FD ) goto label_A20A;
    	if ( LEVEL != #03 ) goto label_A20A;
    	if ( ROOM == #03 ) goto label_A21E;
    
    label_A20A:
    	$2F = #2D; $30 = #A2;
    	
    	if ( LEVEL != #04 ) goto label_A22C;
    	if ( ROOM != #17 ) goto label_A22C;
    
    label_A21E:
    	MIRROR_FLAG = $06E0 = #01;
    	sub_F2AD();
    	sub_DB85();
    
    label_A22C:
    	return;
    }
    

    Теперь это проще изучить. Как видим, тут перебираются аккурат те уровни и комнаты в них, где появляется двойник, устанавливается флаг его наличия и вызываются еще две процедуры. Приводить я их не буду, лишь опишу то, что они делают.
    — $F2AD — выполняет поиск удвоенного маркера #FF, начиная с адреса $060E, затем возвращает длину последовательности байт между $060E и маркером #FFFF (не включая последний) в регистре Y;
    — $DB85 — выполняет сдвиг последовательности байт, начиная с найденного предыдущей процедурой маркера #FFFF, вперед, на длину последовательности, адрес которой хранится в ячейках $2F:$30, и которая также заканчивается маркером #FFFF.
    В ячейки $2F и $30, как мы видим, вносятся жестко заданные адреса где-то в ROM: $A22D, $A23D, $A24D.

    Простого внесения единицы в ячейку $0401 недостаточно, надо еще сделать некие магические действия.

    Двое из ларца, одинаковых с лица

    Так как двойник появляется только тогда, когда взводится флаг в ячейке $0402, то поищем сперва, где производится запись в нее, а затем, где читается значение из нее. Взводится флаг, как и следовало ожидать, тогда, когда мы нажимаем на кнопку, открывающую выход (там ничего интересного). А вот чтение из ячейки производится тут:
    $A319:A9 89	LDA #$89
    $A31B:85 72	STA $0072 = #$89
    $A31D:A9 A3	LDA #$A3
    $A31F:85 73	STA $0073 = #$A3
    $A321:AD 02 04	LDA $0402 = #$01        ;; << останов
    $A324:F0 53	BEQ $A379
    $A326:AD 01 04	LDA $0401 = #$01
    $A329:F0 4E	BEQ $A379
    $A32B:EE 04 04	INC $0404 = #$00
    $A32E:AC 03 04	LDY $0403 = #$00
    $A331:B1 72	LDA ($72),Y @ $A389 = #$15
    $A333:C9 FF	CMP #$FF
    $A335:F0 43     BEQ $A37A
    $A337:CD 04 04  CMP $0404 = #$01
    $A33A:D0 0B     BNE $A347
    $A33C:A9 00     LDA #$00
    $A33E:8D 04 04  STA $0404 = #$01
    $A341:EE 03 04  INC $0403 = #$00
    $A344:EE 03 04  INC $0403 = #$00
    $A347:C8        INY
    $A348:B1 72     LDA ($72),Y @ $A389 = #$15
    ;; ...
    

    Примечательная процедура, не правда ли? Суть ее действий, если изучить ее целиком, очень напоминает процедуру, которая играет за нас в demo play. В ячейках $72:$73 у нас адрес $A389:
    15 01 06 0D 02 02 03 32 0C 4E 02 05 17 01 FF
    Тот же маркер #FF, те же структуры, состоящие из двух байт, где первый из них — время, а второй… нет, второй — уже не имитация геймпада, а что-то иное. Когда отсчитываемое в ячейке $0404 значение сравнивается с первым байтом структуры, счетчик в ячейке $0403 увеличивается на 2 и процесс повторяется. Если посмотреть, что происходит во время этого процесса со вторым байтом, то мы придем к некоему массиву указателей:
    0x15602: 00 00 AE 96 DB 96 64 97 9D 97 ...
    Индексом в этом массиве как раз будет служить наш второй байт. Каждый указатель в этом массиве указывает на структуру, с которой движок работает довольно хитро. Если в этой структуре попадается значение, которое больше некоторого числа, то оно декодируется в индекс, который используется в массиве указателей на определенные процедуры в коде. Если же число меньше некоторого числа, то оно используется как аргумент для вышеобозначенных процедур. Процедуры эти, выполняя определенные действия, вносят следующий указатель в структуру, отвечающую за персонажа. Таким образом, после того, как в структуре персонажа будет задан некий стартовый указатель, начинает выполняться саморегулирующийся цикл, который приводит спрайт в движение. Например, если мы запишем в структуру указатель действия «бег», то каждый шаг бега будет инициироваться предыдущим, задавая новый указатель в этой же структуре в поле ActionPtr во время каждой итерации. Кроме того, в этих процедурах будет производиться перемещение спрайта на экране и озвучивание его действий.

    Всего указателей в том массиве 93 штуки. То есть игра поддерживает 93 действия для персонажа. Но поскольку указатели иногда повторяются, то различных действий несколько меньше. Эту структуру (персонажа) я приводил ранее, поэтому не буду подробно останавливаться на ее разборе. Если же изучить действия двойника, то можно заметить, что его структура по смыслу повторяет структуру самого принца. Иными словами, когда в комнате появляется двойник, то после структуры, описывающей принца, вставляется такая же структура, которая описывает уже двойника. Взводится флаг в ячейке $0401, а дальше движок, в зависимости от номера уровня, номера комнаты и наших действий, вносит в эту структуру изменения, приводя, таким образом, двойника в движение.

    Полный код (в псевдокоде) 'привода' двойника
    char sub_A25D()
    {
    	if ( !MIRROR_FLAG ) goto label_A277;
    	$06E0 = MIRROR_FLAG;
    	if ( level == #03 ) goto label_A28D;
    
    	if ( level != #04 ) goto label_A272;
    	goto label_A319;
    
    label_A272:
    	// here CMP #05
    	goto label_A398;
    
    label_A277:
    	return;
    
    label_A278:
    	$0072 = #86;
    	$0073 = #A3;
    	$04FE = #00;
    	MIRROR.Y.LOW = #40;
    	return sub_A326();
    
    label_A28D:
    	$04FB = $0402 = #00;
    	MIRROR.DIRECTION = #FF;
    	if ( room != #03 ) goto label_A2CF;
    
    	if ( $04FD ) goto label_A278;
    
    	if ( PRINCE.Y.LOW >= #48 ) goto label_A2CF;	
    	$04FB = $04FC;	// if prince around mirror (by Y pos)
    	if ( !$04FB ) goto label_A2C3;
    
    	if ( PRINCE.X.HI ) goto label_A2C3;
    	if ( PRINCE.X.LOW <= #AC ) goto label_A2D0;
    
    label_A2C3:
    	A = #02;
    	Y = #0E;
    	sub_CAFD();
    	MIRROR.X.HI = #02;
    
    label_A2CF:
    	return;	
    
    label_A2D0:
    	X = #98;
    	if ( !PRINCE.DIRECTION ) goto label_A2D9;
    	X = #94;
    
    label_A2D9:
    	MIRROR.X.LOW = X;
    	MIRROR.X.HI = #00;
    	MIRROR.Y.LOW = PRINCE.Y.LOW;
    	if ( PRINCE.ACTION_INDEX == #06 ) goto label_A2C3;
    	if ( PRINCE.POSE_INDEX <= #06 ) goto label_A2F9;
    	if ( PRINCE.POSE_INDEX <= #0E ) goto label_A301;
    
    label_A2F9:
    	if ( PRINCE.POSE_INDEX <= #20 ) goto label_A302;
    	if ( PRINCE.POSE_INDEX >= #28 ) goto label_A302;
    
    label_A301:
    	return;
    
    label_A302:
    	X = #00;
    	Y = #05;
    
    label_A306:
    	MIRROR.ACTION_PTR = PRINCE.ACTION_PTR;
    	X++;
    	Y--;
    	if ( Y ) goto label_A306;
    
    	MIRROR.DIRECTION = PRINCE.DIRECTION xor #FF;
    	return;
    
    label_A319:
    	$0072 = #89;
    	$0073 = #A3;
    	if ( !$0402 ) goto label_A379;
    	
    label_A326:
    	if ( !$0401 ) goto label_A379;
    	$404++;
    	if ( $72[Y] == #FF ) goto label_A37A;
    	if ( $0404 ) goto label_A347;
    
    	$0404 = #00;
    	$0403 += 2;
    
    label_A347:
    	Y++;
    	
    	A = $72[Y];
    	Y = #0E;
    	sub_CAFD();
    	if ( MIRROR.POSE_INDEX != #6D ) goto label_A379;
    	$0054 = $06F0 = #00;
    	$04B1 = #03;
    	sub_DB23();
    	A = #27;
    	sub_F203();
    	#0610[X] = #02;
    	A = #20;
    	sub_F203();
    	#0610[X] = #02;
    
    label_A379:
    	return;
    
    label_A37A:
    	$060E = $061C = $0401 = #00;
    	return;
    
    A386:
    	.data[XX:YY],1C:01 FF // XX:time, YY:action, FF:EOF
    
    A389:
    	.data[XX:YY],15:01 06:0D 02:02 03:32 0C:4E 02:05 17:01 FF
    
    label_A398:
    	if ( level == #05 && !$610 ) goto label_A3A7;
    	if ( PRINCE.X.LOW <= #10 ) goto label_A3A7;
    	goto label_A3D1;
    
    label_A3A7:
    	$0054 = #00;
    	$04B1 = #0C;
    	if ( !sub_DB18() ) goto label_A3D1;
    	
    	$0072 = #D2;
    	$0073 = #A3;
    	sub_A326();
    	if ( MIRROR.POSE_INDEX != #7C ) goto label_A3D1;
    	$06FC = #00;
    	$06FB = #0B;
    
    label_A3D1:
    	return;	
    
    A3D2:
    	.data[XX:YY]: 04:02 19:2A F0:02 F0:02 F0:02 FF
    
    }
    
    char sub_F203()
    {
    	$0017 = A;
    	switch_bank(#02);
    	sub_B298();
    	$04BF = Y;
    	switch_bank($06D1);
    	Y = $04BF;
    	return;
    }
    
    char sub_B298()
    {
    	X=#00;
    
    label_B29A:	// aka sub_B29A
    	Y=#00;
    	if ( #060E[X] != #FF ) goto label_B2A4;
    	Y++;
    
    label_B2A4:
    	if ( #060E[X] & #7F == $0017 ) goto label_B2BC;
    	if ( #060F[X] != #FF ) goto label_B2B2;
    	Y++;
    
    label_B2B2:
    	if ( Y == #02 ) goto label_B2BF;
    	sub_F215();
    	goto label_B29A;
    
    label_B2BC:
    	Y = #01;
    	return;
    
    label_B2BF:
    	Y = #00;
    	return;
    }
    
    char sub_8730()
    {
    	if ( A == #0616[Y] ) goto label_874B; // $0616+Y - address of MIRROR.ACTION_INDEX
    	$0616[Y] = A;
    	X = A << 1;
    	#0613[Y] = #95F2[X];            // set MIRROR.ACTION_PTR to new value ($0613 + Y - address of ACTION_PTR in MIRROR struct)
    	#0614[Y] = #95F3[X];
    	#0618[Y] = #FF;                    // set EOF marker
    
    label_874B:
    	return;
    }
    

    Стоит заметить, что довольно интересна выполнена процедура «отражения» в зеркале. Если принц находится рядом с зеркалом, то двойник помещается в соседнюю от него позицию, указатель действия принца копируется в структуру двойника, а байт направления инвертируется. Если позиция принца «прыжок с разбега», то двойник «выбегает» из зеркала и убегает прочь.

    Делаем патч

    Такой код увязать с редактором довольно сложно. Можно, конечно, редактировать те короткие массивы данных, которые использует вышеприведенный код, но пришлось бы проделать определенную работу, а эффект был бы незначительный. Редактировать же этот код редактором слишком сложно. Хотелось чего-то большего. И решение было найдено.

    Для управления двойником нам достаточно будет скопировать его структуру после структуры самого принца и выставить флаг $0401. В дальнейшем мы будем задавать действия, которые он будет выполнять, записывая указатель в его структуру. Но как это сделать? Нужно написать код. Но куда его вставить? В игре практически нет свободных мест, а те небольшие огрызки по несколько байт, которые остались между кодом и данными, использовать невозможно. Значит, надо изыскивать дополнительное место иными способами.

    Шире круг

    Как мы помним, Mapper #02 содержит в себе два различных вида маппинга. Один из них — UNROM, — содержит в себе 8 банков PRG-ROM, а второй — UOROM, — 16. Если вставить в ROM-файл еще 8x16 кБ, то маппер изменится на UOROM без вреда для игры. Вставить, однако, надо так, чтобы последний банк так и остался последним, а первые 7 должны остаться в начале.

    Лезем в шестнадцатеричный редактор, меняем в заголовке число банков (пятый по счету байт, смещение 0x04) с #08 на #10, затем вставляем в файл 8x16 кБ нулей, начиная со смещения 0x1C010. Размер ROM-файла изменится и станет равным 262 160 байт. Запустим полученный ROM в эмуляторе… Работает!
    Если мы будем выполнять эту процедуру в «железе», то нам потребуется поменять контроллер маппера, а ROM-память поставить увеличенную — 32x8 кБ, и мы также получим работающую игру.

    ROM увеличили, место есть, но как им воспользоваться? Для того, чтобы вызвать код или прочитать оттуда данные, нам надо включить этот банк и передать туда управление. Сделать это можно безопасно только из последнего банка, но в нем нет места. Куда же писать код?
    Зададимся требованиями:
    • Код должен вызываться из основного цикла игры, так как мы будем управлять двойником прямо во время основной игры;
    • Код должен вызываться и возвращать управление в последний банк;
    • Вызываемый код не должен нарушать работу оригинального кода.


    Вспомним основной цикл. Там у нас вызываются различные процедуры, перед которыми вызываются процедуры включения соответствующего банка. Вставить две новых процедуры и добавить новые вызовы в основной цикл довольно сложно, но мы можем поменять одну из процедур включения банка.
    Возьмем конец цикла:
    ;; ...
    $CC3B:20 00 CB	JSR $CB00
    $CC3E:20 12 9F	JSR $9F12
    $CC41:20 DD A3	JSR $A3DD
    $CC44:4C 1A CC	JMP $CC1A
    

    Процедуры $9F12, $A3DD трогать нельзя, так как они в другом банке, на месте которого должен быть наш. Перенести их туда тоже нельзя, так как они потянут за собой все остальное содержимое банка. Можно, однако, поменять адрес $CB00 на адрес новой процедуры, которая будет включать наш банк #07, вызывать наш код, затем вызывать оригинальную процедуру $CB00 и возвращать управление обратно в цикл. Код примерно такой:
    LDA #07
    JSR $F2D3			;; включаем банк #07
    JSR $8000			;; вызываем наш код, который будет в начале банка
    JMP $CB00			;; вызываем оригинальную процедуру $CB00, 
    					;; которая сама инструкцией RTS вернет управление в основной цикл
    

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

    Долго ли, коротко ли, такая процедура была найдена:
    $C111:AD C6 06	LDA $06C6 = #$00
    $C114:C9 06	CMP #$06
    $C116:B0 20	BCS $C138
    $C118:AD D7 06	LDA $06D7 = #$04
    $C11B:D0 1B	BNE $C138
    $C11D:8D 30 07	STA $0730 = #$00
    $C120:85 54	STA $0054 = #$01
    $C122:8D F0 06	STA $06F0 = #$00
    $C125:AD 17 06	LDA $0617 = #$0C
    $C128:C9 11	CMP #$11
    $C12A:90 0D	BCC $C139
    $C12C:C9 2B	CMP #$2B
    $C12E:B0 09	BCS $C139
    $C130:C9 1A	CMP #$1A
    $C132:90 04	BCC $C138
    $C134:C9 26	CMP #$26
    $C136:90 01	BCC $C139
    $C138:60	RTS
    


    Что она делает — вопрос открытый, но впрочем это и не важно. Главное, что она соответствует всем необходимым требованиям: не вызывается из «динамических» банков, сама не вызывает код из них и не обращается к ним. А переход по адресу $C139 мы немного переделаем.
    Немного модифицируем наш код включения банка и поместим его по адресу $C111:
    $C111:20 17 C1	JSR $C117		;; вызываем код включения банка
    $C114:4C 90 BF	JMP $BF90		;; и вызываем оригинальную процедуру (она теперь живет по адресу $BF90)
    										;; потом она инструкцией RTS вернет управление вызвавшему коду сама
    
    ;;	======== процедура включения нашего банка ==========
    $C117:A9 07	LDA #$07
    $C119:4C D3 F2	JMP $F2D3
    ;;	======== конец процедуры включения нашего банка ==========
    
    										;; а теперь код, вызывающий наш патч
    $C11C:20 17 C1	JSR $C117		;; включаем банк
    $C11F:20 10 B0	JSR $B010		;; вызываем наш патч. Начало нашего кода - $B010.
    $C122:4C 00 CB	JMP $CB00		;; вызываем оригинальную $CB00, которая вернет нас обратно в цикл
    $C125:00	BRK
    ;; ...
    $C138:00	BRK
    

    Мало того, что мы успешно впихнули вызов нашего кода, так у нас еще и осталось уйма места с $C125 по $C138, которое мы можем использовать как-нибудь еще: ведь у нас есть еще целых 7(!) свободных банков, а с ними тоже надо будет работать из последнего банка (если мы их будем в будущем использовать). Адрес нового кода я разместил по адресу $B010 (примерно середина банка), так как нам придется размещать копию уровней и комнат в нашем банке, плюс еще кое-какие данные. Но об этом чуть ниже.

    Модифицируем и саму процедуру, так как в ней есть переходы на адрес $C139, который за ее пределами:
    $BF90:AD C6 06		LDA $06C6 = #$00
    
    ;; ============ CUT ============
    
    $BFAA:90 0D		BCC $BFB9
    $BFAC:C9 2B		CMP #$2B
    $BFAE:B0 09		BCS $BFB9
    $BFB0:C9 1A		CMP #$1A
    $BFB2:90 04		BCC $BFB8
    $BFB4:C9 26		CMP #$26
    $BFB6:90 01		BCC $BFB9
    $BFB8:60		RTS
    $BFB9:4C 39 C1		JMP $C139
    

    Все! То, что когда-то переходило на адрес $C139 теперь переходит на адрес $BFB9, по которому инструкция JMP заставляет прыгнуть на $C139, словно мы никуда и не перемещали код.
    Тело основного цикла теперь мы можем поменять на следующее:
    ;; ...
    $CC3B:20 00 CB	JSR $C11С
    $CC3E:20 12 9F	JSR $9F12
    $CC41:20 DD A3	JSR $A3DD
    $CC44:4C 1A CC	JMP $CC1A
    

    Можем разместить по адресу $B010 какую-нибудь пустышку вроде «RTS» и запустить игру в эмуляторе. Все как было — так и осталось.

    Пишем свой «привод» для «отражения»

    Осталось только разработать свой алгоритм появления отражения в комнате.
    Я разработал следующую структуру данных:


    При входе в комнату, мы по номеру уровня извлекаем указатель в первом массиве указателей (Levels ptrs), если он не нулевой, то переходим ко второму массиву (Rooms ptrs).
    Если извлеченный по номеру комнаты указатель также не нулевой, то приступаем к чтению структуры отражения.
    Структура отражения выглядит следующим образом:
    • Структура, описывающая начальное состояние персонажа (struct CHARACTER);
    • Пары «время»:«действие», которые описывают, что будет делать персонаж и в какой интервал времени;
    • Маркер окончания #FF.


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

    Реагируем на события

    Безусловное появление отражения неинтересно. Вроде оно и есть, но толку никакого. Хотелось бы, чтобы он появлялся в соответствии с какими-либо игровыми событиями и что-то умел делать.

    Начиная с адреса $0500 в памяти у нас лежат данные, которые определяют те или иные изменения в комнатах. Например, если персонаж выпьет зелье из бутылки, то бутылка там больше не появится. Либо, если упадет плита, то в этом массиве будет хранится как то, что теперь на ее месте дырка, так и то, что на том месте, куда она упала — ее осколки. То же самое с открытыми и закрытыми решетками. Каждое такое событие кодируется двумя битами. В комнате у нас 30 блоков, на каждую строку из 10 блоков приходится по 3 байта (4 блока умещается в 1 байте, причем в третьем байте оставшиеся 4 бита остаются неиспользованными), итого по 9 байт на комнату. На уровень, таким образом, выходит 9*24 = 216 байт.

    Как только произошло какое-нибудь действие, соответствующая пара бит в этом массиве устанавливается в определенное значение. Всего возможных комбинаций — 3 (00 — означает, что ничего не происходило), а событий много: решетка открылась, решетка закрылась, выпили из бутылки, плита отсутствует (упала), осколки упавшей плиты; соответственно события перекрываются. Например, если мы поставим бутылку, а над ней повесим падающую плиту, то после ее падения мы либо недосчитаемся бутылки, либо не увидим осколков упавшей плиты.

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

    Заставляем нажать кнопку

    Ну и напоследок остается научить его что-либо делать. Двойник «из коробки» не умеет делать ровным счетом ничего. Умеет лишь появляться на экране и совершать какие-либо движения, мозоля глаза. Контактировать со стенами он не умеет, нажимать на кнопки не умеет, да и вообще он ничего не умеет.

    Пока наш принц что-то делает в игре, его координаты постоянно сравниваются с блоком, в котором он находится, и если этот блок «активный», то предпринимаются определенные действия: например, если мы попали в блок «Кнопка», то эта кнопка будет нажиматься. Двойник же бегает сам по себе и никто за его передвижениями не следит. Значит, мы в своем патче обязаны это сделать за основной движок. Для этого достаточно передать в основной движок (в процедуру проверки) координату двойника вместо координаты принца, а дальше все произойдет само. Но проблема в том, что движок сравнивает блок с массивом данных комнаты, который хранится в другом банке. Тем не менее, в нашем банке еще достаточно места, а во время проверки будет включен именно он, поэтому мы… просто скопируем игровые данные из оригинального банка в наш на то же место, и движок будет считывать эти данные как ни в чем не бывало (помните, ранее мы разместили код в середине банка?). Во время редактирования, правда, теперь надо будет учесть, что изменения следует вносить в основную копию и в резервную.

    Результат налицо:


    FIN

    В конечном итоге удалось заставить двойника быть не просто мебелью, а выполнять простейшее действие — нажать кнопку и открыть нам какую-нибудь дверь. Уже с такой простой вещью в игру удалось привнести элемент головоломки: пока не нажмешь кнопку, не разобьешь плиту или не выпьешь из бутылки в одном конце уровня — в другом не появится отражение, которое не нажмет за тебя кнопку, открывающую, к примеру, выход.

    Что ж, теперь можно добавить и игру вдвоем, или новых персонажей, или новые блоки, но… это уже будет бы другая игра. Я к этому времени свое любопытство полностью удовлетворил, поэтому закрыл отладчик и отправился спать. Начинался теплый июльский понедельник.

    PS

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

    Прикладываю ссылку, по которой можно загрузить архив с редактором, небольшой документацией по игре и исходному коду патча.
    Share post

    Comments 11

      +11
      Аплодирую стоя и с нетерпением жду следующих статей.
        +2
        А почему вы решили с NES-ом мучиться? Взяли бы 8086-реализацию от Broderbund, она гораздо качественнее сделана, плюс для x86 инструментарий гораздо богаче. Да и приобретенный опыт был бы более прикладным.
          +4
          К NES у меня особая, детская ностальгия, и вот мне удалось все-таки до нее дорваться. В PC же версию мне поиграть в детстве, к сожалению, не удалось, да и разобрана она давно уже по кирпичику.
            +2
            Да ладно — разобрана. Где? Исходников ее никто не открывал (а было бы интересно — они на Си).
              0
              Ну как же, был проект FreePrince – качать тут: www.princed.org/downloads/

              Движок полностью отреверсили.
              0
              … плюс для 8086 интересно еще и потому, что один и тот же exe-шник реально работал на 8088 в CGA с pc-speaker и не тормозил — я своими руками запускал; и он же работал на 80286 в VGA и с sound blaster. Вот это была виртуозная работа.
            +2
            А в исходники оригинального PoP специально не заглядывали?
              +5
              Там такие исходники, честно говоря, что заглядывай в них, не заглядывай — все едино. Хардкорнейший ассемблер, причем ассемблер старинной архитектуры. Уж проще в NES копаться (кстати, наверняка для NES с нуля тоже все писали, как и для 8086).
                0
                Вообще-то и в NES, и в Apple II, и в Commodore 64 (а также совместимые в советских клонах Правец и АГАТ) использовался тот же самый процессор MOS Technology 6502. Ассемблер с детства знакомый.
              0
              да уж! Работа — глобальная! присоединяюсь к аплодирующему стоя! Принц персии — одна из первых игр пройденных полностью! 90й год — как сейчас помню!
                –6
                Стрелку вверх, если вам тоже захотелось сыграть в Prince of Persia или тоже расхакать какую-нибудь старую любимую игрушку ;)

                Only users with full accounts can post comments. Log in, please.