Files
Sprinter-SDCC/applications/SprPoP/docs/BUGS_OPEN.md
T
snark13 400f5cba63 SprPoP: поправка в CLIMB-VS-GUARD — звук 11 значит ПРОМАХ
В записи стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет.  Это
было описание НАШЕГО кода, а не SDLPoP: ровно так звук у нас и стоял —
внутри ветки «не парировано» и до присвоения Opp.action = 99_hurt (снято
предыдущим коммитом).  Классическое нарушение правила «источник истины —
SDLPoP»: за оригинал приняли собственную реализацию, и вывод из неё уехал
в доску как факт.

На деле звук 11 в оригинале защищён условием Opp.action != 99_hurt, то
есть звучит ТОЛЬКО на промахе: на попадании играет боль, на парировании
Opp.frame уже 161.

Что это меняет для самого бага:

- УЦЕЛЕЛО наблюдение «в оригинале 11, у нас 8» — это данные.  Арифметика
  приоритетов их подкрепляет: snd_prio[11] = 0x12 против snd_prio[8] =
  0x4B (меньше значит важнее), взмах не мог быть заглушён упором в стену.
- ОТПАЛ вывод «оригинал ведёт стража атакой с промахом, а мы —
  столкновением»: он опирался на неверную посылку.
- УСИЛИЛАСЬ версия про ПОРОГ ДИСТАНЦИИ: раз в оригинале слышен именно 11,
  укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ.  Прежняя «версия про столкновение»
  вытеснила дистанцию зря — возвращаем её в главные подозреваемые.

Отдельно предупреждение тому, кто вернётся к багу: диагностика по звуку
теперь значит другое, старые заметки прогона будут вводить в заблуждение,
сцену надо переснимать.  Положена таблица соответствий (11 = промах, 13 =
попадание, 8 = столкновение, тишина = ранний выход из check_hurting).

На механику урона правки не влияют — расхождение «удар убивает» остаётся
открытым как было.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2k4VZrSyERwk6H97Sfi1
2026-09-02 17:41:07 +03:00

1485 lines
117 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SprPoP — ОТКРЫТЫЕ баги и незакрытые оптимизации
**Здесь ТОЛЬКО незакрытое.** Всё закрытое (и, что важнее, разбор корней)
живёт в [`BUGS_CLOSED.md`](../../PoP/roomtest/BUGS_CLOSED.md) — прежде чем заводить новый баг,
грепни там по симптому. Сырые формулировки пользователя с прогонов —
[`bugs_level1.md`](bugs_level1.md) / [`bugs_level2.md`](bugs_level2.md).
Приоритеты работ — в [`TASKS_OPEN.md`](TASKS_OPEN.md) (закрытые задачи с
протоколами — [`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md)), а не здесь. Правило
проекта: механику сверять с `../SDLPoP/src/` ДО кодинга.
**Ревизия 2026-08-11:** файл вычищен от закрытых записей (правило «в `_OPEN`
только открытое»). Уровни 1-4 приняты smoke-тестами; крупных багов нет.
| ID | что | тип | статус |
|----|-----|-----|--------|
| [SPRITE-ZERO-W0](#sprite-zero-w0) | кадр персонажа изредка отдаёт спрайт 0x0 (залипание уже вылечено) | **редкий** | открыт: нужна трасса маппинга W0 |
| [GATE-FORE-KID](#gate-fore-kid) | Кид в проёме ворот виден поверх решётки | окклюзия | **фикс есть, ждёт проверки в MAME** (2026-08-13) |
| [FORE-DUP](#fore-dup) | передний слой тайла рисуется дважды при перекрытии объектов | оптимизация | открыт: **сначала замерить**, потом чинить |
| [TORCH-ANIM-RIGHT](#torch-anim-right) | под запечённым пламенем застывают не только челюсти чомпера | **низкий** | открыт: на уровнях 1-4 такого соседства нет |
| [T-1](#t-1) | пики перерисовываются безусловно | оптимизация | открыт |
| [BUG-SPIKE-1](#bug-spike-1) | пики залипают выдвинутыми рядом с Кидом | **низкий** | маловоспроизводим: ни сценарием, ни попиксельной подгонкой X не поднимается |
| [BUG-CHOMP-JUMP-1](#bug-chomp-jump-1) | прыжок с места вплотную к чомперу: кадр с отступом назад | **низкий** | маловоспроизводим: на повторе не поднялся; на пререлиз |
| [GRAB-KBD-TIMING](#grab-kbd-timing) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | подозрение на клавиатуру ПОДТВЕРДИЛОСЬ частично: кража байт DSS устранена 2026-08-28, перепроверить |
| [GUARD-ENTRY-DEATH](#guard-entry-death) | вход в комнату вплотную к стражу = мгновенная смерть (не портирован `bump_into_opponent`) | порт | **фикс есть, ждёт проверки в MAME** |
| [BUG-SHADOW-SET](#bug-shadow-set) | Тень на 12-м уровне дерётся спрайтами СТРАЖА: SHADOW.DAT у нас нет вовсе | порт | открыт, разбор готов |
| [PAL-L1-AFTER-INTRO](#pal-l1-after-intro) | вход в игру на уровень 1 после интро — игровая палитра не установлена (чёрный экран) | палитра/fade | открыт: воспроизведение нестабильно, корень не установлен |
| [PAL-DUNGEON-STALE](#pal-dungeon-stale) | переход 3→4 (подземелье→дворец): остаётся палитра подземелья | палитра/fade | открыт: КОРЕНЬ ЯСЕН — fade_in восстанавливает из протухшего снимка |
| [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 |
| [MID-OVERLAY-LAYER](#mid-overlay-layer) | оверлей кромки всегда поверх плит и персонажей (у оригинала — midtable с сортировкой) | слои | открыт: нужен разбор архитектуры |
| [GUARD-FALLOUT-VICTORY](#guard-fallout-victory) | страж, выпавший из комнаты, не засчитывается убитым | порт | открыт: две строки |
| [KBD-STUCK-WAIT](#kbd-stuck-wait) | потерянный break-код клавиши вешает игру НАСМЕРТЬ в межуровневой заставке | клавиатура/клин | **корень найден и устранён** 2026-08-28 (байты крал KEYSCAN DSS); ждёт полевой проверки |
| [L10-BUTTON-DEBRIS](#l10-button-debris) | ур.10 к.1: плита упала на кнопку (1,8) — щебень есть, а решётки закрыты | кнопки/ворота | открыт: **не воспроизводится**, проверено многое (см. разбор) |
| [LOOSE-SEAM-ANIM](#loose-seam-anim) | плита в шве (col −1), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные; толкование звука ИСПРАВЛЕНО 2026-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
<a id="l10-button-debris"></a>
## L10-BUTTON-DEBRIS. Плита, упавшая на кнопку (ур.10, к.1) — щебень есть, решётки закрыты
**Наблюдение (пользователь, 2026-08-24).** Уровень 10, комната 1: Кид стоит
на кнопке `(1,8)` и роняет на неё плиту `(0,8)`. Кнопка превращается в
щебень, но решётки, которые она открывает, остаются закрытыми. Важная деталь
из того же прогона: на 10-й уровень пришли **телепортом Shift+L**, а не
проходом.
**Ожидаемое поведение (оно же оригинал).** Плита, севшая на opener, жмёт его
ОДИН раз с `button_type = tiles_14_debris`; у `trigger_gate` это отдельная
ветка «открыть насовсем»: `modifier >= 188` -> сразу `0xFF`, иначе возврат 2 и
`animate_door` доводит створку до `0xFF`. Ворота с `0xFF` заморожены
открытыми, обычный opener их больше не трогает. Сама кнопка съедается в
щебень. Разобрано в [BUG-LOOSE-BUTTON-1](BUGS_CLOSED.md#bug-loose-button-1).
**НЕ ВОСПРОИЗВЕЛОСЬ** (2026-08-24, отладочный старт `make LEVEL=10 ROOM=1
POS=18`, MAME): Кид стоит на кнопке -> решётка `(2,7)` открылась; удар головой
роняет плиту `(0,8)`; кнопка стала щебнем; **решётка осталась открытой** и
после ухода Кида с тайла. Пользователь тоже не смог повторить в этой сборке.
**Что проверено и совпадает с оригиналом:**
* цепочка кнопки — ровно две цели, обе в комнате 1: `(2,9)` и `(2,7)`
(`bg = 20`, терминатор цепочки — бит 0x80 в LINKLOC, как в seg007:0C09);
* `trigger_gate` построчно равен оригиналу, включая обе ветки debris;
* `add_trob` дедуплицирует по (room, tilepos) — как `find_trob` оригинала,
двойных записей на одни ворота не бывает;
* `animate_door` гасит анимацию при `modifier == 0xFF` и доводит тип 2 до
`0xFF`;
* таймеры связей в файле уровня 10 нулевые (не «jammed» 0x1F);
* `pop_dl1/pop_dl2` перечитываются из файла при каждой загрузке уровня, а
`pop_level_reset_tiles` восстанавливает LINKMAP из эталонной страницы.
**Живые гипотезы (по убыванию правдоподобия):**
1. **Ветка `return 2` (створка ещё не «полностью» открыта).** Визуально
решётка выглядит открытой уже при `modifier ~ 140` (проходимость считается
как `(modifier >> 2) + 6 >= высота кадра`), а мгновенный `0xFF` требует
`modifier >= 188`. То есть глазами «открыто», а по коду — ветка с
анимацией; её в MAME поймать пока не удалось.
2. **Накопленное состояние после телепорта Shift+L** через несколько уровней
(у нас уже был такой класс: [GUARD-DROPPEDOUT-CARRY](BUGS_CLOSED.md#guard-droppedout-carry)).
Отладочный старт сразу на 10-м даёт чистое состояние — и баг не всплывает.
3. Кнопка была нажата/отпущена ранее в этой же сессии, и падение пришлось на
момент, когда trob кнопки уже умер, а таймер связи ещё не истёк.
**Как ловить в следующий раз:** как только симптом повторится — сразу
**QuickSave (F6)**, не выходя из сцены. Сохранение везёт с собой LINKMAP
(`pop_level_qsave` пишет `pop_dl1/pop_dl2`) и модификаторы комнат, то есть даёт
ровно то состояние, которое сейчас не удаётся собрать искусственно.
---
<a id="kbd-stuck-wait"></a>
## KBD-STUCK-WAIT. Потерянный break-код вешает игру насмерть в межуровневой заставке
> **КОРЕНЬ НАЙДЕН И УСТРАНЁН 2026-08-28.** Байты у нас крал сам DSS — см.
> раздел «Корень» ниже. Правка в `libc/irq/_irq_tramp.c`. Запись остаётся
> открытой до полевой проверки и решения по страховке-таймауту.
## Корень (2026-08-28): скан-коды воровал KEYSCAN DSS
Пришёл из чтения исходников DSS (`docs/sources/Estex-DSS/DSS/`, подсказка
пользователя). Обработчик прерывания DSS живёт в IM1 по `0x0038`
(`DSS-MAIN.ASM`), и его `INTx38_Handler` ПЕРВЫМ ДЕЛОМ делает `CALL KEYSCAN`,
а `KEYSCAN.RESCAN` (`KEYINTER.ASM`) вычерпывает FIFO SIO досуха.
Наш трамплин проверял «есть ли клавиатурный байт» ОДИН раз, на входе в
прерывание, а хвост кадрового пути уходил в DSS (`jp 0x0038`). Значит
скан-код, прилетевший ПОЗЖЕ этой проверки, доставался DSS и уезжал в его
буфер — при этом **Rx-overrun не взводится**, потому что байт не потерян
железом, он просто прочитан не тем владельцем. Отсюда и загадка
исходного диагноза: бит залип, а `_kbdraw_overrun == 0`.
**Измерено в MAME (сессия 2026-08-28, брейки + `totalcycles`):**
| Что | Значение |
|---|---|
| окно от входа трамплина до чтения байта DSS | **738 тактов ≈ 34 мкс**, на КАЖДОМ кадровом прерывании (50/с) |
| из них пролог самого DSS (`jp 0x38``.Handler``call INTx38_Handler` → 11 `push``call KEYSCAN`) | 481 такт ≈ 22 мкс |
| цепочка кадровых хендлеров у SprPoP | пуста (`_irq_chain_n = 0`) — окно уже минимальное |
| расчётная частота на железе при удержании клавиши (typematic 30 Гц = 60 байт/с) | потеря байта раз в ~10 с |
Последняя строка сходится с полевым наблюдением пользователя: залипание
стрелки ловилось на ур. 14, когда Кид долго бежит влево через несколько
комнат.
**Пойманный случай (MAME, старая сборка).** Стоп на `IN A,($18)` внутри
`KEYSCAN` при `_kbdraw_active = 1`: DSS прочитал `A = 0x74` — make стрелки
«вправо», при том что `_kbdraw_pending = 2` (EXT), то есть префикс `E0` уже
разобрали МЫ. Посылка `E0 74` разорвана пополам между двумя владельцами
канала. Злейший подвид — кража `E0` у break'а `E0 F0 6B`: тогда мы гасим
бит в PLAIN-половине, а взведённый бит в EXT остаётся навсегда, и это ровно
залипшая стрелка `0x6B`/`0x72` из исходного разбора.
**Правка.** Пока raw-канал открыт, кадровый путь трамплина НЕ вызывает
обработчик DSS вообще (приватный `RETI` вместо `jp 0x0038`) — окно ровно
ноль. Промежуточный вариант «проверить FIFO перед chain'ом» пробовали и
отвергли замером: он снимает лишь свою треть окна (738 → 481 такт), кражи
продолжались. Цена принятого варианта: пока открыт raw, у DSS замирает
кадровое обслуживание — опрос мыши и мигание текстового курсора (PoP не
нужно ни то, ни другое; зафиксировано в `<kbd_raw.h>`).
**Проверка (MAME 2026-08-28).** До правки брейк на входе `KEYSCAN` с
условием «страница точно DSS + наш raw открыт» срабатывал мгновенно (50/с);
после правки молчит, а положительный контроль — брейк на нашем приватном
`RETI` — срабатывает сразу. ВАЖНО про методику: первый брейк без проверки
сигнатуры байтов дал ЛОЖНОЕ срабатывание — в W0 периодически лежит
драйверная страница DSS, и по адресу `0x0570` там своя команда.
**Что осталось открытым.** Кража устранена, но потерять break всё ещё
может настоящий Rx-overrun SIO (редкий: FIFO вычерпывает `kbd_raw_poll` из
главного цикла). Значит вариант A из таблицы ниже — таймаут в циклах
«ждём отпускания всех клавиш» — остаётся разумной страховкой от КЛАССА
отказа «вечный клин», и его стоит сделать отдельно.
## Исходный разбор (2026-08-24)
**Наблюдение (пользователь, 2026-08-24).** Нажат Shift+L (чит «следующий
уровень») — игра встала намертво: картинка прежнего уровня, реакции нет.
**Диагноз (снят в живой сессии MAME).** Программа НЕ рухнула и не потеряла
кадровые прерывания — IRQ приходили штатно (вход по вектору виден в
`history`). Стояли в `pop_pre_cutscene_show` (pop_intro.c:949), в его первом
цикле:
```c
while (kbd_raw_any_down()) { kbd_raw_sync(); gfx_wait_vsync(); }
```
Цикл намеренный: клавиша, которой закончили уровень (тот самый Shift+L), не
должна тут же сойти за «пропустить заставку». Внутри — `gfx_wait_vsync`
(опрос бита 5 порта #FE), он отрабатывал нормально; не выходил ВНЕШНИЙ цикл.
Причина — залипший бит в `_kbdraw_down[]`: код `0x72` в PLAIN-половине карты
(то есть **keypad 2**; стрелка Down — это `E0 72` и живёт в EXT-половине,
байт 46). Состояние на момент разбора: `_kbdraw_active = 1`,
`_kbdraw_pending = 0`, `_kbdraw_overrun = 0` — то есть штатная страховка по
Rx-overrun не срабатывала. Гашение бита отладчиком (`_kbdraw_down[14] = 0`)
сразу вернуло игру к жизни: заставка прошла, уровень 2 загрузился.
**Чем бит может залипнуть** (разбор `libc/irq/_irq_tramp.c`):
1. **Потерянный break при переполнении FIFO SIO.** Приёмник глубиной 3
байта; когда прерывания запрещены надолго (загрузка уровня через ESTEX —
ровно момент Shift+L), пачка `F0 code` не помещается. Защита есть
(чтение RR1 → `_kbdraw_overrun``kbd_raw_sync()` снимает held), но она
срабатывает, только если SIO успел выставить сам бит overrun.
2. **Разъехавшийся префиксный автомат.** `F0` (break) и `E0` (ext) — отдельные
байты, состояние копится в `_kbdraw_pending`; любой сброс между префиксом
и кодом переворачивает смысл, и break декодируется как MAKE. Ветка
«fake shift» (`E0 12` / `E0 59`) делает это по построению — она гасит
`pending` и пишет код как make даже у break-формы.
3. **Make и break у разных владельцев канала.** Пока `_kbdraw_active == 0`,
байты уходят DSS. Клавиша, нажатая до `kbd_raw_open()` (или в момент
close/open), даёт make одному владельцу, а break — другому: снимать
нечего, бит остаётся.
4. **Держащий вход эмулятора.** `set_input ... frames=0` из MCP-моста держит
клавишу нажатой, typematic шлёт make повторно (наблюдалось: погашенный
отладчиком бит возвращался). Это артефакт отладки, а не игры, но именно
он мог создать исходное состояние в этой сессии.
**Варианты решения.**
| Вариант | За | Против |
|---------|----|--------|
| **A. Таймаут в циклах ожидания** (ждать отпускания не дольше ~1 с, дальше идти) | Дёшево, локально; снимает КЛАСС отказа «вечный клин» независимо от причины; заставка от лишней секунды не страдает | Лечит симптом, а не причину: залипшая клавиша дальше продолжит «нажиматься» в gameplay |
| **B. Анти-залипание в `kbd_raw`** (гасить биты, которые давно не подтверждались typematic-повтором) | Чинит класс причин целиком, помогает и в gameplay | **Модификаторы typematic НЕ повторяют** (обжигались: `kbd_overrun_wipe_modifiers`) — значит Shift/Ctrl гасить нельзя, нужен список исключений; плюс хранение «свежести» на 512 кодов дорого, придётся огрублять |
| **C. Ужесточить страховку по overrun** (чистить held не только по биту RR1, но и по «подозрительным» ситуациям: длинный DI, возврат из загрузки) | Бьёт по самой вероятной причине (1); дёшево по памяти | Эвристика: можно снять РЕАЛЬНО зажатую клавишу (Кид перестанет бежать посреди разбега) |
| **D. Ничего, считать артефактом отладки** | Ноль работы | Если это не мост, а SIO, то на железе получаем намертво зависшую игру — худший класс отказа |
**Если возвращаться — начинать с точной причины:** лог байтов SIO в
трамплине (кольцо на 32-64 байта + дамп по клавише) и повтор сценария
«Shift+L во время дисковой загрузки». Без этого выбор между B и C — гадание.
**Смежное:** `kbd_raw_fifo_drain`, `kbd_overrun_wipe_modifiers` в memory —
там уже закрытые случаи залипания того же битмапа.
---
<a id="grab-kbd-timing"></a>
## GRAB-KBD-TIMING. Зацеп в падении срабатывает НЕ ТАК СТАБИЛЬНО, как в оригинале — РАЗБОР ПОСЛЕ ВСЕХ УРОВНЕЙ
> **Решение пользователя (2026-08-12): отложено до готовности всех уровней.**
> Механика работает, это вопрос ощущения, а не проходимости.
> **Обновление 2026-08-28.** Гипотеза «дело в клавиатурном модуле, а не в
> физике» подтвердилась ЧАСТИЧНО: найдена и устранена кража скан-кодов
> обработчиком DSS (разбор — [KBD-STUCK-WAIT](#kbd-stuck-wait)). Украденный
> make Shift'а или стрелки — это ровно «нажал, а не сработало», и на
> железе при удержании клавиши такое случалось раз в ~10 с. Перепроверить
> ощущение зацепа на новой сборке ДО того, как лезть в физику.
**Наблюдение (пользователь).** Вход на уровень 7: Кид влетает в комнату сверху
и, пролетая мимо края пола, МОЖЕТ за него зацепиться — но получается заметно
реже, чем в оригинале. «Похоже, это проблемы нашего клавиатурного модуля».
**Что уже сделано и почему это НЕ закрывает вопрос.** В ходе разбора
[GRAB-BELOW-ROOM](BUGS_CLOSED.md#grab-below-room) восстановлена ветка
`ACT_MIDAIR` в `check_action` (кадры 102..105 — начало падения, где `fall_y` ещё
не разогнан): раньше на этих четырёх кадрах `check_grab` не звался вовсе, то
есть окно зацепа было короче оригинального. Это должно было добавить
стабильности, но саму гипотезу про клавиатуру не проверяет.
**Куда смотреть, когда дойдут руки.** Порядок именно такой — сначала
измерить, потом чинить:
1. Снять, ЧТО видит движок в момент падения: `pop_ctrl_shift_held()` покадрово
(Shift зажат заранее — читается ли он каждый кадр, или FIFO SIO отдаёт
состояние с пропусками; см. memory `kbd_raw_fifo_drain`,
`kbd_overrun_wipe_modifiers` — оба прошлых бага были именно про модификаторы).
2. Сверить с оригиналом ширину окна по кадрам: у нас `check_grab` доступен на
102..105 (midair) + весь `ACT_FREEFALL`, как в seg006 — расхождений в коде
быть не должно, значит расхождение либо во вводе, либо в темпе игры
([L1-SPEED](TASKS_OPEN.md#l1-speed): игра идёт на ~39 % быстрее оригинала,
а окно зацепа отмеряется В КАДРАХ — то есть по времени оно у нас короче).
3. Хост-тест `t_grab.c` уже умеет мерить ширину окна (карта исходов по фазе X и
задержке нажатия) — на нём и проверять «стало стабильнее».
### Протокол замера (написан 2026-08-12, чтобы следующий заход начался с фактов)
Пользователь связывает с этим же и «двойные срабатывания не вовремя». Прежде
чем трогать драйвер — доказать, что он вообще виноват: **сегодняшний случай
«Кид ходит как с зажатым Shift» оказался артефактом MCP-моста, а не драйвера**
(`pause` освободил 6 незакрытых входов, которые плагин переустанавливал каждый
кадр; как только они отпустились, драйвер снял бит сам). То есть break-коды
драйвер обрабатывает верно, и ложная тревога здесь стоила часа.
Что мерить:
1. **Поток скан-кодов**: кольцевой лог в трамплине прерывания
(`libc/irq/_irq_tramp.c` / `kbd_raw_poll.c`) — байт кода, флаг make/break,
номер кадра. Читать из MAME по адресу буфера (`mem`), как читаем `_Kid`.
2. **Что увидел движок**: `pop_ctrl_shift_held()` и битмап `_kbdraw_down`
(адрес — из `.map`, для SprPoP 0xAEF9) на тех же кадрах. Расхождение
«код пришёл, а движок не увидел» = потеря в драйвере; «кода не было, а бит
стоит» = потеря break (то, что подозревает пользователь).
3. **Сцены**: бег с отпусканием, разворот, вис + отпускание Shift, падение с
зажатым заранее Shift (вход на уровень 7), быстрое повторное нажатие
(проверка «двойного срабатывания» — typematic, см. memory
`kbd_overrun_wipe_modifiers`).
Что считать нормой: каждому make ровно один break; между make и видимым
`kbd_raw_down()` — не больше кадра; при удержании typematic-повторы НЕ должны
доходить до `pop_ctrl` как новые нажатия (диспетчер ждёт фронт).
Отдельная гипотеза, которую замер тоже закроет: окно зацепа отмеряется В
КАДРАХ, а игра идёт на ~39 % быстрее оригинала ([L1-SPEED](TASKS_OPEN.md#l1-speed))
— тогда «менее стабильно» это не клавиатура, а темп, и чинить надо темп.
---
<a id="уровень-2"></a>
# С приёмки уровня 2 (2026-08-07)
Всё найденное тем прогоном закрыто, кроме двух записей ниже
(`BUG-SPIKE-1` — уровень 2, комната 6). Карта содержимого уровня 2 (что где
стоит по `res2002.bin`, какие кнопки какие ворота открывают) — в
[`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#l2-pass), по ней видно, «механика не
сработала» это или «так и задумано».
<a id="bug-spike-1"></a>
## BUG-SPIKE-1. Пики залипают выдвинутыми — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ
> **Статус 2026-08-05, вечер.** Пользователь **повторить не смог**, а замер
> (ниже) показал, что чистый пробег по убранным пикам убивает штатно. То
> есть в смертельности бага, похоже, нет вовсе: наблюдался частный случай —
> пики, УЖЕ выдвинутые полностью (h = 1), для бегущего безвредны и в
> оригинале. Запись оставлена открытой ровно из-за визуального расхождения
> со скриншотом SDLPoP (у нас острия торчат, у него убраны) — см. конец.
>
> **Как получить состояние нарочно:** подойти к пикам вплотную (выдвинутся),
> отступить на полшага, НЕ выходя из зоны срабатывания, и пробежать по ним.
> Пользователь пробовал и это, и попиксельную подгонку X читом `[`/`]` —
> не поднялось. Вывод для будущего разбора: состояние **не чисто
> позиционное**, одной шириной габарита его не объяснить; следующий
> подозреваемый — момент, в который `process_trobs` застаёт модификатор
> относительно кадра Кида.
**Наблюдение (пользователь, 2026-08-05).** Уровень 2, комната 6, пики (1,3):
| действие | что происходит |
|----------|----------------|
| длинный прыжок с ряда 0 на пики | **смерть — правильно** (путь `fell_on_spikes`) |
| пробег по ряду 1 прямо по пикам | **урона нет** |
| после уборки пик | **на экране остаются белые остатки остриёв** (в оригинале чисто) |
| прыжок на месте, стоя на пиках | урона нет |
| просто стоять на выдвинутых пиках | можно сколько угодно |
**Замер (MAME, чтение `room_modif` комнаты 6).** Пока Кид стоит на тайле,
модификатор пики (индекс 13) = **0x8E**, то есть «пики ПОЛНОСТЬЮ вышли и
идёт обратный отсчёт». Дальше вся арифметика сходится с оригиналом:
```
is_spike_harmful (seg007:1178): 0/-1 → 0; <0 → 1; 1..4 → 2; >=5 → 0
check_spiked (seg006:0658): убивает при h>=2 на кадрах бега 7..14
и при h!=0 на кадрах приземления 43/26
```
То есть **при h = 1 (пики уже вышли) бегущий не гибнет и в оригинале**
смертельно только окно ВЫДВИЖЕНИЯ (модификатор 1..4, h = 2). Наши
`animate_spike`, `start_anim_spike`, `is_spike_harmful`, `check_spiked`
сверены с seg006/seg007 построчно и совпадают дословно.
**ВТОРОЕ НАБЛЮДЕНИЕ (то же место, сравнение с оригиналом).** Кид уронил
плиту-потолок и спрыгнул вниз; пики выдвинулись и «спрятались не все — часть
артефактов осталась». Скриншоты рядом:
[наш](bugscreens/l2-r6-spikes-ours.png) и
[SDLPoP](bugscreens/l2-r6-spikes-sdlpop.png) в той же позе. У нас из-под
щебня торчат белые острия, у оригинала пик не видно ВООБЩЕ.
**ГИПОТЕЗА «ТРИГГЕР СРАБАТЫВАЕТ РАНО» ПРОВЕРЕНА И ОПРОВЕРГНУТА (замер
2026-08-05, MAME, watchpoint на `room_modif[13]` комнаты 6).** Чистый
пробег по УБРАННЫМ пикам убивает штатно:
```
запись modif=1 : кадр 11 (беговой), x=112, curr_col=2 ← пики пошли вверх
запись modif=2 : кадр 12 (беговой), x=117, curr_col=3 ← Кид уже НА тайле, h=2
запись modif=3 : кадр 177 (frame_177_spiked) ← напоролся
```
То есть `check_spike_below`, `check_spiked`, `is_spike_harmful` и тайминг
выдвижения работают правильно, и «раннего» триггера нет.
**Настоящий корень — пики ЗАЛИПАЮТ выдвинутыми.** Пока габарит Кида
накрывает колонку пики, `check_spike_below` каждый кадр зовёт
`start_anim_spike`, а тот при отрицательном модификаторе переставляет его
обратно в 0x8F — отсчёт до уборки не доходит. А выдвинутые пики (h = 1)
для бегущего БЕЗВРЕДНЫ по правилам оригинала. Отсюда обе жалобы: пробег
по уже вышедшим пикам не убивает, и они же остаются торчать на экране.
**Что осталось выяснить (и это единственный открытый вопрос).** Код
`start_anim_spike` у нас с оригиналом совпадает дословно, значит оригинал
тоже удерживал бы пики, стой Кид там же. На скриншоте SDLPoP в похожей
позе пики УБРАНЫ — то есть его Кид стоит чуть левее и его габарит колонку
пики уже не задевает. Разница в 2–3 пикселя посадки, а у нас такие
расхождения по X уже ловились (см. заметку в BUG-GRAB-1: после касания
площадки SDLPoP уводит Кида на 134, мы — на 141).
**Как закрывать:** инструментировать SDLPoP (печать `char_x_left/right`,
`left/right_checked_col` и модификатора пики каждый кадр), проиграть ту же
сцену — падение плиты-потолка в комнате 6 и остановку на щебне — и сверить
с нашей трассой ПОЗИЦИЮ КИДА после приземления. Если позиции совпадут, а
диапазоны колонок разойдутся — виноват габарит (`kid_fp` против
`set_char_collision` текущего кадра); если разойдутся позиции — это отдельный
баг посадки, а пики — его следствие.
---
<a id="уровень-3"></a>
---
# Оптимизация отрисовки (записано 2026-07-29)
Не баги — план работ. Оба пункта про одно: у оригинала пометка тайла к
перерисовке стоит копейки (бит в таблице, которая всё равно чистится каждый
кадр), а у нас каждая такая пометка превращается в реальный heal (копию из
ОЗУ-копии акселератора) плюс блиты. Поэтому буквальный порт «перерисовываем
безусловно» корректен, но дорог.
<a id="t-1"></a>
## T-1. Пики: перерисовывать по причине, а не безусловно
**Сейчас:** `pop_process_trobs` зовёт `pop_spike_redraw` каждый кадр для
каждой живой пики в комнате (порт `redraw_21h`, который `animate_spike`
вызывает вне всяких `if`). Это корректно, но лишнее для пик, до которых
Киду дела нет.
**Надо:** перерисовывать тайл пики, только если
1. **сменился её видимый кадр** (шаг выдвижения/уборки), ЛИБО
2. **её кто-то стёр** — а стереть у нас может только heal, то есть тайл
попал в прямоугольник `kid_heal` этого кадра.
Это и есть модель оригинала, просто выраженная флагами: `redraw_at_char`
(seg003:0576) каждый кадр помечает `set_redraw_fore` тайлы персонажа, причём
**объединение текущего и предыдущего** прямоугольника
(`MIN(char_top_row, prev_char_top_row)` и т.д.), а `animate_spike` помечает
свой тайл. Итог = {тайл сменил кадр} ∪ {тайлы Кида}.
**Как:** слой Кида и так считает `cL..cR`/`rT..rB` в `pop_fore_over_kid`
пусть публикует их (плюс предыдущие, как в оригинале), а цикл trob'ов
сравнивает `tilepos` с диапазоном целочисленно. Никаких пересечений
прямоугольников (см. память `manual_hints_over_auto_detect`).
**Приоритет:** отдаётся почти бесплатно ПОСЛЕ T-2, отдельно не окупается.
<a id="t-2"></a>
---
<a id="bug-chomp-jump-1"></a>
## BUG-CHOMP-JUMP-1. Прыжок с места вплотную к чомперу: кадр с отступом назад — НИЗКИЙ ПРИОРИТЕТ, МАЛОВОСПРОИЗВОДИМ
> **Статус 2026-08-08.** Наблюдение пользователя на приёмке L3-CHOMP: Кид
> стоит вплотную СЛЕВА от чомпера лицом вправо, прыжок с места — в анимации
> проскакивает кадр, где он «чуть отступил назад», и только потом идёт
> прыжок. В оригинале прыжок идёт прямо с места. **На повторе в тот же
> заход не воспроизвёлся** — отложено до пререлиза.
**Что уже установлено (чтобы не разбирать заново).**
1. **Это НЕ анимация.** `seq_3_standing_jump` (SDLPoP `seqtbl.c:382`)
состоит только из положительных смещений:
`act(run_jump), f16, f17, dx(2) f18…f22, dx(7) f23, dx(9) f24, dx(5) dy(-6) f25`.
Ни одного отрицательного `dx` — отката в последовательности нет вовсе.
Значит отступ даёт `bumped()`, то есть физика посчитала въезд в
препятствие и выровняла `Char.x` назад.
2. **`is_obstacle` для чомпера у нас совпадает с оригиналом** (seg004:037E):
препятствие только при `modif == 2`, причём именно `== 2`, БЕЗ маски
`0x7F` — то есть окровавленный чомпер (`0x82`) в ванили не бампит вовсе.
Проверено, расхождения нет.
3. **Прямая трасса прыжка отката НЕ показала.** Ввод «UP и RIGHT
одновременно» (mame bridge, `key UP 24` + `key RIGHT 24`) даёт чистое
движение вперёд: `x` 148 -> 155, кадр становится 178 (перемололо). Ни
одного кадра с уменьшением `x`.
**Главная гипотеза — ПОРЯДОК НАЖАТИЙ.** Если ↑ приходит на кадр раньше →,
то `control_standing` уходит не в `standing_jump()`, а в `up_pressed()`
вертикальный прыжок с зацепом, а он **выравнивает `Char.x`**. Отсюда и
«отступил, потом прыгнул». Проверять надо `check_jump_up` /
`jump_up_or_grab` / `grab_up_no_floor_behind` (seg005:0836 и далее), а не
прыжок с места. Внимание на `can_climb_up` (seg005:0828): там у чомпера
ЕСТЬ спецкейс (`seq_73_climb_up_to_closed_gate` при взгляде ВПРАВО) — он у
нас портирован (`pop_map.c`, ветка `TILE_MIRROR || TILE_CHOMPER`), но именно
вокруг него и стоит копать.
**Как ловить.** Брейкпоинт на резидентном `_kid_tick` с
`{printf "f=%d x=%d act=%d col=%d",b@Kid+0,b@Kid+1,b@Kid+6,b@Kid+4; g}`
одна строка на кадр, адрес `_Kid` из `.sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map`
(после КАЖДОЙ пересборки другой). Плюс стоп-кадр `1` в момент отступа и
чтение `Kid` из памяти. Метод — memory `z80_profiling_method`.
---
## Заметки (отладка)
- Тестовые клавиши осторожного шага: **J** = шаг влево, **L** = шаг вправо
(эмуляция Shift+стрелка), см. `pop_ctrl.c` `KBD_DBG_STEP*`. Первый шаг в
сторону = разворот (как в оригинале safe_step), движение со второго.
- Читы (`pop_cheat.h`): **K** — убить стража, **I** — бессмертие (toggle),
**S** — выдать меч, **Shift+L** — следующий уровень, **`[`/`]`** — сдвиг
Кида на пиксель по X.
- Респавн после смерти — по **↑** (или авто через `RESPAWN_DELAY`); ставит
Kid в стартовую позицию УРОВНЯ (`pop_start_level`, порт do_startpos).
- **ROOMNAV (`=`/`-`) — тоже наш чит**, которого в оригинале не было, как и
`S`. Все они со временем съедутся в общий блок читов, разрешаемый в
настройках; пока просто включены (`pop_cheats = 1` в `sprpop.c`).
Известный баг этого чита закрыт — [BUG-CHEAT-FIGHT-1](BUGS_CLOSED.md#bug-cheat-fight-1).
- Комнаты **13, 18, 24 уровня 1 недостижимы** в обычной игре — это свойство
данных уровня (разбор — «НЕ БАГИ» в [`BUGS_CLOSED.md`](../../PoP/roomtest/BUGS_CLOSED.md));
приоритет багов в них низкий. Аналогично 23/24 на уровне 3.
---
<a id="torch-anim-right"></a>
## TORCH-ANIM-RIGHT. Под пламенем могут застыть не только челюсти чомпера
Открыто 2026-08-10 при закрытии
[BUG-TORCH-CHOMP-2](BUGS_CLOSED.md).
Пламя факела запекается в фон и рисуется в ячейке ПРАВОГО СОСЕДА. Оригинал
после каждого кадра факела метит этого соседа (`set_redraw_anim_right`,
seg007:0101) и перерисовывает весь его слой `anim` поверх огня; мы метим
только когда сосед — чомпер. Слой `draw_tile_anim` (seg008:0644) рисует
также **пики, зелье и меч** — если такой тайл окажется справа от факела и
будет в статике, пламя накроет и его.
На уровнях 1–4 такого соседства не встретилось, поэтому расширять пометку
(она стоит перерисовки тайла каждый кадр на каждый факел) заранее не стали.
**Как проверять:** поставить факел слева от пики/меча/зелья в тестовой
комнате и дождаться статики; либо пройти уровни 5+ и смотреть на клетку
справа от каждого факела.
**Как чинить, если встретится:** там же, в ветке факела `pop_process_trobs`,
добавить в `trob_rcode`-проверку нужные коды и соответствующий вид пометки
(`POP_RD_SPIKE` / `POP_RD_FLOOR`), а зелье — переставить в порядке обхода
так, чтобы оно рисовалось ПОСЛЕ факела.
---
<a id="fore-dup"></a>
## FORE-DUP. Передний слой тайла рисуется ДВАЖДЫ при перекрытии объектов
Найдено пользователем 2026-08-10 (вопросом, а не по симптому — картинка
верная, страдают только такты).
**У нас.** Fore-проход зовёт КАЖДЫЙ рисующий по своему прямоугольнику:
`pop_char_fore` для слотов Кида и соперника, `pop_mirror_draw` для отражения.
Окно клипа (`pop_fore_set_clip`) у каждого своё. Если футпринты двух
объектов накрывают один тайл, его передний кусок рисуется два раза.
Когда случается:
- **Кид и отражение** — ВСЕГДА (стоят на одном тайле зеркала); этот случай
создан фиксом fore-прохода над отражением 2026-08-10;
- **Кид и соперник в одном тайле** — то есть весь ближний бой;
- Кид и падающий кусок плиты.
**Картинку не портит:** куски переднего слоя идут прозрачным блитом-копией,
операция идемпотентная. Портило бы при XOR/mono с накоплением — таких в
fore-слое нет.
**Такты тратит,** и в самом дорогом месте: fore-проход исторически самая
тяжёлая часть кадра (memory `pop_fore_layer_cost` — было 78 % кадра, окно
клипа и кэш кладки дали 3.2×).
**Как устроено в оригинале — дубль НЕВОЗМОЖЕН по построению.**
`redraw_at_char` (seg003:0427) ничего не рисует, а только помечает тайлы
своего прямоугольника:
```c
for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row)
for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col)
set_redraw_fore(get_tilepos(tile_col, tile_row), 1);
```
а `set_redraw_fore` (seg007:0550) — это `redraw_frames_fore[tilepos] = frames`,
ПРИСВАИВАНИЕ. Два персонажа на одном тайле оставят там ту же единицу, и
единственный обход тайлов нарисует передний кусок один раз.
**Что делать.** Механизм пометок у нас уже есть и прямо назван портом этой
архитектуры — `pop_redraw.h`. Fore-слой остался единственным местом на
прямом вызове. Приведение к оригиналу: `pop_char_fore`/`pop_mirror_draw`
вместо прохода ставят пометки, а один проход в конце кадра их разбирает.
**СНАЧАЛА ЗАМЕРИТЬ, потом чинить.** Это самый горячий путь, и окно клипа
(вместо перебора тайлов) в своё время дало 3.2× — переход на пометки может
часть этого вернуть назад. Замер: брейкпоинт на листьях fore-слоя со
счётчиком, сцена «бой в комнате 3 уровня 1» и «Кид на тайле зеркала,
ур. 4»; сравнить число нарисованных кусков с числом уникальных тайлов.
Если дубль мал — оставить как есть и закрыть запись.
---
---
<a id="sprite-zero-w0"></a>
## SPRITE-ZERO-W0. Кадр персонажа изредка отдаёт спрайт 0x0 — РЕДКИЙ
Всплыло при разборе [BUG-CHAR-STALE-PAGE](BUGS_CLOSED.md#bug-char-stale-page)
(тень застыла на двух страницах в разных позах). Залипание слота вылечено —
`cd_sig_make` больше не берёт снимок, если кадр не рисовали, — но САМА
причина нулевого спрайта не найдена: `atlas_image` для валидного кадра
изредка отдаёт запись 0x0.
Подозрение: конкуренция за окно W0. `pop_char_draw` маппит страницу атласа
(`gfx_w0_map(pages[page].page)`) и держит её до `gfx_w0_unmap()` в конце, а
внутри этого окна успевают отработать `cd_splash` и `pop_sword_draw`; рядом
в кадре W0 маппят чтение данных уровня (`pop_level_tile`, `pop_level_set_tile`)
и mob-тик. Если какой-то путь размапливает окно раньше времени, чтение
заголовка спрайта даст нули.
Как ловить: watchpoint на порт окна 0 либо счётчик «w==0 при непустом кадре»
в `pop_char_draw` с записью кадра/страницы/idx в отладочную ячейку, дальше
читать её из MAME. Симптом редкий (пользователь поймал дважды).
<a id="gate-fore-kid"></a>
## GATE-FORE-KID. Кид, стоящий В ПРОЁМЕ ворот, виден ПОВЕРХ решётки — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ
> **Портировано 2026-08-13** (повтор нашёлся на уровне 10, комната 7, тайл
> (2,6) — подтверждено чтением памяти MAME: `Kid.room=7, curr_row=2,
> curr_col=6`, код тайла `0x04`). Сделано так:
>
> * **решение** — `pop_gate_over_char(ch)` в `pop_map.c`: персонаж стоит на
> тайле ворот текущей комнаты (колонки 0..8; девятая отсекается, там бары
> легли бы в соседнюю комнату — у оригинала это `Kid.room != room_R`);
> * **отрисовка** — `draw_gate_fore` в `pop_bg.c`, точный порт seg008:1153:
> только прозрачные куски 51 + ломти 52, без непрозрачного 50 и без
> хвостового `DOOR_FRAM_SLICE` (фон под барами уже нарисован). Зовётся
> ОДИН раз на персонажа за кадр, не в тайловом цикле;
> * **тесты** — `tests-host/t_char.c`: `char_gate_covers_char_standing_in_it`,
> `char_gate_ignores_neighbour_tiles`, `char_gate_ignores_other_room`,
> `char_gate_never_in_last_column`. Отрисовка в харнесс не линкуется,
> поэтому проверяется именно решение — ради этого оно и вынесено в карту.
>
> Расхождение с оригиналом (осознанное, в `docs/impl_diff.md`): оригинал
> спрашивает жёстко про `Kid`, потому что foretable у него одна на проход
> тайлов; у нас fore-проход идёт по персонажу, поэтому спрашиваем про того,
> кого рисуем — страж в проёме тоже уходит за решётку.
>
> Шовный случай (ворота у СОСЕДА слева) работал и раньше — он ниже.
> Банк 2 +601 Б, банк 3 +92 Б, резидент не изменился.
Нашёл пользователь 2026-08-11 (уровень 5, комната 24, верхние ворота):
Кид стоит на тайле ворот, а решётка рисуется ПОД ним — он виден целиком,
хотя должен быть за прутьями.
**Как в оригинале.** `draw_tile_fore` (seg008:0D15) первой же строкой:
```c
if (tile_left == tiles_4_gate && Kid.curr_row == drawn_row &&
Kid.curr_col == drawn_col - 1 && Kid.room != room_R) {
draw_gate_fore();
}
```
то есть когда Кид стоит ИМЕННО на тайле ворот, их решётка дорисовывается
в **foretable** — поверх персонажа: `draw_gate_fore` (seg008) кладёт спрайт
51 (низ решётки) и дальше вверх кусками по 8 px спрайтом 52, до
`gate_top_y`, прозрачным блитом.
**Что есть у нас.** Портирован только ШОВНЫЙ случай (`pop_bg.c`,
`pop_fore_over_char`): если футпринт зашёл за левый шов и у соседа слева в
этом ряду ворота — их бары перерисовываются поверх персонажа через
`draw_gate_back`. Случая «ворота ВНУТРИ комнаты, персонаж на их тайле»
нет вовсе, отсюда симптом.
**Почему не чинится одной строкой.** Правка идёт в `pop_fore_over_char`
самый горячий путь кадра (memory `pop_fore_layer_cost`: когда-то 78 %
кадра, окно клипа дало 3.2×). Добавлять проверку надо так, чтобы она не
стоила ничего на каждом тайле футпринта: условие дешёвое (`тайл под
персонажем == ворота`), но рисование — это ещё один блит на кадр, пока
Кид стоит в проёме. Плюс нужен свой расчёт полосы (`gate_top_y` по
живому openness), а не готовый `draw_gate_back`, который рисует ЗА
персонажем.
**Проверять:** уровень 5, комната 24 — встать в проём верхних ворот
(колонка 1 ряда 0) при частично поднятой решётке; прутья должны
перекрывать Кида. Тот же случай — любые ворота на уровнях 1-3.
---
<a id="guard-entry-death"></a>
## GUARD-ENTRY-DEATH. Вход в комнату со стражем вплотную = мгновенная смерть — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ
**Наблюдение (пользователь, 2026-08-13).** В некоторых ситуациях (особенно
после телепортов) Кид, входя в комнату, сразу оказывается чуть ли не в одном
тайле со стражем и немедленно погибает: меч ещё в ножнах, а безоружному любой
укол смертелен.
**Что в оригинале.** Стража при входе НЕ отбрасывает — проверено по SDLPoP:
`enter_guard` (seg002:0112) ставит его ровно в тайл из данных уровня
(`Char.x = level.guards_x[room-1]`), никаких проверок дистанции до Кида нет.
Ситуация «вошёл вплотную» возможна и там. Защит четыре:
1. **`bump_into_opponent` (seg003:08AA)** — главная. Безоружный Кид
(`Char.sword == sword_0_sheathed`) при вооружённом сопернике
(`Opp.sword != sheathed`, `Opp.action < 2`), лицом к лицу
(`Char.direction != Opp.direction`) и `can_guard_see_kid >= 2`, на
`ABS(char_opp_dist()) <= 15` не получает укол, а ОТСКАКИВАЕТ: `Char.y`
прижимается к полу, `fall_y = 0`, `seq_47_bump`.
2. Кид сам достаёт меч — `control_standing` (seg005:0351).
3. Страж поднимается в стойке покоя с мечом в ножнах — `seq_77`, ему нужно
сперва заметить Кида (`autocontrol_guard_inactive``move_down_forw`).
4. Вплотную страж не колет: `autocontrol_guard_active` при `distance < 12`
(или `< 8` с убранным мечом) разворачивается/шагает, а `check_hurting`
требует `min_hurt_range = 8` для безоружного противника.
**Корень у нас.** Пункты 2-4 были портированы верно (`pop_ctrl.c:288`,
`pop_guard_cold.c:107`, `guards.c:553`), а **пункт 1 не портирован вовсе**:
в `kid_phys` (`pop_map.c`) между `determine_col()` и `check_collisions()`
было пусто, тогда как `play_kid_frame` (seg000:1238) зовёт там
`bump_into_opponent`. Без отскока дистанция успевает вырасти в рабочий
диапазон удара 8..29 — и первый же `move_6_shift` стража даёт
`hurt_by_sword` с `take_hp(100)`.
**Сделано.** `bump_into_opponent` портирован в `pop_map.c` (рядом с
`bumped_floor`) и вызывается из `kid_phys` на штатном месте. Резидент не
вырос, банк 3: 11066 → 11156 Б. Опциональные фиксы SDLPoP
(`fix_painless_fall_on_guard`, `fix_jumping_over_guard`) намеренно НЕ
портированы — оставлено базовое поведение движка 1989 года.
**Проверять:** войти в комнату со стражем вплотную (телепортом `+`/`` или
`Shift+L`) без меча в руке — Кид должен отскочить с `seq_47`, а не умереть;
падение на стража сверху по-прежнему безболезненно (это оригинал, не баг).
---
<a id="loose-room-change"></a>
## LOOSE-ROOM-CHANGE. Расшатанная плита не доваливается, если Кид ушёл в соседнюю комнату — ФИКС ЕСТЬ, ЖДЁТ ПРОВЕРКИ
**Наблюдение (пользователь, 2026-08-13).** Уровень 10, комната 2: в (2,9)
стоит плита, которая под бегом Кида должна разрушаться. Если Кид пробегает
по ней и уходит в правую комнату, плита не успевает разрушиться и упасть —
переключение комнаты обрывает алгоритм.
**Что в оригинале.** loose-пол — обычный **trob** (`make_loose_fall`,
seg007:0EF6 → `add_trob(curr_room, curr_tilepos, 0)`), а `process_trobs`
(seg007:0000) крутит ВЕСЬ список каждый кадр, независимо от `drawn_room`:
`animate_tile` делает `get_room_address(trob.room)` и работает с таблицей
той комнаты, где trob живёт. Фаза лежит в `curr_room_modif`, то есть прямо
в таблице уровня. Поэтому брошенная плита досчитывает до
`loose_floor_delay`, `remove_loose` убирает тайл, `add_mob` рождает кусок —
и всё это без Кида в комнате. Ровно так же ведут себя `mobs[]`
(`do_mobs`, seg007:1063) — их список чистится только в `start_level`.
**Корень у нас.** Падающие КУСКИ (mob) чужую комнату умели давно
(`pop_loose_mob_room_changed`, [BUG-LOOSE-2](BUGS_CLOSED.md)), а вот ФАЗА
тряски/отсчёта жила в `pop_loose_modif[30]` — массиве ТЕКУЩЕЙ комнаты, —
и `pop_loose_reset()` при смене комнаты обнулял его целиком. То есть
плита, брошенная на полпути, возвращалась в покой. То же для плит-потолков
(`pop_ceil_modif[10]`).
**Сделано** (двухуровневое хранение вместо переноса всей механики в trob —
`pop_loose_modif` остаётся быстрым массивом отрисовки текущей комнаты):
1. `pop_loose_leave_room()` (`pop_map.c`, зовётся первым делом в
`enter_room_side`, пока карта ещё описывает СТАРУЮ комнату) — сдаёт фазу
всех loose-тайлов в персистентный `room_modif` (это и есть
`curr_room_modif` оригинала) и заводит trob на незаконченные.
2. `animate_loose()` (`pop_trob.c`, case `TILE_LOOSE` в `pop_process_trobs`)
— досчитывает фазу заочно; на провале ставит тайл в EMPTY
(`pop_level_set_tile`), кладёт в модификатор тип уровня (`remove_loose`)
и рождает кусок через новый `pop_loose_mob_spawn_at(room, row, col)`.
Пока комната ОТРИСОВАНА, trob снимается сразу — хозяин фазы
`pop_loose_tick`.
3. `pop_loose_reset()` теперь не обнуляет, а ЗАБИРАЕТ фазу новой комнаты из
`room_modif`: вернувшийся Кид застаёт плиту на той же фазе.
4. `pop_loose_forget()` — старт/рестарт уровня: забыть фазы БЕЗ сдачи
(у оригинала всё сносит `load_level`). Без него первый же `enter_room`
после `pop_trob_reset()` занёс бы в свежий уровень дрожь из прошлой жизни.
Резидент не изменился (20569 Б); банк 3 +451 Б, банк 6 +181 Б, банк 7 +62 Б.
Хост-тест `phys_loose_survives_room_change` (`tests-host/t_phys.c`) закрывает
передачу фазы.
**Проверять:** уровень 10, комната 2 — пробежать по плите (2,9) в правую
комнату и вернуться: дыра должна быть на месте. Регресс: комната 7
(две соседние плиты подряд), комната 12 плита (0,1) → уход в 15
([BUG-LOOSE-2](BUGS_CLOSED.md)), плита-потолок, рестарт уровня после смерти
на дрожащей плите.
---
<a id="loose-seam-anim"></a>
## LOOSE-SEAM-ANIM. Плита в шве исчезает без анимации падения — МЕЛКИЙ, ОТЛОЖЕН
> **Решение пользователя (2026-08-13):** «пока пусть будет так».
**Наблюдение.** Уровень 10: Кид расшатывает плиту (2,9) комнаты 2 и убегает
вправо в комнату 7, где та же плита видна в шве как (2,−1). Плита
доваливается правильно (см. [LOOSE-ROOM-CHANGE](#loose-room-change)) и с
экрана исчезает, но **мгновенно** — без дрожания и без падающего куска.
**Почему так.** Заочный досчёт (`animate_loose`, `pop_trob.c`) сознательно
не рисует: комната не отрисована, а фаза живёт в `room_modif`, мимо
`pop_loose_modif`, на который смотрит отрисовка дрожащего кадра. Исчезновение
видно только потому, что провал меняет ТАЙЛ, а по этому событию
(`pop_neigh_dirty`) главный цикл перечитывает кромку и перерисовывает шов.
Падающий кусок (mob) рождается в комнате 2 и честно летит, но `mob_tick_one`
рисует только `here` (своя комната), а комната 2 — не отрисованная.
**Что надо, если браться.** Шов — это НЕ обычный тайл: чужая колонка 9
рисуется внутри нашей колонки 0 (`draw_tile`, ветка `lcode == 11`), а
дрожащий кадр берётся из `pop_loose_modif[row*10 + (col-1)]`, то есть при
`col == 0` индекс уезжает за границу массива (для ряда 0 — в −1). Значит
анимация шва потребует не «включить рисование», а дать чужой колонке
собственный источник фазы. Заодно там же чинить и этот выход за границу.
**Проверять:** тот же сценарий уровня 10; в оригинале плита в шве дрожит и
роняет кусок так же, как в своей комнате.
---
<a id="jump-floor-legs"></a>
## JUMP-FLOOR-LEGS. Прыжок с пола: ноги Кида поверх кромки пола — ПОЧИНЕН, ЖДЁТ ПРОВЕРКИ
> **Корень (найден трассой 2026-08-13).** Проход оверлеев в
> `pop_fore_over_char` обходил **только два крайних ряда** футпринта:
>
> ```c
> if (action != 2) other_overlay_tile(rB, c); /* опорный */
> if (rT != rB) other_overlay_tile(rT, c); /* верхний */
> ```
>
> Это буквальный порт `redraw_at_char2` (seg003:0645), и в оригинале он
> верен: там `char_top_row` и `char_bottom_row` ВСЕГДА соседние. У нас между
> ними появляется дыра — `char_footprint` форсит `rT = rB − 1` (голова торчит
> в ряд выше), а затем окно fore-клипа расширяет `rB` ещё на ряд вниз.
>
> На спорном кадре трасса из MAME дала `rT=0 rB=2 cL=0 cR=1 do_ov=1` и **ноль**
> вызовов `overlay_mid_tile`: ряды 0 и 2 обошли, ряд 1 — тот самый, где стоит
> плита пола, — пропустили. Отсюда и подсказка пользователя «глубже в падении
> плита рисуется правильно»: там `y_to_row` даёт уже 2, ряд 1 попадает в пару
> крайних, и всё сходится.
>
> **Фикс:** обходить ВЕСЬ диапазон `rB..rT`, сохранив правило оригинала «при
> `action == 2` опорный ряд не трогаем». После фикса трасса на том же кадре:
> один вызов `overlay_mid_tile`, исход для (1,1) = «нарисовали», коды тайлов
> `curr=3 left=0` — байт в байт как в отладочном выводе SDLPoP.
>
> Пилларный оверлей, который «работал и раньше», к этому отношения не имел:
> это `fore_id = 95` из отдельного fore-прохода, а не midtable-оверлей.
>
> Банк 2 +28 Б. Хост-тесты (6 наборов) проходят.
### Разбор, по которому искали (оставлен: метод пригодится)
**Наблюдение (пользователь, 2026-08-13).** Уровень 10, комната 1, прыжок с
пола. В оригинале нижняя часть Кида скрыта ЗА передней гранью пола, у нас
рисуется весь Кид поверх неё (за передней колонной он при этом уходит
правильно). Скриншоты — в переписке сессии.
**Состояние кадра (снято из памяти MAME на замороженном кадре, сборка
2026-08-13):**
| поле | значение |
|------|----------|
| `Kid.frame` | 103 (`frame_103_start_fall_2`) |
| `Kid.action` | 3 (`actions_3_in_midair`) |
| `Kid.x` / `Kid.y` | 69 / 127 |
| `Kid.direction` | 1 (влево) |
| `Kid.curr_col` / `curr_row` | 0 / 2 |
| `Kid.room` / `cur_room` | 1 / 1 |
`pop_y_land[2] = 118` — то есть ноги на 9 px НИЖЕ уровня пола ряда 1, ровно в
полосе его передней грани (`draw_tile_bottom` рисует её на `63*row + 65 = 128`).
Тайлы комнаты 1 (`room_fg`, прочитаны там же):
```
ряд 0: 01 01 00 00 00 00 00 00 04 0B
ряд 1: 04 00 03 13 10 11 13 01 07 0F
ряд 2: 0C 01 03 01 01 0F 01 01 04 03
```
Кромочная колонка левого соседа (`lcol_fg[0..2]`): `04 0C 04`.
**Что УЖЕ проверено и исключено.**
1. **Гейт `redraw_at_char2` (seg003:0645) портирован верно.** Сверено строка
в строку: наш `do_ov` в `pop_fore_over_char` даёт то же множество поз
(захват 78-79, `action` 2/3/4/6, `bumped` 102-106, начало подъёма 135-136),
и climb-ветка 137..144 тоже совпадает. При `action == 3` оверлей
ВКЛЮЧАЕТСЯ — дело не в гейте.
2. **`draw_other_overlay` (seg008:1492) на колонке 0 не сработал бы и в
оригинале.** Обе его ветки требуют пустого соседа: `tile_left == empty`
(у нас `lcol_fg[1] = 0x0C`, doortop — не пусто) либо `drawn_col > 0` и
пустой тайл ЧЕРЕЗ ОДИН слева. Значит окклюдер — НЕ он, и наш порт этой
функции здесь ни при чём.
3. **`pop_tile_code(row, 1)` кромку читает правильно** — из `pop_t_lfg`, а не
«стена всегда».
**Две живые гипотезы, проверять в этом порядке.**
* **Это вообще не отрисовка, а физика.** У нас `Kid.y = 127` при уровне пола
118 — Кид «утоплен» на 9 px, и ноги торчат ниже кромки просто потому, что
они там и есть. Проверяется дёшево: снять `Char.y` у SDLPoP на том же
кадре 103 той же последовательности. Если там 118..120 — искать надо в
seqtbl/`fall_speed`, а не в слое фона.
* **Окклюдер — другой тайл/другой механизм.** Тогда нужна очная ставка:
прогнать SDLPoP с трассой (`dbg_shots`-fprintf в `draw_other_overlay` и
`add_kid_to_objtable` уже стоят в нашей копии, см. memory
`pop_pixel_diff_vs_sdlpop`) и сравнить СПИСОК тайлов, попавших в midtable
на этом кадре, с тем, что рисуем мы. Гадать дальше по коду смысла нет —
два прохода по seg008 уже не дали ответа.
**Проверять после починки:** тот же прыжок в комнате 1 уровня 10; за передней
колонной Кид уходит и сейчас — это не должно сломаться.
---
<a id="mob-clip-right"></a>
## MOB-CLIP-RIGHT. Окна и узор кладки рисуются поверх падающих плит
**Найдено прогонами 2026-08-13 (уровень 13, комнаты 16 и 23).**
**Симптом.** На падающей плите проступают элементы ЗАДНЕЙ стены — узор
кладки, а на ряде 0 и целое окно. Плита при этом цела: элементы ложатся
поверх неё, не стирая.
**Корень.** `mob_render` (`pop_room.c`) каждый кадр вызывает
`draw_tile(r, m->col + 1)` — перерисовывает ЦЕЛЫЙ соседний тайл поверх уже
нарисованного куска, чтобы спрятать его правую часть (спрайт 42 лежит в
столбце `col+1`). `draw_tile` кладёт всё содержимое тайла, включая декор
пустого тайла — окно и узор. Ряд берётся из координаты куска, поэтому на
ряде 0 перерисовывается тайл с окном.
Правка [#6](#) (клип перерисовки окном коридора heal) сделала артефакт
ЗАМЕТНЕЕ: раньше перерисовка размазывалась вокруг, теперь попадает точно в
прямоугольник плиты.
**Как в оригинале.** Такой перерисовки нет вовсе. `redraw_at_cur_mob`
(seg007:1094) зовётся ТОЛЬКО из `loose_fall`, один раз в момент сбития, и
лишь ставит пометки на тайл и соседа. Правая часть куска прячется
собственным клипом объекта: `add_mob_to_objtable` (seg007:1161) задаёт
`clip.right = 40`.
**Что делать.** Портировать клип вместо подпорки:
1. выяснить, во что превращается `clip.right = 40` в наших координатах
(у оригинала `obj_x` в удвоенных единицах: `char_x_left = obj_x / 2 + 58`);
2. блитить правую часть (env 42) с ограничением ширины — примитив с шириной
есть для персонажей (`gfx_blit_cols_part_w`), для env надо посмотреть;
3. убрать `draw_tile(r, col+1)` и окно fore-клипа вокруг него;
4. вернуть коридор heal к 64x32.
**Побочная выгода:** уходит по одному полному `draw_tile` на кусок за кадр —
при шести падающих плитах это шесть отрисовок тайла.
---
<a id="mid-overlay-layer"></a>
## MID-OVERLAY-LAYER. Оверлей кромки всегда поверх, а должен сортироваться
**Найдено разбором 2026-08-13.**
`overlay_mid_tile` (`pop_bg.c`) выполняется в проходе ПОСЛЕ персонажей, то
есть безусловно поверх всего нарисованного. В оригинале это `draw_other_overlay`
(seg008:1499), и он переключает таблицу на **midtable**
тот же слой, где живут персонажи и падающие куски (`add_mob_to_objtable`,
тип `0x80`), а внутри слоя всё сортируется по `y` (`compare_curr_objs`,
seg008:1572).
Следствие: узор из оверлея выигрывает у плиты, даже когда плита ближе к
зрителю. Пока у нас нет сортируемого midtable, точечными правками это не
лечится.
Связано с [BG-ONCE](TASKS_OPEN.md#bg-once) — там та же техническая
сердцевина: привести наши слои в соответствие с таблицами оригинала.
---
<a id="guard-fallout-victory"></a>
## GUARD-FALLOUT-VICTORY. Выпавший из комнаты страж не засчитывается убитым
**Найдено разбором 2026-08-13** (при проверке чита `K` на Джафаре).
У `on_guard_killed` в оригинале ДВА места вызова:
| место | что |
|---|---|
| `play_guard`, seg006:1494 | HP кончились — **портировано** |
| `check_guard_fallout`, seg002:264 | страж выпал из комнаты — **НЕ портировано** |
Наш `pop_guard_fallout` (`pop_guard.c`) просто гасит слот. Значит столкнуть
Джафара в пропасть на 13-м уровне можно, а выход на 14-й от этого не
откроется — расхождение с оригиналом.
Правка: позвать `on_guard_killed()` в ветке «не скелет». Мешает только то,
что функция сейчас `static` в `guards.c` (банк 1), а `pop_guard_fallout`
живёт в резиденте.
---
<a id="leveldoor-startroom-wipe"></a>
## LEVELDOOR-STARTROOM-WIPE. В стартовой комнате за входной дверью рисуется лестница вместо черноты
**Найдено разбором 2026-08-17** (попутно к фиксу
[LEVELDOOR-PALACE-CLIP](BUGS_CLOSED.md#leveldoor-palace-clip) — то же
`draw_leveldoor`).
`draw_leveldoor` (seg008:1D29) в приподнятой створке различает две комнаты:
```c
if (modifier_left) {
if (level.start_room != drawn_room) {
add_backtable(..., 144 /*level door stairs*/, ...);
} else {
short leveldoor_width = (tbl_level_type[current_level] == 0) ? 39 : 48;
sbyte x_low = (tbl_level_type[current_level] == 0) ? 2 : 0;
add_wipetable(0, 8*(draw_xh + 1) + x_low, ybottom - 4, 45, leveldoor_width, 0);
}
}
```
Смысл: за дверью, **через которую вошли на уровень**, лестницы нет — там
чернота, и оригинал её ЗАТИРАЕТ прямоугольником, а не рисует марш 144.
Ширина затирки дворцовая/подземная (48 против 39) и подземная сдвинута на
2 px вправо — то есть здесь ЕЩЁ одно расхождение подземелья и дворца, помимо
уже починенного `leveldoor_right += 8`.
У нас (`pop_room.c`, `draw_leveldoor`) стоит безусловно:
```c
if (modif)
pop_env_b(144, x, ybottom - 4); /* лестница (комната ≠ стартовой) */
```
— условие названо в комментарии, но не реализовано. Различие стартовой
комнаты у нас есть только в логике входа (`pop_leveldoor_enter`,
`pop_map.c:1166`), не в отрисовке.
**Когда видно:** только пока створка входной двери приподнята
(`modif != 0`) в стартовой комнате — то есть в анимации входа на уровень и
если дверь снова открыть. При закрытой двери `modif == 0`, и обе ветки
ничего не рисуют, поэтому баг и не попался на обходах уровней 1-4.
**Что мешает сделать прямо:** у нас нет понятия wipetable — нужен чёрный
прямоугольник в проходе backtable, то есть либо прямой `gfx`-fill в
`draw_leveldoor`, либо запечка. Проверять на уровне 1 (подземелье, ширина
39, сдвиг 2) И на уровне 4 (дворец, 48, сдвиг 0) — иначе поймаем только
половину.
### <a id="bug-shadow-set"></a>BUG-SHADOW-SET. Тень в бою рисуется спрайтами стража
**Симптом** (найдено пользователем 2026-08-20): в бою Тень выглядит не
Кидом. В SDLPoP она Кид всегда.
**Корень.** Набор спрайтов выбирает не charid, а поле `sword` САМОГО
КАДРА: `load_frame_to_obj` (`seg008.c:1752`) держит `chtab_base` жёстко
равным `id_chtab_2_kid` и прибавляет `cur_frame.sword >> 6`. У всех 241
кадра `frame_table_kid` эти биты нулевые, у всех кадров `frame_tbl_guard`
`0xC0`. Тень берёт таблицу стража для кадров 150..189 (`seg006.c:533`),
значит идёт через chtab_5.
Дальше и кроется наша ошибка: **chtab_5 — это не «страж», а «соперник
уровня»**. Он грузится из `tbl_guard_dat[tbl_guard_type[уровень]]`
(`seg000.c:1117`), а `tbl_guard_type[12] == 4`**SHADOW.DAT**, и это
графика КИДА в боевых позах (палитра SHADOW.DAT побайтно равна палитре
Кида; у GUARD.DAT там серая рампа под перекраску).
**У нас** `pop_guard_load` (`pop_cdraw.c:98`) разбирает только типы 2
(скелет) и 3 (визирь), а всё остальное — включая тип 4 — уводит в
`guard_names`. SHADOW.DAT в ассетах отсутствует, каталога нет, в
Makefile правила нет.
**Починка** — часть работы по атласу Тени, см.
[`../docs/shadow_atlas_plan.md`](../../PoP/docs/shadow_atlas_plan.md) Ш0/Ш1:
довезти SHADOW.DAT и запечь его во вторую половину атласа Тени. Отдельно
чинить смысла нет: как только Тень поедет своим атласом, ветка выбора
набора для типа 4 исчезнет вместе с проблемой.
**Оговорка.** `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`).
**Приоритет низкий:** ситуация возникает только когда труп остаётся на
проваливающемся полу, играбельности не мешает.
---
## BUG-SND-FIRSTRUN — при ПЕРВОМ запуске первый эффект слегка искажён
**Симптом** (пользователь, 2026-08-20): после загрузки системы, при первом
запуске программы, самый первый звуковой эффект идёт с искажением; при
повторном запуске программы искажения нет. Наблюдалось ещё на тестовых
примерах CBL, до PoP.
**Причина, скорее всего, уже устранена.** Аппаратный буфер CBL (256
слотов) железо не чистит, а запись в порт управления сразу пускает
воспроизведение с нулевого слота — то есть первые 23,4 мс играет то, что
лежало в буфере раньше. При первом запуске это остатки после загрузки
системы, при втором — наша же тишина, поэтому и слышно только один раз.
Разбор — `../docs/sound_plan.md` §10.2.
Лечение сделано в libc: `_cbl_prime` заливает буфер тишиной сразу после
включения (`cbl_open`). **В MAME проверить нельзя**: эмулируемый буфер
стартует нулями при двухдополнительном ЦАП, то есть там этот дефект нем
изначально — записи до и после фикса совпали побитово по огибающей.
**Что нужно:** проверка на РЕАЛЬНОМ железе, первый запуск после холодной
загрузки. Если искажение осталось — значит мусор приходит не из буфера
CBL, и копать надо в системных буферах DSS.
**Приоритет низкий:** один раз за сеанс, на играбельность не влияет.
---
<a id="pal-l1-after-intro"></a>
## PAL-L1-AFTER-INTRO. Вход в игру на уровень 1 после интро — палитры нет (чёрный экран)
**Наблюдение (пользователь, 2026-08-23).** После заставок (title → intro →
demo) нажатие клавиши в attract-demo начинает новую игру на уровне 1 —
экран ЧЁРНЫЙ при живом геймплее. При этом:
- Shift+L с уровня 1 на уровень 2 (тот же маршрут через pre-cutscene) —
палитра появляется (закрыто быстрым фиксом `fade_in_pending` в
`sprpop.c`, маршрут CUTSCENE → LEVEL_LOAD → PLAYING);
- Restart Game через меню — уровень 1 с НОРМАЛЬНОЙ палитрой.
**Контекст.** Маршрут demo_new_game (`sprpop.c` ~996): dispatch(NEW_GAME)
`pop_level_switch()` → dispatch(LEVEL_READY) → PLAYING. По коду палитру
там никто не трогает, а демо-ветка перед этим делает
snapshot+dim(4,0)+fade_in(4) — то есть к моменту новой игры палитра должна
быть яркой. Контрольный прогон в MAME (сборка с фиксом) демо и уровень 1
за настройками показал ЯРКУЮ картинку — воспроизведение нестабильно.
**Гипотезы для проверки (порядок дешевизны):**
1. **Тайминг нажатия.** Клавиша, СОСКИПНУВШАЯ intro (intro_run абортится по
`kbd_raw_any_down`), остаётся в held-state и первым же кадром демо даёт
`demo_new_game` — но fade_in демо-ветки блокирующий и завершается ДО
входа в игровой цикл, так что в эту гипотезу код не верит. Проверить
трассой: снять обе экранные палитры (`gfx_pal_get` 0 и 1) на первом кадре
PLAYING.
2. **Снимок демо-ветки снят с чёрной палитры.** Если нажатие клавиши
приходится МЕЖДУ `intro_load_palette` (fload+sync+snapshot+dim(4,0)) и
`intro_restore_game_palette` — аборт оставляет экран чёрным, но restore
всё равно выполняется после... сверить фактический порядок вызовов
трассой.
3. **`font_ready`=0 в момент fade_in** — dim/fade молча выходят (guard в
`pop_ui.c`). Маловероятно (шрифт грузится в `pop_boot`
`pop_menu_init`), но дёшево исключить.
**Правильное решение, скорее всего, — этап B из
[`../docs/palette_plan.md`](palette_plan.md):** явный
«load → apply/fade» контракт на КАЖДОМ входе в PLAYING, а не точечные
fade_in в отдельных маршрутах. Точечно чинить только после стабильного
воспроизведения.
---
<a id="pal-dungeon-stale"></a>
## PAL-DUNGEON-STALE. Переход 3→4 (подземелье→дворец): остаётся палитра подземелья
**Наблюдение (пользователь, 2026-08-23).** Shift+L с уровня 3 на уровень 4:
pre-cutscene отыгрывает, уровень 4 начинается — но env/wall-цвета (слоты
0x50–0x6F) остаются ПОДЗЕМНЫМИ. На переходе 1→2 (оба подземелье) всё
правильно.
**Корень ясен.** Быстрый фикс `fade_in_pending` (`sprpop.c`, маршрут
CUTSCENE → LEVEL_LOAD → PLAYING) восстанавливает яркость из СНИМКА, а снимок
делает `intro_restore_game_palette()` ДО `pop_level_switch()`:
```
pop_pre_cutscene_show(pre) → kid.pal + bg(СТАРЫЙ набор) + snapshot + dim(4,0)
pop_level_switch() → pop_bg_load(дворец): в палитры записаны
32 записи ДВОРЦА (pal_tile.pal)
pop_ui_fade_in(4) → восстанавливает ОБЕ палитры ИЗ СНИМКА —
то есть ЗАЛИВАЕТ НАЗАД подземные 0x50..0x6F
```
fade_in по построению пишет все 256 записей из снимка — он затирает и только
что загруженный тайлсет. На 1→2 не видно, потому что наборы совпадают.
**Как чинить (в порядке правильности):**
1. **По плану (этап B `../docs/palette_plan.md`):** разделить «логическую
палитру» и «яркость» (pop_pal: load_* обновляет снимок ПОСЛЕ bg_load,
apply/fade только показывает). Тогда снимок на момент fade_in всегда
актуален.
2. Точечно: переставить обновление снимка ПОСЛЕ `pop_level_switch()`
(снять snapshot заново перед fade_in). Работает, но это ещё один пластырь
поверх модели, которая и так уже в трёх местах по-разному управляет
яркостью.
**Проверять:** Shift+L 3→4 И 5→7 (дворец→подземелье, обратное направление),
плюс штатный проход уровня 3 через дверь (там же смена набора); после починки
сверить цвета кладки дворца с `pal_tile.pal` (таблица в palette_plan.md §1.3).
**Решение пользователя (2026-08-23): пока не фиксить** — закрыть вместе с
этапом B плана палитр.
---
<a id="snd-pace-dead"></a>
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается — СНЯТ 2026-08-28
> **Закрыт удалением самой ветки.** Сцена с Джафаром переведена на ЕДИНЫЕ
> часы — кадры луча (`intro_pv_animated`): вторая шкала по насосу и гонка,
> которая её выбирала, убраны целиком. Насос как часы был нужен потому,
> что кадр сцены рисовался дольше своего интервала; теперь отрисовка
> разложена по интервалам (`pv_restore_bg`), и счёт кадров честен.
> Побочный эффект гонки — «кода реплики каждый прогон в другом месте» —
> исчез; проверено пользователем. Ниже — исходный разбор.
**Замер (MAME, 2026-08-27).** В сцене `intro_pv_scene()` зонды показали
`snd_done == 0` на всём прогоне — то есть ветка `if (snd_pace)`, ради
которой и заводилась вся арифметика 57/40, **не исполняется ни разу**.
Сцена идёт по запасной кадровой ветке (`POP_T60` + `gfx_wait_vsync`).
**Корень.** `pop_intro.c`:
```c
snd_prev = pop_snd_tick;
...
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */
```
`pop_snd_tick` — счётчик порций насоса, он растёт раз в 11,7 мс. Между
двумя этими чтениями проходят единицы микросекунд, поэтому значения
совпадают почти всегда и признак выходит НУЛЕВЫМ, даже когда звук
исправно играет.
**Чем плохо.** Кадровая ветка не имеет догона вообще, и вся работа по
привязке сцены к часам звука пропадает впустую. Симптом — [PV-RENDER-BOUND](#pv-render-bound).
**Как чинить.** Проверять «часы идут» с реальным зазором: снять
`pop_snd_tick`, подождать кадр (`gfx_wait_vsync()`), снять снова. Одна
строка, но включение ветки меняет темп ВСЕХ сцен — трогать только вместе
с прогоном каждой.
---
<a id="pv-render-bound"></a>
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра — ИСПРАВЛЕНО 2026-08-28
> **Причина оказалась не в цене кадра, а в том, ГДЕ отсчитывался интервал.**
> Кадр рисовался целиком, и только потом ждали vsync и ещё четыре — то есть
> отрисовка ПРИБАВЛЯЛАСЬ к делителю. Теперь она разложена на блоки, каждый
> из которых влезает в кадровый интервал, и после каждого ждём vsync
> (`pv_restore_bg` + раскладка кадра в `cut_run` / `pre_room_animated` /
> `intro_pv_draw_frame`); фон восстанавливаем только по картинке, 200 строк
> вместо 256. Удешевлять сцену не понадобилось: отрисовка ~2 кадра при
> бюджете 5. Подгонка `PV_MAGIC_LEAD` снята (0) — проверено пользователем:
> кода попадает в нужный кадр. Ниже — исходный замер.
**Замер (MAME, 2026-08-27).** Реплика Джафара `m53` — 1403 порции насоса,
то есть 16,42 с реального времени. За это время шкала сцены прошла
**811 тиков вместо 985**: анимация идёт со скоростью ~49 тиков/с при
номинале 60. Отставание пропорционально времени, поэтому одинаково на
любом наборе записей (flac / mt32).
Зонды исключили другие версии: длина трека отдана насосу полная
(`pop_mus_left` на пуске = 1403), курсор дошёл до последней страницы
(10 из 11) — **трек доигрывает целиком, отстаёт именно картинка**.
**Следствие, которое важно понимать.** Звук, выданный САМОЙ анимацией
(створка ворот, дверь покоев), разъехаться с ней не может: у них общая
шкала. Уезжает только внешний по времени звук — музыка. Поэтому
эффекты дверей совпадают, а кода реплики приходит раньше молнии.
**Обход (сделан 2026-08-27).** `PV_MAGIC_LEAD` в `pop_intro.c` двигает
жест заклинания целиком (замах, шаг назад, вспышка) на подобранную на
слух величину. Это подгонка, а не лечение.
**Как чинить по-настоящему.** Удешевить кадр сцены: сейчас он
перерисовывается целиком (`gfx_copy_page` на весь экран + актёры +
факелы + звёзды). Кандидат — перерисовывать только изменившиеся куски,
как в игровом слое.
---
<a id="mus-left-tear"></a>
## MUS-LEFT-TEAR. Неатомарное чтение `pop_mus_left`
**Хазард.** `pop_mus_left``volatile uint16_t`, который УМЕНЬШАЕТ
прерывание насоса (`pop_sfx.c`), а читают его из главного цикла обычным
чтением: `pop_music_busy()` (`pop_music.c`), `pop_ctrl.c`, `pop_music.c`
(освобождение трека). На Z80 это две загрузки по байту. Если насос
успеет между ними при заёме из старшего байта (`0x0100``0x00FF`),
сложится **ложный ноль** — «трек кончился» за 256 порций, то есть за три
секунды до настоящего конца.
**Где выстрелит.** `pop_music_busy()` ждут `hof_wait_music()` (финальная
тема) и титры («держим логотип до конца темы»): ложный ноль оборвёт
ожидание на три секунды раньше.
**Статус.** Показан рассуждением; в прогоне 2026-08-27 не проявился
(проверено зондом с устойчивым чтением — результат не изменился). Окно
узкое: несколько тактов из 245 000 между прерываниями.
**Как чинить.** Геттер, читающий до совпадения двух подряд:
```c
uint16_t pop_music_left(void) { uint16_t a, b;
do { a = pop_mus_left; b = pop_mus_left; } while (a != b); return a; }
```
Вариант с `di`/`ei` тоже годится и стоит те же копейки (десяток тактов
раз в кадр, задержка прерывания ничтожна против 11,7 мс периода насоса),
но двойное чтение не трогает состояние прерываний вовсе — на фоне
[cbl_w0_bios_conflict](../../PoP/roomtest/BUGS_CLOSED.md) это плюс.
## CLIMB-VS-GUARD
Кид стоит рядом 2,6, страж — этажом выше на 1,6. Кид тянется подтянуться
на 1,6.
**Оригинал (SDLPoP).** Страж машет мечом, Кид срывается обратно на 2,6
**без потери HP**.
**У нас.** Тот же замах убивает Кида на месте. Если Кид безоружен, это
мгновенная смерть (ветка «заколот без меча», HP разом в ноль) — и дальше
тело падает, что до 2026-08-31 давало отдельный симптом «мёртвый
вприсядку» (закрыт развилкой в `land()`, см. коммит того же дня).
**Наблюдение сверх того (пользователь, 2026-08-31).** В этой же связке
поведение расходится сильнее: страж провалился с ряда 1 на ряд 2 ВМЕСТЕ с
Кидом — причём Кид ушёл в провал в колонке 7 (там дыра), а страж между
колонками 5 и 6, где на ряду 1 пол ЕСТЬ. То есть страж проваливается
сквозь целый пол. Это может быть тем же корнем, что и удар: обоим нужен
корректный ряд/колонка персонажа в момент подтягивания.
**Что уже проверено статически (2026-08-31).** Вся цепочка засчитывания
удара сверена с оригиналом и совпадает ДОСЛОВНО:
- гейт по кадру Кида (нулевой кадр и кадры выхода по лестнице пропускают
разбор целиком) — `pop_check_sword_hurting`;
- приоритет стража при встречном попадании (отметка «ранен» у Кида
снимается) — `pop_check_sword_hurt`;
- требование ОДНОГО ряда у бьющего и жертвы — `check_hurting`;
- кадры удара (укол/третий удар), диапазон дистанции (8 для безоружной
жертвы, 12 для вооружённой, верхняя граница 29);
- формула расстояния `pop_char_opp_dist` — совпадает с `char_opp_dist`.
Значит расходятся не правила, а ВХОДНЫЕ данные: ряд, колонка или X
персонажа во время виса/подтягивания. Наиболее вероятный кандидат —
дистанция: висящий вплотную под кромкой в оригинале не дотягивает до
нижнего порога 8, а у нас пара пикселей переводит его через порог.
**Как чинить.** Не гадать — замерить на живой сцене: в момент, когда
`Opp.action` становится «ранен», снять у обоих `curr_row`, `curr_col`, `x`,
`direction` и сравнить с теми же величинами в SDLPoP на том же кадре
(метод — lldb-трасса живого SDLPoP, как в
[[sdlpop_odd_pixel_char_x]]). Отдельно снять, почему страж теряет опору:
`get_tile_at_char` под ним в кадре провала.
**Уточнение пользователя (2026-08-31, важное).** Оригинальное поведение —
Кид сорвался без потери HP — ВОСПРОИЗВОДИТСЯ и у нас: исход сильно зависит
от того, где именно стоит страж. То есть расхождение не абсолютное, а
пороговое, и это прямо подкрепляет версию про ДИСТАНЦИЮ: пара пикселей
переводит расстояние через нижний порог засчитывания удара (8 для
безоружной жертвы), и удар из «мимо» становится смертельным. Значит
искать надо не потерянную ветку, а сдвиг координаты/порога — сравнивать
`pop_char_opp_dist` в момент замаха при ОДИНАКОВОЙ расстановке.
**ЗВУК КАК ИНДИКАТОР ПУТИ (пользователь, 2026-08-31) — лучшая зацепка.**
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
(1664 Б). Раскладка не сдвинута.
**ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
Здесь стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет. Это
описание НАШЕГО кода, а не оригинала: ровно так звук стоял у нас, внутри
ветки «не парировано» и до присвоения `Opp.action = 99_hurt`. В SDLPoP
(seg002:1039..1044) он стоит в ХВОСТЕ `check_hurting` и защищён условием
`Opp.action != actions_99_hurt`:
if (Char.direction == dir_56_none) return; // Fix looping "sword moving" sound.
if (Char.frame == frame_154_poking && Opp.frame != frame_161_parry &&
Opp.action != actions_99_hurt)
play_sound(sound_11_sword_moving);
То есть звук 11 в оригинале — это индикатор **ПРОМАХА**, а не укола: на
попадании его нет (играет боль), на парировании нет (`Opp.frame` уже 161).
Расхождение найдено и исправлено 2026-09-02 по симптому «непрерывный взмах
клинка у безоружного Кида на подъёме» (звук опознан в живой сессии по
`snd_curr == 11`; указатели насоса при этом сошлись с `snd.idx` байт в
байт, то есть тракт звука был исправен). Заодно приехал и пропущенный
ранний выход по `dir_56_none` — тот самый, что автор SDLPoP подписал
«Fix looping sword moving sound».
**Что из прежних выводов уцелело, а что нет.**
* УЦЕЛЕЛО: наблюдение «в оригинале слышен 11, у нас 8» — это данные, и они
остаются. Арифметика приоритетов их подкрепляет: `snd_prio[11] = 0x12`
(18) против `snd_prio[8] = 0x4B` (75), меньше значит важнее, поэтому
взмах не мог быть заглушён упором в стену. При ТОГДАШНЕМ коде звук 11
звучал на любом уколе, значит его отсутствие действительно означало, что
до кадра укола дело не дошло.
* ОТПАЛО: вывод «оригинал ведёт стража атакой с промахом, а мы —
столкновением» опирался на неверную посылку и больше не следует из звука
сам по себе.
* УСИЛИЛОСЬ: то, что в оригинале в этой сцене слышен ИМЕННО 11, теперь
доказывает, что укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ. А это ровно версия
про ПОРОГ ДИСТАНЦИИ (см. выше про пару пикселей и нижний порог 8), а не
про потерянную ветку. Прежняя «версия про столкновение» её вытеснила
зря — возвращаем дистанцию в главные подозреваемые.
**ВНИМАНИЕ тому, кто вернётся к этому багу: диагностика по звуку с
2026-09-02 ЗНАЧИТ ДРУГОЕ.** Старые заметки прогона будут вводить в
заблуждение — переснимать сцену заново. Новая таблица:
| слышно | что это значит |
|---|---|
| 11 (взмах) | укол состоялся и ПРОМАЗАЛ — поведение оригинала в этой сцене |
| 13 (боль Кида) | укол ПОПАЛ, `Opp.action = 99_hurt` |
| 8 (упор в стену) | укола не было вовсе, сработало столкновение |
| тишина | `check_hurting` вышел раньше: меч не вынут / ряды не совпали / кадр не 153-154 |
На саму механику урона правка 2026-09-02 НЕ влияет (тронут только звук),
поэтому расхождение «удар убивает» остаётся открытым как было.
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
этой связке расходится широко, чинить нужно целиком, а не по одному
симптому.
## HP-BAR-RESTART
**Симптом (пользователь, 2026-08-31).** Гибель на 2-м уровне, возврат к
началу по Ctrl+A: строка HP на одном из двух экранов остаётся отрисованной
по результатам боя. Замечание там же: «в начале уровня она отрисовывается
только в один экран».
**Что уже известно.**
Ctrl+A у нас — РЕСТАРТ УРОВНЯ (`sprpop_cold.c`, тот же путь, что пункт меню
`POP_MENU_RESTART_LEVEL`), а не возврат в заставку.
Перерисовка полосы устроена счётчиком страниц, а не флагом:
`pop_hp_invalidate` ставит `hp_todo = 2`, и `pop_hp_draw` тратит по одной
странице за кадр (`src/pop_cdraw.c`). Все четыре холодных пути
(старт уровня, вход в комнату, быстрая загрузка, возврат из меню)
инвалидацию зовут — механизм на месте.
**Главный подозреваемый — зона статус-строки.** Полоса HP делит строку со
статус-текстом, и пока текст висит (`pop_status_ticks != 0`), стирание
чистит ТОЛЬКО края — левее `POP_STATUS_L` и правее `POP_STATUS_R`, —
а середину не трогает, чтобы текст не мигал. При старте уровня текст как
раз висит («LEVEL 2»), поэтому всё, что от прошлой полосы попало в
середину, там и остаётся. Полоса стража рисуется справа налево от 314 и
при большом запасе HP заходит именно в эту незачищаемую зону.
**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ,
а это не одно и то же: между двумя вызовами отрисовки переворота может не
быть, и оба прохода уходили в одну страницу — вторая оставалась с
делениями прошлого боя. Фикс: проход тратится только когда
`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый
проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена
там же — старая полоса могла заходить под статус-текст, где щадящая чистка
её не трогала; текст сразу перезапрашивается, чтобы не пропал.
**Что проверить при взятии в работу.**
1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос:
при скольких делениях полоса стража (или Кида) заходит под текст.
2. Уходят ли оба прохода `hp_todo` в РАЗНЫЕ страницы на старте уровня —
если между ними нет переворота, обе перерисовки лягут в одну.
3. Возможное решение: на старте уровня чистить полосу во всю ширину
независимо от статус-текста (текст всё равно перерисовывается заново
через `pop_status_invalidate`), либо запоминать максимальную ширину
прошлой полосы и стирать по ней.