Files
Sprinter-SDCC/applications/PoP/docs/shadow_atlas_plan.md
T

18 KiB
Raw Blame History

Атлас Тени — разбор и план

Дата: 2026-08-20. Статус: ЗАКРЫТО. Ш0-Ш4 сделаны, прогон пользователем на уровнях 4, 5, 6 и 12 — всё корректно.

Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража (в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не умеет, поэтому запекаем результат в отдельный атлас заранее.

1. Что делает оригинал (проверено по SDLPoP, не по памяти)

draw_objtable_item, seg008.c:1600:

case 1: // shadow
    add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl,     obj_y, blitters_2_or,  1);
    add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);

Тот же самый спрайт кладётся ДВАЖДЫ: первый проход в x, второй в x+1. Два уточнения, которые меняют алгоритм запекания:

  1. blitters_2_or — это НЕ побитовое ИЛИ. В SDLPoP он реализован обычным блитом с colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306: SDL_SetColorKey(image, SDL_TRUE, 0)). То есть первый проход — наш обычный прозрачный блит, один в один.
  2. blitters_3_xor работает по 24-битному RGB, а не по индексам палитры (blit_xor, seg009.c:3190: конвертация в 24 бита, затем *p_dest ^= *p_src побайтно). Прозрачности у него нет вообще — XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это чёрный 0x000000, а XOR с нулём ничего не меняет.

Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой палитры даёт цвет, которого в ней нет.

Каким набором спрайтов рисуется Тень (проверено 2026-08-20)

Сначала я решил, что в боевых кадрах Тень рисуется спрайтами СТРАЖА, и записал это в план. Это было неверно, поправка ниже.

Набор выбирает НЕ charid. load_frame_to_obj (seg008.c:1752):

word chtab_base = id_chtab_2_kid;              // жёстко Кид
obj_chtab = chtab_base + (cur_frame.sword >> 6);   // старшие 2 бита кадра

то есть набор берётся из САМИХ ДАННЫХ КАДРА, поле sword: младшие 6 бит — картинка меча, старшие два — смещение chtab относительно Кида (types.h:361). Проверил обе таблицы:

  • frame_table_kidвсе 241 кадра имеют sword & 0xC0 == 0 → chtab_2. Значит Кид всегда рисуется своими спрайтами, боевые кадры 150..189 не исключение;
  • frame_tbl_guard — все кадры имеют 0xC0 → chtab_5.

Тень берёт frame_tbl_guard для кадров 150..189 (seg006.c:533), значит в бою она идёт через chtab_5. Но chtab_5 — это не «страж», это «соперник уровня»: он грузится из tbl_guard_dat[tbl_guard_type[уровень]] (seg000.c:1117), а tbl_guard_type[12] == 4SHADOW.DAT.

Открыл SHADOW.DAT: его палитра побайтно равна палитре Кида (у GUARD.DAT там серая рампа под перекраску), а спрайты — Кид в боевых позах, не страж. Отрендерил тройками «Кид / SHADOW.DAT / GUARD.DAT» для кадров 151/153/158/161/167: первые две колонки — один и тот же персонаж, третья — серый страж в тюрбане.

Вывод: Тень ВСЕГДА выглядит Кидом, и ощущение пользователя верно. Спрайты при этом лежат в двух файлах: не-боевые кадры в chtab_2 (KID), боевые — в chtab_5, заполненном SHADOW.DAT.

Можно ли взять для боя собственные кадры Кида

Механически да: frame_table_kid покрывает 150..189 со своей геометрией, и хватило бы снять спецветку для charid_1_shadow в load_frame. Но кадры НЕ совпадают: из 34 боевых у 31 отличается габарит (на 1-6 px), у 3 отличаются пиксели. Это разная графика, а не одна и та же в двух файлах. Поэтому берём SHADOW.DAT — он и есть «кадры Кида для Тени», подготовленные авторами.

2. Единственное место, где запечка отличается от оригинала

XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона. Разбор по пикселям показывает, что зависимость узкая:

пиксель первый проход второй проход зависит от фона?
спрайт непрозрачен в x закрашен цветом спрайта XOR с цветом из x1 нет
прозрачен в x, непрозрачен в x−1 фон фон XOR цвет да
прозрачен в обоих фон фон нет (не рисуем)

То есть от фона зависит только кайма в один пиксель по левым кромкам силуэта. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у зеркала, уровень 6, бой на 12-м) фон XOR цвет == цвет, и запечка точна. На светлом фоне оригинал подкрасит эту кайму, мы — нет.

Это осознанное расхождение, в impl_diff.md при реализации.

3. Замеры на реальных ассетах

Прогон алгоритма по правильным наборам (SDLPoP/data/KID + SDLPoP/data/SHADOW):

набор кадров макс. габарит разных цветов
Кид (chtab_2) 219 56×57 44
SHADOW.DAT (chtab_5 на ур. 12) 32 49×38 22
вместе 251 57×57 (с учётом +1 px сдвига) 45

Цветов вместе почти столько же, сколько у одного Кида: SHADOW.DAT сидит на той же палитре, новых сочетаний XOR почти не даёт. Всего пикселей во всех кадрах Тени — 91 045.

Насколько заметна подмена

«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB (максимум возможного — 441). Пробовал три стратегии подбора:

  • A — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
  • B — взвешенный k-means по всем цветам (двигает вообще все);
  • C — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
палитра стратегия изменено px заметно (40..90) сильно (>90) худшая
16 A самые частые 959 (1,05 %) 569 381 128
16 C гибрид 5+11 6 984 (7,67 %) 1 078 76 114
32 A самые частые 50 (0,05 %) 40 4 113
32 C гибрид 28+4 73 (0,08 %) 58 0 90

Читается так. При 32 цветах всё практически идеально: 73 пикселя на 251 кадр, грубых промахов нет вовсе. При 16 цветах выбор стратегии виден: «самые частые» трогает меньше пикселей (959), но 381 из них уезжает СИЛЬНО; гибрид размазывает ошибку — грубых остаётся 76, то есть примерно 0,3 пикселя на кадр.

Решение: 16 цветов, стратегия C (гибрид 5 + 11)

Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: отличий не видно. Объяснение в самих числах: перцептивно значимых пикселей при 16 цветах гибридом — 1 154 на 251 кадр, это ~4,6 пикселя на кадр при ~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.

Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие наборы — принцесса, визирь, мышь), а разница неразличима.

Но стратегия обязана быть гибридной. При 16 цветах «взять самые частые» впятеро хуже по грубым промахам (381 пиксель против 76), и это единственное место, где выбор стратегии виден. Гибрид: 5 самых частых берём ТОЧНО, оставшиеся 11 слотов отдаём под взвешенные кластеры хвоста.

Если в реальной игре кайма всё же будет резать глаз — переход на 32 цвета это одна константа в упаковщике и один блок палитры, данные не меняются.

4. Палитра: что занято и куда класть

блок кто примечание
0x30..0x3F VGA-16 общая; из неё цвет вспышки, пузырьки зелий, отладочная метка
0x40..0x4F chtab_1 зелья, пламя
0x50..0x5F env тайлсета меняется подземелье/дворец
0x60..0x6F wall тайлсета меняется подземелье/дворец
0x70..0x7F chtab_2 Кид
0x80..0x8F chtab_0 меч в руке
0x90..0x9F chtab_5 страж, перезаливается цветом стража

Занято 112 слотов из 256, свободно 144 — девять выровненных блоков по 16. Оговорки: 0xFF в наших атласах это маркер прозрачности, а запись 0 правит flash_bg, так что блоки 0x00 и 0xF0 лучше не трогать.

Берём 0xA0..0xAF (16 слотов) — сразу за стражем, персонажи остаются сгруппированы, а блок 0xB0 остаётся свободным (под 32 цвета Тени, если понадобится, или под будущие наборы).

Важно: в наших атласах прозрачность кодируется байтом 0xFF, а исходный индекс 0 в них означает «прозрачно». У Тени чёрный — настоящий цвет (это XOR-погашенная середина силуэта, 51 % всех её пикселей), поэтому у неё маппинг свой: «нет пикселя» → 0xFF, цвет k → 0xA0 + k, и слот 0xA0 = чёрный НЕПРОЗРАЧНЫЙ.

5. Объём

251 кадр против 219 у Кида — по страницам EMM примерно как нынешний набор Кида (28 страниц), плюс пара на кадры из SHADOW.DAT. При 215 свободных страницах на старте (memory sprinter_emm_budget) это не проблема.

Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м уровне умирает.

6. План работ

Ш0. Довезти SHADOW.DAT. Его у нас нет вовсе (см. BUG-SHADOW-SET) — добавить каталог ассетов и правило в Makefile рядом с GUARD/SKEL/VIZIER.

Ш1. Упаковщик toolchain/pop_pack_shadow.py: прогнать оба набора через алгоритм §1, собрать 16-цветную палитру гибридом (10 точных + 6 кластеров хвоста), выдать poc/res/shadow/shadow0..N.atl + shadow.pal + pop_shadow_atlas.h. Критерий: предпросмотр PNG совпадает с видом Тени в SDLPoP.

Ш2. Загрузка: pop_shadow_load() рядом с pop_kid_load, палитра в 0xA0..0xAF, и обе страницы дабл-буфера (как bg_load_tile_pal). Грузить ЛЕНИВО — только когда на уровне есть Тень (4, 5, 6, 12), иначе 28 страниц EMM висят зря.

Ш3. Отрисовка: одно место — pop_cdraw.c:551..568, где выбирается pages. Сейчас там для соперника берётся gp, а для не-боевых кадров Тени подменяется на kidp; станет «charid == CHARID_1_SHADOW → shadowp» БЕЗ подмены: обе половины (кадры Кида и кадры SHADOW.DAT) лежат в ОДНОМ атласе Тени, так что ветка становится проще нынешней.

Ш4. Проверка в MAME: уровень 4 (Тень у зеркала), уровень 6 (Тень крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то есть проверяется вторая половина набора).

7. Что проверить артефактом до Ш3

  1. Индекс кадра для Тени в боевых кадрах: frame_tbl_guard адресуется как frame + add_frame - 149 (seg006.c:535), то есть у нашего атласа Тени нумерация двух половин должна совпадать с тем, что уже делает pop_frame_tbl_is_guard.
  2. Перекраска Тени НЕ нужна: SHADOW.DAT приходит уже в палитре Кида, а curr_guard_color у не-стражей равен 0 (seg002:183). Ветку pop_guard_set_palette для типа 4 звать нельзя — она затрёт палитру Тени палитрой стража.

8. Как сделали (2026-08-20)

  • toolchain/pop_pack_shadow.py — запекает обе половины, собирает 16-цветную палитру гибридом (получилось 5 частых точно + 11 кластеров хвоста), пишет poc/res/shadow/sk0..27.atl, sf0..3.atl и roomtest/pop_shadow_atlas.h. Итог: 251 спрайт, 32 EMM-страницы, 225 178 Б.
  • roomtest/pop_shadow.c/.h (банк 8) — загрузка. Грузим один раз при старте, а не по уровням: страниц EMM с запасом, а забыть перезагрузку на границе легко — ровно так и появился BUG-SHADOW-SET.
  • pop_cdraw.c — одна ветка выбора атласа; заодно брызги урона теперь берут атлас отдельным указателем (spl), потому что у оригинала они всегда из «своего» chtab, а у Тени кадр может прийти из другой половины набора.

Грабля, стоившая одного прогона

Набор загрузился, силуэт нарисовался правильной формы — и целиком чёрный. Причина: gfx_pal_fload("KID\\kid.pal") заливает ВСЕ 256 записей палитры и затирает любые слоты, выставленные до него. Ровно та же беда уже была с тайлсетом — сразу за этим вызовом стоит pop_bg_pal_apply. Поэтому палитра Тени вынесена в отдельный pop_shadow_pal_apply() и зовётся там же, а не внутри загрузки атласов.

Правило на будущее: любой новый набор палитровых слотов красится ПОСЛЕ kid.pal, рядом с pop_bg_pal_apply.

Проверено

Уровни 4 (рождение из зеркала), 5 (кража зелья), 6 (прыжок через пропасть) и 12 (бой — там работает вторая половина набора, sf*) — прогон пользователя 2026-08-20, расхождений не найдено.

Расхождение из §2 (кайма в один пиксель по левым кромкам на НЕчёрном фоне) записано в impl_diff.md.