Skip to content

feat(display): size virtual displays from the client's image area - #452

Draft
LucasBonafe wants to merge 2 commits into
Nonary:masterfrom
LucasBonafe:feat/virtual-display-physical-image-size
Draft

feat(display): size virtual displays from the client's image area#452
LucasBonafe wants to merge 2 commits into
Nonary:masterfrom
LucasBonafe:feat/virtual-display-physical-image-size

Conversation

@LucasBonafe

@LucasBonafe LucasBonafe commented Aug 23, 2026

Copy link
Copy Markdown

Also opened on Vibeshine as Nonary/vibeshine#266, since that is where features originate.
Take whichever side suits you and I will close the other — they are not meant to both land.

The problem

The stream protocol carries pixels, never physical dimensions. I checked nvhttp /launch, the SDP
attributes in src/rtsp.cpp, and moonlight-common-c: nothing anywhere carries a client's screen size
or DPI. So the host genuinely cannot tell a 2504-pixel-wide phone panel from a 2504-pixel-wide desktop
monitor.

effective_virtual_display_scale_percent() deals with that by guessing from the short edge alone
(src/platform/windows/virtual_display_identity.cpp):

const auto ideal_scale = static_cast<double>(short_edge) * 100.0 / 864.0;

Resolution is a poor proxy for density, and the existing tests already show the seam: 3440x1440 and
1440x2560 are very different displays and both resolve to 175%.

The user-visible result is that buttons, text, and the cursor come out a different physical size on the
client than they are on the host's own monitor. #386 is this, from the other end: a user picked 175% in
Vibepollo, Windows recommended 150% "on two different phones", and the two disagreed until they set
Windows by hand.

The approach

Rather than add another picked percentage, this takes the physical route the Apollo maintainer already
argued for in ClassicOldSong#290 (the issue ClassicOldSong#1013 was closed as a duplicate
of). Asked about specifying scaling as a percentage from the client, he answered:

Nope, that could mess up with Windows native DPI settings.

and then:

Physical size is baked into EDID and Windows can determine DPI based on the information. It's more
precise than manually picked scales.

He also noted that auto-detecting the client's physical size is not feasible and it has to be entered
manually — which matches what the protocol survey above found.

The host already synthesises an EDID physical size in virtual_display_sunshine.cpp, but it derives the
millimetres from the scale (dpi = 96 * scale / 100, then mm = px * 25.4 / dpi). This change lets
that run in the honest direction: measure the client once, put the real millimetres in the EDID, and let
Windows derive DPI from them the way it does for any real monitor. The Windows scale becomes a derived
value rather than the primary input.

The setting

One new option, dd_virtual_display_image_width_mm: the width, in millimetres, of the image area the
client actually displays. 0 disables it and nothing changes.

It is deliberately the image area, not the device. On a letterboxed panel those differ — a 16:10
stream on the 8.0-inch inner panel of a Galaxy Z Fold8 Ultra occupies about 7.01 inches of it — so a
device diagonal would bake in an error. Height is derived from the width and the requested mode's pixel
aspect, so only one number is configured.

The calculation

dpi           = width_px * 25.4 / image_width_mm
ideal_scale % = dpi / 96 * 100          -> snapped to the scales Windows exposes
height_mm     = image_width_mm * height_px / width_px

Worked through on two measured clients:

Client Mode Image area (mm) Configured DPI Ideal Snapped Heuristic gives
Alienware m16 R2 2560x1600 344.68 x 215.42 345 188.5 196.3% 200% 175%
Galaxy Z Fold8 Ultra (inner, landscape) 2504x2256 150.97 x 136.01 151 421.2 438.8% 450% 250%

Both sets of dimensions are from the vendors: the m16 R2 active area is Dell's own manual (188.65 PPI,
which the numbers reproduce exactly), and the Z Fold8 Ultra figures follow from Samsung's 8.0-inch inner
panel spec (421.3 PPI, likewise reproduced). The phone is the case that shows why this matters — the
resolution heuristic is off by 200 percentage points there.

Suggested widths

Nobody knows their client's image width offhand, and a number that needs a datasheet to find is a number
nobody sets. So the two measurements above are also offered in the UI.
src_assets/common/assets/web/configs/clientImageWidthPresets.ts is a plain table of device, mode, and
width:

export const clientImageWidthPresets: ClientImageWidthPreset[] = [
  // Dell's manual gives a 344.68 x 215.42 mm active area at 2560x1600, so 188.65 PPI.
  { label: 'Alienware m16 R2', mode: '2560x1600', widthMm: 345 },
  // Samsung's 8.0-inch inner panel at 2504x2256 works out to 150.97 x 136.01 mm, so 421.3 PPI.
  { label: 'Galaxy Z Fold8 Ultra (inner panel)', mode: '2504x2256', widthMm: 151 },
];

The settings schema hangs that table off the field as a generic presets property, and the three
editors that render numeric fields - global settings, per-client overrides, per-app overrides - render
any field's suggestions through a native <datalist>. Nothing is keyed on this particular setting, and
no device name reaches the C++ at all: the host still only ever sees a number of millimetres.

The table binds nothing. It is a list of worked examples rather than a supported-device list, the field
still accepts any width the host accepts, and a device missing from it is a convenience gap rather than
a blocker. Adding one is a single row.

Idempotency

Reconnecting re-runs this against a display that already carries the previous answer, so both halves are
pure functions of the configuration and read nothing back:

  • The EDID size is computed from the configured millimetres and the mode. It cannot drift, because
    nothing feeds the previous size back in.
  • The Windows scale path was already safe and stays that way. set_display_scale_percent() re-queries
    recommended_index and recomputes desired_relative from scratch on every call, and short-circuits
    when current_index == desired_index. The relative index never accumulates.

MeasuredImageWidthResolvesIdempotently covers both, including feeding a derived scale back in as an
explicit setting.

Precedence

Unchanged for anyone not setting the new option, and the existing semantics still win:

  • an explicit dd_virtual_display_scale percentage overrides the measurement;
  • 0 ("preserve Windows' choice") still leaves the Windows DPI setting alone;
  • only the automatic (-1) recommendation is refined.

The measured EDID size is applied regardless of the scale mode, since reporting a true physical size is
correct even when Vibepollo is not touching the DPI setting.

Scope

Kept small on purpose. No protocol extension, no client auto-detection, no new UI component — the
setting is a plain number field with a native list of suggested widths, and it reuses
effective_virtual_display_scale_percent() and the
existing config_overrides allowlists rather than adding a parallel path, so it is already
per-client and per-app overridable. The scale-snapping helper is extracted once and shared by both the
heuristic and the derived path.

web-legacy is not wired up; it is excluded from packaging in cmake/packaging/common.cmake.

Testing — please read

Seven test cases were added to tests/unit/platform/windows/test_virtual_display_sunshine.cpp, in the
existing style, covering the derived scale, the fallback when unset or out of range, explicit-scale
precedence, the derived physical size, the absent cases, and idempotency. Two more were added to
src_assets/common/assets/web/scripts/v2-parity.test.ts, pinning the suggestion table to the field and
to the widths the C++ tests and the documentation are written against, so the three cannot drift apart
silently.

I could not run the project's own test suite, and I have not tested this on a real host. Vibepollo
builds under MSYS2 UCRT64 and that toolchain is not installed on my machine; the full build also needs
the virtual display driver SDK (<virtual_display/driver/control_client.h>). What I did instead:

  • Compiled the struct, the constants, the scale table and both function bodies, all extracted verbatim
    from virtual_display.h and virtual_display_identity.cpp, with MSVC at
    /W4 /permissive- /std:c++20, and ran every assertion from the nine scale and size test cases, the
    two pre-existing ones included as regression checks. 35/35 pass, no warnings.
  • Ran the web test suite (npm run test:v2-parity, 6/6), the TypeScript typecheck
    (vue-tsc --noEmit, clean), the production build (npm run build, clean), and prettier --check
    over every file touched.

Not verified: that the whole project compiles under the real toolchain, and — most importantly —
that a real client actually ends up with correctly-sized UI. The EDID path in particular needs someone
with the driver and a device to confirm that Windows picks up the advertised size and settles on the
expected scale. I would not merge this without that check.

AI assistance

This change was written with AI assistance (Claude). Per CONTRIBUTING.md I have reviewed the diff and I
understand what it does; the arithmetic above was verified independently against the vendor figures, and
the limits of what was actually executed are stated plainly in the section above rather than implied.

Design discussion

The shape of this — EDID physical size as the input rather than a picked percentage — is open for
discussion in #453, including the alternatives I considered and the questions I'd most like answered
before this goes any further. If you'd rather implement it yourself, everything needed is written up
there and this draft can simply be closed.

Refs ClassicOldSong#290, ClassicOldSong#1013, #386, #453, Nonary/vibeshine#266.

The stream protocol carries pixels, never physical dimensions, so the host
cannot tell a 2504-pixel-wide phone panel from a 2504-pixel-wide monitor.
effective_virtual_display_scale_percent() therefore guessed a Windows scale
from the short edge alone, and buttons, text, and the cursor landed at a
different physical size on the client than they have on the host's monitor.

Add dd_virtual_display_image_width_mm: the measured width, in millimetres, of
the image area the client actually displays. When set, the virtual display
advertises that size through its synthetic EDID and Windows derives DPI from it
the same way it does for a real monitor. The Windows scale becomes a value
derived from the measurement rather than the primary input, which is both more
precise than a picked percentage and closer to how Windows expects to be told
about a display.

The width describes the image area, not the device. A letterboxed stream is
smaller than the panel carrying it, so the height is derived from the width and
the requested mode's pixel aspect instead of from the panel's shape.

Both halves stay pure functions of the configuration. Reconnecting re-runs them
against a display that already carries the previous answer, so neither may read
back what it wrote last time; a covering test pins that down. An explicit
dd_virtual_display_scale still wins, and 0 still leaves the Windows DPI setting
untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dd_virtual_display_image_width_mm asks for a number nobody knows offhand: the
width, in millimetres, of the image area a client actually displays. Working it
out means finding a datasheet and dividing, which is enough friction to leave
the setting unused and the guess in place.

Offer the measurements that have already been worked out. clientImageWidthPresets
is a plain table of device, mode, and width; the settings schema hangs it off the
field, and the three editors that render numeric fields - global settings,
per-client overrides, per-app overrides - expose any field's suggestions through
a datalist. The Alienware m16 R2 (345 mm at 2560x1600) and the inner panel of a
Galaxy Z Fold8 Ultra (151 mm at 2504x2256) start the table, both from vendor
figures the existing tests already assert against.

The table carries no behaviour and binds nothing: it is a list of examples, the
field still takes any width the host accepts, and a device missing from it is a
convenience gap rather than a blocker. Suggestions are a generic field property
rather than something keyed on this one setting, so the editors stay free of
per-setting special cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant