В общем-то и целом, мой предыдущий эмулятор ZX Spectrum, написанный на go, работал. Показывал нужное (или очень близко к нужному) и давал чувство приближенности к программистам. Однако у него были очень фундаментальные проблемы, связанные исключительно с языком разработки. Ну по крайней мере я сейчас так думаю.
Другим движущим фактором все-таки была бесплатность БЯМок от cloud.ru (не реклама, да и бесплатности больше нет). Ну где еще можно на халяву пожечь сотни миллионов токенов и получить хоть что-то работающее?
В общем, я принял волевое решение переписать все на С++. Под катом дневник "переписчика", в котором я последовательно описываю все боли и страдания начинающего писателя эмуляторов. Желающим сразу посмотреть на конечный результат можно сходить на https://github.com/kiltum/zxcpp
Что не так с go? Самая большая боль это лаги. Нажимаешь ты, к примеру, клавишу "стрелка влево", а игра начинает реагировать с задержкой. Хоть задержка и небольшая, но самое неприятное в том, что эта задержка плавающая. Поначалу я списывал все на свои кривые руки, дескать не постиг всего goшного дзена и все такое прочее. Однако скачав и запустив gospeccy (как видно из названия, он тоже написан на go), я обнаружил там те же самые проблемы, пусть и в меньшей степени. После Н часов разборок выяснилось (тут капитан очевидность), что в go все межпотоковое взаимодействие устроено на каналах. И все обычно используют буферизированные, иначе вылазит куча проблем с фризами на ровном месте. И если для обычных программ это не вызывает никаких проблем, то в эмуляторах, где обычно важен каждый тик в синхронизации, это ад и ужас.
День первый. Первым делом необходимо перенести эмулятор Z80. Решил задачу просто: клал рядом файл на go и просил БЯМ переписать его на С++. Благодаря очень схожему синтаксису языков шансов ошибиться было мало. Следом попросил БЯМ по похожему шаблону перенести тесты.
В мире эмуляторов ZX Spectum есть два тестовых монстра, на которые ориентируются все остальные. Первый это тест FUSE из одноименного эмулятора. Представляет собой два текстовых файлика. В первом находится исходное состояние регистров процессора и состояние памяти. Во втором - тоже самое, но уже после выполнения команды. Процесс тестирования прост: читаем исходное, с помощью эмулятора выполняем нужное и потом сравниваем получившееся с требуемым. Просто, надежно и 100% дает понять, какой опкод работает, а какой нет.
Второй тест носит название zex и распространяется в виде двух файлов zexall.com и zexdoc.com. Отличие этих файликов только в наборе проверяемого: zexall тестирует все команды, включая недокументированные, а zexdoc тестирует только "официальные". Это обычные программы для CP/M, которые гоняют комбинации команд с разными значениями и затем сравнивают контрольную сумму с эталонной. Совпало - тест пройден. Не совпало - у меня проблемы. Процесс тестирование не менее простой: загружаем код по адресу 0х100, перехватываем функции CP/M "вывод символа" и "вывод строки" и начинаем его выполнение. Как РС стал 0х0000 - тест закончился.
И тут обнаруживается первая боль, правда уже известная по версии на go: тесты по командам SCF и CCF проходят либо там, либо там. Я читал форумы, курил мануалы, мучал БЯМки, но получить оба теста рабочими не получалось. В итоге смалодушничал и подправил FUSE. Но галочку в памяти поставил.
День второй. Скорость выполнения zexall меня удручала. Поначалу казалось, что это я такой нетерпеливый, но выполнение с командой time показало правду: 160 секунд на С++ против 60 на go. Как так-то? Ведь все вокруг считают, что должно быть наоборот! В общем, ударился в оптимизацию. Добавление флага -О3 выравняло скорости. Но мне мало и я с помощью БЯМки научился профилировать, посмотрел наиболее часто вызываемые функции, пооптимизировал код и методы вызова... Итогом стало 32 секунды.
День третий. Понял, что провел предидущий день абсолютно зря. Какой смысл оптимизировать один маленький кусочек, если всего остального нет? Значит следующий шаг это реализация видеовывода. За роль видеокарты в ZX Spectrum отвечает специализированная микросхема ULA. Причем из-за того, что она работает в режиме реального времени (ведь луч в ЭЛТ мониторе ждать не может), эта микросхема полностью главенствует над Z80. Тактирует, когда можно и тормозит (буквально, прекращением подачи тактовых сигналов), когда нельзя. Так же она обслуживает клавиатуру и ввод-вывод с магнитофона. Иначе говоря, в нынешних реалиях это южный и северный мост в одной коробке.
Посмотрел на реализацию ULA на go. Какой же коварной была БЯМка, когда утверждала, что она нарисовала реализацию, полностью соответствующую таймингам! Врала, причем как сивый мерин. Значит, надо писать новую!
БЯМки тут слабые помошники, поэтому зарылся в форумы и осколки документации. Самым неприятным оказалось то, что все описывали поведение ULA по разному. Сходились только три числа: каждый кадр "рендерится" 69888 тактов, время отрисовки одной строки 224 такта и от сигнала INT до начала отображения первого байта в левом верхнем углу проходит 14366 такта.
Немного помучавшись найти точные данные, я сел изобретать свои с помощью калькулятора. Итак, самой главной точкой отсчета является момент, когда ULA ставит сигнал INT процессору. Все, кадр отрисован и ... И что? А в реальности происходит следующее: монитор гасит луч и катушки отклоняющей системы начинают двигать его назад, в верхний левый угол. Это происходит не мгновенно, так что у реального процессора есть куча циклов что-нибудь поделать. Потом ULA генерит всякие синхроимпульсы и прочее, что необходимо уже монитору, чтобы определиться, где начинается кадр. И наконец все понимают, что момент "начинаем рисовать" наступил. На счетчике тактов красуется цифра 3560.
Луч начинает свое движение слева-направо. На экране у ZX Spectrum тут бордюр, поэтому ULA не нужен доступ к памяти и она просто рисует тем цветом, что хранится в регистре. Ширина экрана 352 точки, на каждый такт рисуется две точки. Итого проходит 176 тактов, прежде чем отрисуется одна линия. Куда деваются остальные 48? На гашение луча и возврат его на следующую строчку.
После отрисовки 48 линий бордюра сверху наступает череда экранной области. Тут распределение тактов аналогичное. 24 такта на бордюр слева, 128 тактов на экран, 24 такта на бордюр справа и 48 на возврат луча. И вот тут скрывается одна из особенностей спектрума. Если процессор в это время выполняет команду из "нижних" 16к памяти, где находится экран, то возникает конфликт кому читать и ULA тормозит процессор. Причем на разное число тактов, которое зависит от того, что именно сейчас рисует луч. В результате есть побочный эффект: программы в "нижней" памяти выполняются медленней. Реализовать этот эффект при текущей "архитектуре" эмулятора раз плюнуть, но я не смог найти хоть какого-то использования этого эффекта в реальной жизни. В общем, пропускаем.
Есть и еще один баг, называемый "эффектом снега". Он опять же возникает из-за бага в ULA и аппаратного конфликта, но так же нигде не используется, кроме как в паре демок. Более того, он присутствует только на первых спектрумах, поэтому его реализацию я тоже пропускаю. В общем, желающие могут почитать про него сами.
Вернемся к таймингам. Нижний бордюр отрисовывается абсолютно точно так же, как и верхний. Наконец на такте 68072 луч доходит до правого нижнего угла как монитора, так и экрана. Что делается 1816 тактов до сигнала INT - я не разбирался. В импортном это называется overscan, где монитор тоже принимает какую-то синхронизацию по окончанию кадра.
Итого у нас получилось 3560 (синхронизация) + 10752 (48*224 на верхний бордюр) + 43008 (экран) + 10752 (нижний бордюр) + 1816 (оверскан) = 69888 тактов. Идеальное попадание, бьющееся со всеми цифрами.
У версии с 128 килобайтами тактировка немного другая, в основном из-за работающего на более высокой частоте процессора. Там формула получается такой: 3368 + 10944 + 43776 + 10944 + 1876 = 70908. Желающих рассмотреть детально приглашаю в src/ula.cpp.
Следующий шаг это клавиатура. Тут реализация очень простая. Если нажали кнопку на реальной клавиатуре, то просто гасим (да, у спектрума ненажатая кнопка это 1) или поднимаем бит в соответствующем байте. Абсолютно ничего сложного или заковыристого. Аналогично и для кемпстон джойстика, только там 1 нажатая кнопка, а 0 - отпущенная.
Последний шагом подсовываю образ ПЗУ от спектрума и ...

День 4. Радостный, что вчера все реализовал так хорошо, подсовываю диагностический ром. И тут же получаю ошибкой по физиономии. Оказывается, у меня 16к версия спектрума - самая первая в линейке. При попытке насильно потестировать верхние 32 килобайт получаю вот такой вот экран

Сказать, что я офигел мрачно это покривить душой. Какие неисправные микросхемы? Там вся память это массив в 64 килобайта из uint8_t! Но я точно помню, что гошная версия проходила этот тест!
Немного помучавшись и не найдя причин такого поведения, я решил сжульничать. Раз у меня есть версия, которая проходит тест, то я просто добавлю самый мощный механизм отладки: просто буду выводить состояние РС у процессора и сравню его с "нерабочим вариантом".
Различие выяснилось довольно быстро:
PC: 925 c3
Lines differ:
File 1: PC: 961 21 < GOOD
File 2: PC: 246 < BAD
Расшифровываю. Где-то по адресу 925 есть команда JP, которая в рабочей версии переходит по адресу 961, а в моей по адресу 246. Открываю дамп тестового рома и вижу 961. Как это так?? Ну как может обычная команда JP вести себя так по разному?
Вручную дизассемблирую код
PC: 939 10 DJNZ 0xfe (till b>0, repeat)
PC: 93b 2b DEC HL
PC: 93c 7c LD A,H
PC: 93d b5 OR L
PC: 93e 20 JR NZ, ed
PC: 940 39 ADD HL,SP
PC: 941 e9 JP (HL) Jump PC<-HL
PC: 920 6 LD B,n n
PC: 922 31 LD SP,nn
PC: 925 c3 JP 0961
Все логично, просто бежит какой-то цикл и потом безусловный переход. Давай посмотрю, что там на этом 0х246
PC: 23b af XORA
PC: 23c 1 LD BC,nn
PC: 23f ed
PC: 241 dd
PC: 245 fb EI
PC: 246 1 LD BC,nn
PC: 38 33 INC SP
PC: 39 33 INC SP
PC: 3a dd ????
PC: 254 6 LD B,n
PC: 256 db IN A,(n) <<< IN 1F
257 - > 1f
PC: 258 e6 AND n
259 -> 1f
PC: 25a 20 JR NZ, e
Код работает, работает, срабатывает INT (это кусок с адресом 38) и продолжает. Потом он читает порт кемпстона и в зависимости, есть он (возврат 00) или нет (FF) уходит куда-то еще. На "старой" версии джойстика не было, может проблема в этом? Ок, отключаю обработку порта 1F. Ничего не меняется. Да как так-то?
Тут, что бы вас не мучать, я опущу кучу логов и разборок. Я вставлял "брекпоинты", дампил состояние регистров и наконец нашел причину. Зачем-то тестер немного раньше просто записывал в адреса 926 и 927 значения 46 и 2. На реальной машине это производит абсолютно нулевой эффект. Но у меня-то разницы между ПЗУ и ОЗУ абсолютно никакой нет! В итоге получался зацикленный код, который сразу после тестирования нижних 16 килобайт возвращался назад и начинал тестировать снова. Решение простое: в сеттере памяти просто прописать условие "если пытаются записать в адреса ниже 0х4000, то ничего не делай".
После правок тестер милостливо согласился, что у меня не 16к машина, но все так же ругался на верхнюю память. Битая и все тут! Хорошо, инструментарий уже наработан, поэтому я довольно быстро вышел на нужный участок кода.
PC: 3f8 ed
PC: 3f8 ed
PC: 3f8 ed
PC: 3f8 ed b1 CPIR
Lines differ:
File 1: PC: 3f8 ed
File 2: PC: 3fa 20
Press any key to continue...
Lines differ:
File 1: PC: 3f8 ed
File 2: PC: 3fc fd
Расшифровываю. По адресу 0x3f8 находится инструкция CPIR, логика которой простая до безобразия: повторяем инструкцию CPI, пока BC не станет нулевым. А CPI просто сравнивает содержимое памяти по адресу HL с содержимым регистра А, ставит флаг Z в зависимости от результата и уменьшает HL, одновременно увеличивая BC. Говоря иными словами, перед вами простая проверка "память заполнена значениями ХХ".
Добавляю еще кучу дампов и получаю следующее
WB fffb ff
WB2 fffb ff
WB fffc ff
WB2 fffc ff
WB fffd ff
WB2 fffd ff
Сначала тест заполняет всю память значениями FF. Это ожидаемо.
RB fffb ff
RB fffb ff
RB fffc 0
RB fffc 0
RB fffd 0
А вот следующим шагом он читает из памяти и получает в четырех последних байтах 0. Естественно, он считает, что память битая и фейлит тест. Но у меня-то память обычный массив! И судя по дебагу, никто и ничего между шагами туда не пишет! Что-то "снаружи" портит память.
Если не я, то кто? Конечно, сразу же подумал на компилятор. Я уже сталкивался с тем, что GCC на армах генерил непотребство. Ну и что, что эти армы были STM32, а не М1? ARMы же! В пользу этой версии говорил тот факт, что при выключенной оптимизации тест внезапно проходил!
Сменил компилятор на clang. Тот же эффект. Значит, виноват все-таки я. Пошел с мольбой в локальный чатик, где тусуются гуру С++, съевшие не один пуд соли. Через буквально пять минут я получил совет добавить в опции компилятора -g -fsanitize=address . Да, будет работать помедленней, но скажет, если программа полезет куда-то не туда.
И вуаля! первый же запуск произошел с крашем, в котором белом по черному было написано, что код из ULA лезет не туда, куда надо. При -О3 массивы "памяти" и "экрана" в памяти рядом, без выравнивания и в результате код залазит немного не туда. Падения не происходит, потому что для операционки абсолютно пофиг, кто и как туда лезет, лишь бы не за границу выделенной области. Фикс тривиальный и вуаля! Все тесты памяти проходят, все соглашаются, что у меня спектрум 48к с полностью исправной памятью. Ура, победа!
И снова фиг там по всей морде. Тест ULA утверждает, что прерывание происходит слишком часто. И что самое странное, каждый раз цифры "на сколько быстрее" разные. Ушел в следующий день задумчивым.
День пятый. Мучал тест с разных сторон. Каждый запуск - разное значение. Вручную выставляя другие тайминги со стороны ULA смог добиться только того, что гарантированно получал "слишком редко" или "слишком часто". Сломал мозг на предмет поиска того, на что же еще может ориентироваться тест. Через некоторое время сдался и написал автору теста письмо "чтозанафиг, памагитечемможете".
День 6. Автор теста не отвечает. Продолжаю мучать тайминги и попутно начал приделывать звук. Основная проблема со звуком состоит в том, что процессор, работая на частоте 3,5МГц, дергает ножку ULA, которая в реальной жизни подсоединена через транзистор к динамику (ну или на выход EAR). Сколько проходит тактов между дерганиями ножки мне известно, поэтому нужен простой конвертор PWM в PCM. Тут сильно помогла БЯМка, быстро рисуя тестовые функции. Звук появился, но хрипящий и булькающий. Ну хоть какой-то успех на фоне остального.
День седьмой. Автор теста ответил! И даже кусок кода, который это тестирует приложил! С разрешения автора я приложу самую важную часть
irq_test_lp ld ix,$+7 ;raster will be at unknown point in frame here so wait for new IRQ (frame)
jp wait_for_irq
ld ix,$+7 ;14 - wait for IRQ from known point
jp wait_for_irq ;10
ld l,h ;HL = number of cycles per IRQ /10 (HL= A:HL<<8)
ld h,a
ld de,0+(24+32+20+20)/10 ;add on set-up and IRQ processing overhead (last +20 is assumed CPU response delay)
add hl,de
...
...
...
wait_for_irq ld hl,0 ;10 - when IRQ occurs, jump made to IX
ei ;4 - if no IRQ occurs before HL loops, show time out message
xor a ;4
ld e,a ;4
ld bc,$280 ;10 - ie: 2.5 in decimal (25 cycles / 10 to make dec-conversion easier)
wfirq_lp add hl,bc ;11
adc a,e ;4
jp nc,wfirq_lp ;10 = 25 cycles per loop
irq_time_out ld bc,irq_timed_out_txt
irq_error push bc
pop hl
ld bc,$0008
rst $30
call error_beep
rst wait_key_press_rst
jp system_test_menu
Что он делает? Сбрасывает счетчик тактов и ждет прерывания. Ведь на данном шаге нам не понятно, где и как и что рисует ULA. Как только прерывание пришло, он снова сбрасывает счетчик и считает, сколько прошло тактов до следующего. И далее сравнивает с уже известными значениями. Ну или рапортует, что прерываний вообще нет.
Вам понятно, где ошибка? Надеюсь, что точно так же как и мне - нет. Ведь прерывание у меня генерирует ULA, там все захардкожено на такты и варианта "лишнее" просто нет. Но тест проваливается. Ладно, нахожу расположение этого теста в ПЗУ и врубаю супер-дупер мощный дебаг в виде printf.
DI: e86
T: 0 U: 29943 PC: 238d A: 0 HL: 23e1 I: 1
T: 10 U: 29953 PC: 2390 A: 0 HL: 0 I: 1
EI: 2391
T: 20 U: 29963 PC: 2391 A: 0 HL: 0 I: 1
T: 71 U: 30014 PC: 238d A: 0 HL: 0 I: 0
T: 81 U: 30024 PC: 2390 A: 0 HL: 0 I: 0
EI: 2391
T: 91 U: 30034 PC: 2391 A: 0 HL: 0 I: 0
T: 95 U: 30038 PC: 2392 A: 0 HL: 0 I: 0
T: 99 U: 30042 PC: 2393 A: 0 HL: 0 I: 0
И снова расшифровываю. Где-то по адресу 0хе86 тестер запрещает прерывания. Код выполняется-выполняется и прибегает ULA с "я отрисовала экран, вот тебе прерывание". Так как стоит запрет прерываний, процессору на это пофиг и он продолжает работать как и раньше. Наконец процессору разрешают обработать прерывание командой EI, он быстренько пробегает первый цикл (строка 5) и так же быстренько пробегает второй (строка 8). Но обратите внимание на T: и U: . Это счетчики тактов процессора и внутренних тактов ULA. Разница между первым циклом и вторым 10 тактов, которые ушли на обработку прерывания от ULA! Но в реальности счетчик ULA во втором цикле должен стать 0. Дальнейший анализ выполнения разъяснил природу разных значений в тесте. Каждый раз, когда запускался тест, значение внутренних "часов" ULA было разное. Ну не могу я нажимать кнопки с точностью 1/3500000 секунды и все тут.
Теперь осталось понять, где же кроется ошибка в том, что эмулятор неправильно обрабатывает прерывания. Как ни странно, но проблема обнаружилась буквально после прочтения первого абзаца про прерывания из официального даташита Z80. Соль в том, что в реальности никто не держит INT больше Н тактов. Подняли, подождали, опустили. Отработал процессор или нет - всем снаружи пофиг. А у меня логика получилась вывернутая "раз подняли INT, значит его надо обработать, рано или поздно". Пруфы на куске кода выше, строки 2,3,5, где флаг "прерывание" поднят всегда до EI. Теперь все встает на свои места: ULA когда-то давно (по меркам процессора) подняло прерывание, где-то на середине экрана пришло разрешение, обработчик прерываний отработал вместе с первым циклом и второй цикл посчитал число тактов с начала-середины-конца экрана до конца. Вуаля! "У вас слишком часто прерывания".
Фикс из одной строчки и теперь тест стал зеленым. Все вовремя.
И внезапно, на этом фоне побед и успехов звук починился простым добавлением 1 в число семплов. Раньше происходил "недогруз" из-за округлений и для звуковой системы раз в Н миллисекунд прилетало "тишина". А на слух это как щелчки.
В итоге "на сейчас" у меня полностью рабочий эмулятор ZX Spectrum 48, который издает все положенные звуки и проходит все положенные ему тесты. Что может быть лучше?
День восьмой. ...А лучше может быть только сделать так, чтобы эмулятор научился загружать очень важные в домашнем хозяйстве программы. Ну бухгалтерию там или про склад что-нибудь. Ну или на худой конец игры. Просто капельку развлечься после тяжелого трудового будня.
Для начала скачал какой-то огромный архив очень полезного софта. Сделал пару подходов, подсчитал и выяснил, что цифра 128 в имени файла встречается 19 тысяч раз. А 48 - только 13. Расширение TAP имеют 29 тысяч файлов, а TZX - только 27. Ближайшее расширение в два раза меньше. Это, конечно, очень кривая статистика, но она говорит прямо: больше всего игр для 128 килобайтного спектрума в формате TAP. Значит, надо апгрейдить мой эмулятор.
И тут, внезапно, до меня дошло прочитанное на каком-то из форумов различие в Z80. Если отсеять весь шлак, то были версии, сделанные по технологии NMOS и были версии, сделанные по CMOS. И ведь тестер имеет пункт про Z80, но я туда не залазил, ибо смысл после FUSE и ZEX? Залез. Надо же, тестер пишет, что у меня NMOS. Нашел "старые" реализации SCF и CCF. Оппа, а теперь некий SGF! Все, разгадка была найдена ... Тесты FUSE собирались и проверялись на CMOS версии. А ZEX - на NMOS. Пара if'ов и теперь мой эмулятор может эмулировать чего угодно. И тесты fuse вернул "правильные" и "настоящие".
Вернемся к моему барану. Главное отличие 128к версии от 48к, как ни странно, в объеме памяти. Но как хитрые инженеры впихнули 128 килобайт в 64 килобайта адресного пространства? Ответ прост: разбили все 128 килобайт на 8 банков по 16 килобайт и сделали механизм их переключения. Если смотреть на адресное пространство снизу, то первые 16 килобайт занимает ПЗУ (причем в версии 128 их два, и маппится одно из), потом 16 килобайт всегда из банка 2, следующие 16 всегда из банка 5 и самые верхние - любой банк по желанию.
Сделать переключалку банков памяти тоже не составило никакого труда, так что тут особо описывать нечего. Если интересно, то можете изучить код Memory::writePort .
Поглядев на заставку от 128 версии, я приступил к читалке файлов. Начал с формата TAP, он же "тапок", ибо он и проще всего и потом та же логика используется и в TZX. Чтобы распарсить этот формат, нам надо прочитать два байта - это будет длина блока. Потом понять, что за блок, сделать нужное и перейти к следующему блоку. Хвала спектруму, у него всего два типа блоков: заголовок и данные. Причем в заголовке описывается что за данные следом.
Теперь вопрос: как дать спектруму эти данные? На сейчас существует ровно два варианта. Первый это перехватить в ПЗУ вызов функции "прочитай с магнитофона" и подсунуть уже готовые данные. Преимущества: быстро и очень быстро. Минусы: за бортом остаются все программы, имеющие нестандартные загрузчики. Этот я реализовал в версии с go и остался недоволен. Второй вариант более классический: при чтении с порта магнитофона кормить единичками и ноликами всех тех, кто это попросит.
Немного подумав, я решил идти по самому простому пути. Делаю массив, в который буду складывать "ХХ тактов 0, YY тактов 1". Потом, если "магнитофон играет", буду потактово отдавать нужное. Да, размер пожираемой памяти растет, но что сейчас 5 мегабайт?
Я писал абзацы выше дольше, чем АИ рисовал мне скелет читалки. Наступило время тестов. "Зарядил" виртуальный магнитофон, ввел в эмуляторе LOAD "", выбрал менюшку Tape->Play ... и на экране появились так знакомые любому спектрумисту полосы. И все. Еще раз. "и-и-и-и-и-всхрюк" и пустота. То есть процедура чтения видит и распознает пилот-тон, но вот дальше не понимает, что в нее суют.
Ну тут-то разногласий быть не может, верно? Иду, спрашиваю у БЯМки, что там с таймингами при записи на ленту. Отвечает. Сравниваю с гопотой. Ответы в общем-то совпадают, но именно в общем-то. Пилот для заголовка восемь тыщ импульсов по 2163 такта, единичка кодируется 1710 тактами, нолик - 850. После пилота синк по 667 и 337 тактов. Читаю доки. Тоже самое, что и с ULA - совпадают только тайминги заголовка и ноликов и единичек. Остальное разное. Да сколько можно-то? Ведь все должно быть расписано за прошедшее время!
Ладно, у меня есть 100% вариант, как выяснить правильные тайминги: сам эмулятор. Я могу засунуть еще один printf в потроха, чтобы он вывел мне все, что происходит при выполнении команды SAVE "p".
Итак, представляю вам самые правильные тайминги для ленты zx spectrum.
8063 импульса. Каждый импульс состоит из 2168 тактов 1 и 2168 0
Первый синхроимпульс. 667 тактов 1
Второй синхроимпульс. 775 тактов 0
Данные. От старшего бита к младшему. 0 - 850, 1 - 1710 тактов
Финальный синхроимпульс. 945 тактов 1
Дальше пауза в 1 секунду
3223 импульса. Каждый импульс состоит из 2168 тактов 1 и 2168 0
... и дальше повторение со второй строчки
Как только я все это забил в эмулятор, то тут же получил офигенную картинку.

И полоски на месте и грузится! И даже лагов в управлении нет! Очередное ура!
День десятый (девятый сперли). С помощью БЯМки быстренько добавил поддержку TZX, причем только самых нужных блоков. Заодно, пока отвлекся, БЯМка нарисовала мне эмулятор AY-3-8912. Запускаю.

Вот вам пример нестандартного загрузчика. Читает мимо процедур ПЗУ, бордюр не дергает и показывает красивый счетчик оставшегося времени в правом нижнем углу. Крутотецкая крутотень! Даже сейчас не все умеют рисовать прогресс-бар.
И оно считалось! И даже запустилось! Правда, звук отвратительный до безобразия. И процессора жрет столько, что макбук начинает крутящуюся радугу рисовать везде. Но все равно я нашел финальный плюс: даже со всем букетом проблем всего лагов нет. На кнопки реагирует абсолютно адекватно.
День 11. Нашел, почему жрет ЦПУ. Добрый БЯМка прочитал, что AY8912 работает на частоте процессора. Ну и бахнул "на каждый такт засунь один семпл в звуковуху". То, что частота процессора 3,5МГц, а звуковуха жрет семплы с частотой 44,1КГц, его совершенно не смутило. В итоге эмулятор радостно забивал все вокруг и макбук пытался переварить весь этот поток безобразия. Ладно, выключил пока звуковуху и переключился в 48к режим. Exolon в этом режиме я помню до последней косточки.
Каждый цикл "изменил-загрузил-проверил" теперь примерно по 5 минут. Или примерно 120 байт в секунду. Были турболоадеры, но и там получалось выжать в районе 300, да и то только на хорошей пленке.
Но что мешает мне реализовать свой турболоадер? Пока "читается кассета", просто сниму все ограничения на частоту эмуляции Z80. Немного мучений и мой турболоадер загружает exolon за 15 секунд! Совсем другое дело!
И тут же выяснилась первая проблема: сразу после загрузки начинает играть музыка, но нет никакой реакции на кнопки. Где-то секунд через 20 начинают переливаться звездочки и появляется реакция на клавиатуру. Если начать играть, то музыка продолжает звучать и только потом раздаются звуки выстрелов и взрывов. И только после этого все звуки начинают попадать в тайминги.
Для освежения памяти посмотрел ютубчик. Там звездочки начинают мигать тут же и музыка чуть выше тоном и быстрее. Значит, меня память не подводит. Где гуляет бравый герой эти 20 секунд? Может быть, я опять переполняю буфер звука "тишиной"? Уменьшил число семплов, звук хрюкает, но пауза на месте.
Вообще отключил весь звук. Пауза в эти долбанные 20 секунд на месте. Ушел спать расстроенным.
День 12. Следующий день принес странное откровение. Все-таки это забагованный exolon. Если в эмуляторе включить 128к, то звездочки начинают мигать сразу. И в отладочной инфе я вижу, как он пишет в порты звуковухи. Переключаешь на 48 - 20 секунд фриза и звук через порт ULA. Зачем, почему - ответа нет. Попутно выяснил, что да, есть как минимум две версии exolon. Та, где на заставке разноцветные шарики - она для 128. А та, где на заставке мужик в скафандре - для 48. Так что, дорогие писатели эмуляторов, не всегда стоит сразу искать ошибки у себя (тут большой смаил).
Но в общем-то, это все следствия одного и того же. В детстве у меня был только спектрум с 48 килобайтами и я совершенно не знаю, как должна вести (и звучать) себя 128 килобайтная версия. И что самое плохое, БЯМка тоже не представляет. Написанный ей код как-то очень слабо коррелирует с ожидаемым.
Попробовал посмотреть в другие эмуляторы. Программисты, оставляйте комментарии, мать вашу! Ну не все же способны раскрутить в голове конечный автомат с кучей шагов!
В общем, тут я сдался и притащил из интернетов некий плеер файликов, который внутри себя имел эмулятор ay8913. Немного запросов к БЯМке и код компилируется. Но опять в динамиках полная тишина.
День 13. Со свежей головой обнаружил, что эмулятор звуковухи генерит беззнаковые 16 битные семплы. А SDL нужны знаковые. Хорошо, переделал. Опять тишина.
С помощью БЯМки написал рядом генератор тона в 1000Гц. Ок, звучит. Значит дело не в SDL. Вспомнил, что вообще-то ay8912 сама по себе должна уметь генерировать звук, без внешних дерганий.
ayChip->write(0, 111 & 0xFF); // FTR_A - fine tune register A (low byte)
ayChip->write(1, (111 >> 8) & 0x0F); // CTR_A - coarse tune register A (high nibble)
ayChip->write(7, 0xFE); // Mixer - enable tone for channel A (bit 0 = 0), disable noise (bits 3-5 = 1)
ayChip->write(8, 0x0F); // Amplitude A - set maximum volume for channel A
Это я честно спер из какой-то статьи в духе "заставляем ваш компьютер играть музыку". И да, оно запищало. Значит, весь "звуковой тракт" у меня исправен. Но почему же молчит exolon? Ведь он пишет в регистры и я даже вижу, как звуковуха выдает семплы обратно!
Как обычно, причина проблемы обнаружилась сама собой. Отвлекшись на пару минут, я обнаружил, что звук есть, просто я не дожидался его. Перезапустил игру. Начинаю играть и где-то на втором экране начинает играть музыка, потом становятся слышны звуки выстрелов и взрывов и наконец, звуковая дорожка догоняет реальную ситуацию и все синхронизируется.
А что у нас может портить так картину? Правильно, очередной перегруз звуковухи компа, но на этот раз тишиной. Существующая схема "поспи Н миллисекунд, возьми у 8913 семпл и отдай его в звуковуху" рабочая, но только до тех времен, пока Н точно совпадает с темпом разбора SDL звука. Немного поигравшись на калькуляторе, я так и не смог подобрать точное значение. То звук булькал, то отставал. В итоге решил проблему в духе "и так сойдет": просто стал пропускать тишину в семплах.
И бах! Внезапно я обнаружил, что теперь у меня есть полностью рабочий эмулятор ZX Spectrum 128. Все тесты проходит, звук играет, с магнитофона читает, игрушки играет. Чего мне еще надобно-то? Вариант "хотя бы менюшки облагородить" не подходит.
День 14. А надо написать на хабр. Не должны мои боли пропасть. Вот, написал.
Но вообще чем больше кода, тем дальше пишем его. Надо еще добавить поддержку Beta Interface с дисками. В свое время много читал про TR-DOS, но так и не видел их живьем. Следом еще один шаг до всяких пентагонов, АТМ-Турбо и прочих Spectrum Next...
Комментарии (4)

acsent1
03.11.2025 09:45Интересно если бы на расте писал, то была бы проблема с этой невыроаненной памятью?

RepppINTim
03.11.2025 09:45Была бы
Раст защищает от небезопасного доступа к памяти (гонки данных, висячие указатели), но он не может защитить от логических ошибок - если у тебя в коде баг, из-за которого ты пишешь в массив screen за его пределами, но при этом попадаешь в соседний массив memory, то с точки зрения раст все будет легально - работаешь в пределах выделенной тебе памяти, он не знает что эти два массива для тебя разные сущности

RepppINTim
03.11.2025 09:45Вся история - отличная реклама хорошей отладки)
Проблемы с памятью, таймингами, прерываниями - все это решалось бы в разы быстрее, будь у вас с самого начала нормальный JTAG-отладчик. Но вы выбрали путь printf-дебаггинга, и в итоге каждый шаг превращался в многодневное детективное расследование
Поучительно, но не очень эффективно :)
patyupin
Уважуха автору!