# mdview2 — план: рендер-кэш вместо живого парсинга на каждый скролл Контекст и обоснование — см. обсуждение 2026-06-23 (после оптимизации mdview через ``, см. `mdview-модель-документа-и-рендеринг.md`). Идея: вместо того чтобы при каждом скролле повторно идти в файл (`fb()`, W3-банкинг) и гонять markdown inline-парсер (`handle_inline_marker`/emphasis state machine), один раз отрендерить каждую логическую строку в готовый байтовый буфер и дальше выводить его на экран напрямую — без парсинга, без обращения к исходному файлу. Ключевые подтверждённые факты (эмпирически в MAME, не из документации): - **Формат буфера ESTEX `WINCOPY`(59h)/`WINREST`(5Ah)** — пара байт `(char, attr)` на ячейку, по строкам, шаг строки = `width*2`, без паддинга. Можно генерировать самим, не вызывая `WINCOPY`. `WINREST` копирует сразу `H` строк одним вызовом. Детали ABI и регистров — см. `tests/winrest/winrest.c`. - **Бюджет EMM**: 256 страниц (4 МБ) total, 215 (3440 КБ) free на старте программы. Файл+индекс в худшем случае (128 КБ файл) съедают 16 страниц (256 КБ) — остаётся ≈199 страниц (3184 КБ). Кэш всего документа целиком (даже худший случай: 16384 строк × 160 байт = 2.6 МБ) укладывается без LRU/частичного кэша. ## Единый формат строки (пересмотрено 2026-06-23) Исходно планировалось два типа строк (тип 1 — многоцветные, помещающиеся; тип 2 — nowrap/code, один стиль, чистый текст для экономии памяти). От этого деления отказались: строки таблиц (`IF_NOWRAP`, но не `IF_CODE`) всё равно проходят inline-парсер и могут содержать несколько атрибутов (bold/italic в ячейках) — предположение "один стиль" для них неверно. Бюджет EMM ([[sprinter_emm_budget]]) с большим запасом покрывает (char,attr)-формат для ВСЕХ строк без исключения, поэтому усложнение не оправдано. **Единый формат**: кэш-запись = `len` пар `(char,attr)` — реальная длина контента в ячейках, без паддинга до 80, капается на `MAX_CACHE_LINE_LEN`=255. Вывод: `bios_fillcharattr(' ', base_attr, SCREEN_W)` (очистить строку) → `win_rest(row, left_margin, 1, len, page)` (контент). Для широких nowrap-строк горизонтальный скролл (Фаза 5) — это просто смещение НАЧАЛА среза внутри ТОГО ЖЕ (char,attr)-буфера на `hscroll*2` байт, тот же `win_rest`, без отдельного плain-текстового формата и без необходимости на лету "разворачивать" текст+ атрибут в пары. ## Фазы реализации ### Фаза 0 — скаффолдинг `examples/mdview2/` [СДЕЛАНО 2026-06-23] Новая директория со своим `Makefile`/`app.mk` (по аналогии с `mdview/`, не модифицируем `mdview.c`). `mdview2.c` — копия `mdview.c` без изменений логики (только usage-строка/заголовок комментария). Собирается чисто, дискета собрана. ### Фаза 1 — формат кэша и директория строк [СДЕЛАНО и ЗАКРЫТО 2026-06-23] Реализовано в `mdview2.c`: - `cache_rec_t` (РОВНО 8 байт: `page`, `off` (uint16_t), `len`, `flags`, `reserved`, `pad[2]`; размер задаётся `CACHE_DIR_REC_SIZE = sizeof(cache_rec_t)`, НЕ хардкодом — см. разобранный инцидент ниже) — **отдельная** директория (`cache_dir_phys[]`/`cache_dir_get`/`cache_dir_put`), своя ёмкость = `file_pages+1` страниц (как у индекса). **Важное уточнение к исходному тексту плана ниже**: директория НЕ переиспользует слоты `idx_rec_t` in-place — рендер-воркер (Фаза 2) при обработке строки N может заглядывать в idx-записи СОСЕДНИХ строк (откат cont-сегментов, line_idx+1 для границы сегмента); если бы строка N-1 была перезатёрта сразу после своего рендера, воркер строки N прочитал бы уже не исходные off/flags, а указатель в кэш — поломав откат. Бюджет EMM ([[sprinter_emm_budget]]) позволяет отдельный массив — он безопаснее. - Пул контента — отдельные EMM-страницы (`cache_content_phys[]`), ленивый рост по 1 странице в `cache_reserve()` (bump-allocator, запись никогда не разбивается через границу страницы; длина капается на `MAX_CACHE_LINE_LEN`=255 ячеек). - `cache_commit()` = один `bank_write()` на строку (буфер строки собирается локально в W1/W2 заранее, не в W3 — иначе конфликт с `fb()` при чтении исходника во время рендера). - `win_rest()` (ESTEX WINREST 5Ah) — вывод готового буфера на экран. - `phase1_selftest()` — самопроверка (резервирует/коммитит 4 тестовые ячейки, кладёт запись в директорию по индексу 0, рисует через `win_rest` дважды подряд с координатами через параметры функции) — убрать в Фазе 2. **Подтверждено визуально в MAME (2026-06-23)**: оба квадрата 2×2 на месте, без единой задержки, с координатами через переменные — Фаза 1 полностью закрыта. **Разобранный инцидент (НЕ платформенный квирк)**: по пути долго казалось, что `win_rest()` после "всплеска" обычных BIOS print-вызовов рисует буфер в неправильном месте, причём воспроизводилось только когда `row`/`col` приходили через переменные, а не как константы — это и было ключом. Настоящая причина: `cache_rec_t` фактически занимал 7 байт (SDCC z80 не паддит структуры), а `CACHE_DIR_REC_SIZE` был захардкожен как 8 — `bank_read`/`bank_write` копировали 8 байт в 7-байтный буфер на стеке, затирая соседнюю переменную (параметр `col`) вызывающей функции. Фикс — `cache_rec_t` явно до 8 байт + размер через `sizeof()`. Полная история и общий урок — [[sprinter_winrest_format]] и [[defer_unexplained_quirks]]. ### Фаза 2 — рендер-воркер (бывший `render_line`) [СДЕЛАНО 2026-06-23] `render_line_to_cache(line_idx)` — адаптация `render_line()`: та же классификация строк/префиксов/inline-парсинг (`handle_inline_marker`, emphasis state machine, soft-wrap join) БЕЗ ИЗМЕНЕНИЙ, но вместо `bios_writeattr`/`flush_run`/`wrchar`/ `bios_fillcharattr` на экран — пишем `(char,attr)` пары через `cc_put`/`cc_fill` в локальный `cellbuf` (без батчинга через `runbuf`/`flush_run` — тот паттерн был нужен только чтобы минимизировать число BIOS-вызовов, при записи в локальный буфер смысла нет, пишем посимвольно сразу по месту), затем ОДНИМ `cache_reserve()`+`cache_commit()`+`cache_dir_put()` коммитим всю строку. Кэшируется ПОЛНЫЙ контент строки до `MAX_CACHE_LINE_LEN`, без обрезки по `SCREEN_W` и без среза по `viewport_x` (view/scroll-time понятия, Фаза 4-5). Также найден и исправлен по ходу баг в `win_rest()`: `IX` всегда указывал на начало страницы (`#0xC000`), полностью игнорируя `off` — работало только в Фазе 1, где в кэше была ровно одна запись со смещением 0; как только Фаза 2 начала пакетировать много строк на одной странице с разными `off`, все строки стали читаться с начала страницы. Фикс: `win_rest()` принимает `off` (uint16_t), `IX = 0xC000 + off` (новая раскладка ABI сверена через `sdcc -S` с непустым телом — у `__naked` с пустым телом SDCC не генерирует код доступа к параметрам, нужен пробный non-naked враппер). Подтверждено визуально в MAME. ### Фаза 3 — фоновый пре-рендер с прогрессом [СДЕЛАНО 2026-06-23] `emit_seg()` вызывает `render_line_to_cache()` **interleaved** с построением индекса — но с отставанием на одну строку: рендер строки N требует уже существующей idx-записи N+1 (источник `seg_end`), которой ещё нет в момент, когда строка N только создана. Поэтому `emit_seg()` для новой строки рендерит ПРЕДЫДУЩУЮ (`n_lines-2` после инкремента) — её флаги (`IF_NOWRAP`/`IF_CODE`/ `IF_BLANK`) к этому моменту уже дописаны вызовом `set_*_cur()` на предыдущей итерации `index_lines()`. Последнюю строку файла (у которой "следующей" не будет) дорендеривает сам `index_lines()` после выхода из цикла, как и `render_line()` делал для последней строки при живом рендере (`seg_end = file_size`). Требует, чтобы `cache_dir_phys[]` был выделен ДО `index_lines()` — это уже так (`load_file()` выделяет директорию кэша, затем вызывается `index_lines()`). **Важное сужение скоупа относительно исходного текста плана ниже**: пункты "спиннер крутится, пока `rendered_up_to < n_lines`" и "скролл ограничен диапазоном `[0, rendered_up_to]`" **не реализованы и не нужны** в этой архитектуре — рендеринг происходит СИНХРОННО внутри той же однопроходной `index_lines()`, без событийного цикла во время загрузки; пользователь физически не может начать скроллить, пока `index_lines()` не вернёт управление, а к этому моменту весь документ уже полностью в кэше. Спиннер из `index_lines()` (каждые 16 строк) сохранён как есть — он покрывает индексацию+рендер вместе, отдельный прогресс-индикатор не нужен. ### Фаза 4 — cache-only draw path при скролле Цикл перерисовки видимой области (после пре-рендера) идёт **только** по директории строк → `win_rest` (тип 1) или срез текста+`bios_writeattr` (тип 2). Никаких обращений к `fb()`/исходному файлу в steady-state скролле. ### Фаза 5 — горизонтальный скролл для широких nowrap-строк Срез ТОГО ЖЕ (char,attr)-кэш-буфера по текущему `hscroll`-офсету (смещение начала на `hscroll*2` байт внутри буфера строки), `win_rest(row, col, 1, visible_len, page)` с новым `off`. Никакого отдельного плоско-текстового формата не нужно (см. пересмотр "Единый формат строки" выше). ### Фаза 6 — тестирование в MAME Тот же набор паттернов, что использовался для mdview1 (заголовки/списки/цитаты/ code-block/bold-italic/nowrap-обрезка), ПЛЮС: реальный замер EMM на большом документе (не синтетика — проверить, что бюджет из [[sprinter_emm_budget]] действительно держится на чём-то близком к 128 КБ); UX пре-рендера (спиннер + ограничение скролла, отсутствие "дыр" в недорендеренной области); проверка `win_rest` на РЕАЛЬНОМ отрендеренном контенте (не только синтетический A/B/C/D тест из `tests/winrest`). ## Открытые вопросы (решить по ходу, не блокируют старт Фазы 0) - ~~Хранение длины строки в директории~~ — решено в Фазе 1: поле `len` (uint8_t) прямо в `cache_rec_t`. - ~~Деление строк на тип 1/тип 2~~ — отказались (см. "Единый формат строки" выше): единый (char,attr)-формат для всех строк, капается на `MAX_CACHE_LINE_LEN`=255 ячеек (с тем же индикатором обрезки на этапе вывода, что уже есть в рендере для nowrap-строк). - Освобождать ли страницы исходного файла после того, как все его строки отрендерены (вернуть EMM в пул) — даёт больше места про запас, но усложняет (нужна гарантия, что назад к файлу обращаться больше не придётся — а это не так, если позже добавится поиск по тексту). Не делать в v1. - BIOS-вариант `WIN_COPY_WIN`/`WIN_RESTORE_WIN` (0B2h/0B3h, RST 8) не проверен (см. [[sprinter_winrest_format]]) — ESTEX-варианта достаточно для v1, проверять BIOS-вариант только если понадобится экономия на RST-диспетчеризации.