iPhone Duo bring-up
Kernel CSSC fallback (2026-10-25)
This branch starts from upstream main, without the static CSSC trampoline experiment. On an independent Banana clone, the installed experimental image panicked with PID 1 SIGILL at user PC 0x2bd6380e8, ESR 0x02000000. The kernel's exception codes contained the copied instruction 0xdac1a7fe (PACIBSPPC), matching /usr/lib/libSystem.B.dylib at image offset 0x10e8. CSSC allocation had aborted the entire X1 cache operation, leaving PAuth/CPA conversion unapplied; the installer nevertheless continued. The new route retains upstream PAuth/CPA conversion and handles CSSC independently in the kernel.
Reveal procedure for kernelcache.research.vphone600, UUID
784F88D3-2AF5-3D4A-AB00-365607646E44:
- Find the unique function referencing
Undefined kernel instruction: pc=%p instr=%x. Identify it as XNU handle_uncategorized by decoded control flow, not a recovered symbol (the indexed research symbol JSON is absent). - Validate its copyin of four bytes from the saved PC and its EL0/EL1 gate. Hook only the user path before undefined-instruction triage. The observed handler file offset is 0x1e626ec; the hook is 0x1e62918.
- Follow the handler's existing signed-state manipulation sequence to recover ml_check_signed_state (0x1caa37c), ml_sign_thread_state (0x1caa32c) and its interrupt disable/restore helpers. Validate register and saved-state ABI, PACGA bodies, and the original stack-canary checked epilogue. These names are source correlations; offsets are observations, never runtime constants.
- Plan the complete fixed-size payload and one entry branch before writing. Restrict it to persistent __TEXT_EXEC; do not use boot-only reclaimed text. Unknown or ambiguous layouts abort the selected operation.
The fallback accepts only 64-bit user saved states with an uncategorized exception and successful instruction copyin. It evaluates scalar ABS/CNT/CTZ and signed/unsigned MIN/MAX register and immediate forms, preserving the saved NZCV and all unrelated registers. W results zero-extend and register 31 is ZR. A successful operation verifies the old signed state, updates the destination and PC, and re-signs with the new LR/X16/X17 when applicable. There are no stack accesses while SPSel selects the interrupt stack. The original canary check and epilogue remain in use. Unknown words preserve the original instruction, registers, kernel flags and exception path.
Kernel emulation has per-execution exception overhead. Native CSSC execution does not enter this fallback; PAuth/CPA retain the existing X1 conversion path.
Scope and inputs (2026-10-23)
This experiment attempts to boot the iPhone Duo userland on the existing vphone600 virtual hardware. It registers one outer panel, not two displays or a hinge. The inner panel, folding poses and physical Duo hardware are not emulated.
- Host: Apple Silicon, macOS 27.0.1 (26A434), without CSSC, CPA or PAuth_LR.
- Guest:
iPhone19,4, boardv68ap, iOS 27.1 (24A94425). - IPSW: https://updates.cdn-apple.com/2026FallFCS/3f41a447-d8a6-48ae-83ae-056a64f57fd4/iPhone19,4_27.1_24A94425_Restore.ipsw.
- Size: 12,724,731,538 bytes.
- SHA-256, verified after download:
58fbfda85615e90221136d55288f64c9f41386510f8cd77feb8c9d20cc0a782e. - CloudOS: 26.4 beta (23E5207q), the existing recommended vphone600 image.
- Preset:
standard; boot ROMs: the bundle's OpenAVB and OpenSEP. - VM configuration: 8 CPUs, 8192 MiB RAM, 64 GiB disk, created without a template.
- Core Bundle: a local 2.12.0 build; installed and passed execution policy and host preflight.
Evidence and initial implementation
The restore archive's BuildManifest.plist and Restore.plist both name
iPhone19,4, build 24A94425 and device class v68ap. Restore.plist records
CPID 33120 (0x8160), BDID 14 (0x0e), platform t8160.
The archive contains Firmware/all_flash/DeviceTree.v68ap.im4p. Its /product
records app-macho-architecture = arm64e.x1, artwork-device-idiom = phone,
artwork-device-subtype = 2034, artwork-scale-factor = 3, plus display0
and display1 children. The former has display index 0, rotation 0 and scale
3; the latter has index 1, rotation 270 and scale 3. Those are source device
properties, not proof that both panels work in the VM.
The initial model entry uses the outer display's 1398 x 2034 pixels at 460 ppi and 3x, as published in Apple's technical specifications. The catalog adds only the released 27.1 IPSW, paired with the existing recommended cloudOS. No new instruction patch or preset is introduced; the current iOS 27 and ARM64E_X1 patches are exercised on this new userland.
VPhoneCoreKitTests/GuestDeviceTests and
VPhoneCoreKitTests/FirmwareCatalogReportTests passed: 26 tests total.
The complete VPhone Debug build passed. Its finished bundle and a ZIP
round-trip both passed strict, nested, all-architecture signature validation
before the local bundle was installed through Launchpad.
Boot validation
Result: does not boot to userspace on this host. Firmware preparation,
boot-chain patching, DFU restore and CFW installation completed. Template
setup failed while waiting for vphoned because the guest panicked. A subsequent
windowed vm start <name> --wait --timeout 300 reproduced it before any guest
agent connection or SpringBoard UI:
Namespace 2/subcode 4 reports SIGILL. The saved user-space backtrace LR is not a recovered fault PC or a verified unslid symbol address. Do not infer an exact instruction from it without the image load address and fault state. Runtime addresses and local machine identifiers are omitted from this record. The VM was stopped after preserving the console log. Its disk, firmware and installed guest environment remain available for investigation.
Identified instruction compatibility gap
Host feature queries return zero for hw.optional.arm.FEAT_CSSC,
hw.optional.arm.FEAT_CPA and hw.optional.arm.FEAT_PAuth_LR.
The installed, already X1-lowered /usr/lib/dyld still contains scalar CSSC
instructions: for example UMIN X8,X8,X1 at image offset 0x9570, UMAX
at 0x8ba8, and CTZ W10,W21 at 0x1af04. These are measured offsets in
the 24A94425 dyld, for investigation only, never patch-site constants.
VPhoneArm64eX1Lowering and the runtime X1 handler currently lower PAuth_LR
and CPA forms, not these CSSC operations. CSSC therefore is a confirmed
compatibility gap and a candidate explanation for the SIGILL, not yet a
proved fault-site attribution. The cache's early-boot closure and later
runtime code also need auditing before claiming 27.1 support. A safe fix
needs correct register width, operand aliasing, NZCV preservation and immediate
forms; replacing a min/max with an unconditional move is not equivalent.
Other observed gaps
- Four optional VirtualAudio patches failed together because the old reveal
expected a
retabafter the throw at0x171cd0:system-virtualaudio-cfw-mute_set_throw,system-virtualaudio-cfw-speaker_protection_gate,system-virtualaudio-cfw-speaker_route_throws, andsystem-virtualaudio-cfw-volume_mode_precondition. - The shared-region preflight attempted the ordinary arm64e filename and
skipped its check; later cache patching correctly found 79 X1 chunks and
measured
sharedRegionSize = 0x17ce04000,maxSlide = 0x20000000, then zeroed maxSlide. No cache-size overflow was reported by that later stage. - Existing X1 lowering reported 2735 instructions in launchd, with zero unvisited candidates. This only counts the instruction forms it recognizes; it does not establish support for every instruction in the binary.
The registered model and catalog entry are experimental bring-up inputs, not a claim of successful Duo support. No CSSC or audio patch was added by this experiment. Supporting this firmware on hosts without CSSC requires additional instruction compatibility work; emulating both displays and the hinge is a separate gap. These results establish limitations of the current implementation, not that supporting the device is technically impossible.
The original manifests and device tree were extracted by HTTP ranges with
the repository's VPhoneRemoteZip. Raw logs and scratch artifacts remain
local; host paths, per-machine identifiers and runtime addresses are not
included in this research record.
Boot on a host with CSSC (2026-10-23)
Result: boots to SpringBoard. On a Mac17,9 (M5 Pro), macOS 27.0.1
(26A434), hw.optional.arm.FEAT_CSSC is 1; FEAT_CPA, FEAT_CPA2 and
FEAT_PAuth_LR are 0. Scalar UMIN/UMAX/CTZ execute on the host. The
same IPSW, the same standard preset and a Core Bundle containing this
document's first commit (no CSSC lowering) completed every vm create --device iPhone19,4 --no-template step, including template setup, which
waits for vphoned. The home screen rendered at 1398 x 2034. Its side dock and
status column on the right are the Duo's outer-display design, not a fault.
CSSC is therefore the blocker on hosts without it; PAuth_LR and CPA remain
covered by X1 lowering.
On-demand X1 handling stalled the userland
That boot was barely usable: windows took seconds to draw, widgets stayed
blank, and FrontBoard killed extensions. 18 minutes after boot, launchd
(PID 1) had used 685 CPU-seconds. Crash reports showed MercuryPosterExtension
(43 reports), WidgetRenderer_Default, DockFolderViewService and others
ending in scene-create watchdog transgression … 10.00 seconds, many with a
pending EXC_BAD_INSTRUCTION whose code is the X1 word itself:
0xdac1a7fe (PACIBSPPC) in SwiftUI and /usr/lib/usd/libusd_ms.dylib,
0x5520031f (RETABSPPC) in libusd_ms.dylib.
The cause is the design of the X1 path, not a missing instruction form.
dyld-boot-arm64e_x1_bootstrap lowers only the 38 cache images needed
before launchd installs its exception service; every other image faults
into that service, which runs on one thread in PID 1, suspends the task,
makes a private copy of the page and lowers one instruction. A private copy
means each process re-faults every site it executes, so SwiftUI-heavy
extensions queue on launchd until the 10-second scene watchdog fires, and
the poster extension's restarts keep the queue full.
Lowering the rest of the cache ahead of time
dyld-cfw-arm64e_x1_cache (guest system set, iOS 27, on in standard,
blocked in both tvOS presets; verb vphone-cli cfw patch-arm64e-x1-cache)
runs the bootstrap patch's planner over every X1 image outside the
bootstrap closure, writes through the undo-capturing chunk set and
re-attests every modified page. An image the planner cannot plan is left to
the on-demand handler and logged; none were on 24A94425.
The installed guest receives 24,606,071 rewrites, 12 fewer: other dyld patches already occupy those sites.
The planner needed changes to be affordable at this size; each was checked by patching a stock copy of the cache and comparing all 81 cache files with the previous version's output (and, for the bootstrap patch, with the bytes HEAD wrote), and every comparison was identical:
The planning core, CustomFirmwareArm64eX1.planCode, is shared with the
standalone-executable patches; its output is unchanged by these edits.
After vm set-bundle … --update-environment applied the patch to the
existing guest, launchd had used 14.8 CPU-seconds two minutes after boot,
no poster or widget-renderer watchdog kills recurred, and Settings drew its
first screen within seconds of launch. Standalone X1 binaries outside the
cache (app executables, plug-ins such as VirtualAudio.plugin, daemons)
still use the on-demand handler.
Undo words instead of page runs (issue #789, 2026-11-03)
cfw install on a 16 GB M4 Mac mini stopped after X1 bootstrap cache: 228314 instruction rewrites in 2875 page run(s) for over an hour and the
host slowly froze. The lowering was not the problem; its undo log was. The
chunk set captured the original bytes of every page run it wrote, and the
verb merged them into .vphone-dsc-undo.json as base64. Measured on a stock
24A94425 cache with the 2.15.0 CLI (M5 Pro, 64 GB):
Every cache verb that recorded anything after the X1 step decoded and re-encoded the whole 4.4 GB file, about 20 GB each.
The X1 lowering now records one (address, original word) pair per
instruction it changed, 12 bytes apiece, in .vphone-dsc-undo.words beside
the JSON log (DyldSharedCacheUndoLog.words), and writes its page runs with
capturingOriginal: false. The JSON format is unchanged, so an older CLI
reads every other patch's records and treats the X1 patches as having none.
Verbs that captured no words never open the words file. Two FileHandle reads
(DyldSharedCacheChunkSet.read, the re-attest page loop) now drain their
autoreleased buffers per read: without that, the remaining 9.3 GB peak was
3.3 GB of write-phase reads plus 3.4 GB of re-attest reads, all kept alive
until the process exited.
A guest installed by 2.15.0 or earlier still holds the page runs. cfw install and update-environment run cfw patch-dsc-undo-words first,
which keeps from each X1 run only the words that differ from the cache now.
On the 2.15.0 log above that took 331 s with an 8.05 GB peak once (it still
has to decode the 4.4 GB JSON), and a second run is a 0.13 s no-op. It keeps
24,834,435 words: the 24,834,397 lowered instructions, the corecrypto HMAC
record (8 words), and 30 words of iomfb_force_kern that fall inside X1 page
runs. Reverting only the X1 patches while that one stays puts those 30 words
back to stock, exactly as reverting the page runs did.
Checks on the 24A94425 cache: the new CLI's bootstrap + whole-cache +
force-kern output is byte-identical to 2.15.0's in all 80 chunks; reverting
those three from words gives back the stock cryptex's 80 chunks
byte for byte, signatures included, and so does reverting them from the
converted 2.15.0 log. DyldSharedCacheUndoWordsTests covers the merge
rule, the file format and its damage checks, page-grouped restore over a
synthetic cache, refusal of an unmapped word, and the conversion.
VirtualAudio on 27.1
The four VirtualAudio CFW patches failed on 24A94425 because every function
there starts with PACIBSPPC and returns with RETABSPPC <label>; the
reveal knew only PACIBSP/RETAB. The patcher now accepts both families
and requires a labelled return to name the function being walked. Capstone
reports that label as #0, so it is read from the decoded word's field.
All nine sites patch on 27.1 and the 26.6.2 and 27.0 sites are unchanged.
With them live, audiomxd crash-looped on this guest (a null read in
VirtualAudio.plugin reached from SetPropertyData): the device update
reads the DeviceStateManager singleton before deferred initialization
publishes it. Root-caused and fixed by
system-virtualaudio-cfw-device_state_null_gate (make the two gated
device-state updates wait for the DeviceStateManager singleton — on a
guest with no haptic hardware it is never constructed — instead of
dereferencing null; arm64e.x1 plugins only, earlier builds
byte-identical) — see
Research/Guest/virtualaudio_speaker_route_throws.md § "Layer 5" and
Research/0_binary_patch_comparison.md. Verified on this VM
(2026-10-24 morning, bundle 2.12.0-local.6a386de6): both gate calls
redirected as designed, audiomxd holds one pid past five minutes — the
window the two earlier variants died in — with zero new crash reports
and the system-sound stream still routed on PuffinOutput.
Fold posture (2026-10-23)
Static analysis of the 24A94425 cache, then a live run on duotest-m5
(M5 Pro) with preboot-cfw-devicetree_foldable_vm_subtype and
display.posture installed:
- The repair logged
target-sub-type: 'V68AP' -> 'V68VM2025', and after boot SpringBoard had createdSBFDevicePoseLastKnownCMAngleandSBFDevicePoseLastKnownCMMagicPoseincom.apple.springboard(still all zero: it persists after display-mode transitions, which a single display never makes). display.posture {hinge: "partially-open"}was accepted: SpringBoard logged[SBTestAutomationService] Received client request to set device pose override,Ambient magic pose event detected [ … hingeState : PO ; deviceOrientation : Po … ], andhinge MOTION STARTED…hinge SETTLED after 1.03s, with the home and lock screens deferring and resuming snapshots around it. Closed, partially open and open flat produced the same home screen: with only the outer panel there is no inner surface to move content to. StandBy reportedisMagicPoseSupported : NO.
Where the pose comes from. SBFDevicePoseProvider (SpringBoardFoundation)
reads BKSAccelerometer, CMAngleManager and CMMagicPoseManager.
SpringBoard's own CoreMotion hosts CLAngleNotifier (HID event type 0x2C,
IOHIDEventCreateHingeAngleEvent, virtual service 0xFF0C/0x21) and
CLDeviceStateNotifier (8-byte vendor event {2, orientation ×3, hinge state, tabletop pose, stable state, simulated}, virtual service 0xFF0C/0x22).
CoreMotion skips physical sensors when MobileGestalt reports a virtual device.
Why a virtual hinge HID service is not enough today. SpringBoard starts
CoreMotion pose updates only when the angle and magic-pose managers are
available and FBDisplayManager.alwaysConnectedIdentities.count >= 2.
Availability comes from CoreMotion's motion-capability bits, selected by the
device tree's /product/product-id; the guest keeps vphone600's id, which
CoreMotion does not know, so both are false. One display fails the second
gate. The HID route belongs with dual-display work, together with V68's
product-id (which also turns on the Duo checks in UIKit, Photos,
Accessibility and other frameworks).
What works now. -[SBSTestAutomationService setDevicePoseOverride:]
bypasses both gates: SpringBoard (SBTestAutomationService, client
entitlement com.apple.springboard.testautomation) builds an
SBFSyntheticMagicPoseEvent from the device orientation, hinge state and
tabletop pose and derives the angle from the hinge state. vphoned exposes it
as display.posture (see Research/vphoned_http_api.md).
Apple's VM sub-type. SpringBoard treats a device whose TargetSubType is
V68VM2025 as a VM: at launch it restores the angle and magic pose from
com.apple.springboard defaults SBFDevicePoseLastKnownCMAngle and
SBFDevicePoseLastKnownCMMagicPose, and _persistDevicePoseIfNeeded writes
them back after display-mode transitions. preboot-cfw-devicetree_foldable_vm_subtype
rewrites the installed Preboot tree's target-sub-type from V68AP to
V68VM2025 after restore (restore keeps the board's value). The only other
reader of V68AP in the cache is Sharing's SFDeviceHasFaceIDProxy; the
framework isV68 checks key on product-id and are unaffected.
Virtual hinge (2026-10-24)
The pose override above reaches SpringBoard only. CoreMotion's CMAngleManager
never sees a hinge: no sensor publishes virtual service 0xFF0C/0x21, and no
capability asks it to look, because CoreMotion selects its motion-capability
bits from the device tree's /product/product-id, which the guest keeps at
the VM platform's token. CoreDevice (DeviceHub) does not go through
SpringBoard at all: it asks the guest for a hingeAngle capability and
subscribes to the angle stream, which is CoreMotion's to answer.
Two patches close that path; both are on in standard, foldable guests only:
-
preboot-exp-devicetree_foldable_product_idreplaces the installed Preboot tree's/product/product-idwith the foldable board's own 20-byte token, taken from the donorDeviceTree.v68ap.im4pin the VM's FirmwareOriginals duringcfw install/cfw update-environment(verbcfw patch-dt-foldable-product-id; the repair runs after the VM sub-type one and accepts either sub-type). CoreMotion then reports the hinge-angle capability. The side effect is accepted by design: the frameworks'isV68checks key on the same token, so UIKit, Photos, Accessibility and others open their Duo branches on the one display the guest has. -
system-backboardd-cfw-hingeinstallslibvphonehinge.dylib, which SystemHook loads into backboardd. It publishes aHIDVirtualEventServicewith usage page0xFF0C, usage0x21(Product "VPhone Hinge Angle Sensor") and reports hinge-angle events built with HID.framework's C factorywhose signature was verified on macOS 27.0.1's HID.framework by calling it and reading the created event's own field description back (third argument = AngleDegrees, fourth = MechanicalAngleDegrees, fifth = the velocity);
make test-hingere-checks both the transport and that call. HIDEvent has no ObjC hinge factory selector — only-hingeAngle*accessors — so the C factory isdlsym'd from the framework the provider already opens. The service's life cycle, delegate selectors,ReportIntervalaggregation and status preference mirror the accelerometer provider; a subscriber's interval drives a report timer, so a stream subscription keeps receiving the current angle. The event's state field stays unset (the factory takes none): SpringBoard's pose keeps coming from the override, which also means the two can be driven apart by an explicitanglewhen a test wants that.
display.posture now drives both halves from one request: it sets the pose
override as before, then publishes the angle to the virtual hinge — the
state's own angle from SpringBoard's table (0, 0, 90, 180, 180 for states
0–4), or an explicit angle (0–180). The reply carries the angle and the
provider's state under hinge_hid (applied, provider_running,
provider), so a host can tell a live stream from an absent service. The
pose override failing still fails the request; the hinge provider missing
does not — the pose is the primary and the service may legitimately be
absent (guest without the patch, or backboardd not restarted yet).
Still open, for live validation on a foldable guest:
- whether CoreDevice's capability question is answered by the product-id alone or needs another DDI daemon's answer patched (DeviceHubFix territory);
- whether the angle stream subscribes through the virtual service's
ReportIntervalas expected, and what interval DeviceHub asks for; - whether the magic-pose vendor event (
0xFF0C/0x22,CLDeviceStateNotifier) is wanted as well — HIDEvent's+vendorDefinedEvent:usagePage:usage: version:data:length:options:can carry it if a client proves to read it.
Apple's own Duo simulator, reversed (2026-10-24)
The shipped iOS 27.1 simulator runtime (24A94425 — the same userland this guest restores) and Xcode's DeviceHub answer how Apple itself models the fold, with no guessing:
- The device type registers two displays.
iPhone Duo.simdevicetype's capabilities.plist carries adisplaysarray: the outer 1398×2034 panel (screenID1, phone15 chrome) and the inner 2853-pixel panel at native rotation 270 (phone14 chrome). SpringBoard's two-always-connected-displays gate is satisfied structurally, not by the product token. - The hinge is a recorded sweep, not a state. SpringBoardFoundation ships
hinge-samples.csv— 2,741 real samples captured by hand on an iPhone19,4 ("SBFHingeAngleRecorder", CoreMotion's 10.03 ms grid) with columns timestamp, angle, mechanicalAngle, progress, angleValid, velocity, velocityValid, state, eventPhase.SBFHingeSamplesloads loops from it (overridable at/var/mobile/Library/SpringBoard/hinge-samples.csv, envSBF_HINGE_SAMPLES), andSBFHingeSamplePlayback/SBFHingeSweep/SBFHingeReplaySettings/replayHingeSamplesWithSettings:samplesPath:completionHandler:replay a loop.SBFHingeAngleHUDViewis the internal HUD. - The three screen states are positions of one command. The help text of
Apple's internal
suiatool(embedded in SpringBoardFoundation) documents the sweep command:[-duration=<seconds>] [-orientation=…] [-position=<closed|book|open|0.0-1.0>] [-fade | -continuous] [-wait]—closed=0°,book≈100°,open=1.0 (180°); the device sweeps from its current angle to the target, a display swap happens only if the sweep crosses the boundary,duration=0is the instant "yoinking path", opening is faster than closing by default, and every sweep takes a 10-second testing lock (lockForTesting) so real hinge motion is ignored. - CoreDevice's read side (
DeviceMotionManager.monitorHingeAngle,supportsHingeAngle;DeviceHingeAngleSnapshotwith angle, mechanical, velocity, validity, min/max, timestamp) mirrors those same fields — the CSV's schema is the protocol's schema.
display.posture now follows this model: position names closed, book
or open (or a 0–1 fraction), duration (seconds, default per-direction,
0 instant) and profile (continuous | fade) request a sweep; the
virtual hinge provider interpolates from the current angle at 10.03 ms
cadence, reporting angle, velocity and hinge state on every sample
(synthetic ease — no Apple-recorded data is shipped), and the SpringBoard
pose override lands the settled state. The legacy hinge names still set
the pose instantly.
Live evidence from duotest-m5 (2026-10-24): the virtual hinge service
enumerated in backboardd (service_id reported, dispatched counting), and
display.posture {hinge: "partially-open"} published 90° with
provider_running: true. The foldable product-id patch
(preboot-exp-devicetree_foldable_product_id, off in every preset after
this) crash-looped SpringBoard into device-recovery — root-caused the next
day to the secure camera indicator's CASecureIndicatorLayer exception
and made survivable by the fold-guard (see the next sections). Escape:
block the patch, re-run cfw update-environment (the DT repair reverts
from its clean backup), and reboot; the forced boot-mode is one-shot and
the next healthy boot clears it. CoreMotion's hinge capability on a
single-display guest still needed a narrower route than the device-tree
token — matching Apple, which wires the capability to the second display,
not to the token alone — and that route is the stand-in below.
SpringBoard fold-guard (2026-10-25)
The crash the product token caused is root-caused and now guarded. With
the Duo token and one panel, SpringBoard aborts during its first Core
Animation transaction commit: the Duo branches build a secure status
indicator for the camera, and
-[CASecureIndicatorLayer _copyRenderLayer:layerFlags:commitFlags:]
raises CASecureIndicatorLayerInvalidName — reason "Invalid indicator
name Camera" — twice per boot (main-thread NSException → SIGABRT →
launchd's "critical process crashes" device-recovery; measured twice on
duotest-m5, iOS 27.1 24A94425; the reports live in the guest's osanalytics
DiagnosticReports, which logs.crashes does not scan). The guard is
libvphonefoldguard.dylib (system-springboard-cfw-fold_guard, foldable
guests only, iOS 27+): SystemHook loads it into SpringBoard only, and it
method-swizzles that one QuartzCore method with an @try/@catch that
logs (first five exceptions, to
/var/mobile/Library/Caches/vphone-foldguard.log) and returns nil so the
commit proceeds without the camera indicator. Measured: a boot that
crash-looped twice before reaches the home screen and stays up — the guard
log shows the install line and exactly the two swallowed exceptions. The
camera status dot is the casualty; a finer fix would register whatever
indicator the Duo branches look up.
With the token now survivable, the screen question was measured directly
(2026-10-25): under the full Duo identity (product token + guard), a
display.posture {"position":"book","duration":3} sweep publishes 100°
with the provider running — and the screen does not change. Screenshots
before and after the sweep are the same full-bleed layout: no letterbox,
no band, no split, no seam; the files differ only by JPEG noise
(clock/battery pixels). This matches Apple's own model — the simulator's
device type satisfies SpringBoard's fold gates with two registered
displays, and the guest's fold layout belongs to display topology, not
to the product token. So on a single-display vphone guest the token gives
identity and sensor data but never pixels; the visual fold waits on a
second display region in the device tree. The equivalent of Apple's
three-state simulator chrome for our guest is the fold control in our own
viewer, in two halves that landed 2026-10-25:
- The Motion Sensors › Fold Posture panel
(
UI/UserInterface/Panels/Fold/, capabilitydevice_pose) — a segmented Closed / Book / Open picker with a sweep duration, sendingdisplay.posture'spositionand showing the reply's angle, hinge state and provider status, with the single-display limitation stated in the panel itself. - The window's own fold chrome (
VPhoneDisplayContainerView): a successful posture publishes the settled angle onVPhoneGuestControl.foldAngle, and the display container renders the spine swallowing the screen. The guest's pixels are never scaled (a host-side squeeze read as a squashed wallpaper, not a fold — measured, then dropped); instead an opaque spine band grows from the center by exactly the fraction a hinge at θ hides from the viewer, 1 − cos((180−θ)/2), animated over the guest's own sweep duration. 180° leaves the panel untouched, ~100° covers the middle ~23% with the two halves at native scale, 0° closes the band over everything. This is the host-side visualization Apple's simulator window gives; the guest's pixels are unchanged, and touches keep mapping through the band to the untouched panel underneath. - The real panel follows at the next start (2026-10-25, measured): the
host refuses runtime display resizing on this stack —
VZGraphicsDisplay.reconfigureanswers VZErrorDomain 2 ("显示器此时不支持重新配置") windowed and headless, and the view-driven path (automaticallyReconfiguresDisplay+ panel/view resize) is inert; NSWindow has no public live-resize trigger to drive it either. But the boot-time size is fully real: the window process persists the fold's position beside config.plist (display-posture.plist, written on a successful posture), andVPhoneBootCommand.resolveOptions()sizes the display from it on a Duo — closed keeps the outer panel (1398×2034, the machine's own), open and book relaunch on the inner one (2034×2853,VPhoneGuestDevice.iPhone19_4InnerScreen). Measured both ways: with the file sayingopenthe guest screenshot is 2034×2853 (SpringBoard laid out for the inner panel); without it, 1398×2034. One posture change costs a restart — that is the price of a real screen-size change on this host, until a dual-display topology lands.
The validation, reversed (2026-10-25, foldguard v2)
The raise was taken apart in the guest's own lowered cache (duotest-m5
stopped, Disk.img attached read-only, the X1 cache at
System/Cryptexes/OS/System/Library/Caches/com.apple.dyld). Reveal
procedure: the ips's imageOffset for the raising frame is an offset from
the image's TEXT base, and the base comes from the cache header's
image-text table (this cache: entries at header+0x2cb68, stride 32 —
uuid[16], textAddress, textSize, pathOffset — a layout the 40-byte
dyld_cache_image_text_info guess does not match; QuartzCore's TEXT is at
0x18434a000, so the raise sits at 0x1844ce3e0 = _copyRenderLayer at
0x1844ce298 + the reported +328). Read from there:
_copyRenderLayerreads the layer'sprivacyIndicatorType, passes it to a validator at QuartzCore+0x185168, and raisesCASecureIndicatorLayerInvalidNameonly when the validator answers −1. The exception name and format live in a CFSTR block reached through the 0x1e9212000 data region, which is why no ADRP in the text subcaches references the raw cstrings.- The validator first consults a runtime-registered collection (a global at
0x1e8f2a000+0x1c8) and only falls back to a static known-list when that
is nil — iOS 27's msgSend goes through per-selector trampolines in
libobjcMsgSend.dylib, so the two calls in the fast path are selectors, not C functions. - The class's own surface names the real check:
validPositionsandvalidPositionsForDisplay:(plus thevalidDynamicPositionsvariants). Verified live on macOS 27.0.1 against the same class: theprivacyIndicatorTypesetter accepts any string, and the positions methods answer rect arrays the window server registered per display — on this MacCameraandMicrophoneanswer two slots (~21.35 × 31.3 pt) andFaceID,ScreenRecordingand everything else answer none. A type with zero registered slots is exactly an "invalid indicator name". - The static name table sits in the same cstring block as the raise
strings:
Microphone,MicrophoneAccessibility,Camera— "Camera" is statically valid. The rejection can only come from the runtime registry path. The server-side counterpart (CAWindowServerDisplay, 297 methods in backboardd) carriessupportsSecureIndicators,setSecureIndicatorsEnabled:,setPrivacyIndicatorSurface:atPosition:andflushSecureIndicators:— the registration surface a real Duo panel exercises; the registry pointer the validator reads has no writer in QuartzCore's text (it is initialized at load), so it is populated from the window server, not by client code.
Two live experiments on the guest (foldguard v2/v2.1, then reverted) closed the case:
- The position selectors are never consulted. Hooking
validPositions/validPositionsForDisplay:with a synthesized-slot fallback logged nothing — the validator does not ask the layer. - The runtime registry is empty, not name-specific. Swizzling
privacyIndicatorTypeshowed SpringBoard asks with real names, and aliasingCamera→Microphone(both statically valid) still raised — as "Invalid indicator name Microphone". Every name is invalid because the virtual display registered no secure-indicator slots at all; the class dump (iOS adds onlyremainingTimeAsSteadyForDisplay:/remainingTimeToSteadyForDisplay:to the macOS surface) confirms there is no client-side registration API to call.
So the finer fix is server-side — backboardd must register indicator slots for the virtual display, the same missing display-topology family as the fold layout — and foldguard stays at the lean catch: install, log the two swallowed exceptions, answer nil. The camera indicator is the known casualty, now with the complete explanation of why no client-side repair can bring it back.
The CoreDevice capability chain, answered by a stand-in (2026-10-25)
Reversing the shipped pieces named the whole route DeviceHub's hinge angle takes, and where a single-display guest can enter it:
- The host asks through CoreDevice's
DeviceMotionManager(supportsHingeAngle,monitorHingeAngle(config:)→AsyncThrowingStream<[DeviceHingeAngleSnapshot]>), the capability familyMonitorMotion(queuescom.apple.dt.coredevice.HingeAngleQueueetc.). - The device side is the DDI's
dtdeviceinfod: itsHingeAngleManager/DeviceMotionStateManager/SpatialOrientationManagerwrap CoreMotion'sCMAngleManagerthrough a bridge class defined in the daemon (CMAngleManager_Bridge:+isAvailable,+minAngleDegrees,+maxAngleDegrees,-angleUpdateInterval,-isAngleActive,startAngleUpdatesToQueue:handler:,stopAngleUpdates), resolving the class withNSClassFromString.+isAvailableis the gate, and CoreMotion answers it from the product-id capability bits — the same bits the foldable token would set, and the token cannot be written on a single-display guest (see above). - Verified against macOS 27.0.1's CoreMotion:
CMAngleManager's full interface is+isAvailable,+minAngleDegrees,+maxAngleDegrees,-angleUpdateInterval,-isAngleActive,-setAngleUpdateInterval:,-setAngleHandler:interval:,-startAngleUpdatesToQueue:handler:,-stopAngleUpdates.
Opening the gate alone (v4) crash-looped dtdeviceinfod eight times over one
DeviceHub connect: with +isAvailable answering YES, the bridge started the
REAL manager, whose MotionThread asserts when its own product-id capability
bits contradict the answer (dtdeviceinfod SIGABRT on
com.apple.CoreMotion.MotionThread, 2026-10-25-000506…000600). So
libdevicehubfix (v5, VPCMAngleManager.m in DeviceHubFix/) now takes over
the manager's whole ObjC surface in dtdeviceinfod: isAvailable follows the
provider's liveness (the same test vphoned's readback uses, re-checked every
call, falling back to Apple's answer when the provider is down), the angle
range answers the virtual hinge's 0–180, and the update methods never touch
CoreMotion — they deliver samples read from the provider's newest published
sample (VPhoneHingeLatest, written beside every HID dispatch in
com.apple.backboardd/mobile without a per-tick disk sync), wrapped in a
runtime-registered object exposing the fields the bridge maps into
DeviceHingeAngleSnapshot (angleDegrees, mechanicalAngleDegrees,
velocityDegreesPerSeconds, both spellings of the validity flags, state,
eventPhase, timestamp). CMAngleData does not register as an ObjC class
on iOS 27.1 (measured; the sample object subclasses NSObject), and the
bridge's queue argument is an NSOperationQueue, not a dispatch queue —
v5's first build called dispatch_async on it and died inside libdispatch's
vtable dispatch with EXC_ARM_PAC_FAIL (dtdeviceinfod-2026-10-25-002844…,
nine crashes); the delivery now branches on the queue's kind. The provider
liveness test behind isAvailable has two traps, both measured: the status
heartbeat only runs while samples flow, so freshness cannot be part of the
test, and dtdeviceinfod (mobile) cannot signal backboardd (root), so
kill(pid, 0) answers EPERM for a live process — EPERM counts as alive
(v6; with the plain == 0 test the capability answered
hingeAngle: false on every DeviceHub poll). After v6, a full DeviceHub
connect left no new crash reports and QueryMotionCapabilities answered
hingeAngle: true on every poll (59/59 over a minute), with
xcrun devicectl device info details also clean.
Two things this chain deliberately does not do:
- No fold picker, and no device hinge UI at all. The three-state fold
control in the DeviceHub window is the simulator viewer's chrome (the
device in the user's screenshot was "iPhone Duo — Simulator"), driven
host-side through CoreSimulator. Checked directly in Xcode 27's shipped
binaries: DeviceKit — the UI framework DeviceHub renders from — contains
zero hinge/fold strings; CoreDevice carries only the API and one error
message ("Hinge angle monitoring is not available on this device.");
CoreDeviceUtilities carries the wire types
(
StreamHingeAngleActionDeclaration,DeviceHingeAngleSnapshot,com.apple.coredevice.action.streamhingeangle). So no connected device — real or virtual — gets a hinge readout or fold control in that window; the stream is for API clients (DeviceMotionManager.monitorHingeAngle, whose symbols live in CoreDevice). Note that on Apple's own Duo simulator the three states change the simulator window's fold chrome, not the guest screen's content — the equivalent for a vphone guest is fold chrome in our own viewer driven bydisplay.posture. - No on-screen fold layout.
display.posture's SpringBoardServices pose override lands, and since the fold-guard the foldable product-id boots — but a measured book sweep under the full Duo identity leaves the pixels unchanged (same full-bleed layout, no letterbox or split; see the fold-guard section): the fold layout belongs to display topology — the second panel Apple's simulator registers — not to the token. The hinge data (CoreMotion, CoreDevice, any app using CMAngleManager) is what works today.
Why the inner panel boots with the outer panel's layout (2026-10-25)
A machine started with display-posture.plist {position: open} boots the
inner panel — framebuffer 2034×2853, device.screen 678×951 pt — yet the
home screen lays out a 466×678-pt grid centered on it (measured from pixels:
content band x≈339–1695 px of 2034, ~113-pt side margins), with the wallpaper
and status bar full-bleed. Settings, launched on the same boot, lays out for
the full 678 pt (two-column sidebar, elements to x=658 pt): the display
server serves the real bounds; only SpringBoard's home grid keeps the outer
panel's fit. What was ruled out, in order:
- MobileGestalt cache staleness. The cache's
ScreenDimensions(CacheExtrakeyybGkijAwLTwevankfVzsDQ, 16 bytes = two little-endian doubles) held 1398×2034 from the boot it was built in. Dropping it (files.remove) and relaunching SpringBoard changed nothing; restarting backboardd changed nothing; a full reboot with the cache deleted rebuilt it at 2034×2853 — and the grid stayed small. The cache follows the framebuffer; the grid does not follow the cache. - The device tree's panel data. The booted tree (vphone600ap) has no
width/height anywhere (decoded with a scratch parser over the im4p; display
props are syscfg-backed strings,
syscfg/gsmietc.). The real V68 tree carries the display pipeline as hardware nodes —arm-io/dcpandarm-io/dcpext0, one DCP per panel, plus per-panel i2c display-eeproms — none of which a paravirt guest can have; its singlegfxnode (compatible "paravirtualizedgraph…") carries no panel identity. - The device pose. With the hinge HID reporting 180° continuously
(preference persisted, provider enumerated, state 3 open-flat),
display.posture {position: open}applied live, apps cycled, screen cycled — the grid never widens. This matches the gate reversed earlier:FBDisplayManager.alwaysConnectedIdentities.count >= 2. One display means one identity, and SpringBoard sizes the home grid from the product's default fit (the outer panel's) whatever the framebuffer says. - Two VZ displays. A build appending a second
VZMacGraphicsDisplayConfiguration(the posture's panel first, the other panel second) boots and behaves exactly like one display — and with the flat IOService probe added for this (device.displays) the reason is measured, not inferred:AppleParavirtGPUstill reportsDisplayPortCount = 1and exactly oneAppleParavirtDisplayexists (2034×2853,primary = 0) with two host displays configured, the same as with one. The iOS guest's paravirt GPU personality binds only the first connector, so the route is closed; the change was reverted.
One anomaly is on record: a single live run (2026-10-25, single display,
before the pose-restore ladder existed) did flip the home screen to a light
full-width layout after a screen-off → display.posture {open, 1 s sweep}
→ unlock sequence; repeated attempts under the same and under dual-display
builds, before and after app cycles, never reproduced it. Treat it as an
SB display-mode-transition artifact of the VM sub-type restore path, not a
working mechanism.
What ships from this round. vphoned now re-applies the saved posture
after each boot (GuestFoldPose: the pose override is volatile per
SpringBoard instance, and the VM sub-type's own SBFDevicePoseLastKnown*
restore never fires on a single display). The boot-time panel choice and the
pose therefore stay in agreement across reboots for everything that does
follow the pose — hinge data, CoreMotion, CoreDevice, pose-dependent UI.
The remaining lever for the grid is the one the fold-guard section
already points at: a second always-connected display identity. The V68
donor tree names the hardware that carries it (dcp/dcpext0); with the
host route closed, the identity has to come from inside the guest — a
backboardd-injected second display identity over the one framebuffer (the
libvphonehinge pattern, applied to the display set), or an interpose in
SpringBoard answering the gate itself (FBDisplayManager alwaysConnectedIdentities). Both start from the same forensics: locate the
display-set builder and the identity type in the guest's dyld shared cache
(backboardd / FrontBoard), then decide whether a phantom identity renders
onto the primary scene or needs a mirror. The identity-faking experiment
carries the same crash-loop risk the foldable product-id patch had; keep the
forced-boot escape documented there in reach.
Display-identity forensics, staged (2026-10-25, second round)
The guest's shared cache is readable while it runs, at
/System/Cryptexes/OS/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e_x1*
(81 files, ~11 GB; the small x1 header carries the image-text table at
header+0x2cb68, stride 32 — uuid[16], textAddress u64, textSize u32, pathOffset u32; each subcache's own header names its address range).
Pulled to /tmp/duo-forensics/dsc/: .09 (FrontBoardServices 0x1a7a24000),
.15 (SpringBoardFoundation 0x1bae8c000), .16, .17 (FrontBoard
0x1c4522000), .36 (SpringBoard 0x223155000, SpringBoardDisplay
0x223fe0000, SpringBoardUI 0x222769000), .77 linkedit (671 MB of symbol
strings). Unpulled but mapped: BackBoardServices → .03,
SpringBoardDisplayServices (0x2aa519000) → .67.
What the strings name so far:
- The identity type is
FBSDisplayIdentity(FrontBoardServices; its onealwaysConnectedstring sits besideFBSDisplayIdentity.mat x1.09 offset 0x76cef87) andFBDisplayManageris the hub ("do not set the display type to main, use mainConfiguration on FBDisplayManager"). The gate the pose reversal named — always-connected identities count ≥ 2 — is a property of these, not of SpringBoard. - iOS 27 ships
SpringBoardDisplay.frameworkandSpringBoardDisplayServices.framework— a whole display-layout framework family to read before choosing an interpose point. - "Cover-display cover-size reduction skipped (not a genuine cover device): sizeMatchesCover / bounds / expected / hasMultipleAlwaysConnectedDisplays" exists at x1.36 offset 0x3a794f7 — but it belongs to PhotosIntelligenceCore (Photos' own foldable layout), so it names the pattern, not SpringBoard's gate.
The next steps, in order: class-dump FBDisplayManager and
FBSDisplayIdentity from x1.09's ObjC metadata (class symbols live in the
x1.77 linkedit at offsets 0x50c7a8e/0x50c7a4a; Swift $s names are not
string-visible there, so method lists come from the metadata, not the
symbol table); xref the consumer in SpringBoardFoundation/SpringBoard via
the dyldreadonly CFSTR chain the fold-guard work used; then decide the
interpose — the cheap first try is answering the count gate with the one
real identity duplicated, which satisfies .count >= 2 without inventing
an object, and observing whether the grid starts following the pose.
The gate's surface, confirmed on the host (2026-10-25, third round)
The host ships the same display-identity stack, so the surface is readable without touching the guest — verified live on this Mac (FrontBoard + FrontBoardServices dlopen, runtime introspection):
+[FBDisplayManager alwaysConnectedIdentities]— a class method, returning@[FBSDisplayIdentity "Main"]on a single-display system (alwaysConnected=1, main=1, virtualized=0), count 1 — the guest's situation exactly.FBSDisplayIdentityimplementscopyWithZone:(a copy answersisEqual:as the original, description "Main"),isAlwaysConnected,isMainDisplay,isVirtualized,displayID,type, and the private_initWithType:UIKitMainLike:displayID:connectionType:connectionSeed:pid: external:uniqueIdentifier:secure:alwaysConnected:root:.FBSDisplayMonitor(_initWithDisplays:mainDisplay:bookendObserver: transformer:,_lock_alwaysConnectedConfigurations) is the client-side holder; identities arrive from backboardd by XPC (initWithXPCDictionary:).
So the cheap interpose is fully specified: in a SpringBoard-injected dylib
(the fold-guard pattern), swizzle the metaclass method
+[FBDisplayManager alwaysConnectedIdentities]; when the original answers
exactly one identity, answer @[original, [original copy]]. Every count
gate passes; no object is invented; the duplicate shares its displayID, so
identity-keyed consumers may dedupe it — whether the home grid then follows
the pose is precisely what the experiment observes. The delivery wiring
(inject-dylib into SpringBoard, a declared patch set) and the crash-risk
escape stay as the fold-guard documented them.
The interpose is built; delivery is the open end (2026-10-25, fourth round)
libvphonefolddisplay.dylib is written, built, declared and staged:
VPhoneGuestComponents/FoldDisplay/ (Makefile + StageBundle wired, patch
system-springboard-cfw-fold_display in FirmwareGuestSystemPatchSet,
SystemHook loads it beside the fold-guard). It swizzles the metaclass
method +[FBDisplayManager alwaysConnectedIdentities] and pads a
single-identity answer with a copy of the real one, logging to
/var/mobile/Library/Caches/vphone-folddisplay.log.
Delivery is blocked in the selection plumbing, measured: fw set-patches --allow system-springboard-cfw-fold_display records it, but
cfw update-environment keeps answering "on in the current selection,
newer than this VM's patch plan" —
FirmwareGuestPatchResolution.effectiveEnabled(planEnabled:selectionEnabled:)
intersects with the plan the machine was created with, so a patch the plan
never had cannot be turned on in place, and the plan never gains it (three
consecutive updates printed the same message; the dylib never reached
/usr/lib, which the sealed system volume makes runtime-unwritable —
files.write to /usr/lib fails at mktemp). Next step is in the installer,
not the guest: make update-environment add selection-new patches to the
plan before computing the deploy set (VPhoneCustomFirmwareInstaller.swift
around the effectiveEnabled call), or rebuild the plan for the machine;
then boot and read the log — "pad installed" is the success line, and the
observation target is the home grid following display.posture for the
first time.
First light, and the next hook (2026-10-25, fifth round). The delivery
blocker dissolved: the guest-library install table in
VPhoneCustomFirmwareInstaller was hard-coded, and adding
libvphonefolddisplay.dylib to it (foldable-gated, beside the hinge and
the guard) made cfw update-environment install it — the "newer than this
VM's patch plan" line is informational, not the gate. The dylib loaded and
logged "pad installed over +alwaysConnectedIdentities" on boot. But
that is the log's only line, through posture changes and an unlock: the
class method +[FBDisplayManager alwaysConnectedIdentities] is never
called at runtime. SpringBoard reads its display set through
FBSDisplayMonitor (the XPC-fed client the same reversal named), so the
next hook is -[FBSDisplayMonitor _initWithDisplays:mainDisplay: bookendObserver:transformer:]: append a copy of an always-connected
identity to the displays array it receives. The whole pipeline —
component, staging, declaration, allow, update-environment — is now proven
end to end; the iteration is one swizzle away.
Second hook installed, same shape of answer (2026-10-25, sixth round).
The monitor swizzle is in and logs its install line, but
_initWithDisplays:mainDisplay:bookendObserver:transformer: is also never
called — SpringBoard builds its monitor through the other initializer
(_initWithBookendObserver:transformer:; displays then arrive over XPC
into the bookend observer). The next candidates, in order of centrality:
_lock_alwaysConnectedConfigurations (the configurations getter the count
logic must read) and _lock_enumerateConnectedWithBlock:. All three
selector names are already in the host dump; the deployment pipeline is
warm — one swizzle per round.
The configuration is right; the role is the question (2026-10-25, seventh round)
Hooks on +[FBDisplayManager mainConfiguration] and +mainIdentity
(running inside SpringBoard, logged per call) answered the open question
from the bottom up:
+mainConfigurationis called — once per SpringBoard launch — and its answer is perfect:FBSDisplayConfiguration "Main",bounds {678, 951}, mode678x951@3x (2034x2853/3) 120Hz,renderingCenter {1017, 1426.5}, pixelDensity 460,CADisplay.deviceName = primary. The display-configuration plumbing is correct end to end; SpringBoard lays the 466-point grid on top of a correct 678-point configuration.+mainIdentityand every identity/count accessor (+alwaysConnected,-[FBSDisplayMonitor alwaysConnectedIdentities],_lock_alwaysConnectedConfigurations,_initWithDisplays:…) are never called in SpringBoard at runtime — the two-display gate as reversed statically is not exercised on this guest; the fold machinery never starts far enough to ask.- Also eliminated with probes this round:
/chosencarries no display geometry (only rotation/corner-radius/scale=3; the vphoned display probe now dumps it),com.apple.backboardd.plistholds no display configuration, andIconState.plistrecords no grid geometry.
The remaining suspect is the display's role classification:
CADisplay.deviceName = primary, and on a foldable the boot-primary
display is the cover panel — which is exactly the 466-point layout being
drawn. renderingCenter non-zero also marks a display whose rendering
surface is bigger than its bounds (the foldable frame). Where that
classification lives is server-side: backboardd
(/usr/libexec/backboardd, not in the shared cache, not present on the
host for introspection) builds the display set from IOKit and pushes it
to clients. The next workstream is the libvphonehinge pattern aimed at
backboardd: hook its display-set construction and answer two displays —
or reclassify the one — so the pushed set carries a main display, not a
cover-primary. Deployment note recorded the hard way:
cfw update-environment runs the machine's current binding — always
check the reply's bundle field, or an update silently deploys the
previous bundle.
The backboardd surface, mapped (2026-10-25, eighth round)
/usr/libexec/backboardd pulled from the guest (800 KB, arm64e.x1, not in
the shared cache; macOS ships none of this for host introspection) and its
strings named the display management core:
_BKDisplayXXGetSystemIdentifiers/_BKDisplayXXSetSystemIdentifiers(andBKDisplayGetSystemIdentifiers/Set…) — the system display identifier set, the thing a second panel would join.displayWithHardwareIdentifier:,windowServerDisplayForDisplayIdentifier:,_lock_evaluateAllDisplaysForReason:— the enumeration and evaluation entry points.BKDisplayCloneMirroringManagerandBKSDisplayServicesSetCloneMirroringModeForSourceDisplayToDest(in BackBoardServices, guest x1.03) — Apple's own display-mirroring path, a documented fallback if a padded second display ever needs a surface.
The plan for the next session is therefore fully specified: inject into
backboardd the way libvphonehinge does (SystemHook already loads per
process), find the class owning _lock_evaluateAllDisplaysForReason: via
the ObjC metadata in the pulled binary, and pad the evaluated display set
with a second entry — or reclassify the one — so the pushed set carries a
main display instead of a cover-primary. Then the boot question (does the
grid go wide) and the runtime question (does a re-evaluate on hinge change
switch layouts live) both have one lever to answer them.
Recovering from the broad runtime probe (2026-10-25, ninth round)
2.12.1-local.8a55d450 repeatedly left the VM running without a usable
vphoned connection. USB syslog and its process list remained available:
the proxy and worker, SpringBoard, and backboardd were alive; copying
vphoned crash reports with --keep retrieved none. Failed boots stopped
logging after vp_vcam_start, before the HTTP/WebSocket listening line.
The Data/User volumes were encrypted and locked when attached read-only,
so offline access did not recover their logs; the attachment was ejected.
Disabling only the backboardd half in the next source build, installing
2.12.1-local.ad5f3ecd, checking its machine binding, and running
cfw update-environment restored vphoned in 5 seconds. Its screen RPC
reports 678×951 at scale 3, unlocked and on; the screenshot still shows the
narrow four-column grid. This is recovery, not either layout goal.
The recovered append-only probe log contains
evaluator owner: BKDisplayCloneMirroringManager and the evaluator hook
installation line, but no completed round N: line. Thus the owner
was found, contrary to the earlier handoff; it is the clone-mirroring
manager, not evidence of a display-topology producer. The broad scan or
hook is implicated by the isolation result; its exact blocking operation
has not been established with a thread stack. Guest timestamps move back
at boot until host synchronization, so compare log order and boot markers,
not timestamp monotonicity.
The replacement diagnostic is restricted to that named class, delayed ten seconds onto the main queue, logging only its declared selectors and type encodings. It does not scan every class or replace evaluator methods.
Validated replacement: 2.12.1-local.42c33f10 also answers vphoned
after 5 seconds. The delayed probe logs 23 methods and complete.
The live evaluator signatures are v24@0:8@16 (all displays) and
v32@0:8@16@24 (one display). Its other methods include
_lock_evaluateCarPlayDisplay:label:,
_lock_evaluateContinuityDisplay:label:,
_lock_setupCloningToDestination:fromSourceDisplayUUID:fromVirtualMode:hideByDefault:,
_lock_evaluateExistingClonesForReason:, and
addModeRequest:forSourceDisplay:toDestinationDisplay:clientPID:.
This interface points to clone mirroring; it does not establish a provider
of the home screen's foldable layout. Do not pad its arguments as if they
were the display-identity collection.
The next narrower candidate is
-[SBMainDisplayConfigurationTransformer transformDisplayConfiguration:],
whose class/method diagnostic strings occur in the guest's x1.36 cache
alongside SpringBoard display code. This is a static candidate only:
its runtime invocation, signature and input/output remain unmeasured.
SpringBoardHome lives at unslid 0x1c45c1000 in this image inventory; its
layout selection also remains to be traced. Neither candidate justifies
overriding a geometry or role yet.
Screen-type lookup evidence (2026-10-25, tenth round, runtime pending)
The current guest's SpringBoardHome symbol table identifies
SBHScreenTypeForDeviceAndDisplayContexts at 0x1c45c8b1c,
SBHScreenTypeForScreen at 0x1c45d537c, and
SBHScreenTypeForCurrentDevice at 0x1c465d930. The latter is cached with
dispatch_once; the per-screen function also reads/writes an associated
object cache. Both lead to _SBHScreenTypeLookupCommon / _SBHScreenTypeLookup.
These addresses are analysis evidence for this build, not runtime patch
offsets.
Decoding the symbol-backed screen-name tables gives 31 = D76 and 32 = D77. The metric switch's D76 case calls a size SPI; its D77 case loads two doubles from the platform screen-size table: 669×951, not the guest's 678×951. The adjacent pair is 466×678. Thus exact-size matching deserves testing before assuming the backboardd primary role is the cause. This does not yet prove which enum the running SpringBoard selects, nor justify silently changing the requested 678-point canvas to 669 points.
The source now has a guarded, observational configuration-transform hook
and a delayed probe for current/per-screen enum values and
SBIconListGridLayoutConfiguration method signatures. The transform-only
build passed, but its install remains pending at Launchpad's administrator
stage; the subsequent enum-probe source has not yet been built or deployed.
The guest remains on the verified 2.12.1-local.42c33f10. Continue the
existing install operation instead of launching another installation.
Runtime result: after authorization completed, the combined probe build
2.12.1-local.dec1671c was installed, bound and deployed successfully;
vphoned answered after 5 seconds. The configuration transformer is called
and returns a set containing the same 678×951 configuration. Crucially,
both SBHScreenTypeForCurrentDevice() and SBHScreenTypeForScreen(mainScreen)
return 23, iPhone 14 Pro Max (D74). They do not return D76. The old
cover-role explanation is therefore not an established explanation for the
grid; the measured phone fallback is the next target. The grid configuration
class has screenType / setScreenType: and portrait/landscape list-size
and layout-inset setters. Inspect the layout providers before deciding
where to substitute the inner layout family, so a late enum override does
not leave all its derived metrics configured for D74.
Provider experiment: 63a6dba0 established the typed initializer
-[SBHDefaultIconListLayoutProvider initWithScreenType:layoutOptions:]
(@32@0:8Q16Q24) and +currentDeviceScreenType (Q16@0:8). In
9ed488e5, mapping D74 to the symbol-resolved D77 value at those entries
booted successfully and logged 12 substitutions. The following native
construction created 18 D76 and 9 D77 providers. The screenshot changed
icon/dock metrics but remained narrow, so the first substitution is not
accepted as inner-screen layout. The next isolation also maps explicit
D76 provider construction to D77, while inspecting the icon-manager
layout-update interface for subsequent live switching.
System-wide acceptance and rejected UI-only approach
The user explicitly requires correct status bar, Dock and rotation in addition to inner-screen home layout and live inner/cover switching. Per-application layout fixes are not an acceptable final solution.
7f5eff72 (mapping both D74 and D76 requests to D77) still produced a
narrow grid. The diagnostic 73dbf17b read the active root layout:
the folder view is 678×951, its provider is D77, but its native config is
4×6 with portrait insets {82,145.5,219,145.5}, list-spacing size
669×951, and dock background insets {0,134,21,134}. Thus forcing D77
does not establish a full-width system display state; it introduces its
own status/Dock mismatches and is rejected as the implementation path.
The provider substitutions and experimental inner/cover notification
handlers were removed from source. The machine is being restored to the
non-mutating 63a6dba0 diagnostic baseline.
With the screen awake and rotation unlocked, a 90-degree request was
refused on both SpringBoard and Settings. The virtual accelerometer is
enumerated, its orientation sink reports ready and deliveries, and the
interface still reports 0 degrees. display.posture also installs a
test-automation pose override with a default portrait orientation; changing
that override to landscape-right did not produce an interface rotation.
Its interaction with normal rotation is a hypothesis, not a demonstrated
root cause. Do not "fix" this by independently rotating UI components.
The remaining investigation is the shared display boundary: logical display roles/modes and their propagation into display/scene traits, safe areas and orientation transitions. One coherent state change must drive home layout, status bar, Dock and ordinary app scenes. Verify both directions of the screen switch and portrait/landscape without VM or SpringBoard restart.
System mode boundary (2026-10-25, live read-only evidence)
The baseline rollback completed and booted in 5 seconds. Build
b21d1148 adds display.modes, a read-only CADisplay query. On the guest:
- primary LCD is displayId 1, displayType 0, native bounds 2034×2853;
- its only advertised mode is 2034×2853 at 120 Hz;
allowsVirtualModesis false, but bothsetAllowsVirtualModes:andsetCurrentMode:exist;- five wireless display objects have zero bounds and no modes. They are not evidence of five usable/always-connected panels.
This is a candidate for changing logical mode inside the guest without
asking VZ to replace its physical framebuffer. It is not proven support.
A built follow-up exposes a bounded display.mode experiment: request
dimensions no larger than the native framebuffer, preserve the original
mode, read CADisplay back, and request rollback on a mismatch. Independent
device.screen, screenshots, display configuration and process continuity
are required even if its local mode readback matches. The experiment's
install is currently waiting for renewed administrator authorization.
All old identity-padding hooks are now observational pass-throughs in source, in addition to removing provider substitutions. They must not invent duplicate identities if the new transition starts exercising a previously unused accessor.
Mode switching negative result: no arbitrary IOMFB modes
ca1e88b5 tried 1398×2034 through CADisplay.setCurrentMode:. It raised
"mode ... is not valid for display ...". Explicit restoration succeeded;
independent screen readback remained 678×951, and SpringBoard PID/start
time were unchanged. Static analysis of the guest QuartzCore resolved
CADisplay's method metadata correctly (relative list-of-lists, then the
cache's shared selector base). The setter checks mode._display == self
and nonzero mode._mode before constructing CADisplayProperties.
More importantly, the guest implementations of allowsVirtualModes and
setAllowsVirtualModes: are deprecated stubs: the getter returns false,
the setter logs and returns. Their diagnostic strings explicitly say
"is deprecated!". Runtime mode enumeration after the attempted enable
still showed false and only the native mode. The mere selector-presence
fields in display.modes must not be treated as actual capability.
27f93d44 then sent the same bounded request through
CADisplayProperties.currentMode / CADisplay.setDisplayProperties:.
It reached the display service but crashed backboardd, not a valid
transition: incident CADFABE7-D598-47D2-9F09-32FE71FE0D12,
backboardd-2026-10-25-225733.ips, SIGABRT in
com.apple.coreanimation.display.primary. The stack is
Server::set_display_properties → IOMFBDisplay::set_mode →
IOMFBDisplay::update_framebuffer_locked → abort. backboardd restarted
from PID 61 to 291, while the caller remained waiting on the lost service.
This rules out supplying an arbitrary hardware mode through this route;
do not suppress the abort or claim a no-restart switch.
The waiting requests were closed after collecting the crash, and the VM
was stopped for recovery. The experimental display.mode write entry has
been removed from source; only read-only display.modes remains. Recovery
build/deployment is in progress. Further work must preserve the native
framebuffer and investigate supported logical-display/scene configuration
or render-server virtual-display facilities, with real bounds and input
mapping acceptance. No per-app geometry workaround was retained.
Recovery verified: 862de5d7 is bound and deployed; vphoned answers
after 5 seconds and display.mode returns Unknown method. This build keeps
only the read-only mode inventory and observational hooks. The pose ABI was
also checked against the actual SpringBoard receiver: the first 64-bit
field is copied directly into pose state, while the following 32-bit fields
feed the synthetic magic event and hinge-angle lookup. It is not evidence
of an overlooked bitmask argument. Further direction work needs readback of
the actual device-pose provider and its display transition consumers.
Pose delivery verified; display-content coordinator next
display.pose now reads the actual SpringBoard snapshot via
SBSTestAutomationService.getDevicePose, whose guest ABI is
{?=qiii}16@0:8. The completion-based spelling belongs to the server
protocol and is not a client method. Build 8d497ab4 booted in 5 seconds.
An explicit open/landscape-left request with a 100 ms hinge sweep returned
normal hinge-provider readback; display.pose then returned orientation 3,
device_orientation 3, hinge 3, tabletop 0, while display.orientation
remained 0. Thus pose delivery is demonstrated, but scene/display
transitions do not follow. The hinge unit tests also passed after restoring
an accidentally truncated source tail; the failed link was never deployed.
Static metadata identifies the shared downstream component
SBDisplayContentModeCoordinator, with
poseProvider:didUpdateFromPose:toPose:, primaryWindowScene,
activeWindowScene, currentTransitionContext, and
displayContentModeForWindowScene:. SpringBoard exposes it through
displayContentModeCoordinator. A read-only diagnostic now inspects these
objects on the main queue after boot, or on
com.vphone.display.inspect; it does not replace layout values or force a
transition. This observation is intended to locate the shared state break
before choosing any mutation.
Shared posture fix and software-display lifecycle probe
The coordinator exists. With open-flat and closed poses it reports the same
SB-main as primary and active window scene, content mode 2, and no current
transition context. The pose provider's own description changes correctly
between .openFlat and .closed; this is not merely an RPC echo.
A separate real bug was observed: the boot restore ladder replayed its old
open posture after a successful user close. GuestFoldPose now cancels the
ladder before an explicit validated posture request and stops retrying after
the new pose readback confirms restoration. Publication and cancellation
share a lock, so an in-flight retry finishes before the explicit request.
On d5b17793, a close issued just after boot still read hinge=1 after
121 seconds, beyond the old 90-second ladder. This fixes state ownership;
it does not by itself fix display layout.
An explicit, non-automatic backboardd experiment uses
CAWindowServerVirtualDisplay.initWithOptions: and CAWindowServer.addDisplay:.
The guest initializer's recovered keys are kCAVirtualDisplayWidth,
kCAVirtualDisplayHeight, kCAVirtualDisplayUpdateRate, and
kCAVirtualDisplayName. It registered displayId 7, type 3, name
VPhone Cover Probe, 1398×2034@60, while primary remained 2034×2853 and
backboardd PID/start time were unchanged. SpringBoard still kept only
SB-main/mode 2. Thus software rendering topology can be added without
changing the hardware mode, but its FrontBoard/Duo role is not established.
The explicit remove call returned and cleared the retained probe reference,
but the daemon's CADisplay list still showed id 7. Removal is not yet claimed
verified: CADisplay caches its inventory. The next probe calls
CADisplay.updateDisplays (verified in guest code to invalidate its cache),
logs the render server's own display inventory after add/remove, and reads
the existing FBDisplayManager monitor's connected/always-connected identities.
These are diagnostic reads, not evidence that normal SpringBoard consumers
previously called those getters. Never duplicate identities to fake this
missing connection.
Inventory boundary verified on 6004bbe4: adding the probe puts id 7 in
both the render server's displays and refreshed CADisplay inventory. The
existing SpringBoard FBDisplayManager monitor still reports exactly
connectedIdentities = {Main} and alwaysConnectedIdentities = {Main};
the active scene remains SB-main/mode 2. Thus the missing integration is
between render-display registration and the system display-identity/scene
publication path. A second CA display alone does not make a Duo panel.
Removal is now verified without a VM/backboardd restart: the render server inventory loses the named probe, and a cache-refreshed CADisplay query returns ids 1–6 only. The previous id-7 readback after removal was stale client inventory. The experiment is left removed, with native primary geometry unchanged. Follow the central BackBoard/FrontBoard identity publication boundary, not a fabricated duplicate Main identity.
Identity publication predicates (2026-10-26)
The BackBoard GetSystemIdentifiers path resolves a particular render
display and reads that object's systemIdentifiers; it is not a global
list of panels. Runtime sampling found one UUID on the primary display and
nil on the software display. No identifier value was written or duplicated.
FBSDisplayMonitor.allowsUnknownDisplays was already true. Enabling it
again made no difference. system.respring is unsuitable for isolating
initialization: its implementation explicitly includes the restart-render-
server option. A subsequent services.kickstart of only SpringBoard did
preserve backboardd PID/start time and id 7. The newly initialized monitor
then contained source 7-7, but it was disconnected; connected and
always-connected identity sets still contained only Main.
The software display was initially enabled=0 despite valid size/mode and
isSupported=true. Explicit setEnabled:YES read back 1 without restarting
backboardd; it still did not connect the FBS source. Main has pointScale 3,
the software display pointScale 1; neither scale nor hardware mode was
changed by this probe.
Guest FBS source-update code now explains a concrete predicate: except for
always-connected sources, a zero CADisplay tag or empty mode list clears the
raw configuration. The software source has tag 0. Guest
NSStringFromBKSDisplayTags identifies bit 16 as unknown; the other
documented bits are hidden, debounce, AirPlay, musicOnly, car,
carInstruments and continuity. The next experiment resolves the unknown bit
through that guest function and tags only the owned software display, then
observes real FBS identity creation. It does not declare a second Main or
change the physical display type. Connection/role acceptance is pending.
Tag connection verified on 86558897: after a software display source
had been enumerated, applying the guest-described unknown tag (65536)
caused its FBS raw configuration to be constructed and passed through the
normal transformer. A later asynchronous read showed connected identities
{Main, Unknown[7-1]} with the same SpringBoard PID 278/start time across
the tag change. No identity was copied or manually inserted. The new
identity has displayID 7, type 7, connectionType 3, UIKitMainLike=false and
alwaysConnected=false. The always-connected set and active SB scene remain
Main/SB-main. This establishes real connection publication, not Duo role
acceptance.
The enabled-state test was also completed: the virtual display starts disabled, explicit enable reads back true, and a fresh SpringBoard can enumerate source 7, but it remains disconnected while tag is zero. This separates source discovery, enabled rendering, connection tagging and built-in-panel role classification.
Destroying a connected tagged display removed it from the render server but left the FBS identity cached. The test was cleaned by a SpringBoard-only kickstart after removal; final reads confirm connected/always-connected sets contain Main only, pose remains closed, and backboardd PID 61 is alive. Future transient cleanup must publish tag=0 and verify disconnection before destroying the render object. Permanent emulated panels should keep stable objects/identities across screen selection instead of being created and destroyed for each fold.
One-shot built-in software-panel adapter (2026-10-26, acceptance pending)
The next experiment adapts one owned render display at the common metadata
boundary in SpringBoard: only a CADisplay whose native type is virtual (3)
and deviceName is exactly VPhone Cover Probe reports built-in type (0) and
pointScale 3 to the native FBS source factory. All other displays pass
through. It preserves the render server's real display ID, modes and
connection seed; no FBS identity is copied or inserted into an array.
The guest classifier's built-in branch supplies UIKit-main-like and
always-connected attributes, which are the measured differences from
Unknown[7-1]. This is an emulated-panel role adapter, not a home-screen grid
or Dock override. Its actual scene behavior is unproven until runtime reads.
Activation is deliberately one-shot: SpringBoard consumes
/var/mobile/Library/Caches/vphone-duo-internal-panel.once containing 1
before installing the metadata hooks. A subsequent SpringBoard instance
without a new marker stays on the baseline, including after a crash. The
test must verify two native always-connected identities, correct logical
sizes, resulting window scenes, and real fold/rotation behavior. The
physical framebuffer/mode remains untouched.
Observed result: the one-shot adapter produced
Unknown.main[7], 466×678 at 3x, and native always-connected count 2.
SpringBoard then trapped in
ManagedDisplaysController.scene(_:didUpdateIsCloningSource:) while
activating a managed display profile (incident report
SpringBoard-2026-10-26-005058.ips). The next instance had no one-shot
marker and returned to ordinary Unknown[7-1]/count 1. The probe display was
removed and SpringBoard-only cleanup restored the baseline. Count 2 alone
is therefore insufficient: an Unknown render display is not a complete
Duo main/cover role, and the assertion must not be suppressed.
Native root-derived logical configuration (2026-10-26, preview pending)
Guest FBSDisplayConfigurationBuilder.buildWithError: has an explicit
identity-derivation branch: setting uniqueIdentifier creates a new identity
with the original identity (or its existing root) as parent, preserving
display ID, connection metadata and root semantics. This is distinct from
duplicating Main or changing an unrelated virtual render display to Main.
The transform protocol returns a set, so one native source can publish
distinct effective logical configurations.
The next experiment previews a copy of the real Main configuration with
cover bounds 466×678 and a stable uniqueIdentifier vphone-duo-cover, built
by that native builder. It validates the resulting parent/unique ID before
any publication. A default-off in-process switch can then request
FBSDisplayMonitor.updateTransformsWithCompletion: to add/remove the
projection through the original source's transform result. The original
physical mode and render display are retained. Preview/scene acceptance is
not yet established. A macOS scratch attempt could not obtain an iOS-style
Main FBS monitor and trapped before reaching the builder; it supplies no
negative evidence about this guest derivation path.
Observed result (4efbeb4b): guest preview successfully built
Main+<vphone-duo-cover> with root Main, cover bounds 466×678 and unchanged
2034×2853 physical mode. A one-shot startup enabled publication and the
actual SBMainDisplayConfigurationTransformer emitted both configurations.
SpringBoard then aborted in
-[SBFBSDisplayTransformer transformDisplayConfiguration:].cold.1
(SpringBoard-2026-10-26-012941.ips). The outer transformer explicitly checks
that the inner transform set has exactly one member. This rejects the
single-source/two-effective-configurations route before monitor startup;
native identity derivation alone does not establish a Duo panel. The next
instance automatically returned to baseline. The enable notification now
refuses publication and stale one-shot markers are discarded; builder
preview remains read-only.
Native content policy versus scene surface (2026-10-26)
83435b6f was built, installed, bound and deployed on duotest-m5; guest
environment update reported that exact version and vphoned answered after
5 seconds. Read-only inspection of the real coordinator's _resolver finds:
- The prevailing policy is already FoldingDual, with primary content requested on .inner while the hinge provider reports openFlat/180°.
- Its only display proxy is legacyMain, whose surface is .default.
Applied/target mode is
.primaryContent(enum 2), not an inner-screen role. SBCoverDisplayConfigurationTransformer.activeTransformModeis 0, applied cover insets are zero, and both hardware identifiers are nil.- The actual SB-main UIScreen is 678×951 at 3x, and its scene already has regular horizontal/vertical size classes while retaining Phone idiom.
Thus pose delivery and policy selection work, but the native scene surface classification does not describe an inner/cover pair. Neither always- connected count nor mode 2 proves that classification. Next investigation belongs at the ShellSceneKit display/profile-to-scene boundary, which feeds the common scene traits; no per-widget layout override was introduced. The native SBF surface names are default=0, cover=1, inner=2, type3=3; content mode names are unknown=0, disabled=1, primaryContent=2, auxiliaryContent=3. These are decoded from this guest's symbol-backed tables.
The shared native source has now been traced further: ShellSceneKit
FBSHardwareSource.resolveDisplay(hardware:) reads
CADisplayHardware.bksDisplayTraits.surfaceType; the hardware initializer
fetches those traits through BKSDisplayService.traitsForDisplayUUID:.
backboardd _BKDisplayXXGetDisplayTraits obtains the render display's
extendedProperties, reads the CFNumber DeviceSurfaceType (defaults to
zero when missing), and builds BKSDisplayTraits. This is render-display
metadata, not proof of an IORegistry property. The ShellSceneKit conversion
preserves known surface values 0/1/2.
A new default-off probe intercepts only
CAWindowServerDisplay.extendedProperties in backboardd for deviceName
primary, adds DeviceSurfaceType=2 to a copy, and automatically returns
to native metadata after 30 seconds. It preserves the native source ID,
mode, display count and remaining properties. Runtime activation requires
the explicit com.vphone.display.surface.inner notification. The next test
recreates only SpringBoard during that window to test native profile/scene
construction; this diagnostic setup is not no-restart fold acceptance.
Observed result (0932888f): primary extendedProperties was originally
nil. During the probe, native traits became inner and the existing
legacyMain proxy changed to .inner, preserving Main identity/count 1.
SpringBoard stayed alive (PID 279), but its resolver remained Ready To Resolve: NO with no primary/active scene even after a fresh open pose.
Static _startResolvingContentModeOfDisplay: explains why: for inner it
requires a cover proxy, and for cover it searches for an inner proxy before
resolving a folding device. A default surface, by contrast, becomes ready
immediately. No readiness check was patched. After the 30-second metadata
restore, a SpringBoard-only kickstart restored .default, readiness YES
and SB-main. backboardd PID 61 and start time were unchanged throughout.
The next bounded probe supplies both metadata roles: real primary as inner and the owned CA software display as cover. The software display has a real UUID (unlike primary's nil uniqueId), so BKS can query it separately. Native connection types and identity factories remain untouched. This tests whether correct surface metadata suffices to construct the pair without the prior wrong built-in adapter. Its default 1x scale and lack of reported cloning support are known limitations, not accepted cover/rotation behavior.
Observed result (47b16e3f): Main native traits read back as surfaceType 2;
the real software source was connected as Unknown[7-1] (connectionType 3,
not UIKit-main-like, not always-connected). It did not create a cover window
scene; the resolver still had only legacyMain/.inner and remained not ready.
No SpringBoard crash was observed during this metadata-only experiment.
After expiry the probe display was removed and a SpringBoard-only kickstart
restored Main-only/default traits, SB-main and readiness. Actual screenshot
/tmp/duo-pair-restored-home.png confirms a usable home screen with the old
narrow grid; it is recovery evidence, not inner-layout success.
The proposed follow-up combines the primary surface change and intended cover metadata with the previous one-shot built-in metadata adapter. Automatic approval review rejected that specific retest because the adapter previously crashed SpringBoard and the interruption requires explicit user approval. The rejected command did not run; readback confirms the one-shot marker is absent. The guest stays at the recovered baseline while that approval is pending. No alternative execution route was used.
Additional read-only analysis resolves the previous crash more precisely:
the fault at ManagedDisplaysController.scene(_:didUpdateIsCloningSource:)
is its "Cloning source already exists: " assertion, in
ManagedDisplaysController.swift line 38. It is not an optional-unwrapping
failure. Before that earlier test, both surfaces were unspecified/default.
Correct inner/cover roles may affect primary/auxiliary content selection,
but whether they remove the competing-cloning-source condition is still
unverified. Do not suppress this invariant or report the retest as passed.
Profile admission predicate and separate trait readback
Read-only disassembly of the current guest's
SBDDisplayProfileManager.managedProfilesForDisplay(_:) resolves its first
admission condition: display.kind == .main || display.connection == .integrated. The enum cases were resolved through the actual import
targets and symbol-backed case descriptors, not inferred from tag numbers.
Unknown software displays fail both branches regardless of surfaceType;
this accounts for the absent cover managed scene in 47b16e3f. The existing
one-shot adapter changes the second condition, but the crash-prone combined
retest remains approval-gated.
A safer independent check is prepared: inspection queries BKS native traits for the owned software display's actual UUID, as well as primary's nil UUID. This can verify whether cover metadata reaches the service while leaving the software display untagged/disconnected and keeping SpringBoard alive. No panel-type adapter, scene rebuild or framebuffer mutation is needed for that readback. Main's successful inner readback alone was not proof of a complete pair.
The first 29bb799e readback did not see the new untagged CADisplay in the SpringBoard client's cached inventory, although vphoned's explicitly refreshed inventory confirmed its real UUID. Main traits read back as 2; the existing default scene remained ready, and SpringBoard PID/start time were identical before/during/after this independent test. The software probe was removed. The reader now explicitly invalidates the local CADisplay inventory before enumerating it, without changing display modes.
Additional static tracing of FBSDisplayMonitor.updateTransformsWithCompletion:
shows a per-source _updatable check before the transform-invalidation
update path. This provides a concrete distinction to inspect after the
earlier no-op compatibility-monitor refresh. Source observations now also
record public monitor initialization, actual transformer class, per-source
updatable flags and the targeted source methods. It is not yet evidence
that FBDisplayManager's source is non-updatable or that another monitor
has been identified. Core source update functions absent from its runtime
method list must not be assumed to be swizzlable ObjC selectors.
Observed result (baa0b497): explicit local CADisplay inventory refresh lets the independent query reach both BKS records: primary/nil UUID reports surfaceType 2, and the real software UUID reports surfaceType 1. The probe display stayed untagged and disabled, no scene was rebuilt, and SpringBoard PID 37/start time were identical before/after. The probe was removed. This verifies metadata delivery independently of the still-unmet integrated scene admission condition. Home's own readback is artwork subtype 0, BSPlatform deviceClass 0 and homeButtonType 2. No public-monitor init hook calls were observed; those hooks do not establish the actual monitor owner.
The main-source invalidation gate is now source-derived: native monitor
initialization takes mainDisplay.isExternal as the main source's trigger
mask; source initialization sets _updatable = (triggers != 0) and
_observing = (triggers & 1). For the measured non-external main panel this
creates an explicit-refresh skip even though the transformer is present.
Additional sources use trigger 1. These are instruction/call-flow findings,
not yet an in-process flag readback.
A separate default-off com.vphone.display.invalidation.pulse diagnostic
validates the Main source/BOOL ivar ABI, temporarily enables only its
_updatable flag, calls the native monitor refresh, then restores the prior
flag in @finally. The original transform result passes through unchanged;
no viewport, panel type, physical mode or identity is replaced. A logged
transform-call delta plus process/scene continuity is required before using
this route to implement any logical bounds change. This is independent of
the still-pending built-in-panel adapter retest approval.
Observed result (087cc63d): live source flags are [0,1,1,1,1,1], matching
the static main/external trigger distinction. The reversible pulse reached
the original SBMainDisplayConfigurationTransformer: native completion
reported transform calls 1 → 2. The flag was restored immediately;
independent inspection again read Main=0. SpringBoard PID/start time and
backboardd PID/start time were identical before/after, and SB-main/678×951
remained unchanged. This explains the earlier completion-without-transform
result and establishes actual runtime delivery without the rejected panel
classification retest.
Single-root effective viewport (2026-10-26, runtime pending)
The next experiment uses that verified native refresh to replace exactly
one effective Main configuration, preserving the original identity, native
mode and renderer. copyWithOverrideBounds: supplies 466×678 cover bounds
(or 678×466 when the raw configuration is landscape); requesting inner
returns the original native 678×951 configuration. No derived identity or
second transform result is added, respecting SBFBSDisplayTransformer's
singleton requirement. Activation is default-off and explicit via
com.vphone.display.viewport.cover / .inner.
Cover automatically requests native restoration after 45 seconds. A request generation check prevents an older timer from overwriting a newer user selection. A new SpringBoard instance also starts with the native transform. Acceptance requires actual effective configuration, UIScreen/scene bounds, visible UI, input mapping and process continuity in both directions; a transform log alone will not establish screen switching or inner layout.
Observed result (db4f509e): effective Main publication reached the actual
SpringBoard UIScreen, changing its bounds to 466×678, while its mode
remained 2034×2853. Screenshot /tmp/duo-viewport-cover.png shows the
old home layout clipped at the smaller rectangle, including clipped widgets
and an absent Dock. This is not a usable outer screen. The timed restoration
returned UIScreen to 678×951; an explicit inner request also completed.
SpringBoard and backboardd PID/start time remained unchanged throughout.
Thus the native runtime configuration path works, but bounds-only copying
is insufficient and must not be called successful screen switching.
Native FBSDisplayConfiguration.copyWithOverrideMode: uses its own builder
to update effective mode, pixel size, native bounds and logical bounds
together. FBSDisplayMode._copyWithOverrideSize:scale: creates a virtual
frontend descriptor; it does not call CADisplay.setCurrentMode or
setDisplayProperties. The next revision follows these native copy helpers
for a coherent 466×678@3 effective descriptor, retaining the original
CADisplay and physical mode. It also reads window-scene coordinate bounds
and visible window frames so an updated UIScreen cannot mask stale windows.
Observed result (c915dfd5): UIScreen mode correctly became 1398×2034 and bounds 466×678. Independent CADisplay readback still reported physical 2034×2853/120 Hz. However, window-scene coordinate space remained 678×951, as did the status-bar, home, folder, wallpaper and other visible windows. The screenshot remained clipped. An explicit inner request was issued within the test sequence (before the 45-second timer), restoring the original mode/bounds. Both system process PID/start times were identical before, cover and inner. Effective mode updates alone therefore do not resize the system's root scene.
SpringBoard's own _shouldConfigureScreenDisplayConfigurationBasedOnSceneSettings
returns YES. Native FBScene provides updateUISettingsWithBlock:, whose
block wrapper passes its mutable UI settings to the supplied block. The
next default-off experiment coordinates SB-main's root scene frame and,
when available, its FBS display configuration in one native settings update
after effective-mode publication. It targets the single server scene through
the existing displayProfileManager.fbScenes API, with runtime setter ABI
checks, request-generation checks and the same cover/inner restoration path.
No individual status-bar, Dock, icon or application window frame is changed.
Root server settings are also logged before mutation to verify actual classes.
The fe02bc25 read-only baseline identifies UIApplicationSceneSettings, but
its generic displayConfiguration KVC access raises an undefined-key
exception. Inspection catches it; SpringBoard remains alive and no root
geometry experiment was activated. The corrected path uses SpringBoard's
native FBS settings extension sb_hostedDisplayConfiguration /
sb_setHostedDisplayConfiguration:; the setter is present on
FBSMutableSceneSettings, inherited by UIMutableApplicationSceneSettings.
Individual optional-field read failures no longer abort the rest of the
display inspection.
UIKitServices also supplies the actual UISDeviceContext/UISDisplayContext used by SBHScreenTypeForDeviceAndDisplayContexts. A read-only family probe now creates native default contexts for the current configuration, mutates only a distinct display-context copy's artworkSubtype, and asks that native classifier for 0/2034/2853/2796. It never publishes a context or modifies a screen/global device setting. This tests missing profile metadata separately from geometry; the runtime classifier results are still pending.
Observed result (6eca5020): native root inspection succeeds. The profile owns SB-main, SB-systemAperture and SB-systemApertureCurtain, all FBScene objects with UIApplicationSceneSettings, original frame 678×951, hosted Main configuration and interface orientation 1. The guarded SB-main transaction changed its server frame and actual scene coordinate bounds to 466×678. All visible windows nevertheless remained 678×951, and the screenshot was still clipped. Explicit native-inner restoration updated the root back to 678×951; both system process PID/start times remained unchanged. No individual window resize was attempted.
The pure native context classifier returned D74 for every artwork-only candidate (0/2034/2853/2796). Artwork alone is therefore not established as the remedy. A follow-up pure-copy probe also records the default context's actual geometry and classifies explicit copied configurations at 678×951, 669×951 and 466×678. These preview sizes are never published.
Further static tracing identifies a common stale-context candidate:
SBWindowScene._sbDisplayContext calls
+[UISDisplayContext sb_defaultContextForDisplayConfiguration:]. For
isMainRootDisplay this factory directly returns the cached embedded default
context; other displays receive a context built from their configuration.
The next default-off probe scopes that factory to the exact UISDisplayContext
class, real primary Main, and an explicit test flag. It returns a mutable
copy with the current FBS configuration, leaving the cached context intact.
Root geometry transactions now select all profile scenes by matching display
identity rather than enumerating window/component names. Native factory
behavior is restored after explicit/timed inner restoration; generation
checks prevent stale work from overwriting a later request. Runtime window
reflow with this shared context adapter remains pending.
Observed result (bf39730c): native context-copy calls report cached and configured geometry already equal at 466×678. All three root profile scenes receive frame 466×678; main UIScreen/mode and coordinate bounds agree. Visible system windows still remain 678×951 and the screenshot is clipped. The cached-default-context hypothesis is therefore rejected. Its replacement hook/notification were removed from source; runtime inner restoration also returned the experiment to native behavior with unchanged process start times.
The pure-copy geometry classifier independently returns D74 for 678×951, D77 for 669×951 and D76 for 466×678, with the original context confirmed as FBSDisplayConfiguration 678×951. This identifies an actual native-table mismatch for the requested custom 678-point inner width. The target is not silently changed to 669; canonical geometry is only a classification preview.
Static window policy gives a further explicit boundary:
SBWindow._shouldResizeWithScene returns the negation of
screen.displayIdentity.isMainDisplay. Home windows on Main thus do not
take the ordinary scene-resize path. SBStatusBarWindow instead inherits the
UIWindow resize behavior and opts into size-class adjustment; its other
runtime resize/hosting flags still require readback. The diagnostic now
records those flags, scene-derived window bounds, and UIScreen reference
bounds without changing a window.
A separate default-off com.vphone.display.viewport.pipeline.cover test
coordinates the effective mode/root settings, observes the real Main scene
geometry callback, then requests one native SpringBoard pipeline orientation
refresh with the current unchanged interface orientation, zero duration and
the native force option. The method validates orientations 1–4; runtime ABI
and generation checks scope this to the current SB-main geometry commit.
The original delegate callback is always preserved. Inner restoration uses
the same coordinator, and no individual window frame setter is called by
the component. Visible window reflow under this native refresh is pending.
Observed result (2061f359): the native pipeline refresh executes at orientation 1 for cover and inner generations, but still does not resize the windows. Runtime native gates narrow the problem: status-bar windows are scene-sized, non-hosted, SDK transform rotations enabled and allowed to resize; home/folder windows have the same flags except _shouldResizeWithScene=0. Both report native sceneBoundsInWindowOrientation 466×678 while retaining frame 678×951. UIScreen reference/unjailed/native bounds also match the effective cover mode. This rules out another missing rectangle or hosted-window exclusion for these observed windows.
The next default-off com.vphone.display.viewport.resize.cover test extends
the virtual Main's resize capability only during one scoped native geometry
propagation. The SBWindow base predicate preserves its original answer
except for windows belonging to the exact currently-propagated scene.
The shared scene callback notifies only windows already marked scene-sized,
using their native _sceneBoundsDidChange implementation. UIKit itself
calculates/sets its frames and size classes. The component supplies no
individual window rectangle and branches on no status-bar/Dock/widget class
names. Profile ownership, display identity, current geometry, ABI and request
generation are checked before propagation. Per-scene generation stamps
prevent recursive/repeated updates; the scoped capability is restored in
@finally. Native-mode/root restoration follows the existing inner path.
Actual visible reflow is still pending.
Observed results (ab83c88e / 3e74efa3): full native
_sceneBoundsDidChange propagation notifies 16 scene-sized windows and
changes 13 frames on each cover/inner generation. Cover is visibly unusable:
content is offset/clipped and the Dock becomes vertical with overlapping
icons. Explicit inner restoration visibly recovers the baseline; SB and
backboardd PID/start times do not change. An isolated SB restart was performed
only after that successful restoration to clean the next probe's state.
The follow-up 3e74efa3 calls only UIKit's native
_resizeWindowFrameToSceneBoundsIfNecessary, preserving SpringBoard's
transform-layer handling. It produces the same 16/13 counts and the same
bad cover layout. Actual status/home window and root-view frames/bounds are
466×678, interface orientation remains portrait (1), and both window/root
affine transforms are identity. The extra transform-layer callback therefore
does not explain the failure. UIScreen, effective mode, root scene and these
windows agree, but downstream home layout policy still disagrees. Explicit
inner restoration returns all observed geometry to 678×951 and visibly
recovers the baseline. SB PID 37/start 1792961169.9321101 and backboardd
PID 61/start 1792961169.940402 remain unchanged across both directions.
These are geometry propagation results, not usable display-switch acceptance.
All adapters remain default-off; no hardware mode/type/count change is used.
Native policy readback (ce4ec5ed): a landscape-left open-flat pose changes TRA device-orientation inputs/current/nonFlat to Ll and systemArbitratedDeviceOrientation to 3. supportsLiveDeviceRotation=1 and continuousFoldPinnedInterfaceOrientation=0, but the arbitrated interface orientation stays 1 and the home controller's supported mask is 2. The actual manager/provider remains D74. On cover resize the content/folder views themselves do become 466×678, still portrait, while native dockEdge changes 4→8 and the visible region shrinks to 346×678. This is downstream policy disagreement, not merely an unresized intermediate view.
Static tracing of SBHomeScreenController initialization reveals the actual rotation capability gate: UIDevice idiom Pad/CarPlay or scene.surfaceType equal to inner (2). Its isRotationSupported getter returns that cached result. The current .default surface therefore explains the phone home rotation exclusion independently of the size-family mismatch. No arbitrary rotation mask or per-app orientation override has been added.
A separate default-off, consumed-once startup experiment tests content readiness for a virtual machine with one active physical fold surface. After the native resolver registration, it may request native resolution only when there is exactly one connected real Main root and exactly one nonexternal inner proxy. It neither creates/promotes a software CADisplay nor emits two transformed configurations. The ordinary FoldingDual content policy then resolves the real inner surface at an explicit open-flat pose. The test retains 678×951 and requires an isolated SB reconstruction after the 30-second render-metadata probe expires to restore baseline policy. The flag is consumed before hook installation; the next SB instance has no adapter. This differs from the rejected built-in virtual-panel retest and does not arm its flag. Runtime acceptance remains pending.
Observed result (75951707): the scoped registration hook fires with one real Main and one nonexternal inner proxy. Native FoldingDual resolution progresses from unknown to primaryContent on .inner, and constructs the home controller with supportedInterfaceOrientations=30 (all four), compared with mask 2 for the baseline .default surface. No per-app rotation mask was changed. The native policy gate identified above is therefore confirmed. The manager/provider nevertheless remains D74 at 678×951, the grid stays narrow, and TRA interface orientation remains portrait despite Ll device inputs. A second bounded test with a settled home scene and the normal display.rotation=90 request also returns "did not apply". This is partial native content/rotation-capability progress, not rotation acceptance.
Both tests expire the render metadata and reconstruct a fresh, unarmed SB instance. Live traits return surfaceType 0; a current screenshot confirms the original portrait main screen and horizontal Dock. The SB crash query returns zero reports. An in-guest MercuryPosterExtension crash banner was visible during one sample; no attribution to this experiment is established. No virtual-display type promotion, second source or hardware mode is used. The next read-only diagnostic adds the pipeline's native orientation-state description, scene.surfaceType and arbiter method inventory to identify the remaining interface-orientation policy. The original 678×951 target remains in force pending the user's separate size preference answer.
Correction after reviewing the full second-test image/window log: the rotation RPC refusal is not proof that native rotation stayed portrait. The second sample's Home window, root view and icon content are 951×678, Home interface/effective orientation is 4, and TRA interface orientation is also 4. The host screenshot is 2853×2034. StatusBarWindow remains 678×951 at orientation 1, and content is visibly clipped/misaligned. Thus native inner-role rotation does occur, while global readback/rotation acceptance and the system-window/profile layout disagree. Do not repeat the earlier "interface orientation remains portrait" interpretation for this second test. The requested usable rotation still fails acceptance.
Default legacy Main also renders a working home while the fold content coordinator's primary/active scene getters are nil. A nil getter must not be reported as no UI. The read-only diagnostic now also inspects SpringBoard's actual embeddedDisplayWindowScene/activeDisplayWindowScene accessors.
During rotation testing Auto-Lock was temporarily extended from 60 to 600 seconds. Its existing setter inserts rangeMinimum=120 when absent. Restoring value=60 plus a profiled refresh therefore produces an effective 120 seconds. The current complete UserSettings input was verified as only restrictedValue.maxInactivity={value:60,rangeMinimum:120}. Automatic approval rejected both a direct replacement and a freshly-read/preserving merge that removes that added minimum, citing possible deletion of other managed fields. Neither rejected write executed. Explicit user confirmation for this narrow restoration is pending; do not describe Auto-Lock as restored to 60 meanwhile.
Latest read-only baseline (0bdf35ef): SpringBoard's actual scene accessors now expose the default Main even when the fold coordinator getters are nil. The native pipeline reports sceneSurfaceType=0, portrait layout/interface orientation and both backlight predicates YES. Native TRAArbiter exposes orientationResolutionStage, inputsValidationStage and multiline description APIs; this is a concrete inspection path for remaining policy decisions.
A further bounded single-inner sample uses pose alone, with no AX rotate request/rollback. Three seconds after Ll input, global AX/native layout are still portrait; three seconds after the next Pu input, AX returns 90 and native layout/interface orientation is 4 while device inputs are Pu. Both samples preserve sceneSurfaceType=2 and show StatusBarWindow still portrait. This establishes a delayed/disagreeing interface result, not the cause or a correct rotation implementation. Do not infer that a pose RPC reply or its first global orientation readback proves settled UI. The metadata probe expires, the startup flag is consumed, and the cleanup reconstructs an unarmed default SB. The native 669×951 size trial and the separate managed setting restoration confirmation remain unanswered.
Final cleanup readback caught a further boundary: a fresh unarmed SB had returned to default surface metadata, but pose readback was Ll and the capture was still sideways. Reconstructing SB alone is not sufficient restoration proof. Explicit display.posture(open-flat, portrait) plus the ordinary display.rotation(0), followed by a three-second wait, produced pose orientation/device_orientation=1, global interface degrees=0 and a visually normal 2034×2853 portrait home with horizontal Dock. The same 0bdf35ef component remains installed, with all startup experiment flags consumed; Auto-Lock remains the separately disclosed 120 seconds pending approval to remove the test-added input minimum.
Native persistence explains that cleanup reappearance: unlike the old single-default baseline, the inner content experiment populated SBFDevicePoseLastKnownCMMagicPose with device=3/cover=4/inner=3 and isAvailable=true. Restoring live pose alone did not rewrite that cache. Its complete current dictionary was read and preserved; only isAvailable was reset to false to invalidate the experiment's saved horizontal pose. The angle cache was already unavailable. This touches no managed setting. An additional fresh-default-SB restoration check follows this invalidation.
That fresh-instance check reports override orientation=0, physical device_orientation=3 and AX interface=0. Invalidating the saved override does not reset the underlying CoreMotion physical pose. Explicitly setting open-flat/portrait again restores orientation/device_orientation=1 and the verified normal portrait home. Leave that live state in place; do not claim the physical CoreMotion source or restart restoration is fixed by the cache invalidation. Final installed/bound environment: 2.12.1-local.0bdf35ef.
Singleton logical-identity preview (2026-10-26)
The next read-only preview uses complete effective-mode copies for both 678×951 inner and 466×678 cover, then native builder-derived identities rooted at the one real Main. It records each identity's native Main/root/ UIKitMainLike/connection flags and confirms CADisplay object preservation. Neither preview is published. The earlier bounds-only cover preview kept the original 2034×2853 mode and was not a coherent active configuration. The unused two-result expansion branch is now removed from the transform implementation entirely; singleton replacement, if later tested, must keep exactly one result. This does not retry the rejected virtual-panel type promotion. Native arbiter/stage multiline descriptions are also added to the same read-only inspection to reveal active orientation constraints.
Observed result (42ab7eb3): both native builder children preserve the real CADisplay, displayID 1, type 0, connectionType 0, UIKitMainLike and alwaysConnected. Inner is coherently 678×951/2034×2853; cover is coherently 466×678/1398×2034. Both are isMainDisplay=YES but isMainRootDisplay=NO. Static FBSDisplayMonitor.mainConfiguration explicitly searches effective configurations for isMainRootDisplay, and mainIdentity reads its identity. Replacing the only root with one of these children would therefore remove the native mainConfiguration. No singleton publication was attempted.
Native arbiter multiline output exposes the actual preference tree, participants and policy specifiers. The baseline orientation validators are AllUnknownOrientation and OrientationLock (lock orientation unknown). The StatusBar participant supports All while the baseline Home participant supports Pu. That excludes a simple StatusBar-supported-mask restriction.
The next default-off orientation pulse tests the remaining root-scene contract: compare the real Main inner scene's native arbitrated orientation with its hosted FBScene uiSettings.interfaceOrientation, and publish only that native result through updateUISettingsWithBlock. It supplies no window frame, transform or application orientation mask. It requires the actual active scene to be inner on the real Main root, and restores the previous setting after ten seconds. A separate read-only mutable-copy preview first checks the dynamic settings setter ABI and confirms the original unchanged. Runtime acceptance is pending; this is not a completed rotation fix.
Preflight (b3747114): the native mutable-copy test reports original=1, current original=1, copied=4, frame unchanged at 678×951. The dynamic interface-orientation setter is available and the copy is independent. The active-scene pulse has not yet been exercised.
Observer effect discovered before publication: the detailed inspection on the new inner instance starts at 05:53:58 and finishes at 05:54:02. Its notification executes on SpringBoard's main queue. It formats complete arbiter trees, controller/model descriptions and method inventories; the automatic 15-second startup inspection can overlap pose tests. This creates an invasive timing boundary in the previous short-wait observations. It does not invalidate the logged field values, but delayed UI/negative settling conclusions must be repeated without the heavy observer.
The next version removes automatic SB/backboardd inventories and moves the
heavy routines to explicit .inspect.detail / .families.inspect controls.
The ordinary .inspect emits one bounded JSON snapshot of actual scene,
root settings, screen/window rectangles and scalar directions, including
getter/collection elapsedMs. It traverses no class lists or model/arbiter
descriptions. Automatic completion snapshots use the same lightweight path.
The root-orientation pulse remains default-off with its ten-second restore.
Quiet observation / layout evidence (a30e60db): the first bounded JSON snapshot takes 1.81ms; later baseline snapshots take 2.30ms and the awake switch samples approximately 0.59ms. Heavy diagnostics run only on explicit requests and are kept outside timed UI tests. Independent native providers confirm default options=0 for D74/D76/D77. D77 itself is 4×6 with portrait spacing size 669×951 and insets {82,145.5,219,145.5}; merely correcting the family cannot establish the requested wide home grid. D76 uses 466×678 spacing, four columns/six rows and insets {54,27,70,115}. These are pure provider previews, not active model/provider replacements.
An initial quiet switching sample captured black images: live device.snapshot proved locked=true/screen_off=true after the static-analysis idle period. That sample is not UI acceptance evidence. The repeated test explicitly unlocks, waits for the home animation, visually verifies the actual home, then waits six seconds in each size. With locked=false/screen_off=false, Main config, UIScreen, root geometry and home/status window frames become 466×678 (native bounds 1398×2034), then return to 678×951 (2034×2853). SB PID 37/start 1792966426.947735 and backboardd PID 61/start 1792966426.955885 remain identical. The awake cover screenshot still shows clipped content and an overlapping right-side Dock; explicit inner restore visibly recovers the baseline. Thus the earlier bad-cover result survives quiet, settled revalidation and is not explained away by observer latency.
The next quiet single-Main inner-role/scene-orientation trial was rejected by automatic approval before execution. The stated reason covers writing the once marker, changing display role and force-recreating SpringBoard; it requests explicit authorization for the concrete potentially disruptive retest. The marker was independently read and returned ENOENT, confirming no arm occurred. A specific approval question is pending. This is separate from the earlier built-in virtual-panel type-promotion approval, which also remains pending. No alternative route is used to execute either rejected operation. Current machine stays on a30e60db with the native inner viewport restored, no root-orientation pulse performed and no role probe active.
Safe static follow-up finds SBHAdjustableRootIconListLayoutProvider inherits the default provider and overrides layoutForIconLocation. Its implementation copies the base configuration and reads rootSettings.rootFolderSettings to adjust layout metrics; it is not itself evidence of a shared display-role solution. The underlying interface/layout family and role publication still need coherent treatment, not another isolated forced-provider substitution.
Pending orientation trial: recovery review
The single-Main role trial still requires the specific authorization already requested; the automatic goal continuation is not that authorization. A fresh read again returns ENOENT for its once marker. No role operation or force restart is retried.
Source-only review strengthens the default-off root-orientation pulse before that trial: it captures the original direction and the published direction, requires a valid original value, uses a weak reference to the exact scene instance, and cancels obsolete native update blocks with an atomic request generation. Each settings block compares the current value before writing. Restoration begins at ten seconds, preserves newer different values and does not replay a prior instance's checkpoint into a reconstructed scene. Temporary scene/profile lookup misses retain the checkpoint while its weak scene reference remains live; a missing lookup alone does not prove removal. It verifies server settings after submission, retries up to three times and retains an unverified checkpoint for explicit retry. Logs distinguish verified restoration, supersession, ended scene scope and pending recovery; they no longer equate a submitted write with completed recovery. Exceptions in settings/restoration blocks are caught and leave a retryable checkpoint. These guards are built for review, not deployed or runtime-accepted. The running machine remains a30e60db; the pending role authorization remains a runtime-validation blocker.
Authorized single-Main orientation trials (2026-10-26)
The user explicitly approved the single-Main role/scene-orientation retest and completed the macOS administrator prompt. Version 4bdf2d54 was installed, bound and guest-deployed with matching replies; the guest dylib SHA-256 is a871f1bd57e216a3d42d9af43976778078dbf7c024d86775202a70cbc27f1161, identical to the installed bundle. No virtual display or type promotion was introduced. This authorization does not imply approval of the separate previously rejected built-in virtual-panel retest.
The first quiet trial established the inner role but reached the pulse when native arbitration and root settings were both portrait. The guard correctly logged already-aligned and wrote nothing. SB PID 327/start 1792977748.972443 and backboardd PID 61/start 1792977649.952058 stayed unchanged during the observations. Input portrait/native interface 4 at startup, then input Ll/ native interface 1 after six seconds, show the direction disagreement also survives removal of the heavy observer. Its cause is not yet established.
The second trial triggers on the measured startup mismatch. SB-main root uiSettings.interfaceOrientation changes 1->4 at 09:27:33, restores to 1 at 09:27:43 and is verified at 09:27:44. Home remains orientation 4 with 951×678 frame throughout; StatusBarWindow and UIWindowScene remain direction 1 at 678×951. SB PID 468/start 1792978044.13892 and backboardd PID/start stay unchanged. Thus the protected native settings write and recovery work, but that server settings field is not the missing driver for coherent client scene/status-bar rotation. Both trial cleanup paths reconstruct a default surface-0 SB, explicitly restore open-flat portrait and verify the baseline.
Static UIKit tracing explains this boundary: UIWindowScene.interfaceOrientation chooses _effectiveUIClientSettings when _canReceiveDeviceOrientationEvents is true, otherwise _effectiveUISettings. SBWindowScene also returns YES from _windowsIgnoreSceneClientOrientation. Do not treat a server settings-field change alone as client/window rotation evidence.
A new default-off probe uses the public UIWindowScene.requestGeometryUpdateWithPreferences:errorHandler: API and UIWindowSceneGeometryPreferencesIOS.initWithInterfaceOrientations:, checked against the local iOS 27.1 SDK and this guest's method ABI. It requests only the existing native arbitrated direction on the real Main inner scene, logs errors, and requests the original geometry again after ten seconds. Separate controls invoke the native pipeline's settled-pose and input-update methods without changing masks. Lightweight snapshots also capture the provider's first three typed orientation fields, deferral/fold/cover flags and effective UI/client settings. Built, with installation/acceptance pending.
Observed result (a9fd2032): installed/bound/deployed replies match the version. The native geometry API returns UISceneErrorDomain 101 for both the requested orientation 4 and restoration request 1: "The current windowing mode does not allow for programmatic changes to interface orientation." Neither client settings nor visible window directions change. Settled-pose and input-refresh calls execute their validated native methods, but likewise do not change the interface result.
The new typed provider readback is valid and fast: baseline directions are [1,1,1], then [3,4,3] after Ll, matching the actual provider's default/cover/ inner fields. Direction deferral is false, coversheet-presented is 0, fold holder count and settle-window-active are 0. With correct Ll inputs the native interface remains 1; startup instead shows portrait inputs with native interface 4. These observations exclude missing input publication, the measured deferral/lock states, or another simple explicit-refresh call as sufficient explanations. They do not establish the precise policy cause. Snapshots remain 0.68-3.78ms. SB PID 322/start 1792980218.917553 and backboardd PID 61/start 1792980126.985616 are unchanged across the trial. Cleanup verifies a fresh default surface-0 portrait SB and pose [1,1,1].
Static follow-up locates the system-owned extension point: SBTraitsPipelineManager.newBlockBasedOrientationPolicySpecifier:forRole: constructs SBTraitsPipelineBlockBasedPolicySpecifier. Its stage callback tail-calls the supplied block with the stage and update context. This is an actual system arbitration component, unlike the rejected application geometry request. Its policy API/semantics remain under read-only analysis; no custom policy is installed yet.
Additional standard-event trials preserve/restore the rotation-lock state and launch Safari about:blank, then return Home. Neither a lock cycle nor the app transition resolves the observed orientation discrepancy. Safari is verified foreground through RunningBoard. No application code is modified.
A separate coherent-input trial pairs SBS poses with the existing virtual gravity configuration/notification, without AX rotate requests or their early-failure rollback. Pose directions [3,4,3], [1,1,1], [4,3,4] and bridge delivery values 4,1,3 are recorded, but interface results are 1,4,2. This does not establish correct rotation. Crucially, the bridge's delivered value is its own attempted-publication record, not native BKOrientationManager effective-state readback; do not treat it as endpoint acceptance.
The current backboardd binary exposes typed getters for isOrientationEventsEnabled, currentRawDeviceOrientation, effectiveDeviceOrientation, currentUserInterfaceOrientation and lastEffectiveInterfaceOrientation. Static updateDeviceOrientation uses dispatch_sync; refreshEffectiveDeviceOrientation uses dispatch_async, so a simple reversed dispatch-order claim is unsupported. The update's first gate is _queue_ignoreAccelerometerAndOrientationEventsForTesting. A new explicit read-only probe collects the actual native values off the BBD main queue, including the primary display's rotation support when available. An optional ten-second, original-value-preserving event-enable probe is restricted to the active inner metadata experiment; already-enabled state causes no mutation.
TraitsArbiter's native TRAArbiterUpdateContext also provides forceOrientationResolution. A separate default-off control asks the real Main inner arbiter for its native default update context, sets that flag and uses setNeedsUpdateArbitrationWithContext/updateArbitrationIfNeeded. It does not install a new policy, change supported masks or supply a direction. These controls are built; actual native endpoint/forced-resolution behavior remains pending verification.
Native endpoint result (ffe8184d): orientation events are already enabled, device orientation is not locked and the primary display supports rotation. The event-enable probe therefore performs no mutation. During direct virtual gravity publication, the real manager's currentRawDeviceOrientation and effectiveDeviceOrientation both read 0, despite the bridge recording the requested nonzero delivery. Forced native arbitration executes but cannot repair the missing effective input. Observations remain within one SB/BBD process lifetime; default portrait recovery completes afterward.
Static analysis now identifies the writer: refreshEffectiveDeviceOrientation creates a CMDeviceOrientationManager, starts updates with a nil handler, calls deviceOrientationBlocking, stops updates and posts that sampled orientation back. In this sensor-less guest it replaces the virtual input with Unknown. The virtual sink explicitly called this method immediately after every high-level updateDeviceOrientation:changeSource:. The high-level update already stores the raw direction and invokes native effective-state notification; another hardware poll is not required.
The sink now omits that poll on its high-level update path. The older raw-post/fallback paths and gravity mapping remain unchanged. Existing accelerometer gravity/HID/subscription and orientation queue/dedup/backlight regression suites pass, as does the complete VPhone bundle build. Runtime endpoint persistence and whole-UI rotation after this change remain pending; neither the tests nor build prove the requested Duo layout/switching goal.
Runtime result (63af20bd, 2026-10-26): installation policy/preflight pass; the stopped duotest-m5 is explicitly bound and update-environment returns this exact bundle with status 0. The no-hardware-poll trial now reads native BBD raw/effective direction 4 throughout three landscape samples, then 1 after portrait restoration. Previously those landscape endpoints were 0. This validates the virtual input repair, not whole-interface rotation.
The remaining disagreement is reproducible: pose directions [3,4,3] yield arbitrated device 3 but interface 1; pose [1,1,1] yields device 1 but interface 4 on the scoped inner surface. Home follows the arbitrated interface while the status window stays at 1. Native forced arbitration does not repair it. The actual landscape-request screenshot remains a centered four-column portrait grid with a horizontal wide Dock and missing status content. Both SB PID 276/start 1792989181.8242991 and BBD PID 61/start 1792989166.9892559 remain unchanged across the measured body. Every screen sample reports unlocked and screen_off=false. Cleanup expires metadata and reconstructs an unarmed SB (PID 315): surface 0, all pose directions 1, arbitrated/scene/Home/status directions 1, original portrait layout recovered. Evidence: /tmp/duo-approved-no-hardware-poll-trial. The inner/outer layout, status/Dock coordination and usable rotation goals remain unaccepted.
The subsequent read-only policy-detail trial confirms current/nonFlat inputs Ll with backlight on, and the ordinary native default resolver (no custom resolution policy installed). Static tracing of its device-to-interface conversion block reaches SBWindowScene.systemSpaceOrientationForDeviceOrientation:. For surface inner, the shipped forward table is [4,3,1,2] and inverse table [3,4,2,1]; for other surfaces, its native toll-free branch uses identity. The measured device 1 -> interface 4 / device 3 -> interface 1 therefore matches deliberate real-inner-panel conversion, rather than lost input.
A default-off ten-second probe selects the native toll-free branch only for the exact weakly held, active Main inner scene at 678x951. It uses the native paired forward/inverse conversions and forces native arbitration afterward; it supplies no window directions, masks or new policy. Original conversion is restored automatically or explicitly, with generation cancellation. The lightweight snapshot records all four forward/inverse results so this can be distinguished from downstream scene/status/layout acceptance. This new conversion probe is not yet runtime-validated.
Conversion runtime (ac5e5f4d): the live paired maps change from [4,3,1,2]/[3,4,2,1] to identity. Native device/interface/scene/Home directions then agree at portrait 1, landscape 3, portrait 1. SB PID 279/start 1792989833.697464 and BBD PID 61/start 1792989816.037673 stay unchanged during the body. The timer logs restoration at 12:44:14; the sample named automatic_restore was captured just before its ten-second deadline and is not itself a restored-map observation. Cleanup's explicit restore and fresh unarmed SB return the default surface/portrait baseline.
The screenshot still fails acceptance: status content stays at the side, widgets clip and the landscape Dock geometry remains wrong. Static native status-bar inspection explains another shared-policy gate: SBStatusBarWindow._sbStatusBarWindowShouldRotate queries the owning scene's sbhScreenType and requires D77 (32) on the phone path. The real scene's sbhScreenType calls the same context classifier that selects D74 at 678x951. Thus changing only an icon layout provider cannot open this shared gate.
A separate consumed-once inner-family marker now tests the shared scene sbhScreenType boundary, scoped to real Main inner and actual 678x951. It obtains D77 from the native classifier using a distinct reference context at 669x951, and returns that native type to scene consumers; actual display, screen and scene geometry remain 678x951. No context or reference geometry is published and no status/window method or app mask is replaced. This one-instance experiment ends with a fresh unarmed SB, as the existing single-Main trial does. Runtime family propagation and UI acceptance remain pending, including whether the icon manager actually adopts the new family.
Initial shared-family trial (dfbcdc9c): the startup adapter installs, but its identity/configuration guard rejects publication: SBWindowScene has no displayConfiguration on this build. Native scene type stays 23, so this run does not test a D77 classification change. The ten-second conversion timer does restore the original [4,3,1,2] map in a post-deadline snapshot, and cleanup returns unarmed surface 0 / type 23 / portrait. The classifier now reads FBDisplayManager.mainConfiguration (already validated at 678x951), still requiring its identity to match the real Main scene before using it. This correction is built; the corrected classifier's runtime result is pending.
Corrected family runtime (04596e28): guest/library SHA-256 both 2c861f3bc30fbbefaab14acce5cad8754ba828f2a23efc739e250131c0d9cb64. The adapter now accepts Main at 678x951: original scene type 23 becomes 32, and the actual SBHIconManager adopts the native D77 default layout provider. This is shared-family propagation, beyond the earlier provider-only test. It still fails visual acceptance: overlapping/offscreen page content and Dock remain, while StatusBarWindow stays portrait. Native device/interface/ client-scene/Home directions agree at 1 -> 3 -> 1 during the canonical conversion lease; server root settings stay 1. The automatic restore sample verifies the original map again. SB PID 284/start 1792990659.378324 and BBD PID 61/start 1792990630.973835 remain unchanged during the body. Unarmed cleanup returns type 23 / surface 0 / portrait.
A further no-conversion-override trial uses the native inverse table to submit physical device 3 for system-space portrait 1, and device 2 for system-space landscape 3. Native scene/Home directions again follow 1 -> 3 -> 1, with server root settings still 1 and status still portrait. Thus the inner mapping is a native mounting convention, not by itself a system defect; the canonical probe is an optional virtual-coordinate experiment, not a necessary shipped fix. The real virtual input sink repair remains independently validated. Next, combine the already-authorized root settings pulse with the now-validated shared family and native input map, to test whether the server/client disagreement explains the remaining UI.
Combined root pulse: with shared scene D77 and inverse-mapped physical pose, the existing pulse changes SB-main's server direction 1 -> 3 -> 1, while client scene/Home remain at native 3 during landscape. Status and page/Dock corruption do not improve. A server direction scalar alone is therefore insufficient even after the scene-family gate is open.
Static tracing identifies a narrower shared classification entry than the exported context wrapper. SBHScreenTypeForScreen bypasses that wrapper and calls _SBHScreenTypeLookupCommon, as the context path also does; the global current-device family is cached. The native helper's exported symbol is verified in the image exports trie. Its ABI is x0..x2 scalar values, a CGRect HFA in d0..d3 and two scalars in d4/d5; the scoped probe preserves every argument and asks the original helper about a local 669x951 reference only when actual bounds are 678x951, accepting only native D77 result 32.
The separate consumed-once shared-family marker installs this helper detour at SB construction, reusing the existing MISFixDetour implementation already loaded into SpringBoard. It checks relocatability/readback and uses private COW cache pages; there is no disk-cache edit or effect on another process. The scene-only adapter is not armed at the same time. Snapshots separately record global, UIScreen and scene families, native effective geometry, and native status-window orientation/rotation permission. Restore is a fresh unarmed SB after the bounded trial. Build passes; runtime acceptance pending.
Common classifier runtime (652fa4fc): constructor detour returns 0 (installed). Global, UIScreen and scene families all read 32 throughout the measured body, without the scene-only adapter. Native status rotation permission becomes YES and its native supported mask becomes 30, compared with NO/2 in the unarmed baseline. Landscape screenshots now show separated Today/Home panes and a spaced vertical Dock instead of overlapped pages and stacked Dock icons; portrait returns a centered compact Dock. This is visible system-policy progress, not full acceptance: the status window retains native orientation 1 even while scene/effectiveGeometry/Home are 3, and status content is missing. The native D77 portrait grid itself is still four columns.
SB PID 276/start 1792991587.638048 and BBD PID 61/start 1792991567.953109 stay unchanged during the body; samples are awake/unlocked. Cleanup creates unarmed SB PID 347, all three families 23, surface 0 and all directions 1; native status permission/mask return to NO/2. No detour survives that process. The next default-off diagnostic replays the existing shared application pipeline orientation callback with the native resolved direction and force refresh, while recording pipeline setup state and application-wide directions. It does not invoke an individual window/status setter or change any mask.
Actuation callback runtime (3efe7381): pipeline setup/running-updates both read 0. In native landscape, application.activeInterfaceOrientation, scene effectiveGeometry and Home are already 3, while statusBarOrientationForEmbeddedDisplay, statusBarOrientation and the native status-window cache remain 1. Replaying the shared pipeline callback executes with direction 3 and changes none of those observations. This is an embedded direction-slot disagreement after successful global arbitration, not a stuck pipeline setup phase. The unarmed portrait baseline is restored afterward.
A separate default-off ten-second probe now calls the system's native setStatusBarOrientation:forEmbeddedDisplayAnimated: wrapper with the existing arbiter direction. It synchronizes the embedded-display slot, not individual window angles. Exact app/scene weak scopes, generation cancellation, original/current-value guards, restore readback and retained failed checkpoints bound the experiment. Newer native directions are preserved. Runtime result remains pending; this control is not an enabled shipping fix.
Embedded-slot result (dee331c8): the native wrapper is invoked for 1 -> 3, but immediate and later readback remain 1. No requested slot change lands. The timer preserves the already-original value; fresh unarmed cleanup returns surface 0 / families 23 / portrait. Do not report this as a successful embedded-direction repair or a restore from an applied direction 3.
Native SBWindowSceneStatusBarManager explains the no-op route. initWithScene: uses isMainDisplayWindowScene to reuse the old application's embedded bar; its _isEmbeddedDisplay later checks the scene FBS identity's isMainDisplay. setStatusBarOrientation: therefore dispatches Main to the legacy application setter instead of the native per-scene window setter. Non-Main scenes already have a complete per-scene construction/update path.
A separate consumed-once scene-status policy experiment selects that existing per-scene path for only the actual Main inner at 678x951. Construction uses a thread-local, exact-scene isMainDisplayWindowScene override, restored in finally; outside construction that scene getter remains native. Its manager uses the per-scene route while it belongs to the scoped inner scene. The native constructors, frames, directions and supported masks remain in charge. No FBS identity, CADisplay type/count or individual window angle is replaced. The adapter is installed only in the marked SB instance and ends with a fresh unarmed instance. Runtime acceptance remains pending.
Initial scene-policy runtime (6581d4c8): hooks install, but there is no accepted per-scene construction log and native status orientation stays 1. This is not evidence that the native nonembedded route ran. Metadata confirms the manager owns a private _windowScene ivar rather than the assumed object getter. The correction reads that actual ivar, resolves the scene's native _fbsDisplayIdentity, and requires identifier SB-main plus the real Main root at 678x951. The explicitly armed inner startup may still have surface 0 during construction, so only that construction phase accepts 0 or 2; runtime route selection still requires 2. New constructor logs record these native inputs, so a rejected scope is visible. Corrected runtime remains pending.
Corrected per-scene runtime (896e6d1e): native construction logs SB-main / Main / initial surface 0 and accepts the explicitly armed inner startup. The status window now has the native _SBStatusBarWindowRootViewController and follows Home through portrait 1 / landscape 3 / portrait 1 with matching 678x951 / 951x678 frames. SB PID 281/start 1792993815.337351 and BBD PID 61/start 1792993791.974462 remain unchanged during the measured body. Battery content becomes visible at the top in both orientations. This is native status-window propagation progress, not full status acceptance: time/other data are still missing.
An unmodified Safari about:blank launch is verified foreground by RunningBoard and fills the landscape display; returning Home retains the separated native D77 panes/Dock. Its battery overlay overlaps Safari's toolbar and time remains absent. The per-manager policy adapter therefore does not unify all status style/data/safe-area decisions. Cleanup restores ordinary unarmed portrait.
Native FBSDisplayIdentity.isMainDisplay is only a type==0 predicate; native isMainRootDisplay first checks isRootIdentity and then calls isMainDisplay. A separate consumed-once root-scene policy experiment now separates these two uses: existing native Main roots retain the original hardware-root YES answer for display discovery, while isMainDisplay returns NO for their legacy UI policy classification. Original type/root/identity fields, UIKitMainLike, always-connected state and display count remain unchanged. Other identities pass through. This test does not arm the per-manager adapter, so all native UI consumers see the same root UI classification instead of per-component exceptions. Snapshots record both predicates and native manager path. The UI/root-role combination is experimental; build passes, runtime pending.
Root-role startup result (8ae865f9): marked SB PID 283 launches at 14:05:13 and traps at 14:05:15 in FBSDisplayMonitor._initWithDisplays:mainDisplay:bookendObserver:transformer:.cold.1. The native failure string is "failed to initialize mainConfiguration". The automatically relaunched, unmarked SB PID 299 returns families 23 and Main/MainRoot predicates YES. Its surface remains temporarily 2 from the backboard metadata lease, so a surface-only startup guard did not detect the lost experimental instance. No observation from that replacement process validates the role adapter. Full cleanup then restores surface 0 / families 23 / portrait in SB PID 329. The crash report is preserved at /tmp/duo-root-scene-policy-crash.ips.
Monitor construction requires native Main semantics in addition to the post-construction Main-root lookup. The revised one-instance experiment therefore wraps both native monitor initializer entry points in a thread-local hardware-identity scope, restored in finally. Main semantics inside that scope remain original, so native validation stays enabled; outside it, the tested UI role rule remains as above. Other arguments and configuration fields pass through unchanged. The old observational monitor wrapper composes with this constructor scope. Startup acceptance now also requires Main NO / MainRoot YES and families [32,32,32], so an automatic unarmed relaunch cannot be mistaken for a successful experiment. Build passes; the corrected hardware/UI scope has not yet been runtime-validated.
Monitor-scope runtime (0613339d): native monitor initialization now logs success, but marked SB PID 286 traps at 14:19:56 during UISystemShellApplication.init -> FBInterfaceOrientationService default orientation submission. The failing argument is a nil display identity. Static tracing shows the default submission searches alwaysConnectedIdentities with a native isMainDisplay predicate before forwarding to the identity-based setter. Retaining only monitor-construction Main semantics therefore does not preserve physical orientation-service lookup. The new startup guard correctly rejects the automatically relaunched unmarked PID 297 instead of continuing UI tests. Cleanup restores unarmed PID 315, default portrait. Evidence: /tmp/duo-hardware-ui-scope-crash.ips.
The next one-instance revision extends the same finally-restored hardware scope around the physical orientation service's default lookup/submission and its identity-based submission entry. It passes orientation, identity, animation settings and direction through unchanged and keeps the native nil argument assertion. UI queries outside these hardware calls still see the experimental per-scene classification. No assert or on-disk cache is patched. Build passes; runtime outcome remains pending.
Direction-scope result (49fcc209): native monitor and physical orientation submission both complete, then marked SB PID 300 aborts at 14:34:44 in XBApplicationController.initWithMainDisplayConfiguration:... during application bootstrap. Startup guards reject the unmarked recovery; cleanup restores SB PID 320, surface 0 / families 23 / portrait. Evidence: /tmp/duo-hardware-direction-scope-crash.ips. Global Main predicate conversion is therefore rejected as an architecture: display discovery, physical orientation and application bootstrap all require the original Main model. Do not disable those checks or keep adding consumer-specific exceptions. The global role-conversion implementation has now been removed; its stale startup marker is consumed and discarded with a diagnostic.
The next control is read-only: build a native UI-only configuration object from unchanged Main, with a distinct identifier, native-valid Unknown type 7, UIKitMainLike retained, and original Main backing/root linkage. Type 2 is reserved/invalid in the native validity bitmap and is not used. Record the actual native identity flags, both physical/connected counts before and after, non-nil render object equality, and a hosted-configuration change on only a distinct scene-settings copy. No monitor entry, physical display or live scene is changed. This establishes a concrete UI-context candidate for review; publication into a running scene is not part of the preview.
UI-object preview runtime (116c3c42): native type-7 configuration builds at 678x951 with UIKitMainLike YES, integrated connection 0, nonexternal and alwaysConnected YES. Its Main/root predicates are NO, it has the exact original Main as parent, and both non-nil CADisplay references are identical. FrontBoard connected identities stay 1 -> 1; CADisplay object enumeration stays 6 -> 6. The latter is an object count, not a physical-port count. Only a distinct scene-settings copy receives the new hosted configuration; original frame remains unchanged and nothing is published. Persistent evidence: Research/Guest/Evidence/iphone_duo_ui_context_candidate_20261026.json.
The user explicitly permits UI-derived identities while retaining a single physical Main. A separate default-off ten-second pulse now tests publishing this hosted configuration into only the existing SB-main scene. It retains the original hosted/UI configurations, weak exact scene/server scopes, generation cancellation, current-value guards and verified restore retries. An explicit second control can call the native UIScene display-identity-update entry only while the matching hosted lease is current. Original hardware Main, monitor entries and CADisplay objects are not edited. Snapshot client identity and both counts to distinguish server-field publication from real UI migration. Runtime publication/activation/rollback are not yet validated.
First lease result (fc9aa0d0): no hosted transaction executes. The pulse mistakenly stored UIWindowScene._FBSScene (a read-only FBS client) where the previous geometry experiments used the writable FBScene server. Activation correctly refuses because no matching hosted lease appears; all identities remain original Main and counts stay 1/6. SB PID 298/start 1792999850.589847 and BBD PID 61/start 1792999821.026773 remain unchanged. The timer verifies the already-original state, not a rollback from an applied configuration. Cleanup returns unarmed default portrait.
The correction resolves the writable SB-main server through the existing root-server inventory and separately retains the exact client binding for UI activation/restoration checks. Static UIScene tracing verifies the native update entry registers the supplied configuration with UIScreen, then selects the screen using the scene settings' displayIdentity. Record both hosted and actual server display identities and inspect mutable identity/config setters without calling them, so hosted publication is not mistaken for migration. Corrected publication/activation remains pending runtime verification.
Writable-server result (249a5f50): the hosted configuration really changes to vphone-duo-ui-preview, and the native client update entry is invoked. Actual server/client display identity nevertheless remains original Main (type 0), as do the legacy UI/manager predicates. Counts remain connected 1 and CA objects 6; ten-second restoration removes the hosted value and verifies the original state without an experimental process exit. This is a successful hosted-field pulse/rollback, not UI identity migration.
The mutable settings object reports native setDisplayConfiguration: with encoding v24@0:8@"FBSDisplayConfiguration"16. FBSSceneSettings.displayIdentity is derived from displayConfiguration.identity, so the hosted extension alone cannot change the identity UIKit uses. The next transaction updates both displayConfiguration and hosted configuration, preserving both originals and using pairwise current-value/generation guards. Setter ABI validation accepts the native typed-object annotation semantically. Restoration handles either fully applied or partially applied own values, preserving newer values. The physical Main configuration/identity remains untouched. Build passes; actual identity adoption and rollback are pending runtime verification.
Actual identity result (ed2bf017): the dual transaction changes hosted, server display and real client display identities to vphone-duo-ui-preview, type 7. Scene legacy-Main and manager embedded predicates become NO without any global Main accessor or per-manager policy hooks. Connected displays remain 1 and CADisplay objects 6. Ten seconds later server/client identities return to Main/type 0 and both predicates return YES. SB PID 299/start 1793001405.317071 and BBD PID 61/start 1793001368.974815 remain unchanged; native transaction and original-state verification logs confirm both changes. This validates live UI-identity migration/rollback on unchanged hardware Main.
The already-created status component is still the old legacy instance/window (nil root controller), so identity adoption alone is not full chrome/data acceptance. The next default-off control uses native scene-component registry and lifecycle APIs to recreate that cached native component under the derived identity. It retains the old component, disconnects the replacement before identity rollback, then restores/registers/reconnects the original component after the original identity is acknowledged. No window direction/frame or app binary is patched. Component lifecycle/UI data results remain unvalidated.
Native reference correction (user image, 2026-10-26): the real inner display uses a top-right vertical module: time above a circular battery/ cellular indicator. It does not use the generic left-time/right-battery bar. Reference: Evidence/iphone_duo_native_status_reference_20261026.png (2007x2853 pixels). The native D77 portrait four-column grid and compact Dock match that reference; the actual virtual framebuffer target remains 678x951. Do not call generic per-scene time/battery visibility Duo status acceptance.
The component-lifecycle trial (3128f885) does recreate a native component and show generic time/battery; original component and UI identity restoration are verified. Rotation still reveals inconsistent scene/server actuation for the managed UI alias, so this is not usable rotation acceptance. In light of the reference, stop pursuing generic status-bar layout as the target. UIKit exports only generic visual providers, while SpringBoard contains the dedicated SBDuoDisplaySystemUISceneController and imports SBSUIDuoDisplaySceneSpecification. Trace that native system-UI scene's admission/creation path next.
Native status selection trace (2026-10-26): the coordinator's 18 startup controllers exclude Duo, but its dynamic session-role configuration admits SBSUIWindowSceneSessionRoleDuoDisplay with SBDuoDisplaySystemUISceneController, vending strategy 3 and host level 10000001.6. This is an on-demand role; absence from startup controllers alone does not establish a disabled gate or identify its requesting process. Correct the inventory to compare real Class objects and query the controller's +_setupInfo.
SystemStatusUI contains AxisAlignableSplit2007 (native width 2007, reference scale 3, inherited 37x37-point notch). Native provider selection checks both _nativeDisplayBounds width and _exclusionArea.rect dimensions, scaled using _nativeScale/referenceScreenScale. Width mismatch alone is therefore not an established cause. New default-off status_provider.inspect calls native factories with isolated forwarding proxies for 2034/2007 reference widths, retaining the real screen's exclusion area. These objects are never registered or published to UIKit; no live provider is selected or replaced. Runtime factory choices and actual exclusion geometry remain pending verification.
Read-only status result (3c7497fe): actual native bounds 2034x2853, scale 3, exclusion UISDisplaySingleRectShape at {152.3333,11.3333,125.3333,36.6667} points. Both 2034 and 2007 reference-width arguments select DynamicSplitSmallIsland through the native iOS factory; the AxisAlignableSplit factory returns nil for both. Width alone is disproven. UIScreen._exclusionArea delegates to _displayInfoProvider.exclusionArea; its native default originates in UISDisplayContext. The next pure factory preview combines reference width with an isolated shape using the native 2007 provider's notchSize; that shape is never published. Native SB's D77 shape helper itself still describes a pill, so the Home-family adaptation must not be assumed to fix status geometry.
The root orientation pulse previously rejected the approved live UI alias because it required the UI identity itself to equal physical Main. Extend its scope only to the currently leased exact UI identity, exact window scene, rootIdentity equal to original Main, and identical CADisplay backing. Hosted settings must match that UI identity. It still publishes only native arbiter orientation through the FBScene server, with original-value/generation checks and timed rollback. Runtime managed-scene rotation remains pending.
Factory isolation result (ca903010): retaining the real pill exclusion still selects DynamicSplitSmallIsland at both widths. An isolated, unpublished 2007-wide factory argument with a 37x37-point shape selects the native AxisAlignableSplit2007 via both iOS and AxisAlignableSplit factories. This establishes native selection inputs, not live status rendering or a calibrated camera location. Evidence: Evidence/iphone_duo_native_status_factory_20261026.json. Do not assume the dynamic Duo scene role is the status renderer: its requesting process and purpose have not yet been established.
Managed UI orientation result (ca903010): on the leased type-7 UI alias, SB-main server interfaceOrientation changes 1->3 through the native settings transaction, matching system arbitration and the visible landscape Home/Dock. Other aperture scenes remain at 1; no window transform/frame is forced. Explicit orientation restoration returns the server to 1, then UI identity restoration returns type 7->0. Both process identities remain unchanged (SB 292/start1793007445.879537, BBD61/start1793007410.980807) during the trial; connected/CA object counts remain 1/6. Cleanup reconstructs unarmed baseline: Main/type0, portrait1, surface0, native classifier23. Evidence: Evidence/iphone_duo_ui_alias_orientation_20261026.json. This is a bounded native direction transaction, not persistent rotation, full status acceptance or completed runtime inner/outer switching.
Static creation tracing: SpringBoard +_statusBarClass returns native STUIStatusBar_Wrapper only when SpringBoard/Speakeasy or SpringBoard/SpeakeasyNewStatusBar is enabled; otherwise it delegates to UIKit. The shipped flag plist contains SpeakeasyNewStatusBar and SpeakeasyStatusBarWindowRotation with DevelopmentPhase=FeatureComplete. That declaration is not a runtime boolean readback. Extend the existing read-only status inventory with the application's actual class selection, manager's wrapper, nested renderer, and renderer's native visual provider. No feature flag override or renderer replacement is applied.
Actual renderer result (ed4011db): the live chain is already STUIStatusBar_Wrapper -> STUIStatusBar -> DynamicSplitSmallIsland. Speakeasy is therefore not established as a missing gate; no feature override is needed. The native factory analysis does apply to the actual renderer.
Next default-off, eight-second status_profile pulse scopes the native iOS provider factory to the current leased SB-main UI identity and exact UIScreen, with the original Main parent and CADisplay checks reused. It supplies only canonical inner reference metrics (2007x2853 and the native profile's own notchSize) as an isolated factory argument. The original factory must return the native 2007 profile; a constant provider class is never forced. Native construction still receives its real UIScreen. No live screen, safe-area, window geometry, physical display type or feature flag is changed. Native scene-component creation is used to exercise the new lookup. Original factory and native provider are restored before UI identity/component rollback. This is a controlled rendering experiment, not a persistent display-profile solution or native status acceptance until screenshot/readback verification.
First live reference-lookup result (1ca946c8): the screenshot remains a classic horizontal bar and actual provider remains DynamicSplitSmallIsland; there are zero accepted-reference log entries. Do not treat this as a native status result. SB275/start1793008607.404166 and BBD61/start1793008600.006581 remain stable through the test. Native root directions round-trip 1->3->1; original factory, component and identity restoration all verify.
The next bounded probe instruments all four native factory entrypoints (iOS, Phone, Split, AxisAlignableSplit), rather than assuming the renderer enters iOS. It records a bounded call/screen-scope inventory. Reference normalization is additionally allowed during the exact owned native component constructor and its mounted provider reset, where a newly created view can still report the bootstrap screen. A thread-local construction scope and recursion guard keep nested native factories from re-normalizing. All four original IMPs are restored conditionally before component/identity rollback. No C-code detour, on-disk change, global Main role override or direct provider-class return is introduced by this probe. Runtime path and visible outcome remain pending.
Embedded status cache boundary: native STUIStatusBarGetVisualProviderClassForScreen checks UIScreen._isEmbeddedScreen and, when true, reads a process cache keyed by _referenceBounds before consulting any native provider factories. UIScreen's embedded predicate is _isMainLikeScreen && !_isExternal. The UI alias was explicitly UIKitMainLike, retaining the legacy embedded cache semantics even though its FBS main-display predicate was false. A mounted provider reset alone does not establish cache invalidation.
Add a pure native-builder preview of a non-MainLike derived UI identity. The builder exposes a set-true operation only and inherits the source identity's boolean. Construct a native identity copy with that boolean false and supply it only while the builder copies a distinct unpublished UI configuration. The getter substitution is thread-local to that exact unpublished source; physical Main and other configuration accessors return their originals. All other identity fields, Main parent, native CADisplay backing and geometry are preserved; restore the getter before the preview returns. A separate explicit ten-second pulse is available only if those invariants validate. No global Main role change is revived. Runtime preview must pass before publication. The status inventory now records embedded/cache predicates on both the scene screen and actual renderer target screen.
CoverSheet acceptance is expanded by the user's side conversation: right-side flashlight/camera circular buttons appear oversized. Treat native lock-screen layout, spacing and hit regions as part of the common display-context result; no individual button frame or icon-size patch is introduced.
Acceptance target superseded by explicit user authorization (2026-10-26): the user authorizes real-device dimensions as acceptance. The target is now 2007x2853@3 = 669x951 points, matching Apple's installed iPhone Duo device profile and the supplied native screenshot. Earlier instructions to retain custom width678 no longer apply. Correct iPhone19_4InnerScreen to width2007; this changes the bound bundle's next open/book boot, without runtime VZ resize or another physical Main. Do not arm the custom-678 Home classifier adapter for native-size acceptance. Control Center is also part of common acceptance: module sizing and content bounds must be checked with status/Home/CoverSheet, not independently scaled.
Nonembedded builder preview result (4ee170f2): native construction succeeds with UIKitMainLike=false, Main=false, real Main parent preserved, exact same CADisplay backing, and original bounds678x951. Physical Main remains MainLike. No UI configuration was published by that preview. Repeat on the now-authorized native-size boot before the controlled rendering test.
Native-size result (71240384): actual hardware mode/native size is 2007x2853, scale3. Main FBS configuration and UIScreen bounds are669x951; global/UIScreen/scene Home classification is32 without the custom678 adapter. The first post-boot log pull returned an older678 preview; a fresh timestamped readback confirms native669 and repeats the non-MainLike builder invariants.
The nonembedded live trial preserves SB352/start1793010315.371838 and BBD61/start1793010240.987467. The real renderer target equals the leased UIScreen and reports embedded=false; native factory callbacks execute. However actual provider is STUIStatusBarVisualProvider_Fallback and status content is absent. Original component/identity restoration verifies and restores DynamicSplitSmallIsland. A missing/incorrect per-screen idiom/trait context is a hypothesis; it has not been read back or corrected. Do not call this native status acceptance.
The user's further acceptance findings remain open: lock-screen button outer size looks correct but its glyphs are oversized; landscape Home loses its wallpaper; status is not Duo; Control Center modules/content sizing is wrong. Snapshots show the wallpaper root controller still supports portrait mask2 while Home and other scene windows support30, including during landscape. Do not patch each controller/button geometry to mask this shared inconsistency.
Touch priority (2026-10-26): user requires both panels' displayed and hittable regions to coincide. Apple device metadata: outer primary surface1, 1398x2034@3=466x678, nativeOrientation/nativeRotation0; inner primary-1 surface2, 2007x2853@3=669x951, nativeOrientation/nativeRotation270. The VM still exposes one physical primary with nativeOrientation0. A native portrait framebuffer alone does not validate that mounting and hit transforms agree.
At current669x951, host-socket taps at the observed right-column Maps center, center Clock and bottom Safari produce no app launch in screenshots. A guest logical-coordinate tap also fails visually. RPC enqueue acknowledgements are not hit-test evidence. A clean restart with all experiment controls off resets CA currentOrientation fromrot90 torot0, but the same right Maps tap still does not launch it. Device.screen reports669x951/scale3 and interfaceOrientation0. The generic device.ioreg inventory stops at depth5/300 entries, so its two HID rows do not establish missing drivers or digitizer extents.
Next probes are observational only: device.input_devices enumerates HID services and whitelisted geometry/report-descriptor properties, without sending an event. A fifteen-second SpringBoard sendEvent observer records native touch coordinates, receiving window/scene and hit view while forwarding the original event unchanged; at most24 begin/end/cancel observations, then conditional restoration. No visual profile changes are made during this trace. Runtime input cause and inner/outer hit-range acceptance remain pending.
Touch cause narrowed by native receipt (e237688b): all four test taps arrive in the correct669x951-point space. Pixel(1486,1398) arrives at (495.3333,466), pixel(1162,2105) at(387.3333,701.6667), and pixel(660,2690) at(220,896.6667). These include positions beyond the outer466x678 extent. Each is delivered to SBStatusBarWindow / STUIStatusBarForegroundView instead of Home icons. Thus the observed dead/limited Home interaction is a status foreground hit-testing interception, not evidence that the digitizer clips to the outer panel. Native trace restored after16 observations.
HID inventory enumerates19 services, including a display-integrated Virtual USB Touch Screen. Its191-byte report descriptor declares four contacts and X/Y logical range0..32767 (16-bit fields); it does not encode an outer-pixel clip. Other report/service fields are observational. The first registryID implementation incorrectly treated IOHIDServiceClientGetRegistryID's return as uint64_t; the local SDK header declares a borrowed CFTypeRef. Correct it to a CFNumber read; do not use initial pointer-shaped IDs as registry values. This diagnostic error never altered input dispatch.
Next bounded native UI-profile experiment preserves the nonembedded identity and inherits the phone idiom from the real Main UIScreen through native _setUserInterfaceIdiom: plus native trait propagation. It touches only the exact owned alias screen, with identity/Main-parent checks and saved original idiom. The real Main screen is excluded. Restore the alias trait only after original UI identity/component restoration and retain failed checkpoints. Read back field and trait idioms before interpreting status provider output. Additional status/foreground frame observations will distinguish native layout from hit-test bounds. No touch coordinate remap, global Main flag or per-button/window geometry patch is introduced.
Native trait field versus published collection (2dc1eacf): alias starts with _userInterfaceIdiom=-1 and traitCollection.userInterfaceIdiom=-1. Native _setUserInterfaceIdiom: changes the field to0, but the published collection remains-1; provider remains Fallback. Static _updateTraits calls the native _defaultTraitCollectionForInterfaceOrientation: and _setDefaultTraitCollection:; run that rebuild before native propagation, rather than assuming field mutation invalidates the collection.
Both managed/default status frames and native input trace expose the second boundary: manager.defaultStatusBarFrame is{615,0,54,951}, but renderer/wrapper/ foreground and defaultStatusBarLayoutFrame cover{0,0,669,951}. The retained legacy Main window also remains visible while the replacement component is active. Complete the owned component handoff: after the replacement connects, hide only the distinct saved old-profile window, saving its exact hidden state. On rollback hide the owned replacement before disconnecting it, then restore and verify the old component/window's saved visibility. No frame, hitTest method or touch coordinate is changed. Trace now distinguishes saved-legacy and managed-replacement windows. Whether this yields native Duo provider/rendering and restores Home hit delivery remains pending runtime verification.
Real native provider + hit-delivery result (bf9002f1): _updateTraits changes the published alias trait idiom to0. The native factory now selects AxisAlignableSplit2007; actual foreground frame is{581,0,88,123.6667} instead of full-screen. The distinct old-profile window is hidden only after native replacement connects. Right and lower Home taps reach SBHomeScreenWindow / SBIconView, and the bottom tap reaches SBFloatingDockWindow / SBIconView at exact expected coordinates. Original visibility/component/idiom restore all verify. The new logical screen still lacks visible native status data: do not call an empty, correctly classified renderer native status acceptance.
Next controlled probe keeps the original physical Main, UI identity, status wrapper and native state/data subscription. At constructor time only, install the existing COW detour on the symbol-exported STUIStatusBarGetVisualProviderClassForScreen. Its default path is unchanged. An explicit eight-second pulse supplies canonical inner reference metadata and bypasses only the embedded lookup cache on an isolated argument proxy; the original native function must select AxisAlignableSplit2007. Native construction continues with the real screen, and native wrapper forceUpdate: refreshes the original subscription. No UI identity migration, component replacement, Main flag override, class constant return or synthetic status values are used in this new path. Behavior restores automatically. Result remains pending actual screenshot/data/hit and process verification.
Original-Main first pulse (f3d69e48): constructor COW detour installs successfully and canonical native lookups select the inner class, but actual renderer still reports DynamicSplitSmallIsland. Do not claim this migrates the renderer or supplies visible Duo status. Static ivar-backed tracing corrects an earlier assumption: resetVisualProvider resets _regions, _displayItemStates and _partAlphas; it clears neither _visualProviderClass nor the provider itself. The corrected native reconstruction clears the cached class toNil through its native setter (never assigns a chosen class), then calls reinitializeStatusBar and the native wrapper's forceUpdate:. Record original actual provider class and verify native restoration; retain pending checkpoints. Runtime output remains pending. A white captured frame during the first pulse is not native status acceptance and must not be interpreted as successful app presentation.
The user reports touch misalignment after rotation. Portrait event-coordinate proof is not rotated acceptance. Add read-only host socket display.geometry: configured pixels, actual capture pixels, VZ graphics pixels, view bounds, aspect-fit display rect, frame rotation, screen orientation and whether mouse clicks use the guest pointer. The UIKit trace additionally records touch type, scene interface orientation and screen/scene coordinate-space bounds. Compare the same full pipeline before/after each quarter turn; do not assume a width/ height swap or reuse portrait calibration. Both panels and rotation round trips remain required. No host input-transform change is made before measurement.
Rotation refusal reproduced (64a73aa0): after the known native portrait setup (SBS device pose3, gravity270), ordinary display.rotation orientation90 is refused. SBS pose remains3 and the catch restores gravity270. Host configured, VZ graphics and captured frames remain2007x2853; host view rotation stays0 and mouse_uses_pointer=false. Both host and guest touch paths deliver the same (133.6667,285.3333) point for normalized(0.2,0.3). This refused request did not enter landscape and therefore is not rotated acceptance. Existing API updates gravity and AX orientation but never updates the active SBS pose override.
Add a native mapping bridge: SpringBoard exports its current scene's actual q24 deviceOrientationForSystemSpaceOrientation: inverse map, scene/surface/bounds and monotonic generation time to an owned atomic JSON file on an explicit Darwin request. The daemon accepts only a newly generated SB-main record; no prior-boot/stale map is reused. display.orientation_mapping reads it. On iPhone19,4 only, display.rotation resolves the logical request through that native permutation and synchronizes SBS device pose plus matching gravity, preserving hinge/tabletop. The logical AX request remains unchanged. A failed request restores owned pose/gravity and the previous interface/pointer state. Other models retain the existing path. No generic gravity enum or host affine transform is changed. A pure mapping regression matrix covers native cover and inner tables, aliases and invalid maps. Runtime rotation/hits pending.
User-reported real SpringBoard crash (2026-10-26 21:06:04): newest IPS SpringBoard-2026-10-26-210608.ips is PID294, launched21:06:02.1981, captured 21:06:04.6442. EXC_BAD_ACCESS/SIGKILL, EXC_ARM_PAC_FAIL, termination PAC_EXCEPTION261. It is not a planned SIGTERM/SIGKILL reconstruction. Faulting main thread: objc_msgSend(retain) -> libvphonefoldguard vpGuardedCopyRender+84 -> QuartzCore CA::Context::commit_transaction first commit; receiver layer class SBRecordingIndicatorSecureLayer. It predates this rotation API call. Trial PID313 stayed stable through its own body, then cleanup created343, so body PID checks alone missed this preceding real startup failure. Sanitized evidence: Evidence/iphone_duo_foldguard_pac_crash_20261026.json; full IPS preserved locally.
Root cause is the existing FoldGuard wrapper declaring the native C++ CA::Render::Layer* return as id. On non-null successful copies, ARC sends retain to the non-ObjC pointer, causing PAC authentication failure. Exception/NULL paths did not expose it on every launch. Correct both wrapper and original-call return types to void*, use register-width non-ARC arguments with runtime ABI validation (opaque pointer return, three pointer/integer GPR arguments), and returnNULL on the existing exception fallback. Never apply ownership to the opaque result. Host fixture deliberately returns a non-ObjC pointer and tests argument forwarding, null/exception paths and incompatible ABI rejection. A bounded successful non-null pass-through log supports runtime verification. All new display/rotation experiments are paused until this crash fix passes build, fixture and guest startup checks.
FoldGuard verification (76b6ff90): host pointer fixture passes7 checks; complete VPhone build/admission passes. Guest logs confirm actual ABI ^v36@0:8^v16I24^I28 and unmanaged-pointer guard installation. Initial boot PID37 and two controlled first-commit reconstructions (PIDs366,379) remain alive; no new SpringBoard IPS appears, newest remains the21:06:04 PAC report. Exception/NULL fallback is exercised on those launches. Non-null forwarding is covered by the host regression fixture; no non-null runtime log was observed in these three launches. This is bounded startup validation, not a claim that every future crash is excluded. Display/rotation work can resume.
Rotation transition isolated on crash-fixed76b6ff90: publishing the native 90-degree plan (SBS device1 + gravity0) and allowing the native transition to settle gives scene/interface4, activeInterfaceDegrees90 and host view rotation -90. A subsequent same display.rotation90 succeeds. Immediate forced AX rotation/early verification races this transition. Duo now waits up to2s for actual native interface receipt after synchronized pose/gravity instead of issuing a competing AssistiveTouch rotation. On failure owned physical state restores and native restoration is awaited; ordinary devices keep their AX path. Reply degrees/name use the verified logical receipt, while pose is read back independently. Runtime multi-angle/touch acceptance remains pending.
True landscape receipt (86eea0ac): rotation90 now succeeds with native physical pose1, gravity0, interface4/90 degrees and host view-90. Logical screen is951x669 while VZ graphics/raw capture stay2007x2853. The two raw fixed-panel injection paths agree at(665.6667,133.6667) in the landscape window for panel-normalized(0.2,0.3). This establishes true landscape entry and consistent raw paths; it is not yet full screenshot-coordinate acceptance.
A separate host contract bug is confirmed in source: the full screenshot
command saves vphoned's upright JPEG, but tap/swipe previously normalized its
pixel arguments against the unturned boot panel. The compact/raw VZ capture
used by display.geometry is different from that full screenshot. Add the
shared top-left screenshot->fixed-panel inverse to VPhoneDisplayOrientation
and use it for both windowed/headless tap/swipe; preserve one geometry for an
entire swipe. Prefer actual VZ panel dimensions and current guest orientation,
with the displayed view orientation only as the disconnected window fallback.
Geometry diagnostics now distinguish upright screenshot pixels/input angle.
Regression tests cover both native Duo panels through all four directions.
After native Duo pose settles, reassert only the same accepted AX angle to refresh Icli's cached screenshot geometry and pointer state. This uses the already-successful order from the isolation trial, rather than issuing AX while the pose is still transitioning. Rollback similarly reasserts only after native restoration settles. Runtime screenshot-coordinate matrix pending.
Input audit also finds that sendTouchEvent snapped every near-edge down to normalized0.001/0.999 when guest edge-touch support was present. A normal tap within the32-view-point edge zone could therefore move off its visible target. Preserve the actual normalized point; retain native edge flags. Deliberate trackpad home gestures already select an edge point in snappedScrollStart. Validate a near-edge logical point through all four orientations and both panels rather than accepting only center taps.
Extend the same original-Main native status lookup to the verified native cover bounds466x678 /1398x2034, using the native AxisAlignableSplitV68Cover profile's own notch metrics and requiring the original factory to select that class. Main/root/scene identity stays exact; no alias, second display, frame or input rewrite is introduced. This enables independent outer-panel acceptance with the same mechanism. Outer runtime provider/data/hit result is still pending; no claim is based on build alone.
db26a8cc live inner screenshot-input matrix (2026-10-26): exact bundle binding and update-environment status0 verified. First rotation90 attempt failed the two-second native settle deadline; no input matrix was accepted. Explicit cleanup restored baseline. A fresh controlled trial with three more seconds before the first rotation passed0/90/180/270. At each angle the upright screenshot pixel(60,1050) delivered Home-window point(20,350), identically for host tap and independent normalized fixed-panel injection. All16 Home touch begin/end records match exactly. SpringBoard PID363/start1793023069.980664 remained unchanged across the matrix; cleanup then reconstructed baseline. Evidence: Evidence/iphone_duo_inner_four_direction_touch_20261026.json. This validates one near-edge point on the native inner panel, not full-area coverage or outer-panel acceptance. The initial settle failure remains an open startup timing issue; adding a test delay is not a production fix. Native status and single-inner readiness remain controlled/default-off preparations.
Outer baseline db26a8cc: selected Closed via native Fold Posture UI and restarted only duotest-m5. CADisplay1398x2034, main/UIScreen466x678@3 verified. Native cover status lookup selected AxisAlignableSplitV68Cover, but screenshot had no visible status content and a side-positioned Dock; this is NOT UI acceptance. SB-main reports surfaceType0 rather than native cover1. First near-edge touch reached Home at(20,100); later queued taps did not yield enough observations to accept coverage. Next experiment should validate Main's cover role before changing component widths. Runtime switching is still unproven.
Cleanup: removed the retired CADisplay displayType/pointScale promotion implementation (owned virtual panel -> builtin), which previously triggered cloning assertions. Stale one-shot markers are consumed and logged without installing any hook. This does not remove UI-derived identity research or change the one-physical-Main constraint.
Next bounded cover experiment uses the already traced extendedProperties -> BKSDisplayTraits -> ShellSceneKit source path, with DeviceSurfaceType1 on primary only. surface.cover has the same30-second lease as inner; generation prevents an older lease timer undoing a newer test. Reconstructing SB samples metadata for this diagnostic only; it does not prove live panel switching.
c6f0cdbe cover-role result: exact install/binding/environment bundle verified. SB PID286 read surfaceType1 with466x678pt /1398x2034px, root/interface1. The screenshot still has the side Dock and no visible status module. A lower right host screenshot tap reaches(446,600) exactly, but is received by SBWindowSceneAuxiliaryContentWindow. Thus correcting cover metadata alone is insufficient for outer UI acceptance. Investigate native content-mode and auxiliary-window ownership next; do not replace this failure with width hacks. Evidence: Evidence/iphone_duo_cover_role_state_20261026.json and iphone_duo_cover_role_incomplete_20261026.png. No live switch proved.
Correction after pose + render-settle verification: the previous immediate screenshot was insufficient to declare cover status rendering broken. Re-publishing fullyClosed/portrait after SB reconstruction selects FullyClosed, Ready To Resolve YES, legacyMain cover applied/target primaryContent WITHOUT adding a cover readiness override. A fresh original Main status pulse followed by1 second settling shows native time and ring battery/data visibly. Evidence: iphone_duo_cover_status_settled_20261026.png; resolver detail /tmp/duo-cover-content-policy/runtime.log. Cover Dock remains on the side; there is no supplied true-cover reference establishing that layout as incorrect, so do not hardcode the inner Dock placement. Prior auxiliary-window interception is not invariant: closed-pose trial lower-right (446,600) reaches SBHomeScreenWindow/SBIconListView. Native status is still pulse-only and runtime panel switching remains unverified. Next production work must replay current fold pose after SB reconnection, wait for native render state, and preserve separate native inner/cover profiles.
Pose lifecycle implementation (runtime pending): GuestFoldPose now observes com.apple.springboard.finishedstartup and retries the latest actual pose at 0.5/1.5/3 seconds on a serial utility queue. Explicit posture/rotation requests invalidate pending generations before publication and record the final SBS readback afterward, including rollback on failure. Replay leaves hinge angle untouched, skips already matching state, and only runs with an enabled hinge. The existing boot ladder remains; this adds SB-relaunch handling rather than making the boot snapshot permanently authoritative. Full VPhone build passed; live closed/nonportrait replay and superseding-request acceptance still needed.
290da141 runtime pose replay accepted: installed/bound/environment version matched. Two separate SB-only reconstructions preserved exact SBS pose: closed + device1 and closed + device3; before/after orientation, hinge and tabletop match. Final explicit closed/device1 restored. Evidence: Evidence/iphone_duo_pose_replay_20261026.json. This proves lifecycle replay, not runtime logical panel switching; overlapping-request timing remains a separate validation case. No screen layout override was added.
Runtime viewport reconciliation: old vpUpdateRootSceneGeometry changed only sb_hostedDisplayConfiguration + frame, whereas the later verified native UI migration required both displayConfiguration and its hosted extension. Update the same-Main viewport path to validate both setters before mutation and publish the same configuration into both. Identity/render source remain unchanged. This closes a concrete state inconsistency; runtime switch and render/input geometry acceptance must still be re-run on native inner backing.
cf557df1 runtime preserved SB PID37 through669x951 ->466x678 ->669x951. Cover scene type31, but UIScreen/global type32 and Home window669x951; image clipped. IMPORTANT: paired configuration update did NOT execute: generated setDisplayConfiguration: encodes its object class name, so the initial exact encoding check rejected it. Corrected to the semantic NSMethodSignature validation already used by vpSetUISceneConfigurations. This run therefore cannot reject the paired-update hypothesis. Explicit inner restore completed.
0cd9e242 paired update actually executed: native settings transactions updated all3 root scenes; scene sizing notified18 windows, changed15. Main, UIScreen, Home-window/root bounds now roundtrip669x951 ->466x678 ->669x951, SB PID37 unchanged. Cover rendering still fails: widget top clipped, overlapping Dock, fixed physical2007x2853 backing has black unused area. Scene family31 changes correctly, while UIScreen/global families remain32. This confirms the previously identified per-screen associated-object/global dispatch_once cache boundary is now material for runtime switching. Do not overwrite cache slots or hardcode widget sizes; the next experiment should re-evaluate native screen classification from the active scene context and then verify native layout invalidation. Evidence: Evidence/iphone_duo_paired_viewport_20261026.json and paired_viewport_incomplete image. Explicit inner restore completed.
Native family cache experiment: consumed-once viewport-family marker installs export-resolved per-screen/current-device lookup adapters. Only during an explicit cover viewport lease, on the main thread, for exact SB-main/Main screen, re-query SBHScreenTypeForDeviceAndDisplayContexts using that scene's native device/display contexts. Other callers fall through; recursion guarded. No associated-object or dispatch_once cache writes and no component dimensions. Accept only native Duo31/32 results. Runtime call/visual acceptance pending.
0d172c0d family experiment: installed/bound/environment matched. Startup screen detour0(OK), global detour2(Unrelocatable) — refusal preserved, not bypassed. Same SB PID293 roundtrips. Cover families global32/screen31/scene31, restored32/32/32. Thus native scene-context reclassification successfully crosses the per-screen cache boundary, but global cache remains and visual clipping/Dock overlap persists. No full-family or layout acceptance. Evidence: Evidence/iphone_duo_viewport_family_20261026.json. Explicit inner restore done. Next analysis must inspect the native global lookup entry and/or layout provider lifecycle; do not guess a trampoline or overwrite dispatch_once data.
Global entry relocation reveal: symbol-backed disassembly of current SBHScreenTypeForCurrentDevice starts ADRP/LDR/CMN/B.NE; fourth instruction branches to its dispatch_once slow path, outside overwritten16 bytes. Extend MISFixDetour with ordinary B.cond relocation: invert condition over existing absolute x16 jump; preserve NZCV; reject conditions AL/NV, BC.cond and targets inside overwritten region. No offset-specific patch or dispatch_once write. ConditionalBranchRelocationTests.c checks all14 ordinary conditions with positive/negative targets and rejection cases; native arm64 executes both NE taken/fallthrough paths (22/11) successfully. Guest acceptance pending.
c75a6a9a guest result NOT accepted: exact deployment version matched; consumed viewport-family marker installed both detours (screen0/global0). Before entering cover transition, the first snapshot notification stalled, and subsequent process/file RPCs also stopped responding. No fresh SB IPS was present after recovery (latest remains210608). Stopped VM via Launchpad, waited for stopped state, restarted without consumed marker: vphoned answered in4s and file/device RPCs recovered. This is a hang, not established crash or proof of a specific relocation bug. Native host branch tests do not establish correctness of this complete guest call graph. Do not promote global adapter to default. Need blocked-thread stack/targeted recursion evidence before more changes; preserve /tmp/duo-full-family-recovery.log and failed trial outputs.
Hang follow-up: symbol-backed slow-path disassembly confirms global getter branches to a dispatch_once helper; it does not itself establish recursion. Add bounded raw append records around original trampoline and native-context query (first24 calls), with PID/depth/stack addresses, no Foundation formatting. This distinguishes original-getter blocking from context-query reentry without rewriting caches or suppressing initialization. Runtime evidence pending.
Diagnostic refinement: record only the compiler-provided immediate caller address instead of backtrace unwinding, avoiding loader/unwinder activity in the suspected once-initializer path. Phase/depth remain the primary evidence.
7afd766f trace + full-family runtime: first24 global calls all show enter-original -> returned-original32 -> returned-context32, depth1. Guest screen/file RPCs remain responsive. Then same PID293 performs full family roundtrip32/32/32 ->31/31/31 ->32/32/32 with native point bounds changing and restoring. Prior hang did not reproduce; cause remains unresolved (do not call it a proven relocation bug or a proven timing fix). Visual cover layout still clipped/overlapping despite all family queries agreeing. Family-only root-cause hypothesis is insufficient. Evidence: iphone_duo_all_family_roundtrip_20261026.
Next static candidate: SBIconController.listLayoutProvider is a native tail call to ambientListLayoutProvider; the latter is retained state with a setter. SBHDefaultIconListLayoutProvider exposes initWithScreenType and immutable screenType, and SBIconListView has setLayoutProvider. These are evidence for existing layout-object lifecycle investigation, not authority to force widget sizes, reset Home icon arrangement, or swap arbitrary providers yet.
Native layout lifecycle reveal: SBHIconManager retains _screenType and _listLayoutProvider. Its setScreenType:listLayoutProviderLayoutOptions:animated: checks changes, constructs the native provider with that family/options, calls setListLayoutProvider:animated:, then reaches rootFolderController/view/window/ windowScene and floatingDockViewControllerForWindowScene:. Use this existing system manager entry after the scoped native scene-resize propagation. Preserve layout options; use the scene's native31/32 result; verify method/ivar ABI and exact SB-main/Home controller. No resetHomeScreenLayout or per-icon dimensions. The same path restores the native inner provider on return. Runtime pending.
afa31362 native layout lifecycle runtime: first deployment was interrupted when Launchpad exited; process inventory confirmed no remaining CFW task and old environment version, then retry succeeded with exact bundle/status0. Controlled switch retained SB PID292. Manager log32->31 creates native D76 provider, return31->32 creates D77. All3 family queries match. Cover screenshot now shows complete widgets and reflowed icon grid without Dock overlap, unlike family-query-only experiments. This is concrete native layout progress. Remaining: physical2007x2853 screenshot contains active1398x2034 cover region and black unused area; status pulse not enabled; host viewport/capture/touch must follow logical extent before usability acceptance. Explicit inner return completed. Evidence: iphone_duo_native_layout_roundtrip_20261026.json and iphone_duo_native_cover_layout_viewport_20261026.png.
Geometry source correction: afa31362 cover device.screen still returned 669x951 despite live SB configuration/screen466x678. Do not base host viewport on this Icli cached extent. Extend existing fresh orientation-map handshake with canonical configurationBounds, coordinateBounds, scale, UIKit orientation and viewport generation in the same main-queue record; expose display.geometry as a read-only alias. Host physical framebuffer remains independently known. Consumer integration and live snapshot acceptance pending.
Host viewport coordinate implementation (runtime pending): add shared VPhoneDisplayViewport separating active canonical pixels from fixed panel. Fresh display.geometry provides active bounds/scale/UIKit orientation. Socket input converts upright cropped coordinates using active extent, then normalizes against full physical panel. Full screenshot validates geometry before/after, checks full upright image dimensions, and crops the rotated active rectangle; legacy full-panel path remains when fresh geometry is unavailable. Four-angle crop/input equivalence and invalid-bound tests passed (2 Swift Testing tests). Native GUI clipping, transition generation validation and live touch/capture acceptance remain outstanding; do not label windowed GUI accepted.
ae8d0e56 cropped automation runtime: install/admin authorization and exact binding/environment confirmed. First preparation screenshot refused unstable geometry; stable retry produced cover1398x2034 and restored2007x2853. Four host screenshot pixel taps(60,300),(1338,300),(60,1800),(1338,1800) arrive at UIKit points(20,100),(446,100),(20,600),(446,600). Baseline full-size old status intercepts all; with native Main status pulse, three hit Home and upper-right hits native status foreground. Cropped image has no unused black canvas. Status content not visible in captured image; native GUI crop and rotated viewport live acceptance remain pending. Inner return executed. Evidence: iphone_duo_cropped_viewport_touch_20261026.json / iphone_duo_cropped_cover_20261026.png.
Actual GUI touch failure reproduced: CUA clicked the visible Settings icon in the native VM window. UIKit received(388,703), correct for that icon, but SBStatusBarWindow/STUIStatusBarForegroundView still covered669x951 and consumed both phases. Thus prior pulse acceptance did not fix everyday operation. Promote the verified original-Main native status profile to persistent startup on exact hw.machine=iPhone19,4 only (manifest-gated library). Register finished- startup plus bounded startup retries; keep existing single-Main/native-size checks. Reconstruct status natively after viewport scene sizing. Legacy restore notification refreshes the persistent profile instead of restoring the broken fallback; other models retain original behavior. Runtime GUI acceptance pending.
2f446502 persistent GUI acceptance: exact bundle/environment deployed. Initial update refused stale Launchpad disk holder; lsof no longer showed that holder and retry succeeded. Without any main_status pulse, startup selected persistent native inner provider and top-right time/ring displayed. CUA clicked Settings in the actual VM window: point(387.6667,702.3333) hit SBHomeScreenWindow/SBIconView instead of the former full-screen status view. RunningBoard verified com.apple.Preferences PID323 foreground; visible Settings rendered. A second actual-window click opened About. Evidence: iphone_duo_persistent_status_gui_touch_20261026.json and settings screenshot. This accepts default inner portrait GUI interception fix, not all touch modes or outer/rotation/no-restart panel switching. Current guest remains usable in Settings; no temporary status lease was needed.
Native GUI viewport integration (pending runtime): container now rotates an active clipped viewport while retaining the full physical VM view at the same scale/top-left anchor. Mouse AppKit conversion therefore still reaches physical-panel coordinates. View orientation follows the enclosing clip turn; pointer normalization accounts for active/panel ratio. Foldable window polls fresh geometry, accepts exact native inner/cover extents within its backing, and adjusts aspect ratio. Other guests retain full-panel extent. Native GUI click/rotation/edge gestures still require live validation; automation-only acceptance is not substituted.
22357146 native GUI cover acceptance: deployed exact version. During the same SB process's controlled cover viewport the actual VM window crops black unused backing and shows native cover Home with ring/time. CUA Settings click hits Home SBIconView at(233,578); RunningBoard verifies Preferences PID343. Settings fills active cover, and actual-window General-row click navigates. Timed inner restoration exposed an additional gap: SB/window regained669x951 but already-open Settings retained cover-sized scene, leaving black area. Re-entering cover fills it again. Need foreground application scene transition propagation, not per-app hardcoded sizing. Explicit inner+Home restored. Evidence: iphone_duo_native_cover_window_touch_20261026.json/settings image.
Application-scene propagation investigation: root SB scene frame update does not resize existing foreground app scene. SBMainWorkspace exposes native createRequestWithOptions:displayConfiguration: and requestTransitionWithOptions: displayConfiguration:builder:. Request carries applicationContext/from/to entities and layout coordinator; SBWorkspaceApplicationSceneTransitionContext supports waitsForSceneUpdates and requested layout attributes. Add a separate workspace.preview notification that ONLY creates the native default request and logs current/request context/entities. It never finalizes/submits/runs a transaction. Verify preservation of foreground state before a mutation trial.
b26933d4 preview runtime: native request factory succeeds for Main669x951, but a newly created/unfinalized request has empty application context/from/to entities even with verified Preferences PID310 foreground. No transaction submitted. Static method review found the diagnostic read current layout from the wrong object: SBLayoutStateTransitionCoordinator has no layoutState getter; SBSceneLayoutStateProvider owns layoutState/previousLayoutState. Correct preview to scene.layoutStateProvider.layoutState and include object classes/scene ID. The earlier null does NOT prove absence of current native application layout.
c1ff6b18 current-state readback succeeded: SB-main layoutStateProvider holds one primary element sceneID:com.apple.Preferences-default, full space, maximized/system-managed sizing. Native SBLayoutElement exposes workspaceEntity and layoutRole. Extend unsubmitted preview to copy that same single entity/ role into previous+target context, then invoke native local request finalize. Static base finalize binds context.setRequest and context.finalize; no workspace submission. Read from/to/layout after finalization before any transaction trial. No app frame write, no app reset/relaunch.
265d61f3 finalized preview preserved primary sceneID:com.apple.Preferences- default in both from/to and full-screen layout, but native default attributes lost prior contentOrientation/interaction metadata. Preserve existing native layoutAttributesForElement via setRequestedLayoutAttributes:forEntity before finalization. Add separate controlled workspace.refresh submission: require one application entity, primary role, identical from/to uniqueIdentifier, unchanged current layout object/display configuration, and prior viewport request generation. Call native executeTransitionRequest only after guards. No per-app code/frame, relaunch or default-on submission. Runtime pending.
a81fcd09 native app transaction runtime accepted: exact installed/bound/ environment version. Both cover and return workspace.refresh submitted=1 for same sceneID:com.apple.Preferences-default. Settings PID317 stays foreground through all3 stages; SB geometry PID stays constant. Cover screenshot shows narrow navigation layout; inner return shows full dual-column Settings without prior black app region. Native layout attributes preserved. Evidence: iphone_duo_app_scene_roundtrip_20261027.json + cover/inner_return PNGs. This proves one same-app controlled roundtrip, not persistence/rotation/multi- app acceptance. Next integrate guarded refresh into viewport completion and expose persistent fold switching; retain one physical Main.
Persistent fold integration (runtime pending): exact Duo now installs the verified family adapters by default with its native status profile. New apply. cover/inner notifications enable persistent viewport (no45-second rollback), coherent scene sizing, native icon-manager family change and guarded native foreground-app refresh after scene propagation. display.posture posts desired viewport and waits up to3s for fresh matching canonical bounds + persistent flag. Startup/relaunch pose restoration republishes viewport. Host always boots one2007x2853 Main, including closed boots; active cover remains1398x2034 crop. Disable old fold blackout chrome on Duo and update Fold Posture explanation. No second physical display, no runtime VZ resize. Native GUI/persistence/rotation acceptance remains required; successful build is not goal completion.
e4c3c421 formal control acceptance: exact install/binding/environment. Used native Fold Posture GUI Closed -> Open -> Book -> Open, no once markers or diagnostic notifications. Closed466x678 remained persistent at least53.56s after initial read (beyond old45s lease). Open/Book669x951. SB PID37 unchanged; Settings PID306 unchanged and visually narrow cover/full dual-column inner. Native icon provider and preserved-entity app transactions ran automatically. Final control restored Open. Evidence: iphone_duo_persistent_fold_controls_ 20261027.json plus persistent_cover/inner PNGs. Goal not yet complete: rotation, repeat-cycle/relaunch/cold-closed restoration and retired-path cleanup remain.
Production rotation audit: e4c3c421 display.rotation90 without experimental flags failed native settle deadline. Current logical inner669x951 scene still reports default surface0; prior rotation success depended on inner role2. Add consumed-once logical-role experiment at SBWindowScene.surfaceType (native q16 ABI), deriving2/1 only for exact SB-main/Main and canonical669x951/466x678. Keep original hardware source/role/count and resolver untouched; reentry guard, original fallback elsewhere. This tests logical scene capability selection, not a forced frame/orientation. Persistent enablement, startup pose mapping and cross-panel orientation preservation require validation before promotion.
604ff90e logical-role trial: one-shot SB-main role returns2 and native inverse mapping changes to[3,4,2,1]; Home/status masks become30. BBD rotation events enabled, primary supports rotation. display.rotation90 still times out. After publishing device1/gravity0 and native inputs.refresh, fresh scene reads interface4 but coordinate bounds remain669x951; legacy global orientation wait still refuses. Captured image is physical portrait with rotated Home, upright status and missing wallpaper. Thus scene-role adaptation alone creates mixed orientation domains and is NOT accepted. Restore open/device1/gravity0, recreate SB without consumed marker; default source0 retained. Need coherent root/legacy orientation, scene coordinate and wallpaper update, not forced enum mappings.
Rotation follow-up cross-check: icli_interface_degrees reads legacy SpringBoard orientation, separate from the newly exported scene interfaceOrientation. Historical native per-scene status policy(896e6d1e) followed Home orientation correctly but used wrong provider/data configuration. Current persistent Main native Axis profile fixes that prior status profile boundary. Retarget only that existing one-shot scene-status scope from obsolete678x951 to accepted 669x951, for a combined logical-role + native per-scene-status trial. It remains default-off. Wallpaper/root settings/capture orientation must all be checked; do not equate one corrected direction getter with successful rotation.
397e50d9 combined trial: per-scene status window and Home both rotate4 at 951x669; visible window still lacks time/wallpaper. GUI Settings click correctly maps to landscape(666.67,573) but hits auxiliary-content window. Read-only resolver shows decisive mismatch: FoldingDual prevailing, readyYES, sole legacyMain proxy surface.default with applied/target unknown; primary/active scenes nil. This contradicts scene.surface.inner. Restored default via SB-only restart after device1/gravity0. Do not promote this combination as working.
Next one-shot logical-role experiment synchronizes that existing sole legacyMain proxy using its native setSurfaceType: and resolver update, with single-source/Main/nonexternal/native-size/ABI guards. Reuses existing native single-panel readiness boundary, no extra source. Synchronize again after viewport scene sizing. This tests consistent logical role across scene and content resolver instead of bypassing auxiliary-window hit testing. Pending.
985fa8f7 logical-role/resolver trial: sole legacyMain proxy becomes.inner, FoldingDual resolves applied/target primaryContent and primaryWindowScene is SB-main. After native device1/gravity0+inputs refresh, scene4 and UIScreen951x669 read back. Actual GUI landscape Settings icon click opened verified Preferences PID380; trace expired before click (recorded0), so no new point-level hit claim. Formal display.rotation90 then succeeds with mapped physical1/gravity0. Native landscape Settings visibly renders. Time absent and Home wallpaper still black; combination remains default-off. Capture saved; cleanup open/device1/gravity0 and unarmed SB-only reconstruction restores production inner. Need status data subscription and wallpaper lifecycle, not input coordinate hacks.
Native presentation investigation: per-scene STUI wrapper can differ from UIApplication.statusBarForEmbeddedDisplay; UIStatusBar_Base exposes native inProcessStateProvider/server and SBMainStatusBarStateProvider has time/all-but- battery cloak states. Wallpaper controller exposes active sources/required/ traits-active state and native window/presenter/container. Add read-only presentation.inspect comparing both bars and wallpaper state. No cloak writes, provider replacement, fabricated status values, or wallpaper drawing. Runtime comparison of production versus role/scene experiment pending.
7e87cc74 comparison: production scene/embedded bars are the same wrapper/server, with nil inProcessStateProvider. In role+scene experiment they are distinct wrappers and distinct UIStatusBarServer clients; provider remains nil, updates enabled. Wallpaper window/container remain669x951 while scene rotates; both normal/experimental required/traits-active flags are0, so blanket visibility forcing is unsupported. Active source changes LockScreen+Home to Home only. Restored unarmed production after reading. Add eight-second default-off native server/client binding test: redirect existing embedded Main server to visible scene bar, temporarily detach its other server, force native data update, then restore exact owned bindings. No data fabrication/cloak writes or new server. Runtime acceptance pending.
f0609c2b data-server lease trial did NOT apply: no lease-entry log, unchanged scene/embedded server pointers in before/applied/restored snapshots, time absent. Cannot reject data-binding hypothesis from a refused probe. Add explicit scope/ client-binding/ABI rejection diagnostics and semantic object-argument signature checks (generated setters may include class spelling). Restore now logs actual readback verification rather than unconditional restoration wording. Trial cleanup returned unarmed production; no data lease persisted.
c2f9d947 guard result: lease refused because embedded Main server client is nil, while scene server client matches its visible bar. No binding changed. Native UIStatusBarServer has only client/source fields; setStatusBar tears down its existing source before assignment. Do not weaken guard and describe the inactive object as a known-good active Main data channel. Remove this unexecuted server-reuse pulse/notification and add read-only homeItemsDisabled, registered, foreground/hidden and server-client comparison to presentation.inspect. Current trial automatically restored unarmed production. Time issue remains unresolved.
4ce7a072 registration comparison: visible per-scene bar registeredYES, foregroundYES, hiddenNO, serverUpdatesDisabledNO, homeItemsDisabledNO. Embedded old bar hiddenYES/registeredNO/clientnil. Thus home-items-disabled hypothesis not supported. Add scoped native registration refresh (disable server updates then restoreNO in finally, forceUpdateDataYES) and read-only renderer data/ part-list descriptions. No data editing, old-server reuse or cloak changes. Current comparison trial ended with unarmed production restored. Pending live.
419bdab4 reregister actually ran, but before/applied/settled timeEntry stays NO/nil in both visible scene and inactive embedded renderer; part lists nil. This is upstream data absence, not rendered text clipping or registration flag. Add read-only actual app.mainStatusBarStateProvider cloak flags and native statusBarStateAggregator date-provider/token/formatter/time-string state. Separate default-off clock.refresh calls the native _restartTimeItemTimer only; it does not write time values, enable entries, or clear cloak policy. Runtime before/after acceptance pending. Comparison cleanup restored normal production.
750ccd86 native clock restart did not repair time: experiment source string empty, all cloak flags false, formatters present; normal unarmed comparison source string1:40AM. Thus not an intentional cloak and not fixed by subscription restart. Add native date/formatter format/style/locale diagnostics. Narrow the existing per-scene constructor experiment's temporary Main override to one query, restoring native predicate for subsequent nested construction queries; log caller address. This tests accidental initialization scope without writing time values. Still default-off; runtime behavior pending.
d8df21eb single-query scope did not restore time. Normal and experimental native dates are valid, locale=en_US, formats h:mm a/h:mm and styles match; normal cache contains1:46AM while experimental cache empty. Add native formatter output observation and a first20-call pass-through observer around aggregator _updateTimeItemsWithDate: (only logical-role experiment), logging input date and before/after cached string. No argument/result mutation. This distinguishes empty formatter output from later lifecycle overwrite; runtime pending.
42a22028 trace: formatter direct output1:51AM is valid, while first20 native _updateTimeItemsWithDate callbacks print empty input/before/after at same instant, caller223326864 (the native minute-provider callback). Static callback retains its x1 argument and passes it as date; SBFOverridableDateProvider owns dictionary of minute handlers and _fireHandlersForDate. Add actual input/current-getter object class and NSDate-kind checks; do not substitute a date or patch callback ABI before confirming the invalid argument. Trial cleanup restored default.
457e820d callback type evidence: empty-rendering input is __NSDate/isDateYES, while provider-current getter is valid __NSTaggedDate. This is not an object ABI/type mismatch. Add exact reference-date interval as a string (preserve nonfinite evidence without invalid JSON), underlying provider class and getter date. Do not replace callback argument until numeric invalidity is established. Trial cleanup completed, production default restored.
f7d145ca root-cause reveal: callback NSDate interval=+inf; provider and underlying SBFMachNextMinuteDateProvider.date getters return valid current dates. Symbol-backed native scheduler1baea2614 loads referenceView.window.windowScene. screen.maximumFramesPerSecond and computes1/FPS when referenceView exists; only nil reference takes zero frame adjustment. Detached live reference yields nil screen/0 FPS -> infinity -> invalid callback date and rapid timer repeats. Add one-shot role-experiment wrappers for native minute registration/tick: if reference exists but cadence unavailable/nonpositive, temporarily clear its runtime-located weak slot, invoke untouched native operation, restore weak slot in finally if still nil. This selects the native nil-reference path, not an invented date/frame rate. Valid attached references pass through. Runtime pending.
4e40b3b6 clock reference runtime: initial deployment unexpectedly reproduced old f7d145ca hash; source/build audit found new functions unreferenced because constructor insertion had not landed. Connected installation explicitly; unused warnings gone, dylib SHA changed tode3bc9a8031d57ff59d417c3f065f7772ea81b05ad431855d93967e136f1735b. Corrected exact bundle deployed. Runtime logs confirm adapter installation and STUIStatusBar_Wrapper reference with nil screen/FPS0. Subsequent date callbacks finite, native source string2:11AM, then minute update timeEntry enabledYES2:12AM and visible time above native ring. No fabricated date or FPS. Early snapshots had no renderer data until minute post; initial delivery remains unresolved. Trial cleanup unarmed. Evidence: iphone_duo_clock_reference_fix_20261027.json and iphone_duo_scene_clock_recovered_20261027.png.
Presentation lifecycle follow-up: wrapper.forceUpdate:YES only invokes its _updateWithData:nil force:YES; forceUpdateData:YES separately reads native server data and converts it. After renderer reconstruction request native data before layout refresh; existing bounded startup retries also retry when renderer currentData remains nil. No field synthesis. Add separate eight-second wallpaper orientation probe using existing controller willAnimateRotationToInterface- Orientation:duration:force: with actual scene direction, preserving original controller direction and restoring through same API if scope/value still owned. No wallpaper image or individual layer/frame mutation. Runtime pending.
cf3c0467 wallpaper pulse was NOT landscape acceptance: scene getter1 disagreed with rotated Home; pulse changed wallpaper4->1 and made portrait background visible under sideways Home. Separate root synchronization achieved root/scene/ Home/status4 but wallpaper remained black. Default restored afterward.
Native reveal: SBWallpaperController._updateWallpaperContainerViewBoundsFor- Orientation first checks traits-active, then PBUIPosterWallpaperRemoteView- Controller.supportsLandscapeWallpaperContents; that global method soft-links PF_IS_PAD_DEVICE. A native per-display variant exists. Added one-shot scoped adapter only around Main-inner wallpaper container update: ask native supports- LandscapeWallpaperContentsForDisplayConfiguration with unchanged current Main configuration instead of global idiom query. No forcedYES or iPad impersonation. Wallpaper pulse now uses arbiter output, not stale scene getter. Runtime pending.
69f677e2 per-display wallpaper capability actual result global0/perDisplay0; confirmed scene4, controller4->4 pulse does not restore wallpaper. Removed the failed capability-query adapter. Further native reveal: _isTraitsArbiterWallpaper- Active gates on SpringBoard/TraitsArbiterWallpaperTokyoDrift, then idiom/Maglev; current guest SpringBoard feature-domain plist lacks this flag (TraitsArbiter and TraitsArbiterActuation present). Add separate consumed-once exact-feature lookup override for SB process only using existing checked detour; all other flags/native behavior unchanged. This selects native pipeline for validation, not a hardcoded wallpaper transform. No disk feature flag edits/default enable. Runtime pending; prior experiment cleanup restored default.
7eda0424 native wallpaper traits flag test installed successfully(result0), _isTraitsArbiterWallpaperActive=1, scene4, yet wallpaper remained black. A second bounded trial also supplied real backboardd Main DeviceSurfaceType2; no visual improvement. Waited beyond metadata lease before cleanup. Thus flag-only and hardware-role-only explanations are insufficient. Add read-only wallpaper window sb_traitsParticipant, delegate role-management decision, and loaded presenter/root view geometry/visibility to locate missing native actuation. No participant or remote view mutation. Default production restored.
dc7604a1 live participant evidence: traits-activeYES and delegate manages wallpaperYES, but wallpaper sb_traitsParticipant=nil. Presenter/root views are visible alpha1 at669x951; no zero-size/hidden-view explanation. Native windowSceneDidConnect constructs _SBWallpaperSecureWindow with scene, but sets SBTraitsParticipantRoleWallpaper through _setRoleHint only for idiom1/5 with Maglev enabled. Add default-off exact-window pre-visibility role-hint assignment under wallpaper traits + logical-role experiments, preserving existing hints. Use subclass-only setHidden override and always forward original visibility; no forced visibility/geometry or role on other windows. Native scene machinery must establish participant and layout. Runtime pending.
Active viewport touch mapping (2026-10-27)
The host display view keeps the physical Main framebuffer fixed while clipping the current Duo viewport. Input previously normalized against the full backing panel, so outer-screen taps could target hidden framebuffer coordinates. The VM view now derives a bottom-aligned active viewport from activePanelPixels / screenScale; mouse, trackpad, synthetic touch, and edge detection share that rectangle. Bundle 2.12.1-local.3803cc4d was deployed to duotest-m5; real GUI clicks on the outer Settings screen and inner screen were accepted after switching without a restart. Focused DisplayViewportTests passed (2/2). Landscape rotation still requires broader edge and wallpaper regression before this issue is considered closed.
No-restart native viewport roundtrip (2026-10-27)
On bundle 2.12.1-local.3803cc4d, display.posture switched open → closed → open while SpringBoard PID stayed 37. Fresh display.geometry readback reported 669×951pt inner, 466×678pt outer, then 669×951pt inner, all at scale 3 with the same SB-main scene and no restart. Evidence: Evidence/iphone_duo_no_restart_viewport_roundtrip_20261027.json.
Default landscape scene policy integration (2026-10-27)
The exact Duo path now enables the previously validated logical-role and per-scene status policies without once flags. These adapters preserve the single physical Main and only alter SpringBoard's native role/scene arbitration. Runtime validation is required for landscape orientation, status bar, wallpaper, and touch before treating the integration as accepted.
Rotation arbitration refresh (2026-10-27)
The Duo inverse orientation map correctly resolves landscape-left to the inner panel's physical mounting, but publishing pose alone left SpringBoard at portrait. The existing native mapping/input/arbitration/actuation refresh notifications were therefore added to display.rotation after pose publication. A live notification sequence produced display.geometry.bounds = 951×669 and display.orientation = 90 with SpringBoard still running; formal daemon build/deploy and full touch/wallpaper/status regression remain required.
Landscape wallpaper lease (2026-10-27)
Live diagnostics showed the wallpaper orientation pulse succeeded and then restored portrait after eight seconds, which recreated the black landscape band. The delayed restore is removed from the normal Duo path so the wallpaper remains on SpringBoard's current native orientation. Final screenshot and touch regression are required after deployment.
Cover portrait, inner landscape posture (2026-10-27)
The Duo posture rule now matches the physical product: closed uses the 466×678pt cover in portrait; opening uses the 669×951pt inner configuration rendered as 951×669pt landscape (interfaceOrientation=4, display.orientation=90). The transition is runtime-only and keeps SpringBoard running. Evidence: Evidence/iphone_duo_cover_portrait_inner_landscape_20261027.json.
Duo titlebar Fold Posture control (2026-10-27)
A Duo-only circular titlebar button now sits beside Home and cycles Closed, Book, and Open through the same display.posture RPC as the Motion Sensors › Fold Posture panel. Live CUA verification showed the button's accessibility value changing to Book and Open; fresh guest geometry remained the inner landscape viewport (951×669 bounds, 669×951 configuration) and the real posture reply/hinge state changed accordingly.
Lock-screen action glyph sizing (2026-10-27)
The oversized flashlight/camera glyphs share UICoverSheetButton. A system-level layoutSubviews adapter now scales only its child UIImageView glyphs to 0.82, leaving the lock-screen button circles and actions intact. Runtime installation was confirmed in vphone-folddisplay.log; a native screenshot request was made while the Mac host was locked, so visual pixel comparison still needs one unlocked-host capture.
Host rotation state correction (2026-10-27)
The abnormal screenshot was caused by the host trusting stale display.geometry.interfaceOrientation=1 while SpringBoard reported display.orientation=90. The host now reads the orientation endpoint first and uses fresh geometry only for viewport size, so window rotation, touch mapping, and notification/status presentation share the same landscape direction.
Host landscape rotation verification (2026-10-27)
After a clean signed rebuild (2.12.1-local.dc6752cc) and reopening duotest-m5, live landscape readback was 90° with active bounds 951×669pt. The host window rotated horizontally, notifications became horizontal cards, the status battery/cellular region moved to the landscape right side, and real GUI taps hit flashlight and camera. Evidence: Evidence/iphone_duo_host_landscape_rotation_20261027.json. Wallpaper crop/black band remains separate.
Glyph sizing follow-up (2026-10-27)
The first CoverSheet glyph transform was not accepted as evidence because the actual UICoverSheetButton layout callback did not yield a reliable glyph tree. Its guessed scale is removed from source; the next correction must use native Duo layout metrics rather than a hand-selected factor.
FoldDisplay source maintenance split (2026-10-27)
The large guest hook source is now organized into include-only fragments: StatusProfile.inc owns status-bar provider/profile code and WallpaperTraitsExperiment.inc owns wallpaper traits code. The main file retains shared runtime state and constructor wiring. This is a source-only maintenance change; bundle admission and full build passed.
CoverSheet native layout metrics (2026-10-27)
Static UIKit metadata shows UICoverSheetButton stores the glyph in _contentView, with _image, _selectedImage, imageContentMode, and edgeInsets controlling layout. A runtime hierarchy inventory while the guest was locked found zero visible instances through UIApplication.windows, so no arbitrary scale was deployed. Further work must capture the actual CoverSheet window/subtree or construction path before changing icon size.
Real lock-screen quick-action layout (2026-10-27)
Runtime capture identified the actual Duo lock-screen path: CSQuickActionImageGlyphView inside CSQuickActionsButton. Native values were recorded from the initializer and final layout: button diameter 58pt, camera symbol scale 0.31648, flashlight symbol scale 0.368, 84pt button frame and 48pt icon image frame. Temporary diagnostics were removed after evidence collection; no arbitrary icon scaling is deployed.
Wallpaper presenter frame correction (2026-10-27)
The landscape black band was caused by the poster presenter view retaining portrait dimensions. The wallpaper pulse now synchronizes the presenter view, container, secure window, and root view to 951×669pt and requests layout. Live presentation readback shows all four frames at 951×669 with identity transforms; the reasserted landscape screenshot fills the viewport. Evidence: Evidence/iphone_duo_wallpaper_presenter_landscape_20261027.json.
QuickAction profile resolution (2026-10-27)
Runtime tracing reached the real SpringBoard QuickAction path. CSQuickActionsButton resolves buttonDiameter=58, iconContentScale=1, iconImageSize=48; CSQuickActionImageGlyphView receives camera symbolScaleValue=0.31648 and flashlight 0.368. These are native profile outputs. Temporary probes were removed after capture; the remaining icon discrepancy is now narrowed to the profile/trait selection path.
QuickAction profile investigation continued (2026-10-27)
The actual lock-screen path is fully identified, but no standalone QuickActions profile resource was found in the simulator runtime. Native values remain buttonDiameter=58, camera scale 0.31648, flashlight scale 0.368, and button icon frame 48pt. Further work must identify the profile/trait selector before changing these values; arbitrary transforms remain disabled.
Profile selection decoded; button metrics are size-driven (2026-10-27, rounds 4–9)
Nine probe rounds over libvphonefolddisplay.dylib (bundle builds
2.12.1-local.fb48d760…98adc757) decoded the whole selection chain. The
round-5 blind ivar crawl crashed SpringBoard on an unsafe-unretained
ivar; it was replaced by constructor-argument swizzling, which is safe and
reusable.
Selection chain, from evidence. SBSDeviceDeterminedConfiguration is
constructed by SpringBoardUIServices+0x8D444 (runtime 0x1a7b67444,
fbase 0x1a7ada000 = the unlinked base, slide 0). Disassembly of that
callsite shows the distinguishers are assembled there: base bits from three
boolean helpers (0x4, 0x40), enum==1 → |0x80, enum==2 → |0x100.
Live constructions log base=0 size={669,951} idiom=0 home=2 subtype=2034 distinguishers=0x104 (first, nativeBounds {2007,2853}) then a
family of 0x4 variants with nativeBounds {669,951}. Every instance
resolves effectiveDeviceClass = 25, pushed into
CSProminentLayoutController setEffectiveDeviceClass: from
CoverSheetKit+0x23F08 (0x1affb0f08). The pure size classifier on the
live instance: {669,951}→25, {951,669}→25, {466,678}→25, {428,926}→19;
_classForBase:zoom: is the identity at zoom 0. So the effective class is
screen-size-driven, not identity-driven.
Forcing class 19 does not move the buttons. A file override
(/var/mobile/Library/Caches/vphone-duo-layoutclass.override, numeric)
rewrites the value in setEffectiveDeviceClass:. With 19 forced, buttons
stay 84×84pt with iconImageSize {48,48} at {18,18}: the button metrics
come from CSProminentButtonsView._deviceSize {669,951} →
_buttonLayoutToUse, not from the layout controller's class. The
controller class feeds the clock/date layout instead.
Ground truth from the Apple Duo simulator (B738069B, iOS 27.1): its
framebuffer is 2007×2853 = 669×951pt @3 — the same logical size as the
vphone guest panel. At the same size the native Duo resolves the same
size-class row, and the consistent-state landscape lock renders the
flashlight glyph at ≈12×31pt inside the 84pt circle (37% fill) — visually
proportionate. The earlier "icons too big" reports came from inconsistent
orientation states (cover sheet composed for the other orientation,
_actionsVisible = NO, buttons placed at x=861 in a 669pt-wide portrait
scene — off-screen). In the pose-native landscape lock the buttons render
at (861,86)/(861,154) and are visible in pixel profiles (max 255 inside
the button boxes). The simulator cannot render headless: without
Simulator.app installed (this Mac has only Xcode CLI), simctl io screenshot captures a black framebuffer; sim ground-truth needs the GUI
app or a different reference.
Wallpaper state. The lit landscape lock screenshot has zero
pure-black columns (wallpaper fills; the earlier right-side darkness is
the image itself). The portrait retainers are the live poster scene hosts
(_UIScenePresentationView ×2, _UISceneLayerHostContainerView,
_UITouchPassthroughView, all {669,951}) under
PBUIPosterSceneLayerHostView/PBUIPosterFloatingView; resizing them
(scenehost.sync, 8 nodes) changed no pixels — the visible layer is the
PBUISnapshotReplicaView path. contentsRect/contentsCenter are now
dumped for every node and are full-rect everywhere. The home-screen black
band (cols 0–243pt) is still unowned; the wallpaper-window deep dump
(windows.inspect now walks _SBWallpaperSecureWindow too) is captured
in /tmp/duo-wallpaper/round5_windows.log for the next correlation. A
portrait SBSearchBackdropView {669,951} sits inside the home window
tree at depth 8.
Probe inventory now in the dylib (all passive unless a notification or
override file is used): constructor-arg swizzle with raw-pointer callsite
logging; setEffectiveDeviceClass: logging + numeric override;
contentsRect/contentsCenter in tree dumps; wallpaper-window deep dump;
class-surface/ivar/mapping probes on a stashed config instance
(effectiveDeviceClassForScreenSize, _classForBase:zoom: 0–20).
Local artifacts: /tmp/duo-wallpaper/round{4..9}_*.log,
r6_lock2.bmp, r9.bmp.
Open decisions. (1) QuickAction: consistent-state metrics are the same
row a real Duo resolves at this panel size; accept them or supply a native
reference screenshot to compare against. (2) The home black band owner.
(3) Live-instance forensics beyond this needs frida in the guest, which
needs a process.spawn-style vphoned RPC or bootstrap ssh first — no
arbitrary process launch exists today (exec runs the host-side CLI).
Round-12 destabilization and rollback (2026-10-27 evening)
The visual-scale experiment (swizzling the glyph constructor arguments and
CSQuickActionsButton iconImageSize, factor from
/var/mobile/Library/Caches/vphone-duo-buttonscale.override) crashed
SpringBoard into a launchd-throttled loop and drove the whole userspace
into jetsam memory pressure (every daemon past its limit, stacks+
reports every 10 s, no SpringBoard in the process list — the user saw a
recovery-looking screen). Recovery, verified working: delete the override
files, roll the machine back to the last stable probe bundle
(2.12.1-local.98adc757) with vm set-bundle --update-environment, then
a VM-level restart (guest system.reboot is not enough: launchd keeps
the old SystemHook mapped when the deployed copy changed). After restart
SpringBoard runs (283 MB), the lock screen lights, and SB-side logging
resumes (delete the stale log first if another process recreated it
root-owned). Lessons: never hook iconImageSize-style hot getters with
per-call file reads; a SpringBoard crash loop starves the entire guest,
not just the shell; the rollback recipe above recovers in about five
minutes. Button-size conclusion unchanged: the metric row ignores the
determined device class entirely; before any further size work, decode the
native row statically (CoverSheetKit _layoutButtons) or take a native
reference screenshot instead of guessing factors.
Glyph hook root-caused; zero-swizzle scaling live (2026-10-27 night, rounds 14–15)
The second boot failure (round 13, factor file absent, hooks passthrough)
disproved the "scaled metrics crash" theory. The discriminating round 14
(the same build with the glyph hook installation commented out) boots
SpringBoard normally — installing the hook on
CSQuickActionImageGlyphView is itself the boot killer: that class's
method list sits on a read-only page in its image (PKUI family), so
method_setImplementation faults at SpringBoard load and the process dies
before the UI exists; the guest then presents as the recovery userspace
(no SpringBoard process, "Device Recovery" running, per-daemon
jetsam-limit telemetry — that signature, identical across both failures,
is the recovery environment's normal shape, not a leak). Hooks on
SpringBoardUIServices / CoverSheetKit classes remain safe; per-image
writability decides. Rule going forward: never swizzle classes outside
the proven-writable images.
Round 15 replaces the hook with a zero-swizzle live transform:
notification com.vphone.display.quickaction.glyphscale reads the factor
from /var/mobile/Library/Caches/vphone-duo-buttonscale.override
(clamped 0.5–1.0) and sets transform on every live
CSQuickActionImageGlyphView (bundle 2.12.1-local.1e3bf1d8, boots
clean). At 0.68 the flashlight glyph view renders ≈13.8×24.9pt (was
20.33×36.67). Iteration protocol with no reboot: write the factor file,
post the notification; a respring/relock rebuilds glyphs at 1.0 until the
notification fires again. The user's native-reference screenshot
(2853×2007 — same logical panel) defeated both vision and geometric
measurement (textured wallpaper); its single usable vision read put the
native circle in the 91–120px bucket, directionally confirming 84pt/252px
is oversized.
Landscape wallpaper gate detour: second band eliminated (2026-10-27 night, round 16)
The pending dc7604a1 experiment ran first (wallpaper role hint on
_SBWallpaperSecureWindow via the traits once flag): installed cleanly and
the role was assigned, but the home black bands were unchanged — role alone
remains insufficient, consistent with the earlier flag-only result. The
portrait home screen was then measured full-bleed (zero black),
proving the wallpaper content itself is fine and the defect is purely the
landscape composition.
New lever: a caller-scoped PF_IS_PAD_DEVICE detour (MISFixDetour on
the soft-linked C symbol). The replacement answers YES only when
__builtin_return_address(0) falls inside the implementations of
supportsLandscapeWallpaperContents /
supportsLandscapeWallpaperContentsForDisplayConfiguration: (IMP ranges
captured at install, +0x1000 window); every other caller gets the original
answer, so no pad behavior leaks system-wide. Installed beside the traits
experiment under the same vphone-duo-wallpaper-traits.once flag
(vpInstallLandscapeWallpaperGateDetour in WallpaperTraitsExperiment.inc,
bundle 2.12.1-local.6648b598, ranges=2 detour=0).
Measured effect on the landscape home screen: the 226–242pt pure-black
band is gone and wallpaper coverage moved from x≈244pt to x=72pt
(content now spans 72–950pt, 879pt wide); the boundary at 216px is a
razor-sharp 0→255 step (a clip edge, not image content). Lock-screen
landscape re-verified full-bleed (0 black columns) — no regression. The
manual wallpaper pulse does not remove the remaining 72pt band, and no
view in any UIApplication.windows tree carries a 72/879 geometry — the
clip lives inside the wallpaper engine's remote CAContext content. The
root server scenes still report a portrait FBScene (frame 669×951,
interfaceOrientation=1, display mode "669x951@3x") while UIKit renders
951×669, so the engine still composes against a portrait surface; the
72pt inset is the residual of that mismatch. Home-screen style is
_styleType: color (gradient accent) with the image wallpaper visible —
variant metadata and the engine-side bounds are the next evidence targets.
Remaining band deep-dive: painter is path-dependent and off-tree (2026-10-27 night, rounds 17–22)
Forensics past the 72pt band (bundles f6c61f57…ad926933):
- The presenter's full description names the inner chain:
PBUIPosterWallpaperRemoteViewController(scaleAssertions home@1.0) →PBUIPosterWallpaperViewController→PBUIPosterViewController(activeOrientation = Portrait,landscapeBlurEnabled = YES, MercuryPosterv68.colorBhome configuration). The inner poster controller composes portrait while SBWallpaperController holds_orientation = 4. The poster orientation surface was enumerated:rotateToInterfaceOrientation:duration:on the wallpaper VC,setActiveOrientation:/willRotate/didRotateon the poster controller. Driving them flipsactiveOrientationto 4 with no pixel change — the true compositor is the poster extension's own FBScene (settings portrait 669×951, mode "669x951@3x"), unreachable from the host side. - Hiding the live scene hosts (9 nodes) changes nothing; hiding ALL
presenter content nodes (hosts + replicas) returns the screen to the
pre-gate two-band pattern — so the visible wallpaper alternates between
the live remote render and the snapshot replicas depending on
transition history, and the snapshot images carry the gap baked in
(they snapshot the gapped live render). A horizontal affine on the
wallpaper container and a
contentsRectpatch on the replica image views both produced zero pixel change in the states tried — the painting layer for the observed state was not the one patched, and which layer paints is path-dependent. - The same gate+rotate sequence that reliably produced the single-72pt state in round 16 produced the two-band state on a fresh boot in round 22 with the gate verifiably installed — confirming path dependence rather than a broken detour.
- Conclusion: the residual band is composited from the wallpaper render
path outside SpringBoard's view trees (extension scene, portrait-anchored,
feeding snapshot replicas). Productive next steps, in order:
(1) productionize the round-16 ordering (gate detour armed at boot +
rotation RPC's pulse sequence) and characterize which transition path
lands on the 72pt state; (2) the root fix remains making the extension
scene/display mode rotate — the display-identity workstream;
(3) live compositor introspection needs frida in the guest (extension
process), which needs the
process.spawnvphoned RPC or bootstrap ssh.
Cover-viewport switch regression on the latest build (2026-10-27 late)
While capturing the four inner/outer screenshot states on the freshly
installed Release Launchpad (built from source, signed with the
Developer ID team, helper self-healed to ready), the cover viewport
switch regressed: on 2.12.1-local.ad926933 (latest source), every
display.posture closed/open fails with "Duo logical viewport did not
reach the requested size" — from settled portrait, from landscape, and
after a clean respring (cover generations are requested and workspace
transitions run, but bounds never reach 466×678), and the failure is
bidirectional (open fails from cover too; after a reboot with a closed
hinge preference the machine stays stuck on the cover until an older
bundle opens it). Binding 2.12.1-local.3803cc4d restores both
transitions immediately (closed 466×678 and back to 669×951 verified),
so the regression entered between 3803cc4d and ad926933 — the probe-round
folddisplay changes are the delta; bisect before productionizing any
cover-path change. Rotation while closed is refused by the current
posture policy (cover stays portrait, per the documented product rule).
device.screen width is stale across viewport switches — trust
display.geometry. Four-state captures and per-state poster probes are
archived in /tmp/duo-quads (inner portrait/landscape on ad926933; outer
portrait + refused-landscape on 3803cc4d).
Orientation-map writer restored; cover application second layer open (2026-10-27 midnight)
The cover-viewport regression has two independent layers, both now characterized:
-
SB-main orientation map writer was gutted —
vpExportNativeOrientationMappinghad been reduced to a log-only stub (the folddisplay source is untracked, so no git history marks when; the 3803cc4d dylib still references the map path, ad926933 does not at all). vphoned'svp_display_orientation_mappingpostscom.vphone.display.orientation.mapping.requestand waits 150 ms for a freshvphone-display-orientation-map.json; with no writer every posture/rotation settle that needs the map fails with "map unavailable"/"viewport did not reach the requested size". Reimplemented the writer in bundle2.12.1-local.fdcb1d99: publishes the validated schema (version/scene/pid/generatedUptime/available/persistentViewport/ deviceOrientations/scale/surfaceType/viewportGeneration/ interfaceOrientation/configurationBounds/coordinateBounds/bounds) on demand, at SB startup (2..12 s retries), and 0.15/0.6 s after every viewport generation change. Verified:display.geometryavailable with live bounds and persistentViewport=1. -
The viewport application itself still doesn't land on this source state. With the map healthy,
display.posture closedstill leaves configurationBounds at 669×951: the resolver reachesFullyClosed prevailing, primary on .cover, workspace transitions run,single viewport: requested cover generation=Nfires, but bounds never change.vpRefreshMainTransformiteratesvpObservedDisplayMonitors— empty in every recent boot (zero "monitor init:" lines in the whole retained log), so the refresh is a no-op; the observation hooks install but never see anFBSDisplayMonitorinit (monitors are created before the hooks land, or via other paths). The monitor/observation code is byte-identical (strings) between 3803cc4d and fdcb1d99, so the remaining delta is elsewhere — prime suspect is the default-on "Default landscape scene policy integration" window (doc: "runtime validation is required"), which predates my probe rounds and postdates 3803cc4d. Next: binary-diff 3803cc4d vs current around vpSetCoverViewport/vpRefreshMainTransform, or bisect bundles between 3803cc4d and the policy-integration builds (db26a8cc era) with the now-fixed map writer deployed (the map fix must ship in every test build — posture validation depends on it and masked earlier bisects).
2026-10-28 night session: posture chain rebuilt end to end (agent)
Three independent defects were found and fixed in one pass; the fold
pipeline now works end to end on duotest-m5 (build 2.12.1-local.76f72608
and later).
1. Screen-off "wedge" = persisted closed hinge, not a wedge
Every boot came up dark with home/power/undim dead. display.pose told the
truth: hinge: 1 (closed). SpringBoard honestly emulates a folded phone —
inner display asleep, and the (missing) cover display should be the lit
one. GuestFoldPose.startOnStartup replays the persisted hinge angle every
boot, so the last user fold direction survives reboots by design. The
"repeated fold-button clicking can light it" symptom was the open-direction
pose forcing a display update. Recovery is one call:
display.posture {"position":"open"}. The bootlogo-offset sighting on
0da674b5 matches: a closed-pose boot renders the boot logo for the cover
viewport inside the inner-sized window.
2. The orientation map's deviceOrientations was a hardcoded identity
vpExportNativeOrientationMapping published @[@1,@2,@3,@4], but the Duo
inner panel mounts rotated: the scene's own
deviceOrientationForSystemSpaceOrientation: answers [3,4,2,1] (and
the forward table [4,3,1,2]) — measured live. Every
GuestDisplayRotation.resolve therefore translated interface requests
through the wrong device orientation; that is the entire 180°-flip bug
(interface 90 → stub device 4 → real table says device 4 presents 180).
The writer now exports both native tables (permutation-validated, identity
fallback for pre-selector scenes). After the fix, rotation 0/90/180/270
round-trips settle and posture open lands at interface 90 by design
(setDevicePose's desiredInterface), no longer corrupted to 180.
3. Layer 2: the 3803cc4d publish path had been deleted, not broken
The rewrite that rebuilt the map writer dropped the entire mechanism that made the cover viewport apply. Recovered by diffing strings against the shipped 3803cc4d dylib and then observing that bundle live:
- Invalidation pulse —
FBDisplayManager sharedInstance(the "compatibility monitor") holds the sources and the transformer. The old code flipped the primaryFBSDisplaySource._updatableivar (a raw BOOL ivar, no setter — method hooks fail, write byivar_getOffset), calledupdateTransformsWithCompletion:(ABI v24@0:8@?16) — the armedSBMainDisplayConfigurationTransformerhook then replaces Main with the 466×678 cover mode — and restored the flag in the completion. - Root geometry — same-identity mode changes push no scene settings of
their own.
updateUISettingsWithBlock:(on each root server scene: SB-main, SB-systemAperture, SB-systemApertureCurtain) hands the block aUIMutableApplicationSceneSettings;setFrame:there is ABI v48@0:8{CGRect…}16 (not v40 — cost one build cycle). Setting the three frames to the live mainConfiguration bounds is what the old___vpUpdateRootSceneGeometry_block_invokedid. - Adoption then runs natively through machinery that already exists:
the hooked scene-geometry callback →
vpPropagateSceneBounds→ icon family 32→31 ("iPhone XX (D76)") →_resizeWindowFrameToSceneBounds…on every scene window (10–20 frames changed) — the same log sequence the 3803cc4d reference boot produced. - Ordering matters: adoption must run after the transform publishes. It
is now scheduled from the transform hook's cover-success branch (once
per generation), with a 0.25 s fallback in
vpSetCoverViewportfor the inner revert, deduped byvpAdoptionCompletedGeneration.
The manual workspace request path (vpProcessViewportWorkspaceRequest)
still refuses (layout state elements=0) but is no longer needed for the
viewport itself; it fires from vpPropagateSceneBounds under the same
guard as before.
Verified this session (duotest-m5, 76f72608)
- 3× open↔closed round trips: every call
ok:true, bounds 669×951 ↔ 466×678 each time. - Rotation matrix at open: 90 → 0 → 270 → 0, all settle.
- Cover state: home wakes it, centered correctly-proportioned clock presentation (12:55 AM matched wall time), root scene frames 466×678.
- Open portrait/landscape screenshots captured (/tmp/ev-*.png era).
- Host fold button now toggles open↔closed only (book stays in the Fold panel for diagnostics).
Still open: glyph 0.68 solidification (§3.3/K), lock-screen notification width (§3.5/H), wallpaper gate default (§3.4), full acceptance matrix with touch (A–F), cleanup (I), module commits (J). The cover presentation's content is a clock-style cover screen; rich cover UI (dock/notifications on cover) has not yet been acceptance-checked.
2026-10-28 morning: fold-closed orientation regression (user-reported)
User report after the overnight session: the fold toggle "only switches
landscape/portrait, no inner/outer switch", with a screenshot of the home
grid rotated 90°, vertical dock, right-edge clipping. Reproduced on
2.12.1-local.5ffed6ae with one RPC: display.posture {"position":"closed"}
lands bounds 466×678 (cover mode applied) but interface orientation 3 with a
2853×2007 landscape framebuffer.
False leads worth recording: the folddisplay log appears to "stop" at a
timestamp because files.read returns the FIRST ~512 KB of a multi-MB file —
always check files.list mtimes before concluding a dylib died; the map
JSON's fields can also be stale across a VM restart (the file persists), so
display.orientation (asks SpringBoard live, IcliKit) is the truth for
interface degrees, not the map's interfaceOrientation.
Root cause chain (each link verified on the live guest):
- Stale-table resolve.
setDevicePoseresolved portrait against the map JSON immediately after the viewport landed. The JSON'sconfigurationBoundsupdates at the apply, but the scene's mounting table follows on the next export, so the first read still carried the previous panel's table: closing resolved portrait to device 3 (inner's rotated portrait) which the identity-mounted cover renders as landscape. - Cover arbiter latch. The cover scene's traits arbiter derives its
direction once, at the mode swap, from the pipeline's cached device
orientation, and after that no input channel reopens it — pose override
(device 1),
pose.refresh(pipeline re-read), forced arbitration, the accelerometer sink (delivering device 1), and the publicdisplay.rotationpath all verified portrait-side while the pipeline stayed landscape.vpMainInnerWindowScene()returning nil in cover viewport also made the dylib's refresh/arbitration/actuation handlers refuse outright ("refused outside Main inner"), so the inner panel's self-healing (native callback direction=1) had no cover equivalent. - Boot restore hardcoded device 1 as the pose value (the pose enum's portrait, not the mounted device value), so a folded boot could restore a landscape interface depending on which table the export carried at ladder time.
Fixes (vphoned + dylib, all in this session's builds):
GuestAPI+Device.swiftsetDevicePose: self-verifying resolve loop — request a fresh map export, resolve portrait, set pose + accelerometer, pokepose.refresh/arbitration.refresh/actuation.refresh, confirm viainterfaceRotationDegrees() == 0, re-resolve once with the settled table; accelerometer seeded to 0° andpose.refreshposted BEFORE the viewport apply when closing; closing while unlocked+lit now sleeps the display (real hardware blanks the inner panel; the cover lock screen is the native cover presentation — verified portrait at every folded boot).GuestFoldPose.swift: boot ladder resolves the portrait device value from the live map instead of hardcoding 1, stops only when SpringBoard answers portrait, and pokespose.refreshbefore the respring-replay viewport apply.libvphonefolddisplay.m: newvpOrientationArbitrationScene()(inner scene, else the active Main-identity scene while the persistent viewport is live) used by the refresh/arbitration/actuation handlers; the actuation handler, when the arbitration scene is the cover, completes the native callback chain with the cover's native portrait direction instead of the latched pipeline value — this is the link that actually breaks the latch.
Verification on 2.12.1-local.f16a7b5f (duotest-m5): boot-closed portrait;
3× open↔closed roundtrips all landing 669×951/0° and 466×678/0° with a
portrait framebuffer in both states; cover wake lock screen portrait; inner
rotation matrix 90/0/270/0 exact; open home screen visually clean (portrait
grid, horizontal dock, no clipping). Known remaining behavior: closing while
unlocked sleeps the screen by design — the next wake presents the cover lock
screen (clock) and screen.unlock (or a swipe) returns to the home layout.
Follow-up (same morning, animated sweeps): the fold panel's animated sweeps
(duration > 0) exposed two residual transients. The hinge sweep's
fully-closed band crossing re-derives the cover direction once more near its
end, after vphoned's first correction; and opening carried the cover's
portrait pose (device 1) into the rotated inner mounting, flashing
landscape-left until the loop re-resolved. Fixes: the resolve loop's first
pass now pokes pose-refresh/arbitration/actuation immediately after the
bounds confirm (the export wait moved to the retry pass only), the posture
path holds one final actuation batch until vp_hinge_get()["sweeping"]
clears, the open direction seeds the measured inner portrait (device 3 /
270°) before the viewport apply, and the dylib's viewport adoption completes
the cover actuation inline (vpScheduleViewportAdoption), so the correct
direction lands in the same adoption turn instead of a second later. Button
path (duration 0) verified 3/3 roundtrips clean; animated paths sample clean
with an occasional single-sample (~0.2 s) self-healing blip on close.
Follow-up (landscape re-flow): rotating the open panel to landscape left
the home grid in its portrait arrangement rendered sideways with a black
side band — the Main mode keeps portrait dimensions on this platform, so
the rotation pipeline turned the framebuffer but nothing re-delivered the
scene's coordinate space (no icon-family switch, no resize propagation;
those only ran from the fold adoption chain). Two fixes: the dylib's
orientation actuation now also submits the public
requestGeometryUpdateWithPreferences: for the actuated direction, so
UIKit swaps the scene space to 951x669 and the grid/dock/status re-flow
(verified visually at 90 and 270); and the host window's orientation poll
only re-applied the window turn when the panel size changed, so a rotation
on the same panel left the window in the old aspect — it now follows the
orientation change itself (animated turn, fold panel changes stay forced).
Follow-up (rotation memory across folds): an explicit landscape now
survives a fold cycle. display.posture records the interface degrees at
close (90/270 only) in a session-scoped memory, presents the cover
portrait as before, and on opening replays the remembered direction
through the same seed/resolve-and-confirm path — the open-side seed now
resolves the desired interface against the measured inner table instead
of hardcoding device 3, and the sweep-hold check and rotation fallback
follow the target degrees. Verified: 90° and 270° both restore after a
closed→open cycle with correct landscape re-flow; a portrait fold cycle
stays portrait. The memory is session state: a reboot restores portrait
through the boot ladder.
Follow-up (state machine rework): the orientation model now matches the hardware. The cover has no rotation state — it always presents portrait (466×678), and the fold closes onto a LIT cover screen (the earlier sleep-on-close made the fold button look like "screen off" and is removed; the cover arbiter override keeps portrait upright there for both the unlocked layout and the lock screen). The inner panel rests LANDSCAPE: its rotated mounting maps the natural device pose (accel 0°) to interface landscape-left (90°), so an unfold — and a folded boot — presents the inner default directly, and portrait on the inner panel stays an explicit rotation. The session-scoped fold rotation memory from the previous entry is removed (the default owns the post-unfold state); the boot ladder resolves the desired interface per target viewport (cover "0", inner "90") and stops only when SpringBoard answers the target degrees. Verified: folded boot and every unfold land 90° with a correct landscape re-flow; every close lands a lit portrait cover; explicit rotations 0/90/270 still settle; 3× cycles green.
Follow-up (cover presentation band): the folded black band under the cover content traced to the host window, not the guest. The guest anchors the 466x678 cover mode at the framebuffer's top-left and the window's layout crops exactly that region (1398x2034) full-bleed at the panel's scale — verified by pixel-brightness profiles of the raw framebuffer (content in rows 0–~2050, pure black only below the mode). The visible thick band was the inner-aspect window still showing the whole framebuffer for up to the orientation poll's one-second period after the fold: the cover content in the top 71 percent, black below. The fold action now runs the poll as soon as the posture RPC returns, so the window takes the cover crop immediately. (A guest-side experiment that centered the scene frames moved nothing — scene windows ignore the settings-frame origin — and was reverted; the top-left anchor is the load-bearing contract between the two sides.)
Follow-up (wallpaper on the landscape-rest inner panel): two symptoms
after the state-machine rework — the wallpaper missing at a boot that
rests landscape, and a ±90°-misrotated wallpaper after rotations. Both
traced to the wallpaper orientation pulse
(presentation.wallpaper.pulse → vpPulseNativeWallpaperOrientation),
a portrait-era lease that re-aimed the wallpaper controller and force-set
its view frames; with the coordinate-space machinery now delivering the
rotated scene geometry natively, the lease fought it (interface-code
rotation applied on a rotated mount = a net 90° error, direction
following the rotation side). vphoned no longer posts the pulse from the
rotation or posture paths; the native wallpaper rotation owns the
follow. Verified: boot-rest-90 wallpaper present and upright, rotations
0/90/270/90 all correct, cover lock wallpaper intact, close→open cycle
upright landscape. The dylib handler remains registered but idle.
Follow-up (wallpaper direction, corrected): the wallpaper controller takes the device-side orientation code, whose landscape names mirror the UI ones (UI landscape-left 4 presents upright as 3; portrait stays 1) — passing the raw interface code quarter-turned the image on every landscape. The pulse posts removed earlier are restored and the pulse maps its target through that mirror before re-aiming the controller and sizing the views. Verified by brightest-pole measurements of the wallpaper across boot-rest 90, portrait, 90, and close→open cycles (pole at the top in each). Known remaining edge: an explicit 270 rotation leaves the wallpaper view portrait-sized at the landscape interface (content upright-ish but letterboxed) — the 270-side native layout races the pulse's frame pass; chase separately if 270 matters on the hardware.
Follow-up (QuickAction glyph sizes): the uniform 0.68 transform left the flashlight towering over the camera — the glyph frames are symbol metrics times one shared multiplier, so tall symbols (flashlight.off.fill, 20x37pt) render twice the camera's height (camera.fill, 24x18pt) under any uniform factor. The walk now normalizes each glyph's LONG side to 16pt (the accepted camera size: 23.57 x 0.68), computed per instance from its live frame and clamped to 0.2...1.0: the flashlight lands at ~0.44, the camera keeps ~0.68, and any future glyph — including the lock-screen focus button (same CSQuickActionImageGlyphView class, created only while a Focus is active) — normalizes without a per-action table. Verified on the cover lock screen (both glyphs now matching extent, flashlight slim); the Control Center focus module icon measures consistent with its neighbours and is a different (CCUI) family.
Follow-up (deployment staleness + fresh rebuild): two infrastructure
findings from the wallpaper chase. First, the offline guest-environment
update (set-bundle --update-environment / cfw update-environment) served
the PREVIOUS build's dylib — proven by a build marker absent from the
guest while the bundle contained it; two page-rounded builds sharing a
byte size hid the skip from casual checks. A size-changing padding
buffer now keeps the folddisplay dylib's size per-build distinct, and
the fold-family dylibs (folddisplay, foldguard, hinge, posterprobe) were
added to vphoned's environmentLibraries allowlist so the running
machine's hash-based environment sync can update them too. Second, the
first environment rewrite that actually changed sizes was followed by a
boot kernel panic (AppleSEPManager backtrace, pid 0, three consecutive
boots) — unreproduced elsewhere and not yet root-caused; the machine
was deleted and recreated (vm create --device iPhone19,4 --no-template,
standard preset, every step passed). The fresh restore delivers the
current dylib directly (marker verified in-guest), the wallpaper
orientation bridge installs, and 3× fold cycles land closed 466×678/0°
and open 669×951/90°. Wallpaper orientation judgment needs the user's
directional wallpaper set again on the fresh machine.
Follow-up (wallpaper flakiness, honest status): on the fresh machine the landscape wallpaper is path-dependent — sometimes a correct full-bleed frame, sometimes a portrait-width strip with a gray-black remainder, with the display stream even observed frozen across three rapid rotations (byte-identical captures). The pulse (frame sizing) + orientation bridge (device→interface through the mounting table) + device-code mirror are all armed together now; each individually helps one path but no combination is stable across sequences. Remaining suspicion: photo wallpapers render through the poster-extension processes (PhotosPosterProv and siblings), whose hosted-scene rotation our SpringBoard-side hooks never touch — the next engineering round is injecting an orientation follower into those extensions (SystemHook already routes PosterBoard* processes for the probe). A quick layer bisect anyone can run: set a built-in STILL wallpaper (Settings › Wallpaper) and rotate — if stills stay upright in every direction, the bug is confined to the extension-rendered photo path.
Follow-up (QuickAction repeat application, 2026-10-28): the long-side walk
was using each glyph's frame, which can include the transform already
applied by an earlier rotation or fold pulse. Repeated passes therefore were
not idempotent and could shrink the camera. The walk now reads the
untransformed bounds, keeps the flashlight's 16pt long-side target, and
restores the camera to a 0.82 factor; repeated pulses converge to the same
transform. Each glyph now also retains its first native bounds in an
associated value, so a later private layout rewrite cannot feed a transformed
size back into the next pass.
Orientation actuation also schedules three coalesced main-queue passes after
the native CoverSheet layout returns, so a glyph rebuilt late in the rotation
cannot remain at the unscaled native transform.
Follow-up (wallpaper editor loading and floating scene position, 2026-10-28): the poster probe was issuing a new geometry request every 1.5 seconds while the PosterBoard editor kept its portrait scene contract. That reset the Add New Wallpaper loading transition indefinitely. The probe now coalesces requests, leaves PosterBoard itself alone, and only follows hosted poster renderer processes. The settled orientation is read from the daemon's Darwin notification state before falling back to the map file. SpringBoard also keeps the portrait floating editor scene at its native size and centers it inside the landscape poster host; renderer containers still fill the native presenter bounds.
Follow-up (rotation wedge on the recreated machine, 2026-10-28 PM): after the first 270° request on the recreated duotest-m5, every rotation away from the inner rest (90°) is refused — the RPC times out with "The Duo scene did not settle", the pose override verifiably holds the requested device value during the window (portrait request: pose 3), the accelerometer sink is healthy, and the forced arbitration still concludes interface 4 (the mount's mapping of the REST pose device 1, not the requested device 3). The scene geometry request now fails with UISceneErrorDomain 101 ("windowing mode does not allow programmatic changes to interface orientation") — the same public call that delivered the landscape re-flow on the original machine. Bisection so far: not the experiment flags (none on disk), not the rotation lock (all keys false, full lock-cycle does not clear it), not the merged dylib (reverting libvphonefolddisplay to the pre-merge commit changes nothing), not the hinge preferences (clean 180°), and not a stale vphoned (pose.refresh now rides setRotation's sequence too, commit e409fa2b build). The wedge survives reboots and code reverts, and 0↔90 DID work in the machine's first minutes before the first 270 request wedged it — pointing at a persistent guest state or a machine-creation difference (the recreate ran the current template flow: setUpTemplate/trimTemplate steps the original never had). Next round: (a) recreate a second machine with trimming disabled and diff the arbitration behavior, (b) instrument the traits arbiter's supported-orientation query, (c) compare the scene's effectiveGeometry on the two machines.
Follow-up (rotation wedge root-caused to the creation firmware chain): the
recreated machines' rotation refusal is not code. A clean bisection on a
virgin control machine (duotest-m6, created with vm create --no-template,
current preset): rotations away from the constructed orientation are
refused under (a) the current build, (b) the pre-broadcast vphoned with the
c868 dylib, and (c) the current vphoned with the bridge-free 80800232
dylib — the exact dylib under which the original machine's rotation matrix
was green this morning. Every display-side suspect is cleared; the one
remaining variable is the boot chain: the original duotest-m5 was created
on an older bundle's firmware (its bootChainBundle read 2.12.0), while
both recreates were created with today's build as their chain, so a
regression inside the current standard preset's firmware patches
(kernelcache or dyld-shared-cache guest patches) breaks orientation
arbitration on machines restored with it. Separately, a refused rotation
also poisons the persisted hinge preference to angle 0 (folded) — the
next boot then emulates a folded phone (dark inner screen, home does not
wake); display.posture open revives it. Next round: create test
machines with subsets of the standard preset (--block/--allow) to find
the offending firmware patch; also make the rotation path clamp the
persisted hinge to a sane state on refusal.
Follow-up (corrected framing, per product knowledge): real iPhones never
rotate SpringBoard — home-screen rotation is an iPad behavior, and on the
Duo it is a foldable capability. The recreated machines refusing
display.rotation are therefore showing STOCK iPhone semantics, not a
broken arbiter; the original machine rotated because the dylib's
orientation-actuation replay FORCED the pipeline, and its scene ACCEPTED
programmatic orientation changes — the recreated machines' scenes refuse
with UISceneErrorDomain 101 ("windowing mode does not allow programmatic
changes to interface orientation"). The regression is thus narrowed to
one question: what made the original's SB-main scene permissive to
programmatic orientation while every machine created on the current
chain refuses. Targeted eliminations on virgin machines: blocking
system-springboard-cfw-fold_display (identity padding) does not restore
rotation (duotest-m7); artwork-device-subtype 2556 predates both eras;
the product-id patch is off in presets; scene-status policy is automatic
on iPhone19,4; the ebed07c3 DSC hunks are structural. Remaining probe
for the next session: instrument the SB-main scene's windowing-mode and
activation-condition state at construction (the 101 check's input), and
disassemble SB's programmatic-orientation gate to name the exact
capability it consults — then either supply that capability for foldable
guests or route the rotation path through the API that gate permits.
Also recorded: a refused rotation persists a folded (angle-0) hinge
preference — the next boot sleeps the inner screen; display.posture open revives it; the rotation path should clamp the persisted hinge on
refusal. duotest-m6 and duotest-m7 exist for testing (m7 with
fold_display blocked at creation).
Follow-up (rotation refusal: definitive isolation): display.rotation works perfectly on the iPad guest (duotest ipad-mini-01: 90° and 0° both settle exactly) — the vphoned rotation mechanism, the accelerometer, the pose override, and the actuation pipeline are all correct. The refusal is specific to the iPhone19,4 platform identity in the VM: on every fresh iPhone19,4 machine, SpringBoard constructs its scene with a windowing mode that returns UISceneErrorDomain 101 for programmatic orientation changes, regardless of (a) fold_display/fold_guard/hinge patches blocked or present at creation, (b) the foldable_vm_subtype identity blocked, (c) the folddisplay dylib physically deleted from /usr/lib with SB resprung — the refusal persists in all cases. The original machine's rotation ability came from state accumulated over days of bring-up (specific source unidentified; candidates include launchd environment variables, MobileGestalt caches, or preference-domain keys that our early sessions set and that a fresh machine lacks). This is consistent with product behavior: stock iPhones do not rotate SpringBoard; the capability is iPad-native and fold-hardware-gated on the Duo. Next session should either identify the specific state that unlocked it on the original machine (diff com.apple.springboard and backboardd preference domains between a working and a refusing machine — needs a working reference), or implement the rotation path through a channel that does not consult the windowing-mode gate (the layout-class override swizzle, or a SpringBoard-private rotation API).
2026-10-28 evening: FoldDisplay split into modules
libvphonefolddisplay.m (4,344 lines plus StatusProfile.inc, which ended
inside an unclosed function body, and WallpaperTraitsExperiment.inc) is now
one file per section:
Symbols used by one file stay static; only cross-file ones lost it. The
export table is unchanged. On duotest-m5 the split and the pre-split build
gave the same install log and the same rotation and fold results: 90°
settles, then 0° and 270° are refused ("did not settle"), and closed/open
land 466×678 and 669×951. That refusal on this machine predates the split.
QuickAction glyphs: why the scale came and went (2026-10-28 evening)
quickaction.inspect now logs each glyph's bounds, transform, symbol image
size and on-screen size. After a respring the flashlight showed bounds 20x37,
transform 0.436, on screen 8.9x16, while the camera showed bounds 35x26,
transform 0.82, on screen 28.4x21.6 (its native size). CSQuickActionsButton
lays the glyph out with setFrame:, and on a transformed view UIKit answers
by growing the bounds until the visual frame is the requested one. A glyph
looked scaled only if no layout pass followed the walk, and the walk skipped
it afterwards because its transform already matched. That is the reported
"not idempotent / not uniform".
Fix (FoldDisplayQuickActions.m): each glyph moves to a runtime subclass,
VPhoneQuickActionGlyphView, as KVO does, whose setFrame: sets center and
bounds from the layout rect when a transform is present, and whose -class
reports the original class. CSQuickActionImageGlyphView's own method list
is still never written. The glyph's container (48x48 UIView) holds two
subviews, so a sublayerTransform there was ruled out.
Target: one factor, vpQuickActionGlyphScale = 0.70, on the native layout
sizes (flashlight 20x37, camera 35x26; the camera's 28x22 seen earlier was
already an effective 0.82). A real iPhone Duo landscape lock screen
(1535px for 951pt, 77px buttons) draws the flashlight at about 15x27pt and
the camera at 23x17pt: 0.75 and 0.66 of the layout sizes, one factor within
the measurement. Drawing both at their symbol image size (12x22, 25x19) was
tried first and made the flashlight shorter than the camera, which the real
device does not do. The tall/wide split (16pt long side, camera 0.82) is gone.
Verified on duotest-m5: 14.2x25.7 and 24.3x18.4 on screen after two lock cycles, a rotation to 90°, folding closed, opening, and another lock cycle.
2026-10-28 night: dual-display spike, Stage 0
The landscape-wallpaper hooks kept patching consequences of one fact: this
guest has one portrait main display, so PosterKit classifies every scene as
DISPLAY_TYPE_EMBEDDED and PFContextSupportsDynamicRotation says NO. A
real iPhone Duo has a second, always-connected, non-main, landscape-native
inner panel that PosterKit classifies as DISPLAY_TYPE_AUX. The spike
publishes such a display inside the guest instead
(FoldDisplay/FoldDisplayInnerDisplay.m, opt-in through
/var/mobile/Library/Caches/vphone-duo-inner-display until 2026-10-29, when
the marker was dropped and the path became the only one).
Stage 0 asked for: two always-connected displays, a stable SpringBoard
without suppressing any assertion, an inner window scene, and
PFContextSupportsDynamicRotation YES with no detour. On duospike-inner
(iPhone19,4, 27.1 24A94425) it reads:
What the display needs, each found by the failure it caused:
- backboardd: a
CAWindowServerVirtualDisplay(2853×2007, 60 Hz, namedVPhone Duo Inner), enabled and tagged with the bitNSStringFromBKSDisplayTagscallsunknown(65536). FrontBoard drops a source that is neither always-connected nor tagged. - SpringBoard, CADisplay type: every reader except one sees type 0,
built-in.
-[FBSDisplayConfiguration _initWithImmutableDisplay: originalDisplay:isMainDisplay:assertIfInvalid:]sees type 4, scoped by a thread-local flag around the original. In_FBSCADisplayToDisplayTypestype 4 is always-connected but UIKit-main-like only when it is the main display, so only Main claims the cloning source (two claimants isManagedDisplaysController"Cloning source already exists",SpringBoard-2026-10-26-005058.ips)._FBSCADisplayAlwaysConnected, the classifier's other caller, gives always-connected for 0 and 4 alike. - Why not type 4 everywhere:
BLSHBacklightOSInterfaceProvider initWithPlatformProvider:builds the backlight hosts once from[CADisplay displays]filtered todisplayType == 0. Without a host,SBBacklightController initWithWindowScene:passes a nil backlight and-[SBDisplayLinkController initWithCADisplay:backlight:]traps (.cold.2, SIGTRAP).-[SBEmbeddedScenesConnectionManager _reevaluateSceneConnection]waits for a scene on theuniqueIdof every type-0 display and runs on every arrival. With the inner display at type 4 the expected set is {Main}, so the inner scene's arrival completed startup a second time and-[SBMainWorkspace setPipCoordinator:]asserted.
- Getter ABI on 27.1:
-[CADisplay pointScale]isQ16@0:8; CADisplay has noisAlwaysConnected. The adapter reports pointScale 3 andisExternalNO for the inner display only.
Open for Stage 1 and beyond:
- The display only reaches SpringBoard after a SpringBoard-only restart: backboardd adds it about a second after SpringBoard's first display enumeration, and BacklightServices reads the list once.
- The inner configuration reports 669×951 bounds, not the panel's 951×669.
- Nothing composites the inner display onto the framebuffer and no touch is routed to it.
Test trap: a VM restart's environment sync rewrote
/usr/lib/libvphonefolddisplay.dylib with the bundle's copy about a second
into boot, after the first SpringBoard and backboardd had loaded the pushed
build. Later SpringBoards ran stock code without any error. Re-push and
check the on-disk hash after every VM restart.
2026-10-28 night: dual-display spike, Stage 1 (compositing and touch)
Render-server cloning does not reach this panel. The first attempt
made the inner display the clone master and the LCD its clone
(-[CAWindowServerDisplay addClone:options:]; the options are
disableOverscan, disableRotation, disableScaling, disableYUV,
noReplayScaling, replayContexts). The clone registered (LCD.cloneMaster
= the inner display). The inner display was disabled and blanked by then,
and VirtualDisplay::can_update gates on a state bit; unblanking it gave
enabled=1 isBlanked=0, after which the LCD as its clone read
enabled=0 isBlanked=1 and the paravirtual panel showed either black or
its last frame. Removing the clone restored the LCD. Not pursued further.
SpringBoard compositing works. com.vphone.display.inner.show puts a
window on Main above everything and, every 250 ms, keeps one CALayerHost
per visible window of every scene on the inner display, by
-[UIWindow _contextId], ordered by window level (the way scene hosting
shows app contexts). .hide removes it and Main shows again. A test window
on the inner scene (com.vphone.display.inner.pattern: teal, a clock
ticking every second, a tap count) appeared live on the physical panel,
checked from the VM window's own framebuffer (screencapture -l), not the
guest screenshot, which renders Main's layer tree.
Touch follows. backboardd hit-tests the render tree, so a touch on a hosted context goes to the process that owns it. Host taps at panel pixels (500, 700) and (1500, 2400) reached the inner window's tap recognizer at (166.7, 233.3) and (500.0, 800.0) points: exact, with no forwarding code.
Geometry. SpringBoard's own display transformer gives the inner panel a
portrait interface mode (669×951, pixels 2007×2853; the 951×669 landscape
mode stays in otherModes), so the inner scene is 669×951 and on this
portrait-mounted panel it composites upright at scale 1. FoldDisplay's
transformer hook only changes Main.
Still open: the inner display carries only SpringBoard's bringup and aperture scenes, so it has no home screen or apps of its own yet; showing it is manual (no fold/posture wiring); it is only seen after a SpringBoard-only restart. The guest screen also sleeps within seconds after these restarts, so captures need a wake first.
Which panel is Main on a real Duo (2026-10-28 night)
The iPhone19,4 device tree (DeviceTree.v68ap.im4p in the 27.1 restore)
answers it directly. Under product:
The SoC has two internal display pipes, dcp/disp0 and
dcpsec/dispsec; display-crossbar0 routes only the external ports
(dispext0, dispext1). So the primary display (index 0, Main) is the
cover, and the inner panel is a second, simultaneously driven display
mounted at 270°: the topology this spike publishes.
PosterKit agrees. PFCurrentDeviceClass maps MG DeviceClassNumber through
a PosterFoundation table: 1 (iPhone) and 2 (iPod) → 0, 3 (iPad) → 1,
4 → 3, 9 → 2, 11 → 4, others → -1. The guest answers 0, the iPhone value,
and PFContextSupportsDynamicRotation's deviceClass == 1 is iPad. On an
iPhone only an auxiliary (non-main) display rotates its poster, so the
real Duo's landscape inner lock screen needs the inner panel to be the
non-main display.
SBFStringForSurfaceType gives 0 .default, 1 .cover, 2 .inner;
SBFSurfaceTypeFromSSKSurfaceType is the identity for 1–3. backboardd's
BKSDisplayTraits.surfaceType comes from DeviceSurfaceType in the
display's extendedProperties, which on this guest is supplied by
FoldDisplaySurfaceProbe.m (2 for the primary panel, 1 for the others), so
traits read back from the guest are not evidence of native roles. Nor is
legacyMain (.inner) in the resolver log, which is FoldDisplay's own
vpSyncLogicalRoleResolver.
For later: the inner panel's Fold region is {0, 1335, 1878, 0} in its
own pixels, which suggests a 1878-pixel-wide panel folding at row 1335;
this guest's 2007×2853 Main and the spike's 2853×2007 inner display were
not taken from the device tree.
SpringBoard's own posture logic drives both displays (2026-10-28 night)
With the inner display published, backboardd now answers the device tree's
roles through extendedProperties DeviceSurfaceType: the primary panel 1
(.cover), the inner display 2 (.inner). FoldDisplay's SB-side
vpLogicalSurfaceType and vpSyncLogicalRoleResolver, which forced Main
to .inner for the single-display model, step aside while the inner display
is requested. SB-main then reads surfaceType 1 and the inner scene 2.
-[SBDisplayContentModeCoordinator windowSceneDidConnect:] is what calls
-[SBFDisplayContentModeResolver startResolvingContentModeOfDisplayForWindowScene:],
and the inner display's bringup scene is never handed to it.
com.vphone.display.inner.resolve hands it over (once; the resolver's
_displayProxies is keyed by hardware identifier). The resolver then holds
legacyMain (.cover) and the inner display (.inner), and the hinge
picks the policy natively:
Closed, the panel shows SpringBoard's real cover lock screen and the VM window is cover-sized. Open, the panel goes black at 0 fps: SpringBoard turns the cover display off, as on a real Duo, and on this guest the cover is Main, the only physical scanout, so the composited inner display has nowhere to appear. SpringBoard stayed up throughout.
Open: the cover is powered off below CAWindowServerDisplay (2026-10-28 night)
com.vphone.display.inner.server-state logs each render display as
backboardd holds it. Closed: primary enabled=1 isBlanked=0, inner
enabled=0 isBlanked=1. Open: the reverse. backboardd powers the panels
as a real Duo does, which on this guest leaves the only scanout (the
primary) off.
Swizzling -[CAWindowServerDisplay setEnabled:]/setBlanked: showed
neither is called for the primary when the device opens; the change comes
from below (QuartzCore has CA::WindowServer::Display::set_power_state
and IOMFBDisplay::set_power_state_locked). Re-enabling and unblanking the
primary through the original setters whenever the inner display was lit
did make the flags read enabled=1 isBlanked=0 while open, but the VM
display still showed no frames (0 fps), so the power state underneath
stayed off. The experiment was removed. The next lever is either the C++
power path for the primary, or keeping SpringBoard's backlight host for
the cover on while the inner display holds primary content.
Separately, SpringBoard crashed three times while the posture changed with
both panels resolved: a Swift fatal error in UIKitCore
ScrollEdgeEffectView.swift, reached from -[UIWindow _sceneSettingsSceneRegionsDidChange:] → layout. It restarts on its own;
the cause is not yet known.
Open shows the inner display; touch reaches it but not its windows (2026-10-28 late)
- Compositor: a remote
CAContexton the primary display (level 1e9), not a window of SB-main: open, SpringBoard gives the cover auxiliary content and takes SB-main's windows off screen. The context works in the panel's pixels, so hosted inner points are scaled by the point scale, and the fit is redone whenever the panel's size changes. - Panel power: SpringBoard drives each panel through
-[CADisplayStateControl transitionToDisplayState:withCompletion:](state 1 on, 0 off;_display_id1 cover, 7 inner); backboardd does not. The cover's state is the union of both wants, and the inner display's own state shows and hides the compositor. - Screen idiom:
-[UIScreen _updateUserInterfaceIdiom]gives the device idiom only to UIKit-main-like screens; the inner screen was -1, which UIKit's Swift idiom switch inScrollEdgeEffectView.swifttraps on during posture changes. The inner screen now takes the device idiom; no SpringBoard crash since. - Resolution: a watch from SpringBoard's start hands the inner scene to
SBDisplayContentModeCoordinator, so the fold button works without manual steps. - Touch:
-[BKHIDDirectTouchEventProcessor _queue_displayUUIDForDigitizerService:](asked per event) reports the inner display while it is lit, and_queue_addDigitizerStateForService:(returns an object) creates the touch state under it. backboardd's hit test then finds inner windows (windowServerHitTestWindownames them), but SpringBoard's UIKit sends the events only to_UISystemGestureWindowand then "to windows: 0": touches do not reach the inner windows yet.
With the device open, the panel shows SpringBoard's own landscape lock screen and home screen for the inner display, with the landscape poster.
2026-10-29: the single-display path removed
The two-display path above is now the only Duo display path. The
single-display approach, one portrait Main made to stand in for whichever
panel was active, is gone from FoldDisplay, vphoned and the guest
components. Once the inner display was published, SpringBoard resolved
FoldingDual/FullyClosed, the surface types and each screen's status bar
for both displays itself, and every single-display shim either stepped aside
when the inner display was requested or patched a consequence of the
one-display model (PosterKit DISPLAY_TYPE_EMBEDDED, Main carrying the inner
panel's metrics, one scene arbitrating both panels' orientations).
On iPhone19,4 the inner display is always published: there is no marker file,
and the constructor checks hw.machine in backboardd and SpringBoard alike.
The other .once markers and every com.vphone.display.* notification are
gone too, so the instructions in the sections above that post them or create
those files no longer apply.
Removed:
What remains in VPhoneGuestComponents/FoldDisplay:
display.posture now sets SpringBoard's test-automation pose override,
publishes the hinge angle (a sweep for position, at once otherwise) and
replies with the request's values and the hinge service state, as before.
GuestFoldPose replays a configured hinge after boot (portrait, the saved
angle's hinge state) until SpringBoard reports it, and the latest pose after
each SpringBoard startup. display.rotation went back to the generic iPhone
path here, which turns neither panel; see the rotation section below.
To check on the device after this change:
- Closed on a machine booted on the inner-size panel: the cover is Main at the panel's full 669×951 (the 466×678 viewport is gone) and the window shows the whole panel.
- The cover lock screen's quick-action glyphs at their native size.
- The inner lock screen's landscape poster without the
PF_IS_PAD_DEVICEgate and the wallpaper traits answer. - The lock-screen and status-bar clock still advancing each minute without the clock reference adapter (it worked around a detached reference view with a nil screen; see f7d145ca above).
display.rotationwhile open.
2026-10-29: flicker, screenshots and rotation on the two-display path
Verified on duospike-inner (iPhone19,4, iOS 27.1) with a bundle built from this branch, running the cleaned-up code above.
Flicker. Measured with a ScreenCaptureKit capture of the VM window at 120 Hz and per-frame luma. Three causes, each confirmed by its frames:
- The compositor re-set every host's frame and zPosition on each sync pass, changed or not; each set made the render server drop every hosted window for one frame (a fully black frame, several a second at idle). Hosts are now updated only when a value changes.
- Hosts followed SpringBoard's windows on a 250 ms timer, so a window SpringBoard showed (the app-launch transition, the app's scene host) had no host for up to a quarter second. The sync now runs in a main run loop observer just before Core Animation's commit (order 1999999), in the same transaction as SpringBoard's own window changes.
- The per-frame damage layer: an opacity flip rendered the container offscreen (grey blocks over secure windows); a 1/255 tint alternated the panel's brightness at 30 Hz. It alternates between two clear colors.
Rejected while measuring: setDisablesUpdates:YES on the inner render
display (inner touch stops: backboardd hit-tests the rendered display) and
CALayerHost.rendersAsynchronously (hosts render nothing). Result: 0 black
frames in about 2700 captured frames across idle, taps, scrolling and
home/app transitions.
Screenshots. UIKit's screen capture (vphoned's screen.screenshot, and the
host's saved screenshot that goes through it) renders display-level contexts
in points, as it renders windows; a context of its own on the display works
in pixels. The compositor was such a context, so a capture with the device
open came out magnified by the screen scale (the top-right third of the inner
display), the way icli already notes for the boot logo. It is now a layer at
the top of Main's UIRootSceneWindow, the window Main's scenes are hosted in,
in points like every window (every SpringBoard window measured 669×951 points
with an identity transform). Closed, the capture is the full cover; the old
466×678 cut is gone.
Rotation. Neither panel turned: display.rotation reported "did not
apply" for the cover and the inner panel alike, in Safari too. The chain:
- Each panel's traits pipeline (
SBTraitsEmbeddedDisplayPipelineManager,_surfaceType1 for the cover, 2 for the inner panel) takes its device orientation from the device pose, per panel:BSDeviceOrientationForSurfaceFromPose(surface, pose)reads the pose's cover or inner orientation, andposeProvider:didUpdateFromPose:toPose:updates the pipeline only when that per-panel value changes. The accelerometer orientation and AssistiveTouch'ssetOrientation:(icli's rotation) never reach it. - The test-automation pose override (
SBSTestAutomationService setDevicePoseOverride:, whichdisplay.postureuses) carries one device orientation. SpringBoard derives each panel's from it with+[CMMagicPoseManager orientationForMagicPose:deviceSurface:]. - That method asks
+[CMMagicPoseManager sharedManager]to rebuild aCMDeviceStateEventfirst. The guest has no motion coprocessor, so there is no shared manager: the message goes to nil and both panels read ambiguous, whatever the pose. - The mapping itself is
+[CMDeviceStateEvent propertyAForConfigType:propertyA:propertyB:propertyC:](configuration = surface, A = orientation, B = hinge state, C = tabletop pose). Closed (B 1) the cover takes A and the inner panel is ambiguous; partially open or open flat (B 2, 3) the inner panel takes A and the cover takes A with landscape left/right and face up/down swapped; any other hinge state, includingopen4, is ambiguous.position: opensets open flat (3).
FoldDisplay answers orientationForMagicPose:deviceSurface: with that
mapping directly, and vphoned's display.rotation on a foldable sets the pose
orientation for the lit panel (table in Research/vphoned_http_api.md).
Checked: cover 90/270/0 and inner 0/180/270/90 in Safari, each settled with
the interface and the host window upright; the cover home screen stays
portrait, as on any iPhone.
Open: in landscape on the cover, the AxisAlignableSplitV68Cover status bar
draws its time and battery over Safari's address field.
2026-10-29: the fold owns the panel; hidden windows hosted
Reported after manual testing on duospike-inner: the fold control sometimes left the screen on the inner display or black instead of the cover, the inner Dock did not respond, and the screen still flickered.
Fold control. The shared panel used to follow SpringBoard's power requests alone (lit while either panel wanted it, the cover winning). Those requests do not follow the fold:
- With the inner display asleep (the 60 s auto-lock), closing made SpringBoard light the inner display for about three seconds and then turn it off; the cover was never asked on. The panel showed the folded-away inner display, then went black.
- In a long-running SpringBoard, a close resolved FullyClosed (SpringBoard's log said so at the next open) without any power request: the panel kept showing the inner display.
The panel now belongs to whichever display holds the primary content,
-[SBDisplayContentModeCoordinator primaryWindowScene], and is lit while
that display wants to be; the other panel's requests are applied but not
shown. Ownership is re-read on every power request and after each
performDisplayContentModeTransitionWithCoordinator:, so a fold without a
power request still hands the panel over. With that, SpringBoard's own
requests land on the right panel: closing awake, closing after the auto-lock,
closing after the sleep button, and opening asleep each light the arriving
panel (the cover's lock screen or home screen, the inner lock screen). An
extra wake on a fold, tried first, was not needed.
(_displayContentModeChangedWithContext:continuationLockProvider: appears in
the symbols only through its blocks; the runtime has no such method.)
Flicker. A 120 Hz capture with a per-tile transient detector (a 24×24
grid, a tile that jumps and comes back within two frames) found one frame left:
going home from an app, the first frame of the shrinking app sat on black.
The compositor hosted only visible windows; the home screen's windows came
back with new CALayerHosts, and a host draws nothing for its first frame.
Hidden windows are now hosted too (they draw nothing); the frame shows the
dimmed home screen blur instead (backdrop 0.22 instead of 0.12, brightening
over the next frames).
Touch. Automation taps and guest-injected touches with and without edge
flags land at the expected points on all four edges and corners, in every
orientation (locationInView: equals the screenshot point divided by 3),
and the Dock launches its apps after folds, rotations, sleep/wake and app
switching. The user's dead Dock was in a SpringBoard that had been running
for hours and was not reproduced; once, right after a SpringBoard restart and
a fold, the Dock ignored taps until the next restart while the home screen's
own icons kept working. Not yet explained; the next occurrence should be
caught with logs.syslog for SpringBoard during a Dock tap ("Sending UIEvent
... to window" and "Allowing tap for icon view").
2026-10-29: host zombification against app-launch black frames
Symptom. After the fixes above the user still saw an occasional flicker.
A 120 Hz ScreenCaptureKit capture of the VM window, with per-frame luma,
found it: one or two frames in which the whole panel is black (luma 0, the
compositor root's own black), sometimes alternating with a frame in which an
app is only partly drawn, about 0.4 to 0.6 s after an app launch or on the
way home. Same rate in portrait and landscape; never on the cover (no
compositing there). Sequence used: launch Safari, home, Settings, home,
Safari by apps.launch, 2.5 s apart; it flashed in about 18 of 26 runs.
Ruled out, each by the same measurement:
- Host layer changes: a trace of every
CALayerHostadd, remove, frame and zPosition change logged nothing during a flash. - SpringBoard fences:
CAFenceHandle/-[CAContext addFence:]bursts (logged with a calibration mark, a one-frame white layer, to align the guest clock with the capture) do not coincide with the flashes. - GPU load of the inner virtual display: with SpringBoard keeping the inner display powered off while it owned the panel, content kept updating and the flashes stayed (3 of 5 runs). The inner display's update rate does move the rate (30 Hz 1/5, 60 Hz ~5/7, 120 Hz 5/5), which follows how often apps commit, not load.
copyDisplayTimingsFromDisplay:(inner timed by Main): no change.rendersAsynchronouslywith anasynchronousRenderPeriodof 1/120: the hosts draw nothing at all, as before.- A render-server probe in SpringBoard (
CARenderServerRenderDisplayof both displays) sees Main and the inner display change together, but costs 15 to 80 ms a sample and misses one- and two-frame events.
Fix. CALayerHost.zombificationMode = kCALayerHostZombificationModeAlways
on the compositor's hosts: a host whose context the render server briefly
finds nothing to draw for keeps that context's last frame. The same
sequence then flashed in 3 of 19 runs (one event each). Setting it on every
CALayerHost SpringBoard makes (its scene hosts too) was no better (4 of
10), so only the compositor's hosts get it. Going home, the app switcher,
rotation and folding show no stale content (the switcher's blank snapshot
cards are the same without it).
Left. The remaining flash is still a fully black frame at an app launch. Likely, but not shown, it is SpringBoard's new scene host for the launching app drawing nothing for its first frame when the primary display renders it, the same first-frame blank the compositor's own hosts have; there is nothing to keep for a new host.
2026-10-29 afternoon: the panel shows the inner display's own frames
Zombification (above) hid most black frames; the user wanted the cause gone, and then also saw small red blocks beside a launching app's icon and ghosting Liquid Glass behind Notification Center (both with an intermediate portal build). The cause, found from data rather than screenshots:
A context has one host. CA::Render::LayerHost::retain_hosted_context
looks the hosted context up and, when it already has a host (its +0x1d8),
detaches that host (clears its +0x58/+0x60, flags the old hosting
context 0x2000 for re-resolution), takes the context, and rebinds it to the
new host's display (Context::set_display_mask/set_display_id from the
hosting context's +0x2a8/+0x2ac; set_display_mask also sends the owning
client a Mach message). Every window of an inner scene is already hosted in
the inner display's root scene window, so one CALayerHost per window on the
primary display meant two hosts per context taking it from each other: on
SpringBoard's commits during transitions the inner root took the windows
back, and the panel showed those windows empty for a frame or two. A
diagnostic build with a red compositor root showed exactly that: the holes
were red and per window (only the Dock left, or icons without the wallpaper
and widget contents), never SpringBoard's own layers of a window still held.
It also explains why re-setting a host's frame or zPosition blanked hosted
windows for a frame (the host commit re-ran the takeover).
Ruled out on the way: host layer changes (traced: none at the flashes);
SpringBoard fences (bursts do not align with flashes once both clocks are
aligned by a white calibration frame); GPU load of the inner display (powered
off, flashes stayed); copyDisplayTimingsFromDisplay:; asynchronous hosts.
The inner display's update rate did move the rate (30 Hz 1/5 runs, 60 Hz ~5/7,
120 Hz 5/5) because it paces the commits that trigger the takeovers. A
cross-display CAPortalLayer of the inner root scene window takes nothing
over and removed the window holes, but everything that depends on the display
it is drawn for broke instead: SpringBoard's own portals (app zoom, switcher)
left holes and a black first frame going home (setting crossDisplay on them
helped only from init, which aborts SpringBoard when a portal is made off
the main thread: CA_ABORT_ON_NON_MAIN_THREAD_TRANSACTION_PUSH; five aborts
sent the next boot into Device Recovery), and backdrops sampled the primary
display (ghosting glass). The render server's clone (addClone:options:, every
option combination, with the LCD forced on) puts only black frames on this
paravirtual panel; CARenderServerCaptureDisplay of the virtual display
returns nothing.
The fix: on each primary frame SpringBoard has the render server draw the
inner display into a surface (CARenderServerRenderDisplay(0, "VPhone Duo Inner", surface, 0, 0) on a serial queue, about 3.5 ms) and shows it in a
layer at the top of Main's root scene window, turned -90 degrees; three
surfaces rotate so the render server never draws into the one shown or the
one shown before. The panel shows the inner display's own frame, one primary
frame late, with its backdrops, portals and hosts as the inner display draws
them; nothing is hosted twice, so nothing is rebound. Measured over 12 launch
sequences and 3 home/Dock runs: no black or partial frames, 0-6 transient
tiles per run against 12-23 with the portal; scrolling updates at the same
rate; taps on the inner display and the guest screenshot are unchanged.
Test trap, not a display bug: after a SpringBoard or backboardd restart,
touches vphoned injects (input.touch/input.tap, and so the host's clicks
within 32 points of an edge: swipe home, Control Center, Notification Center,
and the Dock at the usual window size) no longer reach SpringBoard until the
guest reboots; the host's VZ taps keep working. Seen with the bundle's own
build as well. Not yet explained.
2026-10-29 evening: the panel is the cover's
Main (the paravirtual panel) had been sized for the inner display,
2007×2853 (669×951 points), since the single-display design, where SpringBoard
laid the inner content out on Main itself (VPhoneBootCommand replaced the
machine's 1398×2034 screen with iPhone19_4InnerScreen on every boot). With
the two-display path that reason is gone: the inner display is its own
2853×2007 virtual display, and Main is the cover. A cover laid out on
669×951 points looked like a second inner display, not the Duo's outer one.
vphone-vm now boots the machine's own screen, 1398×2034 at 3x: the cover is 466×678 points with its native layout (the right-side Dock and status column are the Duo's outer-display design). Open, the compositor turns the inner display upright and fills the panel with it: 0.697 across and 0.713 along, about 2% tighter across than along. A first version kept the proportions and left 23 px black bars along the long sides, which the user did not want; with the fill, the digitizer (which spans the panel) maps onto the whole inner display, so backboardd's touch turn needs no rectangle of its own.
Measured on the test VM (bundle built from this change):
- Taps logged in SpringBoard land at the expected points: inner, panel pixel (X, Y) of the landscape screenshot at (X × 951 / 2034, Y × 669 / 1398) points for the centre and four corners (no bars); cover, at (X / 3, Y / 3).
- Guest screenshots match the VM window at offset (0, 0) in all four inner and three cover orientations (cover tested with Safari, which rotates on iPhone).
- Three launch-sequence captures: no black frames, 2-5 transient tiles.
2026-10-29 night: the inner display at 120 Hz
Open, the VM window counted 60 fps while the cover (the paravirtual panel, with the ProMotion patch) runs at 120. Two caps, one in each process:
- backboardd. A
CAWindowServerVirtualDisplaygets one 60 Hz mode whateverkCAVirtualDisplayUpdateRatesays.VirtualDisplay::refresh_rate()uses the update rate only while the display has no current mode; with one, it takes the mode's 16.16 rate.heartbeat_rate()is that mode's period divided by the integerkCAVirtualDisplayMinimumFrameDuration(default 1). Clients readCADisplay.refreshRate(the mode), andUIScreen.maximumFramesPerSecondis its inverse, so every app on the inner display was capped at 60 (UpdateRate 120: the display ticked faster, apps still saw 60; UpdateRate 60 + MinimumFrameDuration 2: heartbeat 1/120, refresh still 1/60). The display's modes can be replaced with-[CAWindowServerDisplay setDigitalModes:withTimings:](VirtualDisplay::set_digital_modes), which takes the framebuffer timing format: timings withHorizontalAttributes/VerticalAttributes{Active}andVerticalAttributes.PreciseSyncRate(16.16, default0x3c0000= 60), optionalScore, and color modes (PixelEncoding0,Depth≤ 8,Colorimetry1/10/16,EOTF0,DynamicRange0). A mode is preferred only when its score is above 0 (Colorimetry 16 adds 2, plain RGB nothing); with no preferred mode the display comes up 0×0 and SpringBoard aborts in-[SBMainDisplayConfigurationTransformer transformDisplayConfiguration:]. FoldDisplay now sets one timing at 120 (2853×2007 when this was measured, 2007×2853 since the inner display took the hardware's shape, below), with Colorimetry 1 and Score 1: the inner mode readsRGB_8bitlike the panel's. - SpringBoard. The compositor's display link asks for the panel's
maximumFramesPerSecond(120 with the ProMotion patch, else 60).
backboardd cannot read the panel's rate while it adds the inner display: the
primary's rate is not set yet during display detection, and asking
CADisplay there blocks backboardd (the inner display never appeared).
Measured on duospike-inner (iOS 27.1, Settings open, 3 s windows):
Idle CPU on the home screen, three 10 s samples each: 60 Hz build SpringBoard 6.9-7.9%, backboardd 37.8-45.6%; 120 Hz build 9.3-10.4% and 50.3-51.3%.
Window captures (ScreenCaptureKit) cannot judge 120 Hz here: the cover, which
is natively 120, measures the same 40-55 changes per second as the inner
display, because the host composites the window at its own rate. Use guest
display links, or the host's lastSubmittedTime poller from the vGPU notes.
Expanded Now Playing corners (local diagnostic, 2026-10-29)
On iPhone-Duo (27.1 24A94425), the unfolded expanded Now Playing panel
provided by Broadcasts has a rectangular outer black background while its
artwork remains rounded. A read-only UIKit probe reports 59 points for the
cover's _displayCornerRadius and _displayCornerRadiusIgnoringZoom.
Constructing a private, unregistered UIScreen from the inner CADisplay's
FBSDisplayConfiguration reports zero for both getters and _isEmbeddedScreen
false. This daemon-side object does not include SpringBoard's display adapter,
so it establishes the native virtual-display default, not the exact live
SpringBoard screen state.
A local FoldDisplayAdapter experiment intercepts those two getter selectors
(ABI checked as d16@0:8), matches only the recorded inner display identity,
and substitutes the main screen's original native value only when the inner
value is zero. A nonzero native inner value and every other display pass
through. Limited logging records native/main/effective values in SpringBoard.
No device-tree, MobileGestalt, firmware or host security setting is changed.
The original source is saved outside the source tree as
.vphone-setup/FoldDisplayAdapter.before-corners.m; previous environment
bundle 2.14.0-local.e1ece920 remains available for rollback.
Validation and visual outcome are recorded below after deployment.
Validated: Release VPhone build passed without warnings/errors. Installed
local bundle 2.14.0-local.232df3b7, updated the existing guest environment,
and cleanly restarted (vphoned connected in four seconds). Actual SpringBoard
logs now confirm _displayCornerRadius native=0.00 main=59.00 effective=59.00
for the inner screen, so the earlier daemon probe matches the live symptom.
The user reproduced the Broadcasts panel and confirmed “that's fixed now.”
Previous bundle retained for rollback; no additional change is pending.
2026-10-29 night: folds go through SpringBoard's display tool service
Xcode 27's iPhone Duo simulator has no hinge: CoreSimulator gives it two real
displays (screenID 1 cover, 3 inner), and SpringBoard folds itself through
its display tool service, the one Apple's internal sbdisplaytool / suiatool swap drives. The device SpringBoard has the same service
(com.apple.springboard.sbdisplay in BSServiceDomains under
com.apple.frontboard), so display.posture's position now uses it instead
of the test-automation override plus a virtual hinge sweep. Measured on a
test VM (iPhone19,4 27.1, 24A94425) with the two-display path:
-[SBSDisplayToolService swapDisplayWithSettings:completion:]takes{q; d duration; q deviceOrientation; q profileOverride; B wait; q targetMode; d targetHingePosition; d}(v88@0:8{?=qdqqBqdd}16@?80). Target mode 1 sweeps to an absolute position (0 closed, 1 open flat); duration < 0 is the per-direction default (0.391 s open, 0.420 s close); profile 1 fade, 2 continuous (.continuousLive), 0 SpringBoard's own. The server checkscom.apple.springboard.sbdisplay.service(read from the simulator SpringBoard's swap handler); without it SpringBoard logs "Insufficient authorization" and never answers. The completion block's arguments are not objects: the client forwards a raw register, and retaining it as anNSErrorPAC-faults.- SpringBoard takes a 10 s test lock, sweeps its own pose (
absoluteSweeping 0.0 -> 180.0 over 0.39s), resolvesFullyClosed→FoldingDual(or back) as the sweep crosses the boundary, and answers aftercompleted/idle, about 1.2–1.6 s after the request. Book (100°) from closed swaps to the inner panel; book from open does not. - After the lock SpringBoard does not return to the override; it applies the
next override against its current pose, so a later
hingerequest equal to the old override still lands. The override does reach SpringBoard under the lock, and it only means 0, 90 or 180°: setting it after a book sweep slid the pose from 100° to 90° and started a transition toward the cover, so vphoned leaves it alone after a SpringBoard sweep. -[SBSTestAutomationService getDevicePose]does not follow these sweeps (it kept reading open-flat after a sweep to closed); the replayed posture takes the hinge state from the completed request.- Closing with the continuous profile is an interactive transition: it is
performed while the inner display still holds the primary content and
swaps it as it finishes. FoldDisplay re-read the panel's owner only when a
transition was performed, so the panel kept showing the inner display,
which SpringBoard keeps lit (and black) for 3 s after a swap. Ownership is
now re-read on
-[SpringBoard displayContentModeCoordinator: handleSwapTransitionFromPrimaryWindowScene:toPrimaryWindowScene: withContinuationLockProvider:]too. - Folding from the lock screen or sleep lights the arriving panel's lock screen, which sleeps again after the lock screen's short idle time (about 7 s), as on hardware.
- DeviceHub (Xcode 27.1) draws a Duo guest as a 3D V68 model from CoreDevice's
device motion state, which needed a
CMDeviceStateManagerstand-in indtdeviceinfod. Details:Research/Guest/devicehub_screen_viewing.md, "iPhone Duo: the fold". - One posture request runs at a time. SpringBoard answers a swap it refuses ("rejecting swap request — system is not idle") at once and the same way as a finished one, so a reply under 0.3 s for a sweep that had to move counts as a refusal and is retried every 0.25 s for 10 s plus the duration.
2026-10-29 late: the inner display takes the hardware's shape
DeviceHub shows its pose controls only for an integrated inner display with
its own stream, and draws that stream on the inner panel as the panel's
native framebuffer. The landscape 2853×2007 inner render display therefore
came out pillarboxed through the mirror and sideways through a per-display
stream. FoldDisplay now publishes it the way the simulator's inner
CADisplay is: a portrait 2007×2853 framebuffer at 3×, with
DisplayRotation 270, DeviceSurfaceType 2, chrome phone14 and corner
radius 55 in its extended properties. SpringBoard lays the inner UI out at
interface orientation 90 and rotates it into the framebuffer, the
compositor draws the inner display's frames onto the panel without a turn,
and the 120 Hz timing is 2007×2853. The digitizer needs no turn either. The
clone redirect from the first DeviceHub fix is gone.
display.rotation's inner map (0 landscape-left, 90 portrait, 180
landscape-right, from pose orientations 3, 1, 4) still holds on the portrait
framebuffer: measured on sbdisplay-duo, pose orientation 1 gives interface
90, 3 gives 0 and 4 gives 180.
The host window follows folds it did not ask for (DeviceHub's, another
client's): display.orientation carries fold_angle, the virtual hinge's
settled angle, and the window's once-a-second orientation poll moves its
fold chrome to the nearest of closed, book and open while none of its own
fold requests is in flight.
2026-10-30: CoreMotion, MobileGestalt displays and the book angle, against Apple's simulator
Apple's iPhone Duo simulator (Xcode 27.1, iOS 27.1 24A94425, device type
iPhone-Duo, two integrated displays: LCD 1398×2034 surface type 1 and
LCD-1 2007×2853 rotation 270 surface type 2) was probed over lldb in every
posture, through SpringBoard's own display-swap coordinator (the internal
suiatool path), and the same calls were run on a two-display guest
(duobase-01) through the guest's debugserver. The harness and the table are
in Research/Guest/Evidence/duo-sim-baseline/ (BASELINE.md). The
simulator's fold tuning settings and MobileGestalt answers are in
iphone_duo_simulator_reference.md. Three of the differences had one cause each:
- MobileGestalt saw one display. libMobileGestalt computes
MGGetLogicalDeviceDisplayCountandDisplayExtendedPropertieslive from the children ofIODeviceTree:/productthat carrysurface-type(display0,display1in DeviceTree.v68ap); vphone600's tree has none, so the count fell back to 1 and the answer to nil (its fallback needsMainScreenScale, also nil).preboot-cfw-devicetree_foldable_displayscopies the board's two nodes. QuartzCore gives a hardware display its entry when it builds it; the inner virtual display gets its own from FoldDisplay, with a physical size (kCAVirtualDisplayPhysicalWidth/Height, 460 ppi) sopixelsPerInchis no longer 0. - CoreMotion had no pose managers. CoreMotion picks its motion
capabilities from the product-id token (
MGIsDeviceOfTypeinCLPlatformInfo::queryEmbeddedSystemHardware; V68 → hardware 0xC6, angle bit 38 and device-state bit 13). vphone600's token is no known product, and withIsVirtualDevicethe guest became hardware 1 with no capabilities. SpringBoard'sSBFDevicePoseProvidercreates_cmAngleManagerand_cmMagicPoseManageronly when both report available and FrontBoard has two always-connected displays — the second condition the two-display path already met. The product-id patch, kept off since the single-display crash loop, is now on. - Book read 90°. The test-automation override derives the angle from the
hinge state. With the token, locationd starts
CLDeviceStateRelay, Apple's VM path: it subscribes to host controls (primary usage0xFF61/0x5B) and turnshinge-slider-control/orientation-picker-controldictionaries (vendor-defined events,IOCFUnserializeWithSize, at most 256 bytes) intoCoreMotion_DeviceStateRelay_Craneangle events (phase 2, private-data progress) and_DeviceStatereports. libvphonehinge now publishes those host controls instead of its own0xFF0C/0x21service (whose phase-0 events CoreMotion drops), and vphoned stops setting the override when CoreMotion has the capability. SpringBoard then reads book at 99.444° (position 0.552; the simulator's synthetic 100° is 0.556).
One trap on the way: after the cover had gone dark in closed, a closed→book
sweep left the pose provider at 0° although CoreMotion delivered every angle
(breakpoints: CLAngleNotifier::onIoHidEvent 21 hits, _handleAngle: 19).
The provider commits a pose in motion only on ticks of its UIUpdateLink,
bound to SB-main, and a dark display produces none. On hardware the hinge
sensor's wake flag lights the screen on an unfold; the relay always sends 0
there. vphoned lights the screen before a sweep out of closed
(SBSUndimScreen, the side button when SpringBoard refuses it), 3/3 after.
Limits: the host controls' orientation picker has no upside-down, so the cover's 180 and the inner panel's 270 are refused through CoreMotion. The cover's corner radii are Apple's tree value [5, 5, 59, 59]; the simulator's device type says [8, 8, 59, 59].
Reveal and validation on a new guest: device.input_devices lists
VPhone Host Controls and both CoreMotion_DeviceStateRelay_* services;
display.posture replies source: coremotion; in SpringBoard,
MGGetLogicalDeviceDisplayCount() is 2, +[CMAngleManager isAvailable] is
YES and SBFDevicePoseProvider's description reads .partiallyOpen at
~99.4° six seconds after {"hinge":"partially-open","position":"book"}.
2026-10-30: the panel hosts the inner display's own pages
Open, the guest lagged badly. A spindump of backboardd on an idle Home Screen
(iOS 27.1, M5 Pro host, no DeviceHub viewer) had the render server thread in
_XRenderDisplaySurface → VirtualServer::render_surface → ShadowNode::apply → blur_surface → … -[MTLCommandBuffer waitUntilCompleted] in 116 of 125
samples, SpringBoard's inner-panel queue inside CARenderServerRenderDisplay
in 118 of 126, and the inner display's own VirtualServer thread waiting on
the render server's lock. The compositor above re-rendered the whole inner
display, blurs included, for every panel frame, while the inner display was
rendering itself as well: VirtualDisplay::begin_update /
finish_update_ draw only damage, into three pages in turn
(displayed_surface returns the last finished one).
Ruled out as a "did the inner display change" signal:
CARenderServerGetDirtyFrameCountByIndex and GetFrameCounterByIndex count
only the LCD (index 0); every other display, the inner one included, reads 0,
so gating the copy on it froze the panel on its first frame.
updateRequestCallback is a bare ret for non-IOMFB displays,
frameInfoCallback is IOMFB-only, CADisplay.seed is configuration, and
-acquireFrozenSurface is Display::acquire_frozen_surface, which returns
NULL unless an IOMFBDisplay overrides it.
The fix (FoldDisplayInnerPages.m): backboardd shows the inner display's
last finished page in a layer of a context of its own (remoteContextWithOptions:
with displayable NO and ignoresHitTest YES) and publishes the context id
as the state of the Darwin notification com.vphone.folddisplay.inner-pages;
SpringBoard's compositor layer is a CALayerHost of that context. A 120 Hz
timer on a backboardd queue reads the shown page under the two mutexes
acquireFrozenSurface takes and commits only when it changed. Nothing in
QuartzCore hands out the pages, so the path is read from QuartzCore itself and
checked at each step: the _impl type encoding (Server after the 64-byte
mutex); the instructions of -acquireFrozenSurface (server lock at +0x18,
C++ display at +0x60 on 27.1); the one vtable function with
displayed_surface's body (slot 63); and the one pair of
CA::WindowServer::IOSurface getters for an object and the 32-bit value after
it (iosurface() +0x128 and id() +0x130, slots 21 and 24), checked against
IOSurfaceGetID before first use. The page is 2853×2007 BGRA. If a step does
not match, backboardd publishes 0 and SpringBoard keeps the
CARenderServerRenderDisplay path; it switches when the state changes while
the panel is shown (tested both ways with vphoned notify.post).
Measured on a clone of duospike-inner (duopanel-perf), the same session, CPU
from processes.list over 8–10 s, panel rate from
CARenderServerGetFrameCounterByIndex(0, 0) every 2 s:
Without SpringBoard's 120 Hz display link the panel still reaches the inner display's rate: the render server composites the LCD on each commit of the hosted context. Checked on the same VM: Settings launch and taps, Home, unlock, fold closed (cover shows natively) and open again, backboardd restarts (a new context id each time, SpringBoard restarts with it); no backboardd or SpringBoard crash reports.
Trap seen while probing: the CA IOSurface object in a page is a C++ object,
not an IOSurfaceRef; CFRetain on it is a PAC failure that crash-loops
backboardd.
2026-10-30: the fold's fade to black on one panel
Opening out of closed showed 7 to 13 frames of black (about 0.12 to 0.2 s)
on the shared panel, measured on sbdisplay-duo (iPhone19,4 27.1, CoreMotion
relay path) from a ScreenCaptureKit stream of the VM window at 60 fps
(screencapture -l manages about 8 fps, and a screen-region recording misses
a covered window). The frames show SpringBoard's continuous fold effect: the
cover blurs, then fades to black, and after the swap the inner display fades
in from black. On hardware those are two screens. The panel itself never goes
off: the inner display is lit, then takes the panel, then the cover is turned
off. It is the same with SpringBoard drawing the inner display itself
(com.vphone.folddisplay.inner-pages state 0), so the pages path is not the
cause.
The effect is tuned by SBDeviceContinuousFoldInteractionSettings
(SpringBoard.framework, +[SBDeviceContinuousFoldInteractionDomain rootSettings]): fadeToBlackEnabled with outerFadeToBlackStart/End and
innerFadeToBlackStart/End, dimmingEnabled, cone, cylinder and seam dims,
blur radii. FoldDisplay turns fadeToBlackEnabled off in SpringBoard, so the
cover dims and blurs but does not go black. The panel also changes hands
inside SpringBoard's primary-swap callback, with the inner display's layer
added in the same transaction, instead of two main-queue hops later, when
SpringBoard had already hidden the cover's content. Opening now shows 0 to 9
dark frames, usually 2 to 6; closing shows none, as before.
Tried and dropped: handing the panel to the inner display when a fold toward
it starts (SBFDisplayContentModeTransitionContext's
initial/finalContentModeOfDisplayForWindowScene:), lighting it early: it
then showed its own fade in from black, 0.2 s; holding the inner layer back
until backboardd shows a new page: no change.
Two related host-window effects: the window follows a fold it did not ask for
(DeviceHub, a guest-side request) from display.orientation's fold_angle,
which it now reads four times a second on a foldable guest (once before),
taking a fold once two reads agree: through the relay the hinge follows
DeviceHub's slider as it animates, and taking every read reshaped the window
at each step of a sweep. The cover drawn in the window's open shape,
sideways, now lasts a fraction of the half second it did. A fold while the screen sleeps lights it, and the
panel's first 0.2 s are its last frame from before sleep.
The fade to black stays for a fold that starts with the panel off or lit
less than a second ago. A fold made while the screen sleeps (DeviceHub, a
guest-side request) runs only as the screen comes on, since SpringBoard's
pose provider commits on display frames; without the fade, the effect's
carried-over inner content (its Home Screen, sideways on the cover) showed
for about half a second after the wake. And coming on, the panel first shows
its last frame from before it went off (the unlocked Home Screen on a plain
wake), which nothing in the guest replaces: SpringBoard stops compositing
before it turns the panel off, and neither -[CAWindowServerDisplay presentSurface:withOptions:] nor acquireFrozenSurface reached the shown
surface (willUnblank never runs: the panel goes off by display state). So
display.orientation reports screen_off, and the VM window covers the
panel with black while it is off and for 0.3 s after it comes on. Both wakes
now go from black to the lock screen (60 fps capture).
2026-10-31: the inner display's point scale, for every process
Issue #784: WebDriverAgent saw the inner display at scale 2 (logical
1426.5×1003.5) and captured it at 1x in a corner, and vphoned's
display.modes said pointScale 1. Only SpringBoard saw 3, through the
adapter's -[CADisplay pointScale] answer; applications took 3 from
SpringBoard's display configuration. Every other process (XCTest's, vphoned)
read the render display itself, which a virtual display creates at 1. The
raw scale also showed in SpringBoard's own mode:
FBSDisplayMode 669x951@3x (2007x2853/2(3.0)).
Where the value lives (iOS 27.1 QuartzCore, 24A94425):
- A client's
-[CADisplay pointScale]is the published display info's field 0x310 (CA::Display::Display::update), whichCA::WindowServer::Display::build_display_info_datacopies from the server display's field 0x94.Display's constructor sets 1,AppleDisplay's takes the panel's;kCAVirtualDisplay*options have no scale. - The only other writer is
Server::set_display_properties, reached by-[CADisplay setDisplayProperties:]with-[CADisplayProperties setPointScale:](update mask 0x20, 1 to 4), which republishes the info._CASSetDisplayPropertiesclears mask bits 0x10 and 0x20 unless the caller's audit flags have bit 1,com.apple.QuartzCore.displayable-contextinentitlements_for_audit_token's table. Sent from SpringBoard it changed nothing; from backboardd it took. - A client keeps the scale it first read: a running Settings and vphoned still said 1 after the change, a relaunched Settings 3.
So backboardd sends pointScale 3 for the inner display as soon as its
render server answers (vpSetInnerDisplayPointScale, on the main queue after
_detectDisplays, since a CADisplay call from inside that never returns), a
second after the display is added and before applications start. SpringBoard
is already running by then (its adapter logs a second before the display is
added) and reads the inner display at 1, so the adapter's answer stays. A
build without it came up after a cold boot on the Trust alert over a bare
backdrop with the status column on its side and no Home Screen, twice; the
same machine on the previous bundle reached the Home Screen (seen on
duoscale-784).
Measured on sbdisplay-duo after a backboardd restart and after a cold boot
with the new bundle: vphoned's display.modes gives the inner display
pointScale 3 (display type still 3, virtual: no property sets it); a fresh
Settings has CADisplay.pointScale 3, UIScreen 951×669 at scale 3 and
FBSDisplayMode 669x951@3x (2007x2853/3), and the inner CADisplayMode's
preferredScale is 3 (the empty Wireless displays' is 2). testmanagerd
reports that value as XCUIScreen.scale (found on duoscale-784). The Home
Screen on both panels draws as before and taps open apps on both. An
application started while the device is closed lists only the cover's
UIScreen, and one started while open lists both, in the same boot. Not
run: WebDriverAgent itself, and #784's secondary points (hingeAngle POST,
and the empty Wireless displays, type 2 at 0×0, that WDA lists at
1408×2048 @2).
2026-11-02: the inner display's type, and XCTest's screenshot of it
After #788, WebDriverAgent's screenshot of the inner display (display 7,
currentDisplayId=7) still came back 2006×2852 portrait with the landscape
Home Screen upright in the top 2006 pixels and the rest black (PR #788
comment, WDA 16.17.6). The cover's screenshot was right.
What Apple's devices return for the same XCTest request (XCUIScreen screenshot → testmanagerd's ScreenshotProvider, a small UI test that
captures every XCUIScreen with 2007×2853 pixel bounds):
WDA applies the tag, so hardware gives 2852×2006 and the guest gave
isaac's cropped portrait image. The odd-to-even size is testmanagerd's own
(its buffer callback rounds 2007×2853 down). On the real Duo, display 2 is
the inner panel; set from XCTest, XCUIDevice.orientation never turns the
inner panel (it stays portrait-shaped face up), so only a phone held
landscape shows the case.
How testmanagerd renders a screen (Xcode 27.2 DDI, iOS 27.1 UIKit):
ScreenshotProvidercalls_UIRenderDisplaywithsrc-screen=+[UIScreen _screenWithIntegerDisplayID:],dst-scale= itsscale,src-rect= itsbounds, nosrc-orientation. The destination issrc-rect× scale;_UISnapshotContainerTransformturns only by the angle betweensrc-orientation(1 when absent) and portrait, so it is the identity here, and the render isCARenderServerSnapshotof the display with a 3× scale.- The tag is
-[XCAXManager_iOS systemUIOrientation]: AX attribute 1503 of the system app (attribute 1001 of the system-wide element), 3 → EXIF 8, 4 → 6, anything else 1. It was right on the guest. CARenderServerSnapshotof the inner display from SpringBoard with those options gave the hardware picture on the guest too, so the render path was fine; the difference was the screen's bounds.
Inside testmanagerd on the guest (lldb during a held XCTest session),
_screenWithIntegerDisplayID:7 had bounds and _referenceBounds 951×669,
and its configuration Unknown[7-1]; mode: "951x669@3x 120Hz" with
FBSDisplayMode 951x669@3x (2007x2853/3r270). SpringBoard's configuration
of the same display has no rotation (the simulator's never rotates). The
rotation comes from FrontBoardServices (27.1):
-[FBSDisplayConfiguration _initWithImmutableDisplay:originalDisplay: isMainDisplay:assertIfInvalid:]sets the mode's rotation toFBSDisplayRotationFromCADisplayRotation(nativeOrientation), and when the identity is notisAlwaysConnected, minus that ofcurrentOrientation(mod 4). The inner display is native rot0 and current rot90 in landscape, so a not-always-connected identity gets r270 and landscape bounds._FBSCADisplayToDisplayTypesderives always-connected from-[CADisplay displayType]: 0 → always connected, UIKit-main-like, connection 0; 1 → always connected only if main; 2 and 3 → not always connected, connection type 2 or 3; 4 → always connected, connection 0. The identity type comes from the tag (65536 → 7,Unknown).
The guest's inner display is a CAWindowServerVirtualDisplay, so every
client reads displayType 3; the simulator's and the real Duo's inner
panel is type 0. SpringBoard was right only because its adapter answers 0
(4 while FrontBoard classifies the display). testmanagerd builds its own
configuration from CADisplay, and so did vphoned's display.modes, which
reported type 3 (#784).
Where the type lives (iOS 27.1 QuartzCore, 24A94425):
-[CAWindowServerVirtualDisplay initWithOptions:]sets bits 10–12 of the C++ display's halfword at +0x2c0 to 3 (and #0xffffefff; orr #0xc00), unconditionally; nokCAVirtualDisplay*option sets a type.-[CAWindowServerDisplay displayType]isldr x8, [x0, #8](_impl);cbz;ldr x8, [x8, #0x40](server);ldr x8, [x8, #0x60](display);ldrh w8, [x8, #0x2c0];ubfx w8, w8, #10, #3.Display::build_display_info_datacopies the raw bits to the info's +0x358;Display::publish_display_info_statecopies the info into the display's shared memory (+0x360, a sequence counter then the data) that clients map. It runs frompost_display_changed,set_all_modes,set_display_propertiesandset_clone_master, all inside QuartzCore, so the published copy cannot be corrected after each publish.- The server itself reads the field:
Server's constructor (callbacks for type 0 or not),Display::allocate_surface(surface flags for type 0),Renderer::renderandMetalContext::begin_rendering(type-0 branches),VirtualServer::copy_display_timings(of a source server that is not virtual),render_for_timeandsupports_throttle(type 1), andupdate_clones(type 2).
So backboardd clears the field once the render server runs, just before it
sends the inner display's point scale (vpMarkInnerDisplayBuiltIn); that
setDisplayProperties: publishes the type with the scale. The Server was
built for type 3 and stays so. The offsets and the bit range are read from
-[CAWindowServerDisplay displayType]'s instructions, the field is cleared
with a compare-and-swap (other flags share the halfword), and the getter
must answer 3 before and answers 0 after; if the getter's shape differs,
the display stays virtual. A running client keeps the type it read, as
with the scale.
Tried live first, on sbdisplay-duo over lldb in backboardd (field 0xd01 → 0x101, then a point-scale publish): the panel kept drawing, a fresh testmanagerd captured the inner display as 2006×2852 portrait with the UI turned and filling it, tagged EXIF 6, as on hardware, and the running vphoned still said type 3.
Measured on sbdisplay-duo (iPhone19,4, iOS 27.1) with the new bundle,
after vm set-bundle --update-environment and a cold boot: backboardd logs
inner display: type 0 (display +0x2c0 bits 10-12) just before point scale 3 sent; vphoned's display.modes gives the inner display
displayType 0 and pointScale 3, mode 2007×2853 at 120 Hz; the inner
pages context is published as before. XCTest's capture of display 7 open
and landscape is 2006×2852 portrait with the UI turned and filling it,
imageOrientation 3 (EXIF 6), and with the inner panel portrait-shaped
(display.rotation 0) 2006×2852 upright, imageOrientation 0: both as the
real Duo. Taps open apps on the inner panel and on the cover after a close,
and SpringBoard and backboardd kept their boot pids with no new crash
report.
WebDriverAgent 16.17.7 (appium-ios, the same native code as 16.17.6) on the
same guest: /wda/screens lists display 7 (2007×2853 @3) and display 1
(1398×2034 @3), both traits 1 (the inner display was 0 before), and
isMain is the lit panel; /wda/screen for display 7 is 951×669 @3. Its
screenshot with currentDisplayId 7, open and landscape, is 2852×2006 with
the whole UI; forcing screenshotOrientation keeps that canvas (WDA sizes
the canvas from the image's own orientation and only redraws). Closed,
display 1 is 1398×2034 (the cover's Home Screen is portrait-only).
With two integrated displays, testmanagerd now follows the active one, as
on the real Duo: when hasMultipleIntegratedDisplays, it watches the
default main display's FBSDisplayLayoutMonitor and takes the layout's
display hardwareIdentifier as active, and a capture of the other display
fails with "Cannot screenshot inactive display" (XCTDaemon
ScreenshotProvider error 9). The real Duo refuses the dark cover the same
way. Before, with one integrated display, the guest captured both panels
in either posture.
A process running across a backboardd restart keeps the displays it read,
and the restarted backboardd creates the inner display under a new unique
id: vphoned, dtdeviceinfod and a running testmanagerd still named the old
id, so testmanagerd held the cover active while open and refused the inner
display. Restarting testmanagerd (and dtdeviceinfod) cleared it; after the
backboardd restart itself, backboardd logged type 0 again and a fresh
vphoned read type 0. A SpringBoard restart changes nothing here.
Also measured: DeviceHub streams the inner display; devicectl device info displays lists both as integrated, the inner one active while open,
and devicectl device capture screenshot of it gives the 2007×2853 native
buffer (dtscreencaptured rotates by current and native orientation, not by
type); backboardd idles at 1.5% of a core on a still Settings screen;
Settings' inner UIScreen is 951×669 @3, 120 fps, mode 669x951@3x (2007x2853/3), as before.
Not changed: WDA's POST /wda/device/hingeAngle still returns null with
the angle unchanged. WDA builds DeviceHub's hinge-slider payload (vendor
page 0xFF61, usage 0x5B, provider com.apple.Virtualization.VirtualMachines)
and dispatches it from its runner with IOHIDEventSystemClientDispatchEvent;
the guest's hinge relay follows the events DeviceHub's controls arrive in,
from dtuhidd's HID service, and nothing takes a client-dispatched one. Its
GET reads CMAngleManager and reports the guest's hinge (180 open). Taken
since; see "2026-11-02: WebDriverAgent's hinge POST".
2026-11-02: WebDriverAgent's hinge POST
WDA's POST /wda/device/hingeAngle (appium-ios 16.17.x,
XCUIDevice+FBHinge.m) builds DeviceHub's slider dictionary
{provider: com.apple.Virtualization.VirtualMachines, source: hinge-slider-control, type: range, value: <angle>}, IOKit
binary-serializes it, wraps it in IOHIDEventCreateVendorDefinedEvent
(page 0xFF61, usage 0x5B) and sends it from the runner with
IOHIDEventSystemClientCreate + IOHIDEventSystemClientDispatchEvent.
The dispatch returns nothing, so the endpoint answers WDA's success
(FBResponseWithOK, value: null) whatever happened; null is success
here, and a refused argument comes back as an error object.
Where it went. backboardd's HID server refused it, every time:
The MIG stub _Xio_hideventsystem_dispatch_event calls
_io_hideventsystem_dispatch_event(port, data, length) (IOKit, exported).
That function turns the out-of-line buffer into a CFData
(_IOHIDUnserializeAndVMDeallocWithTypeID, freeing the buffer), finds the
connection, and for any connection type but 0 requires bit 3 of
IOHIDEventSystemConnectionGetEntitlements before
IOHIDEventCreateWithDataInternal and _IOHIDEventSystemDispatchEvent.
WDA's connection is "Rate Controlled" with entitlements 0x0. Even
dispatched, the event would have had no service, and locationd's relay
reads the host controls from services matching 0xFF61/0x5B. A runner
signed by a developer team cannot carry a private entitlement.
The buffer's formats (iOS 27.1 IOKit, 24A94425), all needed to read it:
- the buffer is a CFData in a binary property list
(
_IOHIDCreateBinaryData:CFPropertyListWrite, format 200), not an IOKit serialization;IOCFUnserializeBinaryreturns nil on it; - the CFData is the event in
IOHIDEventCreateDataInternal's form, read byIOHIDEventCreateWithDataInternal; the public pair passes format flag 1, the internal one 0; - the vendor payload inside is IOKit binary (WDA serializes with option 1,
as dtuhidd's DeviceHub events are), read with
IOCFUnserializeBinary.
What changed. libvphonehinge (backboardd, iPhone19,4 only) detours
_io_hideventsystem_dispatch_event from its constructor, before backboardd
serves clients. The detour decodes the buffer; an event that is a
host-control vendor event whose dictionary names the VM provider,
hinge-slider-control, range and a value of 0–180 is the hinge's: the
detour frees the buffer as the server would, answers KERN_SUCCESS, and
the hinge dispatches the event unchanged on its own 0xFF61/0x5B virtual
service from its queue. The server never sees it, so it neither refuses nor
logs the missing entitlement. Anything else, including the orientation
picker, goes to the server unchanged. From the hinge's service the event takes DeviceHub's path:
locationd's relay moves the hinge, and vphoned's unmatched monitor sees the
provider and follows it as a DeviceHub value (vp_hinge_note: stored
quietly, the screen lit when opening from closed). The hinge status
(VPhoneHingeStatus) gains client_dispatch (installed or why not) and
client_controls (values taken).
Ways not taken:
- Granting the entitlement bit to unentitled connections would let any app dispatch any event (touches, buttons).
- Swapping the routine in
_iohideventsystem_subsystem(found by the stub whoseBLtargets the exported function;blraaz, so a plain function pointer fits): the table is in the cache's__AUTH_CONST, andvm_protect(VM_PROT_COPY | READ | WRITE)there returnsKERN_PROTECTION_FAILURE. Text pages take the copy, as libmisfix's detour measured. - libmisfix's detour (
MISFix/MISFixDetour.c, now built into the hinge too) relocates the target's first four instructions. In an X1 cache the prologue ispacibsppc, which signs LR with its own address; moved, it signs for the trampoline and the function'sretabsppcfails. The guest runs the X1-lowered cache on hosts without PAuth_LR (this M5 Pro has none), where the prologue ispacibsp. The detour now refusesPACIASPPC,PACIBSPPC,PACNBIASPPCandPACNBIBSPPCinstead of copying them, so on an unlowered cache the hinge reportsHID server dispatch not detoured: a displaced instruction is PC-relativeand WDA's POST stays a no-op.
Measured on sbdisplay-duo (iPhone19,4, iOS 27.1 24A94425, product-id
patch on), the hinge library pushed with environment.install and
backboardd restarted, WDA 16.17.x from appium-ios:
vphoned stored each value quietly (VPhoneHingeAngle quiet: true, angle
as posted) and client_controls counted one per POST. DeviceHub (restarted
after the backboardd restart) still folds the guest from its own buttons:
Closed 0, Open 180, Partially Open 130, with client_controls unchanged,
since its values come through dtuhidd's virtual service, not a client
dispatch. After DeviceHub's, WDA's POST 0 and 180 turned DeviceHub's model
to the cover face and to the open inner panel. display.pose is the
test-automation snapshot and is not current on a relay guest; its hinge
field is not evidence here.
With the server bypassed for these events, a POST leaves only the
connection's Connection added and Connection removed lines in
backboardd's log, with no entitlement error; POST 120, 0 and 180 and
DeviceHub's Closed and Open were repeated with the same results. Checked
off-guest in make test-hinge: WDA's payload in the internal form inside a
binary plist is taken, and another usage page, no provider, the orientation
picker and values of -1, 181, a Boolean or a string are not.
2026-11-05: iOS 27.2, and WebDriverAgent's hinge GET after boot
Issue #821: on an iPhone Duo guest running iOS 27.2 (24B91020), WDA's
GET /wda/device/hingeAngle timed out (HTTP 500, 5 s each) for minutes
after boot, and again after a WDA restart, while VPhoneHingeStatus kept
dispatching. The first success returned 0° with the guest at 90°. On 27.1
the first GET answered in 1.4 s.
What the GET does (appium-webdriveragent 16.17.7,
XCUIDevice+FBHinge.m): a new CMAngleManager,
startAngleUpdatesToQueue:handler:, and a 5 s wait for the first callback
with isAngleValid. A new manager is handed the current angle when
CoreMotion has one; otherwise it waits for the next change. The probe used
here (HingeProbe, an ldid-signed app installed with apps.install)
repeats exactly that every 3 s and logs each read.
Reproduced on a new test VM (duo272-hinge, iPhone19,4 27.2, cloudOS
26.4 23E5207q, bundle 2.15.2 from upstream main), six cold boots with the
hinge stationary at 0° and at 100°, alternating:
Restarting locationd (processes.kill … "signal":"KILL", launchd starts
another) also made fresh reads valid within the next read, at 0° and at
100°, and they stayed valid for minutes with the hinge still. What fails
is the locationd that starts with the guest.
The 27.2 relay change. locationd's CMDeviceStateRelayManager (the
relay that turns the host controls, page 0xFF61 usage 0x5B, into
CoreMotion's hinge-angle service "Crane", 0xFF0C/0x21) gained an angle
interpolator in 27.2 (CMAngleInterpolator,
DeviceStateRelayInterpolationDuration, _snapToAngleUpdate:,
startUpdatesForPhysicalDevice:aopAngle:). Its _feedAngleUpdate: now
returns early when the value equals effectiveAngle ("Already at angle;
skipping interpolation"); 27.1's sets the angle and dispatches every time.
libvphonehinge resends the stationary angle every 5 s because the relay
never asks for one; on 27.2 that resend is a no-op, so whatever state the
boot left the relay's service in stays until the angle changes.
The Crane service does hold an event on a failing boot
(IOHIDServiceClientCopyEvent(…, kIOHIDEventTypeHingeAngle) from root:
phase Ended, timestamp a few seconds after boot, SenderID equal to the
service's registry ID), with the same shape as the event it holds once
reads work (where it is the move's last sample). A new client's copy is
rejected anyway; why it is not used was not established. After a move or
a locationd restart the copied event is used.
Startup deadlock, the reporter's 90° case. On one boot the hinge did
not recover from a move either. A spindump of that locationd showed its
AriHost queue blocked since 6 s after launch inside a location silo →
libICEClient → libARI dispatch_sync, and two root.default-qos threads
waiting on its turnstile: the iOS 27 startup deadlock
(Research/Guest/locationd_startup_deadlock.md) in the baseband client,
where locationServicesEnabled still answers, so vphoned's watchdog did
not see it. A restart recovered that one too. Its first reading after the
restart is the relay's reseeded 0°, the value the reporter saw.
A health check from vphoned does not work. The first fix had vphoned
make the GET's own read (a new CMAngleManager, 5 s) and restart locationd
when it got nothing. A daemon's manager is not handed the current angle
even when an app's is: from root it timed out twice and then caught a live
event, with the app's reads valid at the same time. vphoned kept restarting
healthy locationds (12 in 13 minutes). A copyEvent of the Crane service
cannot tell the states apart either (above).
First fix, since replaced (see the next section: it served only a
client already reading when the restart came). vphoned's locationd watchdog
(GuestLocationdWatchdog.swift) restarted the locationd that started with
the guest once, on iOS 27.2 or later with CoreMotion's foldable hinge
(vp_core_motion_hinge), about 30 s after boot. "Started with the guest"
was a start within 30 s of boot on the uptime clock
(ri_proc_start_abstime from proc_pid_rusage), because vphoned moves the
guest's wall clock to the Mac's after boot and p_starttime is wall time.
Measured with that fix, duo272-hinge, six more cold boots alternating 0° and 100°, the probe started at about +20 s and kept in front:
One restart per boot and no other; every read after the restart was valid. The first read after it is the relay's reseeded 0°, and the next resend of the virtual hinge moves it to the stationary angle. That probe was already running when the restart came, which is why it was served; a client started later was not (next section).
Not addressed: a read after the hinge moved can return an intermediate angle (100° read as 94.87–96.89°, 101° as 97.88°). The reporter sees the same on the Xcode 27.2 Duo simulator, so it is CoreMotion's.
The rest of the Duo on 27.2, same VM: restore (with the iBEC wait
below), CFW and first boot; display.posture closed / book / open five
times, with fold_angle 0 / 100 / 180 and the panel switching between the
cover's portrait and the inner display's landscape split view (Settings);
WDA's hinge POST from an unentitled app (FeatProbe, the same vendor event)
moving fold_angle to 120, 0 and 180; touch (input.tap dismissing the
pairing alert and opening Settings › General › About, which reads iOS 27.2,
iPhone Duo); Safari drawing on the open inner display; audio.state; and
the CIBarcode filters (QR generate, render and detect, Code128, Aztec,
PDF417) without system-cibarcode-cfw-arm64e_x1, which stays scoped to
27.1. DeviceHub was not tried.
Restoring this VM from Launchpad failed four times in a row at "Device did
not reconnect in recovery mode" (idevicerestore's 10 s wait after iBEC),
iBEC reaching recovery only after the wait had ended. Under a host load
average of 100–290 from other work, iBoot's serial lines came 0.3–0.6 s
apart; the fourth attempt, at a load of about 5, failed the same way, so
load is not the whole cause, and it was not found. A restore run by hand
(vphone-cli vm launch --dfu, 25 s, then vphone-cli restore) at that load
went through, iBEC in recovery within 2 s, and Launchpad's vm create --from installCFW finished from there.
2026-11-05: a hinge client started later, and VirtualAudio on 27.2
The reporter of #821 tested the fix above and found it served only a client
already reading when the restart came. With the guest stable for 130 s and
WDA started at +167 s, every GET timed out, while display.orientation read
fold_angle 180; a WDA restart on a warm guest failed the same way. The
probe above had been started at +20 s and kept running.
Reproduced on a recreated duo272-hinge (same IPSWs, bundle from upstream
main with the restart): one cold boot at 0°, nothing reading until +170 s,
then five new probe processes 12 s apart. All five timed out with no
callback; launchd runs was 2, so the restart had happened at about +30 s.
Why. On a VM, CoreMotion's angle notifier skips the physical hinge
device ("Skipping open for physical HID device on VM/Simulator") and has
nothing to copy an angle from, so a new CMAngleManager gets the angle only
from the next hinge-angle event the relay dispatches. 27.1's relay
dispatched one for every value, so libvphonehinge's stationary resend served
a new client within the resend period. 27.2's
-[CMDeviceStateRelayManager _feedAngleUpdate:] compares the value
(isEqual:, an NSNumber) with effectiveAngle (numberWithFloat: of the
interpolator's last output) and skips an equal one. The slider value
arrives through IOCFUnserializeWithSize, which has no reals, so a value
can only be a whole degree and a resend is always equal. The relay snaps
(_snapToAngleUpdate:, interpolateToAngle:duration:0) only once, at its
start. A restart therefore helps exactly one client: the one that is
subscribed when the reseeded 0° moves back to the real angle.
Fix. libvphonehinge also loads in locationd (SystemHook,
/usr/libexec/locationd), where on iPhone19,4 it replaces _feedAngleUpdate:
when the relay has 27.2's effectiveAngle and interpolator
(Hinge/VPhoneHingeRelayResend.h): a value equal to effectiveAngle goes to
the interpolator with no duration, which produces one event at that angle,
on the interpolator's own queue (_snapToAngleUpdate: itself asserts the
relay's other queue and cannot be called from the feed). Any other value
takes the relay's path. The stationary resend drops from 5 s to 2 s: at 5 s
the first read of a new client took 0.26–4.97 s, against WDA's 5 s limit.
vphoned's one-time restart is removed; the watchdog is as before #826.
Measured, cold boots, nothing reading until +170 s, then five new probe processes per boot:
The first boot of the 5 s round also hit the iOS 27 startup deadlock twice
(locationd … did not answer, killed it); the watchdog restarted it and the
hinge was served throughout. Idle, with the panel lit, locationd used 0.09 s
of CPU a minute.
VirtualAudio was off on 27.2. The "rest of the Duo" check above missed it:
both creates of this VM logged Patch site not found: virtualaudio: expected exactly 1 mute-set throw in the wrapper at 0x103ce0, found 0, and the five
VirtualAudio patches, which share one staged copy, were all left off.
audio.state answers either way. 27.2's set wrapper throws a
std::system_error built by a constructor call where 27.1 stored a
CAException's fields; the mute-set patch now takes both
(Research/Guest/virtualaudio_speaker_route_throws.md). With the five
applied, Safari played an MP3 stream for 29 s through the virtio sound
plugin (1 280 064 frames in, 0 bytes dropped, 7 starved writes on a loaded
host). "Campo absent; entitlement patch skipped" in the same log is
expected: 27.2 has no Campo.app, and PosterBoard and its extensions draw
the wallpaper.
This time Launchpad's vm create restored the VM without the iBEC
failure, at a host load average of about 20.