- А видеовыход у него есть?
- И как ты себе это представляешь?
(из разговора о Vectrex)
Vectrex выпускался GCE в 1982 - 1983 гг. и представляет собой игровой компьютер (приставку) ключевая особенность которой, векторный дисплей, делает его одним из самых необычных и интересных 8-разрядных компьютеров. С некоторой натяжкой можно сказать, что он является упрощённой версией векторных игровых автоматов Cinematronics, технически более совершенных.
В качестве процессора в Vectrex используется Motorola 6809 - он похож на MOS 6502/6510, но добавлены 16-битные регистры, дополнительные режимы адресации, умножение.
Тактовая частота - 1.5MHz.
Поскольку компьютер был выпущен как игровая приставка и игры для него продавались на картриджах, программа размещается в ПЗУ картриджа (32 кб), а ОЗУ - совсем крохотное (1 кб - две штуки 2114) и предназначено больше для данных.
Также есть встроенное ПЗУ с BIOS'ом (8 кб - одна 2363), который включает набор подпрограмм для рисования векторов и вывода текста, несколько примитивных мелодий и даже одну игру - Minestorm (многим известную как Asteroids).
Звук реализован на чипе AY8912 (также используется в MSX2 и поздних ZX Spectrum) однако, кроме этого существует штатная возможность проигрывания 8-битного звука через ЦАП (практическое применение этого способа, впрочем, ограничено).
Vectrex выполнен в виде моноблока (включающего ЭЛТ экран), но клавиатура не предусмотрена в принципе. Управление осуществляется двумя джойстиками (в т.ч. аналоговыми). Кроме того, может быть подключено световое перо и очки 3D Imager.
С 1982 г. по нынешнее время для Vectrex написали примерно полторы сотни игр, несколько серьёзных программ (типа редакторов графики, музыки, анимации), а также около десятка демо и интро. Интересно, что более половины игр (и все демо) выпущены после 1995 года, т.е. через десятилетие после прекращения производства и поддержки Vectrex. Возрождение платформы связано, в первую очередь, с появлением хороших эмуляторов, которые сделали разработку доступной любому желающему. Сами компьютеры пока также вполне доступны на eBay.
ВЕКТОРНЫЙ ДИСПЛЕЙ
За исключением части отвечающей за вывод изображения, архитектура Vectrex довольно обычна для компьютеров того периода. Основной интерес представляет именно подсистема формирующая векторное изображение, ей и будет посвящена большая часть статьи.
Векторные дисплеи, сейчас практически не встречающиеся и мало кому известные, были распространены в 1970-е.
Основное отличие от всем известных растровых заключается в отсутствии автоматической развёртки. Луч не бегает по строчкам сам. Его перемещение управляется программой - код, который вы напишите, определяет направление, скорость, длительность и яркость, с которой будет перемещаться луч. Как следствие - у векторного дисплея нет пикселов, а, следовательно, и нет понятия "разрешение" (по крайней мере, в привычном понимании).
Фактически, такой дисплей представляет собой осциллограф, к горизонтальному (X), вертикальному (Y) и каналу яркости (Z) которого подключены цифро-аналоговые преобразователи.
Чтобы получить на экране линию, необходимо не просто переместить луч из одной точки в другую, а сделать это равномерно и с нужной скоростью. Затем луч можно погасить и переместить в другую точку, где зажечь и переместить в третью, и т.д. Таким образом получим некую фигуру.
После завершения перемещения люминофор будет какое-то время светиться и линия будет видна. Однако, это послесвечение длится недолго.
По этой причине всё, что требуется, включая выполнение кода, задержки вызванные обращением к периферии и саму отрисовку линий, нужно выполнить за ограниченное время.
Если линий будет много, изображение начнёт мерцать. Это делает невозможным создание закрашенных фигур (разве что совсем крохотных), а сколько-нибудь серьёзные вещи придётся писать на ассемблере т.к., даже если представить себе идеально оптимизирующий компилятор, рассчитать все временные задержки при перемещении луча, которые будут происходить в выдаваемом им коде, проблематично - одна лишняя загрузка в регистр в неудачном месте может совершенно исказить изображение.
Если для традиционных 8-битных платформ (типа Commodore VIC-20, C64 или ZX Spectrum) вполне можно программировать, ни разу не посмотрев на схему компьютера, в случае с Vectrex понимание того, как работает часть формирующая изображение - необходимо.
Конечно, можно пользоваться для отрисовки линий, простых фигур и вывода надписей готовыми подпрограммами BIOS, однако это не позволяет в полной мере использовать возможности устройства - рано или поздно всё равно придётся работать с железом напрямую.
По этой причине, знакомство с разработкой будет тесно переплетено с описанием устройства компьютера.
Разбираться будем на примере построения прямой линии из точки, где луч уже находился, в некую новую заданную точку.
В данном контексте нас интересуют следующие компоненты Vectrex'a (в скобках приведены номера на блок схеме):
MOS 6522 (IC207) - универсальный адаптер интерфейсов (VIA - Versatile Interface Adapter) - этот чип отображается на область памяти $D000...$D00F. Соответственно, запись по этим адресам значений (например, командами процессора STA $D00x) приводит к изменению его регистров.
6522 содержит, в частности, порты ввода-вывода, два таймера, сдвиговый регистр (shift register) .
Подробности лучше посмотреть в документации на чип (см. ссылку на архив в конце статьи), но суть состоит в том что, записывая командами микропроцессора значения в упомянутые выше ячейки памяти, мы устанавливаем торчащие из чипа ножки в 0 или 1 (а также наоборот - можем проверить, поданы ли на них 0 или 1).
MC 1408 (IC301) - 8-разрядный цифро-аналоговый преобразователь (DAC, ЦАП). Преобразует код, поступающий на него с чипа 6522 в соответствующий уровень напряжения. С точки зрения программирования диапазон напряжений соответствует цифрам -128 ... +127 (а не 0...255!)
LF347 (IC303) - схемы выборки-хранения s&h (store & hold) на операционных усилителях. Сохраняют на выходе напряжение (в т.ч. после того, как оно будет снято с входа). Их две - по каналам Y и Z (для X не нужна, пояснение ниже).
CD 4052 (IC302) - аналоговый мультиплексор с цифровым управлением (mux). Взависимости от кода на его цифровых входах (которые подключены всё к тому же 6522) пропускает входное напряжение с ЦАП-а на один из нескольких своих выходов.
4066 (IC305) - аналоговые ключи. Управляемые (тем же 6522) выключатели, позволяющие пропускать или не пропускать через себя напряжение.
LF247 (IC303) - интеграторы на операционных усилителях (их два - по X и по Y, соответственно). Преобразуют входной прямоугольный сигнал, амплитуда которого задана кодом на ЦАП-е, в изменяющееся напряжение, заставляющее луч плавно перемещаться из одной точки в другую, оставляя на экране светящийся след.
Далее идёт электронно-лучевая трубка с отклоняющей системой (по горизонтали и вертикали) на которую через усилители подаётся напряжение с интеграторов и, отдельно, напряжение управляющее яркостью луча.
При отсутствии напряжения на отклоняющих системах луч находится в центре экрана. При максимально допустимом напряжении на любой из них - за пределами экрана.
(для более подробного рисунка см. блок-схему и схему)
Рисование осуществляется примерно следующим образом:
Загружая в определённые регистры 6522 значения, мы можем устанавливать на выходе ЦАП нужное напряжение. Но ЦАП всего один, а нам нужно выставить три напряжения - X (направление по горизонтали -128...+127), Y (направление по вертикали -128...+127) и Z (яркость 0...$7F).
Для этого после установки каждого напряжения нужно переключить мультиплексор, чтобы напряжение было передано на нужный выход. С каналами Y и Z в этом отношении всё просто, а вот канал X идёт (явно для упрощения схемы) в обход мультиплексора.
Т.е., устанавливая Y или Z мы всегда одновременно устанавливаем и X !
Поэтому поступаем так:
1. Записываем в ЦАП яркость, переключаем мультиплексор на вывод в канал Z. Напряжение сохраняется на схеме sample & hold (s&h) канала Z.
2. Записываем в ЦАП Y, переключаем мультиплексор на вывод в канал Y. Напряжение сохраняется на схеме s&h канала Y.
3. Выключаем мультиплексор и записываем в ЦАП X (s&h тут не нужна, так как напряжение сохраняется на самом ЦАП)
Канал Z нам больше не интересен (яркость постоянна), а вот с X и Y разбираемся дальше.
Итак, напряжения по X и Y с выходов схем s&h у нас поданы на аналоговые ключи. Через 6522 (выход PB7) мы подаём на эти ключи сигнал RAMP. Ключи одновременно открываются и оба напряжения попадают на соответствующие интеграторы - по X и по Y.
На выходе интеграторов, соответственно, получаем изменяющиеся напряжения. Они меняются либо от предыдущего значения, оставшегося на конденсаторе интегратора (помните, мы куда-то там до этого поставили луч?), либо от нуля (если ранее интеграторы сбросили в ноль, подав на них через тот же 6522 сигнал ZERO - конденсатор разрядится).
Интегрирование идёт, напряжение меняется, луч движется по экрану и оставляет след за счёт послесвечения люминофора. Когда надоест, мы можем его остановить, отключив напряжение от интеграторов уже упомянутым сигналом RAMP.
Таким образом линия нарисована, а остаток напряжения на интеграторах соответствует её концу (и началу следующей, если понадобится).
Возникает вопрос - в какой момент отключать напряжение? В принципе, это ваше дело. Вы можете просто посчитать, какой длины нужен вектор и вбить задержку в нужное число тактов подходящими командами.
Однако, на практике обычно применяется другой способ - задействуется таймер 1 в 6522. В таймер заносится некое значение, не слишком удачно названное "scale" (масштаб) и начинается обратный отсчёт. Когда значение достигнет нуля, сигналом RAMP интегрирование будет автоматически остановлено. Т.е., достаточно выставить и запустить таймер, луч остановится сам. Однако, тут есть проблема - луч остановится, но как об этом узнать, чтобы начать рисовать следующий?
Для этого придётся в цикле проверять один из регистров 6522, где после завершения счёта установится флажок. По сути, получается ожидание впустую, поэтому это время в цикле иногда используют для выполнения каких-нибудь полезных вычислений.
Помимо сплошной линии есть достаточно кривая возможность рисовать пунктирную. Для этой цели используется сдвиговый регистр (shift register) в 6522. Заносим туда необходимый паттерн (к примеру, $AA = %01010101) и говорим 6522, что сдвиг должен происходить автоматически. При сдвиге каждый бит выползает на сигнал BLANK и, таким образом, луч сам включается на единицах и выключается на нулях. Проблема в том, что после 8 сдвигов в регистре остаются одни нули и весь наш замечательный пунктир обрывается. Чтобы этого не происходило, необходимо снова и снова заносить туда значение pattern. Делается это в вышеупомянутом цикле ожидания окончания интеграции. В таких условиях получить именно тот пунктир, какой хочется - весьма непросто.
Впрочем, именно этот регистр используется BIOS'ом для функции вывода текста (т.е., фактически, стандартные символы - растровые, просто рисуются прерывистыми горизонтальными векторами).
Из всего вышеописанного следует три важных момента:
1.Рисование происходит не по абсолютным координатам, а по относительным. Следующее перемещение отсчитывается от конца предыдущего и вектор имеет некую длину в некотором направлении (кстати говоря, это позволяет совершенно штатно выводить изображение за границы видимой части экрана - к примеру, для реализации скроллинга).
Из-за различных утечек с каждым перемещением быстро растёт погрешность (единицы сантиметров на десять тысяч тактов), поэтому в начале каждого "кадра" (серии рисований) луч выставляют в центр экрана.
2.Длина и направление вектора зависит от сочетания scale и напряжений по X и Y. В некотором смысле, scale задаёт длину, а X и Y направление (но при этом также влияя на длину). Можно сказать, что на рисунке с графиком scale задаёт время от A до B (или от B до C), а значение X (или Y) подаваемое на ЦАП - наклон отрезков на нижней части графика (говоря иначе - скорость изменения напряжения).
3.Поскольку scale - время перемещения луча, оно должно быть по возможности минимальным. Чем оно меньше, тем больше векторов можно успеть нарисовать, пока не начнётся мерцание.
Для немерцающего изображения в 50 кадров в секунду (50 гц для любого Vectrex) необходимо со всеми рисованиями и вычислениями уложиться в 30000 тактов.
Если рисовать горизонтальные линии во всю ширину экрана в максимальном масштабе ($FF) и перед началом рисования каждой линии устанавливать луч в центр, а затем в точку начала линии, то в упомянутые 30000 тактов уложится примерно 60-70 линий, что весьма немного. Ясно, что при уменьшении масштаба (и, соответственно, уменьшении их длины) максимальное число линий будет расти.
Рисование прямой линии
Теперь разберём, как всё вышеописанное реализуется в коде (константы VIA_* из vectrex.i от исходников BIOS).
Практически любая программа для Vectrex представляет собой бесконечный цикл. Первым делом в нём всегда вызывается подпрограмма BIOS Wait_Recal, а затем уже всё остальное - необходимые вычисления, отрисовка всех векторов для данного "кадра", проигрывание музыки, опрос джойстиков и пр.
В отличие от традиционных растровых дисплеев, где луч обегает все строчки одинаковое для каждого кадра время, здесь всё иначе - ведь в одном кадре может выводится 10 линий, а в следующем только 5.
Для обеспечения равного времени выполнения всех итераций цикла ("кадров") используется следующий метод:
Таймер 2 VIA 6522 программируется в режим one shot и в него заносится значение Vec_Rfrsh = 30000 ($7530). С момента занесения идёт обратный отсчёт. В начале Wait_Recal ожидаем, когда отсчёт закончится. Как только он закончился, осуществляем рекалибровку схем (а в таймер опять заносим то же значение). Смысл этого ожидания в том, чтобы итерация цикла гарантированно занимала не менее 30000 тактов. Если рисование и вычисления заняли меньше, время всё равно будет "дополнено" до 30000.
А вот если они заняли больше, это приведёт к негативным эффектам - во-первых, начнут гаснуть векторы нарисованные первыми, во-вторых, из-за поздней рекалибровки, могут возникнуть различные искажения.
Почему именно 30000? Значение выбрано из расчёта 50 Гц (т.е. на один "кадр" отводится 1/50 секунды), исходя из частоты процессора 1.5 MHz. Именно 50 Гц, по-видимому, выбрано чисто по традиции.
Wait_Recal состоит из нескольких последовательных процедур:
- Увеличивается счётчик Vec_Loop_Count (это просто переменная word)
- Ожидается когда закончит считать таймер 2 (и если закончил, запускается заново)
- Устанавливается максимальный масштаб (scale) = $FF
- Выключение обнуления интеграторов, выключение луча, луч перемещается в позицию x = $7F, y = $7F (левый нижний угол) из предыдущей своей точки (которая может быть какой угодно)
- Включается луч, очищается сдвиговый регистр (т.е. пустой паттерн для линий), включение "обнуления" интегратора (луч оказывается в центре)
- Выключение обнуления интеграторов, выключение луча, луч перемещается в позицию x = $80, y = $80 (правый верхний угол)
- Ещё раз включение обнуления интеграторов (луч снова оказывается в центре)
- Устанавливается ноль относительно нуля ЦАПа (см. ниже примечание от svo)
Каким образом помогает калибровке перемещение луча в углы экрана - неясно.
С практической точки зрения важно, что в результате этих действий, после вызова Wait_Recal луч у нас выключен и принудительно находится в центре (обнуление интеграторов включено - они не могут работать, даже если начать интегрирование), scale установлен в $FF.
Итак, исходник:
loop: jsr Wait_Recal ; ожидание таймера 2 и рекалибровка CRT ; Включаем луч и выключаем "обнуление" интеграторов (иначе луч не будет перемещаться) lda #$CE ; BLANK low, ZERO high sta <VIA_cntl ; записываем в управляющий регистр 6522 ;Устанавливаем нужный масштаб (scale): lda #$ff ; $ff -- максимальный масштаб sta <VIA_t1_cnt_lo ; записываем в младший байт таймера 1 ;Устанавливаем яркость луча (Z): lda #$7f ; $7f - максимальная яркость sta <VIA_port_a ; записываем значение в ЦАП ldb #$04 stb <VIA_port_b ; (4 в portb) включаем мультиплексор и переключаем его на канал 2 (sel0=0, sel1=1), чтобы напряжение попало в s&h по Z stb <VIA_port_b ; задержка ldb #$05 stb <VIA_port_b ; (5 в port b) выключаем мультиплексор (сигнал S/H с 6522) ldb #100 ; куда рисуем линию - X lda #100 ; куда рисуем линию - Y sta <VIA_port_a ; записываем значение Y в ЦАП clr <VIA_port_b ; (0 в port b) включаем мультиплексор и переключаем его на канал 0, чтобы напряжение попало в s&h для Y inc <VIA_port_b ; (1 в port b) выключаем мультиплексор stb <VIA_port_a ; Записываем значение X в ЦАП и там оставляем (оно идёт дальше мимо мультиплексора, в отличие от Y и Z). lda #$aa ; паттерн для линии ($ff - сплошная, $aa - пунктир) ldb #$40 ; бит прерывания таймера 1 sta <VIA_shift_reg ; записываем паттерн в регистр сдвига clr <VIA_t1_cnt_hi ; запускаем таймер, что автоматически начинает интегрирование (сигналом RAMP) wait_timer: sta <VIA_shift_reg ; обновляем паттерн в регистре сдвига bitb <VIA_int_flags ; проверяем бит прерывания таймера 1 ($40) - не вышло ли время соответствующее заданному scale beq wait_timer ; если нет, обновляем паттерн снова. Если вышло, линия готова clr <VIA_shift_reg ; очищаем паттерн для линии (без этого в конце линии появится яркая точка) ; при желании рисуем ещё линии, играем музыку, выполняем вычисления и прочее ; ...... bra loop ; и всё сначала
Обратите внимание, что паттерн записывается в регистр дважды - перед началом интегрирования и потом в каждой итерации цикла. Второе необходимо потому, что при сдвиге (автоматическом) регистр становится пустым и если значение не обновлять - линия станет невидимой. При обновлении же очень важно правильное соответствие сдвига и тактов, которые занимают команды в цикле. Если туда вставить даже один nop, видимый паттерн линии изменится.
Если рисуется несколько линий (к примеру, квадрат) то, конечно, "clr <VIA_shift_reg" имеет смысл ставить не после каждой линии, а один в самом конце.
В подпрограмме BIOS Draw_VL (вызывающей Drawline_d) этот момент обыгрывается довольно хитро: есть переменная Vec_Misc_Count, в которую заносится число линий, которые планируется нарисовать. И когда дорисована последняя, там не просто очищается паттерн, но ещё выполняется сброс интеграторов (т.е. частично выполняется код из Wait_Recal).
Если нужно быстро вернуть луч в центр экрана до завершения цикла и очередного вызова Wait_Recal, можно пользоваться "обнулением" интеграторов, не забывая его отключать:
; обнуляем интеграторы lda #$CC sta <VIA_cntl ; /BLANK low and /ZERO low ; ставим луч в центр, записывая нули в ЦАП ldd #$0302 clr <VIA_port_a ; clear D/A register sta <VIA_port_b ; mux=1, disable mux stb <VIA_port_b ; mux=1, enable mux stb <VIA_port_b ; do it again ldb #$01 stb <VIA_port_b ; disable mux ; выключаем обнуление интеграторов lda #$CE ; /Blank low, /ZERO high sta <VIA_cntl
Тоже самое делает подпрограмма BIOS Reset0Ref (плюс, там ещё очищается shift register), которая вызывается из Wait_Recal.
Существенно, что необходимо выполнять И обнуление интеграторов И запись нулей в ЦАП. Для эмулятора это без разницы, а вот на реальном Vectrex разница очень даже будет.
ЗАМЕЧАНИЯ:
Описанный вариант отрисовки линии намеренно избыточен. К примеру, масштаб и яркость совершенно необязательно устанавливать каждый раз, сплошная линия не потребует лишнего цикла с обновлением shift register. В ряде случаев куда оптимальнее (но менее наглядно) загружать сразу A и B инструкцией LDD, использовать clr, inc/dec и т.д. В реальной ситуации это делать придётся, т.к. экономит такты.
По-поводу "бессмысленных" инструкций задержки: после записи в порты 6522, в коде BIOS'а иногда выжидают несколько тактов. Иначе следующая запись может не пройти. Когда это нужно и когда нет - вопрос достаточно туманный. Судя по экспериментам (как моим, так и других людей) - не нужно вообще. Возможно, это требовалось для ранних экземпляров Vectrex.
Нужно понимать, что если хочется рисовать длинные непрерывные вектора (к примеру, во всю ширину экрана), то это возможно лишь при максимальном масштабе (scale). Однако, если он будет максимальным, это автоматически означает снижение "разрешения" в том смысле, что шаг в единицу по вертикали будет означать расстояние примерно в ширину яркой линии (т.е. между рядом параллельными линиями будет промежуток).
Если же делать длинные линии из нескольких коротких, места стыков будут заметны.
На практике имеет смысл постоянно переключать масштаб - в одном (большом) рисуется линия, в другом (маленьком) луч перемещается к началу рисования следующей.
Если нарисовать длинную (от -128 до 127) горизонтальную линию (в масштабе $FF) так, чтобы её левый конец был у правого края экрана, а правый конец далеко за экраном, то не получится передвинуть её влево так, чтобы правый конец оказался за левым краем экрана.
На первый взгляд здесь нет проблемы, т.к. перемещения луча всегда относительны. Однако, на настоящем Vectrex линия влево за край не уйдёт (причём, если уменьшать начальную координату линии плавно, то будет видно, как её движение влево будет замедляться и прекратится, не дойдя до нужной позиции около четверти экрана. Это связано с ограничениями на максимальную амплитуду напряжений на выходах интеграторов (эмулятор, к слову, этого не понимает).
Яркость вектора определяется не только значением Z, но также и временем, которое луч находится в данном конкретном месте. Соответственно, яркость будет выше если а) луч перемещается медленно б) луч перемещается по одной и той же траектории многократно.
В этой связи стоит упомянуть про точки - они рисуются простым включением луча (без интегрирования), выжидания в цикле некоторого времени, от которого зависит яркость и выключения луча (см. подпрограмму Dot_here).
Прямая линия с интегрированием вручную
В предыдущем разделе для начала перемещения луча мы запускали таймер, что автоматически запускало интегрирование. И затем ожидали окончания, проверяя в цикле, не закончил ли таймер считать.
Существует другой способ рисования линии, при котором таймер не используется: интегрирование запускаем вручную (установкой RAMP на ножке PB7 6522), а затем любым способом ждём нужное нам время, пока луч ползёт в заданном (X и Y) направлении. Взависимости от задачи, для ожидания могут быть использованы простые nop, либо цикл (в котором, в частности, можно обновлять значение shift register, если требуется штрих-пунктирная линия).
В конце рисования интегрирование прекращается (также вручную).
Код выглядит так:
loop: jsr Wait_Recal lda #$CE ; (11001110) /Blank low, /ZERO high sta <VIA_cntl ; enable beam, disable zeroing clr <VIA_shift_reg ; Пеоеключаем выход PB7 6522, чтобы интегрирование начинать вручную (а не с запуском таймера, как обычно) lda #$18 ; (00011000) AUX: shift mode 4 (110). PB7 not timer controlled. PB7 is ~RAMP ldb #$81 ; (10000001) disable MUX (bit0=1), disable ~RAMP (bit7=1), MUX set to channel Y stb <VIA_port_b sta <VIA_aux_cntl ; Заносим значение в ЦАП и включаем мультиплексор, чтобы оно оказалось на интеграторе канала Y (оно же ; неизбежно окажется и в канале X, т.к. X идёт мимо мультиплексора) lda #127 ; задаём Y sta <VIA_port_a ldb #$80 ; (10000000) enable MUX (bit0=0), disable ~RAMP (bit7=1), MUX set to channel Y stb <VIA_port_b ; enable MUX, that means put DAC to Y integrator S/H ; Теперь выключаем мультиплексор и записываем в ЦАП значение X ldb #127 ; задаём X lda #$81 ; (10000001) disable MUX (bit0=1), disable ~RAMP (bit7=1), MUX set to channel Y (уже неважно) sta <VIA_port_b stb <VIA_port_a ; store B (X_update) to DAC ; начинаем интегрирование ldb #$01 ; (00000001) Disable mux (bit0=1), enable ~RAMP (bit7=0), MUX set to channel Y (уже неважно) stb <VIA_port_b ; паттерн нужно задавать именно после начала интегрирования (чтобы до него он был пустой). Иначе получим в начале загнутый хвостик ldb #$ff ; паттерн для сплошной линии stb <VIA_shift_reg ; выдерживаем паузу, во время которой луч идёт по экрану в выбранном выше (X,Y) направлении nop nop nop nop nop nop clr <VIA_shift_reg ; прекращаем рисовать линию, задав пустой паттерн ; окончание интегрирования ldb #$81 ; (10000001) disable MUX (bit0=1), disable ~RAMP (bit7=1), MUX set to channel Y stb <VIA_port_b ; эта задержка нужна в том случае, если следуюшая линия должна начаться с места окончания этой. ; Без задержки между ними будет неизвестный науке разрыв nop nop nop nop ; восстанавливаем обычное управление началом интеграции - по таймеру lda #$98 ; (10011000) AUX: shift mode 4 (110). PB7 timer controlled (bit7=1). PB7 is ~RAMP sta <VIA_aux_cntl bra loop
Поскольку таймер здесь не используется, задание масштаба (scale) не имеет смысла и ни на что не влияет. Направление и длина линии полностью определяются значениями X,Y и задержкой между началом и окончанием интегрирования.
В BIOS подобный подход (без таймера) используется при выводе символов (подпрограмма Print_Str).
Рисование кривой
С рисованием прямых векторов, если разобраться, всё обстоит достаточно просто. Но что делать, если требуется нарисовать кривую? Специальных аппаратных возможностей для этого Vectrex не имеет.
Более того, вы даже не можете произвольно перемещать луч - необходимо переключать каналы мультиплексора, причём один из каналов (X) вообще идёт мимо него.
С официальной точки зрения рисовать кривые нельзя. В BIOS нет ни одной подпрограммы, которая бы делала что-то похожее. Однако, если делать всё вручную, кривые, с рядом серьёзных ограничений, нарисовать всё же удаётся.
Для этого, как и в предыдущем случае, от запуска интегрирования по таймеру и проверки его окончания придётся отказаться.
Запуск производится вручную (сигналом RAMP) и, после того как луч начал движение, мы в нужные моменты начинаем писать значения в каналы X и Y, в результате чего луч вынужден менять направление. Помимо, опять же, необходимости чётко вычислять необходимые для записи моменты, серьёзным ограничением является необходимость переключения мультиплексора между каналами. На практике это приводит к тому, что с каналом X всё хорошо, т.к. он идёт в обход мультиплексора (поэтому отклонения луча по горизонтали получаются чистые и аккуратные). А вот с каналом Y всё плохо.
Чтобы в него записать, необходимо включить мультиплексор (активировать выход S/H 6522 ведущий на DIS MUX мультиплексора), записать значение и снова выключить мультиплексор что, вероятно, приведёт к остановке интегрирования.
Рассмотрим простой пример, в котором рисуется кривая с отклонением только по горизонтали (X):
loop: jsr Wait_Recal ; калибровка lda #$7f sta <VIA_t1_cnt_lo ; масштаб (здесь действует только на Moveto_d) lda #-120 ; Y ldb #0 ; X -127 jsr Moveto_d ; перемещаем луч в точку начала кривой ; режим с интегрированием вручную и выключение mux ldd #$1881 stb <VIA_port_b ; poke $81 to port B: disable MUX, disable ~RAMP sta <VIA_aux_cntl ; poke $18 to AUX: shift mode 4. PB7 not timer controlled. PB7 is ~RAMP ; Значение Y, к которому начнёт двигаться луч lda #127 ; Y sta <VIA_port_a ; записываем в ЦАП decb ; B now $80 stb <VIA_port_b ; enable MUX, that means put DAC to Y integrator S/H ; интегрирование должно начинаться когда в ЦАП уже есть какой-то X, иначе в начале будет прямой отрезок линии ldb #0 ; X start inc <VIA_port_b ; MUX off, only X on DAC now stb <VIA_port_a ; store B (X_update) to DAC ; начинаем интегрирование ldb #$01 ; load poke for MUX disable, ~RAMP enable stb <VIA_port_b ; MUX disable, ~RAMP enable ; задаём паттерн (сплошная линия) ldb #$ff stb <VIA_shift_reg ; рисуем, собственно, кривую. Для каждого сегмента записываем в ЦАП новое значение X. На практике, конечно, ; удобнее это делать в цикле и значения брать из таблицы. Общее время выполнения записей и промежуточных ; вычислений влияет на форму и длину кривой lda #$10 sta <VIA_port_a ; первый сегмент lda #$20 sta <VIA_port_a ; второй сегмент lda #$30 sta <VIA_port_a ; третий сегмент ; прекращаем интегрирование ldb #$81 ; load value for ramp off, MUX off stb <VIA_port_b ; poke $81, ramp off, MUX off ; в конце кривой будет заметен темный кончик. С этой задержкой, по крайней мере, не будет дырки перед ним nop nop nop nop clr <VIA_shift_reg ; очищаем паттерн ; восстанавливаем обычный режим таймера (enable PB7 timer, SHIFT mode 4) lda #$98 sta <VIA_aux_cntl bra loop
Фактически, приведенный пример аналогичен примеру из предыдущего раздела (прямая линия с интегрированием вручную), просто здесь в процессе рисования мы еще и в DAC значение X пишем.
Что касается использования shift register, здесь есть сложности. Пунктирную линию сделать можно, но точность пунктира будет условной, т.к. проблематично изменять и X и значение shift register в точности тогда, когда это нужно.
Если через равные промежутки времени писать в X одинаковые значения (например 10,10,10), изменение будет линейным - т.е. получится наклонная прямая.
От задержек между записью значений зависит гладкость кривой и её длина. Если задержки большие, получится просто ломаная линия. Поэтому в конкретных ситуациях будет иметь значение любая лишняя инструкция, а циклы, вполне вероятно, придётся разворачивать. Кроме того, получившаяся кривая может зависеть от конкретного экземпляра вектрекса (из-за отличия параметров аналоговых компонентов/цепей), хотя добиться примерной схожести на разных экземплярах - возможно (правда неясно, где взять столько Vectrex'ов для тестирования).
Описанная технология использовалась на практике, хотя и редко. Наиболее известный пример - дорога в игре Pole Position. Другой - моя intro "Electric Force" для CC'2015.
Можно ли менять яркость в процессе рисования линии (прямой или кривой)? В практических целях - вряд ли. Яркость меняется через тот же ЦАП - так же, как и с каналом Y, понадобится переключение мультиплексора (с соответствующими побочными эффектами). Кроме того, на это будет уходить время, так что длина сегмента линии одинаковой яркости будет слишком большой.
Растр из векторов
Если у нас есть возможность рисовать вектора, логично предположить, что из них можно попытаться построить растр, заставив луч бегать туда-сюда и включать-выключать его в нужное время. Однако, при реализации этой идеи возникает ряд препятствий, которые преодолимы лишь частично:
1. За доступные 30000 тактов (чтобы изображение было стабильным) слишком много линий развёртки не нарисуешь.
Фактически, при максимальной длине линии их число (назовём это вертикальным разрешением) будет измеряться десятками. Причём, если scale будет максимальный (т.е., если мы хотим растр шириной в экран), то линий успеет нарисоваться меньше. Более того - нужно учитывать, что после рисования каждой линии мы должны как-то перейти к началу следующей. Даже если мы рисуем во время хода луча обратно (создавая себе проблемы при адресации точек), при максимальном scale не получится нарисовать несколько горизонтальных линий вплотную - между ними будут значительные промежутки. Чтобы этого избежать, можно уменьшать scale перед перемещением луча по вертикали и возвращать обратно на максимум перед рисованием линии. Это переключание также займёт время.
Можно отказаться от растра на всю ширину экрана и изначально задать scale поменьше. Тогда, во-первых, решается проблема с промежутками по вертикали, во-вторых уменьшается время рисования линий.
2. Если не рекалибровать схему после каждой линии, всё это дело съезжает в сторону.
Фатальность проблемы зависит от выбранного scale (т.е., чем дольше рисуется луч, тем сильнее уплывают параметры интеграторов) и от конкретного экземпляра Vectrex. На моём экземпляре при растре во всю ширину экрана уже вторая линия начинается на миллиметр левее, так что о стабильном растре говорить не приходится. Уменьшение scale несколько снижает остроту проблемы, но не снимает её.
Решением является рекалибровка (обнуление интеграторов и ЦАПа) после каждой линии. Это даёт стабильный растр но, разумеется, ценой лишних тактов.
На эмуляторе (ParaJVE) уплывание параметров аналоговых элементов схемы отсутствует, поэтому ориентироваться на него в этом плане нельзя.
3. Включать и выключать луч в процессе его движения нужно очень быстро и точно.
Приемлимая скорость возможна лишь при использовании shift register.
Причём, если при рисовании пунктирной линии равномерность пунктира мало кого волнует, то при выводе изображения регулярная потеря точек или лишние промежутки, особенно в совокупности с другими проблемами - недопустимы.
Подход, с таймером тут не срабатывает, поэтому нужно использовать описанный ранее запуск интегрирования вручную после чего, через точно рассчитанные промежутки времени (так, чтобы каждые 8 "пикселов" правильно стыковались друг с другом) shift register обновляется нужными значениями. Соответственно, окончание рисования линии определяется также вручную - программным счётчиком.
Именно так работает подпрограмма BIOS Print_Str, при помощи которой обычно выводятся на экран текстовые строки. На практике, однако, есть масса ньюансов - достаточно посмотреть в код Print_Str. Кстати говоря, в ней для экономии тактов после каждой строки луч не устанавливается в ноль. Из-за чего почти любая строка (длиннее нескольких символов, взависимости от масштаба) выводится искажённой, как минимум на некоторых экземплярах Vectrex.
4.Как уже было замечено ранее, переключать яркость во время рисования линии проблематично. Однако ничто (кроме необходимости уложиться в 30000 тактов) не мешает рисовать 2-3 растра разной яркости, накладывая один на другой.
Звук
Несмотря на почтенный возраст, со звуком в Vectrex дела обстоят неплохо. Есть даже два независимых способа его воспроизводить - через чип AY8912 (т.е. аналогично Yamaha MSX2 и продвинутым версиям ZX Spectrum) и через ЦАП (т.е. проигрывая сэмплы).
Применение сэмплов ограничено скромным размером адресуемой памяти и производительностью процессора - слишком много тактов будет расходоваться на звук. Я использовал оцифровку короткой фразы в своей интро Invitron. Общий смысл в том, что выбирается соответствующий канал мультиплексора (sel0=1, sel1=1) и далее значения (8 бит, со знаком) пишутся в ЦАП.
Выглядит это так:
lda #%10000110 sta <VIA_port_b ; enable mux, set mux to sound channel (%11) ldx #sample ; sample address ldy #23570 ; sample length (bytes) next: lda ,-y lda ,x+ ; получаем очередной байт sta <VIA_port_a ; пишем его в ЦАП cmpy #$0000 beq done ldb #$19 ; задержка. $19 для 8КГц delay: decb cmpb #$00 bne delay jmp >next done: .... sample: db xx, xx, xx, ...
Преобразовать сэмпл к нужному виду (в данном случае signed 8 bit, mono, 8khz) можно так: ffmpeg -i input.wav -acodec pcm_s8 -ar 8000 -ac 1 output.au (не забыв потом отрезать заголовок)
Что касается проигрывания музыки через AY8912, то это основной способ, не слишком затратный по тактам. Даже в BIOS'е есть встроенные средства для воспроизведения примитивных мелодий (и небольшой их набор). Проиграть мелодию оттуда можно так:
inc Vec_Music_Flag loop: jsr DP_to_C8 ldu #$fef8 ; адрес данных мелодии в ROM jsr Init_Music_chk ; инициализация jsr Wait_Recal ; стандартное ожидание следующего кадра и рекалибровка jsr Do_Sound ; проигрывание tst Vec_Music_Flag ; проверка доигралась ли мелодия beq endmusic jsr DP_to_D0 ; здесь, при необходимости, вывод изображения bra loop endmusic:
Однако, если говорить о воспроизведении на 8912 нормальной музыки, самый простой способ - использование YM_VPACK.EXE из пакета VecSound от Christopher Salomon (понадобится DosBox).
YM_VPACK преобразует известный (в узких кругах :) формат YM (YM5) в .asm, содержащий сразу плейер (умеющий распаковывать музыку на лету) и саму музыку. Под Windows .ym можно получить путём экспорта из Arkos Tracker, либо преобразовав с помощью в AY_Emul (преобразование там из плейлиста осуществляется).
Файл .ym для YM_VPACK предварительно должен быть распакован (пакованный YM - это архив lha).
Остаётся натравить на него ассемблер, в результате чего получится бинарник, включающий плеер и музыку, который можно запустить на Vectrex.
Нужно учитывать, что тактов всё равно расходуется немало (там происходит RLE распаковка "на лету"). Совсем же неупакованная музыка скорее всего займёт неприемлимый объем.
Для звуковых эффектов через 8912 также есть готовое решение - AYFXEdit (PC/Win) и плеер для них в виде sfx.asm (от Richard Chadd)
Карта памяти
Память распределена следующим образом
$0000 - $7FFF - ПЗУ картриджа (собственно, ваша программа)
$8000 - $C7FF - не используемое адресное пространство
$C800 - $CBFF - ОЗУ, свободно - 874 байта
$C800 - $C87F - ОЗУ, используется Vectrex'ом
$D000 - $D7FF - регистры VIA 6522
$D800 - $DFFF - адресуются одновременно ОЗУ и 6522, не использовать
$E000 - $FFFF - ПЗУ BIOS (включая игру Minestorm)
При включении Vectrex, после инициализации системы BIOS и показа заставки, программа начинает выполняться с адреса $0000. После нажатия Reset, либо при включении с зажатой кнопкой "1" заставка пропускается. В случае каких-либо проблем с чтением картриджа типичная реакция системы - запуск встроенной игры MineStorm.
О загадочном канале "zero ref"
Помимо каналов Y, Z и SOUND у мультиплексора есть ещё один загадочный выход, обозначенный на схеме как 'ZERO REF' и ведущий на положительные входы интеграторов. В BIOS этот канал выбирается только в подпрограмме Reset_Pen (она же ACTGND в официальном листинге - SET ACTIVE GROUND). Эта подпрограмма, в свою очередь, часть Reset0Ref (вызываемой при сбросе луча в центр экрана) и явно является частью процесса калибровки. Процитирую мнение svo на эту тему:
--- svo ---
Ноль DAC-а не равен нулю железному (типа потенциал земли) ни разу. И, чтобы быть уверенным, что мы все делаем именно относительно DAC-овского логического нуля, мы выполняем такой фокус. Запоминаем на S&H, что бы он там ни выдавал, и дальше работаем относительно этого значения.
ЦАП перемножающий, выдает ток, пропорциональный произведению разности потенциалов Vref+-Vref- (ноги 14, 15) и цифрового значения на входе. Как устанавливаются эти Vref, похоже не очень важно, суть в том, что какой-то ток скармливается на преобразователь тока в напряжение + фильтр на IC304/2. И вот у него на выходе все уже совсем приблизительно.
Какому потенциалу соответствует цифра "0" в этакой схеме? Я думаю, что разработчики этой схемы очень хорошо знали, что это будет всякий раз разный потенциал, и сделали такую самокалибрующуюся конструкцию.
Интегратор интегрирует по времени значение разности потенциалов между входами ОУ. Например, если разность 0, то интеграл этого должен быть 0 даже при T=вечность. Если потенциал неинвертирующего входа не будет равен потенциалу нуля, потому что мы забили на калибровку, получится нелинейность, типа 1 - 0 != 2 - 1. Интеграл нуля не будет равен нулю и все уползет неизвестно куда. Внешне это, наверное, и должно проявляться как плохо предсказуемые искажения векторов.
--- end svo ---
Средства разработки
Проблема с эмуляторами Vectrex носит иной характер, чем с эмуляторами компьютеров с растровым дисплеем (Commodore, Atari, Spectrum и пр.) Существенная часть схемы Vectrex - аналоговая. И полная её эмуляция хотя и возможна чисто теоретически, на практике вряд ли в ближайшее время кто-то будет заниматься подобным.
Соответственно, существующие эмуляторы в этом отношении скорее симуляторы - на них корректно работает лишь некоторое количество наиболее используемых в играх и демках подходов к формированию изображения. Впрочем, это покрывает почти все игры и часть демок.
Лучший, на данный момент, эмулятор Vectrex - ParaJVE. Он также включает отладчик ParaJVD, из под которого запускается ParaJVE. Всё это можно получить, написав автору вежливое письмо и попросив у него ключик.
Как это работает:
Создаём где-нибудь test.asm. Например, в C:\Program Files (x86)\parajvd\data\sources\test
В качестве ассемблера можно использовать as09 v1.41 / windows (79872 bytes) или a09.exe / windows (98304 bytes). Оба нормально запускаются под Win 64 бит (большинство ассемблеров 6809 не запускаются под Win 64).
Я пользовался as09, но предупреждаю, что у него есть две проблемы 1) макросы почти неработоспособны 2) слишком длинные строки (комментариев) не допускаются. Причём, в обоих случаях возникают очень странные ошибки.
Сборка происходит двумя командами:
as09.exe -i test.asm (получаем бинарник test.bin)
as09.exe -ig -h0 -w200 -l -m test.asm (получаем отладочную информацию - test.dbg и test.lst)
В результате имеем .asm, .bin, .dbg, .lst
(.lst очень полезен если нужно посмотреть, во-первых, как именно ассемблер понял каждую мнемонику, во-вторых, сколько тактов какие команды займут)
Далее можно просто запустить .bin в эмуляторе jve:
ParaJVE.exe -game=test.bin
А можно в отладчике jvd:
Запускаем parajvd.bat, создаём проект. Там есть два варианта (source mode) - .lst либо .dbg. Разница в следующем (цитирую автора):
So the DBG mode shows the source exactly as you typed it, whereas LST does not (if you look at the content of a generated LST file, you will see that it contains lots of "garbage" text, like generated addresses, etc.) But on the other hand, the LST option is good if your source uses a lot of macros (LST will display the expanded macro, whereas DBG will not).
В качестве "source" выбираем не .asm (как можно было бы подумать), а .dbg либо .lst
Если проект уже создан, просто выбираем после запуска jvd.bat нужный. Запускается jvd и в нём ваш test.bin. Можно отлаживать.
Нет иного способа перезагрузить dbg/lst, кроме как полным перезапуском jvd (!). Меню Debug/Reload Cartridge ROM относится к jve. Т.е. jvd не узнает о перезагрузке.
Через этот Reload можно перезагрузить перекомпилированный .bin и он запустится, не более того. Причём, нельзя делать это когда програма остановлена (например, на breakpoint'e) - всё повиснет.
Breakpoint'ы при выходе сохраняются (т.е. при следующем запуске jvd он сработает).
Ещё раз: не стоит воспринимать JVE как полноценный эмулятор железа. Он ориентирован скорее на выполнение типичного софта. К примеру, всё что происходит в вызове Wait_Recal для эмулятора без особого ущерба можно сократить до нескольких команд (вызов DP_to_D0 и сброс интеграторов). Получившийся код будет прекрасно работать в эмуляторе, но совершенно не работать на реальном железе.
Кроме того, от некоторых безобидных на вид сочетаний команд эмулятор может падать с ошибкой.
Однако, если при программировании не выходить за рамки того, что делается в самом BIOS'е, можно считать, что эмуляция достаточно хороша.
Есть и другой, более старый эмулятор - DVE. Он похуже и под DOS. Тем не менее, вполне работает под DosBox, включает отладчик с дизассемблером и может быть полезен. В числе прочего, рекомендую почитать help.dat/* от него.
Лично мне было оптимально писать код в Sublime Text 3, а затем одним нажатием кнопки ассемблировать его и тут же запускать в JVE (запуская соответствующий .bat).
Отладчик (JVD) я не использовал вообще - чем с ним возиться, быстрее самому понять, где ошибка.
Разумеется, регулярно приходилось проверять код на настоящем Vectrex. Я для этого использовал эмулятор ПЗУ изготовленный svo (подключаемый по USB), но в принципе в Интернете можно найти или купить аналогичное готовое решение.
Заготовка исходника .asm:
include "vectrex.i" org 0 db "g GCE 2015", $80 ; Изменять можно только год. 'g' - знак копирайта dw $F600 ; адрес музыки которую надо играть при показе названия программы на начальной заставке (в данном случае - никакой) db $F8, $32, 33, -$36; высота, ширина, Y, X названия программы на заставке db "PROGRAM TITLE", $80; название программы db 0 ; признак конца заголовка loop: jsr Wait_Recal .... bra loop
Немного о дизассемблерах...
Их есть два, предназначенных именно для Vectrex - оба с исходниками (на C и на Pascal). Оба под DOS (т.е. требуют DosBox) и без описания.
С одним из этих дизассемблеров идут конфиги для дизассемблирования пары десятков игр и программ. Всё это можно найти на http://vectrexmuseum.com/share/coder/
Также можно попробовать IDA. Но до версии 6.7 он не понимает адресации по DP (что делает его практически бесполезным). В 6.7. вроде как это изменилось, но я не проверял.
Лично я пользовался DIS6809.EXE, вполне успешно. Для него надо создать .ctl примерно такого вида:
; FILENAME.CTL TITLE _просто_название_ ASM FILE FILENAME.BIN 000000 0000 076A LABS LABELS ENTRY 0024 ; начало кода ASCII 0000 0023 ; от 0 до 23 - ascii строка BYTE 0710 0769 ; от 0710 до 0769 - данные (fcb)
и т.д. (там есть исходник на Паскале, из него в общих чертах ясно, что делают те или иные ключевые слова и параметры).
Затем в DosBox пишем dis6809 filename.ctl >filename.asm
Ссылки
http://vectrexmuseum.com/share/coder/
Форум vectorgaming
ParaJVD
ParaJVE
Asm80 - онлайн эмулятор 6809, с отладчиком
Архив, в который я собрал различную документацию, примеры и утилиты (кроме эмуляторов)
Перевод статьи о разработке игры Frogger
Рассказ svo о Vectrex (текст и видео)
Исходники моих работ - Electric Force, Invitron, Rainy, EmptyScreenTro
А также книжки "6809 Assembly Language Programming - Leventhal.pdf" , "CoCoAssemblyLang_Color.pdf"
Автор статьи: Пётр Соболев (frog AT enlight.ru), 1 сентября 2015 г.