## How it works Some information stated in this document may be incorrect, this document is the result of my reverse engineering. ### Control channel Fan control is **not** a memory region you can poke. It is a command protocol on a private pair of I/O ports, independent of the ACPI EC (`0x62`/`0x66`) and of the OEM host interface (`0x6C`/`0x68`). | port | role | |---|---| | `0x25C` | data | | `0x25D` | command and status | Status bits on `0x25D`: `0x01` OBF (a byte is waiting to be read), `0x02` IBF (input buffer busy, do not write). A transaction is: drain any stale output, wait for IBF to clear, write the preamble `0xFF` to the command port, write the command, then write each payload byte to the data port, waiting for IBF between bytes. For a read, wait for OBF and take the result from the data port. Two commands matter: | command | payload | meaning | |---|---|---| | `0xBB` | `50` | table version, one byte returned | | `0xDD` | ` ` | register table access | In the selector byte, **bit 7 selects the direction**: `0x02` reads from table 2, `0x82` writes to it. So reading register `0x30` is `DD 02 30 00`, and writing 140 to register `0x35` is `DD 82 35 8C`. ### Registers All in table 2, all reached with command `0xDD`. | reg | name | access | range | notes | |---|---|---|---|---| | `0x30` | fan count | R | `2` | constant; used as a liveness check before any write | | `0x31` | fan control mode | R/W | `0` / `1` | `0` = EC's own curve, `1` = manual. **Global** | | `0x32` | fan select | W | `0`–`1` | chooses which fan `0x33`/`0x34`/`0x35` refer to | | `0x33` | tachometer, low byte | R | | | | `0x34` | tachometer, high byte | R | | `rpm = (0x34 << 8) \| 0x33` | | `0x35` | PWM duty | R/W | `0`–`255` | per fan | Two properties are easy to get wrong: **`0x31` is global.** It is one bit for the whole controller, not one per fan. Clearing it while fan 1 is selected releases fan 0 as well. Consequently `--fan` does not confine the effect of a write. **Entering manual mode freezes both fans** wherever the EC's curve last put them. `set X --fan 0` therefore means "freeze both fans, and give fan 0 the value X" - the fan you did not name stops responding to temperature and sits at whatever duty it happened to have. **While `0x31` is `0`, register `0x35` belongs to the controller.** It writes its own computed duty there and overwrites anything the host puts in. Reading it in that state is useful telemetry: it shows what the EC's curve is currently doing. ### Setting a fan ``` DD 82 32 select the fan DD 82 31 01 manual mode DD 82 32 re-assert the selection DD 82 35 duty 0-255 ``` Order matters. A duty written while `0x31` is still `0` gets overwritten by the EC's curve before manual mode engages - the vendor software writes in the opposite order and has this bug. The selector is written twice on purpose. Coming out of automatic mode, a duty write has been observed landing on the wrong fan, as if the mode write had cleared the selection. Re-asserting it costs one transaction and removes the failure. Releasing is the mode register alone, and nothing else: ``` DD 82 31 00 leave the duty untouched ``` ### Reading fan speed Two independent paths: - **Registers** `0x34` and `0x33` over the ports, three transactions per fan. - **The aperture**, a read-only mirror of the controller's RAM in host physical memory. Fan speeds sit at `0xFEDD8B7C` and `0xFEDD8B7E` as big-endian 16-bit values, ready to use. The aperture is the better source for telemetry: two bytes at roughly 1.2 µs each, no transaction, no contention with writes. `fan-info` reads both and compares them - that comparison is the validation test. ### Temperature Temperature is **never written and never read over the control ports.** It comes entirely from the aperture, one byte per sensor: | field | address | meaning | confidence | |---|---|---|---| | `CTMP` | `0xFEDD8358` | CPU temperature, whole °C | confirmed | | `CLOT` | `0xFEDD8301` | second sensor, whole °C | name only | `CTMP` is confirmed by the machine's own ACPI tables. The thermal zone's `_TMP` method reads that exact byte and converts it with `TTMP = CTMP * 10 + 2732`, which is degrees Celsius turned into tenths of a kelvin. That pins down the location, the unit and the fact that the rest of the system trusts the same number. `CLOT` is declared in the ACPI field but never read by anything, so its meaning rests on the field name alone. Treat it as unverified. Practical notes: - **One unsigned byte.** Whole degrees, no fractions, no negatives. Do not expect it to agree with `k10temp` to the decimal - that driver reads the CPU's own registers over a completely different path. - **The refresh rate belongs to the EC.** Reading a hundred times a second gets you nothing that reading once a second does not. - **The controller does not measure the CPU.** It receives that number from the SoC and stores it. You are reading the EC's picture of the world. You cannot set a temperature. What the curve does is map a temperature to a percent, convert that to a duty with `duty = round(percent * 255 / 100)`, and write the duty. `--sensor` chooses which of the two readings drives the mapping: `cpu`, `board`, or `max` (the default, and the safer choice with two sensors on one curve). --- ## The vendor path on Windows Recorded for comparison, and as the origin of the findings above. ``` HealthyTable_SetFanPwmDuty(duty) AsusWinIO64.dll, RVA 0x28AE0 └─ RwEcCmd(len, cmd, buf, dir, &status) RVA 0x200D0 └─ DeviceIoControl(\\.\AsusSAIO, 0x80102070, req, 16, req, 16) └─ IOCTL dispatch AsusSAIO.sys, RVA 0x12A0 └─ handler RVA 0x1AA0 └─ transaction core RVA 0x1828 └─ port write RVA 0x1D70 └─ out dx, al RVA 0x1DCB ``` The 16-byte request structure, same buffer in and out: | offset | type | field | |---|---|---| | `0x00` | u8 | command code, `0xBB` or `0xDD` | | `0x01` | u8 | payload length, 1–8 | | `0x02` | u8[8] | payload | | `0x0A` | u8 | direction: 1 expects a returned byte, 0 is a write | | `0x0B` | u8 | byte returned by the EC | | `0x0C` | u32 | status, `0` = OK, `0x102` = timeout | The library needs **SYSTEM**, not merely administrator, and the ASUS System Analysis service has to be running. - **`0x12A0`** is the IOCTL dispatcher. It compares the control code against a list; `cmp eax, 0x80102070` sits at `0x1367` and branches to `0x13F7`. - **`0x13F7`** rejects the request unless both buffers are at least `0x10` bytes, copies 16 bytes in, calls `0x1AA0`, copies 16 bytes back and sets `Information = 0x10`. This is where the *16 bytes, same buffer both ways* claim comes from. - **`0x1AA0`** unpacks the structure exactly as tabulated: command from `[buf]`, length from `[buf+1]`, payload pointer `buf+2`, direction from `[buf+0xA]`, status pointer `buf+0xC`; after calling `0x1828` it stores the returned byte at `[buf+0xB]`. The call is bracketed by `KeWaitForSingleObject` and `KeReleaseMutex` - the serialising mutex is real, and any independent implementation needs its own. - **`0x1828`** is the transaction core and carries the port numbers as literals: `mov edx, 0x25c`, and `mov edx, 0x25d` paired with `mov cl, 0xff` - the preamble byte written to the command port. - **`0x1734`** is the status-bit wait. It polls at most `0x3E8` = **1000** times and returns `0x102` on expiry. Each iteration calls a helper at `0x1000` that wraps `KeDelayExecutionThread` with a relative interval of `arg × -10` in 100 ns units; the caller passes `0x64` = 100, so **100 µs per poll**. The `POLL_LIMIT` and `POLL_DELAY` constants in this program are the same numbers, arrived at independently. - **`0x1D70`** is a generic port write that switches on a width byte: 1 → `out dx, al` at `0x1DCB`, 2 → `out dx, ax`, 4 → `out dx, eax`. Two imports are worth noting. `MmMapIoSpace` / `MmUnmapIoSpace` are the driver's own route to the telemetry aperture - the same physical page this program maps through `/dev/mem`. `IoGetCurrentProcess`, `ZwOpenProcessTokenEx`, `ZwQueryInformationToken` and `RtlEqualSid` are the caller check that makes administrator insufficient: the driver compares the calling token's SID rather than checking for elevation.