Skip to content

Instantly share code, notes, and snippets.

@w568w
Last active August 13, 2026 10:22
Show Gist options
  • Select an option

  • Save w568w/957976b59906e0ce5d6c13ad342e1593 to your computer and use it in GitHub Desktop.

Select an option

Save w568w/957976b59906e0ce5d6c13ad342e1593 to your computer and use it in GitHub Desktop.
My battery charging limit fix for MECHREVO Wujie 14XA

My Battery Charging Limit Fix for the MECHREVO Wujie 14XA

This write-up documents how I fixed the Battery Charging Limit function on the MECHREVO Wujie 14XA (also known as the MechRevo Forza 14X). I did this with the help of Codex and GPT-5.6 Sol.

1. What Is Wrong with the BIOS/EC Firmware?

A bug in the charge-limit control logic was partially fixed in EC firmware version 2.08.

However, even with the "latest" firmware, the charging limit still often fails to take effect on many models. EC versions 2.12 and 2.08 are bundled with BIOS versions N.1.14MRO50 and N.1.14MRO19, respectively.

With Codex's help, I disassembled the EC firmware images and identified several important addresses in the controller:

Address Meaning Writable via ACPI Calls
0x07B9 The effective Live Limit, ranging from 1 to 100 percent Yes
0x087F The Stored Limit No; it is above 0x07FF
0x0742[2] The enable gate for the charging limit Yes
0x07C3 and 0x0770 State values that may have various effects, which are still unknown to us Yes

It seems that we should be able to control the limit simply by writing to 0x07B9, right?

No! There is a caveat. The charging routine in the EC firmware works roughly as follows:

bool state_is(uint8_t value)
{
    return xram[0x07C3] == value ||
           xram[0x0770] == value;
}

// The following code is executed once per second.

bool limit_enabled = state_is(4) || state_is(5);

if (!limit_enabled) {
    uint8_t stored_limit = xram[0x087F] & 0x7f;

    if (stored_limit == 0 || stored_limit > 100) {
        disable_control();
        return;
    }
}

enable_control(); // Enable control using the Live Limit.
store_limit();    // Save the Live Limit as the Stored Limit.

For this laptop model, or at least for my unit, limit_enabled is unfortunately always false. The EC therefore checks stored_limit, which is also an invalid value.

As a result, the EC always executes disable_control() and returns. The Live Limit register is never read, which is why writing to 0x07B9 has no effect at all.

We seem to be stuck here because we cannot change the Stored Limit directly. Its address is greater than 0x07ff, so it cannot be written through an ACPI call.

2. The Solution Is Obvious

So, how can we fix this? The workaround is actually quite intuitive: we can change the State values!

Here is my plan:

  1. Upgrade the BIOS/EC firmware to the latest version. Either N.1.14MRO50 or N.1.14MRO19 should work.
  2. Write a value to the Live Limit. For example, set it to 60% with xram[0x07B9] <- 0x3C.
  3. Overwrite the State value with 4 by setting xram[0x07C3] <- 0x04, so that limit_enabled becomes true.
  4. Optionally, wait for a second or so to allow the EC to execute store_limit(). Then restore the original value of 0x07C3.
  5. Read 0x087F to verify whether the workaround worked.

This worked on my unit:

$ upower -i /org/freedesktop/UPower/devices/battery_BAT0
  native-path:          BAT0
  vendor:               OEM
  model:                standard
  serial:               00001
  power supply:         yes
  updated:              Wed Jul 15 02:34:42 2026 (6 seconds ago)
  has history:          yes
  has statistics:       yes
  battery
    present:             yes
    rechargeable:        yes
    state:               fully-charged
    warning-level:       none
    energy:              76.8768 Wh
    energy-empty:        0 Wh
    energy-full:         80.08 Wh
    energy-full-design:  80.08 Wh
    voltage-min-design:  15.4 V
    capacity-level:      Normal
    energy-rate:         0 W
    voltage:             16.232 V
    charge-cycles:       N/A
    percentage:          96%
    capacity:            100%
    technology:          lithium-ion
    icon-name:          'battery-full-charged-symbolic'

$ cat /sys/class/power_supply/BAT0/current_now
0

The battery is at 96% but is still reported as fully charged. The reported current is also zero.


The attachment is a simple script that automates this process. โš ๏ธ Review it carefully before running it!

3. Looking Back

This finding also explains something else. There are reports that flashing BIOS/EC firmware from another OEM with the same motherboard can resolve the issue. Those BIOS versions include a menu option for configuring the Stored Limit, which naturally breaks the deadlock.

In short, the root cause is that Mechrevo initializes the Stored Limit to an invalid value. And because of the EC's control logic, this leaves the system trapped in a state where writes to the Live Limit are effectively ignored.

Whether their engineers fully understand the hardware and firmware they are shipping is quite an interesting question.

#!/usr/bin/env bash
[[ ${1:-} == "--apply" ]] || {
echo "Refusing to write EC registers without --apply"
exit 2
}
(( EUID == 0 )) || {
echo "Must run as root"
exit 1
}
[[ -w /proc/acpi/call ]] || {
echo "acpi_call is unavailable"
exit 1
}
ec_read() {
printf '%s %s' '\_SB.INOU.ECRR' "$1" > /proc/acpi/call
tr -d '\000' < /proc/acpi/call
}
ec_write() {
printf '%s %s %s' '\_SB.INOU.ECRW' "$1" "$2" > /proc/acpi/call
tr -d '\000' < /proc/acpi/call
}
limit=60
original_state=$(ec_read 0x07c3)
other_state=$(ec_read 0x0770)
original_gate=$(( $(ec_read 0x0742) ))
[[ "$original_state" == "0x7" && "$other_state" == "0xff" ]] || {
echo "Unexpected EC state: 07C3=$original_state, 0770=$other_state"
exit 1
}
if (( original_gate & 0x04 )); then
echo "Charge-limit gate is already enabled"
exit 0
fi
# Set the host-visible live limit.
ec_write 0x07b9 "$limit" >/dev/null
# Temporarily satisfy legacy_state_is(4), allowing the EC to open the
# gate and copy 07B9 into its host-inaccessible stored limit at 087F.
trap 'ec_write 0x07c3 "$original_state" >/dev/null' EXIT
ec_write 0x07c3 0x04 >/dev/null
sleep 2
ec_write 0x07c3 "$original_state" >/dev/null
trap - EXIT
# Allow another EC update cycle, now with the legacy state restored.
sleep 2
live_limit=$(( $(ec_read 0x07b9) & 0x7f ))
final_gate=$(( $(ec_read 0x0742) ))
(( live_limit == limit && (final_gate & 0x04) != 0 )) || {
echo "Repair failed: limit=$live_limit, gate=$(printf '0x%02x' "$final_gate")"
exit 1
}
printf 'Repair succeeded: limit=%d%%, gate=0x%02x\n' \
"$live_limit" "$final_gate"
@minortex

minortex commented Jul 15, 2026

Copy link
Copy Markdown

Great work. This provide a solution for anyone who doesn't want to take risk to flash BIOS.

I update to Slimbook's latest BIOS with EC 2.12, they give me more option in BIOS, and my machine have a better energy efficiency under the same workload ( web browsing ~ 8-9w and video for 12-13w).

It's said that Mechrevo has terrible tuning, so there is no reason sticking with the stock firmware.

@minortex

Copy link
Copy Markdown

And how about the reverse engineering of the 0x7A6 register? It didn't work at all no matter how you manually change the value or use Mechrevo control center.

I set charge limit to 80% on Slimbook's BIOS, but it still kept its origin value.

@w568w

w568w commented Jul 15, 2026

Copy link
Copy Markdown
Author

And how about the reverse engineering of the 0x7A6 register? It didn't work at all no matter how you manually change the value or use Mechrevo control center.

@minortex I checked the EC source code. It turns out that the 0X07A6 actually controls the charging voltage, NOT the current / limit:

if (!(xram[0x0741] & 0x01)) xram[0x07A6] &= 0xcf; // If manual control is disabled, clear the setting

uint8_t mode = xram[0x07A6] & 0x30;

if (higher_priority_condition) // E.g., The battery is fully charged. Use the lowest possible voltage
    offset = 250;
else if (condition_200 || mode == 0x20)
    offset = 200;
else if (condition_150)
    offset = 150;
else if (condition_100 || mode == 0x10)
    offset = 100;
else
    offset = 50;

set_voltage(base_charging_voltage - cell_count * offset);

In my case, the base_charging_voltage is 17600 (mV), cell_count might be 4.

@LongSang01

Copy link
Copy Markdown

This is useful. So now I just want to say: fvck MECHREVO!

By the way, my laptop had to go back for repairs twice because of problems with the cooling fins and the screen hinges.

@minortex

minortex commented Jul 15, 2026

Copy link
Copy Markdown

@w568w
So what does the higher_priority_condition and condition_* mean? As far as I observed, my battery is always 16.396V when full charged, indicates the offset is 250mV.

I am using a second-handed machine boughted two months ago. It's been almost two years since the seller purchased it, but the battery is degraded significantly.
I used to think voltage is the most consequence of battery degradation, but seems the battery is always on 16.396V, I'm confused.

@w568w

w568w commented Jul 15, 2026

Copy link
Copy Markdown
Author

what does the higher_priority_condition and condition_* mean?

@minortex Here is what I manage to dig:

uint8_t mode = xram[0x07A6] & 0x30;

uint16_t score =
    ((uint16_t)xram[0x09C9] << 8) |
     xram[0x09CA]; // A weighted score (see below)

uint16_t battery_current =
    ((uint16_t)xram[0x0A4E] << 8) |
     xram[0x0A4F]; // Unit: mA

uint8_t temperature = xram[0x0A54]; // Unit: Celcius

uint16_t base_voltage =
    ((uint16_t)xram[0x030E] << 8) |
     xram[0x030F];

uint16_t offset_per_cell;

if (score > 0x3DE0 ||
    battery_current >= 550 ||
    temperature >= 25) {
    offset_per_cell = 250;

} else if (score > 0x2D00 ||
           battery_current >= 450 ||
           temperature >= 19 ||
           mode == 0x20) {
    offset_per_cell = 200;

} else if (score > 0x21C0 ||
           battery_current >= 350 ||
           temperature >= 13) {
    offset_per_cell = 150;

} else if (score > 0x1950 ||
           battery_current >= 250 ||
           temperature >= 10 ||
           mode == 0x10) {
    offset_per_cell = 100;

} else if (score > 0x10E0 ||
           battery_current >= 150 ||
           temperature >= 7) {
    offset_per_cell = 50;

} else {
    offset_per_cell = 0;
}

set_voltage(base_voltage - cell_count * offset_per_cell);

The weighted scoring algorithm

The firmware tracks how many hours the battery stays in high voltage / high temperature as a score. The algorithm is:

// when each hour passed:

if (pack_voltage <= cell_count * 4100) return; // only increment if each cell voltage >= 4100 mV
if (score >= 65000) return;

if (battery_temperature < 29.85ยฐC) {
    score += 1;
} else if (29.85ยฐC <= battery_temperature <= 39.85ยฐC) {
    score += 3;
} else {
    score += 7;
}

It looks like a heuristic method to measure the remaining life of the battery.

@minortex

minortex commented Jul 15, 2026

Copy link
Copy Markdown

@w568w
It's crazily conservative, the working temperature of the battery almost won't get lower than 15ยฐC, single digits make no sense. Besides, low temperature is much a threat than heat.


However, I read my register:

# score
โฏ sudo uv run tools/ec_rw.py read 0x9c9
EC[0x09C9 (2505)] = 0xFF (255)
โฏ sudo uv run tools/ec_rw.py read 0x9ca
EC[0x09CA (2506)] = 0xFF (255)
# current
โฏ sudo uv run tools/ec_rw.py read 0xa4e
EC[0x0A4E (2638)] = 0xFF (255)
โฏ sudo uv run tools/ec_rw.py read 0xa4f
EC[0x0A4F (2639)] = 0xFF (255)
# temp
โฏ sudo uv run tools/ec_rw.py read 0xa54
EC[0x0A54 (2644)] = 0xFF (255)
# base_voltage
โฏ sudo uv run tools/ec_rw.py read 0x30e
EC[0x030E (782)] = 0x44 (68)
โฏ sudo uv run tools/ec_rw.py read 0x30f
EC[0x030F (783)] = 0xC0 (192)
# 0x44c0 -> 17600

Several registers are in empty values, here are two conditions:

  • If the register is protected, we can't read from them.
  • The values are realstic, so we can never get a higher voltage than 16.6v (actually lower 16.4v).

@w568w

w568w commented Jul 15, 2026

Copy link
Copy Markdown
Author

the working temperature of the battery almost won't get lower than 15ยฐC [...] Besides, low temperature is much a threat than heat.

I suppose the condition temperature >= X is for the low temperature scenario: The lower the temperature, the higher the battery charging voltage should be (in order to keep the charger work effectively).

When the temperature is sufficiently high (at room temperature), you can ignore that condition because it is disjunctive.

I read my register

As far as I discovered, any address greater than 0x7ff should be both unreadable (always returning 0xFF) and unwritable.

07FD = AB
07FE = 17
07FF = AE
0800 = FF
0801 = FF
...
087F = FF

@minortex

minortex commented Jul 15, 2026

Copy link
Copy Markdown

Standard ACPI show that only 256 bytes register is available, our registers are far more than this range. Any other way to get them?

from 0x800 the value become 0xff, but in 0xDxx and 0xFxx some values are readable/writable, look at this

The score may not be persistently stored, when you fully shutdown, replace the battery, and remove the discrete BIOS battery, they may be reset, so I tend to believe the temp is the main cause.

Therefore, the best way to test is to place my machine into the fridge? ๐Ÿ˜‚

@minortex

Copy link
Copy Markdown

Wait a minute, I'll disassemble my battery out and place it info fridge.

@minortex

minortex commented Jul 15, 2026

Copy link
Copy Markdown

My battery's temperature decrease to 20ยฐC, Here is the result:

Snipaste_2026-07-15_23-15-47

I dig out the output voltage allowed by the EC with codex, its 0x522/0x523, using little-endian.

0x42 << 8 | 0x68 get 17000mV๏ผŒon 150mV preset, likely not correspond from above codes...
@w568w How did you disassembly out those codes?


Now the 0x7a6 takes effect!

# 0x20
โฏ sudo uv run tools/ec_rw.py write 0x7a6 0x20
EC[0x07A6 (1958)] : 0x10 (16) -> 0x20 (32)
โฏ sudo uv run tools/ec_rw.py read 0x522
EC[0x0522 (1314)] = 0xA0 (160)
โฏ sudo uv run tools/ec_rw.py read 0x523
EC[0x0523 (1315)] = 0x41 (65) # 16800mV
โฏ cat /sys/class/power_supply/BAT0/voltage_now
16654000
โฏ cat /sys/class/power_supply/BAT0/current_now
68000

# 0x10
โฏ sudo uv run tools/ec_rw.py write 0x7a6 0x10
EC[0x07A6 (1958)] : 0x20 (32) -> 0x10 (16)
โฏ sudo uv run tools/ec_rw.py read 0x522
EC[0x0522 (1314)] = 0x68 (104)
โฏ sudo uv run tools/ec_rw.py read 0x523
EC[0x0523 (1315)] = 0x42 (66) # 17000
โฏ cat /sys/class/power_supply/BAT0/current_now
612000
โฏ cat /sys/class/power_supply/BAT0/voltage_now
16818000

the temperature isn't low enough so 0x00 is the same as 0x10.

@w568w

w568w commented Jul 16, 2026

Copy link
Copy Markdown
Author

How did you disassembly out those codes?

I simply asked the Codex to "decompile and research the EC firmware" for me, which used disasm51 to reverse-engeering the firmware:

disasm51 --force ec-bank1.bin

Then I checked the critical logic manually.

@minortex

minortex commented Jul 16, 2026

Copy link
Copy Markdown

I found a picture of this EC, it has a 128KB of build-in flash, so we can't easily flash it using CH341.
ๅ›พ็‰‡

The firmware appears to be unencrypted so we can flash it, but if we don't handle the checksum or other things correctly, the laptop will get bricked.


update:
Scratch that. I realized that the 128k flash is actually not used, the real EC firmware resides in another SOP8-packaged flash right next to the DRAM socket:

ๅ›พ็‰‡

I double-checked this with the ID showed by the EC Flasher, and it turns out to be an XM25QH80, with 1MB NOR flash, which I initially mistook for the BIOS flash.

For context, the actual BIOS flash is the WSON8 package tucked under the cooling assembly.

@minortex

Copy link
Copy Markdown

@w568w I noticed your battery has almost zero wear, how long have you been using it and what is the cycle count (via 0x4a6/0x4a7, due to the ACPI interface is broken and always reports zero)?

I came across a post where users of XMG EVO 14 AMD talked about the health dropped very quickly. In this post, OPโ€™s battery voltage dropped to 15.97V the moment it's unplugged. It's definitely not fully charged, based on basic battery chemistry.

@LongSang01 and I checked our capacity in the BIOS, and it's only 4000mAh with 16.4V we shared in the last post. On a full-charge state, this makes no sense. Setting up a threshold is pointless under this condition.

So if it's convenient for you, would you mind doing a battery calibration: Just drain the battery to ~5% and then charge it to full, which might update the charge_full value.

@w568w

w568w commented Jul 16, 2026

Copy link
Copy Markdown
Author

how long have you been using it and what is the cycle coun

I checked it via the utility by LongSang01:

$ sudo ./wujie14xCC -status

=== ๅฝ“ๅ‰้…็ฝฎๅฆ‚ไธ‹ ===
ๆ‰‹ๅŠจๆจกๅผ        : ๅผ€ๅฏ
ๅ……็”ตๆจกๅผ        : ~100%
ๅ……็”ตไธŠ้™        : 80% (ๅทฒ็”Ÿๆ•ˆ)
็”ตๆฑ ๆธฉๅบฆ        : 35ยฐC
็›ธๅฏนๅ……็”ต็Šถๆ€    : 100%
ๅพช็Žฏๆฌกๆ•ฐ        : 45
่ฎพ่ฎกๅฎน้‡        : 5200 mAh
ๆปกๅ……ๅฎน้‡        : 4000 mAh
็”ตๆฑ ๆŸ่€—        : 23.1%
ๅ‰ฉไฝ™ๅฎน้‡        : 4000 mAh
็”ตๆฑ ็”ตๅŽ‹        : 16091 mV
่ฎพ่ฎก็”ตๅŽ‹        : 15400 mV
็”ตๆฑ ็”ตๆต        : 0 mA
้”ฎ็›˜็ฏ          : ๅ…ณ (็ญ‰็บง 0)
ๆ€ง่ƒฝๆจกๅผ        : 45W

FYI, here is the algorithm for estimating FCC (Fully Charged Capacity):

init() {
    internal_fcc = 5200 mAh; // set the initial value to design capacity
    fcc_bucket = internal_fcc / 100;
}

// relative_state_of_charge: the batteryโ€™s reported charge percentage, calculated from remaining_capacity / full_charge_capacity.

when relative_state_of_charge <= 12% () {
    // Start a learning cycle at low capacity.
    remaining_estimate = fcc_bucket * relative_state_of_charge;
}

when charging() {
    // Accumulate the current samples to estimate the capacity.
    // This method is executed 5 times per second during charging.
    current_remainder += get_current_current(); // Unit: mA
    delta_mAh = current_remainder / 18000; // mA -> mAh
    current_remainder %= 18000;
    remaining_estimate += delta_mAh;
}

when relative_state_of_charge == 100% () {
    // After fully charged, update the estimated fcc.
    candidate_bucket = remaining_estimate / 100;
    candidate_bucket = clamp(candidate_bucket, old_fcc / 100 - 3, old_fcc / 100 + 3);
    fcc_bucket = candidate_bucket;
    internal_fcc = fcc_bucket * 100;

    xram[0x0404:0x0405] = internal_fcc; // write out the internal_fcc for public interface.
}

TL;DR: You must do a full charging cycle (<=12% -> 100%) to calibrate the estimated FFC. And each calibration can update the FCC by at most +/- 300 mAh.

users of XMG EVO 14 AMD talked about the health dropped very quickly. In this post, OPโ€™s battery voltage dropped to 15.97V the moment it's unplugged. It's definitely not fully charged, based on basic battery chemistry.

Are you saying that the EC has a different mechanism to control the charge limit?

@minortex

minortex commented Jul 17, 2026

Copy link
Copy Markdown

According to your analysis, it explained that:

  • On LongSang01's machine, The initial FCC is 4000mAh;
  • On my machine, it's 4000mAh, after a calibration, it dropped to 3700mAh;
  • On the machine from the OP, it's 52.36 / 80.08 * 5200 = 3400mAh.

Are you saying that the EC has a different mechanism to control the charge limit?

Nope. This is related to battery working principles: When you charge a battery, it charges in constant current mode before reaching the charging limit voltage, and switches to constant voltage mode once the limit reached.

When you unplug the charger on CV mode, the battery is not charged to full, causing the reported battery voltage to drop slightly.

If you always charge the battery below the charge limit voltage, the battery will never reach a full charge. That's exactly where we are at right now. According to the logic of the code you dig, if the battery is consistently undercharged, using the battery down to 12% will initiate a vicious cycle of capacity degradation.

@LongSang01

LongSang01 commented Jul 17, 2026

Copy link
Copy Markdown
  • On LongSang01's machine, The initial FCC is 4000mAh;

At the beginning, it was 4300mAh. When tested again in July, it was 4000mAh.

wujie14xCC obtains the full-charge capacity by reading 0x404 + 0x405, and this value is consistent with the value displayed in the BIOS.

@LongSang01

LongSang01 commented Jul 17, 2026

Copy link
Copy Markdown

By the way, I like using volumeshader for GPU testing. It quickly drains the battery, and then I just slowly charge it back up to full

@minortex

Copy link
Copy Markdown

You must occasionally perform a complete charge-discharge cycle on the battery; otherwise, continuously limiting the charging upper limit for a long period may damage the battery.

The problem is, the battery has never reached its cut-off voltage. How can we even call it 'damaged'?

To be clear, when our laptop is kept on AC power, the current is almost zero, so calendar degradation from voltage is what matters most. Since the EC-limited voltage is 16.4V, which is more than 1V below the 17.6V battery cut-off voltage, it almost does no harm to the battery.

It quickly drains the battery, and then I just slowly charge it back up to full. That's just how batteries work.

High-power discharging is the worst way to drain a battery because the internal resistance increases, causing the battery to output the least amount of usable energy.

Chemically, the active material doesn't even have enough time for proper ion diffusion under such a heavy load. If you want a proper calibration, the best way is to use a light workload, like playing a video without danmaku, which aims for a mild power draw of 10-12w (about 0.1-0.15C).

@LongSang01

Copy link
Copy Markdown

I looked into some references, and it turns out you were right. I haven't done much research on batteries myself. When I was previously testing whether the charging limit feature worked, I kept using WebGL as a quick way to verify it, so I need to correct my earlier mistake.

By the way, I just flashed the Slimbook BIOSN.1.14GOS07 EC2.12 on my machine, and so far I haven't encountered any issues. Now wujie14xCC now also works properly with this version of the BIOS. I also wrote a simple guide explaining how to flash the BIOS.

@minortex

minortex commented Jul 17, 2026

Copy link
Copy Markdown
uint16_t score =
    ((uint16_t)xram[0x09C9] << 8) |
     xram[0x09CA]; // A weighted score (see below)

uint8_t temperature = xram[0x0A54]; // Unit: Celcius

@w568w

I doubt it's actually temperature, the temperature variable has already been factored in the score, so it shouldn't be involved in the logic again.

Base on my reverse-engineered findings with the help of codex, I tend to believe it's a throttling factor.

the factor is related to xram[0x468] which is zero on a regular basis, so it may not be the reason that the voltage is limited.

@w568w

w568w commented Jul 18, 2026

Copy link
Copy Markdown
Author

I doubt it's actually temperature, the temperature variable has already been factored in the score, so it shouldn't be involved in the logic again.

I double-checked the registers' semantics, and you're right.

I haven't read the assembly codes myself but by reading the register, 0x07A6 seems like the battery ages (in months). And the 0x0A4E:0x0A4F is not the battery current, either. It's cycle count.

So the correct condition should be:

if (exposure_score > 0x3DE0 ||
      cycle_count >= 550 ||
      battery_age_months >= 25) {
    // ...
}

@minortex

minortex commented Jul 18, 2026

Copy link
Copy Markdown

I tried to dig out the r/w attributes of different EC regions, This explained why we can't read some registers out.
Here is the result:

Window EC Base Address Size Host Readable Host Writable
0 0x000 1024 B All Fully Prohibited
1 0x400 512 B All Lower Half Only 0x400โ€“0x4FF
2 0x600 512 B All Upper Half Only 0x700โ€“0x7FF
3 0xC00 1024 B All Fully Allowed

So the permission mapping of the host in theory:

XRAM MMIO Read MMIO Write Description
0x000โ€“0x3FF Yes No window 0๏ผŒfull window write-protect
0x400โ€“0x4FF Yes Yes window 1 lower half
0x500โ€“0x5FF Yes No window 1 upper half, write-protect
0x600โ€“0x6FF Yes No window 2 lower half, write-protect
0x700โ€“0x7FF Yes Yes window 2 upper half
0x800โ€“0xBFF No No not covered by any H2RAM window
0xC00โ€“0xFFF Yes Yes window 3
>=0x1000 No No H2RAM only maps the lower 4K of data local memory

@minortex

Copy link
Copy Markdown

The restrictions above are only applied only when you use ACPI interface or H2RAM to read these registers.


@w568w
I reversed the EC flasher ifux64.efi, and found a native method to bypass the restrictions via io ports. Please check it out here:
https://github.com/minortex/ec_reverse/blob/main/tools/i2ec_rw.py

the registers we discussed before can be read through that, besides, addresses above 7ff can be read out as well. Through this method, we can not only read the registers we discussed earlier, but also access addresses above 0x7ff:

โฏ sudo uv run tools/i2ec_rw.py read 0x07ff
EC[0x07FF] = 0x7D (125)
โฏ sudo uv run tools/i2ec_rw.py read 0x0800
EC[0x0800] = 0x00 (0)
โฏ sudo uv run tools/i2ec_rw.py read 0x0801
EC[0x0801] = 0x40 (64)
โฏ sudo uv run tools/i2ec_rw.py read 0x0802
EC[0x0802] = 0x00 (0)
โฏ sudo uv run tools/i2ec_rw.py read 0x0803
EC[0x0803] = 0x01 (1)

I hope this work helps!

@minortex

Copy link
Copy Markdown

How can we access the xram of EC?

I managed to hunt down the IT5570 datasheet, which is likely a close relative to our IT5571.

In the chapter 7.17.4.15 Special Control 1 (SPCTRL1), it mentions that xram[0x200d].1:0 dictates the read/write permissions. Now, here is the funny part: If read permission wasn't allowed in the first place, we wouldn't even be able to read this register to find out!

Luckily, it actually allows both reading and writing. This opens up a whole world of possibilities for us to verify and tinker with the values.

Since disassembling binaries isn't my strongest suit, I think this task fits @w568w best. So, over to you, man! Looking forward to seeing what you can cook up with this.

@w568w

w568w commented Jul 19, 2026

Copy link
Copy Markdown
Author

@minortex Thanks for digging into this!

However, I suppose there aren't other features left to "unearth" for now? My next step is to develop a DKMS kernel module in my spare time to support all the interfaces we've discovered so far. (That might have to wait until I have some actual free time, though :) )

That being said, if you have any other ideas you'd like to verify, feel free to discuss anytime.

@minortex

minortex commented Jul 19, 2026

Copy link
Copy Markdown

@w568w Take your time with the driver, no rush at all! Work/life always comes first!

Speaking of unearthing things, I just had a new initial finding:

once registers 0x9c9 and 0x9ca are cleared, the target voltage 0x523 and 0x522 becomes 17600, meaning the offset is zero!

the register 0x9c7 tracks seconds, 0x9c8 tracks minutes, their maxium value is 60. Once a cycle of 3600s (60 * 60) finishes and resets, the score increments by 1 * factor. The factor is the one you digged out earlier!

But we still don't know what determines the initial value. Even though the battery and AC is cut off, it's still hard-limited to 17000mV on the very first boot, and takes about tens of hours to drop to 16600mV.

@minortex

Copy link
Copy Markdown

I manually modded the EC firmware:

  1. Disable automatic battery voltage offset logic. Only manually writing to 0x7a6 is accepted.
  2. Adjust 0x7a6 xram handling: 0x00 -> None; 0x10 -> 17000mV; 0x20 -> 16600mV, and ignore APExistFlag condition check to retain 0x7a6 value.
  3. Bypass the 300mAh adjustment cap during calibration, directly report the real calibrated capacity value to ACPI.

After a calibration, my battery displays its real full charge capacity:

  native-path:          BAT0
  vendor:               OEM
  model:                standard
  serial:               00001
  power supply:         yes
  updated:              Fri Jul 24 00:30:34 2026 (11 seconds ago)
  has history:          yes
  has statistics:       yes
  battery
    present:             yes
    rechargeable:        yes
    state:               discharging
    warning-level:       none
    energy:              66.528 Wh
    energy-empty:        0 Wh
    energy-full:         69.3 Wh
    energy-full-design:  80.08 Wh
    voltage-min-design:  15.4 V
    capacity-level:      Normal
    energy-rate:         22.0066 W
    voltage:             16.718 V
    charge-cycles:       61
    time to empty:       3.0 hours
    percentage:          96%
    capacity:            86.5385%
    technology:          lithium-ion
    charge-start-threshold:        75%
    charge-end-threshold:          80%
    charge-threshold-supported:    yes
    icon-name:          'battery-full-symbolic'
  History (charge):
    1784824157  96.000  discharging
  History (rate):
    1784824234  22.007  discharging
    1784824183  13.629  discharging
    1784824157  16.247  discharging
  History (voltage):
    1784824234  16.718  discharging
    1784824208  16.857  discharging
    1784824183  16.826  discharging
    1784824157  16.763  discharging

If interested, see ec_reverse. You can flash the 1MB chip via a SPI programmer or replace the target binary in the Slimbook EC flasher.

@w568w

w568w commented Jul 24, 2026

Copy link
Copy Markdown
Author

@minortex Impressive work! I have had the idea (and the flasher) but never been brave enough to test on my working laptop. (I once bricked my previous laptop before ๐Ÿ˜…)

I am thinking if it is possible to measure the real FCC accurately with userspace tools, e.g., reading the current from the register per second and sum up the observation values.

@minortex

Copy link
Copy Markdown

never been brave enough to test on my working laptop

Having read that article, I wonder why modifying NVRAM broke the BIOS flash?
On our GX4HRXL, the BIOS flash is in a WSON package, which makes offline flashing difficult. Besides, the flash is validated by AMD PSP.
I also tried enabling S3 sleep via UMAF, but it's protected.

The EC flash is totally without signature validation, that's good news.
However, it's a bit hard to clip onto the chip, because it's too close to the memory socket. Here is a workground:

  1. Trim off a bit of the plastic on the left side
  2. Stuff a piece of paper into the middle of the clip to tighten it up.
  3. Hold the clip in place by hand while flashing.

Since I use flashrom for reading and writing, I just stage the command in advance, and hit Enter with my other hand.


if it is possible to measure the real FCC accurately with userspace tools?

It won't be a problem. While calibrating, the EC expose 0x388 and 0x389 (big-endian) so we can see how much charge in mAh has been added, but it clears them when the current drops to zero, this script may be of help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment