diff --git a/applications/PoP/docs/shadow_atlas_plan.md b/applications/PoP/docs/shadow_atlas_plan.md
index c4a12a5..59d97fa 100644
--- a/applications/PoP/docs/shadow_atlas_plan.md
+++ b/applications/PoP/docs/shadow_atlas_plan.md
@@ -1,6 +1,7 @@
# Атлас Тени — разбор и план
-Дата: 2026-08-20. Статус: **разбор сделан, код не писали.**
+Дата: 2026-08-20. Статус: **СДЕЛАНО** (Ш0-Ш3), проверено на 6-м уровне;
+уровни 4 и 12 ещё не прогонялись.
Задача: сейчас Тень рисуется спрайтами Кида (вне боя) и спрайтами стража
(в бою) как есть, поэтому от Кида она не отличается. В оригинале её вид
@@ -225,3 +226,39 @@ XOR идёт по тому, что УЖЕ на экране, то есть ре
`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 (рождение из зеркала) и 12 (бой — там работает
+ вторая половина набора, `sf*`);
+- записать в `impl_diff.md` расхождение из §2 (кайма в один пиксель по
+ левым кромкам на НЕчёрном фоне).
diff --git a/applications/PoP/poc/res/shadow/sf0.atl b/applications/PoP/poc/res/shadow/sf0.atl
new file mode 100644
index 0000000..313de95
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sf0.atl differ
diff --git a/applications/PoP/poc/res/shadow/sf1.atl b/applications/PoP/poc/res/shadow/sf1.atl
new file mode 100644
index 0000000..daccba6
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sf1.atl differ
diff --git a/applications/PoP/poc/res/shadow/sf2.atl b/applications/PoP/poc/res/shadow/sf2.atl
new file mode 100644
index 0000000..1f0b6d4
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sf2.atl differ
diff --git a/applications/PoP/poc/res/shadow/sf3.atl b/applications/PoP/poc/res/shadow/sf3.atl
new file mode 100644
index 0000000..5c54e5a
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sf3.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk0.atl b/applications/PoP/poc/res/shadow/sk0.atl
new file mode 100644
index 0000000..00d2e35
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk0.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk1.atl b/applications/PoP/poc/res/shadow/sk1.atl
new file mode 100644
index 0000000..4089c79
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk1.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk10.atl b/applications/PoP/poc/res/shadow/sk10.atl
new file mode 100644
index 0000000..943fcf7
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk10.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk11.atl b/applications/PoP/poc/res/shadow/sk11.atl
new file mode 100644
index 0000000..c573db6
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk11.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk12.atl b/applications/PoP/poc/res/shadow/sk12.atl
new file mode 100644
index 0000000..c4be0c0
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk12.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk13.atl b/applications/PoP/poc/res/shadow/sk13.atl
new file mode 100644
index 0000000..eb433b5
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk13.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk14.atl b/applications/PoP/poc/res/shadow/sk14.atl
new file mode 100644
index 0000000..f07e418
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk14.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk15.atl b/applications/PoP/poc/res/shadow/sk15.atl
new file mode 100644
index 0000000..80217bb
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk15.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk16.atl b/applications/PoP/poc/res/shadow/sk16.atl
new file mode 100644
index 0000000..ee4bd35
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk16.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk17.atl b/applications/PoP/poc/res/shadow/sk17.atl
new file mode 100644
index 0000000..86e63a6
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk17.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk18.atl b/applications/PoP/poc/res/shadow/sk18.atl
new file mode 100644
index 0000000..07498ba
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk18.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk19.atl b/applications/PoP/poc/res/shadow/sk19.atl
new file mode 100644
index 0000000..24a06d3
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk19.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk2.atl b/applications/PoP/poc/res/shadow/sk2.atl
new file mode 100644
index 0000000..c4e5e72
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk2.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk20.atl b/applications/PoP/poc/res/shadow/sk20.atl
new file mode 100644
index 0000000..6b1e0f7
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk20.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk21.atl b/applications/PoP/poc/res/shadow/sk21.atl
new file mode 100644
index 0000000..d446b37
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk21.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk22.atl b/applications/PoP/poc/res/shadow/sk22.atl
new file mode 100644
index 0000000..ae853da
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk22.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk23.atl b/applications/PoP/poc/res/shadow/sk23.atl
new file mode 100644
index 0000000..450828b
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk23.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk24.atl b/applications/PoP/poc/res/shadow/sk24.atl
new file mode 100644
index 0000000..258ba93
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk24.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk25.atl b/applications/PoP/poc/res/shadow/sk25.atl
new file mode 100644
index 0000000..8d3c861
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk25.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk26.atl b/applications/PoP/poc/res/shadow/sk26.atl
new file mode 100644
index 0000000..e24d76a
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk26.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk27.atl b/applications/PoP/poc/res/shadow/sk27.atl
new file mode 100644
index 0000000..81600d0
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk27.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk3.atl b/applications/PoP/poc/res/shadow/sk3.atl
new file mode 100644
index 0000000..e2a92c1
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk3.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk4.atl b/applications/PoP/poc/res/shadow/sk4.atl
new file mode 100644
index 0000000..358078e
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk4.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk5.atl b/applications/PoP/poc/res/shadow/sk5.atl
new file mode 100644
index 0000000..8b92d5f
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk5.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk6.atl b/applications/PoP/poc/res/shadow/sk6.atl
new file mode 100644
index 0000000..39b4993
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk6.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk7.atl b/applications/PoP/poc/res/shadow/sk7.atl
new file mode 100644
index 0000000..65d80bb
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk7.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk8.atl b/applications/PoP/poc/res/shadow/sk8.atl
new file mode 100644
index 0000000..75ac4ce
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk8.atl differ
diff --git a/applications/PoP/poc/res/shadow/sk9.atl b/applications/PoP/poc/res/shadow/sk9.atl
new file mode 100644
index 0000000..a69cacf
Binary files /dev/null and b/applications/PoP/poc/res/shadow/sk9.atl differ
diff --git a/applications/PoP/roomtest/BUGS_OPEN.md b/applications/PoP/roomtest/BUGS_OPEN.md
index a764b2f..1369e5b 100644
--- a/applications/PoP/roomtest/BUGS_OPEN.md
+++ b/applications/PoP/roomtest/BUGS_OPEN.md
@@ -24,6 +24,7 @@
| [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | открыт: подозрение на клавиатурный модуль, не на физику |
| [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** |
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
+| [BUG-CORPSE-AIR](#bug-corpse-air) | труп Кида висит в воздухе, когда убившая его плита улетела вниз | физика | открыт |
| [LOOSE-ROOM-CHANGE](#loose-room-change) | расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату | порт | закрыт 2026-08-13, кроме [LOOSE-SEAM-ANIM](#loose-seam-anim) |
| [JUMP-FLOOR-LEGS](#jump-floor-legs) | в прыжке с пола ноги Кида рисуются ПОВЕРХ кромки пола, а должны за ней | окклюзия | **корень найден и починен, ждёт проверки в MAME** |
| [MOB-CLIP-RIGHT](#mob-clip-right) | окна и узор кладки рисуются ПОВЕРХ падающих плит | окклюзия | открыт: корень найден 2026-08-13 |
@@ -870,3 +871,25 @@ Makefile правила нет.
**Оговорка.** `pop_guard_set_palette` для типа 4 звать НЕЛЬЗЯ — она
затрёт палитру Тени палитрой стража (`curr_guard_color` у не-стражей = 0,
`seg002:183`).
+
+### BUG-CORPSE-AIR. Труп висит в воздухе после падения плиты
+
+**Симптом** (найдено пользователем 2026-08-20, ур. 12 к. 15, старт с
+`POS=15`): Кид встал на проваливающийся пол (0,6), плита обрушилась и
+убила его, но ТЕЛО осталось лежать на уровне исчезнувшего пола, а плита
+улетела дальше вниз. Труп висит в пустоте.
+
+**Где смотреть.** Кадр смерти — `actions_5_bumped` (см. seq в
+`kid_data`), а гравитация в `check_action` (seg006:05CD) для действия 5
+включается только на кадрах приседа: у оригинала есть отдельные фиксы
+`FIX_DEAD_FLOATING_IN_AIR` (кадры 177..185) именно про этот случай —
+значит **в оригинале 1989 года труп в воздухе тоже висит**, и SDLPoP
+чинит это опциональным фиксом, по умолчанию выключенным.
+
+**Отсюда вопрос к решению**, а не сразу правка: чинить ли вообще. Если
+чинить — портировать `fix_dead_floating_in_air` и записать как
+осознанное расхождение в `../docs/impl_diff.md` (мы уже отказались от
+двух других опциональных фиксов SDLPoP в `bump_into_opponent`).
+
+**Приоритет низкий:** ситуация возникает только когда труп остаётся на
+проваливающемся полу, играбельности не мешает.
diff --git a/applications/PoP/roomtest/Makefile b/applications/PoP/roomtest/Makefile
index 93af3ee..d81074e 100644
--- a/applications/PoP/roomtest/Makefile
+++ b/applications/PoP/roomtest/Makefile
@@ -35,7 +35,7 @@ MEMORY ?= huge # small-раскладка + банки кода в W
# ВАЖНО: любое сравнение занятости банков имеет смысл только при ОДНОМ и том
# же ALLOCS — иначе сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 3000
-EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 4=pop_kdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c --bank 7=pop_redraw.c --bank 8=roomtest_cold.c --bank 8=pop_level_cold.c --bank 8=pop_kboot.c --bank 8=pop_guard_cold.c $(PROF_FLAGS)
+EXTRA_FLAGS ?= --gfx 256 -I $(CURDIR)/../poc/res/bg --max-allocs $(ALLOCS) --bank 2=pop_bg.c --bank 7=pop_room.c --bank 4=pop_cdraw.c --bank 4=pop_kdraw.c --bank 3=pop_map.c --bank 1=guards.c --bank 5=pop_ctrl.c --bank 6=pop_trob.c --bank 7=pop_redraw.c --bank 8=roomtest_cold.c --bank 8=pop_level_cold.c --bank 8=pop_kboot.c --bank 8=pop_guard_cold.c --bank 8=pop_shadow.c $(PROF_FLAGS)
# Профилирование полосами бордюра (см. PROF() в roomtest.c) — ВКЛЮЧЕНО по
# умолчанию, пока идёт работа с производительностью; make PROF=0 выключает.
# Каждая фаза кадра красит бордюр в свой цвет, высота полосы на скриншоте
@@ -112,8 +112,16 @@ VIZIER_DIR := $(CURDIR)/../poc/res/vizier
VIZIER_DATA := $(VIZIER_DIR)/g0.atl $(VIZIER_DIR)/g1.atl $(VIZIER_DIR)/g2.atl \
$(VIZIER_DIR)/g3.atl $(VIZIER_DIR)/g4.atl
+# ТЕНЬ — свой набор, запечённый упаковщиком (наложение спрайта на себя со
+# сдвигом; наш блит так не умеет, см. ../docs/shadow_atlas_plan.md). Две
+# половины в одном наборе: sk* — кадры вне боя (спрайты Кида), sf* — кадры
+# 150..189 (SHADOW.DAT, это тоже графика Кида, а не стража).
+SHADOW_DIR := $(CURDIR)/../poc/res/shadow
+SHADOW_DATA := $(foreach n,0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27,$(SHADOW_DIR)/sk$(n).atl) \
+ $(foreach n,0 1 2 3,$(SHADOW_DIR)/sf$(n).atl)
+
EXTRA_DATA := $(BG_DATA) $(KID_DATA) $(KID_BIN) $(GUARD_DATA) $(SKEL_DATA) \
- $(VIZIER_DATA) $(LVL_DATA)
+ $(VIZIER_DATA) $(SHADOW_DATA) $(LVL_DATA)
include $(PROJ_ROOT)/app.mk
# Образ HDD для MAME: ресурсы раскладываются ПО КАТАЛОГАМ (8.3, как в DSS),
@@ -130,6 +138,7 @@ hdd: $(EXAMPLE).exe
$(foreach f,$(GUARD_DATA),GUARD:$(f)) \
$(foreach f,$(SKEL_DATA),SKEL:$(f)) \
$(foreach f,$(VIZIER_DATA),VIZIER:$(f)) \
+ $(foreach f,$(SHADOW_DATA),SHADOW:$(f)) \
$(foreach f,$(LVL_DATA),LEVELS:$(f))
TC := $(PROJ_ROOT)/applications/PoP/toolchain
@@ -144,6 +153,9 @@ $(SKEL_DATA): $(TC)/pop_pack_guard.py
$(VIZIER_DATA): $(TC)/pop_pack_guard.py
cd $(TC) && python3 pop_pack_guard.py VIZIER
+$(SHADOW_DATA) pop_shadow_atlas.h: $(TC)/pop_pack_shadow.py $(TC)/pop_pack_bg.py $(TC)/pop_pack_kid.py
+ cd $(TC) && python3 pop_pack_shadow.py
+
$(KID_DATA): $(TC)/pop_pack_kid.py $(TC)/pop_pack_bg.py
cd $(TC) && python3 pop_pack_kid.py
kid_data.h $(KID_BIN): $(TC)/pop_extract_kid_data.py
diff --git a/applications/PoP/roomtest/pop_cdraw.c b/applications/PoP/roomtest/pop_cdraw.c
index d24823b..fcdbcb4 100644
--- a/applications/PoP/roomtest/pop_cdraw.c
+++ b/applications/PoP/roomtest/pop_cdraw.c
@@ -23,6 +23,7 @@
#include "_pop_kdraw.h"
#include "pop_vflip.h" /* зеркальные страницы атласов (зелье инверсии) */ /* меч + блит по id — сосед по банку 4 */ /* атласы/кадр Кида, pop_sword_draw, окна Char */
#include "pop_cdraw.h"
+#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_pace.h" /* pop_beam_sample — счёт кадров по лучу */
#include "pop_state.h" /* ВРЕМЕННО: зонды pop_dbg_b* */
#include "pop_bg.h" /* POP_YOFF, pop_fore_over_char, pop_fore_set_clip */
@@ -527,6 +528,10 @@ void pop_char_draw(uint8_t who) __banked
const pop_char_t *ch;
const kframe *fr;
const atlas_t *pages;
+ /* Атлас БРЫЗГ отдельно от кадра: у оригинала они всегда из «своего»
+ * chtab (Кид — chtab_2 image 218, прочие — chtab_5 image 1,
+ * seg006:2019), а у Тени кадр может прийти из другой половины набора. */
+ const atlas_t *spl;
const uint8_t *img;
uint8_t npages, page, idx, dp, flip, hurt, lskip;
uint8_t w, h, vis_w;
@@ -549,19 +554,31 @@ void pop_char_draw(uint8_t who) __banked
if (who == POP_CH_KID) {
pop_loadkid();
ch = &Kid; fr = &kid_frame; pages = kidp; npages = kid_npages;
+ spl = kidp;
hurt = pop_kid_hurt;
} else {
pop_loadshad();
ch = &Guard; fr = &pop_gframe; pages = gp;
npages = g_ok ? GUARD_PAGES : 0;
+ spl = gp;
hurt = pop_guard_hurt;
if (Guard.charid == 0) return; /* соперника на сцене нет */
- /* ТЕНЬ (ур. 4) — это зеркальный Кид: вне боевых кадров 150..189 она
- * ходит по таблице КИДА (seg006:0532), а `image` из неё индексирует
- * спрайты Кида, а не стража. Набор атласов обязан идти за таблицей —
- * условие берём из того же места, что load_frame, чтобы они не
- * разъехались. */
- if (!pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) {
+ /* Набор атласов обязан идти за ТАБЛИЦЕЙ КАДРОВ — условие берём из
+ * того же места, что load_frame (seg006:0532), чтобы они не
+ * разъехались: вне боевых кадров 150..189 Тень ходит по таблице
+ * КИДА, и `image` из неё индексирует спрайты Кида. */
+ if (Guard.charid == CHARID_1_SHADOW && pop_shadow_kid_n) {
+ /* У Тени СВОЙ запечённый набор (pop_shadow.h): её вид даёт
+ * наложение спрайта на себя со сдвигом, а пакетный блит так не
+ * умеет. Обе половины лежат в нём же, поэтому подмены на kidp
+ * здесь уже не нужно. */
+ if (pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) {
+ pages = pop_shadow_fgt; npages = pop_shadow_fgt_n;
+ } else {
+ pages = pop_shadow_kid; npages = pop_shadow_kid_n;
+ }
+ spl = pop_shadow_fgt; /* брызги — из половины chtab_5 */
+ } else if (!pop_frame_tbl_is_guard(Guard.charid, Guard.frame)) {
pages = kidp; npages = kid_npages;
}
}
@@ -741,7 +758,7 @@ void pop_char_draw(uint8_t who) __banked
/* по нему считается футпринт */
}
pop_dbg_m15(); /* ЗАМЕР: снимок прямоугольника сделан */
- if (hurt) cd_splash(s, who, pages, fp_x, obj_y);
+ if (hurt) cd_splash(s, who, spl, fp_x, obj_y);
/* add_sword_to_objtable (seg006:1798): клинок отдельным спрайтом поверх
* персонажа — со своим прямоугольником heal. */
{
diff --git a/applications/PoP/roomtest/pop_shadow.c b/applications/PoP/roomtest/pop_shadow.c
new file mode 100644
index 0000000..34e3b3d
--- /dev/null
+++ b/applications/PoP/roomtest/pop_shadow.c
@@ -0,0 +1,88 @@
+/*
+ * pop_shadow.c — загрузка запечённого набора спрайтов Тени. Зачем набор
+ * отдельный и почему половин две — в шапке pop_shadow.h.
+ *
+ * Код ХОЛОДНЫЙ (раз на уровень), поэтому живёт в банке 8 вместе с прочей
+ * загрузкой. Сами atlas_t — в W2: их читает отрисовка из банка 4, а
+ * данные видны из любого банка.
+ */
+
+#include
+#include
+#include
+#include "pop_shadow.h"
+#include "pop_shadow_atlas.h"
+
+atlas_t pop_shadow_kid[POP_SHADOW_KID_PAGES];
+atlas_t pop_shadow_fgt[POP_SHADOW_FGT_PAGES];
+uint8_t pop_shadow_kid_n, pop_shadow_fgt_n;
+
+/* ГРУЗИМ ОДИН РАЗ НА ЗАПУСК, а не по уровням.
+ *
+ * Тень бывает на ЧЕТЫРЁХ уровнях: 4 — рождается прыжком сквозь зеркало
+ * (jump_through_mirror, seg003:0811, ставит Char.charid = charid_1_shadow
+ * прямо из Кида), 5 — крадёт зелье, 6 — прыгает через пропасть, 12 —
+ * дерётся. Читается как три, потому что таблиц init_shad_N всего три
+ * (5/6/12): четвёртой нет — на 4-м Тень копируется с Кида, а не поднимается
+ * из таблицы. Так вот, напрашивается
+ * грузить набор только там, как это сделано с перевёрнутыми наборами
+ * (pop_vflip_load_all зовётся лишь на POP_UPSIDE_LEVEL). Решение
+ * пользователя — грузить сразу, и оно правильное по двум причинам:
+ * страниц EMM с запасом (32 из ~215 свободных), а вот забыть перезагрузку
+ * на границе уровня легко — ровно так и появился BUG-SHADOW-SET. Разовая
+ * загрузка убирает целый класс ошибок и заодно не тормозит переход между
+ * уровнями на 32 файла. */
+int pop_shadow_load(void) __banked
+{
+ uint8_t i;
+ if (pop_shadow_kid_n || pop_shadow_fgt_n) return 0; /* уже загружен */
+
+ for (i = 0; i < POP_SHADOW_KID_PAGES; i++) {
+ if (atlas_load(&pop_shadow_kid[i], pop_shadow_kid_atl[i]) != 0) {
+ pop_shadow_free();
+ return -1;
+ }
+ pop_shadow_kid_n = (uint8_t)(i + 1);
+ }
+ for (i = 0; i < POP_SHADOW_FGT_PAGES; i++) {
+ if (atlas_load(&pop_shadow_fgt[i], pop_shadow_fgt_atl[i]) != 0) {
+ pop_shadow_free();
+ return -1;
+ }
+ pop_shadow_fgt_n = (uint8_t)(i + 1);
+ }
+
+ pop_shadow_pal_apply();
+ return 0;
+}
+
+/* ПАЛИТРА ОТДЕЛЬНО ОТ ЗАГРУЗКИ, и это не косметика.
+ *
+ * `gfx_pal_fload("KID\\kid.pal")` заливает ВСЕ 256 записей — это единая
+ * игровая палитра, — и затирает любые слоты, выставленные до него. Ровно
+ * та же беда уже была с тайлсетом: сразу за ним стоит pop_bg_pal_apply,
+ * который возвращает цвета env/wall. Наши 0xA0..0xAF из той же оперы, и
+ * в первом заходе я на это наступил: набор грузился, силуэт рисовался
+ * правильной формы, но ЦЕЛИКОМ ЧЁРНЫЙ (поймано пользователем на 6-м
+ * уровне). Поэтому вызов живёт рядом с pop_bg_pal_apply, а не внутри
+ * загрузки атласов.
+ *
+ * Обе страницы дабл-буфера: у них свои палитры. Через буфер в W2 —
+ * gfx_pal_load отдаёт указатель BIOS ($A4), а тот читает только
+ * #4000-#BFFF и банковую rodata не увидит. */
+void pop_shadow_pal_apply(void) __banked
+{
+ uint8_t buf[64], k;
+ if (!pop_shadow_kid_n) return; /* набора нет — нечего красить */
+ for (k = 0; k < 64; k++) buf[k] = pop_shadow_pal[k];
+ gfx_pal_load(0, POP_SHADOW_PAL_BASE, 16, buf);
+ gfx_pal_load(1, POP_SHADOW_PAL_BASE, 16, buf);
+}
+
+void pop_shadow_free(void) __banked
+{
+ uint8_t i;
+ for (i = 0; i < pop_shadow_kid_n; i++) atlas_free(&pop_shadow_kid[i]);
+ for (i = 0; i < pop_shadow_fgt_n; i++) atlas_free(&pop_shadow_fgt[i]);
+ pop_shadow_kid_n = pop_shadow_fgt_n = 0;
+}
diff --git a/applications/PoP/roomtest/pop_shadow.h b/applications/PoP/roomtest/pop_shadow.h
new file mode 100644
index 0000000..7463e2f
--- /dev/null
+++ b/applications/PoP/roomtest/pop_shadow.h
@@ -0,0 +1,55 @@
+/*
+ * pop_shadow.h — запечённый набор спрайтов ТЕНИ.
+ *
+ * Оригинал спрайтов Тени не хранит: он кладёт обычный спрайт дважды — в x
+ * прозрачным блитом и в x+1 «блиттером XOR» (draw_objtable_item,
+ * seg008.c:1600). Пакетный блит так не умеет, поэтому результат запечён
+ * заранее (toolchain/pop_pack_shadow.py); разбор и замеры —
+ * ../docs/shadow_atlas_plan.md.
+ *
+ * ПОЛОВИН ДВЕ, и это не наша выдумка. Набор спрайтов у оригинала выбирает
+ * не персонаж, а поле `sword` САМОГО КАДРА (load_frame_to_obj,
+ * seg008.c:1752: chtab_base жёстко = chtab_2, плюс `sword >> 6`). У всех
+ * кадров таблицы Кида эти биты нулевые, у всех кадров таблицы стража —
+ * 0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533),
+ * значит идёт через chtab_5 — а chtab_5 на 12-м уровне грузится из
+ * SHADOW.DAT (tbl_guard_type[12] == 4), и это графика КИДА в боевых позах.
+ * Отсюда:
+ *
+ * pop_shadow_kid[] — кадры вне боя (запечены из спрайтов Кида);
+ * pop_shadow_fgt[] — кадры 150..189 (запечены из SHADOW.DAT).
+ *
+ * Какая половина нужна, отрисовка уже знает — тем же условием, что
+ * выбирает таблицу кадров (pop_frame_tbl_is_guard).
+ *
+ * СПРАЙТ ТЕНИ НА ПИКСЕЛЬ ШИРЕ исходного: второй проход оригинала кладётся
+ * в x+1, суммарный след занимает [x, x+w]. Рисовать по тем же
+ * координатам, что и Кида — сдвиг уже внутри спрайта.
+ */
+#ifndef POP_SHADOW_H
+#define POP_SHADOW_H
+
+#include
+#include
+
+extern atlas_t pop_shadow_kid[]; /* половина «вне боя» */
+extern atlas_t pop_shadow_fgt[]; /* половина «в бою» (SHADOW.DAT) */
+/* Сколько страниц реально загружено; 0 = набора нет (уровень без Тени
+ * или загрузка не удалась) — отрисовка тогда идёт прежним путём. */
+extern uint8_t pop_shadow_kid_n, pop_shadow_fgt_n;
+
+/* Загрузить набор. Звать ОДИН РАЗ при старте: набор не зависит от
+ * уровня, а перезагрузка на границе — лишний повод её забыть (почему
+ * именно так — в шапке pop_shadow.c). Повторный вызов — no-op.
+ * Возврат: 0 — OK, -1 — ошибка загрузки. */
+int pop_shadow_load(void) __banked;
+
+/* Залить 16 слотов палитры Тени. ЗВАТЬ ПОСЛЕ ЛЮБОЙ заливки общей
+ * палитры (gfx_pal_fload kid.pal): та кладёт все 256 записей и затирает
+ * наши — см. развёрнуто в pop_shadow.c. Рядом с pop_bg_pal_apply. */
+void pop_shadow_pal_apply(void) __banked;
+
+/* Освободить страницы; звать на выходе из программы. */
+void pop_shadow_free(void) __banked;
+
+#endif
diff --git a/applications/PoP/roomtest/pop_shadow_atlas.h b/applications/PoP/roomtest/pop_shadow_atlas.h
new file mode 100644
index 0000000..7c46fe7
--- /dev/null
+++ b/applications/PoP/roomtest/pop_shadow_atlas.h
@@ -0,0 +1,71 @@
+/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.
+ * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.
+ *
+ * Две половины, потому что и у оригинала их две (см. шапку
+ * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),
+ * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.
+ * Какая половина нужна, приложение уже знает — тем же условием,
+ * что выбирает таблицу кадров (pop_frame_tbl_is_guard).
+ *
+ * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход
+ * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].
+ * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.
+ */
+#ifndef POP_SHADOW_ATLAS_H
+#define POP_SHADOW_ATLAS_H
+
+#include
+
+#define POP_SHADOW_SHIFT 3
+#define POP_SHADOW_MASK 7
+#define POP_SHADOW_PAL_BASE 0xA0 /* слоты Sprinter под Тень */
+
+/* Половина «вне боя» — кадры Кида (chtab_2). */
+#define POP_SHADOW_KID_PAGES 28
+/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */
+#define POP_SHADOW_FGT_PAGES 4
+
+static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {
+ "SHADOW\\sk0.atl",
+ "SHADOW\\sk1.atl",
+ "SHADOW\\sk2.atl",
+ "SHADOW\\sk3.atl",
+ "SHADOW\\sk4.atl",
+ "SHADOW\\sk5.atl",
+ "SHADOW\\sk6.atl",
+ "SHADOW\\sk7.atl",
+ "SHADOW\\sk8.atl",
+ "SHADOW\\sk9.atl",
+ "SHADOW\\sk10.atl",
+ "SHADOW\\sk11.atl",
+ "SHADOW\\sk12.atl",
+ "SHADOW\\sk13.atl",
+ "SHADOW\\sk14.atl",
+ "SHADOW\\sk15.atl",
+ "SHADOW\\sk16.atl",
+ "SHADOW\\sk17.atl",
+ "SHADOW\\sk18.atl",
+ "SHADOW\\sk19.atl",
+ "SHADOW\\sk20.atl",
+ "SHADOW\\sk21.atl",
+ "SHADOW\\sk22.atl",
+ "SHADOW\\sk23.atl",
+ "SHADOW\\sk24.atl",
+ "SHADOW\\sk25.atl",
+ "SHADOW\\sk26.atl",
+ "SHADOW\\sk27.atl",
+};
+
+static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {
+ "SHADOW\\sf0.atl",
+ "SHADOW\\sf1.atl",
+ "SHADOW\\sf2.atl",
+ "SHADOW\\sf3.atl",
+};
+
+/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */
+static const uint8_t pop_shadow_pal[64] = {
+ 0x00,0x00,0x00,0x00,0xD8,0xFC,0xFC,0x00,0x6C,0x80,0xDC,0x00,0x78,0x44,0x24,0x00,0xA0,0xB8,0xD8,0x00,0xB5,0x7C,0x21,0x00,0x00,0x90,0xB8,0x00,0x5D,0x6D,0xAB,0x00,0x0C,0xF0,0x6C,0x00,0xCC,0x38,0x05,0x00,0x38,0xCC,0xC0,0x00,0x58,0xDB,0xA7,0x00,0xBB,0x9F,0x54,0x00,0x5D,0x18,0x60,0x00,0x37,0xD7,0x37,0x00,0x2F,0x17,0xD2,0x00,
+};
+
+#endif
diff --git a/applications/PoP/roomtest/roomtest_cold.c b/applications/PoP/roomtest/roomtest_cold.c
index d3f7c08..774fe5e 100644
--- a/applications/PoP/roomtest/roomtest_cold.c
+++ b/applications/PoP/roomtest/roomtest_cold.c
@@ -23,6 +23,7 @@
#include /* gfx_copy_page */
#include "pop_tune.h"
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
+#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
@@ -749,6 +750,7 @@ int pop_boot(void) __banked
}
pop_sword_load(); /* меч в руке (без него кадры 229+ без клинка) */
pop_guard_load(); /* атласы стража (GUARD\\g0..g4) */
+ pop_shadow_load(); /* запечённый набор Тени — один раз на запуск */
if (pop_kid_load(28) != 0) { /* ВСЕ страницы: бег+стоп+разворот+присед+прыжок */
puts("kid atlas load failed");
return -1;
@@ -771,6 +773,8 @@ int pop_boot(void) __banked
* дворцовых уровнях без этого рисуется дворцовая геометрия подземельными
* красками (сине-серое вместо песочного). */
pop_bg_pal_apply();
+ /* ...и палитру Тени: kid.pal затёр и её 16 слотов (pop_shadow.c). */
+ pop_shadow_pal_apply();
/* Фон комнаты рисуется в ОБЕ графические страницы: у каждой своя
* ОЗУ-копия/видео-ОЗУ, а heal берёт чистый фон из ОЗУ-копии ТОЙ
@@ -853,6 +857,7 @@ void pop_shutdown(void) __banked
closegraph();
pop_bg_free();
pop_kid_free();
+ pop_shadow_free();
pop_guard_free();
pop_kid_data_free();
pop_level_free();
diff --git a/applications/PoP/toolchain/pop_pack_shadow.py b/applications/PoP/toolchain/pop_pack_shadow.py
new file mode 100644
index 0000000..aac74fb
--- /dev/null
+++ b/applications/PoP/toolchain/pop_pack_shadow.py
@@ -0,0 +1,292 @@
+#!/usr/bin/env python3
+"""
+pop_pack_shadow.py — упаковщик спрайтов ТЕНИ.
+
+ЗАЧЕМ ОТДЕЛЬНЫЙ НАБОР. Оригинал не хранит спрайтов Тени вовсе: он кладёт
+обычный спрайт ДВАЖДЫ — в x обычным прозрачным блитом, и в x+1 «блиттером
+XOR» (draw_objtable_item, seg008.c:1600). Наш пакетный блит так не умеет,
+поэтому результат запекается заранее, здесь.
+
+ЧТО ИМЕННО ДЕЛАЕТ ОРИГИНАЛ (проверено по исходнику, не по памяти):
+
+ * `blitters_2_or` — НЕ побитовое ИЛИ. В SDLPoP это обычный блит с
+ colour key = индекс 0 (method_6_blit_img_to_scr, seg009.c:3306), то
+ есть один в один наш прозрачный блит.
+ * `blitters_3_xor` работает по 24-битному RGB, а не по индексам палитры
+ (blit_xor, seg009.c:3190: конвертация в 24 бита, затем `*p_dest ^=
+ *p_src` побайтно). Прозрачности у него нет — XOR'ится весь
+ прямоугольник, но прозрачный пиксель это чёрный 0x000000, а XOR с
+ нулём ничего не меняет.
+
+Отсюда своя палитра: XOR двух цветов игровой палитры даёт цвет, которого в
+ней нет.
+
+ЕДИНСТВЕННОЕ РАСХОЖДЕНИЕ. XOR считается по тому, что УЖЕ на экране, то
+есть зависит от фона. Разбор по пикселям: от фона зависит только кайма в
+один пиксель по ЛЕВЫМ кромкам силуэта (там, где спрайт прозрачен в x, но
+непрозрачен в x−1). На чёрном фоне `фон XOR цвет == цвет`, и запечка
+точна; Тень почти всегда на чёрном. Мы печём именно этот случай.
+
+ДВА НАБОРА В ОДНОМ АТЛАСЕ. Набор спрайтов у оригинала выбирает НЕ
+персонаж, а поле `sword` самого кадра: load_frame_to_obj (seg008.c:1752)
+держит chtab_base жёстко равным chtab_2 и прибавляет `sword >> 6`. У всех
+кадров frame_table_kid эти биты нулевые, у всех кадров frame_tbl_guard —
+0xC0. Тень берёт таблицу стража для кадров 150..189 (seg006.c:533), то
+есть идёт через chtab_5, а chtab_5 на 12-м уровне грузится из SHADOW.DAT
+(tbl_guard_type[12] == 4, seg000.c:1117) — и это графика КИДА в боевых
+позах, а не стража. Поэтому печём обе половины:
+
+ KID — data/KID/res(401+id).png, id 0..218 -> sk0..skN.atl
+ SHADOW — data/SHADOW/res(751+id).png, id 0..31 -> sf0..sfM.atl
+
+Выход: poc/res/shadow/sk*.atl + sf*.atl + pop_shadow_atlas.h (палитра
+внутри заголовка — 16 записей, её заливает приложение через gfx_pal_load).
+"""
+import os
+import sys
+from PIL import Image
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import pop_pack_bg as B
+from pop_pack_kid import transpose_cols
+
+DATA = os.path.join(HERE, "..", "SDLPoP", "data")
+OUT_DIR = os.path.join(HERE, "..", "poc", "res", "shadow")
+PAL_BASE = 0xA0 # слоты Sprinter под Тень: 0xA0..0xAF
+NCOL = 16 # столько цветов в палитре Тени
+SHIFT = 3 # sk[id>>3], idx id&7 — как у Kid (кадры крупные)
+MASK = (1 << SHIFT) - 1
+
+# Половины набора: (каталог, база ресурса, префикс выходных файлов).
+# Off-by-one тот же, что у Kid и стража: images[0] = res(база+1), значит
+# frame.image = N -> res(база+1+N) (см. pop_pack_kid.load_kid).
+HALVES = (("KID", 400, "sk"),
+ ("SHADOW", 750, "sf"))
+
+
+def frames(dirname, base):
+ """Все кадры половины: [(id, w, h, [[rgb|None]...])] — уже после
+ наложения XOR, в честном RGB."""
+ out = []
+ id = 0
+ while True:
+ path = os.path.join(DATA, dirname, f"res{base + 1 + id}.png")
+ if not os.path.exists(path):
+ break
+ im = Image.open(path)
+ if im.mode != "P":
+ B.die(f"{path}: mode {im.mode}, ожидался P")
+ pal = im.getpalette()[:NCOL * 3]
+ rgb = [tuple(pal[i * 3:i * 3 + 3]) for i in range(NCOL)]
+ w, h = im.size
+ px = im.load()
+ grid = []
+ for y in range(h):
+ # Ширина на один больше: второй проход кладётся в x+1, и
+ # суммарный след занимает [x, x+w].
+ row = [None] * (w + 1)
+ for x in range(w): # проход 1: обычный блит
+ i = px[x, y]
+ if i:
+ row[x] = rgb[i]
+ for x in range(w): # проход 2: XOR в x+1
+ i = px[x, y]
+ if not i:
+ continue # чёрный: XOR — не операция
+ s = rgb[i]
+ t = x + 1
+ row[t] = s if row[t] is None else tuple(a ^ b for a, b in zip(row[t], s))
+ grid.append(row)
+ out.append((id, w + 1, h, grid))
+ id += 1
+ return out
+
+
+def histogram(halves):
+ h = {}
+ for _, fr in halves:
+ for _, _, _, grid in fr:
+ for row in grid:
+ for c in row:
+ if c is not None:
+ h[c] = h.get(c, 0) + 1
+ return h
+
+
+def _d2(a, b):
+ return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2
+
+
+def _kmeans(pts, k, init):
+ """Взвешенный Ллойд по СПИСКУ ЦВЕТОВ с весами (их полсотни, не пиксели)."""
+ cent = list(init)
+ for _ in range(80):
+ groups = [[] for _ in cent]
+ for c, n in pts:
+ groups[min(range(len(cent)), key=lambda j: _d2(c, cent[j]))].append((c, n))
+ new = []
+ for j, g in enumerate(groups):
+ if not g:
+ new.append(cent[j])
+ continue
+ w = sum(n for _, n in g)
+ new.append(tuple(int(round(sum(c[i] * n for c, n in g) / w)) for i in range(3)))
+ if new == cent:
+ break
+ cent = new
+ return cent
+
+
+def build_palette(hist):
+ """16 цветов ГИБРИДОМ: часть слотов — самые частые цвета ТОЧНО, остаток
+ — взвешенные кластеры хвоста.
+
+ Почему не «просто самые частые»: тогда редкие цвета уезжают в далёкий
+ ближайший, и грубых промахов (сдвиг >90 по евклиду в RGB при максимуме
+ 441) получается впятеро больше — 381 пиксель против 76 на все 251
+ кадр (замер 2026-08-20). Почему не чистый k-means: он двигает вообще
+ все цвета, включая массовые.
+
+ Разделение слотов подбираем перебором по критерию «сначала минимум
+ грубых промахов, потом заметных, потом худшая ошибка»."""
+ items = sorted(hist.items(), key=lambda kv: -kv[1])
+
+ def stats(pal):
+ bad = worst = mid = big = 0
+ for c, n in items:
+ e = min(_d2(c, p) for p in pal) ** 0.5
+ if e > 0:
+ bad += n
+ worst = max(worst, e)
+ if e >= 90:
+ big += n
+ elif e >= 40:
+ mid += n
+ return bad, mid, big, worst
+
+ best = None
+ for m in range(1, NCOL): # m слотов под хвост
+ keep = [c for c, _ in items[:NCOL - m]]
+ rest = [(c, n) for c, n in items[NCOL - m:]]
+ if not rest:
+ continue
+ pal = keep + _kmeans(rest, m, [c for c, _ in rest[:m]])
+ bad, mid, big, worst = stats(pal)
+ score = (big, mid, worst)
+ if best is None or score < best[0]:
+ best = (score, m, pal, (bad, mid, big, worst))
+ _, m, pal, st = best
+ # Чёрный обязан быть ТОЧНЫМ и лежать в слоте 0: это XOR-погашенная
+ # середина силуэта, больше половины всех пикселей Тени.
+ j = min(range(len(pal)), key=lambda i: _d2((0, 0, 0), pal[i]))
+ pal[j] = (0, 0, 0)
+ pal[0], pal[j] = pal[j], pal[0]
+ bad, mid, big, worst = st
+ tot = sum(hist.values())
+ print(f" палитра: {NCOL - m} частых точно + {m} кластеров хвоста; "
+ f"из {len(items)} цветов изменено {bad} px из {tot} "
+ f"({100.0 * bad / tot:.2f} %), заметно {mid}, сильно {big}, худшая {worst:.0f}")
+ return pal
+
+
+def encode(grid, w, h, pal):
+ """RGB-сетка -> байты атласа: нет пикселя -> 0xFF, цвет -> PAL_BASE + k.
+ Чёрный — НАСТОЯЩИЙ цвет Тени (слот 0), а не прозрачность."""
+ row = bytearray()
+ for y in range(h):
+ for x in range(w):
+ c = grid[y][x]
+ if c is None:
+ row.append(0xFF)
+ else:
+ row.append(PAL_BASE + min(range(len(pal)), key=lambda k: _d2(c, pal[k])))
+ return transpose_cols(w, h, bytes(row))
+
+
+def write_header(path, counts, pal):
+ vals = []
+ for (r, g, b) in pal:
+ vals += [b, g, r, 0] # формат gfx_pal_load: B, G, R, 0
+ lines = [
+ "/* pop_shadow_atlas.h — раскладка атласа ТЕНИ.",
+ " * СГЕНЕРЕНО toolchain/pop_pack_shadow.py — НЕ править вручную.",
+ " *",
+ " * Две половины, потому что и у оригинала их две (см. шапку",
+ " * упаковщика): кадры вне боя адресуют chtab_2 (спрайты Кида),",
+ " * кадры 150..189 — chtab_5, а он на 12-м уровне это SHADOW.DAT.",
+ " * Какая половина нужна, приложение уже знает — тем же условием,",
+ " * что выбирает таблицу кадров (pop_frame_tbl_is_guard).",
+ " *",
+ " * Спрайт Тени НА ОДИН ПИКСЕЛЬ ШИРЕ исходного: второй проход",
+ " * оригинала кладётся в x+1, и суммарный след занимает [x, x+w].",
+ " * Рисовать по тем же координатам, что и Кида — сдвиг уже внутри.",
+ " */",
+ "#ifndef POP_SHADOW_ATLAS_H",
+ "#define POP_SHADOW_ATLAS_H",
+ "",
+ "#include ",
+ "",
+ "#define POP_SHADOW_SHIFT %d" % SHIFT,
+ "#define POP_SHADOW_MASK %d" % MASK,
+ "#define POP_SHADOW_PAL_BASE 0x%02X /* слоты Sprinter под Тень */" % PAL_BASE,
+ "",
+ "/* Половина «вне боя» — кадры Кида (chtab_2). */",
+ "#define POP_SHADOW_KID_PAGES %d" % counts["sk"],
+ "/* Половина «в бою» — SHADOW.DAT (chtab_5, кадры 150..189). */",
+ "#define POP_SHADOW_FGT_PAGES %d" % counts["sf"],
+ "",
+ "static const char *const pop_shadow_kid_atl[POP_SHADOW_KID_PAGES] = {",
+ ]
+ lines += [' "SHADOW\\\\sk%d.atl",' % i for i in range(counts["sk"])]
+ lines += [
+ "};",
+ "",
+ "static const char *const pop_shadow_fgt_atl[POP_SHADOW_FGT_PAGES] = {",
+ ]
+ lines += [' "SHADOW\\\\sf%d.atl",' % i for i in range(counts["sf"])]
+ lines += [
+ "};",
+ "",
+ "/* 16 цветов, запись 4 байта (B, G, R, 0) — формат gfx_pal_load. */",
+ "static const uint8_t pop_shadow_pal[64] = {",
+ " " + ",".join("0x%02X" % v for v in vals) + ",",
+ "};",
+ "",
+ "#endif",
+ "",
+ ]
+ with open(path, "w") as f:
+ f.write("\n".join(lines))
+ print(f" pop_shadow_atlas.h -> {path}")
+
+
+def main():
+ os.makedirs(OUT_DIR, exist_ok=True)
+ halves = [(prefix, frames(d, base)) for d, base, prefix in HALVES]
+ for (d, base, prefix), (_, fr) in zip(HALVES, halves):
+ if not fr:
+ B.die(f"нет спрайтов в data/{d} (SHADOW.DAT распакован?)")
+ print(f" {d}: {len(fr)} кадров")
+ pal = build_palette(histogram(halves))
+
+ counts, total, nspr = {}, 0, 0
+ for prefix, fr in halves:
+ pages = {}
+ for id, w, h, grid in fr:
+ pages.setdefault(id >> SHIFT, {})[id & MASK] = (w, h, encode(grid, w, h, pal))
+ nspr += 1
+ npg = max(pages) + 1
+ counts[prefix] = npg
+ for pg in range(npg):
+ nm = f"{prefix}{pg}.atl"
+ cnt, sz = B.pack_atlas(os.path.join(OUT_DIR, nm), pages.get(pg, {}))
+ total += sz
+ print(f" {prefix}*.atl: {len(fr)} спрайтов, {npg} EMM-страниц")
+ write_header(os.path.join(HERE, "..", "roomtest", "pop_shadow_atlas.h"), counts, pal)
+ print(f"ИТОГО: {nspr} спрайтов Тени, "
+ f"{counts['sk'] + counts['sf']} EMM-страниц, {total} Б -> {OUT_DIR}")
+
+
+if __name__ == "__main__":
+ main()