Skip to content

feat(tdp-control): support Intel handhelds - #256

Draft
srsholmes wants to merge 2 commits into
mainfrom
feat/tdp-control-intel-handhelds
Draft

srsholmes wants to merge 2 commits into
mainfrom
feat/tdp-control-intel-handhelds

Conversation

@srsholmes

@srsholmes srsholmes commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Draft — not for merge yet. The Intel paths have never run on Intel hardware. Holding this open for OneXPlayer community testing before it lands.

Split 3 of 3 out of #253, and the substantial one.

The problem

TDP control did nothing on an Intel handheld. The plugin drives wattage through ryzenadj, which writes the AMD SMU mailbox over MMIO — on an Intel CPU that's a no-op then an error — so the whole feature was dead on Arc G3 / Xe3-class devices.

What's added

Three new pure modules, each unit-tested:

  • lib/firmware-attributes.ts — discovers power rails by attribute name across every driver in /sys/class/firmware-attributes. A handheld whose vendor driver implements the interface publishes the exact envelope its firmware accepts (min_value/max_value on PL1): per-unit exact, and the only correct source for a device that shipped after our device table was last touched. applyFirmwareRange() folds that envelope into a device match — a known row keeps its hand-tuned battery cap and presets clamped into the firmware interval, while a fallback match derives presets from the range.
  • lib/rapl.ts — Intel RAPL powercap, resolving constraints by name rather than by slot index, and capturing both the sustained and boost limits.
  • lib/gpu.ts — Intel GPU frequency control via the xe/i915 sysfs knobs, alongside the existing AMD path.

AMD is deliberately unchanged

This is the whole reason #253 was split. Generic rail discovery runs only on a positively identified Intel CPU — not merely "not AMD", so an unreadable /proc/cpuinfo keeps every machine on the old detection order. ROG Ally and Legion Go keep their DMI-matched paths, probed in the same order and written in the same milliwatt unit; ryzenadj keeps its precedence everywhere else.

Letting generic discovery outrank ryzenadj on all AMD devices is a real improvement, but it changes behaviour on hardware neither of us can test. That idea is written up in #253 rather than riding along with Intel support.

There are two deliberate differences on the DMI-matched paths, both stated here rather than left implicit:

  1. They carry scaleKnown: false, so a write the firmware rejects is retried once in the other unit and latches whichever is accepted, rather than throwing. That can only fire where the old unconditional watts * 1000 already failed outright — which appears to be the state a modern-kernel ROG Ally is in today, since asus-armoury's ppt rails take watts and we send milliwatts. On a healthy write nothing changes. If the flip is also rejected, the original error propagates.
  2. A failed boost rail (fppt/sppt) write is warned and skipped instead of thrown. MSI's driver exposes only PL1 + PL2, so the old unconditional write of all three failed outright on anything without a PL3. Only observable where a write already fails.

Review fixes

A review pass found four things, now fixed:

  • discoverPowerRails matched on the attribute directory name and never read current_value, so a stub or read-only driver publishing an empty ppt_pl1_spl/ would latch method="wmi" and starve the Intel RAPL fallback — losing TDP control on a device that works on main.
  • The unit-flip retry left the boost rails written in the unit it had just disproved, so PL1 landed correctly while PL2/PL3 kept firmware defaults for that apply.
  • Generic discovery is gated on a positive GenuineIntel rather than !isAmdCpu(). Both probes fail closed, so an unreadable /proc/cpuinfo on an AMD ROG Ally would otherwise have routed it into the new path — precisely the AMD change this PR promises not to make.
  • resolveZoneConstraints fell back to slot 0 only when nothing was named, so a zone naming peak_power/short_term but no long_term resolved to null where main took constraint_0. It now falls back whenever long_term is missing, but never to the file already identified as short_term.

Testing

Backend 3260 pass, UI 532 pass, typecheck clean. The pure helpers are covered: firmware-attributes parsing, RAPL constraint resolution, GPU frequency writes, and applyFirmwareRange.

Not verified on hardware — I have no Intel handheld. The Intel paths are the ones wanting a real device, so OneXPlayer community testing would land here.

Related

No open issue tracks Intel handheld support — this came out of the work in #253.

TDP control did nothing on an Intel handheld. The plugin drives wattage
through ryzenadj, which writes the AMD SMU mailbox over MMIO — on an
Intel CPU that's a no-op then an error — so the whole feature was dead
on Arc G3 / Xe3-class devices.

Three new pure modules, each unit-tested:

- lib/firmware-attributes.ts — discovers power rails by attribute name
  across every driver in /sys/class/firmware-attributes. A handheld whose
  vendor driver implements the interface publishes the exact envelope its
  firmware accepts (min_value/max_value on PL1), which is per-unit exact
  and works for a device that shipped after our table was last touched.
  applyFirmwareRange() folds that envelope into a device match: a known
  row keeps its hand-tuned battery cap and presets, clamped into the
  firmware interval; a fallback match derives presets from the range.
- lib/rapl.ts — Intel RAPL powercap, resolving constraints by name rather
  than slot index and capturing both sustained and boost limits.
- lib/gpu.ts — Intel GPU frequency control via the xe/i915 sysfs knobs,
  alongside the existing AMD path.

AMD IS DELIBERATELY UNCHANGED. Generic rail discovery runs only when the
CPU isn't AMD; ROG Ally and Legion Go keep their DMI-matched paths, in
the same order, written in the same milliwatt unit, and ryzenadj keeps
its precedence everywhere else. Letting discovery outrank ryzenadj on all
AMD devices is a real improvement but it changes behaviour on hardware we
can't test, so it stays a separate PR (#253) rather than riding along
with Intel support.

One deliberate difference on the DMI-matched paths: they carry
scaleKnown: false, so a write the firmware rejects is retried once in the
other unit and latches whichever is accepted, instead of throwing. That
only fires where the old unconditional `watts * 1000` already failed
outright — which is the state a modern-kernel ROG Ally appears to be in,
since asus-armoury's ppt rails take watts and we send milliwatts.

Split out of #253. Backend 3257 pass, UI 532 pass, typecheck clean.
Untested on Intel hardware — I don't have an Intel handheld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8sNiuexULKVgN3TyqAWsA
discoverPowerRails matched on the attribute *directory* name and never
read current_value, so a stub or read-only driver publishing an empty
ppt_pl1_spl/ would latch method="wmi" and starve the RAPL fallback — a
device that works on main would lose TDP control entirely. Read the rail
before claiming it, the way the DMI-matched probes already do.

The unit-flip retry left the boost rails written in the unit it had just
disproved: PL1 landed correctly while PL2/PL3 kept firmware defaults for
that apply. Re-write them once the scale has latched.

Generic discovery is now gated on a positive GenuineIntel rather than
!isAmdCpu(). Both probes fail closed, so an unreadable /proc/cpuinfo on
an AMD ROG Ally would have routed it into the new discovery path —
exactly the AMD behaviour change this branch promises not to make.

resolveZoneConstraints fell back to slot 0 only when nothing was named at
all, so a zone naming peak_power/short_term but no long_term resolved to
null where main took constraint_0. Fall back whenever long_term is
missing — but never to the file already identified as short_term, since
capping boost while believing we capped sustained draw is worse than
reporting no RAPL control.

Boost-rail write failures are warned-and-continued rather than thrown,
which is a second (intended) difference from main on the WMI path: MSI's
driver exposes only PL1 + PL2, and the old unconditional write of all
three failed outright on anything without a PL3. Now stated in the code
and in the PR description instead of being implicit.

5 new tests. Backend 3260 pass, typecheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K8sNiuexULKVgN3TyqAWsA
@srsholmes srsholmes changed the title feat(tdp-control): TDP and GPU control on Intel handhelds feat(tdp-control): support Intel handhelds Aug 17, 2026
@srsholmes
srsholmes marked this pull request as draft August 17, 2026 11:03
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