Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Relative pointer on iOS 15

One relative report moves the pointer a fixed distance. The distance depends only on the size of that report. ghosthand computes the reports for any move and lands within 0.2 pt.
HID report (dx, dy) ──▶ IOHIDPointerScrollFilter ──▶ AssistiveTouch ──▶ pointer v = floor(sqrt(dx² + dy²)) move = (dx, dy) · table(v) / v points, clamped at edges
Code: Sources/ghosthand/relative-pointer.swift. Tests: Tests/ghosthandTests/relative-pointer-tests.swift.

The table

table is defaultAccelTable in IOHIDFamily, curve for acceleration 1.0. Points are 16.16 fixed values; x is scaled by 400 dpi / 67 and y by 96 / 67:
Report speed vPointer movesSlope
0 to 2.60 to 0.54 pt0.20 pt per unit
2.6 to 25.70.54 to 23.6 pt1.0
25.7 to 71.623.6 to 136 pt2.45
71.6 to 137136 to 199 pt0.97
137 to 206199 to 214.9 ptsmall
206 and more214.9 pt (cap)0
Sources:
The parametric HIDAccelCurves in the same repo do not match the phone (off by 25% to 3x). Only the default table does.

Measurements

iPhone 6s, iOS 15.8.4, AssistiveTouch at default Tracking Speed. Each row: home in the corner, N reports of (d, 0), pointer found in a USB frame.
dNTableMeasured
140081.4 pt81.5 pt
473138.6138.5
825147.6147.5
24487.787.5
641117.4117.5
1281190.6190.5
2001214.5214.5
Also measured:
  • Diagonals: (30,40) → 50.0, 66.5 pt; (3,4) × 34 → 59.0, 79.0 pt. Both as the table says.
  • Timing: 25 × (8,0) with 0, 8, 16, 30, 60 and 100 ms gaps: 147.5 pt every time.
  • Fractions add up: 20 × (1,0) → 4.0 pt.
  • Cap: one (−3000,−3000) report moves 152 pt per axis, not to the corner.
  • Match gain: 1.000 on x and y.
  • Accuracy: 40 moves in a row, each from the last predicted spot: 0.2 pt mean error, 0.36 pt max. The pointer was found to 0.25 pt, so this is the measuring limit.

Timing rules

RuleWhy
Wait 150 ms after homingAfter a report hits an edge, iOS drops moves for up to ~100 ms: 3 of 8 reports lost with a 40 ms pause, none with 100 ms
Wait 100 ms between the last move and a pressPresses 0 to 30 ms after a move burst were lost, and twice opened Notification Center. 60 ms and more worked every time
Do not home before every tapHoming pushes into the top edge. Keep the model's position and home only when it is unknown (daemon start, reconnect, rotation, 60 s idle)
Keep targets inside the screenA clamped report makes the tracked position unreliable, so the next move homes again

Not tested

  • Tracking Speed other than the default. Matching fits a gain that scales the table. If another speed changes the table shape, taps miss by a few points and the match log shows different x and y gains.
  • iOS 16, and iPhones other than the 6s.

Findings log

Found in opencode session ses_f183f876dffeunVOl7gtSaEhIW (Discord thread 1554090899112468580), on 2026-09-28.
Rejected ideas:
IdeaWhy not
IOHIDFamily parametric HIDAccelCurvesOff by 25% to 3x on the phone. iOS uses defaultAccelTable
Constant 10-unit steps every 16 ms (old code)Correct but slow (1-3 s) and quantized: up to half a step (~3.4 pt) off per move
One huge report to homeCapped at 214.9 pt per report; homing needs several
Declare a higher resolution in the descriptorChanges only the unit, not the curve (IOHIDEventService.cpp resolution math)
Turn acceleration offNo setting on iPhone; a HID device cannot set NoAcceleration
Absolute or digitizer reportsiOS 15 AssistiveTouch dropped them over BLE (not tested over Classic); absolute works from iOS 17
Closed loop: check each tap with a frameNot needed: open loop lands within the measuring limit (0.25 pt)
Bluetooth pairing trap (seen while testing): after the phone was reset, the Mac still had a bond ("iPhone", Peer removed pairing information in the daemon log) and pairing failed. Removing the Mac's bond (blueutil --unpair <address>) fixed it. Do it only once: running it again after the phone re-paired deleted the new bond, and the phone showed "Connesso" but never subscribed (BLE). Over Classic, a stale bond made the iPhone reject the Mac's channels; forget the Mac on both sides and pair again.
Open issue: ghosthand lock (Control-Command-Q) did not lock the iPhone 6s on iOS 15.8.4 in this session. Not investigated.
Experiment script: tmp/phase0.py (git-ignored). It used a temporary /debug/pointer daemon route that sent raw reports and saved frames; the route was removed after the measurements.