diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt
index c4ffd6ee3..256e3b742 100644
--- a/src/common/CMakeLists.txt
+++ b/src/common/CMakeLists.txt
@@ -21,6 +21,7 @@ add_library(common
   fifo_queue.h
   file_system.cpp
   file_system.h
+  gsvector.cpp
   gsvector.h
   gsvector_formatter.h
   gsvector_neon.h
diff --git a/src/common/common.vcxproj b/src/common/common.vcxproj
index 5f99db87d..a8ebe6090 100644
--- a/src/common/common.vcxproj
+++ b/src/common/common.vcxproj
@@ -56,6 +56,7 @@
     <ClCompile Include="error.cpp" />
     <ClCompile Include="fastjmp.cpp" />
     <ClCompile Include="file_system.cpp" />
+    <ClCompile Include="gsvector.cpp" />
     <ClCompile Include="layered_settings_interface.cpp" />
     <ClCompile Include="log.cpp" />
     <ClCompile Include="memmap.cpp" />
diff --git a/src/common/common.vcxproj.filters b/src/common/common.vcxproj.filters
index 474d06552..83084db04 100644
--- a/src/common/common.vcxproj.filters
+++ b/src/common/common.vcxproj.filters
@@ -78,6 +78,7 @@
     </ClCompile>
     <ClCompile Include="dynamic_library.cpp" />
     <ClCompile Include="binary_span_reader_writer.cpp" />
+    <ClCompile Include="gsvector.cpp" />
   </ItemGroup>
   <ItemGroup>
     <Natvis Include="bitfield.natvis" />
diff --git a/src/common/gsvector.cpp b/src/common/gsvector.cpp
new file mode 100644
index 000000000..e1660b510
--- /dev/null
+++ b/src/common/gsvector.cpp
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
+// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0)
+
+#include "gsvector.h"
+
+#include <cmath>
+
+GSMatrix2x2::GSMatrix2x2(float e00, float e01, float e10, float e11)
+{
+  E[0][0] = e00;
+  E[0][1] = e01;
+  E[1][0] = e10;
+  E[1][1] = e11;
+}
+
+GSMatrix2x2 GSMatrix2x2::operator*(const GSMatrix2x2& m) const
+{
+  GSMatrix2x2 ret;
+  ret.E[0][0] = E[0][0] * m.E[0][0] + E[0][1] * m.E[1][0];
+  ret.E[0][1] = E[0][0] * m.E[0][1] + E[0][1] * m.E[1][1];
+  ret.E[1][0] = E[1][0] * m.E[0][0] + E[1][1] * m.E[1][0];
+  ret.E[1][1] = E[1][0] * m.E[0][1] + E[1][1] * m.E[1][1];
+  return ret;
+}
+
+GSVector2 GSMatrix2x2::operator*(const GSVector2& v) const
+{
+  return GSVector2(row(0).dot(v), row(1).dot(v));
+}
+
+GSMatrix2x2 GSMatrix2x2::Identity()
+{
+  GSMatrix2x2 ret;
+  ret.E[0][0] = 1.0f;
+  ret.E[0][1] = 0.0f;
+  ret.E[1][0] = 0.0f;
+  ret.E[1][1] = 1.0f;
+  return ret;
+}
+
+GSMatrix2x2 GSMatrix2x2::Rotation(float angle_in_radians)
+{
+  const float sin_angle = std::sin(angle_in_radians);
+  const float cos_angle = std::cos(angle_in_radians);
+
+  GSMatrix2x2 ret;
+  ret.E[0][0] = cos_angle;
+  ret.E[0][1] = -sin_angle;
+  ret.E[1][0] = sin_angle;
+  ret.E[1][1] = cos_angle;
+  return ret;
+}
+
+GSVector2 GSMatrix2x2::row(size_t i) const
+{
+  return GSVector2::load(&E[i][0]);
+}
+
+GSVector2 GSMatrix2x2::col(size_t i) const
+{
+  return GSVector2(E[0][i], E[1][i]);
+}
+
+void GSMatrix2x2::store(void* m)
+{
+  std::memcpy(m, E, sizeof(E));
+}
diff --git a/src/common/gsvector.h b/src/common/gsvector.h
index f19e92e04..fdef42676 100644
--- a/src/common/gsvector.h
+++ b/src/common/gsvector.h
@@ -12,3 +12,24 @@
 #else
 #include "common/gsvector_nosimd.h"
 #endif
+
+class GSMatrix2x2
+{
+public:
+  GSMatrix2x2() = default;
+  GSMatrix2x2(float e00, float e01, float e10, float e11);
+
+  GSMatrix2x2 operator*(const GSMatrix2x2& m) const;
+
+  GSVector2 operator*(const GSVector2& v) const;
+
+  static GSMatrix2x2 Identity();
+  static GSMatrix2x2 Rotation(float angle_in_radians);
+
+  GSVector2 row(size_t i) const;
+  GSVector2 col(size_t i) const;
+
+  void store(void* m);
+
+  float E[2][2];
+};
diff --git a/src/common/gsvector_neon.h b/src/common/gsvector_neon.h
index b37fbe751..7ffe8c552 100644
--- a/src/common/gsvector_neon.h
+++ b/src/common/gsvector_neon.h
@@ -800,6 +800,8 @@ public:
     return vget_lane_s32(vreinterpret_s32_f32(v2s), i);
   }
 
+  ALWAYS_INLINE float dot(const GSVector2& v) const { return vaddv_f32(vmul_f32(v2s, v.v2s)); }
+
   ALWAYS_INLINE static GSVector2 zero() { return GSVector2(vdup_n_f32(0.0f)); }
 
   ALWAYS_INLINE static GSVector2 xffffffff() { return GSVector2(vreinterpret_f32_u32(vdup_n_u32(0xFFFFFFFFu))); }
diff --git a/src/common/gsvector_nosimd.h b/src/common/gsvector_nosimd.h
index 7e0b8765a..02d200b51 100644
--- a/src/common/gsvector_nosimd.h
+++ b/src/common/gsvector_nosimd.h
@@ -666,6 +666,8 @@ public:
     return I32[i];
   }
 
+  ALWAYS_INLINE float dot(const GSVector2& v) const { return (x * v.x + y * v.y); }
+
   ALWAYS_INLINE static constexpr GSVector2 zero() { return GSVector2::cxpr(0.0f, 0.0f); }
 
   ALWAYS_INLINE static constexpr GSVector2 xffffffff()
diff --git a/src/common/gsvector_sse.h b/src/common/gsvector_sse.h
index 99a48e705..a876b2acf 100644
--- a/src/common/gsvector_sse.h
+++ b/src/common/gsvector_sse.h
@@ -643,6 +643,8 @@ public:
     return _mm_extract_ps(m, i);
   }
 
+  ALWAYS_INLINE float dot(const GSVector2& v) const { return _mm_cvtss_f32(_mm_dp_ps(m, v.m, 0x31)); }
+
   ALWAYS_INLINE static GSVector2 zero() { return GSVector2(_mm_setzero_ps()); }
 
   ALWAYS_INLINE static GSVector2 xffffffff() { return zero() == zero(); }
diff --git a/src/core/fullscreen_ui.cpp b/src/core/fullscreen_ui.cpp
index b18f08aef..3cfc2abe0 100644
--- a/src/core/fullscreen_ui.cpp
+++ b/src/core/fullscreen_ui.cpp
@@ -4393,6 +4393,10 @@ void FullscreenUI::DrawDisplaySettingsPage()
                   &Settings::GetDisplayAlignmentName, &Settings::GetDisplayAlignmentDisplayName,
                   DisplayAlignment::Count);
 
+  DrawEnumSetting(bsi, FSUI_CSTR("Screen Rotation"), FSUI_CSTR("Determines the rotation of the simulated TV screen."),
+                  "Display", "Rotation", Settings::DEFAULT_DISPLAY_ROTATION, &Settings::ParseDisplayRotation,
+                  &Settings::GetDisplayRotationName, &Settings::GetDisplayRotationDisplayName, DisplayRotation::Count);
+
   if (is_hardware)
   {
     DrawEnumSetting(bsi, FSUI_CSTR("Line Detection"),
@@ -7382,6 +7386,7 @@ TRANSLATE_NOOP("FullscreenUI", "Determines the amount of audio buffered before b
 TRANSLATE_NOOP("FullscreenUI", "Determines the emulated hardware type.");
 TRANSLATE_NOOP("FullscreenUI", "Determines the format that screenshots will be saved/compressed with.");
 TRANSLATE_NOOP("FullscreenUI", "Determines the position on the screen when black borders must be added.");
+TRANSLATE_NOOP("FullscreenUI", "Determines the rotation of the simulated TV screen.");
 TRANSLATE_NOOP("FullscreenUI", "Determines the size of screenshots created by DuckStation.");
 TRANSLATE_NOOP("FullscreenUI", "Determines whether a prompt will be displayed to confirm shutting down the emulator/game when the hotkey is pressed.");
 TRANSLATE_NOOP("FullscreenUI", "Determines which algorithm is used to convert interlaced frames to progressive for display on your system.");
@@ -7690,6 +7695,7 @@ TRANSLATE_NOOP("FullscreenUI", "Scaling");
 TRANSLATE_NOOP("FullscreenUI", "Scan For New Games");
 TRANSLATE_NOOP("FullscreenUI", "Scanning Subdirectories");
 TRANSLATE_NOOP("FullscreenUI", "Screen Position");
+TRANSLATE_NOOP("FullscreenUI", "Screen Rotation");
 TRANSLATE_NOOP("FullscreenUI", "Screenshot Format");
 TRANSLATE_NOOP("FullscreenUI", "Screenshot Quality");
 TRANSLATE_NOOP("FullscreenUI", "Screenshot Size");
diff --git a/src/core/gpu.cpp b/src/core/gpu.cpp
index 6b680e8ed..f06736946 100644
--- a/src/core/gpu.cpp
+++ b/src/core/gpu.cpp
@@ -32,6 +32,7 @@
 #include "fmt/format.h"
 
 #include <cmath>
+#include <numbers>
 #include <thread>
 
 Log_SetChannel(GPU);
@@ -138,7 +139,8 @@ void GPU::UpdateSettings(const Settings& old_settings)
 
     if (!CompileDisplayPipelines(g_settings.display_scaling != old_settings.display_scaling,
                                  g_settings.display_deinterlacing_mode != old_settings.display_deinterlacing_mode,
-                                 g_settings.display_24bit_chroma_smoothing != old_settings.display_24bit_chroma_smoothing))
+                                 g_settings.display_24bit_chroma_smoothing !=
+                                   old_settings.display_24bit_chroma_smoothing))
     {
       Panic("Failed to compile display pipeline on settings change.");
     }
@@ -1094,7 +1096,8 @@ void GPU::UpdateCommandTickEvent()
 void GPU::ConvertScreenCoordinatesToDisplayCoordinates(float window_x, float window_y, float* display_x,
                                                        float* display_y) const
 {
-  const GSVector4i draw_rc = CalculateDrawRect(g_gpu_device->GetWindowWidth(), g_gpu_device->GetWindowHeight(), true);
+  const GSVector4i draw_rc =
+    CalculateDrawRect(g_gpu_device->GetWindowWidth(), g_gpu_device->GetWindowHeight(), true, true);
 
   // convert coordinates to active display region, then to full display region
   const float scaled_display_x = (window_x - static_cast<float>(draw_rc.left)) / static_cast<float>(draw_rc.width());
@@ -1104,6 +1107,8 @@ void GPU::ConvertScreenCoordinatesToDisplayCoordinates(float window_x, float win
   *display_x = scaled_display_x * static_cast<float>(m_crtc_state.display_width);
   *display_y = scaled_display_y * static_cast<float>(m_crtc_state.display_height);
 
+  // TODO: apply rotation matrix
+
   DEV_LOG("win {:.0f},{:.0f} -> local {:.0f},{:.0f}, disp {:.2f},{:.2f} (size {},{} frac {},{})", window_x, window_y,
           window_x - draw_rc.left, window_y - draw_rc.top, *display_x, *display_y, m_crtc_state.display_width,
           m_crtc_state.display_height, *display_x / static_cast<float>(m_crtc_state.display_width),
@@ -1936,7 +1941,8 @@ bool GPU::PresentDisplay()
 {
   FlushRender();
 
-  const GSVector4i draw_rect = CalculateDrawRect(g_gpu_device->GetWindowWidth(), g_gpu_device->GetWindowHeight());
+  const GSVector4i draw_rect = CalculateDrawRect(g_gpu_device->GetWindowWidth(), g_gpu_device->GetWindowHeight(),
+                                                 !g_settings.debugging.show_vram, true);
   return RenderDisplay(nullptr, draw_rect, !g_settings.debugging.show_vram);
 }
 
@@ -2007,6 +2013,7 @@ bool GPU::RenderDisplay(GPUTexture* target, const GSVector4i draw_rect, bool pos
       float src_size[4];
       float clamp_rect[4];
       float params[4];
+      float rotation_matrix[2][2];
     } uniforms;
     std::memset(uniforms.params, 0, sizeof(uniforms.params));
 
@@ -2060,6 +2067,23 @@ bool GPU::RenderDisplay(GPUTexture* target, const GSVector4i draw_rect, bool pos
     uniforms.src_size[1] = static_cast<float>(display_texture->GetHeight());
     uniforms.src_size[2] = rcp_width;
     uniforms.src_size[3] = rcp_height;
+
+    if (g_settings.display_rotation != DisplayRotation::Normal)
+    {
+      static constexpr const std::array<float, static_cast<size_t>(DisplayRotation::Count) - 1> rotation_radians = {{
+        static_cast<float>(std::numbers::pi * 1.5f), // Rotate90
+        static_cast<float>(std::numbers::pi),        // Rotate180
+        static_cast<float>(std::numbers::pi / 2.0),  // Rotate270
+      }};
+
+      GSMatrix2x2::Rotation(rotation_radians[static_cast<size_t>(g_settings.display_rotation) - 1])
+        .store(uniforms.rotation_matrix);
+    }
+    else
+    {
+      GSMatrix2x2::Identity().store(uniforms.rotation_matrix);
+    }
+
     g_gpu_device->PushUniformBuffer(&uniforms, sizeof(uniforms));
 
     g_gpu_device->SetViewportAndScissor(real_draw_rect);
@@ -2315,7 +2339,8 @@ bool GPU::ApplyChromaSmoothing()
   return true;
 }
 
-GSVector4i GPU::CalculateDrawRect(s32 window_width, s32 window_height, bool apply_aspect_ratio /* = true */) const
+GSVector4i GPU::CalculateDrawRect(s32 window_width, s32 window_height, bool apply_rotation,
+                                  bool apply_aspect_ratio) const
 {
   const bool integer_scale = (g_settings.display_scaling == DisplayScalingMode::NearestInteger ||
                               g_settings.display_scaling == DisplayScalingMode::BlinearInteger);
@@ -2347,6 +2372,15 @@ GSVector4i GPU::CalculateDrawRect(s32 window_width, s32 window_height, bool appl
     active_height /= x_scale;
   }
 
+  // swap width/height when rotated, the flipping of padding is taken care of in the shader with the rotation matrix
+  if (g_settings.display_rotation == DisplayRotation::Rotate90 ||
+      g_settings.display_rotation == DisplayRotation::Rotate270)
+  {
+    std::swap(display_width, display_height);
+    std::swap(active_width, active_height);
+    std::swap(active_top, active_left);
+  }
+
   // now fit it within the window
   float scale;
   float left_padding, top_padding;
@@ -2640,7 +2674,7 @@ bool GPU::RenderScreenshotToFile(std::string filename, DisplayScreenshotMode mod
 {
   u32 width = g_gpu_device->GetWindowWidth();
   u32 height = g_gpu_device->GetWindowHeight();
-  GSVector4i draw_rect = CalculateDrawRect(width, height, true);
+  GSVector4i draw_rect = CalculateDrawRect(width, height, true, !g_settings.debugging.show_vram);
 
   const bool internal_resolution = (mode != DisplayScreenshotMode::ScreenResolution || g_settings.debugging.show_vram);
   if (internal_resolution && m_display_texture_view_width != 0 && m_display_texture_view_height != 0)
diff --git a/src/core/gpu.h b/src/core/gpu.h
index 42ec451c1..d1398d3cd 100644
--- a/src/core/gpu.h
+++ b/src/core/gpu.h
@@ -207,7 +207,7 @@ public:
   virtual void FlushRender() = 0;
 
   /// Helper function for computing the draw rectangle in a larger window.
-  GSVector4i CalculateDrawRect(s32 window_width, s32 window_height, bool apply_aspect_ratio = true) const;
+  GSVector4i CalculateDrawRect(s32 window_width, s32 window_height, bool apply_rotation, bool apply_aspect_ratio) const;
 
   /// Helper function to save current display texture to PNG.
   bool WriteDisplayTextureToFile(std::string filename, bool compress_on_thread = false);
diff --git a/src/core/gpu_shadergen.cpp b/src/core/gpu_shadergen.cpp
index 40400c63c..1be4e495a 100644
--- a/src/core/gpu_shadergen.cpp
+++ b/src/core/gpu_shadergen.cpp
@@ -12,7 +12,11 @@ GPUShaderGen::~GPUShaderGen() = default;
 
 void GPUShaderGen::WriteDisplayUniformBuffer(std::stringstream& ss)
 {
-  DeclareUniformBuffer(ss, {"float4 u_src_rect", "float4 u_src_size", "float4 u_clamp_rect", "float4 u_params"}, true);
+  // Rotation matrix split into rows to avoid padding in HLSL.
+  DeclareUniformBuffer(ss,
+                       {"float4 u_src_rect", "float4 u_src_size", "float4 u_clamp_rect", "float4 u_params",
+                        "float2 u_rotation_matrix0", "float2 u_rotation_matrix1"},
+                       true);
 
   ss << R"(
 float2 ClampUV(float2 uv) {
@@ -31,6 +35,10 @@ std::string GPUShaderGen::GenerateDisplayVertexShader()
   float2 pos = float2(float((v_id << 1) & 2u), float(v_id & 2u));
   v_tex0 = u_src_rect.xy + pos * u_src_rect.zw;
   v_pos = float4(pos * float2(2.0f, -2.0f) + float2(-1.0f, 1.0f), 0.0f, 1.0f);
+
+  // Avoid HLSL/GLSL constructor differences by explicitly multiplying the matrix.
+  v_pos.xy = float2(dot(u_rotation_matrix0, v_pos.xy), dot(u_rotation_matrix1, v_pos.xy));
+
   #if API_VULKAN
     v_pos.y = -v_pos.y;
   #endif
diff --git a/src/core/hotkeys.cpp b/src/core/hotkeys.cpp
index 910a76937..68d3c7d56 100644
--- a/src/core/hotkeys.cpp
+++ b/src/core/hotkeys.cpp
@@ -409,7 +409,7 @@ DEFINE_HOTKEY("TogglePostProcessing", TRANSLATE_NOOP("Hotkeys", "Graphics"),
                   PostProcessing::DisplayChain.Toggle();
               })
 
-              DEFINE_HOTKEY("ToggleInternalPostProcessing", TRANSLATE_NOOP("Hotkeys", "Graphics"),
+DEFINE_HOTKEY("ToggleInternalPostProcessing", TRANSLATE_NOOP("Hotkeys", "Graphics"),
               TRANSLATE_NOOP("Hotkeys", "Toggle Internal Post-Processing"), [](s32 pressed) {
                 if (!pressed && System::IsValid())
                   PostProcessing::InternalChain.Toggle();
@@ -494,6 +494,27 @@ DEFINE_HOTKEY("ToggleOSD", TRANSLATE_NOOP("Hotkeys", "Graphics"), TRANSLATE_NOOP
                   HotkeyToggleOSD();
               })
 
+DEFINE_HOTKEY("RotateClockwise", TRANSLATE_NOOP("Hotkeys", "Graphics"),
+              TRANSLATE_NOOP("Hotkeys", "Rotate Display Clockwise"), [](s32 pressed) {
+                if (!pressed)
+                {
+                  g_settings.display_rotation = static_cast<DisplayRotation>(
+                    (static_cast<u8>(g_settings.display_rotation) + 1) % static_cast<u8>(DisplayRotation::Count));
+                }
+              })
+
+DEFINE_HOTKEY("RotateCounterclockwise", TRANSLATE_NOOP("Hotkeys", "Graphics"),
+              TRANSLATE_NOOP("Hotkeys", "Rotate Display Counterclockwise"), [](s32 pressed) {
+                if (!pressed)
+                {
+                  g_settings.display_rotation =
+                    (g_settings.display_rotation > static_cast<DisplayRotation>(0)) ?
+                      static_cast<DisplayRotation>((static_cast<u8>(g_settings.display_rotation) - 1) %
+                                                   static_cast<u8>(DisplayRotation::Count)) :
+                      static_cast<DisplayRotation>(static_cast<u8>(DisplayRotation::Count) - 1);
+                }
+              })
+
 DEFINE_HOTKEY("AudioMute", TRANSLATE_NOOP("Hotkeys", "Audio"), TRANSLATE_NOOP("Hotkeys", "Toggle Mute"),
               [](s32 pressed) {
                 if (!pressed && System::IsValid())
diff --git a/src/core/settings.cpp b/src/core/settings.cpp
index e74018644..85ca2c411 100644
--- a/src/core/settings.cpp
+++ b/src/core/settings.cpp
@@ -259,6 +259,10 @@ void Settings::Load(SettingsInterface& si)
     ParseDisplayAlignment(
       si.GetStringValue("Display", "Alignment", GetDisplayAlignmentName(DEFAULT_DISPLAY_ALIGNMENT)).c_str())
       .value_or(DEFAULT_DISPLAY_ALIGNMENT);
+  display_rotation =
+    ParseDisplayRotation(
+      si.GetStringValue("Display", "Rotation", GetDisplayRotationName(DEFAULT_DISPLAY_ROTATION)).c_str())
+      .value_or(DEFAULT_DISPLAY_ROTATION);
   display_scaling =
     ParseDisplayScaling(si.GetStringValue("Display", "Scaling", GetDisplayScalingName(DEFAULT_DISPLAY_SCALING)).c_str())
       .value_or(DEFAULT_DISPLAY_SCALING);
@@ -541,6 +545,7 @@ void Settings::Save(SettingsInterface& si, bool ignore_base) const
   si.SetBoolValue("Display", "Force4_3For24Bit", display_force_4_3_for_24bit);
   si.SetStringValue("Display", "AspectRatio", GetDisplayAspectRatioName(display_aspect_ratio));
   si.SetStringValue("Display", "Alignment", GetDisplayAlignmentName(display_alignment));
+  si.SetStringValue("Display", "Rotation", GetDisplayRotationName(display_rotation));
   si.SetStringValue("Display", "Scaling", GetDisplayScalingName(display_scaling));
   si.SetBoolValue("Display", "OptimalFramePacing", display_optimal_frame_pacing);
   si.SetBoolValue("Display", "PreFrameSleep", display_pre_frame_sleep);
@@ -1456,6 +1461,38 @@ const char* Settings::GetDisplayAlignmentDisplayName(DisplayAlignment alignment)
   return Host::TranslateToCString("DisplayAlignment", s_display_alignment_display_names[static_cast<int>(alignment)]);
 }
 
+static constexpr const std::array s_display_rotation_names = {"Normal", "Rotate90", "Rotate180", "Rotate270"};
+static constexpr const std::array s_display_rotation_display_names = {
+  TRANSLATE_NOOP("Settings", "No Rotation"),
+  TRANSLATE_NOOP("Settings", "Rotate 90° (Clockwise)"),
+  TRANSLATE_NOOP("Settings", "Rotate 180° (Vertical Flip)"),
+  TRANSLATE_NOOP("Settings", "Rotate 270° (Clockwise)"),
+};
+
+std::optional<DisplayRotation> Settings::ParseDisplayRotation(const char* str)
+{
+  int index = 0;
+  for (const char* name : s_display_rotation_names)
+  {
+    if (StringUtil::Strcasecmp(name, str) == 0)
+      return static_cast<DisplayRotation>(index);
+
+    index++;
+  }
+
+  return std::nullopt;
+}
+
+const char* Settings::GetDisplayRotationName(DisplayRotation rotation)
+{
+  return s_display_rotation_names[static_cast<int>(rotation)];
+}
+
+const char* Settings::GetDisplayRotationDisplayName(DisplayRotation rotation)
+{
+  return Host::TranslateToCString("Settings", s_display_rotation_display_names[static_cast<size_t>(rotation)]);
+}
+
 static constexpr const std::array s_display_scaling_names = {
   "Nearest", "NearestInteger", "BilinearSmooth", "BilinearSharp", "BilinearInteger",
 };
diff --git a/src/core/settings.h b/src/core/settings.h
index 821535949..c939549c8 100644
--- a/src/core/settings.h
+++ b/src/core/settings.h
@@ -143,6 +143,7 @@ struct Settings
   DisplayCropMode display_crop_mode = DEFAULT_DISPLAY_CROP_MODE;
   DisplayAspectRatio display_aspect_ratio = DEFAULT_DISPLAY_ASPECT_RATIO;
   DisplayAlignment display_alignment = DEFAULT_DISPLAY_ALIGNMENT;
+  DisplayRotation display_rotation = DEFAULT_DISPLAY_ROTATION;
   DisplayScalingMode display_scaling = DEFAULT_DISPLAY_SCALING;
   DisplayExclusiveFullscreenControl display_exclusive_fullscreen_control = DEFAULT_DISPLAY_EXCLUSIVE_FULLSCREEN_CONTROL;
   DisplayScreenshotMode display_screenshot_mode = DEFAULT_DISPLAY_SCREENSHOT_MODE;
@@ -424,6 +425,10 @@ struct Settings
   static const char* GetDisplayAlignmentName(DisplayAlignment alignment);
   static const char* GetDisplayAlignmentDisplayName(DisplayAlignment alignment);
 
+  static std::optional<DisplayRotation> ParseDisplayRotation(const char* str);
+  static const char* GetDisplayRotationName(DisplayRotation alignment);
+  static const char* GetDisplayRotationDisplayName(DisplayRotation alignment);
+
   static std::optional<DisplayScalingMode> ParseDisplayScaling(const char* str);
   static const char* GetDisplayScalingName(DisplayScalingMode mode);
   static const char* GetDisplayScalingDisplayName(DisplayScalingMode mode);
@@ -480,6 +485,7 @@ struct Settings
   static constexpr DisplayCropMode DEFAULT_DISPLAY_CROP_MODE = DisplayCropMode::Overscan;
   static constexpr DisplayAspectRatio DEFAULT_DISPLAY_ASPECT_RATIO = DisplayAspectRatio::Auto;
   static constexpr DisplayAlignment DEFAULT_DISPLAY_ALIGNMENT = DisplayAlignment::Center;
+  static constexpr DisplayRotation DEFAULT_DISPLAY_ROTATION = DisplayRotation::Normal;
   static constexpr DisplayScalingMode DEFAULT_DISPLAY_SCALING = DisplayScalingMode::BilinearSmooth;
   static constexpr DisplayExclusiveFullscreenControl DEFAULT_DISPLAY_EXCLUSIVE_FULLSCREEN_CONTROL =
     DisplayExclusiveFullscreenControl::Automatic;
diff --git a/src/core/system.cpp b/src/core/system.cpp
index 33a0b5281..604d343bc 100644
--- a/src/core/system.cpp
+++ b/src/core/system.cpp
@@ -4065,7 +4065,6 @@ void System::CheckForSettingsChanges(const Settings& old_settings)
         g_settings.display_24bit_chroma_smoothing != old_settings.display_24bit_chroma_smoothing ||
         g_settings.display_crop_mode != old_settings.display_crop_mode ||
         g_settings.display_aspect_ratio != old_settings.display_aspect_ratio ||
-        g_settings.display_alignment != old_settings.display_alignment ||
         g_settings.display_scaling != old_settings.display_scaling ||
         g_settings.display_show_gpu_usage != old_settings.display_show_gpu_usage ||
         g_settings.gpu_pgxp_enable != old_settings.gpu_pgxp_enable ||
@@ -5304,11 +5303,14 @@ void System::RequestDisplaySize(float scale /*= 0.0f*/)
     (static_cast<float>(g_gpu->GetCRTCDisplayWidth()) / static_cast<float>(g_gpu->GetCRTCDisplayHeight())) /
     g_gpu->ComputeDisplayAspectRatio();
 
-  const u32 requested_width =
+  u32 requested_width =
     std::max<u32>(static_cast<u32>(std::ceil(static_cast<float>(g_gpu->GetCRTCDisplayWidth()) * scale)), 1);
-  const u32 requested_height =
+  u32 requested_height =
     std::max<u32>(static_cast<u32>(std::ceil(static_cast<float>(g_gpu->GetCRTCDisplayHeight()) * y_scale * scale)), 1);
 
+  if (g_settings.display_rotation == DisplayRotation::Rotate90 || g_settings.display_rotation == DisplayRotation::Rotate180)
+    std::swap(requested_width, requested_height);
+
   Host::RequestResizeHostDisplay(static_cast<s32>(requested_width), static_cast<s32>(requested_height));
 }
 
diff --git a/src/core/types.h b/src/core/types.h
index 108db954f..cba718e3a 100644
--- a/src/core/types.h
+++ b/src/core/types.h
@@ -152,6 +152,15 @@ enum class DisplayAlignment : u8
   Count
 };
 
+enum class DisplayRotation : u8
+{
+  Normal,
+  Rotate90,
+  Rotate180,
+  Rotate270,
+  Count
+};
+
 enum class DisplayScalingMode : u8
 {
   Nearest,
diff --git a/src/duckstation-qt/graphicssettingswidget.cpp b/src/duckstation-qt/graphicssettingswidget.cpp
index 155cd3f54..63523a0d3 100644
--- a/src/duckstation-qt/graphicssettingswidget.cpp
+++ b/src/duckstation-qt/graphicssettingswidget.cpp
@@ -116,6 +116,9 @@ GraphicsSettingsWidget::GraphicsSettingsWidget(SettingsWindow* dialog, QWidget*
   SettingWidgetBinder::BindWidgetToEnumSetting(sif, m_ui.displayAlignment, "Display", "Alignment",
                                                &Settings::ParseDisplayAlignment, &Settings::GetDisplayAlignmentName,
                                                Settings::DEFAULT_DISPLAY_ALIGNMENT);
+  SettingWidgetBinder::BindWidgetToEnumSetting(sif, m_ui.displayRotation, "Display", "Rotation",
+                                               &Settings::ParseDisplayRotation, &Settings::GetDisplayRotationName,
+                                               Settings::DEFAULT_DISPLAY_ROTATION);
   SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.gpuThread, "GPU", "UseThread", true);
   SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.threadedPresentation, "GPU", "ThreadedPresentation",
                                                Settings::DEFAULT_THREADED_PRESENTATION);
@@ -596,6 +599,12 @@ void GraphicsSettingsWidget::setupAdditionalUi()
       QString::fromUtf8(Settings::GetDisplayAlignmentDisplayName(static_cast<DisplayAlignment>(i))));
   }
 
+  for (u32 i = 0; i < static_cast<u32>(DisplayRotation::Count); i++)
+  {
+    m_ui.displayRotation->addItem(
+      QString::fromUtf8(Settings::GetDisplayRotationDisplayName(static_cast<DisplayRotation>(i))));
+  }
+
   for (u32 i = 0; i < static_cast<u32>(GPULineDetectMode::Count); i++)
   {
     m_ui.gpuLineDetectMode->addItem(
diff --git a/src/duckstation-qt/graphicssettingswidget.ui b/src/duckstation-qt/graphicssettingswidget.ui
index 06ca84fd5..d06e288d7 100644
--- a/src/duckstation-qt/graphicssettingswidget.ui
+++ b/src/duckstation-qt/graphicssettingswidget.ui
@@ -320,7 +320,7 @@
            </widget>
           </item>
           <item row="0" column="1">
-           <layout class="QHBoxLayout" name="horizontalLayout_8" stretch="1,0">
+           <layout class="QHBoxLayout" name="horizontalLayout_8">
             <item>
              <widget class="QComboBox" name="fullscreenMode"/>
             </item>
@@ -336,9 +336,6 @@
             </property>
            </widget>
           </item>
-          <item row="1" column="1">
-           <widget class="QComboBox" name="displayAlignment"/>
-          </item>
           <item row="2" column="0" colspan="2">
            <layout class="QGridLayout" name="advancedDisplayOptionsLayout">
             <item row="0" column="0">
@@ -371,6 +368,16 @@
             </item>
            </layout>
           </item>
+          <item row="1" column="1">
+           <layout class="QHBoxLayout" name="horizontalLayout">
+            <item>
+             <widget class="QComboBox" name="displayAlignment"/>
+            </item>
+            <item>
+             <widget class="QComboBox" name="displayRotation"/>
+            </item>
+           </layout>
+          </item>
          </layout>
         </widget>
        </item>
diff --git a/src/duckstation-qt/qttranslations.cpp b/src/duckstation-qt/qttranslations.cpp
index 676ead8c8..262c36403 100644
--- a/src/duckstation-qt/qttranslations.cpp
+++ b/src/duckstation-qt/qttranslations.cpp
@@ -220,6 +220,7 @@ const char* QtHost::GetDefaultLanguage()
 
 static constexpr const ImWchar s_base_latin_range[] = {
   0x0020, 0x00FF, // Basic Latin + Latin Supplement
+  0x00B0, 0x00B0, // Degree sign
   0x2022, 0x2022, // General punctuation
 };
 static constexpr const ImWchar s_central_european_ranges[] = {