diff --git a/applications/PoP/roomtest/TASKS_OPEN.md b/applications/PoP/roomtest/TASKS_OPEN.md
index bb74c28..3a6135f 100644
--- a/applications/PoP/roomtest/TASKS_OPEN.md
+++ b/applications/PoP/roomtest/TASKS_OPEN.md
@@ -1206,6 +1206,38 @@ N навык M» — отдельная надобность, если пона
Фазы З1..З5 и список «проверить артефактом до кодинга» — в доке.
+### MOB-SPR-EMM. Композит падающего куска — в EMM (1284 Б W2)
+
+`mob_spr` (`pop_room.c`) — самый крупный буфер в W2: 4 + 64*20 байт под
+композит падающей плиты. Это половина нынешнего дефицита W2.
+
+**В БАНК ЕГО ПЕРЕНЕСТИ НЕЛЬЗЯ** (разобрано 2026-08-26). Цепочка рисования
+— `pop_room` (банк 7) -> `pop_mem_b` (банк 5) -> `blit_b_clip` (резидент), и
+трамплин на `pop_mem_b` переключает W3. Указатель на данные банка 7 после
+этого показывает на страницу банка 5: блит нарисует мусор, причём молча.
+`--bank-data` вдобавок действует на весь модуль, а `mobs` из того же файла
+читают снаружи.
+
+**ЧТО РАБОТАЕТ — EMM-страница с маппингом в W0.** W0 не переключают ни
+трамплин (он трогает только W3), ни звуковой ISR (насос живёт в W3), значит
+адрес `0x0000+off` валиден и в банке 7, и в банке 5, и в резиденте — ровно
+как у атласных спрайтов, которые тем же блитом и рисуются. `blit_b_clip`
+окно не трогает, маппингом занимается вызывающий.
+
+**Цена.** `mob_spr_build` читает три части через `gfx_w0_map(a->page)`, а
+приёмник должен быть в том же окне — держать оба нельзя. Сборку придётся
+вести построчно: строка части в 64-байтовый буфер на стеке, перемаппить
+окно, записать. Вызывается это лишь на смену тайлсета. Плюс
+`gfx_w0_map`/`unmap` вокруг каждого куска — около 1350 тактов, при шести
+кусках меньше 2 % кадра.
+
+**Чем подтверждать.** Падающие плиты в комнате 1.2 и каскад уровня 13:
+ошибка проявится не крашем, а мусором на месте куска.
+
+**Дешёвая половинчатая мера** (если 1284 Б не нужны целиком): буфер объявлен
+с запасом 64x20, фактический композит около 58x19 — точные константы дают
+~180 байт без единого риска.
+
### PAL-ARC. Палитры — в архив своей группы (полишинг)
Файлов палитр в игре шесть: `KID\kid.pal` (1024 Б), `PV\story.pal`,
diff --git a/applications/PoP/roomtest/pop_config.c b/applications/PoP/roomtest/pop_config.c
index ba9d117..e5a2d3f 100644
--- a/applications/PoP/roomtest/pop_config.c
+++ b/applications/PoP/roomtest/pop_config.c
@@ -51,7 +51,7 @@ void pop_settings_defaults(void) __banked
pop_settings.show_build_info = 0;
pop_settings.enhancement_flags = 0;
pop_settings.time_limit_enabled = 1;
- pop_settings.show_debug_bar = 1; /* roomtest — dev-сборка */
+ pop_settings.show_debug_bar = 0; /* по умолчанию чистый экран */
}
/* Применить модель к настоящим владельцам runtime-состояния. Sound нельзя
diff --git a/applications/PoP/roomtest/pop_intro.c b/applications/PoP/roomtest/pop_intro.c
index 8c1c0de..6a565b6 100644
--- a/applications/PoP/roomtest/pop_intro.c
+++ b/applications/PoP/roomtest/pop_intro.c
@@ -721,6 +721,7 @@ static int intro_pv_animated(void)
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;
@@ -753,7 +754,9 @@ static int intro_pv_animated(void)
if (bad) return -1;
}
- pop_pal_fade_in(INTRO_FADE);
+ /* Проявление — ПОСЛЕ первого нарисованного кадра (см. ниже в цикле):
+ * иначе две секунды fade идут по пустой комнате, а принцесса возникает
+ * уже на свету. */
/* Актёрские атласы в EMM; дальше по сцене диск трогает только подкачка
* следующей реплики — по одной странице за кадр (pop_music_load_step).
* Полноэкранная accel-копия кадра насосу не мешает: leaf режет DI
@@ -838,6 +841,9 @@ static int intro_pv_animated(void)
torch_right, sand_frame, flash,
render_tick);
pop_sfx_tick(); /* как в оригинале — в конце отрисовки кадра */
+ /* Первый кадр собран под чёрной палитрой — теперь проявляем его
+ * целиком, вместе с принцессой и факелами. */
+ if (!faded_in) { faded_in = 1; pop_pal_fade_in(INTRO_FADE); }
pop_music_service(); /* заявки на музыку из сцены (эффект без оцифровки) */
/* Страницу следующей реплики читаем ЗДЕСЬ, до досыпания кадра:
* 33 мс диска укладываются в паузу логического кадра (133 мс) и
@@ -940,7 +946,6 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
}
pop_screen_present(1);
- pop_pal_fade_in(INTRO_FADE);
result = 0;
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
intro_skip_begin(&skip);
@@ -968,6 +973,9 @@ static int pre_room_animated(uint8_t picture, uint8_t state, uint8_t frames)
/* Первый тик уже потрачен на commit: ещё четыре = 5/50 с. */
for (hold = 1; hold < 5; hold++) gfx_wait_vsync();
+ /* Проявляем ПОСЛЕ первого кадра: иначе две секунды fade идут по
+ * пустой комнате, а часы и пламя возникают уже на свету. */
+ if (frame == 0) pop_pal_fade_in(INTRO_FADE);
torch_left = (uint8_t)((torch_left + 1) % 9);
torch_right = (uint8_t)((torch_right + 1) % 9);
sand = (uint8_t)((sand + 1) % 3);
@@ -1332,8 +1340,13 @@ static int cut_scene_8(void)
cut_kid_init(CHARID_24_MOUSE, 144, 167, 106);
/* princess_crouching (seg001:024D): сидит и гладит мышь. */
cut_shad_init(131, 169, 110);
- pop_pal_fade_in(INTRO_FADE);
- aborted = cut_run(20, 1);
+ /* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
+ * время на экране была бы пустая комната: персонажи появляются только с
+ * первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
+ * картинка проявляется целиком, вместе с актёрами. */
+ aborted = cut_run(1, 1);
+ if (!aborted) pop_pal_fade_in(INTRO_FADE);
+ if (!aborted) aborted = cut_run(19, 1);
if (!aborted) {
cut_kid_seq(107); /* мышь встаёт и уходит */
aborted = cut_run(20, 1);
@@ -1356,10 +1369,15 @@ static int cut_scene_9(void)
/* princess_stand (seg001:026A) — поза PV1, лицом вправо. */
cut_shad_init(144, 169, 94);
Guard.direction = 0;
- pop_pal_fade_in(INTRO_FADE);
+ /* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
+ * время на экране была бы пустая комната: персонажи появляются только с
+ * первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
+ * картинка проявляется целиком, вместе с актёрами. */
+ aborted = cut_run(1, 0); /* мышь ещё за кадром */
+ if (!aborted) pop_pal_fade_in(INTRO_FADE);
/* init_mouse_go (seg001:022A): мышь вбегает справа. */
cut_kid_init(CHARID_24_MOUSE, 199, 167, 105);
- aborted = cut_run(5, 1);
+ if (!aborted) aborted = cut_run(5, 1);
if (!aborted) {
cut_shad_seq(112); /* принцесса приседает */
aborted = cut_run(9, 1);
@@ -1383,8 +1401,13 @@ static int cut_scene_12_short(void)
if (cut_begin(pre_hourglass_state()) != 0) return -1;
/* init_princess_x156 (seg001:0230) = init_princess + x = 156. */
cut_shad_init(156, 166, 94);
- pop_pal_fade_in(INTRO_FADE);
- aborted = cut_run(2, 0);
+ /* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
+ * время на экране была бы пустая комната: персонажи появляются только с
+ * первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
+ * картинка проявляется целиком, вместе с актёрами. */
+ aborted = cut_run(1, 0);
+ if (!aborted) pop_pal_fade_in(INTRO_FADE);
+ if (!aborted) aborted = cut_run(1, 0);
if (!aborted) {
cut_shad_seq(98); /* princess turn around [PV1] */
aborted = cut_run(24, 0);
@@ -1406,9 +1429,14 @@ static int cut_ending(void)
/* 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);
- pop_pal_fade_in(INTRO_FADE);
+ /* ПЕРВЫЙ КАДР — ДО ПРОЯВЛЕНИЯ. fade_in занимает две секунды, и всё это
+ * время на экране была бы пустая комната: персонажи появляются только с
+ * первым кадром цикла. Рисуем его при ещё чёрной палитре — тогда
+ * картинка проявляется целиком, вместе с актёрами. */
+ aborted = cut_run(1, 1);
+ if (!aborted) pop_pal_fade_in(INTRO_FADE);
pop_sfx_play(26); /* «arrived to princess» */
- aborted = cut_run(8, 1);
+ if (!aborted) aborted = cut_run(7, 1);
if (!aborted) {
cut_shad_seq(108); /* поворот и объятие */
aborted = cut_run(5, 1);
diff --git a/applications/PoP/roomtest/pop_menu.c b/applications/PoP/roomtest/pop_menu.c
index 919182d..6753805 100644
--- a/applications/PoP/roomtest/pop_menu.c
+++ b/applications/PoP/roomtest/pop_menu.c
@@ -687,7 +687,11 @@ int8_t pop_menu_process(void) __banked
getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
pop_pal_snapshot();
- pop_pal_menu_dim(2); /* 50%, диапазон UI остаётся ярким */
+ /* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
+ * числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
+ * теперь затемнение на одну шестнадцатую — фон под меню почти не гас
+ * (поймано пользователем 2026-08-26). */
+ pop_pal_menu_dim(POP_PAL_DIM_BLACK / 2);
menu_draw(sel);
for (;;) {
diff --git a/applications/PoP/roomtest/roomtest.c b/applications/PoP/roomtest/roomtest.c
index 0e38647..ebecd64 100644
--- a/applications/PoP/roomtest/roomtest.c
+++ b/applications/PoP/roomtest/roomtest.c
@@ -128,11 +128,6 @@ static void kbd_idle(void)
#define SEQ_STAND 2
#define ROOM_1 1
#define LINK_DOWN 3 /* сторона links: 0=L,1=R,2=U,3=D */
-#define KBD_QUICKSAVE 0x0B /* F6, PS/2 set 2 */
-#define KBD_QUICKLOAD 0x01 /* F9, PS/2 set 2 */
-#define KBD_MENU 0x76 /* Esc — открыть pause menu (edge) */
-#define KBD_QUIT 0x09 /* F10 — отладочный немедленный выход (был на Esc,
- * пока меню не готово рисовать — см. план MS4) */
/* ОТЛАДОЧНЫЙ ОБХОД КОМНАТ: '+'/'-' (цифровой блок либо '='/'-' основного
@@ -417,63 +412,20 @@ int main(void)
* QuickSave/QuickLoad идут тем же механизмом, что F6/F9 (запрос
* подхватит блок ниже в этой же границе кадра), рестарты исполняются
* после вычисления back-страницы (как респавн), QUIT рвёт цикл. */
+ /* Меню, F10 и QuickSave/Load — целиком в банке 8 (pop_frame_ui).
+ * Всё это события «раз в партию», а кода на них было полсотни строк
+ * в резиденте; наружу остаются флаги, которые нужны самому циклу. */
if (!is_demo) {
- uint8_t e = kbd_raw_down(KBD_MENU);
- uint8_t menu_opened;
- int8_t mrc = POP_MENU_NONE;
- if (e && !esc_prev) pop_menu_request = 1;
- esc_prev = e;
- menu_opened = pop_menu_request;
- if (menu_opened) {
- if (!pop_app_dispatch(POP_APP_EV_PAUSE)) {
- failed = 1; quit = 1; break;
- }
- 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_BACKUP)
- pop_qsave_request_load_backup();
- else if (mrc == POP_MENU_RESTART_LEVEL) menu_restart = 1;
- else if (mrc == POP_MENU_RESTART_GAME) {
- if (!pop_app_dispatch(POP_APP_EV_RESTART_INTRO)) failed = 1;
+ uint8_t want_restart = 0, dead_reset = 0;
+ int8_t urc = pop_frame_ui(&want_restart, &dead_reset);
+
+ if (want_restart) menu_restart = 1;
+ if (dead_reset) dead_frames = 0;
+ if (urc) {
+ if (urc < 0) failed = 1;
+ if (urc != 1) quit = 1; /* 1 — restart game, без quit */
break;
}
- else if (mrc == POP_MENU_QUIT) {
- if (!pop_app_dispatch(POP_APP_EV_QUIT)) failed = 1;
- quit = 1;
- break;
- }
- if (menu_opened) {
- /* Меню переписало обе страницы целиком. Снимки слотов об
- * этом не знают и продолжают считать, что персонаж на
- * странице уже нарисован, — стоящий Кид оставался бы
- * невидимым до первого своего движения. */
- pop_cd_forget();
- pop_status_invalidate();
- if (!pop_app_dispatch(POP_APP_EV_RESUME)) {
- failed = 1; quit = 1; break;
- }
- }
- }
- /* F10 — отладочный немедленный выход (раньше был на Esc, но Esc
- * теперь открывает меню; уйдёт вместе с отладкой). */
- if (!is_demo && kbd_raw_down(KBD_QUIT)) {
- if (!pop_app_dispatch(POP_APP_EV_QUIT)) failed = 1;
- quit = 1;
- break;
- }
- /* F6/F9 только ставят запрос; файловая операция выполняется здесь,
- * на границе кадров — вне окон Char и до heal/tick/draw. */
- if (!is_demo) {
- uint8_t s = kbd_raw_down(KBD_QUICKSAVE);
- uint8_t l = kbd_raw_down(KBD_QUICKLOAD);
- int8_t qrc;
- if (s && !qs_save_prev) pop_qsave_request_save();
- if (l && !qs_load_prev) pop_qsave_request_load();
- qs_save_prev = s; qs_load_prev = l;
- qrc = pop_qsave_process();
- if (qrc > 0) dead_frames = 0;
}
if (frozen) {
PROF(0); /* чёрный: ждём vsync */
@@ -761,66 +713,12 @@ int main(void)
* или доиграли запись — показать текущий кадр и затем вернуться в
* title. В частности, не вызываем level_switch(0->1). */
if (is_demo && (kid_room == 24 || pop_kid_dead)) demo_finished = 1;
- if (!is_demo && pop_next_level != pop_current_level) {
- pop_pre_cutscene_t pre = pop_flow_pre_cutscene(pop_next_level,
- pop_timer_minutes);
- /* Смена уровня целиком — в банке (pop_level_switch): грузится
- * файл, спрайты соперника и тайлсет. До 2/4/6/8/9/12 таблица
- * flow запускает отдельное состояние CUTSCENE, как play_level()
- * SDLPoP: следующий уровень не трогаем до завершения/skip. */
- if (pre != POP_PRE_NONE) {
- if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_STARTED)) {
- failed = 1;
- quit = 1;
- break;
- }
- pop_pre_cutscene_show(pre);
- if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED)) {
- failed = 1;
- quit = 1;
- break;
- }
- /* Катсцена оставляет экран чёрным. Новый уровень будет
- * открыт одним применением его готовой палитры. */
- reveal_pending = 1;
- } else {
- /* CBL глушим ДО первой же операции с палитрой. Палитра идёт
- * через BIOS ($A4) с запрещёнными прерываниями: насос не
- * успевает долить блок, железо крутит хвост своего буфера —
- * на слух скрежет. Раньше pop_sfx_pause() звался только
- * внутри pop_level_switch, то есть УЖЕ ПОСЛЕ гашения, и
- * переход без катсцены (в т.ч. телепорт Shift+L) скрежетал
- * почти всегда — поймано пользователем 2026-08-25. Ветка с
- * катсценой свободна от этого: pop_pre_cutscene_show глушит
- * звук первым действием. */
- pop_sfx_pause();
- /* Без fade: старый кадр гасим сразу и обязательно даём
- * показать один чёрный растр ДО диска/перерисовки. */
- pop_pal_apply(4);
- pop_wait_edge();
- reveal_pending = 1;
- if (!pop_app_dispatch(POP_APP_EV_LEVEL_FINISHED)) {
- failed = 1;
- quit = 1;
- break;
- }
- }
- if (pop_level_switch() != 0 ||
- !pop_app_dispatch(POP_APP_EV_LEVEL_READY)) {
- failed = 1;
- quit = 1;
- break;
- }
- if (reveal_pending) {
- /* Обе страницы и snapshot уже принадлежат новому уровню:
- * включить его корректную палитру одним переходом. */
- pop_pal_apply(0);
- reveal_pending = 0;
- }
- /* level_switch оставляет CBL закрытым: palette/fade идут через
- * BIOS и при открытом буфере давали скрежет на первом кадре. */
- (void)pop_sfx_start();
- gfx_set_draw_page(back);
+ /* Смена уровня целиком уехала в банк 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_kid_dead) { /* demo после смерти завершается, без respawn */
dead_frames++;
diff --git a/applications/PoP/roomtest/roomtest_cold.c b/applications/PoP/roomtest/roomtest_cold.c
index b4cda1d..e48fbeb 100644
--- a/applications/PoP/roomtest/roomtest_cold.c
+++ b/applications/PoP/roomtest/roomtest_cold.c
@@ -30,6 +30,16 @@
#include "pop_pace.h" /* pop_speed_mode — режим скорости в отладочной метке */
#include "pop_settings.h" /* pop_settings_defaults — стартовый профиль VANILLA */
#include "pop_cfg.h" /* pop_cfg_load — POP.CFG поверх дефолтов */
+#include "pop_flow.h" /* таблица level-flow: какая сцена перед уровнем */
+#include "pop_intro.h" /* pop_pre_cutscene_show — сцена перед уровнем */
+#include "pop_app.h" /* pop_app_dispatch — состояния приложения */
+#include "pop_pal.h" /* pop_pal_apply — гашение перед загрузкой */
+#include "pop_timer.h" /* pop_timer_minutes — выбор сцены «времени мало» */
+#include "pop_ui.h" /* POP_PAL_DIM_BLACK */
+#include "pop_menu.h" /* пауза-меню: коды выбора */
+#include "pop_qsave.h" /* QuickSave/QuickLoad по F6/F9 */
+#include "pop_status.h" /* pop_status_invalidate после меню */
+#include
#include "pop_status.h" /* статус-строка и отладочная полоса — банк 9 */
#include "pop_menu.h" /* pop_menu_init/free — атлас UI в bank 9 */
#include "pop_ui.h" /* ранняя чёрная палитра + snapshot стартового экрана */
@@ -73,6 +83,134 @@ static void find_start_level_door(uint8_t room)
}
}
+
+
+/* МЕНЮ, F10 И QUICKSAVE/LOAD — граница кадра, но не горячий путь.
+ *
+ * Всё это события «раз в партию», а в главном цикле они занимали полсотни
+ * строк резидента W1. Здесь же держим и edge-состояния клавиш: снаружи они
+ * никому не нужны.
+ *
+ * restart_level — «пользователь выбрал Restart Level» (главный цикл сделает
+ * это сам, после вычисления back-страницы, как респавн);
+ * dead_reset — «QuickLoad удался, счётчик кадров смерти сбросить».
+ * Возврат: 0 — продолжаем кадр, 1 — выйти из игрового цикла (Restart Game),
+ * 2 — выйти и завершить работу (Quit), -1 — сбой диспетчера. */
+int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
+{
+ static uint8_t esc_prev, qs_save_prev, qs_load_prev;
+ uint8_t e = kbd_raw_down(KBD_MENU);
+ uint8_t menu_opened;
+ int8_t mrc = POP_MENU_NONE;
+
+ if (e && !esc_prev) pop_menu_request = 1;
+ esc_prev = e;
+ menu_opened = pop_menu_request;
+ if (menu_opened) {
+ if (!pop_app_dispatch(POP_APP_EV_PAUSE)) return -1;
+ 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_BACKUP)
+ pop_qsave_request_load_backup();
+ else if (mrc == POP_MENU_RESTART_LEVEL) *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)
+ return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
+ if (menu_opened) {
+ /* Меню переписало обе страницы целиком. Снимки слотов об этом не
+ * знают и продолжают считать, что персонаж на странице уже
+ * нарисован, — стоящий Кид оставался бы невидимым до первого своего
+ * движения. */
+ pop_cd_forget();
+ pop_status_invalidate();
+ if (!pop_app_dispatch(POP_APP_EV_RESUME)) return -1;
+ }
+ /* F10 — отладочный немедленный выход (раньше был на Esc, но Esc теперь
+ * открывает меню; уйдёт вместе с отладкой). */
+ if (kbd_raw_down(KBD_QUIT))
+ return (int8_t)(pop_app_dispatch(POP_APP_EV_QUIT) ? 2 : -1);
+ /* F6/F9 только ставят запрос; файловая операция выполняется здесь, на
+ * границе кадров — вне окон Char и до heal/tick/draw. */
+ {
+ uint8_t s = kbd_raw_down(KBD_QUICKSAVE);
+ 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();
+ qs_save_prev = s; qs_load_prev = l;
+ if (pop_qsave_process() > 0) *dead_reset = 1;
+ }
+ return 0;
+}
+
+/* СМЕНА УРОВНЯ — целиком здесь, а не в главном цикле.
+ *
+ * Событие редкое (раз в несколько минут игры), а кода на нём была без
+ * малого сотня строк — и все они лежали в _main, то есть в резиденте W1,
+ * где у нас счёт на десятки байт. Наружу остаётся одно условие и один
+ * вызов.
+ *
+ * back — страница, в которую главный цикл рисует кадр: после загрузки
+ * уровня её надо вернуть себе (level_switch показывает свою).
+ * Возврат: 0 — уровень сменён, -1 — сбой, главный цикл завершает работу. */
+int8_t pop_level_change(uint8_t back) __banked
+{
+ uint8_t reveal_pending = 0;
+ pop_pre_cutscene_t pre = pop_flow_pre_cutscene(pop_next_level,
+ pop_timer_minutes);
+
+ /* Загрузка файла уровня, спрайтов соперника и тайлсета — в
+ * pop_level_switch ниже. До 2/4/6/8/9/12 таблица
+ * flow запускает отдельное состояние CUTSCENE, как play_level()
+ * SDLPoP: следующий уровень не трогаем до завершения/skip. */
+ if (pre != POP_PRE_NONE) {
+ if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_STARTED))
+ return -1;
+ pop_pre_cutscene_show(pre);
+ if (!pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED))
+ return -1;
+ /* Катсцена оставляет экран чёрным. Новый уровень будет
+ * открыт одним применением его готовой палитры. */
+ reveal_pending = 1;
+ } else {
+ /* CBL глушим ДО первой же операции с палитрой. Палитра идёт
+ * через BIOS ($A4) с запрещёнными прерываниями: насос не
+ * успевает долить блок, железо крутит хвост своего буфера —
+ * на слух скрежет. Раньше pop_sfx_pause() звался только
+ * внутри pop_level_switch, то есть УЖЕ ПОСЛЕ гашения, и
+ * переход без катсцены (в т.ч. телепорт Shift+L) скрежетал
+ * почти всегда — поймано пользователем 2026-08-25. Ветка с
+ * катсценой свободна от этого: pop_pre_cutscene_show глушит
+ * звук первым действием. */
+ pop_sfx_pause();
+ /* Без fade: старый кадр гасим сразу и обязательно даём
+ * показать один чёрный растр ДО диска/перерисовки. */
+ pop_pal_apply(POP_PAL_DIM_BLACK);
+ pop_wait_edge();
+ reveal_pending = 1;
+ if (!pop_app_dispatch(POP_APP_EV_LEVEL_FINISHED))
+ return -1;
+ }
+ if (pop_level_switch() != 0 ||
+ !pop_app_dispatch(POP_APP_EV_LEVEL_READY))
+ return -1;
+ if (reveal_pending) {
+ /* Обе страницы и snapshot уже принадлежат новому уровню:
+ * включить его корректную палитру одним переходом. */
+ pop_pal_apply(0);
+ reveal_pending = 0;
+ }
+ /* level_switch оставляет CBL закрытым: palette/fade идут через
+ * BIOS и при открытом буфере давали скрежет на первом кадре. */
+ (void)pop_sfx_start();
+ gfx_set_draw_page(back);
+
+ return 0;
+}
+
/* start_level (seg003:0055) + do_startpos (seg003:01A3) + set_start_pos
* (seg003:028A).
*
@@ -937,7 +1075,7 @@ int pop_boot(void) __banked
* понадобится как источник обычного fade-in. Для build-info экран
* остаётся штатный яркий игровой фон. */
if (!pop_settings.show_build_info) {
- pop_pal_apply(4);
+ pop_pal_apply(POP_PAL_DIM_BLACK);
}
/* Стартовая комната до title НЕ НУЖНА: LEVEL_LOAD всё равно заново
@@ -1091,7 +1229,7 @@ int pop_new_game_load(void) __banked
/* Title мог завершиться на яркой title-палитре. Сначала погасить её,
* затем восстановить полный game snapshot; level_switch собирает обе
* страницы уже невидимо, и первый игровой кадр проявляется. */
- pop_pal_apply(4);
+ pop_pal_apply(POP_PAL_DIM_BLACK);
gfx_set_draw_page(0);
gfx_set_bank(GFX_BANK_NORMAL);
pop_screen_fill_game();
@@ -1099,7 +1237,7 @@ int pop_new_game_load(void) __banked
pop_screen_fill_game();
gfx_set_visible_page(1);
pop_pal_game_load();
- pop_pal_apply(4);
+ pop_pal_apply(POP_PAL_DIM_BLACK);
if (pop_level_switch() != 0) return -1;
/* Первый уровень появляется сразу, без fade, но только после того как
* обе страницы и его собственная палитра полностью готовы. */
diff --git a/applications/PoP/roomtest/roomtest_cold.h b/applications/PoP/roomtest/roomtest_cold.h
index 870e13e..d1fe99b 100644
--- a/applications/PoP/roomtest/roomtest_cold.h
+++ b/applications/PoP/roomtest/roomtest_cold.h
@@ -93,6 +93,23 @@ int pop_boot(void) __banked;
* файла нет — молча остаться на текущем. -1 — фатально. */
int pop_level_switch(void) __banked;
+/* Смена уровня целиком (катсцена, гашение, загрузка, палитра, звук).
+ * back — страница кадра главного цикла. 0 — ок, -1 — сбой. */
+int8_t pop_level_change(uint8_t back) __banked;
+
+/* Клавиши служебного слоя. Живут здесь, а не в roomtest.c: сам обработчик
+ * (pop_frame_ui) переехал в банк, а главный цикл о них больше не знает. */
+#define KBD_QUICKSAVE 0x0B /* F6, PS/2 set 2 */
+#define KBD_QUICKLOAD 0x01 /* F9, PS/2 set 2 */
+#define KBD_MENU 0x76 /* Esc — открыть pause menu (edge) */
+#define KBD_QUIT 0x09 /* F10 — отладочный немедленный выход (был на Esc,
+ * пока меню не готово рисовать — см. план MS4) */
+
+/* Меню, F10 и QuickSave/Load на границе кадра. restart_level/dead_reset —
+ * флаги для главного цикла. 0 — продолжаем, 1 — Restart Game, 2 — Quit,
+ * -1 — сбой. */
+int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked;
+
/* Чистый старт настоящей игры после Enter/Esc на title/intro/credits либо
* клавиши в attract-demo. Ждёт отпускания клавиши, сбрасывает состояние
* новой игры, загружает FIRST_LEVEL и проявляет его первый кадр. */