diff --git a/Package.swift b/Package.swift index 3779e11..8c43c78 100644 --- a/Package.swift +++ b/Package.swift @@ -27,5 +27,6 @@ let package = Package( ] ), .testTarget(name: "WindowBurnCoreTests", dependencies: ["WindowBurnCore"]), + .testTarget(name: "WindowBurnInputTests", dependencies: ["WindowBurn", "WindowBurnCore"]), ] ) diff --git a/README.md b/README.md index cc15b5b..f81aa59 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,20 @@ Development checks: ```bash swift format lint --strict --recursive Package.swift Sources Tests swift test +swift script/check-renderer-alpha.swift ./script/build_and_run.sh --verify ``` +To inspect capture fidelity and material rendering on a disposable native window: + +```bash +./script/build_and_run.sh --quality-review "$PWD/dist/quality-review" +``` + +This saves native/cover/replacement screenshots, pixel comparisons, and GPU timings without +closing your documents. See the [quality audit](docs/quality-audit.md) for the design, +reproduction steps, and remaining limitations. + To prepare a universal Developer ID-signed and notarized release locally: ```bash diff --git a/Sources/WindowBurn/AccessibilityWindowService.swift b/Sources/WindowBurn/AccessibilityWindowService.swift index 07c3646..a7d75a7 100644 --- a/Sources/WindowBurn/AccessibilityWindowService.swift +++ b/Sources/WindowBurn/AccessibilityWindowService.swift @@ -2,12 +2,13 @@ import AppKit import ApplicationServices import WindowBurnCore -struct AccessibleWindow { +// AX handles are immutable references; all mutable interaction state lives in the coordinator. +struct AccessibleWindow: @unchecked Sendable { let target: TargetWindow let element: AXUIElement } -struct AccessibleWindowControl { +struct AccessibleWindowControl: @unchecked Sendable { let window: AccessibleWindow let kind: WindowControlKind let button: AXUIElement @@ -339,7 +340,7 @@ enum AccessibilityWindowService { ) } - private static func window(matching target: TargetWindow) -> AccessibleWindow? { + static func window(matching target: TargetWindow) -> AccessibleWindow? { let application = AXUIElementCreateApplication(target.ownerPID) guard let windowValues = copyAttribute(kAXWindowsAttribute, from: application) @@ -364,6 +365,42 @@ enum AccessibilityWindowService { size: size ) } + return window(matching: target, among: windows) + } + + /// Enumerates an application's windows once for a resolver pass. Each AX read + /// uses the remaining pass budget, including reads on returned child handles. + static func preflightWindows( + ownerPID: pid_t, deadline: TimeInterval + ) -> [AccessibleWindow] { + let application = AXUIElementCreateApplication(ownerPID) + guard + let elements = copyAttribute(kAXWindowsAttribute, from: application, deadline: deadline) + as? [AXUIElement] + else { return [] } + + var windows: [AccessibleWindow] = [] + for element in elements { + guard ProcessInfo.processInfo.systemUptime < deadline else { break } + guard + let position = pointAttribute(kAXPositionAttribute, from: element, deadline: deadline), + let size = sizeAttribute(kAXSizeAttribute, from: element, deadline: deadline), + size.width > 1, size.height > 1 + else { continue } + let title = copyAttribute(kAXTitleAttribute, from: element, deadline: deadline) as? String + windows.append( + AccessibleWindow( + target: TargetWindow( + ownerPID: ownerPID, title: title, frame: CGRect(origin: position, size: size)), + element: element + )) + } + return windows + } + + static func window( + matching target: TargetWindow, among windows: [AccessibleWindow] + ) -> AccessibleWindow? { let candidates = windows.enumerated().map { index, window in WindowCandidate( id: UInt32(index), @@ -382,17 +419,54 @@ enum AccessibilityWindowService { return windows[Int(match.id)] } + /// Resolver-worker preflight; never called by the blocking mouse callback. + static func closeControl( + in window: AccessibleWindow, deadline: TimeInterval + ) -> (AccessibleWindowControl, CGRect)? { + guard + let value = copyAttribute( + kAXCloseButtonAttribute, from: window.element, deadline: deadline), + CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } + let button = unsafeDowncast(value, to: AXUIElement.self) + guard + (copyAttribute(kAXEnabledAttribute, from: button, deadline: deadline) as? Bool) != false, + let position = pointAttribute(kAXPositionAttribute, from: button, deadline: deadline), + let size = sizeAttribute(kAXSizeAttribute, from: button, deadline: deadline), + size.width > 0, size.height > 0 + else { return nil } + return ( + AccessibleWindowControl(window: window, kind: .close, button: button), + CGRect(origin: position, size: size) + ) + } + private static func copyAttribute( _ attribute: String, - from element: AXUIElement + from element: AXUIElement, + deadline: TimeInterval? = nil ) -> CFTypeRef? { + if let deadline { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0 else { return nil } + // AX timeouts apply only to this exact handle, not its children. Restore + // the global default before publishing handles used later for native actions. + AXUIElementSetMessagingTimeout(element, Float(min(0.05, remaining))) + } + defer { + if deadline != nil { AXUIElementSetMessagingTimeout(element, 0) } + } var value: CFTypeRef? let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) return result == .success ? value : nil } - private static func pointAttribute(_ attribute: String, from element: AXUIElement) -> CGPoint? { - guard let value = copyAttribute(attribute, from: element) else { return nil } + private static func pointAttribute( + _ attribute: String, from element: AXUIElement, deadline: TimeInterval? = nil + ) -> CGPoint? { + guard let value = copyAttribute(attribute, from: element, deadline: deadline) else { + return nil + } let axValue = unsafeDowncast(value, to: AXValue.self) guard AXValueGetType(axValue) == .cgPoint else { return nil } @@ -400,8 +474,12 @@ enum AccessibilityWindowService { return AXValueGetValue(axValue, .cgPoint, &point) ? point : nil } - private static func sizeAttribute(_ attribute: String, from element: AXUIElement) -> CGSize? { - guard let value = copyAttribute(attribute, from: element) else { return nil } + private static func sizeAttribute( + _ attribute: String, from element: AXUIElement, deadline: TimeInterval? = nil + ) -> CGSize? { + guard let value = copyAttribute(attribute, from: element, deadline: deadline) else { + return nil + } let axValue = unsafeDowncast(value, to: AXValue.self) guard AXValueGetType(axValue) == .cgSize else { return nil } diff --git a/Sources/WindowBurn/AppDelegate.swift b/Sources/WindowBurn/AppDelegate.swift index 2784498..a511838 100644 --- a/Sources/WindowBurn/AppDelegate.swift +++ b/Sources/WindowBurn/AppDelegate.swift @@ -1,12 +1,14 @@ import AppKit import Carbon.HIToolbox +import Metal import OSLog import WindowBurnCore @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "app") private let coordinator = BurnCoordinator() + private var qualityReview: QualityReviewController? private let torchCursor = TorchCursorController() private var burnHotKey: GlobalHotKey? private var torchHotKey: GlobalHotKey? @@ -19,14 +21,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var isSoakAndBurnModeEnabled = false func applicationDidFinishLaunching(_ notification: Notification) { + if let argumentIndex = CommandLine.arguments.firstIndex(of: "--quality-review"), + CommandLine.arguments.indices.contains(argumentIndex + 1) + { + let output = URL(fileURLWithPath: CommandLine.arguments[argumentIndex + 1], isDirectory: true) + let review = QualityReviewController() + qualityReview = review + Task { @MainActor in + do { + try await review.run(outputDirectory: output) + } catch { + try? FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + try? "FAILED: \(error.localizedDescription)".write( + to: output.appendingPathComponent("status.txt"), atomically: true, encoding: .utf8 + ) + logger.error("Quality review failed: \(error.localizedDescription, privacy: .public)") + } + NSApp.terminate(nil) + } + return + } installStatusItem() + // Prewarm effects before enabling interception on the dedicated input thread. + if let device = MTLCreateSystemDefaultDevice() { + do { + try BurnRenderer.prewarm(device: device) + } catch { + logger.error("Effect prewarm failed: \(error.localizedDescription, privacy: .public)") + } + } installHotKeys() + InputDiagnostics.start() + coordinator.excludedCaptureWindowIDs = { [weak self] in + self?.torchCursor.captureWindowIDs ?? [] + } coordinator.onSoakAndBurnPhaseChange = { [weak self] phase in self?.updateSoakAndBurnCursor(for: phase) } - coordinator.onSoakCaptureStateChange = { [weak self] isCapturing in - self?.torchCursor.setTemporarilyHidden(isCapturing) - } coordinator.onDestructiveCloseFailure = { [weak self] in self?.disableInteractiveModesAfterCloseFailure() } @@ -42,12 +73,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { "Window Burn is ready; accessibility=\(PermissionService.hasAccessibilityAccess), screenCapture=\(PermissionService.hasScreenCaptureAccess), inputMonitoring=\(PermissionService.hasInputMonitoringAccess)" ) if isDemoLaunch { - DispatchQueue.main.async { [weak self] in - self?.coordinator.showDemo() + Task { @MainActor [weak self] in + await self?.coordinator.showDemo() } } else if isSoakDemoLaunch { - DispatchQueue.main.async { [weak self] in - self?.coordinator.showSoakDemo() + Task { @MainActor [weak self] in + await self?.coordinator.showSoakDemo() } } else if isTorchLaunch { DispatchQueue.main.async { [weak self] in @@ -79,6 +110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { item.button?.toolTip = "Window Burn — burn ⌃⌥⌘B, torch ⌃⌥⌘F, soak & burn ⌃⌥⌘U" let menu = NSMenu() + menu.delegate = self let burnItem = NSMenuItem( title: "Burn & Close Front Window", action: #selector(burnFrontWindow), @@ -154,6 +186,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusItem = item } + func menuWillOpen(_ menu: NSMenu) { + windowControlInterceptor?.isMenuTracking = true + } + + func menuDidClose(_ menu: NSMenu) { + windowControlInterceptor?.isMenuTracking = false + } + private func installHotKeys() { do { burnHotKey = try GlobalHotKey( @@ -190,30 +230,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { closeHandler: { [weak self] control in self?.coordinator.interceptWindowControl(control) ?? false }, - torchHandler: { [weak self] location in - guard let self else { return false } - return torchCursor.withoutOverlay { - coordinator.interceptTorchClick(at: location) - } + torchHandler: { [weak self] location, window in + self?.coordinator.interceptTorchClick( + at: location, resolvedWindow: window + ) ?? false }, - soakAndBurnHandler: { [weak self] event, location in - guard let self else { return false } - switch event { - case .down: - torchCursor.beginPointerDrag(atQuartzPoint: location) - case .dragged: - torchCursor.move(toQuartzPoint: location) - case .up: - torchCursor.endPointerDrag(atQuartzPoint: location) - } - if case .down = event { - return torchCursor.withoutOverlay { - coordinator.interceptSoakAndBurn(event, at: location) - } - } - return coordinator.interceptSoakAndBurn(event, at: location) + soakAndBurnHandler: { [weak self] event, location, window in + self?.coordinator.interceptSoakAndBurn( + event, at: location, resolvedWindow: window + ) ?? false } ) + windowControlInterceptor?.interactionState = { [weak self] in + self?.coordinator.pointerInteractionState ?? ([], false) + } + torchCursor.pointerLocation = { [weak self] in + self?.windowControlInterceptor?.latestPointerLocation + } } catch { logger.error( "Mouse interception is unavailable: \(error.localizedDescription, privacy: .public)" @@ -226,7 +259,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func showDemo() { - coordinator.showDemo() + Task { await coordinator.showDemo() } } @objc private func toggleTorchMode() { diff --git a/Sources/WindowBurn/BurnCoordinator.swift b/Sources/WindowBurn/BurnCoordinator.swift index 408c0e0..341b972 100644 --- a/Sources/WindowBurn/BurnCoordinator.swift +++ b/Sources/WindowBurn/BurnCoordinator.swift @@ -33,8 +33,16 @@ final class BurnCoordinator { private var soakGeneration = 0 private var soakCloseTask: Task? var onSoakAndBurnPhaseChange: ((SoakAndBurnPhase) -> Void)? - var onSoakCaptureStateChange: ((Bool) -> Void)? var onDestructiveCloseFailure: (() -> Void)? + var excludedCaptureWindowIDs: (() -> Set)? + + private let soakSurfaceID = UUID() + var pointerInteractionState: (surfaces: [PointerTargetSnapshot.Region], canStart: Bool) { + if let soakCaptureFrame { + return ([.init(id: soakSurfaceID, frame: soakCaptureFrame)], false) + } + return (torchSessionRegistry.interactionRegions, !isBusy && !torchSessionRegistry.isAtCapacity) + } func burnFrontWindow() { guard !isBusy, torchSessions.isEmpty else { return } @@ -46,7 +54,8 @@ final class BurnCoordinator { do { let accessibleWindow = try AccessibilityWindowService.focusedWindow() let capturedWindow = try await WindowCaptureService.capture( - target: accessibleWindow.target + target: accessibleWindow.target, + excludingWindowIDs: excludedCaptureWindowIDs?() ?? [] ) let panelFrame = ScreenCoordinateConverter.appKitFrame( for: capturedWindow.captureFrame, @@ -55,18 +64,18 @@ final class BurnCoordinator { ) let profile = BurnProfile.random() - try overlay.present( + try await overlay.present( image: capturedWindow.image, + handoffImage: capturedWindow.handoffImage, shadowImage: capturedWindow.shadowImage, shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, startImmediately: false, - onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false } ) try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) - guard overlay.activateReplacementSurface() else { + guard try await overlay.activateReplacementSurface() else { throw BurnOverlayError.rendererUnavailable } overlay.startBurning() @@ -93,7 +102,8 @@ final class BurnCoordinator { do { let capturedWindow = try await WindowCaptureService.capture( - target: control.window.target + target: control.window.target, + excludingWindowIDs: excludedCaptureWindowIDs?() ?? [] ) let panelFrame = ScreenCoordinateConverter.appKitFrame( for: capturedWindow.captureFrame, @@ -102,14 +112,14 @@ final class BurnCoordinator { ) let profile = BurnProfile.random() - try overlay.present( + try await overlay.present( image: capturedWindow.image, + handoffImage: capturedWindow.handoffImage, shadowImage: capturedWindow.shadowImage, shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, startImmediately: false, - onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false } ) try AccessibilityWindowService.perform(control) @@ -117,7 +127,7 @@ final class BurnCoordinator { try await AccessibilityWindowService.finishClosingDiscardingUnsavedChanges( control.window ) - guard overlay.activateReplacementSurface() else { + guard try await overlay.activateReplacementSurface() else { throw BurnOverlayError.rendererUnavailable } overlay.startBurning() @@ -150,7 +160,9 @@ final class BurnCoordinator { return true } - func interceptTorchClick(at screenPoint: CGPoint) -> Bool { + func interceptTorchClick( + at screenPoint: CGPoint, resolvedWindow: AccessibleWindow? + ) -> Bool { if let sessionID = torchSessionRegistry.sessionID(containing: screenPoint), let session = torchSessions[sessionID], let ignition = TorchBurnGeometry.normalizedIgnition( @@ -184,7 +196,7 @@ final class BurnCoordinator { guard !isBusy, - let accessibleWindow = AccessibilityWindowService.window(at: screenPoint), + let accessibleWindow = resolvedWindow, TorchBurnGeometry.normalizedIgnition( screenPoint: screenPoint, captureFrame: accessibleWindow.target.frame @@ -213,7 +225,8 @@ final class BurnCoordinator { do { guard let session = torchSessions[sessionID] else { return } let capturedWindow = try await WindowCaptureService.capture( - target: session.accessibleWindow.target + target: session.accessibleWindow.target, + excludingWindowIDs: excludedCaptureWindowIDs?() ?? [] ) guard let session = torchSessions[sessionID] else { return } let panelFrame = ScreenCoordinateConverter.appKitFrame( @@ -241,27 +254,35 @@ final class BurnCoordinator { throw WindowCaptureError.noMatchingWindow } let profile = BurnProfile.randomTorch() - try session.overlay.present( + session.pendingClicks = [] + try await session.overlay.present( image: capturedWindow.image, + handoffImage: capturedWindow.handoffImage, shadowImage: capturedWindow.shadowImage, shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, style: .torch(initialIgnitions: ignitionPoints), startImmediately: false, - onFirstFrame: nil, completion: { [weak self] in self?.finishTorchBurn(sessionID) } ) - session.isOverlayReady = true + for click in session.pendingClicks { + if let ignition = TorchBurnGeometry.normalizedIgnition( + screenPoint: click, captureFrame: session.captureFrame + ) { + _ = session.overlay.addIgnition(ignition) + } + } session.pendingClicks = [] + session.isOverlayReady = true try await AccessibilityWindowService.closeDiscardingUnsavedChanges( session.accessibleWindow ) guard torchSessions[sessionID] != nil, - session.overlay.activateReplacementSurface() + try await session.overlay.activateReplacementSurface() else { throw BurnOverlayError.rendererUnavailable } @@ -285,13 +306,14 @@ final class BurnCoordinator { func interceptSoakAndBurn( _ event: SoakAndBurnPointerEvent, - at screenPoint: CGPoint + at screenPoint: CGPoint, resolvedWindow: AccessibleWindow? ) -> Bool { switch event { case .down: switch soakAndBurnSession.phase { case .readyToSoak: - return beginSoaking(at: screenPoint) + return beginSoaking( + at: screenPoint, resolvedWindow: resolvedWindow) case .readyToBurn: return igniteSoakedWindow(at: screenPoint) case .soaking, .burning: @@ -325,7 +347,7 @@ final class BurnCoordinator { logger.info("Soak-and-burn mode was cancelled") } - func showDemo() { + func showDemo() async { guard !isBusy, torchSessions.isEmpty else { return } isBusy = true logger.info("Starting demo burn") @@ -343,12 +365,11 @@ final class BurnCoordinator { height: size.height ) let profile = BurnProfile.random() - try overlay.present( + try await overlay.present( image: image, panelFrame: contentFrame, profile: profile, presentation: .demoWindow, - onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false self?.logger.info("Demo burn completed") @@ -361,7 +382,7 @@ final class BurnCoordinator { } } - func showSoakDemo() { + func showSoakDemo() async { guard !isBusy, torchSessions.isEmpty else { return } isBusy = true logger.info("Starting soak-and-burn demo") @@ -392,13 +413,12 @@ final class BurnCoordinator { BurnIgnitionPoint(x: 0.56, y: 0.46), BurnIgnitionPoint(x: 0.50, y: 0.50), ] - try overlay.present( + try await overlay.present( image: image, panelFrame: contentFrame, profile: profile, style: .soakAndBurn(initialSoakPoints: [soakPoints[0]]), presentation: .demoWindow, - onFirstFrame: nil, completion: { [weak self] in self?.isBusy = false self?.logger.info("Soak-and-burn demo completed") @@ -439,11 +459,13 @@ final class BurnCoordinator { ) } - private func beginSoaking(at screenPoint: CGPoint) -> Bool { + private func beginSoaking( + at screenPoint: CGPoint, resolvedWindow: AccessibleWindow? + ) -> Bool { guard !isBusy, torchSessions.isEmpty, - let accessibleWindow = AccessibilityWindowService.window(at: screenPoint), + let accessibleWindow = resolvedWindow, TorchBurnGeometry.normalizedIgnition( screenPoint: screenPoint, captureFrame: accessibleWindow.target.frame @@ -468,20 +490,18 @@ final class BurnCoordinator { isSoakOverlayActive = false pendingSoakRelease = false notifySoakAndBurnPhaseChange() - onSoakCaptureStateChange?(true) Task { @MainActor [weak self] in guard let self else { return } do { - // Ordering a cursor panel out is asynchronous at the WindowServer boundary. - // Give it several display frames before ScreenCaptureKit snapshots the target. - try await Task.sleep(for: .milliseconds(80)) + // ScreenCaptureKit excludes the cursor panel by ID, so no timing delay is needed. guard generation == soakGeneration, soakAndBurnSession.phase != .readyToSoak else { return } let capturedWindow = try await WindowCaptureService.capture( - target: accessibleWindow.target + target: accessibleWindow.target, + excludingWindowIDs: excludedCaptureWindowIDs?() ?? [] ) guard generation == soakGeneration, soakAndBurnSession.phase != .readyToSoak else { return @@ -492,28 +512,34 @@ final class BurnCoordinator { padding: BurnOverlayController.padding ) let initialSoakPoints = pendingSoakTrail.points + pendingSoakTrail = SoakTrail() guard !initialSoakPoints.isEmpty else { throw WindowCaptureError.noMatchingWindow } soakCaptureFrame = capturedWindow.captureFrame let profile = BurnProfile.randomTorch() - try overlay.present( + try await overlay.present( image: capturedWindow.image, backdropImage: capturedWindow.backdropImage, + handoffImage: capturedWindow.handoffImage, shadowImage: capturedWindow.shadowImage, shadowSamplingOffset: capturedWindow.shadowSamplingOffset, panelFrame: panelFrame, profile: profile, style: .soakAndBurn(initialSoakPoints: initialSoakPoints), - onFirstFrame: nil, completion: { [weak self] in self?.finishSoakAndBurn() } ) - isSoakOverlayActive = true + guard generation == soakGeneration, soakAndBurnSession.phase != .readyToSoak else { + return + } + for point in pendingSoakTrail.points { + _ = overlay.addSoakPoint(point) + } pendingSoakTrail = SoakTrail() - onSoakCaptureStateChange?(false) + isSoakOverlayActive = true if pendingSoakRelease || soakAndBurnSession.phase == .readyToBurn { pendingSoakRelease = false _ = overlay.finishSoaking() @@ -522,6 +548,8 @@ final class BurnCoordinator { "Started soaking pid \(accessibleWindow.target.ownerPID) with \(initialSoakPoints.count) sampled point(s)" ) } catch { + guard generation == soakGeneration else { return } + overlay.dismiss() resetSoakAndBurn() logger.error("Soaking failed: \(error.localizedDescription, privacy: .public)") showError(error) @@ -551,16 +579,26 @@ final class BurnCoordinator { soakCloseTask = Task { @MainActor [weak self] in guard let self else { return } do { - guard overlay.prepareForIgnitionHandoff() else { + guard generation == soakGeneration, !Task.isCancelled else { return } + let handoffImage = await WindowCaptureService.captureHandoff( + targetFrame: captureFrame, + excludingWindowIDs: excludedCaptureWindowIDs?() ?? [] + ) + guard generation == soakGeneration, !Task.isCancelled else { return } + let handoffPrepared = try await overlay.prepareForIgnitionHandoff( + handoffImage: handoffImage) + guard generation == soakGeneration, !Task.isCancelled else { return } + guard handoffPrepared else { overlay.dismiss() resetSoakAndBurn() return } try await AccessibilityWindowService.closeDiscardingUnsavedChanges(accessibleWindow) guard generation == soakGeneration, !Task.isCancelled else { return } - soakCloseTask = nil + let replacementActive = try await overlay.activateReplacementSurface() + guard generation == soakGeneration, !Task.isCancelled else { return } guard - overlay.activateReplacementSurface(), + replacementActive, soakAndBurnSession.beginBurning(), overlay.igniteSoakedWindow(at: ignition) else { @@ -568,6 +606,7 @@ final class BurnCoordinator { resetSoakAndBurn() return } + soakCloseTask = nil notifySoakAndBurnPhaseChange() logger.info( "Ignited the soaked window for pid \(accessibleWindow.target.ownerPID) at \(ignition.x, format: .fixed(precision: 2)), \(ignition.y, format: .fixed(precision: 2))" @@ -575,6 +614,7 @@ final class BurnCoordinator { } catch is CancellationError { return } catch { + guard generation == soakGeneration else { return } soakCloseTask = nil overlay.dismiss() resetSoakAndBurn() @@ -616,7 +656,6 @@ final class BurnCoordinator { private func resetSoakAndBurn() { soakCloseTask?.cancel() soakCloseTask = nil - onSoakCaptureStateChange?(false) soakGeneration += 1 isBusy = false soakAndBurnSession.reset() diff --git a/Sources/WindowBurn/BurnOverlayController.swift b/Sources/WindowBurn/BurnOverlayController.swift index 3bae34b..0c3d11f 100644 --- a/Sources/WindowBurn/BurnOverlayController.swift +++ b/Sources/WindowBurn/BurnOverlayController.swift @@ -1,5 +1,6 @@ import AppKit import MetalKit +import QuartzCore import WindowBurnCore enum BurnOverlayError: LocalizedError { @@ -23,15 +24,18 @@ enum BurnOverlayPresentation { @MainActor final class BurnOverlayController { - static let padding: CGFloat = 84 + nonisolated static let padding: CGFloat = 84 private var window: NSWindow? private var metalView: MTKView? private var renderer: BurnRenderer? + var lastGPUFrameDuration: TimeInterval? { renderer?.lastGPUFrameDuration } + func present( image: CGImage, backdropImage: CGImage? = nil, + handoffImage: CGImage? = nil, shadowImage: CGImage? = nil, shadowSamplingOffset: CGPoint = .zero, panelFrame: CGRect, @@ -39,9 +43,8 @@ final class BurnOverlayController { style: BurnRendererStyle = .sweep, presentation: BurnOverlayPresentation = .effectOverlay, startImmediately: Bool = true, - onFirstFrame: (() throws -> Void)?, completion: (() -> Void)? = nil - ) throws { + ) async throws { dismiss() guard let device = MTLCreateSystemDefaultDevice() else { @@ -83,34 +86,36 @@ final class BurnOverlayController { demoWindow.isReleasedWhenClosed = false window = demoWindow } + // AppKit's default ordering animation scales the first frames, exposing the handoff. + window.animationBehavior = .none let metalView = MTKView(frame: CGRect(origin: .zero, size: panelFrame.size), device: device) metalView.autoresizingMask = [.width, .height] metalView.colorPixelFormat = .bgra8Unorm metalView.clearColor = MTLClearColorMake(0, 0, 0, 0) metalView.isPaused = true metalView.enableSetNeedsDisplay = true - if case .soakAndBurn = style { - metalView.autoResizeDrawable = false - metalView.preferredFramesPerSecond = 30 - } else { - metalView.preferredFramesPerSecond = 60 - } + // Keep captured text and contours at the display's native resolution. + // Only the simulation fields use a smaller grid. + metalView.preferredFramesPerSecond = 60 metalView.wantsLayer = true metalView.layer?.isOpaque = false + // Keep the capture's wide gamut through the compositor (including titlebar accents). + if let metalLayer = metalView.layer as? CAMetalLayer { + metalLayer.colorspace = CGColorSpace(name: CGColorSpace.displayP3) + } let burnRenderer = try BurnRenderer( device: device, image: image, backdropImage: backdropImage, shadowImage: shadowImage, + handoffImage: handoffImage, shadowSamplingOffset: shadowSamplingOffset, profile: profile, style: style, horizontalPadding: presentation == .demoWindow ? 0 : Float(Self.padding / panelFrame.width), verticalPadding: presentation == .demoWindow ? 0 : Float(Self.padding / panelFrame.height), - cornerRadius: presentation == .demoWindow - ? 0 - : Float(10 / max(1, panelFrame.height - Self.padding * 2)), + cornerRadius: 0, completion: { [weak self] in self?.dismiss() completion?() @@ -118,16 +123,6 @@ final class BurnOverlayController { ) metalView.delegate = burnRenderer - if case .soakAndBurn = style { - let pointPixelScale = window.screen?.backingScaleFactor ?? window.backingScaleFactor - let pixelSize = InteractiveRenderSizing.pixelSize( - for: metalView.bounds.size, - pointPixelScale: pointPixelScale, - maximumPixelCount: 2_000_000 - ) - metalView.layer?.contentsScale = pointPixelScale - metalView.drawableSize = CGSize(width: pixelSize.width, height: pixelSize.height) - } window.contentView = metalView self.window = window @@ -141,12 +136,12 @@ final class BurnOverlayController { window.makeKeyAndOrderFront(nil) } - burnRenderer.synchronizeNextFrame() - metalView.draw() do { - try onFirstFrame?() + try await burnRenderer.presentFrame(in: metalView) + guard self.window === window else { throw CancellationError() } + try Task.checkCancellation() } catch { - dismiss() + if self.window === window { dismiss() } throw error } @@ -163,20 +158,22 @@ final class BurnOverlayController { } @discardableResult - func activateReplacementSurface() -> Bool { + func activateReplacementSurface() async throws -> Bool { guard let metalView, let renderer else { return false } + let presentedWindow = window renderer.activateReplacementSurface() - metalView.draw() - return true + try await renderer.presentFrame(in: metalView) + try Task.checkCancellation() + return window === presentedWindow } @discardableResult - func prepareForIgnitionHandoff() -> Bool { + func prepareForIgnitionHandoff(handoffImage: CGImage? = nil) async throws -> Bool { guard let metalView, let renderer, @@ -184,8 +181,11 @@ final class BurnOverlayController { else { return false } - metalView.draw() - return true + let presentedWindow = window + try renderer.setHandoffImage(handoffImage) + try await renderer.presentFrame(in: metalView) + try Task.checkCancellation() + return window === presentedWindow } @discardableResult diff --git a/Sources/WindowBurn/BurnRenderer.swift b/Sources/WindowBurn/BurnRenderer.swift index 8cb3bda..9978646 100644 --- a/Sources/WindowBurn/BurnRenderer.swift +++ b/Sources/WindowBurn/BurnRenderer.swift @@ -9,6 +9,7 @@ enum BurnRendererError: LocalizedError { case pipeline(String) case texture(String) case sampler + case presentation(String) var errorDescription: String? { switch self { @@ -24,6 +25,8 @@ enum BurnRendererError: LocalizedError { "The captured window could not become a Metal texture: \(message)" case .sampler: "Metal could not create a texture sampler." + case .presentation(let message): + "The captured window was not presented: \(message)" } } } @@ -46,13 +49,21 @@ final class BurnRenderer: NSObject, MTKViewDelegate { private static var pipelineResourcesByRegistryID: [UInt64: PipelineResources] = [:] + static func prewarm(device: MTLDevice) throws { + _ = try pipelineResources(for: device) + } + private let commandQueue: MTLCommandQueue + // Acquired before touching currentDrawable, which otherwise waits on the main thread + // when the GPU has exhausted CAMetalLayer's drawable pool. + private let inFlightFrame = DispatchSemaphore(value: 1) private let pipeline: MTLRenderPipelineState private let clearWetPipeline: MTLComputePipelineState private let accumulateWetPipeline: MTLComputePipelineState private let initializeCombustionPipeline: MTLComputePipelineState private let stepCombustionPipeline: MTLComputePipelineState private let texture: MTLTexture + private var handoffTexture: MTLTexture? private let backdropTexture: MTLTexture private let shadowTexture: MTLTexture private let wetAccumulationTexture: MTLTexture @@ -80,14 +91,24 @@ final class BurnRenderer: NSObject, MTKViewDelegate { private var burnStartedAt: TimeInterval? private var isHandoffPrepared = false private var isReplacementSurfaceActive = false - private var shouldSynchronizeNextFrame = false private var hasCompleted = false + private var presentationContinuation: CheckedContinuation? + private var presentationID: UUID? + private var submittedPresentationID: UUID? + private weak var presentationView: MTKView? + private var presentationTimeout: Task? + + /// A fixed clock for the reproducible visual preview. Normal interactions use the display clock. + var previewElapsedTime: TimeInterval? + private var previousPreviewElapsedTime: TimeInterval? + private(set) var lastGPUFrameDuration: TimeInterval = 0 init( device: MTLDevice, image: CGImage, backdropImage: CGImage?, shadowImage: CGImage?, + handoffImage: CGImage? = nil, shadowSamplingOffset: CGPoint, profile: BurnProfile, style: BurnRendererStyle, @@ -140,11 +161,11 @@ final class BurnRenderer: NSObject, MTKViewDelegate { let wetTextureWidth: Int let wetTextureHeight: Int if imageAspect >= 1 { - wetTextureWidth = 256 - wetTextureHeight = max(64, Int((256 / imageAspect).rounded())) + wetTextureWidth = 512 + wetTextureHeight = max(128, Int((512 / imageAspect).rounded())) } else { - wetTextureWidth = max(64, Int((256 * imageAspect).rounded())) - wetTextureHeight = 256 + wetTextureWidth = max(128, Int((512 * imageAspect).rounded())) + wetTextureHeight = 512 } let wetTextureDescriptor = MTLTextureDescriptor.texture2DDescriptor( pixelFormat: .r16Float, @@ -179,24 +200,14 @@ final class BurnRenderer: NSObject, MTKViewDelegate { } combustionStateTextures = [firstCombustionTexture, secondCombustionTexture] - let textureLoader = MTKTextureLoader(device: device) - let textureOptions: [MTKTextureLoader.Option: Any] = [ - .origin: MTKTextureLoader.Origin.topLeft, - .SRGB: false, - ] do { - texture = try textureLoader.newTexture( - cgImage: image, - options: textureOptions - ) - backdropTexture = try textureLoader.newTexture( - cgImage: backdropImage ?? image, - options: textureOptions - ) - shadowTexture = try textureLoader.newTexture( - cgImage: shadowImage ?? image, - options: textureOptions - ) + texture = try Self.makePremultipliedTexture(image: image, device: device) + backdropTexture = try Self.makePremultipliedTexture( + image: backdropImage ?? image, device: device) + shadowTexture = try Self.makePremultipliedTexture(image: shadowImage ?? image, device: device) + handoffTexture = try handoffImage.map { + try Self.makePremultipliedTexture(image: $0, device: device) + } } catch { throw BurnRendererError.texture(error.localizedDescription) } @@ -221,20 +232,110 @@ final class BurnRenderer: NSObject, MTKViewDelegate { lastDrawTime = now } - func synchronizeNextFrame() { - shouldSynchronizeNextFrame = true - } - func activateReplacementSurface() { isReplacementSurfaceActive = true - shouldSynchronizeNextFrame = true + } + + func setHandoffImage(_ image: CGImage?) throws { + handoffTexture = try image.map { + try Self.makePremultipliedTexture(image: $0, device: texture.device) + } + } + + private static func makePremultipliedTexture(image: CGImage, device: MTLDevice) throws + -> MTLTexture + { + let rowBytes = image.width * 4 + // This must match ScreenCaptureKit and CAMetalLayer to avoid clipping native colors. + guard + let colorSpace = CGColorSpace(name: CGColorSpace.displayP3), + let context = CGContext( + data: nil, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: rowBytes, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + ), + let pixels = context.data + else { throw BurnRendererError.texture("the premultiplied image buffer could not be created") } + context.setBlendMode(.copy) + context.interpolationQuality = .none + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, + width: image.width, + height: image.height, + mipmapped: false + ) + descriptor.usage = .shaderRead + descriptor.storageMode = .shared + guard let texture = device.makeTexture(descriptor: descriptor) else { + throw BurnRendererError.texture("the premultiplied texture could not be created") + } + texture.replace( + region: MTLRegionMake2D(0, 0, image.width, image.height), + mipmapLevel: 0, + withBytes: pixels, + bytesPerRow: rowBytes + ) + return texture + } + + /// GPU completion alone does not mean the compositor has displayed the drawable. + func presentFrame(in view: MTKView) async throws { + guard presentationContinuation == nil else { + throw BurnRendererError.presentation("another presentation is still pending") + } + let id = UUID() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + presentationID = id + presentationContinuation = continuation + presentationView = view + presentationTimeout = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.finishPresentation( + id: id, + error: BurnRendererError.presentation("the display did not acknowledge the frame") + ) + } + view.draw() + } + } + + private func finishPresentation(id: UUID?, error: Error? = nil) { + guard id == presentationID, let continuation = presentationContinuation else { return } + presentationContinuation = nil + presentationID = nil + submittedPresentationID = nil + presentationView = nil + presentationTimeout?.cancel() + presentationTimeout = nil + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + + private func retryPendingPresentation() { + guard + presentationContinuation != nil, + submittedPresentationID == nil, + let view = presentationView + else { return } + // A paused first-frame or handoff view has no display-link tick to retry a + // draw skipped while the previous GPU frame was still in flight. + view.draw() } @discardableResult func prepareForIgnitionHandoff() -> Bool { guard case .soakAndBurn = style, burnStartedAt == nil else { return false } isHandoffPrepared = true - shouldSynchronizeNextFrame = true return true } @@ -276,16 +377,34 @@ final class BurnRenderer: NSObject, MTKViewDelegate { func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} func draw(in view: MTKView) { + guard !hasCompleted else { return } + guard inFlightFrame.wait(timeout: .now()) == .success else { return } + var didSubmitFrame = false + defer { + if !didSubmitFrame { inFlightFrame.signal() } + } + let drawableRequestedAt = CACurrentMediaTime() + let nextDrawable = view.currentDrawable + InputDiagnostics.drawableWait(CACurrentMediaTime() - drawableRequestedAt) guard - !hasCompleted, - let drawable = view.currentDrawable, + let drawable = nextDrawable, let renderPass = view.currentRenderPassDescriptor, let commandBuffer = commandQueue.makeCommandBuffer() - else { return } + else { + finishPresentation( + id: presentationID, + error: BurnRendererError.presentation("no drawable or command buffer is available") + ) + return + } let now = CACurrentMediaTime() - let elapsed = startTime.map { now - $0 } ?? 0 - let frameDuration = min(max(now - (lastDrawTime ?? now), 0), 1.0 / 15.0) + let elapsed = previewElapsedTime ?? startTime.map { now - $0 } ?? 0 + let clockDelta = + previewElapsedTime.map { $0 - (previousPreviewElapsedTime ?? 0) } + ?? now - (lastDrawTime ?? now) + let frameDuration = isHandoffPrepared ? 0 : min(max(clockDelta, 0), 1.0 / 15.0) + previousPreviewElapsedTime = previewElapsedTime lastDrawTime = now var wetDeposits = wetDepositQueue.takePendingDeposits().map { point in SIMD4( @@ -295,17 +414,20 @@ final class BurnRenderer: NSObject, MTKViewDelegate { 0.34 ) } - if soakEndedAt == nil, let activePoint = wetDepositQueue.latestPoint { + if soakEndedAt == nil, frameDuration > 0, let activePoint = wetDepositQueue.latestPoint { wetDeposits.append( SIMD4( activePoint.x, activePoint.y, profile.seed + activePoint.x * 137.3 + activePoint.y * 271.9, - max(0.012, Float(frameDuration) * 0.82) + Float(frameDuration) * 0.82 ) ) } - guard encodeWetFieldUpdates(wetDeposits, on: commandBuffer) else { return } + guard encodeWetFieldUpdates(wetDeposits, on: commandBuffer) else { + finishPresentation(id: presentationID, error: BurnRendererError.presentation("wet encoder")) + return + } let burnElapsed: TimeInterval if case .soakAndBurn = style { @@ -373,7 +495,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { wetVisualProfile.dropletDensity, wetVisualProfile.verticalSag, wetVisualProfile.urineTintStrength, - 0 + WetMaterialOptics.refractiveIndex ) var waterGeometry = SIMD4( wetVisualProfile.impactRadius, @@ -394,6 +516,12 @@ final class BurnRenderer: NSObject, MTKViewDelegate { shadowSamplingOffset.x, shadowSamplingOffset.y ) + var renderingInfo = SIMD4( + startTime != nil || previewElapsedTime != nil ? 1 : 0, + handoffTexture == nil ? 0 : 1, + isHandoffPrepared ? 1 : 0, + 0 + ) var wetUniforms = activeWetPoint.map { point in [SIMD4(point.x, point.y, profile.seed + 11.73, impactFade)] @@ -422,8 +550,14 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ignitionUniforms: ignitionUniforms, on: commandBuffer ) - else { return } + else { + finishPresentation( + id: presentationID, error: BurnRendererError.presentation("combustion encoder")) + return + } guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPass) else { + finishPresentation( + id: presentationID, error: BurnRendererError.presentation("render encoder")) return } @@ -446,6 +580,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { index: 3 ) encoder.setFragmentTexture(shadowTexture, index: 4) + encoder.setFragmentTexture(handoffTexture ?? texture, index: 5) encoder.setFragmentSamplerState(sampler, index: 0) encoder.setFragmentBytes(&timing, length: MemoryLayout>.stride, index: 0) encoder.setFragmentBytes(&padding, length: MemoryLayout>.stride, index: 1) @@ -501,14 +636,42 @@ final class BurnRenderer: NSObject, MTKViewDelegate { length: MemoryLayout>.stride, index: 15 ) + encoder.setFragmentBytes( + &renderingInfo, + length: MemoryLayout>.stride, + index: 16 + ) encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) encoder.endEncoding() + let pendingPresentationID = submittedPresentationID == nil ? presentationID : nil + if let pendingPresentationID { + submittedPresentationID = pendingPresentationID + drawable.addPresentedHandler { [weak self] _ in + Task { @MainActor in + self?.finishPresentation(id: pendingPresentationID) + } + } + } + commandBuffer.addCompletedHandler { [weak self, inFlightFrame] buffer in + // Release on Metal's completion queue; waiting for MainActor here would + // prevent a busy UI from making rendering progress. + inFlightFrame.signal() + let duration = max(0, buffer.gpuEndTime - buffer.gpuStartTime) + let failure = buffer.status == .error ? buffer.error?.localizedDescription : nil + Task { @MainActor in + self?.lastGPUFrameDuration = duration + if let failure { + self?.finishPresentation( + id: pendingPresentationID, + error: BurnRendererError.presentation(failure) + ) + } + self?.retryPendingPresentation() + } + } commandBuffer.present(drawable) + didSubmitFrame = true commandBuffer.commit() - if shouldSynchronizeNextFrame { - commandBuffer.waitUntilCompleted() - shouldSynchronizeNextFrame = false - } let canComplete: Bool = { if case .soakAndBurn = style { return burnStartedAt != nil } @@ -724,7 +887,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { descriptor.colorAttachments[0].isBlendingEnabled = true descriptor.colorAttachments[0].rgbBlendOperation = .add descriptor.colorAttachments[0].alphaBlendOperation = .add - descriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + descriptor.colorAttachments[0].sourceRGBBlendFactor = .one descriptor.colorAttachments[0].sourceAlphaBlendFactor = .one descriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha descriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha @@ -806,6 +969,13 @@ final class BurnRenderer: NSObject, MTKViewDelegate { return value; } + float paperGrain(float2 physicalUV, float seed) { + float2 offset = float2(seed * 0.0137, seed * 0.0319); + float bundles = valueNoise(physicalUV * float2(48.0, 240.0) + offset); + float fibers = valueNoise(physicalUV * float2(153.0, 610.0) + offset * 3.1); + return bundles * 0.68 + fibers * 0.32; + } + float radialEdgeOffset( float2 uv, float2 physicalDelta, @@ -895,9 +1065,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { radius, distance ); - contribution *= 0.78 + valueNoise( - uv * float2(83.0, 49.0) + wetPoint.z * 0.011 - ) * 0.22; + contribution *= 0.70 + paperGrain(uv * float2(aspect, 1.0), fieldInfo.w) * 0.30; density += contribution * max(0.0, wetPoint.w); } @@ -1031,10 +1199,15 @@ final class BurnRenderer: NSObject, MTKViewDelegate { moisture = max(moisture, depositedMoisture); } - float retainedHeat = heat * max(0.0, 1.0 - heatDecay * deltaTime); - float spreadHeat = clamp(neighborHeat, 0.0, maximumHeat) * spreadRate; + if (deltaTime <= 0.0) { + nextState.write(float4(heat, moisture, fuel, damage), position); + return; + } + float retainedHeat = heat * exp(-heatDecay * deltaTime); + float heatDifference = max(0.0, clamp(neighborHeat, 0.0, maximumHeat) - heat); + float spreadHeat = heatDifference * (1.0 - exp(-spreadRate * 8.0 * deltaTime)); float nextHeat = min( - max(max(retainedHeat, spreadHeat), max(sourceHeat, 0.0)), + max(retainedHeat + spreadHeat, max(sourceHeat, 0.0)), maximumHeat ); float evaporatedMoisture = min( @@ -1068,6 +1241,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { texture2d backdrop [[texture(2)]], texture2d combustionState [[texture(3)]], texture2d windowShadow [[texture(4)]], + texture2d handoffImage [[texture(5)]], sampler imageSampler [[sampler(0)]], constant float2 &timing [[buffer(0)]], constant float2 &padding [[buffer(1)]], @@ -1084,7 +1258,8 @@ final class BurnRenderer: NSObject, MTKViewDelegate { constant float4 &waterGeometry [[buffer(12)]], constant float4 &wetPaperDamage [[buffer(13)]], constant float &windowCornerRadius [[buffer(14)]], - constant float4 &replacementInfo [[buffer(15)]] + constant float4 &replacementInfo [[buffer(15)]], + constant float4 &renderingInfo [[buffer(16)]] ) { float progress = timing.x; float time = timing.y; @@ -1124,13 +1299,24 @@ final class BurnRenderer: NSObject, MTKViewDelegate { float2 contentSize = 1.0 - padding * 2.0; float2 imageUV = (input.uv - padding) / contentSize; + // A presented, unmodified compositor cover bridges the native window handoff. + // Both this cover and all material textures have an explicit premultiplied layout. + if (replacementInfo.x < 0.5 && renderingInfo.y > 0.5 + && (renderingInfo.x < 0.5 || renderingInfo.z > 0.5)) { + return handoffImage.sample(imageSampler, input.uv); + } + if (renderingInfo.x < 0.5) { + float4 original = image.sample(imageSampler, imageUV); + float4 shadow = windowShadow.sample(imageSampler, input.uv + replacementInfo.zw); + return replacementInfo.x * replacementInfo.y > 0.5 ? shadow : original; + } + float horizontalMask = step(0.0, imageUV.x) * step(imageUV.x, 1.0); float rectangleMask = horizontalMask * step(0.0, imageUV.y) * step(imageUV.y, 1.0); float capturedShape = image.sample(imageSampler, imageUV).a; - float insideMask = rectangleMask - * smoothstep(0.01, 0.99, capturedShape); + float insideMask = rectangleMask * capturedShape; float contentAspect = aspect * contentSize.x / max(contentSize.y, 0.001); float2 shapePosition = (imageUV - 0.5) * float2(contentAspect, 1.0); float2 shapeHalfSize = float2(contentAspect * 0.5, 0.5); @@ -1167,12 +1353,12 @@ final class BurnRenderer: NSObject, MTKViewDelegate { if (radialMode > 0.5 && ignitionCount > 0) { float nearestFront = 1000.0; - float maximumRadius = length(float2(aspect, 1.0)) + 0.12; + float maximumRadius = length(float2(contentAspect, 1.0)) + 0.12; for (uint index = 0; index < ignitionCount; index++) { float4 ignition = ignitions[index]; float age = max(0.0, time - ignition.z); float ignitionProgress = clamp(age / burnDuration, 0.0, 1.0); - float2 delta = (imageUV - ignition.xy) * float2(aspect, 1.0); + float2 delta = (imageUV - ignition.xy) * float2(contentAspect, 1.0); float radialDistance = length(delta); float radialRagged = radialEdgeOffset( imageUV, @@ -1191,14 +1377,13 @@ final class BurnRenderer: NSObject, MTKViewDelegate { signedDistance = 1000.0; } - float grain = fbm(imageUV * float2(31.0, 19.0) + seedOffset * 3.7); + float grain = paperGrain(imageUV * float2(contentAspect, 1.0), seed); float pinholeNoise = valueNoise(imageUV * float2(83.0, 47.0) + seedOffset * 8.1); float scorchBand = insideMask * step(0.0, signedDistance) * (1.0 - smoothstep(0.0, charWidth * 1.35, signedDistance)); scorchBand *= 0.58 + grain * 0.42; - float stateScorch = smoothstep(0.06, 0.34, combustionDamage) - * (1.0 - smoothstep(0.72, 0.98, combustionDamage)); + float stateScorch = smoothstep(0.01, 0.12, combustionDamage); scorchBand *= mix(1.0, stateScorch, soakMode); float pores = scorchBand * smoothstep(0.74, 0.95, pinholeNoise + scorchBand * 0.18); @@ -1350,7 +1535,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { wrinkleFullDensity, bakedDensity ); - float2 paperCoordinate = imageUV * float2(aspect, 1.0); + float2 paperCoordinate = imageUV * float2(contentAspect, 1.0); float foldWarp = fbm( imageUV * float2(10.0, 8.0) + seedOffset * 4.3 ) * 6.0; @@ -1452,7 +1637,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { imageUV * float2(83.0, 49.0) + float2(wetPoint.z * 0.031, wetPoint.z * 0.011) ); - float2 filmDelta = wetDelta * float2(aspect, 1.0); + float2 filmDelta = wetDelta * float2(contentAspect, 1.0); float filmDistance = length(filmDelta); float filmAngle = atan2(filmDelta.y, filmDelta.x); float localRadius = mix( @@ -1509,7 +1694,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { liquidFilm *= mix(0.62, 1.0, stillSoaking); float impactDistance = length( - wetDelta * float2(aspect, 1.0) + wetDelta * float2(contentAspect, 1.0) ); float impactPulse = 0.91 + sin(time * 22.0) * 0.09; float impactBody = ( @@ -1612,14 +1797,14 @@ final class BurnRenderer: NSObject, MTKViewDelegate { activeImpactRim * 0.64 ); - float2 dropletGrid = float2(34.0 * aspect, 26.0); + float2 dropletGrid = float2(34.0 * contentAspect, 26.0); float2 dropletCell = floor(imageUV * dropletGrid); float dropSeed = hash21(dropletCell + seedOffset * 13.0); float dropSeedY = hash21(dropletCell.yx + seedOffset * 21.0 + 7.3); float2 dropletCenter = ( dropletCell + float2(dropSeed, dropSeedY) ) / dropletGrid; - dropletDelta = (imageUV - dropletCenter) * float2(aspect, 1.0); + dropletDelta = (imageUV - dropletCenter) * float2(contentAspect, 1.0); dropletRadius = mix(0.0032, 0.0086, hash21(dropletCell + 41.7)); float dropletDistance = length(dropletDelta); float dropletBody = 1.0 - smoothstep( @@ -1708,12 +1893,19 @@ final class BurnRenderer: NSObject, MTKViewDelegate { pores *= combustibleMask; keep *= 1.0 - pores * 0.82; - float4 source = image.sample(imageSampler, clamp(sourceUV, 0.0, 1.0)); + float curlBand = (1.0 - smoothstep(0.0, hotCoreWidth * 7.0, abs(signedDistance))) + * smoothstep(0.06, 0.32, combustionDamage) * insideMask * combustibleMask; + float2 burnNormal = normalize(float2(dfdx(signedDistance), dfdy(signedDistance)) + + float2(0.00001)); + sourceUV += burnNormal * curlBand * float2(0.0018 / contentAspect, 0.0018); + + float4 source = image.sample(imageSampler, sourceUV); + source.rgb = source.a > 0.0001 ? source.rgb / source.a : float3(0.0); if (absorptionMask > 0.001) { float densityBlur = log2(1.0 + max(0.0, localFluidDensity)); float blurScale = 0.82 + densityBlur * 0.34; float2 blurStep = float2( - backgroundBlurRadius / max(aspect, 0.001), + backgroundBlurRadius / max(contentAspect, 0.001), backgroundBlurRadius ) * blurScale; float3 blurredSource = source.rgb * 4.0; @@ -1782,123 +1974,43 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * (0.07 + min(densityBlur * 0.018, 0.09)); } if (wetMask > 0.001) { - float2 dispersionOffset = waterNormalXY - * dispersionStrength - * wetMask; - float red = image.sample( - imageSampler, - clamp(sourceUV + dispersionOffset, 0.0, 1.0) - ).r; - float blue = image.sample( - imageSampler, - clamp(sourceUV - dispersionOffset, 0.0, 1.0) - ).b; - source.rgb = float3(red, source.g, blue); - - float3 waterNormal = normalize(float3( - -waterNormalXY.x * 2.7, - -waterNormalXY.y * 2.7, - 1.0 - )); - float3 lightDirection = normalize(float3(-0.42, -0.58, 0.70)); + // Absorbed liquid changes transmission; a free surface contributes reflection. + // Beer-Lambert extinction does not turn black printed pixels yellow. + float opticalDepth = max(0.0, waterThickness * 1.8 + absorptionMask * 0.38) + * urineTintStrength; + float3 transmittance = exp(-float3(0.11, 0.26, 1.15) * opticalDepth); + float3 linearSource = pow(max(source.rgb, 0.0), float3(2.2)); + linearSource *= transmittance; + float fiberDarkening = absorptionMask * (0.035 + grain * 0.075); + linearSource *= 1.0 - fiberDarkening; + + float3 waterNormal = normalize(float3(-waterNormalXY * 1.65, 1.0)); + float3 lightDirection = normalize(float3(-0.46, -0.62, 0.64)); float3 halfVector = normalize(lightDirection + float3(0.0, 0.0, 1.0)); - float specular = pow(max(dot(waterNormal, halfVector), 0.0), 30.0); - float reflectionBand = smoothstep( - 0.78, - 0.99, - sin( - imageUV.y * 31.0 - - imageUV.x * 7.0 - + seedOffset.x - ) * 0.5 + 0.5 - ); - float2 reflectionUV = clamp( - sourceUV + float2( - waterNormalXY.x * 0.034, - -0.055 - waterNormalXY.y * 0.026 - ), - 0.0, - 1.0 - ); - float3 reflected = image.sample(imageSampler, reflectionUV).rgb; - float fresnel = 0.08 + pow(1.0 - max(waterNormal.z, 0.0), 2.2); + float normalReflectance = pow((1.0 - waterDetail.w) / (1.0 + waterDetail.w), 2.0); + float fresnel = normalReflectance + + (1.0 - normalReflectance) * pow(1.0 - max(waterNormal.z, 0.0), 5.0); + float freeSurface = max(liquidMask * 0.55, dropletMask); + float3 reflectedDirection = reflect(float3(0.0, 0.0, -1.0), waterNormal); + // A broad studio/sky lobe is a stable environment approximation, not moving stripes. + float sky = smoothstep(-0.25, 0.75, reflectedDirection.y); + float3 environment = mix(float3(0.10, 0.12, 0.15), float3(0.76, 0.84, 0.95), sky); + float specularAlignment = max(dot(waterNormal, halfVector), 0.0); + float specular = pow(specularAlignment, 120.0) + + pow(specularAlignment, 14.0) * 0.08; float reflectionAmount = clamp( - wetMask * reflectionStrength * (0.20 + fresnel * 1.25) - + dropletRim * 0.035, - 0.0, - 0.22 - ); - float3 reflectionTint = mix( - reflected, - float3(0.66, 0.80, 0.94), - 0.10 - ); - source.rgb = mix(source.rgb, reflectionTint, reflectionAmount); - - float3 urineTint = float3(0.93, 0.72, 0.19); - float3 absorbedEdgeTint = float3(0.57, 0.34, 0.055); - float visibleAbsorption = smoothstep( - 0.015, - 0.34, - absorptionMask - ); - float visibleLiquid = smoothstep( - 0.015, - 0.24, - liquidMask - ); - float tintAmount = clamp( - ( - visibleAbsorption * 0.24 - + visibleLiquid * 0.10 - ) * urineTintStrength, + freeSurface * fresnel + wetRim * reflectionStrength * 0.25, 0.0, - 0.30 - ); - source.rgb = mix(source.rgb, urineTint, tintAmount); - source.rgb.b *= 1.0 - tintAmount * 0.18; - float sourceLuminance = dot( - source.rgb, - float3(0.2126, 0.7152, 0.0722) - ); - float darkSurfaceBoost = mix( - 0.055, - 0.018, - smoothstep(0.14, 0.72, sourceLuminance) - ); - source.rgb += urineTint - * visibleLiquid - * urineTintStrength - * darkSurfaceBoost; - source.rgb += urineTint - * visibleAbsorption - * urineTintStrength - * 0.042; - float absorbedEdge = smoothstep(0.05, 0.34, absorptionMask) - * (1.0 - smoothstep(0.58, 0.94, absorptionMask)); - source.rgb = mix( - source.rgb, - absorbedEdgeTint, - absorbedEdge * urineTintStrength * 0.22 + 0.8 ); - source.rgb += urineTint * wetRim * 0.035; - - float highlight = specular * wetMask * (0.055 + waterThickness * 0.08); - highlight += wetRim * (0.012 + reflectionBand * 0.025); - highlight += dropletHighlight * 0.07 + dropletRim * 0.012; - source.rgb += float3(1.0, 0.88, 0.42) - * highlight + linearSource = linearSource * (1.0 - reflectionAmount) + + environment * reflectionAmount; + linearSource += float3(0.98, 0.99, 1.0) + * (specular * freeSurface * 0.10 + + dropletHighlight * 0.16 + + wetRim * 0.008) * highlightIntensity; - source.rgb *= 1.0 - wetMask * 0.02; - float4 softReflection = image.sample( - imageSampler, - clamp(reflectionUV + waterNormalXY * 0.006, 0.0, 1.0) - ); - source.rgb = mix( - source.rgb, - softReflection.rgb, - waterThickness * wetMask * 0.008 - ); + source.rgb = pow(max(linearSource, 0.0), float3(1.0 / 2.2)); } if (wrinkleMask > 0.001) { source.rgb *= 1.0 + paperFoldLighting * 0.22; @@ -1928,7 +2040,8 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * residualCharOpacity * combustibleMask * (0.42 + grain * 0.58); - source.a *= insideMask * max(keep, burnedResidue); + // Preserve source coverage once; squaring captured alpha darkens rounded borders. + source.a = capturedShape * rectangleMask * max(keep, burnedResidue); float localEffectCoverage = max( max(absorptionMask, liquidMask), @@ -1943,14 +2056,18 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * (1.0 - step(1.5, wetInfo.y)); source.a *= mix(1.0, localOverlayCoverage, preserveNativeWindow); - float3 toastedSource = source.rgb * float3(0.50, 0.16, 0.035); + float3 toastedSource = source.rgb * float3(0.46, 0.23, 0.08); float3 edgeSoot = mix( - float3(0.16, 0.025, 0.004), - float3(0.018, 0.006, 0.002), + float3(0.080, 0.053, 0.032), + float3(0.014, 0.012, 0.010), grain ); source.rgb = mix(source.rgb, toastedSource, scorchBand * 0.46); - source.rgb = mix(source.rgb, edgeSoot, scorchBand * scorchBand * 0.64); + source.rgb = mix(source.rgb, edgeSoot, scorchBand * scorchBand * 0.86); + source.rgb *= 1.0 + curlBand * dot(burnNormal, float2(-0.45, -0.65)) * 0.30; + float ashFibers = smoothstep(0.68, 0.88, grain) + * scorchBand * smoothstep(0.45, 0.82, combustionDamage); + source.rgb = mix(source.rgb, float3(0.31, 0.29, 0.26), ashFibers * 0.4); float effectMask = mix(horizontalMask, insideMask, radialMode) * combustibleMask @@ -1981,53 +2098,58 @@ final class BurnRenderer: NSObject, MTKViewDelegate { hotCore = max(hotCore, tornEdgeArrival * edgeFlicker * 0.82); emberEdge = max(emberEdge, tornEdgeArrival * 0.74); glow = max(glow, tornEdgeArrival * 0.52); - - float flameMotion = fbm(float2( - imageUV.x * (8.0 + turbulence * 2.8) + time * 0.42 + seedOffset.x, - imageUV.y * 2.7 - time * 2.9 + seedOffset.y - )); - float flameDetail = fbm( - imageUV * float2(31.0, 12.0) - + float2(seedOffset.y - time * 0.75, seedOffset.x - time * 4.1) - ); - float broadLicks = valueNoise(float2( - imageUV.x * 13.0 - time * 0.38 + seedOffset.y, - time * 0.72 + seedOffset.x - )); - float fineLicks = valueNoise(float2( - imageUV.x * 39.0 + time * 0.21 + seedOffset.x, - time * 1.27 + seedOffset.y - )); - float lickHeight = pow( - clamp(broadLicks * 0.72 + fineLicks * 0.38, 0.0, 1.0), - 1.65 - ); - float flameReach = maximumFlameReach - * (0.16 + lickHeight * 0.84) - * (0.82 + flameMotion * 0.31); - float flameEnvelope = effectMask - * step(signedDistance, 0.0) - * (1.0 - smoothstep(hotCoreWidth * 0.65, flameReach, burnedDistance)); - float flameBreakup = smoothstep( - 0.25, - 0.73, - flameDetail + flameMotion * 0.28 - ); - float breakupMix = smoothstep(emberWidth, flameReach, burnedDistance); - float flame = flameEnvelope - * mix(1.0, 0.44 + flameBreakup * 0.56, breakupMix); - float flamePhase = clamp(burnedDistance / max(flameReach, 0.001), 0.0, 1.0); - - float3 deepRed = float3(0.72, 0.006, 0.001); - float3 orange = float3(1.0, 0.16, 0.002); - float3 gold = float3(1.0, 0.68, 0.055); - float3 hotWhite = float3(1.0, 0.96, 0.72); - float3 flameColor = mix(deepRed, orange, 1.0 - flamePhase); - flameColor = mix( - flameColor, - gold, - pow(1.0 - flamePhase, 2.4) - ); + float emberPatches = smoothstep(0.30, 0.74, grain * 0.72 + edgeFlicker * 0.28); + hotCore *= emberPatches * 0.28; + emberEdge *= 0.45 + emberPatches * 0.25; + glow *= 0.35; + + // Integrate a short buoyant column above the actual reacting material. Each + // sample follows a rising, curling streamline back down to its fuel source. + // This is a 2.5D participating-medium approximation, not a 3D fluid solver. + float2 gasCoordinate = imageUV * float2(contentAspect, 1.0); + float curl = fbm(gasCoordinate * float2(8.0, 5.0) + + float2(seedOffset.x, time * 1.4)) - 0.5; + float opticalDepth = 0.0; + float hotGas = 0.0; + for (uint layer = 0; layer < 8; layer++) { + float heightFraction = pow((float(layer) + 0.35) / 8.0, 1.5); + float rise = maximumFlameReach * heightFraction; + float2 foot = imageUV + float2( + (curl * rise * 0.46 + + sin(time * 3.1 + imageUV.y * 19.0 + heightFraction * 4.0) + * rise * 0.12) / contentAspect, + rise + ); + float footInside = step(0.0, foot.x) * step(foot.x, 1.0) + * step(0.0, foot.y) * step(foot.y, 1.0); + float4 fuelState = combustionState.sample(imageSampler, clamp(foot, 0.0, 1.0)); + float fuelShape = image.sample(imageSampler, foot).a * footInside; + float reaction = smoothstep(0.23, 0.78, fuelState.r) + * smoothstep(0.015, 0.14, fuelState.a) + * smoothstep(0.01, 0.40, fuelState.b) + * (1.0 - smoothstep(0.08, 0.72, fuelState.g)) + * fuelShape; + float gasNoise = fbm(float2( + gasCoordinate.x * 31.0 + curl * heightFraction * 2.2 + seedOffset.y, + imageUV.y * 19.0 + time * 4.8 + float(layer) * 3.713 + )); + float tongue = smoothstep(0.20 + heightFraction * 0.27, 0.76, gasNoise); + float entrainment = pow(1.0 - heightFraction, 1.5); + float density = reaction * tongue * entrainment * 0.42; + opticalDepth += density; + hotGas += density * clamp(fuelState.r / 1.5, 0.0, 1.0) + * (1.0 - heightFraction * 0.62); + } + float flame = 1.0 - exp(-opticalDepth * 3.4); + float gasTemperature = opticalDepth > 0.0001 ? hotGas / opticalDepth : 0.0; + // Incandescent soot cools from pale yellow at its reaction zone to red at the tips. + float3 deepRed = float3(0.74, 0.025, 0.002); + float3 orange = float3(1.0, 0.25, 0.015); + float3 gold = float3(1.0, 0.73, 0.22); + float3 hotWhite = float3(1.0, 0.94, 0.72); + float3 flameColor = mix(deepRed, orange, smoothstep(0.10, 0.38, gasTemperature)); + flameColor = mix(flameColor, gold, smoothstep(0.38, 0.78, gasTemperature)); + flameColor = mix(flameColor, hotWhite, smoothstep(0.72, 1.0, gasTemperature) * 0.65); float sparkCell = floor(imageUV.x * 92.0); float sparkSeed = hash21(float2(sparkCell + seedOffset.x, 9.7 + seedOffset.y)); @@ -2040,7 +2162,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { sparkX += (sparkDrift - 0.5) * sparkTravel * 0.12; float sparkY = front - 0.018 - sparkTravel; float2 sparkDelta = float2( - (imageUV.x - sparkX) * aspect, + (imageUV.x - sparkX) * contentAspect, imageUV.y - sparkY ); float sparkHead = 1.0 - smoothstep(0.0012, 0.0065, length(sparkDelta)); @@ -2057,7 +2179,7 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * sparkFlicker; if (radialMode > 0.5 && ignitionCount > 0) { - float maximumRadius = length(float2(aspect, 1.0)) + 0.12; + float maximumRadius = length(float2(contentAspect, 1.0)) + 0.12; for (uint index = 0; index < ignitionCount; index++) { float4 ignition = ignitions[index]; float age = max(0.0, time - ignition.z); @@ -2078,13 +2200,13 @@ final class BurnRenderer: NSObject, MTKViewDelegate { ); float angle = particleSeed * 6.2831853; float2 particlePosition = ignition.xy + float2( - cos(angle) / aspect, + cos(angle) / contentAspect, sin(angle) ) * max(0.0, radius - emberWidth * 0.45); particlePosition.x += (driftSeed - 0.5) * particleAge * 0.12; particlePosition.y -= particleAge * (0.22 + particleSeed * 0.34); float2 particleDelta = float2( - (imageUV.x - particlePosition.x) * aspect, + (imageUV.x - particlePosition.x) * contentAspect, imageUV.y - particlePosition.y ); float particleHead = 1.0 - smoothstep( @@ -2116,11 +2238,11 @@ final class BurnRenderer: NSObject, MTKViewDelegate { * (1.0 - smoothstep(glowWidth, maximumFlameReach * 2.15, burnedDistance)) * (0.045 + 0.10 * fbm(float2( imageUV.x * 5.0 + time * 0.25 + seedOffset.x, - imageUV.y * 4.0 - time * 0.34 + seedOffset.y + imageUV.y * 4.0 + time * 0.34 + seedOffset.y ))); float steamNoise = fbm(float2( imageUV.x * 7.0 + time * 0.18 + seedOffset.y, - imageUV.y * 6.0 - time * 0.72 + seedOffset.x + imageUV.y * 6.0 + time * 0.72 + seedOffset.x )); float steam = insideMask * soakMode @@ -2136,56 +2258,27 @@ final class BurnRenderer: NSObject, MTKViewDelegate { + orange * emberEdge * 1.05 + flameColor * flame * 1.58 + hotWhite * hotCore * 1.95; - float outsideDistance = max(0.0, roundedRectangleDistance); - float shapeAntialias = max(fwidth(roundedRectangleDistance), 0.0005); - float exterior = 1.0 - insideMask; - float replacementActive = replacementInfo.x; - float hasNativeShadow = replacementInfo.y; - float survivingExterior = smoothstep(0.08, 0.72, keep); - float4 capturedShadow = windowShadow.sample( - imageSampler, - input.uv + replacementInfo.zw - ); - float nativeShadowAlpha = capturedShadow.a - * exterior - * survivingExterior - * replacementActive - * hasNativeShadow; - float fallbackExterior = smoothstep( - -shapeAntialias, - shapeAntialias, - roundedRectangleDistance - ); - float exteriorShadow = exterior - * (1.0 - smoothstep(0.006, 0.065, outsideDistance)) - * survivingExterior - * replacementActive - * (1.0 - hasNativeShadow) - * 0.30; - float exteriorRim = fallbackExterior - * (1.0 - smoothstep(0.0, 0.0045, outsideDistance)) - * survivingExterior - * replacementActive - * (1.0 - hasNativeShadow) - * 0.18; - float exteriorDepth = max( - nativeShadowAlpha, - max(exteriorShadow, exteriorRim) - ); - float outputAlpha = max( - source.a, - max(exteriorDepth, max(fireAlpha, max(spark, max(smoke, steam)))) - ); - float3 outputRGB = source.rgb * source.a - + fireRGB - + float3(1.0, 0.48, 0.045) * spark * 2.20 - + float3(0.10, 0.075, 0.068) * smoke - + float3(0.80, 0.88, 0.92) * steam * 1.18 - + capturedShadow.rgb * nativeShadowAlpha - + float3(0.012, 0.010, 0.009) * exteriorShadow - + float3(0.20, 0.17, 0.13) * exteriorRim; - - outputRGB = outputAlpha > 0.0 ? outputRGB / outputAlpha : 0.0; + // The padded capture also contains the body: only use it outside that crop. + // Corners already belong to the original body and must not receive alpha twice. + float exterior = 1.0 - rectangleMask; + float shadowVisibility = exterior * smoothstep(0.08, 0.72, keep) + * replacementInfo.x * replacementInfo.y; + float4 capturedShadow = windowShadow.sample(imageSampler, input.uv + replacementInfo.zw); + float4 surface = float4(source.rgb * source.a, source.a); + surface += capturedShadow * shadowVisibility * (1.0 - surface.a); + + float vaporAlpha = clamp(smoke + steam * 0.52, 0.0, 0.7); + float3 vaporColor = mix(float3(0.11, 0.10, 0.09), float3(0.78, 0.83, 0.86), + steam / max(smoke + steam, 0.001)); + float3 outputRGB = vaporColor * vaporAlpha + surface.rgb * (1.0 - vaporAlpha); + float outputAlpha = vaporAlpha + surface.a * (1.0 - vaporAlpha); + fireAlpha = clamp(fireAlpha, 0.0, 0.96); + float3 radiance = 1.0 - exp(-fireRGB * 1.1); + outputRGB = radiance * fireAlpha + outputRGB * (1.0 - fireAlpha); + outputAlpha = fireAlpha + outputAlpha * (1.0 - fireAlpha); + float sparkAlpha = clamp(spark, 0.0, 1.0); + outputRGB = float3(1.0, 0.68, 0.22) * sparkAlpha + outputRGB * (1.0 - sparkAlpha); + outputAlpha = sparkAlpha + outputAlpha * (1.0 - sparkAlpha); return float4(outputRGB, outputAlpha); } """# diff --git a/Sources/WindowBurn/InputDiagnostics.swift b/Sources/WindowBurn/InputDiagnostics.swift new file mode 100644 index 0000000..ba40e9f --- /dev/null +++ b/Sources/WindowBurn/InputDiagnostics.swift @@ -0,0 +1,297 @@ +import CoreGraphics +import Foundation +import OSLog +import QuartzCore + +/// Opt-in timing and event counts. Never records window contents or pointer coordinates. +enum InputDiagnostics { + static let isEnabled = CommandLine.arguments.contains("--input-diagnostics") + private static let recorder = InputDiagnosticsRecorder() + @MainActor private static var heartbeatTimer: Timer? + + @MainActor static func start() { + guard isEnabled, heartbeatTimer == nil else { return } + let recorder = recorder + recorder.heartbeat() + let timer = Timer(timeInterval: 0.1, repeats: true) { _ in recorder.heartbeat() } + heartbeatTimer = timer + RunLoop.main.add(timer, forMode: .common) + recorder.start() + } + + static func productionTap(_ tap: CFMachPort) { + guard isEnabled else { return } + recorder.productionTap(tap) + } + + static func eventTap( + type: CGEventType, duration: TimeInterval, timestamp: CGEventTimestamp = 0 + ) { + guard isEnabled else { return } + recorder.productionEvent(type: type, duration: duration, timestamp: timestamp) + } + + static func cursorMoved(point: CGPoint) { + guard isEnabled else { return } + recorder.cursorMoved(point: point) + } + + static func recoverySample(point: CGPoint, isPressed: Bool) { + guard isEnabled else { return } + recorder.recoverySample(point: point, isPressed: isPressed) + } + + static func drawableWait(_ duration: TimeInterval) { + guard isEnabled else { return } + recorder.drawableWait(duration) + } +} + +// All mutable metrics are guarded by the lock. The report source is configured once +// before resume; the HID tap and its run loop are owned exclusively by their thread. +private final class InputDiagnosticsRecorder: @unchecked Sendable { + private struct EventCounts { + var down = 0 + var moved = 0 + var dragged = 0 + var up = 0 + var disabledByTimeout = 0 + var disabledByUserInput = 0 + + mutating func add(_ type: CGEventType) { + switch type { + case .leftMouseDown: down += 1 + case .mouseMoved: moved += 1 + case .leftMouseDragged: dragged += 1 + case .leftMouseUp: up += 1 + case .tapDisabledByTimeout: disabledByTimeout += 1 + case .tapDisabledByUserInput: disabledByUserInput += 1 + default: break + } + } + + var description: String { + "down=\(down),move=\(moved),drag=\(dragged),up=\(up)," + + "timeout=\(disabledByTimeout),userDisabled=\(disabledByUserInput)" + } + } + + private struct IntervalMetrics { + var raw = EventCounts() + var production = EventCounts() + var maximumCallbackDuration: TimeInterval = 0 + var slowCallbacks = 0 + var eventAgeSamples = 0 + var maximumEventAge: TimeInterval = 0 + var cursorMoves = 0 + var cursorPositionChanges = 0 + var recoverySamples = 0 + var recoveryPositionChanges = 0 + var recoveryPressedSamples = 0 + var drawableSamples = 0 + var maximumDrawableWait: TimeInterval = 0 + } + + private struct State { + var started = false + var observerAvailable = false + var productionTap: CFMachPort? + var heartbeat: TimeInterval = 0 + var previousRecoveryPoint: CGPoint? + var previousCursorPoint: CGPoint? + var previousHIDPoint: CGPoint? + var previousSessionPoint: CGPoint? + var interval = IntervalMetrics() + } + + private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "input-diagnostics") + private let lock = NSLock() + private var state = State() + private let reportTimer = DispatchSource.makeTimerSource( + queue: DispatchQueue(label: "dev.malikov.WindowBurn.input-diagnostics", qos: .utility) + ) + + func start() { + let shouldStart = lock.withLock { + guard !state.started else { return false } + state.started = true + return true + } + guard shouldStart else { return } + let thread = Thread { [self] in runHIDObserver() } + thread.name = "Window Burn input diagnostics HID observer" + thread.qualityOfService = .userInteractive + thread.start() + reportTimer.setEventHandler { [weak self] in self?.report() } + reportTimer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(50)) + reportTimer.resume() + logger.notice("Input diagnostics enabled; interval counts only, no pointer coordinates") + } + + func heartbeat() { + lock.withLock { state.heartbeat = CACurrentMediaTime() } + } + + func productionTap(_ tap: CFMachPort) { + lock.withLock { state.productionTap = tap } + } + + func productionEvent( + type: CGEventType, duration: TimeInterval, timestamp: CGEventTimestamp + ) { + let eventAge: TimeInterval? + if timestamp != 0, type != .tapDisabledByTimeout, type != .tapDisabledByUserInput { + let uptimeNanoseconds = DispatchTime.now().uptimeNanoseconds + let elapsedNanoseconds = uptimeNanoseconds >= timestamp ? uptimeNanoseconds - timestamp : 0 + eventAge = max(0, Double(elapsedNanoseconds) / 1_000_000_000 - duration) + } else { + eventAge = nil + } + lock.withLock { + state.interval.production.add(type) + state.interval.maximumCallbackDuration = max( + state.interval.maximumCallbackDuration, duration + ) + if duration >= 0.016 { state.interval.slowCallbacks += 1 } + if let eventAge { + state.interval.eventAgeSamples += 1 + state.interval.maximumEventAge = max(state.interval.maximumEventAge, eventAge) + } + } + } + + func rawEvent(_ type: CGEventType) { + lock.withLock { state.interval.raw.add(type) } + } + + func cursorMoved(point: CGPoint) { + lock.withLock { + state.interval.cursorMoves += 1 + if let previous = state.previousCursorPoint, previous != point { + state.interval.cursorPositionChanges += 1 + } + state.previousCursorPoint = point + } + } + + func recoverySample(point: CGPoint, isPressed: Bool) { + lock.withLock { + state.interval.recoverySamples += 1 + if let previous = state.previousRecoveryPoint, previous != point { + state.interval.recoveryPositionChanges += 1 + } + if isPressed { state.interval.recoveryPressedSamples += 1 } + state.previousRecoveryPoint = point + } + } + + func drawableWait(_ duration: TimeInterval) { + lock.withLock { + state.interval.drawableSamples += 1 + state.interval.maximumDrawableWait = max(state.interval.maximumDrawableWait, duration) + } + } + + private func runHIDObserver() { + let context = InputDiagnosticsHIDContext(recorder: self) + let mask = [ + CGEventType.leftMouseDown, .mouseMoved, .leftMouseDragged, .leftMouseUp, + ].reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << $1.rawValue) } + guard + let tap = CGEvent.tapCreate( + tap: .cghidEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: inputDiagnosticsHIDCallback, + userInfo: Unmanaged.passUnretained(context).toOpaque() + ), + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + else { + logger.error("Read-only HID observer unavailable; other diagnostic metrics remain active") + return + } + context.tap = tap + let runLoop = CFRunLoopGetCurrent() + CFRunLoopAddSource(runLoop, source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + lock.withLock { state.observerAvailable = true } + withExtendedLifetime(context) { CFRunLoopRun() } + lock.withLock { state.observerAvailable = false } + CFRunLoopRemoveSource(runLoop, source, .commonModes) + CFMachPortInvalidate(tap) + } + + private func report() { + let now = CACurrentMediaTime() + let productionTap = lock.withLock { state.productionTap } + let productionTapEnabled = productionTap.map { CGEvent.tapIsEnabled(tap: $0) } + let hidPressed = CGEventSource.buttonState(.hidSystemState, button: .left) + let sessionPressed = CGEventSource.buttonState(.combinedSessionState, button: .left) + let hidPoint = CGEventSource(stateID: .hidSystemState).flatMap { CGEvent(source: $0)?.location } + let sessionPoint = CGEventSource(stateID: .combinedSessionState).flatMap { + CGEvent(source: $0)?.location + } + let report = lock.withLock { + let interval = state.interval + state.interval = IntervalMetrics() + let hidChanged = changed(previous: state.previousHIDPoint, current: hidPoint) + let sessionChanged = changed(previous: state.previousSessionPoint, current: sessionPoint) + state.previousHIDPoint = hidPoint + state.previousSessionPoint = sessionPoint + return ( + interval, max(0, now - state.heartbeat), state.observerAvailable, + hidChanged, sessionChanged + ) + } + let (metrics, heartbeatAge, observerAvailable, hidChanged, sessionChanged) = report + let message = + "hidObserver=\(observerAvailable) raw[\(metrics.raw.description)] " + + "tap[\(metrics.production.description)] " + + "tapEnabled=\(productionTapEnabled.map(String.init) ?? "unavailable") " + + "callbackMaxMs=\(milliseconds(metrics.maximumCallbackDuration)) " + + "callbacksOver16ms=\(metrics.slowCallbacks) " + + "eventAge[samples=\(metrics.eventAgeSamples),maxMs=\(milliseconds(metrics.maximumEventAge))] " + + "cursor[calls=\(metrics.cursorMoves),changed=\(metrics.cursorPositionChanges)] " + + "mainAgeMs=\(milliseconds(heartbeatAge)) " + + "recovery[samples=\(metrics.recoverySamples),changed=\(metrics.recoveryPositionChanges)," + + "pressed=\(metrics.recoveryPressedSamples)] " + + "buttons[hid=\(hidPressed),session=\(sessionPressed)] " + + "positionChanged[hid=\(hidChanged),session=\(sessionChanged)] " + + "drawable[samples=\(metrics.drawableSamples),maxMs=\(milliseconds(metrics.maximumDrawableWait))]" + logger.notice("\(message, privacy: .public)") + } + + private func changed(previous: CGPoint?, current: CGPoint?) -> String { + guard let previous, let current else { return "unavailable" } + return previous != current ? "yes" : "no" + } + + private func milliseconds(_ duration: TimeInterval) -> String { + String(format: "%.2f", duration * 1_000) + } +} + +/// Only the observer thread touches the tap. Its retained lifetime spans CFRunLoopRun. +private final class InputDiagnosticsHIDContext { + let recorder: InputDiagnosticsRecorder + var tap: CFMachPort? + + init(recorder: InputDiagnosticsRecorder) { self.recorder = recorder } +} + +private func inputDiagnosticsHIDCallback( + proxy: CGEventTapProxy, + type: CGEventType, + event: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + if let userInfo { + let context = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + context.recorder.rawEvent(type) + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput, let tap = context.tap { + CGEvent.tapEnable(tap: tap, enable: true) + } + } + return Unmanaged.passUnretained(event) +} diff --git a/Sources/WindowBurn/PointerTargetResolver.swift b/Sources/WindowBurn/PointerTargetResolver.swift new file mode 100644 index 0000000..690dfb8 --- /dev/null +++ b/Sources/WindowBurn/PointerTargetResolver.swift @@ -0,0 +1,120 @@ +import ApplicationServices +import Foundation +import OSLog +import WindowBurnCore + +struct ResolvedPointerTarget: Sendable { + let windowID: CGWindowID + let window: AccessibleWindow + let closeControl: AccessibleWindowControl? +} + +struct ResolvedPointerTargets: Sendable { + var windows = PointerTargetSnapshot(regions: [], capturedAt: 0) + var controls = PointerTargetSnapshot(regions: [], capturedAt: 0) + var targets: [UUID: ResolvedPointerTarget] = [:] +} + +/// AX and WindowServer queries run here, never on the mouse tap or the UI thread. +final class PointerTargetResolver: Sendable { + private let timer: DispatchSourceTimer + + init(publish: @escaping @Sendable (ResolvedPointerTargets) -> Void) { + let queue = DispatchQueue(label: "dev.malikov.WindowBurn.input-targets", qos: .userInitiated) + let diagnostics = PointerResolverDiagnostics() + timer = DispatchSource.makeTimerSource(queue: queue) + timer.setEventHandler { publish(Self.resolve(diagnostics: diagnostics)) } + timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(10)) + timer.resume() + } + + func stop() { timer.cancel() } + + static func nativeInputCandidates(from candidates: [PointWindowCandidate]) + -> [PointWindowCandidate] + { + // WindowServer lists visual surfaces, including click-through screen-recording + // and cursor overlays. Only normal application windows define native targets + // and occlusion; our effect panels have explicit interaction surfaces. + candidates.filter { $0.layer == 0 && $0.isOnScreen && $0.alpha > 0.01 } + } + + private static func resolve(diagnostics: PointerResolverDiagnostics) -> ResolvedPointerTargets { + let startedAt = ProcessInfo.processInfo.systemUptime + let deadline = startedAt + 0.2 + let ownPID = ProcessInfo.processInfo.processIdentifier + var windowRegions: [PointerTargetSnapshot.Region] = [] + var controlRegions: [PointerTargetSnapshot.Region] = [] + var targets: [UUID: ResolvedPointerTarget] = [:] + var windowsByPID: [pid_t: [AccessibleWindow]] = [:] + let candidates = WindowServerWindowService.onScreenWindows() + for candidate in Self.nativeInputCandidates(from: candidates) { + let blocked = PointerTargetSnapshot.Region(id: nil, frame: candidate.frame) + guard candidate.ownerPID != ownPID, + ProcessInfo.processInfo.systemUptime < deadline + else { + windowRegions.append(blocked) + controlRegions.append(blocked) + continue + } + if windowsByPID[candidate.ownerPID] == nil { + windowsByPID[candidate.ownerPID] = AccessibilityWindowService.preflightWindows( + ownerPID: candidate.ownerPID, deadline: deadline) + } + guard + let window = AccessibilityWindowService.window( + matching: TargetWindow( + ownerPID: candidate.ownerPID, title: candidate.title, frame: candidate.frame), + among: windowsByPID[candidate.ownerPID] ?? []), + window.target.frame.equalTo(candidate.frame) + else { + windowRegions.append(blocked) + controlRegions.append(blocked) + continue + } + let id = UUID() + let control = AccessibilityWindowService.closeControl(in: window, deadline: deadline) + // Both interactive tools eventually close the window. Unsupported windows pass through. + targets[id] = ResolvedPointerTarget( + windowID: candidate.id, window: window, closeControl: control?.0) + windowRegions.append(.init(id: control == nil ? nil : id, frame: candidate.frame)) + if let control { controlRegions.append(.init(id: id, frame: control.1)) } + controlRegions.append(blocked) + } + diagnostics.record( + duration: ProcessInfo.processInfo.systemUptime - startedAt, + windowCount: candidates.count, applicationCount: windowsByPID.count, + targetCount: windowRegions.filter { $0.id != nil }.count) + return ResolvedPointerTargets( + windows: .init(regions: windowRegions, capturedAt: startedAt), + controls: .init(regions: controlRegions, capturedAt: startedAt), targets: targets + ) + } +} + +/// Accessed exclusively by the resolver's serial queue. +private final class PointerResolverDiagnostics: @unchecked Sendable { + private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "input-diagnostics") + private var lastReportAt = ProcessInfo.processInfo.systemUptime + private var passes = 0 + private var maximumDuration: TimeInterval = 0 + private var budgetExhaustions = 0 + + func record(duration: TimeInterval, windowCount: Int, applicationCount: Int, targetCount: Int) { + guard InputDiagnostics.isEnabled else { return } + passes += 1 + maximumDuration = max(maximumDuration, duration) + if duration >= 0.2 { budgetExhaustions += 1 } + let now = ProcessInfo.processInfo.systemUptime + guard now - lastReportAt >= 1 else { return } + let message = + "resolver[passes=\(passes),maxMs=\(String(format: "%.2f", maximumDuration * 1_000))," + + "budgetExhaustions=\(budgetExhaustions),windows=\(windowCount)," + + "applications=\(applicationCount),targets=\(targetCount)]" + logger.notice("\(message, privacy: .public)") + lastReportAt = now + passes = 0 + maximumDuration = 0 + budgetExhaustions = 0 + } +} diff --git a/Sources/WindowBurn/QualityReviewController.swift b/Sources/WindowBurn/QualityReviewController.swift new file mode 100644 index 0000000..cbd5458 --- /dev/null +++ b/Sources/WindowBurn/QualityReviewController.swift @@ -0,0 +1,435 @@ +import AppKit +import ImageIO +import WindowBurnCore + +@MainActor +final class QualityReviewController { + private let overlay = BurnOverlayController() + private var fixture: NSWindow? + private var backdrop: NSWindow? + private var outputDirectory = URL(fileURLWithPath: "/tmp/window-burn-quality") + private var startedAt = CACurrentMediaTime() + private var events: [[String: Any]] = [] + private var completionReached = false + private var stage = "setup" + private var gpuSamples: [String: [Double]] = [:] + private var captureProcess: Process? + + func run(outputDirectory: URL) async throws { + self.outputDirectory = outputDirectory + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + startedAt = CACurrentMediaTime() + var result: Result? + let work = Task { @MainActor in + do { + try await self.performReview() + result = .success(()) + } catch { + result = .failure(error) + } + } + defer { + work.cancel() + if let captureProcess, captureProcess.isRunning { captureProcess.terminate() } + overlay.dismiss() + fixture?.orderOut(nil) + backdrop?.orderOut(nil) + fixture = nil + backdrop = nil + } + do { + for _ in 0..<600 { + if let result { return try result.get() } + try await Task.sleep(for: .milliseconds(100)) + } + throw failure("Quality review timed out after 60 seconds") + } catch { + try? status("failed: \(error.localizedDescription)") + throw error + } + } + + private func performReview() async throws { + let screen = NSScreen.main ?? NSScreen.screens[0] + let visible = screen.visibleFrame + let size = CGSize(width: min(760, visible.width - 240), height: min(430, visible.height - 260)) + let contentFrame = CGRect( + x: (visible.midX - size.width / 2).rounded(), + y: (visible.midY - size.height / 2).rounded(), + width: size.width, height: size.height + ) + let backdrop = NSWindow( + contentRect: contentFrame.insetBy(dx: -160, dy: -160), styleMask: .borderless, + backing: .buffered, defer: false + ) + backdrop.backgroundColor = NSColor(srgbRed: 0.13, green: 0.20, blue: 0.25, alpha: 1) + backdrop.isReleasedWhenClosed = false + backdrop.animationBehavior = .none + backdrop.level = .floating + backdrop.hasShadow = false + backdrop.orderFrontRegardless() + self.backdrop = backdrop + let fixture = NSWindow( + contentRect: contentFrame, styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false + ) + fixture.title = "Window Burn — native fixture" + fixture.hasShadow = true + fixture.isReleasedWhenClosed = false + fixture.animationBehavior = .none + fixture.hasShadow = true + fixture.level = NSWindow.Level(rawValue: NSWindow.Level.floating.rawValue + 1) + fixture.contentView = QualityFixtureView(frame: CGRect(origin: .zero, size: size)) + self.fixture = fixture + NSApp.activate(ignoringOtherApps: true) + fixture.makeKeyAndOrderFront(nil) + let targetFrame = CGRect( + x: fixture.frame.minX, + y: (NSScreen.screens.first?.frame.maxY ?? 0) - fixture.frame.maxY, + width: fixture.frame.width, height: fixture.frame.height + ) + try writeJSON( + [ + "x": targetFrame.minX, "y": targetFrame.minY, + "width": targetFrame.width, "height": targetFrame.height, + "padding": BurnOverlayController.padding, + "screenScale": screen.backingScaleFactor, + ], name: "fixture.json") + try status("fixture-ready") + try await Task.sleep(for: .seconds(1)) + let target = TargetWindow( + ownerPID: ProcessInfo.processInfo.processIdentifier, title: fixture.title, frame: targetFrame + ) + let captured = try await WindowCaptureService.capture(target: target) + try Task.checkCancellation() + try verifyCapturedShadow(captured, name: "capture-shadow") + let native = try await snapshot(frame: targetFrame, name: "01-native") + let panelFrame = ScreenCoordinateConverter.appKitFrame( + for: captured.captureFrame, mainDisplayHeight: NSScreen.screens.first?.frame.maxY ?? 0, + padding: BurnOverlayController.padding + ) + let profile = BurnProfile(duration: 2.4, seed: 41, tilt: 0.03, turbulence: 1.1, charWidth: 0.07) + completionReached = false + try await overlay.present( + image: captured.image, handoffImage: captured.handoffImage, shadowImage: captured.shadowImage, + shadowSamplingOffset: captured.shadowSamplingOffset, + panelFrame: panelFrame, profile: profile, startImmediately: false, + completion: { [weak self] in self?.completionReached = true } + ) + try status("cover-presented") + let cover = try await snapshot(frame: targetFrame, name: "02-cover") + fixture.orderOut(nil) + try await Task.sleep(for: .milliseconds(150)) + let closedUnderCover = try await snapshot(frame: targetFrame, name: "02b-closed-under-cover") + guard try await overlay.activateReplacementSurface() else { throw failure("No replacement") } + try status("replacement-presented") + let replacement = try await snapshot(frame: targetFrame, name: "03-replacement") + try writeJSON( + [ + "nativeToCover": difference(native, cover, frame: targetFrame), + "nativeToClosedUnderCover": difference(native, closedUnderCover, frame: targetFrame), + "nativeToReplacement": difference(native, replacement, frame: targetFrame), + "note": + "RGB errors are 8-bit sRGB values; independent /usr/sbin/screencapture display snapshots are compared.", + ], name: "comparison.json") + try await Task.sleep(for: .milliseconds(500)) + try status("burning") + overlay.startBurning() + try await waitForEffect() + + try await reviewTranslucency(fixture: fixture, target: target, panelFrame: panelFrame) + fixture.makeKeyAndOrderFront(nil) + try await Task.sleep(for: .milliseconds(350)) + let soaked = try await WindowCaptureService.capture(target: target) + try Task.checkCancellation() + let points = [ + BurnIgnitionPoint(x: 0.35, y: 0.34), BurnIgnitionPoint(x: 0.45, y: 0.42), + BurnIgnitionPoint(x: 0.56, y: 0.37), BurnIgnitionPoint(x: 0.61, y: 0.52), + ] + completionReached = false + try await overlay.present( + image: soaked.image, backdropImage: soaked.backdropImage, shadowImage: soaked.shadowImage, + shadowSamplingOffset: soaked.shadowSamplingOffset, panelFrame: panelFrame, + profile: BurnProfile(duration: 6, seed: 41, tilt: 0, turbulence: 1.1, charWidth: 0.07), + style: .soakAndBurn(initialSoakPoints: [points[0]]), + completion: { [weak self] in self?.completionReached = true } + ) + try status("soaking") + for point in points.dropFirst() { + try await samplePause(seconds: 0.45) + _ = overlay.addSoakPoint(point) + } + try await samplePause(seconds: 1.5) + _ = overlay.finishSoaking() + _ = try await snapshot(frame: targetFrame, name: "04-wet") + try await samplePause(seconds: 1) + let wetHandoff = await WindowCaptureService.captureHandoff(targetFrame: targetFrame) + try Task.checkCancellation() + guard try await overlay.prepareForIgnitionHandoff(handoffImage: wetHandoff) else { + throw failure("Could not prepare wet handoff") + } + fixture.orderOut(nil) + try await Task.sleep(for: .milliseconds(150)) + guard try await overlay.activateReplacementSurface(), + overlay.igniteSoakedWindow(at: BurnIgnitionPoint(x: 0.48, y: 0.65)) + else { throw failure("Could not ignite wet fixture") } + try status("wet-burning") + try await samplePause(seconds: 1.5) + _ = try await snapshot(frame: targetFrame, name: "05-wet-fire") + try await waitForEffect() + try writeJSON(gpuStatistics(), name: "gpu-timings.json") + try status("complete") + } + + private func reviewTranslucency(fixture: NSWindow, target: TargetWindow, panelFrame: CGRect) + async throws + { + fixture.alphaValue = 0.78 + defer { fixture.alphaValue = 1 } + fixture.makeKeyAndOrderFront(nil) + try await Task.sleep(for: .milliseconds(350)) + let captured = try await WindowCaptureService.capture(target: target) + try Task.checkCancellation() + try verifyCapturedShadow(captured, name: "translucent-capture-shadow") + let native = try await snapshot(frame: target.frame, name: "06-translucent-native") + try await overlay.present( + image: captured.image, handoffImage: captured.handoffImage, shadowImage: captured.shadowImage, + shadowSamplingOffset: captured.shadowSamplingOffset, panelFrame: panelFrame, + profile: BurnProfile(duration: 2, seed: 41, tilt: 0, turbulence: 1, charWidth: 0.07), + startImmediately: false + ) + try status("translucent-cover-presented") + let cover = try await snapshot(frame: target.frame, name: "07-translucent-cover") + fixture.orderOut(nil) + try await Task.sleep(for: .milliseconds(150)) + guard try await overlay.activateReplacementSurface() else { + throw failure("No translucent replacement") + } + let replacement = try await snapshot(frame: target.frame, name: "08-translucent-replacement") + try writeJSON( + [ + "windowAlpha": 0.78, + "nativeToCover": difference(native, cover, frame: target.frame), + "nativeToReplacement": difference(native, replacement, frame: target.frame), + ], name: "translucent-comparison.json") + overlay.dismiss() + } + + private func waitForEffect() async throws { + for _ in 0..<240 { + if completionReached { return } + try await samplePause(seconds: 0.05) + } + throw failure("Effect did not complete within 12 seconds") + } + + private func samplePause(seconds: TimeInterval) async throws { + let end = CACurrentMediaTime() + seconds + while CACurrentMediaTime() < end { + if let duration = overlay.lastGPUFrameDuration, duration.isFinite, duration > 0 { + gpuSamples[stage, default: []].append(duration * 1_000) + } + try await Task.sleep(for: .milliseconds(50)) + } + } + + private func gpuStatistics() -> [String: Any] { + var output: [String: Any] = [ + "note": + "GPU execution milliseconds sampled every ~50ms; excludes presentation and CPU time and is not FPS." + ] + for (name, samples) in gpuSamples { + let sorted = samples.sorted() + output[name] = [ + "samples": sorted.count, + "p50ms": sorted[Int(Double(sorted.count - 1) * 0.50)], + "p95ms": sorted[Int(Double(sorted.count - 1) * 0.95)], + "maxms": sorted[sorted.count - 1], + ] + } + return output + } + + private func snapshot(frame: CGRect, name: String) async throws -> CGImage { + try Task.checkCancellation() + let url = outputDirectory.appendingPathComponent("\(name).png") + let padded = frame.insetBy( + dx: -BurnOverlayController.padding, dy: -BurnOverlayController.padding) + let region = [padded.minX, padded.minY, padded.width, padded.height] + .map { String(Int($0.rounded())) }.joined(separator: ",") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + process.arguments = ["-x", "-R\(region)", url.path] + process.standardOutput = FileHandle.nullDevice + let errors = Pipe() + process.standardError = errors + captureProcess = process + defer { captureProcess = nil } + let exitStatus: Int32 = try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { completed in + continuation.resume(returning: completed.terminationStatus) + } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: error) + } + } + try Task.checkCancellation() + guard exitStatus == 0 else { + let detail = String( + decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + throw failure("Screen capture failed for \(name): \(detail)") + } + guard + let source = CGImageSourceCreateWithURL(url as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { throw failure("Could not decode independent screenshot \(name).png") } + return image + } + + private func verifyCapturedShadow(_ captured: CapturedWindow, name: String) throws { + guard let image = captured.shadowImage, let data = pixels(image) else { + throw failure("The native fixture shadow was not captured") + } + let scale = CGFloat(captured.image.width) / captured.captureFrame.width + let padding = Int((BurnOverlayController.padding * scale).rounded()) + var maximumAlpha = 0 + var nonzeroPixels = 0 + var sampledPixels = 0 + for y in 0..= padding + captured.image.width + || y >= padding + captured.image.height + { + let alpha = Int(data[(y * image.width + x) * 4 + 3]) + maximumAlpha = max(maximumAlpha, alpha) + nonzeroPixels += alpha > 0 ? 1 : 0 + sampledPixels += 1 + } + } + try writeJSON( + [ + "maximumExteriorAlpha": maximumAlpha, "nonzeroExteriorPixels": nonzeroPixels, + "sampledExteriorPixels": sampledPixels, + "hasNativeShadow": maximumAlpha > 0 && nonzeroPixels > 0, + "note": "The fixture hasShadow is true; fully transparent exterior is a capture failure.", + ], name: "\(name).json") + guard maximumAlpha > 0, nonzeroPixels > 0 else { + throw failure("Captured fixture shadow is fully transparent outside the window body") + } + } + + private func difference(_ reference: CGImage, _ candidate: CGImage, frame: CGRect) -> [String: + Any] + { + guard reference.width == candidate.width, reference.height == candidate.height, + let lhs = pixels(reference), let rhs = pixels(candidate) + else { return ["dimensionsMatch": false] } + let scale = CGFloat(reference.width) / (frame.width + BurnOverlayController.padding * 2) + let padding = BurnOverlayController.padding * scale + let content = CGRect( + x: padding, y: padding, width: frame.width * scale, height: frame.height * scale) + var groups = Array(repeating: (sum: 0.0, maximum: 0, samples: 0, changed: 0), count: 3) + for y in 0.. 2 ? 1 : 0 + } + } + } + var result: [String: Any] = [ + "dimensionsMatch": true, "width": reference.width, "height": reference.height, + ] + for (index, name) in ["content", "corners", "shadow"].enumerated() { + let value = groups[index] + result[name] = [ + "meanAbsoluteError": value.sum / Double(max(1, value.samples)), + "maximumError": value.maximum, + "fractionOverTwo": Double(value.changed) / Double(max(1, value.samples)), + "withinTolerance": value.sum / Double(max(1, value.samples)) <= 1 + && value.maximum <= 8, + ] + } + result["tolerance"] = ["meanAbsoluteErrorAtMost": 1, "maximumErrorAtMost": 8] + return result + } + + private func pixels(_ image: CGImage) -> [UInt8]? { + var data = [UInt8](repeating: 0, count: image.width * image.height * 4) + let success = data.withUnsafeMutableBytes { bytes in + guard + let context = CGContext( + data: bytes.baseAddress, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + | CGBitmapInfo.byteOrder32Big.rawValue + ) + else { return false } + context.translateBy(x: 0, y: CGFloat(image.height)) + context.scaleBy(x: 1, y: -1) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return true + } + return success ? data : nil + } + + private func status(_ message: String) throws { + stage = message + events.append(["stage": message, "seconds": CACurrentMediaTime() - startedAt]) + try message.write( + to: outputDirectory.appendingPathComponent("status.txt"), atomically: true, encoding: .utf8) + try writeJSON(events, name: "timeline.json") + } + + private func writeJSON(_ value: Any, name: String) throws { + try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) + .write(to: outputDirectory.appendingPathComponent(name), options: .atomic) + } + + private func failure(_ message: String) -> NSError { + NSError( + domain: "WindowBurn.QualityReview", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) + } +} + +@MainActor +private final class QualityFixtureView: NSView { + override func draw(_ dirtyRect: NSRect) { + NSColor(srgbRed: 0.97, green: 0.95, blue: 0.90, alpha: 1).setFill() + bounds.fill() + let text: [(String, CGFloat, CGFloat)] = [ + ("A window, down to the last pixel.", 30, bounds.height - 80), + ("Native titlebar · Retina text · preserved corners and shadow", 15, bounds.height - 115), + ("Heat dries the paper. Flame follows the remaining fuel.", 18, 48), + ] + for (value, size, y) in text { + (value as NSString).draw( + at: CGPoint(x: 32, y: y), + withAttributes: [ + .font: NSFont.systemFont(ofSize: size, weight: size > 20 ? .semibold : .regular), + .foregroundColor: NSColor(srgbRed: 0.10, green: 0.15, blue: 0.18, alpha: 1), + ]) + } + for (index, color) in [NSColor.systemOrange, .systemTeal, .systemIndigo, .systemPink] + .enumerated() + { + color.setFill() + NSBezierPath( + roundedRect: CGRect(x: 32 + CGFloat(index) * 174, y: 105, width: 156, height: 130), + xRadius: 12, yRadius: 12 + ).fill() + } + } +} diff --git a/Sources/WindowBurn/TorchCursorController.swift b/Sources/WindowBurn/TorchCursorController.swift index a68bb4b..fe3c005 100644 --- a/Sources/WindowBurn/TorchCursorController.swift +++ b/Sources/WindowBurn/TorchCursorController.swift @@ -30,10 +30,14 @@ final class TorchCursorController { private var cursorView: EffectCursorView? private var timer: Timer? private var didHideSystemCursor = false - private var isTemporarilyHidden = false - private var isTrackingPointerDrag = false + private var lastArtworkUpdate: TimeInterval = 0 + var pointerLocation: (() -> CGPoint?)? private(set) var style: EffectCursorStyle? var isEnabled: Bool { style != nil } + var captureWindowIDs: Set { + guard let panel, panel.windowNumber > 0 else { return [] } + return [CGWindowID(panel.windowNumber)] + } func setEnabled(_ enabled: Bool) { setStyle(enabled ? .torch : nil) @@ -44,38 +48,15 @@ final class TorchCursorController { self.style = style guard let style else { - isTrackingPointerDrag = false hide() return } cursorView?.style = style panel?.setContentSize(style.size) - guard !isTemporarilyHidden else { return } show() } - func setTemporarilyHidden(_ hidden: Bool) { - guard hidden != isTemporarilyHidden else { return } - isTemporarilyHidden = hidden - if hidden { - hide() - } else if style != nil { - show() - } - } - - func withoutOverlay(_ operation: () -> Result) -> Result { - panel?.orderOut(nil) - defer { - if isEnabled, !isTemporarilyHidden { - updatePosition() - panel?.orderFrontRegardless() - } - } - return operation() - } - func move(toQuartzPoint point: CGPoint) { let appKitPoint = ScreenCoordinateConverter.appKitPoint( forQuartzPoint: point, @@ -84,18 +65,8 @@ final class TorchCursorController { move(toAppKitPoint: appKitPoint) } - func beginPointerDrag(atQuartzPoint point: CGPoint) { - isTrackingPointerDrag = true - move(toQuartzPoint: point) - } - - func endPointerDrag(atQuartzPoint point: CGPoint) { - move(toQuartzPoint: point) - isTrackingPointerDrag = false - } - private func show() { - guard let style, !isTemporarilyHidden else { return } + guard let style else { return } if panel == nil { let panel = NSPanel( @@ -134,7 +105,7 @@ final class TorchCursorController { if timer == nil { let timer = Timer( - timeInterval: 1.0 / 30.0, + timeInterval: 1.0 / 60.0, target: self, selector: #selector(updatePosition), userInfo: nil, @@ -156,26 +127,28 @@ final class TorchCursorController { } @objc private func updatePosition() { - if !isTrackingPointerDrag { - move(toAppKitPoint: NSEvent.mouseLocation) + if let point = pointerLocation?() { + move(toQuartzPoint: point) } else { - updateArtwork() + move(toAppKitPoint: NSEvent.mouseLocation) } } private func move(toAppKitPoint point: CGPoint) { guard let style else { return } - panel?.setFrameOrigin( - CGPoint( - x: point.x - style.hotSpot.x, - y: point.y - style.hotSpot.y - ) - ) + let origin = CGPoint(x: point.x - style.hotSpot.x, y: point.y - style.hotSpot.y) + if panel?.frame.origin != origin { panel?.setFrameOrigin(origin) } + InputDiagnostics.cursorMoved(point: point) updateArtwork() } private func updateArtwork() { - cursorView?.animationTime = ProcessInfo.processInfo.systemUptime + // The idle badge is static. Artwork animates at 30 Hz; pointer motion stays at 60 Hz. + guard style != .adultBadge(isSoaking: false) else { return } + let now = ProcessInfo.processInfo.systemUptime + guard now - lastArtworkUpdate >= 1.0 / 30.0 else { return } + lastArtworkUpdate = now + cursorView?.animationTime = now cursorView?.needsDisplay = true } } diff --git a/Sources/WindowBurn/WindowCaptureService.swift b/Sources/WindowBurn/WindowCaptureService.swift index 9bec94d..a8406cc 100644 --- a/Sources/WindowBurn/WindowCaptureService.swift +++ b/Sources/WindowBurn/WindowCaptureService.swift @@ -1,6 +1,6 @@ import CoreGraphics import OSLog -import ScreenCaptureKit +@preconcurrency import ScreenCaptureKit import WindowBurnCore struct CapturedWindow { @@ -9,12 +9,14 @@ struct CapturedWindow { let shadowImage: CGImage? let shadowSamplingOffset: CGPoint let captureFrame: CGRect + let handoffImage: CGImage? } enum WindowCaptureError: LocalizedError { case permissionMissing case noMatchingWindow case noMatchingDisplay + case invalidImage var errorDescription: String? { switch self { @@ -24,14 +26,20 @@ enum WindowCaptureError: LocalizedError { "ScreenCaptureKit could not match the focused window." case .noMatchingDisplay: "ScreenCaptureKit could not match the focused window to a display." + case .invalidImage: + "ScreenCaptureKit returned an image that could not be cropped to the window." } } } +@MainActor enum WindowCaptureService { private static let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "capture") - static func capture(target: TargetWindow) async throws -> CapturedWindow { + static func capture( + target: TargetWindow, + excludingWindowIDs: Set = [] + ) async throws -> CapturedWindow { guard PermissionService.hasScreenCaptureAccess else { throw WindowCaptureError.permissionMissing } @@ -56,35 +64,6 @@ enum WindowCaptureService { throw WindowCaptureError.noMatchingWindow } - let filter = SCContentFilter(desktopIndependentWindow: window) - let pixelSize = CapturePixelSizing.pixelSize( - for: window.frame.size, - pointPixelScale: CGFloat(filter.pointPixelScale) - ) - let configuration = SCStreamConfiguration() - configuration.width = pixelSize.width - configuration.height = pixelSize.height - configuration.showsCursor = false - configuration.ignoreShadowsSingleWindow = true - configuration.scalesToFit = true - configuration.preservesAspectRatio = true - - logger.info( - "Capturing \(window.frame.width, format: .fixed(precision: 0))×\(window.frame.height, format: .fixed(precision: 0)) points at \(filter.pointPixelScale, format: .fixed(precision: 2))× into \(pixelSize.width)×\(pixelSize.height) pixels" - ) - - let image = try await SCScreenshotManager.captureImage( - contentFilter: filter, - configuration: configuration - ) - - let shadowCapture = await captureAlignedShadow( - contentFilter: filter, - windowImage: image, - pointPixelScale: CGFloat(filter.pointPixelScale), - padding: BurnOverlayController.padding - ) - let displayFrames = content.displays.map(\.frame) guard let displayIndex = BackdropCaptureGeometry.displayIndex( @@ -95,46 +74,228 @@ enum WindowCaptureService { throw WindowCaptureError.noMatchingDisplay } let display = content.displays[displayIndex] - let backdropFilter = SCContentFilter(display: display, excludingWindows: [window]) - let backdropConfiguration = SCStreamConfiguration() - backdropConfiguration.width = pixelSize.width - backdropConfiguration.height = pixelSize.height - backdropConfiguration.sourceRect = BackdropCaptureGeometry.sourceRect( - for: window.frame, - in: display.frame + let windowFilter = SCContentFilter(desktopIndependentWindow: window) + let pointPixelScale = CGFloat(windowFilter.pointPixelScale) + let pixelSize = CapturePixelSizing.pixelSize( + for: window.frame.size, + pointPixelScale: pointPixelScale + ) + let paddedFrame = window.frame.insetBy( + dx: -BurnOverlayController.padding, + dy: -BurnOverlayController.padding ) - backdropConfiguration.showsCursor = false - backdropConfiguration.scalesToFit = true - backdropConfiguration.preservesAspectRatio = true + let paddedRegion = DisplayCaptureRegion( + captureFrame: paddedFrame, + displayFrame: display.frame, + pointPixelScale: pointPixelScale + ) + let usesDisplayGeometry = display.frame.contains(window.frame) - logger.info( - "Capturing the same rect from display \(display.displayID) with target window \(window.windowID) excluded" + // The window and its native shadow come from one compositor image. All + // independent captures run together so their contents stay close in time. + async let surface = { @MainActor in + try await captureSurface( + window: window, + display: display, + independentFilter: windowFilter, + pixelSize: pixelSize, + pointPixelScale: pointPixelScale, + paddedRegion: usesDisplayGeometry ? paddedRegion : nil + ) + }() + async let backdrop = { @MainActor in + try await captureBackdrop( + window: window, + display: display, + pointPixelScale: pointPixelScale, + excluding: content.windows.filter { excludingWindowIDs.contains($0.windowID) } + ) + }() + async let handoff = { @MainActor in + await captureCompositedPatch( + display: display, + region: usesDisplayGeometry ? paddedRegion : nil, + excluding: content.windows.filter { excludingWindowIDs.contains($0.windowID) } + ) + }() + let ((image, shadowImage, shadowOffset), backdropImage, handoffImage) = try await ( + surface, backdrop, handoff ) - let backdropImage = try await SCScreenshotManager.captureImage( - contentFilter: backdropFilter, - configuration: backdropConfiguration + + logger.info( + "Captured \(window.frame.width, format: .fixed(precision: 0))×\(window.frame.height, format: .fixed(precision: 0)) points at \(pointPixelScale, format: .fixed(precision: 2))×; display geometry: \(usesDisplayGeometry)" ) return CapturedWindow( image: image, backdropImage: backdropImage, - shadowImage: shadowCapture?.image, - shadowSamplingOffset: shadowCapture?.samplingOffset ?? .zero, - captureFrame: window.frame + shadowImage: shadowImage, + shadowSamplingOffset: shadowOffset, + captureFrame: window.frame, + handoffImage: handoffImage ) } + /// Refreshes the visible compositor surface immediately before a delayed handoff. + /// The frame is the unpadded window frame in Quartz screen coordinates. + static func captureHandoff( + targetFrame: CGRect, + excludingWindowIDs: Set = [] + ) async -> CGImage? { + guard PermissionService.hasScreenCaptureAccess else { return nil } + do { + let content = try await SCShareableContent.excludingDesktopWindows( + false, + onScreenWindowsOnly: true + ) + guard let display = content.displays.first(where: { $0.frame.contains(targetFrame) }) else { + return nil + } + let filter = SCContentFilter(display: display, excludingWindows: []) + let region = DisplayCaptureRegion( + captureFrame: targetFrame.insetBy( + dx: -BurnOverlayController.padding, + dy: -BurnOverlayController.padding + ), + displayFrame: display.frame, + pointPixelScale: CGFloat(filter.pointPixelScale) + ) + return await captureCompositedPatch( + display: display, + region: region, + excluding: content.windows.filter { excludingWindowIDs.contains($0.windowID) } + ) + } catch { + logger.warning( + "Handoff snapshot unavailable: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private static func captureSurface( + window: SCWindow, + display: SCDisplay, + independentFilter: SCContentFilter, + pixelSize: PixelSize, + pointPixelScale: CGFloat, + paddedRegion: DisplayCaptureRegion? + ) async throws -> (CGImage, CGImage?, CGPoint) { + if let paddedRegion { + let configuration = displayConfiguration(for: paddedRegion) + let image = try await SCScreenshotManager.captureImage( + contentFilter: SCContentFilter(display: display, including: [window]), + configuration: configuration + ) + let paddingPixels = (BurnOverlayController.padding * pointPixelScale).rounded() + guard + let windowImage = image.cropping( + to: CGRect( + x: paddingPixels, + y: paddingPixels, + width: CGFloat(pixelSize.width), + height: CGFloat(pixelSize.height) + ) + ) + else { throw WindowCaptureError.invalidImage } + return (windowImage, image, .zero) + } + + // A display filter clips a window spanning multiple displays. Preserve the + // desktop-independent fallback for this case until multi-display compositing exists. + let configuration = SCStreamConfiguration() + configuration.width = pixelSize.width + configuration.height = pixelSize.height + configuration.showsCursor = false + configuration.ignoreShadowsSingleWindow = true + configuration.scalesToFit = false + configuration.preservesAspectRatio = true + configuration.colorSpaceName = CGColorSpace.displayP3 + async let image = { @MainActor in + try await SCScreenshotManager.captureImage( + contentFilter: independentFilter, + configuration: configuration + ) + }() + async let shadow = { @MainActor in + await captureAlignedShadow( + contentFilter: independentFilter, + expectedContentSize: pixelSize, + pointPixelScale: pointPixelScale, + padding: BurnOverlayController.padding + ) + }() + let (windowImage, shadowCapture) = try await (image, shadow) + return (windowImage, shadowCapture?.image, shadowCapture?.samplingOffset ?? .zero) + } + + private static func captureBackdrop( + window: SCWindow, + display: SCDisplay, + pointPixelScale: CGFloat, + excluding windows: [SCWindow] + ) async throws -> CGImage { + guard + let region = DisplayCaptureRegion( + captureFrame: window.frame, + displayFrame: display.frame, + pointPixelScale: pointPixelScale + ) + else { throw WindowCaptureError.noMatchingDisplay } + return try await SCScreenshotManager.captureImage( + contentFilter: SCContentFilter(display: display, excludingWindows: [window] + windows), + configuration: displayConfiguration(for: region) + ) + } + + private static func captureCompositedPatch( + display: SCDisplay, + region: DisplayCaptureRegion?, + excluding windows: [SCWindow] + ) async -> CGImage? { + guard let region else { return nil } + do { + return try await SCScreenshotManager.captureImage( + contentFilter: SCContentFilter(display: display, excludingWindows: windows), + configuration: displayConfiguration(for: region) + ) + } catch { + logger.warning( + "Handoff snapshot unavailable: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + private static func displayConfiguration(for region: DisplayCaptureRegion) + -> SCStreamConfiguration + { + let configuration = SCStreamConfiguration() + configuration.width = region.pixelSize.width + configuration.height = region.pixelSize.height + configuration.sourceRect = region.sourceRect + configuration.destinationRect = region.destinationRect + configuration.showsCursor = false + // Screenshot capture applies the single-window shadow flag to display + // filters too. Its default is true, which silently removes native shadows. + configuration.ignoreShadowsDisplay = false + configuration.ignoreShadowsSingleWindow = false + configuration.scalesToFit = false + configuration.preservesAspectRatio = true + configuration.colorSpaceName = CGColorSpace.displayP3 + return configuration + } + private static func captureAlignedShadow( contentFilter: SCContentFilter, - windowImage: CGImage, + expectedContentSize: PixelSize, pointPixelScale: CGFloat, padding: CGFloat ) async -> (image: CGImage, samplingOffset: CGPoint)? { let paddingPixels = max(1, Int((padding * pointPixelScale).rounded())) let configuration = SCStreamConfiguration() - configuration.width = windowImage.width + paddingPixels * 2 - configuration.height = windowImage.height + paddingPixels * 2 + configuration.width = expectedContentSize.width + paddingPixels * 2 + configuration.height = expectedContentSize.height + paddingPixels * 2 configuration.showsCursor = false configuration.ignoreShadowsSingleWindow = false + configuration.colorSpaceName = CGColorSpace.displayP3 configuration.scalesToFit = false configuration.preservesAspectRatio = true @@ -146,7 +307,10 @@ enum WindowCaptureService { guard let capturedContentOrigin = opaqueContentOrigin( in: shadowImage, - expectedContentSize: CGSize(width: windowImage.width, height: windowImage.height) + expectedContentSize: CGSize( + width: expectedContentSize.width, + height: expectedContentSize.height + ) ) else { logger.warning("Could not align the captured native window shadow") diff --git a/Sources/WindowBurn/WindowControlInterceptor.swift b/Sources/WindowBurn/WindowControlInterceptor.swift index 3748339..16bfaef 100644 --- a/Sources/WindowBurn/WindowControlInterceptor.swift +++ b/Sources/WindowBurn/WindowControlInterceptor.swift @@ -2,6 +2,7 @@ import ApplicationServices import CoreGraphics import Foundation import OSLog +import WindowBurnCore enum WindowControlInterceptorError: LocalizedError { case eventTapUnavailable @@ -11,28 +12,33 @@ enum WindowControlInterceptorError: LocalizedError { } } -enum SoakAndBurnPointerEvent { - case down - case dragged - case up -} +enum SoakAndBurnPointerEvent { case down, dragged, up } @MainActor final class WindowControlInterceptor { typealias CloseHandler = @MainActor (AccessibleWindowControl) -> Bool - typealias TorchHandler = @MainActor (CGPoint) -> Bool - typealias SoakAndBurnHandler = @MainActor (SoakAndBurnPointerEvent, CGPoint) -> Bool + typealias TorchHandler = @MainActor (CGPoint, AccessibleWindow?) -> Bool + typealias SoakAndBurnHandler = + @MainActor (SoakAndBurnPointerEvent, CGPoint, AccessibleWindow?) -> Bool - private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "interceptor") private let closeHandler: CloseHandler private let torchHandler: TorchHandler private let soakAndBurnHandler: SoakAndBurnHandler - private var eventTap: CFMachPort? - private var runLoopSource: CFRunLoopSource? - private var suppressNextLeftMouseUp = false - private var isSuppressingSoakSequence = false - var isTorchModeEnabled = false - var isSoakAndBurnModeEnabled = false + private let input = PointerTapState() + private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "input-diagnostics") + private var resolver: PointerTargetResolver? + private var drainTimer: Timer? + private var acceptedSoakSequence: UInt64? + var interactionState: (() -> (surfaces: [PointerTargetSnapshot.Region], canStart: Bool))? + var isMenuTracking = false { didSet { publishConfiguration() } } + var isTorchModeEnabled = false { didSet { publishConfiguration() } } + var isSoakAndBurnModeEnabled = false { + didSet { + if !isSoakAndBurnModeEnabled { acceptedSoakSequence = nil } + publishConfiguration() + } + } + var latestPointerLocation: CGPoint? { input.latestPointerLocation } init( closeHandler: @escaping CloseHandler, @@ -42,105 +48,342 @@ final class WindowControlInterceptor { self.closeHandler = closeHandler self.torchHandler = torchHandler self.soakAndBurnHandler = soakAndBurnHandler - - let eventMask = - (CGEventMask(1) << CGEventType.leftMouseDown.rawValue) - | (CGEventMask(1) << CGEventType.leftMouseDragged.rawValue) - | (CGEventMask(1) << CGEventType.leftMouseUp.rawValue) - guard - let eventTap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: eventMask, - callback: windowControlEventTapCallback, - userInfo: Unmanaged.passUnretained(self).toOpaque() - ) - else { - throw WindowControlInterceptorError.eventTapUnavailable + try input.start() + resolver = PointerTargetResolver { [input] in input.publishTargets($0) } + let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.drain() } } - - let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) - self.eventTap = eventTap - runLoopSource = source - CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) - CGEvent.tapEnable(tap: eventTap, enable: true) - logger.info("Close-button, torch, and soak-and-burn mouse interception is active") + RunLoop.main.add(timer, forMode: .common) + drainTimer = timer } func stop() { - guard let runLoopSource else { return } - CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, .commonModes) - self.runLoopSource = nil - eventTap = nil + resolver?.stop() + resolver = nil + drainTimer?.invalidate() + drainTimer = nil + input.stop() } - fileprivate func shouldSuppress(type: CGEventType, location: CGPoint) -> Bool { - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - if let eventTap { - CGEvent.tapEnable(tap: eventTap, enable: true) - logger.notice("Mouse event tap was re-enabled after macOS disabled it") + private func publishConfiguration() { + let state = interactionState?() + input.configure( + mode: isSoakAndBurnModeEnabled ? .soak : (isTorchModeEnabled ? .torch : .close), + surfaces: state?.surfaces ?? [], canStart: state?.canStart ?? true, + isSuspended: isMenuTracking + ) + } + + private func drain() { + let batch = input.drain() + for diagnostic in batch.diagnostics { + logger.notice( + "Pointer down: mode=\(diagnostic.mode, privacy: .public) decision=\(diagnostic.reason, privacy: .public) eventWindow=\(diagnostic.eventWindowID) preparedWindow=\(diagnostic.preparedWindowID) snapshotAgeMs=\(diagnostic.snapshotAge * 1_000, format: .fixed(precision: 2))" + ) + } + for event in batch.events { + switch event.kind { + case .down: + guard let id = event.targetID, let route = batch.routes[id] else { continue } + switch route { + case .close(let control): + if !closeHandler(control) { try? AccessibilityWindowService.perform(control) } + case .torch(let window): + guard isTorchModeEnabled else { continue } + _ = torchHandler(event.point, window) + case .soak(let window): + guard isSoakAndBurnModeEnabled else { continue } + if soakAndBurnHandler(.down, event.point, window) { + acceptedSoakSequence = event.sequenceID + } + } + publishConfiguration() + case .dragged: + if event.sequenceID == acceptedSoakSequence { + _ = soakAndBurnHandler(.dragged, event.point, nil) + } + case .up: + if event.sequenceID == acceptedSoakSequence { + acceptedSoakSequence = nil + _ = soakAndBurnHandler(.up, event.point, nil) + } + case .moved: + break } - return false } + publishConfiguration() + } +} - if type == .leftMouseUp, suppressNextLeftMouseUp { - suppressNextLeftMouseUp = false - return true - } +struct PointerDownDiagnostic: Sendable { + let mode: String + let reason: String + let eventWindowID: Int64 + let preparedWindowID: CGWindowID + let snapshotAge: TimeInterval +} + +enum PointerRoute: Sendable { + case close(AccessibleWindowControl) + case torch(AccessibleWindow?) + case soak(AccessibleWindow?) +} - if type == .leftMouseUp, isSuppressingSoakSequence { - isSuppressingSoakSequence = false - _ = soakAndBurnHandler(.up, location) - logger.info("Finished an intercepted soak-and-burn pointer sequence") - return true +/// The lock protects only value snapshots and a bounded queue. No AX, AppKit, GPU, +/// disk I/O or synchronous dispatch to the main actor is permitted under it. +final class PointerTapState: @unchecked Sendable { + enum Mode { case close, torch, soak } + private let lock = NSLock() + private let clock: @Sendable () -> UInt64 + + init(clock: @escaping @Sendable () -> UInt64 = { DispatchTime.now().uptimeNanoseconds }) { + self.clock = clock + } + private var mode = Mode.close + private var canStart = true + private var isSuspended = false + private var surfaces: [PointerTargetSnapshot.Region] = [] + private var targets = ResolvedPointerTargets() + private var buffer = PointerEventBuffer(capacity: 128) + private var routes: [UUID: PointerRoute] = [:] + private var downDiagnostics: [PointerDownDiagnostic] = [] + private var sequence: UInt64 = 0 + private var activeTarget: UUID? + private var isDiscardingOverflowSequence = false + private var activeDownTimestamp: UInt64 = 0 + private var tap: CFMachPort? + private var runLoop: CFRunLoop? + private var stopped = false + private let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "interceptor") + + var latestPointerLocation: CGPoint? { lock.withLock { buffer.latestPointerEvent?.point } } + + func configure( + mode: Mode, surfaces: [PointerTargetSnapshot.Region], canStart: Bool, + isSuspended: Bool = false + ) { + lock.withLock { + if mode != self.mode { + routes = routes.filter { _, route in + if case .close = route { return true } + return false + } + } + self.mode = mode + self.surfaces = surfaces + self.canStart = canStart + self.isSuspended = isSuspended } + } + + func publishTargets(_ targets: ResolvedPointerTargets) { + lock.withLock { self.targets = targets } + } - if type == .leftMouseDragged, isSuppressingSoakSequence { - _ = soakAndBurnHandler(.dragged, location) - return true + func drain() -> ( + events: [PointerInputEvent], routes: [UUID: PointerRoute], diagnostics: [PointerDownDiagnostic] + ) { + lock.withLock { + let result = (buffer.drain(), routes, downDiagnostics) + routes.removeAll(keepingCapacity: true) + downDiagnostics.removeAll(keepingCapacity: true) + return result } + } + + func start() throws { + let mask = [CGEventType.leftMouseDown, .leftMouseDragged, .leftMouseUp, .mouseMoved] + .reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << $1.rawValue) } + guard + let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, place: .headInsertEventTap, options: .defaultTap, + eventsOfInterest: mask, callback: windowControlEventTapCallback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) + else { throw WindowControlInterceptorError.eventTapUnavailable } + self.tap = tap + InputDiagnostics.productionTap(tap) + let thread = Thread { [self] in runTap() } + thread.name = "Window Burn mouse interception" + thread.qualityOfService = .userInteractive + thread.start() + logger.info("Mouse interception uses a dedicated run loop and asynchronous UI delivery") + } - if type == .leftMouseDown, isSoakAndBurnModeEnabled, - soakAndBurnHandler(.down, location) - { - isSuppressingSoakSequence = true - logger.info("Intercepted a soak-and-burn click") - return true + private func runTap() { + guard let tap = lock.withLock({ self.tap }) else { return } + let loop = CFRunLoopGetCurrent()! + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)! + CFRunLoopAddSource(loop, source, .commonModes) + let shouldRun = lock.withLock { + runLoop = loop + return !stopped } + if shouldRun { + CGEvent.tapEnable(tap: tap, enable: true) + CFRunLoopRun() + } + CFRunLoopRemoveSource(loop, source, .commonModes) + CFMachPortInvalidate(tap) + lock.withLock { runLoop = nil } + } - if type == .leftMouseDown, isTorchModeEnabled, torchHandler(location) { - suppressNextLeftMouseUp = true - logger.info("Intercepted a torch ignition click") - return true + func stop() { + lock.withLock { + stopped = true + if let tap { CGEvent.tapEnable(tap: tap, enable: false) } + if let runLoop { + CFRunLoopStop(runLoop) + CFRunLoopWakeUp(runLoop) + } } + } - guard - type == .leftMouseDown, - let control = AccessibilityWindowService.windowControl(at: location), - closeHandler(control) - else { + func receive(type: CGEventType, event: CGEvent) -> Bool { + receive( + type: type, point: event.location, timestamp: event.timestamp, + windowID: event.getIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent) + ) + } + + func receive(type: CGEventType, point: CGPoint, timestamp time: UInt64, windowID: Int64) -> Bool { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + lock.withLock { + if !stopped, let tap { CGEvent.tapEnable(tap: tap, enable: true) } + } + logger.notice("Mouse event tap re-enabled; timeout=\(type == .tapDisabledByTimeout)") + return false + } + return lock.withLock { + guard !stopped else { return false } + var decision = "passed" + var preparedWindowID: CGWindowID = 0 + let snapshotAge = ProcessInfo.processInfo.systemUptime - targets.windows.capturedAt + defer { + if type == .leftMouseDown, InputDiagnostics.isEnabled, downDiagnostics.count < 16 { + downDiagnostics.append( + .init( + mode: String(describing: mode), reason: decision, + eventWindowID: windowID, preparedWindowID: preparedWindowID, snapshotAge: snapshotAge) + ) + } + } + let latestTime = buffer.latestPointerEvent?.timestamp ?? 0 + let nowNanoseconds = clock() + let isRecent = nowNanoseconds <= time || nowNanoseconds - time <= 250_000_000 + let isFresh = time >= latestTime && isRecent + switch type { + case .leftMouseDown: + guard isFresh else { + decision = "stale-event" + return false + } + isDiscardingOverflowSequence = false + // A prior release can be absent after a tap interruption. Finish that gesture + // before accepting another click, without reusing the stale drag position. + if let activeTarget { + _ = buffer.append( + .init( + kind: .up, point: point, timestamp: time, + sequenceID: sequence, targetID: activeTarget)) + self.activeTarget = nil + } + guard !isSuspended else { + decision = "menu-tracking" + _ = buffer.append(.init(kind: .moved, point: point, timestamp: time, sequenceID: 0)) + return false + } + let now = ProcessInfo.processInfo.systemUptime + let snapshot = mode == .close ? targets.controls : targets.windows + let surface = mode == .close ? nil : surfaces.first { $0.frame.contains(point) } + guard + let targetID = snapshot.target( + at: point, now: now, surfaces: mode == .close ? [] : surfaces), + surface != nil || canStart + else { + decision = canStart ? "no-target" : "busy" + _ = buffer.append(.init(kind: .moved, point: point, timestamp: time, sequenceID: 0)) + return false + } + let target = targets.targets[targetID] + preparedWindowID = target?.windowID ?? 0 + if surface == nil, windowID > 0, windowID != Int64(target?.windowID ?? 0) { + decision = "window-mismatch" + return false + } + let route: PointerRoute + switch mode { + case .close: + guard let control = target?.closeControl else { return false } + route = .close(control) + case .torch: route = .torch(surface == nil ? target?.window : nil) + case .soak: route = .soak(surface == nil ? target?.window : nil) + } + sequence &+= 1 + let routeID = UUID() + guard + buffer.append( + .init( + kind: .down, point: point, timestamp: time, + sequenceID: sequence, targetID: routeID)) + else { + // A replacement surface must never leak clicks to the window beneath it, + // even if the UI has stalled long enough to fill the bounded queue. + isDiscardingOverflowSequence = surface != nil + return isDiscardingOverflowSequence + } + routes[routeID] = route + activeTarget = routeID + activeDownTimestamp = time + decision = "accepted" + return true + case .leftMouseDragged: + if isDiscardingOverflowSequence { return true } + if let activeTarget { + guard isFresh else { return true } + _ = buffer.append( + .init( + kind: .dragged, point: point, timestamp: time, + sequenceID: sequence, targetID: activeTarget)) + return true + } + case .leftMouseUp: + if isDiscardingOverflowSequence { + isDiscardingOverflowSequence = false + return true + } + if let activeTarget { + guard time >= activeDownTimestamp else { return true } + // Always finish an owned sequence, but never move artwork to an old point. + let releasePoint = isFresh ? point : (buffer.latestPointerEvent?.point ?? point) + _ = buffer.append( + .init( + kind: .up, point: releasePoint, timestamp: max(time, latestTime), + sequenceID: sequence, targetID: activeTarget)) + self.activeTarget = nil + return true + } + default: break + } + if isFresh { + _ = buffer.append(.init(kind: .moved, point: point, timestamp: time, sequenceID: 0)) + } return false } - - suppressNextLeftMouseUp = true - logger.info("Intercepted a \(control.kind.logName, privacy: .public) button click") - return true } } private func windowControlEventTapCallback( - proxy: CGEventTapProxy, - type: CGEventType, - event: CGEvent, + proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, userInfo: UnsafeMutableRawPointer? ) -> Unmanaged? { guard let userInfo else { return Unmanaged.passUnretained(event) } - let interceptor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() - let location = event.location - - let shouldSuppress = MainActor.assumeIsolated { - interceptor.shouldSuppress(type: type, location: location) - } - return shouldSuppress ? nil : Unmanaged.passUnretained(event) + let input = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + let startedAt = ProcessInfo.processInfo.systemUptime + let suppressed = input.receive(type: type, event: event) + InputDiagnostics.eventTap( + type: type, duration: ProcessInfo.processInfo.systemUptime - startedAt, + timestamp: event.timestamp + ) + return suppressed ? nil : Unmanaged.passUnretained(event) } diff --git a/Sources/WindowBurn/WindowServerWindowService.swift b/Sources/WindowBurn/WindowServerWindowService.swift index 39a50b0..2f288ca 100644 --- a/Sources/WindowBurn/WindowServerWindowService.swift +++ b/Sources/WindowBurn/WindowServerWindowService.swift @@ -6,17 +6,17 @@ import WindowBurnCore enum WindowServerWindowService { private static let logger = Logger(subsystem: "dev.malikov.WindowBurn", category: "resolver") - static func targetWindow(at point: CGPoint) -> TargetWindow? { + static func onScreenWindows() -> [PointWindowCandidate] { guard let windowInfo = CGWindowListCopyWindowInfo( [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID ) as? [[String: Any]] else { - return nil + return [] } - let candidates = windowInfo.compactMap { info -> PointWindowCandidate? in + return windowInfo.compactMap { info -> PointWindowCandidate? in guard let id = (info[kCGWindowNumber as String] as? NSNumber)?.uint32Value, let ownerPID = (info[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value, @@ -41,10 +41,13 @@ enum WindowServerWindowService { ) } + } + + static func targetWindow(at point: CGPoint) -> TargetWindow? { guard let match = WindowAtPointMatcher.frontmost( at: point, - amongFrontToBack: candidates, + amongFrontToBack: onScreenWindows(), excludingPID: ProcessInfo.processInfo.processIdentifier ) else { diff --git a/Sources/WindowBurnCore/CombustionModel.swift b/Sources/WindowBurnCore/CombustionModel.swift index 01af745..15b914e 100644 --- a/Sources/WindowBurnCore/CombustionModel.swift +++ b/Sources/WindowBurnCore/CombustionModel.swift @@ -1,3 +1,5 @@ +import Foundation + public struct CombustionProfile: Equatable, Sendable { public let ignitionThreshold: Float public let moistureResistance: Float @@ -67,11 +69,15 @@ public enum CombustionModel { let moisture = min(max(state.moisture, 0), 1) let fuel = min(max(state.fuel, 0), 1) let damage = min(max(state.damage, 0), 1) + guard deltaTime > 0 else { + return CombustionCellState(heat: heat, moisture: moisture, fuel: fuel, damage: damage) + } - let retainedHeat = heat * max(0, 1 - profile.heatDecay * deltaTime) - let spreadHeat = min(max(neighboringHeat, 0), profile.maximumHeat) * profile.spreadRate + let retainedHeat = heat * exp(-profile.heatDecay * deltaTime) + let heatDifference = max(0, min(max(neighboringHeat, 0), profile.maximumHeat) - heat) + let spreadHeat = heatDifference * (1 - exp(-profile.spreadRate * 8 * deltaTime)) var nextHeat = min( - max(max(retainedHeat, spreadHeat), max(sourceHeat, 0)), + max(retainedHeat + spreadHeat, max(sourceHeat, 0)), profile.maximumHeat ) @@ -161,9 +167,7 @@ public enum CombustionVisualModel { smoothstep(0.16, 0.76, remainingMoisture) * 0.75 ) let steamOpacity = min(0.62, boilingMoisture * 0.62) - let scorchOpacity = - smoothstep(0.06, 0.34, damage) - * (1 - smoothstep(0.72, 0.98, damage)) + let scorchOpacity = smoothstep(0.01, 0.12, damage) let undamagedMaterial = 1 - smoothstep(0.50, 0.98, damage) let materialVisibility = diff --git a/Sources/WindowBurnCore/DisplayCaptureRegion.swift b/Sources/WindowBurnCore/DisplayCaptureRegion.swift new file mode 100644 index 0000000..ae61b11 --- /dev/null +++ b/Sources/WindowBurnCore/DisplayCaptureRegion.swift @@ -0,0 +1,44 @@ +import CoreGraphics + +/// A display crop with an explicit pixel destination, including transparent off-display padding. +public struct DisplayCaptureRegion: Equatable, Sendable { + public let pixelSize: PixelSize + public let sourceRect: CGRect + public let destinationRect: CGRect + + public init?( + captureFrame: CGRect, + displayFrame: CGRect, + pointPixelScale: CGFloat + ) { + guard + pointPixelScale.isFinite, pointPixelScale > 0, + !captureFrame.isEmpty, !displayFrame.isEmpty, + [captureFrame, displayFrame].allSatisfy({ frame in + [frame.origin.x, frame.origin.y, frame.width, frame.height].allSatisfy(\.isFinite) + }) + else { return nil } + + let clippedFrame = captureFrame.intersection(displayFrame) + guard !clippedFrame.isNull, !clippedFrame.isEmpty else { return nil } + + pixelSize = CapturePixelSizing.pixelSize( + for: captureFrame.size, + pointPixelScale: pointPixelScale + ) + sourceRect = CGRect( + x: clippedFrame.minX - displayFrame.minX, + y: clippedFrame.minY - displayFrame.minY, + width: clippedFrame.width, + height: clippedFrame.height + ) + + // ScreenCaptureKit clamps negative source coordinates. Giving it the clipped + // source and its matching destination preserves both the origin and native scale. + let left = ((clippedFrame.minX - captureFrame.minX) * pointPixelScale).rounded() + let top = ((clippedFrame.minY - captureFrame.minY) * pointPixelScale).rounded() + let right = ((clippedFrame.maxX - captureFrame.minX) * pointPixelScale).rounded() + let bottom = ((clippedFrame.maxY - captureFrame.minY) * pointPixelScale).rounded() + destinationRect = CGRect(x: left, y: top, width: right - left, height: bottom - top) + } +} diff --git a/Sources/WindowBurnCore/PointerDragRecovery.swift b/Sources/WindowBurnCore/PointerDragRecovery.swift new file mode 100644 index 0000000..9def035 --- /dev/null +++ b/Sources/WindowBurnCore/PointerDragRecovery.swift @@ -0,0 +1,28 @@ +import CoreGraphics + +/// Reconciles a captured gesture with hardware state when an event tap loses events. +public struct PointerDragRecovery { + public enum Update: Equatable { case dragged, up } + public private(set) var isActive = false + private var lastPoint = CGPoint.zero + + public init() {} + + public mutating func begin(at point: CGPoint) { + isActive = true + lastPoint = point + } + + public mutating func end() { isActive = false } + + public mutating func sample(at point: CGPoint, isPressed: Bool) -> Update? { + guard isActive else { return nil } + guard isPressed else { + end() + return .up + } + guard point != lastPoint else { return nil } + lastPoint = point + return .dragged + } +} diff --git a/Sources/WindowBurnCore/PointerEventBuffer.swift b/Sources/WindowBurnCore/PointerEventBuffer.swift new file mode 100644 index 0000000..4222244 --- /dev/null +++ b/Sources/WindowBurnCore/PointerEventBuffer.swift @@ -0,0 +1,122 @@ +import CoreGraphics +import Foundation + +public struct PointerInputEvent: Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case down + case dragged + case up + case moved + + fileprivate var isMotion: Bool { self == .dragged || self == .moved } + } + + public let kind: Kind + public let point: CGPoint + public let timestamp: UInt64 + public let sequenceID: UInt64 + public let targetID: UUID? + + public init( + kind: Kind, + point: CGPoint, + timestamp: UInt64, + sequenceID: UInt64, + targetID: UUID? = nil + ) { + self.kind = kind + self.point = point + self.timestamp = timestamp + self.sequenceID = sequenceID + self.targetID = targetID + } +} + +/// A bounded handoff from a pointer callback to an asynchronously serviced UI. +/// The owner must serialize access. Draining retains pointer freshness and the +/// release reservation for an accepted gesture whose button is still held. +public struct PointerEventBuffer: Sendable { + private struct Gesture: Equatable, Sendable { + let sequenceID: UInt64 + let targetID: UUID? + + init(_ event: PointerInputEvent) { + sequenceID = event.sequenceID + targetID = event.targetID + } + } + + public let capacity: Int + public private(set) var latestPointerEvent: PointerInputEvent? + public var count: Int { events.count } + public var isGestureActive: Bool { activeGesture != nil } + + private var events: [PointerInputEvent] = [] + private var activeGesture: Gesture? + + public init(capacity: Int = 128) { + precondition(capacity >= 2, "A pointer buffer must hold a complete click") + self.capacity = capacity + events.reserveCapacity(capacity) + } + + /// Returns whether the event entered the delivery queue. A fresh event that + /// cannot fit still updates the pointer snapshot. Stale or mismatched gesture + /// events change neither the queue nor that snapshot. + @discardableResult + public mutating func append(_ event: PointerInputEvent) -> Bool { + if let latestPointerEvent, event.timestamp < latestPointerEvent.timestamp { + return false + } + switch event.kind { + case .down: + guard activeGesture == nil else { return false } + case .dragged, .up: + guard activeGesture == Gesture(event) else { return false } + case .moved: + break + } + latestPointerEvent = event + + switch event.kind { + case .down: + // Reserve both this down and its eventual up before accepting the click. + guard makeRoom(for: 2) else { return false } + events.append(event) + activeGesture = Gesture(event) + case .up: + // Every successful down reserved this slot, including across a UI drain. + events.append(event) + activeGesture = nil + case .dragged, .moved: + if let last = events.last, + last.kind == event.kind, + last.sequenceID == event.sequenceID, + last.targetID == event.targetID + { + events[events.count - 1] = event + return true + } + let reservedRelease = isGestureActive ? 1 : 0 + guard makeRoom(for: 1 + reservedRelease) else { return false } + events.append(event) + } + return true + } + + public mutating func drain() -> [PointerInputEvent] { + let pending = events + events.removeAll(keepingCapacity: true) + return pending + } + + private mutating func makeRoom(for requiredSlots: Int) -> Bool { + while events.count + requiredSlots > capacity { + guard let index = events.firstIndex(where: { $0.kind.isMotion }) else { + return false + } + events.remove(at: index) + } + return true + } +} diff --git a/Sources/WindowBurnCore/PointerTargetSnapshot.swift b/Sources/WindowBurnCore/PointerTargetSnapshot.swift new file mode 100644 index 0000000..6f25917 --- /dev/null +++ b/Sources/WindowBurnCore/PointerTargetSnapshot.swift @@ -0,0 +1,34 @@ +import CoreGraphics +import Foundation + +/// Prepared off the event-tap thread. A nil ID blocks hit testing behind that region. +public struct PointerTargetSnapshot: Sendable { + public struct Region: Sendable { + public let id: UUID? + public let frame: CGRect + + public init(id: UUID?, frame: CGRect) { + self.id = id + self.frame = frame + } + } + + public let regions: [Region] + public let capturedAt: TimeInterval + + public init(regions: [Region], capturedAt: TimeInterval) { + self.regions = regions + self.capturedAt = capturedAt + } + + public func target( + at point: CGPoint, now: TimeInterval, surfaces: [Region] = [] + ) -> UUID? { + // Replacement surfaces outlive the native window and its resolver cache. + if let surface = surfaces.first(where: { $0.frame.contains(point) }) { + return surface.id + } + guard now >= capturedAt, now - capturedAt <= 0.3 else { return nil } + return regions.first(where: { $0.frame.contains(point) })?.id + } +} diff --git a/Sources/WindowBurnCore/TorchBurn.swift b/Sources/WindowBurnCore/TorchBurn.swift index 475b9bf..4f36af6 100644 --- a/Sources/WindowBurnCore/TorchBurn.swift +++ b/Sources/WindowBurnCore/TorchBurn.swift @@ -58,6 +58,10 @@ public struct TorchWindowSessionRegistry: Equatable, Sendable { entries.count } + public var interactionRegions: [PointerTargetSnapshot.Region] { + entries.reversed().map { .init(id: $0.id, frame: $0.captureFrame) } + } + public var isAtCapacity: Bool { count >= Self.maximumConcurrentWindows } diff --git a/Sources/WindowBurnCore/WetMaterialOptics.swift b/Sources/WindowBurnCore/WetMaterialOptics.swift new file mode 100644 index 0000000..d38cd9c --- /dev/null +++ b/Sources/WindowBurnCore/WetMaterialOptics.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Thin-film optics; color is transmitted through liquid, with a separate specular reflection. +public enum WetMaterialOptics { + public static let refractiveIndex: Float = 1.333 + + public static func reflectance(cosine: Float) -> Float { + let normalReflection = pow((1 - refractiveIndex) / (1 + refractiveIndex), 2) + return normalReflection + (1 - normalReflection) * pow(1 - min(max(cosine, 0), 1), 5) + } + + public static func transmittance(density: Float, tintStrength: Float) -> SIMD3 { + let opticalDepth = max(0, density) * max(0, tintStrength) + return SIMD3( + exp(-opticalDepth * 0.11), + exp(-opticalDepth * 0.26), + exp(-opticalDepth * 1.15) + ) + } +} diff --git a/Sources/WindowBurnCore/WetVisualProfile.swift b/Sources/WindowBurnCore/WetVisualProfile.swift index 3143e70..36c8443 100644 --- a/Sources/WindowBurnCore/WetVisualProfile.swift +++ b/Sources/WindowBurnCore/WetVisualProfile.swift @@ -91,8 +91,8 @@ public struct WetVisualProfile: Equatable, Sendable { absorptionRadius: 0.155, verticalSag: 0.034, urineTintStrength: 0.56, - backgroundBlurRadius: 0.024, - backgroundBlurStrength: 0.90, + backgroundBlurRadius: 0.0032, + backgroundBlurStrength: 0.28, wrinkleStartDensity: 0.62, wrinkleFullDensity: 1.80, tearStartDensity: 3.0, diff --git a/Support/Info.plist b/Support/Info.plist index 77ec7f2..5e663b4 100644 --- a/Support/Info.plist +++ b/Support/Info.plist @@ -21,9 +21,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.3.1 + 0.4.0 CFBundleVersion - 4 + 5 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/Tests/WindowBurnCoreTests/CombustionModelTests.swift b/Tests/WindowBurnCoreTests/CombustionModelTests.swift index 0f0ee2b..3e73b00 100644 --- a/Tests/WindowBurnCoreTests/CombustionModelTests.swift +++ b/Tests/WindowBurnCoreTests/CombustionModelTests.swift @@ -4,6 +4,40 @@ import Testing @Suite("Stateful combustion") struct CombustionModelTests { + @Test("A paused simulation cannot acquire heat from its neighbors or source") + func zeroTimeDoesNotEvolveMaterial() { + let state = CombustionCellState(heat: 0.2, moisture: 0.4, fuel: 0.9, damage: 0.1) + let next = CombustionModel.step( + state: state, + neighboringHeat: 1, + sourceHeat: 1, + deltaTime: 0, + profile: .cinematic + ) + #expect(next == state) + } + + @Test("Heat conduction scales with elapsed time rather than refresh rate") + func heatConductionIsIndependentOfRefreshRate() { + func simulatedHeat(frames: Int) -> Float { + var state = CombustionCellState(heat: 0, moisture: 0, fuel: 0, damage: 1) + for _ in 0.. 0 && thirtyHertz < 0.2) + } + @Test("Dry material ignites and loses fuel") func dryMaterialBurns() { let next = CombustionModel.step( @@ -151,6 +185,20 @@ struct CombustionModelTests { #expect(damaged.scorchOpacity > fresh.scorchOpacity) } + @Test("Char remains dark while damaged material is still visible") + func charDoesNotTurnBackIntoUnburnedPaper() { + let response = CombustionVisualModel.response( + depositedMoisture: 0, + remainingMoisture: 0, + heat: 0.3, + damage: 0.9, + progress: 0.6, + isRadial: true + ) + #expect(response.scorchOpacity > 0.9) + #expect(response.materialVisibility > 0) + } + @Test("Radial fire remains attached to its front instead of globally fading") func radialFireHasNoGlobalTerminalFade() { let response = CombustionVisualModel.response( diff --git a/Tests/WindowBurnCoreTests/DisplayCaptureRegionTests.swift b/Tests/WindowBurnCoreTests/DisplayCaptureRegionTests.swift new file mode 100644 index 0000000..b0999cf --- /dev/null +++ b/Tests/WindowBurnCoreTests/DisplayCaptureRegionTests.swift @@ -0,0 +1,86 @@ +import CoreGraphics +import Testing + +@testable import WindowBurnCore + +@Suite("Display capture region") +struct DisplayCaptureRegionTests { + @Test("Keeps native pixels and a known origin for a padded window") + func preservesNativePadding() throws { + let region = try #require( + DisplayCaptureRegion( + captureFrame: CGRect(x: 377, y: 316, width: 748, height: 577), + displayFrame: CGRect(x: 0, y: 0, width: 1_710, height: 1_107), + pointPixelScale: 2 + ) + ) + + #expect(region.pixelSize == PixelSize(width: 1_496, height: 1_154)) + #expect(region.sourceRect == CGRect(x: 377, y: 316, width: 748, height: 577)) + #expect(region.destinationRect == CGRect(x: 0, y: 0, width: 1_496, height: 1_154)) + } + + @Test("Clips a display-edge window without shifting or stretching its pixels") + func preservesClippedPadding() throws { + let region = try #require( + DisplayCaptureRegion( + captureFrame: CGRect(x: -84, y: -49, width: 1_878, height: 1_240), + displayFrame: CGRect(x: 0, y: 0, width: 1_710, height: 1_107), + pointPixelScale: 2 + ) + ) + + #expect(region.pixelSize == PixelSize(width: 3_756, height: 2_480)) + #expect(region.sourceRect == CGRect(x: 0, y: 0, width: 1_710, height: 1_107)) + #expect(region.destinationRect == CGRect(x: 168, y: 98, width: 3_420, height: 2_214)) + } + + @Test("Converts a display above and left of the main display into local points") + func supportsSecondaryDisplayCoordinates() throws { + let region = try #require( + DisplayCaptureRegion( + captureFrame: CGRect(x: -1_960, y: -960, width: 700, height: 500), + displayFrame: CGRect(x: -1_920, y: -900, width: 1_920, height: 1_080), + pointPixelScale: 1 + ) + ) + + #expect(region.pixelSize == PixelSize(width: 700, height: 500)) + #expect(region.sourceRect == CGRect(x: 0, y: 0, width: 660, height: 440)) + #expect(region.destinationRect == CGRect(x: 40, y: 60, width: 660, height: 440)) + } + + @Test("Rounds pixel edges consistently on a fractional scale") + func roundsSharedEdges() throws { + let region = try #require( + DisplayCaptureRegion( + captureFrame: CGRect(x: -1, y: 10, width: 853, height: 479), + displayFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080), + pointPixelScale: 1.5 + ) + ) + + #expect(region.pixelSize == PixelSize(width: 1_280, height: 719)) + #expect(region.destinationRect == CGRect(x: 2, y: 0, width: 1_278, height: 719)) + } + + @Test("Rejects empty, off-display, and invalid capture requests") + func rejectsInvalidRegions() { + let display = CGRect(x: 0, y: 0, width: 1_710, height: 1_107) + for frame in [ + CGRect.zero, + CGRect(x: 2_000, y: 100, width: 400, height: 300), + CGRect(x: CGFloat.infinity, y: 100, width: 400, height: 300), + ] { + #expect( + DisplayCaptureRegion(captureFrame: frame, displayFrame: display, pointPixelScale: 2) == nil + ) + } + for scale in [CGFloat.zero, -1, .nan, .infinity] { + #expect( + DisplayCaptureRegion(captureFrame: display, displayFrame: display, pointPixelScale: scale) + == nil + ) + } + } +} diff --git a/Tests/WindowBurnCoreTests/PointerDragRecoveryTests.swift b/Tests/WindowBurnCoreTests/PointerDragRecoveryTests.swift new file mode 100644 index 0000000..95bc555 --- /dev/null +++ b/Tests/WindowBurnCoreTests/PointerDragRecoveryTests.swift @@ -0,0 +1,25 @@ +import CoreGraphics +import Testing + +@testable import WindowBurnCore + +@Suite struct PointerDragRecoveryTests { + @Test func recoversMotionAndMissingReleaseExactlyOnce() { + var drag = PointerDragRecovery() + drag.begin(at: .zero) + let moved = CGPoint(x: 100, y: 200) + #expect(drag.sample(at: moved, isPressed: true) == .dragged) + #expect(drag.sample(at: moved, isPressed: true) == nil) + #expect(drag.sample(at: moved, isPressed: false) == .up) + #expect(!drag.isActive) + #expect(drag.sample(at: moved, isPressed: false) == nil) + } + + @Test func nativeReleaseAndCancellationStopRecovery() { + var drag = PointerDragRecovery() + #expect(drag.sample(at: .zero, isPressed: true) == nil) + drag.begin(at: .zero) + drag.end() + #expect(drag.sample(at: CGPoint(x: 20, y: 30), isPressed: false) == nil) + } +} diff --git a/Tests/WindowBurnCoreTests/PointerEventBufferTests.swift b/Tests/WindowBurnCoreTests/PointerEventBufferTests.swift new file mode 100644 index 0000000..a1a9104 --- /dev/null +++ b/Tests/WindowBurnCoreTests/PointerEventBufferTests.swift @@ -0,0 +1,197 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import WindowBurnCore + +@Suite("Asynchronous pointer event buffer") +struct PointerEventBufferTests { + @Test("Rapid clicks retain their complete order when the UI drains later") + func preservesRapidClickOrdering() { + var buffer = PointerEventBuffer(capacity: 4) + let events = [ + event(.down, time: 1, sequence: 1), + event(.up, time: 2, sequence: 1), + event(.down, time: 3, sequence: 2), + event(.up, time: 4, sequence: 2), + ] + + for event in events { #expect(buffer.append(event) == true) } + + #expect(buffer.drain() == events) + #expect(buffer.count == 0) + #expect(buffer.latestPointerEvent == events.last) + #expect(!buffer.isGestureActive) + } + + @Test("Consecutive motion coalesces without replacing gesture boundaries") + func coalescesMotion() { + var buffer = PointerEventBuffer(capacity: 6) + let down = event(.down, time: 1, sequence: 1) + let lastDrag = event(.dragged, time: 3, sequence: 1) + let up = event(.up, time: 4, sequence: 1) + let lastMove = event(.moved, time: 6, sequence: 0) + + #expect(buffer.append(down) == true) + #expect(buffer.append(event(.dragged, time: 2, sequence: 1)) == true) + #expect(buffer.append(lastDrag) == true) + #expect(buffer.append(up) == true) + #expect(buffer.append(event(.moved, time: 5, sequence: 0)) == true) + #expect(buffer.append(lastMove) == true) + + #expect(buffer.drain() == [down, lastDrag, up, lastMove]) + } + + @Test("Motion from different sequences or targets is not coalesced") + func keepsMotionIdentity() { + var buffer = PointerEventBuffer(capacity: 4) + let first = event(.moved, time: 1, sequence: 1) + let second = event(.moved, time: 2, sequence: 2) + let third = event(.moved, time: 3, sequence: 2, target: UUID()) + + #expect(buffer.append(first) == true) + #expect(buffer.append(second) == true) + #expect(buffer.append(third) == true) + + #expect(buffer.drain() == [first, second, third]) + } + + @Test("A full queue refuses a new click without losing accepted clicks") + func refusesClickWithoutTerminalCapacity() { + var buffer = PointerEventBuffer(capacity: 3) + let down = event(.down, time: 1, sequence: 1) + let up = event(.up, time: 2, sequence: 1) + + #expect(buffer.append(down) == true) + #expect(buffer.append(up) == true) + #expect(buffer.append(event(.down, time: 3, sequence: 2)) == false) + #expect(!buffer.isGestureActive) + #expect(buffer.append(event(.up, time: 4, sequence: 2)) == false) + #expect(buffer.drain() == [down, up]) + } + + @Test("The reserved release slot survives a drain while the button is held") + func reservesReleaseAcrossDrain() { + var buffer = PointerEventBuffer(capacity: 2) + let down = event(.down, time: 1, sequence: 1) + let drag = event(.dragged, time: 2, sequence: 1) + let up = event(.up, time: 3, sequence: 1) + + #expect(buffer.append(down) == true) + #expect(buffer.drain() == [down]) + #expect(buffer.isGestureActive) + #expect(buffer.append(drag) == true) + #expect(buffer.append(up) == true) + #expect(buffer.drain() == [drag, up]) + #expect(!buffer.isGestureActive) + } + + @Test("Queue pressure removes motion before rejecting a complete click") + func evictsMotionBeforeClickBoundaries() { + var buffer = PointerEventBuffer(capacity: 3) + let down = event(.down, time: 3, sequence: 1) + let up = event(.up, time: 5, sequence: 1) + + #expect(buffer.append(event(.moved, time: 1, sequence: 0)) == true) + #expect(buffer.append(event(.moved, time: 2, sequence: 2)) == true) + #expect(buffer.append(down) == true) + #expect(buffer.append(event(.dragged, time: 4, sequence: 1)) == true) + #expect(buffer.append(up) == true) + + let drained = buffer.drain() + #expect(drained.count <= 3) + #expect(drained.filter { $0.kind == .down || $0.kind == .up } == [down, up]) + #expect(drained.last == up) + } + + @Test("Cursor position stays fresh even when there is no room for queued motion") + func recordsLatestPointerAtCapacity() { + var buffer = PointerEventBuffer(capacity: 2) + let down = event(.down, time: 1, sequence: 1) + let drag = event(.dragged, time: 2, sequence: 1) + let up = event(.up, time: 3, sequence: 1) + + #expect(buffer.append(down) == true) + #expect(buffer.append(drag) == false) + #expect(buffer.latestPointerEvent == drag) + #expect(buffer.append(up) == true) + #expect(buffer.drain() == [down, up]) + } + + @Test("Late coordinates cannot rewind the cursor before or after a drain") + func rejectsStaleTimestamps() { + var buffer = PointerEventBuffer() + let latest = event(.moved, time: 20, sequence: 0) + + #expect(buffer.append(latest) == true) + #expect(buffer.append(event(.moved, time: 10, sequence: 0)) == false) + #expect(buffer.drain() == [latest]) + #expect(buffer.append(event(.moved, time: 15, sequence: 0)) == false) + #expect(buffer.latestPointerEvent == latest) + #expect(buffer.count == 0) + } + + @Test("Equal timestamps preserve a down and up delivered at the same tick") + func acceptsEqualTimestamps() { + var buffer = PointerEventBuffer() + let down = event(.down, time: 1, sequence: 1) + let up = event(.up, time: 1, sequence: 1) + + #expect(buffer.append(down) == true) + #expect(buffer.append(up) == true) + #expect(buffer.drain() == [down, up]) + } + + @Test("An old gesture cannot end or add deposits to the currently held gesture") + func rejectsUnmatchedSequenceEvents() { + var buffer = PointerEventBuffer() + let target = UUID() + let down = event(.down, time: 1, sequence: 2, target: target) + let up = event(.up, time: 6, sequence: 2, target: target) + + #expect(buffer.append(down) == true) + #expect(buffer.append(event(.dragged, time: 2, sequence: 1, target: target)) == false) + #expect(buffer.append(event(.up, time: 3, sequence: 1, target: target)) == false) + #expect(buffer.append(event(.down, time: 4, sequence: 3, target: target)) == false) + #expect(buffer.append(event(.up, time: 5, sequence: 2, target: UUID())) == false) + #expect(buffer.isGestureActive) + #expect(buffer.latestPointerEvent == down) + #expect(buffer.append(up) == true) + #expect(buffer.drain() == [down, up]) + } + + @Test("A long held gesture stays bounded while retaining its start and release") + func boundsLongGesture() { + var buffer = PointerEventBuffer(capacity: 4) + let down = event(.down, time: 1, sequence: 1) + let up = event(.up, time: 10_001, sequence: 1) + + #expect(buffer.append(down) == true) + for time in UInt64(2)...10_000 { + #expect(buffer.append(event(.dragged, time: time, sequence: 1)) == true) + #expect(buffer.count <= 3) + } + #expect(buffer.append(up) == true) + + let drained = buffer.drain() + #expect(drained.count == 3) + #expect(drained.first == down) + #expect(drained[1].timestamp == 10_000) + #expect(drained.last == up) + } + + private func event( + _ kind: PointerInputEvent.Kind, + time: UInt64, + sequence: UInt64, + target: UUID? = nil + ) -> PointerInputEvent { + PointerInputEvent( + kind: kind, + point: CGPoint(x: Double(time), y: 20), + timestamp: time, + sequenceID: sequence, + targetID: target + ) + } +} diff --git a/Tests/WindowBurnCoreTests/PointerTargetSnapshotTests.swift b/Tests/WindowBurnCoreTests/PointerTargetSnapshotTests.swift new file mode 100644 index 0000000..35d350e --- /dev/null +++ b/Tests/WindowBurnCoreTests/PointerTargetSnapshotTests.swift @@ -0,0 +1,46 @@ +import CoreGraphics +import Foundation +import Testing + +@testable import WindowBurnCore + +@Suite struct PointerTargetSnapshotTests { + private let point = CGPoint(x: 50, y: 50) + private let frame = CGRect(x: 0, y: 0, width: 100, height: 100) + + @Test func burningSurfaceWinsOverWindowUnderneathEvenAfterCacheExpires() { + let surface = UUID() + let native = UUID() + let snapshot = PointerTargetSnapshot( + regions: [.init(id: native, frame: frame)], capturedAt: 1 + ) + #expect( + snapshot.target(at: point, now: 10, surfaces: [.init(id: surface, frame: frame)]) == surface) + #expect(snapshot.target(at: point, now: 10) == nil) + } + + @Test func unknownFrontWindowOrMenuBlocksWindowBehindIt() { + let native = UUID() + let snapshot = PointerTargetSnapshot( + regions: [.init(id: nil, frame: frame), .init(id: native, frame: frame)], capturedAt: 1 + ) + #expect(snapshot.target(at: point, now: 1.1) == nil) + } + + @Test func frontmostSurfaceAndCloseButtonHitRegionsAreRespected() { + let old = UUID() + let new = UUID() + let snapshot = PointerTargetSnapshot(regions: [], capturedAt: 1) + #expect( + snapshot.target( + at: point, now: 1, surfaces: [.init(id: new, frame: frame), .init(id: old, frame: frame)]) + == new) + let closeOnly = PointerTargetSnapshot( + regions: [ + .init(id: old, frame: CGRect(x: 10, y: 10, width: 12, height: 12)), + .init(id: nil, frame: frame), + ], capturedAt: 1) + #expect(closeOnly.target(at: point, now: 1) == nil) + #expect(closeOnly.target(at: CGPoint(x: 15, y: 15), now: 1) == old) + } +} diff --git a/Tests/WindowBurnCoreTests/WetMaterialOpticsTests.swift b/Tests/WindowBurnCoreTests/WetMaterialOpticsTests.swift new file mode 100644 index 0000000..b7be5ea --- /dev/null +++ b/Tests/WindowBurnCoreTests/WetMaterialOpticsTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import WindowBurnCore + +@Suite("Thin liquid optics") +struct WetMaterialOpticsTests { + @Test("Water reflects about two percent head-on and approaches a mirror at grazing angles") + func waterFresnelResponse() { + #expect(abs(WetMaterialOptics.reflectance(cosine: 1) - 0.02037) < 0.0001) + #expect(WetMaterialOptics.reflectance(cosine: 0) == 1) + #expect(WetMaterialOptics.reflectance(cosine: 0.25) > 0.2) + } + + @Test("A clear dry surface transmits every channel without inventing color") + func zeroThicknessPreservesImage() { + #expect(WetMaterialOptics.transmittance(density: 0, tintStrength: 0.56) == SIMD3(repeating: 1)) + } + + @Test("Increasing liquid depth absorbs light without brightening black pixels") + func transmissionObeysBeerLambertLaw() { + let thin = WetMaterialOptics.transmittance(density: 0.2, tintStrength: 0.56) + let thick = WetMaterialOptics.transmittance(density: 1, tintStrength: 0.56) + #expect(thick.x < thin.x && thick.y < thin.y && thick.z < thin.z) + #expect(thick.z < thick.y && thick.y < thick.x) + #expect(thick.x <= 1 && thick.z > 0) + #expect(thick * SIMD3(repeating: 0) == SIMD3(repeating: 0)) + } +} diff --git a/Tests/WindowBurnCoreTests/WetVisualProfileTests.swift b/Tests/WindowBurnCoreTests/WetVisualProfileTests.swift index ce17541..3af91b4 100644 --- a/Tests/WindowBurnCoreTests/WetVisualProfileTests.swift +++ b/Tests/WindowBurnCoreTests/WetVisualProfileTests.swift @@ -13,15 +13,15 @@ struct WetVisualProfileTests { #expect(profile.dispersionStrength < profile.refractionStrength) } - @Test("The stain remains matte while the absorbed background stays blurred") + @Test("Absorption preserves readable texture rather than turning into a blur mask") func matteStainPreservesTheCapturedWindow() { let profile = WetVisualProfile.cinematic #expect((0.015...0.06).contains(profile.reflectionStrength)) #expect((0.10...0.32).contains(profile.highlightIntensity)) #expect((0.40...0.68).contains(profile.urineTintStrength)) - #expect((0.018...0.032).contains(profile.backgroundBlurRadius)) - #expect((0.82...0.98).contains(profile.backgroundBlurStrength)) + #expect(profile.backgroundBlurRadius < 0.005) + #expect(profile.backgroundBlurStrength < 0.4) } @Test("Droplets break up the impact without carpeting the wet patch") diff --git a/Tests/WindowBurnInputTests/PointerTapStateTests.swift b/Tests/WindowBurnInputTests/PointerTapStateTests.swift new file mode 100644 index 0000000..3d4bace --- /dev/null +++ b/Tests/WindowBurnInputTests/PointerTapStateTests.swift @@ -0,0 +1,293 @@ +import ApplicationServices +import CoreGraphics +import Foundation +import Testing +import WindowBurnCore + +@testable import WindowBurn + +@Suite struct PointerTapStateTests { + private func input() -> PointerTapState { + let input = PointerTapState(clock: { 100 }) + input.configure( + mode: .torch, + surfaces: [.init(id: UUID(), frame: CGRect(x: 0, y: 0, width: 200, height: 200))], + canStart: false) + return input + } + + private func event(_ timestamp: UInt64, point: CGPoint = CGPoint(x: 50, y: 50)) throws -> CGEvent + { + let event = try #require(CGEvent(source: nil)) + event.timestamp = timestamp + event.location = point + return event + } + + @Test func firstNativeTorchGestureAcceptsUnannotatedCGEvents() throws { + try expectFirstNativeGesture(mode: .torch, eventWindowID: nil) + } + + @Test func firstNativeSoakGestureAcceptsUnannotatedCGEvents() throws { + try expectFirstNativeGesture(mode: .soak, eventWindowID: nil) + } + + @Test func firstNativeTorchGestureAcceptsMatchingWindowIdentity() throws { + try expectFirstNativeGesture(mode: .torch, eventWindowID: 42) + } + + @Test func firstNativeSoakGestureAcceptsMatchingWindowIdentity() throws { + try expectFirstNativeGesture(mode: .soak, eventWindowID: 42) + } + + @Test func menuTrackingPassesNewClicksOverAnActiveBurningSurface() throws { + let input = input() + input.configure( + mode: .torch, + surfaces: [.init(id: UUID(), frame: CGRect(x: 0, y: 0, width: 200, height: 200))], + canStart: true, isSuspended: true) + + #expect(!input.receive(type: .leftMouseDown, event: try event(1))) + #expect(!input.receive(type: .leftMouseDragged, event: try event(2))) + #expect(!input.receive(type: .leftMouseUp, event: try event(3))) + #expect(input.drain().routes.isEmpty) + } + + @Test func menuTrackingPassesNewClicksOverAPreparedNativeWindow() throws { + let input = PointerTapState(clock: { 100 }) + let id = UUID() + let frame = CGRect(x: 0, y: 0, width: 200, height: 200) + input.publishTargets( + ResolvedPointerTargets( + windows: .init( + regions: [.init(id: id, frame: frame)], capturedAt: ProcessInfo.processInfo.systemUptime), + targets: [ + id: ResolvedPointerTarget( + windowID: 42, + window: AccessibleWindow( + target: .init(ownerPID: 1, title: nil, frame: frame), + element: AXUIElementCreateApplication(1)), closeControl: nil) + ])) + input.configure(mode: .soak, surfaces: [], canStart: true, isSuspended: true) + + #expect(!input.receive(type: .leftMouseDown, event: try event(1))) + #expect(!input.receive(type: .leftMouseDragged, event: try event(2))) + #expect(!input.receive(type: .leftMouseUp, event: try event(3))) + #expect(input.drain().routes.isEmpty) + + input.configure(mode: .soak, surfaces: [], canStart: true, isSuspended: false) + #expect(input.receive(type: .leftMouseDown, event: try event(4))) + #expect(input.receive(type: .leftMouseUp, event: try event(5))) + #expect(input.drain().routes.count == 1) + } + + @Test func menuTrackingStillOwnsTheReleaseOfAnAlreadyAcceptedGesture() throws { + let input = input() + let surfaces = [ + PointerTargetSnapshot.Region(id: UUID(), frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + ] + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + input.configure(mode: .torch, surfaces: surfaces, canStart: true, isSuspended: true) + + #expect(input.receive(type: .leftMouseDragged, event: try event(2))) + #expect(input.receive(type: .leftMouseUp, event: try event(3))) + let batch = input.drain() + #expect(batch.events.map(\.kind) == [.down, .dragged, .up]) + #expect(batch.routes.count == 1) + #expect(!input.receive(type: .leftMouseDown, event: try event(4))) + #expect(!input.receive(type: .leftMouseUp, event: try event(5))) + + input.configure(mode: .torch, surfaces: surfaces, canStart: true, isSuspended: false) + #expect(input.receive(type: .leftMouseDown, event: try event(6))) + #expect(input.receive(type: .leftMouseUp, event: try event(7))) + #expect(input.drain().routes.count == 1) + } + + @Test func menuClickAfterAMissedReleaseKeepsItsNativeUp() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + input.configure(mode: .torch, surfaces: [], canStart: true, isSuspended: true) + + #expect(!input.receive(type: .leftMouseDown, event: try event(2))) + #expect(!input.receive(type: .leftMouseUp, event: try event(3))) + let batch = input.drain() + #expect(batch.events.filter { $0.kind == .down || $0.kind == .up }.map(\.timestamp) == [1, 2]) + #expect(batch.routes.count == 1) + } + + private func expectFirstNativeGesture( + mode: PointerTapState.Mode, eventWindowID: Int64? + ) throws { + let input = PointerTapState(clock: { 100 }) + let id = UUID() + let frame = CGRect(x: 0, y: 0, width: 200, height: 200) + let window = AccessibleWindow( + target: .init(ownerPID: 1, title: "Original native window", frame: frame), + element: AXUIElementCreateApplication(1)) + let closeControl = AccessibleWindowControl( + window: window, kind: .close, button: AXUIElementCreateApplication(1)) + input.configure(mode: mode, surfaces: [], canStart: true) + input.publishTargets( + ResolvedPointerTargets( + windows: .init( + regions: [.init(id: id, frame: frame)], capturedAt: ProcessInfo.processInfo.systemUptime), + targets: [ + id: ResolvedPointerTarget(windowID: 42, window: window, closeControl: closeControl) + ])) + + let down = try event(10) + if let eventWindowID { + // CGEvent(source:) does not preserve writes to this annotation field. + // Exercise a known native annotation through the value entry point. + #expect( + input.receive( + type: .leftMouseDown, point: down.location, timestamp: down.timestamp, + windowID: eventWindowID)) + } else { + #expect( + down.getIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent) == 0) + #expect(input.receive(type: .leftMouseDown, event: down)) + } + + // A refresh may remove or replace the native window before the main actor + // delivers this click. Its route must keep the original preflight handle. + input.publishTargets(ResolvedPointerTargets()) + let initialBatch = input.drain() + #expect(initialBatch.events.map(\.kind) == [.down]) + #expect(initialBatch.routes.count == 1) + let acceptedDown = try #require(initialBatch.events.first) + let routeID = try #require(acceptedDown.targetID) + let route = try #require(initialBatch.routes[routeID]) + let retainedWindow: AccessibleWindow? + switch route { + case .torch(let original): + #expect(mode == .torch) + retainedWindow = original + case .soak(let original): + #expect(mode == .soak) + retainedWindow = original + case .close: + Issue.record("An interactive tool click must retain its tool route") + retainedWindow = nil + } + let retained = try #require(retainedWindow) + #expect(retained.target == window.target) + #expect(retained.element === window.element) + + #expect(input.receive(type: .leftMouseDragged, event: try event(20))) + #expect(input.receive(type: .leftMouseUp, event: try event(30))) + let terminalBatch = input.drain() + #expect(terminalBatch.events.map(\.kind) == [.dragged, .up]) + #expect(terminalBatch.events.allSatisfy { $0.sequenceID == acceptedDown.sequenceID }) + #expect(terminalBatch.events.allSatisfy { $0.targetID == routeID }) + #expect(terminalBatch.routes.isEmpty) + #expect(!input.receive(type: .leftMouseDragged, event: try event(40))) + } + + @Test func oldClickCannotTargetANewWindowAtItsFormerCoordinates() throws { + let input = PointerTapState(clock: { 1_000_000_000 }) + input.configure( + mode: .torch, + surfaces: [.init(id: UUID(), frame: CGRect(x: 0, y: 0, width: 200, height: 200))], + canStart: false) + #expect(!input.receive(type: .leftMouseDown, event: try event(1))) + #expect(input.drain().routes.isEmpty) + } + + @Test func windowIdentityMismatchPassesThroughStaleNativeSnapshot() throws { + let input = PointerTapState(clock: { 100 }) + let id = UUID() + let frame = CGRect(x: 0, y: 0, width: 200, height: 200) + input.configure(mode: .torch, surfaces: [], canStart: true) + input.publishTargets( + ResolvedPointerTargets( + windows: .init( + regions: [.init(id: id, frame: frame)], capturedAt: ProcessInfo.processInfo.systemUptime), + targets: [ + id: ResolvedPointerTarget( + windowID: 42, + window: AccessibleWindow( + target: .init(ownerPID: 1, title: nil, frame: frame), + element: AXUIElementCreateApplication(1)), closeControl: nil) + ] + )) + #expect( + !input.receive(type: .leftMouseDown, point: CGPoint(x: 50, y: 50), timestamp: 1, windowID: 43) + ) + #expect(input.drain().routes.isEmpty) + } + + @Test func fullQueueStillShieldsBurningSurfaceFromNativeClicks() throws { + let input = input() + for time in stride(from: UInt64(1), through: 127, by: 2) { + #expect(input.receive(type: .leftMouseDown, event: try event(time))) + #expect(input.receive(type: .leftMouseUp, event: try event(time + 1))) + } + #expect(input.receive(type: .leftMouseDown, event: try event(129))) + #expect(input.receive(type: .leftMouseDragged, event: try event(130))) + #expect(input.receive(type: .leftMouseUp, event: try event(131))) + #expect(input.drain().events.count == 128) + } + + @Test func repeatClicksAndDragStayOwnedWhileMainDoesNotDrain() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + #expect(input.receive(type: .leftMouseDragged, event: try event(2))) + #expect(input.receive(type: .leftMouseUp, event: try event(3))) + #expect(input.receive(type: .leftMouseDown, event: try event(4))) + #expect(input.receive(type: .leftMouseUp, event: try event(5))) + let batch = input.drain() + #expect(batch.events.map(\.kind) == [.down, .dragged, .up, .down, .up]) + #expect(batch.routes.count == 2) + } + + @Test func delayedReleaseFromPreviousGestureCannotFinishNewGesture() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(10))) + #expect(input.receive(type: .leftMouseUp, event: try event(20))) + #expect(input.receive(type: .leftMouseDown, event: try event(30))) + #expect(input.receive(type: .leftMouseUp, event: try event(20))) + #expect(input.receive(type: .leftMouseDragged, event: try event(40))) + #expect(input.receive(type: .leftMouseUp, event: try event(50))) + #expect(input.drain().events.map(\.timestamp) == [10, 20, 30, 40, 50]) + } + + @Test func modeOffThenOnCannotReplayCancelledClicks() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + input.configure(mode: .close, surfaces: [], canStart: true) + input.configure(mode: .torch, surfaces: [], canStart: true) + #expect(input.receive(type: .leftMouseUp, event: try event(2))) + let batch = input.drain() + #expect(batch.routes.isEmpty) + #expect(batch.events.map(\.kind) == [.down, .up]) + } + + @Test func unknownTargetsKeepTheirNativeSequence() throws { + let input = PointerTapState(clock: { 100 }) + input.configure(mode: .torch, surfaces: [], canStart: true) + #expect(!input.receive(type: .leftMouseDown, event: try event(1))) + #expect(!input.receive(type: .leftMouseDragged, event: try event(2))) + #expect(!input.receive(type: .leftMouseUp, event: try event(3))) + #expect(input.drain().routes.isEmpty) + } + + @Test func missedReleaseFinishesOnceBeforeTheNextClick() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + #expect(input.receive(type: .leftMouseDown, event: try event(2))) + #expect(input.receive(type: .leftMouseUp, event: try event(3))) + #expect(input.drain().events.map(\.kind) == [.down, .up, .down, .up]) + } + + @Test func queuedGestureCannotRewindTheLatestPointer() throws { + let input = input() + #expect(input.receive(type: .leftMouseDown, event: try event(1))) + let latest = CGPoint(x: 120, y: 150) + #expect(!input.receive(type: .mouseMoved, event: try event(30, point: latest))) + #expect(input.receive(type: .leftMouseDragged, event: try event(20))) + #expect(input.latestPointerLocation == latest) + #expect(input.receive(type: .leftMouseUp, event: try event(25))) + #expect(input.latestPointerLocation == latest) + } +} diff --git a/Tests/WindowBurnInputTests/PointerTargetResolverTests.swift b/Tests/WindowBurnInputTests/PointerTargetResolverTests.swift new file mode 100644 index 0000000..8b517e4 --- /dev/null +++ b/Tests/WindowBurnInputTests/PointerTargetResolverTests.swift @@ -0,0 +1,37 @@ +import CoreGraphics +import Foundation +import Testing +import WindowBurnCore + +@testable import WindowBurn + +@Suite struct PointerTargetResolverTests { + @Test(arguments: [false, true]) + func cursorCannotHideNativeTargetWithOrWithoutScreenRecording(isRecording: Bool) { + let screen = CGRect(x: 0, y: 0, width: 1710, height: 1107) + let finder = CGRect(x: 29, y: 64, width: 855, height: 536) + var raw = [ + candidate(id: 4, layer: 2_147_483_630, frame: CGRect(x: 50, y: 100, width: 28, height: 40)) + ] + if isRecording { raw.append(candidate(id: 955, layer: 24, frame: screen)) } + raw.append(candidate(id: 785, layer: 0, frame: finder)) + let native = PointerTargetResolver.nativeInputCandidates(from: raw) + #expect(native.map(\.id) == [785]) + #expect( + WindowAtPointMatcher.frontmost( + at: CGPoint(x: 55, y: 110), amongFrontToBack: native, excludingPID: 0)?.id == 785) + } + + @Test func normalWindowsRetainOcclusionOrder() { + let frame = CGRect(x: 0, y: 0, width: 200, height: 200) + let native = PointerTargetResolver.nativeInputCandidates(from: [ + candidate(id: 2, layer: 0, frame: frame), + candidate(id: 1, layer: 0, frame: frame), + ]) + #expect(native.map(\.id) == [2, 1]) + } + + private func candidate(id: UInt32, layer: Int, frame: CGRect) -> PointWindowCandidate { + .init(id: id, ownerPID: 1, title: nil, frame: frame, layer: layer, isOnScreen: true, alpha: 1) + } +} diff --git a/docs/input-and-compositing-audit.md b/docs/input-and-compositing-audit.md new file mode 100644 index 0000000..01bc959 --- /dev/null +++ b/docs/input-and-compositing-audit.md @@ -0,0 +1,230 @@ +# Намокание: ввод, переключение приложений и живой фон + +Дата: 2026-09-05. Базовый коммит: `f852fc2` в `codex/seamless-material-effects`. +Это исследование оставшегося дефекта, а не подтверждение его исправления. +Последующая переработка ввода и результаты проверок: [Pointer input repair](pointer-input-fix.md). + +## Что удалось подтвердить + +Пользователь уточнил: замирает нарисованный курсор Window Burn, обычная мышь +продолжает работать. Предыдущие 90 тестов проверяли модели и геометрию, но не +аппаратный ввод. Визуальный QualityReviewController напрямую вызывает overlay и +тоже обходит event tap, AX hit testing и движение панели курсора. + +Журнал реальных попыток (локальное время): + +| Попытка | Нажатие | Overlay готов | Восстановлено отпускание через HID | Tap снова включён после отключения | +| --- | --- | --- | --- | --- | +| 1 | 16:48:55.804 | 16:48:55.999 | 16:48:57.549 | 16:49:00.007 | +| 2 | 16:50:03.794 | 16:50:03.970 | 16:50:10.499 | 16:50:14.870 | + +Обе попытки: окно 855×1072 pt при масштабе 2×. С padding это overlay +2046×2480 px, около 5,1 млн пикселей. Запись о повторном включении не содержит +момента начала задержки и не позволяет вычислить её длительность. Текущий +лог объединяет timeout и отключение пользовательским вводом; различать их +по старой записи нельзя. Журнал сохранён локально в +`dist/input-diagnostics/reported-reproduction.log`. + +### Физическая проверка с независимым HID observer + +Пользователь повторил намокание в диагностической сборке: удержание +17:01:41.409–17:02:07.202 (~26 секунд), два кратких замирания, без полного +зависания. Полный журнал: `dist/input-diagnostics/full-reproduction.log`. + +- HID observer продолжает получать drag-события. Например, за интервал, + заканчивающийся 17:02:00, raw drag=61, production tap drag=0, + recovery samples=46, cursor position changes=44. +- Почти весь жест production tap не получает drag. Исключения: 20 в стартовом + интервале, 3 после повторного включения tap и 1 около отпускания. + Повторное включение записано в 17:01:43.687 и 17:02:07.639. +- Нормальное движение обеспечивается **recovery**, который фактически стал + основным каналом. Это подтверждённый дефект канала ввода, а не только гипотеза + о тяжёлом шейдере. +- За 26 интервалов получено 1199 аппаратных drag и только 24 production drag; + в 23 интервалах production drag=0. Курсор изменил переданную позицию 818 раз. + В первом интервале 49 raw drag, но все 36 recovery samples вернули одну + позицию. Следовательно, опрос `CGEvent(source: .hidSystemState)` не даёт + независимый надёжный поток координат. Его устаревшая позиция может конфликтовать + со свежей позицией callback. Новый источник должен сохранять `event.location` + непосредственно из независимого наблюдателя, а не опрашивать global position. +- В большинстве секунд draw и recovery обслуживаются 42–47 раз; максимальное + ожидание drawable в каждой из этих секунд около 33 мс. Во время поджигания + оно доходит до 50–67 мс. Это CPU wall time ожидания доступного drawable, + **не измерение длительности GPU-шейдера**. +- Максимальная зарегистрированная длительность callback начала жеста 21,73 мс; + после повторного включения tap — 0,52 мс. Многосекундного callback в записи + нет. Снимки возраста main heartbeat при soaking не превышают ~101 мс. + Отчёт с периодом 1 сек не исключает короткую остановку между отчётами. +- HID button=true и session button=false во время удержания согласуются с тем, + что приложение подавляет mouseDown. Session state нельзя использовать как + замену физическому состоянию кнопки. + +Эти данные не подтверждают долгий тупик в WindowServer. Они подтверждают потерю +основного drag-канала и конкуренцию запасного канала с ожиданием drawable. +На этом этапе причина отключения tap ещё не различалась; следующий запуск +ниже установил timeout и задержанную доставку. Длительность двух замеченных пауз +не локализована stack trace. Автоматический sampler с порогом mainAge >400 мс +не сработал; отсутствие stack trace не является доказательством отсутствия пауз. + +### Уточняющий запуск: причина отключения и возраст событий + +В диагностике дополнительно включено наблюдение `mouseMoved` в production tap +(такие события всегда пропускаются), фоновый `CGEvent.tapIsEnabled`, раздельные +счётчики `tapDisabledByTimeout`/`tapDisabledByUserInput` и возраст события перед +входом в callback. Пользователь повторил физический жест +17:10:04.024–17:10:22.399, снова заметил краткие замирания. +Журнал: `dist/input-diagnostics/classification-full.log`. + +| Конец интервала | Raw drag | Production drag | Tap enabled | Timeout notifications | Max age до callback | +| --- | ---: | ---: | --- | ---: | ---: | +| 17:10:05.919 | 60 | 0 | false | 0 | нет событий | +| 17:10:07.928 | 51 | 2 | true | 1 | 2963,73 мс | +| 17:10:08.887 | 53 | 3 | true | 1 | 1393,84 мс | +| 17:10:09.928 | 59 | 1 | false | 0 | 28,31 мс | +| 17:10:11.928 | 62 | 3 | true | 1 | 2596,65 мс | +| 17:10:22.925 | 27 | 2 | true | 1 | 10870,76 мс | + +`userDisabled=0`; `mouseMoved` во время пропавших drag также отсутствует. +Гипотеза простой переклассификации движения не объясняет эту попытку. +Между 17:10:12 и 17:10:21 наблюдатель каждую секунду видит выключенный production +tap при продолжающемся физическом вводе. Сам callback начала жеста занимает +максимум 34,35 мс; последующие callbacks — доли миллисекунды, но некоторые +полученные ими события старше нескольких секунд. + +Подтверждённая цепочка: production tap отключается по timeout → доставка +drag прекращается → курсор зависит от опроса, который иногда возвращает одну +позицию при продолжающемся движении → после включения приходят старые события. +Их координаты передаются прямо в `move(toQuartzPoint:)` без проверки timestamp. +Это создаёт второй путь к возврату курсора на устаревшую позицию. + +Например, интервал 17:10:09 содержит 59 raw drag, 46 recovery samples с нулём +изменений позиции, только 3 перемещения панели и продолжающиеся 46 draw calls. +Это локализует краткую остановку к каналу координат, а не полной остановке UI. +Зависимость от drawable остаётся измеренным дополнительным ограничением. +Возраст события **не равен** времени выполнения callback и не доказывает +непрерывную блокировку главного потока на 10,9 секунды. Точный низкоуровневый +планировочный механизм задержки требует A/B с независимым потоком перехвата. + +## Почему восстановление по таймеру недостаточно + +В `WindowControlInterceptor` event tap установлен в главный run loop. +В том же run loop работают новый recovery timer (60 Hz), панель курсора +(30 Hz) и `@MainActor BurnRenderer`. Во время удержания кнопки собственный +таймер курсора намеренно не обновляет положение. Он полагается на drag callback. + +1. Callback drag синхронно вызывает `NSPanel.setFrameOrigin`. Callback down + также скрывает/показывает панель и выполняет Accessibility hit testing. + Работа с окнами и AX внутри блокирующего перехвата — первый кандидат на + длительную задержку. Конкретный взаимный тупик WindowServer пока не доказан. +2. `BurnRenderer.draw` получает `MTKView.currentDrawable` на главном потоке. + Исчерпание drawable pool может приостановить поток. Apple документирует + ожидание до одной секунды в [CAMetalLayer.nextDrawable](https://developer.apple.com/documentation/quartzcore/cametallayer/nextdrawable()). + Нет отдельного неблокирующего ограничения кадров в полёте. Мокрый шейдер + продолжает работать после отпускания, пока ожидается поджигание. +3. Recovery timer не является независимым watchdog: при остановке главного + потока он останавливается вместе с вводом и курсором. + +Вторая физическая проверка выше подтвердила timeout, задержанную доставку и +устаревшие координаты. Низкоуровневую причину задержки доставки следует проверить +через A/B переноса event tap на отдельный поток. Очередь мокрых +точек очищается каждый кадр; накопления всей истории движения в renderer нет. +Явный `waitUntilCompleted` используется при handoff, а не на каждом кадре +обычного намокания. + +## Диагностика, отличающая эти случаи + +Запуск: `./script/build_and_run.sh --input-diagnostics`. +Смотреть категорию `input-diagnostics` подсистемы `dev.malikov.WindowBurn`. +Диагностика включается только этим аргументом и не меняет подавление событий. +Она не записывает экран, текст окон или абсолютные координаты указателя. + +- Независимый listen-only HID tap считает физические события на своём потоке. +- Фоновый отчёт продолжает идти, даже если главный поток остановлен. +- Heartbeat показывает возраст последнего обслуживания главного run loop. +- Отдельно измеряются callback, ожидание drawable, смены положения курсора + и образцы recovery. HID и session button state сравниваются. + +Если raw motion продолжается при растущем возрасте heartbeat, нужен stack +главного потока (`sample WindowBurn 5 -file /tmp/window-burn-sample.txt`). +Ожидание drawable укажет на GPU/backpressure; WindowServer/AX вызов внутри +callback — на синхронный путь ввода. Если heartbeat нормален, сравнить +raw motion, main drag, recovery changes и cursor changes: это выделит потерю +событий или устаревший источник положения. + +Следующий A/B: при той же GPU-нагрузке callback только сохраняет состояние, +а работа AppKit выполняется после возврата callback. Второй A/B — прежний +callback при остановленном renderer. Синтетический drag не заменяет физическую +проверку: `.hidSystemState` может отличаться от состояния инъецированных событий. + +Приоритетная переработка после этих измерений: + +1. Отдельный run loop для перехвата, короткий callback: принять/подавить событие, + записать его в ограниченную очередь, вернуть управление. Не выполнять AX, + движение/скрытие NSPanel или ожидание main actor из callback. Решение о + перехвате должно опираться на заранее подготовленное состояние допустимого + целевого окна; неизвестное/устаревшее состояние пропускает обычный ввод. +2. Доставлять в UI последнюю позицию, сохраняя порядок начала и конца жеста. + Не создавать бесконечную очередь UI-задач на каждое аппаратное движение. + Брать координаты из самого HID-события. Подавление клика и наблюдение движения + должны быть разделены, чтобы подавление mouseDown не лишало нас drag/up. + Проверять sequence ID и timestamp: событие старого жеста/старше последней + принятой позиции не должно двигать курсор или возобновлять уже завершённый полив. +3. Убрать блокирующее получение drawable с главного потока; отделить очередь + рендера и ограничить число кадров в полёте. Курсор не должен ждать кадр + намокания. Снизить стоимость мокрого прохода отдельно от резкости снимка. +4. Проверить той же физической попыткой: raw/accepted gesture согласованы, + отпускание доставлено один раз, курсор не зависит от recovery, главный + поток не ждёт drawable. Только после этого считать зависание исправленным. + +## Почему Cmd-Tab оставляет мокрые островки + +`BurnOverlayController` создаёт `.screenSaver` panel с `.canJoinAllSpaces`. +Наблюдения за активным приложением, Space, перемещением и минимизацией целевого +окна нет. В фазе soaking `BurnRenderer` рисует только мокрые участки, оставляя +сухую область прозрачной. Сам Finder всё ещё существует под ними. +Когда Finder уходит назад, панель остаётся сверху — ровно как на скриншотах. + +Для ближайшей версии предлагается отменять незавершённое намокание и возвращать +обычный ввод при потере контекста: смене приложения/Space/целевого окна, +перемещении, минимизации или закрытии окна. Cmd-Tab должен проходить в систему. +`NSWorkspace` notifications покрывают приложения и Spaces; смену окна внутри +того же приложения и его геометрию нужно наблюдать через AX. + +Альтернатива — сохранять мокрое состояние и прятать/возвращать его вместе с +целевым окном. Это требует отслеживания точного Window ID, порядка, геометрии, +перекрытий и Spaces. Простая замена `.screenSaver` на `.normal` недостаточна. +[order(_:relativeTo:)](https://developer.apple.com/documentation/appkit/nswindow/order(_:relativeto:)) +устанавливает порядок в данный момент, а не постоянную связь с чужим окном. +Это отдельная функциональность, а не маленький косметический фикс. + +## Почему в дырке стоп-кадр + +`WindowCaptureService.captureBackdrop` один раз вызывает +`SCScreenshotManager.captureImage` с исключённым исходным окном. +`BurnRenderer` создаёт `backdropTexture` один раз. При `ruptureMask > 0` +шейдер подмешивает RGB этой текстуры, сохраняя непрозрачность исходной формы. +Это изображение отверстия; WindowServer не видит настоящую прозрачность. + +Просто обнулить alpha сейчас нельзя: исходное окно закрывается только при +поджигании, поэтому в отверстии снова будет виден целый Finder. + +Предлагаемый путь: + +1. До поджигания получать живой фон через `SCStream`, исключая целевое окно и + собственные панели. Ограничить захват областью эффекта, передавать последний + `CVPixelBuffer` в Metal через IOSurface/texture cache без CPU-конвертации в + CGImage. Не накапливать очередь старых кадров. Этот механизм есть в + [официальном примере Apple](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos). +2. После подтверждённого закрытия исходного окна делать разрывы действительно + прозрачными. Тогда видео за ними обновляет сам WindowServer. Стрим нужен + только там, где остаётся оптическое искажение живого фона. +3. Измерять задержку и стоимость стрима отдельно. Live capture имеет задержку, + дополнительную нагрузку и ограничения захвата защищённого контента; + он не равен прямой прозрачности. При сбое стрима отменять эффект, а не + оставлять незаметно устаревший фон. + +Порядок работы: установить причину зависания → разнести ввод и рендеринг → +обработать потерю контекста → добавить живой фон и настоящую прозрачность после +закрытия. Проверка результата должна включать физическое удержание 30–60 секунд +на крупном Retina-окне, отпускание за его пределами, Cmd-Tab, Space, несколько +окон одного приложения и движущееся видео на фоне. diff --git a/docs/pointer-input-fix.md b/docs/pointer-input-fix.md new file mode 100644 index 0000000..681bd74 --- /dev/null +++ b/docs/pointer-input-fix.md @@ -0,0 +1,135 @@ +# Pointer input repair — 2026-09-05 + +The shader snapshot was already committed in `fdf9f8e`; the input investigation +was saved through `f707026`. The supplied 36-second recording shows a Finder +window underneath a burning surface becoming active. Its cursor artwork is not +visible, so the recording alone cannot identify each missed click. + +## Cause and change + +The earlier [physical-input audit](input-and-compositing-audit.md) established +session-tap timeouts and events delivered seconds late. The tap was attached to +the main run loop, shared with drawable acquisition and cursor animation. Its +callbacks also performed AX lookup and hid/moved/ordered the cursor panel. +When that tap was disabled, the click-through effect panel let native windows +receive the input. The existing torch-session registry already routed repeated +clicks into the screenshot correctly when callbacks actually arrived. + +The tap now owns a separate run-loop thread. Its callback uses prepared value +snapshots and a bounded queue; it never invokes AppKit, AX, rendering, or a +synchronous main-thread dispatch. Apple documents that the callback executes on +[the run loop hosting its source](https://developer.apple.com/documentation/coregraphics/cgevent/tapcreate(tap:place:options:eventsofinterest:callback:userinfo:)). + +- A resolver worker prepares front-to-back native window and close-button regions. + Unsupported/covered/expired native regions pass ordinary input through. AX reads have + an explicit per-handle deadline, and each application's window list is read + once per refresh. Where an event supplies a native window ID, a mismatch rejects + a stale target. The snapshot freshness limit is 300 ms; this remains a bounded + cache, not a continuous AX/Space/window lifecycle observer. +- Active replacement surfaces take priority over native windows beneath them, + including while their first screenshot is still being prepared. A queued click + retains its original resolved window rather than hit-testing a different window + later. Queue overflow on an existing effect consumes the whole click sequence + without adding another mark, so it cannot activate the app beneath the effect. +- Accepted down/up pairs retain their order and reserved queue space. Motion is + coalesced; the cursor uses the newest event position independently of the UI's + gesture queue. Timestamps and sequence IDs reject old motion and old releases. + Input older than 250 ms cannot start a new interaction. Mode cancellation drops + pending tool actions while retaining ownership through the physical release. +- The cursor follows at 60 Hz, with artwork at 30 Hz and no repeated redraw of the + static idle badge. Capture excludes its window ID, so it stays visible while + preparing the effect. Hardware-position polling and synchronous cursor-panel + manipulation inside the mouse callback have been removed. +- A nonblocking one-frame GPU gate precedes drawable acquisition. Busy animation + frames are skipped; paused first-frame/handoff requests are retried after GPU + completion. Compositor acknowledgement still gates native-window handoff, and + no `waitUntilCompleted` remains on the main thread. Shaders are unchanged. + +## Verification + +Physical torch check, 18:31:37–18:31:58: the user confirmed that the cursor follows +and repeated clicks create new ignition sites. Two fixture windows burned, with +14 additional accepted ignition sites. Native fixture counters remained unchanged +through that attempt; the one initial native click was a separate automation +probe that bypassed the system event stream. Raw/production counts were 24/24 +mouse downs, 7/7 drags and 24/24 releases. There were no tap timeouts or disabled +intervals. Maximum callback: 0.22 ms; event age: 1.46 ms; drawable wait: 1.01 ms. + +A separate physical soak gesture at 18:32:52–18:32:57 was followed by ignition at +18:32:58. In the surrounding 18:32:51–18:32:59 interval the HID observer and +production tap both received 262 drags, two downs and two releases, with no +disabled intervals. Maximum callback: 0.14 ms; event age: 0.24 ms; drawable wait: +0.37 ms. This initial trial was short and did not yet include subjective +confirmation of the 18+ artwork. + +`swift format lint --strict --recursive Package.swift Sources Tests` passes, +as do all 113 tests and the universal arm64/x86_64 release build. The signed local +app was rebuilt and relaunched with input diagnostics. The measurements above +were captured during implementation; subsequent cache guards and idle-artwork +changes had automated regression coverage but still needed final physical input +verification. The follow-up below records the final-build regression and retest. + +### Follow-up: native entry blocked in every application + +The user subsequently reported that both tools passed clicks and drags through +in Finder and other applications. Diagnostics on the actual physical events at +19:15:18–19:15:22 showed `decision=no-target`, fresh snapshots (25–92 ms), and no +native event window ID. This was a target-selection failure, before effect +creation or window-identity validation. + +The live WindowServer list contained a full-screen `Screenshot` surface on layer +24 and cursor surfaces on layer 2147483630. The new resolver had treated every +nonzero layer as an opaque input blocker. WindowServer enumerates visual surfaces; +these click-through overlays therefore hid native input targets in the cache. +The cursor surface alone can cover the click point, even without screen recording. + +The resolver now uses only visible normal application windows (layer 0) for +native targets and occlusion, matching the existing `WindowAtPointMatcher` rule. +Unsupported normal windows still block targets behind them. Active effect surfaces +continue to use their explicit interaction regions. The regression test uses the +observed full-screen recording and cursor layers above a normal Finder window: +it failed before the filter change and passes afterward, with coverage for the +cursor alone as well as the recording overlay. Additional interceptor +tests cover first native entry for both tools with unavailable and matching event +window IDs, retaining the original target through drag and release after refresh. + +Window Burn's own status menu suspends new interception while tracking, so its +items cannot act on a normal window or burning surface underneath. A gesture +accepted before the menu opens keeps ownership of its remaining drag/release. +The suspension and restoration paths also have interceptor regression coverage. + +Final signed build, physical retest at 19:25:46–19:26:08: the user confirmed +"Оба режима работают" and "вот сейчас просто идеально". Soaking started on a +native Finder window from its first down, followed by ignition. Torch then +started on another native Finder window and accepted three additional ignition +sites. All eight downs, 208 drags and eight releases reached the production tap, +matching the independent HID observer; no timeouts or disabled intervals occurred. +Maximum callback: 0.52 ms; event age: 0.99 ms; drawable acquisition: 1.16 ms. + +All 123 tests pass, including cursor-layer cases with and without the recording +overlay. Strict Swift format lint, `git diff --check`, the universal arm64/x86_64 +release build and the signed-app verification pass. The running app's Mach-O UUID +matches the tested debug build. Local evidence is saved under ignored +`dist/input-diagnostics/native-layer-fix/`. + +Regression coverage includes the core queue plus the actual interceptor's routing +state, with rapid clicks, delayed release, missed release, mode off/on, unknown +windows, expired snapshots, native-window identity mismatch, stale coordinates, +and full-queue shielding. These tests construct events but never post UI input. +The separate fixture is intentionally local under ignored `dist/input-review`. + +Run diagnostics with: + +```sh +./script/build_and_run.sh --input-diagnostics --torch +./script/build_and_run.sh --input-diagnostics --soak-and-burn +``` + +The resolver reports aggregate timing/counts only. Down-event decisions are +buffered on the tap and logged by the main drain only with `--input-diagnostics`; +they include the rejection reason, native/prepared window IDs and snapshot age. +They do not include window titles or contents. The existing input diagnostics +report raw/production event counts, callback age, cursor changes and drawable +waits. GPU duration and drawable acquisition wait are different measurements. +The [existing compositing limitations](input-and-compositing-audit.md#почему-cmd-tab-оставляет-мокрые-островки) +are outside this pointer repair. diff --git a/docs/quality-audit.md b/docs/quality-audit.md new file mode 100644 index 0000000..c80263d --- /dev/null +++ b/docs/quality-audit.md @@ -0,0 +1,86 @@ +# Аудит качества захвата и эффектов + +Ветка: `codex/seamless-material-effects`. База сравнения: `main`, версия 0.3.1. + +## Откуда появлялись артефакты + +1. **Разрыв между окном и снимком.** `waitUntilCompleted()` подтверждал завершение работы GPU, но окно закрывалось до подтверждения появления кадра на дисплее. Дополнительно AppKit анимировал появление панели: на реальной записи её ширина на первых кадрах уменьшалась с 1520 до 1496 пикселей. Теперь используется `animationBehavior = .none` и подтверждение фактического вывода кадра. Настоящая тень раньше исчезала при закрытии, а её замена включалась только после опроса Accessibility с интервалом 50 мс. +2. **Изменённые полупрозрачные края.** Шейдер изменял исходную альфу через `smoothstep`, затем умножал её повторно. Например, исходные 50% прозрачности превращались в 25% покрытия. Формат premultiplied alpha не был явно определён для текстуры и финального смешивания. +3. **Неточная тень.** Тело окна, тень и фон снимались последовательно. Положение тени определялось поиском почти непрозрачных пикселей; при неудаче рисовались искусственные тень и ободок. +4. **Размытие при намокании.** Весь оверлей, включая текст и границы, ограничивался двумя миллионами пикселей и 30 кадрами/с. Это ухудшало чёткость Retina-окна ещё до изменения материала. +5. **Условная физика.** Влага накапливалась отпечатками кисти, пламя следовало заданному фронту и шуму. Перенос тепла зависел от числа кадров; даже шаг нулевой длительности мог менять тепло. Окрашивание и размытие жидкости не описывали оптику тонкого слоя. + +## Что изменено + +### Захват и передача изображения + +Для окна, находящегося на одном дисплее, ScreenCaptureKit получает тело и родную тень **одним кадром в координатах дисплея**. Из него вырезается тело окна. Вырез у края дисплея использует согласованные `sourceRect` и `destinationRect`: обрезанные участки не растягиваются на весь оверлей. Фон и переходный снимок захватываются параллельно. + +Перед закрытием показывается снимок видимого участка экрана, включая окно, тень и окружение. Только после `MTLDrawable.addPresentedHandler` отправляется действие закрытия. Пока приложение подтверждает закрытие, этот кадр сохраняет внешний вид. Затем показывается материал с родной тенью, подтверждается его вывод и запускается анимация. Для уже намокшего окна переходный снимок обновляется непосредственно перед поджиганием. + +У `SCScreenshotManager` обнаружена дополнительная особенность: настройка `ignoreShadowsSingleWindow`, по умолчанию `true`, влияла и на снимки с фильтром дисплея. Теперь обе настройки игнорирования теней явно отключены. Проба на реальном окне до исправления давала нулевую альфу за пределами тела, после — 237 040 пикселей тени с максимальной альфой 229. Эта ошибка также показала, почему нельзя проверять захват только другим вызовом того же API: итоговая проверка использует независимый системный `screencapture` и отдельно проверяет наличие тени в исходнике. + +Панель курсора исключается из захвата по window ID. Искусственное ожидание 80 мс для её исчезновения больше не нужно. Рендер сохраняет исходную альфу, использует явно заданный premultiplied RGBA и Display P3. Это сохраняет насыщенные цвета нативных кнопок, которые терялись при промежуточном переводе в sRGB. Для неизменённого материала есть отдельный проход без эффектов. Родная тень содержит тело окна, поэтому она не накладывается второй раз на полупрозрачные углы. Весь оверлей выводится в родном разрешении с целевой частотой 60 кадров/с; маленькая сетка остаётся только у симуляции. Metal-программы прогреваются при обычном запуске приложения. + +### Материал + +Добавлены оптика слоя жидкости с поглощением по толщине, френелевское отражение для показателя преломления воды, согласованные волокна и нормали материала. Пламя получает источник из поля тепла, топлива и влажности, вытягивается вверх и меняет излучение с температурой. Перенос тепла учитывает длительность шага; пауза не изменяет симуляцию. + +Это **физически мотивированная модель 2.5D**, а не фотометрически откалиброванная симуляция. Температура и толщина здесь нормализованы; это не измерения в кельвинах и миллиметрах. Полноценной трёхмерной жидкости, сохранения массы стекающего слоя, деформации сетки бумаги и объёмного рассеяния света пока нет. + +## Воспроизводимая проверка + +```bash +swift format lint --strict --recursive Package.swift Sources Tests +swift test +swift script/check-renderer-alpha.swift +./script/build_and_run.sh --quality-review "$PWD/dist/quality-review" +``` + +Проверка создаёт собственное тестовое окно и фон. Она не закрывает пользовательские документы и не устанавливает перехватчик кнопок. Нужен уже выданный доступ к записи экрана. Результаты записываются в указанную папку: + +- PNG настоящего окна, переходного кадра и замены при остановленной анимации; +- аналогичная последовательность для полупрозрачного окна; +- отдельные сравнения содержимого, углов и тени в JSON; +- снимки намокания и огня; +- длительности работы GPU с явным отделением от частоты вывода кадров; +- временная шкала и `status.txt`. + +Проверка альфы компилирует настоящий Metal-шейдер и выводит прозрачность 0/64/128/192/255 через три прохода. У нового шейдера максимальное расхождение — 0; прежний шейдер из `main` нарушает тот же контракт с расхождением 64. Дополнительный `--benchmark` у этого скрипта измеряет только fragment-проход. На Apple M4 при 3588×2350 его p95 составил 25,91 мс для сухого горения и 37,84 мс для влажного: полноэкранные 60 кадров/с сейчас недостижимы без отдельной оптимизации газового поля. + +Числовые допуски относятся к синтетическому окну на проверенном дисплее. Они не доказывают отсутствие артефактов во всех приложениях. Превью нужно также оценить в движении. + +## Результат на этом Mac + +Проверено 5 сентября 2026 на Apple M4, Retina 2×, область 1856×1260. Проверка использует независимый системный захват экрана; PNG с ICC-профилем приводятся к единому sRGB для численного сравнения. + +| Сравнение | Максимальное расхождение RGB | +| --- | ---: | +| Настоящее окно → переходный кадр | 0/255 | +| Исходное окно уже скрыто, переходный кадр остаётся | 0/255 | +| Настоящее окно → материал с родной тенью | 3/255 | +| Полупрозрачное окно → переходный кадр | 0/255 | +| Полупрозрачное окно → материал с родной тенью | 3/255 | + +Все области содержимого, углов и теней прошли объявленные допуски. В исходном снимке подтверждено 508 467 ненулевых пикселей тени вне прямоугольника тела окна. Независимая запись экрана нужна дополнительно к таблице: именно она обнаружила потерю тени, которую не замечало первоначальное сравнение двух снимков через один API. + +Пройдены 88 тестов Swift Testing, строгий Swift format lint, GPU-проверка альфы, сборка universal arm64/x86_64 и запуск подписанного локального `.app`. Результаты относятся к тестовым окнам и одному дисплею; это не проверка всех сторонних приложений и конфигураций экранов. + +## Что остаётся ограничением + +- У публичных API macOS нет общей атомарной транзакции для закрытия чужого окна и представления нашего слоя. Переходный снимок перекрывает этот промежуток. +- Для окна между несколькими дисплеями сохранён прежний desktop-independent захват и резервное выравнивание тени. Точный переход для смешанных масштабов требует отдельных снимков/оверлеев каждого дисплея. +- Изображение окружения временно заморожено. Движущийся фон, полупрозрачные приложения и перекрывающиеся окна могут выдать смену кадра. Оверлей по-прежнему находится выше обычных окон. +- SDR/Display P3 сохраняет широкий цветовой охват современных Mac. HDR/EDR и яркость за пределами SDR требуют отдельного тракта. +- Большое разрешение повышает нагрузку GPU. Целевая частота 60 кадров/с не равна гарантии её достижения на всех Mac или при четырёх одновременных эффектах. +- Поведение закрытия несохранённых документов и отказ от неоднозначных диалогов сохранены. Ошибка перехвата по-прежнему пропускает штатное закрытие без модального предупреждения. + +## Готовые реализации и источники + +[Burn My Windows](https://github.com/Schneegans/Burn-My-Windows) работает внутри GNOME-композитора: он может удерживать настоящий actor окна до завершения эффекта. Это полезный архитектурный ориентир, но не готовый модуль для macOS. + +- Apple: [ScreenCaptureKit и снимки](https://developer.apple.com/videos/play/wwdc2023/10136/), [подтверждение вывода drawable](https://developer.apple.com/documentation/metal/mtldrawable/addpresentedhandler(_:)), [синхронизация Metal с Core Animation](https://developer.apple.com/documentation/quartzcore/cametallayer/presentswithtransaction). +- NVIDIA: [GPU Gems 3 — Real-Time Simulation and Rendering of 3D Fluids](https://developer.nvidia.com/gpugems/gpugems3/part-v-physics-simulation/chapter-30-real-time-simulation-and-rendering-3d-fluids), [Fast Fluid Dynamics Simulation on the GPU](https://developer.nvidia.com/gpugems/gpugems/part-vi-beyond-triangles/chapter-38-fast-fluid-dynamics-simulation-gpu). +- PBRT: [Dielectric BSDF](https://pbr-book.org/4ed/Reflection_Models/Dielectric_BSDF). + +Следующий большой шаг к фотографическому результату — перенос и впитывание жидкости с сохранением массы, термомеханическая деформация тонкой сетки и объёмное пламя с освещением материала. Их стоит делать после оценки текущего результата и профилирования: одна только более сложная формула шума этого качества не даст. diff --git a/script/build_and_run.sh b/script/build_and_run.sh index 4270f0f..d78e9d9 100755 --- a/script/build_and_run.sh +++ b/script/build_and_run.sh @@ -65,6 +65,14 @@ case "$MODE" in run) open_app ;; + --input-diagnostics) + open_app --args --input-diagnostics "${@:2}" + ;; + --quality-review|quality-review) + REVIEW_OUTPUT="${2:-$DIST_DIR/quality-review}" + mkdir -p "$REVIEW_OUTPUT" + open_app --args --quality-review "$REVIEW_OUTPUT" + ;; --demo|demo) open_app --args --demo ;; @@ -94,7 +102,7 @@ case "$MODE" in pgrep -x "$PROCESS_NAME" >/dev/null ;; *) - echo "usage: $0 [run|--demo|--demo-soak|--torch|--soak-and-burn|--debug|--logs|--telemetry|--verify]" >&2 + echo "usage: $0 [run|--input-diagnostics [--torch|--soak-and-burn]|--quality-review [output-directory]|--demo|--demo-soak|--torch|--soak-and-burn|--debug|--logs|--telemetry|--verify]" >&2 exit 2 ;; esac diff --git a/script/check-renderer-alpha.swift b/script/check-renderer-alpha.swift new file mode 100644 index 0000000..b97ff94 --- /dev/null +++ b/script/check-renderer-alpha.swift @@ -0,0 +1,215 @@ +import Foundation +import Metal + +let device = MTLCreateSystemDefaultDevice()! +let queue = device.makeCommandQueue()! +let rendererPath = + CommandLine.arguments.dropFirst().first { !$0.hasPrefix("--") } + ?? "Sources/WindowBurn/BurnRenderer.swift" +let renderer = try String(contentsOfFile: rendererPath, encoding: .utf8) +let beginning = renderer.range(of: "private static let shaderSource = #\"\"\"")!.upperBound +let ending = renderer.range(of: "\"\"\"#", range: beginning.. MTLTexture { + let td = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: format, width: size, height: size, mipmapped: false) + td.storageMode = .shared + td.usage = [.shaderRead, .renderTarget] + let result = device.makeTexture(descriptor: td)! + pixels.withUnsafeBytes { + result.replace( + region: MTLRegionMake2D(0, 0, size, size), mipmapLevel: 0, withBytes: $0.baseAddress!, + bytesPerRow: size * 4) + } + return result +} +let original = texture(.rgba8Unorm, pixels: pixels) +let blank = texture(.rgba8Unorm, pixels: [UInt8](repeating: 0, count: pixels.count)) +let sd = MTLSamplerDescriptor() +sd.minFilter = .linear +sd.magFilter = .linear +sd.sAddressMode = .clampToZero +sd.tAddressMode = .clampToZero +let sampler = device.makeSamplerState(descriptor: sd)! +for (name, replacement, rendering) in [ + ("compositor-cover", SIMD4(0, 1, 0, 0), SIMD4(0, 1, 0, 0)), + ("native-shadow-prestart", SIMD4(1, 1, 0, 0), SIMD4(0, 0, 0, 0)), + ("active-dry-material", SIMD4(1, 1, 0, 0), SIMD4(1, 0, 0, 0)), +] { + let target = texture(.bgra8Unorm, pixels: [UInt8](repeating: 0, count: pixels.count)) + let pass = MTLRenderPassDescriptor() + pass.colorAttachments[0].texture = target + pass.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 0) + pass.colorAttachments[0].loadAction = .clear + pass.colorAttachments[0].storeAction = .store + let command = queue.makeCommandBuffer()! + let encoder = command.makeRenderCommandEncoder(descriptor: pass)! + encoder.setRenderPipelineState(pipeline) + for index in 0...5 { + encoder.setFragmentTexture(index == 1 || index == 3 ? blank : original, index: index) + } + encoder.setFragmentSamplerState(sampler, index: 0) + for index in 0...16 { + var value = SIMD4(repeating: 0) + if index == 2 { value.x = 1 } + if index == 3 { value = SIMD4(1, 0, 1, 0.03) } + if index == 4 { value = SIMD4(2, 0, 3, 0) } + if index == 7 { value = SIMD4(0, 2, 0, 0) } + if index == 8 { value = SIMD4(0.0038, 0.024, 0.094, 0.2) } + if index == 11 { value.w = 1.333 } + if index == 15 { value = replacement } + if index == 16 { value = rendering } + encoder.setFragmentBytes(&value, length: MemoryLayout>.stride, index: index) + } + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + command.commit() + command.waitUntilCompleted() + guard command.status == .completed else { + fatalError("GPU failed: \(String(describing: command.error))") + } + var output = [UInt8](repeating: 0, count: pixels.count) + target.getBytes( + &output, bytesPerRow: size * 4, from: MTLRegionMake2D(0, 0, size, size), mipmapLevel: 0) + var maximumDifference = 0 + for i in stride(from: 0, to: pixels.count, by: 4) { + for (outChannel, inChannel) in [(0, 2), (1, 1), (2, 0), (3, 3)] { + maximumDifference = max( + maximumDifference, abs(Int(output[i + outChannel]) - Int(pixels[i + inChannel]))) + } + } + print( + "\(name): max byte difference=\(maximumDifference), GPU=\((command.gpuEndTime-command.gpuStartTime)*1000)ms" + ) + guard maximumDifference <= 1 else { exit(1) } +} + +// Optional GPU-only stress measurement; upload, simulation and compositor time are excluded. +if CommandLine.arguments.contains("--benchmark") { + func upload(width: Int, height: Int, format: MTLPixelFormat, values: [T]) -> MTLTexture { + let description = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: format, width: width, height: height, mipmapped: false) + description.storageMode = .shared + description.usage = [.shaderRead, .renderTarget] + let texture = device.makeTexture(descriptor: description)! + values.withUnsafeBytes { + texture.replace( + region: MTLRegionMake2D(0, 0, width, height), mipmapLevel: 0, + withBytes: $0.baseAddress!, bytesPerRow: $0.count / height) + } + return texture + } + + for (width, height) in [(1856, 1260), (3588, 2350)] { + let aspect = Float(width) / Float(height) + var fullImage = [UInt8](repeating: 255, count: width * height * 4) + for index in stride(from: 0, to: fullImage.count, by: 4) { + fullImage[index] = UInt8(80 + (index / 4 % width) % 176) + fullImage[index + 1] = UInt8(80 + (index / 4 / width) % 176) + fullImage[index + 2] = 130 + } + let fullTexture = upload(width: width, height: height, format: .rgba8Unorm, values: fullImage) + let target = upload(width: width, height: height, format: .bgra8Unorm, values: fullImage) + let fieldWidth = 512 + let fieldHeight = Int(512 / aspect) + for wet in [false, true] { + var wetPixels = [Float16](repeating: 0, count: fieldWidth * fieldHeight) + var statePixels = [Float16](repeating: 0, count: fieldWidth * fieldHeight * 4) + for y in 0..] = [ + SIMD4(0.25, 1.5 + Float(frame) / 60, 0, 0), + SIMD4(0.08, 0.12, 0, 0), + SIMD4(aspect, 0, 0, 0), + SIMD4(73.9, 0, 1.15, 0.075), + SIMD4(wet ? 2 : 1, 1, 6, wet ? 1 : 0), + SIMD4(0.5, 0.6, 0, 73.9), + SIMD4(0.48, 0.52, 73.9, 0.8), + SIMD4(1, 2, 3, wet ? 1 : 0), + SIMD4(0.0038, 0.024, 0.094, 0.20), + SIMD4(0.28, 0, 0.195, 0.076), + SIMD4(0.00045, 0.000008, 0.028, 0.20), + SIMD4(0.055, 0.034, 0.56, 1.333), + SIMD4(0.064, 0.155, 0.0032, 0.28), + SIMD4(0.62, 1.8, 3, 5.4), + .zero, + SIMD4(1, 0, 0, 0), + SIMD4(1, 0, 0, 0), + ] + for (index, var value) in values.enumerated() { + encoder.setFragmentBytes(&value, length: MemoryLayout>.stride, index: index) + } + encoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) + encoder.endEncoding() + command.commit() + command.waitUntilCompleted() + guard command.status == .completed else { fatalError("Benchmark render failed") } + if frame >= 4 { samples.append((command.gpuEndTime - command.gpuStartTime) * 1000) } + } + samples.sort() + let label = wet ? "soak-and-burn" : "dry-burn" + print( + "benchmark \(device.name) \(width)x\(height) \(label): median=\(samples[10])ms p95=\(samples[18])ms; 20 measured frames, GPU fragment pass only" + ) + } + } +}