# Атлас Тени — разбор и план Дата: 2026-08-20. Статус: **ЗАКРЫТО.** Ш0-Ш4 сделаны, прогон пользователем на уровнях **4, 5, 6 и 12** — всё корректно. Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража (в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид даёт наложение спрайта на себя со сдвигом; наш пакетный блит такого не умеет, поэтому запекаем результат в отдельный атлас заранее. ## 1. Что делает оригинал (проверено по SDLPoP, не по памяти) `draw_objtable_item`, `seg008.c:1600`: ```c 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`): ```c 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] == 4` → **SHADOW.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 с цветом из x−1 | **нет** | | прозрачен в 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`.