Compare commits

...

4 Commits

Author SHA1 Message Date
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
18 changed files with 510 additions and 67 deletions
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -17,6 +17,7 @@
#include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */ #include <sprite.h> /* gfx_w0_map/unmap — доступ к странице индекса */
#include "pop_music.h" #include "pop_music.h"
#include "pop_sfx.h" #include "pop_sfx.h"
#include "_pop_sfx.h" /* pop_snd_ok — открыт ли вывод (см. pop_music_busy) */
#include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */ #include "pop_app.h" /* POP_APP_DEMO — в демке музыки нет */
#include "_pop_file.h" #include "_pop_file.h"
@@ -454,7 +455,15 @@ void pop_music_service(void) __banked
uint8_t pop_music_busy(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_ctrl.h" /* pop_ctrl_init/close — ввод поднимает и закрывает boot */
#include "pop_shadow.h" /* запечённый набор Тени */ #include "pop_shadow.h" /* запечённый набор Тени */
#include "pop_sfx.h" /* звуковые эффекты через CBL */ #include "pop_sfx.h" /* звуковые эффекты через CBL */
#include <cbl.h> /* cbl_shutdown — погасить железо звука на выходе */
#include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */ #include "pop_music.h" /* pop_music_busy/free — ждём трек заставки */
#include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */ #include "pop_vflip.h" /* сброс зеркальных страниц на смене уровня */
#include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */ #include "pop_cheat.h" /* pop_cheats — режим разработки включает pop_boot */
@@ -1205,14 +1206,23 @@ int pop_boot(void) __banked
* полсекунды, и лучше, чтобы она прошла под картинкой. */ * полсекунды, и лучше, чтобы она прошла под картинкой. */
int pop_boot_sound(void) __banked int pop_boot_sound(void) __banked
{ {
if (boot_sound_done) return 0; if (!boot_sound_done) {
boot_sound_done = 1; boot_sound_done = 1;
(void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */ (void)pop_sfx_init_begin(); /* эффектов может и не быть — не фатально */
/* Индекс музыкального набора — одна страница EMM. Без него музыки не /* Индекс музыкального набора — одна страница EMM. Без него музыки не
* будет (pop_music_info вернёт -1 на любой трек), но игра работает: * будет (pop_music_info вернёт -1 на любой трек), но игра работает:
* это необязательный ресурс, как и сами треки. */ * это необязательный ресурс, как и сами треки. */
(void)pop_music_index_load(); (void)pop_music_index_load();
pop_settings_apply(1); /* CFG -> speed/cheats/CBL */ 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(); (void)pop_sfx_start();
return 0; return 0;
} }
@@ -1481,4 +1491,14 @@ void pop_shutdown(void) __banked
pop_guard_free(); pop_guard_free();
pop_kid_data_free(); pop_kid_data_free();
pop_level_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" # промежуточные атласы, по набору в папке ATL = APP / "build" / "atl" # промежуточные атласы, по набору в папке
PACKED = APP / "assets" / "packed" # раскладка диска игры PACKED = APP / "assets" / "packed" # раскладка диска игры
GEN = APP / "gen" # генерируемые заголовки GEN = APP / "gen" # генерируемые заголовки
DIST = APP / "dist" # ручное содержимое дистрибутива (README и
# производные наборы, напр. тихий звук)
def atl(name): def atl(name):
@@ -139,3 +141,10 @@ def packed(name):
p = PACKED / name p = PACKED / name
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
return p 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()
+45 -45
View File
@@ -1,67 +1,67 @@
# Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update # Эталон размеров _CODE (байт); обновление: python3 toolchain/size_check.py --update
accfill 3783 accfill 3797
accop 7067 accop 7067
argv 3431 argv 3445
assrtest 3847 assrtest 3861
atlas 9193 atlas 9193
attrprob 4087 attrprob 4101
banked 1056 banked 1070
bankedbg 1067 bankedbg 1081
banklocl 4683 banklocl 4697
banktest 3767 banktest 3781
bgi_img 8677 bgi_img 8691
bgitest 3748 bgitest 3762
bios_text 4461 bios_text 4475
blitperf 5860 blitperf 5860
blitw 4939 blitw 4939
cat 913 cat 927
cblstream 5862 cblstream 5944
cbltest 6008 cbltest 6090
cblwav 6101 cblwav 6183
conio 4605 conio 4619
conio2 3929 conio2 3943
convbench 3508 convbench 3508
dec_test 860 dec_test 874
errno 5966 errno 5980
fbench 8085 fbench 8149
fdmax 6023 fdmax 6087
filetest 10547 filetest 10611
fpsdiv 4870 fpsdiv 4870
gets 509 gets 523
gfx_dbuf 5142 gfx_dbuf 5207
gfx_demo 4114 gfx_demo 4128
gfxbanks 6044 gfxbanks 6044
hello 4167 hello 4181
hello2 4244 hello2 4258
irqtest 6690 irqtest 6754
kbdpoll 1282 kbdpoll 1282
kbdraw 4911 kbdraw 4911
ls 4835 ls 4849
malloc 4461 malloc 4475
mem_test 4555 mem_test 4569
mouse 4382 mouse 4396
openenv 6124 openenv 6138
pageflip 6334 pageflip 6334
palfile 5406 palfile 5406
ptime 5744 ptime 5758
rt_test 5197 rt_test 5216
scroll 6744 scroll 6744
seek 4188 seek 4202
simple 955 simple 969
solidt 11531 solidt 11595
spranim 9431 spranim 9431
spriteclip 4119 spriteclip 4119
sprites 6791 sprites 6791
stattest 7549 stattest 7563
stdlib 6643 stdlib 6657
stest2 3644 stest2 3658
strtest 1340 strtest 1354
text_palette 5024 text_palette 5038
timedir 5454 timedir 5468
w0page 8831 w0page 8831
w3bgfx 5243 w3bgfx 5243
w3big 3673 w3big 3673
w3huge 3700 w3huge 3700
w3probe 3556 w3probe 3556
w3tiny 3471 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; static uint8_t atexit_armed;
if (!atexit_armed) { if (!atexit_armed) {
atexit_armed = 1; 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(); 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_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). * Требования как у <irq.h>: код/данные в W2 (tiny/big).
*/ */
@@ -67,6 +72,11 @@
#include <stdint.h> #include <stdint.h>
/* Выключить звук ПОЛНОСТЬЮ перед завершением программы: закрыть сессию,
* снять bit7 независимо от держателей и оставить ЦАП в центре. Звать
* последним действием завершения; идемпотентно. */
void cbl_shutdown(void);
/* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума /* Коды частоты дискретизации (bits 3..0 порта 0x4E; из форума
* Sprinter Team; коды 0/1 — legacy, не использовать). */ * Sprinter Team; коды 0/1 — legacy, не использовать). */
#define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */ #define CBL_FREQ_7K8 0x8 /* 7.8125 кГц */
+7
View File
@@ -10,4 +10,11 @@
extern void (*_atexit_stack[ATEXIT_MAX])(void); extern void (*_atexit_stack[ATEXIT_MAX])(void);
extern int _atexit_top; extern int _atexit_top;
/* Разматыватель цепочки (LIFO) — общее тело exit() и возврата из main. */
void _atexit_run(void);
/* Указатель на него для crt0; ставит atexit() при первой регистрации.
* Подробности и мотивация косвенности — в шапке _atexit_hook.c. */
extern void (*_atexit_hook)(void);
#endif #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 * завершении (exit()), максимум 8, порядок LIFO. Возвращает 0 или -1
* при переполнении стека хендлеров. * при переполнении стека хендлеров.
* *
* Внимание: возврат из main() без явного exit() уходит в inline RST * ВОЗВРАТ ИЗ main ТОЖЕ РАЗМАТЫВАЕТ ЦЕПОЧКУ (с 2026-09-02), как и требует
* 10h #41 в crt0.s и цепочку atexit НЕ выполняет — программам с * стандарт C. Раньше crt0 уходил из main прямо в ESTEX EXIT, и хендлеры
* хендлерами нужно завершаться через exit(). * не выполнялись: программа теряла и сброс файловых буферов (_fclosall), и
* гашение звука, если не звала exit() явно. Теперь первая регистрация
* ставит _atexit_hook, а crt0 его дёргает — см. _atexit_hook.c.
*/ */
#include <stdlib.h> #include <stdlib.h>
@@ -17,5 +19,8 @@ int atexit(void (*fn)(void))
return -1; return -1;
} }
_atexit_stack[_atexit_top++] = fn; _atexit_stack[_atexit_top++] = fn;
/* Хендлеры появились — значит crt0 обязан размотать цепочку и при
* простом `return` из main. Ставим каждый раз: проверка дороже. */
_atexit_hook = _atexit_run;
return 0; return 0;
} }
+1 -6
View File
@@ -9,11 +9,6 @@
void exit(int code) void exit(int code)
{ {
while (_atexit_top > 0) { _atexit_run();
void (*fn)(void) = _atexit_stack[--_atexit_top];
if (fn) {
fn();
}
}
_exit(code); _exit(code);
} }
+17
View File
@@ -22,6 +22,7 @@
.module crt0 .module crt0
.globl _main .globl _main
.globl __atexit_hook
;; Linker-emitted symbols (resolved at link time). ;; Linker-emitted symbols (resolved at link time).
.globl s__INITIALIZER .globl s__INITIALIZER
@@ -88,6 +89,22 @@ _start::
ld de, (_argv) ld de, (_argv)
call _main 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. ;; SDCC's int return → DE. Low byte is the exit code.
ld a, e ld a, e
ld b, a ld b, a
+17
View File
@@ -27,6 +27,7 @@
.module crt0_banked .module crt0_banked
.globl _main .globl _main
.globl __atexit_hook
.globl _n_banks .globl _n_banks
.globl _bank_pages .globl _bank_pages
@@ -218,6 +219,22 @@ skip_bank_load:
ld de, (_argv) ld de, (_argv)
call _main 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). ;; main returned: int return is in DE per SDCC 4.5 __sdcccall(1).
ld a, e ld a, e
jr exit_with_a jr exit_with_a