Разгон игры «Fred» для ZX Spectrum

В один из недавних дней, возвращаясь с работы в транспорте, я, как обычно, отпустил мысли бродить куда им захочется, и они неожиданно забрели в самое начало девяностых, когда я был подростком и у меня был мой первый компьютер, самодельный клон ZX Spectrum 48. Я вспомнил первую игру, которая у меня была, Fred. Проникнувшись воспоминаниями, я добрался до дома, запустил эмулятор и поиграл минут 30, игра хорошая, рекомендую. Но выглядит игра по нынешним временам дёргано, я стал капризным и люблю много fps. А может быть, её можно как-то ускорить без титанических усилий, подумал я. Оказалось — да, можно.

Это Fred

Это Fred

Как в это играть

Это раздел для тех, кто совсем не знает, что такое ZX Spectrum, или очень сильно забыл. Чтобы поиграть в игры, понадобится эмулятор. Самый крутой — Spectaculator, но он стоит денег. Есть много бесплатных и весьма качественных, попробуйте разные, мне нравятся ZX Spin и Klive. Важное уточнение: существует несколько моделей ZX Spectrum, большинство эмуляторов позволяет выбрать, какую модель эмулировать. Для нас подойдёт классика ZX Spectrum 48. Ещё нужно скачать файл с игрой, и эти файлы бывают разные, об этом чуть ниже.

Сразу после включения или сброса, после быстрого тестирования памяти мы увидим надпись »© 1982 Sinclair Research Ltd» на белом фоне, и это означает, что интерпретатор BASIC готов к работе, можно нажать какую-нибудь клавишу, например ENTER. Если вам совершенно не интересно разбираться в том, как работать на этой древней машине, а хочется просто поиграть в игры, то у меня для вас хорошая новость: это вполне возможно. Игры для ZX Spectrum доступны для эмуляторов в нескольких форматах. Первый тип форматов — полные снапшоты состояния компьютера, хранятся в файлах с расширениями .sna или .z80. Такие снапшоты обычно создаются, когда игра полностью загружена и показывает главное меню. Вам достаточно просто открыть файл со снапшотом в эмуляторе, и можно играть. Второй тип форматов — образы магнитной ленты или дискеты, хранятся в файлах с расширениями .tap, .tzx, .dsk. Они подключаются к эмулятору и изнутри выглядят как кассета в магнитофоне или дискета в дисководе. Дальше с файлами, хранящимися в образе, можно работать, используя команды операционной системы эмулируемого компьютера. Однако эмуляторы и тут приходят на помощь, многие из них умеют заставить эмулируемый компьютер автоматически загружать файлы из образов, да ещё и в ускоренном режиме. Если вы разобрались, как это сделать в вашем эмуляторе, и вам полностью достаточно такого способа — это прекрасно, можете пропустить следующие абзацы.

Итак, сразу после включения запускается интерпретатор BASIC, который находится в ROM эмулируемого компьютера. Этот интерпретатор также выполняет роль примитивной операционной системы. Нижняя часть экрана — это область ввода и редактирования команд. Реализация ZX Spectrum BASIC необычна тем, что в ней операторы вводятся не побуквенно, а сразу, целиком. Когда редактор отображает мигающий курсор «K» (что означает «keyword»), то каждая клавиша соответсвует оператору, который будет введён при её нажатии. Иногда соответствие клавиш и операторов вполне интуитивно («P» → «PRINT», «L» → «LET», «F» → «FOR», «N» → «NEXT», «G»→«GOTO»), для остальных можно определить экспериментально. В старые времена пользователи покупали наклейки на клавиатуры, сейчас большинство эмуляторов имеет окошко с подсказкой по спектрумовской клавиатуре, на котором можно увидеть, что кнопок на спектрумской клавиатуре немного, всего 40, поэтому кнопки совмещают много функций.

Окно эмулятора и экранная клавиатура

Окно эмулятора и экранная клавиатура

После ввода оператора курсор сменится на мигающую «L» («letter»), и редактор будет воспринимать нажатия клавиш как отдельные буквы. У ZX Spectrum есть две shift-клавиши: CapsShift, которую нужно удерживать, чтобы набрать заглавные буквы, и SymbolShift, которую нужно удерживать, чтобы набирать символы, расположенные на тех же клавишах, что и буквы/цифры. В оригинальном ZX Spectrum они располагались так: CapsShift внизу слева, SymbolShift внизу справа, а сейчас в разных эмуляторах эти клавиши отображаются на клавиатуру по-разному: иногда это левый и правый Shift, а иногда задействуются Ctrl или Alt, иногда это можно поменять в настройках. Проверьте свой эмулятор, набрав, например, PRINT «Hello», пусть это будет вашим первым испытанием. Стирать неправильный ввод можно, нажав CapsShift и 0, это спектрумовский backspace, но многие эмуляторы понимают и современный backspace. Ключевые слова операторов стираются также целиком. Когда вы нажмёте ENTER, то интерпретатор сразу исполнит введённую команду, если в ней нет BASIC-овского номера строки в начале, если же есть, то строка добавится в текст текущей программы. Попробуйте выполнить вышеуказанный PRINT.

Чтобы загрузить программу с магнитофонной ленты, нужно набрать LOAD ». На всякий случай, это делается так: нажать «j», на экране напечатается ключевое слово «LOAD», потом нажать SymbolShift (это может быть правый Shift, или правый Ctrl, или правый Alt) и два раза «p», напечатаются кавычки, потом нажать ENTER. Большинство детей, которым родители покупали Спектрумы для игр, полностью обходились этим набором действий. После ввода этой команды бордюр экрана начинает медленно мигать красным и голубым цветами, ожидая сигнала от магнитофона. В старые времена нужно было нажать кнопку воспроизведения, а сейчас нужно открыть в эмуляторе файл с образом магнитной ленты (расширения файлов .tap или .tzx), после чего нажать виртуальную кнопку воспроизведения. Файлы, записанные на магнитной ленте, начинаются с пилотного сигнала, который звучит как чистый тон в течении примерно 3 секунд, он нужен для подстройки фазы считывателя. И сразу же после пилотного сигнала начиналась какофония звуков, которую можно было слушать примерно 5 минут, пока игра загружалась, полоски на бордюре при этом сменялись на сине-жёлтые. Если вы настоящий хардкорный фанат, или хотите проникнуться духом эпохи, то вы непременно должны терпеливо ждать, пока программа полностью загрузится. Для тех же, кто хочет просто поиграть, большинство эмуляторов умеют выполнять быструю загрузку.

Так происходит загрузка игры

Так происходит загрузка игры

Задача игры в том, что оказавшись на самом нижнем уровне лабиринта, выбраться из него, найдя выход, расположенный на самом верху. Лабиринт генерируется случайно. Трудность в том, что игроку видна лишь малая часть лабиринта вокруг него, потому что графика в игре крупная (и мультяшная). К счастью, петель в лабиринте нет, только развилки и тупики. И, конечно же, лабиринт населён врагами, которых становится больше при продвижении в следующие уровни игры. На первом уровне вам противостоят ёжики, через которых надо прыгать (точнее прыгать надо на месте, ёжик сам пробежит под вами), ядовитые капли, и страшные привидения без головы, которые умеют проходить через стены. У вас вроде бы есть пистолет, но по ёжикам он не попадает, потому что они слишком мелкие, а привидению на пули почти пофиг, они могут только поменять направление движения. Так что на пистолет полагаться не стоит, но он станет полезнее против врагов, которые появляются на новых уровнях

Технические подробности

ZX Spectrum работает на процессоре Z80, который способен адресовать 64 кбайт памяти, и в данном компьютере эта память распределена так: нижние 16 кбайт отображаются в ROM, в которой прошит интерпретатор BASIC-а, остальные 48 кбайт — это RAM, включающая видео-память. Экран состоит из центральной части с разрешением 256×192 пикселей, и цветного бордюра. У ZX Spectrum имеется единственный видеорежим, и весь видеовывод сводится к записи в видеопамять. По адресам 4000–57FF расположена область пикселей, в которой каждому байту соответствует горизонтальная группа из 8 пикселей, то есть по одному биту на пиксель. По адресам 5800 расположена область цветовых атрибутов, каждый байт которой определял два цвета для группы 8×8 пикселей: один цвет для пикселей со значением 1 (цвет тона, на Спектруме известен как INK), и второй цвет — для пикселей со значением 0 (цвет фона, на Спектруме известен как PAPER). Такую группу пикселей мы будем дальше называть блоком-ячейкой или блоком-клеткой, весь экран делился на 32×24 ячейки. Казалось бы, такая грубая возможность раскраски подходит только для трёх случаев: вывод цветного текста (одна буква — один блок-ячейка), вывод монохромной графики достаточно высокого разрешения (не обязательно чёрно-белой, можно выбрать два любых цвета), и вывод грубой цветной блочной графики. При попытке нарисовать что-то цветное высокого разрешения происходило наложение цветов, знаменитый color clash.

Увеличенное изображение из игры, жёлтые линии показывают границы блоков-ячеек

Увеличенное изображение из игры, жёлтые линии показывают границы блоков-ячеек

Однако авторы игр со временем выработали приёмы игрового дизайна для обхода этих ограничений. Адресация видеопамяти обладает ещё вот какой особенностью: при переходе на следующий адрес в области пикселей мы сдвинемся вправо, но дойдя до правого края строки (в которой 32 байта) мы попадём не на следующую строку, а на 8 строк ниже, потом ещё на 8 строк ниже, и так 8 раз. Только после этого адрес станет указывать на вторую строку пикселей. Иными словами, последовательное увеличение адресов видеопамяти формирует через-8-строчную развёртку. Это можно наблюдать на анимированной картинке выше: когда игра загружается, то байты считываются с ленты последовательно в видеопамять, рисуя заставку. Такая адресация означает, что для сдвига к группе пикселей справа нужно увеличить адрес на 1, а для сдвига к группе пикселей вниз, в пределах одного блока-ячейки, нужно увеличить старший байт адреса на 1. Это знание нам очень пригодится.

Анализ

Попробуем применить наше знание для анализа того, как выглядит и отрисовывается игра. Даже не заглядывая внутрь игры мы можем уже многое сказать о её устройстве. Лабиринт состоит из больших блоков размером 4×5 клеток (32×40 пикселей), каждый блок лабиринта окрашен в один цвет. Скроллинг в игре выполняется с шагом в 1 блок-ячейку (8 пикселей), что позволяет избегать наложения цветов на стыках блоков лабиринта. Враги также двигаются с шагом в 8 пикселей.

В старые времена этот скроллинг по 8 пикселей казался вполне терпимым. Но игра прорисовывает экран очень медленно, что делает процесс очень дёрганным. Может, это и неплохо, есть время подумать и игра становится немного стратегичной, но мне очень захотелось сделать движение в игре визуально плавнее. Я верил, что это возможно, потому что существуют игры с гораздо более плавным скроллингом, например моя любимая Soldier of Fortune. Значит, нужно уменьшить паузы между кадрами.

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

Код этой процедуры очень понятный, можно даже сказать что он слишком очевидный и чуть-чуть наивный. Это не насмешка и не выпендрёж, наоборот, простота и ясность этого кода мне очень нравятся. Так писали в 1983 году. Нам повезло, что эта процедура очень компактная. Листинг нужно понимать так: для некоторых команд перед ними стоит адрес, по которому эти команды расположены в памяти, для читабельности я оставил адреса только для тех команд, на которые делается переход. Шестнадцатеричные числа записываются со знаком доллара в начале.

;
;Main video loop. Draw a screen based on representation of 2-byte cells corresponding to 8*8 pixel screen cells.
;
$6391 LD HL,($638F) ;reg HL points to top-left cell
$6394 LD DE, $0801  ;initialize pixel row and column pointers. reg D - initial pixel row (8), reg E - column (1)
      LD C, $16     ;height of game screen, number of 8-pixel rows

;outermost loop
$6399 PUSH DE       
      PUSH HL
      LD B, $18     ;width of game screen, number of cells in row

;outer loop, go by columns from left to right in one 8-pixel row
$639D PUSH BC
      PUSH DE      
      LD E, (HL)   ; get first byte of a cell value
      INC HL       ; 
      LD D, (HL)   ; get second byte of a cell value
      INC HL       ; move to next cell
;top 3 bits of cell code is a paper color for screen block, remaining bits, if multiplied by 8, are offset to 8 bytes of pixels
;to extract this info from cell code, it's value is shifted left 3 times
      XOR A
      LD B, $03   
$63A6 SLA E       
      RL D        
      RL A        
      DJNZ $63A6  
; after this loop, reg A is paper color, reg DE is offset to pixel data
      PUSH HL      ; store cell pointer, since we need HL 
      LD HL, $9400 ; base of pixel data, each unit is 8 bytes
      ADD HL, DE   ; add offset, now HL point to pixel data

      POP BC        
      POP DE       ; this is what we actually want to restore - screen row and column pointer
      PUSH DE      ; but still want it to be preserved for later
      PUSH BC       

      PUSH HL      ; save source pixel pointer

      PUSH AF
      CALL $62D3   ; calculate video mem address into HL based on screen row and column values from DE
      POP AF
      PUSH HL      ; save video mem addr
      CALL $6442   ; update from reg A an attribute area corresponding to video mem addr in HL
      POP HL       ; restore video mem 

      LD B, $08    ; loop counter, will draw 8 bytes on screen, one 8x8 box, each byte is row of 8 pixels
      POP DE       ; restore pointer to source pixels
;innermost loop, transfer pixels into video mem
$63C5 LD A, (DE)   ; get pixels byte from source
      LD (HL), A   ; put pixels byte into video mem
      INC DE       ; to next source pixels byte
      INC H        ; down to next video mem row
$63C9 DJNZ $63C5   ; loop 8 times 

$63CB POP HL       ; restore source cell pointer
      POP DE       ; restore screen pixel row and column pointers
      INC E        ; move to next column to the right
      POP BC       ; restore row and column counters
$63CF DJNZ $639D   ; loop to fill next screen cell

$63D1 POP HL       ; restore cell pointer at the begining of a row
      LD DE, $0040 ; how many bytes to add to get to cell which is 1 row down
      ADD HL, DE   ; now HL points to the leftmost cell of next row of cells

      POP DE       ; restore screen pixel row and column pointers
      LD A, D
      ADD A, $08   ;move to next pixel row
      LD D, A

      DEC C        ;number of remaining rows
      JR NZ, $6399 ;outermost loop, go and fill next row
$63DE RET

Процедура отрисовки динамического игрового экрана состоит из трёх вложенных друг в друга циклов. Самый внешний цикл идёт сверху вниз по 8-пиксельным строкам, внутренний цикл идёт слева направо блоками/клетками 8×8 пикселей внутри одной строки, и самый внутренний цикл идёт внутри клетки сверху вниз строчками 8 пикселей в ширину и 1 пиксель в высоту, каждая из которых занимает 1 байт. Информация о том, как отрисовать клетку 8×8 закодирована двумя байтами, причём старшие 3 бита описывают цвет, остальные 13 бит — смещение, по которому хранятся 8 байт пикселей. Итого, при превращении закодированного экрана в отрисованный экран получается распаковка 2 байта в 9 байт (8 байт пикселей и 1 байт цвета). Зачем нужен закодированный экран, почему не отрисовывать сразу? Есть две причины. Во-первых, наличие промежуточного представления экрана позволяет заполнять это промежуточное представление по частям: одна процедура рисует стены, верёвки и остальные статические элементы лабиринта, другая процедура рисует персонаж игрока, третья процедура рисует врагов, и так далее. Во-вторых, лабиринт состоит из блоков 4×5 клеток (32×40 пикселей), и при шаге скроллинга 8 пикселей ясно, что очень часто будет так, что блоки по краям будут отрисованы не полностью. Вместо того, чтобы делать сложную логику обрезания блоков, в игре используется промежуточная область чуть больше, чем видимый размер экрана, и хранится смещение видимой области. Это означает, что не далеко не каждый скроллинг приведёт к пересчёту закодированного экрана, чаще всего только изменится смещение, и перерисуется видимый экран.

В качестве счётчика самого внешнего цикла выступает регистр B, в качестве счётчика внутреннего цикла выступает регистр C. Поскольку эти регистры портятся внутри циклов, значения счётчиков сохраняются на стеке. Закодированный экран хранится построчно, слева направо, поэтому для перехода на следующую блок-клетку вправо нужно перейти к двум следующим байтам. Логично, что и видимый экран рисуется в том же порядке. Итого два внешних цикла двигаются по элементам и закодированного экрана и видимого экрана, самый внутренний цикл копирует байты, принадлежащие одной клетке. Во внешних циклах на элемент закодированного экрана указывает регистровая пара HL, а на видимую область указывают два значения: регистр D, хранящий номер однопиксельной строки, соответствующей самым верхним строкам блоков-ячеек, и регистр E, указывающий на номер 8-пиксельного байта в строке. Чтобы превратить эти два значения в видео-адрес, вызывается вспомогательная процедура, расположенная по адресу $62D3, после которой вызывается ещё одна вспомогательная процедура $6442, которая занимается вычислением адреса в области цветовых атрибутов. При движении слева направо в цикле по блокам-клеткам внутри строки инкрементируются и горизонтальный указатель регистр E, и указатель за закодированый блок регистр HL (инкрементируется два раза, конечно). Счётчиком самого внутреннего цикла снова выступает регистр B. Тут происходит просто копирование байтов пикселей из памяти в видеопамять, а чтобы перейти на строку вниз, увеличивается старший байт видеоадреса.

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

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

Оптимизация

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

;screen drawing proc
;populates video memory and colors based on contants of encoded screen buffer
$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 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 ink 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

      PUSH BC          ;store cell code pointer, since we will need to use BC
      LD C, D          ;save high byte of video mem addr here
      LD B, $08        ;loop counter
;innermost loop
$63C0 LD A, (HL)       ;get pixels byte from source
      LD (DE), A       ;put pixels byte onto screen
      INC L            ;move to next pixels source byte
      INC D            ;move down to next video 8-pixels row
      DJNZ $63C0       ;loop

      LD D, C          ;restore DE so it is the same as before pixels copying
      POP BC           ;restore cell code pointer
      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 HL, $0010     ;this offset should be added to cell addr 
      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
$63DE RET


;inputs: HL
;outputs: IX,DE
$6442 LD A,H
      AND $18
      SRA A
      SRA A
      SRA A
      ADD A, $58
      LD D, A
      LD E, L
      PUSH DE
      POP IX
      LD D,H
$6453 RET 

Пройдёмся по изменениям. Чтобы код остался компактным, я постарался уменьшить количество сохранений на стек и перемещения данных между регистрами, для чего пришлось продумать хранение значений в регистрах. Регистровая пара BC хранит указатель на текущую закодированную ячейку, пара DE — адрес в видеопамяти, IX — адрес в памяти цветовых атрибутов, пара HL используется для вычислений. Счётчики для внешнего и внутреннего циклов хранятся в регистре A и в альтернативном регистре A, потому что так проще их уменьшать в конце цикла.

Самая глобальная оптимизация — перенос вызова процедуры вычисления адреса в видеопамяти во внешний цикл. Сразу же вызывается процедура, которая вычислит адрес в памяти цветовых атрибутов. Во внутреннем цикле есть оптимизации поменьше. Чтобы разбить код ячейки на цвет и смещение адреса пикселей, я вместо цикла (который требует регистр для счёта циклов, которых 3), в котором использовались двухбайтовые команды сдвига SLA и RL, я отдельно выделяю цвет через быстрые однобайтовые команды сдвига RLCA, и отдельно умножаю смещение на 8 через сложение его с самим собой. Старый вариант занимал примерно 110 тактов процессора, новый занимает 62 такта. Далее, полный адрес источника пикселей получаем, добавляя смещение к базе, которая равна $9400, и мы можем добиться этого экономнее, сложив только старшие байты смещения и базы. Для самого внутреннего цикла мы очень хотим использовать в качестве счётчика регистр B, потому что он задействован в самой быстрой и компактной команде для циклов DJNZ. Значит, нам придётся сохранить регистровую пару BC на стеке, что позволит нам заодно занять регистр C для временного хранения значения из регистра D вместо того, чтобы сохранять весь DE на стеке. В самом внутреннем цикле можно добиться крохотной экономии тактов процессора, увеличивая только младший адрес источника пикселей.

В старом варианте процедуры шаг внутреннего цикла выполнялся 904 такта процессора, новый же выполняется 476 тактов. Со старой процедурой игра обновляла экран примерно каждые 224 миллисекунды (что примерно 4.5 fps), с новой процедурой между обновлениями экрана проходит 153 миллисекунды (что примерно 6.5 fps). По моим ощущениям играется ощутимо бодрее, мои старые игровые рефлексы перестали работать.

Что дальше

Итак, шаг внутреннего цикла после оптимизаций выполняется 476 тактов, из которых 280 тактов занимает самый внутренний цикл, то есть копирование из памяти в память.

Как ещё можно ускорить игру? Есть несколько идей.

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

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

  3. Самый внутренний цикл можно развернуть. Не обязательно все 8 проходов.

  4. Копирование из памяти в память по одному байту — это медленно. Занятная особенность процессора Z80 состоит в том, что самые быстрые операции с памятью — это стековые операции PUSH и POP. Они мало того, что пишут/читают по два байта за раз, так ещё и сдвигают указатель стека на два байта, что делает их очень удобными для последовательных действий с памятью

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

Где взять?

https://github.com/kmatveev/zx-fred-reveng/tree/main/turbofred-1

можно скачать снапшот или .tap-файл

© Habrahabr.ru