# 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) | зацеп в падении срабатывает менее стабильно, чем в оригинале | **после всех уровней** | открыт: подозрение на клавиатурный модуль, не на физику |
| [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-24) |
| [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-27) |
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | открыт: замер есть, лечение — удешевить кадр |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
---
## 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`) и модификаторы комнат, то есть даёт
ровно то состояние, которое сейчас не удаётся собрать искусственно.
---
## KBD-STUCK-WAIT. Потерянный break-код вешает игру насмерть в межуровневой заставке
> **Решение пользователя (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 —
там уже закрытые случаи залипания того же битмапа.
---
## GRAB-KBD-TIMING. Зацеп в падении срабатывает НЕ ТАК СТАБИЛЬНО, как в оригинале — РАЗБОР ПОСЛЕ ВСЕХ УРОВНЕЙ
> **Решение пользователя (2026-08-12): отложено до готовности всех уровней.**
> Механика работает, это вопрос ощущения, а не проходимости.
**Наблюдение (пользователь).** Вход на уровень 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))
— тогда «менее стабильно» это не клавиатура, а темп, и чинить надо темп.
---
# С приёмки уровня 2 (2026-08-07)
Всё найденное тем прогоном закрыто, кроме двух записей ниже
(`BUG-SPIKE-1` — уровень 2, комната 6). Карта содержимого уровня 2 (что где
стоит по `res2002.bin`, какие кнопки какие ворота открывают) — в
[`TASKS_CLOSED.md`](../../PoP/roomtest/TASKS_CLOSED.md#l2-pass), по ней видно, «механика не
сработала» это или «так и задумано».
## 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` текущего кадра); если разойдутся позиции — это отдельный
баг посадки, а пики — его следствие.
---
---
# Оптимизация отрисовки (записано 2026-07-29)
Не баги — план работ. Оба пункта про одно: у оригинала пометка тайла к
перерисовке стоит копейки (бит в таблице, которая всё равно чистится каждый
кадр), а у нас каждая такая пометка превращается в реальный heal (копию из
ОЗУ-копии акселератора) плюс блиты. Поэтому буквальный порт «перерисовываем
безусловно» корректен, но дорог.
## 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, отдельно не окупается.
---
## 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.
---
## 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`), а зелье — переставить в порядке обхода
так, чтобы оно рисовалось ПОСЛЕ факела.
---
## 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»; сравнить число нарисованных кусков с числом уникальных тайлов.
Если дубль мал — оставить как есть и закрыть запись.
---
---
## 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. Симптом редкий (пользователь поймал дважды).
## 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.
---
## 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`, а не умереть;
падение на стража сверху по-прежнему безболезненно (это оригинал, не баг).
---
## 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)), плита-потолок, рестарт уровня после смерти
на дрожащей плите.
---
## 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; в оригинале плита в шве дрожит и
роняет кусок так же, как в своей комнате.
---
## 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; за передней
колонной Кид уходит и сейчас — это не должно сломаться.
---
## 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` на кусок за кадр —
при шести падающих плитах это шесть отрисовок тайла.
---
## 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) — там та же техническая
сердцевина: привести наши слои в соответствие с таблицами оригинала.
---
## 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`
живёт в резиденте.
---
## 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) — иначе поймаем только
половину.
### 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`).
### 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.
**Приоритет низкий:** один раз за сеанс, на играбельность не влияет.
---
## 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 в отдельных маршрутах. Точечно чинить только после стабильного
воспроизведения.
---
## 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 плана палитр.
---
## SND-PACE-DEAD. Пейсинг сцен по насосу CBL не включается
**Замер (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()`), снять снова. Одна
строка, но включение ветки меняет темп ВСЕХ сцен — трогать только вместе
с прогоном каждой.
---
## PV-RENDER-BOUND. Сцена с принцессой не укладывается в свой бюджет кадра
**Замер (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` на весь экран + актёры +
факелы + звёзды). Кандидат — перерисовывать только изменившиеся куски,
как в игровом слое.
---
## 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) это плюс.