SprPoP: единые часы в сцене с Джафаром + конец уровня ждёт свою мелодию

1. СЦЕНА С ДЖАФАРОМ — ОДНИ ЧАСЫ, КАДРЫ ЛУЧА.  Было две шкалы, и выбор между
   ними делался гонкой на старте сцены: одна выборка pop_snd_tick через кадр,
   «успел ли диск раскрутить звук».  От прогона к прогону сцена шла то по
   тикам насоса CBL, то по кадрам луча, и кода реплики приходилась каждый раз
   на другое место картинки (наблюдение пользователя).  Насос был нужен
   потому, что кадр рисовался дольше своего интервала; теперь отрисовка
   разложена по интервалам (pv_restore_bg), и счёт кадров честен — ветка
   насоса убрана целиком.

2. ПОДКАЧКА ТРЕКА — ПОЛСТРАНИЦЫ ЗА ШАГ (pop_music_load_step).  8 КБ ≈ 16 мс
   влезают в кадровый интервал, целая страница (33 мс) не влезала и
   растягивала кадр сцены.  В сцене шаг остаётся безусловным (иначе реплики
   не успевали грузиться, memory pv_music_stall_regression) и оплачивается
   ровно одним интервалом.

3. КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ.  Оригинал (seg003:387, play_level_2) не
   сменяет уровень, пока `check_sound_playing()`: экран пройденного уровня
   живёт с анимацией факелов, пока звучит трек.  Мы уходили на смену сразу и
   обрывали мелодию на первых нотах.  Теперь ждём большего из двух:
   pop_music_busy() и счётчика pop_endmus_left по длине записи
   (gen/pop_music_ticks.h) — второе нужно потому, что при ВЫКЛЮЧЕННОЙ музыке
   busy ложен, а оригинал выдерживает паузу и молча.

Стартовый уровень возвращён на 1 (отладочный LEVEL=14 был только для замера).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 16:19:43 +03:00
parent 3449f6f8c9
commit f25ed37d85
5 changed files with 108 additions and 72 deletions
+43 -61
View File
@@ -795,12 +795,7 @@ static int intro_pv_animated(void)
uint16_t tick;
uint16_t frame_end;
uint16_t render_tick;
uint16_t beam = 0; /* запасной путь: кадров луча ждали */
uint16_t snd_done = 0; /* тиков насоса прошло с начала сцены */
uint16_t snd_want = 0; /* сколько их должно было пройти */
uint16_t snd_acc = 0; /* дробный остаток пересчёта 57/40 */
uint8_t snd_prev = 0;
uint8_t snd_pace = 0;
uint16_t beam = 0; /* кадров луча потрачено с начала сцены */
uint8_t period;
uint8_t anim_step = 0;
uint8_t torch_left = 1;
@@ -849,18 +844,22 @@ static int intro_pv_animated(void)
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
* бандами по 16 строк. */
(void)pop_sfx_start();
/* ЧЕМ МЕРИТЬ ВРЕМЯ. Пока сцена была немой, кадры просто считали
* ожидания vsync — и этого хватало. С музыкой выяснилось, что так
* считаются НАШИ ожидания, а не прошедшее время: полноэкранная копия
* страницы (~0,55 млн тактов) съедает больше кадра луча, и сцена
* незаметно отстаёт — в MAME молния била уже ПОСЛЕ конца реплики.
* Часы берём у насоса CBL: он идёт от расхода буфера железом, 85,4 Гц,
* и ему безразлично, чем занят главный цикл. Если звук выключен,
* часы стоят — тогда работает прежний путь по кадрам луча. */
snd_prev = pop_snd_tick;
gfx_wait_vsync();
snd_pace = (uint8_t)(pop_snd_tick != snd_prev); /* часы реально идут? */
snd_prev = pop_snd_tick;
/* ЧЕМ МЕРИТЬ ВРЕМЯ — ЕДИНЫЕ ЧАСЫ: КАДРЫ ЛУЧА.
*
* Была вторая шкала — тики насоса 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 */
@@ -928,59 +927,42 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
/* ГАРАНТИРОВАННЫЙ ШАГ ПОДКАЧКИ, а не «если останется время».
*
* Ниже страница следующей реплики читается в ПАУЗЕ кадра, и это
* Раньше страница следующей реплики читалась в ПАУЗЕ кадра, и это
* верно ровно до тех пор, пока пауза есть. В этой сцене её почти
* нет: кадр дорогой, шкала работает по принципу «долг не догоняем»
* (snd_want подтягивается к snd_done), и цикл ожидания выходит
* сразу. Замер в MAME 2026-08-26: ОДНА страница за семь секунд,
* то есть одиннадцать страниц m53 не успевали за всю сцену —
* pop_music_play() на своём тике не находил готового трека, и
* реплики Джафара пропадали совсем.
* не было: кадр дорогой, шкала работает по принципу «долг не
* догоняем», и цикл ожидания выходил сразу. Замер в MAME
* 2026-08-26: ОДНА страница за семь секунд, то есть одиннадцать
* страниц m53 не успевали за всю сцену — pop_music_play() на своём
* тике не находил готового трека, и реплики Джафара пропадали
* совсем (memory pv_music_stall_regression).
*
* Страница стоит 33 мс против кадра сцены в 100-133 мс, поэтому
* один безусловный шаг в кадр сцену не ломает: он лишь тратит
* часть той же паузы, а если паузы не было — добавляет к кадру
* четверть его длины, и шкала это учтёт сама. */
if (pop_music_loading()) (void)pop_music_load_step();
* Поэтому шаг безусловный — и оплачен как обычный блок кадра: он
* читает полстраницы (~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; }
}
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр cutscene — 6/8/7 тиков по
* 1/60 с; один тик = 85,4/60 = 1,424 тика насоса, и 57/40 = 1,425
* повторяет это с точностью 0,07 % (30 мс на всю сцену) без
* деления в кадре и без 32-битной арифметики. */
if (snd_pace) {
snd_acc += (uint16_t)period * 57u;
while (snd_acc >= 40u) { snd_acc -= 40u; snd_want++; }
for (;;) {
uint8_t now = pop_snd_tick;
/* Разность в uint8_t: счётчик переполняется каждые 3 с, а
* заглядываем мы в него не реже раза в кадр. */
snd_done += (uint8_t)(now - snd_prev);
snd_prev = now;
if (snd_done >= snd_want) break;
/* Страницу следующей реплики читаем ЗДЕСЬ, в паузе кадра:
* 33 мс диска укладываются в ожидание (кадр сцены — 133 мс),
* и подкачка не добавляется к длительности кадра, а
* растворяется в ней. */
if (pop_music_loading()) (void)pop_music_load_step();
else gfx_wait_vsync();
}
/* ДОЛГ НЕ ДОГОНЯЕМ. Подкачка следующей реплики (страница — 33 мс)
* и любой тяжёлый кадр оставляют snd_done впереди snd_want, и
* дальше цикл гнал кадры без единого ожидания: пламя факелов
* мелькало, а события соседних тиков (створка ворот и дверь
* покоев) слипались в один кадр — заявка перезаписывала заявку, и
* один из звуков пропадал. Тот же принцип, что у pop_pace_end:
* якорь ставится ПО ФАКТУ, пропущенное время не наверстывается. */
if (snd_done > snd_want) snd_want = snd_done;
} else {
/* ПЕЙСИНГ ПО ОРИГИНАЛЬНОЙ ШКАЛЕ. Кадр сцены — 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++; }
+21 -3
View File
@@ -18,6 +18,8 @@
#include "pop_map.h" /* pop_clip_char_top (clip_char) */
#include "pop_geom.h"
#include "pop_level.h" /* POP_LEVEL_LAST — level 15 исключён */
#include "pop_cutscene.h" /* POP_T60 — тики оригинала в кадры луча */
#include "pop_music_ticks.h" /* длина мелодии конца уровня */
#include <fcntl.h>
#include <unistd.h>
#include <sprinter_mem.h>
@@ -36,6 +38,14 @@ int8_t knock;
* оригинале, чтобы читы «следующий/предыдущий уровень» (levels_plan.md §4)
* легли сюда же без переделки. */
uint8_t pop_next_level;
/* СКОЛЬКО ЛОГИЧЕСКИХ КАДРОВ ЕЩЁ ДЕРЖАТЬ УРОВЕНЬ, пока играет мелодия конца.
* У оригинала (seg003:387, play_level_2) уровень не сменяется, пока
* `check_sound_playing()`: экран пройденного уровня живёт с анимацией
* факелов ровно столько, сколько звучит трек. У нас признак «звучит» —
* pop_music_busy(), но он ложен при ВЫКЛЮЧЕННОЙ музыке, а оригинал
* выдерживает паузу и молча. Поэтому здесь взводится счётчик по длине
* записи (gen/pop_music_ticks.h), а главный цикл ждёт большего из двух. */
uint8_t pop_endmus_left;
/* ---- Атласы Kid (прямая адресация image>>3) ------------------------ *
@@ -358,9 +368,17 @@ void play_seq(void)
else if (ev == 4) {
/* seg006:646. На четвёртом уровне уход за дверь — это
* встреча с тенью, и трек там свой; на 13-м (Джафар) и
* 15-м оригинал молчит. */
if (pop_current_level == 4) pop_sfx_play(32);
else if (pop_current_level != 13) pop_sfx_play(41);
* 15-м оригинал молчит. Вместе с треком взводим
* задержку смены уровня (см. pop_endmus_left): кадров
* = длина записи в тиках -> кадры луча -> делённое на
* длину логического кадра (4 кадра луча вне боя). */
if (pop_current_level == 4) {
pop_sfx_play(32);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_32) / 4);
} else if (pop_current_level != 13) {
pop_sfx_play(41);
pop_endmus_left = (uint8_t)(POP_T60(POP_MUS_TICKS_41) / 4);
}
}
}
break;
+2
View File
@@ -26,6 +26,8 @@ extern int8_t knock;
* пройден». Читает и обнуляет главный цикл. Пока уровень один, поэтому
* там рестарт того же уровня; загрузка следующего — levels_plan.md. */
extern uint8_t pop_next_level;
/* Задержка смены уровня на время мелодии конца (см. pop_kid.c). */
extern uint8_t pop_endmus_left;
/* Загрузить npages атласов Kid (kid0..kid{npages-1}.atl). ДО initgraph.
* 0 — OK, -1 — ошибка. */
+19 -5
View File
@@ -49,6 +49,7 @@ static uint8_t ld_busy;
static int ld_fd;
static uint8_t ld_slot;
static uint8_t ld_next; /* следующая страница к чтению */
static uint8_t ld_half; /* 0 = впереди первая половина страницы */
static uint8_t ld_pages;
static uint8_t ld_id;
@@ -110,13 +111,14 @@ void pop_music_free(void) __banked
* ПОСТРАНИЧНО, А НЕ ЦЕЛИКОМ. Реплики сцены с принцессой идут встык, и
* следующую надо читать, пока звучит текущая. Трек — до 320 КБ, целиком
* это 0,7 с диска: на 7,5 кадрах в секунду сцена встала бы на пять кадров.
* Одна страница стоит 33 мс — меньше четверти кадра, и подкачка растворя-
* ется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* Шаг стоит 16 мс (полстраницы, см. pop_music_load_step) — меньше одного
* кадрового интервала, и подкачка растворяется в анимации. Кто может позволить себе паузу (титры, чёрный экран
* между сценами), зовёт pop_music_load — тот же цикл, но целиком. */
static void load_abort(void)
{
if (ld_busy) close(ld_fd);
ld_busy = 0;
ld_half = 0;
slot_release(ld_slot);
}
@@ -146,23 +148,35 @@ int8_t pop_music_load_begin(uint8_t id) __banked
ld_busy = 1;
ld_slot = n;
ld_next = 0;
ld_half = 0;
ld_pages = pages;
ld_id = id;
return 0;
}
/* ШАГ = ПОЛСТРАНИЦЫ (8 КБ, ~16 мс), а не страница (~33 мс).
*
* Шаг вызывают раз в кадр из циклов, где кадр разложен на блоки по одному
* кадровому интервалу (20 мс): катсцены после перехода на блочную
* отрисовку, см. pop_intro.c/pv_restore_bg. Целая страница в интервал не
* влезала и незаметно растягивала кадр сцены; половина влезает, а цена —
* вдвое больше вызовов (11 страниц реплики = 22 кадра вместо 11, то есть
* меньше полусекунды). Игровому циклу это безразлично: там логический
* кадр 80-100 мс. */
int8_t pop_music_load_step(void) __banked
{
uint8_t p;
if (!ld_busy) return 1; /* нечего грузить */
p = mem_get_page(slot_blk[ld_slot], ld_next);
/* Последняя страница короче — читаем сколько есть; ошибку ловим по
* отрицательному коду, а не по «не 16384». */
if (!p || bank_read_page(ld_fd, p, 0, 16384) < 0) {
/* Последняя страница короче — читаем сколько есть (вторая половина
* может дать и ноль байт); ошибку ловим по отрицательному коду. */
if (!p || bank_read_page(ld_fd, p, ld_half ? 8192 : 0, 8192) < 0) {
load_abort();
return -1;
}
if (!ld_half) { ld_half = 1; return 0; } /* дочитаем в следующий раз */
ld_half = 0;
if (++ld_next < ld_pages) return 0;
close(ld_fd);
+23 -3
View File
@@ -357,6 +357,7 @@ int main(void)
* девелоперская сборка может до title держать дворец LEVEL=11. */
if (pop_app_state == POP_APP_DEMO) {
pop_next_level = 0;
pop_endmus_left = 0;
pop_seamless = 0;
pop_checkpoint = 0;
if (pop_level_switch() != 0) {
@@ -765,9 +766,28 @@ int main(void)
/* Смена уровня целиком уехала в банк 8 (pop_level_change): это
* событие раз в несколько минут, а кода на нём было под сотню строк
* в самом горячем месте программы — резидент W1 у нас в дефиците. */
if (!is_demo && pop_next_level != pop_current_level &&
pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
if (!is_demo && pop_next_level != pop_current_level) {
/* КОНЕЦ УРОВНЯ ЖДЁТ МЕЛОДИЮ (порт seg003:387, play_level_2):
*
* if (next_level == current_level || check_sound_playing())
* draw_game_frame(); ... // экран уровня живёт
* else { stop_sounds(); return next_level; }
*
* То есть пройденный уровень остаётся на экране — с анимацией
* факелов, идущим таймером и всем прочим — ровно столько,
* сколько звучит трек конца уровня; мы же уходили на смену
* уровня сразу и обрывали мелодию на первых нотах.
*
* «Звучит» проверяем двумя способами: pop_music_busy() пока
* трек играет и счётчик pop_endmus_left по длине записи (его
* взводит play_seq вместе с самим треком) — иначе с
* ВЫКЛЮЧЕННОЙ музыкой паузы не было бы вовсе, а оригинал
* выдерживает её и молча. */
if (pop_endmus_left) pop_endmus_left--;
if (!pop_endmus_left && !pop_music_busy() &&
pop_level_change(back) != 0) {
failed = 1; quit = 1; break;
}
}
if (!is_demo && pop_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++;