feat: complete engine and editor subsystems (Stages 1-5 from TODO_ARTEM)

- Stage 1: Eliminate fish-eye distortion with precomputed tangential ray offset table and fractional DDA depth
- Stage 2: Full asset management in EblanStudio (stb_image/stb_image_write, 128x128 BGRA texture packaging, WAV sound manager)
- Stage 3: Multi-level room streaming, LevelTransition/CELL_PORTAL trigger system, 16-frame screen fade to black
- Stage 4: 60Hz cutscene engine in pure x86 ASM (v4 binary track, waypoint camera motion, shortest-arc pan, CP1251 Russian subtitles, letterbox overlay, audio)
- Stage 5: EblanStudio cutscene timeline (F6) and windowless Live ASM Viewport over shared-memory IPC directly rendered into DX11
- Tooling & Tests: comprehensive regression test suite (94 editor + 107 engine tests), IPC tests, and cutscene demo
This commit is contained in:
Artvasil09 2026-10-22 16:24:22 +03:00
parent 01b9aea697
commit a1d1baa869
54 changed files with 16131 additions and 476 deletions

View file

@ -6,7 +6,7 @@
- **Language**: Pure x86 Assembly (MASM32 / MSVC `ml.exe`).
- **Dependencies**: Windows API only (`kernel32.lib`, `user32.lib`, `gdi32.lib`, `winmm.lib`). No external DLLs, DirectX, OpenGL, or C runtimes.
- **Rendering**: Software DIBits raycaster (Wolfenstein 3D style DDA grid stepping, texture mapping, distance shading, billboard sprites, 2D UI canvas with 8x8 bitmap font).
- **Coordinate Space**: 16.16 signed fixed-point math (`FixedMul`, `FixedDiv`). Trigonometry via 512-entry precomputed lookup tables (`tableSin`, `tableCos`).
- **Coordinate Space**: Player/entity positions are raw world units (64 per cell). Trigonometry and sprite scale use 16.16; DDA distances retain 8 fractional bits (24.8) until projection. Trigonometry uses 512-entry lookup tables.
---
@ -137,7 +137,7 @@ invoke DrawNumber8x8, 120, 42, playerHealth, COLOR_WHITE
The engine supports both procedural default generation and binary level loading. When `assets/map.dat` exists, `LoadMapBinary` in `include/engine_map.inc` automatically loads it:
- **Header (`MapHeader`, 38 bytes, packed)**:
- `magic` (uint32): `0x4E4C4245` ("EBLN")
- `version` (uint16): `2` (v1 legacy: sprite coords are 16.16 fixed, converted on load)
- `version` (uint16): `4` (readers also accept v1 fixed sprite coordinates and v2/v3 raw-coordinate maps, including early v2 maps with transitions)
- `mapWidth` (uint16): `32`
- `mapHeight` (uint16): `32`
- `playerStartX` (uint16): cell X coordinate (e.g. 2)
@ -146,13 +146,23 @@ The engine supports both procedural default generation and binary level loading.
- `spriteCount` (uint16): count of sprite entries
- `ceilingColor` (uint32): 00RRGGBBh
- `floorColor` (uint32): 00RRGGBBh
- `reserved[12]` (bytes): reserved padding
- `transitionCount` (uint16): 0..16 (v1 ignores this formerly reserved field)
- `cutsceneCount` (uint16): 0..128 commands (offset 28, v4 only)
- `cutsceneStringCount` (uint16): 0..32 strings (offset 30, v4 only)
- `cutsceneFlags` (uint16): bit 0 = autoplay on room entry (offset 32, v4 only)
- `reserved[4]` (bytes): reserved padding
- **Grid Data**: `32 * 32 = 1024` bytes (row-major order). Cell value = texture slot (1 brick, 2 wood, 3 stone, 4 metal, 5 door, 7 pillar, 8 glass, 9 marble).
- **Sprite Array**: `spriteCount * 24` bytes (`SpriteStruct`: `x`, `y` in raw world units, `textureId`, `scale` 16.16, `dist`, `flags`).
- **Transition Array**: `transitionCount * 42` bytes (`LevelTransition` in both `engine_defs.inc` and `editor/src/level_format.h`). Target names are ASCII basenames, 5..31 bytes including `.dat`, with a terminating NUL inside `targetMap[32]`. Put linked maps in the same assets directory. Triggers must be unique and occupy empty, open-door or portal cells; every portal cell needs a transition.
- Files are validated completely before replacing the active room. Counts above capacity, truncated/extra payload, invalid coordinates/scales, blocked spawns and invalid transition names are rejected. Black ceiling/floor colors are valid. A failed runtime transition keeps the current room; a failed startup load creates the procedural map.
- **Cutscene Array (v4)**: after transitions, `cutsceneCount * 16` bytes; followed by `cutsceneStringCount * 96` bytes of NUL-terminated CP1251 strings. No serialized pointers. Earlier versions ignore the new header fields and load an empty track.
- New saves use v4 without rewriting existing map files until explicitly saved. Sprite positions must remain inside the map; scales accepted by loaders are 0..16.0 (0 keeps the legacy 1.0 behavior).
Human users can design levels visually using `run_editor.bat` (`editor/editor.exe`, written in C++17 + ImGui + DirectX 11), with 1-click Playtest (F5).
AI agents can directly read or write `assets/map.dat` following this specification.
F5 copies neighboring maps and resources into a dedicated temporary directory, writes the current unsaved map there under its original filename, validates all reachable destinations, and launches `engine.exe "assets\selected-map.dat"`. It never replaces the source `assets/map.dat` or changes the editor's current file path. Starting the engine without an argument still loads `assets/map.dat`.
### F. Texture Pack (`assets/textures/texturepack.bin`)
At startup the engine always generates procedural textures first, then
`LoadTexturePack` tries to override them from `assets/textures/texturepack.bin`:
@ -163,3 +173,37 @@ Rebuild the pack from the KAZIK 48-slot atlas (`casino3d-hd.bin`) or fully proce
python tools\make_texturepack.py
```
If the pack is missing or invalid, the engine silently keeps procedural textures.
The loader stages and validates the complete pack before replacing pixels. Slots 10..15 are sprite slots; `CELL_PORTAL` uses a separate `portalTextureBuffer`, so importing slot 10 never changes portals. First import creates missing directories and seeds untouched slots with procedural defaults. Import/reset writes are atomic; an invalid existing pack is preserved and reported to the user.
### G. Regression Checks
Run `python tools\test_regressions.py` on Windows with the build toolchains installed. The scenarios compile the real editor logic and ASM procedures, exercise file I/O, playtest isolation, transitions, keyboard edges, renderer geometry, cutscene validation and editor undo in TEMP, and verify that project assets remain unchanged. C++ preview and ASM camera/angle/fade/subtitle states are compared frame by frame. Use `--keep` to retain generated framebuffers and fixtures. These checks do not replace a manual UI playtest.
### H. Cutscenes (v4)
`include/engine_cutscene.inc` owns validation, playback and overlays. `CutsceneEvent` is packed: `uint16 tickTimestamp`, `uint8 opCode`, `uint8 param8`, `int32 paramA`, `int32 paramB`, `int32 dataId`. Time uses 60 Hz ticks, timestamps 0..65535, individual durations 0..3600 ticks. Commands are sorted by timestamp; a nonempty track ends with a single final END.
| Opcode | paramA | paramB | dataId / param8 |
|---|---|---|---|
| 0 END | ignored | ignored | Returns control, retains camera pose |
| 1 WAIT | duration | ignored | Delays dispatch; existing tweens continue |
| 2 MOVE_CAM_TO | raw world X | raw world Y | dataId = duration (0 snaps) |
| 3 PAN_CAM_TO | target angle 0..511 | duration | Shortest arc |
| 4 SHOW_SUBTITLE | string index | duration (0 persistent) | CP1251, up to 95 characters |
| 5 HIDE_SUBTITLE | ignored | ignored | Clears subtitle |
| 6 PLAY_SOUND | string index, or -1 to stop | ignored | param8 bit 0 = loop |
| 7 FADE_SCREEN | darkness 0..16 | duration | 0 clear, 16 black |
| 8 CHANGE_SUBLEVEL | transition index | ignored | Uses existing transition destination/spawn/fade |
MOVE and PAN interpolate linearly using signed integer math. Camera endpoints must be walkable; movement segments ignore collisions. Each tick advances existing tweens, expires text and waits, then dispatches due commands in stored order. Same-timestamp commands can start independent movement, pan and fade together. WAIT delays overdue commands without shifting their timestamps. A replacement tween starts from the current pose.
Autoplay begins after a room transition finishes. C starts/replays the current map's track; Tab skips it. Movement/actions are blocked during playback. End/skip/level changes stop cinematic sound, clear subtitles and fade, and return control at the current camera pose. Letterbox is 2.35:1, subtitles use up to 3 centered rows of 38 glyphs over a half-brightness panel. WAV names are ASCII basenames of up to 63 characters in `assets/sounds`; playback uses one asynchronous PlaySound channel.
F6 opens EblanStudio's timeline. Author commands with buttons, drag diamonds to change timestamps, select overlapping events through the command list, and place MOVE points by clicking the map. The inspector edits parameters and strings. Autoplay, scene data and strings participate in save/load and undo/redo. Preview shows the actual game perspective in the embedded viewport with the camera path on the map; F5 runs the full game, validates referenced WAV files and stages them with linked maps. Editor preview stops at a level-change command. F5/F6 are handled before ImGui keyboard-capture checks.
`examples/cutscene_demo/run_demo.bat` runs an isolated two-room example with camera waypoints, Russian subtitles, WAV, fades and a scripted transition. Regenerate it with `python tools/make_cutscene_demo.py`.
### I. Embedded Game View
The x64 editor uses a windowless x86 `engine.exe --preview:Local\EblanPreview-...` process to execute the actual `Render3DScene`, cinematic overlay and fade procedures. No duplicate raycaster. `editor/src/preview_protocol.h` and `include/engine_preview.inc` define a versioned 1,357,792-byte shared-memory mailbox: 128-byte header, 1024 cells, 32 sprites, a 96-byte CP1251 subtitle, 16 texture slots and a 320x240 DIB frame. Header state is 0 idle, 1 engine owns payload, 2 editor owns result, 3 shutdown. Publish with InterlockedExchange/xchg; never modify payload while state is 1. All fields are fixed-width; do not put pointers or size_t into the wire layout.
The renderer validates camera and sprite bounds, uploads textures only when their revision changes, and exits if its parent process ends. The editor has process-exit/timeout checks and a retry button, converts DIB padding to opaque alpha, uploads BGRA to DX11 and displays with point sampling at 4:3. Map/inspector changes appear without saving. Timeline playback/scrubbing drives the viewport camera, subtitles and fade. Free-camera inspection affects no authored data. The viewport shows the scene and cinematic overlays; normal game HUD, door interactions and room switching are tested in F5.
`tools/test_preview_ipc.py` exercises the actual renderer without a visible window. The full regression suite also checks the x86-to-x64-to-DX11 path on a WARP device and compares the live viewport's pixels with a direct ASM rendering. Build engine.exe before running these tests.

View file

@ -53,6 +53,8 @@ engine.exe
| **Shift** | Спринт (ускорение) |
| **E / Пробел** | Взаимодействие (открыть/закрыть дверь перед собой) |
| **M** | Включить/выключить миникарту (радар) |
| **C** | Запустить или повторить кат-сцену текущей карты |
| **Tab** | Пропустить кат-сцену |
| **Escape** | Выход из игры |
---
@ -64,8 +66,10 @@ engine.exe
Движок оснащен нативным высокопроизводительным визуальным редактором уровней в стиле Unity / Godot 4:
- Написан на **C++17 + Dear ImGui + DirectX 11**.
- Запуск в 1 клик через `run_editor.bat` или `editor/editor.exe`.
- **Мгновенный плейтест (F5)**: редактор автоматически сериализует уровень в бинарный формат `assets/map.dat` и запускает ассемблерный движок `engine.exe`.
- **Мгновенный плейтест (F5)**: редактор создаёт временную копию связанных карт и ресурсов и запускает `engine.exe` с выбранной стартовой картой. Несохранённые изменения попадают в копию; исходные карты и путь текущего файла не меняются.
- **Undo/Redo (Ctrl+Z / Ctrl+Y)** — история до 64 действий (сетка, спавн, цвета, спрайты).
- **Кат-сцены (F6)** — таймлайн камеры, поворотов, субтитров, WAV, затемнений и переходов. Правки сцены также поддерживают Undo/Redo.
- **Игровой вид в редакторе** — режимы «Рядом», «Игровой вид» и «Карта». Изображение рисует тот же ASM-рендерер, что и игра; изменения стен, спрайтов, цветов и импортированных текстур видны без F5. Кликните по игровому виду для осмотра: WASD, стрелки, Shift; правая кнопка мыши поворачивает камеру. Обзор не меняет спавн или ключи кат-сцены.
- **Файловые диалоги Windows**: Ctrl+O (открыть), Ctrl+S (сохранить), Ctrl+Shift+S (сохранить как).
- Инструменты:
- **Кисть [B]**: рисование стен выбранного типа (Кирпич, Дерево, Камень, Металл, Дверь, Колонна, Стекло, Мрамор).
@ -104,3 +108,30 @@ build_editor.bat
- `include/engine_render2d.inc` — примитивы (DrawPixel, Rect, Outline), шрифт 8x8, миникарта.
- `include/font8x8.inc` — битовые маски ASCII-символов.
- `AGENTS.md` — подробное руководство для AI-агентов по написанию новых игр на этом движке.
## Переходы и проверка первых трёх этапов
Карты новых сохранений используют EBLN v4; существующие v1/v2/v3 продолжают читаться. Связанные `.dat` лежат в одной папке. В портале задаётся ASCII-имя файла до 31 символа, целевой спавн, угол и затемнение. На карту доступно до 16 переходов. Заливка учитывает этот лимит целиком; удаление клетки удаляет её триггер. Чёрный пол и потолок поддерживаются.
Порталы имеют отдельную текстуру и не занимают слот фонаря. Первый импорт изображения создаёт недостающие папки и пакет со стандартными текстурами в остальных слотах. Повреждённый пакет не перезаписывается импортом; ошибка показывается в строке состояния. Неудачный переход сохраняет текущую комнату и показывает сообщение в игре.
Проверки без изменения карт проекта:
```cmd
python tools\test_regressions.py
```
Для сохранения тестовых кадров добавьте `--keep`. Нужны MASM32, MSVC BuildTools и Windows SDK.
## Кат-сцены: этапы 4 и 5
В EblanStudio нажмите **F6** и добавьте команды кнопками таймлайна. Для движения камеры укажите точку на карте и длительность в кадрах (60 кадров = секунда). Поворот, движение, субтитры и затемнение можно запускать одновременно. Ромбы перетаскиваются по времени; список «Команда» позволяет выбрать события с одинаковым временем. Параметры выбранной команды редактируются справа. Конец сцены добавляется автоматически.
«При входе» включает автозапуск после загрузки комнаты. Кнопка «Предпросмотр» и ползунок кадра показывают пролёт в игровом виде редактора, включая субтитры, чёрные полосы и затемнение. На карте одновременно видна траектория. Выбор «Камера сцены» возвращает вид к таймлайну после свободного осмотра. **F5** запускает полную игру с временной копией карт и звуков. Для команды перехода сначала создайте портал, затем выберите его в инспекторе. WAV можно импортировать через меню ассетов; имя файла должно быть ASCII.
Камера движется линейно между точками, поворачивается по кратчайшей дуге и во время пролёта проходит сквозь стены. Конечные точки должны лежать в свободных клетках. Доступна одна сцена на карту, до 128 команд и 32 строк по 95 символов CP1251. На экране помещаются 3 строки субтитров по 38 символов. Звуки используют один канал: новый WAV заменяет предыдущий, завершение или пропуск сцены останавливает его. Tab возвращает управление в текущей точке камеры.
Готовый пример: [examples/cutscene_demo/README.md](examples/cutscene_demo/README.md). После сборки запустите `examples\cutscene_demo\run_demo.bat`: вступление с русскими субтитрами, WAV и затемнением переводит камеру во вторую комнату. Пример хранится отдельно от основных карт проекта.
Предпросмотр останавливается на команде перехода в другую комнату; цепочка комнат и игровой ввод проверяются через F5. F5/F6 работают при фокусе интерфейса и текстовых полей. Ошибка запуска показывается отдельным сообщением. При старте редактор открывает карту проекта относительно своего файла, поэтому запуск из другой рабочей папки не ломает пути.
Формат v4 и параметры команд описаны в [AGENTS.md](AGENTS.md) и [архитектуре движка](docs/ENGINE_ARCHITECTURE.md). Сценарии регрессии также проверяют сохранение/загрузку трека, повреждённые команды, Undo/Redo, упаковку WAV для F5, горячие клавиши при фокусе в интерфейсе, покадровое совпадение состояния C++ с ASM, передачу кадров в DX11 и точное совпадение пикселей игрового вида с движком. Перед запуском проверок соберите `engine.exe` через `build.bat`.

View file

@ -5,6 +5,34 @@
---
## Проверка реализации этапов 1–3 — 21.10.2026
- Этап 1: тангенциальные углы лучей, единственная косинусная коррекция, DDA с 8 дробными битами до проекции. В регрессионной сцене прямая стена имеет одинаковую глубину во всех 320 столбцах.
- Этап 2: PNG/JPG/BMP, BGRA и хромакей, безопасное создание и обновление пакета, стандартные слоты при первом импорте, WAV и манифесты. Портал не использует слот фонаря.
- Этап 3: EBLN v3 с чтением v1/v2, полная проверка перед заменой комнаты, обработка ошибочного перехода, спавны, затемнение и защита от повторного срабатывания при появлении в портале. F5 работает с временной копией карт и ресурсов.
- Регрессионные проверки: `python tools/test_regressions.py`. Ручная проверка интерфейса и длительный игровой прогон остаются отдельной проверкой качества.
- Этапы 4 и 5 реализованы следующим изменением, см. статус ниже.
## Реализация этапов 4–5 — 22.10.2026
- Этап 4: бинарный трек EBLN v4, чистый ASM, линейные пролёты по точкам, повороты по кратчайшей дуге, паузы, русские субтитры CP1251, letterbox 2.35:1, WAV, затемнения и переходы. Автозапуск при входе, C для повтора и Tab для пропуска; управление игроком блокируется на время сцены. Старые карты v1/v2/v3 читаются.
- Этап 5: F6 открывает таймлайн с дорожками, перетаскиванием ключей, списком команд, инспектором, установкой точек на карте, автозапуском, Undo/Redo и сохранением. Предпросмотр и перемотка показывают сцену глазами камеры во встроенном игровом виде, а на карте — траекторию. F5 запускает полную игру и связанные комнаты с проверкой WAV.
- Пример `examples/cutscene_demo/run_demo.bat` показывает пролёт, текст, звук и переход во вторую комнату; основные карты проекта он не меняет.
- Регрессионный набор расширен проверками формата v4, проигрывателей ASM/C++, оверлея, переходов, редактора и покадрового совпадения состояния.
- Уточнения исходного ТЗ: сериализуемый `dataPtr` заменён на `dataId`, строки хранятся в отдельном пуле; движение задаётся длительностью. Использованы линейные waypoints, камера во время пролёта не сталкивается со стенами. WAV воспроизводится одним каналом PlaySoundA без внешних библиотек.
## Завершение редактора и исправление F5 — 22.10.2026
- Встроен игровой вид, который получает кадры от настоящего ASM-рендерера. Доступны режимы «Рядом», «Игровой вид», «Карта», свободный осмотр и камера таймлайна. Изменения стен, цветов, спрайтов и текстур видны без сохранения.
- Исправлена блокировка F5/F6 при захвате клавиатуры интерфейсом ImGui. Ошибки запуска показываются отдельным диалогом. Начальная карта открывается относительно расположения редактора, независимо от рабочей папки.
- Автопроверки проверяют горячие клавиши с фокусом в интерфейсе, реальную передачу кадров из x86 ASM в x64 редактор и DX11, обновление геометрии, перемотку кат-сцены, точное совпадение пикселей с отдельным движком и освобождение процесса рендерера.
- Все пять этапов реализованы. Предпросмотр редактора останавливается на команде перехода между комнатами; полный сценарий переходов и игровой ввод доступны через F5. Это ограничение предпросмотра, а не отсутствие переходов в движке.
- Итоговая проверка: 94/94 сценария редактора и 107/107 сценариев ASM, совпадение всех 320 углов лучей, покадровое совпадение проигрывателей и точное совпадение пикселей встроенного вида с движком. Обе программы пересобраны. Исходные ресурсы проекта при проверках остались без изменений. Ручное управление окнами было остановлено пользователем через Esc; полный визуальный прогон интерфейса не завершён.
Разделы ниже сохраняют исходное ТЗ и описание прежних проблем как историю требований. Актуальные формат и поведение описаны в `AGENTS.md` и `docs/ENGINE_ARCHITECTURE.md`.
---
## 📋 Оглавление
1. [Задача 1: Устранение Fish-Eye эффекта и фикс рендера](#задача-1-устранение-fish-eye-эффекта-и-фикс-рендера)
2. [Задача 2: Полноценный импорт ассетов в EblanStudio](#задача-2-полноценный-импорт-ассетов-в-eblanstudio)
@ -14,7 +42,7 @@
---
## Задача 1: Устранение Fish-Eye эффекта и фикс рендера
## Задача 1: Устранение Fish-Eye эффекта и фикс рендера [ВЫПОЛНЕНО / DONE]
### 1.1. Суть проблемы в текущем коде
В файле [`include/engine_raycaster.inc`](include/engine_raycaster.inc) лучи выпускаются с равномерным шагом угла:
@ -57,7 +85,7 @@ $$\theta_x = \text{playerAngle} + \arctan\left(\left(\frac{2x}{\text{SCREEN\_W}}
---
## Задача 2: Полноценный импорт ассетов в EblanStudio
## Задача 2: Полноценный импорт ассетов в EblanStudio [ВЫПОЛНЕНО / DONE]
### 2.1. Требования
У Артема и дизайнеров уровней должна быть возможность в пару кликов мыши в редакторе загружать свои изображения (PNG) и звуки (WAV) без ручного запуска сторонних питоновских скриптов.
@ -84,17 +112,19 @@ $$\theta_x = \text{playerAngle} + \arctan\left(\left(\frac{2x}{\text{SCREEN\_W}}
---
## Задача 3: Многоуровневые локации и стриминг подуровней
---
## Задача 3: Многоуровневые локации и стриминг подуровней [ВЫПОЛНЕНО / DONE]
### 3.1. Секторно-комнатная модель уровней
Вместо одной изолированной сетки $32 \times 32$ мир организуется как **граф связанных секторов (комнат)**.
- Каждый сектор — это отдельный бинарный файл подуровня (например, `sublevel_01.dat`, `sublevel_02.dat`) со своей сеткой $32 \times 32$, цветами пола/потолка и локальными спрайтами.
- Каждый сектор — это отдельный бинарный файл подуровня (например, `map.dat`, `sublevel_02.dat`) со своей сеткой $32 \times 32$, цветами пола/потолка и локальными спрайтами.
- Сектора соединяются **Порталами / Дверями перехода (Sector Portals)**.
### 3.2. Структура данных перехода (Portal Transition)
В заголовок или массив сущностей карты добавляется тип `CellType::CELL_PORTAL`:
В заголовок и сущности карты добавлен тип `CELL_PORTAL = 10` и структура:
```cpp
// Структура перехода между подуровнями
// Структура перехода между подуровнями (42 байта packed)
struct LevelTransition {
uint8_t triggerCellX; // Координата X двери/триггера в текущем секторе
uint8_t triggerCellY; // Координата Y двери/триггера в текущем секторе
@ -103,23 +133,29 @@ struct LevelTransition {
uint16_t targetSpawnY; // Точка появления в новом секторе (Y)
uint16_t targetAngle; // Направление взгляда после перехода
uint8_t fadeEffect; // 0 = мгновенно, 1 = затемнение экрана (Fade to Black)
uint8_t reserved; // Выравнивание
};
```
### 3.3. Логика динамической подгрузки и выгрузки в Ассемблере
В [`include/engine_map.inc`](include/engine_map.inc):
1. **Процедура перехода `SwitchSublevel(szNextMap, spawnX, spawnY, spawnAngle)`**:
- **Шаг 1 (Fade Out)**: Плавное затемнение буфера экрана `screenBuffer` за 10-15 кадров (уменьшение яркости RGB).
- **Шаг 2 (Unload)**: Очистка текущих спрайтов (`mov spriteCount, 0`), сброс флагов дверей.
- **Шаг 3 (Load)**: Чтение нового файла карты в `gameMap`, обновление цветов неба/пола (`mapCeilingColor`, `mapFloorColor`), загрузка массива спрайтов нового сектора.
1. **Процедура перехода `SwitchSublevel(szNextMap, spawnX, spawnY, spawnAngle, fadeEffect)`**:
- **Шаг 1 (Fade Out)**: Плавное затемнение буфера экрана `screenBuffer` за 16 кадров с помощью целочисленного масштабирования цветовых каналов RGB.
- **Шаг 2 (Unload)**: Очистка текущих спрайтов (`mov spriteCount, 0`), сброс флагов и переходных триггеров.
- **Шаг 3 (Load)**: Чтение нового файла карты через `LoadSublevelBinary` в `gameMap`, обновление цветов неба/пола (`mapCeilingColor`, `mapFloorColor`), загрузка массива спрайтов нового сектора.
- **Шаг 4 (Spawn)**: Установка новых координат игрока `playerX = spawnX * 64 + 32`, `playerY = spawnY * 64 + 32`, `playerAngle = spawnAngle`.
- **Шаг 5 (Fade In)**: Плавное осветление экрана.
2. **Память**:
- Оперативная память остается постоянной (~1-2 МБ), старые комнаты полностью освобождают буферы, что позволяет создавать гигантские замки и комплексы из сотен связанных комнат.
- **Шаг 5 (Fade In)**: Плавное 16-кадровое осветление экрана.
2. **Память и производительность**:
- Постоянный объем оперативной памяти (~1-2 МБ), буферы очищаются и заполняются новым сектором на лету, позволяя создавать бесконечные цепочки и комплексы комнат.
- Во время обычного геймплея оверхед пост-процесса затемнения равен нулю (`cmp fadeState, 0; je fade_done`).
3. **Интеграция в EblanStudio**:
- Добавлен тайл `10. Портал перехода` с неоново-фиолетовой палитрой и меткой портала на 2D-сетке.
- Инспектор свойств перехода: выбор файла целевого подуровня (`.dat`), кнопка обзора файловой системы, слайдеры целевого спавна и угла, переключатель Fade to Black, быстрое удаление и кнопка мгновенного перехода к подуровню в редакторе.
- Поддержка сериализации и десериализации переходов в бинарный формат с сохранением 100% обратной совместимости.
---
## Задача 4: Система кат-сцен и скриптовых последовательностей
## Задача 4: Система кат-сцен и скриптовых последовательностей [ВЫПОЛНЕНО / DONE]
### 4.1. Архитектура: Бинарный трек событий (Keyframe Track)
Кат-сцена представляет собой упорядоченный во времени список событий (Action Track). Во время кат-сцены управление игрока блокируется, а виртуальный режиссер интерполирует камеру, спавнит звуки и выводит диалоговые субтитры.
@ -178,11 +214,11 @@ struct CutsceneEvent {
| Этап | Задача | Затронутые файлы | Ожидаемый результат |
|---|---|---|---|
| **Этап 1** | Исправление Fish-Eye и угла лучей | `include/engine_math.inc`, `include/engine_raycaster.inc` | Стены остаются ровными по краям экрана, нет искажения при вращении камеры. |
| **Этап 2** | Импорт текстур и спрайтов в редакторе | `editor/src/stb_image.h`, `editor/src/editor_app.cpp` | Кнопка импорта в GUI загружает PNG и обновляет `assets/textures/texturepack.bin`. |
| **Этап 3** | Система связанных секторов (комнат) | `editor/src/level_format.h`, `include/engine_map.inc` | Переход между комнатами по триггеру с выгрузкой старой карты и загрузкой новой. |
| **Этап 4** | Движок кат-сцен и диалогов | `include/engine_data.inc`, `include/engine_render2d.inc`, `main.asm` | Пролет камеры по ключевым точкам, показ диалоговых субтитров, блокировка инпута. |
| **Этап 5** | Визуальный таймлайн в EblanStudio | `editor/src/editor_app.cpp`, `editor/src/editor_app.h` | Визуальное редактирование траектории камеры и сценария кат-сцены в UI. |
| **Этап 1 — готово** | Исправление Fish-Eye и угла лучей | `include/engine_math.inc`, `include/engine_raycaster.inc` | Стены остаются ровными по краям экрана, нет искажения при вращении камеры. |
| **Этап 2 — готово** | Импорт текстур и спрайтов в редакторе | `editor/src/stb_image.h`, `editor/src/editor_app.cpp` | Кнопка импорта в GUI загружает PNG и обновляет `assets/textures/texturepack.bin`. |
| **Этап 3 — готово** | Система связанных секторов (комнат) | `editor/src/level_format.h`, `include/engine_map.inc` | Переход между комнатами по триггеру с выгрузкой старой карты и загрузкой новой. |
| **Этап 4 — готово** | Движок кат-сцен и диалогов | `include/engine_cutscene.inc`, `include/engine_data.inc`, `main.asm` | Пролёт камеры по ключевым точкам, субтитры, звук, затемнения, переходы, блокировка инпута. |
| **Этап 5 — готово** | Визуальный таймлайн в EblanStudio | `editor/src/editor_cutscene.inl`, `editor/src/editor_viewport.inl`, `editor/src/cutscene_preview.h` | Редактирование траектории и сценария, предпросмотр игрового вида внутри редактора, Undo/Redo, F5 для полной игры. |
---

Binary file not shown.

3
assets/sounds/sounds.inc Normal file
View file

@ -0,0 +1,3 @@
; ==============================================================================
; EBLAN ENGINE - Auto-Generated Sound Manifest (winmm.lib PlaySound / MCI)
; ==============================================================================

1
assets/sounds/sounds.txt Normal file
View file

@ -0,0 +1 @@
# EblanStudio Sound Registry & MCI Aliases

BIN
assets/sublevel_02.dat Normal file

Binary file not shown.

View file

@ -5,6 +5,13 @@ rem ============================================================================
rem EBLAN ENGINE - Build Script (MASM32 / MSVC BuildTools)
rem ==============================================================================
if exist "E:\MASM32\masm32\bin\ml.exe" (
set "ML_EXE=E:\MASM32\masm32\bin\ml.exe"
set "LINK_EXE=E:\MASM32\masm32\bin\link.exe"
set "LIB_PATHS=/LIBPATH:E:\MASM32\masm32\lib"
set "EXTRA_FLAGS=/SUBSYSTEM:WINDOWS /RELEASE /VERSION:4.0"
goto do_build
)
if exist "D:\MASM32\masm32\bin\ml.exe" (
set "ML_EXE=D:\MASM32\masm32\bin\ml.exe"
set "LINK_EXE=D:\MASM32\masm32\bin\link.exe"

View file

@ -4,9 +4,8 @@
### Мировое пространство (World Space)
- Мир разделен на клетки сетки размером **64x64 единицы** (`CELL_SIZE equ 64`).
- Координаты игрока `playerX` и `playerY` хранятся в формате **16.16 signed fixed point**:
- Целая часть = `координата / 64` (индекс клетки в сетке `gameMap`).
- Дробная часть = смещение внутри клетки.
- Координаты игрока и спрайтов — **целые мировые единицы**, 64 единицы на клетку. Индекс клетки: `coordinate >> 6`.
- Синусы, косинусы и масштаб спрайтов используют 16.16. Дистанции DDA используют **24.8** до проекции, чтобы не терять дробную часть на каждом шаге.
- Карта `gameMap` имеет размер **32x32 клетки** (всего 1024 байта). Индекс клетки вычисляется через сдвиг: `index = (mapY << 5) + mapX`.
### Углы и тригонометрия (Angles & Trig)
@ -41,12 +40,15 @@
### Алгоритм на столбец:
1. **Вычисление угла луча**:
$$\text{rayAngle} = \left(\text{playerAngle} - \frac{\text{FOV}}{2} + \frac{\text{screenX} \cdot \text{FOV}}{320}\right) \ \& \ 511$$
$$\text{rayAngle} = \left(\text{playerAngle} + \operatorname{round}\left[\arctan\left((2x/320-1)\tan(\text{FOV}/2)\right)\frac{256}{\pi}\right]\right) \ \& \ 511$$
Смещения и их косинусы вычисляются один раз в `InitMathTables`; углы остаются дискретными (512 направлений).
2. **Трассировка по сетке (DDA - Digital Differential Analysis)**:
- `deltaDist = (64 << 24) / abs(rayDirection16_16)`: расстояния сохраняют 8 дробных бит. При нулевой компоненте направления используется недостижимая дистанция, деления на ноль нет.
- Вычисляются шаг сетки (`stepX = \pm 1`, `stepY = \pm 1`) и расстояния до следующих линий сетки (`sideDistX`, `sideDistY`).
- В цикле луч шагает от клетки к клетке, пока не встретит твердую ячейку (`IsSolidCell`).
- Флаг `sideHit` фиксирует, была ли пересечена вертикальная грань ($X$) или горизонтальная ($Y$).
3. **Устранение дисторсии «рыбий глаз»**:
- Некорректированная дистанция используется для точки попадания текстуры.
- Расстояние по лучу умножается на косинус разницы между углом луча и углом камеры:
$$\text{dist}_{\text{perp}} = \text{dist} \cdot \cos(\text{rayAngle} - \text{playerAngle})$$
4. **Проекция высоты стены**:
@ -66,12 +68,12 @@
0. Спрайты хранят позицию в **сырых мировых единицах** (1 клетка = 64 единицы, как `playerX`/`playerY`). Перед отрисовкой все спрайты сортируются по убыванию квадрата расстояния (алгоритм художника, insertion sort по `spriteOrder`).
1. Позиция каждого спрайта переводится в систему координат камеры:
$$X_{\text{cam}} = \Delta X \cdot \cos(-\text{ang}) - \Delta Y \cdot \sin(-\text{ang})$$
$$Y_{\text{cam}} = \Delta X \cdot \sin(-\text{ang}) + \Delta Y \cdot \cos(-\text{ang})$$
$$X_{\text{cam}} = \Delta X \cdot \sin(-\text{ang}) + \Delta Y \cdot \cos(-\text{ang})$$
$$Y_{\text{cam}} = \Delta X \cdot \cos(-\text{ang}) - \Delta Y \cdot \sin(-\text{ang})$$
2. Если $Y_{\text{cam}} > 8$ (спрайт находится перед камерой):
- Проекция на экран: $\text{screenX} = \frac{X_{\text{cam}} \cdot \text{PROJ\_DIST}}{Y_{\text{cam}}} + 160$.
- Размер: $\text{size} = \frac{64 \cdot \text{PROJ\_DIST}}{Y_{\text{cam}}} \cdot \text{scale}$ (scale — 16.16 fixed).
3. Спрайт «стоит на полу»: нижняя граница = горизонт + size/2 (как низ стены на той же глубине), верхняя = низ − size.
3. Спрайт стоит на полу: нижняя граница = горизонт + половина размера при масштабе 1.0; верхняя = низ − размер с учётом масштаба. Изменение масштаба не сдвигает точку опоры.
4. Отрисовка вертикальных текстурированных полос с проверкой буфера глубины `depthBuffer[stripeX]`: если спрайт ближе стены, пиксели выводятся в `screenBuffer`. Тексели `00FF00FFh` (маджента) прозрачны. Применяется тот же дистанционный туман, что и у стен.
---
@ -89,3 +91,54 @@
DIB_RGB_COLORS, SRCCOPY
```
- Обеспечивает честное ретро-разрешение и пиксельный вид с нулевой нагрузкой на видеокарту.
## 5. Загрузка карт и ресурсов
Новые карты сохраняются как EBLN v4: заголовок 38 байт, сетка 1024 байта, спрайты по 24 байта, переходы по 42 байта, команды кат-сцены по 16 байт и строки по 96 байт. Чтение старых v1/v2/v3 сохранено; ранние v2 с переходами поддерживаются. Загрузчики сначала проверяют весь файл во временных буферах, затем заменяют комнату. При ошибке перехода текущая комната сохраняется; при ошибке начальной загрузки создаётся процедурная карта. Значение цвета 0 — полноценный чёрный цвет.
Файлы связанных подуровней лежат рядом друг с другом. В `targetMap[32]` хранится только ASCII-имя `.dat` с завершающим нулём. Переход после появления в его ячейке повторно активируется только после выхода из неё. Двери и взаимодействие реагируют на отдельные нажатия.
Портал использует отдельный буфер текстуры, не занимая слоты спрайтов 10..15. Пакет текстур загружается через отдельный буфер и применяется целиком после проверки. Постоянные буферы занимают примерно 2.5 МиБ (основная часть — пакет текстур и его буфер проверки); число загруженных комнат всегда одно.
F5 запускает копию графа карт и ресурсов во временной папке. Открытая карта со всеми несохранёнными изменениями сохраняется только в эту копию, под исходным именем. Движок получает стартовую карту первым аргументом командной строки. Исходные файлы и текущий путь редактора не меняются.
Регрессионные сценарии: `python tools/test_regressions.py`. С `--keep` сохраняются тестовые кадры и карты. Автотесты проверяют математику, файловый ввод-вывод, состояние процедур и отрисовку таймлайна без окон; ручная проверка интерфейса остаётся отдельной задачей.
## 6. Кат-сцены в движке
Подсистема `include/engine_cutscene.inc` воспроизводит один трек текущей карты. В заголовке v4 поля по смещениям 28/30/32 задают число команд (до 128), число строк (до 32) и флаг автозапуска (бит 0). В v1–v3 эти поля игнорируются. Сразу после переходов идут команды `CutsceneEvent`, затем строки CP1251 с завершающим нулём в пределах каждого 96-байтового слота. Текст редактора хранится в UTF-8 и преобразуется при сохранении; непредставимые символы отклоняются. В файле нет указателей.
Команда занимает 16 байт: `tickTimestamp:WORD`, `opCode:BYTE`, `param8:BYTE`, `paramA:SDWORD`, `paramB:SDWORD`, `dataId:SDWORD`. Метки времени упорядочены, отсчитываются от старта с частотой 60 Гц и ограничены 65535. Продолжительность отдельного действия — до 3600 кадров. Непустой трек заканчивается END; загрузчик проверяет весь трек, строки, координаты, флаги и ссылки до замены активного состояния. Точная таблица параметров приведена в разделе H файла `AGENTS.md`.
`UpdatePlayer` обрабатывает автозапуск после завершения межкомнатного затемнения и ручной запуск клавишей C. При активной сцене управление движением, дверями и обычными порталами заблокировано. UpdateCutscene сначала обновляет уже начатые интерполяции и время показа текста, затем обрабатывает паузу и наступившие команды. Несколько команд с одним временем выполняются в порядке хранения: камера, поворот, текст, звук и затемнение могут работать одновременно. WAIT задерживает обработку следующих команд, но время и уже начатые интерполяции продолжаются.
Координаты камеры — сырые мировые единицы (64 на клетку). MOVE_CAM_TO использует линейную интерполяцию по длительности из `dataId`; PAN_CAM_TO — кратчайшую дугу угла 0..511 и длительность из `paramB`. Целочисленное умножение с последующим знаковым делением гарантирует точную конечную точку. Нулевая длительность означает мгновенную установку. Новая команда того же типа заменяет незавершённую интерполяцию, начиная с текущего состояния. Конечная клетка должна быть проходимой; сами отрезки не проверяют столкновения со стенами.
FADE_SCREEN интерполирует темноту от 0 до 16 и масштабирует каждый RGB-канал. Во время сцены HUD заменён чёрными полосами по 52 пикселя (видимая область 320×136, примерно 2.35:1). Субтитры центрируются на полупрозрачной плашке: до 3 строк по 38 глифов 8×8, перенос по ширине или LF. Полный текст ограничен 95 символами CP1251; лишние экранные строки не выводятся. SHOW_SUBTITLE с нулевой длительностью остаётся до HIDE_SUBTITLE или завершения сцены.
PLAY_SOUND использует `PlaySoundA` из уже подключённой WinMM, асинхронно и без системного звука при ошибке. Строка содержит только ASCII-имя WAV длиной до 63 символов, файл лежит в `assets/sounds`. Бит 0 `param8` включает повтор, индекс -1 останавливает звук. Канал один; новый WAV заменяет предыдущий. END, Tab и переход прекращают звук и снимают наложения. Положение камеры сохраняется при возврате управления; защита портала предотвращает немедленный обратный переход из текущей клетки.
CHANGE_SUBLEVEL ссылается на существующий переход по индексу и использует его имя карты, спавн, угол и настройку затемнения. Старый трек останавливается до загрузки новой комнаты. Уже достигнутая темнота сохраняется при начале затемнения перехода, чтобы избежать светлой вспышки. Новая комната может запустить собственную сцену после появления.
## 7. Таймлайн EblanStudio
F6 или кнопка панели открывает таймлайн (`editor/src/editor_cutscene.inl`). Дорожки камеры, текста, звука и сцены показывают метки времени и перетаскиваемые ромбы. Команды с одинаковым временем можно выбрать через список «Команда». Инспектор редактирует время, длительность, координаты, углы, строки, WAV, повтор и целевой портал. Для камеры кнопка «Указать на карте» позволяет выбрать свободную клетку, не меняя сетку. END добавляется автоматически и переносится за последнее действие. Удаление портала удаляет или перенумеровывает ссылки сцены.
Трек, строки и автозапуск входят в снимки Undo/Redo и формат карты. Состояние предпросмотра (`cutscene_preview.h`) задаёт камеру, текст и затемнение для встроенного игрового вида; ползунок кадра восстанавливает состояние к выбранному времени. На 2D-карте видны траектория и направление. Звук предпросмотра можно отключить. При команде перехода предпросмотр останавливается. Полная последовательность комнат и игровой ввод проверяются через F5. При подготовке F5 редактор дополнительно проверяет наличие всех WAV, на которые ссылаются достижимые карты, и копирует их во временную папку.
Пример `examples/cutscene_demo` содержит две независимые карты, русские субтитры и короткий WAV. `run_demo.bat` запускает его после сборки движка; `tools/make_cutscene_demo.py` восстанавливает файлы примера. Регрессии сравнивают состояния камеры, угла, текста и затемнения C++ и ASM покадрово, проверяют повреждённые треки, переходы, упаковку звуков и пиксели кинематографического оверлея.
## 8. Игровой вид внутри редактора
В центре доступны режимы «Рядом», «Игровой вид» и «Карта». Игровое изображение создаётся настоящими ASM-процедурами движка: `Render3DScene`, `RenderCutsceneOverlay` и `ApplyCutsceneFade`. Копии raycaster на C++ нет. Сохраняется исходное разрешение 320×240, соотношение 4:3 и точечное масштабирование. Обычный игровой HUD во время редактирования скрыт; кат-сцены показывают свои полосы, субтитры и затемнение.
Редактор собран для x64, движок — для x86, поэтому рендерер работает в отдельном процессе без окна. Аргумент `--preview:<имя>` выбирает этот режим до обычной инициализации окна и карты. Именованный раздел памяти передаёт несохранённую сетку, спрайты, цвета, камеру, текст и пакет текстур. Общий формат описан в `editor/src/preview_protocol.h` и зеркально в `include/engine_preview.inc`; размер и смещения проверяются при сборке C++. Один запрос в работе исключает чтение неполного кадра: state=1 принадлежит движку, state=2 — редактору. Публикация выполняется атомарно с барьером памяти. Пакет текстур копируется в движок при изменении ревизии.
Движок проверяет диапазоны входных координат и спрайтов, наблюдает за процессом редактора и завершает работу, если тот закрыт. Редактор проверяет завершение рендерера и тайм-аут, показывает ошибку с повторным запуском и освобождает процесс и память при закрытии. Готовый BGRA-кадр загружается в текстуру DX11; неиспользуемый байт DIB заменяется непрозрачной альфой.
WASD, стрелки и правая кнопка мыши позволяют осмотреть сцену после клика по игровому виду; Shift ускоряет движение. Свободная камера не меняет спавн и ключи. Начало воспроизведения или перемотка таймлайна возвращают вид к камере сцены. Правки геометрии, цветов, спрайтов и импорт текстур видны без сохранения и F5. Межкомнатные переходы и взаимодействия проверяются в полном запуске F5.
F5/F6 обрабатываются до признака захвата клавиатуры ImGui — иначе фокус внутри редактора блокировал запуск. Ошибки подготовки и запуска F5 выводятся отдельным диалогом. Начальный путь карты определяется относительно расположения редактора и загружается при старте, независимо от рабочей папки процесса.
Проверки выполняют полный путь x86 ASM → раздел памяти → x64 редактор → текстура DX11 на устройстве WARP. Пиксели встроенного вида сравниваются с прямым вызовом обычного ASM-рендерера. Отдельный сценарий `python tools/test_preview_ipc.py` проверяет обновление стен и текстур, субтитры, затемнение и завершение без открытия окон. Для полного регрессионного набора предварительно соберите движок.

Binary file not shown.

Binary file not shown.

View file

@ -15,13 +15,13 @@ call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\
cd /d "%~dp0"
echo [BUILD] Compiling EblanStudio (C++17 + Dear ImGui + DirectX 11)...
cl /nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNDEBUG ^
cl /nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNOMINMAX /D_CRT_SECURE_NO_WARNINGS /DNDEBUG ^
/Isrc /Iimgui /Iimgui\backends ^
src\main.cpp src\editor_app.cpp ^
imgui\imgui.cpp imgui\imgui_draw.cpp imgui\imgui_tables.cpp imgui\imgui_widgets.cpp ^
imgui\backends\imgui_impl_win32.cpp imgui\backends\imgui_impl_dx11.cpp ^
/Fe:editor.exe ^
/link /SUBSYSTEM:WINDOWS d3d11.lib dxgi.lib d3dcompiler.lib user32.lib gdi32.lib shell32.lib comdlg32.lib
/link /SUBSYSTEM:WINDOWS d3d11.lib dxgi.lib d3dcompiler.lib user32.lib gdi32.lib shell32.lib comdlg32.lib winmm.lib
if errorlevel 1 goto err

Binary file not shown.

View file

@ -1,20 +1,25 @@
[Window][Workspace]
Pos=0,60
Size=1344,716
LastUsed=20261015
Size=1536,708
LastUsed=20261022
[Window][Debug##Default]
Pos=60,60
Size=400,400
LastUsed=20261015
LastUsed=20261022
[Window][Toolbar]
Pos=0,20
Size=1344,38
LastUsed=20261015
Size=1536,38
LastUsed=20261022
[Window][StatusBar]
Pos=0,776
Size=1344,32
LastUsed=20261015
Pos=0,768
Size=1536,32
LastUsed=20261022
[Window][Не удалось запустить игру]
Pos=649,356
Size=237,80
LastUsed=20261022

View file

@ -0,0 +1,96 @@
#pragma once
#include "level_format.h"
#include <windows.h>
#include <string>
#include <vector>
#include <cstring>
struct CutsceneTrack {
std::vector<CutsceneEvent> events;
std::vector<std::string> strings; // UTF-8 in the editor, CP1251 on disk/in ASM
uint16_t flags = 0;
};
inline bool CutsceneEncode(const std::string& text, std::string& encoded) {
if (text.empty()) { encoded.clear(); return true; }
int wideSize = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast<int>(text.size()), nullptr, 0);
if (!wideSize) return false;
std::wstring wide(wideSize, 0);
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), static_cast<int>(text.size()), wide.data(), wideSize);
BOOL replaced = FALSE;
int size = WideCharToMultiByte(1251, WC_NO_BEST_FIT_CHARS, wide.data(), wideSize, nullptr, 0, nullptr, &replaced);
if (!size || replaced || size >= CUTSCENE_STRING_BYTES) return false;
encoded.resize(size);
WideCharToMultiByte(1251, WC_NO_BEST_FIT_CHARS, wide.data(), wideSize, encoded.data(), size, nullptr, &replaced);
if (replaced) return false;
for (unsigned char ch : encoded) if (ch == 0 || (ch < 32 && ch != '\n')) return false;
return true;
}
inline std::string CutsceneDecode(const char* data, int size) {
if (!size) return {};
int n = MultiByteToWideChar(1251, 0, data, size, nullptr, 0);
std::wstring wide(n, 0); MultiByteToWideChar(1251, 0, data, size, wide.data(), n);
n = WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), nullptr, 0, nullptr, nullptr);
std::string text(n, 0);
WideCharToMultiByte(CP_UTF8, 0, wide.data(), static_cast<int>(wide.size()), text.data(), n, nullptr, nullptr);
return text;
}
inline bool IsSoundFilename(const std::string& name) {
if (name.size() < 5 || name.size() > 63) return false;
for (unsigned char ch : name)
if (ch < 32 || ch >= 127 || std::strchr("\\/:*?\"<>|", ch)) return false;
auto ext = name.substr(name.size() - 4);
return ext[0] == '.' && (ext[1] | 32) == 'w' && (ext[2] | 32) == 'a' && (ext[3] | 32) == 'v';
}
inline bool ValidateCutscene(const CutsceneTrack& track, const uint8_t* cells, size_t transitionCount, std::string& error) {
auto fail = [&](const char* reason) { error = reason; return false; };
if (track.events.size() > MAX_CUTSCENE_EVENTS || track.strings.size() > MAX_CUTSCENE_STRINGS || (track.flags & ~CUTSCENE_AUTOPLAY))
return fail("Превышен лимит или неверные флаги кат-сцены");
for (const auto& text : track.strings) {
std::string encoded;
if (!CutsceneEncode(text, encoded)) return fail("Текст: до 95 символов CP1251, без emoji и управляющих символов");
}
auto stringValid = [&](int index) { return index >= 0 && index < static_cast<int>(track.strings.size()); };
uint16_t previous = 0;
for (size_t i = 0; i < track.events.size(); ++i) {
const auto& e = track.events[i];
if (e.tickTimestamp < previous || e.opCode > OP_CHANGE_SUBLEVEL || (e.param8 & ~(e.opCode == OP_PLAY_SOUND ? 1 : 0)))
return fail("Неверный порядок или команда кат-сцены");
previous = e.tickTimestamp;
if (e.opCode == OP_END && i + 1 != track.events.size()) return fail("Команда конца должна быть последней");
switch (e.opCode) {
case OP_WAIT:
if (e.paramA < 0 || e.paramA > CUTSCENE_MAX_DURATION) return fail("Неверная длительность паузы");
break;
case OP_MOVE_CAM_TO:
if (e.paramA < 0 || e.paramA >= 2048 || e.paramB < 0 || e.paramB >= 2048 || e.dataId < 0 || e.dataId > CUTSCENE_MAX_DURATION)
return fail("Неверная точка или длительность движения камеры");
if (cells && cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_EMPTY &&
cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_DOOR_OPEN && cells[(e.paramB / 64)*32 + e.paramA / 64] != CELL_PORTAL)
return fail("Точка камеры находится внутри стены");
break;
case OP_PAN_CAM_TO: case OP_FADE_SCREEN:
if (e.paramA < 0 || e.paramA > (e.opCode == OP_PAN_CAM_TO ? 511 : 16) || e.paramB < 0 || e.paramB > CUTSCENE_MAX_DURATION)
return fail("Неверный поворот или затемнение камеры");
break;
case OP_SHOW_SUBTITLE:
if (!stringValid(e.paramA) || e.paramB < 0 || e.paramB > CUTSCENE_MAX_DURATION) return fail("Неверный текст или длительность субтитра");
break;
case OP_PLAY_SOUND:
if (e.paramA != -1 && (!stringValid(e.paramA) || !IsSoundFilename(track.strings[e.paramA]))) return fail("Звук: ASCII-имя WAV из assets/sounds");
break;
case OP_CHANGE_SUBLEVEL:
if (e.paramA < 0 || e.paramA >= static_cast<int>(transitionCount)) return fail("Выберите существующий переход для кат-сцены");
break;
}
int span = e.opCode == OP_MOVE_CAM_TO ? e.dataId :
(e.opCode == OP_PAN_CAM_TO || e.opCode == OP_FADE_SCREEN || e.opCode == OP_SHOW_SUBTITLE ? e.paramB :
e.opCode == OP_WAIT ? e.paramA : 0);
if (static_cast<int>(e.tickTimestamp) + span > 65535) return fail("Команда выходит за пределы 65535 кадров");
}
if (!track.events.empty() && track.events.back().opCode != OP_END) return fail("Кат-сцена должна заканчиваться командой Конец");
return true;
}

View file

@ -0,0 +1,60 @@
#pragma once
#include "cutscene_format.h"
#include <algorithm>
#include <functional>
// Deterministic 60 Hz reference player for the editor preview and scrubber.
// The ASM player follows the same ordering: finish existing tweens, then dispatch.
struct CutscenePreview {
struct Tween {
int from = 0, to = 0, duration = 0, elapsed = 0;
int Advance(int value) {
if (!duration) return value;
++elapsed;
value = from + (to-from)*elapsed/duration;
if (elapsed >= duration) { value = to; duration = 0; }
return value;
}
void Start(int current, int target, int ticks) { from = current; to = target; duration = ticks; elapsed = 0; }
} xTween, yTween, angleTween, fadeTween;
int x = 0, y = 0, angle = 0, fade = 0;
int tick = 0, nextEvent = 0, wait = 0, subtitle = -1, subtitleUntil = 0, transition = -1;
bool active = false;
void Start(int px, int py, int direction) {
*this = {};
x = px; y = py; angle = direction; active = true;
}
void Stop() {
active = false; subtitle = -1; subtitleUntil = 0; fade = 0;
xTween = {}; yTween = {}; angleTween = {}; fadeTween = {};
}
void Step(const CutsceneTrack& track, const std::function<void(int, bool)>& sound = {}) {
if (!active) return;
x = xTween.Advance(x); y = yTween.Advance(y);
angle = angleTween.Advance(angle) & 511;
fade = fadeTween.Advance(fade);
if (subtitleUntil && tick >= subtitleUntil) { subtitle = -1; subtitleUntil = 0; }
if (wait > 0 && --wait > 0) { ++tick; return; }
while (nextEvent < static_cast<int>(track.events.size()) && track.events[nextEvent].tickTimestamp <= tick) {
const auto e = track.events[nextEvent++];
switch (e.opCode) {
case OP_END: Stop(); return;
case OP_WAIT: wait = e.paramA; if (wait) { ++tick; return; } break;
case OP_MOVE_CAM_TO:
xTween.Start(x, e.paramA, e.dataId); yTween.Start(y, e.paramB, e.dataId);
if (!e.dataId) { x = e.paramA; y = e.paramB; } break;
case OP_PAN_CAM_TO: {
int delta = ((e.paramA-angle+256)&511)-256;
angleTween.Start(angle, angle+delta, e.paramB);
if (!e.paramB) angle = e.paramA; break;
}
case OP_SHOW_SUBTITLE: subtitle = e.paramA; subtitleUntil = e.paramB ? tick+e.paramB : 0; break;
case OP_HIDE_SUBTITLE: subtitle = -1; subtitleUntil = 0; break;
case OP_PLAY_SOUND: if (sound) sound(e.paramA, (e.param8&1)!=0); break;
case OP_FADE_SCREEN: fadeTween.Start(fade, e.paramA, e.paramB); if (!e.paramB) fade = e.paramA; break;
case OP_CHANGE_SUBLEVEL: transition = e.paramA; Stop(); return;
}
}
++tick;
}
};

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,14 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#ifndef _CRT_SECURE_NO_WARNINGS
#define _CRT_SECURE_NO_WARNINGS
#endif
#include "level_format.h"
#include "cutscene_preview.h"
#include "preview_protocol.h"
#include <string>
#include <vector>
#include <deque>
@ -13,12 +22,14 @@ enum ToolMode {
TOOL_SPRITE_SPAWN
};
// Full level snapshot for undo/redo (grid + spawn + palette + sprites)
// Full level snapshot for undo/redo (grid + spawn + palette + sprites + transitions)
struct LevelSnapshot {
uint8_t map[MAP_GRID_H][MAP_GRID_W];
int playerStartX, playerStartY, playerStartAngle;
uint32_t ceilingColor, floorColor;
std::vector<SpriteEntry> sprites;
std::vector<LevelTransition> transitions;
CutsceneTrack cutscene;
};
class EditorApp {
@ -34,6 +45,28 @@ public:
void StopPlaytest();
void CheckPlaytestProcess();
void InitD3D(struct ID3D11Device* pDevice);
void ShutdownD3D();
// Asset Management
std::string GetTexturePackPath() const;
std::string GetSoundsDirectory() const;
bool ImportTextureImage(int slotIndex, bool isSprite, const std::string& explicitPath = "");
bool ExportTextureImage(int slotIndex);
bool ResetTextureSlotToDefault(int slotIndex);
bool ImportSoundAudio(const std::string& explicitPath = "");
void HandleDropFile(const std::string& droppedPath);
void ReloadTextureSlotSRV(int slotIndex);
void LoadAllTexturePreviews();
void UpdateSoundManifest();
std::vector<std::string> GetSoundList() const;
void PlayAudioSound(const std::string& filename);
void StopAudioSound();
void RenderAssetManagerModal();
bool showAssetManagerModal = false;
bool showCutsceneTimeline = false;
void RenderUI();
// Level state
@ -44,13 +77,16 @@ public:
uint32_t ceilingColor = 0x00383838;
uint32_t floorColor = 0x00222222;
std::vector<SpriteEntry> sprites;
std::vector<LevelTransition> transitions;
CutsceneTrack cutscene;
// Editor state
ToolMode currentTool = TOOL_BRUSH;
CellType selectedPaletteCell = CELL_BRICK;
int selectedSpriteIdx = -1;
int selectedTransitionIdx = -1;
bool isEngineRunning = false;
std::string statusMessage = "Готов к работе. EblanStudio v1.1";
std::string statusMessage = "Готов к работе. EblanStudio v1.2";
std::string currentFilePath = "..\\assets\\map.dat";
// Viewport pan & zoom
@ -58,7 +94,56 @@ public:
float canvasPanX = 20.0f;
float canvasPanY = 20.0f;
// Texture preview SRVs (DirectX 11)
struct ID3D11Device* pd3dDevice = nullptr;
struct ID3D11ShaderResourceView* textureSRVs[16] = { nullptr };
private:
bool EnsureEnginePreview();
void StopEnginePreview();
void UpdateEnginePreview();
void RenderSceneViewport();
std::string FindEnginePath() const;
HANDLE previewMapping = nullptr;
PROCESS_INFORMATION previewProcess{};
EnginePreviewPacket* previewPacket = nullptr;
struct ID3D11Texture2D* previewTexture = nullptr;
struct ID3D11ShaderResourceView* previewSRV = nullptr;
struct ID3D11SamplerState* previewSampler = nullptr;
std::vector<uint32_t> previewPixels;
std::vector<uint8_t> previewTextures;
std::string previewError;
bool previewTexturesDirty = true;
bool previewAttempted = false;
bool previewHasFrame = false;
bool previewSceneCamera = true;
int viewportMode = 0; // 0 split, 1 scene, 2 map
int previewX = 160, previewY = 160, previewAngle = 0;
double previewTurn = 0, previewMoveX = 0, previewMoveY = 0;
ULONGLONG previewRequestTime = 0;
bool showPlaytestError = false;
void RenderCutsceneTimeline();
void RenderCutsceneInspector();
void DrawCutscenePath(void* drawList, float originX, float originY, float tileSize);
void AddCutsceneEvent(uint8_t op);
void NormalizeCutscene();
void DeleteCutsceneEvent();
void RemoveCutsceneTransition(int index);
void ScrubCutscene(int frame);
void StopCutscenePreview();
void AdvanceCutscenePreview(float seconds);
int CutsceneEndTick() const;
int selectedCutsceneEvent = -1;
int cutsceneCursor = 0;
int dragEventOriginalTick = 0;
float cutsceneTimelineZoom = 60.0f;
bool placeCameraPoint = false;
bool cutscenePreviewPlaying = false;
bool cutscenePreviewSound = true;
double cutscenePreviewAccumulator = 0;
CutscenePreview cutscenePreview;
void RenderMenuBar();
void RenderToolbar();
void RenderGridCanvas();
@ -67,6 +152,11 @@ private:
void RenderHierarchy();
void HandleHotkeys();
bool SaveLevelToFile(const std::string& filepath, bool updateCurrentPath);
bool PreparePlaytest(std::string& startMap, std::string& enginePath);
void CleanupPlaytest();
bool WriteTextureSlot(int slotIndex, const std::vector<uint8_t>& pixels);
bool SetCell(int gridX, int gridY, uint8_t value);
void ApplyTool(int gridX, int gridY);
void FloodFill(int startX, int startY, uint8_t targetType, uint8_t fillType);
@ -87,7 +177,11 @@ private:
// File dialogs
std::string BrowseOpenFile();
std::string BrowseSaveFile();
std::string BrowseOpenImageFile();
std::string BrowseOpenAudioFile();
std::string BrowseSavePngFile(int slotIndex);
std::string playtestDirectory;
HANDLE hEngineProcess = NULL;
PROCESS_INFORMATION engineProcInfo{};
};

View file

@ -0,0 +1,343 @@
// Editor-only authoring and deterministic top-down preview of the ASM track.
static const char* const kSceneNames[] = {
"Конец", "Пауза", "Камера", "Поворот", "Субтитр", "Скрыть текст", "Звук", "Затемнение", "Переход"
};
int EditorApp::CutsceneEndTick() const {
return cutscene.events.empty() ? 0 : cutscene.events.back().tickTimestamp;
}
void EditorApp::StopCutscenePreview() {
if (cutscenePreviewPlaying) PlaySoundA(nullptr, nullptr, 0);
cutscenePreviewPlaying = false;
cutscenePreviewAccumulator = 0;
}
void EditorApp::ScrubCutscene(int frame) {
previewSceneCamera = true;
StopCutscenePreview();
cutsceneCursor = (std::max)(0, (std::min)(frame, CutsceneEndTick()));
cutscenePreview.Start(playerStartX*64+32, playerStartY*64+32, playerStartAngle);
for (int i = 0; i <= cutsceneCursor && cutscenePreview.active; ++i) cutscenePreview.Step(cutscene);
}
void EditorApp::AdvanceCutscenePreview(float seconds) {
if (!cutscenePreviewPlaying) return;
cutscenePreviewAccumulator += (std::min)(static_cast<double>(seconds), 0.25) * 60.0;
auto audio = [&](int index, bool loop) {
if (!cutscenePreviewSound) return;
if (index < 0) { PlaySoundA(nullptr, nullptr, 0); return; }
const std::string path = GetSoundsDirectory() + cutscene.strings[index];
if (!PlaySoundA(path.c_str(), nullptr, SND_FILENAME | SND_ASYNC | SND_NODEFAULT | (loop ? SND_LOOP : 0)))
statusMessage = "Не удалось воспроизвести WAV: " + path;
};
while (cutscenePreviewAccumulator >= 1.0 && cutscenePreviewPlaying) {
cutscenePreviewAccumulator -= 1.0;
cutsceneCursor = cutscenePreview.tick;
cutscenePreview.Step(cutscene, audio);
if (!cutscenePreview.active) {
StopCutscenePreview();
if (cutscenePreview.transition >= 0)
statusMessage = "Предпросмотр дошёл до перехода. F5 проверяет последовательность в игре.";
}
}
}
void EditorApp::NormalizeCutscene() {
std::vector<std::pair<CutsceneEvent, int>> sorted;
int endTick = 0;
bool selectedEnd = false;
for (int i = 0; i < static_cast<int>(cutscene.events.size()); ++i) {
auto event = cutscene.events[i];
if (event.opCode == OP_END) {
endTick = (std::max)(endTick, static_cast<int>(event.tickTimestamp));
selectedEnd |= i == selectedCutsceneEvent;
continue;
}
int duration = event.opCode == OP_MOVE_CAM_TO ? event.dataId :
(event.opCode == OP_PAN_CAM_TO || event.opCode == OP_FADE_SCREEN || event.opCode == OP_SHOW_SUBTITLE ? event.paramB :
event.opCode == OP_WAIT ? event.paramA : 1);
event.tickTimestamp = static_cast<uint16_t>((std::min)(static_cast<int>(event.tickTimestamp), 65535-duration));
endTick = (std::max)(endTick, event.tickTimestamp + (std::max)(1,duration));
sorted.push_back({event, i});
}
std::stable_sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.first.tickTimestamp < b.first.tickTimestamp; });
const int previousSelection = selectedCutsceneEvent;
selectedCutsceneEvent = -1;
cutscene.events.clear();
for (const auto& item : sorted) {
if (item.second == previousSelection) selectedCutsceneEvent = static_cast<int>(cutscene.events.size());
cutscene.events.push_back(item.first);
}
if (!sorted.empty() || selectedEnd) {
CutsceneEvent end{}; end.tickTimestamp = static_cast<uint16_t>((std::min)(65535, endTick)); end.opCode = OP_END;
cutscene.events.push_back(end);
if (selectedEnd) selectedCutsceneEvent = static_cast<int>(cutscene.events.size()) - 1;
}
StopCutscenePreview();
}
void EditorApp::AddCutsceneEvent(uint8_t op) {
if (cutscene.events.size() >= MAX_CUTSCENE_EVENTS ||
(cutscene.events.empty() && MAX_CUTSCENE_EVENTS < 2)) { statusMessage = "Лимит: 128 команд кат-сцены."; return; }
if (op == OP_CHANGE_SUBLEVEL && transitions.empty()) { statusMessage = "Сначала создайте портал перехода на карте."; return; }
if ((op == OP_SHOW_SUBTITLE || op == OP_PLAY_SOUND) && cutscene.strings.size() >= MAX_CUTSCENE_STRINGS) {
statusMessage = "Лимит: 32 строки/звука в кат-сцене."; return;
}
PushUndo();
CutsceneEvent event{}; event.opCode = op;
event.tickTimestamp = static_cast<uint16_t>((std::min)(cutsceneCursor, 61935));
switch (op) {
case OP_MOVE_CAM_TO: event.paramA = playerStartX*64+32; event.paramB = playerStartY*64+32; event.dataId = 120; break;
case OP_PAN_CAM_TO: event.paramA = playerStartAngle; event.paramB = 120; break;
case OP_SHOW_SUBTITLE: event.paramA = static_cast<int>(cutscene.strings.size()); event.paramB = 180;
cutscene.strings.push_back("Текст кат-сцены"); break;
case OP_PLAY_SOUND: {
auto sounds = GetSoundList();
event.paramA = static_cast<int>(cutscene.strings.size());
cutscene.strings.push_back(sounds.empty() ? "sound.wav" : sounds.front()); break;
}
case OP_CHANGE_SUBLEVEL: event.paramA = (std::max)(0, selectedTransitionIdx); break;
case OP_FADE_SCREEN: event.paramA = 16; event.paramB = 60; break;
case OP_WAIT: event.paramA = 60; break;
}
cutscene.events.push_back(event);
selectedCutsceneEvent = static_cast<int>(cutscene.events.size()) - 1;
NormalizeCutscene();
placeCameraPoint = op == OP_MOVE_CAM_TO;
ScrubCutscene(cutsceneCursor);
}
void EditorApp::DeleteCutsceneEvent() {
if (selectedCutsceneEvent < 0 || selectedCutsceneEvent >= static_cast<int>(cutscene.events.size())) return;
PushUndo();
cutscene.events.erase(cutscene.events.begin() + selectedCutsceneEvent);
selectedCutsceneEvent = -1; placeCameraPoint = false;
std::vector<std::string> strings;
std::vector<int> remap(cutscene.strings.size(), -1);
for (auto& event : cutscene.events) {
if ((event.opCode == OP_SHOW_SUBTITLE || event.opCode == OP_PLAY_SOUND) && event.paramA >= 0) {
if (remap[event.paramA] < 0) { remap[event.paramA] = static_cast<int>(strings.size()); strings.push_back(cutscene.strings[event.paramA]); }
event.paramA = remap[event.paramA];
}
}
cutscene.strings.swap(strings);
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
}
void EditorApp::RemoveCutsceneTransition(int index) {
cutscene.events.erase(std::remove_if(cutscene.events.begin(), cutscene.events.end(), [&](CutsceneEvent& event) {
if (event.opCode != OP_CHANGE_SUBLEVEL) return false;
if (event.paramA == index) return true;
if (event.paramA > index) --event.paramA;
return false;
}), cutscene.events.end());
selectedCutsceneEvent = -1;
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
}
void EditorApp::RenderCutsceneTimeline() {
ImGui::TextUnformatted("КАТ-СЦЕНА / 60 кадров в секунду");
ImGui::SameLine();
bool autoplay = (cutscene.flags & CUTSCENE_AUTOPLAY) != 0;
if (ImGui::Checkbox("При входе", &autoplay)) { PushUndo(); cutscene.flags = autoplay ? CUTSCENE_AUTOPLAY : 0; }
ImGui::SameLine();
if (!cutscenePreviewPlaying) {
if (ImGui::Button("▶ Предпросмотр")) {
std::string error;
if (cutscene.events.empty()) statusMessage = "Добавьте команды кат-сцены.";
else if (!ValidateCutscene(cutscene, &map[0][0], transitions.size(), error)) statusMessage = error;
else {
cutscenePreview.Start(playerStartX*64+32, playerStartY*64+32, playerStartAngle);
previewSceneCamera = true;
cutsceneCursor = -1; cutscenePreviewPlaying = true; cutscenePreviewAccumulator = 1;
AdvanceCutscenePreview(0);
}
}
} else if (ImGui::Button("■ Стоп")) { StopCutscenePreview(); ScrubCutscene(0); }
ImGui::SameLine();
if (ImGui::Checkbox("WAV", &cutscenePreviewSound) && !cutscenePreviewSound) PlaySoundA(nullptr, nullptr, 0);
if (ImGui::Button("+ Камера")) AddCutsceneEvent(OP_MOVE_CAM_TO);
ImGui::SameLine(); if (ImGui::Button("+ Поворот")) AddCutsceneEvent(OP_PAN_CAM_TO);
ImGui::SameLine(); if (ImGui::Button("+ Текст")) AddCutsceneEvent(OP_SHOW_SUBTITLE);
ImGui::SameLine(); if (ImGui::Button("+ Звук")) AddCutsceneEvent(OP_PLAY_SOUND);
ImGui::SameLine(); if (ImGui::Button("+ Переход")) AddCutsceneEvent(OP_CHANGE_SUBLEVEL);
if (ImGui::Button("+ Затемнение")) AddCutsceneEvent(OP_FADE_SCREEN);
ImGui::SameLine(); if (ImGui::Button("+ Пауза")) AddCutsceneEvent(OP_WAIT);
ImGui::SameLine(); if (ImGui::Button("+ Скрыть текст")) AddCutsceneEvent(OP_HIDE_SUBTITLE);
ImGui::SameLine(); if (ImGui::Button("Очистить")) {
PushUndo(); StopCutscenePreview(); cutscene = {}; selectedCutsceneEvent = -1; placeCameraPoint = false; ScrubCutscene(0);
}
const char* selectedName = selectedCutsceneEvent >= 0 && selectedCutsceneEvent < static_cast<int>(cutscene.events.size()) ? kSceneNames[cutscene.events[selectedCutsceneEvent].opCode] : "Выберите команду";
ImGui::SetNextItemWidth(210);
if (ImGui::BeginCombo("Команда",selectedName)) {
for (int i=0;i<static_cast<int>(cutscene.events.size());++i) {
char label[160]; snprintf(label,sizeof(label),"#%d %s — кадр %u",i+1,kSceneNames[cutscene.events[i].opCode],cutscene.events[i].tickTimestamp);
if (ImGui::Selectable(label,i==selectedCutsceneEvent)) { selectedCutsceneEvent=i; placeCameraPoint=false; ScrubCutscene(cutscene.events[i].tickTimestamp); }
}
ImGui::EndCombo();
}
ImGui::SetNextItemWidth(160);
int frame = cutsceneCursor;
if (ImGui::SliderInt("Кадр", &frame, 0, (std::max)(1, CutsceneEndTick()))) ScrubCutscene(frame);
ImGui::SameLine(); ImGui::Text("%.2f с / %.2f с", cutsceneCursor/60.0f, CutsceneEndTick()/60.0f);
ImGui::SameLine(); ImGui::SetNextItemWidth(90); ImGui::SliderFloat("Масштаб", &cutsceneTimelineZoom, 20, 180, "%.0f");
const float left = 82, pixels = cutsceneTimelineZoom/60.0f;
ImGui::BeginChild("TrackScroll", ImVec2(0, 130), true, ImGuiWindowFlags_HorizontalScrollbar);
ImVec2 origin = ImGui::GetCursorScreenPos();
const float width = (std::max)(ImGui::GetContentRegionAvail().x, left+(CutsceneEndTick()+120)*pixels);
auto* draw = ImGui::GetWindowDrawList();
static const char* rows[] = {"Камера", "Текст", "Звук", "Сцена"};
for (int row = 0; row < 4; ++row) {
draw->AddRectFilled(ImVec2(origin.x,origin.y+20+row*22), ImVec2(origin.x+width,origin.y+42+row*22), row%2 ? IM_COL32(31,34,41,255) : IM_COL32(39,42,49,255));
draw->AddText(ImVec2(origin.x+2,origin.y+23+row*22), IM_COL32(210,215,225,255), rows[row]);
}
for (int seconds = 0; seconds <= (CutsceneEndTick()+120)/60; ++seconds) {
float x = origin.x+left+seconds*cutsceneTimelineZoom;
char label[24]; snprintf(label,sizeof(label),"%ds",seconds);
draw->AddText(ImVec2(x,origin.y),IM_COL32(150,160,180,255),label);
draw->AddLine(ImVec2(x,origin.y+18),ImVec2(x,origin.y+108),IM_COL32(65,70,82,255));
}
bool sortAfterDrag = false;
for (int i = 0; i < static_cast<int>(cutscene.events.size()); ++i) {
auto& event = cutscene.events[i];
int row = event.opCode == OP_MOVE_CAM_TO || event.opCode == OP_PAN_CAM_TO ? 0 :
event.opCode == OP_SHOW_SUBTITLE || event.opCode == OP_HIDE_SUBTITLE ? 1 : event.opCode == OP_PLAY_SOUND ? 2 : 3;
ImVec2 center(origin.x+left+event.tickTimestamp*pixels,origin.y+31+row*22);
ImGui::SetCursorScreenPos(ImVec2(center.x-7,center.y-7));
ImGui::PushID(i);
ImGui::InvisibleButton("Key", ImVec2(14,14));
if (ImGui::IsItemActivated()) { PushUndo(); selectedCutsceneEvent = i; dragEventOriginalTick = event.tickTimestamp; StopCutscenePreview(); }
if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) {
int value = dragEventOriginalTick + static_cast<int>(ImGui::GetMouseDragDelta().x/pixels);
event.tickTimestamp = static_cast<uint16_t>((std::max)(0,(std::min)(61935,value)));
cutsceneCursor = event.tickTimestamp;
}
if (ImGui::IsItemDeactivated()) sortAfterDrag = true;
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s: кадр %u (%.2f с)",kSceneNames[event.opCode],event.tickTimestamp,event.tickTimestamp/60.0f);
ImU32 color = selectedCutsceneEvent == i ? IM_COL32(255,220,90,255) : IM_COL32(80+row*35,190-row*20,235,255);
draw->AddQuadFilled(ImVec2(center.x,center.y-7),ImVec2(center.x+7,center.y),ImVec2(center.x,center.y+7),ImVec2(center.x-7,center.y),color);
ImGui::PopID();
}
float cursor = origin.x+left+cutsceneCursor*pixels;
draw->AddLine(ImVec2(cursor,origin.y),ImVec2(cursor,origin.y+108),IM_COL32(255,90,90,255),2);
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2(width,112));
ImGui::EndChild();
if (sortAfterDrag) { NormalizeCutscene(); ScrubCutscene(cutsceneCursor); }
if (placeCameraPoint) ImGui::TextWrapped("Выберите точку камеры на карте. Клетки уровня при этом не меняются.");
else ImGui::TextWrapped("Выберите ромб для редактирования. C — запуск в игре, Tab — пропуск. Предпросмотр камеры показан на карте.");
}
void EditorApp::RenderCutsceneInspector() {
if (selectedCutsceneEvent >= static_cast<int>(cutscene.events.size())) { selectedCutsceneEvent = -1; return; }
CutsceneEvent edited = cutscene.events[selectedCutsceneEvent];
bool changed = false, activated = false;
auto activatedItem = [&]() { activated |= ImGui::IsItemActivated(); };
ImGui::TextColored(ImVec4(1,0.8f,0.3f,1),"КАТ-СЦЕНА: %s",kSceneNames[edited.opCode]);
int frame = edited.tickTimestamp;
changed |= ImGui::InputInt("Кадр",&frame); activatedItem();
edited.tickTimestamp = static_cast<uint16_t>((std::max)(0,(std::min)(65535,frame)));
ImGui::Text("Время: %.2f с",edited.tickTimestamp/60.0f);
bool textChanged = false;
std::string newText;
switch (edited.opCode) {
case OP_MOVE_CAM_TO: {
float x = edited.paramA/64.0f, y = edited.paramB/64.0f;
if (ImGui::SliderFloat("Камера X",&x,0,31.99f,"%.2f")) { edited.paramA = static_cast<int>(x*64); changed = true; } activatedItem();
if (ImGui::SliderFloat("Камера Y",&y,0,31.99f,"%.2f")) { edited.paramB = static_cast<int>(y*64); changed = true; } activatedItem();
changed |= ImGui::InputInt("Длительность",&edited.dataId); activatedItem();
edited.dataId = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.dataId));
if (ImGui::Button("Указать на карте")) placeCameraPoint = true;
ImGui::TextWrapped("Линейный пролёт между точками; 0 кадров — мгновенно. Камера не сталкивается со стенами."); break;
}
case OP_PAN_CAM_TO: {
float angle = edited.paramA*360.0f/512;
if (ImGui::SliderFloat("Угол",&angle,0,359.3f,"%.1f°")) { edited.paramA = static_cast<int>(angle*512/360)&511; changed = true; } activatedItem();
changed |= ImGui::InputInt("Длительность",&edited.paramB); activatedItem();
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB));
ImGui::TextWrapped("Поворот идёт по кратчайшей дуге."); break;
}
case OP_SHOW_SUBTITLE: case OP_PLAY_SOUND: {
char buffer[384]{};
if (edited.paramA >= 0 && edited.paramA < static_cast<int>(cutscene.strings.size())) strcpy_s(buffer,cutscene.strings[edited.paramA].c_str());
if (edited.opCode == OP_SHOW_SUBTITLE) {
textChanged = ImGui::InputTextMultiline("Текст",buffer,sizeof(buffer),ImVec2(-1,85)); activatedItem();
changed |= ImGui::InputInt("Показывать кадров",&edited.paramB); activatedItem();
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB));
ImGui::TextWrapped("До 95 символов CP1251, на экране 3 строки по 38. 0 кадров — до команды скрытия или конца.");
} else {
textChanged = ImGui::InputText("WAV",buffer,sizeof(buffer)); activatedItem();
auto sounds = GetSoundList();
if (ImGui::BeginCombo("Из ассетов",buffer)) {
for (const auto& sound : sounds) if (ImGui::Selectable(sound.c_str())) { strcpy_s(buffer,sound.c_str()); textChanged = true; activated = true; }
ImGui::EndCombo();
}
bool loop = (edited.param8&1)!=0;
if (ImGui::Checkbox("Повторять",&loop)) { edited.param8 = loop ? 1 : 0; changed = true; } activatedItem();
if (ImGui::Button("Стоп звука в этой точке")) { edited.paramA = -1; changed = true; activated = true; }
ImGui::TextWrapped("Имя WAV из assets/sounds, ASCII, до 63 символов. Новый звук заменяет предыдущий.");
}
newText = buffer;
changed |= textChanged;
break;
}
case OP_FADE_SCREEN:
changed |= ImGui::SliderInt("Затемнение",&edited.paramA,0,16); activatedItem();
changed |= ImGui::InputInt("Длительность",&edited.paramB); activatedItem();
edited.paramB = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramB)); break;
case OP_WAIT:
changed |= ImGui::InputInt("Пауза кадров",&edited.paramA); activatedItem();
edited.paramA = (std::max)(0,(std::min)(CUTSCENE_MAX_DURATION,edited.paramA));
ImGui::TextWrapped("Пауза задерживает следующие команды; начатые движения продолжаются."); break;
case OP_CHANGE_SUBLEVEL:
if (ImGui::BeginCombo("Портал",edited.paramA >= 0 && edited.paramA < static_cast<int>(transitions.size()) ? transitions[edited.paramA].targetMap : "Нет перехода")) {
for (int i=0;i<static_cast<int>(transitions.size());++i) {
ImGui::PushID(i);
if (ImGui::Selectable(transitions[i].targetMap,edited.paramA==i)) { edited.paramA=i; changed=true; activated=true; }
ImGui::PopID();
}
ImGui::EndCombo();
} break;
}
if (activated) PushUndo();
if (changed) {
StopCutscenePreview();
if (textChanged && edited.paramA < 0 && cutscene.strings.size() < MAX_CUTSCENE_STRINGS) {
edited.paramA = static_cast<int>(cutscene.strings.size()); cutscene.strings.push_back(newText);
}
cutscene.events[selectedCutsceneEvent] = edited;
if (textChanged && edited.paramA >= 0) cutscene.strings[edited.paramA] = newText;
NormalizeCutscene(); ScrubCutscene(cutsceneCursor);
}
if (ImGui::Button("Удалить команду")) DeleteCutsceneEvent();
}
void EditorApp::DrawCutscenePath(void* list, float ox, float oy, float size) {
if (!showCutsceneTimeline) return;
auto* draw = static_cast<ImDrawList*>(list);
ImVec2 previous(ox+(playerStartX+0.5f)*size,oy+(playerStartY+0.5f)*size);
for (int i=0;i<static_cast<int>(cutscene.events.size());++i) {
const auto& event=cutscene.events[i];
if (event.opCode!=OP_MOVE_CAM_TO) continue;
ImVec2 point(ox+event.paramA/64.0f*size,oy+event.paramB/64.0f*size);
draw->AddLine(previous,point,IM_COL32(80,190,255,220),2);
draw->AddCircleFilled(point,i==selectedCutsceneEvent?6.0f:4.0f,IM_COL32(255,220,60,255));
previous=point;
}
if (cutscene.events.empty()) return;
ImVec2 camera(ox+cutscenePreview.x/64.0f*size,oy+cutscenePreview.y/64.0f*size);
float angle=cutscenePreview.angle*3.14159265f/256;
draw->AddCircle(camera,size*0.3f,IM_COL32(255,255,255,255),0,2);
draw->AddLine(camera,ImVec2(camera.x+std::cos(angle)*size,camera.y+std::sin(angle)*size),IM_COL32(255,255,255,255),2);
if (cutscenePreview.subtitle>=0 && cutscenePreview.subtitle<static_cast<int>(cutscene.strings.size())) {
ImVec2 top=ImGui::GetWindowPos(), available=ImGui::GetWindowSize();
ImVec2 p(top.x+8,top.y+available.y-66);
draw->AddRectFilled(p,ImVec2(top.x+available.x-8,top.y+available.y-8),IM_COL32(0,0,0,200));
draw->AddText(nullptr,0,ImVec2(p.x+8,p.y+4),IM_COL32(255,255,255,255),cutscene.strings[cutscenePreview.subtitle].c_str(),nullptr,available.x-40);
}
if (cutscenePreview.fade) {
ImVec2 top=ImGui::GetWindowPos(), available=ImGui::GetWindowSize();
draw->AddRectFilled(top,ImVec2(top.x+available.x,top.y+available.y),IM_COL32(0,0,0,cutscenePreview.fade*255/16));
}
}

View file

@ -0,0 +1,186 @@
// The viewport displays frames produced by engine.exe itself, not a second raycaster.
std::string EditorApp::FindEnginePath() const {
char module[MAX_PATH]{}; GetModuleFileNameA(nullptr, module, MAX_PATH);
const auto directory = fs::path(module).parent_path();
std::error_code ec;
for (const auto& candidate : {directory / "engine.exe", directory.parent_path() / "engine.exe", fs::absolute("../engine.exe", ec)}) {
ec.clear();
if (fs::is_regular_file(candidate, ec)) return fs::absolute(candidate, ec).string();
}
return {};
}
void EditorApp::StopEnginePreview() {
if (previewProcess.hProcess) {
if (previewPacket) InterlockedExchange(&previewPacket->state, 3);
if (WaitForSingleObject(previewProcess.hProcess, 300) == WAIT_TIMEOUT) {
TerminateProcess(previewProcess.hProcess, 0);
WaitForSingleObject(previewProcess.hProcess, 1000);
}
CloseHandle(previewProcess.hProcess); CloseHandle(previewProcess.hThread);
previewProcess = {};
}
if (previewPacket) { UnmapViewOfFile(previewPacket); previewPacket = nullptr; }
if (previewMapping) { CloseHandle(previewMapping); previewMapping = nullptr; }
previewHasFrame = false;
}
bool EditorApp::EnsureEnginePreview() {
if (previewPacket) return true;
if (previewAttempted) return false;
previewAttempted = true;
auto fail = [&](const std::string& reason) {
previewError = reason; StopEnginePreview(); return false;
};
const auto engine = FindEnginePath();
if (engine.empty()) return fail("Не найден engine.exe. Соберите движок через build.bat.");
const std::string name = "Local\\EblanPreview-" + std::to_string(GetCurrentProcessId()) + "-" + std::to_string(GetTickCount64());
previewMapping = CreateFileMappingA(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, sizeof(EnginePreviewPacket), name.c_str());
if (!previewMapping) return fail("Не удалось создать буфер игрового вида: " + std::to_string(GetLastError()));
previewPacket = static_cast<EnginePreviewPacket*>(MapViewOfFile(previewMapping, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(EnginePreviewPacket)));
if (!previewPacket) return fail("Не удалось открыть буфер игрового вида.");
// Initialize in place: no large packet on the thread's stack.
memset(previewPacket, 0, sizeof(*previewPacket));
previewPacket->magic = 0x56504245; previewPacket->version = 1; previewPacket->parentPid = GetCurrentProcessId();
std::string command = "\"" + engine + "\" \"--preview:" + name + "\"";
STARTUPINFOA si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESHOWWINDOW; si.wShowWindow = SW_HIDE;
if (!CreateProcessA(engine.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr,
fs::path(engine).parent_path().string().c_str(), &si, &previewProcess))
return fail("Не удалось запустить игровой вид: " + std::to_string(GetLastError()));
previewTexturesDirty = true; previewError.clear();
return true;
}
void EditorApp::UpdateEnginePreview() {
if (!EnsureEnginePreview()) return;
if (WaitForSingleObject(previewProcess.hProcess, 0) == WAIT_OBJECT_0) {
DWORD code = 0; GetExitCodeProcess(previewProcess.hProcess, &code);
previewError = "Игровой вид остановлен (код " + std::to_string(code) + "). Пересоберите engine.exe.";
StopEnginePreview(); return;
}
const LONG state = InterlockedCompareExchange(&previewPacket->state, 0, 0);
if (state == 1) {
if (GetTickCount64() - previewRequestTime > 5000) {
previewError = "Движок не ответил на запрос игрового вида."; StopEnginePreview();
}
return;
}
if (state == 2) {
previewPixels.assign(std::begin(previewPacket->pixels), std::end(previewPacket->pixels));
for (auto& pixel : previewPixels) pixel |= 0xFF000000; // DIB X -> opaque DX11 alpha
if (pd3dDevice) {
if (!previewTexture) {
D3D11_TEXTURE2D_DESC desc{};
desc.Width = 320; desc.Height = 240; desc.MipLevels = desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
if (FAILED(pd3dDevice->CreateTexture2D(&desc,nullptr,&previewTexture)) ||
FAILED(pd3dDevice->CreateShaderResourceView(previewTexture,nullptr,&previewSRV))) {
if (previewSRV) { previewSRV->Release(); previewSRV = nullptr; }
if (previewTexture) { previewTexture->Release(); previewTexture = nullptr; }
previewError = "Не удалось создать изображение игрового вида."; StopEnginePreview(); return;
}
}
ID3D11DeviceContext* context = nullptr; pd3dDevice->GetImmediateContext(&context);
context->UpdateSubresource(previewTexture,0,nullptr,previewPixels.data(),320*4,0);
context->Release();
}
previewHasFrame = true;
}
if (previewTexturesDirty) {
if (!ReadTextureSlots(GetTexturePackPath(), previewTextures)) {
previewError = "Повреждён пакет текстур. Исправьте его и нажмите «Повторить».";
StopEnginePreview(); return;
}
memcpy(previewPacket->textures,previewTextures.data(),previewTextures.size());
++previewPacket->textureRevision; previewTexturesDirty = false;
}
const bool scene = showCutsceneTimeline && !cutscene.events.empty() && previewSceneCamera;
previewPacket->x = scene ? cutscenePreview.x : previewSceneCamera ? playerStartX*64+32 : previewX;
previewPacket->y = scene ? cutscenePreview.y : previewSceneCamera ? playerStartY*64+32 : previewY;
previewPacket->angle = scene ? cutscenePreview.angle : previewSceneCamera ? playerStartAngle : previewAngle;
previewPacket->ceiling = ceilingColor; previewPacket->floor = floorColor;
previewPacket->spriteCount = static_cast<uint32_t>((std::min)(sprites.size(),size_t(32)));
memcpy(previewPacket->cells,map,sizeof(map));
if (!sprites.empty()) memcpy(previewPacket->sprites,sprites.data(),previewPacket->spriteCount*sizeof(SpriteEntry));
previewPacket->cinematic = scene && cutscenePreview.active;
previewPacket->fade = scene ? cutscenePreview.fade : 0;
memset(previewPacket->subtitle,0,96);
if (scene && cutscenePreview.subtitle >= 0 && cutscenePreview.subtitle < static_cast<int>(cutscene.strings.size())) {
std::string encoded;
if (CutsceneEncode(cutscene.strings[cutscenePreview.subtitle],encoded)) memcpy(previewPacket->subtitle,encoded.data(),encoded.size());
}
previewRequestTime = GetTickCount64();
InterlockedExchange(&previewPacket->state,1);
}
static void ViewportPointSampler(const ImDrawList*, const ImDrawCmd* command) {
auto* sampler = static_cast<ID3D11SamplerState*>(command->UserCallbackData);
ID3D11Device* device = nullptr; sampler->GetDevice(&device);
ID3D11DeviceContext* context = nullptr; device->GetImmediateContext(&context);
context->PSSetSamplers(0,1,&sampler); context->Release(); device->Release();
}
void EditorApp::RenderSceneViewport() {
ImGui::TextUnformatted("ИГРОВОЙ ВИД / ASM");
if (ImGui::Button("К спавну")) {
previewSceneCamera = true; ScrubCutscene(0);
}
ImGui::SameLine();
if (ImGui::Checkbox("Камера сцены",&previewSceneCamera) && !previewSceneCamera) {
previewX = cutscenePreview.x; previewY = cutscenePreview.y; previewAngle = cutscenePreview.angle;
}
if (!pd3dDevice) { ImGui::TextUnformatted("Игровой вид доступен в окне редактора."); return; }
UpdateEnginePreview();
if (!previewError.empty()) {
ImGui::TextWrapped("%s",previewError.c_str());
if (ImGui::Button("Повторить")) { StopEnginePreview(); previewAttempted = false; previewError.clear(); }
}
if (!previewHasFrame || !previewSRV) { ImGui::TextUnformatted("Подготовка кадра движка…"); return; }
const auto available = ImGui::GetContentRegionAvail();
float width = (std::max)(1.0f,(std::min)(available.x,(available.y-48)*4/3));
ImVec2 size(width,width*3/4);
if (!previewSampler) {
D3D11_SAMPLER_DESC desc{}; desc.Filter=D3D11_FILTER_MIN_MAG_MIP_POINT;
desc.AddressU=desc.AddressV=desc.AddressW=D3D11_TEXTURE_ADDRESS_CLAMP; desc.MaxLOD=D3D11_FLOAT32_MAX;
pd3dDevice->CreateSamplerState(&desc,&previewSampler);
}
if (previewSampler) ImGui::GetWindowDrawList()->AddCallback(ViewportPointSampler,previewSampler);
ImGui::Image(reinterpret_cast<ImTextureID>(previewSRV),size);
if (previewSampler) ImGui::GetWindowDrawList()->AddCallback(ImDrawCallback_ResetRenderState,nullptr);
const bool hovered = ImGui::IsItemHovered();
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) ImGui::SetWindowFocus();
// Free camera never writes to the player's spawn or to authored keyframes.
if (!cutscenePreviewPlaying && !ImGui::GetIO().WantTextInput &&
ImGui::IsWindowFocused() && !ImGui::IsAnyItemActive() && !ImGui::IsPopupOpen(nullptr,ImGuiPopupFlags_AnyPopupId)) {
const auto held = [](ImGuiKey key) { return ImGui::IsKeyDown(key) ? 1 : 0; };
const int forward = held(ImGuiKey_W)-held(ImGuiKey_S), strafe = held(ImGuiKey_D)-held(ImGuiKey_A);
const int turn = held(ImGuiKey_RightArrow)-held(ImGuiKey_LeftArrow);
const float dt = (std::min)(ImGui::GetIO().DeltaTime,0.1f);
if (forward || strafe || turn || (hovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right))) {
if (previewSceneCamera) {
previewX = previewPacket ? previewPacket->x : playerStartX*64+32;
previewY = previewPacket ? previewPacket->y : playerStartY*64+32;
previewAngle = previewPacket ? previewPacket->angle : playerStartAngle;
previewSceneCamera = false;
}
previewTurn += turn*128*dt + (hovered && ImGui::IsMouseDown(ImGuiMouseButton_Right) ? ImGui::GetIO().MouseDelta.x*0.5 : 0);
int step=static_cast<int>(previewTurn); previewTurn-=step; previewAngle=(previewAngle+step)&511;
const double a = previewAngle*3.141592653589793/256;
const double speed = (ImGui::GetIO().KeyShift ? 360 : 180)*dt;
previewMoveX += (forward*std::cos(a)-strafe*std::sin(a))*speed;
previewMoveY += (forward*std::sin(a)+strafe*std::cos(a))*speed;
int dx=static_cast<int>(previewMoveX),dy=static_cast<int>(previewMoveY);
previewMoveX-=dx; previewMoveY-=dy;
auto free = [&](int x,int y) {
for (int oy : {-12,12}) for (int ox : {-12,12}) {
if(x+ox<0 || x+ox>=2048 || y+oy<0 || y+oy>=2048 || !IsWalkableCell(map[(y+oy)/64][(x+ox)/64])) return false;
}
return true;
};
if (free(previewX+dx,previewY)) previewX+=dx;
if (free(previewX,previewY+dy)) previewY+=dy;
}
}
ImGui::TextWrapped("Клик по виду: WASD, стрелки — осмотр. Shift — быстрее. Камера сцены — предпросмотр таймлайна.");
}

View file

@ -6,7 +6,7 @@
// ==============================================================================
constexpr uint32_t MAP_MAGIC = 0x4E4C4245; // 'EBLN'
constexpr uint16_t MAP_FORMAT_VERSION = 2; // v2: sprite x/y in raw world units (v1 was 16.16 fixed)
constexpr uint16_t MAP_FORMAT_VERSION = 4; // v4 adds a cutscene track and CP1251 string pool
constexpr int MAP_GRID_W = 32;
constexpr int MAP_GRID_H = 32;
constexpr int MAP_TOTAL_CELLS = MAP_GRID_W * MAP_GRID_H; // 1024 bytes
@ -24,13 +24,37 @@ enum CellType : uint8_t {
CELL_PILLAR = 7,
CELL_GLASS = 8,
CELL_MARBLE = 9,
CELL_COUNT = 10
CELL_PORTAL = 10,
CELL_COUNT = 11
};
constexpr int MAX_LEVEL_TRANSITIONS = 16;
constexpr int MAX_CUTSCENE_EVENTS = 128;
constexpr int MAX_CUTSCENE_STRINGS = 32;
constexpr int CUTSCENE_STRING_BYTES = 96;
constexpr int CUTSCENE_MAX_DURATION = 3600;
constexpr uint16_t CUTSCENE_AUTOPLAY = 1;
enum CutsceneOpCode : uint8_t {
OP_END, OP_WAIT, OP_MOVE_CAM_TO, OP_PAN_CAM_TO, OP_SHOW_SUBTITLE,
OP_HIDE_SUBTITLE, OP_PLAY_SOUND, OP_FADE_SCREEN, OP_CHANGE_SUBLEVEL
};
#pragma pack(push, 1)
struct LevelTransition {
uint8_t triggerCellX; // Trigger cell coordinate X (0..31)
uint8_t triggerCellY; // Trigger cell coordinate Y (0..31)
char targetMap[32]; // Target sublevel filename (e.g. "sublevel_02.dat")
uint16_t targetSpawnX; // Spawn cell X in destination map (0..31)
uint16_t targetSpawnY; // Spawn cell Y in destination map (0..31)
uint16_t targetAngle; // Player orientation (0..511)
uint8_t fadeEffect; // 1 = smooth fade to black, 0 = instant cut
uint8_t reserved; // Padding (total 42 bytes)
};
struct MapHeader {
uint32_t magic; // 'EBLN'
uint16_t version; // 1
uint16_t version; // Current writer: 4; reader also accepts v1/v2/v3
uint16_t mapWidth; // 32
uint16_t mapHeight; // 32
uint16_t playerStartX; // Cell coordinate (0..31)
@ -39,7 +63,20 @@ struct MapHeader {
uint16_t spriteCount; // Number of active sprites
uint32_t ceilingColor; // 00RRGGBBh
uint32_t floorColor; // 00RRGGBBh
uint8_t reserved[12]; // Reserved for future use
uint16_t transitionCount; // Number of sublevel transitions
uint16_t cutsceneCount; // v4 only; old versions ignore the remaining header fields
uint16_t cutsceneStringCount;
uint16_t cutsceneFlags; // bit 0 = start automatically after entering this map
uint8_t reserved[4]; // Total header remains 38 bytes
};
struct CutsceneEvent {
uint16_t tickTimestamp; // Absolute 60 Hz timeline timestamp, 0..65535
uint8_t opCode;
uint8_t param8; // PLAY_SOUND bit 0 = loop; other operations require 0
int32_t paramA; // X, angle, text/sound index, fade level or transition index
int32_t paramB; // Y, duration (PAN/FADE/SUBTITLE) or unused
int32_t dataId; // MOVE duration; never a serialized memory pointer
};
// Engine world units: 1 cell = 64 units (raw integers, NOT fixed point).
@ -52,3 +89,8 @@ struct SpriteEntry {
uint32_t flags; // Flags / type
};
#pragma pack(pop)
static_assert(sizeof(MapHeader) == 38, "EBLN header layout changed");
static_assert(sizeof(SpriteEntry) == 24, "EBLN sprite layout changed");
static_assert(sizeof(LevelTransition) == 42, "EBLN transition layout changed");
static_assert(sizeof(CutsceneEvent) == 16, "EBLN cutscene event layout changed");

116
editor/src/level_io.h Normal file
View file

@ -0,0 +1,116 @@
#pragma once
#include "level_format.h"
#include "cutscene_format.h"
#include <array>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
// Parse into a separate value: a failed load must never partially replace a room.
struct BinaryLevel {
MapHeader header{};
std::array<uint8_t, MAP_TOTAL_CELLS> cells{};
std::vector<SpriteEntry> sprites;
std::vector<LevelTransition> transitions;
CutsceneTrack cutscene;
};
inline bool IsMapFilename(const char* name) {
size_t length = 0;
for (; length < 32 && name[length]; ++length) {
unsigned char ch = static_cast<unsigned char>(name[length]);
if (ch < 32 || ch >= 127 || std::strchr("\\/:*?\"<>|", ch)) return false;
}
if (length < 5 || length >= 32) return false;
const char* ext = name + length - 4;
return ext[0] == '.' && (ext[1] | 32) == 'd' &&
(ext[2] | 32) == 'a' && (ext[3] | 32) == 't';
}
inline bool IsWalkableCell(uint8_t cell) {
return cell == CELL_EMPTY || cell == CELL_DOOR_OPEN || cell == CELL_PORTAL;
}
inline bool ValidateLevel(const BinaryLevel& level, std::string& error) {
const auto& h = level.header;
auto fail = [&](const char* reason) { error = reason; return false; };
if (h.magic != MAP_MAGIC || h.version < 1 || h.version > MAP_FORMAT_VERSION ||
h.mapWidth != MAP_GRID_W || h.mapHeight != MAP_GRID_H)
return fail("Неверный формат или версия карты");
if (level.sprites.size() > MAX_LEVEL_SPRITES || level.transitions.size() > MAX_LEVEL_TRANSITIONS)
return fail("Превышен лимит спрайтов или переходов");
if (h.playerStartX >= MAP_GRID_W || h.playerStartY >= MAP_GRID_H || h.playerStartAngle > 511)
return fail("Неверные координаты или угол спавна");
for (uint8_t cell : level.cells) if (cell > 15) return fail("Неверный тип клетки");
if (!IsWalkableCell(level.cells[h.playerStartY * MAP_GRID_W + h.playerStartX]))
return fail("Спавн игрока находится внутри стены");
for (const auto& sprite : level.sprites) {
if (sprite.x < 0 || sprite.x >= MAP_GRID_W * 64 || sprite.y < 0 || sprite.y >= MAP_GRID_H * 64 ||
sprite.textureId >= 16 || sprite.scale < 0 || sprite.scale > 16 * 65536)
return fail("Неверные координаты, текстура или масштаб спрайта");
}
std::array<bool, MAP_TOTAL_CELLS> triggers{};
for (const auto& transition : level.transitions) {
if (transition.triggerCellX >= MAP_GRID_W || transition.triggerCellY >= MAP_GRID_H ||
transition.targetSpawnX >= MAP_GRID_W || transition.targetSpawnY >= MAP_GRID_H ||
transition.targetAngle > 511 || transition.fadeEffect > 1 || !IsMapFilename(transition.targetMap))
return fail("Неверные свойства перехода (имя файла: ASCII, до 31 символа, .dat)");
size_t index = transition.triggerCellY * MAP_GRID_W + transition.triggerCellX;
if (triggers[index] || !IsWalkableCell(level.cells[index]))
return fail("Повторный переход или триггер внутри стены");
triggers[index] = true;
}
for (size_t i = 0; i < level.cells.size(); ++i)
if (level.cells[i] == CELL_PORTAL && !triggers[i]) return fail("Портал не имеет настроенного перехода");
return ValidateCutscene(level.cutscene, level.cells.data(), level.transitions.size(), error);
}
inline bool ReadLevelFile(const std::string& path, BinaryLevel& result, std::string& error) {
std::ifstream in(path, std::ios::binary | std::ios::ate);
if (!in) { error = "Не удалось открыть карту: " + path; return false; }
const auto fileSize = in.tellg();
in.seekg(0);
BinaryLevel candidate;
if (!in.read(reinterpret_cast<char*>(&candidate.header), sizeof(MapHeader))) {
error = "Обрезанный заголовок карты"; return false;
}
const auto& h = candidate.header;
const size_t transitionCount = h.version == 1 ? 0 : h.transitionCount;
if (h.spriteCount > MAX_LEVEL_SPRITES || transitionCount > MAX_LEVEL_TRANSITIONS) {
error = "Превышен лимит объектов карты"; return false;
}
const size_t eventCount = h.version >= 4 ? h.cutsceneCount : 0;
const size_t stringCount = h.version >= 4 ? h.cutsceneStringCount : 0;
if (eventCount > MAX_CUTSCENE_EVENTS || stringCount > MAX_CUTSCENE_STRINGS) {
error = "Превышен лимит событий или строк кат-сцены"; return false;
}
const size_t expected = sizeof(MapHeader) + MAP_TOTAL_CELLS +
h.spriteCount * sizeof(SpriteEntry) + transitionCount * sizeof(LevelTransition) +
eventCount * sizeof(CutsceneEvent) + stringCount * CUTSCENE_STRING_BYTES;
if (fileSize != static_cast<std::streamoff>(expected)) {
error = "Размер файла не соответствует заголовку карты"; return false;
}
candidate.sprites.resize(h.spriteCount);
candidate.transitions.resize(transitionCount);
in.read(reinterpret_cast<char*>(candidate.cells.data()), MAP_TOTAL_CELLS);
if (!candidate.sprites.empty())
in.read(reinterpret_cast<char*>(candidate.sprites.data()), candidate.sprites.size() * sizeof(SpriteEntry));
if (!candidate.transitions.empty())
in.read(reinterpret_cast<char*>(candidate.transitions.data()), candidate.transitions.size() * sizeof(LevelTransition));
candidate.cutscene.events.resize(eventCount);
if (eventCount) in.read(reinterpret_cast<char*>(candidate.cutscene.events.data()), eventCount * sizeof(CutsceneEvent));
candidate.cutscene.flags = h.version >= 4 ? h.cutsceneFlags : 0;
for (size_t i = 0; i < stringCount; ++i) {
char bytes[CUTSCENE_STRING_BYTES]{};
if (!in.read(bytes, sizeof(bytes))) { error = "Обрезанная строка кат-сцены"; return false; }
const char* end = static_cast<const char*>(std::memchr(bytes, 0, sizeof(bytes)));
if (!end) { error = "Строка кат-сцены не имеет завершающего нуля"; return false; }
candidate.cutscene.strings.push_back(CutsceneDecode(bytes, static_cast<int>(end - bytes)));
}
if (!in) { error = "Не удалось прочитать карту целиком"; return false; }
if (h.version == 1) for (auto& sprite : candidate.sprites) { sprite.x >>= 16; sprite.y >>= 16; }
if (!ValidateLevel(candidate, error)) return false;
result = std::move(candidate);
return true;
}

View file

@ -6,9 +6,14 @@
#include <tchar.h>
#include <string>
#include <shellapi.h>
// Forward declare message handler from imgui_impl_win32.cpp
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
// Global Application Instance (for Drag & Drop messages)
static EditorApp* g_pApp = nullptr;
// Global DirectX 11 Data
static ID3D11Device* g_pd3dDevice = nullptr;
static ID3D11DeviceContext* g_pd3dDeviceContext = nullptr;
@ -87,6 +92,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
ShowWindow(hWnd, SW_SHOWDEFAULT);
UpdateWindow(hWnd);
DragAcceptFiles(hWnd, TRUE);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
@ -135,6 +141,9 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
ImGui_ImplDX11_Init(g_pd3dDevice, g_pd3dDeviceContext);
EditorApp app;
app.LoadLevel(app.currentFilePath);
app.InitD3D(g_pd3dDevice);
g_pApp = &app;
bool done = false;
while (!done) {
@ -161,6 +170,9 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
g_pSwapChain->Present(1, 0); // V-Sync enabled for 60 FPS
}
g_pApp = nullptr;
app.ShutdownD3D();
ImGui_ImplDX11_Shutdown();
ImGui_ImplWin32_Shutdown();
ImGui::DestroyContext();
@ -227,6 +239,15 @@ LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
return true;
switch (msg) {
case WM_DROPFILES: {
HDROP hDrop = reinterpret_cast<HDROP>(wParam);
char filePath[MAX_PATH];
if (DragQueryFileA(hDrop, 0, filePath, MAX_PATH)) {
if (g_pApp) g_pApp->HandleDropFile(filePath);
}
DragFinish(hDrop);
return 0;
}
case WM_SIZE:
if (g_pd3dDevice != NULL && wParam != SIZE_MINIMIZED) {
CleanupRenderTarget();

View file

@ -0,0 +1,28 @@
#pragma once
#include <cstdint>
#include <cstddef>
#include "level_format.h"
// Single producer/consumer mailbox, shared by the x64 editor and x86 engine.
// state: 0 ready, 1 request owned by engine, 2 result owned by editor, 3 quit.
// Never modify payload while state == 1. All fields have fixed wire widths.
struct EnginePreviewPacket {
uint32_t magic = 0x56504245; // EBPV
uint32_t version = 1;
volatile long state = 0;
uint32_t parentPid = 0;
int32_t x = 160, y = 160, angle = 0;
uint32_t ceiling = 0, floor = 0, spriteCount = 0;
uint32_t cinematic = 0, fade = 0, textureRevision = 0;
uint32_t reserved[19]{};
uint8_t cells[1024]{};
SpriteEntry sprites[32]{};
char subtitle[96]{};
uint32_t textures[16*128*128]{};
uint32_t pixels[320*240]{};
};
static_assert(sizeof(long) == 4, "Preview protocol requires Windows LONG");
static_assert(offsetof(EnginePreviewPacket, cells) == 128, "Preview header mismatch");
static_assert(offsetof(EnginePreviewPacket, textures) == 2016, "Preview texture offset mismatch");
static_assert(offsetof(EnginePreviewPacket, pixels) == 1050592, "Preview framebuffer offset mismatch");
static_assert(sizeof(EnginePreviewPacket) == 1357792, "Preview mapping size mismatch");

7988
editor/src/stb_image.h Normal file

File diff suppressed because it is too large Load diff

1724
editor/src/stb_image_write.h Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,15 @@
# Пример кат-сцены
Запустите `run_demo.bat` после сборки движка. Вступление длится около 10 секунд:
камера проходит три отрезка, поворачивается, показывает русские субтитры,
проигрывает WAV и с затемнением переходит во вторую комнату. Tab пропускает сцену,
C запускает её повторно на карте, где она есть. Во второй комнате работает обычное управление.
Для редактирования откройте `assets/map.dat` этой папки в EblanStudio (Ctrl+O),
затем откройте таймлайн клавишей F6. Выберите команду в списке или ромб на дорожке.
F5 использует временную копию карт и звуков. Исходные карты основного проекта этот пример не меняет.
Файлы примера можно восстановить командой `python tools/make_cutscene_demo.py`
из корня репозитория. Предпросмотр в редакторе показывает сцену глазами камеры
в игровом виде, а на карте — её траекторию. Ползунок кадра перематывает изображение.
F5 запускает полную последовательность со сменой комнаты.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
@echo off
cd /d "%~dp0"
start "" "..\..\engine.exe" "assets\map.dat"

583
include/engine_cutscene.inc Normal file
View file

@ -0,0 +1,583 @@
; Timed 60 Hz cutscene track. Strings are bounded CP1251 data, never pointers.
.code
ValidateSoundFilename PROC uses ebx ecx esi, namePtr:DWORD
mov esi, namePtr
xor ecx, ecx
cs_sound_character:
mov al, [esi+ecx]
test al, al
jz cs_sound_end
cmp al, 32
jb cs_sound_invalid
cmp al, 127
jae cs_sound_invalid
cmp al, 92
je cs_sound_invalid
cmp al, '/'
je cs_sound_invalid
cmp al, ':'
je cs_sound_invalid
cmp al, '*'
je cs_sound_invalid
cmp al, '?'
je cs_sound_invalid
cmp al, 34
je cs_sound_invalid
cmp al, '<'
je cs_sound_invalid
cmp al, '>'
je cs_sound_invalid
cmp al, '|'
je cs_sound_invalid
inc ecx
cmp ecx, 64
jb cs_sound_character
cs_sound_invalid:
xor eax, eax
ret
cs_sound_end:
cmp ecx, 5
jb cs_sound_invalid
mov eax, [esi+ecx-4]
or eax, 20202000h
cmp eax, 7661772Eh
jne cs_sound_invalid
mov eax, 1
ret
ValidateSoundFilename ENDP
ValidateStagedCutscene PROC uses ebx ecx edx esi edi, eventCount:DWORD, stringCount:DWORD, flags:DWORD, cells:DWORD, transitions:DWORD
LOCAL previous:DWORD, eventIndex:DWORD
cmp eventCount, MAX_CUTSCENE_EVENTS
ja csv_invalid
cmp stringCount, MAX_CUTSCENE_STRINGS
ja csv_invalid
mov eax, flags
and eax, 0FFFFFFFEh
jnz csv_invalid
mov esi, OFFSET stagedCutsceneStrings
mov edx, stringCount
csv_next_string:
test edx, edx
jz csv_events
xor ecx, ecx
csv_string_char:
mov al, [esi+ecx]
test al, al
jz csv_string_end
cmp al, 10
je csv_string_advance
cmp al, 32
jb csv_invalid
csv_string_advance:
inc ecx
cmp ecx, CUTSCENE_STRING_BYTES
jb csv_string_char
jmp csv_invalid
csv_string_end:
add esi, CUTSCENE_STRING_BYTES
dec edx
jmp csv_next_string
csv_events:
mov esi, OFFSET stagedCutsceneEvents
mov previous, 0
mov eventIndex, 0
csv_event:
mov eax, eventIndex
cmp eax, eventCount
jae csv_finished
movzx eax, [esi].CutsceneEvent.tickTimestamp
cmp eax, previous
jb csv_invalid
mov previous, eax
movzx eax, [esi].CutsceneEvent.opCode
cmp eax, OP_CHANGE_SUBLEVEL
ja csv_invalid
movzx edx, [esi].CutsceneEvent.param8
cmp eax, OP_PLAY_SOUND
je csv_sound_flags
test edx, edx
jnz csv_invalid
jmp csv_dispatch
csv_sound_flags:
cmp edx, 1
ja csv_invalid
csv_dispatch:
cmp eax, OP_END
je csv_end
cmp eax, OP_WAIT
je csv_wait
cmp eax, OP_MOVE_CAM_TO
je csv_move
cmp eax, OP_PAN_CAM_TO
je csv_pan
cmp eax, OP_FADE_SCREEN
je csv_fade
cmp eax, OP_SHOW_SUBTITLE
je csv_text
cmp eax, OP_PLAY_SOUND
je csv_sound
cmp eax, OP_CHANGE_SUBLEVEL
je csv_transition
jmp csv_next
csv_end:
mov eax, eventIndex
inc eax
cmp eax, eventCount
jne csv_invalid
jmp csv_next
csv_wait:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, CUTSCENE_MAX_DURATION
ja csv_invalid
jmp csv_next
csv_move:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, MAP_W*CELL_SIZE
jae csv_invalid
mov ebx, [esi].CutsceneEvent.paramB
cmp ebx, MAP_H*CELL_SIZE
jae csv_invalid
shr eax, CELL_SHIFT
shr ebx, CELL_SHIFT
shl ebx, 5
add ebx, eax
add ebx, cells
mov al, [ebx]
cmp al, CELL_EMPTY
je csv_move_duration
cmp al, CELL_DOOR_OPEN
je csv_move_duration
cmp al, CELL_PORTAL
jne csv_invalid
csv_move_duration:
mov eax, [esi].CutsceneEvent.dataId
cmp eax, CUTSCENE_MAX_DURATION
ja csv_invalid
jmp csv_next
csv_pan:
cmp [esi].CutsceneEvent.paramA, ANGLE_MASK
ja csv_invalid
jmp csv_duration
csv_fade:
cmp [esi].CutsceneEvent.paramA, 16
ja csv_invalid
jmp csv_duration
csv_text:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, stringCount
jae csv_invalid
csv_duration:
cmp [esi].CutsceneEvent.paramB, CUTSCENE_MAX_DURATION
ja csv_invalid
jmp csv_next
csv_sound:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, -1
je csv_next
cmp eax, stringCount
jae csv_invalid
imul eax, CUTSCENE_STRING_BYTES
add eax, OFFSET stagedCutsceneStrings
invoke ValidateSoundFilename, eax
test eax, eax
jz csv_invalid
jmp csv_next
csv_transition:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, transitions
jae csv_invalid
csv_next:
movzx eax, [esi].CutsceneEvent.opCode
xor ebx, ebx
cmp eax, OP_MOVE_CAM_TO
jne csv_span_not_move
mov ebx, [esi].CutsceneEvent.dataId
jmp csv_check_span
csv_span_not_move:
cmp eax, OP_WAIT
jne csv_span_not_wait
mov ebx, [esi].CutsceneEvent.paramA
jmp csv_check_span
csv_span_not_wait:
cmp eax, OP_PAN_CAM_TO
je csv_span_param_b
cmp eax, OP_FADE_SCREEN
je csv_span_param_b
cmp eax, OP_SHOW_SUBTITLE
jne csv_check_span
csv_span_param_b:
mov ebx, [esi].CutsceneEvent.paramB
csv_check_span:
movzx eax, [esi].CutsceneEvent.tickTimestamp
add eax, ebx
cmp eax, 65535
ja csv_invalid
add esi, sizeof CutsceneEvent
inc eventIndex
jmp csv_event
csv_finished:
cmp eventCount, 0
je csv_valid
cmp BYTE PTR [esi-sizeof CutsceneEvent+CutsceneEvent.opCode], OP_END
jne csv_invalid
csv_valid:
mov eax, 1
ret
csv_invalid:
xor eax, eax
ret
ValidateStagedCutscene ENDP
StopCutscene PROC uses eax ebx
mov isCutsceneActive, 0
mov cutscenePending, 0
mov cutsceneSubtitle, -1
mov cutsceneSubtitleUntil, 0
mov cutsceneFade, 0
mov csMoveDuration, 0
mov csAngleDuration, 0
mov csFadeDuration, 0
mov cutsceneWait, 0
mov eax, playerY
sar eax, CELL_SHIFT
shl eax, 5
mov ebx, playerX
sar ebx, CELL_SHIFT
add eax, ebx
mov portalLatchCell, eax
invoke PlaySoundA, 0, 0, 0
ret
StopCutscene ENDP
StartCutscene PROC
mov cutscenePending, 0
cmp cutsceneCount, 0
je cs_start_empty
call StopCutscene
mov cutsceneTick, 0
mov cutsceneIndex, 0
mov isCutsceneActive, 1
mov eax, 1
ret
cs_start_empty:
xor eax, eax
ret
StartCutscene ENDP
UpdateCutscene PROC uses ebx ecx edx esi edi
LOCAL transitionFade:DWORD
cmp isCutsceneActive, 0
je cs_update_done
cmp csMoveDuration, 0
je cs_step_angle
inc csMoveElapsed
mov eax, csMoveToX
sub eax, csMoveFromX
imul csMoveElapsed
idiv csMoveDuration
add eax, csMoveFromX
mov playerX, eax
mov eax, csMoveToY
sub eax, csMoveFromY
imul csMoveElapsed
idiv csMoveDuration
add eax, csMoveFromY
mov playerY, eax
mov eax, csMoveElapsed
cmp eax, csMoveDuration
jb cs_step_angle
mov csMoveDuration, 0
cs_step_angle:
cmp csAngleDuration, 0
je cs_step_fade
inc csAngleElapsed
mov eax, csAngleDelta
imul csAngleElapsed
idiv csAngleDuration
add eax, csAngleFrom
and eax, ANGLE_MASK
mov playerAngle, eax
mov eax, csAngleElapsed
cmp eax, csAngleDuration
jb cs_step_fade
mov csAngleDuration, 0
cs_step_fade:
cmp csFadeDuration, 0
je cs_subtitle_expiry
inc csFadeElapsed
mov eax, csFadeTo
sub eax, csFadeFrom
imul csFadeElapsed
idiv csFadeDuration
add eax, csFadeFrom
mov cutsceneFade, eax
mov eax, csFadeElapsed
cmp eax, csFadeDuration
jb cs_subtitle_expiry
mov csFadeDuration, 0
cs_subtitle_expiry:
cmp cutsceneSubtitleUntil, 0
je cs_step_wait
mov eax, cutsceneTick
cmp eax, cutsceneSubtitleUntil
jb cs_step_wait
mov cutsceneSubtitle, -1
mov cutsceneSubtitleUntil, 0
cs_step_wait:
cmp cutsceneWait, 0
je cs_dispatch
dec cutsceneWait
jnz cs_advance_tick
cs_dispatch:
mov eax, cutsceneIndex
cmp eax, cutsceneCount
jae cs_advance_tick
shl eax, 4
add eax, OFFSET cutsceneEvents
mov esi, eax
movzx eax, [esi].CutsceneEvent.tickTimestamp
cmp eax, cutsceneTick
ja cs_advance_tick
inc cutsceneIndex
movzx eax, [esi].CutsceneEvent.opCode
cmp eax, OP_END
je cs_end
cmp eax, OP_WAIT
je cs_wait
cmp eax, OP_MOVE_CAM_TO
je cs_move
cmp eax, OP_PAN_CAM_TO
je cs_pan
cmp eax, OP_SHOW_SUBTITLE
je cs_text
cmp eax, OP_HIDE_SUBTITLE
je cs_hide_text
cmp eax, OP_PLAY_SOUND
je cs_play_sound
cmp eax, OP_FADE_SCREEN
je cs_fade
cmp eax, OP_CHANGE_SUBLEVEL
je cs_change_level
jmp cs_end
cs_wait:
mov eax, [esi].CutsceneEvent.paramA
mov cutsceneWait, eax
test eax, eax
jnz cs_advance_tick
jmp cs_dispatch
cs_move:
mov eax, playerX
mov csMoveFromX, eax
mov eax, playerY
mov csMoveFromY, eax
mov eax, [esi].CutsceneEvent.paramA
mov csMoveToX, eax
mov eax, [esi].CutsceneEvent.paramB
mov csMoveToY, eax
mov eax, [esi].CutsceneEvent.dataId
mov csMoveDuration, eax
mov csMoveElapsed, 0
test eax, eax
jnz cs_dispatch
mov eax, csMoveToX
mov playerX, eax
mov eax, csMoveToY
mov playerY, eax
jmp cs_dispatch
cs_pan:
mov eax, playerAngle
mov csAngleFrom, eax
mov ebx, [esi].CutsceneEvent.paramA
sub ebx, eax
add ebx, 256
and ebx, ANGLE_MASK
sub ebx, 256
mov csAngleDelta, ebx
mov csAngleElapsed, 0
mov eax, [esi].CutsceneEvent.paramB
mov csAngleDuration, eax
test eax, eax
jnz cs_dispatch
mov eax, [esi].CutsceneEvent.paramA
mov playerAngle, eax
jmp cs_dispatch
cs_text:
mov eax, [esi].CutsceneEvent.paramA
mov cutsceneSubtitle, eax
mov eax, [esi].CutsceneEvent.paramB
test eax, eax
jz cs_text_expiry_ready
add eax, cutsceneTick
cs_text_expiry_ready:
mov cutsceneSubtitleUntil, eax
jmp cs_dispatch
cs_hide_text:
mov cutsceneSubtitle, -1
mov cutsceneSubtitleUntil, 0
jmp cs_dispatch
cs_play_sound:
mov eax, [esi].CutsceneEvent.paramA
cmp eax, -1
jne cs_sound_start
invoke PlaySoundA, 0, 0, 0
jmp cs_dispatch
cs_sound_start:
imul eax, CUTSCENE_STRING_BYTES
add eax, OFFSET cutsceneStrings
push esi
mov esi, OFFSET csSoundPrefix
mov edi, OFFSET csSoundPath
cs_copy_sound_prefix:
lodsb
stosb
test al, al
jnz cs_copy_sound_prefix
dec edi
pop esi
mov eax, [esi].CutsceneEvent.paramA
imul eax, CUTSCENE_STRING_BYTES
add eax, OFFSET cutsceneStrings
mov ebx, esi
mov esi, eax
cs_copy_sound_name:
lodsb
stosb
test al, al
jnz cs_copy_sound_name
movzx eax, [ebx].CutsceneEvent.param8
shl eax, 3
or eax, 00020003h ; SND_FILENAME | SND_ASYNC | SND_NODEFAULT (+ SND_LOOP)
invoke PlaySoundA, OFFSET csSoundPath, 0, eax
jmp cs_dispatch
cs_fade:
mov eax, cutsceneFade
mov csFadeFrom, eax
mov eax, [esi].CutsceneEvent.paramA
mov csFadeTo, eax
mov eax, [esi].CutsceneEvent.paramB
mov csFadeDuration, eax
mov csFadeElapsed, 0
test eax, eax
jnz cs_dispatch
mov eax, csFadeTo
mov cutsceneFade, eax
jmp cs_dispatch
cs_change_level:
mov eax, cutsceneFade
mov transitionFade, eax
mov ebx, [esi].CutsceneEvent.paramA
imul ebx, sizeof LevelTransition
add ebx, OFFSET transitionList
call StopCutscene
movzx eax, [ebx].LevelTransition.targetSpawnX
movzx ecx, [ebx].LevelTransition.targetSpawnY
movzx edx, [ebx].LevelTransition.targetAngle
movzx esi, [ebx].LevelTransition.fadeEffect
lea edi, [ebx].LevelTransition.targetMap
invoke SwitchSublevel, edi, eax, ecx, edx, esi
cmp fadeState, 1
jne cs_transition_ready
mov eax, transitionFade
cmp eax, fadeLevel
jbe cs_transition_ready
mov fadeLevel, eax ; continue from the cinematic fade without flashing bright
cs_transition_ready:
ret
cs_end:
call StopCutscene
ret
cs_advance_tick:
inc cutsceneTick
cs_update_done:
ret
UpdateCutscene ENDP
; Letterbox (320 / 2.35 -> 136-pixel picture) and up to three wrapped lines.
RenderCutsceneOverlay PROC uses ebx ecx edx esi edi
LOCAL row:DWORD, count:DWORD, pixelY:DWORD
invoke DrawRect, 0, 0, SCREEN_W, 52, COLOR_BLACK
invoke DrawRect, 0, 188, SCREEN_W, 52, COLOR_BLACK
invoke DrawString8x8, 232, 10, OFFSET strSkipScene, COLOR_GRAY
mov eax, cutsceneSubtitle
cmp eax, -1
je cs_overlay_done
cmp eax, cutsceneStringCount
jae cs_overlay_done
imul eax, CUTSCENE_STRING_BYTES
add eax, OFFSET cutsceneStrings
mov esi, eax
; Semitransparent subtitle panel over the bottom of the picture.
mov edi, OFFSET screenBuffer + 158*SCREEN_PITCH
mov ecx, 30*SCREEN_W
cs_subtitle_dim:
mov eax, [edi]
and eax, 00FEFEFEh
shr eax, 1
stosd
loop cs_subtitle_dim
mov row, 0
cs_subtitle_line:
mov edi, OFFSET csLineBuffer
mov count, 0
cs_subtitle_char:
lodsb
test al, al
jz cs_subtitle_string_end
cmp al, 10
je cs_subtitle_emit
stosb
inc count
cmp count, 38
jb cs_subtitle_char
jmp cs_subtitle_emit
cs_subtitle_string_end:
dec esi
cs_subtitle_emit:
mov byte ptr [edi], 0
mov eax, count
shl eax, 2
mov ebx, SCREEN_HALF_W
sub ebx, eax
mov eax, row
imul eax, 10
add eax, 160
invoke DrawString8x8, ebx, eax, OFFSET csLineBuffer, COLOR_WHITE
cmp byte ptr [esi], 0
je cs_overlay_done
inc row
cmp row, 3
jb cs_subtitle_line
cs_overlay_done:
ret
RenderCutsceneOverlay ENDP
ApplyCutsceneFade PROC uses ebx ecx edx esi edi
mov ebx, 16
sub ebx, cutsceneFade
cmp ebx, 16
jae cs_fade_done
mov edi, OFFSET screenBuffer
mov ecx, SCREEN_W*SCREEN_H
cs_fade_pixel:
mov eax, [edi]
movzx edx, al
imul edx, ebx
shr edx, 4
movzx eax, ah
imul eax, ebx
shr eax, 4
shl eax, 8
or edx, eax
mov eax, [edi]
shr eax, 16
imul eax, ebx
shr eax, 4
shl eax, 16
or eax, edx
stosd
loop cs_fade_pixel
cs_fade_done:
ret
ApplyCutsceneFade ENDP

View file

@ -1,3 +1,8 @@
COMM screenBuffer:BYTE:FRAME_BUFFER_SIZE
COMM textureBuffer:BYTE:MAX_TEXTURES * TEX_SIZE_BYTES
COMM portalTextureBuffer:BYTE:TEX_SIZE_BYTES
COMM texturePackStaging:BYTE:MAX_TEXTURES * TEX_SIZE_BYTES
; ==============================================================================
; EBLAN ENGINE - Global State and Data Structures
; ==============================================================================
@ -47,11 +52,34 @@ keyAction db 0
keyRun db 0
keyShowMap db 1 ; minimap toggle
keyScreenshot db 0 ; F12 screenshot request
keyActionHeld db 0
keyMapHeld db 0
keyShotHeld db 0
keyCutscene db 0
keySkipScene db 0
keySceneHeld db 0
keySkipHeld db 0
; Map palette (overridable by map.dat header)
mapCeilingColor dd COLOR_CEILING
mapFloorColor dd COLOR_FLOOR
szMapFilePath db "assets\map.dat",0
szAssetsPrefix db "assets\",0
startupMapPath db MAX_PATH_BYTES dup(0)
portalLatchCell dd -1 ; suppress repeated entry until the player leaves the cell
mapErrorFrames dd 0
; Sector Streaming & Portals
transitionCount dd 0
pendingMapPath db 64 dup(0)
szSublevelFullPath db 80 dup(0)
pendingSpawnX dd 0
pendingSpawnY dd 0
pendingSpawnAngle dd 0
; Screen Fade Transition State Machine
fadeState dd 0 ; 0 = None, 1 = Fading Out, 2 = Black / Switching, 3 = Fading In
fadeLevel dd 0 ; 0..16 (0 = visible, 16 = black)
; Texture pack (optional; falls back to procedural textures)
szTexPackPath db "assets\textures\texturepack.bin",0
@ -63,9 +91,40 @@ spriteCount dd 0
; Engine HUD & Strings
strControls1 db "WASD: Move / Strafe | Shift: Run",0
strControls2 db "Arrows: Turn Camera",0
strControls3 db "E: Door | M: Minimap | F12: Shot | Esc: Exit",0
strControls3 db "E:Use C:Scene M:Map F12:Shot Esc:Exit",0
strEngineName db "EBLAN ENGINE 3D (x86 MASM)",0
strDoorHint db "[E] - Door",0
strPortalHint db "[E] - Enter Portal",0
strMapLoadError db "Cannot load destination level",0
strSkipScene db "[Tab] Skip",0
cutsceneCount dd 0
cutsceneStringCount dd 0
cutsceneFlags dd 0
isCutsceneActive dd 0
cutscenePending dd 0
cutsceneTick dd 0
cutsceneIndex dd 0
cutsceneWait dd 0
cutsceneSubtitle dd -1
cutsceneSubtitleUntil dd 0
cutsceneFade dd 0
csMoveFromX dd 0
csMoveFromY dd 0
csMoveToX dd 0
csMoveToY dd 0
csMoveDuration dd 0
csMoveElapsed dd 0
csAngleFrom dd 0
csAngleDelta dd 0
csAngleDuration dd 0
csAngleElapsed dd 0
csFadeFrom dd 0
csFadeTo dd 0
csFadeDuration dd 0
csFadeElapsed dd 0
csSoundPrefix db "assets\sounds\",0
csSoundPath db 128 dup(0)
csLineBuffer db 40 dup(0)
; Screenshot (F12): writes 32-bit top-down BMP of screenBuffer
szScreenshotPath db "screenshot.bmp",0
@ -88,22 +147,50 @@ screenshotHeader db 'B','M'
; ------------------------------------------------------------------------------
.data?
ALIGN 4
screenBuffer db FRAME_BUFFER_SIZE dup(?)
; (moved to COMM)
ALIGN 4
depthBuffer dd SCREEN_W dup(?)
; Glass (Transparent Wall) per-column buffers
ALIGN 4
glassDistBuffer dd SCREEN_W dup(?)
glassDrawStartBuf dd SCREEN_W dup(?)
glassDrawEndBuf dd SCREEN_W dup(?)
glassLineHeightBuf dd SCREEN_W dup(?)
glassTexXBuf dd SCREEN_W dup(?)
glassSideHitBuf dd SCREEN_W dup(?)
; Fixed-point Math Lookup Tables (512 angles, 16.16 signed fixed point)
ALIGN 4
tableSin dd ANGLE_512 dup(?)
tableCos dd ANGLE_512 dup(?)
ALIGN 4
tableRayAngleOffset dd SCREEN_W dup(?)
ALIGN 4
tableRayCos dd SCREEN_W dup(?)
; Game Map Grid (32x32 = 1024 bytes)
gameMap db MAP_SIZE dup(?)
; Texture Memory (16 slots of 128x128 32-bit BGRA = 1 MB)
ALIGN 4
textureBuffer db MAX_TEXTURES * TEX_SIZE_BYTES dup(?)
; (moved to COMM)
; (moved to COMM)
; (moved to COMM)
ALIGN 4
spriteList db MAX_SPRITES * (sizeof SpriteStruct) dup(?)
spriteOrder dd MAX_SPRITES dup(?) ; sprite indices sorted far-to-near
ALIGN 4
transitionList db MAX_TRANSITIONS * (sizeof LevelTransition) dup(?)
ALIGN 4
cutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?)
cutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?)
stagedCutsceneEvents db MAX_CUTSCENE_EVENTS * (sizeof CutsceneEvent) dup(?)
stagedCutsceneStrings db MAX_CUTSCENE_STRINGS * CUTSCENE_STRING_BYTES dup(?)

View file

@ -62,6 +62,8 @@ VK_KEY_UP equ 26h
VK_KEY_RIGHT equ 27h
VK_KEY_DOWN equ 28h
VK_KEY_F12 equ 7Bh
VK_KEY_C equ 43h
VK_KEY_TAB equ 9
; Colors (32-bit 00RRGGBBh; in memory a DIB stores bytes B,G,R,0 little-endian)
COLOR_BLACK equ 00000000h
@ -89,6 +91,24 @@ CELL_DOOR_OPEN equ 6
CELL_PILLAR equ 7
CELL_GLASS equ 8
CELL_MARBLE equ 9
CELL_PORTAL equ 10 ; Sector transition portal
CELL_COUNT equ 11
; Sector Transition Portals
MAX_TRANSITIONS equ 16
MAX_PATH_BYTES equ 260
MAP_FORMAT_VERSION equ 4
LevelTransition STRUCT ; sizeof = 42 bytes (packed)
triggerCellX db 0 ; 1 byte
triggerCellY db 0 ; 1 byte
targetMap db 32 dup(0); 32 bytes ASCII
targetSpawnX dw 0 ; 2 bytes
targetSpawnY dw 0 ; 2 bytes
targetAngle dw 0 ; 2 bytes
fadeEffect db 0 ; 1 byte (0=instant, 1=fade to black)
reserved db 0 ; 1 byte padding
LevelTransition ENDS
; Sprite Types & Struct
MAX_SPRITES equ 32
@ -118,7 +138,7 @@ MAP_MAGIC equ 4E4C4245h ; 'EBLN'
MapHeader STRUCT ; sizeof = 38 bytes (packed)
magic dd 0 ; 'EBLN'
version dw 0 ; 1 = sprite coords 16.16 fixed, 2 = raw world units
version dw 0 ; 1 = fixed coords, 2/3 = transitions, 4 = cutscenes
mapWidth dw 0 ; 32
mapHeight dw 0 ; 32
playerStartX dw 0 ; cell X
@ -127,5 +147,32 @@ MapHeader STRUCT ; sizeof = 38 bytes (packed)
spriteCount dw 0 ; number of sprites
ceilingColor dd 0 ; 00RRGGBBh
floorColor dd 0 ; 00RRGGBBh
reserved db 12 dup(0)
transitionCount dw 0 ; number of transitions in file
cutsceneCount dw 0
cutsceneStringCount dw 0
cutsceneFlags dw 0
reserved db 4 dup(0)
MapHeader ENDS
MAX_CUTSCENE_EVENTS equ 128
MAX_CUTSCENE_STRINGS equ 32
CUTSCENE_STRING_BYTES equ 96
CUTSCENE_MAX_DURATION equ 3600
CUTSCENE_AUTOPLAY equ 1
OP_END equ 0
OP_WAIT equ 1
OP_MOVE_CAM_TO equ 2
OP_PAN_CAM_TO equ 3
OP_SHOW_SUBTITLE equ 4
OP_HIDE_SUBTITLE equ 5
OP_PLAY_SOUND equ 6
OP_FADE_SCREEN equ 7
OP_CHANGE_SUBLEVEL equ 8
CutsceneEvent STRUCT
tickTimestamp dw 0
opCode db 0
param8 db 0
paramA dd 0
paramB dd 0
dataId dd 0
CutsceneEvent ENDS

View file

@ -8,6 +8,27 @@
; PollInput: Reads keyboard state and updates movement & action flags
; ------------------------------------------------------------------------------
PollInput PROC uses eax ebx
mov keyForward, 0
mov keyBackward, 0
mov keyStrafeLeft, 0
mov keyStrafeRight, 0
mov keyTurnLeft, 0
mov keyTurnRight, 0
mov keyRun, 0
mov keyAction, 0
mov keyScreenshot, 0
mov keyCutscene, 0
mov keySkipScene, 0
invoke GetForegroundWindow
cmp eax, hEngineWnd
je input_has_focus
mov keyActionHeld, 0
mov keyMapHeld, 0
mov keyShotHeld, 0
mov keySceneHeld, 0
mov keySkipHeld, 0
ret
input_has_focus:
; 1. Forward (W / Up Arrow)
invoke GetAsyncKeyState, VK_KEY_W
@ -15,7 +36,9 @@ PollInput PROC uses eax ebx
invoke GetAsyncKeyState, VK_KEY_UP
or bx, ax
test bx, 8000h
setnz keyForward
jz @F
mov keyForward, 1
@@:
; 2. Backward (S / Down Arrow)
invoke GetAsyncKeyState, VK_KEY_S
@ -23,55 +46,117 @@ PollInput PROC uses eax ebx
invoke GetAsyncKeyState, VK_KEY_DOWN
or bx, ax
test bx, 8000h
setnz keyBackward
jz @F
mov keyBackward, 1
@@:
; 3. Strafe Left (A)
invoke GetAsyncKeyState, VK_KEY_A
test ax, 8000h
setnz keyStrafeLeft
jz @F
mov keyStrafeLeft, 1
@@:
; 4. Strafe Right (D)
invoke GetAsyncKeyState, VK_KEY_D
test ax, 8000h
setnz keyStrafeRight
jz @F
mov keyStrafeRight, 1
@@:
; 5. Turn Left (Left Arrow)
invoke GetAsyncKeyState, VK_KEY_LEFT
test ax, 8000h
setnz keyTurnLeft
jz @F
mov keyTurnLeft, 1
@@:
; 6. Turn Right (Right Arrow)
invoke GetAsyncKeyState, VK_KEY_RIGHT
test ax, 8000h
setnz keyTurnRight
jz @F
mov keyTurnRight, 1
@@:
; 7. Action / Open Door (E / Space)
; Actions are emitted once per physical press (also for E + Space).
invoke GetAsyncKeyState, VK_KEY_E
mov bx, ax
invoke GetAsyncKeyState, VK_KEY_SPACE
or bx, ax
test bx, 8000h
setnz keyAction
jz action_released
cmp keyActionHeld, 0
jne action_sampled
mov keyAction, 1
mov keyActionHeld, 1
jmp action_sampled
action_released:
mov keyActionHeld, 0
action_sampled:
; 8. Run / Sprint (Shift)
invoke GetAsyncKeyState, VK_KEY_SHIFT
test ax, 8000h
setnz keyRun
jz @F
mov keyRun, 1
@@:
; 9. Toggle Minimap (M - single press trigger)
invoke GetAsyncKeyState, VK_KEY_M
test ax, 0001h ; Check if key was pressed since last call
jz @F
test ax, 8000h
jz map_key_released
cmp keyMapHeld, 0
jne map_key_sampled
xor keyShowMap, 1
@@:
; 10. Screenshot (F12 - single press trigger)
mov keyMapHeld, 1
jmp map_key_sampled
map_key_released:
mov keyMapHeld, 0
map_key_sampled:
invoke GetAsyncKeyState, VK_KEY_F12
test ax, 0001h
jz @F
test ax, 8000h
jz shot_key_released
cmp keyShotHeld, 0
jne shot_key_sampled
mov keyScreenshot, 1
@@:
mov keyShotHeld, 1
jmp shot_key_sampled
shot_key_released:
mov keyShotHeld, 0
shot_key_sampled:
invoke GetAsyncKeyState, VK_KEY_C
test ax, 8000h
jz scene_key_released
cmp keySceneHeld, 0
jne scene_key_sampled
mov keyCutscene, 1
mov keySceneHeld, 1
jmp scene_key_sampled
scene_key_released:
mov keySceneHeld, 0
scene_key_sampled:
invoke GetAsyncKeyState, VK_KEY_TAB
test ax, 8000h
jz skip_key_released
cmp keySkipHeld, 0
jne skip_key_sampled
mov keySkipScene, 1
mov keySkipHeld, 1
jmp skip_key_sampled
skip_key_released:
mov keySkipHeld, 0
skip_key_sampled:
cmp isCutsceneActive, 0
je normal_keys_enabled
mov keyForward, 0
mov keyBackward, 0
mov keyStrafeLeft, 0
mov keyStrafeRight, 0
mov keyTurnLeft, 0
mov keyTurnRight, 0
mov keyRun, 0
mov keyAction, 0
normal_keys_enabled:
; 11. Escape - Exit application
invoke GetAsyncKeyState, VK_KEY_ESCAPE
test ax, 8000h

View file

@ -4,6 +4,15 @@
.code
; Forward prototypes for map & sublevel transition procedures
SwitchSublevel PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
LoadSublevelBinary PROTO :DWORD, :DWORD, :DWORD, :DWORD
DoSublevelTransition PROTO
ApplyScreenFade PROTO
ValidateMapFilename PROTO :DWORD
ValidateStagedCutscene PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
CheckPortalTriggers PROTO
; ------------------------------------------------------------------------------
; InitMap: Creates a rich test level with rooms, hallways, pillars & doors
; ------------------------------------------------------------------------------
@ -80,8 +89,16 @@ loop_wall2:
mov playerX, 160
mov playerY, 160
mov playerAngle, 0
call StopCutscene
mov cutsceneCount, 0
mov cutsceneStringCount, 0
mov cutsceneFlags, 0
mov cutscenePending, 0
mov spriteCount, 0
mov transitionCount, 0
mov portalLatchCell, -1
mov mapCeilingColor, COLOR_CEILING
mov mapFloorColor, COLOR_FLOOR
ret
InitMap ENDP
@ -145,7 +162,7 @@ IsSolidCell PROC cellType:DWORD
cmp eax, CELL_DOOR_OPEN
je not_solid
mov eax, 1 ; Solid wall or closed door
mov eax, 1 ; Solid wall, closed door, or portal wall
ret
not_solid:
@ -154,9 +171,9 @@ not_solid:
IsSolidCell ENDP
; ------------------------------------------------------------------------------
; InteractDoor: Checks if player is facing a door and toggles open/close
; InteractDoor: Checks if player is facing a door or portal and handles interaction
; ------------------------------------------------------------------------------
InteractDoor PROC uses ebx ecx edx
InteractDoor PROC uses ebx ecx edx esi edi
LOCAL targetX:DWORD, targetY:DWORD
; Compute point 60 units in front of player
@ -189,17 +206,49 @@ InteractDoor PROC uses ebx ecx edx
check_door_close:
cmp eax, CELL_DOOR_OPEN
jne no_door
jne check_portal_interact
; Close the door!
invoke SetMapCell, targetX, targetY, CELL_DOOR
ret
check_portal_interact:
; Check if facing a transition trigger or CELL_PORTAL
cmp transitionCount, 0
je no_door
mov esi, OFFSET transitionList
mov edx, 0
portal_find_loop:
cmp edx, transitionCount
jge no_door
movzx eax, [esi].LevelTransition.triggerCellX
cmp eax, targetX
jne portal_find_next
movzx eax, [esi].LevelTransition.triggerCellY
cmp eax, targetY
jne portal_find_next
; Found transition!
movzx eax, [esi].LevelTransition.targetSpawnX
movzx ebx, [esi].LevelTransition.targetSpawnY
movzx ecx, [esi].LevelTransition.targetAngle
movzx edx, [esi].LevelTransition.fadeEffect
lea edi, [esi].LevelTransition.targetMap
invoke SwitchSublevel, edi, eax, ebx, ecx, edx
ret
portal_find_next:
add esi, sizeof LevelTransition
inc edx
jmp portal_find_loop
no_door:
ret
InteractDoor ENDP
; ------------------------------------------------------------------------------
; CheckDoorHint: Returns eax=1 if player is facing a door (for HUD hint)
; CheckDoorHint: Returns 1 for Door, 2 for Portal, 0 for none (for HUD hint)
; ------------------------------------------------------------------------------
CheckDoorHint PROC uses ebx ecx edx
LOCAL targetX:DWORD, targetY:DWORD
@ -224,144 +273,697 @@ CheckDoorHint PROC uses ebx ecx edx
invoke GetMapCell, targetX, targetY
cmp eax, CELL_DOOR
je hint_yes
je hint_door
cmp eax, CELL_DOOR_OPEN
je hint_yes
je hint_door
cmp eax, CELL_PORTAL
je hint_portal
; Check if target is a trigger in transitionList
cmp transitionCount, 0
je hint_none
mov edx, 0
mov eax, OFFSET transitionList
hint_check_loop:
cmp edx, transitionCount
jge hint_none
movzx ebx, [eax].LevelTransition.triggerCellX
cmp ebx, targetX
jne hint_check_next
movzx ebx, [eax].LevelTransition.triggerCellY
cmp ebx, targetY
je hint_portal
hint_check_next:
add eax, sizeof LevelTransition
inc edx
jmp hint_check_loop
hint_none:
xor eax, eax
ret
hint_yes:
hint_door:
mov eax, 1
ret
hint_portal:
mov eax, 2
ret
CheckDoorHint ENDP
; ------------------------------------------------------------------------------
; LoadMapBinary: Checks if assets/map.dat exists and loads custom map,
; player spawn position, angle, ceiling/floor colors, and sprites.
; Returns: eax = 1 if custom map loaded, 0 if fallback to procedural map
; CheckPortalTriggers: Checks if player stepped onto/into a transition trigger cell
; ------------------------------------------------------------------------------
LoadMapBinary PROC uses ebx ecx edx esi edi
LOCAL hFile:DWORD
LOCAL bytesRead:DWORD
LOCAL header:MapHeader
LOCAL count:DWORD
CheckPortalTriggers PROC uses ebx ecx edx esi edi
cmp fadeState, 0
jne cpt_done
cmp transitionCount, 0
je cpt_done
; Try to open assets/map.dat
invoke CreateFileA, OFFSET szMapFilePath, GENERIC_READ, FILE_SHARE_READ,\
0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
; Current player cell X and Y
mov eax, playerX
sar eax, 6
mov ebx, eax
mov eax, playerY
sar eax, 6
mov ecx, eax
shl eax, 5
add eax, ebx
cmp eax, portalLatchCell
je cpt_done
mov portalLatchCell, eax
mov esi, OFFSET transitionList
mov edx, 0
cpt_loop:
cmp edx, transitionCount
jge cpt_done
movzx eax, [esi].LevelTransition.triggerCellX
cmp eax, ebx
jne cpt_next
movzx eax, [esi].LevelTransition.triggerCellY
cmp eax, ecx
jne cpt_next
; Walked into trigger cell! Initiate transition
movzx eax, [esi].LevelTransition.targetSpawnX
movzx ebx, [esi].LevelTransition.targetSpawnY
movzx ecx, [esi].LevelTransition.targetAngle
movzx edx, [esi].LevelTransition.fadeEffect
lea edi, [esi].LevelTransition.targetMap
invoke SwitchSublevel, edi, eax, ebx, ecx, edx
ret
cpt_next:
add esi, sizeof LevelTransition
inc edx
jmp cpt_loop
cpt_done:
ret
CheckPortalTriggers ENDP
; ------------------------------------------------------------------------------
; SwitchSublevel: Triggers transition sequence to target sublevel file
; ------------------------------------------------------------------------------
SwitchSublevel PROC uses esi edi, pszNextMap:DWORD, spawnX:DWORD, spawnY:DWORD, spawnAngle:DWORD, fadeEffect:DWORD
cmp fadeState, 0
jne switch_busy
mov esi, pszNextMap
lea edi, pendingMapPath
mov ecx, 32
copy_next_map:
lodsb
stosb
test al, al
jz next_map_copied
dec ecx
jnz copy_next_map
mov byte ptr [edi-1], 0
next_map_copied:
mov eax, spawnX
mov pendingSpawnX, eax
mov eax, spawnY
mov pendingSpawnY, eax
mov eax, spawnAngle
mov pendingSpawnAngle, eax
cmp fadeEffect, 0
je switch_instant
; Initiate smooth Fade Out (takes 16 frames ~ 0.26s)
mov fadeState, 1
mov fadeLevel, 1
ret
switch_instant:
call DoSublevelTransition
switch_busy:
ret
SwitchSublevel ENDP
; ------------------------------------------------------------------------------
; DoSublevelTransition: Unloads old sector and loads new sector
; ------------------------------------------------------------------------------
DoSublevelTransition PROC uses ebx ecx edx
; The loader replaces the room only after validating every staged value.
invoke LoadSublevelBinary, OFFSET pendingMapPath, pendingSpawnX, pendingSpawnY, pendingSpawnAngle
test eax, eax
jnz transition_loaded
mov mapErrorFrames, 180
transition_loaded:
ret
DoSublevelTransition ENDP
; ------------------------------------------------------------------------------
; ApplyScreenFade: Screen darkening post-processing and transition sequencer
; ------------------------------------------------------------------------------
ApplyScreenFade PROC uses ebx ecx edx esi edi
cmp fadeState, 0
je fade_done
cmp fadeState, 1
je do_fade_out
cmp fadeState, 2
je do_switch_state
cmp fadeState, 3
je do_fade_in
jmp fade_done
do_fade_out:
inc fadeLevel
cmp fadeLevel, 16
jl apply_darkening
mov fadeLevel, 16
mov fadeState, 2
call DoSublevelTransition
mov fadeState, 3
jmp apply_darkening
do_switch_state:
call DoSublevelTransition
mov fadeState, 3
jmp apply_darkening
do_fade_in:
dec fadeLevel
cmp fadeLevel, 0
jg apply_darkening
mov fadeLevel, 0
mov fadeState, 0
jmp fade_done
apply_darkening:
cmp fadeLevel, 0
jle fade_done
cmp fadeLevel, 16
jl fade_math
; Fully black screen
mov edi, OFFSET screenBuffer
mov ecx, SCREEN_W * SCREEN_H
xor eax, eax
rep stosd
jmp fade_done
fade_math:
mov ebx, 16
sub ebx, fadeLevel ; scale: 1..15
mov esi, OFFSET screenBuffer
mov edi, esi
mov ecx, SCREEN_W * SCREEN_H
fade_pixel_loop:
mov eax, [esi]
; Blue
movzx edx, al
imul edx, ebx
shr edx, 4
; Green
movzx eax, ah
imul eax, ebx
shr eax, 4
shl eax, 8
or edx, eax
; Red
mov eax, [esi]
shr eax, 16
movzx eax, al
imul eax, ebx
shr eax, 4
shl eax, 16
or eax, edx
mov [edi], eax
add esi, 4
add edi, 4
dec ecx
jnz fade_pixel_loop
fade_done:
ret
ApplyScreenFade ENDP
; ------------------------------------------------------------------------------
; LoadSublevelBinary: Parameterized level loader for any .dat file
; Overrides player spawn if spawnX != -1, populates gameMap, sprites, transitions.
; Returns: eax = 1 on success, 0 on fallback
; ------------------------------------------------------------------------------
LoadSublevelBinary PROC uses ebx ecx edx esi edi, pszPath:DWORD, spawnX:DWORD, spawnY:DWORD, spawnAngle:DWORD
LOCAL hFile:DWORD, bytesRead:DWORD, header:MapHeader
LOCAL numSprites:DWORD, numTransitions:DWORD, index:DWORD
LOCAL numEvents:DWORD, numStrings:DWORD, sceneFlags:DWORD
LOCAL resolvedPath[MAX_PATH_BYTES]:BYTE
LOCAL stagedMap[MAP_SIZE]:BYTE
LOCAL stagedSprites[MAX_SPRITES * (sizeof SpriteStruct)]:BYTE
LOCAL stagedTransitions[MAX_TRANSITIONS * (sizeof LevelTransition)]:BYTE
LOCAL newX:DWORD, newY:DWORD, newAngle:DWORD
; Relative map names live in assets. Accept an actual assets prefix,
; drive-qualified paths and absolute paths, never just the first letter.
mov esi, pszPath
test esi, esi
jz load_default_path
cmp byte ptr [esi], 0
jne resolve_map_path
load_default_path:
mov esi, OFFSET szMapFilePath
resolve_map_path:
lea edi, resolvedPath
mov ecx, MAX_PATH_BYTES - 1
cmp byte ptr [esi], 0
je load_failed
cmp byte ptr [esi+1], ':'
je copy_map_path
cmp byte ptr [esi], 92
je copy_map_path
cmp byte ptr [esi], '/'
je copy_map_path
push esi
push ecx
mov ebx, OFFSET szAssetsPrefix
mov ecx, 6
check_assets_prefix:
mov al, [esi]
or al, 20h
cmp al, [ebx]
jne prefix_not_assets
inc esi
inc ebx
loop check_assets_prefix
cmp byte ptr [esi], 92
je prefix_is_assets
cmp byte ptr [esi], '/'
je prefix_is_assets
prefix_not_assets:
pop ecx
pop esi
mov ebx, OFFSET szAssetsPrefix
copy_assets_prefix:
mov al, [ebx]
test al, al
jz copy_map_path
stosb
inc ebx
dec ecx
jmp copy_assets_prefix
prefix_is_assets:
pop ecx
pop esi
copy_map_path:
lodsb
stosb
test al, al
jz open_level
dec ecx
jnz copy_map_path
jmp load_failed
open_level:
invoke CreateFileA, ADDR resolvedPath, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
mov hFile, eax
cmp hFile, INVALID_HANDLE_VALUE
je load_fallback
; Read MapHeader (38 bytes, packed)
cmp eax, INVALID_HANDLE_VALUE
je load_failed
invoke ReadFile, hFile, ADDR header, sizeof MapHeader, ADDR bytesRead, 0
test eax, eax
jz close_and_fallback
jz close_load_failed
cmp bytesRead, sizeof MapHeader
jl close_and_fallback
; Verify Magic 'EBLN'
jne close_load_failed
cmp header.magic, MAP_MAGIC
jne close_and_fallback
; Validate version (1 = legacy 16.16 sprite coords, 2 = raw units)
movzx eax, header.version
cmp eax, 1
jl close_and_fallback
cmp eax, 2
jg close_and_fallback
; Validate grid dimensions (engine is fixed at 32x32)
jne close_load_failed
cmp header.version, 1
jb close_load_failed
cmp header.version, MAP_FORMAT_VERSION
ja close_load_failed
cmp header.mapWidth, MAP_W
jne close_and_fallback
jne close_load_failed
cmp header.mapHeight, MAP_H
jne close_and_fallback
; Apply Player Spawn Position (in cells -> fixed-point world units)
movzx eax, header.playerStartX
shl eax, 6 ; * 64
add eax, 32 ; center in cell
mov playerX, eax
movzx eax, header.playerStartY
shl eax, 6 ; * 64
add eax, 32
mov playerY, eax
; Apply Player Start Angle (0..511)
movzx eax, header.playerStartAngle
and eax, ANGLE_MASK
mov playerAngle, eax
; Apply Custom Ceiling and Floor Colors if specified
cmp header.ceilingColor, 0
je @F
mov eax, header.ceilingColor
mov mapCeilingColor, eax
@@:
cmp header.floorColor, 0
je @F
mov eax, header.floorColor
mov mapFloorColor, eax
@@:
; Read 1024 bytes of gameMap
invoke ReadFile, hFile, OFFSET gameMap, MAP_SIZE, ADDR bytesRead, 0
test eax, eax
jz close_and_fallback
cmp bytesRead, MAP_SIZE
jne close_and_fallback
; Read Sprites
jne close_load_failed
cmp header.playerStartX, MAP_W
jae close_load_failed
cmp header.playerStartY, MAP_H
jae close_load_failed
cmp header.playerStartAngle, ANGLE_MASK
ja close_load_failed
movzx eax, header.spriteCount
cmp eax, MAX_SPRITES
jle @F
mov eax, MAX_SPRITES
@@:
mov count, eax
mov spriteCount, eax
test eax, eax
jz finish_load
imul eax, sizeof SpriteStruct
mov ecx, eax
invoke ReadFile, hFile, OFFSET spriteList, ecx, ADDR bytesRead, 0
test eax, eax
jz close_and_fallback
mov eax, count
imul eax, sizeof SpriteStruct
cmp bytesRead, eax
jne close_and_fallback
; Format v1 stored sprite x/y as 16.16 fixed point -> convert to raw units
ja close_load_failed
mov numSprites, eax
xor eax, eax
cmp header.version, 1
jne finish_load
mov ecx, 0
convert_v1_sprites:
cmp ecx, count
jge finish_load
mov eax, ecx
je old_map_no_transitions
; Accept the extended v2 files written by early EblanStudio builds.
movzx eax, header.transitionCount
old_map_no_transitions:
cmp eax, MAX_TRANSITIONS
ja close_load_failed
mov numTransitions, eax
mov numEvents, 0
mov numStrings, 0
mov sceneFlags, 0
cmp header.version, 4
jb old_map_no_cutscene
movzx eax, header.cutsceneCount
cmp eax, MAX_CUTSCENE_EVENTS
ja close_load_failed
mov numEvents, eax
movzx eax, header.cutsceneStringCount
cmp eax, MAX_CUTSCENE_STRINGS
ja close_load_failed
mov numStrings, eax
movzx eax, header.cutsceneFlags
mov sceneFlags, eax
old_map_no_cutscene:
mov eax, numTransitions
imul eax, sizeof LevelTransition
mov ebx, eax
mov eax, numSprites
imul eax, sizeof SpriteStruct
add eax, OFFSET spriteList
mov edx, [eax].SpriteStruct.x
sar edx, 16
mov [eax].SpriteStruct.x, edx
mov edx, [eax].SpriteStruct.y
sar edx, 16
mov [eax].SpriteStruct.y, edx
inc ecx
jmp convert_v1_sprites
add ebx, eax
add ebx, sizeof MapHeader + MAP_SIZE
mov eax, numEvents
imul eax, sizeof CutsceneEvent
add ebx, eax
mov eax, numStrings
imul eax, CUTSCENE_STRING_BYTES
add ebx, eax
invoke GetFileSize, hFile, 0
cmp eax, ebx
jne close_load_failed
finish_load:
invoke ReadFile, hFile, ADDR stagedMap, MAP_SIZE, ADDR bytesRead, 0
test eax, eax
jz close_load_failed
cmp bytesRead, MAP_SIZE
jne close_load_failed
mov eax, numSprites
imul eax, sizeof SpriteStruct
mov ebx, eax
invoke ReadFile, hFile, ADDR stagedSprites, ebx, ADDR bytesRead, 0
test eax, eax
jz close_load_failed
cmp bytesRead, ebx
jne close_load_failed
mov eax, numTransitions
imul eax, sizeof LevelTransition
mov ebx, eax
invoke ReadFile, hFile, ADDR stagedTransitions, ebx, ADDR bytesRead, 0
test eax, eax
jz close_load_failed
cmp bytesRead, ebx
jne close_load_failed
mov ebx, numEvents
imul ebx, sizeof CutsceneEvent
invoke ReadFile, hFile, OFFSET stagedCutsceneEvents, ebx, ADDR bytesRead, 0
test eax, eax
jz close_load_failed
cmp bytesRead, ebx
jne close_load_failed
mov ebx, numStrings
imul ebx, CUTSCENE_STRING_BYTES
invoke ReadFile, hFile, OFFSET stagedCutsceneStrings, ebx, ADDR bytesRead, 0
test eax, eax
jz close_load_failed
cmp bytesRead, ebx
jne close_load_failed
invoke ValidateStagedCutscene, numEvents, numStrings, sceneFlags, ADDR stagedMap, numTransitions
test eax, eax
jz close_load_failed
; Validate all payload data before touching the active room.
lea esi, stagedMap
mov ecx, MAP_SIZE
validate_grid:
lodsb
cmp al, 15
ja close_load_failed
loop validate_grid
lea esi, stagedSprites
mov ecx, numSprites
validate_sprite:
test ecx, ecx
jz validate_transitions
cmp header.version, 1
jne sprite_coords_raw
sar [esi].SpriteStruct.x, 16
sar [esi].SpriteStruct.y, 16
sprite_coords_raw:
cmp [esi].SpriteStruct.x, 0
jl close_load_failed
cmp [esi].SpriteStruct.x, MAP_W * CELL_SIZE
jge close_load_failed
cmp [esi].SpriteStruct.y, 0
jl close_load_failed
cmp [esi].SpriteStruct.y, MAP_H * CELL_SIZE
jge close_load_failed
cmp [esi].SpriteStruct.textureId, MAX_TEXTURES
jae close_load_failed
cmp [esi].SpriteStruct.scale, 0
jl close_load_failed
cmp [esi].SpriteStruct.scale, 16 * FIXED_ONE
ja close_load_failed
add esi, sizeof SpriteStruct
dec ecx
jmp validate_sprite
validate_transitions:
lea esi, stagedTransitions
mov index, 0
validate_transition:
mov eax, index
cmp eax, numTransitions
jae validate_portal_cells
cmp [esi].LevelTransition.triggerCellX, MAP_W
jae close_load_failed
cmp [esi].LevelTransition.triggerCellY, MAP_H
jae close_load_failed
cmp [esi].LevelTransition.targetSpawnX, MAP_W
jae close_load_failed
cmp [esi].LevelTransition.targetSpawnY, MAP_H
jae close_load_failed
cmp [esi].LevelTransition.targetAngle, ANGLE_MASK
ja close_load_failed
cmp [esi].LevelTransition.fadeEffect, 1
ja close_load_failed
push ecx
lea edi, [esi].LevelTransition.targetMap
invoke ValidateMapFilename, edi
pop ecx
test eax, eax
jz close_load_failed
movzx eax, [esi].LevelTransition.triggerCellY
shl eax, 5
movzx edx, [esi].LevelTransition.triggerCellX
add eax, edx
mov al, stagedMap[eax]
cmp al, CELL_EMPTY
je transition_grid_ok
cmp al, CELL_DOOR_OPEN
je transition_grid_ok
cmp al, CELL_PORTAL
jne close_load_failed
transition_grid_ok:
lea edi, stagedTransitions
mov ecx, index
unique_transition:
test ecx, ecx
jz transition_unique
mov ax, WORD PTR [esi]
cmp ax, WORD PTR [edi]
je close_load_failed
add edi, sizeof LevelTransition
dec ecx
jmp unique_transition
transition_unique:
add esi, sizeof LevelTransition
inc index
jmp validate_transition
validate_portal_cells:
xor ebx, ebx
portal_grid_loop:
cmp ebx, MAP_SIZE
jae validate_spawn
cmp stagedMap[ebx], CELL_PORTAL
jne next_portal_grid
lea esi, stagedTransitions
mov ecx, numTransitions
find_grid_transition:
test ecx, ecx
jz close_load_failed
movzx eax, [esi].LevelTransition.triggerCellY
shl eax, 5
movzx edx, [esi].LevelTransition.triggerCellX
add eax, edx
cmp eax, ebx
je next_portal_grid
add esi, sizeof LevelTransition
dec ecx
jmp find_grid_transition
next_portal_grid:
inc ebx
jmp portal_grid_loop
validate_spawn:
movzx eax, header.playerStartX
movzx ebx, header.playerStartY
movzx ecx, header.playerStartAngle
cmp spawnX, -1
je spawn_values_ready
mov eax, spawnX
mov ebx, spawnY
mov ecx, spawnAngle
spawn_values_ready:
cmp eax, MAP_W
jae close_load_failed
cmp ebx, MAP_H
jae close_load_failed
cmp ecx, ANGLE_MASK
ja close_load_failed
mov newX, eax
mov newY, ebx
mov newAngle, ecx
shl ebx, 5
add ebx, eax
mov al, stagedMap[ebx]
cmp al, CELL_EMPTY
je commit_level
cmp al, CELL_DOOR_OPEN
je commit_level
cmp al, CELL_PORTAL
jne close_load_failed
commit_level:
invoke CloseHandle, hFile
mov eax, 1 ; Custom map loaded!
call StopCutscene
mov esi, OFFSET stagedCutsceneEvents
mov edi, OFFSET cutsceneEvents
mov ecx, numEvents
shl ecx, 2
rep movsd
mov esi, OFFSET stagedCutsceneStrings
mov edi, OFFSET cutsceneStrings
mov ecx, numStrings
imul ecx, CUTSCENE_STRING_BYTES / 4
rep movsd
mov eax, numEvents
mov cutsceneCount, eax
mov eax, numStrings
mov cutsceneStringCount, eax
mov eax, sceneFlags
mov cutsceneFlags, eax
and eax, CUTSCENE_AUTOPLAY
mov cutscenePending, eax
lea esi, stagedMap
mov edi, OFFSET gameMap
mov ecx, MAP_SIZE / 4
rep movsd
lea esi, stagedSprites
mov edi, OFFSET spriteList
mov ecx, numSprites
imul ecx, sizeof SpriteStruct / 4
rep movsd
lea esi, stagedTransitions
mov edi, OFFSET transitionList
mov ecx, numTransitions
imul ecx, sizeof LevelTransition
rep movsb
mov eax, numSprites
mov spriteCount, eax
mov eax, numTransitions
mov transitionCount, eax
mov eax, header.ceilingColor
and eax, 00FFFFFFh
mov mapCeilingColor, eax
mov eax, header.floorColor
and eax, 00FFFFFFh
mov mapFloorColor, eax
mov eax, newY
shl eax, 5
add eax, newX
mov portalLatchCell, eax
mov eax, newX
shl eax, CELL_SHIFT
add eax, CELL_SIZE / 2
mov playerX, eax
mov eax, newY
shl eax, CELL_SHIFT
add eax, CELL_SIZE / 2
mov playerY, eax
mov eax, newAngle
mov playerAngle, eax
mov mapErrorFrames, 0
mov eax, 1
ret
close_and_fallback:
close_load_failed:
invoke CloseHandle, hFile
load_fallback:
call InitMap ; Procedural fallback level
load_failed:
xor eax, eax
ret
LoadMapBinary ENDP
LoadSublevelBinary ENDP
; Map references in EBLN are bounded ASCII basenames, never arbitrary pointers.
ValidateMapFilename PROC uses ebx ecx edx esi, pszName:DWORD
mov esi, pszName
xor ecx, ecx
filename_character:
mov al, [esi+ecx]
test al, al
jz filename_end
cmp al, 32
jb filename_invalid
cmp al, 127
jae filename_invalid
cmp al, 92
je filename_invalid
cmp al, '/'
je filename_invalid
cmp al, ':'
je filename_invalid
cmp al, '*'
je filename_invalid
cmp al, '?'
je filename_invalid
cmp al, 34
je filename_invalid
cmp al, '<'
je filename_invalid
cmp al, '>'
je filename_invalid
cmp al, '|'
je filename_invalid
inc ecx
cmp ecx, 32
jb filename_character
jmp filename_invalid
filename_end:
cmp ecx, 5
jb filename_invalid
mov eax, DWORD PTR [esi+ecx-4]
or eax, 20202000h
cmp eax, 7461642Eh ; .dat
jne filename_invalid
mov eax, 1
ret
filename_invalid:
xor eax, eax
ret
ValidateMapFilename ENDP
LoadMapBinary PROC
mov eax, OFFSET szMapFilePath
cmp startupMapPath, 0
je load_startup_map
mov eax, OFFSET startupMapPath
load_startup_map:
invoke LoadSublevelBinary, eax, -1, -1, 0
test eax, eax
jnz startup_map_ready
call InitMap
startup_map_ready:
ret
LoadMapBinary ENDP

View file

@ -3,23 +3,28 @@
; ==============================================================================
.data
fAngleDiv REAL4 256.0
fFixedScale REAL4 65536.0
fAngleDiv REAL4 256.0
fFixedScale REAL4 65536.0
fScreenHalfW REAL4 160.0
fTanHalfFov REAL4 0.66817864 ; tan(48 * PI / 256) = tan(33.75 deg)
fRadToAngle REAL4 81.48733 ; 256.0 / PI
.code
; ------------------------------------------------------------------------------
; InitMathTables: Builds 512-entry Sin & Cos tables in 16.16 fixed point
; InitMathTables: Builds 512-entry Sin & Cos tables in 16.16 fixed point,
; and 320-entry tangential ray angle offset table for flat-plane projection.
; ------------------------------------------------------------------------------
InitMathTables PROC uses eax ecx edi
LOCAL curAngle:DWORD
LOCAL curVal:DWORD
; 1. Generate 512-entry Sin/Cos tables
mov ecx, 0
init_angle_loop:
mov curAngle, ecx
mov curVal, ecx
fldpi
fimul curAngle
fimul curVal
fdiv fAngleDiv ; st(0) = (angle * PI) / 256
fsincos ; st(0) = cos, st(1) = sin
@ -36,6 +41,32 @@ init_angle_loop:
cmp ecx, ANGLE_512
jl init_angle_loop
; 2. Generate 320-entry Ray Angle Offset table (tangential perspective correction)
; and precomputed cosine table for instant fish-eye correction.
; angleOffset = round( atan(((x - 160.0) / 160.0) * tan(FOV/2)) * (256.0 / PI) )
mov ecx, 0
init_ray_loop:
mov curVal, ecx
fild curVal ; st(0) = x
fsub fScreenHalfW ; st(0) = x - 160.0
fdiv fScreenHalfW ; st(0) = (x - 160.0) / 160.0 = cameraX
fmul fTanHalfFov ; st(0) = cameraX * tan(FOV/2)
fld1 ; st(0) = 1.0, st(1) = cameraX * tan(FOV/2)
fpatan ; st(0) = atan(st(1)/st(0)) = radOffset
fmul fRadToAngle ; st(0) = radOffset * (256.0 / PI)
fistp DWORD PTR [tableRayAngleOffset + ecx*4]
; Precompute tableRayCos[ecx] = cos(angleOffset)
mov eax, DWORD PTR [tableRayAngleOffset + ecx*4]
and eax, ANGLE_MASK
mov edx, [tableCos + eax*4]
mov DWORD PTR [tableRayCos + ecx*4], edx
inc ecx
cmp ecx, SCREEN_W
jl init_ray_loop
ret
InitMathTables ENDP

View file

@ -13,6 +13,30 @@ UpdatePlayer PROC uses ebx ecx edx esi edi
LOCAL nextX:DWORD, nextY:DWORD
LOCAL checkGridX:DWORD, checkGridY:DWORD
; Freeze player movement and inputs during level transitions
cmp fadeState, 0
jne update_player_done
cmp cutscenePending, 0
je scene_manual_start
call StartCutscene
scene_manual_start:
cmp keyCutscene, 0
je scene_check_active
cmp isCutsceneActive, 0
jne scene_check_active
call StartCutscene
scene_check_active:
cmp isCutsceneActive, 0
je normal_player_update
cmp keySkipScene, 0
je scene_update_tick
call StopCutscene
ret
scene_update_tick:
call UpdateCutscene
ret
normal_player_update:
mov moveX, 0
mov moveY, 0
@ -36,6 +60,8 @@ check_action:
cmp keyAction, 0
je check_movement
call InteractDoor
cmp fadeState, 0
jne update_player_done
; 2. Determine Movement Speed
check_movement:
@ -149,9 +175,12 @@ check_x_col:
mov checkGridY, ecx
invoke GetMapCell, checkGridX, checkGridY
cmp eax, CELL_PORTAL
je allow_portal_x
invoke IsSolidCell, eax
test eax, eax
jnz skip_x_move ; Collision on X: slide along wall!
allow_portal_x:
mov eax, nextX
mov playerX, eax
@ -180,13 +209,20 @@ check_y_col:
mov checkGridX, ebx
invoke GetMapCell, checkGridX, checkGridY
cmp eax, CELL_PORTAL
je allow_portal_y
invoke IsSolidCell, eax
test eax, eax
jnz update_player_done ; Collision on Y
allow_portal_y:
mov eax, nextY
mov playerY, eax
update_player_done:
cmp fadeState, 0
jne @F
call CheckPortalTriggers
@@:
ret
UpdatePlayer ENDP

150
include/engine_preview.inc Normal file
View file

@ -0,0 +1,150 @@
; Windowless renderer used by EblanStudio. Same Render3DScene and overlays as game.
; Fixed wire layout is mirrored by editor/src/preview_protocol.h.
.data
previewPrefix db "--preview:",0
previewHandle dd 0
previewMemory dd 0
previewParent dd 0
previewTextureRevision dd -1
.code
TryEnginePreview PROC uses ebx ecx edx esi edi
mov esi, OFFSET startupMapPath
mov edi, OFFSET previewPrefix
mov ecx, 10
repe cmpsb
jne preview_not_requested
invoke OpenFileMappingA, 6, 0, esi ; FILE_MAP_READ | FILE_MAP_WRITE
test eax, eax
jz preview_exit_error
mov previewHandle, eax
invoke MapViewOfFile, eax, 6, 0, 0, 1357792
test eax, eax
jz preview_exit_error
mov previewMemory, eax
mov ebx, eax
cmp DWORD PTR [ebx], 56504245h
jne preview_exit_error
cmp DWORD PTR [ebx+4], 1
jne preview_exit_error
invoke OpenProcess, 00100000h, 0, DWORD PTR [ebx+12] ; SYNCHRONIZE
test eax, eax
jz preview_exit_error
mov previewParent, eax
call InitMathTables
call GenerateDefaultTextures
preview_wait:
invoke WaitForSingleObject, previewParent, 0
test eax, eax
jz preview_exit_ok
mov ebx, previewMemory
cmp DWORD PTR [ebx+8], 3
je preview_exit_ok
cmp DWORD PTR [ebx+8], 1
je preview_render
invoke Sleep, 1
jmp preview_wait
preview_render:
; Treat IPC as untrusted: bounds before touching engine buffers.
mov eax, [ebx+16]
cmp eax, 2048
jae preview_exit_error
mov playerX, eax
mov eax, [ebx+20]
cmp eax, 2048
jae preview_exit_error
mov playerY, eax
mov eax, [ebx+24]
and eax, 511
mov playerAngle, eax
mov eax, [ebx+28]
mov mapCeilingColor, eax
mov eax, [ebx+32]
mov mapFloorColor, eax
mov eax, [ebx+36]
cmp eax, MAX_SPRITES
ja preview_exit_error
mov spriteCount, eax
lea esi, [ebx+128]
mov edi, OFFSET gameMap
mov ecx, 1024/4
rep movsd
push esi
mov esi, OFFSET gameMap
mov ecx, MAP_SIZE
preview_cell_check:
lodsb
cmp al, 15
ja preview_exit_error
loop preview_cell_check
pop esi
mov edi, OFFSET spriteList
mov ecx, 32*sizeof SpriteStruct/4
rep movsd
; Validate every sprite, including unsaved inspector edits.
mov esi, OFFSET spriteList
mov ecx, spriteCount
preview_sprite_check:
test ecx, ecx
jz preview_textures
cmp DWORD PTR [esi].SpriteStruct.textureId, MAX_TEXTURES
jae preview_exit_error
cmp DWORD PTR [esi].SpriteStruct.x, 2048
jae preview_exit_error
cmp DWORD PTR [esi].SpriteStruct.y, 2048
jae preview_exit_error
cmp DWORD PTR [esi].SpriteStruct.scale, 1048576
ja preview_exit_error
add esi, sizeof SpriteStruct
dec ecx
jmp preview_sprite_check
preview_textures:
mov eax, [ebx+48]
cmp eax, previewTextureRevision
je preview_scene
mov previewTextureRevision, eax
lea esi, [ebx+2016]
mov edi, OFFSET textureBuffer
mov ecx, MAX_TEXTURES*TEX_SIZE_BYTES/4
rep movsd
preview_scene:
call Render3DScene
mov ebx, previewMemory
cmp DWORD PTR [ebx+40], 0
je preview_copy_frame
mov cutsceneSubtitle, -1
cmp BYTE PTR [ebx+1920], 0
je preview_overlay
lea esi, [ebx+1920]
mov edi, OFFSET cutsceneStrings
mov ecx, 96/4
rep movsd
mov BYTE PTR [cutsceneStrings+95], 0
mov cutsceneStringCount, 1
mov cutsceneSubtitle, 0
preview_overlay:
call RenderCutsceneOverlay
mov ebx, previewMemory
mov eax, [ebx+44]
cmp eax, 16
ja preview_exit_error
mov cutsceneFade, eax
call ApplyCutsceneFade
preview_copy_frame:
mov esi, OFFSET screenBuffer
mov ebx, previewMemory
lea edi, [ebx+1050592]
mov ecx, FRAME_BUFFER_SIZE/4
rep movsd
mov eax, 2
xchg eax, [ebx+8] ; Publish complete frame (full memory barrier).
jmp preview_wait
preview_exit_ok:
invoke UnmapViewOfFile, previewMemory
invoke CloseHandle, previewParent
invoke CloseHandle, previewHandle
invoke ExitProcess, 0
preview_exit_error:
invoke ExitProcess, 2
preview_not_requested:
ret
TryEnginePreview ENDP

View file

@ -4,6 +4,9 @@
.code
RenderSprites PROTO :DWORD
RenderGlass PROTO
; ------------------------------------------------------------------------------
; Render3DScene: Full frame 2.5D raycasting pass (Floor, Ceiling, Walls, Sprites)
; ------------------------------------------------------------------------------
@ -15,6 +18,8 @@ Render3DScene PROC uses ebx ecx edx esi edi
LOCAL stepX:DWORD, stepY:DWORD
LOCAL sideDistX:DWORD, sideDistY:DWORD
LOCAL deltaDistX:DWORD, deltaDistY:DWORD
LOCAL rawDist:DWORD
LOCAL perpWallDistFixed:DWORD ; 24.8 until projection
LOCAL perpWallDist:DWORD
LOCAL sideHit:DWORD ; 0 = X-wall (Vertical), 1 = Y-wall (Horizontal)
LOCAL wallType:DWORD
@ -24,6 +29,13 @@ Render3DScene PROC uses ebx ecx edx esi edi
LOCAL texStep:DWORD, texPos:DWORD
LOCAL texPtr:DWORD
LOCAL pScreenPixel:DWORD
LOCAL hasGlassHit:DWORD
LOCAL glassRawDist:DWORD
LOCAL glassPerpDistFixed:DWORD
LOCAL glassPerpDist:DWORD
LOCAL glassLineHeight:DWORD
LOCAL glassDrawStart:DWORD, glassDrawEnd:DWORD
LOCAL glassTexX:DWORD, glassSideHit:DWORD
; --------------------------------------------------------------------------
; 1. Fast Background Clear: Ceiling (Top Half) and Floor (Bottom Half)
@ -43,16 +55,15 @@ Render3DScene PROC uses ebx ecx edx esi edi
mov screenX, 0
ray_column_loop:
; Calculate Ray Angle: playerAngle - FOV_HALF + (screenX * FOV_ANGLES / 320)
mov hasGlassHit, 0
; Lookup Tangential Ray Angle Offset (flat plane perspective correction)
mov eax, screenX
imul eax, FOV_ANGLES
xor edx, edx
mov ecx, SCREEN_W
div ecx ; eax = offset from left of FOV
add eax, playerAngle
sub eax, FOV_HALF
and eax, ANGLE_MASK
mov rayAngle, eax
mov edx, [tableRayAngleOffset + eax*4]
add edx, playerAngle
and edx, ANGLE_MASK
mov rayAngle, edx
mov eax, edx
; Ray Direction (16.16 Fixed Point)
mov edx, [tableCos + eax*4]
@ -69,37 +80,47 @@ ray_column_loop:
sar eax, 6
mov mapY, eax
; Delta Dist X = abs(64 << 16 / rayDirX)
; Delta Dist X in 24.8: (64 << 24) / abs(rayDirX)
mov eax, rayDirX
test eax, eax
jnz calc_delta_x
mov eax, 1 ; avoid div by 0
mov deltaDistX, 3FFFFFFFh
jmp delta_x_ready
calc_delta_x:
cdq
xor eax, edx
sub eax, edx ; eax = abs(rayDirX)
mov ecx, eax
mov eax, (64 shl 16)
mov eax, (64 shl 24)
xor edx, edx
div ecx
mov deltaDistX, eax
delta_x_ready:
; Delta Dist Y = abs(64 << 16 / rayDirY)
; Delta Dist Y in 24.8: (64 << 24) / abs(rayDirY)
mov eax, rayDirY
test eax, eax
jnz calc_delta_y
mov eax, 1
mov deltaDistY, 3FFFFFFFh
jmp delta_y_ready
calc_delta_y:
cdq
xor eax, edx
sub eax, edx ; eax = abs(rayDirY)
mov ecx, eax
mov eax, (64 shl 16)
mov eax, (64 shl 24)
xor edx, edx
div ecx
mov deltaDistY, eax
delta_y_ready:
; Calculate Step and Initial Side Dist
cmp rayDirX, 0
jne nonzero_step_x
mov stepX, 1
mov sideDistX, 3FFFFFFFh
jmp check_step_y
nonzero_step_x:
cmp rayDirX, 0
jge ray_dir_x_pos
mov stepX, -1
@ -108,9 +129,10 @@ calc_delta_y:
shl eax, 6
mov edx, playerX
sub edx, eax
imul edx, deltaDistX
sar edx, 6
mov sideDistX, edx
mov eax, edx
imul deltaDistX
shrd eax, edx, 6
mov sideDistX, eax
jmp check_step_y
ray_dir_x_pos:
@ -120,11 +142,17 @@ ray_dir_x_pos:
inc eax
shl eax, 6
sub eax, playerX
imul eax, deltaDistX
sar eax, 6
imul deltaDistX
shrd eax, edx, 6
mov sideDistX, eax
check_step_y:
cmp rayDirY, 0
jne nonzero_step_y
mov stepY, 1
mov sideDistY, 3FFFFFFFh
jmp dda_loop
nonzero_step_y:
cmp rayDirY, 0
jge ray_dir_y_pos
mov stepY, -1
@ -132,9 +160,10 @@ check_step_y:
shl eax, 6
mov edx, playerY
sub edx, eax
imul edx, deltaDistY
sar edx, 6
mov sideDistY, edx
mov eax, edx
imul deltaDistY
shrd eax, edx, 6
mov sideDistY, eax
jmp dda_loop
ray_dir_y_pos:
@ -143,8 +172,8 @@ ray_dir_y_pos:
inc eax
shl eax, 6
sub eax, playerY
imul eax, deltaDistY
sar eax, 6
imul deltaDistY
shrd eax, edx, 6
mov sideDistY, eax
; --------------------------------------------------------------------------
@ -177,6 +206,117 @@ step_in_y:
test_hit:
invoke GetMapCell, mapX, mapY
mov wallType, eax
cmp eax, CELL_GLASS
jne not_glass_hit
; If this ray already recorded a glass wall, continue stepping through subsequent glass cells
cmp hasGlassHit, 0
jne dda_loop
; Record first glass hit:
mov hasGlassHit, 1
cmp sideHit, 0
jne glass_dist_from_y
mov eax, sideDistX
sub eax, deltaDistX
jmp glass_apply_fisheye
glass_dist_from_y:
mov eax, sideDistY
sub eax, deltaDistY
glass_apply_fisheye:
mov glassRawDist, eax
mov ecx, screenX
mov ecx, [tableRayCos + ecx*4]
imul ecx
shrd eax, edx, 16
cmp eax, 1
jg @F
mov eax, 1
@@:
mov glassPerpDistFixed, eax
add eax, 128
shr eax, 8
cmp eax, 1
jae @F
mov eax, 1
@@:
mov glassPerpDist, eax
; Glass line height & drawStart / drawEnd
mov eax, (64 * PROJ_DIST shl 8)
xor edx, edx
div glassPerpDistFixed
cmp eax, 1
jge @F
mov eax, 1
@@:
mov glassLineHeight, eax
mov eax, SCREEN_HALF_H
mov edx, glassLineHeight
shr edx, 1
sub eax, edx
cmp eax, 0
jge @F
xor eax, eax
@@:
mov glassDrawStart, eax
mov eax, SCREEN_HALF_H
mov edx, glassLineHeight
shr edx, 1
add eax, edx
cmp eax, SCREEN_H
jl @F
mov eax, SCREEN_H - 1
@@:
mov glassDrawEnd, eax
; Glass texX
cmp sideHit, 0
jne glass_tex_hit_y
mov eax, glassRawDist
mov ebx, rayDirY
imul ebx
shrd eax, edx, 8
mov ebx, playerY
shl ebx, 16
add eax, ebx
jmp glass_compute_tex_x
glass_tex_hit_y:
mov eax, glassRawDist
mov ebx, rayDirX
imul ebx
shrd eax, edx, 8
mov ebx, playerX
shl ebx, 16
add eax, ebx
glass_compute_tex_x:
shr eax, 15
and eax, TEX_MASK
cmp sideHit, 0
jne glass_check_flip_y
cmp rayDirX, 0
jle @F
mov ecx, TEX_MASK
sub ecx, eax
mov eax, ecx
jmp @F
glass_check_flip_y:
cmp rayDirY, 0
jge @F
mov ecx, TEX_MASK
sub ecx, eax
mov eax, ecx
@@:
mov glassTexX, eax
mov eax, sideHit
mov glassSideHit, eax
; Continue stepping through the glass to the back wall!
jmp dda_loop
not_glass_hit:
invoke IsSolidCell, wallType
test eax, eax
jz dda_loop
@ -194,17 +334,23 @@ dist_from_y:
sub eax, deltaDistY
apply_fisheye_fix:
; perpWallDist = dist * cos(rayAngle - playerAngle)
mov ebx, rayAngle
sub ebx, playerAngle
and ebx, ANGLE_MASK
mov ecx, [tableCos + ebx*4]
imul ecx ; edx:eax
mov rawDist, eax ; Euclidean distance in 24.8, also used for texture X
; Fast tableRayCos lookup (precomputed perpendicular cosine per screen column)
mov ecx, screenX
mov ecx, [tableRayCos + ecx*4]
imul ecx ; edx:eax = rawDist * cos(rayAngleOffset)
shrd eax, edx, 16
cmp eax, 1 ; Prevent division by zero
jg @F
mov eax, 1
@@:
mov perpWallDistFixed, eax
add eax, 128
shr eax, 8
cmp eax, 1
jae rounded_distance_ready
mov eax, 1
rounded_distance_ready:
mov perpWallDist, eax
; Store in Depth Buffer for Sprite Occlusion
@ -214,9 +360,13 @@ apply_fisheye_fix:
; --------------------------------------------------------------------------
; Projected Line Height on Screen: (64 * PROJ_DIST) / perpWallDist
; --------------------------------------------------------------------------
mov eax, (64 * PROJ_DIST)
mov eax, (64 * PROJ_DIST shl 8)
xor edx, edx
div perpWallDist
div perpWallDistFixed
cmp eax, 1
jge @F
mov eax, 1 ; Clamp to >= 1 to prevent div by zero in texStep
@@:
mov lineHeight, eax
; Calculate drawStart and drawEnd
@ -241,28 +391,33 @@ apply_fisheye_fix:
@@:
; --------------------------------------------------------------------------
; Texture Coordinate X (texX)
; Texture Coordinate X (texX) with full 128-texel sub-unit precision
; --------------------------------------------------------------------------
cmp sideHit, 0
jne tex_hit_y
; wallHit = playerY + perpWallDist * rayDirY
mov eax, perpWallDist
; wallHitFixed = (playerY << 16) + rawDist * rayDirY
mov eax, rawDist
mov ebx, rayDirY
imul ebx
shrd eax, edx, 16
add eax, playerY
shrd eax, edx, 8 ; (24.8 * 16.16) -> 16.16
mov ebx, playerY
shl ebx, 16
add eax, ebx
jmp compute_tex_x
tex_hit_y:
mov eax, perpWallDist
; wallHitFixed = (playerX << 16) + rawDist * rayDirX
mov eax, rawDist
mov ebx, rayDirX
imul ebx
shrd eax, edx, 16
add eax, playerX
shrd eax, edx, 8
mov ebx, playerX
shl ebx, 16
add eax, ebx
compute_tex_x:
and eax, 63 ; coordinate within 64-unit cell
shl eax, 1 ; scale 0..63 to 0..127 (TEX_W)
shr eax, 15
and eax, TEX_MASK ; full 0..127 range (all 128 texture columns)
mov texX, eax
; Flip texture coordinate depending on ray direction
@ -288,6 +443,10 @@ set_tex_ptr:
and eax, 15
shl eax, 16 ; eax = wallType * 65536
add eax, OFFSET textureBuffer
cmp wallType, CELL_PORTAL
jne wall_texture_ready
mov eax, OFFSET portalTextureBuffer
wall_texture_ready:
mov texPtr, eax
; Texture vertical sampling step (16.16 fixed point)
@ -369,14 +528,43 @@ write_wall_pixel:
jmp wall_pixel_loop
end_wall_stripe:
mov ecx, screenX
cmp hasGlassHit, 0
je no_glass_in_col
mov eax, glassPerpDist
mov [glassDistBuffer + ecx*4], eax
mov eax, glassDrawStart
mov [glassDrawStartBuf + ecx*4], eax
mov eax, glassDrawEnd
mov [glassDrawEndBuf + ecx*4], eax
mov eax, glassLineHeight
mov [glassLineHeightBuf + ecx*4], eax
mov eax, glassTexX
mov [glassTexXBuf + ecx*4], eax
mov eax, glassSideHit
mov [glassSideHitBuf + ecx*4], eax
jmp col_glass_saved
no_glass_in_col:
mov DWORD PTR [glassDistBuffer + ecx*4], 0
col_glass_saved:
inc screenX
cmp screenX, SCREEN_W
jl ray_column_loop
; --------------------------------------------------------------------------
; 3. Render Sprites
; 3. Render Sprites (Behind Glass & Normal Columns)
; --------------------------------------------------------------------------
call RenderSprites
invoke RenderSprites, 1
; --------------------------------------------------------------------------
; 4. Render Glass (Semi-Transparent Wall Pass)
; --------------------------------------------------------------------------
call RenderGlass
; --------------------------------------------------------------------------
; 5. Render Sprites (In Front of Glass)
; --------------------------------------------------------------------------
invoke RenderSprites, 2
ret
Render3DScene ENDP
@ -390,11 +578,11 @@ Render3DScene ENDP
; per-column depth test vs depthBuffer, chroma key
; Sprite x/y are RAW world units (1 cell = 64 units), same scale as playerX/Y.
; ------------------------------------------------------------------------------
RenderSprites PROC uses ebx ecx edx esi edi
RenderSprites PROC uses ebx ecx edx esi edi passFilter:DWORD
LOCAL i:DWORD, j:DWORD, sprIdx:DWORD
LOCAL spriteX:DWORD, spriteY:DWORD
LOCAL transX:DWORD, transY:DWORD
LOCAL sprScreenX:DWORD, sprSize:DWORD
LOCAL sprScreenX:DWORD, sprSize:DWORD, baseSize:DWORD
LOCAL origStartX:DWORD, origStartY:DWORD
LOCAL drawStartX:DWORD, drawEndX:DWORD
LOCAL drawStartY:DWORD, drawEndY:DWORD
@ -483,7 +671,7 @@ sort_inner:
add ebx, OFFSET spriteList
mov eax, [ebx].SpriteStruct.dist
cmp eax, keyDist
jbe sort_insert ; dist[j] <= key -> insert after j
jae sort_insert ; dist[j] >= key -> keep far-to-near order
; Shift spriteOrder[j] -> spriteOrder[j+1]
mov ecx, j
@ -566,6 +754,7 @@ sprite_loop:
mov eax, (64 * PROJ_DIST)
xor edx, edx
div transY
mov baseSize, eax
mov ecx, [ebx].SpriteStruct.scale
test ecx, ecx
jnz @F
@ -589,7 +778,7 @@ sprite_loop:
; Floor-anchored: sprite bottom sits at the floor line of its depth
; (wall bottom = horizon + size/2), top = bottom - size
mov eax, SCREEN_HALF_H
mov edx, sprSize
mov edx, baseSize
shr edx, 1
add eax, edx
mov drawEndY, eax
@ -655,6 +844,30 @@ stripe_loop:
cmp eax, drawEndX
jg next_sprite
; Filter by glass pass
mov ecx, stripe
mov edx, [glassDistBuffer + ecx*4]
cmp passFilter, 1
jne check_pass_front
; Pass 1 (behind glass or columns without glass):
test edx, edx
jz normal_sprite_depth_test
cmp transY, edx
jl advance_stripe ; Sprite is in front of glass -> defer to Pass 2
jmp normal_sprite_depth_test
check_pass_front:
cmp passFilter, 2
jne normal_sprite_depth_test
; Pass 2 (in front of glass only):
test edx, edx
jz advance_stripe ; No glass in this column -> already drawn
cmp transY, edx
jge advance_stripe ; Sprite is behind glass -> already drawn
normal_sprite_depth_test:
; Depth test: sprite visible only if closer than wall in this column
mov eax, transY
mov ecx, stripe
@ -739,3 +952,172 @@ next_sprite:
sprites_done:
ret
RenderSprites ENDP
; ------------------------------------------------------------------------------
; RenderGlass: Renders semi-transparent glass wall pass over background & sprites
; ------------------------------------------------------------------------------
RenderGlass PROC uses ebx ecx edx esi edi
LOCAL screenX:DWORD
LOCAL drawStart:DWORD, drawEnd:DWORD, lineHeight:DWORD
LOCAL texX:DWORD, texPos:DWORD, texStep:DWORD
LOCAL texPtr:DWORD, pScreenPixel:DWORD
LOCAL sideHit:DWORD, perpDist:DWORD
mov eax, CELL_GLASS
shl eax, 16 ; CELL_GLASS * TEX_SIZE_BYTES (8 * 65536)
add eax, OFFSET textureBuffer
mov texPtr, eax
mov screenX, 0
glass_col_loop:
mov ecx, screenX
cmp ecx, SCREEN_W
jge glass_done
mov eax, [glassDistBuffer + ecx*4]
test eax, eax
jz next_glass_col
mov perpDist, eax
mov eax, [glassDrawStartBuf + ecx*4]
mov drawStart, eax
mov eax, [glassDrawEndBuf + ecx*4]
mov drawEnd, eax
mov eax, [glassLineHeightBuf + ecx*4]
mov lineHeight, eax
mov eax, [glassTexXBuf + ecx*4]
mov texX, eax
mov eax, [glassSideHitBuf + ecx*4]
mov sideHit, eax
; Texture vertical step: texStep = (128 << 16) / lineHeight
mov eax, (128 shl 16)
xor edx, edx
div lineHeight
mov texStep, eax
; texPos = (drawStart - SCREEN_HALF_H + lineHeight / 2) * texStep
mov eax, drawStart
sub eax, SCREEN_HALF_H
mov ebx, lineHeight
shr ebx, 1
add eax, ebx
imul eax, texStep
mov texPos, eax
; Screen pixel address: screenBuffer + (drawStart * SCREEN_PITCH) + (screenX * 4)
mov eax, drawStart
imul eax, SCREEN_PITCH
mov ecx, screenX
shl ecx, 2
add eax, ecx
add eax, OFFSET screenBuffer
mov pScreenPixel, eax
mov ecx, drawStart
glass_pixel_loop:
cmp ecx, drawEnd
jg next_glass_col
; Sample texture: texY = (texPos >> 16) & TEX_MASK
mov eax, texPos
shr eax, 16
and eax, TEX_MASK
mov ebx, eax ; ebx = texY
shl eax, 7 ; texY * 128
add eax, texX
shl eax, 2
add eax, texPtr
mov edx, [eax] ; edx = sampled texel (00RRGGBBh)
; Wall shading: Y-facing walls darkened 50% for 3D depth perception
cmp sideHit, 1
jne @F
and edx, 00FEFEFEh
shr edx, 1
@@:
; Distance fog for glass
mov eax, perpDist
cmp eax, 350
jl glass_blend
cmp eax, 700
jge glass_fog_black
and edx, 00FEFEFEh
shr edx, 1
jmp glass_blend
glass_fog_black:
mov edx, 00111111h
glass_blend:
; Check if this texel is the solid metal frame:
; Outer 6 pixels: texX < 6 || texX >= 122 || texY < 6 || texY >= 122
cmp texX, 6
jl write_solid_frame
cmp texX, 122
jge write_solid_frame
cmp ebx, 6
jl write_solid_frame
cmp ebx, 122
jge write_solid_frame
; Inside the glass pane: SEMI-TRANSPARENT BLEND!
; Background pixel from screenBuffer
mov eax, pScreenPixel
mov eax, [eax]
; Check diagonal reflection streak:
mov esi, texX
add esi, ebx
and esi, 63
cmp esi, 4
jl glass_shine_streak
cmp esi, 24
jl glass_pane_blend
cmp esi, 28
jl glass_shine_streak
glass_pane_blend:
; 50% background + 50% glass tint:
and eax, 00FEFEFEh
shr eax, 1
and edx, 00FEFEFEh
shr edx, 1
add eax, edx
jmp write_glass_out
glass_shine_streak:
; Specular reflection: 50% background + 50% glass + specular glare
and eax, 00FEFEFEh
shr eax, 1
and edx, 00FEFEFEh
shr edx, 1
add eax, edx
; Add specular highlight:
add eax, 00203040h
write_glass_out:
mov edx, pScreenPixel
mov [edx], eax
jmp next_glass_pixel
write_solid_frame:
mov eax, pScreenPixel
mov [eax], edx
next_glass_pixel:
mov eax, texPos
add eax, texStep
mov texPos, eax
add pScreenPixel, SCREEN_PITCH
inc ecx
jmp glass_pixel_loop
next_glass_col:
inc screenX
jmp glass_col_loop
glass_done:
ret
RenderGlass ENDP

View file

@ -4,6 +4,8 @@
.code
GenerateDefaultSpriteTextures PROTO
; ------------------------------------------------------------------------------
; GenerateDefaultTextures: Procedurally creates 128x128 32-bit BGRA textures
; Zero external asset dependency - works out of the box on any machine!
@ -11,6 +13,12 @@
GenerateDefaultTextures PROC uses ebx ecx edx esi edi
LOCAL texBase:DWORD, px:DWORD, py:DWORD, color:DWORD
mov edi, OFFSET textureBuffer
mov ecx, MAX_TEXTURES * TEX_SIZE_BYTES / 4
xor eax, eax
rep stosd
call GenerateDefaultSpriteTextures
; --------------------------------------------------------------------------
; Texture 1: Red Brick Wall
; --------------------------------------------------------------------------
@ -460,6 +468,72 @@ write_marble_pixel:
cmp py, TEX_H
jl marble_y_loop
; --------------------------------------------------------------------------
; Texture 10: Portal / Dimensional Vortex (Swirling purple / cyan stargate)
; --------------------------------------------------------------------------
mov texBase, OFFSET portalTextureBuffer
mov py, 0
portal_y_loop:
mov px, 0
portal_x_loop:
; Base: deep cosmic dark purple
mov color, 002A0835h
; Stone arch frame on outer 8 pixels
mov eax, px
cmp eax, 8
jl set_portal_arch
cmp eax, 120
jge set_portal_arch
mov eax, py
cmp eax, 8
jl set_portal_arch
; Calculate distance from center (64, 64)
mov eax, px
sub eax, 64
imul eax, eax
mov edx, py
sub edx, 64
imul edx, edx
add eax, edx ; r^2 = dx^2 + dy^2
; Concentric glowing energy rings
cmp eax, 2500 ; r ~ 50
jg write_portal_pixel
and eax, 15
cmp eax, 3
jg @F
mov color, 00D030F0h ; Bright violet energy ring
jmp write_portal_pixel
@@:
cmp eax, 8
jg write_portal_pixel
mov color, 00E0E040h ; Cyan shimmer
jmp write_portal_pixel
set_portal_arch:
mov color, 00484852h ; Carved rune stone arch
write_portal_pixel:
mov eax, py
shl eax, 7
add eax, px
shl eax, 2
mov edi, texBase
add edi, eax
mov edx, color
mov [edi], edx
inc px
cmp px, TEX_W
jl portal_x_loop
inc py
cmp py, TEX_H
jl portal_y_loop
ret
GenerateDefaultTextures ENDP
@ -471,65 +545,206 @@ GenerateDefaultTextures ENDP
; Returns: eax = 1 if pack loaded, 0 if procedural textures stay active.
; ------------------------------------------------------------------------------
LoadTexturePack PROC uses ebx ecx edx esi edi
LOCAL hFile:DWORD
LOCAL bytesRead:DWORD
LOCAL magic:DWORD, pkVersion:DWORD, slotCount:DWORD
LOCAL hFile:DWORD, bytesRead:DWORD
LOCAL magic:DWORD, pkVersion:WORD, slotCount:WORD
LOCAL payloadBytes:DWORD
invoke CreateFileA, OFFSET szTexPackPath, GENERIC_READ, FILE_SHARE_READ,\
0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
mov texPackLoaded, 0
invoke CreateFileA, OFFSET szTexPackPath, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
mov hFile, eax
cmp hFile, INVALID_HANDLE_VALUE
cmp eax, INVALID_HANDLE_VALUE
je pack_fail
; Read 8-byte header (dd magic, dw version, dw slotCount)
invoke ReadFile, hFile, ADDR magic, 4, ADDR bytesRead, 0
test eax, eax
jz pack_close_fail
cmp bytesRead, 4
jl pack_close_fail
jne pack_close_fail
cmp magic, TEXPACK_MAGIC
jne pack_close_fail
invoke ReadFile, hFile, ADDR pkVersion, 2, ADDR bytesRead, 0
test eax, eax
jz pack_close_fail
cmp bytesRead, 2
jl pack_close_fail
jne pack_close_fail
cmp pkVersion, 1
jne pack_close_fail
invoke ReadFile, hFile, ADDR slotCount, 2, ADDR bytesRead, 0
test eax, eax
jz pack_close_fail
cmp bytesRead, 2
jl pack_close_fail
; Clamp slot count to engine capacity
mov eax, slotCount
and eax, 0FFFFh
jne pack_close_fail
movzx eax, slotCount
test eax, eax
jz pack_close_fail
cmp eax, MAX_TEXTURES
jle @F
mov eax, MAX_TEXTURES
@@:
mov slotCount, eax
test eax, eax
jz pack_close_fail
; Read slotCount * TEX_SIZE_BYTES pixels straight into textureBuffer
mov ecx, slotCount
shl ecx, 16 ; * 65536
invoke ReadFile, hFile, OFFSET textureBuffer, ecx, ADDR bytesRead, 0
test eax, eax
jz pack_close_fail
mov eax, slotCount
ja pack_close_fail
shl eax, 16
cmp bytesRead, eax
mov payloadBytes, eax
add eax, 8
mov ebx, eax
invoke GetFileSize, hFile, 0
cmp eax, ebx
jne pack_close_fail
; Commit only after the complete payload has been read successfully.
invoke ReadFile, hFile, OFFSET texturePackStaging, payloadBytes, ADDR bytesRead, 0
test eax, eax
jz pack_close_fail
mov eax, payloadBytes
cmp bytesRead, eax
jne pack_close_fail
mov esi, OFFSET texturePackStaging
mov edi, OFFSET textureBuffer
mov ecx, payloadBytes
shr ecx, 2
rep movsd
invoke CloseHandle, hFile
mov texPackLoaded, 1
mov eax, 1
ret
pack_close_fail:
invoke CloseHandle, hFile
pack_fail:
xor eax, eax
ret
LoadTexturePack ENDP
; Slots 10..15 remain sprite slots even when the map contains portals.
GenerateDefaultSpriteTextures PROC uses eax ebx ecx edx esi edi
LOCAL px:DWORD, py:DWORD, slot:DWORD, color:DWORD
mov edi, OFFSET textureBuffer + 10 * TEX_SIZE_BYTES
mov ecx, 6 * TEX_SIZE_BYTES / 4
mov eax, COLOR_CHROMA_KEY
rep stosd
mov slot, 10
sprite_default_slot:
mov py, 0
sprite_default_y:
mov px, 0
sprite_default_x:
mov color, COLOR_CHROMA_KEY
cmp slot, 10
jne default_barrel
cmp px, 60
jl lamp_shade
cmp px, 68
jge lamp_shade
cmp py, 40
jl lamp_shade
cmp py, 120
jge lamp_shade
mov color, 00404040h
lamp_shade:
cmp px, 48
jl lamp_foot
cmp px, 80
jge lamp_foot
cmp py, 16
jl lamp_foot
cmp py, 44
jge lamp_foot
mov color, 00FFC030h
lamp_foot:
cmp px, 52
jl default_sprite_write
cmp px, 76
jge default_sprite_write
cmp py, 120
jl default_sprite_write
cmp py, 126
jge default_sprite_write
mov color, 00303030h
jmp default_sprite_write
default_barrel:
cmp slot, 11
jne default_statue
mov eax, px
sub eax, 64
imul eax, eax
imul eax, 2704
mov ebx, py
sub ebx, 70
imul ebx, ebx
imul ebx, 1156
add eax, ebx
cmp eax, 3125824
jg default_sprite_write
mov color, 006A401Eh
mov eax, py
sub eax, 70
cdq
xor eax, edx
sub eax, edx
cmp eax, 29
jl barrel_highlight
cmp eax, 35
jg barrel_highlight
mov color, 00555555h
jmp default_sprite_write
barrel_highlight:
cmp px, 53
jge default_sprite_write
cmp eax, 26
jge default_sprite_write
mov color, 008A5A2Eh
jmp default_sprite_write
default_statue:
cmp px, 44
jl statue_body
cmp px, 84
jge statue_body
cmp py, 112
jl statue_body
cmp py, 126
jge statue_body
mov color, 00606060h
statue_body:
cmp px, 54
jl statue_head
cmp px, 74
jge statue_head
cmp py, 40
jl statue_head
cmp py, 112
jge statue_head
mov eax, px
xor eax, py
and eax, 7
add eax, 08Ah
imul eax, 010101h
mov color, eax
statue_head:
mov eax, px
sub eax, 64
imul eax, eax
mov ebx, py
sub ebx, 28
imul ebx, ebx
add eax, ebx
cmp eax, 100
jg default_sprite_write
mov color, 009A9A9Ah
default_sprite_write:
mov edi, slot
shl edi, 16
mov eax, py
shl eax, 7
add eax, px
shl eax, 2
add edi, eax
mov eax, color
mov DWORD PTR [textureBuffer + edi], eax
inc px
cmp px, TEX_W
jl sprite_default_x
inc py
cmp py, TEX_H
jl sprite_default_y
inc slot
cmp slot, 13
jl sprite_default_slot
ret
GenerateDefaultSpriteTextures ENDP

126
main.asm
View file

@ -16,6 +16,9 @@ includelib winmm.lib
; Windows API Prototypes
ExitProcess PROTO :DWORD
GetModuleHandleA PROTO :DWORD
GetCommandLineA PROTO
GetFileSize PROTO :DWORD, :DWORD
GetForegroundWindow PROTO
RegisterClassExA PROTO :DWORD
CreateWindowExA PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
ShowWindow PROTO :DWORD, :DWORD
@ -39,6 +42,12 @@ CreateFileA PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :DWORD, :D
ReadFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
WriteFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
CloseHandle PROTO :DWORD
PlaySoundA PROTO :DWORD, :DWORD, :DWORD
OpenFileMappingA PROTO :DWORD, :DWORD, :DWORD
MapViewOfFile PROTO :DWORD, :DWORD, :DWORD, :DWORD, :DWORD
UnmapViewOfFile PROTO :DWORD
OpenProcess PROTO :DWORD, :DWORD, :DWORD
WaitForSingleObject PROTO :DWORD, :DWORD
; Structures
POINT STRUCT
@ -104,6 +113,8 @@ include include/engine_input.inc
include include/engine_physics.inc
include include/engine_raycaster.inc
include include/engine_render2d.inc
include include/engine_cutscene.inc
include include/engine_preview.inc
.code
@ -121,12 +132,13 @@ WndProc PROC hWnd:DWORD, uMsg:DWORD, wParam:DWORD, lParam:DWORD
handle_keydown:
cmp wParam, VK_KEY_ESCAPE
jne @F
jne check_f12
invoke PostQuitMessage, 0
mov isRunning, 0
xor eax, eax
ret
@@:
check_f12:
; Movement and action edges are handled only by PollInput.
invoke DefWindowProcA, hWnd, uMsg, wParam, lParam
ret
@ -207,6 +219,11 @@ Engine_Init ENDP
; Engine_RenderHUD: Draws 2D overlay text, controls, minimap, and FPS
; ------------------------------------------------------------------------------
Engine_RenderHUD PROC uses ebx ecx edx
cmp isCutsceneActive, 0
je normal_engine_hud
call RenderCutsceneOverlay
ret
normal_engine_hud:
; 1. Top HUD Header
invoke DrawString8x8, 10, 10, OFFSET strEngineName, COLOR_YELLOW
@ -219,13 +236,26 @@ Engine_RenderHUD PROC uses ebx ecx edx
invoke DrawString8x8, 10, 218, OFFSET strControls1, COLOR_WHITE
invoke DrawString8x8, 10, 228, OFFSET strControls3, COLOR_GREEN
; 4. Door Interaction Hint (center screen)
; 4. Door / Portal Interaction Hint (center screen)
call CheckDoorHint
test eax, eax
jz @F
invoke DrawString8x8, 128, 150, OFFSET strDoorHint, COLOR_YELLOW
@@:
cmp eax, 1
je show_door_hint
cmp eax, 2
je show_portal_hint
jmp check_minimap
show_door_hint:
invoke DrawString8x8, 128, 150, OFFSET strDoorHint, COLOR_YELLOW
jmp check_minimap
show_portal_hint:
invoke DrawString8x8, 110, 150, OFFSET strPortalHint, COLOR_CYAN
check_minimap:
cmp mapErrorFrames, 0
je hud_no_map_error
invoke DrawString8x8, 44, 164, OFFSET strMapLoadError, COLOR_RED
hud_no_map_error:
; 5. Minimap Radar (Toggle with 'M')
call DrawMinimap
@ -237,12 +267,17 @@ Engine_RenderHUD ENDP
; ------------------------------------------------------------------------------
Engine_Frame PROC uses ebx esi edi
; 1. Input & Physics Update
cmp mapErrorFrames, 0
je no_error_tick
dec mapErrorFrames
no_error_tick:
call PollInput
call UpdatePlayer
; 2. 3D Raycasting & 2D HUD Render
call Render3DScene
call Engine_RenderHUD
call ApplyCutsceneFade
; 2b. Screenshot request (F12): dump fresh framebuffer to BMP
cmp keyScreenshot, 0
@ -251,6 +286,9 @@ Engine_Frame PROC uses ebx esi edi
call SaveScreenshot
@@:
; 2c. Screen Fade effect for sublevel transitions
call ApplyScreenFade
; 3. Present Framebuffer to Screen via StretchDIBits (320x240 -> 1280x960)
invoke StretchDIBits, hEngineDC,\
0, 0, WINDOW_W, WINDOW_H,\
@ -274,6 +312,14 @@ Engine_Frame PROC uses ebx esi edi
mov fpsTimer, eax
frame_done:
; Reset one-frame input triggers
mov keyForward, 0
mov keyBackward, 0
mov keyStrafeLeft, 0
mov keyStrafeRight, 0
mov keyTurnLeft, 0
mov keyTurnRight, 0
mov keyAction, 0
ret
Engine_Frame ENDP
@ -296,6 +342,69 @@ ss_done:
ret
SaveScreenshot ENDP
; Optional first argument: a quoted or unquoted starting map path.
; Assets and linked map basenames remain relative to the working directory.
ParseStartupMap PROC uses ebx ecx edx esi edi
invoke GetCommandLineA
mov esi, eax
mov startupMapPath, 0
cmp byte ptr [esi], 34
jne skip_executable_word
inc esi
skip_executable_quote:
lodsb
test al, al
jz startup_arg_done
cmp al, 34
jne skip_executable_quote
jmp skip_argument_spaces
skip_executable_word:
lodsb
test al, al
jz startup_arg_done
cmp al, 32
jne skip_executable_word
skip_argument_spaces:
cmp byte ptr [esi], 32
je consume_argument_space
cmp byte ptr [esi], 9
jne copy_startup_argument
consume_argument_space:
inc esi
jmp skip_argument_spaces
copy_startup_argument:
xor ebx, ebx
cmp byte ptr [esi], 34
jne startup_argument_loop_init
mov ebx, 1
inc esi
startup_argument_loop_init:
mov edi, OFFSET startupMapPath
mov ecx, MAX_PATH_BYTES - 1
startup_argument_loop:
lodsb
test al, al
jz terminate_startup_arg
cmp al, 34
je terminate_startup_arg
test ebx, ebx
jnz write_startup_char
cmp al, 32
je terminate_startup_arg
cmp al, 9
je terminate_startup_arg
write_startup_char:
stosb
dec ecx
jnz startup_argument_loop
mov startupMapPath, 0
ret
terminate_startup_arg:
mov byte ptr [edi], 0
startup_arg_done:
ret
ParseStartupMap ENDP
; ------------------------------------------------------------------------------
; main: Application Entry Point & Game Loop
; ------------------------------------------------------------------------------
@ -303,6 +412,8 @@ main PROC
LOCAL msg:MSG
LOCAL curTick:DWORD, elapsed:DWORD
call ParseStartupMap
call TryEnginePreview
call Engine_Init
main_loop:
@ -344,6 +455,7 @@ do_tick:
jmp main_loop
exit_loop:
call StopCutscene
invoke ReleaseDC, hEngineWnd, hEngineDC
invoke timeEndPeriod, 1
invoke ExitProcess, 0

BIN
main.obj

Binary file not shown.

View file

@ -0,0 +1,88 @@
static void RunCutsceneChecks(EditorApp& app) {
Check(app.LoadLevel("../assets/map.dat"), "cutscene test starts from legacy map");
app.playerStartAngle = 508; app.playerStartY = 3;
app.cutscene.flags = CUTSCENE_AUTOPLAY;
app.cutscene.strings = {"Привет, мир!", "quiet.wav"};
app.cutscene.events = {
{0,OP_MOVE_CAM_TO,0,288,160,4}, {0,OP_PAN_CAM_TO,0,4,4,0},
{0,OP_FADE_SCREEN,0,16,4,0}, {0,OP_SHOW_SUBTITLE,0,0,0,0},
{0,OP_PLAY_SOUND,0,1,0,0}, {1,OP_WAIT,0,2,0,0},
{2,OP_HIDE_SUBTITLE,0,0,0,0}, {4,OP_FADE_SCREEN,0,0,4,0}, {8,OP_END,0,0,0,0}
};
Check(app.SaveLevel("../assets/scene.dat"), "save v4 cutscene, Russian text and sound reference");
BinaryLevel level; std::string error;
Check(ReadLevelFile("../assets/scene.dat",level,error) && level.header.version == 4 && level.cutscene.events.size() == 9 &&
level.cutscene.strings[0] == "Привет, мир!" && level.cutscene.flags == CUTSCENE_AUTOPLAY,
"v4 track round trip retains UTF8 and autoplay");
std::string encoded;
Check(CutsceneEncode("Привет, мир!",encoded) && encoded.size() == 12 && static_cast<unsigned char>(encoded[0]) == 0xCF,
"Russian subtitles use the runtime CP1251 font");
Check(!CutsceneEncode("😀",encoded), "unrepresentable subtitle is rejected");
CutscenePreview preview;
preview.Start(160,224,508);
std::ofstream trace("../assets/cutscene_reference.bin",std::ios::binary);
int audioCount = 0;
for (int tick=0;tick<=8;++tick) {
preview.Step(app.cutscene,[&](int id,bool loop) { if(id==1 && !loop) ++audioCount; });
int state[6] = {preview.x,preview.y,preview.angle,preview.fade,preview.subtitle,preview.active?1:0};
trace.write(reinterpret_cast<const char*>(state),sizeof(state));
if (tick==2) Check(preview.x==224 && preview.y==192 && preview.angle==0 && preview.fade==8 && preview.subtitle==0,
"preview interpolates signed movement, shortest angle, fade and wait");
if (tick==3) Check(preview.subtitle==-1, "wait delays a following hide command until its deadline");
}
trace.close();
Check(preview.x==288 && preview.y==160 && preview.angle==4 && !preview.active && preview.fade==0 && audioCount==1,
"preview reaches exact targets and stops cleanly");
auto invalid = app.cutscene;
invalid.events[0].dataId=3601;
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "oversized camera duration rejected");
invalid=app.cutscene;invalid.events[3].paramA=99;
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "invalid string reference rejected");
invalid=app.cutscene;invalid.events.pop_back();
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "missing end marker rejected");
invalid=app.cutscene;invalid.events[0].paramA=0;invalid.events[0].paramB=0;
Check(!ValidateCutscene(invalid,&app.map[0][0],app.transitions.size(),error), "camera point in a wall rejected");
for (const char* file : {"scene_badop.dat","scene_badref.dat","scene_badtext.dat","scene_badorder.dat","scene_badflags.dat","scene_noend.dat"}) {
Check(!app.LoadLevel(std::string("../assets/")+file) && app.cutscene.events.size()==9,
"malformed cutscene load preserves editor track");
}
std::string start, engine;
Check(app.PreparePlaytest(start,engine) && start=="assets\\scene.dat", "F5 packages v4 track and referenced WAV");
app.CleanupPlaytest();
app.cutscene.strings[1]="absent.wav";start.clear();engine.clear();
Check(!app.PreparePlaytest(start,engine), "F5 rejects missing cutscene audio before launch");
app.CleanupPlaytest();
app.LoadLevel("../assets/map.dat");
Check(app.cutscene.events.empty() && app.cutscene.strings.empty(), "legacy map clears previous track");
app.AddCutsceneEvent(OP_MOVE_CAM_TO);
Check(app.cutscene.events.size()==2 && app.placeCameraPoint, "timeline camera button inserts an end marker and enables map placement");
app.Undo();Check(app.cutscene.events.empty(), "undo includes cutscene commands");
app.Redo();Check(app.cutscene.events.size()==2, "redo restores cutscene commands");
app.cutscene = {};app.selectedCutsceneEvent=-1;
for(int i=0;i<40;++i) { app.AddCutsceneEvent(OP_SHOW_SUBTITLE);app.DeleteCutsceneEvent(); }
Check(app.cutscene.strings.empty(), "deleting commands reclaims string pool slots");
app.AddCutsceneEvent(OP_CHANGE_SUBLEVEL);
app.SetCell(4,2,CELL_EMPTY);
Check(std::none_of(app.cutscene.events.begin(),app.cutscene.events.end(),[](const auto& e){return e.opCode==OP_CHANGE_SUBLEVEL;}),
"removing a portal repairs cutscene transition references");
app.LoadLevel("../assets/scene.dat");app.showCutsceneTimeline=true;
ImGui::CreateContext();
auto& io=ImGui::GetIO();io.DisplaySize=ImVec2(1360,840);io.DeltaTime=1.0f/60;io.IniFilename=nullptr;
unsigned char* pixels;int w,h;io.Fonts->GetTexDataAsRGBA32(&pixels,&w,&h);
ImGui::NewFrame();app.RenderUI();ImGui::Render();
Check(ImGui::GetDrawData()->TotalVtxCount>0,"editor timeline renders without a native window");
io.AddKeyEvent(ImGuiKey_F5,true);
ImGui::NewFrame(); io.WantCaptureKeyboard=true; io.WantTextInput=true;
app.HandleHotkeys();
Check(app.isEngineRunning,"F5 launches even when ImGui captures keyboard input");
ImGui::Render();
if(app.hEngineProcess) WaitForSingleObject(app.hEngineProcess,5000);
app.CheckPlaytestProcess();
io.AddKeyEvent(ImGuiKey_F5,false); io.AddKeyEvent(ImGuiKey_F6,true);
ImGui::NewFrame(); io.WantCaptureKeyboard=true; io.WantTextInput=true;
app.HandleHotkeys();
Check(!app.showCutsceneTimeline,"F6 remains available while a text widget owns focus");
ImGui::Render();
ImGui::DestroyContext();
app.showCutsceneTimeline=false;
}

View file

@ -0,0 +1,211 @@
.data
sceneFile db 'scene.dat',0
sceneInvalidOp db 'scene_badop.dat',0
sceneInvalidRef db 'scene_badref.dat',0
sceneInvalidText db 'scene_badtext.dat',0
sceneInvalidOrder db 'scene_badorder.dat',0
sceneInvalidFlags db 'scene_badflags.dat',0
sceneMissingEnd db 'scene_noend.dat',0
sceneTraceFile db 'cutscene_trace.bin',0
sceneFrameFile db 'cutscene_frame.bgra',0
sceneTrace dd 54 dup(0)
lastSoundFlags dd 0
soundCapture db 128 dup(0)
.code
AuditPlaySoundA PROC uses ebx ecx esi edi, path:DWORD, module:DWORD, flags:DWORD
mov eax, flags
mov lastSoundFlags, eax
mov esi, path
mov soundCapture, 0
test esi, esi
jz sound_mock_done
mov edi, OFFSET soundCapture
mov ecx, 127
sound_mock_copy:
lodsb
stosb
test al, al
jz sound_mock_done
loop sound_mock_copy
mov byte ptr [edi], 0
sound_mock_done:
mov eax, 1
ret
AuditPlaySoundA ENDP
RunCutsceneChecks PROC uses ebx ecx edx esi edi
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
CHECK eax, 1 ; load v4 track with Russian subtitles
CHECK cutsceneCount, 9 ; all serialized cutscene events loaded
CHECK cutsceneStringCount, 2 ; all serialized strings loaded
CHECK cutscenePending, 1 ; autoplay waits until normal player update
movzx eax, BYTE PTR [cutsceneStrings]
CHECK eax, 0CFh ; Russian text arrives as CP1251 bytes
call StartCutscene
mov esi, 0
scene_trace_tick:
call UpdateCutscene
mov edi, esi
imul edi, 24
add edi, OFFSET sceneTrace
mov eax, playerX
stosd
mov eax, playerY
stosd
mov eax, playerAngle
stosd
mov eax, cutsceneFade
stosd
mov eax, cutsceneSubtitle
stosd
mov eax, isCutsceneActive
stosd
cmp esi, 0
jne scene_trace_not_zero
CHECK lastSoundFlags, 00020003h ; WAV event uses asynchronous filename playback
CHECK DWORD PTR [soundCapture+14], 65697571h ; sound path is assets/sounds/quiet.wav
scene_trace_not_zero:
cmp esi, 2
jne scene_trace_not_two
CHECK playerX, 224 ; camera reaches exact interpolated X midpoint
CHECK playerY, 192 ; camera interpolates a negative Y delta
CHECK playerAngle, 0 ; pan wraps through zero along shortest arc
CHECK cutsceneFade, 8 ; fade reaches midpoint
CHECK cutsceneSubtitle, 0 ; wait blocks a later hide command
scene_trace_not_two:
inc esi
cmp esi, 9
jl scene_trace_tick
invoke Dump, OFFSET sceneTraceFile, OFFSET sceneTrace, 216
CHECK playerX, 288 ; movement reaches its exact target before END
CHECK playerY, 160 ; Y target retained after END
CHECK playerAngle, 4 ; angle target retained after END
CHECK isCutsceneActive, 0 ; END returns control
CHECK cutsceneFade, 0 ; END clears cinematic darkening
CHECK cutsceneSubtitle, -1 ; END clears subtitles
CHECK lastSoundFlags, 0 ; END stops audio
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
mov keyForward, 1
mov keyCutscene, 0
mov keySkipScene, 0
call UpdatePlayer
CHECK isCutsceneActive, 1 ; player update starts autoplay
CHECK playerX, 160 ; movement input does not move the player during the first scene frame
call UpdatePlayer
CHECK playerX, 192 ; next frame is driven only by cinematic interpolation
mov keySkipScene, 1
call UpdatePlayer
CHECK isCutsceneActive, 0 ; Tab skips and releases camera control
mov keySkipScene, 0
mov keyCutscene, 1
call UpdatePlayer
CHECK isCutsceneActive, 1 ; C can restart a finished scene
mov keyCutscene, 0
mov keyForward, 0
mov testFocus, 1
mov hEngineWnd, 1
mov testKeys[VK_KEY_W*2], 8000h
mov testKeys[VK_KEY_TAB*2], 8000h
call PollInput
movzx eax, keyForward
CHECK eax, 0 ; scene masks normal movement input
movzx eax, keySkipScene
CHECK eax, 1 ; skip input remains available in a scene
call StopCutscene
call PollInput
movzx eax, keyForward
CHECK eax, 1 ; normal input returns after scene stop
mov testKeys[VK_KEY_W*2], 0
mov testKeys[VK_KEY_TAB*2], 0
mov keyForward, 0
mov keySkipScene, 0
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
call StartCutscene
invoke LoadSublevelBinary, OFFSET sceneInvalidOp, -1, -1, 0
CHECK eax, 0 ; unknown cutscene opcode rejected
CHECK isCutsceneActive, 1 ; invalid track load keeps currently running scene
CHECK cutsceneCount, 9 ; invalid track load keeps original commands
invoke LoadSublevelBinary, OFFSET sceneInvalidRef, -1, -1, 0
CHECK eax, 0 ; invalid subtitle reference rejected
invoke LoadSublevelBinary, OFFSET sceneInvalidText, -1, -1, 0
CHECK eax, 0 ; unterminated string rejected
invoke LoadSublevelBinary, OFFSET sceneInvalidOrder, -1, -1, 0
CHECK eax, 0 ; unsorted commands rejected
invoke LoadSublevelBinary, OFFSET sceneInvalidFlags, -1, -1, 0
CHECK eax, 0 ; unsupported scene flags rejected
invoke LoadSublevelBinary, OFFSET sceneMissingEnd, -1, -1, 0
CHECK eax, 0 ; scene without END rejected
invoke LoadSublevelBinary, OFFSET mainPath, -1, -1, 0
CHECK isCutsceneActive, 0 ; a successful room load stops old playback
CHECK cutsceneCount, 0 ; a legacy room clears old commands
CHECK cutsceneStringCount, 0 ; a legacy room clears old string references
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
mov DWORD PTR [cutsceneEvents+3*sizeof CutsceneEvent+CutsceneEvent.paramB], 2
call StartCutscene
call UpdateCutscene
call UpdateCutscene
call UpdateCutscene
CHECK cutsceneSubtitle, -1 ; timed subtitle expires even while a WAIT blocks events
mov cutsceneSubtitle, 0
mov edi, OFFSET screenBuffer
mov ecx, SCREEN_W*SCREEN_H
mov eax, COLOR_BLUE
rep stosd
call RenderCutsceneOverlay
CHECK DWORD PTR [screenBuffer], COLOR_BLACK ; cinematic top letterbox
CHECK DWORD PTR [screenBuffer+200*SCREEN_PITCH], COLOR_BLACK ; cinematic bottom letterbox
CHECK DWORD PTR [screenBuffer+100*SCREEN_PITCH], COLOR_BLUE ; picture area remains visible
CHECK DWORD PTR [screenBuffer+159*SCREEN_PITCH], 0000007Fh ; subtitle panel is semitransparent
mov edi, OFFSET screenBuffer+160*SCREEN_PITCH
mov ecx, 28*SCREEN_W
xor ebx, ebx
scene_white_pixel:
cmp DWORD PTR [edi], COLOR_WHITE
jne scene_next_pixel
inc ebx
scene_next_pixel:
add edi, 4
loop scene_white_pixel
xor eax, eax
test ebx, ebx
setnz al
CHECK eax, 1 ; Russian subtitle glyphs are drawn
invoke Dump, OFFSET sceneFrameFile, OFFSET screenBuffer, FRAME_BUFFER_SIZE
mov edi, OFFSET screenBuffer
mov ecx, SCREEN_W*SCREEN_H
mov eax, 00123456h
rep stosd
mov cutsceneFade, 8
call ApplyCutsceneFade
CHECK DWORD PTR [screenBuffer], 00091A2Bh ; fade blends each RGB channel correctly
mov cutsceneFade, 16
call ApplyCutsceneFade
CHECK DWORD PTR [screenBuffer], 0 ; full cinematic fade produces black
call StopCutscene
; Scene transitions use the same validated room switch and fade sequencer.
invoke LoadSublevelBinary, OFFSET sceneFile, -1, -1, 0
mov cutsceneCount, 2
mov edi, OFFSET cutsceneEvents
mov ecx, 8
xor eax, eax
rep stosd
mov BYTE PTR [cutsceneEvents+CutsceneEvent.opCode], OP_CHANGE_SUBLEVEL
mov WORD PTR [cutsceneEvents+sizeof CutsceneEvent], 1
call StartCutscene
mov cutsceneFade, 16
call UpdateCutscene
CHECK isCutsceneActive, 0 ; changing sublevel stops the old scene
CHECK fadeState, 1 ; scripted transition starts its configured fade
CHECK fadeLevel, 16 ; transition preserves an existing cinematic fade without a bright flash
mov esi, 32
scene_transition_fade:
call ApplyScreenFade
dec esi
jnz scene_transition_fade
CHECK spriteCount, 1 ; scripted transition loads the destination room
CHECK cutsceneCount, 0 ; scripted transition cannot reuse old scene buffers
ret
RunCutsceneChecks ENDP

View file

@ -0,0 +1,157 @@
// Compile the real editor implementation in this translation unit so the
// scenarios can exercise tools and playtest preparation without opening a UI.
#include <string>
#include <vector>
#include <deque>
#include <windows.h>
#define private public
#include "../editor/src/editor_app.h"
#undef private
#include "../editor/src/editor_app.cpp"
#include <iostream>
static int failures = 0, checks = 0;
static void Check(bool ok, const char* name) {
++checks;
if (!ok) ++failures;
std::cout << (ok ? "PASS " : "FAIL ") << name << '\n';
}
static std::vector<uint8_t> Bytes(const fs::path& path) {
std::ifstream in(path, std::ios::binary);
return {std::istreambuf_iterator<char>(in), {}};
}
#include "cutscene_editor_checks.inl"
#include "viewport_editor_checks.inl"
int main(int argc, char** argv) {
EditorApp app;
std::vector<uint8_t> defaults;
ReadTextureSlots("absent-defaults.bin", defaults);
{
std::ofstream out("../assets/defaults.bin", std::ios::binary);
out.write(reinterpret_cast<const char*>(defaults.data()), defaults.size());
}
Check(app.LoadLevel("../assets/map.dat"), "load v3 main map");
Check(app.sprites.size() == 2 && app.transitions.size() == 1, "load all entities");
Check(app.LoadLevel("../assets/legacy1.dat") && app.sprites[0].x == 224, "convert v1 coordinates");
Check(app.LoadLevel("../assets/legacy2.dat") && app.transitions.size() == 1, "read extended v2 maps");
Check(app.LoadLevel("../assets/black.dat") && app.ceilingColor == 0 && app.floorColor == 0, "apply black palette");
Check(app.LoadLevel("../assets/map.dat"), "restore main map");
auto before = app.CaptureSnapshot();
auto pathBefore = app.currentFilePath;
for (const char* name : {"truncated.dat", "overflow.dat", "badname.dat", "badspawn.dat", "orphan.dat", "duplicate.dat"}) {
Check(!app.LoadLevel(std::string("../assets/") + name), name);
Check(app.currentFilePath == pathBefore && app.sprites.size() == before.sprites.size() &&
app.transitions.size() == before.transitions.size() && !std::memcmp(app.map, before.map, sizeof(app.map)),
"failed load preserves current room");
}
Check(app.SaveLevel("../assets/saved.dat"), "save current format atomically");
BinaryLevel saved; std::string error;
Check(ReadLevelFile("../assets/saved.dat", saved, error) && saved.header.version == MAP_FORMAT_VERSION, "saved map reads back");
auto goodSave = Bytes("../assets/saved.dat");
app.playerStartX = -1;
Check(!app.SaveLevel("../assets/saved.dat") && Bytes("../assets/saved.dat") == goodSave,
"invalid save does not overwrite a valid file");
app.playerStartX = 2;
std::vector<uint8_t> rgba(128 * 128 * 4);
for (int i = 0; i < 128 * 128; ++i) {
rgba[4*i] = 0x11; rgba[4*i+1] = 0x22; rgba[4*i+2] = 0x33; rgba[4*i+3] = 255;
}
stbi_write_png("wall.png", 128, 128, 4, rgba.data(), 128 * 4);
Check(app.ImportTextureImage(1, false, "wall.png"), "first PNG import creates directories and pack");
auto pack = Bytes("../assets/textures/texturepack.bin");
Check(pack.size() == 8 + 16 * 65536, "complete pack layout");
Check(pack[8+65536] == 0x33 && pack[9+65536] == 0x22 && pack[10+65536] == 0x11 && pack[11+65536] == 0,
"RGBA converts to engine BGRA");
auto fallback = MakeDefaultTextureSlot(2);
Check(std::equal(fallback.begin(), fallback.end(), pack.begin() + 8 + 2 * 65536), "first import retains fallback walls");
auto lamp = MakeDefaultTextureSlot(10);
Check(std::equal(lamp.begin(), lamp.end(), pack.begin() + 8 + 10 * 65536), "first import retains sprite defaults");
for (int y = 0; y < 128; ++y) for (int x = 0; x < 64; ++x) rgba[4*(y*128+x)+3] = 0;
stbi_write_png("sprite.png", 128, 128, 4, rgba.data(), 128 * 4);
Check(app.ImportTextureImage(11, true, "sprite.png"), "import transparent sprite");
auto updated = Bytes("../assets/textures/texturepack.bin");
const auto* sprite = updated.data() + 8 + 11 * 65536;
Check(sprite[0] == 255 && sprite[1] == 0 && sprite[2] == 255 && sprite[3] == 0 && sprite[400] == 0x33,
"alpha converts to chroma key");
Check(std::equal(pack.begin(), pack.begin() + 8 + 11 * 65536, updated.begin()), "sprite import preserves other slots");
fs::copy_file("../assets/textures/texturepack.bin", "../assets/imported.bin", fs::copy_options::overwrite_existing);
auto corrupt = updated; corrupt.resize(100);
{ std::ofstream out("../assets/textures/texturepack.bin", std::ios::binary); out.write(reinterpret_cast<char*>(corrupt.data()), corrupt.size()); }
Check(!app.ImportTextureImage(1, false, "wall.png") && Bytes("../assets/textures/texturepack.bin") == corrupt,
"invalid existing pack is preserved");
fs::copy_file("../assets/imported.bin", "../assets/textures/texturepack.bin", fs::copy_options::overwrite_existing);
Check(app.ResetTextureSlotToDefault(2), "reset uses the same safe pack writer");
auto reset = Bytes("../assets/textures/texturepack.bin");
Check(std::equal(updated.begin() + 8 + 11 * 65536, updated.end(), reset.begin() + 8 + 11 * 65536),
"reset preserves imported sprites");
Check(app.ImportSoundAudio("quiet.wav"), "WAV import without scripts");
app.StopAudioSound();
auto manifest = Bytes("../assets/sounds/sounds.inc");
Check(Bytes("quiet.wav") == Bytes("../assets/sounds/quiet.wav") &&
std::string(manifest.begin(), manifest.end()).find("SND_QUIET") != std::string::npos, "sound file and manifest");
app.InitDefaultLevel();
Check(app.SetCell(2, 2, CELL_PORTAL) && app.transitions.size() == 1, "brush creates portal and transition together");
app.FloodFill(2, 2, CELL_PORTAL, CELL_EMPTY);
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "fill removes transition with portal");
app.map[2][2] = CELL_METAL; app.map[2][3] = CELL_METAL;
app.FloodFill(2, 2, CELL_METAL, CELL_PORTAL);
Check(app.transitions.size() == 2 && app.map[2][2] == CELL_PORTAL && app.map[2][3] == CELL_PORTAL,
"small fill creates every transition");
app.SetCell(2, 2, CELL_EMPTY); app.SetCell(3, 2, CELL_EMPTY);
auto fillBefore = app.CaptureSnapshot();
app.FloodFill(2, 2, CELL_EMPTY, CELL_PORTAL);
Check(app.transitions.empty() && !std::memcmp(app.map, fillBefore.map, sizeof(app.map)), "oversized portal fill is atomic");
app.PushUndo(); app.SetCell(2, 2, CELL_PORTAL); app.Undo();
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "undo restores both grid and transitions");
app.Redo(); Check(app.map[2][2] == CELL_PORTAL && app.transitions.size() == 1, "redo restores portal");
app.currentTool = TOOL_PLAYER_SPAWN; app.ApplyTool(2, 2);
Check(app.map[2][2] == CELL_EMPTY && app.transitions.empty(), "moving spawn onto a portal removes its trigger");
app.InitDefaultLevel();
for (int i = 0; i < 16; ++i) app.SetCell(i + 1, 20, CELL_PORTAL);
Check(!app.SetCell(20, 20, CELL_PORTAL) && app.map[20][20] != CELL_PORTAL && app.transitions.size() == 16,
"portal limit cannot create orphan cells");
Check(app.LoadLevel("../assets/sublevel_02.dat"), "open sublevel for F5");
auto originalMain = Bytes("../assets/map.dat"), originalSub = Bytes("../assets/sublevel_02.dat");
auto originalPath = app.currentFilePath;
app.map[12][12] = CELL_MARBLE; // unsaved edit included only in preview
app.LaunchPlaytest();
Check(app.isEngineRunning && app.currentFilePath == originalPath, "F5 preserves current file path");
Check(Bytes("../assets/map.dat") == originalMain && Bytes("../assets/sublevel_02.dat") == originalSub,
"F5 never overwrites source maps");
fs::path sandbox = app.playtestDirectory;
BinaryLevel preview;
Check(ReadLevelFile((sandbox / "assets/sublevel_02.dat").string(), preview, error) && preview.cells[12*32+12] == CELL_MARBLE,
"F5 includes unsaved edits");
Check(Bytes(sandbox / "assets/map.dat") == originalMain, "reverse portal keeps the real main map");
Check(Bytes(sandbox / "assets/textures/texturepack.bin") == Bytes("../assets/textures/texturepack.bin"), "F5 copies imported resources");
if (app.hEngineProcess) WaitForSingleObject(app.hEngineProcess, 5000);
auto launched = Bytes(sandbox / "launched.txt");
Check(std::string(launched.begin(), launched.end()) == "assets\\sublevel_02.dat", "engine receives the selected start map");
app.CheckPlaytestProcess();
Check(!app.isEngineRunning && !fs::exists(sandbox), "finished playtest cleans its own temporary directory");
strcpy_s(app.transitions[0].targetMap, "missing.dat");
app.LaunchPlaytest();
Check(!app.isEngineRunning && app.playtestDirectory.empty() && Bytes("../assets/map.dat") == originalMain,
"missing linked map aborts F5 without data loss");
RunCutsceneChecks(app);
if (argc > 1) {
Check(ReadLevelFile(std::string(argv[1]) + "/map.dat", preview, error), "existing project main map remains compatible");
Check(ReadLevelFile(std::string(argv[1]) + "/sublevel_02.dat", preview, error), "existing project sublevel remains compatible");
app.LoadLevel(std::string(argv[1]) + "/map.dat");
std::string start, executable;
Check(app.PreparePlaytest(start, executable), "existing project graph prepares for playtest");
app.CleanupPlaytest();
app.LoadLevel(std::string(argv[1]) + "/sublevel_02.dat");
start.clear(); executable.clear();
Check(app.PreparePlaytest(start, executable), "existing project sublevel and reverse link prepare for playtest");
app.CleanupPlaytest();
}
if(argc>2) RunViewportChecks(app,argv[2]);
std::cout << "Editor: " << checks - failures << '/' << checks << " passed\n";
return failures ? 1 : 0;
}

View file

@ -0,0 +1,370 @@
include engine_api.inc
AuditGetAsyncKeyState PROTO :DWORD
AuditGetForegroundWindow PROTO
AuditPlaySoundA PROTO :DWORD, :DWORD, :DWORD
GetAsyncKeyState TEXTEQU <AuditGetAsyncKeyState>
GetForegroundWindow TEXTEQU <AuditGetForegroundWindow>
PlaySoundA TEXTEQU <AuditPlaySoundA>
CopyFileA PROTO :DWORD, :DWORD, :DWORD
include include/engine_defs.inc
include include/font8x8.inc
include include/engine_data.inc
include include/engine_math.inc
include include/engine_map.inc
include include/engine_textures.inc
include include/engine_input.inc
include include/engine_physics.inc
include include/engine_raycaster.inc
include include/engine_render2d.inc
include include/engine_cutscene.inc
include startup_parser.inc
include project_paths.inc
.data
resultCount dd 0
results dd 256 dup(0)
resultPath db 'engine_results.bin',0
anglePath db 'angles.bin',0
framePath db 'frame.bgra',0
projectFramePath db 'project_frame.bgra',0
subPath db 'sublevel_02.dat',0
mainPath db 'map.dat',0
arenaPath db 'arena.dat',0
blackPath db 'black.dat',0
badPath db 'truncated.dat',0
missingPath db 'missing.dat',0
legacy1Path db 'legacy1.dat',0
legacy2Path db 'legacy2.dat',0
badNamePath db 'badname.dat',0
blockedPath db 'badspawn.dat',0
overflowPath db 'overflow.dat',0
orphanPath db 'orphan.dat',0
duplicatePath db 'duplicate.dat',0
validPack db 'assets/imported.bin',0
badPack db 'assets/badpack.bin',0
defaultPixels db 'assets/defaults.bin',0
defaultFile dd 0
defaultBytes dd 0
testKeys dw 256 dup(0)
testFocus dd 1
snapshot dd 6 dup(0)
CHECK MACRO value, expected
mov eax, value
cmp eax, expected
sete al
movzx eax, al
mov ecx, resultCount
mov [results+ecx*4], eax
inc resultCount
ENDM
.code
AuditGetAsyncKeyState PROC key:DWORD
mov ecx, key
movzx eax, WORD PTR [testKeys+ecx*2]
ret
AuditGetAsyncKeyState ENDP
AuditGetForegroundWindow PROC
mov eax, testFocus
ret
AuditGetForegroundWindow ENDP
Dump PROC uses ebx, path:DWORD, dataPtr:DWORD, dataSize:DWORD
LOCAL file:DWORD, written:DWORD
invoke CreateFileA, path, GENERIC_WRITE, 0, 0, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0
mov file, eax
invoke WriteFile, file, dataPtr, dataSize, ADDR written, 0
invoke CloseHandle, file
ret
Dump ENDP
CaptureState PROC uses eax esi edi ecx
mov eax, playerX
mov snapshot, eax
mov eax, playerY
mov snapshot[4], eax
mov eax, spriteCount
mov snapshot[8], eax
mov eax, transitionCount
mov snapshot[12], eax
mov eax, DWORD PTR [gameMap]
mov snapshot[16], eax
mov eax, mapCeilingColor
mov snapshot[20], eax
ret
CaptureState ENDP
StateUnchanged PROC uses ebx
mov eax, playerX
cmp eax, snapshot
jne state_changed
mov eax, playerY
cmp eax, snapshot[4]
jne state_changed
mov eax, spriteCount
cmp eax, snapshot[8]
jne state_changed
mov eax, transitionCount
cmp eax, snapshot[12]
jne state_changed
mov eax, DWORD PTR [gameMap]
cmp eax, snapshot[16]
jne state_changed
mov eax, mapCeilingColor
cmp eax, snapshot[20]
jne state_changed
mov eax, 1
ret
state_changed:
xor eax, eax
ret
StateUnchanged ENDP
include tests/cutscene_engine_checks.inc
AuditMain PROC
call InitMathTables
invoke Dump, OFFSET anglePath, OFFSET tableRayAngleOffset, SCREEN_W*4
call ParseStartupMap
CHECK DWORD PTR [startupMapPath], 65636170h ; quoted command-line start map (pace...)
call GenerateDefaultTextures
invoke CreateFileA, OFFSET defaultPixels, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0
mov defaultFile, eax
invoke ReadFile, defaultFile, OFFSET texturePackStaging, MAX_TEXTURES*TEX_SIZE_BYTES, OFFSET defaultBytes, 0
invoke CloseHandle, defaultFile
mov esi, OFFSET texturePackStaging
mov edi, OFFSET textureBuffer
mov ecx, MAX_TEXTURES*TEX_SIZE_BYTES/4
repe cmpsd
sete al
movzx eax, al
CHECK eax, 1 ; editor fallback previews match all engine default pixels
CHECK DWORD PTR [textureBuffer+10*TEX_SIZE_BYTES], COLOR_CHROMA_KEY ; default lamp exists independently of portal
invoke CopyFileA, OFFSET validPack, OFFSET szTexPackPath, 0
mov ecx, 128
dirty_stack:
push 0A5A5A5A5h
loop dirty_stack
add esp, 512
call LoadTexturePack
CHECK eax, 1 ; valid texture pack loads with dirty stack
CHECK DWORD PTR [textureBuffer+TEX_SIZE_BYTES], 00112233h ; imported PNG reaches ASM unchanged
CHECK DWORD PTR [textureBuffer+11*TEX_SIZE_BYTES], COLOR_CHROMA_KEY ; imported sprite transparency reaches ASM
invoke CopyFileA, OFFSET badPack, OFFSET szTexPackPath, 0
call LoadTexturePack
CHECK eax, 0 ; truncated texture pack rejected
CHECK DWORD PTR [textureBuffer+TEX_SIZE_BYTES], 00112233h ; failed pack load preserves current pixels
mov startupMapPath, 0
call LoadMapBinary
CHECK eax, 1 ; startup main map loads
CHECK spriteCount, 2 ; startup sprite count
CHECK transitionCount, 1 ; startup transition count
invoke SwitchSublevel, OFFSET subPath, 4, 6, 0, 0
CHECK spriteCount, 1 ; instant transition replaces sprites
CHECK playerX, 288 ; destination X spawn
CHECK playerY, 416 ; destination Y spawn
invoke SwitchSublevel, OFFSET mainPath, 2, 2, 0, 1
mov esi, 32
fade_frames:
call ApplyScreenFade
dec esi
jnz fade_frames
CHECK fadeState, 0 ; fade finishes and releases player
CHECK fadeLevel, 0 ; fade returns full brightness
CHECK spriteCount, 2 ; fade transition loads destination
invoke LoadSublevelBinary, OFFSET arenaPath, -1, -1, 0
CHECK eax, 1 ; map basename beginning with a resolves in assets
invoke LoadSublevelBinary, OFFSET legacy1Path, -1, -1, 0
CHECK eax, 1 ; legacy v1 accepted
CHECK DWORD PTR [spriteList], 224 ; legacy sprite coordinates converted
invoke LoadSublevelBinary, OFFSET legacy2Path, -1, -1, 0
CHECK eax, 1 ; legacy extended v2 accepted
CHECK transitionCount, 1 ; legacy v2 transitions retained
invoke LoadSublevelBinary, OFFSET blackPath, -1, -1, 0
CHECK eax, 1 ; black palette map loads
CHECK mapCeilingColor, 0 ; black ceiling applied
CHECK mapFloorColor, 0 ; black floor applied
call CaptureState
invoke SwitchSublevel, OFFSET missingPath, 2, 2, 0, 0
call StateUnchanged
CHECK eax, 1 ; missing destination preserves current room
CHECK mapErrorFrames, 180 ; failed transition reports an error
invoke LoadSublevelBinary, OFFSET badPath, -1, -1, 0
CHECK eax, 0 ; truncated map rejected
call StateUnchanged
CHECK eax, 1 ; truncated load preserves current room
invoke LoadSublevelBinary, OFFSET badNamePath, -1, -1, 0
CHECK eax, 0 ; unterminated target filename rejected
invoke LoadSublevelBinary, OFFSET blockedPath, -1, -1, 0
CHECK eax, 0 ; blocked spawn rejected
invoke LoadSublevelBinary, OFFSET overflowPath, -1, -1, 0
CHECK eax, 0 ; object count overflow rejected without clamping
invoke LoadSublevelBinary, OFFSET orphanPath, -1, -1, 0
CHECK eax, 0 ; portal without transition rejected
invoke LoadSublevelBinary, OFFSET duplicatePath, -1, -1, 0
CHECK eax, 0 ; duplicate triggers rejected
invoke LoadSublevelBinary, OFFSET mainPath, 0, 0, 0
CHECK eax, 0 ; transition spawn inside a wall rejected
call StateUnchanged
CHECK eax, 1 ; invalid spawn leaves current room intact
mov esi, OFFSET missingPath
mov edi, OFFSET startupMapPath
copy_missing_startup:
lodsb
stosb
test al, al
jnz copy_missing_startup
call LoadMapBinary
CHECK DWORD PTR [gameMap], 01010101h ; missing startup map creates the procedural fallback
CHECK spriteCount, 0 ; fallback clears stale sprites
CHECK transitionCount, 0 ; fallback clears stale transitions
mov startupMapPath, 0
; Re-entering an occupied destination trigger must not bounce back.
invoke LoadSublevelBinary, OFFSET subPath, 2, 2, 0
mov mapErrorFrames, 0
call CheckPortalTriggers
CHECK spriteCount, 1 ; destination trigger is latched on spawn
mov playerX, 224
call CheckPortalTriggers
mov playerX, 160
call CheckPortalTriggers
CHECK fadeState, 1 ; leaving and re-entering a trigger activates it
mov fadeState, 0
mov fadeLevel, 0
; Input APIs are mocked; PollInput itself is the production procedure.
mov hEngineWnd, 1
mov testKeys[VK_KEY_SHIFT*2], 8000h
call PollInput
movzx eax, keyRun
CHECK eax, 1 ; Shift starts sprint
mov testKeys[VK_KEY_SHIFT*2], 0
call PollInput
movzx eax, keyRun
CHECK eax, 0 ; releasing Shift stops sprint
mov testKeys[VK_KEY_E*2], 8000h
call PollInput
movzx eax, keyAction
CHECK eax, 1 ; action emitted on first key press
call PollInput
movzx eax, keyAction
CHECK eax, 0 ; held action does not repeat
mov testKeys[VK_KEY_E*2], 0
call PollInput
mov testKeys[VK_KEY_E*2], 8000h
call PollInput
movzx eax, keyAction
CHECK eax, 1 ; releasing action re-arms it
mov testFocus, 0
call PollInput
movzx eax, keyAction
CHECK eax, 0 ; unfocused engine ignores keyboard
; Flat wall at x=640, player x=160: every perpendicular depth is 480.
mov edi, OFFSET gameMap
mov ecx, MAP_SIZE
xor eax, eax
rep stosb
xor ecx, ecx
straight_wall:
mov eax, ecx
shl eax, 5
mov byte ptr [gameMap+eax+10], CELL_BRICK
inc ecx
cmp ecx, MAP_H
jl straight_wall
mov playerX, 160
mov playerY, 1024
mov playerAngle, 0
mov spriteCount, 0
call Render3DScene
mov esi, OFFSET depthBuffer
mov ecx, SCREEN_W
mov ebx, 1
check_flat_depth:
cmp DWORD PTR [esi], 480
je flat_depth_ok
xor ebx, ebx
flat_depth_ok:
add esi, 4
loop check_flat_depth
CHECK ebx, 1 ; all 320 columns of a flat wall have exactly the same depth
invoke Dump, OFFSET framePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
; Overlapping sprites are sorted far-to-near, independent of insertion order.
mov spriteCount, 2
mov esi, OFFSET spriteList
mov [esi].SpriteStruct.x, 288
mov [esi].SpriteStruct.y, 1024
mov [esi].SpriteStruct.scale, FIXED_ONE
mov [esi].SpriteStruct.textureId, 11
add esi, sizeof SpriteStruct
mov [esi].SpriteStruct.x, 416
mov [esi].SpriteStruct.y, 1024
mov [esi].SpriteStruct.scale, FIXED_ONE
mov [esi].SpriteStruct.textureId, 12
call Render3DScene
CHECK spriteOrder, 1 ; farther sprite draws first
CHECK spriteOrder[4], 0 ; nearer sprite draws last
; Sprite slot 10 can be replaced without repainting wall portals.
mov edi, OFFSET textureBuffer+10*TEX_SIZE_BYTES
mov ecx, TEX_SIZE_BYTES/4
mov eax, COLOR_RED
rep stosd
mov edi, OFFSET portalTextureBuffer
mov ecx, TEX_SIZE_BYTES/4
mov eax, COLOR_GREEN
rep stosd
mov byte ptr [gameMap+16*MAP_W+10], CELL_PORTAL
mov spriteCount, 0
call Render3DScene
CHECK DWORD PTR [screenBuffer+120*SCREEN_PITCH+160*4], 00007F00h ; portal uses separate artwork despite imported sprite slot 10
mov byte ptr [gameMap+16*MAP_W+10], CELL_BRICK
; Scaling a sprite changes its height, not the floor point at its depth.
mov edi, OFFSET textureBuffer+11*TEX_SIZE_BYTES
mov ecx, TEX_SIZE_BYTES/4
mov eax, COLOR_RED
rep stosd
mov spriteCount, 1
mov esi, OFFSET spriteList
mov [esi].SpriteStruct.scale, 2*FIXED_ONE
call Render3DScene
CHECK DWORD PTR [screenBuffer+179*SCREEN_PITCH+160*4], COLOR_RED ; scaled sprite still reaches its floor point
mov eax, DWORD PTR [screenBuffer+180*SCREEN_PITCH+160*4]
cmp eax, COLOR_RED
setne al
movzx eax, al
CHECK eax, 1 ; scaled sprite does not sink into the floor
; Cardinal rays must not divide by zero or wrap DDA distances.
mov spriteCount, 0
mov playerAngle, ANGLE_90
call Render3DScene
CHECK DWORD PTR [depthBuffer+160*4], 1024 ; exact cardinal ray handles zero X direction
call GenerateDefaultTextures
invoke CopyFileA, OFFSET projectPackPath, OFFSET szTexPackPath, 0
call LoadTexturePack
CHECK eax, 1 ; actual project texture pack loads
invoke LoadSublevelBinary, OFFSET projectMapPath, -1, -1, 0
CHECK eax, 1 ; actual project map loads in ASM
call Render3DScene
invoke Dump, OFFSET projectFramePath, OFFSET screenBuffer, FRAME_BUFFER_SIZE
call RunCutsceneChecks
mov eax, resultCount
inc eax
shl eax, 2
invoke Dump, OFFSET resultPath, OFFSET resultCount, eax
invoke ExitProcess, 0
AuditMain ENDP
END AuditMain

6
tests/playtest_stub.cpp Normal file
View file

@ -0,0 +1,6 @@
#include <fstream>
int main(int argc, char** argv) {
if (argc != 2) return 1;
std::ofstream("launched.txt") << argv[1];
return 0;
}

View file

@ -0,0 +1,65 @@
// Integration test: real x86 ASM producer -> x64 editor -> DX11 WARP texture.
static void RunViewportChecks(EditorApp& app, const char* actualEngine) {
std::error_code ec;
const auto testEngine = app.FindEnginePath();
fs::copy_file(actualEngine,testEngine,fs::copy_options::overwrite_existing,ec);
Check(!ec,"stage real ASM renderer for editor integration");
ID3D11Device* device=nullptr;
HRESULT hr=D3D11CreateDevice(nullptr,D3D_DRIVER_TYPE_WARP,nullptr,0,nullptr,0,D3D11_SDK_VERSION,&device,nullptr,nullptr);
Check(SUCCEEDED(hr),"DX11 software device supports the embedded viewport");
if(!device || ec) return;
app.LoadLevel("../assets/scene.dat"); app.showCutsceneTimeline=true; app.previewAttempted=false;
app.InitD3D(device);
auto pump = [&]() {
int frames=0; ULONGLONG until=GetTickCount64()+5000;
while(frames<3 && GetTickCount64()<until) {
if(app.previewPacket && InterlockedCompareExchange(&app.previewPacket->state,0,0)==2) ++frames;
app.UpdateEnginePreview();
if(!app.previewError.empty()) break;
Sleep(2);
}
return frames>=3 && app.previewError.empty();
};
Check(pump() && app.previewSRV && app.previewHasFrame,"real ASM frames arrive in the editor DX11 viewport");
auto before=app.previewPixels;
app.map[3][4]=CELL_METAL;
Check(pump() && app.previewPixels!=before,"unsaved geometry updates the live game view");
app.map[3][4]=CELL_EMPTY;
app.ScrubCutscene(2);
Check(pump() && app.previewPacket->x==224 && app.previewPacket->angle==0 && app.previewPacket->fade==8,
"timeline scrub drives the actual ASM viewport camera and fade");
if(app.previewTexture) {
D3D11_TEXTURE2D_DESC desc{};app.previewTexture->GetDesc(&desc);
desc.Usage=D3D11_USAGE_STAGING;desc.BindFlags=0;desc.CPUAccessFlags=D3D11_CPU_ACCESS_READ;
ID3D11Texture2D* staging=nullptr;
device->CreateTexture2D(&desc,nullptr,&staging);
ID3D11DeviceContext* context=nullptr;device->GetImmediateContext(&context);
bool equal=false;
if(staging) {
context->CopyResource(staging,app.previewTexture);
D3D11_MAPPED_SUBRESOURCE read{};
if(SUCCEEDED(context->Map(staging,0,D3D11_MAP_READ,0,&read))) {
equal=true;
for(int y=0;y<240;++y) if(memcmp(static_cast<const char*>(read.pData)+y*read.RowPitch,
app.previewPixels.data()+y*320,1280)) equal=false;
context->Unmap(staging,0);
}
staging->Release();
}
Check(equal,"DX11 texture pixels match the ASM framebuffer with opaque alpha");
context->Release();
}
app.StopEnginePreview();
Check(!app.previewProcess.hProcess && !app.previewPacket && !app.previewMapping,"closing viewport releases renderer process and mapping");
app.ShutdownD3D();device->Release();app.showCutsceneTimeline=false;
const auto demo=fs::path(actualEngine).parent_path()/"examples/cutscene_demo/assets/map.dat";
Check(app.LoadLevel(demo.string()),"isolated cutscene demo loads in the editor");
std::string start,executable;
Check(app.PreparePlaytest(start,executable),"demo F5 validates both rooms and packages its WAV");
app.CleanupPlaytest();
CutscenePreview demoPreview;
demoPreview.Start(app.playerStartX*64+32,app.playerStartY*64+32,app.playerStartAngle);
while(demoPreview.active && demoPreview.tick<1000) demoPreview.Step(app.cutscene);
Check(demoPreview.transition==0 && demoPreview.x==800 && demoPreview.y==544 && demoPreview.angle==0,
"demo camera reaches final waypoint and dispatches its room transition");
}

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Build the separate cutscene example; never modifies the user's assets/ maps."""
from pathlib import Path
import math
import struct
import wave
ROOT = Path(__file__).resolve().parents[1] / "examples/cutscene_demo"
def write_map(name, *, spawn, angle, events=(), strings=(), transitions=(), autoplay=False):
grid = bytearray(1024)
for x in range(32): grid[x] = grid[31*32+x] = 1
for y in range(32): grid[y*32] = grid[y*32+31] = 1
for y in range(1, 18): grid[y*32+16] = 3
for x, y in ((6,6),(10,5),(12,12),(4,10)): grid[y*32+x] = 7
for t in transitions: grid[t[1]*32+t[0]] = 10
sprites = [(7*64+32,5*64+32,11,65536,0,0),(11*64+32,7*64+32,10,65536,0,0)]
header = struct.pack("<I7HII4H4s",0x4E4C4245,4,32,32,*spawn,angle,len(sprites),0x303848,0x282018,
len(transitions),len(events),len(strings),int(autoplay),bytes(4))
payload = header+grid+b"".join(struct.pack("<6i",*s) for s in sprites)
payload += b"".join(struct.pack("<BB32sHHHBB",*t) for t in transitions)
payload += b"".join(struct.pack("<HBBiii",*e) for e in events)
payload += b"".join(s.encode("cp1251").ljust(96,b"\0") for s in strings)
(ROOT / "assets" / name).write_bytes(payload)
def main():
(ROOT / "assets/sounds").mkdir(parents=True,exist_ok=True)
# END, WAIT, MOVE, PAN, TEXT, HIDE, SOUND, FADE, LEVEL
events = [(0,7,0,16,0,0),(0,7,0,0,60,0),(0,6,0,1,0,0),(0,2,0,544,224,180),
(20,4,0,0,140,0),(180,3,0,128,90,0),(180,2,0,544,544,180),
(240,4,0,2,170,0),(360,3,0,0,90,0),(360,2,0,800,544,180),
(450,5,0,0,0,0),(540,7,0,16,60,0),(600,8,0,0,0,0),(601,0,0,0,0,0)]
write_map("map.dat",spawn=(3,3),angle=0,events=events,
strings=("Кат-сцена: камера, текст и звук", "chime.wav", "Tab — пропустить. После пролёта — другая комната."),
transitions=((15,8,b"second_room.dat",3,3,0,1,0),),autoplay=True)
write_map("second_room.dat",spawn=(3,3),angle=0,
transitions=((2,3,b"map.dat",3,3,0,1,0),))
with wave.open(str(ROOT / "assets/sounds/chime.wav"),"wb") as sound:
sound.setnchannels(1);sound.setsampwidth(2);sound.setframerate(16000)
sound.writeframes(b"".join(struct.pack("<h",int(4000*math.sin(2*math.pi*660*i/16000)*(1-i/8000))) for i in range(8000)))
print(ROOT)
if __name__ == "__main__": main()

View file

@ -44,7 +44,7 @@ def gen_brick():
r = g = b = 0x55
else:
n = (((x ^ y) & 7) << 2)
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
b = min(255, b + n) # match ASM packed-color noise (low byte)
out += px(r, g, b)
return bytes(out)
@ -57,7 +57,7 @@ def gen_wood():
r, g, b = 0x20, 0x10, 0x05
else:
n = ((y & 3) << 3)
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
b = min(255, b + n) # match ASM packed-color noise (low byte)
out += px(r, g, b)
return bytes(out)
@ -70,7 +70,7 @@ def gen_stone():
r = g = b = 0x22
else:
n = ((((x * 3) + y) & 15) << 2)
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
b = min(255, b + n) # match ASM packed-color noise (low byte)
out += px(r, g, b)
return bytes(out)
@ -134,7 +134,7 @@ def gen_marble():
r = g = b = 0x55
else:
n = (x ^ y) & 3
r = min(255, r + n); g = min(255, g + n); b = min(255, b + n)
b = min(255, b + n) # match ASM packed-color noise (low byte)
out += px(r, g, b)
return bytes(out)
@ -213,9 +213,9 @@ GENERATORS = {
10: gen_lamp,
11: gen_barrel,
12: gen_statue,
13: lambda: bytes(TEX_BYTES), # reserved
14: lambda: bytes(TEX_BYTES), # reserved
15: lambda: bytes(TEX_BYTES), # reserved
13: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
14: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
15: lambda: px(255, 0, 255) * (TEX_W * TEX_H), # transparent reserved sprite
}
def main():

87
tools/test_preview_ipc.py Normal file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Exercise the actual windowless ASM renderer through its public mailbox."""
import ctypes as C
import mmap
import os
from pathlib import Path
import struct
import subprocess
import time
ROOT = Path(__file__).resolve().parents[1]
SIZE, TEXTURES, PIXELS = 1357792, 2016, 1050592
def check_preview(engine, expected=None, output=None):
name = f"Local\\EblanPreview-test-{os.getpid()}-{time.monotonic_ns()}"
with mmap.mmap(-1, SIZE, tagname=name) as packet:
base = C.addressof(C.c_char.from_buffer(packet))
state = C.cast(base+8, C.POINTER(C.c_long))
struct.pack_into("<4I3i6I", packet, 0, 0x56504245, 1, 0, os.getpid(), 160, 1024, 0,
0x383838, 0x222222, 0, 0, 0, 1)
for y in range(32): packet[128+y*32+10] = 1
# Flat red wall, other slots black; proves texture upload rather than fallback.
packet[TEXTURES+65536:TEXTURES+2*65536] = struct.pack("<I",0xE02010)*16384
proc = subprocess.Popen([str(engine), "--preview:"+name], cwd=ROOT,
creationflags=subprocess.CREATE_NO_WINDOW)
def request():
# Aligned DWORD stores are atomic and release-ordered on x86/x64.
# Payload writes above finish before publishing the request.
state[0]=1
deadline = time.monotonic()+5
while state[0] != 2:
if proc.poll() is not None: raise AssertionError(f"Preview process exited: {proc.returncode}")
if time.monotonic()>deadline: raise AssertionError("Preview renderer did not answer")
time.sleep(.005)
return packet[PIXELS:PIXELS+307200]
try:
frame=request()
middle=struct.unpack_from("<I",frame,(120*320+160)*4)[0]
assert middle not in (0,0x383838,0x222222), "Wall was not rendered"
packet[128+16*32+5]=4
changed=request()
assert changed!=frame, "Unsaved wall change did not reach the renderer"
packet[128+16*32+5]=0
struct.pack_into("<2I",packet,40,1,16)
assert not any(request()), "Full cinematic fade is not black"
struct.pack_into("<2I",packet,40,1,0)
packet[1920:1920+7]="Привет".encode("cp1251")+b"\0"
subtitle=request()
assert not any(subtitle[:8*320*4]) and not any(subtitle[188*320*4:]), "Letterbox missing"
assert subtitle!=frame, "Subtitle overlay missing"
# Compare the live IPC output with a direct Render3DScene regression dump.
if expected:
data=(ROOT/"assets/map.dat").read_bytes()
h=struct.unpack_from("<I7HII",data)
version=h[1]; count=h[7]
struct.pack_into("<3i",packet,16,h[4]*64+32,h[5]*64+32,h[6])
struct.pack_into("<5I",packet,28,h[8],h[9],count,0,0)
packet[128:1152]=data[38:1062]
sprites=bytearray(data[1062:1062+count*24])
if version==1:
for i in range(count):
x,y=struct.unpack_from("<ii",sprites,i*24)
struct.pack_into("<ii",sprites,i*24,x>>16,y>>16)
packet[1152:1152+len(sprites)]=sprites
pack=(ROOT/"assets/textures/texturepack.bin").read_bytes()
packet[TEXTURES:TEXTURES+len(pack)-8]=pack[8:]
struct.pack_into("<I",packet,48,2)
actual=request()
assert actual==Path(expected).read_bytes(), "Viewport differs from the standalone ASM renderer"
if output:
header=struct.pack("<2sIHHI",b"BM",307254,0,0,54)
header+=struct.pack("<IiiHHIIiiII",40,320,-240,1,32,0,307200,0,0,0,0)
Path(output).write_bytes(header+actual)
state[0]=3
assert proc.wait(timeout=5)==0, "Preview did not exit cleanly"
print("PASS live ASM viewport: map edits, texture upload, letterbox, subtitles, fade and clean shutdown")
if expected: print("PASS live viewport pixels exactly match the standalone ASM renderer")
finally:
if proc.poll() is None: proc.terminate(); proc.wait(timeout=5)
del state
if __name__ == "__main__":
check_preview(ROOT/"engine.exe")

194
tools/test_regressions.py Normal file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Compile and run real C++/ASM regression scenarios in an isolated TEMP tree.
Requires the same MASM32 and MSVC tools as the normal builds. No test opens the
editor/game window or writes the project's levels, textures or executables.
Use --keep to retain the generated framebuffer and fixtures for inspection.
"""
import argparse
import hashlib
import importlib.util
import math
import os
from pathlib import Path
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import wave
ROOT = Path(__file__).resolve().parents[1]
def run(args, cwd):
result = subprocess.run(args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=120, errors="replace")
if result.returncode:
print(result.stdout)
raise RuntimeError(f"Failed ({result.returncode}): {args[0]}")
return result.stdout
def fixtures(work):
assets = work / "assets"
assets.mkdir()
(work / "editor").mkdir()
grid = bytearray(1024)
for x in range(32):
grid[x] = grid[31 * 32 + x] = 1
for y in range(32):
grid[y * 32] = grid[y * 32 + 31] = 1
def level(name, *, version=3, count=2, transitions=None, black=False, spawn=(2, 2), cells=None):
cells = bytearray(grid if cells is None else cells)
transitions = [(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] if transitions is None else transitions
for t in transitions:
cells[t[1]*32+t[0]] = 10
h = struct.pack("<I7HIIH10s", 0x4E4C4245, version, 32, 32, *spawn, 0, count,
0 if black else 0x383838, 0 if black else 0x222222,
len(transitions), bytes(10))
sprites = b"".join(struct.pack("<6i", 224 << (16 if version == 1 else 0),
(224 + i * 64) << (16 if version == 1 else 0), 11, 65536, 0, 0)
for i in range(count))
data = h + cells + sprites + b"".join(struct.pack("<BB32sHHHBB", *t) for t in transitions)
(assets / name).write_bytes(data)
return data
good = level("map.dat")
level("sublevel_02.dat", count=1, spawn=(3, 2), transitions=[(2, 2, b"map.dat", 5, 2, 0, 1, 0)])
level("arena.dat", count=1, transitions=[])
level("legacy1.dat", version=1, count=1, transitions=[])
level("legacy2.dat", version=2)
level("black.dat", black=True)
# Deliberately malformed v4 payloads; ordinary fixtures stay v1..v3.
def bad_scene(name, events, strings=(b"text",), flags=0):
header = bytearray(good[:38]); struct.pack_into("<H",header,4,4)
struct.pack_into("<HHH",header,28,len(events),len(strings),flags)
payload = bytes(header)+good[38:]+b"".join(struct.pack("<HBBiii",*e) for e in events)
payload += b"".join(text.ljust(96,b"\0") for text in strings)
(assets/name).write_bytes(payload)
bad_scene("scene_badop.dat",[(0,99,0,0,0,0),(1,0,0,0,0,0)])
bad_scene("scene_badref.dat",[(0,4,0,12,0,0),(1,0,0,0,0,0)])
bad_scene("scene_badtext.dat",[(0,4,0,0,0,0),(1,0,0,0,0,0)],(b"x"*96,))
bad_scene("scene_badorder.dat",[(3,4,0,0,0,0),(1,0,0,0,0,0)])
bad_scene("scene_badflags.dat",[(1,0,0,0,0,0)],flags=2)
bad_scene("scene_noend.dat",[(0,4,0,0,0,0)])
(assets / "truncated.dat").write_bytes(good[:48])
level("overflow.dat", count=33)
level("badname.dat", transitions=[(4, 2, b"x" * 32, 3, 2, 0, 1, 0)])
level("badspawn.dat", spawn=(0, 0))
orphan = bytearray(grid); orphan[2 * 32 + 4] = 10
level("orphan.dat", transitions=[], cells=orphan)
level("duplicate.dat", transitions=[(4, 2, b"sublevel_02.dat", 3, 2, 0, 1, 0)] * 2)
(assets / "badpack.bin").write_bytes(struct.pack("<IHH", 0x58544245, 1, 16) + bytes(12))
with wave.open(str(work / "editor/quiet.wav"), "wb") as sound:
sound.setnchannels(1); sound.setsampwidth(2); sound.setframerate(8000)
sound.writeframes(bytes(800))
def main():
sys.dont_write_bytecode = True
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keep", action="store_true")
args = parser.parse_args()
work = Path(tempfile.mkdtemp(prefix="eblan-regressions-"))
watched = list((ROOT / "assets").rglob("*"))
original = {p: hashlib.sha256(p.read_bytes()).digest() for p in watched if p.is_file()}
succeeded = False
try:
fixtures(work)
vcvars_candidates = [Path(os.environ.get("ProgramFiles(x86)", "C:/Program Files (x86)")) /
"Microsoft Visual Studio/18/BuildTools/VC/Auxiliary/Build/vcvars64.bat"]
vcvars_candidates += list(Path("C:/Program Files/Microsoft Visual Studio").glob("*/BuildTools/VC/Auxiliary/Build/vcvars64.bat"))
vcvars = next((p for p in vcvars_candidates if p.is_file()), None)
if not vcvars:
raise RuntimeError("MSVC vcvars64.bat was not found")
sources = [ROOT / "tests/editor_regressions.cpp"] + [ROOT / "editor/imgui" / name for name in
("imgui.cpp", "imgui_draw.cpp", "imgui_tables.cpp", "imgui_widgets.cpp")]
flags = "/nologo /O2 /W3 /EHsc /std:c++17 /utf-8 /DUNICODE /D_UNICODE /DWIN32_LEAN_AND_MEAN /DNOMINMAX /D_CRT_SECURE_NO_WARNINGS /DNDEBUG"
batch = f'@echo off\ncall "{vcvars}" >nul 2>&1\n'
batch += f'cl {flags} "{ROOT / "tests/playtest_stub.cpp"}" /Fe:engine.exe\nif errorlevel 1 exit /b 1\n'
batch += f'cl {flags} ' + " ".join(f'"{p}"' for p in sources)
batch += ' /Fe:editor_tests.exe /link /SUBSYSTEM:CONSOLE d3d11.lib dxgi.lib user32.lib gdi32.lib shell32.lib comdlg32.lib winmm.lib\n'
(work / "build_tests.bat").write_text(batch)
run(["cmd", "/c", str(work / "build_tests.bat")], work)
print(run([str(work / "editor_tests.exe"), str(ROOT / "assets"), str(ROOT / "engine.exe")], work / "editor"), end="")
spec = importlib.util.spec_from_file_location("texturepack", ROOT / "tools/make_texturepack.py")
texturepack = importlib.util.module_from_spec(spec)
spec.loader.exec_module(texturepack)
generated = b"".join(texturepack.GENERATORS[i]() for i in range(16))
if generated != (work / "assets/defaults.bin").read_bytes():
raise RuntimeError("Python texture generator differs from editor/runtime defaults")
print("PASS Python, editor and engine use identical procedural texture defaults")
main_source = (ROOT / "main.asm").read_text(encoding="utf-8")
api = main_source[:main_source.index("; ------------------------------------------------------------------------------\n; Engine Includes")]
api = re.sub(r"^(GetAsyncKeyState|GetForegroundWindow|PlaySoundA)\s+PROTO.*\n", "", api, flags=re.M)
(work / "engine_api.inc").write_text(api)
start = main_source.index("ParseStartupMap PROC")
end = main_source.index("ParseStartupMap ENDP", start) + len("ParseStartupMap ENDP")
(work / "startup_parser.inc").write_text(main_source[start:end])
(work / "project_paths.inc").write_text(
f'.data\nprojectMapPath db "{ROOT / "assets/map.dat"}",0\n'
f'projectPackPath db "{ROOT / "assets/textures/texturepack.bin"}",0\n.code\n')
masm_candidates = [Path(p) for p in ("D:/MASM32/masm32/bin/ml.exe", "C:/masm32/bin/ml.exe", "F:/MASM32/masm32/bin/ml.exe")]
masm_candidates += list(Path("C:/Program Files (x86)/Microsoft Visual Studio").glob("*/BuildTools/VC/Tools/MSVC/*/bin/Hostx64/x86/ml.exe"))
ml = next((p for p in masm_candidates if p.is_file()), None)
if not ml:
raise RuntimeError("x86 MASM assembler was not found")
sdk = sorted(Path("C:/Program Files (x86)/Windows Kits/10/Lib").glob("*/um/x86"))
if not sdk:
raise RuntimeError("Windows x86 SDK libraries were not found")
run([str(ml), "/c", "/coff", "/Cp", "/nologo", f"/I{work}", f"/I{ROOT}",
f"/Fo{work / 'engine_tests.obj'}", str(ROOT / "tests/engine_regressions.asm")], work)
link = ml.with_name("link.exe")
run([str(link), "/NOLOGO", "/SUBSYSTEM:CONSOLE", f"/LIBPATH:{sdk[-1]}",
f"/OUT:{work / 'engine_tests.exe'}", str(work / "engine_tests.obj"),
"kernel32.lib", "user32.lib", "gdi32.lib", "winmm.lib"], work)
run([str(work / "engine_tests.exe"), "pace map.dat"], work)
source = (ROOT / "tests/engine_regressions.asm").read_text() + (ROOT / "tests/cutscene_engine_checks.inc").read_text()
names = re.findall(r"^\s+CHECK .*?; (.+)$", source, re.M)
raw = (work / "engine_results.bin").read_bytes()
values = struct.unpack(f"<{len(raw)//4}I", raw)
if values[0] != len(names):
raise RuntimeError(f"ASM result count mismatch: {values[0]} vs {len(names)}")
for name, passed in zip(names, values[1:]):
print(("PASS " if passed else "FAIL ") + name)
angles = struct.unpack("<320i", (work / "angles.bin").read_bytes())
expected = tuple(round(math.atan((x-160)/160*math.tan(48*math.pi/256))*256/math.pi) for x in range(320))
if angles != expected or not all(values[1:]):
raise RuntimeError("ASM regressions failed")
print(f"ASM: {len(names)}/{len(names)} passed; 320 ray angles match the specification")
frame = (work / "frame.bgra").read_bytes()
bitmap_header = struct.pack("<2sIHHI", b"BM", len(frame)+54, 0, 0, 54)
bitmap_header += struct.pack("<IiiHHIIiiII", 40, 320, -240, 1, 32, 0, len(frame), 0, 0, 0, 0)
(work / "flat_wall.bmp").write_bytes(bitmap_header + frame)
project_frame = (work / "project_frame.bgra").read_bytes()
(work / "project_preview.bmp").write_bytes(bitmap_header + project_frame)
for path, digest in original.items():
if hashlib.sha256(path.read_bytes()).digest() != digest:
raise RuntimeError(f"Test changed a project asset: {path}")
reference = (work / "assets/cutscene_reference.bin").read_bytes()
if (work / "cutscene_trace.bin").read_bytes() != reference:
raise RuntimeError("ASM and editor cinematic players disagree on frame-by-frame state")
print("PASS editor and ASM cutscene state agrees on every tested frame")
viewport_spec = importlib.util.spec_from_file_location("preview_ipc", ROOT / "tools/test_preview_ipc.py")
viewport_tests = importlib.util.module_from_spec(viewport_spec)
viewport_spec.loader.exec_module(viewport_tests)
viewport_tests.check_preview(ROOT / "engine.exe",work / "project_frame.bgra",work / "live_viewport.bmp")
(work / "cutscene_preview.bmp").write_bytes(bitmap_header + (work / "cutscene_frame.bgra").read_bytes())
print("PASS original project assets remain byte-for-byte unchanged")
succeeded = True
finally:
if args.keep or not succeeded:
print(f"Test artifacts: {work}")
else:
# Only delete the verified dedicated directory created above.
if work.resolve().parent == Path(tempfile.gettempdir()).resolve() and work.name.startswith("eblan-regressions-"):
shutil.rmtree(work)
if __name__ == "__main__":
main()