libbgi: переворот экрана акселератором (gfx_copy_page + _bgi_flip_rows_raw)

Первый шаг L9-INVERT.  Разведка железа закрыта экспериментом: Port_Y можно
менять между read- и write-триггером, если перед вторым OUT стоит STOP —
приём уже был в проде в _bgi_scroll_cols_raw (указал пользователь), так что
обходной путь через ОЗУ-буфер не понадобился.

- _bgi_flip_rows_raw: построчная копия video->video с реверсом Port_Y
  приёмника (DI/EI бандами по 16 строк, размер блока армируется в каждой
  скобке, убывающий y1 держится SMC-ячейкой);
- gfx_copy_page(area, GFX_COPY_DIRECT|GFX_COPY_VFLIP): копия области из
  неактивной страницы в активную; банк 0x50, то есть приёмник обновляется и
  в VRAM, и в ОЗУ-копии — иначе heal восстанавливал бы старый фон;
- tests/pageflip: 5/5 PASS в MAME (прямая, vflip, рамка, широкая 320 двумя
  проходами, источник цел).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 16:38:10 +03:00
parent 52b43b3d81
commit 7fe4c25ad1
8 changed files with 454 additions and 1 deletions
+22 -1
View File
@@ -33,7 +33,28 @@
# Часть I — libbgi # Часть I — libbgi
## I.0 РАЗВЕДКА ЖЕЛЕЗА — делать ПЕРВОЙ, до любого кода ## I.0 РАЗВЕДКА ЖЕЛЕЗА — ЗАКРЫТА 2026-08-12
**Оба факта подтверждены, ядро и обёртка написаны и проверены в MAME**
(`tests/pageflip`, 5/5 PASS — прямая копия, vflip, целость рамки, широкая
копия 320 = два прохода, неприкосновенность источника).
- **Р1 подтверждён**: Port_Y можно менять между read- и write-триггером,
если ПЕРЕД вторым OUT стоит STOP (`LD B,B`) — он разоружает accel, и fetch
immediate-операнда OUT уже безопасен. Приём не новый: ровно так работает
`_bgi_scroll_cols_raw` (ядро `gfx_scroll_v`), то есть он был в проде
задолго до этой задачи — указал пользователь. Значит и обходной путь
через ОЗУ-буфер не нужен;
- **Р2 подтверждён**: обе страницы адресуемы одновременно, копия между ними
идёт по разнице баз (`_gfx_addr_shadow_base` / `_gfx_addr_base`), строку
выбирает Port_Y.
Сделано: `libbgi/bgi256/_bgi_flip_rows_raw.c` (ядро) +
`libbgi/common/gfx_copy_page.c` (обёртка, режимы `GFX_COPY_DIRECT` /
`GFX_COPY_VFLIP`) + `tests/pageflip`. Пункты I.1 и I.3 ниже — ЗАКРЫТЫ этим
же коммитом; описание оставлено как контракт.
### Исходный текст разведки (для истории)
Два факта, на которых стоит вся схема, сейчас НЕ подтверждены артефактом. Два факта, на которых стоит вся схема, сейчас НЕ подтверждены артефактом.
Пока их нет, остальные пункты не начинать (memory `defer_unexplained_quirks`). Пока их нет, остальные пункты не начинать (memory `defer_unexplained_quirks`).
+1
View File
@@ -41,6 +41,7 @@ malloc 4461
mem_test 4555 mem_test 4555
mouse 4382 mouse 4382
openenv 6124 openenv 6124
pageflip 4960
palfile 5406 palfile 5406
ptime 5744 ptime 5744
rt_test 5187 rt_test 5187
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
41 mem_test
42 mouse
43 openenv
44 pageflip
45 palfile
46 ptime
47 rt_test
+9
View File
@@ -163,6 +163,15 @@ void _bgi_scroll_rows_raw(uint16_t src, uint16_t dst,
void _bgi_scroll_cols_raw(uint16_t src, uint16_t dst, void _bgi_scroll_cols_raw(uint16_t src, uint16_t dst,
uint8_t w, uint8_t h, uint8_t y0, uint8_t y1); uint8_t w, uint8_t h, uint8_t y0, uint8_t y1);
/* _bgi_flip_rows_raw — ядро gfx_copy_page(GFX_COPY_VFLIP): построчная копия
* с ПЕРЕВОРОТОМ ПО ВЕРТИКАЛИ. Как scroll_rows, но Port_Y чтения растёт, а
* Port_Y записи УБЫВАЕТ, поэтому на строку нужны два OUT — безопасные
* благодаря STOP'у между read- и write-триггером (приём из scroll_cols).
* w — байт/строка (0=256), h — строк 1..256 (0=256), y0 — Port_Y первой
* строки src (++), y1 — Port_Y первой строки dst (--). */
void _bgi_flip_rows_raw(uint16_t src, uint16_t dst,
uint8_t w, uint8_t h, uint8_t y0, uint8_t y1);
+151
View File
@@ -0,0 +1,151 @@
/*
* _bgi_flip_rows_raw — построчная копия video->video с ПЕРЕВОРОТОМ ПО
* ВЕРТИКАЛИ (mode 0x81). Строка src с Port_Y = y0 уходит в строку dst с
* Port_Y = y1, дальше y0 растёт, а y1 УБЫВАЕТ: за h строк область
* отражается сверху вниз. Ядро gfx_copy_page(GFX_COPY_VFLIP) — переворот
* экрана для зелья инверсии (applications/PoP/docs/l9_invert_plan.md).
*
* Отличие от _bgi_scroll_rows_raw: там read и write идут на ОДНОМ Port_Y
* (одна строка), поэтому OUT на строку один. Здесь строки разные, значит
* Port_Y надо сменить МЕЖДУ read- и write-триггером — а это ровно тот
* случай, где наивная схема ломается: accel перехватывает любое чтение
* программной памяти, и fetch immediate-операнда OUT спурьезно
* перезапустил бы read-burst, набив буфер кодом
* (memory/accel_operand_fetch_retrigger).
*
* РЕШЕНИЕ — то же, что в _bgi_scroll_cols_raw (там оно уже в проде,
* gfx_scroll_v): STOP (LD B,B) ПЕРЕД вторым OUT. Он разоружает accel
* (m_acc_dir = 0), после чего операнд OUT безопасен; затем LD L,L снова
* взводит горизонтальный режим — уже для записи. Порядок на строку:
* {OUT y0 · arm · read · STOP · OUT y1 · arm · write · STOP}
* Оба OUT выполняются только пока accel РАЗОРУЖЁН — это инвариант, на
* котором всё держится; менять порядок нельзя.
*
* Убывающий y1 держим SMC-ячейкой, а не регистром: HL/DE заняты адресами,
* B — счётчиком банды, C — растущим y0, и свободного 8-битного не
* остаётся. Обновление ячейки (`dec a` + `ld (…),a`) стоит на строке
* 17Т и идёт ПОСЛЕ STOP'а, то есть при разоружённом accel — безопасно.
*
* DI/EI — БАНДАМИ по 16 строк ВНУТРИ (как у scroll-ядер): держать DI на
* весь проход нельзя (порвёт CBL-звук). Размер блока армируется в КАЖДОЙ
* скобке — в окне EI между бандами CBL-ISR армирует accel своим размером.
*
* ВХОД (__sdcccall(1)): src->HL, dst->DE; стек: w 4(ix) — байт в строке
* (размер accel-блока, 1..256, 0 = 256), h 5(ix) — строк 1..256 (0 = 256,
* конвенция accel), y0 6(ix) — Port_Y ПЕРВОЙ строки src (инкремент),
* y1 7(ix) — Port_Y ПЕРВОЙ строки dst (ДЕКРЕМЕНТ). Callee-pop 4 байта.
*
* Raw: клиппинга нет (вызывающий), W3 замаплен (_bgi_begin/_bgi_end
* снаружи), банк 0x50 (запись бьёт VRAM + ОЗУ-копию — иначе перевёрнутый
* фон не попадёт в копию и heal начнёт восстанавливать старую картинку).
* Перекрытия src/dst нет by design — стороны в РАЗНЫХ видеостраницах.
* Строку > 256 байт режет вызывающий. Клоббер: AF/BC; HL/DE/IX
* сохраняются (LD L,L / LD D,D / LD B,B — accel-опкоды-no-op'ы).
*/
#include "../_bgi.h"
void _bgi_flip_rows_raw(uint16_t src, uint16_t dst,
uint8_t w, uint8_t h, uint8_t y0, uint8_t y1) __naked {
(void)src; (void)dst; (void)w; (void)h; (void)y0; (void)y1;
__asm
push ix
ld ix, #0
add ix, sp
;; HL = src, DE = dst (адреса-константы; строку задаёт Port_Y)
ld a, 4 (ix)
ld (fl_row_len), a ; SMC: размер блока <- w (0 = 256)
ld (fl_row_len2), a ; ...и в хвостовую скобку
ld a, 7 (ix)
ld (fl_row_ydst), a ; SMC: Port_Y записи <- y1 (убывает)
ld c, 6 (ix) ; C = бегущий Port_Y чтения (y0)
;; --- precompute: полных 16-банд (ix+5) + хвост (SMC); h=0 => 256 ---
ld a, 5 (ix)
and #0x0F
ld (fl_row_tail), a ; хвост = h & 15 (0 и при h=0, и при h=256)
ld a, 5 (ix)
or a, a
jr NZ, fl_row_shr
ld a, #16 ; h=0 => 256 => ровно 16 полных банд
jr fl_row_setfull
fl_row_shr:
rrca
rrca
rrca
rrca
and #0x0F ; full = h >> 4
fl_row_setfull:
ld 5 (ix), a ; ix+5 = число полных 16-строчных банд
fl_row_band:
ld a, 5 (ix)
or a, a
jr Z, fl_row_tailband ; полные банды кончились
dec 5 (ix) ; full -= 1
ld b, #16 ; 16 строк в банде
di
ld d, d ; 0x52 режим размера блока (no-op для D)
ld a, #0
fl_row_len = . - 1
ld b, b ; выключить размер сохранён (B=16 цел)
fl_row_next:
ld a, c
out (#0x89), a ; Port_Y = y0 (accel РАЗОРУЖЁН операнд безопасен)
inc c ; y0++
ld l, l ; 0x6D арм гориз. копии
ld a, (hl) ; read-burst: src-строка -> буфер
ld b, b ; 0x40 STOP (разоружить перед OUT y1)
ld a, #0
fl_row_ydst = . - 1
out (#0x89), a ; Port_Y = y1 accel разоружён
dec a
ld (fl_row_ydst), a ; y1-- (запись при разоружённом accel)
ld l, l ; 0x6D арм гориз. копии для записи
ld (de), a ; write-burst: буфер -> dst-строка
ld b, b ; 0x40 стоп
djnz fl_row_next
ei ; окно прерываний между бандами
jr fl_row_band
;; --- хвост h&15 строк: отдельная скобка со СВОИМ армированием ---
fl_row_tailband:
;; y1 у хвоста ПРОДОЛЖЕНИЕ, а не исходное значение: переносим
;; текущее из основной скобки (у неё своя immediate-ячейка).
ld a, (fl_row_ydst)
ld (fl_row_ydst2), a
ld a, #0
fl_row_tail = . - 1
or a, a
jr Z, fl_row_done ; хвоста нет
ld b, a ; B = остаток строк (1..15)
di
ld d, d
ld a, #0
fl_row_len2 = . - 1
ld b, b
fl_row_trow:
ld a, c
out (#0x89), a
inc c
ld l, l
ld a, (hl)
ld b, b
ld a, #0
fl_row_ydst2 = . - 1
out (#0x89), a
dec a
ld (fl_row_ydst2), a
ld l, l
ld (de), a
ld b, b
djnz fl_row_trow
ei
fl_row_done:
pop ix
;; callee-pop 4 байта (w, h, y0, y1)
pop hl ; ret-адрес
pop af
pop af
jp (hl)
__endasm;
}
+65
View File
@@ -0,0 +1,65 @@
/*
* gfx_copy_page — копия области из НЕактивной страницы в активную:
* прямая или с ПЕРЕВОРОТОМ ПО ВЕРТИКАЛИ (mode-agnostic обёртка).
*
* Зачем. Два сценария, оба про «взять готовую картинку целиком»:
* - GFX_COPY_DIRECT — второй странице дабл-буфера отдать то, что уже
* нарисовано в первой, вместо повторной отрисовки сцены (у тайловых
* движков это в разы дешевле и, главное, невидимо для игрока: он
* видит только готовый кадр);
* - GFX_COPY_VFLIP — перевернуть уже нарисованное вверх ногами (зелье
* инверсии в порте Prince of Persia).
*
* Направление то же, что у gfx_scroll_*: источник — НЕактивная (теневая)
* страница, приёмник — активная (draw). Банк на время копии —
* GFX_BANK_NORMAL (0x50): запись бьёт VRAM И ОЗУ-копию. Это не деталь, а
* инвариант: accel ЧИТАЕТ ОЗУ-копию, поэтому копируется чистый фон без
* спрайтов, а если писать банком без тени, копия страницы-приёмника
* останется старой и heal начнёт восстанавливать прошлую картинку.
*
* VFLIP отражает область ВНУТРИ СЕБЯ: строка area->y + i уходит в строку
* area->y + area->h - 1 - i. Горизонтального зеркала здесь нет и не
* будет — accel копирует блок только вперёд, побайтовый реверс стоил бы
* 320 burst'ов на строку вместо двух (для персонажей зеркало делается
* раскладкой спрайта, см. gfx_blit_cols).
*
* Ширина режется на burst'ы ≤ 255 байт (лимит accel-блока; 256 у нынешних
* leaf'ов даёт мусор на втором проходе — тот же TODO, что в gfx_scroll_h).
* Высоту и DI/EI-банды по 16 строк leaf делает сам.
*/
#include "../include/gfx.h"
#include "../include/sprite.h"
#include "../_bgi.h"
void gfx_copy_page(const gfx_rect_t *area, uint8_t mode) {
uint8_t saved = gfx_get_bank();
uint16_t w = area->w;
uint8_t h = (uint8_t)area->h; /* 256 => 0 — конвенция accel */
uint16_t src = _gfx_addr_shadow_base + (uint16_t)area->x;
uint16_t dst = _gfx_addr_base + (uint16_t)area->x;
uint8_t y0 = (uint8_t)area->y;
/* Port_Y приёмника: при перевороте начинаем с НИЖНЕЙ строки области и
* идём вверх; при прямой копии — та же строка, что у источника. */
uint8_t y1 = (mode == GFX_COPY_VFLIP)
? (uint8_t)(area->y + area->h - 1)
: y0;
if (!w || !area->h) return;
gfx_set_bank(GFX_BANK_NORMAL);
_bgi_begin();
while (w) {
uint8_t cw = (w > 255) ? 255 : (uint8_t)w;
if (mode == GFX_COPY_VFLIP)
_bgi_flip_rows_raw(src, dst, cw, h, y0, y1);
else
_bgi_scroll_rows_raw(src, dst, cw, h, y0);
src += cw; dst += cw; w -= cw;
}
_bgi_end();
gfx_set_bank(saved);
}
+11
View File
@@ -320,4 +320,15 @@ typedef struct {
void gfx_scroll_h(const gfx_rect_t *area, int16_t dx, gfx_rect_t *dirty); void gfx_scroll_h(const gfx_rect_t *area, int16_t dx, gfx_rect_t *dirty);
void gfx_scroll_v(const gfx_rect_t *area, int16_t dy, gfx_rect_t *dirty); void gfx_scroll_v(const gfx_rect_t *area, int16_t dy, gfx_rect_t *dirty);
/* Копия области из НЕактивной страницы в активную: прямая или с
* ПЕРЕВОРОТОМ ПО ВЕРТИКАЛИ (строка area->y + i -> area->y + h - 1 - i).
* Банк на время копии — 0x50, то есть приёмник обновляется И в VRAM, и в
* ОЗУ-копии (иначе heal целевой страницы восстанавливал бы старый фон).
* Источником служит ОЗУ-копия, поэтому переносится ЧИСТЫЙ фон — спрайты,
* нарисованные банком без тени, не копируются.
* Горизонтального зеркала нет: accel копирует блок только вперёд. */
#define GFX_COPY_DIRECT 0
#define GFX_COPY_VFLIP 1
void gfx_copy_page(const gfx_rect_t *area, uint8_t mode);
#endif #endif
+4
View File
@@ -0,0 +1,4 @@
PROJ_ROOT := $(abspath $(CURDIR)/../..)
EXAMPLE := pageflip
EXTRA_FLAGS ?= --gfx 256
include $(PROJ_ROOT)/app.mk
+191
View File
@@ -0,0 +1,191 @@
/*
* pageflip — регресс gfx_copy_page: копия области из НЕактивной страницы в
* активную, прямая (GFX_COPY_DIRECT) и с переворотом по вертикали
* (GFX_COPY_VFLIP, ядро _bgi_flip_rows_raw).
*
* ЗАЧЕМ ИМЕННО ТАКОЙ ТЕСТ. Переворот строит на квирке акселератора: между
* read- и write-триггером надо СМЕНИТЬ Port_Y, а fetch операнда любой
* инструкции в этом окне перезапускает read-burst и набивает буфер кодом
* (memory/accel_operand_fetch_retrigger). Ядро обходит это STOP'ом перед
* вторым OUT — приём взят из _bgi_scroll_cols_raw, где он уже в проде.
* Ошибка здесь даёт не «немного не так», а сдвинутые/мусорные строки,
* поэтому проверка ПОБАЙТНАЯ, а не на глаз.
*
* Что проверяется:
* T1 DIRECT: строка i источника легла в строку i приёмника;
* T2 VFLIP: строка i источника легла в строку (h-1-i) приёмника;
* T3 рамка вокруг области цела — burst не вылез за пределы;
* T4 ШИРОКАЯ копия (320 байт = два прохода по 255+65): вылезший или
* потерянный второй проход виден по краям строки;
* T5 ИСТОЧНИК не тронут — копия односторонняя.
*
* Паттерн намеренно разный и по строкам, и по колонкам: цвет строки несёт
* её номер (сдвиг по Y виден сразу), а маркеры на первом/последнем пикселе
* ловят сдвиг по X и потерю хвоста широкой копии.
*
* ВИД НА ЭКРАНЕ: сами области выглядят чёрными — палитра по умолчанию
* задаёт лишь 16 первых цветов, а тест намеренно пишет индексы 0x21..0x60,
* чтобы значения были различимы побайтно. Это ожидаемо: проверка идёт
* getpixel'ом по ИНДЕКСАМ, а не глазами.
*
* getpixel читает ОЗУ-КОПИЮ экрана — ту самую, которую обязана обновить
* копия (банк 0x50). Значит T1/T2 заодно подтверждают инвариант «после
* gfx_copy_page heal целевой страницы восстановит НОВЫЙ фон, а не старый»:
* без записи в тень тест бы их провалил.
*/
#include <graphics.h>
#include <gfx.h>
#include <sprite.h>
#define AX 8 /* область-образец: левый верхний угол */
#define AY 40
#define AW 64
#define AH 32
#define WX 0 /* широкая область (два прохода по ширине) */
#define WY 120
#define WW 320
#define WH 16
#define ROW_C0 0x41 /* цвет строки i = ROW_C0 + i */
#define MARK_L 0x21 /* маркер левого края строки */
#define MARK_R 0x22 /* маркер правого края */
#define FRAME 0x2F /* рамка вокруг области */
#define DIRT 0x35 /* чем забиваем приёмник до копии */
/* Нарисовать образец в ТЕКУЩЕЙ draw-странице: каждая строка своим цветом,
* по краям маркеры. */
static void draw_pattern(int x0, int y0, int w, int h)
{
int y;
for (y = 0; y < h; y++) {
setfillstyle(SOLID_FILL, (uint8_t)(ROW_C0 + y));
bar(x0, y0 + y, x0 + w - 1, y0 + y);
putpixel(x0, y0 + y, MARK_L);
putpixel(x0 + w - 1, y0 + y, MARK_R);
}
}
/* Забить приёмник мусором + рамка вокруг (её никто не должен трогать). */
static void draw_dirt(int x0, int y0, int w, int h)
{
setfillstyle(SOLID_FILL, FRAME);
bar(x0 - 1, y0 - 1, x0 + w, y0 + h);
setfillstyle(SOLID_FILL, DIRT);
bar(x0, y0, x0 + w - 1, y0 + h - 1);
}
/* Строка src_y образца обязана лежать в строке dst_y приёмника. */
static uint8_t row_matches(int x0, int y0, int w, int src_y, int dst_y)
{
int x;
uint8_t want = (uint8_t)(ROW_C0 + src_y);
if ((uint8_t)getpixel(x0, y0 + dst_y) != MARK_L) return 0;
if ((uint8_t)getpixel(x0 + w - 1, y0 + dst_y) != MARK_R) return 0;
for (x = 1; x < w - 1; x++)
if ((uint8_t)getpixel(x0 + x, y0 + dst_y) != want) return 0;
return 1;
}
static uint8_t check_direct(int x0, int y0, int w, int h)
{
int y;
for (y = 0; y < h; y++)
if (!row_matches(x0, y0, w, y, y)) return 0;
return 1;
}
static uint8_t check_vflip(int x0, int y0, int w, int h)
{
int y;
for (y = 0; y < h; y++)
if (!row_matches(x0, y0, w, y, h - 1 - y)) return 0;
return 1;
}
static uint8_t check_frame(int x0, int y0, int w, int h)
{
int x, y;
for (x = -1; x <= w; x++) {
if ((uint8_t)getpixel(x0 + x, y0 - 1) != FRAME) return 0;
if ((uint8_t)getpixel(x0 + x, y0 + h) != FRAME) return 0;
}
for (y = 0; y < h; y++) {
if ((uint8_t)getpixel(x0 - 1, y0 + y) != FRAME) return 0;
if ((uint8_t)getpixel(x0 + w, y0 + y) != FRAME) return 0;
}
return 1;
}
static void say(int y, const char *s, uint8_t ok)
{
setcolor(WHITE);
outtextxy(8, y, (char *)s);
setcolor(ok ? GREEN : RED);
outtextxy(200, y, ok ? (char *)"PASS" : (char *)"FAIL");
}
void main(void)
{
gfx_rect_t area;
uint8_t t[5];
uint8_t i;
initgraph();
gfx_set_visible_page(0);
gfx_pal_sync(); /* у страницы 1 своя палитра */
gfx_set_bank(GFX_BANK_NORMAL);
/* Образец — в страницу 1 (она будет НЕактивной, то есть источником). */
gfx_set_draw_page(1);
setfillstyle(SOLID_FILL, BLACK);
bar(0, 0, 319, 255);
draw_pattern(AX, AY, AW, AH);
draw_pattern(WX, WY, WW, WH);
/* Приёмник — страница 0. */
gfx_set_draw_page(0);
setfillstyle(SOLID_FILL, BLACK);
bar(0, 0, 319, 255);
draw_dirt(AX, AY, AW, AH);
/* T1: прямая копия. */
area.x = AX; area.y = AY; area.w = AW; area.h = AH;
gfx_copy_page(&area, GFX_COPY_DIRECT);
t[0] = check_direct(AX, AY, AW, AH);
/* T2/T3: та же область с переворотом (поверх результата T1 — значит
* непереписанная строка сразу видна как «не отразилось»). */
gfx_copy_page(&area, GFX_COPY_VFLIP);
t[1] = check_vflip(AX, AY, AW, AH);
t[2] = check_frame(AX, AY, AW, AH);
/* T4: широкая копия — 320 байт не влезают в один accel-блок. */
setfillstyle(SOLID_FILL, DIRT);
bar(WX, WY, WX + WW - 1, WY + WH - 1);
area.x = WX; area.y = WY; area.w = WW; area.h = WH;
gfx_copy_page(&area, GFX_COPY_VFLIP);
t[3] = check_vflip(WX, WY, WW, WH);
/* T5: источник обязан остаться нетронутым. */
gfx_set_draw_page(1);
t[4] = check_direct(AX, AY, AW, AH);
gfx_set_draw_page(0);
setcolor(WHITE);
outtextxy(8, 8, "gfx_copy_page test v1");
say(150, "T1 direct copy", t[0]);
say(162, "T2 vflip copy", t[1]);
say(174, "T3 frame intact", t[2]);
say(186, "T4 vflip 320 wide", t[3]);
say(198, "T5 source intact", t[4]);
for (i = 0; i < 5; i++)
if (!t[i]) break;
setcolor(WHITE);
outtextxy(8, 216, i == 5 ? (char *)"ALL PASS" : (char *)"SOME FAILED");
outtextxy(8, 228, "done");
for (;;) { }
}