diff --git a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs index 15abf57..1b92cfa 100644 --- a/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs +++ b/src/SharpEmu.Libs/VideoOut/RenderPhaseProfile.cs @@ -367,8 +367,9 @@ internal static class RenderPhaseProfile } // Cache calls on other threads must not enter the render-thread counters. + // Check the switch first to avoid thread-local storage allocation when profiling is disabled. internal static Scope MeasureDetail(Phase phase) => - _scopeDepth > 0 ? Measure(phase) : default; + Enabled && _scopeDepth > 0 ? Measure(phase) : default; internal static bool DetailMeasurementsEnabled => Enabled && _scopeDepth > 0; diff --git a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs index 0b47391..0470bbb 100644 --- a/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs +++ b/src/SharpEmu.Libs/VideoOut/VideoOutExports.cs @@ -72,6 +72,7 @@ public static partial class VideoOutExports // sceVideoOutGetEventId (mapped below), so the exact value is internal; only // its distinctness from the flip ident matters for GetEventId/GetEventData. private const ulong SceVideoOutInternalEventVblank = 0x40; + private const ulong SceVideoOutInternalEventOutputMode = 0x8; private const short OrbisKernelEventFilterVideoOut = -13; private static readonly object _stateGate = new(); @@ -239,6 +240,7 @@ public static partial class VideoOutExports public uint OutputWidth { get; set; } = 1920; public uint OutputHeight { get; set; } = 1080; public uint RefreshRate { get; set; } = 60; + public ulong OutputMode { get; set; } = SceVideoOutOutputModeDefault; public float Gamma { get; set; } = 1.0f; public Dictionary LatencyStartPoints { get; } = new(); public Queue<(long FlipArg, long Timestamp)> LatencyStartPointOrder { get; } = new(); @@ -249,6 +251,7 @@ public static partial class VideoOutExports public VideoOutBufferSlot[] BufferSlots { get; } = CreateBufferSlots(); public List FlipEvents { get; } = new(); public List VblankEvents { get; } = new(); + public List OutputModeEvents { get; } = new(); public long OpenTimestamp; public long LastCpuFlipTimestamp = -1; public int WindowTop; @@ -435,10 +438,31 @@ public static partial class VideoOutExports if (supported < 0) return supported; if (supported == 0) return OrbisVideoOutErrorUnavailableOutputMode; var handle = unchecked((int)ctx[CpuRegister.Rdi]); - // The only advertised configuration is the current default output. - return TryGetPort(handle, out _) - ? (int)OrbisGen2Result.ORBIS_GEN2_OK - : OrbisVideoOutErrorInvalidHandle; + var mode = ctx[CpuRegister.Rsi]; + if (!TryGetPort(handle, out var port)) + { + return OrbisVideoOutErrorInvalidHandle; + } + + FlipEventRegistration[] registrations; + lock (_stateGate) + { + port.OutputMode = mode; + registrations = port.OutputModeEvents.ToArray(); + } + + var eventHint = (mode & 0x0000_FFFF_FFFF_FFFFUL) << 16; + foreach (var registration in registrations) + { + _ = KernelEventQueueCompatExports.TriggerDisplayEvent( + registration.Equeue, + SceVideoOutInternalEventOutputMode, + OrbisKernelEventFilterVideoOut, + eventHint, + registration.UserData); + } + + return (int)OrbisGen2Result.ORBIS_GEN2_OK; } [SysAbiExport( @@ -884,6 +908,52 @@ public static partial class VideoOutExports return (int)OrbisGen2Result.ORBIS_GEN2_OK; } + [SysAbiExport( + Nid = "kmSe30JTs+E", + ExportName = "sceVideoOutAddOutputModeEvent", + Target = Generation.Gen5, + LibraryName = "libSceVideoOut")] + public static int VideoOutAddOutputModeEvent(CpuContext ctx) + { + var equeue = ctx[CpuRegister.Rdi]; + var handle = unchecked((int)ctx[CpuRegister.Rsi]); + var userData = ctx[CpuRegister.Rdx]; + if (!TryGetPort(handle, out var port)) + { + return OrbisVideoOutErrorInvalidHandle; + } + + if (!KernelEventQueueCompatExports.IsValidEqueue(equeue)) + { + return OrbisVideoOutErrorInvalidEventQueue; + } + + ulong outputMode; + lock (_stateGate) + { + var existingIndex = port.OutputModeEvents.FindIndex(registration => registration.Equeue == equeue); + if (existingIndex >= 0) + { + port.OutputModeEvents[existingIndex] = new FlipEventRegistration(equeue, userData); + } + else + { + port.OutputModeEvents.Add(new FlipEventRegistration(equeue, userData)); + } + outputMode = port.OutputMode; + } + + var eventHint = (outputMode & 0x0000_FFFF_FFFF_FFFFUL) << 16; + return KernelEventQueueCompatExports.TriggerDisplayEvent( + equeue, + SceVideoOutInternalEventOutputMode, + OrbisKernelEventFilterVideoOut, + eventHint, + userData) + ? (int)OrbisGen2Result.ORBIS_GEN2_OK + : OrbisVideoOutErrorInvalidEventQueue; + } + [SysAbiExport( Nid = "oNOQn3knW6s", ExportName = "sceVideoOutDeleteVblankEvent", @@ -1025,6 +1095,11 @@ public static partial class VideoOutExports return 1; } + if (ident == SceVideoOutInternalEventOutputMode) + { + return 8; + } + return OrbisVideoOutErrorInvalidEvent; } @@ -1050,7 +1125,9 @@ public static partial class VideoOutExports } if (filter != OrbisKernelEventFilterVideoOut || - (ident != SceVideoOutInternalEventFlip && ident != SceVideoOutInternalEventVblank)) + (ident != SceVideoOutInternalEventFlip && + ident != SceVideoOutInternalEventVblank && + ident != SceVideoOutInternalEventOutputMode)) { return OrbisVideoOutErrorInvalidEvent; } diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/RenderPhaseProfileTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/RenderPhaseProfileTests.cs new file mode 100644 index 0000000..edb60d9 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/VideoOut/RenderPhaseProfileTests.cs @@ -0,0 +1,43 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.Libs.VideoOut; +using Xunit; + +namespace SharpEmu.Libs.Tests.VideoOut; + +public sealed class RenderPhaseProfileTests +{ + [Fact] + public void DisabledDetailMeasurement_DoesNotAllocateOnANewThread() + { + if (RenderPhaseProfile.Enabled) return; + + long allocated = -1; + var thread = new Thread(() => + { + var before = GC.GetAllocatedBytesForCurrentThread(); + using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageTracking)) + { + } + allocated = GC.GetAllocatedBytesForCurrentThread() - before; + }) { IsBackground = true }; + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(30))); + Assert.Equal(0, allocated); + } + + [Fact] + public void DetailMeasurement_PreservesTheOuterScope() + { + Assert.False(RenderPhaseProfile.DetailMeasurementsEnabled); + using (RenderPhaseProfile.Measure(RenderPhaseProfile.Phase.Draw)) + { + Assert.Equal(RenderPhaseProfile.Enabled, RenderPhaseProfile.DetailMeasurementsEnabled); + using (RenderPhaseProfile.MeasureDetail(RenderPhaseProfile.Phase.ImageTracking)) + Assert.Equal(RenderPhaseProfile.Enabled, RenderPhaseProfile.DetailMeasurementsEnabled); + Assert.Equal(RenderPhaseProfile.Enabled, RenderPhaseProfile.DetailMeasurementsEnabled); + } + Assert.False(RenderPhaseProfile.DetailMeasurementsEnabled); + } +} diff --git a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs index e59005a..debb298 100644 --- a/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs +++ b/tests/SharpEmu.Libs.Tests/VideoOut/VideoOutFlipRequestTests.cs @@ -234,6 +234,60 @@ public sealed class VideoOutFlipRequestTests : IDisposable } } + [Fact] + public void OutputModeEventIsImmediatelyAvailableAndTracksConfiguration() + { + _context[CpuRegister.Rdi] = StatusAddress; + _context[CpuRegister.Rsi] = 0; + Assert.Equal(0, KernelEventQueueCompatExports.KernelCreateEqueue(_context)); + var bytes = new byte[8]; + Assert.True(_memory.TryRead(StatusAddress, bytes)); + var queue = BinaryPrimitives.ReadUInt64LittleEndian(bytes); + try + { + const ulong userData = 0x1234_5678_9ABC_DEF0; + _context[CpuRegister.Rdi] = queue; + _context[CpuRegister.Rsi] = (ulong)_handle; + _context[CpuRegister.Rdx] = userData; + Assert.Equal(0, VideoOutExports.VideoOutAddOutputModeEvent(_context)); + + AssertOutputModeEvent(queue, expectedMode: 1, userData); + + _context[CpuRegister.Rdi] = (ulong)_handle; + _context[CpuRegister.Rsi] = 1; + _context[CpuRegister.Rdx] = 0; + _context[CpuRegister.Rcx] = 0; + _context[CpuRegister.R8] = 0; + Assert.Equal(0, VideoOutExports.VideoOutConfigureOutput(_context)); + + AssertOutputModeEvent(queue, expectedMode: 1, userData); + } + finally + { + _context[CpuRegister.Rdi] = queue; + Assert.Equal(0, KernelEventQueueCompatExports.KernelDeleteEqueue(_context)); + } + } + + private void AssertOutputModeEvent(ulong queue, ulong expectedMode, ulong userData) + { + var timeoutAddress = MemoryBase + 0x400; + Assert.True(_memory.TryWrite(timeoutAddress, BitConverter.GetBytes(1_000_000u))); + _context[CpuRegister.Rdi] = queue; + _context[CpuRegister.Rsi] = MemoryBase + 0x200; + _context[CpuRegister.Rdx] = 1; + _context[CpuRegister.Rcx] = MemoryBase + 0x300; + _context[CpuRegister.R8] = timeoutAddress; + Assert.Equal(0, KernelEventQueueCompatExports.KernelWaitEqueue(_context)); + + var eventBytes = new byte[0x20]; + Assert.True(_memory.TryRead(MemoryBase + 0x200, eventBytes)); + Assert.Equal(8UL, BinaryPrimitives.ReadUInt64LittleEndian(eventBytes)); + Assert.Equal(-13, BinaryPrimitives.ReadInt16LittleEndian(eventBytes.AsSpan(0x08))); + Assert.Equal(expectedMode, BinaryPrimitives.ReadUInt64LittleEndian(eventBytes.AsSpan(0x10)) >> 16); + Assert.Equal(userData, BinaryPrimitives.ReadUInt64LittleEndian(eventBytes.AsSpan(0x18))); + } + [Theory] [InlineData(0)] [InlineData(1)]