No description
  • Rust 82.7%
  • Nix 17.3%
Find a file
Andi 8ff8e51109 filc: provide hostname, found by a partial build
A background build got through source unpack, the vendored projects'
configure scripts and into build_compiler_rt.sh before it had to be cut
off -- far enough to confirm the phase structure holds and to surface
one real gap: configure_cmake_project.sh calls `hostname` to stamp a
rebuild-detection cookie, and the sandbox has no PATH entry for it. It
is harmless when missing, but add it rather than leave the error in the
log. The comments and README now say what the partial run did and did
not establish, rather than claiming the whole thing was written blind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nZrh5rXDy72uxx6mLaqTU
2026-09-01 16:04:29 +00:00
nix filc: provide hostname, found by a partial build 2026-09-01 16:04:29 +00:00
rust Audit the protocol, smooth the rendering 2026-09-01 09:46:48 +00:00
.gitignore Package iMaze with Nix, modernise the C sources, start a Rust rewrite 2026-08-31 17:48:50 +00:00
flake.lock init 2026-08-31 17:17:55 +02:00
flake.nix Add a from-source Fil-C build of the server and tools 2026-09-01 15:00:24 +00:00
README.md filc: provide hostname, found by a partial build 2026-09-01 16:04:29 +00:00
SECURITY.md Add a from-source Fil-C build of the server and tools 2026-09-01 15:00:24 +00:00

iMaze, packaged with Nix

A Nix flake that builds iMaze — Hans-Ulrich Kiel and Jörg Czeranski's multi-player X11 maze shooter, the Unix answer to MidiMaze II on the Atari ST — from its original C sources, plus a full rewrite of it in Rust.

You run through a labyrinth and shoot everything round that is not one of your own shots. Anything round is either another player, a computer-controlled ninja, or a deadly shot.

.
├── flake.nix          packages, apps, dev shell, checks
├── nix/imaze.nix      the game, built from the 2001 C sources
├── nix/imaze-rs.nix   the Rust rewrite
├── nix/filc.nix       the Fil-C toolchain, from source
├── nix/imaze-filc.nix the server and tools, compiled memory-safe with Fil-C
├── nix/nixos-module.nix   services.imaze
├── nix/patches/       7 patches against the C sources
├── rust/              the Rust crates: imaze-rs (core, server, CLI) + imaze-gui
└── SECURITY.md        a memory-safety bug in the C client, and why Rust is clear

Quick start

# One terminal: a server on a freshly generated 15x15 labyrinth
nix run .#genlab -- -w 15 -h 15 -d 25 arena.lab
nix run .#imazesrv -- -l - arena.lab

# Another terminal: the game
nix run . -- -H localhost

# And some opposition
nix run .#ninja -- -H localhost

The eleven labyrinths shipped with the game are in $(nix build .#imaze --no-link --print-out-paths)/share/imaze/labs if you would rather not generate your own.

In the client, connect with Server → Connect (the -H flag only fills in the address field). Cursor keys move, space/shift/alt shoot — you only ever have one shot in flight — ^S pauses, ^Q resumes, Tab turns you around if the server allows it. Window opens the rear view, map, compass and score list. Click a window first so it takes keyboard input.

man imaze, man imazesrv, man ninja, man genlab, man xlabed and man imazestat document the rest; share/doc/imaze/README in the package is the authors' original text.

What the flake provides

Output
packages.imaze (default) the game, Athena widgets
packages.imaze-motif same, Motif control panel
packages.imaze-rs the Rust rewrite: imaze, imazesrv, ninja, genlab, imazestat, imazelab
packages.filc the Fil-C toolchain, built from source (Linux)
packages.imaze-filc the server and tools, compiled memory-safe with Fil-C (Linux)
apps.{imaze,imazesrv,ninja,imazestat,genlab,xlabed} one per C binary
apps.{imaze-rs,imazesrv-rs,ninja-rs,genlab-rs,imazestat-rs,imazelab} the Rust ones
overlays.default adds imaze, imaze-rs, filc, imaze-filc to a nixpkgs instance
nixosModules.imaze (default) services.imaze, for hosting a server
devShells.default both build environments, C and Rust
checks see below

imaze installs the six programs, the eleven labyrinths and eight .au sounds under share/imaze/, man pages, and desktop entries for the client and the labyrinth editor.

Package options

pkgs.imaze.override {
  guiToolkit = "motif";              # or "athena" (default)
  withAudio = false;                 # drop the OSS /dev/dsp backend
  withJoystick = false;              # drop <linux/joystick.h> support
  defaultServer = "maze.example.org";# baked-in default host
}

defaultServer is worth setting if you run a permanent server: upstream's compiled-in default is imaze.rz.tu-clausthal.de, which stopped answering a very long time ago.

Sound goes to /dev/dsp — that is the only backend iMaze ever grew on Linux. On a PulseAudio or PipeWire desktop, run the client as padsp imaze, or pass -q to turn sound off.

The two packages both install bin/imaze, bin/imazesrv, bin/ninja, bin/genlab and bin/imazestat — that is what "drop-in" means — so putting both into one profile is a file collision. Pick one, or reach the Rust ones through nix run .#imaze-rs and friends.

Hosting a server

{
  imports = [ imaze.nixosModules.default ];

  services.imaze = {
    enable = true;
    openFirewall = true;
    bots = 2;                    # so nobody arrives to an empty labyrinth
    generate = { width = 24; height = 24; };
  };
}

That is the whole of it. The labyrinth is generated at build time from services.imaze.generate — width, height, wall and door density, and a seed that is fixed by default so that rebuilding your configuration does not quietly replace the arena everyone knows — or you can point services.imaze.labyrinth at a .lab file of your own, including one of the eleven the game ships.

The rest of the options are the server's own: port and sessionPort, cycleTime, and rules.{faceless,colorless,reflective,quickturn,invisible}. package chooses which implementation to run; both provide imazesrv, and the Rust one is the default because only it takes a fixed sessionPort.

The service runs under DynamicUser with the usual systemd sandbox — no capabilities, no writable filesystem, AF_INET and AF_INET6 only — because it reads one file at startup and then pushes datagrams about. Each bot is its own unit, so one that wanders off is restarted without disturbing the others, and all of them are bound to the server's unit.

Two ports, not one. A client meets the server over TCP and is then handed to a UDP port for the game itself, so openFirewall opens both. sessionPort exists because the server would otherwise fall back to any free port if its usual one were taken, and a firewall rule written for 5323 would then be pointing at nothing; naming the port makes the server insist on it and fail loudly instead.

Clients behind NAT. The 1994 protocol has the server speak first and the client only ever answer, which no stateful firewall or NAT will allow — the server's opening datagram arrives before anything has gone out to create the mapping. The Rust client sends one empty packet as soon as it has swapped addresses, which opens the way and costs nothing; a server that has not finished registering it yet simply ignores it. The C client does not do this, so playing on a remote server from behind NAT needs the Rust one, or an inbound UDP hole.

The Rust rewrite

rust/ is a rewrite of the whole of iMaze — client, server and bot. It speaks the 1994 protocol in both directions, so the pieces mix freely with the originals: the Rust client plays on imazesrv, and the 1994 client plays on the Rust server. It is checked that way too, rather than merely resembling the original.

nix run .#genlab-rs -- -w 15 -h 15 -d 25 arena.lab
nix run .#imazesrv-rs -- -l - arena.lab    # or .#imazesrv for the C one

nix run .#imaze-rs -- -H localhost         # the game
nix run .#ninja-rs -- -H localhost         # something to shoot at
nix run .#imazelab                         # the labyrinth editor
imaze_rs::rng the game's 4.3BSD random(), reimplemented exactly
imaze_rs::lab the iMazeLab1 file format, read and write
imaze_rs::generate baulab.c's generator and its playability test
imaze_rs::protocol the TCP "prolog" handshake, both roles
imaze_rs::game the UDP session as a client sees it
imaze_rs::view the ray caster the view is drawn from
imaze_rs::server the world: movement, shots, scores, respawns
imaze the game, drawn with egui
imazesrv the server
ninja a computer-controlled player
genlab drop-in replacement, byte-identical output
imazestat drop-in replacement, same output from the same server
imazelab an egui labyrinth editor

The game

Arrows move, space (or shift, or alt) shoots — one shot in flight at a time. Tab turns you round, ^S pauses, ^Q resumes. Click the view first so it takes the keys; while a text field has focus the keys go there instead. The side panel has the scores, a field-of-view slider and an overhead map.

The server sets the pace — one datagram every 60ms, each of which has to be answered with the keys that are down — so the session runs on its own thread and the interface reads the latest state from behind a mutex. A key held between datagrams still turns you smoothly because the view redraws at 30fps regardless.

Unlike the C client, -H host connects straight away rather than only filling the address in.

How the view is drawn

rechne3d.c is 2000 lines of fixed-point arithmetic that walks the visible walls and projects their corners. This casts one ray per screen column instead — the same picture, arrived at the way a maze game usually does it now.

The one thing that is not the textbook version is whose wall a ray hits. In iMaze a wall belongs to the block it is drawn in rather than to the boundary between two blocks, so the two faces of one wall can differ; that is what a one-way door is. A ray leaving a block therefore asks about the face of the block it is leaving, which is exactly the face a player standing there sees. There is a test for it: from one side the wall is there, from the other the ray carries straight through.

Players are drawn as balls with a face whose eyes swing round as they turn, so you can tell whether they have seen you, and are clipped column by column against the wall depths — a player half behind a corner is drawn half.

The caster is not the expensive part: a full 1600-column frame of a 25×25 labyrinth casts in about 0.018ms, and the wall mesh is a thousand-odd quads that the GPU does not notice. What made motion look slow was the update rate, not the frame cost — the world only advances when a datagram arrives, about sixteen times a second, and the same frame was being redrawn in between. The client now keeps the last two server states and draws a point on the way from one to the next, chosen by the wall clock, so everything moves smoothly at the display's rate while the server still ticks at its own underneath. It trails the truth by up to one cycle and never guesses past the latest state, so a stall freezes rather than flinging players off. This is purely how the client draws; the server sees nothing different, and the interpolation is covered by tests in the imaze binary.

The labyrinth tools

Byte-identical is the interesting part. global.c carries its own copy of the BSD additive-feedback generator, so reimplementing it exactly means the whole generator is reproducible: genlab -r 42 -w 8 -h 8 writes the same 327 bytes whichever implementation you run. The C version keeps its state in unsigned long, which is 64 bits wide here, but only bits 1..31 ever reach the output and carries only travel upwards, so u32 arithmetic is exact.

Two behaviours were kept even though they look like bugs, because matching the original is the point:

  • Option arguments go through atoi semantics, so -w 16abc means 16.
  • iMaze's stock defaults (16×16 at 38% walls) fail the playability test for a thousand seeds in a row and give up. The C genlab does exactly the same; it is only usable with -d turned down or the field made smaller. There is a test pinning this.

imazelab reads and writes the same files as the game's own xlabed, rolls new labyrinths with the ported generator, tells you live whether the result is playable and whether the server would complain about any wall, and can ask a running server who is on it. Because walls are stored per block, the two sides of one wall are separate things: the editor draws them as two parallel strokes and lets you edit each, which is how a one-way door is made.

The server

imazesrv holds the labyrinth, hands it out, moves everyone, decides who shot whom, keeps the scores and puts the dead back somewhere quiet. It takes the same options as the C one — -p, -l, -c, and the rule flags -F, -C, -R, -Q, -I — plus -r to fix the random seed. It does not implement -A (autoanswer).

Where the C server forks a process per session and another per client being initialised, and talks to them over socket pairs, this runs one thread per connection for the prolog — a client that stops halfway must not hold up a match — and one thread for the game, which owns the world and the single UDP socket everybody plays over.

The constants are the game's own: a block is 256 fine units, a player is a circle of radius 48, a step is 25 and a shot travels twice that, being shot costs thirty cycles, and you get one point and one shot in flight.

What is not a port is the collision arithmetic. bewegung.c slides a player along a wall by fitting a straight or circular correction path to the obstacle, in about 900 lines of fixed point. Here a step is resolved one axis at a time and clamped against whatever the circle would cross, which slides along walls and around corners just as readably, in about thirty lines. The server is the authority on where everyone is, so a client cannot tell the difference — and the 1994 client, pointed at it, does not.

Averaged over five seeds a bot covers about the same ground on either server (3033 units against 3443), though the spread per seed is wide either way: how far you get depends mostly on where you spawned.

Checks

nix flake check builds both toolkit variants, runs the Rust test suite (93 tests, the egui passes included), and runs eight functional tests, one of them a NixOS machine:

  • server-and-bot generates a labyrinth, starts imazesrv on it, lets a ninja bot join over loopback and confirms imazestat reports the bot and its kill message.
  • client-opens-a-window starts the real X11 client against Xvfb and confirms it maps a window and stays up, and that the compiled-in sound directory points into the package's own store path.
  • rust-plays-on-the-c-server — the Rust bot joins a real imazesrv next to a C ninja, plays for five seconds and leaves again. imazestat has to list it while it plays, the server log has to show it as a player rather than a query, and it has to exit cleanly, which takes the prolog, the handover to UDP, five seconds of datagrams answered, and the server acknowledging the request to leave. It is the graphical client's network path exactly, with nothing drawn.
  • c-clients-play-on-the-rust-server — the same in the other direction, and the one that pins the server: the 1994 ninja and the 1994 imazestat are pointed at the Rust imazesrv, and the Rust bot alongside them has to play a hundred cycles and cover some ground. The old bot is not going to be talked round, so if it plays, the server's half of the protocol is right.
  • nixos-module boots two machines. The server comes up under systemd with a generated labyrinth and two bots; from the other machine, through the firewall, the 1994 imazestat reads the status listing and a bot plays a game across the network. The player's firewall is left on, so this is also what checks that a client can open its own way in. Then the service is restarted, to catch a server that leaves its ports behind.
  • rust-genlab-matches-c runs both genlabs over ten parameter sets and requires the same exit status and, where they succeed, identical bytes.
  • rust-imazestat-matches-c puts two bots on a server and diffs what the two imazestats report.
  • rust-labyrinths-load-in-the-c-server hosts a Rust-generated labyrinth on the C server, plays a bot on it and requires the server to load it without warnings.

Notes on the source

  • Version 1.4 (December 2001) is the authors' final release and the one packaged here. The imaze.README on ftp.gwdg.de describes 1.2, whose client can only be built against Sun's XView toolkit — XView has no packaging in nixpkgs and has been dead for decades, so 1.2 would give you the server and the bots but no game. 1.4 replaced XView with Athena and Motif front ends over the same Xlib renderer, and adds the labyrinth editor, the server query tool and configurable game rules.
  • The 1.4 tarball is fetched from FreeBSD's ports distfile cache, which is the last mirror still carrying it; the source hash pins the contents regardless of where it comes from.
  • Upstream's hand-written configure hardcodes paths like /usr/X11R6 in its per-OS branches, so the package asks for its neutral OS=unknown branch and states the toolkit, audio and joystick choices explicitly.

The patches

Two are needed to compile at all against a 2026 toolchain:

  1. fehler_text() used sys_errlist[]/sys_nerr, which glibc no longer lets new binaries link against — strerror() instead.
  2. grafik.h used u_char without including <sys/types.h>; it only ever worked because of include order elsewhere.

The other five fix things a current compiler is right to complain about, and together they make the build warning-free:

  1. XKeycodeToKeysym → XkbKeycodeToKeysym; the old call has been deprecated since 2007 and ignores the XKB group.
  2. A 40-byte buffer that a colour-allocation error message can overflow.
  3. A static atan(int, int) returning an angle in the game's own units, which collides with the libm builtin gcc is free to act on.
  4. A ptrdiff_t printed with %d.
  5. The sound loader ignored read()'s result, so a short read played uninitialised memory; its error paths also leaked the buffer and the file descriptor.

Everything else builds unmodified, with -std=gnu89 -fcommon for pre-C99 empty parameter lists and tentative definitions.

These patches are compile fixes, not the memory-safety bug the C client has in its packet handling — see SECURITY.md. That one is left in the C (the sources are meant to build as they were) and simply does not exist in the Rust rewrite, which the audit there sets out.

Security

The 1994 C client has an out-of-bounds array read driven by a single colour byte from the server, described in SECURITY.md. The Rust rewrite is not affected — it masks and moduloes colours rather than indexing a fixed array by a raw network byte — and a regression test pins that. The rest of the Rust decoder was audited for the same class of bug and length-checks every variable-length field.

A memory-safe C build (Fil-C)

There is a third way to answer that bug class without rewriting anything: compile the original C with Fil-C, a memory-safe fork of LLVM/clang whose codegen bounds-checks every access. An out-of-bounds read then traps at the access instead of handing Xlib a wild pointer.

packages.filc builds the Fil-C toolchain from source — the LLVM fork, its runtime, and its musl libc, driven by upstream's own build_base.sh. packages.imaze-filc uses it to build the server and tools:

nix build .#imaze-filc      # imazesrv, ninja, imazestat, genlab, memory-safe

Only the non-graphical programs are built this way. Fil-C code links solely against Fil-C-compiled libraries, and no Fil-C build of the Xaw/Xt/Xmu stack exists, so the graphical client is out — but the server, which is what parses packets from untrusted clients, is exactly the thing worth hardening, and it is X11-free.

Two honest caveats. Building filc is an LLVM-fork compile — the better part of an hour, a lot of RAM and disk — so it is kept out of nix flake check; you opt in with the build command above. And it was not built to completion where it was written: a partial run got through source unpack, the vendored projects' configure scripts and into the compiler-rt step — enough to confirm the phases line up and to catch a missing build tool, now fixed — but the LLVM compile past that point runs for hours and could not finish here, so it stays unverified end to end. The source hash is pinned and verified and the flake evaluates; any remaining rough edges on a first full build are the sort the partial run was already shaking out.

Licence

iMaze is distributed under a BSD 3-clause licence with an additional clause restricting use of the name "iMaze" — see Copyright in the source tarball or share/doc/imaze/Copyright in the built package. The packaging and the Rust code in this repository are offered under the same terms.