Skip to content

Commit 4662b75

Browse files
feat(display): size virtual displays from the client's image area
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>
1 parent e1dd309 commit 4662b75

12 files changed

Lines changed: 242 additions & 18 deletions

File tree

docs/configuration.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4229,6 +4229,33 @@ Controls AMD's high-motion quality boost. Use `auto` to leave the driver default
42294229

42304230
Sets how long a paused virtual display may remain ready before the display helper releases it. Set `0` to disable the timeout.
42314231

4232+
### dd_virtual_display_image_width_mm
4233+
4234+
Sets the width, in millimetres, of the image area the client actually displays. Set `0` to disable.
4235+
4236+
The stream protocol carries pixels, not physical dimensions, so the host cannot tell a 2504-pixel-wide
4237+
phone panel from a 2504-pixel-wide desktop monitor. Left to itself it guesses a scale from the
4238+
resolution, and buttons, text, and the cursor end up a different physical size on the client than they
4239+
are on the host's own monitor. Measuring the image area once removes the guess: the virtual display
4240+
advertises those dimensions in its EDID, Windows derives DPI from them the same way it does for a real
4241+
monitor, and interface elements come out at their true physical size.
4242+
4243+
Measure the **image area**, not the device. On a letterboxed panel the two differ: a 16:10 stream on the
4244+
8.0-inch inner panel of a Galaxy Z Fold8 Ultra occupies only about 7.01 inches of it. The height is
4245+
derived from the width and the requested mode, so only the width is configured.
4246+
4247+
Known-good widths, as a starting point — this is an ordinary list, so add your own device by measuring
4248+
its image area:
4249+
4250+
| Client | Mode | Image area (mm) | Width to configure | Density |
4251+
| --- | --- | --- | --- | --- |
4252+
| Alienware m16 R2 | 2560x1600 | 344.68 x 215.42 | `345` | 188.65 PPI |
4253+
| Galaxy Z Fold8 Ultra (inner panel, landscape) | 2504x2256 | 150.97 x 136.01 | `151` | 421.3 PPI |
4254+
4255+
`dd_virtual_display_scale` still wins when set to an explicit percentage, and `0` (preserve Windows'
4256+
choice) still leaves the Windows DPI setting alone. The measured width only replaces the automatic
4257+
recommendation.
4258+
42324259
### dd_virtual_display_scale
42334260

42344261
Sets the virtual-display scale override. Leave it unset or at the automatic setting to use the recommended scale for the requested display mode.

src/config.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,7 @@ namespace config {
939939
true, // use_sunshine_virtual_display_driver
940940
false, // activate_virtual_display
941941
-1, // virtual_display_scale_percent
942+
0, // virtual_display_image_width_mm
942943
0, // virtual_display_permanent_count
943944
false, // virtual_display_permanent_count_configured
944945
{}, // snapshot_exclude_devices
@@ -1901,6 +1902,18 @@ namespace config {
19011902
<< "%; use -1 (recommended), 0, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, or 500.";
19021903
}
19031904
}
1905+
{
1906+
int value = video.dd.virtual_display_image_width_mm;
1907+
int_f(vars, "dd_virtual_display_image_width_mm", value);
1908+
// Mirrors VDISPLAY::kMinImageWidthMillimeters/kMaxImageWidthMillimeters, which live in a
1909+
// Windows-only header this platform-neutral parser cannot include.
1910+
if (value == 0 || (value >= 10 && value <= 2000)) {
1911+
video.dd.virtual_display_image_width_mm = value;
1912+
} else {
1913+
BOOST_LOG(warning) << "Ignoring out-of-range virtual display image width " << value
1914+
<< " mm; use 0 to disable, or 10-2000.";
1915+
}
1916+
}
19041917
bool_f(vars, "vulkan_hdr_layer", video.dd.vulkan_hdr_layer);
19051918
{
19061919
auto it = vars.find("dd_virtual_display_permanent_count");
@@ -2583,6 +2596,7 @@ namespace config {
25832596
"dd_use_sunshine_virtual_display_driver",
25842597
"dd_activate_virtual_display",
25852598
"dd_virtual_display_scale",
2599+
"dd_virtual_display_image_width_mm",
25862600
"dd_virtual_display_permanent_count",
25872601
"dd_mode_remapping",
25882602
"dd_wa_dummy_plug_hdr10",
@@ -3087,6 +3101,7 @@ namespace config {
30873101
const auto prev_dd_use_sunshine_virtual_display_driver = video.dd.use_sunshine_virtual_display_driver;
30883102
const auto prev_dd_activate_virtual_display = video.dd.activate_virtual_display;
30893103
const auto prev_dd_virtual_display_scale_percent = video.dd.virtual_display_scale_percent;
3104+
const auto prev_dd_virtual_display_image_width_mm = video.dd.virtual_display_image_width_mm;
30903105
const auto prev_dd_virtual_display_permanent_count = video.dd.virtual_display_permanent_count;
30913106
const auto prev_dd_virtual_display_permanent_count_configured = video.dd.virtual_display_permanent_count_configured;
30923107
const auto prev_dd_snapshot_exclude_devices = video.dd.snapshot_exclude_devices;
@@ -3161,6 +3176,7 @@ namespace config {
31613176
(prev_dd_use_sunshine_virtual_display_driver != video.dd.use_sunshine_virtual_display_driver) ||
31623177
(prev_dd_activate_virtual_display != video.dd.activate_virtual_display) ||
31633178
(prev_dd_virtual_display_scale_percent != video.dd.virtual_display_scale_percent) ||
3179+
(prev_dd_virtual_display_image_width_mm != video.dd.virtual_display_image_width_mm) ||
31643180
(prev_dd_virtual_display_permanent_count != video.dd.virtual_display_permanent_count) ||
31653181
(prev_dd_virtual_display_permanent_count_configured != video.dd.virtual_display_permanent_count_configured) ||
31663182
(prev_dd_snapshot_exclude_devices != video.dd.snapshot_exclude_devices) ||

src/config.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ namespace config {
231231
bool use_sunshine_virtual_display_driver; ///< Use the Vibepollo Display Driver instead of rollback drivers such as SudoVDA.
232232
bool activate_virtual_display; ///< Auto-activate Sunshine virtual display when selected as the target output.
233233
int virtual_display_scale_percent; ///< Windows scale for virtual displays (-1 is resolution-based; 0 preserves Windows' choice).
234+
int virtual_display_image_width_mm; ///< Width of the client's visible image area in millimetres (0 disables). Drives the synthetic EDID so Windows derives DPI from a real physical size.
234235
int virtual_display_permanent_count; ///< Number of always-present Sunshine virtual displays to request when explicitly configured.
235236
bool virtual_display_permanent_count_configured; ///< False preserves installs that predate this setting.
236237
std::vector<std::string> snapshot_exclude_devices; ///< Device IDs to skip when saving display snapshots.

src/platform/windows/virtual_display.h

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,40 @@ namespace VDISPLAY {
6868
std::uint32_t scale_percent
6969
);
7070

71-
// Resolve the configured virtual-display scale. -1 selects a resolution-based
72-
// recommendation, 0 preserves Windows' existing choice, and positive values are exact.
71+
// Physical size of the image area a virtual display advertises through its synthetic EDID.
72+
struct virtual_display_physical_size_t {
73+
std::uint32_t width_mm = 0;
74+
std::uint32_t height_mm = 0;
75+
};
76+
77+
// Lowest and highest client image widths we accept, in millimetres. The range spans a
78+
// phone's inner panel up to a projected image; anything outside it is a typo, not a display.
79+
inline constexpr int kMinImageWidthMillimeters = 10;
80+
inline constexpr int kMaxImageWidthMillimeters = 2000;
81+
82+
// The DPI a 100% Windows scale factor is defined against.
83+
inline constexpr double kWindowsReferenceDpi = 96.0;
84+
85+
// Resolve the image area a virtual display should advertise, from the measured width of the
86+
// client's visible image and the mode being requested. Returns nullopt when no width is
87+
// configured, leaving callers on the scale-derived EDID. Height follows the mode's pixel
88+
// aspect: the streamed image fills the client's image area, so its pixels are square even
89+
// when the client's panel is not the same shape as the image.
90+
std::optional<virtual_display_physical_size_t> virtual_display_physical_size_mm(
91+
int configured_image_width_mm,
92+
std::uint32_t width,
93+
std::uint32_t height
94+
);
95+
96+
// Resolve the configured virtual-display scale. -1 selects a recommendation, 0 preserves
97+
// Windows' existing choice, and positive values are exact. A measured client image width
98+
// refines the recommendation: it describes the client's pixel density exactly, where the
99+
// resolution-only heuristic can only guess at it.
73100
std::uint32_t effective_virtual_display_scale_percent(
74101
int configured_scale_percent,
75102
std::uint32_t width,
76-
std::uint32_t height
103+
std::uint32_t height,
104+
int configured_image_width_mm = 0
77105
);
78106

79107
// Read the MHC2 peak-luminance value from a Windows HDR calibration profile selection.

src/platform/windows/virtual_display_identity.cpp

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ namespace {
1212
100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500
1313
};
1414

15+
// Windows only exposes the scales in kWindowsScalePercentages, so every derived scale has to
16+
// land on one of them.
17+
std::uint32_t nearest_windows_scale_percent(const double ideal_scale) {
18+
const auto closest = std::ranges::min_element(
19+
kWindowsScalePercentages,
20+
[ideal_scale](const auto lhs, const auto rhs) {
21+
return std::abs(static_cast<double>(lhs) - ideal_scale) <
22+
std::abs(static_cast<double>(rhs) - ideal_scale);
23+
}
24+
);
25+
return closest != kWindowsScalePercentages.end() ? *closest : 100u;
26+
}
27+
28+
bool image_width_is_usable(const int image_width_mm) {
29+
return image_width_mm >= VDISPLAY::kMinImageWidthMillimeters &&
30+
image_width_mm <= VDISPLAY::kMaxImageWidthMillimeters;
31+
}
32+
1533
bool equals_ascii_ci(const std::string_view lhs, const std::string_view rhs) {
1634
return lhs.size() == rhs.size() &&
1735
std::ranges::equal(lhs, rhs, [](const char left, const char right) {
@@ -108,25 +126,47 @@ namespace VDISPLAY {
108126
starts_with_wide_ascii_ci(product_code, L"5");
109127
}
110128

129+
std::optional<virtual_display_physical_size_t> virtual_display_physical_size_mm(
130+
const int configured_image_width_mm,
131+
const std::uint32_t width,
132+
const std::uint32_t height
133+
) {
134+
if (!image_width_is_usable(configured_image_width_mm) || width == 0 || height == 0) {
135+
return std::nullopt;
136+
}
137+
138+
virtual_display_physical_size_t size {};
139+
size.width_mm = static_cast<std::uint32_t>(configured_image_width_mm);
140+
size.height_mm = static_cast<std::uint32_t>((std::max)(
141+
1L,
142+
std::lround(
143+
static_cast<double>(configured_image_width_mm) *
144+
static_cast<double>(height) / static_cast<double>(width)
145+
)
146+
));
147+
return size;
148+
}
149+
111150
std::uint32_t effective_virtual_display_scale_percent(
112151
const int configured_scale_percent,
113152
const std::uint32_t width,
114-
const std::uint32_t height
153+
const std::uint32_t height,
154+
const int configured_image_width_mm
115155
) {
116156
if (configured_scale_percent >= 0) {
117157
return static_cast<std::uint32_t>(configured_scale_percent);
118158
}
119159

160+
// A measured image width pins the client's real pixel density, so the scale follows from
161+
// physics instead of from a guess keyed on resolution alone.
162+
if (image_width_is_usable(configured_image_width_mm) && width > 0) {
163+
const auto dots_per_inch =
164+
static_cast<double>(width) * 25.4 / static_cast<double>(configured_image_width_mm);
165+
return nearest_windows_scale_percent(dots_per_inch * 100.0 / VDISPLAY::kWindowsReferenceDpi);
166+
}
167+
120168
const auto short_edge = (std::min)(width, height);
121-
const auto ideal_scale = static_cast<double>(short_edge) * 100.0 / 864.0;
122-
const auto closest = std::ranges::min_element(
123-
kWindowsScalePercentages,
124-
[ideal_scale](const auto lhs, const auto rhs) {
125-
return std::abs(static_cast<double>(lhs) - ideal_scale) <
126-
std::abs(static_cast<double>(rhs) - ideal_scale);
127-
}
128-
);
129-
return closest != kWindowsScalePercentages.end() ? *closest : 100u;
169+
return nearest_windows_scale_percent(static_cast<double>(short_edge) * 100.0 / 864.0);
130170
}
131171

132172
std::uint64_t client_uuid_to_virtual_display_id(const GUID &client_guid) {

src/platform/windows/virtual_display_sudovda.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4441,7 +4441,8 @@ namespace VDISPLAY_SUDOVDA {
44414441
const auto scale_percent = VDISPLAY::effective_virtual_display_scale_percent(
44424442
config::video.dd.virtual_display_scale_percent,
44434443
width,
4444-
height
4444+
height,
4445+
config::video.dd.virtual_display_image_width_mm
44454446
);
44464447
if (scale_percent > 0) {
44474448
const bool has_virtual_target_identity =

src/platform/windows/virtual_display_sunshine.cpp

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6560,9 +6560,16 @@ namespace VDISPLAY_SUNSHINE {
65606560
}
65616561
const auto dpi_settings_prefix = virtual_display_dpi_settings_prefix(display_id);
65626562
const auto configured_scale = config::video.dd.virtual_display_scale_percent;
6563+
const auto configured_image_width_mm = config::video.dd.virtual_display_image_width_mm;
65636564
const auto effective_scale = VDISPLAY::effective_virtual_display_scale_percent(
65646565
configured_scale,
65656566
width,
6567+
height,
6568+
configured_image_width_mm
6569+
);
6570+
const auto measured_image_size = VDISPLAY::virtual_display_physical_size_mm(
6571+
configured_image_width_mm,
6572+
width,
65666573
height
65676574
);
65686575
const auto dpi_snapshot = configured_scale == 0 ?
@@ -6574,8 +6581,22 @@ namespace VDISPLAY_SUNSHINE {
65746581
create_request.display_id = display_id;
65756582
create_request.width = width;
65766583
create_request.height = height;
6577-
if (effective_scale > 0) {
6578-
const auto dpi = 96.0 * static_cast<double>(effective_scale) / 100.0;
6584+
if (measured_image_size) {
6585+
// A measured image area is reported verbatim. Windows derives DPI from the EDID, so
6586+
// handing it the client's real dimensions makes on-screen elements come out at their
6587+
// true physical size instead of a size implied by a picked scale.
6588+
create_request.physical_width_mm = std::clamp(
6589+
measured_image_size->width_mm,
6590+
sunshine_driver::kMinPhysicalSizeMillimeters,
6591+
sunshine_driver::kMaxPhysicalSizeMillimeters
6592+
);
6593+
create_request.physical_height_mm = std::clamp(
6594+
measured_image_size->height_mm,
6595+
sunshine_driver::kMinPhysicalSizeMillimeters,
6596+
sunshine_driver::kMaxPhysicalSizeMillimeters
6597+
);
6598+
} else if (effective_scale > 0) {
6599+
const auto dpi = VDISPLAY::kWindowsReferenceDpi * static_cast<double>(effective_scale) / 100.0;
65796600
create_request.physical_width_mm = std::clamp(
65806601
static_cast<std::uint32_t>(std::lround(static_cast<double>(width) * 25.4 / dpi)),
65816602
sunshine_driver::kMinPhysicalSizeMillimeters,
@@ -6600,7 +6621,9 @@ namespace VDISPLAY_SUNSHINE {
66006621
<< ", HDR peak=" << create_request.hdr_max_luminance_nits << " nits"
66016622
<< ", scale=" << effective_scale << "%"
66026623
<< ", physical=" << create_request.physical_width_mm << 'x'
6603-
<< create_request.physical_height_mm << " mm).";
6624+
<< create_request.physical_height_mm << " mm"
6625+
<< ", physical source=" << (measured_image_size ? "measured client image" : "scale")
6626+
<< ").";
66046627
sunshine_driver::ControlResult<sunshine_driver::CreateTemporaryDisplayResult> create_result;
66056628
if (reclaimed_for_reuse) {
66066629
// Ownership is already established in-place. Enter the existing-display
@@ -7215,7 +7238,8 @@ namespace VDISPLAY_SUNSHINE {
72157238
const auto scale_percent = VDISPLAY::effective_virtual_display_scale_percent(
72167239
config::video.dd.virtual_display_scale_percent,
72177240
width,
7218-
height
7241+
height,
7242+
config::video.dd.virtual_display_image_width_mm
72197243
);
72207244
if (scale_percent > 0) {
72217245

src_assets/common/assets/web/components/settings/SettingsOverrideEditor.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const virtualDisplayOnlyKeys = new Set([
159159
'virtual_display_mode',
160160
'virtual_display_layout',
161161
'dd_virtual_display_scale',
162+
'dd_virtual_display_image_width_mm',
162163
'dd_activate_virtual_display',
163164
'dd_virtual_display_permanent_count',
164165
'dd_paused_virtual_display_timeout_secs',

src_assets/common/assets/web/configs/settingsSchema.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const clientOverrideableKeys = new Set([
8787
'dd_use_sunshine_virtual_display_driver',
8888
'dd_activate_virtual_display',
8989
'dd_virtual_display_scale',
90+
'dd_virtual_display_image_width_mm',
9091
'dd_virtual_display_permanent_count',
9192
'dd_mode_remapping',
9293
'dd_wa_dummy_plug_hdr10',
@@ -353,6 +354,14 @@ const virtualDisplayCustomizationFields = (): SettingsField[] => [
353354
descriptionKey: 'ui.settings.fields.dd_virtual_display_scale.description',
354355
visibleWhen: { key: 'virtual_display_mode', notEquals: 'disabled' },
355356
}),
357+
number('dd_virtual_display_image_width_mm', {
358+
labelKey: 'ui.settings.fields.dd_virtual_display_image_width_mm.label',
359+
descriptionKey: 'ui.settings.fields.dd_virtual_display_image_width_mm.description',
360+
min: 0,
361+
max: 2000,
362+
step: 1,
363+
visibleWhen: { key: 'virtual_display_mode', notEquals: 'disabled' },
364+
}),
356365
];
357366

358367
const everydayPacingFields = (): SettingsField[] => [
@@ -796,6 +805,7 @@ export const settingsDefaults: Record<string, unknown> = {
796805
virtual_display_mode: 'per_client',
797806
virtual_display_layout: 'exclusive',
798807
dd_virtual_display_scale: -1,
808+
dd_virtual_display_image_width_mm: 0,
799809
frame_limiter_enable: false,
800810
frame_limiter_provider: 'auto',
801811
frame_limiter_fps_limit: 0,

src_assets/common/assets/web/public/assets/locale/ui/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,10 @@
13621362
"description": "Keep up to four virtual monitors outside streams. 0 creates them only when needed.",
13631363
"label": "Always-available virtual displays"
13641364
},
1365+
"dd_virtual_display_image_width_mm": {
1366+
"description": "Width of the image area your client actually displays, in millimetres. Windows reads it from the virtual display's EDID and derives DPI from it, so buttons and the cursor match the physical size they have on the host monitor. Measure the image area, not the device: a letterboxed stream is smaller than the panel. 0 disables it. Examples: 345 for an Alienware m16 R2 at 2560x1600, 151 for a Galaxy Z Fold8 Ultra inner panel at 2504x2256.",
1367+
"label": "Client image width (mm)"
1368+
},
13651369
"dd_virtual_display_scale": {
13661370
"description": "Adjust interface size without changing the resolution requested by the client.",
13671371
"label": "Windows scaling"

0 commit comments

Comments
 (0)