libbgi: idle-хук в ожидании кадра; им лечится потеря нажатий с Shift

Причина потерь (замеры — applications/PoP/roomtest/TASKS.md, KBD-1): при
зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», нажатие
стрелки становится 5 байтами вместо 2, а импульс запроса прерывания здесь
теряется примерно в 44 % случаев — трёхбайтовый FIFO SIO переполняется, и
байт пропадает ДО чтения порта.  Лечится только плотным вычерпыванием: раз
в ~0.5 мс.  Столько времени есть даром — при пейсинге «3 растровых кадра на
логический тик» процессор проводит ~42 мс из 60 в gfx_wait_vsync, крутя
опрос луча и больше ничего не делая.

- gfx_set_idle_hook(fn) — что вызывать, пока gfx_wait_vsync ждёт луч.
  Состояние в отдельном data-модуле (_gfx_idle_state.c), чтобы не тянуть
  сеттер в программы, которые хук не ставят.
- Лучевой цикл зовёт хук в обеих фазах.  BC (счётчик таймаута)
  сохраняется, косвенный вызов — push адреса возврата + jp (hl), так как
  `call (hl)` в Z80 нет; без хука это ret по нулевому указателю, порядка
  двух десятков тактов в цикле, который и так сжигает время.
- Путь FPS-делителя не затронут: там ожидание через HALT.
- roomtest вешает на хук kbd_raw_poll.

Проверка в MAME счётчиками (брейкпоинты с { b@ADDR = b@ADDR+1 ; g } на
чтении порта 0x18 и на установке make-бита): 35 нажатий Shift+Home → 35
make, ноль потерь; до фикса было 9 из 10.  Боевой сценарий: четыре Shift+→
подряд дали четыре осторожных шага (Kid.x 114 -> 147).  _CODE +170 Б,
кадровый бюджет не затронут.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Александр Петров
2026-08-01 16:05:24 +03:00
parent b56f2b4582
commit 4b498d171b
7 changed files with 125 additions and 19 deletions
+24 -6
View File
@@ -13,7 +13,20 @@
## P0 — делаем сейчас ## P0 — делаем сейчас
### KBD-1. Shift + стрелки: нажатия теряются — определить причину ### KBD-1. Shift + стрелки: нажатия теряются — **ПРИЧИНА НАЙДЕНА, ФИКС ПРОВЕРЕН**
> **Итог (2026-08-01).** Причина — не наш код и не DI-окна графики: при
> зажатом Shift PS/2 удваивает трафик («fake shift»), а импульс запроса
> прерывания здесь теряется примерно в 44 % случаев, и трёхбайтовый FIFO
> SIO переполняется. Лечится ПЛОТНЫМ опросом: `kbd_raw_poll` повешен
> idle-хуком на ожидание кадра (`gfx_set_idle_hook`, новый API libbgi) —
> процессор всё равно проводит там ~42 мс из 60, крутя опрос луча.
>
> **Проверка в roomtest тем же счётным методом: 35 нажатий Shift+Home →
> 35 make, ноль потерь** (до фикса — 9 из 10). Боевой сценарий тоже:
> четыре Shift+→ подряд дали четыре осторожных шага, `Kid.x` 114 → 147.
> Цена: `_CODE` +170 Б, кадровый бюджет не затронут (опрос стоит в
> простое). Ниже — полный протокол, как к этому пришли.
**Симптом (пользователь, 2026-08-01).** Залипаний почти нет, но при **Симптом (пользователь, 2026-08-01).** Залипаний почти нет, но при
УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше
@@ -183,8 +196,8 @@
Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это
кандидат на «когда дойдём до реального железа», а не на сейчас. кандидат на «когда дойдём до реального железа», а не на сейчас.
**Что делать (в порядке зависимостей):** **Что сделано по этому плану (2026-08-01):**
1. Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания 1. Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания
луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`. луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`.
Полезен не только нам — любой программе даёт «качать» что-то в ожидании Полезен не только нам — любой программе даёт «качать» что-то в ожидании
кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова
@@ -196,9 +209,14 @@
потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift
потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt — потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt —
тогда опрос работает исключительно в той ситуации, ради которой заведён. тогда опрос работает исключительно в той ситуации, ради которой заведён.
2. Вернуть вызовы в занятую треть кадра (они бесплатны, просто сами по себе 2. ✅ Перемерено в roomtest тем же счётным методом: **35/35**, потерь нет.
ничего не решали). 3. ⏳ Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило.
3. Перемерить тем же счётным методом уже в roomtest: цель — 25/25. Держать в уме, если на реальном железе или на более тяжёлых сценах
(несколько стражей) потери появятся снова — накрыть блиты дешевле, чем
изобретать что-то новое.
4. ⏳ Ручная проверка пользователем — без неё этап не закрыт: скриптовые
нажатия ровнее человеческих, и «залипания до отпускания Shift» они не
воспроизводили с самого начала.
**Куда смотреть дальше, если плотного опроса не хватит.** **Куда смотреть дальше, если плотного опроса не хватит.**
1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на 1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на
+25 -13
View File
@@ -57,22 +57,32 @@ const uint8_t n_banks = 4;
/* Режим читов (pop_cheat.h): на время разработки включаем в main. */ /* Режим читов (pop_cheat.h): на время разработки включаем в main. */
uint8_t pop_cheats; uint8_t pop_cheats;
/* ПОТЕРЯ НАЖАТИЙ ПРИ ЗАЖАТОМ Shift — НЕ ЧИНИТСЯ ИЗ ЭТОГО ЦИКЛА. /* ПОТЕРЯ НАЖАТИЙ ПРИ ЗАЖАТОМ Shift — лечится ПЛОТНЫМ опросом клавиатуры.
* *
* Здесь стояли шесть вызовов kbd_raw_poll() (вычерпать FIFO клавиатуры * При зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», и
* после каждой тяжёлой фазы кадра). Убраны: замерено, что они не меняют * одно нажатие стрелки превращается в 5 байт вместо 2 (отпускание — тоже
* НИЧЕГО — 9 дошедших make-байт из 10 нажатий и с ними, и без них. Они * 5). Приёмный FIFO SIO — 3 байта, а импульс запроса прерывания здесь
* стояли ровно там, где прерывания и так разрешены, то есть добавляли то, * теряется примерно в 44 % случаев, поэтому байты копятся и один из
* что трамплин делает сам. * полусотни пропадает ДО чтения порта: нажатие «не срабатывает».
* *
* Настоящая причина лежит НИЖЕ нашего кода: при зажатом Shift PS/2 шлёт на * Что НЕ помогает (замерено, TASKS.md/KBD-1): редкий опрос (несколько
* расширенную клавишу вдвое больше байт («fake shift»), а импульс запроса * вызовов за кадр — они попадают туда, где прерывания и так разрешены, и
* прерывания пропускается примерно в 44 % случаев — байты копятся в * лишь дублируют трамплин) и укорачивание DI-окон графики (в кадре вообще
* трёхбайтовом FIFO SIO и один из полусотни теряется ДО чтения порта. * без блитов потерь БОЛЬШЕ). Снимать `di` в accel-ядрах нельзя — машина
* Длина наших DI-окон ни при чём: в замороженном кадре, где блитов нет * уходит в перезагрузку.
* вообще, потерь БОЛЬШЕ, а не меньше.
* *
* Полный протокол замеров и куда копать дальше — TASKS.md, KBD-1. */ * Что помогает: опрос раз в ~0.5 мс. Столько времени берётся из ожидания
* кадра — процессор всё равно крутит там ~42 мс из 60, ничего не делая.
* Поэтому kbd_raw_poll вешается idle-хуком на gfx_wait_vsync (см.
* kbd_idle ниже); полезной работы это не отнимает. Потолок приёма
* подтверждён отдельно (tests/kbdpoll: 25 нажатий → 25, 250 байт из 250). */
/* Обёртка ради типа: хук — void(*)(void), а kbd_raw_poll возвращает
* «было ли что вычерпано». Приводить указатель кастом не хочется. */
static void kbd_idle(void)
{
kbd_raw_poll();
}
/* seq_2_stand (types.h seqids) — Kid стоит, ждёт ввода. */ /* seq_2_stand (types.h seqids) — Kid стоит, ждёт ввода. */
#define SEQ_STAND 2 #define SEQ_STAND 2
@@ -317,6 +327,7 @@ int main(void)
puts("kbd_raw open failed"); puts("kbd_raw open failed");
return 1; return 1;
} }
gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */
while (!kbd_raw_down(KBD_ESC)) { while (!kbd_raw_down(KBD_ESC)) {
/* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один /* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один
@@ -583,6 +594,7 @@ int main(void)
} }
} }
gfx_set_idle_hook(0); /* снять хук ДО закрытия raw-канала */
pop_ctrl_close(); pop_ctrl_close();
closegraph(); closegraph();
pop_bg_free(); pop_bg_free();
+5
View File
@@ -347,6 +347,11 @@ void _bgi_poly_edge(int x0, int y0, int x1, int y1);
extern void _cbl_port_ref(void); extern void _cbl_port_ref(void);
extern void _cbl_port_unref(void); extern void _cbl_port_unref(void);
/* Idle-хук ожидания кадра (common/_gfx_idle_state.c, ставится
* gfx_set_idle_hook). NULL = нет; зовётся из лучевого цикла
* gfx_wait_vsync. */
extern void (*_gfx_idle_hook)(void);
/* ---- FPS-делитель (frame pacing) --------------------------------- * /* ---- FPS-делитель (frame pacing) --------------------------------- *
* Фоновый счётчик кадров через цепочку кадровых IRQ (libc/irq). Данные * Фоновый счётчик кадров через цепочку кадровых IRQ (libc/irq). Данные
* в common/_gfx_fps_state.c; при _gfx_fps_div<=1 путь не активен и * в common/_gfx_fps_state.c; при _gfx_fps_div<=1 путь не активен и
+10
View File
@@ -0,0 +1,10 @@
/*
* _gfx_idle_state — указатель на idle-хук, который gfx_wait_vsync зовёт,
* пока ждёт луч. Отдельный data-модуль: на него ссылаются и установщик
* (gfx_set_idle_hook), и сам цикл ожидания, а тянуть один из-за другого
* в программу, которая хук не ставит, ни к чему. Без инициализации
* (crt0 зануляет _DATA — NULL = хука нет).
*/
#include "../_bgi.h"
void (*_gfx_idle_hook)(void);
+27
View File
@@ -0,0 +1,27 @@
/*
* gfx_set_idle_hook — что вызывать, пока gfx_wait_vsync ждёт луч.
*
* ЗАЧЕМ. Ожидание кадра — это самый длинный простой в кадре: у игры с
* пейсингом «3 растровых кадра на логический тик» процессор проводит там
* ~42 мс из 60, крутя `in a,(#0xFE)` и больше ничего не делая. Хук
* позволяет занять это время полезным опросом, не отнимая ни такта у
* полезной работы.
*
* Первый потребитель (и повод завести) — клавиатура: приёмный FIFO SIO
* всего 3 байта, а одно нажатие расширенной клавиши при зажатом Shift
* даёт пачку из 5 байт («fake shift»); если импульс запроса прерывания
* потерян, байты копятся и один теряется. Лечится вычерпыванием по
* опросу (`kbd_raw_poll` из <kbd_raw.h>) — но только ПЛОТНЫМ, раз в
* ~0.5 мс, а не пару раз за кадр. Замер: applications/PoP/roomtest/
* TASKS.md, задача KBD-1.
*
* ТРЕБОВАНИЯ К ХУКУ: он зовётся в тесном цикле, поэтому обязан быть
* дешёвым и НЕ рисовать (окно W3 в этот момент принадлежит вызывающему,
* а не графике) и не ждать сам. fn = 0 — снять хук.
*/
#include "../_bgi.h"
void gfx_set_idle_hook(void (*fn)(void))
{
_gfx_idle_hook = fn;
}
+22
View File
@@ -62,6 +62,7 @@ static void gfx_wait_vsync_beam(void)
;; --- фаза 1: дождаться bit5==1 (гарантированно в blank) --- ;; --- фаза 1: дождаться bit5==1 (гарантированно в blank) ---
ld bc, #0 ; ~65536 попыток на фазу таймаут ld bc, #0 ; ~65536 попыток на фазу таймаут
_gwv_wait1: _gwv_wait1:
call _gwv_idle ; занять простой (см. gfx_set_idle_hook)
in a, (#0xFE) in a, (#0xFE)
bit 5, a bit 5, a
jr nz, _gwv_have1 jr nz, _gwv_have1
@@ -75,6 +76,7 @@ static void gfx_wait_vsync_beam(void)
;; --- фаза 2: дождаться bit5==0 (сам переход начало кадра) --- ;; --- фаза 2: дождаться bit5==0 (сам переход начало кадра) ---
ld bc, #0 ld bc, #0
_gwv_wait0: _gwv_wait0:
call _gwv_idle
in a, (#0xFE) in a, (#0xFE)
bit 5, a bit 5, a
jr z, _gwv_done jr z, _gwv_done
@@ -83,6 +85,26 @@ static void gfx_wait_vsync_beam(void)
or c or c
jr nz, _gwv_wait0 jr nz, _gwv_wait0
;; таймаут и здесь переходим к тому же фолбэку ;; таймаут и здесь переходим к тому же фолбэку
jr _gwv_fallback
;; --- вызов idle-хука ------------------------------------- *
;; BC (счётчик таймаута) сохраняем: хук обычная C-функция и
;; клобберит всё, кроме IX. Косвенный вызов через push адреса
;; возврата + jp (hl): `call (hl)` в Z80 нет. Цена холостого
;; прохода около двух десятков тактов в цикле, который и так
;; сжигает время впустую.
_gwv_idle:
ld hl, (__gfx_idle_hook)
ld a, h
or l
ret z ; хук не поставлен
push bc
ld de, #_gwv_idle_ret
push de ; адрес возврата для jp (hl)
jp (hl)
_gwv_idle_ret:
pop bc
ret
_gwv_fallback: _gwv_fallback:
ei ei
+12
View File
@@ -150,6 +150,18 @@ void gfx_heal(int x, int y, int w, int h);
* gfx_set_visible_page(hidden); // tear-free flip */ * gfx_set_visible_page(hidden); // tear-free flip */
void gfx_wait_vsync(void); void gfx_wait_vsync(void);
/* Что вызывать, ПОКА gfx_wait_vsync ждёт луч. Ожидание кадра — самый
* длинный простой в кадре (при пейсинге «3 растровых кадра на логический
* тик» это ~42 мс из 60), и хук занимает его, не отнимая тактов у
* полезной работы. Повод — вычерпывание FIFO клавиатуры по опросу
* (`kbd_raw_poll`, см. <kbd_raw.h>): трёхбайтовый приёмник переполняется
* на пачках «fake shift», а плотность нужна ~раз в 0.5 мс, которую иначе
* взять негде. Хук зовётся в тесном цикле: он обязан быть дешёвым, НЕ
* рисовать (окно W3 сейчас не за графикой) и не ждать сам. fn = 0 —
* снять. На путь FPS-делителя (gfx_set_fps_div n>=2) не влияет: там
* ожидание идёт через HALT, и процессор не крутит цикл. */
void gfx_set_idle_hook(void (*fn)(void));
/* FPS-делитель (frame pacing): логический кадр = РОВНО n кадровых /* FPS-делитель (frame pacing): логический кадр = РОВНО n кадровых
* интервалов, независимо от плавания длительности рендера. n: 1 = 50 fps * интервалов, независимо от плавания длительности рендера. n: 1 = 50 fps
* (дефолт), 2 = 25, 3 = ~16.7, 4 = 12.5, ...; 0 трактуется как 1. * (дефолт), 2 = 25, 3 = ~16.7, 4 = 12.5, ...; 0 трактуется как 1.