diff --git a/src/core/host_display.cpp b/src/core/host_display.cpp
index 6345dabdb..dd9aba03b 100644
--- a/src/core/host_display.cpp
+++ b/src/core/host_display.cpp
@@ -1,4 +1,12 @@
 #include "host_display.h"
+#include "common/log.h"
+#include "common/string_util.h"
+#include "stb_image_resize.h"
+#include "stb_image_write.h"
+#include <cmath>
+#include <cstring>
+#include <vector>
+Log_SetChannel(HostDisplay);
 
 HostDisplayTexture::~HostDisplayTexture() = default;
 
@@ -46,3 +54,154 @@ std::tuple<s32, s32, s32, s32> HostDisplay::CalculateDrawRect() const
   top += m_display_top_margin;
   return std::tie(left, top, width, height);
 }
+
+bool HostDisplay::WriteTextureToFile(const void* texture_handle, u32 x, u32 y, u32 width, u32 height,
+                                     const char* filename, bool clear_alpha /* = true */, bool flip_y /* = false */,
+                                     u32 resize_width /* = 0 */, u32 resize_height /* = 0 */)
+{
+  std::vector<u32> texture_data(width * height);
+  u32 texture_data_stride = sizeof(u32) * width;
+  if (!DownloadTexture(texture_handle, x, y, width, height, texture_data.data(), texture_data_stride))
+  {
+    Log_ErrorPrintf("Texture download failed");
+    return false;
+  }
+
+  const char* extension = std::strrchr(filename, '.');
+  if (!extension)
+  {
+    Log_ErrorPrintf("Unable to determine file extension for '%s'", filename);
+    return false;
+  }
+
+  if (clear_alpha)
+  {
+    for (u32& pixel : texture_data)
+      pixel |= 0xFF000000;
+  }
+
+  if (flip_y)
+  {
+    std::vector<u32> temp(width);
+    for (u32 flip_row = 0; flip_row < (height / 2); flip_row++)
+    {
+      u32* top_ptr = &texture_data[flip_row * width];
+      u32* bottom_ptr = &texture_data[((height - 1) - flip_row) * width];
+      std::memcpy(temp.data(), top_ptr, texture_data_stride);
+      std::memcpy(top_ptr, bottom_ptr, texture_data_stride);
+      std::memcpy(bottom_ptr, temp.data(), texture_data_stride);
+    }
+  }
+
+  if (resize_width > 0 && resize_height > 0 && (resize_width != width || resize_height != height))
+  {
+    std::vector<u32> resized_texture_data(resize_width * resize_height);
+    u32 resized_texture_stride = sizeof(u32) * resize_width;
+    if (!stbir_resize_uint8(reinterpret_cast<u8*>(texture_data.data()), width, height, texture_data_stride,
+                            reinterpret_cast<u8*>(resized_texture_data.data()), resize_width, resize_height,
+                            resized_texture_stride, 4))
+    {
+      Log_ErrorPrintf("Failed to resize texture data from %ux%u to %ux%u", width, height, resize_width, resize_height);
+      return false;
+    }
+
+    width = resize_width;
+    height = resize_height;
+    texture_data = std::move(resized_texture_data);
+    texture_data_stride = resized_texture_stride;
+  }
+
+  bool result;
+  if (StringUtil::Strcasecmp(extension, ".png") == 0)
+  {
+    result = (stbi_write_png(filename, width, height, 4, texture_data.data(), texture_data_stride) != 0);
+  }
+  else if (StringUtil::Strcasecmp(filename, ".jpg") == 0)
+  {
+    result = (stbi_write_jpg(filename, width, height, 4, texture_data.data(), 95) != 0);
+  }
+  else if (StringUtil::Strcasecmp(filename, ".tga") == 0)
+  {
+    result = (stbi_write_tga(filename, width, height, 4, texture_data.data()) != 0);
+  }
+  else if (StringUtil::Strcasecmp(filename, ".bmp") == 0)
+  {
+    result = (stbi_write_bmp(filename, width, height, 4, texture_data.data()) != 0);
+  }
+  else
+  {
+    Log_ErrorPrintf("Unknown extension in filename '%s': '%s'", filename, extension);
+    return false;
+  }
+
+  if (!result)
+  {
+    Log_ErrorPrintf("Failed to save texture to '%s'", filename);
+    return false;
+  }
+
+  return true;
+}
+
+bool HostDisplay::WriteDisplayTextureToFile(const char* filename, bool full_resolution /* = true */,
+                                            bool apply_aspect_ratio /* = true */)
+{
+  if (!m_display_texture_handle)
+    return false;
+
+  s32 resize_width = 0;
+  s32 resize_height = 0;
+  if (apply_aspect_ratio && full_resolution)
+  {
+    if (m_display_aspect_ratio > 1.0f)
+    {
+      resize_width = m_display_texture_view_width;
+      resize_height = static_cast<s32>(static_cast<float>(resize_width) / m_display_aspect_ratio);
+    }
+    else
+    {
+      resize_height = m_display_texture_view_height;
+      resize_width = static_cast<s32>(static_cast<float>(resize_height) * m_display_aspect_ratio);
+    }
+  }
+  else if (apply_aspect_ratio)
+  {
+    const auto [left, top, right, bottom] = CalculateDrawRect();
+    resize_width = right - left;
+    resize_height = bottom - top;
+  }
+  else if (!full_resolution)
+  {
+    const auto [left, top, right, bottom] = CalculateDrawRect();
+    const float ratio =
+      static_cast<float>(m_display_texture_view_width) / static_cast<float>(m_display_texture_view_height);
+    if (ratio > 1.0f)
+    {
+      resize_width = right - left;
+      resize_height = static_cast<s32>(static_cast<float>(resize_width) / ratio);
+    }
+    else
+    {
+      resize_height = bottom - top;
+      resize_width = static_cast<s32>(static_cast<float>(resize_height) * ratio);
+    }
+  }
+
+  if (resize_width < 0)
+    resize_width = 1;
+  if (resize_height < 0)
+    resize_height = 1;
+
+  const bool flip_y = (m_display_texture_view_height < 0);
+  s32 read_height = m_display_texture_view_height;
+  s32 read_y = m_display_texture_view_y;
+  if (flip_y)
+  {
+    read_height = -m_display_texture_view_height;
+    read_y = (m_display_texture_height - read_height) - (m_display_texture_height - m_display_texture_view_y);
+  }
+
+  return WriteTextureToFile(m_display_texture_handle, m_display_texture_view_x, read_y, m_display_texture_view_width,
+                            read_height, filename, true, flip_y, static_cast<u32>(resize_width),
+                            static_cast<u32>(resize_height));
+}
diff --git a/src/core/host_display.h b/src/core/host_display.h
index 086909d01..19ad7e573 100644
--- a/src/core/host_display.h
+++ b/src/core/host_display.h
@@ -46,6 +46,9 @@ public:
   virtual void UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y, u32 width, u32 height, const void* data,
                              u32 data_stride) = 0;
 
+  virtual bool DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                               u32 out_data_stride) = 0;
+
   virtual void Render() = 0;
 
   virtual void SetVSync(bool enabled) = 0;
@@ -90,9 +93,16 @@ public:
   void SetDisplayLinearFiltering(bool enabled) { m_display_linear_filtering = enabled; }
   void SetDisplayTopMargin(s32 height) { m_display_top_margin = height; }
 
-  // Helper function for computing the draw rectangle in a larger window.
+  /// Helper function for computing the draw rectangle in a larger window.
   std::tuple<s32, s32, s32, s32> CalculateDrawRect() const;
 
+  /// Helper function to save texture data to a PNG. If flip_y is set, the image will be flipped aka OpenGL.
+  bool WriteTextureToFile(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, const char* filename,
+                          bool clear_alpha = true, bool flip_y = false, u32 resize_width = 0, u32 resize_height = 0);
+
+  /// Helper function to save current display texture to PNG.
+  bool WriteDisplayTextureToFile(const char* filename, bool full_resolution = true, bool apply_aspect_ratio = true);
+
 protected:
   s32 m_window_width = 0;
   s32 m_window_height = 0;
diff --git a/src/duckstation-qt/d3d11displaywidget.cpp b/src/duckstation-qt/d3d11displaywidget.cpp
index 68667cffb..18ebb4922 100644
--- a/src/duckstation-qt/d3d11displaywidget.cpp
+++ b/src/duckstation-qt/d3d11displaywidget.cpp
@@ -141,6 +141,24 @@ void D3D11DisplayWidget::UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y
   }
 }
 
+bool D3D11DisplayWidget::DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height,
+                                         void* out_data, u32 out_data_stride)
+{
+  ID3D11ShaderResourceView* srv =
+    const_cast<ID3D11ShaderResourceView*>(static_cast<const ID3D11ShaderResourceView*>(texture_handle));
+  ID3D11Resource* srv_resource;
+  D3D11_SHADER_RESOURCE_VIEW_DESC srv_desc;
+  srv->GetResource(&srv_resource);
+  srv->GetDesc(&srv_desc);
+
+  if (!m_readback_staging_texture.EnsureSize(m_context.Get(), width, height, srv_desc.Format, false))
+    return false;
+
+  m_readback_staging_texture.CopyFromTexture(m_context.Get(), srv_resource, 0, x, y, 0, 0, width, height);
+  return m_readback_staging_texture.ReadPixels<u32>(m_context.Get(), 0, 0, width, height, out_data_stride / sizeof(u32),
+                                                    static_cast<u32*>(out_data));
+}
+
 void D3D11DisplayWidget::SetVSync(bool enabled)
 {
   m_vsync = enabled;
diff --git a/src/duckstation-qt/d3d11displaywidget.h b/src/duckstation-qt/d3d11displaywidget.h
index 06b474b82..803a1c441 100644
--- a/src/duckstation-qt/d3d11displaywidget.h
+++ b/src/duckstation-qt/d3d11displaywidget.h
@@ -1,4 +1,5 @@
 #pragma once
+#include "common/d3d11/staging_texture.h"
 #include "common/d3d11/stream_buffer.h"
 #include "common/d3d11/texture.h"
 #include "common/windows_headers.h"
@@ -39,6 +40,8 @@ public:
                                                     u32 initial_data_stride, bool dynamic) override;
   void UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y, u32 width, u32 height, const void* texture_data,
                      u32 texture_data_stride) override;
+  bool DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                       u32 out_data_stride) override;
 
   void SetVSync(bool enabled) override;
 
@@ -74,6 +77,7 @@ private:
 
   D3D11::Texture m_display_pixels_texture;
   D3D11::StreamBuffer m_display_uniform_buffer;
+  D3D11::AutoStagingTexture m_readback_staging_texture;
 
   bool m_allow_tearing_supported = false;
   bool m_vsync = false;
diff --git a/src/duckstation-qt/opengldisplaywidget.cpp b/src/duckstation-qt/opengldisplaywidget.cpp
index 18b12770f..c27965906 100644
--- a/src/duckstation-qt/opengldisplaywidget.cpp
+++ b/src/duckstation-qt/opengldisplaywidget.cpp
@@ -164,6 +164,24 @@ void OpenGLDisplayWidget::UpdateTexture(HostDisplayTexture* texture, u32 x, u32
   glBindTexture(GL_TEXTURE_2D, old_texture_binding);
 }
 
+bool OpenGLDisplayWidget::DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height,
+                                          void* out_data, u32 out_data_stride)
+{
+  GLint old_alignment = 0, old_row_length = 0;
+  glGetIntegerv(GL_PACK_ALIGNMENT, &old_alignment);
+  glGetIntegerv(GL_PACK_ROW_LENGTH, &old_row_length);
+  glPixelStorei(GL_PACK_ALIGNMENT, sizeof(u32));
+  glPixelStorei(GL_PACK_ROW_LENGTH, out_data_stride / sizeof(u32));
+
+  const GLuint texture = static_cast<GLuint>(reinterpret_cast<uintptr_t>(texture_handle));
+  GL::Texture::GetTextureSubImage(texture, 0, x, y, 0, width, height, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                                  height * out_data_stride, out_data);
+
+  glPixelStorei(GL_PACK_ALIGNMENT, old_alignment);
+  glPixelStorei(GL_PACK_ROW_LENGTH, old_row_length);
+  return true;
+}
+
 void OpenGLDisplayWidget::SetVSync(bool enabled)
 {
   // Window framebuffer has to be bound to call SetSwapInterval.
diff --git a/src/duckstation-qt/opengldisplaywidget.h b/src/duckstation-qt/opengldisplaywidget.h
index dbee48124..a4820408a 100644
--- a/src/duckstation-qt/opengldisplaywidget.h
+++ b/src/duckstation-qt/opengldisplaywidget.h
@@ -44,6 +44,8 @@ public:
                                                     u32 initial_data_stride, bool dynamic) override;
   void UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y, u32 width, u32 height, const void* texture_data,
                      u32 texture_data_stride) override;
+  bool DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                       u32 out_data_stride) override;
 
   void SetVSync(bool enabled) override;
 
diff --git a/src/duckstation-sdl/d3d11_host_display.cpp b/src/duckstation-sdl/d3d11_host_display.cpp
index e12a7a8a0..a1765389d 100644
--- a/src/duckstation-sdl/d3d11_host_display.cpp
+++ b/src/duckstation-sdl/d3d11_host_display.cpp
@@ -168,6 +168,24 @@ void D3D11HostDisplay::UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y,
   }
 }
 
+bool D3D11HostDisplay::DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                                       u32 out_data_stride)
+{
+  ID3D11ShaderResourceView* srv =
+    const_cast<ID3D11ShaderResourceView*>(static_cast<const ID3D11ShaderResourceView*>(texture_handle));
+  ID3D11Resource* srv_resource;
+  D3D11_SHADER_RESOURCE_VIEW_DESC srv_desc;
+  srv->GetResource(&srv_resource);
+  srv->GetDesc(&srv_desc);
+
+  if (!m_readback_staging_texture.EnsureSize(m_context.Get(), width, height, srv_desc.Format, false))
+    return false;
+
+  m_readback_staging_texture.CopyFromTexture(m_context.Get(), srv_resource, 0, x, y, 0, 0, width, height);
+  return m_readback_staging_texture.ReadPixels<u32>(m_context.Get(), 0, 0, width, height, out_data_stride / sizeof(u32),
+                                                    static_cast<u32*>(out_data));
+}
+
 void D3D11HostDisplay::SetVSync(bool enabled)
 {
   m_vsync = enabled;
diff --git a/src/duckstation-sdl/d3d11_host_display.h b/src/duckstation-sdl/d3d11_host_display.h
index 70e47f6a5..820b19b87 100644
--- a/src/duckstation-sdl/d3d11_host_display.h
+++ b/src/duckstation-sdl/d3d11_host_display.h
@@ -1,5 +1,6 @@
 #pragma once
 #include "common/d3d11/stream_buffer.h"
+#include "common/d3d11/staging_texture.h"
 #include "common/d3d11/texture.h"
 #include "common/windows_headers.h"
 #include "core/host_display.h"
@@ -31,6 +32,8 @@ public:
                                                     bool dynamic) override;
   void UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y, u32 width, u32 height, const void* data,
                      u32 data_stride) override;
+  bool DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                       u32 out_data_stride) override;
 
   void SetVSync(bool enabled) override;
 
@@ -63,6 +66,7 @@ private:
 
   D3D11::Texture m_display_pixels_texture;
   D3D11::StreamBuffer m_display_uniform_buffer;
+  D3D11::AutoStagingTexture m_readback_staging_texture;
 
   bool m_allow_tearing_supported = false;
   bool m_vsync = true;
diff --git a/src/duckstation-sdl/opengl_host_display.cpp b/src/duckstation-sdl/opengl_host_display.cpp
index 563a55dd8..2d0f6534a 100644
--- a/src/duckstation-sdl/opengl_host_display.cpp
+++ b/src/duckstation-sdl/opengl_host_display.cpp
@@ -21,7 +21,7 @@ public:
   GLuint GetGLID() const { return m_id; }
 
   static std::unique_ptr<OpenGLDisplayWidgetTexture> Create(u32 width, u32 height, const void* initial_data,
-                                                          u32 initial_data_stride)
+                                                            u32 initial_data_stride)
   {
     GLuint id;
     glGenTextures(1, &id);
@@ -129,6 +129,24 @@ void OpenGLHostDisplay::UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y,
   glBindTexture(GL_TEXTURE_2D, old_texture_binding);
 }
 
+bool OpenGLHostDisplay::DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                                        u32 out_data_stride)
+{
+  GLint old_alignment = 0, old_row_length = 0;
+  glGetIntegerv(GL_PACK_ALIGNMENT, &old_alignment);
+  glGetIntegerv(GL_PACK_ROW_LENGTH, &old_row_length);
+  glPixelStorei(GL_PACK_ALIGNMENT, sizeof(u32));
+  glPixelStorei(GL_PACK_ROW_LENGTH, out_data_stride / sizeof(u32));
+
+  const GLuint texture = static_cast<GLuint>(reinterpret_cast<uintptr_t>(texture_handle));
+  GL::Texture::GetTextureSubImage(texture, 0, x, y, 0, width, height, 1, GL_RGBA, GL_UNSIGNED_BYTE,
+                                  height * out_data_stride, out_data);
+
+  glPixelStorei(GL_PACK_ALIGNMENT, old_alignment);
+  glPixelStorei(GL_PACK_ROW_LENGTH, old_row_length);
+  return true;
+}
+
 void OpenGLHostDisplay::SetVSync(bool enabled)
 {
   // Window framebuffer has to be bound to call SetSwapInterval.
diff --git a/src/duckstation-sdl/opengl_host_display.h b/src/duckstation-sdl/opengl_host_display.h
index e54093f35..a10b6d170 100644
--- a/src/duckstation-sdl/opengl_host_display.h
+++ b/src/duckstation-sdl/opengl_host_display.h
@@ -3,8 +3,8 @@
 #include "common/gl/texture.h"
 #include "core/host_display.h"
 #include <SDL.h>
-#include <string>
 #include <memory>
+#include <string>
 
 class OpenGLHostDisplay final : public HostDisplay
 {
@@ -26,6 +26,8 @@ public:
                                                     bool dynamic) override;
   void UpdateTexture(HostDisplayTexture* texture, u32 x, u32 y, u32 width, u32 height, const void* data,
                      u32 data_stride) override;
+  bool DownloadTexture(const void* texture_handle, u32 x, u32 y, u32 width, u32 height, void* out_data,
+                       u32 out_data_stride) override;
 
   void SetVSync(bool enabled) override;