From ef783c6ce34f1167c4b5d0358bb033eefef13c7c Mon Sep 17 00:00:00 2001 From: Shane Michael Mathews Date: Tue, 21 Jul 2026 12:56:15 -0400 Subject: [PATCH] feat(mpeg): host-fed CD-stream ES injection for the guest-driven decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add feedMpegCdStreamBytes(), the missing data verb alongside the existing notifyMpegCdStreamStart/Eof control verbs. It lets an embedder that services the movie stream host-side (an HLE'd IOP streamer, or an on-disc stream transform applied before decode) push elementary-stream bytes straight into the guest-driven decoder instead of reimplementing the guest ring protocol or carrying a second, parallel decoder. Bytes route to every open decoder on the current CD-stream generation and demux through the same PSS path as sceMpegDemuxPss. There is no guest thread behind a host feed, so stream callbacks are not dispatched on this path yet; that gap is flagged with a TODO for a follow-up per-decoder event queue rather than silently dropped. Feed is order-insensitive with respect to sceMpegCreate. Previously, bytes fed before any decoder existed on a generation were silently lost: the routing loop found nothing to fan out to and returned 0, indistinguishable from "no active stream," and a decoder created afterward had no way to recover the missing prefix. Bytes fed before a decoder exists are now held in a bounded per-generation stage and replayed into the next decoder created on that generation before its first getPicture. The stage is discarded on overflow, stream stop, and stream restart, and the return value is `size` whenever a CD stream is active whether the bytes were routed live or staged, so a caller can no longer read "accepted but staged" as "rejected." Pure addition otherwise: a header constant plus one function declaration, one new public function, a defaulted parameter on the internal appendPssBytes, and nothing else in ps2xRuntime calls it — no existing call site or behavior changes for any current title. --- ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp | 98 +++++- ps2xRuntime/src/lib/Kernel/Stubs/MPEG.h | 16 + ps2xTest/src/ps2_runtime_expansion_tests.cpp | 349 +++++++++++++++++++ 3 files changed, 458 insertions(+), 5 deletions(-) diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp b/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp index bd161d479..1525cf606 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp +++ b/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.cpp @@ -502,6 +502,9 @@ namespace ps2_stubs uint32_t nextCallbackHandle = 1u; uint64_t cdStreamGeneration = 0u; bool currentCdStreamEofSeen = false; + std::vector cdStreamStagedBytes; // host-fed ES held before a decoder exists + uint64_t cdStreamStageGeneration = 0u; // generation the stage belongs to + bool cdStreamStageOverflowed = false; // cap hit -> stage abandoned this generation uint32_t feedEsTraceCount = 0u; uint32_t demuxPssTraceCount = 0u; uint32_t demuxRingTraceCount = 0u; @@ -1154,7 +1157,8 @@ namespace ps2_stubs const uint8_t *data, size_t size, uint32_t guestAddr, - std::vector &callbackEvents) + std::vector &callbackEvents, + bool trackGuestAddrs = true) { if (!data || size == 0) { @@ -1184,10 +1188,13 @@ namespace ps2_stubs playback.sawInput = true; playback.pssBuffer.insert(playback.pssBuffer.end(), data, data + size); - playback.pssGuestAddrs.reserve(playback.pssGuestAddrs.size() + size); - for (size_t i = 0; i < size; ++i) + if (trackGuestAddrs) { - playback.pssGuestAddrs.push_back(guestAddr + static_cast(i)); + playback.pssGuestAddrs.reserve(playback.pssGuestAddrs.size() + size); + for (size_t i = 0; i < size; ++i) + { + playback.pssGuestAddrs.push_back(guestAddr + static_cast(i)); + } } processPssBuffer(mpegAddr, playback, callbackEvents); if (!playback.decodedFrames.empty()) @@ -1528,6 +1535,9 @@ namespace ps2_stubs g_mpeg_stub_state.nextCallbackHandle = 1u; g_mpeg_stub_state.cdStreamGeneration = 0u; g_mpeg_stub_state.currentCdStreamEofSeen = false; + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + g_mpeg_stub_state.cdStreamStageGeneration = 0u; + g_mpeg_stub_state.cdStreamStageOverflowed = false; g_mpeg_stub_state.feedEsTraceCount = 0u; g_mpeg_stub_state.demuxPssTraceCount = 0u; g_mpeg_stub_state.demuxRingTraceCount = 0u; @@ -1551,6 +1561,8 @@ namespace ps2_stubs std::lock_guard lock(g_mpeg_stub_mutex); ++g_mpeg_stub_state.cdStreamGeneration; g_mpeg_stub_state.currentCdStreamEofSeen = false; + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + g_mpeg_stub_state.cdStreamStageOverflowed = false; g_mpeg_stub_state.feedEsTraceCount = 0u; g_mpeg_stub_state.demuxPssTraceCount = 0u; g_mpeg_stub_state.demuxRingTraceCount = 0u; @@ -1573,6 +1585,8 @@ namespace ps2_stubs { std::lock_guard lock(g_mpeg_stub_mutex); g_mpeg_stub_state.currentCdStreamEofSeen = true; + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + g_mpeg_stub_state.cdStreamStageOverflowed = false; bool changed = false; for (auto &[mpegAddr, playback] : g_mpeg_stub_state.playbackByMpeg) { @@ -1599,6 +1613,68 @@ namespace ps2_stubs } } + size_t feedMpegCdStreamBytes(const uint8_t *data, size_t size) + { + if (!data || size == 0u) + { + return 0u; + } + + size_t routedCount = 0u; + { + std::lock_guard lock(g_mpeg_stub_mutex); + + // Active only between notifyMpegCdStreamStart() and notifyMpegCdStreamEof(). + const bool cdStreamActive = + g_mpeg_stub_state.cdStreamGeneration != 0u && + !g_mpeg_stub_state.currentCdStreamEofSeen; + if (!cdStreamActive) + { + return 0u; + } + + // Host-fed data carries no guest addresses, so no callback event is queued. + // TODO(host-feed callbacks): queue on MpegPlaybackState for a guest syscall to drain. + std::vector callbackEvents; + for (auto &[mpegAddr, playback] : g_mpeg_stub_state.playbackByMpeg) + { + if (playback.cdStreamGeneration != g_mpeg_stub_state.cdStreamGeneration) + { + continue; + } + appendPssBytes(mpegAddr, playback, data, size, 0u, callbackEvents, false); + ++routedCount; + } + + // No decoder on the current generation took these bytes: stage them until the + // first sceMpegCreate on this generation; afterwards feed routes live. + if (routedCount == 0u && !g_mpeg_stub_state.cdStreamStageOverflowed) + { + if (g_mpeg_stub_state.cdStreamStageGeneration != g_mpeg_stub_state.cdStreamGeneration) + { + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + g_mpeg_stub_state.cdStreamStageGeneration = g_mpeg_stub_state.cdStreamGeneration; + } + + if (g_mpeg_stub_state.cdStreamStagedBytes.size() + size > kMpegHostFeedStageCapBytes) + { + // Dropping the tail would hand a later decoder a gapped stream (staged + // prefix, then a hole, then live feed); discard the whole stage instead + // and let the next decoder resync cleanly on live feed. + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + g_mpeg_stub_state.cdStreamStageOverflowed = true; + } + else + { + g_mpeg_stub_state.cdStreamStagedBytes.insert( + g_mpeg_stub_state.cdStreamStagedBytes.end(), data, data + size); + } + } + } + g_mpeg_cv.notify_all(); + return size; + } + void sceMpegFlush(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime) { (void)rdram; @@ -1736,7 +1812,19 @@ namespace ps2_stubs { std::lock_guard lock(g_mpeg_stub_mutex); - getPlaybackState(param_1) = makeFreshPlaybackState(); + auto &pb = getPlaybackState(param_1); + pb = makeFreshPlaybackState(); + if (!g_mpeg_stub_state.cdStreamStageOverflowed && + !g_mpeg_stub_state.cdStreamStagedBytes.empty() && + g_mpeg_stub_state.cdStreamStageGeneration == pb.cdStreamGeneration) + { + std::vector replayEvents; // host feed: none consumed + appendPssBytes(param_1, pb, + g_mpeg_stub_state.cdStreamStagedBytes.data(), + g_mpeg_stub_state.cdStreamStagedBytes.size(), + 0u, replayEvents, /*trackGuestAddrs=*/false); + g_mpeg_stub_state.cdStreamStagedBytes.clear(); + } } const uint32_t puVar4 = uVar3 + 0x108u; diff --git a/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.h b/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.h index bd9a8d5a4..8075cb792 100644 --- a/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.h +++ b/ps2xRuntime/src/lib/Kernel/Stubs/MPEG.h @@ -4,9 +4,25 @@ namespace ps2_stubs { + // Upper bound on how many host-fed bytes are held in the pre-decoder stage + // (see feedMpegCdStreamBytes below) before the stage is abandoned for the + // rest of the current CD-stream generation. Internal tuning knob, not part + // of the API contract. + constexpr size_t kMpegHostFeedStageCapBytes = 4u * 1024u * 1024u; + void resetMpegStubState(); void notifyMpegCdStreamStart(); void notifyMpegCdStreamEof(); + // Push `size` bytes of the active CD movie stream's program-stream/PES data into + // the guest-driven MPEG decoder from host memory. Must be bracketed by + // notifyMpegCdStreamStart()/notifyMpegCdStreamEof(). Order-insensitive w.r.t. + // sceMpegCreate: bytes fed before any decoder exists on the current CD-stream + // generation are staged (bounded; see kMpegHostFeedStageCapBytes) and replayed + // into the next decoder created on that generation. Staged bytes are discarded on + // stream stop/restart. Returns `size` while a CD stream is active (routed or + // staged), 0 only when no CD stream is active. Thread-safe; stream callbacks are + // not dispatched on this path. + size_t feedMpegCdStreamBytes(const uint8_t *data, size_t size); void sceMpegFlush(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceMpegAddBs(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); void sceMpegAddCallback(uint8_t *rdram, R5900Context *ctx, PS2Runtime *runtime); diff --git a/ps2xTest/src/ps2_runtime_expansion_tests.cpp b/ps2xTest/src/ps2_runtime_expansion_tests.cpp index c31b4d425..8ffb59046 100644 --- a/ps2xTest/src/ps2_runtime_expansion_tests.cpp +++ b/ps2xTest/src/ps2_runtime_expansion_tests.cpp @@ -322,6 +322,36 @@ namespace ctx->pc = 0u; } + // A minimal decodable program-stream packet (PES header + one MPEG-1 video + // sequence), shared by the host-feed MPEG tests below. Returns a fresh + // mutable copy each call so callers may slice/consume it freely. + std::vector makeMpegHostFeedTestPacket() + { + static const std::vector es = { + 0x00u, 0x00u, 0x01u, 0xB3u, 0x01u, 0x00u, 0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u, + 0x00u, 0x00u, 0x01u, 0xB5u, 0x14u, 0x8Au, 0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u, + 0x01u, 0xB8u, 0x00u, 0x08u, 0x00u, 0x40u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu, + 0xFFu, 0xF8u, 0x00u, 0x00u, 0x01u, 0xB5u, 0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u, + 0x00u, 0x01u, 0x01u, 0x13u, 0xF8u, 0x7Du, 0x29u, 0x48u, 0x88u, 0x00u, 0x00u, 0x01u, + 0xB3u, 0x01u, 0x00u, 0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u, 0x00u, 0x00u, 0x01u, + 0xB5u, 0x14u, 0x8Au, 0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u, 0x01u, 0xB8u, 0x00u, + 0x08u, 0x00u, 0xC0u, 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu, 0xFFu, 0xF8u, 0x00u, + 0x00u, 0x01u, 0xB5u, 0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u, 0x00u, 0x01u, 0x01u, + 0x13u, 0xF8u, 0x7Du, 0x29u, 0x48u, 0x88u, 0x00u, 0x00u, 0x01u, 0xB3u, 0x01u, 0x00u, + 0x10u, 0x12u, 0xFFu, 0xFFu, 0xE0u, 0x18u, 0x00u, 0x00u, 0x01u, 0xB5u, 0x14u, 0x8Au, + 0x00u, 0x01u, 0x00u, 0x17u, 0x00u, 0x00u, 0x01u, 0xB8u, 0x00u, 0x08u, 0x01u, 0x40u, + 0x00u, 0x00u, 0x01u, 0x00u, 0x00u, 0x0Fu, 0xFFu, 0xF8u, 0x00u, 0x00u, 0x01u, 0xB5u, + 0x8Fu, 0xFFu, 0xF3u, 0x41u, 0x80u, 0x00u, 0x00u, 0x01u, 0x01u, 0x13u, 0xF8u, 0x7Du, + 0x29u, 0x48u, 0x88u}; + + std::vector packet = { + 0x00u, 0x00u, 0x01u, 0xE0u, + 0x00u, static_cast(es.size() + 3u), + 0x80u, 0x00u, 0x00u}; + packet.insert(packet.end(), es.begin(), es.end()); + return packet; + } + } void register_ps2_runtime_expansion_tests() @@ -1137,6 +1167,325 @@ void register_ps2_runtime_expansion_tests() "repeated temporary starvation should continue advancing from the held frame"); }); + tc.Run("host-fed CD stream decodes without a guest demux call", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector packet = makeMpegHostFeedTestPacket(); + + const size_t consumed = ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()); + t.Equals(consumed, packet.size(), + "feedMpegCdStreamBytes should report the whole packet consumed"); + + ps2_stubs::notifyMpegCdStreamEof(); + + t.Equals(ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()), static_cast(0u), + "feedMpegCdStreamBytes after CD EOF should be a no-op"); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "host-fed stream should decode to the expected frame width"); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x08u), 0u, + "host-fed stream should report frame index zero"); + }); + + tc.Run("host feed staged before a decoder opens reaches the later-created decoder", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector packet = makeMpegHostFeedTestPacket(); + const size_t consumed = ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()); + t.Equals(consumed, packet.size(), + "feed before any decoder exists should still report the stream as active (staged, not dropped)"); + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + ps2_stubs::notifyMpegCdStreamEof(); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "the later-created decoder should decode the staged prefix"); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x08u), 0u, + "first decoded frame should report frame index zero"); + }); + + tc.Run("host feed split across a packet boundary decodes", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector packet = makeMpegHostFeedTestPacket(); + constexpr size_t kSplitAt = 40; // inside the PES payload (payload starts at byte 9) + const size_t firstConsumed = ps2_stubs::feedMpegCdStreamBytes(packet.data(), kSplitAt); + t.Equals(firstConsumed, kSplitAt, + "first half of a split feed should be routed to the open decoder"); + const size_t secondConsumed = ps2_stubs::feedMpegCdStreamBytes( + packet.data() + kSplitAt, packet.size() - kSplitAt); + t.Equals(secondConsumed, packet.size() - kSplitAt, + "second half of a split feed should be routed to the open decoder"); + + ps2_stubs::notifyMpegCdStreamEof(); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "a packet split across two host feeds should still decode"); + }); + + tc.Run("host feed decodes across a CD-stream restart", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + ps2_stubs::notifyMpegCdStreamStart(); + const std::vector packetGen1 = makeMpegHostFeedTestPacket(); + ps2_stubs::feedMpegCdStreamBytes(packetGen1.data(), packetGen1.size()); + ps2_stubs::notifyMpegCdStreamEof(); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "generation 1 host-fed stream should decode"); + + ps2_stubs::notifyMpegCdStreamStart(); + const std::vector packetGen2 = makeMpegHostFeedTestPacket(); + ps2_stubs::feedMpegCdStreamBytes(packetGen2.data(), packetGen2.size()); + ps2_stubs::notifyMpegCdStreamEof(); + + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "generation 2 host-fed stream should decode after a restart"); + }); + + tc.Run("host feed staging is bounded and recovers after overflow", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector packet = makeMpegHostFeedTestPacket(); + ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()); + + const std::vector junk(ps2_stubs::kMpegHostFeedStageCapBytes + 1u, 0xAAu); + ps2_stubs::feedMpegCdStreamBytes(junk.data(), junk.size()); + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + ps2_stubs::notifyMpegCdStreamEof(); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 320u, + "overflow should discard the staged prefix; with nothing decodable, width should stay the " + "safe seeded default instead of the fed stream's real width"); + + // Delete the generation-1 decoder so the recovery leg's feed genuinely has no + // open decoder to route to and exercises staging again, rather than routing live + // to a decoder that notifyMpegCdStreamStart would otherwise carry over. + R5900Context deleteCtx{}; + setRegU32(deleteCtx, 4, kMpegAddr); + ps2_stubs::sceMpegDelete(rdram.data(), &deleteCtx, nullptr); + + // Recovery leg: the overflow flag is per generation, so a fresh generation + // stages and decodes normally again. + ps2_stubs::notifyMpegCdStreamStart(); + const std::vector packetGen2 = makeMpegHostFeedTestPacket(); + ps2_stubs::feedMpegCdStreamBytes(packetGen2.data(), packetGen2.size()); + + R5900Context createCtx2{}; + setRegU32(createCtx2, 4, kMpegAddr); + setRegU32(createCtx2, 5, kWorkAddr); + setRegU32(createCtx2, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx2, nullptr); + t.IsTrue(::getRegU32(&createCtx2, 2) != 0u, + "sceMpegCreate should return a nonzero handle for the recovery decoder"); + + ps2_stubs::notifyMpegCdStreamEof(); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 16u, + "the overflow flag should clear per generation so the next generation stages and decodes normally"); + }); + + tc.Run("host feed staged before EOF is discarded, not resurrected after EOF", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + constexpr uint32_t kImageAddr = 0x00160000u; + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector packet = makeMpegHostFeedTestPacket(); + ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()); + + ps2_stubs::notifyMpegCdStreamEof(); + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + R5900Context pictureCtx{}; + setRegU32(pictureCtx, 4, kMpegAddr); + setRegU32(pictureCtx, 5, kImageAddr); + ps2_stubs::sceMpegGetPicture(rdram.data(), &pictureCtx, nullptr); + t.Equals(Ps2FastRead32(rdram.data(), kMpegAddr + 0x00u), 320u, + "the stage should be discarded at EOF; a post-EOF decoder must not replay a dead generation's " + "prefix (width should stay the safe seeded default, not the fed stream's real width)"); + }); + + tc.Run("feedMpegCdStreamBytes before a CD stream start is a no-op", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegAddr = 0x00123000u; + constexpr uint32_t kWorkAddr = 0x00140000u; + + R5900Context createCtx{}; + setRegU32(createCtx, 4, kMpegAddr); + setRegU32(createCtx, 5, kWorkAddr); + setRegU32(createCtx, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtx, nullptr); + t.IsTrue(::getRegU32(&createCtx, 2) != 0u, + "sceMpegCreate should return a nonzero handle"); + + const std::vector packet = { + 0x00u, 0x00u, 0x01u, 0xE0u, 0x00u, 0x04u, 0x80u, 0x00u, 0x00u, 0xAAu}; + + t.Equals(ps2_stubs::feedMpegCdStreamBytes(packet.data(), packet.size()), static_cast(0u), + "feedMpegCdStreamBytes with no active CD stream (handle exists but generation 0) should not consume bytes"); + }); + + tc.Run("host feed fans out to every open decoder and reports the input range once", [](TestCase &t) + { + std::vector rdram(PS2_RAM_SIZE, 0u); + ps2_stubs::resetMpegStubState(); + + constexpr uint32_t kMpegA = 0x00123000u; + constexpr uint32_t kWorkA = 0x00140000u; + constexpr uint32_t kMpegB = 0x00133000u; + constexpr uint32_t kWorkB = 0x00150000u; + + R5900Context createCtxA{}; + setRegU32(createCtxA, 4, kMpegA); + setRegU32(createCtxA, 5, kWorkA); + setRegU32(createCtxA, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtxA, nullptr); + t.IsTrue(::getRegU32(&createCtxA, 2) != 0u, + "sceMpegCreate should return a nonzero handle for decoder A"); + + R5900Context createCtxB{}; + setRegU32(createCtxB, 4, kMpegB); + setRegU32(createCtxB, 5, kWorkB); + setRegU32(createCtxB, 6, 0x2000u); + ps2_stubs::sceMpegCreate(rdram.data(), &createCtxB, nullptr); + t.IsTrue(::getRegU32(&createCtxB, 2) != 0u, + "sceMpegCreate should return a nonzero handle for decoder B"); + + ps2_stubs::notifyMpegCdStreamStart(); + + const std::vector programEnd = {0x00u, 0x00u, 0x01u, 0xB9u}; + const size_t consumed = ps2_stubs::feedMpegCdStreamBytes(programEnd.data(), programEnd.size()); + t.Equals(consumed, programEnd.size(), + "feedMpegCdStreamBytes should report the input range once, not per decoder"); + + R5900Context isEndCtxA{}; + setRegU32(isEndCtxA, 4, kMpegA); + ps2_stubs::sceMpegIsEnd(rdram.data(), &isEndCtxA, nullptr); + t.Equals(getRegS32(isEndCtxA, 2), 1, "decoder A should have received the fed bytes"); + + R5900Context isEndCtxB{}; + setRegU32(isEndCtxB, 4, kMpegB); + ps2_stubs::sceMpegIsEnd(rdram.data(), &isEndCtxB, nullptr); + t.Equals(getRegS32(isEndCtxB, 2), 1, "decoder B should have received the fed bytes"); + }); + tc.Run("sceMpegGetPicture releases an old waiter when the CD stream restarts", [](TestCase &t) { PS2Runtime runtime;