Skip to content

Board 8D87: keyboard lighting end to end, plus the AMD SMU mailbox and iGPU Curve Optimizer corrections found beside it - #176

Open
tempestnano wants to merge 20 commits into
theantipopau:mainfrom
tempestnano:fix/per-key-brightness-colour-map
Open

Board 8D87: keyboard lighting end to end, plus the AMD SMU mailbox and iGPU Curve Optimizer corrections found beside it#176
tempestnano wants to merge 20 commits into
theantipopau:mainfrom
tempestnano:fix/per-key-brightness-colour-map

Conversation

@tempestnano

@tempestnano tempestnano commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Everything here was found on one machine — OMEN MAX 16-ak0098nr, board 8D87, BIOS F.07, EC 40.38, Ryzen AI 9 HX 375 — and every claim below is scoped to it unless it cites upstream source. Two tracks landed together because the second was found while verifying the first: the AMD tuning page was open beside the lighting page throughout, and its slider turned out to be mislabelled and its mailbox misaddressed on this silicon.

  • Keyboard lighting — the per-key picture, brightness, colour pickers, the Fn+1/Fn+2 cycle, and the two owners that were taking the picture away.
  • AMD SMU — a mailbox address that was wrong for Strix Halo, an iGPU Curve Optimizer gate that shipped a slider the silicon refuses, and the label above it.

Part 1 — Keyboard lighting

The per-key picture is the MCU's colour map, not the LampArray

This keyboard has two lighting interfaces, and which one carries the picture decides almost everything else:

what it is brightness? who redraws it
mi_04 HID LampArray, addressed by lamp id yes — a per-lamp intensity byte the host, every frame
mi_03 the MCU's own 176-entry static colour map no — raw RGB, drawn as handed over the MCU, autonomously

Per-key painting goes to mi_03, so the picture survives the Fn overlay — the MCU redraws its base layer from state it owns, which a LampArray frame is never part of. mi_04 is reached only as the fallback for a board whose lamp ids cannot be resolved to colour-map positions.

The runtime log shows the split exactly:

[KeyboardMap] Apply: 176 LEDs ledsOk=True, 0 keys keysOk=True, bar=True barOk=True

176 LEDs through the colour map; zero lamps through the other interface.

Brightness: a master over the picture, and a level per cell

The slider in question

The Brightness slider did nothing on this board. DojoPerKeyBackend stored its value in _brightness and applied it only where an intensity byte exists, which is mi_04 — so on every board that has a layout, _brightness was written and never read.

Nothing surfaced that. The slider moved, the view-model held the value, the backend held the value, and the write returned True. There is no colour readback on either interface, so "accepted" was the entire software claim — and it was accurate. Only the keyboard disagreed.

Brightness is now host-side arithmetic in DojoPerKeyBackend.WriteColorMap, the single point every colour map passes through on its way to mi_03, scaled linearly and rounded to nearest exactly as mi_04's intensity channel scales a lamp. It is two controls, because one slider was doing two unrelated jobs — it scaled the whole picture and drove the light bar, so "dim these four keys" had no expression and dimming the keys dimmed the bar with them:

  • The master is the backend's, applied on the way to the wire.
  • The per-cell level is applied in the view-model, as a separate field rather than a rewrite of the picked colour. Baking it into the hex is lossy and one-way — drag a key to 10% and back to 100% and the colour is gone, quantised to whatever survived. A test fails if someone simplifies it that way later.
  • Light bar zones take a level too. HP's frame carries one brightness byte for all four zones, so per-zone dimming cannot go to the firmware and is the same host-side arithmetic. The master still rides the byte.

Three things that had to be right alongside it:

  • The retained map is unscaled. SetBrightnessAsync re-sends the same picture at a new level; keeping the scaled bytes would compound every change on the last dimmed copy.
  • _mcuShowsHostMap tracks whether the map is what the keyboard is drawing. A brightness change repaints a static picture, but must never freeze a running device animation into a still frame, never un-blank a keyboard whose backlight is off (the colour write carries 0x09/0x01, which would turn it back on), and never fight host lamp ownership.
  • The caps preview their own level but not the master — at master 3 the whole editor goes near-black and you cannot see what you are painting. The preview's rounding is pinned to the backend's by a test: with no colour readback, a disagreement between what the cap shows and what the key does has nothing to settle it.

A device-rendered effect cannot be dimmed on this board

That is a hardware limit, and it now rests on measurement rather than on inference. The claim used to rest on [4] of the effect record reading back unchanged through every frame ever sent — but every one of those frames carried 0, so a firmware treating 0 as "leave alone" would have looked identical to one that ignores the field. --effect-brightness was added to LightingProbe and measured:

sent read back rest of the frame
0 (historic) 200 consumed
60 200 consumed — effect → Wave, colour → #FF0000
160 200 consumed — colour → #00FF00

160 is a value this firmware has itself been observed holding, not an out-of-range guess. Both frames were demonstrably acted on — effect, colour, speed and direction all took — and brightness alone was ignored at zero and non-zero alike. So it is not a zero-sentinel.

MCU command 0x0C is still sent and still expected to fail here: it is not acknowledged at any payload value (0, 1, 2, 3, 50, 100) while 0x03, 0x83, 0x09, 0x0A and 0x10 are all acknowledged through the same handle and the same frame builder. Five accepted and one refused across six payloads is the command being rejected, not a bad value or a broken transport. It costs one frame and is the right lever on a board that implements it, so it stays.

Two silent drops made loud

KeyboardLightingServiceV2.SetPerKeyBrightness returned false for any non-DojoPerKeyBackend backend and said nothing; it now logs which backend declined, and KeyboardMapViewModel surfaces that in the Apply status line rather than leaving a user to infer a dead slider from a keyboard that did not dim.

Colour pickers on the two cards that never had one

The device-effects card and the light bar card took custom colours as hex text and nothing else, so choosing one meant already knowing its hex. Both now use the same ColorPickerDialog the per-key editor has always had, with the same gesture — the swatch is the picker button — reused rather than reimplemented, because two colour-picking implementations in one app is how they end up disagreeing about what a hex string means.

The swatches render on every keystroke, since the hex boxes bind with UpdateSourceTrigger=PropertyChanged. Typing #FF8800 walks through #, #F, #FF… and a converter that throws on those turns ordinary typing into a stream of binding failures; they fall back to black instead. One intermediate state is not what it looks like: WPF's ColorConverter reads four-character hex as #ARGB shorthand, so #FF88 is a real colour rather than a parse failure, and the swatch briefly shows it on the way to #FF8800. Found by asserting the obvious guess and watching it fail.

The Fn keys were already a profile switcher

Polling the MCU's 0x83 state read in-process at 10 Hz while the keys were pressed, with HP's OmenCommandCenterBackground stopped:

  • Fn+1 and Fn+2 are one control — next and previous through a single list. Fn+3 through Fn+0 do nothing to lighting.
  • The list holds what a host wrote to it. Two entries in the observed cycle were this project's own probe writes, and an ordinary 0x03 effect frame was enough to install one.
  • One slot per effect type, holding that effect's last parameters. Wave was written red then green in one session and only green appeared — not an append-only history.
  • 0x0A StoreLightingToFlash neither creates nor reorders slots.

A new "Fn+1 / Fn+2 cycle" card stages effects, marks one as Showing, and writes them. Profiles written this way are recalled by the keyboard itself — with OmenCore closed, uninstalled, or on a machine where it was never installed. Two firmware rules live in FnCyclePlan where they can be tested rather than in the view model: duplicate effect types collapse to the later one (the firmware would overwrite anyway, and the survivor keeps the earlier position so removing a duplicate does not silently reorder a cycle someone has learned), and the Showing profile is written last, because the keyboard displays whatever arrived most recently and no command says which one to show.

The card says out loud what cannot be discovered by trying: writing adds to or updates what the keyboard holds, nothing in the mapped command surface removes or reorders a slot, and 0x83 reads only the effect currently showing — so a profile taken off the staged list stays on the keyboard with no readback that could reveal it. Swipe staged with a theme is flagged before any frame goes out, since it has no preset palette on this firmware and renders black, which inside a cycle is discovered days later by pressing Fn+1 into a dead keyboard.

One correction worth recording. The 0x83 poll tears while any key is held — around 17% of samples come back with a corrupt leading byte, giving an effect id of 236, a colour count of 36, or a speed of 8, none of which those fields can hold. That is not the Fn write: it happens just as much while pressing Fn+3..Fn+0, which change nothing, and the same poll runs at 0% torn over 400+ samples at rest. It is ordinary HID input traffic colliding with the feature read. Filter on field plausibility and the signal is clean; print the torn frames as state and you will invent a finding.

Windows repaints the keyboard the moment we let go of it

Reported as "the keyboard colors don't seem to stick if I close the program". Nothing about the apply failed — the colours applied, held while the app was open, and were gone at the next launch.

It is not OmenCore erasing its own work. The whole picture goes through mi_03, the MCU holds that map and redraws it autonomously, and Dispose's comment says exactly this and is correct. Windows Dynamic Lighting is the second owner, and it wins whenever no application holds the device. While OmenCore holds host control it is incidentally locking Windows out; releasing on the way down ends that, and Dynamic Lighting repaints within one refresh interval — 33 ms on this board, which the device itself reports as its minimum update.

Measured the only way this hardware allows, since there is no colour readback anywhere: with AmbientLightingEnabled = 1 the picture reverted on exit; with Dynamic Lighting off and nothing else changed, the same picture held. One registry toggle, two outcomes, no rebuild and no reboot between them.

HidLampArray's class comment already named this hazard almost word for word, down to the 33 ms — "Arbitrating with Dynamic Lighting is a policy decision, not a transport one." The hazard was understood and written down, and then no policy was ever decided. This is that decision:

DynamicLightingState reads the master toggle and the per-device card and reports whether Windows will take the device back. Read-only on purpose — these are another feature's settings and the user owns them, so the editor explains and offers to open the Settings page rather than quietly switching off a Microsoft feature to make ours look better. The per-device card is treated as a veto rather than as something that tracks the master, because it was only ever observed in one state and inferring the other would be guessing at semantics nobody measured.

Registering as a Dynamic Lighting-aware app was costed and rejected for this symptom, recorded here so it is not proposed again as an oversight. Ambient control requires MSIX package identity — enforced by the AmbientLightingServer, which only accepts clients that have it — and, decisively, control of either kind ends when the process ends. Neither foreground nor ambient registration produces a picture that holds with OmenCore closed.

Save to keyboard, and a picture written down

With Windows no longer taking the device, the keyboard draws its own colour map — and nothing made that map durable or wrote the picture down anywhere.

Autonomous mode is the contract rather than a workaround. Microsoft describes a device Windows is not driving as one that "operates in Autonomous mode … the hardware falls back to default behavior as defined by its firmware". On this keyboard that firmware default is the mi_03 colour map, so storing the map to flash is how an application participates correctly.

StoreToFlash existed, was documented, and was reachable through the service — and only the Fn-cycle write ever called it, which persists the effect record. No per-key apply flashed anything, so a painted picture was volatile MCU state and a power cycle took it.

That the colour map is flashable at all is not an assumption: HP's own client does it on every per-key apply — DojoKbLightingModel.SetKeyColor passes isWriteRegistry: true unconditionally, so 0x0A follows every colour round, and that is what makes a picture set in OGH survive a reboot. We deliberately do not copy that literally, because 0x0A writes MCU flash and "every apply" in an editor with a brightness slider is a flash write per slider drag. One button, one write, one intent — and it applies before flashing, since flashing whatever happens to be installed would persist the previous picture and report success.

config.json keeps a second copy, as painted — colour and per-cell level stored apart rather than flattened, for the same lossiness reason as above — restored at startup, where it takes precedence over the four-zone fill because the two describe the same keyboard at different resolutions and the fill would erase a picture completely. Restoring the editor and repainting the hardware are kept separate: the constructor restores cells and sends nothing, because the keyboard may already be showing this picture from its own flash, and painting on construction would overwrite a device effect the user chose since.


Part 2 — AMD SMU corrections

Strix Halo was given Phoenix's mailbox

ConfigureSmuAddresses grouped StrixHalo with VanGogh, Rembrandt, Phoenix, Mendocino and HawkPoint on MP1 address set 2 — message 0x3B10528, response 0x3B10578. It belongs on set 3 with Strix Point: 0x3B10928 / 0x3B10978.

RyzenAdj's nb_smu_ops.c selects set 3 for FAM_KRACKANPOINT, FAM_STRIXPOINT and FAM_STRIXHALO in one case group. UXTU reaches the same split independently and from the other direction: Socket_FT6_FP7_FP8 shares a single message table across the socket group but branches the mailbox addresses for exactly those parts. Two implementations, no coordination, same answer — and the value they agree on is the one already in use for Strix Point.

While Strix Halo sat in the set-2 arm, every MP1 message sent on a Ryzen AI MAX landed on registers that part does not read. Nothing caught it, because a write to an address the SMU ignores does not report an error — it reports nothing, and the readback path then compared one unrelated value against another.

The doc comment claimed the addresses were cross-checked against nb_smu_ops.h. They are defined in nb_smu_ops.c; the header carries no per-family selection at all, so that cross-check could not have found this.

The iGPU Curve Optimizer gate shipped a slider the silicon refuses

SetIgpuCO's PSMU 0xB7 arm listed Strix Point, Strix Halo and Mendocino alongside the four families upstream actually maps. RyzenAdj's set_cogfx (lib/api.c, v0.19.0) has Cezanne/Renoir/Lucienne on MP1 0x64 and Rembrandt/Phoenix/HawkPoint/VanGogh on PSMU 0xB7, and no others. Strix Halo is the pointed one: it has a case in set_cogfx carrying only the comment "0xB7 is rejected on this architecture" before falling through to default — so of the four names SupportsIgpuUndervolt let through, the one that resolved to a real arm resolved to the message upstream measured as refused. 7945H and 7845H are Dragon Range and reach no arm at all. Only 6900H was ever aimed at a supported id.

None of that surfaced because _lastIgpuCO was assigned regardless of the mailbox's answer, so a refusal read back as the requested offset. Families with no confirmed id now return UnknownCmd rather than Failed, and the warning tells "no message exists for this family" apart from "the SMU refused the message we sent".

Strix Point stays shut, and now for a measured reason rather than an inherited table. RyzenAdj gives it no set_cogfx case; UXTU's socket table would send 0xB7 anyway. SmuProbe --igpu asks the part, on a Ryzen AI 9 HX 375:

PSMU 0xB7 -> CmdRejectedPrereq        MP1 0xB7 -> UnknownCmd

identically for CO −20 and CO 0, so the refusal is about machine state and not the argument. The two statuses differing is the finding: UnknownCmd would mean the message does not exist, CmdRejectedPrereq means the PSMU knows it and declined. It also corroborates the mailbox above — a wrong address gives timeouts, not a clean "no such command".

--prereq chases the precondition. It is OC mode, and OC mode is itself refused: enable-oc returns CmdRejectedPrereq — the same status as the thing it would unlock — while disable-oc returns Ok. Refusing to enable and permitting to disable is what a locked-off capability looks like. Run as enable/retry/disable/retry so a coincidence would have shown itself.

A refusal status is still not an outcome, so the offset was applied under load and measured anyway. That required changing the metric. Raw clock and raw voltage are each useless alone: they drift with the operating point and they drift together, so a phase running warmer shows higher clock and higher voltage — the V/F curve being traversed, not shifted. The first run returned a raw clock mean of +2.0%, above threshold and a positive if clock were the measure, with voltage up +1.4% beside it. An undervolt does not raise voltage. A Curve Optimizer undervolt shifts the curve — less voltage for the same clock — so the verdict turns on V/MHz, whose spread across identical pairs is 1.2pp against raw clock's 4.1pp:

run V/MHz mean range sign consistent
CO −20 (real) −0.30% −0.94 … +0.28 no
CO 0 (sham) +0.25% 0.00 … +0.69 no

The real run sits inside the sham's noise. Effect size is what makes that decisive rather than merely inconclusive: 20 counts at ~4 mV is ~80 mV against a measured ~0.89 V, so a working offset moves V/MHz by roughly 9%. This is thirty times smaller and changes sign.

The instrument needed correcting before it could be trusted

RyzenAdj's PM table map for 0x005D000B is shifted by one field on this board: its "gfx power" is voltage and its "volt" is temperature. Its clock offset still lands on a clock only because three adjacent fields are clocks — the coincidence that keeps a wrong map looking right. Reading 0x4B4 as watts reports a saturated 890M pulling 41 W as 0.86 W, and nothing in the output looks wrong. The offsets used here are measured, and the header comment records the idle-vs-loaded pairs that fix them.

A side finding worth having: the PSMU query messages answer in the argument registers, so this path is not write-only. get-pbo-scalar returning 1.0f is exactly the shape of a stale buffer, so it was moved — 1.000x → 2.000x → 1.000x through the same transport — rather than trusted on a first read.

The slider the user moved was labelled L3 Cache on an AMD part

"CACHE OFFSET / L3 Cache" was hardcoded in TuningView, and on AMD that slider's value goes to the iGPU Curve Optimizer, not to a cache domain. Three labels for one number, two of them wrong on this vendor. Both label lines now bind to the view model.


Scope

Board-specific in origin and confined to what it touches. The lighting work is DojoPerKeyBackend, which serves OMEN MAX per-key keyboards only — HidPerKeyBackend (the 0x03F0 OMEN family), WmiBiosBackend and EcDirectBackend are untouched, and the mi_04 fallback path behaves exactly as before. The Fn-cycle card appears only where a backend reaches the MCU (0D62:54BF). The SMU changes are per-family switches, and core Curve Optimizer is untouched — that is MP1 0x4C and a separate question from the graphics curve.

Verification

Full suite: 1510/1510 passing, 0 failed, 0 build warnings (1485 OmenCoreApp.Tests + 25 OmenCore.Linux.Tests), on main merged in at 5667f3a. New coverage: DojoPerKeyBrightnessTests, KeyboardMapBrightnessTests, DeviceLightingSwatchTests, FnCyclePlanTests, DeviceLightingFnCycleTests, DynamicLightingStateTests, PerKeyPersistenceTests, PerKeyCellKeyAgreementTests, RyzenSmuTransportTests, AmdPowerLimitTests.

Two of those are load-bearing in a way worth naming:

  • DojoPerKeyBrightnessTests pins the scaling — full intensity is byte-for-byte transparent across all 256 channel values (a rounding error there would tint every colour a user picks), zero is black, the scale is linear and monotonic, and the channel * intensity product never overflows the byte it is cast to. The device end cannot be tested — DojoKeyboardMcu is sealed over a raw handle and neither interface reads colour back — so the arithmetic is the last automatically checkable point.
  • PerKeyCellKeyAgreementTests — the editor and the stored record compose a cell identity in two different files and agree only by convention. When two such formats drift, nothing throws: the restore matches nothing and the editor comes back at its defaults, which is indistinguishable from the save never having happened. Those tests assert the two sides equal each other rather than a hard-coded string, since asserting the string would pass just as happily if someone changed one side and updated the test to match.

Exercised on real 8D87 hardware via LightingProbe --per-key --zones FF0000 --brightness N --commit (both interfaces open, the layout resolves, the scaled colour map is accepted at each level; --brightness alone correctly reports accepted: False, which is 0x0C being refused as documented), LightingProbe --effect-brightness, the 10 Hz 0x83 state poll for the Fn cycle, the Dynamic Lighting registry A/B, and SmuProbe --igpu --scan / --prereq / --sham plus the loaded outcome runs.

Two red CI jobs, fixed here

Both were already failing on main at 5667f3a and neither comes from this branch. They are fixed in one commit here so the checks on this PR mean something.

Run Unit Tests (linux-qa.yml) has never passed - red going back at least to 2026-05-25. It ran dotnet test OmenCore.sln on ubuntu-latest, and the solution contains OmenCoreApp and OmenCoreApp.Tests, which target net8.0-windows10.0.19041.0 and cannot be restored on Linux at all: NETSDK1100, before a single test is discovered. EnableWindowsTargeting would get it past restore only to run WPF tests on a machine with no Windows, so the job now runs the one genuinely cross-platform test project - OmenCore.Linux.Tests, which main added in 64ebae0 and which nothing had pointed a job at yet. The Windows suite is already covered by build-and-test in ci.yml.

GameLibraryViewModelTests failed on the Windows runner and passes on a developer machine, which is the tell. It asserts IsScanning is true immediately after the constructor's fire-and-forget scan - which holds only while that scan is still running. With no game platforms installed, all six per-platform scanners short-circuit, the Task.WhenAll over them is already complete, the await resumes synchronously, and the whole of ScanLibraryAsync (finally block included) runs inline inside the constructor; IsScanning is back to false before it returns. The test now subscribes to ScanCompleted before constructing and accepts either outcome - still running, or already finished - since both prove a scan was started and neither is reachable if one was not.

What is not verified, and should be read before merging

  • The durability of Save to keyboard. Nobody has applied a picture, pressed the button, power-cycled the machine and looked. The mechanism is established — 0x0A is documented, it is what HP's own client calls on every colour round, and the colour map is what the MCU draws autonomously — but every link in that chain comes from the decompiled client and this board's command table rather than from watching the outcome. The tooltip promises a picture that survives a power cycle, so until someone looks, that promise rests on inference.
  • The Strix Halo mailbox fix is untested against hardware — there is no Strix Halo part here. The two new transport tests pin each family to its cited address set, so the next edit to that switch has to argue with a citation rather than a hunch. It is a correction derived from two independent upstream implementations agreeing, not from a measurement.
  • Whether the Fn cycle list survives a power cycle. The one flash write made while probing was within a single power session, so it shows only that 0x0A does not disturb the list — not that unflashed entries come back after a cold boot. Nothing in the UI claims otherwise.

🤖 Generated with Claude Code

tempestnano and others added 8 commits August 15, 2026 10:15
The OMEN MAX keyboard has two lighting interfaces and only one of them
has a brightness channel. mi_04 (HID LampArray) carries a per-lamp
intensity byte; mi_03 (the MCU's 176-entry static colour map) is raw RGB
and the MCU draws exactly what it is handed.

DojoPerKeyBackend stored the slider's value in _brightness and applied it
only where an intensity byte existed. That was right until per-key
painting moved to mi_03 so the picture would survive the Fn overlay.
After that move mi_04 is only the fallback for a board whose lamp ids
cannot become colour-map positions - so on every board that has a
layout, which is every board the editor lists keys for, the value was
written and never read.

Nothing surfaced it. The slider moved, both layers held the value, and
the write returned true; there is no colour readback on either
interface, so "accepted" was the whole software claim and it was honest.
Only the keyboard disagreed.

Scale on the host instead, in WriteColorMap - the one point every map
passes through on its way to mi_03 - linearly and rounded to nearest,
the way mi_04's intensity channel scales a lamp. Keep the unscaled map
so SetBrightnessAsync re-sends the picture at a new level instead of
compounding each change on the last dimmed copy of it, and track whether
that map is what the keyboard is drawing: a brightness change repaints a
static picture, and must not freeze a running animation into a still
frame, un-blank a keyboard whose backlight is off, or fight host lamp
ownership. A device-rendered effect still cannot be dimmed on this
hardware, which the UI already says.

MCU command 0x0C is still sent and still expected to fail here. Measured
on 8D87 it is not acknowledged at any payload value - 0, 1, 2, 3, 50,
100 - while 0x03, 0x83, 0x09, 0x0A and 0x10 are all acknowledged through
the same handle and the same frame builder. Its doc comment claimed to
be unmeasured; it has been measured, and now says so.

Two silent drops made loud: the service layer logs which backend
declined per-key brightness, and the editor says so in the Apply status
line rather than leaving a user to infer a dead slider from a keyboard
that did not dim.

8 tests pin the arithmetic, which is the last automatically checkable
point - DojoKeyboardMcu is sealed over a raw handle and neither
interface reads colour back. Exercised on board 8D87 through
LightingProbe: both interfaces open, layout resolves, scaled map
accepted at each level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The editor had a single Brightness slider that scaled the whole picture
and also drove the light bar, so "dim these four keys" had no expression
and dimming the keys dimmed the bar with them.

Split into a master over the whole picture and a level per cell, and
multiply them. The master stays the backend's, applied on its way to the
wire; the per-cell level is applied here, which is safe because it is a
separate field rather than a rewrite of the colour the user picked.
Baking it into the hex would be lossy and one-way - drag a key to 10%
and back to 100% and the picked colour is gone - and there is a test
that fails if someone simplifies it that way later.

Light bar zones take a level too. HP's frame carries one brightness byte
for all four zones, so per-zone dimming cannot go to the firmware and is
the same host-side arithmetic as the keys. The master still rides the
byte.

The caps preview their own level so per-key differences are visible
while editing, and deliberately not the master: at master 3 the whole
editor goes near black and you cannot see what you are painting. The
preview's rounding is pinned to the backend's by a test, because there
is no colour readback on this hardware to settle a disagreement between
what the cap shows and what the key does.

Also closes the one payload the "no effect brightness" verdict rested on
inference for. Every effect frame ever sent carried Brightness = 0, so a
firmware treating 0 as "leave alone" would have looked identical to one
that ignores the field. Measured on 8D87 via a new --effect-brightness
flag: 60 and 160 were each sent in a frame the MCU demonstrably consumed
- effect became Wave, colour became FF0000 then 00FF00, speed and
direction took - and [4] read back 200 unchanged both times. 160 is a
value this firmware has itself been observed holding, so it is not an
out-of-range guess. The verdict now stands on measurement.

13 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The interesting writes to this record are not ours. The Fn lighting
shortcuts change the installed effect, and the only way to see what they
set is to be watching when they do - which a shell loop around
--read-effect cannot do, because process start dominates the sample
interval and a change that reverts inside a second falls between
samples.

--watch-effect holds one handle and polls at 10 Hz, printing only
changes. What it found on 8D87:

Fn+1 and Fn+2 are ONE control. Fn+1 is next and Fn+2 is previous through
the same list of saved effects; Fn+3 through Fn+0 do nothing to
lighting. The list holds what the host wrote to it - two entries in it
were this tool's own writes, and one of them was never flashed, so a
plain command 0x03 is enough to populate a slot. There is one slot per
effect type holding that effect's last parameters, not an append-only
history: Wave was written red then green in one session and only green
is in the list. StoreLightingToFlash neither creates nor reorders slots.

So the Fn keys are a usable profile switcher rather than a firmware
behaviour to work around, and filling them needs no command this project
does not already send.

Torn frames are filtered rather than printed, which is the difference
between a reading and an artefact. Polling tears at ~17% while any key
is pressed and 0% at rest over 400+ samples - it is ordinary HID input
traffic on the same device colliding with the feature read, and it
happens just as much while pressing keys that change nothing. A torn
frame carries an effect id of 236 or 240, a colour count of 36, a speed
of 8; none are values the fields can hold, so plausibility is a
sufficient filter. Printing them as state is how contention becomes a
"finding" - which it briefly did here before the service was stopped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The device-effects card and the light bar card took custom colours as
hex text and nothing else, so choosing one meant knowing the hex for it.
The per-key editor has had a picker since it shipped; these two just
never got one.

Same dialog, same gesture: the swatch IS the picker button, with the hex
box kept beside it for typing an exact value. Reusing ColorPickerDialog
rather than adding a second picker, because two colour-picking
implementations in one app is how they end up disagreeing about what a
hex string means.

The swatch renders on every keystroke - the boxes bind with
UpdateSourceTrigger=PropertyChanged - so it has to survive the partial
values typing walks through. It falls back to black instead of throwing.

One of those states is not what it looks like, and the test says so:
WPF's ColorConverter reads four-character hex as #ARGB shorthand, so
"#FF88" is a real colour rather than a parse failure, and the swatch
flashes it on the way to "#FF8800". Found by asserting the obvious guess
and watching it fail.

11 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…writes

Pressing Fn+1 changes the keyboard lighting on 8D87 and nothing here knew
why. Measured by polling the MCU's 0x83 state read in-process at 10 Hz while
the keys were pressed, with HP's OmenCommandCenterBackground stopped:

Fn+1 and Fn+2 are one control, not two - next and previous through a single
list. Fn+3 through Fn+0 do nothing to lighting. The list holds what a host
wrote to it: two entries in the observed cycle were this project's own probe
writes, and an ordinary 0x03 effect frame was enough to install one, with no
flash write involved. There is one slot per effect type holding that effect's
last parameters, so Wave written red then green leaves one entry, green - not
an append-only history. 0x0A StoreLightingToFlash neither creates nor reorders
slots.

So this is a hardware profile switcher we can drive with commands we already
send, and the result outlives the process, the install, and the machine being
reimaged.

Adds an "Fn+1 / Fn+2 cycle" card. Stage profiles from the effects card above
it, mark one as Showing, write. The staged set persists in config.json,
because the MCU will not enumerate its cycle and ours is the only account of
what was curated.

The two firmware rules live in FnCyclePlan where they can be tested rather
than in the view model. Duplicate effect types collapse to the later one - the
firmware would overwrite anyway, so sending both spends a frame installing an
entry nothing could ever recall. The Showing profile is written last, because
the keyboard displays whatever arrived most recently and there is no command
that says which one to show. Staging and applying share one record builder, so
a profile in the cycle is the same frame the user watched Apply produce.

What the card says out loud, because a user cannot discover it by trying:
writing adds to or updates what the keyboard holds. Nothing in the mapped
command surface removes or reorders a slot, and 0x83 reads only the effect
currently showing. A profile taken off the list stays on the keyboard, and no
readback exists that could reveal it.

Swipe staged with a theme is refused up front - it has no preset palette on
this firmware and renders black, which in a cycle is found days later by
pressing Fn+1 into a dead keyboard. Audio Pulse is allowed with a note that
it is host-fed and will sit at a steady colour.

Measured on 8D87, BIOS F.07, keyboard MCU 0D62:54BF. The card appears only
where a backend reaches that MCU.

49 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The badge read "No EC" on a machine where the PawnIO driver was installed,
running, and healthy. Cause: drivers\LpcACPIEC.bin was not next to the exe.

PawnIOEcAccess caches that module in a static byte[] whose comment claimed it
was embedded in the assembly. It is not. LoadEcModule reads it off disk from
<appdir>\drivers\ or C:\Program Files\PawnIO\modules\, PublishSingleFile does
not bundle None content items, and IncludeAllContentForSelfExtract is
deliberately off. The PawnIO installer creates no modules\ directory, so there
is no second place for it to be found.

Costs nothing through the installer, which takes the publish directory with
recursesubdirs. It matters for hand-assembled drops: copying only OmenCore.exe
loses EC access, undervolt and the APU clamp lift together, and the only
symptom is the badge - every driver-level check still passes.

Comment corrected to say where the module comes from and what breaks without
it.

Also types the bare catch in FindPawnIOInstallationPath and retires its
baseline entry. It was flagged only because the comment above shifted its line
number, but a registry lookup that throws and falls through silently is
exactly what someone debugging "No EC" wants logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tempestnano tempestnano changed the title Fix the per-key RGB brightness slider doing nothing on boards with a known keyboard layout Keyboard lighting on board 8D87: a brightness slider that works, per-cell levels, colour pickers, and the Fn+1/Fn+2 profile cycle Aug 15, 2026
tempestnano and others added 10 commits August 16, 2026 09:27
RyzenControl.SupportsIgpuUndervolt lists four CPU names - Ryzen AI MAX,
6900H, 7945H, 7845H. A Ryzen AI 9 HX 375 matches none of them, so
ApplyOffsetAsync zeroed the iGPU counts before they reached an SMU
message and nothing was ever sent. The offset then read back as 0 mV
against a non-zero request, and the only thing the UI could say about it
was "mismatch between requested and readback" - which describes a write
that was attempted and refused, not one that was never attempted.

The provider now records why a non-zero iGPU offset did not land and
surfaces it as a warning, so the status line explains the drop instead of
reporting a bare mismatch. The gate moved into ApplyRyzenOffsetAsync,
which is the one place that knows the offset was dropped; zeroing it in
the caller made a dropped request indistinguishable from a request for 0.

_lastIgpuCO also moved to only advance when the mailbox accepted the
message. It was assigned unconditionally, so on the CPUs that do take
this path a refused write read back as a successful one.

Separately, the chips under the status line were hardcoded to "Cache" on
both vendors while the status line above them said "iGPU" for the same
number. On AMD the CacheMv field carries the iGPU Curve Optimizer offset;
there is no cache voltage domain being written. Both surfaces now go
through one label.

This does not enable iGPU CO on Strix Point. SetIgpuCO would send PSMU
0xB7 there, which is not measured on this silicon, and a status code from
that mailbox is not evidence of an effect - the same trap that made
All-Core CO look like it worked when the module was not loading at all.
Enabling it needs an outcome measurement first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chips were fixed in the previous commit; the control above them was
not. "CACHE OFFSET / L3 Cache" is hardcoded in TuningView, and on AMD
that slider's value goes to the iGPU Curve Optimizer, not to a cache
domain. Three labels for one number, two of them wrong on this vendor.

Both label lines now bind to the view model, which switches them with
IsAmdCpu and raises the change notification for them - the setter did not
notify any of the strings that read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SetIgpuCO's PSMU 0xB7 arm listed Strix Point, Strix Halo and Mendocino
alongside the four families upstream actually maps. RyzenAdj's set_cogfx
(lib/api.c, v0.19.0) has Cezanne/Renoir/Lucienne on MP1 0x64 and
Rembrandt/Phoenix/HawkPoint/Van Gogh on PSMU 0xB7, and no others.

Strix Halo is the pointed one. It has a case in set_cogfx carrying only
the comment "0xB7 is rejected on this architecture" before falling
through to default. That is the family a "RYZEN AI MAX" CPU name reaches
here - so of the four names SupportsIgpuUndervolt lets through, the one
that resolves to a real iGPU CO arm resolved to the message upstream
measured as refused. 7945H and 7845H are Dragon Range and reach no arm at
all. Only 6900H, Rembrandt, was ever aimed at a supported id.

None of that surfaced because _lastIgpuCO was assigned regardless of the
mailbox's answer, so the refusal read back as the requested offset. That
was fixed two commits ago; this one stops the message being sent.

Strix Point simply has no set_cogfx case and returns
ADJ_ERR_FAM_UNSUPPORTED. Upstream is not behind on the family - the same
file gives it set_coall (MP1 0x4C) and set_coper (MP1 0x4b). It is the
graphics curve that is unmapped, which is a reason to measure before
adding an id here, not a reason to guess one.

Families with no confirmed id now return UnknownCmd rather than Failed,
and the warning tells "no message exists for this family" apart from "the
SMU refused the message we sent".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wrong register

ConfigureSmuAddresses grouped StrixHalo with VanGogh, Rembrandt, Phoenix,
Mendocino and HawkPoint on MP1 address set 2 - message 0x3B10528, response
0x3B10578. It belongs on set 3 with StrixPoint: 0x3B10928 / 0x3B10978.

RyzenAdj's nb_smu_ops.c selects set 3 for FAM_KRACKANPOINT, FAM_STRIXPOINT and
FAM_STRIXHALO in one case group. UXTU reaches the same split independently, from
the other direction: Socket_FT6_FP7_FP8 shares a single message table across the
whole socket group but branches the mailbox addresses for exactly those parts.
Two implementations, no coordination, same answer - and the value they agree on
is the one we already use for Strix Point.

While Strix Halo sat in the set-2 arm, every MP1 message sent on a Ryzen AI MAX
landed on registers that part does not read. Nothing caught it because a write
to an address the SMU ignores does not report an error; it reports nothing, and
the readback path then compared one unrelated value against another.

This compounds with the iGPU gate. RYZEN AI MAX is one of the four names
SupportsIgpuUndervolt admits, so on that part we were sending a message RyzenAdj
marks as rejected, through a mailbox the part does not read.

The doc comment claimed the addresses were cross-checked against RyzenAdj's
nb_smu_ops.h. They are defined in nb_smu_ops.c; the header carries no per-family
selection at all, so the cross-check could not have found this. Comment now
cites the file that actually decides.

Untested against hardware - there is no Strix Halo part here. The two new
transport tests pin each family to its cited address set so the next edit to
this switch has to argue with a citation rather than a hunch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… that it does not

OmenCore had to pick a side in a disagreement it could not settle from source. RyzenAdj's
set_cogfx gives PSMU 0xB7 to Rembrandt, Phoenix, HawkPoint and VanGogh, marks it rejected
on Strix Halo, and gives Strix Point no case at all. UXTU's socket table for FT6/FP7/FP8 -
which Strix Point routes into - carries ("set-cogfx", false, 0xb7) and would send it.

SmuProbe --igpu asks the part. On board 8D87 / Ryzen AI 9 HX 375:

  PSMU 0xB7 -> CmdRejectedPrereq      MP1 0xB7 -> UnknownCmd

identically for CO -20 and CO 0, so the refusal is about machine state and not the
argument. The two statuses differing is the finding: UnknownCmd would have meant the
message does not exist, CmdRejectedPrereq means the PSMU knows it and declined. It also
corroborates the mailbox - a wrong address gives timeouts, not a clean "no such command".

--prereq chases what the precondition is. UXTU puts enable-oc on the same PSMU mailbox as
set-cogfx, and RyzenAdj sends PSMU 0x17 for it on Rembrandt, the one family it also grants
0xB7. Here enable-oc returns CmdRejectedPrereq - the same status as the thing it would
unlock - while disable-oc returns Ok. Refusing to enable and permitting to disable is what
a locked-off capability looks like. Run as enable/retry/disable/retry so a coincidence
would have shown itself. There is no host-side sequence that reaches graphics CO here.

So SupportsIgpuUndervolt stays shut for Strix Point, and now for a measured reason rather
than an inherited table. Core CO is untouched - that is MP1 0x4C and a separate question.

--sham was run first and is kept even though the refusal made the outcome measurement
moot: writing 0 in both phases gave mean -0.1% clock and -0.1% volt with inconsistent
sign, so any future graphics-side claim has to clear ~1% with a consistent sign.

The instrument needed correcting before it could be trusted. RyzenAdj's PM table map for
0x005D000B is shifted by one field on this board: its "gfx power" is voltage and its
"volt" is temperature. Its clock offset still lands on a clock only because three adjacent
fields are clocks. Reading 0x4B4 as watts reports a saturated 890M pulling 41 W as 0.86 W
and nothing in the output looks wrong. The offsets used here are measured, and the header
comment records the idle-vs-loaded pairs that fix them.

--oracle is the side finding: the PSMU query messages answer in the argument registers.
get-pbo-scalar returning 1.0f is exactly the shape of a stale buffer, so it was moved -
1.000x -> 2.000x -> 1.000x through the same transport. This path is not write-only, which
was the reason the AMD limits had to be checked through an external ryzenadj.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not re-added

FamilySupportsIgpuCurveOptimizer excluded StrixPoint because RyzenAdj's set_cogfx has no
case for it. That is a correct reason but a fragile one - it reads as an upstream gap, and
the obvious next contributor closes gaps. UXTU does exactly that: its socket table for
FT6/FP7/FP8 carries set-cogfx on PSMU 0xb7 and Strix Point routes into it.

The comment now carries the measurement instead of the absence. SmuProbe --igpu --scan gets
CmdRejectedPrereq from PSMU 0xB7 and UnknownCmd from MP1 0xB7 on a Ryzen AI 9 HX 375, and
--prereq shows the precondition is OC mode and that enable-oc is refused with the same
status while disable-oc succeeds. Adding StrixPoint here would ship a slider that cannot
move, and the silent-drop path this branch already fixed would then be the only thing
standing between the user and a mismatch they cannot explain.

Comment only - no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ake a gain

The graphics CO verdict rested on a status code. CmdRejectedPrereq is a refusal rather than
an Ok, which felt like it earned an exception to "verify by outcome" - it does not. The
offset is now applied under load and measured, and the metric had to change to get an
honest answer.

Raw clock and raw voltage are each useless alone here. They drift with the operating point
and they drift TOGETHER: a phase that happens to run warmer shows higher clock AND higher
voltage, which is the V/F curve being traversed, not shifted. The first outcome run returned
a raw clock mean of +2.0% - above the 1% threshold, and a positive if clock were the measure -
with voltage up +1.4% beside it. An undervolt does not raise voltage.

A Curve Optimizer undervolt shifts the curve: less voltage for the same clock. So the verdict
now turns on V/MHz, which cancels the wander by construction. Across identical pairs its
spread is 1.2pp against raw clock's 4.1pp.

Measured on a Ryzen AI 9 HX 375 under a saturating WebGL load at 98-99% busy:

  CO -20 (real)   V/MHz mean -0.30%   (-0.94 .. +0.28)   sign consistent: no
  CO   0 (sham)   V/MHz mean +0.25%   ( 0.00 .. +0.69)   sign consistent: no

The real run sits inside the sham's noise. Effect size is what makes this decisive rather
than merely inconclusive: 20 counts at ~4 mV is ~80 mV against a measured ~0.89 V, so a
working offset moves V/MHz by roughly 9%. This is thirty times smaller and changes sign.

Raw clock is kept in the output as a secondary read, and the NOT CONFIRMED branch now names
the drift explicitly when clock clears its threshold without a consistent sign - that being
the exact shape that would otherwise get reported as a win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lus-igpu

# Conflicts:
#	docs/CHANGELOG_v4.1.7.md
Reported as "the keyboard colors don't seem to stick if I close the
program". Nothing about the apply failed: the colours applied, held for
as long as the app was open, and were gone at the next launch.

It is not OmenCore erasing its own work, and the log rules that out.
`Apply: 176 LEDs ledsOk=True, 0 keys keysOk=True` against `Layout
Dojo/Global (176 LEDs, 99 keys)` is the whole picture going through
mi_03's colour map and none of it through mi_04 - and the MCU holds that
map and redraws it autonomously. Dispose's comment says exactly this and
is correct. The picture should have survived.

Windows Dynamic Lighting is the second owner, and it wins whenever no
application holds the device. While OmenCore holds host control it is
incidentally locking Windows out; releasing on the way down ends that,
and Dynamic Lighting repaints within one refresh interval - 33 ms on this
board, which the device itself reports as its minimum update.

Measured on 8D87 the only way this hardware allows, since there is no
colour readback anywhere on either interface: with
AmbientLightingEnabled = 1 the picture reverted on exit; with Dynamic
Lighting off and nothing else changed, the same picture held. One
registry toggle, two outcomes, no rebuild and no reboot between them.

HidLampArray's class comment already named this hazard almost word for
word, down to the 33 ms - "Arbitrating with Dynamic Lighting is a policy
decision, not a transport one." The hazard was understood and written
down, and then no policy was ever decided. This is that decision.

DynamicLightingState reads the master toggle and the per-device card and
reports whether Windows will take the device back. Read-only on purpose:
these are another feature's settings and the user owns them, so the
editor explains and offers to open the Settings page rather than quietly
switching off a Microsoft feature to make ours look better. The
per-device card is treated as a veto rather than as something that tracks
the master, because it was only ever observed in one state and inferring
the other would be guessing at semantics we have not measured.

Registering as a Dynamic Lighting-aware app was costed and rejected FOR
THIS SYMPTOM, which is worth recording so it is not proposed again as an
oversight. Ambient control requires MSIX package identity - enforced by
the AmbientLightingServer, which only accepts clients that have it - and,
decisively, control of either kind ends when the process ends. Neither
foreground nor ambient registration produces a picture that holds with
OmenCore closed.

6 tests. Read() is not among them: it reads the current user's registry,
so a test of it would assert something about the machine running the
suite rather than about this code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The counterpart to the Dynamic Lighting fix. With Windows no longer
taking the device, the keyboard draws its own colour map - and until now
nothing made that map durable or wrote the picture down anywhere.

Autonomous mode is not a workaround here, it is the contract. Microsoft
describes a device Windows is not driving as one that "operates in
Autonomous mode ... the hardware falls back to default behavior as
defined by its firmware". On this keyboard that firmware default IS the
mi_03 colour map, so storing the map to flash is how an application
participates correctly rather than how it evades anything.

Save to keyboard: applies the picture, then flashes it. StoreToFlash
existed, was documented, and was reachable through the service - and only
the Fn-cycle write ever called it, which persists the effect record. No
per-key apply flashed anything, so a painted picture was volatile MCU
state and a power cycle took it.

That the colour map is flashable at all is not an assumption. HP's own
client does it on every per-key apply: DojoKbLightingModel.SetKeyColor
passes isWriteRegistry: true unconditionally, so 0x0A follows every
colour round, and that is what makes a picture set in OGH survive a
reboot. We deliberately do not copy that literally - 0x0A writes MCU
flash, and "every apply" in an editor with a brightness slider is a flash
write per drag. One button, one write, one intent.

Applying before flashing rather than flashing what happens to be
installed: otherwise the button persists the PREVIOUS picture and reports
success, which is the worst of both.

config.json keeps a second copy, as painted - colour and per-cell level
stored apart rather than flattened, because baking the level into the
colour is lossy and one-way and a cell taken to 10% and back to 100%
would not come back the colour it started. Restored at startup, where it
takes precedence over the four-zone fill: the two describe the same
keyboard at different resolutions and the fill would erase a picture
completely. The lazy KeyboardMap is tested last in that condition so a
machine with no saved picture never touches the device at startup.

Restoring the editor and repainting the hardware are kept separate. The
constructor restores cells and sends nothing, because the keyboard may
already be showing this picture from its own flash, and painting on
construction would overwrite a device effect the user chose since.

15 tests. The load-bearing ones are in PerKeyCellKeyAgreementTests: the
editor and the stored record compose a cell identity in two different
files and agree only by convention, and when two such formats drift
nothing throws - the restore matches nothing and the editor comes back at
its defaults, which is indistinguishable from the save never having
happened. Those tests assert the two sides equal EACH OTHER rather than a
hard-coded string, since asserting the string would pass just as happily
if someone changed one side and updated the test to match. The identity
carries its kind because a colour-map position and a lamp id are both
small integers addressing different lights.

Not yet confirmed on hardware: nobody has applied a picture, flashed it,
power-cycled and looked. The mechanism is established from HP's client;
the outcome on this board is not, and the tooltip's promise rests on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
theantipopau pushed a commit that referenced this pull request Aug 16, 2026
Three pillars, in priority order:

1. Sensor truth and the fan control that depends on it. Traced the
   r/HPOmen "shows CPU at 36 degrees when it's over 80" report to a real
   bug: GetAcpiCpuTemperature() latches the FIRST enumerated ACPI thermal
   zone permanently, and only displaces it on a CPU/CPUZ/TZ00 name match
   that real HP zone names often miss - so a skin/ambient sensor can be
   reported as CPU temp indefinitely. The 18C outlier guard doesn't help,
   because it's conditioned on _cachedCpuTemp > 0 and so doesn't apply
   when the WMI BIOS path is weak or absent. Fan curves consume this
   value, so it's a fan-control bug, not just a display bug.

2. Perceived performance, motion, and footprint. Three UI passes shipped
   in 4.1.x without moving user perception, which means the wrong thing
   was measured - so this starts with a baseline harness (cold start,
   view-switch cost, idle tray CPU, working-set growth) before optimizing.

3. Roboto Condensed. Needs embedding (not a Windows system font) and a
   consolidation pass first: 102 FontFamily declarations exist and widely
   bypass the central AppFontFamily resource.

Also corrects two stale claims inherited from the predecessor roadmap,
both checked against source rather than trusted: the Darfon VID is
already scanned (the real 8D41 blocker is an unconfirmed command set, not
VID detection), and TemperatureRgbService is referenced by one test, so
deleting it isn't a clean removal.

Rolls forward every unfinished v4.0.0 item, plus the unmerged PR #176
backlight-freeze regression. Planning only - no code change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tempestnano tempestnano changed the title Keyboard lighting on board 8D87: a brightness slider that works, per-cell levels, colour pickers, and the Fn+1/Fn+2 profile cycle Board 8D87: keyboard lighting end to end, plus the AMD SMU mailbox and iGPU Curve Optimizer corrections found beside it Aug 17, 2026
tempestnano and others added 2 commits August 19, 2026 13:15
One conflict, in UndervoltStatus, and it was additive on both sides: main added
HasIndependentReadback while this branch added IgpuOffsetRequestedButNotApplied.
Both are kept.

They do not overlap in the status line. IgpuOffsetRequestedButNotApplied is set
alongside a Warning, and TuningStatusFormatter reports a warning before it ever
reaches the requested-vs-readback comparison that HasIndependentReadback
qualifies. So the iGPU-refusal message still wins where it applies, and main's
"write acknowledged (no independent hardware readback on this path)" wording
covers the ordinary AMD case as it does on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither failure comes from this branch; both reproduce on main at 5667f3a. They
are fixed here so the checks on this PR mean something.

Run Unit Tests (linux-qa.yml) has never passed, going back at least to
2026-05-25. It ran "dotnet test OmenCore.sln" on ubuntu-latest, and the solution
contains OmenCoreApp and OmenCoreApp.Tests, which target
net8.0-windows10.0.19041.0 and cannot be restored on Linux at all - NETSDK1100,
before a single test is discovered. Setting EnableWindowsTargeting would get it
past restore only to run WPF tests on a machine with no Windows, so the job now
runs the one test project that is actually cross-platform,
OmenCore.Linux.Tests - which main added in 64ebae0 and nothing had pointed a job
at yet. The Windows suite is already covered by build-and-test in ci.yml.

GameLibraryViewModelTests failed on main's Windows runner and passes on a
developer machine, which is the tell. The test asserts IsScanning is true right
after the constructor's fire-and-forget scan. That holds only while the scan is
still running: with no game platforms installed, all six per-platform scanners
short-circuit, the Task.WhenAll over them is already complete, the await resumes
synchronously, and the whole of ScanLibraryAsync - finally block included - runs
inline inside the constructor. IsScanning is back to false before the
constructor returns. It now subscribes to ScanCompleted before constructing and
accepts either outcome, still running or already finished, since both prove a
scan was started and neither is reachable if one was not.

Full suite green on Windows: 1485 + 25 = 1510 passed, 0 failed, 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant