# DS1Edit - Diablo II Level Editor
A level editor for Diablo II that allows creation and modification of game maps (.ds1 files). Built with Allegro 5 for GPU-accelerated rendering at 60+ FPS. Runs on Windows, Linux, and macOS.
DS1Edit is a continuation of [Paul Siramy's `win_ds1edit`](http://paul.siramy.free.fr/_divers/ds1/dl_ds1edit.html), the Diablo II community's DS1 editor since 2002, ported from Allegro 4 to Allegro 5 and extended. See [Acknowledgments](#acknowledgments) and [NOTICE](NOTICE).
## Configure once. Launch. Choose your map.
Point DS1Edit at your Diablo II installation in `Ds1edit.ini`, then launch the
editor and choose maps from its built-in browser. Everyday use needs no
command-line arguments, map IDs, or separate launcher.

*The editor in action: browse maps by Act, edit the map, and
inspect its properties in the same window.*
### Why try this continuation?
- **Easier to get started:** one install-folder setting resolves the standard
game archives; maps and assets load directly from your MPQs.
- **Quicker iteration:** GPU-accelerated rendering and tilesets that stay loaded
when switching maps. The [2.1.0 release measurements](https://github.com/bethington/d2-ds1-edit/releases/tag/v2.1.0)
improved first-map loading from 23.3 seconds to 0.5 seconds, and another map
sharing its tileset from 9.4 seconds to 2 milliseconds compared with earlier
builds of this continuation.
- **Built-in map discovery:** browse areas grouped by Act, including an Unlisted
group for map fragments the game tables do not name.
- **A workflow for your mod:** loose files in a configured mod folder take
priority over base-game assets, without repacking archives.
- **Modern platform support:** Windows, Linux, and macOS builds, plus a map
viewport that grows when you resize the window.
- **More export options:** full-map PNGs, tile and sprite frame exports, and
layered animation composition to animated PNG.
See a full-map PNG export of Harrogath

*An existing reference render showing full-map PNG output.*
## Download
**[Download the latest release](https://github.com/bethington/d2-ds1-edit/releases/latest)** — Windows, Linux, and macOS builds are published for every tag.
| Platform | Package | Runtime dependency |
|----------|---------|--------------------|
| Windows (x86) | `DS1Edit--win32.zip` | none — Allegro 5 DLLs and `vcruntime140.dll` are bundled; needs Windows 10 or later (its Universal C Runtime) |
| Linux (x86_64) | `DS1Edit--linux-x86_64.tar.gz` | system Allegro 5 and a system OpenGL driver (see below) |
| macOS (arm64) | `DS1Edit--macos-arm64.tar.gz` | `brew install allegro` |
Unpack anywhere and run `ds1edit` (`ds1edit.exe` on Windows, `ds1edit.app` on macOS). There is no installer.
**Windows** needs no separate Visual C++ Redistributable: the package carries the
x86 `vcruntime140.dll`. It does rely on the Universal C Runtime, which is part of
Windows 10 and later.
**Linux** needs Allegro 5 from your package manager, plus a working OpenGL
driver (Mesa or your GPU vendor's; the editor links the system `libGL`):
```bash
# Debian / Ubuntu
sudo apt install liballegro5.2 liballegro-image5.2 liballegro-ttf5.2 liballegro-dialog5.2
# Fedora
sudo dnf install allegro5 allegro5-addon-image allegro5-addon-ttf
```
**macOS** packages contain `ds1edit.app` (the entry point to launch; Input
Monitoring, which Allegro needs for the keyboard, is granted to it) beside the
plain `ds1edit` binary and `assets/` (keep the app beside `assets/`). The app is ad-hoc signed, not notarized,
so Gatekeeper will refuse the first launch. Either right-click the app and
choose Open, or clear the quarantine flag:
```bash
xattr -dr com.apple.quarantine ds1edit.app ds1edit
```
The macOS package is built and checked by CI but its GUI launch has not yet been
tried on a Mac by the maintainers; see [docs/release-readiness.md](docs/release-readiness.md).
You need your own copy of Diablo II (Classic or LoD) — the editor reads tiles and sprites straight out of your MPQs and ships no game assets.
## Quick Start
1. [Download the latest release](https://github.com/bethington/d2-ds1-edit/releases/latest)
for your platform and unpack it. See the runtime dependencies above.
2. Copy `Ds1edit.ini.sample` to `Ds1edit.ini` beside the executable and set your
Diablo II install folder:
```ini
d2_install = C:\Diablo2
```
You can leave `d2_install` blank: the editor then looks for Diablo II itself
(see [Configuration](#configuration)).
3. Launch `ds1edit.exe` on Windows, `ds1edit` on Linux, or `ds1edit.app` on macOS, with no arguments.
Choose a map from the built-in browser.
You do not need to enter a DS1 path, LevelType ID, or LevelDef value to browse
and open maps. Command-line options remain available for automation and direct
file opening. To compile the editor yourself, see [Building](#building).
## Features
- **DS1 Map Editing** - Create and modify Diablo II level files for Acts 1-5
- **GPU-Accelerated Rendering** - VIDEO bitmap pipeline with ~1ms render time per frame
- **D2 Blend Modes** - Correct additive, multiply, and screen blending (reverse-engineered from D2CMP.dll)
- **Object Animation** - DCC/DC6 sprite rendering with shadow projection
- **Multi-Zoom** - 7 zoom levels from 1:1 to 1:16
- **Keyboard navigation** - arrow keys and **WASD** scroll the map; **Ctrl+arrows** browse maps; **Ctrl+Z** / **Ctrl+U** undo. Edge-of-window mouse scrolling is off unless you set `mouse_edge_scroll = YES` in `Ds1edit.ini` (see [Controls](#controls))
- **Checked saves** - a save goes through a staging file, keeps a numbered backup, and rolls back on failure; a failed save is reported and never closes the editor
- **Project Diablo 2** - detected automatically and read ahead of the vanilla archives, or pinned off with `use_pd2 = no`
- **Reads your MPQs directly** - maps, tiles, sprites, palettes and data tables, with a mod-directory overlay; nothing has to be extracted first
- **Headless Mode** - Command-line screenshot rendering for CI/testing
- **Frame-rate tooling** - `--selftest` frame-time report and `scripts/perf/fps_test.py` (see [Troubleshooting](#troubleshooting))
- **Performance Profiling** - Optional per-frame and per-section timing (`-DDS1EDIT_PERF_LOG=ON`)
## Building
Requirements: CMake 3.21 or later, a C compiler (Visual Studio 2019 or later on
Windows), Allegro 5 (vcpkg on Windows, system packages elsewhere), and Python 3 with
Pillow for the test suite (`pip install -r test/python/requirements.txt`). See
[BUILDING.md](BUILDING.md).
```bash
# Windows -- Visual Studio 2022 (x86); uses build/vcpkg_installed/x86-windows
cmake --preset default # Configure the VS2022 build in tmp/build-vs2022
cmake --build --preset dev # Dev build (optimized + debug symbols)
cmake --build --preset release # Release build (full optimization + LTCG)
# Linux -- Ninja + system Allegro 5
sudo apt install build-essential ninja-build cmake pkg-config zlib1g-dev liballegro5-dev liballegro-image5-dev liballegro-ttf5-dev liballegro-dialog5-dev
cmake --preset linux && cmake --build --preset linux
# macOS -- Ninja + Homebrew Allegro 5
brew install allegro ninja pkg-config
cmake --preset macos && cmake --build --preset macos
```
### Build Presets
| Preset | Config | Use |
|--------|--------|-----|
| `dev` | RelWithDebInfo | Day-to-day development, F5 debugging |
| `release` | Release | Distribution, maximum performance |
| `debug` | Debug | Deep debugging (slower rendering) |
### VSCode Integration
- **F5** builds and launches with the `dev` preset
- Launch dropdown has configs for every Act/area
- "Run DS1Edit" configs launch without debugger for full speed
## Testing
```bash
pip install -r test/python/requirements.txt # Python test dependencies (Pillow)
ctest --preset fast # ~20 s: every portable unit suite
ctest --preset check # ~35 s: fast + golden core, adversarial slice, display test
ctest --preset default # Windows (VS2022 build): every suite (also: full)
ctest --preset linux --output-on-failure # Linux (or: macos, ci)
ctest --preset default -L portable # only the portable suites
python -m unittest discover -s test/python -t . -p "test_*.py" # the Python suites directly
python scripts/run_golden_tests.py --core --tolerance 4 # golden screenshots
```
`fast`, `check` and `full` are tiers (see "Test tiers" in [BUILDING.md](BUILDING.md)):
use `fast` while working, `check` before pushing, `full` for releases. Each family
has its own names (`ci-fast`, `linux-check`, `macos-full`, ...). Tests run
without flashing console windows, and tests that open the editor set
`DS1EDIT_TEST_OFFSCREEN=1`, which keeps its window off-screen and non-activating,
so you can keep typing while they run. `golden_core` and `display_selftest` need a
vanilla 1.13c runtime you own: configure with `-DDS1EDIT_GOLDEN_RUNTIME=`
(without it they report `Not Run (Skipped)`, never a pass).
About 75 CTest suites run: native Unity suites for the parsers, decoders, editor
logic and CLI, and `py_*` Python suites that CMake registers from `test/python/`
(label `portable`). If Python is not found at configure time, a failing
`py_unavailable` test is registered on purpose so a missing interpreter cannot
look like a green run.
Suites labelled `portable` use only authored fixtures and need no Diablo II
files. Suites that need a licensed installation or a live reference are local
lanes, never run in public CI: the frame-rate test (`perf_fps`, needs
`-DDS1EDIT_GAME_DIR=`), the Project Diablo 2 lane (`pd2_*`, registered only with
`-DDS1EDIT_PD2_DIR=`; see "Project Diablo 2 lane" in [BUILDING.md](BUILDING.md)),
and the corpus and live-equivalence lanes in
[test/equivalence/README.md](test/equivalence/README.md).
### Golden Screenshot Tests
`scripts/run_golden_tests.py` renders maps in headless mode and compares them
with reference PNGs in `test/golden/`. It is strict: exit **0** only when every
selected map passes, **1** when any map fails or is skipped (a missing
reference or INI is a skip), **2** for an invalid command line or
configuration. Each run writes a new `tmp/golden-runs/run-*/` directory with
`results.json` and the rendered images; `--update` replaces references
atomically. Inputs must be pinned (the `visual-core.json` profile used by
`scripts/equivalence/render_cases.py`) for a comparison to mean anything. The six
core references were regenerated from pinned vanilla 1.13c inputs for 2.2.0 and
pass at tolerance 4.
### Unit Tests
Examples: `test_palette` (colour matching), `test_rgba_cache` (tile cache),
`test_dt1_decode` (DT1 sub-tile decoder), `test_ds1_save` and `test_editor_undo`
(save and undo behaviour). Run `ctest --preset default -N` for the full list.
## Command Line
Run `ds1edit help` for the full verb list. The area-browser verbs:
```bash
ds1edit list-areas # every area, grouped by Act
ds1edit list-files [filter] # DS1 files with their LvlType / Def
ds1edit audit-lvltypes # LvlTypes rows whose name disagrees with its Act
ds1edit --headless out.png
ds1edit --area "Act 1 - Town" --headless out.png
```
The legacy `--list-areas` / `--list-files` / `--audit-lvltypes` spellings still work.
`audit-lvltypes` prints every mismatch between a `LvlTypes.txt` row's `Act X - ...` name prefix and its authoritative `Act` column.
### How a map picks its palette
`LvlTypes.txt`'s **`Act` column** is the source of truth, not the row name and not the
DS1. The chain is:
```
LvlPrest.txt (Def) -> Levels.txt (LevelType) -> LvlTypes.txt (Act + File 1..32)
| |
palette the DT1 set
```
`palette_resolve_act()` takes that `Act` when it is 1-5, falls back to the DS1's own
act, and defaults to 1. The palette index is `act - 1`, selecting `Act1..Act5/Pal.pl2`
straight from your MPQs.
Maps that no table names -- the maze and room fragments the game assembles
procedurally -- are grouped per Act as **Unlisted**, with their tileset inferred from
the folder (its listed neighbours first, then a group whose name matches the folder).
## Configuration
Copy `Ds1edit.ini.sample` to `Ds1edit.ini` and point it at your Diablo II install:
```ini
d2_install = C:\Diablo2
```
That one line fills every MPQ slot. You can even leave it blank: the editor finds
Diablo II on its own and logs what it found at startup. It tries, in order,
`HKCU\Software\Blizzard Entertainment\Diablo II`, the same key under HKLM (64-bit and
WOW6432Node views), the `Diablo II` Uninstall entry (HKCU, HKLM, WOW6432Node), then
common folders; a registry path that does not exist falls through to the next step.
Windows reads the registry; Linux and macOS probe common folders. **Nothing needs extracting** -- maps, tiles,
sprites, palettes and the data tables are all read straight out of the archives, and
the whole map list appears in the browser. Set `mod_dir` and your mod's loose files
take priority over the base game without repacking anything.
Individual archives can still be named explicitly if you need to override the
auto-detected set:
```ini
d2char = C:\Diablo2\d2char.mpq
d2data = C:\Diablo2\d2data.mpq
d2exp = C:\Diablo2\d2exp.mpq
patch_d2 = C:\Diablo2\patch_d2.mpq
```
See the sample file for all available settings (resolution, scroll speed, gamma, etc.).
`mouse_edge_scroll` (default `NO`) turns on scrolling when the mouse touches the
window border; see [Controls](#controls) for the keys.
`stderr.txt`, written in the folder you run the editor from, holds the startup
log (detected install, data set in use, display driver and renderer) and any
warnings. Attach it to bug reports.
### Project Diablo 2
If Project Diablo 2 is installed (its `ProjectD2` folder holds `pd2data.mpq`), the editor
finds it on its own and reads PD2's data and maps instead of the vanilla ones. It looks
at `pd2_dir`, PD2's Uninstall entry, and the `ProjectD2` folder next to Diablo II. An
automatically found PD2 is used only if it belongs to the Diablo II install the editor
selected; one that belongs to a different install is skipped (startup logs that it was
not used). An explicit `pd2_dir` is always honoured.
```ini
; auto (default) | yes (require it) | no (vanilla only)
use_pd2 = auto
; blank = detect; or e.g. C:\Diablo2\ProjectD2
pd2_dir =
```
PD2's archives are searched after `mod_dir` and before the base game's, in this order:
`pd2data.mpq`, `pd2maps.mpq`, `pd2assets.mpq`, `pd2monchars.mpq`, `ProjectD2\patch_d2.mpq`,
then `patch_d2`, `d2exp`, `d2data`, `d2char`. PD2 ships no readable load list, so this order
is the editor's assumption: the first four are the order PD2's own DLL names them, with the
folder's patch last. `D2MultiRes`, `SGD2FreeRes`, `d2gl` and `ver-IX86-1` only hold display
helpers and are not opened. Startup prints a line such as
`d2install: Project Diablo 2 data from (use_pd2=auto, ...)`; with
`use_pd2 = no` it prints `vanilla Diablo II data` instead. `use_pd2 = yes` logs an error and
falls back to vanilla data if PD2 cannot be found. Explicit `d2char`/`d2data`/`d2exp`/`patch_d2`
paths and `mod_dir` still win over detection. To pin vanilla data for reproducible
runs, set `use_pd2 = no`. The command-line equivalents are `--pd2-dir=` and `--use-pd2=`.
## Controls
| Input | Action |
|-------|--------|
| Arrow keys, **WASD** | Scroll the map |
| **Ctrl+arrows** | Previous / next map in the area browser (plain arrows no longer do this) |
| Mouse at the window edge | Scroll, only with `mouse_edge_scroll = YES` (default `NO`) |
| **Ctrl+Z** | Undo the last map edit. While a properties-panel field is being edited, the panel owns Ctrl+Z |
| **Ctrl+U** | Undo tile edits; acts once per key press |
| **Alt+I** | Select strictly identical tiles |
| **Alt**-drag | Box-select objects |
| **Ctrl+S** | Save the map (see below) |
Scroll speeds are `keyb_scroll_x/y` and `mouse_scroll_x/y` in `Ds1edit.ini`. The
[manual](docs/getting-started/manual.md) describes the rest of the editing keys.
### Saving and out-of-bounds objects
A save writes a staging file first, renames the existing map to the first free
`name-000.ds1`, `name-001.ds1`, ... backup, then moves the new file into place, and
rolls everything back if a step fails. A failed save shows an error and leaves the
editor open with your edits intact; it never ends the program.
Objects that lie outside the map's `width*5` by `height*5` grid (about 80 objects in
38 vanilla maps) are kept, drawn with a warning tint, selectable, and saved.
## Troubleshooting
**The editor is slow (a few frames per second, or exit takes seconds).** A
bitmap that fell back to system memory draws a correct picture at a fraction
of the speed. Look for a `render: WARNING -- ... MEMORY bitmap` line in
`stderr.txt`, and run `ds1edit assets/act1_town.ini --selftest=60`: it prints
the frame-time summary and fails if any memory bitmap was drawn. To measure
across maps, drivers and window sizes, use `python scripts/perf/fps_test.py
--exe ` and read its table as described under "Frame-rate
test" in [BUILDING.md](BUILDING.md); `render avg` above 16.7 ms at 1080p
cannot hold 60 fps.
**The window opens but the map area is black.** The status bar and the mouse
cursor draw, the frame rate looks normal, and nothing appears in the logs.
This is the graphics driver, and switching it fixes it. Which way you switch
depends on how you got the editor.
Allegro picks a backend per platform, and either choice can misbehave on a
given machine: Direct3D has been seen dropping to 0 fps, and OpenGL has been
seen rendering an all-black map exactly as described. An `allegro5.cfg` next
to the executable overrides the choice:
```ini
[graphics]
driver=opengl
```
- **From a release package**, there is no `allegro5.cfg` and you are on the
platform default. Create the file next to `ds1edit` with `driver=opengl`, or
`driver=direct3d` on Windows, and try the other one.
- **From a source build**, the repository ships an `allegro5.cfg` that pins
OpenGL, and CMake copies it next to the executable. Rename or delete it to
fall back to the platform default.
The editor prints the driver it ended up on at startup, so check `stderr.txt`
(or the console) for:
```text
display: 2560x1440, OpenGL driver
display: renderer "NVIDIA GeForce RTX 5060 Ti/PCIe/SSE2", vendor "NVIDIA Corporation"
```
**The editor draws correctly but at well under one frame per second.** The map
is right, the mouse works, nothing is in the logs except a very small number in
the `[perf]` block.
That is almost always the GPU not being used at all, and the `renderer` line
above says so. Two cases:
- **`renderer "GDI Generic"`** (or `llvmpipe` / `swrast` on Linux) is a software
rasterizer, not a GPU. The editor now warns about this by name. It means no
vendor driver is installed, or the OpenGL context could not reach the card --
a remote desktop session is the usual reason. Install the graphics vendor's
driver and run on the machine's own console.
- **The renderer names an integrated chip** (`Intel(R) UHD Graphics`, say) on a
laptop that also has a discrete card. The executable asks for the discrete GPU
on both NVIDIA and AMD, but a per-application setting can override that: check
NVIDIA Control Panel → Manage 3D Settings → Program Settings (or the Windows
Graphics settings page) and set `ds1edit.exe` to the high-performance GPU.
The `[perf]` block splits `render` from `present`, which narrows it further: time
in `render` is compositing the map, time in `present` is `al_flip_display`.
**Direct3D at well under one frame per second on a machine with a working GPU**
was a bug in versions before this fix: the built-in font's glyph bitmaps were
created before the display and stayed in system memory, so every text draw went
through a slow path. They are now converted to video bitmaps after the display
is created, and `ds1edit --selftest` prints the average render time and fails
above 250 ms per frame. If you still see it, report the `display:` and
`renderer` lines.
**Known issue: exiting in fullscreen on Direct3D can hang** while the display is
torn down (reproduced on Windows before 2.2.0). 2.2.0 frees the GPU bitmaps before
destroying the display, which should remove the hang, but that has not been
re-verified in fullscreen. If it still happens, use `full_screen = NO`, or end the
process from Task Manager, and report it with your `stderr.txt`.
## Project Structure
See [docs/structure.md](docs/structure.md) for detailed layout.
Key directories:
- `src/` - C source code organized into `core/`, `render/`, `editor/`, `ui/`, `mpq/`
- `assets/` - Runtime data (copied to `bin/` at build time); `assets/ui/` holds the editor's interface images, `assets/editor/` its data files
- `test/` - Unit tests (Unity framework), Python suites in `test/python/`
- `scripts/` - Golden test and equivalence/qualification scripts (Python)
- `bin/` - Build output directory (gitignored)
## Architecture
### Rendering Pipeline
7-pass compositing pipeline in `src/render/preview.c`, all GPU-accelerated:
1. Base terrain (floors, lower walls, tile shadows)
2. Object shadows (tinted black silhouettes)
3. Objects behind walls
4. Upper walls + objects in front
5. Roofs
6. Special tiles
7. Walkable info overlay
### Timing
- **Game tick**: 25 Hz (animation frame advancement)
- **Display**: Vsync rate (60-165 Hz depending on monitor)
- **Render work**: ~1ms per frame (GPU-bound, not CPU-bound)
### D2 Blend Modes
COF layer transparency (`trans_b` field) mapped to Allegro 5 blender states:
| trans_b | Mode | Formula |
|---------|------|---------|
| 0 | 75% transparent | Alpha 0.25 |
| 1 | 50% transparent | Alpha 0.50 |
| 2 | 25% transparent | Alpha 0.75 |
| 3 | Additive | min(src + dst, 255) |
| 4 | Multiply | (src * dst) / 255 |
| 6 | Screen | src + dst - src*dst/255 |
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make changes and add tests
4. Run `ctest --preset default` (or your platform's preset) to verify
5. Submit a pull request
## License
[MIT License](LICENSE) - Copyright (c) 2025-2026 Ben Ethington, covering the
modifications and additions made in this project.
The original `win_ds1edit` code this project grew out of was written by Paul
Siramy and released publicly with every build from 2002 to 2011, without a
formal license text; it is used here with attribution, not relicensed. The MPQ
reader in `src/mpq/` is Tom Amigo's Stormless MPQ sample code. [NOTICE](NOTICE)
has the full attribution and file-by-file provenance, and ships in every release
package next to LICENSE.
## Acknowledgments
This project stands entirely on **Paul Siramy's** original `win_ds1edit` — the DS1
map editor that the Diablo II modding community has relied on since 2002. Paul
wrote the DS1/DT1/DCC/DC6 format handling, the WYSIWYG editing model, and the
25,000+ lines this codebase grew out of, and released the source publicly
alongside every build. Everything here is a continuation of that work, not a
replacement for it. Every source file that descends from `win_ds1edit` carries
a header saying so; [NOTICE](NOTICE) lists them all.
- **Original DS1Edit / win_ds1edit by Paul Siramy** —
[download page](http://paul.siramy.free.fr/_divers/ds1/dl_ds1edit.html) ·
[documentation](http://paul.siramy.free.fr/_divers/ds1/doc/index.html)
- **MPQ reader** (`src/mpq/`) — Tom Amigo's Stormless MPQ sample code (2000),
as adapted by Paul for `win_ds1edit`
- GUI Loader by Mark Nevill ('DarthDevilous')
- The [Phrozen Keep](http://d2mods.com/forum/) community, for two decades of
file-format documentation
- Built with the [Allegro 5](https://liballeg.org/) game programming library
- D2 blend mode formulas reverse-engineered from D2CMP.dll via Ghidra