feat(encode/amf): accept AMF runtime >=1.4.34 (graceful degrade) + log loaded amfrt64.dll identity
ci / rust (push) Failing after 47s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 56s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
docker / deploy-docs (push) Successful in 11s
ci / bench (push) Successful in 5m8s
android / android (push) Successful in 14m4s
arch / build-publish (push) Successful in 12m44s
deb / build-publish (push) Successful in 13m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m19s
windows-host / package (push) Successful in 9m36s
apple / swift (push) Successful in 4m40s
apple / screenshots (push) Successful in 19m47s

The Windows AMF encoder hard-rejected any runtime <1.4.36 — a Jan-2025
(Adrenalin 25.1.1) driver floor. Every AMD host on an older driver failed the
session with "update the AMD driver" after 8 retries, notably Boot Camp Macs
whose bundled amfrt64.dll lags far behind.

Split the single pin:
- AMF_MIN_VERSION (1.4.34): the ABI floor accepted at load. Every vtable slot
  the FFI mirrors is a base-interface slot stable since well before 1.4.34; the
  1.4.35/1.4.36-only features are string-keyed encoder properties already applied
  via set_prop(required=false), which log-and-continue — so an older driver
  degrades those features individually instead of failing.
- AMF_HEADER_VERSION (1.4.36): the header the mirror targets, now passed to
  AMFInit capped at min(header, runtime) so claiming a version newer than the
  runtime can't make AMFInit reject an otherwise-usable older driver.

No functionality removed: a >=1.4.36 runtime behaves exactly as before.

Also logs, once per process, the AMF runtime version AND the loaded amfrt64.dll's
full path + file-version resource (via GetModuleFileNameW + VerQueryValueW). This
surfaces the Boot Camp failure mode where the display driver reads 25.x but the
System32 amfrt64.dll is a stale build reporting an old AMF version; the too-old
decline now names the DLL path/build and points at reboot + DDU reinstall.

Not compile-verified: amf.rs is Windows-only and this Linux box can't cross-build
it (a dependency's C build fails for the msvc target). Needs cargo check/clippy on
the Windows build box / CI. rustfmt-clean; the windows-crate FFI signatures were
verified against the on-disk 0.62.2 bindings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:32:27 +02:00
parent 3c16c1dd30
commit 644c035a34
+168 -35
View File
@@ -12,8 +12,11 @@
//! Drives the AMF runtime through its **C vtable ABI**: the GPUOpen public headers define
//! C-compatible vtable structs for every interface, and FFmpeg's `amfenc.c` (plain C) drives AMF
//! exclusively through them, so that ABI — not the C++ classes — is the stable, supported
//! surface. The FFI below mirrors ONLY the interfaces/slots we call, pinned to header version
//! **v1.4.36** (`AMF_FULL_VERSION` 1.4.36.0, gated at load via `AMFQueryVersion`). The runtime is
//! surface. The FFI below mirrors ONLY the interfaces/slots we call, written against header
//! version **v1.4.36** (`AMF_FULL_VERSION` 1.4.36.0). At load the runtime is accepted down to a
//! stable-ABI floor of **v1.4.34** (the `AMFQueryVersion` gate); the 1.4.35/1.4.36-only encoder
//! features are string-keyed properties that degrade individually on older drivers, not vtable
//! changes (see [`sys::AMF_MIN_VERSION`]). The runtime is
//! loaded at runtime from the driver-installed `amfrt64.dll` — exactly as `nvenc.rs` loads
//! `nvEncodeAPI64.dll` — so this compiles unconditionally on Windows (**no build feature, no new
//! dependency**). Since Phase 3 (design §7) this is the sole AMD dispatch: a box without a
@@ -50,6 +53,7 @@ use std::collections::VecDeque;
use std::ffi::c_void;
use std::ptr;
use windows::core::{w, Interface, PCWSTR};
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11Resource, ID3D11Texture2D, D3D11_BIND_RENDER_TARGET,
D3D11_BIND_SHADER_RESOURCE, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
@@ -57,9 +61,15 @@ use windows::Win32::Graphics::Direct3D11::{
use windows::Win32::Graphics::Dxgi::Common::{
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_SAMPLE_DESC,
};
use windows::Win32::Storage::FileSystem::{
GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, VS_FIXEDFILEINFO,
};
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
// ---------------------------------------------------------------------------------------------
// Mirrored AMF C ABI (pinned to GPUOpen header release v1.4.36 — amf/public/include).
// Mirrored AMF C ABI (written against GPUOpen header release v1.4.36 — amf/public/include; every
// slot below is a base-interface slot whose layout is stable since <= v1.4.34, the loader's
// accepted ABI floor, so the mirror is valid on every runtime the loader admits).
//
// Layout rules this mirror relies on: every AMF interface is a struct whose sole member is a
// pointer to a C vtable; derived interfaces PREPEND their base's slots in order (AMFInterface →
@@ -118,11 +128,24 @@ mod sys {
}
}
/// The pinned header version this FFI mirrors: `AMF_FULL_VERSION` for 1.4.36.0
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). The loader requires the runtime to report at
/// least this via `AMFQueryVersion`, guaranteeing every vtable slot mirrored below exists at
/// the mirrored offset.
pub const AMF_PINNED_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
/// The AMF header release this FFI mirror was written against: `AMF_FULL_VERSION` for 1.4.36.0
/// (core/Version.h `AMF_MAKE_FULL_VERSION`). This is the version claimed to `AMFInit` — but
/// capped at the runtime's own reported version (see `load_factory`), so an older-but-accepted
/// runtime is asked only for the ABI it actually provides.
pub const AMF_HEADER_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (36u64 << 16);
/// The oldest AMF runtime the loader accepts (`AMF_FULL_VERSION` 1.4.34.0 — AMD Adrenalin
/// 24.6.1). This is an **ABI floor, not a feature floor**: every vtable slot mirrored in this
/// module belongs to a base interface (`AMFFactory`/`AMFContext`/`AMFComponent`/`AMFData`/
/// `AMFBuffer`) whose layout has been stable — append-only, no mid-vtable insertions — since
/// well before 1.4.34, so a 1.4.34 runtime is guaranteed to expose every mirrored slot at its
/// mirrored offset. Everything 1.4.35/1.4.36 added that this path can touch (new HQ presets,
/// AV1 B-frame / picture management) is a *string-keyed encoder property*, applied through
/// [`set_prop`] with `required=false` — a runtime that lacks it rejects the property (logged)
/// and the feature degrades, rather than shifting any vtable offset. Below this floor the
/// mirror is not guaranteed to match, so the loader declines cleanly (an old-driver decline,
/// never UB).
pub const AMF_MIN_VERSION: u64 = (1u64 << 48) | (4u64 << 32) | (34u64 << 16);
/// `AMF_SURFACE_FORMAT` (core/Surface.h).
pub const AMF_SURFACE_NV12: i32 = 1;
@@ -474,6 +497,76 @@ fn amf_ok(r: sys::AmfResult, what: &str) -> Result<()> {
}
}
/// Format an `AMF_FULL_VERSION` u64 as `major.minor.patch` (the build field is dropped — every
/// version comparison and log line in this module ignores it).
fn amf_version_str(v: u64) -> String {
format!(
"{}.{}.{}",
(v >> 48) & 0xffff,
(v >> 32) & 0xffff,
(v >> 16) & 0xffff
)
}
/// Best-effort on-disk identity of the loaded `amfrt64.dll`: `(full path, file-version resource)`.
/// The file-version is the driver build baked into the DLL (e.g. `31.0.24033.1003`), which — unlike
/// the AMF runtime version — is directly comparable to the display-driver version. This pins down
/// the Boot Camp failure mode: the display driver can report 25.x while the `amfrt64.dll` actually
/// loaded (System32, via the SYSTEM32-only search) is a stale build whose AMF + file versions lag
/// it. Diagnostics only — any failure yields `None` and never affects the load.
///
/// # Safety
/// `module` must be a live module handle owned by the caller (here, the never-unloaded
/// `amfrt64.dll` from `LoadLibraryExW`).
unsafe fn loaded_dll_identity(module: HMODULE) -> (Option<String>, Option<String>) {
let mut buf = [0u16; 512];
let n = GetModuleFileNameW(Some(module), &mut buf) as usize;
// n == 0 → failed; n >= len → path truncated (no guaranteed NUL) — bail either way. Otherwise
// `GetModuleFileNameW` NUL-terminates at `buf[n]`, so `buf` is a valid PCWSTR for the query.
if n == 0 || n >= buf.len() {
return (None, None);
}
let path = String::from_utf16_lossy(&buf[..n]);
(Some(path), dll_file_version(PCWSTR(buf.as_ptr())))
}
/// Read a DLL's `\`-root `VS_FIXEDFILEINFO` file version as `a.b.c.d`. `None` if the file has no
/// version resource or any step fails (diagnostics only).
///
/// # Safety
/// `path` must be a valid NUL-terminated wide string pointing at a readable file path.
unsafe fn dll_file_version(path: PCWSTR) -> Option<String> {
let size = GetFileVersionInfoSizeW(path, None);
if size == 0 {
return None;
}
let mut block = vec![0u8; size as usize];
GetFileVersionInfoW(path, None, size, block.as_mut_ptr() as *mut c_void).ok()?;
let mut value: *mut c_void = ptr::null_mut();
let mut len: u32 = 0;
let ok = VerQueryValueW(
block.as_ptr() as *const c_void,
w!("\\"),
&mut value,
&mut len,
);
if !ok.as_bool() || value.is_null() || (len as usize) < std::mem::size_of::<VS_FIXEDFILEINFO>()
{
return None;
}
// SAFETY: on success `VerQueryValueW` points `value` at a `VS_FIXEDFILEINFO` living inside
// `block` and valid for `len` bytes (checked >= its size); `block` outlives this read.
let ffi = &*(value as *const VS_FIXEDFILEINFO);
let (ms, ls) = (ffi.dwFileVersionMS, ffi.dwFileVersionLS);
Some(format!(
"{}.{}.{}.{}",
ms >> 16,
ms & 0xffff,
ls >> 16,
ls & 0xffff
))
}
// ---------------------------------------------------------------------------------------------
// Runtime loader (the analogue of nvenc.rs `load_api`): resolve amfrt64.dll's two exports once
// per process, gate on the pinned header version, and keep the factory singleton forever.
@@ -493,9 +586,9 @@ unsafe impl Send for AmfLib {}
unsafe impl Sync for AmfLib {}
/// Resolve the AMF runtime once per process. `Err` = AMF genuinely unavailable here (no AMD
/// driver / `amfrt64.dll`, or a runtime older than the pinned v1.4.36 headers) — callers fail
/// their open cleanly with an "update the AMD driver" message (the session then fails; since
/// Phase 3 there is no libavcodec AMF fallback).
/// driver / `amfrt64.dll`, or a runtime older than the minimum-supported v1.4.34 — see
/// [`sys::AMF_MIN_VERSION`]) — callers fail their open cleanly with an "update the AMD driver"
/// message (the session then fails; since Phase 3 there is no libavcodec AMF fallback).
fn try_factory() -> std::result::Result<&'static AmfLib, &'static str> {
static LIB: std::sync::OnceLock<std::result::Result<AmfLib, String>> =
std::sync::OnceLock::new();
@@ -520,10 +613,11 @@ fn load_factory() -> std::result::Result<AmfLib, String> {
// System32-only search path keeps a planted DLL out of the SYSTEM-service process (same
// hardening as the NVENC loader). The two transmutes cast the resolved exports to their
// documented prototypes (core/Factory.h `AMFQueryVersion_Fn`/`AMFInit_Fn`).
// `AMFQueryVersion` writes one u64 through a live pointer; `AMFInit` is passed the pinned
// header version and fills `factory` with the process-global singleton only on AMF_OK
// (null-checked after). The module is never freed, so the factory and both entry points stay
// valid for the process lifetime.
// `AMFQueryVersion` writes one u64 through a live pointer; `AMFInit` is passed the header
// version capped at the runtime's own (never newer than what the runtime provides) and fills
// `factory` with the process-global singleton only on AMF_OK (null-checked after). The module
// is never freed, so the factory and both entry points stay valid for the process lifetime.
// `loaded_dll_identity` only reads that module's own path + version resource (diagnostics).
unsafe {
let module = LoadLibraryExW(w!("amfrt64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
.map_err(|e| {
@@ -540,33 +634,75 @@ fn load_factory() -> std::result::Result<AmfLib, String> {
if r != sys::AMF_OK {
return Err(format!("AMFQueryVersion failed: {} ({r})", result_name(r)));
}
// The vtable layouts mirrored above are the pinned header's; an older runtime may lack
// trailing slots (or predate an insertion), so require at least the pinned version — an
// old driver is a clean decline (clear session error), not UB.
if version < sys::AMF_PINNED_VERSION {
// On-disk identity of the DLL we actually loaded (System32's amfrt64.dll, via the
// SYSTEM32-only search above) — the Boot Camp diagnostic: the display driver can read 25.x
// while THIS file is a stale build whose AMF + file versions lag it, so an "update the
// driver" decline is confusing (they did — this DLL just didn't follow).
let (dll_path, dll_file_ver) = loaded_dll_identity(module);
let dll_desc = format!(
"{}{}",
dll_path.as_deref().unwrap_or("amfrt64.dll"),
dll_file_ver
.as_deref()
.map(|v| format!(" (file version {v})"))
.unwrap_or_default(),
);
// Accept any runtime at or above the ABI floor (AMF_MIN_VERSION): every vtable slot this
// module mirrors predates it, so its layout is guaranteed; 1.4.35/1.4.36-only encoder
// features are string-keyed properties that degrade via `set_prop(required=false)`, not
// vtable changes. Below the floor the mirror is not guaranteed — decline cleanly (a clear
// old-driver session error, never UB).
if version < sys::AMF_MIN_VERSION {
return Err(format!(
"AMF runtime {}.{}.{} is older than the host's pinned headers 1.4.36 — update \
the AMD driver",
(version >> 48) & 0xffff,
(version >> 32) & 0xffff,
(version >> 16) & 0xffff,
"AMF runtime {amf} (loaded from {dll_desc}) is older than the minimum supported \
1.4.34 — update the AMD driver (Adrenalin 24.6.1+; 25.1.1+ for the \
fully-validated feature set). If the display driver already reports a newer \
version, this amfrt64.dll did not update — reboot, then DDU + reinstall so \
System32's copy is refreshed.",
amf = amf_version_str(version),
));
}
// Claim no more than the runtime provides: passing a version NEWER than the runtime can
// make AMFInit reject an otherwise-usable older driver, and this path only ever calls ABI
// present at/below the runtime's version. On a >=1.4.36 runtime this is a no-op (== header).
let init_version = sys::AMF_HEADER_VERSION.min(version);
let mut factory: *mut sys::AmfFactory = ptr::null_mut();
let r = init(sys::AMF_PINNED_VERSION, &mut factory);
let r = init(init_version, &mut factory);
if r != sys::AMF_OK {
return Err(format!("AMFInit failed: {} ({r})", result_name(r)));
}
if factory.is_null() {
return Err("AMFInit returned a null factory".into());
}
// Visible once per process (this runs inside `try_factory`'s OnceLock init). Both the AMF
// runtime version AND the loaded DLL's path + file version are logged, so a field report
// shows "display driver says 25.x but amfrt64.dll is an old build" at a glance.
if version >= sys::AMF_HEADER_VERSION {
tracing::info!(
amf_version = %amf_version_str(version),
dll = %dll_desc,
"AMF runtime loaded (meets the validated 1.4.36 baseline)"
);
} else {
tracing::warn!(
amf_version = %amf_version_str(version),
dll = %dll_desc,
"AMF runtime is older than the validated 1.4.36 baseline — accepted (the core \
encode ABI is stable), but advanced features (LTR / intra-refresh recovery, AV1 \
coded-size alignment, in-band HDR metadata) validated on 1.4.36 may be \
unavailable on this driver and will degrade individually (see the per-property \
logs below). Update to AMD Adrenalin 25.1.1+ for the fully-validated path."
);
}
Ok(AmfLib { factory, version })
}
}
// ---------------------------------------------------------------------------------------------
// Per-codec property tables (names verified against the pinned v1.4.36 headers —
// components/VideoEncoderVCE.h, VideoEncoderHEVC.h and VideoEncoderAV1.h; the enum VALUES differ
// Per-codec property tables (names verified against the v1.4.36 headers —
// components/VideoEncoderVCE.h, VideoEncoderHEVC.h and VideoEncoderAV1.h; a name a pre-1.4.36
// runtime doesn't recognise is applied through `set_prop(required=false)`, which logs and
// continues, so an older driver degrades that one feature rather than failing. The enum VALUES differ
// between the codecs, e.g. CBR is 1 on AVC but 3 on HEVC/AV1, SPEED is 1 vs 10 vs 100, and AV1
// swaps the ULTRA_LOW_LATENCY/LOW_LATENCY usage values relative to AVC/HEVC).
// ---------------------------------------------------------------------------------------------
@@ -1118,15 +1254,12 @@ impl AmfEncoder {
bit_depth: u8,
chroma: ChromaFormat,
) -> Result<Self> {
// The once-per-process runtime version (and the older-than-baseline warning) is logged in
// `load_factory`; this per-session line ties an individual encoder open to that version.
let lib = try_factory().map_err(|e| anyhow!("native AMF unavailable: {e}"))?;
tracing::debug!(
version = %format!(
"{}.{}.{}",
(lib.version >> 48) & 0xffff,
(lib.version >> 32) & 0xffff,
(lib.version >> 16) & 0xffff
),
"AMF runtime loaded"
version = %amf_version_str(lib.version),
"opening AMF encoder"
);
let props = codec_props(codec);
// AV1 is RDNA3+ — probe at open (never assume), so a pre-RDNA3 box fails HERE with a clear
@@ -3062,7 +3195,7 @@ mod tests {
return;
}
};
assert!(lib.version >= sys::AMF_PINNED_VERSION);
assert!(lib.version >= sys::AMF_MIN_VERSION);
// SAFETY: same contracts as `ensure_inner`, minus the external device: `CreateContext`
// fills `ctx` only on AMF_OK; `InitDX11(null)` asks AMF to create its own D3D11 device
// (may fail on exotic boxes — treated as a skip); `CreateComponent` likewise. Guards