I figured it out with some inspiration from this thread
#include <cstdio>
template<class T> struct format;
template<class T> struct format<T*>       { static constexpr char const * spec = "%p";  };
template<> struct format<int>             { static constexpr char const * spec = "%d";  };
template<> struct format<double>          { static constexpr char const * spec = "%.2f";};
template<> struct format<const char*>     { static constexpr char const * spec = "%s";  };
template<> struct format<char>            { static constexpr char const * spec = "%c";  };
template<> struct format<unsigned long>   { static constexpr char const * spec = "%lu"; };
template <typename... Ts>
class cxpr_string
{
public:
    constexpr cxpr_string() : buf_{}, size_{0}  {
        size_t i=0;
        ( [&]() {
            const size_t max = size(format<Ts>::spec);
            for (int i=0; i < max; ++i) {
                buf_[size_++] = format<Ts>::spec[i];
            }
        }(), ...);
        buf_[size_++] = 0;
    }
    static constexpr size_t size(const char* s)
    {
        size_t i=0;
        for (; *s != 0; ++s) ++i;
        return i;
    }
    template <typename... Is>
    static constexpr size_t calc_size() {
        return (0 + ... + size(format<Is>::spec));
    }
    constexpr const char* get() const {
        return buf_;
    }
    static constexpr cxpr_string<Ts...> ref{};
    static constexpr const char* value = ref.get();
private:
    char buf_[calc_size<Ts...>()+1] = { 0 };
    size_t size_;
};
template <typename... Ts>
auto cpp_vsnprintf(char* s, size_t n, Ts... arg)
{
    return snprintf(s, n, cxpr_string<Ts...>::value, arg...);
}
int main()
{
    char buf[100];
    cpp_vsnprintf(buf, 100, "my R", 2, 'D', 2, '=', 3.5);
    printf(buf);
}
Demo
Output:
my R2D2=3.50
You can see that format strings are neatly packed into the binary:
        .string "%s%d%c%d%c%.2f"
        .zero   1
        .quad   15