From 3a4433dbb9d2989734ebbb5fbdfa385cb4a0656b Mon Sep 17 00:00:00 2001 From: chevydan6 <339888127+chevydan6@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:41:53 +0900 Subject: [PATCH] fix(smu): support verified FA608WV.309 CO readback --- docs/strix-fa608wv309-co.md | 68 +++++++++++++ src/corecycler/smu/driver.py | 83 ++++++++++++++- tests/test_strix_readback.py | 37 +++++++ tests/test_strix_verified_profile.py | 147 +++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 docs/strix-fa608wv309-co.md create mode 100644 tests/test_strix_readback.py create mode 100644 tests/test_strix_verified_profile.py diff --git a/docs/strix-fa608wv309-co.md b/docs/strix-fa608wv309-co.md new file mode 100644 index 0000000..b9259e7 --- /dev/null +++ b/docs/strix-fa608wv309-co.md @@ -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. diff --git a/src/corecycler/smu/driver.py b/src/corecycler/smu/driver.py index 378aa2d..914c62d 100644 --- a/src/corecycler/smu/driver.py +++ b/src/corecycler/smu/driver.py @@ -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 ( @@ -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() @@ -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 @@ -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. diff --git a/tests/test_strix_readback.py b/tests/test_strix_readback.py new file mode 100644 index 0000000..c5576f0 --- /dev/null +++ b/tests/test_strix_readback.py @@ -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 diff --git a/tests/test_strix_verified_profile.py b/tests/test_strix_verified_profile.py new file mode 100644 index 0000000..96fba3b --- /dev/null +++ b/tests/test_strix_verified_profile.py @@ -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()