diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c64b831bc..a427f1e7c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -143,6 +143,7 @@
     Feature #6128: Soft Particles
     Feature #6161: Refactor Sky to use shaders and GLES/GL3 friendly
     Feature #6162: Refactor GUI to use shaders and to be GLES and GL3+ friendly
+    Feature #6171: In-game log viewer
     Feature #6189: Navigation mesh disk cache
     Feature #6199: Support FBO Rendering
     Feature #6248: Embedded error marker mesh
diff --git a/apps/openmw/mwgui/debugwindow.cpp b/apps/openmw/mwgui/debugwindow.cpp
index 728e16b21a..4c71874c1f 100644
--- a/apps/openmw/mwgui/debugwindow.cpp
+++ b/apps/openmw/mwgui/debugwindow.cpp
@@ -5,6 +5,8 @@
 #include <MyGUI_EditBox.h>
 
 #include <LinearMath/btQuickprof.h>
+#include <components/debug/debugging.hpp>
+#include <components/settings/settings.hpp>
 
 #ifndef BT_NO_PROFILE
 
@@ -84,28 +86,105 @@ namespace MWGui
 
         // Ideas for other tabs:
         // - Texture / compositor texture viewer
-        // - Log viewer
         // - Material editor
         // - Shader editor
 
+        initLogView();
+
+#ifndef BT_NO_PROFILE
         MyGUI::TabItem* item = mTabControl->addItem("Physics Profiler");
         mBulletProfilerEdit = item->createWidgetReal<MyGUI::EditBox>
                 ("LogEdit", MyGUI::FloatCoord(0,0,1,1), MyGUI::Align::Stretch);
+#else
+        mBulletProfilerEdit = nullptr;
+#endif
     }
 
-    void DebugWindow::onFrame(float dt)
+    void DebugWindow::initLogView()
+    {
+        MyGUI::TabItem* itemLV = mTabControl->addItem("Log Viewer");
+        mLogView = itemLV->createWidgetReal<MyGUI::EditBox>
+                ("LogEdit", MyGUI::FloatCoord(0,0,1,1), MyGUI::Align::Stretch);
+        mLogView->setEditReadOnly(true);
+
+        mLogCircularBuffer.resize(std::max<int64_t>(0, Settings::Manager::getInt64("log buffer size", "General")));
+        Debug::setLogListener([this](Debug::Level level, std::string_view prefix, std::string_view msg)
+        {
+            if (mLogCircularBuffer.empty())
+                return;  // Log viewer is disabled.
+            std::string_view color;
+            switch (level)
+            {
+                case Debug::Error: color = "#FF0000"; break;
+                case Debug::Warning: color = "#FFFF00"; break;
+                case Debug::Info: color = "#FFFFFF"; break;
+                case Debug::Verbose:
+                case Debug::Debug: color = "#666666"; break;
+                default: color = "#FFFFFF";
+            }
+            bool bufferOverflow = false;
+            const int64_t bufSize = mLogCircularBuffer.size();
+            auto addChar = [&](char c)
+            {
+                mLogCircularBuffer[mLogEndIndex++] = c;
+                if (mLogEndIndex == bufSize)
+                    mLogEndIndex = 0;
+                bufferOverflow = bufferOverflow || mLogEndIndex == mLogStartIndex;
+            };
+            auto addShieldedStr = [&](std::string_view s)
+            {
+                for (char c : s)
+                {
+                    addChar(c);
+                    if (c == '#')
+                        addChar(c);
+                }
+            };
+            for (char c : color)
+                addChar(c);
+            addShieldedStr(prefix);
+            addShieldedStr(msg);
+            if (bufferOverflow)
+                mLogStartIndex = (mLogEndIndex + 1) % bufSize;
+        });
+    }
+
+    void DebugWindow::updateLogView()
+    {
+        if (!mLogView || mLogCircularBuffer.empty() || mLogStartIndex == mLogEndIndex)
+            return;
+        if (mLogView->isTextSelection())
+            return;  // Don't change text while player is trying to copy something
+
+        std::string addition;
+        const int64_t bufSize = mLogCircularBuffer.size();
+        {
+            std::unique_lock<std::mutex> lock = Log::lock();
+            if (mLogStartIndex < mLogEndIndex)
+                addition = std::string(mLogCircularBuffer.data() + mLogStartIndex, mLogEndIndex - mLogStartIndex);
+            else
+            {
+                addition = std::string(mLogCircularBuffer.data() + mLogStartIndex, bufSize - mLogStartIndex);
+                addition.append(mLogCircularBuffer.data(), mLogEndIndex);
+            }
+            mLogStartIndex = mLogEndIndex;
+        }
+
+        size_t scrollPos = mLogView->getVScrollPosition();
+        bool scrolledToTheEnd = scrollPos+1 >= mLogView->getVScrollRange();
+        int64_t newSizeEstimation = mLogView->getTextLength() + addition.size();
+        if (newSizeEstimation > bufSize)
+            mLogView->eraseText(0, newSizeEstimation - bufSize);
+        mLogView->addText(addition);
+        if (scrolledToTheEnd && mLogView->getVScrollRange() > 0)
+            mLogView->setVScrollPosition(mLogView->getVScrollRange() - 1);
+        else
+            mLogView->setVScrollPosition(scrollPos);
+    }
+
+    void DebugWindow::updateBulletProfile()
     {
 #ifndef BT_NO_PROFILE
-        if (!isVisible())
-            return;
-
-        static float timer = 0;
-        timer -= dt;
-
-        if (timer > 0)
-            return;
-        timer = 1;
-
         std::stringstream stream;
         bulletDumpAll(stream);
 
@@ -118,4 +197,17 @@ namespace MWGui
 #endif
     }
 
-}
+    void DebugWindow::onFrame(float dt)
+    {
+        static float timer = 0;
+        timer -= dt;
+        if (timer > 0 || !isVisible())
+            return;
+        timer = 0.25;
+
+        if (mTabControl->getIndexSelected() == 0)
+            updateLogView();
+        else
+            updateBulletProfile();
+    }
+}
\ No newline at end of file
diff --git a/apps/openmw/mwgui/debugwindow.hpp b/apps/openmw/mwgui/debugwindow.hpp
index 33647c0789..352a58c50c 100644
--- a/apps/openmw/mwgui/debugwindow.hpp
+++ b/apps/openmw/mwgui/debugwindow.hpp
@@ -14,8 +14,17 @@ namespace MWGui
         void onFrame(float dt) override;
 
     private:
+        void initLogView();
+        void updateLogView();
+        void updateBulletProfile();
+
         MyGUI::TabControl* mTabControl;
 
+        MyGUI::EditBox* mLogView;
+        std::vector<char> mLogCircularBuffer;
+        int64_t mLogStartIndex = 0;
+        int64_t mLogEndIndex = 0;
+
         MyGUI::EditBox* mBulletProfilerEdit;
     };
 
diff --git a/apps/openmw/mwgui/windowmanagerimp.cpp b/apps/openmw/mwgui/windowmanagerimp.cpp
index 3b79b21a7c..95a437ceaa 100644
--- a/apps/openmw/mwgui/windowmanagerimp.cpp
+++ b/apps/openmw/mwgui/windowmanagerimp.cpp
@@ -884,6 +884,8 @@ namespace MWGui
         if (mLocalMapRender)
             mLocalMapRender->cleanupCameras();
 
+        mDebugWindow->onFrame(frameDuration);
+
         if (!gameRunning)
             return;
 
@@ -903,8 +905,6 @@ namespace MWGui
 
         mHud->onFrame(frameDuration);
 
-        mDebugWindow->onFrame(frameDuration);
-
         mPostProcessorHud->onFrame(frameDuration);
 
         if (mCharGen)
@@ -2061,9 +2061,7 @@ namespace MWGui
 
     void WindowManager::toggleDebugWindow()
     {
-#ifndef BT_NO_PROFILE
         mDebugWindow->setVisible(!mDebugWindow->isVisible());
-#endif
     }
 
     void WindowManager::togglePostProcessorHud()
diff --git a/components/debug/debugging.cpp b/components/debug/debugging.cpp
index e1bea70954..62d1bc716d 100644
--- a/components/debug/debugging.cpp
+++ b/components/debug/debugging.cpp
@@ -61,6 +61,9 @@ namespace Debug
     }
 #endif
 
+    static LogListener logListener;
+    void setLogListener(LogListener listener) { logListener = std::move(listener); }
+
     std::streamsize DebugOutputBase::write(const char *str, std::streamsize size)
     {
         if (size <= 0)
@@ -94,6 +97,8 @@ namespace Debug
                 lineSize++;
             writeImpl(prefix, prefixSize, level);
             writeImpl(msg.data(), lineSize, level);
+            if (logListener)
+                logListener(level, std::string_view(prefix, prefixSize), std::string_view(msg.data(), lineSize));
             msg = msg.substr(lineSize);
         }
 
diff --git a/components/debug/debugging.hpp b/components/debug/debugging.hpp
index 3e6739c1e1..63c44ae8b4 100644
--- a/components/debug/debugging.hpp
+++ b/components/debug/debugging.hpp
@@ -133,6 +133,8 @@ namespace Debug
     };
 #endif
 
+    using LogListener = std::function<void(Debug::Level, std::string_view prefix, std::string_view msg)>;
+    void setLogListener(LogListener);
 
 }
 
diff --git a/components/debug/debuglog.hpp b/components/debug/debuglog.hpp
index 96b9d798e9..bcb282ab3a 100644
--- a/components/debug/debuglog.hpp
+++ b/components/debug/debuglog.hpp
@@ -35,7 +35,7 @@ public:
             return;
 
         // Locks a global lock while the object is alive
-        mLock = std::unique_lock<std::mutex>(sLock);
+        mLock = lock();
 
         // If the app has no logging system enabled, log level is not specified.
         // Show all messages without marker - we just use the plain cout in this case.
@@ -61,6 +61,8 @@ public:
             std::cout << std::endl;
     }
 
+    static std::unique_lock<std::mutex> lock() { return std::unique_lock<std::mutex>(sLock); }
+
 private:
     const bool mShouldLog;
 };
diff --git a/docs/source/reference/modding/settings/general.rst b/docs/source/reference/modding/settings/general.rst
index ae2448c38b..39045e7733 100644
--- a/docs/source/reference/modding/settings/general.rst
+++ b/docs/source/reference/modding/settings/general.rst
@@ -86,3 +86,17 @@ since if the country code isn't specified the generic language-code only locale
 refer to any of the country-specific variants.
 
 This setting can only be configured by editing the settings configuration file.
+
+log buffer size
+---------------
+
+:Type:		integer
+:Range:		>= 0
+:Default:	65536
+
+Buffer size for the in-game log viewer (press F10 to toggle the log viewer).
+When the log doesn't fit into the buffer, only the end of the log is visible in the log viewer.
+Zero disables the log viewer.
+
+This setting can only be configured by editing the settings configuration file.
+
diff --git a/files/mygui/openmw_layers.xml b/files/mygui/openmw_layers.xml
index 0b70dbb000..750c4cacd3 100644
--- a/files/mygui/openmw_layers.xml
+++ b/files/mygui/openmw_layers.xml
@@ -8,14 +8,14 @@
         <Property key="Size" value="600 520"/>
     </Layer>
     <Layer name="Windows" overlapped="true" pick="true"/>
-    <Layer name="Debug" overlapped="true" pick="true"/>
     <Layer name="DragAndDrop" overlapped="false" pick="false"/>
     <Layer name="DrowningBar" overlapped="false" pick="false"/>
     <Layer name="MainMenuBackground" overlapped="true" pick="true"/>
     <Layer name="MainMenu" overlapped="true" pick="true"/>
-    <Layer name="Console" overlapped="true" pick="true"/>
     <Layer name="LoadingScreenBackground" overlapped="false" pick="true"/>
     <Layer name="LoadingScreen" overlapped="false" pick="true"/>
+    <Layer name="Debug" overlapped="true" pick="true"/>
+    <Layer name="Console" overlapped="true" pick="true"/>
     <Layer name="Modal" overlapped="true" pick="true"/>
     <Layer name="Popup" overlapped="true" pick="true"/>
     <Layer name="Notification" overlapped="false" pick="false"/>
diff --git a/files/settings-default.cfg b/files/settings-default.cfg
index 0e2e5e4c44..da50ca5fc7 100644
--- a/files/settings-default.cfg
+++ b/files/settings-default.cfg
@@ -400,6 +400,9 @@ notify on saved screenshot = false
 # For example "de,en" means German as the first prority and English as a fallback.
 preferred locales = en
 
+# Buffer size for the in-game log viewer (press F10 to toggle). Zero disables the log viewer.
+log buffer size = 65536
+
 [Shaders]
 
 # Force rendering with shaders. By default, only bump-mapped objects will use shaders.