Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25abf8ea36 | |||
| f206a4cca6 | |||
| aaa480d0f2 | |||
| 8548aa9132 | |||
| 400f5cba63 | |||
| cb995bf0fc | |||
| 38fb3c03bb | |||
| 879f2bae31 | |||
| b3f9a7430c | |||
| fee3bdb354 | |||
| 6a97124e0d | |||
| 8610a8c178 | |||
| 81e8ed4676 | |||
| 8490288d79 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# SprPoP — CHANGELOG
|
||||
|
||||
Заметные изменения по релизам. **Версия, дата и коммит каждой записи
|
||||
берутся из git** — из аннотированного тега, а не проставляются руками,
|
||||
поэтому расходиться с историей они не могут:
|
||||
|
||||
git for-each-ref \
|
||||
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
|
||||
refs/tags/<тег>
|
||||
|
||||
Заголовок записи = `<имя тега> — <дата тега> — <коммит тега>`. Коммит —
|
||||
дереференс аннотированного тега (`*objectname`), то есть та ревизия, на
|
||||
которой релиз собран: по ней воспроизводится ровно этот билд.
|
||||
|
||||
Здесь только то, что видно ИГРОКУ или меняет способ сборки/запуска.
|
||||
Разбор причин живёт в сообщениях коммитов, открытые баги — в
|
||||
`docs/BUGS_OPEN.md`, закрытые — в архиве roomtest.
|
||||
|
||||
Файл начат с релиза v0.9.6; более ранняя история — в `git log` и в
|
||||
досках задач.
|
||||
|
||||
## v0.9.6-hang-sound-button — 2026-09-02 — `aaa480d`
|
||||
|
||||
Три бага, найденные в живой игре и проверенные в MAME после исправления.
|
||||
|
||||
- **Зацеп срывался на следующем кадре** (регресс, тянулся с 4e12aa5).
|
||||
Кид цеплялся за кромку, поза зацепа рисовалась — и он тут же падал.
|
||||
Ловилось на длинном прыжке уровня 3 (комната 7) и в attract-демо, где
|
||||
Кид вместо подъёма на кромку (0,2) улетал на пики. Причина: страж
|
||||
«падения сквозь стену» принимал кладку под кромкой за стену, в которую
|
||||
персонаж влетел, и отменял только что состоявшийся захват. Теперь
|
||||
удачный зацеп отменяет этого стража. `38fb3c0`
|
||||
|
||||
- **Непрерывный взмах клинка у безоружного Кида.** На подъёме шёл
|
||||
нескончаемый свист меча, которого у Кида нет. Причина: в порт не
|
||||
доехали два условия оригинала, включая гард, который автор SDLPoP
|
||||
подписал прямым текстом как «фикс зацикленного звука взмаха». `cb995bf`
|
||||
|
||||
- **Кнопка под Кидом заливалась кладкой** на дворцовых уровнях. Кид
|
||||
подтягивается на открывающую кнопку, и кромка, которая его прячет,
|
||||
нарисована кирпичом (уровень 4, комната 18). Причина: подстановка
|
||||
спрайта «кнопка без пола слева» действует только в подземелье, и это
|
||||
условие было потеряно в перерисовке поверх персонажа — при том что в
|
||||
статической отрисовке комнаты оно есть. `8548aa9`
|
||||
|
||||
Сопутствующее, для разработки:
|
||||
|
||||
- В доске исправлено толкование звука 11: это индикатор ПРОМАХА, а не
|
||||
«любого укола». Прежняя запись описывала наш собственный код вместо
|
||||
оригинала, и из-за неё в CLIMB-VS-GUARD зря отодвинули версию про порог
|
||||
дистанции. `400f5cb`
|
||||
- Новый набор host-тестов `t_hang`: мерит не «вис наступил», а «вис
|
||||
ДЕРЖИТСЯ» — прежние наборы регресс зацепа не видели именно поэтому.
|
||||
Входит в `38fb3c0`.
|
||||
- В `CLAUDE.md` записан путь запуска на HDD-образе: игра лежит в
|
||||
`D:\GAMES\SPRPOP`, а не в корне диска. `aaa480d`
|
||||
@@ -60,6 +60,24 @@ seqtbl, `seg007` mob/loose/падающие объекты, `seg008` отрис
|
||||
нового бага грепни архив по симптому: корень мог уже разбираться.
|
||||
Открытые файлы обязаны читаться целиком за раз.
|
||||
|
||||
**CHANGELOG. Ставишь релизный тег — добавь запись в `CHANGELOG.md`.**
|
||||
Версия, дата И КОММИТ в заголовке берутся ИЗ GIT, руками не
|
||||
проставляются:
|
||||
|
||||
git for-each-ref \
|
||||
--format='%(refname:short) | %(creatordate:short) | %(*objectname:short)' \
|
||||
refs/tags/<тег>
|
||||
|
||||
Заголовок = `<имя тега> — <дата> — <коммит>`. Коммит обязателен: тег
|
||||
можно передвинуть или переименовать, а ревизия, на которой релиз собран,
|
||||
должна оставаться в записи однозначно. Берётся именно `*objectname`
|
||||
(дереференс аннотированного тега), а не `objectname` — последний вернёт
|
||||
хеш самого объекта тега, а не коммита.
|
||||
|
||||
В запись идёт только то, что видно ИГРОКУ или меняет сборку/запуск, — по
|
||||
одному пункту «симптом -> причина одной фразой -> хеш коммита». Полный
|
||||
разбор живёт в сообщении коммита, дублировать его сюда не надо.
|
||||
|
||||
Индекс документации с отметками актуальности — **`docs/README.md`**,
|
||||
начинать чтение оттуда.
|
||||
|
||||
@@ -133,9 +151,15 @@ Makefile склеивает архивы прямо в `assets/packed/<КАТА
|
||||
(memory `mame_autotest`, `mame_mcp_bridge`, `mame_hdd_test_disk`).
|
||||
Пересобрал образ → MAME ОБЯЗАН полный рестарт (`mame_hdd_rebuild_restart`).
|
||||
|
||||
**Игра лежит на образе в `D:\GAMES\SPRPOP\`**, а НЕ в корне диска (так с
|
||||
обобщения HDD-сборки, коммит ea8efdb — один образ рассчитан на несколько
|
||||
приложений). `dir D:\` показывает только каталог `GAMES`; запуск из корня
|
||||
отвечает `Bad command or file name`.
|
||||
|
||||
**Тайминги моста** (не ждать дольше, см. `docs/mame-autotest.md` §10):
|
||||
старт `run_bridge.sh` → 6 с → `go` → 8 с → `keyseq d:{ENTER}` +
|
||||
`keyseq sprpop{ENTER}` → 5 с → программа работает.
|
||||
`keyseq cd games\sprpop{ENTER}` + `keyseq sprpop{ENTER}` → 5 с →
|
||||
программа работает.
|
||||
|
||||
Отладочные тумблеры в живой сессии (`src/sprpop.c`): **1** — заморозить
|
||||
кадр, **2** — продолжить (разбор позы/окклюзии); **ESC** — выход. Читы
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -38,7 +38,7 @@
|
||||
| [SND-PACE-DEAD](#snd-pace-dead) | пейсинг сцен по насосу CBL не включается: признак «часы идут» вычисляется двумя чтениями подряд | **тайминг/звук** | **снят 2026-08-28**: ветка удалена, сцена на единых часах по лучу |
|
||||
| [PV-RENDER-BOUND](#pv-render-bound) | сцена с принцессой рисуется дороже бюджета: ~49 тиков/с вместо 60, музыка уезжает от картинки | производительность | **исправлено 2026-08-28**: кадр разложен на блоки по интервалу, удешевлять не понадобилось |
|
||||
| [MUS-LEFT-TEAR](#mus-left-tear) | `pop_mus_left` (16 бит, пишет прерывание) читается из главного цикла неатомарно — возможен ложный «трек кончился» | **потенциальный** | открыт: хазард показан рассуждением, в прогоне не проявился |
|
||||
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные (2026-08-31) |
|
||||
| [CLIMB-VS-GUARD](#climb-vs-guard) | Кид подтягивается к стражу этажом выше: у нас удар засчитывается и убивает, в оригинале Кид просто срывается без урона; страж при этом способен провалиться сквозь пол вслед за Кидом | бой/физика | открыт: цепочка удара сверена — совпадает, расходятся входные данные; толкование звука ИСПРАВЛЕНО 2026-09-02 (звук 11 = промах, а не «любой укол») — главный подозреваемый снова ПОРОГ ДИСТАНЦИИ |
|
||||
| [HP-BAR-RESTART](#hp-bar-restart) | после гибели и Ctrl+A на ОДНОЙ из двух страниц остаётся полоса HP по результатам боя | дабл-буфер/UI | КОРЕНЬ НАЙДЕН, фикс есть, ждёт проверки (2026-08-31) |
|
||||
|
||||
---
|
||||
@@ -1374,23 +1374,63 @@ uint16_t pop_music_left(void) { uint16_t a, b;
|
||||
В той же сцене оригинал играет ВЗМАХ (звук 11, «клинок движется»), а у нас
|
||||
слышен звук, похожий на упор Кида в стену (звук 8, `bumped`).
|
||||
|
||||
Почему это важно. Звук 11 оригинал играет в `check_hurting` СРАЗУ на
|
||||
кадре укола — ДО проверки расстояния и до отметки «ранен». То есть он
|
||||
звучит при ЛЮБОМ уколе, попал тот или нет. Значит его отсутствие
|
||||
означает не «промахнулись», а «до укола дело вообще не дошло»: страж не в
|
||||
кадре укола, либо разбор вышел раньше (меч не вынут / ряды не совпали).
|
||||
А звучащий вместо него упор в стену говорит, что у нас сработало
|
||||
СТОЛКНОВЕНИЕ, а не атака.
|
||||
|
||||
Набор звуков проверен и НЕ виноват: в `assets/packed/SND/snd.idx` слот 11
|
||||
на месте и содержит собственный короткий сэмпл (1280 Б), слот 8 — другой
|
||||
(1664 Б). Раскладка не сдвинута.
|
||||
|
||||
Отсюда рабочая версия: в этой связке оригинал ведёт стража по ветке
|
||||
«атака с промахом», а мы — по ветке «столкновение с персонажем»
|
||||
(`bump_into_opponent` / `check_bumped`). Это же объясняет и провалившегося
|
||||
сквозь пол стража: столкновение двигает его координату, а не атака.
|
||||
Проверять надо ветку выбора действия стража, а не только дистанцию.
|
||||
**ПОПРАВКА 2026-09-02 — прежнее толкование звука было ОШИБОЧНЫМ.**
|
||||
|
||||
Здесь стояло, будто оригинал играет звук 11 «сразу на кадре укола, ДО
|
||||
проверки расстояния», то есть при ЛЮБОМ уколе, попал тот или нет. Это
|
||||
описание НАШЕГО кода, а не оригинала: ровно так звук стоял у нас, внутри
|
||||
ветки «не парировано» и до присвоения `Opp.action = 99_hurt`. В SDLPoP
|
||||
(seg002:1039..1044) он стоит в ХВОСТЕ `check_hurting` и защищён условием
|
||||
`Opp.action != actions_99_hurt`:
|
||||
|
||||
if (Char.direction == dir_56_none) return; // Fix looping "sword moving" sound.
|
||||
if (Char.frame == frame_154_poking && Opp.frame != frame_161_parry &&
|
||||
Opp.action != actions_99_hurt)
|
||||
play_sound(sound_11_sword_moving);
|
||||
|
||||
То есть звук 11 в оригинале — это индикатор **ПРОМАХА**, а не укола: на
|
||||
попадании его нет (играет боль), на парировании нет (`Opp.frame` уже 161).
|
||||
Расхождение найдено и исправлено 2026-09-02 по симптому «непрерывный взмах
|
||||
клинка у безоружного Кида на подъёме» (звук опознан в живой сессии по
|
||||
`snd_curr == 11`; указатели насоса при этом сошлись с `snd.idx` байт в
|
||||
байт, то есть тракт звука был исправен). Заодно приехал и пропущенный
|
||||
ранний выход по `dir_56_none` — тот самый, что автор SDLPoP подписал
|
||||
«Fix looping sword moving sound».
|
||||
|
||||
**Что из прежних выводов уцелело, а что нет.**
|
||||
|
||||
* УЦЕЛЕЛО: наблюдение «в оригинале слышен 11, у нас 8» — это данные, и они
|
||||
остаются. Арифметика приоритетов их подкрепляет: `snd_prio[11] = 0x12`
|
||||
(18) против `snd_prio[8] = 0x4B` (75), меньше значит важнее, поэтому
|
||||
взмах не мог быть заглушён упором в стену. При ТОГДАШНЕМ коде звук 11
|
||||
звучал на любом уколе, значит его отсутствие действительно означало, что
|
||||
до кадра укола дело не дошло.
|
||||
* ОТПАЛО: вывод «оригинал ведёт стража атакой с промахом, а мы —
|
||||
столкновением» опирался на неверную посылку и больше не следует из звука
|
||||
сам по себе.
|
||||
* УСИЛИЛОСЬ: то, что в оригинале в этой сцене слышен ИМЕННО 11, теперь
|
||||
доказывает, что укол у стража СОСТОЯЛСЯ и ПРОМАЗАЛ. А это ровно версия
|
||||
про ПОРОГ ДИСТАНЦИИ (см. выше про пару пикселей и нижний порог 8), а не
|
||||
про потерянную ветку. Прежняя «версия про столкновение» её вытеснила
|
||||
зря — возвращаем дистанцию в главные подозреваемые.
|
||||
|
||||
**ВНИМАНИЕ тому, кто вернётся к этому багу: диагностика по звуку с
|
||||
2026-09-02 ЗНАЧИТ ДРУГОЕ.** Старые заметки прогона будут вводить в
|
||||
заблуждение — переснимать сцену заново. Новая таблица:
|
||||
|
||||
| слышно | что это значит |
|
||||
|---|---|
|
||||
| 11 (взмах) | укол состоялся и ПРОМАЗАЛ — поведение оригинала в этой сцене |
|
||||
| 13 (боль Кида) | укол ПОПАЛ, `Opp.action = 99_hurt` |
|
||||
| 8 (упор в стену) | укола не было вовсе, сработало столкновение |
|
||||
| тишина | `check_hurting` вышел раньше: меч не вынут / ряды не совпали / кадр не 153-154 |
|
||||
|
||||
На саму механику урона правка 2026-09-02 НЕ влияет (тронут только звук),
|
||||
поэтому расхождение «удар убивает» остаётся открытым как было.
|
||||
|
||||
**Решение пользователя:** отложено на будущее (2026-08-31) — поведение в
|
||||
этой связке расходится широко, чинить нужно целиком, а не по одному
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|----------|-------|
|
||||
| [`TASKS_OPEN.md`](TASKS_OPEN.md) | **Что берётся в работу сейчас** — начинать отсюда |
|
||||
| [`BUGS_OPEN.md`](BUGS_OPEN.md) | Открытые баги и незакрытые оптимизации |
|
||||
| [`../CHANGELOG.md`](../CHANGELOG.md) | **Что менялось по релизам** глазами игрока; версия и дата берутся из git-тега |
|
||||
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде |
|
||||
| [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP |
|
||||
| [`keys_plan.md`](keys_plan.md) | План перевода на неё: что совпадает, что переназначить, каких функций нет вовсе |
|
||||
|
||||
@@ -1102,8 +1102,6 @@ static void check_hurting(void)
|
||||
(of != FRAME_161_PARRY && of != FRAME_150_PARRY)) {
|
||||
/* Соперник НЕ парирует. */
|
||||
if (cf == FRAME_154_POKING) {
|
||||
/* seg002:0DAE — свист клинка мимо цели. */
|
||||
pop_sfx_play(11);
|
||||
min_range = (uint8_t)(Opp.sword < SWORD_2_DRAWN ? 8 : 12);
|
||||
distance = pop_char_opp_dist();
|
||||
if (distance >= (int16_t)min_range && distance < 29)
|
||||
@@ -1116,6 +1114,29 @@ static void check_hurting(void)
|
||||
pop_char_set_seq(SEQ_69_ATTACK_WAS_PARRIED);
|
||||
play_seq();
|
||||
}
|
||||
|
||||
/* СВИСТ КЛИНКА МИМО ЦЕЛИ (seg002:1039..1044) — в оригинале он стоит в
|
||||
* ХВОСТЕ функции, после ОБЕИХ веток, а не внутри ветки «не парировано».
|
||||
* Мы его туда и переносим; раньше он звучал раньше времени и без двух
|
||||
* условий оригинала, отчего на подъёме Кида шёл непрерывный взмах
|
||||
* клинка у безоружного (наблюдение пользователя 2026-09-02, звук
|
||||
* опознан по snd_curr == 11 в живой сессии).
|
||||
*
|
||||
* Три отличия от прежнего кода, все из оригинала:
|
||||
*
|
||||
* 1. Ранний выход по dir_56_none — это ИМЕННО анти-зацикливание, автор
|
||||
* SDLPoP так его и подписал («Fix looping sword moving sound»).
|
||||
* Направление dir_56_none означает «персонаж выключен» (clear_char),
|
||||
* и махать клинком ему нечем.
|
||||
* 2. Условие `Opp.action != ACTION_99_HURT`: попали — играет звук боли,
|
||||
* а не свист мимо. Поэтому звук обязан идти ПОСЛЕ присвоения выше.
|
||||
* 3. Кадры перечитываются у персонажей, а не берутся из cf/of: ветка
|
||||
* парирования только что записала Opp.frame, а play_seq в ней —
|
||||
* Char.frame, и снимки выше уже устарели. */
|
||||
if (Char.direction == DIR_56_NONE) return;
|
||||
if (Char.frame == FRAME_154_POKING && Opp.frame != FRAME_161_PARRY &&
|
||||
Opp.action != ACTION_99_HURT)
|
||||
pop_sfx_play(11);
|
||||
}
|
||||
|
||||
/* check_sword_hurting (seg002:0D1A): прогнать проверку с ОБЕИХ сторон. */
|
||||
|
||||
@@ -771,15 +771,26 @@ static void overlay_mid_tile(int row, int col)
|
||||
pop_env_b(42, x, pop_tile_table[1].right_y + dmy);
|
||||
{ /* draw_tile_base (seg008:0A8E) — ЦЕЛИКОМ, вместе с подстановками id:
|
||||
* у loose верх плиты берётся из loose_fram_left (в pop_tile_table base_id=0),
|
||||
* у opener'а без пола слева — 148. Раньше здесь стоял голый base_id, и
|
||||
* у opener'а без пола слева — 148, НО только в ПОДЗЕМЕЛЬЕ. Условие
|
||||
* `tbl_level_type[current_level] == 0` в оригинале стоит третьим
|
||||
* (seg008:628) и у нас было потеряно ИМЕННО ЗДЕСЬ: статическая
|
||||
* отрисовка (pop_room.c, draw_tile_base) его имеет, а оверлей — нет.
|
||||
* Отсюда симптом «кнопка, на которую лезет Кид, залита кирпичом»:
|
||||
* пока тайл рисует статика, кнопка правильная, но как только Kid
|
||||
* встаёт под кромку, оверлей перерисовывает её поверх него уже
|
||||
* спрайтом 148, а во дворце это другая картинка (кладку палас
|
||||
* вообще рисует заливками, а не спрайтами). Уровень 4, комната 18,
|
||||
* кнопка (0,4) — найдено пользователем 2026-09-02.
|
||||
*
|
||||
* Раньше здесь стоял голый base_id, и
|
||||
* верх loose-плиты в оверлей не попадал: передняя грань ложилась поверх
|
||||
* Kid (foretable), а сама плита — нет, и подтягивающийся Kid рисовался
|
||||
* поверх её верхней плоскости (комната 12). */
|
||||
uint8_t base_id = t->base_id;
|
||||
if (code == 11 && row >= 0)
|
||||
base_id = POP_LOOSE_FRAM_LEFT[pop_loose_frame(pop_loose_modif[row * 10 + col])];
|
||||
else if (code == 0x0F && lcode == 0)
|
||||
base_id = 148;
|
||||
else if (code == 0x0F && lcode == 0 && !pop_palace)
|
||||
base_id = 148; /* ТОЛЬКО ПОДЗЕМЕЛЬЕ (seg008:628) */
|
||||
if (base_id)
|
||||
pop_env_b(base_id, x, t->base_y + dmy);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
@@ -837,30 +839,35 @@ static uint8_t can_grab_front_above(void)
|
||||
/* check_grab (seg006:0A28): в падении при зажатом Shift — зацепиться за
|
||||
* уступ спереди-сверху (seq_15), если скорость падения ещё мала и высота
|
||||
* подходящая. Выравнивает передний край по кромке, гасит fall_y, взводит
|
||||
* grab_timer (блок climb-up на несколько кадров). */
|
||||
static void check_grab(void)
|
||||
* grab_timer (блок climb-up на несколько кадров).
|
||||
*
|
||||
* ВОЗВРАТ: 1 — зацеп СОСТОЯЛСЯ на этом кадре. Нужен вызывающему (do_fall):
|
||||
* дальше по кадру идёт glide_through_wall_guard, и ему нельзя трогать
|
||||
* персонажа, которого мы только что повесили на кромку (см. там же). */
|
||||
static uint8_t check_grab(void)
|
||||
{
|
||||
uint8_t old_x;
|
||||
if (!pop_ctrl_shift_held()) return; /* Shift не зажат */
|
||||
if ((uint8_t)Char.fall_y >= 32) return; /* падает слишком быстро */
|
||||
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return;
|
||||
if (!pop_ctrl_shift_held()) return 0; /* Shift не зажат */
|
||||
if ((uint8_t)Char.fall_y >= 32) return 0; /* падает слишком быстро */
|
||||
if ((uint16_t)pop_y_land[Char.curr_row + 1] > (uint16_t)(Char.y + 25)) return 0;
|
||||
old_x = Char.x;
|
||||
Char.x = (uint8_t)char_dx_forward(-8);
|
||||
determine_col();
|
||||
if (!can_grab_front_above()) {
|
||||
Char.x = old_x; /* не за что — назад */
|
||||
determine_col();
|
||||
} else {
|
||||
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
|
||||
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
|
||||
Char.fall_y = 0;
|
||||
pop_char_set_seq(SEQ_15_GRAB_LEDGE_MIDAIR);
|
||||
play_seq();
|
||||
determine_col();
|
||||
grab_timer = 12;
|
||||
pop_sfx_play(9); /* seg006 check_grab */
|
||||
is_screaming = 0; /* seg006:1219 */
|
||||
return 0;
|
||||
}
|
||||
Char.x = (uint8_t)char_dx_forward((int8_t)distance_to_edge_weight());
|
||||
Char.y = (uint8_t)pop_y_land[Char.curr_row + 1];
|
||||
Char.fall_y = 0;
|
||||
pop_char_set_seq(SEQ_15_GRAB_LEDGE_MIDAIR);
|
||||
play_seq();
|
||||
determine_col();
|
||||
grab_timer = 12;
|
||||
pop_sfx_play(9); /* seg006 check_grab */
|
||||
is_screaming = 0; /* seg006:1219 */
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void make_loose_fall(int pos, uint8_t modifier);
|
||||
@@ -970,8 +977,16 @@ static void do_fall(void)
|
||||
}
|
||||
if (nrow > 4) nrow = 4; /* защита pop_y_land[] от выхода */
|
||||
if ((uint16_t)pop_y_land[nrow] > (uint16_t)Char.y) {
|
||||
check_grab(); /* ещё летит — попытка зацепа */
|
||||
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
|
||||
/* ЗАЦЕП ОТМЕНЯЕТ GUARD. seq_15 — «grab ledge MIDAIR», то есть после
|
||||
* удачного зацепа Char.action == 3, персонаж уже подтянут вплотную к
|
||||
* кромке, а под кромкой в оригинальных комнатах стоит КЛАДКА. Guard,
|
||||
* позванный следом, видел ровно её (t == TILE_WALL, d == 10), считал
|
||||
* это «пролётом сквозь стену», отбрасывал Кида на 5 пикселей назад и
|
||||
* гасил fall_x — зацеп рисовался и тут же срывался. Ловилось на
|
||||
* длинном прыжке уровня 3 (комната 7 -> кромка комнаты 2) и в
|
||||
* attract-демо; регресс-набор — tests/host/t_glide.c. */
|
||||
if (!check_grab()) /* ещё летит — попытка зацепа */
|
||||
glide_through_wall_guard(); /* и не сквозь кладку (см. выше) */
|
||||
} else if (Char.curr_row <= 2) {
|
||||
if (get_tile_at_char() == TILE_WALL)
|
||||
in_wall();
|
||||
|
||||
@@ -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 */
|
||||
@@ -1490,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();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ OBJS_phys := build/eng_pop_geom.rel build/eng_pop_kid.rel \
|
||||
OBJS_grab := $(OBJS_phys)
|
||||
# t_wall — удар о стену в воздухе (BUG-JUMPWALL-1), состав тот же.
|
||||
OBJS_wall := $(OBJS_phys)
|
||||
# t_hang — зацеп обязан ДЕРЖАТЬСЯ (регресс glide_through_wall_guard).
|
||||
OBJS_hang := $(OBJS_phys)
|
||||
# t_gate — открытая решётка не должна считаться стеной у осторожного шага
|
||||
# (нужны pop_map + физика, то есть тот же состав).
|
||||
OBJS_gate := $(OBJS_phys)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* t_hang.c — ЗАЦЕП ОБЯЗАН ДЕРЖАТЬСЯ, а не срываться на следующем кадре.
|
||||
*
|
||||
* Регресс 2026-09-02. glide_through_wall_guard() (порт fix_glide_through_wall,
|
||||
* взят в билд 2026-08-31) звался в do_fall СРАЗУ ПОСЛЕ check_grab. Беда в
|
||||
* том, что seq_15 называется grab_ledge_MIDAIR: после удачного зацепа
|
||||
* Char.action == 3 (в воздухе), персонаж уже подтянут вплотную к кромке — а
|
||||
* под кромкой в комнатах оригинала стоит КЛАДКА. Guard видел ровно её
|
||||
* (t == TILE_WALL, d == 10), считал это «пролётом сквозь стену», отбрасывал
|
||||
* персонажа на 5 пикселей назад и гасил fall_x. Зацеп РИСОВАЛСЯ и тут же
|
||||
* срывался — уровень 3 (комната 7, длинный прыжок через четыре пролёта) и
|
||||
* attract-демо (комната 2, прыжок с места).
|
||||
*
|
||||
* ПОЧЕМУ ПРЕЖНИЕ НАБОРЫ ЭТОГО НЕ ПОЙМАЛИ. t_grab судит по «действие стало
|
||||
* вис» — а вис-то наступал, он просто не жил. Отсюда мера здесь другая:
|
||||
* вис обязан ДЕРЖАТЬСЯ подряд несколько кадров. Разница между «зацепился»
|
||||
* и «зацепился и держится» — это и есть разница между багом и нормой.
|
||||
*
|
||||
* СЦЕНА (геометрия уровня 3, комната 7, приведённая внутрь одной комнаты,
|
||||
* чтобы шов не примешивался): кромка на (0,0), под ней кладка (1,0), четыре
|
||||
* пролёта (0,1..4), толчок с плиты (0,5), разбег справа.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "tcheck.h"
|
||||
#include "scene.h"
|
||||
#include "stubs.h"
|
||||
#include "pop_kid.h"
|
||||
#include "pop_map.h"
|
||||
|
||||
#define E 0x00
|
||||
#define F 0x01
|
||||
#define G 0x04
|
||||
#define L 0x0B
|
||||
#define T 0x13
|
||||
#define W 0x14
|
||||
|
||||
static uint8_t room[30] = {
|
||||
F, E, E, E, E, L, F, T, F, T,
|
||||
W, E, E, E, E, W, W, W, W, W,
|
||||
W, E, E, E, E, W, W, W, W, W,
|
||||
};
|
||||
|
||||
#define ACT_HANG_CLIMB 2
|
||||
#define ACT_HANG_STRAIGHT 6
|
||||
#define HOLD_FRAMES 3
|
||||
|
||||
/* Вис ДЕРЖИТСЯ: действие «вис» стоит HOLD_FRAMES кадров подряд. */
|
||||
static uint8_t hang_holds(uint8_t ledge, uint8_t nrun)
|
||||
{
|
||||
uint8_t i, run = 0;
|
||||
room[0] = ledge;
|
||||
sc_room(room, 7);
|
||||
sc_kid_at(9, 0, -1); /* лицом влево, у правого края */
|
||||
sc_run(SC_L, nrun); /* разбег */
|
||||
sc_run(SC_L | SC_U, 6); /* толчок в прыжок */
|
||||
sc_trace_clear();
|
||||
sc_run(SC_SHIFT, 30); /* полёт, зацеп и жизнь после него */
|
||||
for (i = 0; i < sc_len; i++) {
|
||||
uint8_t a = sc_trace[i].action;
|
||||
if (a == ACT_HANG_CLIMB || a == ACT_HANG_STRAIGHT) {
|
||||
if (++run >= HOLD_FRAMES) return 1;
|
||||
} else run = 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Сколько фаз разбега из 41 дают ЖИВОЙ вис. До регресса — все, кроме
|
||||
* первой (слишком близкий толчок), то есть 34; при сорванном зацепе — ноль. */
|
||||
static uint8_t window(uint8_t ledge)
|
||||
{
|
||||
uint8_t k, n = 0;
|
||||
for (k = 6; k <= 46; k++) if (hang_holds(ledge, k)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
TC_TEST(long_jump_hang_survives_wall_under_ledge)
|
||||
{
|
||||
/* Кромка-пол: под ней кладка — та самая, на которую срабатывал guard. */
|
||||
TC_TRUE(window(F) >= 30);
|
||||
}
|
||||
|
||||
TC_TEST(long_jump_hang_survives_on_gate)
|
||||
{
|
||||
/* Кромка-решётка: тайл другой, кладка под ним та же. Проверяется
|
||||
* отдельно, потому что зацеп за решётку идёт своей веткой can_grab. */
|
||||
TC_TRUE(window(G) >= 30);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
sc_init();
|
||||
TC_RUN(long_jump_hang_survives_wall_under_ledge);
|
||||
TC_RUN(long_jump_hang_survives_on_gate);
|
||||
return 0;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+163
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 кГц */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+83
@@ -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))
|
||||
Reference in New Issue
Block a user