Reverse Engineering My AULA F75 Keyboard on Linux (Ongoing)

Reverse engineering a budget mechanical keyboard to get full RGB and macro support on Linux.

March 30, 2026·updated June 12, 2026·8 min read··view raw

Why I'm Doing ThisCopied!

I run Fedora 43 with Hyprland. My keyboard is an AULA F75. The software to configure it, RGB effects, speed, macros, is Windows-only. There is no Linux support, no open protocol spec, nothing.

The keyboard has a "Ripples_shining" effect I like. It lights up from a keypress and spreads outward like a wave. The problem is the speed slider only goes from 0 to 4, and even at maximum it feels sluggish. I want faster. I want to push values the UI doesn't allow.

This post is not a success story. I haven't cracked it yet. But I've learned a lot about how this keyboard talks to the OS, and I'm documenting everything here so I don't lose the thread, and in case someone else is doing the same thing.


The SetupCopied!

  • Fedora 43, Hyprland, kernel 6.x
  • AULA F75 (VendorID: 258a, ProductID: 010c)
  • Wine 11.0 Staging
  • Connected via USB-C (wired mode only, wireless is a separate problem)

Step 1: Getting the Software Running Under WineCopied!

The AULA software needs raw HID access via /dev/hidraw. By default, the hid-generic kernel driver claims the device and blocks this. The fix is a udev rule.

Find your keyboard's IDs:

lsusb
# Bus 001 Device 003: ID 258a:010c BY Tech Gaming Keyboard

Create the rule:

sudo nano /etc/udev/rules.d/99-aula-f75.rules
SUBSYSTEM=="usb", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="010c", MODE="0666"
KERNEL=="hidraw*", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="010c", MODE="0666"

Reload:

sudo udevadm control --reload-rules && sudo udevadm trigger

Verify:

ls -la /dev/hidraw*
# should show crw-rw-rw-

The F75 exposes two hidraw nodes. Confirm which belong to the keyboard:

for f in /dev/hidraw*; do
  echo -n "$f: "
  udevadm info --attribute-walk --name=$f | grep -E "idVendor|idProduct" | head -2
done

Mine: /dev/hidraw1 and /dev/hidraw2 both show 258a:010c.

Install Wine, reset the prefix if needed, and run:

rm -rf ~/.wine  # if you get kernel32.dll errors
WINEARCH=win64 wineboot --init
wine ~/Downloads/AULA_F75_Setup.exe
wine ~/.wine/drive_c/Program\ Files\ \(x86\)/AULA/F75/OemDrv.exe

It opens. The keyboard is detected. Effects apply. Wine's hidraw integration works fine for this, this part is solved.


Step 2: Capturing the PacketsCopied!

Plan: use usbmon and tshark to capture USB traffic while moving the speed slider.

sudo modprobe usbmon
sudo tshark -i usbmon1 -w /tmp/aula_capture.pcapng
# move slider in AULA software, Ctrl+C

Extract HID payloads:

tshark -r /tmp/aula_capture.pcapng \
  -Y "usb.device_address == 3 && usb.transfer_type == 0x02 && usb.dst == \"1.3.0\"" \
  -V 2>/dev/null | grep "Data Fragment"

This worked once. I got real SET_REPORT control transfers:

bRequest: SET_REPORT (0x09)
wValue: 0x0306  (Report ID 6, Feature type)
wLength: 520

Three packets per Apply. 520 bytes each, zero-padded. Diffing the packets between min and max speed:

# Only one byte differs:
# index 79 (0x4F): min speed = 0x40, max speed = 0x00

Speed is a single byte. 0x40 at slider position 0 (slowest), 0x00 at position 4 (fastest). Linear steps of 0x10. Lower value = faster. The UI is already sending the minimum possible value (0x00) at its maximum setting, so there's nothing to unlock by going below, unless the firmware wraps or interprets 0x00 as "default" rather than true zero.

The problem: every subsequent capture returned empty output. Wine's HID backend is not consistent. Sometimes it routes through the USB stack (visible to usbmon), sometimes it bypasses it entirely and writes directly to hidraw. You can't rely on usbmon alone.


Step 3: What the HID Descriptor SaysCopied!

sudo usbhid-dump -a 1:3 2>/dev/null

Relevant section for Report ID 6:

85 06          → Report ID = 6
75 08          → field size = 8 bits
96 07 02       → report count = 519 bytes
B1 02          → Feature report

Device expects: Report ID 6, Feature type, 519 bytes payload = 520 bytes total. Matches the control transfer capture.


Step 4: Trying to Send Packets DirectlyCopied!

The correct ioctl for HID Feature Reports on Linux:

# _IOC(READ|WRITE, 'H', 0x06, 520)
HIDIOCSFEATURE = 0xC2084806

Script that sends all three captured packets in sequence:

import fcntl, time

HIDIOCSFEATURE = 0xC2084806

PKT1 = bytes.fromhex("0684000001008000...")  # init/handshake
PKT2 = bytes.fromhex("0604000001008000...")  # effect config
PKT3 = bytes.fromhex("060a000001000002...")  # color data

def pad(pkt):
    return bytes(pkt) + b'\x00' * (520 - len(pkt))

def send(path, pkt):
    with open(path, 'rb+', buffering=0) as f:
        fcntl.ioctl(f, HIDIOCSFEATURE, bytearray(pad(pkt)))

for speed in [0x40, 0x20, 0x00, 0x01, 0x05, 0xff]:
    pkt2 = bytearray(PKT2)
    pkt2[79] = speed
    send('/dev/hidraw2', PKT1)
    send('/dev/hidraw2', bytes(pkt2))
    send('/dev/hidraw2', PKT3)
    time.sleep(4)

Result: ioctl succeeded, something happened on the keyboard, but it was the wrong effect. The ripple changed to something else entirely and speed was identical across all values.

The packets from capture session 1 were probably not from Ripples_shining, I don't know what effect was active during that capture. And even if they were, something about the packet content or sequence is wrong.


Step 5: strace, Where Things Got ComplicatedCopied!

Since usbmon was unreliable, next approach: strace to intercept the actual syscalls Wine makes when writing to hidraw.

Find which Wine process holds the hidraw fds:

for pid in $(ps aux | grep -i wine | grep -v grep | awk '{print $2}'); do
  echo "=== PID $pid ==="
  ls -la /proc/$pid/fd 2>/dev/null | grep hidraw
done

Found it: the winedevice process holds:

  • fd 51 → /dev/hidraw1
  • fd 52 → /dev/hidraw2

Attaching strace on write to those fds returned nothing. Tried following threads with -f, tried all fd numbers, still nothing useful coming through.

The discovery (from continuing the investigation separately) is that Wine uses writev() with internal pipes, not direct write() to the hidraw fd. The real 520-byte HID payload appears inside a writev iov buffer:

writev(...)
  iov_len=520  ← this is the real device packet

Other sizes (64, 40, 12 bytes) in the strace output are Wine's internal message framing, not device data. The filter was wrong the whole time.


Step 6: What the Real Packets Look LikeCopied!

Using writev interception, two real 520-byte payloads were extracted, pkt_0.bin (low speed) and pkt_1.bin (high speed). Diffing them:

Only ONE byte changes:
offset 109: 0x47 → 0x07

No checksum changes. No header changes. Just that one byte inside what appears to be a repeated pattern block:

...09 47 09 47 09 47...

...09 07 09 47 09 47...

So the speed value isn't a standalone "speed field", it's embedded inside a table or pattern structure. Modifying only offset 109 and sending the packet had no visible effect on the keyboard. The firmware either ignores isolated single-byte changes, or requires multiple entries in the pattern to be updated, or needs a separate commit packet after the config.


Where Things StandCopied!

ThingStatus
Wine runs the software on Fedora
udev rule for hidraw access
HID descriptor parsed✅ Report ID 6, 520 bytes
Speed byte identified (index 79 from usbmon, index 109 from writev)⚠️ conflicting
Reliable packet capture method
Sending correct effect-specific packets
Speed values beyond UI limits tested

The two different byte indices (79 from the usbmon capture, 109 from the writev capture) are suspicious. They might be from different packet types, or the usbmon capture was from a different effect entirely. I need a clean, confirmed capture of Ripples_shining specifically at all five speed positions.


UPDATE (June 2026): Cracked the protocol, and learned why the speed cap can't be beatenCopied!

Coming back to this months later, I got a lot further. I'll lead with the punchline because it's not the one I wanted: the speed cap is real and lives in the keyboard's firmware. You cannot beat it by sending a bigger number. But I now have full native-Linux control of the keyboard with no Wine at all, and I understand the actual path to a faster ripple. Here's everything.

The "conflicting offset" mystery was embarrassingly simpleCopied!

Offsets 79 and 109 were never in conflict. The pkt_*.bin files I'd extracted from strace had a 30-byte text prefix glued to the front — the literal strace filename /tmp/aula_trace_final2.412637:. 109 − 30 = 79. Same byte the whole time. Two captures that agreed perfectly, and I'd spent days suspecting they came from different effects. Always check your extraction boundaries.

A few more things from the old post turned out to be mislabeled:

  • The 0684... packet I'd called the "init/handshake" is actually a read request (command byte 0x84, bit 7 = read).
  • The 520-byte packet "with full content" I saw in writev was the device's response to a read, flowing back through Wine's pipe — not something the app was sending.
  • "Modifying one byte did nothing" was because my base packet hex was corrupted/truncated, so every write was replacing the whole config region with garbage. There were even stray bytes left in the live effect table from those bad March writes, still there in June.

The protocolCopied!

It's HID feature reports, Report ID 6, 520 bytes, on the vendor interface (the hidraw node whose report descriptor contains 85 06 ... 96 07 02 ... b1 02). Header:

06 CMD A0 A1 A2 A3 L0 L1  <data...>
   CMD: bit 7 = read. 0x04 = write config, 0x84 = read config,
        0x0a = write per-key color table, 0x8a = read it
   A*:  address (00 00 01 00 = main config region)
   L*:  length, little-endian (0x0080 config, 0x0200 color table)

The missing piece for reading was the sequence: SET_FEATURE the read request, then GET_FEATURE report 6 returns the data. A bare GET returns nothing. Once I had that, I could dump and diff the keyboard's live state at will — no more guessing from captures.

Decoding the effect table (on real hardware this time)Copied!

The config region has a per-effect table of (a, b) pairs. By changing one byte at a time and watching the keyboard, I pinned them down:

  • a = brightness (0x09 full, 0x01 visibly dim)
  • b = (speed << 4) | color — high nibble speed, low nibble color preset

Ripple lives at offsets 78–79. And here's the wall: the firmware clamps speed at 4. Writing 0x57 (speed 5) or 0xF7 (speed 15) looks identical to 0x47 (speed 4). The UI stops at 4 because 4 is the hardware maximum. There is no magic value below or above the range that unlocks anything.

Why the cap exists — and it's not going anywhereCopied!

I dumped the keyboard's memory with the read commands and hit the answer. Read commands 0x88, 0x89, 0x9d, 0x9e, 0x9f return 8051 microcontroller machine code. This is a SinoWealth-family chip, and the effects — including Ripples_shining — are rendered inside that firmware. The speed field is just an index into a fixed animation table baked into the chip. You'd have to reflash custom firmware to change the animation engine itself. The number-tweaking approach was doomed from the start.

The real path to a faster ripple: render it on the hostCopied!

This is how the flashier keyboards actually do it. They don't send a bigger speed value — they put the board into a static / custom per-key mode where it just displays whatever color each key is told to be, then stream color frames from the PC 30–60 times a second, computing the animation in software. At that point "speed" is just a variable in your code.

And this keyboard supports it. There's a per-key color table: 0x0a to write, 0x8a to read, 512 bytes = 4 bytes per key (RR GG BB 00), 128 keys. I confirmed my writes land in it — read them straight back.

Where I'm actually stuck nowCopied!

One wall left, and it's a different wall than before: writing the color table stores the colors but doesn't display them. The LEDs only update when the keyboard is in custom/DIY mode and something triggers a refresh. The AULA app clearly sends a "commit/latch" command after writing colors that I haven't captured yet. (This is the old "missing commit packet" hypothesis from above — turns out it was right, just for the color path instead of the config path.)

Finding that commit command needs a clean strace of the app, and that fought me hard:

  • ptrace_scope was 0, but attaching still failed with Operation not permitted even under sudo.
  • The cause: a stale strace from an earlier debugging attempt was still attached to the Wine process (TracerPid in /proc/<pid>/status pointed at it). A process can only have one tracer. Killing the orphan released the lock.

Lesson worth its own line: if sudo strace says "Operation not permitted," check grep TracerPid /proc/<pid>/status before assuming it's a security policy. It's often just a tracer you forgot to detach.

What I builtCopied!

A small native tool, aula_f75.py, that needs no Wine at all:

python3 aula_f75.py dump -o snapshot.bin   # read live config
python3 aula_f75.py speed 0x47             # set ripple speed (4 = max)
python3 aula_f75.py restore snapshot.bin   # roll back

I can read and write the full config, set effects, brightness, color, and write the per-key color table. The only thing between me and an arbitrarily-fast host-rendered ripple is that one commit command — which is the very next thing to capture now that strace works.

A note for anyone else: OpenRGB has an open, unimplemented support request for this exact device (258a:010c). Nobody's mapped the protocol there yet. If you're reading this and want it in OpenRGB, the notes above are most of the way.


Current HypothesesCopied!

Speed is probably not a simple single-byte field. Leading theories:

  1. Pattern table encoding — the speed value is repeated across multiple entries in a lookup table. All entries need to change together.
  2. Derived encoding — the byte value is computed from the speed setting rather than stored directly. Need to understand the formula.
  3. Missing commit packet — there's a separate "apply" packet that triggers the firmware to use the new config. Without it, the keyboard ignores the data.
  4. Wrong interface — the config might need to go to hidraw1, not hidraw2, or through a different report ID entirely.

What's NextCopied!

The number-tweaking plan is dead (the firmware caps it), so the new plan is the host-rendering one:

  1. Capture the AULA app's commit/latch command with strace now that ptrace works — write color table, click apply, diff the traffic for the short packet sent after the 0x0a writes.
  2. Reproduce that commit from aula_f75.py and confirm the LEDs update live.
  3. Wire up an evdev keypress listener + a frame loop: on each keypress, expand a ring of lit keys outward, repaint the whole table at ~60fps. Speed becomes a variable — finally faster than the firmware allows.
  4. Bonus: decode the remaining config fields and contribute the protocol to OpenRGB's open request for this device.

The udev rule, Wine setup, full config read/write, effect decoding, and per-key color writes are all done and reproducible. The single remaining unknown is the color-table commit command.

Will update this when I get further.


Running the Software (TL;DR)Copied!

If you just want to use the AULA software on Fedora with Wine:

# udev rule (one-time setup)
echo 'SUBSYSTEM=="usb", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="010c", MODE="0666"
KERNEL=="hidraw*", ATTRS{idVendor}=="258a", ATTRS{idProduct}=="010c", MODE="0666"' \
  | sudo tee /etc/udev/rules.d/99-aula-f75.rules
sudo udevadm control --reload-rules && sudo udevadm trigger

# run the software
wine ~/.wine/drive_c/Program\ Files\ \(x86\)/AULA/F75/OemDrv.exe

Tested on Fedora 43, Wine 11.0 Staging, wired USB-C. Wireless mode needs a separate udev rule for the dongle's VID/PID.

Comments