Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions docs/strix-fa608wv309-co.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Verified HX 370 Curve Optimizer protocol

This profile is limited to ASUS FA608WV.309, SMU 11.93.11.0, Ryzen AI 9 HX
370 (family 0x1A, model 0x24, stepping 0), with all 12 cores and SMT online.
It does not generalize this protocol to other Strix Point or Strix Halo images.

## Firmware evidence

The official [ASUS update](https://www.asus.com/us/supportonly/fa608wv/helpdesk_bios/)
contains the SMU and CCX firmware. Analysis used full decompressed bodies from
[PSPTool](https://github.com/PSPReverse/PSPTool), with Xtensa assembly checked
against the decompiler output. Body SHA256 values:

- SMU: `92d3b77a027eae84c02c07a508e831831660f3622811c679eae2165f7eb8048f`
- CCX: `d96ac821d6d22808198d88891b9487d966299e12ccfa906e2796c0dac78f6964`

RSMU 0xAF, the previous getter, forwards internal CCX 0x0F and queries a
floating-point curve, returning 600 for the existing request encoding.
RSMU 0x5E maps to SMU handler 0x494A8, forwards internal CCX 0x53, and reaches
CCX handler 0x145BC. That handler loads the same signed integer field written
by MP1 0x4B (SMU 0x48E5C -> CCX 0x47 -> 0x1445C).

The getter and setter both encode group in bits 31:28 and slot in bits 27:20.
The presence check accepts group 0 slots 0,2,4,6 and group 1 slots 0..7.
SMU initialization at 0x28A40 compacts populated slots in ascending order
into APIC core numbering, then adds SMT thread bits. Live CPUID confirms:

| Linux physical core IDs | Firmware group | Firmware slots |
| --- | --- | --- |
| 0,1,2,3 | 0 | 0,2,4,6 |
| 8,9,10,11,12,13,14,15 | 1 | 0,1,2,3,4,5,6,7 |

RSMU 0x82 reports capability bits. Its bit 2 uses the same permission test as
the CO setter; the tested machine reports 0xE6. Presence and capability do not
substitute for validating the setter, getter, and restoration together.

## Driver prerequisite and checks

The tested ryzen_smu 0.1.7 source masks a fast firmware rejection as success
because it checks `tmp != SMU_Return_OK && !retries`. Direct reads of response
SMN 0x03B10A80 confirmed firmware 0xFF while sysfs reported 1 on absent slots.
The driver must propagate non-success responses before enabling this profile.

Profile initialization issues only read commands. It verifies exact identity,
SMU version, online SMT/L3 layout, absent-slot rejection, capability bit 2,
all 16 slot statuses, and full signed-32-bit margin bounds. Any mismatch keeps
CO writes blocked. The shared command set remains unchanged; the verified
instance uses getter 0x5E and an explicit core map.

## Hardware validation and limits

Two read passes found zero margins on all 12 populated slots and 0xFF on the
four absent slots. After fixing the driver, its status matched the actual
hardware response on all 32 probes.

Minimal -1/set/read/restore tests passed for group/slot 0:0, 0:2, and 1:1.
Each checked all 12 margins before, during, and after the change: only the
selected slot changed, and every baseline was restored. The normal CoreCycler
API separately backed up 12 values, changed Linux core 1 to -1, verified the
other cores were unchanged, and restored all 12 values successfully.

The GUI then reported Connected and 12 zero CO values. No full auto-tuning
or stability session was run. Effective voltage reduction under load was not
independently measured. No firmware, fuse, or BIOS setting was modified.

Tests cover all core addresses, backup/restoration, rejected requests,
incorrect firmware/layout/capability, invalid full-width margins, and removal
of the profile when the SMU version changes.
83 changes: 82 additions & 1 deletion src/corecycler/smu/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import os
import struct
import threading
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path

from .commands import (
Expand Down Expand Up @@ -143,6 +143,7 @@ def __init__(
dry_run: bool = False,
) -> None:
self.commands = commands
self._original_commands = commands
self.sysfs = sysfs_path
self.dry_run = dry_run
self._smu_lock = threading.Lock()
Expand Down Expand Up @@ -208,6 +209,26 @@ def set_topology(self, topology) -> None:
self._core_map_error = "core map discovery in progress"
ids = sorted(topology.cores)
self._known_core_ids = ids or None
self.commands = self._original_commands
if self._set_verified_strix_topology(topology):
return
if self.commands.generation == CPUGeneration.ZEN5_STRIX_POINT and ids:
# HX 370 / older firmware can acknowledge the documented read while
# returning 600 for every index. An ACK alone is not a valid CO
# baseline. Probe before GUI/tuner availability is decided; never
# try a tuning write to discover what an invalid response means.
response = self._send_get_co(0)
value = decode_co_arg(0, response.args[0], self.commands.generation)
minimum, maximum = self.commands.co_range
if not response.success or not minimum <= value <= maximum:
detail = f"returned {value}" if response.success else "failed"
self._core_map_error = (
f"Strix Point CO readback {detail}; expected [{minimum}, {maximum}]. "
"Individual-core tuning is unavailable until the readback protocol "
"and core addressing are verified."
)
log.warning(self._core_map_error)
return
if not ids or not self.commands.has_co or not self.commands.uniform_8core_ccds:
self._core_map_error = None
return
Expand All @@ -232,6 +253,66 @@ def set_topology(self, topology) -> None:
self._core_map = core_map
self._core_map_error = None

def _set_verified_strix_topology(self, topology) -> bool:
"""Apply the locally verified FA608WV.309 / HX 370 protocol only.

Firmware trace: RSMU 0x5E forwards CCX 0x53, which reads the same
margin field MP1 0x4B writes. Initialization at SMU 0x28A40 compacts
populated slots into APIC core numbers. Live tests confirm group 0
slots 0,2,4,6 and group 1 slots 0..7, including -1/read/restore tests.
Unknown images keep the existing guard, not this addressing profile.
"""
if (self.commands.generation != CPUGeneration.ZEN5_STRIX_POINT
or topology.family != 0x1A or topology.model != 0x24
or topology.stepping != 0 or "HX 370" not in topology.model_name):
return False
try:
bios = Path("/sys/class/dmi/id/bios_version").read_text().strip()
except OSError:
return False
if bios != "FA608WV.309":
return False

def block(reason: str) -> bool:
self._core_map_error = f"Verified Strix CO profile unavailable: {reason}"
log.warning(self._core_map_error)
return True

version = self._send_rsmu_command(0x02)
if not version.success or version.args[0] != 0x0B5D0B00:
return block("SMU version differs from 11.93.11.0")
expected = {cid: (0, cid * 2) for cid in range(4)}
expected.update({cid: (1, cid - 8) for cid in range(8, 16)})
if (set(topology.cores) != set(expected) or not topology.cpus_all_online
or any(core.ccd != expected[cid][0] or len(core.logical_cpus) != 2
for cid, core in topology.cores.items())):
return block("online core/SMT/L3 layout differs from the verified 4+8 layout")
# The old ryzen_smu driver converts a fast firmware rejection into
# success. Check a traced, absent slot before allowing any writes.
rejected = self._send_rsmu_command(0x5E, (1 << 20,))
if rejected.success:
return block("driver hides firmware rejections; install the response fix")
capability = self._send_rsmu_command(0x82)
if not capability.success or not capability.args[0] & 4:
return block("firmware does not report CO permission")
minimum, maximum = self.commands.co_range
for group in range(2):
for slot in range(8):
response = self._send_rsmu_command(0x5E, (group << 28 | slot << 20,))
populated = group == 1 or slot % 2 == 0
if response.success != populated:
return block(f"unexpected presence/status for group {group}, slot {slot}")
if populated:
raw = response.args[0]
value = raw if raw < 0x80000000 else raw - 0x100000000
if not minimum <= value <= maximum:
return block(f"invalid margin {value} for group {group}, slot {slot}")
self.commands = replace(self.commands, get_co_cmd=0x5E, get_co_mailbox="rsmu")
self._core_map = expected
self._core_map_error = None
log.info("Using verified FA608WV.309 / SMU 11.93.11.0 CO getter and core mapping")
return True

@staticmethod
def _derive_group_slots(group_ids: list[int], holes_trusted: bool) -> list[int] | None:
"""Slots this group's numbering proves by itself; None means read the fuse.
Expand Down
37 changes: 37 additions & 0 deletions tests/test_strix_readback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Regression for HX 370 firmware acknowledging an invalid CO readback."""

from unittest.mock import MagicMock

import pytest

from corecycler.engine.topology import CPUTopology, PhysicalCore
from corecycler.smu.commands import CPUGeneration, get_commands
from corecycler.smu.driver import RyzenSMU, SMUResponse


@pytest.mark.parametrize("value,success", [(600, True), (0, False)])
def test_invalid_strix_baseline_blocks_tuning_without_a_write(value, success):
commands = get_commands(CPUGeneration.ZEN5_STRIX_POINT)
smu = RyzenSMU(commands)
smu._send_get_co = MagicMock(return_value=SMUResponse(success, (value, 0, 0, 0, 0, 0), b""))
smu._send_command = MagicMock()
topology = CPUTopology(cores={0: PhysicalCore(0, 0, None, (0, 12))})

smu.set_topology(topology)

assert "Strix Point CO readback" in smu.core_map_error
assert smu.get_co_offset(0) is None
assert not smu.set_co_offset(0, -1)
assert not smu.set_all_co(-1)
smu._send_command.assert_not_called()
smu._send_get_co.assert_called_once_with(0)


def test_plausible_strix_readback_preserves_existing_behavior():
commands = get_commands(CPUGeneration.ZEN5_STRIX_POINT)
smu = RyzenSMU(commands)
smu._send_get_co = MagicMock(return_value=SMUResponse(True, (0, 0, 0, 0, 0, 0), b""))

smu.set_topology(CPUTopology(cores={0: PhysicalCore(0, 0, None, (0, 12))}))

assert smu.core_map_error is None
147 changes: 147 additions & 0 deletions tests/test_strix_verified_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Protocol regressions from FA608WV.309 firmware and live probe evidence."""
from unittest.mock import MagicMock

import pytest

from corecycler.engine.topology import CPUTopology, PhysicalCore
from corecycler.smu.commands import CPUGeneration, get_commands
from corecycler.smu.driver import RyzenSMU, SMUResponse


def response(value=0, success=True):
return SMUResponse(success, (value & 0xFFFFFFFF, 0, 0, 0, 0, 0), b"")


def topology():
ids = list(range(4)) + list(range(8, 16))
return CPUTopology(
model_name="AMD Ryzen AI 9 HX 370 w/ Radeon 890M", family=26, model=36,
stepping=0, cores={cid: PhysicalCore(cid, int(cid >= 8), None, (i, i + 12))
for i, cid in enumerate(ids)},
)


@pytest.fixture
def hardware(monkeypatch):
monkeypatch.setattr("corecycler.smu.driver.Path.read_text", lambda self: "FA608WV.309\n")
smu = RyzenSMU(get_commands(CPUGeneration.ZEN5_STRIX_POINT))
margins = {(0, s): 0 for s in (0, 2, 4, 6)} | {(1, s): 0 for s in range(8)}

def read(cmd, args=(0,) * 6):
if cmd == 2:
return response(0x0B5D0B00)
if cmd == 0x82:
return response(0xE6)
assert cmd == 0x5E, f"Wrong getter: {cmd:#x}"
key = (args[0] >> 28, args[0] >> 20 & 0xFF)
return response(margins[key]) if key in margins else response(args[0], False)

def write(cmd, args):
assert cmd == 0x4B
key = (args[0] >> 28, args[0] >> 20 & 0xFF)
raw = args[0] & 0xFFFF
margins[key] = raw if raw < 0x8000 else raw - 0x10000
return response()

smu._send_rsmu_command = MagicMock(side_effect=read)
smu._send_command = MagicMock(side_effect=write)
smu.check_writable = MagicMock(return_value=(True, "OK"))
return smu, margins


def test_verified_profile_maps_every_core_and_restores(hardware):
smu, margins = hardware
original = get_commands(CPUGeneration.ZEN5_STRIX_POINT)
smu.set_topology(topology())
assert smu.core_map_error is None
assert smu.commands.get_co_cmd == 0x5E
assert original.get_co_cmd == 0xAF # shared and Halo command sets stay unchanged
for cid in topology().cores:
smu._send_command.assert_not_called()
assert smu.backup_co_offsets(12) == {cid: 0 for cid in topology().cores}
assert smu.set_co_offset(cid, -1)
assert sum(v != 0 for v in margins.values()) == 1
group, slot = smu.core_map[cid]
assert (group, slot) == ((0, cid * 2) if cid < 4 else (1, cid - 8))
assert margins[group, slot] == -1
assert smu.restore_co_offsets() == (True, [])
assert not any(margins.values())
smu._send_command.reset_mock()


@pytest.mark.parametrize("failure", ["old_driver", "version", "capability", "presence", "margin", "offline", "layout"])
def test_profile_mismatches_block_all_writes(hardware, failure):
smu, margins = hardware
topo = topology()
original_read = smu._send_rsmu_command.side_effect

def read(cmd, args=(0,) * 6):
if failure == "old_driver" and cmd == 0x5E and args[0] == 1 << 20:
return response(args[0])
if failure == "version" and cmd == 2:
return response(0x0B5D0400)
if failure == "capability" and cmd == 0x82:
return response(0)
if failure == "presence" and cmd == 0x5E and args[0] == 6 << 20:
return response(args[0], False)
return original_read(cmd, args)

if failure == "margin":
margins[1, 7] = 0x10000 # low-16 decoding alone would incorrectly accept zero
if failure == "offline":
topo.cpus_all_online = False
if failure == "layout":
topo.cores.pop(15)
smu._send_rsmu_command.side_effect = read
smu.set_topology(topo)
assert smu.core_map_error
assert not smu.set_co_offset(0, -1)
assert not smu.set_all_co(-1)
smu._send_command.assert_not_called()


def test_reinitialization_drops_verified_profile_on_version_change(hardware):
smu, _ = hardware
smu.set_topology(topology())
assert smu.commands.get_co_cmd == 0x5E
smu._send_rsmu_command.side_effect = lambda cmd, args=(0,): response(0x0B5D0400)
smu.set_topology(topology())
assert smu.commands.get_co_cmd == 0xAF
assert smu.core_map is None
assert not smu.set_co_offset(0, -1)


@pytest.mark.parametrize("difference", ["bios", "cpu_model", "stepping", "name"])
def test_unverified_images_keep_old_readback_guard(hardware, monkeypatch, difference):
smu, _ = hardware
topo = topology()
if difference == "bios":
monkeypatch.setattr("corecycler.smu.driver.Path.read_text", lambda self: "FA608WV.310\n")
elif difference == "cpu_model":
topo.model = 0x64
elif difference == "stepping":
topo.stepping = 1
else:
topo.model_name = "AMD Ryzen AI 9 365"
smu._send_get_co = MagicMock(return_value=response(600))
smu.set_topology(topo)
smu._send_rsmu_command.assert_not_called()
assert smu.commands.get_co_cmd == 0xAF
assert smu.core_map_error
assert not smu.set_co_offset(0, -1)
smu._send_command.assert_not_called()


def test_unreadable_bios_keeps_old_readback_guard(hardware, monkeypatch):
smu, _ = hardware

def unreadable(path):
raise PermissionError("DMI unavailable")

monkeypatch.setattr("corecycler.smu.driver.Path.read_text", unreadable)
smu._send_get_co = MagicMock(return_value=response(600))
smu.set_topology(topology())
smu._send_rsmu_command.assert_not_called()
assert smu.core_map_error
assert not smu.set_all_co(-1)
smu._send_command.assert_not_called()