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>
This commit is contained in:
2026-06-23 22:41:32 +03:00
parent 1b39a60ff0
commit eab5a2d6ac
6 changed files with 3034 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
# 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-диспетчеризации.