#!/usr/bin/env python3
r"""lcxl3-driver — make the LCXL3 speak the corpus's language.

THE PROBLEM IT SOLVES
    The v3 hardware and the 169-track corpus disagree about what a CC means, and
    only one of them is cheap to change.

    Paint (RGB + the OLED) works ONLY in DAW mode — tested, not assumed: painting
    on the MIDI port in standalone did nothing, painting on the DAW port with DAW
    mode off did nothing, and the same paint after `daw on` lit the whole surface.
    But DAW mode's CC map is FIXED, and it overlaps v2's with different meanings:

        row    v2 CC (lcxl_grid)      v3 DAW CC
        A      13-20                  13-20      <- identical
        B      29-36                  21-28
        C      49-56                  29-36      <- v2's row B numbers!
        D      77-84                  5-12
        E      41-44, 57-60           37-44
        F      73-76, 89-92           45-52

    Renumbering the corpus to match would move controls silently — row C knobs
    would answer to row B's numbers — which is the exact failure class the #94
    migration spent 169 files fixing. So we translate in software instead, and the
    corpus keeps the numbering it already agrees on.

    Also confirmed on the hardware: in DAW mode the surface reports on the DAW
    interface ONLY. Nothing arrives on the MIDI port. That is why the rig went
    deaf when the v3 arrived.

WHY ABSOLUTE MODE, FOR NOW
    The endless encoders CAN send relative deltas, but relative mode is per-row
    opt-in and every row currently reads 0 (queried, not assumed). In absolute
    mode a v3 encoder behaves exactly like a v2 pot, so playability is a pure
    renumbering job. Relative mode needs an integrator that OWNS the value — a
    real improvement (it kills pot-pickup for good) but a separate change, and it
    is where paint-by-value belongs. See `--relative` for the experiment.

WHY IT DERIVES THE MAP RATHER THAN HOLDING ONE
    `lcxl_grid.py` is the one authored table (feedback_one_parser_per_concept).
    This module reads v2 numbers OUT of it positionally, so if the grid changes,
    the translation follows instead of drifting.

WHY A VIRTUAL PORT AND A RECONCILER
    SuperCollider runs `MIDIIn.connectAll` once at boot, so a port that appears
    later is not connected. And a port resolved once is a stale binding waiting to
    happen (feedback_stale_binding_pattern — the rig's most repeated failure). So
    we publish a stable virtual port and re-assert the aconnect link on a timer,
    the same shape as tools/midi-autoconnect.sh and tools/tidal-ardour-autoroute.sh.

    Requires: sudo pacman -S python-mido python-rtmidi
"""

from __future__ import annotations

import argparse
import json
import math
import os
import pathlib
import signal
import subprocess
import sys
import time

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

import lcxl3 as L  # noqa: E402

try:
    import lcxl_grid as GRID  # noqa: E402
except ImportError:  # pragma: no cover
    GRID = None

VIRTUAL_PORT_NAME = "ParVagues LCXL3"

# In relative mode the guide shifts each encoder's CC by +64. We arm rows B and
# C ONLY (21-36 -> 85-100). Row A stays ABSOLUTE deliberately: A1-A4 are
# Ardour-learned level knobs, and integrating them from a driver-side seed of 0
# would yank a live fader toward silence on first touch — the CC77 footgun
# class. Absolute row A behaves exactly like the v2 pots did; row-A relative
# waits for Ardour value feedback (soft takeover), not for a guess.
REL_CCS = {base + 64: base for base in range(21, 37)}
REL_ROWS = (2, 3)

# Clicks absorbed at a tempo landmark before the knob moves on. 2 is enough to
# FEEL as a notch without fighting a deliberate fast sweep (which arrives as
# multi-step deltas anyway).
DETENT_TICKS = 2


def rel_delta(v: int) -> int:
    """Offset-64 relative value -> signed delta.

    MEASURED, not assumed (2026-08-29, PLN's hands on the surface): slow single
    clicks arrived as raw 63/65/66 = -1/+1/+2 in offset-64. The first decoder
    assumed 7-bit two's complement, which read +1 as -63 — direction inverted
    and every click a full-range jump. PLN felt it before the log showed it.
    """
    return v - 64


def parse_bpm(text: str) -> float:
    """The track's setcps idiom is `setcps (BPM/60/4)`. Default 120 if absent.

    Wall-clock phase at this rate is NOT Tidal's cycle position — the glow
    breathes AT the right tempo without claiming beat alignment. True phase
    needs a feed from SC; that is the known next step, not a silent assumption.
    """
    import re
    m = re.search(r"setcps\s*\(?\s*([0-9.]+)\s*/\s*60", text)
    return float(m.group(1)) if m else 120.0


REPO = pathlib.Path(__file__).resolve().parent.parent

# The editor already publishes both of these, and has for a while. The driver
# does not need a new channel, a socket or a plugin: it needs to READ.
#   current-track   one line, the loaded .tidal, rewritten on change by the HUD
#   eval-events     one JSON object per ctrl+enter: {t, path, d:[streams]}
# PLN, three times in one session — "i see still ROSE_ROUGE in title", "why dont
# i see the lcxl colors evolve as i map buttons on the new track", "probably
# this will be an opp to confirm you read current file well" — is describing one
# bug: a driver that parsed its track once, at launch, and never looked again.
TRACK_FILE = pathlib.Path(os.path.expanduser("~/.cache/parvagues/current-track"))
EVAL_FILE = pathlib.Path(os.path.expanduser("~/.cache/parvagues/eval-events.jsonl"))


def read_current_track() -> str | None:
    try:
        t = TRACK_FILE.read_text().strip()
    except OSError:
        return None
    return t or None


def resolve_track(raw: str, strict: bool = True) -> pathlib.Path | None:
    """Accept an absolute path, a repo-relative one, or a bare track name.

    The HUD publishes repo-relative ("live/midi/nova/nujazz/x.tidal"), PLN types
    whatever is shortest. Same resolution order as lcxl-leds.py so the two tools
    can never disagree about which file "the track" means.
    """
    p = pathlib.Path(raw).expanduser()
    if p.exists():
        return p
    if (REPO / raw).exists():
        return REPO / raw
    name = raw if raw.endswith(".tidal") else raw + ".tidal"
    hits = sorted((REPO / "live").rglob(name)) + sorted((REPO / "copycat").rglob(name))
    if len(hits) == 1:
        return hits[0]
    if not strict:
        # In the follow loop an unresolvable name is a shrug, not a death: the
        # editor may be sitting on a scratch buffer that is not in the repo.
        return None
    sys.exit(f"lcxl3-driver: cannot resolve track {raw!r} ({len(hits)} matches)")


def last_eval(path: pathlib.Path) -> float:
    """Timestamp of the most recent ctrl+enter on this track, 0.0 if none.

    Read from the tail: the log is append-only and grows all night, and the only
    line that matters is the last one for this file.
    """
    try:
        with EVAL_FILE.open("rb") as fh:
            fh.seek(0, os.SEEK_END)
            back = min(fh.tell(), 8192)
            fh.seek(-back, os.SEEK_END)
            lines = fh.read().decode("utf-8", "replace").splitlines()
    except OSError:
        return 0.0
    want = str(path)
    for line in reversed(lines):
        try:
            ev = json.loads(line)
        except ValueError:
            continue
        p = str(ev.get("path", ""))
        if p and (want.endswith(p) or p.endswith(want)):
            return float(ev.get("t", 0.0))
    return 0.0


def parse_tempo_knob(text: str) -> tuple[int, float, float] | None:
    """(v2 cc, lo BPM, hi BPM) when the track's tempo IS a knob.

    sept1.tidal, and now the house idiom:
        # cps ((range 60 180 "^29")/60/4)
    PLN heard this work through the driver while the OLED said "B1 unmapped" —
    the translation is track-agnostic, so the knob steered the clock even though
    the driver was still holding the PREVIOUS track's map. Which is the whole
    argument for following the track: the sound was right and every label lied.

    Units: a `range` inside a `cps` expression that is divided by 60 is in BPM
    (`/60/4` = BPM to cps at four beats a bar). Without that divisor the numbers
    ARE cps, and cps * 240 is the BPM of a four-beat bar. Guessing wrong here
    puts a factor of 240 on the screen, so it is read off the line, not assumed.
    """
    import re
    for line in text.splitlines():
        if line.lstrip().startswith("--") or "cps" not in line:
            continue
        m = re.search(r'range\s+([0-9.]+)\s+([0-9.]+)\s+"\^(\d+)"', line)
        if not m:
            continue
        lo, hi, cc = float(m.group(1)), float(m.group(2)), int(m.group(3))
        if not re.search(r"/\s*60", line):
            lo, hi = lo * 240.0, hi * 240.0
        return cc, lo, hi
    return None


def resets_cycles(text: str) -> bool:
    """Does an eval of this track's pattern block ALSO reset the clock?

    A blank line is Tidal's block separator (the editor sends the run of
    non-blank lines around the cursor — pulsar-parvagues-hud/lib/eval-publish.js
    reimplements exactly that). So when `resetCycles` shares a block with a `dN`,
    every ctrl+enter on that pattern restarts cycle 0 — which is both the reason
    PLN hears the kick retrigger on rapid re-evals, and a GIFT: it makes each
    eval a true phase anchor, so the bar countdown can be real instead of
    wall-clock guesswork.
    """
    import re
    for block in re.split(r"\n\s*\n", text):
        if "resetCycles" in block and re.search(r"^\s*d\d+\s*\$", block, re.M):
            return True
    return False


def parse_context(text: str) -> dict[int, str]:
    """cc -> what the control DOES in this track, for the OLED's line 2.

    PLN on seeing cell names there: "'A1 A2 A3' is less useful than what it is
    (sample name or effect like 'rose:4' or '# crush')". The .tidal already
    says it: knobs sit inside `# effect (... "^NN" ...)`, buttons inside
    `midiOn/midiOff "^NN" (body)`. Line-based on purpose — the paren-aware
    block traps (reference_corpus_rewrite_traps) are for REWRITERS; a display
    hint may be imperfect, it just must never lie about which cc it belongs to.
    """
    import re
    ctx: dict[int, str] = {}
    prio: dict[int, int] = {}
    for line in text.splitlines():
        if line.lstrip().startswith("--"):
            continue                    # commented-out code must not label a cc
        for m in re.finditer(r'"\^(\d+)"', line):
            cc = int(m.group(1))
            snip, rank = None, 0
            m2 = re.search(r'#\s*(\w+)\b(?=[^"]*"\^' + str(cc) + '")', line)
            if m2:
                snip, rank = "# " + m2.group(1), 3
            else:
                m3 = re.search(r'midi(On|Off)\s+"\^' + str(cc) + r'"\s*\((.+)', line)
                if m3 and m3.group(2).strip():
                    body = re.sub(r'^<\|\s*', "", m3.group(2).strip())
                    # An inline trailing comment IS the best label — it is what
                    # PLN himself called the gesture ("-- Le Delay rose!!!").
                    # This block SAID that and did not do it: the only `--` strip
                    # was anchored at the start of the body, where a trailing
                    # comment never is, so the Haskell soup always won. Putting
                    # the per-control name on the OLED is what finally showed it
                    # — C8 of piment_bresilien read `d8 # n "23")) --` when the
                    # line ends in PLN's own `-- Raise COMEON!`. Split the
                    # comment off FIRST and prefer it; the body is the fallback.
                    raw, _, tail = body.partition("--")
                    tail = tail.strip()
                    if tail:
                        snip = tail
                    else:
                        # No stage note on this line, so the body is Haskell, and
                        # `loopAt 1 . (#` names nothing on a 16-char screen. PLN
                        # asked for "the sample name or effect like 'rose:4'" —
                        # both are already in the body: the sample is its first
                        # quoted string (never a "^NN" control ref, hence the ^
                        # exclusion), the effect its first identifier outside the
                        # strings. Read them off the RAW body: the trailing strip
                        # below includes '"' in its character set and so eats the
                        # closing quote, which silently defeated a first attempt
                        # to match it afterwards.
                        q = re.search(r'"([^"^]+)"', raw)
                        quoted = q.group(1).strip() if q else ""
                        w = re.search(r"[A-Za-z_][\w']*", re.sub(r'"[^"]*"', " ", raw))
                        ident = w.group(0) if w else ""
                        numeric = quoted.replace(".", "").replace(" ", "").isdigit()
                        if quoted and not numeric:
                            snip = quoted                 # "break:18"
                        elif ident and quoted:
                            snip = f"{ident} {quoted}"    # `# n "6"` -> "n 6"
                        elif ident:
                            snip = ident
                        else:
                            snip = raw.strip().strip('"( ').rstrip('")( ')
                    if snip:
                        snip, rank = snip[:17], 2 if m3.group(1) == "On" else 1
            if not snip:
                # last resort: the line's own trailing comment — PLN's stage
                # notes ("-- Savoy 8/8 Break") beat any mechanical extraction
                m4 = re.search(r'--\s*(.+)$', line)
                if m4:
                    snip, rank = m4.group(1).strip()[:17], 1
            if snip and rank > prio.get(cc, 0):
                ctx[cc], prio[cc] = snip, rank
    return ctx


def parse_track(path: pathlib.Path) -> tuple[set[int], set[int]]:
    """(^NN control set, dN orbit set) straight out of a .tidal file.

    COMMENTED-OUT lines do not count, for the same reason `parse_context`
    already skips them: nouvelle_couleur.tidal carries a parked tempo knob,
    `-- # cps ((range 60 180 "^29")/60/4)`, and counting it lit B1 on a board
    whose oldest rule is "dark = unmapped". A control PLN has commented out does
    nothing, so it must look like nothing. Uncomment the line and the next
    follow tick (< 1 s) lights it.
    """
    import re
    text = path.read_text(errors="replace")
    live = "\n".join(l for l in text.splitlines()
                     if not l.lstrip().startswith("--"))
    ccs = {int(m) for m in re.findall(r"\^(\d+)", live)}
    orbits = {int(m) for m in re.findall(r"\bd(\d+)\b(?=\s*\$|\s*<)", text)} or              {int(m) for m in re.findall(r"\bd(\d+)\b", text)}
    return ccs, orbits

# v3 DAW-mode indices per physical row, verified against the hardware by moving
# one control per row and reading what arrived (fader1->cc5 ch16, A1->13 ch16,
# B1->21 ch16, C1->29 ch16, E1->37 ch1, F1->45 ch1).
V3_ROWS: dict[str, list[int]] = {
    "A": list(range(13, 21)),
    "B": list(range(21, 29)),
    "C": list(range(29, 37)),
    "D": list(range(5, 13)),
    "E": list(range(37, 45)),
    "F": list(range(45, 53)),
}


def build_map() -> dict[int, int]:
    """v3 DAW CC -> v2 CC, positionally, straight out of the authored grid."""
    if GRID is None:
        sys.exit("lcxl3-driver: cannot import lcxl_grid — run me from the repo's tools/")
    out: dict[int, int] = {}
    for row, v3ccs in V3_ROWS.items():
        v2ccs = GRID.ROW_CCS.get(row)
        if not v2ccs:
            sys.exit(f"lcxl3-driver: lcxl_grid has no row {row!r}")
        if len(v2ccs) != len(v3ccs):
            sys.exit(
                f"lcxl3-driver: row {row} has {len(v2ccs)} v2 cells but "
                f"{len(v3ccs)} v3 indices — the grid and the hardware disagree, "
                "which is exactly what must not be papered over"
            )
        for v3, v2 in zip(v3ccs, v2ccs):
            out[v3] = v2
    return out


def label_table(m: dict[int, int], ctx: dict[int, str], mapped: set[int],
                lang, grid, overlay_only: bool = False,
                native_all: bool = False,
                tempo: tuple[int, float, float] | None = None,
                ) -> list[tuple[int, int, str, bool]]:
    """(v3 index, v2 cc, name, native) for every control that owns a display.

    Only the 32 analogue controls appear: faders 05h-0Ch and encoders 0Dh-24h.
    The 16 buttons (25h-34h) own no per-control target, so nothing on the
    firmware side ever contests the global overlay for them.

    `native` decides who draws that control's screen. Default True — let the
    firmware auto-trigger its own display with OUR name and ITS live value; it
    tracks the value at wire speed and times the dismissal itself, which no
    host-side keep-alive can match. The exception is the three DJF knobs, whose
    readout is prose the numeric display cannot render ("HPF 3.4kHz !!", the
    heart frames at the zero detent) — for those we clear the auto bits and take
    the screen back, uncontested, on the global overlay.
    """
    out: list[tuple[int, int, str, bool]] = []
    for v3, v2 in sorted(m.items()):
        if not L.is_analogue(v3):
            continue
        is_mapped = v2 in mapped
        name = lang.control_label(v2, ctx.get(v2), is_mapped)
        role, _who = grid.CC_ROLE.get(v2, ("", 0))
        native = not overlay_only and (native_all or role != "family_filter")
        if tempo and v2 == tempo[0] and is_mapped:
            # The tempo knob joins the DJFs on OUR overlay: the firmware can
            # only draw the wire value, and "63" is not a tempo. We owe him BPM.
            name = lang.tempo_label(tempo[1], tempo[2])
            native = False
        out.append((v3, v2, name, native))
    return out


def describe_map(m: dict[int, int]) -> None:
    print(f"{'row':<4} {'v3 DAW CC':<22} -> v2 CC")
    for row, v3ccs in V3_ROWS.items():
        v2 = [m[c] for c in v3ccs]
        same = " (unchanged)" if v2 == v3ccs else ""
        print(f"{row:<4} {str(v3ccs[0]) + '-' + str(v3ccs[-1]):<22} -> "
              f"{','.join(str(x) for x in v2)}{same}")
    collisions = sorted(set(m) & set(m.values()) - {c for c in m if m[c] == c})
    if collisions:
        print(f"\nNOTE: {len(collisions)} v3 index/es also exist as v2 numbers with a")
        print("DIFFERENT meaning — translating (not renumbering) is what keeps that safe:")
        for c in collisions:
            print(f"  v3 #{c} means {L.control_name(c)}, but v2 #{c} is a different cell")


# ------------------------------------------------------------- aconnect glue ----


def _aconnect_pairs() -> str:
    try:
        return subprocess.run(
            ["aconnect", "-l"], capture_output=True, text=True, timeout=3
        ).stdout
    except Exception:
        return ""


def _clients(direction: str) -> list[tuple[str, str, str]]:
    """[(port_id, client_name, port_name)] from `aconnect -i|-o`.

    BOTH names, because they are genuinely different and each is load-bearing:
    python-rtmidi registers the CLIENT as 'RtMidiOut Client' and puts our chosen
    name on the PORT. Matching only the client name (the original code) meant the
    driver's own virtual port could never be found — it reported src=None while
    `aconnect -i` listed 'ParVagues LCXL3' plainly.
    """
    try:
        out = subprocess.run(
            ["aconnect", direction], capture_output=True, text=True, timeout=3
        ).stdout
    except Exception:
        return []
    res, cid, cname = [], None, ""
    for line in out.splitlines():
        if line.startswith("client "):
            cid = line.split()[1].rstrip(":")
            parts = line.split("'")
            cname = parts[1].strip() if len(parts) > 1 else ""
        elif cid and line.strip() and line[0] in " \t":
            tok = line.strip().split()
            if tok and tok[0].isdigit():
                parts = line.split("'")
                pname = parts[1].strip() if len(parts) > 1 else ""
                res.append((f"{cid}:{tok[0]}", cname, pname))
    return res


def _find_port(direction: str, needle: str) -> str | None:
    """First port whose CLIENT or PORT name contains needle."""
    for pid, cname, pname in _clients(direction):
        if needle in cname or needle in pname:
            return pid
    return None


def _sc_input_ports() -> list[str]:
    """SuperCollider's MIDI *destination* ports, e.g. ['130:0', ...].

    THE DIRECTION TRAP, which bit the original reconcile() silently.
        `aconnect -i` lists ports you can read FROM; `-o` lists ports you can
        write TO. SuperCollider appears in both: in0..inN are destinations,
        out0..outN are sources. Asking `-i` for "SuperCollider" returns out0
        (130:5 on this rig), and `aconnect <virt> 130:5` writes into an
        output-only port. It fails — and the original code discarded the
        CompletedProcess, then printed "reconcile: src -> dst" as if it had
        worked. Nothing was ever connected.
    """
    return [pid for pid, cname, _ in _clients("-o") if "SuperCollider" in cname]


def _sources_feeding(dst: str) -> list[tuple[str, str]]:
    """[(src_port_id, src_client_name)] currently connected INTO dst.

    Parsed off the SOURCE side ("Connecting To: ..."), the only place
    `aconnect -l` prints an edge with both of its ends.
    """
    res, cid, cname, port = [], None, "", None
    for line in _aconnect_pairs().splitlines():
        if line.startswith("client "):
            cid = line.split()[1].rstrip(":")
            parts = line.split("'")
            cname = parts[1].strip() if len(parts) > 1 else ""
            port = None
        elif cid and line.strip() and line[0] in " \t":
            tok = line.strip().split()
            if tok and tok[0].isdigit():
                port = f"{cid}:{tok[0]}"
            elif "Connecting To:" in line and port:
                for t in line.split("Connecting To:")[1].split(","):
                    if t.strip().split("[")[0].strip() == dst:
                        res.append((port, cname))
    return res


# Clients whose traffic must NOT reach SuperCollider while we translate. These
# carry the surface's RAW v3 numbers, which collide with the corpus's v2 numbers
# on 16 indices with a DIFFERENT meaning — so leaving them connected does not
# merely duplicate, it fires the WRONG control alongside the right one: row C
# knob 4 would drive ^52 (correct) and ^32 (row B's cell) at once.
RAW_CLIENTS = ("LCXL3", "Launch Control XL")
# Midi Through carries whatever midi-autoconnect's rule 1 pushes into it — the
# surface again, i.e. a second copy of the same stream. A doubled CC is
# survivable on a knob and fatal on a latch: the toggle fires twice and cancels,
# which reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",)


def prune(protect: set[str], verbose: bool = False,
          cut_thru: bool = True) -> list[str]:
    """Cut every raw path into SuperCollider, protecting `protect` port ids.

    PROTECT BY PORT ID, NOT BY NAME. python-rtmidi calls our client
    'RtMidiOut Client', so a name-based "skip our own port" test does not match
    and prune would cheerfully cut the very link it just made. Identity here is
    the ALSA port id; the names are decoration.

    WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29
        `MIDIIn.connectAll` subscribes SC to EVERY source present at boot: on
        this rig in2=Midi Through, in3=LCXL3 MIDI, in4=LCXL3 DAW. So the moment
        the driver published a correct translated stream, SC heard it ALONGSIDE
        two raw ones. reconcile() only ever ADDED links, so the driver could not
        have been correct here however right its translation was.
    """
    cut = []
    bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
    for dst in _sc_input_ports():
        for src, name in _sources_feeding(dst):
            if src in protect:
                continue
            if any(b in name for b in bad):
                subprocess.run(["aconnect", "-d", src, dst],
                               capture_output=True, timeout=3)
                cut.append(f"{src} '{name}' -> {dst}")
                if verbose:
                    print(f"  prune: cut {src} '{name}' -> {dst}")
    return cut


def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool:
    """Wire our virtual port into SC's MIDI input, then cut the raw ones.

    Order matters and is deliberate: we only prune AFTER a confirmed connect.
    Cutting the raw surface while our replacement stream is not actually
    delivering would leave PLN with a dead surface — worse than a wrong one.
    """
    src = _find_port("-i", VIRTUAL_PORT_NAME)
    dst = next(iter(_sc_input_ports()), None)      # -o, NOT -i: see note above
    if not src or not dst:
        if verbose:
            print(f"  reconcile: src={src} dst={dst} — nothing to do yet")
        return False
    r = subprocess.run(["aconnect", src, dst], capture_output=True,
                       text=True, timeout=3)
    err = (r.stderr or "").strip()
    already = "already subscribed" in err.lower()
    if r.returncode != 0 and not already:
        # Report it. A reconciler that cannot say "I failed" is decoration.
        print(f"  reconcile: FAILED {src} -> {dst}: {err or r.returncode}")
        return False
    if verbose:
        print(f"  reconcile: {src} -> {dst}" + (" (already)" if already else ""))
    prune({src}, verbose=verbose, cut_thru=cut_thru)
    return True


# -------------------------------------------------------------------- driver ----


class Driver:
    def __init__(self, args):
        self.args = args
        self.map = build_map()
        self.stats = {"in": 0, "out": 0, "dropped": 0}
        self.unmapped: dict[tuple[int, int], int] = {}
        mido = L._mido()
        self.surface = L.Surface("daw", need_input=True)
        try:
            self.virt = mido.open_output(VIRTUAL_PORT_NAME, virtual=True)
        except Exception as e:
            sys.exit(
                f"lcxl3-driver: could not create the virtual port ({e}).\n"
                "python-rtmidi with the ALSA backend is required."
            )
        self.out_channel = args.channel - 1
        self._stop = False

        # ---- the visual language (v3-only; the v2 surface keeps lcxl-leds) ----
        self.lang = None
        if args.paint:
            import lcxl3_language
            self.lang = lcxl3_language
            self.v2_to_v3 = {v2: v3 for v3, v2 in self.map.items()}
            # Driver-owned values, v2-keyed. DJF seeds at the bypass detent —
            # anything else at that position would colour the whole mix.
            import lcxl_grid
            self.grid = lcxl_grid
            self.values = {}
            for v2 in self.v2_to_v3:
                role, _ = self.grid.CC_ROLE.get(v2, ("", 0))
                self.values[v2] = 64 if role == "family_filter" else 0
            # ---- what the CURRENT track is; all of it replaced on a change ----
            self.track_path: pathlib.Path | None = None
            self.track_mtime = 0.0
            self.track_name = "no track"
            self.bpm = 120.0                  # static fallback (setcps)
            self.tempo: tuple[int, float, float] | None = None   # (cc, lo, hi)
            self.resets = False               # eval == cycle 0 on this track
            self.ctx: dict[int, str] = {}
            self.mapped: set[int] = self.lang.mapped_ccs(set(), set())
            # Bar phase is INTEGRATED, not read off the wall clock: with a tempo
            # knob the BPM changes under us, and `time.time() * bpm / 60` jumps
            # the phase every time it does — the countdown would lie exactly
            # when it matters most, mid-transition.
            self._beats = 0.0
            self._phase_t = time.time()
            self._eval_seen = 0.0
            self._last_mutes: tuple[int, ...] = ()
            self._detent_hold = 0
            self._detent_at: float | None = None
            self._detent_v = -1
            self._last_rgb: dict[int, tuple[int, int, int]] = {}
            self._last_touch = 0.0
            self._oled_lines = ("", "", "")
            self._oled_up_at = 0.0
            # Who is drawing the screen right now. The whole bug was not knowing.
            self._oled_owner = "driver"
            self._labels: list[tuple[int, int, str, bool]] = []
            self._native: dict[int, bool] = {}
            # Row-E latches. The latch NEVER lived in Tidal: midiOn/midiOff read
            # the last value received, and on the classic surface the buttons
            # themselves were hardware toggles. v3 DAW buttons are momentary, so
            # the driver owns the latch now — press flips it, release is
            # swallowed, and the LED paints the driver's state, which IS the
            # truth Tidal sees. All-off at start matches the untouched-control
            # default (orDef 0). Row F stays momentary: gestures are held, and
            # the panic chord needs four simultaneous 127s.
            self.latch: dict[int, bool] = {}
            # The lap clock: elapsed-on-this-track, shown on home row 2 so a
            # set's overstretch is visible at a glance. A track CHANGE is the
            # lap: "we could have 'lap' chronometer automatically via our track
            # change controls" — and following the editor gives us that for free.
            self.track_started = time.time()
            start = args.track or (read_current_track() if args.follow else None)
            if start:
                self.load_track(resolve_track(start), first=True)

    def start(self) -> None:
        print(f"enabling DAW mode (the only mode that paints, and the only one")
        print(f"  that reports the fixed map we translate from)")
        self.surface.daw_mode(True)
        time.sleep(0.2)
        if self.args.relative:
            for row in REL_ROWS:
                self.surface.relative(row, True)
            print("  encoder rows B+C -> RELATIVE (deltas; the driver owns the values)")
            print("  row A stays absolute: A1-A4 are Ardour's, we hold no truth for them")
        print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
        reconcile(verbose=True, cut_thru=not self.args.keep_thru)
        if self.lang:
            # Touch overlays should get out of the way fast (units of 0.1 s).
            self.surface.feature(L.FEAT_DISPLAY_TIMEOUT, 12)
            self.full_paint()
            self.oled_home()
            self.oled_labels()
            if not self.args.no_seed:
                # Seed ONLY non-Ardour knob cells: DJF to bypass, fx to 0. Never
                # buttons (a seeded gate is the mutebomb), never faders/A1-A4
                # (Ardour-learned: CC77 unsolicited goes-to-silence), never ^93.
                n = 0
                for v2, val in self.values.items():
                    if v2 in self.grid.KNOB_CCS and v2 not in self.grid.ARDOUR_CCS \
                            and v2 in self.mapped:
                        self.emit(v2, val)
                        n += 1
                print(f"  seeded {n} knob cells (DJF at bypass, fx at 0)")

    # ------------------------------------------------------------- paint ----

    def emit(self, v2: int, value: int) -> None:
        mido = L._mido()
        self.virt.send(mido.Message("control_change", channel=self.out_channel,
                                    control=v2, value=value))
        self.stats["out"] += 1

    # ------------------------------------------------- following the editor ----

    def load_track(self, path: pathlib.Path, first: bool = False) -> None:
        """(Re)read the track. Called at launch, on a track change, and on SAVE.

        The save case is not a nicety: PLN maps a button, saves, and expects the
        board to light up ("why dont i see the lcxl colors evolve as i map
        buttons on the new track?"). Re-parsing on mtime makes the surface follow
        composition, not just performance.
        """
        old, old_tempo = self.track_path, self.tempo
        changed = old is None or path != old
        text = path.read_text(errors="replace")
        track_ccs, orbits = parse_track(path)
        self.track_path = path
        try:
            self.track_mtime = path.stat().st_mtime
        except OSError:
            self.track_mtime = 0.0
        self.track_name = path.stem
        self.bpm = parse_bpm(text)
        self.tempo = parse_tempo_knob(text)
        self.resets = resets_cycles(text)
        self.ctx = parse_context(text)
        self.mapped = self.lang.mapped_ccs(track_ccs, orbits)
        tempo_note = ""
        if self.tempo:
            cc, lo, hi = self.tempo
            tempo_note = (f", TEMPO on {self.grid.label(cc)} "
                          f"{lo:g}-{hi:g} BPM (live)")
        print(f"track {'->' if changed and not first else '~'} {self.track_name}: "
              f"{len(track_ccs)} controls, orbits {sorted(orbits)}, "
              f"{len(self.ctx)} labels{tempo_note}"
              f"{', eval=cycle 0' if self.resets else ''}")
        if changed and not first:
            self.track_started = time.time()      # a new track is a new lap
            self.carry_values(old_tempo)
        # Anchor on the evals ALREADY logged for this file, so a load mid-track
        # does not re-fire an hour-old anchor as if it just happened.
        self._eval_seen = last_eval(path)
        if self.resets and self._eval_seen:
            self.anchor_phase(self._eval_seen)
        if first:
            return
        self.oled_labels(quiet=True)
        self.reassert_paint()
        self.oled_home()

    def carry_values(self, old_tempo: tuple[int, float, float] | None) -> None:
        """What survives a track change, and what goes home to its default.

        PLN's spec, verbatim (2026-08-30): "faders are real positions, but could
        we reset the knobs to 'defaults' on all effects? and maybe teh DJFs stay
        across tracks so i can do djf-and-fader-crossfaded-transitions".

        So: DJFs carry (they ARE the transition), faders are physical and cannot
        be moved by us anyway, effect knobs go to 0. Two more cells carry that he
        did not name, for reasons the sound would have taught us the hard way:

          * the TEMPO knob. Resetting it to 0 means the range floor — a 60->120
            transition would slam back to 60 the instant the new track loads,
            which is the precise opposite of the move it exists to serve. Both
            the old and the new track's tempo cell are protected, because during
            the change either one may be the knob under his thumb.
          * every BUTTON. Emitting to a gate is the mutebomb; the latch state is
            reset in the DRIVER so the LEDs stop lying and the next press means
            ON, but nothing is sent. A gate that was open stays open until he
            closes it, which is what a performer expects of a held gesture.
        """
        keep = {v2 for v2 in self.values
                if self.grid.CC_ROLE.get(v2, ("", 0))[0] == "family_filter"}
        for t in (old_tempo, self.tempo):
            if t:
                keep.add(t[0])
        reset = 0
        for v2 in sorted(self.values):
            if v2 in keep or v2 not in self.grid.KNOB_CCS \
                    or v2 in self.grid.ARDOUR_CCS:
                continue
            if self.values.get(v2, 0) == 0:
                continue
            self.values[v2] = 0
            if v2 in self.mapped:
                self.emit(v2, 0)
            reset += 1
        latches = sum(1 for on in self.latch.values() if on)
        self.latch.clear()
        for v2 in self.grid.BUTTON_CCS:
            if v2 in self.values:
                self.values[v2] = 0
        print(f"  lap reset · {reset} effect knobs -> 0 · {len(keep)} carried "
              f"(DJF + tempo) · {latches} latches cleared (no CC sent)")

    def anchor_phase(self, t_eval: float) -> None:
        """Cycle 0 was at `t_eval` — rebuild the beat count from there.

        Only valid on tracks whose pattern block contains `resetCycles` (see
        `resets_cycles`). Assumes the tempo has not moved since that eval, which
        is true of the common case (eval, then steer) and drifts gracefully
        otherwise: the next eval re-anchors.
        """
        now = time.time()
        self._beats = max(0.0, now - t_eval) * self.current_bpm() / 60.0
        self._phase_t = now

    def follow_tick(self) -> None:
        """One second's worth of "is this still the track?"."""
        if not self.args.follow:
            return
        pub = read_current_track()
        path = resolve_track(pub, strict=False) if pub else None
        if path is None:
            path = self.track_path
        if path is None:
            return
        try:
            mt = path.stat().st_mtime
        except OSError:
            return
        if path != self.track_path or mt != self.track_mtime:
            self.load_track(path)
            return
        if self.resets:
            t = last_eval(path)
            if t > self._eval_seen:
                self._eval_seen = t
                self.anchor_phase(t)

    # --------------------------------------------------------------- tempo ----

    def current_bpm(self) -> float:
        """The tempo the track is ACTUALLY at: the knob's, if it has one."""
        if self.tempo:
            cc, lo, hi = self.tempo
            return lo + (hi - lo) * self.values.get(cc, 0) / 127.0
        return self.bpm

    def tempo_step(self, cur: int, d: int) -> int:
        """One relative click on the tempo knob — with LANDMARK DETENTS.

        PLN: "i used the center notch, now ill rely on OLED". The v3 encoder has
        no ridge, but the driver owns the value, so the ridge can be software:
        a step that would cross a landmark lands ON the landmark's closest step
        instead of past it, and the next few clicks are absorbed before it moves
        on. He feels a notch at every musical tempo, not just at 12 o'clock —
        and because the driver (not the surface) holds the value, the classic
        LCXL is unaffected: there, the pot's own position is still the truth.
        """
        cc, lo, hi = self.tempo
        span = hi - lo

        def bpm(v: int) -> float:
            return lo + span * v / 127.0

        # A fast sweep is a gesture toward a destination, not a placement: it
        # passes through every notch untouched. Notches are for the last clicks,
        # which is where the ridge on the old pot actually earned its keep.
        # A fast sweep is a gesture toward a destination, not a placement: it
        # passes through every notch untouched. Notches are for the last clicks,
        # which is where the ridge on the old pot actually earned its keep.
        if abs(d) > 1:
            self._detent_hold, self._detent_at = 0, None
            return max(0, min(127, cur + d))
        # Which landmark is holding us, by identity — not "is a landmark near".
        # Two earlier versions welded the knob to the first notch it met: the
        # closest STEP to a landmark usually sits just below it, so stepping up
        # re-crosses the same landmark and snaps back. Measured: 24 consecutive
        # clicks stuck at 114.8 BPM. The notch is released when the value has
        # actually left it, and only then.
        if self._detent_at is not None and cur != self._detent_v:
            self._detent_at = None
        if d and self._detent_hold > 0 and self._detent_at is not None:
            self._detent_hold -= 1
            return cur
        target = max(0, min(127, cur + d))
        crossed = [t for t in self.lang.TEMPO_LANDMARKS
                   if min(bpm(cur), bpm(target)) < t <= max(bpm(cur), bpm(target))
                   and t != self._detent_at]
        if crossed:
            lm = crossed[0] if d > 0 else crossed[-1]
            best = min(range(128), key=lambda v: abs(bpm(v) - lm))
            self._detent_hold, self._detent_at, self._detent_v = \
                DETENT_TICKS, lm, best
            return best
        return target

    def mutes_held(self) -> tuple[int, ...]:
        """Which mute FAMILIES are down right now (1=kick, 2=percs, 3=bass+mel)."""
        out = []
        for v2 in self.values:
            role, who = self.grid.CC_ROLE.get(v2, ("", 0))
            if role == "family_mute" and self.values.get(v2, 0) > 0:
                out.append(who)
        return tuple(sorted(out))

    def beats_left(self) -> int:
        """Beats until the bar ends, 4..1 — the drop cue."""
        self.phases()
        return max(1, math.ceil(4.0 - (self._beats % 4.0)))

    def phases(self) -> tuple[float, float]:
        """(beat, bar) phase 0..1, INTEGRATED at the live BPM.

        Advancing a running total (rather than `time.time() * bpm / 60`) is what
        makes a tempo knob safe: the wall-clock form re-scales all of history
        every time the BPM moves, so the phase jumped — and the bar countdown
        would have lied exactly during a transition, the one moment it is read.
        """
        now = time.time()
        self._beats += max(0.0, now - self._phase_t) * self.current_bpm() / 60.0
        self._phase_t = now
        return self._beats % 1.0, (self._beats / 4.0) % 1.0

    def paint_cell(self, v2: int) -> None:
        v3 = self.v2_to_v3.get(v2)
        if v3 is None or 5 <= v3 <= 12:
            return          # faders have NO LEDs — painting them is a no-op lie
        mapped = v2 in self.mapped
        if v2 in self.grid.BUTTON_CCS:
            rgb = self.lang.button_colour(v2, self.values.get(v2, 0) > 0, mapped)
        else:
            beat, bar = self.phases()
            if mapped and self.tempo and v2 == self.tempo[0]:
                rgb = self.lang.tempo_colour(self.current_bpm(), beat, bar,
                                             staged=bool(self.mutes_held()))
            else:
                rgb = self.lang.ring_colour(v2, self.values.get(v2, 0), mapped,
                                            beat, bar)
        if self._last_rgb.get(v3) != rgb:
            self.surface.rgb(v3, *rgb)
            self._last_rgb[v3] = rgb

    def full_paint(self, quiet: bool = False) -> None:
        for v2 in self.v2_to_v3:
            self.paint_cell(v2)
        if not quiet:
            lit = sum(1 for c in self._last_rgb.values() if c != (0, 0, 0))
            print(f"  painted: {lit} live cells, "
                  f"{len(self._last_rgb) - lit} dark (unmapped)")

    def reassert_paint(self) -> None:
        """The device repaints its own defaults on events we cannot see (page
        flips arrive as notes; firmware layout changes announce nothing). So the
        paint is a BINDING, and bindings on this rig are re-asserted, never
        assumed — the same doctrine as every aconnect link. Cheap: 48 small
        SysEx every 2 s."""
        self._last_rgb.clear()
        self.full_paint(quiet=True)

    def tick_glow(self) -> None:
        """Repaint only the breathing cells (DJF zero mark + danger zones).
        Sine at the track's BPM — PLN: a blink "is a bit aggressive", and out of
        tempo it reads as an error. RGB is quantised by _rgb, so a repaint only
        transmits when the 7-bit colour actually moved."""
        for v2 in self.v2_to_v3:
            if v2 not in self.mapped:
                continue
            # The tempo cell breathes too — at the bar when locked on a landmark,
            # at the beat when a mute is held, which is the drop counting itself
            # in on the ring while his eyes are on the OLED.
            if (self.tempo and v2 == self.tempo[0]) \
                    or self.lang.animated(v2, self.values.get(v2, 64)):
                self.paint_cell(v2)

    def _home_detail(self) -> str:
        el = int(time.time() - self.track_started)
        return f"{self.current_bpm():.0f}BPM {el // 60}:{el % 60:02d}"

    def oled_home(self) -> None:
        t, pmt, val = self.lang.home_lines(self.track_name, self._home_detail())
        self.surface.configure_display(L.TGT_STATIONARY, 2)
        for field, line in enumerate((t, pmt, val)):
            self.surface.set_text(L.TGT_STATIONARY, field, line)
        self.surface.configure_display(L.TGT_STATIONARY, 0x7F)

    # ------------------------------------------------- the per-control screen ----

    def oled_labels(self, quiet: bool = False) -> None:
        """Teach the firmware's OWN overlay to speak corpus, once per track.

        This is the fix. Three rounds of overlay tactics lost the same race, and
        the race was unwinnable because we were playing on the wrong board: the
        firmware's value display is not the global 36h overlay we kept
        re-summoning, it is a SEPARATE display target per analogue control,
        addressed by that control's own CC index, auto-triggered on every change
        by config bit 6 (default: set). PLN moving a fader fired target 05h
        30 times a second; our 0.9 s keep-alive on 36h never stood a chance, and
        "no info at all" is exactly right — the fader's own screen has a name
        field and we had never written it.

        So stop fighting and start labelling. The firmware then draws
        "d4 # crushbus" over the value it was already drawing, which is strictly
        more than our overlay could show and needs no keep-alive at all.

        Targets are the ABSOLUTE indices, like the LEDs: a relative-mode encoder
        reports CC 85-100 but is still display target 21-36.
        """
        self._labels = label_table(
            self.map, self.ctx, self.mapped, self.lang, self.grid,
            overlay_only=self.args.oled_overlay,
            native_all=self.args.oled_native_all,
            tempo=self.tempo)
        for v3, _v2, name, native in self._labels:
            self.surface.label_control(v3, name, native=native)
            time.sleep(0.002)   # 32 SysEx back-to-back; give the parser air
        self._native = {v3: native for v3, _v2, _n, native in self._labels}
        if not quiet:
            n_native = sum(1 for *_x, nat in self._labels if nat)
            print(f"  labelled {len(self._labels)} analogue controls on their OWN "
                  f"display targets ({n_native} firmware-drawn, "
                  f"{len(self._labels) - n_native} ours)")

    def oled_touch(self, v2: int, value: int) -> None:
        """Update the temporary overlay and KEEP IT ALIVE while playing.

        Round 3's diff logic had a self-defeating stamp: _oled_up_at was
        refreshed on every push, so "stale" never became true during a sweep —
        the firmware's 1.2 s timeout dropped OUR overlay after the first
        summon, and its own native value display (which shows the raw wire
        value: "0" for a relative encoder) owned the screen. PLN saw exactly
        that. Now the bring-up is re-sent every 0.9 s of continuous activity —
        under the firmware timeout — and _oled_up_at is stamped ONLY when the
        bring-up was actually sent.
        """
        now = time.time()
        if now - self._last_touch < 0.05:
            return
        self._last_touch = now
        role, _who = self.grid.CC_ROLE.get(v2, ("", 0))
        if self.tempo and v2 == self.tempo[0]:
            # The tempo knob gets BPM, a landmark arrow, and — while a mute is
            # down — the bar countdown. PLN's move needs all three and the
            # firmware's raw 0-127 gives none of them.
            held = self.lang.mute_summary(self.mutes_held())
            lines = self.lang.tempo_lines(self.current_bpm(), held,
                                          self.beats_left() if held else None)
        elif role == "family_mute":
            # The whole held SET, not the last finger down.
            lines = self.lang.mute_lines(self.mutes_held())
        else:
            lines = self.lang.touch_lines(v2, value, self.ctx.get(v2))
        # If the firmware owns this control's screen, sending our overlay is the
        # ownership battle itself — two writers, one 128x64 panel, and the one
        # with the hardware event loop wins. Keep the ghost honest and stand down.
        v3 = self.v2_to_v3.get(v2)
        if v3 is not None and self._native.get(v3):
            self._oled_lines = lines
            self._last_cc = v2
            self._oled_up_at = now      # the screen IS up, just not by our hand
            self._oled_owner = "firmware"
            self.ghost_write()
            return
        self._oled_owner = "driver"
        summon = now - self._oled_up_at > 0.9
        if summon:
            self.surface.configure_display(L.TGT_TEMPORARY, 2)
        for field, line in enumerate(lines):
            if summon or line != self._oled_lines[field]:
                self.surface.set_text(L.TGT_TEMPORARY, field, line)
        if summon:
            self.surface.configure_display(L.TGT_TEMPORARY, 0x7F)
            self._oled_up_at = now
        self._oled_lines = lines
        self._last_cc = v2
        self.ghost_write()

    # ---------------------------------------------------------- the ghost ----
    # "can you pull state of oled?" — the protocol has NO display readback, so
    # nobody can PULL the screen. What we can do is mirror every byte WE send
    # into a ghost the shell can read. The one thing the ghost cannot show is
    # the firmware's own native value overlay — so when PLN reports content the
    # ghost does not have, that discrepancy itself is the diagnosis: it was the
    # firmware talking, not us.
    GHOST = pathlib.Path("/tmp/lcxl3-oled-ghost.txt")

    def ghost_write(self) -> None:
        try:
            age = time.time() - self._oled_up_at
            t, pmt, val = self.lang.home_lines(self.track_name, self._home_detail())
            home = (t, pmt, val)
            cc = getattr(self, "_last_cc", None)
            if age >= 1.2:
                up, lines = "STATIONARY (home, ours)", home
            elif self._oled_owner == "firmware":
                # The one case the ghost must NOT claim to mirror pixel for
                # pixel: the firmware is drawing this, from the name we gave its
                # per-control target plus its own live value. Fields 1-2 are our
                # reading of the same control, not what is on the glass.
                v3 = self.v2_to_v3.get(cc)
                name = dict((a, c) for a, _b, c, _d in self._labels).get(v3, "?")
                up = f"PER-CONTROL 0x{v3:02X} (firmware draws, name is ours)"
                lines = (name, "^ its numeric value below", "(we only set the name)")
            else:
                up, lines = "TEMPORARY 0x36 (touch overlay, ours)", self._oled_lines
            self.GHOST.write_text(
                "┌──────────────────┐  showing: %s\n" % up
                + "".join("│ %-16s │\n" % l.replace("\x1e", "<3") for l in lines)
                + "└──────────────────┘\n"
                + "overlay age %.1fs · last cc %s · home: %s\n"
                % (age, cc, " / ".join(home)))
        except OSError:
            pass

    def tick_zero_anim(self, now: float) -> None:
        """~3 fps heartbeat on the value line while a DJF rests at its zero."""
        cc = getattr(self, "_last_cc", None)
        if cc is None or now - self._oled_up_at > 1.1:
            return
        role, _ = self.grid.CC_ROLE.get(cc, ("", 0))
        v = self.values.get(cc, 64)
        if role != "family_filter" or not (61 <= v <= 67):
            return
        frame = self.lang.ZERO_FRAMES[int(now * 3) % len(self.lang.ZERO_FRAMES)]
        if frame != self._oled_lines[1]:
            self.surface.set_text(L.TGT_TEMPORARY, 1, frame)
            self._oled_lines = (self._oled_lines[0], frame, self._oled_lines[2])
            self.ghost_write()

    def stop(self) -> None:
        if self.lang and self._labels:
            # Hand the screen back the way we found it. The three DJF targets had
            # their auto-trigger bits cleared, and those are NOT restored by
            # leaving DAW mode on `--stay`; a next session (or Ardour) would find
            # three knobs that silently refuse to show a value.
            try:
                self.surface.native_overlay(True)
            except Exception:
                pass
        if self.args.relative:
            for row in REL_ROWS:
                try:
                    self.surface.relative(row, False)
                except Exception:
                    pass
        if not self.args.stay:
            try:
                self.surface.daw_mode(False)
                print("\nDAW mode -> OFF (the guide says a host should exit on quit)")
            except Exception:
                pass
        try:
            self.virt.close()
        except Exception:
            pass
        self.surface.close()

    def run(self) -> int:
        mido = L._mido()
        self.start()

        def onsig(_s, _f):
            self._stop = True

        signal.signal(signal.SIGINT, onsig)
        signal.signal(signal.SIGTERM, onsig)

        print("\ntranslating — ctrl-c to stop\n")
        last_recon = last_pulse = last_assert = last_labels = last_home = time.time()
        last_follow = time.time()
        while not self._stop:
            for msg in self.surface.inp.iter_pending():
                self.stats["in"] += 1
                if msg.type != "control_change":
                    if self.args.verbose:
                        # Page/track/mode presses arrive as notes — the invisible
                        # traffic that likely explains the surface repainting its
                        # defaults. Make it visible so next time we KNOW.
                        print(f"  [non-cc] {msg}")
                    continue
                # Feature-control traffic (ch7) is state, not a musical control.
                if msg.channel == L.CH_FEATURE:
                    if self.args.verbose:
                        print(f"  [feature] {L._describe(msg)}")
                    continue
                # Relative encoders arrive at cc+64 carrying a signed DELTA.
                # The driver owns the value: integrate, then speak ABSOLUTE v2
                # downstream — SC and the corpus never learn relative exists,
                # and neither does the classic LCXL (the BC invariant).
                if self.args.relative and msg.control in REL_CCS:
                    base = REL_CCS[msg.control]
                    v2 = self.map.get(base)
                    if v2 is None:
                        continue
                    d = rel_delta(msg.value)
                    if not self.lang:
                        value = max(0, min(127, d))
                    elif self.tempo and v2 == self.tempo[0] \
                            and not self.args.no_detents:
                        value = self.tempo_step(self.values.get(v2, 0), d)
                    else:
                        value = max(0, min(127, self.values.get(v2, 0) + d))
                    if self.lang:
                        if value == self.values.get(v2):
                            continue               # pinned at an end stop
                        self.values[v2] = value
                    self.emit(v2, value)
                    if self.lang:
                        self.paint_cell(v2)
                        self.oled_touch(v2, value)
                        if not self.args.no_echo:
                            # DAW-style feedback: the firmware's per-control
                            # display draws ITS OWN numeric value, which never
                            # moves for a relative encoder (the driver owns the
                            # value). Echo the integrated value back on the
                            # encoder's own CC so the number on row 3 is true.
                            # If this ever fights the RGB paint, --no-echo; the
                            # 2 s reassert heals any ring skirmish meanwhile.
                            self.surface.out.send(mido.Message(
                                "control_change", channel=msg.channel,
                                control=base, value=value))
                    if self.args.verbose:
                        print(f"  {L.control_name(base):<16} rel {d:+3d} "
                              f"-> v2 #{v2:<3} = {value}")
                    continue
                v2 = self.map.get(msg.control)
                if v2 is None:
                    name = L.EDGE_BUTTONS.get(msg.control)
                    if name:
                        # Known edge key, no function assigned yet (the view/rig
                        # layer design). Named in the log so the census stays done.
                        if self.args.verbose and msg.value:
                            print(f"  [edge] {name} pressed (ch{msg.channel + 1} "
                                  f"#{msg.control})")
                        continue
                    key = (msg.channel, msg.control)
                    self.unmapped[key] = self.unmapped.get(key, 0) + 1
                    self.stats["dropped"] += 1
                    if self.args.verbose:
                        print(f"  [unmapped] ch{msg.channel + 1} #{msg.control}")
                    continue
                if self.lang and v2 in self.grid.BUTTON_CCS \
                        and self.grid.CC_TO_CELL[v2][0] == "E" \
                        and not self.args.no_latch:
                    if msg.value == 0:
                        continue                      # release: a latch ignores it
                    on = not self.latch.get(v2, False)
                    self.latch[v2] = on
                    self.values[v2] = 127 if on else 0
                    self.emit(v2, self.values[v2])
                    self.paint_cell(v2)
                    self.oled_touch(v2, self.values[v2])
                    if self.args.verbose:
                        print(f"  {L.control_name(msg.control):<16} LATCH "
                              f"-> v2 #{v2:<3} = {'ON' if on else 'off'}")
                    continue
                if self.args.relative and self.lang and 21 <= msg.control <= 36:
                    # An UNSHIFTED encoder CC while relative is armed means the
                    # +64 claim is wrong for this firmware — say so, loudly.
                    print(f"  !! encoder #{msg.control} arrived UNSHIFTED in "
                          "relative mode — the +64 assumption is false, tell PLN")
                self.virt.send(
                    mido.Message(
                        "control_change",
                        channel=self.out_channel,
                        control=v2,
                        value=msg.value,
                    )
                )
                self.stats["out"] += 1
                if self.lang:
                    self.values[v2] = msg.value
                    self.paint_cell(v2)
                    self.oled_touch(v2, msg.value)
                if self.args.verbose:
                    print(
                        f"  {L.control_name(msg.control):<16} "
                        f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
                    )
            now = time.time()
            if now - last_recon > self.args.reconcile_secs:
                reconcile(verbose=self.args.verbose,
                          cut_thru=not self.args.keep_thru)
                last_recon = now
            if self.lang and now - last_pulse > 0.05:
                self.tick_glow()
                self.tick_zero_anim(now)
                last_pulse = now
            if self.lang and now - last_assert > 2.0:
                self.reassert_paint()
                last_assert = now
            if self.lang and now - last_follow > 1.0:
                self.follow_tick()
                last_follow = now
            if self.lang and now - last_home > 2.0:
                # tick the lap clock on home row 2; a field write on the
                # stationary target needs no re-summon
                self.surface.set_text(L.TGT_STATIONARY, 1, self._home_detail())
                self.ghost_write()
                last_home = now
            if self.lang and now - last_labels > 20.0:
                # Same reason `reassert_paint` exists: a DAW-mode round trip (a
                # replug, Ardour grabbing the port, the LED endpoint stalling)
                # drops everything we configured. Labels are cheap to restate —
                # 32 SysEx every 20 s — and a screen that has silently reverted
                # to "0" is indistinguishable from the bug we just fixed.
                self.oled_labels(quiet=True)
                last_labels = now
            time.sleep(0.001)

        self.stop()
        print(f"\nin={self.stats['in']} translated={self.stats['out']} "
              f"dropped={self.stats['dropped']}")
        if self.unmapped:
            print("unmapped controls seen (these are the surface's extra buttons —")
            print("Page/Track/Record/Play/Solo/Mute, unused by the corpus today):")
            for (ch, cc), n in sorted(self.unmapped.items(), key=lambda kv: -kv[1]):
                print(f"  ch{ch + 1} #{cc}  x{n}")
        return 0


def show_oled(args) -> int:
    """Offline dry run of the per-control label table. Opens no MIDI port.

    A screen is the one output we cannot read back — the protocol has no display
    query, so no amount of running proves the text is right. What CAN be checked
    without the device is the text itself: that every analogue control gets a
    name, that faders fall back to the grid's role instead of an empty ctx, and
    that nothing overflows 16 characters. That is what this prints.
    """
    import lcxl3_language as lang
    import lcxl_grid as grid
    m = build_map()
    ctx: dict[int, str] = {}
    mapped: set[int] = set()
    if args.track:
        tp = pathlib.Path(args.track)
        track_ccs, orbits = parse_track(tp)
        ctx = parse_context(tp.read_text(errors="replace"))
        mapped = lang.mapped_ccs(track_ccs, orbits)
        print(f"track {tp.stem}: {len(ctx)} context labels, orbits {sorted(orbits)}\n")
    else:
        mapped = set(m.values())
        print("no --track: every control treated as mapped\n")
    rows = label_table(m, ctx, mapped, lang, grid,
                       overlay_only=args.oled_overlay,
                       native_all=args.oled_native_all)
    print(f"{'tgt':<5} {'control':<12} {'v2':<4} {'draws':<9} name")
    over = 0
    for v3, v2, name, native in rows:
        if len(name) > 16:
            over += 1
        print(f"0x{v3:02X}  {L.control_name(v3):<12} {grid.label(v2):<4} "
              f"{'firmware' if native else 'us (0x36)':<9} {name!r}")
    print(f"\n{len(rows)} analogue targets · "
          f"{sum(1 for *_x, n in rows if n)} firmware-drawn · "
          f"{over} names over 16 chars")
    print("buttons 0x25-0x34 own no per-control target — the global overlay is")
    print("uncontested there, which is why they never lost the screen.")
    return 1 if over else 0


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="lcxl3-driver", description=__doc__.splitlines()[0])
    p.add_argument("--show-map", action="store_true", help="print the translation and exit")
    p.add_argument("--verbose", "-v", action="store_true", help="log every translation")
    p.add_argument("--channel", type=int, default=1,
                   help="output MIDI channel, 1-based (default 1; SC ignores it, "
                        "Ardour may not)")
    p.add_argument("--relative", action="store_true",
                   help="switch encoder rows to relative: the driver integrates the "
                        "deltas and OWNS the values, emitting absolute v2 CCs — "
                        "pot-pickup cannot exist. Settled with PLN 2026-08-29.")
    p.add_argument("--paint", action="store_true",
                   help="drive the RGB rings + OLED with the v3 visual language "
                        "(lcxl3_language: role hue x value lightness, diverging DJF, "
                        "touched-control context). Needs --track for the mapped set.")
    p.add_argument("--track", help=".tidal file whose ^NN set defines what is LIVE; "
                                   "everything else paints dark (dark = unmapped). "
                                   "Optional now: with --follow the driver reads "
                                   "the track the editor publishes")
    p.add_argument("--no-follow", dest="follow", action="store_false",
                   help="do NOT follow the editor's current track "
                        "(~/.cache/parvagues/current-track) or re-read it on "
                        "save; pin whatever --track said at launch")
    p.add_argument("--no-echo", action="store_true",
                   help="do not echo integrated encoder values back to the "
                        "device (the echo is what makes the firmware's numeric "
                        "display track relative encoders)")
    p.add_argument("--no-detents", action="store_true",
                   help="tempo knob sweeps smoothly instead of notching at "
                        "musical landmarks (60, 90, 120, 128, 174...)")
    p.add_argument("--no-latch", action="store_true",
                   help="row-E buttons pass through momentary instead of the "
                        "driver-owned toggle latch")
    p.add_argument("--no-seed", action="store_true",
                   help="do not emit the initial knob seeds (DJF bypass, fx 0)")
    p.add_argument("--graph", action="store_true",
                   help="print what currently feeds SuperCollider's MIDI in, and exit")
    p.add_argument("--keep-thru", action="store_true",
                   help="do NOT cut Midi Through -> SuperCollider. Only for when "
                        "something else legitimately feeds SC through it; by default "
                        "we cut it, because it is a second copy of the surface and a "
                        "doubled CC cancels a latching button.")
    p.add_argument("--show-oled", action="store_true",
                   help="print the per-control label table for --track and exit. "
                        "No port is opened — this is the offline dry run for the "
                        "one thing about a screen we cannot verify ourselves.")
    p.add_argument("--oled-overlay", action="store_true",
                   help="FALLBACK: suppress the firmware's per-control value "
                        "display everywhere (config bits 5+6 clear) and draw our "
                        "own 3-field overlay for every control, uncontested. Use "
                        "if the per-control name field turns out not to render.")
    p.add_argument("--oled-native-all", action="store_true",
                   help="let the firmware draw the DJF knobs too. Loses the "
                        "'HPF 3.4kHz !!' readout for a bare 0-127 — for the eye "
                        "test that compares the two, not for a gig.")
    p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit")
    p.add_argument("--reconcile-secs", type=float, default=5.0)
    args = p.parse_args(argv)

    if args.graph:
        ports = _sc_input_ports()
        if not ports:
            print("SuperCollider has no ALSA-seq input ports — is sclang running?")
            return 1
        bad = RAW_CLIENTS + THRU_CLIENTS
        for dst in ports:
            for src, name in _sources_feeding(dst):
                tag = ("OK (ours)" if VIRTUAL_PORT_NAME in name
                       else "WOULD CUT" if any(b in name for b in bad) else "left alone")
                print(f"  {src:<8} {name:<24} -> {dst:<8}  {tag}")
        return 0
    if args.show_map:
        describe_map(build_map())
        return 0
    if args.show_oled:
        return show_oled(args)
    return Driver(args).run()


if __name__ == "__main__":
    sys.exit(main())
