/*
 * vectordesert - an infinite wire-mesh vector desert flythrough
 *
 * A retro "vector graphics" landscape: vast wireframe plains, occasional
 * vector mountain ranges, and saguaro cacti built entirely out of square
 * wire-mesh frames. Everything (terrain included) is drawn as square
 * wireframe loops.
 *
 * Controls and settings are documented in README.md and printed at startup.
 */

#include <GLFW/glfw3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
#include <ctype.h>
#include <math.h>

#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif

/* ------------------------------------------------------------------ */
/* tiny vector math                                                    */
/* ------------------------------------------------------------------ */
typedef struct { float x, y, z; } V3;

static V3 v3(float x, float y, float z){ V3 r={x,y,z}; return r; }
static V3 add(V3 a, V3 b){ return v3(a.x+b.x, a.y+b.y, a.z+b.z); }
static V3 sub(V3 a, V3 b){ return v3(a.x-b.x, a.y-b.y, a.z-b.z); }
static V3 scl(V3 a, float s){ return v3(a.x*s, a.y*s, a.z*s); }
static V3 cross3(V3 a, V3 b){
    return v3(a.y*b.z - a.z*b.y,
              a.z*b.x - a.x*b.z,
              a.x*b.y - a.y*b.x);
}
static float dot3(V3 a, V3 b){ return a.x*b.x + a.y*b.y + a.z*b.z; }
static float len3(V3 a){ return sqrtf(a.x*a.x + a.y*a.y + a.z*a.z); }
static V3 norm3(V3 a){ float l=len3(a); if(l<1e-6f) return v3(0,1,0); return scl(a,1.0f/l); }

/* rotate v around unit axis by angle (Rodrigues) */
static V3 rotateAround(V3 v, V3 axis, float ang){
    float c = cosf(ang), s = sinf(ang);
    V3 t1 = scl(v, c);
    V3 t2 = scl(cross3(axis, v), s);
    V3 t3 = scl(axis, dot3(axis, v) * (1.0f - c));
    return add(add(t1, t2), t3);
}

static float clampf(float v, float lo, float hi){ return v<lo?lo:(v>hi?hi:v); }
static float lerpf(float a, float b, float t){ return a + (b-a)*t; }
static float smoothstepf(float e0, float e1, float x){
    float t = clampf((x-e0)/(e1-e0), 0.0f, 1.0f);
    return t*t*(3.0f-2.0f*t);
}

/* ------------------------------------------------------------------ */
/* hashing + value noise                                               */
/* ------------------------------------------------------------------ */
static uint32_t hashi(int x, int y){
    uint32_t h = (uint32_t)(x*374761393) + (uint32_t)(y*668265263);
    h = (h ^ (h >> 13)) * 1274126177u;
    h ^= h >> 16;
    return h;
}
static float hashf(int x, int y){
    return (hashi(x,y) & 0xffffffu) / (float)0xffffff;
}

static float valnoise(float x, float y){
    int xi = (int)floorf(x), yi = (int)floorf(y);
    float xf = x - xi, yf = y - yi;
    float v00 = hashf(xi,   yi);
    float v10 = hashf(xi+1, yi);
    float v01 = hashf(xi,   yi+1);
    float v11 = hashf(xi+1, yi+1);
    float u = xf*xf*(3.0f-2.0f*xf);
    float v = yf*yf*(3.0f-2.0f*yf);
    return lerpf(lerpf(v00, v10, u), lerpf(v01, v11, u), v);
}

/* fractal brownian motion in [0,1] */
static float fbm(float x, float y, int oct){
    float sum = 0.0f, amp = 0.5f, norm = 0.0f;
    for(int i=0;i<oct;i++){
        sum  += amp * valnoise(x, y);
        norm += amp;
        x *= 2.0f; y *= 2.0f; amp *= 0.5f;
    }
    return sum / norm;
}

/* ------------------------------------------------------------------ */
/* user settings                                                       */
/* ------------------------------------------------------------------ */
typedef struct {
    float terrainHue;      /* 0..1 */
    float mountainFreq;    /* how often ranges appear (scale)          */
    float mountainRough;   /* 0 = smooth domes .. 1 = jagged peaks     */
    float mountainMinH;    /* height of the low parts of a range       */
    float mountainMaxH;    /* height of the tallest peaks              */
    float cactusFreq;      /* 0..1 probability a cell holds a cactus   */
    float cactusSizeVar;   /* 0..1 random size spread                  */
    float cactusMinSize;   /* smallest allowed cactus height           */
    float cactusMaxSize;   /* largest allowed cactus height            */
    float cactusHue;       /* 0..1 */
    int   maxArms;         /* arms on the largest cacti                */
} Settings;

static Settings S = {
    .terrainHue   = 0.075f,   /* desert orange/tan */
    .mountainFreq = 1.0f,
    .mountainRough= 0.5f,
    .mountainMinH = 6.0f,
    .mountainMaxH = 26.0f,
    .cactusFreq   = 0.35f,
    .cactusSizeVar= 0.55f,
    .cactusMinSize= 4.0f,
    .cactusMaxSize= 12.0f,
    .cactusHue    = 0.33f,    /* green */
    .maxArms      = 5,
};

#define ARM_LIMIT 12

/* ------------------------------------------------------------------ */
/* blast craters: bombs flatten the terrain (and clear cacti) inside a  */
/* radius. Stored here so terrainHeight() can carve them out.           */
/* ------------------------------------------------------------------ */
typedef struct { float x, z, r, bowlR, depth; } Crater;
static Crater *g_craters   = NULL;   /* append-only: every bomb keeps a crater */
static int     g_nCraters  = 0;
static int     g_craterCap = 0;

static void addCrater(float x, float z, float size){
    if(g_nCraters == g_craterCap){
        g_craterCap = g_craterCap ? g_craterCap*2 : 32;
        g_craters = (Crater*)realloc(g_craters, (size_t)g_craterCap*sizeof(Crater));
    }
    Crater *c = &g_craters[g_nCraters++];
    c->x = x; c->z = z;
    c->r     = 0.5f * size;                       /* blast / clear radius */
    c->bowlR = 0.16f * size;                      /* crater radius        */
    c->depth = clampf(size*0.12f, 20.0f, 130.0f); /* deep, well-defined   */
}

static bool inCrater(float x, float z){
    for(int i=0;i<g_nCraters;i++){
        float dx=x-g_craters[i].x, dz=z-g_craters[i].z;
        if(dx*dx + dz*dz < g_craters[i].r*g_craters[i].r) return true;
    }
    return false;
}

/* ------------------------------------------------------------------ */
/* terrain                                                             */
/* ------------------------------------------------------------------ */

/* The shape of a range/basin in 0..1: a ridged multifractal that roughness
 * morphs from a flat-topped mesa (0) to jagged crests (1). Used for both the
 * mountains (added) and the valleys (subtracted). */
static float rangeRidge(float x, float z){
    float bf = 0.05f * (26.0f / fmaxf(S.mountainMaxH, 1.0f));
    float freq=bf, amp=0.5f, sum=0.0f, norm=0.0f, weight=1.0f, mass=0.0f;
    for(int i=0;i<6;i++){
        float n = 1.0f - fabsf(2.0f*valnoise(x*freq, z*freq) - 1.0f);
        n *= n;
        if(i==0) mass = n;
        n *= weight;
        weight = clampf(n*2.5f, 0.0f, 1.0f);
        sum  += n*amp;
        norm += amp;
        freq *= 2.0f; amp *= 0.5f;
    }
    float jagged = clampf(sum / norm, 0.0f, 1.0f);
    float mesa   = clampf((mass - 0.25f) * 3.2f, 0.0f, 1.0f);
    return lerpf(mesa, jagged, S.mountainRough);
}

static float terrainHeight(float x, float z){
    /* The default state of the world is a near-flat desert plain with only
     * very gentle undulation. */
    float plains = (fbm(x*0.02f, z*0.02f, 3) - 0.5f) * 2.0f;
    float h = plains;

    /* Coherent low-frequency blobs decide where ranges/basins occur; the
     * user's mountainFreq sets how much of the plain they cover. */
    float thr = clampf(0.80f - 0.11f*S.mountainFreq, 0.35f, 0.86f);

    /* mountain ranges rise above the plain */
    float mMask = smoothstepf(thr, thr+0.10f, valnoise(x*0.006f, z*0.006f));
    if(mMask > 0.0f)
        h += mMask * lerpf(S.mountainMinH, S.mountainMaxH, rangeRidge(x, z));

    /* valleys / basins carve below the plain (a differently-phased mask so
     * they fall in different places than the mountains). Roughness shapes
     * them too: flat-bottomed basins -> rugged canyons. */
    float vMask = smoothstepf(thr, thr+0.10f, valnoise(x*0.006f+19.3f, z*0.006f-23.1f));
    if(vMask > 0.0f)
        h -= vMask * lerpf(S.mountainMinH, S.mountainMaxH, rangeRidge(x+57.0f, z-91.0f)) * 0.85f;

    /* blast craters. Two separable effects so that overlapping blasts
     * accumulate rather than wipe each other out:
     *   - flatten: erase the procedural mountains/valleys back to the plain
     *     across the blast radius (combined as a max, applied once);
     *   - carve: a deep bowl + raised rim, summed over every crater so that
     *     repeated blasts in one place deepen and reshape the crater. */
    float flat = 0.0f, carve = 0.0f;
    for(int i=0;i<g_nCraters;i++){
        Crater *c = &g_craters[i];
        float dx=x-c->x, dz=z-c->z;
        float d2 = dx*dx + dz*dz;
        if(d2 >= c->r*c->r) continue;
        float d = sqrtf(d2);
        float f = smoothstepf(c->r, c->r*0.65f, d);    /* 1 inside, 0 at rim */
        if(f > flat) flat = f;
        /* bowl: flat floor inside, steep wall up to the lip */
        float wall = smoothstepf(c->bowlR, c->bowlR*0.55f, d);
        carve -= c->depth * wall;
        /* raised rim: a bump straddling the lip, rising above the surface */
        float rim = (d - c->bowlR)/(c->bowlR*0.25f);
        carve += c->depth*0.35f*expf(-rim*rim);
    }
    if(flat > 0.0f) h = lerpf(h, plains, flat);
    h += carve;
    return h;
}

/* ------------------------------------------------------------------ */
/* color: HSV -> RGB                                                   */
/* ------------------------------------------------------------------ */
static V3 hsv2rgb(float h, float s, float v){
    h = h - floorf(h);
    float i = floorf(h*6.0f);
    float f = h*6.0f - i;
    float p = v*(1.0f-s);
    float q = v*(1.0f-s*f);
    float t = v*(1.0f-s*(1.0f-f));
    switch(((int)i)%6){
        case 0: return v3(v,t,p);
        case 1: return v3(q,v,p);
        case 2: return v3(p,v,t);
        case 3: return v3(p,q,v);
        case 4: return v3(t,p,v);
        default:return v3(v,p,q);
    }
}

/* ------------------------------------------------------------------ */
/* camera matrices (no GLU dependency)                                 */
/* ------------------------------------------------------------------ */
static void perspectiveGL(float fovyDeg, float aspect, float znear, float zfar){
    float fH = tanf(fovyDeg * (float)M_PI / 360.0f) * znear;
    float fW = fH * aspect;
    glFrustum(-fW, fW, -fH, fH, znear, zfar);
}

static void lookAtGL(V3 eye, V3 center, V3 up){
    V3 f = norm3(sub(center, eye));
    V3 s = norm3(cross3(f, up));
    V3 u = cross3(s, f);
    float m[16] = {
        s.x,  u.x, -f.x, 0.0f,
        s.y,  u.y, -f.y, 0.0f,
        s.z,  u.z, -f.z, 0.0f,
        0.0f, 0.0f, 0.0f, 1.0f
    };
    glMultMatrixf(m);
    glTranslatef(-eye.x, -eye.y, -eye.z);
}

/* ------------------------------------------------------------------ */
/* fog (distance fade)                                                 */
/* ------------------------------------------------------------------ */
static V3 camPos;
static float g_viewRadius = 128.0f;   /* rendering distance (runtime)   */
static bool  g_showAlt = false;       /* altitude indicator toggle      */
#define FOG_NEAR 18.0f

/* fog reaches full opacity a little inside the draw radius so the mesh
 * edge fades into the sky instead of popping */
static float fogFarDist(void){
    float f = g_viewRadius - 8.0f;
    return f < FOG_NEAR + 5.0f ? FOG_NEAR + 5.0f : f;
}

/* ------------------------------------------------------------------ */
/* settings persistence: every user setting is saved on exit and       */
/* reloaded at startup, so the program resumes with the last values.   */
/* ------------------------------------------------------------------ */
static const char *configPath(void){
    static char path[512];
    const char *home = getenv("HOME");
    if(home && *home) snprintf(path, sizeof path, "%s/.vectordesert.cfg", home);
    else              snprintf(path, sizeof path, "vectordesert.cfg");
    return path;
}

static void saveSettings(void){
    FILE *f = fopen(configPath(), "w");
    if(!f) return;
    fprintf(f, "terrainHue %.5f\n",    S.terrainHue);
    fprintf(f, "mountainFreq %.5f\n",  S.mountainFreq);
    fprintf(f, "mountainRough %.5f\n", S.mountainRough);
    fprintf(f, "mountainMinH %.5f\n",  S.mountainMinH);
    fprintf(f, "mountainMaxH %.5f\n",  S.mountainMaxH);
    fprintf(f, "cactusFreq %.5f\n",    S.cactusFreq);
    fprintf(f, "cactusSizeVar %.5f\n", S.cactusSizeVar);
    fprintf(f, "cactusMinSize %.5f\n", S.cactusMinSize);
    fprintf(f, "cactusMaxSize %.5f\n", S.cactusMaxSize);
    fprintf(f, "cactusHue %.5f\n",     S.cactusHue);
    fprintf(f, "maxArms %d\n",         S.maxArms);
    fprintf(f, "viewRadius %.3f\n",    g_viewRadius);
    fclose(f);
}

static void loadSettings(void){
    FILE *f = fopen(configPath(), "r");
    if(!f) return;
    char key[64]; float v;
    while(fscanf(f, "%63s %f", key, &v) == 2){
        if      (!strcmp(key,"terrainHue"))    S.terrainHue    = v;
        else if (!strcmp(key,"mountainFreq"))  S.mountainFreq  = v;
        else if (!strcmp(key,"mountainRough")) S.mountainRough = v;
        else if (!strcmp(key,"mountainMinH"))  S.mountainMinH  = v;
        else if (!strcmp(key,"mountainMaxH"))  S.mountainMaxH  = v;
        else if (!strcmp(key,"cactusFreq"))    S.cactusFreq    = v;
        else if (!strcmp(key,"cactusSizeVar")) S.cactusSizeVar = v;
        else if (!strcmp(key,"cactusMinSize")) S.cactusMinSize = v;
        else if (!strcmp(key,"cactusMaxSize")) S.cactusMaxSize = v;
        else if (!strcmp(key,"cactusHue"))     S.cactusHue     = v;
        else if (!strcmp(key,"maxArms"))       S.maxArms       = (int)v;
        else if (!strcmp(key,"viewRadius"))    g_viewRadius    = v;
    }
    fclose(f);
}

/* ------------------------------------------------------------------ */
/* line batch: collect every wireframe segment into one vertex+colour  */
/* array so a whole frame's geometry ships in a single glDrawArrays.   */
/* Distance fade is done by hardware fog, not per-vertex on the CPU.   */
/* ------------------------------------------------------------------ */
typedef struct { float *v; float *c; int n, cap; } Batch;

static void batchEnsure(Batch *b, int extra){
    if(b->n + extra <= b->cap) return;
    int nc = b->cap ? b->cap : 8192;
    while(nc < b->n + extra) nc *= 2;
    b->v = (float*)realloc(b->v, (size_t)nc*3*sizeof(float));
    b->c = (float*)realloc(b->c, (size_t)nc*3*sizeof(float));
    b->cap = nc;
}
static inline void batchVert(Batch *b, float x, float y, float z, const V3 *col){
    int i = b->n++;
    b->v[i*3]=x; b->v[i*3+1]=y; b->v[i*3+2]=z;
    b->c[i*3]=col->x; b->c[i*3+1]=col->y; b->c[i*3+2]=col->z;
}
static inline void batchSeg(Batch *b, V3 a, V3 q, const V3 *col){
    batchEnsure(b, 2);
    batchVert(b, a.x,a.y,a.z, col);
    batchVert(b, q.x,q.y,q.z, col);
}
static void batchDraw(const Batch *b){
    if(b->n <= 0) return;
    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_COLOR_ARRAY);
    glVertexPointer(3, GL_FLOAT, 0, b->v);
    glColorPointer(3, GL_FLOAT, 0, b->c);
    glDrawArrays(GL_LINES, 0, b->n);
    glDisableClientState(GL_COLOR_ARRAY);
    glDisableClientState(GL_VERTEX_ARRAY);
}

/* ------------------------------------------------------------------ */
/* vector stroke font (for the HUD)                                    */
/* Each glyph is a set of line segments on a 0..4 (x) by 0..6 (y) grid */
/* ------------------------------------------------------------------ */
#define GLY(name) static const float name[] =
GLY(gA){0,0,2,6, 2,6,4,0, 1,2,3,2};
GLY(gB){0,0,0,6, 0,6,3,6, 0,3,3,3, 0,0,3,0, 3,6,3,3, 3,3,3,0};
GLY(gC){4,6,1,6, 1,6,0,5, 0,5,0,1, 0,1,1,0, 1,0,4,0};
GLY(gD){0,0,0,6, 0,6,2,6, 2,6,4,4, 4,4,4,2, 4,2,2,0, 2,0,0,0};
GLY(gE){4,6,0,6, 0,6,0,0, 0,0,4,0, 0,3,3,3};
GLY(gF){4,6,0,6, 0,6,0,0, 0,3,3,3};
GLY(gG){4,6,1,6, 1,6,0,5, 0,5,0,1, 0,1,1,0, 1,0,4,0, 4,0,4,3, 4,3,2,3};
GLY(gH){0,0,0,6, 4,0,4,6, 0,3,4,3};
GLY(gI){1,6,3,6, 2,6,2,0, 1,0,3,0};
GLY(gJ){4,6,4,1, 4,1,3,0, 3,0,1,0, 1,0,0,1};
GLY(gK){0,0,0,6, 4,6,0,3, 0,3,4,0};
GLY(gL){0,6,0,0, 0,0,4,0};
GLY(gM){0,0,0,6, 0,6,2,3, 2,3,4,6, 4,6,4,0};
GLY(gN){0,0,0,6, 0,6,4,0, 4,0,4,6};
GLY(gO){1,0,3,0, 3,0,4,2, 4,2,4,4, 4,4,3,6, 3,6,1,6, 1,6,0,4, 0,4,0,2, 0,2,1,0};
GLY(gP){0,0,0,6, 0,6,3,6, 3,6,3,3, 3,3,0,3};
GLY(gQ){1,0,3,0, 3,0,4,2, 4,2,4,4, 4,4,3,6, 3,6,1,6, 1,6,0,4, 0,4,0,2, 0,2,1,0, 2,2,4,0};
GLY(gR){0,0,0,6, 0,6,3,6, 3,6,3,3, 3,3,0,3, 0,3,4,0};
GLY(gS){4,5,3,6, 3,6,1,6, 1,6,0,5, 0,5,1,3, 1,3,3,3, 3,3,4,1, 4,1,3,0, 3,0,1,0, 1,0,0,1};
GLY(gT){0,6,4,6, 2,6,2,0};
GLY(gU){0,6,0,1, 0,1,1,0, 1,0,3,0, 3,0,4,1, 4,1,4,6};
GLY(gV){0,6,2,0, 2,0,4,6};
GLY(gW){0,6,1,0, 1,0,2,4, 2,4,3,0, 3,0,4,6};
GLY(gX){0,0,4,6, 0,6,4,0};
GLY(gY){0,6,2,3, 4,6,2,3, 2,3,2,0};
GLY(gZ){0,6,4,6, 4,6,0,0, 0,0,4,0};
GLY(g0){1,0,3,0, 3,0,4,2, 4,2,4,4, 4,4,3,6, 3,6,1,6, 1,6,0,4, 0,4,0,2, 0,2,1,0, 0,1,4,5};
GLY(g1){1,4,2,6, 2,6,2,0, 1,0,3,0};
GLY(g2){0,5,1,6, 1,6,3,6, 3,6,4,5, 4,5,4,4, 4,4,0,0, 0,0,4,0};
GLY(g3){0,6,3,6, 3,6,4,5, 4,5,3,3, 2,3,3,3, 3,3,4,2, 4,2,3,0, 3,0,1,0, 1,0,0,1};
GLY(g4){3,0,3,6, 3,6,0,2, 0,2,4,2};
GLY(g5){4,6,0,6, 0,6,0,3, 0,3,3,3, 3,3,4,2, 4,2,4,1, 4,1,3,0, 3,0,1,0, 1,0,0,1};
GLY(g6){4,5,3,6, 3,6,1,6, 1,6,0,4, 0,4,0,1, 0,1,1,0, 1,0,3,0, 3,0,4,1, 4,1,4,2, 4,2,3,3, 3,3,0,3};
GLY(g7){0,6,4,6, 4,6,1,0};
GLY(g8){1,3,3,3, 1,3,0,4, 0,4,0,5, 0,5,1,6, 1,6,3,6, 3,6,4,5, 4,5,4,4, 4,4,3,3, 3,3,4,2, 4,2,4,1, 4,1,3,0, 3,0,1,0, 1,0,0,1, 0,1,0,2, 0,2,1,3};
GLY(g9){0,1,1,0, 1,0,3,0, 3,0,4,2, 4,2,4,5, 4,5,3,6, 3,6,1,6, 1,6,0,5, 0,5,0,4, 0,4,1,3, 1,3,4,3};
GLY(gDot){2,0,2,1};
GLY(gDash){1,3,3,3};
GLY(gColon){2,1,2,2, 2,3,2,4};
GLY(gPct){0,0,4,6, 0,5,1,4, 3,2,4,1};
GLY(gSlash){0,0,4,6};
GLY(gLBrk){3,6,1,6, 1,6,1,0, 1,0,3,0};
GLY(gRBrk){1,6,3,6, 3,6,3,0, 3,0,1,0};
GLY(gEq){0,2,4,2, 0,4,4,4};
GLY(gComma){2,1,1,-1};
#undef GLY

/* returns number of segments and sets *out to the segment array */
static int glyphFor(char c, const float **out){
    #define R(arr) do{ *out = arr; return (int)(sizeof(arr)/(4*sizeof(float))); }while(0)
    switch(c){
        case 'A': R(gA); case 'B': R(gB); case 'C': R(gC); case 'D': R(gD);
        case 'E': R(gE); case 'F': R(gF); case 'G': R(gG); case 'H': R(gH);
        case 'I': R(gI); case 'J': R(gJ); case 'K': R(gK); case 'L': R(gL);
        case 'M': R(gM); case 'N': R(gN); case 'O': R(gO); case 'P': R(gP);
        case 'Q': R(gQ); case 'R': R(gR); case 'S': R(gS); case 'T': R(gT);
        case 'U': R(gU); case 'V': R(gV); case 'W': R(gW); case 'X': R(gX);
        case 'Y': R(gY); case 'Z': R(gZ);
        case '0': R(g0); case '1': R(g1); case '2': R(g2); case '3': R(g3);
        case '4': R(g4); case '5': R(g5); case '6': R(g6); case '7': R(g7);
        case '8': R(g8); case '9': R(g9);
        case '.': R(gDot); case '-': R(gDash); case ':': R(gColon);
        case '%': R(gPct); case '/': R(gSlash); case '[': R(gLBrk);
        case ']': R(gRBrk); case '=': R(gEq); case ',': R(gComma);
        default: *out = NULL; return 0;   /* space / unknown */
    }
    #undef R
}

/* draw a string; (x,y) is the top-left of the text cell, in pixels */
static void drawText(const char *s, float x, float y, float sc){
    float pen = x;
    for(const char *p=s; *p; ++p){
        char c = (char)toupper((unsigned char)*p);
        const float *g; int n = glyphFor(c, &g);
        if(n){
            glBegin(GL_LINES);
            for(int i=0;i<n;i++){
                glVertex2f(pen + g[i*4+0]*sc, y + (6.0f - g[i*4+1])*sc);
                glVertex2f(pen + g[i*4+2]*sc, y + (6.0f - g[i*4+3])*sc);
            }
            glEnd();
        }
        pen += 5.0f*sc;
    }
}

/* ------------------------------------------------------------------ */
/* heads-up display                                                    */
/* ------------------------------------------------------------------ */
static void drawHUD(int fbw, int fbh, float alpha){
    float sc = fbh / 240.0f;
    if(sc < 2.0f) sc = 2.0f;
    float lineH = 9.0f * sc;
    float x = 4.0f * sc;
    float y = 4.0f * sc;

    /* 2D overlay, no depth */
    glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity();
    glOrtho(0.0, fbw, fbh, 0.0, -1.0, 1.0);
    glMatrixMode(GL_MODELVIEW);  glPushMatrix(); glLoadIdentity();
    glDisable(GL_DEPTH_TEST);
    glDisable(GL_FOG);

    char buf[64];

    /* title */
    glColor4f(0.95f, 0.85f, 0.35f, alpha);
    drawText("VECTOR DESERT", x, y, sc*1.1f);
    y += lineH*1.6f;

    glColor4f(0.55f, 1.0f, 0.75f, alpha);
    #define LINE(...) do{ snprintf(buf,sizeof(buf),__VA_ARGS__); \
                          drawText(buf, x, y, sc); y += lineH; }while(0)
    LINE("1/2  TERRAIN HUE   %.2f", S.terrainHue);
    LINE("3/4  MTN FREQ      %.2f", S.mountainFreq);
    LINE(",/.  MTN ROUGH     %.2f", S.mountainRough);
    LINE("T/Y  MTN MIN H     %.0f", S.mountainMinH);
    LINE("U/I  MTN MAX H     %.0f", S.mountainMaxH);
    LINE("5/6  CACTUS FREQ   %.2f", S.cactusFreq);
    LINE("7/8  SIZE VAR      %.2f", S.cactusSizeVar);
    LINE("J/K  MIN SIZE      %.1f", S.cactusMinSize);
    LINE("N/M  MAX SIZE      %.1f", S.cactusMaxSize);
    LINE("9/0  CACTUS HUE    %.2f", S.cactusHue);
    LINE("-/=  MAX ARMS      %d",   S.maxArms);
    LINE("[/]  RENDER DIST   %.0f", g_viewRadius);
    LINE("X    FULL STOP");
    LINE("G    FLOOR ALT     %s",   g_showAlt ? "ON" : "OFF");
    #undef LINE

    /* camera + window controls footer */
    y += lineH*0.5f;
    glColor4f(0.45f, 0.70f, 0.85f, alpha);
    drawText("WASD MOVE  ARROWS PAN  PGUP/DN ALT  SPACE BOMB  F FULL", x, y, sc*0.85f);

    glEnable(GL_FOG);
    glEnable(GL_DEPTH_TEST);
    glMatrixMode(GL_PROJECTION); glPopMatrix();
    glMatrixMode(GL_MODELVIEW);  glPopMatrix();
}

/* on-screen altitude indicator: the terrain floor directly beneath the
 * camera, the camera's own altitude, and the clearance between them, plus a
 * vertical clearance gauge. Drawn top-right when toggled on. */
static void drawAltIndicator(int fbw, int fbh){
    float floor = terrainHeight(camPos.x, camPos.z);
    float camAlt = camPos.y;
    float clear = camAlt - floor;

    float sc = fbh/240.0f; if(sc < 2.0f) sc = 2.0f;
    float lineH = 9.0f*sc, pad = 5.0f*sc;
    float gaugeW = 6.0f*sc, gaugeH = 56.0f*sc;
    float gx1 = fbw - pad, gx0 = gx1 - gaugeW;
    float textW = 12.0f*5.0f*sc;
    float tx = gx0 - 6.0f*sc - textW;
    float ty = pad;

    glMatrixMode(GL_PROJECTION); glPushMatrix(); glLoadIdentity();
    glOrtho(0.0, fbw, fbh, 0.0, -1.0, 1.0);
    glMatrixMode(GL_MODELVIEW);  glPushMatrix(); glLoadIdentity();
    glDisable(GL_DEPTH_TEST);
    glDisable(GL_FOG);

    char buf[48];
    glColor4f(0.95f, 0.85f, 0.35f, 1.0f);
    drawText("ALTITUDE", tx, ty, sc); ty += lineH*1.4f;
    glColor4f(0.55f, 1.0f, 0.75f, 1.0f);
    snprintf(buf,sizeof buf,"FLOOR %.1f", floor);  drawText(buf,tx,ty,sc); ty+=lineH;
    snprintf(buf,sizeof buf,"CAM   %.1f", camAlt);  drawText(buf,tx,ty,sc); ty+=lineH;
    snprintf(buf,sizeof buf,"CLEAR %.1f", clear);   drawText(buf,tx,ty,sc); ty+=lineH;

    /* vertical clearance gauge (0..120) */
    float gTop = pad, gBot = pad + gaugeH;
    glColor4f(0.45f, 0.70f, 0.85f, 1.0f);
    glBegin(GL_LINE_LOOP);
        glVertex2f(gx0, gTop); glVertex2f(gx1, gTop);
        glVertex2f(gx1, gBot); glVertex2f(gx0, gBot);
    glEnd();
    float cl = clampf(clear/120.0f, 0.0f, 1.0f);
    float fillY = gBot - cl*(gBot-gTop);
    glColor4f(0.55f, 1.0f, 0.75f, 1.0f);
    glBegin(GL_LINES);
        for(float yy=gBot-1.5f*sc; yy>fillY; yy-=2.5f*sc){
            glVertex2f(gx0+1.0f, yy); glVertex2f(gx1-1.0f, yy);
        }
    glEnd();

    glEnable(GL_FOG);
    glEnable(GL_DEPTH_TEST);
    glMatrixMode(GL_PROJECTION); glPopMatrix();
    glMatrixMode(GL_MODELVIEW);  glPopMatrix();
}

/* ------------------------------------------------------------------ */
/* drawing: square wire-mesh tube                                      */
/* ------------------------------------------------------------------ */
static void perpBasis(V3 n, V3 *u, V3 *v){
    V3 a = (fabsf(n.y) < 0.99f) ? v3(0,1,0) : v3(1,0,0);
    *u = norm3(cross3(a, n));
    *v = cross3(n, *u);
}

/* Draw a wire-mesh tube with a (ribbed) circular cross-section that follows
 * an arbitrary centerline. The cross-section frame is parallel-transported
 * along the path so it never twists, which lets arms curve smoothly. The
 * polygonal rings read as the vertical ribs/flutes of a saguaro. */
#define MAX_PTS  64
#define MAX_SIDES 16

static void appendTube(Batch *b, const V3 *pts, const float *rad, int n,
                       int sides, const V3 *col){
    if(n < 2) return;
    if(sides > MAX_SIDES) sides = MAX_SIDES;
    if(sides < 3) sides = 3;

    V3 prevDir = norm3(sub(pts[1], pts[0]));
    V3 u, v; perpBasis(prevDir, &u, &v);

    V3 prevRing[MAX_SIDES];

    for(int i=0;i<n;i++){
        /* tangent at this point */
        V3 d;
        if(i==0)        d = norm3(sub(pts[1], pts[0]));
        else if(i==n-1) d = norm3(sub(pts[i], pts[i-1]));
        else            d = norm3(sub(pts[i+1], pts[i-1]));

        /* parallel-transport the frame from prevDir to d */
        if(i>0){
            V3 ax = cross3(prevDir, d);
            float s = len3(ax);
            if(s > 1e-5f){
                ax = scl(ax, 1.0f/s);
                float ang = atan2f(s, dot3(prevDir, d));
                u = rotateAround(u, ax, ang);
                v = rotateAround(v, ax, ang);
            }
        }
        prevDir = d;

        /* build the ring */
        V3 ring[MAX_SIDES];
        for(int k=0;k<sides;k++){
            float a = 2.0f*(float)M_PI * k / sides;
            V3 off = add(scl(u, cosf(a)*rad[i]), scl(v, sinf(a)*rad[i]));
            ring[k] = add(pts[i], off);
        }

        batchEnsure(b, sides*4);
        for(int k=0;k<sides;k++)                       /* ring loop */
            batchSeg(b, ring[k], ring[(k+1)%sides], col);
        if(i>0)                                        /* longitudinal ribs */
            for(int k=0;k<sides;k++)
                batchSeg(b, prevRing[k], ring[k], col);
        for(int k=0;k<sides;k++) prevRing[k] = ring[k];
    }
}

/* ------------------------------------------------------------------ */
/* cactus                                                              */
/* ------------------------------------------------------------------ */
/* one saguaro arm: starts on the trunk surface, curves outward through a
 * quarter circle, then rises straight up to a rounded tip. */
static void appendArm(Batch *b, V3 attach, V3 dir, V3 up, float bendR,
                      float upLen, float ra, const V3 *col, int sides){
    V3 pts[MAX_PTS];
    float rad[MAX_PTS];
    int n = 0;

    const int nb = 6;                 /* points along the bend */
    for(int j=0;j<=nb;j++){
        float a = (float)j/nb * (float)M_PI*0.5f;
        V3 p = add(attach, add(scl(dir, sinf(a)*bendR),
                               scl(up, (1.0f-cosf(a))*bendR)));
        pts[n] = p; rad[n] = ra; n++;
    }
    /* straight vertical section */
    V3 bendTop = pts[n-1];
    const int nu = 5;
    for(int j=1;j<=nu;j++){
        float t = (float)j/nu;
        pts[n] = add(bendTop, scl(up, upLen*t));
        /* taper to a rounded tip */
        rad[n] = ra * (t < 0.8f ? 1.0f : lerpf(1.0f, 0.18f, (t-0.8f)/0.2f));
        n++;
    }
    appendTube(b, pts, rad, n, sides, col);
}

/* build one saguaro into the batch. `dist` is its distance from the camera,
 * used to drop ring facets (level of detail) on far-off cacti. */
static void buildCactus(Batch *b, float wx, float wz, float dist){
    /* deterministic per-cell randomness */
    int cx = (int)floorf(wx);
    int cz = (int)floorf(wz);
    uint32_t hh = hashi(cx*3, cz*7);

    float r0 = ((hh        ) & 0xffff) / (float)0xffff;

    /* size: variation spreads around the midpoint of the user's min/max
     * bounds, so the result is always constrained to [minSize, maxSize]. */
    float mid    = (S.cactusMinSize + S.cactusMaxSize) * 0.5f;
    float spread = (S.cactusMaxSize - S.cactusMinSize) * 0.5f * S.cactusSizeVar;
    float minH   = mid - spread;
    float maxH   = mid + spread;
    float H      = lerpf(minH, maxH, r0);

    /* normalized size -> arm count. smaller = fewer, larger = more. */
    float tsize = (maxH - minH > 1e-3f) ? (H - minH)/(maxH - minH) : 0.5f;
    int arms = (int)lroundf(tsize * S.maxArms);
    if(arms < 0) arms = 0;
    if(arms > S.maxArms) arms = S.maxArms;
    if(arms > ARM_LIMIT) arms = ARM_LIMIT;

    float ground = terrainHeight(wx, wz);
    V3 base = v3(wx, ground, wz);
    V3 up   = v3(0,1,0);
    float rt = 0.35f + 0.03f*H;          /* slender trunk */
    V3 col = hsv2rgb(S.cactusHue, 0.7f, 0.9f);

    /* level of detail by distance */
    int   tSides = (dist < 90.0f) ? 10 : (dist < 160.0f ? 7 : 5);
    int   aSides = (dist < 90.0f) ?  8 : 5;
    float rscale = (dist < 90.0f) ? 1.0f : (dist < 160.0f ? 0.6f : 0.4f);

    /* trunk: tapered fluted column with a rounded dome at the top */
    V3 tpts[MAX_PTS];
    float trad[MAX_PTS];
    int nT = (int)(H*rscale) + (rscale < 1.0f ? 3 : 6);
    if(nT < 3) nT = 3;
    if(nT > MAX_PTS) nT = MAX_PTS;
    for(int i=0;i<nT;i++){
        float t = (float)i/(nT-1);
        tpts[i] = add(base, scl(up, t*H));
        float r = rt * (1.0f - 0.20f*t);             /* gentle taper */
        if(t > 0.92f)                                 /* dome the very top */
            r *= sqrtf(clampf(1.0f - powf((t-0.92f)/0.08f, 2.0f), 0.0f, 1.0f));
        trad[i] = fmaxf(r, 0.02f);
    }
    appendTube(b, tpts, trad, nT, tSides, &col);

    /* arms: spread around the trunk, attaching in the mid/upper region */
    for(int i=0;i<arms;i++){
        uint32_t ah = hashi(cx*13 + i*101, cz*17 - i*53);
        float a0 = ((ah        ) & 0xffff)/(float)0xffff;
        float a1 = ((ah >> 10  ) & 0xffff)/(float)0xffff;

        float angle = (i * (2.0f*(float)M_PI / (arms>0?arms:1))) + a0*0.8f;
        V3 dir = v3(cosf(angle), 0.0f, sinf(angle));

        float hf   = lerpf(0.35f, 0.62f, a1);         /* attach height frac */
        float yh   = H*hf;
        float rAtt = rt * (1.0f - 0.20f*hf);          /* trunk radius there */
        V3 attach  = add(base, add(scl(up, yh), scl(dir, rAtt*0.7f)));

        float ra    = rt * 0.62f;
        float bendR = H * 0.10f;
        float upLen = H * (0.28f + 0.18f*a0) * (1.0f - hf*0.4f);

        appendArm(b, attach, dir, up, bendR, upLen, ra, &col, aSides);
    }
}

/* ------------------------------------------------------------------ */
/* terrain + scene rendering                                           */
/* ------------------------------------------------------------------ */
#define CELL          2.5f    /* finest terrain quad size                  */
#define CACTUS_CELL   3.0f
#define TERRAIN_CELLS 360     /* budget: max cells across the view diameter*/

/* Cached scene geometry. The terrain and cactus meshes are rebuilt only
 * when a relevant setting changes or the camera leaves the region the
 * cache covers; every other frame is just two glDrawArrays calls. */
static Batch gTerr, gCacti;

static struct {
    bool  ok;
    float cx, cz, radius, hueT, mFreq, mRough, mMinH, mMaxH, margin;
} tk;                          /* terrain cache key */
static struct {
    bool  ok;
    float cx, cz, radius, freq, sizeVar, minS, maxS, hue, margin;
    int   arms;
} ck;                          /* cactus cache key */

/* terrain LOD: cell size grows with the rendering distance so the cell
 * count (and therefore the work) stays bounded no matter how far we draw */
static float terrainCell(void){
    return fmaxf(CELL, (2.0f*g_viewRadius)/(float)TERRAIN_CELLS);
}

static void buildTerrain(float cx, float cz, float margin){
    gTerr.n = 0;
    float cell = terrainCell();
    float reach = fogFarDist() + margin + 2.0f*cell;  /* world half-extent */
    int   nx   = (int)ceilf((2.0f*reach)/cell) + 1;
    if(nx > 700) nx = 700;                            /* hard safety cap */
    float ox = floorf((cx - reach)/cell)*cell;
    float oz = floorf((cz - reach)/cell)*cell;
    float cull = fogFarDist() + margin + cell;        /* emit within here  */
    float cull2 = cull*cull;

    /* one height + colour per grid vertex, computed once and shared by the
     * (up to) four edges that touch it -- no redundant terrainHeight calls */
    static float *hgt=NULL,*cr=NULL,*cg=NULL,*cb=NULL; static int hcap=0;
    int nv = nx*nx;
    if(nv > hcap){
        hcap = nv;
        hgt = (float*)realloc(hgt, (size_t)nv*sizeof(float));
        cr  = (float*)realloc(cr,  (size_t)nv*sizeof(float));
        cg  = (float*)realloc(cg,  (size_t)nv*sizeof(float));
        cb  = (float*)realloc(cb,  (size_t)nv*sizeof(float));
    }
    for(int j=0;j<nx;j++){
        float z = oz + j*cell;
        for(int i=0;i<nx;i++){
            float x = ox + i*cell;
            float h = terrainHeight(x, z);
            int id = j*nx + i;
            hgt[id] = h;
            float bright = 0.30f + clampf(h/fmaxf(1.0f,S.mountainMaxH), 0.0f, 1.0f)*0.55f;
            V3 col = hsv2rgb(S.terrainHue, 0.55f, bright);
            cr[id]=col.x; cg[id]=col.y; cb[id]=col.z;
        }
    }
    /* emit shared grid edges (east + north neighbour of each vertex) */
    for(int j=0;j<nx;j++){
        float z = oz + j*cell;
        for(int i=0;i<nx;i++){
            float x = ox + i*cell;
            float dx = x-cx, dz = z-cz;
            if(dx*dx + dz*dz > cull2) continue;
            int id = j*nx + i;
            V3 c = v3(cr[id],cg[id],cb[id]);
            if(i < nx-1){
                int e = id+1;
                batchEnsure(&gTerr, 2);
                batchVert(&gTerr, x, hgt[id], z, &c);
                V3 ce = v3(cr[e],cg[e],cb[e]);
                batchVert(&gTerr, x+cell, hgt[e], z, &ce);
            }
            if(j < nx-1){
                int nn = id+nx;
                batchEnsure(&gTerr, 2);
                batchVert(&gTerr, x, hgt[id], z, &c);
                V3 cn = v3(cr[nn],cg[nn],cb[nn]);
                batchVert(&gTerr, x, hgt[nn], z+cell, &cn);
            }
        }
    }
}

static void buildCacti(float cx, float cz, float margin){
    gCacti.n = 0;
    float reach = g_viewRadius + margin;   /* cacti reach as far as terrain */
    float reach2 = reach*reach;
    float ox = floorf((cx - reach)/CACTUS_CELL)*CACTUS_CELL;
    float oz = floorf((cz - reach)/CACTUS_CELL)*CACTUS_CELL;
    float xe = cx + reach, ze = cz + reach;

    for(float z=oz; z<=ze; z+=CACTUS_CELL){
        for(float x=ox; x<=xe; x+=CACTUS_CELL){
            int gx=(int)floorf(x/CACTUS_CELL), gz=(int)floorf(z/CACTUS_CELL);
            float place = hashf(gx*991+7, gz*787+13);
            if(place >= S.cactusFreq) continue;

            float jx = (hashf(gx*5+1, gz*9+2)-0.5f) * CACTUS_CELL*0.7f;
            float jz = (hashf(gx*11+3, gz*3+4)-0.5f) * CACTUS_CELL*0.7f;
            float wx = (gx+0.5f)*CACTUS_CELL + jx;
            float wz = (gz+0.5f)*CACTUS_CELL + jz;

            float dx = wx-cx, dz = wz-cz;
            float d2 = dx*dx + dz*dz;
            if(d2 > reach2) continue;
            if(inCrater(wx, wz)) continue;        /* obliterated by a blast */
            buildCactus(&gCacti, wx, wz, sqrtf(d2));
        }
    }
}

/* rebuild whichever caches are stale, then leave them ready to draw */
static void ensureCaches(void){
    float tmargin = fmaxf(24.0f, g_viewRadius*0.18f);
    float dxt = camPos.x - tk.cx, dzt = camPos.z - tk.cz;
    if(!tk.ok || dxt*dxt+dzt*dzt > (tmargin*0.9f)*(tmargin*0.9f) ||
       tk.radius!=g_viewRadius || tk.hueT!=S.terrainHue ||
       tk.mFreq!=S.mountainFreq || tk.mRough!=S.mountainRough ||
       tk.mMinH!=S.mountainMinH || tk.mMaxH!=S.mountainMaxH){
        buildTerrain(camPos.x, camPos.z, tmargin);
        tk.ok=true; tk.cx=camPos.x; tk.cz=camPos.z; tk.radius=g_viewRadius;
        tk.hueT=S.terrainHue; tk.mFreq=S.mountainFreq; tk.mRough=S.mountainRough;
        tk.mMinH=S.mountainMinH; tk.mMaxH=S.mountainMaxH;
        tk.margin=tmargin;
    }

    float cmargin = fmaxf(16.0f, g_viewRadius*0.12f);
    float dxc = camPos.x - ck.cx, dzc = camPos.z - ck.cz;
    if(!ck.ok || dxc*dxc+dzc*dzc > (cmargin*0.9f)*(cmargin*0.9f) ||
       ck.radius!=g_viewRadius ||
       ck.freq!=S.cactusFreq || ck.sizeVar!=S.cactusSizeVar ||
       ck.minS!=S.cactusMinSize || ck.maxS!=S.cactusMaxSize ||
       ck.hue!=S.cactusHue || ck.arms!=S.maxArms){
        buildCacti(camPos.x, camPos.z, cmargin);
        ck.ok=true; ck.cx=camPos.x; ck.cz=camPos.z; ck.radius=g_viewRadius;
        ck.freq=S.cactusFreq; ck.sizeVar=S.cactusSizeVar;
        ck.minS=S.cactusMinSize; ck.maxS=S.cactusMaxSize;
        ck.hue=S.cactusHue; ck.arms=S.maxArms;
    }
}

/* ------------------------------------------------------------------ */
/* settings printout + key handling                                    */
/* ------------------------------------------------------------------ */
static void printSettings(void){
    printf("\rterrainHue=%.3f mtnFreq=%.2f mtnRough=%.2f mtnH=%.0f-%.0f "
           "cactusFreq=%.2f sizeVar=%.2f size=%.1f-%.1f cactusHue=%.3f "
           "maxArms=%d rDist=%.0f   ",
           S.terrainHue, S.mountainFreq, S.mountainRough,
           S.mountainMinH, S.mountainMaxH, S.cactusFreq,
           S.cactusSizeVar, S.cactusMinSize, S.cactusMaxSize,
           S.cactusHue, S.maxArms, g_viewRadius);
    fflush(stdout);
}

static bool  g_fullscreen = false;
static int   g_winX=80, g_winY=80, g_winW=1280, g_winH=720;

/* free-fly camera state */
static float camYaw   = 0.0f;     /* 0 looks toward -z */
static float camPitch = -0.18f;   /* slightly downward */
static V3    camVel;              /* persistent world-space velocity (W/S) */
static double g_lastInput = 0.0;  /* time of last keypress (for HUD fade)  */

/* ================================================================== */
/* bombs + animated vector mushroom clouds                            */
/* ================================================================== */
#define MAX_BOMBS   48
#define BOMB_GRAV   120.0f        /* fall acceleration                    */
#define EXPLO_DUR   12.0f         /* explosion lifetime (seconds)         */

typedef struct {
    bool  active;
    bool  exploding;              /* false = falling, true = mushroom     */
    float gx, gz;                 /* ground impact x,z                    */
    float y;                      /* current altitude while falling       */
    float vy;                     /* fall speed                           */
    float groundY;                /* terrain height at impact             */
    float t;                      /* time since detonation                */
    float size;                   /* cloud size (20 * max mountain height)*/
    V3    col;                    /* cloud hue                            */
} Bomb;
static Bomb bombs[MAX_BOMBS];

static uint32_t g_rng = 0x2545F491u;
static float frand(void){
    g_rng = g_rng*1664525u + 1013904223u;
    return ((g_rng >> 8) & 0xffffff) / (float)0xffffff;
}
static V3 vlerp(V3 a, V3 b, float t){
    return v3(lerpf(a.x,b.x,t), lerpf(a.y,b.y,t), lerpf(a.z,b.z,t));
}

/* a wobbly horizontal ring -- the basic cloud-billow primitive */
static void drawWobRing(float cx,float cy,float cz,float r,int nA,
                        float wob,int lobes,float phase,V3 col,float a){
    if(r <= 0.0f || a <= 0.0f) return;
    glColor4f(col.x,col.y,col.z,a);
    glBegin(GL_LINE_LOOP);
    for(int k=0;k<nA;k++){
        float ang = 2.0f*(float)M_PI*k/nA;
        float rr = r*(1.0f + wob*sinf(lobes*ang + phase));
        glVertex3f(cx+cosf(ang)*rr, cy, cz+sinf(ang)*rr);
    }
    glEnd();
}

/* a wireframe surface of revolution (stem / cap): stacked rings plus
 * longitudinal strands, with an animated billow wobble */
static void drawLathe(float cx,float cz,const float*ys,const float*rs,
                      const V3*cols,int nP,float wob,int lobes,
                      float phase,float alpha){
    const int nA=48, stride=4;                 /* 12 longitudinal strands */
    for(int i=0;i<nP;i++){
        glColor4f(cols[i].x,cols[i].y,cols[i].z,alpha);
        glBegin(GL_LINE_LOOP);
        for(int k=0;k<nA;k++){
            float ang = 2.0f*(float)M_PI*k/nA;
            float rr = rs[i]*(1.0f + wob*sinf(lobes*ang + phase + i*0.7f));
            glVertex3f(cx+cosf(ang)*rr, ys[i], cz+sinf(ang)*rr);
        }
        glEnd();
    }
    for(int k=0;k<nA;k+=stride){
        float ang = 2.0f*(float)M_PI*k/nA;
        glBegin(GL_LINE_STRIP);
        for(int i=0;i<nP;i++){
            glColor4f(cols[i].x,cols[i].y,cols[i].z,alpha);
            float rr = rs[i]*(1.0f + wob*sinf(lobes*ang + phase + i*0.7f));
            glVertex3f(cx+cosf(ang)*rr, ys[i], cz+sinf(ang)*rr);
        }
        glEnd();
    }
}

/* wireframe sphere for the initial fireball */
static void drawSphereWire(float cx,float cy,float cz,float r,
                           V3 col,float alpha,float phase){
    for(int j=1;j<6;j++){
        float th = (float)M_PI*j/6;
        drawWobRing(cx, cy+cosf(th)*r, cz, sinf(th)*r, 32,
                    0.08f, 6, phase+j, col, alpha);
    }
    for(int m=0;m<6;m++){
        float a = (float)M_PI*m/6;
        glColor4f(col.x,col.y,col.z,alpha);
        glBegin(GL_LINE_STRIP);
        for(int j=0;j<=16;j++){
            float th = (float)M_PI*j/16;
            float rr = sinf(th)*r;
            glVertex3f(cx+cosf(a)*rr, cy+cosf(th)*r, cz+sinf(a)*rr);
        }
        glEnd();
    }
}

/* a vortex torus -- the rolling cap whose outer edge curls up and over.
 * Rmaj is the ring radius, rmin the tube radius; the tube cross-section is
 * drawn as latitude rings plus meridional strands so the roll reads clearly */
static void drawTorusCloud(float cx,float cz,float cy,float Rmaj,float rmin,
                           V3 col,V3 hot,float alpha,float roil){
    if(alpha <= 0.01f) return;
    const int nPhi=10, nTh=40, nStrand=14;
    /* latitude rings around the tube */
    for(int p=0;p<nPhi;p++){
        float phi = 2.0f*(float)M_PI*p/nPhi;
        float Rr  = Rmaj + rmin*cosf(phi);
        float yy  = cy   + rmin*sinf(phi);
        float ct  = 0.5f + 0.5f*cosf(phi);          /* 1 outer .. 0 inner   */
        V3 c = vlerp(hot, col, ct);
        float wob = 0.12f + 0.05f*sinf(roil + phi);
        drawWobRing(cx, yy, cz, Rr, nTh, wob, 7, roil*1.1f + phi*2.0f, c, alpha);
    }
    /* meridional strands showing the rolling tube cross-section */
    for(int m=0;m<nStrand;m++){
        float th = 2.0f*(float)M_PI*m/nStrand;
        glBegin(GL_LINE_STRIP);
        for(int p=0;p<=nPhi;p++){
            float phi = 2.0f*(float)M_PI*p/nPhi;
            float Rr  = Rmaj + rmin*cosf(phi);
            float yy  = cy   + rmin*sinf(phi);
            float ct  = 0.5f + 0.5f*cosf(phi);
            V3 c = vlerp(hot, col, ct);
            glColor4f(c.x,c.y,c.z, alpha);
            glVertex3f(cx+cosf(th)*Rr, yy, cz+sinf(th)*Rr);
        }
        glEnd();
    }
}

/* the full animated mushroom cloud */
static void drawMushroom(const Bomb *b){
    float t=b->t, H=b->size, gY=b->groundY, cx=b->gx, cz=b->gz;
    float eg   = smoothstepf(0.0f, 3.0f, t);                /* main growth */
    float late = clampf((t-3.0f)/6.0f, 0.0f, 1.0f);         /* slow billow */
    float fade = (t < EXPLO_DUR-2.5f) ? 1.0f
                                      : clampf((EXPLO_DUR-t)/2.5f,0.0f,1.0f);
    float roil = t*1.6f;                                    /* roll phase  */
    V3 hue = b->col;
    V3 hot = vlerp(hue, v3(1.0f,0.95f,0.7f), 0.75f);        /* incandescent*/
    float alpha = fade;

    float top  = H*(0.22f + 0.78f*eg);
    float stemTopY = gY + top*0.50f;
    float capR   = H*0.30f*(0.35f + 0.65f*eg)*(1.0f + 0.4f*late);
    float stemR  = H*0.055f*(0.5f + 0.5f*eg);
    /* the head always sits on top of the stem -- it never detaches. It just
     * keeps billowing and rolling (the roil phase advances with time) as the
     * whole cloud fades away. */
    float capCenterY = stemTopY + capR*0.45f;
    float capH       = top*0.50f;

    /* initial blinding fireball, fading into the rising column */
    if(t < 1.0f){
        float ff = clampf(t/0.6f, 0.0f, 1.0f);
        float fr = H*0.14f*ff + H*0.02f;
        float fy = gY + fr*0.9f + (stemTopY-gY)*0.2f*ff;
        float fa = alpha*clampf(1.0f-(t-0.4f)/0.6f, 0.0f, 1.0f);
        if(fa > 0.01f) drawSphereWire(cx,fy,cz,fr, hot, fa, roil*2.0f);
    }

    /* ground shock + dust skirt */
    float shockR = H*0.5f*smoothstepf(0.0f,2.0f,t);
    float shockA = alpha*clampf(1.0f - t/4.0f, 0.0f,1.0f)*0.7f;
    if(shockA > 0.01f){
        drawWobRing(cx, gY+H*0.005f, cz, shockR,      48, 0.05f, 8, roil,      hue, shockA);
        drawWobRing(cx, gY+H*0.02f,  cz, shockR*0.7f, 44, 0.10f, 6, roil*1.3f, hue, shockA*0.8f);
    }

    /* rising vortex rings climbing the stem */
    for(int k=0;k<3;k++){
        float rp = fmodf(t*0.5f + k*0.34f, 1.0f);
        float ry = gY + rp*(stemTopY-gY);
        float rr = stemR*(1.0f + rp*2.2f);
        drawWobRing(cx, ry, cz, rr, 32, 0.14f, 5, roil+k, vlerp(hot,hue,rp), alpha*(1.0f-rp)*0.7f);
    }

    /* stem -- always joined to the cap */
    {
        const int nP=8; float ys[8], rs[8]; V3 cs[8];
        for(int i=0;i<nP;i++){
            float f=(float)i/(nP-1);
            ys[i]=gY + f*(stemTopY-gY);
            rs[i]=stemR*(0.8f + 0.35f*sinf(f*(float)M_PI));
            cs[i]=vlerp(hot,hue, clampf(f*1.2f,0.0f,1.0f));
        }
        drawLathe(cx,cz, ys,rs,cs, nP, 0.08f, 5, roil, alpha*0.9f);
    }

    /* domed top of the cap */
    {
        const int nP=8; float ys[8], rs[8]; V3 cs[8];
        for(int i=0;i<nP;i++){
            float f=(float)i/(nP-1);
            float dome=sqrtf(clampf(1.0f-f*f,0.0f,1.0f));
            ys[i]=capCenterY + f*(capH*0.5f);
            rs[i]=capR*dome;
            cs[i]=vlerp(hot,hue, clampf(0.3f+f,0.0f,1.0f));
        }
        float wob=0.12f + 0.05f*sinf(roil);
        drawLathe(cx,cz, ys,rs,cs, nP, wob, 7, roil*1.2f, alpha);
    }

    /* the billowing cap as a rolling vortex torus whose outer edge curls
     * over -- its underside meets the top of the stem */
    drawTorusCloud(cx,cz,capCenterY, capR*0.78f, capR*0.5f, hue, hot, alpha*0.85f, roil);
}

/* the falling bomb: a small finned body trailing a bright tracer */
static void drawBomb(const Bomb *b){
    float x=b->gx, y=b->y, z=b->gz, s=1.8f;
    glColor4f(1.0f,0.75f,0.35f,0.85f);
    glBegin(GL_LINES);
        glVertex3f(x,y,z); glVertex3f(x,y+10.0f,z);
    glEnd();
    glColor4f(0.9f,0.9f,0.95f,1.0f);
    glBegin(GL_LINE_LOOP);
        glVertex3f(x,y+s*2,z); glVertex3f(x+s,y,z);
        glVertex3f(x,y-s*2,z); glVertex3f(x-s,y,z);
    glEnd();
    glBegin(GL_LINE_LOOP);
        glVertex3f(x,y+s*2,z); glVertex3f(x,y,z+s);
        glVertex3f(x,y-s*2,z); glVertex3f(x,y,z-s);
    glEnd();
}

/* drop a new bomb into a random spot in the field of view, between half
 * the rendering distance and the full rendering distance */
static void spawnBomb(void){
    int slot=-1;
    for(int i=0;i<MAX_BOMBS;i++) if(!bombs[i].active){ slot=i; break; }
    if(slot<0){                         /* all busy: reuse the oldest */
        float best=-1.0f;
        for(int i=0;i<MAX_BOMBS;i++) if(bombs[i].t>best){ best=bombs[i].t; slot=i; }
    }
    Bomb *b=&bombs[slot];
    float R = g_viewRadius;
    float a = camYaw + (frand()-0.5f)*1.1f;          /* within the FOV     */
    float dist = lerpf(0.5f*R, R, frand());          /* half..full distance*/
    b->gx = camPos.x + sinf(a)*dist;
    b->gz = camPos.z - cosf(a)*dist;
    b->groundY = terrainHeight(b->gx, b->gz);
    b->size = 20.0f * S.mountainMaxH;
    b->y  = b->groundY + fmaxf(160.0f, b->size*0.55f);
    b->vy = 0.0f;
    b->t  = 0.0f;
    b->exploding = false;
    b->active = true;
    b->col = hsv2rgb(frand(), 0.85f, 1.0f);   /* vivid, extremely bright */
}

static void updateBombs(float dt){
    for(int i=0;i<MAX_BOMBS;i++){
        Bomb *b=&bombs[i];
        if(!b->active) continue;
        if(!b->exploding){
            b->vy += BOMB_GRAV*dt;
            b->y  -= b->vy*dt;
            if(b->y <= b->groundY){       /* impact -> detonate */
                b->y = b->groundY;
                b->exploding = true;
                b->t = 0.0f;
                /* obliterate mountains + cacti within the cloud's total
                 * radius, and gouge a real crater bowl at the impact point */
                addCrater(b->gx, b->gz, b->size);
                tk.ok = false; ck.ok = false;   /* force terrain+cacti rebuild */
            }
        } else {
            b->t += dt;
            if(b->t >= EXPLO_DUR) b->active = false;
        }
    }
}

static void drawBombs(void){
    bool any=false;
    for(int i=0;i<MAX_BOMBS;i++) if(bombs[i].active){ any=true; break; }
    if(!any) return;

    /* additive blending + no fog makes the clouds glow at full, extreme
     * brightness regardless of distance */
    glDisable(GL_FOG);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE);
    for(int i=0;i<MAX_BOMBS;i++){
        if(!bombs[i].active) continue;
        if(bombs[i].exploding) drawMushroom(&bombs[i]);
        else                   drawBomb(&bombs[i]);
    }
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glEnable(GL_FOG);
}

static void toggleFullscreen(GLFWwindow *w){
    g_fullscreen = !g_fullscreen;
    if(g_fullscreen){
        glfwGetWindowPos(w, &g_winX, &g_winY);
        glfwGetWindowSize(w, &g_winW, &g_winH);
        GLFWmonitor *m = glfwGetPrimaryMonitor();
        const GLFWvidmode *vm = glfwGetVideoMode(m);
        glfwSetWindowMonitor(w, m, 0, 0, vm->width, vm->height, vm->refreshRate);
    } else {
        glfwSetWindowMonitor(w, NULL, g_winX, g_winY, g_winW, g_winH, 0);
    }
}

static void keyCB(GLFWwindow *w, int key, int sc, int action, int mods){
    (void)sc; (void)mods;
    if(action != GLFW_PRESS && action != GLFW_REPEAT) return;
    g_lastInput = glfwGetTime();
    switch(key){
        case GLFW_KEY_ESCAPE: glfwSetWindowShouldClose(w, GLFW_TRUE); break;
        case GLFW_KEY_F:      if(action==GLFW_PRESS) toggleFullscreen(w); break;
        case GLFW_KEY_SPACE:  if(action==GLFW_PRESS) spawnBomb(); break;
        case GLFW_KEY_X:      if(action==GLFW_PRESS) camVel = v3(0,0,0); break;  /* full stop */
        case GLFW_KEY_G:      if(action==GLFW_PRESS) g_showAlt = !g_showAlt; break;

        case GLFW_KEY_1: S.terrainHue   = fmodf(S.terrainHue-0.01f+1.0f,1.0f); printSettings(); break;
        case GLFW_KEY_2: S.terrainHue   = fmodf(S.terrainHue+0.01f,1.0f);      printSettings(); break;
        case GLFW_KEY_3: S.mountainFreq = clampf(S.mountainFreq-0.05f,0.2f,4.0f);printSettings(); break;
        case GLFW_KEY_4: S.mountainFreq = clampf(S.mountainFreq+0.05f,0.2f,4.0f);printSettings(); break;
        case GLFW_KEY_5: S.cactusFreq   = clampf(S.cactusFreq-0.02f,0.0f,1.0f); printSettings(); break;
        case GLFW_KEY_6: S.cactusFreq   = clampf(S.cactusFreq+0.02f,0.0f,1.0f); printSettings(); break;
        case GLFW_KEY_7: S.cactusSizeVar= clampf(S.cactusSizeVar-0.02f,0.0f,0.95f);printSettings(); break;
        case GLFW_KEY_8: S.cactusSizeVar= clampf(S.cactusSizeVar+0.02f,0.0f,0.95f);printSettings(); break;
        case GLFW_KEY_9: S.cactusHue    = fmodf(S.cactusHue-0.01f+1.0f,1.0f);  printSettings(); break;
        case GLFW_KEY_0: S.cactusHue    = fmodf(S.cactusHue+0.01f,1.0f);       printSettings(); break;
        case GLFW_KEY_MINUS: if(S.maxArms>0) S.maxArms--; printSettings(); break;
        case GLFW_KEY_EQUAL: if(S.maxArms<ARM_LIMIT) S.maxArms++; printSettings(); break;
        case GLFW_KEY_LEFT_BRACKET:  g_viewRadius = clampf(g_viewRadius-16.0f, 40.0f, 960.0f); printSettings(); break;
        case GLFW_KEY_RIGHT_BRACKET: g_viewRadius = clampf(g_viewRadius+16.0f, 40.0f, 960.0f); printSettings(); break;
        case GLFW_KEY_COMMA:  S.mountainRough = clampf(S.mountainRough-0.05f,0.0f,1.0f); printSettings(); break;
        case GLFW_KEY_PERIOD: S.mountainRough = clampf(S.mountainRough+0.05f,0.0f,1.0f); printSettings(); break;
        case GLFW_KEY_T: S.mountainMinH = clampf(S.mountainMinH-1.0f, 0.0f, S.mountainMaxH); printSettings(); break;
        case GLFW_KEY_Y: S.mountainMinH = clampf(S.mountainMinH+1.0f, 0.0f, S.mountainMaxH); printSettings(); break;
        case GLFW_KEY_U: S.mountainMaxH = clampf(S.mountainMaxH-1.0f, S.mountainMinH, 80.0f); printSettings(); break;
        case GLFW_KEY_I: S.mountainMaxH = clampf(S.mountainMaxH+1.0f, S.mountainMinH, 80.0f); printSettings(); break;
        case GLFW_KEY_J: S.cactusMinSize = clampf(S.cactusMinSize-0.5f, 1.0f, S.cactusMaxSize); printSettings(); break;
        case GLFW_KEY_K: S.cactusMinSize = clampf(S.cactusMinSize+0.5f, 1.0f, S.cactusMaxSize); printSettings(); break;
        case GLFW_KEY_N: S.cactusMaxSize = clampf(S.cactusMaxSize-0.5f, S.cactusMinSize, 30.0f); printSettings(); break;
        case GLFW_KEY_M: S.cactusMaxSize = clampf(S.cactusMaxSize+0.5f, S.cactusMinSize, 30.0f); printSettings(); break;
        default: break;
    }
}

static void fbSizeCB(GLFWwindow *w, int width, int height){
    (void)w;
    glViewport(0, 0, width, height);
}

/* ------------------------------------------------------------------ */
/* command line                                                        */
/* ------------------------------------------------------------------ */
static bool argval(const char *arg, const char *key, float *out){
    size_t n = strlen(key);
    if(strncmp(arg, key, n)==0 && arg[n]=='='){
        *out = (float)atof(arg+n+1);
        return true;
    }
    return false;
}

static void usage(const char *prog){
    printf("usage: %s [--fullscreen] [options]\n", prog);
    printf("  --terrain-hue=0..1     --mountain-freq=0.2..4\n");
    printf("  --mountain-rough=0..1\n");
    printf("  --mountain-min-height=0..80  --mountain-max-height=0..80\n");
    printf("  --cactus-freq=0..1\n");
    printf("  --cactus-size-var=0..0.95  --cactus-hue=0..1\n");
    printf("  --cactus-min-size=1..30    --cactus-max-size=1..30\n");
    printf("  --max-arms=0..%d\n", ARM_LIMIT);
}

int main(int argc, char **argv){
    bool startFull = false;
    float fv;
    loadSettings();                 /* resume last session's settings; CLI args below still override */
    for(int i=1;i<argc;i++){
        if(strcmp(argv[i],"--fullscreen")==0) startFull = true;
        else if(strcmp(argv[i],"--help")==0 || strcmp(argv[i],"-h")==0){ usage(argv[0]); return 0; }
        else if(argval(argv[i],"--terrain-hue",&fv))     S.terrainHue   = clampf(fv,0,1);
        else if(argval(argv[i],"--mountain-freq",&fv))   S.mountainFreq = clampf(fv,0.2f,4.0f);
        else if(argval(argv[i],"--mountain-rough",&fv))  S.mountainRough= clampf(fv,0.0f,1.0f);
        else if(argval(argv[i],"--mountain-min-height",&fv)) S.mountainMinH= clampf(fv,0.0f,80.0f);
        else if(argval(argv[i],"--mountain-max-height",&fv)) S.mountainMaxH= clampf(fv,0.0f,80.0f);
        else if(argval(argv[i],"--cactus-freq",&fv))     S.cactusFreq   = clampf(fv,0,1);
        else if(argval(argv[i],"--cactus-size-var",&fv)) S.cactusSizeVar= clampf(fv,0,0.95f);
        else if(argval(argv[i],"--cactus-min-size",&fv)) S.cactusMinSize= clampf(fv,1.0f,30.0f);
        else if(argval(argv[i],"--cactus-max-size",&fv)) S.cactusMaxSize= clampf(fv,1.0f,30.0f);
        else if(argval(argv[i],"--cactus-hue",&fv))      S.cactusHue    = clampf(fv,0,1);
        else if(argval(argv[i],"--max-arms",&fv))        S.maxArms      = (int)clampf(fv,0,ARM_LIMIT);
        else { fprintf(stderr,"unknown arg: %s\n", argv[i]); usage(argv[0]); return 1; }
    }
    if(S.cactusMinSize > S.cactusMaxSize){
        float t = S.cactusMinSize; S.cactusMinSize = S.cactusMaxSize; S.cactusMaxSize = t;
    }
    if(S.mountainMinH > S.mountainMaxH){
        float t = S.mountainMinH; S.mountainMinH = S.mountainMaxH; S.mountainMaxH = t;
    }

    if(!glfwInit()){ fprintf(stderr,"glfwInit failed\n"); return 1; }

    GLFWmonitor *mon = NULL;
    int cw = g_winW, ch = g_winH;
    if(startFull){
        mon = glfwGetPrimaryMonitor();
        const GLFWvidmode *vm = glfwGetVideoMode(mon);
        cw = vm->width; ch = vm->height;
        g_fullscreen = true;
    }

    GLFWwindow *win = glfwCreateWindow(cw, ch, "vectordesert", mon, NULL);
    if(!win){ fprintf(stderr,"window creation failed\n"); glfwTerminate(); return 1; }
    glfwMakeContextCurrent(win);
    glfwSwapInterval(1);
    glfwSetKeyCallback(win, keyCB);
    glfwSetFramebufferSizeCallback(win, fbSizeCB);

    printf("vectordesert -- wire-mesh vector desert flythrough\n");
    printf("move: W/S throttle  A/D strafe  PageUp/Dn altitude  arrows pan\n");
    printf("      X full stop  SPACE drops a bomb  G floor-altitude indicator\n");
    printf("keys: 1/2 terrainHue  3/4 mtnFreq  ,/. mtnRough  T/Y U/I mtnH\n");
    printf("      5/6 cactusFreq  7/8 sizeVar  J/K minSize  N/M maxSize\n");
    printf("      9/0 cactusHue  -/= maxArms  [ ] renderDist  F full  ESC quit\n\n");
    printSettings();
    printf("\n");

    glEnable(GL_DEPTH_TEST);
    glEnable(GL_LINE_SMOOTH);
    glHint(GL_LINE_SMOOTH_HINT, GL_NICEST);
    glEnable(GL_BLEND);
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    glLineWidth(1.0f);

    /* hardware linear fog fades distant geometry into the sky -- far cheaper
     * than computing a per-vertex fade on the CPU, and it lets the scene
     * geometry stay in static cached vertex arrays */
    GLfloat fogcol[4] = { 0.03f, 0.02f, 0.06f, 1.0f };
    glEnable(GL_FOG);
    glFogi(GL_FOG_MODE, GL_LINEAR);
    glFogfv(GL_FOG_COLOR, fogcol);
    glHint(GL_FOG_HINT, GL_NICEST);

    double t0 = glfwGetTime();
    g_lastInput = t0;                 /* HUD starts visible, then fades */
    g_rng ^= (uint32_t)(t0*1000.0) * 2654435761u;   /* seed the bomb RNG */
    camPos = v3(0.0f, 14.0f, 0.0f);
    const float moveSpeed = 28.0f;   /* WASD / strafe units per second */
    const float climbSpeed= 22.0f;   /* PageUp / PageDown              */
    const float panSpeed  = 1.4f;    /* arrow-key turn rate (rad/s)    */

    while(!glfwWindowShouldClose(win)){
        double t1 = glfwGetTime();
        float dt = (float)(t1 - t0);
        t0 = t1;

        /* arrow keys pan the view */
        if(glfwGetKey(win, GLFW_KEY_LEFT)  == GLFW_PRESS) camYaw   -= panSpeed*dt;
        if(glfwGetKey(win, GLFW_KEY_RIGHT) == GLFW_PRESS) camYaw   += panSpeed*dt;
        if(glfwGetKey(win, GLFW_KEY_UP)    == GLFW_PRESS) camPitch += panSpeed*dt;
        if(glfwGetKey(win, GLFW_KEY_DOWN)  == GLFW_PRESS) camPitch -= panSpeed*dt;
        camPitch = clampf(camPitch, -1.45f, 1.45f);

        /* view direction from yaw/pitch (yaw 0 -> -z) */
        V3 fwd = v3(sinf(camYaw)*cosf(camPitch),
                    sinf(camPitch),
                   -cosf(camYaw)*cosf(camPitch));
        V3 hfwd  = norm3(v3(fwd.x, 0.0f, fwd.z));     /* ground-plane forward */
        V3 right = norm3(cross3(fwd, v3(0,1,0)));

        /* W/S are a throttle: they add thrust along the current heading to a
         * persistent world-space velocity that holds when released. Because
         * the velocity lives in world space, panning the view with the arrow
         * keys does not change the direction of travel. */
        const float accel = 22.0f, maxSpeed = 90.0f;
        if(glfwGetKey(win, GLFW_KEY_W) == GLFW_PRESS) camVel = add(camVel, scl(hfwd,  accel*dt));
        if(glfwGetKey(win, GLFW_KEY_S) == GLFW_PRESS) camVel = add(camVel, scl(hfwd, -accel*dt));
        float sp = len3(camVel);
        if(sp > maxSpeed) camVel = scl(camVel, maxSpeed/sp);
        camPos = add(camPos, scl(camVel, dt));

        /* A/D strafe; PageUp/Down change altitude */
        if(glfwGetKey(win, GLFW_KEY_D) == GLFW_PRESS) camPos = add(camPos, scl(right, moveSpeed*dt));
        if(glfwGetKey(win, GLFW_KEY_A) == GLFW_PRESS) camPos = add(camPos, scl(right,-moveSpeed*dt));
        if(glfwGetKey(win, GLFW_KEY_PAGE_UP)   == GLFW_PRESS) camPos.y += climbSpeed*dt;
        if(glfwGetKey(win, GLFW_KEY_PAGE_DOWN) == GLFW_PRESS) camPos.y -= climbSpeed*dt;

        /* never sink below the terrain */
        float floorY = terrainHeight(camPos.x, camPos.z) + 2.0f;
        if(camPos.y < floorY) camPos.y = floorY;

        updateBombs(dt);

        V3 target = add(camPos, fwd);

        int fbw, fbh;
        glfwGetFramebufferSize(win, &fbw, &fbh);
        float aspect = (fbh>0) ? (float)fbw/(float)fbh : 1.0f;

        glClearColor(0.03f, 0.02f, 0.06f, 1.0f);  /* dusk desert sky */
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        glMatrixMode(GL_PROJECTION);
        glLoadIdentity();
        perspectiveGL(60.0f, aspect, 0.5f, g_viewRadius + 50.0f);

        glMatrixMode(GL_MODELVIEW);
        glLoadIdentity();
        lookAtGL(camPos, target, v3(0,1,0));

        /* distance fade tracks the rendering distance */
        glFogf(GL_FOG_START, FOG_NEAR);
        glFogf(GL_FOG_END,   fogFarDist());

        ensureCaches();
        batchDraw(&gTerr);
        batchDraw(&gCacti);
        drawBombs();

        /* HUD: fully visible, then fades after 10s without a keypress */
        double idle = glfwGetTime() - g_lastInput;
        float hudAlpha = (idle < 10.0)
                       ? 1.0f
                       : clampf(1.0f - (float)(idle-10.0)/1.5f, 0.0f, 1.0f);
        if(hudAlpha > 0.001f) drawHUD(fbw, fbh, hudAlpha);
        if(g_showAlt) drawAltIndicator(fbw, fbh);

        glfwSwapBuffers(win);
        glfwPollEvents();
    }

    printf("\n");
    saveSettings();                 /* persist settings for next launch */
    glfwDestroyWindow(win);
    glfwTerminate();
    return 0;
}
