Repository navigation
Releases: Alihkhawaher/KVM-Control-Plus
Release list
v0.1.4 — the picture became adjustable
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.rsis
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 of1192/256round-trips as1192/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.confunder[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 inlast-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 —
soslider_widthis 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--soakare all unchanged in this build.
v0.1.3 — the out-of-memory crash, fixed and measured
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_MBto 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
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_LATENCYis 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
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
ScrollAreagrows 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 ~1360pxagainst a150–590pxviewport). - 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
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) andwindows(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'src.exedirectly
instead of depending onwinres. - 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.