From 4e43890fceb40ad6043663d2585f0ac1a112a05b Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Thu, 27 Aug 2026 22:11:01 +0300 Subject: [PATCH] =?UTF-8?q?SprPoP:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=B1=D0=BE=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=83?= =?UTF-8?q?=D0=B1=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=D0=BC=D1=83=20=E2=80=94=20=D0=BF=D1=80=D1=8F?= =?UTF-8?q?=D0=BC=D0=BE=D0=B9=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D1=87=D1=83=D0=B6=D0=BE=D0=B9=20=D0=B1=D0=B0=D0=BD=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пройденная игра доходила до таблицы рекордов и умирала: программа исчезала, машина следом вставала намертво (di;halt на 0x0000) либо уходила в reset. Одинаково из Flex Navigator и из голого DSS. КОРЕНЬ. pop_ui.h объявлял группу pop_text_*_mapped БЕЗ __banked. Пока pop_hof.c лежал в банке 9 рядом с pop_ui.c, прямой call был верен; после переноса pop_hof/pop_config/pop_pal в банк 10 тот же call стал уходить в пустой хвост чужого банка. Процессор полз по 0xFF до 0x0000, где ловушка DSS ставит B=0x27 и сворачивает процесс — подмена страниц W1/W2/W3, которую было видно на трупе, оказалась уборкой, а не причиной. Точную инструкцию (call $E503 = _pop_text_map банка 9) дала трассировка MAME на узком участке: trace включалась брейкпоинтом на входе в pop_hof_show и выключалась на процедуре завершения процесса DSS (0x1E56). ЧТО СДЕЛАНО * pop_ui.h/.c — группа text_*_mapped помечена __banked. * toolchain/check_bank_calls.py — две проверки банкового кода: 1) прямой call в чужой банк (доказательна, ВАЛИТ сборку — проверено намеренной поломкой); 2) указатель на данные своего банка, отданный в чужой (эвристика по форме кода, только предупреждает). Встроена в app.mk, запускается сразу после линковки. * pop_hof.c — курсор ввода строится на стеке: литерал "_" лежал в _BANK10 и после пометки __banked уезжал из-под ног чужому банку, заливая экран знаками вопроса. * libc: kbd_raw_keypad_as_ext() — kbd_raw_sync переносит голые коды нумпада в EXT-половину карты. Лечит залипание стрелок (потерянный префикс E0 сажал make в PLAIN как код нумпада, и снять его было нечем), заодно нумпад стал управлением: 7/8/9, 4/6, 2 и 5 = вниз. * pop_pace.c — цикл ожидания луча зовёт тот же idle-хук, что и gfx_wait_vsync: без этого F10 в геймплее не работал вовсе. * pop_hof.c — Esc в таблице рекордов отменяет запись (расхождение с оригиналом записано в docs/impl_diff.md). * Экран версии показывается только через Menu/Settings/About: стартовый показ и Ctrl+V убраны, мёртвый код снят. * sprpop_cold.c — pop_start_level зовёт pop_hp_invalidate: после Ctrl+A с выросшим за уровень максимумом полоса HP моргала между страницами. Разбор всех четырёх багов — в applications/PoP/roomtest/BUGS_CLOSED.md (FINAL-BANKCALL, FINAL-HOF-GARBAGE, KBD-ARROW-PHANTOM, F10-GAMEPLAY), правило про банки — в applications/SprPoP/CLAUDE.md. Co-Authored-By: Claude Opus 5 --- app.mk | 9 + applications/PoP/roomtest/BUGS_CLOSED.md | 112 ++++++++++ applications/SprPoP/CLAUDE.md | 26 +++ applications/SprPoP/docs/impl_diff.md | 37 ++++ applications/SprPoP/docs/keys.txt | 1 - applications/SprPoP/src/pop_ctrl.c | 18 +- applications/SprPoP/src/pop_hof.c | 44 +++- applications/SprPoP/src/pop_menu.c | 47 ---- applications/SprPoP/src/pop_menu.h | 1 - applications/SprPoP/src/pop_pace.c | 7 +- applications/SprPoP/src/pop_state.h | 6 + applications/SprPoP/src/pop_ui.c | 12 +- applications/SprPoP/src/pop_ui.h | 23 +- applications/SprPoP/src/sprpop.c | 14 +- applications/SprPoP/src/sprpop_cold.c | 17 +- applications/SprPoP/src/sprpop_cold.h | 1 - libc/include/kbd_raw.h | 25 +++ libc/kbd/_kbdraw.h | 24 +++ libc/kbd/_kbdraw_state.c | 3 + libc/kbd/kbd_raw_keypad_as_ext.c | 17 ++ libc/kbd/kbd_raw_sync.c | 31 +++ toolchain/check_bank_calls.py | 259 +++++++++++++++++++++++ 22 files changed, 645 insertions(+), 89 deletions(-) create mode 100644 libc/kbd/kbd_raw_keypad_as_ext.c create mode 100755 toolchain/check_bank_calls.py 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))