Commit ac864787 by PLN (Algolia)

feat(lcxl3): a probe that asks the device instead of us guessing

Three rounds of screen fixes were hypotheses dressed as patches: the lap clock
is frozen at 0:00 on glass while our ghost ticks, and the DJF Hz readout
"refresh maybe every 1s, not smooth at all". Both point at one unproven rule —
that a field write to an ALREADY-VISIBLE display target does not repaint
without a bring-up — and the protocol offers no display readback, so the only
instrument that can settle it is PLN's eyes, asked precise questions in order.

So: a guided probe. Each step prints an instruction, sends a minimal
distinguishing sequence, and records what he saw; the run ends with a table and
a verdict naming the surviving hypothesis, plus JSON in the scratchpad so the
answers outlive the session. --dry-run prints every byte with the hypothesis it
tests, so the whole thing is reviewable without hardware.

It distinguishes: bring-up NONE/AFTER/BEFORE/RECONFIG on the stationary target;
the same on the temporary target plus a 20/10/5-per-second rate ladder (which
directly decides the DJF smoothness fix); whether per-control targets can be
driven while untouched; which auto bit actually summons; and an arrangement
census with a SENTINEL, so an arrangement the firmware IGNORES reads as "still
the sentinel" instead of masquerading as a real layout — without that, "ignored"
and "renders like the previous one" are the same observation and the census
would invent arrangements that do not exist.

Two things it found by reading our code, before touching the device. The
targets showing both symptoms are on arrangement 2, not the 4 we describe in
our own notes (4 is only label_control's default). And CFG_BRING_UP = 0x7F is
arithmetically ARR_TRIGGER | AUTO_TOUCH | AUTO_CHANGE, so every bring-up may
RE-ARM the auto-display bits we deliberately clear on the three DJF knobs —
which would explain, mechanically, why the firmware's number keeps reappearing
over "HPF 3.4kHz !!". That became its own probe step.

Safety, since the rig is live: display and config SysEx only, enforced by an AST
check that there are zero cc/note/rgb/feature call sites plus raisers installed
over those methods at open time; it refuses to run while the driver is up
(printing the pid, the kill, and the restart line); and it restores the
arrangement the driver expects from a finally block.

NOT YET RUN ON HARDWARE — committed so the work is not parked in a scratchpad.
parent 15c2c86a
#!/usr/bin/env python3
r"""lcxl3-probe-display — WHEN does the LCXL3's OLED actually repaint?
A guided, eye-in-the-loop probe. It sends nothing but display/config SysEx, tells
PLN exactly what to look at, asks what he saw, and writes the answers to JSON so
the session that reads them is reading *observations*, not a theory.
WHY THIS FILE EXISTS
The v3 display work has now lost the same argument four times, and each loss
had the same shape: we assumed something about *when* the panel re-renders,
built on the assumption, and only found out it was false from PLN's eyes.
Round 4 (`armada/tasks/035-thirty-four-screens.md`) finally read the chapter
and found thirty-four display targets instead of two. That was a real win,
and it left one assumption still standing, untested, load-bearing, and now
contradicted by two independent symptoms.
THE STANDING ASSUMPTION, quoted from the code that depends on it —
`tools/lcxl3-driver.py`, in the 10-second home tick:
# 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())
"needs no re-summon" is a hypothesis wearing a comment's clothes
(feedback_stated_invariant_is_a_hypothesis). Nobody ever watched the glass
for eleven seconds to check it.
TWO SYMPTOMS, ONE SUSPECT
(a) The lap clock on the home screen is frozen at `0:00` on the device,
while the driver's ghost mirror (/tmp/lcxl3-oled-ghost.txt) ticks
correctly. The ghost mirrors what we SENT. So the bytes go out and the
pixels do not move. `oled_home()` sends a bring-up (`configure_display
(0x35, 0x7F)`) exactly ONCE, at startup — every subsequent lap-clock
update is a bare `set_text`.
(b) The DJ-filter Hz readout on the global temporary target (0x36) refreshes
only at the driver's ~0.9 s re-summon cadence, never between. PLN: "I see
the Hz but it refreshes maybe every 1s, not smooth at all." Look at
`oled_touch()`: between summons it sends `set_text` and nothing else. So
the one moment the display changes is the one moment a bring-up is sent.
Both symptoms are explained by a single mechanism: **a text field written to
an already-visible display target does not repaint it; the target has to be
re-summoned (config byte 0x7F, "bring up the display with its current
contents") before the new field contents reach the panel.** If that is true,
the lap clock is a one-line fix and smooth Hz is a one-line fix, and both
fixes are the same line.
But "explained by" is not "caused by", so the probe also has to be able to
come back with the other answers: that 0x35 never repaints for any pattern
we can send (a different bug entirely), or that a bring-up must come BEFORE
the write rather than after (in which case the driver's summon ordering is
backwards and always was), or that the panel repaints fine and the frozen
clock is a stale-string bug upstream in `_home_detail()`.
HYPOTHESES THIS PROBE DISTINGUISHES
H1-NONE A visible target repaints on a bare field write. (The comment in
the driver is right; the frozen clock is somebody else's bug.)
H1-AFTER A field write reaches the panel only when followed by a bring-up.
(Predicted. Fixes both symptoms with one call, in both places.)
H1-BEFORE Only a bring-up that PRECEDES the writes renders them — i.e. the
bring-up latches a frame and writes land in the next frame.
H1-NEITHER 0x35 does not animate under any of the three patterns; the
stationary target may be write-once-per-configure.
H2-RATE The fastest bring-up cadence that still renders cleanly. Decides
whether the DJF Hz readout can be smooth at all, and at what
cost in SysEx per second. Three rates, PLN's adjectives.
H3-IDLE A per-control target (0x05-0x24) can be repainted while its
control is untouched — or only the firmware's own auto-trigger
ever brings it up.
H3-BIT5 / H3-BIT6
Which suppression bit actually matters. Bit 6 = auto-display on
Change, bit 5 = auto-display on Touch (the guide says touch here
means Shift + rotate, not a capacitive sensor). The driver clears
BOTH on the three DJF knobs; if only one of them was doing the
work we are throwing away a display we could have used.
H4-ARR The arrangement census. Bits 0-4 of the config byte hold 0..31;
the guide's table documents 0 (cancel), 1 (Name + our text
value), 2 (Title + Name + our text value), 3 (Title + 8 names),
4 (Name + FIRMWARE-drawn numeric value) and 31 (trigger).
5..30 are undocumented — they may be real, or they may be
silently ignored. Two things we want out of the census:
(a) an arrangement where the HOST supplies the value string,
so the panel can read "37%" or "2.4kHz" instead of the
firmware's raw 0-127 wire value (which for a relative
encoder is a permanent, confident lie);
(b) which arrangement puts the NAME on the TOP line. Right now
our per-control line 1 renders empty and PLN, seated low,
reads rows 1-2 and cannot see row 3 at all (the screen's
bottom bezel eats it — measured from his chair 2026-08-29,
which is why `lcxl3_language.home_lines` already puts the
row that matters SECOND). An empty top row is the most
legible row on the panel, wasted.
H5-TRIGGER Is the "bring-up" byte 0x7F actually arrangement 31 (0x1F) with
bits 5 and 6 set? 0x7F == 0b1111111 says yes arithmetically. If
the firmware reads the whole byte, then every bring-up we send to
a per-control target RE-ARMS the auto-display bits we cleared —
which would silently undo the DJF suppression that
`oled_labels()` sets up. Cheap to test, nasty if true.
WHAT THIS PROBE WILL NOT DO
Send a single CC or note. Not one. The surface's CCs are wired into a LIVE
SuperDirt rig and a stray CC changes what a room hears; CC93 is the panic
kill. So the transport here is display/config SysEx only, and the Surface
object has its `cc()` and `note()` methods replaced with raisers at open
time — a bug in this file becomes a traceback, not a sound. It also touches
no ALSA connection: it opens its own output port and never prunes anything.
There is NO DISPLAY READBACK in this protocol. The device never tells us
what it is showing. That is the whole reason this file is interactive: PLN's
eyes are the only instrument, so the tool's job is to make the question
small enough that an eye can answer it.
USAGE
# review the whole probe without a device, byte for byte
python3 tools/lcxl3-probe-display.py --dry-run
# at the desk, driver stopped, reading the OLED
python3 tools/lcxl3-probe-display.py
# just the arrangement census, all 30 candidate values
python3 tools/lcxl3-probe-display.py --only 4 --full-census
Answers persist to --out (default: this session's scratchpad) as JSON.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import pathlib
import subprocess
import sys
import textwrap
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lcxl3 as L # noqa: E402
DEFAULT_OUT = ("/tmp/claude-1000/-home-pln-Work-Sound-Tidal/"
"0f7ff2b8-ccb4-40d8-b86c-5741f929f419/scratchpad/"
"display-probe-results.json")
# The per-control target the probe pokes. 0x15 == 21 == encoder row B col 1.
#
# Chosen, not defaulted-into: row B is fx-per-orbit in lcxl_grid, so it is NOT
# in ARDOUR_CCS (which is all 8 faders plus A1-A4 = the d9-d12 levels). Steps 3
# and 5 ask PLN to physically turn this knob; if the raw DAW port happens to be
# connected to something, a row-B nudge moves an effect on one orbit and not an
# Ardour fader or a track level. It also has an LED ring and it rotates, which
# a fader (targets 0x05-0x0C, no LEDs) does and does not respectively.
DEFAULT_CONTROL = 0x15
# What the driver leaves the device in, and therefore what we must hand back:
# * every per-control target at ARR_NAME_NUMVAL with bits 5+6 set —
# `Driver.stop()` calls `native_overlay(True)` for exactly this reason
# * the stationary target at arrangement 2 — `oled_home()` sends
# `configure_display(L.TGT_STATIONARY, 2)`, a bare 2, so bits 5+6 clear
DRIVER_STATIONARY_CFG = 2
DRIVER_CONTROL_ARRANGEMENT = L.ARR_NAME_NUMVAL
# ------------------------------------------------------------------- wire ----
class Wire:
"""The only thing in this file allowed to talk to the device.
Every send is logged as the full F0..F7 byte string with the hypothesis it
serves, so --dry-run is a complete, reviewable transcript and a real run
leaves the same transcript in the JSON next to PLN's answers. If a step's
result later looks wrong, the bytes that produced it are right there.
"""
def __init__(self, surface, dry: bool):
self.s = surface
self.dry = dry
self.log: list[str] = []
self._why = ""
def why(self, text: str) -> None:
"""Name the hypothesis the next few sends are testing."""
self._why = text
line = f"# {text}"
self.log.append(line)
if self.dry:
print(f" {line}")
def _emit(self, payload: list[int], note: str) -> None:
hexs = " ".join(f"{b:02X}" for b in
[0xF0, *L.HEADER[1:], *payload, 0xF7])
self.log.append(f"{hexs} {note}")
if self.dry:
print(f" {hexs} {note}")
return
self.s.sysex(*payload)
def config(self, target: int, cfg: int, note: str = "") -> None:
arr = cfg & 0x1F
bits = []
if cfg & L.CFG_AUTO_CHANGE:
bits.append("bit6/change")
if cfg & L.CFG_AUTO_TOUCH:
bits.append("bit5/touch")
if cfg == L.CFG_BRING_UP:
what = "BRING-UP (0x7F)"
elif cfg == L.CFG_CANCEL:
what = "CANCEL (0x00)"
elif arr == L.ARR_TRIGGER:
what = f"trigger arr31, {'+'.join(bits) or 'no auto bits'}"
else:
what = f"arr {arr}, {'+'.join(bits) or 'no auto bits'}"
self._emit([L.CMD_CONFIG_DISPLAY, target, cfg & 0x7F],
note or f"configure_display(0x{target:02X}) -> {what}")
def text(self, target: int, field: int, s: str, note: str = "") -> None:
data = [ord(c) & 0x7F for c in s
if 0x20 <= ord(c) <= 0x7E or 0x1B <= ord(c) <= 0x1E]
self._emit([L.CMD_SET_TEXT, target, field, *data],
note or f"set_text(0x{target:02X}, field {field}, {s!r})")
def daw_mode(self, on: bool) -> None:
self._emit([L.CMD_DAW_MODE, 0x7F if on else 0x00],
f"DAW mode -> {'ON' if on else 'OFF'} "
"(the only mode that paints or draws)")
def sleep(self, secs: float) -> None:
if self.dry:
self.log.append(f"# (wait {secs:.2f}s)")
print(f" # (wait {secs:.2f}s)")
return
time.sleep(secs)
def _disarm(surface) -> None:
"""Make the live-rig safety rule mechanical instead of aspirational.
Never send a CC or a note from this file: the surface's CC stream feeds a
live SuperDirt rig, and CC93 is gPanic. Replacing the methods means a future
edit that forgets gets a traceback at the desk instead of a sound in a room
(feedback_verify_before_claiming_safe — a negative grep is not a guarantee).
"""
def _refuse(*_a, **_k):
raise RuntimeError(
"lcxl3-probe-display tried to emit a CC or note. This probe is "
"display/config SysEx ONLY — the surface's CCs are wired into a "
"live rig and CC93 is the panic kill. Fix the caller.")
surface.cc = _refuse
surface.note = _refuse
surface.feature = _refuse
surface.palette = _refuse
surface.rgb = _refuse
# ------------------------------------------------------------------- asking ----
class Asker:
"""PLN's eyes, as an input device. Records every answer with its question."""
def __init__(self, results: list[dict], dry: bool):
self.results = results
self.dry = dry
self.aborted = False
def _read(self, prompt: str) -> str:
try:
return input(prompt).strip()
except EOFError:
print("\n(no tty — treating as skip)")
return "s"
def yn(self, step: str, hypothesis: str, question: str) -> str:
"""-> 'y' | 'n' | 'skip'. 'q' aborts the run (restore still happens).
ONE prompt, one line: the answer may carry its own note after the
letter ("n it flashed once then went blank"). A second "anything to
add?" prompt per question sounded thorough on paper and is 25 extra
Enters at the desk — and every extra prompt is a chance for PLN's
typing to drift out of step with what the screen is doing, which would
silently mis-file an observation. Cheap notes, no extra keystroke.
"""
if self.dry or self.aborted:
self._record(step, hypothesis, question, "dry-run" if self.dry
else "aborted", "")
return "skip"
while True:
raw = self._read(f" {question}\n [y/n/s(kip)/q(uit)"
" — add a comment after the letter if you like] ")
head, _, note = raw.partition(" ")
head = head.strip().lower()
if head.startswith("q"):
self.aborted = True
self._record(step, hypothesis, question, "aborted", note.strip())
return "skip"
if head[:1] in ("y", "n", "s"):
ans = {"y": "yes", "n": "no", "s": "skip"}[head[0]]
self._record(step, hypothesis, question, ans, note.strip())
return {"yes": "y", "no": "n", "skip": "skip"}[ans]
print(" y, n, s or q please (optionally followed by a comment).")
def text(self, step: str, hypothesis: str, question: str) -> str:
if self.dry or self.aborted:
self._record(step, hypothesis, question, "dry-run" if self.dry
else "aborted", "")
return ""
a = self._read(f" {question}\n > ")
if a.lower() in ("q", "quit"):
self.aborted = True
a = "aborted"
self._record(step, hypothesis, question, a, "")
return a
def _record(self, step: str, hypothesis: str, question: str,
answer: str, note: str) -> None:
self.results.append({
"step": step,
"hypothesis": hypothesis,
"question": question,
"answer": answer,
"note": note,
})
def banner(title: str, why: str) -> None:
print()
print("=" * 72)
print(title)
print("=" * 72)
# The `why` blocks open on the same line as their triple quote, so a plain
# dedent would leave line 0 flush and the rest indented. Dedent the tail
# jointly instead, which preserves the sub-bullets the census banner relies
# on to stay readable.
lines = why.strip("\n").splitlines()
head, tail = lines[0].strip(), textwrap.dedent("\n".join(lines[1:]))
print(f" {head}")
for line in tail.splitlines():
print(f" {line}".rstrip())
print()
def hold(msg: str, dry: bool) -> None:
if dry:
return
try:
input(f" {msg} ")
except EOFError:
pass
# -------------------------------------------------------------------- steps ----
def step1_stationary(w: Wire, a: Asker, args) -> None:
"""H1 — does a bare field write repaint an already-visible 0x35?
The minimal distinguishing test is a counter, because a counter separates
"did not render" from "rendered the same thing": if PLN sees 1 and then
still 1 while our log says we sent 2,3,4,5, the panel is not repainting.
One tick per second is slow enough that a human can be sure.
"""
banner("STEP 1 — the home screen (stationary target 0x35)",
"""The driver's lap clock updates row 2 with a bare set_text every 10 s
and PLN sees 0:00 forever. Three patterns, same counter. Whichever
one animates is the one the driver should be using.""")
ticks = args.ticks
# Baseline: one full configure + write + bring-up, exactly like oled_home().
w.why("H1 setup — establish a VISIBLE 0x35, the way the driver does at boot")
w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
w.text(L.TGT_STATIONARY, 0, "PROBE 1")
w.text(L.TGT_STATIONARY, 1, "COUNT 0")
w.text(L.TGT_STATIONARY, 2, "ParVagues")
w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
hold("The home screen should read PROBE 1 / COUNT 0 / ParVagues. enter...",
w.dry)
w.why("H1-NONE — bare set_text to a visible target, NO bring-up. This is "
"exactly what the driver's 10 s lap-clock tick does today.")
print(f" 1a: writing COUNT 1..{ticks} to row 2, one per second, no bring-up.")
print(" WATCH THE HOME SCREEN.")
for n in range(1, ticks + 1):
w.text(L.TGT_STATIONARY, 1, f"COUNT {n}")
w.sleep(1.0)
a.yn("1a", "H1-NONE",
f"Did row 2 count up to {ticks} (not stuck on COUNT 0)?")
w.why("H1-AFTER — set_text then bring-up. The predicted fix: the panel "
"renders the field buffer only when re-summoned.")
print(f" 1b: same counter, bring-up (0x7F) AFTER each write.")
for n in range(1, ticks + 1):
w.text(L.TGT_STATIONARY, 1, f"AFTER {n}")
w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
w.sleep(1.0)
a.yn("1b", "H1-AFTER", f"Did row 2 count AFTER 1..{ticks}?")
w.why("H1-BEFORE — bring-up then set_text. If THIS is the one that "
"animates, the summon latches a frame and writes land in the NEXT "
"one, and the driver's ordering has been backwards all along.")
print(f" 1c: same counter, bring-up BEFORE each write.")
for n in range(1, ticks + 1):
w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
w.text(L.TGT_STATIONARY, 1, f"BEFOR {n}")
w.sleep(1.0)
a.yn("1c", "H1-BEFORE", f"Did row 2 count BEFOR 1..{ticks}?")
w.why("H1 control — re-send the whole configure+write+bring-up sequence, "
"i.e. the full oled_home(). If ONLY this renders, 0x35 is "
"write-once-per-configure and the fix is a whole re-config each tick.")
for n in range(1, 4):
w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
w.text(L.TGT_STATIONARY, 0, "PROBE 1")
w.text(L.TGT_STATIONARY, 1, f"FULL {n}")
w.text(L.TGT_STATIONARY, 2, "ParVagues")
w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
w.sleep(1.0)
a.yn("1d", "H1-RECONFIG", "Did row 2 count FULL 1..3?")
def _winning_pattern(results: list[dict]) -> str:
"""Which of 2a/2b/2c actually animated 0x36 — used to pick the rate test.
Reading our own recorded answers instead of asking PLN to repeat himself.
Falls back to 'after', the predicted pattern, if nothing answered yes.
"""
got = {r["step"]: r["answer"] for r in results}
for step, name in (("2a", "none"), ("2b", "after"), ("2c", "before")):
if got.get(step) == "yes":
return name
return "after"
def step2_temporary(w: Wire, a: Asker, args) -> None:
"""H2 — same three patterns on 0x36, then how fast it can go.
Symptom (b) lives here: DJF Hz only moves at the 0.9 s re-summon. If the
bring-up is what renders, then the ceiling on smoothness is "how often can
we bring up 0x36 before the panel or the SysEx parser complains" — and that
number is what the driver's DJF path should be built around. Three rates,
because "smooth" is PLN's word and only PLN can say it.
"""
banner("STEP 2 — the touch overlay (temporary target 0x36)",
"""The DJF Hz readout refreshes once a second and PLN wants it smooth.
Same three patterns first, then a rate ladder to find the ceiling.""")
ticks = args.ticks
w.why("H2 setup — bring 0x36 up the way oled_touch() does (arrangement 2, "
"three fields: title / param / value)")
w.config(L.TGT_TEMPORARY, 2)
w.text(L.TGT_TEMPORARY, 0, "PROBE 2")
w.text(L.TGT_TEMPORARY, 1, "COUNT 0")
w.text(L.TGT_TEMPORARY, 2, "0x36")
w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
hold("The overlay should read PROBE 2 / COUNT 0 / 0x36. enter...", w.dry)
w.why("H2-NONE — bare set_text between summons: precisely the driver's "
"inter-summon path, and the suspect for 'not smooth at all'")
for n in range(1, ticks + 1):
w.text(L.TGT_TEMPORARY, 1, f"COUNT {n}")
w.sleep(1.0)
a.yn("2a", "H2-NONE",
f"Did the overlay's row 2 count up to {ticks} with no bring-up?")
w.why("H2-AFTER — set_text then bring-up")
for n in range(1, ticks + 1):
w.text(L.TGT_TEMPORARY, 1, f"AFTER {n}")
w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
w.sleep(1.0)
a.yn("2b", "H2-AFTER", f"Did it count AFTER 1..{ticks}?")
w.why("H2-BEFORE — bring-up then set_text")
for n in range(1, ticks + 1):
w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
w.text(L.TGT_TEMPORARY, 1, f"BEFOR {n}")
w.sleep(1.0)
a.yn("2c", "H2-BEFORE", f"Did it count BEFOR 1..{ticks}?")
# --- the rate ladder -----------------------------------------------------
pattern = _winning_pattern(a.results)
print()
print(f" rate ladder using the '{pattern}' pattern "
f"({'from your answers above' if not w.dry else 'dry-run default'}).")
print(" Each rate sweeps a fake Hz readout so you can judge MOTION, not")
print(" just change — a smooth sweep and a laggy one look identical if the")
print(" numbers are random. Values follow the real djf_readout shape.")
for hz_rate, secs, label in ((20.0, args.rate_secs, "~20/s"),
(10.0, args.rate_secs, "~10/s"),
(5.0, args.rate_secs, "~5/s")):
step = f"2-{int(hz_rate)}"
w.why(f"H2-RATE {label} — is this cadence smooth, flickery, or laggy? "
f"({int(hz_rate)} bring-ups/s = {int(hz_rate)} config + "
f"{int(hz_rate)} text SysEx per second)")
print()
print(f" {label}: sweeping for {secs:g}s. WATCH THE OVERLAY.")
n_frames = max(1, int(hz_rate * secs))
for i in range(n_frames):
# A believable DJF readout: LPF sweeping 20 Hz -> 20 kHz and back,
# the same prose lcxl3_language.djf_readout() produces.
frac = abs(((i / max(1, n_frames - 1)) * 2.0) - 1.0)
khz = 0.02 + frac * 19.98
val = f"LPF {khz:.2f}kHz" if khz >= 1 else f"LPF {khz * 1000:.0f}Hz"
if pattern == "before":
w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
w.text(L.TGT_TEMPORARY, 1, val)
elif pattern == "after":
w.text(L.TGT_TEMPORARY, 1, val)
w.config(L.TGT_TEMPORARY, L.CFG_BRING_UP)
else:
w.text(L.TGT_TEMPORARY, 1, val)
w.sleep(1.0 / hz_rate)
a.text(step, f"H2-RATE {label}",
f"At {label}: smooth / flickery / laggy / blank / garbled? "
"(one line, your words)")
w.why("H2 teardown — dismiss our overlay so it does not sit on the panel")
w.config(L.TGT_TEMPORARY, L.CFG_CANCEL)
def step3_percontrol(w: Wire, a: Asker, args) -> None:
"""H3 — can we repaint a per-control target ourselves, and which auto bit
is doing the suppression work?
Round 4 concluded "stop fighting, start labelling" and that was right, but
it left the per-control targets as something only the FIRMWARE can bring up.
If we can summon one ourselves, a lot becomes possible (a knob's screen can
show a value we computed rather than the wire value). And the driver clears
bits 5 AND 6 on the three DJF knobs on the theory that both matter; if only
one does, we are suppressing a display we could have kept.
"""
idx = args.control
name = L.control_name(idx)
banner(f"STEP 3 — one control's own display (target 0x{idx:02X}, {name})",
f"""Every fader and encoder owns a display target addressed by its own
DAW-mode CC index. Steps here ask you to TURN {name} — chosen
because row B is fx-per-orbit, not one of Ardour's faders or
levels, so a nudge is the least consequential thing on the
surface. Turn it gently and only when asked.""")
w.why(f"H3-IDLE — arrangement 2 with BOTH auto bits CLEAR, three fields, "
f"no bring-up, control untouched. Does a per-control target render "
f"purely because we wrote to it?")
w.config(idx, 2)
w.text(idx, 0, "PROBE3 A")
w.text(idx, 1, "IDLE NOBRINGUP")
w.text(idx, 2, "row3")
w.sleep(1.5)
a.yn("3a", "H3-IDLE",
f"WITHOUT touching {name}: did the screen show PROBE3 A / IDLE...?")
w.why("H3-IDLE+BRINGUP — same, then a bring-up. If this renders, we can "
"drive a knob's own screen on our schedule, untouched.")
w.text(idx, 1, "IDLE BRINGUP")
w.config(idx, L.CFG_BRING_UP)
w.sleep(1.5)
a.yn("3b", "H3-IDLE-BRINGUP",
f"Still not touching {name}: did it show IDLE BRINGUP?")
w.why("H3-BIT6 — bit 6 only (auto-display on CHANGE). Turning the control "
"should summon its screen; the question is whether OUR text is what "
"comes up, or the firmware's own arrangement.")
w.config(idx, 2 | L.CFG_AUTO_CHANGE)
w.text(idx, 0, "PROBE3 B")
w.text(idx, 1, "BIT6 CHANGE")
w.text(idx, 2, "row3")
hold(f"Now TURN {name} a little, read the screen, then press enter...", w.dry)
a.yn("3c", "H3-BIT6",
"On turning it, did the screen show PROBE3 B / BIT6 CHANGE "
"(rather than a bare number)?")
w.why("H3-BIT5 — bit 5 only (auto-display on TOUCH). The guide glosses "
"touch as 'this is the Shift + rotate', so that is the gesture.")
w.config(idx, 2 | L.CFG_AUTO_TOUCH)
w.text(idx, 0, "PROBE3 C")
w.text(idx, 1, "BIT5 TOUCH")
w.text(idx, 2, "row3")
hold(f"Now hold SHIFT and rotate {name}, then press enter...", w.dry)
a.yn("3d", "H3-BIT5",
"On Shift+rotate, did the screen show PROBE3 C / BIT5 TOUCH?")
w.why("H3-SUPPRESS — both bits CLEAR. This is the state oled_labels() "
"puts the three DJF knobs in. Does the firmware really stay quiet?")
w.config(idx, 2)
hold(f"Turn {name} again (and try Shift+rotate), then press enter...", w.dry)
a.yn("3e", "H3-SUPPRESS",
f"With both bits clear, did the panel stay QUIET when you moved {name} "
"(no screen summoned at all)?")
def _arrangement_list(args) -> list[int]:
"""Which arrangement values to census.
Honest about the limits: the config byte's bits 0-4 hold 0..31, 0 is
documented as cancel and 31 as trigger, so 1..30 are the candidate
arrangements. The guide's table documents 1, 2, 3 and 4. Nothing tells us
whether 5..30 are real, aliases, or ignored — and "ignored" is the nasty
case, because an ignored write leaves the PREVIOUS arrangement on screen and
looks exactly like "this arrangement renders the same as the last one". The
sentinel in the census step exists to make that case visible.
"""
if args.arrangements:
out: list[int] = []
for part in args.arrangements.split(","):
part = part.strip()
if "-" in part:
lo, hi = part.split("-", 1)
out.extend(range(int(lo), int(hi) + 1))
elif part:
out.append(int(part))
return [n for n in out if 1 <= n <= 30]
if args.full_census:
return list(range(1, 31))
return [1, 2, 3, 4, 5, 6, 7, 8]
def step4_census(w: Wire, a: Asker, args) -> None:
"""H4 — the arrangement census. The highest-value question in the file.
For each candidate arrangement: set it, write distinguishable strings F0..Fn
into every field, bring it up, and have PLN describe the LAYOUT. `F0`..`F8`
are deliberately the field INDICES, so "top line says F1" is a decoded
answer, not a guess.
THE SENTINEL. Before each candidate we set a known arrangement (1) with a
loud, unmistakable string. If the candidate value is ignored by the
firmware, PLN sees arrangement 1's layout still holding the sentinel text —
which is distinguishable from a real arrangement, because a real one gets
the F0..Fn strings written over it. Without this, "ignored" and "renders
identically to the previous one" are the same observation and the census
would quietly invent arrangements that do not exist.
"""
idx = args.control
name = L.control_name(idx)
arrs = _arrangement_list(args)
fields = args.fields
banner("STEP 4 — the ARRANGEMENT CENSUS",
f"""Target 0x{idx:02X} ({name}). {len(arrs)} arrangement(s):
{', '.join(str(n) for n in arrs)}.
Fields written: F0..F{fields - 1} (the string IS its field index).
Two things we are hunting:
(a) an arrangement where WE supply the value string, so a knob
can read "37%" or "2.4kHz" instead of the firmware's raw
0-127 (which for a relative encoder never moves at all);
(b) an arrangement that puts the NAME on the TOP line. Our
per-control line 1 renders empty today and you cannot see
row 3 from your chair, so an empty top row is the most
legible row on the panel, wasted.
For each one, say: how many lines, which Fn is on which line,
big or small text, and whether a firmware-drawn NUMBER is there.
If the screen looks blank, nudge {name} — the auto bits are set.
Answer 'q' at any point to stop the census early.""")
for arr in arrs:
if a.aborted:
break
# The sentinel must be a DIFFERENT arrangement from the candidate, or
# it proves nothing — for arrangement 1 we sentinel with 4 instead.
sent = 4 if arr == 1 else 1
w.why(f"H4-ARR sentinel — arrangement {sent} with a loud marker, so "
f"that an IGNORED arrangement {arr} is visible as 'still the "
f"sentinel' rather than passing as a real layout")
w.config(idx, sent | L.CFG_AUTO_BOTH)
for f in range(fields):
w.text(idx, f, "SENTINEL" if f == 0 else f"s{f}")
w.config(idx, L.CFG_BRING_UP)
w.sleep(0.4)
w.why(f"H4-ARR {arr} — set arrangement {arr} (auto bits set), write "
f"F0..F{fields - 1}, bring up, and READ IT")
w.config(idx, (arr & 0x1F) | L.CFG_AUTO_BOTH)
for f in range(fields):
w.text(idx, f, f"F{f}")
w.config(idx, L.CFG_BRING_UP)
w.sleep(0.3)
print()
print(f" --- arrangement {arr} ---")
seen = a.text(f"4-arr{arr}", f"H4-ARR {arr}",
f"arrangement {arr}: what do you see? (lines top->bottom, "
"which Fn where, text size, 'SENTINEL' if unchanged)")
if a.aborted:
break
if seen.strip().lower() in ("s", "skip"):
continue
a.yn(f"4-num{arr}", f"H4-ARR {arr} numeric",
f"arrangement {arr}: is a firmware-drawn NUMBER (a bare 0-127) "
"on the screen?")
def step5_trigger(w: Wire, a: Asker, args) -> None:
"""H5 — is 0x7F a bring-up, or arrangement 31 plus both auto bits?
0x7F == 0b1111111: bits 0-4 = 0x1F = 31 = ARR_TRIGGER, bit 5 set, bit 6 set.
lcxl3.py names it CFG_BRING_UP and quotes the guide's "bring up the display
with its current contents", and the driver sends it to 0x36 constantly. But
if the firmware reads the WHOLE byte, then any bring-up sent to a
PER-CONTROL target also re-arms the two auto bits — silently undoing the
suppression `oled_labels()` sets on the three DJF knobs, whose whole point
is that the firmware must not draw a number over our "HPF 3.4kHz !!".
Two sub-tests, both cheap:
5a trigger with the auto bits CLEAR (0x1F). If the panel comes up, the
trigger works on its own and 0x7F's high bits are just noise we have
been sending — or they are a bit-set side effect, which 5b catches.
5b send the real 0x7F, then move the control. If it now auto-summons
where 3e said it stayed quiet, the bring-up re-armed the bits and the
driver has a latent DJF regression it re-arms every 0.9 s.
"""
idx = args.control
name = L.control_name(idx)
banner("STEP 5 — is a 'bring-up' secretly arrangement 31 + both auto bits?",
"""0x7F = arr 31 | bit5 | bit6, arithmetically. If the firmware reads
the whole byte, every bring-up re-arms the auto-display bits we
cleared — which would silently undo the DJF suppression.""")
w.why("H5-TRIGGER — arrangement 2, auto bits CLEAR, then trigger with "
"0x1F (arr 31, no auto bits). Does a bare trigger render?")
w.config(idx, 2)
w.text(idx, 0, "PROBE5 A")
w.text(idx, 1, "TRIG 0x1F")
w.text(idx, 2, "row3")
w.config(idx, L.ARR_TRIGGER)
w.sleep(1.5)
a.yn("5a", "H5-TRIGGER",
f"Without touching {name}: did the screen show PROBE5 A / TRIG 0x1F?")
w.why("H5-REARM — now the real 0x7F, then move the control. If it summons, "
"0x7F set bits 5+6 and every driver bring-up re-arms them.")
w.text(idx, 1, "TRIG 0x7F")
w.config(idx, L.CFG_BRING_UP)
w.sleep(0.5)
hold(f"Now turn {name} gently, then press enter...", w.dry)
a.yn("5b", "H5-REARM",
f"After the 0x7F, does turning {name} SUMMON a screen again "
"(it stayed quiet in step 3e with the bits clear)?")
# ------------------------------------------------------------------ verdict ----
def verdict(results: list[dict]) -> list[str]:
"""One paragraph naming which hypothesis survived, in the shape the parent
session needs: what to change in the driver, and where."""
got = {r["step"]: r["answer"] for r in results}
out: list[str] = []
def y(step: str) -> bool:
return got.get(step) == "yes"
def n(step: str) -> bool:
return got.get(step) == "no"
ran = {r["step"] for r in results}
# --- H1 -----------------------------------------------------------------
if not ran & {"1a", "1b", "1c", "1d"}:
h1 = ""
elif y("1a"):
h1 = ("H1-NONE survives: a bare set_text DOES repaint a visible 0x35. "
"The driver's comment was right and the frozen lap clock is a "
"different bug — look upstream at _home_detail() / track_started, "
"or at whether the 10 s branch runs at all "
"(lcxl3-driver.py:881-886).")
elif n("1a") and y("1b"):
h1 = ("H1-AFTER survives: a field write reaches the panel only when a "
"bring-up FOLLOWS it. Both symptoms are the same one-line fix — "
"add configure_display(TGT_STATIONARY, 0x7F) after the lap-clock "
"set_text (lcxl3-driver.py:884), and stop gating the 0x36 "
"bring-up on the 0.9 s summon timer in oled_touch().")
elif n("1a") and n("1b") and y("1c"):
h1 = ("H1-BEFORE survives: only a bring-up that PRECEDES the writes "
"renders them — the summon latches a frame and writes land in the "
"next one. oled_touch()'s ordering (config, texts, config) has "
"been half-right by accident; the lap clock needs a bring-up "
"before its set_text, and the DJF path needs its summon moved.")
elif n("1a") and n("1b") and n("1c") and y("1d"):
h1 = ("H1-RECONFIG survives: 0x35 renders only on a full "
"configure+write+bring-up cycle. The stationary target is "
"effectively write-once-per-configure, so the lap-clock tick has "
"to call the whole oled_home(), not a single set_text.")
elif n("1a") and n("1b") and n("1c") and n("1d"):
h1 = ("H1-NEITHER: nothing animated 0x35. Every pattern we can send "
"failed, so the stationary target is not the place for a live "
"clock at all — next suspects are the display-timeout feature "
"(FEAT_DISPLAY_TIMEOUT, which the driver sets to 1.2 s and which "
"may be dismissing 0x35), or a per-target write rate limit.")
else:
h1 = ("H1 undecided (step 1 was partial) — the stationary target's "
"repaint rule is still an open question.")
if h1:
out.append(h1)
# --- H2 -----------------------------------------------------------------
rates = [(r["hypothesis"], r["answer"]) for r in results
if r["step"].startswith("2-")]
if rates:
out.append("DJF smoothness, in PLN's words: "
+ "; ".join(f"{h.split()[-1]} -> {ans!r}" for h, ans in rates)
+ ". Pick the fastest rate he did not call flickery or "
"garbled and make that the DJF readout's cadence.")
if y("2a"):
out.append("0x36 differs from 0x35: it repaints on a bare set_text, so "
"the DJF lag is the driver's own diff/summon gating, not the "
"protocol.")
# --- H3 -----------------------------------------------------------------
if y("3b") and n("3a"):
out.append("H3-IDLE-BRINGUP: a per-control target CAN be driven while "
"its control is untouched, as long as we bring it up. That "
"unlocks host-computed values on a knob's own screen.")
elif y("3a"):
out.append("H3-IDLE: per-control targets repaint on a bare write, "
"untouched — the strongest possible result, and it means the "
"0x36 overlay is optional for knob feedback.")
elif n("3a") and n("3b"):
out.append("Per-control targets are firmware-summoned only; labelling "
"(round 4's fix) really is the whole available surface there.")
if y("3c") and n("3d"):
out.append("Only bit 6 (change) summons on this firmware — clearing "
"bit 5 as well on the DJF knobs is harmless but pointless.")
if y("3d") and n("3c"):
out.append("Only bit 5 (Shift+rotate) summons — bit 6 is not what has "
"been stealing the screen, and the round-4 story needs "
"amending.")
if n("3e"):
out.append("!! Clearing both bits did NOT silence the firmware: the DJF "
"suppression in oled_labels() does not actually work, and "
"any prose readout on those three knobs is still contested.")
# --- H4 -----------------------------------------------------------------
census = [r for r in results if r["step"].startswith("4-arr")
and r["answer"] not in ("dry-run", "aborted", "", "skip")]
if census:
out.append(f"Arrangement census: {len(census)} value(s) described — see "
"the table. Read them for (a) any arrangement whose value "
"line carries OUR string rather than a firmware number, and "
"(b) whichever one puts the name on the TOP line; that pair "
"is the per-control layout the driver should switch to. Any "
"answer that still reads SENTINEL means the firmware IGNORED "
"that arrangement value — not a layout, an absence.")
# --- H5 -----------------------------------------------------------------
if y("5b") and y("3e"):
out.append("!! H5-REARM confirmed: sending 0x7F re-arms the auto-display "
"bits. Every bring-up undoes a suppression, so anything that "
"clears bits 5/6 must re-clear them after each summon.")
elif y("5a"):
out.append("A bare trigger (0x1F, no auto bits) renders — so a bring-up "
"need not carry bits 5+6, and suppression can survive it.")
return out
# --------------------------------------------------------------------- main ----
def driver_procs() -> list[tuple[int, str]]:
"""(pid, cmdline) for any running lcxl3-driver — never touched, only named."""
try:
out = subprocess.run(["pgrep", "-af", "lcxl3-driver"],
capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.SubprocessError):
return []
procs = []
for line in out.splitlines():
pid, _, cmd = line.partition(" ")
if pid.isdigit() and "pgrep" not in cmd:
procs.append((int(pid), cmd.strip()))
return procs
def restore(w: Wire, args) -> None:
"""Hand the device back the way the driver expects to find it.
Not politeness: the probe leaves per-control targets on odd arrangements
with the auto bits cleared, and a knob that silently refuses to show a value
is exactly the failure round 4 spent an evening on. This mirrors
Driver.stop(), which calls native_overlay(True) for the same reason.
DAW mode is deliberately left ON — it is what the driver enables at start,
and turning it off here would blank the panel while PLN is still reading it.
"""
print()
print("restoring the device to the state the driver expects:")
w.why("restore — dismiss our overlay, un-name the probe control, put every "
"per-control target back to arrangement 4 with both auto bits set "
"(what Driver.stop()'s native_overlay(True) does), and leave the home "
"screen on arrangement 2")
w.config(L.TGT_TEMPORARY, L.CFG_CANCEL)
w.text(args.control, 1, "")
w.text(args.control, 2, "")
w.text(args.control, 0, "")
cfg = L.display_config(DRIVER_CONTROL_ARRANGEMENT, True, True)
for idx in range(L.TGT_CONTROL_FIRST, L.TGT_CONTROL_LAST + 1):
w.config(idx, cfg)
w.sleep(0.002)
w.config(L.TGT_STATIONARY, DRIVER_STATIONARY_CFG)
w.text(L.TGT_STATIONARY, 0, "PROBE DONE")
w.text(L.TGT_STATIONARY, 1, "restart driver")
w.text(L.TGT_STATIONARY, 2, "ParVagues")
w.config(L.TGT_STATIONARY, L.CFG_BRING_UP)
print(" 32 per-control targets -> arrangement 4 + bits 5|6")
print(" 0x36 cancelled, 0x35 -> arrangement 2")
print(" DAW mode left ON (the driver enables it anyway)")
print(" the probe control's name field is blank until the driver's next")
print(" oled_labels() pass rewrites it — restart the driver to be sure.")
def print_table(results: list[dict]) -> None:
print()
print("=" * 72)
print("RESULT TABLE")
print("=" * 72)
w_step = max([4] + [len(r["step"]) for r in results])
w_hyp = max([10] + [len(r["hypothesis"]) for r in results])
print(f"{'step':<{w_step}} {'hypothesis':<{w_hyp}} answer")
print(f"{'-' * w_step} {'-' * w_hyp} {'-' * 30}")
for r in results:
ans = r["answer"] or "(blank)"
print(f"{r['step']:<{w_step}} {r['hypothesis']:<{w_hyp}} {ans}")
if r["note"]:
print(f"{'':<{w_step}} {'':<{w_hyp}} note: {r['note']}")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
prog="lcxl3-probe-display",
description=__doc__.splitlines()[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Display/config SysEx only. This probe never emits a CC or a "
"note — the surface's CCs feed a live rig and CC93 is gPanic.")
p.add_argument("--dry-run", action="store_true",
help="print every byte sequence it WOULD send, with the "
"hypothesis each one tests, and ask nothing")
p.add_argument("--assume-driver-stopped", action="store_true",
help="proceed even though lcxl3-driver is running (it will "
"fight you: it re-asserts paint every 2 s and re-writes "
"all 32 labels every 20 s)")
p.add_argument("--control", type=lambda s: int(s, 0),
default=DEFAULT_CONTROL,
help=f"per-control display target for steps 3-5 "
f"(default 0x{DEFAULT_CONTROL:02X} = encoder B1, which "
"is not one of Ardour's controls)")
p.add_argument("--only", default="",
help="run only these steps, e.g. --only 1 or --only 1,4")
p.add_argument("--ticks", type=int, default=5,
help="counter length in steps 1 and 2 (default 5, 1/s)")
p.add_argument("--rate-secs", type=float, default=3.0,
help="seconds per rung of the step-2 rate ladder")
p.add_argument("--arrangements", default="",
help="census values, e.g. '1-8' or '1,2,3,4,9' "
"(default 1-8; bits 0-4 allow 1..30)")
p.add_argument("--full-census", action="store_true",
help="census every candidate arrangement 1..30 — 30 "
"questions, be sure")
p.add_argument("--fields", type=int, default=9,
help="how many text fields to write per arrangement "
"(default 9: arrangement 3 has 9, the rest fewer, and "
"out-of-range writes appear to be ignored)")
p.add_argument("--out", default=DEFAULT_OUT,
help="JSON results path")
args = p.parse_args(argv)
if args.control < L.TGT_CONTROL_FIRST or args.control > L.TGT_CONTROL_LAST:
p.error(f"--control must be 0x{L.TGT_CONTROL_FIRST:02X}.."
f"0x{L.TGT_CONTROL_LAST:02X} (only faders and encoders own a "
"per-control display target; buttons do not)")
steps = {s.strip() for s in args.only.split(",") if s.strip()} or \
{"1", "2", "3", "4", "5"}
print(__doc__.strip().splitlines()[0])
print()
procs = driver_procs()
if procs and not args.dry_run and not args.assume_driver_stopped:
print("!! lcxl3-driver is RUNNING. It will fight this probe:")
print(" - reassert_paint() every 2 s")
print(" - oled_labels() rewrites all 32 per-control targets every 20 s")
print(" - oled_touch() redraws 0x36 on any surface movement")
print(" Any 'no' you report could be the driver overwriting us.")
print()
for pid, cmd in procs:
print(f" pid {pid}: {cmd}")
print()
print(" To stop it (SIGTERM — it restores the device on the way out):")
for pid, _cmd in procs:
print(f" kill {pid}")
print(" Then run this probe. To bring it back afterwards:")
for _pid, cmd in procs:
print(f" cd {pathlib.Path(__file__).resolve().parent} && {cmd}")
print()
print(" Or pass --assume-driver-stopped to proceed anyway.")
return 2
if procs and args.assume_driver_stopped:
print(f"(warning: {len(procs)} lcxl3-driver process(es) still running, "
"proceeding as asked)")
surface = None
if not args.dry_run:
surface = L.Surface("daw")
_disarm(surface)
print(f"output port: {surface.out_name!r} (our own; no aconnect "
"changes, nothing pruned)")
else:
print("--dry-run: no port opened, no questions asked. "
"Every byte below is what a real run would send.")
w = Wire(surface, args.dry_run)
results: list[dict] = []
a = Asker(results, args.dry_run)
started = _dt.datetime.now().isoformat(timespec="seconds")
try:
w.why("setup — DAW mode is the only mode that paints or draws")
w.daw_mode(True)
w.sleep(0.25)
if "1" in steps and not a.aborted:
step1_stationary(w, a, args)
if "2" in steps and not a.aborted:
step2_temporary(w, a, args)
if "3" in steps and not a.aborted:
step3_percontrol(w, a, args)
if "4" in steps and not a.aborted:
step4_census(w, a, args)
if "5" in steps and not a.aborted:
step5_trigger(w, a, args)
except KeyboardInterrupt:
print("\n\ninterrupted — restoring the device before exit")
finally:
try:
restore(w, args)
except Exception as exc: # noqa: BLE001
print(f" !! restore failed: {exc}")
print(" Restart the driver; its startup re-configures everything.")
if surface is not None:
surface.close()
if results:
print_table(results)
print()
print("=" * 72)
print("VERDICT")
print("=" * 72)
lines = verdict(results)
if args.dry_run:
print(" (dry run — no observations, so no hypothesis can be settled.)")
print(" With real answers this section names the survivor and points at")
print(" the exact driver lines to change.")
for line in lines:
print()
for chunk in _wrap(line, 70):
print(f" {chunk}")
out = pathlib.Path(args.out)
try:
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"tool": "lcxl3-probe-display",
"started": started,
"finished": _dt.datetime.now().isoformat(timespec="seconds"),
"argv": sys.argv[1:],
"dry_run": args.dry_run,
"driver_running": [{"pid": pid, "cmd": cmd} for pid, cmd in procs],
"control_target": args.control,
"control_name": L.control_name(args.control),
"arrangements_probed": _arrangement_list(args),
"aborted": a.aborted,
"answers": results,
"verdict": lines,
"wire_log": w.log,
}, indent=2) + "\n")
print()
print(f"answers + full wire log -> {out}")
except OSError as exc:
print(f"\n!! could not write {out}: {exc}")
return 1
return 0
def _wrap(text: str, width: int) -> list[str]:
words, line, out = text.split(), "", []
for word in words:
if line and len(line) + 1 + len(word) > width:
out.append(line)
line = word
else:
line = f"{line} {word}".strip()
if line:
out.append(line)
return out
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment