"use strict";

/**
 * AtdtDosDoor — the DOS door runner (SPEC-DOSDOORS §8-§12).
 *
 * The caller's tab boots a real FreeDOS inside v86, the door talks to
 * COM1, and this file is everything around that machine: the serial
 * bridge into the ANSI terminal, and an EtherDFS *server* speaking the
 * EDF5 protocol to the in-DOS redirector, so drive D: is the door's
 * shared game directory served by api.php.
 *
 * Three kinds of file, three routes (§9.4):
 *   session  planted or created per node (drop file, DOOR.BAT, temp
 *            files matching the manifest's session globs): live only
 *            in this tab's memory, the server never sees them;
 *   static   manifest-declared immutable files (the .EXE, art):
 *            cached whole, validated once at open by size+stamp;
 *   shared   everything else — the game state. Reads pull the file
 *            through a cache that is flushed whenever the door's
 *            generation counter moves; writes go straight through.
 *
 * EtherSrv and DoorFs are DOM-free and take a pluggable io, so the
 * unit tests and the Playwright end-to-end run the real code against
 * an in-memory backend.
 */
var AtdtDosDoor = (function () {

    // ---- CP437 → Unicode ---------------------------------------------------

    /** The high half (0x80-0xFF), the classic IBM set. */
    var CP437_HIGH = [
        0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7,
        0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5,
        0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9,
        0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192,
        0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA,
        0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB,
        0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
        0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
        0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
        0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
        0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
        0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
        0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4,
        0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229,
        0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248,
        0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
    ];

    /** One serial byte as the string the AnsiParser expects. Controls
     *  (below 0x20) pass through untranslated: they ARE the protocol. */
    function cp437Char(b) {
        if (b < 0x80) { return String.fromCharCode(b === 0x7F ? 0x2302 : b); }
        return String.fromCharCode(CP437_HIGH[b - 0x80]);
    }

    // ---- little-endian helpers and the BSD checksum ------------------------

    function u16(a, o) { return a[o] | (a[o + 1] << 8); }
    function u32(a, o) { return (a[o] | (a[o + 1] << 8) | (a[o + 2] << 16) | (a[o + 3] << 24)) >>> 0; }
    function putU16(a, o, v) { a[o] = v & 0xFF; a[o + 1] = (v >> 8) & 0xFF; }
    function putU32(a, o, v) { a[o] = v & 0xFF; a[o + 1] = (v >> 8) & 0xFF; a[o + 2] = (v >> 16) & 0xFF; a[o + 3] = (v >> 24) & 0xFF; }

    /** The EtherDFS frame checksum: ror16 then add, byte by byte. */
    function bsdsum(a, off, len) {
        var s = 0;
        for (var i = 0; i < len; i++) {
            s = ((s >>> 1) | ((s & 1) << 15));
            s = (s + a[off + i]) & 0xFFFF;
        }
        return s;
    }

    function b64encode(bytes) {
        var s = "";
        for (var i = 0; i < bytes.length; i++) { s += String.fromCharCode(bytes[i]); }
        return (typeof btoa !== "undefined") ? btoa(s) : Buffer.from(bytes).toString("base64");
    }

    function b64decode(s) {
        if (typeof atob !== "undefined") {
            var bin = atob(s);
            var out = new Uint8Array(bin.length);
            for (var i = 0; i < bin.length; i++) { out[i] = bin.charCodeAt(i); }
            return out;
        }
        return new Uint8Array(Buffer.from(s, "base64"));
    }

    // ---- DOS names ---------------------------------------------------------

    /** "GAME.DAT" → "GAME    DAT" (the 11-byte FCB form). */
    function fcbName(name) {
        var dot = name.lastIndexOf(".");
        var base = dot === -1 ? name : name.slice(0, dot);
        var ext = dot === -1 ? "" : name.slice(dot + 1);
        return (base + "        ").slice(0, 8) + (ext + "   ").slice(0, 3);
    }

    /** Match an 11-byte FCB template ("FILE????TXT") against a name. */
    function fcbMatch(tmpl, name) {
        var f = fcbName(name);
        for (var i = 0; i < 11; i++) {
            if (tmpl[i] !== "?" && tmpl[i] !== f[i]) { return false; }
        }
        return true;
    }

    /** DOS wildcard match on a plain name (mirrors the server's rule). */
    function maskMatch(name, mask) {
        if (mask === "" || mask === "*.*" || mask === "*") { return true; }
        return fcbMatch(fcbTemplate(mask), name);
    }

    /** "FILE*.D?T" → "FILE????D?T": the FCB template of a wildcard mask. */
    function fcbTemplate(mask) {
        var dot = mask.lastIndexOf(".");
        var base = dot === -1 ? mask : mask.slice(0, dot);
        var ext = dot === -1 ? "*" : mask.slice(dot + 1);
        var pad = function (s, n) {
            var star = s.indexOf("*");
            if (star !== -1) { s = s.slice(0, star) + "????????".slice(0, n - star); }
            return (s + "        ").slice(0, n);
        };
        return pad(base.toUpperCase(), 8) + pad(ext.toUpperCase(), 3);
    }

    /** Wire path → canonical relative ("\DIR\F.TXT" → "DIR/F.TXT"). */
    function canonPath(p) {
        p = String(p || "").replace(/\\/g, "/").toUpperCase();
        var parts = [];
        p.split("/").forEach(function (seg) {
            if (seg !== "" && seg !== ".") { parts.push(seg); }
        });
        return parts.join("/");
    }

    function baseName(p) {
        var i = p.lastIndexOf("/");
        return i === -1 ? p : p.slice(i + 1);
    }

    function dirName(p) {
        var i = p.lastIndexOf("/");
        return i === -1 ? "" : p.slice(0, i);
    }

    /** Now as the DOS packed stamp the server uses (date high word). */
    function dtimeNow() {
        var d = new Date();
        var date = ((Math.max(1980, d.getFullYear()) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
        var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
        return ((date << 16) | time) >>> 0;
    }

    // ---- DoorFs: routing + caches ------------------------------------------

    /**
     * The DOS drive as the shim sees it. io is the transport seam:
     *   io.op(params)     one dosfs op ({op: "read", ...}) → result
     *   io.batch(ops)     up to 64 ops in one request → {results: []}
     * Both resolve with the server's reply object (never reject for
     * protocol errors — {ok: false} rides the resolve).
     * @constructor
     */
    /** Biggest file the shim will hold whole for write-through patching. */
    var CACHE_FILE_CAP = 1048576;

    /** Write-behind: queued writes admitted before the wire, at most
     *  this many. Deep on purpose (a queued write is ~300 bytes, so
     *  256 is under 100KB): when a host's sustained drain rate is
     *  slower than the pace cap allows the door to be slowed, the
     *  DEFICIT accumulates as depth, and the depth must hold minutes
     *  of it before the hard cap surfaces a single DOS error. */
    var WQ_DEPTH = 256;
    /** Above this depth, admission is paced at the MEASURED drain
     *  rate (an EWMA of recent flush times) plus a small
     *  depth-proportional catch-down. The cap is the load-bearing
     *  number: EtherDFS's five retry windows can total as little as
     *  ~280ms on an unlucky tick alignment, so any admission slower
     *  than ~250ms occasionally loses the patience roll and hands
     *  the door a write error - the stress rig caught exactly one,
     *  at the same record, on three straight runs. So the ack is
     *  never held past 250ms: when the host's sustained drain rate
     *  is slower than that, the deficit lands in DEPTH (absorbed by
     *  the deep queue) instead of in the DOS side's face. Pacing at
     *  the measured rate still keeps depth near the soft mark on any
     *  host faster than the cap, headroom in reserve for bursts. */
    var WQ_SOFT = 8;
    var WQ_PACE_SLOPE = 10;
    var WQ_PACE_CAP = 250;
    /** How many transport-ladder rounds a queued write gets (each
     *  round is ~7 seconds of retries) before its path is latched. */
    var WQ_ROUNDS = 3;
    /** Consecutive writes to ONE file coalesce into a single queue
     *  entry, dispatched as one batched request (the server runs a
     *  batch's ops in order). A world reset streams thousands of
     *  records through one handle; entry-per-record cost a paced
     *  wire trip each, and on a host whose sustained drain ran
     *  slower than the pace cap the deficit landed in depth until
     *  the hard cap pinned - at which point the admission stall
     *  outlived DOS patience and the LAST writes of an otherwise
     *  perfect reset drew "Unable to WRITE" (the field failure on
     *  the demo host, right as the reset finished). Coalesced, a
     *  240KB stream is ~15 entries instead of ~1000, the queue
     *  never nears its depth, and the reset drains in seconds. */
    var WQ_JOIN_OPS = 64;      // server-side batch cap
    var WQ_JOIN_BYTES = 16384; // raw payload per entry (b64 rides ~22KB)

    function DoorFs(io, opts) {
        this.io = io;
        this.statics = (opts && opts.statics) || [];
        this.sessions = (opts && opts.sessions) || [];
        this.genSeen = (opts && typeof opts.gen === "number") ? opts.gen : 0;
        this.overlay = new Map();      // canonical path → {bytes, attr, dtime}
        this.staticCache = new Map();  // canonical path → {bytes, attr, dtime}
        this.sharedCache = new Map();  // canonical path → {bytes, attr, dtime}
        this.statCache = new Map();    // canonical path → {attr, size, dtime} (from listings)
        this.pulling = new Map();      // canonical path → in-flight fetchFile promise
        this.ownUnfolded = 0;          // own mutations dispatched, gen not yet folded
        this.wq = [];                  // write-behind FIFO: {params, path, tries} or {marker}
        this.wqDrainAvg = 150;         // EWMA of flush times, the pacing clock
        this.wqActive = false;         // a queue entry is on the wire right now
        this.wqActivePath = null;      // that entry's path
        this.wqHeld = 0;               // slots admitted but not yet pushed
        this.wqDead = new Map();       // path → true: flush failed, report on next touch
        this.wqAdmits = [];            // writers waiting for a queue slot
        this.wqDrains = [];            // teardown waiting for an empty queue
        this.wqPathWaits = [];         // per-path barriers: {path, resolve}
        this.onGenChange = (opts && opts.onGenChange) || function () {};
    }

    /**
     * Dispatch one of our own gen-bumping ops, counted so sawGen can
     * tell our bumps from another node's. Every mutate() caller folds
     * the reply with sawGen(r && r.gen, true) FIRST, ok or not: that
     * fold is also what hands the count back.
     */
    DoorFs.prototype.mutate = function (params) {
        this.ownUnfolded++;
        return this.io.op(params);
    };

    /** mutate(), for a coalesced run: one request, N server-side ops,
     *  N gen bumps credited so the fold keeps reading them as ours. */
    DoorFs.prototype.mutateBatch = function (ops) {
        this.ownUnfolded += ops.length;
        return this.io.batch(ops);
    };

    // ---- the write-behind queue -------------------------------------------
    //
    // WRITEFILE is the one operation a door never retries and the DOS
    // side never waits long for: EtherDFS gives any call about half a
    // second (five sends, ~110ms apart, then error 2), and a shared
    // host's throttle burst runs longer than that whenever it likes.
    // So writes are answered the moment the local copy is patched, and
    // the wire work rides this strictly-ordered queue behind them,
    // where the transport's retry ladder has all the time it needs.
    // Everything that must observe a write barriers first - but only
    // on ITS OWN PATH's queued entries (a create of OBJDAT14 has no
    // business waiting behind MONSTER.DAT's thousand-record stream;
    // the first cut waited for an EMPTY queue and starved every
    // cross-file operation for the whole phase). The two whole-drive
    // observers, a directory listing and a wildcard delete, ride a
    // marker through the queue instead: everything enqueued before
    // them lands first, and writes enqueued after never block them.
    // If a flush truly dies (rounds of ladders spent, or the server
    // refuses), the path is latched, its cached copy is dropped, and
    // the NEXT operation on it gets the error a synchronous write
    // would have gotten: the door shows its dialog one operation
    // late, against a file whose earlier bytes all landed.

    /** A queue slot, when one is free. Admission reserves the slot,
     *  so a burst of admissions can never overshoot the depth; past
     *  the soft mark the ack is paced to hold slack in reserve. */
    DoorFs.prototype.wqAdmit = function () {
        var self = this;
        var depth = this.wq.length + this.wqHeld;
        if (depth < WQ_DEPTH) {
            this.wqHeld++;
            if (depth <= WQ_SOFT) { return Promise.resolve(); }
            var pace = Math.min(WQ_PACE_CAP,
                this.wqDrainAvg + (depth - WQ_SOFT) * WQ_PACE_SLOPE);
            return new Promise(function (r) { setTimeout(r, pace); });
        }
        return new Promise(function (r) { self.wqAdmits.push(r); });
    };

    DoorFs.prototype.wqPush = function (params, path, size) {
        this.wqHeld = Math.max(0, this.wqHeld - 1);
        this.wq.push({ ops: [params], path: path, tries: 0,
            raw: size || 0, joinable: params.op === "write" });
        this.wqPump();
    };

    /** Fold a write into the tail entry when it is the same file's
     *  still-open batch: no new slot, no pacing, answered on the
     *  spot. The tail is off limits while its request is on the
     *  wire (its ops array is already serialized into a body), and
     *  a marker, a truncate, or another path's entry ends the run,
     *  so ordering and marker semantics stay exact. */
    DoorFs.prototype.wqJoin = function (params, path, size) {
        var tail = this.wq[this.wq.length - 1];
        if (!tail || tail.marker || !tail.joinable || tail.path !== path) { return false; }
        if (tail === this.wq[0] && this.wqActive) { return false; }
        if (tail.ops.length >= WQ_JOIN_OPS || tail.raw + size > WQ_JOIN_BYTES) { return false; }
        tail.ops.push(params);
        tail.raw += size;
        return true;
    };

    /** Are any queued (or in-flight) entries about this path? */
    DoorFs.prototype.wqPathBusy = function (path) {
        if (this.wqActive && this.wqActivePath === path) { return true; }
        for (var i = 0; i < this.wq.length; i++) {
            if (this.wq[i].path === path) { return true; }
        }
        return false;
    };

    /** Resolves when nothing queued about this path remains. */
    DoorFs.prototype.pathBarrier = function (path) {
        var self = this;
        if (!this.wqPathBusy(path)) { return Promise.resolve(); }
        return new Promise(function (r) {
            self.wqPathWaits.push({ path: path, resolve: r });
        });
    };

    /** Resolves when everything enqueued BEFORE this call has landed;
     *  writes enqueued after never hold it up (no starvation). */
    DoorFs.prototype.wqMarkerBarrier = function () {
        var self = this;
        if (this.wq.length === 0 && !this.wqActive) { return Promise.resolve(); }
        return new Promise(function (r) {
            self.wq.push({ marker: r });
            self.wqPump();
        });
    };

    DoorFs.prototype.wqSettleWaiters = function () {
        var keep = [];
        for (var i = 0; i < this.wqPathWaits.length; i++) {
            var w = this.wqPathWaits[i];
            if (this.wqPathBusy(w.path)) { keep.push(w); } else { w.resolve(); }
        }
        this.wqPathWaits = keep;
        if (this.wq.length === 0 && !this.wqActive) {
            while (this.wqDrains.length > 0) { this.wqDrains.shift()(); }
        }
    };

    DoorFs.prototype.wqPump = function () {
        var self = this;
        if (this.wqActive) { return; }
        var head = this.wq[0];
        if (!head) {
            this.wqSettleWaiters();
            return;
        }
        if (head.marker) {
            this.wq.shift();
            head.marker();
            this.wqPump();
            return;
        }
        this.wqActive = true;
        this.wqActivePath = head.path;
        var t0 = Date.now();
        var wire = head.ops.length === 1
            ? this.mutate(head.ops[0])
            : this.mutateBatch(head.ops);
        wire.then(function (r) {
            self.wqDrainAvg = self.wqDrainAvg * 0.8 + (Date.now() - t0) * 0.2;
            // A batch reply carries per-op gens; the last one is the
            // whole run's high-water mark. ok means every op landed.
            var g = (r && r.results && r.results.length > 0)
                ? r.results[r.results.length - 1].gen
                : (r ? r.gen : undefined);
            var allOk = !!r && r.ok !== false
                && !(r.results || []).some(function (x) { return !x || !x.ok; });
            self.sawGen(g, true);
            self.ownUnfolded = Math.max(0, self.ownUnfolded - (head.ops.length - 1));
            self.wqActive = false;
            self.wqActivePath = null;
            var latch = function () {
                self.wqDead.set(head.path, true);
                // The copy holds bytes the board never got: reads must
                // go back to the board's truth, not serve the fiction.
                self.sharedCache.delete(head.path);
                self.staticCache.delete(head.path);
                self.statCache.delete(head.path);
            };
            if (r === null || r === undefined) {
                // The transport ladder came up empty; give the entry
                // another round after a breath, a few times over.
                head.tries++;
                if (head.tries < WQ_ROUNDS) {
                    // Still first in line while it waits: the path
                    // stays busy for barriers, order holds.
                    self.wqActive = true;
                    self.wqActivePath = head.path;
                    setTimeout(function () {
                        self.wqActive = false;
                        self.wqActivePath = null;
                        self.wqPump();
                    }, 1500 * head.tries);
                    return;
                }
                latch();
            } else if (!allOk) {
                // The server answered and said no: permanent.
                latch();
            }
            self.wq.shift();
            while (self.wqAdmits.length > 0 && self.wq.length + self.wqHeld < WQ_DEPTH) {
                self.wqHeld++;
                self.wqAdmits.shift()();
            }
            self.wqSettleWaiters();
            self.wqPump();
        });
    };

    /** Resolves when every queued write has landed (or been latched). */
    DoorFs.prototype.drain = function () {
        var self = this;
        if (this.wq.length === 0 && !this.wqActive) { return Promise.resolve(); }
        return new Promise(function (r) { self.wqDrains.push(r); });
    };

    /** drain(), but never longer than ms: teardown must not hang. */
    DoorFs.prototype.drainTimeout = function (ms) {
        return Promise.race([
            this.drain(),
            new Promise(function (r) { setTimeout(r, ms); })
        ]);
    };

    /** Plant a session-only file (drop file, DOOR.BAT). */
    DoorFs.prototype.plant = function (path, bytes, attr) {
        this.overlay.set(canonPath(path), {
            bytes: bytes, attr: (attr === undefined ? 0x20 : attr), dtime: dtimeNow()
        });
    };

    DoorFs.prototype.isSession = function (path) {
        if (this.overlay.has(path)) { return true; }
        var b = baseName(path);
        return this.sessions.some(function (g) { return maskMatch(b, g); });
    };

    DoorFs.prototype.isStatic = function (path) {
        var b = baseName(path);
        return this.statics.some(function (g) { return maskMatch(b, g); });
    };

    /**
     * Fold a server reply's gen into the caches. An advance no bigger
     * than our own unfolded mutations is our own writing coming back;
     * anything beyond that means another node wrote too, and the
     * shared cache goes (§9.4). The old rule ("own bump is exactly
     * +1") assumed one mutation in flight at a time - but when the DOS
     * side gives a call up (its patience is about half a second) the
     * door moves on while the op is still riding the retry ladder, the
     * next write overlaps it, and their replies cross: the fold then
     * read our own two bumps as a foreign write and flushed every
     * cache mid-world-reset. The same slack keeps the 30-second ping,
     * racing a write storm with a between-writes gen snapshot, from
     * flushing caches that are perfectly current.
     */
    DoorFs.prototype.sawGen = function (gen, ownWrite) {
        if (typeof gen === "number") {
            var advance = gen - this.genSeen;
            if (advance > 0) {
                if (advance > this.ownUnfolded) {
                    this.sharedCache.clear();
                    this.statCache.clear();
                }
                this.genSeen = gen;
                this.onGenChange(gen);
            }
        }
        if (ownWrite) { this.ownUnfolded = Math.max(0, this.ownUnfolded - 1); }
    };

    /**
     * The whole file, wherever it lives. Resolves {bytes, attr, dtime}
     * or null. Wire pulls are single-flight per path and batched: one
     * round trip for anything up to 64K (a read and a stat ride the
     * same request), a second round trip of parallel chunk reads for
     * bigger files. EtherDFS allows a DOS call about half a second
     * before it gives up, so round trips are the budget that matters.
     */
    DoorFs.prototype.fetchFile = function (path) {
        var self = this;
        if (this.overlay.has(path)) { return Promise.resolve(this.overlay.get(path)); }
        if (this.isStatic(path) && this.staticCache.has(path)) {
            return Promise.resolve(this.staticCache.get(path));
        }
        if (this.sharedCache.has(path)) { return Promise.resolve(this.sharedCache.get(path)); }
        if (this.pulling.has(path)) { return this.pulling.get(path); }
        var finish = function (chunks, s) {
            var total = 0;
            chunks.forEach(function (c) { total += c.length; });
            var all = new Uint8Array(total);
            var at = 0;
            chunks.forEach(function (c) { all.set(c, at); at += c.length; });
            var f = (s && s.ok && s.exists)
                ? { bytes: all, attr: s.attr, dtime: s.dtime }
                : { bytes: all, attr: 0x20, dtime: dtimeNow() };
            (self.isStatic(path) ? self.staticCache : self.sharedCache).set(path, f);
            return f;
        };
        var p = this.pathBarrier(path).then(function () {
            return self.io.batch([
                { op: "read", path: path, offset: 0, length: 65535 },
                { op: "stat", path: path }
            ]);
        }).then(function (r) {
            var rs = (r && r.results) || [];
            var rd = rs[0], s = rs[1];
            if (s) { self.sawGen(s.gen, false); }
            if (!rd || !rd.ok) { return null; }
            var first = b64decode(rd.data || "");
            if (rd.eof || first.length === 0 || !s || !s.ok || !s.exists) {
                return finish([first], s);
            }
            // The rest of the file, at most TWO chunks per request. A
            // shared host's response filters (mod_security ships a 512KB
            // default limit; 256KB configs are common) eat oversized
            // replies whole, and the original everything-in-one-request
            // pull built replies past half a megabyte for a big .EXE —
            // which is how a door could work on one host and silently
            // fail on another. Each batch retries once; and a file that
            // still comes back short is NEVER cached, because a torn
            // .EXE wearing a full-size stat kills the DOS loader with
            // no message on any screen.
            var chunks = [first];
            var got = first.length;
            var pull = function (per, retried) {
                if (got >= s.size) { return Promise.resolve(true); }
                var ops = [];
                for (var off = got; off < s.size && ops.length < per; off += 65535) {
                    ops.push({ op: "read", path: path, offset: off, length: 65535 });
                }
                return self.io.batch(ops).then(function (r2) {
                    var rs2 = (r2 && r2.results) || [];
                    var ok = rs2.length === ops.length && rs2.every(function (c) { return c && c.ok; });
                    if (!ok) {
                        if (!retried) { return pull(per, true); }
                        if (per > 1) { return pull(1, false); }  // smaller pieces, once more
                        return false;
                    }
                    var before = got;
                    rs2.forEach(function (c) {
                        var b = b64decode(c.data || "");
                        chunks.push(b);
                        got += b.length;
                    });
                    if (got === before) {
                        return false;   // the file shrank underneath us
                    }
                    return pull(per, false);
                });
            };
            return pull(2, false).then(function (whole) {
                return whole && got >= s.size ? finish(chunks, s) : null;
            });
        }).finally(function () { self.pulling.delete(path); });
        this.pulling.set(path, p);
        return p;
    };

    /** stat: {exists, attr, size, dtime} (overlay first, then wire). */
    DoorFs.prototype.stat = function (path) {
        var self = this;
        if (path === "") {
            return Promise.resolve({ exists: true, attr: 0x10, size: 0, dtime: dtimeNow() });
        }
        var local = this.overlay.get(path) || (this.isStatic(path) ? this.staticCache.get(path) : null)
            || this.sharedCache.get(path);
        if (local) {
            return Promise.resolve({ exists: true, attr: local.attr, size: local.bytes.length, dtime: local.dtime });
        }
        if (this.statCache.has(path)) {
            var st = this.statCache.get(path);
            return Promise.resolve({ exists: true, attr: st.attr, size: st.size, dtime: st.dtime });
        }
        if (this.isSession(path)) {
            return Promise.resolve({ exists: false });  // session files never hit the wire
        }
        return this.pathBarrier(path).then(function () {
            return self.io.op({ op: "stat", path: path });
        }).then(function (r) {
            self.sawGen(r && r.gen, false);
            if (!r || !r.ok || !r.exists) { return { exists: false }; }
            self.statCache.set(path, { attr: r.attr, size: r.size, dtime: r.dtime });
            return { exists: true, attr: r.attr, size: r.size, dtime: r.dtime };
        });
    };

    /** read: resolves a Uint8Array slice (may be short at EOF) or null. */
    DoorFs.prototype.read = function (path, offset, length) {
        return this.fetchFile(path).then(function (f) {
            if (f === null) { return null; }
            var end = Math.min(f.bytes.length, offset + length);
            return offset >= f.bytes.length ? new Uint8Array(0) : f.bytes.slice(offset, end);
        });
    };

    /** write: session files stay local; the rest write through. Resolves bytes written or -1. */
    DoorFs.prototype.write = function (path, offset, data) {
        var self = this;
        if (this.isSession(path)) {
            var f = this.overlay.get(path);
            if (!f) { f = { bytes: new Uint8Array(0), attr: 0x20, dtime: dtimeNow() }; this.overlay.set(path, f); }
            if (offset + data.length > f.bytes.length) {
                var grown = new Uint8Array(offset + data.length);
                grown.set(f.bytes, 0);
                f.bytes = grown;
            }
            f.bytes.set(data, offset);
            f.dtime = dtimeNow();
            return Promise.resolve(data.length);
        }
        // A flush that died on this path: the door hears about it now,
        // one operation late, the way a synchronous write would have
        // said no. The latch clears so work can continue if the host
        // recovers; a still-dead host just latches again.
        if (this.wqDead.has(path)) {
            this.wqDead.delete(path);
            return Promise.resolve(-1);
        }
        // Patch the cached copy first so the door's next read is both
        // instant and current. Past the cap the entry is let go instead
        // of grown: an append-forever file (a news log) must not pin
        // megabytes in the tab; reads of it fall back to the
        // pull-and-cache road. Growth is geometric (spare capacity in
        // c.buf) so a create-and-stream costs one copy per doubling,
        // not one per record.
        var c = this.sharedCache.get(path) || (this.isStatic(path) ? this.staticCache.get(path) : null);
        if (c) {
            if (offset + data.length > CACHE_FILE_CAP) {
                this.sharedCache.delete(path);
                this.staticCache.delete(path);
            } else {
                var newLen = Math.max(c.bytes.length, offset + data.length);
                if (!c.buf || newLen > c.buf.length) {
                    var cap = Math.max(8192, Math.floor(newLen * 3 / 2));
                    var nb = new Uint8Array(cap);
                    nb.set(c.bytes, 0);
                    c.buf = nb;
                }
                c.buf.set(data, offset);
                c.bytes = c.buf.subarray(0, newLen);
                c.dtime = dtimeNow();
            }
        }
        this.statCache.delete(path);
        // Answer the DOS side as soon as a queue slot admits us; the
        // wire write follows in strict order with all the retry time
        // it needs, instead of the half second EtherDFS would allow.
        var params = { op: "write", path: path, offset: offset, data: b64encode(data) };
        if (this.wqJoin(params, path, data.length)) {
            return Promise.resolve(data.length);
        }
        return this.wqAdmit().then(function () {
            self.wqPush(params, path, data.length);
            return data.length;
        });
    };

    /** create/truncate. Resolves {ok, size, attr, dtime}. */
    DoorFs.prototype.create = function (path, attr, truncate) {
        var self = this;
        if (this.isSession(path)) {
            var have = this.overlay.get(path);
            if (!have || truncate) {
                this.plant(path, new Uint8Array(0), attr);
                have = this.overlay.get(path);
            }
            return Promise.resolve({ ok: true, size: have.bytes.length, attr: have.attr, dtime: have.dtime });
        }
        return this.pathBarrier(path).then(function () {
            return self.mutate({ op: "create", path: path, attr: attr, truncate: !!truncate });
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            if (!r || !r.ok) { return { ok: false }; }
            self.sharedCache.delete(path);
            self.statCache.delete(path);
            self.wqDead.delete(path);   // a fresh file starts clean
            if (truncate) {
                // A file born (or reborn) this session is cached from
                // birth, and every write keeps the copy current, so it
                // never has to be pulled back over the wire. This is
                // what a world reset does at scale: Usurper's editor
                // creates DATA\MONSTER.DAT, streams a thousand records
                // through one handle, then re-reads and re-writes every
                // record to arm it. Without the birth entry that first
                // read-back pulled a quarter megabyte across several
                // round trips INSIDE one DOS read call, and the DOS
                // side only waits about half a second (EtherDFS: five
                // sends ~110ms apart, then error 2) - on shared hosting
                // the pull and the reply burst around it blew the
                // window and the editor popped "Unable to WRITE to :
                // DATA\MONSTER.DAT" dialogs mid-reset.
                (self.isStatic(path) ? self.staticCache : self.sharedCache)
                    .set(path, { bytes: new Uint8Array(0), attr: r.attr, dtime: r.dtime });
            }
            return { ok: true, size: r.size, attr: r.attr, dtime: r.dtime };
        });
    };

    /** Set a shared file's size (DOS write-of-zero). Resolves ok.
     *  Rides the write-behind queue like any write: answered from the
     *  patched copy, flushed in order behind. */
    DoorFs.prototype.truncate = function (path, size) {
        var self = this;
        if (this.wqDead.has(path)) {
            this.wqDead.delete(path);
            return Promise.resolve(false);
        }
        var c = this.sharedCache.get(path);
        if (c) {
            if (size > CACHE_FILE_CAP) {
                this.sharedCache.delete(path);
            } else if (size <= c.bytes.length) {
                if (c.buf) {
                    // Zero the cut so a later sparse grow reads as DOS
                    // zero fill, never as resurrected old bytes.
                    c.buf.fill(0, size, c.bytes.length);
                    c.bytes = c.buf.subarray(0, size);
                } else {
                    c.bytes = c.bytes.slice(0, size);
                }
                c.dtime = dtimeNow();
            } else {
                // A zero-filled grow, kept current like a write.
                if (!c.buf || size > c.buf.length) {
                    var nb = new Uint8Array(Math.max(8192, Math.floor(size * 3 / 2)));
                    nb.set(c.bytes, 0);
                    c.buf = nb;
                }
                c.bytes = c.buf.subarray(0, size);
                c.dtime = dtimeNow();
            }
        }
        this.statCache.delete(path);
        var params = { op: "truncate", path: path, size: size };
        return this.wqAdmit().then(function () {
            self.wqPush(params, path);
            return true;
        });
    };

    /** list of {name, attr, size, dtime} with the overlay folded in, or null. */
    DoorFs.prototype.list = function (dir, pattern, attrMask) {
        var self = this;
        return this.wqMarkerBarrier().then(function () {
            return self.io.op({ op: "list", dir: dir, pattern: pattern, attr_mask: attrMask });
        }).then(function (r) {
            self.sawGen(r && r.gen, false);
            if (!r || !r.ok) { return null; }
            var out = r.entries.slice();
            var seen = {};
            out.forEach(function (e) {
                seen[e.name] = true;
                // A listing is a stat of everything it names: remember it,
                // so the OPEN that follows costs no round trip.
                if ((e.attr & 0x10) === 0) {
                    self.statCache.set(dir === "" ? e.name : dir + "/" + e.name,
                        { attr: e.attr, size: e.size, dtime: e.dtime });
                }
            });
            self.overlay.forEach(function (f, p) {
                if (dirName(p) === dir && !seen[baseName(p)] && maskMatch(baseName(p), pattern)) {
                    out.push({ name: baseName(p), attr: f.attr, size: f.bytes.length, dtime: f.dtime });
                }
            });
            return out;
        });
    };

    DoorFs.prototype.remove = function (path) {
        var self = this;
        var mask = baseName(path);
        if (mask.indexOf("*") !== -1 || mask.indexOf("?") !== -1) {
            var dir = dirName(path);
            var hit = false;
            Array.from(this.overlay.keys()).forEach(function (p) {
                if (dirName(p) === dir && maskMatch(baseName(p), mask)) {
                    self.overlay.delete(p);
                    hit = true;
                }
            });
            return this.wqMarkerBarrier().then(function () {
                return self.mutate({ op: "delete", path: path });
            }).then(function (r) {
                self.sawGen(r && r.gen, true);
                if (r && r.ok) { self.sharedCache.clear(); self.statCache.clear(); }
                return hit || (r && r.ok);
            });
        }
        if (this.overlay.has(path)) {
            this.overlay.delete(path);
            return Promise.resolve(true);
        }
        return this.pathBarrier(path).then(function () {
            return self.mutate({ op: "delete", path: path });
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            if (r && r.ok) {
                self.sharedCache.delete(path);
                self.statCache.delete(path);
                self.wqDead.delete(path);   // nothing left to report on
            }
            return !!(r && r.ok);
        });
    };

    DoorFs.prototype.rename = function (from, to) {
        var self = this;
        if (this.overlay.has(from)) {
            this.overlay.set(to, this.overlay.get(from));
            this.overlay.delete(from);
            return Promise.resolve(true);
        }
        return Promise.all([this.pathBarrier(from), this.pathBarrier(to)]).then(function () {
            return self.mutate({ op: "rename", path: from, newpath: to });
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            if (r && r.ok) { self.sharedCache.delete(from); self.sharedCache.delete(to); self.statCache.delete(from); self.statCache.delete(to); }
            return !!(r && r.ok);
        });
    };

    DoorFs.prototype.mkdir = function (path) {
        var self = this;
        return this.pathBarrier(path).then(function () {
            return self.mutate({ op: "mkdir", path: path });
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            return !!(r && r.ok);
        });
    };

    DoorFs.prototype.rmdir = function (path) {
        var self = this;
        return this.pathBarrier(path).then(function () {
            return self.mutate({ op: "rmdir", path: path });
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            return !!(r && r.ok);
        });
    };

    DoorFs.prototype.setattr = function (path, fields) {
        var self = this;
        if (this.overlay.has(path)) {
            var f = this.overlay.get(path);
            if (fields.attr !== undefined) { f.attr = fields.attr; }
            if (fields.dtime !== undefined) { f.dtime = fields.dtime; }
            return Promise.resolve(true);
        }
        var p = { op: "setattr", path: path };
        if (fields.attr !== undefined) { p.attr = fields.attr; }
        if (fields.dtime !== undefined) { p.dtime = fields.dtime; }
        return this.pathBarrier(path).then(function () {
            return self.mutate(p);
        }).then(function (r) {
            self.sawGen(r && r.gen, true);
            if (r && r.ok) { self.sharedCache.delete(path); self.statCache.delete(path); }
            return !!(r && r.ok);
        });
    };

    DoorFs.prototype.lock = function (path, offset, length, wantLock) {
        var self = this;
        if (this.isSession(path)) { return Promise.resolve(true); }  // only this node can see it
        return this.pathBarrier(path).then(function () {
            return self.io.op({ op: wantLock ? "lock" : "unlock", path: path, offset: offset, length: length });
        }).then(function (r) {
            // A fresh lock is the door's cue that it is about to read the
            // record for real: the reply gen flushes anything stale first.
            self.sawGen(r && r.gen, false);
            return !!(r && r.ok && (r.granted === undefined || r.granted));
        });
    };

    /**
     * Validate remembered static files in one round trip: anything whose
     * size or stamp moved is dropped. entries: [{path, bytes, attr, dtime}].
     */
    DoorFs.prototype.seedStatics = function (entries) {
        var self = this;
        if (!entries || entries.length === 0) { return Promise.resolve(0); }
        var ops = entries.slice(0, 64).map(function (e) { return { op: "stat", path: e.path }; });
        return this.io.batch(ops).then(function (r) {
            var results = (r && r.results) || [];
            var kept = 0;
            entries.slice(0, 64).forEach(function (e, i) {
                var s = results[i];
                if (s && s.ok && s.exists && s.size === e.bytes.length && s.dtime === e.dtime) {
                    self.staticCache.set(e.path, { bytes: e.bytes, attr: s.attr, dtime: e.dtime });
                    kept++;
                }
            });
            return kept;
        });
    };

    // ---- EtherSrv: the EDF5 answering machine ------------------------------

    var EDF5_TYPE_LO = 0xED, EDF5_TYPE_HI = 0xF5;   // ED F5 on the wire

    /** DOS error codes the redirector understands. */
    var E_FILE_NOT_FOUND = 2, E_PATH_NOT_FOUND = 3, E_ACCESS_DENIED = 5,
        E_NO_MORE_FILES = 0x12, E_LOCK_VIOLATION = 0x21;

    /**
     * The server half of EtherDFS, one per door session.
     * handleFrame(Uint8Array) resolves the answer frame (or null for
     * frames that are not ours). File ids and directory cluster ids are
     * ours to assign (§9.2); DOS never sees a real handle.
     * @constructor
     */
    function EtherSrv(fs) {
        this.fs = fs;
        this.mac = [0x02, 0xED, 0xF5, 0x0A, 0x7D, 0x71];  // locally administered
        this.files = new Map();     // id → path
        this.nextFile = 1;
        this.dirs = new Map();      // cluster id → {entries: [...]}
        this.nextDir = 1;
        this.dirOrder = [];         // LRU of cluster ids (searches never close)
        // EtherDFS re-sends an unanswered query every ~100ms, five tries,
        // byte for byte. Retries of an op still in flight join it instead
        // of dispatching again (a write must not land twice); an answer
        // already given is replayed instantly, so a DOS-side retry of a
        // slow call becomes a warm hit instead of a second slow call.
        this.inflight = new Map();  // query key → Promise
        this.answered = new Map();  // query key → reply frame
        this.answeredOrder = [];
    }

    EtherSrv.prototype.allocFile = function (path) {
        var id = this.nextFile;
        do {
            id = (id % 0xFFFE) + 1;   // 1..0xFFFE, skip live ids
        } while (this.files.has(id));
        this.nextFile = id;
        this.files.set(id, path);
        return id;
    };

    EtherSrv.prototype.allocDir = function (entries) {
        var id = (this.nextDir % 0xFFFE) + 1;
        this.nextDir = id;
        this.dirs.set(id, { entries: entries });
        this.dirOrder.push(id);
        while (this.dirOrder.length > 32) {           // searches never close; LRU caps them
            this.dirs.delete(this.dirOrder.shift());
        }
        return id;
    };

    /** Is this frame an EDF5 query addressed to us (or broadcast)? */
    EtherSrv.prototype.wants = function (f) {
        if (!f || f.length < 60) { return false; }
        if (f[12] !== EDF5_TYPE_LO || f[13] !== EDF5_TYPE_HI) { return false; }
        var bcast = true, mine = true;
        for (var i = 0; i < 6; i++) {
            if (f[i] !== 0xFF) { bcast = false; }
            if (f[i] !== this.mac[i]) { mine = false; }
        }
        return bcast || mine;
    };

    /**
     * Answer one query frame. Resolves the reply frame, or null when the
     * frame is not an EDF5 query for this server (or a retry that joined
     * an op already in flight, whose original call will inject the reply).
     */
    EtherSrv.prototype.handleFrame = function (f) {
        var self = this;
        if (!this.wants(f)) { return Promise.resolve(null); }
        var end = Math.max(60, u16(f, 52) || f.length);
        var qkey = "";
        for (var qi = 56; qi < end; qi++) { qkey += String.fromCharCode(f[qi]); }
        var past = this.answered.get(qkey);
        if (past && Date.now() - past.at < 2000) {
            // A retry of something already answered; anything older is a
            // genuine new call that happens to look identical (the one-byte
            // sequence counter wraps), and must be dispatched for real.
            return Promise.resolve(past.out);
        }
        if (this.inflight.has(qkey)) {
            return this.inflight.get(qkey).then(function () { return null; });
        }
        var p = this.answerFrame(f).then(function (out) {
            self.inflight.delete(qkey);
            self.answered.set(qkey, { out: out, at: Date.now() });
            self.answeredOrder.push(qkey);
            while (self.answeredOrder.length > 16) {
                self.answered.delete(self.answeredOrder.shift());
            }
            return out;
        });
        this.inflight.set(qkey, p);
        return p;
    };

    /** Build the reply frame for one (deduplicated) query. */
    EtherSrv.prototype.answerFrame = function (f) {
        var self = this;
        var al = f[59];
        var payload = f.subarray(60, Math.max(60, u16(f, 52) || f.length));
        return this.dispatch(al, payload).then(function (r) {
            // r: {ax, data?}
            var data = r.data || new Uint8Array(0);
            var out = new Uint8Array(60 + data.length);
            for (var i = 0; i < 6; i++) {
                out[i] = f[6 + i];          // to the asker
                out[6 + i] = self.mac[i];   // from us
            }
            out[12] = EDF5_TYPE_LO; out[13] = EDF5_TYPE_HI;
            putU16(out, 52, out.length);
            out[56] = f[56];                // mirror version + CKS flag
            out[57] = f[57];                // echo the sequence
            putU16(out, 58, r.ax);
            out.set(data, 60);
            if ((f[56] & 0x80) !== 0) {     // checksum covers byte 56 onward
                putU16(out, 54, bsdsum(out, 56, out.length - 56));
            }
            return out;
        });
    };

    /** One op. Resolves {ax, data?}. */
    EtherSrv.prototype.dispatch = function (al, p) {
        var self = this;
        var fs = this.fs;
        var text = function (bytes) {
            var s = "";
            for (var i = 0; i < bytes.length; i++) { s += String.fromCharCode(bytes[i]); }
            return s;
        };
        switch (al) {
            case 0x01:  // RMDIR
                return fs.rmdir(canonPath(text(p))).then(function (ok) { return { ax: ok ? 0 : E_ACCESS_DENIED }; });
            case 0x03:  // MKDIR
                return fs.mkdir(canonPath(text(p))).then(function (ok) { return { ax: ok ? 0 : E_ACCESS_DENIED }; });
            case 0x05:  // CHDIR: succeed iff the directory exists
                return fs.stat(canonPath(text(p))).then(function (s) {
                    return { ax: (s.exists && (s.attr & 0x10) !== 0) || canonPath(text(p)) === "" ? 0 : E_PATH_NOT_FOUND };
                });
            case 0x06:  // CLOSEFILE
                this.files.delete(u16(p, 0));
                return Promise.resolve({ ax: 0 });
            case 0x08: {  // READFILE: OOOO SS LL
                var rpath = this.files.get(u16(p, 4));
                if (rpath === undefined) { return Promise.resolve({ ax: E_FILE_NOT_FOUND }); }
                return fs.read(rpath, u32(p, 0), u16(p, 6)).then(function (data) {
                    return data === null ? { ax: E_FILE_NOT_FOUND } : { ax: 0, data: data };
                });
            }
            case 0x09: {  // WRITEFILE: OOOO SS data
                var wpath = this.files.get(u16(p, 4));
                if (wpath === undefined) { return Promise.resolve({ ax: E_FILE_NOT_FOUND }); }
                var off = u32(p, 0);
                var body = p.subarray(6);
                if (body.length === 0) {
                    // DOS write-of-zero sets the file size to the offset:
                    // usually a shrink, occasionally a zero-filled grow.
                    if (fs.isSession(wpath)) {
                        var ov = fs.overlay.get(wpath);
                        if (ov) {
                            if (off <= ov.bytes.length) {
                                ov.bytes = ov.bytes.slice(0, off);
                            } else {
                                var grown = new Uint8Array(off);
                                grown.set(ov.bytes, 0);
                                ov.bytes = grown;
                            }
                        }
                        return Promise.resolve({ ax: 0, data: new Uint8Array(2) });
                    }
                    return fs.truncate(wpath, off).then(function (okT) {
                        var z = new Uint8Array(2);
                        return okT ? { ax: 0, data: z } : { ax: E_ACCESS_DENIED };
                    });
                }
                return fs.write(wpath, off, body).then(function (n) {
                    if (n < 0) { return { ax: E_ACCESS_DENIED }; }
                    var d = new Uint8Array(2);
                    putU16(d, 0, n);
                    return { ax: 0, data: d };
                });
            }
            case 0x0A:    // LOCK
            case 0x0B: {  // UNLOCK
                var lpath = this.files.get(u16(p, 2));
                if (lpath === undefined) { return Promise.resolve({ ax: E_FILE_NOT_FOUND }); }
                var n = u16(p, 0);
                var regions = [];
                for (var ri = 0; ri < n && 4 + ri * 8 + 8 <= p.length; ri++) {
                    regions.push({ off: u32(p, 4 + ri * 8), len: u32(p, 8 + ri * 8) });
                }
                var want = (al === 0x0A);
                var step = function (i) {
                    if (i >= regions.length) { return Promise.resolve({ ax: 0 }); }
                    return fs.lock(lpath, regions[i].off, regions[i].len, want).then(function (ok) {
                        return ok ? step(i + 1) : { ax: E_LOCK_VIOLATION };
                    });
                };
                return step(0);
            }
            case 0x0C: {  // DISKSPACE: AX=sec/clust BX=total CX=bytes/sec DX=free
                var d = new Uint8Array(6);
                putU16(d, 0, 16384);   // total clusters (64 MB at 4K)
                putU16(d, 2, 512);     // bytes per sector
                putU16(d, 4, 8192);    // free clusters (32 MB)
                return Promise.resolve({ ax: 8, data: d });  // 8 sectors per cluster
            }
            case 0x0E: {  // SETATTR: A path
                return fs.setattr(canonPath(text(p.subarray(1))), { attr: p[0] })
                    .then(function (ok) { return { ax: ok ? 0 : E_FILE_NOT_FOUND }; });
            }
            case 0x0F: {  // GETATTR → tt dd ssss A
                return fs.stat(canonPath(text(p))).then(function (s) {
                    if (!s.exists) { return { ax: E_FILE_NOT_FOUND }; }
                    var d = new Uint8Array(9);
                    putU16(d, 0, s.dtime & 0xFFFF);
                    putU16(d, 2, (s.dtime >>> 16) & 0xFFFF);
                    putU32(d, 4, s.size);
                    d[8] = s.attr;
                    return { ax: 0, data: d };
                });
            }
            case 0x11: {  // RENAME: L src dst
                var slen = p[0];
                var from = canonPath(text(p.subarray(1, 1 + slen)));
                var to = canonPath(text(p.subarray(1 + slen)));
                return fs.rename(from, to).then(function (ok) { return { ax: ok ? 0 : E_FILE_NOT_FOUND }; });
            }
            case 0x13:  // DELETE (wildcards allowed)
                return fs.remove(canonPath(text(p))).then(function (ok) { return { ax: ok ? 0 : E_FILE_NOT_FOUND }; });
            case 0x16:    // OPEN
            case 0x17:    // CREATE
            case 0x2E: {  // SPOPNFIL
                var stack = u16(p, 0), act = u16(p, 2), mode = u16(p, 4);
                var path = canonPath(text(p.subarray(6)));
                var openReply = function (s, rr, omode) {
                    var d = new Uint8Array(25);
                    d[0] = s.attr & 0xFF;
                    var f = fcbName(baseName(path) || "");
                    for (var i = 0; i < 11; i++) { d[1 + i] = f.charCodeAt(i); }
                    putU16(d, 12, s.dtime & 0xFFFF);
                    putU16(d, 14, (s.dtime >>> 16) & 0xFFFF);
                    putU32(d, 16, s.size);
                    putU16(d, 20, self.allocFile(path));
                    putU16(d, 22, rr);
                    d[24] = omode & 0xFF;
                    return { ax: 0, data: d };
                };
                if (al === 0x17) {  // CREATE always makes/truncates
                    return fs.create(path, stack & 0xFF, true).then(function (r) {
                        return r.ok ? openReply({ attr: r.attr, size: r.size, dtime: r.dtime }, 2, 2) : { ax: E_ACCESS_DENIED };
                    });
                }
                return fs.stat(path).then(function (s) {
                    if (al === 0x16) {  // plain OPEN
                        if (!s.exists) { return { ax: E_FILE_NOT_FOUND }; }
                        return openReply(s, 1, stack & 0x7F);
                    }
                    // SPOPNFIL: the action code decides
                    if (s.exists) {
                        if ((act & 0x02) !== 0) {  // truncate it
                            return fs.create(path, 0x20, true).then(function (r) {
                                return r.ok ? openReply({ attr: r.attr, size: 0, dtime: r.dtime }, 3, mode & 0x7F) : { ax: E_ACCESS_DENIED };
                            });
                        }
                        if ((act & 0x01) !== 0) { return openReply(s, 1, mode & 0x7F); }
                        return { ax: E_ACCESS_DENIED };
                    }
                    if ((act & 0x10) !== 0) {  // create the missing file
                        return fs.create(path, stack & 0xFF, true).then(function (r) {
                            return r.ok ? openReply({ attr: r.attr, size: 0, dtime: r.dtime }, 2, mode & 0x7F) : { ax: E_ACCESS_DENIED };
                        });
                    }
                    return { ax: E_FILE_NOT_FOUND };
                });
            }
            case 0x1B: {  // FINDFIRST: A + full path mask
                var srchAttr = p[0];
                var full = canonPath(text(p.subarray(1)));
                var mask = baseName(full);
                var dir = dirName(full);
                if ((srchAttr & 0x3F) === 0x08) {
                    // Volume-label-only search: answer with the board's label.
                    var lbl = this.answerEntry({ name: "ATDT-DOS", attr: 0x08, size: 0, dtime: dtimeNow() }, 0, 0);
                    return Promise.resolve({ ax: 0, data: lbl });
                }
                // Exact-name searches answer from anything already known
                // locally: the session overlay (IF EXIST D:\DOOR.BAT is the
                // boot path), a cached file, or a stat memo from a listing
                // (COMMAND.COM locating START.BAT and the program it names
                // both land here). DOS gives a call half a second; a wire
                // round trip must never be spent on a question the shim can
                // already answer.
                if (mask.indexOf("*") === -1 && mask.indexOf("?") === -1) {
                    var known = null;
                    var ovf = fs.overlay.get(full)
                        || fs.staticCache.get(full) || fs.sharedCache.get(full);
                    if (ovf) {
                        known = { name: mask, attr: ovf.attr, size: ovf.bytes.length, dtime: ovf.dtime };
                    } else if (fs.statCache.has(full)) {
                        var sm = fs.statCache.get(full);
                        known = { name: mask, attr: sm.attr, size: sm.size, dtime: sm.dtime };
                    }
                    if (known) {
                        var kcl = this.allocDir([known]);
                        return Promise.resolve({ ax: 0, data: this.answerEntry(known, kcl, 0) });
                    }
                }
                return fs.list(dir, mask, srchAttr).then(function (entries) {
                    if (entries === null) { return { ax: E_PATH_NOT_FOUND }; }
                    entries = entries.filter(function (e) {
                        // HID/SYS/DIR entries only when asked for (§9.2).
                        var special = e.attr & 0x1E;
                        return (special & ~srchAttr) === 0;
                    });
                    if (entries.length === 0) { return { ax: E_NO_MORE_FILES }; }
                    var cl = self.allocDir(entries);
                    return { ax: 0, data: self.answerEntry(entries[0], cl, 0) };
                });
            }
            case 0x1C: {  // FINDNEXT: CC pp A template(11)
                var cl2 = u16(p, 0), pos = u16(p, 2), attr2 = p[4];
                var tmpl = text(p.subarray(5, 16));
                var snap = this.dirs.get(cl2);
                if (!snap) { return Promise.resolve({ ax: E_NO_MORE_FILES }); }
                for (var k = pos + 1; k < snap.entries.length; k++) {
                    var e = snap.entries[k];
                    if (((e.attr & 0x1E) & ~attr2) !== 0) { continue; }
                    if (!fcbMatch(tmpl, e.name)) { continue; }
                    return Promise.resolve({ ax: 0, data: this.answerEntry(e, cl2, k) });
                }
                return Promise.resolve({ ax: E_NO_MORE_FILES });
            }
            case 0x21: {  // SEEKFROMEND: oooo SS → offset from start
                var spath = this.files.get(u16(p, 4));
                if (spath === undefined) { return Promise.resolve({ ax: E_FILE_NOT_FOUND }); }
                var back = u32(p, 0) | 0;  // signed: DX low, CX high
                return fs.stat(spath).then(function (s) {
                    if (!s.exists) { return { ax: E_FILE_NOT_FOUND }; }
                    var posn = Math.max(0, s.size + back);
                    var d = new Uint8Array(4);
                    putU32(d, 0, posn);
                    return { ax: 0, data: d };
                });
            }
            case 0x24: {  // SETFILETIMESTAMP: tt dd SS
                var tpath = this.files.get(u16(p, 4));
                if (tpath === undefined) { return Promise.resolve({ ax: E_FILE_NOT_FOUND }); }
                var dt = ((u16(p, 2) << 16) | u16(p, 0)) >>> 0;
                return fs.setattr(tpath, { dtime: dt }).then(function (ok) {
                    return { ax: ok ? 0 : E_FILE_NOT_FOUND };
                });
            }
            default:
                return Promise.resolve({ ax: E_ACCESS_DENIED });
        }
    };

    /** The shared FINDFIRST/FINDNEXT 24-byte entry answer. */
    EtherSrv.prototype.answerEntry = function (e, cluster, pos) {
        var d = new Uint8Array(24);
        d[0] = e.attr & 0xFF;
        var f = fcbName(e.name);
        for (var i = 0; i < 11; i++) { d[1 + i] = f.charCodeAt(i); }
        putU16(d, 12, e.dtime & 0xFFFF);
        putU16(d, 14, (e.dtime >>> 16) & 0xFFFF);
        putU32(d, 16, e.size);
        putU16(d, 20, cluster);
        putU16(d, 22, pos);
        return d;
    };

    // ---- the serial stream filter ------------------------------------------

    /**
     * Sits between the UART and the terminal: answers the two terminal
     * queries doors make (cursor position, device attributes) and
     * watches for the two sentinels. It does NOT intercept ANSI music
     * (1.7.4, an ATA engine finding): the music opener ESC[M is byte
     * for byte the standard delete-line control, which full-screen
     * door output emits position-dependently as it repaints - the old
     * interception read the first innocent delete-line as a tune and
     * swallowed everything after it while waiting for a ^N that never
     * comes (a serial probe counted 2320 bytes of a LORD title screen
     * going out with nothing printed, and the sentinels were eaten
     * with the rest, so the seat never tore down). There is no way to
     * tell the two apart at the stream level; the era-true fallback
     * for a terminal without music support is to print the note
     * letters as text, and doors ask the caller about ANSI music at
     * setup anyway.
     * @constructor
     */
    var SENTINELS = ["ATDT-DOOR-EOF", "ATDT-DOOR-NOSRV"];

    function StreamFilter(hooks) {
        this.hooks = hooks;        // {emit(byte), reply(string), sentinel(name)}
        this.state = "text";       // text | esc | csi
        this.buf = "";
        this.hold = "";            // partial sentinel match, held off the screen
    }

    /** Text bytes pass through a sentinel gate: a run that could still
     *  become a sentinel is held back, so the marker never paints. */
    StreamFilter.prototype.text = function (b) {
        var h = this.hooks;
        var cand = this.hold + String.fromCharCode(b);
        for (var s = 0; s < SENTINELS.length; s++) {
            if (cand === SENTINELS[s]) {
                this.hold = "";
                h.sentinel(s === 0 ? "eof" : "nosrv");
                return;
            }
        }
        var prefix = SENTINELS.some(function (sv) { return sv.indexOf(cand) === 0; });
        if (prefix) {
            this.hold = cand;
            return;
        }
        this.flushHold();
        if (b === 0x41) {          // this byte could still start a fresh match
            this.hold = "A";
            return;
        }
        h.emit(b);
    };

    StreamFilter.prototype.flushHold = function () {
        for (var i = 0; i < this.hold.length; i++) {
            this.hooks.emit(this.hold.charCodeAt(i));
        }
        this.hold = "";
    };

    StreamFilter.prototype.push = function (b) {
        var h = this.hooks;
        if (this.state === "esc") {
            if (b === 0x5B) { this.state = "csi"; this.buf = ""; return; }
            this.state = "text";
            h.emit(0x1B);
            h.emit(b);
            return;
        }
        if (this.state === "csi") {
            var ch = String.fromCharCode(b);
            if ((b >= 0x30 && b <= 0x39) || ch === ";" || ch === "?" || ch === "=" || ch === "!") {
                this.buf += ch;
                return;
            }
            // A final byte: answer the queries, forward the rest verbatim.
            this.state = "text";
            if (ch === "n" && this.buf === "6") {
                h.reply("cursor");
                return;
            }
            if (ch === "c" && (this.buf === "" || this.buf === "0")) {
                h.reply("da");
                return;
            }
            h.emit(0x1B);
            h.emit(0x5B);
            for (var i = 0; i < this.buf.length; i++) { h.emit(this.buf.charCodeAt(i)); }
            h.emit(b);
            return;
        }
        if (b === 0x1B) { this.flushHold(); this.state = "esc"; return; }
        this.text(b);
    };

    // ---- keyboard → serial -------------------------------------------------

    /** Map a KeyboardEvent to the bytes a door expects, or null. */
    function keyBytes(e) {
        var k = e.key;
        if (e.ctrlKey && !e.altKey && !e.metaKey && k.length === 1) {
            var up = k.toUpperCase();
            if (up >= "A" && up <= "Z") { return [up.charCodeAt(0) - 64]; }  // ^A..^Z
            if (k === "]") { return [0x1D]; }                                // the escape chord
            return null;
        }
        if (e.ctrlKey || e.altKey || e.metaKey) { return null; }
        if (k.length === 1) {
            var c = k.charCodeAt(0);
            return (c >= 0x20 && c <= 0x7E) ? [c] : null;   // 7-bit only on the wire
        }
        switch (k) {
            case "Enter": return [0x0D];
            case "Backspace": return [0x08];
            case "Tab": return [0x09];
            case "Escape": return [0x1B];
            case "Delete": return [0x7F];
            case "ArrowUp": return [0x1B, 0x5B, 0x41];
            case "ArrowDown": return [0x1B, 0x5B, 0x42];
            case "ArrowRight": return [0x1B, 0x5B, 0x43];
            case "ArrowLeft": return [0x1B, 0x5B, 0x44];
            case "Home": return [0x1B, 0x5B, 0x48];
            case "End": return [0x1B, 0x5B, 0x4B];
        }
        return null;
    }

    // ---- the IndexedDB static store ----------------------------------------

    var IDB_NAME = "atdt-dosdoors", IDB_STORE = "statics";

    function idbOpen() {
        return new Promise(function (resolve) {
            if (typeof indexedDB === "undefined") { resolve(null); return; }
            var req = indexedDB.open(IDB_NAME, 1);
            req.onupgradeneeded = function () {
                req.result.createObjectStore(IDB_STORE);
            };
            req.onsuccess = function () { resolve(req.result); };
            req.onerror = function () { resolve(null); };
        });
    }

    function idbLoad(db, slug) {
        return new Promise(function (resolve) {
            if (!db) { resolve([]); return; }
            try {
                var req = db.transaction(IDB_STORE).objectStore(IDB_STORE).get(slug);
                req.onsuccess = function () { resolve(req.result || []); };
                req.onerror = function () { resolve([]); };
            } catch (e) { resolve([]); }
        });
    }

    function idbSave(db, slug, entries) {
        if (!db) { return; }
        try {
            db.transaction(IDB_STORE, "readwrite").objectStore(IDB_STORE).put(entries, slug);
        } catch (e) { /* private mode etc: the cache is a luxury */ }
    }

    // ---- the SysOp's view of the door's own DOS screen ---------------------

    /**
     * With diagnostics on, a SysOp caller gets the machine's local text
     * screen mirrored in a corner overlay (§13). Everything a door
     * prints BEFORE it opens the COM port lands there and nowhere else:
     * a bad path in its batch file, a missing file, a runtime error.
     * After an abnormal exit the overlay lingers so the last screen can
     * be read; a click closes it.
     */
    function makeDebugView() {
        var cols = 80, rows = 25;
        var grid = [];
        var el = document.createElement("pre");
        el.style.cssText = "position:fixed;right:10px;bottom:10px;z-index:50;margin:0;"
            + "padding:6px 9px;background:#000;color:#4c4;border:1px solid #364;"
            + "font:12px/14px monospace;white-space:pre;opacity:.94;overflow:hidden;max-width:96vw";
        el.title = "The door's local DOS screen (diagnostics). Click to close.";
        var pending = false;
        var ended = "";
        var blank = function () {
            grid = [];
            for (var r = 0; r < rows; r++) { grid.push(new Array(cols).fill(" ")); }
        };
        blank();
        var render = function () {
            pending = false;
            var out = "--- the door's DOS screen (diagnostics)" + ended + " ---\n";
            for (var r = 0; r < rows; r++) { out += grid[r].join("") + "\n"; }
            el.textContent = out;
        };
        var queue = function () {
            if (!pending) {
                pending = true;
                (window.requestAnimationFrame || setTimeout)(render);
            }
        };
        document.body.appendChild(el);
        el.addEventListener("click", function () { el.remove(); });
        return {
            put: function (row, col, chr) {
                if (grid[row] !== undefined && col < cols) {
                    grid[row][col] = chr >= 0x20 ? cp437Char(chr) : " ";
                    queue();
                }
            },
            resize: function (c, r) {
                cols = c > 0 ? c : 80;
                rows = r > 0 ? r : 25;
                blank();
                queue();
            },
            linger: function (reason) {
                ended = ", ended: " + (reason || "?");
                el.style.borderColor = "#a55";
                render();
                setTimeout(function () { el.remove(); }, 30000);
            },
            remove: function () { el.remove(); }
        };
    }

    // ---- the prefetch ------------------------------------------------------

    /**
     * Warm a door's files before the machine exists. DOS gives a file
     * call about half a second before it fails; a real host's round
     * trip can eat most of that, so anything the boot path will read
     * must already be here when FreeDOS asks. Statics come whole
     * whatever their size; other root files (the batch file, configs,
     * the game data a door opens on startup) come too, up to half a
     * megabyte each, a couple dozen at most. Session-glob names stay
     * out: those never touch the wire. Files ride EIGHT to a request
     * (a read and a stat each), so a thirty-file door costs a handful
     * of round trips, not thirty; anything too big for its first chunk
     * falls back to the chunked single-file pull. The listing also
     * memoizes a stat of every root file, so searches and opens cost
     * nothing. Shared by the caller-side runner and the SysOp's
     * maintenance page; tick (optional) is called once per wire trip
     * to feed a progress meter.
     */
    function prefetchAll(fsv, tick) {
        var beat = function () { if (tick) { tick(); } };
        var failed = [];
        return fsv.list("", "*.*", 0x10).then(function (entries) {
            var extras = 0;
            var wants = (entries || []).filter(function (e) {
                if ((e.attr & 0x10) !== 0 || fsv.isSession(e.name)) { return false; }
                if (fsv.isStatic(e.name)) { return !fsv.staticCache.has(e.name); }
                if (e.size <= 524288 && extras < 24 && !fsv.sharedCache.has(e.name)) {
                    extras++;
                    return true;
                }
                return false;
            });
            var at = 0;
            var lane = function () {
                if (at >= wants.length) { return Promise.resolve(); }
                var batch = wants.slice(at, at + 8);
                at += 8;
                var ops = [];
                batch.forEach(function (e) {
                    // 16KB first chunks: eight to a request stays a
                    // ~175KB reply, safely under the response caps
                    // shared-host filters enforce (256KB configs are
                    // common; mod_security ships 512KB).
                    ops.push({ op: "read", path: e.name, offset: 0, length: 16383 });
                    ops.push({ op: "stat", path: e.name });
                });
                return fsv.io.batch(ops).then(function (r) {
                    var rs = (r && r.results) || [];
                    var leftovers = [];
                    if (rs.length < ops.length) {
                        // The whole reply died (a host filter, a network
                        // hiccup): the slow road still works, one file
                        // at a time in filter-sized pieces.
                        batch.forEach(function (e) { leftovers.push(e.name); });
                    } else {
                        batch.forEach(function (e, i) {
                            var rd = rs[i * 2], st = rs[i * 2 + 1];
                            if (st) { fsv.sawGen(st.gen, false); }
                            if (!rd || !rd.ok || !st || !st.ok || !st.exists) { return; }
                            if (!rd.eof) {
                                leftovers.push(e.name);   // bigger than one chunk
                                return;
                            }
                            (fsv.isStatic(e.name) ? fsv.staticCache : fsv.sharedCache)
                                .set(e.name, { bytes: b64decode(rd.data || ""), attr: st.attr, dtime: st.dtime });
                        });
                    }
                    beat();
                    var big = function (i) {
                        if (i >= leftovers.length) { return null; }
                        return fsv.fetchFile(leftovers[i]).then(function (f) {
                            if (f === null) { failed.push(leftovers[i]); }
                            beat();
                            return big(i + 1);
                        });
                    };
                    return Promise.resolve(big(0)).then(function () { return lane(); });
                });
            };
            // TWO lanes share the batch cursor: on a latency-bound host
            // the wall time roughly halves, while the in-flight reply
            // volume only doubles — deliberately modest, because some
            // shared hosts throttle on reply VOLUME per window, and a
            // wide fan-out would trip exactly the hosts the transfer
            // path is hardened against.
            // Resolves {failed: [names]} — files the wire would not
            // hand over whole even on the fallback road. The runner
            // says so out loud: a missing .EXE at boot is otherwise a
            // silent instant exit and a mystery.
            var lanes = [lane()];
            if (wants.length > 8) { lanes.push(lane()); }
            return Promise.all(lanes).then(function () { return { failed: failed }; });
        });
    }

    // ---- the runner --------------------------------------------------------

    /**
     * Boot the machine and run the door. cfg:
     *   slug     the door
     *   term     the AtdtTerm Terminal (parser, painter, transport, doorHold)
     *   base     URL prefix of dosdoors/ (default "dosdoors/")
     *   onExit   function (reason) — after teardown, before the menu key
     * Returns a handle with stop() for force teardown.
     */
    function run(cfg) {
        var term = cfg.term;
        var base = cfg.base || "dosdoors/";
        var post = function (body) { return term.transport.post(body); };
        var dead = false;
        var emulator = null;
        var pingTimer = null, throttleTimer = null, quietSince = Date.now();
        var keyHandler = null, visHandler = null;
        var dbg = null, bootAt = 0, everTyped = false;
        var escCount = 0, escLast = 0;
        var idb = null;
        var shortFetch = false;

        var say = function (text) {
            term.drainReveal();
            term.parser.feed(text);
            term.painter.repaint();
        };

        /** Everything stops here, exactly once. */
        var teardown = function (reason) {
            if (dead) { return; }
            dead = true;
            clearInterval(pingTimer);
            clearInterval(throttleTimer);
            if (keyHandler) { document.removeEventListener("keydown", keyHandler, true); }
            if (visHandler) { document.removeEventListener("visibilitychange", visHandler); }
            var finish = function () {
                if (emulator) {
                    try { emulator.destroy(); } catch (e) { /* already down */ }
                    emulator = null;
                }
                if (token) {
                    // A prefetch that came up short reframes an early
                    // "done": the batch ended because files were missing.
                    var why = (reason === "done" && shortFetch) ? "shortfetch" : reason;
                    var tok = token;
                    var sendClose = function () {
                        post({ a: "dosdoor.close", t: tok, reason: why });
                    };
                    // Write-behind still flushing (a door that saved on
                    // its way out): let it land before the seat goes.
                    if (fsv) { fsv.drainTimeout(120000).then(sendClose); } else { sendClose(); }
                }
                term.doorHold = false;
                // The door owned the parser: put the terminal back the way
                // a menu expects it (full scroll region, plain attributes).
                term.parser.top = 0;
                term.parser.bot = term.buf.rows - 1;
                term.parser.feed("\x1b[0m");
                var abnormal = false;
                if (reason === "time") {
                    say("\r\n\x1b[0m Your time in this door is up for today.\r\n");
                } else if (reason === "error" || reason === "nosrv") {
                    say("\r\n\x1b[0m The door could not start. There is a note in the SysOp log.\r\n");
                    abnormal = true;
                } else if (reason === "done" && !everTyped && bootAt > 0 && Date.now() - bootAt < 8000) {
                    // The door ended almost before it began: nearly always its
                    // batch file or program failing on the DOS side, where the
                    // caller sees nothing. Point the SysOp at the tool for it.
                    say("\r\n\x1b[0m The door ended as soon as it started. To see why, the SysOp\r\n"
                        + " can turn on diagnostics for this door (DOS Doors console, edit)\r\n"
                        + " and enter it again: its DOS screen appears in the corner.\r\n");
                    abnormal = true;
                }
                if (dbg) {
                    // Keep the last DOS screen readable after a bad exit,
                    // stamped with why the session ended.
                    if (abnormal) { dbg.linger(reason); } else { dbg.remove(); }
                    dbg = null;
                }
                // The menu screen waits in its pause step: one key repaints it.
                var sendMenuKey = function () {
                    post({ a: "key", key: " " }).then(function (res) { term.handle(res); });
                };
                if (abnormal) {
                    setTimeout(sendMenuKey, 2500);   // let the note be read first
                } else {
                    sendMenuKey();
                }
                if (cfg.onExit) { cfg.onExit(reason); }
            };
            if (reason === "escape" && emulator) {
                // Drop carrier first: a well-raised door sees NO CARRIER and
                // saves on its way out, the way a real hangup worked. The
                // machine may be idle-asleep; run it so it can notice.
                try {
                    emulator.run();
                    emulator.bus.send("serial0-carrier-detect-input", false);
                } catch (e) { /* going down anyway */ }
                setTimeout(finish, 1500);
            } else {
                finish();
            }
        };

        // ---- 1. open the seat ----
        var token = null, fsv = null, srv = null, filter = null;
        term.doorHold = true;
        post({ a: "dosdoor.open", slug: cfg.slug }).then(function (res) {
            if (!res || !res.ok || !res.session) {
                var why = (res && res.error) || "error";
                var msg = {
                    door_disabled: "This door is closed right now.",
                    acs_denied: "This door is not available at your access level.",
                    at_capacity: "Every node of this door is in use. Try again in a bit.",
                    no_time_left: "You are out of time in this door for today."
                }[why] || "The door could not start.";
                term.doorHold = false;
                say("\r\n\x1b[0m " + msg + "\r\n");
                post({ a: "key", key: " " }).then(function (r2) { term.handle(r2); });
                if (cfg.onExit) { cfg.onExit(why); }
                return;
            }
            var s = res.session;
            token = s.token;
            var manifest = s.manifest || {};

            // ---- 2. the filesystem view ----
            // A kicked line or dead session answers with frames, not results.
            var guard = function (res2) {
                if (res2 && res2.frames && res2.frames.some(function (f) { return f.t === "disconnect"; })) {
                    teardown("kicked");
                    term.handle(res2);
                    return null;
                }
                if (res2 && res2.error === "bad_token") { teardown("error"); return null; }
                return res2;
            };
            // A transport failure (a host filter eating a reply, a
            // network blip) resolves null instead of throwing: every
            // caller treats null as "that request failed" and the
            // prefetch falls back rather than dying mid-chain.
            // One shot per op used to be the rule, and on decent hosting
            // a second is never needed: but a shared host can eat a
            // request or its reply whole, and worse, throttle in bursts
            // that last whole seconds, and the guest then saw a hard DOS
            // error ("Unable to ACCESS") for a file that is fine. Every
            // op replays safely (writes carry an absolute offset, a
            // relocked range is the caller's own), so a failed transport
            // climbs a ladder of retries spanning about seven seconds
            // before the guest hears no - long enough to outlive a
            // throttle window, short enough that a truly dead line
            // still fails a hung program promptly.
            var OP_WAITS = [350, 900, 2200, 4000];
            var opTry = function (body, left) {
                return post(body).then(guard).catch(function () { return null; })
                    .then(function (res2) {
                        if (res2 !== null || left <= 0 || dead) { return res2; }
                        return new Promise(function (r2) {
                            setTimeout(r2, OP_WAITS[OP_WAITS.length - left] || 900);
                        }).then(function () { return opTry(body, left - 1); });
                    });
            };
            var io = {
                op: function (params) {
                    var body = { a: "dosfs", t: token };
                    Object.keys(params).forEach(function (k) { body[k] = params[k]; });
                    return opTry(body, OP_WAITS.length);
                },
                batch: function (ops) {
                    return opTry({ a: "dosfs", t: token, ops: ops }, OP_WAITS.length);
                }
            };
            fsv = new DoorFs(io, {
                statics: manifest.static || [],
                sessions: manifest.session || [],
                gen: s.gen
            });
            var enc = function (txt) {
                var b = new Uint8Array(txt.length);
                for (var i = 0; i < txt.length; i++) { b[i] = txt.charCodeAt(i) & 0xFF; }
                return b;
            };
            fsv.plant(s.dropfile.name, enc(s.dropfile.text));
            fsv.plant("DOOR.BAT", enc(s.doorbat));
            srv = new EtherSrv(fsv);
            say("\x1b[0m\x1b[2J\x1b[1;1H\x1b[1;30m Dialing the door node...\r\n"
                + " Fetching the game. The first entry takes the longest;\r\n"
                + " a big door can need up to half a minute. \x1b[0m");
            var tick = function () {
                term.parser.feed("\x1b[1;30m.\x1b[0m");
                term.painter.repaint();
            };

            // The machine's own downloads (emulator core, BIOS, boot
            // floppy — the biggest first-visit bytes) start NOW and
            // stream WHILE the game files prefetch; the two waits used
            // to run back to back and the caller paid for both in a
            // row. The machine holds at the starting line (autostart
            // off) until the game files are warm, because a door
            // racing its own prefetch on a slow host loses the DOS
            // patience roll on its first big read. run() fires when
            // BOTH the machine reports ready and the prefetch is done,
            // whichever lands last.
            var machineReady = false, wantBoot = false;
            var releaseBoot = function () {
                if (dead || !emulator) { return; }
                if (machineReady && wantBoot) {
                    bootAt = Date.now();
                    try { emulator.run(); } catch (e) { /* already up */ }
                }
            };
            boot(s, manifest, function () {
                machineReady = true;
                releaseBoot();
            });

            // Statics remembered from an earlier call, revalidated cheaply.
            idbOpen().then(function (db) {
                idb = db;
                return idbLoad(db, cfg.slug);
            }).then(function (remembered) {
                return fsv.seedStatics(remembered);
            }).then(function () {
                return prefetchAll(fsv, tick);
            }).then(function (pf) {
                if (pf && pf.failed && pf.failed.length > 0) {
                    // The boot still gets its chance — a door that only
                    // needed the files that DID arrive plays on — but the
                    // caller hears about it, and if the door dies young
                    // the hangup reason in the SysOp log says shortfetch
                    // instead of a mystifying plain done.
                    shortFetch = true;
                    say("\r\n\x1b[1;31m Part of the game would not transfer ("
                        + pf.failed.slice(0, 3).join(", ")
                        + (pf.failed.length > 3 ? ", ..." : "") + ").\r\n"
                        + " The door may not start. The SysOp log will say so.\x1b[0m\r\n");
                }
                say("\r\n\x1b[1;30m Booting the node...\x1b[0m\r\n");
                wantBoot = true;
                releaseBoot();
            });
        });

        // ---- 3. the machine ----
        var boot = function (s, manifest, onReady) {
            if (dead) { return; }
            var V86Ctor = (typeof V86 !== "undefined") ? V86
                : (typeof window !== "undefined" ? window.V86 : null);
            if (!V86Ctor) {
                teardown("error");
                return;
            }
            // The stamp keeps browsers off stale cached runtime files
            // after an upgrade; the floppy especially must never be old.
            var rtv = "?v=" + (s.rtv || 0);
            emulator = new V86Ctor({
                wasm_path: base + "client/v86.wasm" + rtv,
                memory_size: 16 * 1024 * 1024,
                vga_memory_size: 2 * 1024 * 1024,
                bios: { url: base + "client/seabios.bin" + rtv },
                vga_bios: { url: base + "client/vgabios.bin" + rtv },
                fda: { url: base + "boot/freedos.img" + rtv },
                boot_order: 0x321,
                // Held at the line: run() comes from releaseBoot once
                // the game files are warm AND the machine is ready.
                autostart: false,
                disable_keyboard: true,
                disable_mouse: true,
                disable_speaker: true,
                // "jit": "off" in dosdoor.json runs this door on v86's
                // interpreter: the JIT mis-negates LOOPE/LOOPNE, so a
                // Turbo Pascal door with the RTE-200 delay patch can park
                // forever in its delay loop on a fast host. Slower boot,
                // correct CPU. (1.7.4)
                disable_jit: manifest.jit === "off",
                net_device: { type: "ne2k" }
            });
            emulator.add_listener("emulator-ready", function () {
                if (onReady) { onReady(); }
            });

            // Serial out → filter → CP437 → the shared ANSI parser.
            var paintQueued = false;
            filter = new StreamFilter({
                emit: function (b) {
                    term.parser.feed(cp437Char(b));
                    if (!paintQueued) {
                        paintQueued = true;
                        (window.requestAnimationFrame || setTimeout)(function () {
                            paintQueued = false;
                            term.painter.repaint();
                        });
                    }
                },
                reply: function (kind) {
                    if (!emulator) { return; }
                    var answer = kind === "cursor"
                        ? "\x1b[" + (term.parser.y + 1) + ";" + (term.parser.x + 1) + "R"
                        : "\x1b[?1;0c";
                    sendSerial(answer.split("").map(function (c) { return c.charCodeAt(0); }));
                },
                sentinel: function (which) {
                    teardown(which === "eof" ? "done" : "nosrv");
                }
            });
            emulator.add_listener("serial0-output-byte", function (b) {
                quietSince = Date.now();
                filter.push(b);
            });

            // Diagnostics on + a SysOp at the keys: mirror the machine's
            // own text screen, the one place a door that dies before
            // opening the COM port says why.
            if (s.debug && s.sysop && typeof document !== "undefined" && document.body) {
                dbg = makeDebugView();
                emulator.add_listener("screen-set-size", function (a) {
                    if (dbg) { dbg.resize(a[0], a[1]); }
                });
                emulator.add_listener("screen-put-char", function (a) {
                    if (dbg) { dbg.put(a[0], a[1], a[2]); }
                });
            }

            // The wire: answer EDF5 queries, drop everything else. Network
            // traffic counts as activity: a machine mid-file-operation is
            // busy even when the screen and the serial line are silent,
            // and the idle sleep must never stop it there.
            emulator.add_listener("net0-send", function (data) {
                quietSince = Date.now();
                srv.handleFrame(data).then(function (reply) {
                    if (reply && emulator) {
                        emulator.bus.send("net0-receive", reply);
                        // A reply that took its time (a slow host, a retry
                        // ladder mid-climb) can land on a machine the idle
                        // sleep already stopped - delivered into a parked
                        // CPU it would wait forever. Waking is free: run()
                        // on a running machine is a no-op by design.
                        quietSince = Date.now();
                        try { emulator.run(); } catch (e) { /* coming up */ }
                    }
                });
            });

            var rtcSet = false;
            emulator.add_listener("emulator-started", function () {
                // The machine's hardware clock: v86 seeds it with UTC and
                // reads it back through UTC getters, so shifting it once
                // by the board's offset makes the DOS clock, and the DOS
                // DAY (LORD rolls its day at the DOS midnight), match the
                // board's. Once only: this event fires again on every
                // idle-sleep wake, and the clock must not jump with it.
                if (!rtcSet) {
                    rtcSet = true;
                    try {
                        emulator.v86.cpu.devices.rtc.rtc_time += (s.tzoffmin || 0) * 60000;
                    } catch (e) { /* an emulator without the knob keeps UTC */ }
                }
                // Carrier up: doors watch DCD the way callers watched dial tone.
                ["serial0-carrier-detect-input", "serial0-data-set-ready-input",
                    "serial0-clear-to-send-input"].forEach(function (ev) {
                    emulator.bus.send(ev, true);
                });
            });

            var sendSerial = function (bytes) {
                if (!emulator) { return; }
                wake();
                emulator.serial_send_bytes(0, new Uint8Array(bytes));
            };

            // ---- keys: ours alone while the door runs ----
            keyHandler = function (e) {
                if (dead) { return; }
                e.stopImmediatePropagation();
                var bytes = keyBytes(e);
                if (bytes === null) { return; }   // browser keeps its chords
                e.preventDefault();
                if (bytes.length === 1 && bytes[0] === 0x1D) {  // Ctrl+]
                    var now = Date.now();
                    escCount = (now - escLast < 2000) ? escCount + 1 : 1;
                    escLast = now;
                    if (escCount >= 3) {
                        teardown("escape");
                        return;
                    }
                    say("\x1b[s\x1b[25;1H\x1b[0;30;47m Ctrl+] again " + (3 - escCount) + " more time" + (escCount === 2 ? "" : "s") + " to hang up the door \x1b[0m\x1b[u");
                    return;
                }
                quietSince = Date.now();
                everTyped = true;
                sendSerial(bytes);
            };
            document.addEventListener("keydown", keyHandler, true);

            // ---- the idle sleep (§8.4) ----
            // A door waiting for a key is a CPU spinning on a UART poll.
            // Run it free for a minute after the last sound (ten seconds
            // in a hidden tab), then let the machine sleep whole. Any
            // keypress wakes it with an UNCONDITIONAL run(): v86's run()
            // on a running machine is a no-op by design, while stop() is
            // asynchronous, so a wake path that first asks "is it
            // running?" can lose the race and leave the machine down for
            // good. The 1.7.0 duty-cycle throttle asked exactly that
            // question every 250ms and wedged the owner's board deaf.
            var wake = function () {
                quietSince = Date.now();
                if (emulator) {
                    try { emulator.run(); } catch (e) { /* already coming up */ }
                }
            };
            // Freeze-aware: when the page thread itself stalls (GC, a
            // hidden tab's housekeeping), the machine and this timer
            // stop TOGETHER - the quiet was the thread's, not the
            // door's. A tick that arrives long after its schedule must
            // grant grace instead of stopping, because a machine parked
            // mid-file-operation right after an answer was delivered
            // has no wake left: no reply is owed, and in a hidden tab
            // no key or visibility change is coming. That parked a
            // world reset for good until a click woke it.
            var lastThrottleTick = Date.now();
            throttleTimer = setInterval(function () {
                if (!emulator || dead) { return; }
                var now = Date.now();
                var stalled = now - lastThrottleTick > 5000;
                lastThrottleTick = now;
                if (stalled) {
                    quietSince = now;
                    try { emulator.run(); } catch (e) { /* coming up */ }
                    return;
                }
                var limit = (typeof document !== "undefined" && document.hidden) ? 10000 : 60000;
                if (now - quietSince > limit) {
                    try { emulator.stop(); } catch (e) { /* already down */ }
                }
            }, 1000);
            if (typeof document !== "undefined") {
                visHandler = function () {
                    if (!document.hidden) { wake(); }
                };
                document.addEventListener("visibilitychange", visHandler);
            }

            // ---- the clock ----
            pingTimer = setInterval(function () {
                post({ a: "dosdoor.ping", t: token }).then(function (r) {
                    if (!r) { return; }
                    if (r.frames && r.frames.some(function (f) { return f.t === "disconnect"; })) {
                        teardown("kicked");
                        term.handle(r);
                        return;
                    }
                    if (r.error) { teardown("error"); return; }
                    if (typeof r.minutes === "number" && r.minutes <= 0) { teardown("time"); return; }
                    fsv.sawGen(r.gen, false);
                });
            }, 30000);

            // Remember the statics this session actually pulled.
            var saveStatics = function () {
                if (!idb) { return; }
                var entries = [];
                fsv.staticCache.forEach(function (f, p) {
                    entries.push({ path: p, bytes: f.bytes, attr: f.attr, dtime: f.dtime });
                });
                idbSave(idb, cfg.slug, entries);
            };
            setTimeout(saveStatics, 20000);   // after the door is up and read in
            var prevExit = cfg.onExit;
            cfg.onExit = function (reason) {
                saveStatics();
                if (prevExit) { prevExit(reason); }
            };
        };

        return {
            stop: function () { teardown("forced"); }
        };
    }

    return {
        run: run,
        prefetchAll: prefetchAll,
        DoorFs: DoorFs,
        EtherSrv: EtherSrv,
        StreamFilter: StreamFilter,
        cp437Char: cp437Char,
        keyBytes: keyBytes,
        fcbName: fcbName,
        fcbTemplate: fcbTemplate,
        maskMatch: maskMatch,
        canonPath: canonPath,
        bsdsum: bsdsum,
        b64encode: b64encode,
        b64decode: b64decode
    };
})();

if (typeof module !== "undefined" && module.exports) {
    module.exports = AtdtDosDoor;
}
