Skip to content

hwcomposer: follow output scale changes instead of latching at boot - #76

Open
aarmea wants to merge 3 commits into
waydroid:lineage-20from
aarmea:u/aarmea/fractional-scale-tracking
Open

hwcomposer: follow output scale changes instead of latching at boot#76
aarmea wants to merge 3 commits into
waydroid:lineage-20from
aarmea:u/aarmea/fractional-scale-tracking

Conversation

@aarmea

@aarmea aarmea commented Jul 31, 2026

Copy link
Copy Markdown

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.

comparison

This PR makes the scale actually track the compositor:

  1. the legacy integer wl_output.scale no longer latches the cached scale,
  2. wp_fractional_scale_v1.preferred_scale is followed for the life of every
    surface (not just during boot calibration),
  3. xdg_output is used to follow scale changes even when no surface is
    mapped
    , and
  4. a scale change now reconfigures the display instead of only being cached.

Reproduction

Any wlroots-based compositor with wp_fractional_scale_manager_v1 (sway below),
persist.waydroid.multi_windows=true, any app:

OUT=HEADLESS-1
APP=com.android.settings

swaymsg "output $OUT scale 1"
waydroid session start &            # wait for sys.boot_completed == 1
waydroid app launch $APP            # correct: fills the display

waydroid shell am force-stop $APP
swaymsg "output $OUT scale 1.5"; sleep 5
swaymsg "output $OUT scale 1";   sleep 2

waydroid app launch $APP            # BUG: renders at half size, top-left

Also reproduces with the flips queued across an lxc-freeze/lxc-unfreeze (the
events 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:

  1. output_handle_scale() latched monotonically.

    d->scale = std::max((int)d->scale, scale);

    wl_output.scale carries the legacy integer scale — a compositor using
    fractional scaling reports ceil() here (2 for a 1.5 output). With the
    std::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) = 2 on a scale-1 output, every surface is
    halved on each axis — the observed failure (a 1920 px render occupies 960).

  2. The wp_fractional_scale_v1 object was destroyed after boot calibration,
    so no preferred_scale ever arrived while the session was warm and nothing
    corrected the latch.

Changes

  • output_handle_scale() — when wp_fractional_scale_manager_v1 is
    available, 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.
  • Per-surface wp_fractional_scale_v1 kept for the window's lifetime
    (window::fractional_scale), so preferred_scale keeps arriving. This is the
    authoritative source whenever a surface exists, since it reflects the output
    that surface is actually on.
  • xdg_output support — the scale is also derived from
    xdg_output.logical_size vs wl_output.mode (1920/1280 = 1.5). This is the
    only way to follow a scale change while no surface is mapped, since
    preferred_scale is only delivered for mapped surfaces. It is applied only
    when there are no live windows, so it never fights preferred_scale on a
    mixed-DPI multi-output setup.
    Note: zxdg_output_manager_v1 is bound at version ≤ 2 on purpose — version 3
    deprecates zxdg_output_v1.done, which we rely on.
  • apply_scale_change() — a single path used by both sources: publish
    waydroid.display_scale, keep the reported DPI in step, re-derive the display
    size from the compositor's logical size, and hotplug so SurfaceFlinger re-reads
    the config.
  • Calibration fallback now prefers the real logical size. full_width comes
    from wl_output.mode and is in physical pixels, so the old fallback divided
    it by the scale — baking in a wrong size whenever the scale was not yet known.
    xdg_output reports logical size directly. (This is the NOTICE the code
    already 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 grim captures of the output.

Check Before After
Boot @1, flip 1→1.5→1, relaunch 49%×49% (half each axis) 98%/99% — identical to fresh boot
Same, flips queued across lxc-freeze/unfreeze broken identical
4 consecutive kiosk close/reopen cycles broken all identical, scale steady
display_scale tracking, surface mapped never updated 1.0↔1.5 within ~2 s
display_scale tracking, no surface mapped never updated tracks 1.5 / 2.0 / 1.0
Boot @1.5 (physical mode 1920×1080) wm size 1920×1080, density 270 unchanged (no regression)

Human 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

  • No behavioral change for compositors without
    wp_fractional_scale_manager_v1.
  • Live density is not changed. ro.sf.lcd_density is a read-only property
    and keeps its boot value, so the live DPI is carried in display->density and
    reported from hwc_attribute(). That does not move WindowManager's density:
    changing it live needs setForcedDisplayDensityForUser, a framework API that
    a vendor HAL cannot reach (and IWaydroidTask exposes no density method). So
    a 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.
  • Unrelated pre-existing bug, not addressed here: persist.waydroid.width /
    height are treated as logical units and then multiplied by the scale, so
    pinning them to the physical mode double-applies the scale (1920 pinned at
    scale 1.5 → wm size 2880×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

  • Waydroid 1.6.2, LineageOS 20 / Android 13 x86_64, vendor MAINLINE.
  • sway (wlroots), headless output, pixman renderer.
  • persist.waydroid.multi_windows=true.

Human testing

  • Microsoft Surface Pro (original): 1920x1080 at DPI 1.5
  • Same Waydroid/LineageOS as above
  • sway (wlroots) via shepherd-launcher on u/albert/2/android-activity/#75

Generative 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.

aarmea added 3 commits July 29, 2026 22:13
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.
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