Files
Sprinter-SDCC/applications/SprPoP/src/pop_menu.c
T
snark13 4e43890fce SprPoP: финал больше не убивает программу — прямой вызов в чужой банк
Пройденная игра доходила до таблицы рекордов и умирала: программа
исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила
в reset.  Одинаково из Flex Navigator и из голого DSS.

КОРЕНЬ.  pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked.  Пока
pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после
переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в
пустой хвост чужого банка.  Процессор полз по 0xFF до 0x0000, где ловушка
DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3,
которую было видно на трупе, оказалась уборкой, а не причиной.

Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка
MAME на узком участке: trace включалась брейкпоинтом на входе в
pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56).

ЧТО СДЕЛАНО

* pop_ui.h/.c — группа text_*_mapped помечена __banked.
* toolchain/check_bank_calls.py — две проверки банкового кода:
  1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено
     намеренной поломкой);
  2) указатель на данные своего банка, отданный в чужой (эвристика по
     форме кода, только предупреждает).
  Встроена в app.mk, запускается сразу после линковки.
* pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10
  и после пометки __banked уезжал из-под ног чужому банку, заливая экран
  знаками вопроса.
* libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды
  нумпада в EXT-половину карты.  Лечит залипание стрелок (потерянный
  префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем),
  заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз.
* pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и
  gfx_wait_vsync: без этого F10 в геймплее не работал вовсе.
* pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с
  оригиналом записано в docs/impl_diff.md).
* Экран версии показывается только через Menu/Settings/About: стартовый
  показ и Ctrl+V убраны, мёртвый код снят.
* sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с
  выросшим за уровень максимумом полоса HP моргала между страницами.

Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md
(FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY),
правило про банки — в applications/SprPoP/CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:11:01 +03:00

763 lines
33 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_menu.c — pause menu, шрифты и временный UI-слой, БАНК 9.
*
* Отрисовка принципиально идёт GFX_BANK_SPRITE: прозрачный цвет 0xFF
* пропускается, а пауза не становится частью фоновой ОЗУ-копии игры.
* Каждый кадр UI собирается на скрытой
* видеостранице, флипается целиком и затем стирается копией чистого фона.
* Поэтому при переходе между пунктами нет видимой построчной перерисовки.
*
* Шрифты — исходные hc_small_font_data/hc_font_data из SDLPoP, заранее
* переведённые toolchain/pop_extract_font.py в FONT\\FONT.ATL. Атлас в W0
* нужен ещё и как временное хранилище снимка палитры: затемнение не тратит
* 1 КБ дефицитной W2 и всегда восстанавливает точные цвета уровня.
*/
#include <stdint.h>
#include <kbd_raw.h>
#include <graphics.h>
#include <gfx.h>
#include <sprite.h>
#include "pop_menu.h"
#include "pop_pal.h"
#include "pop_ui.h"
#include "pop_font.h" /* POP_FONT_*_ASCENT — раскладка считается по шрифту */
#include "pop_bg.h" /* POP_YOFF/POP_PLAYFIELD_H — границы игровой области */
#include "pop_cdraw.h" /* POP_HP_Y — нижнюю статус-полосу делим с HP */
#include "pop_pace.h"
#include "pop_settings.h"
#include "pop_cfg.h"
#include "pop_qsave.h"
#include "pop_status.h"
#include "pop_sfx.h"
/* --- ОБЩАЯ ВЕРТИКАЛЬНАЯ РАСКЛАДКА ------------------------------------- *
*
* Экран у нас 320x256, но ИГРА занимает только 200 строк: 192 поля тайлов
* плюс 8 строк полосы HP, начиная с POP_YOFF. Всё, что выше и ниже, —
* борта, и на реальном ТВ/мониторе они могут быть срезаны. Поэтому каждый
* экран UI центрируется в этой самой полосе, а не в 256 строках кадра, и
* обязан в неё помещаться целиком.
*
* Блок считается «сверху вниз»: UI_CENTER_TOP отдаёт строку, с которой
* начинается силуэт содержимого (не базовая линия!). Первая базовая линия
* = верх + ascent шрифта, дальше — шаги. */
#define UI_AREA_TOP POP_YOFF
#define UI_AREA_H (POP_PLAYFIELD_H + 8) /* поле + полоса HP = 200 */
#define UI_CENTER_TOP(h) (UI_AREA_TOP + (UI_AREA_H - (h)) / 2)
/* Отдельный вариант для экранов, у которых нижняя статус-полоса занята
* своим содержимым: центрируем только по полю тайлов. */
#define UI_CENTER_FIELD(h) (UI_AREA_TOP + (POP_PLAYFIELD_H - (h)) / 2)
/* Рамка выделения строки. Заглавные малого шрифта занимают РОВНО пять
* строк [base-ascent .. base-1], поэтому симметричный отступ считается от
* них, а не от базовой линии: SEL_PAD строк воздуха сверху и снизу плюс
* сама линия рамки. Раньше низ рамки брался «на глаз» (base+5), и она
* визуально сидела ниже текста. */
#define SEL_PAD 3
#define SEL_TOP_DY (POP_FONT_SMALL_ASCENT + SEL_PAD + 1) /* 9 */
#define SEL_BOT_DY (SEL_PAD) /* 3 */
#define MENU_N_ITEMS 7
#define MENU_STEP 14
#define MENU_LIST_H (SEL_TOP_DY + (MENU_N_ITEMS - 1) * MENU_STEP + \
SEL_BOT_DY + 1)
/* Центрируем СПИСОК по полю тайлов: GAME PAUSED — не пункт меню, а строка
* состояния программы, и в блок центрирования не входит. */
#define MENU_FIRST_BASE (UI_CENTER_FIELD(MENU_LIST_H) + SEL_TOP_DY)
/* Оригинал пишет её в нижнюю статус-полосу, между делениями HP Кида слева и
* стража справа (SDLPoP rect_bottom_text = {193,70,202,250}, valign_bottom):
* базовая линия на строку ниже полосы, силуэт крупного шрифта ложится ровно
* на неё. */
#define MENU_PAUSED_BASE (POP_YOFF + POP_HP_Y + 8)
#define MENU_SEL_L 115
#define MENU_SEL_R 205
#define MENU_BORDER 0x37 /* светло-серый VGA16, не затемняем */
#define UI_LEFT 45
#define UI_RIGHT 274
#define UI_VALUE_RIGHT 257
#define SETTINGS_N_ITEMS 9
/* Три секции по одному заголовку: T -> GENERAL, i2 -> GAMEPLAY,
* i6 -> CONTROLS. Между заголовком и строками, как и между строками, —
* фиксированные шаги, поэтому вся высота выражается через них. */
#define SET_STEP 14
#define SET_HEAD_GAP 17
#define SET_BLOCK_H (POP_FONT_BIG_ASCENT + 3 * SET_HEAD_GAP + \
9 * SET_STEP + SEL_BOT_DY + 1)
#define SET_TITLE_BASE (UI_CENTER_TOP(SET_BLOCK_H) + POP_FONT_BIG_ASCENT)
#define SET_HEAD1 (SET_TITLE_BASE + SET_HEAD_GAP)
#define SET_HEAD2 (SET_HEAD1 + 4 * SET_STEP + SET_HEAD_GAP)
#define SET_HEAD3 (SET_HEAD2 + 3 * SET_STEP + SET_HEAD_GAP)
/* CONTROLS — ДВЕ КОЛОНКИ. В одну колонку раскладка уже не помещалась:
* после приведения клавиш к keys.txt (docs/keys_plan.md) их стало вдвое
* больше, а по высоте есть только 200 точек на всё вместе с заголовком.
*
* Слева — игра (бег, лазание, бой), справа — служебное и читы. Число строк
* в колонках разное и зависит от того, включены ли читы, поэтому высота
* блока считается в рантайме по ДЛИННОЙ колонке. */
#define CTRL_TITLE_GAP 18
#define CTRL_N_LEFT 10 /* игровые клавиши */
#define CTRL_N_SYS 7 /* служебные */
#define CTRL_N_CHEAT 5 /* читы: показываются, только если включены */
/* Колонки сдвинуты вправо на 15 точек против первой раскладки: там слева
* оставалось 6 свободных точек, а справа 36, и блок выглядел прижатым к
* краю. Теперь поля 21 и ~20 — по обе стороны одинаково. */
#define CTRL_L_KEY 21
#define CTRL_L_DESC 77
#define CTRL_R_KEY 179
#define CTRL_R_DESC 235
#define CTRL_STEP 13
/* ABOUT = ровно тот же текст, что на стартовом Sprinter screen, минус
* строка про клавиши. ABOUT_H — и высота блока, и смещение ПОСЛЕДНЕЙ
* базовой линии от его верха (совпадают, т.к. последняя строка малая). */
#define ABOUT_GAP1 30
#define ABOUT_GAP2 15
#define ABOUT_H (POP_FONT_BIG_ASCENT + 2 * ABOUT_GAP1 + ABOUT_GAP2)
#define BUILD_KEYS_GAP 48
/* Подтверждение: рамка карточки, вопрос крупным, две кнопки. */
#define CONF_BOX_H 66
#define CONF_TOP UI_CENTER_TOP(CONF_BOX_H)
#define CONF_Q_BASE (CONF_TOP + 20)
#define CONF_BTN_BASE (CONF_TOP + 50)
/* Уведомление: крупное сообщение + строка про клавиши. */
#define NOTICE_GAP 44
#define NOTICE_H (POP_FONT_BIG_ASCENT + NOTICE_GAP)
#define NOTICE_BASE (UI_CENTER_TOP(NOTICE_H) + POP_FONT_BIG_ASCENT)
#define UI_KEYS_TEXT "ESC, ENTER OR SPACE: CONTINUE"
/* SDLPoP menu.c: движение = loose_shake_2, подтверждение/Back =
* loose_shake_3, изменение значения = sword_vs_sword. Открытие pause menu
* намеренно без звука. */
#define MENU_SND_CHANGE 10
#define MENU_SND_MOVE 21
#define MENU_SND_SELECT 22
#define MENU_SND_NONE 0xFF
/* Значения приходят из Makefile: ID — короткий git commit, DATE — дата
* этого коммита. Fallback оставляет исходник самостоятельным для host/IDE. */
#ifndef POP_BUILD_ID
#define POP_BUILD_ID "DEV"
#endif
#ifndef POP_BUILD_DATE
#define POP_BUILD_DATE "UNKNOWN"
#endif
/* Порядок = план §3. SETTINGS — вложенный экран в этом же bank 9. */
static const uint8_t menu_cmds[MENU_N_ITEMS] = {
POP_MENU_RESUME,
POP_MENU_QUICKSAVE,
POP_MENU_QUICKLOAD,
POP_MENU_RESTART_LEVEL,
POP_MENU_SETTINGS,
POP_MENU_RESTART_GAME,
POP_MENU_QUIT
};
static const char * const menu_text[MENU_N_ITEMS] = {
"RESUME",
"QUICKSAVE (F6)",
"QUICKLOAD (F9)",
"RESTART LEVEL",
"SETTINGS",
"RESTART GAME",
"QUIT GAME"
};
/* Через bar, а не rectangle: у игры уже есть быстрый rectfill, тогда как
* rectangle тянет тяжёлый универсальный line-движок в и без того тесный W1. */
static void menu_box(int left, int top, int right, int bottom, uint8_t color)
{
setfillstyle(SOLID_FILL, color);
bar(left, top, right, top);
bar(left, bottom, right, bottom);
bar(left, top, left, bottom);
bar(right, top, right, bottom);
}
/* Рамка выделения СТРОКИ: её вертикаль задаёт только базовая линия текста,
* поэтому ни один экран не считает отступы сам. */
static void menu_sel_box(int left, int right, int baseline)
{
menu_box(left, baseline - SEL_TOP_DY, right, baseline + SEL_BOT_DY,
MENU_BORDER);
}
/* Весь UI собирается на скрытой странице. Источник gfx_copy_page — shadow
* видимой страницы, где оверлей отсутствует: все строки ниже идут через
* GFX_BANK_SPRITE. Один ui_end() = один готовый, tear-free кадр. */
static uint8_t ui_begin(void)
{
gfx_rect_t full;
uint8_t back = (uint8_t)(gfx_get_visible_page() ^ 1);
/* Полностраничная accel-копия держит DI дольше половины CBL-буфера.
* Закрываем вывод, иначе железо повторяет старые 128 байт (скрежет).
* ui_end откроет его уже с заново заполненной тишиной. */
pop_sfx_pause();
gfx_set_draw_page(back);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
gfx_copy_page(&full, GFX_COPY_DIRECT);
gfx_set_bank(GFX_BANK_SPRITE);
return back;
}
static void ui_end(uint8_t back)
{
pop_wait_edge(); /* готовый кадр показать только на фронте */
gfx_set_visible_page(back);
(void)pop_sfx_start();
}
static void menu_sound(uint8_t id)
{
if (id == MENU_SND_NONE) return;
pop_sfx_play(id);
pop_sfx_tick(); /* play_menu_sound: запуск немедленно */
}
static void ui_wait_release(void)
{
while (kbd_raw_down(KBD_ENTER) || kbd_raw_down(KBD_ESC) ||
kbd_raw_down(KBD_SPACE) ||
kbd_raw_down(KBD_UP) || kbd_raw_down(KBD_DOWN) ||
kbd_raw_down(KBD_LEFT) || kbd_raw_down(KBD_RIGHT))
pop_wait_edge();
}
/* Действие = Enter ИЛИ Space: в оригинале выбор подтверждается обеими, и
* держать их врозь незачем — обе читаются одним фронтом. */
static uint8_t ui_action_down(void)
{
return (uint8_t)(kbd_raw_down(KBD_ENTER) || kbd_raw_down(KBD_SPACE));
}
/* Информационный экран уже нарисован — ждём Esc/Enter/Space по фронту. */
static void ui_wait_ack(void)
{
uint8_t esc_prev = 0, act_prev = 0;
for (;;) {
uint8_t esc = kbd_raw_down(KBD_ESC);
uint8_t act = ui_action_down();
if ((esc && !esc_prev) || (act && !act_prev)) {
ui_wait_release();
return;
}
esc_prev = esc; act_prev = act;
pop_wait_edge();
}
}
static void menu_draw(uint8_t sel)
{
uint8_t back = ui_begin();
uint8_t i;
/* Как в SDLPoP: только затемнённая замороженная сцена, компактный
* список и тонкий серый контур выбора, без непрозрачной карточки. */
menu_sel_box(MENU_SEL_L, MENU_SEL_R,
MENU_FIRST_BASE + (int)sel * MENU_STEP);
pop_text_map();
for (i = 0; i < MENU_N_ITEMS; i++)
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2,
MENU_FIRST_BASE + (int)i * MENU_STEP, menu_text[i]);
/* Полосу сначала чистим: там могло висеть сообщение игры («60 MINUTES
* LEFT», «QUICKSAVE»), и две надписи ложились друг на друга. */
pop_text_unmap();
pop_status_wipe();
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, MENU_PAUSED_BASE,
"GAME PAUSED");
pop_text_unmap();
ui_end(back);
}
/* Явные (uint8_t) — не «на всякий случай»: SDCC 4.5 на константном
* ВЫРАЖЕНИИ больше 127 в инициализаторе uint8_t даёт warning 158, хотя
* значение помещается (на голом литерале 218 — молчит). */
static const uint8_t settings_y[SETTINGS_N_ITEMS] = {
(uint8_t)(SET_HEAD1 + 1 * SET_STEP), (uint8_t)(SET_HEAD1 + 2 * SET_STEP),
(uint8_t)(SET_HEAD1 + 3 * SET_STEP), (uint8_t)(SET_HEAD1 + 4 * SET_STEP),
(uint8_t)(SET_HEAD2 + 1 * SET_STEP), (uint8_t)(SET_HEAD2 + 2 * SET_STEP),
(uint8_t)(SET_HEAD2 + 3 * SET_STEP),
(uint8_t)(SET_HEAD3 + 1 * SET_STEP), (uint8_t)(SET_HEAD3 + 2 * SET_STEP)
};
static const char * const speed_text[] = { "NORMAL", "FAST", "FASTEST" };
static void settings_line(uint8_t item, const char *name, const char *value,
uint8_t selected)
{
int y = settings_y[item];
if (selected) menu_sel_box(UI_LEFT, UI_RIGHT, y);
pop_text_draw_mapped(POP_TEXT_SMALL, 60, y, name);
if (value) pop_text_right_mapped(POP_TEXT_SMALL, UI_VALUE_RIGHT, y, value);
}
static void settings_draw(uint8_t sel)
{
uint8_t back = ui_begin();
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, SET_TITLE_BASE,
"SETTINGS");
pop_text_draw_mapped(POP_TEXT_SMALL, 60, SET_HEAD1, "GENERAL");
settings_line(0, "SOUND", pop_settings.sound_enabled ? "ON" : "OFF", sel == 0);
settings_line(1, "MUSIC", pop_settings.music_enabled ? "ON" : "OFF", sel == 1);
settings_line(2, "DEBUG BAR",
pop_settings.show_debug_bar ? "ON" : "OFF", sel == 2);
settings_line(3, "RESTORE DEFAULTS...", 0, sel == 3);
pop_text_draw_mapped(POP_TEXT_SMALL, 60, SET_HEAD2, "GAMEPLAY");
settings_line(4, "TIME LIMIT",
pop_settings.time_limit_enabled ? "60 MIN" : "UNLIMITED",
sel == 4);
settings_line(5, "SPEED", speed_text[pop_settings.speed_mode], sel == 5);
settings_line(6, "CHEATS", pop_settings.cheats_enabled ? "ON" : "OFF", sel == 6);
pop_text_draw_mapped(POP_TEXT_SMALL, 60, SET_HEAD3, "CONTROLS");
settings_line(7, "SHOW KEY BINDINGS", 0, sel == 7);
settings_line(8, "ABOUT...", 0, sel == 8);
pop_text_unmap();
ui_end(back);
}
/* Две колонки «клавиша — что делает». Первые CTRL_N_BASE строк общие, за
* ними идут читовые: их показываем, только когда читы включены. */
/* ЛЕВАЯ КОЛОНКА — игра. Строка «IN A FIGHT» здесь заголовок, а не клавиша:
* ниже неё те же стрелки и Shift означают уже другое, и без разделителя
* список читался бы как противоречивый. */
static const char * const ctrl_l_key[CTRL_N_LEFT] = {
"ARROWS", "UP", "DOWN", "SHIFT", "SH+ARROWS", "HOME PGUP",
"IN A FIGHT", "SHIFT", "UP", "DOWN"
};
static const char * const ctrl_l_desc[CTRL_N_LEFT] = {
"RUN / TURN", "JUMP, CLIMB", "CROUCH", "PICK UP", "CAREFUL STEP",
"JUMP L / R",
"", "STRIKE", "BLOCK", "SHEATHE"
};
/* ПРАВАЯ КОЛОНКА — служебное, ниже читы. */
static const char * const ctrl_r_key[CTRL_N_SYS + 1 + CTRL_N_CHEAT] = {
"BACKSPACE", "SPACE", "T", "F6 / F9", "CTRL+A", "CTRL+S M", "CTRL+Q F10",
"CHEATS",
"SHIFT+L", "K", "+ / -", "SHIFT+I", "SHIFT+S T"
};
static const char * const ctrl_r_desc[CTRL_N_SYS + 1 + CTRL_N_CHEAT] = {
"MENU", "TIME LEFT", "SHOW TIMER", "SAVE / LOAD", "RESTART LVL",
"SOUND / MUSIC", "QUIT",
"",
"NEXT LEVEL", "KILL GUARD", "TIME + / -", "FLIP SCREEN", "HEAL / MAX HP"
};
/* Одна строка колонки: слева клавиша, справа что она делает. Пустое
* описание — значит строка была заголовком секции, и клавишу мы печатаем
* как есть, без второго столбца. */
static void ctrl_row(int kx, int dx, int y, const char *key, const char *desc)
{
pop_text_draw_mapped(POP_TEXT_SMALL, kx, y, key);
if (desc[0]) pop_text_draw_mapped(POP_TEXT_SMALL, dx, y, desc);
}
static void controls_draw(void)
{
uint8_t back = ui_begin();
/* С выключенными читами правая колонка короче: вместо пяти строк идёт
* одна пояснительная. Блок центрируется по ДЛИННОЙ из двух колонок. */
uint8_t cheats = pop_settings.cheats_enabled;
uint8_t rright = (uint8_t)(cheats ? CTRL_N_SYS + 1 + CTRL_N_CHEAT
: CTRL_N_SYS + 1);
uint8_t rows = (uint8_t)(rright > CTRL_N_LEFT ? rright : CTRL_N_LEFT);
uint8_t i;
int top = UI_CENTER_TOP(POP_FONT_BIG_ASCENT + CTRL_TITLE_GAP +
(int)(rows - 1) * CTRL_STEP);
int y0 = top + POP_FONT_BIG_ASCENT;
int y;
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, y0, "CONTROLS");
y = y0 + CTRL_TITLE_GAP;
for (i = 0; i < CTRL_N_LEFT; i++, y += CTRL_STEP)
ctrl_row(CTRL_L_KEY, CTRL_L_DESC, y, ctrl_l_key[i], ctrl_l_desc[i]);
y = y0 + CTRL_TITLE_GAP;
for (i = 0; i < rright; i++, y += CTRL_STEP) {
const char *key = ctrl_r_key[i], *desc = ctrl_r_desc[i];
/* Заголовок секции читов: с выключенными читами он же и объясняет,
* почему список ниже пуст. */
if (i == CTRL_N_SYS && !cheats) desc = "DISABLED";
ctrl_row(CTRL_R_KEY, CTRL_R_DESC, y, key, desc);
}
pop_text_unmap();
ui_end(back);
}
/* Текст экрана About — он же текст стартового Sprinter screen. Общий код
* ровно затем, чтобы эти два экрана не разъехались: пункт меню обязан
* показывать то же самое, только без строки про клавиши. */
static void about_text(int top)
{
int y = top + POP_FONT_BIG_ASCENT;
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, y, "PRINCE OF PERSIA");
y += ABOUT_GAP1;
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2, y,
"SPRINTER SP2000 BUILD");
y += ABOUT_GAP1;
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2, y,
"BUILD " POP_BUILD_ID);
y += ABOUT_GAP2;
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2, y, POP_BUILD_DATE);
}
static void about_draw(void)
{
uint8_t back = ui_begin();
pop_text_map();
about_text(UI_CENTER_TOP(ABOUT_H));
pop_text_unmap();
ui_end(back);
}
static void confirm_draw(const char *question, uint8_t sel)
{
uint8_t back = ui_begin();
menu_box(82, CONF_TOP, 238, CONF_TOP + CONF_BOX_H - 1, MENU_BORDER);
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, CONF_Q_BASE, question);
if (sel == 0) menu_sel_box(104, 151, CONF_BTN_BASE);
else menu_sel_box(169, 216, CONF_BTN_BASE);
pop_text_center_mapped(POP_TEXT_SMALL, 128, CONF_BTN_BASE, "YES");
pop_text_center_mapped(POP_TEXT_SMALL, 192, CONF_BTN_BASE, "NO");
pop_text_unmap();
ui_end(back);
}
static uint8_t ui_confirm(const char *question)
{
uint8_t sel = 1; /* разрушительные действия по умолчанию NO */
uint8_t up_prev = 0, down_prev = 0, left_prev = 0, right_prev = 0;
uint8_t esc_prev = 0, ent_prev = 0;
ui_wait_release();
confirm_draw(question, sel);
menu_sound(MENU_SND_SELECT);
for (;;) {
uint8_t up = kbd_raw_down(KBD_UP);
uint8_t down = kbd_raw_down(KBD_DOWN);
uint8_t left = kbd_raw_down(KBD_LEFT);
uint8_t right = kbd_raw_down(KBD_RIGHT);
uint8_t esc = kbd_raw_down(KBD_ESC);
uint8_t ent = ui_action_down();
uint8_t redraw = 0;
if ((up && !up_prev) || (down && !down_prev) ||
(left && !left_prev) || (right && !right_prev)) {
sel ^= 1;
redraw = 1;
}
if (esc && !esc_prev) {
ui_wait_release();
return 0;
}
if (ent && !ent_prev) {
uint8_t yes = (uint8_t)(sel == 0);
ui_wait_release();
return yes;
}
up_prev = up; down_prev = down; left_prev = left; right_prev = right;
esc_prev = esc; ent_prev = ent;
if (redraw) {
confirm_draw(question, sel);
menu_sound(MENU_SND_MOVE);
}
pop_wait_edge();
}
}
static void notice_draw(const char *message)
{
uint8_t back = ui_begin();
pop_text_map();
pop_text_center_mapped(POP_TEXT_BIG, GFX_WIDTH / 2, NOTICE_BASE, message);
pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2,
NOTICE_BASE + NOTICE_GAP, UI_KEYS_TEXT);
pop_text_unmap();
ui_end(back);
}
static void ui_notice(const char *message)
{
ui_wait_release();
notice_draw(message);
menu_sound(MENU_SND_SELECT);
ui_wait_ack();
}
static void controls_process(void)
{
ui_wait_release();
controls_draw();
menu_sound(MENU_SND_SELECT);
ui_wait_ack();
}
static void about_process(void)
{
ui_wait_release();
about_draw();
menu_sound(MENU_SND_SELECT);
ui_wait_ack();
}
static uint8_t settings_finish(uint8_t changed)
{
int8_t rc;
if (!changed) return 0;
pop_settings_apply(1);
/* ESTEX может уйти в диск на много CBL-периодов. */
pop_sfx_pause();
rc = pop_cfg_save();
(void)pop_sfx_start();
ui_notice(rc > 0 ? "SETTINGS SAVED" : "SAVE ERROR");
return 1;
}
static uint8_t settings_process(void)
{
uint8_t sel = 0, changed = 0;
uint8_t up_prev = 0, down_prev = 0, left_prev = 0, right_prev = 0;
uint8_t esc_prev = 0, ent_prev = 0;
ui_wait_release();
settings_draw(sel);
menu_sound(MENU_SND_SELECT); /* вход во вложенное меню */
for (;;) {
uint8_t up = kbd_raw_down(KBD_UP);
uint8_t down = kbd_raw_down(KBD_DOWN);
uint8_t left = kbd_raw_down(KBD_LEFT);
uint8_t right = kbd_raw_down(KBD_RIGHT);
uint8_t esc = kbd_raw_down(KBD_ESC);
uint8_t ent = ui_action_down();
uint8_t redraw = 0;
uint8_t change = 0;
uint8_t sound = MENU_SND_NONE;
int8_t delta = 0;
if (up && !up_prev) {
sel = (uint8_t)(sel ? sel - 1 : SETTINGS_N_ITEMS - 1);
redraw = 1; sound = MENU_SND_MOVE;
}
if (down && !down_prev) {
sel = (uint8_t)(sel == SETTINGS_N_ITEMS - 1 ? 0 : sel + 1);
redraw = 1; sound = MENU_SND_MOVE;
}
if (esc && !esc_prev) {
uint8_t saved = settings_finish(changed);
ui_wait_release();
return saved;
}
if ((left && !left_prev) || (right && !right_prev))
delta = (right && !right_prev) ? 1 : -1;
/* Left/right принадлежат только числовой настройке скорости;
* остальные строки меняются Enter либо Space, чтобы стрелкой не
* открыть случайно Controls и не сбросить параметры. */
if ((ent && !ent_prev) || (sel == 5 && delta)) {
if (sel == 0) {
pop_settings.sound_enabled ^= 1; change = 1;
} else if (sel == 1) {
pop_settings.music_enabled ^= 1; change = 1;
} else if (sel == 2) {
pop_settings.show_debug_bar ^= 1; change = 1;
} else if (sel == 3) {
if (ui_confirm("RESTORE DEFAULTS?")) {
pop_settings_defaults(); change = 1;
}
redraw = 1; sound = MENU_SND_SELECT;
} else if (sel == 4) {
pop_settings.time_limit_enabled ^= 1; change = 1;
} else if (sel == 5) {
uint8_t speed = pop_settings.speed_mode;
if (delta < 0) speed = (uint8_t)(speed ? speed - 1 : POP_SPEED_FASTEST);
else speed = (uint8_t)(speed == POP_SPEED_FASTEST ? POP_SPEED_NORMAL : speed + 1);
pop_settings.speed_mode = speed; change = 1;
} else if (sel == 6) {
pop_settings.cheats_enabled ^= 1; change = 1;
} else if (sel == 7) {
controls_process(); redraw = 1; sound = MENU_SND_SELECT;
} else if (sel == 8) {
about_process(); redraw = 1; sound = MENU_SND_SELECT;
}
}
if (change) {
/* Настройки работают сразу, а запись откладывается до выхода. */
pop_settings_apply(1);
changed = 1;
redraw = 1;
sound = MENU_SND_CHANGE;
}
up_prev = up; down_prev = down; left_prev = left; right_prev = right;
esc_prev = esc; ent_prev = ent;
if (redraw) {
settings_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
}
}
static int8_t menu_quickload(void)
{
uint8_t slots;
/* probe делает open/close на D:, поэтому насос в это время выключен. */
pop_sfx_pause();
slots = pop_qsave_probe();
(void)pop_sfx_start();
if (slots & POP_QS_SLOT_SAVE) return POP_MENU_QUICKLOAD;
if ((slots & POP_QS_SLOT_BACKUP) && ui_confirm("LOAD BACKUP?"))
return POP_MENU_QUICKLOAD_BACKUP;
if (!(slots & POP_QS_SLOT_BACKUP)) ui_notice("NO QUICKLOAD");
return POP_MENU_NONE;
}
/* Вернуть чистую сцену на ОБЕ страницы и показать ту, которая была до
* меню. Это важно и для отладочного single-buffer: главный цикл там
* ожидает страницу 0, а не ту, в которую меню сделало последний флип. */
static void menu_erase(uint8_t entry_page)
{
gfx_rect_t full;
uint8_t visible = gfx_get_visible_page();
uint8_t hidden = (uint8_t)(visible ^ 1);
full.x = 0; full.y = 0; full.w = GFX_WIDTH; full.h = GFX_HEIGHT;
/* Из shadow видимой страницы берём кадр без текстов. */
gfx_set_draw_page(hidden);
gfx_copy_page(&full, GFX_COPY_DIRECT);
pop_wait_edge();
gfx_set_visible_page(hidden);
/* Теперь чистая hidden — источник для второй страницы. */
gfx_set_draw_page(visible);
gfx_copy_page(&full, GFX_COPY_DIRECT);
if (entry_page != hidden) {
pop_wait_edge();
gfx_set_visible_page(entry_page);
}
}
int pop_menu_init(void) __banked
{
return pop_ui_init();
}
void pop_menu_free(void) __banked
{
pop_ui_free();
}
int8_t pop_menu_process(void) __banked
{
uint8_t sel = 0;
uint8_t up_prev = 0, down_prev = 0, esc_prev = 0, ent_prev = 0;
uint8_t entry_page, saved_page, saved_bank;
uint8_t exit_sound = MENU_SND_NONE;
struct fillsettingstype saved_fill;
int8_t result = POP_MENU_NONE;
if (!pop_menu_request) return POP_MENU_NONE;
pop_menu_request = 0;
if (!pop_ui_ready()) return POP_MENU_RESUME;
/* Открыли по НАЖАТИЮ Esc: дождаться отпускания, иначе то же нажатие
* закроет меню раньше первого кадра. pop_wait_edge опрашивает FIFO. */
while (kbd_raw_down(KBD_ESC)) pop_wait_edge();
entry_page = gfx_get_visible_page();
saved_page = gfx_get_draw_page();
saved_bank = gfx_get_bank();
getfillsettings(&saved_fill);
pop_sfx_pause(); /* snapshot/dim + первый full redraw */
pop_pal_snapshot();
/* 50 % яркости; диапазон UI остаётся ярким. Долю берём от шкалы, а не
* числом: ступеней стало 32 вместо четырёх, и прежняя «2» означала бы
* теперь затемнение на одну шестнадцатую — фон под меню почти не гас
* (поймано пользователем 2026-08-26). */
pop_pal_menu_dim(POP_PAL_DIM_BLACK / 2);
menu_draw(sel);
for (;;) {
uint8_t up = kbd_raw_down(KBD_UP);
uint8_t down = kbd_raw_down(KBD_DOWN);
uint8_t esc = kbd_raw_down(KBD_ESC);
uint8_t ent = ui_action_down();
uint8_t redraw = 0;
uint8_t sound = MENU_SND_NONE;
if (up && !up_prev) {
sel = (uint8_t)(sel ? sel - 1 : MENU_N_ITEMS - 1);
redraw = 1; sound = MENU_SND_MOVE;
}
if (down && !down_prev) {
sel = (uint8_t)(sel == MENU_N_ITEMS - 1 ? 0 : sel + 1);
redraw = 1; sound = MENU_SND_MOVE;
}
if (esc && !esc_prev) {
result = POP_MENU_RESUME;
break;
}
if (ent && !ent_prev && menu_cmds[sel] != POP_MENU_NONE) {
uint8_t cmd = menu_cmds[sel];
if (cmd == POP_MENU_SETTINGS) {
(void)settings_process();
redraw = 1; sound = MENU_SND_SELECT; /* Back -> pause menu */
} else if (cmd == POP_MENU_QUICKLOAD) {
result = menu_quickload();
if (result != POP_MENU_NONE) { exit_sound = MENU_SND_SELECT; break; }
redraw = 1; sound = MENU_SND_SELECT;
} else if (cmd == POP_MENU_QUIT) {
if (ui_confirm("QUIT GAME?")) {
result = POP_MENU_QUIT;
exit_sound = MENU_SND_SELECT;
break;
}
redraw = 1; sound = MENU_SND_SELECT;
} else {
result = (int8_t)cmd;
exit_sound = MENU_SND_SELECT;
break;
}
}
up_prev = up; down_prev = down; esc_prev = esc; ent_prev = ent;
if (redraw) {
menu_draw(sel);
menu_sound(sound);
}
pop_wait_edge();
pop_pace_end(pop_pace_n(0)); /* игра стоит — обычный темп */
}
pop_sfx_pause(); /* две full-page копии menu_erase */
menu_erase(entry_page);
/* menu_erase вернул обе страницы из тени, снятой ДО меню: всё, что
* статус-полосы нарисовали за это время (или сообщение, дотикавшее в
* паузе), надо выложить заново. */
pop_status_invalidate();
pop_pal_apply(0);
setfillstyle(saved_fill.pattern, saved_fill.color);
gfx_set_bank(saved_bank);
gfx_set_draw_page(saved_page);
(void)pop_sfx_start();
menu_sound(exit_sound); /* Esc main menu остаётся бесшумным */
return result;
}