/* * pop_ui.c — текст, палитровый fade и full-screen примитивы (FG5, банк 9). * * Шрифты извлечены из SDLPoP в FONT\FONT.ATL. Внутри map/unmap разрешены * лишь обращения к EMM/W0: всё, что ждёт кадр или трогает BIOS-палитру, * выполняется строго после unmap. Это общий контракт для menu/title/PV. */ #include #include #include #include #include #include #include "pop_bg.h" /* POP_YOFF/POP_PLAYFIELD_H — граница игрового поля */ #include "pop_font.h" #include "pop_arc.h" #include "pop_ui.h" #include "pop_status.h" typedef struct { uint8_t base; uint8_t ascent; uint8_t space; uint8_t first; /* первый код, который есть в наборе */ uint8_t last; } pop_font_desc_t; /* Тёмный набор — тот же мелкий шрифт другим цветом; глифов в нём меньше * (см. pop_font.h), поэтому диапазон кодов хранится у каждого набора. */ static const pop_font_desc_t font_desc[] = { { POP_FONT_SMALL_BASE, POP_FONT_SMALL_ASCENT, POP_FONT_SMALL_SPACE, POP_FONT_FIRST, POP_FONT_LAST }, { POP_FONT_BIG_BASE, POP_FONT_BIG_ASCENT, POP_FONT_BIG_SPACE, POP_FONT_FIRST, POP_FONT_LAST }, { POP_FONT_DARK_BASE, POP_FONT_BIG_ASCENT, POP_FONT_BIG_SPACE, POP_FONT_DARK_FIRST, POP_FONT_DARK_LAST } }; static atlas_t font_at; static uint8_t font_ready; /* БУФЕР ПАЛИТРЫ ПО ФИКСИРОВАННОМУ АДРЕСУ 0x4000. DSS занимает эти 256 * байт только при загрузке программы, дальше они наши (код начинается с * 0x4100), и держать буфер здесь дешевле, чем класть 256 байт на стек в * каждом вызове — идея пользователя, 2026-08-25. */ __at (0x4000) uint8_t pop_pal_buf[256]; static uint8_t text_index(const pop_font_desc_t *font, uint8_t ch) { if (ch < font->first || ch > font->last) ch = '?'; return (uint8_t)(font->base + ch - font->first); } int pop_ui_init(void) __banked { if (font_ready) return 0; if (atlas_load(&font_at, "FONT\\font.atl") != 0) return -1; font_ready = 1; return 0; } void pop_ui_free(void) __banked { if (!font_ready) return; atlas_free(&font_at); font_ready = 0; } uint8_t pop_ui_ready(void) { return font_ready; } void pop_text_map(void) { gfx_w0_map(font_at.page); } void pop_text_unmap(void) { gfx_w0_unmap(); } uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) { const pop_font_desc_t *desc = &font_desc[font]; uint8_t width = 0; while (*s) { const uint8_t *img = (const uint8_t *)atlas_image(&font_at, text_index(desc, (uint8_t)*s++)); width = (uint8_t)(width + img[0] + desc->space); } return width ? (uint8_t)(width - desc->space) : 0; } void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline, const char *s) { const pop_font_desc_t *desc = &font_desc[font]; while (*s) { const uint8_t *img = (const uint8_t *)atlas_image(&font_at, text_index(desc, (uint8_t)*s++)); /* Пробел не рисует НИ ОДНОГО пикселя: его глиф целиком 0xFF, а * текст всегда идёт прозрачным банком. Блит при этом стоит те же * ~4,6 тыс. тактов, что и буква, поэтому в строке из полусотни * символов пропуск пробелов — самая дешёвая экономия, какая тут * есть (важно для статус-строк, они рисуются в игровом кадре). * У крупного пробела вдобавок высота 0: для accel это 256 строк, * и нулевой блит нельзя отдавать ядру даже noclip-вариантом. */ if (img[2] && s[-1] != ' ') gfx_blit_noclip(x, baseline - desc->ascent, img); x += img[0] + desc->space; } } void pop_text_center_mapped(pop_text_font_t font, int center, int baseline, const char *s) { pop_text_draw_mapped(font, center - pop_text_width_mapped(font, s) / 2, baseline, s); } void pop_text_right_mapped(pop_text_font_t font, int right, int baseline, const char *s) { pop_text_draw_mapped(font, right - pop_text_width_mapped(font, s), baseline, s); } /* W0 свободно: snapshot кладём в зарезервированный хвост страницы шрифта. * 4 × 256 Б = ровно 1024 Б, [0x3C00,0x4000), и не пересекаются с атласом. */ /* Снимок палитры лежит в странице шрифта (в W2 килобайта нет). Копируем * memcpy, а не циклом: у SDCC это LDIR — 256 байт за ~5 400 тактов против * примерно ста тысяч у побайтового цикла на C. Именно этот цикл и делал * пересчёт палитры шестикадровым (замер 2026-08-26). */ static void snapshot_chunk(uint8_t chunk, uint8_t *buf, uint8_t store) { uint8_t *slot; gfx_w0_map(font_at.page); slot = (uint8_t *)(POP_FONT_SNAPSHOT_OFF + (uint16_t)chunk * 256u); if (store) memcpy(slot, buf, 256); else memcpy(buf, slot, 256); gfx_w0_unmap(); } /* Снимок палитры целиком: дробить на мелкие порции больше не нужно — * причина скрежета была не в длительности BIOS-вызова, а в том, какое окно * он под себя забирал (см. pop_pal_buf выше). */ void pop_ui_palette_snapshot(void) __banked { uint8_t chunk; if (!font_ready) return; for (chunk = 0; chunk < 4; chunk++) { gfx_pal_get(0, (uint8_t)(chunk << 6), 64, pop_pal_buf); snapshot_chunk(chunk, pop_pal_buf, 1); } } /* Из одного исходного снимка приготовить обе экранные палитры. Умножения * нет: шкала 1/4, 1/2, 3/4 на 8-битном значении не должна тащить делитель * в постоянно вызываемый (во время fade) путь. */ /* ВОСЕМЬ СТУПЕНЕЙ, а не четыре. Fade у оригинала идёт 64 шагами по два * тика — 2,13 с плавного затухания; наши четыре ступени укладывались в * доли секунды и выглядели как моргание, а растянуть их по времени значило * получить четыре заметных скачка яркости. * * Яркость ступени — простое умножение: (v * (8 - step)) >> 3. Через * switch с парами сдвигов выходило дороже: 768 каналов на вызов, и цепочка * сравнений SDCC съедала больше, чем стоит само умножение. * * dim_step: 0 — исходная, 8 — чёрная. */ void pop_ui_palette_dim(uint8_t dim_step, uint8_t keep_ui) __banked { /* ТАБЛИЦА ЯРКОСТИ — НА СТЕКЕ, и это главное здесь по времени. * * Замер в MAME (2026-08-26): вызов стоил 2,44 млн тактов, то есть почти * шесть кадров, из которых на BIOS-заливку палитры уходило всего 136 * тысяч (8 x 17 000). Остальное съедал пересчёт: 768 умножений * uint16 в кадровом цикле — на Z80 это вызов библиотечной функции на * каждый канал. Со ста вызовами подряд (fade кадр за кадром) набегало * больше десяти секунд — ровно то «очень долгое затемнение», которое * поймал пользователь. * * Теперь таблица строится один раз на ступень (256 сложений, без * единого умножения), а каналы её просто читают. 256 байт стека — * дешёвая плата: в этой точке израсходована едва сотня байт из 1279. * * Вместе с memcpy для снимка это дало 487 тысяч тактов на вызов против * 2,44 млн (замер в MAME 2026-08-26). */ uint8_t lut[256]; uint8_t chunk, i, start; uint8_t *buf = pop_pal_buf; if (!font_ready) return; if (dim_step > POP_PAL_DIM_BLACK) dim_step = POP_PAL_DIM_BLACK; if (dim_step) { uint8_t mul = (uint8_t)(POP_PAL_DIM_BLACK - dim_step); uint16_t v = 0; /* Без умножения вовсе: значение растёт на mul/32 с каждым шагом, * дробная часть копится в младших пяти битах аккумулятора. */ for (i = 0; ; i++) { lut[i] = (uint8_t)(v >> 5); v += mul; if (i == 255) break; } } for (chunk = 0; chunk < 4; chunk++) { start = (uint8_t)(chunk << 6); snapshot_chunk(chunk, buf, 0); if (dim_step) { uint8_t *p = buf; for (i = 0; i < 64; i++, p += 4) { uint8_t idx = (uint8_t)(start + i); if (keep_ui && idx >= 0x37 && idx <= 0x3F) continue; p[0] = lut[p[0]]; p[1] = lut[p[1]]; p[2] = lut[p[2]]; } } gfx_pal_load(0, start, 64, buf); gfx_pal_load(1, start, 64, buf); } } void pop_ui_palette_restore(void) __banked { pop_ui_palette_dim(0, 0); } void pop_ui_palette_black(void) __banked { uint8_t chunk; uint16_t i; uint8_t *black = pop_pal_buf; /* BIOS принимает максимум 64 записи за вызов; одним буфером покрываем * все четыре четверти палитры, не занимая постоянную память. */ for (i = 0; i < sizeof(black); i++) black[i] = 0; for (chunk = 0; chunk < 4; chunk++) { uint8_t start = (uint8_t)(chunk << 6); gfx_pal_load(0, start, 64, black); gfx_pal_load(1, start, 64, black); } } /* ДЛИТЕЛЬНОСТЬ ЗАДАЁТСЯ КАДРАМИ, А ПАЛИТРА ТРОГАЕТСЯ ВОСЕМЬ РАЗ. * * frames — сколько кадров луча длится затухание целиком (у оригинала это * 2,13 с). Наивный цикл «ступень на каждый кадр» звал pop_ui_palette_dim * сотню раз, а он не бесплатный: 4 чтения снимка по 256 байт, 768 каналов * арифметики и 8 вызовов BIOS на палитру — около 9 мс, то есть половина * кадра сверху на каждой итерации. Fade растягивался втрое против * заказанного (поймано пользователем: «один fade in секунд восемь»). * Теперь ступень пересчитывается только когда она СМЕНИЛАСЬ. */ static void fade_run(uint8_t frames, uint8_t up) { uint8_t i, shown = 0xFF; if (!frames) { pop_ui_palette_dim(up ? 0 : POP_PAL_DIM_BLACK, 0); return; } for (i = 1; i <= frames; i++) { uint8_t step = (uint8_t)((uint16_t)i * POP_PAL_DIM_BLACK / frames); if (up) step = (uint8_t)(POP_PAL_DIM_BLACK - step); if (step != shown) { pop_ui_palette_dim(step, 0); shown = step; } gfx_wait_vsync(); } /* Довести до края: целочисленное деление могло не дойти до 8 (или 0). */ if (shown != (up ? 0 : POP_PAL_DIM_BLACK)) pop_ui_palette_dim(up ? 0 : POP_PAL_DIM_BLACK, 0); } void pop_ui_fade_out(uint8_t steps) __banked { pop_ui_palette_snapshot(); fade_run(steps, 0); } void pop_ui_fade_in(uint8_t steps) __banked { if (!steps) { pop_ui_palette_restore(); return; } fade_run(steps, 1); } uint8_t pop_screen_begin(void) __banked { uint8_t page = (uint8_t)(gfx_get_visible_page() ^ 1); gfx_set_draw_page(page); gfx_set_bank(GFX_BANK_NORMAL); return page; } void pop_screen_fill(uint8_t color) __banked { setfillstyle(SOLID_FILL, color); bar(0, 0, GFX_WIDTH - 1, GFX_HEIGHT - 1); } /* Экран под ИГРУ: вне поля — цвет POP_COL_OUTSIDE, внутри — цвет 0. * * Зачем два разных чёрных. Вспышки (молния Джафара, урон, зелья) сделаны * подменой ЦВЕТА 0 всей палитры — как в оригинале. Пока борта и полоса HP * тоже были нулём, мигал весь экран целиком, вместе с рамкой и жизнями. * Красим всё, что вне поля 320x192, отдельным индексом, а в палитре держим * его чёрным (pop_pal_outside_apply): картинка та же, но вспышка достаёт * только игровую область. Идея пользователя, 2026-08-25. * * Индексы 1..15 у нас свободны: атласы кладут либо 0xFF (прозрачность), * либо PAL_BASE+v (0x40 и выше), шрифт — 0x3F. */ void pop_screen_fill_game(void) __banked { setfillstyle(SOLID_FILL, POP_COL_OUTSIDE); bar(0, 0, GFX_WIDTH - 1, GFX_HEIGHT - 1); setfillstyle(SOLID_FILL, BLACK); bar(0, POP_YOFF, GFX_WIDTH - 1, POP_CLIP_BOTTOM - 1); /* Заливка съела и обе служебные полосы — они ВНЕ игрового поля. * Инвалидируем здесь, а не у вызывающих: точек заливки несколько * (старт игры, смена уровня), и пропустить одну из них — ровно тот * баг, из-за которого строка исчезала при переходе между комнатами. */ pop_status_invalidate(); } /* Только ИГРОВОЕ ПОЛЕ, без бортов. Вход в комнату перерисовывает лишь * его: борта от комнаты к комнате не меняются, а всё, что на них живёт — * полоса HP, статус-строка, отладочная строка — обязано вход пережить. * Заодно это на четверть меньше заливки (192 строки вместо 256). */ void pop_screen_fill_field(void) __banked { setfillstyle(SOLID_FILL, BLACK); bar(0, POP_YOFF, GFX_WIDTH - 1, POP_CLIP_BOTTOM - 1); } void pop_screen_present(uint8_t page) __banked { gfx_wait_vsync(); gfx_set_visible_page(page); } /* transition_ltr() SDLPoP: подготовленная СКРЫТАЯ страница переползает на * видимую узкими вертикальными полосами слева направо. У оригинала * 2px/120 FPS; здесь 4px/60 FPS даёт ту же длительность (~1,3 с), не * удваивая число полных копий 320x256. * * Страницы при этом НЕ меняются местами: копируем в видимую, как оригинал * копирует offscreen в onscreen. Поэтому у вызывающего после перехода на * ЭКРАНЕ и в скрытой странице лежит одно и то же. */ void pop_screen_present_ltr(void) __banked { gfx_rect_t stripe; uint8_t visible = gfx_get_visible_page(); int x; gfx_set_draw_page(visible); /* source gfx_copy_page = target */ stripe.y = 0; stripe.h = GFX_HEIGHT; for (x = 0; x < GFX_WIDTH; x += 4) { if (kbd_raw_any_down()) { /* Прервали — дорисовать остаток разом, а не бросить полукадр. */ stripe.x = 0; stripe.w = GFX_WIDTH; gfx_copy_page(&stripe, GFX_COPY_DIRECT); return; } stripe.x = x; stripe.w = (uint16_t)((GFX_WIDTH - x < 4) ? GFX_WIDTH - x : 4); gfx_copy_page(&stripe, GFX_COPY_DIRECT); gfx_wait_vsync(); } } /* Нарисовать одну полосу и отпустить её страницу. Общая часть обоих * загрузчиков ниже: они отличаются только тем, откуда взялся атлас. */ static int strip_blit(atlas_t *at, int x, int *y) { const uint8_t *img; gfx_w0_map(at->page); img = (const uint8_t *)atlas_image(at, 0); /* gfx_blit_noclip ограничен шириной uint8 и обрезает 320px. */ gfx_blit(x, *y, img); *y += img[2]; gfx_w0_unmap(); atlas_free(at); return 0; } /* Полноэкранные изображения не помещаются одним getimage в EMM-страницу. * Общий загрузчик полос держит лишь одну страницу и не трогает page flip: * caller уже собрал скрытую страницу и сам решает, когда её показать. */ int pop_screen_blit_atlas_strips(const char * const *parts, uint8_t count, int x, int y) __banked { uint8_t part; gfx_set_bank(GFX_BANK_NORMAL); for (part = 0; part < count; part++) { atlas_t at; if (atlas_load(&at, parts[part]) != 0) return -1; strip_blit(&at, x, &y); } return 0; } /* То же самое, но полосы лежат подряд в АРХИВЕ (заставка, story, PV): один * `open` на весь экран вместо пяти — при цене открытия 51,4 мс против * 32,6 мс на чтение это больше половины времени показа. */ int pop_screen_blit_atlas_arc(pop_arc_t *arc, const uint8_t *tbl, uint8_t base, uint8_t count, int x, int y) __banked { uint8_t part; gfx_set_bank(GFX_BANK_NORMAL); for (part = 0; part < count; part++) { atlas_t at; if (pop_arc_atlas(arc, tbl, (uint8_t)(base + part), &at) != 0) return -1; strip_blit(&at, x, &y); } return 0; }