659071838d
Три остатка плана keys_plan.md записаны в TASKS_OPEN.md с полным разбором, чтобы не выводить его заново. KEYS-D (осмотр соседних комнат) — главное, что стоит помнить: у SDLPoP это три строки, потому что там drawn_room влияет ТОЛЬКО на отрисовку, а физика ходит через get_tile(room, col, row) с явной комнатой. У нас наоборот — карта коллизий грузится для ОТРИСОВАННОЙ комнаты: room_fg, lcol_fg, rcol_fg, above_fg, below_fg это ОДИН комплект на программу. Уведи cur_room к соседу, не трогая kid_room, и Кид считает столкновения по чужим тайлам. Задел под расхождение уже стоит (kid_room отдельной переменной, update_kid_render_dx со сдвигом ±140), но enter_room_side пишет обе разом — это незакрытая часть S3 straddle. Записаны оба пути с ценой: честный (правка ядра, дни) и смотровой режим с остановкой игры (150-250 байт, часы) — плюс что главный риск не в отрисовке, а в возврате. KEYS-R (воскрешение) — четыре места, которые обязаны знать про окно неуязвимости, со ссылками на seg-код. KEYS-F12 (скриншот) — «возможно, когда-то», по пометке пользователя. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1561 lines
126 KiB
Markdown
1561 lines
126 KiB
Markdown
# SprPoP — доска ОТКРЫТЫХ задач (обновлено 2026-08-11)
|
||
|
||
Что берём в работу сейчас и в каком порядке. Каждая запись: что сделать,
|
||
почему именно сейчас, чем подтверждать результат.
|
||
|
||
- закрытые задачи с протоколами и замерами — [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md);
|
||
- открытые баги — [`BUGS_OPEN.md`](BUGS_OPEN.md), закрытые с разбором корней —
|
||
[`BUGS_CLOSED.md`](../../PoP/roomtest/BUGS_CLOSED.md);
|
||
- планы фаз — `../docs/PORT_PLAN.md`, `../docs/layout_plan_v2.md`,
|
||
`../docs/levels_plan.md`.
|
||
|
||
**Состояние на 2026-08-11 (сверено с кодом, не только с доской):**
|
||
|
||
- **уровни 1-4 приняты smoke-тестами** (пользователь). Полные обходы всех
|
||
комнат делаются по готовности ВСЕХ уровней — политика приёмок в
|
||
[`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#pass-policy); отдельных задач
|
||
`L3-PASS`/`L4-PASS` больше нет. Уже сделанные полные обходы уровней 1 и 2
|
||
остаются регресс-базой;
|
||
- **[L4-MIRROR](TASKS_CLOSED.md#l4-mirror) закрыта**: зеркало, отражение,
|
||
прыжок сквозь него и рождение тени проверены в MAME. Хвосты — вид тени
|
||
(отложен, [`../docs/shadow_render.md`](shadow_render.md)) и
|
||
[MIRROR-FG-STALE](BUGS_CLOSED.md#mirror-fg-stale) (закрыт как не баг);
|
||
- **[L3-CHOMP](TASKS_CLOSED.md#l3-chomp) и [L3-SKEL](TASKS_CLOSED.md#l3-skel)
|
||
закрыты** (2026-08-08 / 2026-08-07);
|
||
- **тайлсет palace сделан** — `pop_bg_load(type)`, `pal_*.atl`, дворцовая
|
||
кладка `wall_pattern`, решётчатые тайлы 25-29 и в `tile_table`, и в
|
||
коллизии (`tile_is_floor` совпадает с seg006:0628). То есть шаг 2
|
||
`levels_plan.md` закрыт;
|
||
- **libbgi:** блочные AND/OR/XOR/NOT акселератора (2026-08-11, `tests/accop`)
|
||
— задел под вид тени и под любые эффекты «поверх того, что уже нарисовано».
|
||
|
||
**Обход уровней после оптимизации фаз — идёт сейчас (начат 2026-08-17).**
|
||
Оптимизация зелёной/циан фаз тронула порядок пометок и перерисовок, поэтому
|
||
уровни проходятся заново подряд:
|
||
|
||
| уровень | статус | что нашлось и закрыто по дороге |
|
||
|---|---|---|
|
||
| 1 | **принят** (2026-08-17) | мерцание торцов плит/полов/кнопок (запечка шире копируемого прямоугольника); потолочный fore поверх падающей плиты (не было ряда −1 в проходе foretable); соседний пол под плитой (потерянная половина `draw_mob` — `set_redraw2`); залипший блеск меча (`trob_drawn` не совпадал с тем, что нарисует полная отрисовка) |
|
||
| 2 | **принят** (2026-08-17) | чёрные бары поверх стража — `bar` с `x + w > 320` заворачивался в соседнюю страницу видеопамяти (`dd40c24`); [BUG-CHEAT-IMM-1](BUGS_CLOSED.md#bug-cheat-imm-1) — боевая стойка без меча под читом бессмертия |
|
||
| 3 | **принят** (2026-08-17) | критичных багов не найдено — правок не потребовалось |
|
||
| 4 | **предварительно пройден** (2026-08-17) | [LEVELDOOR-PALACE-CLIP](BUGS_CLOSED.md#leveldoor-palace-clip) — во дворце проём двери уровня на 8 px шире, и контур Кида в анимации ухода обрезался раньше времени (`89b603a`) |
|
||
| 5 | **предварительно пройден** (2026-08-18) | багов не найдено |
|
||
| 6 | **предварительно пройден** (2026-08-18) | [SHADOW-STALE-FRAME](BUGS_CLOSED.md#shadow-stale-frame) — тень залипала на страницах дабл-буфера в РАЗНЫХ позах (кадр брался из кэша, снимок писался по `Guard.frame`, `085a198`); [BLUELINE-NOBLUE](BUGS_CLOSED.md#blueline-noblue) — кусок синего узора на стене: модификатор стены не переводился при загрузке (`af4e644`) |
|
||
| 7 | **предварительно пройден** (2026-08-18) | [SPIKE-BAKED](BUGS_CLOSED.md#spike-baked) — выдвинутые пики консервировались в фон запечкой соседней кнопки и оставались навсегда (`980d48c`); [DIED-ON-BUTTON](BUGS_CLOSED.md#died-on-button) — смерть на кнопке не ломала её насовсем: до `check_press` труп не доходил, самой `died_on_button` не было, а таймер связи переживал респавн (`6feab5d`) |
|
||
| 8 | **предварительно пройден** (2026-08-18) | [GUARD-RESPAWN-COL0](BUGS_CLOSED.md#guard-respawn-col0) — при возврате в комнату страж телепортировался в колонку 0 и падал насмерть: колонку несёт `guards_x`, а не тайл (`c40ae3f`); [SEAM-FIGHT-FLICKER](BUGS_CLOSED.md#seam-fight-flicker) — бой у шва перерисовывал комнату туда-обратно: `leave_room` запрещает уход ещё и на кадрах меча (`a498255`, сверено с SDLPoP покадрово) |
|
||
| 9 | **предварительно пройден** (2026-08-18) | багов не найдено |
|
||
| 10 | **предварительно пройден** (2026-08-18) | багов не найдено |
|
||
| 11 | **предварительно пройден** (2026-08-18) | [MOB-NEIGHBOUR-ROOM](BUGS_CLOSED.md#mob-neighbour-room) — плиты нижнего ряда пропадали без кадров падения (кусок в соседней комнате не рисовался, `ed5615a`); порядок куска относительно соседней плиты и Кида — порт корзины 30 (`4db60c7`) |
|
||
| 12 | **предварительно пройден** (2026-08-18) | багов не найдено |
|
||
| 13+ | не начат | |
|
||
|
||
«Предварительно пройден» ≠ «принят»: уровни 4-6 пройдены прогоном по
|
||
сценарию, а не обходом ВСЕХ комнат — полные обходы делаются по готовности
|
||
всех уровней ([политика приёмок](TASKS_CLOSED.md#pass-policy)).
|
||
Регресс-базой остаются полные обходы уровней 1 и 2.
|
||
|
||
Критичных багов на уровнях 1-6 не осталось. Следом — регресс тактов на
|
||
23/13 (документы фаз: [`../docs/perf_green_phase.md`](perf_green_phase.md),
|
||
[`../docs/perf_cyan_phase.md`](perf_cyan_phase.md), сцена и метод —
|
||
[`../docs/perf_l13_room23.md`](perf_l13_room23.md)).
|
||
|
||
Правило проекта в силе: механику сверять с `../SDLPoP/src/` ДО кодинга;
|
||
диагноз платформы подтверждать артефактом (брейкпоинт/дамп/.asm), а не
|
||
гипотезой (memory `defer_unexplained_quirks`).
|
||
|
||
---
|
||
|
||
## ТЕКУЩАЯ ЦЕЛЬ: уровень 8
|
||
|
||
Уровни 1-7 играются (smoke; зелья уровней 2 и 7 приняты пользователем
|
||
2026-08-12). Уровень 8 **новых тайлов не приносит вовсе** (инвентарь
|
||
`res2008.bin` целиком покрыт уровнями 1-7), тайлсет — подземелье.
|
||
Спецсобытие уровня — мышь, [L8-MOUSE](TASKS_CLOSED.md#l8-mouse) закрыта
|
||
2026-08-12 (хост-набор `t_mouse` + живая проверка в MAME).
|
||
|
||
Остаётся smoke-прогон самого уровня: он длинный и с возвратом через два
|
||
чомпера комнаты 4, а стражи там сильнее прежних (комната 5 — skill **7**,
|
||
это максимум из встречавшихся; 12/22/24 — skill 2/3/3).
|
||
|
||
<a id="l9-invert"></a>
|
||
### L9-INVERT. Зелье ПЕРЕВОРОТА — ЗАКРЫТА 2026-08-26
|
||
|
||
> Проверено пользователем в живой игре.
|
||
|
||
|
||
> **Подробный план реализации — [`../docs/l9_invert_plan.md`](../../PoP/docs/l9_invert_plan.md)**
|
||
> (задачи libbgi, задачи приложения, порядок работ, риски, открытые вопросы).
|
||
> Ниже — механика и обоснование решений; план исполняется по документу.
|
||
|
||
Единственное новое на уровнях 9-11: зелья типа 4 на `(1,7)` комнаты 7 и
|
||
`(0,4)` комнаты 10 (сверено по `res2009.bin`). Уровни 10 и 11 не приносят ни
|
||
тайлов, ни спецсобытий — только дворцовый тайлсет (есть) и стражи skill 3-4.
|
||
|
||
Механика оригинала (seg000:15E9 `toggle_upside`, seg006:1886):
|
||
`upside_down = ~upside_down`, снимается смертью Кида (seg000:1224, при
|
||
`alive >= 0`) и стартом уровня (seg003:38/188); второе зелье переворачивает
|
||
обратно. Управление НЕ инвертируется. Переворот у оригинала — чисто вывод:
|
||
`flip_screen(offscreen)` перед копированием прямоугольников на экран и обратно
|
||
после (seg000:939/946); полоса HP рисуется прямо на экран (y = 194) и НЕ
|
||
переворачивается.
|
||
|
||
**Приём оригинала — переворачивать буфер на выводе КАЖДЫЙ кадр — нам не
|
||
подходит:** страниц ровно две (`gfx_set_visible_page` → ESTEX $54 SELPAGE,
|
||
бит 0), рабочей третьей нет, а переворот в видимую страницу порвёт картинку
|
||
(проход не влезает в vblank). Значит переворачиваем ОДИН РАЗ в момент смены
|
||
флага, а дальше рисуем зеркально:
|
||
`y' = POP_YOFF + POP_PLAYFIELD_H - (y - POP_YOFF) - h` плюс вертикальное
|
||
зеркало самого спрайта.
|
||
|
||
#### Момент переключения — accel-копия экрана (ОСНОВНОЙ ВАРИАНТ)
|
||
|
||
Решение пользователя 2026-08-12. Уже нарисованное переворачиваем не
|
||
перерисовкой комнаты, а построчной копией акселератора:
|
||
|
||
1. **проход A → B с реверсом Y**: строка `y` читается, пишется в `191-y`
|
||
(смена Port_Y между burst-чтением и burst-записью). 320 байт в один burst
|
||
не лезут — две скобки на строку;
|
||
2. **проход B → A без реверса**: вторая страница дабл-буфера получает то же
|
||
изображение.
|
||
|
||
Почему это работает на нашем железе, а не только на бумаге: **accel читает
|
||
ОЗУ-КОПИЮ, а не VRAM** (memory `accel_block_ops`), то есть копируется ЧИСТЫЙ
|
||
фон без персонажей, а запись банком `GFX_BANK_NORMAL` идёт и в видео, и в
|
||
ОЗУ-копию приёмника. После двух проходов обе страницы имеют перевёрнутый фон
|
||
в обеих плоскостях — **heal не меняется ни на строку**, он и дальше
|
||
восстанавливает правильный фон по фактическим координатам отрисовки.
|
||
|
||
Бюджет: 192 строки × 2 burst'а × 2 прохода = 768 burst-строк. По нашим
|
||
замерам строка-burst стоит ~300-530Т (`gfx_heal_noclip` 22×22 = 11 658Т →
|
||
530Т/строку; у заливки линия 51-53Т, но там нет данных) — итого
|
||
**230-410 К тактов, то есть 0.2-0.3 логического кадра** (логический = 3
|
||
растровых ≈ 1.29 млн). Перерисовка комнаты в обе страницы стоила бы под
|
||
миллион тактов И требовала бы vflip-версии тайлового блита — здесь она не
|
||
нужна вовсе.
|
||
|
||
Что дописать в libbgi: `_bgi_copy_rows_raw` умеет только ИНКРЕМЕНТ Port_Y на
|
||
строку (`y0` + шаг вперёд, страйды патчатся SMC) — нужен вариант с
|
||
декрементом одной стороны (параметр «шаг Y» либо отдельное ядро
|
||
`_bgi_flip_rows_raw`). Перед реализацией подтвердить ДАМПОМ, что обе
|
||
страницы одновременно адресуемы в W3: `gfx.h` говорит, что страница 1
|
||
начинается на 320 байт дальше (0xC140), но это комментарий, а не проверка
|
||
(memory `defer_unexplained_quirks`).
|
||
|
||
Зеркало по вертикали стоит по-разному в зависимости от раскладки спрайта:
|
||
|
||
- **row-major** (весь слой фона: тайлы, кладка, факелы, зелья, обломки,
|
||
двери) — бесплатно: внешний цикл идёт по строкам источника снизу вверх, сама
|
||
строка копируется вперёд. В libbgi такого варианта СЕЙЧАС НЕТ (флип есть
|
||
только у `gfx_blit_cols*`, и он горизонтальный) — нужна пара
|
||
`gfx_blit*_vflip`;
|
||
- **column-major** (Кид, стражи, мышь, меч — 34 атласа, 228 563 Б) — реверс
|
||
ВНУТРИ колонки, а accel копирует блок только вперёд. Нужны перевёрнутые
|
||
копии в отдельных EMM-страницах (реверс через стек `pop`/`push`, ~10 тактов
|
||
на байт).
|
||
|
||
**Когда делать копии — решать при реализации,** варианты в порядке
|
||
предпочтения:
|
||
|
||
1. **ленивый постраничный кэш**: страница атласа (8 кадров, 3-10 КБ)
|
||
переворачивается при первом обращении в перевёрнутом режиме — ~0.2-0.4
|
||
растрового кадра на страницу, размазано по времени; реально нужны 5-10
|
||
страниц из 34, и если зелье не выпито, не тратится ничего;
|
||
2. на входе в уровень, где в данных есть зелье типа 4 (скан `bg` уровня) —
|
||
вся пачка разом, ~5-6 млн тактов ≈ 0.3 с, пауза прячется в загрузку;
|
||
3. офлайн в `pop_pack_kid.py`/`pop_pack_guard.py` — рантайм ноль, но +228 КБ
|
||
на диске и +34 страницы всегда, а на дискете это заметная загрузка.
|
||
|
||
Не забыть при реализации: зеркалить надо и heal-прямоугольники, и футпринт
|
||
fore-слоя, и клип (`clip_char`), и брызги; полоса HP и лейбл комнаты остаются
|
||
как есть (они вне поля 192).
|
||
|
||
<a id="ui-sprites"></a>
|
||
### UI-SPRITES. Деления HP — из атласов персонажей в константы резидента
|
||
|
||
`pop_kid_img_blit` (**334 Б в W1/W2**) существует ради двух картинок 6×5:
|
||
полосе HP нужен блит по image id из кидовских атласов, а те column-major, и
|
||
обычным `gfx_blit_noclip` их не нарисовать. Деления Кида (images 216/217,
|
||
`kid27.atl`) и стража (idx 0 в `g0.atl`) — по 30 байт каждое.
|
||
|
||
**Решение пользователя 2026-08-12: положить их const-массивами в РЕЗИДЕНТ**
|
||
(~68 Б данных), а не подселять в фоновый атлас: резидент всегда ниже
|
||
0xC000, значит `gfx_blit*` их видит (буфер обязан быть вне W3), EMM-страница
|
||
не тратится, упаковщики не трогаются. Чистая экономия ~266 Б.
|
||
|
||
Побочный плюс для [L9-INVERT](#l9-invert): страницы `kid27` и `g0` сейчас
|
||
смешанные — деления HP (борт, не переворачиваются) лежат рядом с брызгами
|
||
крови 28×26 (поле, переворачиваются). После переноса обе страницы
|
||
становятся чисто «полевыми», и постраничный кэш перевёрнутых кадров получается
|
||
однородным, без спрайтов-исключений.
|
||
|
||
<a id="mem-cold2"></a>
|
||
### MEM-COLD2. Второй шаг разгрузки резидента
|
||
|
||
> **Пункт 2 сделан 2026-08-12**: `guard_over_kid` с хелперами
|
||
> (`objtile_at_char` / `char_x_left_of` / `tile_div_mod`) уехал в банк 8 —
|
||
> куча 1005 → **1574 Б**. Зовётся раз в кадр, то есть цена — один трамплин.
|
||
> Остались пункты 1 (`enter_room_side`) и 3 (блок редроя шва).
|
||
|
||
После [MEM-COLD1](TASKS_CLOSED.md#mem-cold1) куча 1707 Б. Когда упрётся
|
||
снова, в банк 8 переезжают следующие кандидаты (в порядке выгоды):
|
||
|
||
1. `enter_room_side` — самая большая функция `sprpop.c`, зовётся раз на
|
||
комнату. Требует вынести наружу рабочие массивы комнаты
|
||
(`room_bg`/`lcol_*`/`rcol_*`/`below_fg`/`above_*`) — сами данные останутся
|
||
в `_DATA`, уедет только код;
|
||
2. `guard_over_kid` + `objtile_at_char`/`char_x_left_of`/`tile_div_mod` —
|
||
зовётся КАЖДЫЙ кадр, но ровно один раз: цена выноса — один трамплин;
|
||
3. блок редроя левого шва в `main` (`seam_row_sig` + разбор сигнатуры).
|
||
|
||
Если и этого не хватит — следующим уходит сам кадровый порядок отрисовки,
|
||
но тогда уже стоит мерить, а не выносить наугад.
|
||
|
||
---
|
||
|
||
## ПРЕДЫДУЩАЯ ЦЕЛЬ: уровень 5
|
||
|
||
Уровни 1-4 играются (smoke). Дальше идём по порядку уровней; уровень 5 —
|
||
следующий.
|
||
|
||
**Хорошая новость по ассетам: уровень 5 не приносит НИ ОДНОГО нового тайла.**
|
||
Инвентарь, снятый перебором `res2005.bin` (fg & 0x1F):
|
||
|
||
```
|
||
ур. 5: empty, floor, spike, pillar, gate, closer, doortop_with_floor(7),
|
||
bigpillar_bottom(8), bigpillar_top(9), potion, loose, doortop(12),
|
||
debris, opener, level_door L/R, chomper(18), torch, wall,
|
||
lattice_pillar(25)…lattice_right(29)
|
||
```
|
||
|
||
— всё это уже встречалось на уровнях 1-4 и портировано. Единственное новое
|
||
на уровне 5 — **спецсобытие «тень крадёт зелье»**.
|
||
|
||
| # | Задача | Что | Блокирует |
|
||
|---|--------|-----|-----------|
|
||
| — | [DRAW-COST](#draw-cost) | **кадр уложился в бюджет 2026-08-09**: 470 964 -> 425 600 тактов, период цикла 4 растровых кадра -> **3**. Дальнейшее — запас, не срочность | плавность на ВСЕХ уровнях |
|
||
| ✔ | [L1-SPEED](#l1-speed) | **закрыта 2026-08-19** режимами скорости (NORMAL = 4/5 растров, как оригинал) | — |
|
||
| — | [TUNE-1](#tune-1) | параметры движка → cfg-файл (сейчас `pop_tune.h`) | отладка таймингов и моды; берётся по мере надобности |
|
||
| — | [TUNE-2](#tune-2) | параметры СТРАЖЕЙ (6 таблиц по 12 градаций мастерства + HP) → cfg-файл | подбор сложности боя без пересборки |
|
||
| — | [SND](#snd) | звук: эффекты через CBL, музыка на AY | разбор готов — `../docs/sound_plan.md` |
|
||
| — | [MEM](#mem-next) | следующий шаг разгрузки W1/W2 | берётся по факту нехватки места |
|
||
|
||
Сделанное — в [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md): L4-MIRROR, L3-CHOMP,
|
||
L3-SKEL, L3-CHKP, L2 (машинерия уровней), L2-PASS, L1-PASS, DRAW-CHAR,
|
||
MEM-BANK2, MEM-BANK5, CLIP-1, KBD-1, DBG-CHEATS.
|
||
|
||
---
|
||
|
||
## Ждёт ФИНАЛЬНОЙ приёмки (полные обходы по готовности всех уровней)
|
||
|
||
Сюда попадает то, что уже работает в проверочном прогоне, но должно быть
|
||
подтверждено на сквозных прогонах уровней — потому что задевает механику
|
||
шире, чем собственный сценарий.
|
||
|
||
- **Зацеп ПРЯМО В ПРЫЖКЕ** (`POP_ENABLE_JUMP_GRAB`, `pop_tune.h`, сделан
|
||
2026-08-06, предварительно проверен пользователем). Почему нужен именно
|
||
финальный прогон: точки вызова стоят не только в `check_action`, но и в
|
||
ОБЕИХ ветках `check_bumped` — то есть код вклинивается перед обычным
|
||
ударом о стену. Регрессия проявится не в самом зацепе, а рядом: удар о
|
||
стену с зажатым Shift, осторожный шаг у стены, отскок в прыжке. На
|
||
уровнях 1–3 это надо специально потрогать в паре мест каждого уровня.
|
||
Напоминание: в ВАНИЛИ этого зацепа нет (у SDLPoP — `enable_jump_grab`),
|
||
так что сверять его с оригиналом «как есть» нельзя — только с SDLPoP при
|
||
включённых enhancements.
|
||
Прогон 2026-08-07 (уровни 1 и 2) регрессий рядом не показал, но специально
|
||
на удар о стену с Shift не проверялся.
|
||
|
||
## P0 — делаем сейчас
|
||
|
||
### <a id="char-partial-redraw"></a>CHAR-PARTIAL-REDRAW. Неподвижный персонаж перерисовывается зря: метка «фон трогали» слишком груба — ОБЯЗАТЕЛЬНО
|
||
|
||
**Постановка (пользователь, 2026-08-19).** Проверять, нужна ли отрисовка
|
||
стража, когда он НЕ ДВИГАЕТСЯ. Если движется — лишние ~150 000 тактов
|
||
приемлемы: в оригинале во время боя число физических кадров на логический
|
||
тоже растёт на единицу.
|
||
|
||
**Измерено** (сцена 11/15, чтение `pop_cd` из памяти машины):
|
||
|
||
| объект | x | y |
|
||
|---|---|---|
|
||
| страж, спрайт | 257..284 | 18..56 |
|
||
| страж, клинок (накладной) | 241..261 | 31..37 |
|
||
| пламя факела (колонка 6 → рисуется в ячейке 7) | 232..247 | 5..22 |
|
||
|
||
**Физического перекрытия НЕТ.** По x клинок и пламя пересекаются
|
||
(241..247), но по y расходятся: пламя кончается на 22, клинок начинается с
|
||
31 — девять пикселей чистого зазора. Пользователь увидел это на экране
|
||
раньше, чем я в числах: «страж полностью правее пламени, только меч может
|
||
пересекаться».
|
||
|
||
**Почему тогда он перерисовывается.** Метка «фон трогали» (`pop_cd_touch`
|
||
в `pop_tile.c`) хранится как битовая маска КОЛОНОК по 32 px, отдельно на
|
||
каждый из ТРЁХ рядов по 63 px (`cd_row_of`). Пламя и клинок попадают в
|
||
один ряд 0 и в одну колонку 7 — и `cd_quiet` считает слот задетым.
|
||
Расплата: **148 302 такта, 23 % работы кадра** (85 524 спрайт с клинком и
|
||
снимком + 62 778 fore-проход) за перекрытие, которого нет.
|
||
|
||
**Решение — поднять вертикальную точность метки.** Вместо «маска колонок ×
|
||
3 ряда» хранить на каждую колонку ДИАПАЗОН y (`ymin`/`ymax`): 10 колонок ×
|
||
2 байта × 2 страницы = 40 байт. `pop_cd_touch` расширяет диапазон
|
||
затронутых колонок, `pop_cd_hit` проверяет пересечение диапазонов.
|
||
|
||
Проверка решения на обоих случаях сцены:
|
||
|
||
- **страж:** клинок в колонках 7-8. В колонке 7 у метки лежит y 5..22
|
||
(пламя), у клинка 31..37 — не пересекаются, колонку 8 пламя не трогало.
|
||
Слот остаётся «тихим» → экономия 148 302;
|
||
- **Кид в тяжёлой позиции:** пламя левого факела y 5..22, Кид y 15..55 —
|
||
пересечение НАСТОЯЩЕЕ, и он честно перерисовывается. Так и должно быть.
|
||
|
||
Вариант с 8-пиксельными полосами вместо диапазонов тоже работает
|
||
(24 полосы), но 16-пиксельные УЖЕ НЕТ: пламя и клинок снова слипаются в
|
||
одной полосе. Диапазон на колонку точнее и не требует битовой возни.
|
||
|
||
**Чего делать НЕ надо** (проверено расчётом, не повторять): частичную
|
||
перерисовку персонажа по пересечению и обрезку фона под ним. Обе правки
|
||
решают задачу, которой нет — перекрытия не существует.
|
||
|
||
**Проверка:** замер 11/15 в обеих позициях Кида (лёгкая x = 99, тяжёлая
|
||
x = 106; разбор — [`../docs/perf_l11_room15.md`](perf_l11_room15.md)
|
||
§7), плюс визуальный прогон боя и прохода Кида под факелами: персонаж не
|
||
должен оставлять хвостов и не должен просвечивать сквозь пламя.
|
||
|
||
**Место в очереди — [`../docs/perf_registry.md`](perf_registry.md),
|
||
позиция P15.** Смежное: P14 (fore-проход — вторая половина той же цены).
|
||
|
||
|
||
### <a id="heal-width"></a>HEAL-WIDTH. Ширина точечных heal'ов не совпадает со следом перерисовки — ОБЯЗАТЕЛЬНО
|
||
|
||
**Постановка (пользователь, 2026-08-18).** Ширины heal'ов взяты «на глаз по
|
||
клеткам», а не по реальному следу. Сжатие до фактического следа даёт около
|
||
**10 % площади** самой дорогой операции акселератора; по оценке пользователя
|
||
5-6 % реального выигрыша. Задача обязательная.
|
||
|
||
**Измерено** (каталоги атласов, спрайты плиты):
|
||
|
||
| часть плиты | подземелье | дворец |
|
||
|---|---|---|
|
||
| верх (`41/69/70`) | 32 × 13-14 | 32 × 13-14 |
|
||
| правая грань (`42/71/72`) | **26** × 15-16 | **25** × 15-16 |
|
||
|
||
То есть анимированный след ОДНОЙ плиты — `32 + 26 = 58` пикселей в
|
||
подземелье и `57` во дворце.
|
||
|
||
**Что стоит сейчас.** В коде уживаются ДВЕ разные договорённости:
|
||
|
||
- **60** = «свой тайл + свес соседа» — `pop_floor_bake` (там это записано
|
||
комментарием);
|
||
- **64** = «две клетки» — `pop_loose_shake_draw`, `pop_spike_redraw`
|
||
(«ширина 64 = обе ячейки»).
|
||
|
||
Для трясущейся плиты 64 избыточны: хватает 58 (подземелье) / 57 (дворец),
|
||
то есть **6 пикселей из 64 — впустую, 9,4 % площади heal'а**.
|
||
|
||
**И обратная сторона, которую тоже надо разобрать.** Ни 60, ни 64 не
|
||
покрывают того, что перерисовка РИСУЕТ: два полных `draw_tile` красят до
|
||
**90** пикселей (32 свои + 32 соседа + 26 правой грани соседа, уходящей в
|
||
третью клетку). Видимого артефакта отсюда пока нет — содержимое третьей
|
||
клетки статично и перерисовывается одинаково, а трясущийся сосед накрывает
|
||
эту область своим heal'ом, — но несоответствие «heal уже, чем след» надо
|
||
либо закрыть, либо записать как осознанное.
|
||
|
||
**Что сделать.** Свести ВСЕ точечные heal'ы (`pop_loose_shake_draw`,
|
||
`pop_spike_redraw`, `pop_chomp_redraw`, `pop_floor_bake`, `pop_loose_bake_empty`,
|
||
`pop_ceil_*`, коридор `mob_heal_*`) в таблицу «что рисует / что чистит» и
|
||
привести ширину каждого к фактическому следу. Ширины брать ИЗ АТЛАСА, а не
|
||
из номера клетки — они разные у двух тайлсетов (26 против 25).
|
||
|
||
**Проверка:** замер 13/23 до/после (heal'ы — зелёная фаза) плюс визуальный
|
||
прогон комнат с плитами, пиками и чомперами: сужение heal'а — самый прямой
|
||
способ получить «недочищенный хвост».
|
||
|
||
**Родня — [G8](perf_green_phase.md) (пометка соседа узкой полосой).**
|
||
Это две половины одной темы, и брать их логично вместе: G8 про ширину
|
||
ЗАПЕЧКИ соседнего тайла (60 вместо 28 нужных, да ещё `draw_tile` соседа
|
||
дважды на пометку), HEAL-WIDTH — про ширину HEAL'ов (64 вместо 58/57).
|
||
Числа там уже разобраны: 60 = 32 свой тайл + 28 собственный свес, и для
|
||
запечки САМОГО тайла 60 минимальны — сужать можно только пометку СОСЕДА.
|
||
|
||
**Место в общей очереди — [`../docs/perf_registry.md`](perf_registry.md)
|
||
(позиция P8).** Там же собраны ВСЕ отложенные оптимизации из трёх фазовых
|
||
доков, отсортированные по измеренному эффекту, и разложена цена одного блита
|
||
(6 126 тактов фиксированной накладной на любой блит, независимо от размера).
|
||
|
||
|
||
### <a id="l12-shadow"></a>L12-SHADOW. Уровень 12: тень, бой, слияние — ЗАКРЫТА 2026-08-26
|
||
|
||
> Проверено пользователем в живой игре.
|
||
|
||
|
||
> **Статус 2026-08-13: код написан, хост-тесты зелёные (45 проверок,
|
||
> `tests-host/t_shadow.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.** Для неё
|
||
> нужна сборка с `FIRST_LEVEL=12` и полный рестарт MAME после `make hdd`.
|
||
|
||
План и сверка констант с оригинальными данными —
|
||
[`../docs/levels_12_15_plan.md`](levels_12_15_plan.md) §1 и §7.
|
||
Сделано ровно по нему:
|
||
|
||
| кусок | где | порт |
|
||
|---|---|---|
|
||
| подъём тени в комнате 15 (условие «меч уже подобран») | `guards.c` `pop_check_shadow` | seg002:0070 |
|
||
| `init_shad_12` + вход ПАДЕНИЕМ (`seq_7`) | `guards.c` | data:0EFA |
|
||
| ИИ тени: ждать Кида / драться / сойтись / слиться | `guards.c` `autocontrol_shadow_level12` | seg002:1184 |
|
||
| «ранил тень — ранил себя» | `pop_guard.c` `pop_do_delta_hp` | seg000:1518 |
|
||
| «убил тень — убил себя» | `guards.c` `pop_check_killed_shadow` | seg006:2033 |
|
||
| таймер вспышки слияния (0 → счётчик → −1) | `pop_guard.c` `pop_shadow_timer` | seg003:0735 |
|
||
| меч исчезает при уходе вправо из комнаты 18 | `guards.c` `pop_sword_disappears` | seg002:0536 |
|
||
| появление плит в комнатах 2/13 после слияния | `pop_map.c` `floors_appear` | seg006:1063 |
|
||
| бесшовный переход 12 → 13 (комната 23, без двери) | `sprpop.c` + `pop_start_level` | seg000:0900 |
|
||
|
||
Осознанное расхождение: мигания Кида спрайтами тени во время вспышки нет —
|
||
запись в [`../docs/impl_diff.md`](impl_diff.md).
|
||
|
||
<a id="loose-shake-runs"></a>
|
||
### LOOSE-SHAKE-RUNS. Дрожание плит: пометки по сменам кадра, а не по кадрам
|
||
|
||
**Идея пользователя 2026-08-13, на этап полиша.**
|
||
|
||
`pop_loose_tick` на КАЖДОМ кадре отсчёта ставит `pop_set_redraw(pos,
|
||
POP_RD_LOOSE, 1)` — полную перерисовку тайла, десять раз за отсчёт. А
|
||
кадр дрожания меняется не каждую фазу: таблицы
|
||
(`POP_LOOSE_FRAM_BOTTOM` = 43,73,43,74,74,43,43,43,74,74,74) дают
|
||
|
||
фаза 1 2 3 4 5 6 7 8 9 10
|
||
кадр 73 43 74 74 43 43 43 74 74 74
|
||
^^^^^ ^^^^^^^^ ^^^^^^^^
|
||
парами и тройками одно и то же
|
||
|
||
С фазы 5 кадры идут ТРОЙКАМИ. Значит вместо шести пометок с `pages = 1`
|
||
хватит двух — на фазах 5 и 8 — но с `pages = 2` (обе страницы дабл-буфера,
|
||
иначе на второй застынет старый спрайт и пойдёт мерцание через кадр).
|
||
|
||
**Выигрыш ~20 % работы зелёного блока и ТОЛЬКО на дрожащих плитах.**
|
||
Наивный вариант «ставить лишь на смене кадра» выигрыша НЕ даёт: пять смен
|
||
по две страницы — те же десять перерисовок (проверено арифметикой
|
||
2026-08-13, до правки).
|
||
|
||
Вопрос к этапу полиша: стоит ли неочевидный код такого узкого выигрыша.
|
||
|
||
<a id="bg-once"></a>
|
||
### BG-ONCE. Задний фон рисуется ОДИН раз; вместо чёрных баров — heal
|
||
|
||
**Предложение пользователя 2026-08-13. Не делать сейчас — обязательно к
|
||
проверке и реализации на этапе полиша/оптимизации.**
|
||
|
||
Суть: элементы САМОГО заднего фона — факелы без пламени, окна, узор кладки,
|
||
решётчатая кладка и что ещё найдётся — впечатываются в фон один раз и больше
|
||
не перерисовываются никогда. Ни один объект за ними оказаться не может, а
|
||
значит и возвращать их поверх чего-либо не нужно. Если такой элемент оказался
|
||
затёрт, это САМО ПО СЕБЕ сигнал об ошибке: либо бар/прямоугольник слишком
|
||
велик, либо что-то рисуется не в своём слое.
|
||
|
||
Вторая половина предложения — и она про скорость: там, где мы гасим область
|
||
чёрным баром перед выводом спрайта, звать вместо бара **heal по координатам
|
||
бара**. Бар всё равно приходится потом закрывать фоном, то есть выходит две
|
||
операции; heal делает то же одной и сразу правильным содержимым.
|
||
|
||
**Оценка: идея верная по направлению, но есть два известных препятствия.**
|
||
|
||
1. **Узкий heal уже пробовали — и откатили.** В `pop_room.c` (`pop_clip_sprite`)
|
||
записано: per-спрайтовый heal не используется, потому что выравнивание
|
||
блока акселератора оставляет 1-2 px слайверы правой грани, и на одной
|
||
странице дабл-буфера это мерцает. Поэтому борта чистит полоса
|
||
`pop_room_clip_borders` с гейтом по флагу. Прежде чем менять бары на heal,
|
||
надо решить именно эту задачу — иначе получим мерцание вместо ускорения.
|
||
2. **Полный запрет «не перерисовывать фон» противоречит foretable.**
|
||
`draw_tile_fore` (seg008:715) кладёт в передний слой ГЛАВНУЮ ГРАНЬ стены
|
||
вместе с её узором — она обязана перекрывать персонажа. То есть запрет
|
||
должен различать «декор задней стены» и «передняя грань», а это ровно
|
||
различие backtable/foretable оригинала. Фактически предложение сводится к
|
||
«привести наши слои в соответствие с таблицами оригинала» — тот же вывод,
|
||
к которому пришёл разбор `overlay_mid_tile` (см. ниже).
|
||
|
||
**Известное исключение**, которое надо не сломать: Кид, уходящий по лестнице
|
||
в дверь уровня, действительно уезжает за передний план — но он и рисуется
|
||
отдельно (`clip_char`, правая грань doortop; см. memory `pop_clip_char_todo`).
|
||
|
||
**Критерий приёмки этапа:** пройти уровни с падающими плитами (13) и с
|
||
факелами/окнами (10-12) и убедиться, что ни один фоновый элемент не
|
||
перерисовывается в кадре, где его тайл не менялся; замерить кадр до и после.
|
||
|
||
<a id="l12-shadow-spr"></a>
|
||
### L12-SHADOW-SPR. Тень дерётся спрайтами СТРАЖА, а не своими
|
||
|
||
**Найдено прогоном 2026-08-13.** `tbl_guard_type[12] = 4` — это SHADOW.DAT,
|
||
отдельный набор спрайтов (силуэт Кида), а `pop_guard_load` (`pop_cdraw.c`)
|
||
разбирает только тип 2 (SKEL) и 3 (VIZIER); тип 4 сваливается в набор
|
||
обычного стража. Ассеты есть: `SDLPoP/data/SHADOW` (32 кадра + `res750.pal`).
|
||
|
||
Правка по образцу VIZIER (набор в `pop_pack_guard.py`, каталог в Makefile,
|
||
ветка в `pop_guard_load`), НО сначала проверить две вещи, иначе спрайты
|
||
поедут: (1) какую таблицу кадров получает `CHARID_1_SHADOW` в
|
||
`pop_frame_tbl_is_guard` — Кида или стража, и (2) сколько кадров в наборе
|
||
(32 против 34 у стража) и не выходит ли индекс за его пределы.
|
||
|
||
Это НЕ полный «вид тени» (OR/XOR-блиттеры, [`../docs/shadow_render.md`]) —
|
||
только правильный набор спрайтов вместо чужого.
|
||
|
||
<a id="l12-seam-polish"></a>
|
||
### L12-SEAM-POLISH. Переход 12 -> 13 выглядит коряво
|
||
|
||
Механика работает (комната 23, без заставки и без сброса HP), но сама
|
||
анимация прохода рваная — замечено пользователем 2026-08-13. Полишинг,
|
||
берётся вместе с остальной шлифовкой переходов.
|
||
|
||
<a id="l13-jaffar"></a>
|
||
### L13-JAFFAR. Уровень 13: Джафар, победа, выход — ЗАКРЫТА 2026-08-26
|
||
|
||
> Проверено пользователем в живой игре.
|
||
|
||
|
||
> **Статус 2026-08-13: код написан, хост-тесты зелёные (44 проверки,
|
||
> `tests-host/t_jaffar.c`), ЖИВОЙ ПРОВЕРКИ В MAME ЕЩЁ НЕ БЫЛО.**
|
||
|
||
Своего ИИ у Джафара нет — это обычный страж (seg002:085A), skill 9 и 6 HP уже
|
||
лежат в `pop_level_cold.c`. Сделаны только события и данные:
|
||
|
||
| кусок | где | порт |
|
||
|---|---|---|
|
||
| фора после встречи (`guard_notice_timer`) | `guards.c` `pop_meet_jaffar` + `autocontrol_guard_inactive` | seg002:0544 / 0736 |
|
||
| победа: белая вспышка + «выход разрешён» | `guards.c` `on_guard_killed` | seg006:1927 |
|
||
| выход: кнопка комнаты 24 при уходе ВЛЕВО | `guards.c` `pop_jaffar_exit` | seg002:0517 |
|
||
| гряда плит сверху при входе в комнаты 23/16 | `pop_map.c` `pop_check_fall_flo` | seg000:1317 |
|
||
| фазу со старшим битом на 13-м НЕ гасить | `pop_map.c` `pop_loose_tick`, `pop_trob.c` `animate_loose` | seg007:823 |
|
||
| сотрясение на 13-м плиты НЕ трясёт | `pop_map.c` `do_knock` | seg007:949 |
|
||
| плита бьёт и В БЕГЕ | `pop_map.c` `fell_on_your_head` | seg007:1218 |
|
||
| СПРАЙТЫ Джафара (VIZIER.DAT, своя палитра) | `pop_pack_guard.py`, `pop_cdraw.c`, `Makefile` | seg000:1092 |
|
||
|
||
По ходу нашлась и починена смежная ошибка: цвет стража из данных комнаты
|
||
применялся к ЛЮБОМУ набору, хотя оригинал берёт его только у обычного стража
|
||
(`curr_guard_color = 0` при `tbl_guard_type != 0`, seg002:183) — скелета это
|
||
не задевало случайно, Джафара покрасило бы.
|
||
|
||
Осознанное расхождение (представление фазы отложенного старта) — запись в
|
||
[`../docs/impl_diff.md`](impl_diff.md).
|
||
|
||
**Не портировано намеренно:** `is_show_time` в `on_guard_killed` — это показ
|
||
ОСТАВШЕГОСЯ ВРЕМЕНИ, а часов на 60 минут у нас пока нет вовсе.
|
||
|
||
**Что проверять в MAME** (`make LEVEL=13`):
|
||
|
||
1. комната 23 (стартовая): плиты сверху сыплются ВРАЗНОБОЙ с первых кадров,
|
||
и они бьют, даже если пробегать под ними;
|
||
2. приземление Кида плиты НЕ трясёт (иначе гряда посыплется разом);
|
||
3. уход вправо из комнаты 3: Джафар виден СВОИМИ спрайтами и своей палитрой,
|
||
~2 секунды стоит, не доставая клинок;
|
||
4. смерть Джафара — белая вспышка; уход ВЛЕВО открывает дверь уровня.
|
||
|
||
**Что проверять в MAME** (по порядку прохождения уровня):
|
||
|
||
1. комната 15: пока меч лежит — тени нет; подобрал меч, вышел и вернулся —
|
||
тень СВАЛИВАЕТСЯ сверху, а не стоит готовая;
|
||
2. бой: каждый удар по тени отнимает HP и у Кида; добить её нельзя — на её
|
||
смерти умирает и Кид (белая вспышка);
|
||
3. слияние: убрать меч (`Shift`+вниз), подойти — белая вспышка, тень исчезла,
|
||
потолок HP вырос на единицу;
|
||
4. после слияния в комнатах 2 и 13 (верхний ряд) под ногами появляются плиты;
|
||
5. уход ВПРАВО из комнаты 18 убирает меч из комнаты 15 (вернуться и взять
|
||
второй раз нельзя);
|
||
6. комната 23 — уровень молча становится 13-м, HP НЕ сбрасывается.
|
||
|
||
|
||
### <a id="l7-feather"></a>L7-FEATHER. Зелье МЕДЛЕННОГО ПАДЕНИЯ — ЗАКРЫТА 2026-08-26
|
||
|
||
> Проверено пользователем в живой игре.
|
||
|
||
|
||
> **Статус 2026-08-12: код написан, ждёт живой проверки в MAME.** Сделаны все
|
||
> шесть шагов ниже плюс синие пузырьки зелья «−HP» (тип 5) — они всплыли по
|
||
> ходу: пользователь напомнил, что такое зелье стоит уже на уровне 2 (комната
|
||
> 13, тайл `(1,3)`; ещё ур.8 комн.2 и весь ур.15), а цвет у него был красный.
|
||
> Хост-тесты: `phys_feather_fall_is_slow_and_harmless` (скорость ≤ 4 и HP
|
||
> целое против обычного падения с двух рядов). **Осталось**: проверить в MAME
|
||
> — ур.7 комн.1 (зелёные пузырьки, зелёная вспышка, медленный спуск в шахту) и
|
||
> ур.2 комн.13 (синие пузырьки, −1 HP, экран краснеет один раз).
|
||
|
||
Зелье на `(2,8)` комнаты 1 уровня 7 (сверено по `res2007.bin`: modifier 3, то
|
||
есть `potion_type = 3` — «slow fall»). Сейчас `pop_proc_get_object`
|
||
(`pop_map.c`) для типов 3/4/6 не делает НИЧЕГО (явный TODO): зелье выпивается
|
||
без эффекта.
|
||
|
||
Механика оригинала (прочитана в SDLPoP до планирования, правило проекта):
|
||
|
||
| что | где в SDLPoP | значение |
|
||
|---|---|---|
|
||
| включение | `feather_fall()`, seg000:15F8 | `is_feather_fall = 1`, зелёная вспышка (`flash_color = 2`, `flash_time = 3`), `stop_sounds` + `sound_39_low_weight` |
|
||
| физика | `fall_accel()`, seg006:057C | ускорение **1** вместо 3, потолок скорости **4** вместо 33 (`FALLING_SPEED_*_FEATHER`, types.h:1435) |
|
||
| анимация | опкод `SEQ_JMP_IF_FEATHER`, seg006:586 | в seqtbl есть ветки `stepfloat` / `bumpfloat` — «плавные» кадры падения и удара; таблица у нас из данных оригинала, ветки УЖЕ ЛЕЖАТ в ней |
|
||
| длительность | `do_timers`, seg003:517 | ваниль: пока играет звук (или 225 тиков); фикс SDLPoP `fix_quicksave_during_feather` — таймер `FEATHER_FALL_LENGTH = 18.75 c` |
|
||
| сброс | seg003:189 (`start_level`) | на старте уровня и, по фиксу, при смерти Кида |
|
||
| вид склянки | `draw_tile_fore`, seg008:740 | типы 2..4 — БОЛЬШАЯ склянка (id 13) — **у нас уже так** |
|
||
| цвет пузырьков | `draw_tile_anim`, seg008:652 | типы 3/4 — **зелёный** (`color = 10`), 5/6 — синий, остальные — красный (12) |
|
||
|
||
Шаги (в порядке выполнения, каждый проверяем отдельно):
|
||
|
||
1. **Состояние.** `pop_feather` (счётчик кадров) в `pop_map.c` рядом с
|
||
`fall_accel`; экспорт в `pop_map.h` для `play_seq`. Длительность — по
|
||
таймеру (ванильная привязка к звуку нам не подходит: звука нет), значение
|
||
пересчитать из 18,75 с в наши кадры и завести в `pop_tune.h`. Сброс — в
|
||
`pop_start_level` и по смерти Кида.
|
||
2. **Физика.** `fall_accel()`: при `pop_feather` — `FALL_ACCEL_FEATHER 1` /
|
||
`FALL_MAX_FEATHER 4`. Только для Кида (`charid == CHARID_0_KID`) — так
|
||
правильнее по смыслу, но это ОСОЗНАННОЕ расхождение с ванилью (там эффект
|
||
ловят все, и SDLPoP чинит это опцией) → запись в `../docs/impl_diff.md`.
|
||
3. **Анимация.** Опкод `0xF7` в `play_seq` (`pop_kid.c`) сейчас БЕЗУСЛОВНО
|
||
пропускает адрес; сделать как в оригинале: при `pop_feather` — прыжок по
|
||
адресу (это и даёт `stepfloat`/`bumpfloat`, то есть отсутствие урона и
|
||
«парение»). Правка на 3 строки, но именно она даёт весь визуальный эффект
|
||
падения.
|
||
4. **Вспышка.** Сейчас цвет вспышки — булев `pop_flash_red` (жёлтая/красная);
|
||
расширить до кода цвета и добавить ЗЕЛЁНУЮ (`flash_time = 3`).
|
||
5. **Зелёные пузырьки.** `pop_pack_bg.py` уже красит пузырёк mono-цветом
|
||
(`POT_BUBBLE_COLOR = VGA16 + 12`, красный). Добавить второй набор кадров
|
||
16..22 в зелёном (`+10`) под своими id в атласе `pop_pot` и выбирать набор
|
||
по `potion_type` в `pop_potion_draw` (`pop_room.c`): 3/4 — зелёный, 5/6 —
|
||
синий, иначе красный. Цена — 7 маленьких спрайтов.
|
||
6. **Проверка.** Хост-тест: падение с трёх рядов под пером — скорость не выше
|
||
4, урона нет, Кид жив (сцена в `t_phys`/`t_char`). MAME: комната 1 уровня
|
||
7 — выпить, спрыгнуть в шахту, убедиться в плавном спуске и в том, что
|
||
эффект кончается по таймеру.
|
||
|
||
### <a id="l3-color"></a>L3-COLOR. Палитра КЛАДКИ уровня 3 (в оригинале он зелёный)
|
||
|
||
**Наблюдение (пользователь, 2026-08-07, со сравнением карт VGA).** В
|
||
оригинальной VGA-версии кладка уровня 3 ЗЕЛЁНАЯ, а уровней 1-2 — серо-синяя.
|
||
В SDLPoP все подземелья одинаковые, поэтому по нему разницу не увидеть.
|
||
|
||
**Почему в SDLPoP её нет — проверено, не гипотеза.** Механизм там ЕСТЬ
|
||
(seg000:1140, «Level colors (1.3)»):
|
||
|
||
```c
|
||
int level_color = custom->tbl_level_color[current_level];
|
||
if (level_color != 0) {
|
||
byte* env_pal = level_var_palettes + 0x30*(level_color-1);
|
||
byte* wall_pal = env_pal + 0x30 * custom->tbl_level_type[current_level];
|
||
set_pal_arr(0x50, 0x10, (rgb_type*)env_pal); /* chtab_6 environment */
|
||
set_pal_arr(0x60, 0x10, (rgb_type*)wall_pal); /* chtab_7 wall */
|
||
}
|
||
```
|
||
|
||
`tbl_level_color` (data.h:842) = `{0,0,0,1,0,0,0,1,2,2,0,0,3,3,4,0}` — у
|
||
уровня 3 цвет **1**, у 7 тоже 1, у 8/9 — 2, у 12/13 — 3, у 14 — 4. Но
|
||
`level_var_palettes` — это ресурс **20** из `PRINCE.DAT` (только версии
|
||
1.3/1.4), а в `SDLPoP/data/PRINCE/` его НЕТ: там лежит лишь `res10.bin`
|
||
(палитры стражей). Значит `level_var_palettes == NULL` и вся ветка молча
|
||
пропускается — отсюда одинаковые подземелья.
|
||
|
||
**Данные у нас есть.** В `../MSDOS/PRINCE.DAT` ресурс 20 присутствует:
|
||
offset 22790, **240 байт** = 5 палитр × 16 цветов × 3 байта (6-битные
|
||
каналы, как res10).
|
||
|
||
**Что делать (когда дойдём до вида уровня 3).**
|
||
1. Достать ресурс 20 из `MSDOS/PRINCE.DAT` (упаковщику придётся читать сам
|
||
`.DAT` — сейчас все скрипты берут распакованные PNG из SDLPoP);
|
||
2. сгенерировать таблицу палитр рядом с `pop_guard_pal.h`;
|
||
3. при загрузке уровня заливать слоты **0x50..0x5F** (env) и **0x60..0x6F**
|
||
(wall) — у нас ровно эти базы (`pop_pack_bg.load_indexed`: `pal_base =
|
||
0x60` для WALL, `0x50` для env), то есть совпадение со `set_pal_arr`
|
||
один в один;
|
||
4. `wall_pal = env_pal + 0x30 * tbl_level_type[level]` — для подземелья
|
||
(`level_type == 0`) обе палитры одинаковые.
|
||
|
||
**Грабли, уже пойманные на цвете стражей:** `gfx_pal_load` отдаёт указатель
|
||
в BIOS (`$A4` через `rst #0x08`), а BIOS читает только `#4000-#BFFF` —
|
||
таблицу нельзя передавать прямо из банка (0xC000+), надо копировать в
|
||
W1/W2 (см. [BUG-GUARD-COLOR-1](BUGS_CLOSED.md#bug-guard-color-1)).
|
||
|
||
### <a id="draw-cost"></a>DRAW-COST. Кадр НЕ УКЛАДЫВАЕТСЯ в бюджет — нужна оптимизация
|
||
|
||
> **ШАГ 1 СДЕЛАН 2026-08-08: пропуск неизменившегося персонажа.** Комната
|
||
> 1.3, труп стража, Кид стоит: было **210 %** кадрового периода, стало
|
||
> **116 %** (500 772 такта при бюджете 430 000). Отрисовка перестала быть
|
||
> узким местом: персонажи в покое не рисуются ВООБЩЕ (ноль вызовов
|
||
> `pop_heal_fast` за кадр), весь фон — ДВА блита факелов (44 136 тактов).
|
||
> Механизм и почему метка позиционная — в шапке `pop_cdraw.h`.
|
||
|
||
**Исходные замеры пользователя (полосы бордюра, до шага 1).** Уровень 1,
|
||
Кид СТОИТ — то есть НЕ худший случай, ни боя, ни движения:
|
||
|
||
| комната | синяя (ввод+heal+логика) | зелёная (фон) | циан (спрайты) | итого |
|
||
|---|---|---|---|---|
|
||
| 3, страж УБИТ | ~80 % | ~20 % | ~110 % | **~210 %** |
|
||
| 2, стража НЕТ | ~60 % | ~20 % | ~60 % | ~140 % |
|
||
|
||
Разница ровно в теле убитого стража: +20 % синей и +50 % циана. Труп
|
||
сохраняет `charid != 0`, поэтому каждый кадр честно проходил весь путь
|
||
живого персонажа (heal → спрайт+clip_char+брызги+клинок → fore-проход), хотя
|
||
его кадр постоянен до выхода из комнаты.
|
||
|
||
**Что сделано (шаг 1).** Не спецкейс «мёртвый», а общее правило: у каждой
|
||
страницы дабл-буфера свой снимок ВХОДОВ отрисовки слота; совпал снимок,
|
||
спрайт этой страницы цел и фон в его прямоугольнике не трогали — heal, блит
|
||
и fore-проход пропускаются целиком. Покрывает и труп, и стоящего Кида, и
|
||
ждущего стража. Детали контракта — `pop_cdraw.h`, реализация —
|
||
`pop_char_skip_mask` / `cd_quiet` в `pop_cdraw.c`, метка фона —
|
||
`pop_cd_touch` в резидентном `pop_tile.c`.
|
||
|
||
Грабли, на которые наступили по дороге: сначала метка была ФЛАГОМ «фон
|
||
трогали хоть где-то» — и выигрыш оказался ровно нулевым, потому что факелы
|
||
анимируются каждый кадр и гасили пропуск для всех персонажей сразу (замер:
|
||
597 684 такта, как без оптимизации). Метка обязана быть ПОЗИЦИОННОЙ.
|
||
|
||
**Остаточный эффект от объединения прямоугольников.** Метка одна на
|
||
страницу — объединение всех правок фона. В комнате 1 два факела дают
|
||
прямоугольник x 40…88, а стоящий там Кид занимает x 32…44: пересечение 4 px,
|
||
и он перерисовывается каждый кадр (140 % против 94 %, если отойти). Если
|
||
понадобится — держать не один прямоугольник, а 3-4 отдельных и мержить
|
||
только при переполнении.
|
||
|
||
### Шаг 2 сделан частично: ЛОГИКА (синяя полоса) 2026-08-09
|
||
|
||
Пользователь: «на стоящем Киде с двумя факелами на логику уходит 60 %
|
||
кадрового периода — недопустимо». Разобрано брейкпоинтами в MAME
|
||
(`z80_profiling_method`), сцена: уровень 1 комната 1, Кид СТОИТ вплотную к
|
||
левому факелу (то есть пропуск персонажа НЕ срабатывает — худший случай).
|
||
|
||
**Калибровка, которую надо знать заранее.** Один такт `totalcycles` в MAME
|
||
— НЕ один номинальный T-такт Z80: у Sprinter на обращениях к ОЗУ есть
|
||
wait-state'ы, и замеренная стоимость выходит **≈ 2,4× номинала**
|
||
(`get_tile`: 574 номинальных против 1 422 замеренных). Считать бюджет по
|
||
таблице T-тактов из справочника нельзя — только мерить. Кадр растра =
|
||
430 000; главный цикл спейсится тремя `gfx_wait_vsync`, поэтому работа
|
||
СВЫШЕ 430 000 стоит сразу целый лишний кадр.
|
||
|
||
**Что нашли и починили:**
|
||
|
||
| правка | что было | стало |
|
||
|---|---|---|
|
||
| окно перебора коллизии как в оригинале (было: все 14 колонок каждый кадр) | `check_collisions` 60 888 | 50 940 |
|
||
| `get_tile_div_mod` — таблицей (`tile_div_tbl`/`tile_mod_tbl`), было `/14` и `%14` | 5 400 тактов на вызов, 13 вызовов за кадр ≈ 70 000 = **16 % кадра** | ~250 на вызов |
|
||
| разрешение ряда вынесено из цикла колонок + грань шагом 14 + `wall_type` таблицей | `check_collisions` 58 026 | 42 750 |
|
||
| `move_coll_to_prev` — `memcpy` (LDIR) вместо цикла на C | 14 байт за 5 514 тактов (390 на байт!) | ~1 200 |
|
||
| окно режется на непрерывные пробеги (левый сосед / своя / правый), пролог ряда вынесен на кадр | `check_collisions` 44 022 | **38 334** |
|
||
|
||
Замер одной итерации перебора: **пустая колонка 750 тактов, колонка-стена
|
||
~1 700** (две 16-битные знаковые сверки граней — SDCC пишет их через
|
||
`jp PO / xor 0x80 / jp P`). Ловушка, на которую наступили: «быстрый путь для
|
||
окна внутри комнаты» не срабатывал ПОЧТИ НИКОГДА — Кид, стоящий в колонке 0,
|
||
даёт окно с −1, и шёл медленный сбор во временный буфер с тернарником на
|
||
колонку (1 340 тактов на колонку). Отсюда разбиение на пробеги: вопрос «чья
|
||
это колонка» решается раз на пробег, а `coll_scan` сам двигает `scan_left`.
|
||
|
||
Самое дорогое было НЕ там, где ожидалось: `/14` и `%14` SDCC разворачивает
|
||
в `__divsint` + `__modsint`, а `__modsint` внутри зовёт `__divsint` ещё раз —
|
||
два полноценных 16-битных деления на каждый вопрос «в какой колонке точка».
|
||
Оригинал делит таблицей (seg006:702) — мы просто не портировали это место.
|
||
|
||
**ГЛАВНОЕ: логический кадр уложился в бюджет.** Главный цикл спейсится
|
||
тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 тактов стоит СРАЗУ
|
||
целый лишний растровый кадр. Было 470 964 (период цикла 4 кадра), стало
|
||
**409 956 + ~15 600 на ввод = 425 600** — период цикла **3 растровых кадра**.
|
||
Игра стала быстрее на треть (16,7 логических кадров/с против 12,5).
|
||
|
||
Что дало последние тысячи (по убыванию):
|
||
|
||
| правка | экономия |
|
||
|---|---|
|
||
| `pop_y_to_row` — цепочка сравнений вместо `/63 % 4` | ~12 000 |
|
||
| расширение окна fore-прохода арифметикой вместо перебора 10 колонок и 3 рядов | ~8 500 |
|
||
| `col_from_x` — таблицей (те же `POP_TILE_DIV`, вынесены в резидент) | ~11 000 |
|
||
| `pop_cd_touch` — развёрнутый цикл по страницам, `x+w`/`y+h` один раз | ~8 600 (зовётся с каждого блита фона) |
|
||
| `tp / 10`, `tp % 10` у факелов — таблицей | ~4 000 |
|
||
| пустой слот соперника считается «тихим» | ~8 200 |
|
||
|
||
**Ловушка SDCC, на которой я потерял один прогон:** в `pop_y_to_row` одно и
|
||
то же выражение `t / 63 % 4 - 1` стояло в двух ветках, и компилятор поднял
|
||
деление В ВЕРШИНУ функции — быстрые возвраты не спасали, `__divsint` звался
|
||
всё равно. Лечится выносом медленного хвоста в ОТДЕЛЬНУЮ функцию. Тот же
|
||
эффект уже был описан в `pop_loose_tick`; теперь ясно, что это правило, а не
|
||
частный случай: **любое деление, встречающееся дважды, SDCC поднимает выше
|
||
всех проверок.**
|
||
|
||
Приём, которым это ловится: брейкпоинт на `__divsint`/`__divuint`/
|
||
`__divuchar` с печатью адреса возврата (`printf "%04X", w@(sp)`) — сразу
|
||
видно, кто и сколько раз делит за кадр.
|
||
|
||
**Хвост подобран 2026-08-10.** После переписи `pop_y_to_row` в pop_room.c
|
||
осталось ТРИ места, считавших `(y+60)/63 % 4 - 1` вручную (927 в
|
||
`mob_tick_one`, 976/977 в `mob_render`) — сгенерированный asm подтвердил
|
||
пару `__divsint`+`__modsint` в каждом. Это ~16 200 тактов (3,8 % кадра), но
|
||
только пока кусок плиты в полёте — то есть ровно в самом тяжёлом кадре.
|
||
Заменены вызовом `pop_y_to_row`; в банке 7 теперь НОЛЬ `__divsint`.
|
||
Эквивалентность закреплена тестом `geom_y_to_row_matches_formula`
|
||
(перебор −400..400 против исходной формулы) — вызовы разбросаны по трём
|
||
банкам, и соблазн написать деление «по месту» возвращается.
|
||
|
||
### Полная инвентаризация делений 2026-08-10
|
||
|
||
Способ (повторяемый одной командой из `.sprinter-cc-SprPoP/`):
|
||
|
||
```
|
||
awk '/^;[a-z_0-9]+\.[ch]:[0-9]+:/{s=$0} /^\tcall\t__(div|mod|mul)/{printf "%-22s %-12s %s\n",FILENAME,$2,s}' *.asm
|
||
```
|
||
|
||
Найден **31 вызов в 9 модулях**. Прибрано три места, остальное разобрано и
|
||
осознанно оставлено.
|
||
|
||
Убрано:
|
||
|
||
| место | что было | почему стоило |
|
||
|---|---|---|
|
||
| `pop_cdraw.c` `calc_screen_x_coord` (2 вызова) | `x * 8 / 7` = `__divsint`, **2 400 тактов НА ПЕРСОНАЖА КАЖДЫЙ КАДР** | последнее деление в горячем пути; ~4 800/кадр при живом сопернике |
|
||
| `pop_guard.c` `guard_col_from_x` | `/14` + `%14` **безусловно**, мимо `POP_TILE_DIV` | единственное 16-битное деление, у которого таблица вообще не была подключена |
|
||
| `pop_trob.c` `animate_chomper` | `tp / 10` = `__divuchar` на чомпера каждый кадр | таблицы `TP_ROW`/`TP_COL` уже лежали в ЭТОМ ЖЕ файле, но ниже по тексту — чомперы их не видели |
|
||
|
||
Приём для `×8/7`: **таблица** `SCRX7[1152]` (int8_t, хранит `x/7`) в банке 4,
|
||
индекс `x + 448`, результат `x + SCRX7[i]`. 1 152 байта, резидент не тронут.
|
||
|
||
Два решения по дороге, оба проверены, а не угаданы:
|
||
|
||
1. **Диапазон — весь, включая отрицательные.** Первая версия крыла 0..255 по
|
||
тождеству `8x/7 == x + x/7` с байтовой таблицей `x/7`. Ошибка: `obj_x =
|
||
2*fwd − 116` уходит в минус, как только `fwd < 58` (левее `x_bump[5]`) —
|
||
то есть у ЛЕВОЙ КРОМКИ комнаты, а это не экзотика, и там мы продолжали
|
||
делить. Границы взяты из фактических данных: `kid_data.bin` даёт `dx`
|
||
кадров Кида −5..+10, стража −2..+10; при `Char.x` типа uint8_t и
|
||
`render_dx ∈ {−140, 0, +140}` полный диапазон `obj_x` = **−416..695**.
|
||
Таблица кроет −448..703, деление стало недостижимым (оставлено
|
||
страховкой на третьего персонажа / другой `render_dx`).
|
||
2. **Хранить `x/7` байтом, а не готовое `x*8/7` словом** — по тождеству
|
||
`8x/7 == x + x/7`. Первая версия хранила готовое, «раз всё равно
|
||
индексация двухбайтная». Собраны ОБА варианта, такты посчитаны по
|
||
сгенерированному asm:
|
||
|
||
| вариант | хвост после проверки границ | тактов | байт |
|
||
|---|---|---|---|
|
||
| `int16_t` готовое | `add hl,hl / add hl,de / ld e,(hl) / inc hl / ld d,(hl)` | **132** | 2 304 |
|
||
| `int8_t` `x/7` | `add hl,bc / ld a,(hl) / ld l,a / rlca / sbc a,a / ld h,a / add hl,de` | **131** | 1 152 |
|
||
|
||
Расширение знака и 16-битное сложение стоят ровно столько же, сколько
|
||
лишний `add hl,hl` при двухбайтном индексе, а обращений к памяти на одно
|
||
меньше — под wait-state'ами Sprinter (такт ≈ 2,4× номинала именно на
|
||
обращениях к ОЗУ) байтовый вариант ещё чуть выгоднее номинала. Итог:
|
||
байтовая таблица не хуже по скорости и на килобайт меньше. **Урок:
|
||
«двухбайтный индекс съест выигрыш» — гипотеза; она не подтвердилась.**
|
||
|
||
Кодоген проверен глазами (`bank4_pop_cdraw.asm`): `ld hl,#0x01C0; add hl,de`,
|
||
16-битное беззнаковое сравнение — индекс полный, старший байт не теряется
|
||
(грабли `sdcc_z80_const_ptr_index_bug` обойдены отдельной
|
||
`uint16_t`-переменной). 131 такт номинала против ~1 000 у `__divsint`.
|
||
|
||
Проверка таблицы: все 1 152 записи сверены питоном обратно из `.c`, а ПРАВИЛА
|
||
генерации (тождество + усечение к нулю) — тестом `geom_mul8div7_table_rules`
|
||
на целевом компиляторе: округляй SDCC к минус бесконечности, вся
|
||
отрицательная половина уехала бы на пиксель, и поймалось бы это только
|
||
глазами на левой кромке.
|
||
|
||
Оставлено сознательно (НЕ трогать, это не забытые места):
|
||
|
||
- **Хвосты за таблицей** — `guards.c:84`, `pop_bg.c:497`, `sprpop.c:163`,
|
||
`pop_map.c:412`: срабатывают только при x вне 0..255, то есть когда
|
||
персонаж в соседней комнате. Убирать их — это расширять `POP_TILE_DIV` до
|
||
−140..395 (+280 Б резидента) ради редкого пути.
|
||
- **`pop_geom.c:21`** — намеренный медленный хвост `pop_y_to_row` (см. выше
|
||
про подъём деления SDCC).
|
||
- **`pop_geom.c:52`** — `v % n` в `pop_rnd_fit` для не-степени двойки:
|
||
оригинал зовёт `prandom(1)/(255)/(0xFF)`, все три идут веткой с маской,
|
||
сюда управление не приходит вовсе.
|
||
- **Холодные, раз на комнату/уровень/событие**: `pop_guard.c:266/268/269`
|
||
(вход стража), `pop_map.c:310` (пробуждение скелета), `pop_trob.c` дверь
|
||
уровня, `sprpop.c:473/663/887` (читы и старт), `pop_level.c:131/132`
|
||
(имя файла уровня), `sprpop.c:976/977` (отладочный HUD номера комнаты).
|
||
Каждое — единицы вызовов за секунды игры; таблицы под них только раздули
|
||
бы код.
|
||
|
||
Итог: **в горячем пути делений не осталось**. В банках 4, 6, 7 — ноль
|
||
`__div*`; всё, что видно в списке выше, либо за быстрым путём, либо холодное.
|
||
|
||
### Замер в MAME: A/B со сборкой `ec3cca5` (до правок)
|
||
|
||
Обе сборки прогнаны через полный цикл (`make hdd` → рестарт MAME → загрузка
|
||
уровня 1), сцена — **комната 1, Кид стоит, соперника нет**; скриншоты обеих
|
||
сборок идентичны. Фазы сняты брейкпоинтами на инструкциях `out
|
||
(_io_border), a` профилировочного бордюра, медиана по 60 логическим кадрам:
|
||
|
||
| фаза | до | после | Δ |
|
||
|---|---|---|---|
|
||
| ввод + heal | 62 361 | 62 364 | +3 |
|
||
| логика | 79 878 | 79 878 | 0 |
|
||
| фон | 119 502 | 119 502 | 0 |
|
||
| **спрайты** | **129 568** | **127 510** | **−2 058** |
|
||
| РАБОТА за кадр | 391 258 | 389 221 | −2 037 |
|
||
|
||
Счётчик делений (брейкпоинты на `__divsint`/`__modsint`/`__divuchar`/
|
||
`__moduchar` с печатью адреса возврата):
|
||
|
||
| сцена | до | после |
|
||
|---|---|---|
|
||
| комната 1, только Кид | **1,00 `__divsint`/кадр** (возврат 0xD3AA = `pop_cdraw`) | **0** |
|
||
| комната 3, бой со стражем | **2,01 `__divsint`/кадр** | **0** (82 кадра боя) |
|
||
|
||
Всё сходится в одну картину: единственное деление горячего пути — `×8/7` на
|
||
персонажа, по одному вызову на каждого Char. Экономия ровно в фазе спрайтов
|
||
и ровно на стоимость одного вызова: **2 058 тактов** (документированная
|
||
оценка была ~2 400). С соперником на сцене — вдвое.
|
||
|
||
**Чего этот замер НЕ показывает, и это важно:**
|
||
|
||
- Период цикла как был 3 растровых кадра, так и остался — 2 000 тактов его не
|
||
двигают. Выигрыш ушёл в запас: до нижней границы 430 000 стало 40 800
|
||
тактов вместо 38 700.
|
||
- Остальные правки (три `y_to_row` в `pop_room`, `guard_col_from_x`,
|
||
`tp/10` у чомпера) в этой сцене **не срабатывают вовсе** — им нужны
|
||
падающая плита, переход стража между комнатами и уровень с чомперами.
|
||
Их стоимость известна поштучно, но в бою я их не ловил.
|
||
- Работа в комнате 3 между прогонами **несравнима** (391 204 против 318 145):
|
||
страж живой, фаза боя и срабатывание `pop_char_skip_mask` от прогона к
|
||
прогону разные. Оттуда взят только СЧЁТЧИК делений — он от таймингов не
|
||
зависит.
|
||
|
||
**Профиль работы за логический кадр СЕЙЧАС (409 956 тактов + ~15 600 ввод):**
|
||
|
||
| блок | тактов | % растрового кадра |
|
||
|---|---|---|
|
||
| see_kid + ctrl_tick + skip + heal + kid_tick | 52 536 | 12 |
|
||
| физика Кида + страж + боёвка | 73 452 | 17 |
|
||
| `pop_loose_tick` | 27 438 | 6,4 |
|
||
| **`pop_process_trobs`** (два факела) | **75 720** | **17,6** |
|
||
| `pop_redraw_needed` + шов + skip_mask | 10 932 | 2,5 |
|
||
| **`pop_char_draw` Кида** | **56 250** | **13** |
|
||
| **`pop_char_fore` Кида + борта** | **113 628** | **26** |
|
||
|
||
Синяя полоса (ввод + логика) была ~60 % → стала ~29 %.
|
||
|
||
**Оптимизация закрыта по решению пользователя 2026-08-10.** Всё, что
|
||
осталось неcделанным, вынесено с замерами в
|
||
[`../docs/perf_backlog.md`](perf_backlog.md) — там же протокол «как
|
||
мерить», чтобы не наступать заново на wait-state'ы и на устаревшие адреса
|
||
символов. Что доделано после таблицы выше: `pop_cd_touch` развёрнут,
|
||
tp/10 у факелов таблицей, пустой слот соперника считается тихим, ранний
|
||
выход в fore-проходе (нулевой эффект, оставлен как порт), быстрый путь без
|
||
клипа в `pop_blit_b` (факел 41 778 -> 31 218 тактов).
|
||
|
||
**Что осталось (запас на будущее, срочности больше нет):**
|
||
|
||
1. **`pop_char_fore` 113 628.** Внутри: `char_footprint` + расширение окна
|
||
~21 000, дальше шесть `fore_tile`, из которых два реально рисуют (по
|
||
~32 000). Дальше резать — кэш «в этом тайле переднего слоя нет вовсе».
|
||
2. **`pop_process_trobs` 75 720 на два факела** (~31 000 на факел). Внутри
|
||
одного факела: `gfx_blit_noclip` 8 700, чтение w/h и клип 6 400,
|
||
`pop_cd_touch` (теперь дешевле), маппинг окна 0 и возвраты. Пламя
|
||
перерисовывается каждый кадр обязательно (`TORCH_ANIM_DIV = 1`, кадр
|
||
меняется), так что пропуск тут не поможет — только удешевление блита.
|
||
3. `pop_loose_tick` 27 438 при полном отсутствии падающих плит.
|
||
4. Одно `__divsint` осталось в `pop_char_draw` (`obj_x * 8 / 7`) — ~2 400.
|
||
|
||
### Шаг 2 (СЛЕДУЮЩИЙ, назначен пользователем): ДВИЖУЩИЙСЯ Кид
|
||
|
||
Пропуск закрывает только покой. Наблюдение пользователя 2026-08-08: как
|
||
только Кид побежал, циан-полоса (спрайт + fore-проход) вырастает почти до
|
||
**100 % кадрового периода** — и это на ОДНОГО персонажа. То есть цена
|
||
одной перерисовки персонажа сама по себе непозволительно велика, и её надо
|
||
резать по существу, а не пропусками.
|
||
|
||
Куда смотреть (мерить каждое, метод — `z80_profiling_method`):
|
||
1. разделить замером спрайт-блит и fore-проход: у fore уже была история
|
||
78 % кадра до кэша кладки (`pop_fore_layer_cost`), он и сейчас главный
|
||
подозреваемый;
|
||
2. fore-проход перебирает 2×2…3 тайла и в каждом разбирает кладку заново —
|
||
кэш «в этом тайле переднего слоя нет вовсе» снял бы половину;
|
||
3. расширять перебор ТОЛЬКО по накладным спрайтам (клинок/брызги), а не по
|
||
всему окну клипа: габарит кадра уже покрыт футпринтом + правилом меча;
|
||
4. heal + блит спрайта: сейчас это два прохода по одной площади; посмотреть,
|
||
нельзя ли стирать только РАЗНОСТЬ прямоугольников при мелком сдвиге.
|
||
|
||
**Куда идти дальше в ПОКОЕ — там это ЛОГИКА, а не отрисовка.** Разбивка
|
||
работы кадра брейкпоинтами (комната 3, труп, Кид стоит; всего 500 772
|
||
такта):
|
||
|
||
| участок | тактов | % кадра |
|
||
|---|---:|---:|
|
||
| ввод + `check_skel` + луч видимости + `ctrl_tick` + heal | 65 862 | 15 % |
|
||
| `kid_tick` + физика + страж + боёвка | **260 022** | **60 %** |
|
||
| `loose_tick` + `process_trobs` | 119 562 | 28 % |
|
||
| `redraw_needed` + шов + спрайты + метка | 55 152 | 13 % |
|
||
| — из них два блита факелов | 44 136 | 10 % |
|
||
|
||
1. **60 % на тик персонажей** при том, что оба СТОЯТ — первый кандидат.
|
||
Смотреть `pop_phys_tick`/`pop_guard_phys_tick` (банк 3) и `pop_guard_tick`
|
||
(банк 1): сколько там работы, которую неподвижный персонаж делать не
|
||
обязан, и сколько стоит трамплин на каждом шаге.
|
||
2. **28 % на `loose_tick` + `process_trobs`** в комнате БЕЗ единой ловушки —
|
||
явно перебор; разобрать, что там сканируется каждый кадр (список trob,
|
||
`pop_trob_modif` соседа для шва, `pop_room_link`).
|
||
3. Запечь труп в фон (как щебень: тело в фоне + fore-часть поверх Кида) —
|
||
тогда бесплатным станет и проход Кида ПО телу, который сейчас снимает
|
||
пропуск по правилу «слоты рядом». Предложено пользователем 2026-08-08.
|
||
|
||
Мерить в ЭТОЙ точке (комната 3, страж убит, Кид стоит) — она воспроизводима
|
||
и даёт нижнюю границу; худший случай (бой + бег + падающая плита) считать
|
||
отдельно. Метод — memory `z80_profiling_method` (брейкпоинты с
|
||
`totalcycles`); полосы бордюра показывают только одну картинку из периода и
|
||
годятся лишь для раскладки по фазам.
|
||
|
||
**Наблюдение пользователя на приёмке DRAW-CHAR 2026-08-08:** циан-полоса
|
||
профиля (спрайты + fore) выросла — тогда «в пределах».
|
||
|
||
**Отчего именно.** Раньше перебор тайлов у Кида шёл строго по футпринту
|
||
кадра (`char_x_left/right`, seg006:1021), а он УЖЕ спрайта; расширение
|
||
перебора окном спрайта (клинок и брызги уходят за габарит кадра) было
|
||
только у соперника. DRAW-CHAR сделала его общим — то есть у Кида теперь на
|
||
колонку-другую больше `fore_tile` за кадр. Это не регрессия «лишней
|
||
работы», а недостающая ранее корректность: тайл, который спрайт задевает,
|
||
обязан вернуть свой передний слой поверх него.
|
||
|
||
**Если fore-проход снова станет узким местом** (сейчас он в покое не
|
||
выполняется вовсе): расширять перебор ТОЛЬКО по накладным спрайтам
|
||
(клинок/брызги), а не по всему окну; кэш «в этом тайле fore-слоя нет
|
||
вовсе»; считать окно клипа в тайловых координатах один раз.
|
||
|
||
## P1 — берётся в любой момент
|
||
|
||
### <a id="perf-sweep"></a>PERF-SWEEP. Поиск узких мест по ВСЕЙ игре, а не в одной сцене
|
||
|
||
**Постановка (пользователь, 2026-08-18):** «пока мы тестируем на регресс
|
||
только одну сцену; надо придумать механизм проверки узких мест по всей
|
||
программе — ручной проход уровня, в логи пишутся такты каждой фазы с
|
||
информацией, в какой комнате это происходит (возможно, в каком тайле Кид),
|
||
ищем максимумы по уровню и оптимизируем уже конкретную комнату».
|
||
|
||
Сцена 13/23 закрывает ПИК, но она одна: механики, которых в ней нет
|
||
(чомперы, бой, мышь, зеркало, толпа стражей), в бюджет никто не проверял.
|
||
|
||
#### Чем НЕЛЬЗЯ это делать — и почему
|
||
|
||
Тем, чем меряем сейчас. Брейкпоинты с `printf ...; g` на четырёх границах
|
||
фаз роняют эмуляцию в проценты от реального времени — **ручной проход так не
|
||
сыграть**, а именно ручной проход и есть суть задачи. Плюс две мелочи,
|
||
которые уже стоили времени: адреса зондов надо переснимать после КАЖДОЙ
|
||
пересборки (`.lst` + база модуля), а кольцо `clog` переполняется — сегодня
|
||
первый прогон принёс один «покой», каскад успел вытесниться за 75 с
|
||
ожидания.
|
||
|
||
#### Куда смотреть
|
||
|
||
**Вариант А — тап на порт бордюра в Lua-мосте (рекомендую).** `PROF(n)`
|
||
это `out (0xFE), a`, то есть границы фаз УЖЕ размечены в железе.
|
||
`install_write_tap` на порт даёт колбэк без остановки CPU — накладные
|
||
несравнимо меньше брейкпоинта, играть можно. Главный бонус: **тап не
|
||
привязан к адресам кода**, переснимать после пересборки нечего. «Где» берём
|
||
оттуда же: Lua читает `cur_room` и `Kid.curr_col/curr_row` по адресам из
|
||
`sprpop.map` (их выдавать в маленький файл при сборке, чтобы не парсить
|
||
map руками). Пишем CSV: кадр, комната, тайл Кида, синяя/зелёная/циан/работа,
|
||
период.
|
||
|
||
**Вариант Б — программа меряет себя сама, в РАСТРАХ.** Тактов Z80 изнутри
|
||
не видно, но нам они и не нужны: бюджет сформулирован в растровых кадрах, а
|
||
позицию луча видно поллингом бита 5 порта 0xFE (то же, чем живёт
|
||
`gfx_wait_vsync`). Разрешение ~1/256 кадра ≈ 1 700 тактов — для поиска
|
||
узких мест с запасом. Дороже варианта А по месту в резиденте, но
|
||
единственный, который работает **на живом железе**, а не только в MAME —
|
||
а такт MAME это 2,4× номинала Z80 (memory `sprinter_wait_states_2x`), и
|
||
рано или поздно сверяться придётся с железом.
|
||
|
||
#### Что считать результатом
|
||
|
||
Не «дамп кадров», а сводка: максимум каждой фазы **по комнатам**, число
|
||
кадров сверх 400 000 и сверх растрового кадра 430 000, и топ комнат по
|
||
худшему кадру. Дальше — точечная оптимизация той комнаты, как делали с
|
||
13/23.
|
||
|
||
Смежное: сцена и метод — [`../docs/perf_l13_room23.md`](perf_l13_room23.md);
|
||
фазы — [зелёная](perf_green_phase.md), [циан](perf_cyan_phase.md).
|
||
|
||
|
||
### <a id="t-render"></a>T-RENDER. Тесты слоя отрисовки: «в запечку не попадает транзиент»
|
||
|
||
**Откуда взялось** (пользователь, 2026-08-18, по итогам
|
||
[SPIKE-BAKED](BUGS_CLOSED.md#spike-baked)): «такой сценарий (и другие с
|
||
пиками) можно внести в наши тесткейсы?»
|
||
|
||
**Что есть сейчас.** `tests-host/` — это ЛОГИКА: геометрия, физика Кида,
|
||
`Char`, спецсобытия. Отрисовки там нет вовсе (`stubs.c` глушит все блиты
|
||
пустышками), `pop_tile.c`/`pop_room.c`/`pop_bg.c` не линкуются — таблицы
|
||
деления на ширину тайла в стабах даже продублированы копией. **Ловушки не
|
||
покрыты ничем**: ни пик, ни чомперов, ни `animate_*`. То есть SPIKE-BAKED
|
||
тестами поймать было нельзя в принципе.
|
||
|
||
**Что покрывать.** Не «картинку» (её проверяет глаз в MAME), а инвариант,
|
||
на котором держится вся наша запечка:
|
||
|
||
> при `pop_t_bake_rest = 1` ни один слой отрисовки тайла не выдаёт
|
||
> ТРАНЗИЕНТНЫЙ спрайт — ни своей ячейки, ни соседней.
|
||
|
||
Он проверяем механически и стоит ровно того: именно его нарушение дало
|
||
SPIKE-BAKED, а до того — мерцание плиты, ради которого флаг и заводился.
|
||
|
||
**Как.** Новый набор `t_render`: линкуются НАСТОЯЩИЕ `pop_tile.c`,
|
||
`pop_room.c`, `pop_bg.c`, а `pop_env_b`/`pop_wall_b`/`pop_fore_b` подменяются
|
||
**записывающим** стабом — списком `(id, x, yb)` за вызов. Тогда проверка
|
||
пишется в одну строку: «`draw_tile` при `bake_rest=1` и modif пик 4 не выдал
|
||
ни одного id из `spikes_fram_*`». Тот же стаб сразу закрывает и чомпера, и
|
||
плиту, и будущие ловушки — по одному тесту на каждую.
|
||
|
||
Отдельно и дёшево — характеризация автоматов `animate_spike`/`animate_chomper`
|
||
(сверка полного цикла модификатора с SDLPoP seg007), но она НЕ ловит этот
|
||
класс: автомат был верен, врал слой отрисовки.
|
||
|
||
**Чего это стоит.** Не одна строка: набор тянет за собой атласы и
|
||
`pop_cd_*`, а `stubs.c` придётся разделить (сейчас он подменяет то, что
|
||
здесь нужно настоящим). Прикидка — отдельная сессия. Место в `DATA_LOC`
|
||
проверить заранее: набор линкует ещё три крупных модуля (см. грабли
|
||
раскладки в `tests-host/README.md`).
|
||
|
||
|
||
### <a id="qsave"></a>QSAVE. QuickSave / QuickLoad — ЗАКРЫТА 2026-08-22
|
||
|
||
**Закрыта** (F6/F9, POP.SAV + POP.BAK, коммит f4b4852, тэг v0.6-pop-quicksave).
|
||
Протокол и устройство — [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#qsave);
|
||
формат снимка и разбор SDLPoP —
|
||
[`../docs/quicksave_plan.md`](quicksave_plan.md).
|
||
|
||
### <a id="l1-speed"></a>L1-SPEED. Игра идёт быстрее оригинала (найдено 2026-08-01) — ЗАКРЫТА 2026-08-19
|
||
|
||
**Закрыта** режимами скорости (`pop_pace.h`, клавиша **P**): FASTEST 3/3,
|
||
FAST 3/4, **NORMAL 4/5 = 81,9 / 102,4 мс** против 83,3 / 100 мс у
|
||
оригинала. Условие боя взято буквально из SDLPoP (`seg003.c:363`):
|
||
`Kid.sword == SWORD_2_DRAWN`. Дефолт — FASTEST (решение пользователя).
|
||
Проверено в MAME: период ровно 4 и ровно 5 растров. Разбор и замеры —
|
||
`../docs/frame_pacing_plan.md`. Осталось из исходной формулировки только
|
||
сверить NORMAL секундомером рядом с живым SDLPoP.
|
||
|
||
Исходная запись:
|
||
|
||
Сверка таймингов: оригинал — `BASE_FPS = 60` при `base_speed = 5` тиков на
|
||
логический кадр (`SDLPoP/src/types.h:1373`, `data.h:869`) = **83.3 мс**, в бою
|
||
`fight_speed = 6` = **100 мс**. У нас `sprpop.c` ждёт **три** `gfx_wait_vsync()`
|
||
= 60 мс, и отдельной скорости боя нет — то есть примерно **+39 % к скорости
|
||
эталона**. Соответствие: 4 ожидания (80 мс) обычно, 5 (100 мс) в бою.
|
||
Условие «делать ПОСЛЕ CLIP-1» снято — CLIP-1 закрыт. Проверка —
|
||
секундомером по одинаковому отрезку рядом с живым SDLPoP, не «на глаз».
|
||
|
||
### <a id="tune-1"></a>TUNE-1. Параметры движка — в конфиг, а не в код
|
||
|
||
**Что уже есть.** `pop_tune.h` — все настраиваемые числа собраны в одном
|
||
заголовке: чекпойнт уровня 3 (`POP_CHKP_*`), отладочное окно решётки
|
||
(`POP_DBG_GATE_HOLD`), включатель зацепа в прыжке (`POP_ENABLE_JUMP_GRAB`).
|
||
Правило уже действует: новое «особое событие» или тайминг заводится ТАМ, а
|
||
не константой по месту.
|
||
|
||
**Что нужно сделать.** Читать их из ФАЙЛА рядом с exe, чтобы менять без
|
||
пересборки — под отладку («растянуть решётку, чтобы успеть пройти») и под
|
||
моды. Формат: простой ini/`ключ=значение`, парсер на ~50 строк (числа,
|
||
комментарии `;`, неизвестные ключи игнорировать), файл необязателен —
|
||
нет файла, значит зашитые дефолты. Секции по смыслу: `[level]`,
|
||
`[debug]`, `[enhancements]`.
|
||
|
||
**Ориентир — SDLPoP.** У него это `custom_options_type` (types.h) +
|
||
`SDLPoP.ini` + меню Settings/Mods; наши имена намеренно совпадают с его
|
||
(`custom->имя`), чтобы сверка оставалась механической. Осмотр его меню и
|
||
опций — часть задачи: у него уже разложены по группам стартовые
|
||
HP/минуты, номера «особых» комнат и уровней (шадоу, скелет, зеркало,
|
||
чекпойнт), тайминги ворот и пик, скорости, а отдельной группой —
|
||
`fixes`/`enhancements` (включая `enable_jump_grab`, который мы уже
|
||
портировали). Брать всё подряд не надо: переносим по мере того, как
|
||
константа реально понадобилась в игре.
|
||
|
||
**Оговорка по памяти.** Парсер и таблица параметров — холодный код,
|
||
исполняется один раз при старте: кандидат в банк, а не в резидент W1.
|
||
|
||
### <a id="tune-2"></a>TUNE-2. Параметры СТРАЖЕЙ — в CFG-файл
|
||
|
||
Частный, но самый востребованный срез [TUNE-1](#tune-1): сейчас весь ИИ
|
||
стража зашит константами, и чтобы попробовать «а если он будет блокировать
|
||
пореже» — надо пересобирать.
|
||
|
||
**Что настраивать.** Всё, что индексируется мастерством (12 градаций,
|
||
`NUM_GUARD_SKILLS`), — таблицы в `guards.c`:
|
||
|
||
| таблица | что решает | дефолт для навыка 4 |
|
||
|---|---|---:|
|
||
| `STRIKEPROB` | вероятность удара | 61 |
|
||
| `RESTRIKEPROB` | добить из своего парирования | 5 |
|
||
| `BLOCKPROB` | заблокировать замах | 200 |
|
||
| `IMPBLOCKPROB` | то же, когда Кид только что пробил блок | 100 |
|
||
| `ADVPROB` | наступать | 255 |
|
||
| `REFRACTIMER` | «отдышка» после ранения, кадров | 8 |
|
||
| `extrastrength` | надбавка HP (сейчас зашита как `skill == 4 ? 1 : 0`, `pop_guard_cold.c`) | 1 |
|
||
|
||
Плюс не зависящее от навыка: `tbl_guard_hp[16]` (`pop_level_cold.c`) — база
|
||
HP по уровню, и навык скелета (`SKEL_SKILL`) / тени (`SHADOW_SKILL`).
|
||
|
||
**Формат — как у SDLPoP**, чтобы правки переносились в обе стороны без
|
||
перевода: секции `[Skill 0]`..`[Skill 11]`, внутри `strikeprob=`,
|
||
`blockprob=` и т.д. (`options.c:439`, пример — `SDLPoP/SDLPoP.ini:562`).
|
||
Имена ключей брать ЕГО, регистр игнорировать.
|
||
|
||
**Чего НЕ делать.** Навык конкретного стража живёт в данных уровня
|
||
(`level.guards_skill[комната-1]`, читаем в `pop_level_guard`) — его в CFG
|
||
не тащим, иначе разъедется с файлами уровней. Переопределение «в комнате
|
||
N навык M» — отдельная надобность, если понадобится, то как явная секция
|
||
`[Level N]`, а не подмена данных.
|
||
|
||
**Зачем это стоит сделать.** Прогон 2026-08-20 (11/22, навык 4) дал
|
||
ощущение «страж тяжелее, чем в SDLPoP». Сверка кода расхождений не нашла
|
||
(протокол — в `../docs/impl_diff.md` и в ответе сессии: дистанция,
|
||
диспетчер, три ветки ИИ, шесть таблиц, `justblocked`, `check_hurting`,
|
||
`parry`, `swordfight`, порядок тиков, откат seq_74 = −10 — всё совпало
|
||
побайтно). Проверить такую гипотезу можно только подбором чисел на живом
|
||
прогоне, а для этого их надо крутить без пересборки.
|
||
|
||
**Оговорка по памяти** — та же, что в TUNE-1: парсер холодный, в банк.
|
||
|
||
### <a id="snd"></a>SND. Звук — эффекты через CBL, музыка на AY — ЗАКРЫТА ИНАЧЕ 2026-08-26
|
||
|
||
> **Решение пересмотрено.** Музыка пошла НЕ на AY, а тем же PCM через CBL:
|
||
> записи DOS-версии укладываются в формат железа без единого преобразования
|
||
> в рантайме (`../docs/sound_plan.md` §4). Озвучены заставка, сцены между
|
||
> уровнями и игровые джинглы — 21 трек. Единственный неозвученный кусок —
|
||
> финал `won`, см. [MUS-WON](#mus-won).
|
||
|
||
<details><summary>исходная постановка (путь A на AY)</summary>
|
||
|
||
Полный разбор с замерами — [`../docs/sound_plan.md`](sound_plan.md).
|
||
Коротко, почему это оказалось дешевле, чем выглядело:
|
||
|
||
- эффекты уже лежат **8-битным PCM на 11 000 Гц**, а у CBL есть режим
|
||
10 937,5 Гц (расхождение 0,6 %) и ровно тот же формат сэмпла —
|
||
**играются как есть, без конверсии**; 31 звук, 103 941 Б ≈ 6,3
|
||
EMM-страницы;
|
||
- музыка есть **нотами PC-спикера, 7 КБ на всю игру**, причём нота задана
|
||
прямо в ГЕРЦАХ — пересчёт в период AY это одно деление; MIDI разбирать
|
||
не нужно;
|
||
- AY и COVOX сведены в один ЦАП, поэтому играют одновременно;
|
||
- отдельный таймер не нужен: секвенсор музыки двигает CBL-callback
|
||
(11,7 мс), а короче 12 мс во всей музыке всего 2 ноты из 1469.
|
||
|
||
Фазы З1..З5 и список «проверить артефактом до кодинга» — в доке.
|
||
|
||
</details>
|
||
|
||
### <a id="mob-spr-emm"></a>MOB-SPR-EMM. Композит падающего куска — в EMM (1284 Б W2)
|
||
|
||
`mob_spr` (`pop_room.c`) — самый крупный буфер в W2: 4 + 64*20 байт под
|
||
композит падающей плиты. Это половина нынешнего дефицита W2.
|
||
|
||
**В БАНК ЕГО ПЕРЕНЕСТИ НЕЛЬЗЯ** (разобрано 2026-08-26). Цепочка рисования
|
||
— `pop_room` (банк 7) -> `pop_mem_b` (банк 5) -> `blit_b_clip` (резидент), и
|
||
трамплин на `pop_mem_b` переключает W3. Указатель на данные банка 7 после
|
||
этого показывает на страницу банка 5: блит нарисует мусор, причём молча.
|
||
`--bank-data` вдобавок действует на весь модуль, а `mobs` из того же файла
|
||
читают снаружи.
|
||
|
||
**ЧТО РАБОТАЕТ — EMM-страница с маппингом в W0.** W0 не переключают ни
|
||
трамплин (он трогает только W3), ни звуковой ISR (насос живёт в W3), значит
|
||
адрес `0x0000+off` валиден и в банке 7, и в банке 5, и в резиденте — ровно
|
||
как у атласных спрайтов, которые тем же блитом и рисуются. `blit_b_clip`
|
||
окно не трогает, маппингом занимается вызывающий.
|
||
|
||
**Цена.** `mob_spr_build` читает три части через `gfx_w0_map(a->page)`, а
|
||
приёмник должен быть в том же окне — держать оба нельзя. Сборку придётся
|
||
вести построчно: строка части в 64-байтовый буфер на стеке, перемаппить
|
||
окно, записать. Вызывается это лишь на смену тайлсета. Плюс
|
||
`gfx_w0_map`/`unmap` вокруг каждого куска — около 1350 тактов, при шести
|
||
кусках меньше 2 % кадра.
|
||
|
||
**Чем подтверждать.** Падающие плиты в комнате 1.2 и каскад уровня 13:
|
||
ошибка проявится не крашем, а мусором на месте куска.
|
||
|
||
**Дешёвая половинчатая мера** (если 1284 Б не нужны целиком): буфер объявлен
|
||
с запасом 64x20, фактический композит около 58x19 — точные константы дают
|
||
~180 байт без единого риска.
|
||
|
||
### <a id="pal-arc"></a>PAL-ARC. Палитры — в архив своей группы (полишинг)
|
||
|
||
Файлов палитр в игре шесть: `KID\kid.pal` (1024 Б), `PV\story.pal`,
|
||
`PV\pv.pal` (по 1024), `BG\pop_tile.pal`, `BG\pal_tile.pal` (по 128),
|
||
`TITLE\title.pal` (64). Всё, что лежит в `poc/res/tiles/` (36 файлов) и
|
||
`bg/pop_bg.pal`, на образ не попадает и кодом не читается — осадок ранних
|
||
экспериментов, его стоит просто убрать.
|
||
|
||
**Что предлагается.** Класть палитру в архив ТОЙ ЖЕ группы, где её
|
||
графика: `kid.pal` → `kid.arc`, оба `*_tile.pal` → `bg.arc`, `title.pal` →
|
||
`title.arc`, `story`/`pv.pal` → `pv.arc`. Тогда правило одно на все
|
||
ресурсы («файл лежит в архиве своей группы»), и уходят последние шесть
|
||
открытий: `pop_bg_load` берёт тайлсет и палитру за одно открытие, заставка
|
||
так же. Отдельно ценно, что `kid.pal` перестанет читаться своим `open` на
|
||
КАЖДОЙ загрузке уровня (её перезаливает `pop_pal_game_load`).
|
||
|
||
**Чего это стоит.** Элемент архива читается только в EMM-страницу,
|
||
поэтому нужна маленькая функция «палитра из страницы»: `bank_read` по 256
|
||
байт в буфер и четыре `gfx_pal_load`. Буфер уже есть — `pop_pal_buf` по
|
||
0x4000.
|
||
|
||
**Что теряется.** Возможность подменить палитру файлом рядом с игрой
|
||
(`pal_file_load` сейчас откатывается на `a:\kid.pal`). Решить явно:
|
||
либо файл остаётся приоритетным и архив — запасным путём, либо fallback
|
||
выбрасывается.
|
||
|
||
**Почему НЕ отдельная папка `PAL\` и не один общий атлас палитр.** Папка
|
||
не уменьшает числа файлов и рвёт связь «палитра ↔ её графика». Общий
|
||
атлас экономил бы открытия только при загрузке всех палитр разом, а они
|
||
нужны в разное время: `kid.pal` — каждому уровню, заставочные — только
|
||
заставке.
|
||
|
||
### <a id="hof-entry"></a>HOF-ENTRY. Таблица рекордов — ЗАКРЫТА 2026-08-26
|
||
|
||
> **Сделано.** Оба показа оригинала: между credits и attract-demo и хвостом
|
||
> `end_sequence` (с вводом имени, паузой 120 тиков и возвратом на титульную
|
||
> картинку). Экран — рамка story с логотипом, время в формате `59:43`,
|
||
> строки с тенью, мигающий курсор. Ожидание конца победной темы переехало
|
||
> за таблицу, как в оригинале. Протокол и грабли —
|
||
> [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#hof-entry).
|
||
|
||
### <a id="mus-won"></a>MUS-WON. Финальная тема — кольцевой стриминг — ЗАКРЫТА 2026-08-26
|
||
|
||
> **Сделано.** Кольцо из шести страниц (96 КБ = 9 с), насос заворачивается
|
||
> по `pop_mus_ring`, подкачка в `pop_music_service` по дистанции
|
||
> «проиграно / прочитано». Разбор и грабли — `../docs/sound_plan.md` §8.
|
||
> Проверено в MAME на сборке `LEVEL=14`.
|
||
|
||
<details><summary>исходная постановка</summary>
|
||
|
||
Трек 56 (`won`, 115 с) — это 1,2 МБ, 78 страниц EMM. Ни в `POP_MUS_PAGES`
|
||
(20), ни в разумный бюджет памяти он не влезает, поэтому единственный
|
||
оставшийся неозвученным кусок игры — экран победы.
|
||
|
||
**Что нужно.** Кольцевой буфер: держать в EMM несколько страниц (8 = 128
|
||
КБ хватит с запасом — это 12 с звучания), а загрузчик дочитывает в те, что
|
||
насос уже прошёл. Курсор насоса (`pop_mus_pg`) при этом обязан
|
||
заворачиваться, а загрузчик — держать дистанцию от него и не обгонять на
|
||
круг. Чтение страницы 33 мс против 1,5 с её звучания — запаса вдоволь,
|
||
вся сложность в аккуратности индексов, а не в скорости.
|
||
|
||
**Заодно.** Тот же механизм снимает ограничение `POP_MUS_PAGES` со всех
|
||
треков: сейчас каждый должен помещаться в память целиком.
|
||
|
||
</details>
|
||
|
||
### <a id="arc-atlases"></a>ARC-ATLASES. Атласы — в архивы PBA1 — ЗАКРЫТА 2026-08-26
|
||
|
||
> **Сделано.** Восемь архивов: kid (58 атласов), pv (78), shadow (32), bg
|
||
> (25), title (20), guard (10), vizier (5), skel (4). На образе было 232
|
||
> файла, стало 8 архивов плюс шесть палитр, `kid.ani` и таблицы уровней.
|
||
> Индексы печатает упаковщик (`ARC_<ГРУППА>_<ФАЙЛ>`), серии код проверяет
|
||
> статически. `atlas_load` разделён: `atlas_attach` принимает уже
|
||
> прочитанную страницу, поэтому чтение из архива не дублирует проверку
|
||
> магии. Грабля, стоившая отладки: имя архива обязано лежать в том же
|
||
> банке, что и код, который его читает, — строковый литерал из чужого банка
|
||
> после переключения W3 показывает на другие данные; поэтому наружу отдаётся
|
||
> НОМЕР архива (`pop_arc_open_id`).
|
||
>
|
||
> Остаток на будущее — палитры, см. [PAL-ARC](#pal-arc).
|
||
|
||
<details><summary>исходная постановка</summary>
|
||
|
||
**Что сделано.** Формат PBA1 и читатель готовы и работают на звуке:
|
||
`toolchain/pop_pack_arc.py` собирает архив (заголовок 512 Б, записи по 4
|
||
байта — offset в секторах, size в байтах), `pop_arc.c` открывает его ОДИН
|
||
раз на группу и читает элементы прямо в EMM-страницы. Набор эффектов
|
||
уехал в `SND\snd.arc` целиком.
|
||
|
||
**Что осталось.** Перевести остальные группы: `BG\` (25 файлов), `KID\`
|
||
(60), `GUARD/SKEL/VIZIER/SHADOW` (~50), `TITLE\` (21), `PV\` (75),
|
||
`LEVELS\` (15). Каждая группа — свой архив, потому что таблицу держит
|
||
вызывающий на стеке (4 байта на запись, у PV это 300 Б).
|
||
|
||
**Зачем.** Замер 2026-08-25: `open` 51,4 мс против 32,6 мс на чтение 16 КБ.
|
||
Стартовая пачка ресурсов — около 142 файлов, то есть **7,3 с чистого
|
||
`open`**, который архивы убирают почти целиком. Второй мотив назван
|
||
пользователем прямо: «не нравится огромное кол-во файлов» в корне диска.
|
||
|
||
**Чем подтверждать.** Время от запуска до первого кадра уровня (сейчас
|
||
меряется по MAME), число файлов на образе, и что каждая сцена по-прежнему
|
||
собирается при неполном диске (пути ошибок в `atlas_load`).
|
||
|
||
</details>
|
||
|
||
### <a id="mem-next"></a>MEM. Следующий шаг разгрузки W1/W2
|
||
|
||
`pop_ctrl.c` уехал в банк 5 ([MEM-BANK5](TASKS_CLOSED.md#mem-bank5), куча
|
||
180 Б → 2298 Б; после снижения `--max-allocs` — 2751 Б). Следующий кандидат
|
||
по тому же критерию (**не размер, а частота вызова и отсутствие горячих
|
||
банк→банк переходов**) — **расщепление `pop_kid.c`**: холодная половина
|
||
(загрузка страниц спрайтов, `pop_kid_load`) в банк, движок кадров
|
||
(`load_frame`/`play_seq`, 2×/кадр) оставить в резиденте.
|
||
|
||
Брать по факту нехватки места, не заранее. Таблица резидентного кода по
|
||
модулям и разбор, почему `pop_level.c` в банк НЕЛЬЗЯ, — в
|
||
[`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#mem-bank5).
|
||
|
||
---
|
||
|
||
## Отложено осознанно (не брать, пока не появится причина)
|
||
|
||
- **KBD-1, остаток** — «иногда при зажатом Shift стрелка всё-таки
|
||
пропускается» (ощущение пользователя, не замер: счётчики на 35 нажатиях
|
||
подряд потерь не показывают). Решение пользователя 2026-08-01: отложено до
|
||
финальной полировки. Где именно осталась дыра и что делать, если вернёмся,
|
||
— в [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#kbd-1) (там же весь протокол
|
||
замеров и список того, что делать НЕЛЬЗЯ).
|
||
- **Quickload (Shift+F9) и остальные читы SDLPoP** — оценка сделана
|
||
([DBG-CHEATS](TASKS_CLOSED.md#dbg-cheats)), код не написан. Самое ценное и
|
||
самое дорогое: сериализация `Char` + `room_modif` всех комнат + trob'ов +
|
||
стражей (`levels_plan.md` §4), зато даёт воспроизводимый регресс «вот кадр,
|
||
где баг» вместо ручной подгонки позы.
|
||
- **Звук** (CBL-эффекты, Фаза 5 `PORT_PLAN.md`) — геймплей не блокирует.
|
||
- **Таймер уровня / HUD времени / меню / сохранения** — Фаза 6.
|
||
- **[T-1](BUGS_OPEN.md#t-1)** (пики: перерисовка по причине) — отдаётся почти
|
||
бесплатно после [T-2](BUGS_OPEN.md#t-2), отдельно не окупается.
|
||
- **Отключение мыши на время игры** и **замена PRNG** —
|
||
`../docs/ideas_backlog.md` (оба дают доли процента кадра).
|
||
- **OPT-1** (хирургический редрой шва) — решено НЕ делать, стоимость
|
||
транзиентная; разбор в [`BUGS_CLOSED.md`](../../PoP/roomtest/BUGS_CLOSED.md).
|
||
- **KEYS-D — осмотр соседних комнат** (`H`/`J`/`U`/`N` + `Ctrl+B`, фаза D
|
||
плана [`keys_plan.md`](keys_plan.md)). Отложено 2026-08-27 по решению
|
||
пользователя: полезно игроку, но дорого.
|
||
|
||
**Почему дорого.** У SDLPoP это три строки, потому что там `drawn_room`
|
||
влияет ТОЛЬКО на отрисовку, а физика ходит через `get_tile(room, col,
|
||
row)` с явной комнатой. У нас наоборот: карта коллизий загружается для
|
||
ОТРИСОВАННОЙ комнаты — `room_fg`, `lcol_fg`, `rcol_fg`, `above_fg`,
|
||
`below_fg` — это ОДИН комплект на программу, и описывает он `cur_room`
|
||
(видно в `pop_qsave_restore_room`). Уведи `cur_room` к соседу, не трогая
|
||
`kid_room`, — и Кид начнёт считать столкновения по чужим тайлам:
|
||
провалится сквозь пол или упрётся в воздух.
|
||
|
||
Задел уже стоит: `kid_room` заведён отдельно, `update_kid_render_dx()`
|
||
умеет сдвигать спрайт на ±140 при расхождении. Но `enter_room_side()`
|
||
пишет обе переменные разом — это и есть незакрытая часть S3 straddle
|
||
([`room_model_plan.md`](room_model_plan.md)).
|
||
|
||
**Два пути.** (1) Честный, как в оригинале: игра идёт, пока смотришь.
|
||
Требует развести владельца карты коллизий — правка ядра, дни работы и
|
||
риск для того, что уже работает. (2) Смотровой режим С ОСТАНОВКОЙ игры:
|
||
пока ничто не тикает, рассогласование не проявляется. Нужна третья,
|
||
облегчённая «нарисовать комнату, не трогая состояние»: `enter_room_side`
|
||
попутно делает `pop_guard_leave`/`pop_loose_leave_room`, а
|
||
`pop_qsave_restore_room` перетирает карту и зовёт `pop_loose_reset`.
|
||
Оценка — 150-250 байт в банк 8 и несколько часов с проверкой.
|
||
|
||
Рекомендация: путь (2) плюс запись в `impl_diff.md` (у нас смотровой
|
||
режим останавливает игру, у оригинала — нет). Главный риск не в
|
||
отрисовке, а в ВОЗВРАТЕ: оставить движок с чужой картой или подтёртой
|
||
историей heal — класс багов из memory `pop_seam_room_model`.
|
||
|
||
Обходной путь, который уже работает: `Ctrl++` / `Ctrl+−` — телепорт по
|
||
номеру комнаты. Задачу разбора багов он закрывает, задачу игрока
|
||
«подсмотреть» — нет.
|
||
- **KEYS-R — воскрешение Кида** (`R`, остаток фазы C). Отложено
|
||
2026-08-27. Не ещё один чит, а правка модели смерти: SDLPoP ставит
|
||
`resurrect_time = 20` и `Kid.alive = -1`, после чего об этом обязаны
|
||
знать четыре места — убывание счётчика (`seg003:512`), пропуск пик и
|
||
челюстей (`seg000:1245`), пропуск урона мечом (`seg000:876`),
|
||
восстановление позы в `play_kid` (`seg006:1352`: `loadkid`, полное HP,
|
||
стойка, `Char.x += 8`, `set_start_pos`). То есть окно неуязвимости,
|
||
размазанное по кадровой цепочке, и оно задевает гейт «мёртв», про
|
||
который предупреждает memory `pop_level_restart_scope`. Наш
|
||
`Char.alive` использует конвенцию оригинала (`-1` жив), так что
|
||
портируется, — но точечно и с проверкой каждого гейта.
|
||
- **KEYS-F12 — скриншот в файл** (фаза E). Помечено пользователем как
|
||
«возможно, когда-то в следующих версиях»: 80 КБ на кадр и запись на диск
|
||
посреди игры.
|
||
|
||
---
|
||
|
||
## OPT-BLIT — цена отрисовки: чистка кодогена (СЛЕДУЮЩАЯ СЕССИЯ)
|
||
|
||
> **2026-08-17: контрольный замер снят, задача разложена по фазам.**
|
||
> Работа теперь ведётся в трёх документах, которые живут между сессиями —
|
||
> туда же писать результаты каждой правки:
|
||
>
|
||
> - [`../docs/perf_l13_room23.md`](perf_l13_room23.md) — сцена
|
||
> (каскад 6 плит, ур. 13 комната 23), рецепт воспроизведения, зонды, канал
|
||
> `clog`, сводка по кадрам, разбор габаритов спрайтов;
|
||
> - [`../docs/perf_green_phase.md`](perf_green_phase.md) — ЗЕЛЁНАЯ
|
||
> фаза: пик **805 000** при бюджете 400 000; позиции G1..G6;
|
||
> - [`../docs/perf_cyan_phase.md`](perf_cyan_phase.md) — ЦИАН фаза:
|
||
> пик **632 000** при бюджете 400 000; позиции C1..C7.
|
||
>
|
||
> Порядок работ: **G1** (окно клипа для запечки, −250..400 тыс.) → **G2**
|
||
> (расколоть `draw_tile` + лист для ряда −1) → **C1** (убрать избыточный
|
||
> `pop_cd_touch` в `mob_render`, −81 тыс.) → **G3/C3** (`blit_b_clip` на
|
||
> байтовые габариты) → **C4** (разобрать `pop_char_fore(KID)`: 1 722 → 150 978).
|
||
>
|
||
> Синяя секция (**142 830**) в бюджет укладывается — не трогаем.
|
||
> Ответ на вопрос про `uint8_t`: горячий путь можно переводить целиком,
|
||
> максимум по всем игровым атласам **56×63**; шире 255 только восемь
|
||
> полноэкранных подложек титров/сюжета (320×200 и т. п.), и они рисуются раз
|
||
> на экран — им отдельный банк / прямой libbgi.
|
||
|
||
Открыто 2026-08-13 по итогам разбора зелёного блока. Контекст и все замеры
|
||
— в memory `blit_cost_model` и `sdcc_z80_stack_locals_hot_loop`, протокол
|
||
разбора — в коммитах `f89b7dd` / `b27b313` / этом.
|
||
|
||
**Что уже сделано и чем это подтверждено.** Цена блита разложена регрессией
|
||
по 339 замерам: `такты = 8791 + 198,2*h + 5,96*(w*h)` (такты MAME = системный
|
||
клок ~21,5 МГц, НЕ такты Z80 — растровый кадр 430 000). Байт на пределе
|
||
железа (через акселератор дважды по 3 такта), строка ~198, а вот 8 791 на
|
||
ВЫЗОВ оказались нашим кодогеном: `pop_blit_b` держал `w`/`h` как `uint16_t`,
|
||
из-за чего вся функция уезжала в 14-байтовый стековый кадр и `w = img[0] |
|
||
(img[1] << 8)` разворачивалось в два десятка IX-относительных пересылок.
|
||
После перехода на байтовый габарит: noclip 13 679 -> 11 530 (-16%),
|
||
клипованный 18 853 -> 15 340 (-19%), `_CODE` -42 Б.
|
||
|
||
### а) Где ещё `uint16_t` можно сделать `uint8_t`
|
||
|
||
Габариты спрайтов и всё, что из них считается. Признак: значение заведомо
|
||
<= 255, но объявлено 16-битным «на всякий случай», и живёт в горячем пути.
|
||
Начинать с:
|
||
- `blit_b_clip` (`pop_tile.c`) — `dw`/`dh`/`sx`/`sy` объявлены `int`, хотя
|
||
ядра принимают `uint8_t`; это ВТОРОЙ по частоте путь (33% блитов).
|
||
- `pop_cd_touch` / `cd_cols_of` — четыре `int`-аргумента на вызов.
|
||
- `_gfx_blit_sprite_noclip` и обёртки libbgi: `stride` уже `uint16_t`, а
|
||
`w`/`h` — `uint8_t`; проверить, не расширяются ли они обратно у вызывающих.
|
||
- Кандидаты в `pop_room.c`/`pop_bg.c`: локальные `int x, dby, dmy` в
|
||
`draw_tile` — часть из них помещается в байт, но осторожно: координаты
|
||
бывают отрицательными.
|
||
|
||
**Как проверять:** после каждой правки смотреть пролог функции в
|
||
`.sprinter-cc-SprPoP/*.asm` — исчез ли `ld iy,#-N / add iy,sp / ld sp,iy`
|
||
и сколько осталось `-N (ix)` в теле.
|
||
|
||
### б) Проход по asm за неоптимальным IX-доступом
|
||
|
||
Механическая проверка, даёт больше всего за единицу усилий:
|
||
|
||
```
|
||
grep -c "(ix)" .sprinter-cc-SprPoP/*.asm # где сгущается
|
||
grep -n "ld iy, #-" .sprinter-cc-SprPoP/*.asm # крупные стековые кадры
|
||
grep -n "pop.*\n.*pop.*\n.*push" ... # чтение спилла через стек
|
||
```
|
||
|
||
Признаки беды (все три встречались сегодня): пролог с `iy`-кадром больше
|
||
~6 байт; пары `pop bc / pop hl / push hl / push bc` в теле цикла (SDCC
|
||
читает спиленный указатель через стек вместо `ld l,-N(ix)`); повторный
|
||
пересчёт адреса `arr[i]` под каждое поле структуры.
|
||
|
||
Приоритет по частоте вызова: `pop_blit_b` (сделан) -> `blit_b_clip` ->
|
||
`pop_cd_touch` -> `draw_tile` -> `pop_redraw_needed`.
|
||
|
||
### Что НЕ делать (проверено сегодня, отрицательный результат)
|
||
|
||
- **Не откладывать запекание на другой кадр.** Запекание пишет ОЗУ-копию
|
||
фона, из которой восстанавливает `heal`; отложенное даёт призрак плиты на
|
||
месте дыры. Идея «бюджет одного запекания на кадр» снята.
|
||
- **Не ускорять передачу пикселей** — она на пределе железа (3+3 такта на
|
||
байт), см. модель выше.
|
||
- **Не искать проблему в W3-скобке** (`_bgi_begin`/`_bgi_end` — по пять
|
||
инструкций) и не списывать разброс на прерывания (внутри размерной
|
||
группы разброс 3 такта, код прямолинейный).
|
||
|
||
### Хвосты этой сессии
|
||
|
||
- Пакетная пометка `pop_cd_touch` (`pop_cd_batch_begin/end`, скобка в
|
||
`draw_tile`) — сделана, но выигрыш замером НЕ подтверждён: в захваченных
|
||
кадрах скобка не срабатывала (блиты шли из холодной отрисовки комнаты).
|
||
Переснять на кадрах ЗАПЕКАНИЯ. **2026-08-17: цена НЕпакетного вызова
|
||
замерена — 4 502 такта, 28 % всей цены блита; в `mob_render` он к тому же
|
||
избыточен (см. C1).**
|
||
- Снять временную оснастку: `pop_dbg_m9..m16`, `pop_dbg_b1..b6`,
|
||
`pop_dbg_wh`, `pop_dbg_kind`, `pop_dbg_rdmax*`, `mame/v306/run_bridge_log.sh`.
|
||
- Цель по кадру не достигнута: зелёный пик был 792 012 при цели 400 000.
|
||
После сегодняшних правок не перемерян — начать сессию с контрольного
|
||
замера, а не с новых правок.
|
||
|
||
**ГРАБЛИ (стоили сегодня часа):** проверять, что запущен РОВНО ОДИН MAME
|
||
(`pgrep -f mame.arm | wc -l`) — несколько экземпляров пишут в один
|
||
`error.log`, мост говорит с одним, замеры собираются с другого, и точки
|
||
«не срабатывают». И не обрезать `error.log`, пока MAME его держит: она
|
||
пишет по старому смещению, в начале остаётся дыра из нулей.
|