13 KiB
Атлас Тени — разбор и план
Дата: 2026-08-20. Статус: разбор сделан, код не писали.
Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража (в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не умеет, поэтому запекаем результат в отдельный атлас заранее.
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. Два уточнения, которые меняют алгоритм запекания:
blitters_2_or— это НЕ побитовое ИЛИ. В SDLPoP он реализован обычным блитом с colour key = индекс 0 (method_6_blit_img_to_scr,seg009.c:3306:SDL_SetColorKey(image, SDL_TRUE, 0)). То есть первый проход — наш обычный прозрачный блит, один в один.blitters_3_xorработает по 24-битному RGB, а не по индексам палитры (blit_xor,seg009.c:3190: конвертация в 24 бита, затем*p_dest ^= *p_srcпобайтно). Прозрачности у него нет вообще — XOR'ится весь прямоугольник, но прозрачные пиксели спрайта это чёрный 0x000000, а XOR с нулём ничего не меняет.
Отсюда и берётся необходимость СВОЕЙ палитры: XOR двух цветов игровой палитры даёт цвет, которого в ней нет.
2. Единственное место, где запечка отличается от оригинала
XOR идёт по тому, что УЖЕ на экране, то есть результат зависит от фона. Разбор по пикселям показывает, что зависимость узкая:
| пиксель | первый проход | второй проход | зависит от фона? |
|---|---|---|---|
| спрайт непрозрачен в x | закрашен цветом спрайта | XOR с цветом из x−1 | нет |
| прозрачен в x, непрозрачен в x−1 | фон | фон XOR цвет | да |
| прозрачен в обоих | фон | фон | нет (не рисуем) |
То есть от фона зависит только кайма в один пиксель по левым кромкам
силуэта. На чёрном фоне (а Тень почти всегда на нём — уровень 4 у
зеркала, уровень 6, бой на 12-м) фон XOR цвет == цвет, и запечка точна.
На светлом фоне оригинал подкрасит эту кайму, мы — нет.
Это осознанное расхождение, в impl_diff.md при реализации.
3. Замеры на реальных ассетах
Прогон алгоритма по всем кадрам (SDLPoP/data/KID, SDLPoP/data/GUARD):
| набор | кадров | макс. габарит | разных цветов |
|---|---|---|---|
| Кид (chtab_2) | 219 | 56×57 | 44 |
| Страж (chtab_5) | 34 | 53×42 | 16 |
| вместе | 253 | 57×57 (с учётом +1 px сдвига) | 59 |
Всего пикселей во всех кадрах Тени — 96 746. Квантование до N цветов (оставляем N самых частых, остальные — в ближайший):
| палитра | затронуто пикселей | доля | худшая ошибка (евклид RGB) |
|---|---|---|---|
| 16 цветов | 3 483 | 3,60 % | 151 |
| 32 цвета | 426 | 0,44 % | 113 |
Насколько заметна подмена
«Затронуто» само по себе ничего не говорит — важно, НА СКОЛЬКО сместился цвет. Порог различимости на плоской заливке ~30-40 единиц евклида в RGB (максимум возможного — 441). Пробовал три стратегии подбора:
- A — оставить N самых частых цветов ТОЧНО, остальные в ближайший;
- B — взвешенный k-means по всем цветам (двигает вообще все);
- C — гибрид: часть слотов под точные частые, остаток — кластеры хвоста.
| палитра | стратегия | изменено px | заметно (40..90) | сильно (>90) | худшая | средняя |
|---|---|---|---|---|---|---|
| 16 | A самые частые | 3 483 (3,60 %) | 2 342 | 257 | 151 | 57,7 |
| 16 | B k-means | 29 368 (30,4 %) | 1 972 | 256 | 142 | 10,9 |
| 16 | C гибрид 10+6 | 7 255 (7,50 %) | 2 146 | 129 | 142 | 33,7 |
| 32 | A самые частые | 426 (0,44 %) | 205 | 27 | 113 | 46,0 |
| 32 | B k-means | 3 425 (3,54 %) | 133 | 27 | 110 | 9,1 |
| 32 | C гибрид 20+12 | 1 997 (2,06 %) | 133 | 27 | 110 | 16,0 |
Читается так. При 32 цветах стратегия не важна: «сильно» — ровно 27 пикселей на все 253 кадра в любой из трёх, это неустранимые одиночки без близкого соседа (по 1-3 пикселя на кадр, увидеть нельзя). Перцептивно значимого остаётся 232 пикселя из 96 746, то есть примерно один пиксель на кадр. При 16 цветах разница уже есть: гибрид вдвое срезает «сильно» (257 → 129), но заметных всё равно две с лишним тысячи.
Решение: 16 цветов, стратегия C (гибрид 10 + 6)
Числа выше — про пиксели, а решает глаз. Отрендерил одни и те же кадры в трёх видах (точный цвет / 32 / 16) и сравнил в увеличении ×3: отличий не видно. Объяснение в самих числах: перцептивно значимых пикселей при 16 цветах 2 275 на 253 кадра, это ~9 пикселей на кадр при ~1 000 видимых, и они РАССЫПАНЫ по контуру, а не собраны в пятно.
Поэтому берём 16, а не 32: экономим блок палитры (пригодится под будущие наборы — принцесса, визирь, мышь), а разница неразличима.
Но стратегия обязана быть гибридной. При 16 цветах «взять самые частые» вдвое хуже по грубым промахам (257 пикселей против 129), и это единственное место, где выбор стратегии виден. Гибрид: 10 самых частых берём ТОЧНО, оставшиеся 6 слотов отдаём под взвешенные кластеры хвоста.
Если в реальной игре кайма всё же будет резать глаз — переход на 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. Объём
253 кадра против 219 у Кида — по страницам EMM примерно как нынешний
набор Кида (28 страниц), плюс пара на кадры стража. При 215 свободных
страницах на старте (memory sprinter_emm_budget) это не проблема.
Кадры смерти («убитый Кид») пока НЕ вырезаем — экономия несколько страниц, а риск промахнуться мимо нужного кадра реальный: Тень на 12-м уровне умирает.
6. План работ
Ш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», причём обе половины набора (кадры Кида и кадры стража) лежат в
ОДНОМ атласе Тени, так что подмена становится проще нынешней.
Ш4. Проверка в MAME: уровень 4 (Тень у зеркала), уровень 6 (Тень крадёт зелье), уровень 12 (бой с Тенью — там она в боевых кадрах, то есть проверяется вторая половина набора).
7. Что проверить артефактом до Ш3
- Индекс кадра для Тени в боевых кадрах:
frame_tbl_guardадресуется какframe + add_frame - 149(seg006.c:535), то есть у нашего атласа Тени нумерация двух половин должна совпадать с тем, что уже делаетpop_frame_tbl_is_guard. - Цвет стража для Тени: у не-стражей
curr_guard_color = 0(seg002:183), значит запекать половину «кадры стража» надо с палитрой стража цвета 0, а не с текущей.