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 — делаем сейчас
### 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).** Залипаний почти нет, но при
УДЕРЖИВАЕМОМ Shift первые один-два нажатия ← дают осторожные шаги, дальше
@@ -183,8 +196,8 @@
Плюс пришлось бы переписать все наши константы кодов под Set 3. Значит это
кандидат на «когда дойдём до реального железа», а не на сейчас.
**Что делать (в порядке зависимостей):**
1. Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания
**Что сделано по этому плану (2026-08-01):**
1. Idle-хук в libbgi: `gfx_set_idle_hook(fn)`, вызывается в цикле ожидания
луча внутри `gfx_wait_vsync`. Приложение ставит туда `kbd_raw_poll`.
Полезен не только нам — любой программе даёт «качать» что-то в ожидании
кадра. Осторожно с регистрами: цикл ждёт на BC-таймауте, вокруг вызова
@@ -196,9 +209,14 @@
потери случаются ТОЛЬКО при зажатом модификаторе (замерено; без Shift
потерь нет). Значит хук можно взводить лишь пока нажат Shift/Ctrl/Alt —
тогда опрос работает исключительно в той ситуации, ради которой заведён.
2. Вернуть вызовы в занятую треть кадра (они бесплатны, просто сами по себе
ничего не решали).
3. Перемерить тем же счётным методом уже в roomtest: цель — 25/25.
2. ✅ Перемерено в roomtest тем же счётным методом: **35/35**, потерь нет.
3. ⏳ Вызовы в занятую треть кадра НЕ возвращены: одного idle-хука хватило.
Держать в уме, если на реальном железе или на более тяжёлых сценах
(несколько стражей) потери появятся снова — накрыть блиты дешевле, чем
изобретать что-то новое.
4. ⏳ Ручная проверка пользователем — без неё этап не закрыт: скриптовые
нажатия ровнее человеческих, и «залипания до отпускания Shift» они не
воспроизводили с самого начала.
**Куда смотреть дальше, если плотного опроса не хватит.**
1. **Драйвер MAME — НЕ ТРОГАЕМ** (решение пользователя: пересборка MAME на
+25 -13
View File
@@ -57,22 +57,32 @@ const uint8_t n_banks = 4;
/* Режим читов (pop_cheat.h): на время разработки включаем в main. */
uint8_t pop_cheats;
/* ПОТЕРЯ НАЖАТИЙ ПРИ ЗАЖАТОМ Shift — НЕ ЧИНИТСЯ ИЗ ЭТОГО ЦИКЛА.
/* ПОТЕРЯ НАЖАТИЙ ПРИ ЗАЖАТОМ Shift — лечится ПЛОТНЫМ опросом клавиатуры.
*
* Здесь стояли шесть вызовов kbd_raw_poll() (вычерпать FIFO клавиатуры
* после каждой тяжёлой фазы кадра). Убраны: замерено, что они не меняют
* НИЧЕГО — 9 дошедших make-байт из 10 нажатий и с ними, и без них. Они
* стояли ровно там, где прерывания и так разрешены, то есть добавляли то,
* что трамплин делает сам.
* При зажатом Shift PS/2 обрамляет расширенный код «фиктивным шифтом», и
* одно нажатие стрелки превращается в 5 байт вместо 2 (отпускание — тоже
* 5). Приёмный FIFO SIO — 3 байта, а импульс запроса прерывания здесь
* теряется примерно в 44 % случаев, поэтому байты копятся и один из
* полусотни пропадает ДО чтения порта: нажатие «не срабатывает».
*
* Настоящая причина лежит НИЖЕ нашего кода: при зажатом Shift PS/2 шлёт на
* расширенную клавишу вдвое больше байт («fake shift»), а импульс запроса
* прерывания пропускается примерно в 44 % случаев — байты копятся в
* трёхбайтовом FIFO SIO и один из полусотни теряется ДО чтения порта.
* Длина наших DI-окон ни при чём: в замороженном кадре, где блитов нет
* вообще, потерь БОЛЬШЕ, а не меньше.
* Что НЕ помогает (замерено, TASKS.md/KBD-1): редкий опрос (несколько
* вызовов за кадр — они попадают туда, где прерывания и так разрешены, и
* лишь дублируют трамплин) и укорачивание DI-окон графики (в кадре вообще
* без блитов потерь БОЛЬШЕ). Снимать `di` в accel-ядрах нельзя — машина
* уходит в перезагрузку.
*
* Полный протокол замеров и куда копать дальше — 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 стоит, ждёт ввода. */
#define SEQ_STAND 2
@@ -317,6 +327,7 @@ int main(void)
puts("kbd_raw open failed");
return 1;
}
gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */
while (!kbd_raw_down(KBD_ESC)) {
/* SPACE — тумблер дабл-буфера (edge, чтоб одно нажатие = один
@@ -583,6 +594,7 @@ int main(void)
}
}
gfx_set_idle_hook(0); /* снять хук ДО закрытия raw-канала */
pop_ctrl_close();
closegraph();
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_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) --------------------------------- *
* Фоновый счётчик кадров через цепочку кадровых IRQ (libc/irq). Данные
* в 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) ---
ld bc, #0 ; ~65536 попыток на фазу таймаут
_gwv_wait1:
call _gwv_idle ; занять простой (см. gfx_set_idle_hook)
in a, (#0xFE)
bit 5, a
jr nz, _gwv_have1
@@ -75,6 +76,7 @@ static void gfx_wait_vsync_beam(void)
;; --- фаза 2: дождаться bit5==0 (сам переход начало кадра) ---
ld bc, #0
_gwv_wait0:
call _gwv_idle
in a, (#0xFE)
bit 5, a
jr z, _gwv_done
@@ -83,6 +85,26 @@ static void gfx_wait_vsync_beam(void)
or c
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:
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 */
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 кадровых
* интервалов, независимо от плавания длительности рендера. n: 1 = 50 fps
* (дефолт), 2 = 25, 3 = ~16.7, 4 = 12.5, ...; 0 трактуется как 1.