SprPoP: автономное приложение, выделенное из roomtest

Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:12:28 +03:00
parent 4b74478d19
commit 31b82661eb
235 changed files with 51293 additions and 10 deletions
+335
View File
@@ -0,0 +1,335 @@
# Оптимизация отрисовки — что НЕ сделано (замеры на 2026-08-10)
Список отложенных идей с измеренной ценой. Всё измерено брейкпоинтами в
MAME (`z80_profiling_method`) на роомтесте, уровень 1 комната 1.
Прежде чем брать что-то отсюда — перечитать «Как мерить» ниже: половина
прошлых гипотез не подтвердилась, и подтвердились не те, что казались
очевидными.
## Как мерить (иначе цифры не сходятся)
- **Такт `totalcycles` ≠ номинальный T-такт Z80.** У ОЗУ Sprinter
wait-state'ы, замеренная стоимость ≈ **2,4× справочной** (`get_tile`: 574
против 1 422). Считать по таблице тактов нельзя. Подробности —
memory `sprinter_wait_states_2x`.
- **Растровый кадр = 430 000 тактов.** Главный цикл спейсится тремя
`gfx_wait_vsync`, поэтому работа сверх 430 000 стоит СРАЗУ целый лишний
кадр. Граница дискретная: 3 растровых кадра на логический или 4.
- **Адреса символов меняются после КАЖДОЙ пересборки** (`sprpop.map`,
`bank*_*.sym`). Маркер со старым адресом молча не срабатывает, и разбивка
выглядит правдоподобно, но врёт.
- **Сцена между сессиями не воспроизводится точно**: позиция Кида до
пикселя, состояние плиты (2,6), фаза факелов. Сравнивать «до/после» можно
только по ОДНОЙ функции с одинаковыми входами, а не по общей работе за
кадр.
- Кто делит: брейкпоинт на `__divsint`/`__divuint`/`__divuchar` с печатью
адреса возврата — `bpset <addr>,1,{printf "ret=%04X\n",w@(sp); g}`. Если
адрес возврата в банке (>= 0xC000), поставить тот же брейкпоинт с условием
`w@(sp)==<адрес>` и БЕЗ `g`: машина встанет с нужным банком в окне, и
`dasm` покажет вызывающего.
- **Брейкпоинт по адресу в банке ловит ВСЕ банки.** 0xC000..0xFFFF — общее
окно, и один и тот же адрес есть у семи модулей сразу. Либо ловить через
трамплин (`hl==<адрес>&&(de&0xff)==<банк>`), либо перепроверять, что
срабатывания идут из нужной фазы: иначе в интервал попадает чужой код и
цифры врут (так я намерил несуществующие 134 730 тактов в прологе
`pop_char_fore`).
- Трасса вызовов графики с параметрами: скрипт в истории сессии, ставит
маркеры фаз на трамплин `___sdcc_bcall_ehl` (условие `hl==<адрес>&&(de&0xff)==<банк>`)
и брейкпоинты на листья libbgi с печатью аргументов
(`__sdcccall(1)`: arg1 = HL, arg2 = DE, дальше стек с sp+2).
## Профиль на 2026-08-10
Сцена: комната 1, два факела, стража нет.
| сцена | работа за кадр | период |
|---|---|---|
| Кид в покое, факел не задет (пропуск работает) | 244 026 (57 %) | 3 кадра |
| Кид стоит на факеле (перерисовывается каждый кадр) | 366 240 (85 %) | 3 кадра |
| Кид в щебне (2,4), движется | ~357 000 (83 %) | 3 кадра |
| Кид (0,5) в движении | 421 254 (98 %) | **4 кадра** |
Одна перерисовка персонажа = **~137 000 тактов = 32 % растрового кадра**,
из них полезной работы (heal 20×19 + спрайт 12×41) — меньше трети.
## Профиль дворца (уровень 4) на 2026-08-10
Сцена: уровень 4, Кид НЕПОДВИЖНО стоит на (1,7) (комната с решёткой),
стража нет. Разбивка одного логического кадра брейкпоинтами на границах
фаз (адреса `PROF()` из `sprpop.lst` + базы `_CODE = 0x42AD`):
| фаза | тактов | доля работы |
|---|---:|---:|
| ввод + читы + heal | 59 340 | 11 % |
| логика (kid_tick, phys_tick, страж, боёвка) | 85 356 | 16 % |
| `pop_loose_tick` | 27 396 | 5 % |
| `pop_process_trobs` | 106 107 | 21 % |
| `pop_redraw_needed` | 447 | — |
| шов / смена уровня / вспышка | 5 448 | 1 % |
| `guard_over_kid` + `pop_char_skip_mask` | 4 788 | 1 % |
| `pop_char_draw(KID)` | 52 206 | 10 % |
| соперник + `loose_mob_draw_over` + `hp_draw` | 4 086 | 1 % |
| **`pop_char_fore(KID)`** | **171 693** | **33 %** |
| `pop_room_clip_borders` + прочее | 742 | — |
| **ИТОГО работа** | **517 609** | 120 % растрового кадра → период **4 кадра** |
По цветам бордюра: синий (`PROF(2)`) 144 696 (28 %), зелёный (`PROF(4)`)
139 398 (27 %), циан (`PROF(6)`) 233 515 (45 % работы = 54 % растрового
кадра, начинается на 74 % первого кадра и кончается на 123 %).
## СДЕЛАНО 2026-08-10: метка «фон трогали» стала маской ТАЙЛОВ
Было: один union-прямоугольник на страницу. Три факела трогают по пятну
16x18 в колонках 1, 6 и 8, а их объединение — полоса `x 40..280` на всю
комнату; Кид, стоящий где угодно между крайними факелами, в неё попадал и
перерисовывался каждый кадр со всем fore-проходом.
Стало: `uint16_t pop_cd_dmask[2][3]` — бит на колонку, слово на ряд, набор на
страницу. Колонка берётся сдвигом (`x >> 5`), ряд — цепочкой сравнений;
проверка в `cd_quiet` — три `AND` через резидентный `pop_cd_hit`.
Гранулярность тайла — это гранулярность ОРИГИНАЛА: пометки там тоже по
тайлам (`redraw_frames_anim[tilepos]`, `set_wipe`), персонажи привязаны к
тайлу через `tile_object_redraw[tilepos]`, а единственное подтайловое
уточнение (`wipe_heights`) — по высоте, не по ширине. Полутайл (16 px) не дал
бы ничего: пламя рисуется с отступом 8 px и шириной 16, то есть занимает
середину тайла и задевает обе половины.
Замер на той же сцене, где снимался профиль ниже (Кид неподвижно на (1,7)):
| | было | стало |
|---|---:|---:|
| циан (спрайты + fore) | 233 515 | **24 781** |
| работа за кадр | 517 609 | **306 553** |
| период | 4 растровых кадра | **3** |
---
## СДЕЛАНО 2026-08-10: деление в луче видимости стража (Кид у шва)
`tile_at_kid` (`guards.c`) считала колонку честным `/` и `%`, тогда как везде
уже стоит резидентная таблица `POP_TILE_DIV` (это и есть `tile_div_tbl`
оригинала). У SDCC z80 это `__divsint` плюс `__modsint`, а тот внутри снова
зовёт `__divsint` — ~5 400 тактов на вызов.
Зовут её В ЦИКЛЕ по колонкам между стражем и Кидом
(`check_can_guard_see_kid`, seg003:761). Когда Кид стоит У ШВА, его
`curr_col = 1`, луч тянется через всю комнату, и за кадр набегало ВОСЕМЬ пар
делений — около 43 000 тактов, 10 % растрового кадра, в фазе ЛОГИКИ.
Замер: брейкпоинт на `__divsint` с печатью адреса возврата дал `ret=C033`
восемь раз за кадр; остановка на нём и дизассемблирование с правильным банком
показали `HL65`, `ld de,#14`, `call __divsint` по смещению 0x24 банка 1 —
`tile_at_kid`. После фикса пар `C033` не остаётся ни одной.
**Заодно снята ложная тревога.** В прошлом замере я записал, что на шве
пролог `pop_char_fore` разбухает до 134 730 тактов. Это была ошибка зонда:
брейкпоинт на `fore_tile` стоял по адресу, который совпадает с кодом ДРУГИХ
банков, и в интервал попадали чужие срабатывания. Чистый замер (Кид в
колонке 0, спрайт свисает за левый край, окно `x 8..5`): пролог **16 950**
ровно как в середине комнаты, весь fore-проход 41 803, `pop_room_clip_borders`
14 364. Урок в «Как мерить» выше: адрес в банке нужно либо проверять на
уникальность, либо ловить через трамплин с условием на банк.
---
## 0. Дворцовая кладка в fore-проходе — **СДЕЛАНО 2026-08-10**
Все три шага выполнены; замер после — в конце пункта. Ниже сохранён исходный
разбор: он объясняет, почему предфильтра `fore_tile` мало и откуда взялись
габариты кусков.
**Было: 139 863 такта за кадр (32 % растрового кадра), полезных пикселей —
ровно ноль.**
Замер (уровень 4, Кид на (1,7)). Окно fore-клипа в этот момент —
`x 229..241, y 106..147` (прочитано из `pop_t_fclip_*` брейкпоинтом).
`pop_fore_over_char` обходит 6 тайлов:
| тайл | тактов |
|---|---:|
| (0,6) (0,7) (1,6) (1,7) | 2 800 4 600 каждый |
| **(2,6) — стена** | **74 310** |
| **(2,7) — стена** | **65 553** |
Ряд 2 этой комнаты — стена, и он лежит ПОД ногами Кида, то есть попадает в
его fore-окно всегда. Внутри одного тайла стены `wall_pattern_palace`
делает 6 × `wpp_fill` (≈ 3 100 каждый) + 5 × `pop_wall_b` (≈ 9 760 каждый)
≈ 60 000 тактов.
Ни один кусок в окно не попадает:
- верхняя заливка стоит на `dmy - 59 = 157`, окно кончается на `y = 147`;
все остальные куски ещё ниже;
- тайл (2,6) занимает `x 192..223`, окно начинается с `x = 229` — он
промахивается и по горизонтали тоже.
Тайл всё равно проходит, потому что предфильтр в `fore_tile` (pop_bg.c,
`x0 < fclip_x1 && x0 + 40 > fclip_x0 && y0 - 8 < fclip_y1 && y0 + 70 >
fclip_y0`) намеренно грубый — габарит 40×78 на тайл. Дальше `wpp_fill`
честно режет по окну и выходит с пустым прямоугольником, но 3 100 тактов на
арифметику клипа уже потрачены, а `pop_wall_b` о существовании окна не знает
вовсе: он идёт в `atlas_image` + `gfx_w0_map`, читает `w`/`h` и только там
обнаруживает, что рисовать нечего.
Что делать (по возрастанию объёма):
1. **Ранний выход из `wall_pattern_palace`**: самый верхний пиксель узора —
`dmy - 59`, самый нижний — `dby + высота нижнего декаля`. Один
`if (pop_t_fclip_on && (fclip_y1 <= dmy - 59 || fclip_y0 > dby + h))
return;` плюс такая же проверка по `x` убивает оба тайла целиком почти
даром.
2. Прогнать `pop_wall_b` в этом узоре через ту же проверку окна, что уже
есть у `wpp_fill` (нужны размеры кусков — см. п. 2 ниже, «размеры из
каталога атласа»).
3. Сузить сам предфильтр `fore_tile` до реального габарита узора вместо
40×78 — тогда лечится не только дворец.
Порядок в подземелье тот же, но дешевле: `wall_pattern` в подземелье делает
до 3 блитов и ни одной заливки (~29 000 на тайл против ~60 000). Это же
объясняет, почему после перехода на дворцовый тайлсет период вырос.
Сверено с SDLPoP (`seg008.c:1943 wall_pattern`, ветка
`!is_dungeon && GRAPHICS_VGA`): состав узора у нас дословный — 5
`add_wipetable` + 4 декаля + нижняя заливка + нижний декаль. Расходимся не
составом, а тем, что оригинал складывает всё в `foretable` и рисует одним
`draw_table()`, у которого «посетить тайл» стоит копейки (см. п. 6).
### Что сделано и сколько дало
1. **Ранний выход** из `wall_pattern_palace` по окну fore-клипа (узор целиком
в `x [xh*8, xh*8+32)`, `y [dmy59, dby]`).
2. **Отсев каждого декаля** (`wp_blit`) по реальному габариту вместо
заведомо большего 64×64 в `pop_blit_b`. Размеры сняты из каталогов
атласов: `pal_wall.atl` — группы 3..5 = 8×7, 6..8 и 9..11 = 32×12,
12..14 = 30×5, 15..17 = 32×3.
3. **То же для ПОДЗЕМЕЛЬЯ**: ранний выход `wall_pattern` (габарит там выше —
левая марка уходит на `dby+POP_YOFF67`) плюс `wp_blit` на RNDBLOCK
(32×21), обоих разделителях (9×21) и обеих марках (`pop_wall.atl`:
16/17 = 7×10, 14/15 = 14×5).
Замер: уровень 4, комната 18, Кид сдвигается читом `]` по пикселю (skip
выключен, идёт полный путь), в fore-окне ШЕСТЬ тайлов, ТРИ из них —
дворцовая стена.
| участок | тактов |
|---|---:|
| пролог `pop_char_fore` до первого `fore_tile` | 17 208 |
| обычный тайл | 4 000 – 4 600 |
| **тайл стены (было 60 000 74 000)** | **~12 700** |
| хвост + чистка бортов | 6 055 |
| **fore-проход целиком (было 171 693)** | **70 681** |
Кадр целиком в этой сцене: работа **419 839**, период **3** растровых кадра
(было 517 609 и 4).
Остаток в проходе — пролог 17 208, это уже пункт 1 ниже (футпринт из физики).
Отдельная находка: когда Кид стоит НА ШВЕ (окно `x 8..5`), пролог разбухает
до **134 730** — 86 % прохода; причина не разобрана, см. пункт 1.
---
## 1. Футпринт персонажа — брать из физики, а не считать заново
**Цена: 11 574 такта на каждый fore-проход** (от входа в `char_footprint` до
первого `fore_tile`).
`redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ `char_col_left/right`,
`char_top_row`, `char_bottom_row` — их в этом же кадре посчитала физика
(`set_char_collision`, seg006:0723). У нас `char_footprint` (pop_bg.c)
считает их заново внутри fore-прохода.
Мешает то, что физика (банк 3) держит их в статиках, а слой фона — банк 2.
Надо опубликовать их так же, как уже опубликованы `pop_cd[who].fpx/fpy/fpw/fph`.
**Заодно:** оригинал расширяет футпринт ТОЛЬКО на одну колонку при вынутом
мече и объединяет с футпринтом ПРОШЛОГО кадра (`prev_char_col_left/right`).
Мы вместо этого расширяем окном fore-клипа и посещаем 6 тайлов там, где
оригинал посетил бы 4. Разница видна в замере: fore-проход стоит 58 764
там, где реально рисует, и **112 758 там, где не рисует ничего** — вся
разница в числе посещённых тайлов.
Осторожно: окно клипа заводилось под клинок и брызги (они уходят
вперёд-вверх за габарит кадра). Менять — с прогоном боя и падений.
## 2. Размеры ленты — из каталога атласа, а не через окно 0
**Цена: ~750 тактов на `atlas_image` + часть из 6 396 на «чтение w/h и
арифметика клипа», на КАЖДЫЙ блит фона.**
Сейчас `pop_blit_b`, чтобы узнать размер куска, зовёт `atlas_image` (тот
мапит страницу в W3, читает запись каталога, возвращает W3 назад), потом
`gfx_w0_map` и читает `w`/`h` из шапки ленты.
А размеры **уже лежат в каталоге**: запись 8 байт — `offset u16, fw u8,
fh u8, nx u8, ny u8, резерв u16`, и у всех фоновых лент `nx = ny = 1`, то
есть `fw`/`fh` в точности равны `w`/`h` из шапки (проверено по
`pop_env0.atl`). `atlas_image` их читает и выбрасывает.
Вариант A (0 байт памяти): `atlas_image_wh()` рядом с `atlas_image`
вернуть заодно размер.
Вариант B (без маппинга вовсе): снять каталоги при загрузке в резидентную
таблицу. Объём: фон (env0-4 + wall + fore + pot) = **313 лент**, по 2 байта
= **626 Б**; всё вместе с Кидом и стражем = 600 лент = 1200 Б. Свободной
кучи на 2026-08-10 — 2873 Б.
Ожидаемый выигрыш скромный: ~2 000–3 000 из ~16 000 накладных на блит.
## 3. Один `gfx_w0_map`/`unmap` на группу блитов
**Цена: ~5 500 тактов на блит** (unmap плюс возвраты по цепочке
`pop_pot_b``pop_blit_b` → трамплин).
Куски одного прохода часто лежат на одной странице атласа, а мапим и
размапливаем на каждый. Мешает то, что `pop_blit_b` — общий лист для всех
вызывающих; нужна форма «открыть страницу, N блитов, закрыть».
## 4. Единый проход по тайлам вместо трёх
У оригинала за кадр ОДИН обход тайлов — `redraw_needed_tiles` (seg008):
контекст тайла (`curr_tile`, `curr_modifier`, `draw_xh`, `draw_main_y`)
ставится по разу на тайл в `load_curr_and_left_tile`, а `redraw_needed`
смотрит **семь** независимых счётчиков (`wipe_frames`, `redraw_frames_full`,
`redraw_frames_anim`, `redraw_frames2`, `redraw_frames_floor_overlay`,
`redraw_frames_fore`, `tile_object_redraw`) и делает только помеченное.
У нас **три** обхода: `pop_redraw_needed`, `pop_process_trobs` и
`pop_fore_over_char`. Плюс один `kind` на тайл вместо семи счётчиков — две
разные причины перерисовки одного тайла конфликтуют.
Это большой рефакторинг всего слоя фона; браться только если понадобится
ещё заметный запас.
## 5. objtable: персонажи, привязанные к тайлу
Оригинал кладёт персонажей в `objtable` и рисует их в
`draw_objtable_items_at_tile(tilepos)` во время обхода тайлов — порядок
окклюзии получается сам. У нас отдельный fore-проход НА КАЖДОГО персонажа.
Со вторым персонажем (страж) цена удваивается.
## 6. Отложенные таблицы back/mid/fore
`add_backtable`/`add_midtable`/`add_foretable` только КЛАДУТ запись в массив,
рисование — один `draw_table()` в конце. Поэтому «посетить тайл» у
оригинала стоит копейки. У нас блит идёт сразу из обхода.
## 7. Мелочи с известной ценой
| что | цена | где |
|---|---|---|
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки тайла над головой | 8 892 | `pop_cdraw.c` / `pop_map.c` |
| `pop_loose_tick` при полном отсутствии падающих плит в комнате | 27 438 | `pop_map.c` |
| `obj_x * 8 / 7` — единственное оставшееся `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
## Что уже проверено и НЕ сработало
- **Маска «у тайла есть передний слой» (`FORE_ANY`) + контекст тайла один
раз.** Сделано (коммит `a9f4521`), эффект **нулевой**: в футпринте Кида
тайлы почти всегда С передним слоем, а снятое второе чтение кода съедено
проверкой маски. Оставлено как сближение с оригиналом.
- **«Быстрый путь для окна коллизии целиком внутри комнаты».** Не
срабатывал почти никогда: Кид в колонке 0 даёт окно с −1. Заменён на
разбиение окна на непрерывные пробеги.
- **Флаг «фон трогали» вместо позиционной метки** — нулевой выигрыш,
факелы гасили пропуск для всех сразу (см. `pop_cdraw.h`).