diff --git a/app.mk b/app.mk
index bd142ad..b616158 100644
--- a/app.mk
+++ b/app.mk
@@ -81,9 +81,18 @@ all: $(EXE)
# уже собранный exe (кусало: фикс bank.s не подхватился).
RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
+# ПРОВЕРКА БАНКОВЫХ ВЫЗОВОВ — сразу после линковки, пока артефакты свежие.
+# Ловит прямой `call` в чужой банк: он собирается МОЛЧА и стреляет диким
+# переходом в пустой хвост банка (разбор — в шапке скрипта). Запускается
+# только если банки вообще есть, то есть по наличию каталога сборки с
+# bankN_*.asm; обычным небанковым программам ничего не стоит.
+BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
+
$(EXE): $(SOURCES) $(MKEXE) $(LIB) $(RUNTIME_DEPS)
$(if $(strip $(BUILD_DIR)),@mkdir -p $(dir $@))
$(SPRINTER_CC) $(CC_FLAGS) -o $@ $(SOURCES)
+ @d=$(dir $@).sprinter-cc-$(EXAMPLE); \
+ if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
diff --git a/applications/PoP/roomtest/BUGS_CLOSED.md b/applications/PoP/roomtest/BUGS_CLOSED.md
index ac12419..449c613 100644
--- a/applications/PoP/roomtest/BUGS_CLOSED.md
+++ b/applications/PoP/roomtest/BUGS_CLOSED.md
@@ -3630,3 +3630,115 @@ public `_pop_mus_page` по смещениям из `.sym`):
брейкпоинте, значения в памяти застывают — картина «`busy=0` при `next=3` из
20» выглядела как обрыв чтения с ошибкой, хотя это был просто стоп-кадр.
Прежде чем толковать значения, проверять `status`: `state=run` или `stop`.
+
+---
+
+
+## FINAL-BANKCALL. Финал убивал программу: прямой вызов в чужой банк
+
+**Симптом (пользователь, 2026-08-27).** Пройденная игра доходила до
+таблицы рекордов и умирала: программа исчезала, а машина следом вставала
+намертво (`di; halt` по адресу 0x0000) либо уходила в reset. Из Flex
+Navigator и из голого DSS — одинаково.
+
+**Что оказалось не при чём** (проверено, чтобы не искать заново): Flex
+Navigator, звук и CBL (с выключенным звуком падало так же), потоковое
+чтение победной темы, второй `open` поверх открытого файла, фейды с
+клавиатурным диспетчером, чтение архива PV, межбанковый вызов
+банк 10 → банк 11, переполнение банка (самый полный — BANK8, 15376 из
+16384), утечка манипуляторов (`_fd_open_count` = 1) и утечка ссылок на
+IM2-таблицу (`_irq_refs` = 2, как задумано).
+
+**Как искали.** Бисекция ключом `HOF=0/2/4/5` (пропустить таблицу целиком
+/ только `hof_load` / без фона / без фейдов) сузила место до
+`hof_draw_rows`, а точную инструкцию дала ТРАССИРОВКА MAME на узком
+участке: `trace` включалась брейкпоинтом на входе в `pop_hof_show`
+(0x44AB) и выключалась на процедуре завершения процесса DSS (0x1E56).
+`history` для этого не годится — её 250 записей забиваются обработчиком
+прерываний, пока процессор ползёт по мусору.
+
+**Корень.**
+
+```
+CD42: ld hl,$B8AB ← _gfx_bank
+CD45: ld (hl),$58 ← gfx_set_bank(GFX_BANK_TRANSPARENT)
+CD47: call $E503 ← ПРЯМОЙ вызов; по карте 0009E503 = _pop_text_map, банк 9
+E503: rst $38 ← а в W3 стоит банк 10, там пустой хвост (0xFF)
+```
+
+`pop_ui.h` объявлял группу `pop_text_*_mapped` БЕЗ `__banked`. Пока
+`pop_hof.c` лежал в банке 9 рядом с `pop_ui.c`, прямой `call` был верен.
+Когда `pop_hof`/`pop_config`/`pop_pal` перенесли в банк 10 ради разгрузки
+банка 9, тот же `call` стал уходить в пустоту. Процессор полз по 0xFF до
+0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс: закрывает его
+файлы, возвращает контекст (байт 0x1E48: 2 = шелл, 3 = наша программа) и
+восстанавливает страницы родителя. Именно это выглядело как «W2 подменили
+у нас под ногами» — на самом деле подмена была уже уборкой трупа.
+
+**Фикс.** Группа помечена `__banked`; появилась
+`toolchain/check_bank_calls.py`, встроенная в `app.mk` и валящая сборку
+(проверено намеренной поломкой). Правило записано в `CLAUDE.md`.
+
+---
+
+
+## FINAL-HOF-GARBAGE. Таблица рекордов заливалась знаками вопроса
+
+**Симптом.** После фикса FINAL-BANKCALL экран HOF рисовался правильно, но
+с началом ввода имени строку заливало `?` во всю ширину.
+
+**Корень — вторая мина того же класса, созданная первым фиксом.** Курсор
+рисовался литералом:
+
+```c
+pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_"); /* ___str_3 в _BANK10 */
+```
+
+Пока `pop_text_draw_mapped` был в том же банке, литерал был виден. После
+пометки `__banked` трамплин на время вызова переключает W3 на банк 9 —
+указатель показывает в чужой банк, функция читает оттуда байты до первого
+нуля и рисует их знаками вопроса. Сходится и с наблюдением пользователя
+«сначала рисуется правильно, потом мусор»: строка времени берётся из
+локального массива (стек, W2 — виден всем), а мусор начинается с мигания
+курсора.
+
+**Фикс.** Курсор строится на стеке. Вторая проверка
+`check_bank_calls.py` предупреждает о таких случаях (проверено: на сборке
+с возвращённым литералом даёт ровно одно срабатывание).
+
+---
+
+
+## KBD-ARROW-PHANTOM. Залипшая стрелка вешала ожидания насмерть
+
+**Симптом.** После игры (особенно 14-й уровень — он почти весь проходится
+удержанием Left) текстовые экраны переставали прерываться, а «новая игра»
+вставала намертво: `pop_new_game_load` начинается с
+`while (kbd_raw_any_down())`. При этом ввод имени в HOF работал —
+он опрашивает конкретные коды, а не всю карту.
+
+**Корень (подтверждён дважды: 0x72 24.08, 0x6B 27.08).** Стрелка идёт по
+проводу как `E0 6B`. При переполнении 3-байтового FIFO SIO теряется
+префикс, make садится в PLAIN-половину карты как код нумпада (0x6B = KP4),
+а break приходит уже с префиксом и снимает бит в EXT-половине. PLAIN-бит
+остаётся зажатым навсегда, `kbd_raw_any_down()` отвечает «да» вечно.
+Доказано вмешательством: запись 0 в этот байт отладчиком мгновенно
+перевела состояние 5 (LEVEL_LOAD) → 6 (PLAYING).
+
+**Фикс.** `kbd_raw_keypad_as_ext()` в libc: `kbd_raw_sync()` переносит
+биты голых кодов нумпада в EXT-половину и гасит в PLAIN — make и break
+начинают работать с одним битом. В PS/2 это и есть одни и те же
+физические клавиши, поэтому нумпад заодно стал управлением (7/8/9, 4/6,
+2 и 5 — вниз). НЕ лечит обратный случай (префикс потерян у break) — там
+бит стоит уже в EXT и выглядит как реально зажатая клавиша.
+
+---
+
+
+## F10-GAMEPLAY. Выход по F10 не работал в игре
+
+F10 проверяется в idle-хуке, а хук висит на `gfx_wait_vsync`. Когда
+игровой цикл перевели на пейсинг по лучу, он стал ждать через
+`pop_wait_edge`, который звал `kbd_raw_poll` напрямую — хук не вызывался,
+и F10 в геймплее умер (в заставках и меню работал). Фикс: `pop_wait_edge`
+зовёт тот же `pop_idle`.
diff --git a/applications/SprPoP/CLAUDE.md b/applications/SprPoP/CLAUDE.md
index bf6e083..77a63d2 100644
--- a/applications/SprPoP/CLAUDE.md
+++ b/applications/SprPoP/CLAUDE.md
@@ -155,6 +155,32 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
| `pop_ctrl.c/.h` | Управление: диспетчер `control()` (`seg005`) + ввод `read_user_control` (`seg006`) поверх ``. |
| `pop_map.c/.h` | Коллизия с картой + физика падения/приземления/стены + loose-полы (`seg005/006/007`). |
+## Банки: две мины, которых компилятор не видит
+
+Обе стреляют молча, обе поймали нас 2026-08-27 (разбор — в
+`../PoP/roomtest/BUGS_CLOSED.md`, поиск «FINAL-BANKCALL»).
+
+**1. Прямой вызов в чужой банк.** Трамплин SDCC выбирает по `__banked`
+в ОБЪЯВЛЕНИИ, а не по тому, где функция лежит. Функция без пометки
+зовётся прямым `call` — верно ровно пока вызывающий и вызываемый в одном
+банке. Перенесли модуль ради разгрузки банка — и тот же `call` уходит в
+пустой хвост чужого банка, процессор ползёт по 0xFF до 0x0000, DSS убивает
+процесс. Ловит `toolchain/check_bank_calls.py`, она встроена в сборку и
+ВАЛИТ её (проверено намеренной поломкой).
+
+**2. Указатель на данные своего банка, отданный в чужой.** Обратная
+сторона: пока обе стороны в одном банке, указатель на литерал или
+const-таблицу работает; пометили функцию `__banked` — и трамплин на время
+вызова переключает W3, а указатель показывает уже в чужой банк. Так экран
+таблицы рекордов залило знаками вопроса: в `pop_text_draw_mapped` (банк 9)
+уходил литерал `"_"` из банка 10.
+
+**Правило:** всё, что уходит указателем в другой банк, обязано лежать в
+W2 — стек, `_DATA` или копия. `const`-таблицы и строковые литералы
+банкового модуля наружу отдавать нельзя (то же, что уже записано про
+`pop_tile.c`). Вторая проверка скрипта предупреждает о явных случаях, но
+она эвристическая и гарантией не является.
+
## Порядок слоёв в кадре (важно для окклюзии)
`sprpop.c` каждый тик рисует в СКРЫТУЮ страницу: `pop_char_heal` (стереть
diff --git a/applications/SprPoP/docs/impl_diff.md b/applications/SprPoP/docs/impl_diff.md
index c660128..2f8edc8 100644
--- a/applications/SprPoP/docs/impl_diff.md
+++ b/applications/SprPoP/docs/impl_diff.md
@@ -647,3 +647,40 @@ Enter/Shift — либо оставить как есть уже осознан
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
кадров — труп должен успеть лечь.
+
+---
+
+## Таблица рекордов: Esc отменяет запись, а оригинал уйти не даёт
+
+**Как в оригинале.** `show_hof` вставляет результат в таблицу ДО ввода
+имени и крутит ввод, пока тот не вернёт положительную длину:
+
+```c
+/* SDLPoP seg001.c:624 */
+while (input_str(&rect, hof[hof_index].name, 24, "", 0, 4, color, bgcolor) <= 0);
+/* seg009.c input_str: Esc -> return -1; Enter -> return length */
+```
+
+То есть **уйти нельзя ни по Esc, ни с пустым именем**: и −1, и 0 просто
+начинают ввод заново. Запись в таблице остаётся в любом случае.
+
+**Как у нас.** Enter записывает имя (пустое подставляется как `PLAYER`),
+**Esc отказывается от записи целиком**: таблица перечитывается с диска
+(`hof_load`), `hof_save` не вызывается, экран показывается уже без строки
+ввода.
+
+**Зачем.** Решение пользователя, 2026-08-27. Оригинальное поведение
+означает, что случайно добравшийся до финала игрок обязан вписать имя,
+чтобы вообще уйти с экрана, — на клавиатуре без цифрового блока и с нашим
+raw-каналом это выглядит как зависание, а не как требование.
+
+**Чем платим.** Расхождение видно игроку: в оригинале таблица после
+победы пополняется всегда. Плюс отмена стоит одного лишнего чтения
+POP.HOF — откатывать сдвиг строк, который сделал `hof_insert_current`,
+дешевле перечитыванием файла, чем обратным сдвигом (экран холодный,
+50 мс там никому не мешают).
+
+**Что проверять при регрессе.** После Esc: таблица на экране прежняя (без
+новой строки), POP.HOF на диске не изменился, следующий показ таблицы —
+между credits и attract-demo — даёт тот же список. После Enter с пустым
+именем строка называется `PLAYER` и сохраняется.
diff --git a/applications/SprPoP/docs/keys.txt b/applications/SprPoP/docs/keys.txt
index eca9b6c..3572819 100644
--- a/applications/SprPoP/docs/keys.txt
+++ b/applications/SprPoP/docs/keys.txt
@@ -24,7 +24,6 @@
* Ctrl+R: Return to intro.
* Ctrl+S: Sound on/off.
* Ctrl+M: Music on/off.
-* Ctrl+V: Show version of SprPoP.
* Ctrl+Q / F10: Quit game.
* F6: Quicksave: Save the exact state of the game.
diff --git a/applications/SprPoP/src/pop_ctrl.c b/applications/SprPoP/src/pop_ctrl.c
index 9e287f7..91c5d64 100644
--- a/applications/SprPoP/src/pop_ctrl.c
+++ b/applications/SprPoP/src/pop_ctrl.c
@@ -665,6 +665,12 @@ void pop_control(void) __banked
* «откуда взялся прыжок» останется одним. */
#define KBD_HOME (KBD_EXT | 0x6C)
#define KBD_PGUP (KBD_EXT | 0x7D)
+/* Центр нумпада (KP5) — тоже «вниз», как просит раскладка оригинала: там
+ * весь блок 7/8/9-4/6-1/2/3 был управлением, и середина работала как
+ * приседание. Своей навигационной клавиши у KP5 нет, поэтому после
+ * kbd_raw_keypad_as_ext его голый код 0x73 переезжает в EXT-половину как
+ * есть — читаем оттуда и добавляем к стрелке «вниз». */
+#define KBD_KP5 (KBD_EXT | 0x73)
static void read_input(void)
{
@@ -673,7 +679,7 @@ static void read_input(void)
uint8_t l = (uint8_t)(kbd_raw_down(KBD_LEFT) || home);
uint8_t r = (uint8_t)(kbd_raw_down(KBD_RIGHT) || pgup);
uint8_t u = (uint8_t)(kbd_raw_down(KBD_UP) || home || pgup);
- uint8_t d = kbd_raw_down(KBD_DOWN);
+ uint8_t d = (uint8_t)(kbd_raw_down(KBD_DOWN) || kbd_raw_down(KBD_KP5));
control_x = r ? CONTROL_HELD_RIGHT : (l ? CONTROL_HELD_LEFT : CONTROL_RELEASED);
control_y = d ? CONTROL_HELD_DOWN : (u ? CONTROL_HELD_UP : CONTROL_RELEASED);
control_shift = (kbd_raw_down(KBD_LSHIFT) || kbd_raw_down(KBD_RSHIFT))
@@ -781,6 +787,16 @@ int pop_ctrl_init(void) __banked
control_forward = control_backward = control_up = control_down = CONTROL_RELEASED;
control_shift2 = CONTROL_RELEASED;
ctrl1_forward = ctrl1_backward = ctrl1_up = ctrl1_down = ctrl1_shift2 = CONTROL_RELEASED;
+ /* НУМПАД НАМ НЕ НУЖЕН, И ЭТО ЛЕЧИТ ЗАЛИПАНИЕ СТРЕЛОК. Управление и
+ * меню читают только расширенные коды (KBD_LEFT = KBD_EXT|0x6B и т.д.).
+ * А при переполнении FIFO SIO теряется префикс E0, и make стрелки
+ * садится в PLAIN-половину карты как код нумпада — снять его нечем,
+ * потому что break придёт уже с префиксом. Дальше kbd_raw_any_down()
+ * навсегда «да», и всякое ожидание «пока ничего не нажато» виснет
+ * насмерть (BUG KBD-STUCK-WAIT; поймано на 14-м уровне, где почти весь
+ * путь идёт удержанием Left). Гасит биты kbd_raw_sync(), а его
+ * pop_ctrl_tick зовёт каждый кадр. */
+ kbd_raw_keypad_as_ext(1);
return kbd_raw_open();
}
diff --git a/applications/SprPoP/src/pop_hof.c b/applications/SprPoP/src/pop_hof.c
index de1fb80..e1142d4 100644
--- a/applications/SprPoP/src/pop_hof.c
+++ b/applications/SprPoP/src/pop_hof.c
@@ -343,8 +343,22 @@ static void hof_edit_draw(uint8_t row, const char *name, uint8_t cursor)
x += pop_text_width_mapped(POP_TEXT_BIG_DARK, name) + 1;
}
/* draw_text_cursor(): подчерк той же гарнитуры сразу за текстом,
- * мигающий сменой цвета на фоновый — у нас проще перерисовать. */
- if (cursor) pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, "_");
+ * мигающий сменой цвета на фоновый — у нас проще перерисовать.
+ *
+ * СТРОКА СТРОИТСЯ НА СТЕКЕ, И ЭТО НЕ ПРИДИРКА (поймано в MAME
+ * 2026-08-27). Литерал "_" лёг бы в _BANK10, то есть в окно W3, а
+ * pop_text_draw_mapped живёт в банке 9: трамплин на время вызова
+ * переключает W3, и указатель показывает уже в ЧУЖОЙ банк. Функция
+ * честно читает оттуда байты до первого нуля и рисует их знаками
+ * вопроса — экран заливало мусором на каждое мигание курсора. Стек
+ * лежит в W2, он виден из любого банка. */
+ if (cursor) {
+ char cur[2];
+
+ cur[0] = '_';
+ cur[1] = 0;
+ pop_text_draw_mapped(POP_TEXT_BIG_DARK, x, baseline, cur);
+ }
pop_text_unmap();
}
@@ -409,8 +423,8 @@ static const uint8_t hof_key_code[30] = {
KBD_SPACE, 0x66, KBD_ENTER, KBD_ESC
};
-/* Возвращает 1, когда ввод закончен (Enter/Esc); *changed — надо ли
- * перерисовать строку. */
+/* Возвращает 1 — ввод завершён (Enter), 2 — отменён (Esc); 0 — продолжаем.
+ * *changed — надо ли перерисовать строку. */
static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
uint8_t *previous, uint8_t *changed)
{
@@ -432,7 +446,7 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
*changed = 1;
} else if (i == 28 || i == 29) {
previous[i] = down;
- return 1;
+ return (uint8_t)(i == 28 ? 1 : 2); /* Enter : Esc */
}
}
previous[i] = down;
@@ -446,7 +460,8 @@ static uint8_t hof_name_tick(hof_entry_t *entry, uint8_t *length,
* input_str) — в кадрах луча это HOF_BLINK. */
#define HOF_BLINK POP_T60(6)
-static void hof_edit(uint8_t row)
+/* 1 — игрок отказался вписывать имя (Esc). */
+static uint8_t hof_edit(uint8_t row)
{
uint8_t length = 0, previous[30], i, changed = 1;
uint8_t cursor = 1, phase = 0;
@@ -459,10 +474,14 @@ static void hof_edit(uint8_t row)
for (i = 0; i < sizeof(previous); i++)
previous[i] = kbd_raw_down(hof_key_code[i]);
for (;;) {
+ uint8_t done;
+
kbd_raw_sync();
- if (hof_name_tick(entry, &length, previous, &changed)) {
+ done = hof_name_tick(entry, &length, previous, &changed);
+ if (done == 2) return 1; /* Esc — запись не нужна */
+ if (done) {
if (!length) strcpy(entry->name, "PLAYER");
- return;
+ return 0;
}
if (changed) { cursor = 1; phase = 0; }
if (++phase >= HOF_BLINK) {
@@ -496,8 +515,13 @@ void pop_hof_show(void) __banked
if (hof_page_build(page, row) == 0) {
pop_screen_present(page);
(void)pop_pal_fade_in_skip(HOF_FADE);
- hof_edit(row);
- hof_save();
+ /* ОТКАЗ ПО Esc — РАСХОЖДЕНИЕ С ОРИГИНАЛОМ (docs/impl_diff.md).
+ * SDLPoP крутит `while (input_str(...) <= 0)`, то есть уйти без
+ * имени нельзя вовсе. Мы разрешаем: тогда таблицу перечитываем
+ * с диска (hof_insert_current уже сдвинул строки, и откатывать
+ * их дешевле всего именно так) и НЕ сохраняем. */
+ if (hof_edit(row)) hof_load();
+ else hof_save();
/* Имя вписано: строку показываем уже обычным золотым текстом,
* без полосы (restore_peel + show_hof_text оригинала). */
page = pop_screen_begin();
diff --git a/applications/SprPoP/src/pop_menu.c b/applications/SprPoP/src/pop_menu.c
index d049c9f..a97e77f 100644
--- a/applications/SprPoP/src/pop_menu.c
+++ b/applications/SprPoP/src/pop_menu.c
@@ -653,53 +653,6 @@ static void menu_erase(uint8_t entry_page)
}
}
-static void build_info_draw(void)
-{
- uint8_t back = pop_screen_begin();
- int top = UI_CENTER_TOP(ABOUT_H + BUILD_KEYS_GAP);
-
- pop_screen_fill(BLACK);
- gfx_set_bank(GFX_BANK_SPRITE);
- pop_text_map();
- about_text(top);
- pop_text_center_mapped(POP_TEXT_SMALL, GFX_WIDTH / 2,
- top + ABOUT_H + BUILD_KEYS_GAP, UI_KEYS_TEXT);
- pop_text_unmap();
- pop_screen_present(back);
-}
-
-/* В SprPoP ещё нет автомата TITLE (это FG1/FG6), поэтому build screen
- * показывается перед первым игровым кадром и после выхода возвращает ровно
- * ту же сцену. Настройка уже прочитана в pop_boot до initgraph. */
-void pop_build_info_show(void) __banked
-{
- uint8_t entry_page, saved_page, saved_bank, frames;
- struct fillsettingstype saved_fill;
-
- if (!pop_ui_ready() || !pop_settings.show_build_info) return;
- entry_page = gfx_get_visible_page();
- saved_page = gfx_get_draw_page();
- saved_bank = gfx_get_bank();
- getfillsettings(&saved_fill);
- /* Первый реальный пользователь FG5 fade: старая игровая палитра
- * запоминается в pop_ui, затем full-screen появляется из чёрного. */
- pop_pal_fade_out(4);
- build_info_draw();
- pop_pal_fade_in(4);
- for (frames = 0; frames < 150; frames++) { /* около трёх секунд */
- if (kbd_raw_down(KBD_ESC) || kbd_raw_down(KBD_ENTER) ||
- kbd_raw_down(KBD_SPACE))
- break;
- pop_wait_edge();
- }
- ui_wait_release();
- pop_pal_fade_out(4);
- menu_erase(entry_page);
- pop_pal_fade_in(4);
- setfillstyle(saved_fill.pattern, saved_fill.color);
- gfx_set_bank(saved_bank);
- gfx_set_draw_page(saved_page);
-}
int pop_menu_init(void) __banked
{
diff --git a/applications/SprPoP/src/pop_menu.h b/applications/SprPoP/src/pop_menu.h
index 9b6bcd6..18efe13 100644
--- a/applications/SprPoP/src/pop_menu.h
+++ b/applications/SprPoP/src/pop_menu.h
@@ -40,6 +40,5 @@ int8_t pop_menu_process(void) __banked;
/* Экран Sprinter build, если он включён в POP.CFG. Вызывается после
* открытия raw-клавиатуры и до первого игрового кадра. */
-void pop_build_info_show(void) __banked;
#endif
diff --git a/applications/SprPoP/src/pop_pace.c b/applications/SprPoP/src/pop_pace.c
index 0377422..3fd06ea 100644
--- a/applications/SprPoP/src/pop_pace.c
+++ b/applications/SprPoP/src/pop_pace.c
@@ -11,6 +11,7 @@
#include
#include
#include
+#include "pop_state.h" /* pop_idle — общий idle-хук */
#include "pop_pace.h"
uint8_t pop_speed_mode; /* crt0 зануляет _DATA → NORMAL */
@@ -77,7 +78,11 @@ void pop_wait_edge(void)
t = pop_frame_tick;
do {
pop_beam_sample();
- kbd_raw_poll(); /* KBD-1: опрос обязан быть плотным */
+ /* ТОТ ЖЕ ХУК, что висит на gfx_wait_vsync, а не голый kbd_raw_poll:
+ * кроме плотного опроса FIFO (KBD-1) он проверяет F10, и без этого
+ * выход из игры в геймплее не работал — пейсинг по лучу через
+ * gfx_wait_vsync не ходит. */
+ pop_idle();
} while (pop_frame_tick == t);
}
diff --git a/applications/SprPoP/src/pop_state.h b/applications/SprPoP/src/pop_state.h
index 46606de..4ef6d3f 100644
--- a/applications/SprPoP/src/pop_state.h
+++ b/applications/SprPoP/src/pop_state.h
@@ -19,6 +19,12 @@
* отрыв куска, снимает сам обход по факту пустой таблицы. */
/* F10 нажата — выйти из программы; см. pop_state.c. */
extern uint8_t pop_quit_req;
+
+/* Общий idle-хук: вычерпать клавиатурный FIFO и проверить F10. Живёт в
+ * sprpop.c (резидент), висит на gfx_wait_vsync и вызывается из цикла
+ * ожидания луча — обе точки ожидания обязаны звать ОДНО И ТО ЖЕ, иначе
+ * выход работает не везде. */
+void pop_idle(void);
extern uint8_t pop_mob_busy;
/* Кусок loose приземлился (loose_land, seg007:11E8): 0 = нет, иначе
diff --git a/applications/SprPoP/src/pop_ui.c b/applications/SprPoP/src/pop_ui.c
index 7a24d97..eda9198 100644
--- a/applications/SprPoP/src/pop_ui.c
+++ b/applications/SprPoP/src/pop_ui.c
@@ -71,17 +71,17 @@ uint8_t pop_ui_ready(void)
return font_ready;
}
-void pop_text_map(void)
+void pop_text_map(void) __banked
{
gfx_w0_map(font_at.page);
}
-void pop_text_unmap(void)
+void pop_text_unmap(void) __banked
{
gfx_w0_unmap();
}
-uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s)
+uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) __banked
{
const pop_font_desc_t *desc = &font_desc[font];
uint8_t width = 0;
@@ -95,7 +95,7 @@ uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s)
}
void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
- const char *s)
+ const char *s) __banked
{
const pop_font_desc_t *desc = &font_desc[font];
@@ -115,14 +115,14 @@ void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
}
void pop_text_center_mapped(pop_text_font_t font, int center, int baseline,
- const char *s)
+ const char *s) __banked
{
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)
+ const char *s) __banked
{
pop_text_draw_mapped(font, right - pop_text_width_mapped(font, s),
baseline, s);
diff --git a/applications/SprPoP/src/pop_ui.h b/applications/SprPoP/src/pop_ui.h
index 8ab27b6..b82f082 100644
--- a/applications/SprPoP/src/pop_ui.h
+++ b/applications/SprPoP/src/pop_ui.h
@@ -27,16 +27,25 @@ void pop_ui_free(void) __banked;
uint8_t pop_ui_ready(void);
/* Группа text_*_mapped требует активного pop_text_map() и не вызывает
- * BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования. */
-void pop_text_map(void);
-void pop_text_unmap(void);
-uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s);
+ * BIOS/ESTEX. Для прозрачных букв установить GFX_BANK_SPRITE до рисования.
+ *
+ * ВСЯ ГРУППА __banked, И ЭТО ОБЯЗАТЕЛЬНО (крэш финала, 2026-08-27). Живёт
+ * она в pop_ui.c, то есть в банке 9, а зовут её и из ДРУГИХ банков — HOF
+ * лежит в банке 10. Трамплин SDCC выбирает по объявлению, а не по тому,
+ * где функция реально лежит (memory sdcc_banked_call_rules): без пометки
+ * получается прямой `call` по адресу банка 9, который в чужом банке
+ * попадает в пустой хвост (0xFF) — процессор ползёт по нему до 0x0000, и
+ * DSS убивает процесс с кодом 0x27. Собиралось это МОЛЧА, поэтому рядом
+ * появилась toolchain/check_bank_calls.py — она такое ловит на сборке. */
+void pop_text_map(void) __banked;
+void pop_text_unmap(void) __banked;
+uint8_t pop_text_width_mapped(pop_text_font_t font, const char *s) __banked;
void pop_text_draw_mapped(pop_text_font_t font, int x, int baseline,
- const char *s);
+ const char *s) __banked;
void pop_text_center_mapped(pop_text_font_t font, int center, int baseline,
- const char *s);
+ const char *s) __banked;
void pop_text_right_mapped(pop_text_font_t font, int right, int baseline,
- const char *s);
+ const char *s) __banked;
/* Снимок обеих (идентичных по контракту) палитр хранится в свободном хвосте
* FONT.ATL. dim_step: 0 — исходная, POP_PAL_DIM_BLACK — чёрная; между ними
diff --git a/applications/SprPoP/src/sprpop.c b/applications/SprPoP/src/sprpop.c
index 418f844..648f647 100644
--- a/applications/SprPoP/src/sprpop.c
+++ b/applications/SprPoP/src/sprpop.c
@@ -125,7 +125,10 @@ uint8_t pop_menu_request;
* проявление полосами, меню, игра. Одна точка вместо десятка. */
#define KBD_KEY_F10 0x09
-static void kbd_idle(void)
+/* НЕ static: этот же хук зовёт цикл ожидания луча (pop_wait_edge), иначе
+ * в геймплее F10 не работал бы вовсе — пейсинг по лучу не ходит через
+ * gfx_wait_vsync, к которому хук подвешен (поймано 2026-08-27). */
+void pop_idle(void)
{
kbd_raw_poll();
if (kbd_raw_down(KBD_KEY_F10)) pop_quit_req = 1;
@@ -267,14 +270,15 @@ int main(void)
puts("kbd_raw open failed");
return 1;
}
- gfx_set_idle_hook(kbd_idle); /* вычерпывать FIFO, пока ждём кадр */
+ gfx_set_idle_hook(pop_idle); /* вычерпывать FIFO, пока ждём кадр */
if (!pop_app_dispatch(POP_APP_EV_BOOT_READY)) {
pop_shutdown();
return 1;
}
- /* MS8: CFG уже применён в pop_boot до первого показа. Экран build
- * показывается только по явной пользовательской настройке. */
- pop_build_info_show();
+ /* Экран версии при запуске БОЛЬШЕ НЕ ПОКАЗЫВАЕТСЯ (решение пользователя
+ * 2026-08-27): единственная его точка — пункт ABOUT в меню паузы.
+ * Само состояние BUILD_INFO в автомате осталось: через него проходит
+ * маршрут BOOT -> TITLE, и убирать его ради одной пустой фазы незачем. */
if (!pop_app_dispatch(POP_APP_EV_BUILD_DONE)) {
pop_shutdown();
return 1;
diff --git a/applications/SprPoP/src/sprpop_cold.c b/applications/SprPoP/src/sprpop_cold.c
index b51484d..6561429 100644
--- a/applications/SprPoP/src/sprpop_cold.c
+++ b/applications/SprPoP/src/sprpop_cold.c
@@ -99,7 +99,7 @@ static void find_start_level_door(uint8_t room)
int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
{
static uint8_t esc_prev, qs_save_prev, qs_load_prev;
- static uint8_t restart_prev, version_prev;
+ static uint8_t restart_prev;
static uint8_t intro_prev, showtime_prev, timer_prev;
uint8_t e = (uint8_t)(kbd_raw_down(KBD_MENU) || kbd_raw_down(KBD_MENU_ALT));
uint8_t ctrl = (uint8_t)(kbd_raw_down(KBD_LCTRL) || kbd_raw_down(KBD_RCTRL));
@@ -142,14 +142,6 @@ int8_t pop_frame_ui(uint8_t *restart_level, uint8_t *dead_reset) __banked
if (r && !restart_prev) *restart_level = 1;
restart_prev = r;
}
- /* Ctrl+V — версия сборки. Функция уже есть, до сих пор её показывал
- * только старт программы. */
- {
- uint8_t v = (uint8_t)(ctrl && kbd_raw_down(KBD_VERSION));
-
- if (v && !version_prev) pop_build_info_show();
- version_prev = v;
- }
/* Ctrl+R — вернуться в заставку. Тот же переход, что «Restart Game»
* пунктом меню: автомат сам разложит title/intro с первого экрана. */
{
@@ -431,6 +423,13 @@ void pop_start_level(void) __banked
* гасит заставка «LEVEL N», которую этот же переход и пропускает. */
if (pop_seamless) { pop_seamless = 0; pop_kid_hp_carry(); }
else pop_kid_hp_reset();
+ /* ПОЛОСУ HP — НА ОБЕИХ СТРАНИЦАХ. Старт уровня перерисовал фон, то есть
+ * стёр её, а сама она обновляется только при ИЗМЕНЕНИИ чисел. После
+ * Ctrl+A с выросшим за уровень максимумом (большой кувшин, чит Shift+T)
+ * это давало моргание: одна страница показывала старые деления, вторая
+ * новые (поймано пользователем 2026-08-27). invalidate ровно для этого
+ * и заведён — см. его шапку в pop_cdraw.c. */
+ pop_hp_invalidate();
}
/* ---- ОТЛАДКА: обход комнат по номеру -------------------------------- *
diff --git a/applications/SprPoP/src/sprpop_cold.h b/applications/SprPoP/src/sprpop_cold.h
index d81156f..a0c0c62 100644
--- a/applications/SprPoP/src/sprpop_cold.h
+++ b/applications/SprPoP/src/sprpop_cold.h
@@ -110,7 +110,6 @@ int8_t pop_level_change(uint8_t back) __banked;
* из keys.txt. */
#define KBD_QUIT 0x15 /* Q — вместе с Ctrl */
#define KBD_RESTART 0x1C /* A — Ctrl+A, рестарт уровня */
-#define KBD_VERSION 0x2A /* V — Ctrl+V, показать версию */
#define KBD_INTRO 0x2D /* R — Ctrl+R, вернуться в заставку */
#define KBD_SHOWTIME 0x29 /* Space — сколько осталось (seg000:612) */
#define KBD_TIMER 0x2C /* T — постоянный показ таймера. БЕЗ модификаторов:
diff --git a/libc/include/kbd_raw.h b/libc/include/kbd_raw.h
index 0f33d38..2f31593 100644
--- a/libc/include/kbd_raw.h
+++ b/libc/include/kbd_raw.h
@@ -46,6 +46,31 @@ void kbd_raw_close(void);
* на проводе) прибавить KBD_EXT к базовому коду. */
uint8_t kbd_raw_down(uint16_t code);
+/* Считать ГОЛЫЕ (без префикса 0xE0) коды нумпада ТЕМИ ЖЕ клавишами, что и
+ * расширенные: 0x69..0x7D, кроме ESC 0x76 и F11 0x78. По умолчанию
+ * выключено — нумпад остаётся отдельным набором клавиш.
+ *
+ * В PS/2 это и есть одни и те же физические клавиши: навигационный блок
+ * шлёт код с префиксом 0xE0, нумпад — тот же код голым (KP4 = Left,
+ * KP8 = Up, KP7 = Home, KP9 = PgUp и т.д.). Флаг восстанавливает это
+ * равенство: kbd_raw_sync() переносит биты голых кодов в EXT-половину
+ * карты и гасит их в PLAIN.
+ *
+ * ЗАЧЕМ ЭТО НУЖНО (а не только «нумпад тоже работает»). При переполнении
+ * 3-байтового FIFO SIO теряется префикс, и make стрелки садится в PLAIN
+ * как код нумпада — а break придёт уже с префиксом и снимет бит в ДРУГОЙ
+ * половине. Клавиша остаётся зажатой НАВСЕГДА: kbd_raw_any_down()
+ * отвечает «да», и любое ожидание «пока ничего не нажато» виснет насмерть.
+ * После перекладки make и break работают с одним и тем же битом.
+ *
+ * Чего флаг НЕ лечит: потерю префикса у BREAK (`E0 F0 6B` -> `F0 6B`) —
+ * тогда бит стоит уже в EXT-половине и выглядит как реально зажатая
+ * клавиша; снимается перенажатием или typematic-повтором.
+ *
+ * Перекладку делает kbd_raw_sync(), то есть приложение обязано звать его
+ * раз в кадр (оно и так обязано — см. ниже). */
+void kbd_raw_keypad_as_ext(uint8_t on);
+
/* Есть ли хотя бы одна зажатая клавиша (0/1). Полезно для экранов с
* семантикой «продолжить любой клавишей»; один вызов читает 64-байтную
* bitmap, а не перебирает все 512 scan-кодов через kbd_raw_down(). */
diff --git a/libc/kbd/_kbdraw.h b/libc/kbd/_kbdraw.h
index e02fa85..64359ac 100644
--- a/libc/kbd/_kbdraw.h
+++ b/libc/kbd/_kbdraw.h
@@ -57,4 +57,28 @@ extern uint8_t _kbdraw_overrun; /* 1 = ISR поймал Rx-overrun SIO (по
* читает kbd_raw_down(). */
extern uint8_t _kbdraw_down[64];
+/* ГОЛЫЕ КОДЫ НУМПАДА = ТЕ ЖЕ КЛАВИШИ, ЧТО И РАСШИРЕННЫЕ
+ * (взводит kbd_raw_keypad_as_ext).
+ *
+ * Стрелки приходят как E0 6B / E0 72 / E0 74 / E0 75, то есть в EXT-половину
+ * карты. Если префикс E0 потерян (переполнение 3-байтового FIFO SIO в
+ * длинном DI-окне), make садится в PLAIN-половину как код нумпада, а его
+ * break приходит уже с префиксом и снимает бит в EXT-половине — PLAIN-бит
+ * остаётся зажатым НАВСЕГДА (BUG-KBD: 0x72 2026-08-24, 0x6B 2026-08-27).
+ *
+ * Лечится перекладкой: kbd_raw_sync() переносит биты голых кодов нумпада
+ * в EXT-половину и гасит их в PLAIN. Тогда make без префикса ставит ТОТ ЖЕ
+ * бит, который потом снимет обычный break `E0 F0 xx`, — залипание исчезает,
+ * а нумпад заодно работает как навигационные клавиши (в PS/2 это и есть
+ * одни и те же физические клавиши). Позиции бит совпадают: EXT — те же
+ * байты карты плюс 32.
+ *
+ * Диапазон Set 2 — 0x69..0x7D, но ESC (0x76) и F11 (0x78) лежат внутри и
+ * перекладке НЕ подлежат.
+ *
+ * Чего это не лечит: потерю префикса у BREAK (`E0 F0 6B` -> `F0 6B`). Тогда
+ * бит остаётся стоять уже в EXT-половине, то есть выглядит как реально
+ * зажатая клавиша; снимается перенажатием или typematic-повтором. */
+extern uint8_t _kbdraw_keypad_ext;
+
#endif
diff --git a/libc/kbd/_kbdraw_state.c b/libc/kbd/_kbdraw_state.c
index c15a240..cd018bb 100644
--- a/libc/kbd/_kbdraw_state.c
+++ b/libc/kbd/_kbdraw_state.c
@@ -10,3 +10,6 @@ uint8_t _kbdraw_pending;
uint8_t _kbdraw_overrun; /* ISR взвёл при Rx-overrun SIO (потерян
* break) — kbd_raw_sync снимет залипшее */
uint8_t _kbdraw_down[64]; /* БИТ на скан-код (см. _kbdraw.h) */
+uint8_t _kbdraw_keypad_ext; /* 1 = голые коды нумпада считать теми же
+ * клавишами, что и расширенные
+ * (kbd_raw_keypad_as_ext; см. _kbdraw.h) */
diff --git a/libc/kbd/kbd_raw_keypad_as_ext.c b/libc/kbd/kbd_raw_keypad_as_ext.c
new file mode 100644
index 0000000..029d708
--- /dev/null
+++ b/libc/kbd/kbd_raw_keypad_as_ext.c
@@ -0,0 +1,17 @@
+/*
+ * kbd_raw_keypad_as_ext — голые коды нумпада = те же клавиши, что и
+ * расширенные (стрелки и навигация).
+ *
+ * Мотивация и разбор залипания — в шапке _kbdraw.h рядом с
+ * _kbdraw_keypad_ext; публичный контракт — в . Саму перекладку
+ * делает kbd_raw_sync(): в декодерах ей места нет — клавиатурный блок
+ * трамплина уже длиннее 127 байт, а `jp` внутри него запрещён (копия в W2),
+ * и вставка рвала относительные переходы.
+ */
+#include
+#include "_kbdraw.h"
+
+void kbd_raw_keypad_as_ext(uint8_t on)
+{
+ _kbdraw_keypad_ext = (uint8_t)(on ? 1 : 0);
+}
diff --git a/libc/kbd/kbd_raw_sync.c b/libc/kbd/kbd_raw_sync.c
index caf33bf..6b7deb2 100644
--- a/libc/kbd/kbd_raw_sync.c
+++ b/libc/kbd/kbd_raw_sync.c
@@ -47,11 +47,42 @@ static const uint16_t _kbd_mod_codes[6] = {
0x12, 0x59, 0x14, 0x11, 0x0114, 0x0111
};
+/* Голые коды нумпада (Set 2 0x69..0x7D) — по БИТАМ карты: байт 13 держит
+ * 0x68..0x6F, байт 14 — 0x70..0x77, байт 15 — 0x78..0x7F. Гасим только
+ * клавиши нумпада: ESC (0x76, байт 14 бит 6) и F11 (0x78, байт 15 бит 0)
+ * обязаны проходить, поэтому маски именно такие, а не 0xFF. */
+#define KP_BYTE0 13
+#define KP_MASK0 0x1A /* 0x69 KP1, 0x6B KP4, 0x6C KP7 */
+#define KP_BYTE1 14
+#define KP_MASK1 0xBF /* 0x70..0x75, 0x77 — всё, кроме ESC 0x76 */
+#define KP_BYTE2 15
+#define KP_MASK2 0x3E /* 0x79..0x7D — всё, кроме F11 0x78 */
+
void kbd_raw_sync(void)
{
uint8_t save[6];
uint8_t i;
+ /* ПЕРЕКЛАДЫВАЕМ ГОЛЫЕ КОДЫ НУМПАДА В EXT-ПОЛОВИНУ, если приложение
+ * попросило. Делается здесь, а не в декодерах: клавиатурный блок
+ * трамплина и так длиннее 127 байт (внутри него `jp` запрещён —
+ * трамплин копируется в W2), и вставка рвала относительные переходы.
+ * Цена — шесть операций раз в кадр вместо проверки на каждый байт.
+ *
+ * Почему это лечит залипание: стрелка приходит как E0 6B, и при потере
+ * префикса make садится в PLAIN-половину как код нумпада, а break
+ * придёт уже с префиксом и снимет бит в EXT — plain-бит остался бы
+ * зажатым навсегда. После перекладки make и break работают с ОДНИМ и
+ * тем же битом. Позиции бит совпадают: EXT — те же байты плюс 32. */
+ if (_kbdraw_keypad_ext) {
+ _kbdraw_down[KP_BYTE0 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE0] & KP_MASK0);
+ _kbdraw_down[KP_BYTE1 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE1] & KP_MASK1);
+ _kbdraw_down[KP_BYTE2 + 32] |= (uint8_t)(_kbdraw_down[KP_BYTE2] & KP_MASK2);
+ _kbdraw_down[KP_BYTE0] &= (uint8_t)~KP_MASK0;
+ _kbdraw_down[KP_BYTE1] &= (uint8_t)~KP_MASK1;
+ _kbdraw_down[KP_BYTE2] &= (uint8_t)~KP_MASK2;
+ }
+
if (!_kbdraw_overrun) return;
_kbdraw_overrun = 0;
diff --git a/toolchain/check_bank_calls.py b/toolchain/check_bank_calls.py
new file mode 100755
index 0000000..cd296a2
--- /dev/null
+++ b/toolchain/check_bank_calls.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+"""
+check_bank_calls.py — две проверки банкового кода, которых нет ни у
+компилятора, ни у линкера.
+
+ПРОВЕРКА 1 — ПРЯМОЙ вызов в ЧУЖОЙ банк.
+
+Зачем. Трамплин у SDCC выбирается по `__banked` в ОБЪЯВЛЕНИИ, а не по
+тому, в каком банке функция реально лежит (memory sdcc_banked_call_rules).
+Поэтому функция, объявленная без `__banked`, зовётся прямым `call`, и это
+верно ровно до тех пор, пока вызывающий и вызываемый лежат в ОДНОМ банке.
+Стоит перенести модуль в другой банк ради разгрузки — и тот же `call`
+уходит по адресу, где у нового банка пустой хвост (0xFF). Процессор
+ползёт по нему вверх, переваливает на 0x0000, и DSS убивает процесс.
+
+Собирается это МОЛЧА: ни компилятор, ни линкер такой вызов не ловят.
+Поймано вживую 2026-08-27 — `hof_draw_rows` (банк 10) звал
+`pop_text_map` (банк 9): `call $E503` в пустоту, крэш финала.
+
+Как проверяем. По .map берём банк каждого глобального символа (старшее
+слово адреса: 0 = резидент, N = банк N), по сгенерированным bankN_*.asm —
+все прямые `call`/`jp` по ИМЕНИ символа. Вызов законен, если цель
+резидентная или лежит в том же банке; иначе это мина.
+
+ПРОВЕРКА 2 — УКАЗАТЕЛЬ НА ДАННЫЕ СВОЕГО БАНКА, ОТДАННЫЙ В ЧУЖОЙ.
+
+Обратная сторона той же медали. Пока вызывающий и вызываемый лежали в
+одном банке, указатель на литерал или const-таблицу банка работал. Стоит
+пометить вызываемую функцию `__banked` — и трамплин на время вызова
+переключает W3: тот же указатель показывает уже в чужой банк, и функция
+читает оттуда мусор. Поймано вживую 2026-08-27: `hof_edit_draw` (банк 10)
+передавал литерал "_" в `pop_text_draw_mapped` (банк 9), и экран заливало
+знаками вопроса на каждое мигание курсора.
+
+Ловим ФОРМУ, а не поток данных: аргументы банкового вызова SDCC кладёт
+непосредственно перед ним, поэтому идём от каждого `call ___sdcc_bcall_ehl`
+назад по блоку подготовки и смотрим, не грузился ли там адрес метки из
+области _BANKn этого же модуля.
+
+НЕ ПРОВЕРЕНО: режим, в котором const/статики банковых модулей размещаются
+в РЕЗИДЕНТНОЙ памяти, а не в области банка (SprPoP им не пользуется). По
+построению там всё должно быть тихо — «данными банка» считаются только
+метки из `.area _BANKn`, а резидентные в этот список не попадут, и
+передавать такой указатель в чужой банк законно. Но живьём это не
+гонялось: если режим включат, проверку надо прогнать и убедиться, что она
+не начала молчать там, где не должна.
+
+ЧЕГО ЭТА ПРОВЕРКА НЕ ВИДИТ (и не может): указатель, положенный в
+переменную и переданный позже; адрес, посчитанный арифметикой; указатель,
+отданный резидентной функции, которая сохранит его на будущее. Для этого
+нужен анализ потока данных. Правило остаётся за человеком: всё, что
+уходит указателем в другой банк, обязано лежать в W2 (стек, _DATA, копия).
+
+Использование:
+ check_bank_calls.py # каталог с .map и bankN_*.asm
+Возврат: 0 — чисто, 1 — найдено (печатается списком).
+"""
+
+import re
+import sys
+from pathlib import Path
+
+# `call _foo`, `jp _foo`, с условием и без: `call NZ,_foo` / `jp C,_foo`.
+CALL_RE = re.compile(
+ r'^\s*(call|jp)\s+(?:[A-Za-z]{1,2}\s*,\s*)?(_[A-Za-z_0-9]+)\s*(?:;.*)?$',
+ re.IGNORECASE)
+# Строка карты: " 0009E503 _pop_text_map pop_ui"
+MAP_RE = re.compile(r'^\s+([0-9A-F]{8})\s+(\S+)\s')
+BANK_ASM_RE = re.compile(r'^bank(\d+)_')
+
+# Механика самих банковых вызовов — не наша забота.
+IGNORE = {'___sdcc_bcall_ehl', '___sdcc_bcall'}
+
+# `ld hl,#_метка` / `ld de,#___str_3` — материализация адреса.
+LOADADDR_RE = re.compile(
+ r'^\s*ld\s+(hl|de|bc|iy|ix)\s*,\s*#(_{1,3}[A-Za-z_0-9]+)'
+ r'(?:\s*[+-]\s*\w+)?\s*(?:;.*)?$', re.IGNORECASE)
+PUSH_RE = re.compile(r'^\s*push\s+(hl|de|bc|iy|ix)\s*(?:;.*)?$',
+ re.IGNORECASE)
+# `ld e,#b_foo` перед трамплином называет банк цели.
+BANKARG_RE = re.compile(r'^\s*ld\s+e\s*,\s*#(b_[A-Za-z_0-9]+)\s*(?:;.*)?$',
+ re.IGNORECASE)
+AREA_RE = re.compile(r'^\s*\.area\s+(\S+)')
+LABEL_RE = re.compile(r'^([A-Za-z_][A-Za-z_0-9]*)::?')
+# Директивы данных: по ним отличаем метку ДАННЫХ от метки функции. Без
+# этого проверка принимала бы за указатель саму механику трамплина
+# (`ld hl,#_функция` перед `call ___sdcc_bcall_ehl`).
+DATA_DIR_RE = re.compile(r'^\s*\.(ascii|asciz|str|db|byte|dw|word|ds|blkb)\b',
+ re.IGNORECASE)
+# Сколько строк назад от трамплина считаем «подготовкой аргументов».
+ARG_WINDOW = 40
+
+
+def load_symbol_banks(map_path):
+ """symbol -> номер банка (0 = резидент). Берём ПЕРВОЕ определение:
+ карта повторяет один и тот же список в каждой секции Area."""
+ banks = {}
+ for line in map_path.read_text(errors='replace').splitlines():
+ m = MAP_RE.match(line)
+ if not m:
+ continue
+ value, name = m.group(1), m.group(2)
+ if name.startswith('l__') or name.startswith('s__') or name == '.__.ABS.':
+ continue
+ banks.setdefault(name, int(value[:4], 16))
+ return banks
+
+
+def main(argv):
+ if len(argv) != 2:
+ print(__doc__.strip())
+ return 2
+ build = Path(argv[1])
+ maps = list(build.glob('*.map'))
+ if not maps:
+ print(f'check_bank_calls: нет .map в {build}', file=sys.stderr)
+ return 2
+ banks = load_symbol_banks(maps[0])
+
+ bad = []
+ for asm in sorted(build.glob('bank*_*.asm')):
+ m = BANK_ASM_RE.match(asm.name)
+ if not m:
+ continue
+ self_bank = int(m.group(1))
+ for n, line in enumerate(asm.read_text(errors='replace').splitlines(), 1):
+ c = CALL_RE.match(line)
+ if not c:
+ continue
+ target = c.group(2)
+ if target in IGNORE:
+ continue
+ tb = banks.get(target)
+ if tb is None or tb == 0 or tb == self_bank:
+ continue
+ bad.append((asm.name, n, self_bank, target, tb, line.strip()))
+
+ bad_ptr = check_bank_local_pointers(build, banks)
+
+ if not bad and not bad_ptr:
+ print('check_bank_calls: чисто — ни прямых вызовов в чужой банк, '
+ 'ни указателей на данные своего банка наружу')
+ return 0
+
+ if bad:
+ print('check_bank_calls: ПРЯМОЙ ВЫЗОВ В ЧУЖОЙ БАНК '
+ '(объявить цель __banked либо свести модули в один банк):',
+ file=sys.stderr)
+ for name, line_no, sb, target, tb, text in bad:
+ print(f' {name}:{line_no}: банк {sb} -> {target} (банк {tb}): '
+ f'{text}', file=sys.stderr)
+ if bad_ptr:
+ print('check_bank_calls: ВНИМАНИЕ — похоже, указатель на данные '
+ 'своего банка уходит в чужой (проверить глазами; лечится '
+ 'переносом данных в W2: стек, _DATA или копия):',
+ file=sys.stderr)
+ for name, line_no, sb, label, target, tb in bad_ptr:
+ print(f' {name}:{line_no}: банк {sb}: #{label} -> {target} '
+ f'(банк {tb})', file=sys.stderr)
+ # Сборку валит ТОЛЬКО проверка 1: она доказательна. Проверка 2 —
+ # эвристика по форме кода, её дело предупредить, а не останавливать.
+ return 1 if bad else 0
+
+
+def bank_local_labels(lines):
+ """Метки ДАННЫХ, определённые в области _BANKn этого модуля: их адреса
+ видны только пока банк подключён в W3. Метки функций сюда не берём —
+ их адрес законно кладут в HL для самого трамплина."""
+ labels, area = set(), None
+ for i, line in enumerate(lines):
+ a = AREA_RE.match(line)
+ if a:
+ area = a.group(1)
+ continue
+ m = LABEL_RE.match(line)
+ if not (m and area and area.upper().startswith('_BANK')):
+ continue
+ for nxt in lines[i + 1:i + 4]: # пропускаем пустые/метки
+ if not nxt.strip() or LABEL_RE.match(nxt):
+ continue
+ if DATA_DIR_RE.match(nxt):
+ labels.add(m.group(1))
+ break
+ return labels
+
+
+def check_bank_local_pointers(build, banks):
+ """Адрес метки своего банка, положенный в аргументы банкового вызова
+ в ЧУЖОЙ банк. Ищем форму, а не поток данных (см. шапку файла)."""
+ bad = []
+ for asm in sorted(build.glob('bank*_*.asm')):
+ m = BANK_ASM_RE.match(asm.name)
+ if not m:
+ continue
+ self_bank = int(m.group(1))
+ lines = asm.read_text(errors='replace').splitlines()
+ local = bank_local_labels(lines)
+ if not local:
+ continue
+ for i, line in enumerate(lines):
+ if '___sdcc_bcall_ehl' not in line or 'call' not in line.lower():
+ continue
+ # Банк цели: ближайший `ld e,#b_...` выше вызова.
+ target, tbank = None, None
+ for j in range(i - 1, max(-1, i - 6), -1):
+ b = BANKARG_RE.match(lines[j])
+ if b:
+ target = b.group(1)[2:] # b_foo -> foo
+ tbank = banks.get('_' + target)
+ break
+ if tbank is None or tbank == self_bank:
+ continue
+ # Назад по блоку подготовки аргументов. Останавливаемся на
+ # первом же `call`: аргументы НАШЕГО вызова не могут готовиться
+ # раньше предыдущего — иначе тот бы их и потребил.
+ #
+ # Признак «указатель ушёл наружу» — адрес метки данных банка,
+ # который затем ПОЛОЖЕН В СТЕК как аргумент. Для банкового
+ # вызова это единственный способ передать указатель: HL занят
+ # самим трамплином. Адрес, загруженный ради индексной
+ # арифметики (`ld de,#tbl` + `add hl,de` + `ld a,(hl)`), в стек
+ # не кладётся — так отсеиваются чтения таблиц своего банка.
+ pushed = {} # регистр -> строка push
+ for j in range(i - 1, max(-1, i - ARG_WINDOW), -1):
+ if LABEL_RE.match(lines[j]): # начало другого блока
+ break
+ if re.match(r'^\s*call\b', lines[j], re.IGNORECASE):
+ break
+ pu = PUSH_RE.match(lines[j])
+ if pu:
+ pushed.setdefault(pu.group(1).lower(), j)
+ continue
+ a = LOADADDR_RE.match(lines[j])
+ if not (a and a.group(2) in local):
+ continue
+ reg = a.group(1).lower()
+ if reg not in pushed:
+ continue
+ # Между загрузкой адреса и push регистр не должен меняться:
+ # иначе в стек уходит не адрес, а посчитанное по нему
+ # значение (индексная арифметика по своей таблице).
+ halves = {'hl': 'hl', 'de': 'de', 'bc': 'bc'}.get(reg, reg)
+ touched = re.compile(
+ r'^\s*(?:ld\s+[' + halves[0] + halves[1] + r']\s*,'
+ r'|ld\s+' + reg + r'\s*,'
+ r'|(?:inc|dec)\s+(?:' + reg + r'|[' + halves[0] +
+ halves[1] + r'])\b'
+ r'|add\s+' + reg + r'\b'
+ r'|ex\s+de\s*,\s*hl)', re.IGNORECASE)
+ if any(touched.match(t) for t in lines[j + 1:pushed[reg]]):
+ break
+ bad.append((asm.name, j + 1, self_bank, a.group(2),
+ target, tbank))
+ break
+ return bad
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv))