diff --git a/applications/SprPoP/src/pop_intro.c b/applications/SprPoP/src/pop_intro.c index f53822b..154a09b 100644 --- a/applications/SprPoP/src/pop_intro.c +++ b/applications/SprPoP/src/pop_intro.c @@ -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++; } diff --git a/applications/SprPoP/src/pop_kid.c b/applications/SprPoP/src/pop_kid.c index 90d3c6a..c5c5821 100644 --- a/applications/SprPoP/src/pop_kid.c +++ b/applications/SprPoP/src/pop_kid.c @@ -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 #include #include @@ -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; diff --git a/applications/SprPoP/src/pop_kid.h b/applications/SprPoP/src/pop_kid.h index 0578d87..110bed3 100644 --- a/applications/SprPoP/src/pop_kid.h +++ b/applications/SprPoP/src/pop_kid.h @@ -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 — ошибка. */ diff --git a/applications/SprPoP/src/pop_music.c b/applications/SprPoP/src/pop_music.c index b6848df..eb8747c 100644 --- a/applications/SprPoP/src/pop_music.c +++ b/applications/SprPoP/src/pop_music.c @@ -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); diff --git a/applications/SprPoP/src/sprpop.c b/applications/SprPoP/src/sprpop.c index 648f647..0c9ab0a 100644 --- a/applications/SprPoP/src/sprpop.c +++ b/applications/SprPoP/src/sprpop.c @@ -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++;