Skip to content

fix: scale AX coordinates into HID space on display-downscaled devices - #90

Merged
onevcat merged 1 commit into
lycorp-jp:mainfrom
subdiox:fix/downscaled-display-hid-coordinates
Aug 6, 2026
Merged

fix: scale AX coordinates into HID space on display-downscaled devices#90
onevcat merged 1 commit into
lycorp-jp:mainfrom
subdiox:fix/downscaled-display-hid-coordinates

Conversation

@subdiox

@subdiox subdiox commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

On iPhone 12/13 mini (and the 6/6s/7/8 Plus family) every AX-derived tap, swipe and point hit-test lands ~4% away from its target.

These models lay out a larger point space than they display. The mini renders 375x812 points into a 1125x2436 buffer and downsamples it onto a 1080x2340 panel, so NativePortraitSizepixels / scale, the space FBSimulatorIndigoHID normalizes against — reads 360x780 while AX frames stay in 375x812. DisplayOrientation's portrait mapping is the identity, so the UI point was handed to HID verbatim and arrived multiplied by 812/780 = 1.041.

Measured live on iPhone 12 mini / iOS 26.3.1 (Settings.app, sim-use v0.12.0):

$ sim-use tap '#com.apple.settings.homeScreen'   # row y 648...700
✓ Tap at (187.5, 674.5) completed successfully
→ opened 「検索」, the *next* row (y 701...753)

674.5 x 1.041 = 702. Same cause in the wild: a 48pt-tall button in a login flow could only be pressed by dividing its center by 1.041 by hand, and a describe-ui-driven agent has no way to know that.

It also degrades calibration itself — the AX screen size matches neither native nor its swap, so orderedCandidates cannot prune and the probe budget is spent re-deriving portrait.

Fix

Calibration carries a UIPointScale: the metric ratio between the UI point space and the framebuffer point space, recovered from the AX-reported screen size (rotation swaps the two dimensions but never changes the two numbers, so sorting them yields the portrait-major UI size before the orientation is known). uiToFramebuffer / framebufferToUI apply it in both directions, swapping the factors along with the dimensions in landscape.

Everything that reads the UI screen size goes through the same scale, so they agree with HID again: the gesture preset canvas, the stale-snapshot advisory, and the quadtree's UI-space probes (wrappedProbe now wraps in portrait too, since portrait is no longer necessarily identity).

Devices that render at their panel resolution are unaffected — the scale is exactly identity and hidCGPoint short-circuits as before. A UI size that is a window rather than the display (a resized iPad scene) is rejected by an axis-skew guard instead of becoming a bogus scale.

Verification

Live, iPhone 12 mini / iOS 26.3.1 — same command, same screen:

tap #com.apple.settings.homeScreen (row y 648...700)
v0.12.0 opens 「検索」 (y 701...753) ✗
this branch opens 「ホーム画面とアプリライブラリ」 ✓

Regression, iPhone 17 / iOS 26.5 (renders 1:1): tap '#com.apple.settings.camera' opens カメラ, unchanged.

Unit: make test — 978 tests green, including a new Display-downscaled devices suite (portrait dispatch pinned to the live measurement, uiSize in UI space, round-trip identity in all four orientations) and a UIPointScale suite (identity on 1:1 panels, degenerate sizes rejected, recovered from either orientation, window-sized UI rejected).

iPhone 12/13 mini and the 6/6s/7/8 Plus family lay out a larger point
space than they display: the mini renders 375x812 points into a
1125x2436 buffer and downsamples it onto a 1080x2340 panel. The space
HID normalizes against is pixels / scale, so NativePortraitSize reads
360x780 there while AX frames stay in 375x812 — and because the portrait
mapping was the identity, every AX-derived coordinate reached HID 4.1%
away from the origin.

Measured on iPhone 12 mini / iOS 26.3.1: tapping a Settings row spanning
y 648...700 opened the row below it, and a 48pt-tall button could only
be hit by dividing its center by 1.041 first.

Calibration now carries a UIPointScale — the metric ratio recovered from
the AX-reported screen size, which rotation swaps but never changes — and
uiToFramebuffer / framebufferToUI apply it in both directions. The
gesture canvas, the stale-snapshot advisory and the quadtree's UI-space
probes read the UI size through the same scale, so they agree with HID
again. Devices that render at their panel resolution keep the identity
transform, and an axis-skew guard rejects a UI size that is a window
rather than the display (a resized iPad scene) instead of turning it
into a bogus scale.

Signed-off-by: yuta.ooka <yuta.ooka@lycorp.co.jp>
@onevcat

onevcat commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Good catch! LGTM. Thank you!

@onevcat

onevcat commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

I have some minor following fixes in #92, but it does not block this PR.

@onevcat
onevcat merged commit e4de2f2 into lycorp-jp:main Aug 6, 2026
8 of 10 checks passed
onevcat added a commit that referenced this pull request Aug 6, 2026
The orientation self-calibration README still stated the #34-era premise
(hit-test == HID framebuffer space) in its space table, Decision,
Implementation notes, and Open items, and showed the quadtree probe
wrapped through hidCGPoint. A prominent update note now records the
metric split (PR #90/#92) with the live evidence, the affected
statements are corrected in place, and the Open-items caveat that
anticipated exactly this divergence is marked resolved.

Also the last stale code comments: the OrientationCalibrator enum doc
("shares the HID coordinate space"), the EmptyShellTreeTests coverage-
grid note, and the OrientationRecoveryTests fixture wording (1:1
fixture, behaviour unaffected) now use the axes/metric terms.

Released CHANGELOG entries are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: onevcat <onevcat@gmail.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.

2 participants