Goose

31_mini_doom.goose

// A small Doom-like game, and a tour of loading real binary game assets.
// The supplied Freedoom WAD supplies the map, palette, textures, sprites,
// font and sound effects. This is an independent, deliberately small game;
// it is not a source port and does not reproduce Doom's simulation exactly.
//
//   goose samples/31_mini_doom.goose
//   goose samples/31_mini_doom.goose -- --frames 30
//   goose samples/31_mini_doom.goose -- --wad path/to/freedoom1-e1m1.wad
//
// WASD walks, Shift runs, mouse or Left/Right turns, E or Space uses doors
// and switches. Left mouse or Ctrl fires. 1 selects fists, 2 the pistol,
// 3 the shotgun, 4 the chaingun. Tab shows the map; H toggles help.
// Escape pauses and releases the mouse; click or Escape resumes. Losing
// focus pauses too. R restarts after death or finishing. F12 saves a PNG.
// Starts straight in E1M1. Find the blue key and reach the exit switch.
//
// Included: solid walls, stairs, falling, lifts, locked/secret doors, sector
// lighting, animated liquids, damaging floors, pickups, four enemy types,
// imp fireballs, exploding barrels, weapon/sprite animation and sound.
// Limits: medium difficulty only, simple pursuit (no pathfinding), frontal
// monster sprites, approximate lighting/combat, no music, saves, multiplayer,
// jumping or vertical mouse look. Other weapons become useful ammo pickups.
// Only this map's line specials are implemented. This is not a WAD browser.
//
// Read downwards: bytes -> pictures -> map -> movement -> game -> rendering.
// Plain loops and small records do most of the work. Collision is done in
// the map's two-dimensional plane; a separate physics world is unnecessary.

import std;
import math;
import vec;
import os;
import binary;
import gfx;
import audio;

// --- the WAD directory and little-endian values ------------------------------

struct Lump { name: u8[..8], start: i64, size: i64 }
var wad: u8[>..] = [];
var lumps: Lump[>..] = [];

fn require(ok: bool, message: const u8[:]) {
    if !ok { abort(str("mini doom: ", message)); }
}
// The library returns recoverable failures. This sample adds WAD context and
// stops loading; byte order, range checks and name trimming live in binary.
fn u16le(data: const u8[:], at: i64) -> i64 {
    let value, ok = binary::read_u16le(data, at);
    if !ok { abort(str("mini doom: truncated 16-bit WAD field at byte ", at)); }
    value
}
fn i16le(data: const u8[:], at: i64) -> i64 {
    let value, ok = binary::read_i16le(data, at);
    if !ok { abort(str("mini doom: truncated signed 16-bit WAD field at byte ", at)); }
    value
}
fn u32le(data: const u8[:], at: i64) -> i64 {
    let value, ok = binary::read_u32le(data, at);
    if !ok { abort(str("mini doom: truncated 32-bit WAD field at byte ", at)); }
    value
}
fn name_at(data: const u8[:], at: i64) -> u8[..8] {
    let bytes, ok = binary::read_name(data, at, 8);
    if !ok { abort(str("mini doom: truncated WAD name at byte ", at)); }
    var name: u8[..8] = [];
    name.append(bytes);
    name
}
fn find_lump(name: const u8[:]) -> i64 {
    // Last definition wins, as in a normal WAD directory.
    var i = lumps.len;
    while i > 0 {
        i--;
        if lumps[i].name == name { return i; }
    }
    -1
}
fn lump_bytes(i: i64) -> const u8[:] {
    require(i >= 0 && i < lumps.len, "missing WAD lump");
    let l = lumps[i];
    wad[l.start..l.start + l.size]
}
fn lump(name: const u8[:]) -> const u8[:] {
    let i = find_lump(name);
    require(i >= 0, str("missing lump ", name));
    lump_bytes(i)
}
fn load_wad(path: const u8[:]) {
    require(read_file(path, wad), str("cannot read ", path));
    var header = binary::reader(wad);
    let signature, signature_ok = binary::read_bytes(header, 4);
    let count_field, count_ok = binary::read_u32le(header);
    let directory, directory_ok = binary::read_u32le(header);
    require(signature_ok && count_ok && directory_ok, "truncated WAD header");
    let count: i64 = count_field;
    require(signature == "IWAD" || signature == "PWAD", "expected a Doom WAD");
    require(count <= 100000 && binary::range_ok(wad, directory, count * 16), "bad WAD directory");
    var file = binary::reader(wad);
    binary::skip(file, directory);
    var entries, entries_ok = binary::subreader(file, count * 16);
    require(entries_ok, "truncated WAD directory");
    for i in count {
        var entry, entry_ok = binary::subreader(entries, 16);
        let start, start_ok = binary::read_u32le(entry);
        let size, size_ok = binary::read_u32le(entry);
        let bytes, name_ok = binary::read_name(entry, 8);
        require(entry_ok && start_ok && size_ok && name_ok, "truncated WAD entry");
        require(binary::range_ok(wad, start, size), "lump extends past end of WAD");
        var name: u8[..8] = [];
        name.append(bytes);
        lumps.push(Lump { name, start, size });
    }
    require(find_lump("E1M1") >= 0, "this sample needs the supplied E1M1 WAD");
}

// --- palette pictures in one GPU atlas --------------------------------------

// A shelf packer suffices: each row is as tall as its tallest picture.
// Texture coordinates repeat *within* each rectangle in the fragment shader.
// Nearest filtering keeps the palette artwork crisp and avoids atlas bleeding.
let ATLAS_W = 2048;
let ATLAS_H = 4096;
struct Picture {
    name: u8[..8], kind: i64,       // 0: patch, 1: wall texture, 2: flat
    x: i64, y: i64, w: i64, h: i64,
    left: i64, top: i64,           // patch anchor (weapons and sprites)
}
var pictures: Picture[>..] = [];
var atlas_pixels: u8[>..] = [];
var shelf_x = 0;
var shelf_y = 0;
var shelf_height = 0;
var palette: u8[768];
var font: i64[128];
var sky_picture = 0;

fn find_picture(name: const u8[:], kind: i64) -> i64 {
    for i in pictures.len {
        if pictures[i].kind == kind && pictures[i].name == name { return i; }
    }
    -1
}
fn new_picture(name: const u8[:], kind: i64, w: i64, h: i64) -> i64 {
    require(w > 0 && h > 0 && w <= ATLAS_W && h <= 1024, "bad picture dimensions");
    if shelf_x + w > ATLAS_W {
        shelf_x = 0;
        shelf_y += shelf_height;
        shelf_height = 0;
    }
    require(shelf_y + h <= ATLAS_H, "picture atlas is full");
    var p = Picture { kind: kind, x: shelf_x, y: shelf_y, w: w, h: h, .. };
    p.name.append(name);
    let id = pictures.len;
    pictures.push(p);
    shelf_x += w;
    shelf_height = max(shelf_height, h);
    id
}
fn pixel(pic: i64, x: i64, y: i64, color: i64) {
    let p = pictures[pic];
    if x < 0 || y < 0 || x >= p.w || y >= p.h { return; }
    let at = ((p.y + y) * ATLAS_W + p.x + x) * 4;
    for c in 3 { atlas_pixels[at + c] = palette[color * 3 + c]; }
    atlas_pixels[at + 3] = 255;
}
fn paint_patch(pic: i64, data: const u8[:], origin_x: i64, origin_y: i64) {
    let w = u16le(data, 0);
    require(binary::range_ok(data, 8, w * 4), "truncated patch columns");
    for x in w {
        var at = u32le(data, 8 + x * 4);
        var previous = -1;
        loop {
            require(binary::range_ok(data, at, 1), "unterminated patch column");
            var top = data[at] as i64;
            if top == 255 { break; }
            require(binary::range_ok(data, at, 4), "truncated patch post");
            let count = data[at + 1] as i64;
            require(binary::range_ok(data, at, count + 4), "truncated patch pixels");
            // Also accepts the relative posts used by tall Doom patches.
            if top <= previous { top += previous; }
            previous = top;
            for y in count { pixel(pic, origin_x + x, origin_y + top + y, data[at + 3 + y]); }
            at += count + 4;
        }
    }
}
fn patch(name: const u8[:]) -> i64 {
    let cached = find_picture(name, 0);
    if cached >= 0 { return cached; }
    let data = lump(name);
    let id = new_picture(name, 0, u16le(data, 0), u16le(data, 2));
    pictures[id].left = i16le(data, 4);
    pictures[id].top = i16le(data, 6);
    paint_patch(id, data, 0, 0);
    id
}
fn flat(name: const u8[:]) -> i64 {
    if name == "F_SKY1" { return -1; }
    let cached = find_picture(name, 2);
    if cached >= 0 { return cached; }
    let data = lump(name);
    require(data.len == 4096, "flat must be 64 x 64");
    let id = new_picture(name, 2, 64, 64);
    for y in 64 { for x in 64 { pixel(id, x, y, data[y * 64 + x]); } }
    id
}
fn wall_texture(name: const u8[:]) -> i64 {
    if name == "-" { return -1; }
    let id = find_picture(name, 1);
    require(id >= 0, str("missing wall texture ", name));
    id
}
fn load_pictures() {
    atlas_pixels.resize(ATLAS_W * ATLAS_H * 4, 0);
    let pal = lump("PLAYPAL");
    require(pal.len >= 768, "truncated palette");
    for i in 768 { palette[i] = pal[i]; }
    // Picture 0 is an opaque white pixel, useful for HUD rectangles.
    new_picture("WHITE", 0, 1, 1);
    for i in 4 { atlas_pixels[i] = 255; }

    let names = lump("PNAMES");
    let name_count = u32le(names, 0);
    require(binary::range_ok(names, 4, name_count * 8), "truncated PNAMES");
    let textures = lump("TEXTURE1");
    let count = u32le(textures, 0);
    require(binary::range_ok(textures, 4, count * 4), "truncated texture directory");
    for i in count {
        let at = u32le(textures, 4 + i * 4);
        let name = name_at(textures, at);
        let id = new_picture(name, 1, u16le(textures, at + 12), u16le(textures, at + 14));
        let patches = u16le(textures, at + 20);
        require(binary::range_ok(textures, at + 22, patches * 10), "truncated texture patches");
        for j in patches {
            let p = at + 22 + j * 10;
            let number = u16le(textures, p + 4);
            require(number < name_count, "bad patch name index");
            let patch_name = name_at(names, 4 + number * 8);
            paint_patch(id, lump(patch_name), i16le(textures, p), i16le(textures, p + 2));
        }
    }
    // Load every sprite once. Gameplay only looks up these immutable IDs.
    let first = find_lump("S_START");
    let last = find_lump("S_END");
    require(first >= 0 && last > first, "missing sprite markers");
    for i in first + 1..last { if lumps[i].size > 0 { patch(lumps[i].name); } }
    for c in 33..96 {
        font[c] = patch(str("STCFN", if c < 100 { "0" } else { "" }, c));
    }
    patch("STBAR");
    patch("STFST00");
    patch("STFST10");
    patch("STFST20");
    patch("STFST30");
    patch("STFST40");
    patch("STFDEAD0");
    sky_picture = wall_texture("SKY1");
    for name in ["NUKAGE1", "NUKAGE2", "NUKAGE3", "FWATER1", "FWATER2", "FWATER3", "FWATER4"] {
        flat(name);
    }
}

// --- map records ------------------------------------------------------------

struct Side { offset: float2, upper: i64, lower: i64, middle: i64, sector: i64 }
struct Line {
    a: float2, b: float2, flags: i64, special: i64, tag: i64, front: i64, back: i64,
    activated: bool = false,
}
struct Sector {
    floor: f32, ceiling: f32, floor_pic: i64, ceiling_pic: i64,
    light: f32, special: i64, tag: i64,
    start_floor: f32, start_ceiling: f32,
    motion: i64, target: f32, wait: f32, speed: f32,
    secret_found: bool,
}
struct Seg { a: float2, b: float2, sector: i64 }
struct Subsector { first: i64, count: i64, sector: i64 }
struct Node { origin: float2, delta: float2, front: i64, back: i64 }
struct FloorTriangle { a: float2, b: float2, c: float2, sector: i64 }
var vertices: float2[>..] = [];
var sides: Side[>..] = [];
var lines: Line[>..] = [];
var sectors: Sector[>..] = [];
var segs: Seg[>..] = [];
var subsectors: Subsector[>..] = [];
var nodes: Node[>..] = [];
var floor_triangles: FloorTriangle[>..] = [];

fn cross2(a: float2, b: float2) -> f32 { a.x * b.y - a.y * b.x }
fn scale2(p: float2, s: f32) -> float2 { p * float2 { s, s } }
fn distance2(a: float2, b: float2) -> f32 { length(a - b) }
fn front_sector(line: Line) -> i64 { sides[line.front].sector }
fn back_sector(line: Line) -> i64 { if line.back < 0 { -1 } else { sides[line.back].sector } }

fn sector_at(p: float2) -> i64 {
    var child = nodes.len - 1;
    var steps = 0;
    while child < 32768 {
        require(child >= 0 && child < nodes.len && steps <= nodes.len, "invalid BSP tree");
        let n = nodes[child];
        child = if cross2(n.delta, p - n.origin) <= 0.0 { n.front } else { n.back };
        steps++;
    }
    let leaf = child - 32768;
    require(leaf < subsectors.len, "bad BSP leaf");
    subsectors[leaf].sector
}

// Original Doom subsectors list only their solid/portal edges, not all BSP
// cuts. A fan of seg endpoints leaves holes! Clip a bounding polygon down
// the BSP and then by the leaf's edges to recover each complete convex cell.
// Append into caller-owned scratch; input slices remain valid as it grows.
fn clip_polygon(poly: const float2[:], origin: float2, delta: float2, out: float2[>..<]&) {
    if poly.len == 0 { return; }
    var previous = poly[poly.len - 1];
    var d0 = cross2(delta, previous - origin);
    for current in poly {
        let d1 = cross2(delta, current - origin);
        if (d0 <= 0.0) != (d1 <= 0.0) {
            out.push(previous + scale2(current - previous, d0 / (d0 - d1)));
        }
        if d1 <= 0.0 { out.push(current); }
        previous = current;
        d0 = d1;
    }
}
recursive fn build_floors(child: i64, poly: const float2[:], depth: i64, scratch: float2[>..<]&) {
    require(depth <= nodes.len, "cyclic BSP tree");
    if poly.len < 3 { return; }
    // Every activation keeps only slices/indices. The caller owns the one
    // growable buffer, and each call restores its length before returning.
    let mark = scratch.len;
    if child >= 32768 {
        require(child - 32768 < subsectors.len, "bad floor leaf");
        let leaf = subsectors[child - 32768];
        var start = mark;
        for i in leaf.count {
            let edge = segs[leaf.first + i];
            let input = if i == 0 { poly } else { scratch[start..] };
            start = scratch.len;
            clip_polygon(input, edge.a, edge.b - edge.a, scratch);
        }
        let clipped = scratch[start..];
        for i in 1..clipped.len - 1 {
            // Discard numerical slivers smaller than a twentieth of a
            // square map unit; their centroids can round across a BSP cut.
            if abs(cross2(clipped[i] - clipped[0], clipped[i + 1] - clipped[0])) > 0.1 {
                floor_triangles.push(FloorTriangle { clipped[0], clipped[i], clipped[i + 1], leaf.sector });
            }
        }
    } else {
        require(child >= 0 && child < nodes.len, "bad floor node");
        let n = nodes[child];
        clip_polygon(poly, n.origin, n.delta, scratch);
        let right = scratch[mark..];
        build_floors(n.front, right, depth + 1, scratch);
        let left_start = scratch.len;
        clip_polygon(poly, n.origin, scale2(n.delta, -1.0), scratch);
        let left = scratch[left_start..];
        build_floors(n.back, left, depth + 1, scratch);
    }
    scratch.resize(mark);
}
fn records(name: const u8[:], size: i64) -> const u8[:] {
    let data = lump(name);
    require(data.len > 0 && data.len % size == 0, str("bad ", name, " record size"));
    data
}
fn load_map() {
    let v = records("VERTEXES", 4);
    for i in v.len / 4 { vertices.push(float2 { i16le(v, i * 4), i16le(v, i * 4 + 2) }); }
    let sec = records("SECTORS", 26);
    for i in sec.len / 26 {
        let at = i * 26;
        let bottom: f32 = i16le(sec, at);
        let top: f32 = i16le(sec, at + 2);
        sectors.push(Sector {
            floor: bottom, ceiling: top,
            floor_pic: flat(name_at(sec, at + 4)), ceiling_pic: flat(name_at(sec, at + 12)),
            light: clamp<f32>(i16le(sec, at + 20) / 255.0, 0.0, 1.0),
            special: i16le(sec, at + 22), tag: i16le(sec, at + 24),
            start_floor: bottom, start_ceiling: top, .. });
    }
    let sd = records("SIDEDEFS", 30);
    for i in sd.len / 30 {
        let at = i * 30;
        let sector = u16le(sd, at + 28);
        require(sector < sectors.len, "bad sidedef sector");
        sides.push(Side { float2 { i16le(sd, at), i16le(sd, at + 2) },
            wall_texture(name_at(sd, at + 4)), wall_texture(name_at(sd, at + 12)),
            wall_texture(name_at(sd, at + 20)), sector });
    }
    let ld = records("LINEDEFS", 14);
    for i in ld.len / 14 {
        let at = i * 14;
        let a = u16le(ld, at);
        let b = u16le(ld, at + 2);
        let front = u16le(ld, at + 10);
        let back = i16le(ld, at + 12);
        require(a < vertices.len && b < vertices.len, "bad linedef vertex");
        require(front < sides.len && back >= -1 && back < sides.len, "bad linedef side");
        lines.push(Line { vertices[a], vertices[b], u16le(ld, at + 4),
            u16le(ld, at + 6), u16le(ld, at + 8), front, back });
    }
    let sg = records("SEGS", 12);
    for i in sg.len / 12 {
        let at = i * 12;
        let a = u16le(sg, at);
        let b = u16le(sg, at + 2);
        let line = u16le(sg, at + 6);
        let direction = u16le(sg, at + 8);
        require(a < vertices.len && b < vertices.len && line < lines.len && direction <= 1, "bad seg");
        let side = if direction == 0 { lines[line].front } else { lines[line].back };
        require(side >= 0, "seg has no sidedef");
        segs.push(Seg { vertices[a], vertices[b], sides[side].sector });
    }
    let ss = records("SSECTORS", 4);
    for i in ss.len / 4 {
        let count = u16le(ss, i * 4);
        let first = u16le(ss, i * 4 + 2);
        require(count > 0 && first + count <= segs.len, "bad subsector");
        subsectors.push(Subsector { first, count, segs[first].sector });
    }
    let nd = records("NODES", 28);
    for i in nd.len / 28 {
        let at = i * 28;
        nodes.push(Node { float2 { i16le(nd, at), i16le(nd, at + 2) },
            float2 { i16le(nd, at + 4), i16le(nd, at + 6) },
            u16le(nd, at + 24), u16le(nd, at + 26) });
    }
    let bounds: float2[4] = [float2 { -32768.0, -32768.0 }, float2 { 32768.0, -32768.0 },
        float2 { 32768.0, 32768.0 }, float2 { -32768.0, 32768.0 }];
    var scratch: float2[>..<] = [];
    build_floors(nodes.len - 1, bounds, 0, scratch);
}

// --- things, player and sound ------------------------------------------------

let STEP: f32 = 1.0 / 60.0;
let PLAYER_RADIUS: f32 = 16.0;
let PLAYER_HEIGHT: f32 = 56.0;
struct Player {
    pos: float2, z: f32, falling: f32, yaw: f32,
    health: i64, armor: i64, bullets: i64, shells: i64,
    weapon: i64, shotgun: bool, chaingun: bool, blue_key: bool,
    cooldown: f32, flash: f32, hurt: f32, bonus: f32, bob: f32,
}
struct Actor {
    pos: float2, z: f32, kind: i64, picture: i64,
    radius: f32, health: i64, alive: bool, solid: bool,
    awake: bool, timer: f32, pain: f32, death: f32, attacking: f32,
}
struct Fireball { pos: float2, z: f32, velocity: float2, vz: f32, life: f32 }
var player: Player;
var actors: Actor[..512];
var fireballs: Fireball[64];
var next_fireball = 0;
var game_time: f32 = 0.0;
var damage_clock: f32 = 0.0;
var kills = 0;
var total_monsters = 0;
var secrets = 0;
var total_secrets = 0;
var won = false;
var message: u8[..160];
var message_time: f32 = 0.0;
var random_state: u32 = 12345;

fn random_int(limit: i64) -> i64 {
    random_state = random_state * 1664525 + 1013904223;
    (random_state >> 8) % limit
}
fn say(text: const u8[:]) {
    message.resize(0);
    message.append(text);
    message_time = 3.5;
}
fn monster(kind: i64) -> bool { kind == 3004 || kind == 9 || kind == 3001 || kind == 3002 || kind == 58 }
fn monster_prefix(kind: i64) -> const u8[:] {
    match kind { 3004 => "POSS", 9 => "SPOS", 3001 => "TROO", _ => "SARG" }
}
fn monster_health(kind: i64) -> i64 {
    match kind { 3004 => 20, 9 => 30, 3001 => 60, _ => 150 }
}
fn thing_picture(kind: i64) -> const u8[:] {
    match kind {
        3004 => "POSSA1", 9 => "SPOSA1", 3001 => "TROOA1", 3002 => "SARGA1", 58 => "SARGA1",
        2001 => "SHOTA0", 2002 => "MGUNA0", 2003 => "LAUNA0", 2004 => "PLASA0",
        2005 => "CSAWA0", 2006 => "BFUGA0", 2007 => "CLIPA0", 2008 => "SHELA0",
        2010 => "ROCKA0", 2046 => "BROKA0", 2047 => "CELLA0", 2048 => "AMMOA0",
        2049 => "SBOXA0", 17 => "CELPA0", 8 => "BPAKA0",
        2011 => "STIMA0", 2012 => "MEDIA0", 2013 => "SOULA0", 2014 => "BON1A0",
        2015 => "BON2A0", 2018 => "ARM1A0", 2019 => "ARM2A0", 2023 => "PSTRA0",
        5 => "BKEYA0", 2035 => "BAR1A0", 2028 => "COLUA0",
        43 => "TRE1A0", 54 => "TRE2A0", 47 => "SMITA0", 48 => "ELECA0",
        10 => "PLAYW0", 12 => "PLAYW0", 15 => "PLAYN0", 18 => "POSSL0",
        19 => "SPOSL0", 20 => "TROOM0", 21 => "SARGN0", 24 => "POL5A0",
        26 => "POL6A0", 60 => "GOR4A0", _ => "",
    }
}

let SOUND_NAMES: const u8[:][12] = ["DSPISTOL", "DSSHOTGN", "DSPUNCH", "DSPLPAIN",
    "DSITEMUP", "DSWPNUP", "DSDOROPN", "DSPSTART", "DSSWTCHN", "DSFIRSHT", "DSBAREXP", "DSPODTH1"];
var sounds: audio::Sound[12];
var sound_ready = false;
fn load_sounds(quiet: bool) {
    if quiet || !audio::available() { return; }
    sound_ready = audio::open();
    if !sound_ready {
        write_stderr(str("audio unavailable; continuing silently: ", audio::error(), "\n"));
        return;
    }
    for i in SOUND_NAMES.len {
        let data = lump(SOUND_NAMES[i]);
        require(data.len >= 8 && u16le(data, 0) == 3, "expected DMX PCM sound");
        let rate = u16le(data, 2);
        let count = u32le(data, 4);
        require(rate >= 8000 && rate <= 192000 && count > 32 && binary::range_ok(data, 8, count), "bad DMX sound");
        var pcm: f32[>..] = [];
        // DMX adds sixteen padding samples at each end.
        for j in 16..count - 16 { pcm.push((data[8 + j] - 128.0) / 128.0); }
        sounds[i] = audio::create_sound(pcm, rate);
        require(sounds[i].id != 0, audio::error());
    }
}
fn sound(id: i64, pos: float2) {
    if !sound_ready { return; }
    let delta = pos - player.pos;
    let dist = length(delta);
    let right = float2 { sin(player.yaw), -cos(player.yaw) };
    let pan = clamp(dot(delta, right) / max(dist, 1.0), -1.0, 1.0);
    audio::play(sounds[id], clamp(1.0 - dist / 1400.0, 0.0, 0.7), pan);
}
fn reset_game() {
    actors.resize(0);
    for &f in fireballs { f.life = 0.0; }
    next_fireball = 0;
    kills = 0;
    secrets = 0;
    total_monsters = 0;
    total_secrets = 0;
    game_time = 0.0;
    damage_clock = 0.0;
    random_state = 12345;
    won = false;
    for &line in lines { line.activated = false; }
    for &s in sectors {
        s.floor = s.start_floor;
        s.ceiling = s.start_ceiling;
        s.motion = 0;
        s.wait = 0.0;
        s.secret_found = false;
        if s.special == 9 { total_secrets++; }
    }
    player = Player { health: 100, bullets: 50, weapon: 2, .. };
    let things = records("THINGS", 10);
    var have_start = false;
    for i in things.len / 10 {
        let at = i * 10;
        let pos = float2 { i16le(things, at), i16le(things, at + 2) };
        let kind = u16le(things, at + 6);
        let flags = u16le(things, at + 8);
        let z = sectors[sector_at(pos)].floor;
        if kind == 1 {
            player.pos = pos;
            player.z = z;
            player.yaw = i16le(things, at + 4) * PI / 180.0;
            have_start = true;
        }
        // Skill bit 2 is medium; bit 16 marks multiplayer-only things.
        if (flags & 2) == 0 || (flags & 16) != 0 { continue; }
        let name = thing_picture(kind);
        if name.len == 0 { continue; }
        let picture = find_picture(name, 0);
        require(picture >= 0, str("missing thing sprite ", name));
        let enemy = monster(kind);
        let solid = enemy || kind == 2035 || kind == 2028 || kind == 43 ||
                    kind == 54 || kind == 47 || kind == 48;
        require(actors.len < 512, "too many map things");
        actors.push(Actor { pos: pos, z: z, kind: kind, picture: picture,
            radius: if kind == 3002 || kind == 58 { 30.0 } else { 16.0 },
            health: if enemy { monster_health(kind) } else if kind == 2035 { 20 } else { 0 },
            alive: true, solid: solid, timer: 0.5 + i % 10 * 0.1, .. });
        if enemy { total_monsters++; }
    }
    require(have_start, "missing player-one start");
    if sound_ready { audio::stop_all(); }
    say("FIND THE BLUE KEY AND THE EXIT.  H: HELP");
}

// --- collision, sight and walking -------------------------------------------

fn nearest_on_line(p: float2, a: float2, b: float2) -> float2 {
    let d = b - a;
    let t = clamp(dot(p - a, d) / max(dot(d, d), 0.001), 0.0, 1.0);
    a + scale2(d, t)
}
// Intersection distance along a finite ray. -1 means no intersection.
fn intersect_ray(pos: float2, direction: float2, a: float2, b: float2) -> f32 {
    let edge = b - a;
    let denominator = cross2(direction, edge);
    if abs(denominator) < 0.00001 { return -1.0; }
    let t = cross2(a - pos, edge) / denominator;
    let u = cross2(a - pos, direction) / denominator;
    if t > 0.001 && u >= 0.0 && u <= 1.0 { t } else { -1.0 }
}
fn wall_distance(pos: float2, z: f32, direction: float2, slope: f32, limit: f32) -> f32 {
    var nearest = limit;
    for line in lines {
        let t = intersect_ray(pos, direction, line.a, line.b);
        if t < 0.0 || t >= nearest { continue; }
        let back = back_sector(line);
        if back < 0 { nearest = t; continue; }
        let a = sectors[front_sector(line)];
        let b = sectors[back];
        let height = z + t * slope;
        if height <= max(a.floor, b.floor) || height >= min(a.ceiling, b.ceiling) { nearest = t; }
    }
    nearest
}
fn visible(a: float2, az: f32, b: float2, bz: f32) -> bool {
    let dist = distance2(a, b);
    if dist < 0.1 { return true; }
    wall_distance(a, az, scale2(b - a, 1.0 / dist), (bz - az) / dist, dist) >= dist - 0.1
}
fn can_stand(pos: float2, z: f32, radius: f32, height: f32, ignore: i64, enemy: bool) -> bool {
    let here = sectors[sector_at(pos)];
    if here.floor > z + 24.0 || here.ceiling < max(z, here.floor) + height { return false; }
    for line in lines {
        // A tiny tolerance lets a circle slide along an exactly tangent
        // wall without floating-point drift turning contact into overlap.
        if distance2(pos, nearest_on_line(pos, line.a, line.b)) >= radius - 0.01 { continue; }
        let back = back_sector(line);
        if back < 0 || (line.flags & 1) != 0 || (enemy && (line.flags & 2) != 0) { return false; }
        let a = sectors[front_sector(line)];
        let b = sectors[back];
        let bottom = max(a.floor, b.floor);
        let top = min(a.ceiling, b.ceiling);
        if bottom > z + 24.0 || top - max(z, bottom) < height { return false; }
    }
    for i in actors.len {
        if i == ignore { continue; }
        let a = actors[i];
        if !a.alive || !a.solid { continue; }
        if abs(a.z - z) < height && distance2(pos, a.pos) < radius + a.radius { return false; }
    }
    if enemy && player.health > 0 && abs(player.z - z) < height &&
        distance2(pos, player.pos) < radius + PLAYER_RADIUS { return false; }
    true
}
fn slide(pos: float2, z: f32, delta: float2, radius: f32, ignore: i64, enemy: bool) -> float2 {
    var next = pos + delta;
    if can_stand(next, z, radius, PLAYER_HEIGHT, ignore, enemy) { return next; }
    // Axis fallback is small and forgiving. Substeps below prevent tunneling.
    next = float2 { pos.x + delta.x, pos.y };
    if !can_stand(next, z, radius, PLAYER_HEIGHT, ignore, enemy) { next = pos; }
    let y = float2 { next.x, pos.y + delta.y };
    if can_stand(y, z, radius, PLAYER_HEIGHT, ignore, enemy) { next = y; }
    next
}
fn walk(direction: float2, running: bool) {
    let forward = float2 { cos(player.yaw), sin(player.yaw) };
    let right = float2 { forward.y, -forward.x };
    var movement = scale2(forward, direction.y) + scale2(right, direction.x);
    if length(movement) > 1.0 { movement = normalize(movement); }
    movement = scale2(movement, (if running { 260.0 } else { 170.0 }) * STEP);
    let old = player.pos;
    let steps = max(1, ceil(length(movement) / 4.0) as i64);
    for i in steps {
        player.pos = slide(player.pos, player.z, scale2(movement, 1.0 / steps), PLAYER_RADIUS, -1, false);
        let floor_z = sectors[sector_at(player.pos)].floor;
        if floor_z > player.z { player.z = floor_z; player.falling = 0.0; }
    }
    let ground = sectors[sector_at(player.pos)].floor;
    if player.z > ground {
        player.falling -= 700.0 * STEP;
        player.z = max(ground, player.z + player.falling * STEP);
    } else { player.z = ground; player.falling = 0.0; }
    player.bob += distance2(old, player.pos) * 0.055;
    if distance2(old, player.pos) > 0.001 {
        for i in lines.len {
            let line = lines[i];
            if line.special != 2 && line.special != 88 { continue; }
            let t = intersect_ray(old, player.pos - old, line.a, line.b);
            if t > 0.0 && t <= 1.0 { activate_line(i, true); }
        }
    }
}

// --- moving sectors and use triggers ----------------------------------------

fn neighbor_limits(sector: i64) -> f32, f32 {
    var low = sectors[sector].start_floor;
    var top: f32 = 32768.0;
    for line in lines {
        let a = front_sector(line);
        let b = back_sector(line);
        var neighbor = -1;
        if a == sector { neighbor = b; }
        else if b == sector { neighbor = a; }
        if neighbor >= 0 && neighbor != sector {
            low = min(low, sectors[neighbor].floor);
            top = min(top, sectors[neighbor].ceiling);
        }
    }
    return low, top;
}
fn open_door(index: i64, fast: bool, stay: bool) {
    let s .= sectors[index];
    let low, top = neighbor_limits(index);
    if top == 32768.0 { return; }
    s.target = top - 4.0;
    s.motion = if stay { 4 } else { 1 };  // opening, waiting, closing, stay open
    s.speed = if fast { 280.0 } else { 90.0 };
    s.wait = 0.0;
}
fn activate_line(index: i64, crossing: bool) {
    let line = lines[index];
    let special = line.special;
    if crossing != (special == 2 || special == 88) { return; }
    if special == 11 {
        lines[index].activated = true;
        won = true;
        sound(8, player.pos);
        say("LEVEL COMPLETE!  R: PLAY AGAIN");
    } else if special == 1 || special == 26 || special == 117 {
        if special == 26 && !player.blue_key { say("YOU NEED THE BLUE KEY"); return; }
        let back = back_sector(line);
        if back >= 0 { open_door(back, special == 117, false); sound(6, player.pos); }
    } else if special == 2 || special == 62 || special == 88 || special == 23 {
        lines[index].activated = true;
        for i in sectors.len {
            if line.tag == 0 || sectors[i].tag != line.tag { continue; }
            if special == 2 { open_door(i, false, true); }
            else if sectors[i].motion == 0 {
                let low, top = neighbor_limits(i);
                sectors[i].target = low;
                sectors[i].speed = 120.0;
                sectors[i].motion = if special == 23 { 8 } else { 5 };
            }
        }
        sound(if special == 2 { 6 } else { 7 }, player.pos);
    }
}
fn use() {
    let direction = float2 { cos(player.yaw), sin(player.yaw) };
    var nearest: f32 = 80.0;
    var found = -1;
    for i in lines.len {
        let line = lines[i];
        let t = intersect_ray(player.pos, direction, line.a, line.b);
        if t < 0.0 || t >= nearest { continue; }
        let b = back_sector(line);
        // Usable faces and closed passages stop the use ray. A farther
        // switch must never be activated through a wall or a closed door.
        let blocks = b < 0 || (b >= 0 && sectors[b].ceiling <= sectors[b].floor);
        let usable = line.special != 0 && line.special != 2 && line.special != 88 &&
                     cross2(line.b - line.a, player.pos - line.a) <= 0.0;
        if blocks || usable { nearest = t; found = if usable { i } else { -1 }; }
    }
    if found >= 0 { activate_line(found, false); }
}
fn occupied(index: i64, ceiling: f32) -> bool {
    if sector_at(player.pos) == index && player.z + PLAYER_HEIGHT > ceiling { return true; }
    for a in actors {
        if a.alive && a.solid && sector_at(a.pos) == index && a.z + PLAYER_HEIGHT > ceiling { return true; }
    }
    false
}
fn move_sectors() {
    for i in sectors.len {
        let s .= sectors[i];
        if s.motion == 0 { continue; }
        let amount = s.speed * STEP;
        if s.motion == 1 || s.motion == 4 {
            s.ceiling = min(s.target, s.ceiling + amount);
            if s.ceiling == s.target {
                s.motion = if s.motion == 4 { 0 } else { 2 };
                s.wait = 4.0;
            }
        } else if s.motion == 2 {
            s.wait -= STEP;
            if s.wait <= 0.0 { s.motion = 3; }
        } else if s.motion == 3 {
            let next = max(s.floor, s.ceiling - amount);
            if occupied(i, next) { s.motion = 1; }
            else { s.ceiling = next; if next == s.floor { s.motion = 0; } }
        } else {
            let old_floor = s.floor;
            if s.motion == 5 || s.motion == 8 {
                s.floor = max(s.target, s.floor - amount);
                if s.floor == s.target {
                    s.motion = if s.motion == 8 { 0 } else { 6 };
                    s.wait = 3.0;
                }
            } else if s.motion == 6 {
                s.wait -= STEP;
                if s.wait <= 0.0 { s.motion = 7; }
            } else if s.motion == 7 {
                let next = min(s.start_floor, s.floor + amount);
                // A lift reverses instead of crushing someone into a ceiling.
                if occupied(i, s.ceiling - (next - s.floor)) { s.motion = 5; }
                else { s.floor = next; if next == s.start_floor { s.motion = 0; } }
            }
            if sector_at(player.pos) == i && abs(player.z - old_floor) < 0.1 { player.z = s.floor; }
            for &a in actors {
                if a.alive && sector_at(a.pos) == i && abs(a.z - old_floor) < 0.1 { a.z = s.floor; }
            }
        }
    }
}

// --- pickups and combat -----------------------------------------------------

fn hurt_player(damage: i64) {
    if player.health <= 0 || won { return; }
    let absorbed = min(player.armor, damage / 3);
    player.armor -= absorbed;
    player.health = max(0, player.health - damage + absorbed);
    player.hurt = 0.35;
    sound(3, player.pos);
    if player.health == 0 { say("YOU DIED.  R: TRY AGAIN"); }
}
fn pickup(index: i64) {
    let a .= actors[index];
    let kind = a.kind;
    var taken = false;
    var text: const u8[:] = "PICKED UP AMMO";
    if kind == 5 { player.blue_key = true; taken = true; text = "BLUE KEY"; }
    else if kind == 2001 {
        taken = !player.shotgun || player.shells < 100;
        if !player.shotgun { player.weapon = 3; }
        player.shotgun = true;
        player.shells = min(100, player.shells + 8);
        text = "SHOTGUN";
    } else if kind == 2002 {
        taken = !player.chaingun || player.bullets < 300;
        if !player.chaingun { player.weapon = 4; }
        player.chaingun = true;
        player.bullets = min(300, player.bullets + 20);
        text = "CHAINGUN";
    } else if kind == 2008 || kind == 2049 {
        taken = player.shells < 100;
        player.shells = min(100, player.shells + (if kind == 2049 { 20 } else { 4 }));
        text = "SHELLS";
    } else if kind == 2007 || kind == 2048 || kind == 8 || kind == 2010 || kind == 2046 ||
              kind == 2047 || kind == 17 || kind == 2003 || kind == 2004 || kind == 2005 {
        taken = player.bullets < 300;
        player.bullets = min(300, player.bullets + (if kind == 2048 || kind == 8 { 50 } else { 10 }));
        if kind == 8 { player.shells = min(100, player.shells + 8); }
        if kind != 2007 && kind != 2048 && kind != 8 { text = "SALVAGED BULLETS"; }
    } else if kind == 2011 || kind == 2012 || kind == 2013 || kind == 2014 || kind == 2023 {
        let maximum = if kind == 2013 || kind == 2014 { 200 } else { 100 };
        taken = player.health < maximum;
        let amount = match kind { 2014 => 1, 2011 => 10, 2012 => 25, _ => 100 };
        if taken { player.health = min(maximum, player.health + amount); }
        text = "HEALTH";
    } else if kind == 2015 || kind == 2018 || kind == 2019 {
        let amount = match kind { 2015 => 1, 2018 => 100, _ => 200 };
        taken = if kind == 2015 { player.armor < 200 } else { player.armor < amount };
        if taken { player.armor = if kind == 2015 { player.armor + 1 } else { amount }; }
        text = "ARMOR";
    }
    if taken {
        a.alive = false;
        player.bonus = 0.18;
        say(text);
        sound(if kind == 2001 || kind == 2002 { 5 } else { 4 }, player.pos);
    }
}
fn damage_actor(index: i64, damage: i64) {
    let a .= actors[index];
    if !a.alive || a.health <= 0 { return; }
    a.health -= damage;
    a.awake = true;
    a.pain = 0.18;
    if a.health > 0 { return; }
    a.solid = false;
    a.death = 0.001;
    if monster(a.kind) { kills++; sound(11, a.pos); }
    // Barrel explosions are processed by update_actors, avoiding recursive
    // chain reactions on the native call stack.
}
fn shoot() {
    if player.cooldown > 0.0 || player.health <= 0 { return; }
    let weapon = player.weapon;
    if (weapon == 3 && player.shells == 0) || ((weapon == 2 || weapon == 4) && player.bullets == 0) {
        player.weapon = 1;
        say("OUT OF AMMO - FISTS");
        return;
    }
    if weapon == 3 { player.shells--; }
    else if weapon != 1 { player.bullets--; }
    player.cooldown = match weapon { 1 => 0.42, 2 => 0.28, 3 => 0.85, _ => 0.10 };
    player.flash = 0.09;
    sound(if weapon == 1 { 2 } else if weapon == 3 { 1 } else { 0 }, player.pos);
    let pellets = if weapon == 3 { 7 } else { 1 };
    for pellet in pellets {
        let spread: f32 = if weapon == 3 { (random_int(101) - 50) * 0.0015 }
                         else if weapon == 4 { (random_int(101) - 50) * 0.0005 } else { 0.0 };
        let direction = float2 { cos(player.yaw + spread), sin(player.yaw + spread) };
        var nearest: f32 = if weapon == 1 { 70.0 } else { 1800.0 };
        var target = -1;
        for i in actors.len {
            let a = actors[i];
            if !a.alive || a.health <= 0 { continue; }
            let delta = a.pos - player.pos;
            let along = dot(delta, direction);
            let across = abs(cross2(direction, delta));
            if along <= 0.0 || along >= nearest || across > a.radius { continue; }
            // Classic horizontal aiming: a modest automatic vertical aim
            // helps with stairs, but a ray through the opening must be clear.
            if abs(a.z + 28.0 - (player.z + 41.0)) > 40.0 + along * 0.5 { continue; }
            if visible(player.pos, player.z + 41.0, a.pos, a.z + 28.0) {
                target = i;
                nearest = along;
            }
        }
        if target >= 0 {
            let damage = if weapon == 1 { 12 + random_int(12) } else { 5 * (1 + random_int(3)) };
            damage_actor(target, damage);
        }
    }
    // Noise wakes nearby monsters only through open sight lines.
    for &a in actors {
        if monster(a.kind) && distance2(a.pos, player.pos) < 1000.0 &&
            visible(a.pos, a.z + 32.0, player.pos, player.z + 32.0) { a.awake = true; }
    }
}
fn launch_fireball(a: Actor) {
    let delta = player.pos - a.pos;
    let dist = max(1.0, length(delta));
    fireballs[next_fireball] = Fireball { a.pos, a.z + 32.0, scale2(delta, 280.0 / dist),
        (player.z + 30.0 - a.z - 32.0) * 280.0 / dist, 5.0 };
    next_fireball = (next_fireball + 1) % fireballs.len;
    sound(9, a.pos);
}
fn update_actors() {
    for i in actors.len {
        let a .= actors[i];
        if !a.alive { continue; }
        if a.death > 0.0 {
            let old = a.death;
            a.death += STEP;
            if a.kind == 2035 && old < 0.15 && a.death >= 0.15 {
                sound(10, a.pos);
                let dist = distance2(a.pos, player.pos);
                if dist < 160.0 && visible(a.pos, a.z + 24.0, player.pos, player.z + 32.0) {
                    hurt_player(max(0, (120.0 - dist * 0.7) as! i64));
                }
                for j in actors.len {
                    let other = actors[j];
                    let d = distance2(a.pos, other.pos);
                    if j != i && other.health > 0 && d < 160.0 &&
                        visible(a.pos, a.z + 24.0, other.pos, other.z + 28.0) {
                        damage_actor(j, max(0, (120.0 - d * 0.7) as! i64));
                    }
                }
            }
            if a.kind == 2035 && a.death > 0.65 { a.alive = false; }
            // Guards leave a clip/shotgun after their corpse settles. The
            // dropped item is a separate actor, so the corpse remains visible.
            if old < 0.8 && a.death >= 0.8 && (a.kind == 3004 || a.kind == 9) && actors.len < 512 {
                let kind = if a.kind == 9 { 2001 } else { 2007 };
                actors.push(Actor { pos: a.pos, z: a.z, kind: kind,
                    picture: find_picture(thing_picture(kind), 0), alive: true, .. });
            }
            continue;
        }
        a.z = max(sectors[sector_at(a.pos)].floor, a.z - 220.0 * STEP);
        if !monster(a.kind) { continue; }
        a.pain = max(0.0, a.pain - STEP);
        a.attacking = max(0.0, a.attacking - STEP);
        a.timer -= STEP;
        let delta = player.pos - a.pos;
        let dist = length(delta);
        let sees = player.health > 0 && dist < 1500.0 && visible(a.pos, a.z + 36.0, player.pos, player.z + 32.0);
        if sees { a.awake = true; }
        if !a.awake || player.health <= 0 || a.pain > 0.0 { continue; }
        if sees && a.timer <= 0.0 && (dist < 650.0 || dist < a.radius + 38.0) {
            let melee = a.kind == 3002 || a.kind == 58;
            if !melee || dist < a.radius + 38.0 {
                a.attacking = 0.28;
                a.timer = 1.0 + random_int(100) * 0.012;
                if a.kind == 3001 && dist > 75.0 { launch_fireball(a); }
                else if melee || dist < 75.0 { hurt_player(5 + random_int(12)); }
                else {
                    sound(0, a.pos);
                    if random_int(100) < max(20, 85 - (dist * 0.08) as! i64) {
                        hurt_player(if a.kind == 9 { 6 + random_int(10) } else { 3 + random_int(7) });
                    }
                }
            }
        }
        if a.attacking > 0.0 || dist < 1.0 { continue; }
        let speed: f32 = if a.kind == 3002 || a.kind == 58 { 105.0 } else { 58.0 };
        let movement = scale2(delta, speed * STEP / dist);
        a.pos = slide(a.pos, a.z, movement, a.radius, i, true);
        a.z = max(a.z, sectors[sector_at(a.pos)].floor);
    }
    for &f in fireballs {
        if f.life <= 0.0 { continue; }
        f.life -= STEP;
        let next = f.pos + scale2(f.velocity, STEP);
        let next_z = f.z + f.vz * STEP;
        if !visible(f.pos, f.z, next, next_z) { f.life = 0.0; continue; }
        f.pos = next;
        f.z = next_z;
        if distance2(f.pos, player.pos) < 22.0 && f.z > player.z && f.z < player.z + PLAYER_HEIGHT {
            hurt_player(10 + random_int(15));
            f.life = 0.0;
        }
    }
}
fn tick(direction: float2, running: bool, firing: bool) {
    game_time += STEP;
    message_time = max(0.0, message_time - STEP);
    player.cooldown = max(0.0, player.cooldown - STEP);
    player.flash = max(0.0, player.flash - STEP);
    player.hurt = max(0.0, player.hurt - STEP);
    player.bonus = max(0.0, player.bonus - STEP);
    move_sectors();
    if player.health > 0 {
        walk(direction, running);
        if firing { shoot(); }
        let s .= sectors[sector_at(player.pos)];
        if s.special == 9 && !s.secret_found {
            s.secret_found = true;
            secrets++;
            say("A SECRET IS REVEALED");
        }
        damage_clock += STEP;
        if damage_clock >= 1.0 {
            damage_clock -= 1.0;
            if s.special == 7 && player.z <= s.floor + 1.0 { hurt_player(5); }
        }
        for i in actors.len {
            let a = actors[i];
            if a.alive && a.death == 0.0 && !a.solid && distance2(a.pos, player.pos) < 28.0 &&
                abs(a.z - player.z) < 32.0 { pickup(i); }
        }
    }
    update_actors();
}

// --- meshes: world units become (x, height, -y) -------------------------------

struct Vertex { pos: float3, uv: float2, rectangle: float4, light: f32 }
struct HudVertex { pos: float2, uv: float2, rectangle: float4, color: float4 }
struct Camera { view_proj: gfx::mat4, eye: float4 }

fn atlas_rect(id: i64) -> float4 {
    let p = pictures[id];
    float4 { p.x, p.y, p.w, p.h }
}
fn world_point(p: float2, z: f32) -> float3 { float3 { p.x, z, -p.y } }
fn lighting(sector: i64) -> f32 {
    let s = sectors[sector];
    if s.special == 1 && ((game_time * 9.0) as! i64 + sector * 7) % 11 < 3 { return s.light * 0.4; }
    if s.special == 12 && game_time % 1.0 < 0.15 { return s.light * 0.35; }
    max(0.18, s.light)
}
fn animated_flat(id: i64) -> i64 {
    if id < 0 { return sky_picture; }
    let name = pictures[id].name;
    if name == "NUKAGE1" || name == "NUKAGE2" || name == "NUKAGE3" {
        return find_picture(str("NUKAGE", 1 + (game_time * 4.0) as! i64 % 3), 2);
    }
    if name == "FWATER1" || name == "FWATER2" || name == "FWATER3" || name == "FWATER4" {
        return find_picture(str("FWATER", 1 + (game_time * 4.0) as! i64 % 4), 2);
    }
    id
}
fn switch_texture(id: i64, activated: bool) -> i64 {
    if id < 0 || !activated { return id; }
    let name = pictures[id].name;
    if name.len >= 3 && name[..3] == "SW1" {
        let on = find_picture(str("SW2", name[3..]), 1);
        if on >= 0 { return on; }
    }
    id
}
fn add_wall(out: Vertex[>..]&, a: float2, b: float2, bottom: f32, top: f32,
            texture: i64, offset: float2, anchor: f32, light: f32) {
    if texture < 0 || top <= bottom { return; }
    let pic = pictures[texture];
    let u0 = offset.x / pic.w;
    let u1 = (offset.x + distance2(a, b)) / pic.w;
    let v0 = (anchor - top + offset.y) / pic.h;
    let v1 = (anchor - bottom + offset.y) / pic.h;
    let rectangle = atlas_rect(texture);
    let corners: Vertex[4] = [
        Vertex { world_point(a, bottom), float2 { u0, v1 }, rectangle, light },
        Vertex { world_point(b, bottom), float2 { u1, v1 }, rectangle, light },
        Vertex { world_point(b, top), float2 { u1, v0 }, rectangle, light },
        Vertex { world_point(a, top), float2 { u0, v0 }, rectangle, light }];
    for i in [0, 1, 2, 0, 2, 3] { out.push(corners[i]); }
}
fn world_mesh(out: Vertex[>..]&) {
    for triangle in floor_triangles {
        let s = sectors[triangle.sector];
        for plane in 2 {
            let height = if plane == 0 { s.floor } else { s.ceiling };
            let original = if plane == 0 { s.floor_pic } else { s.ceiling_pic };
            let texture = animated_flat(original);
            let shade = if original < 0 { -1.0 } else { lighting(triangle.sector) };
            let rectangle = atlas_rect(texture);
            for p in [triangle.a, triangle.b, triangle.c] {
                out.push(Vertex { world_point(p, height), scale2(p, 1.0 / 64.0), rectangle, shade });
            }
        }
    }
    for line in lines {
        for face in 2 {
            let side_id = if face == 0 { line.front } else { line.back };
            if side_id < 0 { continue; }
            let other_id = if face == 0 { line.back } else { line.front };
            var side = sides[side_id];
            side.upper = switch_texture(side.upper, line.activated);
            side.lower = switch_texture(side.lower, line.activated);
            side.middle = switch_texture(side.middle, line.activated);
            let s = sectors[side.sector];
            let a = if face == 0 { line.a } else { line.b };
            let b = if face == 0 { line.b } else { line.a };
            // Only the side facing the viewer contributes. This also avoids
            // two coplanar middle textures fighting for the depth buffer.
            if cross2(b - a, player.pos - a) > 0.0 { continue; }
            let shade = lighting(side.sector) * (if abs(a.x - b.x) < 0.1 { 0.88 } else { 1.0 });
            if other_id < 0 {
                if side.middle < 0 { continue; }
                let anchor = if (line.flags & 16) != 0 { s.floor + pictures[side.middle].h } else { s.ceiling };
                add_wall(out, a, b, s.floor, s.ceiling, side.middle, side.offset, anchor, shade);
                continue;
            }
            let other = sectors[sides[other_id].sector];
            if side.upper >= 0 && !(s.ceiling_pic < 0 && other.ceiling_pic < 0) {
                let anchor = if (line.flags & 8) != 0 { s.ceiling }
                             else { other.ceiling + pictures[side.upper].h };
                add_wall(out, a, b, max(s.floor, other.ceiling), s.ceiling,
                         side.upper, side.offset, anchor, shade);
            }
            if side.lower >= 0 {
                let anchor = if (line.flags & 16) != 0 { s.ceiling } else { other.floor };
                add_wall(out, a, b, s.floor, min(s.ceiling, other.floor),
                         side.lower, side.offset, anchor, shade);
            }
            if side.middle >= 0 {
                let bottom = max(s.floor, other.floor);
                let top = min(s.ceiling, other.ceiling);
                let height: f32 = pictures[side.middle].h;
                let anchor = if (line.flags & 16) != 0 { bottom + height } else { top };
                add_wall(out, a, b, max(bottom, anchor + side.offset.y - height),
                         min(top, anchor + side.offset.y), side.middle, side.offset, anchor, shade);
            }
        }
    }
}
fn frame_picture(prefix: const u8[:], frame: i64) -> i64 {
    let letter: u8[1] = [('A' + frame) as u8];
    var id = find_picture(str(prefix, letter, "0"), 0);
    if id < 0 { id = find_picture(str(prefix, letter, "1"), 0); }
    require(id >= 0, str("missing sprite frame ", prefix, letter));
    id
}
fn actor_picture(a: Actor) -> i64 {
    if a.kind == 2035 {
        if a.death > 0.0 { return frame_picture("BEXP", min(4, (a.death * 8.0) as! i64)); }
        return frame_picture("BAR1", (game_time * 4.0) as! i64 % 2);
    }
    if !monster(a.kind) { return a.picture; }
    let prefix = monster_prefix(a.kind);
    if a.death > 0.0 {
        let first = if a.kind == 3001 || a.kind == 3002 || a.kind == 58 { 8 } else { 7 };
        let last = if a.kind == 3002 || a.kind == 58 { 5 } else { 4 };
        return frame_picture(prefix, first + min(last, (a.death * 7.0) as! i64));
    }
    if a.pain > 0.0 {
        return frame_picture(prefix, if a.kind == 3004 || a.kind == 9 { 6 } else { 7 });
    }
    if a.attacking > 0.0 { return frame_picture(prefix, 5); }
    frame_picture(prefix, if a.awake { (game_time * 8.0) as! i64 % 4 } else { 0 })
}
fn billboard(out: Vertex[>..]&, pos: float2, bottom: f32, id: i64, shade: f32) {
    let p = pictures[id];
    let right = float2 { sin(player.yaw), -cos(player.yaw) };
    // Use the WAD's anchor: different animation frames then stay registered.
    let a = pos - scale2(right, p.left);
    let b = a + scale2(right, p.w);
    let top = bottom + p.top;
    add_wall(out, a, b, top - p.h, top, id, float2_0, top, shade);
}
fn sprite_mesh(out: Vertex[>..]&) {
    for a in actors {
        if !a.alive { continue; }
        let shade = if a.kind == 2035 && a.death > 0.0 { 1.0 } else { lighting(sector_at(a.pos)) };
        billboard(out, a.pos, a.z, actor_picture(a), shade);
    }
    for f in fireballs {
        if f.life > 0.0 { billboard(out, f.pos, f.z - 8.0, frame_picture("BAL1", (game_time * 8.0) as! i64 % 2), 1.0); }
    }
}

// --- a small HUD, using the WAD's own font and weapon patches -----------------
// These glyphs are game artwork in the sprite atlas. For ordinary fonts/text,
// use ui's existing font and text renderer over gfx (see docs/stdlib.md).

let WHITE = float4 { 1.0, 1.0, 1.0, 1.0 };
fn hud_quad(out: HudVertex[>..]&, x: f32, y: f32, w: f32, h: f32, id: i64, color: float4) {
    let rectangle = atlas_rect(id);
    let points: float2[4] = [float2 { x, y }, float2 { x + w, y },
                            float2 { x + w, y + h }, float2 { x, y + h }];
    let uv: float2[4] = [float2 { 0.0, 0.0 }, float2 { 1.0, 0.0 },
                        float2 { 1.0, 1.0 }, float2 { 0.0, 1.0 }];
    for i in [0, 1, 2, 0, 2, 3] { out.push(HudVertex { points[i], uv[i], rectangle, color }); }
}
fn hud_picture(out: HudVertex[>..]&, x: f32, y: f32, scale: f32, id: i64) {
    let p = pictures[id];
    hud_quad(out, x, y, p.w * scale, p.h * scale, id, WHITE);
}
fn text_width(text: const u8[:]) -> f32 {
    var width: f32 = 0.0;
    for c in text { width += if c <= 32 || c >= 96 { 4.0 } else { pictures[font[c]].w + 1.0 }; }
    width
}
fn text(out: HudVertex[>..]&, x: f32, y: f32, words: const u8[:], color: float4) {
    var cursor = x;
    for c in words {
        if c <= 32 || c >= 96 { cursor += 4.0; continue; }
        let id = font[c];
        let p = pictures[id];
        hud_quad(out, cursor + 1.0, y + 1.0, p.w, p.h, id, float4 { 0.0, 0.0, 0.0, color.w });
        hud_quad(out, cursor, y, p.w, p.h, id, color);
        cursor += p.w + 1.0;
    }
}
fn centered(out: HudVertex[>..]&, width: f32, y: f32, words: const u8[:]) {
    text(out, (width - text_width(words)) * 0.5, y, words, WHITE);
}
fn map_line(out: HudVertex[>..]&, a: float2, b: float2, color: float4) {
    let d = b - a;
    let normal = scale2(float2 { -d.y, d.x }, 0.45 / max(0.1, length(d)));
    let rect = atlas_rect(0);
    let points: float2[4] = [a - normal, b - normal, b + normal, a + normal];
    for i in [0, 1, 2, 0, 2, 3] { out.push(HudVertex { points[i], float2_0, rect, color }); }
}
fn automap(out: HudVertex[>..]&, screen: float2) {
    hud_quad(out, 0.0, 0.0, screen.x, screen.y - 34.0, 0, float4 { 0.015, 0.025, 0.025, 0.94 });
    let center = float2 { screen.x * 0.5, (screen.y - 34.0) * 0.5 };
    for line in lines {
        let a = center + (line.a - player.pos) * float2 { 0.09, -0.09 };
        let b = center + (line.b - player.pos) * float2 { 0.09, -0.09 };
        let color = if line.special == 26 { float4 { 0.2, 0.45, 1.0, 1.0 } }
                    else if line.special != 0 { float4 { 1.0, 0.8, 0.25, 1.0 } }
                    else if line.back < 0 { float4 { 0.8, 0.23, 0.12, 1.0 } }
                    else { float4 { 0.3, 0.33, 0.3, 0.6 } };
        map_line(out, a, b, color);
    }
    let forward = float2 { cos(player.yaw), -sin(player.yaw) };
    let right = float2 { -forward.y, forward.x };
    let nose = center + scale2(forward, 6.0);
    map_line(out, center - scale2(right, 4.0), nose, WHITE);
    map_line(out, center + scale2(right, 4.0), nose, WHITE);
}
fn draw_hud(out: HudVertex[>..]&, screen: float2, paused: bool, help: bool, map: bool) {
    let bar_y = screen.y - 34.0;
    let center = screen.x * 0.5;
    if player.health > 0 && !map {
        let prefix = match player.weapon { 1 => "PUNG", 2 => "PISG", 3 => "SHTG", _ => "CHGG" };
        let frame = if player.weapon == 3 && player.cooldown > 0.12 {
            min(3, 1 + ((0.85 - player.cooldown) * 4.0) as! i64)
        } else if player.cooldown > 0.12 && player.weapon != 4 { 1 } else { 0 };
        let id = frame_picture(prefix, frame);
        let p = pictures[id];
        let bob = sin(player.bob) * 1.4;
        hud_picture(out, center - 160.0 - p.left + bob, bar_y - 168.0 - p.top + abs(bob), 1.0, id);
        if player.flash > 0.0 && player.weapon != 1 {
            let flash_id = frame_picture(if player.weapon == 3 { "SHTF" }
                                         else if player.weapon == 4 { "CHGF" } else { "PISF" }, 0);
            let f = pictures[flash_id];
            hud_picture(out, center - 160.0 - f.left + bob, bar_y - 168.0 - f.top + abs(bob), 1.0, flash_id);
        }
        hud_quad(out, center - 2.0, bar_y * 0.5, 5.0, 1.0, 0, float4 { 0.9, 0.9, 0.7, 0.7 });
        hud_quad(out, center, bar_y * 0.5 - 2.0, 1.0, 5.0, 0, float4 { 0.9, 0.9, 0.7, 0.7 });
    }
    if map { automap(out, screen); }
    if player.hurt > 0.0 {
        hud_quad(out, 0.0, 0.0, screen.x, bar_y, 0, float4 { 0.7, 0.0, 0.0, player.hurt * 0.7 });
    } else if player.bonus > 0.0 {
        hud_quad(out, 0.0, 0.0, screen.x, bar_y, 0, float4 { 1.0, 0.8, 0.2, player.bonus * 0.6 });
    }
    hud_quad(out, 0.0, bar_y, screen.x, 34.0, 0, float4 { 0.12, 0.12, 0.13, 1.0 });
    let face: u8[>..] = if player.health <= 0 { "STFDEAD0" }
        else { str("STFST", clamp((100 - player.health) / 20, 0, 4), "0") };
    hud_picture(out, center - 12.0, bar_y + 3.0, 1.0, find_picture(face, 0));
    text(out, 7.0, bar_y + 4.0, str("HEALTH ", player.health, "%"), WHITE);
    text(out, 7.0, bar_y + 18.0, str("ARMOR  ", player.armor, "%"), WHITE);
    let ammo: u8[>..] = if player.weapon == 1 { "FISTS" } else if player.weapon == 3 { str("SHELLS ", player.shells) }
               else { str("AMMO ", player.bullets) };
    text(out, center + 28.0, bar_y + 4.0, ammo, WHITE);
    text(out, center + 28.0, bar_y + 18.0, str("KILLS ", kills, "/", total_monsters), WHITE);
    if player.blue_key { hud_picture(out, screen.x - 14.0, bar_y + 3.0, 0.7, find_picture("BKEYA0", 0)); }
    if message_time > 0.0 { centered(out, screen.x, 7.0, message); }
    if help {
        hud_quad(out, 4.0, 22.0, 266.0, 56.0, 0, float4 { 0.0, 0.0, 0.0, 0.7 });
        text(out, 8.0, 26.0, "WASD: WALK  SHIFT: RUN  MOUSE: TURN", WHITE);
        text(out, 8.0, 38.0, "E/SPACE: USE  CLICK/CTRL: FIRE", WHITE);
        text(out, 8.0, 50.0, "1-4: WEAPON  TAB: MAP  H: HELP", WHITE);
        text(out, 8.0, 62.0, "ESC: PAUSE  F12: SCREENSHOT", WHITE);
    }
    if paused || player.health <= 0 || won {
        hud_quad(out, 0.0, bar_y * 0.5 - 22.0, screen.x, 48.0, 0, float4 { 0.0, 0.0, 0.0, 0.75 });
        centered(out, screen.x, bar_y * 0.5 - 15.0,
                 if won { "LEVEL COMPLETE" } else if player.health <= 0 { "YOU DIED" } else { "PAUSED" });
        centered(out, screen.x, bar_y * 0.5,
                 if won || player.health <= 0 { "R: RESTART" } else { "ESC OR CLICK TO RESUME" });
        if won { centered(out, screen.x, bar_y * 0.5 + 12.0, str("SECRETS ", secrets, "/", total_secrets)); }
    }
}

// --- GPU programs -----------------------------------------------------------

let world_vertex = """
    layout(location = 0) in vec3 a_pos;
    layout(location = 1) in vec2 a_uv;
    layout(location = 2) in vec4 a_rectangle;
    layout(location = 3) in float a_light;
    layout(set = 1, binding = 0) uniform Camera { mat4 view_proj; vec4 eye; };
    layout(location = 0) out vec2 uv;
    layout(location = 1) flat out vec4 rectangle;
    layout(location = 2) flat out float light;
    layout(location = 3) out vec3 direction;
    void main() {
        uv = a_uv;
        rectangle = a_rectangle;
        light = a_light;
        direction = a_pos - eye.xyz;
        gl_Position = view_proj * vec4(a_pos, 1.0);
    }
    """;
let world_fragment = """
    layout(location = 0) in vec2 uv;
    layout(location = 1) flat in vec4 rectangle;
    layout(location = 2) flat in float light;
    layout(location = 3) in vec3 direction;
    layout(set = 2, binding = 0) uniform sampler2D atlas;
    layout(location = 0) out vec4 result;
    void main() {
        vec2 local = fract(uv);
        float shade = light;
        if (light < 0.0) {
            float angle = atan(-direction.z, direction.x);
            local.x = fract(-angle * 2.0 / 3.14159265);
            local.y = clamp(0.55 - atan(direction.y, length(direction.xz)) * 0.8, 0.0, 0.99);
            shade = 1.0;
        } else {
            shade *= max(0.38, 1.0 - length(direction) / 1800.0);
            shade = floor(shade * 24.0) / 24.0;
        }
        vec2 texel = rectangle.xy + floor(local * rectangle.zw) + 0.5;
        vec4 sample_color = texture(atlas, texel / vec2(2048.0, 4096.0));
        if (sample_color.a < 0.5) discard;
        result = vec4(sample_color.rgb * shade, 1.0);
    }
    """;
let hud_vertex = """
    layout(location = 0) in vec2 a_pos;
    layout(location = 1) in vec2 a_uv;
    layout(location = 2) in vec4 a_rectangle;
    layout(location = 3) in vec4 a_color;
    layout(set = 1, binding = 0) uniform Canvas { vec4 screen; };
    layout(location = 0) out vec2 uv;
    layout(location = 1) flat out vec4 rectangle;
    layout(location = 2) out vec4 color;
    void main() {
        uv = a_uv;
        rectangle = a_rectangle;
        color = a_color;
        gl_Position = vec4(a_pos.x / screen.x * 2.0 - 1.0, 1.0 - a_pos.y / screen.y * 2.0, 0.0, 1.0);
    }
    """;
let hud_fragment = """
    layout(location = 0) in vec2 uv;
    layout(location = 1) flat in vec4 rectangle;
    layout(location = 2) in vec4 color;
    layout(set = 2, binding = 0) uniform sampler2D atlas;
    layout(location = 0) out vec4 result;
    void main() {
        vec2 texel = rectangle.xy + clamp(floor(uv * rectangle.zw), vec2(0.0), rectangle.zw - 1.0) + 0.5;
        result = texture(atlas, texel / vec2(2048.0, 4096.0)) * color;
    }
    """;

fn upload(buffer: gfx::Buffer&, bytes: const u8[:]) {
    if buffer.id == 0 || gfx::buffer_size(buffer) < bytes.len {
        if buffer.id != 0 { gfx::release(buffer); }
        buffer = gfx::buffer(gfx::BUFFER_VERTEX, max(4096, bytes.len * 2), bytes);
        require(buffer.id != 0, gfx::error());
    } else { require(gfx::update_buffer(buffer, bytes), gfx::error()); }
}

// --- entry point: load once, then update and draw ----------------------------

fn main() {
    var frames = -1;
    var quiet = false;
    var path: u8[>..] = [];
    let argv = args();
    var argument = 1;
    while argument < argv.len {
        let option = argv[argument];
        if option == "--silent" { quiet = true; }
        else if option == "--frames" {
            argument++;
            require(argument < argv.len, "--frames needs a positive count");
            let n, ok = parse_int(argv[argument]);
            require(ok && n > 0, "--frames needs a positive count");
            frames = n;
        } else if option == "--wad" {
            argument++;
            require(argument < argv.len && path.len == 0, "--wad needs one path");
            path.append(argv[argument]);
        } else { abort(str("unknown option: ", option)); }
        argument++;
    }
    if path.len == 0 {
        path.append(resource_dir());
        path.append("data/freedoom1-e1m1.wad");
    }
    load_wad(path);
    load_pictures();
    load_map();
    reset_game();

    require(gfx::open("Goose: Mini Doom / Freedoom E1M1", 960, 600, gfx::WINDOW_RESIZABLE), gfx::error());
    load_sounds(quiet || frames > 0);
    let atlas = gfx::texture2d(ATLAS_W, ATLAS_H);
    require(atlas.id != 0 && gfx::update_texture(atlas, atlas_pixels), gfx::error());
    let sampler = gfx::sampler(gfx::NEAREST, gfx::CLAMP);
    let world_pipeline = gfx::pipeline(embed_shader("vert", world_vertex),
        embed_shader("frag", world_fragment), gfx::PipelineDesc { depth_test: true, depth_write: true });
    let hud_pipeline = gfx::pipeline(embed_shader("vert", hud_vertex),
        embed_shader("frag", hud_fragment), gfx::PipelineDesc { blend: gfx::BLEND_ALPHA });
    require(sampler.id != 0 && world_pipeline.id != 0 && hud_pipeline.id != 0, gfx::error());
    var world_buffer = gfx::Buffer { 0 };
    var hud_buffer = gfx::Buffer { 0 };
    var paused = !gfx::focused();
    var help = false;
    var map = false;
    var wait_for_fire = false;
    var fire_pending = false;
    var accumulator: f32 = 0.0;
    var drawn = 0;
    if !paused && frames < 0 && !gfx::set_mouse_relative(true) { say("MOUSE CAPTURE FAILED - LEFT/RIGHT TURNS"); }
    while gfx::frame() {
        let size = gfx::screen_size();
        if size.x <= 0 || size.y <= 0 { continue; }
        let was_playing = !paused && player.health > 0 && !won;
        var lost_focus = !gfx::focused();
        for event in gfx::events() { if event.kind == gfx::EVENT_FOCUS_LOST { lost_focus = true; } }
        if lost_focus { paused = true; }
        else {
            if gfx::key_pressed("Escape") { paused = !paused; }
            else if paused && gfx::mouse_pressed(gfx::MOUSE_LEFT) { paused = false; }
            if gfx::key_pressed("H") { help = !help; }
            if gfx::key_pressed("Tab") { map = !map; }
            if gfx::key_pressed("R") && (player.health <= 0 || won) { reset_game(); paused = false; }
        }
        let playing = !paused && player.health > 0 && !won;
        if playing != was_playing {
            gfx::set_mouse_relative(playing && frames < 0);
            wait_for_fire = true;
        }
        let fire_down = gfx::mouse_down(gfx::MOUSE_LEFT) || gfx::key_down("Left Ctrl") || gfx::key_down("Right Ctrl");
        if !fire_down { wait_for_fire = false; }
        let dt: f32 = if frames > 0 { STEP } else { min(gfx::delta_time(), 0.1) as f32 };
        if playing && was_playing {
            // Keep a short press until a simulation tick consumes it; a
            // press and release can both arrive between rendered frames.
            if !wait_for_fire && (gfx::mouse_pressed(gfx::MOUSE_LEFT) ||
                gfx::key_pressed("Left Ctrl") || gfx::key_pressed("Right Ctrl")) { fire_pending = true; }
            if gfx::mouse_relative() { player.yaw -= gfx::mouse_delta().x * 0.003; }
            if gfx::key_down("Left") { player.yaw += dt * 2.2; }
            if gfx::key_down("Right") { player.yaw -= dt * 2.2; }
            player.yaw = (player.yaw + TAU) % TAU;
            if gfx::key_pressed("1") { player.weapon = 1; }
            if gfx::key_pressed("2") { player.weapon = 2; }
            if gfx::key_pressed("3") && player.shotgun { player.weapon = 3; }
            if gfx::key_pressed("4") && player.chaingun { player.weapon = 4; }
            if gfx::key_pressed("E") || gfx::key_pressed("Space") { use(); }
            var direction = float2_0;
            if gfx::key_down("W") || gfx::key_down("Up") { direction.y += 1.0; }
            if gfx::key_down("S") || gfx::key_down("Down") { direction.y -= 1.0; }
            if gfx::key_down("D") { direction.x += 1.0; }
            if gfx::key_down("A") { direction.x -= 1.0; }
            accumulator += dt;
            while accumulator >= STEP && !won && player.health > 0 {
                tick(direction, gfx::key_down("Left Shift") || gfx::key_down("Right Shift"),
                     (fire_down || fire_pending) && !wait_for_fire);
                fire_pending = false;
                accumulator -= STEP;
            }
        } else { accumulator = 0.0; fire_pending = false; }
        // Death/completion can happen inside tick, after input was sampled.
        if (player.health <= 0 || won) && gfx::mouse_relative() { gfx::set_mouse_relative(false); }

        // All scratch mesh storage is freed when this frame's scope ends.
        // Rebuilding this small map keeps moving-sector rendering readable.
        var mesh: Vertex[>..] = [];
        world_mesh(mesh);
        sprite_mesh(mesh);
        upload(world_buffer, bytes_of(mesh));
        let hud_scale = min<f32>(size.x / 320.0, size.y / 200.0);
        let screen = float2 { size.x / hud_scale, size.y / hud_scale };
        var hud: HudVertex[>..] = [];
        draw_hud(hud, screen, paused, help, map);
        upload(hud_buffer, bytes_of(hud));
        let eye_height: f32 = if player.health <= 0 { 12.0 } else { 41.0 + sin(player.bob * 2.0) * 0.6 };
        let eye = world_point(player.pos, player.z + eye_height);
        let ahead = float3 { cos(player.yaw), 0.0, -sin(player.yaw) };
        let view = gfx::look_at(eye, eye + ahead, float3 { 0.0, 1.0, 0.0 });
        let view_height: f32 = size.y * (screen.y - 34.0) / screen.y;
        let camera = Camera { gfx::mul(gfx::perspective(1.1, size.x / view_height, 1.0, 12000.0), view),
                              float4 { eye.x, eye.y, eye.z, 0.0 } };
        require(gfx::begin_screen(float4 { 0.16, 0.19, 0.22, 1.0 }), gfx::error());
        gfx::viewport(0.0, 0.0, size.x, view_height);
        gfx::bind(world_pipeline);
        gfx::bind_texture(gfx::FRAGMENT, 0, atlas, sampler);
        gfx::bind_vertex_buffer(world_buffer);
        gfx::uniforms(gfx::VERTEX, 0, camera);
        gfx::draw(mesh.len);
        gfx::viewport(0.0, 0.0, size.x, size.y);
        gfx::bind(hud_pipeline);
        gfx::bind_texture(gfx::FRAGMENT, 0, atlas, sampler);
        gfx::bind_vertex_buffer(hud_buffer);
        gfx::uniforms(gfx::VERTEX, 0, float4 { screen.x, screen.y, 0.0, 0.0 });
        gfx::draw(hud.len);
        gfx::end_pass();
        if gfx::key_pressed("F12") {
            say(if gfx::screenshot("mini_doom.png") { "SAVED MINI_DOOM.PNG" } else { "SCREENSHOT FAILED" });
        }
        drawn++;
        if drawn == frames { break; }
    }
    if frames > 0 {
        let saved = gfx::screenshot("mini_doom.png");
        require(saved, gfx::error());
        print("drew ", drawn, " frames; saved mini_doom.png: ", saved);
    }
    if sound_ready { audio::close(); }
    gfx::close();
}