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

В предыдущей статье я ускорял главную процедуру отрисовки экрана, которая рендерит двухбайтовые коды в блоки-ячейки 8x8 пикселей. Это чем-то напоминает устройство консоли NES, только в ней ячейки отрисовываются аппаратно. Найти главный цикл отрисовки экрана было несложно: я случайно прерывал выполнение программы отладчиком, для многих игр это даёт неплохой шанс оказаться именно в процедуре отрисовки, потому что чаще всего это самая медленная часть (в случае Fred это было особенно справедливо). Если бы не было этого способа, то оставалось бы два варианта. Первый - реверсить весь код игры с самого начала. Второй вариант - найти эмулятор с поддержкой точек останова, которые срабатывают при доступе к определённым адресам памяти (memory breakpoints), и повесить такую точку останова на видеопамять. Я не знал готовых эмуляторов с поддержкой memory breakpoints. Реверсить же весь код по порядку долго и больно, потому что неизвестен смысл многих данных. Гораздо проще двигаться наоборот, от экранной процедуры, когда легко увидеть, во что на экране нарисуется какой байт. Разобраться с устройством экранной процедуры Fred-а было несложно. Теперь, зная, как она работает, мы можем продолжить реверсинг: во-первых, из любопытства о том, как писали игры в 80-х, а во-вторых, вдруг получится ускорить игру ещё немного?

Изучив экранную процедуру мы узнали, из какой области памяти она читает закодированное представление экрана, и было бы интересно найти код, который пишет в эту область памяти. Но вот беда, запись в эту область происходит гораздо реже, чем запись в видеопамять, и метод удачливых прерываний тут не поможет. Поэтому я взял эмулятор и добавил в него поддержку memory breakpoints. Это привело нас в довольно большое количество мест, которые мы рассмотрим по порядку.

Рисование лабиринта

Вот код процедуры, которая рисует блоки уровня, вместе с кодом вспомогательной процедуры.

;
;generate encoded representation of visible part of current level, for subsequent rendering by main video loop
;encoded representation will be generated for 6x8 blocks
;each labyrinth block is converted into 5x4 cells of a same color
;So whole representation takes 6*8*4*5 = 960 cells, which take 1920 ($780) bytes
;Representation is stored at [$ECC0 - $F43F]
;
$6460 LD DE, ($6649) ; coordinates of player (center of a screen), reg D - vertical coord, reg E - horizontal coord
      DEC D
      DEC D          ; top visible row of labyrinth is current row - 2, it may be negative!
      DEC E          
      DEC E
      DEC E          ; left visible column of labyrinth is current column - 3
      LD HL, $ECC0   ; base for encoded screen representation
      LD C, $06      ; number of visible rows
;outer loop, encode level rows
$646E PUSH DE
      PUSH HL
      LD B, $08     ; number of visible columns
;inner loop, encode level elements
      PUSH BC
      LD A, D       ;checking vertical coord
      CP $21        ;compare with level size + 1
      JR C, $6486   ;it is below, ok, so it is possibly block of a labyrinth, let's check another coordinate
      CP $80        ;otherwise, let's check if it greater than level's height, but not negative
      JR C, $6481   ;if yes, then encode a sand block
      LD BC, $E078  ;if here, then coord is negative, which means it's above the level, so we should encode stars
      JR $64A4      ;jump directly to encoding
$6481 LD BC, $C08C  ;code for a sand block
      JR $64A4      ;jump directly to encoding
$6486 LD A, E       ;check horizontal coord
      CP $20        ;compare with level width
      JR C, $6492   ;if lower, then it is a labyrinth item, let's get it
      JR NZ, $6481  ;otherwize, it's a sand block
      LD BC, $A03C  ;another kind of sand block
      JR $64A4      ;jump directly to encoding

$6492 PUSH HL       ;save HL (a destination in encoded screen)
$6493 CALL $61EA    ;get block code
      SLA A         ;offset = blockcode*2, since each cell code is 2 bytes
      LD HL, $64D3  ;base
      ADD A, L
      LD L, A       ;add offset to base
      JR NC, $64A0  ;check for overflow
      INC H         ;adjust high byte if overflow

;now HL contains pointer to encoded info (2 bytes) of top-left cell of a block
$64A0 LD C, (HL)    ;read first byte of block info
      INC HL
      LD B, (HL)    ;read second byte of block info
      POP HL        ;restore HL (a destination in encoded screen)

$64A4 PUSH DE       ;store level coords
      PUSH HL       ;store destination in encoded screen
      PUSH BC       
      POP DE        ;now DE has value of encoded block

;at this point DE contains code for top-left cell of a level block, and HL contains destination address for cells
;all remaining cells for level block are just sequential increments starting from top-left cell
      LD C, $05    ;each labyrinth block is 5 cells high
;inner loop, go by cell rows for labyrinth block
$64AA PUSH HL      ;store destination pointer for beginning of cell row for current block
      LD B, $04    ;labyrinth blocks are 4 cells wide
;innermost loop, go by cells in one row of a labyrinth block
$64AD LD (HL), E   ;write low byte of encoded cell 
      INC HL       ;increment destination address
      LD (HL), D   ;write high byte of encoded cell
      INC HL       ;increment destination address
      INC DE       ;increment cell code
$64B2 DJNZ $64AD   ;loop
      POP HL       ;restore pointer so it points again to leftmost cell of current row

      PUSH DE      ;store cell code, since we need to use DE
      LD DE, $0040 
      ADD HL, DE   ;move to next row. each row is $40 bytes, which is (num of blocks $08)*(block width $04)*(bytes per cell $02)
      POP DE       ;restore cell code

      DEC C        ;decrement cell rows counter
      JR NZ, $64AA ;move to encode next cell row for current level block

      POP HL       ;restore destination pointer for top-left cell of level block
      LD DE, $0008 
      ADD HL, DE   ;move 4 columns to right, since each cell is 2 bytes

      POP DE       ;restore coordinates
      INC E        ;move to next block to the right
      POP BC       ;restore counters of level columns and rows
      DJNZ $6472   ;decrement column counter and repeat if there are any left

      POP HL       ;restore destination pointer of top-left cell of leftmost block on current row
      LD DE, $0140 
      ADD HL, DE   ;move 5 rows below, since each row is $40 bytes, which is (num of blocks $08)*(block width $04)*(bytes per cell $02)
      POP DE       ;restore coordinates
      INC D        ;move to next row
      DEC C        ;decrement row counter
      JR NZ, $646E ;repeat for next row, if there is any
$64D2 RET


;get a level block code for provided level coordinates
;current labyrinth is stored at $E000, it's dimensions are 32x32, from left to right, from top to bottom
;inputs: reg D - level row (0 - 31), reg E - level column (0 - 31)
;result: reg A - block value
$61EA XOR A
      LD L, A
      PUSH DE  ; no need to push/pop, DE doesn't change
      LD A, D  ; we will shift level row index by 3 bits to the left
      SRL A
      RR L
      SRL A
      RR L
      SRL A
      RR L
      OR $E0  ; base address of level map is E000
      LD H, A ; high byte of level element goes to H
      LD A, L  
      OR E    ; low byte of level element is composed of horizontal coord (lower 5 bits) 
      LD L, A ; and 3 bits of vertical coord
      LD A, (HL) ;get level element
      POP DE
$6202 RET

Лабиринт состоит из больших блоков, каждый блок имеет размеры 5x4 клеток. Игра отслеживает грубые координаты игрока, которые описывают, в каком блоке лабиринта игрок сейчас находится. Зная это, можно определить видимое окно из блоков лабиринта. Сама структура лабиринта хранится по адресу $E000, размеры 32x32, каждый блок кодируется одним байтом. Процедура содержит 4 вложенных друг в друга цикла. Самый внешний цикл $646E проходит по 6 строкам лабиринта, внутри него цикл проходит по 8 столбцам лабиринта. Получив однобайтный код блока, его нужно превратить в последовательность двухбайтных кодов ячеек. Для получения кода левой верхней ячейки блока используется таблица по адресу $64D3, индексом в которой выступает код блока, умноженный на 2. Коды для всех остальных ячеек блока получаются последовательным инкрементом. Поскольку блок лабиринта состоит из 5x4 ячеек, то третий цикл $64AA идёт по строкам, а четвёртый, самый внутренний цикл, идёт по ячейкам внутри строки.

Вот так можно визуализировать область ячеек
Вот так можно визуализировать область ячеек

В первой части мы разобрались, как устроен двухбайтный код ячейки: три старших бита кодируют цвет, 13 младших бита умножаются на 8 и указывают на последовательность из 8 байт, кодирующих пиксели. Теперь же, зная, что блоки лабиринта превращаются в коды ячеек простым инкрементом базового значения, мы можем понять, почему так сделано: инкремент кода ячейки не изменяет её цвет, только указатель на пиксели, что подходит к дизайну с одноцветными блоками. Инкремент кода ячейки смещает указатель пикселей на 8, то есть сразу после предыдущих пикселей, это очень хорошо подходит для плотного последовательного хранения пикселей. Плохая новость: такой способ распаковки блоков лабиринта означает, что каждая ячейка у всех блоков уникальна, нельзя закодировать блоки лабиринта так, чтобы у них совпадали какие-то коды ячеек. Если присмотреться к игровому экрану, то визуально он примерно на четверть состоит из пустой черноты. Если в экранной процедуре для пустых блоков-ячеек вообще пропускать отрисовку пикселей (включая расчёт расположения источника), это дало бы отличный прирост скорости. Но не выйдет: и для совершенно пустых блоков лабиринта, и для блоков, в которых висят верёвки, каждая ячейка имеет свой отличающийся код, включая пустые ячейки. Единственный вариант, как реализовать сложную структуру блока - это иметь таблицу того, из каких ячеек состоит блок. Чтение из такой таблицы, конечно же, замедлит выполнение рассматриваемой процедуры, ещё и память под неё надо найти. Поэтому эту оптимизацию вычёркиваем.

Вторая оптимизация, которая приходила в голову в процессе работы над экранной процедурой, состояла в том, чтобы поменять формат двухбайтового кода ячейки: 3 младших бита отвечают за цвет, 13 старших битов - за адрес пикселей. Если это сделать, то в процедуре кодирования лабиринта нужно заменить инкремент кода ячейки на увеличение на 8 байт. Это недешёвая операция для двухбайтовой величины на Z80: нужно загрузить число 8 в регистровую пару и выполнить ADD, и делать это на каждом шаге самого внутреннего цикла, при этом свободных регистровых пар нет. Так что эта оптимизация не выглядит разумной: мы ускорим экранную процедуру, но замедлим процедуру кодирования лабиринта. Поэтому тоже вычёркиваем.

Я привожу код процедур уже в откомментированном виде, но изначально он таким не был, комментарии - результат анализа ассемблерного кода. Я оказался в этой процедуре, потому что сработала memory breakpoint по адресу $64AD, дальше я первым проходом по коду сделал предварительное описание логики и выделил циклы, догадаться до остальных деталей уже было несложно. Из этой процедуры мы узнали много полезных вещей о самой игре. Например, то, по какому адресу хранится текущий лабиринт и что он закодирован однобайтовыми блоками. Мы даже можем написать вспомогательную программку, которая прочитает снапшот игры и отрисует весь лабиринт.

Просмотр карты лабиринта из снапшота игры, белое - блоки, чёрное - проходы
Просмотр карты лабиринта из снапшота игры, белое - блоки, чёрное - проходы

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

Просмотр блоков лабиринта
Просмотр блоков лабиринта

Если вы поняли, как работает процедура, попробуйте самостоятельно ответить на такой вопрос. В игре реализован скроллинг, а это значит, что чаще всего блоки с краёв будут видны лишь частично, потому что не попадают полностью в видимое окно экрана. Где в процедуре отрисовки лабиринта обрабатывается рисование частично видимых блоков? Правильный ответ: нигде. Область ячеек чуть больше, чем видимая часть экрана, и скроллинг происходит через смещение видимого окна. Область ячеек содержит 6x8 полных блоков, то есть 30x32 ячеек, а размеры видимой области экрана 22x24 ячеек. С одной стороны, это сильно упрощает обрезку (clipping) для блоков и спрайтов, и сильно упрощает скроллинг. С другой стороны, это делает скроллинг неравномерным: при движении можно смещать видимое окно, но иногда придётся чуть замедлиться и перекодировать лабиринт.

Можем ли мы оптимизировать эту процедуру? Стоит попытаться. Даже на первый взгляд тут можно, например, оптимизировать третий по вложенности цикл, который начинается с адреса $64AA. Перед началом цикла самого внутреннего цикла, заполняющего ячейки в строке, на стеке запоминается адрес в области ячеек, с которого начинается заполнение строки, хранящийся в HL. После заполнения адрес восстанавливается в HL и к нему прибавляется смещение $40, чтобы спуститься на одну строку вниз. Вполне можно обойтись без сохранения-восстановления, доcтаточно прибавить не ширину целой строки из области ячеек, а уменьшить её на ширину блока, которая равна 4 * 2 = 8 байт, что в итоге даст нам смещение $38. Это экономит 2 байта инструкций и 22 такта процессора в весьма вложенном цикле. Также само увеличение на $38 можно выполнить гораздо быстрее: не нужно сохранять на стек DE и потом восстанавливать. Можно воспользоваться парой BC, у которой к этому моменту регистр B равен 0, то есть достаточно записать $38 в регистр С. Что, регистр C уже используется как счётчик цикла? Без проблем, пусть A будет работать счётчиком цикла. Итого, выбрасываем PUSH+POP, и заменяем загрузку константы в однобайтный регистр, экономим 11+11+(10-4) = 28 тактов и 3 байта инструкций.

Но это всё мелочи, как насчёт радикальной оптимизации? Для процессора Z80 самый быстрый способ записать два байта в память и сдвинуть адрес на соседний - это команда PUSH. Но на этом пути есть несколько проблем. Первая проблема: PUSH двигает указатель стека вниз. Значит, строки ячеек будут заполняться справа налево, и коды ячеек нужно не инкрементить, а декрементить. Тогда уж проще переписать заполнение ячеек блока, чтобы оно происходило не слева направо сверху вниз, а наоборот: справа налево снизу вверх. Вторая проблема: если мы используем стек для записи в память, то мы не можем использовать его как временное хранилище регистров. В предыдущем абзаце мы увидели, что при оптимизации мы вполне можем обходиться без сохранения регистров на стек. Для чего нам нужны регистры? Хранить код ячейки, хранить адрес, куда мы этот код запишем, и счётчики циклов. Самый-самый внутренний цикл выполняется 4 раза, его можно развернуть, это ускорит выполнение и избавит нас от одного из счётчиков циклов. Не забудем, что у нас есть альтернативный набор регистров, с ним нам хватит на счётчики для оставшихся трёх циклов. Получилось у меня вот что:

; can store SP on $6200

;optimization of labyrinth encoding via stack memory write
;approx. 47000 tstates

$6460
  LD DE,($6649)
  INC D
  INC D
  INC D
  DEC E
  DEC E
  DEC E
  DEC E
  LD ($6200),SP ; instead of this variable, write SP exacty to code which does LD SP,NN 
  LD SP,$F440   ;check me!
  LD C,6
oupterloop1: 
  LD B,8
  LD A,B        
  ADD A,E       ;move to the right end of row
  LD E,A
outerloop2: 
  LD A,D
  CP $21
  JR C,A2
  EXX 
  CP $80
  JR C,A1
  LD DE,$E078   
  JR draw
A1: 
  LD DE,$C08C
  JR draw
A2: 
  LD A,E
  CP $20
  JR C,getcellcode
  EXX
  JR NZ,A1
  LD DE,$A03C
  JR draw

getcellcode: 
  CALL $61EA    ;get block code pointed by D and E into A
  RLCA
  EXX
  LD HL, $64D3  ;block info base
  ADD A, L      ;add offset
  LD L,A

;now HL contains pointer to block info (2 bytes), read block info into HL
  LD E,(HL)
  INC HL
  LD D,(HL)

draw:
  LD A,$13
  ADD A,E ;code correction, since we go backwards
  LD E,A
  LD B,5

innerloop1:
  PUSH DE
  DEC DE
  PUSH DE
  DEC DE
  PUSH DE
  DEC DE
  PUSH DE
  DEC DE  
  LD HL,$FFC8
  ADD HL,SP ;same as SUB SP,$38
  LD SP,HL  ;move to previous row  
  DJNZ innerloop1

  EXX  
;correcting SP so it will be -8 to the value before encoding a block
;this way it will point to destination area of next block
;currently SP is original value - 108, so by adding 100 we will get -8
  LD HL,0038  
  LD A,1
  CP B
  JR Z, A7
  INC H; 
A7: ADD HL,SP
  LD SP,HL

  DEC E
  DJNZ outerloop2
  DEC D
  DEC C
  JR NZ outerloop1
  LD SP,NNN ; value of SP is written here
  RET

Новый вариант уже не является таким очевидным для понимания кодом, как исходная процедура, но оптимизации - они всегда такие. Я применил эту оптимизацию и попробовал поиграть, и, честно говоря, не заметил разницы. Что же, если разница не чувствуется, то хотя бы в числах узнать, насколько я улучшил этот код. Чтобы это измерить, я придумал новый тип точек останова: они располагаются парами и измеряют, сколько инструкций между ними было исполнено и сколько тактов это заняло. Выполнение программы не прерывается, просто накапливается статистика, которую можно посмотреть, так что это не совсем точки останова, скорее точки профилирования. И вот что непонятно: при таких замерах число инструкций и число тактов нестабильно. У меня есть два объяснения этому явлению: прерывания и совместный доступ к памяти процессором и видеоконтроллером. Но хоть и без точных значений, прикинуть уровень ускорения мы можем. Исходная процедура выполнялась примерно 90000 тактов, новая процедура выполняется примерно 47000 тактов, то есть в два раза быстрее.

Игрок и враги

Я раньше уже говорил, что способ отрисовки экрана на основе закодированного представления из ячеек 8x8 похож на то, как работают консоли типа NES или SNES. Консоли, конечно же, гораздо продвинутее для двухмерных игр: они предоставляют несколько слоёв для рисования. На ZX Spectrum приходилось программировать слои вручную, и закодированное представление экрана - это как раз то место, в котором происходит наложение слоёв. Самый задний слой - это блоки лабиринта, которые мы только что изучали. Теперь посмотрим, как на них накладываются спрайты. Вот процедура, которая рисует фиолетовых ёжиков.

;
;Draw a hedgehog, with copying content from screen into buffer
;
$696D LD A, ($68E8)   ; number of hedgehogs
      LD B, A         ; will be used as loop counter
      LD HL, $BF68    ; start of list of monster info structures
$6974 CALL $697B      ; check if visible
      JR NC, $69D7    ; skip this monster if it is not visible
$6979 JR $69A4        ; draw a monster if it is visible

;sub-proc which will check if monster is visible
$697B LD E, (HL)      ; horizontal coord
      INC HL
      LD D, (HL)      ; vertical coord
      INC HL
      LD A, ($6629)   ; vertical offset of visible screen
      SUB $08         ; adjust
      LD C, A
      LD A, D
      SUB C
      LD D, A         ; now reg D contains on-screen vertical coord
      CP $16          ; check if this coord is visible
      RET NC          ; exit of no
      LD A, ($6628)   ; horizontal offset of visible screen
      SUB $0A         ; adjust
      LD C, A
      LD A, E
      SUB C
      LD E, A         ; now reg E contains on-screen horizontal coord
      CP $1A          ; check if it is visible
      RET NC          ; exit if no
      XOR A
      SRL D
      RRA
      SRL D
      RRA
      SLA E
      ADD A, E
      LD E, A         ; now DE contain offset of sprite on encoded screen
      SCF             ; carry flag indicates that sprite is visible
      RET             
;end of sub-proc
;back to outer proc, this part will copy sprite cell codes into encoded screen area
$69A4 PUSH HL         
      LD HL, ($638F)  ; address of top-left corner of encoded screen area
      ADD HL, DE      ; add offset
      LD ($643C), HL  ; param for $6409, destination for a sprite
      POP HL          
      INC HL          ; HL points to +3 field of monster structure
      LD ($6436), HL  ; param for $6409, buffer where screen content will be stored
      DEC HL          ; HL points to +2 field of monster structure
      PUSH HL        
      LD A, (HL)      ; load monster animation step (sprite code)
      AND $03         ; leave only 2 bits
      LD E, A
      LD D, $00
      SLA E           ; multiply by 2, it will make an offset
      LD HL, $6140    ; that is a sprite base
      ADD HL, DE      ; add offset, now HL points to monster sprite initial cell code
      LD ($643A), HL  ; param for $6409, source of a sprite
      POP HL
      LD A, $01       ; height of a sprite
      LD ($6411), A   ; modify code of $6409
      INC A           ; width of a sprite
      LD ($6413), A   ; modify code of $6409
      CALL $68F4      ; call $$68F4, which will call $6409
      LD A, $04       
      LD ($6411), A   ; restore code of $6409
      LD ($6413), A   ; restore code of $6409
$69D7 INC HL
      INC HL
      INC HL
      INC HL
      INC HL
      INC HL
      INC HL
      DJNZ $6974       ;next loop
$69E0 RET

Процедура забирает из глобальной переменной количество ёжиков на уровне, и проходится по списку. Для каждого ёжика вызывается вспомогательная процедура $697B, код которой расположен прямо внутри кода процедуры $696D, разрывая её пополам. Вспомогательная процедура делает три вещи. Сначала она пересчитывает абсолютные координаты ёжика в экранные координаты. Как мы помним, размер уровня 32x32 блока, и каждый блок имеет размер 5x4 ячеек, то есть размер уровня 160x128 ячеек, а значит координаты вполне влезают в один байт. Далее вспомогательная процедура проверяет, попадает ли ёжик в видимую часть экрана. Арифметика вычисления экранной координаты беззнаковая, а значит отрицательные экранные координаты превратятся в очень большие положительные, так что для проверки видимости достаточно сравнить экранную координату с максимальным значением и проверить флаг переноса. Если спрайт виден, то из его координат вычисляется смещение в области ячеек, куда его надо отрисовать. Вспомогательная процедура возвращает это смещение и выставляет флаг переноса, если спрайт виден.

Если спрайт виден, то выполнение продолжается с адреса $69A4. Дальнейший код подготавливает параметры для вызова процедуры $68F4 . Некоторые параметры передаются через глобальные переменные $643C, $6436, $643A, но помимо этого два байта в коде процедуры $68F4 модифицируются, и после выполнения восстанавливаются. Давайте же посмотрим, что это за $68F4.

;
;a small wrapper around $6409 which preserves HL and BC
;
$68F4 PUSH HL
      PUSH BC
$68F6 CALL $6409
      POP BC
      POP HL
$68FB RET


;
;copy content of some rectangle of a screen into buffer, then draw a sprite over it
;params:
; ($643C) - 2 bytes, destination in encoded screen area
; ($6436) - 2 bytes, location of a buffer where previous content of a screen will be stored
; ($643A) - 2 bytes, source of a sprite to be drawn
; code of this procedure is modified by a caller, it will change width and height by modifying bytes at $6411 and $6413
;
$6409 LD HL, ($6436)  ; address of a buffer where content of a screen will be stored
      PUSH HL         ; will be saved on stack
      LD HL, ($643C)  ; destination in encoded screen area 
$6410 LD C, $04       ; height of sprite, this value at $6411 is modified by a caller
$6412 LD B, $04       ; width of sprite, this value at $6413 is modified by a caller
;first we copy content from the screen into a buffer
$6414 LD E, (HL)      ; get first byte of cell code
      INC HL
      LD D, (HL)      ; get second byte of cell code
      EX (SP), HL     ; now HL points to buffer area
      LD (HL), E      ; store first byte of cell code
      INC HL
      LD (HL), D      ; store second byte of cell code
      INC HL
      EX (SP), HL     ; buffer pointer will go on stack, and address of encoded screen area goes into HL
      DEC HL
;now we draw sprite on screen
$641E LD DE, ($643A)  ; cell of sprite to be drawn next time
      LD (HL), E      ; we store it on encoded screen area
      INC HL
      LD (HL), D
      INC HL
      INC DE          ; increment cell code!
$6427 LD ($643A), DE  ; memorize next cell code
$642B DJNZ $6414      ; loop
      LD DE, $0038    ; offset to be added to encoded screen area to move to next row. This value is modified by caller
      ADD HL, DE      ; moving to next row
      DEC C           ; decrement row counter
      JR NZ, $6412    ; loop
      POP HL
$6435 RET 

Эта процедура делает вот что: она рисует ячейки спрайта, но перед рисованием каждой ячейки предыдущее значение копируется в некоторый буфер. Рисование происходит в двойном цикле: внешний цикл по строкам спрайта, внутренний цикл по ячейкам внутри строки. Коды ячеек спрайта получаются последовательным инкрементом начального значения, в точности как у блоков лабиринта. Регистры B и C используются как счётчики циклов, причём их начальные значения вовсе не обязательно 4, как видно в коде: перед вызовом эти значения будут изменены вызывающей процедурой. Упражнение на внимательность: вернитесь к коду процедуры $696D и определите, какие значения будут прописаны для количества строк и ячеек в строке для рисования ёжиков (1 строка, 2 ячейки в ней). Регистры D и E используются для хранения двух копируемых байтов, регистровая пара HL содержит адрес чтения или записи. Довольно забавно, как HL перепрыгивает между адресом в области ячеек и адресом в буфере сохранения при помощи инструкции EX (SP),HL , которая выполняется за такие неслабые 19 тактов.

Если вы не понимаете, зачем нужно это копирование предыдущих ячеек в буфер, то это нормально, я тоже не сразу понял. Идея вот в чём: процедура рисования блоков лабиринта не вызывается при каждой отрисовке экрана, например, если игрок стоит на месте, то лабиринт отрисовывается только один раз. А движение спрайтов делается так: запомнили старое содержимое экрана под спрайтом, нарисовали спрайт, восстановили содержимое экрана, переместили спрайт, рисуем в новом месте, и так далее. На самом деле всё ещё интереснее: область ячеек чуть больше, чем видимая область экрана, поэтому при движении сначала скроллится видимое окно внутри области ячеек. При достижении границы происходит перерисовка лабиринта, так что при движении лабиринт перерисовывается примерно на каждый четвёртый шаг.

Давайте посмотрим, как происходит восстановление сохранённых ячеек. Код очень похожий на $6409:

;
;a small wrapper around $64F1 which preserves HL and BC
;
$68FC PUSH HL
      PUSH BC
$68FE CALL $64F1
      POP BC
      POP HL
$6903 RET


;restore a previously stored rectangular part of visible area from buffer
;params: 
;  ($643E) 2 bytes, a destination in screen cells area
;  ($6440) 2 bytes, a source, address which points to a sequence of 2-byte cell codes
$64F1 LD HL, ($643E)  ;pointer where to put sprite cells, a pointer in cell area
      PUSH HL         ;top of the stack will be used as an exchanged temporary storage, so we put destination addr
      LD HL, ($6440)  ;a pointer to sprite source
$64F8 LD C, $04       ;sprite height. This code is modified by caller by changing value at $64F9 !
$64FA LD B, $04       ;width of sprite. This code is modified by caller by changing value at $64FB !
$64FC LD E, (HL)      ;get first byte of sprite cell
      INC HL
      LD D, (HL)      ;get second byte of sprite cell
      INC HL
;; greatest idea!!! instead of EX (SP),HL  use LDI!!!
      EX (SP), HL     ;exchange source and destination pointers
      LD (HL), E      ;put first byte of sprite
      INC HL
      LD (HL), D      ;put second byte of sprite
      INC HL
      EX (SP), HL
$6506 DJNZ $64FC      ;end of inner loop
      EX (SP), HL
      LD DE, $0038    ;move to next row of screen cell area. This value is modified by callers
      ADD HL, DE
      EX (SP), HL
      DEC C
$650F JR NZ, $64FA    ;outer loop
      POP HL
$6512 RET

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

Код процедуры, которая проходит по всем ёжикам и вызывает для них восстановление сохранённых ячеек. Этот код - практически копипаста процедуры $696D, простите, что она некачественно откомментирована, примерно так выглядит мой первый, самый сырой проход комментирования при реверсе.

;
;restore screen content from temp buffers for hedgehogs
;
$69E1 LD A, ($68E8)  ; example value: $28, number of monsters/hazards ?
      LD B, A        ; it will work as a loop counter
      LD HL, $C0C7
$69E8 LD E, (HL)     ; example value: $5A, coordinate?
      INC HL
      LD D, (HL)     ; example value: $3B, coordinate?
      INC HL
;looks like following code checks if monster is currently visible
;and also calculates on-screen coordinates
      LD A, ($6629)  ;vertical offset of visible screen
      SUB $08
      LD C, A
      LD A, D
      SUB C
      LD D, A        ; example value: $D1
      CP $16
      JR NC, $6A33
      LD A, ($6628)  ;screen offset ?
      SUB $0A
      LD C, A
      LD A, E
      SUB C
      LD E, A
      CP $1A
      JR NC, $6A33

;calculate offset in encoded screen based on on-screen coordinates in D and E
      XOR A          ;example value in DE: $0913
      SRL D
      RRA
      SRL D
      RRA
      SLA E
      ADD A, E
      LD E, A       ;example value in DE: $0266

      PUSH HL
      LD HL, ($638F)  ;address of visible top-left cell of encoded screen
      ADD HL, DE      ;add offset, this makes HL to be address in encoded screen area
      LD ($643E), HL  ;sprite destination in cells area, param for proc $64F1
      POP HL
      INC HL
      LD ($6440), HL  ;sprite source, param for proc $64F1
      DEC HL
      LD A, $01
$6A21 LD ($64F9), A ;modify code in proc $64F1, sprite size
      INC A
$6A25 LD ($64FB), A ;modify code in proc $64F1, sprite size
$6A28 CALL $68FC    ;call a proc which then will call $64F1
      LD A, $04
      LD ($64F9), A ;restore default sprite size
      LD ($64FB), A
$6A33 INC HL
      INC HL
      INC HL
      INC HL
      INC HL
      INC HL
      INC HL
$6A3A LD DE, $0012
      AND A
      SBC HL, DE
$6A40 DJNZ $69E8
$6A42 RET

Ещё раз осмыслим логику кода, который мы разбирали. Информация о ёжиках хранится в списке, для каждого ёжика первые два байта содержат абсолютные координаты, третий байт хранит код спрайта. Процедура рисования $696D проходит по списку, для каждого ёжика пересчитывает его абсолютные координаты в экранные. Дальше нужно код спрайта превратить начальный код ячейки. Для этого код спрайта умножается на количество ячеек в спрайте, и прибавляется к базовому значению-константе $6140. Получившийся начальный код ячейки будет одним из параметров для универсальной процедуры рисования $64F1. Двумя другими параметрами будут вычисленный на основе экранных координат адрес в области ячеек, и адрес буфера для сохранения предыдущего содержимого. Перед вызовом в универсальной процедуре рисования $64F1 модифицируются ширина и высота прямоугольной области, в которой будет произведено рисование.

Для внимательных читателей ещё задачка. Если мы идём по списку ёжиков, и перед рисованием ёжика сохраняем в буфер то, что в этом месте было раньше, и при этом ёжики могут рисоваться поверх других ёжиков, то логично, что восстанавливать предыдущее содержимое нужно, идя по списку ёжиков в обратном порядке. Сможете ли вы найти в коде, как это сделано?

Скрытый текст

Список ёжиков в процедуре рисования начинается с $BF68, а в процедуре восстановления идём с хвоста $C0C7. Кроме того, в процедуре восстановления для перехода на предыдущего ёжика есть код по адресу $6A3A, который вычитает $12.

А теперь прекрасная новость: все остальные монстры рисуются аналогичным образом, вызывая $64F1. Код их отрисовки и восстановления является почти копипастой, меняются начальные адреса списков монстров, базовые значения кодов ячеек и размеры спрайтов. На всякий случай вот таблица (заполнил насколько смог)

рисование

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

размер

базовый код ячейки

ёжики

$696D - $69E0

$69E1 - $6A42

1x2

$6140

привидения

$6CFA - $6D7B

$6CB1 - $6CF9

4x3

$E156

скелеты

$8F6C - $8FB2

$8FB3 - $8FE3

4x4

$E202

капли

$6BBF - $6C07

$6C5C

2x1

$8148

ящерицы

$7006 - $705E

2x1

$81F2

мумии

$6E4E - $6EB0

4x3

$E186

летучие мыши

$6F03 - $6F5E

2x3

$61DA

игрок

$63DF - $6408

4x4

$C000

предметики

$AD49

2x2

$A2B2

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

Просмотр спрайта привидения
Просмотр спрайта привидения

Мне очень хотелось заняться оптимизацией универсальных процедур $6409 и $64F1. Дело немного осложняется тем, что при переписывании наверняка сместятся адреса, в которых хранятся значения для счётчиков циклов, а в эти адреса пишут значения все процедуры, которые вызывают $6409 и $64F1. Да, у нас есть таблица вызывающих процедур, и мы можем пропатчить их все, хоть это и лениво.

Но хотите прикол? Размер ёжика всего 2 ячейки. И для того, чтобы скопировать 4 байта в буфер и записать 4 байта в ячейки, процедура $696D делает кучу работы, включая вызов $6409, внутри которой есть аж два цикла! Правильная оптимизация в данном случае - вообще не вызывать универсальную процедуру $6409, а тупо встроить копирование байтиков, будет сильно быстрее и гораздо короче. Аналогичная оптимизация напрашивается для капель и ящериц (их размер 2x1).

Я сделал это, попробовал в игре, и снова не ощутил никакой разницы!

Агрессивная оптимизация экранной процедуры

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

Статистика получилась странная и непонятная, разброс некоторых чисел был очень большой. Я временно решил не обращать внимание на случаи, когда числа улетали вверх, и сконцентрировался на анализе нижних границ. Из всех блоков кода сильнее всего выделялась наша старая знакомая: экранная процедура. В оригинальной игре она выполнялась примерно за 587500 тактов, после оптимизации она выполняется примерно 318000 тактов. Неудивительно, что я не заметил результат оптимизации отрисовки лабиринта: даже старый медленный вариант выполняется в 4 раза быстрее, чем оптимизированная экранная процедура! Что ж, давайте попробуем ускорить экранную процедуру ещё сильнее.

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

$6391 LD BC,($638F)    ;pointer to top-left visible cell
      LD DE, $0801     ;D is pixel row number, E is column number

      LD A, $16        ;counter for outer loop, number of 8-pixel rows
;outer loop
$639A PUSH AF          ;save counter
      PUSH DE
      CALL $62D3       ;calculate video-mem address into HL based on DE
      CALL $6442       ;calculate attribute area address into IX based on HL, and copy screen addr into DE

      ld (spstor), SP
      LD A, $18        ;counter for inner loop, number of columns
;inner loop
$63A4 EX AF, AF'       ;save counter
      LD A, (BC)       ;load first byte of cell code
      LD L, A          ;copy here
      INC BC
      LD A, (BC)       ;load second byte of cell code
      INC BC           ;now BC points to next cell
      LD H, A          ;copy here
      RLCA
      RLCA
      RLCA
      AND $07
      OR $40           ;now reg A contains inc color from high 3 bits of cell code
      LD (IX+$00), A   ;set color for screen cell-block
      ADD HL, HL       ;lower 13 bits of cell code contain offset of cell pixels divided by 8
      ADD HL, HL
      ADD HL, HL       ;get full offset by multiplying by 8, this makes high 3 bits to go away
      LD A, H
      ADD A, $94       ;pixel area base addres is $9400, we add it to pixels offset to get full pixels address
      LD H, A

      LD SP,HL
      EX DE,HL
      LD A,H

      POP DE       
      LD (HL),E    
      INC H        
      LD (HL),D    
      INC H        
      POP DE       
      LD (HL),E    
      INC H        
      LD (HL),D    
      INC H        
      POP DE       
      LD (HL),E    
      INC H        
      LD (HL),D    
      INC H        
      POP DE       
      LD (HL),E    
      INC H        
      LD (HL),D    
      LD H,A

      EX DE,HL
      INC IX           ;move right to next attribute
      INC E            ;move right to next video column
      EX AF, AF'       ;restore loop counter
      DEC A            ;decrement it
      JR NZ, $63A4     ;loop

      LD SP,(spstor)

      LD HL, $0010     ;this offset should be added to cell code 
      ADD HL, BC       ;to get to beginning of next visible cell row
      LD B, H
      LD C, L
      POP DE           ;restore row and column numbers
      LD A, $08        ;move down to next 8-pixel column
      ADD A, D
      LD D, A
      POP AF           ;restore outer column counter
      DEC A            ;decrement it
      JR NZ, $639A     ;loop to next row down
$63F2 RET

Так что же получается, зря мы изучали, как отрисовывается лабиринт и монстры? Не зря. Переписать процедуру отрисовки экрана в таком агрессивном виде я не мог раньше, потому что из-за раскатанного цикла она занимает больше места, чем старая процедура. По адресам $63DF - $6408 располагается процедура отрисовки игрока (см. таблицу). Оптимизация отрисовки ёжиков позволила выделить место, в которое я перенёс процедуру отрисовки игрока, что позволило написать супер-оптимизированную отрисовку экрана.

Ну, а что там по скорости? Новый вариант отрисовки экрана выполняется примерно за 210000 тактов, если мерить точками профилирования. А если измерять точками, которые измеряют FPS, то получаем 110 миллисекунд между срабатываниями, то есть примерно 9 FPS, где-то в полтора раза быстрее, чем предыдущая оптимизация, и в два раза быстрее, чем оригинал. Играть на такой скорости уже очень сложно, потому что темп игры зависит только от скорости выполнения всей логики, включая отрисовку. Мы ускорили отрисовку, а ускорилась вся игра, тут невозможно добавить плавности.

Есть ещё один серьёзный момент. Похоже, что звуковая система игры сделана так, что действие игры прерывается, пока звук не прозвучит до конца. На ускоренном варианте это очень чувствуется: процесс игры становится дёрганным, и дёрганье совпадает со звуками. С воспроизвением звука на ZX Spectrum я не знаком совсем никак, и не чувствую себя в силах разобраться и ускорить.

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

Заключение

Я попробовал оптимизировать старину Fred-а настолько, насколько можно, убедился, что технически концепция работает, но gameplay не очень. Зато в процессе анализа удалось раскрутить назначение всяких внутренних структур игры, например мы знаем, в какой ячейке хранится количество ёжиков. Вот так, от экранной процедуры и вглубь проще всего реверсить игры.

Сам процесс реверсинга Fred-а несложен. Игру делали в 1983 году, и код её немного наивный: очень понятен ход мыслей программиста. Код очень неоптимален по производительности, и в коде много копипасты. Но это не мешает наслаждаться игрой.

Сейчас полностью понятно, как хранится графика игры, есть инструменты для просмотра. Так что если кто-то хочет попробовать себя в pixel art - будет супер.

Спасибо, что прочитали. ZX Spectrum forever!

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


  1. Barsuk
    07.07.2025 08:26

    Думаю что для того что бы игра работала "быстро" нужен отзывчивый на управление геймплей, а для спектрума это значит что надо использовать режим прерываний IM2 и за время между ними отрисовывать экран и опрашивать кнопки. Если все это не помещается в один цикл, поптобовать разделить на два раза. Что то типа такого. В итоге получаем код синхронизированный с прерываниями и визуально ровно работающий.