Атлас Тени: запечённый набор вместо спрайтов стража

Оригинал кладёт спрайт дважды — прозрачным блитом в x и XOR-блиттером в
x+1; пакетный блит так не умеет, поэтому результат запечён упаковщиком.
Две половины, как и у оригинала: sk* — кадры вне боя (спрайты Кида),
sf* — кадры 150..189 (SHADOW.DAT, тоже графика Кида).  251 спрайт,
32 EMM-страницы, палитра 16 цветов в 0xA0..0xAF.

Закрывает BUG-SHADOW-SET: раньше тип 4 уходил в guard_names, и Тень в
бою дралась серым стражем.

Грабля: kid.pal заливает все 256 записей и затирает слоты Тени —
палитра вынесена в pop_shadow_pal_apply рядом с pop_bg_pal_apply.

Проверено в MAME на 6-м уровне: силуэт с контуром, как в оригинале.
This commit is contained in:
2026-08-20 11:22:53 +03:00
parent e60a04e900
commit 30bcc3459b
41 changed files with 610 additions and 10 deletions
+38 -1
View File
@@ -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 (кайма в один пиксель по
левым кромкам на НЕчёрном фоне).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -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`).
### <a id="bug-corpse-air"></a>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`).
**Приоритет низкий:** ситуация возникает только когда труп остаётся на
проваливающемся полу, играбельности не мешает.
+14 -2
View File
@@ -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
+24 -7
View File
@@ -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. */
{
+88
View File
@@ -0,0 +1,88 @@
/*
* pop_shadow.c — загрузка запечённого набора спрайтов Тени. Зачем набор
* отдельный и почему половин две — в шапке pop_shadow.h.
*
* Код ХОЛОДНЫЙ (раз на уровень), поэтому живёт в банке 8 вместе с прочей
* загрузкой. Сами atlas_t — в W2: их читает отрисовка из банка 4, а
* данные видны из любого банка.
*/
#include <stdint.h>
#include <gfx.h>
#include <sprite.h>
#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;
}
+55
View File
@@ -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 <stdint.h>
#include <sprite.h>
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
@@ -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 <stdint.h>
#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
@@ -23,6 +23,7 @@
#include <sprite.h> /* 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();
@@ -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, но
непрозрачен в x1). На чёрном фоне `фон 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 <stdint.h>",
"",
"#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()