testkit: модульные тесты под ucsim_z80 + планы покрытия
Обвязка для быстрых тестов plain-C логики: секунды вместо прогона в MAME, без образа диска. ucsim_z80 идёт в комплекте нашего SDCC — новых зависимостей нет. ПОЧЕМУ ПОД Z80, А НЕ ХОСТОВЫМ GCC. У SDCC z80 int 16 бит, у хоста 32, и расходится это НЕ в объявлениях, а в выражениях: integer promotion повышает операнды до int независимо от того, объявлены они как uint8_t или uint16_t. Перевод кода на фиксированные типы разницу не убирает — убирает только исполнение с z80-семантикой. Побочно проверяется кодогенерация SDCC и модули с inline-asm, которых хостовая сборка не видит в принципе. Устройство: crt0_ucsim.s (SP, зануление, main, halt), tcheck.* (итог в структуру в ОЗУ), run_ucsim.py (гоняет ucsim, дампит tc_result, печатает отчёт), host-tests.mk (общие правила). Вывода через printf нет: тестовый бинарь линкуется без Sprinter-libc. Через ucsim-simif не идём — номера его команд плавают между версиями, halt + dump работают везде. Наборы лежат РЯДОМ с проверяемым кодом, обвязка общая: testkit/t_selftest.c — самопроверка (sizeof(int)==2) applications/PoP/roomtest/tests-host/ — движок PoP Первый содержательный набор — t_geom: сверяет рукописный asm-LCG из pop_geom.c с наивной 32-битной формулой на 128 шагах. Заявка «бит-в-бит как в SDLPoP» до сих пор держалась на комментарии. Тест проверен мутацией: порча эталонной константы даёт красный. Планы дальнейшего покрытия: docs/host-tests-plan.md — libc и libbgi (не начато) applications/PoP/docs/host_tests_plan.md — движок PoP Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
| [`levels_plan.md`](levels_plan.md) | Следующий этап: уровни 2+, второй тайлсет, читы SDLPoP |
|
||||
| [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки |
|
||||
| [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди |
|
||||
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии из `bug_closed.md`, дифф против SDLPoP |
|
||||
| [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) |
|
||||
| [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра |
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# План: модульные тесты движка roomtest под ucsim_z80
|
||||
|
||||
Обвязка общая — `testkit/` в корне репозитория (там же объяснение, почему
|
||||
прогон именно под z80, а не хостовым gcc). Наборы лежат в
|
||||
`../roomtest/tests-host/`.
|
||||
|
||||
Задача плана: **перестать чинить одно и то же дважды**. За два прогона
|
||||
уровня 1 (2026-08-03) закрыто восемь корней, и часть из них — регрессии
|
||||
соседней механики, внесённые предыдущим фиксом. Такие вещи ловятся тестом
|
||||
за миллисекунды, а в MAME — часами ручного вождения Кида.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
| набор | модуль | статус |
|
||||
|-------|--------|--------|
|
||||
| `t_geom` | `pop_geom.c` | 39 проверок, включая побитовую сверку asm-LCG с 32-битной формулой на 128 шагах |
|
||||
|
||||
`pop_geom.c` выбран первым, потому что не тянет ничего за собой. Дальше
|
||||
начинаются швы.
|
||||
|
||||
## Фаза 1. Два шва (блокирует всё остальное)
|
||||
|
||||
### 1.1 Доступ к странице уровня
|
||||
|
||||
`pop_level.c` ходит по абсолютным адресам: `gfx_w0_map(lvl_page)`, затем
|
||||
разыменование `(uint8_t *)(LVL_DATA_OFF + …)`. В тестовом бинаре это
|
||||
обращение в никуда.
|
||||
|
||||
Нужен макрос `W0PTR(off)`:
|
||||
|
||||
- на таргете — `((uint8_t *)(off))`, то есть ровно как сейчас;
|
||||
- в тестах — смещение в обычном массиве-подложке.
|
||||
|
||||
Правка механическая и компайл-таймовая, на размер продукта не влияет.
|
||||
Заодно снимает магию абсолютных констант из тела функций.
|
||||
|
||||
Тестовая подложка должна уметь: загрузить синтетическую комнату (10×3
|
||||
байта fg + mod) и целый синтетический уровень на 24 комнаты, чтобы
|
||||
проверять межкомнатные вещи.
|
||||
|
||||
### 1.2 Журналирующий рендерер
|
||||
|
||||
Вместо `pop_bg.c`/`pop_gdraw.c` в тестовый бинарь линкуется модуль с теми
|
||||
же прототипами, который **не рисует, а записывает вызовы**: какой тайл
|
||||
помечен к перерисовке, каким кодом, с каким счётчиком страниц.
|
||||
|
||||
Это не обход проблемы, а самостоятельная ценность: `BUG-GATE-ANIM-1` был
|
||||
ровно такой формы — ворота меняли состояние, но пометка на перерисовку не
|
||||
ставилась. Проверяется утверждением, а не глазами.
|
||||
|
||||
Минимум, который надо перехватывать: `pop_set_redraw`,
|
||||
`pop_set_redraw_above`, `pop_loose_mob_spawn`, `pop_gate_redraw`.
|
||||
|
||||
## Фаза 2. Регрессионные кейсы из `bug_closed.md`
|
||||
|
||||
После швов `bug_closed.md` превращается в готовую спецификацию: у каждой
|
||||
записи есть симптом и ожидаемое поведение. Кандидаты, которые ловятся
|
||||
логикой (без отрисовки и без железа):
|
||||
|
||||
| баг | что закрепить тестом |
|
||||
|-----|----------------------|
|
||||
| `BUG-LVLSTATE-1` | запись тайла переживает выход из комнаты |
|
||||
| `BUG-RESPAWN-1` | рестарт уровня возвращает ВСЕ тайлы из эталонной копии |
|
||||
| `BUG-RESPAWN-2` | рестарт возвращает таблицу стражей; убитый снова жив |
|
||||
| `BUG-GATE-ANIM-1` | смена состояния ворот ставит пометку `POP_RD_GATE`; закрывающиеся — на обе страницы, открывающиеся — на одну |
|
||||
| `BUG-COLL-1` | `check_collisions` сканирует ряд справа налево и выбирает НАИМЕНЬШУЮ занятую колонку |
|
||||
| `BUG-STANDUP-1` | `bumped_floor` у трупа (`alive >= 0`) только выравнивает и не трогает последовательность |
|
||||
| `BUG-DEATH-1` | `hitp_curr == 0` при живом Киде переводит его в «умирает» ровно один раз |
|
||||
| `BUG-LOOSE-2` | кусок, начавший падать, долетает и кладёт щебень ПОСЛЕ смены комнаты |
|
||||
| `BUG-CEIL-2` | loose-плита ряда 2 верхнего соседа живёт как «ряд −1» |
|
||||
|
||||
`BUG-LOOSE-2` стоит взять первым: он до сих пор помечен в `bug_list.md`
|
||||
как непроверенный именно потому, что гонку «уйти из комнаты раньше, чем
|
||||
долетит плита» через мост MAME воспроизвести не удалось. На уровне логики
|
||||
это несколько строк — заспавнить кусок, сменить комнату, тикать до
|
||||
приземления, проверить щебень в данных уровня.
|
||||
|
||||
Не берутся (нужна картинка либо железо): `BUG-DOOR-CLIP`, `BUG-CEIL-1`,
|
||||
`BUG-CEIL-3`, `BUG-OCCL-1`, `BUG-KBD-4`, `BUG-3`.
|
||||
|
||||
## Фаза 3. Сценарные тесты
|
||||
|
||||
Сейчас шаг кадра размазан по `main()` в `roomtest.c`. Вынести его в
|
||||
`pop_frame_tick()` — тогда появляются тесты вида «поставить Кида в
|
||||
известное состояние, скормить N тиков ввода, проверить итог»:
|
||||
|
||||
```
|
||||
дано: комната 5, Кид на кнопке (0,6)
|
||||
когда: 40 тиков без ввода
|
||||
тогда: комната по-прежнему 5, Кид на полу ряда 2
|
||||
```
|
||||
|
||||
Это тот самый BUG-STANDUP-1, который ловили потиковой трассой в MAME.
|
||||
|
||||
Ввод подаётся не через `kbd_raw_down()`, а через подменяемый источник —
|
||||
это же даст возможность проигрывать записанные сценарии.
|
||||
|
||||
## Фаза 4. Дифф против SDLPoP
|
||||
|
||||
`SDLPoP/src/` лежит в дереве, собирается на хосте, и там **уже стоят
|
||||
отладочные трассы** (`DBG kidobj tilepos=…` в seg008, `DBG make_loose_fall`
|
||||
в seg007). Значит эталон можно заставить печатать потиковую трассу
|
||||
автоматически.
|
||||
|
||||
Схема: общий формат скрипта ввода и общий формат трассы (тик, frame, x, y,
|
||||
room, col, row, action, alive, hp). Гоняем обе реализации, диффим, первое
|
||||
расхождение — номер тика и есть баг. Это ровно то, что делалось руками
|
||||
через MAME, только бесплатно и повторяемо: `BUG-COLL-1` и `BUG-STANDUP-1`
|
||||
такой дифф нашёл бы за секунды.
|
||||
|
||||
**Лицензия.** SDLPoP — GPLv3, правило подпроекта — читать и переписывать,
|
||||
не линковать. Оракул обязан быть **отдельным исполняемым файлом**,
|
||||
общающимся через файлы трасс, а не слинкованным с нашим кодом в один
|
||||
бинарь.
|
||||
|
||||
Требование к детерминизму: сиды PRNG должны совпадать. У нас
|
||||
`POP_PRANDOM_EXACT` даёт ту же последовательность, что в оригинале, и это
|
||||
уже закреплено тестом `geom_lcg_matches_reference`.
|
||||
|
||||
## Чего эти тесты не поймают
|
||||
|
||||
Отрисовку, банки и W-окна, тайминги, клавиатуру — за этим остаётся MAME.
|
||||
|
||||
И отдельный класс: **баги порядка вызовов**. Свежий пример — окно
|
||||
fore-клипа (`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто
|
||||
рисует персонажа; когда порядок «Кид/страж» стал переменным, окно осталось
|
||||
стражьим, и Кид нарисовался поверх передних столбов. Это не «функция
|
||||
вернула не то», unit-тест такое не видит. Ловится инвариантом,
|
||||
вкомпилированным в safe-сборку: «в момент `pop_fore_over_kid` окно клипа
|
||||
принадлежит Киду». Отдельный инструмент, дополняющий тесты.
|
||||
|
||||
## Порядок работ
|
||||
|
||||
1. Шов `W0PTR` + подложка уровня.
|
||||
2. Журналирующий рендерер.
|
||||
3. `BUG-LOOSE-2` — закрыть висящий вопрос.
|
||||
4. Остальные кейсы из таблицы фазы 2.
|
||||
5. `pop_frame_tick()` + сценарные тесты.
|
||||
6. Дифф против SDLPoP.
|
||||
|
||||
Правило приёмки: тест не считается написанным, пока не проверен мутацией —
|
||||
сломать проверяемое место и убедиться, что набор краснеет.
|
||||
@@ -21,8 +21,13 @@
|
||||
```
|
||||
make # собрать roomtest.exe (упаковав ассеты через toolchain/)
|
||||
make run # exe + EXTRA_DATA на дискету + запуск MAME (см. корневой док)
|
||||
make -C tests-host # модульные тесты движка под ucsim_z80 (секунды, без MAME)
|
||||
```
|
||||
|
||||
Логику, которую можно проверить без железа, покрывать в `tests-host/`
|
||||
(обвязка — `testkit/`, там же почему прогон именно под z80). MAME остаётся
|
||||
для отрисовки, банков, таймингов и клавиатуры.
|
||||
|
||||
`MEMORY=small`, `--gfx 256`. Ассеты (`pop_env0..4.atl`, `pop_wall.atl`,
|
||||
`pop_fore.atl`, `kid0..27.atl`, `kid.pal`) генерируются python-скриптами
|
||||
`../toolchain/` — Makefile дёргает их сам при изменении. Данные комнаты —
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,8 @@
|
||||
# Модульные тесты движка roomtest под ucsim_z80. Обвязка общая (testkit/),
|
||||
# здесь — только сами наборы и список модулей, которые в них линкуются.
|
||||
TESTKIT := $(abspath $(CURDIR)/../../../../testkit)
|
||||
ENGINE_DIR := $(abspath $(CURDIR)/..)
|
||||
|
||||
OBJS_geom := build/eng_pop_geom.rel
|
||||
|
||||
include $(TESTKIT)/host-tests.mk
|
||||
@@ -0,0 +1,46 @@
|
||||
# tests-host — модульные тесты движка roomtest
|
||||
|
||||
Прогоняются под `ucsim_z80`, обвязка общая — `testkit/` (там же объяснение,
|
||||
почему под z80, а не хостовым gcc, и грабли).
|
||||
|
||||
```
|
||||
make host-tests # из корня, вместе с остальными
|
||||
make -C applications/PoP/roomtest/tests-host # только эти
|
||||
make -C applications/PoP/roomtest/tests-host t_geom
|
||||
```
|
||||
|
||||
## Что покрыто
|
||||
|
||||
| набор | модуль | что проверяет |
|
||||
|-------|--------|---------------|
|
||||
| `t_geom` | `pop_geom.c` | геометрия комнаты (`x_bump`, `y_land`, `y_to_row`) и PRNG оригинала |
|
||||
|
||||
Главный тест в `t_geom` — `geom_lcg_matches_reference`. LCG оригинала
|
||||
(`s = s*214013 + 2531011`) написан в `pop_geom.c` на ассемблере по схеме
|
||||
Горнера ради обхода `__mullong`, и заявка «бит-в-бит как в SDLPoP» до
|
||||
появления теста держалась только на комментарии. Тест сверяет рукописный
|
||||
asm с наивной 32-битной формулой на 128 шагах — и по возвращаемому
|
||||
значению, и по обеим половинам сида.
|
||||
|
||||
## Что нужно, чтобы двинуться дальше
|
||||
|
||||
Следующие на очереди модули (`pop_trob`, `pop_level`, `pop_map`, логика
|
||||
падающих плит) упираются в два шва, и оба затрагивают продукт:
|
||||
|
||||
1. **Доступ к странице уровня.** `pop_level.c` ходит по абсолютным адресам
|
||||
(`(uint8_t *)(LVL_DATA_OFF + …)` после `gfx_w0_map`) — в тестах это
|
||||
обращение в никуда. Нужен макрос вида `W0PTR(off)`: на таргете как
|
||||
сейчас, в тестах — смещение в обычном массиве.
|
||||
2. **Журналирующий рендерер** вместо `pop_bg`/`pop_gdraw` — не заглушка, а
|
||||
запись вызовов, чтобы проверять «какой тайл помечен к перерисовке, каким
|
||||
кодом, с каким счётчиком страниц». `BUG-GATE-ANIM-1` был ровно такой
|
||||
формы.
|
||||
|
||||
После этого `bug_closed.md` превращается в готовый список регрессионных
|
||||
кейсов: у каждой записи там есть симптом и ожидаемое поведение.
|
||||
|
||||
Отдельно стоит завести тест на `BUG-LOOSE-2` — он до сих пор помечен в
|
||||
`bug_list.md` как непроверенный именно потому, что гонку «уйти из комнаты
|
||||
раньше, чем долетит плита» через мост MAME воспроизвести не удалось. На
|
||||
уровне логики это несколько строк: заспавнить кусок, сменить комнату,
|
||||
тикать до приземления, проверить щебень в данных уровня.
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* t_geom.c — тесты pop_geom.c (геометрия комнаты + PRNG оригинала).
|
||||
*
|
||||
* Модуль выбран первым, потому что не тянет за собой ничего: ни графики,
|
||||
* ни данных уровня, ни клавиатуры — только свой заголовок.
|
||||
*
|
||||
* Главный тест здесь — geom_lcg_matches_reference. В pop_geom.c LCG
|
||||
* оригинала (s = s*214013 + 2531011) написан на ассемблере по схеме
|
||||
* Горнера ради обхода __mullong, и заявка «бит-в-бит как в SDLPoP» до сих
|
||||
* пор держалась только на комментарии. Тест сверяет рукописный asm с
|
||||
* наивной 32-битной формулой на каждом шаге — и по возвращаемому значению,
|
||||
* и по обеим половинам сида.
|
||||
*/
|
||||
#include "tcheck.h"
|
||||
#include "pop_geom.h"
|
||||
|
||||
/* ---- геометрия ------------------------------------------------------ */
|
||||
|
||||
TC_TEST(geom_xbump_layout)
|
||||
{
|
||||
/* Шаг колонок равномерный и равен TILE_SIZEX — на этом стоит вся
|
||||
* арифметика колонок (x_bump[col + FIRST_ONSCREEN_COLUMN]). */
|
||||
uint8_t i;
|
||||
for (i = 1; i < 20; i++)
|
||||
TC_EQ(pop_x_bump[i] - pop_x_bump[i - 1], TILE_SIZEX);
|
||||
|
||||
/* Центр тайла колонки 0 — та самая величина, которую кладут в Char.x
|
||||
* do_startpos и pos_guards (seg003). */
|
||||
TC_EQ(pop_x_bump[0 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 72);
|
||||
TC_EQ(pop_x_bump[9 + FIRST_ONSCREEN_COLUMN] + TILE_SIZEX, 198);
|
||||
}
|
||||
|
||||
TC_TEST(geom_yland_rows)
|
||||
{
|
||||
/* y_land[row + 1]; [0] — ряд «над комнатой». */
|
||||
TC_EQ(pop_y_land[0], -8);
|
||||
TC_EQ(pop_y_land[1], 55);
|
||||
TC_EQ(pop_y_land[2], 118);
|
||||
TC_EQ(pop_y_land[3], 181);
|
||||
TC_EQ(pop_y_land[4], 244);
|
||||
/* Шаг между рядами — TILE_SIZEY. */
|
||||
TC_EQ(pop_y_land[2] - pop_y_land[1], TILE_SIZEY);
|
||||
TC_EQ(pop_y_land[3] - pop_y_land[2], TILE_SIZEY);
|
||||
}
|
||||
|
||||
TC_TEST(geom_y_to_row)
|
||||
{
|
||||
/* Пол каждого ряда должен давать номер этого ряда. */
|
||||
TC_EQ(pop_y_to_row(pop_y_land[1]), 0);
|
||||
TC_EQ(pop_y_to_row(pop_y_land[2]), 1);
|
||||
TC_EQ(pop_y_to_row(pop_y_land[3]), 2);
|
||||
/* Ряд над комнатой. */
|
||||
TC_EQ(pop_y_to_row(pop_y_land[0]), -1);
|
||||
/* Оборот mod 4: ряд «под комнатой» сворачивается обратно в -1 —
|
||||
* ровно на это опирается спавн падающего куска у нижней кромки. */
|
||||
TC_EQ(pop_y_to_row(pop_y_land[4]), -1);
|
||||
}
|
||||
|
||||
/* ---- PRNG: сверка asm-LCG с эталонной формулой ---------------------- */
|
||||
|
||||
#if POP_PRANDOM_EXACT
|
||||
|
||||
#define LCG_ITERS 128
|
||||
|
||||
TC_TEST(geom_lcg_matches_reference)
|
||||
{
|
||||
pop_rnd_t s;
|
||||
unsigned long ref = 12345UL; /* произвольный ненулевой старт */
|
||||
uint16_t i, got, want;
|
||||
|
||||
pop_prandom_set(s, 12345u);
|
||||
|
||||
for (i = 0; i < LCG_ITERS; i++) {
|
||||
got = pop_prandom(&s, 255u);
|
||||
ref = ref * 214013UL + 2531011UL;
|
||||
want = (uint16_t)((uint16_t)(ref >> 16) % 256u);
|
||||
|
||||
/* Обрываемся на первом расхождении: иначе одна ошибка в умножении
|
||||
* забила бы весь буфер отчёта однотипными строками. */
|
||||
if (got != want ||
|
||||
s.lo != (uint16_t)ref ||
|
||||
s.hi != (uint16_t)(ref >> 16)) {
|
||||
TC_EQ(got, want);
|
||||
TC_EQ(s.lo, (uint16_t)ref);
|
||||
TC_EQ(s.hi, (uint16_t)(ref >> 16));
|
||||
break;
|
||||
}
|
||||
}
|
||||
TC_EQ(i, LCG_ITERS); /* прошли всю дистанцию без расхождений */
|
||||
}
|
||||
|
||||
TC_TEST(geom_lcg_seed_zero)
|
||||
{
|
||||
/* Ноль — законный сид (кладку сеют номером комнаты + ряд + колонка,
|
||||
* что вполне даёт 0). У LCG ноль не является неподвижной точкой —
|
||||
* убеждаемся, что asm это воспроизводит. */
|
||||
pop_rnd_t s;
|
||||
unsigned long ref = 0UL;
|
||||
pop_prandom_set(s, 0u);
|
||||
pop_prandom(&s, 255u);
|
||||
ref = ref * 214013UL + 2531011UL;
|
||||
TC_EQ(s.lo, (uint16_t)ref);
|
||||
TC_EQ(s.hi, (uint16_t)(ref >> 16));
|
||||
}
|
||||
|
||||
#endif /* POP_PRANDOM_EXACT */
|
||||
|
||||
TC_TEST(geom_prandom_range)
|
||||
{
|
||||
/* Оба генератора обязаны укладываться в 0..maxv, в том числе когда
|
||||
* maxv+1 НЕ степень двойки (там другая ветка pop_rnd_fit). */
|
||||
pop_rnd_t s;
|
||||
uint16_t i, v, seen_hi = 0;
|
||||
|
||||
uint16_t out_of_range = 0;
|
||||
|
||||
pop_prandom_set(s, 1u);
|
||||
for (i = 0; i < 200; i++) {
|
||||
v = pop_prandom(&s, 4u); /* n = 5, не степень двойки */
|
||||
if (v > 4) out_of_range++;
|
||||
if (v == 4) seen_hi = 1;
|
||||
}
|
||||
TC_EQ(out_of_range, 0);
|
||||
TC_TRUE(seen_hi); /* верхняя граница достижима */
|
||||
|
||||
pop_prandom_set(s, 7u);
|
||||
out_of_range = 0;
|
||||
for (i = 0; i < 64; i++) {
|
||||
v = pop_prandom(&s, 1u); /* n = 2, маска */
|
||||
if (v > 1) out_of_range++;
|
||||
}
|
||||
TC_EQ(out_of_range, 0);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
TC_RUN(geom_xbump_layout);
|
||||
TC_RUN(geom_yland_rows);
|
||||
TC_RUN(geom_y_to_row);
|
||||
#if POP_PRANDOM_EXACT
|
||||
TC_RUN(geom_lcg_matches_reference);
|
||||
TC_RUN(geom_lcg_seed_zero);
|
||||
#endif
|
||||
TC_RUN(geom_prandom_range);
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user