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
+102
View File
@@ -0,0 +1,102 @@
/*
* pop_pace.h — фиксированный логический кадр (frame pacing) по ЛУЧУ.
*
* Задача: период логического кадра = max(n, ceil(W)) вместо нынешнего
* ceil(W) + 2, где W — работа в растровых кадрах. Сегодня главный цикл
* ждёт три gfx_wait_vsync ПОСЛЕ отрисовки, поэтому реальный бюджет кадра
* — один растр (430 080 тактов), и превышение его на такт стоит целого
* лишнего растра. При n=3 бюджет становится 1 290 240.
*
* ПОЧЕМУ НЕ СЧЁТЧИК КАДРОВЫХ ПРЕРЫВАНИЙ (замеры 2026-08-19, MAME, полный
* разбор в docs/frame_pacing_plan.md). Импульс запроса кадрового
* прерывания живёт 32 такта неразогнанного клока = 9,14 мкс, а ядра
* акселератора держат di на весь чанк блита (~0,29 мс). Импульс,
* попавший в такое окно, теряется насовсем — ядро z80 уровневое, защёлки
* нет (то же и на настоящем Spectrum). Замерено: в сцене 11/15 теряется
* от 0 до 2,8 % прерываний в зависимости от ФАЗЫ рендера относительно
* луча, а на полной перерисовке комнаты — три подряд. Сейчас фаза
* плавает (период то 3 растра, то 4) и потери размазаны; при жёстком
* пейсинге фаза застынет, и сцена может залипнуть в плохой — это ровные
* −25 % скорости, невидимые в профиле тактов.
*
* ЧТО ВМЕСТО. Бит 5 порта 0xFE — это ЧТЕНИЕ ПОЛОЖЕНИЯ ЛУЧА (1, когда
* vpos >= 272; MAME sprinter.cpp kbd_fe_r), а не событие: ни импульса,
* ни очереди, ни защёлки. Такой сигнал невозможно потерять — можно
* только не посмотреть. Считаем фронты 1→0 (это vpos = 0, ровно тот же
* момент, которого ждёт gfx_wait_vsync) программно, выборкой из точек,
* которые код и так проходит.
*
* УСЛОВИЕ ТОЧНОСТИ — ОДНО: между соседними выборками должно проходить
* меньше 64 512 тактов (длина окна «бит 5 = 1»: 48 строк из 320). Тогда
* в каждый бланк попадает хотя бы одна выборка, а в промежуток между
* бланками (365 568 тактов) — тем более; значит каждый фронт засчитан
* ровно один раз. Двойной счёт невозможен: инкремент только на переходе,
* и beam_prev тут же обновляется. Условие не про нагрузку, не про di,
* не про прерывания и не про звук — только про зазор.
*
* Запас: самый длинный неделимый кусок без выборки — одно di-окно, то
* есть один вызов _bgi_blit_rows_raw, а он по контракту режется на чанки
* ≤16 строк. Даже если считать грубо — одна выборка на целый блит, а
* самый дорогой замеренный блит 32 073 такта — зазор вдвое меньше окна.
*
* ВАЖНО: бит 5 читается осмысленно ТОЛЬКО при включённом cbl_mode (в
* MAME `data &= ~0xa0` стоит внутри `if (cbl_mode())`, иначе бит выставлен
* в 1 намертво). Его лениво взводит сам gfx_wait_vsync через
* _cbl_port_ref, поэтому pop_pace_arm обязан один раз позвать
* gfx_wait_vsync и ПРОВЕРИТЬ, что фронты реально идут.
*/
#ifndef POP_PACE_H
#define POP_PACE_H
#include <stdint.h>
/* Режимы скорости. Растровый кадр Sprinter — 20,48 мс (48,83 Гц).
* Оригинал (SDLPoP/src/seg003.c:363): base_speed 5 тиков при 60 Гц =
* 83,3 мс, fight_speed 6 = 100 мс. Условие боя у оригинала одно и
* буквальное — «у Кида ВЫНУТ МЕЧ» (Kid.sword == sword_2_drawn), не
* «идёт бой» и не «рядом страж»; проверяется в верху главного цикла. */
/* Нумерация НЕ произвольная: 0 обязан быть дефолтом (crt0 зануляет _DATA),
* а обход по кругу инкрементом даёт ровно NORMAL -> FAST -> FASTEST.
* Заодно номер режима = число палочек в отладочной метке минус один. */
#define POP_SPEED_NORMAL 0 /* 4/5 — 81,9 / 102,4 мс; повтор оригинала */
#define POP_SPEED_FAST 1 /* 3/4 — 61,4 / 81,9 мс; бой как в оригинале */
#define POP_SPEED_FASTEST 2 /* 3/3 — 61,4 мс; не успели — подтормаживаем */
#define POP_SPEED_MODES 3
extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */
/* Клавиша смены режима по кругу (PS/2 set 2). P — свободна: заняты
* K/I/S/L/U/[/] (читы, pop_cheat.h) и +/- (обход комнат). */
#define KBD_SPEED_MODE 0x4D /* P */
/* Делитель для текущего режима. fight = «у Кида вынут меч». */
uint8_t pop_pace_n(uint8_t fight);
/* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы
* ожидания — перечитывать обязаны каждый оборот. */
extern volatile uint8_t pop_frame_tick;
/* ВЫБОРКА ЛУЧА. Дёшево (~57 T с вызовом), не трогает bc/de/ix.
* Звать отовсюду, где иначе получился бы зазор длиннее 64 512 тактов:
* из обёрток рисования и с границ фаз. Модуль НЕ банковый, поэтому из
* банков зовётся прямым call, без трамплина. */
void pop_beam_sample(void) __preserves_regs(b, c, d, e);
/* Однократная подготовка: взвести cbl_mode, снять первое состояние бита
* и убедиться, что фронты идут. 0 — луч не работает (тогда пейсинг
* молча откатывается на прежнее поведение: n ожиданий gfx_wait_vsync). */
uint8_t pop_pace_arm(void);
/* Ждать ОДИН фронт луча. Внутри — тот же плотный опрос клавиатуры, что
* сейчас висит idle-хуком на gfx_wait_vsync (KBD-1: без опроса раз в
* ~0,5 мс теряются нажатия и залипают клавиши). */
void pop_wait_edge(void);
/* Добрать до n фронтов ОТ ЯКОРЯ и переставить якорь ПО ФАКТУ.
* Вызывающий обязан перед этим сделать хотя бы один pop_wait_edge() —
* на нём же и происходит своп страниц (tear-free). Якорь ставится
* по факту, а не anchor += n: догонять пропущенное время нельзя, иначе
* после тяжёлого кадра игра рванёт вперёд. */
void pop_pace_end(uint8_t n);
#endif