Starting from not knowing
Fresh Mageia 10 install on an MSI Stealth 15M B12UE. Everything worked except the special keys on the function row. In Windows with MSI's drivers, F7 cycles the performance profile — performance, balanced, power save — with an overlay showing where you landed. Fn+F7 gives a classic F7.
On Mageia, F7 opened Plasma's System Settings.
I should be upfront about what I am not. I am not a firmware or hardware person. The closest I have come is writing small firmware for PIC microcontrollers, a long time ago, and what reverse engineering I do in my spare time is on x86 — userspace, not embedded controllers. Going in, I did not know what an ACPI DSDT looked like, and I had never touched a WMI GUID.
This is not my first fight with a laptop that would not behave under Linux, though. Twenty years ago my first one was a very cheap machine — a Hyundai rebrand of an Averatec, an HN21 or something close to it — and Linux did not support it at all. The fan in particular had no driver, no vendor tool, and nothing resembling documentation anywhere on the internet.
So I did the crude thing. I wrote a Perl script that sat in a loop reading the
CPU temperature out of /proc, and when it crossed a threshold it wrote to
/proc to kick the fan on. No reverse engineering, no register map, no
understanding of what the hardware was actually doing — just poll a number,
react to it, and keep the machine from cooking itself. It worked, in the sense
that the laptop survived.
The contrast with this project is the point. Back then there was nothing to read, so brute pragmatism was the only option available. This time the information existed, written down by people who had done the work properly, and my job was mostly to find it and not misread it. That is a much better position to start from, and it is worth saying that the difference is the documentation, not the person.
The other thing that made this possible
I used AI assistance throughout — Claude Sonnet 5 through Claude Code, and DeepSeek Pro. I want to state that plainly rather than let it sit implied between the lines, and I will be stating it in the upstream submission too.
The honest accounting: my last kernel module before this was a study exercise after reading Alessandro Rubini's Linux Device Drivers, around kernel 2.4, roughly twenty-five years ago. Everything since then has been userspace. On my own, wiring this together — ACPI disassembly I had never done, a driver architecture I did not know, DKMS packaging conventions I had to discover — would have taken me a week at absolute minimum, and realistically longer, because most of that week would have gone on finding out what I did not know to look for.
What the assistance was genuinely good at was navigation: pointing at the right file, decoding an ASL construct, noticing that 25 existing configurations formed two families. What it was not good at was being right about things it had not checked. It told me confidently — twice, in writing — things that turned out to be false: that the in-kernel driver exposes performance modes (it does not), and that the closest template was a machine that turns out to be a different EC generation. Both were caught by reading the actual source instead of trusting the summary.
That is the shape of it. Faster, not automatic. The verification still has to happen, and on hardware where a wrong write can brick an embedded controller, the verification is the work.
What follows is not a reverse-engineering story. It is a story about reading other people's work carefully. Almost everything difficult here had already been solved by someone more experienced, and my actual contribution was small: notice which machine I had, follow the procedure they documented, and be careful about the two or three places where a wrong guess would have cost me hardware.
The people whose work this rests on
Worth naming before anything else, because the rest of this post is mostly me following their trail:
- The msi-ec project — Jose Angel Pastrana, Aakash Singh and Nikita Kravets, plus everyone who has submitted a laptop. Twenty-five-odd EC configurations, a driver that turns them into sysfs files, and — the part that actually mattered — a written guide explaining how to work out the values for a new machine.
- Armin Wolf, who wrote
msi-wmi-platform, the in-kernel driver that exposes MSI's WMI methods. - Pali Rohár, whose
bmfdecdecodes the WMI metadata blobs that laptop firmware carries.
The msi-ec project has a file called docs/device_support_guide.md. It tells
you, step by step, how to identify the registers for an unsupported laptop. I
did what it said. That is genuinely most of the story.
First: is it even the driver?
The instinct — mine, anyway — was to assume this needed deep work, and to start digging. That instinct cost me a day, and I want to be honest about the order things actually happened rather than present a tidy version.
What I checked first was which drivers were loaded:
$ lsmod | grep -iE 'msi|wmi'
msi_wmi 16384 0
msi_wmi_platform 20480 0
wmi_bmof 12288 0
Three MSI drivers present. But msi-ec — the one that would expose performance
modes — was missing. Pulling strings out of the compiled module showed why:
$ strings msi-ec.ko | grep -c EMS1
25
$ strings msi-ec.ko | grep 15B1
(nothing)
The driver has a hardcoded list of 25 supported EC firmware versions and refuses anything else:
msi_ec: Firmware version is not supported: '%s'
No override parameter. That is a clear, well-defined problem: my laptop needs an entry in that list. So I went off to work out what the entry should contain, and spent a day on ACPI tables before doing the one thing that would have reframed the whole exercise.
What the ACPI tables told me
The msi-ec guide recommends doing this on Windows with RWEverything, watching EC memory change as you toggle settings in MSI Center. I had wiped Windows, so I took the Linux path the guide describes as "very limited compared to the Windows method". Fair warning, and accurate.
sudo acpidump -b
iasl -d dsdt.dat # 92,627 lines
I did not know how to read ASL when I started. It is less intimidating than it looks — it is a declarative description of devices and the methods the OS can call on them. The embedded controller section gives its own field names:
Device (EC)
{
Name (_HID, EisaId ("PNP0C09"))
OperationRegion (EC, EmbeddedControl, Zero, 0x0100)
Field (EC, ByteAcc, NoLock, Preserve)
{
...
CPUT, 8, // offset 0x68 -- CPU temperature
SYSM, 2, // offset 0xd2
PSNM, 7, // offset 0xeb
MODS, 1, // offset 0xec bit 0
KBBL, 1, // bit 1 -- keyboard backlight
SCIC, 8, // offset 0xed
}
}
256 bytes of embedded controller with the manufacturer's own names attached.
SYSM at 0xd2 looked like a system-mode register. That turned out to be
right, but I want to be clear that I did not deduce it — I noticed a
suggestive name and later checked it against what the msi-ec project already
knew.
The hotkey handlers were more interesting. Every _Qxx method — the ones the
EC calls to signal an event — looks like this:
Method (_Q70, 0, NotSerialized)
{
SCIC = 0x70
DBG8 = 0x70
}
Write an ID to a register and stop. No action taken. Thirty of them. One is
different — _QC0 raises a WMI event, which a handler decodes, reading an
event code from 0xd8 and a payload from one of several registers, then
acknowledging by zeroing the code.
The conclusion I drew from this was the useful part: the firmware is fine.
MSI's design is that the EC signals and the operating system decides what to
do. On Windows that is MSI Center. On Linux it is supposed to be msi-ec. The
gap was never in the firmware.
I also decoded the WMI metadata blobs with bmfdec, which ruled out three
unclaimed WMI GUIDs as WiFi SAR sensors and a debug event injector. Twenty
minutes to eliminate three dead ends. Negative results still count.
The thing I should have done first
A day in, I finally ran the obvious command:
$ sudo evtest /dev/input/event6
Event: type 4 (EV_MSC), code 4 (MSC_SCAN), value 91
Event: type 1 (EV_KEY), code 171 (KEY_CONFIG), value 1
F7 was producing a keycode. KEY_CONFIG — the one desktops bind to their
settings application. And it came from /dev/input/event6, the AT keyboard,
not from any MSI WMI device.
So the key had never been going through the WMI event path I had spent a day mapping. It was an ordinary scancode, and something in userspace was translating it. One grep found it, in a file that ships with systemd:
/usr/lib/udev/hwdb.d/60-keyboard.hwdb:1574
evdev:atkbd:dmi:bvn*:bvr*:bd*:svnMicro-Star*:pn*:*
KEYBOARD_KEY_91=config # MSIControl Center
pn* matches every MSI laptop. Scancode 0x91 becomes KEY_CONFIG on all of
them, because on some older model it launched MSI Control Center.
The same file disagrees with itself 45 lines later:
evdev:atkbd:dmi:...:pn*Prestige*:*
evdev:atkbd:dmi:...:pn*Modern*:*
KEYBOARD_KEY_91=prog1 # Fn+F7 Creation Center, sometime F7
Same scancode, opposite meaning, and a comment — "sometime F7" — showing whoever wrote it already knew the catch-all was too broad.
The fix is two lines, matched on the exact product name:
evdev:atkbd:dmi:bvn*:bvr*:bd*:svnMicro-StarInternationalCo.,Ltd.:pnStealth15MB12UE:*
KEYBOARD_KEY_91=prog1
sudo systemd-hwdb update
sudo udevadm trigger --sysname-match="event*" --action=change
F7 stopped opening System Settings. Fn+F7 was never affected — it sends a different scancode entirely.
Half the problem was a wildcard in a text file, and no firmware knowledge was
required to find it. evtest before acpidump would have saved me a day. I
had decided what kind of problem this was before I had any evidence.
The half that did need the homework
F7 now did nothing, which is better than doing the wrong thing but not the goal. Something still had to write the EC.
Here the msi-ec guide earned its place. It explains that the driver matches on the EC firmware version — and the crucial detail, which I would not have guessed, is that this is not the BIOS version:
EC firmware @ 0xa0 = '15B1EMS1.104' <- what msi-ec matches
DMI BIOS version = 'E15B1IMS.108' <- what dmidecode reports
Different string, different format. If I had taken the BIOS version from the boot screen — the obvious move — the config would have silently never matched, and I would have spent an evening debugging a driver that was working correctly.
The guide also documents the firmware naming scheme, which is how I learned that the digit before the version is a board generation and that firmware is incompatible across generations. That turned out to matter.
Pattern matching, not deduction
I did not work out the register map from first principles. I compared the 25 configurations already in the driver and looked for the group my machine belonged to. They fall into two clear families:
| Generation | shift_mode address | Modes offered |
|---|---|---|
| G1 (WMI1) | 0xf2 |
eco / comfort / sport / turbo |
| G2 (WMI2) | 0xd2 |
turbo / eco / comfort |
My laptop has a plausible value at 0xd2 and junk at 0xf2, so: G2, three
modes. That also settled a question I could not answer by reading alone —
super_battery is at 0xe5 in some configs and 0xeb in others, and the G2
answer is 0xeb.
I got the template wrong on the first attempt. The Pulse GL66 12UEK is the only other 12th-generation Alder Lake machine in the driver, so I assumed it was the closest relative. It is G1. Same CPU generation, different EC generation — exactly the distinction the guide had explained and I had skimmed past. The right template was a Prestige 14 and Cyborg 14 config, which matches my machine register for register.
Checking before writing
This is the one part where being cautious was genuinely my own contribution, and it is the part I would tell anyone else to copy.
The msi-ec guide has a warning in capital letters: writing wrong values to the wrong addresses will damage your hardware or brick your device, and resetting the EC will not undo it. On this chassis, disconnecting the battery means taking the back panel off.
So before writing anything, I wrote a small read-only kernel module that dumps the candidate registers and decodes them against the values msi-ec expects:
static const char *shift_mode_name(u8 v)
{
switch (v) {
case 0xc0: return "sport";
case 0xc1: return "comfort";
case 0xc2: return "eco";
case 0xc4: return "turbo";
default: return NULL;
}
}
If my guesses were wrong, the registers would hold arbitrary bytes. They did not:
0xd2 SYSM / shift_mode? = 0xc1 <<< comfort >>>
0xd4 SYSE / fan_mode? = 0x0d <<< auto >>>
0xd3 kbd_bl state = 0x83 (base 0x80 + level 3)
0x98 cooler_boost = 0x02 (bit 7 clear)
0xf2 older-group shift = 0x70 (junk -- confirms not G1)
Two registers landing on valid sentinel values that are identical across all 25 existing configurations is not a coincidence. That is the moment I believed the map was right — not when I read the ASL, but when two independent sources agreed.
The first write
On AC power, nothing unsaved, one register, one bit away from its current value, original noted for restoration:
$ cat shift_mode # comfort (0xc1)
$ echo eco > shift_mode # (0xc2)
$ cat shift_mode # eco
And the fan audibly slowed. The EC had not just stored a byte, it had applied
the profile. Restoring comfort brought it back.
Two packaging traps
Nothing to do with firmware, and both cost me more time than the EC work.
depmod does not look in updates/
I put the module in /lib/modules/$(uname -r)/updates/, ran depmod -a, and
got:
modprobe: ERROR: could not insert 'msi_ec': Operation not supported
Operation not supported is -EOPNOTSUPP — the stock driver's
unsupported-firmware error. modprobe had loaded the kernel's module, not mine,
because Mageia ships:
$ cat /etc/depmod.d/*.conf
search dkms dkms-binary built-in
No updates. The installer now writes a matching search order and, more
usefully, verifies the resolution before loading rather than trusting it:
resolved=$(modinfo -F filename msi-ec)
if [[ $resolved != "$KMOD_DIR/msi-ec.ko" ]]; then
warn "modprobe would load '$resolved' instead of our build"
exit 1
fi
DKMS uses a third path again
Packaging it properly, I nearly shipped an extra/msi-ec override — wrong in a
new way. Mageia's DKMS has its own case in /usr/sbin/dkms:
Mageia*)
prefix=${binary:+$dkms_binary_location}
prefix=${prefix:-$dkms_source_location}
with dkms_source_location="/dkms". Modules land in /lib/modules/<ver>/dkms/,
which the default search order already covers. An extra/ override would have
shadowed the real module with nothing and reproduced the identical error
message I had just finished fixing.
Three module paths, three behaviours, one error string.
The overlay was already installed
The last piece was the Windows-style overlay. I went looking for something to install and found it already running:
$ qdbus org.kde.plasmashell /org/kde/osdService
method void org.kde.osdService.showText(QString icon, QString text)
The same OSD Plasma uses for volume and brightness. No new package:
qdbus org.kde.plasmashell /org/kde/osdService \
org.kde.osdService.showText "battery-profile-performance" "Performance"
The handler script reads the mode list from available_shift_modes instead of
hardcoding it, cycles to the next, then reads the value back and only shows the
overlay if the EC accepted it. An overlay that reports your own write rather
than the hardware's state is an overlay that will eventually lie to you.
Where it ended up
| Piece | Result |
|---|---|
| F7 opens System Settings | two-line hwdb override |
| F7 switches profiles | msi-ec config for EC 15B1EMS1.104 |
| Fn+F7 stays classic F7 | never touched |
| Overlay | Plasma's own osdService |
| Persistence | dkms-msi-ec RPM, rpmlint clean |
Two unrelated bugs wearing one symptom: a udev keymap matching far too broadly, and a missing table entry in a driver.
What I want to leave here is not a technique. It is that the barrier to contributing hardware support is lower than it looks, provided you are willing to read what the people before you wrote down. The msi-ec project has a guide explaining exactly how to identify these values. The kernel's ACPI tooling is packaged in every distribution. The existing configurations in the driver are a corpus you can pattern-match against. None of that required skills I had.
Which is the real difference from that Averatec twenty years ago. The Perl script was not a worse solution because I was a worse engineer — it was the only solution available to someone with no documentation and no community to ask. This time the answer was a nine-line table entry, and it existed because a group of people decided to write down how their laptops worked and how to extend it. The work that makes hardware supportable is usually not the clever part. It is somebody documenting the boring part properly.
The AI assistance compressed a week into an afternoon, and I am not going to pretend otherwise. But it compressed the navigation, not the judgement. Every address in that table was checked against two independent sources before anything was written, and the two occasions the assistant was confidently wrong were caught by reading source code rather than trusting a summary. On hardware where a bad write is unrecoverable without a screwdriver, that distinction is not pedantry — it is the entire job. Both projects I am submitting to require disclosing AI involvement, which I think is exactly right, and I would rather say it here too than have it inferred.
What it did require was care at three specific points: not guessing the firmware string when the guide warned it was distinct, not assuming the closest CPU generation meant the closest EC generation, and not writing to an embedded controller until two independent sources agreed on the address. The rest was reading.
I got the framing wrong at the start, picked the wrong template in the middle,
and wasted a day on disassembly for a bug that evtest found in thirty
seconds. The config is nine lines of data, submitted upstream so the next
person with this laptop does not have to repeat any of it.
MSI Stealth 15M B12UE (MS-15B1), EC firmware 15B1EMS1.104, BIOS
E15B1IMS.108, Mageia 10, kernel 6.18.44. The configuration is going to
BeardOverflow/msi-ec and the hwdb over-match to systemd. Every address was read
before it was written, and exactly one register was ever written.