Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры

Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
  снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
  (UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
  равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
  ABOUT.  About показывает тот же текст, что стартовый Sprinter screen,
  минус строка про клавиши — общий about_text(), чтобы экраны не
  разъехались.  CONTROLS собирается таблицей и центрируется по
  фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
  (SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
  а не пункт меню.  POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
  модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах.  Нижняя:
  порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
  24 тика).  Верхняя отладочная переведена с палочек на текст
  «Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
  своим форматированием чисел (без printf и без деления).
  Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
  не растёт, весь рендер в банке.  Бюджет после правок не изменился
  (_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
  всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
  не блитятся вовсе, а вход в комнату заливает только игровое поле
  (pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
  ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
  статус-строке: геометрия, семантика text_time_total как идентификатора
  сообщения, мигание, рестарт по истечении 36/288.

Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:58:36 +03:00
parent 7fd7f28ffc
commit 47a4b084c4
205 changed files with 8446 additions and 418 deletions
+229
View File
@@ -24,16 +24,158 @@
| [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 восстанавливает из протухшего снимка |
| [CUTSCENE-LTR](#cutscene-ltr) | `Marry Jaffar` → title → credits должны проявляться полосами слева направо | заставка | открыт: зафиксировано 2026-08-24, взять после текущего цикла анимации PV |
| [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), уроненная в прошлой комнате, исчезает без анимации падения | **мелкий** | открыт по решению пользователя: «пока пусть будет так» |
---
<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-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="cutscene-ltr"></a>
## CUTSCENE-LTR. Переходы story через экран TITLE должны проявляться слева направо
**Наблюдение пользователя (2026-08-24).** В SDLPoP переходы `Marry Jaffar`
экран с надписью *Prince of Persia* → credits выполнены тем же приёмом, что и
`In the absence…`: следующая композиция появляется вертикальными полосами
слева направо. Сейчас это последовательность статичных сцен без этого
перехода.
**Очередь.** Не прерывать ради него текущую работу над палитрой и анимацией
Princess/Jaffar. Когда будет взят, выделить общий helper полосового перехода
и проверить все три story-маршрута, включая пропуск клавишей.
---
<a id="grab-kbd-timing"></a>
## GRAB-KBD-TIMING. Зацеп в падении срабатывает НЕ ТАК СТАБИЛЬНО, как в оригинале — РАЗБОР ПОСЛЕ ВСЕХ УРОВНЕЙ
@@ -920,3 +1062,90 @@ Makefile правила нет.
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` в
`roomtest.c`, маршрут CUTSCENE → LEVEL_LOAD → PLAYING);
- Restart Game через меню — уровень 1 с НОРМАЛЬНОЙ палитрой.
**Контекст.** Маршрут demo_new_game (`roomtest.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`](../docs/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` (`roomtest.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 плана палитр.