Files
Sprinter-SDCC/applications/SprPoP/src/pop_intro.c
T
snark13 df5071a967 SprPoP: музыка без перелинковки — длины и длительности уехали на диск
Часть I плана music_runtime_index_plan.md (MI0..MI5).  gen/pop_music_tbl.h
и gen/pop_music_ticks.h УДАЛЕНЫ: длины треков и длительности реплик
читаются из MUS/mus.idx (формат PMI1, tools/pop_idx.py, тесты в
make test-tools).  Один и тот же sprpop.exe работает с любым из четырёх
наборов записей — sha256 бинарника при смене MUSIC_FMT не меняется.

ГДЕ ЖИВЁТ ИНДЕКС.  228 байт таблицы в W2 не положить (свободной кучи там
порядка двух сотен), поэтому индекс лежит в одной странице EMM, а в
резиденте от него два байта.  Данные в странице — со смещения 0x100:
gfx_w0_page_prepare пишет в неё стабы прерываний (0x38 и 0x66), и с нуля
они попали бы прямо в записи id 10 и 21.  Со смещением работает штатная
защита, а не запрет прерываний (тот же приём, что CFG_BASE в
pop_config.c).  Число страниц в индексе не хранится — считается из blocks,
чтобы не разъехалось.

ПАУЗА КОНЦА УРОВНЯ — СОСТОЯНИЕМ, А НЕ СЧЁТЧИКОМ.  pop_endmus_left и
POP_MUS_TICKS_32/41 удалены; главный цикл ждёт pop_music_active() —
«заявка лежит, идёт загрузка или трек звучит».  Одного busy мало: между
заявкой и первой нотой 190-230 мс (замер в sound_plan §9).  Прежний
счётчик закрывал эту щель ценой зависимости EXE от набора и жёсткого
делителя /4, который врал в режимах FAST/FASTEST (там логический кадр 3
кадра луча, а не 4).  Побочно исправилось расхождение с SDLPoP: при
выключенном звуке заявка не кладётся, и уровень меняется сразу, как в
оригинале (seg006:651 + seg003:387) — раньше игра держала пройденный
уровень лишние 12 секунд в тишине.

PV-СЦЕНА — на четырёх якорях (8 байт статики), которые считаются из
индекса при входе в сцену; прежние выражения шкалы не изменились.  План
предлагал протащить структуру времён через пять функций — для сцены,
которая идёт раз за запуск, это того не стоит.

ПАМЯТЬ.  За обе фазы резидент не вырос, а освободился: _CODE 23865 ->
23544, куча 239 -> 256 Б.  Банк 9 похудел на 118 Б (ушла pop_mus_tbl из
rodata), банк 11 — на длительности реплик.

ПРОВЕРЕНО В MAME: exe побайтово одинаков для flac и mt32; все 22 трека в
индексах различаются, и контрольные значения совпали с предсказанными
планом (m41 732->685, m50 831->867, m53 985->1044, m56 9865->10462
блоков, 78->82 страницы); на mt32 PV-сцена проходит целиком по его
длительностям; без mus.idx музыки нет, эффекты работают, игра проходима.

НЕ ПРОВЕРЕНО: потоковый m56 на 82 страницах — до финала надо дойти в
игре.  Единственный оставшийся пункт приёмки, отмечен в sound_plan §11.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011MsUsEFAQfsjjQpJ7RtKVY
2026-08-31 17:17:59 +03:00

1948 lines
97 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* pop_intro.c — вторая половина show_title(): story + Princess/Jaffar (FG8).
*
* Готовые сцены создаёт pop_pack_intro.py из TITLE/PV SDLPoP. Каждая сцена
* хранится пятью полосами <=49 строк, поэтому EMM удерживает лишь текущую
* полосу. pop_cutscene_t остаётся независимым от графики: этот модуль только
* исполняет его события и позднее будет образцом для pre-level сцен FG9.
*/
#include <stdint.h>
#include <kbd_raw.h>
#include <graphics.h>
#include <gfx.h>
#include <sprite.h>
#include "pop_bg.h"
#include "pop_shadow.h"
#include "pop_sfx.h"
#include "pop_music.h"
#include "pop_arc.h"
#include "pv_arc.h"
#include "pop_cutscene.h"
#include "pop_intro.h"
#include "pop_hof.h" /* таблица рекордов между credits и demo */
#include "pop_title.h" /* титульная картинка в финале */
#include "pop_pace.h" /* pop_wait_edge */
#include "pop_pal.h"
#include "pop_timer.h"
#include "pop_ui.h"
#include "pop_kid.h" /* Char/окна, play_seq — движок персонажей катсцен */
#include "pop_guard.h" /* Guard, CHARID_*, pop_gframe */
#define INTRO_PARTS 5
/* ДЛИТЕЛЬНОСТЬ FADE. У оригинала это 64 шага палитры по два тика —
* 128 тиков, то есть 2,13 с (fade_in_1/fade_out_1 -> fade_*_frame ->
* do_simple_wait(1) при wait_time = 2). Наши четыре кадра укладывались в
* восемь сотых секунды, и вся сцена выходила втрое короче оригинальной.
* Точное число кадров считает POP_FADE_FRAMES (pop_ui.h): из
* заказанных 2,13 с вычитается то, что съедает сам пересчёт палитры. */
#define INTRO_FADE POP_FADE_FRAMES
/* Оцифрованные эффекты pv_scene() (SDLPoP seg001:422): створка ворот
* закрывается, затем открывается дверь покоев, из которой входит Джафар. */
#define PV_SND_GATE_CLOSING 4
#define PV_SND_DOOR_OPENING 51
/* Шкала ниже повторяет порядок pv_scene() SDLPoP и живёт в ЕГО тиках
* (60 Гц): обычный кадр cutscene длится 8 тиков, до первой смены темпа 6,
* после начала заклинания 7. В кадры луча переводит POP_T60.
*
* ДЛИННЫЕ ПАУЗЫ ЗДЕСЬ — ЭТО РЕПЛИКИ. Оригинал их не отмеряет, а ждёт
* окончания сэмпла (`do { frame(1) } while (check_sound_playing())`), и
* пока музыки у нас не было, паузы стояли укороченными «на глаз».
*
* ДВА КЛАССА КОНСТАНТ, И ИХ НЕЛЬЗЯ ПУТАТЬ. Раньше вся шкала была записана
* абсолютными литералами, и разница между ними была не видна — из-за этого
* смена набора записей (flac -> mt32) молча ломала сцену:
*
* 1) БИТЫ СЦЕНАРИЯ — интервалы самого pv_scene() SDLPoP. От записи не
* зависят и масштабироваться ею НЕ должны. Отсчитываются от начала
* своего куска (например, всё поведение Джафара — от PV_DIALOG1_START).
* 2) «ЖДЁМ КОНЦА РЕПЛИКИ» — PV_M50_END и PV_EXIT_START. Это целиком
* длина НАШЕЙ записи, и она у каждого набора своя: m50 — 831 тик на
* flac против 867 на mt32, m53 — 985 против 1044. Раньше их
* печатал упаковщик в gen/pop_music_ticks.h, из-за чего EXE был
* привязан к набору; теперь они приезжают из индекса набора
* (MUS/mus.idx) при входе в сцену — см. ЯКОРЯ ниже.
*
* Всё, что стоит ПОСЛЕ реплики, сдвигается вместе с её концом — отсюда
* сложения от PV_M50_END и PV_EXIT_START вместо литералов. */
enum {
PV_MUS_2_START = 12, /* frame(2), затем «принцесса ждёт» (m50) */
PV_MUS_3_LOAD = 60, /* подкачка следующей реплики, m50 звучит */
/* Конец m50: кадр становится 8. Прежний литерал 846 отвечал длине 834
* тика — на три больше нашей записи (831); расхождение осталось с
* времён, когда длину подставляли руками. */
PV_MAGIC_LEAD = 0
};
/* ПОДГОНКА ЗАКЛИНАНИЯ ПОД МУЗЫКУ СНЯТА (2026-08-28): теперь 0.
*
* История: сцена была render-bound — кадр рисовался дороже своего
* бюджета, кадровая ветка пейсинга догона не имела, и анимация шла
* ~49 тиков в секунду вместо 60 (замер 2026-08-27). Реплика Джафара
* при этом играет по РЕАЛЬНОМУ времени, поэтому кода приходила
* примерно на секунду раньше молнии, и жест целиком (замах, шаг
* назад, вспышка) сдвигали раньше сценария на подобранные на слух
* 100 тиков.
*
* Причину убрали: отрисовка разложена по кадровым интервалам
* (pv_restore_bg), сцена идёт по единственным часам — кадрам луча, —
* и подкачка трека оплачивается интервалом (pop_music_load_step).
* После этого подгонка стала вредной: молния била больше чем на
* секунду раньше коды (проверка пользователем 2026-08-28). Константу
* оставляем на месте — если запись другого набора (mt32/ogg) снова
* разъедется, крутить надо её, а не тайминги сценария. */
/* ЯКОРЯ ШКАЛЫ. Четыре точки, от которых отсчитывается всё остальное;
* считаются ОДИН РАЗ при входе в сцену из длительностей набора
* (pop_music_info). Держать их статикой, а не тащить структуру времён
* через пять функций: восемь байт против переделки всех сигнатур, а
* сцена всё равно одна на запуск.
*
* Нет индекса или трека — длительность нулевая, и сцена просто проходит
* без пауз на реплики: ждать нечего, звука-то нет. */
static uint16_t pv_m50_end; /* конец «принцесса ждёт» (m50) */
static uint16_t pv_dialog1; /* Джафар входит — начало реплики m53 */
static uint16_t pv_exit; /* конец m53: Джафар уходит */
static uint16_t pv_anim_end; /* конец всей сцены */
/* Ниже — те же выражения, что были в enum: биты сценария отсчитываются от
* своего якоря и от записи НЕ зависят. */
#define PV_M50_END pv_m50_end
#define PV_WAIT_END ((uint16_t)(pv_m50_end + 40))
#define PV_GATE_END ((uint16_t)(pv_m50_end + 88))
#define PV_DOOR_END ((uint16_t)(pv_m50_end + 112))
#define PV_TURN_START PV_DOOR_END
#define PV_WALK1_START ((uint16_t)(pv_m50_end + 152))
#define PV_DIALOG1_START pv_dialog1
#define PV_MUS_4_LOAD ((uint16_t)(pv_dialog1 + 54))
#define PV_WALK2_START ((uint16_t)(pv_dialog1 + 176))
#define PV_DIALOG2_START ((uint16_t)(pv_dialog1 + 416))
#define PV_RAISE_START ((uint16_t)(pv_dialog1 + 696 - PV_MAGIC_LEAD))
#define PV_STEPBACK_START ((uint16_t)(pv_dialog1 + 703 - PV_MAGIC_LEAD))
#define PV_MAGIC_START ((uint16_t)(pv_dialog1 + 822 - PV_MAGIC_LEAD))
#define PV_EXIT_START pv_exit
#define PV_MUS_4_START ((uint16_t)(pv_exit + 42))
#define PV_GLASS_DONE ((uint16_t)(pv_exit + 210))
#define PV_SLUMP_START ((uint16_t)(pv_exit + 273))
#define PV_ANIM_TICKS pv_anim_end
/* Собрать шкалу под НАШ набор записей. 200 — цепочка битов сценария от
* конца m50 до входа Джафара (40+48+24+40+48), 469 — хвост после его
* ухода; оба от записи не зависят. */
static void pv_timing_init(void)
{
pop_mus_info_t m50, m53;
(void)pop_music_info(POP_MUS_STORY_2, &m50);
(void)pop_music_info(POP_MUS_STORY_3, &m53);
pv_m50_end = (uint16_t)(PV_MUS_2_START + m50.ticks);
pv_dialog1 = (uint16_t)(pv_m50_end + 200);
pv_exit = (uint16_t)(pv_dialog1 + m53.ticks);
pv_anim_end = (uint16_t)(pv_exit + 469);
}
/* Возврат долга шкалы (см. цикл сцены): сколько порций насоса отдаём за один
* кадр сцены и сколько их вообще имеет смысл копить. 1 порция — 11,7 мс;
* 128 порций — полторы секунды, дальше догонять уже нечего. */
enum {
INTRO_STORY_ABSENCE = 0,
INTRO_STORY_MARRY,
INTRO_STORY_CREDITS,
INTRO_STORY_HAIL,
INTRO_STORY_FRAME,
INTRO_STORY_HOF, /* рамка + логотип: фон таблицы рекордов */
INTRO_PV_WAITING,
INTRO_PV_JAFFAR,
INTRO_PV_MAGIC,
INTRO_PV_ALONE,
INTRO_PV_STAND,
INTRO_PV_LYING,
INTRO_PV_MOUSE,
INTRO_PV_SHORT
};
enum {
INTRO_PAL_STORY = 0,
INTRO_PAL_PV = 1,
/* Та же story.pal, но фон текстовой рамки красный:
* load_title_images(0) в end_sequence (seg001:586). */
INTRO_PAL_STORY_END = 2
};
/* Экраны PV лежат в одном архиве по пять полос подряд, ровно в том же
* порядке, в каком идут номера сцен: сначала пять текстовых (story
* res41 + тексты 42/43/45/44 и чистая рамка), затем восемь стадий комнаты
* принцессы (res951 + персонажи) и чистый фон b0. Поэтому имён здесь нет
* — индекс первой полосы экрана считается умножением. */
#if (ARC_PV_P0_0 - ARC_PV_S0_0) != 6 * INTRO_PARTS
#error "экраны PV в архиве лежат не по пять полос подряд"
#endif
#define INTRO_SCENES 14
/* Две текстовые половины show_title(). Между ними PV теперь идёт отдельным
* покадровым renderer-ом, а не четырьмя полными статичными композициями. */
/* ПОЧЕМУ НЕ 0x258, КАК В ОРИГИНАЛЕ. Точка отсчёта у обоих — пуск m55;
* совпасть должен МОМЕНТ ПОЛНОЙ ТЕМНОТЫ, а дороги к нему разные:
* SDLPoP: transition_ltr 80 тиков + pop_wait 0x258 + fade_out_2, а тот
* гасит палитру за 0x40 шагов по 2 тика = 128 тиков;
* у нас: полосовой переход 80 кадров луча (= 96 тиков) + WAIT + fade
* той же длины (INTRO_FADE = 128 тиков).
* Теперь, когда наш fade идёт те же 128 тиков, остаётся поправка на один
* лишь переход: 80 + 600 + 128 - 96 - 128 = 584. Дальше и там и там экран
* уже чёрный, а трек доигрывает — этой паузой сцена и стыкуется с PV. */
static const pop_cs_cmd_t intro_before_pv[] = {
/* absence собирается прямо поверх последнего title-кадра полосовым
* transition_ltr(), как в SDLPoP; оба экрана используют title.pal. */
{ POP_CS_WAIT, POP_T60(584) }, /* story 1: In the absence */
{ POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
/* Файловый fallback сохраняет возможность пройти title на неполном HDD.
* Нормальная ветка его не вызывает: p0..p3 оставлены также для диагностики
* точек сюжета и не являются анимационным ресурсом. */
static const pop_cs_cmd_t intro_pv_fallback[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV },
{ POP_CS_SHOW, INTRO_PV_WAITING },
{ POP_CS_FADE_IN, 0 },
{ POP_CS_WAIT, 0x1A }, /* princess, gate and door */
{ POP_CS_SHOW, INTRO_PV_JAFFAR },
{ POP_CS_WAIT, 0x6D }, /* Jaffar comes/walks */
{ POP_CS_SHOW, INTRO_PV_MAGIC },
{ POP_CS_FLASH, 5 }, /* hourglass appears */
{ POP_CS_WAIT, 0x2F },
{ POP_CS_SHOW, INTRO_PV_ALONE },
{ POP_CS_WAIT, 0x3D }, /* Jaffar leaves, princess looks */
{ POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
/* Хвост show_title() разрезан надвое: между «свадьбой» и титрами оригинал
* ЖДЁТ окончания последней реплики Джафара (seg000:2051), которая начала
* звучать ещё в PV и продолжается поверх смены картинки. */
static const pop_cs_cmd_t intro_marry[] = {
{ POP_CS_PALETTE, INTRO_PAL_STORY },
{ POP_CS_SHOW, INTRO_STORY_MARRY },
{ POP_CS_FADE_IN, 0 },
{ POP_CS_END, 0 }
};
/* Гашение — отдельным шагом: между credits и ним оригинал показывает
* таблицу рекордов, если файл с ней есть (seg000:2060). */
static const pop_cs_cmd_t intro_tail[] = {
{ POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
/* Статические fallback-сценарии хранят уже физические кадры Sprinter.
* Один логический кадр cutscene SDLPoP длится 6/60 с; на 50 Гц это ровно
* 5/50 с. Поэтому суммы proc_cutscene_frame() здесь умножены на пять. */
static const pop_cs_cmd_t pre_2_6[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_STAND },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 130 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t pre_4[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_LYING },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 130 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t pre_8[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_MOUSE },
/* 20 + 20 + 20 логических кадров = 6,0 с. */
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 300 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t pre_9[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_MOUSE },
/* 5 + 9 + 58 логических кадров = 7,2 с. */
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 360 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t pre_12_short[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_SHORT },
/* 2 + 24 логических кадра = те же 2,6 с, что и длинная ветка. */
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 130 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
/* time_expired() SDLPoP: hourglass_state=7, fade in, 2+100 кадров, fade
* out. P7 — готовая стадия комнаты с часами; звук намеренно не стартует,
* так как CBL на lifecycle screen погашен до дисковых чтений. */
static const pop_cs_cmd_t time_expired_script[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_SHORT },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 102 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
/* end_sequence_anim() — объятие/мышь — здесь сведён к отдельной PV-стадии;
* затем end_sequence() SDLPoP показывает HAIL ровно 900 тиков. */
/* Хвост финала после живой сцены встречи: только исходный story-экран. */
/* FADE_OUT в конце НЕТ намеренно: оригинал не гасит HAIL, а проявляет
* поверх него титульную картинку полосами (draw_full_image(TITLE_MAIN) +
* transition_ltr, seg001:590). Гасит экран уже таблица рекордов. */
static const pop_cs_cmd_t ending_hail_script[] = {
{ POP_CS_PALETTE, INTRO_PAL_STORY_END }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 },
{ POP_CS_END, 0 }
};
static const pop_cs_cmd_t ending_script[] = {
{ POP_CS_PALETTE, INTRO_PAL_PV }, { POP_CS_SHOW, INTRO_PV_MOUSE },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 54 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_PALETTE, INTRO_PAL_STORY_END }, { POP_CS_SHOW, INTRO_STORY_HAIL },
{ POP_CS_FADE_IN, 0 }, { POP_CS_WAIT, 900 },
{ POP_CS_END, 0 }
};
static uint8_t intro_scene;
static uint8_t intro_pending_pal;
static uint8_t intro_pal_pending;
/* Полосовой загрузчик рядом со своими вызовами: общий из банка 9 звать
* можно (индексы и таблица — числа на стеке W2, а не указатели на W3), но
* лишний банк→банк на каждую полосу тут ни к чему.
* pop_arc_atlas сам временно занимает W3 только на время чтения. */
static int intro_blit_arc(pop_arc_t *arc, const uint8_t *tbl,
uint8_t base, uint8_t count, int x, int y)
{
uint8_t part;
gfx_set_bank(GFX_BANK_NORMAL);
for (part = 0; part < count; part++) {
atlas_t at;
const uint8_t *img;
if (pop_arc_atlas(arc, tbl, (uint8_t)(base + part), &at) != 0)
return -1;
gfx_w0_map(at.page);
img = (const uint8_t *)atlas_image(&at, 0);
gfx_blit(x, y, img);
y += img[2];
gfx_w0_unmap();
atlas_free(&at);
}
return 0;
}
/* Экран целиком: открыть архив, вылить пять полос, закрыть. Один `open`
* на экран вместо пяти — при 51,4 мс за открытие это больше половины
* времени показа. */
static int intro_blit_screen(uint8_t base, int y)
{
uint8_t tbl[ARC_PV_COUNT * POP_ARC_REC];
pop_arc_t arc;
int res;
if (pop_arc_open_id(&arc, POP_ARC_PV, tbl, ARC_PV_COUNT) < 0) return -1;
res = intro_blit_arc(&arc, tbl, base, INTRO_PARTS, 0, y);
pop_arc_close(&arc);
return res;
}
/* Один актёрский атлас PV из архива. Открытие на каждый — всё равно
* дешевле, чем прежние отдельные файлы (тот же `open`, но без поиска имени
* в каталоге); там, где их несколько подряд, архив открывается один раз. */
static int pv_atlas(pop_arc_t *arc, const uint8_t *tbl, uint8_t idx,
atlas_t *at)
{
return pop_arc_atlas(arc, tbl, idx, at) != 0 ? -1 : 0;
}
/* ---- «Пропустить сцену» — по НОВОМУ нажатию, а не по удержанию -------- *
*
* Клавиша, которой закончили уровень (движение Кида в дверь, чит Shift+L,
* skip предыдущего экрана), в момент старта сцены ЕЩЁ ЗАЖАТА. Считать это
* за skip нельзя: сцена схлопывается в один кадр, и её никто не видит.
* Ждать отпускания безусловно тоже нельзя — потерянный break-код вешает
* игру намертво (BUGS_OPEN.md#kbd-stuck-wait).
*
* Поэтому детектор ФРОНТА: пока хоть что-то зажато со старта, skip не
* взводится; как только всё отпущено, детектор «взводится» и следующее
* нажатие сцену прерывает. Залипший бит в худшем случае делает сцену
* непропускаемой — она доиграет сама, а игра не встанет. */
typedef struct { uint8_t armed; } intro_skip_t;
static void intro_skip_begin(intro_skip_t *sk)
{
kbd_raw_sync();
sk->armed = (uint8_t)!kbd_raw_any_down();
}
static uint8_t intro_skip_requested(intro_skip_t *sk)
{
kbd_raw_sync();
if (!kbd_raw_any_down()) { sk->armed = 1; return 0; }
return sk->armed;
}
static int intro_draw_page(uint8_t scene, uint8_t page)
{
if (scene >= INTRO_SCENES) return -1;
gfx_set_draw_page(page);
gfx_set_bank(GFX_BANK_NORMAL);
pop_screen_fill(BLACK);
return intro_blit_screen((uint8_t)(ARC_PV_S0_0 + scene * INTRO_PARTS),
POP_YOFF);
}
static int intro_draw(uint8_t scene)
{
uint8_t page = pop_screen_begin();
if (intro_draw_page(scene, page) != 0) return -1;
pop_screen_present(page);
return 0;
}
/* У gfx_pal_fload/sync палитра меняется по 64 цвета за вызов BIOS. Если в
* этот момент хоть одна страница содержит следующую сцену, один VSync может
* показать её уже яркой, ещё до штатного fade-in. Поэтому перед сменой
* источника палитры уничтожаем растр на ОБЕИХ страницах, а не только гасим
* их палитры. */
static void intro_black_pages(void)
{
gfx_set_draw_page(0);
gfx_set_bank(GFX_BANK_NORMAL);
pop_screen_fill(BLACK);
gfx_set_draw_page(1);
pop_screen_fill(BLACK);
pop_screen_present(1);
}
/* Для смены полного источника палитры сначала гасим старую сцену и
* оставляем на видимой странице настоящий чёрный растр. */
static void intro_prepare_palette(uint8_t pal)
{
intro_pending_pal = pal;
intro_pal_pending = 1;
pop_pal_apply(POP_PAL_DIM_BLACK);
intro_black_pages();
}
static void intro_commit_palette(void)
{
if (intro_pending_pal == INTRO_PAL_PV) pop_pal_pv_load();
else pop_pal_story_load((uint8_t)(intro_pending_pal == INTRO_PAL_STORY_END));
pop_pal_apply(POP_PAL_DIM_BLACK);
intro_pal_pending = 0;
}
static int intro_draw_new_palette(uint8_t scene)
{
uint8_t page;
/* Палитра загружается, пока обе страницы содержат только чёрный растр.
* После повторного затемнения можно собрать новую сцену в скрытую
* страницу и показать её исключительно чёрной перед fade-in. */
intro_commit_palette();
page = pop_screen_begin();
if (intro_draw_page(scene, page) != 0) return -1;
pop_screen_present(page);
return 0;
}
/* В отличие от intro_draw_page() здесь base собирается в shadow ОБЕИХ
* страниц. После show page 1 palette 0 не видна и в неё безопасно загрузить
* PV\pv.pal; первый animated frame соберётся в page 0. */
static int intro_pv_draw_base(void)
{
gfx_rect_t full;
gfx_set_draw_page(0);
gfx_set_bank(GFX_BANK_NORMAL);
pop_screen_fill(BLACK);
if (intro_blit_screen(ARC_PV_B0_0, POP_YOFF) != 0) return -1;
gfx_set_draw_page(1);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
pop_screen_present(1);
return 0;
}
/* ВОССТАНОВЛЕНИЕ ФОНА ЗАДНЕЙ СТРАНИЦЫ — ДВУМЯ БЛОКАМИ ПО КАДРОВОМУ ИНТЕРВАЛУ.
*
* Персонажи и живой фон рисуются банком спрайтов и в ОЗУ-копию не оседают,
* поэтому «стереть прошлый кадр» = скопировать чистый фон из теневой копии
* соседней страницы. Раньше это была ОДНА копия на весь экран, и она стоила
* ~547 000 тактов = 1,27 кадра луча (модель blit_cost_model). Хвост кадра
* сцены ждал vsync ПОСЛЕ отрисовки, так что эти 1,27 кадра ПРИБАВЛЯЛИСЬ к
* пяти интервалам делителя: сцена шла 6+ кадров вместо 5, то есть ~8,2 fps
* вместо десяти (замер 49/60 тиков — docs/BUGS_OPEN.md PV-RENDER-BOUND).
*
* Лечение — не «удешевить», а РАЗЛОЖИТЬ по интервалам: каждый блок заведомо
* влезает в кадр, и после него честно ждём vsync. Тогда отрисовка живёт
* ВНУТРИ делителя, а не сверх него.
* - копируем только картинку (200 строк с POP_YOFF), а не все 256:
* сверху и снизу чёрная рамка, туда никто не рисует;
* - двумя половинами по 100 строк: ~219 000 тактов каждая, чуть больше
* половины кадра — запас двукратный.
*
* ТРАТИТ РОВНО ДВА кадровых интервала (по gfx_wait_vsync на блок) — это
* учтено в раскладке кадра у всех трёх сцен (см. вызовы). */
#define PV_BG_Y0 POP_YOFF
#define PV_BG_H 200
static void pv_restore_bg(void)
{
gfx_rect_t r;
r.x = 0;
r.w = GFX_WIDTH;
r.h = PV_BG_H / 2;
r.y = PV_BG_Y0;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
r.y = PV_BG_Y0 + PV_BG_H / 2;
gfx_copy_page(&r, GFX_COPY_DIRECT);
gfx_wait_vsync();
}
static void pv_blit_floor(const atlas_t *at, uint8_t frame, int x, int floor)
{
const uint8_t *img;
gfx_w0_map(at->page);
img = (const uint8_t *)atlas_image(at, frame);
/* add_*table() оригинала задаёт нижнюю строку спрайта включительно. */
gfx_blit(x, POP_YOFF + floor - img[2] + 1, img);
gfx_w0_unmap();
}
static void pv_blit_actor(const atlas_t *at, uint8_t frame, int anchor_x,
int floor, uint8_t flip)
{
const uint8_t *img;
int x = anchor_x;
gfx_w0_map(at->page);
img = (const uint8_t *)atlas_image(at, frame);
/* draw_mid SDLPoP зеркалит правое направление относительно hot-point:
* экранный x сначала масштабирован, затем из него вычитается width. */
if (flip) x -= img[0];
gfx_blit_cols(x, POP_YOFF + floor - img[2] + 1, img, flip);
gfx_w0_unmap();
}
static void pv_draw_stars(const uint8_t *phase)
{
static const uint8_t star_x[] = { 20, 16, 23, 17, 24, 18 };
static const uint8_t star_y[] = { 97, 104, 110, 116, 120, 128 };
static const uint8_t star_color[] = { 8, 7, 15, 15, 7 };
uint8_t i;
for (i = 0; i < sizeof(star_x); i++) {
/* draw_rect SDLPoP получает полуинтервал [x,x+1)×[y,y+1).
* BGI bar() включает правую/нижнюю границу и давал звезду 2x2. */
putpixel(star_x[i], POP_YOFF + star_y[i], star_color[phase[i]]);
}
}
/* У Princess/Jaffar номер frame — это индекс frame_tbl_cuts, а картинка
* лежит в отдельном chtab. Эти таблицы — компактная нужная часть таблицы
* SDLPoP: не приходится тащить в банк весь interpreter SEQTBL. */
typedef struct {
int16_t x; /* Char.x оригинала: полу-пиксели */
uint8_t frame;
uint8_t left;
uint8_t visible;
uint8_t step;
} pv_actor_t;
/* frame_tbl_cuts[1..18].image; 10 — служебная пустая запись. */
static const uint8_t pv_princess_image[19] = {
0, 15, 1, 2, 3, 4, 5, 6, 7, 8, 0, 0, 9, 10, 11, 12, 13, 14, 16
};
static const int8_t pv_princess_dx[19] = {
0, 0, 0, 0, 0, -1, 2, 2, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0
};
static const uint8_t pv_princess_parity[19] = {
0, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 0
};
/* frame_tbl_cuts[48..85].dx. image равен frame - 48. */
static const int8_t pv_jaffar_dx[38] = {
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
3, 3, 3, 2, 3, 5, 5, 1, 2, 2, 1, 1, 2, 3, 3, 0, 2, 2, 1
};
static const uint8_t pv_jaffar_parity[38] = {
1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 0,
0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0
};
static const uint8_t pv_walk_frame[6] = { 48, 49, 50, 51, 52, 53 };
static const int8_t pv_walk_dx[6] = { 1, 2, 6, 1, -1, 1 };
static const uint8_t pv_raise_frame[] = {
85, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67,
68, 69, 70, 71, 72, 73, 74, 75, 83, 84, 76
};
static int pv_actor_x(const pv_actor_t *actor, int frame_dx, uint8_t parity)
{
int x = actor->x;
int logical;
if (actor->left) x -= frame_dx;
else x += frame_dx;
logical = x * 2 - 116;
/* load_frame_to_obj: нечётная фаза зависит от flags кадра и direction.
* chtab Princess/Jaffar затем проходит calc_screen_x_coord(320/280). */
if (actor->left == parity) ++logical;
return logical * 8 / 7;
}
static void pv_walk_step(pv_actor_t *jaffar)
{
uint8_t step = (uint8_t)(jaffar->step % 6);
int dx = pv_walk_dx[step];
if (jaffar->left) jaffar->x -= dx;
else jaffar->x += dx;
jaffar->frame = pv_walk_frame[step];
++jaffar->step;
}
static void pv_princess_step(pv_actor_t *princess, uint16_t tick)
{
uint16_t pos;
/* Palert: 2..9, flip, dx(8), 11. Она продолжается и во время входа
* Jaffar, как последовательность Palert оригинала. */
if (tick >= PV_TURN_START && tick < PV_TURN_START + 72 &&
((tick - PV_TURN_START) & 7) == 0) {
pos = (uint16_t)((tick - PV_TURN_START) >> 3);
if (pos < 8) princess->frame = (uint8_t)(2 + pos);
else {
princess->left = 0;
princess->x += 8;
princess->frame = 11;
}
}
/* Pstepback: flip, dx(11), 12; затем 13..16 и вечный кадр 17. */
if (tick >= PV_STEPBACK_START && tick < PV_STEPBACK_START + 42 &&
((tick - PV_STEPBACK_START) % 7) == 0) {
pos = (uint16_t)((tick - PV_STEPBACK_START) / 7);
if (pos == 0) {
princess->left = 1;
princess->x -= 11;
princess->frame = 12;
} else if (pos == 1) {
princess->x -= 1;
princess->frame = 13;
} else if (pos == 2) {
princess->x -= 1;
princess->frame = 14;
} else if (pos == 3) {
princess->x -= 3;
princess->frame = 15;
} else if (pos == 4) {
princess->x -= 1;
princess->frame = 16;
} else {
princess->frame = 17;
}
}
/* Pslump: один переходный frame 1, затем frame 18 до fade-out. */
if (tick == PV_SLUMP_START) princess->frame = 1;
else if (tick == PV_SLUMP_START + 7) princess->frame = 18;
}
static void pv_jaffar_exit_step(pv_actor_t *jaffar)
{
uint8_t pos = jaffar->step++;
if (pos < 6) {
jaffar->frame = (uint8_t)(77 + pos);
} else if (pos < 12) {
if (pos == 6) --jaffar->x;
jaffar->frame = 54;
} else if (pos < 22) {
uint8_t frame = (uint8_t)(57 + pos - 12);
if (frame == 62) jaffar->x -= 2;
else if (frame == 63) ++jaffar->x;
else if (frame == 64) jaffar->x += 3;
else if (frame == 66) ++jaffar->x;
jaffar->frame = frame;
} else if (pos == 22) {
/* Vexit заканчивается flip, dx(16), dx(3), затем Vwalk2. */
jaffar->left = 0;
jaffar->x += 19;
jaffar->frame = 49;
} else {
/* Vexit прыгает на метку Vwalk2: кадр 49 уже выведен в ветке
* flip, следующий шаг обязан начинаться с dx(6), frame 50. */
uint8_t walk = (uint8_t)(pos - 23 + 2);
int dx = pv_walk_dx[walk % 6];
jaffar->x += dx;
jaffar->frame = pv_walk_frame[walk % 6];
}
}
static void pv_jaffar_step(pv_actor_t *jaffar, uint16_t tick)
{
uint16_t pos;
if (tick == PV_WALK1_START || tick == PV_WALK2_START) jaffar->step = 0;
if (((tick >= PV_WALK1_START && tick < PV_DIALOG1_START) ||
(tick >= PV_WALK2_START && tick < PV_DIALOG2_START)) &&
((tick - ((tick < PV_DIALOG1_START) ? PV_WALK1_START :
PV_WALK2_START)) & 7) == 0) {
jaffar->visible = 1;
pv_walk_step(jaffar);
}
/* Vstop: dx(1), 55, 56, Vstand. */
if (tick == PV_DIALOG1_START || tick == PV_DIALOG2_START) {
--jaffar->x;
jaffar->frame = 55;
} else if (tick == PV_DIALOG1_START + 8 || tick == PV_DIALOG2_START + 8) {
jaffar->frame = 56;
} else if ((tick >= PV_DIALOG1_START + 16 && tick < PV_WALK2_START) ||
(tick >= PV_DIALOG2_START + 16 && tick < PV_RAISE_START)) {
jaffar->frame = 54;
}
if (tick >= PV_RAISE_START && tick < PV_EXIT_START &&
((tick - PV_RAISE_START) % 7) == 0) {
pos = (uint16_t)((tick - PV_RAISE_START) / 7);
if (pos < sizeof(pv_raise_frame)) jaffar->frame = pv_raise_frame[pos];
else jaffar->frame = 76;
}
if (tick == PV_EXIT_START) jaffar->step = 0;
if (tick >= PV_EXIT_START && ((tick - PV_EXIT_START) % 7) == 0) {
pv_jaffar_exit_step(jaffar);
if (!jaffar->left && jaffar->x > 205) jaffar->visible = 0;
}
}
static void pv_blit_jaffar(const atlas_t *j0, const atlas_t *j1,
const atlas_t *j2, const pv_actor_t *jaffar)
{
uint8_t image;
int x;
if (!jaffar->visible || jaffar->frame < 48 || jaffar->frame > 85) return;
image = (uint8_t)(jaffar->frame - 48);
x = pv_actor_x(jaffar, pv_jaffar_dx[image], pv_jaffar_parity[image]);
if (image < 13)
pv_blit_actor(j0, image, x, 166, (uint8_t)!jaffar->left);
else if (image < 24)
pv_blit_actor(j1, (uint8_t)(image - 13), x, 166,
(uint8_t)!jaffar->left);
else
pv_blit_actor(j2, (uint8_t)(image - 24), x, 166,
(uint8_t)!jaffar->left);
}
static void pv_blit_princess(const atlas_t *princess, const pv_actor_t *actor)
{
uint8_t frame = actor->frame;
if (frame == 0 || frame > 18) return;
pv_blit_actor(princess, pv_princess_image[frame],
pv_actor_x(actor, pv_princess_dx[frame],
pv_princess_parity[frame]),
166, (uint8_t)!actor->left);
}
/* Длина логического кадра в тиках 60 Гц — cutscene_frame_time оригинала.
* Сколько это кадров луча, решает пейсинг в intro_pv_animated. */
static uint8_t pv_seq_period(uint16_t tick)
{
if (tick < PV_M50_END) return 6;
if (tick < PV_RAISE_START) return 8;
return 7;
}
/* Рисует кадр и меняет страницу; возвращает, сколько кадров луча на это
* ушло (вспышка живёт на физической шкале и стоит одиннадцать). */
static uint8_t intro_pv_draw_frame(const atlas_t *princess, const atlas_t *j0,
const atlas_t *j1, const atlas_t *j2,
const atlas_t *hourglass, const atlas_t *torch,
const pv_actor_t *p, const pv_actor_t *j,
const uint8_t *star_phase,
uint8_t torch_left, uint8_t torch_right,
uint8_t sand_frame, uint8_t flash,
uint16_t tick)
{
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
/* y=116 в princess_room_torch — нижняя строка, не верх спрайта. */
pv_blit_floor(torch, torch_left, 93, 116);
pv_blit_floor(torch, torch_right, 211, 116);
pv_draw_stars(star_phase);
pv_blit_princess(princess, p);
pv_blit_jaffar(j0, j1, j2, j);
if (tick >= PV_MAGIC_START) {
/* В SDLPoP hourglass_sandflow=0 означает первый кадр потока,
* а -1 — отсутствие песка. Корпус появляется ещё при молнии. */
pv_blit_floor(hourglass,
(uint8_t)(tick < PV_GLASS_DONE ? 1 : 2), 152, 168);
}
if (tick >= PV_EXIT_START) {
/* hourglass_sandflow=0 оригинал ставит лишь после окончания
* conjuring — когда Jaffar опускает руки и начинает уходить. */
pv_blit_floor(hourglass, (uint8_t)(8 + sand_frame), 160, 164);
}
/* draw_princess_room_bg() SDLPoP помещает image 2 chtab PV в
* foretable с x=30*8, ybottom=167. В нашем h0 это кадр 0 (res952):
* его надо повторить ПОСЛЕ актёров, иначе Jaffar проходит перед
* правой передней колонной. */
pv_blit_floor(hourglass, 0, 240, 167);
gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back);
if (flash) {
uint8_t pulse;
/* Молния намеренно живёт на физической, а не cutscene-шкале:
* пять быстрых white/black импульсов, актёры на это время стоят. */
for (pulse = 0; pulse < 5; pulse++) {
pop_pal_flash_white(1);
gfx_wait_vsync();
pop_pal_flash_white(0);
gfx_wait_vsync();
}
return 13; /* три интервала кадра + десять на молнию */
}
return 3; /* сколько кадровых интервалов съели */
}
/* Сцена pv_scene() SDLPoP: ожидание Princess с факелами/звёздами, поворот,
* вход и речь Jaffar, заклинание+молния, часы с песком, уход. Тайминг
* воспроизводится на кадровой шкале; пропуск проверяется каждый кадр. */
static int intro_pv_animated(void)
{
atlas_t princess, jaffar0, jaffar1, jaffar2, hourglass, torch;
pv_actor_t princess_actor;
pv_actor_t jaffar_actor;
uint16_t tick;
uint16_t frame_end;
uint16_t render_tick;
uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
uint8_t period;
uint8_t anim_step = 0;
uint8_t torch_left = 1;
uint8_t torch_right = 6;
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
uint8_t sand_frame = 0;
uint8_t flash_done = 0;
uint8_t faded_in = 0;
uint8_t flash;
intro_skip_t skip;
int result = -1;
intro_prepare_palette(INTRO_PAL_PV);
/* commit обязан быть до сборки base: gfx_pal_sync обновляет и видимую
* страницу, поэтому там в этот момент должен оставаться только чёрный
* растр из intro_prepare_palette(). */
intro_commit_palette();
if (intro_pv_draw_base() != 0) return -1;
/* b0_* собирается полосами: на время очередной полосы ей тоже нужна
* временная EMM-страница. Поэтому фон готовится до постоянных
* actor-атласов: порядок сохраняет независимость загрузчика полос от
* числа одновременно живущих анимационных ресурсов. */
/* Шесть атласов сцены — из ОДНОГО открытого архива. */
{
uint8_t tbl[ARC_PV_COUNT * POP_ARC_REC];
pop_arc_t arc;
int8_t bad;
if (pop_arc_open_id(&arc, POP_ARC_PV, tbl, ARC_PV_COUNT) < 0) return -1;
bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_A0, &princess) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_J0, &jaffar0) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_J1, &jaffar1) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_J2, &jaffar2) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_H0, &hourglass) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_T0, &torch) != 0);
pop_arc_close(&arc);
if (bad) return -1;
}
/* Проявление — ПОСЛЕ первого нарисованного кадра (см. ниже в цикле):
* иначе две секунды fade идут по пустой комнате, а принцесса возникает
* уже на свету. */
/* Актёрские атласы в EMM; дальше по сцене диск трогает только подкачка
* следующей реплики — по одной странице за кадр (pop_music_load_step).
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
* бандами по 16 строк. */
(void)pop_sfx_start();
/* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
*
* Была вторая шкала — тики насоса CBL, и выбор между ними делался
* гонкой на старте сцены (одна выборка `pop_snd_tick` через кадр:
* успел ли диск раскрутить звук). Значит от прогона к прогону сцена
* шла то по одним часам, то по другим, и кода реплики приходилась
* каждый раз на другое место картинки (наблюдение пользователя
* 2026-08-28; баг SND-PACE-DEAD). Насос как часы был нужен потому,
* что кадр сцены рисовался ДОЛЬШЕ своего интервала и счёт наших
* ожиданий врал; теперь отрисовка разложена на блоки по кадровому
* интервалу (см. pv_restore_bg), и счёт кадров луча честен.
*
* Осталось учесть единственную работу, которая в интервал не влезала
* сама, — подкачку страницы трека: шаг читает ПОЛСТРАНИЦЫ (~16 мс,
* pop_music_load_step) и оплачивается ровно одним интервалом, как
* любой другой блок кадра. */
result = 0;
princess_actor.x = 120;
princess_actor.frame = 11; /* Pstand, facing left */
princess_actor.left = 1;
princess_actor.visible = 1;
princess_actor.step = 0;
jaffar_actor.x = 198;
jaffar_actor.frame = 54; /* Vstand, behind the closed door */
jaffar_actor.left = 1;
jaffar_actor.visible = 0;
jaffar_actor.step = 0;
tick = 0;
pv_timing_init(); /* шкала под длительности НАШЕГО набора */
intro_skip_begin(&skip);
while (tick < PV_ANIM_TICKS) {
if (intro_skip_requested(&skip)) {
result = 1;
break;
}
/* Один проход = один логический кадр cutscene. Внутри проходим все
* его исходные 6/8/7 тиков, снаружи draw_frame держит результат
* четыре физических кадра — единые номинальные 12,5 FPS. */
flash = 0;
period = pv_seq_period(tick);
frame_end = (uint16_t)(tick + period);
if (frame_end > PV_ANIM_TICKS) frame_end = PV_ANIM_TICKS;
do {
pv_princess_step(&princess_actor, tick);
pv_jaffar_step(&jaffar_actor, tick);
if (tick == PV_MAGIC_START && !flash_done) {
flash = 1;
flash_done = 1;
}
/* Границы шкалы уже стоят там, где оригинал ЖДЁТ окончания
* очередного сэмпла, — заявляем эффект ровно на них. */
if (tick == PV_WAIT_END) pop_sfx_play(PV_SND_GATE_CLOSING);
else if (tick == PV_GATE_END) pop_sfx_play(PV_SND_DOOR_OPENING);
/* Три реплики сцены. Каждая следующая читается с диска, пока
* звучит предыдущая (второй слот pop_music), поэтому здесь
* остаётся только пуск — мгновенный. */
else if (tick == PV_MUS_2_START) pop_music_play();
else if (tick == PV_MUS_3_LOAD)
(void)pop_music_load_begin(POP_MUS_STORY_3);
else if (tick == PV_DIALOG1_START) pop_music_play();
else if (tick == PV_MUS_4_LOAD)
(void)pop_music_load_begin(POP_MUS_STORY_4);
else if (tick == PV_MUS_4_START) pop_music_play();
++tick;
} while (tick < frame_end);
{
uint8_t star = (uint8_t)(anim_step % 6);
torch_left = (uint8_t)((torch_left + 1) % 9);
torch_right = (uint8_t)((torch_right + 1) % 9);
star_phase[star] = (uint8_t)((star_phase[star] + 1) % 5);
if (tick > PV_EXIT_START)
sand_frame = (uint8_t)((sand_frame + 1) % 3);
++anim_step;
}
render_tick = (uint16_t)(tick - 1);
beam += intro_pv_draw_frame(&princess, &jaffar0, &jaffar1, &jaffar2,
&hourglass, &torch, &princess_actor,
&jaffar_actor, star_phase, torch_left,
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
*
* Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* не было: кадр дорогой, шкала работает по принципу «долг не
* догоняем», и цикл ожидания выходил сразу. Замер в MAME
* 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
* страниц m53 не успевали за всю сцену — pop_music_play() на своём
* тике не находил готового трека, и реплики Джафара пропадали
* совсем (memory pv_music_stall_regression).
*
* Поэтому шаг безусловный — и оплачен как обычный блок кадра: он
* читает полстраницы (~16 мс), а следующий gfx_wait_vsync ставит
* нас на край интервала, который мы этим и заняли. */
if (pop_music_loading()) {
(void)pop_music_load_step();
gfx_wait_vsync();
beam++;
}
/* Первый кадр собран под чёрной палитрой — теперь проявляем его
* целиком, вместе с принцессой и факелами. */
if (!faded_in) {
faded_in = 1;
if (pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
}
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 6/8/7 тиков по
* 1/60 с (pv_seq_period), в кадры луча переводит POP_T60, и цель
* КУМУЛЯТИВНА: нецелые 6,67 и 5,83 не копят ошибку.
*
* ДОЛГ НЕ ДОГОНЯЕМ: если кадр вышел дороже своего бюджета, цикл
* ожидания просто не сработает, но beam уже учёл потраченное — и
* следующая цель считается от той же общей шкалы. Наверстывать
* пропущенное нельзя: события соседних тиков (створка ворот, дверь
* покоев) слиплись бы в один кадр, и один из звуков пропал бы. */
{
uint16_t target = POP_T60(tick);
while (beam < target) { gfx_wait_vsync(); beam++; }
}
}
/* CBL НЕ ГЛУШИМ: последняя реплика Джафара звучит поверх смены сцены —
* ровно как в оригинале, где show_title ждёт её окончания уже на
* экране «свадьбы» (seg000:2051). Глушит вызывающий, когда музыка
* действительно доиграла. */
if (result) { pop_music_free(); pop_sfx_pause(); }
if (!result) (void)pop_pal_fade_out_skip(INTRO_FADE);
atlas_free(&torch);
atlas_free(&hourglass);
atlas_free(&jaffar2);
atlas_free(&jaffar1);
atlas_free(&jaffar0);
atlas_free(&princess);
return result;
}
/* hourglass_frame() SDLPoP: четыре границы оставшегося времени выбирают
* один из кадров 6..2. В h0.atl номер кадра совпадает с hourglass_state. */
static uint8_t pre_hourglass_state(void)
{
static const uint8_t bound[] = { 6, 17, 33, 65 };
uint8_t i;
for (i = 0; i < sizeof(bound); i++)
if (bound[i] > pop_timer_minutes) break;
return (uint8_t)(6 - i);
}
/* cutscene_2_6()/cutscene_4(): 26 логических кадров по 100 мс. У SDLPoP
* это 6/60 секунды, у Sprinter — 5/50, то есть 130 физических кадров.
* Статична только поза Princess; draw_princess_room_bg оригинала
* каждый логический кадр заново обновляет факелы, звёзды, корпус часов и
* одну из трёх фаз сыплющегося песка. */
/* Живая комната принцессы БЕЗ актёрской механики: готовая композиция (или
* чистый фон, если picture == PV_ROOM_BARE), поверх неё пламя факелов,
* звёзды в окне и песочные часы заданного состояния. На этом держатся и
* сцены перед уровнями, и экран «время вышло» — разница только в картинке,
* состоянии часов и длине.
*
* hg_state: 0 — часов нет, 7 — время вышло (пустые, без струйки песка). */
#define PV_ROOM_BARE 0xFF
static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
{
atlas_t hourglass, torch;
uint8_t torch_left = 1, torch_right = 6, sand = 0, frame;
uint8_t star_phase[6] = { 1, 1, 1, 1, 1, 1 };
intro_skip_t skip;
int result = -1;
intro_prepare_palette(INTRO_PAL_PV);
intro_commit_palette();
/* Обе shadow-страницы содержат чистую статическую сцену. Динамика ниже
* рисуется GFX_BANK_SPRITE и потому не оседает в этом источнике. */
if (picture == PV_ROOM_BARE) {
if (intro_pv_draw_base() != 0) return -1;
} else if (intro_draw_page(picture, 0) != 0 ||
intro_draw_page(picture, 1) != 0) {
return -1;
}
{
uint8_t tbl[ARC_PV_COUNT * POP_ARC_REC];
pop_arc_t arc;
int8_t bad;
if (pop_arc_open_id(&arc, POP_ARC_PV, tbl, ARC_PV_COUNT) < 0) return -1;
bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_H0, &hourglass) != 0);
if (!bad) bad = (int8_t)(pv_atlas(&arc, tbl, ARC_PV_T0, &torch) != 0);
pop_arc_close(&arc);
if (bad) return -1;
}
pop_screen_present(1);
result = 0;
intro_skip_begin(&skip);
for (frame = 0; frame < frames; frame++) {
uint8_t back, hold, star;
if (intro_skip_requested(&skip)) { result = 1; break; }
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
pv_restore_bg(); /* блоки 1-2: фон, два интервала */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&torch, torch_left, 93, 116);
pv_blit_floor(&torch, torch_right, 211, 116);
pv_draw_stars(star_phase);
if (state) {
pv_blit_floor(&hourglass, state, 152, 168);
/* Струйка песка есть, пока часы идут: при state 7 время уже
* вышло (draw_hourglass, sandflow = -1). */
if (state < 7)
pv_blit_floor(&hourglass, (uint8_t)(8 + sand), 160, 164);
}
pv_blit_floor(&hourglass, 0, 240, 167); /* передняя колонна */
gfx_wait_vsync(); /* блок 3: живой фон */
gfx_set_visible_page(back);
/* Три интервала уже потрачены на блоки отрисовки — добираем до
* пяти (5/50 с = кадр катсцены оригинала). */
for (hold = 3; hold < 5; hold++) gfx_wait_vsync();
/* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
* пустой комнате, а часы и пламя возникают уже на свету. */
if (frame == 0 && pop_pal_fade_in_skip(INTRO_FADE)) { result = 1; break; }
torch_left = (uint8_t)((torch_left + 1) % 9);
torch_right = (uint8_t)((torch_right + 1) % 9);
sand = (uint8_t)((sand + 1) % 3);
star = (uint8_t)(frame % 6);
star_phase[star] = (uint8_t)((star_phase[star] + 1) % 5);
}
if (!result) pop_pal_fade_out(INTRO_FADE);
atlas_free(&torch);
atlas_free(&hourglass);
return result;
}
/* SDLPoP transition_ltr(): подготовленная story-страница копируется на
* видимый title узкими вертикальными полосами слева направо. У оригинала
* 2px/120 FPS; здесь 4px/60 FPS даёт ту же длительность (~1.3 с), не
* удваивая число полных копий 320x256 в ограниченном bank 9. */
/* Собрать «In the absence» в СКРЫТУЮ страницу. Отделено от показа: между
* ними успевает загрузиться трек, а на экране всё это время держится
* последний кадр титров — ровно как у оригинала, который рисует историю в
* offscreen и лишь потом пускает transition_ltr. */
static int intro_story_prepare(void)
{
uint8_t target = (uint8_t)(gfx_get_visible_page() ^ 1);
return intro_draw_page(INTRO_STORY_ABSENCE, target);
}
/* Полосовой показ подготовленной страницы живёт в pop_ui: тем же приёмом
* появляются титульная картинка финала и таблица рекордов. */
static void intro_flash(uint16_t ticks)
{
uint8_t page;
page = pop_screen_begin();
pop_screen_fill(WHITE);
pop_screen_present(page);
while (ticks--) gfx_wait_vsync();
/* Вспышка ничего не меняет в сценарии: вернуть ту же собранную стадию. */
intro_draw(intro_scene);
}
static void intro_restore_game_palette(void)
{
/* Обе страницы — именно чёрный растр до gfx_pal_fload/sync. Иначе
* синхронизация palette 1 способна на один кадр высветить credits перед
* переходом в demo. */
intro_black_pages();
pop_pal_game_load();
pop_pal_apply(POP_PAL_DIM_BLACK);
}
/* ================= ПЕРСОНАЖИ КАТСЦЕН ================================= *
*
* Порт cutscene_8 / cutscene_9 (seg001:034D/03B7) и end_sequence_anim
* (seg001:041C). Все три идут через load_intro(1, …): фон — комната
* принцессы (та же b0_*, что у intro), а поверх играют ДВА персонажа.
*
* Движок общий с игрой, и это главное: последовательности крутит наш
* play_seq (pop_kid_play/pop_guard_play), кадры принцессы берутся из
* таблицы КАТСЦЕН (kid.ani, KID_BIN_CFRAMES_OFF), мышь и Кид — из
* таблицы Кида, как в оригинале (frame_table_kid общая для kid и mouse).
* Отличается только ОТРИСОВКА: спрайты принцессы лежат в PV-атласах, а
* Кид/мышь — в игровых kid-атласах, поэтому в pv.pal под них отведена
* своя палитровая строка 0x70.
*
* Слоты — как в оригинале: Kid-окно занимает мышь (или Кид в финале),
* Guard-окно — принцесса.
*/
/* Набор PV2 (res901..930) не влезает в одну EMM: image 0..16 в a1,
* 17..29 в a2. Номер кадра при этом остаётся исходным image-id. */
#define CUT_PV2_SPLIT 17
/* Один логический кадр катсцены = 5 vsync, как у остальных PV-сцен: у
* оригинала `cutscene_frame_time = 6` тиков по 1/60 с (reset_cutscene,
* seg001:527, и его зовёт load_intro прямо перед сценой) = 100 мс, у нас
* 5 кадров луча по 50 Гц = те же 100 мс.
*
* РАСКЛАДКА ПО ИНТЕРВАЛАМ (иначе отрисовка прибавлялась к делителю, а не
* жила внутри него — см. pv_restore_bg): фон половинами 1-2, актёры и
* декорации 3, служебный блок (звук/подкачка музыки) 4-5. */
#define CUT_FRAME_VSYNC 5
static atlas_t cut_pv1, cut_pv2a, cut_pv2b, cut_hg, cut_torch;
static uint8_t cut_atlases; /* сколько успели загрузить — столько и вернём */
/* Детектор фронта живёт на ВСЮ сцену: она склеена из нескольких cut_run, и
* взводить его заново на каждом отрезке значило бы ловить одно удержание
* несколько раз. */
static intro_skip_t cut_skip;
/* Фазы «живого» фона: пламя двух факелов, песок и мерцание звёзд. Счётчики
* общие на всю сцену — она склеена из нескольких cut_run. */
static uint8_t cut_torch_l, cut_torch_r, cut_sand, cut_star_phase[6];
static uint8_t cut_hg_state;
static void cut_free(void)
{
if (cut_atlases > 4) atlas_free(&cut_torch);
if (cut_atlases > 3) atlas_free(&cut_hg);
if (cut_atlases > 2) atlas_free(&cut_pv2b);
if (cut_atlases > 1) atlas_free(&cut_pv2a);
if (cut_atlases > 0) atlas_free(&cut_pv1);
cut_atlases = 0;
}
static int cut_load(void)
{
uint8_t tbl[ARC_PV_COUNT * POP_ARC_REC];
pop_arc_t arc;
cut_atlases = 0;
if (pop_arc_open_id(&arc, POP_ARC_PV, tbl, ARC_PV_COUNT) < 0) return -1;
if (pv_atlas(&arc, tbl, ARC_PV_A0, &cut_pv1) != 0) {
pop_arc_close(&arc); return -1;
}
cut_atlases = 1;
if (pv_atlas(&arc, tbl, ARC_PV_A1, &cut_pv2a) != 0) {
pop_arc_close(&arc); cut_free(); return -1;
}
cut_atlases = 2;
if (pv_atlas(&arc, tbl, ARC_PV_A2, &cut_pv2b) != 0) {
pop_arc_close(&arc); cut_free(); return -1;
}
cut_atlases = 3;
/* Комната принцессы живёт и в катсценах: пламя факелов, звёзды в окне,
* песок в часах — и ПЕРЕДНЯЯ КОЛОННА, которая по оригиналу закрывает
* персонажей (draw_princess_room_bg + foretable). */
if (pv_atlas(&arc, tbl, ARC_PV_H0, &cut_hg) != 0) {
pop_arc_close(&arc); cut_free(); return -1;
}
cut_atlases = 4;
if (pv_atlas(&arc, tbl, ARC_PV_T0, &cut_torch) != 0) {
pop_arc_close(&arc); cut_free(); return -1;
}
cut_atlases = 5;
pop_arc_close(&arc);
return 0;
}
/* Экранная X кадра: та же математика, что у актёров intro — logical
* координата (x*2 − 116) с нечётной фазой по flags кадра, затем
* calc_screen_x_coord (×8/7). */
static int cut_screen_x(const pop_char_t *ch, const kframe *fr)
{
int x = ch->x;
int logical;
uint8_t left = (uint8_t)(ch->direction < 0);
if (left) x -= fr->dx;
else x += fr->dx;
logical = x * 2 - 116;
if (left == (uint8_t)((fr->flags & 0x80) != 0)) ++logical;
return logical * 8 / 7;
}
/* Принцесса: набор выбирает поле sword кадра (>>6: 1 = PV1 res800,
* 2 = PV2 res900) — ровно как chtab_base + (cur_frame.sword >> 6)
* в seg008:1759. */
static void cut_blit_princess(const pop_char_t *ch, const kframe *fr)
{
const atlas_t *at;
uint8_t idx;
if (fr->image == 255 || cut_atlases < 3) return;
if ((uint8_t)(fr->sword >> 6) >= 2) {
if (fr->image < CUT_PV2_SPLIT) { at = &cut_pv2a; idx = fr->image; }
else { at = &cut_pv2b; idx = (uint8_t)(fr->image - CUT_PV2_SPLIT); }
} else {
at = &cut_pv1; idx = fr->image;
}
pv_blit_actor(at, idx, cut_screen_x(ch, fr),
(int)ch->y + fr->dy, (uint8_t)(ch->direction >= 0));
}
/* Кид и мышь — спрайты chtab_2 из игровых атласов (kidp[]). Атласы Кида
* хранятся column-major (ради бесплатного флипа), поэтому блит колоночный. */
static void cut_blit_kid(const pop_char_t *ch, const kframe *fr)
{
const uint8_t *img;
uint8_t pg, idx, flip;
int x;
if (fr->image == 255) return;
pg = (uint8_t)(fr->image >> 3);
idx = (uint8_t)(fr->image & 7);
if (pg >= kid_npages) return;
flip = (uint8_t)(ch->direction >= 0);
x = cut_screen_x(ch, fr);
gfx_w0_map(kidp[pg].page);
img = (const uint8_t *)atlas_image(&kidp[pg], idx);
if (flip) x -= img[0];
gfx_blit_cols(x, POP_YOFF + (int)ch->y + fr->dy - img[2] + 1, img, flip);
gfx_w0_unmap();
}
/* Сменить последовательность персонажа в его окне (seqtbl_offset_kid_char /
* seqtbl_offset_shad_char оригинала). */
static void cut_kid_seq(uint8_t seq)
{
pop_loadkid(); pop_char_set_seq(seq); pop_savekid();
}
static void cut_shad_seq(uint8_t seq)
{
pop_loadshad(); pop_char_set_seq(seq); pop_saveshad();
}
/* Поставить персонажа так, как это делают init_* оригинала: позиция,
* направление и стартовая последовательность, затем первый play_seq. */
static void cut_kid_init(uint8_t charid, uint8_t x, uint8_t y, uint8_t seq)
{
Kid.charid = charid;
Kid.x = x;
Kid.y = y;
Kid.direction = -1; /* dir_FF_left */
Kid.alive = -1;
Kid.frame = 0;
Kid.fall_x = Kid.fall_y = 0;
Kid.repeat = 0;
cut_kid_seq(seq);
pop_kid_play();
}
static void cut_shad_init(uint8_t x, uint8_t y, uint8_t seq)
{
Guard.charid = CHARID_5_PRINCESS;
Guard.x = x;
Guard.y = y;
Guard.direction = -1;
Guard.alive = -1;
Guard.frame = 0;
Guard.fall_x = Guard.fall_y = 0;
Guard.repeat = 0;
cut_shad_seq(seq);
pop_guard_play();
}
/* proc_cutscene_frame(n): прокрутить n логических кадров, каждый — шаг
* последовательности обоих персонажей плюс отрисовка. Возврат 1 — игрок
* прервал сцену (любое НОВОЕ нажатие). */
static uint8_t cut_run(uint16_t frames, uint8_t kid_visible)
{
uint16_t i;
uint8_t hold;
for (i = 0; i < frames; i++) {
uint8_t back;
kframe kid_fr, pr_fr;
if (intro_skip_requested(&cut_skip)) return 1;
/* Кадр забираем СРАЗУ после play каждого слота. load_frame кладёт
* его в кэш ПО charid активного персонажа: у Кида это kid_frame, у
* всех прочих — общий pop_gframe. В Kid-окне здесь сидит МЫШЬ
* (charid 24), то есть её кадр уходит в pop_gframe и затирается
* следом кадром принцессы, а kid_frame держит позу игрового Кида.
* Читать кэши в конце кадра значило рисовать мышь спрайтом Кида и
* терять принцессу — ровно это и было видно в сценах 8/9. */
pop_kid_play(); /* мышь либо Кид */
kid_fr = (Kid.charid == CHARID_0_KID) ? kid_frame : pop_gframe;
pop_guard_play(); /* принцесса */
pr_fr = pop_gframe;
back = (uint8_t)(gfx_get_visible_page() ^ 1);
gfx_set_draw_page(back);
/* Чистый фон обеих страниц лежит в shadow-копии; персонажи рисуются
* банком спрайтов и потому в неё не оседают. Двумя блоками по
* кадровому интервалу — см. pv_restore_bg. */
pv_restore_bg(); /* блоки 1-2 */
gfx_set_bank(GFX_BANK_SPRITE);
pv_blit_floor(&cut_torch, cut_torch_l, 93, 116);
pv_blit_floor(&cut_torch, cut_torch_r, 211, 116);
pv_draw_stars(cut_star_phase);
/* hourglass_state == 0 — часов в комнате нет вовсе (reset_cutscene
* SDLPoP ставит state=0 и sandflow=-1). Так идёт финальная встреча
* с принцессой: время уже не считается, это победа.
* state 7 — время ВЫШЛО: часы стоят пустыми, и струйки песка в них
* тоже нет (draw_hourglass: `if (hourglass_state >= 7) return;`). */
if (cut_hg_state) {
pv_blit_floor(&cut_hg, cut_hg_state, 152, 168);
if (cut_hg_state < 7)
pv_blit_floor(&cut_hg, (uint8_t)(8 + cut_sand), 160, 164);
}
cut_blit_princess(&Guard, &pr_fr);
if (kid_visible) cut_blit_kid(&Kid, &kid_fr);
/* ПОСЛЕ персонажей: передняя колонна — передний слой комнаты, она
* обязана закрывать и принцессу, и мышь (иначе мышь «просвечивает»
* сквозь неё — поймано пользователем 2026-08-25). */
pv_blit_floor(&cut_hg, 0, 240, 167);
gfx_set_bank(GFX_BANK_NORMAL);
gfx_wait_vsync(); /* блок 3: актёры и декорации */
gfx_set_visible_page(back);
/* Блок 4 — служебный, и он НЕ добавляется к кадру, а живёт внутри
* оставшихся интервалов: подкачка страницы трека стоит ~33 мс, то
* есть меньше двух интервалов, которые всё равно надо переждать.
* Порядок как в сцене с Джафаром: шаг подкачки — безусловный (см.
* memory pv_music_stall_regression). */
pop_sfx_tick();
pop_music_service(); /* «встреча с принцессой» — трек, а не эффект */
for (hold = 3; hold < CUT_FRAME_VSYNC; hold++) gfx_wait_vsync();
cut_torch_l = (uint8_t)((cut_torch_l + 1) % 9);
cut_torch_r = (uint8_t)((cut_torch_r + 1) % 9);
cut_sand = (uint8_t)((cut_sand + 1) % 3);
{
uint8_t star = (uint8_t)(i % 6);
cut_star_phase[star] = (uint8_t)((cut_star_phase[star] + 1) % 5);
}
}
return 0;
}
/* Общая обвязка всех трёх сцен: чистый фон комнаты принцессы в обе
* страницы, PV-палитра, актёрские атласы. hg_state — кадр песочных часов
* (0 = часов нет). Возврат -1 — ресурсов нет
* (неполный HDD), и caller откатывается на статический сценарий. */
static int cut_begin(uint8_t hg_state)
{
uint8_t i;
intro_skip_begin(&cut_skip);
cut_torch_l = 1; cut_torch_r = 6; cut_sand = 0;
for (i = 0; i < 6; i++) cut_star_phase[i] = 1;
cut_hg_state = hg_state;
intro_prepare_palette(INTRO_PAL_PV);
intro_commit_palette();
if (intro_pv_draw_base() != 0) return -1;
if (cut_load() != 0) return -1;
/* Атласы сцены прочитаны — можно открывать вывод. Без этого сцена шла
* молча: pop_ending_show глушит CBL первым действием, а включить его
* обратно было некому, и «arrived to princess» (26) не звучал вовсе. */
(void)pop_sfx_start();
return 0;
}
/* Прервали ли последнюю живую сцену клавишей — нужно вызывающему: по
* пропуску музыку обрываем, а не дослушиваем. */
static uint8_t cut_aborted;
static void cut_end(uint8_t aborted)
{
cut_aborted = aborted;
if (!aborted && pop_pal_fade_out_skip(INTRO_FADE)) cut_aborted = 1;
cut_free();
}
/* cutscene_8 (seg001:034D): мышь сидит рядом с принцессой, встаёт и
* убегает; принцесса поднимается ей вслед. */
static int cut_scene_8(void)
{
uint8_t aborted;
if (cut_begin(pre_hourglass_state()) != 0) return -1;
/* init_mouse_cu8 (seg001:0212) = init_mouse_go + x=144 + seq_106. */
cut_kid_init(CHARID_24_MOUSE, 144, 167, 106);
/* princess_crouching (seg001:024D): сидит и гладит мышь. */
cut_shad_init(131, 169, 110);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 1);
if (!aborted) aborted = pop_pal_fade_in_skip(INTRO_FADE);
if (!aborted) aborted = cut_run(19, 1);
if (!aborted) {
cut_kid_seq(107); /* мышь встаёт и уходит */
aborted = cut_run(20, 1);
}
if (!aborted) {
cut_shad_seq(111); /* принцесса встаёт */
aborted = cut_run(20, 1);
}
cut_end(aborted);
return 0;
}
/* cutscene_9 (seg001:03B7): принцесса стоит, мышь прибегает, принцесса
* приседает к ней. */
static int cut_scene_9(void)
{
uint8_t aborted;
if (cut_begin(pre_hourglass_state()) != 0) return -1;
/* princess_stand (seg001:026A) — поза PV1, лицом вправо. */
cut_shad_init(144, 169, 94);
Guard.direction = 0;
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 0); /* мышь ещё за кадром */
if (!aborted) aborted = pop_pal_fade_in_skip(INTRO_FADE);
/* init_mouse_go (seg001:022A): мышь вбегает справа. */
cut_kid_init(CHARID_24_MOUSE, 199, 167, 105);
if (!aborted) aborted = cut_run(5, 1);
if (!aborted) {
cut_shad_seq(112); /* принцесса приседает */
aborted = cut_run(9, 1);
}
if (!aborted) {
cut_kid_seq(114); /* мышь останавливается */
aborted = cut_run(58, 1);
}
cut_end(aborted);
return 0;
}
/* cutscene_12 (seg001:0525), ветка «времени мало»: принцесса стоит правее
* обычного, через два кадра оборачивается — и всё. Живой её делает та же
* машинка, что сцены 8/9: часы здесь обязаны показывать ОСТАВШЕЕСЯ время,
* а не запечённую в композицию картинку. */
static int cut_scene_12_short(void)
{
uint8_t aborted;
if (cut_begin(pre_hourglass_state()) != 0) return -1;
/* init_princess_x156 (seg001:0230) = init_princess + x = 156. */
cut_shad_init(156, 166, 94);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 0);
if (!aborted) aborted = pop_pal_fade_in_skip(INTRO_FADE);
if (!aborted) aborted = cut_run(1, 0);
if (!aborted) {
cut_shad_seq(98); /* princess turn around [PV1] */
aborted = cut_run(24, 0);
}
cut_end(aborted);
return 0;
}
/* end_sequence_anim (seg001:041C): Кид вбегает к принцессе, она
* поворачивается и обнимает его; следом появляется мышь. */
static int cut_ending(void)
{
uint8_t aborted;
/* end_sequence_anim (seg001:041C) НЕ трогает hourglass_state, а
* reset_cutscene перед сценой обнулил его: песочных часов в финале нет.
* Это и логично — уровень пройден, время больше не идёт. */
if (cut_begin(0) != 0) return -1;
/* init_ending_princess (seg001:0251) + init_ending_kid (seg001:0287). */
cut_shad_init(136, 164, 109);
cut_kid_init(CHARID_0_KID, 198, 164, 1);
/* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
* время на экране была бы пустая комната: персонажи появляются только с
* первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
* картинка проявляется целиком, вместе с актёрами. */
aborted = cut_run(1, 1);
if (!aborted) aborted = pop_pal_fade_in_skip(INTRO_FADE);
pop_sfx_play(26); /* «arrived to princess» */
if (!aborted) aborted = cut_run(7, 1);
if (!aborted) {
cut_shad_seq(108); /* поворот и объятие */
aborted = cut_run(5, 1);
}
if (!aborted) {
cut_kid_seq(13); /* Кид останавливается */
aborted = cut_run(2, 1);
}
/* Kid.frame = 0 (seg001:0348): дальше Кида не видно — он в объятиях,
* и рисуется только принцесса. */
if (!aborted) aborted = cut_run(39, 0);
if (!aborted) {
/* init_mouse_1 (seg001:02D5): мышь приходит к паре. */
cut_kid_init(CHARID_24_MOUSE, 197, 164, 105);
aborted = cut_run(9, 1);
}
if (!aborted) {
cut_kid_seq(101); /* мышь встаёт на задние лапы */
aborted = cut_run(41, 1);
}
cut_end(aborted);
return 0;
}
static uint8_t intro_run(const pop_cs_cmd_t *script)
{
pop_cutscene_t cs;
intro_skip_t skip;
uint16_t arg;
uint8_t aborted = 0;
pop_cutscene_begin(&cs, script);
intro_skip_begin(&skip);
for (;;) {
pop_cs_event_t ev;
if (intro_skip_requested(&skip)) {
pop_cutscene_abort(&cs);
aborted = 1;
break;
}
ev = pop_cutscene_tick(&cs, &arg);
if (ev == POP_CS_EVENT_END) break;
if (ev == POP_CS_EVENT_PALETTE) intro_prepare_palette((uint8_t)arg);
else if (ev == POP_CS_EVENT_SHOW) {
intro_scene = (uint8_t)arg;
if ((intro_pal_pending ? intro_draw_new_palette(intro_scene) :
intro_draw(intro_scene)) != 0) {
pop_cutscene_abort(&cs);
aborted = 1;
break;
}
} else if (ev == POP_CS_EVENT_FADE_IN || ev == POP_CS_EVENT_FADE_OUT) {
/* Fade — 2,13 с, и на стыке экранов их два. Пока он был глухим,
* эти секунды не пропускались ничем: пропуск работал внутри
* сценария, но не между его шагами. Прерывание здесь означает
* то же, что и в самом сценарии — уйти из заставки. */
uint8_t cut = (ev == POP_CS_EVENT_FADE_IN)
? pop_pal_fade_in_skip(INTRO_FADE)
: pop_pal_fade_out_skip(INTRO_FADE);
if (cut) { pop_cutscene_abort(&cs); aborted = 1; break; }
}
else if (ev == POP_CS_EVENT_FLASH) intro_flash(arg);
pop_music_service(); /* подкачка потокового трека (финал) */
gfx_wait_vsync();
}
return aborted;
}
/* Ожидание кадров у шагов, которые показываются НЕ сценарием, а напрямую.
* Оно не глушит поток: победная тема играет кольцом страниц, и дочитывать
* её надо каждый кадр, иначе насос догонит загрузчика. Прерывается новым
* нажатием (детектор фронта); 1 — прервано. */
static uint8_t intro_wait_frames(uint16_t frames)
{
intro_skip_t skip;
intro_skip_begin(&skip);
while (frames--) {
if (intro_skip_requested(&skip)) return 1;
pop_music_service();
pop_wait_edge();
}
return 0;
}
/* Титры проявляются полосами, как и всё остальное в show_title()
* (draw_full_image(STORY_CREDITS) + transition_ltr, seg000:2055). */
static uint8_t intro_credits_ltr(void)
{
uint8_t page = pop_screen_begin();
if (intro_draw_page(INTRO_STORY_CREDITS, page) == 0 &&
pop_screen_present_ltr())
return 1;
return intro_wait_frames(POP_T60(0x168));
}
uint8_t pop_intro_show(void) __banked
{
int pv_result;
/* МУЗЫКА ПРИХОДИТ СЮДА ЗВУЧАЩЕЙ. На титрах играет тема (m54), и
* обрывать её нельзя: у оригинала заставка и история — одна звуковая
* дорожка, где следующая реплика начинается там, где кончилась
* предыдущая. Поэтому здесь ни pop_sfx_pause, ни pop_sfx_start:
* CBL уже открыт титрами. */
if (intro_story_prepare() != 0) {
/* Ошибка файла не должна оставить старый title без выхода: менее
* эффектный, но корректный fallback остаётся обычным fade-in. */
pop_music_free();
pop_sfx_pause();
intro_prepare_palette(INTRO_PAL_STORY);
if (intro_draw_new_palette(INTRO_STORY_ABSENCE) == 0)
(void)pop_pal_fade_in_skip(INTRO_FADE);
} else {
/* Картинка собрана и ждёт в скрытой странице; читаем следующую
* реплику во ВТОРОЙ слот — тема титров при этом продолжает
* звучать из первого. */
(void)pop_music_load(POP_MUS_STORY_1);
/* Тему дослушиваем до конца, как seg000:2027, и только потом
* пускаем историю: пуск и полосовой переход идут встык. */
{
intro_skip_t head;
intro_skip_begin(&head);
while (pop_music_busy() && !intro_skip_requested(&head))
pop_wait_edge();
}
(void)pop_sfx_start(); /* идемпотентно: титры его уже открыли */
pop_music_play();
/* Прервали проявление — это не «показать картинку разом», а «хватит
* заставки»: маршрут тот же, что и у пропуска внутри сценария. */
if (pop_screen_present_ltr()) {
pop_music_free();
pop_sfx_pause();
return 1;
}
}
if (intro_run(intro_before_pv)) {
/* Пропуск по клавише. ГЛУШИМ ВЫВОД: сразу за возвратом пойдёт
* загрузка уровня, а при открытом CBL ESTEX уходит в диск дольше,
* чем играет буфер, и железо начинает крутить свои 256 байт по
* кругу — на слух ровно тот скрежет, что ловится нажатием клавиши
* на первом экране истории. */
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
return 1;
}
/* ДОИГРАТЬ ПОД ЧЁРНЫМ ЭКРАНОМ. Оригинал не обрывает story-трек на
* переходе к PV: fade уводит картинку, а музыка продолжается, и лишь
* когда она смолкла, начинается сцена с принцессой (у SDLPoP это
* общий приём — `while (check_sound_playing())`, seg000:2027). */
{
intro_skip_t tail;
intro_skip_begin(&tail);
while (pop_music_busy() && !intro_skip_requested(&tail)) pop_wait_edge();
}
/* Первая реплика PV читается ЗДЕСЬ, под чёрным экраном и в тишине:
* дальше внутри сцены остаётся только пуск, а следующие две реплики
* подкачиваются по странице за кадр. */
/* Глушение снято 2026-08-28: массовое чтение страниц насос не морит
* (замер на загрузке уровня — ни одной пропущенной порции), а трек
* титров может и должен доигрывать. */
(void)pop_music_load(POP_MUS_STORY_2);
pv_result = intro_pv_animated();
if (pv_result > 0) {
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
return 1;
}
if (pv_result < 0 && intro_run(intro_pv_fallback)) {
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
return 1;
}
/* «Свадьба» показывается ПОВЕРХ звучащей реплики Джафара: она началась
* в PV и переживает и fade-out сцены, и fade-in этого экрана. */
if (intro_run(intro_marry)) {
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
return 1;
}
{
intro_skip_t marry;
intro_skip_begin(&marry);
while (pop_music_busy() && !intro_skip_requested(&marry))
pop_wait_edge();
}
pop_music_free();
pop_sfx_pause();
/* ТИТУЛЬНЫЙ ЭКРАН МЕЖДУ «СВАДЬБОЙ» И ТИТРАМИ. show_title() рисует
* TITLE_MAIN + логотип + Jordan Mechner и проявляет их полосами
* (seg000:2045), и только через 0x78 тиков показывает credits — тоже
* полосами. Раньше обе картинки возникали мгновенно. */
{
int shown = pop_title_logo_ltr(); /* 1 — прервано, -1 — нет архива */
if (shown == 1 || (shown == 0 && intro_wait_frames(POP_T60(0x78)))) {
intro_restore_game_palette();
return 1;
}
}
if (intro_credits_ltr()) {
intro_restore_game_palette();
return 1;
}
/* ТАБЛИЦА РЕКОРДОВ между credits и attract-demo — ровно там же, где её
* показывает show_title(): та же story-палитра, тот же полосовой
* переход, никакого fade (seg000:2060). Пустой таблицы это не
* касается: она просто не показывается. */
if (pop_hof_show_title()) {
intro_restore_game_palette();
return 1;
}
if (intro_run(intro_tail)) {
intro_restore_game_palette();
return 1;
}
/* Abort возможен в любой WAIT и не оставляет загруженных атласов: каждая
* полоса освобождена внутри intro_draw. */
intro_restore_game_palette();
return 0;
}
/* Хвост любой сцены перед уровнем.
*
* ДОСЛУШИВАЕМ ПОД ЧЁРНЫМ ЭКРАНОМ, а не «пусть играет в игре». Оригинал
* так и делает: следующий load_intro начинается с ожидания тишины
* (seg001:681), и к отрисовке уровня трек уже смолк. Мы сначала отдали
* музыку в игру — и она там спотыкалась: загрузка уровня (ESTEX + сборка
* комнаты) не даёт насосу долить блок вовремя. Ждать здесь дешевле, чем
* слушать заикание.
*
* Пропуск сцены обрывает и музыку: игрок нажал клавишу, чтобы идти
* дальше, а не чтобы досидеть до конца трека. */
static void pre_cut_finish(uint8_t aborted)
{
/* ТРЕК ПЕРЕЖИВАЕТ СЦЕНУ — как в оригинале. load_intro (seg001:690)
* ничего не ждёт: гасит экран и возвращает управление, а звук играет
* дальше поверх загрузки уровня и первых секунд игры. Мы вместо этого
* ДОСИЖИВАЛИ трек на чёрном экране (27 — 10,7 с против 2,6 с картинки),
* то есть дарили игроку восемь секунд пустого экрана, и только потом
* глушили. ЗАМЕР 2026-08-28: снято вместе с глушением на загрузке
* уровня; если загрузка не укладывается в дедлайн насоса (251 000
* тактов), вернуть оба места разом.
*
* Пропуск сцены по-прежнему обрывает и музыку: игрок нажал клавишу,
* чтобы идти дальше, а не чтобы дослушать. */
if (aborted) {
pop_music_free();
pop_sfx_pause();
}
intro_restore_game_palette();
}
void pop_pre_cutscene_show(pop_pre_cutscene_t scene) __banked
{
const pop_cs_cmd_t *script = 0;
/* Глушение перед чтением трека снято 2026-08-28 (см. замер насоса). */
/* МУЗЫКА ПЕРЕЖИВАЕТ СЦЕНУ. Треки заставок между уровнями длиннее самих
* сцен (27 — 10,7 с против 2,6 с картинки), и оригинал их не обрывает:
* load_intro просто гасит экран и возвращает управление, а звук
* продолжает играть уже поверх загрузки уровня и первых секунд игры
* (seg001:690). Мы глушили CBL на выходе — музыка обрывалась на
* полуслове. Теперь не глушим: доиграет сама, а следующая загрузка
* трека всё равно освободит слот.
*
* Какой именно трек — как в оригинале: 27 для 2/4/6/12 (seg001:401),
* 35 для 8/9 (seg001:298), 40 для короткой «времени мало» (seg001:388).
* Экран в этот момент чёрный, поэтому читаем трек целиком: пауза на
* диск здесь никому не мешает. */
{
uint8_t track = 27;
if (scene == POP_PRE_8 || scene == POP_PRE_9) track = 35;
else if (scene == POP_PRE_12_SHORT) track = 40;
if (pop_music_load(track) == 0) {
(void)pop_sfx_start();
pop_music_play();
}
}
if (scene == POP_PRE_2_6 || scene == POP_PRE_4 || scene == POP_PRE_12) {
int r = pre_room_animated((uint8_t)(scene == POP_PRE_4 ? INTRO_PV_LYING
: INTRO_PV_STAND),
pre_hourglass_state(), 26);
if (r >= 0) {
pre_cut_finish((uint8_t)(r > 0));
return;
}
/* Неполный HDD: статический fallback сохраняет корректные 130 тиков. */
}
if (scene == POP_PRE_12_SHORT && cut_scene_12_short() == 0) {
pre_cut_finish(cut_aborted);
return;
}
/* Сцены 8/9 — живая анимация мыши и принцессы (cutscene_8/cutscene_9). */
if (scene == POP_PRE_8 || scene == POP_PRE_9) {
int done = (scene == POP_PRE_8) ? cut_scene_8() : cut_scene_9();
if (done == 0) {
pre_cut_finish(cut_aborted);
return;
}
}
if (scene == POP_PRE_2_6 || scene == POP_PRE_12) script = pre_2_6;
else if (scene == POP_PRE_4) script = pre_4;
else if (scene == POP_PRE_8) script = pre_8;
else if (scene == POP_PRE_9) script = pre_9;
else if (scene == POP_PRE_12_SHORT) script = pre_12_short;
if (!script) {
pre_cut_finish(1);
return;
}
pre_cut_finish(intro_run(script));
}
/* Фон таблицы рекордов — та же рамка story плюс логотип PRINCE OF PERSIA
* (STORY_FRAME + HOF_POP оригинала). Рисуем в ЗАДАННУЮ страницу и её не
* показываем: палитрой, строками и переходом распоряжается pop_hof. */
int pop_intro_hof_bg(uint8_t page) __banked
{
return intro_draw_page(INTRO_STORY_HOF, page);
}
void pop_time_expired_show(void) __banked
{
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* time_expired (seg001:04D3) начинается с play_sound(sound_36). */
if (pop_music_load(36) == 0) {
(void)pop_sfx_start();
pop_music_play();
}
/* ПРИНЦЕССЫ ЗДЕСЬ НЕТ. Оригинал не зовёт ни одного init_*: экран —
* это пустая комната и ОСТАНОВИВШИЕСЯ часы (state 7, струйки песка
* нет). Мы показывали готовую композицию с принцессой и полными
* часами — то есть ровно противоположное по смыслу.
* Длина — 2 + 100 кадров cutscene, как в оригинале. */
if (pre_room_animated(PV_ROOM_BARE, 7, 102) < 0)
intro_run(time_expired_script); /* нет ресурсов — старый путь */
/* Оригинал ждёт КОНЦА ТРЕКА уже под чёрным экраном (seg001:374). */
{
intro_skip_t tail;
intro_skip_begin(&tail);
while (pop_music_busy() && !intro_skip_requested(&tail))
pop_wait_edge();
}
pop_music_free();
pop_sfx_pause();
intro_restore_game_palette();
}
void pop_ending_show(void) __banked
{
/* Глушение снято 2026-08-28 (см. замер насоса). */
/* Экран гасим ПЕРВЫМ действием — как load_intro оригинала, который
* начинается с draw_rect(&screen_rect, color_0_black). Иначе загрузка
* ресурсов сцены идёт поверх ЖИВОЙ комнаты уровня 14, и она «мелькает»
* перед встречей. */
pop_pal_apply(POP_PAL_DIM_BLACK);
/* Встреча с принцессой — живая сцена (end_sequence_anim). Дальше по
* скрипту идёт только текст HAIL, поэтому статический сценарий ниже
* начинается с него; при нехватке ресурсов отыгрывается целиком. */
if (cut_ending() == 0) {
/* ПОБЕДНАЯ ТЕМА (56) — потоком с диска: 115 секунд и 1,2 МБ в память
* не влезают никак. Оригинал пускает её здесь же, сразу после
* встречи с принцессой и до экрана HAIL (seg001:583). Дальше
* страницы дочитывает pop_music_service из циклов сцен. */
(void)pop_sfx_start();
(void)pop_music_ring_start(POP_MUS_WON);
intro_run(ending_hail_script);
} else {
intro_run(ending_script);
}
/* ТИТУЛЬНАЯ КАРТИНКА ПОВЕРХ ТЕКСТА. end_sequence заводит таймер на 240
* тиков, проявляет TITLE_MAIN полосами и ждёт его (seg001:590). Экран
* при этом не гаснет: рамка HAIL просто переползает в рисунок дворца.
* Дальше — состояние HALL_OF_FAME; тема продолжает звучать, и
* освобождает её уже таблица рекордов, как в оригинале. */
if (pop_title_main_ltr() == 0)
(void)intro_wait_frames(POP_T60(240));
}