SprPoP: автономное приложение, выделенное из roomtest
Порт PoP переехал в applications/SprPoP — приложение, которое собирается само: код, оригинальные данные, конверторы ресурсов и сборка внутри одной папки. Наружу знает единственный путь — корень тулчейна (SPRINTER_ROOT, по умолчанию ../..). applications/PoP/roomtest ЗАМОРОЖЕНА и остаётся архивом закрытых задач, багов и исполненных планов. Скопировано из applications/PoP/roomtest@4b74478. Перенос проверен побайтово: собранный sprpop.exe совпал с roomtest.exe того же коммита, все 39 дисковых ресурсов и все 16 генерируемых заголовков — тоже, host- тесты зелёные (15/15). Раскладка: src/ рукописный C (roomtest.c -> sprpop.c) gen/ генерируемые заголовки, в репозитории assets/orig/ оригинальные данные игры, вне репозитория (копирайт) assets/packed/ то, что ложится на диск, в раскладке диска tools/ конверторы; все пути — в одном tools/paths.py build/ выход: exe, каталоги ресурсов, hdd/, промежуточные atl/ Сборка ресурсов: assets/packed и gen — версионируемые ВХОДЫ, а не то, что пересчитывается каждым make. Автоматика построена на ОТСУТСТВИИ файла, а не на таймстемпах: git не хранит времена, и в свежем клоне сравнение по времени превращалось бы в лотерею. Недостающий ресурс или заголовок чинится сам, рекурсивным вызовом в ветку генерации. Музыка собирается из любого из четырёх наборов записей (make music-mp3, music-mt32, ...); набор входит в имя stamp'а, поэтому смена набора сама делает музыку устаревшей. Длины реплик больше не захардкожены: упаковщик печатает их в gen/pop_music_ticks.h, и шкала сцены выражена через них — иначе mt32 (реплики на 6% длиннее) молча ломал катсцену. Тулчейн: в app.mk два обратносовместимых крючка (SRC_DIR/BUILD_DIR), HDD_IMG стал ?=; команда сборки roomtest не изменилась. Корневой make host-tests переключён на SprPoP. Подгонка тайминга катсцены с принцессой (PV_MAGIC_LEAD): сцена render-bound и идёт ~49 тиков/с вместо 60, из-за чего кода реплики приходила раньше молнии. Это обход, а не лечение; разбор с замерами — docs/BUGS_OPEN.md, записи SND-PACE-DEAD, PV-RENDER-BOUND, MUS-LEFT-TEAR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
# Prince of Persia — Kid (персонаж): анализ и план
|
||||
|
||||
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Kid играется целиком: интерпретатор
|
||||
> `seqtbl` + `frame_table` (`src/pop_kid.c`), диспетчер `control()`
|
||||
> (`pop_ctrl.c`), коллизия/физика/зацеп (`pop_map.c`), бой и HP. Модель
|
||||
> персонажа стала общей: `Char`-окно (`pop_state.c`) обслуживает и Кида, и
|
||||
> стражей. Таблицы кадров и `seqtbl` уехали из `_CODE` в EMM-страницу
|
||||
> (`kid_data.bin`, см. `layout_plan_v2.md` шаг 1).
|
||||
>
|
||||
> Отступление от §2.1 плана: выбран ПАДДИНГ кадров (общий канвас), а не
|
||||
> per-frame offset — компромисс зафиксирован в `PORT_PLAN.md §6.1`.
|
||||
>
|
||||
> **Документ оставлен как СПРАВОЧНИК по модели персонажа** (`char_type`,
|
||||
> категории `actions_*`, устройство `play_seq`, объём спрайтов) — он нужен
|
||||
> при портировании остальных акторов (скелет, тень, визирь). Текущие
|
||||
> задачи — `../TASKS_OPEN.md`.
|
||||
|
||||
Составлен 2026-07-16. Опирается на разбор `SDLPoP/src/seg006.c`
|
||||
(ядро физики/управления Kid), `seqtbl.c` (таблицы последовательностей),
|
||||
`types.h` (char_type, seq_*, SEQ_*, actions_*), `SDLPoP/data/KID` (спрайты).
|
||||
Фон уже готов и проверен на MAME (`applications/SprPoP`, см.
|
||||
`memory/pop_background_strategy`) — Kid развиваем в том же `SprPoP` как
|
||||
новый PoC (решение пользователя: старый `poc/` не трогаем).
|
||||
|
||||
**Копирайт:** спрайты Kid (`SDLPoP/data/KID`) — Broderbund/Ubisoft.
|
||||
Использование настоящей графики Kid — сознательное решение пользователя
|
||||
(в отличие от плейсхолдера в старом `poc/`, см. PORT_PLAN §5.1).
|
||||
|
||||
---
|
||||
|
||||
## 1. Как устроен персонаж в оригинале (что портируем)
|
||||
|
||||
### 1.1 Состояние — `char_type` (14 полей, types.h)
|
||||
|
||||
```
|
||||
frame текущий номер кадра (индекс во frame_table_kid)
|
||||
x, y позиция (byte; x — с учётом direction)
|
||||
direction -1 влево / 0 вправо
|
||||
curr_col, логическая клетка (тайл), где персонаж
|
||||
curr_row
|
||||
action КАТЕГОРИЯ действия (actions_*, см. 1.2)
|
||||
fall_x, скорость падения (fall_y<22 = 1 ряд, <33 = 2 ряда)
|
||||
fall_y
|
||||
room комната
|
||||
repeat счётчик для удержания-ввода (напр. повторный прыжок)
|
||||
sword есть ли меч (бой — вне Фазы 1)
|
||||
alive жив/мёртв
|
||||
curr_seq УКАЗАТЕЛЬ в seqtbl (байткод текущей последовательности)
|
||||
```
|
||||
|
||||
Состояние крошечное — легко живёт в W2.
|
||||
|
||||
### 1.2 Категории действия — `actions_*` (9 шт)
|
||||
|
||||
`0 stand`, `1 run_jump`, `2 hang_climb`, `3 in_midair`, `4 in_freefall`,
|
||||
`5 bumped`, `6 hang_straight`, `7 turn`, `99 hurt`. `action` определяет,
|
||||
как `check_action()`/`play_kid()` реагируют на ввод и физику каждый тик.
|
||||
|
||||
### 1.3 Движок анимации/движения — ГЛАВНОЕ
|
||||
|
||||
**Движение НЕ физика, а байткод + per-frame смещения** (подтверждает
|
||||
PORT_PLAN §6). Три уровня:
|
||||
|
||||
1. **`seqtbl`** — байткод-программа на действие. Опкоды (types.h):
|
||||
`SEQ_DX`(0xFB) сдвиг x на amount×direction, `SEQ_DY`(0xFA) сдвиг y,
|
||||
`SEQ_FLIP`(0xFE) разворот, `SEQ_JMP`(0xFF)/`SEQ_JMP_IF_FEATHER`(0xF7),
|
||||
`SEQ_UP`/`SEQ_DOWN`(0xFD/0xFC) смена ряда, `SEQ_ACTION`(0xF9) задать
|
||||
`Char.action`, `SEQ_SET_FALL`(0xF8), `SEQ_KNOCK_UP/DOWN`, `SEQ_SOUND`,
|
||||
`SEQ_DIE`/`SEQ_END_LEVEL`/`SEQ_GET_ITEM`. **Байт < 0xF0 = НОМЕР КАДРА**
|
||||
→ ставит `Char.frame` и play_seq возвращается (один кадр за тик).
|
||||
2. **`play_seq()`** (seg006.c:570) — интерпретатор: крутит опкоды из
|
||||
`seqtbl + Char.curr_seq`, пока не встретит кадр. ~15 case — портируется
|
||||
1-в-1. **Квирк:** seqtbl использует АБСОЛЮТНЫЕ DOS-адреса в JMP;
|
||||
`SEQTBL_0 = seqtbl - SEQTBL_BASE(0x196E)` — при порте пересчитать
|
||||
базу (JMP-адреса в наших данных).
|
||||
3. **`frame_table_kid[]`** (seg006.c:127, ~180 кадров) — на КАЖДЫЙ кадр:
|
||||
`{image, sword_flags, dx, dy, flags}`. `image` — индекс спрайта Kid;
|
||||
`dx/dy` — смещение позиции ЭТОГО кадра; `flags`: 0x1F weight_x, 0x20
|
||||
thin, 0x40 needs_floor, 0x80 even/odd-pixel (влияет на x-рендер).
|
||||
|
||||
**Тик персонажа:** `play_kid()` (диспетчер по action+вводу) → `play_seq()`
|
||||
(двигает curr_seq, ставит кадр, применяет seq-dx/dy) → `frame_table[frame]`
|
||||
даёт image+собственные dx/dy → позиция и спрайт. У нас это ложится на
|
||||
`sprite_frame`+`sprite_move` (НЕ `sprite_anim`/`sprite_moveto` — см.
|
||||
PORT_PLAN §6: авторские таблицы, не автопрогрессия).
|
||||
|
||||
### 1.4 Управление — `control_kid()`/`read_user_control()` (seg006.c)
|
||||
|
||||
Читает ввод (у нас — held-state `kbd_raw`, уже готово, §2 PORT_PLAN) и по
|
||||
`Char.action` выбирает последовательность (`seqtbl_offset_char(seq_id)`).
|
||||
Логика «что можно из какого состояния» — ядро ощущения PoP.
|
||||
|
||||
### 1.5 Взаимодействие с картой — collision (seg006.c)
|
||||
|
||||
`check_on_floor()`/`start_fall()` — пол под ногами / падение в яму;
|
||||
`in_wall()` — упор в стену (сдвиг наружу); `check_grab()`/
|
||||
`can_grab_front_above()` — зацеп за уступ; `fell_out()` — вывалиться из
|
||||
комнаты; `check_spiked()`/loose — ловушки; `fall_accel()`/`fall_speed()` —
|
||||
ускорение падения. Всё читает ТИП тайла (`get_tile`) — у нас это уже
|
||||
разобранные `fg[]/bg[]` (level.h/room1_data.h).
|
||||
|
||||
---
|
||||
|
||||
## 2. Спрайты Kid (219 шт, 16 цветов, 177 КБ)
|
||||
|
||||
- 219 PNG (`data/KID`), 16-цветные (палитра `res400.pal`, 16×RGB как env/
|
||||
wall), макс кадр **53×35** — влезает в лимит движка 64×64. 177 КБ в
|
||||
8bpp.
|
||||
- `frame_table_kid` отображает кадр→`image` (индекс спрайта). Число
|
||||
РАЗЛИЧНЫХ image — уточнить (≤219); паковать те, что реально используются
|
||||
платформинг-последовательностями Фазы 1 (не все 219 — бой/катсцены
|
||||
отдельно).
|
||||
- **Палитра:** Kid 16 цветов → слоты Sprinter `0x70-0x7F` (env 0x50, wall
|
||||
0x60 уже заняты; Kid не пересекается). Пиксель i: 0→0xFF, i→0x70+i.
|
||||
Тот же пайплайн, что `pop_pack_bg.py`.
|
||||
- **Атлас:** прямая адресация по номеру image (как фон): `kid[img>>5]`,
|
||||
idx `img&31`; ~7 EMM-страниц (или SHIFT=4). Свой пакер `pop_pack_kid.py`
|
||||
(переиспользовать код `pop_pack_bg.py`).
|
||||
|
||||
### 2.1 РЕШЕНИЕ ДО СТАРТА: per-frame offset vs padding
|
||||
|
||||
Кадры Kid — РАЗНОГО размера, а `sprite_t` рисует от угла фикс. w/h. Два
|
||||
пути (см. PORT_PLAN §6.1, `memory/png_strip_padding_tradeoff`):
|
||||
- **Padding** (bottom-center) — просто, но 219×53×35 ≈ 406 КБ (раздув ×2.3).
|
||||
- **Per-frame offset** — хранить XCO/YCO кадра (у оригинала он и есть,
|
||||
`APPLEII_RESOURCE_FORMAT §2.2`), рисовать `blit(x+xco, y+yco)`; паддинг не
|
||||
нужен, память по факту (177 КБ). Требует лёгкого расширения хранения
|
||||
(offset рядом с кадром) ИЛИ ручного смещения в коде рендера Kid.
|
||||
**Рекомендация:** per-frame offset — оригинал так и делает (frame_table dx/dy
|
||||
+ image XCO/YCO), даёт точное позиционирование И экономию. Хранить xco/yco
|
||||
в нашей копии frame_table (добавить 2 байта/кадр — ~360 Б). Не тянуть
|
||||
расширение `sprite.h` — рисовать Kid прямым `gfx_blit(x+xco, y+yco, img)`
|
||||
(как фон), НЕ через retained `sprite_t`, раз позиция и кадр всё равно
|
||||
задаются вручную каждый тик.
|
||||
|
||||
---
|
||||
|
||||
## 3. Данные для порта (объём)
|
||||
|
||||
- `frame_table_kid` → C-массив ~180×(5+2 offset) ≈ 1.3 КБ (const, ROM).
|
||||
- `seqtbl` (нужные последовательности) → C-массив байт. Весь seqtbl ~1-2 КБ;
|
||||
для Фазы 1 можно взять только платформинг-последовательности (вырезать
|
||||
бой/гардов 55-92) — оценить после разметки. JMP-адреса пересчитать под
|
||||
свою базу.
|
||||
- Спрайты — атласы (EMM, не W2).
|
||||
|
||||
---
|
||||
|
||||
## 4. Фазы работы (по твоему списку, порядок по зависимостям)
|
||||
|
||||
**Фаза K0 — конвейер спрайтов + отрисовка одного кадра**
|
||||
- `pop_pack_kid.py`: 219 (или подмножество) → `kid*.atl` + `kid.pal`
|
||||
(слоты 0x70), таблица кадр→image + xco/yco.
|
||||
- Отрисовать Kid ОДНИМ кадром (stand) в SprPoP поверх фона на верном
|
||||
тайле — проверить палитру/позицию/прозрачность на MAME.
|
||||
- Артефакт-цель: Kid стоит на уступе комнаты 1 как в `1.1-2.png`.
|
||||
|
||||
**Фаза K1 — движок анимации (play_seq + frame_table)**
|
||||
- Портировать `play_seq()` (интерпретатор) + `frame_table_kid` + минимальный
|
||||
`seqtbl` (stand/run/turn).
|
||||
- Прогнать несколько последовательностей вручную (stand→run→stop) —
|
||||
проверить, что кадры и смещения совпадают с оригиналом (сверять с
|
||||
SDLPoP/скриншотами, тайминг 50 Гц).
|
||||
|
||||
**Фаза K2 — управление на месте + ходьба (твои а, б)**
|
||||
- `control_kid` подмножество: stand (2), run (1/84/13), turn (5/6),
|
||||
standing_jump (3), crouch (50/49), safe_step (29-44 — аккуратный шаг).
|
||||
- Held-state через `kbd_raw` (готово).
|
||||
|
||||
**Фаза K3 — коллизия с картой (твой п.3)**
|
||||
- `check_on_floor`/`start_fall` — падение в ямы (тип тайла под ногами из
|
||||
`fg[]`); `in_wall`/стоп у стены; `fell_out` (край экрана — пока без
|
||||
перехода комнат).
|
||||
- Падения/приземления (seq 7/17/19/20) + `fall_accel/fall_speed`.
|
||||
|
||||
**Фаза K4 — прыжки и повисание (твои а-прыжок, в)**
|
||||
- run_jump (4), jump_up (28/14), grab (8/16/24), climb_up (10)/down (68),
|
||||
hang (25/6), release (11/23). Это самый «PoP-овый» кусок — сверять
|
||||
дистанции/тайминг с оригиналом (не на глаз).
|
||||
|
||||
**Фаза K5 — прочее (твой г)**
|
||||
- drink (78), level_door (70), crouch_hop (79), spiked/loose/chomped
|
||||
(ловушки, если тайлы есть в комнате), death (71).
|
||||
|
||||
Бой (меч, seq 55-92, стражники — seg005) — ВНЕ этого плана (отдельная фаза
|
||||
полного приложения, PORT_PLAN §7 Фаза 3).
|
||||
|
||||
---
|
||||
|
||||
## 5. Риски/решения ДО кода (правило defer_unexplained_quirks)
|
||||
|
||||
1. **Per-frame offset** (§2.1) — решить до K0 (влияет на формат данных).
|
||||
Рекомендация: xco/yco в frame_table, прямой blit.
|
||||
2. **seqtbl rebasing** — JMP-адреса абсолютные (SEQTBL_BASE 0x196E); при
|
||||
порте пересчитать в оффсеты своего массива. Проверить на 1-2 seq.
|
||||
3. **Тайминг** — оригинал (DOS) фиксированный тик; наш 50 Гц. Если
|
||||
логическая частота кадров иная — пересчёт dx/dy (PORT_PLAN §8.4).
|
||||
Сверять дистанцию бега/прыжка с эталоном.
|
||||
4. **Число реально нужных кадров/последовательностей** для Фазы 1 —
|
||||
разметить (вырезать бой/катсцены/гардов), чтобы не тянуть все 219
|
||||
спрайта и весь seqtbl.
|
||||
5. **Копирайт графики Kid** — подтверждено решение пользователя (§вводная).
|
||||
|
||||
---
|
||||
|
||||
## 6. Что переиспользуем (готово)
|
||||
|
||||
- Фон комнаты (`pop_bg.c`) — Kid рисуется ПОВЕРХ (сейчас — прямым blit;
|
||||
heal против фона — когда/если понадобится через RAM-копию, фон её уже
|
||||
заполняет, `GFX_BANK_TRANSPARENT`).
|
||||
- `kbd_raw` held-state (§2 PORT_PLAN) — готов и проверен.
|
||||
- Пакер спрайтов/палитра (`pop_pack_bg.py`) — шаблон для `pop_pack_kid.py`.
|
||||
- Разобранная карта комнаты (`fg[]/bg[]`, level.h) — для коллизий.
|
||||
- `gfx_blit`/`gfx_w0_map` из W0-атласа — проверенный путь (bgtest/SprPoP).
|
||||
@@ -0,0 +1,555 @@
|
||||
# Prince of Persia на ZX Sprinter — план порта
|
||||
|
||||
## СТАТУС (обновлено 2026-08-01)
|
||||
|
||||
Документ составлен 2026-07-15 как план «с нуля» и с тех пор во многом
|
||||
исполнен. Читать его надо так:
|
||||
|
||||
| Раздел | Что с ним сейчас |
|
||||
|--------|------------------|
|
||||
| §1 возможности библиотек | актуально как обзор, но **спрайтовый движок `sprite.h` для персонажей НЕ используется**: Kid/страж рисуются прямыми блитами атласов (`gfx_blit_cols_part*`) с ручным heal — так требует модель оригинала (§6) |
|
||||
| §2 held-state клавиатуры | **сделано** (`kbd_mod_state`, `<kbd_raw.h>`). Открытая проблема — потеря байт при аккордах Shift+стрелка; диагноз и план в `../PoP/SprPoP/TASKS_CLOSED.md` (KBD-1) |
|
||||
| §3 форматы данных | актуально; уровень читается живьём (`src/pop_level.c`) |
|
||||
| §4 стратегия фона | **сделано** — тайловый рендерер в рантайме (`src/pop_bg.c`) |
|
||||
| §5 PoC | **закрыт и превзойдён.** `poc/` (плейсхолдер-персонаж) — история; активная разработка ушла в `SprPoP/` с настоящей графикой |
|
||||
| §6 модель движения | **сделано**: `play_seq` + `frame_table` оригинала, не физика с нуля |
|
||||
| §7 фазы | см. отметки статуса прямо в разделе |
|
||||
| §8 риски | п.1 закрыт, п.3 закрыт (28 страниц-атласов Кида), п.2/п.4 — см. отметки в разделе |
|
||||
| §10 режим памяти | **сделано и переросло план**: `huge` + четыре банка кода; актуальная раскладка — `layout_plan_v2.md` |
|
||||
|
||||
**Где смотреть текущее состояние, а не план:** `../SprPoP/README.md`
|
||||
(что играется), `../TASKS_OPEN.md` (что в работе), `levels_plan.md`
|
||||
(следующие уровни), `layout_plan_v2.md` (раскладка кода по окнам и банкам).
|
||||
|
||||
---
|
||||
|
||||
Опирается на
|
||||
`APPLEII_RESOURCE_FORMAT.md` / `MSDOS_RESOURCE_FORMAT.md` / `README.md` в
|
||||
этой папке, на текущий sprinter-cc/libc/libbgi (см. §1) и на локальные копии
|
||||
`applications/PoP/SDLPoP` (github.com/NagyD/SDLPoP, GPLv3) и
|
||||
`applications/PoP/PR` (github.com/NagyD/PR, GPLv2) — используются только как
|
||||
справочник по структурам/константам оригинального движка и как источник
|
||||
готовых распакованных ассетов (`SDLPoP/data/`), не как код для копирования.
|
||||
|
||||
---
|
||||
|
||||
## 1. Что уже есть в sprinter-cc и библиотеках (используем как есть)
|
||||
|
||||
Собрано из `docs/TODO.md`, `docs/libc-reference.md`, `docs/sprite-api-design.md`,
|
||||
`libbgi/include/{gfx.h,sprite.h,graphics.h}`, `examples/rpgwalk`.
|
||||
|
||||
- **Графика 320×256×256** (`GFX_MODE_320x256x256`, режим 0x81) — разрешение и
|
||||
глубина цвета совпадают почти впрямую с VGA-ассетами оригинала
|
||||
(`SDLPoP/data/VPALACE`, `VDUNGEON` — уже 256-цветные PNG). Не нужно ужимать
|
||||
в EGA/CGA палитру.
|
||||
- **BGI-слой** (`graphics.h`) — примитивы, палитра, текст, `getimage/putimage`
|
||||
— Фазы 1-2d готовы и проверены в MAME.
|
||||
- **Спрайтовый движок v2** (`sprite.h`, ветка `sprite-engine-v2`) — ровно то,
|
||||
что нужно персонажам PoP:
|
||||
- retained-модель (`sprite_update`/`sprite_flip`, double-buffer, dirty-биты,
|
||||
heal+blit за один проход);
|
||||
- кадровая анимация по ленте (`sprite_anim`, LOOP/PINGPONG/ONCE,
|
||||
горизонтальная/вертикальная лента) и tween-перемещение
|
||||
(`sprite_moveto`, DDA без knowledge-heavy арифметики);
|
||||
- Y-сортировка слоями (`gfx_sprite_ysort`, `layer`) — то, что нужно для
|
||||
«Кид перед/за стражником» без ручной пересортировки;
|
||||
- атласы в EMM-страницах (`atlas_t`/`atlas_load`) — на восьмерых
|
||||
персонажей в `rpgwalk` уже работает: прямой прецедент для Кида/стражника;
|
||||
- ограничение кадра ≤ 64×64 — с запасом (см. §3: кадры Кида в оригинале
|
||||
~12-30 × 39-42 px).
|
||||
- **Frame pacing** (`gfx_set_fps_div`) + цепочка кадровых IRQ — стабильный
|
||||
логический тик независимо от рендер-нагрузки экрана (проверено MAME).
|
||||
- **EMM-бюджет**: ~3.3 МБ свободно на старте (`memory/sprinter_emm_budget`) —
|
||||
с большим запасом на все спрайт-атласы и предрендеренные фоны комнат (см.
|
||||
§4) даже без выгрузки неиспользуемых уровней.
|
||||
- **Файловый ввод-вывод** (FILE* v2, `fopen/fread/...`) — для загрузки
|
||||
уровней/атласов/палитр с дискеты, по образцу `rpgwalk` (`atlas_load`,
|
||||
`gfx_pal_fload`).
|
||||
- **Клавиатура (событийная)** — `kbhit/getch/getkey` (ASCII + `KEY_*` скан-код
|
||||
для стрелок), см. §2 — это НЕ то, что нужно для управления Кидом один в
|
||||
один (см. ниже).
|
||||
- **Звук** — `cbl.h` (потоковый CBL/COVOX, callback-модель, verified MAME) —
|
||||
подходит для оцифрованных эффектов (`digisnd*.dat` — PC-звук
|
||||
~11 кГц 8-бит, см. `MSDOS_RESOURCE_FORMAT.md` §4).
|
||||
|
||||
Вывод: **движок отрисовки и анимации почти не требует нового кода** —
|
||||
самый близкий по духу пример (`rpgwalk`: атласы, анимация, tween, дабл-буфер,
|
||||
FPS-делитель) переносится на PoP почти без изменений архитектуры.
|
||||
|
||||
---
|
||||
|
||||
## 2. Единственный принципиальный пробел: удержание клавиш
|
||||
|
||||
**Спайк проведён (2026-07-15), вопрос закрыт артефактами — не догадкой.**
|
||||
|
||||
`getch`/`getkey` — это события ESTEX WAITKEY/SCANKEY (по нажатию), без чёткой
|
||||
информации о СОСТОЯНИИ (что зажато прямо сейчас, несколько клавиш
|
||||
одновременно). Prince of Persia на управлении требует именно состояния:
|
||||
держать направление (бег) + одновременно нажать вверх (прыжок вперёд), держать
|
||||
Shift (модификатор) + направление и т.д.
|
||||
|
||||
### 2.1 Находки
|
||||
|
||||
1. **`docs/converted/ProgrammerManual.txt` документирует функцию, которую мы
|
||||
раньше пропустили: `CTRLKEY` (ESTEX $33h)** — «Получить состояние
|
||||
клавиатуры». Дословно: «данные берутся не из буфера клавиатуры (как в
|
||||
остальных функциях), а непосредственно из результатов ПОСЛЕДНЕГО
|
||||
сканирования» — то есть это НАСТОЯЩЕЕ live-state, не событие. Но
|
||||
покрывает только модификаторы: Left/Right Shift, Ctrl, Alt,
|
||||
Rus/Lat, Num/Scroll/Caps Lock, Insert (не обычные клавиши вроде стрелок).
|
||||
Готовое решение для «держать Shift = бежать» — тривиальная обёртка,
|
||||
без архитектурных рисков.
|
||||
2. Для ОБЫЧНЫХ клавиш (стрелки, буквы) такого live-state нет нигде в ESTEX —
|
||||
`WAITKEY`/`SCANKEY`/`TESTKEY` ($30/$31/$37h) — все три отдают ОДИНАКОВЫЙ
|
||||
формат «очередное нажатие», без release. `TESTKEY` не удаляет событие из
|
||||
буфера (полезно для «подсмотреть, не потребляя»), но это тоже разовое
|
||||
нажатие, не состояние.
|
||||
3. Автоповтор клавиатуры (typematic) не годится как замена held-state:
|
||||
`MAME_MCP_GUIDE.md` фиксирует задержку до первого повтора ~1 секунда
|
||||
(типично для PS/2) — на порядок медленнее кадра (20 мс), не подходит для
|
||||
платформера.
|
||||
4. **Решающий артефакт — `libc/irq/_irq_tramp.c` (сам трамплин прерывания,
|
||||
не гипотеза):** вектор 0xFF общий для кадра/клавиатуры/CBL. Ветка
|
||||
клавиатуры (бит 0 порта 0x19 = SIO-A RR0 «байт принят») делает буквально
|
||||
`jp 0x0038` (прямиком в DSS) **до какого-либо чтения порта данных 0x18 И
|
||||
до нашей кадровой цепочки (`_irq_chain`)** — наш `irq_chain_add`
|
||||
вообще не видит клавиатурные прерывания, они физически не доходят до
|
||||
цепочки (см. `tr_notkbd`/`tr_frame` разбор в файле). Значит текущая
|
||||
инфраструктура (тот же механизм, что несёт FPS-делитель) НЕ дает
|
||||
зацепки для клавиатуры без правки самого трамплина.
|
||||
5. Регистр данных SIO (порт 0x18) — аппаратный приёмный буфer, чтение
|
||||
деструктивно (дёргает байт из очереди); кто прочитал первым, тот и
|
||||
владеет байтом. Значит «подглядеть, не мешая DSS» технически
|
||||
невозможно — необходимо либо совсем не трогать этот путь (статус-кво),
|
||||
либо взять его СЕБЕ полностью на время геймплея.
|
||||
|
||||
### 2.2 Рекомендация (конкретная, не три равнозначных варианта)
|
||||
|
||||
**A. Тривиально, почти без риска — обернуть `CTRLKEY` ($33h)** отдельной
|
||||
функцией (например `kbd_mod_state()` в `<conio.h>`) — даёт настоящий
|
||||
held-state для Shift/Ctrl/Alt. Можно делать хоть сейчас, не архитектурное
|
||||
решение.
|
||||
|
||||
**B. Для обычных клавиш (стрелки и т.д.) — по прецеденту CBL.** В
|
||||
`_irq_tramp.c` уже есть пример «приватного» пути на том же векторе 0xFF,
|
||||
который сознательно НЕ чейнится к DSS (CBL: бит 7 порта 0xFE, свой
|
||||
хук `_irq_cbl_hook`, полный сейв, свой `reti`). Предлагаемый новый
|
||||
компонент `<kbd_raw.h>` — симметричный: ветка по биту 0 порта 0x19 читает
|
||||
порт 0x18 САМА (декодирует PS/2 make/break, `0xF0`-префикс — протокол
|
||||
уже задокументирован в `docs/samples/sprinterKeybLib.asm`), ведёт битовую
|
||||
карту «клавиша N зажата», и НЕ прыгает в DSS, пока путь активен —
|
||||
жизненный цикл `kbd_raw_open()`/`kbd_raw_close()` один в один как у
|
||||
`cbl_open`/`cbl_close`.
|
||||
|
||||
**Важное следствие (сообщить пользователю явно, не прятать):** пока
|
||||
`kbd_raw_open()` активен, DSS вообще не получает клавиатурных байт —
|
||||
`kbhit/getch/getkey/CTRLKEY` заведомо не будут работать, ESC для выхода
|
||||
в DSS-смысле тоже (нужно проверять raw-битовую карту самим). Это
|
||||
нормально для активной фазы геймплея (у самой игры и так свой цикл
|
||||
ввода), но означает: экраны/паузы, которым нужен ESTEX-ввод (например,
|
||||
диалог сохранения через `fopen`, если тот когда-либо потребует ввода
|
||||
с консоли), должны на это время `kbd_raw_close()`.
|
||||
|
||||
**Не рекомендую вариант «таймаут-эвристика поверх SCANKEY»** — after
|
||||
находки о typematic-задержке ~1с он не даёт нужной задержки для игры;
|
||||
рекомендация A+B закрывает потребность без компромиссов.
|
||||
|
||||
**Статус: A+B РЕАЛИЗОВАНЫ (2026-07-15, по согласованию с пользователем).**
|
||||
|
||||
- A: `kbd_mod_state()` — `libc/conio/kbd_mod_state.c` + `<conio.h>`
|
||||
(`KBD_MOD_*`).
|
||||
- B: `<kbd_raw.h>` (`libc/kbd/`) + правка `libc/irq/_irq_tramp.c`
|
||||
(новая ветка на бите 0 порта 0x19: raw активен → сама читает порт
|
||||
0x18, декодирует make/break, НЕ чейнится к DSS; raw выключен —
|
||||
поведение как раньше, без изменений). Трамплин вырос со 150 до
|
||||
220 байт — `_IRQ_TRAMP_BUF_SIZE` поднят с 224 до 288 (было 4 байта
|
||||
запаса, стало ≥60). `make -C libc` (fast+safe) — чисто.
|
||||
- **Верификация в MAME** (`tests/kbdraw`, полный цикл open→держать→
|
||||
отпустить→ESC-выход→close): `KBD_LEFT` (0x16B, расширенный код
|
||||
E0 6B) — down на нажатие, up на отпускание, ТОЧНО совпало с
|
||||
константой из `<kbd_raw.h>`; `KBD_ESC` (0x76, обычный код) —
|
||||
корректно закрыл raw-канал и вернул DSS (`IM` вернулся в 1).
|
||||
Побочно найдено и задокументировано в `docs/libc-reference.md`
|
||||
(`<kbd_raw.h>`): MAME-мостовой `press_key` дёргает ОБЕ клавиатуры
|
||||
(PC+ZX) одновременно и через ZX-путь давал паразitный незатухающий
|
||||
бит — не относится к реальному сценарию (пользователь подтвердил:
|
||||
матрица на Sprinter давно не используется), но означает, что
|
||||
будущие MAME-тесты этой функции надо гонять через `:kbd:ms_naturl:*`
|
||||
напрямую, не через удобный `press_key`. UP/DOWN/RIGHT/SPACE/SHIFT
|
||||
константы — НЕ перепроверены поштучно (тот же общеизвестный
|
||||
стандарт PS/2 Set 2, что и подтверждённые LEFT/ESC — проверить перед
|
||||
использованием в PoC, если управление будет ощущаться неверно).
|
||||
- На реальном железе — не проверено (только MAME).
|
||||
|
||||
---
|
||||
|
||||
## 3. Формат данных — что напрямую переносим из docs/*RESOURCE_FORMAT.md
|
||||
|
||||
- **Уровень** (`BLUETYPE`/`BLUESPEC`/`LINKLOC`/`LINKMAP`/`MAP`/`INFO`,
|
||||
2304 байта, 24 экрана × 30 тайлов) — читаем один раз при загрузке уровня
|
||||
в свою C-структуру (прямой memcpy дампа файла, поля читаем по офсетам
|
||||
из `APPLEII_RESOURCE_FORMAT.md` §1). DOS `levels.dat` даёт то же самое
|
||||
+1 байт в конце записи — отбросить.
|
||||
- **Графика фона/спрайтов** — кодек сжатия DOS `.DAT` не восстановлен и
|
||||
восстанавливать не будем: используем уже распакованные PNG из
|
||||
`SDLPoP/data/{KID,GUARD,VPALACE,VDUNGEON,...}` (см.
|
||||
`MSDOS_RESOURCE_FORMAT.md` §5, §7 — тот же контейнерный формат/нумерация,
|
||||
просто другой релиз сборки данных). Измерено локально: кадры Кида —
|
||||
~12×39 .. 30×42 px (P-режим, 4-бит палитра), фоновые тайлы подземелья —
|
||||
32 px по ширине (10 колонок × 32 = 320 — сходится с шириной экрана), высота
|
||||
тайла 20/60/62 px (неоднородные ряды пола/потолка/арок) — укладывается в
|
||||
лимит спрайтового движка (кадр ≤ 64×64) без всяких изменений движка.
|
||||
- **Звук** — `digisnd*.dat` (PC-звук 8-бит ~11 кГц) — конвертация в сырой
|
||||
PCM и проигрывание через `cbl_open`/`cbl_push_*`; `ibm_snd*.dat` (PC-спикер
|
||||
тройки «частота×2Б + длительность») — тривиальный бипер, не требует CBL.
|
||||
MIDI-семейство (`midisnd`, `mt32snd`, `prince.dat`) — вне скоупа (нет
|
||||
синтеза MIDI на платформе; не блокирует геймплей).
|
||||
|
||||
---
|
||||
|
||||
## 4. Стратегия фона — ПЕРЕСМОТРЕНО 2026-07-15: тайловый рендерер В РАНТАЙМЕ
|
||||
|
||||
**Было** (первая версия плана): офлайн-склейка каждой комнаты в готовую
|
||||
растровую картинку 320×~193, `gfx_blit` целиком при входе — обоснование
|
||||
было «ноль нового кода в libbgi». Пересчёт по факту наличия структурных
|
||||
данных комнаты (§3.4 формата, `level.h`) показал: 16 уровней × 24 комнаты ×
|
||||
~60-80 КБ/картинка — это **30+ МБ**, при том что одна и та же картинка
|
||||
тайла (пол/стена/колонна) переиспользуется в десятках комнат — офлайн-
|
||||
склейка печёт её заново в каждую копию.
|
||||
|
||||
**Стало**: тайлы — переиспользуемый набор картинок ОДИН на визуальный
|
||||
стиль (не на комнату), структурные данные комнаты — компактные (60 байт:
|
||||
30×foretable+30×backtable, все 16 уровней ≈ 37 КБ, см. `level.h`).
|
||||
`room_draw()` (applications/PoP/poc/room.c) проходит 30 тайлов комнаты и
|
||||
зовёт `gfx_blit` для каждого, читая картинку из таблицы по типу тайла
|
||||
(`tile_images[TILE_TYPE]`). Итог: десятки-сотни КБ переиспользуемых
|
||||
тайл-картинок на весь визуальный стиль + ~37 КБ структуры уровней —
|
||||
вместо 30+ МБ.
|
||||
|
||||
**Почему это НЕ бьёт по бюджету кадра**: `room_draw()` зовётся ОДИН РАЗ
|
||||
при входе в комнату (смена комнаты — не every-frame событие), не в
|
||||
игровом цикле — это не `sprite_update`, тактовый бюджет кадра не
|
||||
затронут.
|
||||
|
||||
Анимированные тайлы (факел, шипы, дверь-плита) по-прежнему рисуются как
|
||||
отдельные `sprite_t` поверх фона — движок это уже умеет (Y-order/layers,
|
||||
dirty-биты, heal против фона через ОЗУ-копию); `room_draw()` кладёт в
|
||||
ОЗУ-копию именно статичную геометрию (пол/стены/колонны Фазы 1 — §5.2),
|
||||
поверх неё heal спрайтов работает как обычно.
|
||||
|
||||
`toolchain/room_compose.py` (генерик-компоновщик тайлов в одну картинку,
|
||||
§6.1) остаётся полезным ИНСТРУМЕНТОМ конвертации отдельных тайл-картинок
|
||||
(PNG → getimage raw), просто теперь его выход — 32 маленьких файла
|
||||
`tileNN.raw` (по одному на тип тайла), а не один большой файл на комнату;
|
||||
сама раскладка/повторное использование по комнатам — в C-коде
|
||||
(`room_draw`), не в офлайн-склейке.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proof-of-Concept — цель: доказать, что порт вообще ощущается как PoP
|
||||
|
||||
> **Закрыт (исторический раздел).** PoC в `poc/` свою задачу выполнил и
|
||||
> дальше не развивается: управление ощущается как PoP, held-state работает.
|
||||
> Всё, что ниже про плейсхолдер-персонажа и приблизительную дугу прыжка,
|
||||
> — уже неправда для активной ветки: в `SprPoP/` стоит настоящая графика
|
||||
> Кида и авторские таблицы кадров (§6). Раздел оставлен ради истории
|
||||
> решений (в частности §5.1 — почему сначала был плейсхолдер).
|
||||
|
||||
**Объём**: одна комната (например Level 1, экран старта Кида), без
|
||||
переходов между экранами, без стражников (стретч-цель, не обязательна).
|
||||
|
||||
**Что показываем**:
|
||||
1. Кид на экране, с закреплённым офлайн-конвертированным набором кадров
|
||||
(подмножество: idle, walk L/R, jump-начало/дуга/приземление, стоп-на-краю,
|
||||
возможно повисание на краю) — атлас в W0-странице, по образцу `rpgwalk`.
|
||||
2. Управление: держать влево/вправо — идёт; отпустил — тормозит/стоит;
|
||||
нажатие вверх во время бега — прыжок вперёд (дуга по авторским таблицам
|
||||
смещений, не по gravity-физике «с нуля» — см. §6). Здесь же проверяется
|
||||
решение по §2 (реальный held-state).
|
||||
3. Столкновения: пол/край экрана/провал — по факту чтения тайла из
|
||||
`BLUETYPE` под ногами (без LINKLOC-триггеров пока).
|
||||
4. Стабильный кадр 50 Гц через уже готовый `gfx_wait_vsync`/дабл-буфер
|
||||
(без FPS-делителя — Кид анимируется каждый видеокадр, как в оригинале).
|
||||
|
||||
**Критерий успеха**: субъективно «прыжок ощущается как в PoP» (дистанция и
|
||||
тайминг прыжка сверены с оригинальными таблицами, не подобраны на глаз —
|
||||
см. §6), управление отзывчивое (не событийное с задержкой), сцена не мерцает
|
||||
на стыке спрайт/фон.
|
||||
|
||||
**Не входит в PoC**: стражники/бой, звук, HUD/таймер, переходы между
|
||||
комнатами, ловушки/триггеры, титры/меню, сохранения.
|
||||
|
||||
**Расположение**: `applications/PoP/poc/` (свой sprinter-cc проект + Python
|
||||
конвертер ассетов, по структуре `examples/rpgwalk`).
|
||||
|
||||
### 5.1 Статус (2026-07-15) — первая итерация: управление + коллизия края
|
||||
|
||||
Сделано и проверено в MAME (`applications/PoP/poc/`, `make run`):
|
||||
держать LEFT/RIGHT (`kbd_raw_down`, raw-канал из §2) — идёт непрерывно,
|
||||
отпустил — стоит на месте (не событийно, реальный held-state);
|
||||
столкновение с краями экрана (клип по `MINX`/`MAXX`); анимация
|
||||
ходьбы/разворота лицом по направлению (`sprite_anim` пинг-понг);
|
||||
дабл-буфер + `gfx_wait_vsync` — без видимого мерцания. Сборка —
|
||||
`--memory huge` без `--bank` (§10, подтверждено рабочим).
|
||||
|
||||
**Важное отступление от плана (осознанно, не молча):** персонаж —
|
||||
ВРЕМЕННАЯ заглушка (лицензированный спрайт-пак
|
||||
`third_party/16x16-RPG-characters` через `tools/gen_kid_placeholder.py`,
|
||||
тот же источник, что уже использует `examples/rpgwalk`), а НЕ
|
||||
конвертированная графика оригинальной Prince of Persia. Причина:
|
||||
исходный набор кадров Кида (`SDLPoP/data/KID`) — копирайт
|
||||
Broderbund/Ubisoft; автоматический конвейер, который систематически
|
||||
извлекает и переупаковывает его в новый формат, — это на практике
|
||||
внутрипроектное решение, которое стоит принимать пользователю явно
|
||||
для каждого шага, а не проводить асинхронно агентом без лишнего
|
||||
подтверждения. Сама графика — не то, что проверяет PoC (§5 явно:
|
||||
цель — ощущение управления/коллизий, не визуальная точность). Замена
|
||||
на настоящую графику Кида — отдельный шаг, на усмотрение пользователя.
|
||||
|
||||
**Ещё не сделано** (следующие итерации §5): авторские таблицы
|
||||
смещений кадров (§6 — движение при ходьбе линейное, px/кадр),
|
||||
реальный уровень/фон по `BLUETYPE`/`LEVEL1` (сейчас — плейсхолдер:
|
||||
плоский пол на весь экран, без ямы/выступа), `kbd_mod_state`/
|
||||
Shift-бег не подключены к игровому циклу (обёртка готова с Фазы A).
|
||||
|
||||
**Прыжок/присед добавлены и ПРОВЕРЕНЫ (2026-07-15)**: состояние
|
||||
`jumping`/`jump_t`/`crouching`, своя приблизительная дуга прыжка
|
||||
(`jump_height[]`, 40 кадров) — не авторская таблица, см. §6.1.
|
||||
HUD-текст статуса (нет отдельной позы).
|
||||
|
||||
Живое тестирование пользователем нашло реальный баг: держа UP чуть
|
||||
дольше 0.8 с (длительность дуги), получали ДВА прыжка подряд — код
|
||||
проверял `kbd_raw_down(KBD_UP)` как уровень (держится, пока клавиша
|
||||
физически зажата), а не как фронт нажатия, поэтому в момент
|
||||
приземления «UP всё ещё зажат» тут же триггерил новый прыжок.
|
||||
Исправлено edge-detect'ом (`up_prev` — предыдущее состояние UP,
|
||||
триггер только на переход 0→1). Проверено брейкпоинтом в отладчике
|
||||
MAME на адресе входа в код прыжка: за одно длинное удержание UP
|
||||
брейкпоинт срабатывает РОВНО ОДИН РАЗ — фикс подтверждён на уровне
|
||||
кода, не только «на глаз».
|
||||
|
||||
Побочный урок (см. `docs/libc-reference.md` `<kbd_raw.h>`): моя
|
||||
более ранняя попытка проверить UP/DOWN/RIGHT по скриншотам после
|
||||
`press_key` ошибочно решила, что скрипт их не нажимает вообще —
|
||||
на самом деле нажимает исправно, просто скриншот ловил случайный
|
||||
момент дуги. Брейкпоинт/watchpoint на конкретный адрес кода —
|
||||
надёжнее скриншота для таких проверок.
|
||||
|
||||
---
|
||||
|
||||
## 6. Модель движения: авторские таблицы кадров, не физика с нуля
|
||||
|
||||
Оригинальный движок PoP не считает прыжок как непрерывную физику
|
||||
(gravity/velocity каждый тик) — движение персонажа задано таблицами кадров
|
||||
анимации, где у части кадров зашито фиксированное смещение (dx, dy) для
|
||||
ЭТОГО конкретного кадра последовательности (структура видна и в
|
||||
исходниках Apple II — `SEQTABLE.S`/`MOVER.S`, и в SDLPoP `seg003.c`/`seq*`
|
||||
таблицах). Практическое следствие для нашего движка:
|
||||
- **Не использовать** `sprite_anim`/`sprite_moveto` для основного
|
||||
персонажа как есть (они лианейно тянут по таймеру/тянут к линейной
|
||||
цели) — вместо этого приложение само на каждый логический тик:
|
||||
переключает кадр (`sprite_frame`, атлас как лента поз, не «прогрессия
|
||||
первый..последний» автоматом) и одновременно применяет dx,dy ЭТОГО
|
||||
кадра к позиции (`sprite_move`).
|
||||
- Готовая автоматика движка (`sprite_anim`/`sprite_moveto`/tween,
|
||||
Y-сортировка) остаётся полезной для декоративных/фоновых элементов
|
||||
(факелы, патрулирующий стражник вне боя — почти один в один паттерн
|
||||
`rpgwalk`).
|
||||
- Источник таблиц смещений: переснять из `Prince-of-Persia-Apple-II/01 POP
|
||||
Source/Source/{MOVER.S,SEQTABLE.S,FRAMEADV.S}` и/или
|
||||
`SDLPoP/src/seq*.c` — задача Фазы 1 полной реализации (§7), не PoC
|
||||
(для PoC можно взять урезанный набор смещений вручную по количеству
|
||||
пикселей на кадр, посчитанному по видео/скриншотам оригинала, и уточнить
|
||||
позже).
|
||||
|
||||
### 6.1 Инструмент конвертации кадров разного размера (`toolchain/png_strip.py`)
|
||||
|
||||
Кадры персонажа в оригинале — РАЗНОГО размера каждый (bbox зависит от
|
||||
позы; `sprite_t` нашего движка (`libbgi/include/sprite.h`) хранит ОДИН
|
||||
фиксированный w/h на весь спрайт и рисует от угла, без per-frame
|
||||
смещения — в отличие от оригинала, где на каждый кадр было своё XCO/YCO
|
||||
(`APPLEII_RESOURCE_FORMAT.md` §2.2). `toolchain/png_strip.py` (генерик,
|
||||
не завязан на PoP — принимает произвольный список PNG) закрывает это
|
||||
ПАДДИНГОМ: канвас = макс. w/h среди кадров ленты, якорь по умолчанию
|
||||
bottom-center («ноги на месте»), остальное — прозрачность.
|
||||
|
||||
**Компромисс, не полноценное решение**: один сильно выбивающийся по
|
||||
размеру кадр в ленте раздувает канвас (и память) ВСЕХ кадров этой же
|
||||
ленты. Смягчается группировкой по похожим размерам в отдельные атласы
|
||||
(не одна лента на все позы актора — так уже сделано для ходьбы отдельно
|
||||
от прыжка).
|
||||
|
||||
**Полноценное решение (кандидат в будущее расширение библиотеки, НЕ
|
||||
делать без предложения и подтверждения пользователя)**: per-frame
|
||||
смещение в `sprite_t` (аналог XCO/YCO оригинала) — тогда паддинг
|
||||
не нужен вообще, экономия памяти по полной. Делать только если память
|
||||
станет РЕАЛЬНОЙ проблемой (не гипотетической) — тогда предложить как
|
||||
отдельную правку `sprite.h`/движка. Подробности компромисса —
|
||||
memory/png_strip_padding_tradeoff.
|
||||
|
||||
---
|
||||
|
||||
## 7. Полноценное приложение — фазы (после PoC)
|
||||
|
||||
Порядок — по риску и зависимостям, не по геймплейной важности.
|
||||
**Отметки статуса — на 2026-08-01.**
|
||||
|
||||
**Фаза 0 — инфраструктура порта** — **СДЕЛАНА**, но иначе, чем задумано:
|
||||
- Хелд-стейт клавиатуры по §2 — сделан.
|
||||
- Конвертер уровней не понадобился: `res200N.bin` из `SDLPoP/data/LEVELS`
|
||||
кладётся на образ как есть и читается по офсетам в рантайме
|
||||
(`src/pop_level.c`), уровень живёт в EMM-странице.
|
||||
- Конвертер фона в растры **отменён осознанно** (§4): фон собирается
|
||||
тайлами в рантайме. Спрайты — `toolchain/pop_pack_bg.py` /
|
||||
`pop_pack_kid.py` / `pop_pack_guard.py` → атласы `.atl` (Kid — 28
|
||||
страниц, риск §8 п.3 закрыт).
|
||||
|
||||
**Фаза 1 — Кид, полный набор действий** — **СДЕЛАНА**: стоять/идти/бежать/
|
||||
тормозить/разворот/прыжки/повисание/подтягивание/спуск/приседание/
|
||||
осторожный шаг/питьё зелья/смерть от провала и от пик; переходы между
|
||||
комнатами во все четыре стороны. Осталось: **старт по данным уровня**
|
||||
(`pop_level_start_*` реализованы, но не подключены) — задача L1-START в
|
||||
`../TASKS_OPEN.md`.
|
||||
|
||||
**Фаза 2 — мир и ловушки** — **СДЕЛАНА**: кнопки/ворота через
|
||||
`LINKLOC`/`LINKMAP`, шипы, loose-полы (тряска, обрушение, щебень, пробой
|
||||
потолка), зелья, дверь уровня (открывается), факелы. Подробности и
|
||||
справочник — `gates_spikes_plan.md`.
|
||||
|
||||
**Фаза 3 — бой** — **СДЕЛАНА в объёме обычного стражника**: подбор и
|
||||
выхватывание меча, стойка, удар/парирование, коллизия клинков, HP обеих
|
||||
сторон, смерть; ИИ стража (замечает Кида, подходит, боевые ветки),
|
||||
персистентность трупа между комнатами.
|
||||
|
||||
**Фаза 4 — разнообразие противников** — **НЕ НАЧАТА**. Скелет нужен на
|
||||
уровне 3, толстый — на 6, тень — на 12, визирь — на 13; привязка
|
||||
«уровень → тип стража» (`tbl_guard_type`) описана в `levels_plan.md` §1.
|
||||
|
||||
**Фаза 5 — звук** — **НЕ НАЧАТА**: CBL-эффекты (шаги, удары, двери,
|
||||
падение) из `digisnd*.dat`→PCM; PC-спикер тройки (`ibm_snd*.dat`) как
|
||||
опциональный дешёвый бипер без CBL, если формат подтвердится простым
|
||||
парсингом. Опкод `SOUND` в `play_seq` пока просто съедает свой аргумент —
|
||||
точки вызова уже на месте.
|
||||
|
||||
**Фаза 6 — оболочка** — **НЕ НАЧАТА**: титры, меню/выбор уровня, HUD
|
||||
(таймер/жизни), сохранение прогресса (FILE*), финальные катсцены — по
|
||||
минимуму, геймплейно не критично. Полоса HP — единственное, что уже есть.
|
||||
|
||||
**Между Фазами 4 и 5 вклинивается то, чего в этом плане не было:
|
||||
переход между УРОВНЯМИ** (загрузка следующего уровня, второй тайлсет
|
||||
palace, потабличные различия уровней). Отдельный документ —
|
||||
`levels_plan.md`.
|
||||
|
||||
**Фаза 7 — стабилизация**: полный прогон всех 14 уровней в MAME
|
||||
(`mame_interactive.py`), затем на реальном железе; профилирование бюджета
|
||||
кадра по методике `sprite_engine_perf`/`sprite-api-design.md` §9д на самых
|
||||
насыщенных экранах (несколько стражников + ловушки одновременно —
|
||||
проверить лимит ~21 спрайт/кадр и Y-sort лимит 32); при необходимости —
|
||||
банкинг (`--memory big/huge`) для кода/уровня, если размер вылезет за
|
||||
tiny/small.
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски, требующие спайка/артефакта до架构 решений
|
||||
|
||||
(по правилу `defer_unexplained_quirks` — не гадать, проверять)
|
||||
|
||||
1. ~~**Held-state клавиатуры** (§2)~~ — **закрыт** (`<kbd_raw.h>`). Открытый
|
||||
остаток — не «есть ли held-state», а потеря байт при аккордах
|
||||
Shift+стрелка: `../PoP/SprPoP/TASKS_CLOSED.md`, KBD-1.
|
||||
2. **Бюджет кадра** — риск подтвердился, но не в том виде, в каком ожидался:
|
||||
спрайтовый движок для персонажей не используется, поэтому лимит
|
||||
«~21 спрайт/кадр» неприменим. Реальный бюджет упирается в heal+блиты и
|
||||
перерисовку тайлов; замер 2026-07-30 — типичный кадр ~371 К тактов
|
||||
(~86 % периода). Инструмент замера уже в коде: полосы бордюра `PROF()`
|
||||
в `sprpop.c`. План выжимания — `../PoP/SprPoP/TASKS_CLOSED.md` (CLIP-1) и
|
||||
`../BUGS_OPEN.md` (T-1/T-2).
|
||||
3. ~~**Ёмкость атласа на актора**~~ — **закрыт**: Kid разложен на 28
|
||||
атласов-страниц по 8 спрайтов (`pop_pack_kid.py`), страж — на 5;
|
||||
мульти-страничного формата `.atl` не потребовалось. Побочно
|
||||
подтвердился компромисс паддинга (§6.1).
|
||||
4. **Тайминг оригинала** — **ОТКРЫТ, и сверка 2026-08-01 показывает
|
||||
расхождение.** Цифры оригинала (SDLPoP): базовый таймер `BASE_FPS = 60`
|
||||
(`types.h:1373`), логический кадр игры — `base_speed = 5` тиков
|
||||
(`data.h:869`), то есть **83.3 мс (12 лог. кадров/с)**; в бою
|
||||
`fight_speed = 6` → **100 мс (10/с)**. У нас (`sprpop.c`) — три
|
||||
ожидания `gfx_wait_vsync()` на итерацию, то есть **60 мс (16.7/с)** и
|
||||
без отдельной скорости боя. Значит **игра идёт примерно на 39 %
|
||||
быстрее эталона**. Точное соответствие даёт 4 ожидания vsync (80 мс
|
||||
против 83.3) и 5 в бою (100 мс — совпадает точно).
|
||||
Проверять не «на глаз», а секундомером по одинаковому отрезку
|
||||
(SDLPoP рядом на том же экране), и только после того, как кадр
|
||||
перестанет иногда вылезать за период (см. п.2) — иначе замедление
|
||||
спрячет проблему бюджета вместо того, чтобы её показать.
|
||||
|
||||
---
|
||||
|
||||
## 10. Режим памяти сборки
|
||||
|
||||
Пользователь предложил `huge` (горячий код в W1, данные в W2, редко
|
||||
вызываемая логика — банками в W3) как целевой режим. Согласен, с уточнением
|
||||
по срокам принятия решения.
|
||||
|
||||
**`huge` — правильная цель для ПОЛНОГО приложения**, но не то, с чего надо
|
||||
стартовать:
|
||||
|
||||
- Layout `huge` (см. `memory_modes_implemented`): CODE_LOC=0x4100 (W1),
|
||||
DATA_LOC=0x8000 (W2), банки — W3 (порт 0xE2), `crt0_banked` +
|
||||
автодетект W2 (как `small`). Состояние приложения (структуры Кида,
|
||||
уровня, массив `sprite_t`) остаётся в обычном W2-heap ДАЖЕ если код,
|
||||
который его трогает, забанкован — `malloc` из банка возвращает
|
||||
W2-указатель (`bank_local_data_pattern`), так что данные не привязаны к
|
||||
конкретному банку.
|
||||
- Оверхед `__banked`-вызова (trampoline: +3 байта на стеке между ret и
|
||||
аргументами, виртуальный 24-битный адрес, см. `sdcc_banking`) — фиксированная
|
||||
небольшая цена ЗА ВЫЗОВ, не за такт. Это не страшно для функций, которые
|
||||
вызываются РЕДКО за кадр (AI одного стражника, диалог, переход между
|
||||
комнатами) — страшно было бы забанковать что-то, что дёргается ВНУТРИ
|
||||
горячего цикла отрисовки (там уже и так основной бюджет уходит на
|
||||
`sprite_update`/блиты — см. `sprite_engine_perf`, ~19.5К тактов/спрайт).
|
||||
Правило простое: **не банковать код на пути "раз в кадр на объект",
|
||||
банковать код на пути "раз в кадр на комнату/раз в переход/раз в
|
||||
редкое событие"**: логика ИИ стражника целиком, диалоги/катсцены, меню/
|
||||
титры/выбор уровня, парсинг уровня при входе в комнату, сериализация
|
||||
сохранений — хорошие кандидаты в банки; тик Кида, чтение столкновений,
|
||||
вызов `sprite_update`/`gfx_wait_vsync`, обработка ввода — должны остаться
|
||||
небанкованными (W1/W2).
|
||||
- Гранулярность банкования — целый файл (`--bank N=FILE.c`), это уже
|
||||
системный паттерн проекта (тот же принцип, что и «1 файл = 1 юнит DCE» в
|
||||
libc) — значит выгодно с САМОГО начала Фазы 1 (не задним числом) резать
|
||||
исходники приложения по границе «горячее/холодное» файл-в-файл: например
|
||||
`kid_tick.c`/`collision.c`/`room.c`/`input.c` — неизменно вне банков;
|
||||
`guard_ai_*.c`/`dialogue.c`/`menu.c`/`levelload.c`/`combat.c` — кандидаты
|
||||
под `--bank`. Тогда переход на `huge` позже — это правка Makefile/
|
||||
sprinter-cc-вызова (`--memory huge --bank N=file.c ...`), а не рефакторинг
|
||||
логики.
|
||||
|
||||
**Уточнение (проверено в `bin/sprinter-cc`, строки ~342-350): можно сразу
|
||||
собирать PoC на `--memory huge` без единого `--bank`.** Скрипт сам
|
||||
подставляет стаб `const unsigned char n_banks = 0;`, когда `--bank` не
|
||||
передан ни один раз — `crt0_banked` линкуется и корректно пропускает цикл
|
||||
загрузки банков при старте. Layout при этом byte-в-byte совпадает с тем,
|
||||
что делает `crt0_small` для режима `small` (CODE 0x4100/W1, DATA 0x8000/W2,
|
||||
автодетект W2) — разница только в том, что попутно линкуется сам
|
||||
`bank.s` (таблица `_bank_pages` + trampoline-инфраструктура), это
|
||||
незначительный довесок к размеру, не к рантайм-цене. Значит **PoC можно
|
||||
сразу собирать вызовом `sprinter-cc --memory huge` без `--bank`-флагов** —
|
||||
и когда в полном приложении появятся первые «холодные» файлы, переход на
|
||||
банкование — это просто добавление `--bank N=file.c`, без смены
|
||||
`--memory`/адресов/crt0. Сборочная конфигурация не потребует миграции
|
||||
между PoC и полным приложением.
|
||||
|
||||
Единственное, что стоит сделать уже в Фазе 1 полного приложения (не в
|
||||
PoC) — планировать структуру исходников с расчётом на будущий файл-в-файл
|
||||
сплит под банки (см. выше), раз гранулярность банкования — целый файл.
|
||||
|
||||
---
|
||||
|
||||
## 9. Что нужно от пользователя, прежде чем двигаться дальше
|
||||
|
||||
- Подтверждение направления по §2 (какой из трёх вариантов held-state
|
||||
клавиатуры пробовать первым, или сначала спайк-эксперимент в MAME).
|
||||
- Подтверждение объёма PoC (§5) — устраивает ли «одна комната без
|
||||
стражников», или сразу закладывать хотя бы одного патрулирующего
|
||||
стражника (это не архитектурно сложнее — Y-order и tween уже есть,
|
||||
просто больше конвертации ассетов).
|
||||
@@ -0,0 +1,283 @@
|
||||
# Формат ресурсов Prince of Persia (Apple II, оригинальные исходники 1989)
|
||||
|
||||
Источник — официально опубликованные Джорданом Мехнером исходники
|
||||
(`Prince-of-Persia-Apple-II/`, 6502-ассемблер). В отличие от DOS-версии, здесь
|
||||
формат восстановлен **напрямую по коду**, а не по догадкам о байтах —
|
||||
уверенность высокая везде, где указана ссылка на конкретный файл/строки.
|
||||
|
||||
---
|
||||
|
||||
## 1. Формат уровня (`01 POP Source/Levels/LEVEL0`…`LEVEL14`, 2304 байта)
|
||||
|
||||
Файлы уровня — это побайтовый дамп структуры `blueprnt`, которая грузится по
|
||||
фиксированному адресу `$b700` (`EQ.S:28`) и объявлена как `dum blueprnt` в
|
||||
`EQ.S:258-266`. Никакого отдельного заголовка файла нет — это чистый образ
|
||||
структуры в памяти:
|
||||
|
||||
| Поле | Размер | Смещение в файле | Описание |
|
||||
|---|---|---|---|
|
||||
| `BLUETYPE` | 720 Б | 0–719 | 24 экрана × 30 тайлов: тип объекта/тайла |
|
||||
| `BLUESPEC` | 720 Б | 720–1439 | 24 экрана × 30 тайлов: доп. байт состояния объекта |
|
||||
| `LINKLOC` | 256 Б | 1440–1695 | Таблица связей нажимных плит/дверей, часть 1 |
|
||||
| `LINKMAP` | 256 Б | 1696–1951 | Таблица связей, часть 2 |
|
||||
| `MAP` | 96 Б | 1952–2047 | 24 экрана × 4 байта: граф соседних экранов |
|
||||
| `INFO` | 256 Б | 2048–2303 | Метаданные уровня: старт Кида, стражников и т.д. |
|
||||
|
||||
Сумма: 720+720+256+256+96+256 = **2304** — точно совпадает с размером файла,
|
||||
что подтверждает: это чистый дамп структуры, без обёртки.
|
||||
|
||||
### 1.1 Сетка тайлов (`BLUETYPE` / `BLUESPEC`)
|
||||
|
||||
Каждый экран — ровно **30 тайлов** (10 столбцов × 3 ряда): подтверждено
|
||||
таблицами `BlockTable`/`BlockEdge` (`TABLES.S:74-154`) и логикой перехода
|
||||
между экранами в `CTRLSUBS.S:218-234` (при переходе через край экрана
|
||||
`tempblockx` меняется на ±10, `tempblocky` — на ±3).
|
||||
|
||||
Функция `CALCBLUE` (`GRAFIX.S:1757-1784`) вычисляет для экрана 1–24:
|
||||
`BlueType = blueprnt + (screen-1)*30`, `BlueSpec = BlueType + 24*30`,
|
||||
используя таблицу `Mult30` (`TABLES.S:131-140`).
|
||||
|
||||
Байт `BLUETYPE` упакован битовыми полями (`EQ.S:484-486`):
|
||||
|
||||
```
|
||||
бит 7-6: secmask (%11000000) — назначение не установлено по доступному коду
|
||||
(возможно, служебное поле редактора)
|
||||
бит 5: reqmask (%00100000) — флаг "необходимая опорная плитка"
|
||||
(проверяется в BREAKLOOSE, MOVER.S:395-397)
|
||||
бит 4-0: idmask (%00011111) — тип тайла/объекта, 0-29
|
||||
```
|
||||
|
||||
Перечень 30 типов объектов (`MOVEDATA.S:8-37`):
|
||||
|
||||
```
|
||||
0 space 8 pillarbottom 16 exit 24 window2
|
||||
1 floor 9 pillartop 17 exit2 25 archbot
|
||||
2 spikes 10 flask 18 slicer 26 archtop1
|
||||
3 posts 11 loose 19 torch 27 archtop2
|
||||
4 gate 12 panelwof 20 block 28 archtop3
|
||||
5 dpressplate 13 mirror 21 bones 29 archtop4
|
||||
6 pressplate 14 rubble 22 sword
|
||||
7 panelwif 15 upressplate 23 window
|
||||
```
|
||||
|
||||
Проверено вручную на дампе начала `LEVEL1` (`00 00 00 21 01 21 21 21 34 34
|
||||
33 33 21 23 00 34 14 14 14 34 14 34 34 2e 23 0b 01 21 34`) — например,
|
||||
`0x33 → id=0x13=19 (torch)`+reqmask, `0x34 → id=20 (block)`+reqmask —
|
||||
декодирование по таблице сходится чисто.
|
||||
|
||||
`BLUESPEC` — доп. байт, чья семантика зависит от типа тайла (единой схемы
|
||||
нет, разбирается объект-специфичным кодом):
|
||||
|
||||
- **gate** (дверь, `FRAMEADV.S:2222-2234`): на диске — маленький enum (1 =
|
||||
начинает открытой сверху, 2 = снизу, …), который через `initsettings`
|
||||
(`FRAMEADV.S:22-23`, диапазон `gminval=0`..`gmaxval=188`, из
|
||||
`MOVEDATA.S:56-57`) при инициализации уровня превращается в живой счётчик
|
||||
"высоты двери" 0–188.
|
||||
- **loose** (шаткая плитка, `FRAMEADV.S:2224-2237`): при инициализации всегда
|
||||
принудительно обнуляется, независимо от значения на диске.
|
||||
- **flask** (зелье, `FRAMEADV.S:2226,2239-2246`): значение×32 выбирает
|
||||
цвет/тип зелья.
|
||||
- **spikes** (шипы, `MOVER.S:365-382`, константы `spikeExt=5, spikeRet=9` в
|
||||
`MOVEDATA.S:45-46`): 0 = безопасно/убраны, 1–8 — кадр анимации
|
||||
выдвижения/втягивания, `$FF` = навсегда заклинило (тело наколото).
|
||||
- **pressplate/upressplate** (нажимные плиты, `MOVER.S:425-464`,
|
||||
`FRAMEADV.S:2059-2098`): значение — это **индекс в цепочке связей**
|
||||
`LINKLOC`/`LINKMAP` (см. ниже); младшие 5 бит `LINKMAP` по этому индексу
|
||||
одновременно служат счётчиком таймера плиты (0–31), определяющим
|
||||
состояние "поднято/опущено".
|
||||
|
||||
### 1.2 `LINKLOC` / `LINKMAP` — граф триггеров (нажимные плиты → двери и т.п.)
|
||||
|
||||
Два параллельных массива по 256 байт кодируют цепочки "нажатие плиты X →
|
||||
сработать объект на экране S, блок B". Восстановлено из `MOVER.S:506-537`
|
||||
(цикл `trigger`) и `MOVER.S:1549-1581` (`gettimer/chgtimer/getloc/
|
||||
getlastflag/getscrn`):
|
||||
|
||||
```
|
||||
LINKLOC[i]: бит 7 = флаг "последнее звено цепочки"
|
||||
биты 6-5 = младшие 2 бита номера целевого экрана
|
||||
биты 4-0 = номер целевого блока (0-29); $FF = "никуда не привязано"
|
||||
|
||||
LINKMAP[i]: биты 7-5 = старшие 3 бита номера целевого экрана
|
||||
(вместе с LINKLOC биты 6-5 → полный номер экрана 0-31)
|
||||
биты 4-0 = таймер обратного отсчёта плиты (0-31, значим только
|
||||
по индексу самой плиты)
|
||||
```
|
||||
|
||||
`BLUESPEC` плиты хранит индекс `i` её *первого* звена; `getlastflag` идёт
|
||||
вперёд (`inc linkindex`), пока не встретит бит 7 в `LINKLOC`. Сверено на
|
||||
`LEVEL1`: байт по смещению 1440 (`0x89 = 10001001` → флаг конца цепочки,
|
||||
целевой блок 9) и параллельно байт по смещению 1696 (`0x60 = 01100000` →
|
||||
старшие биты номера экрана) — согласуется с этой раскладкой. Заполнены
|
||||
реально используемые уровнем звенья, остальное — "мусорные" повторяющиеся
|
||||
байты-заполнители.
|
||||
|
||||
### 1.3 `MAP` — граф соседних экранов
|
||||
|
||||
24 записи × 4 байта = 96 байт: для каждого экрана (1–24)
|
||||
`MAP[(scrn-1)*4 + 0..3] = левый, правый, верхний, нижний соседние экраны`,
|
||||
читается через `GETLEFT/GETRIGHT/GETUP/GETDOWN` (`CTRLSUBS.S:244-274`,
|
||||
индексация `MAP-4..MAP-1,x` при `x = scrn*4`). Экран `0` зарезервирован как
|
||||
"нет экрана" (проверка `beq ]rts` в этих же процедурах).
|
||||
|
||||
### 1.4 `INFO` — метаданные уровня (256 байт, база = смещение файла 2048)
|
||||
|
||||
Объявлено как `dum INFO` в `EQ.S:272-288`:
|
||||
|
||||
| Смещение (от начала INFO) | Поле | Размер |
|
||||
|---|---|---|
|
||||
| 0 | "число экранов + 1" (используется в `SETINITIALS`, `SUBS.S:1441-1445`) | 1 |
|
||||
| 1–63 | резерв/не используется | 63 |
|
||||
| 64 | `KidStartScrn` | 1 |
|
||||
| 65 | `KidStartBlock` | 1 |
|
||||
| 66 | `KidStartFace` (направление; при загрузке инвертируется XOR `$ff`, `SUBS.S:1516-1518`) | 1 |
|
||||
| 67 | заполнитель | 1 |
|
||||
| 68 | `SwStartScrn` (стартовый экран меча) | 1 |
|
||||
| 69 | `SwStartBlock` | 1 |
|
||||
| 70 | заполнитель | 1 |
|
||||
| 71–94 | `GdStartBlock[1..24]` — стартовый блок стражника на экране; **≥30 = "стражника нет"** (`AUTO.S:1832-1834`, `SUBS.S:1677-1679`) | 24 |
|
||||
| 95–118 | `GdStartFace[1..24]` (86 = "стражника нет", см. `ShadFace cmp #86` по всему `AUTO.S`) | 24 |
|
||||
| 119–142 | `GdStartX[1..24]` — пересчитывается заново из блока при старте уровня, значение на диске почти не используется (`SUBS.S:1674-1690`) | 24 |
|
||||
| 143–166 | `GdStartSeqL[1..24]` | 24 |
|
||||
| 167–190 | `GdStartProg[1..24]` — "программа"/поведение ИИ стражника | 24 |
|
||||
| 191–214 | `GdStartSeqH[1..24]` — обнуляется при старте (`SUBS.S:1685-1686`) | 24 |
|
||||
| 215–255 | резерв/не используется | 41 |
|
||||
|
||||
Проверено на `LEVEL1`: байт по смещению файла 0x800 = `0x18`=24 (число
|
||||
активных экранов = 23+1); по смещению 0x840 — `01 00 ff 00 00 00 ff 1e 1e
|
||||
11 1e 1e ...` → `KidStartScrn=1, KidStartBlock=0, KidStartFace=$FF,
|
||||
SwStartScrn=0, SwStartBlock=0`, далее 24 байта `GdStartBlock`, в основном
|
||||
`0x1e`(30, "нет стражника"), с реальной расстановкой только на экране 3
|
||||
(`0x11`=17) и экране 23 (`0x06`) — согласуется с уровнем, где всего два
|
||||
стражника.
|
||||
|
||||
### 1.5 Как уровень попадает с диска (важно: имя файла — не игровой механизм)
|
||||
|
||||
В рантайме нет чтения "по имени файла LEVELn" — это чисто утилита для
|
||||
экспорта в этом репозитории. Реально `LOADLEVELX` (`MISC.S:795-809`)
|
||||
использует фиксированные таблицы по номеру уровня `bluepTRKlst`/
|
||||
`bluepREGlst` (`MISC.S:776-787`), дающие физическую **дорожку (1-33)** и
|
||||
**регион (0/1)**, затем `rdbluep` (`MASTER.S:598-616`) вызывает
|
||||
низкоуровневое чтение `rw18` (`RdGrpErr`) 9 физических групп по 256 байт
|
||||
(`$b7-$bf`) — 9×256=2304 байта — прямо в буфер blueprint; регион 0/1 выбирает
|
||||
половину 18-секторной дорожки (два уровня делят одну дорожку). Файлы
|
||||
`LEVELn` в этом репозитории — реконструкция этого сырого блока для удобства
|
||||
работы с инструментами.
|
||||
|
||||
---
|
||||
|
||||
## 2. Формат изображений/спрайтов (`IMG.CHTAB1-7`, `IMG.BGTAB1/2.DUN/.PAL`)
|
||||
|
||||
Каждый такой файл грузится целиком по **фиксированному адресу**, заданному
|
||||
константами `chtableN`/`bgtableN` (`GAMEEQ.S:9-18`):
|
||||
|
||||
```
|
||||
chtable1=$6000 chtable2=$8400 chtable3=$0800 chtable4=$9600
|
||||
chtable5=$a800 chtable6=$6000 chtable7=$9f00
|
||||
bgtable1=$6000 bgtable2=$8400
|
||||
```
|
||||
— то есть смещения внутри файла один-в-один совпадают с адресами в памяти
|
||||
после загрузки.
|
||||
|
||||
### 2.1 Раскладка контейнера
|
||||
|
||||
Восстановлено из заголовка-комментария "Image table format" в `HIRES.S:181-186`,
|
||||
процедуры разрешения указателя `setimage` (`HIRES.S:263-277`) и
|
||||
`GETWIDTH`/`PREPREP` (`HIRES.S:283-339`):
|
||||
|
||||
```
|
||||
Смещение 0 : 1 байт — число изображений в таблице (максимум 127,
|
||||
в образцах встречается 0x7f)
|
||||
Смещение 1..254 : 127 × 2-байтных little-endian указателей
|
||||
(указатель на изображение N — по смещению 1+(N-1)*2,
|
||||
N=1..127) — АБСОЛЮТНЫЕ адреса в адресном пространстве
|
||||
фиксированной загрузки этой таблицы, указывающие на
|
||||
запись данных этого изображения
|
||||
Смещение 255 : заполнитель (таблица указателей занимает ровно 256 байт)
|
||||
Смещение 256 (база+0x100) и далее:
|
||||
последовательно идущие записи данных изображений:
|
||||
байт 0: ширина (в байтах на строку)
|
||||
байт 1: высота (число строк)
|
||||
байты 2..(2+ширина*высота-1): сырые байты пикселей,
|
||||
слева направо, сверху вниз, БЕЗ сжатия
|
||||
```
|
||||
|
||||
Проверено вручную на `IMG.BGTAB1.DUN`: с точной арифметикой индексов из
|
||||
`setimage` (`Y = image*2 - 1`, `HIRES.S:264-267`) первые ~30 записей дают
|
||||
строго возрастающую последовательность указателей `0x6101, 0x6133, 0x6159,
|
||||
0x618b, 0x61c9, 0x61fb, 0x6221, 0x6313, 0x63c9, ...` — указатель
|
||||
изображения #1 приходится ровно на `bgtable1 ($6000) + 0x100`, то есть точно
|
||||
на конец 256-байтной таблицы указателей. Это независимо подтверждает и
|
||||
размер таблицы, и семантику указателей.
|
||||
|
||||
**Важно: сжатия в этом формате нет.** RLE/дельта-упаковка (`SngExpand`/
|
||||
`DblExpand`/`DeltaExpPop`/`DeltaExpWipe` в `01 POP Source/Source/UNPACK.S`)
|
||||
применяется только к полноэкранным изображениям (титры/пролог/катсцены), но
|
||||
не к CHTAB/BGTAB — спрайты и фоновые тайлы хранятся как чистые упакованные
|
||||
байты hi-res/double-hi-res экрана Apple II, без какого-либо RLE или дельты.
|
||||
|
||||
### 2.2 Параметры отрисовки (не часть файла ресурса)
|
||||
|
||||
При выводе спрайта (`LAY`/`FASTLAY`/`PEEL` и т.д., `HIRES.S:658-1740`)
|
||||
используются zero-page параметры `PAGE/XCO/YCO/OFFSET/IMAGE/OPACITY/TABLE/
|
||||
BANK` (описаны в `HIRES.S:155-178`): `OFFSET` (0–6) — горизontальный сдвиг на
|
||||
под-байтовый пиксель, `OPACITY` выбирает режим совмещения (AND/OR/STA/XOR/
|
||||
маска-OR) плюс отдельный бит горизонтального зеркалирования (бит 7). Это
|
||||
чисто рантайм-параметры отрисовки, не хранящиеся в файле ресурса. Точный
|
||||
механизм барабанного сдвига для `OFFSET` (таблицы `HRTABLES.S`/`YLO`/`YHI`)
|
||||
не прослежен до конца — при необходимости требует отдельного анализа.
|
||||
|
||||
### 2.3 Инструмент DRAZ (авторская утилита создания спрайтов)
|
||||
|
||||
В `04 Support/DRAZ` нет исходников самой утилиты DRAZ — только файлы данных
|
||||
(`PAC.*` — позы персонажей, и уже скомпилированные `IMG.*`), поэтому
|
||||
внутренний пайплайн DRAZ (как позы превращаются в CHTAB) напрямую не виден.
|
||||
Формат контейнера выше выведен полностью из кода движка-потребителя, что
|
||||
является надёжным, но косвенным источником.
|
||||
|
||||
Отдельно: в игровой логике списков объектов (`ADDBACK`, `GRAFIX.S:191-214`)
|
||||
встречается **рантайм-упаковка ссылки на фоновую картинку** в один байт: бит
|
||||
7 выбирает `bgtable1` или `bgtable2`, биты 0-6 — номер картинки в таблице
|
||||
(0-63). Это соглашение для внутриигровых списков объектов (`bgIMG` и т.п.), а
|
||||
не свойство самих файлов CHTAB/BGTAB на диске.
|
||||
|
||||
---
|
||||
|
||||
## 3. "Главного индекса ресурсов" не существует
|
||||
|
||||
В отличие от DOS-версии (см. `docs/MSDOS_RESOURCE_FORMAT.md`), в рантайм-коде
|
||||
Apple II **нет обобщённого справочника "имя ресурса → расположение на
|
||||
диске"**. Расположение каждого ресурса зашито напрямую как таблицы
|
||||
дорожка/группа-секторов прямо в коде загрузчика:
|
||||
|
||||
- Уровни: `bluepTRKlst`/`bluepREGlst` (`MISC.S:776-787`), используются
|
||||
`LOADLEVELX`/`LOADLEVEL` (`MISC.S:795-809`, `MASTER.S:467-481`).
|
||||
- Альтернативные наборы фонов/персонажей: `bg1trk`/`bg2trk`/`ch4trk`/`ch4off`
|
||||
(`MASTER.S:522-528`).
|
||||
- Массовая загрузка при старте (chtable1-7, bgtable1-2, seqtable и т.д.):
|
||||
прямые вызовы `rw18`/`RdGrp`/`RdSeq` с литеральными hex-списками
|
||||
групп-секторов в `MASTER.S:1250-1360` и `BOOT.S:100-118`.
|
||||
|
||||
Весь дисковый ввод-вывод идёт через нестандартный низкоуровневый драйвер
|
||||
`rw18` (`rw18 = $d000`, `EQ.S:11-12`; папка `02 POP Disk Routines/RW1835`),
|
||||
реализующий нестандартный формат **18 секторов/дорожку** (вместо 16 у
|
||||
стандартного DOS 3.3) — этим объясняется, почему регионы уровня (9×256Б)
|
||||
идут парами на одной физической дорожке. Символические имена
|
||||
`chtableN`/`bgtableN` в `GAMEEQ.S` — ближайший аналог "индекса ресурсов", но
|
||||
они связывают ресурс с **фиксированным адресом в ОЗУ**, а не с положением на
|
||||
диске; связь с диском — отдельная, вручную сопровождаемая таблица,
|
||||
сопоставленная с ресурсом лишь порядком вызовов загрузчика.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что ещё не восстановлено (открытые вопросы)
|
||||
|
||||
- Точное назначение бит `secmask` (%11000000) в `BLUETYPE` — не встречено
|
||||
использование в доступном игровом коде (возможно, поле только для
|
||||
редактора уровней, не читается движком).
|
||||
- Механизм барабанного сдвига `OFFSET` для суб-байтового позиционирования
|
||||
спрайта по X (`HRTABLES.S`) — не прослежен в деталях.
|
||||
- Внутренний формат авторских файлов `PAC.*` инструмента DRAZ (как позы
|
||||
скелетной анимации превращаются в растровые кадры CHTAB) — исходники DRAZ
|
||||
отсутствуют в репозитории, можно только косвенно восстановить по
|
||||
результату (уже скомпилированным `IMG.*`).
|
||||
@@ -0,0 +1,301 @@
|
||||
# Формат ресурсов Prince of Persia (MS-DOS, каталог `MSDOS/`)
|
||||
|
||||
Документ описывает бинарный формат `*.DAT`-файлов ресурсов DOS-версии PoP.
|
||||
Исходников для этой версии нет, поэтому всё, что ниже — результат
|
||||
структурного (эмпирического) анализа реальных файлов из `MSDOS/`, а не чтения
|
||||
кода. Уровень уверенности указан для каждого раздела. Все находки проверены
|
||||
скриптами (Python), которые разбирают файл и валидируют согласованность
|
||||
(например: смещение+размер последней записи таблицы точно совпадает с
|
||||
началом самой таблицы — то есть данные и каталог стыкуются без дыр).
|
||||
|
||||
**Основной источник спецификации формата — `POP-DAT-FormatSpecifications.pdf`**
|
||||
(и его текстовая конверсия `POP-DAT-FormatSpecifications.txt` в этой же папке,
|
||||
для grep/цитирования): *«Prince of Persia — Specifications of File Formats»*,
|
||||
Princed Development Team, 2008 — каноническая спецификация формата `DAT v1.0`,
|
||||
на которой построен и SDLPoP, и Princed Resources. Разбирает контейнер, индекс,
|
||||
чек-сумму, кодеки изображений (RLE / LZG), палитры, формат уровней (room
|
||||
mapping, wall-drawing, room-linking, guards, start position, door events),
|
||||
звук (digital waves / MIDI / PC speaker), бинарные файлы и Mac-варианты. При
|
||||
любом расхождении между эмпирическими находками ниже и этим документом —
|
||||
источником истины считать спецификацию (сверять §-номера: её §3.x).
|
||||
|
||||
Дополнительно как справка при реализации (порт на ZX Sprinter):
|
||||
|
||||
- **SDLPoP** (github.com/NagyD/SDLPoP, GPLv3) — open-source реализация
|
||||
DOS-версии на основе дизассемблирования оригинального `PRINCE.EXE`. Содержит
|
||||
рабочий код чтения `.DAT`-файлов и полный кодек изображений/уровней. Точные
|
||||
структуры (`dat_table_type` и т.п.), процитированные ниже, получены через
|
||||
автоматический пересказ содержимого файла третьей стороной, а не через
|
||||
прямое чтение исходника — поэтому такие детали помечены как "требует сверки
|
||||
при реализации", в отличие от эмпирически подтверждённых байтовых оффсетов.
|
||||
- **Princed Resources / PR** (github.com/NagyD/PR, princed.org, GPLv2) — это
|
||||
профильный инструмент именно для распаковки/запаковки `.DAT`-ресурсов PoP
|
||||
(версии DAT 1 и 2), сделанный тем же автором. В его документации
|
||||
(`doc/Dataformats.md`) официально описаны экспортные форматы ресурсов —
|
||||
это подтверждает и уточняет часть находок ниже (см. §3–4), и является более
|
||||
надёжным источником, чем самостоятельная догадка по байтам.
|
||||
|
||||
**Важная находка:** репозиторий SDLPoP в папке `data/` содержит не только
|
||||
код движка, но и **реальные ресурсы игры** — как сырые `.DAT`-контейнеры, так
|
||||
и уже распакованные поштучно файлы (PNG-кадры спрайтов, `.pal`-палитры,
|
||||
`.bin`-дампы уровней), см. §7. Это готовый источник ассетов и одновременно
|
||||
независимая проверка формата, описанного в этом документе.
|
||||
|
||||
---
|
||||
|
||||
## 1. Общий контейнер `.DAT` (уверенность: высокая, подтверждено на 28 файлах)
|
||||
|
||||
Каждый `*.DAT`-файл (кроме служебных `config.dat`/`setup.dat`, см. §5) — это
|
||||
простой архив-контейнер: блок данных + оглавление (каталог ресурсов) в конце
|
||||
файла.
|
||||
|
||||
### 1.1 Заголовок файла (6 байт, смещение 0x00)
|
||||
|
||||
| Смещение | Размер | Поле | Значение |
|
||||
|----------|--------|--------------|----------|
|
||||
| 0x00 | 4 | `tableOffset`| LE u32. Абсолютное смещение в файле, с которого начинается таблица оглавления. Совпадает с "концом данных". |
|
||||
| 0x04 | 2 | `tableSize` | LE u16. Размер таблицы оглавления в байтах. |
|
||||
|
||||
Инвариант, подтверждённый на всех 28 `.dat`-файлах в каталоге:
|
||||
|
||||
```
|
||||
tableOffset + tableSize == размер файла (без исключений)
|
||||
```
|
||||
|
||||
Данные ресурсов идут сразу после заголовка, начиная с байта 0x06, и
|
||||
заканчиваются на `tableOffset`.
|
||||
|
||||
### 1.2 Таблица оглавления (по смещению `tableOffset`, длиной `tableSize`)
|
||||
|
||||
Таблица — плоский массив записей по 8 байт. Количество записей:
|
||||
`tableSize / 8` (округление вниз; в файле почти всегда остаётся 2 "лишних"
|
||||
байта в хвосте таблицы — назначение не установлено, вероятно, служебное поле
|
||||
инструмента-упаковщика или паддинг; на итоговый разбор не влияет).
|
||||
|
||||
Запись (8 байт):
|
||||
|
||||
| Смещение в записи | Размер | Поле | Описание |
|
||||
|---|---|---|---|
|
||||
| 0 | 2 | `size` | LE u16 — размер данных ресурса в байтах |
|
||||
| 2 | 2 | `id` | LE u16 — идентификатор ресурса |
|
||||
| 4 | 2 | `offset` | LE u16 — **абсолютное** смещение данных ресурса в файле (не относительное!) |
|
||||
| 6 | 2 | `reserved` | во всех проверенных записях (сотни штук) всегда `0x0000` |
|
||||
|
||||
Проверено на `levels.dat`: 16 записей, `id`=2000..2015, и `offset[i] + size[i]
|
||||
== offset[i+1]` для всех соседних записей, а последняя запись заканчивается
|
||||
ровно на `tableOffset` — то есть данные абсолютно плотно упакованы, без
|
||||
пробелов, для этого файла. В других файлах (например `guard.dat`) между
|
||||
записями изредка есть небольшие зазоры в несколько байт (вероятно, выравнивание
|
||||
или "мёртвые" байты от инструмента-компоновщика) — не является нарушением
|
||||
формата.
|
||||
|
||||
### 1.3 Диапазоны `id` по типам файлов (собрано эмпирически)
|
||||
|
||||
Похоже, что числовые ID образуют условные "пространства имён" по типу
|
||||
контента — вероятно, глобальные константы в оригинальном коде:
|
||||
|
||||
| Файл(ы) | Диапазон `id` | Кол-во записей | Предполагаемое содержимое |
|
||||
|---|---|---|---|
|
||||
| `levels.dat` | 2000–2015 | 16 | id=2000 — служебный блок (16 байт, см. §3); id=2001..2015 — 15 уровней |
|
||||
| `guard.dat`, `fat.dat`, `skel.dat`, `shadow.dat` | 750–784 (варьируется) | ~30–35 | id=751(750) — служебный блок; остальные — кадры анимации спрайта |
|
||||
| `vizier.dat` | аналогично guard | — | кадры анимации визиря |
|
||||
| `kid.dat` | ~400+ | 220 | кадры анимации игрока (намного больше — герой умеет гораздо больше действий) |
|
||||
| `guard1.dat`, `guard2.dat` | 750 (1 запись) | 1 | вероятно, дополнительные/альтернативные кадры/варианты |
|
||||
| `title.dat` | 40–55 | 12 | картинки титульного экрана/логотипов |
|
||||
| `cpalace.dat`,`epalace.dat`,`vpalace.dat`,`cdungeon.dat`,`edungeon.dat`,`vdungeon.dat` | 200–1343 | 205–238 | фоновые тайлы дворца/подземелья, отдельно для CGA(`c*`)/EGA(`e*`)/VGA(`v*`) |
|
||||
| `pv.dat` | 800–981 | 103 | доп. графика (возможно, "Prince/Vizier" катсцены) |
|
||||
| `digisnd1/2/3.dat` | 10000+ | 20–44 | оцифрованный звук (Covox/Disney Sound Source) |
|
||||
| `midisnd1/2.dat` | 10024+ / аналог | 16 | General MIDI музыка |
|
||||
| `mt32snd1/2.dat` | 10000+ | 24/7 | музыка для Roland MT-32 |
|
||||
| `ibm_snd1/2.dat` | 10000+ | 44 | музыка/эффекты через PC-спикер |
|
||||
| `prince.dat` | — (1 крупный ресурс) | — | MIDI-тема (вероятно, финальная тема "Принц"/титры — см. текстовые события "The Princess awaits") |
|
||||
|
||||
Во всех файлах первая (наименьшая по `id`) запись — маленький "служебный"
|
||||
ресурс (6–44 байта), стоящий перед основным контентом. Скорее всего это
|
||||
локальная мини-таблица/палитра/список ссылок для данного набора ресурсов —
|
||||
по аналогии с тем, что у уровней id=2000 отдельно от самих уровней (см. §3).
|
||||
|
||||
---
|
||||
|
||||
## 2. Формат уровня (`levels.dat`, id=2001..2015) — уверенность: высокая
|
||||
|
||||
Каждая запись уровня имеет размер **2305 байт** и по данным полностью
|
||||
совпадает по объёму с уровнями из Apple II версии (`01 POP Source/Levels/LEVELn`
|
||||
— ровно **2304 байта** каждый, см. `docs/APPLEII_RESOURCE_FORMAT.md`).
|
||||
|
||||
Вывод: формат карты уровня в DOS-версии, судя по всему, **унаследован
|
||||
практически без изменений от оригинального Apple II формата** (Джордан
|
||||
Мехнер писал игру на 6502 и данные уровней переносились как есть), с добавлением
|
||||
одного лишнего байта в DOS-упаковке (2304+1=2305 — вероятно, контрольный байт/
|
||||
маркер конца, добавленный DOS-упаковщиком ресурсов, а не часть игровых данных).
|
||||
|
||||
**Практическое следствие:** байтовая структура самого уровня (тайлы 3×10 на
|
||||
экран, 24 экрана, таблицы стражников, дверей и т.д.) должна документироваться
|
||||
один раз — по исходникам Apple II (см. соответствующий раздел), и напрямую
|
||||
применяться к DOS `levels.dat`, отбросив 1 лишний байт в конце каждой записи.
|
||||
Байтовые значения тайлов в дампе (в основном 0x00–0x39) визуально согласуются
|
||||
с диапазоном небольших целых кодов тайлов, что для формата карты и ожидается.
|
||||
|
||||
Первая запись, id=2000, размер 16 байт — не уровень, а отдельный маленький
|
||||
блок (возможно: количество уровней, начальный уровень, версия формата,
|
||||
стартовые координаты игрока/охраны по умолчанию). Точное назначение не
|
||||
установлено — требует сопоставления с дизассемблированным кодом загрузчика
|
||||
уровней (в SDLPoP это, по всем признакам, отдельная процедура чтения
|
||||
`level` ресурса).
|
||||
|
||||
**Сверка с независимой распаковкой SDLPoP (`data/LEVELS/`):** там лежат файлы
|
||||
`res2000.bin`…`res2015.bin` (16 штук — количество совпадает). Байты
|
||||
`res2001.bin` содержательно совпадают с тайловыми данными нашей записи
|
||||
id=2001 (та же последовательность значений тайлов) — это подтверждает, что
|
||||
нумерация id верна. Но есть нестыковка по размеру: у SDLPoP `res2000.bin` —
|
||||
**2305 байт** (как и все остальные), тогда как в нашем локальном
|
||||
`levels.dat` запись id=2000 — всего **16 байт**. Скорее всего, это разные
|
||||
релизы/сборки игры (см. §7 — размеры некоторых `.dat` у SDLPoP и у нас уже
|
||||
отличались), и в версии SDLPoP маленький служебный блок либо отсутствует,
|
||||
либо пронумерован иначе. Это не меняет сам формат контейнера, но означает,
|
||||
что **точную семантику 16-байтного блока id=2000 в нашей копии игры пока
|
||||
нельзя проверить через данные SDLPoP** — открытый вопрос.
|
||||
|
||||
---
|
||||
|
||||
### 2.1 Кросс-подтверждение по исходникам Apple II
|
||||
|
||||
Фоновый анализ исходников Apple II (см. `docs/APPLEII_RESOURCE_FORMAT.md`)
|
||||
подтверждает и объясняет структуру уровня напрямую по коду. Уровень на Apple
|
||||
II — дамп структуры `blueprnt` (`EQ.S`): `BLUETYPE`(720Б, 24 экрана×30 тайлов)
|
||||
+ `BLUESPEC`(720Б) + `LINKLOC`(256Б) + `LINKMAP`(256Б) + `MAP`(96Б, граф
|
||||
соседних экранов) + `INFO`(256Б, метаданные/старт Кида/стражников) = ровно
|
||||
2304 байта. Учитывая, что DOS-запись уровня — это ровно 2304+1 байт с
|
||||
байтовыми значениями тайлов, укладывающимися в диапазон 0–29 (id тайла) плюс
|
||||
служебные биты (аналогично `idmask=%00011111`, `reqmask=%00100000` из
|
||||
`EQ.S:484-486`), можно с высокой уверенностью считать, что **DOS-версия
|
||||
использует ту же самую раскладку `blueprnt`**, лишь с добавлением одного
|
||||
байта (вероятно, контрольной суммы) в конце DOS-упаковки. Это снимает
|
||||
необходимость отдельно реверсить формат уровня для DOS — таблица тайлов,
|
||||
enum id (0=space...29=archtop4), формат `LINKLOC`/`LINKMAP` и `INFO` из
|
||||
Apple II документа применимы напрямую.
|
||||
|
||||
## 3. Графика (спрайты и фоновые тайлы) — уверенность: средняя/низкая
|
||||
|
||||
Файлы `kid.dat`, `guard.dat`, `fat.dat`, `shadow.dat`, `skel.dat`,
|
||||
`vizier.dat`, `title.dat`, `c/e/v-palace.dat`, `c/e/v-dungeon.dat`, `pv.dat`
|
||||
хранят по контейнерному формату (§1) множество мелких чанков (десятки—сотни
|
||||
байт каждый).
|
||||
|
||||
Что подтверждено:
|
||||
- Наборы `shadow.dat`/`kid.dat` и `fat.dat`/`vizier.dat` содержат **побайтово
|
||||
идентичные фрагменты** данных в начале файла — это ожидаемо: "Тень" (Shadow)
|
||||
визуально копирует анимацию Кида, а "Толстый страж" (Fat guard, пасхалка)
|
||||
переиспользует модель Визиря. Подтверждает, что персонажи одного "типа
|
||||
тела" используют общий набор геометрии/анимации.
|
||||
- Отдельные чанки *не* имеют очевидного унифицированного заголовка
|
||||
(высота/ширина/палитра) фиксированного размера — попытка интерпретировать
|
||||
первые байты чанка как `{height:u16, width:u16, flags:u16}` не подтвердилась
|
||||
на реальных данных (получаются нереалистичные размеры для маленьких чанков).
|
||||
Вероятно, как и в Apple II версии (см. `FRAMEDEF.S`/`SEQTABLE.S`), геометрия
|
||||
кадра (ширина, высота, точка привязки) хранится **отдельно от самих
|
||||
пиксельных данных** — в таблицах внутри `PRINCE.EXE`, а не в `.DAT`-чанке.
|
||||
Сам чанк, вероятно, содержит только упакованные пиксельные данные
|
||||
(RLE/дельта-упаковка, по аналогии с `UNPACK.S` в Apple II исходниках).
|
||||
- Точный алгоритм упаковки пикселей **не восстановлен** в рамках этого
|
||||
анализа по сырым байтам — байт-в-байт разбор распаковщика без
|
||||
дизассемблирования `PRINCE.EXE` надёжно не сделать. **Но для практических
|
||||
целей это не требуется**: см. §7 — в SDLPoP уже есть тот же самый набор
|
||||
изображений в готовом, распакованном виде (PNG), которым можно пользоваться
|
||||
напрямую как источником ассетов, не реализуя свой декодер `.DAT`-пикселей.
|
||||
Писать собственный декодер имеет смысл только если понадобится читать
|
||||
оригинальные `.DAT` "на лету" (например, для точной сверки контента именно
|
||||
нашей копии игры) — тогда ориентир — исходник SDLPoP (`src/seg009.c`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Звук — уверенность: высокая (по структуре), низкая (по деталям кодека)
|
||||
|
||||
Обнаружено 4 параллельных набора звуковых ресурсов под разные звуковые
|
||||
устройства DOS-эпохи — типично для игр начала 1990-х с "звуковым меню":
|
||||
|
||||
| Файл | Устройство | Формат чанка |
|
||||
|---|---|---|
|
||||
| `midisnd1.dat`, `midisnd2.dat` | General MIDI / MPU-401 | каждый чанк = 2-байтовый LE-префикс длины + встроенный Standard MIDI File (`MThd`...`MTrk`...) |
|
||||
| `mt32snd1.dat`, `mt32snd2.dat` | Roland MT-32/CM-32L | тот же формат: префикс длины + `MThd`/`MTrk`, с MT-32-специфичными SysEx (видны строки `MT-32.mff`, текстовые мета-события вроде `"The Princess awaits"`) |
|
||||
| `prince.dat` | (аналогично MIDI) | отдельный крупный музыкальный ресурс, тот же MIDI-контейнер — вероятно, финальная тема |
|
||||
| `digisnd1/2/3.dat` | Covox / Disney Sound Source / Sound Blaster (оцифрованный звук) | чанк начинается с нескольких служебных байт, среди которых слово `0x2AF8` = 11000 — похоже на частоту дискретизации 11 кГц; далее — сырые 8-битные PCM-сэмплы (значения кластеризуются вокруг ~0x7A–0x90, типично для беззнакового 8-бит аудио, смещённого к середине шкалы) |
|
||||
| `ibm_snd1.dat`, `ibm_snd2.dat` | PC Speaker | чанк — последовательность троек байт похожих на (длительность, делитель_частоты) — простой формат "бипера", отличный от MIDI |
|
||||
|
||||
Подтверждено разбором первых чанков в каждом файле (см. байтовые дампы,
|
||||
проверялись скриптом). Точная семантика полей внутри `digisnd`/`ibm_snd`
|
||||
(разрядность, порядок байт служебного заголовка) не выведена до конца — при
|
||||
реализации порта достаточно распознавания по типу файла и (для MIDI-семейства)
|
||||
можно напрямую воспроизводить встроенный Standard MIDI File, пропустив
|
||||
2-байтовый префикс длины.
|
||||
|
||||
---
|
||||
|
||||
## 5. Готовые распакованные ассеты в SDLPoP (`data/`) — практический источник для порта
|
||||
|
||||
Репозиторий github.com/NagyD/SDLPoP содержит папку `data/`, где, помимо
|
||||
самих `.DAT`-контейнеров, каждый ресурс **продублирован в виде отдельно
|
||||
распакованного файла**, названного по его `id` из таблицы оглавления (§1.2).
|
||||
Проверено через GitHub API (`api.github.com/repos/NagyD/SDLPoP/contents/...`):
|
||||
|
||||
| Подпапка/файл в `data/` | Содержимое | Соответствие нашему разбору |
|
||||
|---|---|---|
|
||||
| `GUARD.DAT`, `GUARD1.DAT`, `GUARD2.DAT` | сырые `.DAT` | размер **побайтово совпадает** с нашими локальными `guard.dat`/`guard1.dat`/`guard2.dat` (6950 / 117 / 117 байт) |
|
||||
| `DIGISND1.DAT`, `MIDISND2.DAT` и др. | сырые `.DAT` | размер **не совпадает** с нашими локальными файлами (48545 vs 50101, 18408 vs 18958) — другой релиз/сборка игры |
|
||||
| `GUARD/res751.png` … `res784.png` | готовые PNG, по одному на кадр анимации, имя = `res<id>.png` | id-диапазон (751-784) точно совпадает с нашим разбором `guard.dat` |
|
||||
| `VPALACE/res200.pal`, `res201.png`, `res202.png`, … | палитра (JASC `.pal`) + PNG-кадры фонов дворца, **VGA-вариант (256 цветов)** | id-диапазон (200+) совпадает с `vpalace.dat` |
|
||||
| `LEVELS/res2000.bin` … `res2015.bin` | сырые дампы уровней по 2304-2305 байт | id совпадает с `levels.dat`; содержимое `res2001.bin` **сверено побайтово** с нашим id=2001 — тайловые данные совпадают |
|
||||
| `KID/`, `PRINCE/`, `SHADOW/`, `SKEL/`, `VIZIER/`, `FAT/`, `TITLE/`, `VDUNGEON/`, `PV/`, `IBM_SND1/`, `IBM_SND2/`, `font/`, `music/` | аналогичные наборы для остальных ресурсов | не проверялись по отдельности, но структура (папка на каждый `.dat`, файлы `res<id>.ext`) наблюдается одинаково |
|
||||
|
||||
**Вывод:** это данные из немного **другого релиза DOS-версии**, чем те, что
|
||||
лежат у нас в `MSDOS/` (см. расхождение в размере `digisnd`/`midisnd`), но
|
||||
формат контейнера и нумерация `id` — те же самые. Практически это значит:
|
||||
|
||||
1. Для получения играбельных PNG-спрайтов и VGA-фонов **не нужно
|
||||
реализовывать декодер сжатия пикселей** — можно взять готовые файлы
|
||||
`data/<ИМЯ>/res<id>.png` напрямую как исходный материал для конвертации
|
||||
под видеорежим ZX Sprinter (в т.ч. `VPALACE`/`VDUNGEON` — уже
|
||||
256-цветный VGA-арт, что прямо отвечает на вопрос про полноцветность).
|
||||
2. Если в проекте важно использовать именно ту версию контента, что в наших
|
||||
`MSDOS/*.dat` (а не версию из SDLPoP) — распаковку своих файлов всё же
|
||||
придётся делать (кодек пикселей по-прежнему не восстановлен для сырых
|
||||
`.DAT`, см. §3), либо принять решение работать с версией SDLPoP как
|
||||
мастер-источником ассетов вместо своей.
|
||||
|
||||
---
|
||||
|
||||
## 6. Служебные не-ресурсные файлы
|
||||
|
||||
- `config.dat`, `setup.dat` — 28 байт, не являются ресурсными контейнерами
|
||||
(не проходят проверку §1.1 — "размер" получается больше самого файла).
|
||||
Скорее всего простые бинарные структуры настроек (звук/видеорежим,
|
||||
выбранный на этапе `SETUP.EXE`/`INSTALL.EXE`), не связаны с игровым
|
||||
контентом.
|
||||
- `desktopd.cfg`, `setup.cfg` — текстовые/бинарные конфиги DOS-инсталлятора,
|
||||
вне скоупа игровых ресурсов.
|
||||
- `PRINCE.EXE` / `PRINCE.REM` — почти идентичны (отличие в единичных байтах
|
||||
в районе смещения ~0x4ED0), похоже на кряк/патч одного байта проверки —
|
||||
не относится к формату ресурсов.
|
||||
- `old-games.nfo` — ASCII-арт NFO релиз-группы (old-games.ru), не игровые
|
||||
данные.
|
||||
|
||||
---
|
||||
|
||||
## 7. Итоговая таблица уверенности
|
||||
|
||||
| Раздел | Уверенность | Как подтверждено |
|
||||
|---|---|---|
|
||||
| Контейнер `.DAT` (заголовок + таблица) | Высокая | Проверено скриптом на всех 28 файлах, инвариант offset+size выполняется без исключений; независимо подтверждено именованием `res<id>.*` в SDLPoP `data/` |
|
||||
| ID-пространства ресурсов | Средняя-высокая | Наблюдение по диапазонам + сверка с `res<id>` именами файлов SDLPoP и побайтовым содержимым `res2001.bin` |
|
||||
| Формат уровня = формату Apple II | Высокая (по размеру и содержимому), служебный блок id=2000 — открытый вопрос | Совпадение размера (2304 vs 2305), тайловые байты сходятся с `res2001.bin` из SDLPoP |
|
||||
| Формат изображений/спрайтов (сырой `.DAT`) | Низкая-средняя | Контейнер подтверждён, кодек пикселей — нет; но практически закрыто наличием готовых PNG в SDLPoP `data/` (§5) |
|
||||
| Формат звука (тип контейнера) | Высокая для MIDI-семейств, средняя для digisnd/ibm_snd | Явные MIDI-сигнатуры `MThd`/`MTrk` видны в байтах |
|
||||
|
||||
**Рекомендация для дальнейшей работы:** для получения арт-ассетов (спрайты,
|
||||
фоны, палитры) — использовать готовые распакованные файлы из
|
||||
`github.com/NagyD/SDLPoP/tree/master/data` (§5), это быстрее и надёжнее
|
||||
самостоятельной реализации декодера. Декодер сырого `.DAT`-формата
|
||||
изображений и точную семантику служебных полей `digisnd`/`ibm_snd`
|
||||
(§3, §4) стоит восстанавливать только если понадобится читать именно нашу
|
||||
локальную копию `MSDOS/*.dat` "как есть" — тогда ориентир прежний: исходник
|
||||
SDLPoP (`src/seg009.c`, `src/data.c`/`data.h`).
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
# `SprPoP/docs` — индекс
|
||||
|
||||
Актуальность на 2026-08-26 (перенос из `applications/PoP/roomtest`).
|
||||
Сюда взято только то, что ещё читают. Исполненные планы остались архивом
|
||||
в `../../PoP/docs/`, закрытые задачи и баги — в `../../PoP/roomtest/`.
|
||||
|
||||
## Входные точки
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`TASKS_OPEN.md`](TASKS_OPEN.md) | **Что берётся в работу сейчас** — начинать отсюда |
|
||||
| [`BUGS_OPEN.md`](BUGS_OPEN.md) | Открытые баги и незакрытые оптимизации |
|
||||
| [`impl_diff.md`](impl_diff.md) | **Осознанные расхождения с SDLPoP**: где сделано не дословно и почему. Новое расхождение — записью сюда, а не только комментарием в коде |
|
||||
| [`keys.txt`](keys.txt) | **Целевая раскладка управления**, к которой подгоняем SprPoP |
|
||||
|
||||
## Производительность
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`perf_registry.md`](perf_registry.md) | **Реестр оптимизаций**: всё отложенное одним списком |
|
||||
| [`perf_backlog.md`](perf_backlog.md) | Отложенная оптимизация отрисовки + **как мерить** (wait-state'ы, границы кадра) |
|
||||
| [`perf_green_phase.md`](perf_green_phase.md) | ЗЕЛЁНАЯ фаза (слой фона): раскладка тактов, способы ускорения G1..G6, журнал правок |
|
||||
| [`perf_cyan_phase.md`](perf_cyan_phase.md) | ЦИАН фаза (персонажи + передний слой): раскладка тактов, C1..C7, журнал правок |
|
||||
| [`perf_l11_room15.md`](perf_l11_room15.md) | Сцена и замер: факел + чомпер + страж (ур.11 к.15) |
|
||||
| [`perf_l13_room23.md`](perf_l13_room23.md) | Сцена и метод замера кадра: каскад плит (ур.13 к.23), зонды, канал `clog` |
|
||||
| [`resident_budget.md`](resident_budget.md) | Бюджет резидента W1/W2: как мерить и как освобождать |
|
||||
| [`layout_plan_v2.md`](layout_plan_v2.md) | Раскладка кода по окнам/банкам/страницам + замеры скорости отрисовки |
|
||||
|
||||
## Движок и содержание игры
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`full_game_plan.md`](full_game_plan.md) | Полноценная игра: автомат состояний, title/intro, demo, таймер, cutscenes, ending, Hall of Fame |
|
||||
| [`levels_plan.md`](levels_plan.md) | Уровни: загрузка, переходы, тайлсеты, читы |
|
||||
| [`levels_12_15_plan.md`](levels_12_15_plan.md) | Уровни 12/13 (тень, Джафар, падающие плиты) + что такое 14/15 и 0 |
|
||||
| [`midtable_analysis.md`](midtable_analysis.md) | Слои отрисовки: back/mid/fore и objtable в оригинале, чего стоит порт |
|
||||
| [`room_model_plan.md`](room_model_plan.md) | `kid_room ≠ drawn_room` (straddle): сделан S1, остальное впереди |
|
||||
| [`quicksave_plan.md`](quicksave_plan.md) | QuickSave/QuickLoad (реализовано): формат снимка `POPQ` v3 — справочник |
|
||||
| [`menu_settings_plan.md`](menu_settings_plan.md) | Pause menu и Settings, `POP.CFG`, VANILLA и задел под ENHANCED |
|
||||
| [`palette_plan.md`](palette_plan.md) | Карта всех 256 слотов палитры + механика fade |
|
||||
| [`status_line_text.md`](status_line_text.md) | Строка HP как статус-строка: инвентаризация ВСЕХ текстов SDLPoP |
|
||||
| [`sound_plan.md`](sound_plan.md) | Звук через CBL: разбор и архитектура |
|
||||
| [`shadow_render.md`](shadow_render.md) | Вид Тени (OR+XOR) — отложено: почему XOR несовместим с прозрачностью `#FF` |
|
||||
| [`roomnav_skip.md`](roomnav_skip.md) | Комнаты для отладочного телепорта `+`/`−`: какие пропускать и почему |
|
||||
| [`host_tests_plan.md`](host_tests_plan.md) | Модульные тесты движка под ucsim_z80: два шва, регрессии, дифф против SDLPoP |
|
||||
|
||||
## Справочники по оригиналу
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`PORT_PLAN.md`](PORT_PLAN.md) | Общая карта фаз со статусами; §6 модель движения, §10 режим памяти |
|
||||
| [`KID_PLAN.md`](KID_PLAN.md) | Модель персонажа: `char_type`, `actions_*`, устройство `play_seq` |
|
||||
| [`gates_spikes_plan.md`](gates_spikes_plan.md) | Раскладка объектов по комнатам, декод `LINKLOC`/`LINKMAP`, ссылки на seg-код |
|
||||
| [`PoP/POP-DAT-FormatSpecifications.pdf`](PoP/POP-DAT-FormatSpecifications.pdf) | **Каноническая спецификация форматов `.DAT`** (грепаемая копия — `.txt`) |
|
||||
| [`PoP/MSDOS_RESOURCE_FORMAT.md`](PoP/MSDOS_RESOURCE_FORMAT.md) | Наш разбор ресурсов MS-DOS-версии |
|
||||
| [`PoP/APPLEII_RESOURCE_FORMAT.md`](PoP/APPLEII_RESOURCE_FORMAT.md) | Наш разбор ресурсов Apple II |
|
||||
|
||||
## Заделы
|
||||
|
||||
| Документ | О чём |
|
||||
|----------|-------|
|
||||
| [`ideas_backlog.md`](ideas_backlog.md) | Осознанно отложенные гипотезы (мышь, PRNG) |
|
||||
| [`prng_alternatives.md`](prng_alternatives.md) | Запасные генераторы, если упрёмся в бюджет кадра |
|
||||
|
||||
## Не переносилось
|
||||
|
||||
Исполнено целиком, лежит в `../../PoP/docs/`: `frame_pacing_plan.md`
|
||||
(фиксированный логический кадр), `l9_invert_plan.md` (зелье переворота),
|
||||
`shadow_atlas_plan.md` (запечка атласа Тени).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
# От SprPoP к полноценной игре — сценарий и оболочка
|
||||
|
||||
Статус: **частично реализовано; аудит обновлён 2026-08-24**. Ранее пометка
|
||||
«завершены FG0–FG12» была неверной: для многих этапов уже есть код и
|
||||
host-тесты, но их критерии приёмки на Sprinter ещё не выполнены. Фактический
|
||||
статус каждого FG приведён в [§14](#14-этапы-реализации).
|
||||
|
||||
Этот документ описывает превращение текущего игрового цикла
|
||||
`SprPoP` в законченную игру: заставка, интро, демонстрационный уровень,
|
||||
сцены между уровнями, таймер, финал и Hall of Fame. План меню и постоянных
|
||||
настроек вынесен в [`menu_settings_plan.md`](menu_settings_plan.md),
|
||||
детальный план QuickSave — в [`quicksave_plan.md`](quicksave_plan.md).
|
||||
|
||||
## 1. Зафиксированный scope
|
||||
|
||||
- Целевая последовательность — оригинальная SDLPoP/DOS PoP с уровнями
|
||||
**1..14**. Уровень 14 — скрытая финальная часть после Джаффара: его номер
|
||||
игроку не показывается, победа наступает в комнате 5.
|
||||
- Уровень **15 удаляется полностью**: не пакуется на HDD, не загружается,
|
||||
отсутствует в переходах, читах и UI; специальная логика potions/copy
|
||||
protection level удаляется.
|
||||
- Уровень **0** остаётся только демонстрационным (attract mode), а не частью
|
||||
новой игры.
|
||||
- Title sequence повторяет SDLPoP. Перед ней допускается отдельный
|
||||
пропускаемый экран с информацией о Sprinter-сборке.
|
||||
- Первая версия использует текущий профиль поведения `VANILLA`. Сейчас это
|
||||
означает **существующую реализацию SprPoP**, включая уже встроенные
|
||||
исправления. Аудит и разведение `VANILLA/ENHANCED` — будущая задача.
|
||||
- Программа работает **только с HDD**. Варианты без сохранения для floppy не
|
||||
проектируются.
|
||||
- Моды и выбор levelset в этот план не входят.
|
||||
|
||||
## 2. Что делает SDLPoP
|
||||
|
||||
Источники истины в локальном SDLPoP:
|
||||
|
||||
- `src/seg000.c`: `start_game()`, `show_title()`, demo mode, общий кадр,
|
||||
проверка финала;
|
||||
- `src/seg003.c`: `init_game()`, `play_level()`, `play_level_2()`;
|
||||
- `src/seg001.c`: cutscene engine, `pv_scene()`, сцены 2/4/6/8/9/12,
|
||||
`time_expired()`, `end_sequence()` и Hall of Fame;
|
||||
- `src/data.h`: `tbl_cutscenes`, параметры уровня 0, win level/room;
|
||||
- `data/TITLE`, `data/PV`, `data/LEVELS/res2000.bin`: ресурсы оболочки.
|
||||
|
||||
Штатный маршрут:
|
||||
|
||||
```text
|
||||
boot
|
||||
-> title / story screens
|
||||
-> Princess + Jaffar intro
|
||||
-> credits / Hall of Fame
|
||||
-> demo level 0
|
||||
-> title или новая игра
|
||||
-> levels 1..14
|
||||
before 2 -> princess cutscene
|
||||
before 4 -> princess cutscene
|
||||
before 6 -> princess cutscene
|
||||
before 8 -> princess + mouse
|
||||
before 9 -> princess + mouse
|
||||
before 12 -> scene selected by remaining time
|
||||
-> level 14, room 5
|
||||
-> embrace + mouse
|
||||
-> ending text/music
|
||||
-> Hall of Fame
|
||||
-> title
|
||||
```
|
||||
|
||||
Кроме уровней, здесь есть глобальный 60-минутный таймер, сцена истечения
|
||||
времени, пропуск сцен клавишей, fade/flash, ожидание музыки и возврат в
|
||||
attract loop после демо или финала.
|
||||
|
||||
## 3. Текущее состояние SprPoP
|
||||
|
||||
Уже реализованы игровой кадр, комнаты, уровни, тайлсеты, Kid/Guard/Shadow,
|
||||
Джаффар, специальные события, checkpoint, переходы уровней, бесшовный
|
||||
выход 12-го уровня, перенос максимального HP и звуковые эффекты.
|
||||
|
||||
Поверх игрового цикла уже добавлены автомат оболочки, title/story, demo
|
||||
уровень 0, global timer, сценарный интерпретатор, level-flow, ending и Hall
|
||||
of Fame. Полный маршрут также собирается в HDD-образ.
|
||||
|
||||
Однако это **не означает готовность оболочки**. На момент аудита остаются
|
||||
существенные незакрытые места:
|
||||
|
||||
- lifecycle палитр: gameplay-переходы используют чёрный барьер без fade;
|
||||
cold start и полный набор dungeon/palace переходов ещё не прошли приёмку;
|
||||
- PV intro Princess/Jaffar уже покадровый (актёры, факелы, звёзды, часы,
|
||||
молния и foreground-колонна); сцены перед 2/4/6 и длинной веткой 12
|
||||
анимируют факелы, звёзды и песок, а сцены 8/9 и короткая ветка 12 пока
|
||||
используют статические позы с исходной длительностью;
|
||||
- demo отображается с игровой палитрой, проходит второй разворот/зацеп и
|
||||
доходит до боя; после смерти Кида корректно завершает цикл;
|
||||
- time-expired, ending и Hall of Fame имеют маршрут и реализацию UI, но не
|
||||
прошли сквозную MAME-проверку вместе с ресурсами и возвратом к title;
|
||||
- нет полного регресса EMM/FD для каждого перехода состояния.
|
||||
|
||||
## 4. Архитектура: автомат состояний приложения
|
||||
|
||||
Нельзя наращивать все режимы условиями внутри кадрового цикла. Текущий
|
||||
цикл должен стать реализацией одного состояния `PLAYING`:
|
||||
|
||||
```text
|
||||
BOOT -> BUILD_INFO -> TITLE -> INTRO -> DEMO
|
||||
| |
|
||||
+---- NEW_GAME <-+
|
||||
|
||||
NEW_GAME -> LEVEL_LOAD -> PLAYING <-> PAUSE_MENU
|
||||
|
|
||||
+-> CUTSCENE -> LEVEL_LOAD
|
||||
+-> TIME_EXPIRED -> TITLE
|
||||
+-> ENDING -> HALL_OF_FAME -> TITLE
|
||||
```
|
||||
|
||||
Минимальный контекст оболочки:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
POP_APP_BOOT,
|
||||
POP_APP_BUILD_INFO,
|
||||
POP_APP_TITLE,
|
||||
POP_APP_INTRO,
|
||||
POP_APP_DEMO,
|
||||
POP_APP_LEVEL_LOAD,
|
||||
POP_APP_PLAYING,
|
||||
POP_APP_PAUSE_MENU,
|
||||
POP_APP_CUTSCENE,
|
||||
POP_APP_TIME_EXPIRED,
|
||||
POP_APP_ENDING,
|
||||
POP_APP_HALL_OF_FAME,
|
||||
POP_APP_QUIT
|
||||
} pop_app_state_t;
|
||||
```
|
||||
|
||||
Переходы задаются результатом состояния, а не прямыми рекурсивными
|
||||
вызовами наподобие SDLPoP `start_game()`/`longjmp()`. На Z80 это проще для
|
||||
стека и позволяет освобождать ресурсы каждого режима в одном месте.
|
||||
|
||||
## 5. Ресурсная модель
|
||||
|
||||
Title и cutscene-ресурсы нельзя постоянно держать рядом с игровыми
|
||||
атласами. Для каждого состояния нужен явный lifecycle:
|
||||
|
||||
```text
|
||||
enter: pause sound -> unload incompatible set -> load set -> apply palette
|
||||
run: process input/timer/animation
|
||||
leave: stop sound -> release EMM pages -> clear transient state
|
||||
```
|
||||
|
||||
Новые группы HDD:
|
||||
|
||||
```text
|
||||
TITLE\ title/story images, palette, optional build-screen assets
|
||||
PV\ princess room, Princess/Jaffar/mouse frames, palettes
|
||||
MUSIC\ intro, cutscene and ending tracks/samples
|
||||
LEVELS\ res2000..res2014.bin
|
||||
```
|
||||
|
||||
Конкретный формат атласов выбирает упаковщик. Runtime не должен разбирать
|
||||
PNG/DAT: как и игровые спрайты, он получает подготовленные `.atl`/`.bin`.
|
||||
|
||||
## 6. Экран Sprinter build
|
||||
|
||||
Отдельное состояние перед оригинальной заставкой:
|
||||
|
||||
```text
|
||||
PRINCE OF PERSIA
|
||||
SPRINTER SP2000 BUILD
|
||||
version / date / build id
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- пропускается любой клавишей;
|
||||
- выключается в Settings;
|
||||
- не запускает музыку оригинального title и не меняет её тайминги;
|
||||
- данные версии генерируются сборкой, а не правятся вручную в C;
|
||||
- отсутствие экрана приводит прямо к `TITLE`.
|
||||
|
||||
## 7. Title и текстовая подсистема
|
||||
|
||||
Порядок переносится из `show_title()`:
|
||||
|
||||
1. основной титульный экран;
|
||||
2. Presents;
|
||||
3. название игры и Jordan Mechner;
|
||||
4. story frame / “In the absence…”;
|
||||
5. intro Princess + Jaffar;
|
||||
6. story “Marry Jaffar…”;
|
||||
7. credits;
|
||||
8. Hall of Fame, если таблица непуста;
|
||||
9. demo level 0.
|
||||
|
||||
Нужны общие примитивы: загрузить full-screen image, вывести строку,
|
||||
показать экран заданное время, transition left-to-right, fade in/out,
|
||||
прервать ожидание клавишей. Текст и меню должны использовать один renderer.
|
||||
|
||||
Критерий: последовательность и музыкальные точки совпадают с SDLPoP;
|
||||
Sprinter build screen не сдвигает оригинальный soundtrack.
|
||||
|
||||
## 8. Demo level 0
|
||||
|
||||
- Добавить на HDD `res2000.bin`.
|
||||
- Загружать уровень обычным loader, но выставлять demo HP и demo mode.
|
||||
- Воспроизводить `demo_moves` как синтетический источник `control_*`.
|
||||
- Пользовательский ввод прерывает демо и начинает новую игру.
|
||||
- Достижение demo end room (у SDLPoP — 24), смерть или конец скрипта
|
||||
возвращают в `TITLE`.
|
||||
- Pause menu, QuickSave и cheats в demo недоступны.
|
||||
- RNG демо и начальное состояние должны быть детерминированы.
|
||||
|
||||
Критерий: без ввода attract loop не требует перезапуска процесса;
|
||||
title -> demo -> title повторяется неограниченно.
|
||||
|
||||
## 9. Глобальный таймер
|
||||
|
||||
Состояние: минуты, тики и флаг показа. Таймер создаётся при New Game,
|
||||
переносится между уровнями и входит в QuickSave.
|
||||
|
||||
Правила `VANILLA`:
|
||||
|
||||
- на pause menu, загрузке HDD, QuickSave/QuickLoad время не идёт;
|
||||
- игровые тики следуют темпу логического кадра, а не частоте render loop;
|
||||
- поведение во время level-end sound и cutscenes сверяется буквально с
|
||||
SDLPoP;
|
||||
- после Джаффара/на финальном уровне время не должно вызвать поражение;
|
||||
- ноль времени переводит приложение в `TIME_EXPIRED`.
|
||||
|
||||
Критерий: одинаковый игровой отрезок в NORMAL даёт то же уменьшение времени,
|
||||
что SDLPoP; сохранение/загрузка не добавляет и не отнимает тики.
|
||||
|
||||
Реализация FG4 живёт одним модулем `src/pop_timer.c` в bank 9:
|
||||
`60:719`, 720 тиков на минуту, счёт только в живом игровом кадре. Settings
|
||||
хранит `TIME LIMIT: 60 MIN / UNLIMITED` в `POP.CFG`; старый семибайтный v1
|
||||
payload по-прежнему читается как `60 MIN`. Читы таймера повторяют SDLPoP,
|
||||
но из-за занятого `+/-` используют F7 (−1 минута, не ниже одной) и F8
|
||||
(+1 минута). Состояние входит в QuickSave v4.
|
||||
|
||||
## 10. Cutscene engine
|
||||
|
||||
Сцены SDLPoP состоят из небольшого набора повторяемых команд. Вместо набора
|
||||
крупных C-функций нужен компактный интерпретатор:
|
||||
|
||||
```text
|
||||
SET_ACTOR actor
|
||||
SET_POS x,y,dir
|
||||
START_SEQ seq
|
||||
WAIT_FRAMES n
|
||||
PLAY_SOUND id
|
||||
WAIT_SOUND
|
||||
SET_HOURGLASS frame
|
||||
SET_SAND state
|
||||
FLASH color,frames
|
||||
FADE_IN / FADE_OUT
|
||||
CLEAR_ACTOR actor
|
||||
END
|
||||
```
|
||||
|
||||
Скрипты — `const` в холодном банке или подготовленный бинарный ресурс.
|
||||
Interpreter обязан:
|
||||
|
||||
- исполнять один шаг/кадр без блокирующих длинных циклов;
|
||||
- поддерживать пропуск сцены;
|
||||
- при пропуске выполнять cleanup и выходить в заранее заданное состояние;
|
||||
- освобождать PV-ресурсы перед загрузкой игрового тайлсета;
|
||||
- не разрешать pause menu/QuickSave внутри сцены.
|
||||
|
||||
Порядок переноса: intro, 2/6, 4, 8, 9, 12, time expired, ending. Сцена 12
|
||||
выбирает короткий или обычный вариант по остатку времени.
|
||||
|
||||
## 11. Переходы между уровнями
|
||||
|
||||
Таблица сценария должна быть отдельна от таблиц механики уровня:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint8_t level;
|
||||
uint8_t pre_cutscene;
|
||||
uint8_t show_level_number;
|
||||
uint8_t ending_rule;
|
||||
} pop_level_flow_t;
|
||||
```
|
||||
|
||||
Особые правила:
|
||||
|
||||
- New Game начинает уровень 1;
|
||||
- перед 2/4/6/8/9/12 запускается сцена;
|
||||
- 12 -> 13 остаётся бесшовным;
|
||||
- после победы над Джаффаром переход идёт в 14;
|
||||
- номер 14 не показывается;
|
||||
- вход в комнату 5 уровня 14 переводит в `ENDING`;
|
||||
- значения больше 14 недопустимы и дают диагностическую ошибку, а не
|
||||
попытку открыть файл.
|
||||
|
||||
## 12. Ending и Hall of Fame
|
||||
|
||||
Ending:
|
||||
|
||||
1. загрузить PV-набор;
|
||||
2. встреча Kid и Princess;
|
||||
3. объятие;
|
||||
4. появление мыши;
|
||||
5. ending music;
|
||||
6. финальные story/title экраны;
|
||||
7. переход в Hall of Fame.
|
||||
|
||||
Hall of Fame хранится на HDD в отдельном версионированном `POP.HOF`.
|
||||
Сохраняются имя и результат; ввод имени использует тот же текстовый/UI слой.
|
||||
Повреждённый или неизвестный формат означает пустую таблицу, но не мешает
|
||||
запуску игры. После показа — возврат в `TITLE`.
|
||||
|
||||
## 13. Удаление уровня 15
|
||||
|
||||
Отдельный ранний этап, чтобы новый flow не наследовал лишний маршрут:
|
||||
|
||||
- убрать `res2015.bin` из `LVL_NUMS` и HDD image;
|
||||
- заменить последний игровой уровень на 14;
|
||||
- остановить Shift+L и прочую навигацию на 14;
|
||||
- удалить `POP_POTIONS_LEVEL` и специальный половинный урон синих зелий;
|
||||
- исключить copy protection из конфигурации и меню;
|
||||
- добавить тест: после уровня 14 приложение входит в ending и никогда не
|
||||
запрашивает `res2015.bin`.
|
||||
|
||||
## 14. Этапы реализации
|
||||
|
||||
Легенда аудита: **✓** — критерий этапа закрыт; **~** — код существует, но
|
||||
критерий приёмки ещё не закрыт; **○** — не начат. Статус отражает состояние
|
||||
исходников и последней MAME-проверки на 2026-08-24, а не только наличие
|
||||
модуля в bank 9.
|
||||
|
||||
| этап | статус | результат и фактическое состояние | критерий приёмки |
|
||||
|---|---|---|---|
|
||||
| **FG0** | ✓ | `POP_LEVEL_LAST=14`, HDD содержит `res2000..res2014`; `t_flow` отвергает 15 | HDD не содержит res2015; переход выше 14 невозможен |
|
||||
| **FG1** | ~ | автомат `pop_app` и `t_app` реализованы; сквозной ресурсный lifecycle и контроль EMM/FD ещё не измерены | старт/рестарт/выход проходят без рекурсии и утечки EMM |
|
||||
| **FG2** | ✓ | QuickSave/QuickLoad с `POP.SAV` и `POP.BAK`; отдельно проверен в MAME 2026-08-22 | критерии `quicksave_plan.md`, включая POP.BAK |
|
||||
| **FG3** | ✓ | pause menu, Settings, подтверждения и двойной буфер реализованы; меню проверялось в MAME; добавлены SDLPoP-звуки навигации и защита CBL вокруг полного redraw/файловых операций | Resume/Save/Load/Restart/Settings/Quit работают |
|
||||
| **FG4** | ~ | `pop_timer`, настройка unlimited, F7/F8 и состояние QuickSave реализованы; есть host-тест, но нет буквального сравнения темпа со SDLPoP на всех переходах | совпадение с SDLPoP и корректный save/load |
|
||||
| **FG5** | ~ | text/full-screen/fade примитивы есть; для входа в первый уровень и границ уровней выбран мгновенный чёрный барьер без fade: CBL и яркая новая палитра включаются только после подготовки обеих страниц; Level 1 проверен в MAME | тестовые экраны и переходы на Sprinter |
|
||||
| **FG6** | ~ | title-ресурсы и порядок кадров реализованы; Enter на title и Esc на первом story в MAME переводят прямо в `FIRST_LEVEL`, минуя demo; полная cold-boot приёмка fade остаётся в FG5 | основной титул/Presents/название/Mechner идут в точном порядке `show_title()`; Enter/Space/Esc/стрелки прерывают ожидание; story/intro продолжит FG8 |
|
||||
| **FG7** | ✓ | level 0, исходная таблица `demo_moves`, demo HP=4 и блокировка игрового UI реализованы; исправлены зеркалирование auto-control, боевой AI Кида и завершение после смерти; в MAME demo проходит разворот/зацеп, доходит до боя и возвращается в attract-цикл без повторного убийства | `res2000.bin`, исходная `demo_moves`, demo HP=4; бесконечный attract loop, любой ввод начинает чистую новую игру; Pause/QuickSave/читы/таймер отключены |
|
||||
| **FG8** | ~ | data-driven interpreter и покадровый PV intro работают; в MAME проверены актёры, факелы, звёзды 1x1, часы/песок, palette-0 lightning и foreground-колонна; Enter/Esc переводят прямо в `FIRST_LEVEL`; временный темп 12,5 FPS и TODO точного pacing записаны в `impl_diff.md` | story/PV intro проходит, любой raw-ввод пропускает его без удержания EMM-страниц |
|
||||
| **FG9** | ~ | `pop_flow` корректно маршрутизирует 2/4/6/8/9/12 и ветку <=5 минут (`t_flow`); 2/4/6 и длинная 12 уже обновляют часы, песок, факелы и звёзды каждые 5 кадров Sprinter; длительности всех веток сверены с SDLPoP: 2/4/6/12 — 2,6 с, 8 — 6,0 с, 9 — 7,2 с; входная клавиша gameplay/Shift+L поглощается до сцены, а новое нажатие делает skip; анимации мыши/Princess в 8/9 и разворот Princess в короткой 12 ещё статичны | таблица flow переводит в CUTSCENE ровно перед 2/4/6/8/9/12; scene 12 выбирает короткий вариант при <=5 минутах |
|
||||
| **FG10** | ~ | переход TIME_EXPIRED и экран существуют, но это ещё статическая PV-стадия; сквозной MAME-маршрут не принят | PV-сцена истечения с пропуском, затем возврат на title/attract; новая игра сбрасывает таймер |
|
||||
| **FG11** | ~ | room 5 уровня 14 переводит в ENDING (`t_flow`); объятие/мышь заменены статической стадией, полный маршрут не принят | room 5 уровня 14 переводит в ENDING; PV-финал и Hail-экран возвращают управление оболочке |
|
||||
| **FG12** | ~ | версионированный `POP.HOF`, ввод имени и восстановление после повреждённого файла реализованы; нужна сквозная MAME-проверка ending → HOF → title | версионированный `POP.HOF`, ввод имени raw-клавиатурой, повреждённый файл = пустая таблица, затем title/attract |
|
||||
|
||||
## 15. Проверки
|
||||
|
||||
- Host-тест автомата: все допустимые переходы и отсутствие уровня 15.
|
||||
- Host-тест cutscene interpreter на синтетическом скрипте и skip в каждой
|
||||
ожидающей команде.
|
||||
- Host-тест demo input: одинаковый seed даёт одинаковый поток управления.
|
||||
- MAME: cold boot -> build info -> title -> demo -> title.
|
||||
- MAME: новая игра -> принудительный переход по всем pre-level scenes.
|
||||
- MAME: time expired и пропуск сцены.
|
||||
- MAME: 13 -> 14 -> room 5 -> ending -> HOF -> title.
|
||||
- Проверка EMM/FD до и после каждого состояния: число страниц и открытых
|
||||
файлов возвращается к базовому.
|
||||
- `make size-check`; крупный cold-код размещать в банках и отдельно следить
|
||||
за лимитом 16 КБ каждого банка.
|
||||
|
||||
## 16. Не входит в план
|
||||
|
||||
- уровень 15 и copy protection;
|
||||
- моды и выбор levelset;
|
||||
- replay/recording;
|
||||
- точная эмуляция SDL video/controller options;
|
||||
- профиль ENHANCED и индивидуальные switches fixes.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Интерактивные объекты (кнопки/гейты/пики) + HP/смерть — ПОДРОБНЫЙ план
|
||||
|
||||
> **Статус: РЕАЛИЗОВАНО (2026-08-01).** Все фазы плана (P0 персистентный
|
||||
> per-room `room_modif`, S пики, B кнопки+ворота) сделаны и играются:
|
||||
> `src/pop_trob.c` (trob-диспетчер, `LINKLOC`/`LINKMAP`, ворота, дверь
|
||||
> уровня, факелы, зелья), `pop_map.c` (HP, смерть на пиках, урон падения),
|
||||
> `pop_redraw.c` (пометки перерисовки вместо прямых блитов). Ограничения
|
||||
> из §0 закрыты: тайлы персистентны, HP/смерть есть, loose обобщён в trob;
|
||||
> L3-вверх (climb-up в комнату сверху) тоже сделан (`pop_leave_dir = 3`).
|
||||
> Из §5 остаётся открытым только **переход на следующий уровень через дверь
|
||||
> уровня** — он вынесен в `levels_plan.md`.
|
||||
>
|
||||
> **Документ оставлен как СПРАВОЧНИК**, а не как план: §1 (раскладка
|
||||
> объектов уровня 1 по комнатам, декод связей кнопка→цель) и §2 (точные
|
||||
> ссылки на механику SDLPoP) продолжают экономить время при отладке.
|
||||
> Текущие задачи — `../TASKS_OPEN.md`.
|
||||
|
||||
Составлен 2026-07-20. Документ самодостаточный: рассчитан на старт
|
||||
«с чистого листа» (пустой контекст). Всё сверено с
|
||||
`applications/PoP/SDLPoP/src/` и данными `res2001.bin`.
|
||||
|
||||
Правило проекта (см. `applications/PoP/CLAUDE.md`): **SDLPoP — источник истины**,
|
||||
перед кодингом читать соответствующий код seg*.c, не гадать.
|
||||
|
||||
---
|
||||
|
||||
## 0. КОНТЕКСТ: текущее состояние `applications/SprPoP` (что уже готово)
|
||||
|
||||
SprPoP — живой прототип порта PoP: комната 1 уровня 1 живой композицией
|
||||
тайлов + Kid (анимация/управление/коллизия/падение/зацеп/переходы). Собрать:
|
||||
`cd applications/SprPoP && make`. Тест в MAME: см. memory
|
||||
`mame_mcp_bridge`/`mame_hdd_test_disk` (канонический цикл: `make` → пересобрать
|
||||
`mame/v306/IMG/test_hdd.chd` через `toolchain/make_hdd.sh` со всеми ассетами →
|
||||
рестарт `run_bridge.sh` → `resume` → ~13с бут → `type_string("d:{ENTER}SprPoP.exe{ENTER}")`).
|
||||
Отладка: клавиши `1`=freeze / `2`=resume в SprPoP; MCP-мост `mame-z80`
|
||||
(read_logical_memory, set_breakpoint, disassemble); адреса символов —
|
||||
`.sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map` (сдвигаются при пересборке!).
|
||||
|
||||
### Модули (все в `applications/SprPoP/`)
|
||||
- `sprpop.c` — главный цикл (дабл-буфер 2 стр.), `enter_room(room)`,
|
||||
обработчики переходов. File-static рабочие массивы (W2):
|
||||
`room_fg[30]`, `room_bg[30]`, `lcol_fg/lcol_bg[3]`, `rcol_fg/rcol_bg[3]`,
|
||||
`below_fg[10]`, `cur_room`.
|
||||
- `pop_level.c/.h` — **уровень из файла** (Фаза L1):
|
||||
- `pop_level_load("res2001.bin")` — читает сырой blueprnt в EMM-страницу
|
||||
(данные с offset `0x100`, ISR-стаб как атлас).
|
||||
- `pop_room_load(room, fg,bg, lcol_fg,lcol_bg, rcol_fg,rcol_bg, below_fg)` —
|
||||
извлекает комнату (fg маскирован `&0x1F`, bg raw) + срезы соседей:
|
||||
leftcol=col9 левого соседа, rightcol=col0 правого, belowrow=row0 нижнего.
|
||||
- `pop_room_link(room, side)` — связь (side 0=L,1=R,2=U,3=D; 0=нет).
|
||||
- `pop_level_start_room/pos/dir()`.
|
||||
- `pop_bg.c/.h` — отрисовка тайлов (порт seg008 draw_tile), fore-окклюзия над
|
||||
Kid (`pop_fore_over_char`, порт set_char_collision+redraw_at_char/char2),
|
||||
**loose-полы** (shake/bake/mob). `draw_tile` — статическая, знает
|
||||
`draw_gate_back` (грань гейта из левой комнаты).
|
||||
- `pop_kid.c/.h` — анимация Kid (интерпретатор seqtbl `play_seq`, порт seg006),
|
||||
`Kid` struct (frame,x,y,dir,curr_col,curr_row,action,fall_x,fall_y,repeat,
|
||||
curr_seq); `knock`-флаг; `kid_cur_dx/flags`, `kid_fp_*` (футпринт).
|
||||
- `pop_ctrl.c/.h` — ввод (порт seg005 control) через `<kbd_raw.h>`.
|
||||
- `pop_map.c/.h` — коллизия/физика (порт seg005/006). Ключевое:
|
||||
- `pop_map_set(fg)` — карта текущей комнаты.
|
||||
- `pop_map_set_edges(l,r,u,d, lcol_fg, rcol_fg)` — связи + кромки швов для
|
||||
коллизии (`get_tile(col=-1)`=lcol, `get_tile(col=10)`=rcol; порт
|
||||
find_room_of_tile).
|
||||
- `pop_phys_tick()` — кадр физики (fall/land/wall/knock/leave).
|
||||
- Переходы: `pop_fell_out` (вниз, y>=211), `pop_leave_dir` (1=left,2=right,
|
||||
x∓140).
|
||||
- **loose-состояние**: `pop_loose_modif[30]` (публично, читает pop_bg),
|
||||
`loose_bake[30]`, `loose_rest[30]` (static); `pop_loose_tick()`,
|
||||
`pop_loose_reset()` (сброс при смене комнаты).
|
||||
|
||||
### Что сделано по фазам
|
||||
- **L1** — данные уровня из файла (room1_data.h удалён). Коммит `21f978d`.
|
||||
- **L2** — переход в комнату снизу (провал/спуск), фиксы окклюзии. Коммит `7f3e32d`.
|
||||
- **L3** — переходы вбок (право+лево) через швы. **Не закоммичено** на момент
|
||||
написания (вместе с этим планом). **L3-вверх (climb-up в комнату сверху) —
|
||||
НЕ сделано.**
|
||||
- **Loose-полы** (тряска knock / падение mob+окклюзия) — коммит `8b30dc2`.
|
||||
|
||||
### Известные ОГРАНИЧЕНИЯ (важно для этого плана)
|
||||
1. **Нет персистентности тайлов**: `enter_room`→`pop_room_load` каждый раз
|
||||
перезагружает ИСХОДНЫЕ тайлы из level-страницы. Изменения (упавший loose,
|
||||
открытый гейт) при повторном входе ТЕРЯЮТСЯ. Для кнопок/гейтов это
|
||||
блокер (см. P0).
|
||||
2. **Нет HP/смерти** Кида (нужно для пик).
|
||||
3. Loose-механика — частный случай trob (нужно обобщить).
|
||||
|
||||
---
|
||||
|
||||
## 1. ДАННЫЕ УРОВНЯ (формат, offsets, объекты)
|
||||
|
||||
Сырой `res2001.bin` (2305 Б) = blueprnt DAT 1.0 (Table 6 в
|
||||
`POP-DAT-FormatSpecifications.txt`). Читается в EMM-страницу с offset `0x100`.
|
||||
Тайл-код = байт `& 0x1F`; верхние биты (модификатор BLUETYPE) сейчас отброшены.
|
||||
|
||||
| Блок | Offset | Размер |
|
||||
|------|--------|--------|
|
||||
| foretable (fg) | 0 | 720 (24 комн × 30) |
|
||||
| backtable (bg=modifier) | 720 | 720 |
|
||||
| **LINKLOC** (doorlink1) | **1440** | 256 |
|
||||
| **LINKMAP** (doorlink2) | **1696** | 256 |
|
||||
| links (roomlinks) | 1952 | 96 (24×{L,R,U,D}) |
|
||||
| start_position | 2112 | 3 (room,pos,dir) |
|
||||
|
||||
Тайл-коды: `0x00`empty `0x01`floor `0x02`**SPIKE** `0x03`pillar `0x04`**GATE**
|
||||
`0x06`**DROP-кнопка(closer)** `0x0B`loose `0x0F`**RAISE-кнопка(opener)**
|
||||
`0x10`lvldoor-L `0x11`lvldoor-R `0x13`torch `0x14`wall.
|
||||
|
||||
### Объекты уровня 1 (по комнатам)
|
||||
```
|
||||
room 5: DROP(0,2)m11 RAISE(0,4)m9 GATE(0,5)m2 RAISE(0,6)m8 GATE(0,9)m1
|
||||
room 6: RAISE(0,2)m7 SPIKE(2,3) SPIKE(2,4) <-- тестовая
|
||||
room 7: RAISE(0,2)m6 GATE(0,9)m2
|
||||
room 8: RAISE(0,6)m5 GATE(0,9)m2 RAISE(1,7)m4
|
||||
room 9: RAISE(0,0)m3 (+ lvldoor(1,3)/(1,4) — выход на level2, отложено)
|
||||
room12: RAISE(0,3)m2 GATE(0,9)m2 SPIKE(2,4)
|
||||
room10/13/14/16/19/24: только SPIKE
|
||||
room20: DROP(1,4)m1 RAISE(1,7)m0
|
||||
```
|
||||
(m = modifier тайла = ИНДЕКС в LINKLOC/LINKMAP.)
|
||||
|
||||
### Room6 (тестовая) — раскладка
|
||||
```
|
||||
fg row0: 13 01 0F 00 03 01 01 13 01 03 (0,2)=RAISE-кнопка, (0,0)/(0,7)=torch
|
||||
fg row1: 14 14 14 00 14 14 14 14 14 14 (1,3)=empty
|
||||
fg row2: 14 14 14 02 02 14 14 14 14 14 (2,3)(2,4)=SPIKE
|
||||
links: L=8 R=2 U=5 D=0
|
||||
```
|
||||
- **Шахта пик**: col3 (row0=empty, row1=empty, row2=spike) + col4 (spike).
|
||||
- **Гейт, видимый у ЛЕВОЙ кромки room6, — это гейт room8 (0,9)**, отрисованный
|
||||
в col0 room6 через левый шов (L=8, leftcol=col9 room8). В room6 гейта НЕТ.
|
||||
|
||||
### Связь кнопка→цель (декод LINKLOC/LINKMAP), проверено:
|
||||
- `get_doorlink_tile(i) = d1[i] & 0x1F`
|
||||
- `get_doorlink_next(i) = !(d1[i] & 0x80)` (0 = конец цепочки)
|
||||
- `get_doorlink_room(i) = ((d1[i]&0x60)>>5) + ((d2[i]&0xE0)>>3)`
|
||||
- `get_doorlink_timer(i) = d2[i] & 0x1F`
|
||||
- где `d1`=LINKLOC@1440, `d2`=LINKMAP@1696. Цепочка: idx++ пока next.
|
||||
|
||||
**Кнопка room6 (0,2) mod=7 → цель: room8 tile(0,9) = ГЕЙТ.** Подтверждено
|
||||
в SDLPoP-скринах: нажатие кнопки поднимает решётку у левой кромки room6.
|
||||
Связь КРОСС-КОМНАТНАЯ (кнопка в room6, гейт в room8) и задаётся таблицей,
|
||||
а НЕ позицией. Кнопка может открывать НЕСКОЛЬКО гейтов в разных комнатах.
|
||||
|
||||
---
|
||||
|
||||
## 2. МЕХАНИКА SDLPoP (точные ссылки)
|
||||
|
||||
### 2.1 Trob-система (анимируемые тайлы)
|
||||
- `add_trob(room,tilepos,type)` seg007:0A5A — в список анимируемых.
|
||||
- Каждый кадр `redraw_needed_tiles`/`process_trobs` продвигает; диспетч по
|
||||
типу тайла → `animate_button/animate_door/animate_spike/animate_loose`
|
||||
(seg007:0033+ таблица `animate_*`).
|
||||
- Состояние тайла хранится в `curr_room_modif[tilepos]` (per-room modifier).
|
||||
- У нас есть частный случай для loose (`pop_loose_tick`+`pop_loose_modif[30]`).
|
||||
|
||||
### 2.2 Пики (spike)
|
||||
- **Триггер выдвижения** — `check_spike_below()` seg006:1199 (зовётся в
|
||||
физике Кида каждый кадр): для каждой колонки футпринта Кида
|
||||
(`get_tile_div_mod_m7(char_x_left)`..`char_x_right`) идёт ВНИЗ от
|
||||
`Char.curr_row` через НЕ-floor тайлы; если встретил `tiles_2_spike` →
|
||||
`start_anim_spike(room,tilepos)`. → Кид у края (0,2) правым краём задевает
|
||||
col3 → скан вниз col3 (empty/empty/spike) → пики вылезают.
|
||||
- `start_anim_spike` seg007:596: если `modif<=0`: `modif==0` → add_trob(type1)
|
||||
+ звук; `modif<0` (кроме 0xFF disabled) → `modif=0x8F`.
|
||||
- `animate_spike` seg007:317: автомат по modif — выдвиг `++modif` (1..4; на 5 →
|
||||
`0x8F`; на 9 → 0, trob кончился); убирание `& 0x80` → `--modif` (на 0 → `=6`).
|
||||
`0xFF` = disabled (не двигать).
|
||||
- `is_spike_harmful` seg007:1178: modif `0/-1`→0 (безопасно); `<0`→1;
|
||||
`1..4`→2; `>=5`→0.
|
||||
- **Смерть**: `check_spiked` seg006:0968 — если тайл под Кидом = spike И harmful
|
||||
И кадр бега (7..14) / старта прыжка (34..39) с harmful>=2, ИЛИ кадр приземления
|
||||
(43/26) с harmful!=0 → `spiked()`. Падение на пики — отдельный путь (см.
|
||||
`is_dead` seg006:1907, frame_177_spiked). Осторожный ШАГ по невыдвинутым — ок.
|
||||
|
||||
### 2.3 Кнопки
|
||||
- `trigger_button(playsound, button_type, modifier)` seg007:0C53: modifier =
|
||||
индекс LINKLOC. `link_timer = get_doorlink_timer(mod)`; если `!=0x1F`
|
||||
(не заклинено): `set_doorlink_timer(mod,5)`; если был `<2` → `add_trob`
|
||||
(кнопка нажимается) + звук; затем `do_trigger_list(mod, button_type)`.
|
||||
- `do_trigger_list` seg007:09E5: идёт по цепочке LINKLOC от idx, для каждой
|
||||
цели `trigger_1(target_type,room,tilepos,button_type)` → если >=0
|
||||
`add_trob(room,tilepos,result)`.
|
||||
- `animate_button` seg007:0D3A: `timer=get_doorlink_timer(mod)-1`;
|
||||
`set_doorlink_timer(mod,timer)`; `timer<2` → кнопка отжимается.
|
||||
- Когда Кид ВСТАЁТ на кнопку: `check_press`-путь seg006 (opener → trigger_button,
|
||||
closer → тоже; если Кид мёртв — `died_on_button`). RAISE=`tiles_15_opener`
|
||||
(0x0F), DROP=`tiles_6_closer` (0x06).
|
||||
|
||||
### 2.4 Гейты
|
||||
- `trigger_1` seg007:0999 → для `tiles_4_gate` → `trigger_gate`.
|
||||
- `trigger_gate(room,tilepos,button_type)` seg007:092C: modif = высота открытия.
|
||||
opener: `0xFF`→игнор; `>=188`(открыт)→держать `238`; иначе `modif=(modif+3)&0xFC`,
|
||||
return 1 (открывать). closer/иначе: `modif!=0` → return 3 (закрыть быстро).
|
||||
- `animate_door` seg007:0522: анимация открытия/закрытия; `door_delta[]={-1,4,4}`,
|
||||
`gate_close_speeds[]={0,0,0,20,40,60,80,100,120}`. Гейт медленно закрывается
|
||||
после истечения таймера кнопки.
|
||||
- Отрисовка: кадры гейта; у нас `draw_gate_back` в pop_bg (грань из левой комн.).
|
||||
- **Проходимость**: Кид блокируется недостаточно открытым гейтом (коллизия
|
||||
как стена, порог по высоте открытия); проходит при `modif` открытом.
|
||||
|
||||
---
|
||||
|
||||
## 3. Поправки к описанию пользователя (что важно)
|
||||
|
||||
1. Гейт НЕ в room6 (0,0) — он в **room8 (0,9)**, виден через левый шов; связь
|
||||
кросс-комнатная (по таблице LINKLOC, не по позиции).
|
||||
2. Кнопка может открывать несколько гейтов в разных комнатах.
|
||||
3. Пики выдвигаются по `check_spike_below` (Кид над колонкой с пиками),
|
||||
имеют состояния (не всегда смертельны), убираются со временем.
|
||||
4. **Кросс-комнатное состояние тайлов ДОЛЖНО ПЕРСИСТИТЬ** — блокер (см. P0).
|
||||
5. HP/смерть — новая подсистема.
|
||||
6. Кнопка сама анимируется (нажата/отжата).
|
||||
|
||||
---
|
||||
|
||||
## 4. ПЛАН РЕАЛИЗАЦИИ (фазы)
|
||||
|
||||
### P0 — Персистентное per-room modifier-состояние + trob-каркас (ПРЕРЕКВИЗИТ)
|
||||
**Проблема:** нажатие кнопки в room6 меняет modif гейта room8 (не текущей
|
||||
комнаты); при входе в room8 нужно отрисовать гейт в текущем состоянии. Плюс
|
||||
это чинит «re-entry восстанавливает тайлы» (loose/гейты).
|
||||
|
||||
Дизайн (предложение — уточнить в реализации):
|
||||
- Массив `room_modif[24][30]` (или lazy per-visited-room) — modifier каждого
|
||||
тайла каждой комнаты. Инициализируется из bg уровня при первой загрузке
|
||||
комнаты; далее ЖИВЁТ (не перезагружается). ~720 Б — влезает в W2 (или в
|
||||
EMM-страницу уровня рядом с данными: остаётся >10КБ).
|
||||
- `enter_room` берёт modif из `room_modif[room]`, а fg — из level-страницы
|
||||
(fg почти не меняется; исключения — loose→empty, надо тоже персистить: либо
|
||||
отдельный `room_fg_override`, либо флаг «loose упал»).
|
||||
- Обобщить loose-trob: единый список trob (room,tilepos,type) + диспетчер
|
||||
`animate_*` по коду тайла. Loose (`pop_loose_*`) — перевести на него.
|
||||
- Кросс-комнатный trigger: `add_trob` в НЕ текущую комнату меняет
|
||||
`room_modif[room][tilepos]`; анимация продвигается даже для невидимой комнаты
|
||||
(в оригинале — да; можно упростить: для невидимой комнаты гейт сразу в
|
||||
финальном состоянии, анимировать только при видимости — решить при реализации).
|
||||
|
||||
### Фаза S — Пики (самодостаточно; отладит HP/смерть)
|
||||
Порядок:
|
||||
1. `room_modif` для пик (из P0 или временно локально).
|
||||
2. `check_spike_below` (порт seg006:1199) — в `pop_phys_tick`.
|
||||
3. `start_anim_spike` + `animate_spike` (порт seg007) — состояние в modif.
|
||||
4. `is_spike_harmful` + `check_spiked` (порт seg006:0968).
|
||||
5. **HP/смерть**: ввести `hitp_curr` (старт напр. 3); `take_hp`; при пиках —
|
||||
мгновенная смерть; seq смерти (`seq_22_crushed`/`frame_177_spiked`..185);
|
||||
анимация смерти; респавн (kid_init на старте или чек-поинт).
|
||||
6. Отрисовка: кадры выдвижения пик. В pop_bg есть `SPIKES_FRAM_RIGHT` — нужны
|
||||
pop-out кадры (spikes_fram по modif) + fore над Кидом.
|
||||
|
||||
### Фаза B — Кнопки + гейты (нужен P0)
|
||||
Порядок:
|
||||
1. Доступ к LINKLOC/LINKMAP из pop_level (добавить геттеры doorlink1/2[i] с
|
||||
маппингом W0 или скопировать таблицы в W2 при load — 512 Б).
|
||||
2. `pop_map` детект «Кид встал на кнопку» (check_press-путь) → `trigger_button`.
|
||||
3. `trigger_button` → таймер + `do_trigger_list` (обход цепочки) →
|
||||
`trigger_gate` для целей → изменить `room_modif[целевой]`.
|
||||
4. `animate_button` (кнопка отжимается) + `animate_door` (гейт откр/закр +
|
||||
авто-закрытие).
|
||||
5. Отрисовка гейта (кадры по modif) через ЛЕВЫЙ шов (гейт room8 в room6) +
|
||||
при входе в room8. Расширить `draw_gate_back`/добавить `draw_gate`.
|
||||
6. Коллизия: закрытый гейт = стена (порог по высоте открытия); открытый —
|
||||
проход. Учесть кросс-комнатный гейт на шве (проход влево room6→room8).
|
||||
|
||||
**Порядок фаз:** P0 → S → B. S в основном независим (кроме HP/trob-каркаса),
|
||||
но проще и отладит смерть/анимацию; B требует P0 (кросс-комнатное состояние).
|
||||
|
||||
---
|
||||
|
||||
## 5. Открытые вопросы / грабли
|
||||
- Персистентность `fg` для loose (тайл→empty): решить в P0 (override-массив или
|
||||
флаг), иначе упавший loose «вернётся».
|
||||
- Анимация trob в НЕВИДИМОЙ комнате: упростить (финальное состояние сразу) или
|
||||
портировать честно.
|
||||
- Двоебуфер: любой транзиент (пики/гейт/кнопка) финализировать перерисовкой
|
||||
«покоя» на ОБЕИХ страницах (урок из loose — см. memory `pop_loose_floors`).
|
||||
- Дверь уровня (lvldoor room9) + переход на level 2 — ОТДЕЛЬНО, отложено.
|
||||
- L3-**вверх** (climb-up в комнату сверху) — ещё не сделан; можно закрыть до
|
||||
объектов или параллельно.
|
||||
@@ -0,0 +1,142 @@
|
||||
# План: модульные тесты движка SprPoP под ucsim_z80
|
||||
|
||||
Обвязка общая — `testkit/` в корне репозитория (там же объяснение, почему
|
||||
прогон именно под z80, а не хостовым gcc). Наборы лежат в
|
||||
`../SprPoP/tests-host/`.
|
||||
|
||||
Задача плана: **перестать чинить одно и то же дважды**. За два прогона
|
||||
уровня 1 (2026-08-03) закрыто восемь корней, и часть из них — регрессии
|
||||
соседней механики, внесённые предыдущим фиксом. Такие вещи ловятся тестом
|
||||
за миллисекунды, а в MAME — часами ручного вождения Кида.
|
||||
|
||||
## Что уже есть
|
||||
|
||||
| набор | модуль | статус |
|
||||
|-------|--------|--------|
|
||||
| `t_geom` | `pop_geom.c` | 39 проверок, включая побитовую сверку asm-LCG с 32-битной формулой на 128 шагах |
|
||||
|
||||
`pop_geom.c` выбран первым, потому что не тянет ничего за собой. Дальше
|
||||
начинаются швы.
|
||||
|
||||
## Фаза 1. Два шва (блокирует всё остальное)
|
||||
|
||||
### 1.1 Доступ к странице уровня
|
||||
|
||||
`pop_level.c` ходит по абсолютным адресам: `gfx_w0_map(lvl_page)`, затем
|
||||
разыменование `(uint8_t *)(LVL_DATA_OFF + …)`. В тестовом бинаре это
|
||||
обращение в никуда.
|
||||
|
||||
Нужен макрос `W0PTR(off)`:
|
||||
|
||||
- на таргете — `((uint8_t *)(off))`, то есть ровно как сейчас;
|
||||
- в тестах — смещение в обычном массиве-подложке.
|
||||
|
||||
Правка механическая и компайл-таймовая, на размер продукта не влияет.
|
||||
Заодно снимает магию абсолютных констант из тела функций.
|
||||
|
||||
Тестовая подложка должна уметь: загрузить синтетическую комнату (10×3
|
||||
байта fg + mod) и целый синтетический уровень на 24 комнаты, чтобы
|
||||
проверять межкомнатные вещи.
|
||||
|
||||
### 1.2 Журналирующий рендерер
|
||||
|
||||
Вместо `pop_bg.c`/`pop_cdraw.c` в тестовый бинарь линкуется модуль с теми
|
||||
же прототипами, который **не рисует, а записывает вызовы**: какой тайл
|
||||
помечен к перерисовке, каким кодом, с каким счётчиком страниц.
|
||||
|
||||
Это не обход проблемы, а самостоятельная ценность: `BUG-GATE-ANIM-1` был
|
||||
ровно такой формы — ворота меняли состояние, но пометка на перерисовку не
|
||||
ставилась. Проверяется утверждением, а не глазами.
|
||||
|
||||
Минимум, который надо перехватывать: `pop_set_redraw`,
|
||||
`pop_set_redraw_above`, `pop_loose_mob_spawn`, `pop_gate_redraw`.
|
||||
|
||||
## Фаза 2. Регрессионные кейсы из `BUGS_CLOSED.md`
|
||||
|
||||
После швов `BUGS_CLOSED.md` превращается в готовую спецификацию: у каждой
|
||||
записи есть симптом и ожидаемое поведение. Кандидаты, которые ловятся
|
||||
логикой (без отрисовки и без железа):
|
||||
|
||||
| баг | что закрепить тестом |
|
||||
|-----|----------------------|
|
||||
| `BUG-LVLSTATE-1` | запись тайла переживает выход из комнаты |
|
||||
| `BUG-RESPAWN-1` | рестарт уровня возвращает ВСЕ тайлы из эталонной копии |
|
||||
| `BUG-RESPAWN-2` | рестарт возвращает таблицу стражей; убитый снова жив |
|
||||
| `BUG-GATE-ANIM-1` | смена состояния ворот ставит пометку `POP_RD_GATE`; закрывающиеся — на обе страницы, открывающиеся — на одну |
|
||||
| `BUG-COLL-1` | `check_collisions` сканирует ряд справа налево и выбирает НАИМЕНЬШУЮ занятую колонку |
|
||||
| `BUG-STANDUP-1` | `bumped_floor` у трупа (`alive >= 0`) только выравнивает и не трогает последовательность |
|
||||
| `BUG-DEATH-1` | `hitp_curr == 0` при живом Киде переводит его в «умирает» ровно один раз |
|
||||
| `BUG-LOOSE-2` | кусок, начавший падать, долетает и кладёт щебень ПОСЛЕ смены комнаты |
|
||||
| `BUG-CEIL-2` | loose-плита ряда 2 верхнего соседа живёт как «ряд −1» |
|
||||
|
||||
`BUG-LOOSE-2` стоит взять первым: он до сих пор помечен в `BUGS_OPEN.md`
|
||||
как непроверенный именно потому, что гонку «уйти из комнаты раньше, чем
|
||||
долетит плита» через мост MAME воспроизвести не удалось. На уровне логики
|
||||
это несколько строк — заспавнить кусок, сменить комнату, тикать до
|
||||
приземления, проверить щебень в данных уровня.
|
||||
|
||||
Не берутся (нужна картинка либо железо): `BUG-DOOR-CLIP`, `BUG-CEIL-1`,
|
||||
`BUG-CEIL-3`, `BUG-OCCL-1`, `BUG-KBD-4`, `BUG-3`.
|
||||
|
||||
## Фаза 3. Сценарные тесты
|
||||
|
||||
Сейчас шаг кадра размазан по `main()` в `sprpop.c`. Вынести его в
|
||||
`pop_frame_tick()` — тогда появляются тесты вида «поставить Кида в
|
||||
известное состояние, скормить N тиков ввода, проверить итог»:
|
||||
|
||||
```
|
||||
дано: комната 5, Кид на кнопке (0,6)
|
||||
когда: 40 тиков без ввода
|
||||
тогда: комната по-прежнему 5, Кид на полу ряда 2
|
||||
```
|
||||
|
||||
Это тот самый BUG-STANDUP-1, который ловили потиковой трассой в MAME.
|
||||
|
||||
Ввод подаётся не через `kbd_raw_down()`, а через подменяемый источник —
|
||||
это же даст возможность проигрывать записанные сценарии.
|
||||
|
||||
## Фаза 4. Дифф против SDLPoP
|
||||
|
||||
`SDLPoP/src/` лежит в дереве, собирается на хосте, и там **уже стоят
|
||||
отладочные трассы** (`DBG kidobj tilepos=…` в seg008, `DBG make_loose_fall`
|
||||
в seg007). Значит эталон можно заставить печатать потиковую трассу
|
||||
автоматически.
|
||||
|
||||
Схема: общий формат скрипта ввода и общий формат трассы (тик, frame, x, y,
|
||||
room, col, row, action, alive, hp). Гоняем обе реализации, диффим, первое
|
||||
расхождение — номер тика и есть баг. Это ровно то, что делалось руками
|
||||
через MAME, только бесплатно и повторяемо: `BUG-COLL-1` и `BUG-STANDUP-1`
|
||||
такой дифф нашёл бы за секунды.
|
||||
|
||||
**Лицензия.** SDLPoP — GPLv3, правило подпроекта — читать и переписывать,
|
||||
не линковать. Оракул обязан быть **отдельным исполняемым файлом**,
|
||||
общающимся через файлы трасс, а не слинкованным с нашим кодом в один
|
||||
бинарь.
|
||||
|
||||
Требование к детерминизму: сиды PRNG должны совпадать. У нас
|
||||
`POP_PRANDOM_EXACT` даёт ту же последовательность, что в оригинале, и это
|
||||
уже закреплено тестом `geom_lcg_matches_reference`.
|
||||
|
||||
## Чего эти тесты не поймают
|
||||
|
||||
Отрисовку, банки и W-окна, тайминги, клавиатуру — за этим остаётся MAME.
|
||||
|
||||
И отдельный класс: **баги порядка вызовов**. Свежий пример — окно
|
||||
fore-клипа (`pop_fore_set_clip`) одно на всех, и его ставит каждый, кто
|
||||
рисует персонажа; когда порядок «Кид/страж» стал переменным, окно осталось
|
||||
стражьим, и Кид нарисовался поверх передних столбов. Это не «функция
|
||||
вернула не то», unit-тест такое не видит. Ловится инвариантом,
|
||||
вкомпилированным в safe-сборку: «в момент `pop_fore_over_char` окно клипа
|
||||
принадлежит Киду». Отдельный инструмент, дополняющий тесты.
|
||||
|
||||
## Порядок работ
|
||||
|
||||
1. Шов `W0PTR` + подложка уровня.
|
||||
2. Журналирующий рендерер.
|
||||
3. `BUG-LOOSE-2` — закрыть висящий вопрос.
|
||||
4. Остальные кейсы из таблицы фазы 2.
|
||||
5. `pop_frame_tick()` + сценарные тесты.
|
||||
6. Дифф против SDLPoP.
|
||||
|
||||
Правило приёмки: тест не считается написанным, пока не проверен мутацией —
|
||||
сломать проверяемое место и убедиться, что набор краснеет.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Идеи и вопросы «на подумать» (PoP)
|
||||
|
||||
Не план работ, а список того, что осознанно отложено: каждая запись —
|
||||
гипотеза с причиной, по которой её стоит проверить, и с тем, что мешает
|
||||
сделать это прямо сейчас.
|
||||
|
||||
## Зелье «переворот экрана» (upside-down)
|
||||
|
||||
**Вопрос пользователя (2026-08-01).** Тайлы фона у нас лежат строками, а
|
||||
кадры Кида/стражей — КОЛОНКАМИ (`transpose_cols` в `pop_pack_kid.py`, ради
|
||||
бесплатного горизонтального зеркала). Значит вертикальный переворот для
|
||||
персонажей заметно сложнее, чем для фона. Верно; но прежде чем это чинить,
|
||||
надо знать три факта.
|
||||
|
||||
**Факт 1 — когда оно вообще нужно.** Зелье переворота — тип 4
|
||||
(`proc_get_object`, `seg006.c:1885` → `toggle_upside()`). Скан всех уровней
|
||||
по данным (`res200N.bin`, тайл 10 = зелье, тип в backtable): тип 4
|
||||
встречается **впервые на уровне 9** (две склянки), и больше нигде. Тип 3
|
||||
(перо, медленное падение) — уровень 7. То есть **до уровня 9 механика не
|
||||
нужна вообще**, и «на первом этапе просто не реализовывать» — не компромисс,
|
||||
а точное соответствие данным уровней 1..8.
|
||||
|
||||
**Факт 2 — что именно делает оригинал.** НЕ переворачивает спрайты.
|
||||
`flip_screen` (`seg009.c:1042`) → `flip_not_ega` (`seg009.c:1023`) меняет
|
||||
местами СТРОКИ готового offscreen-буфера (top↔bottom, порядок пикселей
|
||||
внутри строки не трогает — это вертикальное зеркало, не поворот на 180°).
|
||||
Вызывается вокруг отрисовки кадра целиком (`seg003.c:296..301`): перевернул
|
||||
буфер → дорисовал → перевернул обратно. Так что в оригинале это
|
||||
post-process всего экрана, и вопрос «как перевернуть колоночный спрайт»
|
||||
там просто не возникает.
|
||||
|
||||
**Факт 3 — почему нам этот приём не подходит как есть.** У нас нет шага
|
||||
«готовый offscreen → экран»: рисуем прямо в видеостраницу, а heal берёт фон
|
||||
из ОЗУ-копии этой же страницы. Переворот всей страницы построчно — это
|
||||
320×192 Б копирования КАЖДЫЙ кадр, что мимо бюджета на порядок.
|
||||
|
||||
**Варианты, которые надо будет взвесить (не сейчас):**
|
||||
1. **Предпечённые перевёрнутые атласы.** Второй набор кадров
|
||||
Кида/стража, перевёрнутый по вертикали ещё в `pop_pack_kid.py` (там уже
|
||||
есть транспонирование — добавляется одной строкой). Рантайм: выбор
|
||||
набора + зеркальная арифметика Y. Память: ещё ~28 страниц EMM при
|
||||
бюджете ~3.3 МБ — не проблема. Похоже, самый дешёвый по тактам путь.
|
||||
2. **Фон рисовать с обратным Y** — для row-major тайлов строка остаётся
|
||||
непрерывным accel-прогоном, меняется только адрес назначения; цена —
|
||||
вызов на строку вместо вызова на тайл. Померить, прежде чем закладывать.
|
||||
3. **Аппаратная помощь** — до проектирования проверить, есть ли у
|
||||
акселератора направление копирования «вниз» (обратный инкремент адреса);
|
||||
если есть, вариант 1 может и не понадобиться. Смотреть
|
||||
`docs/new/06-accel.md` и `docs/reference/accel_r.txt`.
|
||||
|
||||
**Почему не сейчас.** Уровни 1..8 этого не требуют, а к уровню 9 у нас уже
|
||||
будет ответ на вопрос «сколько стоит кадр» (задачи CLIP-1/T-2) — без него
|
||||
выбирать между вариантами выше бессмысленно.
|
||||
|
||||
## Заменить генератор псевдослучайных чисел
|
||||
|
||||
Сейчас стоит LCG оригинала, шаг на ассемблере (~1 020 тактов), бит-в-бит
|
||||
совместимый с SDLPoP. Есть более дешёвые Z80-генераторы (86–148 тактов),
|
||||
но потолок выигрыша — 2 814 тактов за кадр, 0.65 %, и он растворяется в
|
||||
обёртках вызова. Тексты процедур, разбор качества и порядок действий —
|
||||
`prng_alternatives.md`. Первый шаг там не про генератор: слить приведение
|
||||
к диапазону в ту же asm-процедуру, чтобы на вызов был один `call`, а не три.
|
||||
|
||||
## Отключать мышь на время игры
|
||||
|
||||
**Гипотеза.** Мышь на Sprinter — источник прерываний (обёртки RST 30h,
|
||||
см. memory `mouse_api`). Игре она не нужна вообще: управление —
|
||||
raw-клавиатура (`<kbd_raw.h>`), которую мы и так забираем у DSS целиком.
|
||||
Значит каждое мышиное прерывание за кадр — украденные такты в бюджете,
|
||||
который у нас и без того занят на 86 %.
|
||||
|
||||
**Откуда взялось (2026-07-30).** При замере бюджета по 100 кадрам три
|
||||
кадра выбились до 552–647 К тактов (1.28–1.51 кадра) при типичных 371 К.
|
||||
Причиной оказалось движение мыши на ХОСТЕ: при неподвижной мыши 225
|
||||
кадров подряд прошли без единого превышения. То есть эффект реальный и
|
||||
измеримый, просто в тесте он был наведён извне.
|
||||
|
||||
**Что проверить.**
|
||||
1. Есть ли у драйвера мыши (RST 30h) функция «выключить/включить» —
|
||||
разобрать список из 14 обёрток; если нет явной, посмотреть, что делает
|
||||
«hide cursor» и снимает ли она обработчик.
|
||||
2. Сколько тактов реально стоит одно мышиное прерывание на нашем железе
|
||||
(замер: breakpoint на входе ISR + totalcycles, при движении мыши).
|
||||
3. Не ломает ли отключение выход в DSS: состояние обязано
|
||||
восстанавливаться при `exit`, включая аварийный (atexit).
|
||||
|
||||
**Почему не сейчас.** Выигрыш проявляется только когда игрок реально
|
||||
двигает мышью, то есть в норме его нет; а риск оставить систему без мыши
|
||||
после выхода — заметный. Делать после того, как закроем стражей и
|
||||
займёмся бюджетом всерьёз (там же, где батчинг кроссбанковых вызовов и
|
||||
возможный возврат `pop_bg` в резидент `--w3`).
|
||||
@@ -0,0 +1,649 @@
|
||||
# Осознанные расхождения с SDLPoP
|
||||
|
||||
Правило подпроекта (`../CLAUDE.md`): расхождение нашей реализации с
|
||||
`SDLPoP/src/` — по умолчанию **баг у нас**. Этот файл — список исключений:
|
||||
мест, где мы сознательно сделали иначе, потому что платформа/ABI/бюджет
|
||||
кадра требуют другого, а НАБЛЮДАЕМОЕ поведение обязано совпадать.
|
||||
|
||||
Формат записи: что делает оригинал → что делаем мы → почему → чем платим и
|
||||
что проверять при регрессе. Если запись перестала быть верной (портировали
|
||||
дословно, отказались от обхода) — удалять, а не оставлять «для истории»:
|
||||
история в git.
|
||||
|
||||
---
|
||||
|
||||
## D-1. История флагов перекрытия у бокового шва: сдвиг вместо тега комнаты
|
||||
|
||||
**Файлы:** `src/pop_map.c` (`pop_coll_shift`, `pop_coll_invalidate`,
|
||||
`check_collisions`), `src/sprpop.c` (`enter_room_side`).
|
||||
**Связанный баг:** BUG-GATE-PASS-1 (`PoP/SprPoP/BUGS_CLOSED.md`).
|
||||
**Дата:** 2026-08-09.
|
||||
|
||||
### Как в оригинале
|
||||
|
||||
`check_collisions` (seg004:0004) вместе с `get_row_collision_data`
|
||||
(seg004:0185) держит **10 слотов** флагов перекрытия и рядом —
|
||||
**параллельный массив номера комнаты**:
|
||||
|
||||
```c
|
||||
row_coll_flags_ptr[tile_col] = curr_flags; /* tile_col — колонка ВНУТРИ разрешённой комнаты (0..9) */
|
||||
row_coll_room_ptr [tile_col] = curr_room; /* и номер этой комнаты */
|
||||
...
|
||||
for (short column = 9; column >= 0; --column) {
|
||||
if (curr_row_coll_room[column] >= 0 &&
|
||||
prev_coll_room[column] == curr_row_coll_room[column]) {
|
||||
if ((prev_coll_flags[column] & 0x0F) == 0 &&
|
||||
(curr_row_coll_flags[column] & 0x0F) != 0)
|
||||
bump_col_left_of_wall = column;
|
||||
...
|
||||
```
|
||||
|
||||
Ключ слота — пара **(колонка в своей комнате, номер комнаты)**. Решётка
|
||||
комнаты 8 и до перехода 8→6, и после лежит в слоте 9 с `room = 8`: история
|
||||
переживает смену комнаты, переход флага 0→1 виден, `bumped()` срабатывает.
|
||||
Комнату оригинал резолвит на лету через `find_room_of_tile` (seg006:005D),
|
||||
никакого кэша всех комнат у него нет.
|
||||
|
||||
### Что делаем мы
|
||||
|
||||
Индекс — **колонка ОТРИСОВАННОЙ комнаты**, диапазон −2…11 (14 слотов,
|
||||
`COLL_C0`/`COLL_N`/`COLL_IDX`), номер комнаты рядом не хранится. При смене
|
||||
комнаты тот же физический тайл менял бы слот на ±10, поэтому раньше история
|
||||
просто выбрасывалась (`pop_coll_invalidate` → `prev = 3` = «уже
|
||||
перекрывал» → бампа нет). Именно это и был BUG-GATE-PASS-1.
|
||||
|
||||
Теперь при **боковом** переходе история не выбрасывается, а
|
||||
**перенумеровывается**: `pop_coll_shift(∓10)` сдвигает `coll_curr`,
|
||||
`coll_above`, `coll_below` на 10 слотов и заполняет освободившиеся
|
||||
тройками. `enter_room_side` зовёт её сразу после `pop_map_set_edges`.
|
||||
|
||||
Корректность держится на том, что `check_leave` двигает `Char.x` ровно на
|
||||
∓140 = 10 тайлов по 14 px, и координата грани (`pop_x_bump[col + …]`)
|
||||
сдвигается на те же 140 вместе с габаритом Кида, — **сами флаги
|
||||
инвариантны**, меняется только номер слота. Сдвигаются `curr/above/below`,
|
||||
а не `prev`: `prev` на следующем кадре всё равно перезапишет
|
||||
`move_coll_to_prev`, выбирая источник как раз из этих трёх.
|
||||
|
||||
Переходы **вверх/вниз** и все прочие входы в комнату (старт уровня,
|
||||
респавн, чит-навигация) остаются на полной инвалидации: там колонки не
|
||||
сдвигаются, но тайлы под ними принадлежат другой комнате — история
|
||||
действительно недействительна.
|
||||
|
||||
### Почему не дословно (вариант A)
|
||||
|
||||
Дословный порт — 10 слотов + параллельный массив номера комнаты, индекс по
|
||||
колонке разрешённой комнаты, бамп только при совпадении номеров; тогда
|
||||
`pop_coll_invalidate` не нужен вовсе, история сама «не совпадает» там, где
|
||||
колонка сменила комнату.
|
||||
|
||||
Не взяли по одной причине: **десяти слотов нам не хватит**. Оригинал
|
||||
перебирает узкое окно вокруг Кида (от `col(char_x_left_coll) − 1` до
|
||||
`col(char_x_right_coll) + 2`), поэтому коллизии слотов у него практически
|
||||
не случаются. Мы держим четырнадцать колонок (−2…11) — при узком окне это
|
||||
не мешает, а вот в десять слотов колонки −2/−1 и 8/9 сядут поверх 8/9.
|
||||
|
||||
**Окно перебора с 2026-08-09 у нас такое же, как в оригинале** (было: все
|
||||
четырнадцать колонок каждый кадр). Признак годности слота при этом не
|
||||
массив номеров комнат, как у оригинала, а ГРАНИЦЫ окна — четыре байта,
|
||||
которые `move_coll_to_prev` переносит в `prev` вместе с флагами; сравнение
|
||||
идёт по пересечению двух окон. Очистки массивов нет вовсе, то есть это
|
||||
дешевле оригинала, а смысл тот же (у него слот вне окна помечен
|
||||
`row_coll_room = −1` и в цикл бампа не попадает). `check_chomped_flags`
|
||||
тоже ограничен окном — иначе протухшие слоты дали бы фантомный перемол.
|
||||
|
||||
### Чем платим
|
||||
|
||||
- Расхождение структур: если в будущем понадобится знать, из какой комнаты
|
||||
пришёл тайл конкретного слота, этого у нас нет — придётся идти в вариант A.
|
||||
- Границы окна надо переносить везде, где переносятся флаги: `pop_coll_shift`
|
||||
двигает и их, `move_coll_to_prev` снимает их в `prev`. Забыть один из
|
||||
переносов = молча потерять или, наоборот, разрешить лишний бамп.
|
||||
- Сдвиг работает только для чисто горизонтальных переходов на ровно 10
|
||||
колонок. Любая будущая диагональ/иная ширина комнаты его сломает молча.
|
||||
- `coll_last_row`: `pop_coll_invalidate` прячет прошлый ряд, чтобы
|
||||
`pop_coll_shift` мог отменить инвалидацию. Порядок вызовов в
|
||||
`enter_room_side` (сначала `pop_map_set_edges`, потом `pop_coll_shift`)
|
||||
стал значимым.
|
||||
|
||||
### Что проверять при регрессе
|
||||
|
||||
Это сердце коллизии, вокруг которого разбирался BUG-SEAM-PINGPONG. После
|
||||
любой правки здесь — прогон швов:
|
||||
|
||||
1. Уровень 1, комнаты 6 ↔ 8, закрытая решётка, **обе** стороны.
|
||||
2. Оба режима подхода: мелким шагом (упереться) и с разбега (не пройти
|
||||
насквозь).
|
||||
3. Проверить, что пинг-понг у шва не вернулся (экран не перескакивает
|
||||
туда-сюда на кадре бампа о ворота).
|
||||
4. `make -C SprPoP/tests-host` — наборы `t_wall`/`t_char` ходят по этой же
|
||||
геометрии.
|
||||
|
||||
---
|
||||
|
||||
## D-2. Кнопка в шве: перерисовываем, хотя оригинал не перерисовывает
|
||||
|
||||
### Что делает оригинал
|
||||
|
||||
Тайл-«трансформер» (кнопка, ворота, пика) перерисовывается только если он в
|
||||
ОТРИСОВАННОЙ комнате: `redraw_11h` → `redraw_tile_height` →
|
||||
`get_trob_pos_in_drawn_room` (seg007:0258), а та для `trob.room != drawn_room`
|
||||
возвращает 30 — заведомо несуществующий tilepos, то есть «не рисовать».
|
||||
Исключение сделано ровно одно — факелы (`animate_torch`, seg007:03CF, ветка
|
||||
`trob.room == room_L && tilepos % 10 == 9`).
|
||||
|
||||
Кнопка соседа слева при этом ВЛИЯЕТ на картинку: `get_tile_to_draw`
|
||||
(seg008:253) подменяет нажатый `tiles_15_opener` на `tiles_1_floor`, а
|
||||
`load_leftroom` (seg008:360) кладёт результат в `leftroom_[row]`, откуда он
|
||||
приходит в `draw_tile` как `tile_left`. У пола правая грань есть, у кнопки
|
||||
нет — значит в оригинале нажатие кнопки, видимой через левый шов, меняет
|
||||
картинку только при следующей ПОЛНОЙ отрисовке комнаты.
|
||||
|
||||
### Что делаем мы
|
||||
|
||||
Перерисовываем шов сразу: `seam_row_sig` (sprpop.c) подмешивает в сигнатуру
|
||||
ряда бит «кнопка нажата» (`pop_doorlink2(mod) & 0x1F > 1`) для тайлов
|
||||
`0x0F`/`0x06`, и change-driven редрой `pop_room_redraw_seam_left` срабатывает
|
||||
на нём так же, как на openness ворот.
|
||||
|
||||
### Почему
|
||||
|
||||
Голый порт давал видимый залип (SEAM-BUTTON-STALE, PoP/SprPoP/BUGS_CLOSED.md): кнопка
|
||||
(1,9) комнаты 11 — она же (1,−1) комнаты 24 — оставалась нарисованной в том
|
||||
состоянии, в каком была на входе в комнату, хотя связь срабатывала. Сигнатура
|
||||
шва следила только за `room_modif`, а у кнопки `modif` — это ИНДЕКС LINKLOC,
|
||||
константа уровня: нажатие живёт в `doorlinks2` и в сигнатуру не приходило
|
||||
никогда. Добавить кнопку в сигнатуру — те же три сравнения на кадр, что уже
|
||||
делались для ворот; воспроизводить артефакт оригинала смысла нет.
|
||||
|
||||
### Чем платим
|
||||
|
||||
- Резидент +200 Б (`_CODE` 24 739 → 24 939), куча W2 1795 → 1595 Б. Если
|
||||
станет тесно — `seam_row_sig` переносится в банк 7 к
|
||||
`pop_room_redraw_seam_left`, ценой одного трамплина на кадр.
|
||||
- Сигнатура ряда стала разнотипной: для кнопки это булев бит, для остальных
|
||||
тайлов — modif. Значения между собой не сравниваются (сравнивается только
|
||||
ряд сам с собой), но при добавлении нового типа тайла в шов про это надо
|
||||
помнить.
|
||||
|
||||
### Что проверять при регрессе
|
||||
|
||||
Уровень 5, кнопка нижних ворот комнаты 24 (она же (1,9) комнаты 11), оба
|
||||
направления: нажать её из комнаты 11 и войти в 24; и наоборот — войти в 24
|
||||
поверху и наступить на неё, стоя в шве. Картинка кнопки обязана совпадать
|
||||
со статусом ворот в обоих случаях.
|
||||
|
||||
---
|
||||
|
||||
## Перо (медленное падение) ловит ТОЛЬКО Кида
|
||||
|
||||
**Оригинал** (`fall_accel`, seg006:057C): `is_feather_fall` — глобальный флаг,
|
||||
и медленное падение достаётся ЛЮБОМУ персонажу, который окажется в `Char`,
|
||||
пока эффект жив. То есть страж, сошедший с уступа в те же секунды, парит
|
||||
вместе с Кидом, хотя зелье пил не он. SDLPoP считает это багом и чинит
|
||||
опцией `fix_feather_fall_affects_guards`.
|
||||
|
||||
**Мы** берём поведение С ФИКСОМ: `pop_feather` проверяется вместе с
|
||||
`Char.charid == CHARID_0_KID` — и в `fall_accel` (`pop_map.c`), и в опкоде
|
||||
`JMP_IF_FEATHER` интерпретатора seqtbl (`pop_kid.c`), чтобы физика и анимация
|
||||
не разъехались.
|
||||
|
||||
**Чем платим.** Сцена, где страж падает при живом пере, будет выглядеть иначе,
|
||||
чем в DOS-оригинале (у нас он падает нормально, там — парит). На уровне 7,
|
||||
единственном с этим зельем, такой сцены нет: зелье в комнате 1, стражи — в
|
||||
других комнатах.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 7: выпить зелье в комнате 1, тут же
|
||||
столкнуть стража в провал — он обязан падать БЫСТРО, а Кид рядом — медленно.
|
||||
|
||||
---
|
||||
|
||||
## Синее зелье («−HP») не ставит свою вспышку
|
||||
|
||||
**Оригинал** (`proc_get_object`, seg006:1892): ветка `case 5` только глушит
|
||||
звуки, играет `sound_13_kid_hurt` и ставит `hitp_delta`. Экран краснеет не
|
||||
здесь, а общим механизмом «Кид ранен» (`flash_if_hurt`, seg003:0AFC).
|
||||
|
||||
**Мы** раньше ставили в этой ветке ещё и `pop_flash_*` (красную вспышку на 2
|
||||
кадра) — то есть красили экран дважды: своей вспышкой и кадром урона.
|
||||
Приведено к оригиналу: ветка правит только `hitp_delta`, краснеет `pop_kid_hurt`.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 2, комната 13, зелье `(1,3)`: выпить —
|
||||
HP убавляется на единицу, экран краснеет РОВНО один раз (без двойного строба).
|
||||
|
||||
---
|
||||
|
||||
## Переворот (зелье инверсии) применяется НА ГРАНИЦЕ КАДРА, а не мгновенно
|
||||
|
||||
**Оригинал** (`toggle_upside`, seg000:15E9): `upside_down = ~upside_down` и
|
||||
`need_redraw_because_flipped = 1` — флаг переключается прямо в момент глотка,
|
||||
то есть в середине кадра. Оригиналу это ничего не стоит: он ВСЕГДА рисует в
|
||||
offscreen неперевёрнутым, а зеркалит только при выводе на экран
|
||||
(`flip_screen` вокруг `copy_screen_rect`, seg000:939/946). Внутренние
|
||||
координаты у него от переворота не зависят вообще.
|
||||
|
||||
**Мы** offscreen-буфера не имеем (две видеостраницы + теневая ОЗУ-копия на
|
||||
каждую), поэтому рисуем зеркально сразу — переворот «зашит» в координаты
|
||||
каждого слоя. Из-за этого момент переключения важен: зелье выпивается из
|
||||
`play_seq`, то есть в СЕРЕДИНЕ кадра, и остаток кадра рисовался бы уже
|
||||
зеркально поверх ещё неперевёрнутого фона. Хуже всего пламя факела — оно
|
||||
ЗАПЕКАЕТСЯ в ОЗУ-копию (`pop_torch_draw`, у него нет heal: каждый следующий
|
||||
кадр непрозрачно накрывает предыдущий). Кадр пламени, положенный в
|
||||
зеркальную позицию на старом фоне, оставался там навсегда — по комнате
|
||||
рассыпались лишние языки огня.
|
||||
|
||||
Поэтому у нас два флага: `pop_upside_want` (пишут зелье, смерть Кида, чит U)
|
||||
и `pop_upside` (читают все слои отрисовки). Переключение — ровно одно место,
|
||||
начало кадра, вместе с перерисовкой: главный цикл делает
|
||||
`pop_upside = pop_upside_want` и зовёт `pop_flip_screen`.
|
||||
|
||||
Сама перерисовка при этом СОВПАДАЕТ с оригиналом: там на
|
||||
`need_redraw_because_flipped` вызывается `redraw_screen(0)` — полная
|
||||
отрисовка, а не отражение уже нарисованного. У нас то же самое —
|
||||
`pop_flip_screen` рисует комнату заново (и получает чистый фон по
|
||||
построению), а вторую страницу дабл-буфера отдаёт копией акселератора.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 9: выпить зелёное зелье — картинка
|
||||
переворачивается ровно один раз, лишних языков пламени по комнате нет. Чит
|
||||
U даёт тот же результат (он идёт тем же путём).
|
||||
|
||||
---
|
||||
|
||||
## Окклюзия воротами: спрашиваем про рисуемого персонажа, а не жёстко про Кида
|
||||
|
||||
**Оригинал** (`draw_tile_fore`, seg008:0D15) первой строкой:
|
||||
|
||||
```c
|
||||
if (tile_left == tiles_4_gate && Kid.curr_row == drawn_row &&
|
||||
Kid.curr_col == drawn_col - 1 && Kid.room != room_R)
|
||||
draw_gate_fore();
|
||||
```
|
||||
|
||||
То есть бары ворот попадают в foretable — поверх всего нарисованного — когда
|
||||
на тайле ворот стоит **именно Кид**. Это следствие устройства оригинала:
|
||||
foretable ОДНА на весь проход тайлов, персонажи в неё уже добавлены, и
|
||||
отдельного «переднего слоя на персонажа» там нет.
|
||||
|
||||
**Мы** ради скорости не рисуем foretable целиком, а возвращаем куски тайлов
|
||||
поверх ТОЛЬКО в прямоугольнике персонажа (`pop_fore_over_char`, см. memory
|
||||
`pop_fore_layer_cost`: полный проход стоил 78 % кадра). Проход идёт по
|
||||
персонажу, значит и вопрос естественно задавать про него —
|
||||
`pop_gate_over_char(ch)`, а не про глобального `Kid`.
|
||||
|
||||
**Чем платим.** Наш вариант — надмножество оригинального: страж (или тень),
|
||||
стоящий в проёме ворот, у нас уходит ЗА решётку, а в оригинале остался бы
|
||||
нарисованным поверх неё, пока на том же тайле нет Кида. Визуально это
|
||||
правильнее, но формально расхождение. Обратной разницы нет: во всех случаях,
|
||||
где оригинал рисует бары поверх, рисуем и мы.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 10, комната 7, тайл (2,6): Кид,
|
||||
стоящий в проёме ворот, виден ЗА прутьями. Решение покрыто хост-тестами
|
||||
(`tests-host/t_char.c`, набор `char_gate_*`) — отрисовка в харнесс не
|
||||
линкуется, поэтому проверяется предикат.
|
||||
|
||||
---
|
||||
|
||||
## Тень рисуется спрайтами КИДА, а не XOR-силуэтом
|
||||
|
||||
**Файлы:** `src/pop_cdraw.c` (выбор набора атласов по `charid`/`frame`).
|
||||
**Дата:** 2026-08-18 (решение принималось раньше, записано здесь).
|
||||
|
||||
**Оригинал** рисует тень тем же кадром Кида, но ДВАЖДЫ — вторым проходом со
|
||||
сдвигом на один пиксель и через XOR. Получается тёмный силуэт с контуром,
|
||||
а не «второй Кид».
|
||||
|
||||
**Мы** рисуем тень обычными спрайтами Кида, обычным блиттером — она выглядит
|
||||
как Кид.
|
||||
|
||||
**Почему.** Приём оригинала — read-modify-write по уже нарисованному, а
|
||||
читать данные из ВИДЕО-ОЗУ (там, где спрайты) на Sprinter нельзя: читается
|
||||
только ОЗУ-копия. Блочный XOR у акселератора есть и работает как раз по
|
||||
ОЗУ-копии, но с нашей прозрачностью он несовместим: прозрачность сделана
|
||||
подавлением записи 0xFF, а XOR прозрачные пиксели тоже смешает — под него
|
||||
нужен набор с прозрачным 0x00 (замер: memory `accel_block_ops`,
|
||||
`sprinter_vram_transparency`). То есть «сделать как в оригинале» всё равно
|
||||
упирается в отдельный набор спрайтов.
|
||||
|
||||
**Чем платим.** Тень визуально неотличима от Кида (уровни 4/5/6/12). На
|
||||
механику не влияет: слот, окна `Char`, ИИ и коллизия у тени свои и от
|
||||
картинки не зависят.
|
||||
|
||||
**План.** Отдельный АТЛАС ТЕНИ (готовый силуэт), а не воспроизведение
|
||||
XOR-прохода: один набор спрайтов вместо второго пути блита. До тех пор
|
||||
расхождение сознательное — багом не заводить.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 6 комната 1: тень стоит слева
|
||||
через провал, поза совпадает с позой Кида-в-стойке. Родственная запись —
|
||||
«Слияние с тенью» ниже.
|
||||
|
||||
---
|
||||
|
||||
## Слияние с тенью: мигания Кида спрайтами тени нет
|
||||
|
||||
**Файлы:** `src/guards.c` (`autocontrol_shadow_level12`),
|
||||
`src/pop_cdraw.c`.
|
||||
**Дата:** 2026-08-13.
|
||||
|
||||
**Оригинал** (`draw_objtable_item`, seg008:20CA) во время вспышки слияния
|
||||
(`united_with_shadow` считает 42 → 0) рисует КИДА как тень на чётных
|
||||
значениях счётчика: тот же кадр уходит не обычным прозрачным блиттером, а
|
||||
парой OR+XOR со сдвигом на пиксель. Получается мерцание «Кид/тень»
|
||||
примерно полторы секунды.
|
||||
|
||||
**Мы** рисуем всё это время обычного Кида, а само событие обозначаем белой
|
||||
вспышкой фона (`pop_flash_color = POP_FLASH_WHITE`, 18 кадров) — она в
|
||||
оригинале тоже есть и ставится тем же кодом.
|
||||
|
||||
**Почему.** У нас Кид и соперник рисуются из РАЗНЫХ атласов своими
|
||||
палитрами (`pop_cdraw.c`), а «тень» — это персонаж слота Guard с палитрой
|
||||
комнаты; блиттеров OR/XOR в libbgi нет вовсе, прозрачность сделана
|
||||
0xFF-подавлением записи. Воспроизвести эффект — значит завести Киду второй
|
||||
набор спрайтов и второй путь блита ради 42 кадров за всю игру.
|
||||
|
||||
**Чем платим.** Момент слияния читается только по вспышке и по тому, что
|
||||
тень исчезла, — без «двоящегося» силуэта. На механику не влияет: счётчик
|
||||
`pop_united_shadow` тикает и уходит в −1 независимо от отрисовки, а от него
|
||||
зависят и повторный подъём тени, и появление плит в комнатах 2/13.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 12: после слияния экран белеет,
|
||||
соперник пропал, HP-потолок вырос на единицу, тень в комнате 15 больше не
|
||||
появляется. Логика покрыта `tests-host/t_shadow.c`.
|
||||
|
||||
---
|
||||
|
||||
## Отложенный старт падающих плит (уровень 13): фаза 0 у нас «не анимируется»
|
||||
|
||||
**Файлы:** `src/pop_map.c` (`pop_check_fall_flo`, `pop_loose_tick`),
|
||||
`src/pop_trob.c` (`animate_loose`).
|
||||
**Дата:** 2026-08-13.
|
||||
|
||||
**Оригинал** (`check_fall_flo`, seg000:1317) раздаёт шести плитам ряда 2
|
||||
верхней комнаты модификатор `−(prandom(0xFF) & 0x0F)`, то есть 0..−15, и
|
||||
заводит на каждую trob. Фаза считает вверх, проходит ноль и дальше идёт
|
||||
обычным отсчётом до провала — плита падает через `n + 11` кадров. Ноль там
|
||||
безопасен: плиту держит в игре СПИСОК trob, а не значение модификатора.
|
||||
|
||||
**Мы** списка trob для loose текущей комнаты не держим — плита анимируется
|
||||
ровно тогда, когда её фаза не ноль (`pop_loose_modif[pos] != 0`). Значит
|
||||
счёт, дойдя до нуля, оборвался бы навсегда. Компенсируем двумя правками,
|
||||
которые работают только в паре:
|
||||
|
||||
* тик перескакивает ноль (`if (m == 0) m = 1`);
|
||||
* стартовое значение берётся на единицу «отрицательнее» (`−n−1`).
|
||||
|
||||
**Чем платим.** Ничем в наблюдаемом поведении: суммарная задержка остаётся
|
||||
`n + 11` кадров, проверено арифметикой на обоих концах диапазона (n = 0 и
|
||||
n = 15). Платим связностью — две правки в разных функциях, и убрать любую
|
||||
одну нельзя.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 13, вход в комнату 23 (она же
|
||||
стартовая): плиты сверху сыплются ВРАЗНОБОЙ, а не разом и не «никогда».
|
||||
Логика покрыта `tests-host/t_jaffar.c`
|
||||
(`jaffar_negative_phase_counts_through_to_fall` и парный контроль на другом
|
||||
уровне).
|
||||
|
||||
---
|
||||
|
||||
## Чит навигации по комнатам не запускает бесшовный переход уровня
|
||||
|
||||
**Файлы:** `SprPoP/sprpop_cold.c` (`pop_dbg_roomnav`), `src/sprpop.c`,
|
||||
`src/pop_state.c` (`pop_nav_hold`).
|
||||
**Дата:** 2026-08-13.
|
||||
|
||||
**Оригинал** (`play_level_2`, seg000:0900) проверяет `Kid.room == 23` КАЖДЫЙ
|
||||
кадр: уровень 12 кончается самим фактом присутствия Кида в комнате 23, двери
|
||||
у него нет. Никакого «как он туда попал» там нет и быть не может —
|
||||
телепорта между комнатами в игре 1989 года не существует.
|
||||
|
||||
**Мы** держим этот триггер, пока Кид попал в комнату ЧИТОМ навигации
|
||||
(`+`/`−`), и отпускаем на первой же смене комнаты обычным ходом.
|
||||
|
||||
**Почему.** Чит перебирает комнаты ПО НОМЕРУ (1..24 с обёрткой), то есть
|
||||
любой обход уровня 12 неизбежно наступает на 23-ю — и уровень молча
|
||||
становится 13-м. Поймано на первом же прогоне 2026-08-13: проверяющий час
|
||||
смотрел «комнату 20 уровня 12», которая на самом деле была комнатой 20
|
||||
уровня 13, и сравнивал её с картой не того уровня. Комнату 23 уровня 12
|
||||
читом не посмотреть в принципе. Это ровно та же болезнь чит-телепорта, что
|
||||
BUG-CHEAT-FIGHT-1 (выход из боя), и лечится там же.
|
||||
|
||||
**Чем платим.** Ничем в игре: в обычном прохождении Кид входит в комнату 23
|
||||
ногами, флаг снят, переход срабатывает как в оригинале. Расхождение видно
|
||||
ТОЛЬКО при включённых читах.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 12: пройти в комнату 23 ногами —
|
||||
уровень меняется на 13-й без заставки и без сброса HP. Обойти уровень
|
||||
читом `+` через 23-ю — уровень НЕ меняется.
|
||||
|
||||
---
|
||||
|
||||
## Чит «убить стража» (K) идёт через штатный путь смерти
|
||||
|
||||
**Файлы:** `src/pop_guard.c` (`pop_guard_kill`).
|
||||
**Дата:** 2026-08-13. Решение пользователя.
|
||||
|
||||
**Оригинал** (seg000:786) ставит `guardhp_delta = -guardhp_curr` И
|
||||
`Guard.alive = 0`. А гейт события смерти в `play_guard` (seg006:1490)
|
||||
требует `Char.alive < 0` — то есть у оригинала чит убивает стража В ОБХОД
|
||||
`on_guard_killed`. На 13-м уровне это заметно: победа над Джафаром по читу
|
||||
не ставит `leveldoor_open = 2`, и выход на 14-й не открывается.
|
||||
|
||||
**Мы** `Guard.alive` в чите не трогаем: применённая дельта обнуляет HP, и
|
||||
`play_guard` сам переводит стража в «умирает», вызвав `on_guard_killed` —
|
||||
брызги, вспышка, флаг выхода. То есть чит даёт ровно «как будто убил Кид».
|
||||
|
||||
**Почему.** Отладочный прогон 13-го уровня иначе требует каждый раз честно
|
||||
выигрывать бой с Джафаром (skill 9, 6 HP) — это дорого по времени, а
|
||||
проверять надо совсем другое.
|
||||
|
||||
**Чем платим.** Ничем в игре: читы включаются флагом `pop_cheats`, в
|
||||
релизной сборке они выключены. Расхождение наблюдаемо только с читами.
|
||||
|
||||
**Что проверять при регрессе.** Уровень 13: `K` на Джафаре → белая вспышка,
|
||||
уход ВЛЕВО открывает дверь уровня. Честная победа в бою даёт то же самое.
|
||||
|
||||
---
|
||||
|
||||
## Страж, вытесненный за правый край комнаты и там убитый, не виден нигде
|
||||
|
||||
**Не расхождение, а особенность оригинала.** Записано, чтобы вопрос не
|
||||
возникал повторно (спросил пользователь 2026-08-19: бой шёл в комнате 15,
|
||||
Кид вытеснил стража вправо — из-за края торчал только меч, — убил его, и
|
||||
труп не появился ни в комнате 15, ни в соседней справа).
|
||||
|
||||
**Почему так.** Три механизма складываются:
|
||||
|
||||
1. **комнату страж не менял.** Его физика работает только в полосе
|
||||
`x ∈ [44, 211)` (`seg000:1254`, у нас то же условие в
|
||||
`pop_guard_phys_tick`), поэтому своим ходом за край он не уходит —
|
||||
Кид вытолкнул его туда толчком, а `Guard.room` остался прежним;
|
||||
2. **мёртвый за Кидом не идёт.** Единственный способ сменить комнату —
|
||||
`follow_guard` при переходе Кида, и первое же условие там
|
||||
(`seg002:0346`) — `Guard.alive < 0 && Guard.sword == sword_2_drawn`,
|
||||
то есть ЖИВОЙ и с вынутым мечом. Мёртвый уходит веткой `leave_guard`,
|
||||
которая сохраняет его в **`Guard.room`** — в старую комнату. У нас
|
||||
ровно это же условие, `pop_guard_cold.c` (`pop_guard_follow`);
|
||||
3. **из соседней комнаты страж не рисуется.** Оригинал при
|
||||
`Guard.room != drawn_room` просто ГАСИТ слот (`seg000:422`:
|
||||
`Guard.direction = dir_56_none`). Механизм «видно из-за шва»
|
||||
(`xpos_in_drawn_room`) работает для коллизий и для Кида, но стража из
|
||||
чужой комнаты на экран не выводит.
|
||||
|
||||
Итог: труп приписан комнате, где страж стоял, а его `guards_x` — за
|
||||
правым краем. При возврате в ту комнату он честно восстанавливается там
|
||||
же, то есть за пределами видимого поля; в соседней комнате его нет,
|
||||
потому что в её данных стража и не было.
|
||||
|
||||
**Живой страж в этой ситуации ведёт себя иначе** — при уходе Кида вправо
|
||||
он идёт следом, если стоит достаточно близко к краю (`Guard.x >= 165`).
|
||||
Это портировано и работает.
|
||||
|
||||
**Чего я НЕ проверял:** живьём в SDLPoP этот сценарий не воспроизводил —
|
||||
вывод сделан чтением трёх мест кода. Если понадобится подтверждение,
|
||||
сценарий короткий: любой бой у правого края комнаты, вытеснить стража за
|
||||
край и добить.
|
||||
|
||||
## ГСЧ разведён по доменам (у оригинала он ОДИН)
|
||||
|
||||
**Оригинал.** `random_seed` один на всё: кладка стены, анимация тайлов,
|
||||
броски боя, модификаторы падающих плит — всё тянет из одной
|
||||
последовательности (`seg009` PRNG, 32-битный LCG). Поэтому в оригинале
|
||||
бой воспроизводим вместе со всем остальным: тот же сид — тот же бой.
|
||||
|
||||
**У нас.** Сидов несколько: `pop_t_seed` (кладка, `pop_tile.h`),
|
||||
`pop_fight_seed` (броски боя, `pop_guard.h`), отдельные у trob и loose.
|
||||
Сам генератор тот же (`pop_prandom`), таблицы вероятностей —
|
||||
побайтно те же, что в `data.h`.
|
||||
|
||||
**Чем платим.** Конкретный бой у нас и в SDLPoP разойдётся: порядок
|
||||
бросков другой, значит блоки/удары лягут иначе. Статистически поведение
|
||||
то же (те же вероятности, тот же генератор), но «сверить бой кадр в кадр
|
||||
с SDLPoP» нельзя, и QuickSave обязан сохранять ВСЕ сиды, а не один.
|
||||
|
||||
**Что проверять при регрессе.** Если страж кажется сильнее/слабее
|
||||
оригинала — сначала проверить не таблицы (они сверены), а **режим
|
||||
скорости**: `fight_speed` у оригинала 100 мс, а в нашем FASTEST бой идёт
|
||||
61,4 мс, то есть в реальном времени на 63 % быстрее, и на глаз это ровно
|
||||
«страж давит сильнее». Режим NORMAL (дефолт) даёт 102,4 мс — см.
|
||||
`frame_pacing_plan.md`.
|
||||
|
||||
## PV intro: единые 12,5 FPS вместо переменных 10/7,5/8,57 FPS
|
||||
|
||||
**Оригинал.** `proc_cutscene_frame()` двигает последовательности через
|
||||
`cutscene_frame_time`: 6 тиков 60 Гц в начале, 8 после первой речи и 7 во
|
||||
время заклинания. Это соответственно 10, 7,5 и примерно 8,57 FPS.
|
||||
|
||||
**У нас (осознанное временное отличие).** Один логический кадр PV держится
|
||||
четыре физических кадра Sprinter: номинально 50/4 = 12,5 FPS. Молния живёт
|
||||
на отдельной физической шкале и не растягивается этим делителем. Если полная
|
||||
отрисовка пересечёт дополнительный фронт, реальная частота может упасть до
|
||||
10 FPS — это допустимо на текущем этапе, но должно быть измерено.
|
||||
|
||||
**TODO.** Перевести PV-сцену на тот же anchor-based механизм точного темпа,
|
||||
который gameplay использует через `pop_beam_sample/pop_pace_end`: измерять
|
||||
число реально прошедших фронтов во время сборки кадра, держать период ровно
|
||||
четыре фронта при укладывании в бюджет и явно учитывать overrun. После замера
|
||||
можно вернуть точные переменные интервалы SDLPoP без накопления фазы.
|
||||
|
||||
## Межуровневые PV-сцены: сохранён реальный период 100 мс
|
||||
|
||||
Это правило не относится к временному темпу основного Princess/Jaffar intro
|
||||
выше. `reset_cutscene()` SDLPoP задаёт для сцен перед уровнями период
|
||||
6 кадров при 60 Гц, то есть 100 мс. На Sprinter тот же период получается
|
||||
ровно как 5 кадров при 50 Гц.
|
||||
|
||||
Суммы вызовов `proc_cutscene_frame()` перенесены без изменения реального
|
||||
времени: сцены 2/4/6 и обе ветки 12 содержат 26 логических кадров (130
|
||||
физических, 2,6 с), сцена 8 — 60 (300, 6,0 с), сцена 9 — 72 (360, 7,2 с).
|
||||
Fade in/out в эти числа не входят, как и в оригинале.
|
||||
|
||||
## Gameplay: загрузка уровней через чёрный cut, без fade
|
||||
|
||||
**Оригинал.** На границах игровых уровней использует fade out/in.
|
||||
|
||||
**У нас (решение пользователя 2026-08-24).** Вход в первый уровень и
|
||||
переход между уровнями выполняются как `старый кадр -> чёрная палитра ->
|
||||
подготовка -> новый кадр с новой палитрой`. Fade на этих двух маршрутах
|
||||
отсутствует. Сюжетные title/story/PV переходы сохраняют собственные fade и
|
||||
left-to-right эффекты.
|
||||
|
||||
Чёрная палитра устанавливается до любого HDD I/O. Загрузчики guard и
|
||||
tileset сами физически правят отдельные цветовые слоты, поэтому после них
|
||||
чёрный экран подтверждается повторно. Зеркальные атласы уровня 9 готовятся
|
||||
до финального источника палитры. CBL открывается последним: старый порядок
|
||||
`level_switch -> CBL open -> BIOS fade` давал скрежет повторяющейся половины
|
||||
аппаратного буфера на входе в Level 1; после перестановки баг исчез в MAME.
|
||||
|
||||
## Тень: кайма силуэта не подкрашивается фоном
|
||||
|
||||
**Оригинал.** Спрайт Тени не хранится — он кладётся ДВАЖДЫ: обычным
|
||||
прозрачным блитом в x и «блиттером XOR» в x+1 (`draw_objtable_item`,
|
||||
seg008.c:1600). XOR идёт по 24-битному RGB того, что УЖЕ на экране
|
||||
(`blit_xor`, seg009.c:3190), поэтому там, где спрайт прозрачен в x, но
|
||||
непрозрачен в x−1, цвет получается как `фон XOR цвет спрайта`.
|
||||
|
||||
**У нас.** Пакетный блит наложения на себя не умеет, поэтому результат
|
||||
запечён в отдельный атлас (`toolchain/pop_pack_shadow.py`,
|
||||
`docs/shadow_atlas_plan.md`). Запекать пришлось для КОНКРЕТНОГО фона, и
|
||||
выбран чёрный: на нём `фон XOR цвет == цвет`, то есть запечка точна.
|
||||
|
||||
**Чем платим.** Ровно одним: **кайма в один пиксель по ЛЕВЫМ кромкам
|
||||
силуэта** на НЕчёрном фоне. У оригинала она принимает оттенок фона, у нас
|
||||
всегда «свой» цвет. Внутренность силуэта и правые кромки совпадают точно —
|
||||
там первый проход уже закрасил пиксель, и от фона результат не зависит.
|
||||
|
||||
**Почему это приемлемо.** Тень бывает на четырёх уровнях, и почти всегда
|
||||
на чёрном: у зеркала (ур. 4), в проёме (5), над пропастью (6), в бою (12).
|
||||
|
||||
**Что проверять при регрессе.** Если Тень окажется на светлом фоне и
|
||||
кайма станет резать глаз — вариантов два: запечь второй набор под светлый
|
||||
фон (ещё 32 страницы EMM) или считать эту кайму прозрачной (силуэт станет
|
||||
на пиксель уже). Оба хуже нынешнего; трогать только по факту жалобы.
|
||||
|
||||
|
||||
## QuickSave/QuickLoad: лейбл печатается ДО дисковой операции, а не после
|
||||
|
||||
**Как в оригинале.** SDLPoP печатает `QUICKSAVE` / `NO QUICKSAVE` (и пару
|
||||
для загрузки) уже ПО РЕЗУЛЬТАТУ операции — `process_quicksave` (seg000:497)
|
||||
сначала делает save/load, потом зовёт `display_text_bottom` и ставит
|
||||
`text_time_total = 24`. На PC это незаметно: файл пишется мгновенно.
|
||||
|
||||
**У нас.** `pop_qsave_process` заявляет строку ПЕРВЫМ действием, ещё до
|
||||
`mem_alloc_pages`/ESTEX, через `pop_status_show_now()` — та печатает её
|
||||
немедленно в ВИДИМУЮ страницу, не дожидаясь конца кадра. Отказ уже потом
|
||||
переписывает строку на `NO QUICKSAVE`/`NO QUICKLOAD` обычной заявкой.
|
||||
|
||||
**Зачем.** Запись снимка на диск занимает доли секунды, и всё это время
|
||||
игра стоит. При порядке оригинала игрок видел сначала необъяснённый фриз,
|
||||
и только по его окончании — надпись, объясняющую то, что уже прошло.
|
||||
Решение пользователя, 2026-08-25.
|
||||
|
||||
**Чем платим.** Строка успевает мигнуть даже там, где операция потом не
|
||||
удалась: сначала `QUICKSAVE`, следом `NO QUICKSAVE`. На практике отказ —
|
||||
редкость (нет места/диска), и «заявка → отказ» читается не хуже.
|
||||
|
||||
**Что проверять при регрессе.** Что после неудачной операции на экране
|
||||
остаётся именно `NO QUICKSAVE`/`NO QUICKLOAD`, а не первая строка: отказ
|
||||
идёт обычной заявкой и печатается кадровым проходом, то есть на кадр позже.
|
||||
|
||||
|
||||
## Смерть Кида: ждём кнопку и перезапускаем УРОВЕНЬ, а не игру
|
||||
|
||||
**Как в оригинале.** `play_kid` (seg006:1383) печатает «Press Button to
|
||||
Continue» с `text_time_total = 288`. Тик — это логический игровой кадр,
|
||||
720 тиков = минута, то есть 12 тиков в секунду: 288 тиков = **24 секунды**.
|
||||
Последние 72 тика (6 секунд) строка мигает с периодом 12 тиков, и на каждом
|
||||
появлении играет звук 38. Дальше развилок ровно две:
|
||||
|
||||
* игрок молчит все 24 секунды — `draw_game_frame` (seg000:958) зовёт
|
||||
`start_game()`, и игра начинается ЗАНОВО, с title, а не с уровня;
|
||||
* игрок нажимает **Enter или Shift** (не любую клавишу!) — seg000:584
|
||||
подменяет их на Ctrl+A: `if (rem_min != 0 && Kid.alive > 6 && (control_shift
|
||||
|| key == SDL_SCANCODE_RETURN)) key = SDL_SCANCODE_A | WITH_CTRL;` — и
|
||||
уровень перезапускается. Условия важны: время не должно быть исчерпано
|
||||
(иначе отработал `expired()`), а `Kid.alive > 6` даёт трупу улечься.
|
||||
|
||||
**У нас.** Обе развилки сведены к одной: 24-секундного выхода в начало
|
||||
игры нет вовсе, строка висит бессрочно (`MSG_HOLD`), а перезапускает уровень
|
||||
ЛЮБАЯ кнопка, а не только Enter/Shift (решение пользователя).
|
||||
`pop_start_level()` возвращает игрока на уровень. Место возрождения выбирает сам `pop_start_level` — на части
|
||||
уровней это не старт, а пройденный чекпойнт. Esc за кнопку продолжения не
|
||||
считается: он открывает pause menu. Логика ожидания живёт в банке
|
||||
(`pop_dead_prompt`, pop_status.c) — резидент W1 переполнен.
|
||||
|
||||
**Зачем.** Решение пользователя, 2026-08-25: возврат к title после каждой
|
||||
смерти в отладочной сборке съедает всё время прохода, а прежний вариант
|
||||
(авто-респавн через 400 кадров либо стрелка вверх) не объяснял игроку, чего
|
||||
от него ждут.
|
||||
|
||||
**Чем платим.** Двумя вещами. Первое: смерть больше не заканчивает
|
||||
партию — счёт попыток фактически бесконечен, тогда как оригинал через 24
|
||||
секунды бездействия отправляет в title. Второе: любая клавиша вместо
|
||||
Enter/Shift означает, что случайное нажатие (например, ещё не отпущенная
|
||||
после боя клавиша) перезапустит уровень — отсюда требование сперва отпустить
|
||||
всё. Когда дойдёт до «настоящей» игры, обе развилки придётся выбирать
|
||||
заново: вернуть таймер на 288 тиков со start_game и сузить клавиши до
|
||||
Enter/Shift — либо оставить как есть уже осознанно.
|
||||
|
||||
**Что проверять при регрессе.** Нажатие принимается только после того, как
|
||||
отпущено ВСЁ, что игрок держал в момент смерти (иначе зажатая при падении
|
||||
стрелка перезапускает уровень мгновенно), и не раньше `RESPAWN_SETTLE`
|
||||
кадров — труп должен успеть лечь.
|
||||
@@ -0,0 +1,58 @@
|
||||
* Left: turn or run left
|
||||
* Right: turn or run right
|
||||
* Up: jump or climb up
|
||||
* Down: crouch or climb down
|
||||
* Down+Left/Right: hop
|
||||
* Shift: pick up things
|
||||
* Shift+Left/Right: careful step
|
||||
* Home or Up+Left: jump left
|
||||
* Page Up or Up+Right: jump right
|
||||
* Up while running: running jump
|
||||
* Shift while falling: grab onto ledge
|
||||
|
||||
* Left/Right: walk (advance or retreat)
|
||||
* Shift: strike (attack)
|
||||
* Up: block (defend)
|
||||
* Down: put sword away; press Shift to draw your sword again.
|
||||
|
||||
===
|
||||
|
||||
* Esc: Pause game.
|
||||
* Space: Show how much time is left.
|
||||
* Ctrl+A: Restart level.
|
||||
|
||||
* Ctrl+R: Return to intro.
|
||||
* Ctrl+S: Sound on/off.
|
||||
* Ctrl+M: Music on/off.
|
||||
* Ctrl+V: Show version of SprPoP.
|
||||
* Ctrl+Q: Quit game.
|
||||
|
||||
* F6: Quicksave: Save the exact state of the game.
|
||||
* F9: Quickload: Load what the last quicksave saved.
|
||||
* F12: Save a screenshot to the screenshots folder.
|
||||
* Backspace: Display the in-game menu. (Esc will also display the menu by default, but you can turn that off.)
|
||||
|
||||
* Shift+L: Go to next level.
|
||||
* -: Decrease remaining time by one minute.
|
||||
* +: Increase remaining time by one minute.
|
||||
* R: Resurrect kid.
|
||||
* K: Kill guard.
|
||||
* Shift+I: Flip the screen upside down.
|
||||
* Shift+W: Slow falling.
|
||||
* Shift+S: Restore a lost hit-point. (Like a small red potion.)
|
||||
* Shift+T: Give more hit-points. (Like a big red potion.)
|
||||
|
||||
===
|
||||
|
||||
* H: Look at the room to the left.
|
||||
* J: Look at the room to the right.
|
||||
* U: Look at the room above.
|
||||
* N: Look at the room below.
|
||||
* Ctrl+B: Go back to the room where the prince is. (Undo H,J,U,N.)
|
||||
|
||||
===
|
||||
|
||||
* [: Shift kid 1 pixel to the left.
|
||||
* ]: Shift kid 1 pixel to the right.
|
||||
* T: Toggle display of timer (remaining minutes:seconds:ticks). Also shows the total elapsed ticks during playback.
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
# SprPoP — план v2: размер кода и раскладка по окнам/банкам/страницам
|
||||
|
||||
> **Замер 2026-08-08, после MEM-BANK2 (актуальная сборка, ALLOCS=3000).**
|
||||
> `_CODE` 22 556 Б, данные 4 375, куча 4 301. Банки: 1 (`guards.c`) 2 289,
|
||||
> 2 (`pop_bg.c`, ГОРЯЧАЯ половина фона) 5 872, 3 (`pop_map.c`) 8 483,
|
||||
> 4 (`pop_cdraw.c`) 5 664, 5 (`pop_ctrl.c`) 2 040, 6 (`pop_trob.c`) 2 889,
|
||||
> 7 (`pop_room.c`, ХОЛОДНАЯ половина фона) 6 035 — все из 16 384.
|
||||
>
|
||||
> Слой фона разрезан надвое по ЧАСТОТЕ ВЫЗОВА (`TASKS_CLOSED.md#mem-bank2`):
|
||||
> общие листья — в резидент `pop_tile.c` (W1 замаплен всегда, таблицы видны
|
||||
> обеим половинам), горячий fore-проход остался в банке 2, холодная отрисовка
|
||||
> комнаты и точечные перерисовки уехали в банк 7. Стык — три тонкие
|
||||
> `__banked`-обёртки, чтобы трамплин платил только холодный путь.
|
||||
> **Банк 2: 90.4 % -> 35.8 %.** Резидент вырос на 2 КБ (листья) — куча
|
||||
> 6 333 -> 4 301 Б, это плата за то, что таблицы должны быть видны из двух
|
||||
> банков.
|
||||
>
|
||||
> Число банков дублируется: `--bank N=` в Makefile И `const uint8_t n_banks`
|
||||
> в `sprpop.c`. Расхождение — не ошибка сборки, а зависание до первого
|
||||
> кадра.
|
||||
>
|
||||
> **Замер 2026-08-01 (историческая отметка).** `_CODE` 25 119 Б, `_DATA` 3 709,
|
||||
> куча ~2.4 КБ. Банки: 1 (`guards.c`) 1 896, 2 (`pop_bg.c`) 13 792,
|
||||
> 3 (`pop_map.c`) 6 331, 4 (отрисовка стража) 2 236 — все из 16 384.
|
||||
> **Резидента `--w3` больше нет**: отрисовка уехала в банк 2, и это сняло
|
||||
> главное ограничение резидента (из банка его было не достать) — банк→банк
|
||||
> работает, трамплин сохраняет страницу окна на стеке. Отрисовка стража
|
||||
> вынесена из банка 2 в собственный банк 4, потому что банк 2 подошёл к
|
||||
> потолку (16 021 из 16 384) — коммит `2f3e854`.
|
||||
>
|
||||
> **Свободного места в банке 2 теперь 10.5 КБ**, в банке 7 — 10.3 КБ; туда
|
||||
> просятся чомперы, зеркало и второй тайлсет (palace). Прежде чем начинать
|
||||
> `levels_plan.md` §3 — всё равно посчитать, куда это ляжет. Следующий
|
||||
> свободный номер банка — 8, гранулярность — файл.
|
||||
>
|
||||
> Из плана ниже **не сделаны шаги 5 (данные: `room_modif`, `dl1/dl2`,
|
||||
> `_kbdraw_down`; потенциал ~1.5 КБ) и 7 (дедуп `draw_tile`, отложен по
|
||||
> решению пользователя)**.
|
||||
|
||||
Статус: **план для отдельной сессии**, составлен 2026-07-29 по свежему замеру.
|
||||
Заменял `size_optimization_plan.md` (v1, 2026-07-21) — тот удалён 2026-08-01
|
||||
как полностью перекрытый этим документом. Документ самодостаточный
|
||||
— рассчитан на старт с пустого контекста.
|
||||
|
||||
Повод: перед стражами и боёвкой (новый код ~5–8 КБ) надо понять, куда он
|
||||
поместится, и заранее развести код так, чтобы банкованные модули не упёрлись в
|
||||
ограничения окна W3.
|
||||
|
||||
---
|
||||
|
||||
## СТАТУС ВЫПОЛНЕНИЯ (обновлено 2026-07-29, коммит ecf5ecf)
|
||||
|
||||
| Шаг | Статус | Факт |
|
||||
|-----|--------|------|
|
||||
| 1. `kid_data.h` → EMM-страница + `load_frame`/`cur_frame` | **сделан** | `_CODE` −3 247 Б; попутно найден и обойдён баг кодогенерации SDCC (см. ниже) |
|
||||
| 2. `pop_geom.c` (дедуп геометрии + PRNG) | **сделан** | −41 Б `_CODE`, −11 Б W3; ценность — не байты, а bank-safe слой |
|
||||
| 3. `pop_map` без вызовов графики | **сделан** (фазы 1a/1b) | через пометки перерисовки, см. ниже |
|
||||
| 4. Разгрузка/перебалансировка W3 | **сделан** | резидент = `pop_bg` + `pop_gdraw` (отрисовка стража, 2026-07-29); W3 14 376 → 12 819 (свободно 3 565 Б) |
|
||||
| 5. Данные (`room_modif`, `dl1/dl2`, `_kbdraw_down`) | не начат | потенциал ~1.5 КБ |
|
||||
| 6. Контракт банка стражей + пробник | **пробник сделан** | `tests/w3bankgfx` — модель подтверждена в MAME, см. ниже |
|
||||
| 7. Дедуп семейства `draw_tile` | отложен по решению пользователя | «мороки много, выгода не так велика» |
|
||||
|
||||
**Замер сейчас против замера §1:** `_CODE` 24 881 → 24 421, куча W2 2 076 → 2 592 Б,
|
||||
W3-резидент 14 376 → 11 632 (свободно 2 008 → 4 752 Б). Сумма кода упала
|
||||
на ~3.2 КБ (данные Kid уехали в EMM), остальное — перераспределение.
|
||||
|
||||
**Замер 2026-07-29 (после стража).** Появление стража съело кучу до 805 Б;
|
||||
разгрузка — вынос ОТРИСОВКИ стража в резидент (`pop_gdraw.c`, `--w3`), логика
|
||||
и состояние остались в W1/W2, чтобы банк `guards.c` их видел (R2). Итог:
|
||||
`_CODE` 26 149 → 25 703, куча **805 → 1 245 Б**, W3-резидент 11 656 → 12 819
|
||||
(свободно 4 728 → 3 565 Б), банк 1 — 236 / 16 384 Б. Граница «что резидент»
|
||||
теперь формулируется одним правилом: **резидент = только то, что рисует и
|
||||
зовётся исключительно из главного цикла**; всё, что может понадобиться банку,
|
||||
остаётся в W1/W2.
|
||||
|
||||
### Что сделано вместо §5.3 (вынос loose в W3)
|
||||
|
||||
Вместо переноса кода между окнами выбран (по обсуждению с пользователем)
|
||||
**порт архитектуры оригинала**: логика ставит пометку, отрисовка идёт
|
||||
отдельным проходом — `set_redraw_*` (seg007) + `redraw_needed` (seg008:0178).
|
||||
Появился `pop_redraw.c/.h`; `pop_trob` и `pop_map` больше не рисуют тайлы.
|
||||
Наши самодельные счётчики (`spike_rest`, `button_rest`, `ldoor_rest`,
|
||||
`loose_bake`, `loose_rest`, `ceil_rest`, `ceil_bake`, `land_bake`) удалены —
|
||||
их роль (вторая страница дабл-буфера) взял счётчик страниц в пометке.
|
||||
|
||||
**Два исключения остались** (обе — функции ТОЛЬКО главного цикла, звать из
|
||||
банка нельзя):
|
||||
- `pop_process_trobs` — пламя факела и пузырёк зелья (покадровый оверлей);
|
||||
- `pop_loose_tick` — падающий кусок (mob): spawn/tick/pos. В оригинале это
|
||||
отдельная подсистема (`mobs` + `draw_moving`), разделение на логику и
|
||||
отрисовку — задел следующей фазы.
|
||||
|
||||
### Пробник банка (2026-07-29): модель ПОДТВЕРЖДЕНА
|
||||
|
||||
`tests/w3bankgfx` (huge + `--w3 res.c` + `--bank 1=bank1.c`, графика 256):
|
||||
|
||||
- банк рисует примитивом libbgi НАПРЯМУЮ — работает; страница W3 внутри
|
||||
банка до блита, после блита и после возврата из вызванной им W1/W2-функции
|
||||
одна и та же (0xF0), резидент — 0xF3. То есть `_bgi_begin`/`_bgi_end`
|
||||
корректно возвращают ИМЕННО банковую страницу (правило R4);
|
||||
- вызов W1/W2-функции из банка работает, и она тоже может рисовать;
|
||||
- резидент W3 жив и вызывается после возврата из банка (R3).
|
||||
|
||||
**Дополнительно выяснено (важно для стражей):** писучие статики
|
||||
`__banked`-модуля линкуются В СТРАНИЦУ БАНКА (0x1C000+) — снаружи их не
|
||||
прочитать, из W1/W2 по 0xC000 видна резидентная страница. Значит всё
|
||||
состояние банкованного кода (позиции стражей, таймеры боя) обязано жить в
|
||||
W1/W2 как обычные глобалы, а банк — только код.
|
||||
|
||||
Ещё одна мина, найденная там же: инлайновый `in a,(#0xE2)` посреди тела
|
||||
функции затирает A, куда SDCC уже положил параметр (у нас из-за этого цвет
|
||||
заливки стал номером страницы, и «резидент не рисовал»). Читать порт
|
||||
отдельной `__naked`-функцией.
|
||||
|
||||
### Найденная по дороге ловушка компилятора
|
||||
|
||||
`(const T *)КОНСТАНТА + var*K` SDCC 4.5 может собрать неверно: умножение
|
||||
делает в 16 битах, а потом берёт только младший байт (`ld c,l` / `inc b`).
|
||||
Кадры Kid с индексом ≥ 52 читались из чужой строки таблицы, у бега/шага
|
||||
пропадал `FRAME_NEEDS_FLOOR` и персонаж проваливался сквозь пол. Лечение —
|
||||
считать адрес в `uint16_t` и кастовать один раз. Тот же паттерн в
|
||||
`pop_level.c` компилируется ПРАВИЛЬНО, т.е. полагаться на «у соседа
|
||||
работает» нельзя. Подробности: memory `sdcc_z80_const_ptr_index_bug`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Что уже сделано из v1 (не повторять)
|
||||
|
||||
- `--opt-code-size` и `--max-allocs 100000` **уже включены по умолчанию** в
|
||||
`bin/sprinter-cc` (v1 §2.1 закрыт, выигрыш получен).
|
||||
- Лишние блиты переднего слоя убраны (v1 §7 п.0): `fore_tile` больше не рисует
|
||||
`bottom_id`, `_CODE` −388 Б.
|
||||
- `gfx_blit_noclip` в libbgi (v1 §8 шаг 1): фоновые блиты в 2.9× дешевле.
|
||||
- `--w3` как резидент окна 3 реализован и обкатан (memory `w3_resident_code`).
|
||||
|
||||
---
|
||||
|
||||
## 1. ЗАМЕР (сборка 2026-07-29, коммит 1214785)
|
||||
|
||||
Команда: `--memory small --gfx 256 --w3 pop_trob.c pop_map.c --w3 pop_bg.c`.
|
||||
|
||||
### 1.1 Окна
|
||||
|
||||
| Область | Занято | Свободно | Примечание |
|
||||
|---|---|---|---|
|
||||
| W1+W2 `_CODE` | 24 881 Б | — | 0x4100…0xA231 |
|
||||
| W1+W2 `_HOME`+`_GSINIT`+`_DATA`+`_BSS` | ~4 240 Б | — | до 0xB2E4 |
|
||||
| **W1+W2 куча** | 0 (никто не malloc'ит) | **2 076 Б** | 0xB2E4…0xBB00 |
|
||||
| W1+W2 стек | — | 1 279 Б | 0xBB00…0xBFFE |
|
||||
| **W3 резидент** | 14 376 Б | **2 008 Б** | 0xC000…0xF828 |
|
||||
| EMM-страницы | 37 атласов + 1 уровень | ~215 страниц свободно | `sprinter_emm_budget` |
|
||||
|
||||
**Итого запаса до стены: ≈ 4 КБ** (2 КБ в W1/W2 + 2 КБ в W3). Стражи туда
|
||||
не влезут.
|
||||
|
||||
### 1.2 Код по модулям (точно, из `.rel`)
|
||||
|
||||
```
|
||||
W1/W2 (_CODE 24 881): W3 резидент (_W3CODE 14 376):
|
||||
pop_kid 6 963 pop_bg 11 643
|
||||
pop_map 6 157 pop_trob 2 733
|
||||
SprPoP 2 583
|
||||
pop_level 1 425
|
||||
pop_ctrl 1 145
|
||||
crt0 333
|
||||
libc+libbgi ~6 275
|
||||
```
|
||||
|
||||
### 1.3 Крупнейшие функции/данные (из `.lst`)
|
||||
|
||||
```
|
||||
pop_bg : draw_tile 3080, other_overlay_tile 1146, wall_pattern 944,
|
||||
mob_render 720, mob_tick_one 661, overlay_mid_tile 498,
|
||||
fore_only_tile 409, climb_overlay_tile 391, tile_table 371
|
||||
pop_map : check_loose_fall_on_kid 674, check_bumped 567, jump_up_or_grab 413,
|
||||
get_tile 266, do_knock 243, check_press 238, check_leave 212
|
||||
pop_kid : kid_seqtbl 2310 + kid_frames 1205 + kid_seq_off 230 = 3745 Б ДАННЫХ
|
||||
(в _CODE!), собственно кода ~3.2 КБ
|
||||
SprPoP : enter_room+main ~2.1 КБ
|
||||
pop_level: room_bg_ptr 1084 (+ 515 Б таблиц LINKLOC/LINKMAP в _DATA)
|
||||
```
|
||||
|
||||
### 1.4 `_DATA` (3 710 Б)
|
||||
|
||||
```
|
||||
pop_trob 963 (room_modif[24][30] = 720 + trobs + rest-массивы)
|
||||
pop_level 515 (копии LINKLOC/LINKMAP уровня)
|
||||
pop_map 163, SprPoP 141, pop_kid 130, pop_bg 115, pop_ctrl 13
|
||||
libc: _irq_state 818, _kbdraw_state 515, _gfx_pal_buf 256, прочее ~200
|
||||
```
|
||||
|
||||
### 1.5 Находки замера (мелкие, но чинить)
|
||||
|
||||
1. **`--w3` берёт ОДИН файл на флаг.** В `Makefile` написано
|
||||
`--w3 pop_trob.c pop_map.c --w3 pop_bg.c`, и это значит «W3 = pop_trob и
|
||||
pop_bg», а `pop_map.c` компилируется как обычный исходник в W1/W2. Судя по
|
||||
`.sprinter-cc-SprPoP/w3_pop_map.rel` (устаревший артефакт), когда-то
|
||||
pop_map был в W3. **Решить осознанно** (см. §4) и записать явно:
|
||||
`--w3 pop_trob.c --w3 pop_bg.c`.
|
||||
2. `libc` тянет `_irq_state` 818 Б + `_kbdraw_state` 515 Б в `_DATA`.
|
||||
`__irq_vec_buf` (513 Б) — таблица векторов IM2; `__kbdraw_down` (512 Б) —
|
||||
битмап клавиш на 512 скан-кодов. Оба можно ужать (см. §5.4), это ~0.7 КБ
|
||||
в самом дефицитном окне.
|
||||
|
||||
---
|
||||
|
||||
## 2. ПРАВИЛА ПЛАТФОРМЫ, ОТ КОТОРЫХ ПЛЯШЕТ РАСКЛАДКА
|
||||
|
||||
Это главное, что изменилось по сравнению с v1: модель банкинга уточнена по
|
||||
`bin/sprinter-cc` (справка `--w3`/`--bank`) и по коду libbgi.
|
||||
|
||||
**(R1) Резидент W3 (`--w3`) и банки W3 (`--bank`) делят одно окно.**
|
||||
Резидент лежит на своей странице 0xC000…0xFFFF; трамплин на время вызова
|
||||
`__banked` подменяет страницу W3 на банковую и возвращает резидентную назад.
|
||||
|
||||
**(R2) Из банка резидент W3 НЕДОСТИЖИМ — и транзитивно тоже.**
|
||||
Пока исполняется банк, резидентной страницы в адресном пространстве нет.
|
||||
Значит нельзя не только `bank → pop_bg()`, но и `bank → pop_map() → pop_bg()`.
|
||||
**Это ключевое ограничение при выборе, что делать банком.**
|
||||
|
||||
**(R3) Резидент → банк работает** (через трамплин в W1), резидент → W1/W2 —
|
||||
тоже.
|
||||
|
||||
**(R4) Графические примитивы libbgi звать можно откуда угодно.**
|
||||
`_bgi_begin` читает текущую страницу W3 из порта 0xE2, а `_bgi_end` её
|
||||
возвращает — то есть скобка корректна и из банка, и из резидента. Нельзя
|
||||
только одно: **звать `_bgi_begin`/`_bgi_end` ИЗ кода, который сам лежит в W3**
|
||||
(после подмены страницы исчезнет исполняемый код — проверено, белый экран).
|
||||
Поэтому `pop_bg` (резидент W3) обязан пользоваться готовыми примитивами
|
||||
(`gfx_blit*`, `bar`, …), а батчинг скобки на весь `draw_tile` (v1 §8 шаг 1)
|
||||
для него **невозможен** без переноса самого `draw_tile` в W1/W2.
|
||||
|
||||
**(R5) `--w3` кладёт в W3 код И rodata модуля** (`--codeseg/--constseg
|
||||
W3CODE`), а писучие статики оставляет в `_DATA` (W2). То есть `const`-таблицы
|
||||
переносятся в W3 бесплатно вместе с модулем (так уже лежит `tile_table` 371 Б).
|
||||
|
||||
**(R6) Данные в EMM-странице читаются, только пока страница в окне.**
|
||||
`gfx_w0_map(page)` / `gfx_w0_unmap()` — окно W0 (0x0000…0x3FFF), первые 0x100
|
||||
занимает ISR-стаб. Так уже работает `pop_level`. Цена — пара `OUT` на
|
||||
маппинг, поэтому годится для «пачками», а не для чтения по байту в горячем
|
||||
цикле.
|
||||
|
||||
---
|
||||
|
||||
## 3. ЧТО ДЕЛАТЬ НЕЛЬЗЯ (анти-паттерны, чтобы не потерять время)
|
||||
|
||||
- **Нельзя банковать `pop_bg`.** Он вызывается из pop_map, pop_trob, SprPoP,
|
||||
pop_kid — то есть из главного цикла на каждом кадре; плюс он сам держит
|
||||
`tile_table` и всю отрисовку. Банк дал бы трамплин на каждый блит.
|
||||
- **Нельзя банковать `pop_map`, пока `pop_map` зовёт `pop_bg`** (R2). Сейчас
|
||||
зовёт: `pop_loose_tick` и компания (~30 вызовов графики).
|
||||
- **Нельзя тащить `kid_frames` в EMM «в лоб»**: он читается несколько раз за
|
||||
кадр из коллизии (`kid_cur_dx`/`kid_cur_flags` → `dx_weight`,
|
||||
`char_x_forward_edge`, …). Нужен кэш кадра (см. §5.1) — иначе маппинг
|
||||
страницы окажется в горячем пути.
|
||||
- **Нельзя «причёсывать» семейство `draw_tile` ради экономии, не имея
|
||||
пиксельного теста.** Мы неделю выравнивали слои по SDLPoP; любой рефактор
|
||||
этой зоны проверять диффом страниц (заморозка кадра клавишей `1` + сравнение
|
||||
VRAM обеих страниц, приём из memory `mame_mcp_bridge`).
|
||||
|
||||
---
|
||||
|
||||
## 4. ЦЕЛЕВАЯ РАСКЛАДКА
|
||||
|
||||
Принцип: **W3-резидент = «толстая графика, которую зовёт только главный цикл»;
|
||||
W1/W2 = ядро, которое должно быть достижимо ОТОВСЮДУ (включая банки); банки =
|
||||
новая холодная логика (стражи, боёвка, будущие уровни)**.
|
||||
|
||||
```
|
||||
W1/W2 (всегда отображено) W3 резидент (стр. 0xC000) Банки W3
|
||||
────────────────────────── ───────────────────────── ─────────
|
||||
libc + libbgi pop_bg (отрисовка тайлов) guards.c
|
||||
pop_kid (интерпретатор+рисование) pop_trob (анимации тайлов) fight.c
|
||||
pop_map (коллизия/физика/предметы) pop_loose.c (loose+потолок) debug/roomnav
|
||||
pop_geom (общая геометрия/тайлы) enter_room-часть SprPoP?
|
||||
pop_ctrl (ввод/диспетчер)
|
||||
SprPoP (главный цикл)
|
||||
```
|
||||
|
||||
Почему так:
|
||||
|
||||
- **`pop_map` остаётся в W1/W2** — его зовут и главный цикл, и (в будущем)
|
||||
банк стражей; в W3 его класть нельзя именно из-за R2. Для этого из него надо
|
||||
вынести графическую часть (loose/потолок) — она уезжает в W3 к `pop_bg`
|
||||
(§5.3). После выноса `pop_map` становится **чистой логикой без единого
|
||||
вызова графики** — тот самый bank-safe API.
|
||||
- **`pop_kid` остаётся в W1/W2**: `play_seq`/`kid_set_seq`/`Kid` нужны и
|
||||
стражам (у стражей ТА ЖЕ seqtbl), а `kid_draw` зовёт только libbgi (R4).
|
||||
- **`pop_trob` остаётся резидентом**: его зовёт только главный цикл, и он сам
|
||||
зовёт `pop_bg` — идеальный житель W3.
|
||||
- **Банк стражей не зовёт ничего из W3.** Рисование стражей — либо через
|
||||
libbgi напрямую (R4), либо (лучше) резидентный `guard_draw()` в W1/W2 рядом
|
||||
с `kid_draw`, а банк только считает состояние. Тот же приём мы уже
|
||||
используем для `pop_item_taken`/`pop_loose_fell`/`pop_ceil_fell`: банк
|
||||
выставляет флаг — резидент рисует.
|
||||
|
||||
---
|
||||
|
||||
## 5. ПЛАН РАБОТ
|
||||
|
||||
Порядок выбран так, чтобы каждый шаг был проверяем отдельно и давал место
|
||||
следующему.
|
||||
|
||||
### Шаг 1. `kid_data.h` (3 745 Б) → EMM-страница + порт `load_frame` — **самый большой выигрыш**
|
||||
|
||||
Сейчас `kid_seqtbl` (2310) + `kid_frames` (1205) + `kid_seq_off` (230) лежат в
|
||||
`_CODE` окна W1/W2 — это 15 % всего дефицитного пространства.
|
||||
|
||||
Как переносить:
|
||||
|
||||
1. `pop_extract_kid_data.py` дополнительно пишет `kid_data.bin` (те же три
|
||||
таблицы подряд, фиксированные смещения).
|
||||
2. Грузим её в отдельную EMM-страницу тем же способом, что уровень
|
||||
(`pop_level_load` — готовый образец), хэндл держим в `pop_kid`.
|
||||
3. **Порт `load_frame` (seg006) и глобала `cur_frame`** — в оригинале ровно
|
||||
так и сделано: раз за тик кадр копируется в структуру, а весь остальной код
|
||||
читает `cur_frame`, а не таблицу. У нас `kid_cur_dx()/kid_cur_flags()`
|
||||
станут чтением из `cur_frame` (5 байт в `_DATA`).
|
||||
4. `play_seq` оборачивается в один `gfx_w0_map(kid_data_page)` … `unmap` на
|
||||
вызов (в тике, не в отрисовке — конфликта с атласом в W0 нет).
|
||||
|
||||
Выигрыш: **−3 745 Б из W1/W2**, цена — один маппинг страницы за тик и 5 байт
|
||||
`_DATA`. Дополнительный бонус: `load_frame`/`cur_frame` — шаг К СХОДСТВУ с
|
||||
оригиналом, а не отход от него.
|
||||
|
||||
Риск: сломать `play_seq` (сердце анимации). Проверка: прогон по комнатам с
|
||||
эталонными позами (вис, подтягивание, прыжки, подъём меча).
|
||||
|
||||
### Шаг 2. Модуль `pop_geom.c` — дедуп + bank-safe фундамент
|
||||
|
||||
Сейчас продублировано между модулями:
|
||||
|
||||
| что | где | сколько |
|
||||
|---|---|---|
|
||||
| `y_to_row`/`y_to_row_mod4` | pop_bg + pop_map | 2 копии |
|
||||
| `char_dx_forward` | pop_kid + pop_map | 2 копии |
|
||||
| `x_bump[20]` | pop_kid (uint8) + pop_map (int16) | 20 + 40 Б, РАЗНЫЕ типы |
|
||||
| `y_land[5]` | pop_kid + pop_map | 10 + 10 Б |
|
||||
| `tile_is_floor` | pop_map (+ проверка кодов в SprPoP) | 2 места |
|
||||
| 32-битный LCG `prandom` | pop_bg (`prandom`) + pop_trob (`trob_prandom`) | 2 копии по ~60 Б + 2 сида |
|
||||
|
||||
Собрать в один W1/W2-модуль `pop_geom.c`: таблицы `x_bump/y_land/dir_front/
|
||||
dir_behind`, `y_to_row`, `char_dx_forward`, `get_tile_div_mod(_m7)`,
|
||||
`tile_is_floor`, `prandom`. Выигрыш прямой — сотни байт (оценка 250–400 Б),
|
||||
но главное — **это и есть тот «чистый» API, который потом сможет звать банк**
|
||||
(R2): вся геометрия оказывается в W1/W2 по определению.
|
||||
|
||||
Осторожно: `prandom` у pop_bg и pop_trob — РАЗНЫЕ последовательности с разными
|
||||
сидами (стены vs фазы факелов). Объединять функцию можно, **сиды — нет**:
|
||||
передавать сид указателем/по индексу, иначе поедет раскладка кладки.
|
||||
|
||||
### Шаг 3. Вынести loose/потолок из `pop_map` в W3
|
||||
|
||||
`pop_map` — единственный модуль W1/W2, который зовёт графику, и делает это
|
||||
ровно в одном логическом блоке: `pop_loose_tick` + `check_press` + `do_knock` +
|
||||
`fell_on_your_head` + `check_loose_fall_on_kid` + плита-потолок (~1.4–2 КБ).
|
||||
|
||||
Вынести их в `pop_loose.c`, собираемый `--w3` рядом с `pop_bg`/`pop_trob`.
|
||||
Тогда:
|
||||
|
||||
- `pop_map` = чистая логика (bank-safe, R2 соблюдён);
|
||||
- W1/W2 худеет ещё на ~1.5–2 КБ;
|
||||
- W3 растёт на столько же — а место там появится после шага 4.
|
||||
|
||||
### Шаг 4. Перебалансировка резидента W3
|
||||
|
||||
После шага 3 в W3 будет тесно (14.4 + 2 ≈ 16.4 КБ > 16 КБ). Разгружаем:
|
||||
|
||||
1. **`wall_pattern` (944 Б) + `mob_render`/`mob_tick_one` (1381 Б)** — кандидаты
|
||||
на переезд в W1/W2: их зовёт только `pop_bg`/`pop_loose`, но сами они уже
|
||||
пользуются только libbgi (R4), значит из W1/W2 работают и остаются
|
||||
достижимыми из банка.
|
||||
2. `tile_table` и мелкие const-таблицы pop_bg (371 + ~300 Б) можно унести в
|
||||
EMM-страницу **уровня** (там ~13.8 КБ свободно) — но только если чтение
|
||||
происходит под уже замапленной страницей. Сейчас `draw_tile` читает
|
||||
`tile_table` ВНЕ W0-контекста → потребуется явный маппинг на тайл. **Не
|
||||
делать раньше замера**: 30 тайлов на входе в комнату × map/unmap — терпимо,
|
||||
а вот в покадровых редроях (пики/loose/кнопка) — уже горячий путь.
|
||||
3. Если и этого мало — `enter_room` (~2.1 КБ, зовётся только при смене комнаты)
|
||||
переносится в резидент W3 или в БАНК (он вызывается из главного цикла =
|
||||
резидента, значит банк допустим по R3).
|
||||
|
||||
### Шаг 5. Данные
|
||||
|
||||
1. **`room_modif[24][30]` = 720 Б** (pop_trob, `_DATA`). Нужен произвольный
|
||||
доступ каждый кадр (анимации, ворота) — в EMM не годится. Но 24 комнаты ×
|
||||
30 байт хранятся ЦЕЛИКОМ, хотя одновременно живут modif'ы только текущей
|
||||
комнаты и соседей по швам. Вариант: хранить полный массив в EMM-странице
|
||||
уровня, а в `_DATA` держать кэш на 2–3 комнаты (свою + левого/правого
|
||||
соседа) с записью обратно при смене комнаты. Выигрыш ~600 Б, цена —
|
||||
аккуратность на швах (кнопка в одной комнате открывает ворота в другой).
|
||||
**Делать последним** — это самая «тонкая» правка по семантике.
|
||||
2. **`dl1[256]`+`dl2[256]` = 512 Б** (pop_level, `_DATA` — копии LINKLOC/
|
||||
LINKMAP уровня) — читаются при нажатии кнопки
|
||||
и при отрисовке нажатой кнопки. Кандидат на чтение прямо из страницы
|
||||
уровня (она и так маппится) — но проверить, что `pop_doorlink2` не зовётся
|
||||
из отрисовки в тот момент, когда в W0 атлас. Выигрыш ~500 Б.
|
||||
3. **`_kbdraw_down[512]` 512 Б** (libc): проверено — это **байт на скан-код**
|
||||
(`libc/kbd/_kbdraw_state.c`), хотя комментарий называет его битовой картой.
|
||||
Упаковка в биты даёт −448 Б, но добавляет сдвиг/маску в ISR-трамплин и в
|
||||
`kbd_raw_down`. Трогать осторожно: raw-клавиатура уже дважды была
|
||||
источником залипаний (memory `kbd_raw_fifo_drain`,
|
||||
`kbd_overrun_wipe_modifiers`) — правку сопровождать прогоном docs/kbd-games.
|
||||
4. **`__irq_vec_buf` 513 Б** (libc IM2): таблица векторов обязана быть
|
||||
выровнена и полна — не трогать.
|
||||
|
||||
### Шаг 6. Контракт банка стражей (проектируется ДО написания кода)
|
||||
|
||||
Когда дойдём до стражей:
|
||||
|
||||
- `guards.c` собирается `--bank 1=guards.c`, режим `huge` (или `big` с
|
||||
`BANKED=W1`, если W3 окажется тесен для трамплинов).
|
||||
- **Банк зовёт только:** `pop_map` (чистая логика после шага 3), `pop_kid`
|
||||
(`play_seq`, `kid_set_seq`, `cur_frame`), `pop_geom`, libc/libbgi.
|
||||
- **Банк НЕ зовёт:** `pop_bg`, `pop_trob`, `pop_loose` (резидент W3) — ни
|
||||
прямо, ни через промежуточные функции. Нужна отрисовка — выставляет флаг,
|
||||
рисует резидент (идиома `pop_item_taken`).
|
||||
- Первым делом — **пробник** (`tests/` или `--bank` на пустышке): банк зовёт
|
||||
`pop_map`-функцию, та зовёт libbgi-примитив; убедиться в MAME, что скобка
|
||||
W3 корректно возвращает банковую страницу (R4) — это проверка модели, а не
|
||||
веры в неё.
|
||||
|
||||
### Шаг 7. Мелкий дедуп в `pop_bg` (после того, как появится тест страниц)
|
||||
|
||||
- Пять функций-редроев (`pop_spike_redraw`, `pop_loose_shake_draw`,
|
||||
`pop_floor_bake`, `pop_button_redraw`, `pop_leveldoor_redraw`) отличаются
|
||||
только прямоугольником heal, банком и набором тайлов — свести к одному
|
||||
параметризованному хелперу (оценка −150…250 Б).
|
||||
- `env_b/wall_b/fore_b/pot_b` — четыре одинаковых обёртки над `blit_b`
|
||||
(оставить: экономия единицы байт, читаемость дороже).
|
||||
- `overlay_mid_tile` / `fore_only_tile` / `climb_overlay_tile` / `draw_tile`
|
||||
— общая структура «взять code/lcode, посчитать x/dmy/dby, разобрать слои».
|
||||
Тут экономия потенциально сотни байт, но это **та самая зона риска из §3** —
|
||||
только с пиксельным диффом до/после и по одному слою за раз.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ожидаемый итог
|
||||
|
||||
| Шаг | W1/W2 | W3 | Риск |
|
||||
|---|---|---|---|
|
||||
| 1. kid_data → EMM + load_frame | **−3 745** | — | средний (сердце анимации) |
|
||||
| 2. pop_geom (дедуп) | −250…400 | — | низкий |
|
||||
| 3. loose → W3 | −1 500…2 000 | +1 500…2 000 | низкий (перенос как есть) |
|
||||
| 4. разгрузка W3 (wall_pattern, mob) | +2 300 | −2 300 | низкий |
|
||||
| 5. данные (room_modif, LINKLOC, kbd) | −1 000…1 600 | — | средний/высокий |
|
||||
| 7. дедуп редроев pop_bg | — | −150…250 | средний |
|
||||
|
||||
Суммарно: **W1/W2 освобождается ~4.5–6 КБ**, W3 остаётся примерно в нынешнем
|
||||
объёме, но становится «правильно заполненным» — в нём только то, что банк
|
||||
никогда не позовёт. Плюс открывается путь к банкам: стражи и боёвка получают
|
||||
до 16 КБ на банк, не трогая резидент.
|
||||
|
||||
---
|
||||
|
||||
## 7. Как мерить и проверять (обязательно к каждому шагу)
|
||||
|
||||
1. **До/после по `.rel`** — точные размеры на модуль:
|
||||
`for f in .sprinter-cc-SprPoP/*.rel; do grep '^A ' $f; done`
|
||||
(области `_CODE`/`_W3CODE`/`_DATA`). Итоги окон печатает сам `sprinter-cc`.
|
||||
2. **Функции** — из `.lst` (метки `_name:` и адреса), скрипт в истории этой
|
||||
сессии; полезно ловить «функция распухла после рефактора».
|
||||
3. **MAME**: любой перенос кода между окнами/страницами — это класс «молча
|
||||
ломается» (`sprinter_memory_modes`). Минимум: комната 1 (loose), 12
|
||||
(вис/подтягивание), 15 (меч), 9 (дверь уровня), 6 (кнопка/ворота).
|
||||
4. **Пиксельный дифф** для правок отрисовки: заморозить кадр (`1`), сравнить
|
||||
обе страницы дабл-буфера через `vram` (см. memory `mame_mcp_bridge`) и/или
|
||||
сверить с эталонным рендером `render_room.py`.
|
||||
5. **Скорость** — после шагов 1 и 4 замерить кадр маркерами в порт 0xFE
|
||||
(приём из v1 §8), чтобы маппинг страницы за тик не съел бюджет.
|
||||
|
||||
## 8. Ссылки
|
||||
|
||||
- `bin/sprinter-cc` — справка по `--w3`, `--bank`, `--memory`, `--memory-manual`.
|
||||
- `runtime/crt0_banked.s`, `runtime/bank.s` — трамплины и захват резидентной
|
||||
страницы W3.
|
||||
- `libbgi/common/_bgi_begin.c` / `_bgi_end.c` — механика скобки W3 (R4).
|
||||
- memory: `w3_resident_code`, `pop_banking_architecture`, `sdcc_banking`,
|
||||
`bank_local_data_pattern`, `sprinter_memory_modes`, `memory_modes_implemented`,
|
||||
`sprinter_emm_budget`, `mame_mcp_bridge`, `avoid_32bit_arith_z80`,
|
||||
`libc_one_function_per_module`.
|
||||
- `applications/SprPoP/TASKS_OPEN.md` — что из этого берётся в работу сейчас.
|
||||
|
||||
---
|
||||
|
||||
## 9. Скорость отрисовки: замеры и запас
|
||||
|
||||
Перенесено из удалённого `size_optimization_plan.md` §8 (замер 2026-07-27) —
|
||||
единственная его часть, которая не была перекрыта этим документом.
|
||||
Профилирование в MAME: маркеры в порт 0xFE + `wpiset … totalcycles` (приём из
|
||||
memory `mame_mcp_bridge`); в самом `sprpop.c` для этого уже стоят полосы
|
||||
бордюра `PROF()`. Кадр Sprinter = **430 080 тактов**.
|
||||
|
||||
**Стоимость блита почти НЕ зависит от размера** — платим за проход по цепочке
|
||||
`gfx_blit → gfx_blit_part → _gfx_blit_full` (16-битная арифметика, клип,
|
||||
пересчёт src, нарезка полос >256), а не за пиксели:
|
||||
|
||||
| путь (спрайт 32×3) | тактов |
|
||||
|---|---|
|
||||
| `gfx_blit` (общее ядро, с клипом) | 13 288 |
|
||||
| линейное ядро без клипа | 4 617 |
|
||||
|
||||
Отсюда `draw_tile(0,0)` тайла шва (9 блитов) стоил **183 690 тактов = 43 %
|
||||
кадра**; сам `bar` — только 13 308.
|
||||
|
||||
**Сделано:** `gfx_blit_noclip()` в libbgi, фоновые блиты `pop_bg` уходят на
|
||||
него, когда спрайт целиком на экране (~2.9×, подтверждено в MAME). Позже
|
||||
тем же приёмом закрыты спрайты персонажей (`gfx_blit_cols_part_noclip`).
|
||||
**Не закрыт heal** — задача CLIP-1 в `../PoP/SprPoP/TASKS_CLOSED.md`.
|
||||
|
||||
**ВАЖНО:** W3-скобку (`_bgi_begin`/`_bgi_end`) ставит САМА libbgi — вызывать
|
||||
её из модуля, собранного с `--w3`, нельзя: после `_bgi_begin` окно W3 занято
|
||||
видеобанком и код вызывающего исчезает из адресного пространства (проверено:
|
||||
белый экран).
|
||||
|
||||
**Запас, когда перестанет хватать бюджета кадра:**
|
||||
|
||||
1. **Батчинг W3-скобки** — одна `_bgi_begin`/`_bgi_end` на весь `draw_tile`
|
||||
вместо скобки на блит; нужен публичный batch-API в libbgi.
|
||||
**Осторожно, и это стало важнее, чем было:** между begin/end стоит `DI`,
|
||||
длинная серия задержит кадровое прерывание — а по разбору KBD-1
|
||||
(`../PoP/SprPoP/TASKS_CLOSED.md`) длинные DI-окна и есть причина потери байт
|
||||
клавиатуры. Батчинг эту проблему УХУДШИТ, если делать его вслепую.
|
||||
2. **Решётка ворот одним спрайтом** — `draw_gate_back` рисует бары по одному
|
||||
(до 7 блитов). Сгенерировать в атласе «столб решётки» и выводить одним
|
||||
`gfx_blit_part` с обрезкой по фазе `gate_bot_y & 7`: 7 блитов → 1.
|
||||
3. **Не перерисовывать статичные части шва** — грань ворот, пол и кромка при
|
||||
анимации решётки не меняются (см. OPT-1 в `../PoP/SprPoP/BUGS_CLOSED.md` —
|
||||
решено не делать, стоимость транзиентная).
|
||||
4. **T-1 / T-2** (`../BUGS_OPEN.md`) — перерисовка пик по причине и
|
||||
idle-skip Кида: самый большой оставшийся резерв, потому что убирает работу
|
||||
целиком, а не удешевляет её.
|
||||
@@ -0,0 +1,462 @@
|
||||
# Что осталось на уровнях 12, 13, 14, 15 и 0
|
||||
|
||||
Статус: разбор по `../SDLPoP/src/`, 2026-08-13. Продолжает
|
||||
[`levels_plan.md`](levels_plan.md) (машинерия уровней и тайлсеты — уже
|
||||
сделаны). Здесь только СПЕЦСОБЫТИЯ, которых у нас ещё нет.
|
||||
|
||||
Правило проекта: источник истины — SDLPoP; все ссылки ниже даны на функцию и
|
||||
строку, чтобы порт начинался с чтения, а не с гипотезы.
|
||||
|
||||
---
|
||||
|
||||
## 0. Что из этой области УЖЕ есть
|
||||
|
||||
Проверено грепом по `SprPoP/`:
|
||||
|
||||
| механика | где у нас | статус |
|
||||
|---|---|---|
|
||||
| слот соперника, общий на всех Char | `pop_guard.c`, `pop_cdraw.c` | готово |
|
||||
| `check_shadow` (спецвход тени) | `guards.c:310` | готово для уровней 4/5/6 |
|
||||
| `do_init_shad` + таблицы `init_shad_5/6` | `guards.c:284` | готово |
|
||||
| ИИ тени 4/5/6 | `guards.c:599/658/680` | готово |
|
||||
| диспетчер `autocontrol_shadow` | `guards.c:711` | ветки 12 НЕТ |
|
||||
| боёвка (удар/блок/парирование/HP соперника) | `guards.c`, `pop_ctrl.c` | готово |
|
||||
| `flash_color` / `flash_time` | `pop_map.c`, `sprpop.c` | готово |
|
||||
| `add_life` | `pop_map.c` | готово |
|
||||
| таблицы уровней (`guard_type`, `guard_hp`, `entry_pose`, `level_type`) | `pop_level_cold.c:41..52` | готово, включая 12=SHADOW, 13=VIZIER |
|
||||
| loose-полы, `make_loose_fall`, mob | `pop_map.c`, `pop_room.c` | готово (без спецкейсов ур. 13) |
|
||||
|
||||
То есть каркас есть весь; ниже — недостающие спецсобытия.
|
||||
|
||||
---
|
||||
|
||||
## 1. Уровень 12 — тень: встреча, бой, слияние
|
||||
|
||||
### 1.1 Подъём тени в комнате 15 (`check_shadow`, seg002:0070)
|
||||
|
||||
```c
|
||||
if (current_level == 12) {
|
||||
if (!united_with_shadow && drawn_room == 15) {
|
||||
Char.room = drawn_room;
|
||||
if (get_tile(15, 1, 0) == tiles_22_sword) return; // меч ещё лежит
|
||||
shadow_initialized = 0;
|
||||
do_init_shad(init_shad_12, 7 /* fall */);
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Отличия от наших веток 5/6: тень поднимается **в падении** (seq 7), условием
|
||||
служит содержимое тайла (меч уже подобран) и флаг `united_with_shadow`.
|
||||
Таблица `init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0}` — то есть
|
||||
x=81, y=232, вправо, колонка 0, ряд 0.
|
||||
|
||||
**Что добавить:** ветку в `pop_check_shadow` + константу `init_shad_12` +
|
||||
глобалы `united_with_shadow`, `shadow_initialized`.
|
||||
|
||||
### 1.2 ИИ тени (`autocontrol_shadow_level12`, seg002:1184)
|
||||
|
||||
Самая содержательная функция уровня. Три режима:
|
||||
|
||||
1. **Первый кадр в комнате 15**: пока Кид не подошёл (`Opp.x < 150`) —
|
||||
`shadow_initialized = 1`; иначе тень ещё раз падает (`do_init_shad`).
|
||||
2. **Кид с мечом** (`Char.sword >= sword_2_drawn`) → тень дерётся обычным
|
||||
`autocontrol_guard_active` (у нас есть, `guards.c:535`). Особый случай:
|
||||
если тень уже ранена (`offguard != 0 && guard_refrac != 0`) — она убирает
|
||||
меч (`move_4_down`).
|
||||
3. **Кид убрал меч** → тень тоже убирает и идёт навстречу; на дистанции
|
||||
`< 10` — **СЛИЯНИЕ**:
|
||||
|
||||
```c
|
||||
flash_color = color_15_brightwhite; flash_time = 18;
|
||||
add_life(); // +1 к максимуму HP
|
||||
united_with_shadow = 42; // время вспышки Кид-тень
|
||||
Char.charid = charid_0_kid; savekid(); // Кид ПЕРЕЕЗЖАЕТ на место тени
|
||||
clear_char(); // тень со сцены
|
||||
```
|
||||
|
||||
Плюс «если Кид бежит к тени — тень бежит к Киду» (кадры бега 3..14 и
|
||||
шага 127..132).
|
||||
|
||||
**Что добавить:** `autocontrol_shadow_level12` в `guards.c` + ветку в
|
||||
диспетчере `autocontrol_shadow` (там уже три ветки, будет четвёртая).
|
||||
|
||||
### 1.3 Общий урон (`do_delta_hp`, seg000:1518)
|
||||
|
||||
```c
|
||||
if (Opp.charid == charid_1_shadow && current_level == 12 && guardhp_delta != 0)
|
||||
hitp_delta = guardhp_delta; // ранил тень — ранил себя
|
||||
```
|
||||
|
||||
Три строки, но без них бой с тенью теряет смысл. У нас `do_delta_hp`
|
||||
портирован — добавить условие.
|
||||
|
||||
### 1.4 Таймер вспышки (`do_timers`, seg003:503)
|
||||
|
||||
```c
|
||||
if (united_with_shadow > 0) {
|
||||
--united_with_shadow;
|
||||
if (united_with_shadow == 0) { --united_with_shadow; /* → -1 */ ... }
|
||||
}
|
||||
```
|
||||
|
||||
`united_with_shadow` живёт как счётчик, потом как «уже слились» (−1).
|
||||
На него смотрят `check_shadow` и `check_can_guard_see_kid`.
|
||||
|
||||
### 1.5 Луч видимости (`check_can_guard_see_kid`, seg003:702)
|
||||
|
||||
```c
|
||||
if ((Guard.charid != charid_1_shadow || current_level == 12) && ...
|
||||
```
|
||||
|
||||
У нас (`guards.c:103`) условие про тень **нужно сверить**: на уровне 12 тень
|
||||
ОБЯЗАНА быть видимой (иначе Кид не достанет меч и бой не начнётся).
|
||||
|
||||
### 1.6 Меч исчезает (`sword_disappears`, seg002:0536)
|
||||
|
||||
```c
|
||||
if (current_level == 12 && Char.room == 18) {
|
||||
get_tile(15, 1, 0);
|
||||
curr_room_tiles[curr_tilepos] = tiles_1_floor;
|
||||
curr_room_modif[curr_tilepos] = 0;
|
||||
}
|
||||
```
|
||||
|
||||
Срабатывает при уходе Кида ВПРАВО из комнаты 18 (`leave_room`, ветка 1).
|
||||
У нас есть `pop_level_set_tile` — порт на пять строк.
|
||||
|
||||
### 1.7 Переход 12 → 13: НЕТ двери уровня, есть «бесшовный выход»
|
||||
|
||||
Проверено по данным (`res2012.bin` / `res2013.bin`) — портала действительно
|
||||
нет, уровень кончается **фактом попадания в комнату**:
|
||||
|
||||
```c
|
||||
// play_level_2, seg000:900
|
||||
} else if (custom->tbl_seamless_exit[current_level] >= 0) {
|
||||
if (Kid.room == /*23*/ custom->tbl_seamless_exit[current_level]) {
|
||||
++next_level;
|
||||
stop_sounds();
|
||||
seamless = 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tbl_seamless_exit[12] = 23` (пара «уровень, комната» читается из оригинального
|
||||
`PRINCE.EXE`, options.c:724). Дальше геометрия складывается так:
|
||||
|
||||
| | комната | ряд 1 | что происходит |
|
||||
|---|---|---|---|
|
||||
| ур. 12 | 13 | `floor bigpil empty empty wall wall …` | Кид бежит ВЛЕВО с колонки 0 |
|
||||
| ур. 12 | 23 | `empty ×6, floor floor floor floor` | попал сюда → **уровень сменился** |
|
||||
| ур. 13 | 23 | `floor bigpil floor ×6 bigpil floor` | старт: ряд 1, кол 9, `seq_84_run` |
|
||||
|
||||
Связи: `комната 13.left = 23`, и в комнату 23 больше ниоткуда не войти.
|
||||
Номер комнаты у обоих уровней **один и тот же (23)**, стартовая позиция
|
||||
уровня 13 — `BP_START = 23, поз 19 (ряд 1, кол 9), dir 0`, а
|
||||
`tbl_entry_pose[13] = 2` даёт «вбегающий» вход (`seq_84_run`, seg003:172).
|
||||
То есть Кид вбегает в комнату слева-направо… нет, `Char.direction =
|
||||
~level.start_dir` — влево, тем же ходом, каким выбежал из уровня 12. Швов не
|
||||
видно.
|
||||
|
||||
Что делает флаг `seamless` (всего два места, оба косметические):
|
||||
|
||||
* `start_level`, seg003:158 — **НЕ сбрасывает HP**: Кид уносит на уровень 13
|
||||
то здоровье, с которым добежал;
|
||||
* `show_level`, seg008:1861/1870 — не показывает заставку «LEVEL 13» и тут же
|
||||
гасит флаг.
|
||||
|
||||
Двери уровня при этом на карте есть, но не при делах: у уровня 12 она одна
|
||||
(комната 3, tilepos 23) — это ВХОД (стартовая комната 12-го — 3), а у уровня
|
||||
13 их две (комната 3 tilepos 13 и комната 5 tilepos 24) — это выходы,
|
||||
открываемые кнопкой из `Jaffar_exit`.
|
||||
|
||||
**Что это значит для нас.** Наш переход уровня сейчас идёт только через
|
||||
`SEQ_END_LEVEL` в двери. Для 12-го нужен второй триггер — проверка в главном
|
||||
цикле «`pop_current_level == 12 && cur_room == 23` → `pop_next_level++`» плюс
|
||||
флаг `seamless`, который пропустит сброс HP в `pop_start_level`. Обе правки
|
||||
маленькие и локальные.
|
||||
|
||||
---
|
||||
|
||||
## 2. Уровень 13 — Джафар
|
||||
|
||||
### 2.1 Кто такой Джафар
|
||||
|
||||
`tbl_guard_type[13] = 3` → `VIZIER.DAT` (у нас в `pop_level_cold.c` уже 3).
|
||||
`tbl_guard_hp[13] = 6`. Отдельного ИИ у него нет:
|
||||
|
||||
```c
|
||||
void autocontrol_Jaffar() { autocontrol_guard(); } // seg002:0697
|
||||
```
|
||||
|
||||
То есть **бой с Джафаром — обычный бой стражи**, отличаются только спрайты,
|
||||
HP и три спецсобытия ниже. Это хорошая новость: боёвка у нас есть.
|
||||
|
||||
### 2.2 Встреча (`meet_Jaffar`, seg002:0544)
|
||||
|
||||
```c
|
||||
if (current_level == 13 && leveldoor_open == 0 && Char.room == 3) {
|
||||
play_sound(sound_29_meet_Jaffar);
|
||||
guard_notice_timer = 28; // Джафар ждёт 28/12 ≈ 2.33 с
|
||||
}
|
||||
```
|
||||
|
||||
Срабатывает при уходе Кида ВПРАВО (`leave_room`, ветка 1).
|
||||
Пара к нему — в `autocontrol_guard_inactive` (seg002:0734), она у нас уже
|
||||
портирована (`guards.c:401`), но **без условия по уровню**:
|
||||
|
||||
```c
|
||||
if (can_guard_see_kid) {
|
||||
if (current_level != 13 || guard_notice_timer == 0) move_down_forw();
|
||||
}
|
||||
```
|
||||
|
||||
**Что добавить:** глобал `guard_notice_timer` + его тик в `do_timers`
|
||||
(seg003:509) + оба условия.
|
||||
|
||||
### 2.3 Победа (`on_guard_killed`, seg006:1929)
|
||||
|
||||
```c
|
||||
} else if (current_level == 13) {
|
||||
flash_color = color_15_brightwhite; flash_time = 18;
|
||||
is_show_time = 1;
|
||||
leveldoor_open = 2; // ← ключ к выходу
|
||||
play_sound(sound_43_victory_Jaffar);
|
||||
}
|
||||
```
|
||||
|
||||
и парная `Jaffar_exit` (seg002:0517), срабатывающая при уходе Кида ВЛЕВО:
|
||||
|
||||
```c
|
||||
if (leveldoor_open == 2) { get_tile(24, 0, 0); trigger_button(0, 0, -1); }
|
||||
```
|
||||
|
||||
То есть смерть Джафара не открывает дверь сама — она ставит флаг, а дверь
|
||||
открывается кнопкой, «нажатой» при уходе влево. `trigger_button` у нас есть.
|
||||
|
||||
### 2.4 Падающие плиты (`check_fall_flo`, seg000:1319)
|
||||
|
||||
```c
|
||||
if (current_level == 13 && (drawn_room == 23 || drawn_room == 16)) {
|
||||
curr_room = room_A; // комната СВЕРХУ
|
||||
for (curr_tilepos = 22; curr_tilepos <= 27; ++curr_tilepos)
|
||||
make_loose_fall(-(prandom(0xFF) & 0x0F)); // ОТРИЦАТЕЛЬНЫЙ модификатор
|
||||
}
|
||||
```
|
||||
|
||||
Вот это и есть «плиты появляются»: при входе в комнаты 23/16 шесть плит ряда 2
|
||||
комнаты СВЕРХУ получают отрицательную фазу — то есть отложенный старт, и
|
||||
сыплются на Кида вразнобой. Зовётся из `check_the_end` при смене комнаты.
|
||||
|
||||
**Три спецкейса уровня 13 в loose-механике**, без них это не работает:
|
||||
|
||||
| место | что | зачем |
|
||||
|---|---|---|
|
||||
| `animate_loose`, seg007:823 | при `modif & 0x80` НЕ останавливать тряску | иначе отрицательная фаза не досчитает до нуля и плита не упадёт |
|
||||
| `loose_make_shake`, seg007:949 | на уровне 13 сотрясение НЕ трясёт плиты | иначе отложенные плиты сбрасываются в 0x80 |
|
||||
| `fell_on_your_head`, seg007:1218 | плита бьёт и в БЕГЕ (кадры 5..14) | на прочих уровнях бегущего не задевает |
|
||||
|
||||
У нас первый пункт критичен: `pop_loose_tick` трактует бит 7 как «тряска» и
|
||||
на `>= 0x84` гасит фазу — отрицательный старт умрёт, не начавшись. Заодно
|
||||
это ровно та же ветка, что мы правили сегодня в
|
||||
[`LOOSE-ROOM-CHANGE`](BUGS_OPEN.md#loose-room-change), так что
|
||||
код на виду.
|
||||
|
||||
### 2.5 Поза входа
|
||||
|
||||
`tbl_entry_pose[13] = 2` — у нас в таблице уже есть; проверить, что режим 2
|
||||
(`seg003:172`) реализован.
|
||||
|
||||
---
|
||||
|
||||
## 3. Уровень 14 — принцесса и конец игры
|
||||
|
||||
Боя нет вовсе (`tbl_guard_type[14] = -1`). Всё сводится к одному событию:
|
||||
|
||||
```c
|
||||
// check_the_end, seg000:1299
|
||||
if (current_level == 14 && drawn_room == 5) end_sequence();
|
||||
```
|
||||
|
||||
`end_sequence` (seg001:573) → `end_sequence_anim` (seg001:332): катсцена
|
||||
«Кид добежал до принцессы» — обнимаются, появляется мышь, затухание, Hall of
|
||||
Fame. Персонажи там играются ТЕМ ЖЕ интерпретатором `seqtbl`
|
||||
(`seq_108_princess_turn_and_hug`, `seq_101_mouse_stands_up`), то есть движок
|
||||
у нас уже подходит — нужны спрайты принцессы (`PRINCESS.DAT`) и раскадровка.
|
||||
|
||||
**Оценка:** это не игровая механика, а ролик. Логично делать вместе с интро
|
||||
и межуровневыми вставками — отдельным банком, как и договаривались
|
||||
(см. memory `pop_banking_architecture`). На проходимость игры не влияет:
|
||||
достаточно довести Кида до комнаты 5 и показать заглушку.
|
||||
|
||||
---
|
||||
|
||||
## 4. Уровень 15 — «уровень зелий» (защита от копирования)
|
||||
|
||||
Не часть сюжета. Это экран проверки подлинности из оригинала: после
|
||||
уровня `copyprot_level` игра подменяет номер на 15, показывает комнату с
|
||||
**14 зельями, на которых нарисованы буквы**, и требует выпить нужное.
|
||||
|
||||
Механика (всё под `USE_COPYPROT` в SDLPoP):
|
||||
|
||||
| место | что делает |
|
||||
|---|---|
|
||||
| `play_level`, seg003:53 | `level_number == copyprot_level` → грузим 15 |
|
||||
| `redraw_screen`, seg003:273 | поверх зелий рисуются БУКВЫ (`copyprot_letter`) |
|
||||
| `load_alter_mod`, seg008:1204 | одно зелье в комнате делается «открытым» (тип 6) |
|
||||
| `animate_potion`, seg007:259 | на уровне 15 своя ветка перерисовки |
|
||||
| `up_pressed`/`do_pickup`, seg005:657 | выпитое зелье убирает букву из таблицы |
|
||||
| `seq` эффект зелья, seg006:1896 | синие зелья на уровне 15 отнимают ПОЛОВИНУ HP |
|
||||
| выход, seg000:700 | из 15 возвращаемся в `copyprot_level` |
|
||||
|
||||
**Рекомендация: не портировать.** Это анти-пиратский экран 1989 года,
|
||||
требующий книжки-манускрипта; SDLPoP держит его выключенным по умолчанию
|
||||
(`enable_copyprot`). Единственное, что стоит взять — **половинный урон
|
||||
синего зелья**, если вдруг захочется полной совместимости; остальное только
|
||||
съест банк. Если решим делать — это отдельная фича «уровень 15», а не часть
|
||||
основного прохождения.
|
||||
|
||||
---
|
||||
|
||||
## 5. Уровень 0 — демо-уровень (аттракт)
|
||||
|
||||
`res2000.bin` у нас распакован. Это тот самый ролик, который крутится на
|
||||
титульном экране: Кид сам бежит, дерётся со стражем и убегает.
|
||||
|
||||
Как устроено:
|
||||
|
||||
| место | что |
|
||||
|---|---|
|
||||
| `do_demo`, seg006:1409 | на уровне 0 вместо чтения клавиатуры зовётся `do_demo()` + `control()` |
|
||||
| `autocontrol_kid`, seg002:702 | Кид управляется тем же `autocontrol_guard` |
|
||||
| `do_auto_moves`, seg002:1089 | проигрыватель ЗАПИСИ ходов: таблица `(time, move)` |
|
||||
| `on_guard_killed`, seg006:1928 | на уровне 0 после убийства стража Кид убегает (`checkpoint = 1`, сброс демо) |
|
||||
| `demo_index` / `demo_time` | позиция в записи; у нас уже объявлены в `guards.c:267` |
|
||||
|
||||
**Хорошая новость:** движок автодвижений (`do_auto_moves`) у нас уже есть —
|
||||
он нужен был тени на уровнях 4/5/6, и `demo_time`/`demo_index` объявлены там
|
||||
же. То есть демо-уровень — это в основном таблица ходов + ветка «Кидом
|
||||
управляет ИИ» в `pop_ctrl`.
|
||||
|
||||
**Когда делать:** вместе с интро/титульным экраном, не раньше. На
|
||||
прохождение не влияет.
|
||||
|
||||
---
|
||||
|
||||
## 6. Порядок работ
|
||||
|
||||
Порядок задан пользователем 2026-08-13: **строго по номерам уровней**, а не
|
||||
по дешевизне кода — уровень 13 не имеет смысла раньше, чем на него можно
|
||||
попасть.
|
||||
|
||||
1. ✅ **Уровень 12** — тень: `init_shad_12`, `autocontrol_shadow_level12`,
|
||||
`united_with_shadow`, общий урон, «убил тень — убил себя»,
|
||||
`sword_disappears`, появление плит. Сделано 2026-08-13, хост-набор
|
||||
`SprPoP/tests-host/t_shadow.c` (45 проверок); живой проверки в MAME
|
||||
ещё не было — доска [`L12-SHADOW`](TASKS_OPEN.md#l12-shadow).
|
||||
2. ✅ **Переход 12 → 13** (§1.7) — room-триггер вместо двери и флаг
|
||||
`pop_seamless` (не сбрасывать HP). Сделано там же.
|
||||
3. ✅ **Уровень 13** — Джафар: `guard_notice_timer`,
|
||||
`on_guard_killed`/`Jaffar_exit`, три спецкейса loose (§2.4) и
|
||||
`check_fall_flo`. Плюс СПРАЙТЫ визиря (VIZIER.DAT) — без них он
|
||||
рисовался обычным стражем. Сделано 2026-08-13, хост-набор
|
||||
`SprPoP/tests-host/t_jaffar.c` (44 проверки); живой проверки в MAME ещё
|
||||
не было — доска [`L13-JAFFAR`](TASKS_OPEN.md#l13-jaffar).
|
||||
4. **Уровень 14** — довести до комнаты 5 и поставить заглушку вместо ролика;
|
||||
сам ролик — в общую задачу «катсцены».
|
||||
5. **Уровень 0 и 15** — отложить: аттракт и защита от копирования на
|
||||
прохождение не влияют.
|
||||
|
||||
---
|
||||
|
||||
## 7. Сверка констант с ОРИГИНАЛЬНЫМИ данными (сделана 2026-08-13)
|
||||
|
||||
Всё ниже снято скриптом прямо с `../SDLPoP/data/LEVELS/res2013.bin` и
|
||||
`res2012.bin` — не из головы и не из констант SDLPoP.
|
||||
|
||||
### 7.1 Падающие плиты уровня 13 — константы сходятся ТОЧНО
|
||||
|
||||
Комнаты, у которых в ряду 2 вообще есть loose:
|
||||
|
||||
| комната | loose в колонках | комната снизу |
|
||||
|---|---|---|
|
||||
| 17 | 2, 3, 4, 5, 6, 7 | **23** |
|
||||
| 1 | 2, 3, 5, 6, 7 | **16** |
|
||||
| 14 | 2 | 24 |
|
||||
|
||||
Связи: `комната 23: up = 17`, `комната 16: up = 1`. То есть
|
||||
`loose_tiles_room_1 = 23` и `room_2 = 16` — это ровно те две комнаты, над
|
||||
которыми лежит ПОЛНАЯ гряда плит, а `first_tile = 22, last_tile = 27` — ровно
|
||||
ряд 2, колонки 2..7:
|
||||
|
||||
```
|
||||
комната 23 → сверху 17: 22:LOOSE 23:LOOSE 24:LOOSE 25:LOOSE 26:LOOSE 27:LOOSE [6/6]
|
||||
комната 16 → сверху 1: 22:LOOSE 23:LOOSE 24:empty 25:LOOSE 26:LOOSE 27:LOOSE [5/6]
|
||||
```
|
||||
|
||||
Два вывода:
|
||||
|
||||
* диапазон 22..27 — **надмножество**: в комнате 1 тайл 24 пустой, и
|
||||
`make_loose_fall` его молча пропустит (проверяет тип тайла). Копировать
|
||||
константы можно как есть;
|
||||
* пара «комната 14 → 24» НЕ входит в спецсобытие намеренно — одна плита, это
|
||||
обычный loose.
|
||||
|
||||
**Важное следствие, которого не было в плане:** стартовая комната уровня 13 —
|
||||
**23** (`BP_START = 23`, поз 19, dir 0), а `check_fall_flo` зовётся из
|
||||
`draw_level_first` → `check_the_end` (seg003:217). Значит плиты начинают
|
||||
сыпаться СРАЗУ при входе на уровень, это его первый кадр, а не событие
|
||||
где-то в середине.
|
||||
|
||||
### 7.2 Джафар — один страж со skill 9
|
||||
|
||||
В `res2013.bin` заполнен ровно один слот стража:
|
||||
|
||||
| комната | тайл | ряд, кол | направление | skill |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 7 | 0, 7 | 255 (влево) | **9** |
|
||||
|
||||
И это сходится с `meet_Jaffar`: событие срабатывает, когда Кид уходит ВПРАВО
|
||||
из комнаты 3, а `комната 3: right = 1` — то есть ровно туда, где стоит
|
||||
Джафар. Skill 9 у нас поддержан: `NUM_GUARD_SKILLS = 12`, все шесть таблиц
|
||||
вероятностей (`guards.c:411..415`) имеют индекс 9. HP = `tbl_guard_hp[13] = 6`
|
||||
(в `pop_level_cold.c` уже стоит).
|
||||
|
||||
`Jaffar_exit` тоже проверен: **комната 24, tilepos 0 = `opener`** (кнопка),
|
||||
модификатор 0 — то есть `trigger_button(0,0,-1)` жмёт реальную кнопку, а не
|
||||
пустой тайл.
|
||||
|
||||
### 7.3 Уровень 12 — меч на месте
|
||||
|
||||
`комната 15, tilepos 1 = SWORD` — условие подъёма тени
|
||||
(`get_tile(15,1,0) == tiles_22_sword`) на наших данных выполняется.
|
||||
`комната 18: right = 19` — `sword_disappears` срабатывает при уходе вправо.
|
||||
|
||||
Комната 15 целиком (по ней видно всю сцену встречи с тенью):
|
||||
|
||||
```
|
||||
ряд 0: floor SWORD floor torch torch LOOSE LOOSE floor empty empty
|
||||
ряд 1: wall wall wall empty empty empty empty wall floor floor
|
||||
ряд 2: wall opener pillar empty empty empty empty wall pillar empty
|
||||
```
|
||||
|
||||
### 7.4 `pop_loose_tick` действительно убьёт отрицательную фазу
|
||||
|
||||
Подтверждено чтением кода: `m = ++pop_loose_modif[pos]; if (m & 0x80) { if (m
|
||||
>= 0x84) → сброс в 0 }`. Для стартового `0xF0..0xFF` первый же тик даёт
|
||||
`m >= 0x84` → фаза обнуляется, плита не падает. **Это и есть та правка №1 из
|
||||
таблицы §2.4**, и она обязательна.
|
||||
|
||||
### 7.5 `init_shad_12` и байтовое переполнение `y`
|
||||
|
||||
`init_shad_12 = {0x0F, 0x51, 0xE8, 0, 0, 0, 0, 0}` → frame 15, x 81,
|
||||
**y 232**, вправо, колонка 0, ряд 0, action 0 + `seq 7 (fall)`. Поле `y` в
|
||||
`char_type` — `byte` (беззнаковое), то есть 232 лежит НИЖЕ поля (192), и
|
||||
падение уводит его дальше с переполнением байта: тень «падает» из-под экрана и
|
||||
появляется сверху. У нас `pop_char_t.y` тоже `uint8_t` — поведение
|
||||
переносится без правок; при портировании просто не «чинить» это как ошибку.
|
||||
|
||||
Отдельно про ряд: `curr_row` берётся ИЗ ТАБЛИЦЫ и равен **0**, хотя
|
||||
`pop_y_to_row(232)` дал бы −1. `do_init_shad` копирует семь полей как есть и
|
||||
ряд не пересчитывает, так что «согласовать» их — значит разойтись с
|
||||
оригиналом. Закреплено тестом `shadow12_rises_when_sword_gone`.
|
||||
@@ -0,0 +1,209 @@
|
||||
# План: от одного уровня к нескольким (загрузка, переходы, тайлсеты)
|
||||
|
||||
Статус: план, 2026-08-01. Продолжает `PORT_PLAN.md` §7 (Фаза 1: «переходы
|
||||
между экранами» → теперь между УРОВНЯМИ). Текущая точка: `SprPoP` играет
|
||||
уровень 1 целиком в одной комнате-за-комнатой модели, но уровень нельзя
|
||||
ни выбрать, ни закончить.
|
||||
|
||||
Источник истины — `../SDLPoP/src/` (правило `../CLAUDE.md`). Ключевые
|
||||
места: `seg000.c: load_lev_spr/play_level_2/init_game`, `seg005.c:
|
||||
up_pressed/go_up_leveldoor`, `seg006.c: play_seq → SEQ_END_LEVEL`,
|
||||
`seg002.c` (спецсобытия уровней), `data.h:835..850` (потабличные различия
|
||||
уровней).
|
||||
|
||||
---
|
||||
|
||||
## 0. Что уже готово (не проектировать заново)
|
||||
|
||||
- **Формат и загрузчик уровня.** `pop_level.c/.h` читает сырой
|
||||
`res200N.bin` (2305 Б) в отдельную EMM-страницу; путь — параметр
|
||||
`pop_level_load(const char *)`. Мультиуровневость здесь стоит одной
|
||||
функции формирования имени.
|
||||
- **Стартовая позиция уровня** уже разобрана: `pop_level_start_room()`,
|
||||
`pop_level_start_pos()`, `pop_level_start_dir()` — реализованы и пока
|
||||
НЕ вызываются (см. `../PoP/SprPoP/TASKS_CLOSED.md` L1-START).
|
||||
- **Страж по данным уровня**: `pop_level_guard()` (порт `enter_guard`),
|
||||
сохранение состояния между комнатами (`pop_guard_state_save`).
|
||||
- **Палитра разложена по слотам ровно как в оригинале** (`pop_pack_kid.py`
|
||||
`build_palette`): env 0x50, wall 0x60, pot 0x40, kid 0x70, меч 0x80,
|
||||
страж 0x90. Это тот же раскрой, что `set_pal_arr(0x50/0x60)` в
|
||||
`seg000.c:1140..1148`, — значит смена тайлсета не требует переиндексации
|
||||
спрайтов Кида (см. §3).
|
||||
- **Все 16 файлов уровней распакованы**: `../SDLPoP/data/LEVELS/res2000..
|
||||
res2015.bin` (0 — демо-уровень).
|
||||
|
||||
---
|
||||
|
||||
## 1. Что реально различается между уровнями (замер по данным, не по памяти)
|
||||
|
||||
Таблицы из `../SDLPoP/src/data.h:840..847` + инвентарь тайлов, снятый прямо
|
||||
с `res200N.bin` (маска `fg & 0x1F`):
|
||||
|
||||
| Ур. | Тайлсет | Страж | Новое против предыдущих |
|
||||
|-----|---------|-------|--------------------------|
|
||||
| 1 | dungeon | обычный | — (текущая база) |
|
||||
| **2** | **dungeon** | **обычный** | **ничего нового: тот же набор объектов минус меч** |
|
||||
| 3 | dungeon | СКЕЛЕТ | чомперы |
|
||||
| 4 | palace | обычный | **тайлсет palace**, зеркало (спецсобытие `mirror_level`) — **СДЕЛАНО** |
|
||||
| 5 | palace | обычный | новых ТАЙЛОВ нет; спецсобытие **тень крадёт зелье** (комната 24) — [L5-SHADOW](TASKS_OPEN.md#l5-shadow) |
|
||||
| 6 | palace | ТОЛСТЫЙ | падение на входе (спецсобытие) |
|
||||
| 7 | dungeon | обычный | — |
|
||||
| 8, 9 | dungeon | обычный | — |
|
||||
| 10, 11 | palace | обычный | — |
|
||||
| 12 | dungeon | ТЕНЬ | seamless-выход (комната 23), исчезающий меч |
|
||||
| 13 | dungeon | ВИЗИРЬ | мышь, особый выход |
|
||||
| 14 | palace | нет | — |
|
||||
| 15 | dungeon | нет | финал |
|
||||
|
||||
Прямое следствие для порядка работ: **уровень 2 не требует ни одного нового
|
||||
ассета и ни одной новой механики** — он проверяет ровно машинерию перехода.
|
||||
Это и есть первый шаг.
|
||||
|
||||
Прочие потабличные различия, которые придётся завести массивами по 16:
|
||||
`tbl_level_type` (тайлсет), `tbl_guard_type` (−1 = стражей нет),
|
||||
`tbl_guard_hp`, `tbl_level_color` (вариантные палитры, 1.3), `tbl_entry_pose`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Шаг 1 — машинерия перехода (цель: уровни 1 → 2 → 3) — **СДЕЛАН 2026-08-04**
|
||||
|
||||
> **Итог.** Всё в этом разделе портировано и проверено в MAME: уровень 1 →
|
||||
> Shift+L → уровень 2 (комната 5, дверь захлопывается за спиной, большие
|
||||
> колонны рисуются) → уровень 3. Разбор что именно сделано и что по
|
||||
> уровню 2 осталось — `../PoP/SprPoP/TASKS_CLOSED.md`, запись **L2**.
|
||||
>
|
||||
> Сверх плана пришлось доделать две вещи, без которых уровень 2 не играется:
|
||||
> **`find_start_level_door`** (стартовый тайл уровня 2 — это правая половина
|
||||
> двери уровня) и **большую склянку** `add_life` (тип зелья 2, комната 20).
|
||||
|
||||
Порядок именно такой; каждый пункт проверяем в MAME отдельно.
|
||||
|
||||
**2.1 Выход с уровня.** Портировать `up_pressed()` ветку двери
|
||||
(`seg005.c:410..423`) + `go_up_leveldoor()` (`seg005.c:497`): дверь рядом
|
||||
(при/за/перед персонажем) И `drawn_room != level.start_room` И створка
|
||||
открыта полностью (`curr_room_modif >= 42` — вариант `fix_exit_door`) →
|
||||
`Char.x = x_bump[...] + 10`, направление влево, `seq_70_go_up_on_level_door`.
|
||||
Затем оживить опкод `0xF1 END_LEVEL` в `play_seq` (`../src/pop_kid.c:418`
|
||||
— сейчас пустой `break`): `++pop_next_level`, как `seg006.c:662`.
|
||||
|
||||
**2.2 Цикл уровня.** В `main()` после тика: `if (pop_next_level !=
|
||||
pop_current_level) → load_level(pop_next_level)`. Порядок сноса/подъёма
|
||||
состояния (порт `load_lev_spr` + `play_level_2`):
|
||||
`pop_level_free` → `pop_level_load("LEVELS\\res200%d.bin")` →
|
||||
`pop_trob_reset` → `pop_guard_reset` → сброс tile-override'ов
|
||||
(`ovr_*` в `sprpop.c`) → `enter_room(pop_level_start_room())` →
|
||||
`kid_init(поза/позиция/направление из данных уровня)`.
|
||||
**HP через уровень переносится** (в оригинале `hitp_beg_lev`), не сбрасывать
|
||||
в максимум — сверить с `seg000.c` `init_game`/`play_level_2`.
|
||||
|
||||
**2.3 Стражи по уровню.** Завести `tbl_guard_type[16]`/`tbl_guard_hp[16]`;
|
||||
`-1` = стражей на уровне нет (уровни 14, 15) — `pop_guard_enter` обязан это
|
||||
понимать, иначе на 14-м полезут стражи из мусора. Для шага 1 (уровни 2, 3)
|
||||
достаточно обычного стража, но проверку `-1` заложить сразу.
|
||||
|
||||
**2.4 Чит «следующий уровень» (Shift+L).** Реализуется ровно тем же
|
||||
`pop_next_level` — и без него отладка уровней превращается в прохождение
|
||||
игры руками. Делать в этом же шаге, не позже (см. §4).
|
||||
|
||||
**Приёмка шага 1:** дверь уровня 1 → уровень 2 играется целиком → его дверь
|
||||
→ уровень 3 стартует (чомперы могут быть ещё не портированы — тогда
|
||||
фиксируем как известное ограничение, а не «баг»).
|
||||
|
||||
---
|
||||
|
||||
## 3. Шаг 2 — второй тайлсет (palace, уровни 4+) — **СДЕЛАН**
|
||||
|
||||
> **Закрыт (ревизия 2026-08-11 по коду).** В дереве есть всё, что
|
||||
> проектировалось ниже: `pop_pack_bg.py` печёт ДВА набора атласов
|
||||
> (`pop_*` из VDUNGEON, `pal_*` из VPALACE, каскад с обменом приоритетов),
|
||||
> `pop_bg_load` принимает тип набора и ставит флаг `pop_palace`, по которому
|
||||
> `pop_room.c` выбирает дворцовую ветку (`wall_pattern` дворца — сплошная
|
||||
> заливка + пять mono-полос, `PALACE_WALL_MONO_IDS`), решётчатые тайлы
|
||||
> 25-29 есть и в `tile_table` (`pop_tile.c`), и в коллизии — `tile_is_floor`
|
||||
> совпадает с `seg006:0628` тайл в тайл. Уровень 4 проходится smoke-тестом.
|
||||
>
|
||||
> Текст ниже оставлен как справочник по раскрою палитры и по тому, почему
|
||||
> смена тайлсета — это перезапись 32 записей, а не переиндексация спрайтов.
|
||||
|
||||
**Ассеты.** `toolchain/pop_pack_bg.py` уже читает PNG каскадом
|
||||
VDUNGEON→VPALACE (та же логика, что в игре), но печёт ОДИН набор атласов
|
||||
(`pop_env0..4.atl`, `pop_wall.atl`, `pop_fore.atl` ≈ 75 КБ). Нужен второй
|
||||
набор из VPALACE (`pal_env*.atl` / `pal_wall.atl`), плюс `torch_debris` —
|
||||
тайл, который встречается только на palace-уровнях. По EMM это ещё ~6
|
||||
страниц при бюджете ~3.3 МБ — не проблема.
|
||||
|
||||
**Палитра — главный технический вопрос, и он уже решён раскроем.**
|
||||
Тайлсет живёт в слотах `0x50..0x5F` (env) и `0x60..0x6F` (wall); Кид, меч,
|
||||
страж, склянки — в других слотах. Значит смена тайлсета = перезапись 32
|
||||
записей палитры (`gfx_pal_set` на обе страницы, как `flash_bg` в
|
||||
`sprpop.c`), а НЕ перезагрузка `kid.pal` и не переиндексация спрайтов.
|
||||
Сделать `pal_dungeon.bin` / `pal_palace.bin` (по 32 записи) и грузить при
|
||||
смене типа уровня. Проверить артефактом: скриншот palace-комнаты против
|
||||
рендера `render_room.py` для того же уровня.
|
||||
|
||||
**Вариантные цвета уровней** (`tbl_level_color`, `level_var_palettes` — это
|
||||
уже 1.3, в 1.0 их нет): по той же механике, тот же диапазон слотов. Решение
|
||||
на будущее — сначала базовые два тайлсета, потом при желании цвета.
|
||||
|
||||
**Выбор набора в коде.** `pop_bg_load()` сейчас грузит фиксированные имена;
|
||||
превратить в `pop_bg_load(type)` с двумя таблицами имён + выгрузка старых
|
||||
атласов при смене типа (`atlas_free`). Переключение — только на границе
|
||||
уровня, не в кадре.
|
||||
|
||||
---
|
||||
|
||||
## 4. Читы SDLPoP: что взять на следующем этапе
|
||||
|
||||
Из `../SDLPoP/README.md` (раздел Cheats). У нас уже есть: **K** — убить
|
||||
стража, **I** — бессмертие (наш, в оригинале нет), **S** — выдать меч (наш),
|
||||
**+/−** — обход комнат (`ROOMNAV`, наш).
|
||||
|
||||
**Брать сразу вместе с переходами уровней** (без них отладка дороже самой
|
||||
работы):
|
||||
|
||||
| Чит | Что даёт | Цена |
|
||||
|-----|----------|------|
|
||||
| **Shift+L — следующий уровень** | единственный вменяемый способ тестировать уровни 2..15 | тривиально: `++pop_next_level` из §2.2 |
|
||||
| **R — воскресить Кида** | у нас респавн по ↑ + таймаут; порт `resurrect` ближе к оригиналу и не мешает управлению | низкая |
|
||||
| **Shift+S / Shift+T — +1 HP / +максимум** | отладка боёвки без «ровно трёх попыток»; честная замена нашему читу бессмертия | низкая, HP-машинерия уже есть |
|
||||
| **[ и ] — сдвинуть Кида на пиксель** (debug-чит SDLPoP) | прямо бьёт в наш класс багов «окклюзия/шов на один пиксель» — воспроизведение позы без ловли момента | тривиально |
|
||||
|
||||
**Брать во вторую очередь:**
|
||||
|
||||
| Чит | Почему позже |
|
||||
|-----|--------------|
|
||||
| **H / J / U / N + Ctrl+B — смотреть соседние комнаты** | требует честной модели `drawn_room ≠ Kid.room` (наш S3-straddle, каркас есть: `update_kid_render_dx`). Зато потом заменяет самодельный `ROOMNAV` и попутно закрывает straddle-задачу |
|
||||
| **Shift+W — медленное падение (feather)** | ветка `JMP_IF_FEATHER` (опкод `0xF7`) в `play_seq` уже есть, но не проверена ничем — чит станет её единственным тестом |
|
||||
| **C / Shift+C — номера комнат** | у нас номер рисуется палочками именно потому, что текст тянет 2 КБ знакогенератора в W2 (`sprpop.c`). Ждёт своего шрифта |
|
||||
|
||||
**Не брать:** `Shift+I` (переворот экрана), `Shift+B` (blind mode) —
|
||||
развлекательные, к отладке порта отношения не имеют. `−/+` (время) — нужен
|
||||
таймер уровня, которого у нас нет (Фаза 6).
|
||||
|
||||
**Отдельно, дорого, но очень ценно — `F6`/`F9` (quicksave/quickload точного
|
||||
состояния).** Это сериализация `Char` + `room_modif` всех комнат + trob'ов +
|
||||
состояния стражей. Даёт то, чего нам сейчас сильно не хватает:
|
||||
воспроизводимый регресс в MAME («вот кадр, где баг») вместо ручного подхода
|
||||
к позиции. Кандидат сразу после того, как заработают уровни.
|
||||
|
||||
---
|
||||
|
||||
## 5. Риски и что проверить артефактом до кодинга
|
||||
|
||||
1. **Размер кода.** Замер сборки 2026-08-01: `_CODE` 25 119 Б,
|
||||
куча ~2.4 КБ, банк 2 (`pop_bg`) 13 792 / 16 384, банк 3 (`pop_map`)
|
||||
6 331, банк 1 (`guards`) 1 896, банк 4 (`pop_gdraw`) 2 236. Чомперы,
|
||||
зеркало, скелет и второй тайлсет пойдут в банк 2 — там осталось 2.6 КБ.
|
||||
**Прежде чем начинать §3, посчитать, куда лягут новые тайлы**, иначе
|
||||
повторится история «банк 2 упёрся в потолок» (коммит 2f3e854). Свободные
|
||||
номера банков есть (5+), гранулярность — файл.
|
||||
2. **Спецсобытия уровней** (`seg002.c`: `level3_set_chkp`, `sword_disappears`,
|
||||
`Jaffar_exit`, зеркало, мышь) — их НЕ надо портировать заранее. Для
|
||||
уровней 2 и 3 нужен только чекпойнт уровня 3. Остальное — по мере
|
||||
подхода к уровню.
|
||||
3. **Чомперы** (уровень 3 и почти все дальше) — отдельная механика
|
||||
(`animate_chomper` + коллизия + смерть); шаблон работы тот же, что у
|
||||
пик/ворот, см. `gates_spikes_plan.md`.
|
||||
4. **`tbl_guard_type = -1`** на уровнях 14/15: без проверки страж
|
||||
«появится» из неинициализированных данных.
|
||||
5. **Уровень 0 (демо)** существует в данных, но в скоуп не входит.
|
||||
@@ -0,0 +1,529 @@
|
||||
# Pause menu и Settings для Sprinter PoP
|
||||
|
||||
Статус: **MS0, MS2 и MS4–MS8 выполнены** (2026-08-23). Pause menu, CFG,
|
||||
Settings, диалоги, Controls и build screen находятся в bank 9. Решение о
|
||||
рендеринге и затемнении — §10.
|
||||
|
||||
Связанные документы:
|
||||
|
||||
- [`full_game_plan.md`](full_game_plan.md) — автомат состояний, title,
|
||||
demo, cutscenes и ending;
|
||||
- [`quicksave_plan.md`](quicksave_plan.md) — состав и восстановление снимка.
|
||||
|
||||
## 1. Решения
|
||||
|
||||
- Программа работает только с HDD; настройки, QuickSave и Hall of Fame
|
||||
всегда могут быть постоянными файлами.
|
||||
- Основной pause menu обязательно содержит QuickSave и QuickLoad.
|
||||
- QuickSave имеет один слот `POP.SAV`; предыдущая корректная запись хранится
|
||||
как `POP.BAK`.
|
||||
- Первая версия имеет один профиль `VANILLA`. Под этим именем пока понимается
|
||||
**текущее поведение SprPoP**, включая уже встроенные исправления.
|
||||
- Дизайн файла и API предусматривает будущий `ENHANCED`, но аудит и
|
||||
переключение fixes сейчас не выполняются.
|
||||
- Уровень 15/copy protection отсутствует.
|
||||
- Моды, levelsets и меню Mods отложены.
|
||||
|
||||
## 2. Что есть в SDLPoP
|
||||
|
||||
`src/menu.c` содержит:
|
||||
|
||||
- Resume, QuickSave, QuickLoad, Restart Level, Settings, Restart Game, Quit;
|
||||
- General, Gameplay, Visuals, Mods, Controls;
|
||||
- toggle/number/key controls, пояснения, scroll и confirmation dialogs;
|
||||
- большой список fixes/enhancements и custom level options.
|
||||
|
||||
На Sprinter не переносятся SDL-специфичные параметры: fullscreen, hardware
|
||||
acceleration, scaling, aspect ratio, rumble. UI берёт структуру SDLPoP, но
|
||||
набор настроек соответствует платформе.
|
||||
|
||||
## 3. Pause menu первой версии
|
||||
|
||||
```text
|
||||
RESUME
|
||||
QUICKSAVE (F6)
|
||||
QUICKLOAD (F9)
|
||||
RESTART LEVEL
|
||||
SETTINGS
|
||||
RESTART GAME
|
||||
QUIT GAME
|
||||
```
|
||||
|
||||
Поведение:
|
||||
|
||||
- `Esc` в `PLAYING` открывает меню; повторный Esc или Resume возвращает игру;
|
||||
- игра, логический таймер и звуковой насос ставятся на паузу согласованно;
|
||||
- QuickSave/QuickLoad только взводят запрос, фактическая операция идёт на
|
||||
безопасной границе кадра;
|
||||
- QuickLoad disabled/показывает `NO QUICKLOAD`, если нет валидных SAV/BAK;
|
||||
- перед QuickLoad из меню лёгкий probe проверяет заголовок и checksum обоих
|
||||
файлов: валидный `POP.BAK` при отсутствующем/битом `POP.SAV` требует
|
||||
отдельного `LOAD BACKUP?`, а не загружается молча;
|
||||
- Restart Level и Restart Game выполняются сразу, БЕЗ подтверждения
|
||||
(2026-08-22): Restart Level перечитывает уровень, Restart Game завершает
|
||||
gameplay и возвращает к первому экрану title/intro; новая игра создаётся
|
||||
общим LEVEL_LOAD только после skip/attract;
|
||||
- Quit требует подтверждения и закрывает файлы/каналы штатным путём;
|
||||
- меню недоступно в demo, cutscene, time-expired и ending;
|
||||
- отдельная debug-комбинация немедленного выхода может остаться только в
|
||||
отладочной сборке.
|
||||
|
||||
## 4. Settings первой версии
|
||||
|
||||
```text
|
||||
GENERAL
|
||||
Sound ON / OFF
|
||||
Show Sprinter screen ON / OFF
|
||||
Restore defaults...
|
||||
|
||||
GAMEPLAY
|
||||
Speed NORMAL / FAST / FASTEST
|
||||
Gameplay profile VANILLA
|
||||
Cheats ON / OFF
|
||||
|
||||
CONTROLS
|
||||
Show key bindings
|
||||
|
||||
BACK
|
||||
```
|
||||
|
||||
`Gameplay profile: VANILLA` показывается read-only: место в модели уже есть,
|
||||
но пользователь не может выбрать ещё не реализованный ENHANCED.
|
||||
|
||||
Изменения применяются немедленно к скорости, читам и звуку, но `POP.CFG`
|
||||
записывается один раз при Back/Esc. На экране есть итог `SETTINGS SAVED` или
|
||||
`SAVE ERROR`; во втором случае runtime-значения остаются рабочими.
|
||||
|
||||
Отладочные параметры `ROOMNAV`, border profiling, stop-frame и переключение
|
||||
double buffering не являются пользовательскими Settings. Они остаются
|
||||
compile-time/debug функциями и скрываются из release UI.
|
||||
|
||||
## 5. Модель настроек
|
||||
|
||||
Игровой код не должен читать UI-структуры. Единственный runtime-контракт:
|
||||
|
||||
```c
|
||||
typedef enum {
|
||||
POP_PROFILE_VANILLA = 0,
|
||||
POP_PROFILE_ENHANCED = 1
|
||||
} pop_gameplay_profile_t;
|
||||
|
||||
typedef struct {
|
||||
uint8_t sound_enabled;
|
||||
uint8_t speed_mode;
|
||||
uint8_t gameplay_profile;
|
||||
uint8_t cheats_enabled;
|
||||
uint8_t show_build_info;
|
||||
uint16_t enhancement_flags;
|
||||
} pop_settings_t;
|
||||
```
|
||||
|
||||
В первой версии загрузчик принимает только `POP_PROFILE_VANILLA`. Значение
|
||||
ENHANCED из более нового/ручного файла заменяется на VANILLA с диагностикой,
|
||||
а не включает частично реализованный режим.
|
||||
|
||||
Будущий профиль задаёт маску возможностей централизованно:
|
||||
|
||||
```text
|
||||
VANILLA -> текущий согласованный набор
|
||||
ENHANCED -> будущий рекомендуемый набор fixes
|
||||
CUSTOM -> только если позже действительно понадобится
|
||||
```
|
||||
|
||||
До отдельного аудита существующие `fix_exit_door`, feather guard behavior,
|
||||
jump grab и sound priorities не переключаются и считаются частью текущего
|
||||
VANILLA.
|
||||
|
||||
## 6. Файл POP.CFG
|
||||
|
||||
Бинарный, компактный, версионированный формат:
|
||||
|
||||
```text
|
||||
+0 "PCFG" magic, 4 Б
|
||||
+4 format_version 1 Б
|
||||
+5 payload_size 2 Б
|
||||
+7 payload фиксированные поля little-endian
|
||||
.. checksum 2 Б
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- путь рядом с exe/в выделенном каталоге игры на HDD;
|
||||
- неизвестная версия, неверная длина или checksum -> defaults;
|
||||
- неизвестные будущие хвостовые поля можно пропустить по `payload_size`;
|
||||
- запись только после Apply/выхода из Settings, не на каждый шаг курсора;
|
||||
- ошибка записи не завершает игру: показать сообщение и оставить runtime
|
||||
значения;
|
||||
- Restore defaults меняет RAM только после подтверждения и затем сохраняет.
|
||||
|
||||
CFG не содержит состояние уровня, QuickSave или Hall of Fame.
|
||||
|
||||
## 7. QuickSave / QuickLoad в меню
|
||||
|
||||
Детальный состав снимка и порядок восстановления — в
|
||||
[`quicksave_plan.md`](quicksave_plan.md). Здесь фиксируется UI и файловая
|
||||
транзакция.
|
||||
|
||||
### Один слот и backup
|
||||
|
||||
Файлы:
|
||||
|
||||
```text
|
||||
POP.SAV текущий слот
|
||||
POP.BAK предыдущий валидный слот
|
||||
POP.NEW временный файл во время записи
|
||||
```
|
||||
|
||||
Безопасная запись:
|
||||
|
||||
1. записать полный снимок в `POP.NEW`;
|
||||
2. закрыть файл;
|
||||
3. повторно открыть/прочитать заголовок и checksum;
|
||||
4. старый валидный `POP.SAV` перенести/скопировать в `POP.BAK`;
|
||||
5. `POP.NEW` сделать новым `POP.SAV`;
|
||||
6. при любой ошибке сохранить прежний `POP.SAV`.
|
||||
|
||||
Точную последовательность rename/copy выбрать после характеризации DSS.
|
||||
Если атомарный rename не гарантирован, использовать copy + fsync/close и
|
||||
никогда не удалять единственную валидную копию до проверки новой.
|
||||
|
||||
### Загрузка
|
||||
|
||||
1. проверить `POP.SAV`;
|
||||
2. если он отсутствует/повреждён/несовместим — проверить `POP.BAK`;
|
||||
3. при валидном BAK показать `LOAD BACKUP?`;
|
||||
4. несовместимая версия — `INCOMPATIBLE SAVE`, без частичной загрузки;
|
||||
5. после успеха закрыть menu, перерисовать обе страницы, перезапустить звук.
|
||||
|
||||
### Сообщения
|
||||
|
||||
Минимальный набор:
|
||||
|
||||
```text
|
||||
QUICKSAVED
|
||||
QUICKLOADED
|
||||
NO QUICKLOAD
|
||||
SAVE ERROR
|
||||
INCOMPATIBLE SAVE
|
||||
LOAD BACKUP?
|
||||
```
|
||||
|
||||
Сообщение показывается UI-слоем, но операция завершается до возврата в
|
||||
игровой кадр.
|
||||
|
||||
## 8. Restart Level / Restart Game
|
||||
|
||||
Restart Level:
|
||||
|
||||
- использует существующий штатный reset текущего уровня;
|
||||
- не перечитывает CFG;
|
||||
- не меняет `POP.SAV`;
|
||||
- сбрасывает состояние, которое сбрасывает текущая реализация SprPoP.
|
||||
|
||||
Restart Game:
|
||||
|
||||
- выполняется сразу, без подтверждения;
|
||||
- завершить текущий gameplay session и вернуть автомат в TITLE;
|
||||
- начать title/intro с самого первого экрана;
|
||||
- создать новую игру с `FIRST_LEVEL` и новым глобальным таймером только
|
||||
после пользовательского skip либо ввода в attract-demo;
|
||||
- настройки оставить;
|
||||
- QuickSave не удалять.
|
||||
|
||||
## 9. Controls
|
||||
|
||||
Первая версия только показывает активную раскладку. Переназначение клавиш
|
||||
откладывается: raw PS/2 канал имеет особенности Shift и расширенных кодов,
|
||||
поэтому generic key-binding UI требует отдельного проекта.
|
||||
|
||||
Экран должен перечислить минимум:
|
||||
|
||||
- движение и Shift/action;
|
||||
- Esc/menu;
|
||||
- F6/F9 QuickSave/QuickLoad;
|
||||
- Ctrl+S sound;
|
||||
- P speed;
|
||||
- доступные cheats, только если они включены: K/Kill Guard, I/Immortal,
|
||||
Shift+L/Next Level, U/Flip Screen и F7/F8/Time −/+ на отдельных понятных
|
||||
строках. Нижней подсказки `Esc or Enter: Back` нет.
|
||||
|
||||
## 10. UI renderer и ввод
|
||||
|
||||
### 10.1. Выбор способа отрисовки: текст против спрайт-атласов
|
||||
|
||||
Ограничение платформы: стандартный текстовый вывод libbgi (`outtextxy`)
|
||||
не годится — он тянет системный знакогенератор в `_gfx_font_buf` (2 КБ
|
||||
статики в W2) плюс жирный резидентный код, а W1/W2 забиты игрой
|
||||
(тот же вывод зафиксирован комментарием в `sprpop_cold.c`, где отладочный
|
||||
борд рисуется палочками именно поэтому). Значит, любой вариант требует
|
||||
СВОЕЙ реализации вывода меню, живущей в отдельном банке (память на банк
|
||||
есть; скорость не критична — меню работает на паузе).
|
||||
|
||||
Рассматривались два подхода.
|
||||
|
||||
**Вариант A — текстовые строки + собственный растровый рендерер.**
|
||||
|
||||
Плюсы:
|
||||
- минимальные данные: шрифт 2–4 КБ + таблицы строк по сотни байт на язык;
|
||||
- весь динамический текст бесплатно: значения опций (ON/OFF,
|
||||
NORMAL/FAST/FASTEST), сообщения (`QUICKSAVED`, `INCOMPATIBLE SAVE`),
|
||||
диалоги (`LOAD BACKUP?`), экран Controls, будущий ввод инициалов
|
||||
Hall of Fame — без текстового движка HoF вообще не сделать;
|
||||
- правка формулировки = правка C-строки, мгновенные итерации;
|
||||
- локализация = вторая таблица строк (+ вторая половина глифов);
|
||||
- **решающий аргумент: так сделано в самом SDLPoP** — см. §10.2.
|
||||
|
||||
Минусы:
|
||||
- надо написать рендерер (блиттер глифа + строка + центрирование +
|
||||
подсветка) — небольшой, но свой;
|
||||
- вид определяется качеством шрифта-ассета.
|
||||
|
||||
**Вариант B — готовые спрайт-атласы** (атлас главного меню с активными/
|
||||
неактивными пунктами, атлас вложенного меню, атлас каждой опции
|
||||
On/Off и т.д.).
|
||||
|
||||
Плюсы:
|
||||
- аутентичный вид: любая типографика/декор запекаются при упаковке;
|
||||
- вывод = существующий блит атласов, текстовый движок не нужен;
|
||||
- язык = другой файл атласа с диска, ноль логики.
|
||||
|
||||
Минусы:
|
||||
- комбинаторика ассетов: 7 пунктов × состояния + вложенные меню + значения
|
||||
всех опций + все сообщения + все диалоги ≈ десятки КБ raw на язык до RLE;
|
||||
второй язык удваивает;
|
||||
- любая правка текста = перегенерация ассетов + перекладка ресурсов;
|
||||
- динамический текст (HoF initials) всё равно потребует шрифтового движка —
|
||||
получили бы ОБЕ системы сразу.
|
||||
|
||||
**Решение (2026-08-22): Вариант A**, шрифт — ассет. Спрайты остаются только
|
||||
для нетекстового декора (рамка/фон меню, маркер выделения — как arrowheads
|
||||
в SDLPoP). Титульный экран — полноэкранная картинка, тема `full_game_plan.md`.
|
||||
|
||||
### 10.2. Референс: как устроено меню в SDLPoP
|
||||
|
||||
`SDLPoP/src/menu.c` + текстовый движок `seg009` — источник структуры:
|
||||
|
||||
- **Текстовые строки + встроенный пропорциональный bitmap-шрифт**
|
||||
`hc_small_font_data[]` (menu.c:2488): символы 32..126, каждый глиф —
|
||||
монохромное изображение переменной ширины; `font_type`
|
||||
{first_char, last_char, space_between_chars, height_above_baseline, chtab}.
|
||||
Никаких per-item атласов, хотя SDL_ttf доступен.
|
||||
- Вывод — портированный движок оригинального DOS PoP (seg009):
|
||||
`draw_text_character` → `method_3_blit_mono(image, x, y, textblit,
|
||||
textcolor)`; `get_line_width` для центрирования; перенос по словам.
|
||||
Тем же движком рисуются in-game тексты и copy protection.
|
||||
- Пункты меню — data-driven C-структуры `{id, previous, next, required,
|
||||
char text[32]}` + таблицы `pause_menu_items[]` / `settings_menu_items[]`;
|
||||
`required` — указатель на флаг disabled, такие пункты пропускаются при
|
||||
навигации (prev/next пересчитываются).
|
||||
- Выделенный пункт = смена цвета текста (bright-white против обычного) +
|
||||
рамка-контур `draw_rect_contours(selection_box, lightgray)`; НЕ отдельный
|
||||
спрайт «активного пункта».
|
||||
- Фон меню — затемнение замороженного игрового кадра:
|
||||
`draw_rect_with_alpha(black, alpha=120)`, внизу просвечивает «GAME PAUSED».
|
||||
- Settings — декларативная таблица `setting_type` со стилями TOGGLE / NUMBER /
|
||||
TEXT_ONLY / KEY, геттером/сеттером/increase/decrease значения, строкой-
|
||||
explanation внизу экрана, скроллом длинных списков и фокусом «левая половина
|
||||
(список) / правая половина (значения)».
|
||||
- Диалоги — один общий `draw_confirmation_dialog(text)` + обработчик
|
||||
результата; диалог возвращает решение автомату меню.
|
||||
- Мини-спрайты только для декора значений (arrowheads up/down/left/right).
|
||||
- Навигация озвучена (menu tick), ввод клавиатура+мышь, hover по прямоугольникам.
|
||||
|
||||
### 10.3. Наша реализация
|
||||
|
||||
- Банк 9: код рендерера,
|
||||
шрифт, таблицы строк, автомат меню. Резидентно — только request-flag и
|
||||
вызов процесса на границе кадра (паттерн pop_qsave_io).
|
||||
- Рендерер повторяет минимальный контракт seg009: пропорциональные глифы,
|
||||
baseline, `draw_string` и центрирование по сумме advance. Блит идёт через
|
||||
W0-атлас, в `GFX_BANK_SPRITE`: `0xFF` в атласе пропускается, а UI временный
|
||||
и не портит теневую копию игрового фона. Перед каждым кадром UI `gfx_copy_page` переносит чистый
|
||||
shadow видимой страницы в скрытую, затем готовый кадр показывается только
|
||||
на следующем фронте. При выходе чистый фон тем же способом возвращается на
|
||||
обе страницы и восстанавливается исходная visible-страница. Поэтому
|
||||
перемещение выделения не показывает поэтапную перерисовку и не оставляет
|
||||
следов на back buffer.
|
||||
- Шрифт — АССЕТ из **оригинальных** `hc_small_font_data[]` и
|
||||
`hc_font_data[]` SDLPoP, не системный ZG и не TTF. Паковщик
|
||||
`toolchain/pop_extract_font.py` делает `FONT\\font.atl`: 95 ASCII-глифов
|
||||
малого и 95 крупного шрифта (7667 Б). Номер ленты вычисляется из ASCII,
|
||||
поэтому это один текстовый движок, а не атлас готовых надписей.
|
||||
- Двуязычность (eng/rus): строки храним в CP866 — латиница и кириллица одним
|
||||
байтовым порядком, одна кодировка на оба алфавита. Локаль = пара
|
||||
(указатель на таблицу строк, файл шрифта); переключатель — одна настройка.
|
||||
Русские строки длиннее английских ~10–15% — раскладку экранов и ширину
|
||||
колонок закладывать по русской. Второй язык можно добавить позже без
|
||||
переделки: сначала eng.
|
||||
- Визуальная композиция MS4 следует SDLPoP: замороженная сцена остаётся
|
||||
открытой, поверх неё компактный центрированный список без чёрной карточки,
|
||||
выбранная строка обведена тонким светло-серым контуром, а крупное
|
||||
`GAME PAUSED` лежит в нижнем борту. Цвета текста и контура берутся из
|
||||
стабильного диапазона палитры 0x37..0x3F.
|
||||
- Фон открытого меню: снимок текущей палитры, затемнение всех слотов кроме
|
||||
UI 0x37..0x3F и точное восстановление при выходе. Снимок хранится в
|
||||
свободном хвосте EMM-страницы шрифта, не в W2.
|
||||
- Навигация MS4: вверх/вниз, Enter/Esc, edge-triggered поверх `kbd_raw`.
|
||||
Left/right и menu tick добавляются вместе с настройками на MS5.
|
||||
|
||||
Первый UI может быть визуально простым. Критично отсутствие потери клавиш,
|
||||
предсказуемая пауза и отсутствие повреждения игрового back buffer.
|
||||
|
||||
### 10.4. Затенение экрана под меню — решение MS4
|
||||
|
||||
Режим меню виден сразу: bank 9 делает динамический снимок palette 0,
|
||||
затемняет RGB-каналы вдвое и пишет одинаковый результат в обе экранные
|
||||
палитры. Девять стабильных UI-слотов 0x37..0x3F не гасятся. При Resume/Enter
|
||||
палитра восстанавливается из EMM-снимка. Это выбранный вариант Б ниже;
|
||||
ступенчатый fade для роликов пока не нужен и остаётся отдельной будущей
|
||||
задачей, а не причиной раздувать MS4.
|
||||
|
||||
**Как сделано в SDLPoP** (`seg009.c`):
|
||||
|
||||
- Меню: `draw_rect_with_alpha(&screen_rect, color_0_black, pause_menu_alpha)`
|
||||
(menu.c:1364) — альфа-заливка чёрным поверх замороженного кадра средствами
|
||||
SDL; нижняя полоса рисуется с alpha=0, чтобы сквозь неё просвечивало
|
||||
«GAME PAUSED». Прямого аналога на Sprinter НЕТ (альфа-блендинг в железе
|
||||
отсутствует) — это SDL-специфика, переносить нечего.
|
||||
- Ролики/переходы: `fade_in_2/fade_out_2(rows)` (seg009.c:3947+, вызовы из
|
||||
seg000.c) — ПОШАГОВОЕ затухание ПАЛИТРЫ к чёрному и обратно: палитра
|
||||
копируется, каждая строка по 16 цветов гасится за несколько кадров
|
||||
(`which_rows` маской выбирает, какие строки участвуют: 0x800/0x1000/...).
|
||||
Вот этот механизм на Sprinter воспроизводим один в один.
|
||||
|
||||
Отсюда рабочая гипотеза: наш примитив = «снимок текущей палитры → ступенчатое
|
||||
приближение к затемнённой копии (кроме резервного блока для UI)», статично для
|
||||
меню и анимированно для роликов/переходов. Варианты:
|
||||
|
||||
**Вариант А — единая основная палитра (глобальный рефакторинг палитры).**
|
||||
|
||||
1. Собрать ВСЕ палитры игры (уровневые наборы `pal_env*`, kid.pal, палитра
|
||||
Тени и пр.) в одну общую 256-цветную; использовать её целиком всегда.
|
||||
Сейчас переиспользования цветов НЕТ — каждая загрузка ассетов перезаписывает
|
||||
слоты (см. pop_boot: kid.pal затирает тайловые цвета, приходится
|
||||
восстанавливать `pop_bg_pal_apply`/`pop_shadow_pal_apply`).
|
||||
2. Для затенения — затемнённая копия основной палитры, КРОМЕ зарезервированного
|
||||
блока из 16 цветов для самого меню (кандидат — стандартные 16 цветов VGA).
|
||||
3. Выход из меню — возврат к полной основной палитре.
|
||||
|
||||
Плюс: решает попутно существующую боль с перезаписью палитр при загрузках.
|
||||
Минус: большой разовый рефакторинг упаковщиков и всех загрузчиков атласов;
|
||||
нужен аудит, что все цвета всех уровней влезают в 256. **Против говорит
|
||||
план перевода камней подземелья на цвета VGA-версии PoP: там ряд уровней
|
||||
несёт ДРУГУЮ палитру, отличную от SDLPoP (VDUNGEON/VPALACE каскад,
|
||||
levels_plan.md), — единая палитра этому прямо противоречит.**
|
||||
|
||||
**Вариант Б — динамический снимок текущей палитры (сейчас выглядит
|
||||
предпочтительным).**
|
||||
|
||||
1. При открытии меню прочитать всю текущую палитру, сохранить.
|
||||
2. Записать затемнённую копию (кроме зарезервированного блока для меню).
|
||||
3. При выходе — восстановить сохранённую.
|
||||
|
||||
Плюс: локальная правка внутри меню, ничего в пайплайне ассетов не меняется;
|
||||
работает при любой текущей палитре автоматически — включая будущие
|
||||
уровне-специфичные палитры VGA-камней; тот же примитив ступенями даёт
|
||||
fade-out/fade-in для роликов и переходов между уровнями (как fade_*_2 в
|
||||
SDLPoP). Минус: чтение/запись 256 записей палитры при входе/выходе (раз на
|
||||
открытие — дёшево); затемнение «на глаз» может по-разному выглядеть на разных
|
||||
уровнях.
|
||||
|
||||
Резервный блок 16 цветов нужен в ОБОИХ вариантах; текущий диапазон 0x37..0x3F
|
||||
(стабильный, проверен) даёт 9 цветов — этого может не хватить на
|
||||
текст+подсветку+рамку, тогда резервировать отдельный блок.
|
||||
|
||||
**Следствие для архитектуры:** работа с цветом/палитрой должна собраться в
|
||||
ОДИН модуль (сейчас она разбросана: gfx_pal_* вызовы в boot, pop_bg_pal_apply,
|
||||
pop_shadow_pal_apply, вспышки урона в sprpop.c и т.д.). Модуль палитры —
|
||||
единственный владелец записи в палитру и предоставляет примитивы, которые
|
||||
понадобятся и меню, и роликам:
|
||||
|
||||
```text
|
||||
pal_snapshot()/pal_restore() — снимок/восстановление всей палитры
|
||||
pal_dim(step) / pal_undim(step) — ступени затемнения (кроме резервного блока)
|
||||
pal_fade_out(rows)/pal_fade_in(rows) — анимированное затухание по строкам
|
||||
(порт fade_out_2/fade_in_2, seg009)
|
||||
```
|
||||
|
||||
Меню уже использует snapshot+dim локально в bank 9. Когда появятся ролики,
|
||||
выделить из него общий palette/fade-модуль; вспышка урона сможет переехать
|
||||
туда же после отдельного аудита.
|
||||
|
||||
## 11. Диалоги
|
||||
|
||||
Общий диалог подтверждения:
|
||||
|
||||
```text
|
||||
QUIT GAME?
|
||||
RESTORE DEFAULTS?
|
||||
LOAD BACKUP?
|
||||
|
||||
YES / NO
|
||||
```
|
||||
|
||||
Диалог не выполняет действие напрямую: он возвращает решение автомату меню,
|
||||
который формирует команду приложению. Так UI не зависит от gameplay-модулей.
|
||||
По умолчанию выбран `NO`; Up/Down/Left/Right меняют ответ, Enter подтверждает,
|
||||
Esc отменяет. Реализованы все три вопроса: Quit, Restore defaults и backup
|
||||
QuickLoad. В Quit-dialog вопрос и `YES / [NO]` заключены в общую рамку;
|
||||
отдельная строка `Enter: Select Esc: Cancel` не выводится.
|
||||
|
||||
## 12. Будущий ENHANCED
|
||||
|
||||
Не реализуется сейчас, но дизайн обязан позволять:
|
||||
|
||||
- добавить второй профиль без смены всего UI;
|
||||
- хранить `enhancement_flags` в CFG;
|
||||
- отличать технические исправления порта (всегда включены) от изменений
|
||||
оригинальной механики;
|
||||
- провести аудит уже встроенных исправлений;
|
||||
- покрыть каждый переключаемый fix host/MAME тестом;
|
||||
- при необходимости добавить Advanced screen, не раздувая основной menu.
|
||||
|
||||
До этого момента нельзя рассыпать проверки `if (enhanced)` по горячему коду.
|
||||
Сначала составляется реестр и выбирается минимальная битовая модель.
|
||||
|
||||
## 13. Этапы реализации
|
||||
|
||||
| этап | результат | критерий приёмки |
|
||||
|---|---|---|
|
||||
| **MS0** ✓ | определить команды app/menu и структуру settings | UI возвращает команду главному циклу; прямых gameplay-вызовов нет |
|
||||
| **MS1** | проверить запись/rename/copy на HDD DSS | crash/power-loss сценарий не теряет обе копии save |
|
||||
| **MS2** ✓ | `POP.CFG`: defaults, load, validate, save | v1 codec, будущий хвост, checksum; повреждённый CFG даёт defaults |
|
||||
| **MS3** | QuickSave hotkeys + POP.SAV/BAK | полный критерий `quicksave_plan.md` |
|
||||
| **MS4** ✓ | текстовый рендерер + два шрифта (малый для пунктов, крупный для сообщений) + минимальный pause menu | SDLPoP fonts в одном W0-atlas, центрирование, dim/restore palette и tear-free page flip; все семь пунктов видимы, навигация и Resume/QuickSave работают в MAME |
|
||||
| **MS5** ✓ | General/Gameplay Settings | значения применяются сразу и после Back/Esc записываются в POP.CFG |
|
||||
| **MS6** ✓ | dialogs + backup recovery | подтверждения default-NO; QuickLoad спрашивает перед валидным POP.BAK |
|
||||
| **MS7** ✓ | Controls help | показаны движение, action, menu, save/load, звук, speed и conditional cheats; MAME проверил отдельные K/I и Shift+L/U и возврат Esc ровно на один уровень |
|
||||
| **MS8** ✓ | build info | CFG читается до первого показа; включаемый build screen получает ID и дату из Make/git |
|
||||
|
||||
QuickSave (`MS1/MS3`) можно реализовать раньше визуального menu: сначала
|
||||
F6/F9 и сообщения, затем подключить те же команды к пунктам UI.
|
||||
|
||||
## 14. Тесты
|
||||
|
||||
- Host: CFG round-trip, defaults, bad magic/version/size/checksum.
|
||||
- Host: меню navigation, disabled items, confirmations, команды приложению.
|
||||
- Host: рендерер строк — вывод глифов обеих локалей, центрирование,
|
||||
ширина строки для малого и крупного шрифта.
|
||||
- Host: SAV invalid -> BAK valid; оба invalid -> NO QUICKLOAD.
|
||||
- MAME: F6, изменение сцены, F9; затем рестарт программы и повторный F9.
|
||||
- MAME: прервать запись/испортить SAV — BAK остаётся загружаемым.
|
||||
- MAME: pause на бое/падении, Resume не меняет состояние и таймер; смена
|
||||
выбранного пункта не показывает промежуточный кадр и после закрытия не
|
||||
оставляет меню на второй странице.
|
||||
- MAME: Settings сохраняются после полного выхода и запуска с HDD.
|
||||
- MAME: включить Show Sprinter screen, перезапустить `SprPoP`, увидеть
|
||||
build ID/date до первого игрового кадра и пропустить экран Esc/Enter/Space.
|
||||
- Проверка лимита 8 DSS handles на каждом error path.
|
||||
- `make size-check`; menu/text строки не должны съесть резидентный бюджет.
|
||||
|
||||
## 15. Не входит в план
|
||||
|
||||
- Mods и выбор levelset;
|
||||
- уровень 15/copy protection;
|
||||
- несколько save slots;
|
||||
- replay/recording;
|
||||
- key rebinding;
|
||||
- SDL visual/controller options;
|
||||
- фактическая реализация ENHANCED и individual fix switches.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Слои отрисовки: как устроен оригинал и чего стоит порт
|
||||
|
||||
Разбор 2026-08-13, по `../SDLPoP/src/seg008.c`. Повод — семь дефектов
|
||||
падающих плит на уровне 13, из которых три оказались не багами кода, а
|
||||
следствием того, что у нас нет слоя, в котором объекты и куски тайлов
|
||||
сортируются между собой. Решение по этому документу ещё не принято.
|
||||
|
||||
---
|
||||
|
||||
## 1. Как это работает в оригинале
|
||||
|
||||
### 1.1 Три таблицы, а не «слои»
|
||||
|
||||
`draw_tables` (seg008:1373) рисует ровно в таком порядке:
|
||||
|
||||
```
|
||||
restore_peels();
|
||||
draw_wipes(0);
|
||||
draw_table(0); // BACKTABLE
|
||||
draw_table(3); // MIDTABLE
|
||||
draw_wipes(1);
|
||||
draw_table(1); // FORETABLE
|
||||
```
|
||||
|
||||
Это грубое разделение на три уровня глубины. Куда попадёт кусок тайла,
|
||||
решает переменная `ptr_add_table`, которую вызывающий переставляет перед
|
||||
`draw_tile*`: по умолчанию `add_backtable`, в оверлее кромки —
|
||||
`add_midtable` (`draw_other_overlay`, seg008:1499), а `add_foretable`
|
||||
вызывается явно и точечно.
|
||||
|
||||
### 1.2 Объекты живут НЕ в таблицах, а в objtable — и привязаны к ТАЙЛУ
|
||||
|
||||
Персонажи, падающие куски, мечи, брызги попадают в `objtable`, и у каждой
|
||||
записи есть поле `tilepos` — тайл, которому объект принадлежит.
|
||||
|
||||
Ключевое: объекты рисуются **не отдельным проходом после фона**, а ВНУТРИ
|
||||
обхода тайлов. В `redraw_needed_tile` (seg008:207) стоит:
|
||||
|
||||
```c
|
||||
if (tile_object_redraw[tilepos]) {
|
||||
if (tile_object_redraw[tilepos] == 0xFF)
|
||||
draw_objtable_items_at_tile(tilepos - 1);
|
||||
draw_objtable_items_at_tile(tilepos);
|
||||
tile_object_redraw[tilepos] = 0;
|
||||
}
|
||||
if (redraw_frames_fore[tilepos]) draw_tile_fore();
|
||||
```
|
||||
|
||||
То есть на каждом тайле: сначала его фоновые куски, потом объекты ЭТОГО
|
||||
тайла, потом его передние куски.
|
||||
|
||||
### 1.3 Порядок глубины складывается из ТРЁХ независимых механизмов
|
||||
|
||||
| механизм | что даёт |
|
||||
|---|---|
|
||||
| порядок обхода тайлов: ряды **2, 1, 0**, колонки 0..9 (seg008:129) | тайл, обойдённый позже, рисуется поверх |
|
||||
| сортировка объектов ВНУТРИ одного тайла (`sort_curr_objs`, seg008:1553) | кто из объектов одного тайла поверх кого |
|
||||
| три таблицы back/mid/fore | грубая глубина для кусков тайлов |
|
||||
|
||||
Сортировка внутри тайла (`compare_curr_objs`, seg008:1572) — пузырьком, и
|
||||
правил в ней три:
|
||||
|
||||
```
|
||||
объект типа 1 (ТЕНЬ) — всегда первым;
|
||||
оба объекта — падающие плиты (0x80): y1 < y2 → по УБЫВАНИЮ y;
|
||||
любая другая пара: y1 > y2 → по ВОЗРАСТАНИЮ y.
|
||||
```
|
||||
|
||||
Обратный порядок для пары плит — не описка: две плиты из `loose_fall` летят
|
||||
в 6 пикселях друг от друга, и верхняя обязана лечь поверх нижней.
|
||||
|
||||
### 1.4 Что из этого следует
|
||||
|
||||
**«Сортируемый midtable» — неточное имя.** Глобальной сортировки среднего
|
||||
слоя в оригинале нет. Есть привязка объекта к тайлу и сортировка внутри
|
||||
тайла; всё остальное решает порядок обхода. Это принципиально дешевле
|
||||
общей сортировки: объектов на один тайл обычно 1-2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Что делаем мы
|
||||
|
||||
Наш кадр — жёсткая последовательность проходов, без привязки объектов к
|
||||
тайлам:
|
||||
|
||||
```
|
||||
фон: pop_loose_tick (физика кусков) -> pop_process_trobs -> pop_redraw_needed
|
||||
-> редрой шва
|
||||
объекты: pop_loose_mob_draw (куски ПОД Kid, отсортированы по y между собой)
|
||||
pop_char_draw(OPP/KID) (порядок задаёт guard_over_kid)
|
||||
pop_loose_mob_draw_over (куски ПОВЕРХ Kid)
|
||||
перед: pop_fore_over_char — ТОЛЬКО в окне вокруг персонажа
|
||||
```
|
||||
|
||||
Отличия, из которых растут все три оставшихся дефекта:
|
||||
|
||||
1. **Объект не знает своего тайла.** Глубина «кусок против Кида» считается
|
||||
отдельной формулой (`pop_room.c`, поле `defer`), а «кусок против КУСКА
|
||||
ТАЙЛА» не считается вовсе — куски тайлов рисуются раньше всех объектов.
|
||||
2. **Передний слой считается только вокруг персонажа.** Это наша
|
||||
оптимизация (memory `pop_fore_layer_cost`: полный проход стоил 78 %
|
||||
кадра). Падающая плита в чужом углу комнаты передних частей тайлов
|
||||
поверх себя не получает — отсюда «плита перед колонной».
|
||||
3. **Оверлей кромки идёт после персонажей** и потому безусловно поверх
|
||||
всех, тогда как у оригинала он в midtable и сортируется.
|
||||
|
||||
---
|
||||
|
||||
## 3. Что затрагивает порт
|
||||
|
||||
| участок | объём правки |
|
||||
|---|---|
|
||||
| `pop_room.c` — куски | привязать к тайлу, убрать `defer`, убрать собственную сортировку |
|
||||
| `pop_cdraw.c` — персонажи | то же: объект вместо слота, привязка к тайлу |
|
||||
| `pop_bg.c` — `overlay_mid_tile`, `fore_only_tile`, `ceil_over_kid_tile` | вызов из обхода тайлов, а не из отдельного прохода |
|
||||
| `pop_redraw.c` — пометки | добавить «на этом тайле есть объект» (порт `tile_object_redraw`) |
|
||||
| `sprpop.c` — главный цикл | вместо трёх проходов один: обход тайлов с объектами внутри |
|
||||
| окно fore-клипа (`pop_t_fclip_*`) | смысл меняется: клип по тайлу, а не по персонажу |
|
||||
|
||||
Плюс новая структура objtable и её сортировка — но маленькая, на тайл.
|
||||
|
||||
---
|
||||
|
||||
## 4. Плюсы
|
||||
|
||||
* **Уходят разом** MOB-CLIP-RIGHT, MID-OVERLAY-LAYER и «плита перед
|
||||
колонной»: все три — следствие отсутствия привязки к тайлу, а не
|
||||
самостоятельные баги.
|
||||
* **Уходят подпорки.** Перерисовка соседнего тайла поверх куска,
|
||||
`defer`, ручная сортировка кусков, отдельный проход `draw_over` —
|
||||
всё это заменяется одним механизмом.
|
||||
* **Совпадение с оригиналом по построению.** Дальше любой вопрос «что
|
||||
поверх чего» решается чтением seg008, а не экспериментом в MAME.
|
||||
* **Возможный выигрыш по кадру.** Сейчас fore-проход считает окно вокруг
|
||||
персонажа и всё равно перебирает до девяти тайлов; при привязке к тайлу
|
||||
передние части рисуются только там, где реально есть помеченный объект.
|
||||
Но это НАДО ЗАМЕРИТЬ, а не обещать.
|
||||
|
||||
---
|
||||
|
||||
## 5. Минусы и риски
|
||||
|
||||
* **Риск регресса широкий.** Трогается порядок отрисовки ВСЕГО: Кид,
|
||||
соперник, меч, брызги, зеркало, куски, оверлеи, полоса потолка.
|
||||
Уровни 1-11 приняты и держатся на текущем порядке.
|
||||
* **Наша оптимизация fore-окна может не пережить порт в прежнем виде.**
|
||||
Она даёт 3.2x на самом дорогом проходе (memory `pop_fore_layer_cost`).
|
||||
Если привязка к тайлу заставит рисовать передние части шире — можно
|
||||
потерять больше, чем выиграть.
|
||||
* **Дабл-буфер.** У оригинала один экран с dirty-rect, у нас две страницы
|
||||
со своими копиями фона и heal. Пометка «на тайле есть объект» обязана
|
||||
быть счётчиком страниц, как остальные наши пометки, — иначе объект
|
||||
перерисуется на одной странице и не перерисуется на другой.
|
||||
* **Банки.** Отрисовка размазана по трём банкам (`pop_bg` 2, `pop_cdraw` 4,
|
||||
`pop_room` 7) плюс резидент. Единый обход тайлов с объектами внутри
|
||||
означает, что цикл обхода зовёт код из всех трёх — надо проверить, что не
|
||||
упрёмся в границы банков и трамплины.
|
||||
* **Объём.** Это не правка, а этап: сопоставимо с тем, что делалось для
|
||||
fore-слоя.
|
||||
|
||||
---
|
||||
|
||||
## 6. Развилки
|
||||
|
||||
**A. Полный порт** — objtable с привязкой к тайлу, сортировка внутри тайла,
|
||||
объекты внутри обхода. Максимально близко к оригиналу, максимальный риск и
|
||||
объём.
|
||||
|
||||
**B. Частичный: только привязать КУСКИ к тайлам.** Персонажей оставить как
|
||||
есть. Закрывает MOB-CLIP-RIGHT и «плиту перед колонной», не трогает
|
||||
проверенный порядок персонажей. Дешевле и безопаснее; MID-OVERLAY-LAYER
|
||||
остаётся.
|
||||
|
||||
**C. Отложить** до этапа BG-ONCE и делать вместе — там всё равно
|
||||
пересматриваются слои, и два пересмотра подряд дороже одного.
|
||||
|
||||
Рекомендация: **B или C**. Вариант A целиком оправдан только если мы
|
||||
одновременно берёмся за BG-ONCE — тогда это один пересмотр слоёв вместо
|
||||
двух, и замер кадра делается один раз.
|
||||
|
||||
---
|
||||
|
||||
## 7. ЗАМЕРЫ (сделаны 2026-08-13, уровень 13)
|
||||
|
||||
Метод — маркеры-пустышки в РЕЗИДЕНТЕ вокруг измеряемого вызова плюс
|
||||
брейкпоинты с `printf totalcycles` (memory `z80_profiling_method`). В
|
||||
банковый код брейкпоинт ставить нельзя: 0xC000 — общее окно всех банков.
|
||||
|
||||
| что | такты | доля логического кадра |
|
||||
|---|---|---|
|
||||
| логический кадр целиком | **1 289 526** | 100 % |
|
||||
| `pop_redraw_needed` | **978** | 0,08 % |
|
||||
| **fore-проход, ОДИН персонаж** | **128 778** | **10 %** |
|
||||
|
||||
Плюс два счётчика за прогон ~229 логических кадров:
|
||||
|
||||
* fore-проход вызван **10 раз** — на 96 % кадров он не выполняется вовсе
|
||||
(пропуск неизменившегося персонажа, `pop_char_skip_mask`). Средняя цена
|
||||
по кадру выходит ~0,4 %, но КАК ТОЛЬКО персонаж движется — платим все 10 %
|
||||
каждый кадр, и при двух персонажах это ~20 %;
|
||||
* **максимум объектов на одном тайле = 2** (комната 16, пара из
|
||||
`loose_fall`: плита сбивает плиту, дальше летят обе). В комнате 23, где
|
||||
гряда падает в пустоту, максимум 1.
|
||||
|
||||
### 7.1 Что эти числа меняют в оценке
|
||||
|
||||
**Сортировка внутри тайла — бесплатна.** Два объекта, пузырёк на два
|
||||
элемента. Возражение против варианта A, которое закладывалось в §5, снято.
|
||||
|
||||
**`pop_redraw_needed` можно не считать вовсе.** 978 тактов против 128 778 у
|
||||
fore-прохода — соотношение 131 к 1.
|
||||
|
||||
**Единственный настоящий риск порта — окно клипа fore-прохода.** Если
|
||||
привязка объектов к тайлам заставит рисовать передние части шире нынешнего
|
||||
окна вокруг персонажа, мы потеряем 10 % кадра, и потеряем их НА ДВИЖЕНИИ,
|
||||
когда бюджет и так самый напряжённый.
|
||||
|
||||
### 7.2 Насколько узко оригинал помечает передний слой — ВЫЯСНЕНО
|
||||
|
||||
Пометки `redraw_frames_fore[]` ставит ровно одна функция — `set_redraw_fore`
|
||||
(seg007:0550), и зовут её из трёх мест. Ни в одном нет «пометить всё».
|
||||
|
||||
**Персонаж — `redraw_at_char` (seg003:0576).** Помечается ПРЯМОУГОЛЬНИК
|
||||
футпринта:
|
||||
|
||||
```c
|
||||
for (tile_row = x_top_row; tile_row <= char_bottom_row; ++tile_row)
|
||||
for (tile_col = x_col_left; tile_col <= x_col_right; ++tile_col)
|
||||
set_redraw_fore(get_tilepos(tile_col, tile_row), 1);
|
||||
```
|
||||
|
||||
с двумя уточнениями: при вынутом мече прямоугольник расширяется на колонку в
|
||||
сторону клинка, а для КИДА берётся объединение с футпринтом ПРОШЛОГО кадра
|
||||
(`prev_char_*`) — чтобы освободившиеся тайлы тоже вернули свои передние
|
||||
части.
|
||||
|
||||
**Падающий кусок — `draw_mob` (seg007:~1147).** Каждый кадр помечается
|
||||
СОСЕД СПРАВА (`++tile_col`), и второй тайл, если кусок висит на границе
|
||||
рядов:
|
||||
|
||||
```c
|
||||
++tile_col;
|
||||
tilepos = get_tilepos(tile_col, tile_row);
|
||||
set_redraw2(tilepos, 1);
|
||||
set_redraw_fore(tilepos, 1);
|
||||
top_row = y_to_row_mod4(ypos - 18);
|
||||
if (top_row != tile_row) { ... то же для top_row ... }
|
||||
add_mob_to_objtable(ypos);
|
||||
```
|
||||
|
||||
**Анимация тайла — `draw_trob` (seg007:01E6):** один тайл.
|
||||
|
||||
**Вывод: пометка переднего слоя в оригинале НЕ ШИРЕ нашего окна.** Она
|
||||
пообъектная — футпринт персонажа и 1-2 тайла на кусок. Значит полный порт
|
||||
objtable **не отнимает** нашу оптимизацию fore-окна, а формализует её:
|
||||
вместо «окно вокруг персонажа» будет «тайлы, помеченные объектами», что
|
||||
как минимум не шире, а для одиночного куска заметно уже.
|
||||
|
||||
Риск, вокруг которого крутилась вся оценка, снят.
|
||||
|
||||
### 7.3 Побочный результат: готовый рецепт для MOB-CLIP-RIGHT
|
||||
|
||||
`draw_mob` даёт точный ответ на вопрос, как оригинал прячет правую часть
|
||||
куска за соседним полом: он НЕ рисует сосед поверх куска (наша подпорка) и
|
||||
НЕ полагается только на `clip.right`. Он помечает соседний тайл СРАЗУ
|
||||
двумя пометками — `set_redraw2` (фон) и `set_redraw_fore` (передний слой).
|
||||
Дальше порядок делает всё сам: фон соседа рисуется ДО куска, его передние
|
||||
части — ПОСЛЕ.
|
||||
|
||||
Это же закрывает и «плиту перед колонной»: передние части соседнего тайла
|
||||
(колонна) ложатся поверх куска, потому что тайл помечен.
|
||||
|
||||
**Рекомендация после разбора: вариант A (полный порт).** Оба возражения
|
||||
против него сняты замерами и этим разбором — сортировка внутри тайла
|
||||
бесплатна (максимум 2 объекта), окно переднего слоя не теряется.
|
||||
@@ -0,0 +1,297 @@
|
||||
# План: консолидация работы с палитрами + переход уровня через fade
|
||||
|
||||
Статус: **этапы A и B реализованы; визуальная приёмка полного маршрута ещё
|
||||
идёт** (2026-08-24). Палитры выделены в bank 10, а renderer cutscene/intro —
|
||||
в bank 11, чтобы не переполнять bank 9 оболочки.
|
||||
Обсуждение велось вокруг `SprPoP/` (банк 9 — оболочка, fade из `pop_ui.c`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Текущее состояние: карта палитры
|
||||
|
||||
Палитра Sprinter — 256 записей по 4 байта (B, G, R, 0) = 1 КБ на страницу.
|
||||
У страниц дабл-буфера ДВЕ раздельные палитры (`gfx_pal_load(0,…)` и
|
||||
`gfx_pal_load(1,…)` — почти всегда парой). BIOS читает буферы только из
|
||||
#4000–#BFFF: банковую rodata напрямую отдавать нельзя (копия в стек/W2),
|
||||
см. грабли `pop_guard_set_palette` и `bg_load_tile_pal`.
|
||||
|
||||
### 1.1 Игровая палитра `KID\kid.pal` — раскладка слотов
|
||||
|
||||
Собирается `toolchain/pop_pack_kid.py build_palette()`, грузится одним
|
||||
`gfx_pal_fload` (перезаписывает все 256 записей). Атласы запекались под эти
|
||||
индексы — менять раскладку нельзя без перепаковки ассетов.
|
||||
|
||||
| Слоты | Назначение | Источник | Динамика |
|
||||
|---|---|---|---|
|
||||
| 0x00 | Цвет фона + **вспышка молнии** (подмена записи 0, `flash_bg` ← do_flash/set_bg_attr SDLPoP) | — | меняется в игре |
|
||||
| 0x01–0x2F | Не закреплены (нули) | — | свободно |
|
||||
| 0x30–0x3F | VGA16 — базовые 16 цветов для mono-блитов: пламя факелов, пузырьки зелья (+12 красный «лечение», +10 зелёный, +9 синий), кровь чомпера (12), дворцовая кладка mono (+6) | `VGA16[]` | статично |
|
||||
| ↳ 0x37–0x3F | Поддиапазон **UI**: текст/рамка меню; единственное, что `keep_ui` не затемняет (`MENU_BORDER`=0x37) | — | — |
|
||||
| 0x40–0x4F | chtab_1 пламя/зелья (`POT_PAL_BASE`) | VDUNGEON res150.pal | статично |
|
||||
| 0x50–0x5F | **ENV фон тайлсета** (`POP_PAL_ENV`) | res200.pal набора | **меняется при смене тайлсета** |
|
||||
| 0x60–0x6F | **WALL тайлсета** (`POP_PAL_WALL`) | res360.pal набора | **меняется при смене тайлсета** |
|
||||
| 0x70–0x7F | Kid (`PAL_BASE`) | KID res400.pal | статично |
|
||||
| 0x80–0x8F | Меч chtab_0 (`SWORD_PAL_BASE`) | POT res700.pal | статично |
|
||||
| 0x90–0x9F | Страж chtab_5 (`GUARD_PAL_BASE`) | res10.bin guard_palettes | **меняется по КОМНАТАМ** |
|
||||
| 0xA0–0xAF | Тень (`POP_SHADOW_PAL_BASE`) | RGB-сетка pop_pack_shadow.py | статично |
|
||||
| 0xB0–0xFF | Свободны (5 слотов) | — | — |
|
||||
|
||||
Итого динамических зон три: запись 0 (молния), env+wall (тип здания),
|
||||
стражи (per-room). Всё остальное одинаково всю игру.
|
||||
|
||||
### 1.2 Полноэкранные палитры заставок
|
||||
|
||||
Каждая перезаписывает ВСЕ 256 записей:
|
||||
|
||||
| Файл | Где используется |
|
||||
|---|---|
|
||||
| `KID\kid.pal` (+ fallback `a:\kid.pal`) | BOOT и возврат в игру после заставок |
|
||||
| `TITLE\title.pal` | экран TITLE |
|
||||
| `PV\story.pal` | INTRO и HALL_OF_FAME (одна палитра на обе фазы) |
|
||||
|
||||
### 1.3 Тайлсеты: подземелье ↔ дворец
|
||||
|
||||
Оба набора используют ОДНИ И ТЕ ЖЕ слоты 0x50–0x5F/0x60–0x6F, заполняя их
|
||||
разными цветами (атласы обоих наборов запекались под эти индексы).
|
||||
Переключение = загрузка 64 байт (32 записи env+wall) в обе страницы
|
||||
(`bg_load_tile_pal`); остальные 224 записи не трогаются.
|
||||
|
||||
Какие уровни дворец — `tbl_level_type` (`pop_level_cold.c:44`):
|
||||
**4, 5, 6, 10, 11, 14**; остальные подземелье.
|
||||
|
||||
Палитра дворца `pal_tile.pal` (расшифровка, формат записи B,G,R):
|
||||
|
||||
ENV 0x50–0x5F (пол, ковры, факелы, ворота, пики, арки):
|
||||
|
||||
| Слот | RGB | | Слот | RGB |
|
||||
|---|---|---|---|---|
|
||||
| 50 | 0,0,0 чёрный | | 58 | 202,190,178 серо-бежевый |
|
||||
| 51 | 121,89,60 коричневый | | 59 | 153,133,129 серо-лиловый |
|
||||
| 52 | 161,121,76 светло-коричневый | | 5A | 76,64,56 тёмный серо-бурый |
|
||||
| 53 | 194,149,89 песочный | | 5B | 153,97,89 кирпично-красный |
|
||||
| 54 | 230,178,113 яркий песок | | 5C | 137,80,72 тёмный кирпич |
|
||||
| 55 | 246,202,125 кремовый | | 5D | 48,125,125 бирюзовый |
|
||||
| 56 | 255,234,170 бледно-кремовый | | 5E | 12,56,89 тёмно-синий |
|
||||
| 57 | 255,255,255 белый | | 5F | 202,56,28 красно-оранжевый |
|
||||
|
||||
WALL 0x60–0x6F (вся палитра песочная): 61=(218,170,89), 62=(226,165,93),
|
||||
63=(226,170,97), 64=(218,161,85), 65=белый, 66=(226,165,93), 67=(218,165,89),
|
||||
68=(226,170,89), 69=(218,170,97), 6A=(255,210,137), 6B=(255,218,149),
|
||||
6C=(255,210,137), 6D=(255,218,145), 6E=(194,153,80 тёмный песок),
|
||||
6F=(238,186,117).
|
||||
|
||||
Чем рисуется во дворце:
|
||||
- **Тело стены — НЕ спрайты**, а сплошные заливки; цвет разыгрывается на
|
||||
комнату prandom'ом (`gen_palace_wall_colors`, `pop_bg.c:140`, порт
|
||||
seg000:1942): подряды 1 и 3 берут случайный из 0x61–0x64, подряды 0 и 2 —
|
||||
из 0x66–0x69; соседи по горизонтали не повторяются.
|
||||
- Декор стен id 3–17 — mono-силуэт цветом VGA16+6 (0x36).
|
||||
- Верх дверных проёмов дворца — спец-id 78–84 + полоса 145 («полоса под
|
||||
окнами», pop_room.c:478).
|
||||
- Остальное (пол, ковры, порталы-факелы, ворота, пики) — env-куски
|
||||
pal_env*.atl с ENV-таблицей выше.
|
||||
|
||||
### 1.4 Стражи (0x90–0x9F)
|
||||
|
||||
Цвет задаётся на КОМНАТУ (`level.guards_color[room-1]`), при входе в
|
||||
комнату зовётся `pop_guard_set_palette(color)` ДО отрисовки (слоты общие
|
||||
на экран — смена посреди кадра дала бы стража в новой палитре с полосой HP
|
||||
в старой). Только для обычных стражей (`tbl_guard_type == 0`): скелет и
|
||||
Джафар имеют собственную палитру, зашитую в kid.pal; им зовётся с color=0
|
||||
(не трогать — иначе Джафар на ур.13 покрасился бы в цвет стража своей
|
||||
комнаты). Внутри одного уровня слоты могут перезаписываться многократно.
|
||||
|
||||
## 2. Текущее состояние: механика fade
|
||||
|
||||
### 2.1 Наша реализация (`pop_ui.c`, банк 9)
|
||||
|
||||
- `pop_ui_palette_snapshot()` — снимок всех 256 записей через
|
||||
`gfx_pal_get` по 4 чанкам × 64; хранится в хвосте страницы шрифта
|
||||
FONT.ATL ([0x3C00,0x4000)), map/unmap W0. Требует `font_ready`.
|
||||
- `pop_ui_palette_dim(step, keep_ui)` — готовит ОБЕ экранные палитры из
|
||||
снимка. Шкала без умножений (только сдвиги):
|
||||
|
||||
| Шаг | Формула на канал | Яркость |
|
||||
|---|---|---|
|
||||
| 0 | x | оригинал |
|
||||
| 1 | `(x>>1)+(x>>2)` | ≈3/4 |
|
||||
| 2 | `x>>1` | 1/2 |
|
||||
| 3 | `x>>2` | 1/4 |
|
||||
| 4 | 0 | чёрный |
|
||||
|
||||
`keep_ui` пропускает 0x37–0x3F (меню остаётся ярким).
|
||||
- `pop_ui_fade_out/in(steps)` — проигрывание ступеней за `steps` кадров
|
||||
vsync (`step = i*4/steps`, целочисленно): steps=4 — канонический (по кадру
|
||||
на ступень), steps<4 — перескакивает ступени, steps>4 — повторяет (плавнее),
|
||||
steps=0 у fade_in — мгновенный restore.
|
||||
- Контракт map/unmap: обращения к EMM/W0 и BIOS-палитре строго после unmap.
|
||||
|
||||
Стоимость одного dim ≈ 15–25 тыс. тактов (~4–7 мс при 3.5 МГц) —
|
||||
укладывается в кадр vsync, на практике лагов нет.
|
||||
|
||||
### 2.2 Как сделано в SDLPoP (seg009.c, USE_FADE/gmMcgaVga)
|
||||
|
||||
- fade_out: каждый кадр КАЖДЫЙ ненулевой канал каждой записи −1; до нуля.
|
||||
- fade_in: `fade_pos` от 0x40 вниз; канал +1, пока меньше оригинала.
|
||||
- Уровней затемнения до 63–64 (VGA-канал 6 бит), полный фейд ~63 кадра ×
|
||||
wait_time=2 тика — медленно и кинематографично.
|
||||
- `which_rows` — битовая маска групп по 16 записей: можно фейдить часть
|
||||
палитры (в оригинале используется).
|
||||
- По завершении принудительно восстанавливается оригинал; после out экран
|
||||
заливается чёрным.
|
||||
|
||||
Это осознанное расхождение (скорость/такты vs плавность) — ЗАПИСАТЬ в
|
||||
`docs/impl_diff.md` (сейчас записи нет).
|
||||
|
||||
## 3. Зафиксированные решения
|
||||
|
||||
1. **Ступени затемнения: остаются 4.** Вариант 8 ступеней той же сдвиговой
|
||||
техникой — рассмотреть отдельно, сейчас не внедрять.
|
||||
2. **Предрасчёт fade-вариантов палитры отклонён.** Аргументы: чтение файла
|
||||
с диска на порядок дороже вычисления; 3–7 КБ постоянной RAM при
|
||||
MEMORY=small непозволительны; предрасчёт привязан к конкретным палитрам,
|
||||
а снимок работает с любой текущей автоматически; keep_ui удвоил бы набор.
|
||||
3. **Считать на лету**, хранить один снимок (уже есть, бесплатно в хвосте
|
||||
страницы шрифта).
|
||||
4. **Буферы на стеке**, не статика (W1/W2 мало) и не 1 КБ: обнулить 64/256
|
||||
байт дешевле, чем держать килобайт резидентно.
|
||||
5. **Контракт `gfx_pal_load(pal, start, count, data)`**: count — число
|
||||
СЛОТОВ, буфер обязан быть `count*4` байт; count=0 означает «все 256».
|
||||
6. **Leaf-applеры остаются на месте** (`pop_bg_pal_apply` — банк 7 со своими
|
||||
таблицами, `pop_shadow_pal_apply`, `pop_guard_set_palette`): банковая
|
||||
rodata чужого банка не видна, перенос сломал бы доступ к данным.
|
||||
7. **Молния (`flash_bg` в sprpop.c) не переносится** — игровой эффект
|
||||
записи 0; после вспышки восстановление записи 0 из снимка ложится на API.
|
||||
8. Модель состояния: разделены «какая палитра логически загружена» (load_*)
|
||||
и «с какой яркостью показана» (apply/fade). Любой load_* обновляет снимок;
|
||||
apply/fade показывает его с нужной глубиной. Это позволяет грузить новую
|
||||
палитру «в темноте» (экран остаётся чёрным, пока не позвали apply/fade_in).
|
||||
|
||||
## 4. Целевой API `pop_pal.c/.h` (банк 9)
|
||||
|
||||
```c
|
||||
/* сброс */
|
||||
void pop_pal_black(void) __banked;
|
||||
/* все 256 записей ОБЕИХ страниц = 0. Стековый buf[256], обнуление циклом,
|
||||
* 8 вызовов gfx_pal_load (4 чанка × 2 страницы, паттерн как в dim).
|
||||
* Зовётся СРАЗУ ПОСЛЕ initgraph в pop_boot (раньше нельзя — нет гарантий
|
||||
* состояния графического режима): закрывает кейс «мусор/палитра предыдущей
|
||||
* программы при включении графики». СНИМОК НЕ ТРОГАЕТ (контракт:
|
||||
* чёрный экран без изменения логической палитры). */
|
||||
|
||||
/* загрузка (пишет полную палитру в обе страницы + refresh снимка;
|
||||
* видимую яркость НЕ трогают — экран меняется только по apply/fade) */
|
||||
void pop_pal_file_load(const char *name) __banked;
|
||||
/* gfx_pal_fload + fallback "a:\" + gfx_pal_sync (fallback сегодня
|
||||
* скопирован в каждом из ~6 мест вызова) */
|
||||
|
||||
void pop_pal_game_load(void) __banked;
|
||||
/* file_load("KID\kid.pal") + pop_bg_pal_apply + pop_shadow_pal_apply.
|
||||
* Сегодня тройка скопирована 3 раза (sprpop_cold ~958, pop_title ~88,
|
||||
* pop_intro ~183). Единое место инварианта «kid.pal затирает слоты
|
||||
* тайлсета 0x50..0x6F и тени 0xA0..0xAF». */
|
||||
|
||||
void pop_pal_level_load(uint8_t full) __banked;
|
||||
/* палитра уровня: kid.pal/shadow + tileset 0x50..0x6F если набор сменился
|
||||
* (сравнение через pop_level_type()). full=1 — ПРИНУДИТЕЛЬНО перечитать
|
||||
* kid.pal/shadow (один экспорт с флагом, не две функции — меньше банковых
|
||||
* точек входа). СТРАЖЕЙ (0x90..0x9F) НЕ включает: это компетенция входа
|
||||
* в комнату (pop_guard_set_palette до первого draw). */
|
||||
|
||||
void pop_pal_story_load(void) __banked; /* PV\story.pal (INTRO и HOF — файл один, функция одна) */
|
||||
void pop_pal_title_load(void) __banked; /* TITLE\title.pal */
|
||||
|
||||
/* отображение */
|
||||
void pop_pal_snapshot(void) __banked; /* переезд из pop_ui, тело то же */
|
||||
void pop_pal_apply(uint8_t fade) __banked; /* = dim(fade, 0), 0..4 */
|
||||
void pop_pal_fade_in(uint8_t steps) __banked; /* переезд из pop_ui */
|
||||
void pop_pal_fade_out(uint8_t steps) __banked;
|
||||
|
||||
/* меню продолжает звать низкоуровневый dim(step, keep_ui=1) — отдельный
|
||||
* тонкий экспорт, чтобы не тащить флаг в горячий apply. Старые имена
|
||||
* pop_ui_palette_* / pop_ui_fade_* УДАЛЯЮТСЯ (без алиасов — меньше
|
||||
* экспорта банка). */
|
||||
```
|
||||
|
||||
Соответствие старое→новое: snapshot→snapshot, restore→apply(0),
|
||||
fade_out/in→fade_out/in, тройка kid.pal×3→game_load, fload+fallback+sync×6→file_load.
|
||||
|
||||
## 5. Этап A: рефакторинг — выполнен (2026-08-24)
|
||||
|
||||
1. Создан `src/pop_pal.c/.h` в **bank 10**, добавлен в Makefile.
|
||||
Он владеет политикой `load logical palette → snapshot → apply brightness`.
|
||||
Низкоуровневые snapshot/dim/fade остаются физически в `pop_ui.c`: там
|
||||
владелец страницы FONT.ATL, где лежит снимок; наружу они доступны только
|
||||
через `pop_pal`.
|
||||
2. Заменены call-sites:
|
||||
- `sprpop_cold.c` ~958: black → game_load вместо тройки;
|
||||
- `pop_title.c` title_restore_game_palette → game_load; загрузка title.pal → title_load;
|
||||
- `pop_intro.c` intro_load/intro_restore → story_load/game_load;
|
||||
- `pop_hof.c` (2 × story.pal) → story_load;
|
||||
- `pop_menu.c`: fade/dim → новые имена (dim с keep_ui — низкоуровневый экспорт);
|
||||
- `sprpop.c` demo-start (snapshot+dim(4,0)+fade_in(4)) → новый API.
|
||||
3. Старые вызовы не остаются в коде приложения; внутренние функции `pop_ui`
|
||||
сохранены как реализации одного владельца памяти снимка.
|
||||
4. Сборка и host-тесты пройдены. `make size-check` неприменим: меняется
|
||||
приложение, а не libc/libbgi.
|
||||
5. MAME smoke-тест полного цикла смен палитр: boot → title (title.pal +
|
||||
fade) → intro (story/kid) → demo fade-in → игра → HOF (story.pal).
|
||||
Проверить: отсутствие мусора при включении графики (эффект black),
|
||||
меню с keep_ui остаётся ярким при затемнении, молния (запись 0)
|
||||
восстанавливается.
|
||||
|
||||
## 6. Этап B: переход уровня через fade — реализован, ждёт визуальной приёмки
|
||||
|
||||
Сценарий (обсуждён, детали уточнить по SDLPoP перед реализацией — как
|
||||
оригинал делает смену уровня, есть ли там fade в DOS-версии):
|
||||
|
||||
```
|
||||
fade_out // последний кадр уровня N темнеет
|
||||
рисуем комнату 1 уровня N+1 // во ВТОРУЮ страницу, в темноте
|
||||
pop_pal_level_load(full=0) // новая палитра: железо+снимок обновлены,
|
||||
// экран всё ещё чёрный
|
||||
флип + копия второй страницы обратно в первую
|
||||
fade_in // = анимированный apply 3→2→1→0
|
||||
```
|
||||
|
||||
Экономия: реально переезжают только 32 записи (env/wall) при смене набора
|
||||
dungeon↔palace; guards_color обновит вход в комнату. Kid/shadow не меняются
|
||||
— потому full=0.
|
||||
|
||||
Реализация находится в `sprpop.c` / `sprpop_cold.c`: последний кадр
|
||||
уровня N темнеет, `pop_level_switch()` подготавливает первый кадр N+1 и
|
||||
обновляет логический источник через `pop_pal_level_load(1)`, затем главный
|
||||
цикл показывает кадр только через fade-in. Восемь ступеней и отдельная
|
||||
анимация смерти не входят в этот этап.
|
||||
|
||||
**Этап B закрывает два открытых бага** (разборы — `BUGS_OPEN.md`):
|
||||
- [PAL-L1-AFTER-INTRO] — вход в игру на уровень 1 после интро с чёрным
|
||||
экраном (маршрут demo_new_game; корень не установлен, воспроизведение
|
||||
нестабильно);
|
||||
- [PAL-DUNGEON-STALE] — переход 3→4 оставляет подземную палитру (корень
|
||||
ясен: fade_in восстанавливает из снимка, снятого ДО загрузки тайлсета
|
||||
дворца; быстрый фикс `fade_in_pending` 2026-08-23 сам же и проявляет этот
|
||||
дефект модели).
|
||||
|
||||
Быстрый фикс 2026-08-23 (маршрут CUTSCENE → LEVEL_LOAD → PLAYING,
|
||||
`fade_in_pending` + `pop_ui_fade_in(4)` после `pop_level_switch`) закрыл
|
||||
чёрный экран на переходах с pre-cutscene внутри подземелья (1→2), но модель
|
||||
«кто и когда меняет яркость» остаётся разношёрстной — её и приводит в
|
||||
порядок этап B.
|
||||
|
||||
## 7. Этап C: документирование
|
||||
|
||||
- Запись в `docs/impl_diff.md`: наши 4 ступени vs SDLPoP ~64 (что делает
|
||||
оригинал, что делаем мы — сдвиговая шкала ради тактов, чем платим —
|
||||
грубее градации, что проверять при регрессе).
|
||||
- После этапа B — дополнить запись про сам переход.
|
||||
|
||||
## 8. Не трогаем
|
||||
|
||||
- Молнию (`flash_bg`, sprpop.c) — включая обход SDCC-бага
|
||||
`gfx_pal_set(0,0,0,0,0)` → ручные `gfx_pal_set(0/1, 0, r,g,b)`;
|
||||
- leaf-applеры: `pop_bg_pal_apply` (банк 7), `pop_shadow_pal_apply`,
|
||||
`pop_guard_set_palette` (данные своих модулей);
|
||||
- хранилище снимка в хвосте страницы шрифта FONT.ATL (бесплатное место,
|
||||
guard `font_ready`);
|
||||
- раскладку слотов 0x00–0xAF (зафиксирована атласами).
|
||||
@@ -0,0 +1,335 @@
|
||||
# Оптимизация отрисовки — что НЕ сделано (замеры на 2026-08-10)
|
||||
|
||||
Список отложенных идей с измеренной ценой. Всё измерено брейкпоинтами в
|
||||
MAME (`z80_profiling_method`) на роомтесте, уровень 1 комната 1.
|
||||
|
||||
Прежде чем брать что-то отсюда — перечитать «Как мерить» ниже: половина
|
||||
прошлых гипотез не подтвердилась, и подтвердились не те, что казались
|
||||
очевидными.
|
||||
|
||||
## Как мерить (иначе цифры не сходятся)
|
||||
|
||||
- **Такт `totalcycles` ≠ номинальный T-такт Z80.** У ОЗУ Sprinter
|
||||
wait-state'ы, замеренная стоимость ≈ **2,4× справочной** (`get_tile`: 574
|
||||
против 1 422). Считать по таблице тактов нельзя. Подробности —
|
||||
memory `sprinter_wait_states_2x`.
|
||||
- **Растровый кадр = 430 000 тактов.** Главный цикл спейсится тремя
|
||||
`gfx_wait_vsync`, поэтому работа сверх 430 000 стоит СРАЗУ целый лишний
|
||||
кадр. Граница дискретная: 3 растровых кадра на логический или 4.
|
||||
- **Адреса символов меняются после КАЖДОЙ пересборки** (`sprpop.map`,
|
||||
`bank*_*.sym`). Маркер со старым адресом молча не срабатывает, и разбивка
|
||||
выглядит правдоподобно, но врёт.
|
||||
- **Сцена между сессиями не воспроизводится точно**: позиция Кида до
|
||||
пикселя, состояние плиты (2,6), фаза факелов. Сравнивать «до/после» можно
|
||||
только по ОДНОЙ функции с одинаковыми входами, а не по общей работе за
|
||||
кадр.
|
||||
- Кто делит: брейкпоинт на `__divsint`/`__divuint`/`__divuchar` с печатью
|
||||
адреса возврата — `bpset <addr>,1,{printf "ret=%04X\n",w@(sp); g}`. Если
|
||||
адрес возврата в банке (>= 0xC000), поставить тот же брейкпоинт с условием
|
||||
`w@(sp)==<адрес>` и БЕЗ `g`: машина встанет с нужным банком в окне, и
|
||||
`dasm` покажет вызывающего.
|
||||
- **Брейкпоинт по адресу в банке ловит ВСЕ банки.** 0xC000..0xFFFF — общее
|
||||
окно, и один и тот же адрес есть у семи модулей сразу. Либо ловить через
|
||||
трамплин (`hl==<адрес>&&(de&0xff)==<банк>`), либо перепроверять, что
|
||||
срабатывания идут из нужной фазы: иначе в интервал попадает чужой код и
|
||||
цифры врут (так я намерил несуществующие 134 730 тактов в прологе
|
||||
`pop_char_fore`).
|
||||
- Трасса вызовов графики с параметрами: скрипт в истории сессии, ставит
|
||||
маркеры фаз на трамплин `___sdcc_bcall_ehl` (условие `hl==<адрес>&&(de&0xff)==<банк>`)
|
||||
и брейкпоинты на листья libbgi с печатью аргументов
|
||||
(`__sdcccall(1)`: arg1 = HL, arg2 = DE, дальше стек с sp+2).
|
||||
|
||||
## Профиль на 2026-08-10
|
||||
|
||||
Сцена: комната 1, два факела, стража нет.
|
||||
|
||||
| сцена | работа за кадр | период |
|
||||
|---|---|---|
|
||||
| Кид в покое, факел не задет (пропуск работает) | 244 026 (57 %) | 3 кадра |
|
||||
| Кид стоит на факеле (перерисовывается каждый кадр) | 366 240 (85 %) | 3 кадра |
|
||||
| Кид в щебне (2,4), движется | ~357 000 (83 %) | 3 кадра |
|
||||
| Кид (0,5) в движении | 421 254 (98 %) | **4 кадра** |
|
||||
|
||||
Одна перерисовка персонажа = **~137 000 тактов = 32 % растрового кадра**,
|
||||
из них полезной работы (heal 20×19 + спрайт 12×41) — меньше трети.
|
||||
|
||||
## Профиль дворца (уровень 4) на 2026-08-10
|
||||
|
||||
Сцена: уровень 4, Кид НЕПОДВИЖНО стоит на (1,7) (комната с решёткой),
|
||||
стража нет. Разбивка одного логического кадра брейкпоинтами на границах
|
||||
фаз (адреса `PROF()` из `sprpop.lst` + базы `_CODE = 0x42AD`):
|
||||
|
||||
| фаза | тактов | доля работы |
|
||||
|---|---:|---:|
|
||||
| ввод + читы + heal | 59 340 | 11 % |
|
||||
| логика (kid_tick, phys_tick, страж, боёвка) | 85 356 | 16 % |
|
||||
| `pop_loose_tick` | 27 396 | 5 % |
|
||||
| `pop_process_trobs` | 106 107 | 21 % |
|
||||
| `pop_redraw_needed` | 447 | — |
|
||||
| шов / смена уровня / вспышка | 5 448 | 1 % |
|
||||
| `guard_over_kid` + `pop_char_skip_mask` | 4 788 | 1 % |
|
||||
| `pop_char_draw(KID)` | 52 206 | 10 % |
|
||||
| соперник + `loose_mob_draw_over` + `hp_draw` | 4 086 | 1 % |
|
||||
| **`pop_char_fore(KID)`** | **171 693** | **33 %** |
|
||||
| `pop_room_clip_borders` + прочее | 742 | — |
|
||||
| **ИТОГО работа** | **517 609** | 120 % растрового кадра → период **4 кадра** |
|
||||
|
||||
По цветам бордюра: синий (`PROF(2)`) 144 696 (28 %), зелёный (`PROF(4)`)
|
||||
139 398 (27 %), циан (`PROF(6)`) 233 515 (45 % работы = 54 % растрового
|
||||
кадра, начинается на 74 % первого кадра и кончается на 123 %).
|
||||
|
||||
## СДЕЛАНО 2026-08-10: метка «фон трогали» стала маской ТАЙЛОВ
|
||||
|
||||
Было: один union-прямоугольник на страницу. Три факела трогают по пятну
|
||||
16x18 в колонках 1, 6 и 8, а их объединение — полоса `x 40..280` на всю
|
||||
комнату; Кид, стоящий где угодно между крайними факелами, в неё попадал и
|
||||
перерисовывался каждый кадр со всем fore-проходом.
|
||||
|
||||
Стало: `uint16_t pop_cd_dmask[2][3]` — бит на колонку, слово на ряд, набор на
|
||||
страницу. Колонка берётся сдвигом (`x >> 5`), ряд — цепочкой сравнений;
|
||||
проверка в `cd_quiet` — три `AND` через резидентный `pop_cd_hit`.
|
||||
|
||||
Гранулярность тайла — это гранулярность ОРИГИНАЛА: пометки там тоже по
|
||||
тайлам (`redraw_frames_anim[tilepos]`, `set_wipe`), персонажи привязаны к
|
||||
тайлу через `tile_object_redraw[tilepos]`, а единственное подтайловое
|
||||
уточнение (`wipe_heights`) — по высоте, не по ширине. Полутайл (16 px) не дал
|
||||
бы ничего: пламя рисуется с отступом 8 px и шириной 16, то есть занимает
|
||||
середину тайла и задевает обе половины.
|
||||
|
||||
Замер на той же сцене, где снимался профиль ниже (Кид неподвижно на (1,7)):
|
||||
|
||||
| | было | стало |
|
||||
|---|---:|---:|
|
||||
| циан (спрайты + fore) | 233 515 | **24 781** |
|
||||
| работа за кадр | 517 609 | **306 553** |
|
||||
| период | 4 растровых кадра | **3** |
|
||||
|
||||
---
|
||||
|
||||
## СДЕЛАНО 2026-08-10: деление в луче видимости стража (Кид у шва)
|
||||
|
||||
`tile_at_kid` (`guards.c`) считала колонку честным `/` и `%`, тогда как везде
|
||||
уже стоит резидентная таблица `POP_TILE_DIV` (это и есть `tile_div_tbl`
|
||||
оригинала). У SDCC z80 это `__divsint` плюс `__modsint`, а тот внутри снова
|
||||
зовёт `__divsint` — ~5 400 тактов на вызов.
|
||||
|
||||
Зовут её В ЦИКЛЕ по колонкам между стражем и Кидом
|
||||
(`check_can_guard_see_kid`, seg003:761). Когда Кид стоит У ШВА, его
|
||||
`curr_col = −1`, луч тянется через всю комнату, и за кадр набегало ВОСЕМЬ пар
|
||||
делений — около 43 000 тактов, 10 % растрового кадра, в фазе ЛОГИКИ.
|
||||
|
||||
Замер: брейкпоинт на `__divsint` с печатью адреса возврата дал `ret=C033`
|
||||
восемь раз за кадр; остановка на нём и дизассемблирование с правильным банком
|
||||
показали `HL−65`, `ld de,#14`, `call __divsint` по смещению 0x24 банка 1 —
|
||||
`tile_at_kid`. После фикса пар `C033` не остаётся ни одной.
|
||||
|
||||
**Заодно снята ложная тревога.** В прошлом замере я записал, что на шве
|
||||
пролог `pop_char_fore` разбухает до 134 730 тактов. Это была ошибка зонда:
|
||||
брейкпоинт на `fore_tile` стоял по адресу, который совпадает с кодом ДРУГИХ
|
||||
банков, и в интервал попадали чужие срабатывания. Чистый замер (Кид в
|
||||
колонке 0, спрайт свисает за левый край, окно `x −8..5`): пролог **16 950** —
|
||||
ровно как в середине комнаты, весь fore-проход 41 803, `pop_room_clip_borders`
|
||||
14 364. Урок в «Как мерить» выше: адрес в банке нужно либо проверять на
|
||||
уникальность, либо ловить через трамплин с условием на банк.
|
||||
|
||||
---
|
||||
|
||||
## 0. Дворцовая кладка в fore-проходе — **СДЕЛАНО 2026-08-10**
|
||||
|
||||
Все три шага выполнены; замер после — в конце пункта. Ниже сохранён исходный
|
||||
разбор: он объясняет, почему предфильтра `fore_tile` мало и откуда взялись
|
||||
габариты кусков.
|
||||
|
||||
**Было: 139 863 такта за кадр (32 % растрового кадра), полезных пикселей —
|
||||
ровно ноль.**
|
||||
|
||||
Замер (уровень 4, Кид на (1,7)). Окно fore-клипа в этот момент —
|
||||
`x 229..241, y 106..147` (прочитано из `pop_t_fclip_*` брейкпоинтом).
|
||||
`pop_fore_over_char` обходит 6 тайлов:
|
||||
|
||||
| тайл | тактов |
|
||||
|---|---:|
|
||||
| (0,6) (0,7) (1,6) (1,7) | 2 800 – 4 600 каждый |
|
||||
| **(2,6) — стена** | **74 310** |
|
||||
| **(2,7) — стена** | **65 553** |
|
||||
|
||||
Ряд 2 этой комнаты — стена, и он лежит ПОД ногами Кида, то есть попадает в
|
||||
его fore-окно всегда. Внутри одного тайла стены `wall_pattern_palace`
|
||||
делает 6 × `wpp_fill` (≈ 3 100 каждый) + 5 × `pop_wall_b` (≈ 9 760 каждый)
|
||||
≈ 60 000 тактов.
|
||||
|
||||
Ни один кусок в окно не попадает:
|
||||
- верхняя заливка стоит на `dmy - 59 = 157`, окно кончается на `y = 147`;
|
||||
все остальные куски ещё ниже;
|
||||
- тайл (2,6) занимает `x 192..223`, окно начинается с `x = 229` — он
|
||||
промахивается и по горизонтали тоже.
|
||||
|
||||
Тайл всё равно проходит, потому что предфильтр в `fore_tile` (pop_bg.c,
|
||||
`x0 < fclip_x1 && x0 + 40 > fclip_x0 && y0 - 8 < fclip_y1 && y0 + 70 >
|
||||
fclip_y0`) намеренно грубый — габарит 40×78 на тайл. Дальше `wpp_fill`
|
||||
честно режет по окну и выходит с пустым прямоугольником, но 3 100 тактов на
|
||||
арифметику клипа уже потрачены, а `pop_wall_b` о существовании окна не знает
|
||||
вовсе: он идёт в `atlas_image` + `gfx_w0_map`, читает `w`/`h` и только там
|
||||
обнаруживает, что рисовать нечего.
|
||||
|
||||
Что делать (по возрастанию объёма):
|
||||
1. **Ранний выход из `wall_pattern_palace`**: самый верхний пиксель узора —
|
||||
`dmy - 59`, самый нижний — `dby + высота нижнего декаля`. Один
|
||||
`if (pop_t_fclip_on && (fclip_y1 <= dmy - 59 || fclip_y0 > dby + h))
|
||||
return;` плюс такая же проверка по `x` убивает оба тайла целиком почти
|
||||
даром.
|
||||
2. Прогнать `pop_wall_b` в этом узоре через ту же проверку окна, что уже
|
||||
есть у `wpp_fill` (нужны размеры кусков — см. п. 2 ниже, «размеры из
|
||||
каталога атласа»).
|
||||
3. Сузить сам предфильтр `fore_tile` до реального габарита узора вместо
|
||||
40×78 — тогда лечится не только дворец.
|
||||
|
||||
Порядок в подземелье тот же, но дешевле: `wall_pattern` в подземелье делает
|
||||
до 3 блитов и ни одной заливки (~29 000 на тайл против ~60 000). Это же
|
||||
объясняет, почему после перехода на дворцовый тайлсет период вырос.
|
||||
|
||||
Сверено с SDLPoP (`seg008.c:1943 wall_pattern`, ветка
|
||||
`!is_dungeon && GRAPHICS_VGA`): состав узора у нас дословный — 5
|
||||
`add_wipetable` + 4 декаля + нижняя заливка + нижний декаль. Расходимся не
|
||||
составом, а тем, что оригинал складывает всё в `foretable` и рисует одним
|
||||
`draw_table()`, у которого «посетить тайл» стоит копейки (см. п. 6).
|
||||
|
||||
### Что сделано и сколько дало
|
||||
|
||||
1. **Ранний выход** из `wall_pattern_palace` по окну fore-клипа (узор целиком
|
||||
в `x [xh*8, xh*8+32)`, `y [dmy−59, dby]`).
|
||||
2. **Отсев каждого декаля** (`wp_blit`) по реальному габариту вместо
|
||||
заведомо большего 64×64 в `pop_blit_b`. Размеры сняты из каталогов
|
||||
атласов: `pal_wall.atl` — группы 3..5 = 8×7, 6..8 и 9..11 = 32×12,
|
||||
12..14 = 30×5, 15..17 = 32×3.
|
||||
3. **То же для ПОДЗЕМЕЛЬЯ**: ранний выход `wall_pattern` (габарит там выше —
|
||||
левая марка уходит на `dby+POP_YOFF−67`) плюс `wp_blit` на RNDBLOCK
|
||||
(32×21), обоих разделителях (9×21) и обеих марках (`pop_wall.atl`:
|
||||
16/17 = 7×10, 14/15 = 14×5).
|
||||
|
||||
Замер: уровень 4, комната 18, Кид сдвигается читом `]` по пикселю (skip
|
||||
выключен, идёт полный путь), в fore-окне ШЕСТЬ тайлов, ТРИ из них —
|
||||
дворцовая стена.
|
||||
|
||||
| участок | тактов |
|
||||
|---|---:|
|
||||
| пролог `pop_char_fore` до первого `fore_tile` | 17 208 |
|
||||
| обычный тайл | 4 000 – 4 600 |
|
||||
| **тайл стены (было 60 000 – 74 000)** | **~12 700** |
|
||||
| хвост + чистка бортов | 6 055 |
|
||||
| **fore-проход целиком (было 171 693)** | **70 681** |
|
||||
|
||||
Кадр целиком в этой сцене: работа **419 839**, период **3** растровых кадра
|
||||
(было 517 609 и 4).
|
||||
|
||||
Остаток в проходе — пролог 17 208, это уже пункт 1 ниже (футпринт из физики).
|
||||
Отдельная находка: когда Кид стоит НА ШВЕ (окно `x −8..5`), пролог разбухает
|
||||
до **134 730** — 86 % прохода; причина не разобрана, см. пункт 1.
|
||||
|
||||
---
|
||||
|
||||
## 1. Футпринт персонажа — брать из физики, а не считать заново
|
||||
|
||||
**Цена: 11 574 такта на каждый fore-проход** (от входа в `char_footprint` до
|
||||
первого `fore_tile`).
|
||||
|
||||
`redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ `char_col_left/right`,
|
||||
`char_top_row`, `char_bottom_row` — их в этом же кадре посчитала физика
|
||||
(`set_char_collision`, seg006:0723). У нас `char_footprint` (pop_bg.c)
|
||||
считает их заново внутри fore-прохода.
|
||||
|
||||
Мешает то, что физика (банк 3) держит их в статиках, а слой фона — банк 2.
|
||||
Надо опубликовать их так же, как уже опубликованы `pop_cd[who].fpx/fpy/fpw/fph`.
|
||||
|
||||
**Заодно:** оригинал расширяет футпринт ТОЛЬКО на одну колонку при вынутом
|
||||
мече и объединяет с футпринтом ПРОШЛОГО кадра (`prev_char_col_left/right`).
|
||||
Мы вместо этого расширяем окном fore-клипа и посещаем 6 тайлов там, где
|
||||
оригинал посетил бы 4. Разница видна в замере: fore-проход стоит 58 764
|
||||
там, где реально рисует, и **112 758 там, где не рисует ничего** — вся
|
||||
разница в числе посещённых тайлов.
|
||||
|
||||
Осторожно: окно клипа заводилось под клинок и брызги (они уходят
|
||||
вперёд-вверх за габарит кадра). Менять — с прогоном боя и падений.
|
||||
|
||||
## 2. Размеры ленты — из каталога атласа, а не через окно 0
|
||||
|
||||
**Цена: ~750 тактов на `atlas_image` + часть из 6 396 на «чтение w/h и
|
||||
арифметика клипа», на КАЖДЫЙ блит фона.**
|
||||
|
||||
Сейчас `pop_blit_b`, чтобы узнать размер куска, зовёт `atlas_image` (тот
|
||||
мапит страницу в W3, читает запись каталога, возвращает W3 назад), потом
|
||||
`gfx_w0_map` и читает `w`/`h` из шапки ленты.
|
||||
|
||||
А размеры **уже лежат в каталоге**: запись 8 байт — `offset u16, fw u8,
|
||||
fh u8, nx u8, ny u8, резерв u16`, и у всех фоновых лент `nx = ny = 1`, то
|
||||
есть `fw`/`fh` в точности равны `w`/`h` из шапки (проверено по
|
||||
`pop_env0.atl`). `atlas_image` их читает и выбрасывает.
|
||||
|
||||
Вариант A (0 байт памяти): `atlas_image_wh()` рядом с `atlas_image` —
|
||||
вернуть заодно размер.
|
||||
Вариант B (без маппинга вовсе): снять каталоги при загрузке в резидентную
|
||||
таблицу. Объём: фон (env0-4 + wall + fore + pot) = **313 лент**, по 2 байта
|
||||
= **626 Б**; всё вместе с Кидом и стражем = 600 лент = 1200 Б. Свободной
|
||||
кучи на 2026-08-10 — 2873 Б.
|
||||
|
||||
Ожидаемый выигрыш скромный: ~2 000–3 000 из ~16 000 накладных на блит.
|
||||
|
||||
## 3. Один `gfx_w0_map`/`unmap` на группу блитов
|
||||
|
||||
**Цена: ~5 500 тактов на блит** (unmap плюс возвраты по цепочке
|
||||
`pop_pot_b` → `pop_blit_b` → трамплин).
|
||||
|
||||
Куски одного прохода часто лежат на одной странице атласа, а мапим и
|
||||
размапливаем на каждый. Мешает то, что `pop_blit_b` — общий лист для всех
|
||||
вызывающих; нужна форма «открыть страницу, N блитов, закрыть».
|
||||
|
||||
## 4. Единый проход по тайлам вместо трёх
|
||||
|
||||
У оригинала за кадр ОДИН обход тайлов — `redraw_needed_tiles` (seg008):
|
||||
контекст тайла (`curr_tile`, `curr_modifier`, `draw_xh`, `draw_main_y`)
|
||||
ставится по разу на тайл в `load_curr_and_left_tile`, а `redraw_needed`
|
||||
смотрит **семь** независимых счётчиков (`wipe_frames`, `redraw_frames_full`,
|
||||
`redraw_frames_anim`, `redraw_frames2`, `redraw_frames_floor_overlay`,
|
||||
`redraw_frames_fore`, `tile_object_redraw`) и делает только помеченное.
|
||||
|
||||
У нас **три** обхода: `pop_redraw_needed`, `pop_process_trobs` и
|
||||
`pop_fore_over_char`. Плюс один `kind` на тайл вместо семи счётчиков — две
|
||||
разные причины перерисовки одного тайла конфликтуют.
|
||||
|
||||
Это большой рефакторинг всего слоя фона; браться только если понадобится
|
||||
ещё заметный запас.
|
||||
|
||||
## 5. objtable: персонажи, привязанные к тайлу
|
||||
|
||||
Оригинал кладёт персонажей в `objtable` и рисует их в
|
||||
`draw_objtable_items_at_tile(tilepos)` во время обхода тайлов — порядок
|
||||
окклюзии получается сам. У нас отдельный fore-проход НА КАЖДОГО персонажа.
|
||||
Со вторым персонажем (страж) цена удваивается.
|
||||
|
||||
## 6. Отложенные таблицы back/mid/fore
|
||||
|
||||
`add_backtable`/`add_midtable`/`add_foretable` только КЛАДУТ запись в массив,
|
||||
рисование — один `draw_table()` в конце. Поэтому «посетить тайл» у
|
||||
оригинала стоит копейки. У нас блит идёт сразу из обхода.
|
||||
|
||||
## 7. Мелочи с известной ценой
|
||||
|
||||
| что | цена | где |
|
||||
|---|---|---|
|
||||
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки тайла над головой | 8 892 | `pop_cdraw.c` / `pop_map.c` |
|
||||
| `pop_loose_tick` при полном отсутствии падающих плит в комнате | 27 438 | `pop_map.c` |
|
||||
| `obj_x * 8 / 7` — единственное оставшееся `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
|
||||
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
|
||||
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
|
||||
|
||||
## Что уже проверено и НЕ сработало
|
||||
|
||||
- **Маска «у тайла есть передний слой» (`FORE_ANY`) + контекст тайла один
|
||||
раз.** Сделано (коммит `a9f4521`), эффект **нулевой**: в футпринте Кида
|
||||
тайлы почти всегда С передним слоем, а снятое второе чтение кода съедено
|
||||
проверкой маски. Оставлено как сближение с оригиналом.
|
||||
- **«Быстрый путь для окна коллизии целиком внутри комнаты».** Не
|
||||
срабатывал почти никогда: Кид в колонке 0 даёт окно с −1. Заменён на
|
||||
разбиение окна на непрерывные пробеги.
|
||||
- **Флаг «фон трогали» вместо позиционной метки** — нулевой выигрыш,
|
||||
факелы гасили пропуск для всех сразу (см. `pop_cdraw.h`).
|
||||
@@ -0,0 +1,172 @@
|
||||
# ЦИАН фаза (персонажи + передний слой) — анализ и оптимизация
|
||||
|
||||
Рабочий документ: живёт между сессиями. Внизу **журнал правок** — каждая
|
||||
запись с замером до/после. Сцена, рецепт воспроизведения и зонды —
|
||||
[`perf_l13_room23.md`](perf_l13_room23.md); там же ответ про 8-битные
|
||||
габариты. Парная фаза — [`perf_green_phase.md`](perf_green_phase.md).
|
||||
|
||||
Границы фазы в `sprpop.c`: от `PROF(6)` (строка 620) до `PROF(0)`
|
||||
(строка 677). Содержимое: `pop_check_mirror`, `pop_loose_mob_draw`,
|
||||
соперник, `pop_char_draw(KID)`, `pop_loose_mob_draw_over`, `pop_fore_needed`,
|
||||
`pop_hp_draw`, `pop_char_fore(KID)`, `pop_cd_clear`,
|
||||
`pop_room_clip_borders`.
|
||||
|
||||
**Бюджет: ≤ 400 000 тактов (растровый кадр = 430 000).**
|
||||
|
||||
| состояние | было (2026-08-17, `c312e4a`) | стало (`af189a1`) |
|
||||
|---|---:|---:|
|
||||
| покой, Кид пропущен | 20 550 | 20 550 |
|
||||
| Кид перерисовывается, кусков нет | 193 000 | 192 000 |
|
||||
| **пик каскада (6 кусков + Кид)** | **631 800** | **270 510 ✔** |
|
||||
|
||||
**ЦЕЛЬ ФАЗЫ ВЫПОЛНЕНА** (270 510 при бюджете 400 000, запас 32 %).
|
||||
|
||||
---
|
||||
|
||||
## 1. Что решило дело
|
||||
|
||||
### C1. Пометки «фон трогали» в `mob_render` подавлены — −55 000
|
||||
|
||||
`pop_loose_mob_tick` помечает **весь коридор** куска одним вызовом, а три
|
||||
блита внутри `mob_render` метили подмножества того же прямоугольника по
|
||||
**4 502 такта** каждый. Механизм — `pop_cd_mute()`/`pop_cd_unmute()`
|
||||
(в `pop_tile.c`; отдельное значение того же флага `pop_cd_batch`, чтобы у
|
||||
`pop_cd_touch` на общем пути осталась ОДНА проверка).
|
||||
|
||||
Добавлена пометка в `mob_spawn_copy`: кусок, рождённый ВНУТРИ тика
|
||||
(`loose_fall` сбил плиту), получает слот с начала таблицы, то есть уже
|
||||
пройденный циклом, — своей пометки в этом кадре он бы не получил, а нарисован
|
||||
был бы. Без этого пропущенная пометка = стёртый и не перерисованный
|
||||
персонаж.
|
||||
|
||||
### C4. Кусок клипуется САМ, вместо чистки бортов после — −138 000
|
||||
|
||||
Самая крупная и самая неожиданная статья. В `mob_render` стоял
|
||||
`pop_clip_sprite`, то есть кусок рисовался в борт целиком и взводил
|
||||
`border_dirty`; `pop_room_clip_borders` потом стирал ДВЕ полосы во всю ширину
|
||||
экрана (320×28 и 320×28) — **150 978 тактов в КАЖДОМ кадре**, пока хоть один
|
||||
кусок торчит выше поля. А гряда 13-го уровня рождается ровно у потолка
|
||||
(`y = 2`), то есть почти весь каскад. Стало 1 722.
|
||||
|
||||
Теперь окно клипа (`pop_t_win_set(0, POP_YOFF, 320, POP_PLAYFIELD_H)`)
|
||||
ставится ТОЛЬКО когда кусок реально задевает борт: внутри поля блиты идут
|
||||
быстрым путём.
|
||||
|
||||
### Композит куска: три блита → один — −163 000
|
||||
|
||||
Части `env 70 / 74 / 72` складываются в ОДИН getimage-блоб при загрузке
|
||||
тайлсета (`mob_spr_build` в `pop_room.c`). Мотив прямо из
|
||||
[[blit_cost_model]]: у блита ~8 800 такта постоянных накладных против ~5 000
|
||||
на пиксели, а шесть кусков в воздухе давали 18 вызовов = **258 708 такта**,
|
||||
больше половины фазы.
|
||||
|
||||
Тонкости, которые пришлось соблюсти:
|
||||
|
||||
- части **перекрываются** (74 и 70 обе идут от `mob_x`), поэтому композит
|
||||
собирается попиксельно с пропуском `0xFF` — ровно как три прозрачных блита
|
||||
друг поверх друга;
|
||||
- габариты частей **читаются**, а не берутся константами: у тайлсетов правая
|
||||
часть разная (26 px в подземелье, 25 во дворце);
|
||||
- блоб лежит в обычной памяти (W2), поэтому блит идёт мимо `atlas_image` и
|
||||
`gfx_w0_map/unmap` — ещё ~1 350 такта на вызов. Новый резидентный лист
|
||||
`pop_mem_b` (`pop_tile.c`);
|
||||
- страйд блоба = его ширина; сначала считается точный габарит, потом
|
||||
копирование. Промежуточная версия объявляла блоб шириной 63 при
|
||||
фактических 58 и переносила пять прозрачных колонок на каждом кадре;
|
||||
- собирается на КАЖДУЮ смену тайлсета; резервный путь на три блита остался
|
||||
(`mob_spr_ok`).
|
||||
|
||||
### Общие правки, попавшие и в эту фазу
|
||||
|
||||
- `blit_b_clip`: байтовый габарит + file-scope вместо локалей (кадр 22 → 12 Б,
|
||||
обращений `(ix)` 211 → 51). Подробности — в
|
||||
[`perf_green_phase.md`](perf_green_phase.md) §G3.
|
||||
- `pop_blit_b`: аргументы в file-scope (76 → 11 обращений `(ix)`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Раскладка на 2026-08-17 (до правок) — для истории
|
||||
|
||||
Подфазы (три готовых `PROF(6)`: 0x4BCE / 0x4C26 / 0x4C93), пик:
|
||||
|
||||
| участок | покой | пик |
|
||||
|---|---:|---:|
|
||||
| `pop_check_mirror` + `pop_loose_mob_draw` + соперник | 23 250 | 154 512 |
|
||||
| `pop_char_draw(KID)` + `pop_loose_mob_draw_over` + `pop_fore_needed` + HP | 3 726 | 445 284 |
|
||||
| `pop_char_fore(KID)` + `pop_cd_clear` + **чистка бортов** | 1 722 | 150 978 |
|
||||
|
||||
Разбор одного `pop_blit_b` (157 замеров быстрого пути, зонды b1..b5):
|
||||
|
||||
| участок | такты | доля |
|
||||
|---|---:|---:|
|
||||
| `atlas_image` + `gfx_w0_map` + чтение габарита | 1 086 | 7 % |
|
||||
| ядро блита (libbgi, `gfx_blit_noclip`) | 10 422 | 64 % |
|
||||
| `pop_cd_touch` — пометка «фон тронут» | 4 502 | 28 % |
|
||||
| `gfx_w0_unmap` + возврат | 264 | 2 % |
|
||||
| ИТОГО | 16 273 | |
|
||||
|
||||
Клипованный путь тогда же: ядро `blit_b_clip` 14 088, итого 16 409.
|
||||
После правок: клипованный блит 11 848, быстрый ~13 900 (у него больше
|
||||
пикселей).
|
||||
|
||||
---
|
||||
|
||||
## 3. Что осталось в запасе (если понадобится ещё)
|
||||
|
||||
Фаза в бюджете, поэтому это задел, а не план.
|
||||
|
||||
### C5. Один `gfx_w0_map`/`unmap` на группу блитов
|
||||
|
||||
1 086 + 264 на вызов. Для композита куска уже не нужно (он в обычной
|
||||
памяти), но остаётся для тайлов фона: куски одного тайла часто лежат в одной
|
||||
странице атласа. Мешает то, что `pop_blit_b` — общий лист для всех
|
||||
вызывающих; нужна форма «открыть страницу, N блитов, закрыть».
|
||||
|
||||
### C6. Размеры ленты — из каталога атласа
|
||||
|
||||
`fw`/`fh` уже лежат в записи каталога (8 байт: `offset u16, fw u8, fh u8,
|
||||
nx u8, ny u8, резерв u16`), и у всех фоновых лент `nx = ny = 1`, то есть они
|
||||
равны `w`/`h` из шапки. `atlas_image` их читает и выбрасывает.
|
||||
|
||||
### C7. objtable вместо отдельного fore-прохода на персонажа
|
||||
|
||||
Позиции 5 и 6 старого `perf_backlog.md` — большой рефакторинг. Оригинал
|
||||
кладёт персонажей и куски в `objtable` и рисует их при обходе тайлов
|
||||
(`draw_objtable_items_at_tile`), порядок окклюзии получается сам; у нас
|
||||
отдельный fore-проход НА КАЖДОГО персонажа.
|
||||
|
||||
### Снять временную оснастку
|
||||
|
||||
Шесть вызовов `pop_dbg_b1..b6` внутри `pop_blit_b` — ~400 такта на блит
|
||||
(`call` + `ret` × 6), плюс `pop_dbg_kind`/`m16` в `pop_redraw_needed`.
|
||||
Снимать ПОСЛЕ того, как оптимизация закончена: без них не мерить.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что НЕ делать
|
||||
|
||||
- **Не ставить W3-скобку из кода с `--w3`** — белый экран
|
||||
(memory `gfx_blit_noclip_fast`).
|
||||
- **Не ускорять передачу пикселей** — предел железа
|
||||
(memory `blit_cost_model`).
|
||||
- **Не возвращать клип куска по `clip.right = 40`** оригинала
|
||||
(`add_mob_to_objtable`, seg007:1161): единицы этого поля не выяснены,
|
||||
буквальные 40 экранных пикселей срезают правый задний угол плиты
|
||||
(прогон 2026-08-13).
|
||||
- **Не сужать коридор heal «по палаццовому следу»** — габариты частей у
|
||||
тайлсетов разные; брать высоту СОБРАННОГО композита (так и сделано).
|
||||
|
||||
---
|
||||
|
||||
## 5. Журнал правок
|
||||
|
||||
| дата | что сделано | циан: покой / Кид / пик | коммит |
|
||||
|---|---|---|---|
|
||||
| 2026-08-17 | базовый замер | 20 550 / 193 000 / **631 800** | `c312e4a` |
|
||||
| 2026-08-17 | C1 подавление пометок в `mob_render` + пометка в `mob_spawn_copy` | — / — / **577 050** | `a3c473d` |
|
||||
| 2026-08-17 | C4 кусок клипуется сам вместо чистки бортов; `blit_b_clip` байты+file-scope | — / — / **438 546** | `a3c473d` |
|
||||
| 2026-08-17 | `pop_blit_b` аргументы в file-scope | — / — / **435 180** | `b2da0b8` |
|
||||
| 2026-08-17 | **композит куска: один блит вместо трёх** | — / — / **273 078** | `b2da0b8` |
|
||||
| 2026-08-17 | точный габарит композита (было 63 при 58) | 20 550 / 192 000 / **270 510 ✔** | `af189a1` |
|
||||
| 2026-08-17 | замер после фиксов уровня 1 | — / — / **379 482** | `40f0d46` |
|
||||
| 2026-08-17 | **регресс после фиксов уровня 2** — без изменений | — / — / **379 488 ✔** | `ec1f384` |
|
||||
@@ -0,0 +1,327 @@
|
||||
# ЗЕЛЁНАЯ фаза (слой фона) — анализ и оптимизация
|
||||
|
||||
Рабочий документ: живёт между сессиями. Внизу **журнал правок** — каждая
|
||||
запись с замером до/после. Сцена, рецепт воспроизведения и зонды —
|
||||
[`perf_l13_room23.md`](perf_l13_room23.md); там же ответ про 8-битные
|
||||
габариты. Парная фаза — [`perf_cyan_phase.md`](perf_cyan_phase.md) (её цель
|
||||
достигнута).
|
||||
|
||||
Границы фазы в `sprpop.c`: от `PROF(4)` (строка 450) до `PROF(6)`
|
||||
(строка 620). Содержимое: `pop_loose_tick`, `pop_process_trobs`,
|
||||
`pop_redraw_needed`, шов, смена уровня, сигналы провалов, вспышка.
|
||||
|
||||
**Бюджет: ≤ 400 000 тактов (растровый кадр = 430 000).**
|
||||
|
||||
| состояние | было (`c312e4a`) | стало (`af189a1`) |
|
||||
|---|---:|---:|
|
||||
| покой в комнате 23 | 35 760 | 35 760 |
|
||||
| дрожат 6 плит-потолков | 366 000 | 335 400 |
|
||||
| **пик каскада** | **805 000** | **546 900** |
|
||||
|
||||
**ЦЕЛЬ ФАЗЫ НЕ ДОСТИГНУТА: 546 900 против 400 000 (1,37×).** Что осталось
|
||||
сделать и почему это именно раскол `draw_tile` — §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. Раскладка ПОСЛЕ правок (замер `af189a1`)
|
||||
|
||||
Пик — кадры 24-28 (посадки плит), больше не кадры провалов.
|
||||
|
||||
| участок | покой | дрожь | пик |
|
||||
|---|---:|---:|---:|
|
||||
| `pop_loose_tick` | 36 234 | 36 234 | **156 762** |
|
||||
| `pop_process_trobs` | 1 050 | 1 050 | 1 050 |
|
||||
| **`pop_redraw_needed`** | 924 | 288 800 | **380 568** |
|
||||
| хвост (шов, смена уровня, сигналы, вспышка) | 9 336 | 9 336 | 10 776 |
|
||||
|
||||
`pop_loose_tick` изнутри на пике: два цикла по тайлам 9 852,
|
||||
**`pop_loose_mob_tick` 154 074** (heal шести летящих кусков), остальное мелочь.
|
||||
|
||||
### Цена одной перерисовки: было → стало
|
||||
|
||||
| вид | было | стало | чем |
|
||||
|---|---:|---:|---|
|
||||
| `RDA_CEIL` — дрожащая плита-потолок | 48 785 | **43 536** | G2 + G3 |
|
||||
| `RDA_CEIL_GONE` — запечь колодец | 251 335 | **138 318** | **G1** (клип полосы) + G2 + G3 |
|
||||
| `RD_FLOOR` — щебень на месте посадки | 198 805 | **179 914** | G2 + G3 + снятая двойная пометка |
|
||||
|
||||
Штук за кадр: `RDA_CEIL` до 6, `RDA_CEIL_GONE` до 2, `RD_FLOOR` до 2.
|
||||
|
||||
### Детальный профиль `RD_FLOOR` (179 914) — главная оставшаяся статья
|
||||
|
||||
Снят зондами по каждому блиту (`pop_dbg_b1/b5`) и по рамкам
|
||||
(`pop_bar_black`, `pop_cd_batch_begin/end`):
|
||||
|
||||
| участок | такты |
|
||||
|---|---:|
|
||||
| вход `pop_floor_bake` + `gfx_set_bank` | 2 382 |
|
||||
| `pop_bar_black` 60×39 (включая `pop_cd_touch` 4 502) | ~15 500 |
|
||||
| **контекст `draw_tile` #1** (5 чтений тайлов, `63*row`, индексация таблицы) | **13 584** |
|
||||
| 4 блита тайла #1 | 50 718 |
|
||||
| **диспетчер между блитами #1** (все `if (code == …)`) | **11 388** |
|
||||
| **контекст `draw_tile` #2** | **13 584** |
|
||||
| 4 блита тайла #2 | ~54 000 |
|
||||
| **диспетчер между блитами #2** | **11 388** |
|
||||
| хвост | 6 474 |
|
||||
|
||||
Итого: **105 500 — сами блиты (реальные пиксели), 74 400 — накладные**, из
|
||||
которых 27 168 контекст двух `draw_tile` и 22 776 их диспетчер.
|
||||
|
||||
---
|
||||
|
||||
## 2. Что сделано (с чем сравнивать)
|
||||
|
||||
### G1. Окно клипа для точечной перерисовки — −90 000
|
||||
|
||||
`pop_t_win_set(x, ytop, w, h)` / `pop_t_win_clear()` в `pop_tile.c`: ставит
|
||||
уже существующее окно `pop_t_fclip_*` на прямоугольник, который перерисовка
|
||||
восстанавливает. Работает в обе стороны — и предфильтр `pop_blit_b`
|
||||
отсеивает куски мимо окна ДО `atlas_image`/`gfx_w0_map`, и `blit_b_clip`
|
||||
режет остальные по нему.
|
||||
|
||||
Где сработало: `pop_ceil_bake_empty` — куски ряда 0 высотой 63 px рисовались
|
||||
целиком, хотя восстановить надо девять строк полосы. **Блит 21 447 → 7 619**,
|
||||
вся перерисовка 251 335 → 138 318.
|
||||
|
||||
Где НЕ сработало — см. §4, отрицательные результаты.
|
||||
|
||||
Побочно: пока окно стоит, `pop_blit_b` не ставит пометку «фон трогали»
|
||||
(признак fore-прохода), поэтому вызывающий обязан пометить прямоугольник сам.
|
||||
В `pop_ceil_shake_draw` добавлен явный `pop_cd_touch` на область heal'а; в
|
||||
`pop_ceil_bake_empty` и `pop_floor_bake` метит `pop_bar_black`, а лишний
|
||||
второй вызов на ту же область снят.
|
||||
|
||||
### G2. Контекст тайла — file-scope, а не локали `draw_tile` — −55 000
|
||||
|
||||
Порт `load_curr_and_left_tile` (seg008:0339): у оригинала это
|
||||
`curr_tile`/`curr_modifier`/`draw_xh`/`draw_main_y`/`draw_bottom_y` —
|
||||
переменные модуля, а не локали.
|
||||
|
||||
Причина в кодогене: в `draw_tile` **57 вызовов**, и каждое живое через вызов
|
||||
значение SDCC спиливал в стековый кадр — 26 байт кадра и **513 обращений
|
||||
`-N(ix)`** (при ~46 замеренных тактах на обращение это ~23 600, что и
|
||||
намерено). Стало **33 обращения**, кадра нет, банк 7 −703 Б.
|
||||
|
||||
### G3. `blit_b_clip` — байтовый габарит + file-scope
|
||||
|
||||
Два шага, и важен порядок наблюдений:
|
||||
|
||||
1. **Байтового габарита ОДНОГО НЕ ХВАТИЛО.** `sx/sy/dw/dh` → `uint8_t`
|
||||
(корректно: кадры атласов ≤ 56×63) дало 211 → 173 обращения, а
|
||||
22-байтовый кадр остался: значений, живых через шесть вызовов ядер libbgi,
|
||||
всё равно больше, чем регистров у Z80.
|
||||
2. **Решило вынесение из локалей** (`bc_*`): 51 обращение, кадр 22 → 12 Б.
|
||||
|
||||
Клипованный блит 14 088 → 11 848. Заодно `blit_b_oversize` больше не ходит
|
||||
через `blit_b_clip` (там теперь байтовый габарит) — рисует напрямую
|
||||
`gfx_blit_part`; это путь под полноэкранные подложки интро/финала.
|
||||
|
||||
### G4. Мелочи
|
||||
|
||||
- `pop_blit_b`: аргументы в file-scope (третий и дальше SDCC передаёт стеком,
|
||||
каждое чтение шло через `-N(ix)`) — 76 → 11 обращений.
|
||||
- `pop_loose_mob_tick`: пометки всех кусков ОДНИМ пакетом
|
||||
(`pop_cd_batch_begin/end`) — было по 4 502 такта на кусок.
|
||||
176 772 → 168 600.
|
||||
- Коридор heal куска — по фактической высоте СОБРАННОГО композита (было 24
|
||||
строки константой, стало 20). 168 600 → 156 762.
|
||||
|
||||
---
|
||||
|
||||
## 3. Что осталось: раскол `draw_tile` (позиция G5)
|
||||
|
||||
**Оставшийся разрыв: −147 000.** Он весь в двух местах.
|
||||
|
||||
### G5. Расколоть `draw_tile` на узкие части, как в оригинале
|
||||
|
||||
**Ожидание: −50 000 … −60 000.**
|
||||
|
||||
У оригинала `draw_tile` (seg008:01C7) — это девять независимых вызовов:
|
||||
`draw_tile_floorright`, `draw_tile_anim_topright`, `draw_tile_right`,
|
||||
`draw_tile_anim_right`, `draw_tile_bottom`, `draw_loose`, `draw_tile_base`,
|
||||
`draw_tile_anim`, `draw_tile_fore`. Для ряда −1 он зовёт шесть из них
|
||||
(`draw_tile_aboveroom`, seg008:01F2), для полосы у потолка — те же шесть плюс
|
||||
`draw_tile_wipe(3)` (`redraw_needed_above`, seg008:02C1).
|
||||
|
||||
У нас всё это — ветки `if (row >= 0)` ВНУТРИ одной функции, то есть контекст
|
||||
(13 584) и диспетчер (11 388) оплачиваются целиком всегда. Расколов, каждая
|
||||
точечная перерисовка сможет звать только нужные части:
|
||||
|
||||
- `pop_floor_bake`: вместо второго полного `draw_tile(row, col+1)` — только
|
||||
его правую грань и базу;
|
||||
- `pop_ceil_shake_draw` / `pop_ceil_bake_empty`: дословный
|
||||
`draw_tile_aboveroom`;
|
||||
- `pop_loose_shake_draw`, `pop_spike_redraw`, `pop_gate_redraw` — то же.
|
||||
|
||||
Риск средний: у `draw_tile` собрано много инвариантов (BUG-LOOSE-3,
|
||||
BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1), проверять придётся прогонами всех
|
||||
уровней. Поэтому делать отдельным заходом, а не хвостом другой правки.
|
||||
|
||||
### G6. Меньше блитов в `RD_FLOOR`
|
||||
|
||||
**Ожидание: неизвестно, надо мерить.** 105 500 из 179 914 — это 7,6 блита,
|
||||
и они рисуют настоящие пиксели. Сократить можно только сократив то, что
|
||||
восстанавливается: бар сейчас 60×39 от `yb+26`, а плита занимает по вертикали
|
||||
меньше (её куски: левая грань `POP_LOOSE_FRAM_LEFT` 32×13 на `dmy = yb+62`,
|
||||
низ `POP_LOOSE_FRAM_BOTTOM` 32×3 на `dby = yb+65`, правая грань в соседе
|
||||
26×16 на `dby−1`). То есть плита живёт в `yb+47 .. yb+65`, а бар начинается с
|
||||
`yb+26` — **21 лишняя строка сверху**.
|
||||
|
||||
Проверять осторожно: бар заодно стирает и то, что рисует ДРУГИЕ куски тайла
|
||||
(орнаментная лента `stripe_id` на `dmy−27 = yb+35` попадает как раз в
|
||||
«лишнюю» часть). Сузишь бар — надо убедиться, что ничего не осталось.
|
||||
|
||||
### G7. heal летящих кусков — 154 074 (28 % фазы)
|
||||
|
||||
Шесть кусков × ~25 700: сам heal 64×20 (по модели ~19 700) + пакетная
|
||||
пометка + накладные `mob_tick_one` (16-байтовый кадр, 99 обращений `(ix)`).
|
||||
**Сам heal у предела железа** — это 1 280 пикселей на кусок, оптимизировать
|
||||
нечего, кроме площади. Площадь уже подрезана до габарита композита.
|
||||
|
||||
Остаётся `mob_tick_one` (~4 500 на кусок = 27 000 на кадр) — то же лечение
|
||||
file-scope, что у `draw_tile`.
|
||||
|
||||
### G8. Пометка соседа — узкой полосой, а не полным тайлом (идея пользователя)
|
||||
|
||||
**Ожидание: заметное, но не мерено. Взять ПОСЛЕ обхода всех уровней**
|
||||
(решение пользователя 2026-08-17: пока идёт отлов багов слоёв, каждая правка
|
||||
добавляет переменных в картину).
|
||||
|
||||
Когда плита (1,8) падает, помечаются ДВА тайла:
|
||||
|
||||
| пометка | что делает |
|
||||
|---|---|
|
||||
| `(1,8)` → `RD_LOOSE_GONE` | бар 40 на своём x, бар 32 на соседе, `draw_tile(1,8)` + `draw_tile(1,9)` |
|
||||
| `(1,9)` → `RD_FLOOR` | бар **60** на x соседа, `draw_tile(1,9)` ЕЩЁ РАЗ |
|
||||
|
||||
То есть сосед перезапекается ЦЕЛИКОМ и ПОВТОРНО, хотя потревожили у него
|
||||
только левые 28 пикселей — там, куда свисает правая грань упавшего тайла.
|
||||
`draw_tile(1,9)` при этом вызывается дважды на одну пометку.
|
||||
|
||||
Что такое эти числа (чтобы не сузить лишнего):
|
||||
|
||||
- **60 = 32 свой тайл + 28 СОБСТВЕННЫЙ свес.** Правая грань пола (кадр 42,
|
||||
26 px) рисуется в клетке соседа с `x+32`, занимая `x+32..x+57`. Для запечки
|
||||
САМОГО тайла 60 уже минимальны — сужать их нельзя;
|
||||
- сузить можно только тот случай, когда тайл помечен ПОТОМУ ЧТО ИЗМЕНИЛСЯ ЕГО
|
||||
ЛЕВЫЙ СОСЕД: тогда нужна полоса 28 px у левого края, а не весь тайл.
|
||||
|
||||
Почему выигрыш не символический: `pop_floor_bake` стоит **179 914** тактов,
|
||||
из них 105 500 — сами блиты. Узкая полоса срезала бы и площадь бара
|
||||
(60×39 → 28×39), и часть блитов — окно клипа там теперь стоит обязательным
|
||||
(см. §4), так что отсев достаётся даром.
|
||||
|
||||
**Условия, из-за которых это не «просто уменьшить число»:**
|
||||
|
||||
1. `pop_floor_bake` — ОБЩАЯ функция: её же зовут кнопка (`pop_button_redraw`),
|
||||
зеркало, подобранный предмет и щебень на месте посадки. Там меняется сам
|
||||
тайл и 60 нужны целиком. Значит нужен отдельный вход (напр.
|
||||
`pop_floor_bake_edge(row, col)`) или параметр-прямоугольник — именно под
|
||||
пометку «изменился мой левый сосед».
|
||||
2. Прежде чем выкидывать вторую пометку целиком, сверить ВЕРТИКАЛЬНЫЕ
|
||||
диапазоны: `pop_loose_bake_empty` кроет `63*row+46 .. +65` (20 строк), а
|
||||
`pop_floor_bake` — `yb+26 .. yb+64` (39 строк). То есть сосед покрыт
|
||||
ВТОРЫМ баром не полностью, и просто снять пометку нельзя.
|
||||
3. Ширина полосы = свес ЛЕВОГО тайла, а он зависит от типа тайла (у loose это
|
||||
8 px по комментарию в `pop_loose_bake_empty`, у пола 26). Брать по
|
||||
максимуму (28) — безопасно.
|
||||
|
||||
### G9. Снять временную оснастку
|
||||
|
||||
Шесть `pop_dbg_b1..b6` внутри `pop_blit_b` — ~400 такта на блит; при 15
|
||||
блитах зелёной это 6 000 на кадр. Плюс `pop_dbg_kind`/`m16` (2 вызова на
|
||||
перерисовку) и `pop_dbg_m5..m15`. Снимать ПОСЛЕ окончания оптимизации: без
|
||||
них не мерить.
|
||||
|
||||
---
|
||||
|
||||
## 4. Копия второй страницы — и почему она ТРЕБУЕТ окна клипа
|
||||
|
||||
Точечные запечки ставятся с `pages = 2`, срабатывают два кадра подряд (по разу
|
||||
на страницу дабл-буфера) и оба раза считают одно и то же. После первого раза
|
||||
нужный прямоугольник уже лежит в ОЗУ-копии первой страницы, и его можно
|
||||
скопировать: `gfx_copy_page` берёт источником ОЗУ-копию НЕактивной страницы
|
||||
(то есть ЧИСТЫЙ фон — спрайты рисуются банком без тени и в копию не попадают),
|
||||
а приёмник обновляет и в видео-ОЗУ, и в ОЗУ-копии. Идея пользователя: тот же
|
||||
приём, что при перевороте экрана (зелёное зелье), только без зеркала.
|
||||
|
||||
| | полная запечка | копия |
|
||||
|---|---:|---:|
|
||||
| щебень / кнопка (60×39) | 179 914 | ~35 000 |
|
||||
| колодец полосы потолка (64×9) | 138 318 | ~17 500 |
|
||||
|
||||
**ДВА УСЛОВИЯ КОРРЕКТНОСТИ.** Оба нарушались и оба дали видимые баги.
|
||||
|
||||
1. **Запечка обязана быть ОГРАНИЧЕНА копируемым прямоугольником.**
|
||||
`draw_tile` рисует тайлы ЦЕЛИКОМ, то есть пишет ШИРЕ бара; копия переносит
|
||||
ровно бар, и всё, что легло вне него, на второй странице остаётся прежним —
|
||||
страницы расходятся, это видно как МЕРЦАНИЕ через кадр. У полосы потолка
|
||||
окно стояло с самого начала (G1), у `pop_floor_bake` — нет, и он мерцал
|
||||
торцами полов, плит и кнопок (найдено пользователем 2026-08-17: уровень 1,
|
||||
комната 6, Кид на кнопке (0,2)). Поэтому в `pop_floor_bake` окно теперь
|
||||
стоит КАК УСЛОВИЕ КОРРЕКТНОСТИ, хотя по скорости само по себе убыточно
|
||||
(см. §5) — снимать его нельзя.
|
||||
2. **Копия годится только если содержимое тайла между двумя кадрами не
|
||||
изменилось.** У анимированного тайла (кнопка с идущим таймером связи)
|
||||
пометка обновляется КАЖДЫЙ кадр и картинка каждый раз другая. Поэтому
|
||||
`pop_set_redraw`/`pop_set_redraw_above` гасят слот копии при ПЕРЕпометке
|
||||
(`pop_bake_slot_reset*`).
|
||||
|
||||
Плюс слот `bake_pg`/`bake_pg_above` помнит, НА КАКОЙ странице сделана первая
|
||||
запечка: копируем только если первая была на ДРУГОЙ странице и дабл-буфер
|
||||
включён. Это покрывает переплетение двух запечек в одном кадре, однобуфер
|
||||
(чит SPACE) и смену комнаты (`pop_bake_forget`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Отрицательные результаты — НЕ повторять
|
||||
|
||||
### Окно клипа в `pop_floor_bake` — по СКОРОСТИ проверено ТРИ раза, каждый раз хуже
|
||||
|
||||
**Но оно всё равно стоит на месте: без него ломается копия второй страницы
|
||||
(§4).** Ниже — только про скорость самого окна.
|
||||
|
||||
| попытка | было | стало |
|
||||
|---|---:|---:|
|
||||
| до G3 | 187 266 | 198 279 |
|
||||
| после G3 | 182 124 | 188 460 |
|
||||
| после `pop_blit_b` file-scope, с детальным зондом | 179 914 | 188 417 |
|
||||
|
||||
Третья попытка объяснила причину: клипованный путь стоит **+1 500 такта на
|
||||
КАЖДОМ** из 7,6 блитов (+11 400), а режет он только редкие высокие куски — в
|
||||
трассе такие нашлись (34 878 → 25 872 и 28 818 → 24 090, всего −13 700), но в
|
||||
среднем по 12 перерисовкам их нет. Запись стоит в коде.
|
||||
|
||||
### Прочее (проверено раньше)
|
||||
|
||||
- **Не откладывать запекание на другой кадр** — запечка пишет ОЗУ-копию, из
|
||||
которой восстанавливает heal; отложенная даёт призрак плиты на месте дыры.
|
||||
- **Не батчить смежные колонки в `pop_ceil_shake_draw`** — плиты стартуют со
|
||||
случайными задержками, в кадре дрожат разрозненные колонки, пробег почти
|
||||
всегда длиной в одну.
|
||||
- **Не ускорять передачу пикселей** — предел железа (3+3 такта на байт,
|
||||
memory `blit_cost_model`). В пиковом кадре «железный» минимум всех блитов
|
||||
≈150 000 из 916 458.
|
||||
- **`LOOSE-SHAKE-RUNS`** (пометки по сменам кадра): наивный вариант выигрыша
|
||||
НЕ даёт — пять смен × две страницы = те же десять перерисовок. Работает
|
||||
только версия «пары и тройки», ~20 % и только на дрожащих плитах; оценка
|
||||
2026-08-13, не перемерена. Подробности —
|
||||
`TASKS_OPEN.md#loose-shake-runs`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Журнал правок
|
||||
|
||||
| дата | что сделано | зелёная: покой / дрожь / пик | коммит |
|
||||
|---|---|---|---|
|
||||
| 2026-08-17 | базовый замер | 35 760 / 366 000 / **805 000** | `c312e4a` |
|
||||
| 2026-08-17 | G2 контекст тайла в file-scope | — / — / **747 954** | `a3c473d` |
|
||||
| 2026-08-17 | G1 окно клипа в `pop_ceil_bake_empty` | — / — / **663 250** | `a3c473d` |
|
||||
| 2026-08-17 | G3 `blit_b_clip` байты + file-scope | — / 335 400 / **575 730** | `a3c473d` |
|
||||
| 2026-08-17 | `pop_blit_b` file-scope; пакетная пометка кусков | — / — / **557 706** | `b2da0b8` |
|
||||
| 2026-08-17 | коридор heal по высоте композита | 35 760 / 335 400 / **546 900** | `9a50ab2` |
|
||||
| 2026-08-17 | **копия второй страницы вместо второй запечки** | — / — / **423 558** | `5ef721e` |
|
||||
| 2026-08-17 | `mob_tick_one` в file-scope; снята оснастка из горячих путей | 35 760 / 326 130 / **414 456** | `18ee60e` |
|
||||
| 2026-08-17 | фикс мерцания: окно клипа в `pop_floor_bake` как условие корректности копии | замер после фикса — ниже | `35b7cd5` |
|
||||
| 2026-08-17 | замер после фиксов уровня 1 (мерцание торцов, потолочный fore, сосед под плитой, блеск меча) | 35 760 / — / **417 630** | `40f0d46` |
|
||||
| 2026-08-17 | **регресс после фиксов уровня 2** (чёрные бары, чит бессмертия) — в пределах шума | — / — / **419 526** | `ec1f384` |
|
||||
@@ -0,0 +1,238 @@
|
||||
# Сцена и замер: факел + чомпер + страж, уровень 11 комната 15
|
||||
|
||||
Вторая целевая сцена для оптимизации (первая — [`perf_l13_room23.md`](perf_l13_room23.md),
|
||||
каскад плит). Здесь узкое место другое: не разовый пик на каскаде, а
|
||||
**постоянная** цена статичной комнаты, в которой одновременно живут два
|
||||
факела, чомпер и страж.
|
||||
|
||||
Такты — `totalcycles` MAME (не такты Z80, ≈2,4× номинала, memory
|
||||
`sprinter_wait_states_2x`). **Растровый кадр = 430 000.** Хвост кадра — три
|
||||
`gfx_wait_vsync`, поэтому логический кадр занимает 3 растра, пока работа
|
||||
укладывается в один; при работе 1..2 растра период становится 4.
|
||||
|
||||
## 1. Сцена
|
||||
|
||||
Уровень 11, комната 15. Проверено чтением состояния машины:
|
||||
`pop_current_level` = 0x0B, `cur_room` = 15.
|
||||
|
||||
| кто | где | кадр |
|
||||
|---|---|---|
|
||||
| Кид | (0,2), x=98 | 15 (стойка с мечом) |
|
||||
| чомпер | (0,3) | застывший (trob мёртв) |
|
||||
| факел | (0,2) — пламя рисуется в ячейке (0,3) | анимируется каждый кадр |
|
||||
| факел | (0,7) — пламя в ячейке (0,8) | анимируется каждый кадр |
|
||||
| страж | (0,8), x=170 | 171 (боевая стойка) |
|
||||
|
||||
Ни Кид, ни страж не двигаются: сцена статична, разброс замера — сотни тактов
|
||||
на 800 000.
|
||||
|
||||
## 2. Замер (`09f32ce`, база модуля `sprpop.c` = 0x42AD)
|
||||
|
||||
867 кадров, зонды A/C/D/E; детализация — тремя отдельными прогонами
|
||||
(m1, m5..m7, M/F). **Период кадра: 4 растра во всех 866 интервалах.**
|
||||
|
||||
| участок | зонды | медиана | доля работы |
|
||||
|---|---|---:|---:|
|
||||
| **синяя: ввод + heal** | A→m1 | 141 048 | 17,6 % |
|
||||
| **синяя: логика** | m1→C | 152 190 | 19,0 % |
|
||||
| синяя, всего | A→C | **293 238** | 36,6 % |
|
||||
| зелёная: `pop_loose_tick` | C→m5 | 28 872 | 3,6 % |
|
||||
| **зелёная: `pop_process_trobs`** | m5→m6 | 92 448 | 11,5 % |
|
||||
| **зелёная: `pop_redraw_needed`** | m6→m7 | 190 260 | 23,7 % |
|
||||
| зелёная: шов/ворота соседа | m7→D | 9 336 | 1,2 % |
|
||||
| зелёная, всего | C→D | **320 916** | 40,0 % |
|
||||
| циан: `check_mirror` + `loose_mob_draw` | D→M | 37 458 | 4,7 % |
|
||||
| **циан: Кид + страж + fore + HP** | M→F | 148 566 | 18,5 % |
|
||||
| циан: `char_fore(KID)` + борта | F→E | 1 740 | 0,2 % |
|
||||
| циан, всего | D→E | **187 758** | 23,4 % |
|
||||
| **работа** | A→E | **801 768** | 1,86 растра |
|
||||
|
||||
Циан здесь **не** выделяется: 187 758 — это 0,44 растрового кадра, а разброс
|
||||
за 867 кадров всего 174 такта (187 674..187 848). Впечатление «циан ~150 %
|
||||
кадра» на глаз не подтвердилось — при периоде 4 растра полосы бордюра
|
||||
размазаны по кадрам и на глаз не читаются.
|
||||
|
||||
## 3. Главная находка: чомпер справа от факела — 190 260 тактов/кадр
|
||||
|
||||
`pop_dbg_rdmax_tot` = **1**: за кадр перерисовывается РОВНО ОДИН тайл, и
|
||||
стоит он все 190 260 тактов зелёной фазы.
|
||||
|
||||
> **ПОПРАВКА 2026-08-19 (после реализации P1).** Механизм ниже описан
|
||||
> верно, но ГЛАВНЫМ источником 190 260 тактов он НЕ был. Зонд
|
||||
> `pop_dbg_kind` показал, что все 312 перерисовок в прогоне — вид
|
||||
> `POP_RD_CHOMP` (полная), и ни одной от факела: собственная пометка
|
||||
> чомпера просто перебивала пометку соседа. Настоящая причина — в §6.
|
||||
> Урок ровно тот, что уже записан в `defer_unexplained_quirks`: механизм,
|
||||
> который правдоподобно объясняет цифру, ещё не доказан цифрой.
|
||||
|
||||
Цепочка:
|
||||
|
||||
1. `TORCH_ANIM_DIV = 1` — факел меняет кадр пламени КАЖДЫЙ логический кадр;
|
||||
2. пламя запекается в фон (`pop_torch_draw`, `GFX_BANK_NORMAL`), а канвас
|
||||
пламени 16×18 лежит **в ячейке правого соседа** (seg008:560) — то есть
|
||||
поверх чомпера;
|
||||
3. поэтому `pop_process_trobs` метит соседа: `if (trob_rcode[i] == TILE_CHOMP)
|
||||
pop_set_redraw(tp + 1, POP_RD_CHOMP, 1)` (порт `set_redraw_anim_right`);
|
||||
4. `pop_chomp_redraw` отвечает на пометку **heal 32×64 + полный `draw_tile`**.
|
||||
|
||||
Расхождение с оригиналом именно в шаге 4. `set_redraw_anim_right` метит
|
||||
слой **anim**, и оригинал возвращает только его (`draw_tile_anim_topright` →
|
||||
`draw_tile_anim_right` → `draw_tile_anim`) — одну графику чомпера поверх
|
||||
огня. Мы вместо этого стираем и пересобираем тайл целиком, со всеми слоями
|
||||
(`draw_tile_right`, `base`, `bottom`, `loose`), которые пламя вообще не
|
||||
трогало.
|
||||
|
||||
Цена по модели блита (`blit_cost_model`, 8791 + 198·h + 5,96·w·h):
|
||||
heal 32×64 ≈ 33 700, значит на один `draw_tile` уходит ≈ 156 000 — сходится
|
||||
с известным замером «полная запечка щебня 179 914».
|
||||
|
||||
Чомпер при этом **застывший**: своей анимации у него нет, поза не меняется,
|
||||
возвращать нужно ровно ту же графику поверх свежего пламени.
|
||||
|
||||
## 4. Что это даёт и куда смотреть дальше
|
||||
|
||||
Ранжирование по цене (доля от 801 768):
|
||||
|
||||
| # | участок | такты | что делать |
|
||||
|---|---|---:|---|
|
||||
| 1 | `redraw_needed`: чомпер под факелом | 190 260 | вернуть только слой anim, как в оригинале — без heal и без остальных слоёв |
|
||||
| 2 | синяя: логика двух Char | 152 190 | графики нет вообще; разобрать `pop_check_can_guard_see_kid` и два `play_seq` |
|
||||
| 3 | циан: Кид + страж | 148 566 | оба будятся каждый кадр — метки фона от чомпера/факела накрывают обоих |
|
||||
| 4 | синяя: heal двух Char | 141 048 | следствие того же: skip не срабатывает ни разу |
|
||||
| 5 | `process_trobs`: два факела | 92 448 | ≈46 000 на факел при блите пламени 16×18 ≈ 14 000 — разобрать накладные |
|
||||
| 6 | `loose_tick` | 28 872 | в комнате нет ни одной loose-плиты |
|
||||
|
||||
Пункты 3 и 4 — одна тема: пока фон трогают каждый кадр, `pop_char_skip_mask`
|
||||
не может пропустить ни Кида, ни стража. Пламя метит узко (16×18), а вот
|
||||
`pop_chomp_redraw` метит весь тайл со свесом — то есть пункт 1 чинит и часть
|
||||
пунктов 3/4.
|
||||
|
||||
Связанные задачи: `HEAL-WIDTH` и G8 в [`perf_green_phase.md`](perf_green_phase.md)
|
||||
— та же болезнь (полный тайл там, где хватает полосы), но на другом
|
||||
материале.
|
||||
|
||||
## 6. Настоящая причина 190 260 тактов: перерисовка неизменной позы
|
||||
|
||||
Найдено при реализации P1, сверкой с `animate_chomper` (seg007:0448).
|
||||
Функция оригинала заканчивается так:
|
||||
|
||||
```c
|
||||
if ((curr_modifier & 0x7F) < 6) {
|
||||
redraw_at_trob();
|
||||
}
|
||||
```
|
||||
|
||||
То есть чомпер перерисовывается **только пока фаза меньше 6** — пять кадров
|
||||
из пятнадцати (`POP_CHOMPER_SPEED = 15`). Это не оптимизация оригинала, а
|
||||
следствие таблицы поз: `chomper_fram1 = {3,2,0,1,4,3,3}`, и начиная с фазы 5
|
||||
и до конца круга поза одна и та же — 3. Рисовать её десять кадров подряд
|
||||
значит рисовать ровно ту же картинку.
|
||||
|
||||
Мы же метили тайл БЕЗУСЛОВНО, каждый кадр, пока trob жив — то есть платили
|
||||
полный `draw_tile` плюс heal 32×64 за неизменную картинку в двух третях
|
||||
кадров. А trob у чомпера живёт, пока Кид в том же РЯДУ (`animate_chomper`
|
||||
снимает его только при фазе ≥ 6 и ушедшем Киде) — в 11/15 Кид стоит в (0,2),
|
||||
чомпер в (0,3), ряд один.
|
||||
|
||||
**Что сделано:**
|
||||
|
||||
1. пометка только при фазе < 6, и на фазе 5 — на ОБЕ страницы дабл-буфера
|
||||
(она последняя рисуемая, её поза обязана лечь на обе; вторую страницу
|
||||
пометка догоняет в кадре фазы 6, где поза та же самая);
|
||||
2. пометка от факела (`set_redraw_anim_right`) переведена на новый вид
|
||||
`POP_RD_CHOMP_ANIM` → `pop_chomp_anim_draw`: три блита графики чомпера
|
||||
поверх свежего пламени, без heal и без остальных слоёв — порт ветки
|
||||
`redraw_frames_anim` (seg008:0211);
|
||||
3. приоритет полной перерисовки над anim в `pop_set_redraw` — у оригинала
|
||||
это два независимых счётчика, и `full` побеждает.
|
||||
|
||||
**Результат** (замер, 552 кадра): полная перерисовка теперь в **40 %**
|
||||
кадров, лёгкий возврат челюстей — в 60 %. Зелёная фаза: 291 888 в дорогом
|
||||
кадре против 183 414 в дешёвом.
|
||||
|
||||
| | работа | зелёная |
|
||||
|---|---:|---:|
|
||||
| до P1 | 768 684 | 294 510 |
|
||||
| после P1, медиана | **657 882** | **183 420** |
|
||||
| после P1, дорогой кадр (40 %) | 765 936 | 291 888 |
|
||||
|
||||
**−110 802 на медиане** при ожидании −160 000. Разница в том, что 40 %
|
||||
кадров по-прежнему платят полную цену: там поза реально меняется, и это уже
|
||||
не лишняя работа, а честная. Дальше её можно резать только раскладом
|
||||
`draw_tile` на части (P7) или сужением heal (P8).
|
||||
|
||||
## 5. Журнал правок по этой сцене
|
||||
|
||||
| дата | правка | работа | синяя | зелёная | циан |
|
||||
|---|---|---:|---:|---:|---:|
|
||||
| 2026-08-19 | базовый замер (`09f32ce`) | 801 768 | 293 238 | 320 916 | 187 758 |
|
||||
| 2026-08-19 | **P5**: гейты холостого хода в `pop_loose_tick` | **767 928** | 285 864 | 294 384 | 187 764 |
|
||||
| | | −33 840 | −7 374 | −26 532 | +6 |
|
||||
| 2026-08-19 | зонды для замера P2/P6 (временные) | 768 684 | 286 518 | 294 510 | 187 761 |
|
||||
| 2026-08-19 | **P1**: чомпер — перерисовка только при фазе < 6 (медиана) | **657 882** | 286 503 | 183 420 | 187 761 |
|
||||
| | | −110 802 | −15 | −111 090 | 0 |
|
||||
|
||||
Оснастка P2/P6 стоит 756 тактов на кадр — замеры до и после сопоставимы.
|
||||
|
||||
Циан не изменился (+6 тактов — шум), и это ожидаемо: `loose_tick` целиком
|
||||
лежит в зелёной. Синяя просела на 7 374 без прямой причины в правке —
|
||||
скорее всего перераскладка кода банка 3 компилятором; проверять отдельно
|
||||
не стали, знак верный.
|
||||
|
||||
## 7. ТЯЖЁЛАЯ позиция: Кид на шаг правее [замер 2026-08-19]
|
||||
|
||||
Поставлена пользователем: один осторожный шаг вправо (x = 106 вместо 99,
|
||||
колонка та же). Спрайт Кида начинает пересекаться с тайлом (0,3), где
|
||||
одновременно чомпер и пламя факела — и `skip_mask` перестаёт его
|
||||
пропускать.
|
||||
|
||||
| фаза | лёгкая | **тяжёлая** | разница |
|
||||
|---|---:|---:|---:|
|
||||
| синяя | 259 500 | 257 520 | −1 980 |
|
||||
| зелёная (медиана) | 181 068 | 180 870 | −198 |
|
||||
| **циан** | 187 761 | **319 842** | **+132 081** |
|
||||
| **работа (медиана)** | 628 542 | **758 358** | **+129 816** |
|
||||
| работа (максимум) | 744 384 | **876 612** | |
|
||||
|
||||
**Период кадра: 4 растра в 335 кадрах, 5 растров в 123 (27 %).** Это уже
|
||||
не «стабильно медленно», а рывки: каждый четвёртый кадр длиннее соседних.
|
||||
|
||||
Разбор циана показывает, куда ушли 132 тысячи:
|
||||
|
||||
| участок | лёгкая | тяжёлая |
|
||||
|---|---:|---:|
|
||||
| `check_mirror` | 3 198 | 3 198 |
|
||||
| `mob_draw` + `guard_over_kid` + `skip_mask` | 34 374 | 18 672 |
|
||||
| **`pop_char_draw(KID)`** | **204** | **54 738** |
|
||||
| соперник: `char_draw` + `char_fore` | 145 896 | 149 748 |
|
||||
| **`fore_needed` + `char_fore(KID)` + борта** | **4 356** | **93 486** |
|
||||
|
||||
То есть Кид из «пропущен за 204 такта» превращается в полноценного
|
||||
персонажа за ~144 000 — ровно столько же, сколько стоит страж.
|
||||
|
||||
**Главный вывод замера: самая дорогая единичная статья кадра — это
|
||||
fore-проход персонажа.** 62 778 у стража и ~89 000 у Кида, вместе около
|
||||
**152 000, то есть 20 % работы кадра**. У Кида он дороже потому, что в его
|
||||
футпринте лежит чомпер, а у чомпера есть собственный передний слой
|
||||
(`POP_CHOMP_FRAM_FOR`), который перерисовывается поверх персонажа каждый
|
||||
кадр.
|
||||
|
||||
## 8. После P15 (точная метка «фон трогали»)
|
||||
|
||||
| фаза | лёгкая до | лёгкая после | тяжёлая до | тяжёлая после |
|
||||
|---|---:|---:|---:|---:|
|
||||
| синяя | 259 050 | **223 902** | 257 520 | **245 808** |
|
||||
| зелёная | 181 494 | 181 494 | 180 870 | 181 761 |
|
||||
| циан | 194 262 | **59 406** | 319 842 | **192 090** |
|
||||
| **работа** | 628 542 | **464 796** | 758 358 | **617 487** |
|
||||
|
||||
В лёгкой позиции не рисуется НИ ОДИН персонаж (циан 59 406 — это уже только
|
||||
`check_mirror`, проверки и передний слой по пометкам). В тяжёлой рисуется
|
||||
один Кид: он действительно стоит под пламенем, а страж — нет.
|
||||
|
||||
**Пятирастровые кадры в тяжёлой позиции исчезли** (было 27 %), период стал
|
||||
ровно 4.
|
||||
|
||||
**Полная очередь оптимизаций с оценками — [`perf_registry.md`](perf_registry.md).**
|
||||
Там же разложена цена одного блита фона по этапам (замер 2026-08-19, 1603
|
||||
блита) и модель зелёной фазы этой сцены.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Сцена и метод замера: каскад плит, уровень 13 комната 23
|
||||
|
||||
Общий документ для двух фазовых: [`perf_green_phase.md`](perf_green_phase.md)
|
||||
(слой фона) и [`perf_cyan_phase.md`](perf_cyan_phase.md) (персонажи + передний
|
||||
слой). Здесь — как воспроизвести сцену, чем мерить, сводка по кадрам и
|
||||
разбор габаритов спрайтов (он общий для обеих фаз).
|
||||
|
||||
Сцена: старт уровня 13. Комната 23 стартовая, ряд 2 комнаты СВЕРХУ (17) —
|
||||
шесть loose-плит в колонках 2..7 (`res2013.bin`: коды `11` в позициях 22..27),
|
||||
`check_fall_flo` раздаёт им отложенный старт `0xF0..0xFF`, и они сыплются
|
||||
вразнобой. Кид стоит у правого края и не двигается.
|
||||
|
||||
Все числа — такты `totalcycles` MAME (системный клок ~21,5 МГц, **НЕ** такты
|
||||
Z80: у ОЗУ Sprinter wait-state'ы, ≈2,4× номинала — memory
|
||||
`sprinter_wait_states_2x`). **Растровый кадр = 430 000.** Логический кадр
|
||||
спейсится тремя `gfx_wait_vsync`, поэтому работа сверх 430 000 стоит сразу
|
||||
целый лишний растровый кадр.
|
||||
|
||||
Сборка: `make LEVEL=13` на `c312e4a`, `_CODE = 0x4100`, база модуля
|
||||
`sprpop.c` = **0x42AD**. **Адреса зондов меняются после КАЖДОЙ
|
||||
пересборки** — брать заново из `.sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map` и
|
||||
`sprpop.lst`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Как воспроизвести сцену
|
||||
|
||||
**Только перезапуском программы.** Проверено и отвергнуто:
|
||||
|
||||
- **выход из комнаты и возврат** (чит `+`/`-`) — не работает: провалившаяся
|
||||
плита-потолок уходит в страницу уровня насовсем (`pop_level_set_tile` в
|
||||
`sprpop.c` по сигналу `pop_ceil_fell`, плюс `animate_loose` в
|
||||
`pop_trob.c`), и при повторном входе `check_fall_flo` не находит ни одной
|
||||
`TILE_LOOSE`;
|
||||
- **рестарт уровня** (`pop_kid_dead = 1` + чит навигации) — не работает по
|
||||
другой причине: `pop_start_level()` сам заходит в стартовую комнату 23,
|
||||
взводит гряду, и она доваливается ЗАОЧНО (через `trob` комнаты 17), пока
|
||||
телепорт уносит Кида в комнату 24;
|
||||
- **поставить сцену руками** (записать `pop_ceil_modif[2..7]` и копию ряда
|
||||
сверху `pop_t_above[2..7]` отладчиком) — записи ложатся, но пока машина
|
||||
БЕЖИТ, их успевает обнулить тот же доваливающийся `trob`.
|
||||
|
||||
Рабочий рецепт (идея пользователя, самый чистый): **`ESC` → зонды →
|
||||
`SprPoP`**. `ESC` выходит в DSS, запуск заново стартует уровень 13 с нуля,
|
||||
Кид сразу в комнате 23, каскад начинается через ~5 логических кадров после
|
||||
отрисовки комнаты. Зонды обязаны стоять **ДО** набора `SprPoP` — за время
|
||||
набора (9 клавиш ≈ 1,8 с) и загрузки атласов каскад успевает пройти целиком.
|
||||
|
||||
## 2. Канал вывода замеров
|
||||
|
||||
`printf` из действия брейкпоинта в `error.log` **не** попадает. Читается
|
||||
verb'ом **`clog N`** плагина `mamebridge` — а его нет в MCP-обёртке
|
||||
(`mame_mcp.py` знает только `cmd`). Годится прямой файловый IPC:
|
||||
положить `/tmp/mame_mcp/req_<ЧИСЛО>.txt` с телом команды и прочитать
|
||||
`resp_<ЧИСЛО>.txt`. **Имя обязано содержать ЧИСЛО** (`init.lua`:
|
||||
`entry:match("^req_(%d+)%.txt$")`) — с буквенным id запрос молча не
|
||||
обслуживается.
|
||||
|
||||
Скрипты сессии (в scratchpad, при необходимости пересоздать): `mrpc.py` —
|
||||
клиент IPC; `run.sh` — цикл «`bpclear` → `ESC` → зонды → `SprPoP` → `clog`»;
|
||||
`parse*.py` — разбор трассы по кадрам.
|
||||
|
||||
Форма зонда: `bpset <addr>,1,{printf "<метка> %d",totalcycles; g}`.
|
||||
Для `pop_dbg_kind` — `printf "K %d %d",a,totalcycles` (аргумент `uint8_t`
|
||||
приходит в `A`, `__sdcccall(1)`).
|
||||
|
||||
## 3. Зонды
|
||||
|
||||
Адреса `out (_io_border), a` (полосы бордюра) из `sprpop.lst` плюс
|
||||
однобайтовые пустышки `pop_dbg_*` из резидентного `pop_state.c`. Резидент
|
||||
важен принципиально: у банковых функций один адрес 0xC000+ есть у восьми
|
||||
модулей сразу, и брейкпоинт ловит все банки (так в прошлой сессии намерили
|
||||
несуществующие 134 730 тактов).
|
||||
|
||||
| зонд | адрес | что |
|
||||
|---|---|---|
|
||||
| A | 0x43E8 | `PROF(2)` — начало кадра (ввод + heal) |
|
||||
| — | 0x46AF | `PROF(2)` — начало логики |
|
||||
| C | 0x47D1 | `PROF(4)` — начало слоя фона (**зелёная**) |
|
||||
| D | 0x4BCE | `PROF(6)` — начало спрайтов (**циан**) |
|
||||
| M | 0x4C26 | `PROF(6)` — кадр Кида |
|
||||
| F | 0x4C93 | `PROF(6)` — fore поверх Кида |
|
||||
| E | 0x4CB7 | `PROF(0)` — конец работы, ждём vsync |
|
||||
| m5/m6/m7 | 0x4DC6 / C7 / C8 | границы внутри зелёной |
|
||||
| m9..m12 | 0x4DCA..CD | внутренности `pop_loose_tick` |
|
||||
| m13/m14/m15 | 0x4DCE / CF / D0 | `pop_ceil_shake_draw`: вход / heal / draw_tile |
|
||||
| kind / m16 | 0x4DD1 / D2 | вид и цена одной перерисовки в `pop_redraw_needed` |
|
||||
| b1..b5 | 0x4DD3..D7 | участки одного `pop_blit_b` |
|
||||
|
||||
Полезные адреса состояния (из `sprpop.map`): `pop_t_room` 0x95F2,
|
||||
`pop_current_level` 0x9945, `pop_ceil_modif` 0x9C9E, `pop_t_above` 0x95EE
|
||||
(указатель), `pop_kid_dead` 0x9C4E, `pop_dbg_rdmax` 0x95B1.
|
||||
Запись в память через MCP — по адресу `0x10000 | addr` (логический вид Z80);
|
||||
присваивание выражением дебаггера (`print b@... = 1`) **не работает**.
|
||||
|
||||
**Грабли:** проверять, что запущен РОВНО ОДИН MAME (`pgrep -f mame.arm | wc -l`).
|
||||
Мост говорит с одним, замеры собираются с другого, и точки «не срабатывают».
|
||||
|
||||
---
|
||||
|
||||
## 4. Сводка по кадрам
|
||||
|
||||
**Базовый замер (`c312e4a`, ДО оптимизации):**
|
||||
|
||||
| фаза каскада | работа | синяя | зелёная | циан | период (растр.) |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| покой в комнате 23 | 190 860 | 134 550 | 35 760 | 20 550 | **3** |
|
||||
| дрожат 6 плит | 537 400 | 142 700 | 366 000 | 28 700 | **4** |
|
||||
| провалы + полёт, ПИК | **1 437 150** | 142 700 | **663 250** | **631 200** | **5–6** |
|
||||
| максимум по секции | | 142 830 | **805 000** | **631 800** | |
|
||||
|
||||
**После оптимизации (`af189a1`, 2026-08-17):**
|
||||
|
||||
| максимум по секции | работа | синяя | зелёная | циан |
|
||||
|---|---:|---:|---:|---:|
|
||||
| было | 1 437 150 | 142 830 | 805 000 | 631 800 |
|
||||
| стало | **916 458** | 142 830 | **546 900** | **270 510** |
|
||||
| | −36 % | — | −32 % | −57 % |
|
||||
|
||||
**Регресс после обхода уровней 1-2 (`ec1f384`, 2026-08-17), 418 кадров:**
|
||||
|
||||
| максимум по секции | работа | синяя | зелёная | циан |
|
||||
|---|---:|---:|---:|---:|
|
||||
| после оптимизации (`af189a1`) | 916 458 | 142 830 | 546 900 | 270 510 |
|
||||
| после фиксов ур. 1 (`40f0d46`) | 873 930 | 158 874 | 417 630 | 379 482 |
|
||||
| **после фиксов ур. 2 (`ec1f384`)** | **878 550** | **158 880** | **419 526** | **379 488** |
|
||||
|
||||
Фиксы второго уровня (чёрные бары, чит бессмертия) на бюджет не повлияли:
|
||||
разница с предыдущим замером +4 620 работы и +1 896 зелёной — шум прогона.
|
||||
|
||||
**Регресс после обхода уровней 3-7 (`3bcaf51`, 2026-08-18), 2435 кадров:**
|
||||
|
||||
| максимум по секции | работа | синяя | зелёная | циан |
|
||||
|---|---:|---:|---:|---:|
|
||||
| после фиксов ур. 2 (`ec1f384`) | 878 550 | 158 880 | 419 526 | 379 488 |
|
||||
| **после фиксов ур. 3-7 (`3bcaf51`)** | **880 170** | **159 774** | **419 520** | **380 244** |
|
||||
| разница | +1 620 | +894 | −6 | +756 |
|
||||
| | +0,2 % | +0,6 % | 0,0 % | +0,2 % |
|
||||
|
||||
Все четыре секции — в пределах шума прогона (сравнить с +4 620 / +1 896
|
||||
выше, которые уже признаны шумом). Зелёная совпала с точностью до 6 тактов.
|
||||
|
||||
Что за это время добавилось в горячий путь: `pop_spike_frame` и
|
||||
`pop_chomp_pose` (SPIKE-BAKED) — один резидентный `call` на слой и ТОЛЬКО на
|
||||
тайлах-ловушках, в этой комнате их нет; и снятие раннего выхода для трупа
|
||||
(DIED-ON-BUTTON) — цепочка физики на мёртвом Киде, а он тут жив. Замер это
|
||||
подтверждает: цена не сдвинулась.
|
||||
|
||||
Распределение периода тоже совпало с эталоном кадр в кадр: **3 растра в
|
||||
2408 кадрах, 4 в 24, 5 в одном** — против «3 в 392, 4 в 24, 5 в одном»
|
||||
у `ec1f384` (кадров в этом прогоне больше просто потому, что дольше стояли в
|
||||
покое после каскада). То есть за бюджет вылезает ровно тот же кусок сцены и
|
||||
ровно на столько же кадров.
|
||||
|
||||
**Регресс после обхода уровней 8-9 (`0dd2f6a`, 2026-08-18), 2701 кадр:**
|
||||
|
||||
| максимум по секции | работа | синяя | зелёная | циан |
|
||||
|---|---:|---:|---:|---:|
|
||||
| после фиксов ур. 3-7 (`3bcaf51`) | 880 170 | 159 774 | 419 520 | 380 244 |
|
||||
| **после фиксов ур. 8-9** | **880 272** | **159 822** | **419 562** | **380 202** |
|
||||
| разница | +102 | +48 | +42 | −42 |
|
||||
|
||||
Разброс ±100 тактов на 880 000 — это 0,01 %, то есть чистый шум прогона
|
||||
(циан вообще ушёл в минус). Период снова совпал кадр в кадр: 4 растра в
|
||||
24 кадрах, 5 в одном.
|
||||
|
||||
Что добавилось за это время и почему не подорожало: фиксы стража
|
||||
(`c40ae3f`) правят только вход в комнату — кода в кадре не прибавилось; фикс
|
||||
боя у шва (`a498255`) добавил два сравнения в `check_leave`, а в этой сцене
|
||||
Кид неподвижен и до порогов не доходит. Отладочная трасса `DBG_KIDOBJ`
|
||||
выключена дефайном и в сборку не попадает.
|
||||
Скачок циан на фиксах ПЕРВОГО уровня (270 510 → 379 482) объяснён там же:
|
||||
восстановлены потерянные половины слоёв (`set_redraw2`, ряд −1 foretable), то
|
||||
есть это плата за корректность, а не регрессия.
|
||||
|
||||
Период кадра по прогону: **3 растра в 392 кадрах, 4 в 24, 5 в одном** — то
|
||||
есть за бюджет вылезает только сам каскад.
|
||||
|
||||
Цель — каждая секция ≤ 400 000. **Синяя и циан в бюджете**; зелёная 419 526,
|
||||
то есть 1,05× цели (и ниже растрового кадра 430 000), остаток разобран в
|
||||
[`perf_green_phase.md`](perf_green_phase.md) §3 (нужен раскол `draw_tile` на
|
||||
узкие части, как в оригинале) и в идее G8 (сузить инвалидацию соседнего тайла
|
||||
до 28-пиксельной полосы).
|
||||
|
||||
Где что расходуется и как это чинить — в фазовых документах:
|
||||
[зелёная](perf_green_phase.md), [циан](perf_cyan_phase.md).
|
||||
|
||||
### Общий вывод по пиковому кадру базового замера (1 437 150)
|
||||
|
||||
| | такты | доля |
|
||||
|---|---:|---:|
|
||||
| блиты (все 27–28 вызовов `pop_blit_b`) | 488 100 | 34 % |
|
||||
| из них «железный» минимум пикселей (модель `198*h + 5,96*w*h`) | ~150 000 | 10 % |
|
||||
| синяя (ввод + heal + логика) | 142 700 | 10 % |
|
||||
| **наши накладные: `draw_tile`, IX-кадры, диспетчер, пометки** | **~1 150 000** | **~80 %** |
|
||||
|
||||
Узкое место — НЕ передача пикселей (она на пределе железа, 3+3 такта на байт,
|
||||
memory `blit_cost_model`), а 16-битная арифметика в стековых кадрах.
|
||||
|
||||
---
|
||||
|
||||
## 5. Габариты спрайтов: можно ли всё перевести на `uint8_t`
|
||||
|
||||
Просканированы каталоги ВСЕХ `.atl` (109 файлов) и исходные PNG наборов
|
||||
`TITLE`/`PV` — тех, что понадобятся для интро, финала и роликов между
|
||||
уровнями.
|
||||
|
||||
**Игровой кадр — весь укладывается в байт:**
|
||||
|
||||
| набор | максимум |
|
||||
|---|---|
|
||||
| фон подземелья/дворца (`*_env*`, `*_wall`, `*_fore`, `pop_pot`) | **48 × 63** |
|
||||
| Кид (`kid0..27`, `sword`) | **56 × 63** (kid3, idx 0) |
|
||||
| страж / скелет / Джафар | **53 × 42** |
|
||||
| спрайты комнаты принцессы (`PV.DAT`: персонажи, песочные часы, факел, звёзды) | **49 × 60** |
|
||||
|
||||
**Больше 255 — только полноэкранные подложки титров и сюжетных экранов.**
|
||||
Их восемь, и все рисуются ОДИН раз при показе экрана:
|
||||
|
||||
| ресурс | размер | где (`data.h`, `full_image[]`) |
|
||||
|---|---|---|
|
||||
| `TITLE/res51` | 320 × 200 | `TITLE_MAIN`, xpos 0 ypos 0 |
|
||||
| `TITLE/res41` | 320 × 200 | `STORY_FRAME`, xpos 0 ypos 0 |
|
||||
| `PV/res951` | 320 × 200 | фон комнаты принцессы (`chtab_9_princessbed`) |
|
||||
| `TITLE/res42..res45` | 272 / 267 / 264 / **256** × 134..142 | «presents», «Prince of Persia», «Mechner» |
|
||||
| `TITLE/res54` | 272 × 65 | заголовок Hall of Fame |
|
||||
|
||||
Высота нигде не превышает 200 — в байт лезет. По ширине не лезут ровно эти
|
||||
восемь, и ни одна из них не участвует в игровом кадре.
|
||||
|
||||
**Вывод: горячий путь можно переводить на 8-битные габариты целиком.**
|
||||
Для подложек — решение пользователя (2026-08-17): работу с роликами вынести в
|
||||
отдельный банк с версиями блита под большие спрайты либо звать libbgi напрямую
|
||||
— клип и проверка выхода за экран им не нужны (рисуются в x = 0/24/48/96,
|
||||
заведомо внутри 320×200). Ширина 320 всё равно потребует ДВУХ burst-скобок
|
||||
акселератора на строку — как уже сделано в `pop_vflip`.
|
||||
|
||||
Существующая страховка уже есть и остаётся: `pop_blit_b` уводит кадр с
|
||||
`img[1] | img[3] != 0` на общий путь `blit_b_oversize`.
|
||||
|
||||
**Регресс после дня оптимизации 11/15 (`d0ac4b1`, 2026-08-19), 2367 кадров:**
|
||||
|
||||
| максимум по секции | эталон `mob-order-B-done` | сейчас | разница |
|
||||
|---|---:|---:|---:|
|
||||
| работа | 913 848 | **911 862** | −1 986 |
|
||||
| синяя | 159 810 | **149 106** | −10 704 |
|
||||
| зелёная | 440 418 | **436 494** | −3 924 |
|
||||
| циан | 393 000 | **382 770** | −10 230 |
|
||||
|
||||
Период: **3 растра в 2341 кадре, 4 в 23, 5 в 2** — как в эталоне.
|
||||
|
||||
Почему сумма минусов по фазам не равна минусу по работе: максимумы разных
|
||||
фаз достигаются В РАЗНЫХ КАДРАХ (пик синей — не тот кадр, где пик зелёной),
|
||||
а «работа» здесь — максимум СУММЫ, а не сумма максимумов.
|
||||
|
||||
Что из правок 11/15 сюда дошло: P16 и P2b дали синюю и циан (они про
|
||||
проверки и луч видимости, а те работают в любой сцене), HEAL-WIDTH дал
|
||||
зелёную (плита 64 → 58 на шести heal'ах кадра).
|
||||
|
||||
**Зелёная по-прежнему выше растрового кадра** (436 494 против 430 000).
|
||||
Главный оставшийся кандидат именно для этой сцены — **P9 (G8)**: при
|
||||
падении плиты помечаются ДВА тайла, и соседний перезапекается целиком и
|
||||
повторно (`draw_tile` соседа дважды на одну пометку), хотя потревожены у
|
||||
него только левые 28 пикселей. При шести падающих плитах это умножается
|
||||
на шесть.
|
||||
|
||||
**ВАЖНО ДЛЯ ПРОЦЕССА.** Этот прогон вскрыл регрессию, которую не поймали
|
||||
ни хост-тесты, ни сцена 11/15: гейт `loose_any` (позиция P5) не взводился
|
||||
в `check_fall_flo`, и плиты уровня 13 дрожали, не падая. Сцену 13/23 надо
|
||||
прогонять после КАЖДОЙ правки loose-механики, а не только когда меняешь её
|
||||
сознательно.
|
||||
@@ -0,0 +1,871 @@
|
||||
# Реестр оптимизаций: всё отложенное, в одном списке
|
||||
|
||||
Собрано 2026-08-19 из [`perf_green_phase.md`](perf_green_phase.md) (G1-G9),
|
||||
[`perf_cyan_phase.md`](perf_cyan_phase.md) (C1-C7),
|
||||
[`perf_backlog.md`](perf_backlog.md) (позиции 1-7),
|
||||
[`../TASKS_OPEN.md`](TASKS_OPEN.md) (HEAL-WIDTH) и из
|
||||
свежего разбора сцены [`perf_l11_room15.md`](perf_l11_room15.md).
|
||||
|
||||
**База для процентов — работа кадра в 11/15: 801 768 тактов** (замер
|
||||
`09f32ce`). Где эффект относится к другой сцене, это сказано явно.
|
||||
|
||||
Оценки помечены: **[замер]** — измерено; **[модель]** — посчитано по
|
||||
измеренным составляющим; **[гипотеза]** — не мерено, нужен прогон.
|
||||
|
||||
---
|
||||
|
||||
## 1. Цена одного блита фона — разложена [замер 2026-08-19]
|
||||
|
||||
Метод: брейкпоинты на резидентных адресах внутри `pop_blit_b` (0x5C50),
|
||||
`temp0` на входе, разница `totalcycles` на каждом вызове. 1603 блита.
|
||||
|
||||
| этап | такты | постоянство |
|
||||
|---|---:|---|
|
||||
| пролог + аргументы + грубый отсев | **810** | ровно, всегда |
|
||||
| `atlas_image` | **672** | ровно, всегда |
|
||||
| `gfx_w0_map` + чтение шапки ленты + арифметика клипа | **2 400** | ровно, всегда |
|
||||
| ядро блита (пиксели) | 4 380 … 26 592 | по размеру кадра |
|
||||
| `pop_cd_touch` | **2 069** (пакетный путь) / 4 115 (настоящая пометка) | почти ровно |
|
||||
| `gfx_w0_unmap` + эпилог | **175** | ровно, всегда |
|
||||
| **весь блит** | 10 458 … 32 718, медиана **16 674** | |
|
||||
|
||||
**Фиксированная накладная = 6 126 тактов на любой блит, хоть 8×8.**
|
||||
У самого дешёвого блита (10 458) это **59 % цены**; у пламени факела 16×18
|
||||
пиксели тянут ~1 700 из ~14 000, то есть **12 %**.
|
||||
|
||||
Это и есть ответ на вопрос «почему маленький блит стоит 14 000». Причины
|
||||
ровно те, о которых спрашивал пользователь:
|
||||
|
||||
- **810 на пролог** — `call ___sdcc_enter_ix`, IX-фрейм и шесть чтений
|
||||
`N(ix)`: третий и четвёртый аргументы (`int x`, `int ybottom`) идут
|
||||
СТЕКОМ, каждое обращение 19 тактов Z80;
|
||||
- **2 069 на `pop_cd_touch`** даже по пакетному пути, где вся работа — четыре
|
||||
сравнения. Сигнатура `(int x, int y, int w, int h)` = 8 байт аргументов,
|
||||
два из них через стек; `w`/`h` никогда не больше 64, `y` не больше 255,
|
||||
то есть три из четырёх могли быть `uint8_t`;
|
||||
- **2 400 на map + шапку** — `gfx_w0_map` (1 086) + `unmap` (264, платится в
|
||||
конце) + ~1 000 на чтение четырёх байт заголовка и арифметику;
|
||||
- **672 на `atlas_image`** — маппинг W3, чтение записи каталога, возврат W3;
|
||||
размеры при этом читаются и выбрасываются (см. §3, C6).
|
||||
|
||||
**Блитов за кадр в 11/15: 8** [замер] — все в зелёной фазе (6 на `draw_tile`
|
||||
чомпера, 2 на пламя факелов). Синяя и циан через `pop_blit_b` не ходят
|
||||
вовсе: heal и персонажи идут своими путями. Значит фиксированные накладные
|
||||
блита стоят сцене **8 × 6 126 = 49 000 тактов/кадр (6,1 % работы)**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Модель зелёной фазы 11/15 [модель, сходится с 320 916 замера]
|
||||
|
||||
| статья | такты | доля фазы |
|
||||
|---|---:|---:|
|
||||
| 8 блитов фона (из них 6 126×8 = 49 000 накладных) | 133 400 | 41 % |
|
||||
| диспетчер `draw_tile` (один тайл чомпера) | 56 200 | 18 % |
|
||||
| цикл `pop_process_trobs` без блитов пламени | 59 100 | 18 % |
|
||||
| `pop_loose_tick` (плит в комнате НЕТ) | 28 872 | 9 % |
|
||||
| heal 32×64 в `pop_chomp_redraw` | 34 000 | 11 % |
|
||||
| шов / ворота соседа | 9 336 | 3 % |
|
||||
|
||||
Больше половины фазы — не пиксели, а обвязка вокруг них.
|
||||
|
||||
---
|
||||
|
||||
## 3. Список приёмов, отсортированный по эффекту
|
||||
|
||||
### P1. Чомпер: перерисовка неизменной позы ✅ СДЕЛАНО 2026-08-19 — −110 802
|
||||
|
||||
**Диагноз, с которым позиция заводилась, оказался неполным.** Я приписал
|
||||
190 260 тактов пометке от факела; зонд `pop_dbg_kind` показал, что все 312
|
||||
перерисовок прогона — вид `POP_RD_CHOMP` (полная), а пометку соседа она
|
||||
просто перебивала. Настоящая причина нашлась сверкой с `animate_chomper`
|
||||
(seg007:0448): оригинал перерисовывает чомпер **только при фазе < 6**, пять
|
||||
кадров из пятнадцати, потому что с фазы 5 поза не меняется
|
||||
(`chomper_fram1 = {3,2,0,1,4,3,3}`). Мы метили тайл каждый кадр.
|
||||
|
||||
Сделано три вещи: условие фазы (с пометкой обеих страниц на фазе 5), новый
|
||||
вид `POP_RD_CHOMP_ANIM` → `pop_chomp_anim_draw` (три блита поверх огня, порт
|
||||
ветки `redraw_frames_anim`) и приоритет полной перерисовки над anim в
|
||||
`pop_set_redraw`. Обе половины работают: замер даёт 40 % полных
|
||||
перерисовок и 60 % лёгких.
|
||||
|
||||
Работа 768 684 → **657 882** (медиана), зелёная 294 510 → **183 420**.
|
||||
В 40 % кадров цена осталась прежней — там поза действительно меняется, и это
|
||||
уже честная работа; резать её дальше только через P7 (раскол `draw_tile`)
|
||||
или P8 (ширина heal).
|
||||
|
||||
Полный разбор — [`perf_l11_room15.md`](perf_l11_room15.md) §6.
|
||||
|
||||
<details><summary>Исходная (неполная) постановка</summary>
|
||||
|
||||
Разбор в [`perf_l11_room15.md`](perf_l11_room15.md) §3. Сейчас пометка от
|
||||
факела обрабатывается как `heal 32×64 + полный draw_tile` (190 260 тактов на
|
||||
единственный перерисованный тайл кадра); оригинал в этом случае рисует
|
||||
ТОЛЬКО `draw_tile_anim` — графику чомпера поверх свежего пламени.
|
||||
|
||||
Останется 1-2 блита челюстей ≈ 20 000-33 000. **Риск низкий**: это
|
||||
сближение с оригиналом, а не отход от него. Побочно снимает широкую пометку
|
||||
«фон трогали» вокруг тайла чомпера — см. P3.
|
||||
|
||||
</details>
|
||||
|
||||
### P2. Синяя фаза разложена [ЗАМЕР 2026-08-19] — гипотеза не подтвердилась
|
||||
|
||||
Замер зондами внутрь обеих половин синей (286 518 тактов):
|
||||
|
||||
| участок | такты | доля работы |
|
||||
|---|---:|---:|
|
||||
| ввод + читы | 14 154 | 1,8 % |
|
||||
| три спецсобытия уровней (skel / mouse / killed_shadow) | **1 650** | 0,2 % |
|
||||
| `pop_frame_timers` + **луч видимости стража** | **37 032** | 4,8 % |
|
||||
| `pop_ctrl_tick` | 18 648 | 2,4 % |
|
||||
| `skip_mask` + **heal двух Char** | **67 734** | 8,8 % |
|
||||
| `mirror_heal` + `fore_heal` | 2 040 | 0,3 % |
|
||||
| `kid_tick` (play_seq) | 10 098 | 1,3 % |
|
||||
| **`pop_phys_tick`** (физика Кида) | **61 266** | 8,0 % |
|
||||
| `pop_guard_tick` (логика стража) | 19 932 | 2,6 % |
|
||||
| **`pop_guard_phys_tick`** (физика стража) | **43 980** | 5,7 % |
|
||||
| боёвка (`sword_hurting` / `sword_hurt` / `delta_hp`) | 9 558 | 1,2 % |
|
||||
| `guard_fallout` + уход из комнаты | 366 | — |
|
||||
|
||||
**Что оказалось не так, как ждали.** Я предполагал, что дорогие тут
|
||||
банковые трамплины на спецсобытиях (по аналогии с `pop_clip_char_top`,
|
||||
8 892 такта за трамплин ради одной проверки). Замер это отверг: три
|
||||
спецсобытия уровней вместе стоят **1 650** — они гейтятся внутри и на
|
||||
уровне 11 честно выходят сразу.
|
||||
|
||||
**Настоящие статьи — три:**
|
||||
|
||||
1. **Физика двух Char — 105 246** (61 266 + 43 980), при том что оба
|
||||
персонажа СТОЯТ и кадр позы не меняется. Это 13,7 % работы кадра и
|
||||
самая крупная статья синей. Нужен ещё один уровень разбора — внутрь
|
||||
`pop_phys_tick` (позиция **P2a**, отдельным заходом).
|
||||
2. **Луч видимости стража — до 37 032** (вместе с `pop_frame_timers`, но тот
|
||||
заведомо копеечный: три счётчика). Считается КАЖДЫЙ кадр, хотя ни Кид,
|
||||
ни страж не сдвинулись. Кандидат на гейт «пересчитывать только при
|
||||
смене позиции или комнаты любого из двоих» — позиция **P2b**,
|
||||
ожидание −30 000, риск низкий.
|
||||
3. **heal двух Char — 67 734.** Отдельной правки не требует: он платится
|
||||
ровно потому, что `skip_mask` никого не пропускает, и уйдёт вместе с
|
||||
P1/P3.
|
||||
|
||||
Новое, найдено 2026-08-19.
|
||||
|
||||
### P15. Точность метки «фон трогали» ✅ СДЕЛАНО 2026-08-19 — −163 746 / −140 871
|
||||
|
||||
Постановка пользователя: не перерисовывать стража, пока он не двигается.
|
||||
|
||||
**Две правки, и вторая оказалась решающей:**
|
||||
|
||||
1. **метка**: вместо «маска колонок × три ряда по 63 px» — диапазон y на
|
||||
каждую колонку (`ymin`/`ymax`, 40 байт на обе страницы). Прежняя
|
||||
гранулярность склеивала пламя факела (y 33..50) с клинком стоящего
|
||||
стража (y 59..65), между которыми девять пикселей зазора;
|
||||
2. **проверка**: `cd_quiet` сверяет спрайт и накладной (клинок, брызги)
|
||||
ДВУМЯ отдельными прямоугольниками вместо объединённого bbox.
|
||||
Объединение включает пустой угол: спрайт стража в колонке 8, клинок
|
||||
уходит в колонку 7 на y 59..65, пламя метит колонку 7 на y 33..50 —
|
||||
и прямоугольник «спрайт + клинок» цеплял метку углом.
|
||||
|
||||
**Без второй правки первая дала почти ноль** (632 676 против 628 542 до
|
||||
неё) — это стоит помнить: точность структуры бесполезна, пока запрос к ней
|
||||
остаётся грубым.
|
||||
|
||||
| | лёгкая позиция | тяжёлая позиция |
|
||||
|---|---:|---:|
|
||||
| синяя | 259 050 → **223 902** | 257 520 → **245 808** |
|
||||
| зелёная | 181 494 → 181 494 | 180 870 → 181 761 |
|
||||
| циан | 194 262 → **59 406** | 319 842 → **192 090** |
|
||||
| **работа** | 628 542 → **464 796** | 758 358 → **617 487** |
|
||||
|
||||
В тяжёлой позиции вдобавок исчезли пятирастровые кадры (было 27 %).
|
||||
|
||||
Проверено в MAME: статика чистая, динамика (пробежка, бой, переход в
|
||||
соседнюю комнату) без хвостов и просвечивания; хост-тесты зелёные.
|
||||
|
||||
Побочно исправлены два собственных дефекта первой редакции: обе страницы
|
||||
обновлялись по условию, проверяющему только страницу 0 (после
|
||||
`pop_cd_clear(0)` метка второй переставала расти), и отсутствовала явная
|
||||
инициализация — пустая колонка обозначается `ymin = 255`, а нули от crt0
|
||||
читались бы как «затронута строка 0».
|
||||
|
||||
### P16. Цианные проверки ✅ СДЕЛАНО 2026-08-19 — −25 818
|
||||
|
||||
Раскладка остатка цианной фазы (59 406) показала, что 47 883 из них — три
|
||||
вызова, а не отрисовка:
|
||||
|
||||
| вызов | было | стало |
|
||||
|---|---:|---:|
|
||||
| `pop_loose_mob_draw` | 978 | 978 (гейт `mobs_live` работает) |
|
||||
| `guard_over_kid` | 14 424 | **0** |
|
||||
| `pop_char_skip_mask` | 28 605 | ~21 000 |
|
||||
|
||||
Три правки:
|
||||
|
||||
1. **`cd_sig_same`** — сравнение снимка БЕЗ построения структуры.
|
||||
`cd_sig_make` записывал тринадцать полей в стековый кадр (через
|
||||
`-n(ix)`), и лишь потом шёл побайтовый цикл; теперь сравнение идёт прямо
|
||||
с источником и выходит на первом расхождении. **−8 016**;
|
||||
2. **`guard_over_kid` по условию** — вопрос «кто поверх кого» не имеет
|
||||
смысла, когда не рисуется никто. Вызов перенесён ПОСЛЕ `skip_mask` и
|
||||
идёт только при `skip != 3`. **−14 118**;
|
||||
3. **`pop_cd_hit_slot`** — проверка «задет ли слот» брала пять аргументов,
|
||||
три из них стеком (45 % тактов на IX). Теперь координаты берутся из
|
||||
`pop_cd`, а сравнение вынесено в `hit_rect` с file-scope аргументами.
|
||||
**−3 684**.
|
||||
|
||||
**Отрицательный результат внутри третьей правки** (не повторять): первая
|
||||
версия была обёрткой, которая внутри всё равно звала `pop_cd_hit` с пятью
|
||||
аргументами — стало ХУЖЕ (1799 тактов Z80 вместо 1318). Снимать аргументы
|
||||
со стека надо у того, кто их читает, а не этажом выше.
|
||||
|
||||
### Отрицательные результаты 2026-08-19 — НЕ ПОВТОРЯТЬ
|
||||
|
||||
Три попытки подряд сделали ХУЖЕ. Общая ошибка в двух из них — я оценивал
|
||||
правку по СУММЕ ТАКТОВ ИНСТРУКЦИЙ в листинге, а не по реально исполняемому
|
||||
пути.
|
||||
|
||||
**1. `cd_sig_same` блоком вместо тринадцати сравнений.** Снимок был
|
||||
переложен так, чтобы сравнивать непрерывные 10 байт начала `pop_char_t`
|
||||
циклом `do { if (*a++ != *b++) return 0; } while (--i)`. По листингу
|
||||
функция стала короче (1939 → 1290 тактов), а на машине **стало хуже:
|
||||
438 978 → 450 426 (+11 448)**.
|
||||
|
||||
Причина: сумма по листингу считает каждую инструкцию ОДИН раз, а тело
|
||||
цикла исполняется ДЕСЯТЬ раз. Тринадцать линейных сравнений выполняются по
|
||||
разу каждое и выходят раньше на первом же расхождении. **Урок: короткий
|
||||
листинг ≠ быстрый код; цикл надо разворачивать в уме.**
|
||||
|
||||
**2. `cd_touch_pb` — пометка «для блита» из file-scope.** `pop_cd_touch`
|
||||
зовётся из `pop_blit_b` с четырьмя аргументами, хотя тот держит те же
|
||||
значения в `pb_x`/`pb_top`/`pb_w`/`pb_h`. Специализированный вход без
|
||||
аргументов дал **438 978 → 442 242 (+3 264)**.
|
||||
|
||||
Причина: в зелёной фазе блиты идут ПАКЕТНЫМ путём (`draw_tile` открывает
|
||||
`pop_cd_batch`), а там нужны все четыре значения сразу — и в регистрах
|
||||
(`x`, `y` приходят в HL/DE) они дешевле, чем чтение из статиков.
|
||||
**Снятие аргументов со стека помогает не всегда: если значение и так живёт
|
||||
в регистре, статик его туда ещё и загружать заставит.**
|
||||
|
||||
**3. Обёртка `pop_cd_hit_slot` поверх `pop_cd_hit`** (описана в P16):
|
||||
внутри всё равно звала функцию с пятью аргументами и добавила свои — стало
|
||||
1799 тактов вместо 1318. Помогло только когда сравнение переехало внутрь.
|
||||
|
||||
### P14. Fore-проход персонажа — от 4 122 до 117 570 [замеры 2026-08-19]
|
||||
|
||||
**Самая НЕСТАБИЛЬНАЯ статья кадра.** Замеры на одной и той же сцене:
|
||||
|
||||
| ситуация | fore-проход |
|
||||
|---|---:|
|
||||
| персонаж пропущен (`skip`) | 4 122 |
|
||||
| стоящий страж | 62 778 |
|
||||
| живой Кид у чомпера | ~89 000 |
|
||||
| **труп Кида в челюстях** | **117 570** |
|
||||
|
||||
Растёт от двух вещей: ширины футпринта (широкий кадр смерти, вынутый меч
|
||||
добавляет колонку) и числа тайлов с передним слоем внутри футпринта (здесь
|
||||
чомпер со своими зубьями). Отсюда практический вывод: **в бою проход будет
|
||||
ближе к сотне тысяч, чем к шестидесяти** — кадры выпадов и ударов широкие.
|
||||
|
||||
Замер трупа сделан по просьбе пользователя. Сама по себе эта ситуация не
|
||||
игровая («когда Кид — труп, игры нет»), но именно она показала верхнюю
|
||||
границу цены.
|
||||
|
||||
**РАЗБОР 2026-08-19: P14 сводится к P4.** Fore-проход Кида в тяжёлой
|
||||
позиции (89 268) разложен зондами:
|
||||
|
||||
| участок | такты |
|
||||
|---|---:|
|
||||
| вход + `pop_fore_set_clip` + `char_footprint` | 10 872 |
|
||||
| арифметика границ окна | 3 786 |
|
||||
| шов ворот + overlay-цикл | 3 294 |
|
||||
| **цикл `fore_tile` по тайлам** | **67 854** (76 %) |
|
||||
| `pop_gate_over_char` + хвост | 3 462 |
|
||||
|
||||
А счётчик показал, что цикл обходит **всего 4 тайла**, и 3 из них реально
|
||||
рисуют (`FORE_ANY != 0`). То есть 67 854 — это НЕ перебор лишних тайлов
|
||||
(их четыре) и не проверки, а **цена самих блитов переднего слоя**: около
|
||||
четырёх блитов по ~16 000, из которых 6 765 на каждом — фиксированная
|
||||
накладная (см. §1).
|
||||
|
||||
**Отсюда вывод для плана:** отдельной «оптимизации fore-прохода» почти нет.
|
||||
Срезать там можно ровно три вещи, и только первая крупная:
|
||||
|
||||
1. **цену блита (P4)** — 4 блита × 6 765 накладных = ~27 000 из 67 854;
|
||||
2. `char_footprint` из физики (**P10**) — часть от 10 872;
|
||||
3. слияние двух трамплинов в банк 2 — ~4 000.
|
||||
|
||||
Иначе говоря, **P4 ускоряет и зелёную фазу (8 блитов), и fore-проход
|
||||
(4 блита), то есть работает и в статике, и в динамике** — в отличие от
|
||||
P14, который я считал самостоятельной позицией.
|
||||
|
||||
`pop_char_fore` = два трамплина в банк 2 (`pop_fore_set_clip` +
|
||||
`pop_fore_over_char`) плюс обход тайлов футпринта, в каждом `fore_tile`.
|
||||
У Кида дороже, чем у стража, потому что в его футпринте лежит чомпер, а у
|
||||
чомпера есть собственный передний слой (`POP_CHOMP_FRAM_FOR`), который
|
||||
перерисовывается поверх персонажа каждый кадр.
|
||||
|
||||
Что можно пробовать, по возрастанию радикальности:
|
||||
|
||||
1. слить два трамплина в один вызов (мелочь, ~4 000);
|
||||
2. **P10** — брать футпринт из физики, а не считать заново (−11 574 на
|
||||
проход, то есть до −23 000 на двоих);
|
||||
3. гейт по сигнатуре: пропускать проход, если не изменились ни кадр
|
||||
персонажа, ни тайлы его футпринта. **Это расхождение с оригиналом** —
|
||||
он рисует foretable безусловно;
|
||||
4. **P13** — objtable и отложенные таблицы: у оригинала «посетить тайл»
|
||||
стоит копейки именно потому, что таблицы только копят записи.
|
||||
|
||||
### P3. Персонажи будятся каждый кадр ✅ ЧАСТИЧНО СБЫЛОСЬ
|
||||
|
||||
**В лёгкой позиции — да:** после P1 `pop_char_draw(KID)` стоит 204 такта,
|
||||
метка от чомпера до Кида больше не дотягивается.
|
||||
|
||||
**В тяжёлой позиции — нет:** стоит Киду шагнуть на 7 пикселей вправо, и его
|
||||
спрайт пересекается с тайлом чомпера и пламени, `skip_mask` перестаёт
|
||||
пропускать, и он снова стоит ~144 000 (54 738 draw + ~89 000 fore). То
|
||||
есть выигрыш P3 держится только пока персонаж не подошёл к анимированному
|
||||
тайлу — а в игре он к нему подходит постоянно.
|
||||
|
||||
Исходная оценка (−70 000) была:
|
||||
|
||||
heal 141 048 + отрисовка 148 566 = 290 000 тактов (36 % работы) уходят на то,
|
||||
что `pop_char_skip_mask` не может пропустить ни Кида, ни стража: фон трогают
|
||||
каждый кадр.
|
||||
|
||||
- **Кида** спасает P1: широкая пометка вокруг чомпера исчезнет;
|
||||
- **стража спасти нельзя** — пламя правого факела (0,7) рисуется в ячейке
|
||||
(0,8), где он и стоит. Там фон честно меняется, и оригинал персонажа тоже
|
||||
перерисовывает.
|
||||
|
||||
### P17. 16 бит там, где хватает 8 ✅ 2026-08-19 (замечание пользователя)
|
||||
|
||||
**1. Границы экрана — беззнаковыми сравнениями.** Проверка «спрайт целиком
|
||||
на экране» стояла как четыре ЗНАКОВЫХ 16-битных сравнения, а знаковое у
|
||||
SDCC z80 разворачивается в `sbc` плюс `jp PO / xor 0x80 / jp P`.
|
||||
Беззнаковая форма делает то же двумя: отрицательная координата становится
|
||||
очень большой и проваливает условие так же, как `>= 0`. **−378.**
|
||||
|
||||
**2. Габариты спрайтов в байтах.** `w`/`h`, `ow`/`oh`, `fpw`/`fph`,
|
||||
`cw`/`ch` в `pop_cdraw_t`, параметры `cd_overlay_add`/`cd_clip_add`, локали
|
||||
в `pop_char_draw`/`cd_splash` и чтение габарита из шапки ленты были
|
||||
`uint16_t`, хотя спрайты атласов не крупнее 64×64 (memory
|
||||
`pop_sprite_size_limits`). **−276 в статике, −1 134 в циане динамики**,
|
||||
плюс 24 байта `_DATA`.
|
||||
|
||||
### P6a. Кэш указателя модификаторов ✅ 2026-08-19 — −840 (ждали −20 000)
|
||||
|
||||
`pop_trob_modif` объявлен `__banked`, а звался на КАЖДЫЙ trob внутри цикла
|
||||
`pop_process_trobs`, хотя комната у них в подавляющем большинстве кадров
|
||||
одна. Указатель теперь кэшируется между итерациями.
|
||||
|
||||
Цикл trobs 78 726 → **74 964**, работа кадра 438 324 → **437 484**.
|
||||
|
||||
**Оценка в реестре была завышена в двадцать раз**, и стоит понять почему:
|
||||
я перенёс её по аналогии с лучом видимости (P2b), где трамплин звался
|
||||
ДЕВЯТЬ раз за кадр. Здесь trob'ов в комнате всего несколько, и кэш
|
||||
экономит два-три вызова. **Урок: «тот же паттерн» не означает «тот же
|
||||
порядок величины» — считать надо число вызовов, а не узнавать шаблон.**
|
||||
|
||||
### P6b. Кэш префетча кодов тайлов — НЕ ДЕЛАЛОСЬ
|
||||
|
||||
Префетч (`pop_level_access_begin/end` плюс чтение кода на каждый trob)
|
||||
стоит **11 058** за кадр. Кэшировать мешает инвалидация: код тайла меняет
|
||||
`pop_level_set_tile` (кнопка → пол, loose → empty), вход в комнату и
|
||||
добавление trob'а — пропустить хоть один источник значит получить
|
||||
застывшую анимацию. С учётом того, что P6a дал 840 вместо 20 000,
|
||||
ожидаемый выигрыш тут тоже стоит считать скромным, а риск он несёт
|
||||
несоразмерный.
|
||||
|
||||
### P10. Футпринт из физики — РАЗБОР 2026-08-19 (без реализации)
|
||||
|
||||
Идея из `perf_backlog.md` §1: `redraw_at_char` (seg003:0430) берёт ГОТОВЫЕ
|
||||
`char_col_left/right`, `char_top_row`, `char_bottom_row`, посчитанные в том
|
||||
же кадре физикой (`set_char_collision`, seg006:0723), а наш
|
||||
`char_footprint` (pop_bg.c) считает их заново внутри fore-прохода.
|
||||
|
||||
**Разбор показал, что «просто передать» не получится: величины разные.**
|
||||
|
||||
| | `char_footprint` (банк 2, fore) | `set_char_collision` (банк 3, физика) |
|
||||
|---|---|---|
|
||||
| ширина | габарит КАДРА `w` из атласа, `wh = (w+1)/2` | то же `fpw`, но затем **`FRAME_THIN` сдвигает края на ±4** |
|
||||
| меч | расширяет диапазон на колонку (`sword >= DRAWN`) | не расширяет |
|
||||
| колонки | `cLraw` до клампа (нужен для шва), затем кламп 0..9 | `coll_xl`/`coll_xr` в пикселях, колонки считает уже `calc_coll_window` |
|
||||
| ряды | `rT`/`rB` от ВЕРХА и НИЗА спрайта, с форсом `rT = rB-1` | `Char.curr_row` — опорный ряд, это другое |
|
||||
|
||||
То есть у оригинала обе задачи пользуются ОДНИМИ величинами, потому что он
|
||||
считает их один раз в `set_char_collision`. У нас они исторически
|
||||
разошлись: коллизии считают своё окно (с поправкой `FRAME_THIN`), fore —
|
||||
своё (габарит кадра плюс колонка под меч).
|
||||
|
||||
**Значит P10 — это не «передать готовое», а сперва СВЕСТИ обе величины к
|
||||
одной, как в оригинале.** Работа не механическая: `FRAME_THIN` влияет на
|
||||
коллизии осознанно (узкие кадры не должны цеплять стену), а fore-проходу
|
||||
нужен полный габарит, иначе передние грани в крайней колонке не
|
||||
перерисуются.
|
||||
|
||||
**Чего не хватает для решения:** отдельного замера самого
|
||||
`char_footprint`. Сейчас известно только «вход + `pop_fore_set_clip` +
|
||||
`char_footprint` = 10 872», а оценка −11 574 в backlog взята из старого
|
||||
замера другой сборки. Первым шагом нужен зонд между `set_clip` и
|
||||
`char_footprint`.
|
||||
|
||||
**Оценка приоритета:** низкая. Даже если `char_footprint` окажется всеми
|
||||
10 872, он платится только когда персонаж рисуется (в статике fore-прохода
|
||||
нет), а сведение двух геометрий к одной — это риск для коллизий, то есть
|
||||
для физики, которая сейчас работает правильно.
|
||||
|
||||
### P18. Метка «фон трогали» огрублена по X — ОТЛОЖЕНО (решение пользователя)
|
||||
|
||||
**Найдено 2026-08-19 пользователем:** Кид перерисовывается, хотя с пламенем
|
||||
не пересекается; на пиксель левее — перестаёт.
|
||||
|
||||
Разбор по памяти машины. Кид `x = 156`, спрайт занимает **x 213..224**,
|
||||
экранные y 43..83. Метка колонки 7 — y 33..50 (пламя правого факела).
|
||||
Колонка считается как `x >> 5`, то есть по 32 пикселя, и спрайт достаёт до
|
||||
224 — ровно первый пиксель колонки 7. По вертикали пересечение с меткой
|
||||
настоящее (43..50), поэтому слот считается задетым.
|
||||
|
||||
А по горизонтали пересечения НЕТ: пламя лежит в колонке 7 на x 232..247,
|
||||
между ним и Кидом восемь пикселей зазора. На пиксель левее спрайт
|
||||
кончается на 223, `223 >> 5 = 6`, колонка 7 не задета — и перерисовка
|
||||
пропадает.
|
||||
|
||||
То есть P15 исправил огрубление по Y и оставил его по X.
|
||||
|
||||
**Почему отложено (аргументы пользователя):**
|
||||
|
||||
- x лежит в 0..319 и в байт не влезает — нужен `uint16_t` на границу, то
|
||||
есть 4 байта на колонку (80 байт на две страницы), и **16-битные
|
||||
сравнения в горячем пути**. А они у SDCC z80 дороги ровно настолько,
|
||||
что могут съесть весь выигрыш (см. отрицательные результаты выше);
|
||||
- огрубить x вдвое (`x >> 1`, диапазон 0..159 влезает в байт) — это лишний
|
||||
сдвиг и при записи, и при проверке, плюс точность падает до 2 пикселей.
|
||||
|
||||
**Непроверенная идея на будущее:** хранить границы НЕ в экранных x, а как
|
||||
смещение ВНУТРИ колонки (0..31, пять бит). Тогда байта хватает и сравнение
|
||||
8-битное, но запись усложняется: прямоугольник, пересекающий несколько
|
||||
колонок, даёт частичные диапазоны у крайних и полные у средних.
|
||||
|
||||
**Когда браться:** если после других позиций бюджет всё ещё не сойдётся.
|
||||
Выигрыш будет именно в пограничных положениях, а их в игре много —
|
||||
персонаж почти всегда стоит рядом с чем-то анимированным.
|
||||
|
||||
### P4. Накладные блита — ОТКАЧЕНО
|
||||
|
||||
**Правка сделана и отменена по решению пользователя.** Критерий: если
|
||||
выигрыш получен ценой сильно усложнённого кода — откатывать.
|
||||
|
||||
Что было: `atlas_image_w0` в libbgi читал каталог из уже подключённой в W0
|
||||
страницы. **−408 на кадре** при ожидании −5 400.
|
||||
|
||||
Почему откачено: цена — вторая публичная функция в API libbgi с НЕЯВНЫМ
|
||||
контрактом («страница обязана быть подключена до вызова»), которую легко
|
||||
вызвать неправильно и молча получить мусор, плюс дублирование чтения
|
||||
каталога. 408 тактов — 0,09 % кадра, меньше разброса между прогонами.
|
||||
|
||||
**Что осталось знанием:** сам `gfx_w0_map` стоит всего **324** такта, а 672
|
||||
у `atlas_image` — это почти целиком вызов функции и арифметика `idx * 8`.
|
||||
Значит непробованная часть P4 («один map на группу блитов») имеет потолок
|
||||
~2 600 за кадр, а не 10 000, как считалось.
|
||||
|
||||
Ожидание было −5 400 (672 такта × 8 блитов зелёной фазы), и оно НЕ
|
||||
оправдалось: цена блита 16 107 → 16 005, то есть −102. Причина в том, что
|
||||
эти 672 — почти целиком вызов функции и арифметика `idx * 8`, а не само
|
||||
переключение окна. Замер после правки показывает, что работа просто
|
||||
переехала между статьями:
|
||||
|
||||
| этап | до | после |
|
||||
|---|---:|---:|
|
||||
| пролог + отсев | 810 | 762 |
|
||||
| `gfx_w0_map` | (в составе 2 400) | **324** |
|
||||
| каталог + шапка ленты + клип | | **2 694** |
|
||||
| ядро | 8 508 | 8 508 |
|
||||
| `cd_touch` + `unmap` + эпилог | 2 883 | 2 883 |
|
||||
| **фиксированная накладная** | **6 765** | **6 663** |
|
||||
|
||||
Правка оставлена: не вредит, убирает лишнее переключение W3 и делает
|
||||
контракт честнее (страница мапится один раз). Но как способ снять
|
||||
накладные она не работает.
|
||||
|
||||
**Что осталось непробованным** (и во что я теперь верю меньше): один
|
||||
`gfx_w0_map` на ГРУППУ блитов — судя по замеру, сам map стоит 324, так что
|
||||
потолок этой правки ~2 600 за кадр, а не 10 000, как считалось.
|
||||
|
||||
<details><summary>Исходная постановка (модель −28 000)</summary>
|
||||
|
||||
| правка | на блит | источник |
|
||||
|---|---:|---|
|
||||
| `pop_cd_touch`: `uint8_t` вместо `int` для `y`/`w`/`h`, ранний выход пакетного пути | ~−1 300 | новое |
|
||||
| один `gfx_w0_map`/`unmap` на ГРУППУ блитов | ~−1 350 | C5 / backlog §3 |
|
||||
| размеры ленты из каталога, без `atlas_image` и чтения шапки | ~−670 | C6 / backlog §2 |
|
||||
| `pop_blit_b`: аргументы в 8 бит, где хватает | ~−400 | новое |
|
||||
|
||||
Все четыре — низкий риск, механическая работа. Вместе снимают ~3 700 из
|
||||
6 126 фиксированных.
|
||||
|
||||
</details>
|
||||
|
||||
### P5. `pop_loose_tick` при пустой комнате — 28 872 → 2 760 ✅ СДЕЛАНО 2026-08-19
|
||||
|
||||
**Получено −26 112 внутри функции, −33 840 на кадре** (замер до/после в
|
||||
11/15). Оценка была −28 000.
|
||||
|
||||
Раскладка холостого хода (замер зондами m9..m12) и что с ней стало:
|
||||
|
||||
| участок | было | стало |
|
||||
|---|---:|---:|
|
||||
| два цикла по тайлам (30 + 10 позиций) | 9 852 | **132** |
|
||||
| `pop_loose_mob_tick` (обход 14 слотов) | 12 090 | **996** |
|
||||
| `check_loose_fall_on_kid` (трамплин + обход) | 5 868 | **546** |
|
||||
| вход + хвост | 1 062 | 1 086 |
|
||||
| **итого** | **28 872** | **2 760** |
|
||||
|
||||
Сделано двумя гейтами:
|
||||
|
||||
- `loose_any` (статик `pop_map.c`) — «идёт ли анимация плит». Ставится в
|
||||
пяти местах записи ненулевой фазы, снимается САМИМ циклом по факту
|
||||
прохода, где не осталось ни одной живой фазы;
|
||||
- `pop_mob_busy` (резидент `pop_state.c`) — «занят ли хоть один слот
|
||||
падающего куска» (`active` или дочистка `clean`). Ставит `mob_alloc`,
|
||||
снимает обход по факту пустой таблицы. В резиденте, а не в `pop_room.c`,
|
||||
потому что читает его `pop_map` из банка 3.
|
||||
|
||||
**Важно про границу:** гейт отвечает не на «есть ли в комнате плиты», а на
|
||||
«идёт ли анимация». У лежащей плиты-потолка фаза 0, и крутить нечего —
|
||||
вопрос пользователя 2026-08-19. Асимметрия намеренная: ложная единица
|
||||
стоит одного холостого прохода, ложный ноль — застывшей навсегда плиты,
|
||||
поэтому взвод стоит рядом с КАЖДОЙ записью, а снятие только по факту.
|
||||
|
||||
Покрытие: `phys_loose_floor_breaks` (взвод от шага и сотрясения) и новый
|
||||
`phys_loose_gate_survives_room_change` — на пятое место взвода
|
||||
(фаза восстановлена входом в комнату), которое не покрывал никто.
|
||||
Мутационная проверка: со снятым взводом тест падает.
|
||||
|
||||
### P6. `pop_process_trobs` разложен [ЗАМЕР 2026-08-19] — 89 784
|
||||
|
||||
| участок | такты |
|
||||
|---|---:|
|
||||
| вход + префетч кодов тайлов (маппинг окна 0) | **11 058** |
|
||||
| цикл: два `pop_torch_draw` | ~36 000 |
|
||||
| цикл: обход самих trob'ов | ~43 000 |
|
||||
|
||||
Цена одного `pop_pot_b` (пламя факела) измерена отдельно, брейкпоинтами на
|
||||
резидентных адресах: **17 346 тактов**, и это ЕДИНСТВЕННАЯ группа в
|
||||
распределении — то есть `pop_pot_b` в кадре зовут только два факела. При
|
||||
канвасе пламени 16×18 сами пиксели там 1 716, то есть **10 % цены**; всё
|
||||
остальное — накладные (см. §1) плюс ~6 900 сверх `pop_blit_b` на самом
|
||||
`pop_pot_b`.
|
||||
|
||||
Направления:
|
||||
|
||||
- **P6a**: `pop_trob_modif(room)` зовётся банковым вызовом на КАЖДЫЙ trob
|
||||
внутри цикла, хотя комната у них одна и та же — вынести наружу;
|
||||
- **P6b**: префетч 11 058 маппит окно 0 каждый кадр, а коды тайлов trob'ов
|
||||
меняются редко — кэшировать с инвалидацией по смене тайла/комнаты;
|
||||
- **P6c**: цена факела — это цена блита, то есть позиция P4.
|
||||
|
||||
Новое, найдено 2026-08-19.
|
||||
|
||||
### P7. G5. Раскол `draw_tile` на узкие части [оценка дока: −50 000 … −60 000]
|
||||
|
||||
Диспетчер + контекст оплачиваются целиком всегда; у оригинала это девять
|
||||
независимых функций. В 11/15 это те самые 56 200 на один тайл — но если
|
||||
сделан P1, `draw_tile` чомпера вообще не вызывается, и здесь эффект пропадёт.
|
||||
Ценность приёма — в ДРУГИХ сценах (13/23, любая комната с плитами).
|
||||
|
||||
**Риск средний**: в `draw_tile` собрано много инвариантов (BUG-LOOSE-3,
|
||||
BUG-LATTICE-DOORTOP, BUG-SEAM-WEDGE-1) — только отдельным заходом с прогоном
|
||||
всех уровней.
|
||||
|
||||
### P8. HEAL-WIDTH — ширина heal'ов по фактическому следу [оценка: 5-6 % цены heal'ов]
|
||||
|
||||
ОБЯЗАТЕЛЬНАЯ по решению пользователя (2026-08-18). След плиты 58 px в
|
||||
подземелье / 57 во дворце против используемых 60 и 64.
|
||||
Постановка — `TASKS_OPEN.md`, якорь `heal-width`.
|
||||
|
||||
### P9. G8 — пометку СОСЕДА ставить узкой полосой (28 px), а не тайлом [гипотеза]
|
||||
|
||||
Парная к HEAL-WIDTH. Относится к сценам с падающими плитами (13/23), в 11/15
|
||||
не играет. Разбор — `perf_green_phase.md` §G8, там же три условия, из-за
|
||||
которых это не «просто уменьшить число».
|
||||
|
||||
### P10. Футпринт персонажа — из физики, а не считать заново [замер: −11 574 на fore-проход]
|
||||
|
||||
`backlog` §1. С двумя персонажами — ~23 000 за кадр. Мешает то, что физика
|
||||
(банк 3) держит `char_col_left/right` в статиках, а слой фона — банк 2.
|
||||
**Риск средний**: окно fore-клипа заводилось под клинок и брызги.
|
||||
|
||||
### P11. Мелочи с известной ценой [замер, `backlog` §7]
|
||||
|
||||
| что | цена | где |
|
||||
|---|---:|---|
|
||||
| `pop_clip_char_top` — трамплин банк 4 → банк 3 ради одной проверки | 8 892 | `pop_cdraw.c` |
|
||||
| `cd_sig_make` + возврат из `pop_char_draw` | 7 944 | `pop_cdraw.c` |
|
||||
| `pop_loadkid` + расчёт координат кадра | 7 410 | `pop_cdraw.c` |
|
||||
| `obj_x * 8 / 7` — последнее `__divsint` в горячем пути | ~2 400 | `pop_char_draw` |
|
||||
|
||||
### P12. G9 — снять временную оснастку [замер: −6 000]
|
||||
|
||||
`pop_dbg_b1..b6` в `pop_blit_b` (~400 на блит), `pop_dbg_kind`/`m16`,
|
||||
`pop_dbg_m5..m15`, счётчик `rd_cnt` в `pop_redraw_needed`.
|
||||
**Только ПОСЛЕ окончания оптимизации** — без них не мерить.
|
||||
|
||||
### P13. Крупные рефакторинги — брать, только если понадобится ещё запас
|
||||
|
||||
**Чем P13 НЕ является (вопрос пользователя 2026-08-19).** Это не «рисовать
|
||||
комнату заново каждый кадр в скрытый буфер». Такой вариант исключён
|
||||
арифметикой: 30 тайлов по 5-6 спрайтов при цене блита 16 674 (и 179 914 за
|
||||
полную запечку одного тайла) дают порядка **3 000 000 тактов — семь
|
||||
растровых кадров**. Оригинал так тоже не делает: у него та же
|
||||
инкрементальная схема с пометками (`redraw_frames_full` / `_anim` /
|
||||
`_fore`), перерисовываются только помеченные тайлы.
|
||||
|
||||
Разница не в объёме отрисовки, а в цене ПОСЕЩЕНИЯ тайла: у нас
|
||||
`fore_tile(r, c)` сразу блитит (со всеми 6 126 фиксированных накладных), а
|
||||
у оригинала `add_backtable`/`add_midtable`/`add_foretable` только кладут
|
||||
запись в массив, и рисует один `draw_table()` в конце. Плюс у него ОДИН
|
||||
обход тайлов за кадр против наших трёх.
|
||||
|
||||
- **C7 / backlog §5-6: objtable + отложенные таблицы back/mid/fore.** У
|
||||
оригинала «посетить тайл» стоит копейки, потому что таблицы только копят
|
||||
записи, а рисует один `draw_table()` в конце. У нас блит идёт сразу из
|
||||
обхода, и fore-проход отдельный НА КАЖДОГО персонажа.
|
||||
- **backlog §4: единый проход по тайлам вместо трёх** (`pop_redraw_needed`,
|
||||
`pop_process_trobs`, `pop_fore_over_char`) и семь счётчиков причин
|
||||
перерисовки вместо одного `kind`.
|
||||
- **G6: меньше блитов в `RD_FLOOR`**, **G7: `mob_tick_one` в file-scope**.
|
||||
|
||||
---
|
||||
|
||||
## 4. ПЛАН РАБОТ — состояние между сессиями
|
||||
|
||||
Рабочий чеклист. Правило: одна позиция = один заход = один коммит с замером
|
||||
до/после на сцене 11/15. Замер обязателен даже когда «очевидно» — из семи
|
||||
закрытых позиций ТРИ дали не то, что ожидалось (P1 — вдвое меньше, P2a —
|
||||
почти ничего, таблицы порогов — регресс).
|
||||
|
||||
### Закрыто
|
||||
|
||||
| # | что | факт |
|
||||
|---|---|---|
|
||||
| P15 | точность метки «фон трогали» + раздельная проверка клинка | **−163 746** лёгкая / **−140 871** тяжёлая |
|
||||
| P16 | цианные проверки: снимок без структуры, `guard_over_kid` по условию, `hit_slot` без пяти аргументов | **−25 818** |
|
||||
| P5 | `loose_tick`: гейты холостого хода | **−33 840** (ждали −28 000) |
|
||||
| P1 | чомпер: перерисовка только при фазе < 6 | **−110 802** (ждали −160 000) |
|
||||
| P2b | луч видимости: колонки + один банковый вызов | **−26 448** (ждали −30 000) |
|
||||
| P2a | `coll_scan` в 8 бит + снят с IX | **−2 892** (крупной статьи в физике нет) |
|
||||
| P3 | Кид перестал будиться каждый кадр | сбылось само после P1 — но только в ЛЁГКОЙ позиции |
|
||||
| P2/P6 | замеры синей фазы и `process_trobs` | гипотеза «трамплины на спецсобытиях» отвергнута |
|
||||
| — | замер цианной фазы | крупного лишнего в отрисовке персонажа нет |
|
||||
|
||||
### Осталось, по убыванию ожидаемого эффекта
|
||||
|
||||
| # | что | ожидание | риск | комментарий |
|
||||
|---|---|---:|---|---|
|
||||
|
||||
| P4 | накладные блита — 6 663 на КАЖДЫЙ блит | частично сделано: **−408** | низкий | из четырёх правок сработала слабо; разбор ниже |
|
||||
| P14 | fore-проход персонажа | сводится к P4 + P10 | — | разбор ниже: цикл обходит всего 4 тайла |
|
||||
| P11 | мелочи с известной ценой | −26 000 | низкий | `clip_char_top` 8 658 подтверждён замером |
|
||||
| P10 | футпринт персонажа из физики | ? (нужен замер) | **высокий** | разбор ниже: величины физики и fore РАЗНЫЕ |
|
||||
| P6a/P6b | `trob_modif` из цикла, кэш префетча | −20 000 | низкий | тот же паттерн трамплина в цикле |
|
||||
| P7 | раскол `draw_tile` (G5) | −50 000 в 13/23 | средний | в 11/15 не играет |
|
||||
| ~~P8~~ | HEAL-WIDTH | ✅ сделано: плита 64→58, чомпер 64→61 | — | эффект ждёт прогона 13/23 |
|
||||
| P9 | G8 — пометка соседа полосой | не оценено | средний | для сцен с плитами |
|
||||
| P13 | objtable + отложенные таблицы, единый проход по тайлам | не оценено | очень высокий | большой рефакторинг слоя фона |
|
||||
| P12 | снять оснастку | −6 000 | нулевой | **последней**: без неё не мерить |
|
||||
|
||||
### Текущее состояние бюджета
|
||||
|
||||
| | работа | синяя | зелёная | циан | период |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| до оптимизации | 801 768 | 293 238 | 320 916 | 187 758 | 4 растра |
|
||||
| после P5 | 767 928 | 285 864 | 294 384 | 187 764 | 4 |
|
||||
| после P1 (медиана) | 657 882 | 286 503 | 183 420 | 187 761 | 4 |
|
||||
| после P2a | 654 990 | 283 215 | 183 798 | 187 812 | 4 |
|
||||
| **после P2b (лёгкая позиция)** | **628 542** | 259 500 | 181 068 | 187 761 | 4 |
|
||||
| ТЯЖЁЛАЯ позиция (Кид на шаг правее) | 758 358 | 257 520 | 180 870 | 319 842 | **4 и 5** |
|
||||
| **после P15, лёгкая** | **464 796** | 223 902 | 181 494 | **59 406** | 4 |
|
||||
| после P15, тяжёлая | 617 487 | 245 808 | 181 761 | 192 090 | **4 везде** |
|
||||
| после P16, лёгкая | 438 978 | 218 052 | 181 494 | 39 438 | 4 |
|
||||
| ~~после P4~~ | ~~438 570~~ | | | | правка **ОТКАЧЕНА** |
|
||||
| после P17, лёгкая | 438 324 | 217 590 | 181 098 | 39 192 | 4 |
|
||||
| **после P6a, лёгкая** | **437 484** | 217 704 | 180 252 | 39 306 | 4 |
|
||||
| **после P17, тяжёлая** | **603 684** | 241 956 | 181 464 | 180 270 | 4 |
|
||||
|
||||
Итог восьми позиций: **801 768 → 464 796 в лёгкой позиции (−42 %)** и
|
||||
**758 358 → 617 487 в тяжёлой (−19 %)**. Отдельно важно: в тяжёлой позиции
|
||||
исчезли пятирастровые кадры (было 27 %), период стал ровно 4 — рывки ушли.
|
||||
|
||||
### Достижима ли цель — арифметика на 2026-08-19
|
||||
|
||||
Цель: работа ≤ 430 000, тогда период станет 3 растра (хвост кадра — три
|
||||
`gfx_wait_vsync`).
|
||||
|
||||
- в ЛЁГКОЙ позиции снять надо **7 484**;
|
||||
- в ТЯЖЁЛОЙ — **173 684**.
|
||||
|
||||
**Лёгких путей больше не осталось.** За 2026-08-19 отвергнуто ЧЕТЫРЕ
|
||||
правки подряд (три с регрессом, одна почти без эффекта), и все они целили
|
||||
в накладные проверок и блита. Фиксированная часть блита 6 663 держится
|
||||
ядром `gfx_w0_map`/`cd_touch`/чтения шапки, а не «лишними» вызовами.
|
||||
|
||||
Всё оставшееся в списке, кроме P13, даёт по оценкам **порядка 100 000** — и
|
||||
это оптимистично. **Арифметика не сходится:** сцена с двумя персонажами,
|
||||
чомпером и двумя факелами в три растра не укладывается без одного из трёх
|
||||
решений:
|
||||
|
||||
1. **P13** — переход на objtable и отложенные таблицы, как в оригинале
|
||||
(единственный резерв нужного размера, но это переписывание слоя фона);
|
||||
2. **осознанное расхождение с оригиналом** — например, не перерисовывать
|
||||
передний слой персонажа, пока не изменились ни персонаж, ни тайлы под
|
||||
ним (гейт по сигнатуре футпринта);
|
||||
3. **принять 4 растра** как рабочий режим для сцен такой плотности и
|
||||
выравнивать период, чтобы не было рывков 4/5.
|
||||
|
||||
Решение за пользователем — это выбор между точностью порта и скоростью.
|
||||
|
||||
### Как воспроизвести сцену (важно для следующей сессии)
|
||||
|
||||
Сборка стартует прямо в ней: `make` (дефолты `LEVEL=11 ROOM=15 POS=2`) →
|
||||
`make hdd` → **полный рестарт MAME** (`chdman -f` даёт новый inode, memory
|
||||
`mame_hdd_rebuild_restart`) → в DSS набрать `d:` и `SprPoP`. Кид встаёт в
|
||||
(0,2) лицом к чомперу, справа факел и страж — та самая сцена замеров.
|
||||
Штатный старт уровня возвращается через `make ROOM=`.
|
||||
|
||||
Проверка, что программа ЖИВА, обязательна перед любым чтением памяти:
|
||||
`cur_room` (0x97AA) должен лежать в 1..24 — на этом уже был сорван один
|
||||
замер (прочитаны два случайных байта остановленной машины).
|
||||
|
||||
### Метод замера
|
||||
|
||||
Зонды — `out (_io_border)` в `sprpop.c` (база модуля 0x42AD) плюс
|
||||
резидентные пустышки `pop_dbg_m*` из `pop_state.c`. Адреса брать ЗАНОВО из
|
||||
`.sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map` после каждой пересборки. Скрипты
|
||||
сессии: `perfrun.py <out> <сек> tag=addr ...` и `parseseq.py <файл> ПОСЛЕД`.
|
||||
|
||||
Цену отдельной РЕЗИДЕНТНОЙ функции можно снять вообще без пересборки:
|
||||
`bpset <вход>,1,{temp0=totalcycles; g}` плюс `bpset <точка>,1,{printf "…
|
||||
%d",totalcycles-temp0; g}`. Так разложен блит в §1.
|
||||
|
||||
---
|
||||
|
||||
## 5. Сводка: что сколько даёт в 11/15
|
||||
|
||||
| # | приём | эффект | тип оценки | риск |
|
||||
|---|---|---:|---|---|
|
||||
| P1 | чомпер: только anim-слой | −160 000 | модель | низкий |
|
||||
| P3 | Кид перестанет будиться | −70 000 | модель | следствие P1 |
|
||||
| P2 | логика двух Char | −40 000 … −70 000 | гипотеза | ? |
|
||||
| P4 | накладные блита (4 правки) | −28 000 | модель | низкий |
|
||||
| P5 | `loose_tick` без плит | ✅ −33 840 | ФАКТ | сделано |
|
||||
| P6 | цикл `process_trobs` | −20 000 … −40 000 | гипотеза | ? |
|
||||
| P10 | футпринт из физики | −23 000 | замер | средний |
|
||||
| P11 | мелочи (4 штуки) | −26 000 | замер | низкий |
|
||||
| P12 | снять оснастку | −6 000 | замер | нулевой |
|
||||
| P7 | раскол `draw_tile` | 0 здесь (−50 000 в 13/23) | оценка | средний |
|
||||
| P8/P9 | HEAL-WIDTH / G8 | 0 здесь (сцены с плитами) | оценка | низкий/средний |
|
||||
|
||||
Верхняя часть списка (P1 + P3 + P4 + P5) — **около −286 000 из 801 768, то
|
||||
есть 36 % работы кадра**, и вся она низкого риска. Этого хватит, чтобы
|
||||
сцена ушла с 1,86 растрового кадра до ~1,2 — но НЕ хватит, чтобы период
|
||||
кадра упал с 4 растров до 3: для этого работа должна уложиться в 430 000,
|
||||
то есть нужны ещё ~90 000 сверху (P2 или P6).
|
||||
|
||||
---
|
||||
|
||||
## 5б. Цианная фаза разложена [ЗАМЕР 2026-08-19]
|
||||
|
||||
Фаза 188 004 тактов, и она НЕ менялась ни от P1, ни от P5, ни от P2b.
|
||||
|
||||
| участок | такты |
|
||||
|---|---:|
|
||||
| `check_mirror` | 3 198 |
|
||||
| `loose_mob_draw` + `guard_over_kid` + `skip_mask` | **34 374** |
|
||||
| **`pop_char_draw(KID)`** | **204** |
|
||||
| **соперник: `char_draw` + `char_fore`** | **145 896** |
|
||||
| `fore_needed` + `hp_draw` | 2 598 |
|
||||
| `char_fore(KID)` + борта | 1 758 |
|
||||
|
||||
**Кид уже пропускается** — 204 такта, то есть надежда P3 всё-таки сбылась
|
||||
после P1: метка от чомпера до него больше не дотягивается. А страж
|
||||
перерисовывается каждый кадр, и это ЧЕСТНО: пламя правого факела (0,7)
|
||||
рисуется в ячейке (0,8), где он стоит, и реально накрывает ему голову
|
||||
(пламя занимает y 5..22, страж 12..62).
|
||||
|
||||
Отрисовка стража (148 302) по частям:
|
||||
|
||||
| участок | такты | доля |
|
||||
|---|---:|---:|
|
||||
| **`pop_char_fore`** (два трамплина в банк 2 + обход тайлов) | **62 778** | 42 % |
|
||||
| клинок: `pop_sword_draw` + `cd_overlay_add` + `cd_clip_add` | 27 522 | 19 % |
|
||||
| блит спрайта + `clip_char_right` | 20 982 | 14 % |
|
||||
| загрузка кадра и геометрия | 12 696 | 9 % |
|
||||
| **`pop_clip_char_top`** (трамплин банк 4 → банк 3) | 8 658 | 6 % |
|
||||
| снимок прямоугольника + `cd_clip_add` | 7 890 | 5 % |
|
||||
| `gfx_w0_unmap` + `cd_sig_make` | 4 968 | 3 % |
|
||||
| вход + `cd_heal` | 2 946 | 2 % |
|
||||
|
||||
**Вывод: крупного лишнего здесь нет.** Единственная явно лишняя статья —
|
||||
трамплин `clip_char_top` (8 658), и убрать его непросто: функции нужны
|
||||
`get_tile` и таблицы деления из банка 3, а перенос в резидент вернёт тот же
|
||||
трамплин внутрь. Всё остальное — работа, которую персонаж действительно
|
||||
делает: рисует себя, клинок и передний слой поверх себя.
|
||||
|
||||
## 6. Иерархия референсов (уточнена 2026-08-19)
|
||||
|
||||
Сравнение трёх реализаций луча видимости показало, что источники не
|
||||
равноценны, и это важно для ЛЮБОЙ будущей оптимизации:
|
||||
|
||||
| источник | что берём | чего НЕ берём |
|
||||
|---|---|---|
|
||||
| **Apple II** (`Prince-of-Persia-Apple-II`) | как это делается на 8 битах: таблицы вместо делений, борьба за такты | ничего — но код на 6502, читать сложнее |
|
||||
| **SDLPoP** | эталон ПОВЕДЕНИЯ (декомпиляция DOS-версии) | реализацию: она нарочно «расслаблена» под 32 бита |
|
||||
| **mininim** | разбор краевых случаев, второе мнение о замысле | алгоритмы — переписан с нуля, механика местами своя |
|
||||
|
||||
Доказательство на конкретном месте: `get_tile_div_mod` в SDLPoP содержит
|
||||
комментарий
|
||||
|
||||
```c
|
||||
// DOS PoP does this:
|
||||
// obj_xl = tile_mod_tbl[xpos];
|
||||
// return tile_div_tbl[xpos];
|
||||
```
|
||||
|
||||
а вместо этого делает `x % TILE_SIZEX` и `x / TILE_SIZEX`. Таблицы в файле
|
||||
лежат, но нужны только для эмуляции чтения DOS-версии ЗА ГРАНИЦЕЙ массива.
|
||||
Apple II (`CTRLSUBS.S`, `GETBLOCKX`) читает ровно `BlockTable[x]`.
|
||||
|
||||
**Правило:** сверять поведение по SDLPoP, а реализацию под 8 бит — по
|
||||
Apple II и по комментариям вида «DOS PoP does this» в самом SDLPoP.
|
||||
|
||||
## 7. Повторяющийся источник цены: банковый трамплин в цикле
|
||||
|
||||
Уже трижды крупнейшей статьёй оказывался не алгоритм, а вызов `__banked`-
|
||||
функции ИЗ ЦИКЛА, идущего в другом банке:
|
||||
|
||||
| место | цена | лечение |
|
||||
|---|---:|---|
|
||||
| луч видимости: `pop_tile_at` по колонке (P2b) | 36 786 → 13 002 | один вызов на весь отрезок |
|
||||
| `pop_clip_char_top` — банк 4 → банк 3 ради одной проверки | 8 892 | не сделано (P11) |
|
||||
| `pop_trob_modif(room)` на каждый trob в цикле | не мерено | не сделано (P6a) |
|
||||
|
||||
**Что проверять в первую очередь при новом «дорогом» месте:** не сколько
|
||||
там арифметики, а сколько раз за кадр пересекается граница банка.
|
||||
|
||||
## Фиксированный логический кадр (2026-08-19) — МЕНЯЕТ ВСЕ ЦЕЛЕВЫЕ ЧИСЛА
|
||||
|
||||
Период логического кадра больше не `ceil(W) + 2`, а `max(n, ceil(W))`
|
||||
(`src/pop_pace.c`, разбор — `frame_pacing_plan.md`). Поэтому:
|
||||
|
||||
- **Бюджет кадра вырос с 430 080 до 1 290 240 тактов** (n = 3, режим
|
||||
FASTEST по умолчанию). Все записи этого реестра, где «работа сверх
|
||||
430 000 стоит сразу целого растра», СЧИТАТЬ УСТАРЕВШИМИ.
|
||||
- 13/23 (максимум работы 911 862) теперь укладывается в период 3 растра —
|
||||
проверено, ни одного кадра длиннее. Прежний профиль был 3/4/5.
|
||||
- Оптимизация из спешной стала плановой: смысл резать такты остался
|
||||
(режим NORMAL при n=4 и бой при n=5 дают ещё больше запаса, а FASTEST —
|
||||
верхнюю планку скорости), но «свалиться за растр» больше не обрыв.
|
||||
- Цена самого пейсинга — ≈4 000 тактов на кадр (0,9 %), замерено A/B.
|
||||
|
||||
Приоритет P9 (G8) и остальных позиций от этого не меняется, но их
|
||||
СРОЧНОСТЬ падает: они больше не спасают от скачка периода.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Генераторы псевдослучайных чисел: запасные варианты
|
||||
|
||||
Что сейчас стоит в порте, какие есть альтернативы и сколько на них реально
|
||||
можно выиграть. Заготовка на случай, если упрёмся в бюджет кадра —
|
||||
**сейчас менять ничего не нужно**.
|
||||
|
||||
## Что стоит сейчас
|
||||
|
||||
`pop_geom.c`, ветка `POP_PRANDOM_EXACT=1` (по умолчанию) — LCG оригинала
|
||||
`s = s*214013 + 2531011`, шаг написан на Z80-ассемблере (единственное такое
|
||||
место в порте). Схема Горнера по разреженной записи константы:
|
||||
|
||||
```
|
||||
214013 = ((((1<<1)+1)<<2 + 1)<<4 + 1)<<10 - 3
|
||||
```
|
||||
|
||||
17 удвоений, три сложения, одно вычитание; величина `3*s`, нужная в конце,
|
||||
попадается по дороге на втором шаге. Тело — **≈1 020 тактов** по статическому
|
||||
подсчёту. Бит-в-бит совместим с SDLPoP, поэтому по картинке можно сверяться
|
||||
с эталоном.
|
||||
|
||||
Вторая ветка, `POP_PRANDOM_EXACT=0` — xorshift16 + шаг Вейля на C.
|
||||
Совместимость теряется.
|
||||
|
||||
Замер в MAME, комната 3, 175 кадров (медиана кадра):
|
||||
|
||||
| вариант | кадр | prandom → torch_draw |
|
||||
|---|---|---|
|
||||
| C, бит-в-бит (16-битные половины) | 403 632 | 10 933 |
|
||||
| C, xorshift16 + Вейль | 397 986 | 7 927 |
|
||||
| **asm, бит-в-бит (сейчас)** | **400 800** | **9 331** |
|
||||
|
||||
## Вариант A — комбинированный LFSR + LCG, ~148 тактов
|
||||
|
||||
Период > 4 млрд (lcm(65536, 65535) ≈ 4.29e9), младшие биты не вырождены.
|
||||
|
||||
```z80
|
||||
prng16:
|
||||
seed1=$+1
|
||||
ld hl, 9999
|
||||
ld b, h
|
||||
ld c, l
|
||||
add hl, hl
|
||||
add hl, hl
|
||||
inc l
|
||||
add hl, bc
|
||||
ld (seed1), hl
|
||||
seed2=$+1
|
||||
ld hl, 987
|
||||
add hl, hl
|
||||
sbc a, a
|
||||
and 101101b
|
||||
xor l
|
||||
ld l, a
|
||||
ld (seed2), hl
|
||||
add hl, bc
|
||||
ret
|
||||
```
|
||||
|
||||
Устройство: `seed1` — LCG `x = 5x + 1` (по модулю 2^16; `inc l` вместо
|
||||
`inc hl` — экономия байта, на период не влияет). `seed2` — 16-битный
|
||||
LFSR Галуа: сдвиг влево, и если выехала единица, XOR младшего байта с маской
|
||||
`0x2D` (примитивный многочлен `x^16 + x^5 + x^3 + x^2 + 1`). На выходе
|
||||
сумма обоих состояний — она и разрушает регулярность младших бит LCG.
|
||||
|
||||
**Что мешает взять как есть:** сиды зашиты в код (SMC), а нам нужны ДВЕ
|
||||
независимые последовательности — раскладка кладки и анимация тайлов.
|
||||
Пришлось бы передавать состояние через указатель, как сейчас у `pop_prandom`
|
||||
(это +20…40 тактов, не принципиально).
|
||||
|
||||
## Вариант B — xorshift(7,9,8), ~86 тактов
|
||||
|
||||
Самый быстрый, период 65535.
|
||||
|
||||
```z80
|
||||
xrnd:
|
||||
ld hl, 1 ; seed must not be 0
|
||||
ld a, h
|
||||
rra
|
||||
ld a, l
|
||||
rra
|
||||
xor h
|
||||
ld h, a
|
||||
ld a, l
|
||||
rra
|
||||
ld a, h
|
||||
rra
|
||||
xor l
|
||||
ld l, a
|
||||
xor h
|
||||
ld h, a
|
||||
ld (xrnd+1), hl
|
||||
ret
|
||||
```
|
||||
|
||||
**Две оговорки.** Ноль — неподвижная точка, а сид раскладки кладки у нас
|
||||
считается как `номер комнаты + смещение ряда + колонка` и вполне может
|
||||
оказаться нулём: нужен либо guard, либо шаг Вейля поверх. И тот же SMC-сид,
|
||||
что в варианте A.
|
||||
|
||||
## Чего НЕ брать: RND из Apple II
|
||||
|
||||
Оригинальный `Prince-of-Persia-Apple-II`:
|
||||
|
||||
```
|
||||
RNDseed := (5 * RNDseed + 23) mod 256
|
||||
```
|
||||
|
||||
```asm
|
||||
RND
|
||||
lda RNDseed
|
||||
asl
|
||||
asl
|
||||
clc
|
||||
adc RNDseed
|
||||
clc
|
||||
adc #23
|
||||
sta RNDseed
|
||||
rts
|
||||
```
|
||||
|
||||
Полный период 256 (`a ≡ 1 mod 4`, `c` нечётное), и для своего движка он
|
||||
работал. Нам не годится: у LCG по модулю 256 младшие биты вырождены — бит 0
|
||||
просто чередуется. Наши вызовы это увидят: раскладка кладки берёт
|
||||
`prandom(1)` (ОДИН бит) и `prandom(4)`, то есть вместо шума получилась бы
|
||||
аккуратная шахматка.
|
||||
|
||||
## Сколько реально можно выиграть
|
||||
|
||||
Меньше, чем кажется по числам 86/148 против 1 020. Тело генератора — уже не
|
||||
весь расход: остаются обёртка `pop_prandom`, приведение к диапазону
|
||||
`pop_rnd_fit` и ABI вызова. Верхняя граница выигрыша видна из замера выше:
|
||||
между нынешним asm-LCG и самым дешёвым из проверенных вариантов разница
|
||||
**2 814 тактов за кадр (0.65 %)** при двух вызовах за кадр, и это ПОТОЛОК —
|
||||
любой из вариантов A/B ниже него не опустится.
|
||||
|
||||
Порядок действий, если понадобится:
|
||||
|
||||
1. Сначала убрать обёртки: слить `pop_rnd_fit` в ту же asm-процедуру, чтобы
|
||||
на вызов приходился один `call`, а не три. Это ничего не ломает и не
|
||||
трогает совместимость с эталоном.
|
||||
2. И только если этого мало — менять генератор, начиная с варианта A
|
||||
(качество последовательности у него не хуже LCG, в отличие от B).
|
||||
|
||||
Важно помнить: число вызовов вырастет с боёвкой. Сейчас их два за кадр
|
||||
(факелы), а `guard_advance` / `guard_block` / `guard_strike` дёргают
|
||||
`prandom(255)` каждый по разу за кадр боя — то есть при драке станет 5–6, и
|
||||
цена вопроса вырастет во столько же раз.
|
||||
@@ -0,0 +1,328 @@
|
||||
# QuickSave / QuickLoad — разбор оригинала и план реализации
|
||||
|
||||
Статус: **РЕАЛИЗОВАНО и проверено в MAME** (2026-08-22; F6/F9, POP.SAV +
|
||||
POP.BAK — см. коммит `v0.6-pop-quicksave`). Документ оставлен как
|
||||
справочник по формату снимка и разбору. Задача на доске —
|
||||
[`../TASKS_OPEN.md#qsave`](TASKS_OPEN.md#qsave).
|
||||
|
||||
---
|
||||
|
||||
## 0. Важная оговорка об «оригинале»
|
||||
|
||||
**В оригинальном PoP 1989 года (DOS/Apple II) QuickSave/QuickLoad НЕТ.**
|
||||
Там вообще нет сохранения посреди уровня: игра рассчитана на один заход в
|
||||
60 минут, а «продолжение» — это только пароль/чекпоинт уровня 7. Поэтому
|
||||
`Prince-of-Persia-Apple-II/` и `MSDOS/` тут не источники — искать в них
|
||||
нечего.
|
||||
|
||||
Источник истины — **SDLPoP**, где быстрое сохранение добавлено как
|
||||
enhancement: `seg000.c`, блок `#ifdef USE_QUICKSAVE`, клавиши **F6** (save)
|
||||
и **F9** (load). Ниже разобран именно он. Это значит, что правило
|
||||
«расхождение с SDLPoP = баг у нас» здесь работает мягче: мы не обязаны
|
||||
повторять его байт-в-байт, но обязаны повторить его **устройство**, потому
|
||||
что оно решает ровно те проблемы, которые возникнут и у нас.
|
||||
|
||||
---
|
||||
|
||||
## 1. Как это устроено в SDLPoP
|
||||
|
||||
### 1.1 Точка вызова — отдельная фаза кадра, не обработчик клавиши
|
||||
|
||||
Клавиша только взводит флаг (`need_quick_save` / `need_quick_load`,
|
||||
`seg000.c:558`), а вся работа делается в `check_quick_op()` — она вызывается
|
||||
из главного цикла **между кадрами**, когда движок в согласованном состоянии.
|
||||
Это принципиально: загрузка посреди тика переписала бы `Char` под ногами у
|
||||
`play_seq`.
|
||||
|
||||
Отказ штатный, не фатальный: `quick_save()`/`quick_load()` возвращают
|
||||
успех/неуспех, и игра печатает `QUICKSAVE` / `NO QUICKLOAD` внизу экрана и
|
||||
продолжается.
|
||||
|
||||
### 1.2 Формат — плоская последовательность переменных, без структуры
|
||||
|
||||
```c
|
||||
#define process(x) ok = ok && process_func(&(x), sizeof(x))
|
||||
```
|
||||
|
||||
Один макрос и один и тот же список обходится **и на запись, и на чтение**
|
||||
(`quick_process(process_save)` / `quick_process(process_load)`). Поля
|
||||
пишутся встык, без имён и тегов; совместимость держится ровно одним
|
||||
средством — **строкой версии в начале файла**:
|
||||
|
||||
```c
|
||||
const char quick_version[] = "V1.16b4 ";
|
||||
```
|
||||
|
||||
При загрузке она сравнивается, и при несовпадении файл просто отвергается
|
||||
(`quick_load`, возврат 0). То есть формат нарочно хрупкий и нарочно
|
||||
одноразовый — это снимок конкретной сборки, а не сейв-формат.
|
||||
|
||||
**Это стоит перенять целиком.** Мы платим за версионирование одним байтом
|
||||
и получаем право менять состав снимка при каждой правке движка.
|
||||
|
||||
### 1.3 Что именно сохраняется
|
||||
|
||||
Полный список — `quick_process`, `seg000.c:257-366`. По смыслу он делится
|
||||
на пять групп:
|
||||
|
||||
| группа | поля |
|
||||
|---|---|
|
||||
| уровень | `level` (2305 Б целиком), `checkpoint`, `upside_down`, `drawn_room`, `current_level`, `next_level`, `leveldoor_open` |
|
||||
| анимируемые объекты | `mobs_count`, `mobs[14]`, `trobs_count`, `trobs[30]` |
|
||||
| Кид | `Kid`, `hitp_curr/max/beg_lev`, `grab_timer`, `holding_sword`, `united_with_shadow`, `have_sword`, `kid_sword_strike`, `pickup_obj_type`, `offguard` |
|
||||
| соперник | `Guard`, `Char`, `Opp`, `guardhp_curr/max`, `demo_index`, `demo_time`, `curr_guard_color`, `guard_notice_timer`, `guard_skill`, `shadow_initialized`, `guard_refrac`, `justblocked`, `droppedout`, `is_guard_notice`, `can_guard_see_kid` |
|
||||
| прочее | кэш коллизии (`*_row_coll_room/flags`, `prev_collision_row`), вспышка (`flash_color/time`), звук (`is_screaming`, `is_feather_fall`, …), **`random_seed`**, время (`rem_min`, `rem_tick`), весь блок управления (`control_*`, `ctrl1_*`) |
|
||||
|
||||
Два наблюдения, важные для нас:
|
||||
|
||||
1. **Состояние ОТРИСОВКИ не сохраняется вообще.** Ни экранных буферов, ни
|
||||
пометок перерисовки, ни того, что уже нарисовано. Вместо этого при
|
||||
загрузке комната перерисовывается с нуля. Это резко упрощает задачу и
|
||||
ровно то, что нам нужно при дабл-буфере.
|
||||
2. **`random_seed` сохраняется.** Без него загрузка не воспроизводима:
|
||||
после неё факелы, чомперы и `prandom` в боёвке пойдут иначе.
|
||||
|
||||
### 1.4 Что делается при загрузке
|
||||
|
||||
`restore_room_after_quick_load()` (`seg000.c:395`) — это и есть вся
|
||||
«сложность» операции:
|
||||
|
||||
- `load_lev_spr(current_level)` — **перезагрузка графики уровня** (тайлсет
|
||||
мог смениться: подземелье/дворец);
|
||||
- `different_room = 1`, `next_room = drawn_room = Kid.room` — принудительно
|
||||
«мы в другой комнате», чтобы движок перерисовал всё;
|
||||
- `load_room_links()` — связи комнат заново;
|
||||
- `draw_game_frame()` — отрисовать кадр (важно для состояния падения);
|
||||
- `hitp_delta = guardhp_delta = 1` — принудительный редрой полос HP;
|
||||
- если `Guard.room != drawn_room` — стража «выключить» (`direction =
|
||||
dir_56_none`, `guardhp_curr = 0`), как в `clear_char()`;
|
||||
- `loadkid_and_opp()` — восстановить окно `Char`/`Opp`;
|
||||
- сбросить таймеры текста и `exit_room_timer`.
|
||||
|
||||
Плюс визуальный приём: перед загрузкой экран заливается чёрным на 5 тиков —
|
||||
чтобы переход читался глазом и не выглядел «дёрганием».
|
||||
|
||||
### 1.5 Чего в SDLPoP решили НЕ восстанавливать
|
||||
|
||||
- звуки — просто `stop_sounds()`;
|
||||
- перо (`is_feather_fall`) — без фикса `fix_quicksave_during_feather`
|
||||
сохранение под пером запрещено вовсе, а при загрузке эффект гасится;
|
||||
- есть опциональный **штраф**: `USE_QUICKLOAD_PENALTY` отнимает минуту
|
||||
игрового времени за квиклоад. Нам не нужен (у нас пока нет игрового
|
||||
таймера).
|
||||
|
||||
---
|
||||
|
||||
## 2. Чем наша архитектура отличается
|
||||
|
||||
| | SDLPoP | у нас | следствие для задачи |
|
||||
|---|---|---|---|
|
||||
| уровень в памяти | `level_type` в ОЗУ, 2305 Б, мутабельный | EMM-страница (`pop_lvl_page`), плюс рабочая копия комнаты в W2 | снимок читает страницу через W0-маппинг, а не `memcpy` |
|
||||
| модификаторы тайлов | внутри `level.bg` | отдельный `room_modif[24][30]` в `pop_trob.c` (**static**) | нужен экспортируемый сериализатор из банка 6 |
|
||||
| код | один бинарник | 8 банков + резидент | сериализатор обязан жить там же, где данные, и зваться через трамплин |
|
||||
| экран | один буфер | **дабл-буфер**, у каждой страницы своя теневая копия | после загрузки перерисовать ОБЕ страницы, иначе через кадр мелькнёт старое |
|
||||
| ОЗУ | сколько угодно | куча 2969 Б, стек 1279 Б | буфер снимка целиком в ОЗУ не положить — писать потоком |
|
||||
| диск | `fopen` | DSS: 8 манипуляторов, 9-й ВЕШАЕТ систему ([[dss_fd_limit]]) | закрывать файл гарантированно, гард уже есть в libc |
|
||||
| ГСЧ | один `random_seed` | **три** независимых: `pop_t_seed`, `trob_seed`, `pop_fight_seed` | сохранять все три, иначе загрузка невоспроизводима |
|
||||
|
||||
---
|
||||
|
||||
## 3. Инвентаризация нашего состояния
|
||||
|
||||
Собрано по `.sprinter-cc-build/.sprinter-cc-sprpop/sprpop.map` (данные всех модулей, включая
|
||||
банковые, лежат в W2 — банк влияет только на код). Отмечено, что глобально
|
||||
(видно снаружи), а что `static` и требует аксессора.
|
||||
|
||||
### 3.1 Мутабельные данные уровня
|
||||
|
||||
| что | где | размер | доступ |
|
||||
|---|---|---|---|
|
||||
| тайлы `fg` (провалившиеся плиты, открытые двери, съеденные предметы) | EMM-страница уровня | 720 Б | `pop_level_set_tile` пишет; чтения наружу нет — **нужен аксессор** |
|
||||
| `room_modif[24][30]` | `pop_trob.c`, static | 720 Б | **нужен сериализатор** (банк 6) |
|
||||
| `room_seen[24]` | `pop_trob.c`, static | 24 Б | там же |
|
||||
| `trobs[30]` + `trobs_count` | `pop_trob.c`, static | 91 Б | там же |
|
||||
| `trob_seed` | `pop_trob.c`, static | 4 Б | там же |
|
||||
| `mobs[14]` | `pop_room.c`, **глобален** | 210 Б | напрямую |
|
||||
| `mobs_live` | `pop_room.c`, static | 1 Б | аксессор |
|
||||
|
||||
### 3.2 Персонажи и бой
|
||||
|
||||
`Kid`, `Char`, `Opp` (`pop_kid.c`), `Guard` (`pop_guard.c`) — по 16 Б,
|
||||
все глобальные. Рядом: `hitp_curr/max/beg_lev/delta`, `guardhp_curr/max/delta`,
|
||||
`guard_skill`, `guard_refrac`, `justblocked`, `kid_sword_strike`, `offguard`,
|
||||
`holding_sword`, `can_guard_see_kid`, `is_guard_notice`,
|
||||
`pop_guard_notice_timer`, `pop_guard_hurt`, `pop_united_shadow`,
|
||||
`pop_shadow_init`, `pop_fight_seed`, `knock`.
|
||||
|
||||
### 3.3 Прогресс и физика
|
||||
|
||||
`pop_current_level`, `pop_next_level`, `pop_checkpoint`, `pop_have_sword`,
|
||||
`pop_item_taken`, `pop_leveldoor_open`, `pop_leveldoor_right`,
|
||||
`pop_leveldoor_ybottom`, `pop_kid_dead`, `pop_kid_hurt`, `pop_feather`,
|
||||
`pop_upside` / `pop_upside_want`, `pop_flash_time` / `pop_flash_color`,
|
||||
`pop_droppedout`, `pop_fell_out`, `pop_leave_dir`, `pop_leave_timer`,
|
||||
`pop_loose_*`, `pop_ceil_modif`, `pop_ceil_fell`, `pop_debris_at`,
|
||||
`pop_seamless`, `pop_jumped_mirror`.
|
||||
|
||||
### 3.4 Ввод
|
||||
|
||||
`control_x/y/shift/forward/backward/up/down/shift2` (`pop_state.c`) — как в
|
||||
SDLPoP, сохраняются.
|
||||
|
||||
### 3.5 Что НЕ сохранять (восстанавливается перерисовкой)
|
||||
|
||||
`room_fg/room_bg`, `lcol_*`/`rcol_*`/`below_fg`/`above_*`, `seam_*`,
|
||||
`cur_room`, `pop_t_*` (весь кэш слоя фона, окна клипа, `pop_cd_*`),
|
||||
`trob_drawn`, `mob_spr`, слоты `pop_cd`, метки `pop_redraw`, запечки
|
||||
(`bake_pg`). Всё это — производное; после загрузки оно обязано быть
|
||||
сброшено и пересчитано, а не восстановлено.
|
||||
|
||||
**Оценка объёма снимка: ≈ 1,9 КБ** (720 + 720 + 210 + 91 + 64 + ~60
|
||||
скаляров + запас).
|
||||
|
||||
---
|
||||
|
||||
## 4. Куда писать снимок: HDD-файл, а не EMM-страница
|
||||
|
||||
Решение: **один основной слот `POP.SAV` на HDD; предыдущая
|
||||
валидная запись хранится в `POP.BAK`.** EMM-слота нет: программа
|
||||
работает только с HDD, а главный сценарий QuickSave обязан переживать
|
||||
перезапуск игры.
|
||||
|
||||
> Пересмотрено 2026-08-21 по вопросу пользователя «почему EMM, а не файл».
|
||||
> Первая редакция плана рекомендовала EMM — это была ошибка: она взвешивала
|
||||
> скорость и недооценивала главный сценарий использования. Разбор оставлен
|
||||
> целиком, потому что довод переносится и на другие «положить в память
|
||||
> вместо диска» решения.
|
||||
|
||||
**Решающий довод: EMM-страница не переживает рестарт программы,** а именно
|
||||
рестарт — тот случай, ради которого QuickSave и нужен. Пример из этого же
|
||||
проекта: сцену каскада плит на 13/23 воспроизводит ТОЛЬКО `ESC` → запуск
|
||||
заново ([`perf_l13_room23.md`](perf_l13_room23.md) §1, где перечислено,
|
||||
почему не годятся ни возврат в комнату, ни рестарт уровня, ни запись
|
||||
состояния отладчиком). Тем более снимок в ОЗУ не переживает перезапуск
|
||||
MAME, обязательный после каждой пересборки образа.
|
||||
|
||||
| сценарий | EMM | файл |
|
||||
|---|---|---|
|
||||
| «переиграть это место ещё раз» | работает, мгновенно | работает, на HDD быстро |
|
||||
| «вернуться к багу после рестарта» | **не работает** | **работает** |
|
||||
|
||||
Второй сценарий не закрывается ничем другим; первый закрывается обоими, и
|
||||
разница в скорости там некритична — 1,9 КБ на HDD ([[mame_hdd_test_disk]] —
|
||||
быстрый путь против дискеты) не заметны на фоне полной перерисовки комнаты,
|
||||
которая при загрузке делается в любом случае и стоит дороже.
|
||||
|
||||
Доводы за EMM, которые при перепроверке оказались слабыми: лимит
|
||||
манипуляторов DSS ни при чём (открываем и закрываем ровно один файл, гард
|
||||
`_fd_guard` в libc и так стоит), а «не нужен путь и права» — экономия одной
|
||||
строки.
|
||||
|
||||
Обход состояния всё равно писать с абстракцией чтения/записи, как у SDLPoP
|
||||
через `process_func`, но второй EMM-слот в scope не входит.
|
||||
|
||||
**Проверить ДО кодинга:** пишется ли `test_hdd.chd` из-под MAME. Если образ
|
||||
только на чтение, файловый путь упрётся в это на первом же шаге и порядок
|
||||
работ придётся менять. Проверка дешёвая — записать пробный файл на `D:` из
|
||||
SprPoP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Формат снимка
|
||||
|
||||
```
|
||||
+0 "PQS1" 4 Б магия
|
||||
+4 версия сборки 1 Б (инкремент при ЛЮБОМ изменении состава)
|
||||
+5 pop_current_level 1 Б
|
||||
+6 длина полезной части 2 Б (контроль, что обход совпал)
|
||||
+8 ... поля встык, ОДИН порядок на запись и на чтение ...
|
||||
.. checksum 2 Б (заголовок + payload)
|
||||
```
|
||||
|
||||
Версия проверяется первой; несовпадение — отказ, как в SDLPoP. Никаких
|
||||
тегов и выравнивания: снимок одноразовый и живёт ровно одну сборку.
|
||||
|
||||
Обход — один список и один макрос, как `process(x)`:
|
||||
|
||||
```c
|
||||
static void qs_walk(qs_io_t io) /* io = запись или чтение */
|
||||
{
|
||||
QS(pop_current_level); QS(pop_checkpoint); ...
|
||||
}
|
||||
```
|
||||
|
||||
Так состав нельзя рассинхронизировать между сохранением и загрузкой —
|
||||
единственная реальная опасность плоского формата.
|
||||
|
||||
---
|
||||
|
||||
## 6. Что делать при загрузке (наш аналог `restore_room_after_quick_load`)
|
||||
|
||||
Порядок важен, каждый пункт закрывает конкретный отказ:
|
||||
|
||||
1. **Сменился уровень?** → `pop_level_load_num()`, `pop_bg_load(tileset)`,
|
||||
атласы стража по типу. Это дорого, но ровно тот же путь, что при
|
||||
переходе уровня (`pop_level_switch`), — переиспользовать его, а не писать
|
||||
заново.
|
||||
2. Залить экран чёрным (приём SDLPoP: переход должен читаться глазом).
|
||||
3. Восстановить состояние обходом `qs_walk`.
|
||||
4. **Сбросить всё производное:** `pop_trob_reset` (но НЕ трогая
|
||||
восстановленные `room_modif`/`trobs` — нужен отдельный «мягкий» сброс,
|
||||
только `trob_drawn` + метки), `pop_redraw_reset`, слоты `pop_cd`,
|
||||
`pop_bake_forget`, `pop_cd_clear`, сигнатуры пропуска перерисовки.
|
||||
5. `pop_room_load(Kid.room)` — рабочая копия комнаты и срезы соседей.
|
||||
6. **Полная отрисовка комнаты в ОБЕ страницы дабл-буфера.** Это наше
|
||||
главное отличие от SDLPoP: одной перерисовки мало, вторая страница
|
||||
останется со старой картинкой и мигнёт через кадр.
|
||||
7. Принудительный редрой полос HP (`hitp_delta = guardhp_delta = 1`).
|
||||
8. Если `Guard.room != Kid.room` — выключить стража
|
||||
(`Guard.direction = DIR_56_NONE`, `guardhp_curr = 0`), как `clear_char`.
|
||||
9. `pop_loadkid_and_opp()` — согласовать окно `Char`/`Opp`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Разбиение на шаги
|
||||
|
||||
| шаг | что | критерий готовности |
|
||||
|---|---|---|
|
||||
| **QS0** | Проверить, что `D:` пишется из-под MAME (пробный файл из SprPoP) | файл создался и читается обратно после рестарта программы |
|
||||
| **QS1** | Аксессоры/сериализаторы для `static`-состояния банковых модулей: `pop_trob.c` (`room_modif`, `room_seen`, `trobs`, `trob_seed`), `pop_room.c` (`mobs_live`), страница уровня (чтение `fg`) | хост-тест `tests-host/t_qsave.c`: обход туда-обратно на синтетическом состоянии даёт байт-в-байт исходное |
|
||||
| **QS2** | Ядро: `qs_walk` + `POP.SAV`, магия/версия/checksum, безопасная замена с предыдущей валидной копией в `POP.BAK` | сохранение и загрузка **в той же комнате, без движения**; порча SAV не портит BAK |
|
||||
| **QS3** | Восстановление отрисовки (§6), включая обе страницы дабл-буфера | загрузка после перехода в другую комнату; нет мерцания через кадр |
|
||||
| **QS4** | Клавиши **F6/F9** (или свободные из `pop_cheat.h`) через `<kbd_raw.h>`, флаги `need_quick_save/load`, обработка **между кадрами** | загрузка посреди боя/падения не ломает `play_seq` |
|
||||
| **QS5** | Загрузка с **другого уровня** (перезагрузка уровня и атласов) | сохранить на ур. 2, уйти на ур. 12, загрузить — тайлсет и стражи верные |
|
||||
|
||||
Порядок не переставлять: QS0 первым (он может изменить весь план), QS3 без
|
||||
QS2 нечего проверять, а QS5 обязан идти после QS3 — иначе смена тайлсета
|
||||
замаскирует ошибки восстановления.
|
||||
|
||||
**Главный критерий приёмки всей задачи:** сохранить состояние, выйти по
|
||||
`ESC`, запустить SprPoP заново, загрузить — и оказаться там же. Именно
|
||||
этого сценария сейчас нет ничем, и ради него задача и делается.
|
||||
|
||||
---
|
||||
|
||||
## 8. Риски и открытые вопросы
|
||||
|
||||
1. **`static` в банковых модулях.** Их нет в карте символов, то есть
|
||||
отладчиком снимок не проверить. Возможно, стоит сделать `room_modif` и
|
||||
`trobs` НЕ-static — так же, как уже сделано с `mobs` в `pop_room.c` и
|
||||
ровно по той же мотивации (там это записано прямым комментарием).
|
||||
2. **Место в банке 6.** `pop_trob` занимает 3802/16384 — запас есть, но
|
||||
сериализатор лучше писать компактным обходом, а не 30 отдельными
|
||||
вызовами.
|
||||
3. **Три ГСЧ.** Проверить, что сохранены ВСЕ: пропуск любого даст
|
||||
«загрузилось, но играется иначе» — самый неприятный класс бага, потому
|
||||
что выглядит как случайность.
|
||||
4. **Согласованность `Char` и `Kid`.** У нас окно `Char` — отдельная копия;
|
||||
если сохранить их рассогласованными (снимок посреди тика), загрузка
|
||||
воскресит рассогласование. Отсюда требование QS4: только между кадрами.
|
||||
Урок свежий — ровно на этом стыке жил
|
||||
[BUG-CHEAT-IMM-1](BUGS_CLOSED.md#bug-cheat-imm-1).
|
||||
5. **Дабл-буфер.** Самый вероятный источник «почти работает»: забыть вторую
|
||||
страницу. Симптом — мерцание через кадр
|
||||
(см. `SprPoP/CLAUDE.md`, раздел про дабл-буфер).
|
||||
6. **Транзакция SAV/BAK.** До кодинга проверить на DSS семантику
|
||||
rename/replace. Если атомарная замена не гарантирована, писать через
|
||||
`POP.NEW`, проверять его после close и не удалять единственную валидную копию
|
||||
до завершения новой.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Бюджет резидента W1/W2: как мерить и как освобождать
|
||||
|
||||
Резидент huge-режима — окно `0x4100..0xBB00` (стек с 0xBB00): код в W1,
|
||||
данные в W2, между концом данных и стеком остаётся куча. Всё, что туда не
|
||||
влезло, живёт в банках.
|
||||
|
||||
## Как СМОТРЕТЬ, а не гадать
|
||||
|
||||
**Карта линкера врёт.** File-static SDCC в неё не попадает, и «дырка» между
|
||||
двумя именованными символами приписывается предыдущему целиком. По карте
|
||||
выходило, что у `pop_bg` 1529 Б данных (на деле 289), а у `pop_t_win_clear`
|
||||
1282 Б кода — при том, что это однострочник, а 1282 Б это два статических
|
||||
помощника соседнего `pop_blit_b`.
|
||||
|
||||
Точный источник — объектные файлы: строки `A <area> size <n> flags <f>` в
|
||||
`.rel` дают ровный размер каждой области модуля, а `S <sym> Def/Ref` — кто
|
||||
символ определяет и кто на него ссылается.
|
||||
|
||||
**Частоту вызовов мерить в MAME счётчиком**, а не оценивать по смыслу:
|
||||
|
||||
```
|
||||
bpset <frame_probe>,1,{printf "F %d ...",temp0,...; temp0=0;...; g}
|
||||
bpset <func_addr>,1,{temp0=temp0+1; g}
|
||||
```
|
||||
|
||||
Обязательна **канарейка** — счётчик заведомо горячей функции в том же
|
||||
прогоне. Дважды спасала: один раз показала, что перехода комнаты в окне
|
||||
замера не было (все нули), другой — что зонды вообще не встали (в zsh
|
||||
`set -- $pair` НЕ разбивает строку на слова, и адрес уезжал в мусор).
|
||||
Полную перерисовку комнаты форсировать читом `+`/`-`, ходьбой ненадёжно.
|
||||
|
||||
## Сделано
|
||||
|
||||
### 1. malloc вон из резидента (−613 Б)
|
||||
|
||||
`cbl_open` держал `malloc`/`free` в мёртвой ветке `CBL_UNDERRUN_SILENCE`, а
|
||||
линкер тянет `.rel` целиком — и куча приезжала каждому приложению. Разведены
|
||||
две публичные точки входа (`cbl_open` / `cbl_open_silence`) поверх общего
|
||||
`_cbl_open_raw`; `cbl_close` больше не зовёт `free`.
|
||||
|
||||
### 2. Разрез pop_tile: холодная половина в банк 5 (−1788 Б)
|
||||
|
||||
`pop_tile.c` был крупнейшим жильцом резидента (5 972 Б кода). Целиком он не
|
||||
уедет: его const-таблицы (`POP_TILE_DIV/MOD`, `pop_tile_table`, таблицы
|
||||
кадров) читают банки 2, 3, 7 и 8, а таблица в чужом банке не видна.
|
||||
|
||||
Отбирали ЗАМЕРОМ, на двух тайлсетах (подземелье ур. 1 и дворец ур. 4 —
|
||||
`pop_mem_b` рисует композитный кусок и мог оказаться дворцовым). Порог —
|
||||
пик не больше 3 вызовов на кадр.
|
||||
|
||||
| уехало в банк 5 | пик/кадр | | осталось в резиденте | пик/кадр |
|
||||
|---|---:|---|---|---:|
|
||||
| `pop_mem_b` | 0 | | `pop_tile_code` | 296 |
|
||||
| `pop_cd_hit` (+`hit_rect`) | 0 | | `pop_cd_touch` | 198 |
|
||||
| `pop_t_win_set/clear` | 0..1 | | `pop_blit_b` (+2 статика) | 184 |
|
||||
| `pop_heal_off` | 0..1 | | `pop_wall_modifier` | 101 |
|
||||
| `pop_potion_flask` | 0..1 | | `pop_env_b` | 73 |
|
||||
| `pop_room_set_above/below` | 1 | | `pop_tile_mod` | 70 |
|
||||
| `pop_cd_init/clear` | 1 | | `pop_cd_batch_end` | 40 |
|
||||
| `pop_bar_black` | 3 | | `pop_fore_set_clip` | 2 |
|
||||
| `pop_cd_hit_slot` | 2..3 | | все const-таблицы | — |
|
||||
|
||||
`pop_fore_set_clip` (88 Б) оставлен намеренно: не стоит отказа от прямого
|
||||
вызова из банка 4, ради которого он и заводился.
|
||||
|
||||
**Цена трамплина замерена**: 252 такта пролог + 84 эпилог + ~50 на стороне
|
||||
вызывающего = **~410 тактов** на вызов. Итого ~1 000 тактов на кадр покоя
|
||||
(0,2 % работы) и ~3 700 на кадр редрава (0,009 растра).
|
||||
|
||||
**Ключ, почему это безопасно:** вызов банк → резидент ПРЯМОЙ, трамплин не
|
||||
нужен (W1/W2 замаплены всегда). Поэтому `blit_b_clip` просто перестал быть
|
||||
`static` и объявлен в `_pop_tile.h`, а не переехал следом за `pop_mem_b`.
|
||||
|
||||
### Итог
|
||||
|
||||
| | было | стало |
|
||||
|---|---:|---:|
|
||||
| `_CODE` резидента | 24 329 | **21 928** |
|
||||
| свободно до стека | **129 Б** | **2 535 Б** |
|
||||
| BANK5 | 2 080 (13 %) | 3 954 (24 %) |
|
||||
|
||||
Проверено в MAME: уровень 1 (подземелье) и уровень 4 (дворец), переходы
|
||||
комнат читом `+`, ходьба — фон, факелы, решётки, гобелены, колонны без
|
||||
искажений.
|
||||
|
||||
## ЛОВУШКА: данные банка в его страницу — НЕ ДЕЛАТЬ без разбора
|
||||
|
||||
Отдельная попытка (`--bank-data=SRC`, коммиты 3545826/9025573) **откачена**:
|
||||
перенос писучих данных банкового модуля в его 16-КБ страницу давал цветной
|
||||
мусор блоками и ронял DSS.
|
||||
|
||||
У `pop_trob` причина найдена: `pop_trob_modif()` ВОЗВРАЩАЕТ УКАЗАТЕЛЬ на
|
||||
`room_modif[24][30]`, а зовут её из банков 2, 3, 7 и резидента — после
|
||||
переноса они пишут по 0xC000+ в СВОЮ страницу, поверх чужого кода.
|
||||
Def/Ref-анализ такого не видит: снаружи ссылки на символ нет, есть ссылка на
|
||||
функцию, отдающую его адрес. Но и `pop_room`, у которого утечки указателя
|
||||
найти не удалось, ломался так же — механизм понят не до конца.
|
||||
|
||||
Нулевая инициализация при этом ни при чём: `mkexe -p 0` был проверен по
|
||||
образу (прогон нулей 14 304 Б, самый длинный прогон 0xFF — 14).
|
||||
|
||||
**Перенос КОДА в банк — штатный путь, на нём стоят все наши банки. Ломался
|
||||
именно перенос ДАННЫХ.**
|
||||
|
||||
## Что осталось
|
||||
|
||||
- `sprpop.c` 2 508 Б и `pop_kid.c` 2 418 Б — следующие по величине, но оба
|
||||
горячие (главный цикл и `play_seq`).
|
||||
- `pop_level.c` 956 Б кода + 660 Б данных (из них `pop_dl1`/`pop_dl2` по
|
||||
256 Б — таблицы дверных связей).
|
||||
- BANK7 на 77 %: если понадобится место в нём — выносить `pop_redraw.c`.
|
||||
@@ -0,0 +1,86 @@
|
||||
# PoP SprPoP — модель `kid_room ≠ drawn_room` (баг #4)
|
||||
|
||||
> **Статус: ЖИВОЙ ПЛАН, сделан частично (сверено 2026-08-01).**
|
||||
> - **S1 — сделан:** `kid_room` заведён отдельно от `cur_room`,
|
||||
> `update_kid_render_dx()` (`sprpop.c`) даёт рендер-смещение ∓140, а
|
||||
> `pop_kid_set_render_dx` применяет его в отрисовке. Фактически это пока
|
||||
> каркас: `enter_room` держит `kid_room == cur_room`, так что смещение
|
||||
> всегда 0.
|
||||
> - **S2/S3/S4 — не сделаны и не срочны.** Исходный повод (баг #4,
|
||||
> пинг-понг у шва) закрыт иначе — поправкой odd-pixel в
|
||||
> `char_x_forward_edge` + `pop_leave_timer` (разбор корня —
|
||||
> `../PoP/SprPoP/BUGS_CLOSED.md`, BUG-SEAM-PINGPONG).
|
||||
>
|
||||
> **Зачем документ остаётся.** Полная straddle-модель понадобится для:
|
||||
> (а) читов осмотра соседних комнат `H/J/U/N` (`levels_plan.md` §4),
|
||||
> (б) сцен, где Кид и страж в разных комнатах кадра, (в) остатков окклюзии у
|
||||
> шва (S4). Брать из `../TASKS_OPEN.md`, когда дойдёт очередь.
|
||||
|
||||
Порт straddle-модели SDLPoP: персонаж может находиться в СОСЕДНЕЙ комнате,
|
||||
пока на экране ещё ТЕКУЩАЯ (drawn_room). Источник истины — SDLPoP.
|
||||
|
||||
## Факты из SDLPoP (подтверждено чтением исходника)
|
||||
|
||||
- `Char.room` (реальная комната персонажа) ≠ `drawn_room` (отрисованная) —
|
||||
штатное состояние.
|
||||
- **Коллизия через ±140:** `xpos_in_drawn_room()` (seg004:0405) сдвигает
|
||||
xpos на `±TILE_SIZEX*SCREEN_TILECOUNTX = ±140`, когда `curr_room` колонки
|
||||
(`curr_row_coll_room[col]`) ≠ `drawn_room` (room_L/room_BL → −140,
|
||||
room_R/room_BR → +140). Т.е. коллизия строится по РЕАЛЬНЫМ тайлам соседей.
|
||||
- **Смена экрана:** `check_the_end()` (seg000:0FBD): `if (next_room!=0 &&
|
||||
next_room!=drawn_room) { drawn_room=next_room; load_room_links; redraw }`.
|
||||
`next_room` ставится в `exit_room()` (= `Char.room` ПОСЛЕ успешного
|
||||
`leave_room`). Значит drawn_room следует за Char.room, но Char.room меняется
|
||||
ТОЛЬКО при реальном пересечении шва (leave_room, seg002:0504) на «легальном»
|
||||
кадре/действии (не turn/climb/standup).
|
||||
- **Отрисовка левого соседа:** только `load_leftroom()` (col9 левого соседа в
|
||||
левую кромку); правый сосед НЕ рисуется (изометрия). Окклюзия ворот на шве —
|
||||
только левая (seg008:696).
|
||||
- **Ceiling-полоса:** `draw_room` рисует доп. ряд из `room_A` (row2, draw_main_y
|
||||
=-1). (Уже реализовано, баг #3.)
|
||||
|
||||
## Текущее состояние нашего движка (до #4)
|
||||
|
||||
`cur_room` (=drawn_room) ВСЕГДА == комната Kid. Шов подделан: Kid остаётся в
|
||||
drawn_room с `curr_col=-1/10` + снапшоты соседей `g_lcol/g_rcol` (коллизия ±1
|
||||
кол) / `lcol_bg` (openness ворот). Уход из комнаты — `pop_leave_dir`/`enter_room`
|
||||
МГНОВЕННО при пересечении порога `char_x`. Отсюда #4: экран переключается
|
||||
раньше, чем в оригинале (Kid должен «отступить» за кромку, оставив старую
|
||||
комнату).
|
||||
|
||||
## План (инкременты, каждый проверяется в MAME)
|
||||
|
||||
### S1. Данные + рендер-смещение Kid
|
||||
- Ввести `kid_room` (реальная комната Kid) отдельно от `cur_room`(=drawn_room).
|
||||
- `kid_x_offset()` = разница комнат: kid_room == left(drawn) → лог. x Kid −140
|
||||
(рисуется за левой кромкой); right → +140; равны → 0. (порт
|
||||
xpos_in_drawn_room).
|
||||
- `kid_draw`/heal/fore используют смещение (Kid рисуется частично за кромкой).
|
||||
- Проверка: Kid у шва рисуется со сдвигом, экран не дёргается.
|
||||
|
||||
### S2. Коллизия по kid_room
|
||||
- Коллизионный контекст (`g_fg`/edges/`g_room`/modif в pop_map) следует за
|
||||
`kid_room`, а не за drawn_room. Когда kid_room≠drawn_room — грузим
|
||||
соседа как коллизионную комнату (curr_col 0..9 в кадре kid_room).
|
||||
- Отрисовка (room_fg и т.п.) остаётся по drawn_room.
|
||||
- Порт `curr_row_coll_room[]`/`xpos_in_drawn_room` можно упростить: держим
|
||||
ОДИН коллизионный room (kid_room) + существующие снапшоты кромок для ±1 кол.
|
||||
|
||||
### S3. Отложенная смена drawn_room
|
||||
- Уход (`check_leave`/`check_leave_below`): ставит `kid_room=сосед`,
|
||||
репроецирует Kid (x∓140, col∓10) — но drawn_room НЕ меняет сразу.
|
||||
- `check_the_end`-эквивалент в главном цикле: `if (kid_room != drawn_room &&
|
||||
<условие коммита>) enter_room(kid_room)`. Условие коммита — по SDLPoP:
|
||||
как только Char.room сменилась легальным leave (не bumped/turn). Для
|
||||
«bumped назад за кромку» drawn_room остаётся (симптом #4).
|
||||
- Проверка сценариев #4/#5 в MAME.
|
||||
|
||||
### S4. Полировка
|
||||
- Окклюзия/ceiling у шва при straddle, BUG-OCCL-1 (глубина), правый край.
|
||||
|
||||
## Связанные баги — все ЗАКРЫТЫ (`../PoP/SprPoP/BUGS_CLOSED.md`)
|
||||
BUG-CEIL-1 (руки при прыжке вверх), BUG-CEIL-2 (loose в потолке),
|
||||
BUG-CEIL-3 (потолок над анимируемыми воротами), BUG-OCCL-1 (тень дальней
|
||||
колонны) — починены без полной straddle-модели. То есть S4 «полировка
|
||||
окклюзии» осталась актуальной только для окклюзии У ШВА при straddle.
|
||||
Memory: `pop_seam_room_model`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Комнаты для отладочного телепорта (`+` / `-`)
|
||||
|
||||
Считано скриптом прямо по `../SDLPoP/data/LEVELS/res20NN.bin`, 2026-08-13.
|
||||
Задача: обход комнат читом идёт последовательно, и в КАЖДОЙ комнате Киду
|
||||
должно найтись место для материализации без падения и смерти.
|
||||
|
||||
## Критерии пропуска
|
||||
|
||||
| причина | как определяется |
|
||||
|---|---|
|
||||
| **недостижима** | до комнаты нельзя дойти от стартовой обходом связей (BFS по left/right/up/down) |
|
||||
| **нет пола** | ни одного тайла, на котором можно стоять (`tile_is_floor`, seg004) |
|
||||
| **пол опасный** | стоять есть на чём, но обычного пола (код 1) нет — только пики, расшатанные плиты и прочее |
|
||||
|
||||
Про достижимость важно: считать «сколько комнат на неё ссылаются» НЕ
|
||||
годится. На уровне 1 комнаты **13** и **18** ссылаются только друг на
|
||||
друга (13.down = 18, 18.up = 13), то есть входящая связь у каждой есть, а
|
||||
из остального уровня в них не попасть. Ловит это только обход от старта.
|
||||
|
||||
Чит ищет место снизу вверх: сперва обычный пол (код 1), потом любой
|
||||
проходимый тайл (`pop_dbg_roomnav`, `sprpop_cold.c`). Поэтому «пол
|
||||
опасный» — это комнаты, где он свалится на fallback и Кид может погибнуть.
|
||||
|
||||
## Таблица по уровням
|
||||
|
||||
| ур. | старт | достижимо | пропускать |
|
||||
|---|---|---|---|
|
||||
| 1 | 1 | 21/24 | **13** (недостижима), **18** (недостижима), **24** (недостижима) |
|
||||
| 2 | 5 | 24/24 | **14** (пол опасный), **17** (пол опасный) |
|
||||
| 3 | 9 | 22/24 | **17** (пол опасный), **19** (пол опасный), **20** (нет пола), **21** (пол опасный), **23** (недостижима/нет пола), **24** (недостижима/нет пола) |
|
||||
| 4 | 1 | 24/24 | **19** (нет пола), **20** (нет пола) |
|
||||
| 5 | 7 | 19/24 | **1** (недостижима), **3** (недостижима), **5** (недостижима), **6** (недостижима), **19** (недостижима/нет пола), **21** (пол опасный), **22** (нет пола) |
|
||||
| 6 | 24 | 14/24 | **2** (пол опасный), **3** (нет пола), **4** (недостижима), **7** (нет пола), **8** (недостижима/нет пола), **11** (пол опасный), **13** (недостижима), **14** (недостижима), **16** (недостижима), **17** (недостижима), **19** (недостижима/нет пола), **20** (недостижима), **21** (недостижима/пол опасный), **22** (недостижима), **23** (нет пола) |
|
||||
| 7 | 17 | 24/24 | **3** (пол опасный), **17** (нет пола), **21** (пол опасный) |
|
||||
| 8 | 1 | 21/24 | **9** (нет пола), **10** (пол опасный), **11** (недостижима), **15** (недостижима), **17** (пол опасный), **19** (недостижима), **20** (пол опасный), **21** (нет пола) |
|
||||
| 9 | 11 | 24/24 | **8** (нет пола), **18** (пол опасный) |
|
||||
| 10 | 1 | 19/24 | **3** (нет пола), **4** (пол опасный), **6** (недостижима), **9** (нет пола), **11** (пол опасный), **13** (нет пола), **18** (нет пола), **20** (нет пола), **21** (недостижима), **22** (недостижима), **23** (недостижима), **24** (недостижима) |
|
||||
| 11 | 6 | 23/24 | **3** (нет пола), **5** (нет пола), **9** (нет пола), **10** (нет пола), **11** (нет пола), **12** (нет пола), **17** (нет пола), **18** (недостижима), **23** (нет пола) |
|
||||
| 12 | 3 | 24/24 | **5** (нет пола), **6** (пол опасный), **8** (пол опасный), **10** (нет пола), **11** (нет пола), **17** (нет пола), **18** (пол опасный), **19** (пол опасный), **22** (нет пола) |
|
||||
| 13 | 23 | 11/24 | **2** (нет пола), **4** (пол опасный), **5** (недостижима), **6** (недостижима/пол опасный), **7** (недостижима), **8** (недостижима/пол опасный), **9** (недостижима), **12** (недостижима), **14** (недостижима), **15** (недостижима), **18** (недостижима/пол опасный), **19** (недостижима/пол опасный), **20** (недостижима), **21** (недостижима), **22** (недостижима/нет пола) |
|
||||
| 14 | 4 | 6/24 | **7** (недостижима/нет пола), **8** (недостижима), **9** (недостижима), **10** (недостижима), **11** (недостижима), **12** (недостижима), **13** (недостижима), **14** (недостижима/пол опасный), **15** (недостижима), **16** (недостижима), **17** (недостижима/пол опасный), **18** (недостижима), **19** (недостижима), **20** (недостижима), **21** (недостижима), **22** (недостижима), **23** (недостижима), **24** (недостижима) |
|
||||
| 15 | 6 | 6/24 | **1** (недостижима), **2** (недостижима), **7** (нет пола), **9** (недостижима/пол опасный), **10** (недостижима/пол опасный), **11** (недостижима/пол опасный), **12** (недостижима/пол опасный), **13** (недостижима/пол опасный), **14** (недостижима/пол опасный), **15** (недостижима/пол опасный), **16** (недостижима/пол опасный), **17** (недостижима/пол опасный), **18** (недостижима/пол опасный), **19** (недостижима/пол опасный), **20** (недостижима/пол опасный), **21** (недостижима/пол опасный), **22** (недостижима/пол опасный), **23** (недостижима/пол опасный), **24** (недостижима/пол опасный) |
|
||||
|
||||
## Спецкомнаты — пропускать независимо от таблицы
|
||||
|
||||
| ур. | комн. | почему |
|
||||
|---|---|---|
|
||||
| 12 | 23 | **seamless exit**: попадание МЕНЯЕТ УРОВЕНЬ на 13-й. Чит-триггер придержан (`pop_nav_hold`), но в обходе комнате делать нечего |
|
||||
| 6 | 1 | **falling exit**: Кид проваливается вниз и уходит на 7-й уровень |
|
||||
| 7 | 17 | **falling entry**: экран сразу переводится на комнату НИЖЕ (в таблице уже «нет пола») |
|
||||
| 7 | 14 | проходится СВЕРХУ ВНИЗ; чит для неё уже особый — ставит Кида в ряд 0 |
|
||||
| 5 | 24 | тень ждёт в колонке −1 ряда 0; посадка рядом начинает схватку, которой там быть не должно |
|
||||
| 13 | 23, 16 | вход роняет гряду плит (`check_fall_flo`) — материализация под падающей плитой |
|
||||
|
||||
## Уровень 15
|
||||
|
||||
Экран защиты от копирования, не часть сюжета (см.
|
||||
[`levels_12_15_plan.md`](levels_12_15_plan.md) §4). Портировать не
|
||||
планируем — **пропускать целиком**.
|
||||
|
||||
## Как применять
|
||||
|
||||
Список — данные, а не логика: держать таблицей в отладочном коде рядом с
|
||||
`pop_dbg_roomnav` и пропускать помеченные комнаты, чтобы `+` всегда попадал
|
||||
в пригодную. Пересчитывать скриптом, если поменяются данные уровней.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Отрисовка Тени (charid_1_shadow) — изыскания, отложено
|
||||
|
||||
Статус на 2026-08-11: **отложено по решению пользователя.** Тень пока
|
||||
рисуется как обычный персонаж — простой копией из атласов Кида
|
||||
(`pop_cdraw.c`, банк 0x5C, аппаратная прозрачность `#FF`). Вернуться к
|
||||
«правильному» виду, когда будут сделаны все уровни: тогда будет известно,
|
||||
какими именно кадрами тень вообще пользуется.
|
||||
|
||||
Этот файл собирает всё, что уже выяснено, чтобы не переоткрывать.
|
||||
|
||||
---
|
||||
|
||||
## 1. Как тень выглядит в оригинале
|
||||
|
||||
Тень рисуется **двумя блитами ОДНОГО И ТОГО ЖЕ спрайта Кида**
|
||||
(`seg008:1602`, `add_objtable`):
|
||||
|
||||
```c
|
||||
case 1: // shadow
|
||||
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl, obj_y, blitters_2_or, 1);
|
||||
add_midtable(obj_chtab, obj_id + 1, obj_xh, obj_xl + 1, obj_y, blitters_3_xor, 1);
|
||||
```
|
||||
|
||||
OR на месте, XOR со сдвигом на пиксель вправо. XOR гасит совпавшее,
|
||||
остаются края — отсюда «контурный» вид. Это ЗАМЫСЕЛ оригинала, а не
|
||||
артефакт SDLPoP: подтверждено печатью из живого SDLPoP (метка `DBGMIRROR`
|
||||
в `add_objtable`) — и тень, и отражение идут из `chtab=2` (собственные
|
||||
спрайты Кида), `swordbits=0`, обычными кадрами:
|
||||
|
||||
```
|
||||
type=4 chtab=2 img=40 dir=0 clipL=137 clipT=3 charid=0 frame=41 <- отражение
|
||||
type=1 chtab=2 img=41 dir=0 clipL=137 clipT=3 charid=1 frame=42 <- тень
|
||||
```
|
||||
|
||||
Единственное различие между отражением и тенью — блиттер.
|
||||
|
||||
## 2. Чем мы располагаем
|
||||
|
||||
Блочные AND/OR/XOR/NOT акселератора подняты в libbgi 2026-08-11 (полный
|
||||
набор строками и колонками, `gfx_blit_op` / `gfx_blit_part_op` /
|
||||
`gfx_blit_cols_op` / `gfx_blit_cols_part_wx_op`; регресс — `tests/accop`,
|
||||
10/10 PASS). Механика и ловушки — memory `accel_block_ops` и шапка
|
||||
`libbgi/common/_gfx_blit_full_op.c`. То есть примитивов достаточно, дело
|
||||
не в них.
|
||||
|
||||
## 3. Две причины, по которым «в лоб» не получается
|
||||
|
||||
### 3.1 XOR несовместим с нашей прозрачностью `#FF`
|
||||
|
||||
Аппаратная прозрачность (бит 3 видеобанка) подавляет запись байта `#FF`,
|
||||
то есть смотрит на **результат** операции:
|
||||
|
||||
| операция | прозрачный пиксель источника | итог |
|
||||
|---|---|---|
|
||||
| AND | `#FF & bg = bg` | работает даром |
|
||||
| OR | `#FF \| bg = #FF`, запись подавляется | работает даром |
|
||||
| XOR | `#FF ^ bg = ~bg`, подавления нет | **инверсия фона по всему футпринту** |
|
||||
|
||||
Совпадение с «ничего не делать» у XOR получается только там, где фон равен
|
||||
0 (`#FF ^ 0 = #FF` → подавляется). В DOS-оригинале прозрачный индекс = 0 —
|
||||
нейтральный и для OR, и для XOR, поэтому там оба блиттера работают на одном
|
||||
наборе спрайтов. У нас прозрачный `0xFF` (`pop_pack_kid.py`: `0 -> 0xFF`,
|
||||
`i -> 0x70 + i`).
|
||||
|
||||
Замаскировать `#FF` внутри операции нельзя в принципе: побитовые AND/OR/XOR
|
||||
не умеют «выбрать по условию», а `#FF` — нейтраль только для AND. Значит
|
||||
источнику XOR-прохода нужен **прозрачный `0x00`**, то есть отдельный набор
|
||||
спрайтов.
|
||||
|
||||
### 3.2 Операция читает ОЗУ-копию экрана, а не видео-ОЗУ
|
||||
|
||||
Чтение страниц `#50..#5F` всегда отдаёт ОЗУ-копию (memory
|
||||
`sprinter_vram_transparency`), а персонажи рисуются банком `0x5C` («не
|
||||
писать в копию» — на этом держится даровой heal). Поэтому второй проход
|
||||
**не увидит результат первого**: два блита оригинала выродились бы в
|
||||
«просто XOR», контурного эффекта не будет.
|
||||
|
||||
Лечится не банком `0x50` (он ломает heal — копия перестанет быть чистым
|
||||
фоном), а **однопроходным композитом**: всё складывается в буфере
|
||||
акселератора за один проход по колонке j футпринта
|
||||
|
||||
```
|
||||
буфер := s[j] ; спрайт
|
||||
буфер |= bg[j] ; вертикальное чтение экрана
|
||||
буфер ^= s[j-1] ; тот же спрайт, предыдущая колонка = сдвиг на +1 px
|
||||
запись ; вертикальная запись колонки
|
||||
```
|
||||
|
||||
что **точно эквивалентно** двум блитам оригинала (крайние колонки: `x` —
|
||||
только OR, `x+w` — только XOR) и вдобавок дешевле их: 4 burst'а на колонку
|
||||
против 6. Такому композиту тоже нужен источник с прозрачным `0x00` — уже
|
||||
на обоих шагах.
|
||||
|
||||
## 4. Сколько стоит подготовить источник с прозрачным `0x00`
|
||||
|
||||
Замер 2026-08-11 (`tests/convbench`, watchpoint по IO-записи в MAME, кадр =
|
||||
430 080 тактов). Цикл безветвочный (`ADD A,A / SBC A,A / CPL / AND` —
|
||||
маска из бита 7: прозрачный `#FF` отличается от цветов Кида `0x70..0x7F`
|
||||
именно им), 59 номинальных T-states на байт, по факту **145.3 такта/байт**
|
||||
(2.5× wait-state'ов ОЗУ):
|
||||
|
||||
| объём | кадров | секунд |
|
||||
|---|---|---|
|
||||
| 1 страница атласа, 16 КБ | 5.5 | 0.11 |
|
||||
| весь атлас Кида, 28 страниц × 16 КБ = 448 КБ | 155 | 3.2 |
|
||||
| он же **по реальному размеру данных (186 КБ)** | 64 | **1.3** |
|
||||
|
||||
Последняя строка — замечание пользователя: 28 атласов занимают 186 КБ, а не
|
||||
448 КБ; обрабатывать по фактическому размеру ленты вместо целой страницы
|
||||
даёт 2.4× (ценой проверки границы в цикле). Потолок разгона самого цикла —
|
||||
ещё примерно вдвое (раскрутка убирает `djnz`, чтение через SP парами +
|
||||
таблица 256 Б вместо арифметики), то есть **~0.7 с** на 186 КБ. Порядок
|
||||
величины при этом не меняется.
|
||||
|
||||
**Окна:** источник и приёмник — разные EMM-страницы, а окно под атласы одно
|
||||
(W0), поэтому конвертация гоняется «страница-источник в W0 →
|
||||
страница-приёмник в W3» целыми страницами; побайтно переключать окно нельзя.
|
||||
EMM-бюджет: +28 страниц (448 КБ) из ~3440 КБ свободных — не проблема
|
||||
(memory `sprinter_emm_budget`), и он одинаков в любом из вариантов.
|
||||
|
||||
## 5. Варианты (когда вернёмся)
|
||||
|
||||
1. **Конвертация в рантайме при загрузке уровня с тенью** (4, 5, 6, 12):
|
||||
диск и упаковщик не трогаем, цена — 1.3 с (или 0.7 с после разгона) на
|
||||
загрузку такого уровня.
|
||||
2. **Лениво, постранично** — 0.11 с (5.5 кадра) при первом обращении тени к
|
||||
странице; рывок один раз на страницу, суммарно меньше, чем вариант 1.
|
||||
3. **Второй набор `.atl` от упаковщика** (`pop_pack_kid.py`, прозрачный
|
||||
`0x00`): 0 с рантайма, +186 КБ на образе и вторая ветка в загрузчике
|
||||
атласов.
|
||||
4. **Только OR-проход** (то, чем можно обойтись бесплатно): OR с нашим
|
||||
`#FF`-атласом работает как есть, тень получается сплошным силуэтом в
|
||||
палитре Кида, без контурного эффекта. Расхождение с оригиналом — тогда
|
||||
записью в `docs/impl_diff.md`.
|
||||
|
||||
**Ключ к выбору — какие кадры тень вообще использует.** Предположение
|
||||
пользователя: только бег, длинный прыжок (из зеркала), питьё зелья и
|
||||
боёвка; прыжки с места и подтягивания — нет. Если так, конвертировать
|
||||
(или паковать) нужно единицы страниц, а не 28, и разница между вариантами
|
||||
почти исчезает. Список снимать по факту — когда уровни 5/6/12 будут
|
||||
проходиться.
|
||||
|
||||
## 6. Что ещё придётся проверить глазами
|
||||
|
||||
Палитра. У нас индексы разложены группами по 16 (`pop_pack_bg.py`):
|
||||
`0x30` VGA16, `0x40` chtab_1, `0x50` env, `0x60` wall, `0x70` kid, `0x80`
|
||||
sword, `0x90` guard. Отсюда ожидания (аналитические, в MAME НЕ
|
||||
проверялись):
|
||||
|
||||
- OR-проход ложится удачно: `0x7X | 0x5Y = 0x7Z` — результат остаётся в
|
||||
палитре Кида, а младший ниббл получается ровно тот же, что дал бы DOS
|
||||
(там OR шёл по 4-битным индексам внутри одной палитры);
|
||||
- XOR-проход уводит результат в группы `0x0Z` (поверх OR-результата) и
|
||||
`0x2Z` (по чистому фону) — **обе группы палитры у нас не заполнены**, то
|
||||
есть контур рискует оказаться просто чёрным.
|
||||
|
||||
Значит к «посмотреть глазами» добавляется вопрос, чем заполнять `0x00..0x0F`
|
||||
и `0x20..0x2F` — по сути это и будет выбор цветов тени. В DOS такого
|
||||
вопроса не было: XOR двух 4-битных индексов всегда оставался внутри той же
|
||||
16-цветной палитры.
|
||||
@@ -0,0 +1,963 @@
|
||||
# Звук в порте PoP — разбор и план
|
||||
|
||||
Дата: 2026-08-20, музыка дописана 2026-08-25. Статус: **PCM-эффекты
|
||||
реализованы; музыка — путь C (PCM через CBL), первый трек играет.**
|
||||
|
||||
Задача пользователя: добавить звук. Приоритет — эффекты; музыку, если
|
||||
найдётся способ. Эффекты — **обязательно WAV, а не PC-спикер**
|
||||
(уточнение 2026-08-20; см. §1а — оказалось, что они и так все в WAV). Ниже — что реально лежит в ассетах, что умеет железо, и
|
||||
почему получившийся план вышел проще, чем ожидалось.
|
||||
|
||||
## 1. Главный вывод
|
||||
|
||||
**Ни MIDI разбирать, ни ноты сочинять не придётся, и ресэмплировать тоже.**
|
||||
|
||||
- Эффекты уже лежат **8-битным беззнаковым PCM на 11 000 Гц**, а у CBL есть
|
||||
режим **10 937,5 Гц** — расхождение 0,6 %, на слух неразличимо. Формат
|
||||
сэмпла совпадает с нашим CBL байт в байт (`cbl.h`: 8 бит, беззнаковый,
|
||||
центр 0x80). То есть данные играются **как есть**, без конверсии.
|
||||
- Музыка есть в виде **списков нот PC-спикера — 7 КБ на всю игру**, а нота
|
||||
там задана прямо в ГЕРЦАХ. Пересчёт в делитель AY — одно деление.
|
||||
- AY и COVOX на Sp2000 сведены в **один ЦАП TDA1543** (док Ивана Мака,
|
||||
§5), значит музыка на AY и эффекты через CBL звучат ОДНОВРЕМЕННО, и
|
||||
смешивать их программно не надо.
|
||||
|
||||
## 1а. Уточнение после разбора ВСЕХ наборов MS-DOS версии (2026-08-20)
|
||||
|
||||
Пользователь попросил, чтобы эффекты были не PC-спикером, а WAV, и заодно
|
||||
посмотреть `mt32snd[1-2].dat`. Разобрал все восемь `.dat` из `MSDOS/`.
|
||||
Ответ короткий: **эффекты И ТАК все до одного есть в WAV, а вот у музыки
|
||||
WAV нет ни в одном наборе.**
|
||||
|
||||
| набор | формат | какие звуки | сколько |
|
||||
|---|---|---|---|
|
||||
| `digisnd1..3` | **WAV**, 8 бит PCM | эффекты 0..23, 44..49, 51 | **31** |
|
||||
| `mt32snd1..2` | MIDI для Roland MT-32 | ТЕ ЖЕ эффекты 0..23, 44..51 | 31 |
|
||||
| `midisnd1..2` | MIDI (AdLib/GM) | **музыка** 24..43, 50, 52..56 | 22 |
|
||||
| `ibm_snd1..2` | ноты PC-спикера | **всё подряд, 0..56** | 57 |
|
||||
|
||||
Здесь пряталась ловушка: `mt32snd` по имени похож на «музыку получше», а
|
||||
на деле это набор ЭФФЕКТОВ для владельцев MT-32 — те же id, что у
|
||||
`digisnd`. Музыки в нём нет вовсе.
|
||||
|
||||
Частоты WAV: 28 звуков на 11 000 Гц, по одному на 8 200, 14 000 и 2 750.
|
||||
Итого 112 922 сэмпла = **11,4 с, ~110 КБ ≈ 6,7 EMM-страниц**.
|
||||
|
||||
Только PC-спикером, без альтернатив, остаются четыре id: 31, 34, 42
|
||||
(пустые) и **38 `blink`** — четыре ноты. То есть на весь звук игры
|
||||
спикер нужен ровно для одного писка.
|
||||
|
||||
### Музыка: WAV нет, есть три пути
|
||||
|
||||
| путь | данные | что получится | цена |
|
||||
|---|---|---|---|
|
||||
| **A. Ноты PC-спикера на AY** | 7 КБ | один квадратный голос — ровно то, что слышали на IBM PC 1989 | секвенсор на полсотни строк |
|
||||
| **B. MIDI -> AY, три голоса** | 27 КБ исходника | богаче: бас + мелодия + арпеджио | разбор MIDI + раскладка по каналам |
|
||||
| **C. MIDI -> WAV на хосте, стрим через CBL** | см. ниже | настоящее звучание, любое | нужен синтезатор на хосте + место |
|
||||
|
||||
Про объём для пути C (замерено по длительностям треков):
|
||||
|
||||
| группа | треков | длительность | WAV 11 кГц |
|
||||
|---|---:|---:|---|
|
||||
| звучат ПО ХОДУ игры (гимн уровня, смерть, зелья, перо, победа) | 12 | 74,7 с | **803 КБ = 50 EMM-страниц** |
|
||||
| заставки и титры | 10 | 248,1 с | 2 665 КБ = 167 страниц |
|
||||
|
||||
Игровая половина в EMM **влезает** (при ~215 свободных страницах), а
|
||||
заставочная — нет, её пришлось бы стримить с диска. Но заставки идут
|
||||
тогда, когда игра ничего не рисует, так что стрим там как раз уместен.
|
||||
|
||||
**Предложение:** начинать с A (7 КБ, работает сразу, ноль рисков), а C
|
||||
держать как отдельную фазу — она ортогональна: проигрыватель WAV для
|
||||
музыки это тот же `cbl_push`, что и для эффектов, только длиннее буфер.
|
||||
B имеет смысл только если C окажется неподъёмным по месту.
|
||||
|
||||
## 1б. РЕШЕНИЯ (пользователь, 2026-08-20)
|
||||
|
||||
1. **Эффекты — WAV через CBL, 8 бит, МОНО, единая частота.** Проверено по
|
||||
`convert_digi_sound` (`seg009.c:2358`): один байт на кадр, то есть
|
||||
моно, и байт беззнаковый (`(b | b<<8) - 32768`), центр 0x80 — ровно
|
||||
формат нашего CBL. Стерео в данных нет вовсе: каналы у оригинала
|
||||
размножаются уже на выходе (`digi_audiospec->channels`).
|
||||
2. **Музыка, первый заход — путь A** (ноты спикера на AY).
|
||||
3. **Заставки и титры — потом WAV.** Конфликта с эффектами там нет:
|
||||
одновременно они не звучат.
|
||||
4. **Музыка ПО ХОДУ игры** (она может совпасть с эффектом) — открыто, два
|
||||
варианта: либо тоже WAV с ГАШЕНИЕМ эффектов на время музыки (музыка
|
||||
важнее — **проверить на слух**), либо путь B (MIDI -> три голоса AY).
|
||||
5. **Все эффекты привести к одной частоте.**
|
||||
6. Синтезатор для MIDI -> WAV — решать ближе к делу; годятся и онлайн-
|
||||
конвертеры, хоть вручную, если fluidsynth/timidity не поставится.
|
||||
|
||||
### Про единую частоту (замер)
|
||||
|
||||
Приводим не к 11 000, а ровно к **10 937,5 Гц — частоте CBL**
|
||||
(`CBL_FREQ_10K9`). Тогда тон точен, а не «на 0,6 % ниже»: проигрывание
|
||||
11 000 Гц данных на 10 937,5 даёт сдвиг **−9,9 цента**, что на коротком
|
||||
эффекте не слышно, но бесплатно избавиться от него всё равно приятно —
|
||||
пересчитывать три файла всё равно придётся.
|
||||
|
||||
| id | звук | было | станет | дельта |
|
||||
|---|---|---|---|---:|
|
||||
| 15 | `leveldoor_sliding` | 2 750 Гц, 4 436 сэмплов | 17 643 | **+13 207 Б** |
|
||||
| 23 | `footstep` | 8 200 Гц, 996 | 1 329 | +333 Б |
|
||||
| 51 | `princess_door_opening` | 14 000 Гц, 6 188 | 4 834 | −1 354 Б |
|
||||
| — | остальные 28 (11 000 Гц) | — | ×0,9943 | −588 Б |
|
||||
|
||||
Итог: **112 922 -> 124 531 Б, 6,9 -> 7,6 EMM-страниц.** Рост целиком от
|
||||
`leveldoor_sliding`: источник у него 2 750 Гц, вчетверо реже целевой, и
|
||||
апсэмплинг не улучшит звучание — только уравняет формат. Платим 0,8
|
||||
страницы за то, что **CBL открывается ОДИН раз и частоту менять не надо
|
||||
никогда** — ни между эффектами, ни при переходе на музыку-WAV.
|
||||
|
||||
(Альтернатива для него — хранить как есть и повторять каждый сэмпл
|
||||
четырежды в рантайме: 2 750 × 4 = 11 000 ровно. Это код в `fill()` ради
|
||||
13 КБ; не стоит того, но если место когда-нибудь прижмёт — вариант есть.)
|
||||
|
||||
## 1в. Бюджет памяти EMM (живой замер 2026-08-20)
|
||||
|
||||
Замерено `mem_info` из работающей программы (уровень 1), а не посчитано на
|
||||
бумаге: инструментовка ставилась временно и откатана.
|
||||
|
||||
| | страниц | КБ |
|
||||
|---|---:|---:|
|
||||
| всего в машине | 256 | 4 096 |
|
||||
| система (DSS) + сам exe: база + 8 банков кода | **43** | 688 |
|
||||
| наши ассеты | **81** | 1 296 |
|
||||
| **занято** | **124** | 1 984 |
|
||||
| **свободно** | **132** | **2 112 (2,06 МБ)** |
|
||||
|
||||
Разбивка 81 страницы ассетов (сходится точно):
|
||||
|
||||
| набор | страниц |
|
||||
|---|---:|
|
||||
| **Тень** (`sk*` 28 + `sf*` 4) | **32** |
|
||||
| Кид (`kid0..27`) | 28 |
|
||||
| фон тайлсета (env 10 + wall 1 + fore 1) | 12 |
|
||||
| страж | 5 |
|
||||
| зелья (chtab_1), меч, `kid_data.bin`, страница уровня | по 1 |
|
||||
|
||||
Самый крупный потребитель теперь — **набор Тени, 32 страницы**, больше
|
||||
самого Кида. Если место когда-нибудь прижмёт, там есть очевидный резерв
|
||||
(кадры смерти и позы, в которых Тень не бывает), но при 132 свободных
|
||||
страницах трогать незачем.
|
||||
|
||||
### Что из этого следует для звука
|
||||
|
||||
| статья | страниц | останется свободно |
|
||||
|---|---:|---:|
|
||||
| эффекты WAV, все 31, 10 937,5 Гц | **8** | 124 |
|
||||
| музыка ПО ХОДУ игры в WAV (путь C, 12 треков) | 50 | 74 |
|
||||
| заставки и титры в WAV (10 треков, 248 с) | 167 | **не влезает** |
|
||||
|
||||
То есть эффекты — капля, игровая музыка в WAV тоже поместится, а
|
||||
заставочную придётся стримить с диска в любом случае (что и планировалось:
|
||||
во время заставок игра ничего не рисует).
|
||||
|
||||
Оговорка: 132 свободных страницы — это на уровне 1. На уровне 9 добавятся
|
||||
зеркальные наборы (`pop_vflip_load_all`: 28 Кид + 5 страж + меч = 34
|
||||
страницы), останется ~98. Проверять запас надо ИМЕННО ТАМ.
|
||||
|
||||
## 1г. MSDOS против SDLPoP: чем отличаются наборы (сверено 2026-08-20)
|
||||
|
||||
У нас лежат ДВЕ копии звука — оригинальные `.dat` в `MSDOS/` (версия
|
||||
1.3/1.4) и распакованные ассеты `SDLPoP/data/` (версия 1.0/1.1). Разница
|
||||
есть, и она влияет на выбор источника.
|
||||
|
||||
### Оцифровка: берём MSDOS
|
||||
|
||||
Заголовок разный (`digi_new_type` против `digi_type`), но **28 звуков из
|
||||
31 совпадают побайтно**. Различаются три, и все не в пользу SDLPoP:
|
||||
|
||||
| id | звук | MSDOS | SDLPoP |
|
||||
|---|---|---:|---:|
|
||||
| 10 | `sword_vs_sword` | 5 020 сэмплов | 3 504 |
|
||||
| 11 | `sword_moving` | 1 172 | 1 172, но **другие байты** |
|
||||
| 48 | `spiked` | 5 069 | **7** — то есть звука нет |
|
||||
|
||||
`spiked` в наборе SDLPoP фактически пустой. Поэтому упаковщик читает
|
||||
`MSDOS/digisnd*.dat`, а не распакованные ассеты — в отличие от графики,
|
||||
где источник наоборот SDLPoP.
|
||||
|
||||
### MIDI: если дойдём до музыки — брать SDLPoP
|
||||
|
||||
Здесь всё наоборот. Содержимое музыкально то же (деление 480, те же
|
||||
каналы 0..7 плюс ударные), но:
|
||||
|
||||
| | MSDOS | SDLPoP |
|
||||
|---|---|---|
|
||||
| формат MIDI | **0** — всё слито в ОДНУ дорожку | **1** — 8-9 дорожек |
|
||||
| размер (звук 24) | 327 Б | 448 Б |
|
||||
| размер (звук 56) | 13 587 Б | 12 773 Б |
|
||||
|
||||
Формат 1 с отдельной дорожкой на инструмент — это готовое разделение
|
||||
голосов. Для пути B (MIDI -> три канала AY) оно решает половину задачи:
|
||||
дорожки можно выбирать напрямую (бас / мелодия / гармония), а не
|
||||
разбирать слитый поток и догадываться, что чем было.
|
||||
|
||||
**Итог: эффекты из MSDOS, музыка (когда дойдёт) из SDLPoP.**
|
||||
|
||||
## 2. Что лежит в ассетах (замерено, а не по памяти)
|
||||
|
||||
Звук в PoP адресуется как ресурс `10000 + N`, N = 0..56 — 57 звуков
|
||||
(`load_sound`, `seg009.c:2289`). Наборов три, и они ПАРАЛЛЕЛЬНЫЕ: один и
|
||||
тот же звук есть в нескольких видах.
|
||||
|
||||
| набор | что это | объём | покрытие |
|
||||
|---|---|---:|---|
|
||||
| `DIGISND1..3.DAT` | оцифровка, 8 бит PCM | 103 941 Б | **31 звук** (эффекты) |
|
||||
| `MIDISND1..2.DAT` | MIDI-музыка | 27 776 Б | музыка |
|
||||
| `IBM_SND1..2` (распакованы) | ноты PC-спикера | **7 КБ** | **все 57** |
|
||||
|
||||
### 2.1 Оцифровка (эффекты)
|
||||
|
||||
Разбор контейнера: индекс по 8 байт на запись (id, offset, size), **первый
|
||||
байт ресурса — контрольная сумма**, тело за ней (спецификация
|
||||
`POP-DAT-FormatSpecifications`, §3.1.2 — на этом я сначала споткнулся и
|
||||
читал мусор). Тело — `digi_type`: `word rate, word count, word unk,
|
||||
byte size`, дальше сэмплы.
|
||||
|
||||
- 31 звук, все 8-битные;
|
||||
- частоты: **28 звуков на 11 000 Гц**, по одному на 8 200 и 14 000;
|
||||
- 103 701 сэмпл = **9,4 секунды**, **103 941 Б ≈ 6,3 EMM-страницы**.
|
||||
|
||||
### 2.2 Ноты PC-спикера (и эффекты, и музыка)
|
||||
|
||||
Формат: `byte type(=0), word tempo`, дальше тройки `word frequency,
|
||||
byte length`; `frequency <= 1` — пауза, `0x12` — конец. Ключевое, что
|
||||
пришлось смотреть в `play_speaker_sound`/`speaker_callback` (`seg009.c`):
|
||||
|
||||
- **`frequency` — это ГЕРЦЫ напрямую** (`generate_square_wave(stream,
|
||||
(float)note->frequency, ...)`), а не делитель PIT, как кажется по
|
||||
маленьким числам;
|
||||
- длительность ноты = `length / tempo` СЕКУНД.
|
||||
|
||||
Замеры по всем 57 звукам: **2212 нот**, частоты 16..65507 Гц,
|
||||
длительности 1,74..2571 мс.
|
||||
|
||||
Из них 23 звука — те, у которых оцифровки НЕТ, то есть вся музыка:
|
||||
заставки, гимны уровней, смерть, победа, титры (id 24..43, 50..56).
|
||||
**1469 нот**, и вот их длительности:
|
||||
|
||||
| длительность ноты | нот | доля |
|
||||
|---|---:|---:|
|
||||
| < 3 мс | 0 | 0 % |
|
||||
| 3..12 мс | 2 | 0,1 % |
|
||||
| 12..25 мс | 140 | 9,5 % |
|
||||
| > 25 мс | 1327 | 90,3 % |
|
||||
|
||||
Это число решает вопрос про таймер — см. §4.
|
||||
|
||||
## 3. Что умеет железо (док Ивана Мака §5 + MAME)
|
||||
|
||||
- **AY-3-8910/8912** в ПЛМ, «программируется по стандартным описаниям» —
|
||||
то есть ZX-порты; в MAME он заведён как `AY8910(config, "ay8912",
|
||||
X_SP/24)`, то есть **тактовая 1,75 МГц**. Период канала = 109375 /
|
||||
частота(Гц), 12 бит (макс 4095) → снизу берутся частоты от ~27 Гц.
|
||||
Точные Z80-адреса портов идут через таблицу DCP, а не напрямую —
|
||||
**проверить артефактом до кодинга** (ожидаем ZX-стандарт 0xFFFD/0xBFFD).
|
||||
- **CBL** — COVOX с буфером 256 Б, две половины по 128; бит 7 порта 0xFE
|
||||
показывает играющую половину, порт управления 0x4E. Прерывание —
|
||||
когда половина сменилась.
|
||||
- **Бипер** (бит 5 порта 0xFE) — туда же в ЦАП. Нам не нужен.
|
||||
- Всё это **сведено в один ЦАП**, поэтому AY и CBL звучат вместе.
|
||||
|
||||
У нас уже есть готовая обвязка CBL (`libc/include/cbl.h`): callback
|
||||
`fill(n)`, выдача блока через `cbl_push_otir()` (порт 0x4F) или через
|
||||
акселератор, коды частот, счётчик недоливов. Своего кольца библиотека не
|
||||
держит — данные пропихиваются прямо из наших EMM-страниц.
|
||||
|
||||
## 4. Предлагаемая архитектура
|
||||
|
||||
```
|
||||
эффекты (31 шт, 8 бит 11 кГц) музыка (23 шт, ноты)
|
||||
│ │
|
||||
EMM-страницы (6,3) 7 КБ нот в банке
|
||||
│ │
|
||||
cbl_push_otir из fill() запись 3 регистров AY
|
||||
│ │
|
||||
CBL (10,9 кГц) ──────┐ ┌────────── AY (1,75 МГц)
|
||||
▼ ▼
|
||||
TDA1543 (аппаратное смешивание)
|
||||
```
|
||||
|
||||
**Один источник прерываний — CBL.** Его callback приходит каждые 128
|
||||
сэмплов = **11,7 мс** при 10,9 кГц, и он же двигает секвенсор музыки.
|
||||
Отдельный таймер (CTC) НЕ нужен: по таблице из §2.2 короче 12 мс всего
|
||||
2 ноты из 1469 — они растянутся на один тик, чего не слышно.
|
||||
|
||||
Почему это важно: `irq_ctc_install` сейчас требует кода в W2 (tiny/big),
|
||||
а SprPoP — huge, и попадёт ли туда CTC-трамплин, зависит от раскладки.
|
||||
Обойтись без него — значит не открывать этот фронт вовсе.
|
||||
|
||||
**Пейсинг кадра при этом не страдает.** Наш темп считается ПО ЛУЧУ
|
||||
(`pop_pace.h`), а не по кадровым прерываниям, поэтому то, что CBL-ветка
|
||||
трамплина делает приватный RETI и съедает кадровые прерывания, нам
|
||||
безразлично. Если бы пейсинг остался на прерываниях — звук бы его сломал.
|
||||
|
||||
## 5. Объём работ
|
||||
|
||||
| фаза | что | оценка |
|
||||
|---|---|---|
|
||||
| **З1** | распаковщик `pop_pack_sound.py`: DAT → `.snd`-страницы EMM (эффекты) + `.not` (ноты музыки) | формат уже разобран |
|
||||
| **З2** | `pop_sfx.c`: `cbl_open` + `fill()`, таблица «звук → страница/смещение/длина», `pop_sfx_play(id)` | ядро |
|
||||
| **З3** | развесить вызовы: 66 мест `play_sound` в оригинале; у нас часть уже помечена TODO (`pop_ctrl.c:408`, `guards.c:992`, `pop_map.c:3035/3107`, …). **Плюс бесплатный кусок**: опкод `SEQ_SOUND` в seqtbl уже разбирается нашим `play_seq` (`pop_kid.c:326`) — шаги, приземления и прочее поедут сами | механическая |
|
||||
| **З4** | `pop_music.c`: секвенсор нот на AY (путь A), тик из CBL-callback | небольшая |
|
||||
| **З6** | заставки и титры — WAV-музыка потоком (эффекты в это время не звучат) | после З1-З4 |
|
||||
| **З7** | музыка по ходу игры: WAV с гашением эффектов ЛИБО путь B — решать по итогам З6 | открыто |
|
||||
| **З5** | приоритеты и вытеснение: у оригинала `play_sound` глушит предыдущий (`stop_sounds`), музыка и эффект — разные каналы | правила из seg009 |
|
||||
|
||||
## 6. Что проверить артефактом ДО кодинга
|
||||
|
||||
1. **Порты AY на Sprinter** — записать в 0xFFFD/0xBFFD и убедиться, что
|
||||
MAME отдаёт звук (порты идут через DCP-таблицу, «стандартные ZX» —
|
||||
это ожидание, а не факт).
|
||||
2. **CBL в режиме huge.** Шапка `cbl.h` говорит «код/данные в W2
|
||||
(tiny/big)», но это скорее всего устаревшая оговорка: IM2-трамплин
|
||||
давно переделан на all-modes, и наш кадровый путь в huge работает.
|
||||
Проверить `cbl_open` из SprPoP.
|
||||
3. **Совместное владение портом 0xFE.** Бит 5 (луч) у нас держится через
|
||||
`_cbl_port_ref` «немым» кодом частоты; когда откроется НАСТОЯЩИЙ CBL,
|
||||
владение переходит к нему. Убедиться, что пейсинг переживает
|
||||
`cbl_open`/`cbl_close`.
|
||||
4. **Цена fill() в кадре.** 128 байт через `cbl_push_otir` раз в 11,7 мс
|
||||
— замерить тем же способом, что и остальное (брейкпоинт + totalcycles).
|
||||
|
||||
## 7. Про MIDI — почему не он
|
||||
|
||||
Музыка в MIDISND — настоящие MThd/MTrk чанки, 27 КБ. Чтобы играть их на
|
||||
AY, нужен разбор MIDI, раскладка каналов на три голоса и таблица
|
||||
инструментов — это отдельный проект, и звучать он будет НЕ так, как
|
||||
оригинал на PC. А набор PC-спикера — это ровно то, что слышал игрок на
|
||||
IBM PC 1989 года: один квадратный голос. Он у нас есть целиком, весит
|
||||
7 КБ и ложится на AY напрямую.
|
||||
|
||||
Если позже захочется богаче — материал уже будет разобран, и можно
|
||||
разложить те же мелодии на три канала AY (бас/мелодия/арпеджио), не трогая
|
||||
ни данные, ни секвенсор.
|
||||
|
||||
---
|
||||
|
||||
## 8. Разводка вызовов по коду (сделано 2026-08-20)
|
||||
|
||||
Портированы ВСЕ места `play_sound()` SDLPoP, у которых есть оцифровка
|
||||
(id 0..23, 44..49, 51 — остальные id это музыка, у них в
|
||||
`pop_sound_tbl.h` длина 0, и вызов просто глушит текущий эффект).
|
||||
|
||||
| id | что | где у нас | оригинал |
|
||||
|----|-----|-----------|----------|
|
||||
| 0 | разбился насмерть | `pop_map.c` land | seg005 |
|
||||
| 1 | крик падения | `pop_map.c` do_fall | seg005:39 |
|
||||
| 2 | плита рухнула | `pop_room.c` (обе ветки посадки) | seg007 |
|
||||
| 3 | кнопка нажата | `pop_trob.c` | seg007 |
|
||||
| 4/5/6/7 | ворота: закрываются / открываются / рухнули / стоп | `pop_trob.c` | seg007 |
|
||||
| 8 | удар о стену | `pop_map.c` bumped_fall/bumped_floor + seqtbl | seg004/seg006 |
|
||||
| 9 | зацеп за карниз | `pop_map.c` check_grab | seg006 |
|
||||
| 10 | клинок о клинок | `sprpop.c` после `check_sword_hurt`, если один из бойцов в кадре 167 | seg000:1353 |
|
||||
| 11 | свист клинка мимо | `guards.c` check_hurting | seg002:0DAE |
|
||||
| 12/13 | ранен соперник / Кид | `guards.c` hurt_by_sword | seg002:0C1F |
|
||||
| 13 | Кид ранен зельем | `pop_map.c` ветка «злого» зелья | seg006:1894 |
|
||||
| 14/15 | дверь уровня: закрывается / едет | `pop_trob.c` | seg007 |
|
||||
| 16 | средняя посадка; толчок о стража | `pop_map.c` land / bump_into_opponent | seg005/seg003:0654 |
|
||||
| 17 | мягкая посадка | `pop_map.c` land | seg005 |
|
||||
| 18 | пьёт | seqtbl (SEQ_SOUND) | seg006 |
|
||||
| 19 | вынул меч | `pop_ctrl.c` | seg005:945 |
|
||||
| 20/21/22 | дрожит плита | `pop_map.c` loose_shake | seg007:0E55 |
|
||||
| 23 | шаг | seqtbl (SEQ_SOUND) | seg006 |
|
||||
| 44 | скелет оживает | `guards.c` pop_check_skel | seg002:106D |
|
||||
| 45 | прыжок в зеркало | `pop_map.c` jump_through_mirror | seg003:0617 |
|
||||
| 46 | сожрал чомпер | `pop_map.c` | seg004 |
|
||||
| 47 | чомпер щёлкнул | `pop_trob.c` (кадр 2) | seg007 |
|
||||
| 48 | напоролся на пики | `pop_map.c` | seg005 |
|
||||
| 49 | пики пошли | `pop_map.c` start_anim_spike | seg007:08F6 |
|
||||
|
||||
Что осталось не разведено — только МУЗЫКА (24/28 смерть, 25 презентация,
|
||||
26 объятия,
|
||||
27/35/40 заставки, 29 встреча Джафара, 30/33 зелья, 32/41 конец уровня,
|
||||
36 время вышло, 37 победа, 43 смерть Джафара, 50/52/53 сюжетные вставки)
|
||||
и 51 (дверь принцессы, тоже из заставки). Их черёд — фаза «музыка».
|
||||
|
||||
**Квирк, за которым следить.** Звук ворот у оригинала звучит не всегда, а
|
||||
по условию видимости (`play_door_sound_if_visible`, seg007:1250): либо
|
||||
ворота в комнате слева и стоят в 9-й колонке, либо ворота в НАРИСОВАННОЙ
|
||||
комнате и колонка не 9-я. У нас это параметр `audible` у `animate_door`.
|
||||
|
||||
**Тряска плиты — свой домен prandom.** Оригинал берёт номер сэмпла (20/21/22)
|
||||
из общего генератора и вдобавок «сжигает» один бросок ради совместимости с
|
||||
DOS-версией; у нас последовательности разведены по доменам
|
||||
(`impl_diff.md`), поэтому у тряски свой сид, а холостой бросок не делаем —
|
||||
на розыгрыши физики и кладки это не влияет.
|
||||
|
||||
## 9. Цена звука в тактах (замер 2026-08-20, MAME)
|
||||
|
||||
Вопрос был поставлен так: звук идёт по прерываниям, значит размазан по всем
|
||||
фазам кадра — и если фазы укладываются, всё хорошо? Да, но проверять это
|
||||
надо не по фазам, а по двум числам, потому что **нагрузка от звука
|
||||
постоянная и от сцены не зависит вовсе**.
|
||||
|
||||
### 9.1 Одно прерывание CBL
|
||||
|
||||
Зонды: `bpset` на входе трамплина (`_irq_tramp`) и на `reti` ветки CBL,
|
||||
разница `totalcycles`. 398 замеров в сцене 11/15.
|
||||
|
||||
| величина | значение |
|
||||
|---|---:|
|
||||
| цена одного прерывания | **7 825 тактов ровно**, с разбросом до 8 299 (среднее 8 001) |
|
||||
| период между прерываниями | 245 759 тактов (= 128 сэмплов на 10 937,5 Гц) |
|
||||
| **доля процессорного времени** | **8 001 / 245 759 = 3,26 %** |
|
||||
|
||||
Цена постоянная, потому что работа фиксированная: OTIR ровно 128 байт плюс
|
||||
скобка сохранения контекста. Ветвлений по данным в насосе нет.
|
||||
|
||||
### 9.2 Дрожание обслуживания — риск для ЗВУКА, не для кадра
|
||||
|
||||
Период плавает 228 804 … 262 734, то есть прерывание опаздывает максимум на
|
||||
**~17 000 тактов = 0,8 мс**. Это самая длинная DI-скобка в коде
|
||||
(акселератор режется по 16 строк, memory `sprinter_wait_states_2x`).
|
||||
Буфер CBL — 128 сэмплов = **11,7 мс**, запас **14×**. Недолива быть не
|
||||
может; счётчик `cbl_underruns()` это подтверждает косвенно (наш `fill`
|
||||
всегда возвращает 1, поэтому он ловит только отсутствие данных, не
|
||||
опоздание).
|
||||
|
||||
### 9.3 A/B в одном прогоне (Ctrl+S), сцена 11/15
|
||||
|
||||
Один и тот же кадр, звук выключается на ходу — сравнение чистое.
|
||||
|
||||
| | работа min | работа max | работа avg | прерываний CBL на кадр |
|
||||
|---|---:|---:|---:|---:|
|
||||
| звук ВКЛ | 453 132 | 572 532 | **498 064** | 1,40 |
|
||||
| звук ВЫКЛ | 444 348 | 559 434 | **487 372** | 0,00 |
|
||||
| разница | +8 784 | +13 098 | **+10 692 (+2,2 %)** | |
|
||||
|
||||
Разница на лёгком кадре (+8 784) — ровно одно прерывание, сходится с §9.1.
|
||||
`CBL/кадр = 0` при выключенном звуке подтверждает, что Ctrl+S реально
|
||||
ЗАКРЫВАЕТ CBL, а не глушит сэмпл: иначе насос продолжал бы отдавать блоки
|
||||
тишины и платить те же 3,26 %.
|
||||
|
||||
### 9.4 Укладываемся ли
|
||||
|
||||
Логический кадр (`pop_pace.h`): NORMAL = 4 растра вне боя = **1 720 000
|
||||
тактов**, FASTEST = 3 растра = **1 290 000**.
|
||||
|
||||
| | работа | доля NORMAL | доля FASTEST |
|
||||
|---|---:|---:|---:|
|
||||
| 11/15, обычная позиция | 498 064 | 29 % | 39 % |
|
||||
| 11/15, тяжёлая позиция (Кид на 7 px правее) | 750 066 макс | 44 % | 58 % |
|
||||
|
||||
Период кадра за все прогоны: 1 719 936 … 1 720 752 — ровно 4 растра, ни
|
||||
одного проскока. **Звук занимает 1,1 % бюджета NORMAL и 1,5 % FASTEST.**
|
||||
|
||||
### 9.5 Где 3,26 % МОГЛИ БЫ стоить дорого
|
||||
|
||||
Ответ «всё размазано, если фазы влезли — ок» верен с одной оговоркой.
|
||||
Пейсинг квантован растром: работа 1,00 растра и 1,02 растра дают РАЗНЫЙ
|
||||
период кадра (3 против 4 интервалов), то есть скачок сразу на 20 мс.
|
||||
Значит звук опасен ровно в одной ситуации — когда сцена стоит в пределах
|
||||
~8 000 тактов НИЖЕ кратного растру порога. Сейчас ближайший запас — 540 000
|
||||
тактов до порога FASTEST, то есть в 60 раз больше цены звука. Проверять
|
||||
эту оговорку заново стоит только если работа кадра подберётся к 430 000 или
|
||||
860 000 вплотную.
|
||||
|
||||
## 10. Мусор при включении и щелчок на выходе (разбор 2026-08-20)
|
||||
|
||||
Жалоба: «при старте, когда разрешается звук, проходит кусок мусора».
|
||||
Разобрано записью выхода MAME в WAV (`-wavwrite`) — по огибающей и
|
||||
автокорреляции, а не на слух.
|
||||
|
||||
### 10.1 На старте мусора НЕТ; это настоящие звуки
|
||||
|
||||
От `cbl_open` до первого эффекта в записи **точная цифровая тишина**
|
||||
(размах 1 при разрешении 16 бит). Дальше — два штатных звука:
|
||||
|
||||
| что | когда | длительность |
|
||||
|---|---|---|
|
||||
| `gate_closing_fast` (6) — решётка в комнате СЛЕВА | +0,30 с после `cbl_open` | обрывается на 80 мс |
|
||||
| `soft_land` (17) — Кид приземляется | +0,38 с | 383 мс |
|
||||
|
||||
Опознаны корреляцией огибающих с оригинальными сэмплами `digisnd`:
|
||||
звук 6 даёт +0,72 с начала записи, звук 17 — +0,32 со сдвигом 80 мс.
|
||||
Приземление на старте КОРРЕКТНО: `start_pos` уровня 1 — тайл (0,0), а он
|
||||
`space`, то есть Кид падает на ряд ниже, на площадку с факелами.
|
||||
|
||||
Обрыв первого звука вторым — тоже поведение оригинала, а не наш дефект:
|
||||
`play_digi_sound` (seg009.c:2402) начинается с `stop_digi()`, голос ОДИН.
|
||||
Ощущение «мусора» даёт именно 80-мс огрызок скрежещущей решётки.
|
||||
|
||||
Звук кнопки (3) при этом не слышен: `pop_sfx_play(3)` случается ДО
|
||||
`cbl_open` и глохнет. С SDLPoP совпадает (там на старте тоже только
|
||||
решётка), но держится это на порядке вызовов — если поднимать звук раньше
|
||||
`pop_start_level`, щелчок кнопки станет слышен.
|
||||
|
||||
### 10.2 Незалитый буфер CBL — дефект есть, но в MAME он немой
|
||||
|
||||
Буфер CBL (256 слотов) железо не чистит ни сбросом, ни записью в порт
|
||||
управления, а эта запись сразу пускает воспроизведение с нулевого слота.
|
||||
Значит первые 256 сэмплов (23,4 мс) — то, что лежало раньше. Разбор по
|
||||
`sprinter.cpp`: `case 0x89` делает `m_cbl_cnt = 0; m_cbl_wa = 0`, а
|
||||
прерывание «долей половину» приходит только на 128-м слоте и ставит
|
||||
указатель на ПРОТИВОПОЛОЖНУЮ половину — своими данными звук идёт лишь с
|
||||
третьей половины.
|
||||
|
||||
В MAME это не слышно: эмулируемый буфер стартует нулями, а ЦАП
|
||||
двухдополнительный, то есть 0 = середина шкалы. На ЖЕЛЕЗЕ там
|
||||
неинициализированное ОЗУ — ровно тот мусор, который ловился ещё на
|
||||
тестовых примерах CBL. Лечение — `_cbl_prime` в `cbl_open`: сразу после
|
||||
включения 256 записей байта тишины в порт данных (заранее нельзя, запись
|
||||
проходит только при поднятом bit7). Стоит 6 400 тактов один раз за
|
||||
открытие; за это время таймер уходит на два-три слота.
|
||||
|
||||
### 10.3 Щелчок на выходе — ГОЛОДАНИЕ насоса, вылечено
|
||||
|
||||
На выходе по ESC в записи было **ровно 11 мс шума на полной громкости**
|
||||
(размах 33 671 — громче всего в прогоне), потом мгновенная тишина. 11 мс
|
||||
= один блок CBL (128 сэмплов = 11,7 мс), то есть один пропущенный долив:
|
||||
`pop_shutdown` звал `closegraph`/`pop_bg_free`/`pop_kid_free` (а это
|
||||
ESTEX на каждый атлас) ПРИ ОТКРЫТОМ звуке, насос не успевал, и железо
|
||||
доигрывало несвежую половину.
|
||||
|
||||
Лечение: `pop_sfx_close()` первым действием `pop_shutdown`. Проверено
|
||||
второй записью — всплеска на выходе больше нет. Механизм тот же, из-за
|
||||
которого звук глушится на время загрузки уровня.
|
||||
|
||||
## 11. Приоритеты и перебиваемость: звук у оригинала НЕ «всегда перебивать»
|
||||
|
||||
Пользователь услышал расхождение: у нас решётка обрывалась приземлением
|
||||
Кида, в SDLPoP — доигрывала до звонкого конца, а приземления не было
|
||||
слышно вовсе. Разбор исходника показал, что мы упустили ЦЕЛЫЙ МЕХАНИЗМ.
|
||||
|
||||
### 11.1 Модель оригинала
|
||||
|
||||
```
|
||||
play_sound(id) seg000:12C5 — только НОМИНИРУЕТ кандидата на кадр:
|
||||
if next < 0 || prio[id] <= prio[next]: next = id
|
||||
|
||||
play_next_sound() seg000:1304 — раз в кадр решает, запускать ли:
|
||||
if next >= 0:
|
||||
if !играет_что_то ||
|
||||
(перебиваем[текущий] && prio[next] <= prio[текущий]):
|
||||
текущий = next; запустить
|
||||
next = -1 // НЕ запустили -> номинант ВЫБРОШЕН, очереди нет
|
||||
```
|
||||
|
||||
Три следствия, каждое слышно:
|
||||
|
||||
- **Неперебиваемый звук доигрывает целиком.** У `gate_closing_fast` (6)
|
||||
`interruptible = 0`, поэтому приземление Кида (17) в этот момент
|
||||
пропадает совсем — не откладывается, а именно теряется.
|
||||
- **Внутри кадра выживает важнейший.** Меньше `prio` — важнее; при
|
||||
равенстве побеждает ПОСЛЕДНИЙ (сравнение `<=`).
|
||||
- **Два источника не «чередуются как получится».** Челюсти (47, prio
|
||||
0x10) всегда важнее решётки (4, prio 0x32): решётка не может перебить
|
||||
укус, а укус решётку — может. Отсюда и картина на ур. 9 к. 9, где
|
||||
решётка звучит только в паузах между укусами.
|
||||
|
||||
### 11.2 Что сделано у нас
|
||||
|
||||
`pop_sfx_play` теперь только номинирует; запуск — в `pop_sfx_tick`,
|
||||
который зовётся раз в кадр в конце отрисовки (там же, где оригинал зовёт
|
||||
`play_next_sound`, seg000:954). Таблицы `snd_prio` (57 байт) и битовая
|
||||
карта `snd_intr` (8 байт) — в резиденте, значения из SDLPoP С УЧЁТОМ
|
||||
`fix_sound_priorities()`: в `config.h` SDLPoP `FIX_SOUND_PRIORITIES`
|
||||
определён безусловно, значит сравниваемся мы с исправленным вариантом
|
||||
(звук 10 → 0x0D, 48 → 0x15, 49 перебиваем).
|
||||
|
||||
Створка двери уровня (15) — единственная запись, которую оригинал правит
|
||||
на ходу: перебиваема при закрытии, нет при открытии (seg007:442/464).
|
||||
Держим отдельным байтом `pop_sfx_slide_intr`, чтобы таблица осталась в
|
||||
ПЗУ. Там же добавлен пропущенный `stop_sounds()` на завершении открытия
|
||||
двери (seg007:455) — без него неперебиваемый съезд (1,6 с) блокировал бы
|
||||
очередь.
|
||||
|
||||
Звуки без оцифровки (музыка, длина 0) не номинируются вовсе — порт
|
||||
проверки `if (NULL == sound_pointers[id]) return;`. Раньше такой id
|
||||
глушил живой эффект.
|
||||
|
||||
### 11.3 Проверка
|
||||
|
||||
Записью MAME, старт уровня 1:
|
||||
|
||||
| | всплески | что это |
|
||||
|---|---|---|
|
||||
| до | 135 мс + 210 мс | решётка, обрезанная приземлением на 80 мс |
|
||||
| после | **один, 455 мс** | решётка целиком, корреляция огибающей со звуком 6 **+0,889** |
|
||||
|
||||
Цена: резидент +~250 Б (таблицы + логика), куча ужалась с 347 до 134 Б —
|
||||
довод в пользу давно назревшей реорганизации базовой памяти.
|
||||
|
||||
## 12. Ворота: гейт слышимости и «решётка встала» (2026-08-20)
|
||||
|
||||
Проверка на сцене, которую предложил пользователь — уровень 9, комната 9:
|
||||
кнопка (1,8), челюсти (1,2), а ворота **в комнате 4, тайл (1,9)**, то есть
|
||||
в комнате СЛЕВА. Нашлись три расхождения сразу.
|
||||
|
||||
### 12.1 Гейт слышимости был неверный
|
||||
|
||||
У нас стояло `audible = (room == cur_room)`. У оригинала
|
||||
(`play_door_sound_if_visible`, seg007:1239) правило другое:
|
||||
|
||||
- ворота в комнате СЛЕВА и в колонке 9 — СЛЫШНЫ (створка видна в шве);
|
||||
- ворота в отрисованной комнате и НЕ в колонке 9 — слышны;
|
||||
- особый случай: уровень 3, комната 2 — слышны всегда.
|
||||
|
||||
Сцена 9/9 попадает ровно в первый пункт, поэтому спуск решётки у нас
|
||||
молчал. Подъём при этом совпадал с оригиналом — потому что звук открытия
|
||||
(5) идёт БЕЗ гейта (seg007:386, прямой `play_sound`). Эта асимметрия и была
|
||||
подсказкой.
|
||||
|
||||
Взят вариант под `FIX_GATE_SOUNDS` (условия через ИЛИ): в config.h SDLPoP
|
||||
он определён безусловно.
|
||||
|
||||
### 12.2 Потерян звук «решётка встала» (7)
|
||||
|
||||
`gate_stop()` (seg007:05E3) зовётся из ТРЁХ мест `animate_door` и каждый раз
|
||||
играет звук 7 через гейт слышимости: конец закрытия, открытие насовсем и
|
||||
ветка «уже 0xFF». У нас во всех трёх стояло только `*type = -1` без звука.
|
||||
Добавлено. Лязг после ОБЫЧНОГО открытия (seg007:395) остаётся без гейта —
|
||||
там оригинал зовёт `play_sound` напрямую.
|
||||
|
||||
### 12.3 Кнопка: у оригинала есть параметр playsound
|
||||
|
||||
`trigger_button(playsound, ...)` — в трёх местах он нулевой: вход на уровень
|
||||
(seg003:170), выход Джаффара (seg002:520) и зелье «открыть» (seg006:1890, у
|
||||
нас не портировано). Мы играли щелчок всегда. Добавлен параметр `snd`.
|
||||
|
||||
### 12.4 Почему щелчок кнопки слышно через раз — это НЕ баг
|
||||
|
||||
Бюджет сцены 9/9 (длительности после пересчёта на 10 937,5 Гц):
|
||||
|
||||
| звук | длительность | prio |
|
||||
|---|---:|---:|
|
||||
| челюсти (47) | 465 мс | 0x10 |
|
||||
| решётка вниз (4) | 97 мс | 0x32 |
|
||||
| решётка вверх (5) | 123 мс | 0x37 |
|
||||
| решётка встала (7) | 75 мс | 0x30 |
|
||||
| кнопка (3) | 106 мс | 0x66 |
|
||||
|
||||
Цикл челюстей — 15 кадров = 1229 мс (замерено брейкпоинтом на номинации:
|
||||
25 805 000 тактов между укусами). Значит укус занимает 465 мс, пауза 764 мс.
|
||||
Кнопка (prio 0x66) перебить челюсти не может (0x66 > 0x10), поэтому слышна
|
||||
только если нажатие попало в паузу — примерно в 6 случаях из 10.
|
||||
Подтверждено пользователем на живой сцене.
|
||||
|
||||
### 12.5 Грабли сцены
|
||||
|
||||
Если игра стартует ПРЯМО в комнате с челюстями, они не заводятся сами:
|
||||
нужно сходить Кидом на левую кнопку и вернуться. Это поведение оригинала
|
||||
(trob челюстей создаётся событием), а не наш дефект — учитывать при
|
||||
постановке автотестов.
|
||||
|
||||
## 13. Повторный аудит игрового звука (2026-08-24)
|
||||
|
||||
Проверены три независимых слоя: содержимое атласов, места вызова и живой
|
||||
тракт `play -> tick -> CBL`.
|
||||
|
||||
- В восьми `SND*.ATL` есть все 31 оцифрованных ресурса: `0..23`, `44..49`
|
||||
и `51`; ненулевая страница/длина есть у каждой записи таблицы.
|
||||
- Для всех 30 PCM-эффектов, которые могут возникать непосредственно в игре,
|
||||
есть место вызова. Последним пропуском был звук 10 при столкновении
|
||||
клинков; условие перенесено буквально из `check_sword_vs_sword` SDLPoP.
|
||||
Звук 51 относится к сцене с принцессой, а не к игровому циклу.
|
||||
- Звук 19 «Кид достал меч» проверен в MAME брейкпоинтами. В момент вызова
|
||||
предыдущий PCM уже закончился (`sfx_left=0`), номинация дошла до
|
||||
`pop_sfx_tick`, после чего курсор получил id 19, страницу 5, смещение
|
||||
`0x1500` и длину 2816 байт. В этом прогоне его не подавляли решётка,
|
||||
плиты, шаги или приоритеты. Если он всё ещё субъективно не слышен, искать
|
||||
надо после выбора эффекта — в непрерывности CBL/громкости самого сэмпла.
|
||||
- После продолжительного прогона title/intro/demo/menu CBL обслужил 3303
|
||||
блока и сообщил 0 программных недоливов (`cbl_requests=0x0CE7`,
|
||||
`cbl_underruns=0`). Это исключает возврат `fill=0`, но само по себе не
|
||||
измеряет запоздание прерывания внутри слишком длинной секции `DI`.
|
||||
- Регрессия full-game на входе в Level 1 оказалась именно запозданием CBL:
|
||||
`pop_level_switch` открывал его ДО блокирующего BIOS fade-in. Demo был
|
||||
чистым, потому что включал палитру без fade. Теперь загрузчик оставляет
|
||||
CBL закрытым, а caller открывает его после окончательной палитры/QuickLoad;
|
||||
скрежет на Level 1 исчез в MAME. Однократный щелчок самого первого
|
||||
`cbl_open` за всю MAME-сессию остаётся отдельной низкоприоритетной задачей.
|
||||
- Открытие pause menu у SDLPoP беззвучно; движение играет 21, вход/выход
|
||||
из подменю — 22, изменение настройки — 10. Эти вызовы перенесены. На
|
||||
время полного копирования страницы и файловых операций CBL закрывается,
|
||||
после flip открывается снова: аппаратная половина не должна повторять
|
||||
старые данные и давать «скрежет».
|
||||
|
||||
Отдельно остаётся игровая музыка и сигнальные мелодии без PCM: смерть
|
||||
(`24/28`), начало/появление Shadow (`25`), встреча Jaffar (`29`), большое
|
||||
и малое зелья (`30/33`), Shadow (`32`), победа/меч (`37`), перо (`39`),
|
||||
конец уровня (`41`) и победа над Jaffar (`43`). Вызовы и AY-проигрыватель
|
||||
для них ещё не реализованы; наличие всех PCM-эффектов эту задачу не закрывает.
|
||||
|
||||
|
||||
## 4. МУЗЫКА: решение пересмотрено (2026-08-25) — путь C вместо A
|
||||
|
||||
В §1б первым заходом был выбран **путь A** (ноты PC-спикера на AY), а путь
|
||||
C (запись -> WAV -> CBL) стоил дорого из-за строки «нужен синтезатор на
|
||||
хосте». Это обстоятельство отпало: у пользователя есть **готовые записи
|
||||
DOS-версии** — `applications/PoP/PoP1_DOS_music` (flac/mp3/ogg/ogg_MT-32,
|
||||
22 трека). Синтезировать нечего, остаётся `ffmpeg -ac 1 -ar 10937 -f u8`.
|
||||
|
||||
### Что померено по этим записям
|
||||
|
||||
| группа | длительность | PCM 10 937,5 Гц |
|
||||
|---|---:|---:|
|
||||
| всё вместе (22 трека) | 339 с | **3 625 КБ = 227 EMM-страниц** |
|
||||
| игровые джинглы (10) | 57 с | 608 КБ |
|
||||
| заставки и титры | 282 с | 3 017 КБ |
|
||||
| финальный `won` один | 115 с | 1 233 КБ |
|
||||
|
||||
Свободной EMM на старте ~3 440 КБ, так что вся музыка разом в память не
|
||||
влезает и не должна: трек грузится под сцену и освобождается после.
|
||||
|
||||
### Что сделано
|
||||
|
||||
`toolchain/pop_pack_music.py` -> один файл `MUS\m<id>.bin` на трек
|
||||
(читается порциями по 16 КБ из одного открытого fd) + каталог
|
||||
`pop_music_tbl.h`. Длина трека хранится **порциями по 128 байт**,
|
||||
а не байтами: 169 КБ в uint16 не влезает, 1350 блоков — легко.
|
||||
|
||||
`pop_music.c` (банк 9) грузит трек в EMM и ставит курсор; насос
|
||||
`pop_sfx_fill` (резидент) получил **третий источник**: эффект важнее
|
||||
музыки, музыка важнее тишины. Эффект музыку не сбрасывает — её курсор
|
||||
стоит, пока эффект доигрывает, и она продолжается с места.
|
||||
|
||||
Проверено в MAME записью звука: трек `story_1_absence` на первом экране
|
||||
истории, корреляция огибающих с эталоном **0,836**, RMS 22,6 против 18,3
|
||||
(разница — 8-битное квантование). Эффекты двери в PV-сцене после него
|
||||
звучат как прежде, то есть освобождение страниц и возврат к набору
|
||||
эффектов работают.
|
||||
|
||||
### Цена и что осталось
|
||||
|
||||
* CBL один: пока играет музыка, эффектов нет. Для заставок это не важно
|
||||
(их там не бывает), для игровых джинглов — открытый вопрос §1б.4.
|
||||
* Резидент вырос на 83 байта (третий источник в насосе) плюс 25 байт
|
||||
данных под курсор и таблицу страниц; запас W2 — 151 байт. Кучи в
|
||||
приложении нет (`malloc` не слинкован), так что это чистый запас роста.
|
||||
* Банк 9 занят на 88 %. Следующий модуль туда уже не влезет — либо
|
||||
переносить, либо заводить банк 12.
|
||||
* `won` (77 страниц) в POP_MUS_PAGES=20 не помещается: финал придётся либо
|
||||
резать, либо стримить кусками по ходу.
|
||||
|
||||
|
||||
## 5. СКРЕЖЕТ ПРИ BIOS-ВЫЗОВАХ: причина и решение (2026-08-25)
|
||||
|
||||
Симптом: во время затемнения (fade) звук хрипел — одинаково с играющей
|
||||
музыкой и в тишине. Пользователь заметил ключевое: **повтор тишины обязан
|
||||
звучать тишиной**, значит дело не в недоливе буфера.
|
||||
|
||||
### Как искали
|
||||
|
||||
Отладочные клавиши, каждая делает ровно один кусок fade:
|
||||
|
||||
| клавиша | что делала | результат |
|
||||
|---|---|---|
|
||||
| H | только ожидание 8 кадров | чисто |
|
||||
| V | только чтение палитры | скрежет |
|
||||
| G | затемнение целиком | скрежет |
|
||||
| J | только запись палитры | скрежет |
|
||||
| L | 512 раз `bios_get_place()` — видео не трогает | **скрежет** |
|
||||
|
||||
`L` и решил вопрос: виновата не палитра, а **любой вызов BIOS**.
|
||||
|
||||
### Причина
|
||||
|
||||
Вход в BIOS — это `rst 8`, то есть `out ($7C),a`. В драйвере MAME он
|
||||
правит `m_rom_sys` и вызывает `update_memory()`, которая перестраивает
|
||||
**окно 0**: `m_pages[0]` + `m_bank_view0.select(1)`. ПЗУ ложится ПОВЕРХ
|
||||
страничного регистра.
|
||||
|
||||
Насос CBL брал окно взаймы именно у W0 (`_io_page_w0 = phys` + `OTIR` по
|
||||
адресу < 0x4000). Пока BIOS работает, запись в порт `0x82` ничего не
|
||||
меняет, и `OTIR` вычитывает ПЗУ, отдавая его в звук.
|
||||
|
||||
Побочно выяснилось, почему `DI` вокруг BIOS помогал лишь иногда: он не
|
||||
даёт войти в ISR (тогда блок просто пропускается, что неслышно), но в
|
||||
обработчиках BIOS есть `EI`, так что защита негарантированная.
|
||||
|
||||
### Решение
|
||||
|
||||
Насос переведён на **W3** (идея пользователя): это окно управляется только
|
||||
портом `0xE2`, подмену из прерывания никто не перекрывает, а BIOS во время
|
||||
нашего ISR не исполняется — окно возвращается до выхода.
|
||||
|
||||
```c
|
||||
saved = _io_page_w3;
|
||||
_io_page_w3 = phys;
|
||||
cbl_push_otir((const void *)(0xC000u + ptr), n);
|
||||
_io_page_w3 = saved;
|
||||
```
|
||||
|
||||
После этого BIOS безопасен везде: и палитра, и любые другие функции.
|
||||
Временный обход палитры мимо BIOS (`gfx_pal_write`) стал не нужен — он
|
||||
остался в libbgi как более быстрый примитив (2,5 тыс. тактов на 64 цвета
|
||||
против 10,8 тыс. у BIOS), но игра его не зовёт.
|
||||
|
||||
Бонус: в W3 нет стаба восстановления окна, который в W0 занимал начало
|
||||
страницы, — звуковые страницы можно использовать целиком.
|
||||
|
||||
## 6. ВСЯ ЗАСТАВКА ОЗВУЧЕНА (2026-08-25)
|
||||
|
||||
К `story_1_absence` добавлены остальные четыре трека заставки:
|
||||
**54** intro_theme (титры), **50** story_2_princess, **53**
|
||||
story_3_Jaffar_enters, **52** story_4_Jaffar_leaves (сцена с принцессой).
|
||||
`MUS_IDS` в Makefile — 50 52 53 54 55, всего 1056 КБ на образе.
|
||||
|
||||
### Два слота вместо одного
|
||||
|
||||
Реплики оригинала идут ВСТЫК: следующая начинается там, где кончилась
|
||||
предыдущая, паузы под загрузку нет. Поэтому `pop_music` держит два слота
|
||||
EMM: `pop_music_load*` всегда пишет в НЕ играющий, `pop_music_play`
|
||||
подменяет резидентную таблицу страниц и отпускает прошлый слот. Своей
|
||||
копии таблицы слот не хранит — её и так держит блок EMM, `mem_get_page`
|
||||
отдаёт номер по индексу (иначе −40 байт W2 у игры, а там их нет).
|
||||
|
||||
Плюс **постраничная загрузка**: `pop_music_load_begin` / `_load_step`
|
||||
читают по одной странице за вызов. Страница стоит 33 мс — четверть
|
||||
логического кадра заставки (133 мс), поэтому подкачка следующей реплики
|
||||
прямо посреди анимации не видна. Кто может позволить себе паузу (титры,
|
||||
чёрный экран между сценами) — зовёт прежний `pop_music_load`.
|
||||
|
||||
### Тайминги приведены к шкале оригинала
|
||||
|
||||
Все длительности сцен взяты из SDLPoP в его тиках (60 Гц), а ждём мы
|
||||
кадрами луча (~50 Гц). Пока сцены были немыми, разбег в 20 % не был
|
||||
виден; с музыкой он слышен сразу — реплика кончается раньше картинки.
|
||||
Введён `POP_T60(t)` (pop_cutscene.h), и на него переведены титры,
|
||||
`intro_before_pv`, хвост после PV и пейсинг самой PV-сцены (счётчик
|
||||
потраченных кадров луча против `POP_T60(tick)`, вместо прежних жёстких
|
||||
четырёх кадров на логический).
|
||||
|
||||
**Паузы-реплики.** Там, где оригинал ждёт конца сэмпла, у нас теперь
|
||||
стоит реальная длина нашей записи: m50 — 831 тик, m53 — 985. Отсюда
|
||||
новая шкала PV: конец m50 на 846, вход Джафара (m53) на 1046, уход
|
||||
(m52) на 2073, конец сцены 2500 тиков (было 1959).
|
||||
|
||||
**Fade перед PV** (вопрос пользователя: наш fade короче, 4 ступени против
|
||||
64). Совпасть должен момент полной темноты, считая от пуска m55:
|
||||
у SDLPoP это 80 (transition) + 600 (wait) + 128 (fade_out_2: 0x40 шагов
|
||||
по 2 тика); у нас переход занимает 80 кадров луча = 96 тиков, а fade —
|
||||
5 тиков. Отсюда `WAIT = 80 + 600 + 128 − 96 − 5 = 707` тиков. Дальше и
|
||||
там и там экран уже чёрный, а трек доигрывает: этой паузой заставка и
|
||||
стыкуется с PV.
|
||||
|
||||
**Музыка переживает смену сцен.** m54 звучит с титров и до первого
|
||||
экрана истории (`pop_intro_show` больше не глушит CBL на входе), m52
|
||||
начинается в PV и доигрывает уже на экране «свадьбы» — как seg000:2051.
|
||||
|
||||
Проверено в MAME: цепочка 54 → 55 → 50 → 53 → 52 отыгрывается целиком,
|
||||
курсор `pop_mus_id` меняется ровно на своих кадрах, интро доходит до
|
||||
демо-режима. Слуховая проверка (нет ли хрипа от диска при играющей
|
||||
музыке) — за пользователем.
|
||||
|
||||
## 7. МУЗЫКА ПО ХОДУ ИГРЫ (2026-08-26)
|
||||
|
||||
Звуки 24..43 в наборе оцифровки ПУСТЫЕ — в оригинале это Adlib-музыка, и
|
||||
в digisnd её нет вовсе. На этом и построено подключение: `pop_sfx_play`
|
||||
для звука с нулевой длиной не пытается его играть, а кладёт НОМЕР в
|
||||
`pop_mus_req` (один байт). Заявку разбирает `pop_music_service()` — один
|
||||
вызов на кадр из любого цикла (игрового, интро, катсцены); всё чтение с
|
||||
диска живёт там.
|
||||
|
||||
**Стриминг вместо загрузки.** Ждать полной загрузки джингла нельзя — это
|
||||
фриз на треть секунды посреди игры. `pop_music_stream` читает ПЕРВУЮ
|
||||
страницу (33 мс) и сразу пускает трек: она звучит 1,5 с, а следующая
|
||||
читается те же 33 мс — запас сорокакратный. Остальные доливаются по
|
||||
одной за кадр, пока `pop_music_loading()`. Номера страниц известны сразу
|
||||
после `mem_alloc_pages`, поэтому таблица для насоса заполняется целиком —
|
||||
данные появятся раньше, чем насос до них дойдёт.
|
||||
|
||||
**Что и где играет** (номера и места — из SDLPoP):
|
||||
|
||||
| трек | событие | место у нас |
|
||||
|------|---------|-------------|
|
||||
| 24 / 28 / 32 | смерть: обычная / в бою / от руки тени | `ctrl_kid_death` |
|
||||
| 25 | вступление 1-го уровня (Кид сидит), тень 6-го | `control_crouched`, `guards.c` |
|
||||
| — | НА ДЕМО-УРОВНЕ музыки нет вовсе: там одни эффекты | гейт в `pop_music_service` |
|
||||
| 27 / 35 / 40 | сцены перед 2/4/6/12, 8/9, «времени мало» | `pop_pre_cutscene_show` |
|
||||
| 29 | встреча с Джафаром | `pop_meet_jaffar` |
|
||||
| 30 / 33 | большая склянка / малая | `pop_proc_get_object` |
|
||||
| 36 | время вышло | `pop_time_expired_show` |
|
||||
| 37 / 43 | меч найден, страж убит / смерть Джафара | `pop_proc_get_object`, `on_guard_killed` |
|
||||
| 39 | перо (медленное падение) | `pop_proc_get_object` |
|
||||
| 41 / 32 | конец уровня / конец 4-го (тень) | опкод SND_LEVEL в `play_seq` |
|
||||
| 26 | встреча с принцессой | `cut_ending` |
|
||||
|
||||
**Вступление первого уровня — автомат, а не «звук при приседе»** (seg005:02EB).
|
||||
Пока `need_level1_music` не ноль, `control_crouched` НЕ ЧИТАЕТ управление:
|
||||
Кид сидит, тема играет, и лишь когда она смолкла, он может встать. Наша
|
||||
первая версия просто играла трек при первом приседе — и тема догоняла
|
||||
игрока посреди уровня (пробежал, спрыгнул, присел — заиграла). Признак
|
||||
«ещё звучит» берём у курсора насоса `pop_mus_left`: он резидентный, и если
|
||||
музыка выключена, курсор остаётся нулём — Кид просто встаёт.
|
||||
|
||||
Темы, которые звучат один раз за заход на уровень (вступление 1-го, тень
|
||||
6-го), сбрасывает `pop_music_level_start()` из `pop_start_level`. Оригинал
|
||||
для этого портит переменную двери (`leveldoor_open = 0x4D`) — у нас на это
|
||||
есть свои два байта.
|
||||
|
||||
**ГДЕ КОНЧАЕТСЯ МУЗЫКА СЦЕНЫ** (уточнено 2026-08-26). Сначала мы отдали
|
||||
трек «доигрывать в игре»: у оригинала load_intro после сцены просто гасит
|
||||
экран и возвращает управление. На слух оказалось хуже, чем в оригинале —
|
||||
музыка спотыкается: загрузка уровня (ESTEX плюс сборка комнаты) не даёт
|
||||
насосу долить блок вовремя. У DOS-версии этой проблемы нет, там звук
|
||||
живёт своей жизнью на аппаратуре.
|
||||
|
||||
Поэтому дослушиваем ПОД ЧЁРНЫМ ЭКРАНОМ, до отрисовки уровня: следующий
|
||||
load_intro у оригинала и так начинается с ожидания тишины (seg001:681), то
|
||||
есть к новому уровню трек в любом случае смолкает. Пропуск сцены обрывает
|
||||
и музыку — игрок нажал клавишу, чтобы идти дальше.
|
||||
|
||||
**ДЛИТЕЛЬНОСТЬ FADE.** fade_in_1/fade_out_1 — это 64 шага палитры по два
|
||||
тика, 128 тиков = 2,13 с; сцена перед уровнем 2 занимает с ними около семи
|
||||
секунд. Наши четыре ступени укладывались в восемь сотых секунды, и сцена
|
||||
выходила втрое короче. Теперь `INTRO_FADE = POP_T60(128)`, а ступеней в
|
||||
`pop_ui_palette_dim` тридцать две вместо четырёх: на четырёх растянутых
|
||||
ступенях затемнение выглядело бы скачками. Половина от оригинальных 64 —
|
||||
на глаз от них не отличается (ступень каждые 66 мс), а вот шестнадцать уже
|
||||
видно. Сумма «fade in + сцена + fade out» при этом совпадает с оригиналом
|
||||
сама собой: длительность каждого fade та же, что у fade_*_1.
|
||||
|
||||
**Цена ступени** (замеры в MAME 2026-08-26, такты 21 МГц; кадр 430 000):
|
||||
|
||||
| версия | такты | что изменилось |
|
||||
|--------|-------|----------------|
|
||||
| исходная | 2 440 000 | снимок копировался побайтовым циклом на C |
|
||||
| + таблица яркости на стеке | 1 250 000 | 768 умножений uint16 заменены 256 сложениями |
|
||||
| + memcpy для снимка | 487 000 | LDIR вместо цикла — главный выигрыш |
|
||||
|
||||
Из оставшихся 487 тысяч 136 тысяч — заливка палитры через BIOS (8 вызовов
|
||||
`gfx_pal_load` по 17 000). Дальше можно было бы хранить готовые таблицы
|
||||
яркости файлом, но при 1,2 мс на построение это уже незаметно.
|
||||
|
||||
ВАЖНО: ступень пересчитывается только когда она СМЕНИЛАСЬ. Наивный цикл
|
||||
«ступень на каждый кадр» звал пересчёт сто раз и растягивал fade до
|
||||
десяти с лишним секунд.
|
||||
|
||||
## 8. ПОТОКОВЫЙ ТРЕК: ФИНАЛЬНАЯ ТЕМА (2026-08-26)
|
||||
|
||||
`won` (56) — 115 с, 1,2 МБ, 78 страниц EMM. В память он не влезает ни при
|
||||
каком бюджете, поэтому играется КОЛЬЦОМ из шести страниц (96 КБ = 9 с): насос
|
||||
идёт по кругу, а `pop_music_service` дочитывает файл в те страницы, которые
|
||||
насос уже прошёл.
|
||||
|
||||
**Кто кого догоняет.** Страница звучит 1,5 с, а читается 33 мс — запас
|
||||
сорокакратный. Дистанция считается без отдельных счётчиков: страница ровно
|
||||
128 блоков насоса, поэтому проигранных страниц = (всего блоков − осталось)
|
||||
/ 128. Пока прочитано меньше, чем проиграно плюс размер кольца, в кольце
|
||||
есть свободный слот. Файл читается ПОСЛЕДОВАТЕЛЬНО, без `lseek`.
|
||||
|
||||
**Что пришлось учесть.**
|
||||
|
||||
* Насос заворачивает страницу только при `pop_mus_ring != 0`; конец трека
|
||||
по-прежнему определяет `left`. Обычный трек этой ветки не касается.
|
||||
* Кольцо обязано сниматься при любом обычном запуске (`pop_music_play`,
|
||||
`_stream`, `_load_begin`): иначе следующий трек играет по кругу первых
|
||||
шести страниц — поймано на титрах сразу после победы.
|
||||
* Живые сцены комнаты принцессы открывают CBL сами (`cut_begin`):
|
||||
`pop_ending_show` глушит звук первым действием, и «arrived to princess»
|
||||
(26) не звучал вовсе.
|
||||
* Тема дослушивается до конца (прерывается клавишей), как `while
|
||||
(check_sound_playing() && !key_test_quit())` в seg001:637. У оригинала
|
||||
между титрами и этим ожиданием стоит ввод имени в таблицу рекордов —
|
||||
когда он появится у нас, ожидание переедет за него (задача HOF-ENTRY).
|
||||
|
||||
Проверено в MAME на сборке `LEVEL=14`: после встречи с принцессой звучит
|
||||
тема победы (`pop_mus_id` = 56, `pop_mus_ring` = 6), курсор уходит далеко
|
||||
за размер кольца — то есть подкачка успевает.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Текст в нижней статус-строке (строке HP) — полная инвентаризация SDLPoP
|
||||
|
||||
Разбор `SDLPoP/src/` на 2026-08-25. Цель — знать ВЕСЬ набор сообщений,
|
||||
которые оригинал печатает в ту же полосу, где нарисованы деления HP,
|
||||
и правила их появления/исчезновения. Это входные данные для порта:
|
||||
у нас пока туда пишется только `GAME PAUSED`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Геометрия: одна полоса на HP и на текст
|
||||
|
||||
```
|
||||
rect_bottom_text = { top 193, left 70, bottom 202, right 250 } // data.h:217
|
||||
display_text_bottom: draw_rect(чёрным) + show_text(halign_center, valign_bottom)
|
||||
```
|
||||
|
||||
* Деления HP **Кида** — от `x = 0` вправо, шаг 7, максимум 10 → занимают `x 0..69`.
|
||||
* Деления HP **стража** — от `x = 314` влево, шаг 7, максимум 10 → занимают `x 245..320`.
|
||||
* Текст живёт РОВНО в промежутке `x 70..250` и по X с делениями не пересекается.
|
||||
* По Y деления на `y = 194..200`, текст (`valign_bottom` к 202) — на `y = 195..201`,
|
||||
то есть на строку ниже. Именно поэтому в оригинале текст выглядит «сидящим»
|
||||
чуть ниже стрелок HP.
|
||||
|
||||
**У нас**: `POP_HP_Y = 194` (`pop_cdraw.h`), экран сдвинут на `POP_YOFF = 28`,
|
||||
базовая линия крупного шрифта `POP_YOFF + POP_HP_Y + 8 = 230` — силуэт
|
||||
ложится на `223..229`, то есть та же картинка.
|
||||
|
||||
## 2. Два примитива и два таймера
|
||||
|
||||
| Имя | Что делает |
|
||||
|-----|------------|
|
||||
| `display_text_bottom(text)` (seg008:2644) | стереть прямоугольник цветом 0 и напечатать текст по центру |
|
||||
| `erase_bottom_text(arg)` (seg008:266D) | стереть прямоугольник; при `arg != 0` ещё и обнулить оба таймера |
|
||||
| `text_time_remaining` | сколько игровых тиков сообщение ещё висит; 0 — ничего не висит |
|
||||
| `text_time_total` | **идентификатор сообщения**, а не только его длительность |
|
||||
|
||||
Обработка тика — в `draw_game_frame`/`idle` (seg000:956). Комментарий в
|
||||
оригинале прямой: *«Note: texts are identified by their total time!»* Значения
|
||||
`text_time_total`, у которых есть особое поведение:
|
||||
|
||||
| `total` | Смысл | Что происходит по истечении |
|
||||
|---------|-------|------------------------------|
|
||||
| 12 | «1 SECOND LEFT» | обычное стирание |
|
||||
| 24 | обычное короткое сообщение | обычное стирание |
|
||||
| 36 | смерть на демо-уровне (0) или на уровне зелий (15) — **текста нет** | `start_game()` — рестарт игры |
|
||||
| 288 | «Press Button to Continue» | `start_game()` — рестарт игры |
|
||||
| 1188 | защита от копирования (уровень 15) | **не убывает и не исчезает** |
|
||||
|
||||
Мигание: при `total == 288` и `remaining < 72` сообщение мигает с периодом 12
|
||||
тиков — 4 тика видно (`blink_frame <= 3`), 8 нет; в кадре `blink_frame == 3`
|
||||
заново печатается текст и играет звук 38 (`sound_38_blink`).
|
||||
|
||||
Сброс: `init_game()` (seg003:32) обнуляет оба таймера и `is_show_time` — то есть
|
||||
любое сообщение умирает на старте уровня.
|
||||
|
||||
---
|
||||
|
||||
## 3. Полный список сообщений
|
||||
|
||||
### 3.1. Состояние программы
|
||||
|
||||
| Текст | Где | Таймер |
|
||||
|-------|-----|--------|
|
||||
| `GAME PAUSED` | seg000:1769, пока `is_paused` | **без таймера**: печатается на входе в паузу, `erase_bottom_text(1)` на выходе (seg000:1784) |
|
||||
|
||||
### 3.2. Уровень и оставшееся время (`show_level` / `show_time`, seg008)
|
||||
|
||||
| Текст | Условие | `total` |
|
||||
|-------|---------|---------|
|
||||
| `LEVEL %d` | `show_level()` при старте уровня; только `1..12` (`hide_level_number_from_level = 14`), не при `seamless`; уровень 13 показывается как **12** (`level_13_level_number`) | 24, дальше сразу `is_show_time = 1` |
|
||||
| `%d MINUTES LEFT` | каждая минута, кратная 5, и каждая из последних 5 | 24 |
|
||||
| `%d SECONDS LEFT` | последняя минута, раз в 12 тиков | 24 |
|
||||
| `1 SECOND LEFT` | остался 1 с | **12** |
|
||||
| `TIME HAS EXPIRED!` | `rem_min == 0` | 24 |
|
||||
| `%d MINUTES PASSED` / `1 MINUTE PASSED` | только SDLPoP (`ALLOW_INFINITE_TIME`), при отрицательном таймере | 24 |
|
||||
|
||||
Что взводит `is_show_time` (все → следующий кадр печатает время):
|
||||
|
||||
* **Space** — seg000:612, штатная клавиша оригинала «сколько осталось»;
|
||||
* читы **`-`/`+` numpad** (изменение времени) — seg000:762 / 777, при этом
|
||||
таймеры сообщения обнуляются, чтобы новое напечаталось немедленно;
|
||||
* **смерть Джафара** — `on_guard_killed()` seg006:1936, уровень 13
|
||||
(`jaffar_victory_level`): вспышка + показать время;
|
||||
* истечение очередной минуты — seg008:1796;
|
||||
* сразу после `show_level()`.
|
||||
|
||||
Обнуляет `is_show_time`: `play_kid()` при смерти (seg006:1365) и
|
||||
`show_copyprot(1)` (seg000:2385).
|
||||
|
||||
### 3.3. Смерть Кида
|
||||
|
||||
| Текст | Где | `total` |
|
||||
|-------|-----|---------|
|
||||
| `Press Button to Continue` | `play_kid()` seg006:1383 — умер на обычном уровне | **288** (мигает, затем рестарт игры) |
|
||||
| *(без текста)* | тот же код, но уровень 0 (демо) или 15 (зелья) | **36** (тихая пауза, затем рестарт игры) |
|
||||
|
||||
Стирается: `fell_out()` (seg006:1342, упал из комнаты 0) и чит **R**
|
||||
(воскрешение, seg000:783) — оба зовут `erase_bottom_text(1)`.
|
||||
|
||||
### 3.4. Сохранение и загрузка
|
||||
|
||||
| Текст | Клавиша | `total` |
|
||||
|-------|---------|---------|
|
||||
| `GAME SAVED` / `UNABLE TO SAVE GAME` | Ctrl+G (`save_game`, seg000:2211) | `total` не ставится, `remaining = 24` |
|
||||
| `QUICKSAVE` / `NO QUICKSAVE` | F6 (расширение SDLPoP, seg000:497) | 24 |
|
||||
| `QUICKLOAD` / `NO QUICKLOAD` | F9 (расширение SDLPoP, seg000:514) | 24 |
|
||||
|
||||
### 3.5. Ответы на клавиши (`answer_text` → `need_show_text`, все `total = 24`)
|
||||
|
||||
| Текст | Клавиша |
|
||||
|-------|---------|
|
||||
| `SOUND ON` / `SOUND OFF` | Ctrl+S |
|
||||
| `KEYBOARD MODE` | Ctrl+K |
|
||||
| `JOYSTICK MODE` / `JOYSTICK NOT FOUND` / `JOYSTICK UNAVAILABLE` | Ctrl+J |
|
||||
| `PRINCE OF PERSIA V1.0` (в SDLPoP заменено на `SDLPoP v%s`) | Ctrl+V |
|
||||
| `SDL COMP v… LINK v…` | Ctrl+C — только SDLPoP |
|
||||
|
||||
### 3.6. Отладочные читы (`cheats_enabled`, `total = 24`)
|
||||
|
||||
| Текст | Клавиша | Смысл |
|
||||
|-------|---------|-------|
|
||||
| `S%d L%d R%d A%d B%d` | `C` | номер отрисованной комнаты и её соседей L/R/A/B |
|
||||
| `AL%d AR%d BL%d BR%d` | Shift+`C` | диагональные соседи |
|
||||
|
||||
### 3.7. Защита от копирования (только уровень 15)
|
||||
|
||||
| Текст | Где | `total` |
|
||||
|-------|-----|---------|
|
||||
| `WORD %d LINE %d PAGE %d` | `show_copyprot(1)` seg000:2389 | **1188** — висит, пока не сменится уровень |
|
||||
|
||||
### 3.8. Только SDLPoP, в оригинале 1989 отсутствует
|
||||
|
||||
| Текст | Где |
|
||||
|-------|-----|
|
||||
| `RECORDING`, `REPLAY SAVED`, `REPLAY CANCELED` | replay.c:599/626/628 |
|
||||
| имя файла скриншота | screenshot.c:62 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Что из этого касается нашего порта
|
||||
|
||||
Реализовано (`pop_status.c`, таймер 24 тика как у оригинала):
|
||||
|
||||
* `GAME PAUSED` — без таймера, рисует само меню (`pop_menu.c`);
|
||||
* `QUICKSAVE` / `NO QUICKSAVE`, `QUICKLOAD` / `NO QUICKLOAD` — заявка стоит
|
||||
в `pop_qsave_process`, то есть в единственном месте, где известно, что
|
||||
именно делали. Лейбл печатается ДО дисковой операции — осознанное
|
||||
расхождение, см. `impl_diff.md`;
|
||||
* `SOUND ON` / `SOUND OFF` — Ctrl+S;
|
||||
* `LEVEL %d` — порт `show_level()` целиком: демо-уровень 0 и номера от 14
|
||||
молчат, тринадцатый показывается двенадцатым, бесшовный переход 12→13
|
||||
пропускается и гасит флаг за собой.
|
||||
|
||||
* вся группа времени — `N MINUTES LEFT`, `N SECONDS LEFT`, `1 SECOND LEFT`,
|
||||
`TIME HAS EXPIRED!`. Флаг `pop_show_time` (порт `is_show_time`) взводит
|
||||
само ядро таймера на круглых пятёрках и каждую секунду последней минуты,
|
||||
а также старт уровня и читы времени; значение 2 означает «перебить
|
||||
текущую строку», как оригинал делает в последнюю минуту;
|
||||
* `Press Button to Continue` — висит бессрочно (`MSG_HOLD`), уровень
|
||||
перезапускает кнопка. Расхождение с оригиналом, см. `impl_diff.md`.
|
||||
|
||||
Пока НЕ печатается:
|
||||
|
||||
* номера комнат (`C`/Shift+`C`) — у нас отдельная отладочная строка;
|
||||
* copy protection и SDLPoP-расширения (replay, скриншоты) — не нужны.
|
||||
|
||||
Отладочная строка вдобавок показывает оставшееся время `##:##` у правого
|
||||
края. На табло уходит `minutes-1`: у оригинала `rem_min` — это НОМЕР идущей
|
||||
минуты, а не остаток целых (старт 60 при `rem_tick` 719 = «почти 60:00»).
|
||||
Секунды считаются делением раз в 12 кадров, а не каждый кадр.
|
||||
|
||||
Нам не нужно: copy protection (уровень 15 исключён из порта — см.
|
||||
`full_game_plan.md`), joystick-режимы, replay, скриншоты.
|
||||
|
||||
Механика, которую придётся портировать целиком, если брать группу времени:
|
||||
пара таймеров `text_time_total`/`text_time_remaining` с семантикой
|
||||
«идентификатор сообщения» — иначе не воспроизвести ни мигание, ни рестарт по
|
||||
истечении 36/288.
|
||||
|
||||
---
|
||||
|
||||
## 5. Цена вывода и что делать, если упрёмся
|
||||
|
||||
Блит одного глифа стоит ~4,6 тыс. тактов почти независимо от размера — это
|
||||
цена вызова, а не пикселей (memory `blit_cost_model`). Полсотни символов =
|
||||
полкадра. Что уже сделано в `pop_status.c` / `pop_ui.c`:
|
||||
|
||||
* **change-driven**: пока показанное не изменилось, не рисуем вовсе;
|
||||
* **по полям**: смена комнаты — две цифры (~9 тыс. тактов, 2% кадра), а не
|
||||
вся строка; подписи рисуются только при полной инвалидации;
|
||||
* **пробелы не блитятся**: их глиф целиком прозрачен, а стоит как буква —
|
||||
на полной отладочной строке это девять сэкономленных блитов;
|
||||
* **заливка только поля** при входе в комнату (`pop_screen_fill_field`):
|
||||
борта от комнаты к комнате не меняются, это и четверть заливки, и то, что
|
||||
обе полосы вход переживают.
|
||||
|
||||
Запас, если бюджета всё же не хватит (идеи пользователя, 2026-08-25):
|
||||
|
||||
1. **Растянуть вывод на несколько кадров, не показывая полуготовую строку.**
|
||||
Печатать по нескольку букв за кадр, держа цвет шрифта чёрным (отдельный
|
||||
индекс палитры), а по готовности подменить этот индекс на белый — строка
|
||||
появится целиком и мгновенно. Стоит ноль байт памяти и укладывается в
|
||||
нашу же технику «два разных чёрных» (`POP_COL_OUTSIDE`).
|
||||
2. **Собирать строку в один спрайт** в свободном хвосте страницы шрифта и
|
||||
блитить одним вызовом. Дороже по подготовке (~35 тыс. тактов на
|
||||
копирование), но выгодно там, где строка ЦЕЛИКОМ меняется каждый раз.
|
||||
Для меню этот путь уже рассматривался и был отвергнут; для статус-строк
|
||||
он имеет смысл только вместе с п.1.
|
||||
|
||||
Про QuickSave/QuickLoad оптимизация не нужна вовсе: там игра и так стоит на
|
||||
время дисковой операции.
|
||||
|
||||
## 6. Ловушка: свисающие глифы
|
||||
|
||||
Зона стирания текста обязана захватывать строку НИЖЕ базовой линии. В малом
|
||||
шрифте `'p'` имеет высоту 7 при ascent 5, `','` — 6: они свисают под базовую
|
||||
линию. Стирание ровно до неё оставляло от хвоста «p» в «Speed:» одинокую
|
||||
точку (поймано в MAME 2026-08-25).
|
||||
Reference in New Issue
Block a user