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
@@ -0,0 +1,275 @@
# Слои отрисовки: как устроен оригинал и чего стоит порт
Разбор 2026-08-13, по `../SDLPoP/src/seg008.c`. Повод — семь дефектов
падающих плит на уровне 13, из которых три оказались не багами кода, а
следствием того, что у нас нет слоя, в котором объекты и куски тайлов
сортируются между собой. Решение по этому документу ещё не принято.
---
## 1. Как это работает в оригинале
### 1.1 Три таблицы, а не «слои»
`draw_tables` (seg008:1373) рисует ровно в таком порядке:
```
restore_peels();
draw_wipes(0);
draw_table(0); // BACKTABLE
draw_table(3); // MIDTABLE
draw_wipes(1);
draw_table(1); // FORETABLE
```
Это грубое разделение на три уровня глубины. Куда попадёт кусок тайла,
решает переменная `ptr_add_table`, которую вызывающий переставляет перед
`draw_tile*`: по умолчанию `add_backtable`, в оверлее кромки —
`add_midtable` (`draw_other_overlay`, seg008:1499), а `add_foretable`
вызывается явно и точечно.
### 1.2 Объекты живут НЕ в таблицах, а в objtable — и привязаны к ТАЙЛУ
Персонажи, падающие куски, мечи, брызги попадают в `objtable`, и у каждой
записи есть поле `tilepos` — тайл, которому объект принадлежит.
Ключевое: объекты рисуются **не отдельным проходом после фона**, а ВНУТРИ
обхода тайлов. В `redraw_needed_tile` (seg008:207) стоит:
```c
if (tile_object_redraw[tilepos]) {
if (tile_object_redraw[tilepos] == 0xFF)
draw_objtable_items_at_tile(tilepos - 1);
draw_objtable_items_at_tile(tilepos);
tile_object_redraw[tilepos] = 0;
}
if (redraw_frames_fore[tilepos]) draw_tile_fore();
```
То есть на каждом тайле: сначала его фоновые куски, потом объекты ЭТОГО
тайла, потом его передние куски.
### 1.3 Порядок глубины складывается из ТРЁХ независимых механизмов
| механизм | что даёт |
|---|---|
| порядок обхода тайлов: ряды **2, 1, 0**, колонки 0..9 (seg008:129) | тайл, обойдённый позже, рисуется поверх |
| сортировка объектов ВНУТРИ одного тайла (`sort_curr_objs`, seg008:1553) | кто из объектов одного тайла поверх кого |
| три таблицы back/mid/fore | грубая глубина для кусков тайлов |
Сортировка внутри тайла (`compare_curr_objs`, seg008:1572) — пузырьком, и
правил в ней три:
```
объект типа 1 (ТЕНЬ) — всегда первым;
оба объекта — падающие плиты (0x80): y1 < y2 → по УБЫВАНИЮ y;
любая другая пара: y1 > y2 → по ВОЗРАСТАНИЮ y.
```
Обратный порядок для пары плит — не описка: две плиты из `loose_fall` летят
в 6 пикселях друг от друга, и верхняя обязана лечь поверх нижней.
### 1.4 Что из этого следует
**«Сортируемый midtable» — неточное имя.** Глобальной сортировки среднего
слоя в оригинале нет. Есть привязка объекта к тайлу и сортировка внутри
тайла; всё остальное решает порядок обхода. Это принципиально дешевле
общей сортировки: объектов на один тайл обычно 1-2.
---
## 2. Что делаем мы
Наш кадр — жёсткая последовательность проходов, без привязки объектов к
тайлам:
```
фон: pop_loose_tick (физика кусков) -> pop_process_trobs -> pop_redraw_needed
-> редрой шва
объекты: pop_loose_mob_draw (куски ПОД Kid, отсортированы по y между собой)
pop_char_draw(OPP/KID) (порядок задаёт guard_over_kid)
pop_loose_mob_draw_over (куски ПОВЕРХ Kid)
перед: pop_fore_over_char — ТОЛЬКО в окне вокруг персонажа
```
Отличия, из которых растут все три оставшихся дефекта:
1. **Объект не знает своего тайла.** Глубина «кусок против Кида» считается
отдельной формулой (`pop_room.c`, поле `defer`), а «кусок против КУСКА
ТАЙЛА» не считается вовсе — куски тайлов рисуются раньше всех объектов.
2. **Передний слой считается только вокруг персонажа.** Это наша
оптимизация (memory `pop_fore_layer_cost`: полный проход стоил 78 %
кадра). Падающая плита в чужом углу комнаты передних частей тайлов
поверх себя не получает — отсюда «плита перед колонной».
3. **Оверлей кромки идёт после персонажей** и потому безусловно поверх
всех, тогда как у оригинала он в midtable и сортируется.
---
## 3. Что затрагивает порт
| участок | объём правки |
|---|---|
| `pop_room.c` — куски | привязать к тайлу, убрать `defer`, убрать собственную сортировку |
| `pop_cdraw.c` — персонажи | то же: объект вместо слота, привязка к тайлу |
| `pop_bg.c``overlay_mid_tile`, `fore_only_tile`, `ceil_over_kid_tile` | вызов из обхода тайлов, а не из отдельного прохода |
| `pop_redraw.c` — пометки | добавить «на этом тайле есть объект» (порт `tile_object_redraw`) |
| `sprpop.c` — главный цикл | вместо трёх проходов один: обход тайлов с объектами внутри |
| окно fore-клипа (`pop_t_fclip_*`) | смысл меняется: клип по тайлу, а не по персонажу |
Плюс новая структура objtable и её сортировка — но маленькая, на тайл.
---
## 4. Плюсы
* **Уходят разом** MOB-CLIP-RIGHT, MID-OVERLAY-LAYER и «плита перед
колонной»: все три — следствие отсутствия привязки к тайлу, а не
самостоятельные баги.
* **Уходят подпорки.** Перерисовка соседнего тайла поверх куска,
`defer`, ручная сортировка кусков, отдельный проход `draw_over`
всё это заменяется одним механизмом.
* **Совпадение с оригиналом по построению.** Дальше любой вопрос «что
поверх чего» решается чтением seg008, а не экспериментом в MAME.
* **Возможный выигрыш по кадру.** Сейчас fore-проход считает окно вокруг
персонажа и всё равно перебирает до девяти тайлов; при привязке к тайлу
передние части рисуются только там, где реально есть помеченный объект.
Но это НАДО ЗАМЕРИТЬ, а не обещать.
---
## 5. Минусы и риски
* **Риск регресса широкий.** Трогается порядок отрисовки ВСЕГО: Кид,
соперник, меч, брызги, зеркало, куски, оверлеи, полоса потолка.
Уровни 1-11 приняты и держатся на текущем порядке.
* **Наша оптимизация fore-окна может не пережить порт в прежнем виде.**
Она даёт 3.2x на самом дорогом проходе (memory `pop_fore_layer_cost`).
Если привязка к тайлу заставит рисовать передние части шире — можно
потерять больше, чем выиграть.
* **Дабл-буфер.** У оригинала один экран с dirty-rect, у нас две страницы
со своими копиями фона и heal. Пометка «на тайле есть объект» обязана
быть счётчиком страниц, как остальные наши пометки, — иначе объект
перерисуется на одной странице и не перерисуется на другой.
* **Банки.** Отрисовка размазана по трём банкам (`pop_bg` 2, `pop_cdraw` 4,
`pop_room` 7) плюс резидент. Единый обход тайлов с объектами внутри
означает, что цикл обхода зовёт код из всех трёх — надо проверить, что не
упрёмся в границы банков и трамплины.
* **Объём.** Это не правка, а этап: сопоставимо с тем, что делалось для
fore-слоя.
---
## 6. Развилки
**A. Полный порт** — objtable с привязкой к тайлу, сортировка внутри тайла,
объекты внутри обхода. Максимально близко к оригиналу, максимальный риск и
объём.
**B. Частичный: только привязать КУСКИ к тайлам.** Персонажей оставить как
есть. Закрывает MOB-CLIP-RIGHT и «плиту перед колонной», не трогает
проверенный порядок персонажей. Дешевле и безопаснее; MID-OVERLAY-LAYER
остаётся.
**C. Отложить** до этапа BG-ONCE и делать вместе — там всё равно
пересматриваются слои, и два пересмотра подряд дороже одного.
Рекомендация: **B или C**. Вариант A целиком оправдан только если мы
одновременно берёмся за BG-ONCE — тогда это один пересмотр слоёв вместо
двух, и замер кадра делается один раз.
---
## 7. ЗАМЕРЫ (сделаны 2026-08-13, уровень 13)
Метод — маркеры-пустышки в РЕЗИДЕНТЕ вокруг измеряемого вызова плюс
брейкпоинты с `printf totalcycles` (memory `z80_profiling_method`). В
банковый код брейкпоинт ставить нельзя: 0xC000 — общее окно всех банков.
| что | такты | доля логического кадра |
|---|---|---|
| логический кадр целиком | **1 289 526** | 100 % |
| `pop_redraw_needed` | **978** | 0,08 % |
| **fore-проход, ОДИН персонаж** | **128 778** | **10 %** |
Плюс два счётчика за прогон ~229 логических кадров:
* fore-проход вызван **10 раз** — на 96 % кадров он не выполняется вовсе
(пропуск неизменившегося персонажа, `pop_char_skip_mask`). Средняя цена
по кадру выходит ~0,4 %, но КАК ТОЛЬКО персонаж движется — платим все 10 %
каждый кадр, и при двух персонажах это ~20 %;
* **максимум объектов на одном тайле = 2** (комната 16, пара из
`loose_fall`: плита сбивает плиту, дальше летят обе). В комнате 23, где
гряда падает в пустоту, максимум 1.
### 7.1 Что эти числа меняют в оценке
**Сортировка внутри тайла — бесплатна.** Два объекта, пузырёк на два
элемента. Возражение против варианта A, которое закладывалось в §5, снято.
**`pop_redraw_needed` можно не считать вовсе.** 978 тактов против 128 778 у
fore-прохода — соотношение 131 к 1.
**Единственный настоящий риск порта — окно клипа fore-прохода.** Если
привязка объектов к тайлам заставит рисовать передние части шире нынешнего
окна вокруг персонажа, мы потеряем 10 % кадра, и потеряем их НА ДВИЖЕНИИ,
когда бюджет и так самый напряжённый.
### 7.2 Насколько узко оригинал помечает передний слой — ВЫЯСНЕНО
Пометки `redraw_frames_fore[]` ставит ровно одна функция — `set_redraw_fore`
(seg007:0550), и зовут её из трёх мест. Ни в одном нет «пометить всё».
**Персонаж — `redraw_at_char` (seg003:0576).** Помечается ПРЯМОУГОЛЬНИК
футпринта:
```c
for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row)
for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col)
set_redraw_fore(get_tilepos(tile_col, tile_row), 1);
```
с двумя уточнениями: при вынутом мече прямоугольник расширяется на колонку в
сторону клинка, а для КИДА берётся объединение с футпринтом ПРОШЛОГО кадра
(`prev_char_*`) — чтобы освободившиеся тайлы тоже вернули свои передние
части.
**Падающий кусок — `draw_mob` (seg007:~1147).** Каждый кадр помечается
СОСЕД СПРАВА (`++tile_col`), и второй тайл, если кусок висит на границе
рядов:
```c
++tile_col;
tilepos = get_tilepos(tile_col, tile_row);
set_redraw2(tilepos, 1);
set_redraw_fore(tilepos, 1);
top_row = y_to_row_mod4(ypos - 18);
if (top_row != tile_row) { ... то же для top_row ... }
add_mob_to_objtable(ypos);
```
**Анимация тайла — `draw_trob` (seg007:01E6):** один тайл.
**Вывод: пометка переднего слоя в оригинале НЕ ШИРЕ нашего окна.** Она
пообъектная — футпринт персонажа и 1-2 тайла на кусок. Значит полный порт
objtable **не отнимает** нашу оптимизацию fore-окна, а формализует её:
вместо «окно вокруг персонажа» будет «тайлы, помеченные объектами», что
как минимум не шире, а для одиночного куска заметно уже.
Риск, вокруг которого крутилась вся оценка, снят.
### 7.3 Побочный результат: готовый рецепт для MOB-CLIP-RIGHT
`draw_mob` даёт точный ответ на вопрос, как оригинал прячет правую часть
куска за соседним полом: он НЕ рисует сосед поверх куска (наша подпорка) и
НЕ полагается только на `clip.right`. Он помечает соседний тайл СРАЗУ
двумя пометками — `set_redraw2` (фон) и `set_redraw_fore` (передний слой).
Дальше порядок делает всё сам: фон соседа рисуется ДО куска, его передние
части — ПОСЛЕ.
Это же закрывает и «плиту перед колонной»: передние части соседнего тайла
(колонна) ложатся поверх куска, потому что тайл помечен.
**Рекомендация после разбора: вариант A (полный порт).** Оба возражения
против него сняты замерами и этим разбором — сортировка внутри тайла
бесплатна (максимум 2 объекта), окно переднего слоя не теряется.