SprPoP: автономное приложение, выделенное из roomtest

Порт PoP переехал в applications/SprPoP — приложение, которое собирается
само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной
папки.  Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT,
по умолчанию ../..).  applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся
архивом закрытых задач, багов и исполненных планов.

Скопировано из applications/PoP/roomtest@4b74478.  Перенос проверен
побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита,
все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host-
тесты зелёные (15/15).

Раскладка:
  src/           рукописный C (roomtest.c -> sprpop.c)
  gen/           генерируемые заголовки, в репозитории
  assets/orig/   оригинальные данные игры, вне репозитория (копирайт)
  assets/packed/ то, что ложится на диск, в раскладке диска
  tools/         конверторы; все пути — в одном tools/paths.py
  build/         выход: exe, каталоги ресурсов, hdd/, промежуточные atl/

Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что
пересчитывается каждым make.  Автоматика построена на ОТСУТСТВИИ файла, а
не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по
времени превращалось бы в лотерею.  Недостающий ресурс или заголовок
чинится сам, рекурсивным вызовом в ветку генерации.

Музыка собирается из любого из четырёх наборов записей (make music-mp3,
music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама
делает музыку устаревшей.  Длины реплик больше не захардкожены: упаковщик
печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них —
иначе mt32 (реплики на 6% длиннее) молча ломал катсцену.

Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR),
HDD_IMG стал ?=; команда сборки roomtest не изменилась.  Корневой
make host-tests переключён на SprPoP.

Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена
render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики
приходила раньше молнии.  Это обход, а не лечение; разбор с замерами —
docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:12:28 +03:00
parent 4b74478d19
commit 31b82661eb
235 changed files with 51293 additions and 10 deletions
@@ -0,0 +1,218 @@
# Текст в нижней статус-строке (строке HP) — полная инвентаризация SDLPoP
Разбор `SDLPoP/src/` на 2026-08-25. Цель — знать ВЕСЬ набор сообщений,
которые оригинал печатает в ту же полосу, где нарисованы деления HP,
и правила их появления/исчезновения. Это входные данные для порта:
у нас пока туда пишется только `GAME PAUSED`.
---
## 1. Геометрия: одна полоса на HP и на текст
```
rect_bottom_text = { top 193, left 70, bottom 202, right 250 } // data.h:217
display_text_bottom: draw_rect(чёрным) + show_text(halign_center, valign_bottom)
```
* Деления HP **Кида** — от `x = 0` вправо, шаг 7, максимум 10 → занимают `x 0..69`.
* Деления HP **стража** — от `x = 314` влево, шаг 7, максимум 10 → занимают `x 245..320`.
* Текст живёт РОВНО в промежутке `x 70..250` и по X с делениями не пересекается.
* По Y деления на `y = 194..200`, текст (`valign_bottom` к 202) — на `y = 195..201`,
то есть на строку ниже. Именно поэтому в оригинале текст выглядит «сидящим»
чуть ниже стрелок HP.
**У нас**: `POP_HP_Y = 194` (`pop_cdraw.h`), экран сдвинут на `POP_YOFF = 28`,
базовая линия крупного шрифта `POP_YOFF + POP_HP_Y + 8 = 230` — силуэт
ложится на `223..229`, то есть та же картинка.
## 2. Два примитива и два таймера
| Имя | Что делает |
|-----|------------|
| `display_text_bottom(text)` (seg008:2644) | стереть прямоугольник цветом 0 и напечатать текст по центру |
| `erase_bottom_text(arg)` (seg008:266D) | стереть прямоугольник; при `arg != 0` ещё и обнулить оба таймера |
| `text_time_remaining` | сколько игровых тиков сообщение ещё висит; 0 — ничего не висит |
| `text_time_total` | **идентификатор сообщения**, а не только его длительность |
Обработка тика — в `draw_game_frame`/`idle` (seg000:956). Комментарий в
оригинале прямой: *«Note: texts are identified by their total time!»* Значения
`text_time_total`, у которых есть особое поведение:
| `total` | Смысл | Что происходит по истечении |
|---------|-------|------------------------------|
| 12 | «1 SECOND LEFT» | обычное стирание |
| 24 | обычное короткое сообщение | обычное стирание |
| 36 | смерть на демо-уровне (0) или на уровне зелий (15) — **текста нет** | `start_game()` — рестарт игры |
| 288 | «Press Button to Continue» | `start_game()` — рестарт игры |
| 1188 | защита от копирования (уровень 15) | **не убывает и не исчезает** |
Мигание: при `total == 288` и `remaining < 72` сообщение мигает с периодом 12
тиков — 4 тика видно (`blink_frame <= 3`), 8 нет; в кадре `blink_frame == 3`
заново печатается текст и играет звук 38 (`sound_38_blink`).
Сброс: `init_game()` (seg003:32) обнуляет оба таймера и `is_show_time` — то есть
любое сообщение умирает на старте уровня.
---
## 3. Полный список сообщений
### 3.1. Состояние программы
| Текст | Где | Таймер |
|-------|-----|--------|
| `GAME PAUSED` | seg000:1769, пока `is_paused` | **без таймера**: печатается на входе в паузу, `erase_bottom_text(1)` на выходе (seg000:1784) |
### 3.2. Уровень и оставшееся время (`show_level` / `show_time`, seg008)
| Текст | Условие | `total` |
|-------|---------|---------|
| `LEVEL %d` | `show_level()` при старте уровня; только `1..12` (`hide_level_number_from_level = 14`), не при `seamless`; уровень 13 показывается как **12** (`level_13_level_number`) | 24, дальше сразу `is_show_time = 1` |
| `%d MINUTES LEFT` | каждая минута, кратная 5, и каждая из последних 5 | 24 |
| `%d SECONDS LEFT` | последняя минута, раз в 12 тиков | 24 |
| `1 SECOND LEFT` | остался 1 с | **12** |
| `TIME HAS EXPIRED!` | `rem_min == 0` | 24 |
| `%d MINUTES PASSED` / `1 MINUTE PASSED` | только SDLPoP (`ALLOW_INFINITE_TIME`), при отрицательном таймере | 24 |
Что взводит `is_show_time` (все → следующий кадр печатает время):
* **Space** — seg000:612, штатная клавиша оригинала «сколько осталось»;
* читы **`-`/`+` numpad** (изменение времени) — seg000:762 / 777, при этом
таймеры сообщения обнуляются, чтобы новое напечаталось немедленно;
* **смерть Джафара** — `on_guard_killed()` seg006:1936, уровень 13
(`jaffar_victory_level`): вспышка + показать время;
* истечение очередной минуты — seg008:1796;
* сразу после `show_level()`.
Обнуляет `is_show_time`: `play_kid()` при смерти (seg006:1365) и
`show_copyprot(1)` (seg000:2385).
### 3.3. Смерть Кида
| Текст | Где | `total` |
|-------|-----|---------|
| `Press Button to Continue` | `play_kid()` seg006:1383 — умер на обычном уровне | **288** (мигает, затем рестарт игры) |
| *(без текста)* | тот же код, но уровень 0 (демо) или 15 (зелья) | **36** (тихая пауза, затем рестарт игры) |
Стирается: `fell_out()` (seg006:1342, упал из комнаты 0) и чит **R**
(воскрешение, seg000:783) — оба зовут `erase_bottom_text(1)`.
### 3.4. Сохранение и загрузка
| Текст | Клавиша | `total` |
|-------|---------|---------|
| `GAME SAVED` / `UNABLE TO SAVE GAME` | Ctrl+G (`save_game`, seg000:2211) | `total` не ставится, `remaining = 24` |
| `QUICKSAVE` / `NO QUICKSAVE` | F6 (расширение SDLPoP, seg000:497) | 24 |
| `QUICKLOAD` / `NO QUICKLOAD` | F9 (расширение SDLPoP, seg000:514) | 24 |
### 3.5. Ответы на клавиши (`answer_text` → `need_show_text`, все `total = 24`)
| Текст | Клавиша |
|-------|---------|
| `SOUND ON` / `SOUND OFF` | Ctrl+S |
| `KEYBOARD MODE` | Ctrl+K |
| `JOYSTICK MODE` / `JOYSTICK NOT FOUND` / `JOYSTICK UNAVAILABLE` | Ctrl+J |
| `PRINCE OF PERSIA V1.0` (в SDLPoP заменено на `SDLPoP v%s`) | Ctrl+V |
| `SDL COMP v… LINK v…` | Ctrl+C — только SDLPoP |
### 3.6. Отладочные читы (`cheats_enabled`, `total = 24`)
| Текст | Клавиша | Смысл |
|-------|---------|-------|
| `S%d L%d R%d A%d B%d` | `C` | номер отрисованной комнаты и её соседей L/R/A/B |
| `AL%d AR%d BL%d BR%d` | Shift+`C` | диагональные соседи |
### 3.7. Защита от копирования (только уровень 15)
| Текст | Где | `total` |
|-------|-----|---------|
| `WORD %d LINE %d PAGE %d` | `show_copyprot(1)` seg000:2389 | **1188** — висит, пока не сменится уровень |
### 3.8. Только SDLPoP, в оригинале 1989 отсутствует
| Текст | Где |
|-------|-----|
| `RECORDING`, `REPLAY SAVED`, `REPLAY CANCELED` | replay.c:599/626/628 |
| имя файла скриншота | screenshot.c:62 |
---
## 4. Что из этого касается нашего порта
Реализовано (`pop_status.c`, таймер 24 тика как у оригинала):
* `GAME PAUSED` — без таймера, рисует само меню (`pop_menu.c`);
* `QUICKSAVE` / `NO QUICKSAVE`, `QUICKLOAD` / `NO QUICKLOAD` — заявка стоит
в `pop_qsave_process`, то есть в единственном месте, где известно, что
именно делали. Лейбл печатается ДО дисковой операции — осознанное
расхождение, см. `impl_diff.md`;
* `SOUND ON` / `SOUND OFF` — Ctrl+S;
* `LEVEL %d` — порт `show_level()` целиком: демо-уровень 0 и номера от 14
молчат, тринадцатый показывается двенадцатым, бесшовный переход 12→13
пропускается и гасит флаг за собой.
* вся группа времени — `N MINUTES LEFT`, `N SECONDS LEFT`, `1 SECOND LEFT`,
`TIME HAS EXPIRED!`. Флаг `pop_show_time` (порт `is_show_time`) взводит
само ядро таймера на круглых пятёрках и каждую секунду последней минуты,
а также старт уровня и читы времени; значение 2 означает «перебить
текущую строку», как оригинал делает в последнюю минуту;
* `Press Button to Continue` — висит бессрочно (`MSG_HOLD`), уровень
перезапускает кнопка. Расхождение с оригиналом, см. `impl_diff.md`.
Пока НЕ печатается:
* номера комнат (`C`/Shift+`C`) — у нас отдельная отладочная строка;
* copy protection и SDLPoP-расширения (replay, скриншоты) — не нужны.
Отладочная строка вдобавок показывает оставшееся время `##:##` у правого
края. На табло уходит `minutes-1`: у оригинала `rem_min` — это НОМЕР идущей
минуты, а не остаток целых (старт 60 при `rem_tick` 719 = «почти 60:00»).
Секунды считаются делением раз в 12 кадров, а не каждый кадр.
Нам не нужно: copy protection (уровень 15 исключён из порта — см.
`full_game_plan.md`), joystick-режимы, replay, скриншоты.
Механика, которую придётся портировать целиком, если брать группу времени:
пара таймеров `text_time_total`/`text_time_remaining` с семантикой
«идентификатор сообщения» — иначе не воспроизвести ни мигание, ни рестарт по
истечении 36/288.
---
## 5. Цена вывода и что делать, если упрёмся
Блит одного глифа стоит ~4,6 тыс. тактов почти независимо от размера — это
цена вызова, а не пикселей (memory `blit_cost_model`). Полсотни символов =
полкадра. Что уже сделано в `pop_status.c` / `pop_ui.c`:
* **change-driven**: пока показанное не изменилось, не рисуем вовсе;
* **по полям**: смена комнаты — две цифры (~9 тыс. тактов, 2% кадра), а не
вся строка; подписи рисуются только при полной инвалидации;
* **пробелы не блитятся**: их глиф целиком прозрачен, а стоит как буква —
на полной отладочной строке это девять сэкономленных блитов;
* **заливка только поля** при входе в комнату (`pop_screen_fill_field`):
борта от комнаты к комнате не меняются, это и четверть заливки, и то, что
обе полосы вход переживают.
Запас, если бюджета всё же не хватит (идеи пользователя, 2026-08-25):
1. **Растянуть вывод на несколько кадров, не показывая полуготовую строку.**
Печатать по нескольку букв за кадр, держа цвет шрифта чёрным (отдельный
индекс палитры), а по готовности подменить этот индекс на белый — строка
появится целиком и мгновенно. Стоит ноль байт памяти и укладывается в
нашу же технику «два разных чёрных» (`POP_COL_OUTSIDE`).
2. **Собирать строку в один спрайт** в свободном хвосте страницы шрифта и
блитить одним вызовом. Дороже по подготовке (~35 тыс. тактов на
копирование), но выгодно там, где строка ЦЕЛИКОМ меняется каждый раз.
Для меню этот путь уже рассматривался и был отвергнут; для статус-строк
он имеет смысл только вместе с п.1.
Про QuickSave/QuickLoad оптимизация не нужна вовсе: там игра и так стоит на
время дисковой операции.
## 6. Ловушка: свисающие глифы
Зона стирания текста обязана захватывать строку НИЖЕ базовой линии. В малом
шрифте `'p'` имеет высоту 7 при ascent 5, `','` — 6: они свисают под базовую
линию. Стирание ровно до неё оставляло от хвоста «p» в «Speed:» одинокую
точку (поймано в MAME 2026-08-25).