Files
Sprinter-SDCC/applications/SprPoP/docs/midtable_analysis.md
T
snark13 31b82661eb 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>
2026-08-27 12:12:28 +03:00

17 KiB
Raw Blame History

Слои отрисовки: как устроен оригинал и чего стоит порт

Разбор 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) стоит:

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.coverlay_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). Помечается ПРЯМОУГОЛЬНИК футпринта:

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), и второй тайл, если кусок висит на границе рядов:

++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 объекта), окно переднего слоя не теряется.