Меню, статус-строка и оболочка игры: title/intro/cutscene/HoF, POP.CFG, палитры

Эта сессия (меню + текст в служебных полосах):

* Меню: рамка выделения считается от силуэта текста (SEL_PAD сверху и
  снизу), а не «на глаз»; все экраны центрируются в игровых 200 строках
  (UI_CENTER_TOP/UI_CENTER_FIELD) и в них помещаются; Enter и Space —
  равноправные клавиши действия (ui_action_down).
* Settings: убраны SHOW SPRINTER SCREEN и BACK, добавлены DEBUG BAR и
  ABOUT.  About показывает тот же текст, что стартовый Sprinter screen,
  минус строка про клавиши — общий about_text(), чтобы экраны не
  разъехались.  CONTROLS собирается таблицей и центрируется по
  фактическому числу строк.
* GAME PAUSED переехала в нижнюю статус-строку, как в оригинале
  (SDLPoP rect_bottom_text = {193,70,202,250}): это состояние программы,
  а не пункт меню.  POP_HP_Y вынесен в pop_cdraw.h — полосу делят два
  модуля.
* pop_status.c (банк 9) — текст в обеих служебных полосах.  Нижняя:
  порт display_text_bottom + таймера (QUICKSAVE/QUICKLOAD/SOUND ON/OFF,
  24 тика).  Верхняя отладочная переведена с палочек на текст
  «Level ##, Room ##, Speed: …, Sound: …, Immortal #» малым шрифтом, с
  своим форматированием чисел (без printf и без деления).
  Заявка сообщения — запись одного байта pop_status_msg: резидент W1/W2
  не растёт, весь рендер в банке.  Бюджет после правок не изменился
  (_CODE 23981, куча 267 Б).
* Цена вывода: блит глифа ~4,6 тыс. тактов независимо от размера, поэтому
  всё change-driven, отладочная строка перерисовывается ПО ПОЛЯМ, пробелы
  не блитятся вовсе, а вход в комнату заливает только игровое поле
  (pop_screen_fill_field) — борта от комнаты к комнате не меняются.
* Интро: в PV-сцене зазвучали пропавшие эффекты оригинала — закрытие
  ворот (4) и открытие двери покоев (51), из которой входит Джафар.
* docs/status_line_text.md — полная инвентаризация ВСЕХ текстов SDLPoP в
  статус-строке: геометрия, семантика text_time_total как идентификатора
  сообщения, мигание, рестарт по истечении 36/288.

Вместе с этим выкладывается накопленная работа по оболочке полной игры:
автомат состояний (pop_app), title, intro/PV и cutscene, attract-demo,
Hall of Fame, глобальный таймер, настройки и POP.CFG, модуль палитр и
fade, звуковой набор, host-тесты на новые швы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:58:36 +03:00
parent 7fd7f28ffc
commit 47a4b084c4
205 changed files with 8446 additions and 418 deletions
+38 -2
View File
@@ -6,8 +6,11 @@ ENGINE_DIR := $(abspath $(CURDIR)/..)
# t_phys гоняет НАСТОЯЩУЮ физику, а она ходит по таблицам анимации
# абсолютными адресами от KD_DATA_OFF (0x100) — там, куда на устройстве
# мапится страница W0. Значит блоб kid_data.bin лежит по 0x100, и код
# набора обязан начинаться выше его конца (0x100 + 3720 = 0x0F88).
CODE_LOC := 0x1000
# набора обязан начинаться выше его конца. С добавлением таблицы кадров
# КАТСЦЕН блоб вырос до 4150 Б (0x100 + 4150 = 0x1136), и прежние 0x1000
# уже резали его хвост — набор падал как «main() не позвал ни одной
# проверки». Держим запас: следующая таблица снова сдвинет границу.
CODE_LOC := 0x1400
# Данные — выше 0x8000: набор t_phys линкует уже полдвижка (геометрия, Kid,
# карта/физика, управление, страж) и его код перевалил за 0x8000. Раньше
# это проявлялось как «тест зациклился»: хвост кода ложился поверх _DATA.
@@ -42,6 +45,9 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
OBJS_grab := $(OBJS_phys)
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
OBJS_wall := $(OBJS_phys)
# t_gate — открытая решётка не должна считаться стеной у осторожного шага
# (нужны pop_map + физика, то есть тот же состав).
OBJS_gate := $(OBJS_phys)
# t_char — физика над Char для РАЗНЫХ персонажей (Кид / страж / тень).
OBJS_char := $(OBJS_phys)
# t_mouse — спецсобытие уровня 8 (мышь). ЕДИНСТВЕННЫЙ набор, куда линкуется
@@ -55,9 +61,39 @@ OBJS_mouse := $(OBJS_phys) build/eng_guards.rel
OBJS_shadow := $(OBJS_phys) build/eng_guards.rel
# t_jaffar — спецсобытия уровня 13 (встреча, победа, выход, падающая гряда).
OBJS_jaffar := $(OBJS_phys) build/eng_guards.rel
# t_cfg — весь codec POP.CFG из единственного прикладного модуля.
OBJS_cfg := build/eng_pop_config.rel build/bank_stub.rel
# pop_app — чистая таблица переходов, без DSS/графики.
OBJS_app := build/eng_pop_app.rel build/bank_stub.rel
# pop_demo — запись управления; t_demo подменяет выход control и raw input.
OBJS_demo := build/eng_pop_demo.rel build/bank_stub.rel
# pop_timer — чистое ядро логического времени; поток QuickSave заглушен
# memory-stream'ом в t_timer.c.
OBJS_timer := build/eng_pop_timer.rel build/bank_stub.rel
# pop_cutscene — общий интерпретатор статических сценариев. Он не знает
# графики, поэтому проверяется отдельно от PV/title-ресурсов.
OBJS_cutscene := build/eng_pop_cutscene.rel build/bank_stub.rel
# pop_flow — чистая таблица маршрута перед загрузкой следующего уровня.
OBJS_flow := build/eng_pop_flow.rel build/bank_stub.rel
include $(TESTKIT)/host-tests.mk
# У host-теста нет DSS/EMM: ему нужен тот же codec/state, но не target-I/O.
build/eng_pop_config.rel: $(ENGINE_DIR)/pop_config.c | $(BUILD)
@$(SDCC) $(CFLAGS) -DPOP_CONFIG_HOST_TEST -c -o $@ $<
build/eng_pop_timer.rel: $(ENGINE_DIR)/pop_timer.c | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/eng_pop_demo.rel: $(ENGINE_DIR)/pop_demo.c | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/eng_pop_cutscene.rel: $(ENGINE_DIR)/pop_cutscene.c | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
build/eng_pop_flow.rel: $(ENGINE_DIR)/pop_flow.c | $(BUILD)
@$(SDCC) $(CFLAGS) -c -o $@ $<
# --- t_phys: блоб таблиц анимации ------------------------------------
KID_BIN := $(abspath $(CURDIR)/../../poc/res/kid/kid_data.bin)
@@ -131,6 +131,13 @@ uint8_t kbd_raw_down(uint16_t code)
return 0;
}
uint8_t kbd_raw_any_down(void)
{
uint8_t i;
for (i = 0; i < TK_KEYS_MAX; i++) if (tk_keys[i]) return 1;
return 0;
}
void tk_keys_clear(void)
{
uint8_t i;
@@ -0,0 +1,135 @@
/* pop_app — протокол верхнего автомата: допустимые и запрещённые переходы. */
#include "tcheck.h"
#include "pop_app.h"
TC_TEST(boot_to_title)
{
pop_app_reset();
TC_EQ(pop_app_state, POP_APP_BOOT);
TC_TRUE(pop_app_dispatch(POP_APP_EV_BOOT_READY));
TC_EQ(pop_app_state, POP_APP_BUILD_INFO);
TC_TRUE(pop_app_dispatch(POP_APP_EV_BUILD_DONE));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
TC_TEST(level_load_roundtrip)
{
pop_app_reset();
pop_app_dispatch(POP_APP_EV_BOOT_READY);
pop_app_dispatch(POP_APP_EV_BUILD_DONE);
TC_TRUE(pop_app_dispatch(POP_APP_EV_NEW_GAME));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
TC_TRUE(pop_app_dispatch(POP_APP_EV_LEVEL_READY));
TC_EQ(pop_app_state, POP_APP_PLAYING);
TC_TRUE(pop_app_dispatch(POP_APP_EV_PAUSE));
TC_EQ(pop_app_state, POP_APP_PAUSE_MENU);
TC_TRUE(pop_app_dispatch(POP_APP_EV_RESUME));
TC_EQ(pop_app_state, POP_APP_PLAYING);
TC_TRUE(pop_app_dispatch(POP_APP_EV_LEVEL_FINISHED));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
TC_TRUE(pop_app_dispatch(POP_APP_EV_LEVEL_READY));
TC_EQ(pop_app_state, POP_APP_PLAYING);
}
TC_TEST(rejects_wrong_event)
{
pop_app_reset();
TC_FALSE(pop_app_dispatch(POP_APP_EV_LEVEL_READY));
TC_EQ(pop_app_state, POP_APP_BOOT);
TC_TRUE(pop_app_dispatch(POP_APP_EV_QUIT));
TC_EQ(pop_app_state, POP_APP_QUIT);
TC_FALSE(pop_app_dispatch(POP_APP_EV_BOOT_READY));
}
TC_TEST(future_routes_are_declared)
{
pop_app_reset();
pop_app_state = POP_APP_TITLE;
TC_TRUE(pop_app_dispatch(POP_APP_EV_NEW_GAME));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
TC_TRUE(pop_app_dispatch(POP_APP_EV_LEVEL_READY));
TC_EQ(pop_app_state, POP_APP_PLAYING);
TC_TRUE(pop_app_dispatch(POP_APP_EV_TIME_EXPIRED));
TC_EQ(pop_app_state, POP_APP_TIME_EXPIRED);
TC_TRUE(pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
TC_TEST(prelevel_cutscene_route)
{
pop_app_state = POP_APP_PLAYING;
TC_TRUE(pop_app_dispatch(POP_APP_EV_CUTSCENE_STARTED));
TC_EQ(pop_app_state, POP_APP_CUTSCENE);
TC_TRUE(pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
}
TC_TEST(demo_routes)
{
pop_app_reset();
pop_app_dispatch(POP_APP_EV_BOOT_READY);
pop_app_dispatch(POP_APP_EV_BUILD_DONE);
TC_TRUE(pop_app_dispatch(POP_APP_EV_INTRO_STARTED));
TC_EQ(pop_app_state, POP_APP_INTRO);
TC_TRUE(pop_app_dispatch(POP_APP_EV_CUTSCENE_FINISHED));
TC_EQ(pop_app_state, POP_APP_DEMO);
TC_TRUE(pop_app_dispatch(POP_APP_EV_DEMO_FINISHED));
TC_EQ(pop_app_state, POP_APP_TITLE);
TC_TRUE(pop_app_dispatch(POP_APP_EV_DEMO_STARTED));
TC_TRUE(pop_app_dispatch(POP_APP_EV_NEW_GAME));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
}
TC_TEST(intro_skip_starts_new_game)
{
pop_app_reset();
pop_app_dispatch(POP_APP_EV_BOOT_READY);
pop_app_dispatch(POP_APP_EV_BUILD_DONE);
pop_app_dispatch(POP_APP_EV_INTRO_STARTED);
TC_EQ(pop_app_state, POP_APP_INTRO);
TC_TRUE(pop_app_dispatch(POP_APP_EV_NEW_GAME));
TC_EQ(pop_app_state, POP_APP_LEVEL_LOAD);
}
TC_TEST(restart_game_returns_to_intro)
{
pop_app_state = POP_APP_PLAYING;
TC_TRUE(pop_app_dispatch(POP_APP_EV_PAUSE));
TC_EQ(pop_app_state, POP_APP_PAUSE_MENU);
TC_TRUE(pop_app_dispatch(POP_APP_EV_RESTART_INTRO));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
/* Финал ВРЕМЕННО ведёт мимо Hall of Fame прямо в title: экран HOF после
* победы показывает чёрное, и застревать в нём хуже, чем не показывать
* (решение пользователя 2026-08-24, BUGS_OPEN.md#hof-after-ending). Сам
* маршрут HOF -> TITLE остаётся живым: в него ходит title-экран. */
TC_TEST(ending_to_title_route)
{
pop_app_state = POP_APP_PLAYING;
TC_TRUE(pop_app_dispatch(POP_APP_EV_ENDING_STARTED));
TC_EQ(pop_app_state, POP_APP_ENDING);
TC_TRUE(pop_app_dispatch(POP_APP_EV_ENDING_FINISHED));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
TC_TEST(hof_route_still_exits_to_title)
{
pop_app_state = POP_APP_HALL_OF_FAME;
TC_TRUE(pop_app_dispatch(POP_APP_EV_HOF_FINISHED));
TC_EQ(pop_app_state, POP_APP_TITLE);
}
void main(void)
{
TC_RUN(boot_to_title);
TC_RUN(level_load_roundtrip);
TC_RUN(rejects_wrong_event);
TC_RUN(future_routes_are_declared);
TC_RUN(prelevel_cutscene_route);
TC_RUN(demo_routes);
TC_RUN(intro_skip_starts_new_game);
TC_RUN(restart_game_returns_to_intro);
TC_RUN(ending_to_title_route);
TC_RUN(hof_route_still_exits_to_title);
}
@@ -0,0 +1,197 @@
/*
* t_cfg — полный codec POP.CFG: заголовок, размер, checksum и payload.
*
* DSS/EMM под host не живут, но pure codec лежит в том же pop_config.c, что
* и target-I/O. Поэтому проверяем весь формат, а не только payload.
*/
#include <stdint.h>
#include <string.h>
#include "tcheck.h"
#include "pop_settings.h"
#include "pop_pace.h" /* POP_SPEED_MODES */
#include "_pop_config.h"
/* Runtime-владельцы нужны только для линковки pop_settings_apply(); codec
* их не вызывает. */
uint8_t pop_speed_mode, pop_cheats, pop_snd_want;
void pop_sfx_toggle(void) __banked { pop_snd_want = (uint8_t)!pop_snd_want; }
static void set_all(uint8_t snd, uint8_t spd, uint8_t prof, uint8_t cheats,
uint8_t info, uint16_t flags, uint8_t time_limit,
uint8_t debug_bar)
{
pop_settings.sound_enabled = snd;
pop_settings.speed_mode = spd;
pop_settings.gameplay_profile = prof;
pop_settings.cheats_enabled = cheats;
pop_settings.show_build_info = info;
pop_settings.enhancement_flags = flags;
pop_settings.time_limit_enabled = time_limit;
pop_settings.show_debug_bar = debug_bar;
}
/* ser→parse должен возвращать ровно те же значения (кроме профиля:
* ENHANCED осознанно садится в VANILLA). */
TC_TEST(cfg_roundtrip)
{
uint8_t buf[POP_CFG_SIZE];
set_all(1, POP_SPEED_FAST, POP_PROFILE_VANILLA, 1, 1, 0xBEEF, 0, 0);
pop_cfg_encode(buf);
memset(&pop_settings, 0, sizeof(pop_settings)); /* стереть */
TC_TRUE(pop_cfg_decode(buf, sizeof(buf)));
TC_EQ(pop_settings.sound_enabled, 1);
TC_EQ(pop_settings.speed_mode, POP_SPEED_FAST);
TC_EQ(pop_settings.cheats_enabled, 1);
TC_EQ(pop_settings.show_build_info, 1);
TC_EQ(pop_settings.enhancement_flags, 0xBEEF);
TC_EQ(pop_settings.time_limit_enabled, 0);
TC_EQ(pop_settings.show_debug_bar, 0);
}
/* ENHANCED из файла применяется как VANILLA (режим ещё не реализован). */
TC_TEST(cfg_enhanced_downgrades_to_vanilla)
{
uint8_t buf[POP_CFG_SIZE];
set_all(1, POP_SPEED_NORMAL, POP_PROFILE_ENHANCED, 0, 0, 0x0003, 1, 1);
pop_cfg_encode(buf);
memset(&pop_settings, 0, sizeof(pop_settings));
TC_TRUE(pop_cfg_decode(buf, sizeof(buf)));
TC_EQ(pop_settings.gameplay_profile, POP_PROFILE_VANILLA);
/* Остальные поля при этом применяются. */
TC_EQ(pop_settings.enhancement_flags, 0x0003);
}
/* Мусор в ЛЮБОМ поле => отказ без частичного применения. */
TC_TEST(cfg_garbage_rejected_whole)
{
uint8_t buf[POP_CFG_SIZE];
uint8_t i;
for (i = 0; i < POP_CFG_PAYLOAD_SIZE; i++) {
set_all(1, POP_SPEED_FASTEST, POP_PROFILE_VANILLA, 1, 0, 0x0001, 1, 1);
pop_cfg_encode(buf);
switch (i) {
case 0: buf[7 + i] = 2; break; /* sound > 1 */
case 1: buf[7 + i] = (uint8_t)POP_SPEED_MODES; break;
case 2: buf[7 + i] = 2; break; /* profile > ENHANCED */
case 3: buf[7 + i] = 2; break; /* cheats > 1 */
case 4: buf[7 + i] = 2; break; /* buildinfo > 1 */
case 7: buf[7 + i] = 2; break; /* timer > 1 */
case 8: buf[7 + i] = 2; break; /* debug bar > 1 */
default: buf[7 + i] = 0xFF; break; /* флаги — любые */
}
/* Изменённый payload обязан иметь корректную checksum: иначе мы
* тестируем checksum, а не диапазон поля. */
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE] = 0;
{
uint8_t j;
for (j = 0; j < POP_CFG_PAYLOAD_SIZE; j++)
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE] ^= buf[7 + j];
}
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE + 1] =
(uint8_t)~buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE];
if (i == 5 || i == 6) { /* flags не валидируются */
memset(&pop_settings, 0, sizeof(pop_settings));
TC_TRUE(pop_cfg_decode(buf, sizeof(buf)));
continue;
}
memset(&pop_settings, 0, sizeof(pop_settings));
TC_FALSE(pop_cfg_decode(buf, sizeof(buf)));
/* Настройки не тронуты — нули после memset остались нулями. */
TC_EQ(pop_settings.sound_enabled, 0);
TC_EQ(pop_settings.speed_mode, 0);
}
}
TC_TEST(cfg_header_checksum_and_future_tail)
{
uint8_t buf[POP_CFG_MAX_SIZE];
uint8_t i, sum;
set_all(1, POP_SPEED_FAST, POP_PROFILE_VANILLA, 0, 1, 0xA55A, 1, 1);
pop_cfg_encode(buf);
buf[0] = 'X';
TC_FALSE(pop_cfg_decode(buf, POP_CFG_SIZE));
pop_cfg_encode(buf);
buf[4] = 2;
TC_FALSE(pop_cfg_decode(buf, POP_CFG_SIZE));
pop_cfg_encode(buf);
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE] ^= 1;
TC_FALSE(pop_cfg_decode(buf, POP_CFG_SIZE));
pop_cfg_encode(buf);
TC_FALSE(pop_cfg_decode(buf, (uint16_t)(POP_CFG_SIZE - 1)));
/* Будущий v1: два незнакомых байта перед checksum должны пропуститься. */
pop_cfg_encode(buf);
buf[5] = POP_CFG_PAYLOAD_SIZE + 2;
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE] = 0x12;
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE + 1] = 0x34;
sum = 0;
for (i = 0; i < POP_CFG_PAYLOAD_SIZE + 2; i++) sum ^= buf[7 + i];
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE + 2] = sum;
buf[POP_CFG_HEADER_SIZE + POP_CFG_PAYLOAD_SIZE + 3] = (uint8_t)~sum;
memset(&pop_settings, 0, sizeof(pop_settings));
TC_TRUE(pop_cfg_decode(buf, (uint16_t)(POP_CFG_SIZE + 2)));
TC_EQ(pop_settings.enhancement_flags, 0xA55A);
}
/* Созданный до FG4 файл (семь payload-байтов) остаётся валиден и обязан
* включить лимит: это исторический дефолт 60 минут, не значение в _DATA. */
TC_TEST(cfg_legacy_enables_time_limit)
{
uint8_t buf[POP_CFG_SIZE];
uint8_t sum = 0, i;
set_all(1, POP_SPEED_NORMAL, POP_PROFILE_VANILLA, 1, 0, 0, 0, 0);
pop_cfg_encode(buf);
buf[5] = POP_CFG_LEGACY_PAYLOAD_SIZE;
for (i = 0; i < POP_CFG_LEGACY_PAYLOAD_SIZE; i++) sum ^= buf[7 + i];
buf[POP_CFG_HEADER_SIZE + POP_CFG_LEGACY_PAYLOAD_SIZE] = sum;
buf[POP_CFG_HEADER_SIZE + POP_CFG_LEGACY_PAYLOAD_SIZE + 1] = (uint8_t)~sum;
pop_settings.time_limit_enabled = 0;
TC_TRUE(pop_cfg_decode(buf, POP_CFG_MIN_SIZE));
TC_EQ(pop_settings.time_limit_enabled, 1);
}
/* Файл, записанный ДО тумблера отладочной полосы (восемь payload-байтов),
* обязан остаться валидным и включить полосу: это её исторический дефолт. */
TC_TEST(cfg_legacy_enables_debug_bar)
{
uint8_t buf[POP_CFG_SIZE];
uint8_t sum = 0, i;
set_all(1, POP_SPEED_NORMAL, POP_PROFILE_VANILLA, 1, 0, 0, 1, 0);
pop_cfg_encode(buf);
buf[5] = POP_CFG_TIMELIMIT_PAYLOAD_SIZE;
for (i = 0; i < POP_CFG_TIMELIMIT_PAYLOAD_SIZE; i++) sum ^= buf[7 + i];
buf[POP_CFG_HEADER_SIZE + POP_CFG_TIMELIMIT_PAYLOAD_SIZE] = sum;
buf[POP_CFG_HEADER_SIZE + POP_CFG_TIMELIMIT_PAYLOAD_SIZE + 1] = (uint8_t)~sum;
pop_settings.show_debug_bar = 0;
TC_TRUE(pop_cfg_decode(buf, (uint16_t)(POP_CFG_HEADER_SIZE +
POP_CFG_TIMELIMIT_PAYLOAD_SIZE +
POP_CFG_TAIL_SIZE)));
TC_EQ(pop_settings.show_debug_bar, 1);
TC_EQ(pop_settings.time_limit_enabled, 1);
}
TC_TEST(settings_apply_syncs_runtime)
{
set_all(0, POP_SPEED_FASTEST, POP_PROFILE_VANILLA, 0, 0, 0, 1, 1);
pop_snd_want = 1;
pop_settings_apply(1);
TC_EQ(pop_speed_mode, POP_SPEED_FASTEST);
TC_EQ(pop_cheats, 0);
TC_EQ(pop_snd_want, 0);
}
void main(void)
{
TC_RUN(cfg_roundtrip);
TC_RUN(cfg_enhanced_downgrades_to_vanilla);
TC_RUN(cfg_garbage_rejected_whole);
TC_RUN(cfg_header_checksum_and_future_tail);
TC_RUN(cfg_legacy_enables_time_limit);
TC_RUN(cfg_legacy_enables_debug_bar);
TC_RUN(settings_apply_syncs_runtime);
}
@@ -0,0 +1,46 @@
/* pop_cutscene — порядок событий, точная семантика WAIT и прерывание. */
#include <stdint.h>
#include "tcheck.h"
#include "pop_cutscene.h"
static const pop_cs_cmd_t script[] = {
{ POP_CS_PALETTE, 1 }, { POP_CS_SHOW, 7 }, { POP_CS_WAIT, 2 },
{ POP_CS_FLASH, 3 }, { POP_CS_WAIT, 1 }, { POP_CS_FADE_OUT, 0 },
{ POP_CS_END, 0 }
};
TC_TEST(script_events_and_waits)
{
pop_cutscene_t cs;
uint16_t arg;
pop_cutscene_begin(&cs, script);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_PALETTE); TC_EQ(arg, 1);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_SHOW); TC_EQ(arg, 7);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_NONE);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_NONE);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_FLASH); TC_EQ(arg, 3);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_NONE);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_FADE_OUT);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_END);
}
TC_TEST(skip_aborts_at_any_wait)
{
pop_cutscene_t cs;
uint16_t arg;
pop_cutscene_begin(&cs, script);
pop_cutscene_tick(&cs, &arg);
pop_cutscene_tick(&cs, &arg);
pop_cutscene_tick(&cs, &arg); /* WAIT(2), ещё есть один кадр */
pop_cutscene_abort(&cs);
TC_EQ(pop_cutscene_tick(&cs, &arg), POP_CS_EVENT_END);
TC_EQ(cs.ended, 1);
}
void main(void)
{
TC_RUN(script_events_and_waits);
TC_RUN(skip_aborts_at_any_wait);
}
@@ -0,0 +1,89 @@
/* pop_demo — запись level 0: смена команд и прерывание любым вводом. */
#include <stdint.h>
#include "tcheck.h"
#include "pop_demo.h"
static int8_t moves[255];
static uint8_t moves_n;
static uint8_t any_key;
static uint8_t fight_ai;
uint8_t hitp_curr;
uint8_t pop_demo_kid_ai(void) __banked
{
return fight_ai;
}
void pop_ctrl_auto_move(int8_t move) __banked
{
moves[moves_n++] = move;
}
uint8_t kbd_raw_any_down(void)
{
return any_key;
}
TC_TEST(replays_sdlpop_move_table)
{
uint8_t done = 0;
moves_n = 0;
fight_ai = 0;
hitp_curr = 4;
pop_demo_begin();
TC_FALSE(pop_demo_tick());
TC_EQ(moves_n, 1);
TC_EQ(moves[0], 0); /* t=1, запись {0, nothing} */
while (moves_n < 14) {
TC_FALSE(pop_demo_tick());
}
TC_EQ(moves[1], 1); /* t=2, запись {1, forward} */
TC_EQ(moves[11], 1); /* t=12 всё ещё forward */
TC_EQ(moves[12], 0); /* t=13, запись {0x0D, nothing} */
while (!done) done = pop_demo_tick();
TC_EQ(moves_n, 254); /* SDLPoP останавливает demo_time на FE */
TC_EQ(moves[253], -1); /* хвост после записи {0xE9, -1} */
}
TC_TEST(fight_ai_pauses_scripted_moves)
{
moves_n = 0;
fight_ai = 1;
hitp_curr = 4;
pop_demo_begin();
TC_FALSE(pop_demo_tick());
TC_EQ(moves_n, 0); /* во время боя demo_time не идёт */
fight_ai = 0;
TC_FALSE(pop_demo_tick());
TC_EQ(moves_n, 1);
TC_EQ(moves[0], 0); /* первый scripted тик не потерян */
}
TC_TEST(death_stops_demo_before_combat_ai)
{
moves_n = 0;
fight_ai = 1;
hitp_curr = 0;
pop_demo_begin();
TC_TRUE(pop_demo_tick());
TC_EQ(moves_n, 0);
}
TC_TEST(any_raw_key_interrupts_demo)
{
any_key = 0;
TC_FALSE(pop_demo_user_input());
any_key = 1;
TC_TRUE(pop_demo_user_input());
}
void main(void)
{
TC_RUN(replays_sdlpop_move_table);
TC_RUN(fight_ai_pauses_scripted_moves);
TC_RUN(death_stops_demo_before_combat_ai);
TC_RUN(any_raw_key_interrupts_demo);
}
@@ -0,0 +1,52 @@
/* pop_flow — только канонические pre-level маршруты и ветка scene 12. */
#include <stdint.h>
#include "tcheck.h"
#include "pop_flow.h"
TC_TEST(only_original_levels_have_pre_scenes)
{
uint8_t level;
for (level = 0; level <= 14; level++) {
pop_pre_cutscene_t scene = pop_flow_pre_cutscene(level, 60);
if (level == 2 || level == 6) TC_EQ(scene, POP_PRE_2_6);
else if (level == 4) TC_EQ(scene, POP_PRE_4);
else if (level == 8) TC_EQ(scene, POP_PRE_8);
else if (level == 9) TC_EQ(scene, POP_PRE_9);
else if (level == 12) TC_EQ(scene, POP_PRE_12);
else TC_EQ(scene, POP_PRE_NONE);
}
TC_EQ(pop_flow_pre_cutscene(15, 60), POP_PRE_NONE);
}
TC_TEST(scene_12_short_time_boundary)
{
TC_EQ(pop_flow_pre_cutscene(12, 6), POP_PRE_12);
TC_EQ(pop_flow_pre_cutscene(12, 5), POP_PRE_12_SHORT);
TC_EQ(pop_flow_pre_cutscene(12, 0), POP_PRE_12_SHORT);
}
TC_TEST(level_14_is_ending_without_number)
{
const pop_level_flow_t *flow = pop_flow_level(14);
TC_TRUE(flow != 0);
TC_FALSE(flow->show_level_number);
TC_TRUE(flow->ending_rule);
TC_TRUE(pop_flow_level(15) == 0);
}
TC_TEST(ending_room_is_exact)
{
TC_TRUE(pop_flow_is_ending(14, 5));
TC_FALSE(pop_flow_is_ending(14, 4));
TC_FALSE(pop_flow_is_ending(13, 5));
TC_FALSE(pop_flow_is_ending(15, 5));
}
void main(void)
{
TC_RUN(only_original_levels_have_pre_scenes);
TC_RUN(scene_12_short_time_boundary);
TC_RUN(level_14_is_ending_without_number);
TC_RUN(ending_room_is_exact);
}
@@ -0,0 +1,202 @@
/*
* t_gate.c — ОТКРЫТЫЕ ворота не должны быть «стеной» для осторожного шага.
*
* Наблюдение (пользователь, 2026-08-24): перед ОТКРЫТОЙ решёткой Кид,
* идущий слева направо, делает лишний мелкий шаг — чтобы подойти к краю,
* Shift+вперёд надо нажимать дважды.
*
* Механика: длину осторожного шага выбирает сам safe_step (seg005:0604) по
* числу, которое вернул get_edge_distance — seq 29..42 это «шаг ровно на
* distance пикселей». А get_edge_distance для тайла-стены зовёт
* dist_from_wall_forward, и та у ОРИГИНАЛА первой строкой отвечает −1, если
* тайл — ворота, сквозь которые можно пройти (seg004:0A18:
* `if (tiletype == tiles_4_gate && !can_bump_into_gate()) return -1`).
* Минус единица уводит разбор дальше, где открытые ворота опознаются как
* обычный ПОЛ: distance = 11, полный шаг. Без этой ветки расстояние
* считается до створки, тип края — EDGE_WALL, и шаг выходит коротким.
*
* Проверяем не поведение через кадры, а сам ответ get_edge_distance: он и
* есть источник длины шага, и утверждение «открытая решётка == обычный
* пол» переживёт любую перенастройку последовательностей.
*/
#include "tcheck.h"
#include "scene.h"
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#include "pop_cdraw.h" /* pop_cd[].fph — высоту кадра ставит отрисовка */
#define F 1 /* floor */
#define G 4 /* gate */
#define W 20 /* wall */
/* Ряд 0: пол до колонки 4, решётка в колонке 5, пол дальше — «коридор с
* решёткой» уровня 1 (комната 5) в чистом виде. */
static const uint8_t room_gate[30] = {
F, F, F, F, F, G, F, F, F, F,
W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W,
};
/* Тот же ряд, но вместо решётки обычный пол — эталон «свободного» края. */
static const uint8_t room_floor[30] = {
F, F, F, F, F, F, F, F, F, F,
W, W, W, W, W, W, W, W, W, W,
W, W, W, W, W, W, W, W, W, W,
};
/* Кид на колонке 4 лицом вправо, решётка (или пол) — следующим тайлом.
* modif — «открытость» ворот: 188 = подняты (проход открыт), 0 = опущены.
* Возврат — distance, тип края остаётся в pop_edge_type(). */
static int edge_at_gate_x(const uint8_t *fg, uint8_t modif, uint8_t x)
{
int d;
sc_room(fg, 1);
tk_modif[5] = modif;
if (x) sc_kid_at_x(4, 0, x, 0 /* лицом вправо */);
else sc_kid_at(4, 0, 0);
/* «Открыты ли ворота» решается сравнением подъёма створки с ВЫСОТОЙ
* КАДРА персонажа (can_bump_into_gate, seg004:0373). В продукте её
* пишет отрисовка (pop_cdraw), которой в наборе нет, — значит задаём
* сами. Порог важен только тем, что 6 (опущенная створка) под
* высотой кадра, а 53 (поднятая) над ней. */
pop_cd[POP_CH_KID].fpw = 12;
pop_cd[POP_CH_KID].fph = 41; /* габарит стойки, снят в MAME */
pop_loadkid();
d = pop_edge_distance();
pop_savekid();
return d;
}
static int edge_at_gate(const uint8_t *fg, uint8_t modif)
{
return edge_at_gate_x(fg, modif, 0);
}
/* ОТКРЫТАЯ решётка — не препятствие: и расстояние, и тип края обязаны
* совпасть с обычным полом, иначе осторожный шаг окажется коротким. */
TC_TEST(open_gate_is_not_a_wall)
{
int d_floor, d_gate;
uint8_t t_floor, t_gate;
d_floor = edge_at_gate(room_floor, 0);
t_floor = pop_edge_type();
d_gate = edge_at_gate(room_gate, 188);
t_gate = pop_edge_type();
TC_EQ(d_gate, d_floor);
TC_EQ(t_gate, t_floor);
}
/* ЗАКРЫТАЯ решётка обязана остаться стеной — иначе фикс выше открыл бы
* проход сквозь опущенные ворота. */
/* ЗАКРЫТАЯ решётка обязана остаться стеной: фикс выше не должен открывать
* проход сквозь опущенные ворота. X берём у самой створки — в центре тайла
* она ещё «далеко» и честно классифицируется как пол. */
TC_TEST(closed_gate_is_a_wall)
{
int d = edge_at_gate_x(room_gate, 0, 136);
TC_EQ(pop_edge_type(), EDGE_WALL);
TC_EQ(d < 11, 1);
}
/* ...а ОТКРЫТАЯ на том же X ведёт себя как чистый пол — это и есть длина
* осторожного шага: 11, а не обрезок до створки. */
TC_TEST(open_gate_steps_like_floor)
{
int d_gate, d_floor;
uint8_t t_gate, t_floor;
d_gate = edge_at_gate_x(room_gate, 188, 136);
t_gate = pop_edge_type();
d_floor = edge_at_gate_x(room_floor, 0, 136);
t_floor = pop_edge_type();
TC_EQ(d_gate, d_floor);
TC_EQ(t_gate, t_floor);
}
/* ---- check_gate_push (seg004:0833) --------------------------------- *
* Кид стоит в тайле решётки, решётка опускается — оригинал ВЫТАЛКИВАЕТ его
* из тайла (иначе Кид «в суперпозиции»: створка рисуется то перед ним, то
* за ним, и уйти он может в обе стороны). Прогоняем кадр физики и смотрим,
* сдвинулся ли X. */
static int8_t push_col; /* curr_col, с которым сработал толчок */
static int push_from_dir(uint8_t x, uint8_t modif, int8_t dir)
{
sc_room(room_gate, 1);
tk_modif[5] = modif;
/* Габарит ПОСЛЕДНЕГО НАРИСОВАННОГО кадра — его в продукте пишет
* отрисовка (pop_cdraw), которой в наборе нет. Значения сняты с живой
* сцены в MAME для стойки Кида: ширина 12, высота 41. */
pop_cd[POP_CH_KID].fpw = 12;
pop_cd[POP_CH_KID].fph = 41;
sc_kid_at_x(5, 0, x, dir);
sc_run(0, 1); /* кадр «стоим» — флаги перекрытия за прошлый */
push_col = Kid.curr_col;
sc_run(0, 1); /* ...и за этот: check_gate_push смотрит оба */
return (int)Kid.x - (int)x;
}
static int push_from(uint8_t x, uint8_t modif)
{
return push_from_dir(x, modif, 0);
}
/* СТОРОНА толчка — формула оригинала `Char.x += 5 - (orig_col <= tile_col) * 10`:
* пока тайл ПЕРСОНАЖА и есть решётка, его выталкивает ВЛЕВО (−5); как только
* персонаж отнесён к следующей колонке (решётка стала левым соседом) — ВПРАВО
* (+5). Граница между сторонами, стало быть, ровно там, где меняется
* curr_col, и проверяется здесь на обоих направлениях взгляда.
*
* Решётка — колонка 5, значит push_col == 5 обязан давать 5, push_col == 6
* (+5), а вне зоны касания створки толчка нет вовсе. */
static uint8_t check_side(int8_t dir, uint8_t *nleft, uint8_t *nright)
{
uint8_t x, bad = 0;
for (x = 130; x <= 162; x++) {
int dx = push_from_dir(x, 0, dir);
if (dx == 0) continue;
/* Величина — кратная 5: пока персонаж остаётся в зоне створки,
* толчок повторяется каждый кадр (так же и в оригинале), поэтому
* проверяем СТОРОНУ и шаг, а не одно конкретное число. */
if (push_col == 5) { if (dx >= 0 || dx % 5) bad++; else (*nleft)++; }
else if (push_col == 6) { if (dx <= 0 || dx % 5) bad++; else (*nright)++; }
else bad++; /* толчок вне тайлов решётки — ошибка */
}
return bad;
}
TC_TEST(closing_gate_pushes_kid)
{
uint8_t nleft = 0, nright = 0, bad;
bad = check_side(0, &nleft, &nright); /* лицом вправо */
bad += check_side(-1, &nleft, &nright); /* лицом влево */
TC_EQ(bad, 0);
TC_EQ(nleft > 0, 1); /* обе стороны реально есть */
TC_EQ(nright > 0, 1);
}
/* ОТКРЫТУЮ решётку выталкивание не трогает вовсе (can_bump_into_gate). */
TC_TEST(open_gate_does_not_push)
{
uint8_t x, moved = 0;
for (x = 130; x <= 162; x++)
if (push_from_dir(x, 188, 0) != 0) moved++;
TC_EQ(moved, 0);
}
int main(void)
{
sc_init();
TC_RUN(open_gate_is_not_a_wall);
TC_RUN(closed_gate_is_a_wall);
TC_RUN(open_gate_steps_like_floor);
TC_RUN(closing_gate_pushes_kid);
TC_RUN(open_gate_does_not_push);
return 0;
}
@@ -21,6 +21,7 @@
#include "stubs.h"
#include "pop_kid.h"
#include "pop_map.h"
#include "pop_ctrl.h"
#include "pop_guard.h" /* pop_do_delta_hp — урон сводится дельтой */
/* Коды тайлов (pop_map): 0 пусто, 1 пол, 3 колонна, 11 loose, 20 стена. */
@@ -166,6 +167,19 @@ TC_TEST(phys_turn_around)
SC_CHECK(golden_turn, "turn");
}
TC_TEST(demo_backward_is_not_screen_mirrored)
{
/* demo_moves уже задаёт forward/backward относительно Кида. На плите
* 0,6 он смотрит вправо, и move=2 обязан начать seq_5 turn, а не
* превратиться через user_control() в seq_1 run вправо к пропасти. */
sc_room(room_flat, 1);
sc_kid_at(4, 1, 0);
pop_ctrl_auto_move(0);
pop_ctrl_auto_move(2);
/* SEQTBL_BASE+0x01CB — начало seq_5 в исходном kid_data.bin. */
TC_EQ((uint16_t)(Kid.curr_seq - SEQTBL_BASE), 0x01CB);
}
TC_TEST(phys_crouch_and_stand_up)
{
sc_room(room_flat, 1);
@@ -354,6 +368,7 @@ void main(void)
TC_RUN(phys_run_right);
TC_RUN(phys_step_with_shift);
TC_RUN(phys_turn_around);
TC_RUN(demo_backward_is_not_screen_mirrored);
TC_RUN(phys_crouch_and_stand_up);
TC_RUN(phys_jump_up_nothing_above);
TC_RUN(phys_run_off_ledge);
@@ -0,0 +1,116 @@
/* pop_timer — логические тики, pause/unlimited, читы и QuickSave state. */
#include <stdint.h>
#include "tcheck.h"
#include "pop_timer.h"
/* Минимальный memory-stream вместо EMM. pop_timer_qsave использует ровно
* u8/u16, поэтому этот stub одновременно проверяет его endian-контракт. */
static uint8_t stream[3];
static uint8_t stream_pos;
void pop_qs_u8(pop_qs_io_t *io, uint8_t *value)
{
if (io->reading) {
if (io->apply) *value = stream[stream_pos];
} else {
stream[stream_pos] = *value;
}
++stream_pos;
}
void pop_qs_u16(pop_qs_io_t *io, uint16_t *value)
{
uint8_t lo, hi;
if (io->reading) {
lo = stream[stream_pos++]; hi = stream[stream_pos++];
if (io->apply) *value = (uint16_t)(lo | ((uint16_t)hi << 8));
} else {
stream[stream_pos++] = (uint8_t)*value;
stream[stream_pos++] = (uint8_t)(*value >> 8);
}
}
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_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
TC_FALSE(pop_timer_tick(1, 0)); /* 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_EQ(pop_timer_minutes, 2);
TC_EQ(pop_timer_ticks, 1);
TC_FALSE(pop_timer_tick(1, 1));
TC_EQ(pop_timer_minutes, 1);
TC_EQ(pop_timer_ticks, POP_TIMER_START_TICKS);
}
TC_TEST(timer_expiry_is_one_shot)
{
pop_timer_minutes = 1;
pop_timer_ticks = 1;
TC_TRUE(pop_timer_tick(1, 1));
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_TEST(timer_cheats_match_sdlpop_rules)
{
pop_timer_minutes = 5;
pop_timer_cheat_subtract();
TC_EQ(pop_timer_minutes, 4);
pop_timer_minutes = 1;
pop_timer_cheat_subtract();
TC_EQ(pop_timer_minutes, 1); /* minus не создаёт expiry */
pop_timer_minutes = 0;
pop_timer_cheat_add();
TC_EQ(pop_timer_minutes, 1);
pop_timer_minutes = 0xFF;
pop_timer_cheat_add();
TC_EQ(pop_timer_minutes, 0xFF); /* не сворачиваемся в 0 */
}
TC_TEST(timer_qsave_restores_exact_state_and_rejects_bad_tick)
{
pop_qs_io_t io;
pop_timer_minutes = 42;
pop_timer_ticks = 103;
stream_pos = 0;
io.reading = io.apply = io.failed = 0;
pop_timer_qsave(&io);
TC_EQ(stream_pos, 3);
pop_timer_minutes = 7;
pop_timer_ticks = 400;
stream_pos = 0;
io.reading = io.apply = 1; io.failed = 0;
pop_timer_qsave(&io);
TC_FALSE(io.failed);
TC_EQ(pop_timer_minutes, 42);
TC_EQ(pop_timer_ticks, 103);
stream[0] = 9; stream[1] = 0; stream[2] = 0; /* tick 0 — impossible */
pop_timer_minutes = 33;
pop_timer_ticks = 444;
stream_pos = 0; io.failed = 0;
pop_timer_qsave(&io);
TC_TRUE(io.failed);
TC_EQ(pop_timer_minutes, 33);
TC_EQ(pop_timer_ticks, 444);
}
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);
}