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 bmfdec decodes 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.