libbgi: FPS-делитель gfx_set_fps_div(n) поверх цепочки кадровых IRQ

Логический кадр = ровно 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 10:36:37 +03:00
parent 2c6f4e33c3
commit 1b4fbeaa6b
11 changed files with 290 additions and 8 deletions
+13
View File
@@ -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
+21
View File
@@ -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;
+14
View File
@@ -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++;
}
+42
View File
@@ -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 <irq.h>
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;
}
+45 -1
View File
@@ -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();
}
+11
View File
@@ -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