diff --git a/Core/CMakeLists.txt b/Core/CMakeLists.txt
index c6cf1f5a80..24b7c17d4d 100644
--- a/Core/CMakeLists.txt
+++ b/Core/CMakeLists.txt
@@ -597,6 +597,8 @@ add_library(Core STATIC
HW/Display.h
HW/GpioMMIO.cpp
HW/GpioMMIO.h
+ HW/AvcDecoder.cpp
+ HW/AvcDecoder.h
HW/MediaEngine.cpp
HW/MediaEngine.h
HW/MpegDemux.cpp
diff --git a/Core/Core.vcxproj b/Core/Core.vcxproj
index bdda9d35a3..b051ba598a 100644
--- a/Core/Core.vcxproj
+++ b/Core/Core.vcxproj
@@ -672,6 +672,7 @@
+
@@ -1189,6 +1190,7 @@
+
diff --git a/Core/Core.vcxproj.filters b/Core/Core.vcxproj.filters
index 5133900c30..16ebfaddd9 100644
--- a/Core/Core.vcxproj.filters
+++ b/Core/Core.vcxproj.filters
@@ -357,6 +357,9 @@
HLE\Libraries
+
+ HW
+
HW
@@ -1692,6 +1695,9 @@
HLE\Libraries
+
+ HW
+
HW
diff --git a/Core/HW/AvcDecoder.cpp b/Core/HW/AvcDecoder.cpp
new file mode 100644
index 0000000000..b87bb611be
--- /dev/null
+++ b/Core/HW/AvcDecoder.cpp
@@ -0,0 +1,187 @@
+// Copyright (c) 2026- PPSSPP Project.
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 2.0 or later versions.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License 2.0 for more details.
+
+// A copy of the GPL 2.0 should have been included with the program.
+// If not, see http://www.gnu.org/licenses/
+
+// Official git repository and contact information can be found at
+// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
+
+#include "ppsspp_config.h"
+
+#include "Common/Log.h"
+#include "Core/HW/AvcDecoder.h"
+
+#ifdef USE_FFMPEG
+extern "C" {
+#include "libavcodec/avcodec.h"
+#include "libavutil/imgutils.h"
+}
+#include "Core/FFMPEGCompat.h"
+#endif // USE_FFMPEG
+
+AvcDecoder::AvcDecoder() {
+#ifdef USE_FFMPEG
+ AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264);
+ if (!codec) {
+ ERROR_LOG(Log::ME, "AvcDecoder: no H.264 decoder in this ffmpeg build");
+ return;
+ }
+ codecCtx_ = avcodec_alloc_context3(codec);
+ if (!codecCtx_) {
+ ERROR_LOG(Log::ME, "AvcDecoder: couldn't allocate a codec context");
+ return;
+ }
+ // The PSP hands us whole access units, so no parser is needed and no truncation is expected.
+ codecCtx_->flags = 0;
+ codecCtx_->flags2 = 0;
+ if (avcodec_open2(codecCtx_, codec, nullptr) < 0) {
+ ERROR_LOG(Log::ME, "AvcDecoder: couldn't open the H.264 decoder");
+ avcodec_free_context(&codecCtx_);
+ codecCtx_ = nullptr;
+ return;
+ }
+ frame_ = av_frame_alloc();
+ if (!frame_) {
+ avcodec_free_context(&codecCtx_);
+ codecCtx_ = nullptr;
+ }
+#endif
+}
+
+AvcDecoder::~AvcDecoder() {
+#ifdef USE_FFMPEG
+ if (frame_) {
+ av_frame_free(&frame_);
+ }
+ if (codecCtx_) {
+ avcodec_free_context(&codecCtx_);
+ }
+#endif
+}
+
+bool AvcDecoder::IsAvailable() {
+#ifdef USE_FFMPEG
+ return true;
+#else
+ return false;
+#endif
+}
+
+void AvcDecoder::Flush() {
+ haveFrame_ = false;
+#ifdef USE_FFMPEG
+ if (codecCtx_) {
+ avcodec_flush_buffers(codecCtx_);
+ }
+ if (frame_) {
+ av_frame_unref(frame_);
+ }
+#endif
+}
+
+bool AvcDecoder::Decode(const u8 *data, int size) {
+ haveFrame_ = false;
+#ifdef USE_FFMPEG
+ if (!codecCtx_ || !frame_ || !data || size <= 0) {
+ return false;
+ }
+
+ // ffmpeg reads a little past the end of a packet, so it gets a padded copy rather than the
+ // caller's buffer. A plain stack AVPacket, like the rest of the emulator uses - the heap
+ // packet API isn't in the ffmpeg headers every target builds against.
+ packetBuf_.resize((size_t)size + AV_INPUT_BUFFER_PADDING_SIZE);
+ memcpy(packetBuf_.data(), data, size);
+ memset(packetBuf_.data() + size, 0, AV_INPUT_BUFFER_PADDING_SIZE);
+
+ AVPacket packet;
+ av_init_packet(&packet);
+ packet.data = packetBuf_.data();
+ packet.size = size;
+
+ av_frame_unref(frame_);
+
+#if LIBAVCODEC_VERSION_INT >= AV_VERSION_INT(57, 48, 101)
+ int err = avcodec_send_packet(codecCtx_, &packet);
+ if (err == AVERROR(EAGAIN)) {
+ // The decoder has frames waiting and won't take more input until one is taken out. It did
+ // not consume the packet, so drain a frame and hand it the same packet again rather than
+ // dropping the access unit.
+ err = avcodec_receive_frame(codecCtx_, frame_);
+ if (err < 0) {
+ WARN_LOG(Log::ME, "AvcDecoder: send_packet wanted a frame out but none came (%d)", err);
+ return false;
+ }
+ const int resend = avcodec_send_packet(codecCtx_, &packet);
+ if (resend < 0) {
+ WARN_LOG(Log::ME, "AvcDecoder: send_packet failed on retry (%d)", resend);
+ return false;
+ }
+ // A frame is already in hand from the drain above - fall through to the format check.
+ } else {
+ if (err < 0) {
+ WARN_LOG(Log::ME, "AvcDecoder: send_packet failed (%d)", err);
+ return false;
+ }
+
+ err = avcodec_receive_frame(codecCtx_, frame_);
+ if (err == AVERROR(EAGAIN)) {
+ // Normal: H.264 reorders, so the decoder wants more input before it gives a frame back.
+ return false;
+ }
+ if (err < 0) {
+ WARN_LOG(Log::ME, "AvcDecoder: receive_frame failed (%d)", err);
+ return false;
+ }
+ }
+#else
+ int gotFrame = 0;
+ int err = avcodec_decode_video2(codecCtx_, frame_, &gotFrame, &packet);
+ if (err < 0) {
+ WARN_LOG(Log::ME, "AvcDecoder: decode_video2 failed (%d)", err);
+ return false;
+ }
+ if (!gotFrame) {
+ // Normal: H.264 reorders, so the decoder wants more input before it gives a frame back.
+ return false;
+ }
+#endif
+
+ width_ = frame_->width;
+ height_ = frame_->height;
+ haveFrame_ = width_ > 0 && height_ > 0;
+ return haveFrame_;
+#else
+ return false;
+#endif
+}
+
+const u8 *AvcDecoder::Plane(int index) const {
+#ifdef USE_FFMPEG
+ if (!haveFrame_ || !frame_ || index < 0 || index >= 3) {
+ return nullptr;
+ }
+ return frame_->data[index];
+#else
+ return nullptr;
+#endif
+}
+
+int AvcDecoder::Stride(int index) const {
+#ifdef USE_FFMPEG
+ if (!haveFrame_ || !frame_ || index < 0 || index >= 3) {
+ return 0;
+ }
+ return frame_->linesize[index];
+#else
+ return 0;
+#endif
+}
diff --git a/Core/HW/AvcDecoder.h b/Core/HW/AvcDecoder.h
new file mode 100644
index 0000000000..35cf1f7078
--- /dev/null
+++ b/Core/HW/AvcDecoder.h
@@ -0,0 +1,73 @@
+// Copyright (c) 2026- PPSSPP Project.
+
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, version 2.0 or later versions.
+
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License 2.0 for more details.
+
+// A copy of the GPL 2.0 should have been included with the program.
+// If not, see http://www.gnu.org/licenses/
+
+// Official git repository and contact information can be found at
+// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
+
+#pragma once
+
+#include
+
+#include "Common/CommonTypes.h"
+
+struct AVCodecContext;
+struct AVFrame;
+
+// A plain "hand it one access unit, get a frame back" H.264 decoder.
+//
+// This exists for sceVideocodec, which is the interface the Media Engine really exposes: the
+// caller owns the buffers and hands over one access unit at a time. MediaEngine can't serve that
+// - it is built around sceMpeg's own model, with the PSMF demuxer, the ringbuffer and its own
+// frame pacing wrapped around the decoder - so this is deliberately a second, separate path
+// rather than a refactor of code every game that plays video depends on. Merging the two is
+// worth doing once sceVideocodec has proven itself.
+//
+// Output is whatever pixel format the decoder produced, normally YUV420P; the caller converts.
+class AvcDecoder {
+public:
+ AvcDecoder();
+ ~AvcDecoder();
+
+ AvcDecoder(const AvcDecoder &) = delete;
+ AvcDecoder &operator=(const AvcDecoder &) = delete;
+
+ // Decodes one access unit. Returns true when a frame came out - H.264 reorders, so a frame
+ // is not produced for every unit fed in, and that is not an error.
+ bool Decode(const u8 *data, int size);
+
+ // Throws away any decoder state, for a seek or a new stream.
+ void Flush();
+
+ bool HasFrame() const { return haveFrame_; }
+ int Width() const { return width_; }
+ int Height() const { return height_; }
+
+ // Only meaningful while HasFrame(). Planes are Y, Cb, Cr; strides are in bytes and are not
+ // necessarily the width.
+ const u8 *Plane(int index) const;
+ int Stride(int index) const;
+
+ // True if we were built without ffmpeg, in which case nothing decodes.
+ static bool IsAvailable();
+
+private:
+ AVCodecContext *codecCtx_ = nullptr;
+ AVFrame *frame_ = nullptr;
+ // The access unit, copied so we can pad it - ffmpeg reads a little past the end of a packet.
+ // Kept between calls so the usual case doesn't allocate.
+ std::vector packetBuf_;
+ bool haveFrame_ = false;
+ int width_ = 0;
+ int height_ = 0;
+};
diff --git a/UWP/CoreUWP/CoreUWP.vcxproj b/UWP/CoreUWP/CoreUWP.vcxproj
index 9bda778ca2..291e398f04 100644
--- a/UWP/CoreUWP/CoreUWP.vcxproj
+++ b/UWP/CoreUWP/CoreUWP.vcxproj
@@ -258,6 +258,7 @@
+
@@ -541,6 +542,7 @@
+
diff --git a/UWP/CoreUWP/CoreUWP.vcxproj.filters b/UWP/CoreUWP/CoreUWP.vcxproj.filters
index 555c850fcd..e800daa7e1 100644
--- a/UWP/CoreUWP/CoreUWP.vcxproj.filters
+++ b/UWP/CoreUWP/CoreUWP.vcxproj.filters
@@ -165,6 +165,7 @@
+
@@ -524,6 +525,7 @@
+
diff --git a/android/jni/Android.mk b/android/jni/Android.mk
index 1a0d219d2c..72b9440a41 100644
--- a/android/jni/Android.mk
+++ b/android/jni/Android.mk
@@ -614,6 +614,7 @@ EXEC_AND_LIB_FILES := \
$(SRC)/Core/HW/GpioMMIO.cpp \
$(SRC)/Core/HW/MemoryStick.cpp \
$(SRC)/Core/HW/MpegDemux.cpp.arm \
+ $(SRC)/Core/HW/AvcDecoder.cpp.arm \
$(SRC)/Core/HW/MediaEngine.cpp.arm \
$(SRC)/Core/HW/SasAudio.cpp.arm \
$(SRC)/Core/HW/SasReverb.cpp.arm \
diff --git a/libretro/Makefile.common b/libretro/Makefile.common
index a47b086c88..d865b0f66c 100644
--- a/libretro/Makefile.common
+++ b/libretro/Makefile.common
@@ -848,6 +848,7 @@ SOURCES_CXX += \
$(COREDIR)/HW/SimpleAudioDec.cpp \
$(COREDIR)/HW/Atrac3Standalone.cpp \
$(COREDIR)/HW/AsyncIOManager.cpp \
+ $(COREDIR)/HW/AvcDecoder.cpp \
$(COREDIR)/HW/MediaEngine.cpp \
$(COREDIR)/HW/MpegDemux.cpp \
$(COREDIR)/HW/MemoryStick.cpp \