Skip to content

Releases: Alihkhawaher/KVM-Control-Plus

v0.1.4 — the picture became adjustable

Choose a tag to compare

@Alihkhawaher Alihkhawaher released this 11 Oct 11:32

v0.1.4 — the picture became adjustable

You said the picture looked washed out and asked for knobs. The knobs are here. The
measurement that came out of building them is the more interesting half of this
release, because it says the washed-out look was not what it looked like.

What was measured before anything was changed

Your screenshot was decoded scanline by scanline in Python — no image library, no
vision model, just the pixels — and sampled inside the video area only (the window
title bar and the app's own status overlay are not part of the picture, and they would
have supplied a "true black" for free):

Sample Measured Reading
"Chats" heading glyphs min 2, 1st percentile 11 the picture reaches true black
list / body text 1% ≈ 101, 5% ≈ 183 greys where the source's own greys are
background 254 true white
a saturated element rgb(0,166,255) chroma intact, not muted

On your stream — MJPG at 1920x1080 — the transfer function was already right: black is
black, white is white. The classic washed-out cause (studio-swing video decoded as
full range: nothing darker than ~6% grey, nothing brighter than ~92%) does not hold
here, and making a range override the default would have made your picture worse.

What your picture does have is a 1920 px frame shown in a ~1295 px pane — 0.67× —
decoded from MJPEG. Soft text against soft text is what reads as "washed out" to
the eye while the levels measure fine. Two usual sharpness fixes were considered and
rejected as the answer: mipmapped minification (epaint honours mipmap_mode only on
the glow backend, and mipmaps smooth rather than sharpen) and a nearest-neighbour
switch (worse below 1:1).

New: Options → PICTURE

  • Source range — auto / full 0-255 / limited 16-235. The override for the day
    a dongle really is wrong: it can hand over studio-swing video inside a format that
    claims full range, and nothing in the stream contradicts it.

  • Matrix — auto / BT.601 / BT.709.

  • brightness, contrast, saturation, gamma sliders.

  • Reset to neutral, disabled when nothing is changed — which also answers "is any of
    this doing anything right now?" without reading the description.

  • The panel shows the device's claim and your choice side by side when they differ:

    picture: BT.709 (media type says BT.709), limited 16-235 (you set limited 16-235)
             | adjustments: source range limited 16-235, contrast ×1.20, gamma 1.40
    

New: the picture is measured, so "washed out" has a number

The luma plane is already walked for the 4:2:0 chroma, so the minimum, maximum, mean
and the share of pixels at or below 16 / at or above 235 come out of that same pass —
no second pass, no extra cost:

Source luma   source luma 2…254 (mean 118); 0.3% at or below 16, 61.2% at or above 235

A source that never goes below ~16 or above ~235 is the washed-out signature, and the
panel says so and names the fix. A source that reaches both ends is told the transfer
function is right, and that the compression and the scaling are what is left.

New: --picture, for A/B without the GUI

KVM-Control-Plus.exe --picture range=limited,contrast=1.2,gamma=1.4 --snapshot out/b.bmp

Properties that were deliberately kept

  • No new dependency. Still eframe + windows, nothing else. src/picture.rs is
    hand-written pure Rust with no device behind it, which is why the colour maths can be
    unit-tested.
  • Neutral is bit-exact. With nothing changed, the maths is the maths it was before —
    a test asserts a sub-level value of 1192/256 round-trips as 1192/256. If you change
    nothing, you see exactly the picture you saw in v0.1.3.
  • One resolution per frame. The settings are locked for the duration of a frame, so
    no frame can be half one setting and half another — and the lock is never taken per
    pixel.
  • Settings persist in settings.conf under [picture], parsed leniently: an
    unparseable value falls back to neutral rather than reaching the picture.
  • Verbose logging is permanent, as always: what the picture is being read as and
    what you changed are logged on one line, in the GUI log and in last-run.log.

Verified

  • Clean release build, zero warnings, 80/80 tests pass.
  • The PICTURE panel seen on screen in the running app (throwaway build that opens on
    the Options tab): every label, value and button rendered, nothing clipped. The first
    build had a real flaw — egui squeezed the slider rail to ~45 px in the narrow pane —
    so slider_width is set to 160 and the fix was re-checked on screen at ~140 px.
  • The CLI path exercised against the real dongle, including the media-vs-chosen line
    above.
  • Since v0.1.3: the bounded frame path (the 2026-10-11 out-of-memory crash), the memory
    ceiling, last-run.log, and --soak are all unchanged in this build.

v0.1.3 — the out-of-memory crash, fixed and measured

Choose a tag to compare

@Alihkhawaher Alihkhawaher released this 11 Oct 10:17

v0.1.3 — the out-of-memory crash, fixed and measured

Two crashes on 2026-10-11 killed this app and the Windows desktop process:

time committed memory process lifetime
08:22:57 485,745,373,184 B (452 GiB) 10 h 30 m
12:17:23 478,142,341,120 B (445 GiB) 5 h 55 m

Both numbers come from Windows itself (Resource-Exhaustion-Detector, event 2004).
When commit ran out, dwm.exe — the compositor that draws the desktop — died with
it, and so did WhatsApp.

What happened

Frames were handed to the UI through an unbounded mpsc::channel(), which never
blocks. A minimised or invisible window is never repainted on Windows (eframe
says so itself: "These windows don't receive RedrawRequested events on Windows"),
so the UI stopped draining while capture kept producing decoded 1920×1080 frames —
6,220,800 bytes each. The arithmetic names the contents exactly:

478,142,341,120 / 6,220,800 = 76,861.9 frames
485,745,373,184 / 6,220,800 = 78,084.1 frames

Measured production rate here: ~30 fps, about 186 MB/s — 478 GB in roughly
45 minutes of an unpainted window.

The fix

  • The frame queue is now one frame long, by construction. Frames wait in a
    FrameSlot; a newer frame replaces the waiting one. A queue with no bound is only
    safe while its consumer keeps consuming, and a UI does not. This is also the more
    correct
    behaviour for live video: the newest frame, never a backlog.
  • Backpressure. If nothing has been collected for two seconds, capture keeps
    reading samples (so the picture is current the instant the window returns) but
    stops converting them. A stalled UI now costs one frame and no CPU.
  • A ceiling on committed memory (src/proc_mem.rs, GetProcessMemoryInfo):
    checked once a second, 4 GiB by default, KVM_MAX_COMMIT_MB to override. Past it,
    capture stops and says so. A bug should be able to damage this app, never the
    machine it runs on.
  • A durable log for GUI runs: %APPDATA%\KVM-Control Plus\last-run.log, with
    stderr aimed at the same file — the message that would have named the failing
    allocation went to a console the GUI had given back to Windows, so it was lost.

Verifying it yourself

--soak <seconds> reproduces the exact condition that was fatal — a producer with
nobody collecting — and prints the committed memory while it happens:

$ KVM-Control-Plus.exe --soak 20
produced 61, collected 0, peak commit 18.8 MB, ceiling 4096 MB
PASS — 61 frames produced with nobody collecting, memory stayed flat

Production stops climbing partway through: that is the backpressure working, and it
is the column that used to climb forever, 6 MB at a time.

The ceiling can be tested without risking anything:

$ KVM_MAX_COMMIT_MB=1 KVM-Control-Plus.exe -v --soak 8
[ERROR] committed 10 MB, past this app's 1 MB ceiling — capture stopped to protect the machine

Plus 67 unit tests (was 62), covering the slot holding one frame however many
arrive uncollected, the "nobody is collecting" signal, and a malformed
KVM_MAX_COMMIT_MB being unable to remove the ceiling.

Full post-mortem, with the event-log evidence and the frame arithmetic:
docs/CRASH_2026-10-11.md.

Everything from v0.1.2 is still here

Correct colour range and matrix read off the negotiated media type (the picture was
too bright), low-latency capture, and measured pipeline latency.

Still two dependencies. This fix adds no crates — GetProcessMemoryInfo comes
from the windows crate already in use.

Asset: KVM-Control-Plus.exe — one self-contained file, no runtime to install.

KVM-Control Plus v0.1.2

Choose a tag to compare

@Alihkhawaher Alihkhawaher released this 10 Oct 21:50

KVM-Control Plus v0.1.2

Two fixes, both reported from watching real video, both caused by code that
assumed something instead of asking for it.

The picture was the wrong colour

The NV12 → RGB conversion hard-coded studio-swing BT.601 for every format.
MediaFoundation disagrees, and says so on the media type:

colour: BT.709 (media type says BT.709), full 0-255 (media type says 0-255)

MJPG (the format selected here) is decoded by MediaFoundation's JPEG decoder, and
JPEG is full-range by definition. Applying studio-swing maths to full-range
data stretches contrast by ~16% and clips everything from luma 235 upward to flat
white. Measured on a ramp:

source luma what you saw what it should be
64 56 64
128 130 128
200 214 200
235 255 235
240 255 240

21 of 256 luma levels — 8% of the tonal range — were crushed to flat white, and
every mid-tone sat 7–16% high. That is the washed-out, too-bright picture with no
highlight detail. On top of that the coefficients were BT.601 applied to BT.709
content, which shifts hues.

Range and matrix are now read from the negotiated media type, with a per-format
fallback (MJPG ⇒ full range), and both the decision and its reason are printed in
the log and shown in the LINK & FORMATS panel. The conversion is a pure
function with unit tests over known ramps — the capture device is not always
awake, so a test is the only reproducible proof.

Latency is now measured, and lower

There was no way to tell whether "the picture is behind" was true. Now each frame
carries the instant it was sampled, and the window reports N ms now / N ms worst — N fps (also overlaid on the video, and logged once a second).

Three changes to the capture path:

  • MF_LOW_LATENCY is requested on the source reader. It never was, so
    MediaFoundation was free to buffer ahead.
  • The UI no longer redraws at the display's refresh rate. The capture thread
    wakes it when a frame actually arrives.
  • Chroma is read once per 4:2:0 pair, instead of being recomputed for every
    pixel.

Also fixed along the way: the reader was told its row pitch was width × 4 — an
RGB32 leftover from before the switch to NV12 — while the conversion ignored the
reported stride entirely. Both now use the stride the media type reports.

Download

KVM-Control-Plus.exe — one file, no installer, no DLLs, no runtime to fetch.
Statically linked CRT, 8.3 MB, icon and version info embedded.

The README now explains the colour rules and how the latency number is produced.

KVM-Control Plus v0.1.1

Choose a tag to compare

@Alihkhawaher Alihkhawaher released this 10 Oct 21:24

KVM-Control Plus v0.1.1

Small release with three things you can see, one you can't.

No console window any more

The GUI used to leave a console showing the same log the panel already carries.
Now a normal launch — double-click, or any launch with no arguments — gives that
console back to Windows before the window is drawn, so you get the GUI and
nothing else.

The diagnostic modes are untouched by this and keep their console, deliberately:
--list-devices, --list-formats, --probe, --raw-probe, --usb,
--snapshot, --grab-native. They are command-line tools, so output goes to
your terminal, the shell waits for them, and the exit status is real.

The detachment only happens when the app is the sole owner of the console.
If it was started from cmd.exe or PowerShell, that console is shared, and
detaching would close your terminal and swallow the output — so it is left
strictly alone. Mode flags and --trace therefore always behave as expected.

Nothing is lost either way: every line still goes to the ring buffer behind the
log panel, and its first line tells you the console was detached.

Hide the controls pane

Hide pane in the top bar collapses the left pane so the video can be the
whole window; the button stays reachable ("Show pane") and the choice is
remembered between runs.

Two defects fixed that only a screenshot could find

  • The controls pane genuinely overflowed but could not be scrolled: egui's
    ScrollArea grows to fit its content by default, so it never registered an
    overflow, and everything below the fold was unreachable. The measured geometry
    is in the log (content ~1360px against a 150–590px viewport).
  • Even once it could scroll, no bar appeared: egui's stock floating style draws
    the handle at zero opacity while idle. The pane uses the solid style now.

The geometry trace that diagnosed this is permanent — it fires only when the
geometry changes, so it stays useful without flooding the panel.

Legible light theme

The log panel's colours were hard-coded for the dark panel, so [INFO] lines
were near-white on the light background. Colour now comes from the active
palette.

Download

KVM-Control-Plus.exe — one file, no installer, no DLLs, no runtime to fetch.
Statically linked CRT, 8.3 MB, with icon and version info embedded.

Requires Windows 10/11 x64. A USB video capture dongle (MS2130-class, shows up
as 345F:2130) is needed for the video half; the CH9329 serial bridge is needed
for remote keyboard/mouse. Neither is required to start the app and look around.

The README now shows screenshots of all three states.

KVM-Control Plus v0.1.0

Choose a tag to compare

@Alihkhawaher Alihkhawaher released this 10 Oct 20:19

First public release.

KVM-Control Plus turns your laptop into a KVM console for another machine:
view its screen through a USB HDMI capture dongle and drive its keyboard and
mouse over a CH9329 serial HID bridge.

Download

KVM-Control-Plus.exe — one file, Windows x64. No installer, no DLL folder,
no runtime to install, and no VC++ redistributable required: the C runtime is
linked statically, so the only libraries it imports are Windows system DLLs.

  • Size: 7.87 MB
  • SHA-256: 784e868d93ffb08c328d72505ac88e04df0e9b7e4887ecb0fdf476ff9453fffc

Run it with no arguments for the GUI. Every diagnostic below is also available
headless.

What's in it

  • Two dependencies, total — eframe (GUI) and windows (all platform
    plumbing). Serial I/O, video capture, settings, input hooks and USB link
    measurement are hand-rolled over Win32; no serialport, nokhwa, image, serde,
    anyhow or channel crate. Build dependencies hold to the same rule: the icon
    and version resources are compiled by driving the SDK's rc.exe directly
    instead of depending on winres.
  • A Devices tab that explains itself. It reports which USB mode the capture
    link negotiated — measured from the USB hub via
    IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX[_V2], not inferred — how much
    throughput that leaves, and every format the device offers marked with whether
    it physically fits. Uncompressed 1080p needs 155–207 MB/s against the ~40 MB/s
    a USB 2.0 link carries, so only MJPEG can work there; pick an uncompressed
    format and the device streams filler frames forever. The app says so.
  • CH9329 protocol layer that is pure and byte-exact tested, a hand-rolled
    serial port and SetupAPI enumeration, a VK→HID keymap with layout-aware
    translation, and an input thread with its own hidden window and Raw Input.
  • Headless diagnostics that found everything above: --list-devices,
    --list-formats, --probe, --usb, --snapshot, --raw-probe,
    --grab-native.

Known limitations

  • A live moving picture is not verified end-to-end. Everything up to and
    including "valid JPEG bytes arrive at the expected buffer size" is confirmed
    against real hardware; a moving image has not been observed in-app, because
    the HDMI source was asleep during every test session. See
    docs/CAPTURE_DIAGNOSIS.md.
  • The capture format must be chosen manually. On a USB 2.0 link the app warns
    loudly when an unfittable format is selected, but it does not yet refuse or
    auto-switch to MJPEG.
  • Mouse control is absolute-position only; relative (game) mode is not wired up.
  • Macros and clipboard/file transfer are not implemented.

Documentation

docs/CAPTURE_DIAGNOSIS.md — the full measured investigation into black
pictures (two independent causes, distinguished by evidence).
docs/CODE_MAP.md — every module and the rules that keep the project lean.
docs/TODO.md — what is done, verified, and next.

A Rust rewrite inspired by the C#/WinUI3 Control3.