Compare commits

..

9 Commits

Author SHA1 Message Date
snark13 879f2bae31 size_baseline: принять atlas — эталон был записан со старым .map
atlas значился 9193 Б против 9627 фактических, и расхождение держалось
через все правки этой сессии: замерял со СПРЯТАННЫМИ правками — те же
9627, то есть к ним оно отношения не имеет.

Похоже на механику самого инструмента: size_check.py читает .map С ДИСКА,
а не пересобирает.  Программа, которую в тот раз не пересобрали, попадает
в эталон со СТАРЫМ числом — и дальше висит расхождением, пока её однажды
не соберут заново.  Сегодня после make clean впервые за долгое время
пересобралось всё, и накопленный рост libc/libbgi стал видимым разом.

Записываю честное число.  Полезный вывод на будущее: `make size-baseline`
имеет смысл только после полной пересборки тестов, иначе он фиксирует
смесь свежих и устаревших размеров.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:42 +03:00
snark13 b3f9a7430c SprPoP: тихие наборы звука и музыки в dist/
Выхлоп tools/pop_quiet.py на текущих наборах: те же snd.arc и m*.bin, но
вдвое тише (out = in/2 + 0x40, тишина 0x80 остаётся на месте), плюс
неизменённые индексы.  Раскладка совпадает с диском игры.

Это ПРОИЗВОДНЫЙ артефакт: восстанавливается из assets/packed одной
командой `python3 tools/pop_quiet.py` за секунды.  Лежит в репозитории по
той же логике, что и assets/packed — чтобы вариант был под рукой без
пересборки; если решим, что 3,6 МБ того не стоят, снимается одним
git rm --cached плюс строка в .gitignore.

На образ игры dist/ автоматически не попадает: make hdd берёт assets/packed
через build/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 15:00:12 +03:00
snark13 fee3bdb354 SprPoP: ALLOCS по умолчанию 10000; обёртка dist_to_edge — за своим хелпером
Правки пользователя, разобранные по диффу и закоммиченные как есть.

Makefile: умолчание --max-allocs поднято с 6000 до 10000 — плотнее
упаковка регистров.  Напоминание из шапки остаётся в силе: занятость
банков сравнима только при ОДНОМ значении ALLOCS, иначе сравниваются не
правки, а уровни оптимизации.

pop_map.c: банковая обёртка pop_dist_to_edge_weight() переехала НИЖЕ
статического distance_to_edge_weight(), который она зовёт, — раньше стояла
до его определения.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:56 +03:00
snark13 6a97124e0d sprinter-cc: --bank-data принимает номер банка
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Было «всё или ничего»: --bank-data уводил писучие данные В СТРАНИЦУ для
ВСЕХ банков сразу.  Теперь флаг принимает необязательный номер и
повторяется: --bank-data 5 --bank-data 6.  Без аргумента поведение прежнее
(все банки), поэтому существующие сборки не меняются.

Зачем поштучно: данные в странице банка НЕ ВИДНЫ снаружи, поэтому модуль с
экспортируемым глобалом обязан остаться на общем _DATA в W1/W2 — а его
сосед в это же время может держать большой приватный буфер вне резидентного
бюджета.  Одним флагом на всю программу это не выражается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:43 +03:00
snark13 8610a8c178 toolchain: предупреждать, когда ISR-стаб W0-страниц остался в W1
Правка пользователя, разобранная по диффу и закоммиченная как есть.

Программа может временно маппить свою EMM-страницу в окно 0 (атласы
спрайтов, gfx_w0_map).  Пока страница там, любое прерывание уходит на
#0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare прошивает туда переход на
_gfx_w0_isr.  Сам стаб живёт в _CODE, а в режимах small и huge _CODE
начинается с 0x4100 — то есть попадает в W1, окно, которое DSS перемаплет
на время СВОИХ вызовов.  Прерывание в этот момент уходит по адресу,
которого сейчас нет: W1 читается как #FF.

Ловится тяжело: собирается молча, проявляется недетерминированно —
зависанием примерно на каждом третьем холодном старте.  Поймано вживую
2026-09-01.

check_w0_isr.py берёт адрес __gfx_w0_isr из .map: символа нет — W0-путь не
слинкован, молчим; адрес >= 0x8000 — он в W2, всё хорошо; иначе WARNING.
Сборку не валит намеренно: свои страницы в W0 кладут не все.  Вызов
добавлен в app.mk рядом с check_bank_calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:59:26 +03:00
snark13 81e8ed4676 SprPoP: pop_quiet.py — тихая копия готовых наборов звука и музыки
Берёт то, что уже лежит в assets/packed, делает тише и кладёт в dist/ в
раскладке диска, перезаписывая прежнее.  Оригиналы не трогаются, поэтому
скрипт идемпотентен: сколько раз ни запусти, громкость упадёт один раз.

Формула — та, что предложил пользователь: PCM у нас 8 бит без знака с
тишиной 0x80, значит вдвое тише это out = in/2 + 0x40 (беззнаковый сдвиг).
Таблица на 256 значений строится floor-делением отклонения от центра, что
при gain = 1/2 совпадает с этой формулой байт в байт — то есть результат
можно сверять с реализацией на Z80 (srl a / add a,#0x40).  Тишина остаётся
тишиной при любом gain, клиппинга нет по построению.

Пересчитываются: MUS/m*.bin целиком (чистый PCM) и ТОЛЬКО тела записей
PBA1 в SND/snd.arc — заголовок и выравнивающие хвосты остаются как есть,
там нули упаковщика, и пересчёт превратил бы их в 0x40.  Индексы
копируются без изменений (длины те же), но с проверкой магии.

Прогон на текущих наборах: 10 записей архива и 22 трека, пик 128 -> 64,
размеры байт в байт прежние, заголовок PBA1 не тронут.

Тест закрепляет формулу и неприкосновенность служебных байт; make
test-tools — 45 тестов.  Раскладка dist/ добавлена в tools/paths.py, по
правилу «пути знает один файл».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:16:07 +03:00
snark13 8490288d79 libc/crt0: возврат из main завершает программу по-настоящему
Два бага одного пути завершения, оба видны только на железе.

1. ЗВУК ПРОДОЛЖАЛСЯ ПОСЛЕ ВЫХОДА.  cbl_close() закрывает СЕССИЮ, но не
гасит железо: bit7 порта 0x004E держит gfx_wait_vsync ради бита луча,
поэтому порт оставался включённым ("немой" режим), и CBL крутил свои 256
слотов уже под шеллом — тихо ровно до первой чужой записи в порт данных,
а дальше она зацикливалась.  Новый cbl_shutdown() гасит bit7 независимо
от держателей и центрует ЦАП обычного COVOX; pop_shutdown зовёт его
последним действием, а _cbl_open_raw регистрирует в atexit его, а не
cbl_close.

2. ЦЕПОЧКА atexit НЕ ВЫПОЛНЯЛАСЬ ПРИ ВОЗВРАТЕ ИЗ main.  crt0 уходил прямо
в ESTEX EXIT, то есть нарушал контракт C (возврат из main = exit(status)).
Молча терялись не только гашение звука и снятие vsync-ссылки, но и
_fclosall: буферизованная запись в файлы пропадала, если программа не
звала exit() явно.  Теперь crt0 после main дёргает _atexit_hook.

Косвенность обязательна: прямая ссылка crt0 на разматыватель притащила бы
его и стек хендлеров в КАЖДУЮ программу.  Указатель живёт в отдельном
data-модуле (два байта _DATA, ни байта кода), ставит его сам atexit() при
первой регистрации — нет регистраций, нет и кода.  Тот же приём, что у
_irq_cbl_hook.

Цена замерена: +14 Б всем программам (блок в crt0) и +64 Б тем
одиннадцати, что реально регистрируют хендлеры (CBL, файловые через
_fclosall, irqtest, gfx_dbuf, solidt) — у них раньше эти хендлеры были
мёртвым кодом.  Эталоны обновлены (кроме atlas: его +434 Б не отсюда,
замерен тот же и без этих правок).

Проверено в MAME: старт и звук как были, выход по F10 возвращает в шелл
чисто (текстовый режим восстановлен, зависания нет), повторный запуск
работает.  Пункт 1 проверяется только на железе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 14:15:48 +03:00
snark13 31d0075090 SprPoP: набор звуковых эффектов пересобран из MSDOS-оцифровки
make SND_SRC=msdos MUSIC_FMT=flac resources-rebuild — полная перегенерация
всех ресурсов.  Изменился только SND/: у MSDOS-набора оцифровка полнее,
чем у SDLPoP (там пуст звук 48 spiked и короче 10 sword_vs_sword — разбор
в docs/sound_plan.md).

Музыка и остальные архивы после перегенерации совпали с закоммиченными
байт в байт — набор flac и есть тот, из которого они сделаны.

Умолчание сборки НЕ меняется: SND_SRC ?= sdlpop, чтобы клон без
оригинального дистрибутива DOS собирался целиком (make fetch).  Вернуть
прежний набор — make resources-sound после make clean stamps, или
make SND_SRC=sdlpop -B resources-sound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:17 +03:00
snark13 6176f6da31 SprPoP: Restart Game возвращал заставку без музыки и вешал её на титуле
pop_boot_sound() был целиком под разовым флагом, а pop_sfx_start() стоял
внутри него.  До перестановки старта CBL открывал сам шаг 0 титров, и
делал это КАЖДЫЙ раз; теперь второй вызов уходил по раннему return, а
pop_title_show начинается с pop_sfx_pause() — значит после Restart Game
насос оставался выключенным и заставка шла молча.

Тем же корнем объясняется зависание: с закрытым CBL курсор трека не
двигается, pop_mus_left не убывает, и ожидания «дослушать тему» в титрах
и интро (while (pop_music_busy())) висели до нажатия клавиши — на экране
Prince of Persia.

- pop_boot_sound: под разовым флагом осталась только ЗАГРУЗКА (индекс
  звука, первая страница набора, индекс музыки, settings_apply);
  pop_sfx_start() зовётся всегда — он идемпотентен и уважает Ctrl+S;
- pop_music_busy(): признак теперь «звучит», а не «есть курсор» —
  спрашивает и про открытый вывод (pop_snd_ok).  Ждать неиграющую музыку
  нельзя в принципе, и эта строка закрывает весь класс подвисаний.

Проверено в MAME: уровень 1 -> ESC -> RESTART GAME -> титул с музыкой
(pop_snd_ok=1, курсор трека прошёл страницы 8..16, pop_mus_id=54), дальше
последовательность сама уходит в сцену с принцессой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DSYUpuaQpKr48kBav2iiV4
2026-09-02 11:45:02 +03:00
48 changed files with 657 additions and 80 deletions
+9 -1
View File
@@ -92,11 +92,19 @@ RUNTIME_DEPS := $(wildcard $(PROJ_ROOT)/runtime/*.s)
# bankN_*.asm; обычным небанковым программам ничего не стоит.
BANK_CHECK := $(PROJ_ROOT)/toolchain/check_bank_calls.py
# ПРОВЕРКА ISR-СТАБА W0-СТРАНИЦ — там же, по свежей карте. Ловит стаб
# _gfx_w0_isr, оставшийся в W1: программа, кладущая свои страницы в W0
# (атласы спрайтов, gfx_w0_map), получает недетерминированные зависания,
# когда прерывание приходит во время вызова DSS и W1 перемаплен. Только
# предупреждение: страницы в W0 кладут не все. Разбор — в шапке скрипта.
W0ISR_CHECK := $(PROJ_ROOT)/toolchain/check_w0_isr.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
if ls $$d/bank*_*.asm >/dev/null 2>&1; then python3 $(BANK_CHECK) $$d; fi; \
if ls $$d/*.map >/dev/null 2>&1; then python3 $(W0ISR_CHECK) $$d; fi
$(MKEXE):
$(MAKE) -C $(PROJ_ROOT)/toolchain/mkexe
+1 -1
View File
@@ -47,7 +47,7 @@ MEMORY ?= huge
#
# ВАЖНО: сравнивать занятость банков можно только при ОДНОМ ALLOCS — иначе
# сравниваются не правки, а уровни оптимизации.
ALLOCS ?= 6000
ALLOCS ?= 10000
# Build info не хранится в C: ID и дата извлекаются из текущего commit.
BUILD_ID ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
BUILD_DATE ?= $(shell git log -1 --format=%cs 2>/dev/null || echo unknown)
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -5
View File
@@ -555,11 +555,6 @@ void pop_determine_col(void) __banked { determine_col(); }
* Обе давно работают от Char, а не от Kid; не хватало только объявлений
* (docs/sdlpop_audit.md, находка 12). */
uint8_t pop_tile_behind_char(void) __banked { return get_tile_behind_char(); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* расстояние до края тайла (для in_wall). */
static int distance_to_edge(int xpos)
@@ -570,8 +565,15 @@ static int distance_to_edge(int xpos)
if (Char.direction == 0) d = TILE_RIGHTX - d; /* dir_0_right */
return d;
}
static int distance_to_edge_weight(void) { return distance_to_edge(dx_weight()); }
int8_t pop_dist_to_edge_weight(void) __banked
{
int d = distance_to_edge_weight();
return (int8_t)(d > 127 ? 127 : (d < -128 ? -128 : d));
}
/* ---- ТОЧНАЯ коллизия со стеной (seg004) --------------------------- *
* Передний край персонажа = char_x_right (взгляд вправо) / char_x_left
* (влево) из set_char_collision (seg006:0723): obj_x/2 + 58, где obj_x =
+10 -1
View File
@@ -17,6 +17,7 @@
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h"
#include "pop_sfx.h"
#include "_pop_sfx.h" /* pop_snd_ok — открыт ли вывод (см. pop_music_busy) */
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h"
@@ -454,7 +455,15 @@ void pop_music_service(void) __banked
uint8_t pop_music_busy(void) __banked
{
return (uint8_t)(pop_mus_left != 0);
/* «ЗВУЧИТ», А НЕ «ЕСТЬ КУРСОР». Курсор трека двигает насос CBL, и пока
* вывод закрыт (pop_sfx_pause на загрузке уровня, Ctrl+S, показ заставки
* после Restart Game) pop_mus_left не убывает НИКОГДА. Вызывающий,
* который ждёт конца мелодии, в этом состоянии висит вечно — ровно так
* заставка после Restart Game вставала на экране Prince of Persia
* (поймано пользователем 2026-09-02). Корень того случая был в другом
* (CBL не открывался заново, см. pop_boot_sound), но ждать неиграющую
* музыку нельзя в принципе, поэтому признак спрашивает и про вывод. */
return (uint8_t)(pop_snd_ok && pop_mus_left != 0);
}
/* ТРЕК В РАБОТЕ — заявка лежит, идёт загрузка или он звучит.
+28 -8
View File
@@ -25,6 +25,7 @@
#include "pop_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
@@ -1205,14 +1206,23 @@ int pop_boot(void) __banked
* полсекунды, и лучше, чтобы она прошла под картинкой. */
int pop_boot_sound(void) __banked
{
if (boot_sound_done) return 0;
boot_sound_done = 1;
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
if (!boot_sound_done) {
boot_sound_done = 1;
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */
}
/* ОТКРЫТИЕ CBL — ВНЕ РАЗОВОГО ГАРДА, и это не мелочь. Загрузка нужна
* один раз за запуск, а вот вывод к моменту следующего показа заставки
* почти наверняка закрыт: pop_title_show начинается с pop_sfx_pause, да
* и уровень оставляет CBL закрытым. Пока start стоял под флагом,
* Restart Game приводил к заставке БЕЗ музыки (поймано пользователем
* 2026-09-02), а заодно подвешивал ожидания «дослушать тему»: с
* выключенным насосом pop_mus_left не убывает, и pop_music_busy()
* истинен вечно. Идемпотентно и уважает Ctrl+S (pop_snd_want). */
(void)pop_sfx_start();
return 0;
}
@@ -1481,4 +1491,14 @@ void pop_shutdown(void) __banked
pop_guard_free();
pop_kid_data_free();
pop_level_free();
/* ЖЕЛЕЗО ЗВУКА ГАСИМ ПОСЛЕДНИМ ДЕЙСТВИЕМ, и pop_sfx_close выше для этого
* НЕ ДОСТАТОЧНО: он закрывает сессию, а bit7 порта CBL остаётся включён,
* пока его держит gfx_wait_vsync ради бита луча. Дальше main просто
* возвращается, а crt0 при возврате уходит прямо в ESTEX EXIT, минуя
* цепочку atexit (runtime/crt0_banked.s) — то есть vsync-ссылку снять
* уже некому. В результате мы уходили в шелл с включённым CBL, и он
* крутил свои 256 слотов: тихо до первой чужой записи в порт данных, а
* дальше она зацикливалась (поймано пользователем на железе 2026-09-02;
* в MAME не воспроизводится). */
cbl_shutdown();
}
+9
View File
@@ -125,6 +125,8 @@ def find_ci(directory, name):
ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
PACKED = APP / "assets" / "packed" # раскладка диска игры
GEN = APP / "gen" # генерируемые заголовки
DIST = APP / "dist" # ручное содержимое дистрибутива (README и
# производные наборы, напр. тихий звук)
def atl(name):
@@ -139,3 +141,10 @@ def packed(name):
p = PACKED / name
p.mkdir(parents=True, exist_ok=True)
return p
def dist(name):
"""Каталог внутри dist/ в раскладке диска; создаётся при обращении."""
p = DIST / name
p.mkdir(parents=True, exist_ok=True)
return p
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""pop_quiet.py — тише в N раз: пережать ГОТОВЫЕ наборы звука и музыки.
Берёт то, что уже лежит в assets/packed (SND/snd.arc + MUS/m*.bin), делает
тише и кладёт в dist/ в той же раскладке диска, ПЕРЕЗАПИСЫВАЯ то, что там
было. Оригиналы в assets/packed не трогаются никогда — значит скрипт
идемпотентен: сколько раз ни запусти, громкость упадёт ровно один раз.
ФОРМУЛА. PCM у нас 8 бит БЕЗ ЗНАКА, тишина = 0x80 (см. cbl.h и
docs/sound_plan.md), то есть байт — это знаковое отклонение от 0x80.
Тише вдвое:
out = 0x80 + (in - 0x80) / 2 = in / 2 + 0x40
Правая форма — та самая, что делается на Z80 двумя командами
(`srl a` / `add a,#0x40`), поэтому таблицу строим floor-делением: тогда
результат совпадает с ней БАЙТ В БАЙТ и его можно сверять с железной
реализацией. Тишина остаётся тишиной (0x80 -> 0x80) при любом gain, а
клиппинга не бывает по построению: |отклонение| только уменьшается.
ЧТО ИМЕННО ПЕРЕСЧИТЫВАЕТСЯ.
MUS/m*.bin — файл целиком: это чистый PCM без заголовка, загрузчик
читает его страницами с нулевого смещения (pop_music.c).
SND/snd.arc — ТОЛЬКО тела записей архива PBA1. Заголовок (512 Б) и
выравнивающий хвост между записями остаются как есть:
там нули упаковщика, а не звук, и пересчёт превратил бы
их в 0x40 — мусор на месте служебных байт.
*.idx — копируются без изменений: длины и смещения не поменялись,
меняются только сами отсчёты. Магию всё же проверяем,
чтобы молча не скопировать файл чужого формата.
Запуск:
python3 tools/pop_quiet.py # вдвое тише, packed -> dist
python3 tools/pop_quiet.py --gain 0.25 # вчетверо тише
python3 tools/pop_quiet.py --src DIR --out DIR
"""
import argparse
import shutil
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import paths
SILENCE = 0x80 # центр 8-битного беззнакового PCM
ARC_MAGIC = b"PBA1" # архив ресурсов, см. src/pop_arc.h
ARC_HEADER = 512
ARC_SECTOR = 512
SND_IDX_MAGIC = b"PSI1"
MUS_IDX_MAGIC = b"PMI1"
def gain_table(gain: float) -> bytes:
"""Таблица перекодировки байта: 256 значений, готовые для bytes.translate.
floor, а не округление к ближайшему: при gain = 1/2 это ровно
`in >> 1 + 0x40`, то есть побайтово то же, что сделал бы Z80.
"""
if not 0.0 <= gain <= 1.0:
raise SystemExit("gain обязан лежать в [0, 1]; громче делать нечем — "
"запас по амплитуде в исходнике уже выбран")
out = bytearray(256)
for v in range(256):
dev = v - SILENCE # знаковое отклонение
out[v] = max(0, min(255, SILENCE + _floor_div(dev, gain)))
return bytes(out)
def _floor_div(dev: int, gain: float) -> int:
"""dev * gain, округлённое ВНИЗ (к минус бесконечности)."""
scaled = dev * gain
i = int(scaled)
return i - 1 if scaled < i else i # int() режет к нулю
def quiet_raw(src: Path, dst: Path, table: bytes) -> int:
"""Файл целиком — PCM (музыкальный трек). Возвращает размер."""
data = src.read_bytes()
dst.write_bytes(data.translate(table))
return len(data)
def quiet_arc(src: Path, dst: Path, table: bytes) -> int:
"""Архив PBA1: пересчитываем только тела записей."""
raw = bytearray(src.read_bytes())
if raw[0:4] != ARC_MAGIC:
raise SystemExit(f"{src}: не {ARC_MAGIC.decode()} — это не наш архив")
count = raw[4]
if not 1 <= count <= (ARC_HEADER - 8) // 4:
raise SystemExit(f"{src}: count = {count}, вне 1..126")
for i in range(count):
off_sec, size = struct.unpack_from("<HH", raw, 8 + i * 4)
off = off_sec * ARC_SECTOR
if off + size > len(raw):
raise SystemExit(f"{src}: запись {i} выходит за файл "
f"({off} + {size} > {len(raw)})")
raw[off:off + size] = bytes(raw[off:off + size]).translate(table)
dst.write_bytes(bytes(raw))
return count
def copy_idx(src: Path, dst: Path, magic: bytes) -> None:
"""Индекс переносим как есть — но убеждаемся, что он наш."""
head = src.read_bytes()[:4]
if head != magic:
raise SystemExit(f"{src}: магия {head!r}, ожидалась {magic!r}")
shutil.copyfile(src, dst)
def peak(data: bytes) -> int:
"""Максимальное отклонение от тишины — для отчёта «стало тише»."""
return max(abs(b - SILENCE) for b in data) if data else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--gain", type=float, default=0.5,
help="множитель амплитуды (по умолчанию 0.5 — вдвое тише)")
ap.add_argument("--src", type=Path, default=paths.PACKED,
help="откуда брать готовые наборы (умолчание assets/packed)")
ap.add_argument("--out", type=Path, default=paths.DIST,
help="куда класть (умолчание dist/)")
args = ap.parse_args()
table = gain_table(args.gain)
src_snd, src_mus = args.src / "SND", args.src / "MUS"
out_snd, out_mus = args.out / "SND", args.out / "MUS"
if not src_snd.is_dir() or not src_mus.is_dir():
raise SystemExit(f"{args.src}: нет SND/ или MUS/ — сначала "
"`make resources`")
out_snd.mkdir(parents=True, exist_ok=True)
out_mus.mkdir(parents=True, exist_ok=True)
print(f"тише в {1 / args.gain:g} раз(а): {args.src} -> {args.out}")
arc = src_snd / "snd.arc"
before = peak(arc.read_bytes()[ARC_HEADER:])
n = quiet_arc(arc, out_snd / "snd.arc", table)
after = peak((out_snd / "snd.arc").read_bytes()[ARC_HEADER:])
copy_idx(src_snd / "snd.idx", out_snd / "snd.idx", SND_IDX_MAGIC)
print(f" SND/snd.arc {n} записей, пик {before} -> {after}")
tracks = sorted(src_mus.glob("m*.bin"))
if not tracks:
raise SystemExit(f"{src_mus}: нет ни одного m*.bin")
total = 0
worst_before = worst_after = 0
for t in tracks:
total += quiet_raw(t, out_mus / t.name, table)
worst_before = max(worst_before, peak(t.read_bytes()))
worst_after = max(worst_after, peak((out_mus / t.name).read_bytes()))
copy_idx(src_mus / "mus.idx", out_mus / "mus.idx", MUS_IDX_MAGIC)
print(f" MUS/ {len(tracks)} треков, {total // 1024} КБ, "
f"пик {worst_before} -> {worst_after}")
print("индексы (snd.idx, mus.idx) скопированы без изменений — длины те же")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,108 @@
"""test_pop_quiet — правило громкости и неприкосновенность служебных байт.
Скрипт трогает данные, которые потом играет насос на Z80, поэтому тест
закрепляет ровно две вещи: формулу (она обязана совпадать с беззнаковым
`A/2 + 0x40`, чтобы результат можно было сверять с железной реализацией) и
то, что заголовок архива с выравнивающим хвостом остаются как были.
Запуск: make test-tools (либо python3 -m unittest discover tools/tests)
"""
import os
import struct
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pop_quiet as X
class Formula(unittest.TestCase):
"""Таблица громкости: то же, что сделал бы Z80."""
def test_half_is_shift_plus_40(self):
t = X.gain_table(0.5)
for v in range(256):
self.assertEqual(t[v], (v >> 1) + 0x40, f"байт {v:#04x}")
def test_silence_stays_silence(self):
for gain in (0.5, 0.25, 0.1, 1.0):
self.assertEqual(X.gain_table(gain)[X.SILENCE], X.SILENCE)
def test_no_clipping_and_range_shrinks(self):
t = X.gain_table(0.5)
self.assertEqual(t[0x00], 0x40)
self.assertEqual(t[0xFF], 0xBF)
for v in range(256):
self.assertLessEqual(abs(t[v] - X.SILENCE), abs(v - X.SILENCE))
def test_gain_one_is_identity(self):
self.assertEqual(X.gain_table(1.0), bytes(range(256)))
def test_gain_out_of_range_rejected(self):
for bad in (-0.1, 1.5):
with self.assertRaises(SystemExit):
X.gain_table(bad)
def make_arc(bodies):
"""Собрать PBA1 ровно так, как это делает tools/pop_pack_arc.py."""
raw = bytearray(X.ARC_HEADER)
raw[0:4] = X.ARC_MAGIC
raw[4] = len(bodies)
blob = bytearray()
for i, body in enumerate(bodies):
off = X.ARC_HEADER + len(blob)
struct.pack_into("<HH", raw, 8 + i * 4, off // X.ARC_SECTOR, len(body))
blob += body
blob += bytes(-len(blob) % X.ARC_SECTOR) # хвост выравнивания — нули
return bytes(raw + blob)
class Archive(unittest.TestCase):
"""PBA1: тела пересчитаны, служебные байты — нет."""
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.bodies = [bytes([0x80, 0xFF, 0x00, 0x81, 0x7F]), bytes([0xC0] * 3)]
self.src = self.dir / "in.arc"
self.dst = self.dir / "out.arc"
self.src.write_bytes(make_arc(self.bodies))
def test_bodies_scaled(self):
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
raw = self.dst.read_bytes()
off = X.ARC_HEADER
got = raw[off:off + len(self.bodies[0])]
self.assertEqual(got, bytes([0x80, 0xBF, 0x40, 0x80, 0x7F]))
def test_header_and_padding_untouched(self):
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
src, dst = self.src.read_bytes(), self.dst.read_bytes()
self.assertEqual(len(src), len(dst))
self.assertEqual(src[:X.ARC_HEADER], dst[:X.ARC_HEADER])
pad_at = X.ARC_HEADER + len(self.bodies[0])
pad_len = -len(self.bodies[0]) % X.ARC_SECTOR
self.assertEqual(dst[pad_at:pad_at + pad_len], bytes(pad_len))
def test_foreign_magic_rejected(self):
self.src.write_bytes(b"NOPE" + bytes(X.ARC_HEADER))
with self.assertRaises(SystemExit):
X.quiet_arc(self.src, self.dst, X.gain_table(0.5))
class Track(unittest.TestCase):
"""Музыкальный трек — чистый PCM, пересчитывается целиком."""
def test_whole_file(self):
d = Path(tempfile.mkdtemp())
src, dst = d / "m54.bin", d / "out.bin"
src.write_bytes(bytes([0x00, 0x80, 0xFF] * 4))
n = X.quiet_raw(src, dst, X.gain_table(0.5))
self.assertEqual(n, 12)
self.assertEqual(dst.read_bytes(), bytes([0x40, 0x80, 0xBF] * 4))
if __name__ == "__main__":
unittest.main()
+24 -5
View File
@@ -33,9 +33,16 @@
# --data-loc 0xN override SDCC --data-loc (default: derived from --memory)
# -Wl FLAG extra linker flag (repeatable)
# --bank N=FILE.c compile FILE.c as bank N; repeatable; pulls crt0_banked
# --bank-data put bank modules' writable data INTO the bank page
# --bank-data [N] put bank modules' writable data INTO the bank page
# (default: it goes to the shared _DATA in W1/W2, which
# stays mapped and is visible from everywhere)
# stays mapped and is visible from everywhere).
# Without an argument — for ALL banks (old behaviour).
# With a bank number — only for that bank; repeatable:
# --bank-data 5 --bank-data 6
# Per-bank matters because bank-local data is NOT visible
# from outside the bank: a module with an exported global
# must stay on the shared _DATA while its neighbour keeps
# a big private buffer off the resident budget.
# automatically and adds -Wl-b_BANKN=0x{N}C000
# --w3 FILE.c place FILE.c resident in window 3 (0xC000), called
# DIRECTLY (no trampoline). Repeatable. Defaults to
@@ -111,7 +118,8 @@ MKEXE_EXTRA=()
BANK_SPECS=() # entries like "1=engine.c"
W3_SPECS=() # entries like "mod.c" — резидентные модули окна W3 (--w3)
USER_DEFS=() # -DFOO / -DFOO=bar — пробрасываются в sdcc как есть
BANK_LOCAL_DATA=0 # 1 (--bank-data): писучие данные банков — В СТРАНИЦУ банка
BANK_LOCAL_DATA=0 # 1 (--bank-data без аргумента): данные ВСЕХ банков — в страницу
BANK_LOCAL_LIST=() # номера банков из --bank-data N (по одному на банк)
W3_RELS=() # заполняется при компиляции W3-модулей
W3_LD_FLAGS=() # -Wl-b_W3CODE=0xC000, если есть --w3
USER_SET_MEMORY="" # непусто, если --memory задан явно (для --w3 авто-small)
@@ -147,7 +155,14 @@ while [[ $# -gt 0 ]]; do
-Wl) LD_EXTRA+=("$2"); shift 2;;
-D*) USER_DEFS+=("$1"); shift;;
--bank) BANK_SPECS+=("$2"); shift 2;;
--bank-data) BANK_LOCAL_DATA=1; shift;;
--bank-data)
# Аргумент необязателен: «--bank-data» = все банки (как было),
# «--bank-data N» = только банк N, флаг повторяемый.
if [[ -n "${2:-}" && "$2" =~ ^[0-9]+$ ]]; then
BANK_LOCAL_LIST+=("$2"); shift 2
else
BANK_LOCAL_DATA=1; shift
fi;;
--mkexe) MKEXE_EXTRA+=("$2"); shift 2;;
--max-allocs) MAX_ALLOCS="$2"; shift 2;;
--gfx) GFX_MODE="$2"; shift 2;;
@@ -464,7 +479,11 @@ if [[ "$CRT0_TYPE" == "banked" ]]; then
# W1/W2, но требует ручного контроля видимости, поэтому
# включается явно через --bank-data.
bank_data_flags=()
[[ $BANK_LOCAL_DATA -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
bank_local=$BANK_LOCAL_DATA
for bl in "${BANK_LOCAL_LIST[@]}"; do
[[ "$bl" == "$bank_n" ]] && bank_local=1
done
[[ $bank_local -eq 1 ]] && bank_data_flags=(--dataseg "BANK${bank_n}")
run "$SDCC" "${CC_FLAGS[@]}" \
--codeseg "BANK${bank_n}" --constseg "BANK${bank_n}" \
"${bank_data_flags[@]}" \
+46 -46
View File
@@ -1,67 +1,67 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3783
accfill 3797
accop 7067
argv 3431
assrtest 3847
atlas 9193
attrprob 4087
banked 1056
bankedbg 1067
banklocl 4683
banktest 3767
bgi_img 8677
bgitest 3748
bios_text 4461
argv 3445
assrtest 3861
atlas 9627
attrprob 4101
banked 1070
bankedbg 1081
banklocl 4697
banktest 3781
bgi_img 8691
bgitest 3762
bios_text 4475
blitperf 5860
blitw 4939
cat 913
cblstream 5862
cbltest 6008
cblwav 6101
conio 4605
conio2 3929
cat 927
cblstream 5944
cbltest 6090
cblwav 6183
conio 4619
conio2 3943
convbench 3508
dec_test 860
errno 5966
fbench 8085
fdmax 6023
filetest 10547
dec_test 874
errno 5980
fbench 8149
fdmax 6087
filetest 10611
fpsdiv 4870
gets 509
gfx_dbuf 5142
gfx_demo 4114
gets 523
gfx_dbuf 5207
gfx_demo 4128
gfxbanks 6044
hello 4167
hello2 4244
irqtest 6690
hello 4181
hello2 4258
irqtest 6754
kbdpoll 1282
kbdraw 4911
ls 4835
malloc 4461
mem_test 4555
mouse 4382
openenv 6124
ls 4849
malloc 4475
mem_test 4569
mouse 4396
openenv 6138
pageflip 6334
palfile 5406
ptime 5744
rt_test 5197
ptime 5758
rt_test 5216
scroll 6744
seek 4188
simple 955
solidt 11531
seek 4202
simple 969
solidt 11595
spranim 9431
spriteclip 4119
sprites 6791
stattest 7549
stdlib 6643
stest2 3644
strtest 1340
text_palette 5024
timedir 5454
stattest 7563
stdlib 6657
stest2 3658
strtest 1354
text_palette 5038
timedir 5468
w0page 8831
w3bgfx 5243
w3big 3673
w3huge 3700
w3probe 3556
w3tiny 3471
winrest 4459
winrest 4473
1 # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
2 accfill
3 accop
4 argv
5 assrtest
6 atlas
7 attrprob
8 banked
9 bankedbg
10 banklocl
11 banktest
12 bgi_img
13 bgitest
14 bios_text
15 blitperf
16 blitw
17 cat
18 cblstream
19 cbltest
20 cblwav
21 conio
22 conio2
23 convbench
24 dec_test
25 errno
26 fbench
27 fdmax
28 filetest
29 fpsdiv
30 gets
31 gfx_dbuf
32 gfx_demo
33 gfxbanks
34 hello
35 hello2
36 irqtest
37 kbdpoll
38 kbdraw
39 ls
40 malloc
41 mem_test
42 mouse
43 openenv
44 pageflip
45 palfile
46 ptime
47 rt_test
48 scroll
49 seek
50 simple
51 solidt
52 spranim
53 spriteclip
54 sprites
55 stattest
56 stdlib
57 stest2
58 strtest
59 text_palette
60 timedir
61 w0page
62 w3bgfx
63 w3big
64 w3huge
65 w3probe
66 w3tiny
67 winrest
+6 -1
View File
@@ -61,7 +61,12 @@ int _cbl_open_raw(uint8_t freq_code, uint8_t fmt, uint8_t pump_mode,
static uint8_t atexit_armed;
if (!atexit_armed) {
atexit_armed = 1;
atexit(cbl_close); /* CBL обязан умолкнуть до шелла */
/* ИМЕННО shutdown, а не close: close оставляет bit7 включённым,
* пока его держит gfx_wait_vsync, и железо крутит буфер уже под
* шеллом. Подстраховка неполная — цепочку atexit разматывает
* только exit(), а crt0 при возврате из main уходит прямо в ESTEX
* EXIT, — поэтому программа обязана звать cbl_shutdown() сама. */
atexit(cbl_shutdown); /* CBL обязан умолкнуть до шелла */
}
IRQ_DISABLE();
+38
View File
@@ -0,0 +1,38 @@
/*
* cbl_shutdown — ЖЁСТКО погасить звук перед возвратом в шелл.
*
* ЧЕМ ОТЛИЧАЕТСЯ ОТ cbl_close(). Тот закрывает СЕССИЮ: снимает насос и
* пересчитывает порт по текущим держателям. А держатель обычно есть —
* gfx_wait_vsync() занимает bit7 навсегда ради бита луча (см. _cbl_port.c),
* — и порт остаётся включённым в "немом" режиме. Для работающей программы
* это правильно, но при выходе означает, что железо продолжает крутить свои
* 256 слотов уже под шеллом: тихо ровно до первой чужой записи в порт
* данных, а дальше эта запись зацикливается навсегда.
*
* ПОЧЕМУ НЕЛЬЗЯ ПОЛОЖИТЬСЯ НА atexit. Цепочку atexit разматывает только
* exit() (libc/sys/exit.c), а crt0 при ВОЗВРАТЕ ИЗ main уходит прямо в
* ESTEX EXIT (runtime/crt0_banked.s) — ни один хендлер не выполняется.
* Значит и снятие vsync-ссылки, и cbl_close из atexit при обычном
* `return 0;` из main не происходят. Поэтому выключение должно быть
* ЯВНЫМ действием программы, а не следствием чужой уборки.
*
* Что делает: закрывает сессию (если была), обнуляет счётчик держателей —
* программа уходит, держать порт больше некому, — гасит bit7 и центрует
* ЦАП обычного COVOX (при выключенном CBL записи в порт данных идут прямо
* в него, см. _cbl_prime.c), чтобы вместо щелчка осталась ровная тишина.
*
* Идемпотентно; звать последним действием завершения.
*/
#include "_cbl.h"
#include "../irq/_irq.h" /* IRQ_DISABLE/IRQ_ENABLE */
void cbl_shutdown(void)
{
cbl_close(); /* снять насос и отпустить IM2-таблицу */
IRQ_DISABLE();
_cbl_port_refs = 0; /* держателей больше нет: программа уходит */
_cbl_ctrl(0); /* bit7 = 0 — CBL выключен целиком */
_cbl_prime(0x80); /* ...и ЦАП COVOX в центре, без щелчка */
IRQ_ENABLE();
}
+13 -3
View File
@@ -55,9 +55,14 @@
* cbl_underruns() считает недоливы в ОБОИХ режимах — это диагностика,
* поведение не меняет.
*
* cbl_close() обязателен (atexit подстрахует) — иначе CBL продолжит
* прерывать шелл после выхода; заодно освобождает буфер тишины, если
* он был аллоцирован.
* cbl_close() обязателен — иначе CBL продолжит прерывать шелл после
* выхода. НО ОН НЕ ГАСИТ ЖЕЛЕЗО ПОЛНОСТЬЮ: пока bit7 держит
* gfx_wait_vsync() ради бита луча, порт остаётся включённым в "немом"
* режиме, и буфер крутится под шеллом. Перед возвратом из main звать
* cbl_shutdown() — он выключает всё и центрует ЦАП.
*
* На atexit тут полагаться НЕЛЬЗЯ: цепочку разматывает только exit(), а
* crt0 при возврате из main уходит прямо в ESTEX EXIT.
*
* Требования как у <irq.h>: код/данные в W2 (tiny/big).
*/
@@ -67,6 +72,11 @@
#include <stdint.h>
/* Выключить звук ПОЛНОСТЬЮ перед завершением программы: закрыть сессию,
* снять bit7 независимо от держателей и оставить ЦАП в центре. Звать
* последним действием завершения; идемпотентно. */
void cbl_shutdown(void);
/* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума
* Sprinter Team; коды 0/1 — legacy, не использовать). */
#define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */
+7
View File
@@ -10,4 +10,11 @@
extern void (*_atexit_stack[ATEXIT_MAX])(void);
extern int _atexit_top;
/* Разматыватель цепочки (LIFO) — общее тело exit() и возврата из main. */
void _atexit_run(void);
/* Указатель на него для crt0; ставит atexit() при первой регистрации.
* Подробности и мотивация косвенности — в шапке _atexit_hook.c. */
extern void (*_atexit_hook)(void);
#endif
+18
View File
@@ -0,0 +1,18 @@
/*
* _atexit_hook — указатель на разматыватель цепочки atexit; его дёргает
* crt0 при ВОЗВРАТЕ ИЗ main.
*
* ЗАЧЕМ КОСВЕННОСТЬ. По стандарту C возврат из main эквивалентен
* exit(status), то есть цепочка atexit обязана размотаться. Но прямая
* ссылка crt0 на разматыватель притащила бы в КАЖДУЮ программу и его, и
* весь стек хендлеров — включая те, что atexit() не зовут вовсе. Поэтому
* crt0 ссылается на этот модуль (два байта _DATA, ни байта кода), а
* заполняет указатель сам atexit() при первой регистрации: нет
* регистраций — указатель нулевой, разматыватель в программу не приезжает,
* crt0 просто уходит в ESTEX EXIT как раньше. Тот же приём, что у
* _irq_cbl_hook в libc/irq.
*/
#include "_atexit.h"
void (*_atexit_hook)(void);
+22
View File
@@ -0,0 +1,22 @@
/*
* _atexit_run — размотать цепочку atexit-хендлеров (LIFO).
*
* Общее тело для двух входов: явного exit() и возврата из main (там его
* зовёт crt0 через _atexit_hook). Отдельным модулем — чтобы программа,
* не зарегистрировавшая ни одного хендлера, не получала этот код вовсе.
*
* Вершина сбрасывается ПЕРЕД вызовом каждого хендлера: если хендлер сам
* позовёт exit(), цепочка не пойдёт по второму кругу.
*/
#include "_atexit.h"
void _atexit_run(void)
{
while (_atexit_top > 0) {
void (*fn)(void) = _atexit_stack[--_atexit_top];
if (fn) {
fn();
}
}
}
+8 -3
View File
@@ -3,9 +3,11 @@
* завершении (exit()), максимум 8, порядок LIFO. Возвращает 0 или -1
* при переполнении стека хендлеров.
*
* Внимание: возврат из main() без явного exit() уходит в inline RST
* 10h #41 в crt0.s и цепочку atexit НЕ выполняет — программам с
* хендлерами нужно завершаться через exit().
* ВОЗВРАТ ИЗ main ТОЖЕ РАЗМАТЫВАЕТ ЦЕПОЧКУ (с 2026-09-02), как и требует
* стандарт C. Раньше crt0 уходил из main прямо в ESTEX EXIT, и хендлеры
* не выполнялись: программа теряла и сброс файловых буферов (_fclosall), и
* гашение звука, если не звала exit() явно. Теперь первая регистрация
* ставит _atexit_hook, а crt0 его дёргает — см. _atexit_hook.c.
*/
#include <stdlib.h>
@@ -17,5 +19,8 @@ int atexit(void (*fn)(void))
return -1;
}
_atexit_stack[_atexit_top++] = fn;
/* Хендлеры появились — значит crt0 обязан размотать цепочку и при
* простом `return` из main. Ставим каждый раз: проверка дороже. */
_atexit_hook = _atexit_run;
return 0;
}
+1 -6
View File
@@ -9,11 +9,6 @@
void exit(int code)
{
while (_atexit_top > 0) {
void (*fn)(void) = _atexit_stack[--_atexit_top];
if (fn) {
fn();
}
}
_atexit_run();
_exit(code);
}
+17
View File
@@ -22,6 +22,7 @@
.module crt0
.globl _main
.globl __atexit_hook
;; Linker-emitted symbols (resolved at link time).
.globl s__INITIALIZER
@@ -88,6 +89,22 @@ _start::
ld de, (_argv)
call _main
;; Returning from main is exit(status) per the C standard, so the
;; atexit chain must run here too. The call is INDIRECT on purpose:
;; atexit() fills __atexit_hook on its first registration, so programs
;; that never register anything keep it NULL and link neither the
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
push de ; DE = int return of main
ld hl, (__atexit_hook)
ld a, h
or a, l
jr Z, atexit_done
ld de, #atexit_done
push de ; return address: Z80 has no `call (hl)`
jp (hl)
atexit_done:
pop de
;; SDCC's int return → DE. Low byte is the exit code.
ld a, e
ld b, a
+17
View File
@@ -27,6 +27,7 @@
.module crt0_banked
.globl _main
.globl __atexit_hook
.globl _n_banks
.globl _bank_pages
@@ -218,6 +219,22 @@ skip_bank_load:
ld de, (_argv)
call _main
;; Returning from main is exit(status) per the C standard, so the
;; atexit chain must run here too. The call is INDIRECT on purpose:
;; atexit() fills __atexit_hook on its first registration, so programs
;; that never register anything keep it NULL and link neither the
;; unwinder nor the handler stack (see libc/sys/_atexit_hook.c).
push de ; DE = int return of main
ld hl, (__atexit_hook)
ld a, h
or a, l
jr Z, atexit_done
ld de, #atexit_done
push de ; return address: Z80 has no `call (hl)`
jp (hl)
atexit_done:
pop de
;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1).
ld a, e
jr exit_with_a
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
check_w0_isr.py — предупредить, что ISR-стаб W0-страниц оказался в W1.
Зачем. Программа может временно маппить свою EMM-страницу в W0 (атласы
спрайтов в sprite_update, ресурсы через gfx_w0_map). Пока страница там,
ЛЮБОЕ прерывание уходит на #0038 ЭТОЙ страницы, поэтому gfx_w0_page_prepare()
прошивает туда `JP _gfx_w0_isr`, а стаб возвращает страницу ядра DSS, зовёт
честный обработчик и восстанавливает страницу обратно.
Мина. Сам стаб живёт в _CODE. В режимах small и huge _CODE начинается с
0x4100, то есть попадает в W1 — окно, которое DSS перемаппит на время СВОИХ
вызовов (docs/new/04-memory.md §4.2, §4.9). Если прерывание придёт, пока
наша страница в W0 И идёт вызов DSS, переход уйдёт по адресу, которого в этот
момент нет: W1 читается как #FF, процессор уползает в бесконечный пролог
системного прерывания. Само libbgi это документирует в common/_gfx_w0_isr.c
(«обязан быть в W2»).
Собирается это МОЛЧА и проявляется НЕДЕТЕРМИНИРОВАННО — зависанием примерно
на каждом третьем холодном старте, что легко списать на случайность. Поймано
вживую 2026-09-01 в порте Loom (huge): стаб был на 0x6769.
Как проверяем. По .map берём адрес __gfx_w0_isr. Если символа нет — W0-путь
в программу не слинкован, молча выходим. Если адрес >= 0x8000 — он в W2, всё
хорошо. Иначе печатаем WARNING: сборку не валим, потому что программа могла
и не класть свои страницы в W0.
Лечение (со стороны приложения): вернуть модули из банков в резидент или
разгрузить _DATA так, чтобы _CODE перевалил за 0x8000 и хвост libbgi уехал в
W2. Порядок линковки сам по себе не помогает: пока весь _CODE влезает в W1
(ёмкость 0x8000-0x4100 = 16128 Б), расстановка модулей ничего не меняет.
"""
import re
import sys
from pathlib import Path
W2_BASE = 0x8000
SYM = '__gfx_w0_isr'
MAP_RE = re.compile(r'^\s+([0-9A-Fa-f]{4,8})\s+(\S+)\s')
def find_symbol(map_path, name):
for line in map_path.read_text(errors='replace').splitlines():
m = MAP_RE.match(line)
if m and m.group(2) == name:
return int(m.group(1), 16)
return None
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_w0_isr: нет .map в {build}', file=sys.stderr)
return 2
addr = find_symbol(maps[0], SYM)
if addr is None:
return 0 # W0-путь не слинкован — проверять нечего
if addr >= W2_BASE:
print(f'check_w0_isr: {SYM} @ 0x{addr:04X} — в W2, ок')
return 0
print(f'check_w0_isr: ВНИМАНИЕ — {SYM} @ 0x{addr:04X} лежит в W1.',
file=sys.stderr)
print(' Если программа маппит свои страницы в W0 (атласы спрайтов, '
'gfx_w0_map,', file=sys.stderr)
print(' atlas_attach), прерывание во время вызова DSS уйдёт в '
'перемапленное W1 —', file=sys.stderr)
print(' недетерминированные зависания на старте. Стаб обязан быть в W2 '
f'(>= 0x{W2_BASE:04X}).', file=sys.stderr)
print(' Лечение: увести _CODE за 0x8000 (модули из банков в резидент / '
'разгрузить', file=sys.stderr)
print(' _DATA). Подробности — в шапке этого скрипта и '
'docs/new/04-memory.md §4.2.', file=sys.stderr)
return 0 # предупреждение, сборку не валим
if __name__ == '__main__':
sys.exit(main(sys.argv))