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

165 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-диспетчеризации.