Skip to content

PowerDisplay: Fall back to persisted VCP values when a monitor read fails - #49445

Draft
moooyo wants to merge 43 commits into
mainfrom
yuleng/worktree/pd-ddc-probe-cache
Draft

PowerDisplay: Fall back to persisted VCP values when a monitor read fails#49445
moooyo wants to merge 43 commits into
mainfrom
yuleng/worktree/pd-ddc-probe-cache

Conversation

@moooyo

@moooyo moooyo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary of the Pull Request

On a monitor whose DDC/CI engine answers intermittently, every discovery pass starts from nothing. A panel that reported its brightness a minute ago can lose that control — or drop out of the flyout entirely — because one pass happened to fail.

This persists every range-valid VCP value read off a monitor, keyed by its canonical DevicePath. In Maximum compatibility mode a later discovery falls back to that value when the hardware will not answer.

Scope is intermittent failure, not permanent failure: the cache can only replay a value the hardware answered at least once, so a panel that never reads a code successfully sees no change. This partially addresses #49342.

PR Checklist

Detailed Description of the Pull Request / Additional comments

What is stored

MonitorStateManager implements IKnownGoodVcpStore, so the cache rides in the existing monitor_state.json next to the user's saved brightness rather than in a new file. Each entry is a KnownGoodVcpFeature: code, current, maximum, and when it was last read.

Only range-valid observations are stored, so the common current=0 / max=0 garbage reply never enters — it fails VcpFeatureValue.IsValid.

Writes are not gated on Maximum compatibility mode, only reads are. A monitor that reads cleanly today can start failing after a cable or dock change, and a lazily populated cache would be empty on exactly the first pass that needs it.

How a cached value is used

VcpDiscoveryEvidence.Reconcile gains the cache as a third source alongside the parsed capabilities string and this pass's probe:

this pass cache result
read succeeded live value wins, cache refreshed
replied, range unusable hit cached value applied, MonitorReadFlags left clear
no reply hit cached value applied, MonitorReadFlags left clear
code never probed (caps parsed) hit value applied only after one live read is attempted

The last row matters: on the caps-parsed path nothing has confirmed the cached value this pass, so the hardware is asked first. On the probe path it has already been asked, and re-reading would be pure I2C noise.

MonitorReadFlags stays clear for anything the hardware did not answer, so a cached value never masquerades as an observation — which #49577 depends on, since it made the restore path write whenever the flag is unset. One consequence is worth naming: the flyout draws a slider at the cached position while powerdisplay get reports that setting as unknown, because MonitorDtoProjector gates on supported && read.

Keeping the cache current

RefreshKnownGoodAfterWrite restamps an entry after a successful SetVCPFeature, so a slider move cannot leave the cache holding the pre-write value. It refreshes only an entry a real read established, and only when the value was scaled against the maximum that entry holds — a monitor whose discovery read failed still carries a placeholder max, and writing that back would mis-scale every later write.

RemoveKnownGoodFeatures clears the cache for monitors a settings reconciliation observably dropped, leaving the user's saved values alone. Cleanup is driven by an observed drop, never by absence from the rebuilt list: a missing or corrupt settings.json yields a defaults object indistinguishable from a real one, and pruning by absence would wipe every monitor not connected at that instant.

A re-observation that changes nothing refreshes the in-memory timestamp but does not mark the file dirty, so a discovery pass no longer rewrites monitor_state.json for a moved timestamp alone.

Validation Steps Performed

  • PowerDisplay.Lib, PowerDisplay.Lib.UnitTests and PowerDisplay built for x64 Debug with VS MSBuild — 0 errors, 0 warnings; PowerDisplay.Lib.UnitTests.dll under vstest.console.exe: 301 passed, 0 failed
  • Affected-hardware validation on the AOC Q27G3XMN is still pending. That monitor, or an equivalent controllable DDC/CI setup, was not available locally. The paths this PR changes are reachable only on hardware whose capabilities string is unusable or whose VCP reads fail intermittently, so this is the main outstanding risk.

Yu Leng and others added 9 commits July 21, 2026 17:15
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Offload direct probe reads, aggregate attempt diagnostics, and prefer live initialization before same-code cache fallback.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 6ea38c04-6f68-4c42-91d9-8a03b49bdd81
@moooyo
moooyo marked this pull request as draft July 22, 2026 08:21
Yu Leng and others added 19 commits July 22, 2026 17:34
Preserve legacy monitor state during migration, require known live values before skipping restore writes, and reject invalid physical monitor handles across probe and initialization paths.
Delay state pruning until an ID is absent from consecutive settings snapshots, and retry the HRESULT form of invalid continuous VCP ranges.
Review of the maximum-compatibility discovery rework surfaced several defects
introduced by this branch. Each is fixed at its cause rather than at the symptom.

VcpDiscoveryEvidence.Reconcile used "the parsed caps advertise this code" as a
proxy for "a live read was already attempted for this code". Those coincide only
when the caps string is unusable, which is the only case where the probe runs. On
the caps-parsed path no probe runs, so a cached code the caps string omits was
applied to the monitor, advertised to the UI, and never read from hardware — and
its cache entry could never be refreshed. PreferLiveRead now derives from whether
the probe actually touched the code.

A probe reply carrying an unusable range no longer hides the feature: the device
answered, which proves support (unimplemented codes fail with
DDCCI_VCP_NOT_SUPPORTED), so the code stays reachable as it did before the
rework. The reply is recorded on the observation rather than inferred.

Cache evidence could overwrite VcpCodeInfo entries parsed from the capabilities
string, discarding discrete value lists and custom names. Marking support is now
add-if-absent through a single helper, so evidence can only widen capabilities.

Known-good observations had no invalidation: a monitor that loses DDC/CI support
for a code while keeping its DevicePath would keep advertising it indefinitely.
LastSuccessfulUtc was persisted but never read; it now bounds how long an
observation may stand in for a live read. Successful reads restamp it, so a
feature that still works never expires.

Monitor-state pruning deleted entries by absence from the rebuilt settings list.
A missing or corrupt settings.json makes GetSettingsOrDefault persist and return
an empty monitor list that is indistinguishable from a real one, so every
disconnected monitor's saved brightness, contrast, volume and known-good cache
was deleted. Pruning is now driven by the entries the rebuild observably dropped,
so an unreadable snapshot drops nothing.

The discrete VCP stage no longer discards a monitor. Handle liveness is already
decided by the probe and the continuous stage; repeating the decision after the
continuous values were applied meant one bad 0xD6 read removed a working monitor
from the flyout, in normal mode as well as maximum compatibility mode.

The restore gate compared only the monitor snapshot, so a profile apply issued
while a debounced slider commit was pending was skipped and then overwritten by
that commit. It now also requires the optimistic UI value to agree.

A throwing native read is contained to its own VCP code instead of unwinding into
the pipeline-wide catch that drops every monitor sharing the hMonitor.

Also fixes a mis-indented XML doc block in MonitorStateRetentionPlanner.

Validation: built PowerDisplay.Lib.UnitTests and PowerDisplay for x64 Debug with
VS MSBuild; ran PowerDisplay.Lib.UnitTests.dll with vstest.console.exe — 268
passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… the cache

Second-pass review of the previous fix commit found that the 30-day freshness
bound on known-good VCP observations was worse than the problem it addressed.

The bound assumed every successful read restamps LastSuccessfulUtc, but on the
one path where the cache is load-bearing no read happens at all: once the probe
has touched a code, PreferLiveRead is false and ContinuousVcpInitializer applies
the cached value and returns without reading or upserting. So the clock only
advances on a successful probe -- the very event whose absence makes the cache
necessary. After thirty days of transient probe failures the entry expired, the
reconciled capabilities went null, and DdcCiController dropped the monitor
entirely rather than merely hiding one slider. The failure was absorbing: only a
successful probe could rebuild the entry, and that was what had been failing.
Restamping when the cached value is applied would fix the regression but make the
bound unreachable, since an entry can then only expire while its monitor is
undiscovered, which settings retention already handles. The bound is removed.

That leaves cached observations without invalidation, which was the original
objection. Fixing it properly means letting a definitive DDCCI_VCP_NOT_SUPPORTED
reply outrank the cache, and that contradicts a deliberate, test-pinned decision
in this branch that positive cache evidence survives such a reply in maximum
compatibility mode. It is a design question for review, not a unilateral change,
and is called out in the PR description instead.

Monitor state collection is also narrowed. Removing a whole state entry deleted
the user's saved brightness, contrast, volume, color temperature and capabilities
for any monitor that aged out of settings -- values that survived indefinitely
before this branch introduced the cache. The known-good cache is discovery state
this feature owns, so only that sub-object is collected now; saved user values
are never removed. RemoveMonitorStates becomes RemoveKnownGoodFeatures.

Validation: built PowerDisplay.Lib.UnitTests and PowerDisplay for x64 Debug with
VS MSBuild; ran PowerDisplay.Lib.UnitTests.dll with vstest.console.exe -- 264
passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The branch introduces three tokens check-spelling does not know. DDCCI appears
as an all-caps run in the DDCCI_VCP_NOT_SUPPORTED comments; main only ever had
the PascalCase DdcCi, which tokenizes to ddc + ci, and ddc is already expected.
AOCB and XMN come from the AOC Q27G3XMN device paths and friendly name used by
the new test fixtures.

spelling2.yml does not set only_check_changed_files, so the Check Spelling job
fails on all three until they are expected.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
IsTransient carried eight bare 0xC02625xx literals plus a bare 1460 with no
direct test, so neither its membership nor the reasoning behind the gaps in the
sequence was reviewable. Move it next to IsPhysicalMonitorUnavailable in
DdcErrorClassifier, give every code its winerror.h name, and record why each
neighbouring code stays out: VCP_NOT_SUPPORTED is the device's final answer,
I2C_NOT_SUPPORTED and I2C_DEVICE_DOES_NOT_EXIST are permanent bus-level facts,
MCA_INVALID_CAPABILITIES_STRING belongs to the capabilities path rather than to a
VCP read, the handle-class codes are owned by IsPhysicalMonitorUnavailable and
must abort rather than retry, and MONITOR_RETURNED_INVALID_TIMING_STATUS_BYTE is
raised only by the get-timing-report command, never by
GetVCPFeatureAndVCPFeatureReply.

CURRENT_CURRENT_VALUE_GREATER_THAN_MAXIMUM_VALUE stays in on purpose and now says
why: a device that genuinely reports current > maximum simply exhausts the
budget, but the same code also results from a corrupted reply, which a retry does
fix.

A data-driven test pins both sets and asserts they stay disjoint, since an
overlap would keep retrying against a handle already known to be gone.

No behaviour change: the membership is identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…l write

The cache was only ever written from a successful read, so a slider move left it
holding the pre-write value. Maximum compatibility mode then republished that
stale value on a later discovery whose probe touched the code but could not read
it: PreferLiveRead is false once the probe has touched a code, so
ContinuousVcpInitializer applies the cached value without re-reading. The flyout
does not gate on ReadValues, so the user saw the stale value presented as live,
and a one-notch slider move snapped the panel to it. This needed no restart --
UpdateMonitorList rebuilds every MonitorViewModel on any display-change refresh.

SetVcpFeatureAsync is the single funnel for all six codes and already holds the
device-native value, monitor.Id, the store and the clock, so the refresh goes
there. It only refreshes an entry a real read already established -- a successful
SetVCPFeature is not evidence that the device implements the code -- and only
when the write was scaled against the very maximum that entry holds. That guard
is load-bearing: BrightnessVcpMax and its siblings default to the placeholder
100 and Monitor is rebuilt on every discovery, so a monitor whose read failed
would otherwise overwrite a read-proven 0-50 range with the placeholder and
mis-scale every later write.

The internal constructor also gains the delayAsync seam VcpFeatureProbeService
already had, and FetchCapabilitiesWithFallbackAsync becomes internal, so the
compatibility-mode gate and the probe-observation persist loop are covered for
the first time -- InternalsVisibleTo and the injecting constructor existed but no
test used them. IntPtr.Zero is a safe handle to drive that with:
TryGetCapabilitiesString short-circuits on it without issuing a native call, so
the capabilities string is deterministically unusable and every remaining
decision comes from the injected reader, clock and store.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ocks

MarkDirtyAndScheduleSave had no _disposed guard, unlike SaveStateToDiskAsync and
Dispose. Dispose snapshots _isDirty, sets _disposed, disposes the debouncer and
flushes, so an upsert arriving after that point mutated the state and set
_isDirty again with no path left to clear it. The reachable window is the
synchronous BuildMonitorFromPhysical block, which performs no cancellation checks
and upserts after each successful blocking VCP read. Late observations are now
dropped on purpose: the next discovery pass re-derives them. MigrateLegacyKeys
stops duplicating the schedule inline and routes through the same helper.

The locks this branch added were exercised by nothing. The one concurrency test
upserts two different monitor Ids, which GetOrAdd maps to two distinct
MonitorState instances and therefore two distinct lock objects, so the suite
passed with every lock statement deleted. The contention they actually guard is
one monitor Id: the discovery thread upserting several VCP codes while the
debounced save enumerates that same KnownGoodVcpFeatures dictionary. The new test
drives exactly that from two threads and fails with InvalidOperationException out
of the ToDictionary inside GetKnownGoodFeatures once the locks are removed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three statements did not match the code they describe.

Reconcile claimed the probe "spends its full paced retry budget on every code it
touches". ProbeCodeAsync breaks after a single attempt on a non-transient error
or a throwing read, which this branch's own ProbeAsync_NonTransientFailureDoes
NotRetry asserts. The guarantee that actually holds -- at least one transaction
per code the probe touched -- is what justifies not re-reading it.

DiscreteVcpInitializer claimed handle liveness is already settled before it runs.
A capabilities string that parses but advertises none of 0x10/0x12/0x62 leaves
nothing for the continuous stage to read and suppresses the probe, so the
discrete reads are the first on that handle. The stage is still safe, for a
different reason: failing here only leaves the read flag unset.

MonitorRestorePlanner described an unflagged value as merely cached. It is just
as often the never-read backing-field default, and naming those defaults -- 0
brightness, 50 contrast, 50 volume, 0x05 color temperature -- explains why a
saved value that happens to match one still has to be written.

Two decisions are also recorded where they are made, rather than left for the
next reviewer to re-litigate. Why a handle-class error discards the monitor
outright: Monitor.Handle is captured once per discovery pass and never
refreshed, so a monitor kept here would answer every later read and write
against a handle already known to be dead. And why positive cache evidence is
deliberately permanent while the monitor stays connected: retracting it on a
DDCCI_VCP_NOT_SUPPORTED reply trades a stale control that writes into the void
for a display that disappears entirely, and that reply is not dependable
negative evidence on the hardware this mode exists for.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…known-good store

Physical-monitor handles only reach PhysicalMonitorHandleManager through monitors
that were successfully built: the map is rebuilt from the returned monitor list,
and its cleanup pass only destroys handles that were already in the previous map.
A handle dropped inside the discovery loop therefore never gets destroyed. This
branch added a new way to drop one -- a handle-class error during continuous VCP
initialization now returns PhysicalMonitorUnavailable and discards the monitor,
where main logged it, left the read flag unset and kept the monitor so its handle
entered the map. A panel that intermittently answers that way leaks one kernel
handle per discovery, and a discovery runs on every display-topology change.
ReleaseAbandonedPhysical now covers all three abandon paths, including the two
that already leaked on main.

FetchCapabilitiesWithFallbackAsync also snapshots MaxCompatibilityMode once. The
property is settable from the UI thread on every settings reload and the method
awaits several times; main read it at a single decision point, this branch grew
that to three, so a toggle arriving mid-pass could have one monitor's evidence
gathered under one mode and reconciled under the other.

NullKnownGoodVcpStore and the two `?? NullKnownGoodVcpStore.Instance` fallbacks
are removed. Nothing ever reached them -- MainViewModel always supplies the real
MonitorStateManager and every test uses the injecting constructor -- so they were
dead code whose only effect would have been to silently disable the discovery
cache if a caller ever forgot the argument. The parameter is now required, which
turns that into a compile error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ial-value source

LoadStateFromDisk guards stateFile.Monitors but not the KnownGoodVcpFeatures list
this branch added, even though System.Text.Json writes an explicit JSON null
straight over the member initializer and the array can carry null elements. The
resulting NullReferenceException escapes to the method-level catch, so every
monitor entry after the offending one is dropped -- and the next whole-file
rewrite in BuildStateJson makes that loss permanent. The property is now typed
`List<KnownGoodVcpFeature>?`, matching how the same file already declares
CapabilitiesRaw and reflecting what deserialization can actually produce, and the
loop null-checks both the collection and its elements.

VcpInitialValue.Source is deleted. It was populated at both construction sites in
Reconcile and read by nothing: the record's only consumers -- InitializeFeature
and the probe-outcome log line -- read Value, IsLive and PreferLiveRead only.
KnownGoodVcpFeature.Source is kept; it is read when a successful write refreshes
a cache entry, and it is a useful field diagnostic in monitor_state.json.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Three tests added in the previous review round overclaimed.

RefreshKnownGoodAfterWrite_EmptyMonitorIdIsIgnored did not pin the guard it is
named for: the fake store did its own monitor-Id comparison, so deleting
`string.IsNullOrEmpty(monitor.Id)` from the production path left the test green.
The fake now answers any Id and counts lookups, and the test asserts the store
was never queried -- removing the guard now fails it.

HandleClassFailuresAreNeverTransient asserted, verbatim, two rows that
IsTransient_RejectsEverythingElse already covers, and its comment claimed to test
set disjointness while hardcoding the two handle codes rather than deriving them,
so an overlap introduced by widening IsPhysicalMonitorUnavailable would have
sailed past it. Deleted; the rationale moves into the DataRow comment that owns
those two inputs.

The comment on ConcurrentUpsertAndRead_OnSameMonitorDoNotTearTheFeatureMap spent
most of its lines critiquing a sibling test instead of describing its own, and
would have gone stale the moment that sibling changed. Rewritten to state what
this test drives and why two monitor Ids cannot reproduce it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ce a control rests on

ReleaseAbandonedPhysical missed one exit. When GetPhysicalMonitorsFromHMONITOR
returns more physicals than QueryDisplayConfig has entries for, the loop breaks
without releasing physicals[i..] -- the one abandon path in that loop still
leaking the handles the helper's own remark describes. The break now releases the
remainder before leaving.

Two observability gaps in exactly the mechanism most likely to produce a
confusing bug report -- a control the cache created rather than the hardware:

The probe outcome line reports attempts, status and lastError but not Replied,
even though Replied is the single input that flips a feature to Supported when
the device answers with a range that cannot scale a percentage. In a shipped
build a replied-but-unusable outcome was indistinguishable from three plain
failures. It is now in the message.

Nothing at all was logged when cached evidence widened a monitor's capabilities
past what the capabilities string advertised. The existing probe-outcome block is
gated on `live.Count > 0`, and live is only populated when the caps string is
unusable -- so on the caps-parsed path, which is where the cache silently adds a
code, there was no line to find. Reconcile now reports which codes it supported on
cache evidence alone, and discovery logs them with the observation's age, which
also gives the persisted LastSuccessfulUtc its first reader.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This branch introduced IVcpFeatureReader as the seam for native VCP reads, but left
DdcCiController.TryGetVcpFeature in place. It deleted six of that helper's seven call
sites, so the controller ended up carrying two parallel paths to the same syscall:
TryGetVcpFeature and DdcCiNative.ReadVcpFeature both wrap
GetVCPFeatureAndVCPFeatureReply and both report Marshal.GetLastWin32Error().

Route the one remaining caller, GetVcpFeatureAsync, through the injected reader and
delete the helper. The error value and the log line are unchanged. Alongside removing
the duplication this puts GetBrightnessAsync, GetContrastAsync, GetVolumeAsync,
GetColorTemperatureAsync, GetInputSourceAsync and GetPowerStateAsync behind the same
injectable seam the initializers already use; they had no test coverage before because
there was no way to reach them without hardware.

VcpFeatureProbeService.TransactionInterval also drops from internal to private. It has
no reader outside its own class, in production or in the tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…hree claims

The commentary this branch added had grown into design essays sitting inside method
bodies, and three statements did not survive being checked against the code.

Corrections:

VcpDiscoveryEvidence said a slipped-through false positive lasts "until its settings
entry ages out", which reads as a 30-day bound. RemoveKnownGoodFeatures is driven by
MonitorStateRetentionPlanner.BuildDroppedIds, i.e. the Rebuild input minus its output,
and MonitorSettingsRebuilder re-emits every currently-discovered monitor with a fresh
LastSeenUtc and keeps IsHidden entries unconditionally. Reclamation therefore needs
30 days both undiscovered and unhidden, which a monitor in daily use never reaches.
The comment now says so.

MonitorRestorePlanner enumerated the never-read values as "0 brightness, 50 contrast,
50 volume, 0x05 color temperature". Those are the Monitor backing-field defaults, but
on the DDC path the value it actually sees is MonitorDiscoveryHelper's placeholder of
50 brightness -- the mid-slider value users routinely save -- so the stated cost of a
redundant write pointed at the wrong number.

MonitorStateManager.MarkDirtyAndScheduleSave claimed Dispose has already flushed by the
time a late caller arrives, and that discovery is the only such caller. Dispose sets
_disposed before it flushes, and UpdateMonitorParameter reaches the same path; the
remark now states what actually holds and why it is still harmless.

Trimming: the 24-line block in Reconcile drops to 17 and keeps the traps rather than
the argument -- the full asymmetry is argued in the PR description, not at the call
site. DiscreteVcpInitializer's remark loses the restatement of when its stage runs but
gains what keeping the monitor costs. MonitorViewModel.ShouldRestoreValue and the
retention call site in MainViewModel.Settings stop repeating the callee's own remarks
verbatim and link to them instead.

Also drops the SA1402 suppression from VcpDiscoveryEvidence.cs. StyleCop.json does not
override maintainabilityRules.topLevelTypes, so SA1402 uses its default of ["class"],
and the file declares one class -- VcpInitialValue is a record struct. Verified by
building without it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…or values

Four tests added by this branch hand their subject an input it cannot distinguish from
a test that already exists, and each has a twin asserting a strict superset.

ContinuousVcpInitializer never dereferences evidence.Capabilities or
CacheSupplementedCodes -- InitializeFeature reads only InitialValues, and feature gating
comes from the Monitor -- so Initialize_OmittedCodeFallsBackToCacheWhenLiveReadFails
drives the same ApplyCachedFallback branch as its Advertised twin, which additionally
asserts BrightnessVcpMax. Its VcpNotSupported reader script remains covered by
Initialize_VcpNotSupportedUsesCacheAndContinuesRemainingReads. The sibling
Initialize_OmittedCodeCachedValueUsesFreshLiveValueAndPersists is deliberately kept: it
is the only test that feeds caps-omitted, cache-supplemented evidence through the
initializer and asserts the Monitor is populated, which is the regression e2852c0
fixed.

ProbeCodeAsync consults the error code exactly once, through IsTransient, so
ProbeAsync_CurrentValueGreaterThanMaximumThenSuccessRetriesWithPacing walks the same
path as the InvalidCommand test; its extra Disposition assertion moves to the survivor.
0xC02625D8's membership in the transient set stays pinned by DdcErrorClassifierTests.

Reconcile_MaximumCompatibilityMarksAdvertisedCachedCodeAsLiveFallback differs from the
union test only in whether the caps string advertises the cached code, and the sole
observable effect of that -- CacheSupplementedCodes -- is asserted by
Reconcile_ReportsOnlyTheCodesTheCacheAloneProved, which now also pins PreferLiveRead for
a caps-advertised cached code. Reconcile_NormalModeReportsNoCacheSupplementedCodes built
input byte-identical to Reconcile_NormalModeIgnoresCache; its assertion moves there.

Adding DdcErrorClassifierTests.Constants_MatchWinerrorValues. Every other assertion in
the suite addresses these codes by name, so the numeric values were pinned only
incidentally, by the raw-hex constants the other test files declare as inputs. A typo in
a constant would have moved production and tests together and left the suite green. With
the values now pinned in one place, VcpFeatureProbeServiceTests' local
CurrentValueGreaterThanMaximum constant goes with the test that used it.

GetKnownGoodFeatures_UsesExactDevicePathComparer uses the file's existing Feature()
helper; its inline initializer differed only in a Source it never asserts on. The
round-trip test keeps its explicit initializer, since the fields it spells out are the
ones it verifies.

Validation: built PowerDisplay.Lib.UnitTests and PowerDisplay for x64 Debug with VS
MSBuild -- 0 errors, 0 warnings; ran PowerDisplay.Lib.UnitTests.dll with
vstest.console.exe -- 296 passed, 0 failed (299 - 4 + 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…e redundant handle

Three cleanups to code this branch introduced, all behaviour-preserving.

The continuous VCP set had been triplicated. Base had exactly one list --
DdcCiNative.ProbeableContinuousVcpCodes -- which this branch deleted and replaced with
three byte-identical private copies in VcpDiscoveryEvidence, ContinuousVcpInitializer
and VcpFeatureProbeService. All three iterate it in declaration order, so a code added
to one and not the others is probed but never applied, or applied but never proven. The
set moves to NativeConstants.ContinuousVcpCodes, next to the three codes it is built
from; hosting it on any of the three consumers would have made the other two depend on
a peer for a constant.

Both initializers took an IntPtr handle that is provably Monitor.Handle:
MonitorDiscoveryHelper.cs:133 is the only write to that property in the module and it
stores physicalMonitor.HPhysicalMonitor, which is exactly what BuildMonitorFromPhysical
passed alongside the monitor. Beyond the duplication this was a coherence problem --
ContinuousVcpInitializer justifies discarding a monitor by reasoning about
Monitor.Handle while actually reading from the parameter, and nothing stopped a caller
from passing one that disagreed. Both Initialize signatures lose the parameter and read
Monitor.Handle directly.

The three near-identical KnownGoodVcpFeature initializers fold into
KnownGoodVcpFeature.From, the inverse of the existing ToVcpFeatureValue.
RefreshKnownGoodAfterWrite now names the VcpFeatureValue it was already constructing for
validation and reuses it.

DiscreteVcpInitializerTests' two tests also collapse to one: Initialize never inspects
read.ErrorCode -- it is used once, interpolated into a log string, and the file never
references DdcErrorClassifier -- so VcpNotSupported and the two handle-class codes are
indistinguishable inputs. The DataTestMethod survives because its handle-class rows are
what would fail if someone copied ContinuousVcpInitializer's drop-the-monitor branch
into this stage; the deleted test's two value assertions move into it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…ate the error rationale

Reconcile_IndeterminateLiveUsesCachedPositiveEvidence and
Reconcile_VcpNotSupportedStillUsesCachedPositiveEvidence differ only in the error code
handed to VcpProbeObservation.Indeterminate. Reconcile never reads LastError -- it
consults Disposition, IsSuccess and Replied -- and Disposition routes both 0xC0262589
and 0xC0262584 to Indeterminate, since neither is handle-class. Same cached entry, same
null parsed capabilities, same includeCache, so the two calls are provably identical
inputs. The survivor is the stronger mutation-killer, because a regression where
DDCCI_VCP_NOT_SUPPORTED retracts cached evidence fails it and not the other; it inherits
the deleted test's IsLive assertion and its retry-budget comment.

ProbeAsync_UnansweredCodeIsNotMarkedAsReplied repeated ProbeAsync_NonTransientFailure
DoesNotRetry's arrange and act verbatim; its one assertion moves into that test.

The <remarks> on DdcErrorClassifier.IsTransient and the DataRow comments on
IsTransient_RejectsEverythingElse were written by the same commit and say the same five
things. The production remarks win -- that is where someone editing the predicate looks
-- and the test keeps only the reason its handle-class rows are load-bearing here, which
is specific to the test rather than to the classifier.

Validation: built PowerDisplay.Lib.UnitTests and PowerDisplay for x64 Debug with VS
MSBuild -- 0 errors, 0 warnings; ran PowerDisplay.Lib.UnitTests.dll with
vstest.console.exe -- 293 passed, 0 failed (296 - 2 methods - 1 DataRow case).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…eter

Third cleanup pass. Where the first two hunted duplicated text, this one asks whether
each shape earns its keep.

Reconcile took both a `cached` dictionary and an `includeCache` flag, but the sole
production caller derives both from the same local: DdcCiController passes
`maxCompatibility ? GetKnownGoodFeatures(monitorId) : new Dictionary<...>()` and then
restates the decision as `includeCache: maxCompatibility`. Since `!includeCache` always
implies `cached` is empty, the `includeCache &&` conjunct could never change the result
of the `cached.TryGetValue` beside it -- one decision expressed twice, in two places a
maintainer had to keep in agreement by hand. The parameter goes; the gate stays where it
has to live anyway, because in normal mode the controller must not even call
GetKnownGoodFeatures. That is what DdcCiControllerCompatibilityGateTests.NormalMode_
NeitherProbesNorTouchesTheCache pins, with `store.GetCallCount == 0` -- strictly stronger
than the unit test it replaces, which only checked that Reconcile ignored a cache it had
already been handed.

`using System;` was orphaned in both initializers when round 2 removed their IntPtr
parameter, and `using System.Threading.Tasks;` in MonitorStateManagerTests by the test
deleted below.

Four more tests hand their subject input it cannot distinguish:

- Reconcile_NormalModeIgnoresCache existed only to exercise the removed parameter.
- Reconcile_NoLiveOrCacheLeavesFeatureUnavailable: Reconcile has no branch keyed on
  emptiness, so it kills no mutant the other tests miss.
- Initialize_AdvertisedCachedCodeUsesFreshLiveValueAndPersists differs from its Omitted
  twin only in CachedEvidence's flag, which changes evidence.Capabilities --
  ContinuousVcpInitializer reads only evidence.InitialValues, and Reconcile emits the
  identical entry for both. The Omitted twin is kept, being the discriminating input; its
  one unique assertion moves across.
- ConcurrentUpserts_PreserveBothMonitorEntries raced two different monitor Ids, which the
  PR's own sibling test explains cannot contend: GetOrAdd maps them to two MonitorState
  instances and therefore two disjoint locks. It exercised ConcurrentDictionary, not this
  branch. Its non-concurrent content is already asserted by RemoveKnownGoodFeatures_
  ClearsCacheButKeepsSavedUserValues, and the real contention path by
  ConcurrentUpsertAndRead_OnSameMonitorDoNotTearTheFeatureMap.
- BuildDroppedIds_KeepsEntriesThatSurvivedTheRebuild: BuildDroppedIds has a single code
  path and two other tests already assert an empty result.

Finally the nine test-local DDC error constants and one inline literal now reference
DdcErrorClassifier by name. Round 1 blocked this because those locals were the only
value-level check on the constants; Constants_MatchWinerrorValues, added in the same
round, is now that oracle, so the duplication can go. Codes DdcErrorClassifier does not
declare stay as raw hex.

Validation: built PowerDisplay.Lib.UnitTests and PowerDisplay for x64 Debug with VS
MSBuild -- 0 errors, 0 warnings; ran PowerDisplay.Lib.UnitTests.dll with
vstest.console.exe -- 288 passed, 0 failed (293 - 5).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…e-planner tests

MonitorStateManagerTests

ControllerDerivedCacheKey_MatchesMonitorIdAndSurvivesRetentionRoundTrip arranged a temp
directory, two MonitorStateManager instances and a JsonDocument parse for one property.
The only mutation it uniquely killed is DeriveMonitorId returning info.DevicePath instead
of MonitorIdentity.FromDevicePath(info.DevicePath), which its first two asserts catch on
their own. Everything after them either asserts the absence of a key no writer produces
-- MonitorIdComparer is OrdinalIgnoreCase, so a raw path with its trailing GUID can never
collide with the canonical Id -- or models a RemoveKnownGoodFeatures call production never
makes, since the retention path passes Monitor.Id, already canonical. It becomes
DeriveMonitorId_ReturnsTheCanonicalIdTheStateFileIsKeyedBy, a pure 14-line test. The
save/reload properties it also touched stay pinned by
KnownGoodFeatures_RoundTripPreservesObservation and the removal tests.

RemoveKnownGoodFeatures_EmptySetKeepsCompleteState is deleted. RemoveKnownGoodFeatures has
no branch on the collection being empty -- it is a bare foreach whose body touches only the
state it looked up -- so MonitorA's fate there is produced by the same code path as
MonitorA's fate in RemoveKnownGoodFeatures_ClearsCacheButKeepsSavedUserValues, where it is
likewise never named in the removal list. Its one piece of genuinely unique coverage was
accidental and unrelated to its name: it was the only place the Volume field made a disk
round trip. That moves to the surviving test, whose comment now also records that it stands
for the no-observed-drop case.

Both removal tests dropped their JsonDocument assertions. GetMonitorParameters and
GetKnownGoodFeatures after a reload already prove what the on-disk shape has to be, and
System.Text.Json is no longer needed in the file.

MonitorRestorePlannerTests

Four of the five DataTestMethods varied readFlag across four rows that cannot discriminate.
ShouldWrite is `(ReadValues & readFlag) != readFlag || target != current || target !=
displayed`, and in those four cases a mis-mapped switch arm yields a Monitor default that
still satisfies the clause under test, so every row reaches the same clause with the same
verdict. They collapse to one row each, spread across different flags.

ShouldWrite_MatchingValueWasRead_ReturnsFalse keeps all four rows: it is the only case
where all three clauses are false, so it is the only one whose verdict depends on the
switch reading the right field, and a comment now says so. The class also gains `sealed`,
matching every other test class in the project.

Validation: built PowerDisplay.Lib.UnitTests for x64 Debug with VS MSBuild -- 0 errors,
0 warnings; ran PowerDisplay.Lib.UnitTests.dll with vstest.console.exe -- 275 passed,
0 failed (288 - 1 test method - 12 DataRow cases). No production code changed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…d observation source

Collapses ten duplicated test-double definitions to four: FixedClock existed three
times verbatim, the queue-backed IVcpFeatureReader three times, the IKnownGoodVcpStore
fake three times, and the Cached(...) fixture three times. They now live in DdcFakes,
pulled in with 'using static'.

Also removes the double indirection between DdcCiNative.ReadVcpFeature and
NativeVcpFeatureReader (the former had exactly one caller), drops the SA1649
suppression in VcpDiscoveryEvidence.cs by ordering the file after its namesake type,
and documents why ErrorGraphicsDdcCiVcpNotSupported is deliberately a member of
neither classification set.

VcpObservationSource is deleted outright: no branch anywhere read it and nothing
logged it, so it was write-only state carried through the persisted schema. The field
never shipped, and System.Text.Json skips unmapped members, so an on-disk file written
by an earlier build of this branch still loads.
…ts own PR

MonitorRestorePlanner, its tests and MonitorViewModel.ShouldRestoreValue are removed,
and TryRestore is aligned with the shape #49577 lands on main: an unconditional write,
no comparison at all.

#49577 fixes the same bug — a saved value silently dropped because it happened to match
a discovery placeholder the hardware never answered for — by deleting the skip-if-equal
check outright rather than by teaching it which values are observations. Keeping this
branch's version would re-introduce the more elaborate one on top of it.

Only the known-good retention block remains in MainViewModel.Settings.cs.
moooyo added a commit that referenced this pull request Jul 30, 2026
#49578)

## Summary of the Pull Request

`DdcCiController.DiscoverFromHandleAsync` abandons a physical monitor on
three paths without destroying its handle.

Handles only reach `PhysicalMonitorHandleManager` through monitors that
were successfully built: the map is rebuilt from the returned monitor
list, and its cleanup pass only destroys handles that were in the
*previous* map. A handle dropped on an abandon path therefore never gets
destroyed. A discovery runs on every display-topology change, so a
monitor that keeps failing leaks one more handle per discovery for the
process lifetime — a docking-station user accumulates them.

Extracted from #49445, where the same fix is bundled with
maximum-compatibility-mode work it does not depend on.

## PR Checklist

- [ ] Closes: #xxx — no issue; extracted from #49445
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass — none added; rationale
below
- [x] **Localization:** All end-user-facing strings can be localized —
this PR adds none
- [ ] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places — none added; no
new project, so no signing JSON, installer WXS or CI YML change is
required
- [ ] **Documentation updated**

## Detailed Description of the Pull Request / Additional comments

### The three leaking paths

| path | before this PR |
| --- | --- |
| more physical monitors than `QueryDisplayConfig` entries for the GDI
name | `break` leaves `physicals[i..]` unreleased — the whole tail, not
just the current one |
| capabilities unavailable | `continue` |
| `BuildMonitorFromPhysical` returned null (construction failed, or it
threw and was caught) | no `else` branch at all |

`ReleaseAbandonedPhysical` is null-handle safe and swallows a failing
`DestroyPhysicalMonitor` at warn level: one handle that cannot be
destroyed must not take down the rest of the discovery pass.

### Why there are no tests

Reaching these call sites means faking the whole native enumeration
surface — `EnumDisplayMonitors`, `GetMonitorInfo`,
`GetPhysicalMonitorsFromHMONITOR` — which is a larger seam than a
one-file leak fix should introduce. The paths were verified by reading
instead. Happy to add the seam if a maintainer would rather have it
covered.

### Known remaining leaks, deliberately out of scope

- `GetPhysicalMonitorsWithRetryAsync`'s retry loop discards a whole
array of live handles when it retries after seeing NULL handles.
- Cancellation unwinds `DiscoverMonitorsAsync` before `UpdateHandleMap`
runs, so that pass's handles never enter the map and are never
destroyed.

Both predate this change and are better addressed separately.

## Validation Steps Performed

- built `PowerDisplay.Lib` and `PowerDisplay.Lib.UnitTests` for x64
Debug with VS MSBuild — 0 errors, 0 warnings
- ran `PowerDisplay.Lib.UnitTests.dll` with `vstest.console.exe`: **186
passed, 0 failed** — no new tests; this only confirms nothing regressed
- no hardware validation performed: reaching an abandon path needs a
monitor whose capabilities fetch fails or whose construction throws

Co-authored-by: Yu Leng (from Dev Box) <[email protected]>
moooyo added a commit that referenced this pull request Jul 30, 2026
…tings (#49577)

## Summary of the Pull Request

`TryRestore` skipped writing a saved monitor value when it already
equalled the value `MonitorViewModel` was showing. That displayed value
is only an observation when the discovery-time VCP read succeeded. When
the read failed it is a placeholder:

| setting | value when the read failed | source |
| --- | --- | --- |
| brightness | `50` | `MonitorDiscoveryHelper` stamps it — *"Initial
placeholder; overwritten if the VCP read succeeds"* |
| contrast | `50` | `Monitor` backing-field default |
| volume | `50` | `Monitor` backing-field default |
| color temperature | `0x05` (6500K) | `Monitor` backing-field default |

A saved value that happened to equal one of those silently suppressed
the restore, and the monitor kept whatever it powered on with. `50` is
the mid-slider value and `0x05` is the most common preset, so the
coincidence is not rare.

This drops the comparison: a restore now always writes.

## PR Checklist

- [ ] Closes: #xxx — no issue; found while splitting up #49445
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass — none added; `TryRestore`
is a private helper in the `PowerDisplay` app project, which has no test
project
- [x] **Localization:** All end-user-facing strings can be localized —
this PR adds none
- [ ] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places — none added; no
new project, so no signing JSON, installer WXS or CI YML change is
required
- [ ] **Documentation updated**

## Detailed Description of the Pull Request / Additional comments

### Why remove the check rather than refine it

The skip-if-equal check dates from PowerDisplay's first commit (#42642,
where it read `// Restore brightness if different from current`); #47051
only refactored it into the shared `TryRestore` helper. It is day-one
"obviously we shouldn't write twice" code, not a response to a reported
problem.

Removing it is correct by construction: with no skip branch there is no
state in which a restore silently does nothing. Any narrower fix has to
decide *when* the displayed value can be trusted, and gets that decision
wrong in exactly the cases that are hardest to reproduce.

### Cost

Two, both bounded:

- **A redundant VCP write when the monitor already sits at the saved
value.** Some panels surface a write on their OSD. Both paths that reach
here are user-initiated: startup restore only runs when
`RestoreSettingsOnStartup` is enabled, and a profile apply happens
because the user invoked that profile.
- **Time.** At most four writes per monitor, serialised on that
monitor's I2C bus (~100 ms each). Monitors still run in parallel through
the existing `Task.WhenAll`.

The `isVisible` guard is untouched, so a monitor still never receives a
write for a feature it does not expose — an unsupported VCP `0x14` is
not written just because a profile carries a color temperature. Input
source and power state are not restored here at all.

### If the redundant write turns out to matter

The narrower fix is to keep the comparison and add one clause: also
write when `(monitor.ReadValues & flag) != flag`, i.e. when the compared
value was never read off the hardware. `MonitorReadFlags` already
carries exactly that information, and `Monitor.ReadValues` is already
maintained by the discovery-time `Initialize*` methods, so it is a small
change on top of this one. I went with the simpler version first — happy
to switch if a maintainer would rather keep the optimisation.

## Validation Steps Performed

- built `PowerDisplay` and `PowerDisplay.Lib.UnitTests` for x64 Debug
with VS MSBuild — 0 errors, 0 warnings
- ran `PowerDisplay.Lib.UnitTests.dll` with `vstest.console.exe`: **186
passed, 0 failed** — unchanged from `main`; this PR touches only the app
project and adds no tests
- no hardware validation performed: the placeholder path this PR fixes
is reachable only on a monitor whose VCP read fails during discovery

Co-authored-by: Yu Leng (from Dev Box) <[email protected]>
Yu Leng (from Dev Box) added 2 commits July 30, 2026 15:15
# Conflicts:
#	src/modules/powerdisplay/PowerDisplay.Lib/Drivers/DDC/DdcCiController.cs
…rop two suppressions

VcpFeatureProbeService.cs held six top-level types and suppressed both SA1649 and
SA1402 to do it. Moving NativeVcpFeatureReader out leaves a single class, so SA1402
no longer applies, and ordering the file after its namesake type satisfies SA1649.

The move stands on its own merit: NativeVcpFeatureReader is the production P/Invoke
seam that DdcCiController and both VCP initializers depend on, not a detail of the
probe service it happened to live inside.
moooyo pushed a commit that referenced this pull request Jul 30, 2026
…mments

The retry budget was being spent where it could not change the outcome. Once
GetVCPFeatureAndVCPFeatureReply returns TRUE, support is proven and Replied is
latched -- BuildCapabilitiesFromProbe reads nothing else -- so the remaining
attempts against a degenerate range only cost I2C transactions and 100 ms of
pacing each. A monitor with no speakers that still ACKs 0x62 with max=0 is the
common shape here: it burned three transactions to produce the same capability
set the first one already settled. The probe now returns on the reply.

Every reply therefore returns from inside the attempt loop, which leaves the
`replied` local provably false at the bottom -- the loop can only fall through
when the device never answered. It is gone, and the terminal Indeterminate takes
the default.

Returning early also keeps the earlier attempt's error on the observation
instead of leaving it to be overwritten by a later failure, so a transient fault
followed by a degenerate reply now reports both facts.

Three doc comments described collaborators that do not exist in this PR:
DdcErrorClassifier claimed the initializers agree with the probe on what to
retry, but TryGetVcpFeature is still a single unclassified read;
ContinuousVcpCodes named an evidence reconciler that arrives with #49445; and
RecordingVcpReader promised a hard failure on an extra read that the probe's
catch-all actually swallows. Each now says what the code does.

DdcErrorClassifier also moves to a block-scoped namespace, matching
csharp_style_namespace_declarations in src/.editorconfig and the rest of the
Drivers/DDC folder.
moooyo added a commit that referenced this pull request Jul 30, 2026
)

## Summary of the Pull Request

In Maximum compatibility mode, when a monitor's capabilities string is
missing or unparsable, discovery falls back to probing each continuous
VCP code directly. That probe issues **one**
`GetVCPFeatureAndVCPFeatureReply` per code, back to back, and treats any
failure as final. On a panel whose DDC/CI engine answers intermittently,
a single transient I2C fault permanently drops that control for the
whole discovery pass — and if every code happens to fault, the monitor
disappears from the flyout entirely.

This replaces the probe with `VcpFeatureProbeService`:

- **paced** — 100 ms between transactions, instead of hammering the I2C
bus back to back
- **retried** — up to 3 attempts, but only for failures another attempt
can plausibly get past
- **classified** — `DdcErrorClassifier` decides what "transient" means,
so the retry budget is not burned on a definitive
`DDCCI_VCP_NOT_SUPPORTED` or on a dead physical-monitor handle
- **aborted early** — a handle-class error stops the remaining codes
rather than issuing more requests against a handle already known to be
invalid

Extracted from #49445, which bundles this with a persisted discovery
cache and a discovery restructure it does not depend on. This piece
stands alone and addresses one of the root causes in #49342 by itself.

## PR Checklist

- [ ] Closes: #xxx — partially addresses #49342; the remaining causes
are in #49445
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized —
this PR adds none
- [ ] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places — none added; no
new project, so no signing JSON, installer WXS or CI YML change is
required
- [ ] **Documentation updated**

## Detailed Description of the Pull Request / Additional comments

### What is and is not retried

`DdcErrorClassifier` names the DDC/CI error codes after `winerror.h` and
splits them into two sets. `DdcErrorClassifierTests` pins both the
membership of each set **and** the numeric value of every constant
against `winerror.h`, so a typo cannot move production and tests
together and leave the suite green.

Retried — framing, arbitration and timing faults on the I2C bus:
`I2C_ERROR_TRANSMITTING_DATA`, `I2C_ERROR_RECEIVING_DATA`,
`DDCCI_INVALID_DATA`, `MCA_INTERNAL_ERROR`,
`DDCCI_INVALID_MESSAGE_COMMAND`, `DDCCI_INVALID_MESSAGE_LENGTH`,
`DDCCI_INVALID_MESSAGE_CHECKSUM`,
`DDCCI_CURRENT_CURRENT_VALUE_GREATER_THAN_MAXIMUM_VALUE`,
`ERROR_TIMEOUT`.

Not retried, each for a stated reason recorded on the predicate:
`DDCCI_VCP_NOT_SUPPORTED` is the device's final answer;
`I2C_NOT_SUPPORTED` and `I2C_DEVICE_DOES_NOT_EXIST` are permanent
bus-level facts; `MCA_INVALID_CAPABILITIES_STRING` belongs to the
capabilities path, not to a VCP read; and the two handle-class codes
must abort rather than retry.

### Behaviour preserved

`FetchCapabilitiesWithFallbackAsync` keeps its signature and still
returns `(string, VcpCapabilities?)`, so nothing outside the probe
changes. `BuildCapabilitiesFromProbe` synthesizes the same shape
`DdcCiNative.ProbeSupportedVcpFeatures` used to, and decides membership
the same way: a code counts as supported when the device *replied*, not
when the value was usable. A reply proves the opcode is implemented even
if the reported range cannot scale a percentage — an unimplemented code
fails with `DDCCI_VCP_NOT_SUPPORTED` instead.

The set of probed codes moves from a private array in `DdcCiNative` to
`NativeConstants.ContinuousVcpCodes`, where the follow-up work in #49445
also needs it.

### Cost

The probe only runs in Maximum compatibility mode, and only when the
capabilities string is already unusable — so this adds no I2C traffic to
a monitor that parses normally. For a monitor that does reach it, the
worst case grows from 3 transactions to 9 plus 900 ms of pacing, and it
is bounded: a definitive refusal stops after one attempt, and a
handle-class error stops the whole probe.

### What is deliberately left out

The probe's values are still discarded — `BuildMonitorFromPhysical`
re-reads each code immediately afterwards. Reusing them needs a carrier
for the observed value, which is `VcpDiscoveryEvidence` in #49445.
`VcpFeatureProbeService` already returns everything that needs
(`VcpProbeObservation` carries the value, the attempt count and the last
error); this PR simply does not consume it yet.

## Validation Steps Performed

- built `PowerDisplay.Lib.UnitTests` for x64 Debug with VS MSBuild — 0
errors, 0 warnings
- ran `PowerDisplay.Lib.UnitTests.dll` with `vstest.console.exe`: **223
passed, 0 failed** (186 on `main` + 37 added here)
- `VcpFeatureProbeServiceTests` drives the pacing, the retry budget, the
transient/definitive split, cancellation before and during the
inter-transaction delay, a throwing native read, and that reads run off
the caller's thread — all through an injected reader and an injected
delay, so no hardware is needed
- no hardware validation performed: reaching this path needs a panel
whose capabilities string is unusable **and** whose VCP reads fail
intermittently

---------

Co-authored-by: Yu Leng (from Dev Box) <[email protected]>
Yu Leng (from Dev Box) added 4 commits July 30, 2026 18:11
#49579 landed with three post-review revisions that this branch has to absorb:

- VcpProbeDisposition was replaced by VcpProbeObservation.IsPhysicalMonitorUnavailable;
  VcpDiscoveryEvidence.Reconcile now uses the property.
- ProbeCodeAsync returns on the first reply instead of retrying a degenerate range.
- RecordingVcpReader was folded into VcpFeatureProbeServiceTests as a private class
  because it had a single consumer there. This branch gives it three, so it goes back
  into DdcFakes, carrying the corrected remark from ca3cd3c.

Also drops a duplicate '_probeService = new VcpFeatureProbeService(new
NativeVcpFeatureReader())' that auto-merged into the injecting constructor and would
have silently overridden the injected reader and delay.
… decision

VcpInitializationResult was a two-value enum in its own file whose only branch asked
one question -- the same shape as VcpProbeDisposition, dropped in b20712a. Eight of
its ten references were 'return Completed', meaning nothing to report. Initialize now
returns bool and documents the polarity.

MonitorStateRetentionPlanner was 51 lines plus a 50-line test file wrapping a set
difference with one call site. The rationale comment moves to that call site. No
coverage is lost: MonitorIdComparer has its own tests, and the case-insensitive
matching of the consumer is already pinned by
MonitorStateManagerTests.RemoveKnownGoodFeatures_MatchesIdsCaseInsensitively.

UpsertKnownGoodFeature threw ArgumentOutOfRangeException on an invalid range that no
caller can produce: all three pre-validate, and nothing tested the throw. The guard
that matters is in LoadStateFromDisk, where untrusted values actually enter.

BuildMonitorFromPhysical tested evidence.IsPhysicalMonitorUnavailable alongside the
null capabilities check. Reconcile only ever sets that flag together with null
capabilities, and the single caller already skips both cases with their own log lines,
so the disjunct could never decide anything.
…aximum compatibility mode

DdcCiController gates only the read side on MaxCompatibilityMode, so the unconditional
upsert here reads like an oversight. It is not: a monitor that reads cleanly today can
start failing after a cable or dock change, and a cache populated lazily would be empty
on exactly the first pass that needs it.
#49596 takes the discovery restructure and the probe-value reuse, leaving this branch
to be the persisted known-good cache alone. Conflicts resolved in favour of this branch
throughout: its versions of VcpDiscoveryEvidence, the two initializers and DdcCiController
are supersets of #49596's, adding the cached source, the store and the clock.

The three DDC files also move to a block-scoped namespace, matching #49596 and the rest
of the Drivers/DDC folder, so the delta between the two branches is the cache and nothing
else.
@moooyo moooyo changed the title PowerDisplay: Improve maximum compatibility mode reliability PowerDisplay: Remember VCP values a monitor has already proven Jul 31, 2026
@moooyo
moooyo changed the base branch from main to yuleng/powerdisplay/probe-value-reuse/1 July 31, 2026 01:00
… I2C

The known-good cache keys off the canonical DevicePath, so DiscoverFromHandleAsync
now derives that Id up front. An empty one — a target with no DevicePath — would
reach UpsertKnownGoodFeature, whose empty-key rejection unwinds into the
per-hMonitor catch and abandons every remaining physical on that handle along with
its Monitor. Deciding it at the top of the loop releases the handle and skips one
entry instead, which is what the old CreateMonitorFromPhysical guard did before the
cache moved Id derivation earlier.

DisplayConfigInventory already filters empty DevicePaths, so this guards a path
discovery should not reach. The CreateMonitorFromPhysical guard stays as a contract
check on an internal helper; its comment now says which of the two is load-bearing.

Also corrects the CacheSupplementedCodes log comment: it claimed the caps-parsed
path is the only one where the cache silently adds a code, but a probe that ran and
got no reply proving the code reaches it too, so the two log lines can overlap.
moooyo added a commit that referenced this pull request Jul 31, 2026
…ad (#49596)

## Summary of the Pull Request

In Maximum compatibility mode, when a monitor's capabilities string is
unusable, discovery probes each continuous VCP code directly to find out
which ones the panel implements — and then **throws the values away**.
`BuildMonitorFromPhysical` immediately re-reads every one of those
codes.

That doubles the I2C traffic on exactly the hardware that cannot take
it, and the re-read is the one whose result the user actually sees: a
panel that answered the probe a moment ago but fails the re-read shows
its brightness slider parked at the never-read default instead of where
the panel really is.

This makes the probe's values survive into the build stage.

Extracted from #49445, which bundles it with a persisted discovery cache
it does not depend on.

## PR Checklist

- [ ] Closes: #xxx — partially addresses #49342; the remaining cause is
in #49445
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized —
this PR adds none
- [ ] **Dev docs:** Added/updated
- [x] **New binaries:** Added on the required places — none added; no
new project, so no signing JSON, installer WXS or CI YML change is
required
- [ ] **Documentation updated**

## Detailed Description of the Pull Request / Additional comments

### What the re-read costs

Worth being precise about, because it is not the slider's *existence*:

| decided by | set from | affected by a failed re-read |
| --- | --- | --- |
| slider visible (`MonitorViewModel.ShowBrightness`) |
`Monitor.Capabilities`, via `UpdateMonitorCapabilitiesFromVcp` before
the initializer runs | no |
| slider position (`Monitor.CurrentBrightness`) | the read | yes — stays
at the never-read default |
| `powerdisplay get` reporting a live reading (`Monitor.ReadValues`) |
the read | yes — reported as unknown |
| relative `powerdisplay adjust` (`AdjustCommandExecutor`) |
`Monitor.ReadValues` | yes — no before-value to adjust from |

So the flyout keeps the control either way; what the second read decides
is whether it is pointed anywhere real, and whether the CLI will admit
to a value. Halving the transactions on a bus that is both slow and, on
this hardware, unreliable is the other half of the win.

### The seam

`FetchCapabilitiesWithFallbackAsync` used to return `(string capsString,
VcpCapabilities? caps)` — capabilities only, no values. It now returns a
`VcpDiscoveryEvidence`, which carries the same two things plus the
values the probe already read and a flag for a handle that died
mid-probe.

`VcpDiscoveryEvidence.Reconcile` folds the probe observations into the
parsed capabilities in one place:

| observation | capabilities | value carried |
| --- | --- | --- |
| read succeeded | code marked supported | yes |
| device replied, range unusable (e.g. `max=0`) | code marked supported
| no — the initializer still owes it a read |
| no reply | unchanged | no |
| handle-class failure | everything discarded | — |

The second row is why membership keys off `Replied` rather than the
value being usable: an unimplemented code fails with
`DDCCI_VCP_NOT_SUPPORTED` and never sets the flag, so a reply proves the
opcode exists even when the reported range cannot scale a percentage.
That is the same rule `BuildCapabilitiesFromProbe` used before this PR;
it just moves next to the value handling.

Like that method, `Reconcile` iterates the observations rather than
`NativeConstants.ContinuousVcpCodes`, which `VcpFeatureProbeService`
only takes as the default for its constructor-injected sweep list. That
keeps a widened sweep from silently dropping a code that answered, but
it is not sufficient on its own: the carried value is consumed only for
codes `ContinuousVcpInitializer` walks, so widening the sweep still
needs a matching edit there. The comment and
`Reconcile_ProbedCodeOutsideTheDefaultSweepIsStillHonoured` both say so
rather than claiming the seam alone covers it.

On the normal path nothing changes: the probe only runs when the caps
string is unusable, so `live` is empty and `Reconcile` is a
pass-through. `Reconcile_ParsedCapabilitiesSurviveWhenNoProbeRan` pins
that.

### Continuous-VCP initialization moves out of the controller

`DdcCiController` carried six near-identical `Initialize*` methods. The
three percent-scaled ones become **`ContinuousVcpInitializer`** —
brightness, contrast, volume. It skips any code the evidence already has
a value for, and returns `false` when a read fails with a handle-class
error, because `Monitor.Handle` is captured once per discovery pass and
never refreshed: a monitor kept alive on a dead handle would send every
later read and write into the void.

It reads through the `IVcpFeatureReader` seam introduced in #49579, so
it is testable without hardware.

The three discrete-enum ones — color preset, input source, power mode —
stay in `DdcCiController`, unchanged. The probe sweeps only
`NativeConstants.ContinuousVcpCodes`, so no discrete value is ever
carried across the seam and extracting them would be a refactor this
change does not need; see *What is deliberately left out*.

Only the continuous stage discards the monitor. That is a policy choice,
not a property of the stage: losing a whole display because `0xD6`
answered badly is worse than showing it without a power control.

`DdcCiController.TryGetVcpFeature` therefore still has three discovery
callers plus `GetVcpFeatureAsync`, the runtime refresh path.

### Behaviour change outside Maximum compatibility mode

**A handle-class error during continuous VCP initialization now discards
the monitor.** Before, the failure was logged, the read flag left unset,
and the monitor kept — so its handle reached
`PhysicalMonitorHandleManager` and every later operation went to a
handle already known to be dead. The cost is that the monitor stays out
of the flyout until a rediscovery: `DisplayChangeWatcher` schedules one
for device-arrival/removal and console-display-state notifications, and
the flyout's Refresh button forces one on demand.

Note this check is not on every path. A caps string that parses but
advertises none of `0x10`/`0x12`/`0x62` leaves
`ContinuousVcpInitializer` nothing to read and suppresses the probe, so
such a monitor is still published with a handle no VCP read has
exercised — and its first VCP read then happens in the discrete stage,
which never discards.

### What is deliberately left out

**Extracting the discrete-VCP initialization.** The probe sweeps only
the continuous codes, so no discrete value is ever reused and moving
`0x14`/`0x60`/`0xD6` out of the controller would be a drive-by refactor
with no bearing on this change. It is worth doing on its own, where the
added test coverage can be reviewed for what it is.

**Remembering a probe value across discoveries.** A probe value is only
useful for the pass that produced it. Carrying one forward — so a later
failing pass can still show the control — is the persisted known-good
cache in #49445, a much larger change with an open design question
attached. This PR is complete without it.

## Validation Steps Performed

- built `PowerDisplay.Lib.UnitTests` and `PowerDisplay` for x64 Debug
with VS MSBuild — 0 errors, 0 warnings
- ran `PowerDisplay.Lib.UnitTests.dll` with `vstest.console.exe`: **240
passed, 0 failed** — 16 of those cases are added here (8 in
`ContinuousVcpInitializerTests`, 7 in `VcpDiscoveryEvidenceTests`, 1 in
`DdcErrorClassifierTests`)
- `VcpDiscoveryEvidenceTests` pins each row of the table above, plus
that a probed code outside the default sweep is still honoured
- `ContinuousVcpInitializerTests` pins that a probed code is never
re-read (the reader is primed with a failure it must not reach), that a
handle-class error stops the remaining codes, and that a feature-level
refusal does not. `Initialize_EveryContinuousCodeIsReadAndApplied` walks
the whole `ContinuousVcpCodes` array with a distinct range and
percentage per feature, so a code added to that array without an arm in
both `IsSupported` and `ApplyValue` fails rather than being silently
skipped or silently discarded — checked by mutation: removing either
volume switch arm fails that test and
`Initialize_ProbedVolumeIsAppliedWithoutReadingAgain`
- not covered by tests: the `DdcCiController` side of the contract —
that `evidence.IsPhysicalMonitorUnavailable` skips the monitor and
releases the physical, and that a `false` from
`ContinuousVcpInitializer` does the same. That layer takes no injectable
dependencies today
- no hardware validation performed: this path is reachable only on a
panel whose capabilities string is unusable, which needs an incomplete
or unreliable DDC/CI implementation

---------

Co-authored-by: Yu Leng (from Dev Box) <[email protected]>
Base automatically changed from yuleng/powerdisplay/probe-value-reuse/1 to main July 31, 2026 07:48
Yu Leng (from Dev Box) added 3 commits July 31, 2026 16:49
#49596 landed on main as a squash commit, so this branch's copy of that
work is now a duplicate of an older revision of it. Resolved by taking
main's final revision wherever the two disagree and replaying only the
known-good VCP cache on top:

- VcpDiscoveryEvidence.Reconcile keeps main's probe-driven iteration
  instead of sweeping NativeConstants.ContinuousVcpCodes, so a widened
  probe list is still honoured, and walks the cached codes the probe did
  not touch as well. Main's class and member documentation is restored.
- DdcCiController keeps main's inline discrete-VCP initialization;
  DiscreteVcpInitializer and its test are dropped, since #49596 folded
  that work back into the controller and nothing references them now.
- ContinuousVcpInitializerTests keeps main's per-code coverage invariant
  and the volume path, adapted to the initializer's store and clock.
…ous codes

Review follow-ups on the known-good VCP cache.

MonitorStateManager.UpsertKnownGoodFeature no longer marks the state file
dirty when neither Current nor Maximum moved. Every discovery pass re-reads
all three continuous codes, so re-observing an unchanged value was the common
case, and each one rewrote the whole file and reset the save debouncer —
cancelling the pending Task.Delay with an OperationCanceledException — for a
timestamp only the support log reads. The entry is still replaced in memory so
LastSuccessfulUtc stays accurate; the comment records that this holds only
while the timestamp is diagnostic, and has to go with any reintroduced
freshness bound.

The probe-success upsert loop in FetchCapabilitiesWithFallbackAsync is bounded
to ContinuousVcpCodes. VcpFeatureProbeService takes its sweep list through the
constructor, so widening it without a matching arm in ContinuousVcpInitializer
would have persisted a code nothing ever applies while Reconcile kept marking
it supported. DdcCiController's internal constructor now forwards that sweep
list so the bound is testable. The same loop gained a comment explaining why a
reading taken before the handle died is still persisted even though Reconcile
discards the pass: the probe stops at the first handle-class failure, so the
value is proven even when the pass is not.

Reconcile and MarkSupported now document that a non-null parsedCapabilities is
extended in place and returned as the same instance — reference identity is
already asserted by Reconcile_ParsedCapabilitiesSurviveWhenNoProbeRan, and a
deep copy would silently lose metadata a later field addition forgot to carry.

RemoveKnownGoodFeatures records the retention blind spot: a monitor whose
handle dies mid-discovery seeds an entry but never reaches settings.json, so
it is never named for collection.

Restored the ContinuousVcpInitializer class summary dropped in the merge with
PR #49596, undid two signature reflows that changed no parameters, and dropped
a fully-qualified System.Array.Empty in favour of a using.

Tests: three pin the save short-circuit (unchanged value keeps the in-memory
timestamp, does not recreate a deleted state file, and the changed-value
control does); two cover the upsert loop (a reading that preceded a dead
handle is persisted, a widened sweep does not reach the cache). Restored the
return-value assertion on Initialize_InvalidRangeDoesNotApplyOrPersist, took
the concurrency test from 100 rounds to 20, and made the temp-directory
cleanup tolerant of a save still in flight.
@moooyo moooyo changed the title PowerDisplay: Remember VCP values a monitor has already proven PowerDisplay: Fall back to persisted VCP values when a monitor read fails Jul 31, 2026
Per maintainer decision, the persist loop in FetchCapabilitiesWithFallbackAsync
no longer filters probe successes by ContinuousVcpCodes, and DdcCiController's
internal constructor no longer forwards a probe sweep list. The bound only
mattered if someone widened VcpFeatureProbeService's sweep without adding a
matching arm to ContinuousVcpInitializer, and the injection point existed
solely to make that bound testable; both are removed rather than carried.
VcpFeatureProbeService keeps its own codes parameter, which is unchanged.

MaxCompatibility_OnlyContinuousCodesReachTheCache goes with them. The comment
explaining why a reading taken before the handle died is still persisted stays,
along with MaxCompatibility_ReadingsTakenBeforeTheHandleDiedAreStillPersisted.

Also reworded "interleavings" in the concurrency test comment, which is not in
the spell-check dictionary or expect.txt and would fail CI once this PR leaves
draft.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant