hwcomposer: follow output scale changes instead of latching at boot - #76
Open
aarmea wants to merge 3 commits into
Open
hwcomposer: follow output scale changes instead of latching at boot#76aarmea wants to merge 3 commits into
aarmea wants to merge 3 commits into
Conversation
On a compositor using fractional scaling, wl_output.scale carries the legacy *integer* scale (ceil of the real one, e.g. 2 for a 1.5 output). output_handle_scale() folds that into the cached scale with std::max(), so once the output is ever bumped to a fractional scale and back the value latches at the ceiling forever: every surface created afterwards is presented at half size on each axis (filling a quarter of the screen) on a scale-1 output, until a full session restart. The std::max() also makes the result independent of event ordering, so the flip poisons the session even when it is queued across an lxc freeze/thaw, and a mixed-DPI multi-output setup latches onto the highest-scale output. The authoritative fractional value comes from wp_fractional_scale_v1.preferred_scale, but that object was created only for the first window during calibration and destroyed immediately, so no preferred_scale event ever arrived while the session was warm. Keep a wp_fractional_scale_v1 alive for the lifetime of every surface and apply preferred_scale changes to the warm session, and stop wl_output.scale from latching the cached value when a fractional-scale manager is present (use it only to seed an initial fallback). Likely fixes the mixed-DPI / suspend-resume "quarter of the screen" reports (e.g. waydroid/waydroid#1347).
wp_fractional_scale_v1.preferred_scale is only delivered for a surface that is actually mapped, so a scale change that happens while no app window exists - the common kiosk case, where the app is closed before the scale changes - was not tracked at all. Derive the scale from xdg_output.logical_size versus wl_output.mode (e.g. 1920/1280 = 1.5), which needs no surface. It is applied only when there are no live windows, so it never fights preferred_scale on a mixed-DPI multi-output setup. Bind zxdg_output_manager_v1 at version <= 2: version 3 deprecates zxdg_output_v1.done, which we rely on to know when a logical_size update is complete. Factor the scale-change handling into apply_scale_change(), which now also re-derives the display size from the compositor's logical size and hotplugs so SurfaceFlinger re-reads the config, instead of only updating the cached value. The calibration fallback prefers the xdg_output logical size too, addressing the long-standing NOTICE about wl_output.mode being in physical pixels.
ro.sf.lcd_density is a read-only property and keeps whatever was published at boot, so it cannot follow a warm output-scale change. Track the live density in the display struct - derived from the unscaled base density and the current scale - and report it from hwc_attribute(), so the DPI reported to SurfaceFlinger matches the scale after a change, as a fresh boot at that scale would. Note this updates the value hwcomposer reports; making WindowManager act on a live density change additionally needs a platform-side hook (setForcedDisplayDensityForUser) that a vendor HAL cannot reach, so the UI zoom still only changes on a session restart.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On a compositor using fractional scaling, the hwcomposer never lowers its cached
output scale once it has been raised. If the output scale is ever bumped to a
fractional value and back — a mixed-DPI multi-monitor setup, a suspend/resume, a
display re-plug, or a kiosk showing a scale-1.5 UI between Android activities —
the cached scale latches at the integer ceiling. Every surface created
afterwards is then presented at half size on each axis (i.e. filling one
quarter of the screen), top-left anchored, until a full
waydroid session stop/start.Android-side state stays correct throughout (display size, density, task
bounds); only the Wayland presentation is wrong.
This PR makes the scale actually track the compositor:
wl_output.scaleno longer latches the cached scale,wp_fractional_scale_v1.preferred_scaleis followed for the life of everysurface (not just during boot calibration),
xdg_outputis used to follow scale changes even when no surface ismapped, and
Reproduction
Any wlroots-based compositor with
wp_fractional_scale_manager_v1(sway below),persist.waydroid.multi_windows=true, any app:Also reproduces with the flips queued across an
lxc-freeze/lxc-unfreeze(theevents replay on thaw), so there is no event-ordering workaround.
This is very likely the same root cause as #1347 ("Waydroid only displaying
in one quarter of the screen", mixed-DPI 2.0 + 1.0 on labwc, with reporters
noting it appears/disappears across display power-cycles and suspend/resume).
Root cause
Two things combine, both in
hwcomposer/wayland-hwc.cpp:output_handle_scale()latched monotonically.wl_output.scalecarries the legacy integer scale — a compositor usingfractional scaling reports
ceil()here (2 for a 1.5 output). With thestd::max, once the output goes to 1.5 (event → 2) and back to 1 (event →1), the scale stays pinned at 2 forever. It also makes the outcome
independent of event ordering, which is why a frozen-container replay
poisons identically, and why a mixed-DPI multi-output setup latches onto
the highest-scale output even when the window is on the scale-1 one.
With
set_buffer_scale = ceil(2) = 2on a scale-1 output, every surface ishalved on each axis — the observed failure (a 1920 px render occupies 960).
The
wp_fractional_scale_v1object was destroyed after boot calibration,so no
preferred_scaleever arrived while the session was warm and nothingcorrected the latch.
Changes
output_handle_scale()— whenwp_fractional_scale_manager_v1isavailable, the legacy integer scale is only used to seed an initial value
and is otherwise ignored. Behavior is unchanged when the compositor has no
fractional-scale manager.
wp_fractional_scale_v1kept for the window's lifetime(
window::fractional_scale), sopreferred_scalekeeps arriving. This is theauthoritative source whenever a surface exists, since it reflects the output
that surface is actually on.
xdg_outputsupport — the scale is also derived fromxdg_output.logical_sizevswl_output.mode(1920/1280 = 1.5). This is theonly way to follow a scale change while no surface is mapped, since
preferred_scaleis only delivered for mapped surfaces. It is applied onlywhen there are no live windows, so it never fights
preferred_scaleon amixed-DPI multi-output setup.
Note:
zxdg_output_manager_v1is bound at version ≤ 2 on purpose — version 3deprecates
zxdg_output_v1.done, which we rely on.apply_scale_change()— a single path used by both sources: publishwaydroid.display_scale, keep the reported DPI in step, re-derive the displaysize from the compositor's logical size, and hotplug so SurfaceFlinger re-reads
the config.
full_widthcomesfrom
wl_output.modeand is in physical pixels, so the old fallback dividedit by the scale — baking in a wrong size whenever the scale was not yet known.
xdg_outputreports logical size directly. (This is theNOTICEthe codealready carried in
output_handle_mode, and the same class of problem as#1867.)
Validation
Claude's testing
Built from source and run in a live session under headless sway (wlroots,
1920×1080, LineageOS 20 x86_64), measured from
grimcaptures of the output.lxc-freeze/unfreezedisplay_scaletracking, surface mappeddisplay_scaletracking, no surface mappedwm size1920×1080, density 270Human testing
Run on a Microsoft Surface Pro (1920x1080, DPI 1.5) in Sway.
Pardon the camera -- the kiosk setup does not lend itself well to direct screen grab.
Before
20260730_211122.mp4
After
20260730_210851.mp4
Scope / known limitations
wp_fractional_scale_manager_v1.ro.sf.lcd_densityis a read-only propertyand keeps its boot value, so the live DPI is carried in
display->densityandreported from
hwc_attribute(). That does not move WindowManager's density:changing it live needs
setForcedDisplayDensityForUser, a framework API thata vendor HAL cannot reach (and
IWaydroidTaskexposes no density method). Soa warm scale change converges on the correct geometry and presentation, but
not on the UI zoom a fresh boot at that scale would give. Closing that gap
needs a platform-side hook and is left out of this PR.
persist.waydroid.width/heightare treated as logical units and then multiplied by the scale, sopinning them to the physical mode double-applies the scale (1920 pinned at
scale 1.5 →
wm size2880×1620). Verified identical on an unpatched build.Worth its own issue; workaround is to pin the logical size (1280×720 at 1.5)
or leave them unset.
Test environment
Claude's testing
persist.waydroid.multi_windows=true.Human testing
u/albert/2/android-activity/#75Generative AI disclosure
I discovered this issue (quarter scale in the top left) manually while developing Android app support for shepherd-launcher. I then prompted Claude Code to first investigate my repository, then here once that was inconclusive. This PR is my attempt to upstream the fix.
Please let me know if you have any concerns with this -- I'm happy to split this up, go into more detail about any part, perform additional manual testing, prep a similar change against Lineage 23, etc.