Files
Sprinter-SDCC/examples/mdview2/docs/mdview2-plan.md
T
snark13 eab5a2d6ac mdview2: add render-cache markdown viewer (Phases 0-4)
New examples/mdview2 — render-cache version of mdview: each logical
line is rendered into an EMM (char,attr) buffer once during file load
(interleaved with index_lines()), then scrolling draws straight from
the cache via ESTEX WINREST, with no re-parsing or fb() access in the
steady state. Horizontal scroll still uses the old live-render path
(Phase 5, not yet migrated).

Format and budget were derisked empirically first (tests/winrest):
confirmed ESTEX WINCOPY/WINREST buffer layout and measured EMM free
space, both folded into the implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:41:32 +03:00

16 KiB
Raw Blame History

mdview2 — план: рендер-кэш вместо живого парсинга на каждый скролл

Контекст и обоснование — см. обсуждение 2026-06-23 (после оптимизации mdview через <bios/text.h>, см. 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-диспетчеризации.