Практическое руководство по FT812, TS-Config, SD-Card и General Sound на реальном проекте Zuma VDAC2
Учебник по реальному проекту Zuma Deluxe VDAC2. Это практический материал о разработке игры класса Zuma для ZX-Evo TS-Config с видеовыводом через VDAC2/FT812, загрузкой с SD-card, вводом через Mr.Gluk/AT-клавиатуру и звуком General Sound.
Оглавление ниже ведёт по полному материалу книги; источники и внешние ссылки перенесены в конец статьи.
- Структура учебника
- 1. Аппаратная связка ZX-Evo + VDAC2
- 2. SPI-протокол FT812
- 3. Memory Map FT812
- 4. Видеотайминги для 640×480
- 5. Display List (DL): список команд рисования FT812
- 6. Bitmap-форматы для FT812
- 7. Производительность и DMA
- 9. Главный цикл рендера (Hello World pattern из TSLib)
- 10. TSLib API — карта макросов
- Глава 12. Bitmap rendering — matrix transform, scale, paletted formats (опыт 2026-05-09)
- 12.1 Главный урок: BITMAP_TRANSFORM работает на bitmap UV, не на screen position
- 12.2 cmd_scale convention
- 12.3 BITMAP_SIZE при upscale
- 12.4. Бюджет памяти для background (1 МБ RAM_G FT812)
- 12.5 Asymmetric downscale (X≠Y)
- 12.6. Почему нули в конце spgbld-страницы могут затереть соседний ресурс
- 12.7. Сжатие PNG/JPEG: экономит файл, но не RAM_G
- 12.8 Финальный выбор для Zuma VDAC2 (level 1 spiral)
- 12.8.1 Полный pipeline компрессии bg (нюансы практики)
- 12.8.2. Ретро-баг: нулевой хвост background затирал atlas шаров
- Глава 13. Frog composition: HD-стиль pipeline (опыт 2026-05-09/10)
- Глава 14. RNG: LFSR Galois + bias + RTC-scramble (опыт 2026-05-10)
- Глава 15. Frog с полной HD-композицией (2026-05-10)
- 15.1 Render order (HD-1:1)
- 15.2 RAM_G layout (1 МБ, baseline 2026-05-10)
- 15.3 Tongue — pos + tongueExpand·dir (HD orbit)
- 15.4 Ball-now / Next-ball через chain atlas (handle 0)
- 15.5 Recoil cycle (HD-style fire animation)
- 15.6 FT81x cmd_scale: matrix хранит INVERSE
- 15.7 Critical bugs found and fixed (2026-05-10 session)
- 15.8 Python visual_emulator.py — prototype-first workflow
- Глава 16. FT81x DL persistent state — Cell, BITMAP_HANDLE и ловушки наследования (2026-05-10)
- Глава 17. Vsync-first sync: race между Z80 build и FT812 render (2026-05-10)
- Глава 18. DXT1-эмуляция на FT812: компрессия фона до 0.5 байт/пикс через L2-mask + RGB565 blend (2026-05-12)
- Глава 19. Апгрейд DXT1-эмуляции с L2 до L4: +50% SPI за фотокачество (2026-05-12)
- Глава 20. Render-loop оптимизации и DL-emit ловушки (2026-05-17)
- 20.1 Bucket-grouped tangent rotation: 32 cmd_rotate → 16, а потом обратно
- 20.2 Per-sprite alpha fade через COLOR_A — плавное поглощение
- 20.3 Cell/ColorA корраптят BC/DE — координаты грузить ПОСЛЕ, а не ДО
- 20.4 Скип лишнего DL: bg-baked = overlay не нужен
- 20.5 Continuous-motion absorb через HSub-advance (mirror of fast-spawn)
- 20.6 Тоннели: маскирование шаров не лечит бюджет строки
- Глава 21. Per-ball matrix с per-slot hysteresis и grouped emit (2026-05-18)
- Глава 22. Расщепление Core на main0 + main1 (slot 1 + slot 3) и невидимая ловушка CMD_ADDRESS_PTR (2026-05-18)
- Глава 23. Экономия RAM_G шаров: путь к PALETTED4444 (v025)
- Глава 24. Почему отказались от псевдо-DXT фона (2026-05-19)
- TL;DR
- Что делал псевдо-DXT (краткое напоминание)
- Симптом на реале — «срыв строчной»
- Реальная причина — пиксельный клок-бюджет на строку
- Решение — один полноэкранный проход
- Что потеряли по сравнению с псевдо-DXT
- Почему не разделили на полосы
- Bigger picture: правило бюджета строки
- Когда стоит вернуться к псевдо-DXT
- Уточнение модели (ground-truth через эмулятор, см. главу 25)
- Глава 25. Bridgetek EveApps FT812 Emulator — настоящая эмуляция чипа (2026-05-19)
- Глава 26. BITMAP_HANDLE binding ловушка FT812 (2026-05-20)
- Глава 27. Сессия 2026-05-20: scoring engine, matrix LUT, ARGB4 frog → tearing fix
- 27.1 Match-3 «синие шары вместо gap» — критический bug
- 27.2 Scoring engine 1:1 с HD-ref Statistics.c
- 27.3 Точный fill_px для прогресс-бара
- 27.4 FT_ScissorXY клобает B — повторение pattern
- 27.5 GaugeShown animated LERP — плавный бар
- 27.6 Pre-baked rotation matrix LUT
- 27.7 OK button hit-test + Fire trigger
- 27.8 MENU sprite skip baked при idle
- 27.9 Главный фикс tearing: ARGB4 + NEAREST для frog
- 27.10 Outcome дня
- Глава 28. Mr.Gluk RTC чтение и часы на экране (2026-05-20)
- Глава 29. Когда эмулятор сам с багом — горький урок (2026-05-20)
- Глава 30. FAT32 с SD-карты в TS-Conf: от WC ZiFi к собственному драйверу RawPak (CMD17 + LFN)
- 30.0 Зачем эта глава
- 30.1 Историческая развилка: почему отказались от WC ZiFi
- 30.2 RawPak: собственный FAT32-ридер на прямом CMD17
- 30.3 BPB и открытие тома — RawPak_OpenRoot
- 30.4 LFN-сопоставление пути /Games/Zuma Deluxe VDAC2/ZUMALVL.PAK
- 30.5 FAT-цепочка: FatNext + ловушка AdvanceOne
- 30.6 Таблица секторов: LBA = PakLba + N (допущение непрерывности)
- 30.7 Двухфазная загрузка: FT812 и SD на одной SPI-шине
- 30.8 Трек на 2 страницы (#06 + #0F) — верхние уровни
- 30.9 Инструмент: локальный Z80-харнесс FAT (не гонять хост зря)
- 30.10 Pack-формат ZUMALVL.PAK
- 30.11 Миф «размер SPG ломает загрузку» — опровергнут
- 30.12 Ловушки (anti-patterns)
- 30.13 Build pipeline
- 30.14 Связано
- Глава 31. Adventure-режим: уровни из таблицы, перенос счёта, Win/Pause (v035–v041)
- Глава 32. Опрос клавиатуры (Mr.Gluk PS/2) и единый глобальный модуль ввода (v044, 2026-05-27)
- 33. General Sound на TS-Config: музыка MOD и SFX из PAK
- 33.1. Порты и базовый handshake
- 33.2. Загрузка MOD музыки
- 33.3. Возврат в меню без повторного BASS_MusicLoad
- 33.4. SFX pack и почему нельзя сбрасывать GS перед gameplay
- 33.5. Частоты sample и скорость проигрывания
- 33.6. Устранение шумового хвоста sample — добивка тишиной, НЕ обрезка
- 33.7. Практические правила
- Глава 34. Реальное железо vs эмулятор: SPI SD-карта, General Sound, инициализация (2026-05-30)
- 34.1. Главный принцип
- 34.2. SD-карта: общая SPI-шина с FT812, byte vs block, «кирпич»
- 34.3. General Sound (MultiSound) на реале: детект есть, загрузка музыки нет
- 34.4. Инициализация на реальном железе — порядок и состояние
- 34.5. Диагностика на железе без F12-дампа
- 34.6. HMM2: «нет сигнала» при переносе VDAC2 init (2026-06-08)
- 34.7. Открыто
- Глава 35. Рефактор 640×480 → 1024×768: зачем, выигрыш и трудности апскейла ×1.6
- 35.1. Зачем переходили — выиграть такты FT812 на строку
- 35.2. Что выиграли
- 35.3. Грабля №1: CMD_SCALE нецелый — дрейф из-за инверсной матрицы
- 35.3.1. Нюанс апскейла pseudo-DXT
- 35.4. Грабля №2: точность не только формы, но и арифметики (срез лягушки)
- 35.5. Грабля №3: 15-битные VERTEX2F и 9-битные VERTEX2II
- 35.6. Грабля №4: BILINEAR + BORDER — чёрная вертикальная линия на стыке при скролле неба
- 35.7. Скейл ассетов и треков
- 35.8. Грабля №5: нецелый NEAREST ×1.6 рвёт тонкие детали (шрифты, логотип ZX Evolution)
- Ловушки (свод)
- Глава 36. Дисковая подсистема и цикл сборки паков: грабли рассинхрона table↔pack
- 36.1. Четыре пака проекта: кто что грузит
- 36.2. Как make_main_pack.py строит и таблицу, и пак из одного источника
- 36.3. Главная грабля: рассинхрон table↔pack при росте кода
- 36.4. Лечение: двойной проход до фикспойнта
- 36.5. ZUMALVL.PAK: трек на 2 страницы и сектор-выровненный TOC
- 36.6. ZUMASND.PAK / ZUMAAUD.PAK: своя пара table↔runtime граблей
- 36.7. Инжект в образ: inject_zuma_to_wc_img.py и test_wc.img-харнесс
- 36.8. Ловушки (anti-patterns)
- 36.9. Связано
- Глава 37. General Sound: детект, предзагрузка, стрим и SFX с питчем — полный цикл и ловушки
- 37.1. Детект карты: GS_Present
- 37.2. Ловушка «звук молчит»: предзагрузка обязательна
- 37.3. Стрим музыки MOD в карту — и почему он двигает анимацию загрузки
- 37.4. SFX с питчем: GS_PlaySfxNote
- 37.5. Почему звук GS не включался на реале, но играл в эмуляторе (порядок записи в FIFO)
- 37.6. Риск вечного ожидания без фолта
- Ловушки (свод)
- Глава 38. Реал vs эмулятор — углубление: clear-on-read регистры, привязка к опциональным этапам, копроцессор-фолты
- Глава 39. Подтяжка сегментов цепи в slot-модели: PULL/CATCH-UP, дробный декей, Z80-ловушки
- Глава 40. Свод граблей: чек-лист перед коммитом
- Источники и внешние ссылки
Накопительный конспект материала. По мере разбора датшитов, источников и собственных экспериментов — здесь оседают факты, формулы, код-примеры, схемы и решения. Из этого вырастет полноценный учебник.
Целевая аудитория: разработчики на Z80 (sjasmplus / asm), которые хотят разобраться с ZX-Evo, TS-Conf, VDAC2 и выводом через FT812.
Короткие определения перед стартом:
-
ZX-Evo / ZX Evolution (Pentevo) — современная ZX Spectrum-совместимая платформа, на которой работает проект.
-
TS-Conf — расширенная конфигурация ZX-Evo от TS-Labs: видеорежимы, страничная память, DMA и порты управления экраном/периферией. В этом учебнике TS-Conf даёт Z80-окружение, страницы памяти, DMA и порты, а финальную картинку игры выводит FT812 через VDAC2.
-
VDAC2 — плата видеовыхода для ZX-Evo с видеочипом FT812.
-
FT812 — видеочип Bridgetek/FTDI EVE. Z80 отправляет ему команды и данные через SPI, а FT812 сам формирует VGA/RGB-сигнал.
-
Display List (DL) — список 32-битных команд рисования FT812. Он хранится во внутренней памяти
RAM_DL(8 КБ, адреса0x300000..0x301FFF). Это не готовая картинка, а команды вродеCLEAR,BITMAP_HANDLE,VERTEX2II,DISPLAY. -
Командный процессор FT812 — внутренний обработчик команд FT812; в документации EVE/TSLib его часто называют co-processor (далее — копроцессор). Z80 пишет команды в кольцевой буфер
RAM_CMD(4 КБ), а копроцессор разворачивает их в настоящий Display List или операции с памятью.
Структура учебника
Учебник состоит из двух частей. Подробное оглавление со ссылками генерируется автоматически (раздел «Оглавление» выше в HTML/PDF).
Часть I. Основы FT812 / VDAC2 (главы 1–11) — фундамент, выведенный из датшитов и TSLib: аппаратная связка ZX-Evo+VDAC2, SPI-протокол, memory map, видеотайминги 640×480, список команд рисования FT812 (Display List / DL), bitmap-форматы, производительность/DMA, главный цикл рендера и карта TSLib API. Читается линейно как введение.
Часть II. Журнал разработки Zuma (главы 12–40) — практический опыт в хронологическом порядке: каждая глава фиксирует конкретную задачу/баг/решение с датой и ссылками на код, baseline и память. Главы самодостаточны — можно читать выборочно по теме. Сквозные темы:
Маршрут по подсистемам для первого чтения:
-
FT812 / VDAC2: главы 1–7, 9–12, 15–27, 35 и 40.1–40.2. Здесь memory map, SPI-транзакции, Display List (DL), RAM_CMD, bitmap-форматы, копроцессор FT812, матрицы, стоимость форматов и ограничения pixel-clock budget.
-
TS-Config: главы 1.2–1.3, 7, 22, 28, 32, 34 и 40.3. Здесь VCONFIG/DMA, страничная память, общая SPI-шина, Mr.Gluk RTC и AT/PS/2-клавиатура.
-
Дисковая подсистема SD-Card: главы 30 и 36. Здесь CMD17, FAT32/LFN, RawPak, PAK-таблицы, двойной проход сборки и инжект в образ.
-
Звуковая подсистема General Sound: главы 33, 37 и 40.5. Здесь порты, handshake, MOD, SFX, preload, FIFO и отличия эмулятора от реального железа.
-
Реальное железо против эмулятора: главы 25, 29, 34, 38 и 40.7. Здесь указано, какие проверки можно доверять харнессам, а какие обязаны проходить на настоящем FT812/ZX-Evo.
-
Рендеринг и оптимизация списка команд FT812 (DL): 12 (bitmap matrix/scale/paletted), 16 (persistent DL state), 17 (vsync-first), 20 (render-loop приёмы), 21 (адаптивная группировка матриц шаров), 23 (PALETTED4444 шаров), 24 (бюджет строки FT812), 26 (BITMAP_HANDLE binding), 27 (matrix LUT, ARGB4 frog → fix tearing).
- Фон уровня: 18–19 (DXT1-эмуляция L2/L4), 24 (почему перешли на единый PALETTED4444-проход).
- Игровые объекты: 13, 15 (композиция лягушки), 14 (RNG), 28 (RTC-часы).
- Инструменты и методология: 25 (эмулятор EveApps + дамп RAM_DL), 29 (когда эмулятор сам врёт — источник истины = RAM dump).
- Загрузка с SD: 30 (FAT32-драйвер: эволюция от WC ZiFi к собственному CMD17+LFN).
- Игровые системы (adventure): 31 (выбор уровня, параметры из таблицы, перенос счёта, Win/Pause).
- Ввод: 28 (RTC через Mr.Gluk), 32 (опрос PC-клавиатуры через PS/2 FIFO + единый глобальный модуль Input.asm: клавиатура/Kempston/мышь, навигация меню).
- Звук (General Sound): 33 (музыка MOD и SFX из PAK: порты/handshake, загрузка модуля, возврат в меню без повторного BASS_MusicLoad, добивка хвоста сэмпла тишиной).
- Реальное железо vs эмулятор: 34 (SPI SD-карта на общей шине с FT812, byte/block-адресация и «кирпич» карты, граница LBA; General Sound на реале — поиск пака в папке, порядок данных в FIFO; инициализация; диагностика на железе без F12), 38 (clear-on-read
REG_INT_FLAGS→ зависание всех переходов; привязка анимации к progress-событиям, а не к времени/циклам; фолты/таймауты копроцессора; чем харнесс/эмулятор НЕ моделируют реал). - Апскейл-рефактор: 35 (640×480 → 1024×768 ×1.6: запрет нецелого
CMD_SCALE, запечённые матрицы 160/256, точная арифметика констант, переполнениеVERTEX2F/VERTEX2II,BILINEAR-стык тайлов). - Дисковая подсистема (сборка): 36 (четыре пака; грабля рассинхрона
make_main_pack.py --table↔--packи двойной проход; трек-сплит на 2 страницы; инжект в образ) — дополняет 30 (ридер). - Звук (углубление): 37 (детект
GS_Present; ловушка «звук молчит безPRELOAD_IDS»; стрим MOD и его роль в тиках анимации; SFX с питчемGS_PlaySfxNote) — дополняет 33. - Подтяжка цепи: 39 (PULL/CATCH-UP/отдача в slot-модели; 5 дефектов рывков квантованной модели; дробный накопитель декея; rear-comp до конца цепи; Z80-ловушка
PUSH AF/POP AF; win-регрессия и урокgaugeFull=1). - Свод граблей: 40 (чек-лист перед коммитом по всем классам: матрицы/координаты, DL и копроцессор FT812, SPI-шина, сборка, звук, Z80-идиомы, реал-vs-эмулятор, тестирование).
Нумерация глав сквозная (1→40). Историческое примечание: ранние версии учебника использовали отдельные пометки
§N/§M/§Rи нумерацию журнала с 18 — в ревизии 2026-05-26 всё приведено к сплошной нумерации.
1. Аппаратная связка ZX-Evo + VDAC2
1.1. Что такое VDAC2
VDAC2 — расширительная плата для ZX-Evo, заменяющая стандартный 5-bit VDAC. Содержит чип FT812 (FTDI Embedded Video Engine):
- 1 МБ графической памяти
RAM_G - Display List (DL): список 32-битных команд рисования в
RAM_DL(8 КБ) - копроцессор FT812 с командным буфером
RAM_CMD(4 КБ) -
VGA/RGB-выход; в проекте 640×480 — логическое пространство игровой механики, а 1024×768 — поздний режим вывода с апскейлом 8/5
-
RGB-выход 8 бит на канал
- SPI-интерфейс к хосту (Z80): FT812 допускает SCLK до 30 MHz, но в нашем
Z80-пути скорость ограничивает не этот предел, а
OUT/OTIRчерез портSPI_DATA. ПриSYS_ZCLK14это примерно14 МГц / 21 такт= ~666 КБ/с для длинногоOTIR-блока без учёта заголовка SPI; при 7 МГц было бы ~333 КБ/с.
В STATUS-регистре TS-Conf версия адаптера:
000— 2-bit VDAC + PWM001/010/011— 3/4/5-bit VDAC111— VDAC2 (FT812) ← наш случай
Sanity-check на старте программы:
IN A, (0xAF) ; STATUS
AND %00000111
CP %00000111
JR NZ, .no_vdac2 ; на этой плате FT812 нет → fallback на TS-Conf рендер
1.2. Порты TS-Conf для общения с FT812
| Порт | Назначение | R/W | Биты |
|---|---|---|---|
| 0xAF | STATUS | R | [2..0] версия видеоадаптера |
| 0xAF | VCONFIG | W | [2] FT_EN (0=TS-Config / 1=FT812), [5] NO_GFX (1=отключить TS-Config gfx, освободить DMA-циклы) |
| 0x77 | SPI_CTRL | W | bit 0 — ZX-Evolution flag, bit 1 — SD CS (0=en/1=dis), bit 2 — FT812 CS (0=dis/1=en) |
| 0x57 | SPI_DATA | R/W | байтовый обмен с активным SPI устройством |
Магические значения SPI_CTRL:
0x03—SPI_FT_CS_OFF(FT812 disable)0x07—SPI_FT_CS_ON(FT812 enable)
1.3. VCONFIG для VDAC2-режима
LD A, %00100100 ; FT_EN=1 (bit 2), NO_GFX=1 (bit 5)
OUT (0xAF), A
NO_GFX=1 отключает обычный TS-Config рендер пикселей. Это экономит DMA-циклы:
лимит DMA на строку = 448 циклов, обычно расходуются на чтение VRAM для отрисовки спектрум-экрана.
С NO_GFX=1 эти циклы целиком уходят CPU и DMA-пересылке байт в FT812. На бордюре чтения и так нет —
там всегда полный лимит свободен.
2. SPI-протокол FT812
2.1. Три типа транзакций (по 2-битному префиксу)
| Префикс | Тип | Структура |
|---|---|---|
00b |
Memory Read | 2b prefix + 22b address + dummy byte + N data bytes |
10b |
Memory Write | 2b prefix + 22b address + N data bytes |
01b |
Host Command | 2b prefix + 6b cmd code + arg byte + 0x00 |
11b |
(зарезервирован) | — |
На уровне Z80 префикс — это два старших бита первого отправляемого байта адреса:
- Read: первый байт =
addr[21:16](биты 7..6 =00) - Write: первый байт =
addr[21:16] OR 0x80(биты 7..6 =10) - Host command: первый байт =
0x40 OR cmd[5:0]
2.2. Каждая транзакция в обёртке CS
FT_ON ; OUT (0x77), 0x07 — взвести CS
... последовательность OUT/IN через 0x57 ...
FT_OFF ; OUT (0x77), 0x03 — снять CS
Внутри одной транзакции адрес FT812 авто-инкрементируется — длина блока не ограничена, если данные пишутся в непрерывную область памяти. Это позволяет одной транзакцией залить весь Display List или большой bitmap.
2.3. Готовые asm-функции (из учебника #2)
Макросы CS
FT_ON: MACRO
LD A, 0x07 ; SPI_FT_CS_ON
OUT (0x77), A ; SPI_CTRL
ENDM
FT_OFF: MACRO
LD A, 0x03 ; SPI_FT_CS_OFF
OUT (0x77), A
ENDM
FT_VMODE: MACRO
LD A, %00100100 ; FT_EN=1, NO_GFX=1
OUT (0xAF), A
ENDM
FT_RD8 — чтение байта из RAM_REG
; In: DE = addr[15..0] (адрес внутри RAM_REG, старший байт фиксирован = 0x30)
; Out: A = прочитанный байт
; Corrupts: AF
FT_RD8:
FT_ON
LD A, 0x30 ; FT_RAM_REG >> 16 = 0x30
OUT (0x57), A ; addr[21..16] (префикс 00b — read)
LD A, D
OUT (0x57), A ; addr[15..8]
LD A, E
OUT (0x57), A ; addr[7..0]
OUT (0x57), A ; dummy OUT (FT812 готовится)
IN A, (0x57) ; dummy IN (особенность чтения)
IN A, (0x57) ; реальные данные
PUSH AF
FT_OFF
POP AF
RET
Важно: для чтения всегда нужен один dummy OUT + один dummy IN после трёх байт адреса, иначе следующий IN вернёт мусор.
FT_RD16
; In: DE = addr[15..0]
; Out: BC = прочитанное 16-битное значение (little-endian)
FT_RD16:
FT_ON
LD A, 0x30 : OUT (0x57), A
LD A, D : OUT (0x57), A
LD A, E : OUT (0x57), A
OUT (0x57), A ; dummy OUT
IN A, (0x57) ; dummy IN
IN A, (0x57) : LD C, A ; младший байт
IN A, (0x57) : LD B, A ; старший байт
FT_OFF
RET
FT_WR8 — запись байта в регистр
; In: DE = addr[15..0], A = записываемое значение
FT_WR8:
PUSH AF
FT_ON
LD A, (0x30) OR 0x80 ; bit 7 = 1 → префикс 10b → write
OUT (0x57), A
LD A, D : OUT (0x57), A
LD A, E : OUT (0x57), A
POP AF
OUT (0x57), A ; данные
FT_OFF
RET
FT_Write — блочная запись через OTIR
; In: HL = Z80-источник, BC = количество байт,
; A = addr[21..16] (без bit 7), DE = addr[15..0]
; Out: HL += BC, ADE += BC (для chained-вызовов)
FT_Write:
PUSH AF
FT_ON
POP AF
PUSH AF
OR 0x80 ; включаем bit 7 — write префикс
OUT (0x57), A
LD A, D : OUT (0x57), A
LD A, E : OUT (0x57), A
POP AF
; пересчитать адрес для следующего вызова: HL += BC, ADE += BC
EX DE, HL
ADD HL, BC
EX DE, HL
ADC A, 0
PUSH AF
; основной OTIR loop (256-байтные пакеты)
LD A, C : OR A ; есть младший хвост?
LD A, B ; A = количество полных пакетов
LD B, C ; B = младший байт count'а
LD C, 0x57 ; SPI_DATA
JR Z, .loop
OTIR ; неполный пакет
OR A
JR Z, .exit
.loop:
OTIR ; B=0 → 256 байт
DEC A
JR NZ, .loop
.exit:
FT_OFF
POP AF
RET
FT_Read строится симметрично через INIR, без bit 7 в первом байте + dummy перед циклом.
2.4. Когда размер блока удобен
- Заливка bitmap в RAM_G — одна транзакция на килобайты
- Полный DL (до 8 KB) — одна транзакция
- Запись в RAM_CMD кольцевого буфера — порциями до wrap’а
3. Memory Map FT812
22-битное адресное пространство; всё mapped единообразно по SPI:
| Диапазон | Размер | Имя | Назначение |
|---|---|---|---|
0x000000-0x0FFFFF |
1024 KB | RAM_G | Графика общего назначения (bitmaps) |
0x1E0000-0x2FFFFB |
1152 KB | ROM_FONT | Шрифты ROM |
0x300000-0x301FFF |
8 KB | RAM_DL | Список команд рисования (Display List / DL) |
0x302000-0x302FFF |
4 KB | RAM_REG | Регистры |
0x308000-0x308FFF |
4 KB | RAM_CMD | Кольцевой командный буфер копроцессора |
Endianness: little-endian для всех многобайтных значений (Z80-friendly).
3.1. Ключевые регистры RAM_REG
| Адрес | Имя | Биты | Сброс | Назначение |
|---|---|---|---|---|
0x302000 |
REG_ID | 8 ro | 0x7C |
Сигнатура чипа (sanity-check) |
0x302004 |
REG_FRAMES | 32 ro | 0 | Счётчик кадров от reset |
0x30200C |
REG_FREQUENCY | 28 rw | 60000000 | Тактовая частота в Hz |
0x30202C |
REG_HCYCLE | 12 rw | 0x224 | Полное число PCLK на строку |
0x302030 |
REG_HOFFSET | 12 rw | 0x02B | H-offset (front porch + sync + back porch) |
0x302034 |
REG_HSIZE | 12 rw | 0x1E0 | Видимая ширина в PCLK = пикселях |
0x302038 |
REG_HSYNC0 | 12 rw | 0x000 | H-sync front porch |
0x30203C |
REG_HSYNC1 | 12 rw | 0x029 | H-sync front + pulse |
0x302040 |
REG_VCYCLE | 12 rw | 0x124 | Полное число строк на кадр |
0x302044 |
REG_VOFFSET | 12 rw | 0x00C | V-offset |
0x302048 |
REG_VSIZE | 12 rw | 0x110 | Видимая высота в строках |
0x30204C |
REG_VSYNC0 | 10 rw | 0 | V-sync front porch |
0x302050 |
REG_VSYNC1 | 10 rw | 0x00A | V-sync front + pulse |
0x302054 |
REG_DLSWAP | 2 rw | 0 | Управление flip’ом DL (0/1/2) |
0x302070 |
REG_PCLK | 8 rw | 0 | Делитель PCLK (0=PCLK выкл) |
0x30206C |
REG_PCLK_POL | 1 rw | 0 | Полярность PCLK |
0x302100 |
REG_CMD_DL | 13 rw | Указатель копроцессора на текущую позицию в DL |
3.2. DLSWAP modes
| Значение | Имя | Эффект |
|---|---|---|
| 0 | DLSWAP_DONE |
Текущий swap завершён (read) |
| 1 | DLSWAP_LINE |
Swap по строке |
| 2 | DLSWAP_FRAME |
Swap в начале vsync (стандарт) |
После записи нового DL → FT_WR8(REG_DLSWAP, DLSWAP_FRAME) — кадр обновится в следующем vsync.
4. Видеотайминги для 640×480
TSLib содержит выверенные таблицы для трёх режимов 640×480 в
Docs/TSLib/Include/FT/81x Const.inc
(блок ; Video modes):
4.1. Главный вывод: 74 Гц выбрали ради строки, а не ради FPS
Выбор VM_640_480_74Hz был осознанным компромиссом: мы пошли на риск сузить парк
поддерживаемых мониторов ради дополнительных тактов FT812 на строку. Режим около
60 Гц совместимее для VGA-мониторов, но в тяжёлых кадрах Zuma срывалась именно
строка, а не игровая логика.
Для FT812 важен HCYCLE: сколько pixel-clock’ов есть на одну строку, чтобы чип
успел пройти Display List и выдать пиксели. У 57/76 Гц строка даёт 800 PCLK, а у
74 Гц — 832 PCLK. Это всего +32 PCLK на строку, но в момент, когда бюджет был на
грани, именно этот запас имел смысл. VM_640_480_76Hz быстрее по refresh, но
HCYCLE у него те же 800, поэтому проблему строки он не решал.
Позже переход на 1024×768@59 дал уже 1344 PCLK на строку и вернул режим ближе к
универсальным ~60 Гц, но на этапе 640×480 выбор 74 Гц был именно выбором строки.
| Режим TSLib | PCLK | H timing (FP/SYNC/BP/VIS) | HCYCLE | V timing (FP/SYNC/BP/VIS) | VCYCLE | Refresh | Практический смысл |
|---|---|---|---|---|---|---|---|
VM_640_480_57Hz |
24 МГц | 16/96/48/640 | 800 | 11/2/31/480 | 524 | 57.25 Гц | совместимее, но меньше строковый бюджет |
VM_640_480_74Hz |
32 МГц | 24/40/128/640 | 832 | 9/3/28/480 | 520 | 73.96 Гц | выбран ради +32 PCLK/строку |
VM_640_480_76Hz |
32 МГц | 16/96/48/640 | 800 | 11/2/31/480 | 524 | 76.34 Гц | выше refresh, но строка не длиннее |
F_MUL — значение, которое TSLib пишет в REG_PCLK. На плате VDAC2 (внешний
клок через CLKEXT + CLKSEL #C0) результирующий pixel clock получается как
8 МГц × F_MUL: 8×3 = 24 МГц (а при F_MUL=4 → 32 МГц, §4.2). Это подтверждается
независимо: HCYCLE × VCYCLE × refresh = 800×524×57 ≈ 24 МГц.
⚠️ Уточнение семантики (важно при переносе). «8 × F_MUL» — мнемоника ИМЕННО нашей настройки клока на VDAC2, а не общая семантика регистра. По даташиту FT81x
REG_PCLK— это ДЕЛИТЕЛЬ системного клока:PCLK = f_sys / REG_PCLK(не множитель и не «база 8 МГц»). У FT81x системный клок задаётсяCLKSEL(по умолчанию 60 МГц; варианты 24/36/48; 72 МГц — уже у BT81x), и после смены клока надо обновитьREG_FREQUENCY. Переносите на другой клок — считайте PCLK по формуле делителя из даташита, не по «×8».
4.2. Константы TSLib для трёх 640×480-режимов
-
VM_640_480_57Hz:F0_MUL=3, H0_FPORCH=16, H0_SYNC=96, H0_BPORCH=48, H0_VISIBLE=640, V0_FPORCH=11, V0_SYNC=2, V0_BPORCH=31, V0_VISIBLE=480. -
VM_640_480_74Hz:F1_MUL=4, H1_FPORCH=24, H1_SYNC=40, H1_BPORCH=128, H1_VISIBLE=640, V1_FPORCH=9, V1_SYNC=3, V1_BPORCH=28, V1_VISIBLE=480. -
VM_640_480_76Hz:F2_MUL=4, H2_FPORCH=16, H2_SYNC=96, H2_BPORCH=48, H2_VISIBLE=640, V2_FPORCH=11, V2_SYNC=2, V2_BPORCH=31, V2_VISIBLE=480.
4.3. Соответствие регистрам FT812
TSLib-макрос FT_ModeTab из
Docs/TSLib/Include/FT/81x Const.inc
укладывает значения в следующие регистры FT812:
| Регистр | Формула | 57 Гц | 74 Гц | 76 Гц |
|---|---|---|---|---|
| REG_HSYNC0 | H_FPORCH | 16 | 24 | 16 |
| REG_HSYNC1 | H_FPORCH + H_SYNC | 112 | 64 | 112 |
| REG_HOFFSET | H_FPORCH + H_SYNC + H_BPORCH | 160 | 192 | 160 |
| REG_HSIZE | H_VISIBLE | 640 | 640 | 640 |
| REG_HCYCLE | H_FPORCH + H_SYNC + H_BPORCH + H_VISIBLE | 800 | 832 | 800 |
| REG_VSYNC0 | V_FPORCH − 1 | 10 | 8 | 10 |
| REG_VSYNC1 | V_FPORCH + V_SYNC − 1 | 12 | 11 | 12 |
| REG_VOFFSET | V_FPORCH + V_SYNC + V_BPORCH − 1 | 43 | 39 | 43 |
| REG_VSIZE | V_VISIBLE | 480 | 480 | 480 |
| REG_VCYCLE | V_FPORCH + V_SYNC + V_BPORCH + V_VISIBLE | 524 | 520 | 524 |
| REG_PCLK | F_MUL (см. §4.1) | 3 → 24 МГц | 4 → 32 МГц | 4 → 32 МГц |
| REG_PCLK_POL | 0 | 0 | 0 | 0 |
Именно строка REG_HCYCLE объясняет выбор 74 Гц: у него единственного из трёх
640×480-режимов строковый бюджет больше 800 PCLK. Цена — менее универсальный
refresh для мониторов.
4.4. Применение через TSLib-макрос
В TSLib переключение режима — одна строчка:
FT_RESOLUTION VM_640_480_57Hz, ResolutionWidthPtr
Где ResolutionWidthPtr — Z80-указатель на 2-байтную ячейку в RAM, куда макрос
сохраняет ширину экрана для последующего использования
(Docs/TSLib/Examples/2.HelloWorld/Include.inc).
FT_RESOLUTION из
Docs/TSLib/Include/FT/812 Macro.inc
сам разворачивается в нужную таблицу
- серию
FT_WR_REG16по адресам HCYCLE/HOFFSET/HSIZE/HSYNC0/HSYNC1/VCYCLE/VOFFSET/VSIZE/VSYNC0/VSYNC1 FT_WR_REG8 FT_REG_PCLKсо значением F_MUL.
4.5. Полная init-последовательность (TSLib FT_BOOT_UP)
Макрос FT_BOOT_UP находится в
Docs/TSLib/Include/FT/812 Macro.inc:
FT_BOOT_UP macro
FT_SEND_COMMAND FT_CMD_PWRDOWN_ ; #50 — power-down
FT_DELAY 3
FT_SEND_COMMAND FT_CMD_CLKEXT ; #44 — внешний clock
FT_DELAY 3
LD B, FT_CMD_CLKSEL ; #61 — clock select
LD C, #C0 ; PLL range / MUL
CALL FT.SendCommand.Param
FT_SEND_COMMAND FT_CMD_ACTIVE ; #00 — активировать
FT_DELAY 15
.WaitIntReady FT_RD_REG8 FT_REG_ID ; ждать REG_ID == 0x7C
CP #7C
JR NZ, .WaitIntReady
.WaitCPU_Reset FT_RD_REG8 FT_REG_CPURESET ; ждать REG_CPURESET == 0
OR A
JR NZ, .WaitCPU_Reset
FT_WR_REG8 FT_REG_PCLK, 0 ; PCLK выкл — тайминги настраиваются "тихо"
FT_WR_REG16 FT_REG_HCYCLE, 0x224 ; default тайминги
FT_WR_REG16 FT_REG_HOFFSET, 0x02B
; ... HSYNC0/1, VCYCLE/OFFSET/VSYNC0/1, SWIZZLE, PCLK_POL ...
FT_WR_REG16 FT_REG_HSIZE, 0x1E0
FT_WR_REG16 FT_REG_VSIZE, 0x110
FT_WR_REG16 FT_REG_CSPREAD, 0x001
FT_WR_REG16 FT_REG_DITHER, 0x001
FT_WR_REG16 FT_REG_OUTBITS, 0x000
FT_WR_REG16 FT_REG_GPIOX_DIR, 0xFFFF ; все GPIOX выходы
FT_WR_REG16 FT_REG_GPIOX, 0xFFFF ; включить DISP
FT_WR_REG8 FT_REG_PCLK, 2 ; PCLK on (временное значение)
endm
На реальном FT812 это не формальность. После ACTIVE чип поднимает oscillator/PLL,
делает внутреннюю проверку RAM и только потом начинает корректно отвечать как EVE.
По даташиту boot-up может занимать до 300 ms, поэтому нельзя заменить ожидание
на короткий busy-loop и сразу писать регистры. Правильный критерий готовности —
не «прошло немного времени», а REG_ID == #7C и затем REG_CPURESET == 0, как
показано выше. Если начать запись раньше, на эмуляторе это может случайно пройти,
а на реальной плате FT812 выглядит как «не включился»: SPI отвечает мусором или
часть ранних записей пропадает.
REG_PCLK=2 в конце FT_BOOT_UP — временное значение: оно лишь включает
развёртку с default-таймингами, чтобы видеовыход «ожил». Рабочий PCLK для 640×480
задаёт следующий шаг — FT_RESOLUTION (REG_PCLK = F_MUL = 3 или 4). Прежний
комментарий «60/2 = 30 МГц» был прикидкой по даташит-формуле делителя при
условном sys-clock 60 МГц (см. уточнение в §4.1).
После FT_BOOT_UP нужный режим выставляется через FT_RESOLUTION VM_640_480_57Hz.
Финальный шаг — переключить выход TS-Conf на FT812 и отключить обычный gfx:
Video_Setting VID_FT812 | VID_NOGFX ; OUT (0xAF), %00100100
Video_Setting — TSLib-макрос из
Docs/TSLib/Include/Video/Macro.inc.
Флаги VID_FT812 и VID_NOGFX объявлены в
Docs/TSLib/Include/TSConf.inc.
По смыслу это тот же вывод в VCONFIG-порт, который описан в учебнике #2.
5. Display List (DL): список команд рисования FT812
Display List, или DL, — это не готовая растровая картинка и не экранный буфер. Это маленькая
программа для видеочипа FT812: список 32-битных команд «очистить экран»,
«выбрать bitmap», «нарисовать вершину», «закончить кадр». FT812 читает этот
список из RAM_DL во время вывода кадра.
5.1. Структура
DL (Display List) = массив 32-битных команд в RAM_DL
(0x300000..0x301FFF). Размер RAM_DL — 8 КБ, одна команда занимает 4 байта,
поэтому верхний предел — 2048 команд.
Каждая команда — 4 байта little-endian. Последняя команда обязана быть DISPLAY().
После записи DL → REG_DLSWAP=DLSWAP_FRAME → следующий vsync покажет новый кадр.
5.2. Базовые opcodes (минимальный набор для Zuma)
| Команда | Opcode | Формат |
|---|---|---|
DISPLAY() |
0x00 << 24 | конец DL |
BEGIN(prim) |
0x1F << 24 | prim | старт примитива |
END() |
0x21 << 24 | конец примитива |
CLEAR_COLOR_RGB(r,g,b) |
0x02 << 24 | (r<<16) | (g<<8) | b | цвет очистки |
CLEAR(c,s,t) |
0x26 << 24 | (c<<2) | (s<<1) | t | очистка буферов |
COLOR_RGB(r,g,b) |
0x04 << 24 | (r<<16) | (g<<8) | b | цвет рисования |
COLOR_A(a) |
0x10 << 24 | a | альфа |
BLEND_FUNC(src,dst) |
0x0B << 24 | (src<<3) | dst | смешивание |
POINT_SIZE(s) |
0x0D << 24 | s | радиус точки в 1/16 px |
LINE_WIDTH(w) |
0x0E << 24 | w | толщина линии 1/16 |
BITMAP_HANDLE(h) |
0x05 << 24 | h | активный handle (0..31) |
BITMAP_SOURCE(addr) |
0x01 << 24 | addr | источник в RAM_G |
BITMAP_LAYOUT(fmt,stride,h) |
0x07 << 24 | (fmt<<19) | (stride<<9) | h | формат + stride |
BITMAP_SIZE(filter,wrx,wry,w,h) |
0x08 << 24 | (filter<<20) | (wrx<<19) | (wry<<18) | (w<<9) | h | визуальный размер |
CELL(c) |
0x06 << 24 | c | номер cell в атласе |
VERTEX2II(x,y,h,c) |
0x80000000 | (x<<21) | (y<<12) | (h<<7) | c | вершина integer + handle + cell |
VERTEX2F(x,y) |
0x40000000 | (sx<<15) | sy | subpixel вершина (1/16) |
SCISSOR_XY(x,y) |
0x1B << 24 | (x<<11) | y | clip origin |
SCISSOR_SIZE(w,h) |
0x1C << 24 | (w<<12) | h | clip size |
SAVE_CONTEXT() |
0x22 << 24 | стек контекста push |
RESTORE_CONTEXT() |
0x23 << 24 | pop |
prim для BEGIN:
- 1 BITMAPS, 2 POINTS, 3 LINES, 4 LINE_STRIP, 5 EDGE_STRIP_R/L/A/B (6,7,8,9), 9 RECTS
5.3. Минимальный «Hello World» DL
Заливаем экран синим цветом:
0x02_00_00_64 ; CLEAR_COLOR_RGB(0,0,100) [синий]
0x26_00_00_07 ; CLEAR(1,1,1) [color, stencil, tag]
0x00_00_00_00 ; DISPLAY()
Записать 12 байт в 0x300000, затем REG_DLSWAP = 2 → синий экран на следующем vsync.
5.4. Цепочка спрайтов из атласа
Для рендера 240 шаров Zuma из атласа (handle 0, 6 cells × 40×40):
SAVE_CONTEXT()
BITMAP_HANDLE(0)
BEGIN(BITMAPS)
; per-ball loop:
; COLOR_RGB(255,255,255) ; без тинта
; VERTEX2II(x, y, 0, color) ; одна 32-bit команда на шар
END()
RESTORE_CONTEXT()
DISPLAY()
Для 240 шаров = ~244 32-bit команды = ~976 байт DL (вмещается в 8KB).
5.5. Копроцессор FT812 (RAM_CMD) — для удобства
Командный буфер 0x308000+ — кольцевой, его читает копроцессор FT812. Команды:
cmd_dlstart— открыть новый DLcmd_swap— REG_DLSWAPcmd_loadimage— JPEG/PNG → RAM_Gcmd_text,cmd_number— рендер текста (DL команды генерируются автоматически)cmd_rotate,cmd_translate,cmd_scale,cmd_setmatrix— матричные трансформации
Запись в RAM_CMD управляется парой REG_CMD_WRITE (наша запись) и REG_CMD_READ (читает FT812).
Wrapping — 4KB. После записи → REG_CMD_WRITE = новый offset. Ждать пока FT812 не прочитает: REG_CMD_READ == REG_CMD_WRITE.
6. Bitmap-форматы для FT812
| Формат | Бит/пиксель | Описание | Применение |
|---|---|---|---|
| ARGB1555 | 16 | 5R 5G 5B 1A | Спрайты с 1-bit маской |
| L1 | 1 | Чёрно-белый | Биткарты |
| L2 | 2 | 4 уровня серого | Полупрозрачные текстуры (есть нюанс — см. Bowman 15.2) |
| L4 | 4 | 16 серых | Шрифты |
| L8 | 8 | 256 серых | Маски |
| RGB332 | 8 | 3R 3G 2B | Фон без точности |
| ARGB2 | 8 | 2A 2R 2G 2B | Лёгкая прозрачность |
| ARGB4 | 16 | 4 на канал | Полупрозрачные спрайты |
| RGB565 | 16 | Стандарт без альфы | Backgrounds, спрайты без прозрачности |
| PALETTED4/8/565 | 4/8/8 | Через CRAM | Наши шары/фон с экономией памяти |
| BARGRAPH | spec. | Гистограммы | UI |
Для Zuma 640×480 рекомендации:
- Background: PALETTED8 (256 цветов) → 640×480 = 307KB; либо RGB565 → 614KB.
- Шары 6 цветов × 40×40: PALETTED8 → 9.6KB атлас; либо RGB565 → 19.2KB. Маска 1-bit для прозрачности отдельным L1-bitmap’ом.
- Жаба 128×128: ARGB4 (есть альфа) → 32KB.
- Курсор 32×32: ARGB1555 → 2KB.
- Killzone 64×64 (1 frame): ARGB4 → 8KB.
Итого ~360 KB из 1024 KB RAM_G — есть запас на анимации.
7. Производительность и DMA
7.1. Оценка bandwidth Z80 → FT812 через SPI
- В проекте включён режим
SYS_ZCLK14: Z80 работает на 14 МГц. -
OTIR= 21 такт/байт. Для длинного блока это14 МГц / 21= ~666 КБ/с полезных данных без учёта 3-байтного адресного заголовка SPI иFT_ON/FT_OFF. -
Если Z80 работает на 7 МГц, оценка вдвое ниже: ~333 КБ/с.
- Через DMA TS-Conf (если задействована) — выше, до ~1 MB/s.
7.2. Полный кадр при 60 fps
- Frame budget: 16.7 ms
-
DL обновление 240 шаров: ~1 KB → примерно 1.5 ms через длинный
OTIR-блок на 14 МГц, плюс накладные расходы кадра. -
Background не обновляется каждый кадр (статичен в RAM_G после init)
7.3. NO_GFX=1 экономит DMA
С NO_GFX=1 лимит DMA на строку (448 циклов) полностью доступен для FT812-передач,
а не делится с TS-Config рендером. Это критично для частых обновлений RAM_G (анимация фона).
9. Главный цикл рендера (Hello World pattern из TSLib)
Docs/TSLib/Examples/2.HelloWorld/Core/MainLoop.asm
— образцовая структура кадра:
.Loop FT_CMD_Start ; начать собирать команды в буфер RAM Z80
FT_DL_Start ; команда DLSTART для копроцессора FT812
FT_ClearColorRGB32 0x000000 ; чёрный фон
FT_ClearAll ; clear color + stencil + tag
CALL ShowText ; <- наш контент
CALL Fizz
FT_Display ; конец DL
FT_CMD_Write ; залить буфер из RAM Z80 в RAM_CMD FT812
FT_WR_REG8 FT_REG_DLSWAP, FT_DLSWAP_FRAME ; запросить swap
.WaitIntSwap FT_RD_REG8 FT_REG_INT_FLAGS ; дождаться SWAP interrupt
AND FT_INT_SWAP
JR Z, .WaitIntSwap
FT_DELAY 2
JP .Loop
9.1. Что делает FT_CMD_Start/FT_CMD_Write
FT_CMD_Start — устанавливает Z80-указатель FT.Coprocessor.BufferPtr в начало
локального буфера CMD_ADDRESS_PTR в RAM Z80 (см.
Docs/TSLib/Include/FT/Coprocessor/BufferMacro.inc).
Все последующие FT_CMD_BUF/FT_ClearAll/FT_Begin/FT_Vertex2ii — это запись 4-байтных
команд в этот локальный буфер (через LD (HL), E : INC HL × 4).
FT_CMD_Write — считает длину буфера (текущий ptr − начало) и блочно отправляет всё
в RAM_CMD FT812 через FT.Coprocessor.Write (вызывает FT_Write/OTIR-цикл).
Это ключевой паттерн: DL собирается в RAM Z80, затем одной транзакцией уходит в FT812. Гораздо эффективнее чем командировать FT812 по одной команде за раз.
9.2. FT_DLSWAP_FRAME / FT_INT_SWAP
После записи команд REG_DLSWAP = FT_DLSWAP_FRAME (=2) запрашивает swap в начале
ближайшего vsync. REG_INT_FLAGS бит FT_INT_SWAP поднимается когда swap состоялся —
это сигнал «можно начинать новый кадр». Без ожидания будут «глитчи»: писать в DL пока
движок FT812 ещё рендерит — неопределённое состояние.
FT_INT_MASK и FT_INT_EN нужно настроить в init (Hello World делает: FT_REG_INT_MASK = FT_INT_SWAP, FT_REG_INT_EN = 1).
10. TSLib API — карта макросов
10.1. Низкий уровень:
Docs/TSLib/Include/FT/812 Macro.inc
| Макрос | Описание |
|---|---|
FT_ON / FT_OFF |
CS управление (= OUT 0x77) |
FT_VMODE |
OUT (VCONFIG), VID_FT812 |
FT_ACTIVE |
host command #00 → выйти из standby |
FT_BOOT_UP |
полная init-последовательность (см. §4.5) |
FT_CMD_RESET |
сброс копроцессора (CMD_READ/WRITE = 0) |
FT_SEND_COMMAND |
host command (3 байта) |
FT_DELAY Count? |
NOP-задержка |
FT_RD_REG8 / FT_RD_REG16 / FT_RD_REG32 |
чтение регистра |
FT_WR_REG8 / FT_WR_REG16 / FT_WR_REG32 |
запись регистра |
FT_RESOLUTION VM_*, RefPtr |
переключение видеорежима |
10.2. Прямой Display List в RAM_DL:
Docs/TSLib/Include/FT/DL Macro.inc
Каждый макрос разворачивается в DEFD <opcode> (4 байта в текущем месте сборки).
Используется когда DL зашит в постоянную область (например, статическая графика
уровня), не строится каждый кадр.
| Макрос | Opcode | Назначение |
|---|---|---|
FT_DISPLAY |
0x00 | Конец DL (обязателен) |
FT_BITMAP_SOURCE Address? |
0x01 | Источник в RAM_G |
FT_CLEAR_COLOR_RGB R,G,B |
0x02 | Цвет очистки |
FT_TAG |
0x03 | Тег для touch |
FT_COLOR_RGB R,G,B |
0x04 | Цвет рисования |
FT_BITMAP_HANDLE H |
0x05 | Активный handle (0..31) |
FT_CELL c |
0x06 | Cell в атласе |
FT_BITMAP_LAYOUT fmt,stride,h |
0x07 | Формат + stride |
FT_BITMAP_SIZE filter,wx,wy,w,h |
0x08 | Размер для рендера |
FT_ALPHA_FUNC |
0x09 | Альфа-тест |
FT_STENCIL_FUNC |
0x0A | Stencil-тест |
FT_BLEND_FUNC src,dst |
0x0B | Смешивание |
FT_POINT_SIZE s |
0x0D | Радиус точки 1/16 |
FT_LINE_WIDTH w |
0x0E | Толщина линии |
FT_COLOR_A a |
0x10 | Альфа |
FT_BITMAP_TRANSFORM_A..F |
0x15-1A | Матрица 2D трансформации |
FT_SCISSOR_XY x,y |
0x1B | Clip origin |
FT_SCISSOR_SIZE w,h |
0x1C | Clip size |
FT_BEGIN prim |
0x1F | Старт примитива |
FT_END |
0x21 | Конец примитива |
FT_SAVE_CONTEXT |
0x22 | Push контекст |
FT_RESTORE_CONTEXT |
0x23 | Pop контекст |
prim для BEGIN: 1=BITMAPS, 2=POINTS, 3=LINES, 4=LINE_STRIP, 5/6/7/8=EDGE_STRIP_*, 9=RECTS.
10.3. Сборка DL через копроцессор:
Docs/TSLib/Include/FT/Coprocessor/BufferMacro.inc
Те же команды, но макросы пишут не DEFD, а FT_CMD_BUF (накапливают в RAM Z80
для последующего FT_CMD_Write). Используется в MainLoop каждый кадр.
| Макрос | Что делает |
|---|---|
FT_CMD_Start |
Сбросить указатель Z80-буфера |
FT_DL_Start |
Команда CMD_DLSTART (открыть новый DL) |
FT_ClearColorRGB32 RGB? |
Цвет очистки 0xRRGGBB |
FT_ClearAll |
Clear all (color + stencil + tag) |
FT_Clear C,S,T |
Selective clear |
FT_Begin prim / FT_End |
Примитивы |
FT_Vertex2f X,Y |
Вершина float (1/16 px) |
FT_Vertex2ii X,Y,H,C |
Вершина integer + handle + cell в одной команде |
FT_PointSize s |
Радиус точки |
FT_LineWidth w |
Толщина линии |
FT_ColorRGB / FT_ColorRGB32 |
Цвет |
FT_ColorA a |
Альфа |
FT_BitmapHandle H |
Активный handle |
FT_BitmapSource addr |
Указать на bitmap в RAM_G |
FT_BitmapLayout fmt,stride,h |
Формат |
FT_BitmapSize filter,wx,wy,w,h |
Размер |
FT_Cell c |
Cell в атласе |
FT_BlendFunc src,dst |
Смешивание |
FT_ScissorXY / FT_ScissorSize |
Clip |
FT_SaveContext / FT_RestoreContext |
Стек контекста |
FT_Tag t / FT_TagMask |
Touch теги |
FT_VertexFormat frac |
Точность Vertex2f (бит 0..7 = 1/2..1/256 px) |
FT_VertexTranslateX/Y |
Смещение всех последующих Vertex |
FT_PaletteSource |
Палитра PALETTED-форматов |
FT_FGColor / FT_BGColor / FT_GRADColor |
Цвета для widgets |
FT_Text X,Y,Font,Opt |
Текст |
FT_String addr,len |
Строка для FT_Text |
FT_Gradient x1,y1,rgb1,x2,y2,rgb2 |
Градиент |
FT_Display |
Конец DL |
FT_CMD_Swap |
CMD_SWAP (через копроцессор) |
FT_CMD_Interrupt ms |
CMD_INTERRUPT |
10.4. Функции TSLib FT.Coprocessor.*
Реализация:
Docs/TSLib/Include/FT/Coprocessor/Buffer.asm
и
Docs/TSLib/Include/FT/Coprocessor/Cmd.asm.
Дополнительные runtime-функции:
FT.Coprocessor.PointSize—LD DE, size→ пишет POINT_SIZE в буферFT.Coprocessor.ColorRGB—LD C, R : LD D, G : LD E, BFT.Coprocessor.ColorA—LD E, AFT.Coprocessor.Vertex2f—LD HL, X : LD DE, Y(subpixel)FT.Coprocessor.WaitFlush— ждать пока FT прочитает RAM_CMDFT.Coprocessor.GetPtr— получить текущий REG_CMD_DL (для return-адресов в DL)FT.Coprocessor.IsFault— проверка ошибки копроцессораFT.Coprocessor.Inflate— распаковать deflate-blob в RAM_G
10.5. Прочее
Docs/TSLib/Include/Cache/Macro.inc—Cache_Setting EN_0000 | EN_4000 | EN_8000— TS-Conf cacheDocs/TSLib/Include/DMA/Macro.inc— TS-Conf DMA helpersDocs/TSLib/Include/Input/Kempston/Mouse— мышь KempstonDocs/TSLib/Include/Math— fixed-point/F16 математика
10.6. Готовый Init_Video для Zuma VDAC2
Реализовано в
Source/ASM/Init_Video.asm.
Собирается под sjasmplus (--syntax=ab) с TSLib.
Зависимости (порядок важен):
DEVICE ZXSPECTRUM4096
define MAPPING_REGISTERS ; Video_Setting через FMADDR
include "Docs/TSLib/Include/TSConf.inc"
include "Docs/TSLib/Include/Video/Macro.inc"
include "Docs/TSLib/Include/FT/81x Const.inc"
include "Docs/TSLib/Include/FT/DL Macro.inc"
include "Docs/TSLib/Include/FT/812 Macro.inc"
module FT
include "Docs/TSLib/Include/FT/812 Func.asm"
endmodule
ResolutionWidthPtr EQU #40F3 ; Z80-RAM ячейки (FT_RESOLUTION пишет туда W/H)
ResolutionHeightPtr EQU #40F5
include "Init_Video.asm"
Сама логика (см. файл):
- Sanity-check VDAC2:
IN A,(STATUS) : AND %111 : CP %111— если бит-маска ≠ 111, возврат с Z=0 (нет VDAC2 на плате). FT_BOOT_UP— полная init FT812: PWRDOWN→CLKEXT→CLKSEL #C0→ACTIVE, ждать REG_ID=0x7C, default тайминги, GPIOX=0xFFFF, REG_PCLK=2 → видеовыход активирован.FT_CMD_RESET— обнулить REG_CMD_READ/WRITE (на случай висящих команд).FT_RESOLUTION VM_640_480_57Hz, ResolutionWidthPtr— переключить тайминги: PCLK=24 МГц (F_MUL=3), HCYCLE 800, VCYCLE 524, HSIZE/VSIZE 640×480.- Залить пустой DL (12 байт =
CLEAR_COLOR_RGB(0,0,0); CLEAR(1,1,1); DISPLAY()) в RAM_DL черезFT.WriteDL, потомREG_DLSWAP=2— чёрный экран до первого MainLoop-кадра. REG_INT_MASK = FT_INT_SWAP, REG_INT_EN = 1— разрешить swap-interrupt для синхронизации MainLoop’а.Video_Setting VID_FT812 | VID_NOGFX=OUT (0xAF), %00100100— переключить TS-Conf выход на FT812 + отключить TS-Config gfx (освобождает 448 DMA-циклов/строку).
Возврат: A=0/Z=1 на успех, A=1/Z=0 если VDAC2 не обнаружен (caller выбирает fallback).
После Init_Video можно входить в MainLoop с FT_CMD_Start/FT_CMD_Write/DLSWAP паттерном (§9).
10.7. Готовый MainLoop для Zuma VDAC2 (каркас)
Реализовано в MainLoop.asm в корне проекта. Собирается без ошибок (см. _test_init_video.asm — там полная цепочка include’ов и Init_Video → MainLoop точка входа).
На текущем этапе MainLoop — proof-of-life каркас: тёмно-синий фон + одна оранжевая точка 16 px радиуса, отскакивающая от краёв 640×480. По мере добавления game-state’а сюда подключатся VDC engine update, цикл по slots[], frog/cursor/score.
Структура одного кадра (6 шагов):
.Loop ; 1. Открываем DL, заливаем общую очистку
FT_CMD_Start
FT_DL_Start
FT_ClearColorRGB32 0x102030
FT_ClearAll
; 2. Контент кадра
CALL ZL_DrawFrame ; PointSize + ColorRGB + Begin POINTS + Vertex2f + End
; 3. Закрытие DL и заливка в FT812
FT_Display
FT_CMD_Write ; OTIR-блок RAM Z80 → RAM_CMD FT812
; 4. Запросить swap при следующем vsync
FT_WR_REG8 FT_REG_DLSWAP, FT_DLSWAP_FRAME
; 5. Заблокироваться до swap-interrupt'а
.WaitIntSwap FT_RD_REG8 FT_REG_INT_FLAGS
AND FT_INT_SWAP
JR Z, .WaitIntSwap
; 6. Update game state (между swap'ом и следующим DL)
CALL ZL_UpdateGame
JP .Loop
Важно про порядок в одном кадре:
FT_CMD_Startсбрасывает указатель локального буфера в RAM Z80 (CMD_ADDRESS_PTR, по умолчанию#C000).- Все макросы группы
FT_*изBufferMacro.incтолько пишут в этот буфер — пока не вызванFT_CMD_Write, ничего на FT812 не уходит. FT_CMD_Write— одна OTIR-транзакция вREG_CMDB_WRITE(FT_RAM_CMD). Эффективнее команд по одной.REG_DLSWAP=FT_DLSWAP_FRAMEзапрашивает swap. Без ожиданияFT_INT_SWAPследующий DL может начать строиться поверх ещё рендерящегося → артефакты.UpdateпослеWaitIntSwap— пока движок FT812 отрисовывает только что засвопленный кадр, Z80 свободен для физики. Это естественная двойная буферизация: кадр N+1 готовится пока кадр N показывается.
Точка состояния (ZL_PointX/ZL_PointY etc.) хранится в коде через DEFW 0 — после загрузки .bin это валидные ячейки, MainLoop при первом входе явно их инициализирует на (SCR_W/2, SCR_H/2) и скорость (3, 2) px/frame в 1/16-формате (VertexFormat=4, по умолчанию).
ZL_DrawFrame использует runtime-функции FT.Coprocessor.ColorRGB/PointSize/Vertex2f (из Coprocessor/Buffer.asm) — они принимают значения в регистрах (BC/DE), а не immediate, что нужно для динамической позиции.
ZL_UpdateGame — bouncing: X += VelX, если X >= MAX_X или X < MIN_X → clamp + VelX = -VelX через мини-helper ZL_NegateW. То же по Y.
Глава 12. Bitmap rendering — matrix transform, scale, paletted formats (опыт 2026-05-09)
12.1 Главный урок: BITMAP_TRANSFORM работает на bitmap UV, не на screen position
В FT81x матрица BITMAP_TRANSFORM_A..F (set через cmd_setmatrix после cmd_loadidentity + операций) трансформирует bitmap UV-coordinates (= какой пиксель bitmap читать), не screen position.
Render-formula: pixel at screen (vertex_pos.x + u, vertex_pos.y + v) reads bitmap at M * (u, v).
Из этого следует:
cmd_translate(X, Y)сдвигает источник читаемых пикселей. Для UV outside bitmap → BORDER возвращает transparent → sprite невидим.- Screen position спрайта задаётся через
Vertex2f((X-half)*16, (Y-half)*16)(subpixel coords), не через matrix. - Matrix используется только для transformations внутри sprite-rect: rotation вокруг центра, scale, shear.
Pattern для rotated sprite (rotation around center)
Sprite size 56×56, центр (28, 28):
CALL ZL_EmitLoadId
LD HL, 28 : LD DE, 28 : CALL ZL_EmitTranslate ; UV center to origin
LD A, (tangent) : CALL ZL_EmitRotate ; rotate UV around (0,0) which is sprite center
LD HL, -28 : LD DE, -28 : CALL ZL_EmitTranslate ; restore offset
CALL ZL_EmitSetMatrix ; emits BITMAP_TRANSFORM_A..F (6 DL cmds)
FT_BitmapHandle 0 / FT_BitmapSource ...
FT_Begin FT_BITMAPS
LD A, cell : CALL FT.Coprocessor.Cell
LD BC, (X-28)*16 : LD DE, (Y-28)*16 : CALL FT.Coprocessor.Vertex2f
FT_End
Matrix формула: M = T(28,28) * R(angle) * T(-28,-28). Combined rotations (tangent + spin) можно складывать в одну: R(tangent + spin) → один cmd_rotate.
12.2 cmd_scale convention
cmd_scale(sx, sy) где sx, sy — f16.16 fixed-point. scale(N, N) отображает bitmap в N раз больше на экране (= sprite displayed at N× native size), не наоборот. Counter-intuitive потому что matrix transforms UV.
Пример: bg хранится 400×300 в RAM_G, нужно отобразить 640×480. Scale factor = 640/400 = 480/300 = 1.6. cmd_scale(0x1999A, 0x1999A) (= 1.6 in f16.16).
12.3 BITMAP_SIZE при upscale
FT_BitmapSize filter, wrap_x, wrap_y, screen_width, screen_height определяет output area on screen (clipping bounds). При upscale указываем целевой размер 640×480, не native размер bitmap.
FT_BitmapLayout format, linestride_bytes, native_height определяет storage в RAM_G — linestride = native_width × bpp, height = native_height (= 300 для 400×300 RGB565).
Filter FT_BILINEAR (vs FT_NEAREST) даёт smooth interpolation между native pixels при upscale — обязательно для качественного render scaled bitmap.
12.4. Бюджет памяти для background (1 МБ RAM_G FT812)
Расчёт ниже — для фона 640×480. В колонке «RAM_G» указаны реальные байты
картинки/слоёв; если грузить данные полными 16-КБ страницами spgbld, в RAM_G
надо дополнительно держать выравнивающий хвост до следующей страницы.
| Вариант | RAM_G | 16-КБ страниц | Качество | Комментарий |
|---|---|---|---|---|
| RGB565 full 640×480 | 614 400 Б | 38 | высокое | 2 байта/пиксель, без альфы |
| ARGB4 full 640×480 | 614 400 Б | 38 | высокое | 2 байта/пиксель, 4 бита на A/R/G/B; для непрозрачного bg альфа не нужна |
| RGB565 400×300 + scale 1.6 | 240 000 Б | 15 | хорошее | ранний компромисс: меньше RAM_G, но апскейл заметен |
| ARGB4 400×300 + scale 1.6 | 240 000 Б | 15 | хорошее | тот же объём, что RGB565; нужен только если bg реально использует альфу |
| RGB565 320×240 + scale 2.0 | 153 600 Б | 10 | среднее | экономно, но теряется детализация |
| RGB332 640×480 | 307 200 Б | 19 | плохое | 1 байт/пиксель, грубая палитра 3-3-2 |
| L8 640×480 | 307 200 Б | 19 | grayscale only | диагностика/маска, не цветной фон |
| L4 640×480 | 153 600 Б | 10 | grayscale only | 16 уровней, для масок/шрифтов, не цветной фон |
| L2 640×480 | 76 800 Б | 5 | grayscale only | 4 уровня, для масок; не цветной фон |
| PALETTED8 640×480 | 308 224 Б | 19+palette | хорошее, если работает | 1 байт index + 1024 Б ARGB8 palette; на Unreal давал серый фон |
| PALETTED4444 640×480 | 307 712 Б | 19+palette | хорошее, если хватает 256 цветов | 1 байт index + 512 Б ARGB4 palette; это не 4 bpp |
| PALETTED4444 400×300 + scale 1.6 | 120 512 Б | 8+palette | хорошее для текущего bg | текущая практичная ветка: 400×300 indices + 512 Б palette |
| pseudo-DXT L2 640×480 | 153 600 Б | 10 | приемлемо, блочность 4×4 | c0 RGB565 160×120 + c1 RGB565 160×120 + L2 mask 640×480 |
| pseudo-DXT L4 640×480 | 230 400 Б | 15 | почти фото | c0 RGB565 160×120 + c1 RGB565 160×120 + L4 mask 640×480 |
Важно про pseudo-DXT. Это не аппаратный формат FT812 и не распаковка в RAM_G.
Мы храним три обычных bitmap-слоя: две цветовые плоскости c0/c1 в RGB565 с
размером 160×120 и полноэкранную маску 640×480. Вариант L2 использует
2-битную маску (4 уровня смешивания), вариант L4 — 4-битную маску
(16 уровней смешивания). Рендер идёт несколькими проходами через blend.
Unreal эмулятор НЕ реализует часть palette-formats — серый фон при попытке. На реальном железе ZX-Evo+FT812 PALETTED должен работать по стандарту FT81x, но для проекта всё равно нужна проверка на реальном VDAC2.
12.5 Asymmetric downscale (X≠Y)
Можно хранить bg с разными scale по осям. Пример: 480×240 RGB565 (X=0.75×, Y=0.5×) = 230 KB. cmd_scale(640/480, 480/240) = cmd_scale(1.33×, 2×). Полезно если detail неравномерно: больше горизонтально (Y blur приемлем) или вертикально.
Для типичных Zuma backgrounds (rotational symmetry — спираль, swirley) — detail изотропен, симметричный downscale (320×240, 400×300) лучше.
12.6. Почему нули в конце spgbld-страницы могут затереть соседний ресурс
spgbld раскладывает данные по страницам TS-Config. Одна страница = 16 384
байта. Если файл занимает не всю страницу, оставшийся хвост страницы заполняется
нулями. Это обычное выравнивание страницы.
Проблема появляется на этапе загрузки в RAM_G FT812. Если загрузчик каждый раз
копирует всю 16-КБ страницу через FT.WriteMem 16384, он отправляет в RAM_G
не только реальные байты файла, но и эти нули в конце последней страницы. Нули
пишутся сразу после полезных данных.
Если следующий ресурс в RAM_G лежит вплотную, хвост из нулей может стереть его начало.
Пример:
RAM_G:
#010000..#04A7FF background, реальные данные
#04A800..#04FFFF нули из хвоста последней spgbld-страницы
#04A800..#05FFFF следующий ресурс, если положить его вплотную
В таком layout загрузка background’а сотрёт начало следующего ресурса.
Рабочие варианты защиты:
-
Грузить нижний по адресу ресурс первым, а следующий ресурс грузить после него поверх нулевого хвоста.
-
Оставлять между ресурсами зазор не меньше одной страницы, если загрузчик всегда копирует по 16 КБ.
-
Для последней страницы передавать в
FT.WriteMemне 16 384 байта, а реальный остаток файла:real_size mod 16384.
См. также §12.8.2: из-за такого нулевого хвоста background затирал начало atlas’а шаров.
12.7. Сжатие PNG/JPEG: экономит файл, но не RAM_G
FT812 не рисует фон прямо из JPEG/PNG. Перед отрисовкой картинка всё равно должна
лежать в RAM_G уже распакованной, почти как обычный BMP: пиксели подряд в одном
из форматов FT812 (RGB565, ARGB4, PALETTED4444, L2, L4 и т.д.).
Поэтому маленький JPEG помогает только до момента загрузки:
- в
.SPGили.PAKфайл кладём маленький JPEG/PNG/zlib-поток; - при загрузке
cmd_loadimageилиcmd_inflateраспаковывает его вRAM_G; - после распаковки фон занимает в
RAM_Gполный размер выбранного bitmap-формата.
Пример: JPEG 640×480 может занимать на диске 80 КБ, но после cmd_loadimage в
RGB565 он займёт в RAM_G 614 400 байт. Сжатие уменьшает размер файла и
объём чтения с SD, но не уменьшает занятый объём RAM_G после распаковки.
12.8 Финальный выбор для Zuma VDAC2 (level 1 spiral)
make_bg_level01.py: source levels/level_src_<NN>.png (clean 640×480) → resize 400×300 LANCZOS → RGB565 LE.
MainLoop.asm ZL_DrawFrame: cmd_loadidentity + cmd_scale(0x1999A, 0x1999A) + cmd_setmatrix + bg setup + Begin/Vertex2ii(0,0,1,0)/End.
Memory: 240 KB bg + ~310 KB atlas + freedom для дальнейших assets (frog, score, particles).
12.8.1 Полный pipeline компрессии bg (нюансы практики)
Workflow make_bg_level01.py → spg → RAM_G:
- Source PNG —
levels/level_src_<NN>.png(clean 640×480). НЕ jpeg оригинал, потому что jpg-artifacts усиливаются после downscale + bilinear upscale в FT812. - Downscale 640×480 → 400×300 (LANCZOS) на Z80-стороне через Python. Важно — LANCZOS, не BICUBIC: на резких границах spirale Zuma BICUBIC ringing artifacts.
- RGB565 LE pack — каждый пиксель 2 байта
((g>>2 & 7)<<13) | (b>>3) | ...little-endian. На FT812 LE — нативный порядок. - Запись в
.binфайл размером 240 000 байт. -
Сборка страниц через spgbld —
Block = #0000, #07..#15, bg_level01_pNN.bin(15 страниц × 16 384 = 245 760 байт; на последней странице 5 760 байт нулевого хвоста). -
Z80 upload-loop в
Initialize:ставит page в slot 2, копирует черезFT.WriteMem16384 байт за раз в RAM_G начиная сBG_RAMG_ADDR=#010000. - DL render в
ZL_DrawFrame:loadidentity+cmd_scale(0x1999A, 0x1999A)+setmatrix+BITMAP_LAYOUT FT_RGB565, ZL_BG_W*2, ZL_BG_H(stride 800 байт, height 300) +BITMAP_SIZE FT_BILINEAR, BORDER, BORDER, 640, 480+Begin BITMAPS / Vertex2ii(0,0,1,0) / End.
12.8.2. Ретро-баг: нулевой хвост background затирал atlas шаров
Эта история относится к §12.6. Хронология:
-
Background первый раз грузился после atlas’а. Atlas лежал в
#050000..#0A6000(302 КБ, старая версия 6×8 frames), background — в#010000..#04A800(240 КБ). -
Последняя spgbld-страница background’а имела нулевой хвост. При копировании полных 16-КБ страниц эти нули записывались дальше реального background’а и доходили до
#0A8000. -
В результате нули затирали первые 8 КБ atlas’а (
#0A6000..#0A8000). Это были последние несколько cells, они рендерились пустыми, и цепочка шаров мерцала. -
Fix: background грузится первым, atlas — вторым. Atlas pages записывают свежие данные поверх нулевого хвоста background’а, поэтому atlas остаётся целым.
Универсальное правило для FT812-проектов: порядок upload pages = обратный к RAM_G layout (старший адрес последним), либо gap ≥16 KB между блоками.
Глава 13. Frog composition: HD-стиль pipeline (опыт 2026-05-09/10)
Композиция лягушки в Zuma-Deluxe (HD-версия github.com/GalaxyShad/Zuma-Deluxe-HD) — multi-sprite c rotation matrix. В VDAC2 реализуется через FT812 multiple BITMAP_HANDLE + matrix manipulation.
13.1 Источники подспрайтов в frog.png
frog.png (324×648 RGBA) — sprite-sheet. Координаты 1:1 из Zuma-Deluxe-HD/src/zuma/ResourceStore.c:
| Sprite | crop (X,Y,W,H) | Назначение |
|---|---|---|
SPR_FROG |
(0, 0, 162, 162) | body (frog с открытым ртом) |
SPR_FROG_TONGUE |
(162, 0, 162, 162) | язык (накладывается над body) |
SPR_FROG_PLATE |
(162, 162, 162, 162) | круглый диск-подставка |
ANIM_FROG_BLINK[0..2] |
(0, 162N, 162, 162) | моргание (frames N=1,2,3) |
ANIM_FROG_BALLS ×6 |
(234, 633, 15, 15) горизонтальный strip | индикаторы цвета next-ball |
Resize 162→122 (LANCZOS) даёт scale ≈ 0.753. Соотношение body/ball = 122/40 ≈ 3.05 (HD соотношение 162/48 = 3.375; -10% — компромисс под 640×480).
13.2 Render pipeline (Frog_Draw порядок)
Из HD Frog.c:
Frog_Draw:
DrawSprite(plate) // no rotation
DrawSetAngle(angle - π/2)
DrawSprite(body) // rotated
DrawSprite(tongue, pos + tongueExpand·dir) // rotated
Frog_DrawTop:
DrawSprite(currentBall, pos + ballExpand·dir) // rotated
DrawSetScale(1.5)
DrawSprite(nextBallIndicator, pos - 40·dir) // rotated
DrawSprite(blinkAnim, pos) // rotated
VDAC2 эквивалент в Frog.asm:
Frog_DrawPlate— handle 4, no matrix (обнулять matrix не нужно если предыдущий блок identity).Frog_DrawBody— handle 2, matrixT(61,61) · R(angle-64) · T(-61,-61)(где 61 = sprite_W/2, -64 = -π/2 для native face=south).Frog_DrawTongue— handle 5, та же matrix как body + offset Vertex2f наtongueExpand·dir. (на текущем этапе отключён до реализации recoil).
13.3 Rotation matrix pattern (см. также §12.1)
Frog_DrawBody:
CALL ZL_EmitLoadId
LD HL, 61 : LD DE, 61 : CALL ZL_EmitTranslate ; T(+61,+61)
LD A, (Frog_Angle) : CALL ZL_EmitRotate ; R(angle-64), -64 встроен в EmitRotate
LD HL, -61 & 0xFFFF : LD DE, -61 & 0xFFFF
CALL ZL_EmitTranslate ; T(-61,-61)
CALL ZL_EmitSetMatrix ; emit BITMAP_TRANSFORM_A..F
FT_BitmapHandle 2
FT_BitmapSource FROG_RAMG_ADDR
FT_BitmapLayout FT_ARGB4, 122*2, 122
FT_BitmapSize FT_BILINEAR, FT_BORDER, FT_BORDER, 122, 122
FT_Begin FT_BITMAPS
LD BC, FROG_VTX_X : LD DE, FROG_VTX_Y : CALL FT.Coprocessor.Vertex2f
FT_End
; reset → identity для последующих ops в DL
CALL ZL_EmitLoadId : CALL ZL_EmitSetMatrix
RET
FROG_VTX_X = FROG_X*16 - 61*16 (subpixel top-left). Frog_Angle — raw BRAD 0..255 (0=east, 64=south, 128=west, 192=north). ZL_EmitRotate сам делает ADD A, 192 (= -64) для коррекции native face direction.
Ключевой урок: matrix НЕ задаёт screen position (это делает Vertex2f), matrix трансформирует UV-чтение внутри bitmap-rect. T(+61,+61) переносит UV-origin в центр sprite, R(angle) вращает UV вокруг этого origin, T(-61,-61) возвращает; результат — rotated bitmap внутри своего фиксированного screen-rect.
13.4 Atan2 от курсора → angle
Источник алгоритма — TS-Conf версия ComputeFrogAngle, перенесённая в
Source/ASM/Frog.asm.
Алгоритм:
dx = SmoothMouseX - FrogX,dy = SmoothMouseY - FrogY(16-bit signed).- Флаги октанта (3 бита):
b0=dx<0,b1=dy<0,b2=swap(если|dy|>|dx|). - |dx|, |dy| через CPL+INC. Swap так чтобы C = max, E = min.
- t = E*128 / C (16-bit/8-bit deление). 128, не 32 — даёт 4× разрешение и плавность на диагоналях.
- Atan LUT[129]:
atan(i/128) × 256/(2π), i=0..128, выход 0..32 BRAD. - Mirror at 90° если был swap:
A = 64 - A. - Apply квадрант по флагам dx/dy: Q1 → A, Q2 → 128-A, Q3 → 128+A, Q4 → 256-A.
Возвращает BRAD 0..255: 0=east, 64=south, 128=west, 192=north.
13.5 Hybrid follow для плавного rotation
Прямое присваивание Frog_Angle = computed даёт jitter при mouse-jitter (kempston через Hyper-V — особенно):
- Big diff (≥4 BRAD = >5.6°) → snap
- Small diff (1..3 BRAD) → ±1 BRAD/frame ramp
- Diff = 0 → no-op
- Deadzone:
max(|dx|,|dy|)<5→ не менять (курсор в frog-center)
Subjective результат: при медленном движении мыши лягушка плавно догоняет, при быстром — мгновенно прыгает. То же самое было в TS-Conf версии, проверено годами.
13.6 Tongue bbox (для будущего расчёта tongueExpand)
Native tongue (162×162 region из frog.png):
- bbox непрозрачных пикселей: x=59..103, y=53..132 (45×80)
- centroid (81, 89), sprite center (81, 81)
Это значит native tongue: язык чуть выше центра sprite до низа. После resize 162→122 bbox переходит в y=40..99. Если рендерить tongue в той же position что и body, язык физически выше центра body (до y=40 после resize).
В HD tongueExpand=24 (idle) сдвигает tongue по dir·24 вниз по native-face=south — язык легализуется под подбородком body. На рендере в VDAC2 (где rotation atan2-driven) это значит:
tongueX = FROG_X + (24·cos_lut[Frog_Angle]) >> 4
tongueY = FROG_Y + (24·sin_lut[Frog_Angle]) >> 4
Vertex2f((tongueX-61)*16, (tongueY-61)*16)
- та же matrix что и body. Реализуем когда дойдём до recoil/fire анимации.
Глава 14. RNG: LFSR Galois + bias + RTC-scramble (опыт 2026-05-10)
14.1 LFSR Galois 16-bit
Базовый PRNG, периодом 65535 (на любом non-zero seed):
LFSR16: ; state в HL
LD A, L : AND 1 ; LSB
SRL H : RR L ; HL >>= 1
JR Z, .no_xor
LD D, #B4 : LD E, 0 ; poly 0xB400 (CRC-16-IBM reverse)
LD A, H : XOR D : LD H, A
LD A, L : XOR E : LD L, A
.no_xor:
RET ; HL = новое state
Альтернативные полиномы #D008, #A005 — те же свойства period-65535.
14.2 Bias-ловушка: AND N + clamp на не-степени двойки
Распространённая ошибка для распределения LFSR-output на N значений:
LD A, L
AND 7 ; 0..7
CP 6
JR C, .ok
SUB 6 ; 6→0, 7→1
.ok:
RET ; A в 0..5
Проблема: distribution неравномерное. Для NUM=6:
- Values 0, 1: вероятность 2/8 = 25% каждое
- Values 2..5: вероятность 1/8 = 12.5% каждое
Visible эффект: на экране в 2 раза больше синих и красных шаров (если color 0=blue, 1=red).
14.3 Mul-then-shift: равномерное распределение
LD A, L : XOR H ; смешать обе половины LFSR (8 бит entropy)
LD H, 0 : LD L, A
LD D, 0 : LD E, A
ADD HL, HL ; HL = A*2
ADD HL, DE ; HL = A*3
ADD HL, HL ; HL = A*6 (A * NUM_COLORS=6)
LD A, H ; A = (A*N) >> 8 → 0..N-1
RET
Distribution: 256/N не делится нацело → bias ≤1/N. Для N=6 максимальное отклонение 2 / 256 = 0.78%.
Generic вариант для произвольного N:
LD E, N ; multiplier из RAM
LD HL, 0
LD B, 8
.loop:
ADD HL, HL
SLA A
JR NC, .skip
ADD HL, DE
.skip:
DJNZ .loop
LD A, H ; (A * N) >> 8
RET
14.4 RTC-scramble seed (для разнообразия per launch)
LFSR с фиксированным seed → одна и та же последовательность каждый запуск. Решение — scramble через TS-Conf RTC секунды:
ReadRTCSeconds:
LD BC, #DFF7 : XOR A : OUT (C), A ; reg 0 = seconds
LD BC, #BFF7 : IN A, (C) ; A = BCD seconds
LD B, A
AND $0F : LD C, A ; low nibble
LD A, B : AND $F0
SRL A : SRL A : SRL A : SRL A ; high nibble
LD B, A
ADD A, A : ADD A, A : ADD A, B ; *5
ADD A, A ; *10
ADD A, C ; +low → 0..59 binary
RET
VDC_Init:
LD HL, #ACE1 : LD (VDC_LfsrSeed), HL
CALL ReadRTCSeconds
OR A : JR NZ, .have : LD A, 17 ; защита если RTC=0
.have:
LD D, A : LD E, A ; multiplier
LD HL, (VDC_LfsrSeed) : LD A, L ; A = low_byte(seed)
LD HL, 0 : LD B, 8
.mul: ; HL = low_byte * RTC_sec через 8x mult
ADD HL, HL : SLA A : JR NC, .skip
ADD HL, DE
.skip:
DJNZ .mul
LD A, H : OR L : JR NZ, .ok
LD HL, #1234 ; protection если результат=0
.ok:
LD (VDC_LfsrSeed), HL
RET
Каждая секунда (RTC ticks) = разный multiplier → разное seed → разная LFSR-цепочка цветов в каждом запуске.
Глава 15. Frog с полной HD-композицией (2026-05-10)
15.1 Render order (HD-1:1)
plate (no rotation) — диск под лягушкой
body (rotation matrix) — frog с лапами + face/mouth
tongue (rotation matrix) — язык, position = pos + tongueExpand·dir
ball-now (no rotation) — выстреливаемый шар, position = pos + ballExpand·dir
next-ball (no rotation) — индикатор на спине, position = pos - 28·dir
overlay (rotation matrix) — face без лап (HD blink frame 0), маскирует корни tongue
Все 4 rotated спрайта (body, tongue, overlay) — одного размера 122×122. Это критично для feature alignment: после rotation eyes body и eyes overlay должны совпадать → они должны быть на одинаковых относительных pixel-offsets от sprite centra. Разный размер → разные относительные offsets → “moon-like” дрейф features при rotation.
15.2 RAM_G layout (1 МБ, baseline 2026-05-10)
#010000..#04C000 bg (15 pages, 400×300 RGB565 + scale 1.6 upscale)
#04C000..#04E000 killzone (1 страница, лежит после нулевого хвоста bg)
#050000..#09C000 balls atlas (19 pages — 6 colors × 8 phases × 56×56 ARGB4)
#09C000..#0A4000 body 122×122 ARGB4 (2 pages)
#0A4000..#0AC000 plate 122×122
#0AC000..#0B4000 tongue 122×122
#0B4000..#0BC000 overlay 122×122 (HD blink frame 0)
свободно 272 КБ для будущих assets
Balls atlas сжат с 16 phases до 8 — освободило 18 pages для overlay full-size.
Chain spin formula поменялась: & 7 вместо & 15, cell = color*8 вместо *16.
15.3 Tongue — pos + tongueExpand·dir (HD orbit)
В отличие от tight-cropped sprite (32×80) с pivot (16, 29) — full 122×122 sprite даёт ту же rotation pattern что body:
; Frog_DrawTongue:
; matrix = T(61, 61) · R(angle + 192) · T(-61, -61)
; Vertex2f at (TmpX-61, TmpY-61), screen rect 122×122
; TmpX = PosX + cos·tongueExpand/128
; TmpY = PosY + sin·tongueExpand/128
tongueExpand = 24 idle (HD), 0..24 при выстреле. Tongue native асимметричный (stripe внутри 162×162 native занимает y=53..133), поэтому при rotation вокруг centra (61, 61) tongue tip “выходит” из mouth area body.
15.4 Ball-now / Next-ball через chain atlas (handle 0)
Используется тот же atlas что и chain rendering. Cell = color*8 + 0 (frame 0, не вращается). Native размер 56×56, рендерится без cmd_scale.
; Frog_DrawBallNow:
; no rotation matrix (identity).
; BITMAP_HANDLE 0 / SOURCE BALLS_RAMG_ADDR / LAYOUT 56*2/56 / SIZE 56/56.
; Cell(ballColor*8) → frame 0 selected color.
; Vertex2f((TmpX-28)*16, (TmpY-28)*16), centred at TmpX, TmpY.
; TmpX = PosX + cos·ballExpand/128 (idle = 24).
Next-ball аналогично, но pos - NEXT_OFFSET·dir (= -28·dir, на спине после rotation body).
15.5 Recoil cycle (HD-style fire animation)
ЛКМ rise-edge → isFire=1, recoilTick=0, ballExpand=0, ballColor=nextBallColor, nextBallColor=random(0..3).
Каждый кадр:
recoilTick += 10 BRAD(≈0.245 rad, HD = 0.25).recoil = sin(recoilTick)(signed byte, -127..127).- Пока recoil ≥ 0:
tongueExpand = 24 - (recoil·24)>>7→ язык втягивается в рот (24→0).ballExpand += 2(cap 24) → шар выезжает.-
pos = posStart - (cos·recoil)/2048, posStart - (sin·recoil)/2048→ тело откатывается на ~8 px max. -
recoil < 0 → end fire, всё в idle.
Полу-цикл синуса = 13 кадров (≈260ms на 50fps), полное возврат ballExpand до 24 — ещё ~5 кадров.
15.6 FT81x cmd_scale: matrix хранит INVERSE
Param scale = visual ratio = output/native:
- bg upscale 400→640:
cmd_scale(1.6)= 0x1999A. Matrix внутри S(1/1.6) = S(0.625). UV = 0.625·screen → UV(640) = 400. ✓ samples full bg. - ball downscale 56→32:
cmd_scale(0.5714)= 0x9249. Matrix S(1.75). UV = 1.75·screen → UV(32) = 56. ✓ samples full ball.
Документация FT81x неоднозначна — проверять empirically через bg upscale.
15.7 Critical bugs found and fixed (2026-05-10 session)
Bug 1 — Frog_ComputeAngle truncate без clamp.
LD C, L для |dx| > 255 обрезает high byte H, остаётся младший байт. E.g., dx=313=0x139 → C=0x39=57. Swap-логика |dy| > |dx| инвертируется → frog резко крутится у краёв экрана.
Fix:
.dx_pos:
LD A, H
OR A
JR Z, .dx_clamped
LD L, 255 ; saturate to 255 if H ≠ 0
.dx_clamped:
LD C, L ; true 8-bit clamp
То же для .dy_pos.
Bug 2 — Frog_DrawNextBall забывал cmd_scale. DrawBallNow применял scale matrix, DrawNextBall пропускал → next-ball рендерился at native 56×56 в screen rect 32×32, центрирован через 16-px half → визуально “огромный шар” с неправильной позицией.
Fix: либо добавить scale matrix в DrawNextBall, либо (как в baseline 2026-05-10) убрать scale из обеих функций и рендерить native 56×56.
Bug 3 — Multi-sprite feature alignment. Body 122 + overlay 80 → eyes на разных pixel-offsets от sprite centra → после rotation eyes body и overlay расходятся → “две точки вращения как Луна”.
Fix: все спрайты с одинаковыми features ОДНОГО размера. Required: balls atlas 16→8 phases для освобождения RAM_G.
15.8 Python visual_emulator.py — prototype-first workflow
Прототипирование parameters (rotation formula, pivot, offsets, recoil curve) в visual_emulator.py (tkinter+PIL) даёт Х30-Х100 ускорение vs цикла sjasmplus → spgbld → Unreal. Параметры подбираются интерактивно через keys (стрелки, [/], ,/., r), затем переносятся в asm как численные EQU.
Visual emulator не симулирует FT812 cmd_translate/rotate/scale 1:1 — но даёт визуальный target behavior для asm transfer. Различия rendering pipeline (PIL bilinear vs FT812 BILINEAR + cmd_scale convention) могут давать ±1-2 px смещения, но архитектурные параметры (radii, pivots, formulas) переносятся точно.
Ключевые количественные приёмы:
- scale 1.6 =
0x1999Aв f16.16 (0.6×65536 ≈ 0x9999, целая 1 = 0x10000). Не0x19999, не0x1A000— точное значение. - stride = native_width × bpp, НЕ display_width. Для 400×300 RGB565 stride = 400×2 = 800. Если поставить 1280 (= 640×2 для display) — bitmap читается из неправильных адресов в RAM_G, на экране каша.
- BITMAP_SIZE.W/H = display, BITMAP_LAYOUT.height = native. Это обязательная асимметрия: SIZE определяет output rect (для clipping), LAYOUT — storage в RAM_G.
- FT_BILINEAR обязательно для качества. С
FT_NEAREST400×300→640×480 даёт ступеньки на диагоналях.
Что НЕ использовали и почему:
| Approach | Причина отказа |
|---|---|
| Full 640×480 RGB565 (614 KB) | занимает 60% RAM_G, не оставляет места под atlas (300+ KB) |
| 320×240 RGB565 + scale 2× (154 KB) | заметная потеря деталей на детализированной spirale |
| RGB332 (307 KB) | работает, но 256 цветов + dithering = грязный gradient на воде |
| PALETTED8 (308 KB + 1 KB palette) | Unreal эмулятор не поддерживает — серый экран. На реальном железе должно работать (стандарт FT81x), но без возможности отладки на эмуляторе — не используем. |
cmd_loadimage JPEG |
JPEG занимает меньше места в .SPG/.PAK, но при загрузке распаковывается в обычный bitmap в RAM_G. Для 640×480 RGB565 это всё равно 614 400 байт RAM_G; экономится файл/SD-чтение, а не видеопамять после распаковки. |
Полученный bg memory layout:
RAM_G:
#000000..#040FFF → reserved (DL/FONT/HANDLES area FT812)
#010000..#04A8FF → bg_level01 (240 000 bytes RGB565 400×300)
#04A900..#04FFFF → нулевой хвост bg (~5 KB) + свободная область
#050000..#0E4FFF → balls atlas (602 112 bytes ARGB4 6×16×56×56)
#0E5000..#0FCFFF → frog body/plate/tongue (3×30 KB ARGB4 122×122)
#0FD000..#0FEFFF → killzone (8 KB)
Дальше по AvailableRamG ещё ~6 KB до 1 MB конца — запас для score, particles.
Глава 16. FT81x DL persistent state — Cell, BITMAP_HANDLE и ловушки наследования (2026-05-10)
После сборки полной HD-композиции лягушки (глава 18) проявился неприятный интермиттент-баг: «крышка» (face overlay) иногда исчезала на N кадров после выстрела. Видимое поведение — после fire ~75% случаев overlay пропадает до следующего fire, ~25% случаев overlay виден.
Что мы исключили (типичные кандидаты, оказавшиеся неверными)
-
Координата overlay (recoil-сдвиг) —
Frog_PosX/Yсмещаются на ±8 px во время recoil. Заменили вычисление overlay-вершины наFrog_PosStartX/Y(статика). Баг остался → координаты ни при чём. -
Cmd-buffer overflow — буфер CMD_ADDRESS_PTR=#C000 на 16 КБ, фактически используется ~2.5 КБ за кадр. Не близко к лимиту.
-
Исключение копроцессора — после ошибки копроцессор останавливается, всё что после игнорируется. Но overlay рендерится ПЕРЕД chain block, и chain рендерится корректно → копроцессор жив.
-
DL пострадал — снимок Z80 RAM (F12-dump) показал что DL для overlay полностью корректный: handle=6, source=#0B4000, ARGB4 244×122 BILINEAR, matrix valid, vertex (266, 170) внутри 640×480.
-
Matrix corruption — overlay использует ту же matrix что body (
T(61)·R(angle+192)·T(-61)). Body не пропадает, overlay пропадает → matrix не виновата. -
RAM_G corruption — overlay area #0B4000..#0BB740 не имеет writers после Initialize (никто туда не пишет). Layout правильный, padding tongue заканчивается ровно на #0B4000 (overlay start), не наезжает.
Root cause — Cell как persistent DL state
Frog block рендерит спрайты в порядке:
plate handle 4 Vertex2f без Cell → cell наследован
body handle 2 Vertex2f без Cell → cell наследован
tongue handle 5 Vertex2f без Cell → cell наследован
ball-now handle 0 Cell(BallColor*8) + Vertex2f → cell ставится
next-ball handle 0 Cell(NextBallColor*8) + Vertex2f → cell перезаписывается
overlay handle 6 Vertex2f без Cell → cell НАСЛЕДОВАН от next-ball!
Перед frog-блоком идёт bg, который рендерится через Vertex2ii(0, 0, 1, 0).
Vertex2ii — специальная компактная команда, которая включает в себя
handle и cell прямо в опкоде (поля 7 бит handle, 7 бит cell). Она ставит
DL state cell=0 как побочный эффект.
После bg DL state: cell=0. Killzone, plate, body, tongue читают этот cell=0.
Когда ball-now эмитит Cell(BallColor*8) — DL state cell меняется. Next-ball
аналогично. После next-ball cell = NextBallColor*8.
Overlay не эмитит Cell перед своим Vertex2f → наследует cell от next-ball.
Overlay = 122×122 ARGB4 stride 244 = 29768 байт = 1 cell в layout. FT81x вычисляет адрес pixel-data:
addr = BITMAP_SOURCE + cell * cell_size_bytes
= OVERLAY_RAMG_ADDR + cell * 29768
= #0B4000 + cell * 0x7448
Для NextBallColor=1 → cell=8 → addr = #0B4000 + 829768 = #EE200. Это далеко за пределами реального overlay sprite в RAM_G (overlay-data заканчивается на #0BB740 < #EE200). Зона #EE200 — не используется, в RAM_G там zeros. ARGB4 нулевые байты = alpha=0 для всех пикселей → overlay полностью прозрачный → невидим*.
Когда NextBallColor=0 (= 25% случаев в randomize 0..3) → cell=0 → читаем правильный overlay из #0B4000 → виден. Отсюда интермиттент.
Fix
Frog_DrawFaceOverlay:
; ... matrix setup ...
FT_BitmapHandle 6
FT_BitmapSource OVERLAY_RAMG_ADDR
FT_BitmapLayout FT_ARGB4, FROG_SPR_W * 2, FROG_SPR_W
FT_BitmapSize FT_BILINEAR, FT_BORDER, FT_BORDER, FROG_SPR_W, FROG_SPR_W
FT_Begin FT_BITMAPS
XOR A
CALL FT.Coprocessor.Cell ; <-- сброс cell в 0
CALL Frog_EmitVertex2f_PosCentered
FT_End
...
FT.Coprocessor.Cell = TSLib helper, эмитит DL command 0x06000000 | (cell & 0x7F).
Универсальное правило: какой DL state в FT81x persists
| Команда | Persists | Scope |
|---|---|---|
| BITMAP_HANDLE | да | global |
| BITMAP_SOURCE | да | per-handle |
| BITMAP_LAYOUT/SIZE | да | per-handle |
| BITMAP_TRANSFORM_A..F | да | global |
| CELL | да | global |
| COLOR_RGB | да | global |
| COLOR_A | да | global |
| BLEND_FUNC | да | global |
| LINE_WIDTH | да | global |
| POINT_SIZE | да | global |
| SCISSOR_XY/SIZE | да | global |
Practical rule: любой Vertex2f, идущий после atlas-блока (где Cell≠0 был эмитен), должен явно эмитить нужный Cell (даже Cell(0) для single-cell sprite). Не полагайся на наследование = 0 by default.
Нюанс
VERTEX2IIvsVERTEX2F. Ловушка наследования CELL касается прежде всегоVERTEX2F: он берёт cell из persistent-состояния. УVERTEX2IIномер ячейки (cell, биты 0..6) и handle зашиты прямо в 32-битную команду, поэтому такой вершине «унаследованный» CELL не страшен. НОVERTEX2IIпри этом сам перезаписывает глобальный CELL для последующих команд — так что если дальше в кадре идётVERTEX2F, он подхватит cell от предыдущегоVERTEX2II. Правило «эмить CELL явно» остаётся в силе именно из-за этого взаимодействия.
Методология поиска
Ловушка для одиночного отладчика — баг локализован в DL pipeline state, который не виден в дампе Z80 RAM (DL state живёт в FT81x регистрах). Дамп показывал все правильные команды; вычислить наследование Cell можно только мысленным прохождением DL.
Diagnostic A (изолировать координату): заменить Vertex источник на статику (Pos→PosStart). Баг не ушёл → не координаты.
Главная подсказка пришла от пользователя: «чем крышка отличается от остальных слоёв спрайта» — заставило сесть и последовательно сравнить overlay с другими frog-спрайтами по всем атрибутам. Различие в позиции в DL pipeline относительно atlas-блока (overlay = единственный single-cell sprite ПОСЛЕ atlas-блока) и привело к Cell.
Похожие ловушки могут возникнуть с любым persistent DL state. При добавлении новых sprite в frame — пройти по всем persistent settings и проверить, что текущий sprite их не наследует случайно (или явно ресетит).
Глава 17. Vsync-first sync: race между Z80 build и FT812 render (2026-05-10)
После сборки полного gameplay loop (chain physics + bullet + match-3) на реальном железе ZX-Evo + FT812 проявился класс артефактов: «цветной мусор / линии посередине экрана при ≥30 шарах в цепи». На эмуляторе Unreal x64 артефакт минимален или отсутствует. На железе — линейно нарастает с числом шаров и усиливается при движении мыши.
Гипотезы и проверка
Гипотеза 1 — RAM_CMD overflow (4 KB ring).
Решение TSLib FT.Coprocessor.Write уже опрашивает REG_CMDB_SPACE перед каждой
SPI-записью и ждёт, пока копроцессор освободит место. Переполнение невозможно через
TSLib API. Гипотеза отклонена.
Гипотеза 2 — RAM_DL overflow (8K commands = 32 KB). Подсчёт DL команд на кадр: bg ~24 + killzone ~14 + frog 6 спрайтов с matrix ~75 + chain N шаров × 8 + cursor ~14 + bullet 1 × 8 ≈ 149 + 8N. При 60 шарах ≈ 629 DL — далеко от 8192 лимита. Подтверждено визуально через красную полоску внизу экрана (диагностика, потом убрана). Отклонено.
Гипотеза 3 — cmd_swap через CMD-FIFO вместо REG_DLSWAP.
По FT81x документации cmd_swap = «копроцессор сам выполнит swap когда DL
готов». Заменили manual REG_DLSWAP=FRAME на FT_CMD_Swap. Программа
зависла (deadlock в CMD-FIFO). Откат, гипотеза отклонена.
Гипотеза 4 — vsync-first sync (HighLander). Кадровый sync с FT812 vsync перед SPI write. Идея: write попадает в vblank window, не накладывается на render. На железе частично помогло — артефакт исчез при стационарной мыши, остался при mouse motion.
Корень оставшегося артефакта: тяжёлый build при mouse motion.
ZL_AimUpdate детектит motion → Frog_ComputeAngle запускается с atan2 LUT[129] +
hybrid follow + 8-octant logic = десятки сложений/делений. Build удлиняется,
SPI write выходит за vblank window в render time → race с FT812 RAM_DL read.
Решение — parallel build + vsync-first write
Перестраиваем main loop:
.Loop ; --- 1. Input + game state + Build DL в Z80 buffer ---
; ВЫПОЛНЯЕТСЯ ПАРАЛЛЕЛЬНО с FT812 рендером prev frame.
CALL Input.Mouse.UpdateMouseState
CALL ZL_AimUpdate ; mouse/keyboard → Frog_Angle
CALL ZL_SmoothMouse
CALL Frog_Update ; ComputeFrogAngle + recoil
CALL VDC_Update
CALL Bullet_Update
CALL Bullet_CheckCollision
FT_CMD_Start ; reset Z80 buffer ptr
FT_DL_Start ; cmd_dlstart
FT_VertexFormat 4
FT_ClearColorRGB32 0x102030
FT_ClearAll
CALL ZL_DrawFrame ; bg + frog + chain + cursor + bullet
FT_Display
; --- 2. Sync с FT812 vsync ПОСЛЕ build ---
.WaitIntSync FT_RD_REG8 FT_REG_INT_FLAGS
AND FT_INT_SWAP
JR Z, .WaitIntSync
.WaitDLSwap FT_RD_REG8 FT_REG_DLSWAP
AND 3
JR NZ, .WaitDLSwap
; --- 3. Burst write Z80 buffer → FT812 RAM_CMD (в vblank window) ---
FT_CMD_Write
CALL FT.Coprocessor.WaitFlush
FT_WR_REG8 FT_REG_DLSWAP, FT_DLSWAP_FRAME
JP .Loop
Ключевое отличие от предыдущей схемы: wait FT INT_SWAP перенесён в середину loop, между build и write. Z80 cycles на input + game state + DL build идут в параллель с тем, что FT812 рендерит предыдущий кадр. Когда Z80 готов — ждёт vsync, затем SPI burst попадает строго в vblank.
Почему это работает
| Pipeline | Build location | Write location | Race |
|---|---|---|---|
| Старая (wait в начале) | После vblank | В render time | Mouse motion → race |
| HighLander (wait в начале v2) | После vblank | В render time | Mouse motion → race |
| Parallel + vsync write | Параллельно с render | Vblank window | Нет |
При mouse motion build занимает ~3-5 ms (atan2 в ZL_KbdAimUpdate + matrix
calc для frog). FT812 render @ 57Hz занимает ~15.5 ms из 17.5 ms кадра, vblank
~2 ms. В старой схеме build ел кусок vblank → write попадал в render. В новой —
build делается в render time, write строго в vblank.
Урок (универсальный)
Sync на vsync должен быть ПЕРЕД сторонним I/O write, не ПОСЛЕ. Z80-only работа (input read из port, game state update в RAM, DL build в Z80 buffer) НЕ трогает FT812 → может идти в любое время, в т.ч. параллельно с render.
Только I/O в FT812 (FT_CMD_Write, FT_WR_REG) требует vblank window. Поэтому
правильный sync = «build в любое время, sync прямо перед I/O burst».
Глава 18. DXT1-эмуляция на FT812: компрессия фона до 0.5 байт/пикс через L2-mask + RGB565 blend (2026-05-12)
Задача
Фон уровня 640×480 в нативном RGB565 занимает 614 400 байт в RAM_G FT812 — 60% от всего 1 МБ. Для multi-level игры (22 уровня Zuma Deluxe) это неприемлемо: 22 × 614 400 = 13.5 МБ — нужен какой-то стриминг или сжатие.
Раньше использовали трюк «400×300 RGB565 + cmd_scale(1.6) NEAREST до 640×480»:
240 000 байт, но качество ступенчатое (см. reference_zuma_vdac2_bg_compression.md).
Хочется честные 640×480 при минимальном объёме.
Block-compressed форматы (DXT, ETC, ASTC) FT812 не поддерживает hardware’но.
Список BITMAP_LAYOUT.format (FT81X PG Table 7): только ARGB1555, L1/L2/L4/L8,
RGB332, ARGB2/4, RGB565, TEXT8X8, TEXTVGA, BARGRAPH, PALETTED565/4444/8. Никаких
DXT/S3TC. BITMAP_EXT_FORMAT (под ASTC) появился только с BT815/816.
Идея
DXT1 кодирует 4×4 пиксельный блок 8 байтами:
- 2 байта c0 endpoint (RGB565)
- 2 байта c1 endpoint (RGB565)
- 4 байта = 16 × 2-битных индексов выбора цвета
Декодирование на лету: для каждого пикселя индекс 0..3 определяет цвет:
0→c01→c12→(2·c0 + c1) / 3(≈ ⅔c0 + ⅓c1)3→(c0 + 2·c1) / 3(≈ ⅓c0 + ⅔c1)
FT812 умеет каждый из этих кусков по-отдельности:
- c0 и c1 endpoint цвета = два RGB565 цвета на блок 4×4 = массив
(W/4)×(H/4)RGB565 - Индекс выбора = 2 бита на пиксель = формат
FT_L2W×H - Интерполяция между c0 и c1 через индекс → реализуется аппаратным alpha-blending’ом:
L2 пишет alpha канал, c0/c1 рисуются с
DST_ALPHA/ONE_MINUS_DST_ALPHAblend
Это трюк из книги J. Bowman The Gameduino 2 Tutorial, Reference and Cookbook,
раздел 15.6 DXT1: EVE/Gameduino 2 не поддерживает DXT1 напрямую, но может
имитировать его несколькими bitmap-pass’ами и blend. Конвертер
ft812_dxt_convert.py (автор — TS-Labs) раскладывает обычный DXT1 в нужный layout.
Формат raw файла
+------------------+ offset 0
| c0 plane | RGB565, (W/4) × (H/4)
| 38400 bytes | для 640×480 → 160 × 120 cells × 2 байта
+------------------+ offset 38400
| c1 plane | RGB565, (W/4) × (H/4)
| 38400 bytes |
+------------------+ offset 76800
| L2 mask | 2 бит/пикс, W × H
| 76800 bytes | для 640×480 → 640 × 480 / 4 = 76800
+------------------+ offset 153600
Всего: 153 600 байт для 640×480 ровно 0.5 байт/пикс — теоретический минимум среди форматов FT812 (PALETTED8 = 1 байт/пикс минимум). Экономия 75% vs raw RGB565.
L2 alpha mapping (нелинейный)
Эмпирически FT812 декодирует 2-битный raw L2 в 8-битную alpha по таблице
(0, 255, 85, 170) для (raw 0, 1, 2, 3). Не линейно — raw=1 → alpha=255,
а не 85.
L2_ALPHAS = (0, 255, 85, 170)
Конвертер использует эту таблицу при выборе selector-ов так, чтобы итоговый
композит после blend = c0 * (1-A/255) + c1 * A/255 давал:
| sel | alpha | финальный цвет | смысл DXT1 |
|---|---|---|---|
| 0 | 0 | c0 | endpoint c0 |
| 1 | 255 | c1 | endpoint c1 |
| 2 | 85 | ⅔c0 + ⅓c1 | интерполяция |
| 3 | 170 | ⅓c0 + ⅔c1 | интерполяция |
Это точно DXT1 декомпрессия, без потерь относительно стандартного DXT1.
Display List — 3 прохода
FT_CMD_BUF (ZL_DL_SAVE_CONTEXT)
CALL ZL_EmitLoadId
CALL ZL_EmitSetMatrix
; handle 1: RGB565 color cells (cell 0=c0, cell 1=c1)
FT_BitmapHandle 1
FT_BitmapSource ZL_BG_COLOR_ADDR
FT_BitmapLayout FT_RGB565, ZL_BG_COLOR_STRIDE, ZL_BG_BLOCK_H
FT_BitmapSize FT_NEAREST, FT_BORDER, FT_BORDER, ZL_BG_W, ZL_BG_H
; handle 8: L2 mask на full resolution
FT_BitmapHandle ZL_BG_L2_HANDLE
FT_BitmapSource ZL_BG_L2_ADDR
FT_BitmapLayout ZL_FT_L2, ZL_BG_L2_STRIDE, ZL_BG_H
FT_BitmapSize FT_NEAREST, FT_BORDER, FT_BORDER, ZL_BG_W, ZL_BG_H
FT_Begin FT_BITMAPS
;--- Pass 1: L2 → alpha канал dst.A ---
FT_CMD_BUF (ZL_DL_COLOR_MASK | ZL_COLOR_MASK_A) ; только A
FT_CMD_BUF (ZL_DL_BLEND_FUNC | (ZL_BLEND_ONE << 3) | ZL_BLEND_ZERO)
FT_CMD_BUF (ZL_DL_COLOR_A | 255)
FT_Vertex2ii 0, 0, ZL_BG_L2_HANDLE, 0
;--- готовимся к color planes ---
FT_CMD_BUF (ZL_DL_COLOR_MASK | ZL_COLOR_MASK_RGB) ; только RGB
CALL ZL_EmitLoadId
FT_CMD_BUF FT_CMD_SCALE
FT_CMD_BUF #00040000 ; sx = 4.0
FT_CMD_BUF #00040000 ; sy = 4.0
CALL ZL_EmitSetMatrix
;--- Pass 2: c1 plane с DST_ALPHA blend ---
FT_CMD_BUF (ZL_DL_BLEND_FUNC | (ZL_BLEND_DST_ALPHA << 3) | ZL_BLEND_ZERO)
FT_Vertex2ii 0, 0, 1, 1 ; cell 1 = c1, out = c1 * A
;--- Pass 3: c0 plane с ONE_MINUS_DST_ALPHA сверху ---
FT_CMD_BUF (ZL_DL_BLEND_FUNC | (ZL_BLEND_ONE_MINUS_DST_ALPHA << 3) | ZL_BLEND_ONE)
FT_Vertex2ii 0, 0, 1, 0 ; cell 0 = c0, out = c0*(1-A) + dst
FT_End
FT_CMD_BUF (ZL_DL_RESTORE_CONTEXT)
Математика итогового пикселя:
после pass1: dst.A = L2_ALPHAS[selector] (∈ {0, 255, 85, 170})
после pass2: dst.RGB = c1 * dst.A / 255
после pass3: dst.RGB = c0 * (1 - dst.A/255) + dst.RGB * 1
= c0 * (1 - A/255) + c1 * (A/255)
Подводные камни (на отладку ушёл вечер)
1. sjasmplus parsing macro-аргументов с |
В --syntax=ab запись FT_CMD_BUF ZL_DL_COLOR_MASK | 15 парсится криво —
в макрос приходит только первый operand (ZL_DL_COLOR_MASK = #20000000),
а | 15 пропадает.
Результат: COLOR_MASK эмитится с битами 0000 (всё запрещено к записи), все
последующие draw-ы становятся no-op-ами, экран = clear color.
Лечение: ВСЕГДА оборачивать в скобки.
FT_CMD_BUF (ZL_DL_COLOR_MASK | 15) ; правильно
FT_ColorMask 1, 1, 1, 1 ; или штатный TSLib-макрос
2. FT_BitmapSize уже эмитит BITMAP_SIZE_H
FT_BitmapSize macro Filter?, WrapX?, WrapY?, Width?, Height?
FT_CMD_BUF ((0x29 << 24) | ((W>>9)<<2) | (H>>9)) ; BITMAP_SIZE_H
FT_CMD_BUF ((0x08 << 24) | ... | (W & 511) | ...) ; BITMAP_SIZE
endm
Передаём 640/480 напрямую в макрос. Если попытаться вручную предварительно
эмитить FT_CMD_BUF (ZL_DL_BITMAP_SIZE_H | hi) + потом FT_BitmapSize с
младшими W_LO, H_LO — макрос затирает ручной SIZE_H своим (с нулевыми
hi-битами, потому что W_LO=128, H_LO=480 укладываются в 9 бит). Высокие биты
теряются → BITMAP_SIZE становится 128×480, draws обрезаются.
Также FT_BitmapLayout сам эмитит BITMAP_LAYOUT_H для linestride > 1023 /
height > 511.
3. BITMAP_SIZE = screen extent, не source
Для c0/c1 cells источник 160×120 + cmd_scale(4,4) → screen draws 640×480.
FT_BitmapSize должен быть 640×480 (final screen extent после matrix),
не source 160×120. Иначе draws обрезаются до 160×120 в верхнем-левом углу.
L2 plane (handle 8) — source уже 640×480 нативно, scale identity → BITMAP_SIZE тоже 640×480.
4. Vertex2ii max 511×511
VERTEX2II имеет 9-битные поля координат (max 511). Для рисования full-screen
640×480 надо использовать Vertex2f с VertexFormat 0 (1 px) или 4 (1/16 px).
В нашем случае все draws начинаются с (0,0), поэтому Vertex2ii ОК — позиция ноль помещается, а размер контролируется через BITMAP_SIZE.
Сравнение объёмов 640×480
| Формат | Байт | vs DXT1-эмул |
|---|---|---|
| Raw RGB565 | 614 400 | 4.0× |
| ARGB4 | 614 400 | 4.0× |
| 400×300 RGB565 + scale 1.6 (старый bg) | 240 000 | 1.56× |
| DXT1-эмуляция (c0+c1+L2) | 153 600 | 1.0× |
| 320×240 RGB565 + scale 2.0 | 153 600 | 1.0× (мыло) |
| PALETTED8 | 308 224 | 2.0× |
| L8 (grayscale) | 307 200 | 2.0× |
Когда использовать
OK Фотореалистичный фон (level background, splash screen) OK Текстуры с плавными цветовыми переходами OK Когда RAM_G сильно ограничен (multi-level игра)
NOT Спрайты с резкими краями и небольшим количеством цветов — артефакты на границах (DXT1 теряет alpha, плохо ловит тонкие линии). Для шаров/frog эффективнее ARGB4. NOT Текст и UI — здесь DXT1 даёт «лесенки» из-за грубых endpoint цветов.
Конвертер ft812_dxt_convert.py
Опции качества (effort -e 0..10):
-e 0— быстро, шумный (видны блоки 4×4 на градиентах)-e 3— почти неотличим от оригинала (рекомендация TS-Labs)-e 6+— perceptual weights + seam smoothing + residual diffusion, медленно
Базовый запуск:
python ft812_dxt_convert.py level01.png -o out/level01 -f l2 -t raw -e 3 -p
Выход:
out/level01_l2.raw— 153 600 байт raw в формате c0|c1|L2 (грузим в RAM_G как есть)out/level01_l2.h— C-заголовок с offset-ами/strides (для интеграции)out/level01_l2_preview.png— реконструкция (для визуальной оценки качества)
Multi-level в Zuma — что меняется
22 уровня × 153 600 = 3.4 МБ pseudo-DXT L2 vs 13.5 МБ raw RGB565. Сейчас один
уровень упаковывается в 10 spgbld-страниц по 16 КБ. При переключении уровней
upload bg = 153 600 Б через длинный OTIR на 14 МГц Z80 — примерно
153600 / (14000000/21) = ~230 мс без учёта накладных расходов. Через
DMA-передачу в SPI будет быстрее, но это отдельный путь и его надо мерить на
реальном железе.
Объёмы по сравнению с zlib (cmd_inflate план):
- pseudo-DXT L2: 153 600 Б raw upload, ~230 мс через
OTIRна 14 МГц, без CPU-decode - pseudo-DXT L4: 230 400 Б raw upload, ~346 мс через
OTIRна 14 МГц, без CPU-decode - ZX0/zlib: ~100 КБ compressed → ~150 КБ uncompressed, upload меньше, но добавляется decode/inflate
pseudo-DXT выигрывает по объёму уже распакованных данных в RAM_G: мы храним не
полный RGB565-кадр, а две маленькие цветовые плоскости и полноэкранную маску.
CPU-decode не нужен; цена переносится в несколько проходов отрисовки FT812.
Качество фотореалистичных фонов визуально приемлемое начиная с -e 3.
Глава 19. Апгрейд DXT1-эмуляции с L2 до L4: +50% SPI за фотокачество (2026-05-12)
Зачем понадобился L4
Глава 18 описала DXT1 на L2-маске: 0.5 байт/пикс, 153 600 байт на 640×480.
Объёмно идеально, но на каменной текстуре фона level_src_01 оставалась
заметная блочность 4×4.
Корень: L2-маска даёт всего 4 уровня между endpoints ({0, 85, 170, 255} →
четыре цвета: c0, ⅔c0+⅓c1, ⅓c0+⅔c1, c1). На гладких градиентах внутри блока
8 уникальных оттенков в исходнике вынуждены коллапсировать в 4 → видна
ступенька в каждом блоке.
Чтобы оценить «насколько лучше» — переходим на L4:
- 16 уровней маски (линейный ramp
0..255шагом 17) - 4×4 блок цветов c0/c1 тот же, размер endpoint planes не меняется
- mask 4bpp вместо 2bpp → +76 800 байт (76800 → 153600)
- Итого raw: 230 400 байт vs 153 600 = +50%
Пиксельный «бюджет фона» 200 КБ был принятой границей бюджета SPI/RAM_G. 230 КБ — чуть выше потолка, но bg уже по-настоящему фотореалистичен.
Сравнение L2 vs L4 в одном блоке
оригинал блока 4×4: цвета на пиксель
+---+---+---+---+
| A | A | B | B | A = (200, 90, 60)
| A | A | B | B | B = (210, 130, 80)
| C | C | D | D | C = (180, 100, 70)
| C | C | D | D | D = (170, 110, 90)
+---+---+---+---+
L2 (4 уровня): L4 (16 уровней):
endpoints: c0=A, c1=D endpoints: c0=A, c1=D
selectors per pixel: selectors per pixel:
A→0 B→2 (⅔A+⅓D) A→0 B→5 (a~85)
C→3 (⅓A+⅔D) D→1 C→10 (a~170) D→15
ошибка перекраски: ошибка перекраски:
B → ⅔A+⅓D отличается от B B → a*A+(1-a)*D с лучше подбираемым α
→ видимый шов между блоками → плавная интерполяция, шов невидим
Что меняется в raw layout
Только размер маски и её stride:
+------------------+ offset 0
| c0 plane | RGB565, (W/4) × (H/4)
| 38400 bytes | для 640×480 → 160 × 120 cells × 2 байта
+------------------+ offset 38400
| c1 plane | RGB565, (W/4) × (H/4)
| 38400 bytes |
+------------------+ offset 76800
| L4 mask | 4 бит/пикс, W × H (вместо 2 бит/пикс)
| 153600 bytes | для 640×480 → 640 × 480 / 2 = 153600 ← х2 от L2
+------------------+ offset 230400
Изменения в asm (минимально)
main.asm: 10 → 15 spgbld pages
BG_FIRST_PAGE EQU 7
; было:
; BG_PAGE_COUNT EQU 10 ; DXT1-decomp 640×480 (c0|c1|L2 = 153600)
; стало:
BG_PAGE_COUNT EQU 15 ; DXT1_L4 640×480 (c0|c1|L4 = 230400, last padded)
RAM_G layout не меняется: BG занимает #010000..#04C000 = 245 760 байт
(230 400 реальных + 15 360 байт нулевого хвоста последней spgbld-страницы). Killzone
сидит ровно на #04C000 — без overlap.
MainLoop.asm: формат маски и stride
; было:
; ZL_BG_L2_STRIDE EQU ZL_BG_W / 4 ; FT_L2 = 2bpp → 4 пикс/байт
; ZL_FT_L2 EQU 17 ; format code FT_L2
; стало:
ZL_BG_L2_STRIDE EQU ZL_BG_W / 2 ; FT_L4 = 4bpp → 2 пикс/байт
ZL_FT_L2 EQU FT_L4 ; format code FT_L4 (=2)
FT_L4 = 2, FT_L2 = 17 — две разные ячейки в BITMAP_LAYOUT.format
(см. FT81x PG §4.7.7, Table 7). Stride для 4bpp = (W+1)/2.
DL pipeline — без изменений
;--- Pass 1: маска → dst.A через ONE/ZERO blend ---
FT_CMD_BUF (ZL_DL_COLOR_MASK | ZL_COLOR_MASK_A)
FT_CMD_BUF (ZL_DL_BLEND_FUNC | (ZL_BLEND_ONE << 3) | ZL_BLEND_ZERO)
FT_CMD_BUF (ZL_DL_COLOR_A | 255)
FT_Vertex2ii 0, 0, ZL_BG_L2_HANDLE, 0 ; теперь L4 mask
;--- Pass 2/3: c1/c0 с DST_ALPHA blend — те же команды ---
L4 декодируется FT812 в линейный 8-битный alpha: raw_value × 17
(значения 0, 17, 34, …, 255). В отличие от L2 ({0, 255, 85, 170}), L4
без перестановок — selector k даёт alpha ≈ k/15 * 255. Конвертер
автоматически использует правильное соответствие.
Финальный blend dst.RGB = c0*(1-A) + c1*A алгебраически одинаков —
просто A теперь имеет 16 значений вместо 4.
Подводный камень: CPU энкодер на Windows нежизнеспособен
Для 640×480 = 19 200 блоков 4×4. Локальный энкодер (без GPU):
| Режим | Результат |
|---|---|
-j 0 (auto = 6 cores) |
BrokenProcessPool (OOM при effort 8 / L4) |
-j 1 (single-process) |
~3 мин до 2% при effort 4 → ~2.5 часа total |
-j 2 effort 6 |
~2 мин до 0%, не дождались |
Multiprocessing у concurrent.futures.ProcessPoolExecutor на Windows
не shared memory: каждый воркер получает копию blocks через pickle.
Для 230 КБ blocks × 6 воркеров = 1.4 МБ × Python overhead ~50× = ~70 МБ
накапливается; через несколько итераций OOM на 4 ГБ VM.
Single-process работает стабильно, но 19 200 блоков × ~0.5 сек/блок (effort 4
с perceptual weights) = 160 мин. Эта длительность была подтверждена
эмпирически на CPU 2 × Xeon Gold 6132 под Hyper-V.
Решение: запускать энкодер на хост-машине с GPU через pyopencl.
python ft812_dxt_convert.py level_src_01.png -o out -f l4 -t raw -x -p -e 8
На AMD gfx1032 весь pipeline (initial pair generation + hybrid refine + write)
проходит менее чем за 30 секунд на effort 8. Готовые файлы
(out/level_src_01_l4.raw 230 400 байт) копируются в проект, режутся на
страницы, собираются.
Разрезание файла на 16-КБ страницы для spgbld
# split_l4.py
PAGE = 16384
data = open('level_src_01_l4.raw', 'rb').read()
assert len(data) == 230400
n_pages = (len(data) + PAGE - 1) // PAGE # = 15
for i in range(n_pages):
chunk = data[i*PAGE:(i+1)*PAGE]
if len(chunk) < PAGE:
chunk += b'\x00' * (PAGE - len(chunk)) # padding zeros
open(f'bg_l4_p{i:02d}.bin', 'wb').write(chunk)
# wrote 15 файлов, последний с 1024 реальных байт + 15360 нулей
spgbld_vdac2.ini:
Block = #0000, #07, bg_l4_p00.bin
Block = #0000, #08, bg_l4_p01.bin
...
Block = #0000, #15, bg_l4_p14.bin
Block = #0000, #16, killzone_p00.bin ; следом, без overlap
Итоговый бюджет
| Формат | Байт | vs Raw | Качество |
|---|---|---|---|
| Raw RGB565 640×480 | 614 400 | 1.00× | reference |
| 400×300 RGB565 + scale 1.6 NEAREST | 240 000 | 0.39× | ступенька 1.6× |
| DXT1_L2 (Глава 18) | 153 600 | 0.25× | блочность 4×4 |
| DXT1_L4 (эта глава) | 230 400 | 0.38× | фоторовно |
| ARGB4 native 640×480 | 614 400 | 1.00× | reference |
L4 даёт 2/3 объёма native RGB565 при визуально неотличимом качестве — ровно та точка цена/качество, которая нужна для multi-level Zuma: 22 × 230 КБ = 5 МБ vs 13.5 МБ raw. Помещается в обычный TR-DOS + spgbld.
Когда выбирать L2 vs L4
-
L2: tile-фон, splash-screen с большими flat-зонами, ограниченный RAM_G. Если 75% экономии важнее минимальной блочности — берём L2.
-
L4: фотореалистичные уровни, фоны с плавными градиентами (наш случай), splash-screen с тонкой деталировкой. +50% к L2, но качество скачком вверх.
Глава 20. Render-loop оптимизации и DL-emit ловушки (2026-05-17)
Главы 15-19 закрыли визуальную часть Zuma. Эта глава — три приёма, которые выжали из FT812 ещё несколько процентов и закрыли тонкий баг рендера. Появились в процессе финального полировок kill-zone (плавное поглощение шаров) и frog-композиции.
20.1 Bucket-grouped tangent rotation: 32 cmd_rotate → 16, а потом обратно
VDC выдаёт каждому шару в цепи tangent 0..255 — направление трека в точке.
HD-источник вращает каждый шар своим cmd_rotate(angle), но на FT812 это
N call’ов cmd_loadidentity → cmd_translate → cmd_rotate → cmd_translate
→ cmd_setmatrix на каждый шар. При длине цепи 85 шаров это ~30% бюджета DL.
Bucket-grouping — группировка шаров по углу:
-
Pre-pass: для каждого шара вычисляем
bucket = (tangent + N/2) >> log2(N)и кешируем (bucket, cell, Vx, Vy) в RAM. -
Outer loop по N бакетам: emit matrix для
bucket * (256/N), inner scan — все шары с этим bucket’ом → Cell + Vertex2f.
При N=32: шаг 11.25° (256/32 = 8 BRAD = 11.25°). Шар получит visually-acceptable rotation, плюс цены matrix-emit’а только 32 раза за кадр.
; 32-bucket scheme: bucket = (tangent+4) >> 3
LD A, (VDC_LastTangent)
ADD A, 4 ; round-nearest
RRCA : RRCA : RRCA ; >> 3
AND 31 ; mod 32
LD (cache_bucket), A
; ...позже, в outer loop:
LD A, (current_bucket)
ADD A, A : ADD A, A : ADD A, A ; bucket * 8
CALL ZL_EmitRotate ; A = BRAD 0..255
Lesson: 16 vs 32. Изначально 32 бакета считались избыточными — попробовали 16 (шаг 22.5°). На статичных шарах выглядело норм, но на быстро двигающихся по крутой кривой (вход в killzone, головной шар) проявился визуальный jitter — глаз ловит ступеньки. Откатили обратно в 32. Урок: не оптимизируй “на глаз” в статике; смотри на самые быстрые моменты gameplay.
20.2 Per-sprite alpha fade через COLOR_A — плавное поглощение
FT812 имеет команду COLOR_A(alpha) — умножает alpha-канал последующего
bitmap’а на 0..255. Это позволяет делать dissolve-эффект на спрайте без
изменения текстуры.
В нашем случае: head-шар цепи во время Game Over absorb должен плавно исчезать в kill-zone, а не пропадать дискретно. Алгоритм:
; Каждый тик absorb (state=1):
LD A, (VDC_HSub) ; HSub 0..31 in cell
ADD A, A : ADD A, A : ADD A, A ; * 8 (max 31*8 = 248)
CPL ; alpha = 255 - HSub*8
LD (VDC_HeadAbsorbAlpha), A ; смыкается с 255 до 7 за цикл
; В .BInner bucket-loop, перед Vertex2f head-шара:
LD A, (VDC_HeadAbsorbAlpha)
LD E, A
CALL FT.Coprocessor.ColorA ; emit COLOR_A(alpha)
LD C, (IX+2) : LD B, (IX+3) ; перезагрузить BC (Cell/ColorA уничтожили)
LD E, (IX+4) : LD D, (IX+5)
CALL FT.Coprocessor.Vertex2f
LD E, 255
CALL FT.Coprocessor.ColorA ; восстановить для остальных шаров
Ловушка: COLOR_A — persistent state DL. Если не восстановить до 255, все последующие спрайты в этом кадре будут полупрозрачные.
Identify the target sprite: head-шар = первая запись в кеше bucket-prepass
по адресу ZL_BALL_CACHE_ADDR. В .BInner проверяем IX == ZL_BALL_CACHE_ADDR
(PUSH IX / POP HL / CP HIGH / CP LOW) — это slot[0]. COLOR_A применяется только
к этому одному Vertex2f.
20.3 Cell/ColorA корраптят BC/DE — координаты грузить ПОСЛЕ, а не ДО
Все одноаргументные DL-команды TSLib (Cell, ColorA, Tag, LineWidth…)
эмитятся через Command_BCDE — формируют 4 байта опкода в BC/DE и пишут в
буфер. После такого CALL’а BC и DE мусор.
Из этого следует жёсткое правило для пары Cell+Vertex2f:
; WRONG — баг, который у нас прятался месяц в DrawKillzoneDual:
LD BC, x_scaled ; BC = X
LD DE, y_scaled ; DE = Y
XOR A
CALL FT.Coprocessor.Cell ; BC, DE corrupted!
CALL FT.Coprocessor.Vertex2f ; uses corrupted BC, DE → sprite в ?,?
; RIGHT — Cell первым, координаты после:
XOR A
CALL FT.Coprocessor.Cell
LD BC, x_scaled
LD DE, y_scaled
CALL FT.Coprocessor.Vertex2f
Bug-symptom при wrong ordering: спрайт рисуется в верхнем-левом углу или вообще
не виден — Cell оставляет в BC значение 0x0600 (опкод Cell), Vertex2f
интерпретирует это как X*16 = 1536, что выходит за разумный экранный диапазон,
либо clip.
Эвристика: если sprite появляется не там где ожидаешь, или мигает, или “то ли есть, то ли нет” — первое что проверить: между LD BC,coords и Vertex2f нет ли промежуточного CALL’а к Cell/ColorA/Tag/etc. Если есть — переставить порядок.
20.4 Скип лишнего DL: bg-baked = overlay не нужен
Иногда самый быстрый рендер — не рисовать вообще. Kill-zone “закрытый череп” уже запечён в bg-арте (golden 8-pointed sun); рисовать overlay поверх в idle-state — двойная работа.
DrawKillzoneDual:
LD A, (VDC_KzFrame)
CP 2
RET C ; KzFrame=0/1 (idle / final GO) → bg сам показывает
; ...emit Cell + Vertex2f только когда KzFrame >= 2 (анимация)
Это экономит ~10 байт DL × 60 FPS = 600 байт/сек трафика SPI, который освобождает Z80 cycles для chain physics + input + sound. Микроптимизация, но накладывается на каждый “статичный” sprite в render-loop’е.
20.5 Continuous-motion absorb через HSub-advance (mirror of fast-spawn)
Last optimization-pattern: используй существующий механизм движения, не пиши
свой. Игра уже умеет двигать цепь плавно — в fast-spawn phase chain
двигается HSub++ × VDC_FAST_ADVANCE=12 раз за тик. Это даёт плавное
скольжение шаров по треку.
Для Game Over absorb разумно использовать тот же механизм с другими параметрами:
VDC_UpdateAbsorb:
LD B, VDC_ABSORB_ADVANCE ; e.g., 8 (32/8 = 4 ticks/cell)
.aa_loop: PUSH BC
CALL .ua_move_once ; HSub++; on wrap → array shift, HSA capped
POP BC
DJNZ .aa_loop
; alpha рассчитывается из HSub → синхрон с motion
.ua_move_once:
LD A, (VDC_HSub)
INC A
CP VDC_CELL_SIZE
JR C, .save ; HSub < CS → просто save
XOR A ; wrap: HSub=0
LD (VDC_HSub), A
; remove slot[0] (array shift), HSA capped → новый head в том же clamped
; последнем track sample → 1px continuity jump (invisible)
Эффект: tail-шары плавно скользят (sub-pixel HSub), head clamped на последнем сэмпле трека, alpha fade ↔ HSub progress. При wrap — array shift И сброс alpha в 255. Visual continuity = 1 px разрыв вместо discrete cell-jump.
Аналогичный паттерн можно применить к: уменьшению цепи после match-3 cascade, выбросу bonus-шаров, любым “цепь сжимается/растягивается” анимациям.
20.6 Тоннели: маскирование шаров не лечит бюджет строки
Практический вывод по уровням с тоннелями/top-mask на реальном FT812: попытки ускорить такие уровни через маскирование/отсечение шаров не дали рабочего результата. Проверялись подходы, где шары под тоннелем не рисуются или дополнительно ограничиваются маской/областью отрисовки. На реале это не дало выигрыша по pixel-clock budget строки.
Причина в том, что основной дорогой участок для этих сцен — не только overdraw top-mask, а широкие bitmap-pass’ы, DL-walk и matrix-команды на шарах:
-
cmd_loadidentity / cmd_translate / cmd_rotate / cmd_setmatrixраздувают поток команд на каждый новый угол; -
FT812 всё равно должен разобрать DL-состояние и пройти команды матрицы;
- маскирование пикселей не уменьшает стоимость matrix-emit’а и DL-walk;
- если маска заменяет красивый alpha-край на жёсткую вырезку, тоннель начинает выглядеть грубо: шар не «уходит под край», а обрубается.
Что важно: у тоннелей/top-mask был красивый альфа-канал. Упрощённые маски ради экономии не только не спасли такты строки, но и ухудшили внешний вид — пропала мягкая граница, вырезка стала грубой. Это плохой обмен: качество потеряли, а строчный бюджет FT812 не выиграли.
Рабочее правило после перехода к 1024×768@59 и одному дешёвому fullscreen-проходу:
- не лечить тоннели грубым pixel-mask’ом;
- сохранять alpha-маски там, где они дают красивое перекрытие;
-
выигрывать такты строки через формат/число полноэкранных проходов и группировку матриц, а не через визуальное урезание тоннелей;
-
если строка сыпалась в двух отдельных случаях — на двухцепочных уровнях и на уровнях с тоннелями — не выключать анимацию шаров как workaround, если бюджет строки уже возвращён на уровне фона/режима;
-
pause/dialog не должны рисовать поворот шаров вообще.
Урок: если проблема проявляется как срыв строк на реальном FT812, сначала проверять не «сколько пикселей шара видно», а сколько полноэкранных/широких bitmap-pass’ов лежит на той же строке и сколько state changes идёт через DL. Scissor/stencil/mask полезны против fillrate/overdraw, но не являются лекарством от перегруженного бюджета строки.
Глава 21. Per-ball matrix с per-slot hysteresis и grouped emit (2026-05-18)
21.1 Постановка задачи
Шары цепи Zuma вращаются по тангенсу трека: на изгибе спрайт повёрнут так, чтобы рисунок (рельеф/блик) шёл по направлению движения, а не «лежал на боку». На каждый шар нужна BITMAP_TRANSFORM с углом = tangent_at_track[i].
В лоб через FT812 это:
; per ball: 5 coproc-commands → 6 BITMAP_TRANSFORM_X DL entries
CALL ZL_EmitLoadId ; cmd_loadidentity
LD HL, ZL_BALL_HALF
LD DE, ZL_BALL_HALF
CALL ZL_EmitTranslate ; cmd_translate(+16, +16)
LD A, (cache+0)
CALL ZL_EmitRotate ; cmd_rotate(tangent_byte)
LD HL, -ZL_BALL_HALF
LD DE, -ZL_BALL_HALF
CALL ZL_EmitTranslate ; cmd_translate(-16, -16)
CALL ZL_EmitSetMatrix ; cmd_setmatrix
Translate(+16) → Rotate(θ) → Translate(-16) — стандартная связка чтобы повернуть spritе вокруг центра bitmap (16,16) для атласа 32×32, а не вокруг угла (0,0).
Стоимость на цепь 35 шаров: 175 coproc-команд + 210 DL-записей BITMAP_TRANSFORM.
FT812 coproc’у на это не хватает vblank-окна даже на 74Hz → тиринг на реале.
21.2 Альтернатива #1: бакеты — почему не подошло
Классический способ дёшево покрыть N шаров: разбить tangent диапазон 0..255 BRAD на K корзин (buckets), назначить каждому шару ближайшую корзину, и в outer-loop эмитить матрицу 1 раз на корзину, а внутри обходить все шары своей корзины.
матрицы за кадр = K (фиксированно)
DL записи = K × 6 transform + N × (cell + vertex)
K=32 → 11.25° на bucket, ~6× быстрее чем per-ball. Так и было сделано до 2026-05-18.
Проблема: «глобальный flip». Если raw tangent шара трамплинит между двумя бакетами кадр-к-кадру (например, из-за округления track-данных), его поворот скачет на 11.25°. И — что хуже — поскольку соседние шары находятся в одной с ним корзине (общая матрица), они визуально мигают сегментом цепи целиком. Глаз ловит «волну» на изгибах.
Это была реальная жалоба пользователя за всю прошедшую неделю работы.
21.3 Альтернатива #2: чистый per-ball — почему сломалось на реале
Прямой переход к per-ball matrix (для каждого шара свой cmd_setmatrix) убирает
эффект «сегмент мигает» начисто — каждый шар вращается независимо. Visual quality
максимальный.
Но coproc-нагрузка взлетела в ~6 раз. На баре эмуляторе (Unreal x64) кадр строился, на реальном FT812 при 74Hz и DL ≥ 300 записей vblank-окна не хватало: коприйцессор не успевал обработать команды до следующего DLSWAP — экран рвало.
Симптом: верхняя половина — frame N, нижняя — frame N−1, с горизонтальной чертой разрыва. Появляется в самых нагруженных моментах (длинная цепь + жаба + bullet).
21.4 Гибрид: per-slot hysteresis + run-length grouped emit
Идея: хранить tangent per-ball независимо (это уже даёт per-slot stability — flicker нет), но эмитить матрицу только когда у соседних шаров в цепи tangent действительно поменялся.
На спирали Zuma соседние шары цепи находятся на одной дуге трека, поэтому их tangent’ы очень близки. С разумной квантизацией (16 BRAD, на длинной цепи 32 — адаптивно, см. §21.4.2) адъяцентные шары часто попадают в одинаковую дольку — для них достаточно одной матрицы.
21.4.1 Per-slot byte-level hysteresis
Pre-pass для каждого шара хранит свой «стабильный» tangent в page-5 RAM
(#4100 + slot_idx), обновляется только когда raw отличается на ≥ THR=8 BRAD:
; D = raw tangent (preserved). HL = state addr через H = STATE_HI, L = slot.
LD A, (VDC_LastTangent)
LD D, A
LD A, C ; slot index
LD H, ZL_BALL_TANGENT_STATE_ADDR >> 8 ; #41 (low byte STATE_ADDR = 0 заведомо)
LD L, A
LD A, (HL) ; prev stable
LD E, A
LD A, D ; raw
SUB E ; (raw - prev) mod 256
JP P, .stab_pos ; signed sign-bit check
NEG
.stab_pos: CP ZL_BALL_TANGENT_HYSTERESIS_THR ; = 8
JR NC, .stab_update ; |delta| >= THR → update
LD A, E ; else keep prev
JR .stab_done
.stab_update: LD A, D
LD (HL), A
.stab_done: ; A = stable tangent (raw if updated, prev else)
Почему именно 8 BRAD threshold: должен быть ≥ ширине квантизационной корзины (8 BRAD), иначе raw, осциллирующий на границе ±4, заставит stable скакать между двумя бакетами. С 8: stable меняется только если raw уехал заметно в новую область → stable settles в одной корзине.
Почему ёлки H=STATE_ADDR>>8, L=slot (а не LD HL,…+LD DE,slot+ADD HL,DE):
выбрали ZL_BALL_TANGENT_STATE_ADDR=#4100 с low-byte=0 специально, чтобы 8-bit
slot index ставился прямо в L без сложения. Сохраняет регистр D (с raw
tangent) от затирания через LD DE, addr.
21.4.2 Адаптивная квантизация: грубее по мере роста цепи
В per-ball loop квантуем stable tangent к корзине и сравниваем с tangent’ом, для которого мы УЖЕ эмитили матрицу. Совпал — пропускаем эмит матрицы.
Адаптивная грубость по длине цепи (ключевой приём — MainLoop.asm:.PerBallLoop):
ширина корзины зависит от ZL_BallCount (= длина цепи). На короткой цепи запас
DL-бюджета большой → можно квантовать мелко (плавнее поворот); на длинной цепи
бюджет на исходе → квантуем грубее, чтобы число эмитов матриц не росло линейно
с числом шаров:
BallCount < 70→AND #F0= 16 корзин (шаг 16 BRAD = 22.5°);BallCount ≥ 70→AND #E0= 8 корзин (шаг 32 BRAD = 45°) — грубее.
.ChainDraw: LD A, #01 ; sentinel (не кратен 16/32)
LD (ZL_TmpLastTangent), A
LD A, (ZL_BallCount)
LD B, A ; loop count
LD IX, ZL_BALL_CACHE_ADDR
.PerBallLoop: LD A, (IX+1) ; cell (+1) = 0xFF marks gap
CP #FF
JP Z, .PBSkip ; (JR out of range — body grew)
PUSH BC
LD A, (IX+0) ; stable tangent
LD D, A
LD A, (ZL_BallCount)
CP 70 ; адаптивный порог
LD A, D
JR C, .PBQuant16
AND #E0 ; длинная цепь (≥70): 8 корзин — грубее
JR .PBQuantDone
.PBQuant16: AND #F0 ; обычно: 16 корзин
.PBQuantDone: LD HL, ZL_TmpLastTangent
CP (HL)
JR Z, .PBNoMatrix ; та же корзина → переиспользуем матрицу
LD (HL), A ; новая корзина → save
; матрица из ПРЕДРАСЧЁТНОГО LUT (минуя coproc, см. §27.6)
LD A, (ZL_TmpLastTangent)
CALL ZL_EmitBallMatrixFromBRAD
.PBNoMatrix:
; ... handle, cell, vertex2f для текущего шара (без матрицы) ...
Два рычага вместе: (1) предрасчёт — матрица не строится coproc-командами на
лету, а копируется готовой из ZL_ChainMatrixLUT (32×24 байта, генератор
make_chain_matrix_lut.py, см. §27.6); (2) адаптивная группировка — соседи
по дуге попадают в одну корзину, эмит делается раз на корзину, а ширина корзины
растёт с длиной цепи. Итог — число матриц на кадр почти не зависит от длины цепи.
Sentinel #01 гарантирует что первый шар всегда триггерит эмит матрицы:
реальные quantized tangent’ы кратны 16 (или 32), значению 1 никогда не равны.
21.4.3 Что в итоге
На спирали с 35 шарами цепи статистически на цепь приходится ~8–15 уникальных
quantized buckets, и balls внутри bucket’а лежат подряд (соседи по track) →
matrix emit срабатывает ~8–15 раз вместо 35. 3–4× падение coproc-нагрузки.
Адаптивная грубость (§21.4.2) удерживает это число и на длинных цепях: при
BallCount ≥ 70 корзины вдвое шире (8 вместо 16), так что эмитов не больше.
Доводка v030 (§27.6): сам эмит матрицы позже перевели на предрасчётный LUT (
ZL_EmitBallMatrixFromBRAD) — матрица копируется готовой, coproc-команды на построение матрицы больше не тратятся вообще (строкаcoproc-cmd/frameниже относится к реализации 2026-05-18 до LUT).
Метрики (snapshot 2026-05-18, до LUT):
Bucketed (старое) Per-ball naive Per-ball + grouped
matrix/frame 32 35 8-15
coproc-cmd/frame 160 175 40-75
DL entries (chain) 294 315 ~150
flip-flicker YES NO NO
vblank ok @ 74Hz YES NO (tear) YES
21.5 Ловушки реализации
Регистр-сейв (B-clobber)
Helpers FT_BitmapLayout, FT_BitmapSize — макросы, разворачивающиеся в
инлайн через FT_CMD_BUF, который клобает BCDE. Поэтому паттерн «сохрани
цвет в B → emit setup macros → возьми обратно из B» молча даёт мусор:
LD A, (Bullet_Color)
LD B, A ; "save"
... CALL ZL_EmitBallHandle ...
FT_BitmapLayout ... ; ← кладёт B = 0x07 (opcode)
FT_BitmapSize ... ; ← кладёт B = 0x08
LD A, B ; ← А не цвет! → cell wrong
AND 3
CALL Cell ; рисует случайный цвет
Симптом был: жаба стреляет одним цветом, в цепь вставляется другой. Потому
что в памяти Bullet_Color корректный (VDC_InsertAt(Bullet_Color)),
а на экране во время полёта пуля рисовалась мусорным cell.
Фикс: перечитать color из памяти после макросов, не из регистра:
LD A, (Bullet_Color)
CP 4
LD A, 0
JR C, .h0
LD A, 9
.h0: CALL ZL_EmitBallHandle
FT_BitmapLayout ...
FT_BitmapSize ...
LD A, (Bullet_Color) ; re-read — macros clobbered registers
AND 3
ADD A,A : ... *32
CALL Cell
Аналогично для chain draw, но там есть IX → (IX+1) cache pointer — Cell
читаем оттуда, IX через хелперы сохраняется.
Sentinel выбор
ZL_TmpLastTangent инициализируется #01, а не #FF — потому что после
AND #F8 реальные quantized tangent’ы могут быть 0, 8, 16, ..., 248. Значение
#FF после AND F8 даёт #F8 (валидный bucket), и если у первого шара
quantized = 248 = #F8, он бы совпал с sentinel и пропустил matrix emit —
а матрицы ещё нет (FT812 unitialized state) → шар нарисуется с identity matrix.
Sentinel #01 гарантированно не совпадает ни с одним quantized = multiple-of-8.
JR vs JP — out of range
После добавления matrix-skip логики body цикла вырос. JR Z, .PBSkip (2 байта,
±127 диапазон) перестал доставать. Заменил на JP Z, .PBSkip (+1 байт но
absolute address). Уроки прошлых сессий: при росте кода всегда чекать
JR-distances через --lst.
21.6 RNG bias как побочный bug (2026-05-18)
Параллельно с per-ball рефакторингом расширил VDC_NUM_COLORS с 4 до 6 (атлас
уже содержал colors 4-5: white + yellow). Жёлтый не появлялся в цепи СОВСЕМ.
LFSR Galois с polynomial #B400:
LD HL, (VDC_LfsrSeed)
LD A, L
AND 1 ; bit out
SRL H : RR L ; shift HL right
JR Z, .no_xor
LD A, H : XOR #B4 : LD H, A ; feed back via poly
.no_xor:
LD (VDC_LfsrSeed), HL
LD A, L
XOR H ; 8-bit "random"
AND 7 ; → 0..7
CP NUM_COLORS ; reject если >= NUM
JR NC, retry
RET
Скрытая корреляция битов: для конкретного poly #B400, после XOR L⊕H и
маски AND 7 результат покрывает почти исключительно {0,1,3,4}, а значения
{2,5,6,7} встречаются ~1 раз на 1000. Rejection (CP NUM_COLORS) отсекает
6,7 — а 2 и 5 он не лечит. Цвета 2 (фиолетовый) и 5 (жёлтый) выпадают почти
никогда.
Замер на baseline: 1000 вызовов VDC_RandomColor дали [306, 231, 2, 230, 230, 1].
Фикс — mul-then-shift вместо bit-masking:
LD A, L
XOR H ; A = 8-bit raw rand
LD L, A
LD H, 0 ; HL = rand byte
LD A, VDC_NUM_COLORS ; A = 6
CALL ZL_Mul16x8 ; HL = rand * NUM (max 6*255=1530, <16-bit)
LD A, H ; A = (rand * NUM) >> 8 = 0..NUM-1
RET
Принцип: любое значение rand 0..255 распределяется по NUM_COLORS bucket’ов пропорционально размеру bucket’а. Bias ≤ 1.4% даже при равномерном rand, и НЕ требует, чтобы определённые биты были некоррелированы.
После замера: [166, 111, 110, 57, 222, 334] — все 6 цветов появляются.
Дистрибуция всё ещё неравномерна из-за самой неравномерности LFSR-байта,
но колор 5 теперь в игре.
21.7 Применимость в других случаях
Паттерн «per-slot hysteresis + run-length grouped emit» обобщается:
-
Условие применимости: есть много объектов, которым нужно индивидуальное состояние (color, scale, rotation), но в смежных объектах состояние часто одинаково.
-
Шаг 1: state per-object с byte-level hysteresis (storage = N байт RAM, threshold ≥ quantization step).
-
Шаг 2: в draw-loop сравнивай с last-emitted state, пропускай emit при совпадении.
Кандидаты на это в Zuma VDAC2:
-
Spin frame (cell number): соседние шары на одной фазе rolling — сейчас они уже имеют разные cell индексы из-за
t × K, group skip = no-op. -
Bitmap handle 0 vs 9 (для colors 4-5 split): группировать по color group — уже работает (handle меняется только при cell ≥ 128).
Глава 22. Расщепление Core на main0 + main1 (slot 1 + slot 3) и невидимая ловушка CMD_ADDRESS_PTR (2026-05-18)
22.1 Проблема: лимит “считать байты Core” 9216
К v020 в Core (slot 1 page 5, ORG #5C00) набралось 9210 байт из 9216 — 6 байт запаса. Каждая новая фича режется на размере: match-3 explode pass добавили с трудом, шрифт “GAME OVER” вкручен между функциями, ring log переехал в slot 0 ещё раньше. Дальше расти некуда — а впереди 22 уровня + стартовый экран + level select + сжатие данных.
22.2 Архитектура split
В соседнем TS-Conf проекте ~/Desktop/Zuma Deluxe уже работает паттерн
main0 / main1:
| Сегмент | Что | Где | Зачем |
|---|---|---|---|
| main0 | резидент: bootstrap, IM2, paging-helpers, общие helpers | slot 1 page 5 (ORG #5C00, до #7FFF = 9.2K) | всегда в памяти |
| main1 | сценовый код (Init_Video + VDC + Frog + Bullet + MainLoop) | slot 3 page #04 (ORG #C000, до #FFFF = 16K) | можно иметь несколько разных main1 страниц под разные сцены и свапать через SetPage3 |
В VDAC2 main0 на 2026-05-18 ужался до 415 байт (Start + Initialize + Init_Core + Init_Int + INT_Handler), main1_play занял 8795 байт в 16K window’е. Лимит “считать байты Core” снят — теперь есть ~7.5K запаса в main1 и ~8.8K в main0.
; main0 (slot 1 page 5)
ORG EntryPoint ; #5C00
module Core
Start: LD SP, StackTop
CALL Initialize
JP MainLoop ; target #C000+ in slot 3 — works
; once Init_Core sets slot 3 = page 4
Initialize: CALL Init_Core
...
Init_Core: FMapAddrInit
SetPage1 5 ; slot 1 -> main0 page
SetPage2 6 ; slot 2 -> TrackData
SetPage3 #04 ; slot 3 -> main1_play page
RET
Init_Int: ... ; IM2 setup + first INT wait
INT_Handler: EI : RET
Main0_End: ; ОБЯЗАТЕЛЬНО после всего main0 кода
; main1_play (slot 3 page #04)
SLOT 3 : PAGE #04 : ORG #C000
Main1_Start:
include "Init_Video.asm"
include "VDC.asm"
include "Frog.asm"
include "Bullet.asm"
include "MainLoop.asm"
Main1_End:
endmodule
SAVEBIN "Core.bin", Core.Start, Core.Main0_End - Core.Start
SAVEBIN "main1_play.bin", Core.Main1_Start, Core.Main1_End - Core.Main1_Start
В spgbld_vdac2.ini добавляется блок:
Block = #C000, #04, main1_play.bin
22.3 Cross-slot CALL работает без thunks
Z80 видит slot 1 (#4000..#7FFF) и slot 3 (#C000..#FFFF) одновременно — они
разные адресные диапазоны, оба маппятся через TS-Conf mapping регистры.
Когда CALL Init_Video (target #C000+) делается из main0 (#5C00+), Z80
толкает return-addr в стек (стек в slot 1, #40F2) и прыгает в slot 3.
Main1 код выполняется, делает RET — return-addr из стека → возвращаемся в
slot 1. Никаких thunks не нужно, пока сцена одна.
Thunks потребуются позже, когда добавим Title/LevelSelect: тогда main0 будет свапать slot 3 на разные main1-страницы и JP в общую entry-точку сцены.
22.4 Ловушка 1: Main0_End в неправильном месте
В первой попытке split метка Main0_End: стояла сразу после RET функции
Init_Core. Но Init_Int: и INT_Handler: определены ниже в файле —
между Init_Core и SLOT 3 директивой. SAVEBIN "Core.bin", ..., Main0_End - Start
покрыл только #5C00..#5D89 = 393 байта и обрезал Init_Int + INT_Handler.
На железе:
- SPG-loader загружает Core.bin (393 байта) в page 5 → #5C00..#5D89
- Остальная page 5 (#5D89..#7FFF) — нули (initialized)
- Z80 на
CALL Init_Intпрыгает в #5D89 → читает 0 = NOP - NOPит через всю slot 1 → входит в slot 2 (#8000+) = TrackData
- На байте 0xFF в TrackData выполняется
RST 38→ прыжок в #0038 (RST vector) - NOPит из #0038 до #1000 (4040 NOP-ов)
- На #1000 в TSLib живёт функция
SetPage0:(LD (FMADDR_REGS+0x10), A : RET) - A = 8 (последнее значение из upload loops) → slot 0 переключается на page 8 = bg_l4_p01
- TSLib исчезает → следующая инструкция читается из bg-картинки = chaos → виснет
Урок: Main0_End: ВСЕГДА последняя строчка main0 секции. Любая код-строка
ниже неё (но выше SLOT 3 директивы) выпадает из SAVEBIN.
22.5 Ловушка 2: TSLib CMD_ADDRESS_PTR = #C000 молча затирает main1
После исправления Main0_End игра запустилась — frog/bg/cursor видны. Но цепочки шаров не появляются, фрог не стреляет, при попытке выстрела полностью виснет.
В эмуляторе VDC_TrySpawn и Bullet_Spawn работают идеально на изолированных вызовах. Регресс физики PASS. То есть код ОК. Проблема — где-то между кадрами.
Дамп с реального железа показал: содержимое #C000 — это FT812 display list команды (CLEAR_COLOR_RGB, VERTEX_FORMAT, CLEAR), а не main1_play код.
Источник в TSLib:
Docs/TSLib/Include/FT/Coprocessor/Buffer.asm:
ifndef CMD_ADDRESS_PTR
define CMD_ADDRESS_PTR #C000
endif
FT_CMD_Start макрос делает LD HL, CMD_ADDRESS_PTR : LD (BufferPtr), HL.
Каждый FT_CMD_BUF пишет 4 байта на BufferPtr++. За кадр накапливается до
ZL_CMD_WARN_BYTES = #0E00 ≈ 3.5 KB DL-команд поверх main1_play кода.
До split этот буфер тоже жил на #C000, но slot 3 содержал бесполезный bg_l4_p01 (или что было default). Buffer перезаписывал данные, которые никто не читал. Безобидно.
После split slot 3 = main1_play → буфер переписывает VDC_Update, Bullet_*, ZL_DrawFrame и т.д. Первый кадр успевает отрендериться (потому что buffer ещё не достиг этих адресов), второй — VDC функций нет, цепь не спавнится, при выстреле Bullet функция уже мусор, Z80 уходит в random код, виснет.
Fix. Перед include "FT/Coprocessor/Include.inc" в main.asm:
; FT command buffer: TSLib дефолтит на #C000 (slot 3). После
; main0/main1 split main1_play код живёт в slot 3 → буфер
; перекрывает код. Перенесён в slot 1 free area после main0.
define CMD_ADDRESS_PTR #5E00
#5E00 — slot 1 page 5 после main0 (415 байт = ends at #5D9F). До конца
slot 1 (#7FFF) → 8.5 KB запаса, в избытке для 3.5 KB буфера.
22.6 Чек-лист split-проекта
Любой split, в котором код переезжает в slot 3 (#C000+), должен пройти:
-
Main0_End:метка — строго после всего main0 кода, передSLOT 3директивой.SAVEBIN "main0.bin", Start, Main0_End - Startгарантирует что вся main0-логика попадает в bin. -
Override
CMD_ADDRESS_PTR— перед TSLib include-ами. Любое значение в writable RAM области, минимум 4 KB до границы window-а. Не #C000. -
Init_Core SetPage3 на main1-страницу — например
SetPage3 #04если main1_play лежит на page 4 в SPG. -
spgbld_vdac2.iniBlock —Block = #C000, #04, main1_play.bin. -
Cross-slot CALL без thunks — JP MainLoop из main0 в #C000 работает после Init_Core. Внутри одной сцены никаких thunks не нужно.
-
Paged simulator с FMADDR_REGS hook —
zuma_full_z80_emulator.pyловит memory writes в #0410..#0413 (mapping registers) и обновляет pages map. Без этого hook-а эмулятор не воспроизводит SetPage* в режиме MAPPING_REGISTERS. Также полезны: ring buffer 4096 PC + PC watchpoint на #1000 (SetPage0 функция) — за минуты находят misjumps. -
Дамп после F12 — RESET, не data. В Unreal F12 = RESET. Содержимое после F12 не отражает состояние во время виса. Для диагностики hang-а — F12 не подходит, нужен paged simulator или hardware-marker.
22.7 Запас на будущее
После v021 split (2026-05-18):
- main0: 8801 байт свободно (415/9216)
- main1_play: 7589 байт свободно (8795/16384)
-
На каждую новую сцену (Title, LevelSelect, разные уровни) можно выделить свою 16K страницу под main1_
.bin без касания main0 -
FT command buffer 4 KB в slot 1 (#5E00..#7FFF) — запас в 4.5 KB
Следующий шаг: подключение Dzx7Turbo из TS-Conf проекта для сжатия per-level данных (15 страниц bg L4 × 22 уровня = 330 страниц без сжатия, с ZX7 ~245 страниц).
Глава 23. Экономия RAM_G шаров: путь к PALETTED4444 (v025)
23.1 Постановка задачи
Изначальный atlas шаров — 6 цветов × 32 spin-фазы × 32×32 px ARGB4 (2 байта/пиксель) = 384 KB. Из 1 MB RAM_G на FT812 после bg, frog, killzone, destroy, text, cursor, sparkle остаётся ~530 KB. Добавление UI-фрейма (gameinterface) требует ещё ~80 KB, остаётся 65 KB — впритык.
Цель: ужать atlas минимум вдвое, сохранив: 6 цветов, 32 spin-фазы, native цвета из source PNG, per-pixel alpha (anti-aliased силуэт).
23.2 Опции форматов FT812
| Формат | Bytes/px | 6×32×32×32 atlas | Alpha |
|---|---|---|---|
| ARGB4 | 2 | 384 KB | прямой 4-bit per px |
| RGB565 | 2 | 384 KB | нет |
| L8 | 1 | 192 KB | = alpha mask (не intensity!) |
| L4 | 0.5 | 96 KB | = alpha mask |
| PALETTED8 | 1 | 192 KB | 1024B RGBA8 палитра |
| PALETTED565 | 1 | 192 KB | нет |
| PALETTED4444 | 1 | 192 KB | 512B ARGB4 палитра |
L4/L8 на FT812 — alpha mask: output = tint × pixel/255. Это
«силуэт + tint», тело шара теряется. Не годится для многоцветных балов
с собственным shading.
23.3 Неудачные попытки (mid-may 2026)
Попытка 1: MONO ARGB4 + COLOR_RGB tint (64 KB). Один atlas silver-шара (R=G=B=L, A=alpha) + tint per ball. Maya-faces на всех цветах одинаковые (из silver-источника), нет белого specular highlight (silver L_max ≈ 230). Tint не работает для многоцветного shading.
Попытка 2: L8 + tint (32 KB). L8 на FT812 — alpha mask, не intensity. Шары полупрозрачные.
Попытка 3: PALETTED8 с BGRA byte order (192 KB + 1024B). FT812 читает палитру как RGBA, не BGRA. Все шары серые.
Попытка 4: PALETTED8 с RGBA byte order (192 KB + 1024B). На этом конкретном FT812 — по-прежнему серые. PALETTED8 трактуется как L8 (chip-revision quirk). Вывод после 4-х неудач — «PALETTED не работает». Этот вывод был ложным.
23.4 PALETTED4444: три условия
User-инсайт: PALETTED4444 (формат = 15) использует палитру СТРОГО 512 байт (256 × 2 ARGB4). Прошлые попытки попадали в одну из трёх ловушек:
-
Размер палитры ≠ 512. При 1024 (RGBA8) FT812 читает байты 512+ как PIXEL data за палитрой → corruption / hang. При меньшем размере → out-of-range index → hang.
-
Формат записи ≠ ARGB4 LE. Корректно:
python word = (a4 << 12) | (r4 << 8) | (g4 << 4) | b4 # 16-bit LE pal_bytes += word.to_bytes(2, "little")1024-байтная RGBA8 палитра ляжет как 256 пар бессмысленных ARGB4 → все цвета серые. -
Адрес не выровнен на 4 байта. FT_PaletteSource берёт младшие 22 бита; FT812 требует 4-byte aligned. Невыровненный → junk.
23.5 Setup PALETTED4444 (baseline v025)
Palette generation (Python):
fake_q = Image.fromarray(opaque_rgb.reshape(-1, 1, 3), "RGB") .quantize(colors=255, method=Image.Quantize.MEDIANCUT)
pal = bytearray()
pal += b"