From 21c2ede4d84c24f6a187992213266f5a4c50458d Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Fri, 21 Aug 2026 19:03:25 +0530 Subject: [PATCH] Add Debug Report performance timing foundation --- .../data/DebugReportLocalDataSource.kt | 19 +++- .../data/mapping/ConditionProfileMapping.kt | 37 ++++++++ .../DebugReportEventOccurrenceMapping.kt | 3 +- .../mapping/DebugReportOverviewMapping.kt | 7 +- .../core/smart/debugging/di/Hilt.kt | 6 ++ .../debugging/domain/DebuggingRepository.kt | 6 +- .../domain/DebuggingRepositoryImpl.kt | 8 +- .../domain/model/report/ConditionProfile.kt | 19 ++++ .../model/report/DebugReportOverview.kt | 6 +- .../smart/debugging/engine/DebugEngine.kt | 80 +++++++++++----- .../recorder/ConditionProfileRecorder.kt | 67 +++++++++++++ .../recorder/ProcessingTimingRecorder.kt | 33 +++++++ .../main/proto/ConditionProfileMessage.proto | 19 ++++ .../src/main/proto/DebugReportMessage.proto | 4 + .../src/main/proto/DebugReportOverview.proto | 8 +- .../mapping/PerformanceTimingMappingTests.kt | 61 ++++++++++++ .../recorder/ConditionProfileRecorderTests.kt | 56 +++++++++++ .../recorder/ProcessingTimingRecorderTests.kt | 25 +++++ .../core/processing/data/DetectorEngine.kt | 48 +++++++++- .../data/processor/ConditionsVerifier.kt | 14 ++- .../data/processor/ScenarioProcessor.kt | 3 + .../domain/DebugReportTimingListener.kt | 22 +++++ .../domain/SmartProcessingListener.kt | 5 +- ...DetectorEngineDetectionOrientationTests.kt | 3 + .../tests/DetectorEngineOrientationTests.kt | 3 + .../tests/ExecutionLimiterTimingTests.kt | 54 +++++++++++ .../tests/ScenarioProcessorTests.kt | 13 ++- docs/debug-report-performance-benchmark.md | 94 +++++++++++++++++++ docs/debug-report-performance-timing.md | 75 +++++++++++++++ 29 files changed, 761 insertions(+), 37 deletions(-) create mode 100644 core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/ConditionProfileMapping.kt create mode 100644 core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/ConditionProfile.kt create mode 100644 core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorder.kt create mode 100644 core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorder.kt create mode 100644 core/smart/debugging/src/main/proto/ConditionProfileMessage.proto create mode 100644 core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/PerformanceTimingMappingTests.kt create mode 100644 core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorderTests.kt create mode 100644 core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorderTests.kt create mode 100644 core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/DebugReportTimingListener.kt create mode 100644 core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ExecutionLimiterTimingTests.kt create mode 100644 docs/debug-report-performance-benchmark.md create mode 100644 docs/debug-report-performance-timing.md diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/DebugReportLocalDataSource.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/DebugReportLocalDataSource.kt index 0186e41f5..929ae09c1 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/DebugReportLocalDataSource.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/DebugReportLocalDataSource.kt @@ -27,6 +27,7 @@ import com.buzbuz.smartautoclicker.core.base.extensions.safeRecreate import com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping.toDomain import com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping.toCountersInitialValues import com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping.toProtobuf +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportCounterInitialValue import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportEventOccurrence import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportOverview @@ -201,6 +202,22 @@ internal class DebugReportLocalDataSource @Inject constructor( } } + /** Read aggregate condition timings from the last completed report, if the report contains them. */ + suspend fun readConditionProfile(): List = + filesMutex.withLock { + if (isWritingReport) return@withLock emptyList() + + messagesFile.safeInputStream()?.use { inputStream -> + while (true) { + val protoMessage = inputStream.safeParseDebugReportMessage() ?: break + if (protoMessage.hasConditionProfileMessage()) { + return@withLock protoMessage.conditionProfileMessage.toDomain() + } + } + } + emptyList() + } + /** Delete the current report files, if any. */ suspend fun deleteReport() { filesMutex.withLock { @@ -249,4 +266,4 @@ internal class DebugReportLocalDataSource @Inject constructor( private const val DEBUG_REPORT_MESSAGES_FILE_NAME = "DebugReportMessages.pb" private const val DEBUG_REPORT_OVERVIEW_FILE_NAME = "DebugReportOverview.pb" -private const val LOG_TAG = "DebugReportFileAccess" \ No newline at end of file +private const val LOG_TAG = "DebugReportFileAccess" diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/ConditionProfileMapping.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/ConditionProfileMapping.kt new file mode 100644 index 000000000..4bbeea466 --- /dev/null +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/ConditionProfileMapping.kt @@ -0,0 +1,37 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +package com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping + +import com.buzbuz.smartautoclicker.core.smart.debugging.ConditionProfileMessageKt.conditionProfileEntry +import com.buzbuz.smartautoclicker.core.smart.debugging.conditionProfileMessage +import com.buzbuz.smartautoclicker.core.smart.debugging.debugReportMessage +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile +import com.buzbuz.smartautoclicker.core.smart.debugging.DebugReportMessage as ProtoDebugReportMessage +import com.buzbuz.smartautoclicker.core.smart.debugging.ConditionProfileMessage as ProtoConditionProfileMessage + +internal fun List.toProtobuf(): ProtoDebugReportMessage = + debugReportMessage { + conditionProfileMessage = conditionProfileMessage { + entries.addAll(this@toProtobuf.map { profile -> + conditionProfileEntry { + conditionId = profile.conditionId + checkCount = profile.checkCount + fulfilledCount = profile.fulfilledCount + totalDurationNs = profile.totalDurationNs + minDurationNs = profile.minDurationNs + maxDurationNs = profile.maxDurationNs + } + }) + } + } + +internal fun ProtoConditionProfileMessage.toDomain(): List = + entriesList.map { entry -> + ConditionProfile( + conditionId = entry.conditionId, + checkCount = entry.checkCount, + fulfilledCount = entry.fulfilledCount, + totalDurationNs = entry.totalDurationNs, + minDurationNs = entry.minDurationNs, + maxDurationNs = entry.maxDurationNs, + ) + } diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportEventOccurrenceMapping.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportEventOccurrenceMapping.kt index fe59f3877..073808fd0 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportEventOccurrenceMapping.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportEventOccurrenceMapping.kt @@ -98,6 +98,7 @@ internal fun ProtoDebugReportMessage.toDomain(): DebugReportEventOccurrence? = ProtoDebugReportMessage.MessageTypeCase.TRIGGEREVENTMESSAGE -> triggerEventMessage.toDomain(relativeTimestampMs) ProtoDebugReportMessage.MessageTypeCase.COUNTERSINITMESSAGE -> null + ProtoDebugReportMessage.MessageTypeCase.CONDITIONPROFILEMESSAGE -> null ProtoDebugReportMessage.MessageTypeCase.MESSAGETYPE_NOT_SET -> { Log.e(LOG_TAG, "Can't read DebugReportEventOccurrence from protobuf") null @@ -123,4 +124,4 @@ private fun ProtoTriggerEventMessage.toDomain(relativeTimestamp: Long): DebugRep conditionsResults = resultsList.map { conditionResult -> conditionResult.toDomain() }, ) -private const val LOG_TAG = "DebugReportEventOccurrenceMapping" \ No newline at end of file +private const val LOG_TAG = "DebugReportEventOccurrenceMapping" diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportOverviewMapping.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportOverviewMapping.kt index 7db4e9cdb..f1f7a3eb5 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportOverviewMapping.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/DebugReportOverviewMapping.kt @@ -19,6 +19,7 @@ package com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping import com.buzbuz.smartautoclicker.core.smart.debugging.debugReportOverview import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportOverview import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds import com.buzbuz.smartautoclicker.core.smart.debugging.DebugReportOverview as ProtoDebugReportOverview @@ -32,6 +33,8 @@ internal fun DebugReportOverview.toProtobuf(): ProtoDebugReportOverview = imageEventFulfilledCount = this@toProtobuf.imageEventFulfilledCount triggerEventFulfilledCount = this@toProtobuf.triggerEventFulfilledCount countersName.addAll(this@toProtobuf.counterNames) + activeDetectionDurationNs = this@toProtobuf.activeDetectionDuration.inWholeNanoseconds + executionLimiterWaitDurationNs = this@toProtobuf.executionLimiterWaitDuration.inWholeNanoseconds } internal fun ProtoDebugReportOverview.toDomain(): DebugReportOverview = @@ -43,4 +46,6 @@ internal fun ProtoDebugReportOverview.toDomain(): DebugReportOverview = imageEventFulfilledCount = imageEventFulfilledCount, triggerEventFulfilledCount = triggerEventFulfilledCount, counterNames = countersNameList.toSet(), - ) \ No newline at end of file + activeDetectionDuration = activeDetectionDurationNs.nanoseconds, + executionLimiterWaitDuration = executionLimiterWaitDurationNs.nanoseconds, + ) diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/di/Hilt.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/di/Hilt.kt index 193e05e6b..0dbf85967 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/di/Hilt.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/di/Hilt.kt @@ -17,6 +17,7 @@ package com.buzbuz.smartautoclicker.core.smart.debugging.di import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.smart.debugging.domain.DebuggingRepository import com.buzbuz.smartautoclicker.core.smart.debugging.domain.DebuggingRepositoryImpl import com.buzbuz.smartautoclicker.core.smart.debugging.engine.DebugEngine @@ -40,4 +41,9 @@ object SmartDebuggingModule { @Singleton internal fun providesDebuggingListener(debugEngine: DebugEngine): SmartProcessingListener = debugEngine + + @Provides + @Singleton + internal fun providesDebugReportTimingListener(debugEngine: DebugEngine): DebugReportTimingListener = + debugEngine } diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepository.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepository.kt index 0357f78de..054f2eb11 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepository.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepository.kt @@ -20,6 +20,7 @@ import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.live.DebugL import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportCounterInitialValue import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportEventOccurrence import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportOverview +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @@ -52,4 +53,7 @@ interface DebuggingRepository { /** Read the last detection session events occurrences. List will be empty no rapport is available. */ fun getLastReportEventsOccurrences(): Flow?> -} \ No newline at end of file + + /** Read aggregate condition timings from the last detection session report. */ + fun getLastReportConditionProfiles(): Flow?> +} diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepositoryImpl.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepositoryImpl.kt index 67dfced5f..bb72abb1c 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepositoryImpl.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/DebuggingRepositoryImpl.kt @@ -24,6 +24,7 @@ import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.live.DebugL import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportCounterInitialValue import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportEventOccurrence import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportOverview +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile import com.buzbuz.smartautoclicker.core.smart.debugging.engine.DebugEngine import kotlinx.coroutines.CoroutineDispatcher @@ -95,4 +96,9 @@ internal class DebuggingRepositoryImpl @Inject constructor( flow { emit(debugReportDataSource.readMessages()) }.flowOn(ioDispatcher) -} \ No newline at end of file + + override fun getLastReportConditionProfiles(): Flow?> = + flow { + emit(debugReportDataSource.readConditionProfile()) + }.flowOn(ioDispatcher) +} diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/ConditionProfile.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/ConditionProfile.kt new file mode 100644 index 000000000..8e3d23f1f --- /dev/null +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/ConditionProfile.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report + +/** Aggregate timing statistics for one condition during a detection session. */ +data class ConditionProfile( + val conditionId: Long, + val checkCount: Long, + val fulfilledCount: Long, + val totalDurationNs: Long, + val minDurationNs: Long, + val maxDurationNs: Long, +) diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/DebugReportOverview.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/DebugReportOverview.kt index 61b637a91..4397487ff 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/DebugReportOverview.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/domain/model/report/DebugReportOverview.kt @@ -27,6 +27,8 @@ import kotlin.time.Duration * @param imageEventFulfilledCount The number of image events that have been triggered during the session. * @param triggerEventFulfilledCount The number of image events that have been triggered during the session. * @param counterNames The names of all counters available in the scenario that was ran to made this report. + * @param activeDetectionDuration Time spent actively processing scenario loops. + * @param executionLimiterWaitDuration Time spent suspended by the user-configured Execution Limiter. */ data class DebugReportOverview( val scenarioId: Long, @@ -36,4 +38,6 @@ data class DebugReportOverview( val imageEventFulfilledCount: Int, val triggerEventFulfilledCount: Int, val counterNames: Set, -) \ No newline at end of file + val activeDetectionDuration: Duration = Duration.ZERO, + val executionLimiterWaitDuration: Duration = Duration.ZERO, +) diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/DebugEngine.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/DebugEngine.kt index 6ebd47e48..cf29f005d 100644 --- a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/DebugEngine.kt +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/DebugEngine.kt @@ -22,6 +22,7 @@ import android.util.Size import com.buzbuz.smartautoclicker.core.base.di.Dispatcher import com.buzbuz.smartautoclicker.core.base.di.HiltCoroutineDispatchers.IO import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition import com.buzbuz.smartautoclicker.core.domain.model.counter.Counter import com.buzbuz.smartautoclicker.core.domain.model.event.Event import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent @@ -29,6 +30,7 @@ import com.buzbuz.smartautoclicker.core.domain.model.event.TriggerEvent import com.buzbuz.smartautoclicker.core.domain.model.scenario.Scenario import com.buzbuz.smartautoclicker.core.processing.domain.EventType import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.processing.domain.model.ProcessedConditionResult import com.buzbuz.smartautoclicker.core.smart.debugging.data.DebugReportLocalDataSource import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.live.DebugLiveEventConditionResult @@ -42,6 +44,9 @@ import com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder.EventOcc import com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder.EventStateRecorder import com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping.toCountersInitProtobuf import com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder.ScreenConditionOccurrenceRecorder +import com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder.ConditionProfileRecorder +import com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder.ProcessingTimingRecorder +import com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping.toProtobuf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -56,6 +61,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.toList import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds /** Engine for the debugging of a scenario processing. */ @@ -68,7 +74,9 @@ internal class DebugEngine @Inject constructor( private val screenConditionOccurrenceRecorder: ScreenConditionOccurrenceRecorder, private val counterValuesRecorder: CounterValuesRecorder, private val eventStateRecorder: EventStateRecorder, -) : SmartProcessingListener { + private val conditionProfileRecorder: ConditionProfileRecorder, + private val processingTimingRecorder: ProcessingTimingRecorder, +) : SmartProcessingListener, DebugReportTimingListener { @OptIn(ExperimentalCoroutinesApi::class) private val coroutineScopeIo: CoroutineScope = @@ -92,13 +100,21 @@ internal class DebugEngine @Inject constructor( counters: List, generateLiveEvents: Boolean, generateReport: Boolean, + conditions: List, ) { + isReportEnabled = generateReport + if (generateReport) { + conditionProfileRecorder.start(conditions.map { it.getValidId() }.toLongArray()) + } else { + conditionProfileRecorder.reset() + } + processingTimingRecorder.reset() + coroutineScopeIo.launch { - isReportEnabled = generateReport shouldGenerateLiveEvents = generateLiveEvents _isDebuggingSession.value = true - if (shouldWriteReport) { + if (generateReport) { overviewRecorder.onSessionStart(scenario) counterValuesRecorder.onSessionStarted(counters) @@ -108,22 +124,36 @@ internal class DebugEngine @Inject constructor( } } + override fun onConditionChecked(conditionId: Long, durationNs: Long, fulfilled: Boolean) { + if (isReportEnabled) { + conditionProfileRecorder.record(conditionId, durationNs, fulfilled) + } + } + + override fun onDetectionLoopProcessed(durationNs: Long) { + if (isReportEnabled) processingTimingRecorder.recordDetectionLoop(durationNs) + } + + override fun onExecutionLimiterWaited(durationNs: Long) { + if (isReportEnabled) processingTimingRecorder.recordExecutionLimiterWait(durationNs) + } + // Processing started on current frame override fun onEventsListProcessingStarted(eventType: EventType) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - overviewRecorder.onFrameProcessingStarted() } } // Processing started for current Event override fun onEventProcessingStarted(event: Event) { + val writeReport = shouldWriteReport coroutineScopeIo.launch { eventOccurrencesRecorder.onEventProcessingStarted() screenConditionOccurrenceRecorder.onEventProcessingStarted() - if (!shouldWriteReport) return@launch + if (!writeReport) return@launch counterValuesRecorder.onEventProcessingStarted() eventStateRecorder.onEventProcessingStarted() } @@ -154,9 +184,8 @@ internal class DebugEngine @Inject constructor( // Processing ended for current Event override fun onEventActionsExecuted(event: Event, results: List) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - overviewRecorder.onActionsExecuted(event) @Suppress("UNCHECKED_CAST") @@ -174,17 +203,15 @@ internal class DebugEngine @Inject constructor( // Processing ended on current frame override fun onEventsProcessingCompleted(eventType: EventType) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - overviewRecorder.onFrameProcessingStopped() } } override fun onEventsProcessingCancelled() { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - overviewRecorder.onFrameProcessingStopped() screenConditionOccurrenceRecorder.reset() eventOccurrencesRecorder.reset() @@ -193,39 +220,48 @@ internal class DebugEngine @Inject constructor( // Image Condition is processed override fun onScreenConditionProcessingStarted() { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - screenConditionOccurrenceRecorder.onImageConditionProcessingStarted() } } // Called anyway,even if not matched override fun onScreenConditionProcessingCompleted(result: ProcessedConditionResult.Screen) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch - screenConditionOccurrenceRecorder.onImageConditionProcessingCompleted(result) } } override fun onCounterValueChanged(counterName: String, previousValue: Double, newValue: Double) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch counterValuesRecorder.onCounterValueChanged(counterName, previousValue, newValue) } } override fun onEventStateChanged(event: Event, newValue: Boolean) { + if (!shouldWriteReport) return coroutineScopeIo.launch { - if (!shouldWriteReport) return@launch eventStateRecorder.onEventStateChanged(event, newValue) } } override fun onSessionEnded() { + val writeReport = isReportEnabled + val conditionProfile = if (writeReport) conditionProfileRecorder.snapshot() else emptyList() + val activeDetectionDurationNs = processingTimingRecorder.activeDetectionDurationNs + val executionLimiterWaitDurationNs = processingTimingRecorder.executionLimiterWaitDurationNs + conditionProfileRecorder.reset() + processingTimingRecorder.reset() + coroutineScopeIo.launch { - if (shouldWriteReport) { + if (conditionProfile.isNotEmpty()) { + debugReportLocalDataSource.writeMessageToReport(conditionProfile.toProtobuf()) + } + + if (writeReport) { debugReportLocalDataSource.stopReportWrite( overview = DebugReportOverview( scenarioId = overviewRecorder.scenarioId, @@ -235,6 +271,8 @@ internal class DebugEngine @Inject constructor( imageEventFulfilledCount = overviewRecorder.imageEventFulfilledCount, triggerEventFulfilledCount = overviewRecorder.triggerEventFulfilledCount, counterNames = counterValuesRecorder.counterNames, + activeDetectionDuration = activeDetectionDurationNs.nanoseconds, + executionLimiterWaitDuration = executionLimiterWaitDurationNs.nanoseconds, ) ) @@ -247,9 +285,9 @@ internal class DebugEngine @Inject constructor( screenConditionOccurrenceRecorder.reset() _lastEventProcessed.value = null _isDebuggingSession.value = false - isReportEnabled = false shouldGenerateLiveEvents = false } + isReportEnabled = false } @Suppress("UNCHECKED_CAST") @@ -343,4 +381,4 @@ private fun ProcessedConditionResult.Screen.getDetectionArea(): Rect? { pos.x + halfSize.width, pos.y + halfSize.height, ) -} \ No newline at end of file +} diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorder.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorder.kt new file mode 100644 index 000000000..5e4ab6583 --- /dev/null +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorder.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder + +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile +import javax.inject.Inject + +/** Fixed-size, allocation-free-on-record aggregate condition profiler. */ +internal class ConditionProfileRecorder @Inject constructor() { + + private var conditionIds = LongArray(0) + private var checkCounts = LongArray(0) + private var fulfilledCounts = LongArray(0) + private var totalDurationsNs = LongArray(0) + private var minDurationsNs = LongArray(0) + private var maxDurationsNs = LongArray(0) + + fun start(conditionIds: LongArray) { + this.conditionIds = conditionIds.distinct().sorted().toLongArray() + checkCounts = LongArray(this.conditionIds.size) + fulfilledCounts = LongArray(this.conditionIds.size) + totalDurationsNs = LongArray(this.conditionIds.size) + minDurationsNs = LongArray(this.conditionIds.size) { Long.MAX_VALUE } + maxDurationsNs = LongArray(this.conditionIds.size) + } + + fun record(conditionId: Long, durationNs: Long, fulfilled: Boolean) { + val index = conditionIds.binarySearch(conditionId) + if (index < 0) return + checkCounts[index] += 1 + if (fulfilled) fulfilledCounts[index] += 1 + totalDurationsNs[index] += durationNs + if (durationNs < minDurationsNs[index]) minDurationsNs[index] = durationNs + if (durationNs > maxDurationsNs[index]) maxDurationsNs[index] = durationNs + } + + fun snapshot(): List = + buildList(conditionIds.size) { + for (index in conditionIds.indices) { + add( + ConditionProfile( + conditionId = conditionIds[index], + checkCount = checkCounts[index], + fulfilledCount = fulfilledCounts[index], + totalDurationNs = totalDurationsNs[index], + minDurationNs = minDurationsNs[index].takeUnless { it == Long.MAX_VALUE } ?: 0L, + maxDurationNs = maxDurationsNs[index], + ) + ) + } + } + + fun reset() { + conditionIds = LongArray(0) + checkCounts = LongArray(0) + fulfilledCounts = LongArray(0) + totalDurationsNs = LongArray(0) + minDurationsNs = LongArray(0) + maxDurationsNs = LongArray(0) + } +} diff --git a/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorder.kt b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorder.kt new file mode 100644 index 000000000..d3dfc5017 --- /dev/null +++ b/core/smart/debugging/src/main/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorder.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder + +import javax.inject.Inject + +/** Fixed-size aggregate for processing-loop measurements included in the Debug Report overview. */ +internal class ProcessingTimingRecorder @Inject constructor() { + + var activeDetectionDurationNs: Long = 0L + private set + var executionLimiterWaitDurationNs: Long = 0L + private set + + fun recordDetectionLoop(durationNs: Long) { + activeDetectionDurationNs += durationNs + } + + fun recordExecutionLimiterWait(durationNs: Long) { + executionLimiterWaitDurationNs += durationNs + } + + fun reset() { + activeDetectionDurationNs = 0L + executionLimiterWaitDurationNs = 0L + } +} diff --git a/core/smart/debugging/src/main/proto/ConditionProfileMessage.proto b/core/smart/debugging/src/main/proto/ConditionProfileMessage.proto new file mode 100644 index 000000000..6c7a6bf84 --- /dev/null +++ b/core/smart/debugging/src/main/proto/ConditionProfileMessage.proto @@ -0,0 +1,19 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +syntax = "proto3"; +option java_multiple_files = true; + +package com.buzbuz.smartautoclicker.core.smart.debugging; + +/** Aggregate condition timings collected during one detection session. */ +message ConditionProfileMessage { + repeated ConditionProfileEntry entries = 1; + + message ConditionProfileEntry { + int64 conditionId = 1; + int64 checkCount = 2; + int64 fulfilledCount = 3; + int64 totalDurationNs = 4; + int64 minDurationNs = 5; + int64 maxDurationNs = 6; + } +} diff --git a/core/smart/debugging/src/main/proto/DebugReportMessage.proto b/core/smart/debugging/src/main/proto/DebugReportMessage.proto index 48bbe8f69..e8ba14ce9 100644 --- a/core/smart/debugging/src/main/proto/DebugReportMessage.proto +++ b/core/smart/debugging/src/main/proto/DebugReportMessage.proto @@ -22,6 +22,7 @@ package com.buzbuz.smartautoclicker.core.smart.debugging; import "CountersInitMessage.proto"; import "ImageEventMessage.proto"; import "TriggerEventMessage.proto"; +import "ConditionProfileMessage.proto"; /** Base class for debug report messages abstraction. */ message DebugReportMessage { @@ -40,5 +41,8 @@ message DebugReportMessage { /** Initial value for counters. */ CountersInitMessage countersInitMessage = 4; + + /** Aggregate per-condition timing profile, written once when the session ends. */ + ConditionProfileMessage conditionProfileMessage = 5; } } diff --git a/core/smart/debugging/src/main/proto/DebugReportOverview.proto b/core/smart/debugging/src/main/proto/DebugReportOverview.proto index 5507b8dd0..e43b6bf5d 100644 --- a/core/smart/debugging/src/main/proto/DebugReportOverview.proto +++ b/core/smart/debugging/src/main/proto/DebugReportOverview.proto @@ -42,4 +42,10 @@ message DebugReportOverview { /** The list of counter name for this scenario. */ repeated string countersName = 7; -} \ No newline at end of file + + /** Time spent actively processing scenario loops, excluding the post-loop Execution Limiter delay. */ + int64 activeDetectionDurationNs = 8; + + /** Elapsed suspension caused specifically by the user-configured Execution Limiter. */ + int64 executionLimiterWaitDurationNs = 9; +} diff --git a/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/PerformanceTimingMappingTests.kt b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/PerformanceTimingMappingTests.kt new file mode 100644 index 000000000..5a4d8fe43 --- /dev/null +++ b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/data/mapping/PerformanceTimingMappingTests.kt @@ -0,0 +1,61 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +package com.buzbuz.smartautoclicker.core.smart.debugging.data.mapping + +import com.buzbuz.smartautoclicker.core.smart.debugging.debugReportOverview +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.ConditionProfile +import com.buzbuz.smartautoclicker.core.smart.debugging.domain.model.report.DebugReportOverview +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds + +class PerformanceTimingMappingTests { + + @Test + fun `condition profile survives protobuf mapping`() { + val expected = listOf( + ConditionProfile( + conditionId = 42L, + checkCount = 10L, + fulfilledCount = 3L, + totalDurationNs = 1_000L, + minDurationNs = 25L, + maxDurationNs = 300L, + ) + ) + + val actual = expected.toProtobuf().conditionProfileMessage.toDomain() + + assertEquals(expected, actual) + } + + @Test + fun `overview performance durations survive protobuf mapping`() { + val expected = DebugReportOverview( + scenarioId = 7L, + duration = 12_000.milliseconds, + frameCount = 50L, + averageFrameProcessingDuration = 4.milliseconds, + imageEventFulfilledCount = 2, + triggerEventFulfilledCount = 1, + counterNames = setOf("counter"), + activeDetectionDuration = 987_654_321.nanoseconds, + executionLimiterWaitDuration = 123_456_789.nanoseconds, + ) + + assertEquals(expected, expected.toProtobuf().toDomain()) + } + + @Test + fun `older overview without performance fields maps them to zero`() { + val oldOverview = debugReportOverview { + scenarioId = 7L + durationMs = 1_000L + } + + val mapped = oldOverview.toDomain() + + assertEquals(0.nanoseconds, mapped.activeDetectionDuration) + assertEquals(0.nanoseconds, mapped.executionLimiterWaitDuration) + } +} diff --git a/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorderTests.kt b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorderTests.kt new file mode 100644 index 000000000..17b634034 --- /dev/null +++ b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ConditionProfileRecorderTests.kt @@ -0,0 +1,56 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +package com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConditionProfileRecorderTests { + + @Test + fun `record aggregates count outcomes and durations`() { + val recorder = ConditionProfileRecorder() + recorder.start(longArrayOf(42L, 7L, 42L)) + + recorder.record(conditionId = 42L, durationNs = 100L, fulfilled = false) + recorder.record(conditionId = 42L, durationNs = 300L, fulfilled = true) + recorder.record(conditionId = 7L, durationNs = 50L, fulfilled = true) + recorder.record(conditionId = 999L, durationNs = 1L, fulfilled = true) + + val profiles = recorder.snapshot() + assertEquals(listOf(7L, 42L), profiles.map { it.conditionId }) + + with(profiles.first { it.conditionId == 42L }) { + assertEquals(2L, checkCount) + assertEquals(1L, fulfilledCount) + assertEquals(400L, totalDurationNs) + assertEquals(100L, minDurationNs) + assertEquals(300L, maxDurationNs) + } + } + + @Test + fun `snapshot includes unchecked conditions with zero durations`() { + val recorder = ConditionProfileRecorder() + recorder.start(longArrayOf(5L)) + + val profile = recorder.snapshot().single() + assertEquals(0L, profile.checkCount) + assertEquals(0L, profile.minDurationNs) + assertEquals(0L, profile.maxDurationNs) + } + + @Test + fun `starting a new session discards the previous session`() { + val recorder = ConditionProfileRecorder() + recorder.start(longArrayOf(1L)) + recorder.record(conditionId = 1L, durationNs = 100L, fulfilled = true) + + recorder.start(longArrayOf(2L)) + recorder.record(conditionId = 2L, durationNs = 25L, fulfilled = false) + + val profile = recorder.snapshot().single() + assertEquals(2L, profile.conditionId) + assertEquals(1L, profile.checkCount) + assertEquals(25L, profile.totalDurationNs) + } +} diff --git a/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorderTests.kt b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorderTests.kt new file mode 100644 index 000000000..200f0b801 --- /dev/null +++ b/core/smart/debugging/src/test/java/com/buzbuz/smartautoclicker/core/smart/debugging/engine/recorder/ProcessingTimingRecorderTests.kt @@ -0,0 +1,25 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +package com.buzbuz.smartautoclicker.core.smart.debugging.engine.recorder + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ProcessingTimingRecorderTests { + + @Test + fun `processing and limiter durations remain separate and reset together`() { + val recorder = ProcessingTimingRecorder() + + recorder.recordDetectionLoop(100L) + recorder.recordDetectionLoop(250L) + recorder.recordExecutionLimiterWait(75L) + + assertEquals(350L, recorder.activeDetectionDurationNs) + assertEquals(75L, recorder.executionLimiterWaitDurationNs) + + recorder.reset() + + assertEquals(0L, recorder.activeDetectionDurationNs) + assertEquals(0L, recorder.executionLimiterWaitDurationNs) + } +} diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/DetectorEngine.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/DetectorEngine.kt index 134096755..bd4563492 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/DetectorEngine.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/DetectorEngine.kt @@ -20,6 +20,7 @@ import android.content.Context import android.content.Intent import android.media.Image import android.media.projection.MediaProjectionManager +import android.os.SystemClock import android.util.Log import com.buzbuz.smartautoclicker.code.smart.detectionmodels.text.OCRModelsRepository @@ -44,6 +45,7 @@ import com.buzbuz.smartautoclicker.core.processing.data.processor.ScenarioProces import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -81,6 +83,7 @@ class DetectorEngine @Inject constructor( private val settingsRepository: SettingsRepository, private val appComponentsProvider: AppComponentsProvider, private val debuggingListener: SmartProcessingListener, + private val debugReportTimingListener: DebugReportTimingListener, private val ocrModelsRepository: OCRModelsRepository, ) { @@ -114,6 +117,10 @@ class DetectorEngine @Inject constructor( /** Scenario currently processed. Null if not detecting. */ private var minProcessingDurationNs: Long = DEFAULT_MIN_PROCESSING_DURATION_NS + /** Report timing receiver for the current detection session, or null when report generation is disabled. */ + private var activeDebugReportTimingListener: DebugReportTimingListener? = null + /** True only for the user-configured rate limit; the unlimited-mode safety delay is not reported as limiter time. */ + private var isExecutionLimiterEnabled: Boolean = false /** * Start the screen detection. @@ -234,6 +241,7 @@ class DetectorEngine @Inject constructor( // Compute minimal processing duration val frameLimit = scenario.computeRate + isExecutionLimiterEnabled = frameLimit > 0.0 minProcessingDurationNs = if (frameLimit <= 0.0) DEFAULT_MIN_PROCESSING_DURATION_NS else (ONE_SECOND_IN_NANO / frameLimit).toLong() @@ -248,8 +256,10 @@ class DetectorEngine @Inject constructor( counters = counters, generateLiveEvents = liveDebugging, generateReport = generateReport, + conditions = screenEvents.flatMap { it.conditions } + triggerEvents.flatMap { it.conditions }, ) } + activeDebugReportTimingListener = debugReportTimingListener.takeIf { generateReport } // Instantiate the processor and initialize its detection state. scenarioProcessor = ScenarioProcessor( @@ -264,7 +274,8 @@ class DetectorEngine @Inject constructor( androidExecutor = actionExecutor, unblockWorkaroundEnabled = settingsRepository.isInputBlockWorkaroundEnabled(), onStopRequested = { stopDetection() }, - progressListener = if (liveDebugging || generateReport) debuggingListener else null, + progressListener = if (liveDebugging || generateReport) debuggingListener else null, + debugReportTimingListener = activeDebugReportTimingListener, ) scenarioProcessor?.onScenarioStart(context) @@ -329,6 +340,7 @@ class DetectorEngine @Inject constructor( scenarioProcessor?.onScenarioEnd() scenarioProcessor = null debuggingListener.onSessionEnded() + activeDebugReportTimingListener = null scalingManager.stopScaling() displayRecorder.resizeDisplay(displayConfigManager.displayConfig.sizePx) @@ -336,6 +348,7 @@ class DetectorEngine @Inject constructor( _state.emit(DetectorState.RECORDING) processingShutdownJob = null minProcessingDurationNs = DEFAULT_MIN_PROCESSING_DURATION_NS + isExecutionLimiterEnabled = false } } @@ -380,13 +393,19 @@ class DetectorEngine @Inject constructor( processingDurationNs = measureNanoTime { scenarioProcessor?.process(screenFrame) } + activeDebugReportTimingListener?.onDetectionLoopProcessed(processingDurationNs) // Avoid looping infinitely to quickly for nothing. if (processingDurationNs < minProcessingDurationNs) { - delay(duration = max( + val limiterDelayMs = max( a = 1, b = (minProcessingDurationNs - processingDurationNs) / ONE_MILLISECOND_IN_NANO, - ).milliseconds) + ) + delayProcessingLoop( + delayMs = limiterDelayMs, + timingListener = activeDebugReportTimingListener, + isExecutionLimiterEnabled = isExecutionLimiterEnabled, + ) } } ?: delay(NO_IMAGE_DELAY_MS.milliseconds) @@ -423,6 +442,27 @@ class DetectorEngine @Inject constructor( } } +/** Wait between processing loops and report only delays caused by the user-configured Execution Limiter. */ +internal suspend fun delayProcessingLoop( + delayMs: Long, + timingListener: DebugReportTimingListener?, + isExecutionLimiterEnabled: Boolean, + elapsedRealtimeNanos: () -> Long = SystemClock::elapsedRealtimeNanos, + delayBlock: suspend (Long) -> Unit = { durationMs -> delay(durationMs.milliseconds) }, +) { + if (timingListener == null || !isExecutionLimiterEnabled) { + delayBlock(delayMs) + return + } + + val startTimestampNs = elapsedRealtimeNanos() + try { + delayBlock(delayMs) + } finally { + timingListener.onExecutionLimiterWaited(elapsedRealtimeNanos() - startTimestampNs) + } +} + private fun OCRModel.getOCRModelPath(): String? = (state as? OCRModelState.Installed)?.path @@ -472,4 +512,4 @@ private const val ONE_MILLISECOND_IN_NANO = 1000000L private const val DEFAULT_MIN_PROCESSING_DURATION_NS = ONE_MILLISECOND_IN_NANO /** Tag for logs. */ -private const val TAG = "DetectorEngine" \ No newline at end of file +private const val TAG = "DetectorEngine" diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ConditionsVerifier.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ConditionsVerifier.kt index 72ba32d0c..276cc8c68 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ConditionsVerifier.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ConditionsVerifier.kt @@ -17,6 +17,7 @@ package com.buzbuz.smartautoclicker.core.processing.data.processor import android.graphics.Bitmap +import android.os.SystemClock import com.buzbuz.smartautoclicker.core.detection.ImageDetector import com.buzbuz.smartautoclicker.core.detection.NumberFormatType as DetectionNumberFormatType @@ -33,6 +34,7 @@ import com.buzbuz.smartautoclicker.core.processing.data.processor.state.Processi import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScreenConditionScalingInfo import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.processing.domain.model.ProcessedConditionResult import kotlinx.coroutines.yield @@ -47,6 +49,7 @@ internal class ConditionsVerifier( private val scalingManager: ScalingManager, private val bitmapSupplier: suspend (String, Int, Int) -> Bitmap?, private val progressListener: SmartProcessingListener? = null, + private val debugReportTimingListener: DebugReportTimingListener? = null, ) { /** List of results for the last call to verifyConditions. */ @@ -64,7 +67,16 @@ internal class ConditionsVerifier( var verificationResult: ProcessedConditionResult for (condition in conditions) { - verificationResult = verifyCondition(condition) + verificationResult = debugReportTimingListener?.let { timingListener -> + val startTimestampNs = SystemClock.elapsedRealtimeNanos() + val result = verifyCondition(condition) + timingListener.onConditionChecked( + conditionId = condition.getValidId(), + durationNs = SystemClock.elapsedRealtimeNanos() - startTimestampNs, + fulfilled = result.isFulfilled, + ) + result + } ?: verifyCondition(condition) verificationResults.addResult(condition.getValidId(), verificationResult) if (operator == OR && verificationResult.isFulfilled) { diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ScenarioProcessor.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ScenarioProcessor.kt index 2db64eaea..05de9c97e 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ScenarioProcessor.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/data/processor/ScenarioProcessor.kt @@ -29,6 +29,7 @@ import com.buzbuz.smartautoclicker.core.processing.data.processor.state.Processi import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.processing.domain.EventType import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import kotlinx.coroutines.yield @@ -56,6 +57,7 @@ internal class ScenarioProcessor( unblockWorkaroundEnabled: Boolean = false, private val onStopRequested: () -> Unit, private val progressListener: SmartProcessingListener?, + private val debugReportTimingListener: DebugReportTimingListener? = null, ) { /** Handle the processing state of the scenario. */ @@ -72,6 +74,7 @@ internal class ScenarioProcessor( scalingManager = scalingManager, bitmapSupplier = bitmapSupplier, progressListener = progressListener, + debugReportTimingListener = debugReportTimingListener, ) /** Execute the detected event actions. */ private val actionExecutor = ActionExecutor( diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/DebugReportTimingListener.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/DebugReportTimingListener.kt new file mode 100644 index 000000000..7efa505f5 --- /dev/null +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/DebugReportTimingListener.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2026 Kevin Buzeau + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +package com.buzbuz.smartautoclicker.core.processing.domain + +/** Receives synchronous performance measurements for the Debug Report. */ +interface DebugReportTimingListener { + + /** Record one completed condition check. Implementations must not allocate per call. */ + fun onConditionChecked(conditionId: Long, durationNs: Long, fulfilled: Boolean) + + /** Record one completed call to the active scenario processing loop. */ + fun onDetectionLoopProcessed(durationNs: Long) + + /** Record elapsed suspension caused specifically by the user-configured Execution Limiter. */ + fun onExecutionLimiterWaited(durationNs: Long) +} diff --git a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingListener.kt b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingListener.kt index aa534fe8a..7e7b8d8dc 100644 --- a/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingListener.kt +++ b/core/smart/processing/src/main/java/com/buzbuz/smartautoclicker/core/processing/domain/SmartProcessingListener.kt @@ -17,6 +17,7 @@ package com.buzbuz.smartautoclicker.core.processing.domain import com.buzbuz.smartautoclicker.core.domain.model.condition.ScreenCondition +import com.buzbuz.smartautoclicker.core.domain.model.condition.Condition import com.buzbuz.smartautoclicker.core.domain.model.counter.Counter import com.buzbuz.smartautoclicker.core.domain.model.event.Event import com.buzbuz.smartautoclicker.core.domain.model.event.ScreenEvent @@ -34,12 +35,14 @@ interface SmartProcessingListener { * @param counters the list of [Counter] to be processed for this scenario. * @param generateLiveEvents tells if the live debugging events should be generated. * @param generateReport tells if the debug report should be generated. + * @param conditions all conditions in this session, used to allocate fixed report storage before processing. */ fun onSessionStarted( scenario: Scenario, counters: List, generateLiveEvents: Boolean, generateReport: Boolean, + conditions: List, ) = Unit @@ -110,4 +113,4 @@ interface SmartProcessingListener { enum class EventType { Screen, Trigger -} \ No newline at end of file +} diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineDetectionOrientationTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineDetectionOrientationTests.kt index d84426575..9475d40a4 100644 --- a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineDetectionOrientationTests.kt +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineDetectionOrientationTests.kt @@ -39,6 +39,7 @@ import com.buzbuz.smartautoclicker.core.processing.data.DetectorEngine import com.buzbuz.smartautoclicker.core.processing.data.DetectorState import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository import io.mockk.MockKAnnotations @@ -107,6 +108,7 @@ class DetectorEngineDetectionOrientationTests { @RelaxedMockK private lateinit var mockSettingsRepository: SettingsRepository @RelaxedMockK private lateinit var mockAppComponentsProvider: AppComponentsProvider @RelaxedMockK private lateinit var mockDebuggingListener: SmartProcessingListener + @RelaxedMockK private lateinit var mockDebugReportTimingListener: DebugReportTimingListener @RelaxedMockK private lateinit var mockOcrModelsRepository: OCRModelsRepository @RelaxedMockK private lateinit var mockImageDetector: ImageDetector @RelaxedMockK private lateinit var mockContext: Context @@ -245,6 +247,7 @@ class DetectorEngineDetectionOrientationTests { settingsRepository = mockSettingsRepository, appComponentsProvider = mockAppComponentsProvider, debuggingListener = mockDebuggingListener, + debugReportTimingListener = mockDebugReportTimingListener, ocrModelsRepository = mockOcrModelsRepository, ) diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineOrientationTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineOrientationTests.kt index db958d41c..9ac79defc 100644 --- a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineOrientationTests.kt +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/DetectorEngineOrientationTests.kt @@ -33,6 +33,7 @@ import com.buzbuz.smartautoclicker.core.display.recorder.DisplayRecorder import com.buzbuz.smartautoclicker.core.processing.data.DetectorEngine import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.settings.domain.SettingsRepository import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -82,6 +83,7 @@ class DetectorEngineOrientationTests { @Mock private lateinit var mockSettingsRepository: SettingsRepository @Mock private lateinit var mockAppComponentsProvider: AppComponentsProvider @Mock private lateinit var mockDebuggingListener: SmartProcessingListener + @Mock private lateinit var mockDebugReportTimingListener: DebugReportTimingListener @Mock private lateinit var mockOcrModelsRepository: OCRModelsRepository private val mockContext: Context = mock(Context::class.java) @@ -161,6 +163,7 @@ class DetectorEngineOrientationTests { settingsRepository = mockSettingsRepository, appComponentsProvider = mockAppComponentsProvider, debuggingListener = mockDebuggingListener, + debugReportTimingListener = mockDebugReportTimingListener, ocrModelsRepository = mockOcrModelsRepository, ) diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ExecutionLimiterTimingTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ExecutionLimiterTimingTests.kt new file mode 100644 index 000000000..7e1cd9f6d --- /dev/null +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ExecutionLimiterTimingTests.kt @@ -0,0 +1,54 @@ +/* Copyright (C) 2026 Kevin Buzeau */ +package com.buzbuz.smartautoclicker.core.processing.tests + +import com.buzbuz.smartautoclicker.core.processing.data.delayProcessingLoop +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions + +class ExecutionLimiterTimingTests { + + @Test + fun `unlimited mode safety delay is not reported as limiter time`() = runTest { + val listener = mock() + var delayedMs = 0L + + delayProcessingLoop( + delayMs = 1L, + timingListener = listener, + isExecutionLimiterEnabled = false, + elapsedRealtimeNanos = { error("Clock must not be read") }, + delayBlock = { delayedMs = it }, + ) + + assertEquals(1L, delayedMs) + verifyNoInteractions(listener) + } + + @Test + fun `limiter records elapsed wait including partial wait on cancellation`() = runTest { + val listener = mock() + val timestamps = ArrayDeque(listOf(100L, 275L)) + + try { + delayProcessingLoop( + delayMs = 10L, + timingListener = listener, + isExecutionLimiterEnabled = true, + elapsedRealtimeNanos = { timestamps.removeFirst() }, + delayBlock = { throw CancellationException("stop") }, + ) + fail("Expected limiter wait to be cancelled") + } catch (_: CancellationException) { + // Expected: the finally block must still retain the partial elapsed wait. + } + + verify(listener).onExecutionLimiterWaited(175L) + } +} diff --git a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt index 30b451cf6..2cba5cbbe 100644 --- a/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt +++ b/core/smart/processing/src/test/java/com/buzbuz/smartautoclicker/core/processing/tests/ScenarioProcessorTests.kt @@ -41,6 +41,7 @@ import com.buzbuz.smartautoclicker.core.processing.data.processor.ScenarioProces import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScreenConditionScalingInfo import com.buzbuz.smartautoclicker.core.processing.data.scaling.ScalingManager import com.buzbuz.smartautoclicker.core.processing.domain.SmartProcessingListener +import com.buzbuz.smartautoclicker.core.processing.domain.DebugReportTimingListener import com.buzbuz.smartautoclicker.core.processing.shadows.ShadowBitmapCreator import com.buzbuz.smartautoclicker.core.processing.utils.ProcessingData.newCondition import com.buzbuz.smartautoclicker.core.processing.utils.ProcessingData.newEvent @@ -64,6 +65,7 @@ import org.mockito.Mockito import org.mockito.Mockito.anyInt import org.mockito.Mockito.mock import org.mockito.Mockito.verify +import org.mockito.Mockito.times import org.mockito.Mockito.verifyNoInteractions import org.mockito.MockitoAnnotations import org.mockito.kotlin.argumentCaptor @@ -121,6 +123,7 @@ class ScenarioProcessorTests { @Mock private lateinit var mockAndroidExecutor: AndroidActionExecutor @Mock private lateinit var mockEndListener: StopRequestListener @Mock private lateinit var mockProgressListener: SmartProcessingListener + @Mock private lateinit var mockDebugReportTimingListener: DebugReportTimingListener @Mock private lateinit var mockScreenBitmap: Bitmap @@ -166,6 +169,7 @@ class ScenarioProcessorTests { private fun createNewScenarioProcessor( events: List, triggerEvent: List, + timingEnabled: Boolean = true, ) : ScenarioProcessor { val processor = ScenarioProcessor( processingTag = "", @@ -179,6 +183,7 @@ class ScenarioProcessorTests { androidExecutor = mockAndroidExecutor, onStopRequested = mockEndListener::onStopRequested, progressListener = mockProgressListener, + debugReportTimingListener = mockDebugReportTimingListener.takeIf { timingEnabled }, ) Mockito.clearInvocations(mockAndroidExecutor) @@ -245,11 +250,11 @@ class ScenarioProcessorTests { actions = listOf(newDefaultClickAction()), ) - scenarioProcessor = createNewScenarioProcessor(listOf(event), emptyList()) + scenarioProcessor = createNewScenarioProcessor(listOf(event), emptyList(), timingEnabled = false) scenarioProcessor.process(mockScreenBitmap) verify(mockImageDetector).setScreenBitmap(mockScreenBitmap, "") - verifyNoInteractions(mockAndroidExecutor, mockEndListener) + verifyNoInteractions(mockDebugReportTimingListener, mockAndroidExecutor, mockEndListener) } @Test @@ -576,6 +581,7 @@ class ScenarioProcessorTests { scenarioProcessor.process(mockScreenBitmap) verify(mockImageDetector).setScreenBitmap(mockScreenBitmap, "") + verify(mockDebugReportTimingListener, times(2)).onConditionChecked(eq(1L), org.mockito.kotlin.any(), org.mockito.kotlin.any()) verifyNoInteractions(mockAndroidExecutor, mockEndListener) } @@ -866,6 +872,7 @@ class ScenarioProcessorTests { scenarioProcessor.process(mockScreenBitmap) verify(mockImageDetector).setScreenBitmap(mockScreenBitmap, "") + verify(mockDebugReportTimingListener, times(1)).onConditionChecked(eq(1L), org.mockito.kotlin.any(), org.mockito.kotlin.any()) assertActionGesture(expectedDuration) verifyNoInteractions(mockEndListener) } @@ -1176,4 +1183,4 @@ class ScenarioProcessorTests { verify(mockImageDetector).setScreenBitmap(mockScreenBitmap, "") verifyNoInteractions(mockAndroidExecutor, mockEndListener) } -} \ No newline at end of file +} diff --git a/docs/debug-report-performance-benchmark.md b/docs/debug-report-performance-benchmark.md new file mode 100644 index 000000000..e49b9721f --- /dev/null +++ b/docs/debug-report-performance-benchmark.md @@ -0,0 +1,94 @@ +# Debug Report condition-timing benchmark + +## Purpose + +The condition-timing prototype was benchmarked before it was converted into the permanent Debug Report architecture. +The goal was to determine whether two monotonic clock reads and one fixed-size aggregate update per reached condition +created a measurable real-world cost. + +This was a device-level comparison rather than a synthetic microbenchmark. It includes the natural variance of screen +capture, image detection, game rendering, Android scheduling, and temperature. The result can therefore show whether +the added measurement stands out during real use; it cannot prove that its cost is exactly zero. + +## Test setup + +- Device: Xiaomi M2012K11I, Android 14, running on battery with its case removed. +- Scenario: a real game automation performing six attack/battle/exit loops before stopping itself. +- Execution Limiter: 10 loops per second for every run. +- Runs: 20 valid runs, five for each mode. +- Total measured detector runtime: 1,106.394 seconds. +- Thermal control: the four modes used a balanced rotation rather than running one mode in a single block. +- Rotation: `C A B D / A D C B / C B A D / B D A C / D B A C`. +- Observations: detector elapsed time, process CPU ticks, battery level, battery temperature, Android thermal status, + report size, and condition-profile consistency. + +The modes were: + +| Mode | Existing Debug Report | Condition timing | +|---|---:|---:| +| A | Off | Off | +| B | On | Off | +| C | On | On | +| D | Off | On | + +Modes A and D isolated the prototype recorder from the existing report machinery. Modes B and C measured its +incremental cost when used as intended inside the Debug Report. The independent combinations existed only for the +prototype experiment; the permanent implementation always enables condition timing with the Debug Report. + +## Results + +| Mode | Median elapsed | Range | Median CPU ticks | Median CPU ticks/s | +|---|---:|---:|---:|---:| +| A: neither | 54.461 s | 52.108–58.338 s | 5,049 | 92.68 | +| B: report only | 53.848 s | 52.728–64.296 s | 6,719 | 122.93 | +| C: report + timing | 53.470 s | 52.912–56.735 s | 6,703 | 125.63 | +| D: timing only | 53.105 s | 52.099–59.489 s | 4,893 | 92.22 | + +The comparisons relevant to condition-timing overhead were: + +- B → C, with the Debug Report already enabled: median elapsed time changed by -0.378 seconds, median raw CPU ticks + changed by -16, and median duration-normalized CPU ticks changed by approximately +2.2%. +- A → D, with the existing report disabled: median elapsed time changed by -1.356 seconds, median raw CPU ticks + changed by -156, and median duration-normalized CPU ticks changed by approximately -0.5%. + +Negative changes are not interpreted as performance improvements. The mixed directions and their size relative to +run-to-run variance mean that the prototype's incremental cost did not stand out in this experiment. By contrast, the +existing Debug Report path produced a clearly visible increase in process CPU ticks, which indicates that the method +was capable of exposing an effect larger than the surrounding noise. + +All 20 runs completed successfully. Battery temperature rose from 41 °C to 45 °C during the early rotation and then +remained at 45 °C. Android reported thermal status 0 throughout, so no run was marked as thermally throttled. Battery +level fell from 67% to 52% across the complete experiment. + +## Data-integrity observations + +The ten timing-enabled runs produced: + +- 604,013 recorded condition checks; +- 348.080 seconds of accumulated condition-processing time; +- 29 configured conditions, of which 21 were reached; +- the expected six successful completions for each scenario milestone condition; and +- no missing or malformed profile output. + +The data also demonstrated the feature's intended value. Four conditions belonging to the same event accounted for +229.524 seconds, or 65.94% of all measured condition-processing time. Two inexpensive conditions from another event +were checked more than 80,000 times each and together accounted for another 10.82%. This shows why both cumulative +time and check count are needed: an individually slow condition can be insignificant when rarely reached, while a +cheap condition can become important through repetition. + +## Interpretation and limits + +The supported conclusion is that the approved aggregate design introduced no **detectable** overhead under this +real-world workload. It is not a claim of mathematically zero cost, nor a general battery benchmark across devices. + +The permanent implementation preserves the properties exercised by the prototype: + +- no timing clock read when Debug Report generation is disabled; +- no per-check allocation, coroutine, lock, or file operation; +- primitive, fixed-size per-condition aggregates; and +- one protobuf snapshot written through the existing serialized report writer when detection ends. + +Raw artifacts were retained locally during development, including per-run metadata, logs, profile CSV files, and the +scenario database used to resolve condition names. They are not committed because they include a debug APK, device +logs, and scenario-specific data; this document records the reproducible test design and aggregate results relevant to +the architecture decision. diff --git a/docs/debug-report-performance-timing.md b/docs/debug-report-performance-timing.md new file mode 100644 index 000000000..c8eef97e7 --- /dev/null +++ b/docs/debug-report-performance-timing.md @@ -0,0 +1,75 @@ +# Debug Report performance timing + +## Purpose + +Performance timing is part of the Debug Report. Its purpose is to help a scenario author find conditions that consume +the most detection time and to show how much time the Execution Limiter deliberately kept detection idle. + +This data is diagnostic. It does not directly measure battery consumption and it does not attempt to explain time +spent inside Android, other applications, or device hardware. + +## Enablement and ownership + +All performance timing is enabled and disabled with Debug Report generation. There is no separate condition profiler +setting or output file. The Debug Report protobuf files are the only stored source of truth. + +The recorder, report models, protobuf schema, and mappings belong to the existing `core:smart:debugging` module. The +processing module contains only the timing boundary and a small synchronous listener because condition evaluation and +the Execution Limiter run there. + +When Debug Report generation is disabled: + +- no condition clock is read; +- no performance aggregate is updated; and +- the processing loop does not measure Execution Limiter suspension time. + +## Condition measurements + +A condition check starts immediately before `verifyCondition` and ends immediately after it returns. The duration +therefore covers all work performed for that condition, including bitmap retrieval and the applicable detector call. + +- A check is counted only when a condition is actually reached and evaluated. +- Conditions skipped by AND/OR short-circuiting are not counted. +- Fulfilled means that the configured condition expression evaluated to true. For a negative screen condition, this + can mean that the image, color, text, or number was not detected. +- Configured but unreached conditions remain in the report with zero checks and zero durations. + +For every configured condition, the report stores its database ID, check count, fulfilled count, total duration, and +minimum and maximum duration. Durations are stored as integer nanoseconds. Nanoseconds are the storage unit and do not +claim nanosecond measurement accuracy. A report reader chooses an appropriate display unit and derives averages and +percentages from the raw values. + +A condition's time share uses the sum of all condition durations as its denominator. It must be described as a share +of condition-processing time, not as a share of CPU, battery, or the whole detection session. + +## Session and Execution Limiter measurements + +The existing Debug Report overview stores whole-session elapsed time. + +Active detection-loop time is the accumulated duration of calls to `ScenarioProcessor.process`. The processing engine +already measures each call to enforce the configured rate, so reporting the total requires only an aggregate update. +It excludes the delay inserted after a loop by the Execution Limiter. + +Execution Limiter wait time measures elapsed suspension at the limiter's delay site. It is recorded only when the +user-configured limiter is enabled. Safety delays used in unlimited mode and delays caused by unavailable screen +frames or scenario actions are not limiter time. A partial limiter suspension interrupted by cancellation is retained. + +Session duration minus active detection-loop time is not treated as Execution Limiter time because that difference +also contains actions and other waits. + +## Lifecycle and compatibility + +Aggregates are allocated and reset when a report session starts, updated synchronously from the single processing +path, snapshotted once when the session ends, and written by the existing serialized Debug Report writer. + +Each session owns a fresh aggregate. Stopping, cancellation, orientation changes, or starting a later session must not +mix values between reports. Timing must never change condition results or prevent detection from stopping normally. + +The condition profile is an optional protobuf message and new overview fields use new field numbers. Readers must +continue to accept older reports, for which performance fields are absent and therefore decode to zero. + +## Deferred work + +This foundation does not include UI, optimization recommendations, fulfilled/unfulfilled timing splits, percentiles, +histograms, CPU attribution, battery estimates, or system-process analysis. Those can be designed later without +changing the meaning of the raw measurements above.