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:
@@ -39,7 +39,7 @@ DATA_FILES := \
|
||||
examples/mdview/SAMPLE.MD
|
||||
|
||||
.PHONY: all tools lib tests examples check clean sdcc floppy \
|
||||
size-check size-baseline $(TESTS) $(APPS)
|
||||
size-check size-baseline host-tests $(TESTS) $(APPS)
|
||||
|
||||
all: tools lib tests examples
|
||||
|
||||
@@ -73,6 +73,14 @@ floppy: tests examples tests/seek/big.txt
|
||||
@echo "Floppy ready: $(FLOPPY_IMG)"
|
||||
@echo "Run: cd $(MAME_DIR) && ./run_mame.sh"
|
||||
|
||||
# Модульные тесты под ucsim_z80. Обвязка — testkit/, сами наборы лежат
|
||||
# рядом с кодом, который проверяют. MAME не нужна, идут за секунды;
|
||||
# ucsim идёт в комплекте нашего SDCC.
|
||||
HOST_TEST_DIRS := testkit applications/PoP/roomtest/tests-host
|
||||
|
||||
host-tests:
|
||||
@for d in $(HOST_TEST_DIRS); do $(MAKE) -C $$d || exit 1; done
|
||||
|
||||
# Размерный регресс: сверить _CODE всех программ с docs/size_baseline.tsv.
|
||||
size-check:
|
||||
python3 toolchain/size_check.py
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,6 +5,15 @@
|
||||
|
||||
## Ближайшее
|
||||
|
||||
- [ ] **Модульные тесты libc и libbgi под ucsim_z80** — план:
|
||||
`docs/host-tests-plan.md`. Обвязка уже готова и обкатана
|
||||
(`testkit/`, первый потребитель — PoP/roomtest). Не срочно, но
|
||||
обязательно: это основной продукт репозитория, а покрыт он сейчас
|
||||
только интеграционными тестами в MAME. Начинать с `time/` и
|
||||
`stdio/` (нулевые швы), затем геометрия `libbgi/common`, затем
|
||||
фейковые ESTEX/BIOS в тестовом crt0 — они открывают `file/`, `io/`,
|
||||
`conio/` и проверку гардов (`_fd_guard`).
|
||||
|
||||
Порядок реализации связки: **сначала цепочка irq, потом FPS-делитель
|
||||
поверх неё** (делитель — просто один слот цепи; так снимается EBUSY
|
||||
на сосуществование со своим тиком приложения).
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# План: модульные тесты libc и libbgi под ucsim_z80
|
||||
|
||||
Статус: **не начато**, задача на будущее. Обвязка уже готова и обкатана —
|
||||
`testkit/` (см. `testkit/README.md`); первый потребитель —
|
||||
`applications/PoP/roomtest/tests-host/`. Этот документ — про то, как накрыть
|
||||
тем же способом основной продукт репозитория.
|
||||
|
||||
## Что это НЕ заменяет
|
||||
|
||||
В `tests/` уже лежат 62 каталога — это **интеграционные** тесты: одна фича =
|
||||
одна программа, которая пакуется на дискету и гоняется в MAME
|
||||
(`docs/mame-autotest.md`). Они проверяют, что API работает на живой машине:
|
||||
ESTEX, BIOS, диск, экран, тайминги.
|
||||
|
||||
Модульные тесты их не отменяют, а дополняют с другой стороны:
|
||||
|
||||
| | `tests/` (MAME) | `tests-host` (ucsim) |
|
||||
|---|---|---|
|
||||
| что проверяет | работает ли на машине | верна ли логика |
|
||||
| граничные случаи | 1–2 на фичу | десятки, дёшево |
|
||||
| время прогона | десятки секунд | миллисекунды |
|
||||
| ловит | железо, тайминги, банки | арифметику, краевые условия, регрессии |
|
||||
|
||||
Правило разделения то же, что уже записано для PoP: **что можно проверить
|
||||
без железа — проверять в ucsim, MAME оставить железу.**
|
||||
|
||||
## Три группы модулей
|
||||
|
||||
### 1. Чистая логика — тестируется как есть, швов не нужно
|
||||
|
||||
Здесь можно начинать в тот же день, когда задачу возьмут в работу.
|
||||
|
||||
**libc:**
|
||||
|
||||
- `time/` — `_tm_is_leap`, `_tm_mdays`, `_tm_month_days`, `_tm_year_days`,
|
||||
`mktime`, `gmtime`, `localtime`, `asctime`, `ctime`. Классическая
|
||||
календарная арифметика: високосные годы, границы месяцев, переходы через
|
||||
год, круговой прогон `mktime(gmtime(t)) == t`. Идеальный первый набор —
|
||||
много краевых случаев и ноль зависимостей.
|
||||
- `stdio/` — `dec_print`, `hex8/16/32`, `_scanf_core`, `sscanf`. Формат и
|
||||
разбор: ширина, знак, переполнение, мусор на входе.
|
||||
- `string/`, `stdlib/` — `strlwr`, `strupr`, `max`, `min`.
|
||||
|
||||
**libbgi/common** (115 модулей, почти всё mode-agnostic):
|
||||
|
||||
- `_bgi_isqrt`, `_bgi_trig` — сверить с эталонной формулой на всём
|
||||
диапазоне, ровно как сделано для LCG в `t_geom` у PoP;
|
||||
- `_bgi_lineseg`, `_bgi_styled_line`, `_bgi_poly_edge`, `_bgi_arc_draw` —
|
||||
геометрия и отсечение: линия целиком вне окна, по диагонали через угол,
|
||||
вырожденная в точку;
|
||||
- `_spr_ysort` — порядок сортировки спрайтов;
|
||||
- `_bgi_hspan`, `_bgi_fill_span` — заливка: краевые span'ы, нулевая ширина.
|
||||
|
||||
### 2. Нужны швы — но швы дешёвые
|
||||
|
||||
**Фейковый ESTEX и BIOS прямо в тестовом crt0.** Оба вызываются через
|
||||
`rst #0x10` и `rst #0x08`, то есть через фиксированные векторы в первых
|
||||
байтах памяти. В тестовом бинаре эти адреса наши: можно положить туда
|
||||
обработчик, который эмулирует крошечную файловую систему в ОЗУ и текстовый
|
||||
экран в буфере. Это открывает:
|
||||
|
||||
- `file/` (32 модуля) и `io/` (21) — `fopen`/`fread`/`fwrite`/`fseek`,
|
||||
буферизация (`docs/file-buffering-design.md`), поведение на EOF и
|
||||
ошибках, `errno`;
|
||||
- `conio/` (38) — вывод в буфер вместо экрана, проверка атрибутов,
|
||||
скроллинга, границ окна.
|
||||
|
||||
Отдельно ценно: **гарды**, которые обязаны быть в обеих сборках. `_fd_guard`
|
||||
(девятый `OPEN` вешает DSS) — это ровно тот случай, где нужен тест, а не
|
||||
вера в комментарий: открыть восемь, убедиться, что девятый вернул ошибку и
|
||||
не дошёл до ESTEX.
|
||||
|
||||
**Кадровый буфер в ОЗУ для libbgi.** Если рисующие ядра умеют писать в
|
||||
обычный буфер, а не только в видеопамять, растеризацию можно проверять
|
||||
снимком: нарисовать фигуру, сравнить с эталонным массивом. Начинать с
|
||||
маленьких (8×8, 16×16) — эталон читаемый прямо в исходнике.
|
||||
|
||||
### 3. Только MAME
|
||||
|
||||
Банки и W-окна, EMM, реальный ESTEX/DSS, клавиатурный трамплин и IM2,
|
||||
CBL-звук (`cbl/`), мышь (`mouse/`), `mem/` (это банки и страницы, а не
|
||||
куча), тайминги и бюджет кадра, ускоритель.
|
||||
|
||||
## Обе сборки: fast и safe
|
||||
|
||||
Библиотеки собираются в двух вариантах (`-D*_NOCHECK` вырезает
|
||||
валидацию параметров). Наборы стоит гонять **против обоих**:
|
||||
|
||||
- в safe — что валидация ловит мусорные аргументы и ставит `errno`;
|
||||
- в fast — что вырезание валидации не поменяло поведение на корректных
|
||||
входах.
|
||||
|
||||
Это дешёвая параметризация Makefile (тот же набор, два `OBJS_*`), и она
|
||||
пресекает целый класс расхождений между вариантами.
|
||||
|
||||
## Фазы
|
||||
|
||||
1. **Календарь и формат.** `time/`, `stdio/`. Нулевые швы, максимальная
|
||||
плотность краевых случаев. Цель — обкатать поток работы на libc.
|
||||
2. **Геометрия libbgi.** `common/`: isqrt, тригонометрия, отсечение линий,
|
||||
рёбра полигонов. Тоже без швов.
|
||||
3. **Фейковые ESTEX/BIOS в crt0.** Открывает `file/`, `io/`, `conio/` и
|
||||
гарды. Самый крупный кусок работы и самая большая отдача.
|
||||
4. **Растр libbgi по снимкам** — если окажется, что ядра можно нацелить на
|
||||
буфер в ОЗУ без правок продукта; иначе отложить.
|
||||
5. **Параметризация fast/safe** — после того, как наборов станет заметно.
|
||||
|
||||
## Критерии
|
||||
|
||||
- Тест не считается написанным, пока не проверен мутацией: сломать
|
||||
проверяемое место, убедиться, что набор краснеет. Пустые тесты хуже
|
||||
отсутствующих.
|
||||
- Каждый закрытый баг libc/libbgi получает регрессионный кейс, если он
|
||||
ловится без железа.
|
||||
- `make host-tests` остаётся быстрым: если суммарно перевалит за несколько
|
||||
секунд, делить на быстрый и полный прогон.
|
||||
|
||||
## Организация
|
||||
|
||||
Наборы кладутся рядом с кодом, обвязка общая:
|
||||
|
||||
```
|
||||
libc/tests-host/ наборы по областям: t_time.c, t_stdio.c, …
|
||||
libbgi/tests-host/ t_geom.c, t_raster.c, …
|
||||
```
|
||||
|
||||
Каждому — `Makefile` на пять строк (`TESTKIT`, `ENGINE_DIR`, `OBJS_*`,
|
||||
`include`), и добавить каталог в `HOST_TEST_DIRS` корневого `Makefile`.
|
||||
Подробности — `testkit/README.md`.
|
||||
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,5 @@
|
||||
# Самопроверка обвязки: t_selftest.c. Модулей под тестом здесь нет —
|
||||
# проверяется, что бинарь собирается, исполняется под ucsim и что
|
||||
# семантика действительно z80 (sizeof(int) == 2).
|
||||
TESTKIT := $(CURDIR)
|
||||
include $(TESTKIT)/host-tests.mk
|
||||
@@ -0,0 +1,110 @@
|
||||
# testkit — модульные тесты под ucsim_z80
|
||||
|
||||
Обвязка для быстрых тестов plain-C кода: секунды вместо прогона в MAME, без
|
||||
образа диска и без эмуляции всего Sprinter.
|
||||
|
||||
Сама по себе к подпроектам не привязана — так можно тестировать и модули
|
||||
libc, и логику приложений. Наборы тестов лежат **рядом с кодом, который они
|
||||
проверяют**, а не здесь:
|
||||
|
||||
| каталог | что проверяет |
|
||||
|---------|---------------|
|
||||
| `testkit/` | самопроверка обвязки (`t_selftest.c`) |
|
||||
| `applications/PoP/roomtest/tests-host/` | движок PoP |
|
||||
|
||||
```
|
||||
make host-tests # из корня: все каталоги с наборами
|
||||
make -C testkit # только самопроверка обвязки
|
||||
make -C <каталог> t_geom # один набор
|
||||
```
|
||||
|
||||
## Почему под z80, а не хостовым gcc
|
||||
|
||||
Первым порывом было собирать те же `.c` обычным gcc — но у SDCC z80
|
||||
`int` 16 бит, а у хоста 32, и это расходится **не в объявлениях, а в
|
||||
выражениях**: integer promotion повышает операнды до `int` независимо от
|
||||
того, объявлены они как `uint8_t` или `uint16_t`.
|
||||
|
||||
```c
|
||||
uint16_t a = 60000, b = 10000;
|
||||
if (a + b > 65000) { … } // z80: 4464 > 65000 → false
|
||||
// host: 70000 > 65000 → true
|
||||
```
|
||||
|
||||
Перевод всего кода на `intN_t` эту разницу НЕ убирает. Убирает её только
|
||||
исполнение с z80-семантикой. Отсюда `ucsim_z80` — он идёт в комплекте
|
||||
нашего же SDCC (`third_party/sdcc/bin`), так что новых зависимостей нет.
|
||||
|
||||
Побочные выгоды того же решения:
|
||||
|
||||
- проверяется **кодогенерация SDCC**, а не только исходник (см. memory
|
||||
`sdcc_z80_cmp_store_a_bug`);
|
||||
- модули с inline-asm тестируются как есть — например `pop_geom.c`, где
|
||||
LCG оригинала написан на ассемблере ради обхода `__mullong`.
|
||||
|
||||
`t_selftest.c` начинается с `TC_EQ(sizeof(int), 2)` — если обвязка однажды
|
||||
переедет на хостовый компилятор, это заметят сразу.
|
||||
|
||||
## Как устроено
|
||||
|
||||
| файл | роль |
|
||||
|------|------|
|
||||
| `crt0_ucsim.s` | голый z80-старт: SP, зануление `_DATA`/`_BSS`, `main`, `halt` |
|
||||
| `tcheck.h/.c` | микро-фреймворк: итог кладётся в `tc_result` в ОЗУ |
|
||||
| `run_ucsim.py` | гоняет ucsim, дампит `tc_result`, печатает отчёт |
|
||||
| `host-tests.mk` | общие правила сборки и прогона |
|
||||
|
||||
Вывода через `printf` нет: тестовый бинарь линкуется **без** Sprinter-libc
|
||||
(там ESTEX, BIOS, файлы — в ucsim этого нет). Результат складывается в
|
||||
структуру в ОЗУ, а раннер читает её командой `dump` после остановки по
|
||||
`halt`. Через ucsim-simif сознательно не идём: номера его команд плавают
|
||||
от версии к версии, а `halt` + `dump` работают везде.
|
||||
|
||||
## Завести наборы в новом месте
|
||||
|
||||
Создать каталог рядом с проверяемым кодом и положить туда `Makefile`:
|
||||
|
||||
```make
|
||||
TESTKIT := $(abspath $(CURDIR)/../../testkit)
|
||||
ENGINE_DIR := $(abspath $(CURDIR)/..) # где лежат модули под тестом
|
||||
OBJS_geom := build/eng_pop_geom.rel # что доложить в набор t_geom
|
||||
include $(TESTKIT)/host-tests.mk
|
||||
```
|
||||
|
||||
Добавить каталог в `HOST_TEST_DIRS` корневого `Makefile`. Сам набор —
|
||||
`t_<имя>.c` с `main()`, который зовёт `TC_RUN`; wildcard подхватит его сам.
|
||||
|
||||
```c
|
||||
#include "tcheck.h"
|
||||
|
||||
TC_TEST(что_проверяем)
|
||||
{
|
||||
TC_EQ(получено, ожидание);
|
||||
TC_TRUE(условие);
|
||||
}
|
||||
|
||||
int main(void) { TC_RUN(что_проверяем); return 0; }
|
||||
```
|
||||
|
||||
## Грабли
|
||||
|
||||
- **`.area _HEADER (ABS)` объявлять ровно один раз и первой.** Если
|
||||
продублировать её в списке порядка областей, sdas заведёт `_HEADER0`, и
|
||||
линкер положит стартовый код не по `0x0000` (уезжал за `_DATA` на
|
||||
`0x830A`, и с PC=0 исполнялся мусор).
|
||||
- **Файл грузить командой `file`, а не аргументом командной строки:** при
|
||||
передаче аргументом ucsim успевает выполнить `run` до загрузки.
|
||||
- **Константы SDCC сворачивает.** `TC_EQ` над константным выражением
|
||||
проверит компилятор, а не код (`warning 110`). Входные данные для
|
||||
арифметических тестов держать в `static volatile`.
|
||||
- Если тест зациклился, раннер снимет его по таймауту (60 с по умолчанию)
|
||||
и скажет об этом явно.
|
||||
|
||||
## Чего эти тесты не поймают
|
||||
|
||||
Банки и W-окна, тайминги, корректность блитов, поведение реальной
|
||||
клавиатуры, отрисовку — для этого остаётся MAME (`docs/mame-autotest.md`).
|
||||
|
||||
Отдельный класс — баги **порядка вызовов** между модулями: общее
|
||||
глобальное состояние, затираемое тем, кто отработал последним. Такое ловит
|
||||
не unit-тест, а инвариант, вкомпилированный в safe-сборку.
|
||||
@@ -0,0 +1,103 @@
|
||||
;; ----------------------------------------------------------------------
|
||||
;; crt0_ucsim.s — стартовый код для тестовых бинарей, исполняемых в ucsim.
|
||||
;;
|
||||
;; Зачем отдельный crt0, а не runtime/crt0*.s: те завязаны на Sprinter —
|
||||
;; префикс ESTEX, argv из командной строки, выход через ESTEX EXIT
|
||||
;; (rst #0x10). В ucsim нет ни ESTEX, ни DSS; нужен голый z80: поставить
|
||||
;; SP, занулить данные, позвать main, встать.
|
||||
;;
|
||||
;; Контракт с раннером (tests-host/run_ucsim.sh):
|
||||
;; - выполнение заканчивается инструкцией `halt` — по ней ucsim
|
||||
;; останавливает симуляцию, и раннер получает управление;
|
||||
;; - результат тестов раннер читает из ОЗУ командой `dump` (адрес берётся
|
||||
;; из карты линковки, символ _tc_result — см. tcheck.c).
|
||||
;; Никакого simif не используем: номера его команд от версии к версии
|
||||
;; плавают, а halt + dump работают везде.
|
||||
;; ----------------------------------------------------------------------
|
||||
|
||||
.module crt0_ucsim
|
||||
.globl _main
|
||||
|
||||
;; Символы, которые расставляет линкер.
|
||||
.globl s__INITIALIZER
|
||||
.globl l__INITIALIZER
|
||||
.globl s__INITIALIZED
|
||||
.globl s__DATA
|
||||
.globl l__DATA
|
||||
.globl s__BSS
|
||||
.globl l__BSS
|
||||
|
||||
;; =========================================================================
|
||||
;; Точка входа: ucsim стартует с PC = 0x0000.
|
||||
;;
|
||||
;; ВАЖНО: `.area _HEADER (ABS)` должна встречаться РОВНО ОДИН РАЗ и первой.
|
||||
;; Если продублировать её в списке порядка областей ниже, sdas заведёт
|
||||
;; вторую область _HEADER0, и линкер положит стартовый код не по 0x0000, а
|
||||
;; куда придётся (проверено: уезжал за _DATA на 0x830A, и с PC=0
|
||||
;; исполнялся мусор).
|
||||
;; =========================================================================
|
||||
.area _HEADER (ABS)
|
||||
.org 0x0000
|
||||
di
|
||||
ld sp, #0xFEF0 ; стек под верхней границей ОЗУ ucsim
|
||||
call gsinit
|
||||
call _main
|
||||
halt ; ucsim остановит симуляцию здесь
|
||||
1$: jr 1$ ; страховка, если halt проигнорирован
|
||||
|
||||
;; =========================================================================
|
||||
;; Порядок остальных областей — объявляем заранее, чтобы линкер разложил их
|
||||
;; так (без _HEADER, см. комментарий выше).
|
||||
;; =========================================================================
|
||||
.area _HOME
|
||||
.area _CODE
|
||||
.area _INITIALIZER
|
||||
.area _GSINIT
|
||||
.area _GSFINAL
|
||||
|
||||
.area _DATA
|
||||
.area _INITIALIZED
|
||||
.area _BSEG
|
||||
.area _BSS
|
||||
.area _HEAP
|
||||
|
||||
;; =========================================================================
|
||||
;; gsinit — занулить _DATA/_BSS и скопировать _INITIALIZER -> _INITIALIZED.
|
||||
;; SDCC вставляет между gsinit и ret в _GSFINAL код инициализации модулей.
|
||||
;; =========================================================================
|
||||
.area _CODE
|
||||
;; zero_area — заполнить BC байт по адресу HL нулями (BC может быть 0).
|
||||
zero_area:
|
||||
ld a, b
|
||||
or a, c
|
||||
ret Z
|
||||
ld (hl), #0
|
||||
dec bc
|
||||
ld a, b
|
||||
or a, c
|
||||
ret Z
|
||||
ld d, h
|
||||
ld e, l
|
||||
inc de
|
||||
ldir
|
||||
ret
|
||||
|
||||
.area _GSINIT
|
||||
gsinit::
|
||||
ld hl, #s__DATA
|
||||
ld bc, #l__DATA
|
||||
call zero_area
|
||||
ld hl, #s__BSS
|
||||
ld bc, #l__BSS
|
||||
call zero_area
|
||||
ld bc, #l__INITIALIZER
|
||||
ld a, b
|
||||
or a, c
|
||||
jr Z, gsinit_done
|
||||
ld de, #s__INITIALIZED
|
||||
ld hl, #s__INITIALIZER
|
||||
ldir
|
||||
gsinit_done:
|
||||
|
||||
.area _GSFINAL
|
||||
ret
|
||||
@@ -0,0 +1,83 @@
|
||||
# ----------------------------------------------------------------------
|
||||
# host-tests.mk — общие правила модульных тестов, исполняемых под ucsim_z80.
|
||||
#
|
||||
# Сама обвязка (crt0_ucsim.s, tcheck.*, run_ucsim.py) лежит в testkit/ и ни
|
||||
# к какому подпроекту не привязана: тестировать так можно и libc, и логику
|
||||
# приложений — всё, что на plain C и не трогает железо.
|
||||
#
|
||||
# Подключается из каталога с наборами t_*.c:
|
||||
#
|
||||
# TESTKIT := $(abspath ../../../../testkit)
|
||||
# ENGINE_DIR := $(abspath ..) # где лежат модули под тестом
|
||||
# OBJS_geom := build/eng_pop_geom.rel # что доложить в этот набор
|
||||
# include $(TESTKIT)/host-tests.mk
|
||||
#
|
||||
# Набор t_<имя>.c подхватывается wildcard'ом, регистрировать не надо.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
ifndef TESTKIT
|
||||
$(error перед include надо задать TESTKIT — путь к каталогу testkit/)
|
||||
endif
|
||||
|
||||
PROJ_ROOT ?= $(abspath $(TESTKIT)/..)
|
||||
SDCC := $(PROJ_ROOT)/third_party/sdcc/bin/sdcc
|
||||
SDASZ80 := $(PROJ_ROOT)/third_party/sdcc/bin/sdasz80
|
||||
UCSIM := $(PROJ_ROOT)/third_party/sdcc/bin/ucsim_z80
|
||||
RUNNER := $(TESTKIT)/run_ucsim.py
|
||||
|
||||
BUILD := build
|
||||
|
||||
# --code-loc: стартовый код занимает 13 байт по 0x0000, код с 0x0200.
|
||||
# --data-loc 0x8000: данные заведомо выше кода.
|
||||
CFLAGS := -mz80 --no-std-crt0 --std-c99 --opt-code-size -I$(TESTKIT) \
|
||||
$(if $(ENGINE_DIR),-I$(ENGINE_DIR))
|
||||
LDFLAGS := -mz80 --no-std-crt0 --code-loc 0x0200 --data-loc 0x8000
|
||||
|
||||
SUITES := $(patsubst t_%.c,%,$(notdir $(wildcard t_*.c)))
|
||||
COMMON := $(BUILD)/crt0_ucsim.rel $(BUILD)/tcheck.rel
|
||||
|
||||
.PHONY: all clean $(addprefix t_,$(SUITES))
|
||||
# Иначе make считает .rel/.ihx промежуточными и удаляет их после прогона —
|
||||
# следующий `make` пересобирал бы всё заново.
|
||||
.SECONDARY:
|
||||
# OBJS_<набор> подставляется в правило по имени цели — нужен второй проход
|
||||
# раскрытия, иначе модуль не попадёт в зависимости и не соберётся.
|
||||
.SECONDEXPANSION:
|
||||
|
||||
# Ярлык каталога в отчёте — с родителем: «tests-host» само по себе
|
||||
# неоднозначно, когда таких каталогов несколько.
|
||||
LABEL := $(notdir $(patsubst %/,%,$(dir $(CURDIR))))/$(notdir $(CURDIR))
|
||||
|
||||
all: $(addprefix run-,$(SUITES))
|
||||
@echo "$(LABEL): все наборы прошли ($(words $(SUITES)))"
|
||||
|
||||
# Прогон одного набора. Раннер сам разбирает tc_result и возвращает код.
|
||||
run-%: $(BUILD)/%.ihx
|
||||
@python3 $(RUNNER) $(UCSIM) $(BUILD)/$*.ihx $(BUILD)/$*.noi
|
||||
|
||||
# Удобный псевдоним: `make t_geom` вместо `make run-geom`.
|
||||
t_%: run-% ;
|
||||
|
||||
$(BUILD)/%.ihx: $(BUILD)/%.rel $(COMMON) $$(OBJS_$$*)
|
||||
@$(SDCC) $(LDFLAGS) -o $@ $(COMMON) $(BUILD)/$*.rel $(OBJS_$*)
|
||||
|
||||
$(BUILD)/%.rel: t_%.c $(TESTKIT)/tcheck.h | $(BUILD)
|
||||
@$(SDCC) $(CFLAGS) -c -o $@ $<
|
||||
|
||||
# Модули под тестом собираются ТЕМ ЖЕ компилятором и с той же семантикой,
|
||||
# что и продукт, — включая модули с inline-asm, которые хостовой сборкой
|
||||
# не проверить в принципе.
|
||||
$(BUILD)/eng_%.rel: $(ENGINE_DIR)/%.c | $(BUILD)
|
||||
@$(SDCC) $(CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD)/tcheck.rel: $(TESTKIT)/tcheck.c $(TESTKIT)/tcheck.h | $(BUILD)
|
||||
@$(SDCC) $(CFLAGS) -c -o $@ $<
|
||||
|
||||
$(BUILD)/crt0_ucsim.rel: $(TESTKIT)/crt0_ucsim.s | $(BUILD)
|
||||
@$(SDASZ80) -plosgff $@ $<
|
||||
|
||||
$(BUILD):
|
||||
@mkdir -p $(BUILD)
|
||||
|
||||
clean:
|
||||
@rm -rf $(BUILD)
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
run_ucsim.py — прогнать тестовый .ihx под ucsim_z80 и разобрать результат.
|
||||
|
||||
Как это устроено (см. crt0_ucsim.s и tcheck.h): тестовый бинарь кладёт итог
|
||||
в структуру tc_result в ОЗУ и заканчивается инструкцией halt. Мы находим
|
||||
адрес _tc_result в карте линковки (.noi), гоняем ucsim, дампим структуру и
|
||||
печатаем отчёт.
|
||||
|
||||
Через simif сознательно не идём: номера его команд меняются от версии к
|
||||
версии, а halt + dump работают одинаково везде.
|
||||
|
||||
Код возврата: 0 — все проверки прошли, 1 — есть провалы или сбой прогона.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TC_MAGIC = 0x5A5A
|
||||
HDR = 8 # magic, total, failed, logn — по 2 байта
|
||||
LOGSZ = 768 # TC_LOGSZ из tcheck.h
|
||||
BYTES_PER_LINE = 16
|
||||
|
||||
# Строка дампа ucsim: "0x8000 5a 5a 07 00 ... ZZ......"
|
||||
# ASCII-хвост тоже может выглядеть как hex, поэтому берём РОВНО столько
|
||||
# токенов, сколько байт положено в строке, а не «сколько нашлось».
|
||||
DUMP_RE = re.compile(r"^0x([0-9A-Fa-f]+)\s+((?:[0-9A-Fa-f]{2}\s+)+)")
|
||||
|
||||
|
||||
def find_symbol(noi_path: Path, name: str) -> int:
|
||||
m = re.search(rf"^DEF {re.escape(name)} (0x[0-9A-Fa-f]+)$",
|
||||
noi_path.read_text(), re.M)
|
||||
if not m:
|
||||
raise SystemExit(f"{noi_path.name}: символ {name} не найден "
|
||||
f"(тест не слинкован с tcheck.c?)")
|
||||
return int(m.group(1), 16)
|
||||
|
||||
|
||||
def run(ucsim: Path, ihx: Path, start: int, length: int, timeout: int):
|
||||
stop = start + length - 1
|
||||
script = (f'file "{ihx}"\n'
|
||||
f'reset\n'
|
||||
f'run\n'
|
||||
f'dump 0x{start:04X} 0x{stop:04X} {BYTES_PER_LINE}\n'
|
||||
f'quit\n')
|
||||
try:
|
||||
p = subprocess.run([str(ucsim), "-t", "z80"], input=script,
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SystemExit(f"ТАЙМАУТ {timeout} с — тест зациклился "
|
||||
f"(нет halt / бесконечный цикл в коде)")
|
||||
return p.stdout
|
||||
|
||||
|
||||
def parse_dump(out: str, start: int, length: int) -> bytes:
|
||||
mem = bytearray(length)
|
||||
seen = bytearray(length)
|
||||
for line in out.splitlines():
|
||||
m = DUMP_RE.match(line.strip())
|
||||
if not m:
|
||||
continue
|
||||
addr = int(m.group(1), 16)
|
||||
off = addr - start
|
||||
if off < 0 or off >= length:
|
||||
continue
|
||||
want = min(BYTES_PER_LINE, length - off)
|
||||
toks = m.group(2).split()[:want]
|
||||
for i, t in enumerate(toks):
|
||||
mem[off + i] = int(t, 16)
|
||||
seen[off + i] = 1
|
||||
if not any(seen):
|
||||
raise SystemExit("не удалось разобрать дамп ucsim:\n" + out)
|
||||
return bytes(mem)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 4:
|
||||
print("usage: run_ucsim.py <ucsim> <test.ihx> <test.noi> [timeout]",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
ucsim, ihx, noi = (Path(a) for a in sys.argv[1:4])
|
||||
timeout = int(sys.argv[4]) if len(sys.argv) > 4 else 60
|
||||
name = ihx.stem
|
||||
|
||||
addr = find_symbol(noi, "_tc_result")
|
||||
out = run(ucsim, ihx, addr, HDR + LOGSZ, timeout)
|
||||
|
||||
if "Halted" not in out:
|
||||
print(f"[{name}] ПРОГОН НЕ ЗАВЕРШИЛСЯ штатным halt:\n{out}")
|
||||
return 1
|
||||
|
||||
mem = parse_dump(out, addr, HDR + LOGSZ)
|
||||
u16 = lambda o: mem[o] | (mem[o + 1] << 8)
|
||||
magic, total, failed, logn = u16(0), u16(2), u16(4), u16(6)
|
||||
|
||||
if magic != TC_MAGIC:
|
||||
print(f"[{name}] tc_result не заполнен (magic=0x{magic:04X}) — "
|
||||
f"main() не позвал ни одной проверки?")
|
||||
return 1
|
||||
|
||||
if failed:
|
||||
log = mem[HDR:HDR + min(logn, LOGSZ)].decode("utf-8", "replace")
|
||||
print(f"[{name}] ПРОВАЛ: {failed} из {total}")
|
||||
for line in log.rstrip("\n").split("\n"):
|
||||
print(f" {line}")
|
||||
return 1
|
||||
|
||||
print(f"[{name}] ok: {total}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* t_smoke.c — проверка самого каркаса, а не движка.
|
||||
*
|
||||
* Главное здесь — TC_EQ(sizeof(int), 2): она доказывает, что тест реально
|
||||
* исполняется с z80-семантикой, а не собран хостовым компилятором. Ради
|
||||
* этого весь прогон под ucsim и затевался (см. обсуждение 16/32 бит).
|
||||
*/
|
||||
#include "tcheck.h"
|
||||
|
||||
TC_TEST(smoke_z80_widths)
|
||||
{
|
||||
TC_EQ(sizeof(int), 2); /* мы под z80, int 16 бит */
|
||||
TC_EQ(sizeof(void *), 2);
|
||||
TC_EQ(sizeof(long), 4);
|
||||
}
|
||||
|
||||
TC_TEST(smoke_arith)
|
||||
{
|
||||
static volatile uint8_t a, b; /* volatile: иначе SDCC свернёт всё
|
||||
* в константу и тест ничего не
|
||||
* проверит (warning 110) */
|
||||
int16_t s;
|
||||
a = 200; b = 100;
|
||||
s = (int16_t)(a + b); /* байты повышаются до int */
|
||||
TC_EQ(s, 300);
|
||||
TC_EQ((uint8_t)(a + b), 44); /* явный заворот в байт */
|
||||
}
|
||||
|
||||
TC_TEST(smoke_wraparound_is_16bit)
|
||||
{
|
||||
/* Ровно тот случай, который на хосте посчитался бы в 32 битах:
|
||||
* здесь заворот обязан произойти. */
|
||||
static volatile uint16_t a, b;
|
||||
a = 60000; b = 10000;
|
||||
TC_EQ((uint16_t)(a + b), 4464);
|
||||
TC_TRUE((uint16_t)(a + b) < 65000);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
TC_RUN(smoke_z80_widths);
|
||||
TC_RUN(smoke_arith);
|
||||
TC_RUN(smoke_wraparound_is_16bit);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* tcheck.c — реализация микро-фреймворка (см. tcheck.h).
|
||||
*
|
||||
* Всё пишется в tc_result — раннер читает эту структуру из ОЗУ. Ничего
|
||||
* не инициализируем явно: crt0_ucsim зануляет _DATA/_BSS, и magic ставится
|
||||
* первым же tc_begin (правило проекта про статики — memory
|
||||
* sdcc_static_storage_gotcha).
|
||||
*/
|
||||
#include "tcheck.h"
|
||||
|
||||
tc_result_t tc_result;
|
||||
const char *tc_curr;
|
||||
|
||||
static void tc_putc(char c)
|
||||
{
|
||||
if (tc_result.logn < TC_LOGSZ - 1)
|
||||
tc_result.log[tc_result.logn++] = (uint8_t)c;
|
||||
}
|
||||
|
||||
static void tc_puts(const char *s)
|
||||
{
|
||||
while (*s) tc_putc(*s++);
|
||||
}
|
||||
|
||||
static void tc_puthex(uint16_t v)
|
||||
{
|
||||
const char *d = "0123456789ABCDEF";
|
||||
tc_puts(" (0x");
|
||||
tc_putc(d[(v >> 12) & 15]); tc_putc(d[(v >> 8) & 15]);
|
||||
tc_putc(d[(v >> 4) & 15]); tc_putc(d[v & 15]);
|
||||
tc_putc(')');
|
||||
}
|
||||
|
||||
/* Знаковое десятичное: значения тестов маленькие, но делаем честно для
|
||||
* всего диапазона int16_t. */
|
||||
static void tc_putd(int16_t v)
|
||||
{
|
||||
char buf[7];
|
||||
uint8_t n = 0;
|
||||
uint16_t u;
|
||||
if (v < 0) { tc_putc('-'); u = (uint16_t)(-v); }
|
||||
else { u = (uint16_t)v; }
|
||||
do { buf[n++] = (char)('0' + (u % 10u)); u /= 10u; } while (u);
|
||||
while (n) tc_putc(buf[--n]);
|
||||
}
|
||||
|
||||
void tc_begin(const char *name)
|
||||
{
|
||||
tc_result.magic = TC_MAGIC;
|
||||
tc_curr = name;
|
||||
}
|
||||
|
||||
static void tc_fail_head(const char *expr, uint16_t line)
|
||||
{
|
||||
tc_result.failed++;
|
||||
tc_puts("FAIL ");
|
||||
tc_puts(tc_curr ? tc_curr : "?");
|
||||
tc_puts(":");
|
||||
tc_putd((int16_t)line);
|
||||
tc_puts(" ");
|
||||
tc_puts(expr);
|
||||
}
|
||||
|
||||
void tc_check(int16_t got, int16_t want, const char *expr, uint16_t line)
|
||||
{
|
||||
tc_result.magic = TC_MAGIC;
|
||||
tc_result.total++;
|
||||
if (got == want) return;
|
||||
tc_fail_head(expr, line);
|
||||
/* Печатаем и десятичное, и hex: сравнение идёт как int16_t, поэтому
|
||||
* беззнаковые величины (сид PRNG, координаты > 32767) в десятичном
|
||||
* виде выглядят отрицательными и сбивают с толку. */
|
||||
tc_puts(" = ");
|
||||
tc_putd(got);
|
||||
tc_puthex((uint16_t)got);
|
||||
tc_puts(", ждали ");
|
||||
tc_putd(want);
|
||||
tc_puthex((uint16_t)want);
|
||||
tc_putc('\n');
|
||||
}
|
||||
|
||||
void tc_checkp(const void *got, const void *want, const char *expr, uint16_t line)
|
||||
{
|
||||
tc_result.magic = TC_MAGIC;
|
||||
tc_result.total++;
|
||||
if (got == want) return;
|
||||
tc_fail_head(expr, line);
|
||||
tc_puts(" — указатели различаются\n");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* tcheck.h — микро-фреймворк для тестов, исполняемых под ucsim_z80.
|
||||
*
|
||||
* Почему не printf: тестовый бинарь линкуется БЕЗ Sprinter-libc (там ESTEX,
|
||||
* BIOS, файлы — ничего этого в ucsim нет). Поэтому результат складывается в
|
||||
* структуру tc_result в ОЗУ, а раннер (run_ucsim.py) читает её командой
|
||||
* `dump` после остановки по halt и печатает по-человечески.
|
||||
*
|
||||
* Использование:
|
||||
*
|
||||
* #include "tcheck.h"
|
||||
* TC_TEST(имя_теста) { ... TC_EQ(получено, ожидание); ... }
|
||||
* TC_MAIN(имя_теста, другой_тест, ...)
|
||||
*
|
||||
* Значения сравниваются как int16_t — этого хватает на всё, чем оперирует
|
||||
* движок (координаты, тайлы, кадры, счётчики).
|
||||
*/
|
||||
#ifndef TCHECK_H
|
||||
#define TCHECK_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define TC_LOGSZ 768
|
||||
#define TC_MAGIC 0x5A5Au
|
||||
|
||||
typedef struct {
|
||||
uint16_t magic; /* TC_MAGIC — раннер убеждается, что нашёл нас */
|
||||
uint16_t total; /* сколько проверок выполнено */
|
||||
uint16_t failed; /* сколько провалилось */
|
||||
uint16_t logn; /* занято в log */
|
||||
uint8_t log[TC_LOGSZ]; /* ASCII-отчёт (только про провалы) */
|
||||
} tc_result_t;
|
||||
|
||||
extern tc_result_t tc_result;
|
||||
|
||||
/* Текущий тест — чтобы в отчёте было видно, где именно упало. */
|
||||
extern const char *tc_curr;
|
||||
|
||||
void tc_begin(const char *name);
|
||||
void tc_check(int16_t got, int16_t want, const char *expr, uint16_t line);
|
||||
void tc_checkp(const void *got, const void *want, const char *expr, uint16_t line);
|
||||
|
||||
/* Основные макросы. __LINE__ хватает для локализации: имя теста уже в
|
||||
* отчёте, а выражение печатается текстом. */
|
||||
#define TC_EQ(got, want) tc_check((int16_t)(got), (int16_t)(want), #got, __LINE__)
|
||||
#define TC_TRUE(cond) tc_check((int16_t)((cond) ? 1 : 0), 1, #cond, __LINE__)
|
||||
#define TC_FALSE(cond) tc_check((int16_t)((cond) ? 1 : 0), 0, #cond, __LINE__)
|
||||
#define TC_PEQ(got, want) tc_checkp((got), (want), #got, __LINE__)
|
||||
|
||||
#define TC_TEST(name) static void name(void)
|
||||
|
||||
/* main() тестового бинаря: перечислить функции тестов. Вариадика через
|
||||
* список вызовов — у SDCC нет проблем с таким раскрытием. */
|
||||
#define TC_RUN(fn) do { tc_begin(#fn); fn(); } while (0)
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user