From fa58db798b7e29331ea54b8700ee560dd45564ec Mon Sep 17 00:00:00 2001
From: Max Kellermann <max.kellermann@gmail.com>
Date: Mon, 28 Nov 2022 21:58:21 +0100
Subject: [PATCH] lib/fmt/RuntimeError: new library

Replacing FormatRuntimeError().
---
 src/Listen.cxx                                | 11 +--
 src/LogInit.cxx                               |  9 +--
 src/Main.cxx                                  |  1 -
 src/Permission.cxx                            |  9 +--
 src/PlaylistDatabase.cxx                      |  8 +-
 src/SongSave.cxx                              |  9 +--
 src/archive/plugins/Iso9660ArchivePlugin.cxx  | 11 +--
 src/archive/plugins/ZzipArchivePlugin.cxx     | 20 +++--
 src/cmdline/OptionParser.cxx                  |  6 +-
 src/cmdline/meson.build                       |  3 +
 src/config/Block.cxx                          | 12 +--
 src/config/Data.cxx                           |  6 +-
 src/config/File.cxx                           | 25 +++----
 src/config/Param.cxx                          |  4 +-
 src/config/Parser.cxx                         |  4 +-
 src/config/Path.cxx                           |  6 +-
 src/config/PlayerConfig.cxx                   | 10 +--
 src/config/meson.build                        |  1 +
 src/db/Configured.cxx                         |  6 +-
 src/db/DatabaseGlue.cxx                       | 12 +--
 src/db/plugins/ProxyDatabasePlugin.cxx        | 12 +--
 src/db/plugins/simple/DatabaseSave.cxx        | 18 ++---
 src/db/plugins/simple/DirectorySave.cxx       | 12 +--
 .../plugins/upnp/ContentDirectoryService.cxx  |  1 -
 src/decoder/DecoderList.cxx                   |  6 +-
 src/decoder/Thread.cxx                        |  8 +-
 src/decoder/plugins/FlacPcm.cxx               |  6 +-
 src/decoder/plugins/MikmodDecoderPlugin.cxx   |  6 +-
 src/decoder/plugins/ModplugDecoderPlugin.cxx  | 10 +--
 src/decoder/plugins/OpenmptDecoderPlugin.cxx  |  1 -
 src/decoder/plugins/OpusDecoderPlugin.cxx     | 14 ++--
 src/decoder/plugins/SidplayDecoderPlugin.cxx  | 13 ++--
 src/decoder/plugins/WavpackDecoderPlugin.cxx  | 11 +--
 src/encoder/Configured.cxx                    |  4 +-
 src/encoder/meson.build                       |  3 +
 src/encoder/plugins/FlacEncoderPlugin.cxx     | 28 +++----
 src/encoder/plugins/LameEncoderPlugin.cxx     |  8 +-
 src/encoder/plugins/ShineEncoderPlugin.cxx    | 12 +--
 src/encoder/plugins/TwolameEncoderPlugin.cxx  |  8 +-
 src/encoder/plugins/VorbisEncoderPlugin.cxx   |  8 +-
 src/event/ServerSocket.cxx                    |  6 +-
 src/filter/Factory.cxx                        |  6 +-
 src/filter/LoadOne.cxx                        |  6 +-
 src/filter/meson.build                        |  3 +
 src/filter/plugins/RouteFilterPlugin.cxx      | 10 +--
 src/filter/plugins/TwoFilters.cxx             |  8 +-
 src/input/Init.cxx                            |  6 +-
 src/input/plugins/CdioParanoiaInputPlugin.cxx | 14 ++--
 src/input/plugins/FileInputPlugin.cxx         | 14 ++--
 src/input/plugins/QobuzErrorParser.cxx        | 10 +--
 src/input/plugins/UringInputPlugin.cxx        |  4 +-
 src/io/FileReader.cxx                         |  2 +-
 src/lib/alsa/HwSetup.cxx                      |  6 +-
 src/lib/alsa/NonBlock.cxx                     |  1 -
 src/lib/dbus/Error.cxx                        |  4 +-
 src/lib/ffmpeg/Error.cxx                      |  4 +-
 src/lib/ffmpeg/Filter.cxx                     |  4 +-
 .../fmt/RuntimeError.cxx}                     | 43 +++--------
 src/lib/fmt/RuntimeError.hxx                  | 75 +++++++++++++++++++
 src/lib/fmt/meson.build                       |  1 +
 src/lib/icu/Init.cxx                          |  1 -
 src/lib/jack/Dynamic.hxx                      |  3 +-
 src/lib/pulse/Error.cxx                       |  1 -
 src/lib/yajl/Handle.cxx                       |  6 +-
 src/mixer/plugins/AlsaMixerPlugin.cxx         |  4 +-
 src/mixer/plugins/OssMixerPlugin.cxx          |  6 +-
 src/mixer/plugins/PulseMixerPlugin.cxx        |  8 +-
 src/neighbor/Glue.cxx                         | 10 +--
 src/net/Resolver.cxx                          | 20 +++--
 src/net/meson.build                           |  3 +
 src/output/Filtered.cxx                       | 14 ++--
 src/output/Init.cxx                           |  5 +-
 src/output/MultipleOutputs.cxx                | 12 +--
 src/output/Source.cxx                         |  7 +-
 src/output/Thread.cxx                         |  6 +-
 src/output/plugins/AlsaOutputPlugin.cxx       |  6 +-
 src/output/plugins/AoOutputPlugin.cxx         | 10 +--
 src/output/plugins/FifoOutputPlugin.cxx       |  9 +--
 src/output/plugins/JackOutputPlugin.cxx       | 22 +++---
 src/output/plugins/OSXOutputPlugin.cxx        | 25 +++----
 src/output/plugins/OpenALOutputPlugin.cxx     | 10 +--
 src/output/plugins/ShoutOutputPlugin.cxx      | 44 +++++------
 src/output/plugins/WinmmOutputPlugin.cxx      | 10 +--
 .../plugins/wasapi/WasapiOutputPlugin.cxx     |  6 +-
 src/pcm/AudioParser.cxx                       | 18 ++---
 src/pcm/ChannelsConverter.cxx                 |  7 +-
 src/pcm/CheckAudioFormat.cxx                  | 14 ++--
 src/pcm/ConfiguredResampler.cxx               | 14 ++--
 src/pcm/FormatConverter.cxx                   |  8 +-
 src/pcm/LibsamplerateResampler.cxx            | 14 ++--
 src/pcm/SoxrResampler.cxx                     | 64 +++++++---------
 src/pcm/Volume.cxx                            |  7 +-
 src/pcm/meson.build                           |  1 +
 src/playlist/plugins/FlacPlaylistPlugin.cxx   |  6 +-
 src/song/Filter.cxx                           |  5 +-
 src/storage/Configured.cxx                    |  4 +-
 src/storage/plugins/CurlStorage.cxx           |  6 +-
 src/storage/plugins/UdisksStorage.cxx         |  6 +-
 src/tag/Config.cxx                            |  6 +-
 src/tag/meson.build                           |  1 +
 src/unix/Daemon.cxx                           |  7 +-
 src/zeroconf/avahi/Helper.cxx                 |  6 +-
 test/read_conf.cxx                            |  6 +-
 test/run_filter.cxx                           |  6 +-
 test/run_output.cxx                           | 10 +--
 105 files changed, 551 insertions(+), 502 deletions(-)
 rename src/{util/RuntimeError.hxx => lib/fmt/RuntimeError.cxx} (58%)
 create mode 100644 src/lib/fmt/RuntimeError.hxx

diff --git a/src/Listen.cxx b/src/Listen.cxx
index 8bbe300ee..9ae856048 100644
--- a/src/Listen.cxx
+++ b/src/Listen.cxx
@@ -27,6 +27,7 @@
 #include "config/Net.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "net/AllocatedSocketAddress.hxx"
 #include "net/UniqueSocketDescriptor.hxx"
 #include "net/SocketUtil.hxx"
@@ -35,7 +36,6 @@
 #include "fs/StandardDirectory.hxx"
 #include "fs/XDG.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <sys/stat.h>
 
@@ -129,9 +129,9 @@ listen_global_init(const ConfigData &config, ClientListener &listener)
 			ServerSocketAddGeneric(listener, param.value.c_str(),
 					       port);
 		} catch (...) {
-			std::throw_with_nested(FormatRuntimeError("Failed to listen on %s (line %i)",
-								  param.value.c_str(),
-								  param.line));
+			std::throw_with_nested(FmtRuntimeError("Failed to listen on {} (line {})",
+							       param.value,
+							       param.line));
 		}
 	}
 
@@ -146,7 +146,8 @@ listen_global_init(const ConfigData &config, ClientListener &listener)
 		try {
 			listener.AddPort(port);
 		} catch (...) {
-			std::throw_with_nested(FormatRuntimeError("Failed to listen on *:%d: ", port));
+			std::throw_with_nested(FmtRuntimeError("Failed to listen on *:{}",
+							       port));
 		}
 	}
 
diff --git a/src/LogInit.cxx b/src/LogInit.cxx
index d98dbfb28..f7e051b2d 100644
--- a/src/LogInit.cxx
+++ b/src/LogInit.cxx
@@ -22,13 +22,13 @@
 #include "LogBackend.hxx"
 #include "Log.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "config/Param.hxx"
 #include "config/Data.hxx"
 #include "config/Option.hxx"
 #include "fs/AllocatedPath.hxx"
 #include "fs/FileSystem.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
 #include "lib/fmt/SystemError.hxx"
 
@@ -79,9 +79,8 @@ log_init_file(int line)
 	out_fd = open_log_file();
 	if (out_fd < 0) {
 #ifdef _WIN32
-		const std::string out_path_utf8 = out_path.ToUTF8();
-		throw FormatRuntimeError("failed to open log file \"%s\" (config line %d)",
-					 out_path_utf8.c_str(), line);
+		throw FmtRuntimeError("failed to open log file \"{}\" (config line {})",
+				      out_path, line);
 #else
 		throw FmtErrno("failed to open log file \"{}\" (config line {})",
 			       out_path, line);
@@ -109,7 +108,7 @@ parse_log_level(const char *value)
 	else if (StringIsEqual(value, "error"))
 		return LogLevel::ERROR;
 	else
-		throw FormatRuntimeError("unknown log level \"%s\"", value);
+		throw FmtRuntimeError("unknown log level \"{}\"", value);
 }
 
 #endif
diff --git a/src/Main.cxx b/src/Main.cxx
index e5c2d8f08..278772ec4 100644
--- a/src/Main.cxx
+++ b/src/Main.cxx
@@ -59,7 +59,6 @@
 #include "config/Domain.hxx"
 #include "config/Parser.hxx"
 #include "config/PartitionConfig.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/ScopeExit.hxx"
 
 #ifdef ENABLE_DAEMON
diff --git a/src/Permission.cxx b/src/Permission.cxx
index e693c834c..2309f43f1 100644
--- a/src/Permission.cxx
+++ b/src/Permission.cxx
@@ -22,11 +22,11 @@
 #include "config/Param.hxx"
 #include "config/Data.hxx"
 #include "config/Option.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "net/AddressInfo.hxx"
 #include "net/Resolver.hxx"
 #include "net/ToString.hxx"
 #include "util/IterableSplitString.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringSplit.hxx"
 
 #include <cassert>
@@ -68,8 +68,7 @@ ParsePermission(std::string_view s)
 		if (s == i->name)
 			return i->value;
 
-	throw FormatRuntimeError("unknown permission \"%.*s\"",
-				 int(s.size()), s.data());
+	throw FmtRuntimeError("unknown permission \"{}\"", s);
 }
 
 static unsigned
@@ -103,8 +102,8 @@ initPermissions(const ConfigData &config)
 			const auto [password, permissions] =
 				Split(value, PERMISSION_PASSWORD_CHAR);
 			if (permissions.data() == nullptr)
-				throw FormatRuntimeError("\"%c\" not found in password string",
-							 PERMISSION_PASSWORD_CHAR);
+				throw FmtRuntimeError("\"{}\" not found in password string",
+						      PERMISSION_PASSWORD_CHAR);
 
 			permission_passwords.emplace(password,
 						     parsePermissions(permissions));
diff --git a/src/PlaylistDatabase.cxx b/src/PlaylistDatabase.cxx
index 7a1515e6b..50461f3db 100644
--- a/src/PlaylistDatabase.cxx
+++ b/src/PlaylistDatabase.cxx
@@ -19,11 +19,11 @@
 
 #include "PlaylistDatabase.hxx"
 #include "db/PlaylistVector.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "io/LineReader.hxx"
 #include "io/BufferedOutputStream.hxx"
 #include "time/ChronoUtil.hxx"
 #include "util/StringStrip.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <fmt/format.h>
 
@@ -55,8 +55,7 @@ playlist_metadata_load(LineReader &file, PlaylistVector &pv, const char *name)
 	       std::strcmp(line, "playlist_end") != 0) {
 		colon = std::strchr(line, ':');
 		if (colon == nullptr || colon == line)
-			throw FormatRuntimeError("unknown line in db: %s",
-						 line);
+			throw FmtRuntimeError("unknown line in db: {}", line);
 
 		*colon++ = 0;
 		value = StripLeft(colon);
@@ -64,8 +63,7 @@ playlist_metadata_load(LineReader &file, PlaylistVector &pv, const char *name)
 		if (std::strcmp(line, "mtime") == 0)
 			pm.mtime = std::chrono::system_clock::from_time_t(strtol(value, nullptr, 10));
 		else
-			throw FormatRuntimeError("unknown line in db: %s",
-						 line);
+			throw FmtRuntimeError("unknown line in db: {}", line);
 	}
 
 	pv.UpdateOrInsert(std::move(pm));
diff --git a/src/SongSave.cxx b/src/SongSave.cxx
index f9dfb7e6f..5df013722 100644
--- a/src/SongSave.cxx
+++ b/src/SongSave.cxx
@@ -23,6 +23,7 @@
 #include "song/DetachedSong.hxx"
 #include "TagSave.hxx"
 #include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "io/LineReader.hxx"
 #include "io/BufferedOutputStream.hxx"
 #include "tag/ParseName.hxx"
@@ -32,7 +33,6 @@
 #include "util/StringAPI.hxx"
 #include "util/StringBuffer.hxx"
 #include "util/StringStrip.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/NumberParser.hxx"
 
 #include <stdlib.h>
@@ -97,9 +97,8 @@ song_load(LineReader &file, const char *uri,
 	while ((line = file.ReadLine()) != nullptr &&
 	       !StringIsEqual(line, SONG_END)) {
 		char *colon = std::strchr(line, ':');
-		if (colon == nullptr || colon == line) {
-			throw FormatRuntimeError("unknown line in db: %s", line);
-		}
+		if (colon == nullptr || colon == line)
+			throw FmtRuntimeError("unknown line in db: {}", line);
 
 		*colon++ = 0;
 		const char *value = StripLeft(colon);
@@ -134,7 +133,7 @@ song_load(LineReader &file, const char *uri,
 			song.SetStartTime(SongTime::FromMS(start_ms));
 			song.SetEndTime(SongTime::FromMS(end_ms));
 		} else {
-			throw FormatRuntimeError("unknown line in db: %s", line);
+			throw FmtRuntimeError("unknown line in db: {}", line);
 		}
 	}
 
diff --git a/src/archive/plugins/Iso9660ArchivePlugin.cxx b/src/archive/plugins/Iso9660ArchivePlugin.cxx
index 84e82ae81..47197624b 100644
--- a/src/archive/plugins/Iso9660ArchivePlugin.cxx
+++ b/src/archive/plugins/Iso9660ArchivePlugin.cxx
@@ -27,7 +27,8 @@
 #include "../ArchiveVisitor.hxx"
 #include "input/InputStream.hxx"
 #include "fs/Path.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringCompare.hxx"
 #include "util/UTF8.hxx"
 
@@ -46,8 +47,8 @@ struct Iso9660 {
 	explicit Iso9660(Path path)
 		:iso(iso9660_open(path.c_str())) {
 		if (iso == nullptr)
-			throw FormatRuntimeError("Failed to open ISO9660 file %s",
-						 path.c_str());
+			throw FmtRuntimeError("Failed to open ISO9660 file {}",
+					      path);
 	}
 
 	~Iso9660() noexcept {
@@ -238,8 +239,8 @@ Iso9660ArchiveFile::OpenStream(const char *pathname,
 {
 	auto statbuf = iso9660_ifs_stat_translate(iso->iso, pathname);
 	if (statbuf == nullptr)
-		throw FormatRuntimeError("not found in the ISO file: %s",
-					 pathname);
+		throw FmtRuntimeError("not found in the ISO file: {}",
+				      pathname);
 
 	const lsn_t lsn = statbuf->lsn;
 	const offset_type size = statbuf->size;
diff --git a/src/archive/plugins/ZzipArchivePlugin.cxx b/src/archive/plugins/ZzipArchivePlugin.cxx
index 258242a34..871e39edf 100644
--- a/src/archive/plugins/ZzipArchivePlugin.cxx
+++ b/src/archive/plugins/ZzipArchivePlugin.cxx
@@ -26,25 +26,24 @@
 #include "../ArchiveFile.hxx"
 #include "../ArchiveVisitor.hxx"
 #include "input/InputStream.hxx"
+#include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/Path.hxx"
 #include "lib/fmt/SystemError.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/UTF8.hxx"
 
 #include <zzip/zzip.h>
 
 #include <utility>
 
-#include <cinttypes> /* for PRIoffset (PRIu64) */
-
 struct ZzipDir {
 	ZZIP_DIR *const dir;
 
 	explicit ZzipDir(Path path)
 		:dir(zzip_dir_open(path.c_str(), nullptr)) {
 		if (dir == nullptr)
-			throw FormatRuntimeError("Failed to open ZIP file %s",
-						 path.c_str());
+			throw FmtRuntimeError("Failed to open ZIP file {}",
+					      path);
 	}
 
 	~ZzipDir() noexcept {
@@ -140,9 +139,9 @@ ZzipArchiveFile::OpenStream(const char *pathname,
 					      pathname);
 
 		default:
-			throw FormatRuntimeError("Failed to open '%s' in ZIP file: %s",
-						 pathname,
-						 zzip_strerror(error));
+			throw FmtRuntimeError("Failed to open '{}' in ZIP file: {}",
+					      pathname,
+					      zzip_strerror(error));
 		}
 	}
 
@@ -161,9 +160,8 @@ ZzipInputStream::Read(std::unique_lock<Mutex> &, void *ptr, size_t read_size)
 		throw std::runtime_error("zzip_file_read() has failed");
 
 	if (nbytes == 0 && !IsEOF())
-		throw FormatRuntimeError("Unexpected end of file %s"
-					 " at %" PRIoffset " of %" PRIoffset,
-					 GetURI(), GetOffset(), GetSize());
+		throw FmtRuntimeError("Unexpected end of file {} at {} of {}",
+				      GetURI(), GetOffset(), GetSize());
 
 	offset = zzip_tell(file);
 	return nbytes;
diff --git a/src/cmdline/OptionParser.cxx b/src/cmdline/OptionParser.cxx
index 8c5997fd3..656c59d1f 100644
--- a/src/cmdline/OptionParser.cxx
+++ b/src/cmdline/OptionParser.cxx
@@ -19,7 +19,7 @@
 
 #include "OptionParser.hxx"
 #include "OptionDef.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringCompare.hxx"
 
 static const char *
@@ -37,7 +37,7 @@ OptionParser::CheckShiftValue(const char *s, const OptionDef &option)
 		return nullptr;
 
 	if (args.empty())
-		throw FormatRuntimeError("Value expected after %s", s);
+		throw FmtRuntimeError("Value expected after {}", s);
 
 	return Shift(args);
 }
@@ -78,7 +78,7 @@ OptionParser::IdentifyOption(const char *s)
 		}
 	}
 
-	throw FormatRuntimeError("Unknown option: %s", s);
+	throw FmtRuntimeError("Unknown option: {}", s);
 }
 
 OptionParser::Result
diff --git a/src/cmdline/meson.build b/src/cmdline/meson.build
index 1a160a1a2..653c41387 100644
--- a/src/cmdline/meson.build
+++ b/src/cmdline/meson.build
@@ -2,6 +2,9 @@ cmdline = static_library(
   'cmdline',
   'OptionParser.cxx',
   include_directories: inc,
+  dependencies: [
+    fmt_dep,
+  ],
 )
 
 cmdline_dep = declare_dependency(
diff --git a/src/config/Block.cxx b/src/config/Block.cxx
index af32337d8..e75d3a51f 100644
--- a/src/config/Block.cxx
+++ b/src/config/Block.cxx
@@ -21,15 +21,15 @@
 #include "Parser.hxx"
 #include "Path.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <stdlib.h>
 
 void
 BlockParam::ThrowWithNested() const
 {
-	std::throw_with_nested(FormatRuntimeError("Error in setting \"%s\" on line %i",
-						  name.c_str(), line));
+	std::throw_with_nested(FmtRuntimeError("Error in setting \"{}\" on line {}",
+					       name, line));
 }
 
 int
@@ -39,7 +39,7 @@ BlockParam::GetIntValue() const
 	char *endptr;
 	long value2 = strtol(s, &endptr, 0);
 	if (endptr == s || *endptr != 0)
-		throw FormatRuntimeError("Not a valid number in line %i", line);
+		throw FmtRuntimeError("Not a valid number in line {}", line);
 
 	return value2;
 }
@@ -147,6 +147,6 @@ ConfigBlock::GetBlockValue(const char *name, bool default_value) const
 void
 ConfigBlock::ThrowWithNested() const
 {
-	std::throw_with_nested(FormatRuntimeError("Error in block on line %i",
-						  line));
+	std::throw_with_nested(FmtRuntimeError("Error in block on line {}",
+					       line));
 }
diff --git a/src/config/Data.cxx b/src/config/Data.cxx
index 4091a43fc..2aa26c6ec 100644
--- a/src/config/Data.cxx
+++ b/src/config/Data.cxx
@@ -20,7 +20,7 @@
 #include "Data.hxx"
 #include "Parser.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
 
 #include <stdlib.h>
@@ -157,8 +157,8 @@ ConfigData::FindBlock(ConfigBlockOption option,
 	for (const auto &block : GetBlockList(option)) {
 		const char *value2 = block.GetBlockValue(key);
 		if (value2 == nullptr)
-			throw FormatRuntimeError("block without '%s' in line %d",
-						 key, block.line);
+			throw FmtRuntimeError("block without '{}' in line {}",
+					      key, block.line);
 
 		if (StringIsEqual(value2, value))
 			return &block;
diff --git a/src/config/File.cxx b/src/config/File.cxx
index a72472723..4652a3626 100644
--- a/src/config/File.cxx
+++ b/src/config/File.cxx
@@ -23,12 +23,12 @@
 #include "Block.hxx"
 #include "Templates.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "system/Error.hxx"
 #include "util/Tokenizer.hxx"
 #include "util/StringStrip.hxx"
 #include "util/StringAPI.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "fs/FileSystem.hxx"
 #include "fs/List.hxx"
 #include "fs/Path.hxx"
@@ -70,8 +70,8 @@ config_read_name_value(ConfigBlock &block, char *input, unsigned line)
 
 	const BlockParam *bp = block.GetBlockParam(name);
 	if (bp != nullptr)
-		throw FormatRuntimeError("\"%s\" is duplicate, first defined on line %i",
-					 name, bp->line);
+		throw FmtRuntimeError("\"{}\" is duplicate, first defined on line {}",
+				      name, bp->line);
 
 	block.AddBlockParam(name, value, line);
 }
@@ -123,10 +123,10 @@ ReadConfigBlock(ConfigData &config_data, BufferedReader &reader,
 
 	if (!option.repeatable)
 		if (const auto *block = config_data.GetBlock(o))
-			throw FormatRuntimeError("config parameter \"%s\" is first defined "
-						 "on line %d and redefined on line %u\n",
-						 name, block->line,
-						 reader.GetLineNumber());
+			throw FmtRuntimeError("config parameter \"{}\" is first defined "
+					      "on line {} and redefined on line {}",
+					      name, block->line,
+					      reader.GetLineNumber());
 
 	/* now parse the block or the value */
 
@@ -227,8 +227,8 @@ ReadConfigFile(ConfigData &config_data, BufferedReader &reader, Path directory)
 			ReadConfigBlock(config_data, reader, name, bo,
 					tokenizer);
 		} else {
-			throw FormatRuntimeError("unrecognized parameter: %s\n",
-						 name);
+			throw FmtRuntimeError("unrecognized parameter: {}",
+					      name);
 		}
 	}
 }
@@ -247,9 +247,8 @@ ReadConfigFile(ConfigData &config_data, Path path)
 	try {
 		ReadConfigFile(config_data, reader, path.GetDirectoryName());
 	} catch (...) {
-		const std::string path_utf8 = path.ToUTF8();
-		std::throw_with_nested(FormatRuntimeError("Error in %s line %u",
-							  path_utf8.c_str(),
-							  reader.GetLineNumber()));
+		std::throw_with_nested(FmtRuntimeError("Error in {} line {}",
+						       path,
+						       reader.GetLineNumber()));
 	}
 }
diff --git a/src/config/Param.cxx b/src/config/Param.cxx
index 91ca2caa4..8f4fd10f1 100644
--- a/src/config/Param.cxx
+++ b/src/config/Param.cxx
@@ -20,14 +20,14 @@
 #include "Param.hxx"
 #include "Path.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <stdexcept>
 
 void
 ConfigParam::ThrowWithNested() const
 {
-	std::throw_with_nested(FormatRuntimeError("Error on line %i", line));
+	std::throw_with_nested(FmtRuntimeError("Error on line {}", line));
 }
 
 AllocatedPath
diff --git a/src/config/Parser.cxx b/src/config/Parser.cxx
index da437fb7c..fae9c4c93 100644
--- a/src/config/Parser.cxx
+++ b/src/config/Parser.cxx
@@ -18,7 +18,7 @@
  */
 
 #include "Parser.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringStrip.hxx"
 #include "util/StringUtil.hxx"
 
@@ -36,7 +36,7 @@ ParseBool(const char *value)
 	if (StringArrayContainsCase(f, value))
 		return false;
 
-	throw FormatRuntimeError(R"(Not a valid boolean ("yes" or "no"): "%s")", value);
+	throw FmtRuntimeError(R"(Not a valid boolean ("yes" or "no"): "{}")", value);
 }
 
 long
diff --git a/src/config/Path.cxx b/src/config/Path.cxx
index 36f7d7b51..370a0f2f8 100644
--- a/src/config/Path.cxx
+++ b/src/config/Path.cxx
@@ -22,7 +22,7 @@
 #include "fs/AllocatedPath.hxx"
 #include "fs/Traits.hxx"
 #include "fs/StandardDirectory.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringSplit.hxx"
 
 #include <cassert>
@@ -40,7 +40,7 @@ GetHome(const char *user)
 {
 	AllocatedPath result = GetHomeDir(user);
 	if (result.IsNull())
-		throw FormatRuntimeError("no such user: %s", user);
+		throw FmtRuntimeError("no such user: {}", user);
 
 	return result;
 }
@@ -107,7 +107,7 @@ ParsePath(const char *path)
 				/ AllocatedPath::FromUTF8Throw(rest);
 		}
 	} else if (!PathTraitsUTF8::IsAbsolute(path)) {
-		throw FormatRuntimeError("not an absolute path: %s", path);
+		throw FmtRuntimeError("not an absolute path: {}", path);
 	} else {
 #endif
 		return AllocatedPath::FromUTF8Throw(path);
diff --git a/src/config/PlayerConfig.cxx b/src/config/PlayerConfig.cxx
index 3980e8556..f2fed568d 100644
--- a/src/config/PlayerConfig.cxx
+++ b/src/config/PlayerConfig.cxx
@@ -22,7 +22,7 @@
 #include "Domain.hxx"
 #include "Parser.hxx"
 #include "pcm/AudioParser.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "Log.hxx"
 #include "MusicChunk.hxx"
 
@@ -38,8 +38,8 @@ GetBufferChunks(const ConfigData &config)
 		buffer_size = param->With([](const char *s){
 			size_t result = ParseSize(s, KILOBYTE);
 			if (result <= 0)
-				throw FormatRuntimeError("buffer size \"%s\" is not a "
-							 "positive integer", s);
+				throw FmtRuntimeError("buffer size \"{}\" is not a "
+						      "positive integer", s);
 
 			if (result < MIN_BUFFER_SIZE) {
 				FmtWarning(config_domain, "buffer size {} is too small, using {} bytes instead",
@@ -53,8 +53,8 @@ GetBufferChunks(const ConfigData &config)
 
 	unsigned buffer_chunks = buffer_size / CHUNK_SIZE;
 	if (buffer_chunks >= 1 << 15)
-		throw FormatRuntimeError("buffer size \"%lu\" is too big",
-					 (unsigned long)buffer_size);
+		throw FmtRuntimeError("buffer size \"{}\" is too big",
+				      buffer_size);
 
 	return buffer_chunks;
 }
diff --git a/src/config/meson.build b/src/config/meson.build
index a07d98663..92d8bd080 100644
--- a/src/config/meson.build
+++ b/src/config/meson.build
@@ -14,6 +14,7 @@ config = static_library(
   include_directories: inc,
   dependencies: [
     log_dep,
+    fmt_dep,
   ],
 )
 
diff --git a/src/db/Configured.cxx b/src/db/Configured.cxx
index 8af80bf66..3b77b6457 100644
--- a/src/db/Configured.cxx
+++ b/src/db/Configured.cxx
@@ -26,7 +26,7 @@
 #include "fs/AllocatedPath.hxx"
 #include "fs/FileSystem.hxx"
 #include "fs/StandardDirectory.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 DatabasePtr
 CreateConfiguredDatabase(const ConfigData &config,
@@ -37,8 +37,8 @@ CreateConfiguredDatabase(const ConfigData &config,
 	const auto *path = config.GetParam(ConfigOption::DB_FILE);
 
 	if (param != nullptr && path != nullptr)
-		throw FormatRuntimeError("Found both 'database' (line %d) and 'db_file' (line %d) setting",
-					 param->line, path->line);
+		throw FmtRuntimeError("Found both 'database' (line {}) and 'db_file' (line }) setting",
+				      param->line, path->line);
 
 	if (param != nullptr) {
 		param->SetUsed();
diff --git a/src/db/DatabaseGlue.cxx b/src/db/DatabaseGlue.cxx
index 449a14a9b..83460ab33 100644
--- a/src/db/DatabaseGlue.cxx
+++ b/src/db/DatabaseGlue.cxx
@@ -18,11 +18,11 @@
  */
 
 #include "DatabaseGlue.hxx"
+#include "DatabasePlugin.hxx"
 #include "Interface.hxx"
 #include "Registry.hxx"
-#include "util/RuntimeError.hxx"
 #include "config/Block.hxx"
-#include "DatabasePlugin.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 DatabasePtr
 DatabaseGlobalInit(EventLoop &main_event_loop,
@@ -35,14 +35,14 @@ DatabaseGlobalInit(EventLoop &main_event_loop,
 
 	const DatabasePlugin *plugin = GetDatabasePluginByName(plugin_name);
 	if (plugin == nullptr)
-		throw FormatRuntimeError("No such database plugin: %s",
-					 plugin_name);
+		throw FmtRuntimeError("No such database plugin: {}",
+				      plugin_name);
 
 	try {
 		return plugin->create(main_event_loop, io_event_loop,
 				      listener, block);
 	} catch (...) {
-		std::throw_with_nested(FormatRuntimeError("Failed to initialize database plugin '%s'",
-							  plugin_name));
+		std::throw_with_nested(FmtRuntimeError("Failed to initialize database plugin '{}'",
+						       plugin_name));
 	}
 }
diff --git a/src/db/plugins/ProxyDatabasePlugin.cxx b/src/db/plugins/ProxyDatabasePlugin.cxx
index a42aa4fbb..b0eca0f5d 100644
--- a/src/db/plugins/ProxyDatabasePlugin.cxx
+++ b/src/db/plugins/ProxyDatabasePlugin.cxx
@@ -37,9 +37,9 @@
 #include "tag/Builder.hxx"
 #include "tag/Tag.hxx"
 #include "tag/ParseName.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/RecursiveMap.hxx"
 #include "util/ScopeExit.hxx"
-#include "util/RuntimeError.hxx"
 #include "protocol/Ack.hxx"
 #include "event/SocketEvent.hxx"
 #include "event/IdleEvent.hxx"
@@ -507,9 +507,9 @@ ProxyDatabase::Connect()
 		if (mpd_connection_cmp_server_version(connection, 0, 20, 0) < 0) {
 			const unsigned *version =
 				mpd_connection_get_server_version(connection);
-			throw FormatRuntimeError("Connect to MPD %u.%u.%u, but this "
-						 "plugin requires at least version 0.20",
-						 version[0], version[1], version[2]);
+			throw FmtRuntimeError("Connect to MPD {}.{}.{}, but this "
+					      "plugin requires at least version 0.20",
+					      version[0], version[1], version[2]);
 		}
 
 		if (!password.empty() &&
@@ -521,8 +521,8 @@ ProxyDatabase::Connect()
 
 		std::throw_with_nested(host.empty()
 				       ? std::runtime_error("Failed to connect to remote MPD")
-				       : FormatRuntimeError("Failed to connect to remote MPD '%s'",
-							    host.c_str()));
+				       : FmtRuntimeError("Failed to connect to remote MPD '{}'",
+							 host));
 	}
 
 	mpd_connection_set_keepalive(connection, keepalive);
diff --git a/src/db/plugins/simple/DatabaseSave.cxx b/src/db/plugins/simple/DatabaseSave.cxx
index 66860eb54..ae7fec7ea 100644
--- a/src/db/plugins/simple/DatabaseSave.cxx
+++ b/src/db/plugins/simple/DatabaseSave.cxx
@@ -20,13 +20,13 @@
 #include "DatabaseSave.hxx"
 #include "db/DatabaseLock.hxx"
 #include "DirectorySave.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "io/BufferedOutputStream.hxx"
 #include "io/LineReader.hxx"
 #include "tag/ParseName.hxx"
 #include "tag/Settings.hxx"
 #include "fs/Charset.hxx"
 #include "util/StringCompare.hxx"
-#include "util/RuntimeError.hxx"
 #include "Version.h"
 
 #include <fmt/format.h>
@@ -102,21 +102,21 @@ db_load_internal(LineReader &file, Directory &music_root)
 			const char *const old_charset = GetFSCharset();
 			if (*old_charset != 0
 			    && strcmp(new_charset, old_charset) != 0)
-				throw FormatRuntimeError("Existing database has charset "
-							 "\"%s\" instead of \"%s\"; "
-							 "discarding database file",
-							 new_charset, old_charset);
+				throw FmtRuntimeError("Existing database has charset "
+						      "\"{}\" instead of \"{}\"; "
+						      "discarding database file",
+						      new_charset, old_charset);
 		} else if ((p = StringAfterPrefix(line, DB_TAG_PREFIX))) {
 			const char *name = p;
 			TagType tag = tag_name_parse(name);
 			if (tag == TAG_NUM_OF_ITEM_TYPES)
-				throw FormatRuntimeError("Unrecognized tag '%s', "
-							 "discarding database file",
-							 name);
+				throw FmtRuntimeError("Unrecognized tag '{}', "
+						      "discarding database file",
+						      name);
 
 			tags[tag] = true;
 		} else {
-			throw FormatRuntimeError("Malformed line: %s", line);
+			throw FmtRuntimeError("Malformed line: {}", line);
 		}
 	}
 
diff --git a/src/db/plugins/simple/DirectorySave.cxx b/src/db/plugins/simple/DirectorySave.cxx
index e986f3c87..4957061b4 100644
--- a/src/db/plugins/simple/DirectorySave.cxx
+++ b/src/db/plugins/simple/DirectorySave.cxx
@@ -26,10 +26,10 @@
 #include "io/LineReader.hxx"
 #include "io/BufferedOutputStream.hxx"
 #include "time/ChronoUtil.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
 #include "util/StringCompare.hxx"
 #include "util/NumberParser.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <fmt/format.h>
 
@@ -126,8 +126,7 @@ static Directory *
 directory_load_subdir(LineReader &file, Directory &parent, std::string_view name)
 {
 	if (parent.FindChild(name) != nullptr)
-		throw FormatRuntimeError("Duplicate subdirectory '%.*s'",
-					 int(name.size()), name.data());
+		throw FmtRuntimeError("Duplicate subdirectory '{}'", name);
 
 	Directory *directory = parent.CreateChild(name);
 
@@ -141,7 +140,7 @@ directory_load_subdir(LineReader &file, Directory &parent, std::string_view name
 				break;
 
 			if (!ParseLine(*directory, line))
-				throw FormatRuntimeError("Malformed line: %s", line);
+				throw FmtRuntimeError("Malformed line: {}", line);
 		}
 
 		directory_load(file, *directory);
@@ -167,7 +166,8 @@ directory_load(LineReader &file, Directory &directory)
 			const char *name = p;
 
 			if (directory.FindSong(name) != nullptr)
-				throw FormatRuntimeError("Duplicate song '%s'", name);
+				throw FmtRuntimeError("Duplicate song '{}'",
+						      name);
 
 			std::string target;
 			auto detached_song = song_load(file, name,
@@ -182,7 +182,7 @@ directory_load(LineReader &file, Directory &directory)
 			const char *name = p;
 			playlist_metadata_load(file, directory.playlists, name);
 		} else {
-			throw FormatRuntimeError("Malformed line: %s", line);
+			throw FmtRuntimeError("Malformed line: {}", line);
 		}
 	}
 }
diff --git a/src/db/plugins/upnp/ContentDirectoryService.cxx b/src/db/plugins/upnp/ContentDirectoryService.cxx
index d8f179d9e..bc9398c48 100644
--- a/src/db/plugins/upnp/ContentDirectoryService.cxx
+++ b/src/db/plugins/upnp/ContentDirectoryService.cxx
@@ -27,7 +27,6 @@
 #include "lib/upnp/Error.hxx"
 #include "Directory.hxx"
 #include "util/NumberParser.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StringFormat.hxx"
 
diff --git a/src/decoder/DecoderList.cxx b/src/decoder/DecoderList.cxx
index 19fd4b3b6..744295106 100644
--- a/src/decoder/DecoderList.cxx
+++ b/src/decoder/DecoderList.cxx
@@ -23,6 +23,7 @@
 #include "Domain.hxx"
 #include "decoder/Features.h"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "config/Data.hxx"
 #include "config/Block.hxx"
 #include "plugins/AudiofileDecoderPlugin.hxx"
@@ -47,7 +48,6 @@
 #include "plugins/MpcdecDecoderPlugin.hxx"
 #include "plugins/FluidsynthDecoderPlugin.hxx"
 #include "plugins/SidplayDecoderPlugin.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 #include "PluginUnavailable.hxx"
 
@@ -164,8 +164,8 @@ decoder_plugin_init_all(const ConfigData &config)
 				 "Decoder plugin '{}' is unavailable: {}",
 				 plugin.name, std::current_exception());
 		} catch (...) {
-			std::throw_with_nested(FormatRuntimeError("Failed to initialize decoder plugin '%s'",
-								  plugin.name));
+			std::throw_with_nested(FmtRuntimeError("Failed to initialize decoder plugin '{}'",
+							       plugin.name));
 		}
 	}
 }
diff --git a/src/decoder/Thread.cxx b/src/decoder/Thread.cxx
index 0dceedc76..064fec7c2 100644
--- a/src/decoder/Thread.cxx
+++ b/src/decoder/Thread.cxx
@@ -29,11 +29,11 @@
 #include "input/InputStream.hxx"
 #include "input/Registry.hxx"
 #include "DecoderList.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "system/Error.hxx"
 #include "util/MimeType.hxx"
 #include "util/UriExtract.hxx"
 #include "util/UriUtil.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StringCompare.hxx"
@@ -455,8 +455,8 @@ try {
 	if (!allocated.empty())
 		error_uri = allocated.c_str();
 
-	std::throw_with_nested(FormatRuntimeError("Failed to decode %s",
-						  error_uri));
+	std::throw_with_nested(FmtRuntimeError("Failed to decode {}",
+					       error_uri));
 }
 
 /**
@@ -521,7 +521,7 @@ decoder_run_song(DecoderControl &dc,
 		if (!allocated.empty())
 			error_uri = allocated.c_str();
 
-		throw FormatRuntimeError("Failed to decode %s", error_uri);
+		throw FmtRuntimeError("Failed to decode {}", error_uri);
 	}
 
 	dc.client_cond.notify_one();
diff --git a/src/decoder/plugins/FlacPcm.cxx b/src/decoder/plugins/FlacPcm.cxx
index d32a19c02..e3c6090e0 100644
--- a/src/decoder/plugins/FlacPcm.cxx
+++ b/src/decoder/plugins/FlacPcm.cxx
@@ -20,7 +20,7 @@
 #include "FlacPcm.hxx"
 #include "pcm/CheckAudioFormat.hxx"
 #include "lib/xiph/FlacAudioFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <cassert>
 
@@ -30,8 +30,8 @@ FlacPcmImport::Open(unsigned sample_rate, unsigned bits_per_sample,
 {
 	auto sample_format = FlacSampleFormat(bits_per_sample);
 	if (sample_format == SampleFormat::UNDEFINED)
-		throw FormatRuntimeError("Unsupported FLAC bit depth: %u",
-					 bits_per_sample);
+		throw FmtRuntimeError("Unsupported FLAC bit depth: {}",
+				      bits_per_sample);
 
 	audio_format = CheckAudioFormat(sample_rate, sample_format, channels);
 }
diff --git a/src/decoder/plugins/MikmodDecoderPlugin.cxx b/src/decoder/plugins/MikmodDecoderPlugin.cxx
index b3e88c35a..8ee697ff8 100644
--- a/src/decoder/plugins/MikmodDecoderPlugin.cxx
+++ b/src/decoder/plugins/MikmodDecoderPlugin.cxx
@@ -21,10 +21,10 @@
 #include "MikmodDecoderPlugin.hxx"
 #include "../DecoderAPI.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "tag/Handler.hxx"
 #include "fs/Path.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 #include "Version.h"
 
@@ -111,8 +111,8 @@ mikmod_decoder_init(const ConfigBlock &block)
 	mikmod_loop = block.GetBlockValue("loop", false);
 	mikmod_sample_rate = block.GetPositiveValue("sample_rate", 44100U);
 	if (!audio_valid_sample_rate(mikmod_sample_rate))
-		throw FormatRuntimeError("Invalid sample rate in line %d: %u",
-					 block.line, mikmod_sample_rate);
+		throw FmtRuntimeError("Invalid sample rate in line {}: {}",
+				      block.line, mikmod_sample_rate);
 
 	md_device = 0;
 	md_reverb = 0;
diff --git a/src/decoder/plugins/ModplugDecoderPlugin.cxx b/src/decoder/plugins/ModplugDecoderPlugin.cxx
index 35490027a..fabfe0702 100644
--- a/src/decoder/plugins/ModplugDecoderPlugin.cxx
+++ b/src/decoder/plugins/ModplugDecoderPlugin.cxx
@@ -22,8 +22,8 @@
 #include "../DecoderAPI.hxx"
 #include "input/InputStream.hxx"
 #include "tag/Handler.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #ifdef _WIN32
@@ -56,14 +56,14 @@ modplug_decoder_init(const ConfigBlock &block)
 	} else if (strcmp(modplug_resampling_mode_value, "fir") == 0) {
 		modplug_resampling_mode = MODPLUG_RESAMPLE_FIR;
 	} else {
-		throw FormatRuntimeError("Invalid resampling mode in line %d: %s",
-				block.line, modplug_resampling_mode_value);
+		throw FmtRuntimeError("Invalid resampling mode in line {}: {}",
+				      block.line, modplug_resampling_mode_value);
 	}
 
 	modplug_loop_count = block.GetBlockValue("loop_count", 0);
 	if (modplug_loop_count < -1)
-		throw FormatRuntimeError("Invalid loop count in line %d: %i",
-					 block.line, modplug_loop_count);
+		throw FmtRuntimeError("Invalid loop count in line {}: {}",
+				      block.line, modplug_loop_count);
 
 	return true;
 }
diff --git a/src/decoder/plugins/OpenmptDecoderPlugin.cxx b/src/decoder/plugins/OpenmptDecoderPlugin.cxx
index a7b58c968..0fb6deece 100644
--- a/src/decoder/plugins/OpenmptDecoderPlugin.cxx
+++ b/src/decoder/plugins/OpenmptDecoderPlugin.cxx
@@ -25,7 +25,6 @@
 #include "tag/Handler.hxx"
 #include "tag/Type.h"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <libopenmpt/libopenmpt.hpp>
diff --git a/src/decoder/plugins/OpusDecoderPlugin.cxx b/src/decoder/plugins/OpusDecoderPlugin.cxx
index 62d8a4039..7da908abf 100644
--- a/src/decoder/plugins/OpusDecoderPlugin.cxx
+++ b/src/decoder/plugins/OpusDecoderPlugin.cxx
@@ -24,6 +24,7 @@
 #include "OpusTags.hxx"
 #include "lib/xiph/OggPacket.hxx"
 #include "lib/xiph/OggFind.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "../DecoderAPI.hxx"
 #include "decoder/Reader.hxx"
 #include "input/Reader.hxx"
@@ -31,7 +32,6 @@
 #include "tag/Handler.hxx"
 #include "tag/Builder.hxx"
 #include "input/InputStream.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <opus.h>
@@ -206,8 +206,8 @@ MPDOpusDecoder::OnOggBeginning(const ogg_packet &packet)
 	assert(IsInitialized() == (output_buffer != nullptr));
 
 	if (IsInitialized() && channels != previous_channels)
-		throw FormatRuntimeError("Next stream has different channels (%u -> %u)",
-					 previous_channels, channels);
+		throw FmtRuntimeError("Next stream has different channels ({} -> {})",
+				      previous_channels, channels);
 
 	/* TODO: parse attributes from the OpusHead (sample rate,
 	   channels, ...) */
@@ -216,8 +216,8 @@ MPDOpusDecoder::OnOggBeginning(const ogg_packet &packet)
 	opus_decoder = opus_decoder_create(opus_sample_rate, channels,
 					   &opus_error);
 	if (opus_decoder == nullptr)
-		throw FormatRuntimeError("libopus error: %s",
-					 opus_strerror(opus_error));
+		throw FmtRuntimeError("libopus error: {}",
+				      opus_strerror(opus_error));
 
 	if (IsInitialized()) {
 		/* decoder was already initialized by the previous
@@ -318,8 +318,8 @@ MPDOpusDecoder::HandleAudio(const ogg_packet &packet)
 				  0);
 	if (gcc_unlikely(nframes <= 0)) {
 		if (nframes < 0)
-			throw FormatRuntimeError("libopus error: %s",
-						 opus_strerror(nframes));
+			throw FmtRuntimeError("libopus error: {}",
+					      opus_strerror(nframes));
 		else
 			return;
 	}
diff --git a/src/decoder/plugins/SidplayDecoderPlugin.cxx b/src/decoder/plugins/SidplayDecoderPlugin.cxx
index bca3acfe0..70d64054f 100644
--- a/src/decoder/plugins/SidplayDecoderPlugin.cxx
+++ b/src/decoder/plugins/SidplayDecoderPlugin.cxx
@@ -25,17 +25,17 @@
 #include "song/DetachedSong.hxx"
 #include "fs/Path.hxx"
 #include "fs/AllocatedPath.hxx"
+#include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/icu/Converter.hxx"
 #ifdef HAVE_SIDPLAYFP
 #include "io/FileReader.hxx"
-#include "util/RuntimeError.hxx"
 #endif
 #include "util/StringFormat.hxx"
 #include "util/Domain.hxx"
 #include "util/AllocatedString.hxx"
 #include "util/CharUtil.hxx"
 #include "util/ByteOrder.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #ifdef HAVE_SIDPLAYFP
@@ -88,10 +88,7 @@ static void loadRom(const Path rom_path, uint8_t *dump)
 {
 	FileReader romDump(rom_path);
 	if (romDump.Read(dump, rom_size) != rom_size)
-	{
-		throw FormatRuntimeError
-			("Could not load rom dump '%s'", rom_path.c_str());
-	}
+		throw FmtRuntimeError("Could not load rom dump '{}'", rom_path);
 }
 #endif
 
@@ -108,8 +105,8 @@ sidplay_load_songlength_db(const Path path)
 	bool error = db->open(path.c_str()) < 0;
 #endif
 	if (error)
-		throw FormatRuntimeError("unable to read songlengths file %s: %s",
-					 path.c_str(), db->error());
+		throw FmtRuntimeError("unable to read songlengths file {}: {}",
+				      path, db->error());
 
 	return db;
 }
diff --git a/src/decoder/plugins/WavpackDecoderPlugin.cxx b/src/decoder/plugins/WavpackDecoderPlugin.cxx
index 2f3781559..987ce02c4 100644
--- a/src/decoder/plugins/WavpackDecoderPlugin.cxx
+++ b/src/decoder/plugins/WavpackDecoderPlugin.cxx
@@ -24,10 +24,11 @@
 #include "pcm/CheckAudioFormat.hxx"
 #include "tag/Handler.hxx"
 #include "fs/Path.hxx"
+#include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/AllocatedString.hxx"
 #include "util/Math.hxx"
 #include "util/ScopeExit.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <wavpack/wavpack.h>
 
@@ -54,8 +55,8 @@ WavpackOpenInput(Path path, int flags, int norm_offset)
 	auto *wpc = WavpackOpenFileInput(path.c_str(), error,
 					 flags, norm_offset);
 	if (wpc == nullptr)
-		throw FormatRuntimeError("failed to open WavPack file \"%s\": %s",
-					 path.c_str(), error);
+		throw FmtRuntimeError("failed to open WavPack file \"{}\": {}",
+				      path, error);
 
 	return wpc;
 }
@@ -72,8 +73,8 @@ WavpackOpenInput(const WavpackStreamReader64 &reader, void *wv_id, void *wvc_id,
 					     wv_id, wvc_id, error,
 					     flags, norm_offset);
 	if (wpc == nullptr)
-		throw FormatRuntimeError("failed to open WavPack stream: %s",
-					 error);
+		throw FmtRuntimeError("failed to open WavPack stream: {}",
+				      error);
 
 	return wpc;
 }
diff --git a/src/encoder/Configured.cxx b/src/encoder/Configured.cxx
index 9a5890327..4ecce1ff7 100644
--- a/src/encoder/Configured.cxx
+++ b/src/encoder/Configured.cxx
@@ -21,8 +21,8 @@
 #include "EncoderList.hxx"
 #include "EncoderPlugin.hxx"
 #include "config/Block.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
-#include "util/RuntimeError.hxx"
 
 static const EncoderPlugin &
 GetConfiguredEncoderPlugin(const ConfigBlock &block, bool shout_legacy)
@@ -43,7 +43,7 @@ GetConfiguredEncoderPlugin(const ConfigBlock &block, bool shout_legacy)
 
 	const auto plugin = encoder_plugin_get(name);
 	if (plugin == nullptr)
-		throw FormatRuntimeError("No such encoder: %s", name);
+		throw FmtRuntimeError("No such encoder: {}", name);
 
 	return *plugin;
 }
diff --git a/src/encoder/meson.build b/src/encoder/meson.build
index 9b21eeca4..2b8ed9f9c 100644
--- a/src/encoder/meson.build
+++ b/src/encoder/meson.build
@@ -35,6 +35,9 @@ encoder_glue = static_library(
   'ToOutputStream.cxx',
   'EncoderList.cxx',
   include_directories: inc,
+  dependencies: [
+    fmt_dep,
+  ],
 )
 
 encoder_glue_dep = declare_dependency(
diff --git a/src/encoder/plugins/FlacEncoderPlugin.cxx b/src/encoder/plugins/FlacEncoderPlugin.cxx
index 83768bf6c..7e75877c5 100644
--- a/src/encoder/plugins/FlacEncoderPlugin.cxx
+++ b/src/encoder/plugins/FlacEncoderPlugin.cxx
@@ -21,8 +21,8 @@
 #include "../EncoderAPI.hxx"
 #include "pcm/AudioFormat.hxx"
 #include "pcm/Buffer.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/DynamicFifoBuffer.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Serial.hxx"
 #include "util/SpanCast.hxx"
 #include "util/StringUtil.hxx"
@@ -147,25 +147,25 @@ flac_encoder_setup(FLAC__StreamEncoder *fse, unsigned compression, bool oggflac,
 	}
 
 	if (!FLAC__stream_encoder_set_compression_level(fse, compression))
-		throw FormatRuntimeError("error setting flac compression to %d",
-					 compression);
+		throw FmtRuntimeError("error setting flac compression to {}",
+				      compression);
 
 	if (!FLAC__stream_encoder_set_channels(fse, audio_format.channels))
-		throw FormatRuntimeError("error setting flac channels num to %d",
-					 audio_format.channels);
+		throw FmtRuntimeError("error setting flac channels num to {}",
+				      audio_format.channels);
 
 	if (!FLAC__stream_encoder_set_bits_per_sample(fse, bits_per_sample))
-		throw FormatRuntimeError("error setting flac bit format to %d",
-					 bits_per_sample);
+		throw FmtRuntimeError("error setting flac bit format to {}",
+				      bits_per_sample);
 
 	if (!FLAC__stream_encoder_set_sample_rate(fse,
 						  audio_format.sample_rate))
-		throw FormatRuntimeError("error setting flac sample rate to %d",
-					 audio_format.sample_rate);
+		throw FmtRuntimeError("error setting flac sample rate to {}",
+				      audio_format.sample_rate);
 
 	if (oggflac && !FLAC__stream_encoder_set_ogg_serial_number(fse,
 						  GenerateSerial()))
-		throw FormatRuntimeError("error setting ogg serial number");
+		throw std::runtime_error{"error setting ogg serial number"};
 }
 
 FlacEncoder::FlacEncoder(AudioFormat _audio_format, FLAC__StreamEncoder *_fse, unsigned _compression, bool _oggflac, bool _oggchaining)
@@ -188,8 +188,8 @@ FlacEncoder::FlacEncoder(AudioFormat _audio_format, FLAC__StreamEncoder *_fse, u
 						 this);
 
 	if (init_status != FLAC__STREAM_ENCODER_INIT_STATUS_OK)
-		throw FormatRuntimeError("failed to initialize encoder: %s\n",
-					 FLAC__StreamEncoderInitStatusString[init_status]);
+		throw FmtRuntimeError("failed to initialize encoder: {}",
+				      FLAC__StreamEncoderInitStatusString[init_status]);
 }
 
 Encoder *
@@ -250,8 +250,8 @@ FlacEncoder::SendTag(const Tag &tag)
 	FLAC__metadata_object_delete(metadata);
 
 	if (init_status != FLAC__STREAM_ENCODER_INIT_STATUS_OK)
-		throw FormatRuntimeError("failed to initialize encoder: %s\n",
-					 FLAC__StreamEncoderInitStatusString[init_status]);
+		throw FmtRuntimeError("failed to initialize encoder: {}",
+				      FLAC__StreamEncoderInitStatusString[init_status]);
 }
 
 template<typename T>
diff --git a/src/encoder/plugins/LameEncoderPlugin.cxx b/src/encoder/plugins/LameEncoderPlugin.cxx
index 56bbd9f75..dc43dc2f6 100644
--- a/src/encoder/plugins/LameEncoderPlugin.cxx
+++ b/src/encoder/plugins/LameEncoderPlugin.cxx
@@ -20,9 +20,9 @@
 #include "LameEncoderPlugin.hxx"
 #include "../EncoderAPI.hxx"
 #include "pcm/AudioFormat.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/NumberParser.hxx"
 #include "util/ReusableArray.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 
 #include <lame/lame.h>
@@ -79,9 +79,9 @@ PreparedLameEncoder::PreparedLameEncoder(const ConfigBlock &block)
 		quality = float(ParseDouble(value, &endptr));
 
 		if (*endptr != '\0' || quality < -1.0f || quality > 10.0f)
-			throw FormatRuntimeError("quality \"%s\" is not a number in the "
-						 "range -1 to 10",
-						 value);
+			throw FmtRuntimeError("quality \"{}\" is not a number in the "
+					      "range -1 to 10",
+					      value);
 
 		if (block.GetBlockValue("bitrate") != nullptr)
 			throw std::runtime_error("quality and bitrate are both defined");
diff --git a/src/encoder/plugins/ShineEncoderPlugin.cxx b/src/encoder/plugins/ShineEncoderPlugin.cxx
index 4e0edfe6a..56b29288e 100644
--- a/src/encoder/plugins/ShineEncoderPlugin.cxx
+++ b/src/encoder/plugins/ShineEncoderPlugin.cxx
@@ -20,8 +20,8 @@
 #include "ShineEncoderPlugin.hxx"
 #include "../EncoderAPI.hxx"
 #include "pcm/AudioFormat.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/DynamicFifoBuffer.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 
 extern "C"
@@ -121,11 +121,11 @@ SetupShine(shine_config_t config, AudioFormat &audio_format)
 		audio_format.channels == 2 ? PCM_STEREO : PCM_MONO;
 
 	if (shine_check_config(config.wave.samplerate, config.mpeg.bitr) < 0)
-		throw FormatRuntimeError("error configuring shine. "
-					 "samplerate %d and bitrate %d configuration"
-					 " not supported.",
-					 config.wave.samplerate,
-					 config.mpeg.bitr);
+		throw FmtRuntimeError("error configuring shine. "
+				      "samplerate {} and bitrate {} configuration"
+				      " not supported.",
+				      config.wave.samplerate,
+				      config.mpeg.bitr);
 
 	auto shine = shine_initialise(&config);
 	if (!shine)
diff --git a/src/encoder/plugins/TwolameEncoderPlugin.cxx b/src/encoder/plugins/TwolameEncoderPlugin.cxx
index 00c8466d6..f07d6a33b 100644
--- a/src/encoder/plugins/TwolameEncoderPlugin.cxx
+++ b/src/encoder/plugins/TwolameEncoderPlugin.cxx
@@ -20,8 +20,8 @@
 #include "TwolameEncoderPlugin.hxx"
 #include "../EncoderAPI.hxx"
 #include "pcm/AudioFormat.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/NumberParser.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 #include "util/Domain.hxx"
 #include "Log.hxx"
@@ -96,9 +96,9 @@ PreparedTwolameEncoder::PreparedTwolameEncoder(const ConfigBlock &block)
 		quality = float(ParseDouble(value, &endptr));
 
 		if (*endptr != '\0' || quality < -1.0f || quality > 10.0f)
-			throw FormatRuntimeError("quality \"%s\" is not a number in the "
-						 "range -1 to 10",
-						 value);
+			throw FmtRuntimeError("quality \"{}\" is not a number in the "
+					      "range -1 to 10",
+					      value);
 
 		if (block.GetBlockValue("bitrate") != nullptr)
 			throw std::runtime_error("quality and bitrate are both defined");
diff --git a/src/encoder/plugins/VorbisEncoderPlugin.cxx b/src/encoder/plugins/VorbisEncoderPlugin.cxx
index 2c3a4e24c..e7ef60597 100644
--- a/src/encoder/plugins/VorbisEncoderPlugin.cxx
+++ b/src/encoder/plugins/VorbisEncoderPlugin.cxx
@@ -19,12 +19,12 @@
 
 #include "VorbisEncoderPlugin.hxx"
 #include "OggEncoder.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/xiph/VorbisComment.hxx"
 #include "pcm/AudioFormat.hxx"
 #include "config/Domain.hxx"
 #include "util/StringUtil.hxx"
 #include "util/NumberParser.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <vorbis/vorbisenc.h>
 
@@ -88,9 +88,9 @@ PreparedVorbisEncoder::PreparedVorbisEncoder(const ConfigBlock &block)
 		quality = ParseDouble(value, &endptr);
 
 		if (*endptr != '\0' || quality < -1.0f || quality > 10.0f)
-			throw FormatRuntimeError("quality \"%s\" is not a number in the "
-						 "range -1 to 10",
-						 value);
+			throw FmtRuntimeError("quality \"{}\" is not a number in the "
+					      "range -1 to 10",
+					      value);
 
 		if (block.GetBlockValue("bitrate") != nullptr)
 			throw std::runtime_error("quality and bitrate are both defined");
diff --git a/src/event/ServerSocket.cxx b/src/event/ServerSocket.cxx
index be8e0f9db..493bd2b42 100644
--- a/src/event/ServerSocket.cxx
+++ b/src/event/ServerSocket.cxx
@@ -19,6 +19,7 @@
 
 #include "ServerSocket.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "net/IPv4Address.hxx"
 #include "net/IPv6Address.hxx"
 #include "net/StaticSocketAddress.hxx"
@@ -31,7 +32,6 @@
 #include "net/ToString.hxx"
 #include "event/SocketEvent.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "Log.hxx"
 
@@ -255,8 +255,8 @@ ServerSocket::Open()
 				const auto address_string = i.ToString();
 
 				try {
-					std::throw_with_nested(FormatRuntimeError("Failed to bind to '%s'",
-										  address_string.c_str()));
+					std::throw_with_nested(FmtRuntimeError("Failed to bind to '{}'",
+									       address_string));
 				} catch (...) {
 					last_error = std::current_exception();
 				}
diff --git a/src/filter/Factory.cxx b/src/filter/Factory.cxx
index 33ac0ecf0..9b727122d 100644
--- a/src/filter/Factory.cxx
+++ b/src/filter/Factory.cxx
@@ -22,7 +22,7 @@
 #include "Prepared.hxx"
 #include "config/Data.hxx"
 #include "config/Block.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 std::unique_ptr<PreparedFilter>
 FilterFactory::MakeFilter(const char *name)
@@ -30,8 +30,8 @@ FilterFactory::MakeFilter(const char *name)
 	const auto *cfg = config.FindBlock(ConfigBlockOption::AUDIO_FILTER,
 					   "name", name);
 	if (cfg == nullptr)
-		throw FormatRuntimeError("Filter template not found: %s",
-					 name);
+		throw FmtRuntimeError("Filter template not found: {}",
+				      name);
 
 	cfg->SetUsed();
 
diff --git a/src/filter/LoadOne.cxx b/src/filter/LoadOne.cxx
index abb45e8ad..bd82bc181 100644
--- a/src/filter/LoadOne.cxx
+++ b/src/filter/LoadOne.cxx
@@ -22,7 +22,7 @@
 #include "Registry.hxx"
 #include "Prepared.hxx"
 #include "config/Block.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 std::unique_ptr<PreparedFilter>
 filter_configured_new(const ConfigBlock &block)
@@ -33,8 +33,8 @@ filter_configured_new(const ConfigBlock &block)
 
 	const auto *plugin = filter_plugin_by_name(plugin_name);
 	if (plugin == nullptr)
-		throw FormatRuntimeError("No such filter plugin: %s",
-					 plugin_name);
+		throw FmtRuntimeError("No such filter plugin: {}",
+				      plugin_name);
 
 	return plugin->init(block);
 }
diff --git a/src/filter/meson.build b/src/filter/meson.build
index cd491fcc9..4e1123aae 100644
--- a/src/filter/meson.build
+++ b/src/filter/meson.build
@@ -17,6 +17,9 @@ filter_glue = static_library(
   'LoadOne.cxx',
   'LoadChain.cxx',
   include_directories: inc,
+  dependencies: [
+    fmt_dep,
+  ],
 )
 
 filter_glue_dep = declare_dependency(
diff --git a/src/filter/plugins/RouteFilterPlugin.cxx b/src/filter/plugins/RouteFilterPlugin.cxx
index 9827278d6..70b1426d0 100644
--- a/src/filter/plugins/RouteFilterPlugin.cxx
+++ b/src/filter/plugins/RouteFilterPlugin.cxx
@@ -47,8 +47,8 @@
 #include "pcm/AudioFormat.hxx"
 #include "pcm/Buffer.hxx"
 #include "pcm/Silence.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringStrip.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <array>
 #include <cstdint>
@@ -159,8 +159,8 @@ PreparedRouteFilter::PreparedRouteFilter(const ConfigBlock &block)
 			throw std::runtime_error("Malformed 'routes' specification");
 
 		if (source >= MAX_CHANNELS)
-			throw FormatRuntimeError("Invalid source channel number: %u",
-						 source);
+			throw FmtRuntimeError("Invalid source channel number: {}",
+					      source);
 
 		if (source >= min_input_channels)
 			min_input_channels = source + 1;
@@ -173,8 +173,8 @@ PreparedRouteFilter::PreparedRouteFilter(const ConfigBlock &block)
 			throw std::runtime_error("Malformed 'routes' specification");
 
 		if (dest >= MAX_CHANNELS)
-			throw FormatRuntimeError("Invalid destination channel number: %u",
-						 dest);
+			throw FmtRuntimeError("Invalid destination channel number: {}",
+					      dest);
 
 		if (dest >= min_output_channels)
 			min_output_channels = dest + 1;
diff --git a/src/filter/plugins/TwoFilters.cxx b/src/filter/plugins/TwoFilters.cxx
index 9a5b1eb16..01503b93c 100644
--- a/src/filter/plugins/TwoFilters.cxx
+++ b/src/filter/plugins/TwoFilters.cxx
@@ -19,7 +19,8 @@
 
 #include "TwoFilters.hxx"
 #include "pcm/AudioFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringBuffer.hxx"
 
 std::span<const std::byte>
@@ -50,9 +51,8 @@ PreparedTwoFilters::Open(AudioFormat &audio_format)
 	auto b = second->Open(b_in_format);
 
 	if (b_in_format != a_out_format)
-		throw FormatRuntimeError("Audio format not supported by filter '%s': %s",
-					 second_name.c_str(),
-					 ToString(a_out_format).c_str());
+		throw FmtRuntimeError("Audio format not supported by filter '{}': {}",
+				      second_name, a_out_format);
 
 	return std::make_unique<TwoFilters>(std::move(a),
 					    std::move(b));
diff --git a/src/input/Init.cxx b/src/input/Init.cxx
index 406cc06b0..f87d87a50 100644
--- a/src/input/Init.cxx
+++ b/src/input/Init.cxx
@@ -25,8 +25,8 @@
 #include "config/Block.hxx"
 #include "Log.hxx"
 #include "PluginUnavailable.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <cassert>
 #include <stdexcept>
@@ -80,8 +80,8 @@ input_stream_global_init(const ConfigData &config, EventLoop &event_loop)
 				 plugin->name, e.what());
 			continue;
 		} catch (...) {
-			std::throw_with_nested(FormatRuntimeError("Failed to initialize input plugin '%s'",
-								  plugin->name));
+			std::throw_with_nested(FmtRuntimeError("Failed to initialize input plugin '{}'",
+							       plugin->name));
 		}
 	}
 }
diff --git a/src/input/plugins/CdioParanoiaInputPlugin.cxx b/src/input/plugins/CdioParanoiaInputPlugin.cxx
index 110dd842d..7e086692c 100644
--- a/src/input/plugins/CdioParanoiaInputPlugin.cxx
+++ b/src/input/plugins/CdioParanoiaInputPlugin.cxx
@@ -23,11 +23,11 @@
 
 #include "CdioParanoiaInputPlugin.hxx"
 #include "lib/cdio/Paranoia.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "../InputStream.hxx"
 #include "../InputPlugin.hxx"
 #include "util/TruncateString.hxx"
 #include "util/StringCompare.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/ByteOrder.hxx"
 #include "util/ScopeExit.hxx"
@@ -114,8 +114,8 @@ input_cdio_init(EventLoop &, const ConfigBlock &block)
 		else if (strcmp(value, "big_endian") == 0)
 			default_reverse_endian = IsLittleEndian();
 		else
-			throw FormatRuntimeError("Unrecognized 'default_byte_order' setting: %s",
-						 value);
+			throw FmtRuntimeError("Unrecognized 'default_byte_order' setting: {}",
+					      value);
 	}
 	speed = block.GetBlockValue("speed",0U);
 
@@ -263,8 +263,8 @@ input_cdio_open(const char *uri,
 	default:
 		cdio_cddap_close_no_free_cdio(drv);
 		cdio_destroy(cdio);
-		throw FormatRuntimeError("Drive returns unknown data type %d",
-					 be);
+		throw FmtRuntimeError("Drive returns unknown data type {}",
+				      be);
 	}
 
 	lsn_t lsn_from, lsn_to;
@@ -287,8 +287,8 @@ CdioParanoiaInputStream::Seek(std::unique_lock<Mutex> &,
 			      offset_type new_offset)
 {
 	if (new_offset > size)
-		throw FormatRuntimeError("Invalid offset to seek %ld (%ld)",
-					 (long int)new_offset, (long int)size);
+		throw FmtRuntimeError("Invalid offset to seek {} ({})",
+				      new_offset, size);
 
 	/* simple case */
 	if (new_offset == offset)
diff --git a/src/input/plugins/FileInputPlugin.cxx b/src/input/plugins/FileInputPlugin.cxx
index dff0279b1..1f1589cb7 100644
--- a/src/input/plugins/FileInputPlugin.cxx
+++ b/src/input/plugins/FileInputPlugin.cxx
@@ -21,11 +21,10 @@
 #include "../InputStream.hxx"
 #include "fs/Path.hxx"
 #include "fs/FileInfo.hxx"
+#include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "io/FileReader.hxx"
 #include "io/FileDescriptor.hxx"
-#include "util/RuntimeError.hxx"
-
-#include <cinttypes> // for PRIu64 (PRIoffset)
 
 #include <sys/stat.h>
 #include <fcntl.h>
@@ -63,8 +62,7 @@ OpenFileInputStream(Path path, Mutex &mutex)
 	const FileInfo info = reader.GetFileInfo();
 
 	if (!info.IsRegular())
-		throw FormatRuntimeError("Not a regular file: %s",
-					 path.c_str());
+		throw FmtRuntimeError("Not a regular file: {}", path);
 
 #ifdef POSIX_FADV_SEQUENTIAL
 	posix_fadvise(reader.GetFD().Get(), (off_t)0, info.GetSize(),
@@ -100,9 +98,9 @@ FileInputStream::Read(std::unique_lock<Mutex> &,
 	}
 
 	if (nbytes == 0 && !IsEOF())
-		throw FormatRuntimeError("Unexpected end of file %s"
-					 " at %" PRIoffset " of %" PRIoffset,
-					 GetURI(), GetOffset(), GetSize());
+		throw FmtRuntimeError("Unexpected end of file {}"
+				      " at {} of {}",
+				      GetURI(), GetOffset(), GetSize());
 
 	offset += nbytes;
 	return nbytes;
diff --git a/src/input/plugins/QobuzErrorParser.cxx b/src/input/plugins/QobuzErrorParser.cxx
index 448c11d03..789f17832 100644
--- a/src/input/plugins/QobuzErrorParser.cxx
+++ b/src/input/plugins/QobuzErrorParser.cxx
@@ -19,8 +19,7 @@
 
 #include "QobuzErrorParser.hxx"
 #include "lib/yajl/Callbacks.hxx"
-#include "util/RuntimeError.hxx"
-
+#include "lib/fmt/RuntimeError.hxx"
 
 using std::string_view_literals::operator""sv;
 
@@ -46,7 +45,7 @@ QobuzErrorParser::QobuzErrorParser(unsigned _status,
 {
 	auto i = headers.find("content-type");
 	if (i == headers.end() || i->second.find("/json") == i->second.npos)
-		throw FormatRuntimeError("Status %u from Qobuz", status);
+		throw FmtRuntimeError("Status {} from Qobuz", status);
 }
 
 void
@@ -55,10 +54,9 @@ QobuzErrorParser::OnEnd()
 	YajlResponseParser::OnEnd();
 
 	if (!message.empty())
-		throw FormatRuntimeError("Error from Qobuz: %s",
-					 message.c_str());
+		throw FmtRuntimeError("Error from Qobuz: {}", message);
 	else
-		throw FormatRuntimeError("Status %u from Qobuz", status);
+		throw FmtRuntimeError("Status {} from Qobuz", status);
 }
 
 inline bool
diff --git a/src/input/plugins/UringInputPlugin.cxx b/src/input/plugins/UringInputPlugin.cxx
index 33574a27b..91aa5b229 100644
--- a/src/input/plugins/UringInputPlugin.cxx
+++ b/src/input/plugins/UringInputPlugin.cxx
@@ -21,12 +21,12 @@
 #include "../AsyncInputStream.hxx"
 #include "event/Call.hxx"
 #include "event/Loop.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/fmt/SystemError.hxx"
 #include "io/Open.hxx"
 #include "io/UniqueFileDescriptor.hxx"
 #include "io/uring/ReadOperation.hxx"
 #include "io/uring/Queue.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <sys/stat.h>
 
@@ -191,7 +191,7 @@ OpenUringInputStream(const char *path, Mutex &mutex)
 		throw FmtErrno("Failed to access {}", path);
 
 	if (!S_ISREG(st.st_mode))
-		throw FormatRuntimeError("Not a regular file: %s", path);
+		throw FmtRuntimeError("Not a regular file: {}", path);
 
 	return std::make_unique<UringInputStream>(*uring_input_event_loop,
 						  *uring_input_queue,
diff --git a/src/io/FileReader.cxx b/src/io/FileReader.cxx
index 3287e544c..aadb36f19 100644
--- a/src/io/FileReader.cxx
+++ b/src/io/FileReader.cxx
@@ -62,7 +62,7 @@ FileReader::Read(void *data, std::size_t size)
 
 	DWORD nbytes;
 	if (!ReadFile(handle, data, size, &nbytes, nullptr))
-		throw FmtLastError("Failed to read from %s", path);
+		throw FmtLastError("Failed to read from {}", path);
 
 	return nbytes;
 }
diff --git a/src/lib/alsa/HwSetup.cxx b/src/lib/alsa/HwSetup.cxx
index 4083947ea..ca653859c 100644
--- a/src/lib/alsa/HwSetup.cxx
+++ b/src/lib/alsa/HwSetup.cxx
@@ -22,9 +22,9 @@
 #include "Format.hxx"
 #include "lib/fmt/AudioFormatFormatter.hxx"
 #include "lib/fmt/ToBuffer.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/ByteOrder.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "pcm/AudioFormat.hxx"
 #include "Log.hxx"
 #include "config.h"
@@ -224,8 +224,8 @@ SetupHw(snd_pcm_t *pcm,
 						     requested_sample_rate));
 
 	if (output_sample_rate == 0)
-		throw FormatRuntimeError("Failed to configure sample rate %u Hz",
-					 audio_format.sample_rate);
+		throw FmtRuntimeError("Failed to configure sample rate {} Hz",
+				      audio_format.sample_rate);
 
 	if (output_sample_rate != requested_sample_rate)
 		audio_format.sample_rate = params.CalcInputSampleRate(output_sample_rate);
diff --git a/src/lib/alsa/NonBlock.cxx b/src/lib/alsa/NonBlock.cxx
index 4b6993772..adc78d1a2 100644
--- a/src/lib/alsa/NonBlock.cxx
+++ b/src/lib/alsa/NonBlock.cxx
@@ -20,7 +20,6 @@
 #include "NonBlock.hxx"
 #include "Error.hxx"
 #include "event/MultiSocketMonitor.hxx"
-#include "util/RuntimeError.hxx"
 
 Event::Duration
 AlsaNonBlockPcm::PrepareSockets(MultiSocketMonitor &m, snd_pcm_t *pcm)
diff --git a/src/lib/dbus/Error.cxx b/src/lib/dbus/Error.cxx
index 4fd6dac22..4a4b48354 100644
--- a/src/lib/dbus/Error.cxx
+++ b/src/lib/dbus/Error.cxx
@@ -31,12 +31,12 @@
  */
 
 #include "Error.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 void
 ODBus::Error::Throw(const char *prefix) const
 {
-	throw FormatRuntimeError("%s: %s", prefix, GetMessage());
+	throw FmtRuntimeError("{}: {}", prefix, GetMessage());
 }
 
 void
diff --git a/src/lib/ffmpeg/Error.cxx b/src/lib/ffmpeg/Error.cxx
index a1bcf872b..03b3e08f4 100644
--- a/src/lib/ffmpeg/Error.cxx
+++ b/src/lib/ffmpeg/Error.cxx
@@ -18,7 +18,7 @@
  */
 
 #include "Error.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 extern "C" {
 #include <libavutil/error.h>
@@ -37,5 +37,5 @@ MakeFfmpegError(int errnum, const char *prefix)
 {
 	char msg[256];
 	av_strerror(errnum, msg, sizeof(msg));
-	return FormatRuntimeError("%s: %s", prefix, msg);
+	return FmtRuntimeError("{}: {}", prefix, msg);
 }
diff --git a/src/lib/ffmpeg/Filter.cxx b/src/lib/ffmpeg/Filter.cxx
index 012e7142f..ce36bd2ff 100644
--- a/src/lib/ffmpeg/Filter.cxx
+++ b/src/lib/ffmpeg/Filter.cxx
@@ -21,7 +21,7 @@
 #include "ChannelLayout.hxx"
 #include "SampleFormat.hxx"
 #include "pcm/AudioFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <cinttypes>
 
@@ -34,7 +34,7 @@ RequireFilterByName(const char *name)
 {
 	const auto *filter = avfilter_get_by_name(name);
 	if (filter == nullptr)
-		throw FormatRuntimeError("No such FFmpeg filter: '%s'", name);
+		throw FmtRuntimeError("No such FFmpeg filter: '{}'", name);
 
 	return *filter;
 }
diff --git a/src/util/RuntimeError.hxx b/src/lib/fmt/RuntimeError.cxx
similarity index 58%
rename from src/util/RuntimeError.hxx
rename to src/lib/fmt/RuntimeError.cxx
index 74cb8a5de..b851d6ecc 100644
--- a/src/util/RuntimeError.hxx
+++ b/src/lib/fmt/RuntimeError.cxx
@@ -1,5 +1,5 @@
 /*
- * Copyright 2013-2020 Max Kellermann <max.kellermann@gmail.com>
+ * Copyright 2022 Max Kellermann <max.kellermann@gmail.com>
  *
  * Redistribution and use in source and binary forms, with or without
  * modification, are permitted provided that the following conditions
@@ -27,40 +27,19 @@
  * OF THE POSSIBILITY OF SUCH DAMAGE.
  */
 
-#ifndef RUNTIME_ERROR_HXX
-#define RUNTIME_ERROR_HXX
+#include "RuntimeError.hxx"
+#include "ToBuffer.hxx"
 
-#include <stdexcept> // IWYU pragma: export
-#include <utility>
-
-#include <stdio.h>
-
-#if defined(__clang__) || defined(__GNUC__)
-#pragma GCC diagnostic push
-// TODO: fix this warning properly
-#pragma GCC diagnostic ignored "-Wformat-security"
-#endif
-
-template<typename... Args>
-static inline std::runtime_error
-FormatRuntimeError(const char *fmt, Args&&... args) noexcept
+std::runtime_error
+VFmtRuntimeError(fmt::string_view format_str, fmt::format_args args) noexcept
 {
-	char buffer[1024];
-	snprintf(buffer, sizeof(buffer), fmt, std::forward<Args>(args)...);
-	return std::runtime_error(buffer);
+	const auto msg = VFmtBuffer<512>(format_str, args);
+	return std::runtime_error{msg};
 }
 
-template<typename... Args>
-inline std::invalid_argument
-FormatInvalidArgument(const char *fmt, Args&&... args) noexcept
+std::invalid_argument
+VFmtInvalidArgument(fmt::string_view format_str, fmt::format_args args) noexcept
 {
-	char buffer[1024];
-	snprintf(buffer, sizeof(buffer), fmt, std::forward<Args>(args)...);
-	return std::invalid_argument(buffer);
+	const auto msg = VFmtBuffer<512>(format_str, args);
+	return std::invalid_argument{msg};
 }
-
-#if defined(__clang__) || defined(__GNUC__)
-#pragma GCC diagnostic pop
-#endif
-
-#endif
diff --git a/src/lib/fmt/RuntimeError.hxx b/src/lib/fmt/RuntimeError.hxx
new file mode 100644
index 000000000..af90250d1
--- /dev/null
+++ b/src/lib/fmt/RuntimeError.hxx
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2022 Max Kellermann <max.kellermann@gmail.com>
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ *
+ * - Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the
+ * distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+ * FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE
+ * FOUNDATION OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+ * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+ * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+ * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
+ * OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+#pragma once
+
+#include <fmt/core.h>
+#if FMT_VERSION >= 80000 && FMT_VERSION < 90000
+#include <fmt/format.h>
+#endif
+
+#include <stdexcept> // IWYU pragma: export
+
+[[nodiscard]] [[gnu::pure]]
+std::runtime_error
+VFmtRuntimeError(fmt::string_view format_str, fmt::format_args args) noexcept;
+
+template<typename S, typename... Args>
+[[nodiscard]] [[gnu::pure]]
+auto
+FmtRuntimeError(const S &format_str, Args&&... args) noexcept
+{
+#if FMT_VERSION >= 90000
+	return VFmtRuntimeError(format_str,
+				fmt::make_format_args(args...));
+#else
+	return VFmtRuntimeError(fmt::to_string_view(format_str),
+				fmt::make_args_checked<Args...>(format_str,
+								args...));
+#endif
+}
+
+[[nodiscard]] [[gnu::pure]]
+std::invalid_argument
+VFmtInvalidArgument(fmt::string_view format_str, fmt::format_args args) noexcept;
+
+template<typename S, typename... Args>
+[[nodiscard]] [[gnu::pure]]
+auto
+FmtInvalidArgument(const S &format_str, Args&&... args) noexcept
+{
+#if FMT_VERSION >= 90000
+	return VFmtInvalidArgument(format_str,
+				fmt::make_format_args(args...));
+#else
+	return VFmtInvalidArgument(fmt::to_string_view(format_str),
+				fmt::make_args_checked<Args...>(format_str,
+								args...));
+#endif
+}
diff --git a/src/lib/fmt/meson.build b/src/lib/fmt/meson.build
index 84ddb8a72..793c296fc 100644
--- a/src/lib/fmt/meson.build
+++ b/src/lib/fmt/meson.build
@@ -10,6 +10,7 @@ endif
 
 fmt = static_library(
   'fmt',
+  'RuntimeError.cxx',
   'SystemError.cxx',
   include_directories: inc,
   dependencies: libfmt,
diff --git a/src/lib/icu/Init.cxx b/src/lib/icu/Init.cxx
index 78bec369c..839cf8c4c 100644
--- a/src/lib/icu/Init.cxx
+++ b/src/lib/icu/Init.cxx
@@ -21,7 +21,6 @@
 #include "Collate.hxx"
 #include "Canonicalize.hxx"
 #include "Error.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <unicode/uclean.h>
 
diff --git a/src/lib/jack/Dynamic.hxx b/src/lib/jack/Dynamic.hxx
index b15d7045e..c1332cb8c 100644
--- a/src/lib/jack/Dynamic.hxx
+++ b/src/lib/jack/Dynamic.hxx
@@ -18,6 +18,7 @@
  */
 
 #include "system/Error.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 /* sorry for this horrible piece of code - there's no elegant way to
    load DLLs at runtime */
@@ -102,7 +103,7 @@ GetFunction(HMODULE h, const char *name, T &result)
 {
 	auto f = GetProcAddress(h, name);
 	if (f == nullptr)
-		throw FormatRuntimeError("No such libjack function: %s", name);
+		throw FmtRuntimeError("No such libjack function: {}", name);
 
 	result = reinterpret_cast<T>(f);
 }
diff --git a/src/lib/pulse/Error.cxx b/src/lib/pulse/Error.cxx
index 94efaa399..c8f3f28f8 100644
--- a/src/lib/pulse/Error.cxx
+++ b/src/lib/pulse/Error.cxx
@@ -18,7 +18,6 @@
  */
 
 #include "Error.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <pulse/context.h>
 #include <pulse/error.h>
diff --git a/src/lib/yajl/Handle.cxx b/src/lib/yajl/Handle.cxx
index d9df886e9..d0eb0aa76 100644
--- a/src/lib/yajl/Handle.cxx
+++ b/src/lib/yajl/Handle.cxx
@@ -28,7 +28,7 @@
  */
 
 #include "Handle.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StringStrip.hxx"
 
@@ -60,8 +60,8 @@ Handle::ThrowError()
 		yajl_free_error(handle, str);
 	};
 
-	throw FormatRuntimeError("Failed to parse JSON: %s",
-				 StripErrorMessage((char *)str));
+	throw FmtRuntimeError("Failed to parse JSON: {}",
+			      StripErrorMessage((char *)str));
 }
 
 } // namespace Yajl
diff --git a/src/mixer/plugins/AlsaMixerPlugin.cxx b/src/mixer/plugins/AlsaMixerPlugin.cxx
index 19a69a532..93546995f 100644
--- a/src/mixer/plugins/AlsaMixerPlugin.cxx
+++ b/src/mixer/plugins/AlsaMixerPlugin.cxx
@@ -20,6 +20,7 @@
 #include "AlsaMixerPlugin.hxx"
 #include "lib/alsa/NonBlock.hxx"
 #include "lib/alsa/Error.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/fmt/ToBuffer.hxx"
 #include "mixer/Mixer.hxx"
 #include "mixer/Listener.hxx"
@@ -30,7 +31,6 @@
 #include "util/ASCII.hxx"
 #include "util/Domain.hxx"
 #include "util/Math.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 extern "C" {
@@ -279,7 +279,7 @@ AlsaMixer::Setup()
 
 	elem = alsa_mixer_lookup_elem(handle, control, index);
 	if (elem == nullptr)
-		throw FormatRuntimeError("no such mixer control: %s", control);
+		throw FmtRuntimeError("no such mixer control: {}", control);
 
 	snd_mixer_elem_set_callback_private(elem, this);
 	snd_mixer_elem_set_callback(elem, ElemCallback);
diff --git a/src/mixer/plugins/OssMixerPlugin.cxx b/src/mixer/plugins/OssMixerPlugin.cxx
index 7a0edb725..df8a5ee43 100644
--- a/src/mixer/plugins/OssMixerPlugin.cxx
+++ b/src/mixer/plugins/OssMixerPlugin.cxx
@@ -20,11 +20,11 @@
 #include "OssMixerPlugin.hxx"
 #include "mixer/Mixer.hxx"
 #include "config/Block.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "io/FileDescriptor.hxx"
 #include "lib/fmt/SystemError.hxx"
 #include "util/ASCII.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <cassert>
@@ -91,8 +91,8 @@ OssMixer::Configure(const ConfigBlock &block)
 	if (control != NULL) {
 		volume_control = oss_find_mixer(control);
 		if (volume_control < 0)
-			throw FormatRuntimeError("no such mixer control: %s",
-						 control);
+			throw FmtRuntimeError("no such mixer control: {}",
+					      control);
 	} else
 		volume_control = SOUND_MIXER_PCM;
 }
diff --git a/src/mixer/plugins/PulseMixerPlugin.cxx b/src/mixer/plugins/PulseMixerPlugin.cxx
index e5713db83..22d717cd7 100644
--- a/src/mixer/plugins/PulseMixerPlugin.cxx
+++ b/src/mixer/plugins/PulseMixerPlugin.cxx
@@ -18,13 +18,13 @@
  */
 
 #include "PulseMixerPlugin.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/pulse/LogError.hxx"
 #include "lib/pulse/LockGuard.hxx"
 #include "mixer/Mixer.hxx"
 #include "mixer/Listener.hxx"
 #include "output/plugins/PulseOutputPlugin.hxx"
 #include "util/NumberParser.hxx"
-#include "util/RuntimeError.hxx"
 #include "config/Block.hxx"
 
 #include <pulse/context.h>
@@ -177,9 +177,9 @@ parse_volume_scale_factor(const char *value) {
 	float factor = ParseFloat(value, &endptr);
 
 	if (endptr == value || *endptr != '\0' || factor < 0.5f || factor > 5.0f)
-		throw FormatRuntimeError("\"%s\" is not a number in the "
-					 "range 0.5 to 5.0",
-					 value);
+		throw FmtRuntimeError("\"{}\" is not a number in the "
+				      "range 0.5 to 5.0",
+				      value);
 
 	return factor;
 }
diff --git a/src/neighbor/Glue.cxx b/src/neighbor/Glue.cxx
index 4a8f47d2c..7cf93c3b4 100644
--- a/src/neighbor/Glue.cxx
+++ b/src/neighbor/Glue.cxx
@@ -24,7 +24,7 @@
 #include "Info.hxx"
 #include "config/Data.hxx"
 #include "config/Block.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <stdexcept>
 
@@ -38,8 +38,8 @@ CreateNeighborExplorer(EventLoop &loop, NeighborListener &listener,
 {
 	const NeighborPlugin *plugin = GetNeighborPluginByName(plugin_name);
 	if (plugin == nullptr)
-		throw FormatRuntimeError("No such neighbor plugin: %s",
-					 plugin_name);
+		throw FmtRuntimeError("No such neighbor plugin: {}",
+				      plugin_name);
 
 	return plugin->create(loop, listener, block);
 }
@@ -72,8 +72,8 @@ NeighborGlue::Open()
 			for (auto k = explorers.begin(); k != i; ++k)
 				k->explorer->Close();
 
-			std::throw_with_nested(FormatRuntimeError("Failed to open neighblor plugin '%s'",
-								  i->name.c_str()));
+			std::throw_with_nested(FmtRuntimeError("Failed to open neighblor plugin '{}'",
+							       i->name));
 		}
 	}
 }
diff --git a/src/net/Resolver.cxx b/src/net/Resolver.cxx
index 02fc0c2c7..8ab6b682f 100644
--- a/src/net/Resolver.cxx
+++ b/src/net/Resolver.cxx
@@ -33,7 +33,7 @@
 #include "Resolver.hxx"
 #include "AddressInfo.hxx"
 #include "HostParser.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/CharUtil.hxx"
 
 #ifdef _WIN32
@@ -54,11 +54,17 @@ Resolve(const char *node, const char *service,
 {
 	struct addrinfo *ai;
 	int error = getaddrinfo(node, service, hints, &ai);
-	if (error != 0)
-		throw FormatRuntimeError("Failed to resolve '%s':'%s': %s",
-					 node == nullptr ? "" : node,
-					 service == nullptr ? "" : service,
-					 gai_strerror(error));
+	if (error != 0) {
+#ifdef _WIN32
+		const char *msg = gai_strerrorA(error);
+#else
+		const char *msg = gai_strerror(error);
+#endif
+		throw FmtRuntimeError("Failed to resolve '{}':'{}': {}",
+				      node == nullptr ? "" : node,
+				      service == nullptr ? "" : service,
+				      msg);
+	}
 
 	return AddressInfoList(ai);
 }
@@ -89,7 +95,7 @@ FindAndResolveInterfaceName(char *host, size_t size)
 
 	const unsigned i = if_nametoindex(interface);
 	if (i == 0)
-		throw FormatRuntimeError("No such interface: %s", interface);
+		throw FmtRuntimeError("No such interface: {}", interface);
 
 	sprintf(interface, "%u", i);
 }
diff --git a/src/net/meson.build b/src/net/meson.build
index 2e558cd71..03af2d307 100644
--- a/src/net/meson.build
+++ b/src/net/meson.build
@@ -49,6 +49,9 @@ net = static_library(
   'SocketDescriptor.cxx',
   'SocketError.cxx',
   include_directories: inc,
+  dependencies: [
+    fmt_dep,
+  ],
 )
 
 net_dep = declare_dependency(
diff --git a/src/output/Filtered.cxx b/src/output/Filtered.cxx
index ed5402cea..374df4af5 100644
--- a/src/output/Filtered.cxx
+++ b/src/output/Filtered.cxx
@@ -22,10 +22,10 @@
 #include "Domain.hxx"
 #include "lib/fmt/AudioFormatFormatter.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "mixer/Mixer.hxx"
 #include "mixer/plugins/SoftwareMixerPlugin.hxx"
 #include "filter/plugins/ConvertFilterPlugin.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringBuffer.hxx"
 #include "Log.hxx"
 
@@ -59,8 +59,8 @@ FilteredAudioOutput::Enable()
 	try {
 		output->Enable();
 	} catch (...) {
-		std::throw_with_nested(FormatRuntimeError("Failed to enable output %s",
-							  GetLogName()));
+		std::throw_with_nested(FmtRuntimeError("Failed to enable output {}",
+						       GetLogName()));
 	}
 }
 
@@ -76,8 +76,8 @@ FilteredAudioOutput::ConfigureConvertFilter()
 	try {
 		convert_filter_set(convert_filter.Get(), out_audio_format);
 	} catch (...) {
-		std::throw_with_nested(FormatRuntimeError("Failed to convert for %s",
-							  GetLogName()));
+		std::throw_with_nested(FmtRuntimeError("Failed to convert for {}",
+						       GetLogName()));
 	}
 }
 
@@ -89,8 +89,8 @@ FilteredAudioOutput::OpenOutputAndConvert(AudioFormat desired_audio_format)
 	try {
 		output->Open(out_audio_format);
 	} catch (...) {
-		std::throw_with_nested(FormatRuntimeError("Failed to open %s",
-							  GetLogName()));
+		std::throw_with_nested(FmtRuntimeError("Failed to open {}",
+						       GetLogName()));
 	}
 
 	FmtDebug(output_domain,
diff --git a/src/output/Init.cxx b/src/output/Init.cxx
index 535be4e18..ae803a1d5 100644
--- a/src/output/Init.cxx
+++ b/src/output/Init.cxx
@@ -23,6 +23,7 @@
 #include "OutputAPI.hxx"
 #include "Defaults.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "pcm/AudioParser.hxx"
 #include "mixer/Type.hxx"
 #include "mixer/Control.hxx"
@@ -36,7 +37,6 @@
 #include "filter/plugins/TwoFilters.hxx"
 #include "filter/plugins/VolumeFilterPlugin.hxx"
 #include "filter/plugins/NormalizeFilterPlugin.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
 #include "util/StringFormat.hxx"
 #include "Log.hxx"
@@ -282,7 +282,8 @@ audio_output_new(EventLoop &normal_event_loop, EventLoop &rt_event_loop,
 
 		plugin = AudioOutputPlugin_get(p);
 		if (plugin == nullptr)
-			throw FormatRuntimeError("No such audio output plugin: %s", p);
+			throw FmtRuntimeError("No such audio output plugin: {}",
+					      p);
 	} else {
 		LogWarning(output_domain,
 			   "No 'audio_output' defined in config file");
diff --git a/src/output/MultipleOutputs.cxx b/src/output/MultipleOutputs.cxx
index 886a03f91..8864d3da7 100644
--- a/src/output/MultipleOutputs.cxx
+++ b/src/output/MultipleOutputs.cxx
@@ -27,7 +27,7 @@
 #include "config/Block.hxx"
 #include "config/Data.hxx"
 #include "config/Option.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/StringAPI.hxx"
 
 #include <cassert>
@@ -62,8 +62,8 @@ try {
 				mixer_listener);
 } catch (...) {
 	if (block.line > 0)
-		std::throw_with_nested(FormatRuntimeError("Failed to configure output in line %i",
-							  block.line));
+		std::throw_with_nested(FmtRuntimeError("Failed to configure output in line {}",
+						       block.line));
 	else
 		throw;
 }
@@ -99,9 +99,9 @@ MultipleOutputs::Configure(EventLoop &event_loop, EventLoop &rt_event_loop,
 						client, block, defaults,
 						&filter_factory);
 		if (HasName(output->GetName()))
-			throw FormatRuntimeError("output devices with identical "
-						 "names: %s",
-						 output->GetName().c_str());
+			throw FmtRuntimeError("output devices with identical "
+					      "names: {}",
+					      output->GetName());
 
 		outputs.emplace_back(std::move(output));
 	});
diff --git a/src/output/Source.cxx b/src/output/Source.cxx
index d5d818fcb..9065eafc3 100644
--- a/src/output/Source.cxx
+++ b/src/output/Source.cxx
@@ -23,8 +23,9 @@
 #include "filter/Prepared.hxx"
 #include "filter/plugins/ReplayGainFilterPlugin.hxx"
 #include "pcm/Mix.hxx"
+#include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "thread/Mutex.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <string.h>
 
@@ -198,8 +199,8 @@ AudioOutputSource::FilterChunk(const MusicChunk &chunk)
 		if (!pcm_mix(cross_fade_dither, dest, data.data(), data.size(),
 			     in_audio_format.format,
 			     mix_ratio))
-			throw FormatRuntimeError("Cannot cross-fade format %s",
-						 sample_format_to_string(in_audio_format.format));
+			throw FmtRuntimeError("Cannot cross-fade format {}",
+					      in_audio_format.format);
 
 		data = {(const std::byte *)dest, other_data.size()};
 	}
diff --git a/src/output/Thread.cxx b/src/output/Thread.cxx
index 5af7c2c41..ce05cb0ce 100644
--- a/src/output/Thread.cxx
+++ b/src/output/Thread.cxx
@@ -24,12 +24,12 @@
 #include "Domain.hxx"
 #include "lib/fmt/AudioFormatFormatter.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "thread/Util.hxx"
 #include "thread/Slack.hxx"
 #include "thread/Name.hxx"
 #include "util/StringBuffer.hxx"
 #include "util/ScopeExit.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <cassert>
@@ -145,8 +145,8 @@ AudioOutputControl::InternalOpen(const AudioFormat in_audio_format,
 					output->prepared_other_replay_gain_filter.get(),
 					*output->prepared_filter);
 		} catch (...) {
-			std::throw_with_nested(FormatRuntimeError("Failed to open filter for %s",
-								  GetLogName()));
+			std::throw_with_nested(FmtRuntimeError("Failed to open filter for {}",
+							       GetLogName()));
 		}
 
 		try {
diff --git a/src/output/plugins/AlsaOutputPlugin.cxx b/src/output/plugins/AlsaOutputPlugin.cxx
index 69b52b2f3..1cdc39772 100644
--- a/src/output/plugins/AlsaOutputPlugin.cxx
+++ b/src/output/plugins/AlsaOutputPlugin.cxx
@@ -25,6 +25,7 @@
 #include "lib/alsa/NonBlock.hxx"
 #include "lib/alsa/PeriodBuffer.hxx"
 #include "lib/alsa/Version.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/fmt/ToBuffer.hxx"
 #include "../OutputAPI.hxx"
 #include "../Error.hxx"
@@ -34,7 +35,6 @@
 #include "thread/Mutex.hxx"
 #include "thread/Cond.hxx"
 #include "util/Manual.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "event/MultiSocketMonitor.hxx"
 #include "event/InjectEvent.hxx"
@@ -846,8 +846,8 @@ AlsaOutput::Open(AudioFormat &audio_format)
 			   );
 	} catch (...) {
 		snd_pcm_close(pcm);
-		std::throw_with_nested(FormatRuntimeError("Error opening ALSA device \"%s\"",
-							  GetDevice()));
+		std::throw_with_nested(FmtRuntimeError("Error opening ALSA device \"{}\"",
+						       GetDevice()));
 	}
 
 	work_around_drain_bug = MaybeDmix(pcm) &&
diff --git a/src/output/plugins/AoOutputPlugin.cxx b/src/output/plugins/AoOutputPlugin.cxx
index 8db12c5c4..32fca8e56 100644
--- a/src/output/plugins/AoOutputPlugin.cxx
+++ b/src/output/plugins/AoOutputPlugin.cxx
@@ -19,10 +19,10 @@
 
 #include "AoOutputPlugin.hxx"
 #include "../OutputAPI.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "thread/SafeSingleton.hxx"
 #include "system/Error.hxx"
 #include "util/IterableSplitString.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/StringAPI.hxx"
 #include "util/StringSplit.hxx"
@@ -121,8 +121,8 @@ AoOutput::AoOutput(const ConfigBlock &block)
 		driver = ao_driver_id(value);
 
 	if (driver < 0)
-		throw FormatRuntimeError("\"%s\" is not a valid ao driver",
-					 value);
+		throw FmtRuntimeError("\"{}\" is not a valid ao driver",
+				      value);
 
 	ao_info *ai = ao_driver_info(driver);
 	if (ai == nullptr)
@@ -136,8 +136,8 @@ AoOutput::AoOutput(const ConfigBlock &block)
 		for (const std::string_view i : IterableSplitString(value, ';')) {
 			const auto [n, v] = Split(Strip(i), '=');
 			if (n.empty() || v.data() == nullptr)
-				throw FormatRuntimeError("problems parsing option \"%.*s\"",
-							 int(i.size()), i.data());
+				throw FmtRuntimeError("problems parsing option \"{}\"",
+						      i);
 
 			ao_append_option(&options, std::string{n}.c_str(),
 					 std::string{v}.c_str());
diff --git a/src/output/plugins/FifoOutputPlugin.cxx b/src/output/plugins/FifoOutputPlugin.cxx
index 0334e05f0..7b58e6474 100644
--- a/src/output/plugins/FifoOutputPlugin.cxx
+++ b/src/output/plugins/FifoOutputPlugin.cxx
@@ -21,12 +21,12 @@
 #include "../OutputAPI.hxx"
 #include "../Timer.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/AllocatedPath.hxx"
 #include "fs/FileSystem.hxx"
 #include "fs/FileInfo.hxx"
 #include "lib/fmt/SystemError.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 #include "open.h"
 
@@ -37,7 +37,6 @@
 
 class FifoOutput final : AudioOutput {
 	const AllocatedPath path;
-	std::string path_utf8;
 
 	int input = -1;
 	int output = -1;
@@ -84,8 +83,6 @@ FifoOutput::FifoOutput(const ConfigBlock &block)
 	if (path.IsNull())
 		throw std::runtime_error("No \"path\" parameter specified");
 
-	path_utf8 = path.ToUTF8();
-
 	OpenFifo();
 }
 
@@ -147,8 +144,8 @@ FifoOutput::Check()
 	}
 
 	if (!S_ISFIFO(st.st_mode))
-		throw FormatRuntimeError("\"%s\" already exists, but is not a FIFO",
-					 path_utf8.c_str());
+		throw FmtRuntimeError("\"{}\" already exists, but is not a FIFO",
+				      path);
 }
 
 inline void
diff --git a/src/output/plugins/JackOutputPlugin.cxx b/src/output/plugins/JackOutputPlugin.cxx
index 3667e5729..7931d491e 100644
--- a/src/output/plugins/JackOutputPlugin.cxx
+++ b/src/output/plugins/JackOutputPlugin.cxx
@@ -22,10 +22,10 @@
 #include "../OutputAPI.hxx"
 #include "../Error.hxx"
 #include "output/Features.h"
+#include "lib/fmt/RuntimeError.hxx"
 #include "thread/Mutex.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/IterableSplitString.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 #include "util/Domain.hxx"
 #include "Log.hxx"
@@ -123,8 +123,8 @@ private:
 
 	void Shutdown(const char *reason) noexcept {
 		const std::scoped_lock<Mutex> lock(mutex);
-		error = std::make_exception_ptr(FormatRuntimeError("JACK connection shutdown: %s",
-								   reason));
+		error = std::make_exception_ptr(FmtRuntimeError("JACK connection shutdown: {}",
+								reason));
 	}
 
 	static void OnShutdown(jack_status_t, const char *reason,
@@ -416,8 +416,8 @@ JackOutput::Connect()
 	jack_status_t status;
 	client = jack_client_open(name, options, &status, server_name);
 	if (client == nullptr)
-		throw FormatRuntimeError("Failed to connect to JACK server, status=%d",
-					 status);
+		throw FmtRuntimeError("Failed to connect to JACK server, status={}",
+				      (unsigned)status);
 
 	jack_set_process_callback(client, Process, this);
 	jack_on_info_shutdown(client, OnShutdown, this);
@@ -430,8 +430,8 @@ JackOutput::Connect()
 					      portflags, 0);
 		if (ports[i] == nullptr) {
 			Disconnect();
-			throw FormatRuntimeError("Cannot register output port \"%s\"",
-						 source_ports[i].c_str());
+			throw FmtRuntimeError("Cannot register output port \"{}\"",
+					      source_ports[i]);
 		}
 	}
 }
@@ -590,8 +590,8 @@ JackOutput::Start()
 				       dports[i]);
 		if (ret != 0) {
 			Stop();
-			throw FormatRuntimeError("Not a valid JACK port: %s",
-						 dports[i]);
+			throw FmtRuntimeError("Not a valid JACK port: {}",
+					      dports[i]);
 		}
 	}
 
@@ -604,8 +604,8 @@ JackOutput::Start()
 				   duplicate_port);
 		if (ret != 0) {
 			Stop();
-			throw FormatRuntimeError("Not a valid JACK port: %s",
-						 duplicate_port);
+			throw FmtRuntimeError("Not a valid JACK port: {}",
+					      duplicate_port);
 		}
 	}
 }
diff --git a/src/output/plugins/OSXOutputPlugin.cxx b/src/output/plugins/OSXOutputPlugin.cxx
index 1a86ea02b..17f3dd04c 100644
--- a/src/output/plugins/OSXOutputPlugin.cxx
+++ b/src/output/plugins/OSXOutputPlugin.cxx
@@ -25,7 +25,7 @@
 #include "apple/Throw.hxx"
 #include "../OutputAPI.hxx"
 #include "mixer/plugins/OSXMixerPlugin.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/Manual.hxx"
 #include "pcm/Export.hxx"
@@ -247,8 +247,8 @@ osx_output_parse_channel_map(const char *device_name,
 
 	while (*channel_map_str) {
 		if (inserted_channels >= num_channels)
-			throw FormatRuntimeError("%s: channel map contains more than %u entries or trailing garbage",
-						 device_name, num_channels);
+			throw FmtRuntimeError("{}: channel map contains more than {} entries or trailing garbage",
+					      device_name, num_channels);
 
 		if (!want_number && *channel_map_str == ',') {
 			++channel_map_str;
@@ -262,8 +262,8 @@ osx_output_parse_channel_map(const char *device_name,
 			char *endptr;
 			channel_map[inserted_channels] = strtol(channel_map_str, &endptr, 10);
 			if (channel_map[inserted_channels] < -1)
-				throw FormatRuntimeError("%s: channel map value %d not allowed (must be -1 or greater)",
-							 device_name, channel_map[inserted_channels]);
+				throw FmtRuntimeError("{}: channel map value {} not allowed (must be -1 or greater)",
+						      device_name, channel_map[inserted_channels]);
 
 			channel_map_str = endptr;
 			want_number = false;
@@ -275,13 +275,13 @@ osx_output_parse_channel_map(const char *device_name,
 			continue;
 		}
 
-		throw FormatRuntimeError("%s: invalid character '%c' in channel map",
-					 device_name, *channel_map_str);
+		throw FmtRuntimeError("{}: invalid character '{}' in channel map",
+				      device_name, *channel_map_str);
 	}
 
 	if (inserted_channels < num_channels)
-		throw FormatRuntimeError("%s: channel map contains less than %u entries",
-					 device_name, num_channels);
+		throw FmtRuntimeError("{}: channel map contains less than {} entries",
+				      device_name, num_channels);
 }
 
 static UInt32
@@ -453,8 +453,8 @@ osx_output_set_device_format(AudioDeviceID dev_id,
 						 sizeof(output_format),
 						 &output_format);
 		if (err != noErr)
-			throw FormatRuntimeError("Failed to change the stream format: %d",
-						 err);
+			throw FmtRuntimeError("Failed to change the stream format: {}",
+					      err);
 	}
 
 	return output_format.mSampleRate;
@@ -582,8 +582,7 @@ FindAudioDeviceByName(const char *name)
 			return id;
 	}
 
-	throw FormatRuntimeError("Found no audio device with name '%s' ",
-				 name);
+	throw FmtRuntimeError("Found no audio device names '{}'", name);
 }
 
 static void
diff --git a/src/output/plugins/OpenALOutputPlugin.cxx b/src/output/plugins/OpenALOutputPlugin.cxx
index 0fbbc28c0..432d9bf22 100644
--- a/src/output/plugins/OpenALOutputPlugin.cxx
+++ b/src/output/plugins/OpenALOutputPlugin.cxx
@@ -19,7 +19,7 @@
 
 #include "OpenALOutputPlugin.hxx"
 #include "../OutputAPI.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <unistd.h>
 
@@ -126,14 +126,14 @@ OpenALOutput::SetupContext()
 {
 	device = alcOpenDevice(device_name);
 	if (device == nullptr)
-		throw FormatRuntimeError("Error opening OpenAL device \"%s\"",
-					 device_name);
+		throw FmtRuntimeError("Error opening OpenAL device \"{}\"",
+				      device_name);
 
 	context = alcCreateContext(device, nullptr);
 	if (context == nullptr) {
 		alcCloseDevice(device);
-		throw FormatRuntimeError("Error creating context for \"%s\"",
-					 device_name);
+		throw FmtRuntimeError("Error creating context for \"{}\"",
+				      device_name);
 	}
 }
 
diff --git a/src/output/plugins/ShoutOutputPlugin.cxx b/src/output/plugins/ShoutOutputPlugin.cxx
index d49558b48..3346aa3af 100644
--- a/src/output/plugins/ShoutOutputPlugin.cxx
+++ b/src/output/plugins/ShoutOutputPlugin.cxx
@@ -21,7 +21,7 @@
 #include "../OutputAPI.hxx"
 #include "encoder/EncoderInterface.hxx"
 #include "encoder/Configured.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StringAPI.hxx"
@@ -105,8 +105,8 @@ require_block_string(const ConfigBlock &block, const char *name)
 {
 	const char *value = block.GetBlockValue(name);
 	if (value == nullptr)
-		throw FormatRuntimeError("no \"%s\" defined for shout device defined "
-					 "at line %d\n", name, block.line);
+		throw FmtRuntimeError("no \"{}\" defined for shout device defined "
+				      "at line {}\n", name, block.line);
 
 	return value;
 }
@@ -140,8 +140,8 @@ ParseShoutTls(const char *value)
 	else if (StringIsEqual(value, "rfc2817"))
 		return SHOUT_TLS_RFC2817;
 	else
-		throw FormatRuntimeError("invalid shout TLS option \"%s\"",
-					 value);
+		throw FmtRuntimeError("invalid shout TLS option \"{}\"",
+				      value);
 }
 
 #endif
@@ -163,17 +163,17 @@ ParseShoutProtocol(const char *value, const char *mime_type)
 
 	if (StringIsEqual(value, "shoutcast")) {
 		if (!StringIsEqual(mime_type, "audio/mpeg"))
-			throw FormatRuntimeError("you cannot stream \"%s\" to shoutcast, use mp3",
-						 mime_type);
+			throw FmtRuntimeError("you cannot stream \"{}\" to shoutcast, use mp3",
+					      mime_type);
 		return SHOUT_PROTOCOL_ICY;
 	} else if (StringIsEqual(value, "icecast1"))
 		return SHOUT_PROTOCOL_XAUDIOCAST;
 	else if (StringIsEqual(value, "icecast2"))
 		return SHOUT_PROTOCOL_HTTP;
 	else
-		throw FormatRuntimeError("shout protocol \"%s\" is not \"shoutcast\" or "
-					 "\"icecast1\"or \"icecast2\"",
-					 value);
+		throw FmtRuntimeError("shout protocol \"{}\" is not \"shoutcast\" or "
+				      "\"icecast1\"or \"icecast2\"",
+				      value);
 }
 
 inline
@@ -309,16 +309,16 @@ HandleShoutError(shout_t *shout_conn, int err)
 
 	case SHOUTERR_UNCONNECTED:
 	case SHOUTERR_SOCKET:
-		throw FormatRuntimeError("Lost shout connection to %s:%i: %s",
-					 shout_get_host(shout_conn),
-					 shout_get_port(shout_conn),
-					 shout_get_error(shout_conn));
+		throw FmtRuntimeError("Lost shout connection to {}:{}: {}",
+				      shout_get_host(shout_conn),
+				      shout_get_port(shout_conn),
+				      shout_get_error(shout_conn));
 
 	default:
-		throw FormatRuntimeError("connection to %s:%i error: %s",
-					 shout_get_host(shout_conn),
-					 shout_get_port(shout_conn),
-					 shout_get_error(shout_conn));
+		throw FmtRuntimeError("connection to {}:{} error: {}",
+				      shout_get_host(shout_conn),
+				      shout_get_port(shout_conn),
+				      shout_get_error(shout_conn));
 	}
 }
 
@@ -381,10 +381,10 @@ ShoutOpen(shout_t *shout_conn)
 		break;
 
 	default:
-		throw FormatRuntimeError("problem opening connection to shout server %s:%i: %s",
-					 shout_get_host(shout_conn),
-					 shout_get_port(shout_conn),
-					 shout_get_error(shout_conn));
+		throw FmtRuntimeError("problem opening connection to shout server {}:{}: {}",
+				      shout_get_host(shout_conn),
+				      shout_get_port(shout_conn),
+				      shout_get_error(shout_conn));
 	}
 }
 
diff --git a/src/output/plugins/WinmmOutputPlugin.cxx b/src/output/plugins/WinmmOutputPlugin.cxx
index 34a5c40f7..b1692de4f 100644
--- a/src/output/plugins/WinmmOutputPlugin.cxx
+++ b/src/output/plugins/WinmmOutputPlugin.cxx
@@ -21,8 +21,8 @@
 #include "../OutputAPI.hxx"
 #include "pcm/Buffer.hxx"
 #include "mixer/plugins/WinmmMixerPlugin.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringCompare.hxx"
 
 #include <array>
@@ -91,7 +91,7 @@ MakeWaveOutError(MMRESULT result, const char *prefix)
 	char buffer[256];
 	if (waveOutGetErrorTextA(result, buffer,
 				 std::size(buffer)) == MMSYSERR_NOERROR)
-		return FormatRuntimeError("%s: %s", prefix, buffer);
+		return FmtRuntimeError("{}: {}", prefix, buffer);
 	else
 		return std::runtime_error(prefix);
 }
@@ -122,8 +122,8 @@ get_device_id(const char *device_name)
 	UINT id = strtoul(device_name, &endptr, 0);
 	if (endptr > device_name && *endptr == 0) {
 		if (id >= numdevs)
-			throw FormatRuntimeError("device \"%s\" is not found",
-						 device_name);
+			throw FmtRuntimeError("device \"{}\" is not found",
+					      device_name);
 
 		return id;
 	}
@@ -143,7 +143,7 @@ get_device_id(const char *device_name)
 			return i;
 	}
 
-	throw FormatRuntimeError("device \"%s\" is not found", device_name);
+	throw FmtRuntimeError("device \"{}\" is not found", device_name);
 }
 
 WinmmOutput::WinmmOutput(const ConfigBlock &block)
diff --git a/src/output/plugins/wasapi/WasapiOutputPlugin.cxx b/src/output/plugins/wasapi/WasapiOutputPlugin.cxx
index 940c79f08..698d3b4f4 100644
--- a/src/output/plugins/wasapi/WasapiOutputPlugin.cxx
+++ b/src/output/plugins/wasapi/WasapiOutputPlugin.cxx
@@ -27,6 +27,7 @@
 #include "output/OutputAPI.hxx"
 #include "lib/icu/Win32.hxx"
 #include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "mixer/plugins/WasapiMixerPlugin.hxx"
 #include "output/Error.hxx"
 #include "pcm/Export.hxx"
@@ -36,7 +37,6 @@
 #include "thread/Thread.hxx"
 #include "util/AllocatedString.hxx"
 #include "util/Domain.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StringBuffer.hxx"
 #include "win32/Com.hxx"
@@ -806,8 +806,8 @@ WasapiOutput::ChooseDevice()
 		if (!SafeSilenceTry([this, &id]() { id = std::stoul(device_config); })) {
 			device = SearchDevice(*enumerator, device_config);
 			if (!device)
-				throw FormatRuntimeError("Device '%s' not found",
-							 device_config.c_str());
+				throw FmtRuntimeError("Device '{}' not found",
+						      device_config);
 		} else
 			device = GetDevice(*enumerator, id);
 	} else {
diff --git a/src/pcm/AudioParser.cxx b/src/pcm/AudioParser.cxx
index dbe0265e6..ae5f83f7a 100644
--- a/src/pcm/AudioParser.cxx
+++ b/src/pcm/AudioParser.cxx
@@ -24,7 +24,7 @@
 
 #include "AudioParser.hxx"
 #include "AudioFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <cassert>
 
@@ -46,7 +46,7 @@ ParseSampleRate(const char *src, bool mask, const char **endptr_r)
 	if (endptr == src) {
 		throw std::invalid_argument("Failed to parse the sample rate");
 	} else if (!audio_valid_sample_rate(value))
-		throw FormatInvalidArgument("Invalid sample rate: %lu", value);
+		throw FmtInvalidArgument("Invalid sample rate: {}", value);
 
 	*endptr_r = endptr;
 	return value;
@@ -100,8 +100,7 @@ ParseSampleFormat(const char *src, bool mask, const char **endptr_r)
 		break;
 
 	default:
-		throw FormatInvalidArgument("Invalid sample format: %lu",
-					    value);
+		throw FmtInvalidArgument("Invalid sample format: {}", value);
 	}
 
 	assert(audio_valid_sample_format(sample_format));
@@ -125,8 +124,7 @@ ParseChannelCount(const char *src, bool mask, const char **endptr_r)
 	if (endptr == src)
 		throw std::invalid_argument("Failed to parse the channel count");
 	else if (!audio_valid_channel_count(value))
-		throw FormatInvalidArgument("Invalid channel count: %u",
-					    value);
+		throw FmtInvalidArgument("Invalid channel count: {}", value);
 
 	*endptr_r = endptr;
 	return value;
@@ -152,8 +150,8 @@ ParseAudioFormat(const char *src, bool mask)
 			src = endptr + 1;
 			dest.channels = ParseChannelCount(src, mask, &src);
 			if (*src != 0)
-				throw FormatInvalidArgument("Extra data after channel count: %s",
-							    src);
+				throw FmtInvalidArgument("Extra data after channel count: {}",
+							 src);
 
 			return dest;
 		}
@@ -178,8 +176,8 @@ ParseAudioFormat(const char *src, bool mask)
 	dest.channels = ParseChannelCount(src, mask, &src);
 
 	if (*src != 0)
-		throw FormatInvalidArgument("Extra data after channel count: %s",
-					    src);
+		throw FmtInvalidArgument("Extra data after channel count: {}",
+					 src);
 
 	assert(mask
 	       ? dest.IsMaskValid()
diff --git a/src/pcm/ChannelsConverter.cxx b/src/pcm/ChannelsConverter.cxx
index d10ae1c12..62177ce7d 100644
--- a/src/pcm/ChannelsConverter.cxx
+++ b/src/pcm/ChannelsConverter.cxx
@@ -19,7 +19,8 @@
 
 #include "ChannelsConverter.hxx"
 #include "PcmChannels.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 
 #include <cassert>
@@ -38,8 +39,8 @@ PcmChannelsConverter::Open(SampleFormat _format,
 		break;
 
 	default:
-		throw FormatRuntimeError("PCM channel conversion for %s is not implemented",
-					 sample_format_to_string(_format));
+		throw FmtRuntimeError("PCM channel conversion for {} is not implemented",
+				      _format);
 	}
 
 	format = _format;
diff --git a/src/pcm/CheckAudioFormat.cxx b/src/pcm/CheckAudioFormat.cxx
index 2f6c45686..64ad64810 100644
--- a/src/pcm/CheckAudioFormat.cxx
+++ b/src/pcm/CheckAudioFormat.cxx
@@ -19,30 +19,30 @@
 
 #include "CheckAudioFormat.hxx"
 #include "AudioFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 void
 CheckSampleRate(unsigned long sample_rate)
 {
 	if (!audio_valid_sample_rate(sample_rate))
-		throw FormatRuntimeError("Invalid sample rate: %lu",
-					 sample_rate);
+		throw FmtRuntimeError("Invalid sample rate: {}",
+				      sample_rate);
 }
 
 void
 CheckSampleFormat(SampleFormat sample_format)
 {
 	if (!audio_valid_sample_format(sample_format))
-		throw FormatRuntimeError("Invalid sample format: %u",
-					 unsigned(sample_format));
+		throw FmtRuntimeError("Invalid sample format: {}",
+				      unsigned(sample_format));
 }
 
 void
 CheckChannelCount(unsigned channels)
 {
 	if (!audio_valid_channel_count(channels))
-		throw FormatRuntimeError("Invalid channel count: %u",
-					 channels);
+		throw FmtRuntimeError("Invalid channel count: {}",
+				      channels);
 }
 
 AudioFormat
diff --git a/src/pcm/ConfiguredResampler.cxx b/src/pcm/ConfiguredResampler.cxx
index c6d369a0c..b54934925 100644
--- a/src/pcm/ConfiguredResampler.cxx
+++ b/src/pcm/ConfiguredResampler.cxx
@@ -23,7 +23,7 @@
 #include "config/Option.hxx"
 #include "config/Block.hxx"
 #include "config/Param.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "config.h"
 
 #ifdef ENABLE_LIBSAMPLERATE
@@ -122,8 +122,8 @@ GetResamplerConfig(const ConfigData &config, ConfigBlock &buffer)
 		return MigrateResamplerConfig(old_param, buffer);
 
 	if (old_param != nullptr)
-		throw FormatRuntimeError("Cannot use both 'resampler' (line %d) and 'samplerate_converter' (line %d)",
-					 block->line, old_param->line);
+		throw FmtRuntimeError("Cannot use both 'resampler' (line {}) and 'samplerate_converter' (line {})",
+				      block->line, old_param->line);
 
 	block->SetUsed();
 	return block;
@@ -137,8 +137,8 @@ pcm_resampler_global_init(const ConfigData &config)
 
 	const char *plugin_name = block->GetBlockValue("plugin");
 	if (plugin_name == nullptr)
-		throw FormatRuntimeError("'plugin' missing in line %d",
-					 block->line);
+		throw FmtRuntimeError("'plugin' missing in line {}",
+				      block->line);
 
 	if (strcmp(plugin_name, "internal") == 0) {
 		selected_resampler = SelectedResampler::FALLBACK;
@@ -153,8 +153,8 @@ pcm_resampler_global_init(const ConfigData &config)
 		pcm_resample_lsr_global_init(*block);
 #endif
 	} else {
-		throw FormatRuntimeError("No such resampler plugin: %s",
-					 plugin_name);
+		throw FmtRuntimeError("No such resampler plugin: {}",
+				      plugin_name);
 	}
 }
 
diff --git a/src/pcm/FormatConverter.cxx b/src/pcm/FormatConverter.cxx
index 07c9751e7..b623f124d 100644
--- a/src/pcm/FormatConverter.cxx
+++ b/src/pcm/FormatConverter.cxx
@@ -19,7 +19,8 @@
 
 #include "FormatConverter.hxx"
 #include "PcmFormat.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 
 #include <cassert>
 
@@ -36,9 +37,8 @@ PcmFormatConverter::Open(SampleFormat _src_format, SampleFormat _dest_format)
 
 	case SampleFormat::S8:
 	case SampleFormat::DSD:
-		throw FormatRuntimeError("PCM conversion from %s to %s is not implemented",
-					 sample_format_to_string(_src_format),
-					 sample_format_to_string(_dest_format));
+		throw FmtRuntimeError("PCM conversion from {} to {} is not implemented",
+				      _src_format, _dest_format);
 
 	case SampleFormat::S16:
 	case SampleFormat::S24_P32:
diff --git a/src/pcm/LibsamplerateResampler.cxx b/src/pcm/LibsamplerateResampler.cxx
index 81ae37454..f198395eb 100644
--- a/src/pcm/LibsamplerateResampler.cxx
+++ b/src/pcm/LibsamplerateResampler.cxx
@@ -19,8 +19,8 @@
 
 #include "LibsamplerateResampler.hxx"
 #include "config/Block.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/ASCII.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "util/SpanCast.hxx"
 #include "Log.hxx"
@@ -69,8 +69,8 @@ pcm_resample_lsr_global_init(const ConfigBlock &block)
 {
 	const char *converter = block.GetBlockValue("type", "2");
 	if (!lsr_parse_converter(converter))
-		throw FormatRuntimeError("unknown samplerate converter '%s'",
-					 converter);
+		throw FmtRuntimeError("unknown samplerate converter '{}'",
+				      converter);
 
 	FmtDebug(libsamplerate_domain,
 		 "libsamplerate converter '{}'",
@@ -93,8 +93,8 @@ LibsampleratePcmResampler::Open(AudioFormat &af, unsigned new_sample_rate)
 	int src_error;
 	state = src_new(lsr_converter, channels, &src_error);
 	if (!state)
-		throw FormatRuntimeError("libsamplerate initialization has failed: %s",
-					 src_strerror(src_error));
+		throw FmtRuntimeError("libsamplerate initialization has failed: {}",
+				      src_strerror(src_error));
 
 	memset(&data, 0, sizeof(data));
 
@@ -138,8 +138,8 @@ LibsampleratePcmResampler::Resample2(std::span<const float> src)
 
 	int result = src_process(state, &data);
 	if (result != 0)
-		throw FormatRuntimeError("libsamplerate has failed: %s",
-					 src_strerror(result));
+		throw FmtRuntimeError("libsamplerate has failed: {}",
+				      src_strerror(result));
 
 	return {data.data_out, size_t(data.output_frames_gen * channels)};
 }
diff --git a/src/pcm/SoxrResampler.cxx b/src/pcm/SoxrResampler.cxx
index 148669045..463dc09b3 100644
--- a/src/pcm/SoxrResampler.cxx
+++ b/src/pcm/SoxrResampler.cxx
@@ -20,7 +20,7 @@
 #include "SoxrResampler.hxx"
 #include "AudioFormat.hxx"
 #include "config/Block.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/Domain.hxx"
 #include "Log.hxx"
 
@@ -100,18 +100,17 @@ SoxrParsePrecision(unsigned value) {
 	case 32:
 		break;
 	default:
-		throw FormatInvalidArgument(
-			"soxr converter invalid precision : %d [16|20|24|28|32]", value);
+		throw FmtInvalidArgument("soxr converter invalid precision: {} [16|20|24|28|32]",
+					 value);
 	}
 	return value;
 }
 
 static double
 SoxrParsePhaseResponse(unsigned value) {
-	if (value > 100) {
-		throw FormatInvalidArgument(
-			"soxr converter invalid phase_respons : %d (0-100)", value);
-	}
+	if (value > 100)
+		throw FmtInvalidArgument("soxr converter invalid phase_respons : {} (0-100)",
+					 value);
 
 	return double(value);
 }
@@ -120,15 +119,13 @@ static double
 SoxrParsePassbandEnd(const char *svalue) {
 	char *endptr;
 	double value = strtod(svalue, &endptr);
-	if (svalue == endptr || *endptr != 0) {
-		throw FormatInvalidArgument(
-			"soxr converter passband_end value not a number: %s", svalue);
-	}
+	if (svalue == endptr || *endptr != 0)
+		throw FmtInvalidArgument("soxr converter passband_end value not a number: {}",
+					 svalue);
 
-	if (value < 1 || value > 100) {
-		throw FormatInvalidArgument(
-			"soxr converter invalid passband_end : %s (1-100%%)", svalue);
-	}
+	if (value < 1 || value > 100)
+		throw FmtInvalidArgument("soxr converter invalid passband_end: {} (1-100%)",
+					 svalue);
 
 	return value / 100.0;
 }
@@ -137,15 +134,13 @@ static double
 SoxrParseStopbandBegin(const char *svalue) {
 	char *endptr;
 	double value = strtod(svalue, &endptr);
-	if (svalue == endptr || *endptr != 0) {
-		throw FormatInvalidArgument(
-			"soxr converter stopband_begin value not a number: %s", svalue);
-	}
+	if (svalue == endptr || *endptr != 0)
+		throw FmtInvalidArgument("soxr converter stopband_begin value not a number: {}",
+					 svalue);
 
-	if (value < 100 || value > 199) {
-		throw FormatInvalidArgument(
-			"soxr converter invalid stopband_begin : %s (100-150%%)", svalue);
-	}
+	if (value < 100 || value > 199)
+		throw FmtInvalidArgument("soxr converter invalid stopband_begin: {} (100-150%)",
+					 svalue);
 
 	return value / 100.0;
 }
@@ -155,14 +150,13 @@ SoxrParseAttenuation(const char *svalue) {
 	char *endptr;
 	double value = strtod(svalue, &endptr);
 	if (svalue == endptr || *endptr != 0) {
-		throw FormatInvalidArgument(
-			"soxr converter attenuation value not a number: %s", svalue);
+		throw FmtInvalidArgument("soxr converter attenuation value not a number: {}",
+					 svalue);
 	}
 
-	if (value < 0 || value > 30) {
-		throw FormatInvalidArgument(
-			"soxr converter invalid attenuation : %s (0-30dB))", svalue);
-	}
+	if (value < 0 || value > 30)
+		throw FmtInvalidArgument("soxr converter invalid attenuation: {} (0-30dB))",
+					 svalue);
 
 	return 1 / std::pow(10, value / 10.0);
 }
@@ -176,8 +170,8 @@ pcm_resample_soxr_global_init(const ConfigBlock &block)
 
 	if (recipe == SOXR_INVALID_RECIPE) {
 		assert(quality_string != nullptr);
-		throw FormatRuntimeError("unknown quality setting '%s' in line %d",
-					 quality_string, block.line);
+		throw FmtRuntimeError("unknown quality setting '{}' in line {}",
+				      quality_string, block.line);
 	} else if (recipe == SOXR_CUSTOM_RECIPE) {
 		// used to preset possible internal flags, like SOXR_RESET_ON_CLEAR
 		soxr_quality = soxr_quality_spec(SOXR_DEFAULT_RECIPE, 0);
@@ -222,8 +216,8 @@ SoxrPcmResampler::Open(AudioFormat &af, unsigned new_sample_rate)
 			   af.channels, &e,
 			   p_soxr_io, &soxr_quality, &soxr_runtime);
 	if (soxr == nullptr)
-		throw FormatRuntimeError("soxr initialization has failed: %s",
-					 e);
+		throw FmtRuntimeError("soxr initialization has failed: {}",
+				      e);
 
 	FmtDebug(soxr_domain, "soxr engine '{}'", soxr_engine(soxr));
 	if (soxr_use_custom_recipe)
@@ -284,7 +278,7 @@ SoxrPcmResampler::Resample(std::span<const std::byte> src)
 	soxr_error_t e = soxr_process(soxr, src.data(), n_frames, &i_done,
 				      output_buffer, o_frames, &o_done);
 	if (e != nullptr)
-		throw FormatRuntimeError("soxr error: %s", e);
+		throw FmtRuntimeError("soxr error: {}", e);
 
 	return { (const std::byte *)output_buffer, o_done * frame_size };
 }
@@ -301,7 +295,7 @@ SoxrPcmResampler::Flush()
 	soxr_error_t e = soxr_process(soxr, nullptr, 0, nullptr,
 				      output_buffer, o_frames, &o_done);
 	if (e != nullptr)
-		throw FormatRuntimeError("soxr error: %s", e);
+		throw FmtRuntimeError("soxr error: {}", e);
 
 	if (o_done == 0)
 		/* flush complete */
diff --git a/src/pcm/Volume.cxx b/src/pcm/Volume.cxx
index a3d8a9856..7e918f4a7 100644
--- a/src/pcm/Volume.cxx
+++ b/src/pcm/Volume.cxx
@@ -20,7 +20,8 @@
 #include "Volume.hxx"
 #include "Silence.hxx"
 #include "Traits.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/AudioFormatFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/TransformN.hxx"
 
 #include "Dither.cxx" // including the .cxx file to get inlined templates
@@ -154,8 +155,8 @@ PcmVolume::Open(SampleFormat _format, bool allow_convert)
 
 	switch (_format) {
 	case SampleFormat::UNDEFINED:
-		throw FormatRuntimeError("Software volume for %s is not implemented",
-					 sample_format_to_string(_format));
+		throw FmtRuntimeError("Software volume for {} is not implemented",
+				      _format);
 
 	case SampleFormat::S8:
 		break;
diff --git a/src/pcm/meson.build b/src/pcm/meson.build
index a8dff1d7a..04cb3f536 100644
--- a/src/pcm/meson.build
+++ b/src/pcm/meson.build
@@ -30,6 +30,7 @@ pcm_basic = static_library(
   include_directories: inc,
   dependencies: [
     util_dep,
+    fmt_dep,
   ],
 )
 
diff --git a/src/playlist/plugins/FlacPlaylistPlugin.cxx b/src/playlist/plugins/FlacPlaylistPlugin.cxx
index aedb1926d..8267b49db 100644
--- a/src/playlist/plugins/FlacPlaylistPlugin.cxx
+++ b/src/playlist/plugins/FlacPlaylistPlugin.cxx
@@ -28,9 +28,9 @@
 #include "../MemorySongEnumerator.hxx"
 #include "lib/xiph/FlacMetadataChain.hxx"
 #include "lib/xiph/FlacMetadataIterator.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "song/DetachedSong.hxx"
 #include "input/InputStream.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <FLAC/metadata.h>
 
@@ -67,8 +67,8 @@ flac_playlist_open_stream(InputStreamPtr &&is)
 {
 	FlacMetadataChain chain;
 	if (!chain.Read(*is))
-		throw FormatRuntimeError("Failed to read FLAC metadata: %s",
-					 chain.GetStatusString());
+		throw FmtRuntimeError("Failed to read FLAC metadata: {}",
+				      chain.GetStatusString());
 
 	FlacMetadataIterator iterator((FLAC__Metadata_Chain *)chain);
 
diff --git a/src/song/Filter.cxx b/src/song/Filter.cxx
index 37edad653..03724941b 100644
--- a/src/song/Filter.cxx
+++ b/src/song/Filter.cxx
@@ -29,8 +29,8 @@
 #include "pcm/AudioParser.hxx"
 #include "tag/ParseName.hxx"
 #include "time/ISO8601.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/CharUtil.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/StringCompare.hxx"
 #include "util/StringStrip.hxx"
 #include "util/ASCII.hxx"
@@ -160,8 +160,7 @@ ExpectFilterType(const char *&s)
 
 	const auto type = locate_parse_type(name.c_str());
 	if (type == TAG_NUM_OF_ITEM_TYPES)
-		throw FormatRuntimeError("Unknown filter type: %s",
-					 name.c_str());
+		throw FmtRuntimeError("Unknown filter type: {}", name);
 
 	return type;
 }
diff --git a/src/storage/Configured.cxx b/src/storage/Configured.cxx
index fcb0e2976..145b52717 100644
--- a/src/storage/Configured.cxx
+++ b/src/storage/Configured.cxx
@@ -24,7 +24,7 @@
 #include "config/Data.hxx"
 #include "fs/StandardDirectory.hxx"
 #include "fs/CheckFile.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/UriExtract.hxx"
 
 static std::unique_ptr<Storage>
@@ -32,7 +32,7 @@ CreateConfiguredStorageUri(EventLoop &event_loop, const char *uri)
 {
 	auto storage = CreateStorageURI(event_loop, uri);
 	if (storage == nullptr)
-		throw FormatRuntimeError("Unrecognized storage URI: %s", uri);
+		throw FmtRuntimeError("Unrecognized storage URI: {}", uri);
 
 	return storage;
 }
diff --git a/src/storage/plugins/CurlStorage.cxx b/src/storage/plugins/CurlStorage.cxx
index 393fdfc71..20b9da70d 100644
--- a/src/storage/plugins/CurlStorage.cxx
+++ b/src/storage/plugins/CurlStorage.cxx
@@ -30,12 +30,12 @@
 #include "lib/curl/Handler.hxx"
 #include "lib/curl/Escape.hxx"
 #include "lib/expat/ExpatParser.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/Traits.hxx"
 #include "event/InjectEvent.hxx"
 #include "thread/Mutex.hxx"
 #include "thread/Cond.hxx"
 #include "util/ASCII.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/SpanCast.hxx"
 #include "util/StringCompare.hxx"
 #include "util/StringFormat.hxx"
@@ -302,8 +302,8 @@ private:
 	/* virtual methods from CurlResponseHandler */
 	void OnHeaders(unsigned status, Curl::Headers &&headers) final {
 		if (status != 207)
-			throw FormatRuntimeError("Status %d from WebDAV server; expected \"207 Multi-Status\"",
-						 status);
+			throw FmtRuntimeError("Status {} from WebDAV server; expected \"207 Multi-Status\"",
+					      status);
 
 		if (!IsXmlContentType(headers))
 			throw std::runtime_error("Unexpected Content-Type from WebDAV server");
diff --git a/src/storage/plugins/UdisksStorage.cxx b/src/storage/plugins/UdisksStorage.cxx
index 36c0ae126..577378f2f 100644
--- a/src/storage/plugins/UdisksStorage.cxx
+++ b/src/storage/plugins/UdisksStorage.cxx
@@ -23,6 +23,7 @@
 #include "storage/StorageInterface.hxx"
 #include "storage/FileInfo.hxx"
 #include "lib/fmt/ExceptionFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/dbus/Glue.hxx"
 #include "lib/dbus/AsyncRequest.hxx"
 #include "lib/dbus/Message.hxx"
@@ -38,7 +39,6 @@
 #include "fs/AllocatedPath.hxx"
 #include "util/Domain.hxx"
 #include "util/StringCompare.hxx"
-#include "util/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <stdexcept>
@@ -180,8 +180,8 @@ UdisksStorage::OnListReply(ODBus::Message reply) noexcept
 		});
 
 		if (dbus_path.empty())
-			throw FormatRuntimeError("No such UDisks2 object: %s",
-						 id.c_str());
+			throw FmtRuntimeError("No such UDisks2 object: {}",
+					      id);
 
 		if (!mount_point.empty()) {
 			/* already mounted: don't attempt to mount
diff --git a/src/tag/Config.cxx b/src/tag/Config.cxx
index ddf622dd5..ae9e2f616 100644
--- a/src/tag/Config.cxx
+++ b/src/tag/Config.cxx
@@ -22,8 +22,8 @@
 #include "ParseName.hxx"
 #include "config/Data.hxx"
 #include "config/Option.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "util/ASCII.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/IterableSplitString.hxx"
 #include "util/StringCompare.hxx"
 #include "util/StringStrip.hxx"
@@ -59,8 +59,8 @@ TagLoadConfig(const ConfigData &config)
 
 		const auto type = tag_name_parse_i(name);
 		if (type == TAG_NUM_OF_ITEM_TYPES)
-			throw FormatRuntimeError("error parsing metadata item \"%s\"",
-						 name);
+			throw FmtRuntimeError("error parsing metadata item \"{}\"",
+					      name);
 
 		if (plus)
 			global_tag_mask.Set(type);
diff --git a/src/tag/meson.build b/src/tag/meson.build
index 9f564db6e..b643e1cf5 100644
--- a/src/tag/meson.build
+++ b/src/tag/meson.build
@@ -58,6 +58,7 @@ tag = static_library(
   tag_sources,
   include_directories: inc,
   dependencies: [
+    fmt_dep,
     libid3tag_dep,
   ],
 )
diff --git a/src/unix/Daemon.cxx b/src/unix/Daemon.cxx
index f6db298ee..719a6b688 100644
--- a/src/unix/Daemon.cxx
+++ b/src/unix/Daemon.cxx
@@ -20,9 +20,9 @@
 #include "config.h"
 #include "Daemon.hxx"
 #include "lib/fmt/PathFormatter.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "lib/fmt/SystemError.hxx"
 #include "fs/AllocatedPath.hxx"
-#include "util/RuntimeError.hxx"
 
 #ifndef _WIN32
 #include "PidFile.hxx"
@@ -217,7 +217,7 @@ daemonize_init(const char *user, const char *group, AllocatedPath &&_pidfile)
 	if (user) {
 		struct passwd *pwd = getpwnam(user);
 		if (pwd == nullptr)
-			throw FormatRuntimeError("no such user \"%s\"", user);
+			throw FmtRuntimeError("no such user \"{}\"", user);
 
 		user_uid = pwd->pw_uid;
 		user_gid = pwd->pw_gid;
@@ -231,8 +231,7 @@ daemonize_init(const char *user, const char *group, AllocatedPath &&_pidfile)
 	if (group) {
 		struct group *grp = getgrnam(group);
 		if (grp == nullptr)
-			throw FormatRuntimeError("no such group \"%s\"",
-						 group);
+			throw FmtRuntimeError("no such group \"{}\"", group);
 		user_gid = grp->gr_gid;
 		had_group = true;
 	}
diff --git a/src/zeroconf/avahi/Helper.cxx b/src/zeroconf/avahi/Helper.cxx
index e7da4138a..c6df1b0a4 100644
--- a/src/zeroconf/avahi/Helper.cxx
+++ b/src/zeroconf/avahi/Helper.cxx
@@ -22,7 +22,7 @@
 #include "lib/avahi/ErrorHandler.hxx"
 #include "lib/avahi/Publisher.hxx"
 #include "lib/avahi/Service.hxx"
-#include "util/RuntimeError.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "Log.hxx"
 
 #include <avahi-common/domain.h>
@@ -56,8 +56,8 @@ AvahiInit(EventLoop &event_loop, const char *service_name,
 	  const char *service_type, unsigned port)
 {
 	if (!avahi_is_valid_service_name(service_name))
-		throw FormatRuntimeError("Invalid zeroconf_name \"%s\"",
-					 service_name);
+		throw FmtRuntimeError("Invalid zeroconf_name \"{}\"",
+				      service_name);
 
 	auto client = shared_avahi_client.lock();
 	if (!client)
diff --git a/test/read_conf.cxx b/test/read_conf.cxx
index b6c2ef0a7..4f8cb59e9 100644
--- a/test/read_conf.cxx
+++ b/test/read_conf.cxx
@@ -20,10 +20,10 @@
 #include "config/Data.hxx"
 #include "config/Param.hxx"
 #include "config/File.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/Path.hxx"
 #include "fs/NarrowPath.hxx"
 #include "util/PrintException.hxx"
-#include "util/RuntimeError.hxx"
 
 #include <stdio.h>
 #include <stdlib.h>
@@ -40,14 +40,14 @@ try {
 
 	const auto option = ParseConfigOptionName(name);
 	if (option == ConfigOption::MAX)
-		throw FormatRuntimeError("Unknown setting: %s", name);
+		throw FmtRuntimeError("Unknown setting: {}", name);
 
 	ConfigData config;
 	ReadConfigFile(config, config_path);
 
 	const auto *param = config.GetParam(option);
 	if (param == nullptr)
-		throw FormatRuntimeError("No such setting: %s", name);
+		throw FmtRuntimeError("No such setting: {}", name);
 
 	printf("%s\n", param->value.c_str());
 	return EXIT_SUCCESS;
diff --git a/test/run_filter.cxx b/test/run_filter.cxx
index 0546001ee..1c9e0a814 100644
--- a/test/run_filter.cxx
+++ b/test/run_filter.cxx
@@ -19,6 +19,7 @@
 
 #include "ConfigGlue.hxx"
 #include "ReadFrames.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "fs/Path.hxx"
 #include "fs/NarrowPath.hxx"
 #include "filter/LoadOne.hxx"
@@ -31,7 +32,6 @@
 #include "system/Error.hxx"
 #include "io/FileDescriptor.hxx"
 #include "util/StringBuffer.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/PrintException.hxx"
 
 #include <cassert>
@@ -48,8 +48,8 @@ LoadFilter(const ConfigData &config, const char *name)
 	const auto *param = config.FindBlock(ConfigBlockOption::AUDIO_FILTER,
 					     "name", name);
 	if (param == nullptr)
-		throw FormatRuntimeError("No such configured filter: %s",
-					 name);
+		throw FmtRuntimeError("No such configured filter: {}",
+				      name);
 
 	return filter_configured_new(*param);
 }
diff --git a/test/run_output.cxx b/test/run_output.cxx
index 39a134135..f33748c2b 100644
--- a/test/run_output.cxx
+++ b/test/run_output.cxx
@@ -21,6 +21,7 @@
 #include "output/Registry.hxx"
 #include "output/OutputPlugin.hxx"
 #include "ConfigGlue.hxx"
+#include "lib/fmt/RuntimeError.hxx"
 #include "event/Thread.hxx"
 #include "fs/Path.hxx"
 #include "fs/NarrowPath.hxx"
@@ -29,7 +30,6 @@
 #include "cmdline/OptionDef.hxx"
 #include "cmdline/OptionParser.hxx"
 #include "util/StringBuffer.hxx"
-#include "util/RuntimeError.hxx"
 #include "util/ScopeExit.hxx"
 #include "util/StaticFifoBuffer.hxx"
 #include "util/PrintException.hxx"
@@ -95,8 +95,8 @@ LoadAudioOutput(const ConfigData &config, EventLoop &event_loop,
 	const auto *block = config.FindBlock(ConfigBlockOption::AUDIO_OUTPUT,
 					     "name", name);
 	if (block == nullptr)
-		throw FormatRuntimeError("No such configured audio output: %s",
-					 name);
+		throw FmtRuntimeError("No such configured audio output: {}",
+				      name);
 
 	const char *plugin_name = block->GetBlockValue("type");
 	if (plugin_name == nullptr)
@@ -104,8 +104,8 @@ LoadAudioOutput(const ConfigData &config, EventLoop &event_loop,
 
 	const auto *plugin = AudioOutputPlugin_get(plugin_name);
 	if (plugin == nullptr)
-		throw FormatRuntimeError("No such audio output plugin: %s",
-					 plugin_name);
+		throw FmtRuntimeError("No such audio output plugin: {}",
+				      plugin_name);
 
 	return std::unique_ptr<AudioOutput>(ao_plugin_init(event_loop, *plugin,
 							   *block));