灵符lingfu

ling-lang

↓ 0 downloads

Ling - The Omniglot Systems Language

Keywords systems, polyglot, ai, crypto, game
lingfu install ling-lang
LicenseApache-2.0 OR MIT
AuthorsLing Lin, Sanny Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (995)

crates/ling-kernel/src/drivers/g3d.rs
//! Native 3-D for compiled `.ling` games on LingOS: the host's game builtins
//! (`ling/src/runtime/game3d.rs`, the shape builtins and the camera / light /
//! fog state of `ling/src/gfx`) rendered straight into the app window's
//! `0xRRGGBB` back buffer, so LingDev's players run their world through the
//! kernel instead of the Ling-side software renderer (`rt_sw3d.ling`).
//!
//! What is here: the host camera (Y-then-X rotation, `zdist`, focal length /
//! `tm_fov`), a per-window z-buffer, near-plane clipping, a fixed-point span
//! rasterizer (nearest texel, perspective-correct every 8 pixels, per-vertex
//! colour, point lights evaluated per 8-pixel block for `tm_draw_lit`, fog,
//! normal / additive blending, the see-through "hole"), the primitive shapes
//! (cube, sphere, cylinder, cone, capsule, torus; cel-lit per vertex, filled
//! or wireframe), `.ltm` meshes with their groups, textured quads, billboards,
//! 2-D sprites, glyph textures from the app's fonts, the particle system and
//! the mesh collision grid (`coll_*`) Soul Symphony's physics asks for.
//!
//! Opaque triangles draw immediately; anything translucent or additive is
//! kept until `flush_3d` and drawn back-to-front. Textures are power-of-two
//! squares of `0xAARRGGBB` texels (PNG with alpha, SVG rendered twice to
//! recover coverage, BMP), resampled like the host's `tm_tex_load`.

use alloc::collections::BTreeMap;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;

use crate::lingrt::{is_list, list_from, list_items, num, str_of, to_f64, to_string, UNIT};

type V3 = [f32; 3];

// ── small math ───────────────────────────────────────────────────────────────

#[inline]
fn sub(a: V3, b: V3) -> V3 {
    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
#[inline]
fn dot(a: V3, b: V3) -> f32 {
    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[inline]
fn cross(a: V3, b: V3) -> V3 {
    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
#[inline]
fn sqrt(x: f32) -> f32 {
    libm::sqrtf(x)
}
#[inline]
fn norm(a: V3) -> V3 {
    let l = sqrt(dot(a, a));
    if l > 1e-8 {
        [a[0] / l, a[1] / l, a[2] / l]
    } else {
        [0.0; 3]
    }
}
#[inline]
fn lerp3(a: V3, b: V3, t: f32) -> V3 {
    [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
}
#[inline]
fn sin(x: f32) -> f32 {
    libm::sinf(x)
}
#[inline]
fn cos(x: f32) -> f32 {
    libm::cosf(x)
}
#[inline]
fn floor(x: f32) -> f32 {
    libm::floorf(x)
}
#[inline]
fn ceil(x: f32) -> f32 {
    libm::ceilf(x)
}
const PI: f32 = core::f32::consts::PI;
const TAU: f32 = core::f32::consts::TAU;

fn pow2(n: u32) -> u32 {
    let mut p = 8;
    while p < n {
        p <<= 1;
    }
    p
}

// ── Ling values ──────────────────────────────────────────────────────────────

#[inline]
fn f(args: &[u64], i: usize, d: f32) -> f32 {
    match args.get(i) {
        Some(&v) => {
            let x = to_f64(v) as f32;
            if x.is_finite() {
                x
            } else {
                d
            }
        },
        None => d,
    }
}
#[inline]
fn n(args: &[u64], i: usize, d: f64) -> f64 {
    args.get(i).map(|&v| to_f64(v)).unwrap_or(d)
}
fn rgb(c: f64) -> u32 {
    (c.max(0.0) as u64 & 0x00FF_FFFF) as u32
}
fn col3(c: u32) -> V3 {
    [((c >> 16) & 255) as f32 / 255.0, ((c >> 8) & 255) as f32 / 255.0, (c & 255) as f32 / 255.0]
}
fn pack3(c: V3) -> u32 {
    let q = |x: f32| (x * 255.0).clamp(0.0, 255.0) as u32;
    (q(c[0]) << 16) | (q(c[1]) << 8) | q(c[2])
}
fn lerp_rgb(a: u32, b: u32, t: f32) -> u32 {
    let ch = |s: u32| -> u32 {
        let x = ((a >> s) & 255) as f32;
        let y = ((b >> s) & 255) as f32;
        ((x + (y - x) * t).clamp(0.0, 255.0) as u32) << s
    };
    ch(16) | ch(8) | ch(0)
}
fn nums(v: &[f32]) -> u64 {
    list_from(v.iter().map(|&x| num(x as f64)).collect())
}
/// `rot` argument: a yaw angle or a row-major 3x3 list.
fn rot_from(args: &[u64], i: usize) -> [f32; 9] {
    if let Some(&v) = args.get(i) {
        if is_list(v) {
            let l = list_items(v);
            if l.len() >= 9 {
                let mut m = [0.0f32; 9];
                for k in 0..9 {
                    m[k] = to_f64(l[k]) as f32;
                }
                return m;
            }
        }
    }
    let yaw = f(args, i, 0.0);
    let (c, s) = (cos(yaw), sin(yaw));
    [c, 0.0, s, 0.0, 1.0, 0.0, -s, 0.0, c]
}
#[inline]
fn xf(m: &[f32; 9], p: V3, s: f32, o: V3) -> V3 {
    [
        o[0] + s * (m[0] * p[0] + m[1] * p[1] + m[2] * p[2]),
        o[1] + s * (m[3] * p[0] + m[4] * p[1] + m[5] * p[2]),
        o[2] + s * (m[6] * p[0] + m[7] * p[1] + m[8] * p[2]),
    ]
}
#[inline]
fn rotn(m: &[f32; 9], v: V3) -> V3 {
    [
        m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
        m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
        m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
    ]
}

// ── textures ─────────────────────────────────────────────────────────────────

/// A square power-of-two texture of `0xAARRGGBB` texels.
pub struct Tex {
    w: u32,
    sh: u32,
    px: Vec<u32>,
}

impl Tex {
    fn new(w: u32, px: Vec<u32>) -> Tex {
        let mut sh = 0;
        while (1u32 << sh) < w {
            sh += 1;
        }
        Tex { w, sh, px }
    }
}

/// Box-filter `src` (any size, `0xAARRGGBB`) into a `size` x `size` square,
/// alpha-weighted so transparent texels do not bleed black.
fn resample(src: &[u32], sw: u32, sh: u32, size: u32) -> Vec<u32> {
    let mut out = vec![0u32; (size * size) as usize];
    if sw == 0 || sh == 0 {
        return out;
    }
    let fx = sw as f32 / size as f32;
    let fy = sh as f32 / size as f32;
    let taps = (ceil(if fx > fy { fx } else { fy }) as u32).clamp(1, 8);
    for y in 0..size {
        for x in 0..size {
            let mut acc = [0u64; 4];
            for j in 0..taps {
                for i in 0..taps {
                    let sx = (((x as f32 + (i as f32 + 0.5) / taps as f32) * fx) as u32).min(sw - 1);
                    let sy = (((y as f32 + (j as f32 + 0.5) / taps as f32) * fy) as u32).min(sh - 1);
                    let c = src[(sy * sw + sx) as usize];
                    let a = (c >> 24) as u64;
                    acc[0] += ((c >> 16) & 255) as u64 * a;
                    acc[1] += ((c >> 8) & 255) as u64 * a;
                    acc[2] += (c & 255) as u64 * a;
                    acc[3] += a;
                }
            }
            let nt = (taps * taps) as u64;
            let a = acc[3] / nt;
            let (r, g, b) = if acc[3] > 0 { (acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3]) } else { (0, 0, 0) };
            out[(y * size + x) as usize] = ((a.min(255) as u32) << 24) | ((r.min(255) as u32) << 16) | ((g.min(255) as u32) << 8) | b.min(255) as u32;
        }
    }
    out
}

fn load_tex(path: &str, size: u32) -> Option<Tex> {
    use crate::drivers::image;
    let data = crate::vfs::read(path)?;
    let data = image::gunzip(&data).unwrap_or(data);
    let l = path.to_ascii_lowercase();
    let size = pow2(size.clamp(8, 1024));
    let img = if l.ends_with(".svg") || l.ends_with(".svgz") || image::looks_svg(&data) {
        image::decode_svg_alpha(&data, size)?
    } else if l.ends_with(".bmp") {
        image::decode_bmp_rgba(&data)?
    } else {
        image::decode_png_rgba(&data)?
    };
    Some(Tex::new(size, resample(&img.px, img.w, img.h, size)))
}

fn glow_tex() -> Tex {
    let n = 64u32;
    let mut px = vec![0u32; (n * n) as usize];
    for y in 0..n {
        for x in 0..n {
            let dx = (x as f32 + 0.5) / n as f32 * 2.0 - 1.0;
            let dy = (y as f32 + 0.5) / n as f32 * 2.0 - 1.0;
            let d = sqrt(dx * dx + dy * dy);
            let core = (1.0 - d / 0.35).clamp(0.0, 1.0);
            let halo = (1.0 - d).clamp(0.0, 1.0);
            let a = (core * core * 0.9 + halo * halo * halo * 0.55).clamp(0.0, 1.0);
            px[(y * n + x) as usize] = (((a * 255.0) as u32) << 24) | 0x00FF_FFFF;
        }
    }
    Tex::new(n, px)
}

fn soft_tex() -> Tex {
    let n = 64u32;
    let mut px = vec![0u32; (n * n) as usize];
    for y in 0..n {
        for x in 0..n {
            let dx = (x as f32 + 0.5) / n as f32 * 2.0 - 1.0;
            let dy = (y as f32 + 0.5) / n as f32 * 2.0 - 1.0;
            let t = (1.0 - sqrt(dx * dx + dy * dy)).clamp(0.0, 1.0);
            let a = t * t * (3.0 - 2.0 * t);
            px[(y * n + x) as usize] = (((a * 255.0) as u32) << 24) | 0x00FF_FFFF;
        }
    }
    Tex::new(n, px)
}

// ── meshes, lights, particles, collision ─────────────────────────────────────

struct Group {
    name: String,
    pos: Vec<V3>,
    uv: Vec<[f32; 2]>,
    nrm: Vec<V3>,
    idx: Vec<[u32; 3]>,
}

struct Mesh {
    groups: Vec<Group>,
}

fn load_ltm(path: &str) -> Option<Mesh> {
    let b = crate::vfs::read(path)?;
    if b.len() < 8 || &b[0..4] != b"LTM1" {
        return None;
    }
    let mut o = 4usize;
    let u32_at = |o: &mut usize| -> Option<u32> {
        if *o + 4 > b.len() {
            return None;
        }
        let v = u32::from_le_bytes([b[*o], b[*o + 1], b[*o + 2], b[*o + 3]]);
        *o += 4;
        Some(v)
    };
    let f32_at = |o: &mut usize| -> Option<f32> { u32_at(o).map(f32::from_bits) };
    let ng = u32_at(&mut o)?;
    let mut groups = Vec::new();
    for _ in 0..ng {
        if o >= b.len() {
            return None;
        }
        let nl = b[o] as usize;
        o += 1;
        let name = String::from_utf8_lossy(&b[o..(o + nl).min(b.len())]).into_owned();
        o += nl;
        let nv = u32_at(&mut o)? as usize;
        let nt = u32_at(&mut o)? as usize;
        if o + nv * 32 + nt * 12 > b.len() {
            return None;
        }
        let mut g = Group { name, pos: Vec::with_capacity(nv), uv: Vec::with_capacity(nv), nrm: Vec::with_capacity(nv), idx: Vec::with_capacity(nt) };
        for _ in 0..nv {
            let x = f32_at(&mut o)?;
            let y = f32_at(&mut o)?;
            let z = f32_at(&mut o)?;
            let u = f32_at(&mut o)?;
            let v = f32_at(&mut o)?;
            let nx = f32_at(&mut o)?;
            let ny = f32_at(&mut o)?;
            let nz = f32_at(&mut o)?;
            g.pos.push([x, y, z]);
            g.uv.push([u, v]);
            g.nrm.push([nx, ny, nz]);
        }
        for _ in 0..nt {
            let a = u32_at(&mut o)?;
            let c = u32_at(&mut o)?;
            let d = u32_at(&mut o)?;
            if (a as usize) < nv && (c as usize) < nv && (d as usize) < nv {
                g.idx.push([a, c, d]);
            }
        }
        groups.push(g);
    }
    Some(Mesh { groups })
}

#[derive(Clone, Copy)]
struct Light {
    p: V3,
    c: V3,
    i: f32,
    r: f32,
}

struct Part {
    p: V3,
    v: V3,
    life: f32,
    max: f32,
    s0: f32,
    s1: f32,
    c0: u32,
    c1: u32,
    kind: u8,
    tex: u32,
    grav: f32,
    spin: f32,
    alpha: f32,
}

/// A triangle soup in a uniform grid, for `coll_sphere` / `coll_ray`.
struct Coll {
    tris: Vec<[V3; 3]>,
    nrm: Vec<V3>,
    lo: V3,
    cell: f32,
    dims: [i32; 3],
    cells: Vec<Vec<u32>>,
    stamp: Vec<u32>,
    q: u32,
}

impl Coll {
    fn build(tris: Vec<[V3; 3]>, nrm: Vec<V3>, cell: f32) -> Coll {
        let mut lo = [f32::INFINITY; 3];
        let mut hi = [f32::NEG_INFINITY; 3];
        for t in &tris {
            for p in t {
                for k in 0..3 {
                    lo[k] = lo[k].min(p[k]);
                    hi[k] = hi[k].max(p[k]);
                }
            }
        }
        if tris.is_empty() {
            lo = [0.0; 3];
            hi = [1.0; 3];
        }
        let mut cell = cell.max(0.5);
        let mut dims;
        loop {
            dims = [0i32; 3];
            for k in 0..3 {
                dims[k] = (((hi[k] - lo[k]) / cell) as i32 + 1).max(1);
            }
            if (dims[0] as i64) * (dims[1] as i64) * (dims[2] as i64) <= 1 << 18 {
                break;
            }
            cell *= 2.0;
        }
        let total = (dims[0] * dims[1] * dims[2]) as usize;
        let mut cells: Vec<Vec<u32>> = (0..total).map(|_| Vec::new()).collect();
        let idx = |c: &Coll, i: i32, j: i32, k: i32| -> usize { ((k * c.dims[1] + j) * c.dims[0] + i) as usize };
        let mut c = Coll { stamp: vec![0; tris.len()], tris, nrm, lo, cell, dims, cells: Vec::new(), q: 0 };
        for (ti, t) in c.tris.iter().enumerate() {
            let (a, b) = c.range(t);
            for k in a[2]..=b[2] {
                for j in a[1]..=b[1] {
                    for i in a[0]..=b[0] {
                        cells[idx(&c, i, j, k)].push(ti as u32);
                    }
                }
            }
        }
        c.cells = cells;
        c
    }
    fn cell_of(&self, p: V3) -> [i32; 3] {
        let mut r = [0i32; 3];
        for k in 0..3 {
            r[k] = (((p[k] - self.lo[k]) / self.cell) as i32).clamp(0, self.dims[k] - 1);
        }
        r
    }
    fn range(&self, t: &[V3; 3]) -> ([i32; 3], [i32; 3]) {
        let mut lo = t[0];
        let mut hi = t[0];
        for p in &t[1..] {
            for k in 0..3 {
                lo[k] = lo[k].min(p[k]);
                hi[k] = hi[k].max(p[k]);
            }
        }
        (self.cell_of(lo), self.cell_of(hi))
    }
    fn query(&mut self, lo: V3, hi: V3, out: &mut Vec<u32>) {
        out.clear();
        self.q = self.q.wrapping_add(1);
        if self.q == 0 {
            self.stamp.fill(0);
            self.q = 1;
        }
        let a = self.cell_of(lo);
        let b = self.cell_of(hi);
        for k in a[2]..=b[2] {
            for j in a[1]..=b[1] {
                for i in a[0]..=b[0] {
                    let ci = ((k * self.dims[1] + j) * self.dims[0] + i) as usize;
                    for &t in &self.cells[ci] {
                        if self.stamp[t as usize] != self.q {
                            self.stamp[t as usize] = self.q;
                            out.push(t);
                        }
                    }
                }
            }
        }
    }
}

/// Closest point on triangle `abc` to `p` (Ericson, Real-Time Collision Detection).
fn closest_on_tri(p: V3, a: V3, b: V3, c: V3) -> V3 {
    let ab = sub(b, a);
    let ac = sub(c, a);
    let ap = sub(p, a);
    let d1 = dot(ab, ap);
    let d2 = dot(ac, ap);
    if d1 <= 0.0 && d2 <= 0.0 {
        return a;
    }
    let bp = sub(p, b);
    let d3 = dot(ab, bp);
    let d4 = dot(ac, bp);
    if d3 >= 0.0 && d4 <= d3 {
        return b;
    }
    let vc = d1 * d4 - d3 * d2;
    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
        let v = d1 / (d1 - d3);
        return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v];
    }
    let cp = sub(p, c);
    let d5 = dot(ab, cp);
    let d6 = dot(ac, cp);
    if d6 >= 0.0 && d5 <= d6 {
        return c;
    }
    let vb = d5 * d2 - d1 * d6;
    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
        let w = d2 / (d2 - d6);
        return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w];
    }
    let va = d3 * d6 - d5 * d4;
    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
        return [b[0] + (c[0] - b[0]) * w, b[1] + (c[1] - b[1]) * w, b[2] + (c[2] - b[2]) * w];
    }
    let denom = 1.0 / (va + vb + vc);
    let v = vb * denom;
    let w = vc * denom;
    [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]
}

// ── camera ───────────────────────────────────────────────────────────────────

struct Cam {
    cry: f32,
    sry: f32,
    crx: f32,
    srx: f32,
    /// Focal length in pixels; 0 = the window height (the host default).
    focal: f32,
    zdist: f32,
    t: V3,
    /// Screen centre override (`set_camera_center`); None = the window centre.
    center: Option<(f32, f32)>,
}

impl Cam {
    #[inline]
    fn depth(&self, p: V3) -> f32 {
        let wx = p[0] - self.t[0];
        let wy = p[1] - self.t[1];
        let wz = p[2] - self.t[2];
        let rz1 = wx * self.sry + wz * self.cry;
        wy * self.srx + rz1 * self.crx
    }
    /// (screen x, screen y, camera depth) with the view's centre and focal length.
    #[inline]
    fn project(&self, p: V3, cx: f32, cy: f32, focal: f32) -> (f32, f32, f32) {
        let wx = p[0] - self.t[0];
        let wy = p[1] - self.t[1];
        let wz = p[2] - self.t[2];
        let rx = wx * self.cry - wz * self.sry;
        let rz1 = wx * self.sry + wz * self.cry;
        let ry = wy * self.crx - rz1 * self.srx;
        let rz = wy * self.srx + rz1 * self.crx;
        let d = rz + self.zdist;
        (cx + focal * rx / d, cy + focal * ry / d, rz)
    }
}

// ── rasterizer ───────────────────────────────────────────────────────────────

/// A projected vertex: screen position, 1/w and the w-divided attributes.
#[derive(Clone, Copy, Default)]
struct A {
    x: f32,
    y: f32,
    iw: f32,
    uw: f32,
    vw: f32,
    wx: f32,
    wy: f32,
    wz: f32,
    /// Vertex colour (0..1): the lit colour of a shape, or the tint of a textured face.
    r: f32,
    g: f32,
    b: f32,
}

impl A {
    #[inline]
    fn lerp(&self, o: &A, t: f32) -> A {
        A {
            x: self.x + (o.x - self.x) * t,
            y: self.y + (o.y - self.y) * t,
            iw: self.iw + (o.iw - self.iw) * t,
            uw: self.uw + (o.uw - self.uw) * t,
            vw: self.vw + (o.vw - self.vw) * t,
            wx: self.wx + (o.wx - self.wx) * t,
            wy: self.wy + (o.wy - self.wy) * t,
            wz: self.wz + (o.wz - self.wz) * t,
            r: self.r + (o.r - self.r) * t,
            g: self.g + (o.g - self.g) * t,
            b: self.b + (o.b - self.b) * t,
        }
    }
}

#[derive(Clone, Copy, PartialEq)]
enum TexId {
    None,
    Glow,
    Soft,
    Solid,
    Id(u32),
}

#[derive(Clone, Copy)]
struct Params {
    tex: TexId,
    alpha: f32,
    blend: u8,
    zwrite: bool,
    ztest: bool,
    /// Point lights per 8-pixel block (`tm_draw_lit`), with this ambient.
    lit: bool,
    amb: f32,
    fog: bool,
    /// Projected see-through circle: [sx, sy, radius, depth threshold]; radius 0 = off.
    hole: [f32; 4],
    /// 2-D (`tm_sprite`): affine, no depth.
    sprite: bool,
}

struct Deferred {
    v: [A; 3],
    p: Params,
    /// Sort key: the nearest 1/w of the triangle (small = far = drawn first).
    key: f32,
}

#[inline]
fn light_at(lights: &[Light], amb: f32, p: V3) -> V3 {
    let mut l = [amb, amb, amb];
    for li in lights {
        let dx = p[0] - li.p[0];
        let dy = p[1] - li.p[1];
        let dz = p[2] - li.p[2];
        let d2 = dx * dx + dy * dy + dz * dz;
        let r2 = li.r * li.r;
        if d2 < r2 {
            let k = 1.0 - sqrt(d2 / r2);
            let e = k * k * li.i;
            l[0] += li.c[0] * e;
            l[1] += li.c[1] * e;
            l[2] += li.c[2] * e;
        }
    }
    l
}

/// Everything the span loop needs besides the triangle itself.
struct Env<'a> {
    buf: &'a mut [u32],
    bw: usize,
    bh: usize,
    zb: &'a mut [f32],
    tex: Option<&'a Tex>,
    lights: &'a [Light],
    fog_color: u32,
    fog_start: f32,
    fog_end: f32,
    zdist: f32,
}

const BLOCK: i32 = 8;
const UOFF: f32 = 4096.0;

/// Scanline-fill one screen-space triangle.
fn raster(env: &mut Env, v: &[A; 3], p: &Params) {
    let (bw, bh) = (env.bw as i32, env.bh as i32);
    if bw <= 0 || bh <= 0 {
        return;
    }
    for a in v {
        if !(a.x.is_finite() && a.y.is_finite() && a.iw.is_finite()) {
            return;
        }
    }
    // sort by y
    let mut s = [v[0], v[1], v[2]];
    if s[1].y < s[0].y {
        s.swap(0, 1);
    }
    if s[2].y < s[1].y {
        s.swap(1, 2);
    }
    if s[1].y < s[0].y {
        s.swap(0, 1);
    }
    let (t0, t1, t2) = (s[0], s[1], s[2]);
    if t2.y - t0.y < 1e-6 {
        return;
    }
    let y_start = (ceil(t0.y - 0.5) as i32).max(0);
    let y_end = (ceil(t2.y - 0.5) as i32).min(bh); // exclusive
    if y_start >= y_end {
        return;
    }
    let alpha8 = (p.alpha.clamp(0.0, 1.0) * 256.0) as u32;
    if alpha8 == 0 {
        return;
    }
    let fog_on = p.fog && env.fog_end > env.fog_start && env.fog_end > 0.0;
    let (fcr, fcg, fcb) = (((env.fog_color >> 16) & 255) as i32, ((env.fog_color >> 8) & 255) as i32, (env.fog_color & 255) as i32);
    let fog_inv = if fog_on { 1.0 / (env.fog_end - env.fog_start) } else { 0.0 };
    let hole_on = p.hole[2] > 0.0;
    let (tw, mask, tsh) = match env.tex {
        Some(t) => (t.w as f32, t.w - 1, t.sh),
        None => (1.0, 0, 0),
    };
    let texpx: &[u32] = env.tex.map(|t| &t.px[..]).unwrap_or(&[]);
    let zd = env.zdist;
    let ztest = p.ztest && !p.sprite;
    let zwrite = p.zwrite && !p.sprite;
    let blend = p.blend;
    let path: u8 = if alpha8 >= 256 && blend == 0 && !fog_on && !hole_on {
        if texpx.is_empty() {
            1
        } else {
            2
        }
    } else {
        0
    };

    for py in y_start..y_end {
        let fy = py as f32 + 0.5;
        // the long edge t0→t2 and the short edge for this row
        let tl = (fy - t0.y) / (t2.y - t0.y);
        let la = t0.lerp(&t2, tl);
        let sa = if fy < t1.y {
            let d = t1.y - t0.y;
            if d < 1e-9 {
                t1
            } else {
                t0.lerp(&t1, (fy - t0.y) / d)
            }
        } else {
            let d = t2.y - t1.y;
            if d < 1e-9 {
                t1
            } else {
                t1.lerp(&t2, (fy - t1.y) / d)
            }
        };
        let (l, r) = if la.x <= sa.x { (la, sa) } else { (sa, la) };
        let span = r.x - l.x;
        if span < 1e-6 {
            continue;
        }
        let x_start = (ceil(l.x - 0.5) as i32).max(0);
        let x_end = (ceil(r.x - 0.5) as i32).min(bw); // exclusive
        if x_start >= x_end {
            continue;
        }
        let inv_span = 1.0 / span;
        let row = py as usize * env.bw;
        // attribute at pixel centre x + 0.5: l + (x + 0.5 - l.x) * d
        let at = |x: i32| -> A { l.lerp(&r, ((x as f32 + 0.5) - l.x) * inv_span) };

        let mut x = x_start;
        let mut a0 = at(x);
        let mut inv0 = if a0.iw > 1e-12 { 1.0 / a0.iw } else { 1e12 };
        let mut l0 = if p.lit { light_at(env.lights, p.amb, [a0.wx * inv0, a0.wy * inv0, a0.wz * inv0]) } else { [1.0; 3] };
        while x < x_end {
            let bl = (x_end - x).min(BLOCK);
            let xe = x + bl;
            let a1 = at(xe);
            let inv1 = if a1.iw > 1e-12 { 1.0 / a1.iw } else { 1e12 };
            let l1 = if p.lit { light_at(env.lights, p.amb, [a1.wx * inv1, a1.wy * inv1, a1.wz * inv1]) } else { [1.0; 3] };
            let z0 = inv0 - zd;
            let z1 = inv1 - zd;
            let blf = bl as f32;
            // texel coordinates, 16.16 fixed, offset to stay positive (wrap is a power of two)
            let u0 = ((a0.uw * inv0 + UOFF) * tw * 65536.0) as i64;
            let u1 = ((a1.uw * inv1 + UOFF) * tw * 65536.0) as i64;
            let v0 = ((a0.vw * inv0 + UOFF) * tw * 65536.0) as i64;
            let v1 = ((a1.vw * inv1 + UOFF) * tw * 65536.0) as i64;
            let du = (u1 - u0) / bl as i64;
            let dv = (v1 - v0) / bl as i64;
            // colour x light, 16.16 (65536 = 1.0)
            let m0 = [(a0.r * l0[0] * 65536.0) as i32, (a0.g * l0[1] * 65536.0) as i32, (a0.b * l0[2] * 65536.0) as i32];
            let m1 = [(a1.r * l1[0] * 65536.0) as i32, (a1.g * l1[1] * 65536.0) as i32, (a1.b * l1[2] * 65536.0) as i32];
            let dm = [(m1[0] - m0[0]) / bl, (m1[1] - m0[1]) / bl, (m1[2] - m0[2]) / bl];
            let (mut mr, mut mg, mut mb) = (m0[0], m0[1], m0[2]);
            // fog factor, 16.16
            let (mut ff, dff) = if fog_on {
                let f0 = (((z0 - env.fog_start) * fog_inv).clamp(0.0, 1.0) * 65536.0) as i32;
                let f1 = (((z1 - env.fog_start) * fog_inv).clamp(0.0, 1.0) * 65536.0) as i32;
                (f0, (f1 - f0) / bl)
            } else {
                (0, 0)
            };
            let mut iw = a0.iw;
            let diw = (a1.iw - a0.iw) / blf;
            let dz = (z1 - z0) / blf;
            let mut z = z0;
            let (mut u, mut vv) = (u0, v0);
            match path {
                // opaque, untextured, no fog / hole: shapes
                1 => {
                    for px in x..xe {
                        let idx = row + px as usize;
                        if !ztest || iw > env.zb[idx] {
                            let sr = ((mr >> 8).clamp(0, 256) * 255) >> 8;
                            let sg = ((mg >> 8).clamp(0, 256) * 255) >> 8;
                            let sb = ((mb >> 8).clamp(0, 256) * 255) >> 8;
                            env.buf[idx] = ((sr as u32) << 16) | ((sg as u32) << 8) | sb as u32;
                            if zwrite {
                                env.zb[idx] = iw;
                            }
                        }
                        iw += diw;
                        mr += dm[0];
                        mg += dm[1];
                        mb += dm[2];
                    }
                },
                // opaque textured, no fog / hole: meshes and cut-out textures
                2 => {
                    for px in x..xe {
                        let idx = row + px as usize;
                        if !ztest || iw > env.zb[idx] {
                            let t = texpx[((((vv >> 16) as u32) & mask) << tsh | (((u >> 16) as u32) & mask)) as usize];
                            let a = t >> 24;
                            if a > 0 {
                                let sr = ((((t >> 16) & 255) as i32 * (mr >> 8)) >> 8).clamp(0, 255);
                                let sg = ((((t >> 8) & 255) as i32 * (mg >> 8)) >> 8).clamp(0, 255);
                                let sb = (((t & 255) as i32 * (mb >> 8)) >> 8).clamp(0, 255);
                                if a >= 255 {
                                    env.buf[idx] = ((sr as u32) << 16) | ((sg as u32) << 8) | sb as u32;
                                    if zwrite {
                                        env.zb[idx] = iw;
                                    }
                                } else {
                                    let d = env.buf[idx];
                                    let (dr, dg, db) = (((d >> 16) & 255) as i32, ((d >> 8) & 255) as i32, (d & 255) as i32);
                                    let a = a as i32;
                                    let r = dr + (((sr - dr) * a) >> 8);
                                    let g = dg + (((sg - dg) * a) >> 8);
                                    let b = db + (((sb - db) * a) >> 8);
                                    env.buf[idx] = ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
                                    if zwrite && a > 247 {
                                        env.zb[idx] = iw;
                                    }
                                }
                            }
                        }
                        iw += diw;
                        u += du;
                        vv += dv;
                        mr += dm[0];
                        mg += dm[1];
                        mb += dm[2];
                    }
                },
                _ => {
                    for px in x..xe {
                        let idx = row + px as usize;
                        if !ztest || iw > env.zb[idx] {
                            let t = if texpx.is_empty() { 0xFFFF_FFFFu32 } else { texpx[((((vv >> 16) as u32) & mask) << tsh | (((u >> 16) as u32) & mask)) as usize] };
                            let mut a = ((t >> 24) * alpha8) >> 8;
                            if hole_on && a > 0 && z < p.hole[3] {
                                let dx = px as f32 + 0.5 - p.hole[0];
                                let dy = py as f32 + 0.5 - p.hole[1];
                                let d = sqrt(dx * dx + dy * dy);
                                let k = ((d - p.hole[2] * 0.55) / (p.hole[2] * 0.45)).clamp(0.0, 1.0);
                                let k = k * k * (3.0 - 2.0 * k);
                                a = (a as f32 * (0.12 + 0.88 * k)) as u32;
                            }
                            if a > 0 {
                                let mut sr = ((((t >> 16) & 255) as i32 * (mr >> 8)) >> 8).clamp(0, 255);
                                let mut sg = ((((t >> 8) & 255) as i32 * (mg >> 8)) >> 8).clamp(0, 255);
                                let mut sb = (((t & 255) as i32 * (mb >> 8)) >> 8).clamp(0, 255);
                                if fog_on {
                                    sr += ((fcr - sr) * (ff >> 8)) >> 8;
                                    sg += ((fcg - sg) * (ff >> 8)) >> 8;
                                    sb += ((fcb - sb) * (ff >> 8)) >> 8;
                                }
                                if a >= 255 && blend == 0 {
                                    env.buf[idx] = ((sr as u32) << 16) | ((sg as u32) << 8) | sb as u32;
                                } else {
                                    let d = env.buf[idx];
                                    let (dr, dg, db) = (((d >> 16) & 255) as i32, ((d >> 8) & 255) as i32, (d & 255) as i32);
                                    let a = a.min(255) as i32;
                                    let (r, g, b) = if blend == 1 {
                                        ((dr + ((sr * a) >> 8)).min(255), (dg + ((sg * a) >> 8)).min(255), (db + ((sb * a) >> 8)).min(255))
                                    } else {
                                        (dr + (((sr - dr) * a) >> 8), dg + (((sg - dg) * a) >> 8), db + (((sb - db) * a) >> 8))
                                    };
                                    env.buf[idx] = ((r as u32) << 16) | ((g as u32) << 8) | b as u32;
                                }
                                if zwrite && a > 247 {
                                    env.zb[idx] = iw;
                                }
                            }
                        }
                        iw += diw;
                        z += dz;
                        u += du;
                        vv += dv;
                        mr += dm[0];
                        mg += dm[1];
                        mb += dm[2];
                        ff += dff;
                    }
                },
            }
            x = xe;
            a0 = a1;
            inv0 = inv1;
            l0 = l1;
        }
    }
}

/// A 2-D line in the window (wireframe shapes); no depth.
fn line2d(buf: &mut [u32], bw: usize, bh: usize, x0: f32, y0: f32, x1: f32, y1: f32, c: u32) {
    if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite()) {
        return;
    }
    let dx = x1 - x0;
    let dy = y1 - y0;
    let steps = (if dx.abs() > dy.abs() { dx.abs() } else { dy.abs() }).min(4096.0) as i32 + 1;
    for i in 0..=steps {
        let t = i as f32 / steps as f32;
        let x = (x0 + dx * t) as i32;
        let y = (y0 + dy * t) as i32;
        if x >= 0 && y >= 0 && (x as usize) < bw && (y as usize) < bh {
            buf[y as usize * bw + x as usize] = c;
        }
    }
}

// ── primitive shapes ─────────────────────────────────────────────────────────

struct Base {
    verts: Vec<V3>,
    nrm: Vec<V3>,
    tris: Vec<[u32; 3]>,
    edges: Vec<[u32; 2]>,
}

impl Base {
    fn finish(mut self) -> Base {
        // area-weighted smooth normals
        let mut nn = vec![[0.0f32; 3]; self.verts.len()];
        for t in &self.tris {
            let a = self.verts[t[0] as usize];
            let b = self.verts[t[1] as usize];
            let c = self.verts[t[2] as usize];
            let fnr = cross(sub(b, a), sub(c, a));
            for &i in t {
                let v = &mut nn[i as usize];
                v[0] += fnr[0];
                v[1] += fnr[1];
                v[2] += fnr[2];
            }
        }
        self.nrm = nn.into_iter().map(norm).collect();
        if self.edges.is_empty() {
            let mut seen: alloc::collections::BTreeSet<(u32, u32)> = alloc::collections::BTreeSet::new();
            for t in &self.tris {
                for &(a, b) in &[(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] {
                    let k = if a < b { (a, b) } else { (b, a) };
                    if seen.insert(k) {
                        self.edges.push([k.0, k.1]);
                    }
                }
            }
        }
        self
    }
}

fn base_cube() -> Base {
    let mut verts = Vec::new();
    for i in 0..8 {
        verts.push([if i & 1 != 0 { 1.0 } else { -1.0 }, if i & 2 != 0 { 1.0 } else { -1.0 }, if i & 4 != 0 { 1.0 } else { -1.0 }]);
    }
    // faces as index quads (outward winding is not needed: shapes draw uncullled)
    let faces: [[u32; 4]; 6] = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
    let mut tris = Vec::new();
    let mut edges = Vec::new();
    for q in &faces {
        tris.push([q[0], q[1], q[2]]);
        tris.push([q[0], q[2], q[3]]);
        for k in 0..4 {
            let (a, b) = (q[k], q[(k + 1) % 4]);
            let e = if a < b { [a, b] } else { [b, a] };
            if !edges.contains(&e) {
                edges.push(e);
            }
        }
    }
    Base { verts, nrm: Vec::new(), tris, edges }.finish()
}

fn base_sphere(seg: i32, rings: i32) -> Base {
    let seg = seg.clamp(3, 96);
    let rings = rings.clamp(2, 64);
    let mut verts = Vec::new();
    let mut tris = Vec::new();
    for r in 0..=rings {
        let phi = PI * r as f32 / rings as f32;
        let (y, rad) = (cos(phi), sin(phi));
        for s in 0..seg {
            let th = TAU * s as f32 / seg as f32;
            verts.push([rad * cos(th), y, rad * sin(th)]);
        }
    }
    for r in 0..rings {
        for s in 0..seg {
            let a = (r * seg + s) as u32;
            let b = (r * seg + (s + 1) % seg) as u32;
            let c = ((r + 1) * seg + s) as u32;
            let d = ((r + 1) * seg + (s + 1) % seg) as u32;
            if r > 0 {
                tris.push([a, b, c]);
            }
            if r < rings - 1 {
                tris.push([b, d, c]);
            }
        }
    }
    Base { verts, nrm: Vec::new(), tris, edges: Vec::new() }.finish()
}

fn base_capsule(seg: i32, rings: i32) -> Base {
    // a sphere whose upper hemisphere is lifted by 1 and lower lowered by 1 (height 2 cylinder + caps),
    // normalised afterwards to span [-1, 1] in y
    let mut b = base_sphere(seg, rings * 2);
    for v in &mut b.verts {
        v[1] = if v[1] > 0.0 { (v[1] + 1.0) * 0.5 } else { (v[1] - 1.0) * 0.5 };
        v[0] *= 0.5;
        v[2] *= 0.5;
    }
    Base { verts: b.verts, nrm: Vec::new(), tris: b.tris, edges: Vec::new() }.finish()
}

fn base_tube(seg: i32, top_r: f32) -> Base {
    let seg = seg.clamp(3, 96);
    let mut verts = Vec::new();
    let mut tris = Vec::new();
    for s in 0..seg {
        let th = TAU * s as f32 / seg as f32;
        verts.push([cos(th), 1.0, sin(th)]); // bottom ring (y down)
    }
    for s in 0..seg {
        let th = TAU * s as f32 / seg as f32;
        verts.push([top_r * cos(th), -1.0, top_r * sin(th)]); // top ring
    }
    let cb = verts.len() as u32;
    verts.push([0.0, 1.0, 0.0]);
    let ct = verts.len() as u32;
    verts.push([0.0, -1.0, 0.0]);
    for s in 0..seg {
        let a = s as u32;
        let b = ((s + 1) % seg) as u32;
        let c = seg as u32 + a;
        let d = seg as u32 + b;
        tris.push([a, b, c]);
        if top_r > 1e-4 {
            tris.push([b, d, c]);
        }
        tris.push([a, cb, b]);
        if top_r > 1e-4 {
            tris.push([c, d, ct]);
        }
    }
    Base { verts, nrm: Vec::new(), tris, edges: Vec::new() }.finish()
}

fn base_torus(seg: i32, sides: i32, tube: f32) -> Base {
    let seg = seg.clamp(3, 96);
    let sides = sides.clamp(3, 48);
    let mut verts = Vec::new();
    let mut tris = Vec::new();
    for i in 0..seg {
        let th = TAU * i as f32 / seg as f32;
        let (c, s) = (cos(th), sin(th));
        for j in 0..sides {
            let ph = TAU * j as f32 / sides as f32;
            let r = 1.0 - tube + tube * cos(ph);
            verts.push([r * c, tube * sin(ph), r * s]);
        }
    }
    for i in 0..seg {
        for j in 0..sides {
            let a = (i * sides + j) as u32;
            let b = (i * sides + (j + 1) % sides) as u32;
            let c = (((i + 1) % seg) * sides + j) as u32;
            let d = (((i + 1) % seg) * sides + (j + 1) % sides) as u32;
            tris.push([a, b, c]);
            tris.push([b, d, c]);
        }
    }
    Base { verts, nrm: Vec::new(), tris, edges: Vec::new() }.finish()
}

fn cel_ramp(d: f32) -> f32 {
    let lo = 0.30;
    let hi = 0.55;
    if d < lo {
        0.20
    } else if d > hi {
        1.0
    } else {
        0.20 + 0.80 * (d - lo) / (hi - lo)
    }
}

fn posterize(c: V3, bands: u32) -> V3 {
    let bands = bands.max(2) as f32;
    let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2];
    if lum < 1e-5 {
        return c;
    }
    let band = floor(lum * bands).min(bands - 1.0);
    let q = (band + 0.5) / bands;
    let k = (q / lum).clamp(0.0, 4.0);
    [(c[0] * k).min(1.0), (c[1] * k).min(1.0), (c[2] * k).min(1.0)]
}

// ── state ────────────────────────────────────────────────────────────────────

struct State {
    cam: Cam,
    lights: Vec<Light>,
    ambient: f32,
    fog_color: u32,
    fog_start: f32,
    fog_end: f32,
    shade_mode: u8,
    rim: f32,
    rim_color: V3,
    bands: u32,
    blend: u8,
    depth_test: bool,
    zbuf: Vec<f32>,
    zw: usize,
    zh: usize,
    zclear: bool,
    texs: Vec<Tex>,
    glow: Tex,
    soft: Tex,
    solid: Tex,
    meshes: Vec<Mesh>,
    colls: Vec<Coll>,
    parts: Vec<Part>,
    max_parts: usize,
    hole: [f32; 4],
    deferred: Vec<Deferred>,
    bases: BTreeMap<(u8, i32, i32), Base>,
    rng: u32,
    scratch: Vec<u32>,
    pub tris: usize,
    /// Render scale: 1 = the window, 0.5 = a quarter of the pixels, upscaled at flush.
    scale: f32,
    auto_scale: bool,
    rbuf: Vec<u32>,
    rw: usize,
    rh: usize,
    frame_open: bool,
    win_w: usize,
    win_h: usize,
    /// Microseconds spent rasterizing / post-processing / upscaling this frame.
    raster_us: u64,
    slow_frames: u32,
    fast_frames: u32,
    /// Toon post: ink outline half-width in pixels (0 = off), colour, depth threshold.
    toon_px: f32,
    toon_color: u32,
    toon_thresh: f32,
    /// The host's 3-band tone ramp as a brightness-scale table (8.8) per luminance.
    ramp_lut: [u16; 256],
    ramp_on: bool,
    drew_3d: bool,
    post_done: bool,
}

static mut G: Option<State> = None;

fn st() -> &'static mut State {
    let g = unsafe { &mut *(&raw mut G) };
    if g.is_none() {
        *g = Some(State {
            cam: Cam { cry: 1.0, sry: 0.0, crx: 1.0, srx: 0.0, focal: 0.0, zdist: 5.0, t: [0.0; 3], center: None },
            lights: Vec::new(),
            ambient: 0.15,
            fog_color: 0,
            fog_start: 0.0,
            fog_end: 0.0,
            shade_mode: 1,
            rim: 0.6,
            rim_color: [115.0 / 255.0, 217.0 / 255.0, 1.0],
            bands: 4,
            blend: 0,
            depth_test: true,
            zbuf: Vec::new(),
            zw: 0,
            zh: 0,
            zclear: true,
            texs: Vec::new(),
            glow: glow_tex(),
            soft: soft_tex(),
            solid: Tex::new(8, vec![0xFFFF_FFFF; 64]),
            meshes: Vec::new(),
            colls: Vec::new(),
            parts: Vec::new(),
            max_parts: 6000,
            hole: [0.0; 4],
            deferred: Vec::new(),
            bases: BTreeMap::new(),
            rng: 0x9E37_79B9,
            scratch: Vec::new(),
            tris: 0,
            scale: 1.0,
            auto_scale: true,
            rbuf: Vec::new(),
            rw: 0,
            rh: 0,
            frame_open: false,
            win_w: 0,
            win_h: 0,
            raster_us: 0,
            slow_frames: 0,
            fast_frames: 0,
            toon_px: 0.0,
            toon_color: 0,
            toon_thresh: 0.05,
            ramp_lut: build_ramp_lut(),
            ramp_on: true,
            drew_3d: false,
            post_done: false,
        });
    }
    g.as_mut().unwrap()
}

/// Triangles rasterized since the last `take_stats` (for the frame-timing overlay).
pub fn take_stats() -> usize {
    let s = st();
    let n = s.tris;
    s.tris = 0;
    n
}

/// " 3d x0.5" while rendering below the window's resolution, "" otherwise.
pub fn scale_note() -> String {
    let s = st();
    if s.scale < 0.999 {
        alloc::format!(" 3d x{:.2}", s.scale)
    } else {
        String::new()
    }
}

// ── toon post: the host's tone ramp (gfx/toon.rs) as a table ─────────────────

fn smoothstep01(f: f32) -> f32 {
    let f = f.clamp(0.0, 1.0);
    f * f * (3.0 - 2.0 * f)
}

/// The default 3-band cel ramp: shadow 0.08 below 0.25, mid 0.5 below 0.6, lit 1.0;
/// soft 0.32 band edges, sheen 0.65 above 0.72.
fn sample_ramp(t: f32) -> f32 {
    const STOPS: [(f32, f32); 4] = [(0.0, 0.08), (0.25, 0.50), (0.60, 1.00), (1.0, 1.0)];
    const SOFT: f32 = 0.32;
    let t = t.clamp(0.0, 1.0);
    if t <= STOPS[0].0 {
        return STOPS[0].1;
    }
    let last = STOPS.len() - 1;
    if t >= STOPS[last].0 {
        return STOPS[last].1;
    }
    for i in 0..last {
        if t < STOPS[i + 1].0 {
            let v = STOPS[i].1;
            let gap_u = STOPS[i + 1].0 - STOPS[i].0;
            let gap_u2 = if i + 2 <= last { STOPS[i + 2].0 - STOPS[i + 1].0 } else { gap_u };
            let hw_u = 0.5 * SOFT * gap_u.min(gap_u2);
            if hw_u > 1e-6 && t > STOPS[i + 1].0 - hw_u {
                let s = smoothstep01((t - (STOPS[i + 1].0 - hw_u)) / (2.0 * hw_u));
                return v + s * (STOPS[i + 1].1 - v);
            }
            if i > 0 {
                let gap_l = STOPS[i].0 - STOPS[i - 1].0;
                let hw_l = 0.5 * SOFT * gap_l.min(gap_u);
                if hw_l > 1e-6 && t < STOPS[i].0 + hw_l {
                    let s = smoothstep01((t - (STOPS[i].0 - hw_l)) / (2.0 * hw_l));
                    return STOPS[i - 1].1 + s * (v - STOPS[i - 1].1);
                }
            }
            return v;
        }
    }
    STOPS[last].1
}

fn build_ramp_lut() -> [u16; 256] {
    let mut lut = [256u16; 256];
    for (lum, e) in lut.iter_mut().enumerate() {
        let l = lum as f32;
        if l < 1.5 {
            continue;
        }
        let t = l / 255.0;
        let mut v = sample_ramp(t);
        let k = smoothstep01((t - 0.72) / 0.20) * 0.65;
        v += (t - v) * k;
        let maxs = if l < 14.0 { 2.2 } else { 4.0 };
        let scale = (v * 255.0 / l).clamp(0.0, maxs);
        *e = (scale * 256.0) as u16;
    }
    lut
}

/// Luminance-preserving-hue tone ramp over the whole buffer (the host applies it every frame).
fn apply_ramp(buf: &mut [u32], lut: &[u16; 256]) {
    for p in buf.iter_mut() {
        let c = *p;
        let (r, g, b) = ((c >> 16) & 255, (c >> 8) & 255, c & 255);
        let lum = ((77 * r + 150 * g + 29 * b) >> 8) as usize;
        let s = lut[lum.min(255)] as u32;
        if s == 256 {
            continue;
        }
        let r2 = ((r * s) >> 8).min(255);
        let g2 = ((g * s) >> 8).min(255);
        let b2 = ((b * s) >> 8).min(255);
        *p = (r2 << 16) | (g2 << 8) | b2;
    }
}

/// Ink where the depth buffer (1/w) jumps: silhouettes and creases. `thresh` is in
/// camera depth units like the host's; a neighbour's depth difference is taken from
/// the 1/w difference scaled by w^2 (one divide per pixel).
#[allow(clippy::too_many_arguments)]
fn draw_outlines(buf: &mut [u32], zb: &[f32], w: usize, h: usize, thick: f32, color: u32, thresh: f32) {
    if w < 3 || h < 3 || zb.len() < w * h || buf.len() < w * h {
        return;
    }
    let t = thick.clamp(0.5, 6.0);
    let ti = ceil(t) as i32;
    let t2 = t * t;
    // coverage of the stamp per offset
    let kw = (2 * ti + 1) as usize;
    let mut cov = vec![0u8; kw * kw];
    for dy in -ti..=ti {
        for dx in -ti..=ti {
            let d2 = (dx * dx + dy * dy) as f32;
            if d2 <= t2 {
                let c = (sqrt(t2 - d2) / if t > 1.0 { t } else { 1.0 }).clamp(0.0, 1.0);
                cov[((dy + ti) as usize) * kw + (dx + ti) as usize] = (c * 255.0) as u8;
            }
        }
    }
    let (ir, ig, ib) = ((color >> 16) & 255, (color >> 8) & 255, color & 255);
    for y in 1..h - 1 {
        for x in 1..w - 1 {
            let idx = y * w + x;
            let iw = zb[idx];
            if iw <= 0.0 {
                continue;
            }
            let inv = 1.0 / iw;
            let k = inv * inv;
            let d = |j: usize| (zb[j] - iw).abs() * k;
            let dmax = d(idx - w).max(d(idx + w)).max(d(idx - 1)).max(d(idx + 1));
            if dmax < thresh {
                continue;
            }
            for dy in -ti..=ti {
                let ny = y as i32 + dy;
                if ny < 0 || ny >= h as i32 {
                    continue;
                }
                for dx in -ti..=ti {
                    let c = cov[((dy + ti) as usize) * kw + (dx + ti) as usize] as u32;
                    if c == 0 {
                        continue;
                    }
                    let nx = x as i32 + dx;
                    if nx < 0 || nx >= w as i32 {
                        continue;
                    }
                    let ni = ny as usize * w + nx as usize;
                    if c >= 255 {
                        buf[ni] = color;
                    } else {
                        let dst = buf[ni];
                        let (dr, dg, db) = ((dst >> 16) & 255, (dst >> 8) & 255, dst & 255);
                        let r = (ir * c + dr * (255 - c)) / 255;
                        let g = (ig * c + dg * (255 - c)) / 255;
                        let b = (ib * c + db * (255 - c)) / 255;
                        buf[ni] = (r << 16) | (g << 8) | b;
                    }
                }
            }
        }
    }
}

#[inline]
fn avg2(p: u32, q: u32) -> u32 {
    (((p ^ q) & 0x00FE_FEFE) >> 1) + (p & q)
}

/// The wireframe primitives of the host's `tm_wire`: unit-size vertices and edges.
fn wire_shape(kind: i32, detail: usize) -> (Vec<V3>, Vec<(usize, usize)>) {
    let mut v: Vec<V3> = Vec::new();
    let mut e: Vec<(usize, usize)> = Vec::new();
    fn by_length(v: &[V3], e: &mut Vec<(usize, usize)>) {
        let d2 = |a: V3, b: V3| dot(sub(a, b), sub(a, b));
        let mut best = f32::MAX;
        for i in 0..v.len() {
            for j in i + 1..v.len() {
                best = best.min(d2(v[i], v[j]));
            }
        }
        for i in 0..v.len() {
            for j in i + 1..v.len() {
                if d2(v[i], v[j]) < best * 1.01 {
                    e.push((i, j));
                }
            }
        }
    }
    match kind {
        1 => {
            let k = 1.0 / sqrt(3.0);
            v = vec![[k, k, k], [k, -k, -k], [-k, k, -k], [-k, -k, k]];
            by_length(&v, &mut e);
        },
        2 => {
            v = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0]];
            by_length(&v, &mut e);
        },
        3 => {
            let g = (1.0 + sqrt(5.0)) / 2.0;
            let nn = sqrt(1.0 + g * g);
            for &(a, b) in &[(1.0f32, g), (-1.0, g), (1.0, -g), (-1.0, -g)] {
                v.push([0.0, a / nn, b / nn]);
                v.push([a / nn, b / nn, 0.0]);
                v.push([b / nn, 0.0, a / nn]);
            }
            by_length(&v, &mut e);
        },
        4 => {
            let (maj, min) = (detail, (detail / 2).max(4));
            for i in 0..maj {
                let a = i as f32 / maj as f32 * TAU;
                for j in 0..min {
                    let b = j as f32 / min as f32 * TAU;
                    let r = 0.72 + 0.28 * cos(b);
                    v.push([r * cos(a), 0.28 * sin(b), r * sin(a)]);
                    let here = i * min + j;
                    e.push((here, i * min + (j + 1) % min));
                    e.push((here, ((i + 1) % maj) * min + j));
                }
            }
        },
        5 => {
            let (rings, segs) = ((detail / 2).max(3), detail);
            for i in 1..rings {
                let p = i as f32 / rings as f32 * PI;
                for j in 0..segs {
                    let a = j as f32 / segs as f32 * TAU;
                    v.push([sin(p) * cos(a), cos(p), sin(p) * sin(a)]);
                    let here = (i - 1) * segs + j;
                    e.push((here, (i - 1) * segs + (j + 1) % segs));
                    if i + 1 < rings {
                        e.push((here, i * segs + j));
                    }
                }
            }
        },
        6 => {
            for j in 0..detail {
                let a = j as f32 / detail as f32 * TAU;
                v.push([cos(a), sin(a), 0.0]);
                e.push((j, (j + 1) % detail));
            }
        },
        7 => {
            for layer in 0..2 {
                let z = if layer == 0 { -0.25 } else { 0.25 };
                for j in 0..10 {
                    let a = j as f32 / 10.0 * TAU - PI * 0.5;
                    let r = if j % 2 == 0 { 1.0 } else { 0.42 };
                    v.push([r * cos(a), r * sin(a), z]);
                    e.push((layer * 10 + j, layer * 10 + (j + 1) % 10));
                }
            }
            for j in 0..10 {
                e.push((j, 10 + j));
            }
        },
        8 => {
            let n = detail;
            for i in 0..=n {
                let t = i as f32 / n as f32 * 2.0 - 1.0;
                v.push([t, 0.0, -1.0]);
                v.push([t, 0.0, 1.0]);
                v.push([-1.0, 0.0, t]);
                v.push([1.0, 0.0, t]);
                let k = i * 4;
                e.push((k, k + 1));
                e.push((k + 2, k + 3));
            }
        },
        _ => {
            let k = 1.0 / sqrt(3.0);
            for &x in &[-1.0f32, 1.0] {
                for &y in &[-1.0f32, 1.0] {
                    for &z in &[-1.0f32, 1.0] {
                        v.push([x * k, y * k, z * k]);
                    }
                }
            }
            by_length(&v, &mut e);
        },
    }
    (v, e)
}

impl State {
    fn rand(&mut self) -> f32 {
        let mut x = self.rng;
        x ^= x << 13;
        x ^= x >> 17;
        x ^= x << 5;
        self.rng = x;
        (x >> 8) as f32 / 16_777_216.0
    }

    /// The window's size (None without a window), remembered for the render target.
    fn win_dims(&mut self) -> Option<(usize, usize)> {
        let (_, w, h) = crate::lingapp::surface()?;
        self.win_w = w;
        self.win_h = h;
        Some((w, h))
    }

    /// The render target's size: the window's scaled by the render scale.
    fn tdims(&self) -> (usize, usize) {
        if self.scale < 0.999 {
            (((self.win_w as f32 * self.scale) as usize).max(1), ((self.win_h as f32 * self.scale) as usize).max(1))
        } else {
            (self.win_w, self.win_h)
        }
    }

    /// The view's centre and focal length in render-target pixels.
    fn view(&self) -> (f32, f32, f32) {
        let (tw, th) = self.tdims();
        let sc = if self.scale < 0.999 { self.scale } else { 1.0 };
        let (cx, cy) = match self.cam.center {
            Some((x, y)) => (x * sc, y * sc),
            None => (tw as f32 * 0.5, th as f32 * 0.5),
        };
        let focal = if self.cam.focal > 0.0 { self.cam.focal * sc } else { th as f32 };
        (cx, cy, focal)
    }

    /// Where 3-D pixels go: the scaled buffer (opened from the window's current
    /// contents) below scale 1, else the window itself.
    fn target(&mut self) -> Option<(*mut u32, usize, usize)> {
        self.win_dims()?;
        if self.scale < 0.999 {
            self.open_frame();
            Some((self.rbuf.as_mut_ptr(), self.rw, self.rh))
        } else {
            let (buf, w, h) = crate::lingapp::surface()?;
            Some((buf.as_mut_ptr(), w, h))
        }
    }

    /// Start a scaled frame: the window's pixels, decimated, become the backdrop.
    fn open_frame(&mut self) {
        if self.frame_open {
            return;
        }
        let Some((win, ww, wh)) = crate::lingapp::surface() else { return };
        let (rw, rh) = self.tdims();
        if self.rbuf.len() != rw * rh {
            self.rbuf = vec![0u32; rw * rh];
        }
        self.rw = rw;
        self.rh = rh;
        let step = ((ww << 16) / rw) as u32;
        for y in 0..rh {
            let sy = (y * wh / rh).min(wh - 1);
            let src = &win[sy * ww..sy * ww + ww];
            let row = &mut self.rbuf[y * rw..y * rw + rw];
            let mut fx = 0u32;
            for px in row.iter_mut() {
                *px = src[((fx >> 16) as usize).min(ww - 1)];
                fx += step;
            }
        }
        self.frame_open = true;
    }

    /// End a scaled frame: the scaled buffer goes back onto the window (a smooth
    /// 2x expansion at half scale, nearest otherwise).
    fn close_frame(&mut self) {
        if !self.frame_open {
            return;
        }
        self.frame_open = false;
        let Some((win, ww, wh)) = crate::lingapp::surface() else { return };
        let (rw, rh) = (self.rw, self.rh);
        if rw == 0 || rh == 0 || self.rbuf.len() < rw * rh {
            return;
        }
        let src = &self.rbuf;
        if ww + 1 >= 2 * rw && wh + 1 >= 2 * rh && ww <= 2 * rw && wh <= 2 * rh {
            for sy in 0..rh {
                let s = &src[sy * rw..sy * rw + rw];
                let ny = (sy + 1).min(rh - 1);
                let n = &src[ny * rw..ny * rw + rw];
                let y0 = 2 * sy;
                let y1 = (y0 + 1).min(wh - 1);
                for sx in 0..rw {
                    let a = s[sx];
                    let b = s[(sx + 1).min(rw - 1)];
                    let c = n[sx];
                    let d = n[(sx + 1).min(rw - 1)];
                    let x0 = 2 * sx;
                    let x1 = (x0 + 1).min(ww - 1);
                    let ab = avg2(a, b);
                    win[y0 * ww + x0] = a;
                    win[y0 * ww + x1] = ab;
                    win[y1 * ww + x0] = avg2(a, c);
                    win[y1 * ww + x1] = avg2(ab, avg2(c, d));
                }
            }
        } else {
            let step = ((rw << 16) / ww) as u32;
            for y in 0..wh {
                let sy = (y * rh / wh).min(rh - 1);
                let s = &src[sy * rw..sy * rw + rw];
                let row = &mut win[y * ww..y * ww + ww];
                let mut fx = 0u32;
                for px in row.iter_mut() {
                    *px = s[((fx >> 16) as usize).min(rw - 1)];
                    fx += step;
                }
            }
        }
    }

    /// The toon post pass over the render target, once per frame: tone ramp, then ink.
    fn post(&mut self) {
        if self.post_done || !self.drew_3d {
            return;
        }
        self.post_done = true;
        let t0 = crate::arch::timer::now_us();
        let Some((ptr, w, h)) = self.target() else { return };
        let buf = unsafe { core::slice::from_raw_parts_mut(ptr, w * h) };
        if self.ramp_on {
            apply_ramp(buf, &self.ramp_lut);
        }
        if self.toon_px > 0.0 && self.zw == w && self.zh == h {
            // the ink is specified in window pixels: thinner on a scaled target
            let px = if self.scale < 0.999 { self.toon_px * self.scale } else { self.toon_px };
            draw_outlines(buf, &self.zbuf, w, h, px, self.toon_color, self.toon_thresh);
        }
        self.raster_us += crate::arch::timer::now_us().saturating_sub(t0);
    }

    /// Pick the render scale for the next frame from this frame's pixel work
    /// (auto mode): below the window's resolution when the 3-D takes more than
    /// 14 ms, back to full when it takes less than 3 ms for a second.
    fn adapt(&mut self) {
        let t = self.raster_us;
        self.raster_us = 0;
        if !self.auto_scale || !self.drew_3d {
            return;
        }
        if t > 14_000 {
            self.slow_frames += 1;
            self.fast_frames = 0;
            if self.slow_frames >= 3 && self.scale > 0.5 {
                self.scale = 0.5;
                self.slow_frames = 0;
            }
        } else if t < 3_000 && self.scale < 1.0 {
            self.fast_frames += 1;
            self.slow_frames = 0;
            if self.fast_frames >= 60 {
                self.scale = 1.0;
                self.fast_frames = 0;
            }
        } else {
            self.slow_frames = 0;
            self.fast_frames = 0;
        }
    }

    fn ensure_z(&mut self, bw: usize, bh: usize) {
        if self.zw != bw || self.zh != bh {
            self.zbuf = vec![0.0; bw * bh];
            self.zw = bw;
            self.zh = bh;
            self.zclear = false;
        } else if self.zclear {
            self.zbuf.fill(0.0);
            self.zclear = false;
        }
    }

    /// Draw one screen-space triangle now (2-D sprites go to the window, 3-D to the target).
    fn draw_now(&mut self, v: &[A; 3], p: &Params) {
        let t0 = crate::arch::timer::now_us();
        let got = if p.sprite { crate::lingapp::surface().map(|(b, w, h)| (b.as_mut_ptr(), w, h)) } else { self.target() };
        let Some((ptr, bw, bh)) = got else { return };
        let buf = unsafe { core::slice::from_raw_parts_mut(ptr, bw * bh) };
        if !p.sprite {
            self.ensure_z(bw, bh);
            self.drew_3d = true;
        }
        self.tris += 1;
        let State { zbuf, texs, glow, soft, solid, lights, fog_color, fog_start, fog_end, cam, .. } = self;
        let tex = match p.tex {
            TexId::None => None,
            TexId::Glow => Some(&*glow),
            TexId::Soft => Some(&*soft),
            TexId::Solid => Some(&*solid),
            TexId::Id(i) => texs.get(i as usize),
        };
        if p.tex != TexId::None && tex.is_none() {
            return;
        }
        let mut env = Env { buf, bw, bh, zb: &mut zbuf[..], tex, lights: &lights[..], fog_color: *fog_color, fog_start: *fog_start, fog_end: *fog_end, zdist: cam.zdist };
        raster(&mut env, v, p);
        self.raster_us += crate::arch::timer::now_us().saturating_sub(t0);
    }

    /// Clip a world-space triangle against the near plane, project it and
    /// draw it (opaque) or keep it for `flush` (translucent). `col` is the
    /// per-vertex colour; `cull` 1 / 2 drops one screen winding.
    #[allow(clippy::too_many_arguments)]
    fn push_tri(&mut self, wp: [V3; 3], uv: [[f32; 2]; 3], col: [V3; 3], p: Params, cull: u8, bias: f32) -> usize {
        if self.win_dims().is_none() {
            return 0;
        }
        let near = -self.cam.zdist + 0.05;
        let d = [self.cam.depth(wp[0]), self.cam.depth(wp[1]), self.cam.depth(wp[2])];
        if d[0] <= near && d[1] <= near && d[2] <= near {
            return 0;
        }
        let mut cp: [(V3, [f32; 2], V3); 4] = [([0.0; 3], [0.0; 2], [0.0; 3]); 4];
        let mut cn = 0usize;
        for i in 0..3 {
            let j = (i + 1) % 3;
            let ain = d[i] > near;
            let bin = d[j] > near;
            if ain && cn < 4 {
                cp[cn] = (wp[i], uv[i], col[i]);
                cn += 1;
            }
            if ain != bin && cn < 4 {
                let t = (near - d[i]) / (d[j] - d[i]);
                cp[cn] = (
                    lerp3(wp[i], wp[j], t),
                    [uv[i][0] + (uv[j][0] - uv[i][0]) * t, uv[i][1] + (uv[j][1] - uv[i][1]) * t],
                    lerp3(col[i], col[j], t),
                );
                cn += 1;
            }
        }
        if cn < 3 {
            return 0;
        }
        let (cx, cy, focal) = self.view();
        let zd = self.cam.zdist;
        let mut tv = [A::default(); 4];
        for k in 0..cn {
            let (pp, t, c) = cp[k];
            let (sx, sy, z) = self.cam.project(pp, cx, cy, focal);
            let iw = 1.0 / (z - bias + zd).max(1e-4);
            tv[k] = A { x: sx, y: sy, iw, uw: t[0] * iw, vw: t[1] * iw, wx: pp[0] * iw, wy: pp[1] * iw, wz: pp[2] * iw, r: c[0], g: c[1], b: c[2] };
        }
        if cull != 0 {
            let area = (tv[1].x - tv[0].x) * (tv[2].y - tv[0].y) - (tv[1].y - tv[0].y) * (tv[2].x - tv[0].x);
            if (cull == 1 && area > 0.0) || (cull == 2 && area < 0.0) {
                return 0;
            }
        }
        let immediate = p.zwrite && p.alpha > 0.97 && p.blend == 0;
        let mut count = 0;
        for k in 1..cn - 1 {
            let tri = [tv[0], tv[k], tv[k + 1]];
            if immediate {
                self.draw_now(&tri, &p);
            } else {
                let key = tri[0].iw.max(tri[1].iw).max(tri[2].iw);
                self.deferred.push(Deferred { v: tri, p, key });
            }
            count += 1;
        }
        count
    }

    /// Draw the translucent triangles kept since the last flush, far to near, run
    /// the post pass and put a scaled frame back onto the window.
    fn flush(&mut self) {
        if !self.deferred.is_empty() {
            let mut list = core::mem::take(&mut self.deferred);
            list.sort_by(|a, b| a.key.partial_cmp(&b.key).unwrap_or(core::cmp::Ordering::Equal));
            for d in &list {
                self.draw_now(&d.v, &d.p);
            }
            list.clear();
            self.deferred = list;
        }
        self.post();
        let t0 = crate::arch::timer::now_us();
        self.close_frame();
        self.raster_us += crate::arch::timer::now_us().saturating_sub(t0);
    }

    /// `tm_wire(kind, x, y, z, size, rx, ry, rz, colour, detail, thick)`: a wireframe
    /// primitive (1 tetrahedron, 2 octahedron, 3 icosahedron, 4 torus, 5 sphere,
    /// 6 ring, 7 star prism, 8 grid, else cube), fogged lines, no depth.
    fn tm_wire(&mut self, args: &[u64]) {
        let kind = n(args, 0, 0.0) as i32;
        let c = [f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
        let size = f(args, 4, 1.0);
        let (rx, ry, rz) = (f(args, 5, 0.0), f(args, 6, 0.0), f(args, 7, 0.0));
        let col = rgb(n(args, 8, 16777215.0));
        let detail = (n(args, 9, 12.0) as usize).clamp(3, 64);
        let thick = n(args, 10, 0.0) > 0.5;
        let Some((ptr, bw, bh)) = self.target() else { return };
        let buf = unsafe { core::slice::from_raw_parts_mut(ptr, bw * bh) };
        let (verts, edges) = wire_shape(kind, detail);
        let (sx, cx) = (sin(rx), cos(rx));
        let (sy, cy) = (sin(ry), cos(ry));
        let (sz, cz) = (sin(rz), cos(rz));
        let world: Vec<V3> = verts
            .iter()
            .map(|v| {
                let (x, y, z) = (v[0], v[1] * cx - v[2] * sx, v[1] * sx + v[2] * cx);
                let (x, z) = (x * cy + z * sy, -x * sy + z * cy);
                let (x, y) = (x * cz - y * sz, x * sz + y * cz);
                [c[0] + x * size, c[1] + y * size, c[2] + z * size]
            })
            .collect();
        let (vcx, vcy, focal) = self.view();
        let near = -self.cam.zdist + 0.05;
        for (i, j) in edges {
            let (mut a, mut b) = (world[i], world[j]);
            let da = self.cam.depth(a);
            let db = self.cam.depth(b);
            if da <= near && db <= near {
                continue;
            }
            if da <= near {
                a = lerp3(a, b, (near - da) / (db - da));
            } else if db <= near {
                b = lerp3(a, b, (near - da) / (db - da));
            }
            let (ax, ay, az) = self.cam.project(a, vcx, vcy, focal);
            let (bx, by, bz) = self.cam.project(b, vcx, vcy, focal);
            if ax.abs() > 20000.0 || ay.abs() > 20000.0 || bx.abs() > 20000.0 || by.abs() > 20000.0 {
                continue;
            }
            let lc = self.fog_apply(col, (az + bz) * 0.5);
            line2d(buf, bw, bh, ax, ay, bx, by, lc);
            if thick {
                line2d(buf, bw, bh, ax + 1.0, ay, bx + 1.0, by, lc);
                line2d(buf, bw, bh, ax, ay + 1.0, bx, by + 1.0, lc);
            }
        }
        self.drew_3d = true;
    }

    fn fog_apply(&self, color: u32, depth: f32) -> u32 {
        if self.fog_end <= 0.0 || self.fog_end <= self.fog_start {
            return color;
        }
        let f = ((depth - self.fog_start) / (self.fog_end - self.fog_start)).clamp(0.0, 1.0);
        lerp_rgb(color, self.fog_color, f)
    }

    fn base(&mut self, kind: u8, e0: i32, e1: i32) -> &Base {
        let key = (kind, e0, e1);
        if !self.bases.contains_key(&key) {
            let b = match kind {
                0 => base_cube(),
                1 => base_sphere(if e0 > 0 { e0 } else { 20 }, if e1 > 0 { e1 } else { 10 }),
                2 => base_tube(if e0 > 0 { e0 } else { 20 }, 1.0),
                3 => base_tube(if e0 > 0 { e0 } else { 20 }, 0.0),
                4 => base_capsule(if e0 > 0 { e0 } else { 14 }, if e1 > 0 { e1 } else { 4 }),
                _ => base_torus(if e0 > 0 { e0 } else { 20 }, if e1 > 0 { e1 } else { 10 }, 0.3),
            };
            self.bases.insert(key, b);
        }
        self.bases.get(&key).unwrap()
    }

    /// The shape builtins: scale → rotate (X, Y, Z) → translate, cel-lit per
    /// vertex in the pen colour; mode 0 filled, 1 wireframe, 2 both.
    #[allow(clippy::too_many_arguments)]
    fn shape(&mut self, kind: u8, c: [f32; 9], mode: i32, e0: i32, e1: i32) {
        let color = col3(crate::lingapp::gfx_color());
        let (cx, cy, cz) = (c[0], c[1], c[2]);
        let (sx, sy, sz) = (c[3], c[4], c[5]);
        let (srx, crx) = (sin(c[6]), cos(c[6]));
        let (sry, cry) = (sin(c[7]), cos(c[7]));
        let (srz, crz) = (sin(c[8]), cos(c[8]));
        let rot = |x: f32, y: f32, z: f32| -> V3 {
            let (y1, z1) = (y * crx - z * srx, y * srx + z * crx);
            let (x2, z2) = (x * cry + z1 * sry, -x * sry + z1 * cry);
            let (x3, y3) = (x2 * crz - y1 * srz, x2 * srz + y1 * crz);
            [x3, y3, z2]
        };
        let (nverts, ntris, nedges) = {
            let b = self.base(kind, e0, e1);
            (b.verts.len(), b.tris.len(), b.edges.len())
        };
        let _ = (ntris, nedges);
        // transformed vertices and normals
        let mut wv: Vec<V3> = Vec::with_capacity(nverts);
        let mut wn: Vec<V3> = Vec::with_capacity(nverts);
        {
            let b = self.bases.get(&(kind, e0, e1)).unwrap();
            let inv = [if sx.abs() > 1e-6 { 1.0 / sx } else { 0.0 }, if sy.abs() > 1e-6 { 1.0 / sy } else { 0.0 }, if sz.abs() > 1e-6 { 1.0 / sz } else { 0.0 }];
            for (v, nr) in b.verts.iter().zip(b.nrm.iter()) {
                let p = rot(v[0] * sx, v[1] * sy, v[2] * sz);
                wv.push([p[0] + cx, p[1] + cy, p[2] + cz]);
                wn.push(norm(rot(nr[0] * inv[0], nr[1] * inv[1], nr[2] * inv[2])));
            }
        }
        let want_fill = mode == 0 || mode == 2;
        let want_wire = mode == 1 || mode == 2;
        if want_fill {
            // per-vertex cel lighting (ling-graphics shading::lit_vertex)
            let amb = self.ambient;
            let shadow = [0.10f32, 0.13, 0.30];
            let eye = self.cam.t;
            let rim = if self.shade_mode == 1 { self.rim * 0.4 } else { self.rim };
            let holo = self.shade_mode == 2;
            let mut lit: Vec<V3> = Vec::with_capacity(nverts);
            for (p, nr) in wv.iter().zip(wn.iter()) {
                let mut acc = [
                    color[0] * amb + shadow[0] * (1.0 - amb),
                    color[1] * amb + shadow[1] * (1.0 - amb),
                    color[2] * amb + shadow[2] * (1.0 - amb),
                ];
                if self.shade_mode != 0 {
                    for l in &self.lights {
                        let d = sub(l.p, *p);
                        let dist = sqrt(dot(d, d)).max(1e-6);
                        let atten = if l.r > 0.0 { (1.0 - dist / l.r).max(0.0) } else { 1.0 };
                        if atten <= 0.0 {
                            continue;
                        }
                        let ldir = [d[0] / dist, d[1] / dist, d[2] / dist];
                        let diff = dot(*nr, ldir).max(0.0);
                        let sh = cel_ramp(diff) * l.i * atten;
                        acc[0] += color[0] * sh * l.c[0];
                        acc[1] += color[1] * sh * l.c[1];
                        acc[2] += color[2] * sh * l.c[2];
                    }
                    if rim > 0.0 {
                        let vd = norm(sub(eye, *p));
                        let fr = (1.0 - dot(*nr, vd).max(0.0)).clamp(0.0, 1.0);
                        let k = fr * fr * fr * rim;
                        acc[0] += self.rim_color[0] * k;
                        acc[1] += self.rim_color[1] * k;
                        acc[2] += self.rim_color[2] * k;
                    }
                    if holo {
                        let s = 0.07;
                        acc[0] += (0.5 + 0.5 * nr[0]) * s * (0.4 + 0.6 * color[0]);
                        acc[1] += (0.5 + 0.5 * nr[1]) * s * (0.4 + 0.6 * color[1]);
                        acc[2] += (0.5 + 0.5 * nr[2]) * s * (0.4 + 0.6 * color[2]);
                    }
                } else {
                    // flat: a fixed key light from the camera's upper left
                    let d = norm([0.35, -0.8, -0.45]);
                    let l = dot(*nr, d).abs();
                    let k = 0.35 + 0.65 * l;
                    acc = [color[0] * k, color[1] * k, color[2] * k];
                }
                let c = [acc[0].min(1.0), acc[1].min(1.0), acc[2].min(1.0)];
                lit.push(if self.shade_mode != 0 { posterize(c, self.bands) } else { c });
            }
            let p = Params {
                tex: TexId::None,
                alpha: 1.0,
                blend: self.blend,
                zwrite: self.blend == 0,
                ztest: self.depth_test,
                lit: false,
                amb: 0.0,
                fog: self.fog_end > 0.0,
                hole: [0.0; 4],
                sprite: false,
            };
            let tris: Vec<[u32; 3]> = self.bases.get(&(kind, e0, e1)).unwrap().tris.clone();
            for t in &tris {
                let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
                self.push_tri([wv[a], wv[b], wv[c]], [[0.0; 2]; 3], [lit[a], lit[b], lit[c]], p, 0, 0.0);
            }
        }
        if want_wire {
            let Some((ptr, bw, bh)) = self.target() else { return };
            let buf = unsafe { core::slice::from_raw_parts_mut(ptr, bw * bh) };
            let (vcx, vcy, focal) = self.view();
            let near = -self.cam.zdist + 0.05;
            let col = pack3(color);
            let edges: Vec<[u32; 2]> = self.bases.get(&(kind, e0, e1)).unwrap().edges.clone();
            for e in &edges {
                let (a, b) = (wv[e[0] as usize], wv[e[1] as usize]);
                let (ax, ay, az) = self.cam.project(a, vcx, vcy, focal);
                let (bx, by, bz) = self.cam.project(b, vcx, vcy, focal);
                if az > near && bz > near {
                    line2d(buf, bw, bh, ax, ay, bx, by, col);
                }
            }
        }
    }

    /// `tm_draw(mesh, group, tex, x, y, z, scale, rot, tint, alpha, uoff, voff, uscale,
    ///          vscale, shade, blend, cull[, ambient])` → triangles queued
    fn tm_draw(&mut self, args: &[u64]) -> usize {
        let mi = n(args, 0, -1.0);
        let gi = n(args, 1, -1.0);
        let ti = n(args, 2, -1.0);
        if mi < 0.0 || mi as usize >= self.meshes.len() {
            return 0;
        }
        let tex = if ti < 0.0 {
            TexId::Glow
        } else if (ti as usize) < self.texs.len() {
            TexId::Id(ti as u32)
        } else {
            return 0;
        };
        let o = [f(args, 3, 0.0), f(args, 4, 0.0), f(args, 5, 0.0)];
        let sc = f(args, 6, 1.0);
        let m = rot_from(args, 7);
        let tint = rgb(n(args, 8, 16777215.0));
        let alpha = f(args, 9, 1.0).clamp(0.0, 1.0);
        let (uo, vo) = (f(args, 10, 0.0), f(args, 11, 0.0));
        let (us, vs) = (f(args, 12, 1.0), f(args, 13, 1.0));
        let shade = f(args, 14, 0.0).clamp(0.0, 1.0);
        let blend = n(args, 15, 0.0) as u8;
        let cull = n(args, 16, 0.0) as u8;
        let lit_amb = f(args, 17, -1.0);
        if self.win_dims().is_none() {
            return 0;
        }
        let hole = if self.hole[3] > 0.0 {
            let (cx, cy, focal) = self.view();
            let (hx, hy, hd) = self.cam.project([self.hole[0], self.hole[1], self.hole[2]], cx, cy, focal);
            let r = focal * self.hole[3] / (hd + self.cam.zdist).max(0.1);
            [hx, hy, r, hd - self.hole[3] * 0.2]
        } else {
            [0.0; 4]
        };
        let p = Params {
            tex,
            alpha,
            blend,
            zwrite: alpha > 0.97 && blend == 0,
            ztest: self.depth_test,
            lit: lit_amb >= 0.0,
            amb: lit_amb.max(0.0),
            fog: self.fog_end > 0.0,
            hole,
            sprite: false,
        };
        let light = norm([0.35, -0.8, -0.45]);
        let tint3 = col3(tint);
        let mesh = core::mem::replace(&mut self.meshes[mi as usize], Mesh { groups: Vec::new() });
        let mut count = 0;
        for (gidx, g) in mesh.groups.iter().enumerate() {
            if gi >= 0.0 && gidx != gi as usize {
                continue;
            }
            for t in &g.idx {
                let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
                let wp = [xf(&m, g.pos[a], sc, o), xf(&m, g.pos[b], sc, o), xf(&m, g.pos[c], sc, o)];
                let uv = [
                    [g.uv[a][0] * us + uo, g.uv[a][1] * vs + vo],
                    [g.uv[b][0] * us + uo, g.uv[b][1] * vs + vo],
                    [g.uv[c][0] * us + uo, g.uv[c][1] * vs + vo],
                ];
                let tt = if shade > 0.0 {
                    let fnrm = norm(cross(sub(wp[1], wp[0]), sub(wp[2], wp[0])));
                    let vn = rotn(&m, g.nrm[a]);
                    let nn = if dot(vn, vn) > 0.25 { norm(vn) } else { fnrm };
                    let l = dot(nn, light).abs();
                    let k = 1.0 - shade * (1.0 - (0.35 + 0.65 * l));
                    [tint3[0] * k, tint3[1] * k, tint3[2] * k]
                } else {
                    tint3
                };
                count += self.push_tri(wp, uv, [tt, tt, tt], p, cull, 0.0);
            }
        }
        self.meshes[mi as usize] = mesh;
        count
    }

    /// A camera-facing textured square at `p`, `size` world units wide.
    #[allow(clippy::too_many_arguments)]
    fn billboard(&mut self, tex: TexId, p: V3, size: f32, rot: f32, tint: u32, alpha: f32, blend: u8) {
        if self.win_dims().is_none() {
            return;
        }
        let (cx, cy, focal) = self.view();
        let near = -self.cam.zdist + 0.05;
        let (sx, sy, z) = self.cam.project(p, cx, cy, focal);
        if z <= near {
            return;
        }
        let w = z + self.cam.zdist;
        let r = focal * size * 0.5 / w.max(1e-3);
        if r < 0.3 || r > 4000.0 {
            return;
        }
        let tint = if blend == 1 {
            let fog_k = if self.fog_end > self.fog_start && self.fog_end > 0.0 { ((z - self.fog_start) / (self.fog_end - self.fog_start)).clamp(0.0, 1.0) } else { 0.0 };
            lerp_rgb(tint, 0, fog_k)
        } else {
            self.fog_apply(tint, z)
        };
        let c3 = col3(tint);
        let iw = 1.0 / w.max(1e-4);
        let (c, s) = (cos(rot), sin(rot));
        let corner = |dx: f32, dy: f32, u: f32, v: f32| A {
            x: sx + (dx * c - dy * s) * r,
            y: sy + (dx * s + dy * c) * r,
            iw,
            uw: u * iw,
            vw: v * iw,
            wx: 0.0,
            wy: 0.0,
            wz: 0.0,
            r: c3[0],
            g: c3[1],
            b: c3[2],
        };
        let q = [corner(-1.0, -1.0, 0.0, 0.0), corner(1.0, -1.0, 0.999, 0.0), corner(1.0, 1.0, 0.999, 0.999), corner(-1.0, 1.0, 0.0, 0.999)];
        let p = Params { tex, alpha, blend, zwrite: false, ztest: self.depth_test, lit: false, amb: 0.0, fog: false, hole: [0.0; 4], sprite: false };
        self.deferred.push(Deferred { v: [q[0], q[1], q[2]], p, key: iw });
        self.deferred.push(Deferred { v: [q[0], q[2], q[3]], p, key: iw });
    }

    fn pfx_draw(&mut self) {
        let parts = core::mem::take(&mut self.parts);
        for p in &parts {
            let t = 1.0 - (p.life / p.max).clamp(0.0, 1.0);
            let size = p.s0 + (p.s1 - p.s0) * t;
            let col = lerp_rgb(p.c0, p.c1, t);
            let fade = ((p.life / p.max) * 3.0).clamp(0.0, 1.0);
            match p.kind {
                1 => {
                    // streak: a thin additive quad from the particle back along its velocity
                    if self.win_dims().is_some() {
                        let (cx, cy, focal) = self.view();
                        let near = -self.cam.zdist + 0.05;
                        let tail = [p.p[0] - p.v[0] * 0.06, p.p[1] - p.v[1] * 0.06, p.p[2] - p.v[2] * 0.06];
                        let (ax, ay, az) = self.cam.project(p.p, cx, cy, focal);
                        let (bx, by, bz) = self.cam.project(tail, cx, cy, focal);
                        if az > near && bz > near {
                            let w = (focal * size * 0.5 / (az + self.cam.zdist).max(1e-3)).max(0.6);
                            let (dx, dy) = (bx - ax, by - ay);
                            let l = sqrt(dx * dx + dy * dy).max(1e-3);
                            let (nx, ny) = (-dy / l * w, dx / l * w);
                            let iwa = 1.0 / (az + self.cam.zdist).max(1e-4);
                            let iwb = 1.0 / (bz + self.cam.zdist).max(1e-4);
                            let c3 = col3(col);
                            let v = |x: f32, y: f32, iw: f32| A { x, y, iw, uw: 0.5 * iw, vw: 0.5 * iw, wx: 0.0, wy: 0.0, wz: 0.0, r: c3[0], g: c3[1], b: c3[2] };
                            let q = [v(ax + nx, ay + ny, iwa), v(ax - nx, ay - ny, iwa), v(bx - nx * 0.3, by - ny * 0.3, iwb), v(bx + nx * 0.3, by + ny * 0.3, iwb)];
                            let pp = Params { tex: TexId::Solid, alpha: fade * p.alpha, blend: 1, zwrite: false, ztest: self.depth_test, lit: false, amb: 0.0, fog: false, hole: [0.0; 4], sprite: false };
                            let key = iwa.max(iwb);
                            self.deferred.push(Deferred { v: [q[0], q[1], q[2]], p: pp, key });
                            self.deferred.push(Deferred { v: [q[0], q[2], q[3]], p: pp, key });
                        }
                    }
                    self.billboard(TexId::Glow, p.p, size * 1.6, 0.0, col, fade * p.alpha, 1);
                },
                2 => {
                    if (p.tex as usize) < self.texs.len() {
                        self.billboard(TexId::Id(p.tex), p.p, size, p.spin * (p.max - p.life), col, p.alpha * fade.min(1.0), 0);
                    }
                },
                _ => self.billboard(TexId::Glow, p.p, size, 0.0, col, fade * p.alpha, 1),
            }
        }
        self.parts = parts;
    }

    fn pfx_push(&mut self, p: Part) {
        if self.parts.len() >= self.max_parts {
            self.parts.remove(0);
        }
        self.parts.push(p);
    }

    fn coll_build(&mut self, args: &[u64]) -> f64 {
        let mi = n(args, 0, -1.0);
        if mi < 0.0 || mi as usize >= self.meshes.len() {
            return -1.0;
        }
        let gsel = n(args, 1, -1.0);
        let o = [f(args, 2, 0.0), f(args, 3, 0.0), f(args, 4, 0.0)];
        let s = f(args, 5, 1.0);
        let m = rot_from(args, 6);
        let cell = f(args, 7, 8.0);
        let mut tris = Vec::new();
        let mut nrm = Vec::new();
        for (gi, g) in self.meshes[mi as usize].groups.iter().enumerate() {
            if gsel >= 0.0 && gi != gsel as usize {
                continue;
            }
            for t in &g.idx {
                let a = xf(&m, g.pos[t[0] as usize], s, o);
                let b = xf(&m, g.pos[t[1] as usize], s, o);
                let c = xf(&m, g.pos[t[2] as usize], s, o);
                let fnrm = norm(cross(sub(b, a), sub(c, a)));
                let vn = rotn(&m, g.nrm[t[0] as usize]);
                let nn = if dot(fnrm, vn) < 0.0 { [-fnrm[0], -fnrm[1], -fnrm[2]] } else { fnrm };
                tris.push([a, b, c]);
                nrm.push(nn);
            }
        }
        self.colls.push(Coll::build(tris, nrm, cell));
        (self.colls.len() - 1) as f64
    }

    fn coll_sphere(&mut self, args: &[u64]) -> u64 {
        let id = n(args, 0, -1.0);
        let mut p = [f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
        let r = f(args, 4, 1.0).max(1e-3);
        if id < 0.0 || id as usize >= self.colls.len() {
            return nums(&[p[0], p[1], p[2], 0.0, 0.0, 0.0, 0.0, 0.0]);
        }
        let mut cand = core::mem::take(&mut self.scratch);
        let coll = &mut self.colls[id as usize];
        let mut nsum = [0.0f32; 3];
        let mut contacts = 0.0f32;
        let mut deepest = 0.0f32;
        for _ in 0..4 {
            coll.query([p[0] - r, p[1] - r, p[2] - r], [p[0] + r, p[1] + r, p[2] + r], &mut cand);
            let mut moved = false;
            for &ti in &cand {
                let t = coll.tris[ti as usize];
                let q = closest_on_tri(p, t[0], t[1], t[2]);
                let d = sub(p, q);
                let dist2 = dot(d, d);
                if dist2 < r * r {
                    let dist = sqrt(dist2);
                    let nn = if dist > 1e-5 { [d[0] / dist, d[1] / dist, d[2] / dist] } else { coll.nrm[ti as usize] };
                    let pen = r - dist;
                    p = [p[0] + nn[0] * pen, p[1] + nn[1] * pen, p[2] + nn[2] * pen];
                    nsum = [nsum[0] + nn[0] * pen, nsum[1] + nn[1] * pen, nsum[2] + nn[2] * pen];
                    contacts += 1.0;
                    deepest = deepest.max(pen);
                    moved = true;
                }
            }
            if !moved {
                break;
            }
        }
        self.scratch = cand;
        let nn = if contacts > 0.0 { norm(nsum) } else { [0.0; 3] };
        nums(&[p[0], p[1], p[2], nn[0], nn[1], nn[2], contacts, deepest])
    }

    fn coll_ray(&mut self, args: &[u64]) -> u64 {
        let id = n(args, 0, -1.0);
        let o = [f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
        let dir = norm([f(args, 4, 0.0), f(args, 5, 0.0), f(args, 6, 1.0)]);
        let maxt = f(args, 7, 1000.0);
        if id < 0.0 || id as usize >= self.colls.len() {
            return nums(&[-1.0, 0.0, 0.0, 0.0]);
        }
        let mut cand = core::mem::take(&mut self.scratch);
        let coll = &mut self.colls[id as usize];
        let e = [o[0] + dir[0] * maxt, o[1] + dir[1] * maxt, o[2] + dir[2] * maxt];
        let lo = [o[0].min(e[0]), o[1].min(e[1]), o[2].min(e[2])];
        let hi = [o[0].max(e[0]), o[1].max(e[1]), o[2].max(e[2])];
        coll.query(lo, hi, &mut cand);
        let mut best = (maxt, [0.0f32; 3], false);
        for &ti in &cand {
            let t = coll.tris[ti as usize];
            let e1 = sub(t[1], t[0]);
            let e2 = sub(t[2], t[0]);
            let pv = cross(dir, e2);
            let det = dot(e1, pv);
            if det.abs() < 1e-9 {
                continue;
            }
            let inv = 1.0 / det;
            let tv = sub(o, t[0]);
            let u = dot(tv, pv) * inv;
            if !(0.0..=1.0).contains(&u) {
                continue;
            }
            let qv = cross(tv, e1);
            let v = dot(dir, qv) * inv;
            if v < 0.0 || u + v > 1.0 {
                continue;
            }
            let tt = dot(e2, qv) * inv;
            if tt > 1e-4 && tt < best.0 {
                best = (tt, coll.nrm[ti as usize], true);
            }
        }
        self.scratch = cand;
        if best.2 {
            nums(&[best.0, best.1[0], best.1[1], best.1[2]])
        } else {
            nums(&[-1.0, 0.0, 0.0, 0.0])
        }
    }

    /// `tm_glyph(font, "c", px)` → [tex, span]: the glyph's coverage as the alpha
    /// of a white `px` x `px` texture centred on its ink; `span` is the square's
    /// side in ems.
    fn glyph(&mut self, args: &[u64]) -> u64 {
        let fid = n(args, 0, -1.0);
        let ch = args.get(1).map(|&v| to_string(v)).unwrap_or_default().chars().next().unwrap_or(' ');
        let px = pow2((n(args, 2, 256.0) as u32).clamp(16, 1024));
        let Some(font) = crate::lingapp::font_fd(fid) else { return nums(&[-1.0, 0.0]) };
        // measure at a probe size, then rasterize so the ink spans px / 1.12
        let probe = px as f32 * 0.5;
        let (m0, _) = font.rasterize(ch, probe);
        let ink = m0.width.max(m0.height) as f32;
        if ink < 1.0 {
            return nums(&[-1.0, 0.0]);
        }
        let size = probe * (px as f32 / 1.12) / ink;
        let (m, cov) = font.rasterize(ch, size);
        if m.width == 0 || m.height == 0 || m.width > px as usize || m.height > px as usize {
            return nums(&[-1.0, 0.0]);
        }
        let mut data = vec![0x00FF_FFFFu32; (px * px) as usize];
        let ox = (px as usize - m.width) / 2;
        let oy = (px as usize - m.height) / 2;
        for y in 0..m.height {
            for x in 0..m.width {
                let a = cov[y * m.width + x] as u32;
                data[(oy + y) * px as usize + ox + x] = (a << 24) | 0x00FF_FFFF;
            }
        }
        self.texs.push(Tex::new(px, data));
        nums(&[(self.texs.len() - 1) as f32, px as f32 / size])
    }
}

/// Draw whatever is still queued when the program presents a frame without a
/// `flush_3d` (the host would hold it until the next flush; here it would be lost).
pub fn end_frame() {
    let g = unsafe { &mut *(&raw mut G) };
    if let Some(s) = g.as_mut() {
        if !s.deferred.is_empty() || s.frame_open || (s.drew_3d && !s.post_done) {
            s.flush();
        }
        s.adapt();
        s.post_done = false;
        s.drew_3d = false;
    }
}

// ── builtins ─────────────────────────────────────────────────────────────────

fn log(s: &str) {
    crate::console_write(s.as_bytes());
}

fn tex_arg(s: &State, id: f64, soft: bool) -> Option<TexId> {
    if id <= -1.5 && soft {
        Some(TexId::Soft)
    } else if id < 0.0 {
        Some(TexId::Glow)
    } else if (id as usize) < s.texs.len() {
        Some(TexId::Id(id as u32))
    } else {
        None
    }
}

/// The 3-D builtins; `None` when `name` is not one of them.
pub fn dispatch(name: &[u8], args: &[u64]) -> Option<u64> {
    let shape_kind: Option<u8> = match name {
        b"cube" | b"box" => Some(0),
        b"sphere" => Some(1),
        b"cylinder" => Some(2),
        b"cone" => Some(3),
        b"capsule" => Some(4),
        b"torus" => Some(5),
        _ => None,
    };
    if let Some(kind) = shape_kind {
        let c = [f(args, 0, 0.0), f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 1.0), f(args, 4, 1.0), f(args, 5, 1.0), f(args, 6, 0.0), f(args, 7, 0.0), f(args, 8, 0.0)];
        let mode = n(args, 9, 0.0) as i32;
        let e0 = n(args, 10, 0.0) as i32;
        let e1 = n(args, 11, 0.0) as i32;
        st().shape(kind, c, mode, e0, e1);
        return Some(UNIT);
    }
    let s = st();
    Some(match name {
        // ── camera ──
        b"set_camera" => {
            s.cam.cry = f(args, 0, 1.0);
            s.cam.sry = f(args, 1, 0.0);
            s.cam.crx = f(args, 2, 1.0);
            s.cam.srx = f(args, 3, 0.0);
            UNIT
        },
        b"set_camera_pos" => {
            s.cam.t = [f(args, 0, 0.0), f(args, 1, 0.0), f(args, 2, 0.0)];
            UNIT
        },
        b"move_camera" => {
            s.cam.t[0] += f(args, 0, 0.0);
            s.cam.t[1] += f(args, 1, 0.0);
            s.cam.t[2] += f(args, 2, 0.0);
            UNIT
        },
        b"set_zdist" => {
            s.cam.zdist = f(args, 0, 5.0);
            UNIT
        },
        b"set_camera_center" => {
            s.cam.center = Some((f(args, 0, 0.0), f(args, 1, 0.0)));
            UNIT
        },
        b"set_focal" => {
            s.cam.focal = f(args, 0, 0.0);
            UNIT
        },
        b"tm_fov" => {
            let deg = f(args, 0, 0.0);
            let bw = s.win_dims().map(|(w, _)| w as f32).unwrap_or(1280.0);
            let vw = f(args, 1, bw);
            s.cam.focal = if deg > 1.0 && deg < 179.0 { (vw * 0.5) / libm::tanf(deg.to_radians() * 0.5) } else { 0.0 };
            UNIT
        },
        // ── lights, depth, fog, shading ──
        b"set_ambient" => {
            s.ambient = f(args, 0, 0.15);
            UNIT
        },
        b"clear_lights" => {
            s.lights.clear();
            UNIT
        },
        b"add_light" => {
            let (mut r, mut g, mut b) = (f(args, 3, 1.0), f(args, 4, 1.0), f(args, 5, 1.0));
            if r > 1.5 || g > 1.5 || b > 1.5 {
                r /= 255.0;
                g /= 255.0;
                b /= 255.0;
            }
            let radius = f(args, 7, 0.0);
            s.lights.push(Light { p: [f(args, 0, 0.0), f(args, 1, -3.0), f(args, 2, 3.0)], c: [r, g, b], i: f(args, 6, 1.0), r: if radius > 0.0 { radius } else { 1.0e6 } });
            UNIT
        },
        b"clear_depth" => {
            s.zclear = true;
            UNIT
        },
        b"set_depth_test" => {
            s.depth_test = n(args, 0, 1.0) as i64 != 0;
            UNIT
        },
        b"set_fog" => {
            let (r, g, b) = (f(args, 0, 0.0).clamp(0.0, 255.0) as u32, f(args, 1, 0.0).clamp(0.0, 255.0) as u32, f(args, 2, 0.0).clamp(0.0, 255.0) as u32);
            s.fog_color = (r << 16) | (g << 8) | b;
            s.fog_start = f(args, 3, 0.0);
            s.fog_end = f(args, 4, 0.0);
            UNIT
        },
        b"set_shade_mode" => {
            s.shade_mode = n(args, 0, 2.0) as u8;
            UNIT
        },
        b"set_cel_bands" => {
            s.bands = (n(args, 0, 4.0) as u32).max(2);
            UNIT
        },
        b"set_rim" => {
            s.rim = f(args, 0, 0.6);
            s.rim_color = [f(args, 1, 115.0) / 255.0, f(args, 2, 217.0) / 255.0, f(args, 3, 255.0) / 255.0];
            UNIT
        },
        b"set_blend" => {
            s.blend = n(args, 0, 0.0) as u8;
            UNIT
        },
        b"flush_3d" | b"render_3d" | b"flush_post" | b"post_now" => {
            s.flush();
            UNIT
        },
        b"toon_outlines" => {
            s.toon_px = f(args, 0, 0.0);
            s.toon_color = rgb(n(args, 1, 0.0));
            s.toon_thresh = f(args, 2, 0.05);
            UNIT
        },
        b"set_tone_ramp" => {
            s.ramp_on = n(args, 0, 1.0) > 0.5;
            UNIT
        },
        // g3d_scale(s): 0 = automatic (full resolution while it is fast enough, else half),
        // 0.25..1 = fixed
        b"g3d_scale" => {
            let v = f(args, 0, 0.0);
            if v <= 0.0 {
                s.auto_scale = true;
                s.scale = 1.0;
            } else {
                s.auto_scale = false;
                s.scale = v.clamp(0.25, 1.0);
            }
            UNIT
        },
        b"tm_wire" => {
            s.tm_wire(args);
            UNIT
        },
        // post-processing the kernel does not do (yet): accepted, no effect
        b"mesh_hue" | b"set_bloom" | b"set_fxaa" | b"set_ssao" | b"set_frame_blur" | b"screen_distort" | b"set_depth_blur" | b"set_antialias" | b"tm_ripple" | b"tm_ripple_use" | b"tm_ripple_clear" | b"tm_voice" | b"font_text_3d" | b"set_normal_override" | b"set_flat_shade" => UNIT,

        // ── textures and meshes ──
        b"tm_tex_load" => {
            let path = crate::lingapp::path_arg(args, 0);
            match load_tex(&path, n(args, 1, 256.0) as u32) {
                Some(t) => {
                    s.texs.push(t);
                    num((s.texs.len() - 1) as f64)
                },
                None => {
                    log(&alloc::format!("g3d: tm_tex_load {path}: not readable\n"));
                    num(-1.0)
                },
            }
        },
        b"tm_tex_count" => num(s.texs.len() as f64),
        b"tm_load" => {
            let path = crate::lingapp::path_arg(args, 0);
            match load_ltm(&path) {
                Some(m) => {
                    s.meshes.push(m);
                    num((s.meshes.len() - 1) as f64)
                },
                None => {
                    log(&alloc::format!("g3d: tm_load {path}: not an LTM1 mesh\n"));
                    num(-1.0)
                },
            }
        },
        b"tm_groups" => num(s.meshes.get(n(args, 0, -1.0).max(0.0) as usize).map(|m| m.groups.len()).unwrap_or(0) as f64),
        b"tm_group" => {
            let key = args.get(1).map(|&v| to_string(v)).unwrap_or_default();
            let r = s
                .meshes
                .get(n(args, 0, -1.0).max(0.0) as usize)
                .filter(|_| n(args, 0, -1.0) >= 0.0)
                .and_then(|m| m.groups.iter().position(|g| g.name == key).or_else(|| m.groups.iter().position(|g| g.name.contains(key.as_str()))));
            num(r.map(|i| i as f64).unwrap_or(-1.0))
        },
        b"tm_group_name" => {
            let r = s.meshes.get(n(args, 0, -1.0).max(0.0) as usize).and_then(|m| m.groups.get(n(args, 1, -1.0).max(0.0) as usize)).map(|g| g.name.clone()).unwrap_or_default();
            crate::lingrt::mk_str(&r)
        },
        b"tm_group_tris" => num(s.meshes.get(n(args, 0, -1.0).max(0.0) as usize).and_then(|m| m.groups.get(n(args, 1, -1.0).max(0.0) as usize)).map(|g| g.idx.len()).unwrap_or(0) as f64),
        b"tm_bounds" => {
            let mut lo = [f32::INFINITY; 3];
            let mut hi = [f32::NEG_INFINITY; 3];
            if let Some(m) = s.meshes.get(n(args, 0, -1.0).max(0.0) as usize) {
                let gsel = n(args, 1, -1.0);
                for (gi, g) in m.groups.iter().enumerate() {
                    if gsel >= 0.0 && gi != gsel as usize {
                        continue;
                    }
                    for p in &g.pos {
                        for k in 0..3 {
                            lo[k] = lo[k].min(p[k]);
                            hi[k] = hi[k].max(p[k]);
                        }
                    }
                }
            }
            if !lo[0].is_finite() {
                lo = [0.0; 3];
                hi = [0.0; 3];
            }
            nums(&[lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]])
        },
        b"tm_edges" => {
            let mut out: Vec<f32> = Vec::new();
            let mi = n(args, 0, -1.0);
            if mi >= 0.0 {
                if let Some(m) = s.meshes.get(mi as usize) {
                    let gsel = n(args, 1, -1.0);
                    let maxe = n(args, 2, 4000.0).max(1.0) as usize;
                    let q = |p: V3| ((p[0] * 20.0 + 0.5 * p[0].signum()) as i64, (p[1] * 20.0 + 0.5 * p[1].signum()) as i64, (p[2] * 20.0 + 0.5 * p[2].signum()) as i64);
                    let mut count: BTreeMap<((i64, i64, i64), (i64, i64, i64)), (u32, V3, V3)> = BTreeMap::new();
                    for (gi, g) in m.groups.iter().enumerate() {
                        if gsel >= 0.0 && gi != gsel as usize {
                            continue;
                        }
                        for t in &g.idx {
                            for k in 0..3 {
                                let a = g.pos[t[k] as usize];
                                let b = g.pos[t[(k + 1) % 3] as usize];
                                let (ka, kb) = (q(a), q(b));
                                let key = if ka < kb { (ka, kb) } else { (kb, ka) };
                                count.entry(key).or_insert((0, a, b)).0 += 1;
                            }
                        }
                    }
                    for (_, (c, a, b)) in count {
                        if c == 1 && out.len() / 6 < maxe {
                            out.extend_from_slice(&[a[0], a[1], a[2], b[0], b[1], b[2]]);
                        }
                    }
                }
            }
            nums(&out)
        },
        b"tm_wall_spots" => {
            let mut out: Vec<f32> = Vec::new();
            let mi = n(args, 0, -1.0);
            if mi >= 0.0 {
                if let Some(m) = s.meshes.get(mi as usize) {
                    let gsel = n(args, 1, -1.0);
                    let spacing = f(args, 2, 30.0).max(1.0);
                    let up = f(args, 3, 6.0);
                    let mut chosen: Vec<V3> = Vec::new();
                    for (gi, g) in m.groups.iter().enumerate() {
                        if gsel >= 0.0 && gi != gsel as usize {
                            continue;
                        }
                        for t in &g.idx {
                            let a = g.pos[t[0] as usize];
                            let b = g.pos[t[1] as usize];
                            let c = g.pos[t[2] as usize];
                            let fnrm = norm(cross(sub(b, a), sub(c, a)));
                            if fnrm[1].abs() > 0.3 {
                                continue;
                            }
                            let vn = g.nrm[t[0] as usize];
                            let nn = if dot(fnrm, vn) < 0.0 { [-fnrm[0], -fnrm[1], -fnrm[2]] } else { fnrm };
                            let bottom = a[1].max(b[1]).max(c[1]);
                            let top = a[1].min(b[1]).min(c[1]);
                            if bottom - top < up * 1.2 {
                                continue;
                            }
                            let p = [(a[0] + b[0] + c[0]) / 3.0, bottom - up, (a[2] + b[2] + c[2]) / 3.0];
                            if chosen.iter().any(|q| {
                                let d = sub(*q, p);
                                d[0] * d[0] + d[2] * d[2] < spacing * spacing && d[1].abs() < spacing
                            }) {
                                continue;
                            }
                            chosen.push(p);
                            out.extend_from_slice(&[p[0], p[1], p[2], nn[0], nn[1], nn[2]]);
                        }
                    }
                }
            }
            nums(&out)
        },
        b"tm_hole" => {
            s.hole = [f(args, 0, 0.0), f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
            UNIT
        },
        b"tm_draw" => num(s.tm_draw(args) as f64),
        b"tm_sprite" => {
            let Some(tex) = tex_arg(s, n(args, 0, -1.0), false) else { return Some(UNIT) };
            let (cx, cy) = (f(args, 1, 0.0), f(args, 2, 0.0));
            let (w, h) = (f(args, 3, 64.0) * 0.5, f(args, 4, 64.0) * 0.5);
            let rot = f(args, 5, 0.0);
            let c3 = col3(rgb(n(args, 6, 16777215.0)));
            let alpha = f(args, 7, 1.0);
            let blend = n(args, 8, 0.0) as u8;
            let (c, sn) = (cos(rot), sin(rot));
            let corner = |dx: f32, dy: f32, u: f32, v: f32| A { x: cx + dx * w * c - dy * h * sn, y: cy + dx * w * sn + dy * h * c, iw: 1.0, uw: u, vw: v, wx: 0.0, wy: 0.0, wz: 0.0, r: c3[0], g: c3[1], b: c3[2] };
            let q = [corner(-1.0, -1.0, 0.0, 0.0), corner(1.0, -1.0, 0.999, 0.0), corner(1.0, 1.0, 0.999, 0.999), corner(-1.0, 1.0, 0.0, 0.999)];
            let p = Params { tex, alpha, blend, zwrite: false, ztest: false, lit: false, amb: 0.0, fog: false, hole: [0.0; 4], sprite: true };
            s.draw_now(&[q[0], q[1], q[2]], &p);
            s.draw_now(&[q[0], q[2], q[3]], &p);
            UNIT
        },
        b"tm_billboard" => {
            if let Some(tex) = tex_arg(s, n(args, 0, -1.0), false) {
                let p = [f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
                s.billboard(tex, p, f(args, 4, 1.0), f(args, 5, 0.0), rgb(n(args, 6, 16777215.0)), f(args, 7, 1.0), n(args, 8, 0.0) as u8);
            }
            UNIT
        },
        b"tm_glyph" => s.glyph(args),
        b"tm_quad" => {
            if let Some(tex) = tex_arg(s, n(args, 0, -1.0), true) {
                let c = [f(args, 1, 0.0), f(args, 2, 0.0), f(args, 3, 0.0)];
                let u = [f(args, 4, 1.0), f(args, 5, 0.0), f(args, 6, 0.0)];
                let v = [f(args, 7, 0.0), f(args, 8, 0.0), f(args, 9, 1.0)];
                let tint = col3(rgb(n(args, 10, 16777215.0)));
                let alpha = f(args, 11, 1.0);
                let blend = n(args, 12, 0.0) as u8;
                let bias = f(args, 13, 0.0);
                let at = |a: f32, b: f32| [c[0] + u[0] * a + v[0] * b, c[1] + u[1] * a + v[1] * b, c[2] + u[2] * a + v[2] * b];
                let q = [at(-1.0, -1.0), at(1.0, -1.0), at(1.0, 1.0), at(-1.0, 1.0)];
                let t = [[0.0, 0.0], [0.999, 0.0], [0.999, 0.999], [0.0, 0.999]];
                let p = Params { tex, alpha, blend, zwrite: false, ztest: s.depth_test, lit: false, amb: 0.0, fog: s.fog_end > 0.0, hole: [0.0; 4], sprite: false };
                s.push_tri([q[0], q[1], q[2]], [t[0], t[1], t[2]], [tint; 3], p, 0, bias);
                s.push_tri([q[0], q[2], q[3]], [t[0], t[2], t[3]], [tint; 3], p, 0, bias);
            }
            UNIT
        },

        // ── collision ──
        b"coll_build" => num(s.coll_build(args)),
        b"coll_tris" => num(s.colls.get(n(args, 0, -1.0).max(0.0) as usize).map(|c| c.tris.len()).unwrap_or(0) as f64),
        b"coll_sphere" => s.coll_sphere(args),
        b"coll_ray" => s.coll_ray(args),

        // ── particles ──
        b"pfx_emit" => {
            let c0 = rgb(n(args, 9, 16777215.0));
            let pt = Part {
                p: [f(args, 0, 0.0), f(args, 1, 0.0), f(args, 2, 0.0)],
                v: [f(args, 3, 0.0), f(args, 4, 0.0), f(args, 5, 0.0)],
                life: f(args, 6, 1.0),
                max: f(args, 6, 1.0).max(1e-3),
                s0: f(args, 7, 0.2),
                s1: f(args, 8, 0.0),
                c0,
                c1: rgb(n(args, 10, c0 as f64)),
                kind: n(args, 11, 0.0) as u8,
                tex: n(args, 12, 0.0).max(0.0) as u32,
                grav: f(args, 13, 1.0),
                spin: 0.0,
                alpha: f(args, 14, 1.0),
            };
            s.pfx_push(pt);
            UNIT
        },
        b"pfx_burst" => {
            let count = n(args, 3, 16.0).clamp(0.0, 2000.0) as usize;
            let (sp, life) = (f(args, 4, 5.0), f(args, 5, 1.0));
            let base = [f(args, 12, 0.0), f(args, 13, 0.0), f(args, 14, 0.0)];
            let c0 = rgb(n(args, 8, 16777215.0));
            let c1 = rgb(n(args, 9, c0 as f64));
            for _ in 0..count {
                let z = s.rand() * 2.0 - 1.0;
                let a = s.rand() * TAU;
                let r = sqrt((1.0 - z * z).max(0.0));
                let sv = sp * (0.55 + 0.45 * s.rand());
                let l = life * (0.6 + 0.4 * s.rand());
                let pt = Part {
                    p: [f(args, 0, 0.0), f(args, 1, 0.0), f(args, 2, 0.0)],
                    v: [base[0] + r * cos(a) * sv, base[1] + z * sv, base[2] + r * sin(a) * sv],
                    life: l,
                    max: l.max(1e-3),
                    s0: f(args, 6, 0.2),
                    s1: f(args, 7, 0.0),
                    c0,
                    c1,
                    kind: n(args, 10, 0.0) as u8,
                    tex: 0,
                    grav: f(args, 11, 1.0),
                    spin: 0.0,
                    alpha: 1.0,
                };
                s.pfx_push(pt);
            }
            UNIT
        },
        b"pfx_rain" => {
            let count = n(args, 5, 1.0).clamp(0.0, 2000.0) as usize;
            let ntex = n(args, 11, 1.0).max(1.0) as u32;
            let col = rgb(n(args, 13, 16777215.0));
            for _ in 0..count {
                let x = f(args, 0, 0.0) + (f(args, 2, 0.0) - f(args, 0, 0.0)) * s.rand();
                let z = f(args, 1, 0.0) + (f(args, 3, 0.0) - f(args, 1, 0.0)) * s.rand();
                let y = f(args, 4, 0.0) + (s.rand() - 0.5) * 2.0 * f(args, 15, 0.0);
                let l = f(args, 7, 3.0) * (0.25 + 0.75 * s.rand());
                let t = n(args, 10, 0.0).max(0.0) as u32 + ((s.rand() * ntex as f32) as u32).min(ntex - 1);
                let spin = (s.rand() - 0.5) * 2.0;
                let pt = Part { p: [x, y, z], v: [0.0, f(args, 6, 90.0), 0.0], life: l, max: l.max(1e-3), s0: f(args, 8, 6.0), s1: f(args, 9, 1.0), c0: col, c1: col, kind: 2, tex: t, grav: 0.0, spin, alpha: f(args, 12, 0.5) };
                s.pfx_push(pt);
            }
            UNIT
        },
        b"pfx_step" => {
            let dt = f(args, 0, 1.0 / 60.0);
            let g = f(args, 1, 9.8);
            let drag = (1.0 - f(args, 2, 0.0) * dt).clamp(0.0, 1.0);
            let floor_y = f(args, 3, 1e9);
            let bounce = f(args, 4, 0.4);
            s.parts.retain_mut(|p| {
                p.life -= dt;
                if p.life <= 0.0 {
                    return false;
                }
                p.v[1] += g * p.grav * dt;
                for k in 0..3 {
                    p.v[k] *= drag;
                    p.p[k] += p.v[k] * dt;
                }
                if p.p[1] > floor_y {
                    p.p[1] = floor_y;
                    p.v[1] = -p.v[1].abs() * bounce;
                    p.v[0] *= 0.7;
                    p.v[2] *= 0.7;
                }
                true
            });
            UNIT
        },
        b"pfx_draw" => {
            s.pfx_draw();
            UNIT
        },
        b"pfx_clear" => {
            s.parts.clear();
            UNIT
        },
        b"pfx_count" => num(s.parts.len() as f64),
        b"pfx_max" => {
            s.max_parts = n(args, 0, 6000.0).clamp(16.0, 100000.0) as usize;
            UNIT
        },
        _ => {
            let _ = str_of;
            return None;
        },
    })
}

← Back to index