diff --git a/applications/SprPoP/assets/packed/SND/snd.arc b/applications/SprPoP/assets/packed/SND/snd.arc
index c44887f..a99ce44 100644
Binary files a/applications/SprPoP/assets/packed/SND/snd.arc and b/applications/SprPoP/assets/packed/SND/snd.arc differ
diff --git a/applications/SprPoP/assets/packed/SND/snd.idx b/applications/SprPoP/assets/packed/SND/snd.idx
index 3a24b52..81739f7 100644
Binary files a/applications/SprPoP/assets/packed/SND/snd.idx and b/applications/SprPoP/assets/packed/SND/snd.idx differ
diff --git a/applications/SprPoP/docs/BUGS_OPEN.md b/applications/SprPoP/docs/BUGS_OPEN.md
index 356e6bf..a17ae37 100644
--- a/applications/SprPoP/docs/BUGS_OPEN.md
+++ b/applications/SprPoP/docs/BUGS_OPEN.md
@@ -39,7 +39,7 @@
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
-| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | открыт: разбор есть (2026-08-31) |
+| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
---
@@ -1423,6 +1423,15 @@ Ctrl+A у нас — РЕСТАРТ УРОВНЯ (`sprpop_cold.c`, тот же
середину, там и остаётся. Полоса стража рисуется справа налево от 314 и
при большом запасе HP заходит именно в эту незачищаемую зону.
+**КОРЕНЬ (2026-08-31).** Счётчик считает СТРАНИЦЫ, но тратился по КАДРАМ,
+а это не одно и то же: между двумя вызовами отрисовки переворота может не
+быть, и оба прохода уходили в одну страницу — вторая оставалась с
+делениями прошлого боя. Фикс: проход тратится только когда
+`gfx_get_draw_page()` отличается от страницы прошлого прохода (первый
+проход идёт всегда). Полная чистка всей ширины при инвалидации добавлена
+там же — старая полоса могла заходить под статус-текст, где щадящая чистка
+её не трогала; текст сразу перезапрашивается, чтобы не пропал.
+
**Что проверить при взятии в работу.**
1. Значения `POP_STATUS_L`/`POP_STATUS_R` против реальной ширины полос:
diff --git a/applications/SprPoP/docs/TASKS_OPEN.md b/applications/SprPoP/docs/TASKS_OPEN.md
index eb99bc0..c4e2dbd 100644
--- a/applications/SprPoP/docs/TASKS_OPEN.md
+++ b/applications/SprPoP/docs/TASKS_OPEN.md
@@ -1009,7 +1009,7 @@ tp/10 у факелов таблицей, пустой слот соперник
## P1 — берётся в любой момент
-### TIME-SPEED. Часы бегут быстрее в FAST/FASTEST
+### TIME-SPEED. Часы бегут быстрее в FAST/FASTEST — СДЕЛАНО 2026-08-31, остался вариант 3 (RTC)
**Наблюдение (пользователь, 2026-08-31):** «при режиме FAST/FASTEST время
начинает бежать быстрее — похоже, время мы считаем в наших логических
@@ -1040,12 +1040,37 @@ tp/10 у факелов таблицей, пустой слот соперник
минутой в любом режиме, а в бою часы по-прежнему замедляются, как в
оригинале. Цена — счётчик-накопитель в `pop_timer_tick`.
-**Решать пользователю; по умолчанию — вариант 1** (режимы скорости
-отладочные, а вариант 2 разводит наш таймер с оригинальным на уровне
-механики).
+3. **Часы от RTC** (идея пользователя, 2026-08-31). Брать время из
+ часов реального времени, а не считать кадры вовсе.
+
+**Почему третий вариант интереснее, чем кажется.** Погрешность есть уже
+СЕЙЧАС и без всяких режимов: логический кадр NORMAL — 4 кадра луча, а это
+81,93 мс, то есть 12,2 кадра в секунду вместо ровных 12. Оригинальная
+минута из 720 тиков проходит у нас за 58,99 с — почти на секунду быстрее.
+За час игры набегает около минуты. Ни один из первых двух вариантов этого
+не лечит: они выравнивают режимы между собой, но обе шкалы остаются
+привязанными к лучу, а луч не кратен игровой секунде.
+
+RTC (`ESTEX $21 SYSTIME`, memory `sprinter_systime_dow`) даёт абсолютную
+шкалу и снимает накопление полностью. Подводные камни, которые надо
+решить при взятии в работу:
+
+- вызов ESTEX стоит дорого и клобберит регистры — читать раз в тик, не в
+ кадре, и не из горячего пути (memory `estex_bios_abi`);
+- разрешение RTC — секунда, а тик игры — 1/12 секунды: нужен гибрид
+ «кадры внутри секунды, синхронизация по RTC на границе», иначе часы
+ задёргаются;
+- пауза, меню и загрузка НЕ должны съедать игровое время — при часах от
+ RTC это перестаёт получаться само собой и требует явного вычитания;
+- быстрая загрузка/сохранение обязаны сохранять смещение, а не абсолютное
+ время.
+
+**Решать пользователю.** Порядок по цене: вариант 1 (ничего), вариант 2
+(счётчик кадров луча, лечит только разбег режимов), вариант 3 (RTC, лечит
+и накопление — но требует разобраться с паузами).
-### SND-SPEAKER-38. Звук мигания надписи: синтезировать ноты спикера в PCM
+### SND-SPEAKER-38. Звук мигания надписи — СДЕЛАНО и ПРОВЕРЕНО 2026-08-31
**Постановка (пользователь, 2026-08-31):** «когда надпись Press Button
начинает мигать — в SDLPoP воспроизводится звук на каждое моргание, у нас
diff --git a/applications/SprPoP/src/pop_cdraw.c b/applications/SprPoP/src/pop_cdraw.c
index 9f9ec6f..1f1ecc3 100644
--- a/applications/SprPoP/src/pop_cdraw.c
+++ b/applications/SprPoP/src/pop_cdraw.c
@@ -989,11 +989,24 @@ void pop_mirror_draw(int clip_top) __banked
* собирает неверно — SUB затирает A, и в s уезжает разность (memory
* sdcc_z80_cmp_store_a_bug). */
static uint8_t hp_todo; /* сколько страниц ещё обновить */
+/* Сколько страниц ещё стереть ЦЕЛИКОМ. Обычное обновление щадит зону
+ * статус-текста (иначе он мигал бы на каждом изменении жизней), но после
+ * рестарта уровня в этой зоне остаются деления ПРОШЛОГО боя: полоса стража
+ * при большом запасе HP заходит под текст, и щадящая чистка их не трогает.
+ * Симптом — «после гибели и Ctrl+A на одной из страниц осталась полоса по
+ * результатам боя» (BUGS_OPEN, HP-BAR-RESTART). */
+static uint8_t hp_wipe; /* сколько страниц ещё стереть */
static uint8_t hp_kid_prev, hp_kidmax_prev, hp_gd_prev, hp_gdmax_prev;
+/* Страница, в которую лёг прошлый проход. Счётчики выше считают СТРАНИЦЫ,
+ * но кадр и страница — не одно и то же: между двумя вызовами переворота
+ * может не быть, и тогда оба прохода уходили в ОДНУ страницу, а вторая
+ * оставалась с делениями прошлого боя. Ровно этим полоса и переживала
+ * Ctrl+A (BUGS_OPEN, HP-BAR-RESTART). */
+static uint8_t hp_page_prev = 0xFF;
/* Заставить перерисовать полосу на обеих страницах: звать при входе в
* комнату (фон перерисован целиком и стёр её) и при старте. */
-void pop_hp_invalidate(void) __banked { hp_todo = 2; }
+void pop_hp_invalidate(void) __banked { hp_todo = 2; hp_wipe = 2; }
void pop_hp_draw(void) __banked
{
@@ -1010,6 +1023,13 @@ void pop_hp_draw(void) __banked
if (changed) hp_todo = 2;
}
if (hp_todo == 0) return;
+ { /* Пока страница та же, что у прошлого прохода, — ждём переворота:
+ * иначе потратим оба прохода на одну страницу. Первый проход
+ * (hp_todo == 2) идёт всегда, ему сравнивать не с чем. */
+ uint8_t pg = gfx_get_draw_page();
+ if (hp_todo < 2 && pg == hp_page_prev) return;
+ hp_page_prev = pg;
+ }
hp_todo--;
/* Стереть прошлую полосу. Фон под ней — НЕ цвет 0, а POP_COL_OUTSIDE
@@ -1021,7 +1041,14 @@ void pop_hp_draw(void) __banked
* текст, его зону не трогаем — иначе он мигал бы при каждом изменении
* жизней. Деления Кида левее POP_STATUS_L, стража — правее POP_STATUS_R,
* так что чистить края по отдельности достаточно. */
- if (pop_status_ticks) {
+ if (hp_wipe) {
+ /* Полная чистка обеих страниц: старая полоса могла заходить под
+ * текст. Текст при этом стирается тоже, поэтому сразу просим
+ * перерисовать и его — иначе строка уровня пропала бы. */
+ hp_wipe--;
+ bar(0, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
+ if (pop_status_ticks) pop_status_invalidate();
+ } else if (pop_status_ticks) {
bar(0, HP_Y + POP_YOFF, POP_STATUS_L - 1, HP_Y + POP_YOFF + 6);
bar(POP_STATUS_R + 1, HP_Y + POP_YOFF, 319, HP_Y + POP_YOFF + 6);
} else {
diff --git a/applications/SprPoP/src/pop_pace.h b/applications/SprPoP/src/pop_pace.h
index be70a04..2daa201 100644
--- a/applications/SprPoP/src/pop_pace.h
+++ b/applications/SprPoP/src/pop_pace.h
@@ -71,6 +71,12 @@ extern uint8_t pop_speed_mode; /* POP_SPEED_*; дефолт — NORMAL */
/* Делитель для текущего режима. fight = «у Кида вынут меч». */
uint8_t pop_pace_n(uint8_t fight);
+/* ЭТАЛОН ХОДА ЧАСОВ — делитель РЕЖИМА NORMAL для того же признака боя.
+ * Игровое время меряется им, а не фактическим темпом: иначе FAST/FASTEST
+ * ускоряли бы и часы (минута проходила за две трети минуты). При NORMAL
+ * фактический делитель равен эталону, поэтому его ход не меняется вовсе —
+ * включая замедление в бою, которое есть и в оригинале. */
+#define POP_PACE_BASE(fight) ((uint8_t)((fight) ? 5 : 4))
/* Счётчик кадров. volatile: его правит pop_beam_sample, а читают циклы
* ожидания — перечитывать обязаны каждый оборот. */
diff --git a/applications/SprPoP/src/pop_status.c b/applications/SprPoP/src/pop_status.c
index 0a83aaf..55cd777 100644
--- a/applications/SprPoP/src/pop_status.c
+++ b/applications/SprPoP/src/pop_status.c
@@ -40,6 +40,7 @@
#include
#include
#include "pop_status.h"
+#include "pop_music.h" /* ждём конец музыки смерти и глушим её по ответу */
#include "pop_ui.h"
#include "pop_font.h"
#include "pop_bg.h" /* POP_YOFF */
@@ -461,14 +462,25 @@ static void dbg_scan(uint8_t room)
#define DEAD_BLINK 72 /* последние 72 тика строка мигает */
#define SND_BLINK 38 /* sound_38_blink на каждом появлении */
+/* Кадр, с которого пошёл отсчёт надписи. НЕ равен DEAD_SETTLE: оригинал
+ * не показывает «Press Button», пока звучит музыка смерти — ветка мёртвого
+ * (seg006:1351) на седьмом шаге просто выходит, если звук ещё играет, и
+ * надпись появляется только после него. Поэтому момент старта отсчёта
+ * заранее не известен и запоминается здесь. */
+static uint16_t dead_base;
static uint8_t dead_armed; /* всё отпущено — можно принимать нажатие */
uint8_t pop_dead_prompt(uint16_t frames) __banked
{
uint16_t rem;
- if (frames < DEAD_SETTLE) return 0;
- if (frames == DEAD_SETTLE) {
+ if (frames < DEAD_SETTLE) { dead_base = 0; return 0; }
+ if (dead_base == 0) {
+ /* Ждём, пока домолчит музыка смерти — порядок оригинала. Пока она
+ * играет, надписи нет и отсчёт 288 не идёт; прервать ожидание можно
+ * Ctrl+A или быстрой загрузкой, они музыку глушат. */
+ if (pop_music_active()) return 0;
+ dead_base = frames;
msg_set(POP_MSG_PRESS_BUTTON, 0, MSG_HOLD);
pop_show_time = 0; /* иначе поверх ляжет время (seg006:1365) */
dead_armed = 0;
@@ -477,7 +489,7 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
/* Свой отсчёт, а не pop_status_ticks: тот 8-битный, а здесь нужно 288.
* Кадры смерти считает вызывающий, так что хватает вычитания. */
- rem = (uint16_t)(frames - DEAD_SETTLE);
+ rem = (uint16_t)(frames - dead_base);
rem = (uint16_t)(rem >= DEAD_TICKS ? 0 : DEAD_TICKS - rem);
if (rem == 0) {
/* Игрок промолчал все 24 секунды — оригинал зовёт start_game(),
@@ -519,6 +531,8 @@ uint8_t pop_dead_prompt(uint16_t frames) __banked
if (!any) return 0;
}
dead_armed = 0;
+ dead_base = 0;
+ pop_music_stop(); /* игрок ответил — доигрывать не заставляем */
msg_set(POP_MSG_NONE, 0, 0);
return 1;
}
diff --git a/applications/SprPoP/src/pop_timer.c b/applications/SprPoP/src/pop_timer.c
index 58ed531..1890fea 100644
--- a/applications/SprPoP/src/pop_timer.c
+++ b/applications/SprPoP/src/pop_timer.c
@@ -14,16 +14,35 @@ uint8_t pop_timer_minutes;
uint16_t pop_timer_ticks;
uint8_t pop_show_time;
+/* Накопленные кадры луча, ещё не сложившиеся в тик. Живёт между кадрами:
+ * при FAST логический кадр короче эталона, и остаток переносится вперёд. */
+static uint8_t tick_acc;
+
void pop_timer_new_game(void) __banked
{
pop_timer_minutes = POP_TIMER_START_MINUTES;
pop_timer_ticks = POP_TIMER_START_TICKS;
pop_show_time = 0;
+ tick_acc = 0;
}
-uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked
+uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
+ uint8_t spent, uint8_t base) __banked
{
if (!enabled || !may_run || pop_timer_minutes == 0) return 0;
+ /* ХОД ЧАСОВ ОТВЯЗАН ОТ ТЕМПА. Тик стоит `base` кадров луча — столько,
+ * сколько их в кадре режима NORMAL. При NORMAL spent == base, и тик
+ * приходится ровно на кадр, как было всегда; в быстрых режимах кадр
+ * короче, остаток копится, и за то же РЕАЛЬНОЕ время выходит столько
+ * же тиков. Цикла не нужно: spent никогда не больше base (быстрые
+ * режимы кадр только УКОРАЧИВАЮТ) — значит не больше тика за вызов. */
+ /* Пауза, меню и загрузка сюда не заходят вовсе, поэтому за время их
+ * работы кадры луча накапливаются мимо нас. Ограничиваем вклад одного
+ * вызова: иначе после меню часы прыгнули бы вперёд на всю паузу. */
+ if (spent > (uint8_t)(base + base)) spent = base;
+ tick_acc = (uint8_t)(tick_acc + spent);
+ if (tick_acc < base) return 0;
+ tick_acc = (uint8_t)(tick_acc - base);
--pop_timer_ticks;
if (pop_timer_ticks != 0) {
diff --git a/applications/SprPoP/src/pop_timer.h b/applications/SprPoP/src/pop_timer.h
index b17c97b..72ce854 100644
--- a/applications/SprPoP/src/pop_timer.h
+++ b/applications/SprPoP/src/pop_timer.h
@@ -35,7 +35,11 @@ void pop_timer_new_game(void) __banked;
* may_run — правила текущей сцены/уровня. Возвращает 1 ОДИН раз, когда
* отсчёт дошёл до нуля; на паузе, HDD/QuickSave и при выключенном лимите
* вызывать можно — состояние останется неизменным. */
-uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run) __banked;
+/* spent — сколько кадров ЛУЧА стоил этот логический кадр (pop_pace_n),
+ * base — сколько их было бы при NORMAL (POP_PACE_BASE). Тик отсчитывается
+ * по base, поэтому режим скорости на ход часов не влияет. */
+uint8_t pop_timer_tick(uint8_t enabled, uint8_t may_run,
+ uint8_t spent, uint8_t base) __banked;
/* Читы, повторяющие +/- SDLPoP: минус не даёт искусственно поставить 0,
* плюс добавляет минуту. Они меняют счётчик и при выключенном лимите —
diff --git a/applications/SprPoP/src/sprpop.c b/applications/SprPoP/src/sprpop.c
index 4f09a00..a3216ab 100644
--- a/applications/SprPoP/src/sprpop.c
+++ b/applications/SprPoP/src/sprpop.c
@@ -240,6 +240,21 @@ static uint8_t item_pos, item_bake, flash_on;
* Через функцию с параметрами, а не пятью литералами в месте вызова: на
* gfx_pal_set(0,0,0,0,0) SDCC 4.5 выдаёт невалидный `ld hl, a`
* (см. build/obj/SprPoP.asm) и ассемблер падает. */
+/* Сколько кадров ЛУЧА прошло с прошлого игрового кадра. Часы считают
+ * именно их, а не ожидаемый делитель темпа: логический кадр не всегда
+ * укладывается в свой бюджет (дорогая сцена, загрузка звука), и часы,
+ * считавшие «по делителю», шли рывками — несколько секунд быстро, потом
+ * притормаживание (наблюдение пользователя 2026-08-31). Луч же идёт
+ * ровно, поэтому по нему время течёт равномерно. */
+static uint8_t timer_beam_delta(void)
+{
+ static uint8_t prev;
+ uint8_t now = pop_frame_tick;
+ uint8_t d = (uint8_t)(now - prev);
+ prev = now;
+ return d;
+}
+
static void flash_bg(uint8_t r, uint8_t g, uint8_t b)
{
gfx_pal_set(0, 0, r, g, b);
@@ -608,7 +623,11 @@ int main(void)
if (!is_demo && pop_timer_tick(pop_settings.time_limit_enabled,
(uint8_t)(Kid.alive < 0 &&
(pop_current_level < 13 ||
- (pop_current_level == 13 && pop_leveldoor_open == 0)))) &&
+ (pop_current_level == 13 && pop_leveldoor_open == 0))),
+ /* ХОД ЧАСОВ — по эталону NORMAL, а не по фактическому
+ * темпу: иначе FAST/FASTEST ускоряли бы и время. */
+ timer_beam_delta(),
+ POP_PACE_BASE(Kid.sword == SWORD_2_DRAWN)) &&
pop_current_level < 13) {
/* Время ИДЁТ и на уровне Джафара (до открытия двери), но
* КОНЧИТЬСЯ игра там уже не может: expired() у оригинала стоит
diff --git a/applications/SprPoP/src/sprpop_cold.c b/applications/SprPoP/src/sprpop_cold.c
index 422d69f..5971e3f 100644
--- a/applications/SprPoP/src/sprpop_cold.c
+++ b/applications/SprPoP/src/sprpop_cold.c
@@ -115,10 +115,10 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
mrc = pop_menu_process();
}
if (mrc == POP_MENU_QUICKSAVE) pop_qsave_request_save();
- else if (mrc == POP_MENU_QUICKLOAD) pop_qsave_request_load();
+ else if (mrc == POP_MENU_QUICKLOAD) { pop_music_stop(); pop_qsave_request_load(); }
else if (mrc == POP_MENU_QUICKLOAD_BACKUP)
- pop_qsave_request_load_backup();
- else if (mrc == POP_MENU_RESTART_LEVEL) *restart_level = 1;
+ { pop_music_stop(); pop_qsave_request_load_backup(); }
+ else if (mrc == POP_MENU_RESTART_LEVEL) { pop_music_stop(); *restart_level = 1; }
else if (mrc == POP_MENU_RESTART_GAME)
return (int8_t)(pop_app_dispatch(POP_APP_EV_RESTART_INTRO) ? 1 : -1);
else if (mrc == POP_MENU_QUIT)
@@ -137,10 +137,15 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
if (ctrl && kbd_raw_down(KBD_QUIT))
return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
/* Ctrl+A — рестарт уровня: тот же путь, что и пунктом меню. */
+ /* БЫСТРЫЙ ВЫХОД ИЗ СМЕРТИ. Надпись «Press Button» у оригинала ждёт,
+ * пока домолчит музыка смерти (seg006:1351), — так же и у нас. Чтобы
+ * ожидание не было принудительным, три быстрых пути (Ctrl+A, обе
+ * быстрые загрузки и пункты меню) музыку ГЛУШАТ: оригинал при Ctrl+A
+ * делает то же самое (seg000:0617, stop_sounds). */
{
uint8_t r = (uint8_t)(ctrl && kbd_raw_down(KBD_RESTART));
- if (r && !restart_prev) *restart_level = 1;
+ if (r && !restart_prev) { pop_music_stop(); *restart_level = 1; }
restart_prev = r;
}
/* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game»
@@ -181,7 +186,7 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
if (s && !qs_save_prev) pop_qsave_request_save();
- if (l && !qs_load_prev) pop_qsave_request_load();
+ if (l && !qs_load_prev) { pop_music_stop(); pop_qsave_request_load(); }
qs_save_prev = s; qs_load_prev = l;
if (pop_qsave_process() > 0) *dead_reset = 1;
}
diff --git a/applications/SprPoP/tests/host/t_timer.c b/applications/SprPoP/tests/host/t_timer.c
index b28ad27..cd9e904 100644
--- a/applications/SprPoP/tests/host/t_timer.c
+++ b/applications/SprPoP/tests/host/t_timer.c
@@ -35,17 +35,17 @@ TC_TEST(timer_starts_and_only_runs_when_allowed)
pop_timer_new_game();
TC_EQ(pop_timer_minutes, POP_TIMER_START_MINUTES);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
- TC_FALSE(pop_timer_tick(0, 1)); /* Settings: unlimited */
+ TC_FALSE(pop_timer_tick(0, 1, 4, 4)); /* Settings: unlimited */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
- TC_FALSE(pop_timer_tick(1, 0)); /* pause/HDD/cutscene */
+ TC_FALSE(pop_timer_tick(1, 0, 4, 4)); /* pause/HDD/cutscene */
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
pop_timer_minutes = 2;
pop_timer_ticks = 2;
- TC_FALSE(pop_timer_tick(1, 1));
+ TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 2);
TC_EQ(pop_timer_ticks, 1);
- TC_FALSE(pop_timer_tick(1, 1));
+ TC_FALSE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 1);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
}
@@ -54,11 +54,11 @@ TC_TEST(timer_expiry_is_one_shot)
{
pop_timer_minutes = 1;
pop_timer_ticks = 1;
- TC_TRUE(pop_timer_tick(1, 1));
+ TC_TRUE(pop_timer_tick(1, 1, 4, 4));
TC_EQ(pop_timer_minutes, 0);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
- TC_FALSE(pop_timer_tick(1, 1));
- TC_FALSE(pop_timer_tick(0, 1));
+ TC_FALSE(pop_timer_tick(1, 1, 4, 4));
+ TC_FALSE(pop_timer_tick(0, 1, 4, 4));
}
TC_TEST(timer_cheats_match_sdlpop_rules)
@@ -107,10 +107,63 @@ TC_TEST(timer_qsave_restores_exact_state_and_rejects_bad_tick)
TC_EQ(pop_timer_ticks, 444);
}
+/* --- ХОД ЧАСОВ НЕ ЗАВИСИТ ОТ РЕЖИМА СКОРОСТИ ------------------------- *
+ *
+ * Тик стоит `base` кадров луча — столько, сколько их в кадре режима
+ * NORMAL. Быстрые режимы только УКОРАЧИВАЮТ кадр (spent < base), поэтому
+ * за одно и то же реальное время выходит одинаковое число тиков.
+ * Наблюдение пользователя, из которого выросла правка: «при FAST/FASTEST
+ * время начинает бежать быстрее». */
+
+/* Сколько тиков насчитается за `frames` логических кадров темпа spent/base. */
+static uint16_t ticks_over(uint8_t frames, uint8_t spent, uint8_t base)
+{
+ uint16_t before;
+ uint8_t i;
+ pop_timer_new_game();
+ before = pop_timer_ticks;
+ for (i = 0; i < frames; i++) pop_timer_tick(1, 1, spent, base);
+ return (uint16_t)(before - pop_timer_ticks);
+}
+
+TC_TEST(timer_normal_ticks_every_frame)
+{
+ /* NORMAL вне боя: кадр равен эталону — тик на каждом кадре, как было до
+ * правки. Страховка, что NORMAL не изменился ни на тик. */
+ TC_EQ(ticks_over(12, 4, 4), 12);
+}
+
+TC_TEST(timer_normal_fight_ticks_every_frame)
+{
+ /* NORMAL в бою: кадр длиннее (5), но и эталон 5 — часы по-прежнему идут
+ * по кадру. В РЕАЛЬНОМ времени это медленнее, и ровно так же ведёт себя
+ * оригинал, поэтому трогать нечего. */
+ TC_EQ(ticks_over(12, 5, 5), 12);
+}
+
+TC_TEST(timer_fast_keeps_real_time)
+{
+ /* FAST вне боя: кадр 3 при эталоне 4. За 12 кадров (36 кадров луча) —
+ * 9 тиков, ровно столько же, сколько NORMAL насчитает за те же 36 кадров
+ * луча. До правки было бы 12: часы бежали на треть быстрее. */
+ TC_EQ(ticks_over(12, 3, 4), 9);
+}
+
+TC_TEST(timer_fastest_fight_keeps_real_time)
+{
+ /* FASTEST в бою — самый короткий кадр против самого длинного эталона: 3 против
+ * 5. За 20 кадров ждём 12 тиков. */
+ TC_EQ(ticks_over(20, 3, 5), 12);
+}
+
void main(void)
{
TC_RUN(timer_starts_and_only_runs_when_allowed);
TC_RUN(timer_expiry_is_one_shot);
TC_RUN(timer_cheats_match_sdlpop_rules);
TC_RUN(timer_qsave_restores_exact_state_and_rejects_bad_tick);
+ TC_RUN(timer_normal_ticks_every_frame);
+ TC_RUN(timer_normal_fight_ticks_every_frame);
+ TC_RUN(timer_fast_keeps_real_time);
+ TC_RUN(timer_fastest_fight_keeps_real_time);
}
diff --git a/applications/SprPoP/tools/paths.py b/applications/SprPoP/tools/paths.py
index 0638cf1..b040b18 100644
--- a/applications/SprPoP/tools/paths.py
+++ b/applications/SprPoP/tools/paths.py
@@ -66,6 +66,35 @@ SND_SOURCES = {
}
+# Ноты PC-СПИКЕРА и МЕЛОДИИ — два других набора того же движка. Нужны нам
+# по разным причинам: мелодии мы играем отдельно (MUS/), а из нот спикера
+# добираем те номера, которых нет НИ в оцифровке, НИ среди мелодий — сейчас
+# это ровно звук 38 (мигание надписи «Press Button»). В поставке SDLPoP
+# наборы лежат распакованными каталогами res1XXXX.bin, в дистрибутиве DOS —
+# контейнерами .DAT; поддерживаем оба вида.
+SND_MIDI = {
+ "sdlpop": (SDLPOP_DATA, "MIDISND%d.DAT", 2),
+ "msdos": (MSDOS, "midisnd%d.dat", 2),
+}
+SND_SPEAKER = {
+ "sdlpop": (SDLPOP_DATA, "IBM_SND%d", 2),
+ "msdos": (MSDOS, "ibm_snd%d.dat", 2),
+}
+
+
+def snd_extra(kind, name="sdlpop"):
+ """(каталог, шаблон, сколько контейнеров) для набора нот или мелодий.
+
+ kind — "midi" или "speaker". Набора может не быть вовсе (у пользователя
+ нет MSDOS, поставка неполная) — это НЕ ошибка: вызывающий просто
+ пропускает добор. Возвращает None, если источник неизвестен.
+ """
+ table = {"midi": SND_MIDI, "speaker": SND_SPEAKER}.get(kind)
+ if table is None:
+ raise SystemExit("неизвестный набор %r; есть: midi, speaker" % kind)
+ return table.get(name)
+
+
def snd_src(name="sdlpop"):
"""(каталог, шаблон имени) контейнеров оцифровки для источника."""
if name not in SND_SOURCES:
diff --git a/applications/SprPoP/tools/pop_pack_sound.py b/applications/SprPoP/tools/pop_pack_sound.py
index 231470f..c91a1fa 100644
--- a/applications/SprPoP/tools/pop_pack_sound.py
+++ b/applications/SprPoP/tools/pop_pack_sound.py
@@ -108,6 +108,69 @@ def resample(src, src_rate, dst_rate):
return bytes(out)
+def load_bundle(dat_dir, pattern, count):
+ """id -> тело ресурса из набора любого вида.
+
+ Наборы приезжают в двух видах: контейнером `.DAT` (дистрибутив DOS) и
+ распакованным каталогом `res1XXXX.bin` (поставка SDLPoP). Тело в обоих
+ случаях начинается с байта типа, так что дальше разбор общий.
+ """
+ out = {}
+ for i in range(1, count + 1):
+ p = dat_dir / (pattern % i)
+ if p.is_dir():
+ for f in sorted(p.glob("res1*.bin")):
+ try:
+ rid = int(f.stem[3:]) - 10000
+ except ValueError:
+ continue
+ out[rid] = f.read_bytes()
+ elif p.is_file():
+ out.update(dat_resources(p))
+ return out
+
+
+def speaker(body):
+ """(темп, [(частота Гц, длина в долях), ...]) или None — если это не ноты.
+
+ Формат — канон Princed, «Internal PC Speaker»: заголовок 3 байта, из них
+ байт 1 — доли на ДВЕ секунды; дальше тройки «частота (2 байта) + длина
+ (1 байт)»; нулевая частота = пауза, 1 и 2 — маркеры; в конце маркер
+ `12 00`, который сам отсекается недобором до целой тройки.
+ """
+ if not body or (body[0] & 7) != 0 or len(body) < 6:
+ return None
+ bps = body[1] or 1
+ notes = []
+ i = 3
+ while i + 3 <= len(body):
+ notes.append((struct.unpack(" те же 8 бит без знака, что и оцифровка.
+
+ Амплитуда НАМЕРЕННО маленькая: сигналы спикера короткие и резкие, и на
+ полном размахе они колют ухо рядом с оцифровкой, которая заметно тише.
+ Значение снижено вдвое по слуховой проверке в MAME (решение пользователя
+ 2026-08-31): на 44 сигнал мигания перекрикивал игру.
+ """
+ out = bytearray()
+ for freq, beats in notes:
+ n = int(round(beats * (2.0 / bps) * rate))
+ if n <= 0:
+ continue
+ if freq <= 2: # 0 — пауза, 1 и 2 — маркеры
+ out += bytes([0x80]) * n
+ continue
+ period = rate / freq
+ for i in range(n):
+ out.append(0x80 + amp if (i % period) < period / 2 else 0x80 - amp)
+ return bytes(out)
+
+
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("--source", default="sdlpop", choices=sorted(P.SND_SOURCES),
@@ -151,6 +214,33 @@ def main():
if rate != 11000:
print(f" звук {sid}: {rate} Гц -> {RATE:g} Гц, {len(samples)} -> {len(pcm)} сэмплов")
+ # ДОБОР ИЗ НОТ PC-СПИКЕРА (docs/TASKS_OPEN.md, SND-SPEAKER-38).
+ # У оригинала три параллельных набора звука, и номера разложены по ним
+ # не подряд: оцифровка, мелодии и ноты спикера. Берём из нот ТОЛЬКО те
+ # номера, которых нет ни там, ни там — иначе синтез перекрыл бы собой
+ # мелодии, которые мы играем отдельно, из MUS/. На поставке SDLPoP это
+ # ровно один номер: 38, сигнал под мигание надписи «Press Button»
+ # (31, 34 и 42 в наборе есть, но они пустые заглушки без нот).
+ midi_cfg = P.snd_extra("midi", args.source)
+ spk_cfg = P.snd_extra("speaker", args.source)
+ midi_ids = set(load_bundle(*midi_cfg)) if midi_cfg else set()
+ added = []
+ for sid, body in sorted((load_bundle(*spk_cfg) if spk_cfg else {}).items()):
+ if sid in sounds or sid in midi_ids:
+ continue
+ sp = speaker(body)
+ if sp is None or not sp[1]:
+ continue
+ pcm = speaker_pcm(sp[0], sp[1], RATE)
+ if not pcm:
+ continue
+ sounds[sid] = pcm
+ added.append("%d (%d нот, %d Б)" % (sid, len(sp[1]), len(pcm)))
+ if added:
+ print(" из нот спикера: " + ", ".join(added))
+ elif spk_cfg:
+ print(" из нот спикера: добирать нечего")
+
# Раскладка: начало каждого звука кратно BLOCK, страницы по PAGE.
# ПЕРВЫЙ БЛОК — ТИШИНА. Он нужен насосу: когда эффект кончился, в CBL
# всё равно надо что-то отдавать, иначе железо доигрывает по кругу хвост