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
Conversation
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Fn+1/Fn+2cycle, and the two owners that were taking the picture away.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:
mi_04mi_03Per-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_04is 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:
176 LEDs through the colour map; zero lamps through the other interface.
Brightness: a master over the picture, and a level per cell
The Brightness slider did nothing on this board.
DojoPerKeyBackendstored its value in_brightnessand applied it only where an intensity byte exists, which ismi_04— so on every board that has a layout,_brightnesswas 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 tomi_03, scaled linearly and rounded to nearest exactly asmi_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:Three things that had to be right alongside it:
SetBrightnessAsyncre-sends the same picture at a new level; keeping the scaled bytes would compound every change on the last dimmed copy._mcuShowsHostMaptracks 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 carries0x09/0x01, which would turn it back on), and never fight host lamp ownership.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 carried0, so a firmware treating0as "leave alone" would have looked identical to one that ignores the field.--effect-brightnesswas added toLightingProbeand measured:0(historic)20060200#FF0000160200#00FF00160is 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
0x0Cis still sent and still expected to fail here: it is not acknowledged at any payload value (0,1,2,3,50,100) while0x03,0x83,0x09,0x0Aand0x10are 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.SetPerKeyBrightnessreturnedfalsefor any non-DojoPerKeyBackendbackend and said nothing; it now logs which backend declined, andKeyboardMapViewModelsurfaces 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
ColorPickerDialogthe 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#FF8800walks 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'sColorConverterreads four-character hex as#ARGBshorthand, so#FF88is 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
0x83state read in-process at 10 Hz while the keys were pressed, with HP'sOmenCommandCenterBackgroundstopped:Fn+1andFn+2are one control — next and previous through a single list.Fn+3throughFn+0do nothing to lighting.0x03effect frame was enough to install one.0x0A StoreLightingToFlashneither 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
FnCyclePlanwhere 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
0x83reads 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 pressingFn+1into a dead keyboard.One correction worth recording. The
0x83poll tears while any key is held — around 17% of samples come back with a corrupt leading byte, giving an effect id of236, a colour count of36, or a speed of8, none of which those fields can hold. That is not theFnwrite: it happens just as much while pressingFn+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, andDispose'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 = 1the 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:DynamicLightingStatereads 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_03colour map, so storing the map to flash is how an application participates correctly.StoreToFlashexisted, 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.SetKeyColorpassesisWriteRegistry: trueunconditionally, so0x0Afollows every colour round, and that is what makes a picture set in OGH survive a reboot. We deliberately do not copy that literally, because0x0Awrites 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.jsonkeeps 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
ConfigureSmuAddressesgroupedStrixHalowith VanGogh, Rembrandt, Phoenix, Mendocino and HawkPoint on MP1 address set 2 — message0x3B10528, response0x3B10578. It belongs on set 3 with Strix Point:0x3B10928/0x3B10978.RyzenAdj's
nb_smu_ops.cselects set 3 forFAM_KRACKANPOINT,FAM_STRIXPOINTandFAM_STRIXHALOin one case group. UXTU reaches the same split independently and from the other direction:Socket_FT6_FP7_FP8shares 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 innb_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 PSMU0xB7arm listed Strix Point, Strix Halo and Mendocino alongside the four families upstream actually maps. RyzenAdj'sset_cogfx(lib/api.c, v0.19.0) has Cezanne/Renoir/Lucienne on MP10x64and Rembrandt/Phoenix/HawkPoint/VanGogh on PSMU0xB7, and no others. Strix Halo is the pointed one: it has a case inset_cogfxcarrying only the comment "0xB7 is rejected on this architecture" before falling through to default — so of the four namesSupportsIgpuUndervoltlet 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
_lastIgpuCOwas assigned regardless of the mailbox's answer, so a refusal read back as the requested offset. Families with no confirmed id now returnUnknownCmdrather thanFailed, 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_cogfxcase; UXTU's socket table would send0xB7anyway.SmuProbe --igpuasks the part, on a Ryzen AI 9 HX 375: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:
UnknownCmdwould mean the message does not exist,CmdRejectedPrereqmeans the PSMU knows it and declined. It also corroborates the mailbox above — a wrong address gives timeouts, not a clean "no such command".--prereqchases the precondition. It is OC mode, and OC mode is itself refused:enable-ocreturnsCmdRejectedPrereq— the same status as the thing it would unlock — whiledisable-ocreturnsOk. 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:
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
0x005D000Bis 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. Reading0x4B4as 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-scalarreturning1.0fis exactly the shape of a stale buffer, so it was moved —1.000x → 2.000x → 1.000xthrough 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(the0x03F0OMEN family),WmiBiosBackendandEcDirectBackendare untouched, and themi_04fallback 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 MP10x4Cand a separate question from the graphics curve.Verification
Full suite: 1510/1510 passing, 0 failed, 0 build warnings (1485
OmenCoreApp.Tests+ 25OmenCore.Linux.Tests), onmainmerged 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:
DojoPerKeyBrightnessTestspins 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 thechannel * intensityproduct never overflows the byte it is cast to. The device end cannot be tested —DojoKeyboardMcuis 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
8D87hardware viaLightingProbe --per-key --zones FF0000 --brightness N --commit(both interfaces open, the layout resolves, the scaled colour map is accepted at each level;--brightnessalone correctly reportsaccepted: False, which is0x0Cbeing refused as documented),LightingProbe --effect-brightness, the 10 Hz0x83state poll for the Fn cycle, the Dynamic Lighting registry A/B, andSmuProbe --igpu --scan/--prereq/--shamplus the loaded outcome runs.Two red CI jobs, fixed here
Both were already failing on
mainat 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 randotnet test OmenCore.slnonubuntu-latest, and the solution containsOmenCoreAppandOmenCoreApp.Tests, which targetnet8.0-windows10.0.19041.0and cannot be restored on Linux at all:NETSDK1100, before a single test is discovered.EnableWindowsTargetingwould 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, whichmainadded in 64ebae0 and which nothing had pointed a job at yet. The Windows suite is already covered bybuild-and-testinci.yml.GameLibraryViewModelTestsfailed on the Windows runner and passes on a developer machine, which is the tell. It assertsIsScanningis 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, theTask.WhenAllover them is already complete, theawaitresumes synchronously, and the whole ofScanLibraryAsync(finallyblock included) runs inline inside the constructor;IsScanningis back tofalsebefore it returns. The test now subscribes toScanCompletedbefore 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
0x0Ais 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.0x0Adoes not disturb the list — not that unflashed entries come back after a cold boot. Nothing in the UI claims otherwise.🤖 Generated with Claude Code