From 1b4fbeaa6b4691b5aa493b802820dc4a36b4c0c5 Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Wed, 15 Jul 2026 10:36:37 +0300 Subject: [PATCH] =?UTF-8?q?libbgi:=20FPS-=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20gfx=5Fset=5Ffps=5Fdiv(n)=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=B0=D0=B4=D1=80=D0=BE=D0=B2=D1=8B=D1=85=20IRQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Логический кадр = ровно n кадровых интервалов (1=50/2=25/3=~16.7 fps); при переполнении слота — выравнивание на ближайший фронт (без дрейфа фазы, в отличие от наивного «жди n фронтов»). Механика: фоновый счётчик _gfx_frame_tick инкрементит _gfx_frame_isr, поставленный в СВОЙ слот цепи (irq_chain_add); gfx_set_fps_div(1) снимает только этот слот (irq_chain_remove), не трогая хендлер приложения. gfx_wait_vsync: ветка n>=2 (счётчик + halt) перед лучевым поллингом; поллинг вынесен в static gfx_wait_vsync_beam (функция с хвостовым __asm не должна иметь переходов через asm — SDCC не эмитит эпилог-метку; ранний return делителя в чистом-C gfx_wait_vsync). Файлы: common/_gfx_fps_state.c (данные), _gfx_frame_isr.c (ISR), gfx_set_fps_div.c (сеттер, единственная ссылка на irq-механику → DCE). Работает tiny/big/huge (цепочка all-modes); small для мелких программ = EINVAL. Проверено MAME (tests/fpsdiv): n=1/2/3 → 20/40/60 кадров на 20 wait'ов (drift=0); n=2 с рендер-заглушкой ~1 кадр → период держится 2 (поглощение перерасхода, наивный путь дал бы ~60); huge идентично; small = EINVAL graceful. Co-Authored-By: Claude Opus 4.8 --- docs/TODO.md | 20 ++++-- docs/size_baseline.tsv | 1 + docs/sprite-api-design.md | 17 ++++- libbgi/_bgi.h | 13 ++++ libbgi/common/_gfx_fps_state.c | 21 +++++++ libbgi/common/_gfx_frame_isr.c | 14 +++++ libbgi/common/gfx_set_fps_div.c | 42 +++++++++++++ libbgi/common/gfx_wait_vsync.c | 46 +++++++++++++- libbgi/include/gfx.h | 11 ++++ tests/fpsdiv/Makefile | 6 ++ tests/fpsdiv/fpsdiv.c | 107 ++++++++++++++++++++++++++++++++ 11 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 libbgi/common/_gfx_fps_state.c create mode 100644 libbgi/common/_gfx_frame_isr.c create mode 100644 libbgi/common/gfx_set_fps_div.c create mode 100644 tests/fpsdiv/Makefile create mode 100644 tests/fpsdiv/fpsdiv.c diff --git a/docs/TODO.md b/docs/TODO.md index 3a0c2b6..74c8dc5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,12 +27,20 @@ `irq_ctc_install` = EINVAL (нужна такая же W2-копия _irq_ctc_tramp); (б) small для мелких программ (BSS в W1) → irq_install EINVAL (safe; снять резервом area _IM2 в верхней W2). -- [ ] **(2) FPS-делитель (frame pacing)** — gfx_set_fps_div(n): логический - кадр = ровно n кадровых интервалов (50/25/16.7 fps), при - переполнении слота — выравнивание на ближайший фронт; счётчик - кадров через irq_chain_add(_gfx_frame_isr) (только tiny/big в - первой версии). ПОЛНЫЙ ПЛАН: docs/sprite-api-design.md §9е - (обсуждён 2026-07-14; ограничение EBUSY снято цепочкой). +- [x] **(2) FPS-делитель (frame pacing)** — СДЕЛАНО 2026-07-15. + gfx_set_fps_div(n): логический кадр = ровно n кадровых интервалов + (1=50/2=25/3=~16.7 fps); при переполнении слота — выравнивание на + ближайший фронт (без дрейфа фазы). Счётчик кадров _gfx_frame_tick + через irq_chain_add(_gfx_frame_isr) — свой слот цепи (n<=1 снимает + его через irq_chain_remove, не трогая хендлер приложения). + gfx_wait_vsync: ветка n>=2 (счётчик+halt) перед лучевым поллингом. + Файлы: common/_gfx_fps_state.c, _gfx_frame_isr.c, gfx_set_fps_div.c, + правка gfx_wait_vsync.c. Работает tiny/big/huge (цепочка all-modes); + small для мелких программ = EINVAL. ПРОВЕРЕНО MAME (tests/fpsdiv): + n=1/2/3 → 20/40/60 кадров на 20 wait'ов (drift=0); n=2 с рендер- + заглушкой ~1 кадр → период держится 2 (поглощение перерасхода, + наивный путь дал бы ~60); huge идентично; small = EINVAL graceful. + ПЛАН: docs/sprite-api-design.md §9е. - [ ] **П6/железо**: MAME-смоук всех тестов после libc-сплита (conio, ptime, stattest, mouse, gfx_demo/gfx_d16/gfx_text/gfx_mous — трогался asm акселератора); затем прогон на реальном Sprinter diff --git a/docs/size_baseline.tsv b/docs/size_baseline.tsv index 1dc6a56..f219f63 100644 --- a/docs/size_baseline.tsv +++ b/docs/size_baseline.tsv @@ -25,6 +25,7 @@ errno 5966 fbench 8085 fdmax 6023 filetest 10547 +fpsdiv 4870 gets 509 gfx_dbuf 5032 gfx_demo 4103 diff --git a/docs/sprite-api-design.md b/docs/sprite-api-design.md index a8fab0e..361a4af 100644 --- a/docs/sprite-api-design.md +++ b/docs/sprite-api-design.md @@ -842,7 +842,22 @@ putimage-заготовкой/маленьким bar. Тест: spranim T7 (порядок, слой поверх y, персистентный resort, LAYER-only). -## 9е. FPS-делитель — frame pacing (ПЛАН, обсуждён 2026-07-14, НЕ реализовано) +## 9е. FPS-делитель — frame pacing (РЕАЛИЗОВАНО 2026-07-15, verified MAME) + +**СТАТУС: сделано.** Файлы точно по плану ниже (common/_gfx_fps_state.c, +_gfx_frame_isr.c, gfx_set_fps_div.c, ветка в gfx_wait_vsync.c). Через +цепочку кадровых IRQ — работает tiny/big/huge (не только tiny/big); +small для мелких программ = EINVAL. tests/fpsdiv: n=1/2/3 → ровно +20/40/60 кадров на 20 wait'ов (drift=0); n=2 с рендер-заглушкой ~1 кадр +→ период 2 (поглощение перерасхода, наивный путь дал бы ~60); huge +идентично tiny; small = EINVAL graceful. Один нюанс реализации: +лучевой поллинг вынесен в static gfx_wait_vsync_beam() (функция, +кончающаяся непрозрачным __asm, не должна иметь переходов «через asm» — +SDCC не эмитит эпилог-метку; ранний return делителя живёт в чистом-C +gfx_wait_vsync). Ниже — исходный план (в силе). + +--- + Цель (постановка пользователя): управляемые режимы 50/25/~16.7 fps — логический кадр занимает РОВНО n кадровых интервалов независимо от diff --git a/libbgi/_bgi.h b/libbgi/_bgi.h index 3359ec9..8ce80f2 100644 --- a/libbgi/_bgi.h +++ b/libbgi/_bgi.h @@ -313,4 +313,17 @@ void _bgi_poly_edge(int x0, int y0, int x1, int y1); extern void _cbl_port_ref(void); extern void _cbl_port_unref(void); +/* ---- FPS-делитель (frame pacing) --------------------------------- * + * Фоновый счётчик кадров через цепочку кадровых IRQ (libc/irq). Данные + * в common/_gfx_fps_state.c; при _gfx_fps_div<=1 путь не активен и + * gfx_wait_vsync остаётся лучевым поллингом. Механику irq_chain_add + * дёргает ТОЛЬКО gfx_set_fps_div.c (DCE). */ +extern uint8_t _gfx_fps_div; /* 0/1 = старый путь; >=2 — делитель */ +extern volatile uint8_t _gfx_frame_tick; /* инкрементит _gfx_frame_isr */ +extern uint8_t _gfx_fps_last; /* тик прошлого выхода из wait */ + +/* Кадровый ISR: чистый инкремент _gfx_frame_tick (правила irq.h — + * никаких ESTEX/gfx/банков). */ +void _gfx_frame_isr(void); + #endif diff --git a/libbgi/common/_gfx_fps_state.c b/libbgi/common/_gfx_fps_state.c new file mode 100644 index 0000000..f0973ce --- /dev/null +++ b/libbgi/common/_gfx_fps_state.c @@ -0,0 +1,21 @@ +/* + * _gfx_fps_state — данные FPS-делителя (frame pacing, docs/sprite-api- + * design.md §9е). Только данные; НЕ инициализировать (crt0 зануляет + * _DATA — див=0 трактуется как «делитель выключен»). + * + * _gfx_fps_div — n: логический кадр = n кадровых интервалов; 0/1 = + * старый лучевой поллинг gfx_wait_vsync (путь не активен); + * _gfx_frame_tick— счётчик кадров, инкрементит _gfx_frame_isr; вся + * арифметика ожидания разностная по модулю 256 (wrap + * безопасен); + * _gfx_fps_last — значение счётчика на прошлом выходе из wait (база + * отсчёта интервалов). + * + * Модуль отделён от сеттера/ISR/wait, чтобы программа без вызова + * gfx_set_fps_div() тянула только эти 3 байта, но не irq-механику. + */ +#include "../_bgi.h" + +uint8_t _gfx_fps_div; +volatile uint8_t _gfx_frame_tick; +uint8_t _gfx_fps_last; diff --git a/libbgi/common/_gfx_frame_isr.c b/libbgi/common/_gfx_frame_isr.c new file mode 100644 index 0000000..d45a33f --- /dev/null +++ b/libbgi/common/_gfx_frame_isr.c @@ -0,0 +1,14 @@ +/* + * _gfx_frame_isr — кадровый обработчик FPS-делителя (ставится в цепочку + * через irq_chain_add из gfx_set_fps_div). Правила irq.h: вызывается + * ~50 раз/с, никаких ESTEX/BIOS/gfx/банков — только инкремент счётчика. + * + * Разностная семантика ожидания в gfx_wait_vsync терпит wrap uint8, + * поэтому счётчик 8-битный (одна INC (nn) — минимальный хвост в ISR). + */ +#include "../_bgi.h" + +void _gfx_frame_isr(void) +{ + _gfx_frame_tick++; +} diff --git a/libbgi/common/gfx_set_fps_div.c b/libbgi/common/gfx_set_fps_div.c new file mode 100644 index 0000000..2002b3d --- /dev/null +++ b/libbgi/common/gfx_set_fps_div.c @@ -0,0 +1,42 @@ +/* + * gfx_set_fps_div — задать делитель кадровой частоты (frame pacing). + * n: логический кадр = РОВНО n кадровых интервалов (1=50 fps дефолт, + * 2=25, 3=~16.7, 4=12.5, ...; 0 трактуется как 1). gfx_wait_vsync() + * при n>=2 переключается со лучевого поллинга на счётчиковый путь + * (docs/sprite-api-design.md §9е). + * + * n>=2: однократно ставит _gfx_frame_isr в ЦЕПОЧКУ кадровых IRQ + * (irq_chain_add — свой слот, не мешает собственному хендлеру + * приложения); повторные вызовы только меняют делитель. + * n<=1: снимает ТОЛЬКО свой слот (irq_chain_remove, НЕ irq_remove — + * чтобы не снести хендлер приложения) и возвращает старый путь. + * + * Возврат 0 / -1+errno: EINVAL (данные не в W2 — неподходящий memory + * mode, из irq_chain_add), ENOMEM (все слоты цепи заняты). ТОЛЬКО этот + * модуль ссылается на irq-механику и _gfx_frame_isr — программа без + * вызова сеттера не линкует ни цепочку, ни ISR (DCE). + */ +#include "../_bgi.h" +#include + +int gfx_set_fps_div(uint8_t n) +{ + if (n < 2) { + /* Выключение: снять свой слот, если ставили. */ + if (_gfx_fps_div >= 2) { + irq_chain_remove(_gfx_frame_isr); + _gfx_fps_div = 1; + } + return 0; + } + + /* Первое включение — занять слот цепи под свой ISR. */ + if (_gfx_fps_div < 2) { + if (irq_chain_add(_gfx_frame_isr) != 0) + return -1; /* errno уже выставлен (EINVAL/ENOMEM) */ + /* База отсчёта — с текущего тика, чтобы первый wait не «опоздал». */ + _gfx_fps_last = _gfx_frame_tick; + } + _gfx_fps_div = n; + return 0; +} diff --git a/libbgi/common/gfx_wait_vsync.c b/libbgi/common/gfx_wait_vsync.c index 400ed5a..8043c21 100644 --- a/libbgi/common/gfx_wait_vsync.c +++ b/libbgi/common/gfx_wait_vsync.c @@ -17,6 +17,12 @@ * число попыток (иное железо, бит ведёт себя не как в MAME) — * откатываемся на одно кадровое прерывание (halt), чтобы не * зависнуть намертво. + * + * FPS-ДЕЛИТЕЛЬ (docs/sprite-api-design.md §9е): при _gfx_fps_div>=2 + * (выставлен gfx_set_fps_div) идём СЧЁТЧИКОВЫМ путём — ждём, пока + * фоновый ISR насчитает n кадровых фронтов от прошлого выхода. В + * отличие от поллинга это ловит фронты, прошедшие ВО ВРЕМЯ рендера + * (elapsed>=n → выравнивание на ближайший фронт, без накопления фазы). */ #include "../_bgi.h" @@ -29,7 +35,22 @@ static void vsync_cleanup(void) _cbl_port_unref(); } -void gfx_wait_vsync(void) +/* EI + HALT: разбудит ЛЮБОЕ прерывание (кадр/клавиатура/CBL), вызывающий + * цикл перепроверит счётчик. EI обязателен (как в fallback ниже). */ +static void gfx_frame_halt(void) __naked +{ + __asm + ei + halt + ret + __endasm; +} + +/* Лучевой поллинг (n<=1) — выделен в отдельную функцию: её тело + * заканчивается непрозрачным __asm-блоком, поэтому в НЕЙ не должно быть + * переходов «через asm» (SDCC не эмитит эпилог-метку за таким блоком). + * Ранний return делителя живёт в gfx_wait_vsync (чистый C). */ +static void gfx_wait_vsync_beam(void) { if (!vsync_ready) { vsync_ready = 1; @@ -70,3 +91,26 @@ void gfx_wait_vsync(void) _gwv_done: __endasm; } + +void gfx_wait_vsync(void) +{ + /* --- счётчиковый путь делителя (n>=2) --- */ + if (_gfx_fps_div >= 2) { + uint8_t n = _gfx_fps_div; + if ((uint8_t)(_gfx_frame_tick - _gfx_fps_last) >= n) { + /* Слот истёк (рендер занял >= n интервалов) — ближайший + * фронт, чтобы не копить фазовую ошибку. */ + uint8_t t = _gfx_frame_tick; + while (_gfx_frame_tick == t) + gfx_frame_halt(); + } else { + while ((uint8_t)(_gfx_frame_tick - _gfx_fps_last) < n) + gfx_frame_halt(); + } + _gfx_fps_last = _gfx_frame_tick; + return; + } + + /* --- n<=1: обычный лучевой поллинг (поведение бит-в-бит прежнее) --- */ + gfx_wait_vsync_beam(); +} diff --git a/libbgi/include/gfx.h b/libbgi/include/gfx.h index 8c85b11..fc61af9 100644 --- a/libbgi/include/gfx.h +++ b/libbgi/include/gfx.h @@ -105,6 +105,17 @@ void gfx_heal(int x, int y, int w, int h); * gfx_set_visible_page(hidden); // tear-free flip */ void gfx_wait_vsync(void); +/* FPS-делитель (frame pacing): логический кадр = РОВНО n кадровых + * интервалов, независимо от плавания длительности рендера. n: 1 = 50 fps + * (дефолт), 2 = 25, 3 = ~16.7, 4 = 12.5, ...; 0 трактуется как 1. + * Меняет ТОЛЬКО поведение gfx_wait_vsync() (call-sites не трогаются): + * при n>=2 vsync ждёт n фронтов по фоновому счётчику кадров (ISR), + * выравниваясь на ближайший фронт при переполнении слота (без дрейфа); + * при n<=1 — обычный лучевой поллинг. + * Возврат 0 / -1+errno: EINVAL (неподходящий memory mode — данные не в + * W2), ENOMEM (заняты все слоты цепочки кадровых обработчиков). */ +int gfx_set_fps_div(uint8_t n); + /* ---- Bitmap-font text -------------------------------------------- * * Шрифт 256 глифов × 8 рядов × 1 байт (ZX-Spectrum формат), 2 КБ. * Грузится лениво при первом использовании BGI-текста; gfx_set_font diff --git a/tests/fpsdiv/Makefile b/tests/fpsdiv/Makefile new file mode 100644 index 0000000..5f5ed68 --- /dev/null +++ b/tests/fpsdiv/Makefile @@ -0,0 +1,6 @@ +# Build fpsdiv.exe — тест FPS-делителя (frame pacing, §9е). + +PROJ_ROOT := $(abspath $(CURDIR)/../..) +EXAMPLE := fpsdiv +EXTRA_FLAGS ?= --gfx 256 +include $(PROJ_ROOT)/app.mk diff --git a/tests/fpsdiv/fpsdiv.c b/tests/fpsdiv/fpsdiv.c new file mode 100644 index 0000000..503bada --- /dev/null +++ b/tests/fpsdiv/fpsdiv.c @@ -0,0 +1,107 @@ +/* + * fpsdiv — тест FPS-делителя (frame pacing, docs/sprite-api-design.md §9е). + * + * Проверяет: + * 1. коэффициент: gfx_set_fps_div(n) → gfx_wait_vsync() ждёт РОВНО n + * кадровых интервалов; 20 wait'ов копят ровно 20·n кадров (точное + * значение = отсутствие дрейфа фазы); + * 2. поглощение перерасхода: при n=2 с рендер-заглушкой ~1 кадр + * период держится 2 (wait добирает до границы слота), а не уезжает + * в 3 — то, чего наивный «жди n фронтов подряд» не умеет; + * 3. сосуществование со СВОИМ хендлером приложения: тест сам держит + * слот цепи (on_frame), делитель добавляет второй — оба тикают, + * gfx_set_fps_div(1) снимает ТОЛЬКО свой слот. + * + * Графический режим НЕ инициализируется: счётчиковый путь gfx_wait_vsync + * трогает лишь _gfx_frame_tick + halt (видео не нужно). Собственный + * счётчик кадров (on_frame) меряет реальные фронты независимо от делителя. + */ + +#include +#include +#include +#include + +static volatile uint16_t frames; /* реальные кадры (наш слот цепи) */ + +static void on_frame(void) +{ + frames++; +} + +/* Атомарное чтение счётчика, разделяемого с ISR. */ +static uint16_t rd(void) +{ + uint16_t v; + IRQ_DISABLE(); + v = frames; + IRQ_ENABLE(); + return v; +} + +/* Занятая пауза без ESTEX — «рендер». */ +static void busy(uint16_t loops) +{ + while (loops--) { + __asm nop __endasm; + } +} + +int main(void) +{ + /* Свой слот цепи — независимый счётчик реальных кадров. */ + if (irq_chain_add(on_frame) != 0) { + printf("chain_add(on_frame): errno=%d\n", errno); + return 1; + } + + /* --- 1. коэффициент n=1/2/3 --- */ + for (uint8_t n = 1; n <= 3; n++) { + if (gfx_set_fps_div(n) != 0) { + printf("set_fps_div(%u): errno=%d\n", n, errno); + return 1; + } + gfx_wait_vsync(); /* выровняться на границу слота */ + uint16_t f0 = rd(); + for (uint8_t i = 0; i < 20; i++) + gfx_wait_vsync(); + uint16_t df = rd() - f0; + printf("n=%u: %u frames / 20 waits (expect %u; drift=%d)\n", + n, df, (unsigned)(20 * n), (int)df - (int)(20 * n)); + } + + /* --- 2. поглощение перерасхода при n=2 --- */ + gfx_set_fps_div(2); + gfx_wait_vsync(); + + /* Откалибровать заглушку так, чтобы «рендер» пересекал ~1 фронт. */ + uint16_t spin = 2000; + for (;;) { + uint16_t a = rd(); + busy(spin); + if (rd() - a >= 1) break; /* заняли >= 1 интервала */ + spin = (uint16_t)(spin + spin / 2 + 1000); + if (spin > 60000u) break; + } + printf("stub spin=%u (~1 frame of render)\n", spin); + + gfx_wait_vsync(); /* чистая граница слота */ + uint16_t p0 = rd(); + for (uint8_t i = 0; i < 20; i++) { + busy(spin); /* рендер ~1 кадр */ + gfx_wait_vsync(); /* слот n=2 должен добрать до 2 */ + } + uint16_t pd = rd() - p0; + printf("n=2 +render: %u frames / 20 waits (expect 40; naive~60)\n", pd); + + /* --- 3. снять ТОЛЬКО слот делителя, наш on_frame обязан жить --- */ + gfx_set_fps_div(1); + uint16_t g0 = rd(); + busy(40000); /* дать нескольким кадрам пройти */ + uint16_t gd = rd() - g0; + printf("after fps_div(1): on_frame still ticks: %u (>0)\n", gd); + + irq_chain_remove(on_frame); + puts("fpsdiv done."); + return 0; +}