42 Commits
Author SHA256 Message Date
copecone 5efe1d0dd2 Change Disable Tile Culling Default to False 2026-10-19 21:10:55 +09:00
copecone 71e86c0262 Adjust UI Layout 2026-10-19 21:10:33 +09:00
copecone f72a8acbac Upgrade Version 2026-10-19 20:06:47 +09:00
copecone d90d06c095 Add Customizable Output Filename Formatting 2026-10-19 18:14:02 +09:00
copecone b66d774cb9 Add Hide Failed Decorations Option 2026-10-19 18:14:02 +09:00
copecone d1ba5d5c18 Add Disable Tile Culling Option 2026-10-19 18:14:02 +09:00
copecone 7c6210d2c4 Cleanup Fix Custom FPS Render Timing Marker Comments 2026-10-19 18:14:01 +09:00
copecone 9e3941e3b5 Add Fix Custom FPS Render Timing Option 2026-10-18 01:01:30 +09:00
copecone 78ea42b368 Add High-Resolution Bloom Correction Option 2026-10-16 22:14:10 +09:00
copecone 064bd816e8 Fix NVENC Falling Back To VBR In CBR Mode 2026-10-16 16:28:49 +09:00
copecone 46fc00df9c Fix Case Insensitive Default Output Directory Match 2026-10-16 16:11:08 +09:00
copecone 769f368f66 Fix Linux Folder Dialog Reopening After Cancel 2026-10-16 16:11:03 +09:00
copecone ed6f990d3b Upgrade Version 2026-10-15 22:57:17 +09:00
copecone 151c91bd86 Merge pull request 'Refactor Comments' (#6) from refactor/comments into main
Reviewed-on: #6
2026-10-15 19:02:22 +09:00
copeconeandClaude Sonnet 5.5 961f78a332 Normalize Comment Terminology
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:29:22 +09:00
copecone 0fc01a84de Document UI And Localization 2026-10-15 16:17:14 +09:00
copecone a389283d33 Document OS Utilities 2026-10-15 16:17:13 +09:00
copecone d2bd655118 Document Harmony Patches 2026-10-15 16:17:13 +09:00
copecone 8deb64fd4d Document Render Core And API 2026-10-15 16:17:13 +09:00
copecone 354c42fdd9 Document FFmpeg Encoder And Decoder 2026-10-15 16:17:12 +09:00
copecone 19d0e258ac Document Audio Pipeline 2026-10-15 16:17:12 +09:00
copecone 7bb47e4974 Fix Zero Duration Of Last Video Frame 2026-10-15 11:27:58 +09:00
copecone fab189ff3b Merge pull request 'Rewrite Audio' (#5) from feature/audio_rewrite into main
Reviewed-on: #5
2026-10-15 01:29:30 +09:00
copecone abb6f729e4 Localize FFmpeg Download And Driver Warning Dialogs 2026-10-14 22:55:15 +09:00
copecone c56175bbc8 Fix Custom Preset Label Handling On Language Change 2026-10-14 22:55:00 +09:00
copecone ab8164a735 Deactivate Mapped Effect Sources During Render 2026-10-14 22:18:35 +09:00
copecone 69d098e04c Emulate AudioSource Playback State For Effect Sounds 2026-10-14 21:55:13 +09:00
copecone 8e0639aafa Fix Warning Dialogs Hidden Behind Render Config Window 2026-10-14 20:46:02 +09:00
copecone 1fdee67f9d Validate Even Render Resolution Before Starting 2026-10-14 20:20:16 +09:00
copecone a4dba82729 Fix Language Change And Name Collision In Presets 2026-10-14 20:20:06 +09:00
copecone 5136127e39 Round Voice Start Times Down To Output Samples 2026-10-13 19:20:59 +09:00
copecone 455436e744 Fix Audio Cleanup On Render Abort 2026-10-13 15:37:16 +09:00
copecone 89f15eca1e Read Spectrum Samples From Clip Store 2026-10-13 15:20:04 +09:00
copecone d0f6fa9ecd Fix Repeated Effect Voice List Scans 2026-10-13 12:02:48 +09:00
copecone 5055e4dfbb Apply AudioSource Playback Control To Voices 2026-10-13 11:57:07 +09:00
copecone cb0fd1bd96 Switch Render Audio To Voice Mixer 2026-10-13 11:30:57 +09:00
copecone 3d2ffe9dfe Fix MP3 Header Read While Game Streams Song 2026-10-13 11:30:42 +09:00
copecone a21d86d3a6 Add Voice Mixer With Timed Changes 2026-10-13 09:43:07 +09:00
copecone 7ea2d8fe3f Resolve Audio Clips From Level Files 2026-10-13 02:00:18 +09:00
copecone 35606d6e58 Match Game MP3 Decoding Timing 2026-10-13 01:59:53 +09:00
copecone 82776fc572 Add FFmpeg Audio File Reader 2026-10-13 00:30:07 +09:00
copecone 0a12c12ff5 Add Chunked Audio Clip Store 2026-10-13 00:30:02 +09:00
85 changed files with 8674 additions and 1072 deletions

No files matched your search

+69 -9
View File
@@ -18,6 +18,21 @@ namespace ADOFAIRenderer.API;
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
/// will <b>not</b> be invoked on every rendered game frame.
/// </para>
/// <para>
/// <b>Ping-pong model:</b> the renderer owns two RenderTextures with the size and format of the game frame.
/// <see cref="Source"/> is the current frame, <see cref="Destination"/> is the buffer the next pass writes.
/// A pass draws from <see cref="Source"/> into <see cref="Destination"/> and then calls <see cref="Swap"/>, which makes
/// the written buffer the new <see cref="Source"/>. <see cref="Blit(Material, int)"/>, and by default
/// <see cref="DrawToDestination"/> and <see cref="ExecuteCommandBuffer"/>, swap automatically. The texture in
/// <see cref="Current"/> after the last hook is encoded. When no hook swaps or replaces the frame, the game frame is
/// encoded unchanged. The first <see cref="Source"/> of every frame is the game's own frame texture
/// (<see cref="OriginalSource"/>); do not write into it.
/// </para>
/// <para>
/// <b>Threading and lifetime:</b> hooks run on the main Unity thread at the end of the frame, after the game has
/// rendered. The textures are valid only during the hook call: do not keep references to <see cref="Source"/>,
/// <see cref="Destination"/> or the context. <c>RenderTexture.active</c> is restored after all hooks have run.
/// </para>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
@@ -25,12 +40,22 @@ namespace ADOFAIRenderer.API;
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
public class FrameHookContext
{
/// <summary>Game frame of this capture (camera quad texture); never written by the context.</summary>
private RenderTexture _originalSource = null!;
/// <summary>First ping-pong buffer, owned by FrameHookPipeline.</summary>
private RenderTexture _pingRT = null!;
/// <summary>Second ping-pong buffer, owned by FrameHookPipeline.</summary>
private RenderTexture _pongRT = null!;
/// <summary>Current frame: the original source, a ping-pong buffer, or a texture set by ReplaceCurrent.</summary>
private RenderTexture _currentSource = null!;
/// <summary>Buffer the next pass writes: always the ping or pong buffer.</summary>
private RenderTexture _currentDestination = null!;
/// <summary>True after the first Swap or ReplaceCurrent of this frame.</summary>
private bool _hasModified;
/// <summary>
@@ -45,6 +70,7 @@ public class FrameHookContext
/// <summary>
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
/// Same texture as <see cref="Source"/>; after the last hook it is the frame that gets encoded.
/// </summary>
public RenderTexture Current => _currentSource;
@@ -59,7 +85,7 @@ public class FrameHookContext
public bool HasModified => _hasModified;
/// <summary>
/// The sequential 0-based index of the video frame currently being encoded.
/// The sequential 0-based index of the video frame currently being captured (its position in the output video).
/// </summary>
public long FrameIndex { get; private set; }
@@ -69,12 +95,13 @@ public class FrameHookContext
public long VideoFrameIndex => FrameIndex;
/// <summary>
/// The sequential 0-based index of the game render frame.
/// The sequential 0-based index of the game render frame, counted from the start of capture.
/// With <c>RenderFPS</c> above the video frame rate it advances by more than one between hook calls.
/// </summary>
public long RenderFrameIndex { get; private set; }
/// <summary>
/// The timestamp of the current frame in seconds relative to video start.
/// The timestamp of the current frame in seconds relative to video start (<see cref="FrameIndex"/> / <see cref="VideoFPS"/>).
/// </summary>
public double VideoTime { get; private set; }
@@ -84,30 +111,32 @@ public class FrameHookContext
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
/// <summary>
/// The width of the frame in pixels.
/// The width of the frame in pixels (width of the current <see cref="Source"/>).
/// </summary>
public int Width => _currentSource.width;
/// <summary>
/// The height of the frame in pixels.
/// The height of the frame in pixels (height of the current <see cref="Source"/>).
/// </summary>
public int Height => _currentSource.height;
/// <summary>
/// The configured render frames per second.
/// The configured render frames per second (game frames simulated per second of render time).
/// </summary>
public double RenderFPS { get; private set; }
/// <summary>
/// The output video frames per second.
/// The output video frames per second: the effective rate, min(configured <c>VideoFPS</c>, <see cref="RenderFPS"/>).
/// </summary>
public double VideoFPS { get; private set; }
/// <summary>
/// The number of successful modification passes completed for the current frame.
/// The number of modification passes completed for the current frame (calls of <see cref="Swap"/> and
/// <see cref="ReplaceCurrent"/>, including the automatic swaps of the helper methods). 0 at the start of every frame.
/// </summary>
public int PassCount { get; private set; }
/// <summary>Creates a context for the first frame; see <see cref="Reset"/> for the parameters.</summary>
internal FrameHookContext(
RenderTexture originalSource,
RenderTexture pingRT,
@@ -122,6 +151,10 @@ public class FrameHookContext
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
}
/// <summary>
/// Prepares the context for a new frame: Source = <paramref name="originalSource"/>, Destination =
/// <paramref name="firstDestination"/> (ping or pong), not modified, PassCount 0, and the frame metadata.
/// </summary>
internal void Reset(
RenderTexture originalSource,
RenderTexture pingRT,
@@ -152,6 +185,11 @@ public class FrameHookContext
/// <summary>
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <remarks>
/// Uses <c>Graphics.Blit</c>, which binds <see cref="Source"/> as the material's <c>_MainTex</c> and changes
/// <c>RenderTexture.active</c>.
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
/// </remarks>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
public void Blit(Material? material = null, int pass = -1)
@@ -174,6 +212,10 @@ public class FrameHookContext
/// <summary>
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <remarks>
/// The current <see cref="Source"/> is not read; the result replaces the frame. <paramref name="customSource"/> is
/// stretched to the size of <see cref="Destination"/>.
/// </remarks>
/// <param name="customSource">The custom source texture to blit from.</param>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
@@ -200,6 +242,7 @@ public class FrameHookContext
/// <summary>
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
/// Does not count as a modification; call <see cref="Swap"/> after drawing to make the result the current frame.
/// </summary>
public void Copy()
{
@@ -207,7 +250,8 @@ public class FrameHookContext
}
/// <summary>
/// Clears <see cref="Destination"/> with the specified color.
/// Clears <see cref="Destination"/> with the specified color (color only; no depth buffer is cleared).
/// Does not swap; the previous <c>RenderTexture.active</c> is restored.
/// </summary>
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
public void ClearDestination(Color? clearColor = null)
@@ -261,6 +305,11 @@ public class FrameHookContext
/// <summary>
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
/// </summary>
/// <remarks>
/// The context does not set a render target for the command buffer: the commands must render into
/// <see cref="Destination"/> themselves (for example with <c>SetRenderTarget</c> or <c>Blit(Source, Destination)</c>),
/// so build or update the buffer inside the hook with the current <see cref="Source"/> and <see cref="Destination"/>.
/// </remarks>
/// <param name="commandBuffer">The command buffer to execute.</param>
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
@@ -278,6 +327,11 @@ public class FrameHookContext
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
/// </summary>
/// <remarks>
/// After the call, <see cref="Source"/> is the buffer that was <see cref="Destination"/>, and <see cref="Destination"/>
/// is the other ping-pong buffer (its content is left over from an earlier pass or frame; the next pass overwrites it).
/// A texture set by <see cref="ReplaceCurrent"/> is dropped by a swap.
/// </remarks>
public void Swap()
{
_hasModified = true;
@@ -291,6 +345,12 @@ public class FrameHookContext
/// <summary>
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
/// </summary>
/// <remarks>
/// The texture becomes <see cref="Source"/> / <see cref="Current"/>; <see cref="Destination"/> is unchanged.
/// The texture is read back for encoding when it is still current after the last hook, so it must stay alive and
/// unchanged until the end of the hook chain, and it should have the frame's width, height and a format the capture
/// can read: the capture sizes its buffers from the texture, and the encoder expects the render resolution.
/// </remarks>
/// <param name="customTexture">The custom texture to set as the active frame.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
public void ReplaceCurrent(RenderTexture customTexture)
+6
View File
@@ -9,6 +9,8 @@ namespace ADOFAIRenderer.API;
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
/// will <b>not</b> be called on every game frame.
/// Register an implementation per render with <see cref="RenderSession.AddFrameHook(IFrameHook)"/> or for every render
/// with <see cref="RenderSession.AddGlobalFrameHook(IFrameHook)"/>.
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
public interface IFrameHook
@@ -18,6 +20,10 @@ public interface IFrameHook
/// </summary>
/// <remarks>
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
/// It is called on the main Unity thread at the end of the frame. Draw from <see cref="FrameHookContext.Source"/>
/// into <see cref="FrameHookContext.Destination"/> and swap (see <see cref="FrameHookContext"/>); returning without
/// a swap leaves the frame unchanged. Do not keep the context or its textures after returning.
/// An exception thrown here is logged and the next hook still runs.
/// </remarks>
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
void OnRenderFrame(FrameHookContext context);
+14 -3
View File
@@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API;
/// <summary>
/// Represents the result and metadata of a completed or canceled render session.
/// </summary>
/// <remarks>
/// Passed to the completion callback registered with <see cref="RenderSession.OnComplete"/>. Immutable.
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderResult
@@ -17,7 +20,10 @@ public class RenderResult
public bool Success { get; }
/// <summary>
/// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created.
/// The output path of the rendered video file: the path given to <see cref="RenderSession.WithOutputPath"/> as passed
/// (relative if it was relative), or the path of the time-stamped file in the output directory chosen in the mod
/// settings (by default &lt;game root&gt;/Render).
/// Empty string if rendering failed or was cancelled (the unfinished file is deleted).
/// </summary>
public string OutputPath { get; }
@@ -27,12 +33,13 @@ public class RenderResult
public long TotalFrames { get; }
/// <summary>
/// The calculated video duration in seconds (based on TotalFrames and FPS).
/// The calculated video duration in seconds: <see cref="TotalFrames"/> divided by the effective video frame rate
/// (min of the configured VideoFPS and RenderFPS); 0 when the frame rate is not positive.
/// </summary>
public double VideoDuration { get; }
/// <summary>
/// Total wall-clock time spent rendering.
/// Total wall-clock time spent rendering, from the end of the countdown (start of capture) to the end of the render.
/// </summary>
public TimeSpan RenderDuration { get; }
@@ -43,6 +50,8 @@ public class RenderResult
/// <summary>
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
/// A cancelled render has "Rendering was cancelled by user."; a failed render has "Rendering failed: " followed by
/// the reason. Use <see cref="Success"/> to test the outcome; the texts are meant for display.
/// </summary>
public string? ErrorMessage { get; }
@@ -82,6 +91,7 @@ public class RenderResult
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
/// <remarks>The file size is read from disk; it is 0 when the file does not exist or cannot be read.</remarks>
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{
var fileSizeBytes = 0L;
@@ -103,6 +113,7 @@ public class RenderResult
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
/// <remarks>OutputPath is empty and FileSizeBytes is 0. A null message is replaced by a generic one.</remarks>
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
{
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
+180 -5
View File
@@ -10,13 +10,41 @@ using UnityEngine;
namespace ADOFAIRenderer.API;
/// <summary>
/// Fluent builder for configuring and starting a render session.
/// Fluent builder for configuring and starting a render session, and the registry of global frame hooks.
/// </summary>
/// <remarks>
/// <para>
/// <b>Usage:</b> create an instance, configure it with the <c>With…</c>, <c>On…</c> and <c>AddFrameHook</c> methods,
/// then call <see cref="Start"/>. The builder's values are copied into the render when <see cref="Start"/> is called;
/// changing the builder afterwards does not affect the running render. A builder can be reused for later renders.
/// </para>
/// <para>
/// <b>Lifecycle of a render:</b> <see cref="Start"/> loads the game scene of the level. When the scene has loaded,
/// a 3-second countdown runs, then capture starts and the level plays with the game clock driven by the render
/// frame rate (offline, not real time). The render ends successfully <see cref="RenderConfig.DelayAfterEnd"/> seconds
/// (render time) after the end condition first holds, or is cancelled by the user (ESC), or fails. In every case
/// the completion callback receives a <see cref="RenderResult"/> and the game returns to the level editor.
/// </para>
/// <para>
/// <b>Threading:</b> every member must be called on the main Unity thread, except the global hook members, which are
/// thread-safe (they lock an internal object). All callbacks (end condition, completion, last update, frame hooks)
/// are invoked on the main Unity thread.
/// </para>
/// <para>
/// <b>Frame hooks</b> run on each captured video frame before it is encoded, in this order: session hooks in the order
/// they were added, then global hooks in registration order.
/// </para>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession
{
/// <summary>
/// Handle returned by AddGlobalFrameHook. Key is the object the caller registered (delegate, material or IFrameHook)
/// and is used by RemoveGlobalFrameHook; Action is the delegate the pipeline invokes.
/// Dispose removes this exact registration once; later calls do nothing.
/// </summary>
private sealed class GlobalHookRegistration : IDisposable
{
public object Key { get; }
@@ -37,6 +65,10 @@ public class RenderSession
}
}
/// <summary>
/// Session hook added to this builder. Key is the object the caller passed (delegate, material or IFrameHook) and
/// is used by RemoveFrameHook; Action is the delegate copied to the render by Start.
/// </summary>
private sealed class SessionHookEntry
{
public object Key { get; }
@@ -49,11 +81,22 @@ public class RenderSession
}
}
/// <summary>Global hooks in registration order. Guarded by GlobalHooksLock.</summary>
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
/// <summary>Lock for GlobalFrameHooks and _globalHooksVersion (global hooks may be changed from any thread).</summary>
private static readonly object GlobalHooksLock = new();
/// <summary>
/// Incremented on every change of GlobalFrameHooks. RenderManager compares it each captured frame to rebuild its
/// cached hook list only after a change. Guarded by GlobalHooksLock.
/// </summary>
private static int _globalHooksVersion;
/// <summary>Session hooks of this builder in the order they were added.</summary>
private readonly List<SessionHookEntry> _frameHooks = new();
/// <summary>Autoplay setting copied to the render by Start (default true).</summary>
private bool _auto = true;
/// <summary>
@@ -63,27 +106,48 @@ public class RenderSession
{
/// <summary>
/// Time-related patch controlling frame timing and clock synchronization.
/// While capturing, <c>Time.time</c>, <c>Time.timeAsDouble</c>, <c>Time.unscaledTime</c>,
/// <c>Time.unscaledTimeAsDouble</c>, <c>Time.deltaTime</c>, <c>Time.unscaledDeltaTime</c>, <c>Time.frameCount</c>
/// and <c>AudioSettings.dspTime</c> return values of the render clock (one frame = 1 / RenderFPS seconds),
/// so the game advances exactly one render frame per Unity frame regardless of how long a frame takes.
/// Disabling it leaves these values to Unity.
/// </summary>
Time,
/// <summary>
/// Screen-related patch controlling screen resolution.
/// While the render is active (from the countdown on), <c>Screen.width</c> and <c>Screen.height</c> return
/// <see cref="RenderConfig.RenderWidth"/> and <see cref="RenderConfig.RenderHeight"/>, so the game creates its
/// camera RenderTexture at the render resolution.
/// </summary>
Screen
}
/// <summary>Time patch setting copied to the render by Start (default true).</summary>
private bool _timePatch = true;
/// <summary>Screen patch setting copied to the render by Start (default true).</summary>
private bool _screenPatch = true;
/// <summary>Custom output file path, or null for the default output directory.</summary>
private string? _outputPath;
/// <summary>Notification setting copied to the render by Start (default true).</summary>
private bool _notification = true;
/// <summary>Custom end condition, or null to keep the default (level cleared).</summary>
private Func<bool>? _endCondition;
/// <summary>Combined completion callbacks, or null to keep the default (notification).</summary>
private Action<RenderResult>? _onComplete;
/// <summary>Combined last-update callbacks, or null for none.</summary>
private Action? _onLastUpdate;
/// <summary>
/// Sets whether auto mode is enabled.
/// Sets whether auto mode (the game's autoplay, <c>RDC.auto</c>) is enabled during the render.
/// With auto mode off, the level is not played automatically; the render still enables the game's no-fail
/// settings (<c>scrController.noFail</c>, <c>scrController.noFailInfiniteMargin</c>).
/// </summary>
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
@@ -115,6 +179,13 @@ public class RenderSession
/// <summary>
/// Sets the custom condition function that determines when the render session should end.
/// </summary>
/// <remarks>
/// The condition is polled on the main Unity thread once per frame from the start of the countdown until the render ends.
/// When it first returns <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of
/// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is
/// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again
/// replaces the previous condition. Exceptions thrown by the condition are not caught.
/// </remarks>
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithEndCondition(Func<bool> endCondition)
@@ -126,6 +197,13 @@ public class RenderSession
/// <summary>
/// Sets the custom output path for the rendered file.
/// </summary>
/// <remarks>
/// The directory of the path is created by <see cref="Start"/> when it does not exist. An existing file at the path
/// is overwritten. The container format follows the file extension. With <c>null</c>, the file is named
/// <c>yyyy-MM-dd HH-mm-ss.&lt;ext&gt;</c> (local start time, extension from <see cref="AdvancedConfig.FileExtension"/>)
/// in the output directory chosen in the mod settings. When the render is cancelled or fails, the unfinished file
/// is deleted.
/// </remarks>
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithOutputPath(string? outputPath)
@@ -137,6 +215,10 @@ public class RenderSession
/// <summary>
/// Sets whether desktop notifications should be displayed when rendering completes.
/// </summary>
/// <remarks>
/// The notification is shown by the default completion callback only. When a callback is added with
/// <see cref="OnComplete"/>, it replaces the default callback and no notification is shown. Default is <c>true</c>.
/// </remarks>
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithNotification(bool enabled)
@@ -148,6 +230,13 @@ public class RenderSession
/// <summary>
/// Adds a callback action to be invoked when the render session completes or fails.
/// </summary>
/// <remarks>
/// Called once per render on the main Unity thread: on success after the output file has been finished, on cancel or
/// failure after the unfinished file has been deleted, and before the game returns to the level editor.
/// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in
/// the order added). The combined callback replaces the default one, which shows the desktop notification.
/// An exception thrown by a callback is logged and stops the callbacks added after it.
/// </remarks>
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession OnComplete(Action<RenderResult> callback)
@@ -159,6 +248,11 @@ public class RenderSession
/// <summary>
/// Adds a callback action to be invoked on the last update of each frame in the render session.
/// </summary>
/// <remarks>
/// Called on the main Unity thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene
/// of the render has loaded until the render ends (including the countdown, before capture starts).
/// Multiple calls combine the callbacks. An exception thrown by a callback is logged.
/// </remarks>
/// <param name="callback">The callback action to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession OnLastUpdate(Action callback)
@@ -172,9 +266,19 @@ public class RenderSession
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <para>
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </para>
/// <para>
/// The hook is called on the main Unity thread at the end of the frame, after the game has rendered it and before
/// the GPU readback for encoding is requested. Write the result into <see cref="FrameHookContext.Destination"/> and
/// swap (or use <see cref="FrameHookContext.Blit(Material, int)"/>, which swaps); a hook that does not swap leaves
/// the frame unchanged. An exception thrown by the hook is logged and the next hook still runs.
/// Session hooks apply only to the render started by <see cref="Start"/> and are discarded when it ends.
/// The same delegate can be added more than once; it then runs once per addition.
/// </para>
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
@@ -190,6 +294,13 @@ public class RenderSession
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
/// Automatically performs a GPU blit from source to destination and swaps buffers.
/// </summary>
/// <remarks>
/// Equivalent to a hook calling <see cref="FrameHookContext.Blit(Material, int)"/>: the current frame is bound as the
/// material's <c>_MainTex</c> by <c>Graphics.Blit</c>. The material is referenced, not copied, so property changes
/// made to it later apply to the following frames. Execution rules are the same as
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>.
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
/// </remarks>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
@@ -204,6 +315,10 @@ public class RenderSession
/// <summary>
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>
/// <see cref="IFrameHook.OnRenderFrame"/> is invoked with the same rules as
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>. Remove it with <see cref="RemoveFrameHook(IFrameHook)"/>.
/// </remarks>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
@@ -239,6 +354,11 @@ public class RenderSession
/// <summary>
/// Removes a previously added frame modification hook callback from this session.
/// </summary>
/// <remarks>
/// Removes the first entry (in the order added) whose registered object is <paramref name="hook"/> or whose delegate
/// equals it. When the same delegate was added several times, one call removes one addition. Does nothing when no
/// entry matches. Affects only renders started after this call.
/// </remarks>
/// <param name="hook">The hook action to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
@@ -251,6 +371,10 @@ public class RenderSession
/// <summary>
/// Removes a previously added material-based post-processing pass from this session.
/// </summary>
/// <remarks>
/// Removes every pass added with <paramref name="material"/> (any pass index). Does nothing when the material is
/// null or destroyed. Affects only renders started after this call.
/// </remarks>
/// <param name="material">The material to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Material material)
@@ -271,6 +395,9 @@ public class RenderSession
/// <summary>
/// Removes a previously added <see cref="IFrameHook"/> from this session.
/// </summary>
/// <remarks>
/// Removes the first entry added with this instance (reference equality). Affects only renders started after this call.
/// </remarks>
/// <param name="hook">The hook instance to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(IFrameHook hook)
@@ -294,9 +421,19 @@ public class RenderSession
/// Registers a global frame modification hook that will execute on every render session.
/// </summary>
/// <remarks>
/// <para>
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </para>
/// <para>
/// The registration stays active for every following render (and a render already running, from its next
/// captured frame) until the returned handle is disposed or the hook is removed with
/// <see cref="RemoveGlobalFrameHook(Action{FrameHookContext})"/> or <see cref="ClearGlobalFrameHooks"/>.
/// Global hooks run after the session hooks, in registration order, on the main Unity thread; an exception thrown
/// by the hook is logged and the next hook still runs. This method is thread-safe. Registering the same delegate
/// twice creates two registrations.
/// </para>
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
@@ -316,6 +453,10 @@ public class RenderSession
/// <summary>
/// Registers a global material-based post-processing pass that will execute on every render session.
/// </summary>
/// <remarks>
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>; the pass is applied as
/// <see cref="FrameHookContext.Blit(Material, int)"/>. Thread-safe.
/// </remarks>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
@@ -335,6 +476,9 @@ public class RenderSession
/// <summary>
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>. Thread-safe.
/// </remarks>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
@@ -353,6 +497,10 @@ public class RenderSession
/// <summary>
/// Unregisters a previously registered global frame modification hook.
/// </summary>
/// <remarks>
/// Removes the first registration (in registration order) whose registered object is <paramref name="hook"/> or whose
/// delegate equals it. Thread-safe; a running render stops calling the hook from its next captured frame.
/// </remarks>
/// <param name="hook">The hook action to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
@@ -373,6 +521,7 @@ public class RenderSession
/// <summary>
/// Unregisters a previously registered global material-based post-processing pass.
/// </summary>
/// <remarks>Removes every registration made with <paramref name="material"/> (any pass index). Thread-safe.</remarks>
/// <param name="material">The material to remove.</param>
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Material material)
@@ -399,6 +548,7 @@ public class RenderSession
/// <summary>
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>Removes the first registration made with this instance (reference equality). Thread-safe.</remarks>
/// <param name="hook">The hook instance to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(IFrameHook hook)
@@ -416,6 +566,7 @@ public class RenderSession
}
}
/// <summary>Removes one registration (called by its Dispose). Does nothing when it was already removed.</summary>
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
{
lock (GlobalHooksLock)
@@ -430,6 +581,10 @@ public class RenderSession
/// <summary>
/// Clears all registered global frame modification hooks.
/// </summary>
/// <remarks>
/// Removes the registrations of every mod, not only the caller's. Handles returned earlier stay valid; disposing them
/// does nothing. Thread-safe.
/// </remarks>
public static void ClearGlobalFrameHooks()
{
lock (GlobalHooksLock)
@@ -443,7 +598,7 @@ public class RenderSession
}
/// <summary>
/// Indicates whether any global frame modification hooks are currently registered.
/// Indicates whether any global frame modification hooks are currently registered (by any mod). Thread-safe.
/// </summary>
public static bool HasGlobalFrameHooks
{
@@ -457,7 +612,7 @@ public class RenderSession
}
/// <summary>
/// The number of currently registered global frame modification hooks.
/// The number of currently registered global frame modification hooks (by any mod). Thread-safe.
/// </summary>
public static int GlobalFrameHookCount
{
@@ -470,6 +625,7 @@ public class RenderSession
}
}
/// <summary>Change counter of the global hook list (see _globalHooksVersion). Thread-safe.</summary>
internal static int GlobalHooksVersion
{
get
@@ -481,6 +637,7 @@ public class RenderSession
}
}
/// <summary>Appends the delegates of every global hook, in registration order, to <paramref name="destination"/>. Thread-safe.</summary>
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
{
lock (GlobalHooksLock)
@@ -495,11 +652,29 @@ public class RenderSession
/// <summary>
/// Applies the configured flags and initiates the render session.
/// </summary>
/// <remarks>
/// <para>
/// Must be called on the main Unity thread while no render is running; this method does not check for a running
/// render. It copies the builder's options and hooks into the render, copies <paramref name="renderConfig"/>
/// (later changes to the caller's object do not affect the render), creates the output directory, and starts
/// loading the game scene (<c>scnGame</c>) with the level. It returns immediately; the render runs over the following
/// frames and reports its end through the <see cref="OnComplete"/> callback.
/// </para>
/// <para>
/// Options that were not set keep their defaults: auto mode on, both patches on, default output path, notification
/// on, end condition "level cleared", default completion callback (notification), no last-update callback.
/// </para>
/// </remarks>
/// <param name="renderConfig">The configuration settings for the render session.</param>
/// <param name="levelPath">The file path of the level to render.</param>
/// <param name="levelPath">The file path of the level to render (the .adofai file).</param>
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
/// <exception cref="ArgumentException">
/// Thrown by the directory creation when the output path has no directory part (for example a bare file name).
/// </exception>
public void Start(RenderConfig renderConfig, string levelPath)
{
// Callbacks not set on this builder keep the values RenderContext holds; RenderContext.Reset at the end of every
// render restores the defaults.
RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch;
+55
View File
@@ -6,29 +6,64 @@ using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data;
/// <summary>
/// Mod-wide settings that are not part of a render preset, stored as JSON in
/// &lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
/// </summary>
internal class GlobalConfig
{
/// <summary>
/// Output directory chosen in the render configuration window; null or empty selects
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
/// </summary>
public string? OutputDirectory { get; set; }
/// <summary>
/// File name format template chosen in the render configuration window; null or empty selects
/// GlobalSettings.DefaultFileNameFormat ("{date} {time}").
/// </summary>
public string? FileNameFormat { get; set; }
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
public string? LastPresetName { get; set; }
/// <summary>
/// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user
/// deleted afterwards are not written again.
/// </summary>
public bool DefaultPresetsInitialized { get; set; }
}
/// <summary>
/// Loads and saves <see cref="GlobalConfig"/>. The file is read once by the static constructor and again by
/// RenderMod.OnInitializeMelon (Load), and written by Save. Read and write errors are logged as warnings and never
/// thrown; a file that cannot be read yields the default settings. Main thread.
/// </summary>
internal static class GlobalSettings
{
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
private static string GetGameRootDir()
{
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
/// <summary>&lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json.</summary>
private static string SettingsFilePath =>
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
/// <summary>
/// Settings in memory. Changes are kept in memory until <see cref="Save"/> is called; <see cref="Load"/> replaces the
/// object.
/// </summary>
public static GlobalConfig Current { get; private set; } = new();
/// <summary>Output directory used when none is configured: &lt;game root&gt;/Render.</summary>
public static string DefaultOutputDirectory =>
Path.Combine(GetGameRootDir(), "Render");
/// <summary>
/// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and
/// exists, otherwise <see cref="DefaultOutputDirectory"/>. The default directory is not created here
/// (RenderManager.SetupCaptureDirectory creates it).
/// </summary>
public static string EffectiveOutputDirectory
{
get
@@ -42,11 +77,27 @@ internal static class GlobalSettings
}
}
/// <summary>Default file name format template: "{date} {time}".</summary>
public const string DefaultFileNameFormat = "{date} {time}";
/// <summary>
/// File name format used for renders without an explicit output path: Current.FileNameFormat when it is set,
/// otherwise <see cref="DefaultFileNameFormat"/>.
/// </summary>
public static string EffectiveFileNameFormat =>
string.IsNullOrWhiteSpace(Current.FileNameFormat)
? DefaultFileNameFormat
: Current.FileNameFormat;
static GlobalSettings()
{
Load();
}
/// <summary>
/// Replaces <see cref="Current"/> with the content of the settings file. A missing, empty or unreadable file gives the
/// default settings (an unreadable file is logged as a warning).
/// </summary>
public static void Load()
{
try
@@ -66,6 +117,10 @@ internal static class GlobalSettings
Current = new GlobalConfig();
}
/// <summary>
/// Writes <see cref="Current"/> as indented JSON to the settings file, creating the directory when needed.
/// Errors are logged as warnings.
/// </summary>
public static void Save()
{
try
+24
View File
@@ -4,14 +4,30 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// MonoBehaviour that runs before every other script's Update (execution order int.MinValue; scripts run from the
/// lowest order to the highest). Lives on a DontDestroyOnLoad object created by RenderMod.
/// It performs the start-up checks (FFmpeg libraries present, NVIDIA driver version) and then advances the render
/// frame counter through RenderManager.OnPreUpdate once per Unity frame, so every other script of that frame sees
/// the same render time.
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
/// </summary>
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{
/// <summary>True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed).</summary>
private bool _checkedFFmpeg;
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
private bool _checkedNvidiaDriver;
/// <summary>
/// Main thread, every frame. The checks are done in order and each one blocks the following steps while its dialog
/// is open: the frame counter is not advanced until both checks are done.
/// </summary>
private void Update()
{
// 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI.
if (!_checkedFFmpeg)
{
if (FFmpegDownloadUI.Instance) return;
@@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
_checkedFFmpeg = true;
}
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
@@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
CheckNvidiaDriverWarning();
}
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreUpdate();
}
}
/// <summary>
/// Shows a warning dialog when the active GPU is an NVIDIA GPU with a driver version below 610.00.
/// No dialog is shown when the GPU is not NVIDIA or the version cannot be determined (GPUUtil returns null).
/// Buttons: open the NVIDIA driver download page and quit the game, disable the render feature for this session
/// (RenderMod.IsFeatureDisabled), or continue.
/// </summary>
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
+54
View File
@@ -6,16 +6,58 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// Runs the frame hooks (session and global, see RenderSession) on a captured frame before it is read back.
/// Owns two RenderTextures (ping and pong) with the size and format of the source frame; each hook pass reads the
/// current frame and writes the other buffer (see FrameHookContext). One instance per render, created in
/// RenderManager.SetupPlay and disposed when the render ends. Main thread only.
/// </summary>
internal class FrameHookPipeline : IDisposable
{
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
private RenderTexture? _pingRT;
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
private RenderTexture? _pongRT;
/// <summary>
/// Buffer returned as the output of the previous Process call (ping or pong), or null when the previous frame was
/// not modified by any hook. Its readback may still be in flight, so the next frame starts writing the other buffer.
/// </summary>
private RenderTexture? _lastOutputRT;
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
private FrameHookContext? _reusableContext;
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
private int _width;
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
private int _height;
/// <summary>Format of the source texture the current buffers were created for.</summary>
private RenderTextureFormat _format;
/// <summary>
/// Runs <paramref name="hooks"/> in order on <paramref name="sourceTexture"/> and returns the resulting texture.
/// An exception thrown by a hook is logged and the remaining hooks still run.
/// RenderTexture.active is restored after the hooks.
/// </summary>
/// <param name="sourceTexture">Captured game frame. Hooks do not write into it (they write the ping-pong buffers).</param>
/// <param name="frameIndex">Video frame index (0-based) of this frame.</param>
/// <param name="renderFrameIndex">
/// Render frame index (0-based, RenderManager.frameCountForVideo): counts every game frame since capture started,
/// including game frames that were not captured because they fall in an already captured video frame.
/// </param>
/// <param name="videoTime">Time of this video frame in seconds from the start of the video.</param>
/// <param name="renderFps">Render frame rate (game frames per second).</param>
/// <param name="videoFps">Effective video frame rate (min(VideoFPS, RenderFPS)).</param>
/// <param name="hooks">Hooks to run, in order. Session hooks come before global hooks.</param>
/// <returns>
/// <paramref name="sourceTexture"/> when there are no hooks, the source is not created, the buffers could not be
/// created, or no hook modified the frame; otherwise the texture holding the final frame (a ping-pong buffer, or a
/// texture a hook passed to FrameHookContext.ReplaceCurrent).
/// </returns>
public RenderTexture? Process(
RenderTexture sourceTexture,
long frameIndex,
@@ -81,6 +123,8 @@ internal class FrameHookPipeline : IDisposable
}
}
// Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in
// _lastOutputRT too; it then matches neither buffer and the next frame starts with ping.
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
@@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable
}
}
/// <summary>
/// Creates the ping and pong buffers when they do not exist, were released, or the source size or format changed.
/// The buffers copy the source descriptor without depth, MSAA and mipmaps, and enable random write (UAV) when the
/// GPU supports compute shaders and random write for that format, so compute shaders can write them.
/// When creating them from the descriptor throws, ARGB32 buffers of the same size are created.
/// </summary>
private void EnsureBuffers(RenderTexture sourceTexture)
{
var width = sourceTexture.width;
@@ -160,6 +210,9 @@ internal class FrameHookPipeline : IDisposable
}
}
/// <summary>
/// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again.
/// </summary>
public void ReleaseBuffers()
{
if (_pingRT)
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
_reusableContext = null;
}
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
public void Dispose()
{
ReleaseBuffers();
+21
View File
@@ -5,10 +5,22 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// Per-frame hook attached to the scrCamera GameObject by RenderManager when the game scene of a render has loaded,
/// and destroyed again when a render is cancelled or fails.
/// Execution order int.MaxValue: its LateUpdate runs after every other script's LateUpdate of the frame.
/// Each frame it calls RenderManager.OnLastUpdate (LateUpdate) and, after the frame has been rendered
/// (end of frame), RenderManager.OnPostFrame with the texture shown on scrCamera's camera quad, which is the frame
/// that gets captured.
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
/// </summary>
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
private FieldInfo? _camQuadMeshField;
/// <summary>Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed.</summary>
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
/// <summary>
/// Main thread, once per frame after all other LateUpdate calls. Runs the last-update work of RenderManager
/// (OnLastUpdate callbacks of the session) and schedules the end-of-frame capture.
/// </summary>
private void LateUpdate()
{
if (RenderManager.Instance)
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
PostFrameAsync().Forget();
}
/// <summary>
/// Waits until the end of the current frame (after the cameras have rendered) and passes the camera quad's main
/// texture to RenderManager.OnPostFrame. The wait is cancelled when this component is destroyed.
/// The texture is null when the quad's material has no RenderTexture; OnPostFrame then only updates the audio.
/// </summary>
private async UniTaskVoid PostFrameAsync()
{
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
+46
View File
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Core;
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
public class RenderPreset
{
/// <summary>Display name of the preset. A file without a name uses the file name (without extension).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Render configuration of the preset.</summary>
public RenderConfig Config { get; set; } = null!;
/// <summary>Returns a copy with a copied configuration.</summary>
public RenderPreset Clone()
{
return new RenderPreset
@@ -26,8 +31,18 @@ public class RenderPreset
}
}
/// <summary>
/// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset
/// name) in &lt;game root&gt;/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use
/// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread.
/// </summary>
public static class PresetManager
{
/// <summary>
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 FPS video.
/// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60.
/// Video bitrates are in kbps.
/// </summary>
private static readonly List<RenderPreset> DefaultPresets = new()
{
new RenderPreset
@@ -96,6 +111,10 @@ public static class PresetManager
}
};
/// <summary>
/// &lt;root&gt;/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
/// base directory when that is unavailable.
/// </summary>
private static string GetPresetsDirectory()
{
string? root = null;
@@ -125,6 +144,10 @@ public static class PresetManager
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
/// <summary>
/// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the
/// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown.
/// </summary>
public static void EnsureDefaultPresetsCreated()
{
try
@@ -164,6 +187,12 @@ public static class PresetManager
}
}
/// <summary>
/// Saves a copy of <paramref name="config"/> as preset <paramref name="name"/>, overwriting the file of the same
/// safe file name (see GetSafeFileName).
/// </summary>
/// <exception cref="ArgumentException">The name is empty or white space.</exception>
/// <exception cref="IOException">The directory or file cannot be written (not caught).</exception>
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
@@ -195,6 +224,13 @@ public static class PresetManager
File.WriteAllText(filePath, json);
}
/// <summary>
/// Loads every *.json preset file (after creating the built-in presets on first use). Files written by older
/// versions are migrated in memory (old key names and flat properties moved into the nested configs); the files are
/// not rewritten. A file that cannot be read is logged and skipped. When two files have the same preset name
/// (case-insensitive), the first one in directory order is used. Returns copies of the built-in presets when the
/// directory does not exist.
/// </summary>
public static List<RenderPreset> GetPresets()
{
EnsureDefaultPresetsCreated();
@@ -332,6 +368,10 @@ public static class PresetManager
return presets;
}
/// <summary>
/// Renames key <paramref name="keyBefore"/> to <paramref name="targetKey"/>: a non-null old value overwrites the target
/// key, and the old key is always removed.
/// </summary>
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
@@ -341,6 +381,12 @@ public static class PresetManager
configObj.Remove(keyBefore);
}
/// <summary>
/// Deletes the file of preset <paramref name="name"/>: first the file with the safe file name; otherwise the first
/// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file
/// matches.
/// </summary>
/// <exception cref="IOException">The file with the safe file name exists but cannot be deleted (not caught).</exception>
public static void DeletePreset(string name)
{
if (string.IsNullOrWhiteSpace(name)) return;
+456 -41
View File
@@ -8,6 +8,7 @@ using System.Diagnostics;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Patch.Engine;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util;
@@ -24,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority;
namespace ADOFAIRenderer.Core;
/// <summary>
/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as
/// stage >= OnScene.
/// <list type="bullet">
/// <item><c>None</c>: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState).</item>
/// <item><c>Load</c>: RenderSession.Start was called; the game scene (scnGame) is being loaded.</item>
/// <item><c>OnScene</c>: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the
/// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage.</item>
/// <item><c>Render</c>: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown).
/// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock.</item>
/// </list>
/// </summary>
internal enum RenderStage
{
None, Load, OnScene, Render
}
/// <summary>
/// Options of the current render, copied from a <see cref="RenderSession"/> by RenderSession.Start and read by
/// RenderManager and the Harmony patches. <see cref="Reset"/> restores the defaults when the render environment is
/// restored (RenderManager.RestoreRenderEnvironment). Main thread only.
/// </summary>
internal static class RenderContext
{
/// <summary>Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded.</summary>
public static bool Auto = true;
/// <summary>
/// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing,
/// which drive the game by the render clock (frameCount / RenderFPS) instead of real time.
/// </summary>
public static bool TimePatch = true;
/// <summary>Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.</summary>
public static bool ScreenPatch = true;
/// <summary>Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory.</summary>
public static string? OutputPath;
/// <summary>Whether the default OnComplete callback shows an OS notification after a successful render.</summary>
public static bool Notification = true;
/// <summary>Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook).</summary>
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
/// <summary>Default end condition: the level is cleared (scrController state Won).</summary>
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
/// <summary>
/// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render
/// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully.
/// </summary>
public static Func<bool> EndCondition = EndConditionDefault;
/// <summary>Default completion callback: shows an OS notification for a successful render when Notification is true.</summary>
private static readonly Action<RenderResult> OnCompleteDefault = result =>
{
if (result.Success && Notification)
@@ -53,11 +91,23 @@ internal static class RenderContext
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
}
};
/// <summary>
/// Called once on the main Unity thread when the render finishes (success) or is cancelled or fails.
/// A session callback replaces the default one, so no notification is shown when a session sets OnComplete.
/// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
/// </summary>
public static Action<RenderResult> OnComplete = OnCompleteDefault;
private static readonly Action OnLastUpdateDefault = () => { };
/// <summary>
/// Called on the main Unity thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
/// game scene of the render has loaded) while the stage is Load or later.
/// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
/// </summary>
public static Action OnLastUpdate = OnLastUpdateDefault;
/// <summary>Restores every option to its default and clears the session frame hooks.</summary>
public static void Reset()
{
Auto = true;
@@ -73,15 +123,38 @@ internal static class RenderContext
}
}
/// <summary>
/// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object
/// created by RenderMod.
///
/// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene
/// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) →
/// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of
/// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread
/// (RunRenderThread) encodes them in frame order together with the audio of each frame.
///
/// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render
/// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the
/// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog.
///
/// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads
/// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful
/// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex
/// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop.
/// </summary>
internal class RenderManager : MonoBehaviour
{
// Singleton Accessors
/// <summary>The single instance, set in Awake. Unity null checks (<c>if (Instance)</c>) are used before access.</summary>
public static RenderManager Instance { get; private set; } = null!;
/// <summary>True from the OnScene stage (scene set up, countdown running) until the render ends.</summary>
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
/// <summary>True while frames are captured and encoded (Render stage). The time patches apply only then.</summary>
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
/// <summary>
/// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames.
/// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames.
/// </summary>
public const int MaxQueuedFrames = 64;
@@ -103,26 +176,66 @@ internal class RenderManager : MonoBehaviour
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
// Statistics
/// <summary>
/// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
/// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay.
/// </summary>
public long framesSent;
/// <summary>Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.</summary>
private readonly Stopwatch _renderStopwatch = new();
// Core Render Configuration and Path
/// <summary>
/// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time
/// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch.
/// </summary>
public int countdown;
/// <summary>
/// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when
/// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches).
/// Main thread only.
/// </summary>
public long frameCount;
/// <summary>
/// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start
/// screen once. Cleared by StopFFmpeg.
/// </summary>
public bool skipPressToStart;
/// <summary>Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).</summary>
public string levelPath = "";
/// <summary>Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).</summary>
private readonly object _abortStateLock = new();
/// <summary>The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread.</summary>
private volatile bool _isRenderCancelled;
/// <summary>The render failed (MarkFailed). Written under _abortStateLock, read by any thread.</summary>
private volatile bool _renderFailed;
/// <summary>Reason given to the first MarkFailed call of this render; null when the render has not failed.</summary>
private volatile string? _failureReason;
/// <summary>True when the render was cancelled. Thread-safe read.</summary>
// ReSharper disable once MemberCanBePrivate.Global
public bool IsRenderCancelled => _isRenderCancelled;
/// <summary>True when the render failed. Thread-safe read.</summary>
public bool IsRenderFailed => _renderFailed;
// Cancelled and failed are mutually exclusive; this is true for either (render must stop).
/// <summary>
/// True when the render was cancelled or failed (the two are mutually exclusive) and must stop.
/// Read by the render thread before each frame and by readback completions. Thread-safe read.
/// </summary>
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
/// <summary>Marks the render as cancelled unless it has already failed (a failure keeps its reason).</summary>
private void MarkCancelled()
{
lock (_abortStateLock)
@@ -131,7 +244,10 @@ internal class RenderManager : MonoBehaviour
}
}
// The first failure reason wins; ignored once the render is already cancelled or failed.
/// <summary>
/// Marks the render as failed with <paramref name="reason"/> (shown in the result message as "Rendering failed: reason").
/// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread.
/// </summary>
private void MarkFailed(string reason)
{
lock (_abortStateLock)
@@ -142,6 +258,7 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).</summary>
private void ClearAbortState()
{
lock (_abortStateLock)
@@ -152,27 +269,70 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>Error message of the RenderResult of an aborted render (failure reason, or the cancel message).</summary>
private string BuildAbortMessage() => IsRenderFailed
? $"Rendering failed: {_failureReason ?? "unknown error"}"
: "Rendering was cancelled by user.";
/// <summary>
/// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main Unity thread, the render
/// thread (audio settings) and the patches. Null until the first render starts.
/// </summary>
public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields)
/// <summary>Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame.</summary>
public RenderStage stage = RenderStage.None;
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
public double timeBase;
/// <summary>Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.</summary>
public double unscaledTimeBase;
/// <summary>
/// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
/// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
/// </summary>
public double dspTimeBase;
/// <summary>Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.</summary>
public int frameCountBase;
/// <summary>
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
/// Created in SetupPlay. Commands are added on the main Unity thread (OnPostFrame, audio patches), mixing runs on the
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
/// </summary>
public OfflineAudioMixer? AudioMixer { get; private set; }
/// <summary>Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.</summary>
public double AudioTime => frameCount / Configuration.RenderFPS;
/// <summary>
/// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with
/// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered;
/// used by the render thread only.
/// </summary>
private AudioSyncCalculator? SyncCalculator { get; set; }
/// <summary>GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.</summary>
private FrameReader? _frameReader;
/// <summary>Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.</summary>
private FrameHookPipeline? _frameHookPipeline;
/// <summary>Session hooks followed by global hooks, rebuilt when the global hook version changes.</summary>
private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
/// <summary>RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.</summary>
private int _cachedGlobalHooksVersion = -1;
/// <summary>True when _activeHooksCache is not empty.</summary>
private bool _hasActiveHooksCached;
/// <summary>True when at least one session or global frame hook is registered. Main thread.</summary>
public bool HasActiveFrameHooks
{
get
@@ -182,6 +342,11 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Rebuilds the active hook list when the global hook registrations changed since the last build.
/// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to
/// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change.
/// </summary>
private void EnsureActiveHooksUpToDate()
{
var globalVersion = RenderSession.GlobalHooksVersion;
@@ -197,12 +362,14 @@ internal class RenderManager : MonoBehaviour
_hasActiveHooksCached = _activeHooksCache.Count > 0;
}
/// <summary>Returns the cached active hook list (session hooks first, then global hooks). The list is reused.</summary>
private List<Action<FrameHookContext>> GetActiveFrameHooks()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
/// <summary>Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.</summary>
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
@@ -212,46 +379,132 @@ internal class RenderManager : MonoBehaviour
_hasActiveHooksCached = false;
}
/// <summary>
/// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling).
/// The value before the increment is the render frame index of the current frame. Main thread only.
/// </summary>
public long frameCountForVideo;
/// <summary>
/// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video
/// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only.
/// </summary>
public long lastVideoFrameIndex = -1;
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
/// <summary>
/// Video frame index of the last capture request (-1 before the first). Written on the main Unity thread, read by the
/// render thread to know when every requested frame has been sent after endRequested.
/// </summary>
// long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread)
public long lastRequestedVideoFrameIndex = -1;
/// <summary>
/// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
/// Incremented in the readback completion callback.
/// </summary>
public int readbackFailureCount;
/// <summary>Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries).</summary>
public int throttleCount;
/// <summary>True while the main Unity thread is inside the throttle wait of OnPostFrame.</summary>
public bool isThrottling;
/// <summary>
/// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled)
/// or a managed buffer rented from ArrayPool&lt;byte&gt;.Shared (black frame for a failed readback).
/// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise.
/// </summary>
internal struct CapturedFrame
{
/// <summary>Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame.</summary>
public IntPtr NativePointer;
/// <summary>Number of valid bytes of the frame data.</summary>
public int Length;
/// <summary>Managed frame data rented from ArrayPool&lt;byte&gt;.Shared (may be longer than Length); null for a native frame.</summary>
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
/// <summary>Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped.</summary>
public ReadbackRequestState? ReadbackState; // Associated state to recycle
/// <summary>True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.</summary>
public bool IsColorConverted;
}
// Thread and Queue State
/// <summary>
/// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order;
/// the render thread takes them in index order (passCount). Every access locks the list itself.
/// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the
/// cleanup in StopFFmpeg / RestoreRenderEnvironment.
/// </summary>
internal SortedList<long, CapturedFrame> ImageQueue = new();
/// <summary>Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it.</summary>
internal AutoResetEvent FrameAvailableEvent = new(false);
// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
/// <summary>Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.</summary>
internal AutoResetEvent FrameDequeuedEvent = new(false);
/// <summary>The render thread of the current render (background, AboveNormal priority); null before the first render.</summary>
public Thread? RenderThread;
/// <summary>
/// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards
/// read and written only by the render thread, under the ImageQueue lock.
/// </summary>
public long passCount;
/// <summary>Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.</summary>
public volatile bool endRequested;
/// <summary>Set by the render thread after the output file was finished and the encoder released without an abort.</summary>
public volatile bool endSuccessful;
/// <summary>
/// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of
/// render time later.
/// </summary>
public long endFrame = -1;
// Private Instance Fields
/// <summary>Encoder of the current render; null when none runs. Cleared when the render thread releases it.</summary>
private FFmpegNativeEncoder? _activeVideoFFmpeg;
/// <summary>
/// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself
/// (the render thread removes its encoder when it finishes).
/// </summary>
private readonly List<FFmpegNativeEncoder> _allVideoFFmpegInstances = new();
/// <summary>The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.</summary>
private int _originalInputOffset;
/// <summary>Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.</summary>
private GameObject? _debugMonitorObj;
/// <summary>ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg.</summary>
private GameObject? _escapeHandlerObj;
/// <summary>
/// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's
/// file was deleted.
/// </summary>
internal string? CurrentOutPath;
// Output file of the current render that is not finished yet.
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
private string? _incompleteOutputPath;
/// <summary>
/// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update
/// hides the difficulty UI and waits for scnGame to finish loading.
/// </summary>
private bool _renderSetup;
private void Awake()
@@ -264,6 +517,10 @@ internal class RenderManager : MonoBehaviour
CleanUpActiveRender();
}
/// <summary>
/// Sets the stage to None. <paramref name="success"/> true clears the cancelled flag; false marks the render as
/// cancelled (unless it already failed). Called at every end path before StopFFmpeg.
/// </summary>
public void ResetRenderState(bool success)
{
if (success) _isRenderCancelled = false;
@@ -271,13 +528,20 @@ internal class RenderManager : MonoBehaviour
stage = RenderStage.None;
}
/// <summary>
/// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases
/// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the
/// unfinished output file. Does not restore the game scene or settings. Main thread.
/// </summary>
public void CleanUpActiveRender()
{
if (stage != RenderStage.None)
MarkFailed("the game exited or RenderManager was destroyed during rendering.");
ResetRenderState(success: false);
AudioSpectrumGenerator.Clear();
// Cancels the render thread's audio waits, then releases the clip store (decoder thread, readers, chunks).
AudioMixer?.Dispose(); AudioMixer = null;
ResetFrameHookState();
@@ -340,8 +604,16 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Main thread, every frame.
/// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen.
/// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene).
/// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is
/// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true.
/// </summary>
private void Update()
{
// scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
if (_renderSetup && ADOBase.lm)
{
ADOBase.lm.hideDifficultyUI = true;
@@ -350,18 +622,24 @@ internal class RenderManager : MonoBehaviour
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
{
_renderSetup = false;
// scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to
// the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs).
scrCamera.instance.forceRTCam = true;
if (!scrCamera.instance.gameObject.GetComponent<PostRenderHook>())
scrCamera.instance.gameObject.AddComponent<PostRenderHook>();
// The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor);
// the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment.
_originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0;
// Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true);
// No-fail with an infinite hit margin, so a missed hit does not fail the level during the render.
if (scrController.instance)
{
scrController.instance.noFail = true;
@@ -378,6 +656,9 @@ internal class RenderManager : MonoBehaviour
DontDestroyOnLoad(_escapeHandlerObj);
}
// While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time,
// so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment.
// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
scrController.instance!.StartCoroutine(SetupPlay());
}
@@ -398,6 +679,8 @@ internal class RenderManager : MonoBehaviour
}
else if (RenderContext.EndCondition())
{
// The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is
// still polled every frame, but endFrame is kept.
if (endFrame == -1)
{
endFrame = frameCount;
@@ -411,6 +694,9 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
/// </summary>
public void OnPreUpdate()
{
if (Instance.stage < RenderStage.OnScene) return;
@@ -422,6 +708,10 @@ internal class RenderManager : MonoBehaviour
private List<scrFloor>? _holdFloorsSource;
private int _holdFloorsSourceCount = -1;
/// <summary>
/// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage.
/// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate.
/// </summary>
public void OnLastUpdate()
{
if (Instance.stage < RenderStage.Load) return;
@@ -473,13 +763,32 @@ internal class RenderManager : MonoBehaviour
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
}
/// <summary>
/// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage:
/// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect
/// AudioSources that were mapped to mixer voices.
/// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind).
/// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback.
/// With RenderFPS above the effective video FPS, only some game frames are captured.
/// </summary>
/// <param name="texture">Frame shown by the camera quad; null skips throttling and capture.</param>
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
if (Instance.stage < RenderStage.Render || !texture) return;
if (Instance.stage < RenderStage.Render) return;
// Audio commands of this frame are queued: update the song voice and let the render thread mix up to the next frame.
if (AudioMixer != null)
{
AudioMixer.UpdateBGM(AudioTime, dspTimeBase);
AudioMixer.Commit((frameCount + 1) / Configuration.RenderFPS);
EffectSource.DeactivateMappedSources(AudioMixer);
}
if (!texture) return;
// [Backpressure Throttling]
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
// The main Unity thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
isThrottling = false;
@@ -516,10 +825,13 @@ internal class RenderManager : MonoBehaviour
var currentFrame = frameCountForVideo++;
// The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS).
var renderFPS = Configuration.RenderFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
// Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down.
// Only the first game frame of each video frame is captured.
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
@@ -546,6 +858,13 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Handler of the render button in the level editor (added by scnEditorPatch).
/// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts.
/// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from
/// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again
/// when the editor starts) and opens the render configuration window. Main thread.
/// </summary>
public static void StartRender()
{
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
@@ -569,12 +888,18 @@ internal class RenderManager : MonoBehaviour
});
}
/// <summary>Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist.</summary>
public void SetupCaptureDirectory()
{
var renderDir = GlobalSettings.EffectiveOutputDirectory;
FileUtil.CreateDirectory(renderDir);
}
/// <summary>
/// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the
/// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set,
/// so random effects repeat between renders. Called by RenderSession.Start.
/// </summary>
public void SetupGameScene()
{
GCS.customLevelPaths = new[] { levelPath };
@@ -589,8 +914,18 @@ internal class RenderManager : MonoBehaviour
_renderSetup = true;
}
/// <summary>
/// Coroutine started on scrController when the game scene is set up (stage OnScene).
/// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the
/// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the
/// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the
/// countdown. On an encoder initialization error, the render fails, the environment is restored and an error
/// dialog returns to the editor.
/// </summary>
private IEnumerator SetupPlay()
{
// The audio sample rate must be one the audio codec paired with the video codec supports
// (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used.
if (Configuration.AudioConfig.RenderAudio)
{
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
@@ -605,9 +940,19 @@ internal class RenderManager : MonoBehaviour
// Cancelled or failed during the countdown: do not start the encoder
if (stage != RenderStage.OnScene) yield break;
if (Configuration.AdvancedConfig.DisableTileCulling && scrLevelMaker.instance)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
// Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are
// Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS.
timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime;
@@ -619,6 +964,7 @@ internal class RenderManager : MonoBehaviour
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
// The frame reader converts on the GPU to the pixel format of the encoder that was actually opened.
try
{
StartFFmpegThread();
@@ -647,28 +993,37 @@ internal class RenderManager : MonoBehaviour
}
Instance.stage = RenderStage.Render;
// levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo).
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
if (!scrConductor.instance || !scrConductor.instance.song) yield break;
if (Configuration.AdvancedConfig.ProcessAudio)
{
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip);
}
if (Configuration.AudioConfig.RenderAudio)
{
AudioMixer = new OfflineAudioMixer(Configuration.AudioConfig.AudioChannels, Configuration.AudioConfig.AudioSampleRate);
AudioMixer.InitializeBGM(scrConductor.instance.song.clip);
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
SyncCalculator = new AudioSyncCalculator(Configuration.AudioConfig.AudioSampleRate, effectiveVideoFps);
}
if (Configuration.AdvancedConfig.ProcessAudio)
{
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
}
// The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity
// does not play it.
scrConductor.instance.song.clip = null;
scrConductor.instance.song.time = -1000;
}
/// <summary>
/// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or
/// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread.
/// The output path is registered as incomplete before the encoder opens the file.
/// </summary>
/// <exception cref="Exception">The encoder could not be created or started; it is disposed before the exception is rethrown.</exception>
private void StartFFmpegThread()
{
if (!_debugMonitorObj)
@@ -698,9 +1053,20 @@ internal class RenderManager : MonoBehaviour
var startTime = DateTime.Now;
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
else {
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
var format = GlobalSettings.EffectiveFileNameFormat;
var ext = Configuration.AdvancedConfig.FileExtension;
var baseName = RenderFileNameUtil.FormatFileName(
format,
startTime,
Configuration,
GlobalSettings.Current.LastPresetName,
levelPath
);
CurrentOutPath = RenderFileNameUtil.GetUniqueFilePath(
GlobalSettings.EffectiveOutputDirectory,
baseName,
ext
);
}
FFmpegNativeEncoder? videoFFmpeg = null;
@@ -754,6 +1120,7 @@ internal class RenderManager : MonoBehaviour
throw;
}
// Background thread: it does not keep the process alive when the game quits.
var encoder = videoFFmpeg;
RenderThread = new Thread(() => RunRenderThread(encoder))
{
@@ -786,23 +1153,35 @@ internal class RenderManager : MonoBehaviour
endSuccessful = !IsRenderAborted;
}
catch (OperationCanceledException) when (IsRenderAborted)
{
// Audio waits end with OperationCanceledException when the render is cancelled or has failed (OfflineAudioMixer.Cancel).
Melon<RenderMod>.Logger.Msg("Render thread stopped: the render was aborted.");
DisposeEncoderAfterStop(videoFFmpeg);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
MarkFailed("encoder thread error. See the log for details.");
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon<RenderMod>.Logger.Error($"Encoder dispose after thread error also failed: {disposeEx.Message}");
}
UnregisterEncoder(videoFFmpeg);
DisposeEncoderAfterStop(videoFFmpeg);
}
}
/// <summary>Releases the encoder after the render thread stopped early (abort or error) and removes it from the encoder list.</summary>
private void DisposeEncoderAfterStop(FFmpegNativeEncoder videoFFmpeg)
{
try
{
videoFFmpeg.Dispose();
}
catch (Exception disposeEx)
{
Melon<RenderMod>.Logger.Error($"Encoder dispose after the render thread stopped also failed: {disposeEx.Message}");
}
UnregisterEncoder(videoFFmpeg);
}
/// <summary>
/// Takes frames from ImageQueue in passCount order and sends each one to the encoder, followed by the audio
/// samples of that frame. Waits on FrameAvailableEvent (50ms) while no frame is ready.
@@ -830,8 +1209,10 @@ internal class RenderManager : MonoBehaviour
}
/// <summary>
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
/// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
/// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
/// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater:
/// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end
/// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped.
/// Returns false when the queue is empty or the next frame has not arrived yet.
/// </summary>
private bool TryDequeueNextFrame(out CapturedFrame frame)
@@ -858,7 +1239,7 @@ internal class RenderManager : MonoBehaviour
/// <summary>
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
/// A frame without data is skipped.
/// A frame without data is skipped (not counted in framesSent). Render thread.
/// </summary>
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
{
@@ -886,23 +1267,22 @@ internal class RenderManager : MonoBehaviour
/// <summary>
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
/// Does nothing when audio rendering is off.
/// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder
/// carried to later frames). Mixing waits until the main Unity thread has committed the audio commands up to that time.
/// Does nothing when audio rendering is off. Render thread.
/// </summary>
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
{
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
var frameIdx = (int)(passCount - 1);
var effectiveVideoFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
var frameTime = frameIdx / effectiveVideoFps;
var channels = Configuration.AudioConfig.AudioChannels;
var samplesNeeded = syncCalculator.GetNextFrameSamples();
var audioBuffer = ArrayPool<float>.Shared.Rent(samplesNeeded * channels);
try
{
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded, frameTime, dspTimeBase);
AudioMixer?.GenerateAudioForFrame(audioBuffer, samplesNeeded);
videoFFmpeg.WriteAudioSamples(audioBuffer, samplesNeeded);
}
finally
@@ -921,6 +1301,7 @@ internal class RenderManager : MonoBehaviour
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
}
/// <summary>Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes.</summary>
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
{
lock (_allVideoFFmpegInstances)
@@ -930,6 +1311,11 @@ internal class RenderManager : MonoBehaviour
_activeVideoFFmpeg = null;
}
/// <summary>
/// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes
/// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor.
/// Called by the ESC handler and on failures. Main thread.
/// </summary>
public void CancelRender()
{
ResetRenderState(success: false);
@@ -938,6 +1324,15 @@ internal class RenderManager : MonoBehaviour
EndRender();
}
/// <summary>
/// Requests the end of encoding. Main thread.
/// <paramref name="success"/> true: completes the audio commands and starts StopRenderCoroutine, which waits for
/// the render thread to send the remaining frames and finish the file.
/// <paramref name="success"/> false: marks the render as cancelled (unless failed), cancels the audio waits, waits up
/// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and
/// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with
/// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do).
/// </summary>
private void StopFFmpeg(bool success)
{
endRequested = true;
@@ -945,6 +1340,9 @@ internal class RenderManager : MonoBehaviour
{
MarkCancelled();
}
// The abort state is set first: the render thread reads it when its audio wait is cancelled.
if (success) AudioMixer?.Complete();
else AudioMixer?.Cancel();
FrameAvailableEvent.Set();
skipPressToStart = false;
_renderSetup = false;
@@ -966,7 +1364,8 @@ internal class RenderManager : MonoBehaviour
RenderThread.Join(2000);
}
// 2. After ensuring thread termination, safely release FFmpeg native encoder resources
// 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null).
// When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it.
if (_activeVideoFFmpeg != null)
{
_activeVideoFFmpeg.Dispose();
@@ -1028,6 +1427,11 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>
/// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died.
/// On success calls OnComplete with a success result and EndRender; when the thread died without finishing
/// the file, the render fails and is cancelled (file deleted).
/// </summary>
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
@@ -1044,6 +1448,7 @@ internal class RenderManager : MonoBehaviour
EndRender();
}
/// <summary>Restores the render environment and loads the level editor (scnEditor).</summary>
private void EndRender()
{
RestoreRenderEnvironment();
@@ -1052,7 +1457,11 @@ internal class RenderManager : MonoBehaviour
Cursor.visible = true;
}
// Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
/// <summary>
/// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level
/// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without
/// leaving the scene.
/// </summary>
private void RestoreRenderEnvironment()
{
Time.timeScale = 1f;
@@ -1087,14 +1496,18 @@ internal class RenderManager : MonoBehaviour
_frameReader?.Dispose();
ResetFrameHookState();
AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator = null;
AudioSpectrumGenerator.Clear();
AudioMixer?.Dispose(); AudioMixer = null;
SyncCalculator = null;
RenderContext.Reset();
}
/// <summary>
/// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS),
/// wall-clock render time, and the output path and file size on success.
/// </summary>
private RenderResult BuildResult(bool success, string? errorMessage = null)
{
_renderStopwatch.Stop();
@@ -1106,6 +1519,7 @@ internal class RenderManager : MonoBehaviour
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
}
/// <summary>Invokes RenderContext.OnComplete; an exception from the callback is logged.</summary>
private static void TriggerOnComplete(RenderResult result)
{
try
@@ -1118,6 +1532,7 @@ internal class RenderManager : MonoBehaviour
}
}
/// <summary>Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.</summary>
private static void TriggerOnLastUpdate()
{
try
+12
View File
@@ -4,14 +4,22 @@ using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Video codec of the output. The encoder implementation (hardware or software) is chosen at run time from the
/// encoders available for the codec (VideoEncoder, GPUUtil.GetPrioritizedEncoders).
/// Values: <c>H264</c> (H.264 / AVC), <c>H265</c> (H.265 / HEVC), <c>VP9</c>, <c>AV1</c>, <c>VVC</c> (H.266).
/// The audio codec paired with each value is given by CodecUtil.AudioCodecMatch.
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
/// <summary>Audio codec choice and supported sample rates per video codec.</summary>
internal static class CodecUtil
{
/// <summary>Audio codec encoded together with each video codec (AAC for H.264/H.265, Opus for the others).</summary>
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
@@ -21,6 +29,10 @@ internal static class CodecUtil
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
/// <summary>
/// Output sample rates (Hz) the mod allows per audio codec. RenderManager.SetupPlay replaces a configured rate that
/// is not in the list with 48000 Hz.
/// </summary>
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
{
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
+18
View File
@@ -10,13 +10,20 @@ namespace ADOFAIRenderer.Data;
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
/// </summary>
/// <remarks>
/// Reading accepts "#RRGGBB" (alpha 1), "#RRGGBBAA" (the '#' is optional), an object {"r","g","b","a"} with 0–1 floats
/// (missing r/g/b = 0, missing a = 1), or null. Invalid or unsupported input reads as null for Color? and as
/// default(Color) (all components 0) for Color; no exception is thrown for an unparsable string.
/// </remarks>
public class ColorJsonConverter : JsonConverter
{
/// <summary>True for <see cref="Color"/> and <see cref="Nullable{Color}"/>.</summary>
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Color) || objectType == typeof(Color?);
}
/// <summary>Writes the color as "#RRGGBBAA" (see <see cref="ToHex"/>), or a JSON null for a null value.</summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
@@ -29,6 +36,7 @@ public class ColorJsonConverter : JsonConverter
writer.WriteValue(ToHex(color));
}
/// <summary>Reads a color in one of the accepted forms (see the class remarks).</summary>
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
@@ -62,6 +70,10 @@ public class ColorJsonConverter : JsonConverter
return isNullable ? null : (object)default(Color);
}
/// <summary>
/// Formats the color as "#RRGGBBAA" with uppercase hex digits. Each component is scaled to 0–255, rounded and clamped.
/// The components are written as stored (no gamma conversion).
/// </summary>
public static string ToHex(Color color)
{
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
@@ -71,6 +83,12 @@ public class ColorJsonConverter : JsonConverter
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
}
/// <summary>
/// Parses "RRGGBB" or "RRGGBBAA" (optional leading '#', surrounding white space ignored, case-insensitive).
/// </summary>
/// <param name="hex">Text to parse.</param>
/// <param name="color">Parsed color (components / 255; alpha 1 for the 6-digit form), or default when parsing fails.</param>
/// <returns>True when the text was parsed.</returns>
public static bool TryParseHex(string? hex, out Color color)
{
color = default;
+9
View File
@@ -2,10 +2,19 @@
namespace ADOFAIRenderer.Data;
/// <summary>Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings).</summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
/// <summary>Constant bitrate at VideoEncodingConfig.Bitrate (kbps).</summary>
CBR,
/// <summary>
/// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger,
/// otherwise at 1.5 × Bitrate.
/// </summary>
VBR,
/// <summary>Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder).</summary>
CQP
}
+272 -156
View File
@@ -1,156 +1,272 @@
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
public double RenderFPS { get; set; }
public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
public void CopyFrom(RenderConfig? source)
{
if (source == null) return;
RenderFPS = source.RenderFPS;
VideoFPS = source.VideoFPS;
RenderWidth = source.RenderWidth;
RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText;
VideoConfig.CopyFrom(source.VideoConfig);
AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
}
public RenderConfig Clone()
{
var clone = new RenderConfig();
clone.CopyFrom(this);
return clone;
}
}
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null;
public void CopyFrom(AdvancedConfig? source)
{
if (source == null) return;
RandomSeed = source.RandomSeed;
ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor;
windPlanetColor = source.windPlanetColor;
}
public AdvancedConfig Clone()
{
var clone = new AdvancedConfig();
clone.CopyFrom(this);
return clone;
}
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
public void CopyFrom(VideoEncodingConfig? source)
{
if (source == null) return;
Codec = source.Codec;
RateControlMode = source.RateControlMode;
Bitrate = source.Bitrate;
MaxBitrate = source.MaxBitrate;
QP = source.QP;
KeyframeInterval = source.KeyframeInterval;
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
}
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
RenderAudio = source.RenderAudio;
AudioSampleRate = source.AudioSampleRate;
AudioChannels = source.AudioChannels;
AudioBitrate = source.AudioBitrate;
}
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}␍
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
/// files (PresetManager). <see cref="ADOFAIRenderer.API.RenderSession.Start"/> renders with a copy (<see cref="Clone"/>).
/// </summary>
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
/// <summary>
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
/// (the game state then advances in smaller time steps).
/// </summary>
public double RenderFPS { get; set; }
/// <summary>
/// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
/// </summary>
public double VideoFPS { get; set; }
/// <summary>Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).</summary>
public int RenderWidth { get; set; }
/// <summary>Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).</summary>
public int RenderHeight { get; set; }
/// <summary>Video encoder settings.</summary>
public VideoEncodingConfig VideoConfig { get; set; } = new();
/// <summary>Audio rendering and encoder settings.</summary>
public AudioEncodingConfig AudioConfig { get; set; } = new();
/// <summary>Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).</summary>
public AdvancedConfig AdvancedConfig { get; set; } = new();
/// <summary>Seconds of render time recorded after the end condition first holds (default 15).</summary>
public double DelayAfterEnd { get; set; } = 15;
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
public bool ShowJudgementText { get; set; } = false;
/// <summary>
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
/// </summary>
public void CopyFrom(RenderConfig? source)
{
if (source == null) return;
RenderFPS = source.RenderFPS;
VideoFPS = source.VideoFPS;
RenderWidth = source.RenderWidth;
RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText;
VideoConfig.CopyFrom(source.VideoConfig);
AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
}
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
public RenderConfig Clone()
{
var clone = new RenderConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>Advanced render options.</summary>
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
/// <summary>
/// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
/// null leaves the random state unchanged.
/// </summary>
public int? RandomSeed { get; set; } = null;
/// <summary>
/// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time
/// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true.
/// </summary>
public bool ProcessAudio { get; set; } = true;
/// <summary>
/// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the
/// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch,
/// RenderManager.OnLastUpdate). Default false.
/// </summary>
public bool NonTile { get; set; } = false;
/// <summary>
/// When true, the game's bloom (<c>VideoBloom</c>) computes its blur size as if the frame were 720 pixels high, so the
/// blur passes on the 198-pixel-high bloom buffer are the same as in a 720p frame at any render height
/// (VideoBloomPatch). Default false.
/// </summary>
public bool HighResBloomCorrection { get; set; } = false;
/// <summary>
/// When true, fixes in-game and render bugs during SetFramerate events (where objects disappear on the first frame
/// or parallax/tiles lag behind) by deferring manual camera rendering to the end of the frame after all LateUpdates.
/// Default true.
/// </summary>
public bool FixCustomFrameRate { get; set; } = true;
/// <summary>
/// When true, prevents floor tiles from being disabled when outside the camera frustum (scrFloor.OnBecameInvisible),
/// eliminating the 1-frame update lag/glitch when tiles enter the screen. Default false.
/// </summary>
public bool DisableTileCulling { get; set; } = false;
/// <summary>
/// When true, hides placeholder decorations (default tree-in-circle sprite) that failed to load or are empty.
/// Default false.
/// </summary>
public bool HideFailedDecorations { get; set; } = false;
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
public string FileExtension { get; set; } = "mkv";
/// <summary>Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null;
/// <summary>Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
/// <summary>
/// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
/// Serialized as "#RRGGBBAA".
/// </summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null;
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(AdvancedConfig? source)
{
if (source == null) return;
RandomSeed = source.RandomSeed;
ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
HighResBloomCorrection = source.HighResBloomCorrection;
FixCustomFrameRate = source.FixCustomFrameRate;
DisableTileCulling = source.DisableTileCulling;
HideFailedDecorations = source.HideFailedDecorations;
FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor;
windPlanetColor = source.windPlanetColor;
}
/// <summary>Returns a copy of this object.</summary>
public AdvancedConfig Clone()
{
var clone = new AdvancedConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
public class VideoEncodingConfig
{
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
public Codec Codec { get; set; } = Codec.H264;
/// <summary>Bitrate control mode (default CBR).</summary>
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
/// <summary>Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).</summary>
public int Bitrate { get; set; } = 30000;
/// <summary>Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.</summary>
public int MaxBitrate { get; set; } = 50000;
/// <summary>
/// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
/// selects 18. Default 18.
/// </summary>
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
/// <summary>Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.</summary>
public double KeyframeInterval { get; set; } = 2.0;
/// <summary>When true, only software (CPU) encoders are tried; hardware encoders are skipped.</summary>
public bool ForceSoftwareEncoder { get; set; }
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(VideoEncodingConfig? source)
{
if (source == null) return;
Codec = source.Codec;
RateControlMode = source.RateControlMode;
Bitrate = source.Bitrate;
MaxBitrate = source.MaxBitrate;
QP = source.QP;
KeyframeInterval = source.KeyframeInterval;
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
}
/// <summary>Returns a copy of this object.</summary>
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>
/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
/// (48000 Hz, 2 channels, 256000 bps).
/// </summary>
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
/// <summary>When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.</summary>
public bool RenderAudio { get; set; } = true;
/// <summary>
/// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod
/// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts.
/// </summary>
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
/// <summary>Number of output audio channels (default 2).</summary>
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
/// <summary>Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).</summary>
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
RenderAudio = source.RenderAudio;
AudioSampleRate = source.AudioSampleRate;
AudioChannels = source.AudioChannels;
AudioBitrate = source.AudioBitrate;
}
/// <summary>Returns a copy of this object.</summary>
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
+7
View File
@@ -39,9 +39,16 @@
"ui.render_config.output_dir": "Output Directory",
"ui.render_config.browse": "Browse...",
"ui.render_config.reset_default": "Reset to Default",
"ui.render_config.filename_format": "Filename Format",
"ui.render_config.filename_format_hint": "Tags: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
"ui.render_config.reset_filename_default": "Reset to Default",
"ui.render_config.file_extension": "File Extension",
"ui.render_config.process_audio": "Process Audio",
"ui.render_config.non_tile": "Non-Tile Mode",
"ui.render_config.high_res_bloom_correction": "High-Resolution Bloom Correction",
"ui.render_config.fix_custom_frame_rate": "Fix Custom FPS Render Timing",
"ui.render_config.disable_tile_culling": "Disable Tile Culling",
"ui.render_config.hide_failed_decorations": "Hide Failed Decorations",
"ui.render_config.random_seed": "Random Seed (Optional)",
"ui.render_config.validation_title": "Invalid Settings",
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
+7
View File
@@ -39,9 +39,16 @@
"ui.render_config.output_dir": "출력 디렉토리",
"ui.render_config.browse": "찾아보기...",
"ui.render_config.reset_default": "기본값으로 초기화",
"ui.render_config.filename_format": "파일 이름 형식",
"ui.render_config.filename_format_hint": "태그: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
"ui.render_config.reset_filename_default": "기본값으로 초기화",
"ui.render_config.file_extension": "파일 확장자",
"ui.render_config.process_audio": "오디오 처리",
"ui.render_config.non_tile": "논타일 모드",
"ui.render_config.high_res_bloom_correction": "고해상도 블룸 보정",
"ui.render_config.fix_custom_frame_rate": "커스텀 FPS 렌더 타이밍 보정",
"ui.render_config.disable_tile_culling": "타일 컬링 비활성화",
"ui.render_config.hide_failed_decorations": "로딩 실패한 장식 숨기기",
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류",
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
+56 -5
View File
@@ -9,29 +9,49 @@ namespace ADOFAIRenderer.Localization;
/// <summary>
/// Provides access to localized strings and handles language synchronization with the game.
/// <para>
/// Locale files are flat JSON objects (<c>"key": "text"</c>) under <c>Localization/Locale/</c>, embedded in the mod
/// assembly as resources; the file name without extension is the locale code (<c>en.json</c> -> "en").
/// The active locale follows the game language: <c>LanguageSyncPatch</c> calls <see cref="SetLanguage"/> after the game's
/// <c>RDString.Setup</c> and <c>RDString.ChangeLanguage</c>. Only Korean maps to its own locale ("ko"); every other
/// language uses English ("en").
/// </para>
/// <para>
/// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself.
/// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards.
/// </para>
/// </summary>
internal static class LocalizationManager
{
/// <summary>Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive.</summary>
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Locale used when the language has no locale file and for keys missing in the active locale.</summary>
private static readonly string FallbackLocaleCode = "en";
/// <summary>
/// Event triggered when the active language changes.
/// Raised by <see cref="SetLanguage"/> with <c>notify: true</c> when the language or the resolved locale differs from
/// the current one; the argument is the new game language. Exceptions from handlers are caught and logged
/// (an exception stops the remaining handlers of that invocation).
/// </summary>
public static event Action<SystemLanguage>? OnLanguageChanged;
/// <summary>
/// Currently active SystemLanguage.
/// Currently active SystemLanguage: the game language as passed to <see cref="SetLanguage"/>, also when it has no
/// locale file of its own (e.g. Japanese, which displays English text).
/// </summary>
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
/// <summary>
/// Currently active locale code (e.g. "en", "ko").
/// Currently active locale code (e.g. "en", "ko"): the locale whose strings <see cref="Get(string)"/> returns first.
/// Always a loaded locale or the fallback "en".
/// </summary>
public static string CurrentLocaleCode { get; private set; } = "en";
/// <summary>Set by the first <see cref="Initialize"/> call; later calls return immediately.</summary>
private static bool _initialized;
/// <summary>Initializes on first access to any member, so lookups work before <c>RenderMod</c> calls <see cref="Initialize"/>.</summary>
static LocalizationManager()
{
Initialize();
@@ -39,6 +59,8 @@ internal static class LocalizationManager
/// <summary>
/// Initializes the localization system and loads embedded language dictionaries.
/// Runs once; further calls do nothing. Selects the initial language without raising
/// <see cref="OnLanguageChanged"/>.
/// </summary>
public static void Initialize()
{
@@ -47,19 +69,26 @@ internal static class LocalizationManager
LoadEmbeddedLocales();
// Detect initial language from game settings
// Detect initial language from game settings.
// At mod initialization RDString.Setup has usually not run yet, so this reads the field's initial value
// (English); LanguageSyncPatch applies the real language when the game calls RDString.Setup.
var initialLang = DetectGameLanguage();
SetLanguage(initialLang, notify: false);
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
}
/// <summary>
/// Logs through the MelonLoader logger of the mod, or to the console when that logger is not available
/// (e.g. when this class is used outside the running mod).
/// </summary>
private static void LogMsg(string msg)
{
try { Melon<RenderMod>.Logger.Msg(msg); }
catch { Console.WriteLine(msg); }
}
/// <summary>Error counterpart of <see cref="LogMsg"/>; falls back to standard error.</summary>
private static void LogError(string msg)
{
try { Melon<RenderMod>.Logger.Error(msg); }
@@ -68,6 +97,9 @@ internal static class LocalizationManager
/// <summary>
/// Loads all *.json locale files embedded in this assembly.
/// A resource is used when its name ends with ".json" and contains "Localization.Locale" (the manifest name built
/// from the folder path, see the EmbeddedResource item in the .csproj). A file that fails to read or parse is
/// logged and skipped; a later resource with the same locale code replaces an earlier one.
/// </summary>
private static void LoadEmbeddedLocales()
{
@@ -109,7 +141,13 @@ internal static class LocalizationManager
/// <summary>
/// Sets the active language and notifies subscribers if changed.
/// The locale is chosen by <see cref="LanguageToLocaleCode"/>; when that locale is not loaded, "en" is used.
/// </summary>
/// <param name="language">Game language (as used by <c>RDString.language</c>).</param>
/// <param name="notify">
/// Whether to raise <see cref="OnLanguageChanged"/> when the language or locale changes; <c>false</c> is used for
/// the initial selection.
/// </param>
public static void SetLanguage(SystemLanguage language, bool notify = true)
{
var localeCode = LanguageToLocaleCode(language);
@@ -140,7 +178,10 @@ internal static class LocalizationManager
/// <summary>
/// Retrieves a localized string for the specified key.
/// Order: active locale, then English, then the key itself (so a missing key is visible in the UI).
/// </summary>
/// <param name="key">Localization key, e.g. <c>"ui.render_config.window_title"</c>; case-sensitive.</param>
/// <returns>The localized text, the key when no locale has it, or an empty string for a null/empty key.</returns>
public static string Get(string key)
{
if (string.IsNullOrEmpty(key)) return string.Empty;
@@ -166,7 +207,12 @@ internal static class LocalizationManager
/// <summary>
/// Retrieves a formatted localized string.
/// The text from <see cref="Get(string)"/> is used as a <see cref="string.Format(string, object[])"/> template
/// (<c>{0}</c>, <c>{1}</c>, ...), formatted with the current culture. When the template does not match the
/// arguments (<see cref="FormatException"/>), the unformatted template is returned.
/// </summary>
/// <param name="key">Localization key.</param>
/// <param name="args">Format arguments; with none, the template is returned as is.</param>
public static string Get(string key, params object[] args)
{
var template = Get(key);
@@ -184,6 +230,7 @@ internal static class LocalizationManager
/// <summary>
/// Maps Unity's SystemLanguage enum to an internal locale code.
/// Korean -> "ko"; every other language -> "en". A new locale file also needs an entry here.
/// </summary>
private static string LanguageToLocaleCode(SystemLanguage language)
{
@@ -196,19 +243,22 @@ internal static class LocalizationManager
/// <summary>
/// Attempts to read the game's current language from RDString or Persistence.
/// Falls back to English when both reads throw.
/// </summary>
private static SystemLanguage DetectGameLanguage()
{
try
{
// First attempt: RDString.language
// First attempt: RDString.language, a public static field of the game (initial value English,
// assigned from Persistence.language in RDString.Setup)
return RDString.language;
}
catch
{
try
{
// Fallback attempt: Persistence.language
// Fallback attempt: Persistence.language, the saved "language" preference (defaults to
// Application.systemLanguage)
return Persistence.language;
}
catch
@@ -221,6 +271,7 @@ internal static class LocalizationManager
/// <summary>
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
/// Every member forwards to the member of the same name in <see cref="LocalizationManager"/>.
/// </summary>
// ReSharper disable once InconsistentNaming
internal static class I18n
+21
View File
@@ -3,10 +3,31 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Makes the async input clock offset follow the render clock immediately.
/// <para>
/// Patched member: <c>AsyncInputUtils.UpdateOffsetTime(long fixDivider = 1)</c>. The async input system maps wall-clock ticks
/// (<c>AsyncInputManager.currFrameTick</c> = <c>DateTime.Now.Ticks</c>, 100 ns units) to conductor time with
/// <c>AsyncInputManager.offsetTick</c> = currFrameTick - <c>scrConductor.dspTime</c> * 10^7. With <c>fixDivider</c> 1 the
/// offset is set directly; otherwise it moves by 1 / fixDivider of the difference per call. <c>scrConductor</c> calls it every
/// frame with divider 100 (damped); <c>scrController</c> calls it with divider 1.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). From stage Render the conductor time follows the
/// patched <c>AudioSettings.dspTime</c> (offline render clock, a fixed step per frame independent of wall-clock time), so the
/// difference changes every frame; the damped update would lag behind it. The prefix always applies the divider-1 update.
/// </para>
/// <para>Reference: https://learn.microsoft.com/en-us/dotnet/api/system.datetime.ticks</para>
/// </summary>
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{
/// <summary>
/// Prefix of <c>AsyncInputUtils.UpdateOffsetTime</c>. While rendering, sets <c>offsetTick</c> to
/// currFrameTick - dspTime * 10^7 (10^7 = 100 ns ticks per second) and <c>offsetTickUpdated</c> to true, for any divider.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix()
+282 -27
View File
@@ -1,25 +1,61 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.Audio.Clip;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Records the file path of each external clip the game loads (always active, also outside rendering,
/// because levels load their song and PlaySound files before a render starts).
/// FindOrLoadAudioClipExternal returns the clip already in audioLib when its key exists, so the path is recorded
/// only when the key is missing and the file is about to be loaded.
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
public static class AudioManagerFindOrLoadAudioClipExternalPatch
{
/// <summary>Records <paramref name="path"/> in <see cref="AudioClipPaths"/> when its audioLib key is not loaded yet. Runs the original always.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Prefix(AudioManager __instance, string path)
{
if (string.IsNullOrEmpty(path)) return;
if (__instance.audioLib.ContainsKey(AudioClipPaths.KeyOf(path))) return;
AudioClipPaths.Record(path);
}
}
/// <summary>
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled sound effect (hit sounds, PlaySound
/// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it.
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the
/// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets
/// time/pitch/SetScheduledEndTime on it afterwards, which the <see cref="EffectSource"/> patches forward to the mapped state.
/// The game volume is passed through; the start pitch is 1 because _Play sets pitch = 1 before PlayScheduled.
/// </summary>
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{
/// <summary>Opens the clip's source so its first chunk starts loading. Returns true (the original runs).</summary>
[HarmonyPrefix]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
// Cache the clip on the main thread
// Cache the clip on the main Unity thread
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
/// <summary>
/// Registers the sound at <paramref name="time"/> (absolute DSP time, converted to render time) as a non-looping effect voice,
/// maps <paramref name="__result"/> to it and queues the source's game object for deactivation at the end of the frame.
/// </summary>
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
@@ -30,14 +66,36 @@ public static class AudioManagerPlayPatch
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null) return;
mixer.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime);
EffectSource.DeactivateAtFrameEnd(__result);
}
}
}
/// <summary>
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
/// AudioManager.MakeSource is mapped to that voice (<see cref="AudioManagerMakeSourcePatch"/>).
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs. The game calls it for
/// hold sounds with an end time above 0 (scrConductor hold sound scheduling); it sets loop = true, pitch = 1, PlayScheduled(time) and
/// SetScheduledEndTime(endTime) on the new source. pitch and SetScheduledEndTime reach the mapped state through the
/// <see cref="EffectSource"/> patches; PlayScheduled is blocked during rendering by another patch.
/// </summary>
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
internal static SourceState? PendingState;
/// <summary>Clears <see cref="PendingState"/> when PlayWithEndTime returns or throws, so it cannot map a later MakeSource call.</summary>
[HarmonyFinalizer]
public static void Finalizer() => PendingState = null;
/// <summary>
/// Opens the clip's source and registers a looping voice from <paramref name="time"/> to <paramref name="endTime"/>
/// (absolute DSP times, converted to render time); the state waits in <see cref="PendingState"/>. Returns true (the original runs).
/// </summary>
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
@@ -46,7 +104,7 @@ public static class PlayWithEndTimePatch
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{
// Cache the clip on the main thread
// Cache the clip on the main Unity thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
@@ -54,59 +112,256 @@ public static class PlayWithEndTimePatch
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register looping sound event to the offline mixer (default pitch = 1.0f)
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
PendingState = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime);
}
}
return true; // Let the original method execute
}
}
/// <summary>
/// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).
/// Active only while a PlayWithEndTime call registered a state (capturing with RenderAudio on); every other MakeSource call
/// (including the one inside AudioManager._Play) is left unchanged.
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
public static class AudioManagerMakeSourcePatch
{
/// <summary>
/// Takes the pending state, maps <paramref name="__result"/> to it and queues the source's game object for deactivation at
/// the end of the frame.
/// </summary>
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result)
{
var state = PlayWithEndTimePatch.PendingState;
if (state == null || !__result) return;
PlayWithEndTimePatch.PendingState = null;
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null) return;
mixer.MapSource(__result, state);
EffectSource.DeactivateAtFrameEnd(__result);
}
}
/// <summary>
/// AudioSource members of an effect source mapped to a <see cref="SourceState"/> during rendering: the patches below skip the
/// Unity implementation and read or change the state instead (isPlaying, time, timeSamples, pitch, SetScheduledEndTime, Stop).
/// The patches apply only while <see cref="TryGet"/> returns true: rendering is capturing (RenderManager.IsCapturingActive,
/// stage Render), the render has an audio mixer (created when the render starts, cleared when it ends), and the source is mapped
/// in that mixer (mappings are created only when RenderAudio is on). Otherwise they run the Unity implementation, so sources
/// without a mapping (the song, menu sounds) and all sources outside rendering behave as in the game.
///
/// The game object of a mapped source is deactivated at the end of the frame it was mapped in: the mixer produces its sound and the
/// state answers every member above, so Unity does not process the source. The game activates it again when AudioManager.MakeSource
/// reuses it. Active AudioSources add main-thread time per frame in proportion to their count, and dense levels keep thousands of
/// scheduled effect sources alive.
/// </summary>
internal static class EffectSource
{
/// <summary>Sources mapped during the current frame, deactivated by <see cref="DeactivateMappedSources"/>. Main thread only.</summary>
private static readonly List<AudioSource> PendingDeactivation = new();
/// <summary>Deactivates the game object of <paramref name="source"/> at the end of the current frame (<see cref="DeactivateMappedSources"/>).</summary>
public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source);
/// <summary>
/// Deactivates the game objects of the sources mapped this frame that are still mapped and not destroyed.
/// Called by RenderManager.OnPostFrame after the audio commands of the frame are committed, which runs only in stage Render with
/// an audio mixer; sources are added only by the render-time registrations (AudioManager._Play and scrConductor.PlayWithEndTime
/// patches with RenderAudio on).
/// </summary>
public static void DeactivateMappedSources(OfflineAudioMixer mixer)
{
foreach (var source in PendingDeactivation)
{
if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false);
}
PendingDeactivation.Clear();
}
/// <summary>
/// State mapped to <paramref name="source"/> in the mixer of the current render, with the current render time in <paramref name="now"/>.
/// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped.
/// </summary>
public static bool TryGet(AudioSource source, out SourceState state, out double now)
{
state = null!;
now = 0;
if (!RenderManager.IsCapturingActive) return false;
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null || !mixer.TryGetSourceState(source, out state)) return false;
now = RenderManager.Instance.AudioTime;
return true;
}
}
/// <summary>
/// AudioSource.isPlaying getter (<see cref="EffectSource"/>): true from the scheduling call until Stop(), the scheduled end time or
/// the end of the clip, including before the scheduled start. AudioManager.Update returns a source to its reusable pool once this is false.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
public static class AudioSourceIsPlayingPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref bool __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.IsPlaying(now);
return false;
}
}
/// <summary>AudioSource.time getter (<see cref="EffectSource"/>): clip position in seconds while playing, 0 while not playing.</summary>
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
public static class AudioSourceTimeGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.GetTime(now);
return false;
}
}
/// <summary>
/// AudioSource.time setter (<see cref="EffectSource"/>): moves the playback position (clip seconds) from the current render time.
/// Set before the scheduled start (ffxPlaySound sets it right after AudioManager.Play), it becomes the start position.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
public static class AudioSourceTimePatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, float value)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetTime(value, now);
return false;
}
}
/// <summary>
/// AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip while playing; while not playing,
/// the last value set through time or timeSamples (0 after Stop()).
/// </summary>
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
public static class AudioSourceTimeSamplesGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref int __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.GetTimeSamples(now);
return false;
}
}
/// <summary>AudioSource.timeSamples setter (<see cref="EffectSource"/>): moves the playback position (samples of the clip).</summary>
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
public static class AudioSourceTimeSamplesPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, int value)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetTimeSamples(value, now);
return false;
}
}
/// <summary>AudioSource.pitch getter (<see cref="EffectSource"/>): the last pitch set.</summary>
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
public static class AudioSourcePitchGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
{
if (!EffectSource.TryGet(__instance, out var state, out _)) return true;
__result = state.GetPitch();
return false;
}
}
/// <summary>AudioSource.pitch setter (<see cref="EffectSource"/>): changes the playback rate from the current render time.</summary>
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
public static bool Prefix(AudioSource __instance, float value)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.Pitch = value;
}
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetPitch(value, now);
return false;
}
}
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
public static bool Prefix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
return false;
}
}
/// <summary>
/// AudioSource.Stop() (<see cref="EffectSource"/>): stops playback at the current render time. The mapping stays, so the source
/// keeps reporting the stopped state. AudioManager.Update calls Stop() on sources it returns to the pool, which are no longer
/// playing, so their voices are left unchanged.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
public static bool Prefix(AudioSource __instance)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.Stop(now);
return false;
}
}
/// <summary>
/// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources),
/// so every effect voice stops at the current render time, including ones scheduled to start later.
/// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine,
/// scrController.FailAction, scrController.DebugUpdate, scnGame.Play and PauseMenu.Choose.
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs (it destroys the sources).
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
public static class AudioManagerStopAllSoundsPatch
{
/// <summary>Stops every effect voice while capturing with RenderAudio on. The original always runs (no return value).</summary>
[HarmonyPrefix]
public static void Prefix()
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime);
}
}
}
+117
View File
@@ -6,13 +6,40 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Unity engine patches that put the game on the offline render clock and the render resolution.
/// <para>
/// Render clock: while capturing (<see cref="RenderManager.IsCapturingActive"/>, stage &gt;= Render) and
/// <c>RenderContext.TimePatch</c> is on (default on; a <c>RenderSession</c> can turn it off), the Unity time getters return
/// the value captured at the capture start (<c>RenderManager.timeBase</c>, <c>unscaledTimeBase</c>, <c>dspTimeBase</c>,
/// <c>frameCountBase</c>, read in <c>SetupPlay</c>) plus the render time <see cref="TimeSpent"/>. Each rendered frame then
/// advances game time by exactly 1 / RenderFPS seconds, independent of how long the frame took to render and encode, and the
/// clocks continue from their real values without a jump. The patches only affect managed callers of these getters
/// (game and mod scripts).
/// </para>
/// <para>
/// All prefixes run on the thread that calls the getter (normally the main Unity thread) and read <c>RenderManager</c> state
/// without locks.
/// </para>
/// </summary>
public static class RenderEnvironmentPatches
{
/// <summary>
/// Render time of the current frame in seconds: <c>RenderManager.frameCount</c> / <c>RenderFPS</c>.
/// frameCount is reset to 0 when capture starts and incremented at the start of every frame (FirstUpdateHook).
/// </summary>
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
/// <summary>
/// <c>Time.time</c> (scaled game time in seconds, float): returns timeBase + <see cref="TimeSpent"/> while capturing with
/// the time patch on. <c>Time.timeScale</c> is not applied to the render time.
/// </summary>
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{
/// <summary>Prefix of the <c>Time.time</c> getter.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -22,9 +49,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.timeAsDouble</c> (double precision of <c>Time.time</c>): timeBase + <see cref="TimeSpent"/>.</summary>
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -34,9 +65,18 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.unscaledTime</c> (time in seconds independent of <c>Time.timeScale</c>, float): unscaledTimeBase +
/// <see cref="TimeSpent"/>. Unscaled time is not advanced by <c>Time.captureDeltaTime</c> (it follows real time), so it is
/// replaced as well.
/// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
/// </summary>
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -46,9 +86,15 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.unscaledTimeAsDouble</c> (double precision of <c>Time.unscaledTime</c>): unscaledTimeBase + <see cref="TimeSpent"/>.
/// </summary>
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -58,9 +104,17 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSettings.dspTime</c> (audio system clock in seconds, double): dspTimeBase + <see cref="TimeSpent"/>.
/// The game schedules the song and computes the song position from this clock (<c>scrConductor</c>), so the level advances
/// by one render frame per frame. The offline audio mixer uses the same base (<c>RenderManager.dspTimeBase</c>).
/// </summary>
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -70,9 +124,19 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSource.PlayScheduled(double time)</c> (start playback at a dspTime): blocked for every AudioSource while
/// <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), so Unity plays no scheduled sound in real time. During
/// capture the song and the sound effects are produced offline by <c>OfflineAudioMixer</c>; sound effects reach it through
/// the <c>AudioManager._Play</c> and <c>scrConductor.PlayWithEndTime</c> patches (AudioPatches.cs).
/// </summary>
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch
{
/// <summary>Prefix of <c>AudioSource.PlayScheduled</c>.</summary>
/// <param name="__instance">The AudioSource (unused).</param>
/// <param name="time">Scheduled start in dspTime seconds (unused).</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
@@ -80,9 +144,16 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.frameCount</c> (frames since startup): frameCountBase + <c>RenderManager.frameCount</c> while capturing
/// (stage &gt;= Render) with <c>RenderContext.TimePatch</c> on.
/// </summary>
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -92,9 +163,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.deltaTime</c> (seconds since the previous frame, float): 1 / RenderFPS.</summary>
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -104,9 +179,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.unscaledDeltaTime</c> (timeScale-independent frame time in seconds, float): 1 / RenderFPS.</summary>
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -116,9 +195,18 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Screen.width</c> (window width in pixels): <c>RenderConfig.RenderWidth</c> while <see cref="RenderManager.IsActive"/>
/// (stage &gt;= OnScene) and <c>RenderContext.ScreenPatch</c> is on (default on). Game code that sizes render textures from
/// the screen size (for example <c>scrCamera</c> recreates <c>camRT</c> at Screen.width x Screen.height when the size
/// differs) then works at the output resolution independent of the window size.
/// </summary>
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -128,9 +216,15 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Screen.height</c> (window height in pixels): <c>RenderConfig.RenderHeight</c>; same conditions as <see cref="ScreenWidthPatch"/>.
/// </summary>
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -140,9 +234,22 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSource.GetSpectrumData(float[] samples, int channel, FFTWindow window)</c> (FFT magnitudes of the source's current
/// output): while capturing (stage &gt;= Render) with <c>AdvancedConfig.ProcessAudio</c> on, fills <c>samples</c> with
/// <see cref="AudioSpectrumGenerator.GetSpectrumData"/> (spectrum of the song at the render time) for any AudioSource.
/// No AudioSource plays during capture, so the original would return silence. The game calls it from
/// <c>scrConductor</c>, <c>scrBarMaker</c> and the volume trackers for audio-reactive visuals.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch
{
/// <summary>Prefix of <c>AudioSource.GetSpectrumData</c>.</summary>
/// <param name="__instance">The AudioSource queried (not used; the song spectrum is returned for every source).</param>
/// <param name="samples">Output array; its length is the number of spectrum bins.</param>
/// <param name="channel">Channel index requested by the caller.</param>
/// <param name="window">FFT window type requested by the caller.</param>
/// <returns>True to run the original method; false when the generated spectrum was written.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{
@@ -152,9 +259,19 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioManager.FindOrLoadAudioClipExternal(string path, bool mp3Streaming, float length, bool stream)</c> (coroutine
/// that loads an external audio file of the level into <c>audioLib</c>): from stage Load with
/// <c>AdvancedConfig.ProcessAudio</c> on, forces <c>stream</c> to false. The game passes <c>stream</c> to
/// <c>DownloadHandlerAudioClip.streamAudio</c> for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming
/// clips; the MP3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>.
/// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html
/// </summary>
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch
{
/// <summary>Prefix of <c>AudioManager.FindOrLoadAudioClipExternal</c>; the original always runs.</summary>
/// <param name="stream">Streaming flag of the load; set to false under the conditions above.</param>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{
+113
View File
@@ -0,0 +1,113 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Fixes the in-game and render bug where objects (floors, decorations, planets) disappear on the first frame of
/// a SetFramerate event (due to camera Z=0 near clip culling) and where parallax/tiles lag behind on subsequent frames.
/// <para>
/// Defers the manual camera rendering (<c>scrCamera.UpdateCustomFrameRateScreen</c>) to the very end of the frame
/// (execution order <c>int.MaxValue - 1</c>), after all components (<c>scrCamera</c>, <c>scrFloor</c>,
/// <c>scrDecorationManager</c>, <c>scrPlanet</c>, <c>scrParallax</c>, and <c>scrController</c>) have finished their
/// <c>LateUpdate</c>.
/// </para>
/// </summary>
[HarmonyPatch]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class CustomFrameRateDeferPatch
{
private static bool _isExecutingDeferred;
private static scrCamera? _pendingCamera;
private static readonly Action<scrCamera>? UpdateCustomFrameRateScreenInvoker =
AccessTools.MethodDelegate<Action<scrCamera>>(AccessTools.Method(typeof(scrCamera), "UpdateCustomFrameRateScreen"));
/// <summary>
/// Checks whether the SetFramerate render timing fix is enabled.
/// Active if rendering session has the option enabled, or enabled by default during normal in-game play.
/// </summary>
public static bool IsEnabled
{
get
{
if (RenderManager.IsActive && RenderManager.Instance?.Configuration?.AdvancedConfig != null)
{
return RenderManager.Instance.Configuration.AdvancedConfig.FixCustomFrameRate;
}
return true;
}
}
/// <summary>
/// Intercepts manual camera render calls from SetCustomFrameRate (Update phase) or CheckUpdateFrameRateScreen
/// (LateUpdate phase). If not currently executing the deferred render pass, records the camera instance and
/// suppresses the premature render.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(scrCamera), "UpdateCustomFrameRateScreen")]
private static bool DeferManualRender(scrCamera __instance)
{
if (!IsEnabled) return true;
if (_isExecutingDeferred) return true;
_pendingCamera = __instance;
return false;
}
/// <summary>
/// Ensures that the runner component is attached to the camera when it starts.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch(typeof(scrCamera), "Start")]
private static void AttachRunner(scrCamera __instance)
{
if (!__instance) return;
if (!__instance.gameObject.GetComponent<CustomFrameRateDeferredRunner>())
{
__instance.gameObject.AddComponent<CustomFrameRateDeferredRunner>();
}
}
/// <summary>
/// Invoked at the very end of LateUpdate (Order int.MaxValue - 1). If a manual render was requested during the frame,
/// executes the camera's original UpdateCustomFrameRateScreen once with all objects fully prepared.
/// </summary>
internal static void ExecuteDeferredRender()
{
if (_pendingCamera == null) return;
var cam = _pendingCamera;
_pendingCamera = null;
if (!cam) return;
_isExecutingDeferred = true;
try
{
UpdateCustomFrameRateScreenInvoker?.Invoke(cam);
}
finally
{
_isExecutingDeferred = false;
}
}
}
/// <summary>
/// Runner component attached to scrCamera that executes deferred custom frame rate renders at the end of LateUpdate.
/// Execution order <c>int.MaxValue - 1</c> guarantees it runs after all normal game components (including
/// scrController at 400), but right before PostRenderHook at <c>int.MaxValue</c>.
/// </summary>
[DefaultExecutionOrder(int.MaxValue - 1)]
internal class CustomFrameRateDeferredRunner : MonoBehaviour
{
private void LateUpdate()
{
CustomFrameRateDeferPatch.ExecuteDeferredRender();
}
}
+93
View File
@@ -0,0 +1,93 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Patches decoration rendering to hide decorations that failed to load (or are empty) and fell back to the default
/// placeholder sprite (tree inside a circle) during a render when <c>AdvancedConfig.HideFailedDecorations</c> is enabled.
/// </summary>
public static class DecorationPatch
{
/// <summary>
/// True while a render is loading or active and <c>HideFailedDecorations</c> is enabled in the configuration.
/// </summary>
public static bool IsEnabled =>
RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.HideFailedDecorations == true;
/// <summary>
/// Checks whether the given sprite is the default placeholder or not-found sprite of the decoration manager.
/// </summary>
public static bool IsPlaceholderSprite(Sprite? sprite, scrDecorationManager? manager = null)
{
if (!sprite) return false;
manager ??= scrDecorationManager.instance;
if (!manager) return false;
return sprite == manager.defaultSprite || (manager.notFoundSprite && sprite == manager.notFoundSprite);
}
/// <summary>
/// Sweeps all visual decorations in <c>scrDecorationManager.instance.allDecorations</c> and clears any placeholder sprites.
/// Called from <see cref="scnGamePatch.ProcessFloors"/> when <c>scnGame.Play</c> runs.
/// </summary>
public static void ProcessDecorations()
{
if (!IsEnabled) return;
var manager = scrDecorationManager.instance;
if (!manager || manager.allDecorations == null) return;
foreach (var dec in manager.allDecorations)
{
if (dec is scrVisualDecoration visualDec && visualDec.spriteRenderer)
{
if (IsPlaceholderSprite(visualDec.spriteRenderer.sprite, manager))
{
visualDec.SetSprite((Sprite)null!);
}
}
}
}
/// <summary>
/// Prefix of <c>scrVisualDecoration.SetSprite(Sprite)</c>: replaces placeholder default/not-found sprites with null
/// when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrVisualDecoration), nameof(scrVisualDecoration.SetSprite), typeof(Sprite))]
// ReSharper disable once InconsistentNaming
public static class VisualDecorationSetSpritePatch
{
[HarmonyPrefix]
public static void Prefix(scrVisualDecoration __instance, ref Sprite? sprite)
{
if (!IsEnabled || !sprite) return;
if (IsPlaceholderSprite(sprite, __instance.manager))
{
sprite = null;
}
}
}
/// <summary>
/// Prefix of <c>scrDecorationManager.ShowEmptyDecorations(bool)</c>: suppresses showing placeholder default sprites
/// during render when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrDecorationManager), nameof(scrDecorationManager.ShowEmptyDecorations))]
public static class ShowEmptyDecorationsPatch
{
[HarmonyPrefix]
public static bool Prefix(bool show)
{
if (show && IsEnabled)
{
return false;
}
return true;
}
}
}
+17
View File
@@ -3,10 +3,27 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the detailed results panel (per-judgement hit counts) that the game shows on the clear screen.
/// <para>
/// Patched member: <c>DetailedResults.Show()</c>. The game calls it from <c>scrController.OnLandOnPortal</c> when a level is won
/// (and detailed results are enabled); it builds the result text per player, enables the component and activates
/// <c>scrController.detailedResults</c>.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), so the panel does not appear in the rendered video
/// during the delay after the level end.
/// </para>
/// </summary>
[HarmonyPatch(typeof(DetailedResults))]
// ReSharper disable once InconsistentNaming
public static class DetailedResultsPatch
{
/// <summary>
/// Prefix of <c>DetailedResults.Show</c>. While rendering, disables the component and deactivates its GameObject.
/// </summary>
/// <param name="__instance">The DetailedResults component being shown.</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Show")]
// ReSharper disable once InconsistentNaming
+34
View File
@@ -4,10 +4,29 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Suppresses the end-screen lanterns (level complete / accuracy / speed trial lamps) shown after a clear.
/// <para>
/// Patched members: <c>EndscreenLanterns.Show(scrPlayer, float, int, bool)</c>, which activates the lantern set, sets the
/// lantern texts and starts DOTween sequences (<c>chainSequence</c> for the chain drop, <c>lampSequences[3]</c> for the lamp
/// effects), returning the number of lanterns shown; and <c>EndscreenLanterns.Hide()</c>, which deactivates the GameObject.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), so the lanterns and their tweens do not appear in
/// the rendered video during the delay after the level end. See also <see cref="scrUIControllerPatch"/>.
/// </para>
/// </summary>
[HarmonyPatch(typeof(EndscreenLanterns))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{
/// <summary>
/// Prefix of <c>EndscreenLanterns.Show</c>. While rendering, hides the lantern set (which also runs
/// <see cref="PrefixHide"/>) and reports zero lanterns shown.
/// </summary>
/// <param name="__instance">The lantern set being shown.</param>
/// <param name="__result">Return value of Show: number of lanterns shown; set to 0 when the original is skipped.</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -22,6 +41,21 @@ public static class EndscreenLanternsPatch
return false; // Skip original Show method
}
/// <summary>
/// Prefix of <c>EndscreenLanterns.Hide</c>. While rendering, releases the lantern tweens started by an earlier Show
/// before the original deactivates the GameObject.
/// <para>
/// The private fields <c>lampSequences</c> (DOTween <c>Sequence[]</c>) and <c>chainSequence</c> (<c>Sequence</c>) are read
/// through Harmony <see cref="Traverse"/>. For each sequence, a public instance method <c>Kill(bool)</c> is looked up on the
/// object's runtime type by reflection and invoked with <c>false</c> when found; then the field slot is set to null.
/// In the game's DOTween.dll, <c>Kill(this Tween, bool complete = false)</c> is an extension method of
/// <c>DG.Tweening.TweenExtensions</c>, so the lookup on <c>Sequence</c> returns null and no Kill is invoked; the effect is
/// that the fields stop referencing the sequences, while the sequences themselves keep running in DOTween.
/// Reflection failures on a single sequence (including a null array element) are ignored. The original Hide always runs
/// afterwards.
/// </para>
/// </summary>
/// <param name="__instance">The lantern set being hidden.</param>
[HarmonyPrefix]
[HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
+13 -1
View File
@@ -5,11 +5,19 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Synchronizes mod localization with ADOFAI's in-game language settings.
/// Keeps the mod UI language (<see cref="LocalizationManager"/>) equal to the game language.
/// <para>
/// Patched members: <c>RDString.Setup()</c>, which initializes the game's string table and sets <c>RDString.language</c> from
/// <c>Persistence.language</c> (it returns immediately when already initialized); and <c>RDString.ChangeLanguage(SystemLanguage)</c>,
/// which stores the language in <c>Persistence.language</c>, clears the initialized flag and calls Setup again.
/// </para>
/// <para>Always active (independent of the render stage). Runs on the main Unity thread.</para>
/// </summary>
[HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch
{
/// <summary>Postfix of <c>RDString.ChangeLanguage</c>: applies the selected language to the mod UI.</summary>
/// <param name="language">Language passed to ChangeLanguage.</param>
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
[HarmonyPostfix]
public static void OnChangeLanguage(SystemLanguage language)
@@ -17,6 +25,10 @@ internal static class LanguageSyncPatch
LocalizationManager.SetLanguage(language);
}
/// <summary>
/// Postfix of <c>RDString.Setup</c>: applies <c>RDString.language</c> (the language resolved by the game) to the mod UI.
/// Runs on every Setup call, including the early-return calls made when the string table is already initialized.
/// </summary>
[HarmonyPatch(nameof(RDString.Setup))]
[HarmonyPostfix]
public static void OnSetup()
+26
View File
@@ -3,9 +3,23 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Overrides two player settings read from <c>Persistence</c> during a render: the "skip intro" option and the planet colors.
/// </summary>
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{
/// <summary>
/// Postfix of the <c>Persistence.skipIntroBehavior</c> getter (setting values Off / AfterFirstTry / Always).
/// <para>
/// <c>scrConductor.StartMusicCo</c> reads this setting when the song is scheduled and, for AfterFirstTry (after a death) or
/// Always (long intro, no checkpoint), sets <c>skipOffset</c>, which moves <c>dspTimeSong</c> back by the level offset and
/// starts the song part-way in. While <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene) and the song has not
/// started yet (<c>scrConductor.hasSongStarted</c> false), the getter returns Off, so <c>skipOffset</c> stays false and the
/// song always plays from its start. The mod's offline BGM mixing, spectrum and video background timing assume this.
/// </para>
/// </summary>
/// <param name="__result">Value returned by the getter; replaced with <c>SkipIntroBehavior.Off</c> under the conditions above.</param>
[HarmonyPostfix]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
@@ -19,6 +33,18 @@ public static class PersistencePatch
}
}
/// <summary>
/// Postfix of <c>Persistence.GetPlayerColor(bool red)</c>, which returns the player's saved planet color
/// (preset name or hex color from the general preferences; default red/blue otherwise). <c>PlanetRenderer</c> uses it to
/// color the fire (red) and ice (blue) planets.
/// <para>
/// Active from stage Load (render started, scene loading) until the stage returns to None. When the render configuration
/// sets <c>AdvancedConfig.firePlanetColor</c> (red) or <c>AdvancedConfig.icePlanetColor</c> (blue), that color replaces the
/// saved color; an unset (null) override keeps the game value. The saved preference itself is not changed.
/// </para>
/// </summary>
/// <param name="red">True for the fire (red) planet, false for the ice (blue) planet.</param>
/// <param name="__result">Color returned by the game; replaced by the configured override when set.</param>
[HarmonyPostfix]
[HarmonyPatch("GetPlayerColor")]
// ReSharper disable once InconsistentNaming
+19
View File
@@ -3,9 +3,21 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Applies the configured color of the third (green, "wind") planet used by multi-planet levels.
/// <para>
/// Patched members: <c>PlanetarySystem.Init()</c>, which creates the planets including <c>planetGreen</c> (default color
/// RGB 0.3, 0.7, 0.0); and <c>PlanetarySystem.ApplyMultiplanetColors()</c>, which recolors the extra planets
/// (green, yellow, purple, ...) from the red/blue planet colors (skipped by the game when <c>GCS.staticPlanetColors</c> is set).
/// Both postfixes reapply the override after the game has set its color.
/// </para>
/// <para>Active from stage Load until the stage returns to None. The fire/ice colors are handled by <see cref="PersistencePatch"/>.</para>
/// </summary>
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
/// <summary>Postfix of <c>PlanetarySystem.Init</c>: applies the wind planet color override to the new planets.</summary>
/// <param name="__instance">The planetary system that was initialized.</param>
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
@@ -14,6 +26,8 @@ public static class PlanetarySystemPatch
ApplyWindPlanetColor(__instance);
}
/// <summary>Postfix of <c>PlanetarySystem.ApplyMultiplanetColors</c>: reapplies the wind planet color override.</summary>
/// <param name="__instance">The planetary system whose extra planet colors were updated.</param>
[HarmonyPostfix]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
@@ -22,6 +36,11 @@ public static class PlanetarySystemPatch
ApplyWindPlanetColor(__instance);
}
/// <summary>
/// Sets <c>planetGreen</c>'s renderer color to <c>AdvancedConfig.windPlanetColor</c> when a render is in progress
/// (stage != None) and the override is set. Does nothing when the override is null or the planet does not exist.
/// </summary>
/// <param name="planetarySystem">The planetary system to update.</param>
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
{
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
+59
View File
@@ -0,0 +1,59 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Makes the game's bloom compute its blur size as if the frame were 720 pixels high
/// ("High-Resolution Bloom Correction" option).
/// <para>
/// Patched member: <c>VideoBloom.OnRenderImage(RenderTexture source, RenderTexture destination)</c>. The game scales the
/// frame down to a bloom buffer 198 pixels high (<c>scale = 198 / source.height</c>) and sets the blur radius in bloom
/// buffer pixels to <c>KernelSize * MediumKernelScale * scale</c> (and <c>KernelSize * LargeKernelScale * scale</c>).
/// <c>BloomBlit</c> turns the radius into the number of 5-tap blur passes and the blend amount of the last pass.
/// </para>
/// <para>
/// While the render is active (<see cref="RenderManager.IsActive"/>) and <c>AdvancedConfig.HighResBloomCorrection</c> is
/// enabled, the prefix multiplies <c>KernelSize</c> by <c>source.height / 720</c> for the duration of the call, so the
/// radius equals <c>KernelSize * MediumKernelScale * 198 / 720</c> and the blur passes on the bloom buffer are the same
/// as in a 720p frame. The finalizer restores the original <c>KernelSize</c>. The bloom buffer size and the downscale
/// from the source frame keep the game's behavior.
/// </para>
/// </summary>
[HarmonyPatch(typeof(VideoBloom))]
public class VideoBloomPatch
{
/// <summary>Frame height in pixels the blur size is computed for.</summary>
private const float ReferenceHeight = 720f;
/// <summary>Prefix of <c>VideoBloom.OnRenderImage</c>.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="source">The camera frame; its height selects the multiplier.</param>
/// <param name="__state">Original <c>KernelSize</c>, or null when it was not changed.</param>
[HarmonyPrefix]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void ScaleKernelSize(VideoBloom __instance, RenderTexture source, out float? __state)
// ReSharper restore InconsistentNaming
{
__state = null;
if (!source || !RenderManager.IsActive || !RenderManager.Instance.Configuration.AdvancedConfig.HighResBloomCorrection)
return;
__state = __instance.KernelSize;
__instance.KernelSize *= source.height / ReferenceHeight;
}
/// <summary>Finalizer of <c>VideoBloom.OnRenderImage</c>: restores <c>KernelSize</c> when the prefix changed it.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="__state">Original <c>KernelSize</c> stored by the prefix, or null.</param>
[HarmonyFinalizer]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void RestoreKernelSize(VideoBloom __instance, float? __state)
// ReSharper restore InconsistentNaming
{
if (__state.HasValue) __instance.KernelSize = __state.Value;
}
}
+20 -1
View File
@@ -6,10 +6,28 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Adds the "Render" button to the level editor toolbar.
/// <para>
/// Patched member: <c>scnEditor.Start()</c>, the editor scene setup (it wires the toolbar buttons, including
/// <c>buttonExit</c>). The postfix clones the exit button next to it and makes the clone start a render.
/// </para>
/// <para>
/// Always active except when the mod features are disabled (<see cref="RenderMod.IsFeatureDisabled"/>, set from the
/// driver warning dialog). Runs on the main Unity thread each time the editor scene starts.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{
/// <summary>
/// Postfix of <c>scnEditor.Start</c>: instantiates a copy of <c>buttonExit</c> under the same parent, named
/// <c>buttonRender</c>, whose click handler is <see cref="RenderManager.StartRender"/>, and replaces its icon with the
/// editor's MoveCamera event sprite (24 x 24 units). Logs a message and adds nothing when the editor or exit button is missing.
/// When the icon child or the sprite is not found, the button keeps the exit icon.
/// </summary>
/// <param name="__instance">The editor scene component.</param>
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
@@ -38,8 +56,9 @@ public static class scnEditorPatch
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// images[0] is the background on the root, images[1] is the icon on the child.
// Sprite path: the icon of the editor's MoveCamera level event, loaded from the game's Resources.
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
+52 -3
View File
@@ -6,16 +6,45 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Patches of <c>scnGame</c> (custom level scene controller, also used by the editor's play mode): hides tiles and planets
/// for the "non-tile" render option and replaces the background video player during a render.
/// </summary>
[HarmonyPatch(typeof(scnGame))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{
/// <summary>
/// Postfix of <c>scnGame.Play(int seqID, bool remakeFloors)</c>, which starts (or restarts) play of the custom level:
/// it resets the conductor, camera and controller state for the given start tile.
/// <para>
/// Active from stage Load when <c>AdvancedConfig.NonTile</c> is enabled. Disables the renderers of every floor in
/// <c>scrLevelMaker.listFloors</c> (floor mesh, legacy sprite, icon, outline, top/bottom glow, multi-planet line,
/// hold mesh, dummy planets) and of every planet of every player (sprite mesh, core/tail/sparks particles, ring, glow,
/// face, samurai sprite), so only the background and decorations remain in the rendered video. The renderers are only
/// disabled; the objects keep running. Hold meshes re-enable themselves, so <c>RenderManager.OnLastUpdate</c> disables
/// them again every frame.
/// </para>
/// </summary>
[HarmonyPostfix]
[HarmonyPatch("Play")]
public static void ProcessFloors()
{
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
DecorationPatch.ProcessDecorations();
var nonTile = RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.NonTile == true;
if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors)
@@ -47,6 +76,7 @@ public static class scnGamePatch
}
return;
// Disables every renderer that draws one planet (fire, ice, extra planets and floor dummy planets).
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
@@ -62,12 +92,29 @@ public static class scnGamePatch
foreach (var renderer in renderers) DisableRenderer(renderer);
}
// Sets Renderer.enabled = false; null or destroyed renderers are skipped (Unity null check).
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
}
/// <summary>
/// Prefix of <c>scnGame.UpdateVideo()</c>, which sets up the level's background video on the native Unity
/// <c>VideoPlayer</c> (<c>scnGame.videoBG</c>): it reads <c>miscSettings.bgVideo</c>, loads a <c>VideoClip</c> for internal
/// (Resources / Addressables) and bundle levels or sets <c>videoBG.url</c> to the file next to the custom level, then calls
/// <c>Stop()</c>, <c>Prepare()</c> and sets <c>isLooping</c> from <c>loopVideo</c>. The game calls it from
/// <c>scnGame.FinishCustomLevelLoading</c> and from <c>scnGame.ReloadAssets</c> when <c>force</c> is true.
/// <para>
/// While <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), an external video file of a custom level is played by
/// <see cref="VideoBGPlayer"/> (FFmpeg decoder), which shows the frame for the render time of each frame. The native
/// <c>VideoPlayer</c> plays in real time and would drift from the offline render clock, so it is disabled for that case.
/// Internal and bundle levels (built-in <c>VideoClip</c> assets, not files) and a missing file use the original method.
/// </para>
/// <para>Outside a render, any <see cref="VideoBGPlayer"/> left on the video object is destroyed and the native player re-enabled.</para>
/// </summary>
/// <param name="__instance">The scene controller.</param>
/// <returns>True to run the original UpdateVideo; false when the mod handled the video (or there is none).</returns>
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
@@ -109,7 +156,8 @@ public static class scnGamePatch
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
// Camera for the CameraFarPlane render mode: the VideoPlayer's target camera, or the "BGStaticCam" scene object
// when none is assigned.
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
@@ -120,6 +168,7 @@ public static class scnGamePatch
}
}
// Disable the native VideoPlayer so it does not play the video in real time alongside VideoBGPlayer.
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
+22 -1
View File
@@ -3,16 +3,37 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Protects the camera render texture <c>scrCamera.camRT</c> when the custom frame rate effect is turned off while it is
/// already off.
/// <para>
/// Patched member: <c>scrCamera.SetCustomFrameRate(bool enable, float frame)</c> (level effect that updates the screen at a
/// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> FPS.
/// Disabling calls <c>Release()</c> on the quad material's current <c>mainTexture</c> and assigns <c>camRT</c> back. When
/// the effect was never enabled, <c>mainTexture</c> is <c>camRT</c> itself, so the original releases the live camera
/// render texture.
/// </para>
/// <para>Always active (independent of the render stage).</para>
/// </summary>
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
/// <summary>
/// Prefix of <c>scrCamera.SetCustomFrameRate</c>. For a disable request while <c>enableCustomFPS</c> is already false,
/// stores the arguments in <c>enableCustomFPS</c> / <c>frameRate</c> and skips the original (no texture is released).
/// Enable requests and disable requests while the effect is on run the original.
/// </summary>
/// <param name="__instance">The camera.</param>
/// <param name="enable">True to turn the custom frame rate on, false to turn it off.</param>
/// <param name="frame">Custom frame rate in frames per second.</param>
/// <returns>True to run the original method; false to skip it.</returns>
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
// Disable request while the effect is off: the quad material's mainTexture is camRT, which the original would Release().
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
+47
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Keeps the game controller (<c>scrController</c>) in uninterrupted play during a render: no pause, no failure,
/// no scene change after the clear, and no clear texts.
/// <para>
/// All patches are active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). RenderManager also sets
/// <c>noFail</c>, <c>noFailInfiniteMargin</c> and <c>disableCongratsMessage</c> on the controller when the scene is ready.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{
/// <summary>
/// Prefix of <c>scrController.TogglePauseGame()</c> (pause menu toggle; sets <c>Time.timeScale</c> to 0 or 1 and shows the
/// pause menu; also called from the pause button, the pause key and <c>OnApplicationPauseCallback</c>). Skipped while
/// rendering, so the level cannot be paused. The skipped call returns
/// false (Harmony default for the unset bool result).
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("TogglePauseGame")]
public static bool PreventPause()
@@ -14,6 +29,12 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.PortalTravelAction(Portal)</c>, which leaves the level through a portal (next level,
/// level select, main menu, ...); the game calls it on entering the Won state for non-world levels and on input after a win.
/// Skipped while rendering, so the scene stays loaded for the delay after the level end.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel()
@@ -21,6 +42,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.FailAction(...)</c>, which enters the Fail state: stops the song and all sounds, pauses the
/// VFX video and shows the failure texts. Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("FailAction")]
public static bool PreventFailAction()
@@ -28,6 +54,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.Fail2Action()</c>, which moves from Fail to Fail2 (accuracy calculation, saving of the
/// best result for boss/custom boss levels, death counter). Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action()
@@ -35,6 +66,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.OnPlayerDied(...)</c>, which kills the remaining players and calls FailAction when no other
/// player is alive. Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied()
@@ -42,6 +78,17 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Postfix of <c>scrController.OnLandOnPortal(...)</c>, the win handler on the last tile: it sets and activates
/// <c>txtCongrats</c> / <c>txtAprilCongrats</c> / <c>txtAllStrictClear</c>, may show the detailed results and end-screen
/// lanterns, saves the result and changes the state to Won.
/// <para>
/// While rendering, clears and deactivates the three clear texts and disables the detailed results panel after the
/// original has run, so they do not appear in the rendered video. The Won state itself is kept: the default render end
/// condition (<c>RenderContext.EndCondition</c>) waits for it.
/// </para>
/// </summary>
/// <param name="__instance">The game controller.</param>
[HarmonyPostfix]
[HarmonyPatch("OnLandOnPortal")]
public static void PreventWinTextsPostfix(scrController __instance)
+52
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Prevents the end portal from being spawned on level tiles for the "non-tile" render option.
/// <para>
/// Patched member: <c>scrFloor.SpawnPortalParticles()</c>, which instantiates the animated portal prefab
/// (<c>scrPortalParticles</c>: glow, particles, cap and icon) as a child of the floor. The game calls it from
/// <c>scrLevelMaker</c>, <c>scrController</c> and <c>scrFloor</c> for the portal floor.
/// </para>
/// <para>
/// Active from stage Load when <c>AdvancedConfig.NonTile</c> is enabled, and only for floors that belong to
/// <c>scrLevelMaker.listFloors</c> (the level's tiles). See <see cref="scnGamePatch.ProcessFloors"/> for the other tile renderers.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
/// <summary>Prefix of <c>scrFloor.SpawnPortalParticles</c>.</summary>
/// <param name="__instance">The floor that would receive the portal.</param>
/// <returns>True to run the original method (spawn the portal); false to skip it (non-tile render, level floor).</returns>
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
@@ -18,4 +33,41 @@ public class scrFloorPatch
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
}
/// <summary>
/// Postfix of <c>scrFloor.Start</c>.
/// The game sets <c>enabled = isVisible</c> in Start(), which disables all off-screen tiles right at spawn.
/// When <c>DisableTileCulling</c> is enabled, force every floor to remain enabled.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch("Start")]
public static void ForceEnableOnStart(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
__instance.enabled = true;
}
}
/// <summary>
/// Prefix of <c>scrFloor.OnBecameInvisible</c>.
/// Skips disabling the floor component (<c>enabled = false</c>) while rendering with
/// <c>AdvancedConfig.DisableTileCulling</c> enabled, preventing the 1-frame update lag/glitch
/// when tiles enter the camera view.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch("OnBecameInvisible")]
// ReSharper disable once InconsistentNaming
public static bool PreventTileCulling(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
return false;
}
return true;
}
}
+10
View File
@@ -3,10 +3,20 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the judgement texts (e.g. Perfect, Early, Late) during a render unless the render configuration enables them.
/// <para>
/// Patched member: <c>scrHitTextManager.ShowHitText(HitMargin, scrPlanet, float, int?)</c>, which shows the hit margin
/// text at the planet for each hit (the game itself skips it for no-HUD modes and hidden margins).
/// </para>
/// <para>Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene) and <c>RenderConfig.ShowJudgementText</c> is false.</para>
/// </summary>
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{
/// <summary>Prefix of <c>scrHitTextManager.ShowHitText</c>.</summary>
/// <returns>True to run the original method (not rendering, or judgement texts enabled); false to skip it.</returns>
public static bool Prefix()
{
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
+15
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Keeps a render from writing custom level results to the player's save data.
/// <para>
/// Patched member: <c>scrMistakesManager.SaveCustom(string hash, bool wonLevel, float multiplier)</c>, which compares the
/// run with the stored completion, accuracy, speed trial and death records of the custom level (keyed by the level hash),
/// stores new bests through <c>Persistence</c> and returns an <c>EndLevelInfo</c>. The game calls it from
/// <c>scrController.OnLandOnPortal</c> (custom level win) and <c>Fail2Action</c> (custom boss level failure).
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene); the run of a render is not stored as a player
/// record. The skipped call returns a default <c>EndLevelInfo</c>.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{
/// <summary>Prefix of <c>scrMistakesManager.SaveCustom</c>.</summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
+17
View File
@@ -3,10 +3,27 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Replaces the "any valid input" check with a render-controlled value, so player input cannot affect the level during a render.
/// <para>
/// Patched member: <c>scrPlayerManager.AnyValidInputWasTriggered()</c>, true when any player pressed a valid key this frame.
/// <c>scrController</c> uses it to leave the press-to-start wait, to restart after a failure (<c>Fail2_Update</c>) and to
/// leave the level after a win (<c>Won_Update</c>).
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). The result is true only while
/// <c>RenderManager.countdown</c> is 0 and <c>RenderManager.frameCount</c> is 0, that is, after the 3 second countdown in
/// <c>SetupPlay</c> has ended and reset the frame counter and before the next frame increments it; it is false otherwise.
/// <c>SetupPlay</c> also sets <c>scrController.levelWasSkipped</c>, which ends the press-to-start wait independently of input.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
/// <summary>Prefix of <c>scrPlayerManager.AnyValidInputWasTriggered</c>.</summary>
/// <param name="__result">Return value used when the original is skipped (see class summary).</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
+16
View File
@@ -4,10 +4,26 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Makes the end portal particle systems run on scaled game time during a render.
/// <para>
/// Patched member: <c>scrPortalParticles.Start()</c> (the animated portal spawned by <c>scrFloor.SpawnPortalParticles</c>;
/// Start pre-simulates its particle system for 1 second and plays it).
/// </para>
/// <para>
/// Active from stage Load. RenderManager sets <c>Time.captureDeltaTime</c> to 1 / RenderFPS, which advances scaled time by a
/// fixed step per frame; unscaled time is not affected by captureDeltaTime and follows real time. A particle system with
/// <c>main.useUnscaledTime</c> would therefore play at the real (slower or faster) frame rate of the offline render, so the
/// postfix sets it to false on every particle system under the portal (including inactive children).
/// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{
/// <summary>Postfix of <c>scrPortalParticles.Start</c>: sets <c>useUnscaledTime = false</c> on all child particle systems.</summary>
/// <param name="__instance">The portal that started.</param>
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
+16
View File
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the "press any key to begin" text of the first level start of a render.
/// <para>
/// Patched member: <c>scrPressToStart.ShowText()</c>, which sets the press/tap-to-begin text and shows the difficulty
/// selector (<c>scrUIController.ShowDifficultyContainer</c>). <c>scrController</c> calls it before waiting for the start input.
/// </para>
/// <para>
/// Active once per render: <c>RenderManager.skipPressToStart</c> is set when the render loads <c>scnGame</c> and cleared by
/// the first ShowText call (or when the render stops). Later ShowText calls run the original.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
/// <summary>
/// Prefix of <c>scrPressToStart.ShowText</c>. When <c>skipPressToStart</c> is set, clears it, hides the difficulty
/// selector and skips the original.
/// </summary>
/// <returns>True to run the original method; false to skip it (first ShowText of a render).</returns>
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
+21
View File
@@ -6,10 +6,31 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Replaces the game's status text (normally "Autoplay", "Auto tile" or "Debug Mode") with the render status.
/// <para>
/// Patched member: <c>scrShowIfDebug.Update()</c>, which each frame sets its UI <c>Text</c> (<c>txt</c>) to the autoplay /
/// auto-tile / debug label or hides it (no-HUD modes, recording mode), in <c>defaultColor</c> (green) or orange for auto tiles.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). The postfix overwrites whatever the original set,
/// every frame, so the text is shown even in the game's no-HUD modes.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrShowIfDebug))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{
/// <summary>
/// Postfix of <c>scrShowIfDebug.Update</c>. Shows, in the default (green) color:
/// <list type="bullet">
/// <item>"Render in Ns.." during the countdown (N = <c>RenderManager.countdown</c>, seconds), otherwise "Render";</item>
/// <item>after the end condition is met (<c>endFrame</c> != -1), a second line "Waiting for X.Xs.." with the remaining
/// part of <c>DelayAfterEnd</c> in render seconds ((frameCount - endFrame) / RenderFPS elapsed), clamped at 0.</item>
/// </list>
/// </summary>
/// <param name="___txt">Private field <c>txt</c>: the status Text component.</param>
/// <param name="___defaultColor">Private field <c>defaultColor</c>: the game's status text color.</param>
[HarmonyPostfix]
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
+16
View File
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the end-screen lanterns again right after the game shows them.
/// <para>
/// Patched member: <c>scrUIController.ShowEndscreenLanterns()</c>, which calls <c>EndscreenLanterns.Show</c> for each player's
/// lantern set (and fades the end-screen flash in co-op). <c>scrController.OnLandOnPortal</c> calls it on a win.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). Works together with <see cref="EndscreenLanternsPatch"/>,
/// which already skips <c>EndscreenLanterns.Show</c> during a render.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{
/// <summary>
/// Postfix of <c>scrUIController.ShowEndscreenLanterns</c>: calls <c>HideEndscreenLanterns()</c>, which runs
/// <c>EndscreenLanterns.Hide</c> on every lantern set.
/// </summary>
/// <param name="__instance">The UI controller.</param>
[HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
+23 -1
View File
@@ -9,17 +9,35 @@ using MelonLoader.Utils;
using UnityEngine;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.4.0-BETA", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.3-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer;
/// <summary>
/// MelonLoader entry point of the mod. Loads the settings and localization, points FFmpeg.AutoGen at the native
/// FFmpeg libraries, and creates the persistent manager objects (RenderManager, FirstUpdateHook, RendererUIManager).
/// </summary>
internal class RenderMod : MelonMod
{
/// <summary>
/// When true, the render button is not added to the level editor (scnEditorPatch checks it in scnEditor.Start).
/// Set by the user from the NVIDIA driver warning dialog (FirstUpdateHook). Main thread only.
/// </summary>
// ReSharper disable once MemberCanBePrivate.Global
public static bool IsFeatureDisabled = false;
/// <summary>
/// Expiration time of this build (UTC). After it, the render configuration window shows an "expired" note next to
/// the version text. It does not block rendering.
/// </summary>
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Called once by MelonLoader after the mod assembly is loaded.
/// The native FFmpeg libraries are loaded from MelonLoader's UserLibs directory.
/// The three manager GameObjects are marked DontDestroyOnLoad so they survive scene changes.
/// </summary>
public override void OnInitializeMelon()
{
GlobalSettings.Load();
@@ -39,6 +57,10 @@ internal class RenderMod : MelonMod
ModAssets.Load();
}
/// <summary>
/// Called by MelonLoader when the game quits. Aborts an active render (encoders disposed, render thread joined,
/// unfinished output file deleted) through RenderManager.CleanUpActiveRender.
/// </summary>
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
+69 -7
View File
@@ -11,14 +11,29 @@ using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Description of one button of a <see cref="CommonWarningDialogUI"/> dialog: label, click action and optional color.
/// </summary>
internal class DialogButton
{
// ReSharper disable FieldCanBeMadeReadOnly.Global
/// <summary>Button label, shown as given (callers pass already localized text).</summary>
public string Text;
/// <summary>
/// Action run on the main Unity thread when the button is clicked. The dialog is destroyed after the action
/// returns, also when the action throws (the exception is logged).
/// </summary>
public Action OnClick;
/// <summary>Normal (idle) background color of the button; <c>null</c> keeps the O5Kit theme button color.</summary>
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
/// <summary>Creates a button description.</summary>
/// <param name="text">Button label.</param>
/// <param name="onClick">Action run on click, before the dialog closes.</param>
/// <param name="backgroundColor">Optional normal background color; <c>null</c> uses the theme color.</param>
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
Text = text;
@@ -27,10 +42,29 @@ internal class DialogButton
}
}
/// <summary>
/// Modal message dialog with a title, a description and one or more buttons.
/// <para>
/// Each <see cref="ShowDialog"/> call creates its own GameObject (kept across scene loads) with a screen-space overlay
/// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it.
/// While the dialog exists, every enabled UI Toolkit root (<see cref="UIDocument"/>) is disabled so that it cannot
/// receive input; the roots are enabled again in <see cref="OnDestroy"/>. Clicking any button runs its action and then
/// destroys the dialog. All members run on the main Unity thread.
/// </para>
/// </summary>
internal class CommonWarningDialogUI : MonoBehaviour
{
/// <summary>
/// The most recently created dialog, or <c>null</c> when that dialog has been destroyed.
/// <c>FirstUpdateHook</c> checks it to wait until a startup dialog is closed.
/// If several dialogs are open at once, this refers to the newest one only.
/// </summary>
public static CommonWarningDialogUI? Instance { get; private set; }
/// <summary>UI Toolkit root elements disabled by this dialog, enabled again when the dialog is destroyed.</summary>
private readonly List<VisualElement> _disabledElements = new();
/// <summary>O5Kit window holding the title, description and buttons; disposed in <see cref="OnDestroy"/>.</summary>
private O5Window? _window;
private void Awake()
@@ -38,6 +72,10 @@ internal class CommonWarningDialogUI : MonoBehaviour
Instance = this;
}
/// <summary>
/// Disposes the window, enables the UI Toolkit roots disabled in <see cref="Initialize"/> and clears
/// <see cref="Instance"/> if it still refers to this dialog.
/// </summary>
private void OnDestroy()
{
_window?.Dispose();
@@ -58,6 +96,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
/// <summary>
/// Creates and shows a new dialog. Must be called on the main Unity thread.
/// </summary>
/// <param name="title">Window title text.</param>
/// <param name="description">Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.</param>
/// <param name="buttons">
/// Buttons laid out left to right with equal widths. With no buttons nothing is shown, because the dialog
/// has no close button and could not be dismissed.
/// </param>
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{
if (buttons.Length == 0) return;
@@ -68,8 +115,14 @@ internal class CommonWarningDialogUI : MonoBehaviour
dialog.Initialize(title, description, buttons);
}
/// <summary>
/// Builds the canvas, backdrop, window and contents. Layout sizes are in canvas units at the 1920x1080 reference
/// resolution of the <see cref="CanvasScaler"/>.
/// </summary>
private void Initialize(string title, string description, DialogButton[] buttons)
{
// uGUI buttons need an EventSystem to receive clicks. When the current scene has none, a child EventSystem
// is created; it is destroyed together with the dialog.
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
@@ -78,6 +131,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// Disable every enabled UI Toolkit root so that it ignores input while the dialog is open.
// Roots that are already disabled are left alone and are not re-enabled in OnDestroy.
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement is { enabledSelf: true })
@@ -87,14 +142,17 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
// Canvas setup.
// Sorting order 32766 draws the dialog above the RendererUIManager window canvas (32000), the render overlays
// (DebugMonitorUI 9999, EscapeHandlerUI 10000) and below FFmpegDownloadUI (32767, the largest sorting order).
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766;
// Scale with screen size against a 1920x1080 reference; match 0.5 blends width and height scaling equally.
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -105,7 +163,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop (scrim) to block background input
// 1. Create full screen backdrop (scrim) to block background input.
// Black at 65% opacity, stretched over the whole canvas; raycastTarget makes it swallow clicks.
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
@@ -120,7 +179,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
// 2. Create actual native O5Window
// 2. Create the O5Kit window as a child of the backdrop: 540x240 units, draggable, fixed size,
// no close button (the dialog closes only through its buttons), 16-unit inner padding.
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_WarningDialog",
@@ -133,7 +193,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
Padding = new RectOffset(16, 16, 16, 16)
});
// 3. Build content inside window.Content
// 3. Build content inside window.Content: a vertical stack of the description (takes the remaining height)
// and the button row (40 units high), 16 units apart.
var content = _window.Content;
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
@@ -144,7 +205,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
// Description text
// Description text: 18 pt, centered, word-wrapped, light gray (88% white)
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
descText.text = description;
descText.alignment = TextAlignmentOptions.Center;
@@ -157,7 +218,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// Button action row
// Button action row: buttons share the row width equally, 10 units apart
var btnRowObj = new GameObject("ButtonRow");
btnRowObj.transform.SetParent(content, false);
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
@@ -176,6 +237,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
for (var i = 0; i < buttons.Length; i++)
{
var btnData = buttons[i];
// The click handler runs the caller's action, logs any exception from it, and always destroys the dialog.
var btn = O5Factory.Button(
ctx: ctx,
parent: btnRowRect,
+62 -12
View File
@@ -8,39 +8,73 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// On-screen overlay shown while a render is running: a statistics panel in the top-right corner and a level progress
/// bar near the bottom of the screen.
/// <para>
/// Created by <c>RenderManager.StartFFmpegThread</c> on a GameObject kept across scene loads, and reused for later
/// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames.
/// <see cref="Update"/> rebuilds the text every frame on the main Unity thread.
/// </para>
/// </summary>
internal class DebugMonitorUI : MonoBehaviour
{
// --- Progress Bar UI Configuration Constants ---
// Sizes and positions are canvas units at the 1920x1080 reference resolution of the CanvasScaler.
/// <summary>Width of the progress bar; the fill width is <c>BarWidth * progress</c>.</summary>
private const float BarWidth = 800f;
/// <summary>Height of the progress bar.</summary>
private const float BarHeight = 28f;
/// <summary>Horizontal offset of the bar center from the screen center (0 = centered).</summary>
private const float BarPosX = 0f;
/// <summary>Vertical offset of the bar center from the screen center; -420 is 120 units above the bottom edge (540 - 420).</summary>
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
/// <summary>Font size of the percentage text drawn on the bar.</summary>
private const int BarTextFontSize = 16; // Increased font size for visibility
/// <summary>Unfilled bar color: opaque near-black with a slight blue tint.</summary>
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
/// <summary>Filled part color: opaque cyan (#00CCFF).</summary>
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
/// <summary>Percentage text color: opaque white.</summary>
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
/// <summary>Color of the uGUI <see cref="Outline"/> drawn around the bar background.</summary>
private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
/// <summary>
/// <c>Outline.effectDistance</c> of the bar outline: the background is redrawn shifted by +-3 units on both axes,
/// which shows as a border about 3 units wide.
/// </summary>
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
/// <summary>Statistics text inside the top-right panel.</summary>
private TextMeshProUGUI _text = null!;
/// <summary>Progress bar background object (parent of the fill and the percentage text).</summary>
private GameObject _progressBarObj = null!;
/// <summary>Progress bar fill object.</summary>
private GameObject _progressBarFillObj = null!;
/// <summary>Fill rect, anchored to the left edge of the bar; its width is set every frame.</summary>
private RectTransform _fillRect = null!;
/// <summary>Text on the bar: percentage and "current floor / last floor index".</summary>
private TextMeshProUGUI _progressText = null!;
/// <summary>
/// Builds the overlay canvas, the statistics panel and the progress bar. Layout values are canvas units at the
/// 1920x1080 reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
// Canvas setup. Sorting order 9999 is below EscapeHandlerUI (10000) and the dialogs/windows of the mod.
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
// Dynamic resolution scaling setup (Reference: 1920x1080)
// Dynamic resolution scaling against a 1920x1080 reference resolution; match 0.5 blends width and height
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -49,7 +83,7 @@ internal class DebugMonitorUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// Create background panel
// Create background panel: 360x220 units, pivot and anchor at the top-right corner, inset 30 units from both edges
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
@@ -64,7 +98,7 @@ internal class DebugMonitorUI : MonoBehaviour
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// Create text
// Create text: 16 pt rich text, top-left aligned, filling the panel minus a 20-unit margin
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
@@ -74,11 +108,11 @@ internal class DebugMonitorUI : MonoBehaviour
_text.fontSize = 16;
_text.alignment = TextAlignmentOptions.TopLeft;
// Prevent TMPro text clipping
// Wrap long lines and let text that exceeds the panel height overflow instead of being truncated
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// Load and apply font
// Apply the mod's bundled font (loaded by ModAssets.Load at mod initialization)
_text.font = ModAssets.NanumSquareNeoNormal;
var textRect = textObj.GetComponent<RectTransform>();
@@ -88,7 +122,7 @@ internal class DebugMonitorUI : MonoBehaviour
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
// 1. Progress Bar Container: centered anchor/pivot, placed at (BarPosX, BarPosY) from the screen center
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
@@ -108,7 +142,8 @@ internal class DebugMonitorUI : MonoBehaviour
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
// 2. Progress Bar Fill: anchored to the left edge and stretched vertically (anchor x 0..0, y 0..1).
// sizeDelta.x is the fill width in units; Update sets it to BarWidth * progress.
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
@@ -123,7 +158,7 @@ internal class DebugMonitorUI : MonoBehaviour
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
// 3. Progress Bar Text: centered over the whole bar
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
@@ -135,7 +170,7 @@ internal class DebugMonitorUI : MonoBehaviour
_progressText.font = ModAssets.NanumSquareNeoNormal;
_progressText.color = BarTextColor;
// Add progress bar text outline for readability
// Black outline (90% opacity, 1.5-unit offset) keeps the white text readable over both the fill and the background
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
@@ -147,10 +182,22 @@ internal class DebugMonitorUI : MonoBehaviour
textRectBar.offsetMax = Vector2.zero;
}
/// <summary>
/// Refreshes the statistics text and the progress bar from <see cref="RenderManager"/> and the game state.
/// Values fall back to 0 (and the queue limit to <c>RenderManager.MaxQueuedFrames</c>) when no RenderManager exists.
/// </summary>
private void Update()
{
if (!_text) return;
// Statistics lines:
// Queue - frames waiting in ImageQueue for the render thread / queue frame limit
// Rendered Frames - RenderManager.frameCount (frames produced by the game)
// Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread)
// Throttling - RenderManager.throttleCount (times the main Unity thread started waiting because ImageQueue
// held more frames than its limit)
// Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error)
// Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
@@ -158,6 +205,7 @@ internal class DebugMonitorUI : MonoBehaviour
var queueCount = 0;
if (RenderManager.Instance)
{
// ImageQueue is shared with the render thread, so its count is read under the same lock
lock (RenderManager.Instance.ImageQueue)
{
queueCount = RenderManager.Instance.ImageQueue.Count;
@@ -177,7 +225,9 @@ internal class DebugMonitorUI : MonoBehaviour
_text.text = sb.ToString();
// Update Progress Bar
// Update Progress Bar.
// progress = seqID of the current floor / index of the last floor (listFloors.Count - 1), clamped to 0..1.
// The text shows the percentage with one decimal and "(current / last)".
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
+37 -9
View File
@@ -6,15 +6,33 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Hold-Escape-to-cancel handler shown during a render.
/// <para>
/// Created by <c>RenderManager</c> when the render reaches the OnScene stage, on a GameObject kept across scene loads.
/// While Escape is held, a centered panel shows a countdown from 3 seconds; holding it for the full 3 seconds
/// (measured in real time with a <see cref="Stopwatch"/>, independent of the patched game time) calls
/// <c>RenderManager.CancelRender</c>. Releasing Escape earlier hides the panel and resets the countdown.
/// The component destroys its GameObject as soon as <c>RenderManager.IsActive</c> is false (stage below OnScene).
/// Runs on the main Unity thread.
/// </para>
/// </summary>
internal class EscapeHandlerUI : MonoBehaviour
{
/// <summary>Centered countdown panel (background and text); inactive while Escape is not held.</summary>
private GameObject _containerObj = null!;
/// <summary>Countdown text inside <see cref="_containerObj"/>.</summary>
private TextMeshProUGUI _textComponent = null!;
/// <summary>Real-time hold duration of the current Escape press; <c>null</c> while Escape is not held.</summary>
private Stopwatch? _escapeStopwatch;
/// <summary>
/// Builds the overlay canvas and the hidden countdown panel. Layout values are canvas units at the 1920x1080
/// reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
@@ -22,7 +40,7 @@ internal class EscapeHandlerUI : MonoBehaviour
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // Placed above debug monitor
// Responsive Canvas Scaler setup
// Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -31,7 +49,7 @@ internal class EscapeHandlerUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create container and background panel object
// 1. Create container and background panel object: 460x160 units at the screen center
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
@@ -46,7 +64,7 @@ internal class EscapeHandlerUI : MonoBehaviour
containerRect.anchoredPosition = Vector2.zero; // Screen center
containerRect.sizeDelta = new Vector2(460, 160);
// 2. Create text object
// 2. Create text object: 60 pt centered rich text, no wrapping, 10-unit margin inside the panel
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
@@ -57,7 +75,8 @@ internal class EscapeHandlerUI : MonoBehaviour
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// Apply game's default font (RDString.fontData.fontTMP)
// Apply the game's TextMeshPro font for the current language (RDString.fontData.fontTMP, assigned in
// RDString.Setup). It is null before RDString.Setup has run; TextMeshPro's default font is kept then.
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
@@ -71,9 +90,13 @@ internal class EscapeHandlerUI : MonoBehaviour
_containerObj.SetActive(false);
}
/// <summary>
/// Tracks the Escape key every frame: shows and updates the countdown while it is held, cancels the render after
/// 3 seconds of holding, and hides the panel when it is released.
/// </summary>
private void Update()
{
// Destroy self if rendering is no longer active
// Destroy self once the render is inactive (stage below OnScene: finished, cancelled or failed)
if (!RenderManager.IsActive)
{
Destroy(gameObject);
@@ -82,6 +105,7 @@ internal class EscapeHandlerUI : MonoBehaviour
if (Input.GetKey(KeyCode.Escape))
{
// First frame of the press: start timing and show the panel
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
@@ -92,13 +116,14 @@ internal class EscapeHandlerUI : MonoBehaviour
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
// Update remaining time text
// Update remaining time text (seconds, one decimal). The text is fixed English, not localized.
_textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
// Micro pulse animation (container smoothly scales up while holding)
// Grow animation: the panel scales linearly from 1.0 to 1.15 over the 3-second hold
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
// Held for the full 3 seconds: reset the panel, then cancel
if (elapsed < 3f) return;
_escapeStopwatch.Stop();
@@ -106,12 +131,14 @@ internal class EscapeHandlerUI : MonoBehaviour
_containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false);
// Cancel rendering and exit scene
// Cancel rendering: RenderManager.CancelRender resets the render state, stops the encoder as cancelled and
// ends the render. StopFFmpeg also destroys this handler's GameObject.
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
else
{
// Escape released before 3 seconds: stop timing and hide the panel
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
@@ -120,6 +147,7 @@ internal class EscapeHandlerUI : MonoBehaviour
}
}
/// <summary>Stops the hold stopwatch if a press is in progress.</summary>
private void OnDestroy()
{
if (_escapeStopwatch == null) return;
+143 -18
View File
@@ -15,27 +15,71 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Modal dialog that installs the FFmpeg shared libraries into MelonLoader's UserLibs folder.
/// <para>
/// Shown by <c>FirstUpdateHook</c> when <see cref="CheckFFmpegExists"/> reports missing libraries. On Windows and Linux
/// the "Download" button downloads a prebuilt BtbN FFmpeg build (GPL, shared libraries) and extracts the libraries
/// into UserLibs; on macOS the button opens https://ffmpeg.org, because the dialog text tells the user to build FFmpeg
/// manually there. The libraries are found by FFmpeg.AutoGen through <c>ffmpeg.RootPath</c> (set to UserLibs in
/// <c>RenderMod.OnInitializeMelon</c>).
/// </para>
/// <para>
/// Threads: UI construction and all Unity object access happen on the main Unity thread. The download and extraction
/// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main Unity thread.
/// </para>
/// <para>
/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract
/// libraries -> "complete" for 1 second -> dialog destroyed. On failure the status shows the error and the buttons
/// return so the user can retry or cancel. Cancel destroys the dialog at once and cancels a running download.
/// </para>
/// </summary>
internal class FFmpegDownloadUI : MonoBehaviour
{
/// <summary>Row with the Download/Open Website and Cancel buttons; hidden while a download runs.</summary>
private GameObject _buttonContainerObj = null!;
/// <summary>Progress bar background (rounded, theme colors); shown when the download starts.</summary>
private GameObject _progressBarObj = null!;
/// <summary>Progress bar fill object.</summary>
private GameObject _progressBarFillObj = null!;
/// <summary>Fill rect; its <c>anchorMax.x</c> is the progress fraction 0..1 (see <see cref="UpdateProgress"/>).</summary>
private RectTransform _fillRect = null!;
/// <summary>Text centered on the progress bar (percentage or "Extracting...").</summary>
private TextMeshProUGUI _progressText = null!;
/// <summary>Status line below the progress bar; shown when the download starts.</summary>
private GameObject _statusTextObj = null!;
/// <summary>Status message, colored by state (gray starting, cyan downloading, yellow extracting, green done, red failed).</summary>
private TextMeshProUGUI _statusText = null!;
/// <summary>O5Kit window holding the dialog contents; disposed in <see cref="OnDestroy"/>.</summary>
private O5Window? _window;
/// <summary>True from the Download click until the download fails; prevents a second concurrent download.</summary>
private bool _isDownloading;
/// <summary>
/// Archive URL for the current platform: a fixed BtbN FFmpeg-Builds autobuild (2026-05-31, FFmpeg master
/// N-124714-g49a77d37be, GPL, shared libraries). Windows gets the win64 .zip; every other platform gets the
/// linux64 .tar.xz (macOS never downloads, see <see cref="Start"/>).
/// </summary>
private string DownloadUrl => Application.platform switch
{
RuntimePlatform.WindowsPlayer => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-win64-gpl-shared.zip",
_ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl-shared.tar.xz"
};
/// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary>
private System.Threading.CancellationTokenSource? _cts;
/// <summary>Actions posted from background threads, run in order on the main Unity thread by <see cref="Update"/>.</summary>
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
/// <summary>
/// Whether UserLibs contains at least one file for each of the five FFmpeg libraries the mod uses
/// (avcodec, avformat, avutil, swscale, swresample), matched by platform-specific file name patterns:
/// <c>avcodec-*.dll</c> on Windows, <c>libavcodec.*.dylib</c> on macOS, <c>libavcodec.so*</c> elsewhere.
/// Only file presence is checked, not the library version or whether it loads.
/// </summary>
/// <returns><c>false</c> when UserLibs does not exist or any library is missing.</returns>
public static bool CheckFFmpegExists()
{
var userLibs = MelonEnvironment.UserLibsDirectory;
@@ -68,6 +112,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
return true;
}
/// <summary>
/// Creates the dialog on a new GameObject kept across scene loads, unless the libraries are already present.
/// Main thread only.
/// </summary>
public static void ShowDownloadUI()
{
@@ -78,8 +126,13 @@ internal class FFmpegDownloadUI : MonoBehaviour
obj.AddComponent<FFmpegDownloadUI>();
}
/// <summary>
/// The open dialog, or <c>null</c> after it is destroyed. <c>FirstUpdateHook</c> waits while it is set and checks
/// the libraries again after it closes.
/// </summary>
public static FFmpegDownloadUI? Instance { get; private set; }
/// <summary>Registers <see cref="Instance"/>; destroys the dialog again when the libraries already exist.</summary>
private void Awake()
{
Instance = this;
@@ -89,9 +142,13 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
/// <summary>
/// Builds the canvas, backdrop and window: description, button row, and the hidden progress bar and status line.
/// Layout values are canvas units at the 1920x1080 reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
@@ -99,7 +156,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32767; // Topmost
// Responsive Canvas Scaler setup
// Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -110,7 +167,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop
// 1. Create full screen backdrop: black at 65% opacity, blocks clicks to everything below
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
@@ -125,7 +182,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
// 2. Create actual native O5Window
// 2. Create the O5Kit window: 580x270 units, draggable, fixed size, no close button (Cancel closes it).
// The title is localized once here; the dialog does not follow later language changes.
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_FFmpegDownloadDialog",
@@ -138,6 +196,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
Padding = new RectOffset(16, 16, 16, 16)
});
// Vertical stack: description (takes the remaining height), buttons (40), progress bar (24), status (22),
// 14 units apart
var content = _window.Content;
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
@@ -148,7 +208,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
// 3. Description text
// 3. Description text: "required" line, then either the macOS build/website lines or the download/size lines
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
@@ -172,7 +232,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// 4. Button container
// 4. Button container: two equal-width 40-unit buttons, 10 units apart
_buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(content, false);
_buttonContainerObj.layer = 5;
@@ -189,6 +249,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
btnLayout.childForceExpandWidth = true;
btnLayout.childForceExpandHeight = true;
// Primary button: download and install (Windows/Linux), or open the FFmpeg website and close the dialog (macOS)
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
? StartDownloadProcess
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
@@ -220,7 +281,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
cancelBtnLE.flexibleWidth = 1f;
cancelBtnLE.preferredHeight = 40f;
// 5. Progress bar container
// 5. Progress bar container (hidden until the download starts): rounded background in the theme object color
// with a rounded outline drawn over it
_progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(content, false);
_progressBarObj.layer = 5;
@@ -251,7 +313,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
barOutlineRect.offsetMin = Vector2.zero;
barOutlineRect.offsetMax = Vector2.zero;
// Progress bar fill image
// Progress bar fill image: anchored at the left edge with zero size offset, so its width is set entirely by
// anchorMax.x (0 = empty, 1 = full) in UpdateProgress
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
@@ -268,7 +331,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero;
// Progress text
// Progress text: 13 pt white, centered over the bar
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
@@ -286,7 +349,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero;
// 6. Status text
// 6. Status text (hidden until the download starts): 14 pt, centered
_statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(content, false);
_statusTextObj.layer = 5;
@@ -310,20 +373,29 @@ internal class FFmpegDownloadUI : MonoBehaviour
statusRect.offsetMax = Vector2.zero;
}
/// <summary>Runs every action posted by the background download task, in posting order.</summary>
private void Update()
{
// Execute queued actions on main thread
// Execute queued actions on main Unity thread
while (_mainThreadQueue.TryDequeue(out var action))
{
action?.Invoke();
}
}
/// <summary>
/// Posts an action to run on the main Unity thread during the next <see cref="Update"/>. Safe to call from any thread.
/// Actions posted after the dialog is destroyed are never run.
/// </summary>
private void ExecuteOnMainThread(Action action)
{
_mainThreadQueue.Enqueue(action);
}
/// <summary>
/// Download button handler (Windows/Linux): switches the dialog to progress mode and starts the background task
/// that downloads the archive, extracts the libraries and closes the dialog. Ignored while a download runs.
/// </summary>
private void StartDownloadProcess()
{
if (_isDownloading) return;
@@ -336,7 +408,9 @@ internal class FFmpegDownloadUI : MonoBehaviour
_cts = new System.Threading.CancellationTokenSource();
// Start download in background thread
// Start download in background thread. Everything inside runs on thread-pool threads: Unity objects are only
// touched through UpdateStatus/UpdateProgress/ExecuteOnMainThread. The task is not awaited; its outcome is
// reported through the status text.
Task.Run(async () =>
{
string tempZipPath = "";
@@ -351,6 +425,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
Directory.CreateDirectory(userLibsDir);
}
// Temporary archive path in UserLibs: ffmpeg_temp.zip (Windows, macOS) or ffmpeg_temp.tar.xz (others).
// It is deleted after extraction (ExtractDllsAsync) or on cancellation.
string extension = Application.platform switch
{
RuntimePlatform.WindowsPlayer => ".zip",
@@ -359,6 +435,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
};
tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension);
// percent is the downloaded fraction 0..1. Progress<T> created on a thread-pool thread (no
// SynchronizationContext) invokes this callback on thread-pool threads; it only posts UI updates.
var progressReporter = new Progress<float>(percent =>
{
UpdateProgress(percent, $"{percent * 100f:F1}%");
@@ -367,7 +445,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
// Extract after download complete
// Extract after download complete; the bar is held at 99% while extracting
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
@@ -377,9 +455,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f));
UpdateProgress(1f, "100%");
// Keep the "complete" message visible for 1000 ms before closing
await Task.Delay(1000);
// Destroy UI on main thread
// Destroy UI on main Unity thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
ExecuteOnMainThread(() =>
{
Destroy(gameObject);
@@ -387,7 +466,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
catch (OperationCanceledException)
{
// Clean up temporary file on cancellation
// Clean up temporary file on cancellation. Cancellation comes from CancelProcess, which has already
// destroyed the dialog, so the status update below is posted but never shown.
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
{
try { File.Delete(tempZipPath); }
@@ -403,7 +483,9 @@ internal class FFmpegDownloadUI : MonoBehaviour
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_failed", ex.Message), new Color(1f, 0.3f, 0.3f));
// Reset buttons on failure
// Reset buttons on failure so the user can retry (Download) or close the dialog (Cancel).
// The label is set to "Retry" only if a child at "DownloadButton/DownloadText" exists. The button made by
// O5Factory.Button in Start is a GameObject named "O5Button", which this path does not match.
ExecuteOnMainThread(() =>
{
_isDownloading = false;
@@ -418,6 +500,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
});
}
/// <summary>
/// Cancel button handler: requests cancellation of a running download and destroys the dialog immediately.
/// An extraction already in progress is not cancellable and finishes in the background.
/// </summary>
private void CancelProcess()
{
if (_isDownloading)
@@ -427,6 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
Destroy(gameObject);
}
/// <summary>Posts a status text and color change to the main Unity thread. Callable from any thread.</summary>
private void UpdateStatus(string message, Color color)
{
ExecuteOnMainThread(() =>
@@ -439,6 +526,9 @@ internal class FFmpegDownloadUI : MonoBehaviour
});
}
/// <summary>Posts a progress bar update to the main Unity thread. Callable from any thread.</summary>
/// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param>
/// <param name="text">Text shown on the bar.</param>
private void UpdateProgress(float percent, string text)
{
ExecuteOnMainThread(() =>
@@ -454,15 +544,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
});
}
/// <summary>
/// Streams the HTTP response body of <paramref name="url"/> to <paramref name="destinationPath"/> (created or
/// overwritten) in 8 KiB chunks. Runs on a thread-pool thread.
/// </summary>
/// <param name="url">Archive URL.</param>
/// <param name="destinationPath">Output file path.</param>
/// <param name="progress">
/// Receives the downloaded fraction 0..1 after every chunk; not reported when the server sends no Content-Length.
/// </param>
/// <param name="cancellationToken">Cancels the request and the copy loop (<see cref="OperationCanceledException"/>).</param>
/// <exception cref="HttpRequestException">The server returned a non-success status code.</exception>
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
{
using var client = new HttpClient();
// Add User-Agent header to prevent blocking
// Add User-Agent header to prevent blocking by servers that reject requests without one
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
// ResponseHeadersRead returns after the headers, so the body is streamed instead of buffered in memory
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
// Total size from Content-Length, or -1 when unknown. File stream: 8192-byte buffer, asynchronous I/O.
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
await using var contentStream = await response.Content.ReadAsStreamAsync();
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
@@ -483,12 +586,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
/// <summary>
/// Extracts the shared libraries from the downloaded archive into <paramref name="outputDirectory"/>, overwriting
/// files of the same name, and deletes the archive afterwards (also on failure; a failed delete is logged as a
/// warning). Runs on thread-pool threads and is not cancellable.
/// <list type="bullet">
/// <item>Windows/macOS (.zip): every entry ending in .dll (Windows) or .dylib (macOS) is extracted flat, by its file
/// name only, ignoring its folder inside the archive.</item>
/// <item>Other platforms (.tar.xz): the archive is unpacked with the system <c>tar</c> command into the temporary
/// folder "ffmpeg_temp_extracted" in <paramref name="outputDirectory"/>; every file matching <c>*.so*</c> in any
/// subfolder is copied flat to <paramref name="outputDirectory"/>, then the temporary folder is deleted.</item>
/// </list>
/// </summary>
/// <param name="zipPath">Downloaded archive path (.zip or .tar.xz despite the parameter name).</param>
/// <param name="outputDirectory">UserLibs folder.</param>
/// <exception cref="Exception"><c>tar</c> exited with a non-zero code (message includes its standard error).</exception>
private async Task ExtractDllsAsync(string zipPath, string outputDirectory)
{
try
{
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
{
// .zip: extract matching library entries flat into outputDirectory
await Task.Run(() =>
{
var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll";
@@ -522,6 +641,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
Directory.CreateDirectory(tempExtractDir);
// tar receives forward-slash paths, quoted for spaces: tar -xf "<archive>" -C "<temp dir>"
var archiveUnixPath = zipPath.Replace('\\', '/');
var tempExtractUnixDir = tempExtractDir.Replace('\\', '/');
@@ -545,7 +665,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
// Find .so files and copy to outputDirectory
// Find .so files (including versioned names such as libavcodec.so.<major>) and copy them flat to
// outputDirectory
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
foreach (var soFile in soFiles)
{
@@ -582,6 +703,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
/// <summary>
/// Disposes the window and the cancellation source and clears <see cref="Instance"/>. A download that was not
/// cancelled keeps running in the background; its UI updates are posted but no longer drained.
/// </summary>
private void OnDestroy()
{
_window?.Dispose();
+36 -2
View File
@@ -8,15 +8,25 @@ namespace ADOFAIRenderer.UI;
/// <summary>
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
/// automatically updating the displayed text whenever the active language changes.
/// <para>
/// The text is set when the binding is made (<see cref="Bind(TextMeshProUGUI, string)"/>), when the component is enabled,
/// and on every <see cref="LocalizationManager.OnLanguageChanged"/> event. A key and a dynamic provider are mutually
/// exclusive: setting one clears the other. With neither set, <see cref="Refresh"/> leaves the text unchanged.
/// Runs on the main Unity thread.
/// </para>
/// </summary>
internal class LocalizedText : MonoBehaviour
{
/// <summary>Text component that receives the string; looked up on the same GameObject if not assigned by Bind.</summary>
private TextMeshProUGUI? _textComponent;
/// <summary>Localization key resolved through <see cref="LocalizationManager.Get(string)"/>; <c>null</c> when a provider is used.</summary>
private string? _key;
/// <summary>Function that returns the full text (already localized/formatted); <c>null</c> when a key is used.</summary>
private Func<string>? _dynamicProvider;
/// <summary>
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
/// Gets or sets the localization key. Setting this will clear any dynamic provider and refresh the text immediately.
/// A null or empty key leaves the current text as it is.
/// </summary>
public string? Key
{
@@ -30,8 +40,11 @@ internal class LocalizedText : MonoBehaviour
}
/// <summary>
/// Sets a dynamic string provider function.
/// Sets a dynamic string provider function, clears the key and refreshes the text immediately.
/// The provider is called on every refresh (enable and language change), so it can combine several keys or
/// runtime values, e.g. a label that depends on UI state.
/// </summary>
/// <param name="provider">Function returning the text to display; called on the main Unity thread.</param>
public void SetDynamic(Func<string> provider)
{
_dynamicProvider = provider;
@@ -39,27 +52,35 @@ internal class LocalizedText : MonoBehaviour
Refresh();
}
/// <summary>
/// Finds the text component and subscribes to language changes. The subscription lasts until <see cref="OnDestroy"/>,
/// also while the component is disabled (a refresh of a disabled object only changes its text).
/// </summary>
private void Awake()
{
EnsureComponent();
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
}
/// <summary>Refreshes the text each time the component is enabled.</summary>
private void OnEnable()
{
Refresh();
}
/// <summary>Unsubscribes from <see cref="LocalizationManager.OnLanguageChanged"/>.</summary>
private void OnDestroy()
{
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
}
/// <summary>Language change handler; the new language is read by <see cref="LocalizationManager.Get(string)"/>.</summary>
private void HandleLanguageChanged(SystemLanguage language)
{
Refresh();
}
/// <summary>Assigns <see cref="_textComponent"/> from the same GameObject if it is not set yet.</summary>
private void EnsureComponent()
{
if (_textComponent == null)
@@ -70,6 +91,8 @@ internal class LocalizedText : MonoBehaviour
/// <summary>
/// Refreshes the text using either the dynamic provider or the static localization key.
/// The provider takes precedence; with neither set, or without a text component, nothing changes.
/// A key that is missing in every locale is displayed as the key itself (see <see cref="LocalizationManager.Get(string)"/>).
/// </summary>
public void Refresh()
{
@@ -88,7 +111,13 @@ internal class LocalizedText : MonoBehaviour
/// <summary>
/// Binds a TextMeshProUGUI component to a localization key.
/// Reuses the <see cref="LocalizedText"/> already on the target's GameObject or adds one, then sets the key,
/// which replaces any earlier binding and updates the text immediately.
/// </summary>
/// <param name="target">Text to localize.</param>
/// <param name="key">Localization key, e.g. <c>"ui.render_config.codec"</c>.</param>
/// <returns>The binding component.</returns>
/// <exception cref="ArgumentNullException"><paramref name="target"/> is null.</exception>
public static LocalizedText Bind(TextMeshProUGUI target, string key)
{
if (target == null) throw new ArgumentNullException(nameof(target));
@@ -100,7 +129,12 @@ internal class LocalizedText : MonoBehaviour
/// <summary>
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
/// Reuses or adds the component like <see cref="Bind(TextMeshProUGUI, string)"/> and updates the text immediately.
/// </summary>
/// <param name="target">Text to localize.</param>
/// <param name="provider">Function returning the text; called on every refresh.</param>
/// <returns>The binding component.</returns>
/// <exception cref="ArgumentNullException"><paramref name="target"/> is null.</exception>
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
{
if (target == null) throw new ArgumentNullException(nameof(target));
+64 -2
View File
@@ -8,9 +8,20 @@ namespace ADOFAIRenderer.UI;
using UnityEngine;
/// <summary>
/// Assets loaded from the mod's embedded asset bundles (fonts and the color conversion compute shader).
/// <para>
/// <see cref="Load"/> is called once from <c>RenderMod.OnInitializeMelon</c>; the fields stay <c>null</c> until then.
/// Bundles are obtained from <c>BundleLoader.GetBundle</c> by name ("fonts", "shaders"). Main thread only.
/// </para>
/// </summary>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal static class ModAssets
{
/// <summary>
/// Loads the font and shader bundles and the assets inside them, logging after each bundle.
/// Exceptions from bundle or asset loading are not caught here and propagate to the caller.
/// </summary>
public static void Load()
{
FontBundle = BundleLoader.GetBundle("fonts");
@@ -23,24 +34,49 @@ internal static class ModAssets
Melon<RenderMod>.Logger.Msg("Shader loaded successfully.");
}
/// <summary>Asset bundle "fonts" holding the TextMeshPro font assets.</summary>
public static AssetBundle FontBundle = null!;
/// <summary>Asset bundle "shaders" holding the compute shaders.</summary>
public static AssetBundle ShaderBundle = null!;
/// <summary>
/// NanumSquare Neo, asset "NanumSquareNeoOTF-Bd SDF" (Bd = Bold weight). Used as the regular UI font of the mod
/// (O5Kit <see cref="RendererFontProvider.Regular"/>, DebugMonitorUI text).
/// </summary>
public static TMP_FontAsset NanumSquareNeoNormal = null!;
/// <summary>
/// NanumSquare Neo, asset "NanumSquareNeoOTF-Eb SDF" (Eb = ExtraBold weight). Used as the O5Kit medium font
/// (<see cref="RendererFontProvider.Medium"/>).
/// </summary>
public static TMP_FontAsset NanumSquareNeoBold = null!;
/// <summary>
/// Compute shader "ColorConversion" that converts captured frames to the encoder's YUV pixel format on the GPU
/// (used by FrameReader).
/// </summary>
public static ComputeShader ColorConversionShader = null!;
}
/// <summary>
/// O5Kit font provider for the mod's UI: the bundled NanumSquare Neo fonts for regular and medium text, and O5Kit's
/// default monospace font.
/// </summary>
internal sealed class RendererFontProvider : IFontProvider
{
/// <summary>O5Kit default provider, used only for <see cref="Monospace"/>.</summary>
private static readonly DefaultFontProvider DefaultFontProvider = new();
private bool _disposed;
/// <summary>Regular text font: <see cref="ModAssets.NanumSquareNeoNormal"/>.</summary>
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
/// <summary>Medium (emphasis) text font: <see cref="ModAssets.NanumSquareNeoBold"/>.</summary>
public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold;
/// <summary>Monospace font (e.g. hex color input): O5Kit's default monospace font.</summary>
public TMP_FontAsset Monospace => DefaultFontProvider.Monospace;
/// <summary>
/// Marks the provider disposed. The font assets belong to <see cref="ModAssets"/> and are not unloaded.
/// </summary>
public void Dispose()
{
if (_disposed) return;
@@ -48,19 +84,39 @@ internal sealed class RendererFontProvider : IFontProvider
}
}
/// <summary>
/// Owner of the mod's O5Kit UI: the shared <see cref="O5Context"/> (config, theme, fonts) and the window manager
/// with its canvas.
/// <para>
/// Added once by <c>RenderMod.OnInitializeMelon</c> to a GameObject kept across scene loads. Other UI code reads
/// <see cref="Context"/> and <see cref="WindowManager"/> through the static <see cref="Instance"/>.
/// <see cref="Update"/> drives O5Kit's per-frame work (object ticks, tooltip, keyboard shortcuts). Main thread only.
/// </para>
/// </summary>
internal class RendererUIManager : MonoBehaviour
{
/// <summary>The single instance, assigned in <see cref="Awake"/>.</summary>
public static RendererUIManager Instance = null!;
/// <summary>Shared O5Kit context used to build every mod window and dialog.</summary>
public static O5Context Context => Instance._context;
/// <summary>Window manager whose canvas holds the mod windows (e.g. RenderConfigWindow).</summary>
public O5WindowManager WindowManager => _windowManager;
private O5Context _context = null!;
private O5WindowManager _windowManager = null!;
/// <summary>
/// Creates the O5Kit context with the mod theme and fonts, then the window manager and its canvas
/// ("RendererMod_Canvas"), and lowers that canvas's sorting order to <see cref="WindowCanvasSortingOrder"/>.
/// </summary>
private void Awake() {
Instance = this;
// O5Kit config: UI scale 1.0, middle click resets a control to its default value, slider drag sensitivity 1.0.
// Theme: O5Kit's dark theme with the mod colors. Colors are RGBA 0..1; the accent color
// (1.000, 0.294, 0.079) is orange-red (#FF4B14 approx.). Sizes are canvas units: corner radius 12,
// outline width 2, control height 40, body font 18, H1 font 48.
_context = new O5Context(
config: new O5Config {
UIScale = 1.0f,
@@ -101,14 +157,20 @@ internal class RendererUIManager : MonoBehaviour
if (windowCanvas != null) windowCanvas.sortingOrder = WindowCanvasSortingOrder;
}
/// <summary>
/// Sorting order of the window canvas: below CommonWarningDialogUI (32766) and FFmpegDownloadUI (32767), above the
/// render overlays (DebugMonitorUI 9999, EscapeHandlerUI 10000).
/// </summary>
private const int WindowCanvasSortingOrder = 32000;
/// <summary>Runs O5Kit's per-frame updates: object ticks, tooltip, and keyboard shortcut handling.</summary>
private void Update() {
O5Object.TickAll();
Context.Tooltip.Tick();
O5Kit.Input.O5ShortcutManager.HandleUpdate();
}
/// <summary>Disposes the window manager, then the context.</summary>
private void OnDestroy() {
_windowManager.Dispose();
_context.Dispose();
File diff suppressed because it is too large. Load diff
-100
View File
@@ -1,100 +0,0 @@
using System;
namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
{
// ── Safety Validation ──
if (inputSamples.Length == 0 ||
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0)
{
return Array.Empty<float>();
}
// If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
{
double srcIndex = (double)i * inputFrequency / targetFrequency;
long idxLow = (long)Math.Floor(srcIndex);
long idxHigh = idxLow + 1;
float t = (float)(srcIndex - idxLow);
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
if (idxLow >= inputLength) idxLow = inputLength - 1;
if (targetChannels == 1) // 1. Mono Output
{
float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono
{
valLow = inputSamples[idxLow];
valHigh = inputSamples[idxHigh];
}
else // Multi -> Mono (Downmix: Average all channels)
{
float sumLow = 0f;
float sumHigh = 0f;
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
}
else if (targetChannels == 2) // 2. Stereo Output
{
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
{
float valLow = inputSamples[idxLow];
float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
float valLowR = inputSamples[idxLow * inputChannels + 1];
float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
}
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{
float valLow, valHigh;
if (c < inputChannels)
{
valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh * inputChannels + c];
}
else
{
valLow = inputSamples[idxLow * inputChannels];
valHigh = inputSamples[idxHigh * inputChannels];
}
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
}
}
}
return outputSamples;
}
}
+168 -107
View File
@@ -1,164 +1,223 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio.Clip;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Replacement for AudioSource.GetSpectrumData during rendering: the spectrum of the song as the song AudioSource would output it.
///
/// Signal: the song clip from an <see cref="AudioClipStore"/> at the output sample rate (AudioSettings.outputSampleRate),
/// played at conductor.song.pitch (linear interpolation between frames) and scaled by conductor.song.volume.
/// Unity analyzes the output signal of the source: bin spacing follows the output rate, pitch moves the frequencies,
/// and volume scales the magnitudes (measured with a Unity 6000.3.10f1 test project).
/// Channel: stereo clips use the requested channel (0 = left, 1 = right); a mono clip is scaled by <see cref="MonoGain"/>.
/// Window: twice the requested spectrum length in output samples, centered on the current render time.
///
/// The song source is shared with the <see cref="OfflineAudioMixer"/> store when its output rate matches; otherwise a separate
/// store is created. A cursor at the analysis position keeps the needed chunks loaded. Main thread only.
/// </summary>
internal static class AudioSpectrumGenerator
{
// ReSharper disable MemberCanBePrivate.Global
public static float[]? AudioClipData { get; private set; }
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
// ReSharper restore MemberCanBePrivate.Global
/// <summary>
/// Gain of a mono clip relative to one channel of a stereo clip with the same samples.
/// Measured with a Unity 6000.3.10f1 test project: the spectrum of a mono clip is about 0.70 times that of each stereo channel (1/√2).
/// </summary>
private const double MonoGain = 0.70710678118654752;
/// <summary>Store created by this class when the mixer store cannot be shared (disposed by <see cref="Clear"/>); otherwise null.</summary>
private static AudioClipStore? _ownStore;
/// <summary>Store the song source belongs to: the mixer store or <see cref="_ownStore"/>.</summary>
private static AudioClipStore? _store;
/// <summary>Song source at the output sample rate, or null when not initialized or the song could not be loaded.</summary>
private static AudioClipSource? _source;
/// <summary>Cursor on <see cref="_source"/> moved to each analysis window, so the decoder thread loads the chunks there.</summary>
private static AudioCursor? _cursor;
/// <summary>Chunk currently held for <see cref="ReadSample"/> (invalid when none).</summary>
private static ChunkView _view;
/// <summary>True after a chunk loading error was logged (logged once per render).</summary>
private static bool _loadErrorLogged;
/// <summary>RenderManager.frameCount of the spectra in <see cref="CachedSpectrums"/>; -1 when empty.</summary>
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
/// <summary>
/// Spectra computed in the current render frame, by spectrum length and source channel. Several game objects read the
/// spectrum every frame; within one frame the render time does not change, so they receive the same values.
/// </summary>
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
public static void Initialize(AudioClip? clip, OfflineAudioMixer? mixer)
{
if (!clip)
{
Clear();
return;
}
Clear();
if (!clip) return;
try
{
Channels = clip.channels;
Frequency = clip.frequency;
int samplesCount = clip.samples;
float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
var rate = AudioSettings.outputSampleRate;
if (mixer != null && mixer.Store.OutputRate == rate)
{
AudioClipData = rawSamples;
TotalSamples = samplesCount;
_store = mixer.Store;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
_ownStore = new AudioClipStore(rate);
_ownStore.Start();
_store = _ownStore;
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
_source = AudioClipResolver.GetOrAdd(_store, clip!.name, clip);
if (_source == null)
{
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song clip '{clip.name}' could not be loaded, the spectrum will be empty.");
return;
}
_cursor = _source.AddCursor(0, false);
Melon<RenderMod>.Logger.Msg($"[AudioSpectrumGenerator] Song '{clip.name}': {_source.Channels} ch at {rate} Hz ({(_ownStore != null ? "own store" : "shared with the mixer")})");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}");
Clear();
}
}
/// <summary>Releases the chunk, the cursor and the own store. Call before the mixer store is disposed.</summary>
public static void Clear()
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
if (_source != null)
{
_cachedFrame = -1;
CachedSpectrums.Clear();
_source.Release(_view);
if (_cursor != null) _source.RemoveCursor(_cursor);
}
_view = default;
_cursor = null;
_source = null;
_store = null;
_ownStore?.Dispose();
_ownStore = null;
_loadErrorLogged = false;
_cachedFrame = -1;
CachedSpectrums.Clear();
}
/// <summary>
/// Fills <paramref name="samples"/> with the magnitude spectrum of the song at the current render time
/// (called by the AudioSource.GetSpectrumData patch while capturing with ProcessAudio on, for any AudioSource).
/// Bin k covers frequency k * outputRate / (2 * samples.Length) Hz in output time; values are 0 ~ 1.
/// Zeros are written when the song is not available, the conductor has not started the song, or the length is not a power of two.
/// <paramref name="window"/> is not read: the Blackman-Harris window is always used (every GetSpectrumData call in the game
/// passes FFTWindow.BlackmanHarris). Main thread only.
/// </summary>
/// <param name="samples">Destination; its length is the spectrum length (power of two).</param>
/// <param name="channel">Requested channel (0 = left, 1 = right), clamped to the song's channels; ignored for mono songs.</param>
/// <param name="window">Requested window (not used).</param>
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
var conductor = scrConductor.instance;
if (_source == null || _store == null || !conductor || !conductor!.hasSongStarted || !conductor.song
|| n == 0 || (n & (n - 1)) != 0)
{
Array.Clear(samples, 0, samples.Length);
Array.Clear(samples, 0, n);
return;
}
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
var render = RenderManager.Instance;
var currentFrame = render.frameCount;
if (currentFrame != _cachedFrame)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Copy(cached, samples, n);
return;
}
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
var sourceChannel = _source.Channels == 1 ? 0 : Math.Min(Math.Max(channel, 0), _source.Channels - 1);
if (CachedSpectrums.TryGetValue((n, sourceChannel), out var cached))
{
Array.Copy(cached, samples, n);
return;
}
// Song position (seconds of song time) at the current render time, as in scrConductor.StartMusicCo:
// the song starts at dspTimeSong + separate countdown (crotchetAtStart * adjustedCountdownTicks in song time).
double pitch = conductor.song.pitch;
double volume = conductor.song.volume;
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
var now = render.dspTimeBase + render.AudioTime;
var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown;
// Window of 2n output samples centered on the current time: output sample i reads source frame first + i * pitch,
// so the window starts n output samples (n * pitch source frames) before the song position.
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
var first = songSeconds * _store.OutputRate - n * pitch;
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
try
{
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
// Keep the cursor at the window start so the decoder thread loads (and keeps) the chunks the window reads.
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
for (var i = 0; i < fftSize; i++)
{
windowSamples[i] = AudioClipData[idx];
var position = first + i * pitch;
var index = (long)Math.Floor(position);
var fraction = position - index;
var s0 = ReadSample(index, sourceChannel);
var s1 = fraction > 0 ? ReadSample(index + 1, sourceChannel) : 0.0;
windowSamples[i] = (float)((s0 + (s1 - s0) * fraction) * gain);
}
else
}
catch (InvalidOperationException e)
{
if (!_loadErrorLogged)
{
windowSamples[i] = 0f;
_loadErrorLogged = true;
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song samples are unavailable, the spectrum will be empty: {e.Message}");
}
Array.Clear(samples, 0, n);
return;
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
CachedSpectrums[(n, sourceChannel)] = spectrum;
Array.Copy(spectrum, samples, n);
}
/// <summary>Sample of source channel <paramref name="channel"/> at <paramref name="frame"/>; 0 outside the clip or when the source failed.</summary>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
private static double ReadSample(long frame, int channel)
{
var source = _source!;
if (frame < 0 || frame >= source.TotalFrames) return 0.0;
if (!_view.IsValid || !_view.Contains(frame))
{
source.Release(_view);
_view = default;
_view = _store!.Acquire(source, frame);
if (!_view.IsValid) return 0.0;
}
return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel];
}
/// <summary>
/// Magnitude spectrum of <paramref name="samples"/> (length n, a power of two) after the Blackman-Harris window:
/// writes min(n / 2, spectrum.Length) bins, bin k = min(|X[k]| * 2.5 / n, 1).
/// </summary>
private static void CalculateFFT(float[] samples, float[] spectrum)
{
var n = samples.Length;
// Apply Blackman-Harris window
// Blackman-Harris window, periodic form: w(i) = a0 - a1 cos(2πi/n) + a2 cos(4πi/n) - a3 cos(6πi/n), i = 0 ~ n - 1
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
@@ -169,16 +228,16 @@ internal static class AudioSpectrumGenerator
for (var i = 0; i < n; i++)
{
var angle = 2.0 * Math.PI * i / (n - 1);
var angle = 2.0 * Math.PI * i / n;
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
}
// Cooley-Tukey Radix-2 FFT
// Cooley-Tukey Radix-2 FFT (iterative, in place, decimation in time); m = log2(n) stages.
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
// Bit-reversal: reorder the input so element i moves to the index with the bits of i reversed (j tracks that index).
var j = 0;
for (var i = 0; i < n - 1; i++)
{
@@ -201,7 +260,8 @@ internal static class AudioSpectrumGenerator
j += k;
}
// FFT stages
// FFT stages: stage s combines pairs of transforms of length le/2 into length le = 2^s with butterflies.
// (ur, ui) is the twiddle factor exp(-i * pi * sub / le2), advanced by multiplying with (wr, wi) = exp(-i * pi / le2).
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
@@ -230,12 +290,13 @@ internal static class AudioSpectrumGenerator
}
}
// Normalize and fill spectrum
var factor = 2.0f / n;
// Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData.
// A sine of amplitude A centered on a bin gives A * a0 * 1.25 with the Blackman-Harris window.
var factor = 2.5f / n;
var outLen = Math.Min(spectrum.Length, n / 2);
for (var i = 0; i < outLen; i++)
{
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
spectrum[i] = Math.Min((float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor, 1f);
}
}
}
}
+21
View File
@@ -2,29 +2,50 @@ using System;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Splits the audio sample stream into per-video-frame sample counts so that audio and video stay aligned without drift.
///
/// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>).
/// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples:
/// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact
/// sample count of that duration rounded down (for example 44100 Hz at 60 FPS gives 735 every frame; at 144 FPS three frames of 306
/// are followed by one of 307, 306.25 on average).
/// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder).
/// </summary>
internal class AudioSyncCalculator
{
/// <summary>Audio sample rate in Hz (samples per channel per second).</summary>
private readonly long _sampleRate;
/// <summary>Video frame rate as a fraction (frames per second = Numerator / Denominator).</summary>
private readonly (long Numerator, long Denominator) _fpsRational;
/// <summary>Total samples handed out since construction or <see cref="Reset"/> (kept for debugging, not read).</summary>
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
/// <summary>Remainder of the last division, in units of 1 / Numerator samples (0 ~ Numerator - 1).</summary>
private long _remainder;
/// <param name="sampleRate">Audio sample rate in Hz.</param>
/// <param name="fps">Video frame rate in frames per second (the encoded frame rate).</param>
public AudioSyncCalculator(long sampleRate, double fps)
{
_sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
}
/// <summary>Restarts the sequence at frame 0 (the remainder is cleared).</summary>
public void Reset()
{
_currentSample = 0;
_remainder = 0;
}
/// <summary>Returns the sample count (frames per channel) of the next video frame and advances to the following frame.</summary>
public int GetNextFrameSamples()
{
// samplesNeeded = floor((sampleRate * Denominator + remainder) / Numerator); the new remainder carries the fraction.
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
+47
View File
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// File path of every external audio clip the game loads, keyed by the game's audioLib key.
///
/// The game loads external clips with AudioManager.FindOrLoadAudioClipExternal(path, ...) and stores them in audioLib under
/// Path.GetFileName(path) + "*external" (the same string becomes AudioClip.name). Recorded paths:
/// - Song: Path.Combine(Path.GetDirectoryName(levelPath), levelData.songFilename) (scnGame song loading).
/// - PlaySound event sound: Path.Combine(Path.GetDirectoryName(levelPath), hitsound) (scnGame.LoadSoundCo);
/// ffxPlaySound plays it as hitsound + "*external".
/// <see cref="Record"/> is called only when the game actually loads the file (the key is not in audioLib yet),
/// so the recorded path is the file of the clip audioLib holds under that key.
/// Thread-safe.
/// </summary>
internal static class AudioClipPaths
{
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
public const string ExternalSuffix = "*external";
/// <summary>File path by audioLib key; also the lock object. Entries are kept for the whole game session.</summary>
private static readonly Dictionary<string, string> Paths = new();
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix;
/// <summary>Records <paramref name="path"/> under <see cref="KeyOf"/>(path), replacing an earlier path of the same key.</summary>
public static void Record(string path)
{
if (string.IsNullOrEmpty(path)) return;
lock (Paths)
{
Paths[KeyOf(path)] = path;
}
}
/// <summary>Recorded path for an audioLib key (or AudioClip.name) of an external clip.</summary>
public static bool TryGetPath(string key, out string path)
{
lock (Paths)
{
return Paths.TryGetValue(key, out path!);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Opens the <see cref="AudioClipSource"/> for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name):
/// - Key recorded in <see cref="AudioClipPaths"/> and the file exists: decoded from the file with <see cref="FFmpegPCMReader"/>
/// (mono MP3 through <see cref="MP3MonoLayoutReader"/>)
/// (song files and PlaySound files of custom levels).
/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with <see cref="UnityClipLoader"/>
/// (built-in hit sounds from Resources, official/DLC level songs from Addressables).
/// Main thread only (the Unity copy calls AudioClip.GetData).
/// </summary>
internal static class AudioClipResolver
{
/// <summary>
/// Returns the source registered under <paramref name="key"/> in <paramref name="store"/>, opening it on first use
/// (file first, then the Unity clip; see the class summary). A key that could not be opened is remembered as null.
/// </summary>
/// <param name="store">Store of the current render.</param>
/// <param name="key">audioLib key of the clip (for example "song.ogg*external" or "sndKick").</param>
/// <param name="clip">Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file.</param>
/// <returns>The source, or null when neither the file nor the clip can be read.</returns>
public static AudioClipSource? GetOrAdd(AudioClipStore store, string key, AudioClip? clip)
{
if (store.TryGet(key, out var existing)) return existing;
if (AudioClipPaths.TryGetPath(key, out var path) && File.Exists(path))
{
IPCMReader? fileReader = null;
try
{
fileReader = OpenFile(path);
if (fileReader is MP3MonoLayoutReader)
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower.");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
}
// The reader opened here becomes the first decode stream; additional streams reopen the file with OpenFile.
// When no source results (store disposed, or the source could not be created), the reader is disposed here
// (readers allow Dispose more than once).
if (fileReader != null)
{
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
if (source == null) fileReader.Dispose();
return source;
}
}
if (!clip) return store.GetOrAdd(key, () => null);
return store.GetOrAdd(key, () => UnityClipLoader.Load(clip!, store));
}
/// <summary>Opens <paramref name="path"/> with FFmpeg; mono MP3 files are wrapped in <see cref="MP3MonoLayoutReader"/> (game layout).</summary>
private static IPCMReader OpenFile(string path)
{
var reader = new FFmpegPCMReader(path);
if (!reader.IsMP3 || reader.Channels != 1) return reader;
return new MP3MonoLayoutReader(reader);
}
}
+667
View File
@@ -0,0 +1,667 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Read position registered on an <see cref="AudioClipSource"/> by one consumer (a playing voice or the spectrum generator).
/// For a windowed source, the chunk containing <see cref="Position"/> and the following chunks
/// (<see cref="AudioClipSource.SlotCount"/> chunks in total) are kept loaded.
/// </summary>
internal sealed class AudioCursor
{
private readonly AudioClipSource _source;
/// <summary>Backing value of <see cref="Position"/>, accessed with Interlocked (64-bit reads are not atomic on every platform).</summary>
private long _position;
private volatile bool _loop;
/// <summary>Created by <see cref="AudioClipSource.AddCursor"/>.</summary>
internal AudioCursor(AudioClipSource source, long position, bool loop)
{
_source = source;
_position = position;
_loop = loop;
}
/// <summary>
/// Frame in the source at the output sample rate (0 ~ TotalFrames - 1).
/// Written by the consumer that reads the source; read by the decoder thread.
/// Moving into another chunk wakes the decoder thread.
/// </summary>
public long Position
{
get => Interlocked.Read(ref _position);
set
{
var previous = Interlocked.Exchange(ref _position, value);
if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk)
_source.OnCursorMoved();
}
}
/// <summary>
/// When true, the chunk after the last chunk is chunk 0 (the window wraps to the clip start), and a position past the end
/// wraps into the clip. Setting it wakes the decoder thread.
/// </summary>
public bool Loop
{
get => _loop;
set
{
_loop = value;
_source.OnCursorMoved();
}
}
}
/// <summary>
/// One loaded chunk of an <see cref="AudioClipSource"/>, held by a consumer between
/// <see cref="AudioClipSource.TryAcquire"/> and <see cref="AudioClipSource.Release"/>.
/// The chunk is not freed while held.
/// </summary>
internal readonly struct ChunkView
{
/// <summary>Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels.</summary>
public readonly float[] Data;
/// <summary>Source frame of the first frame in <see cref="Data"/>.</summary>
public readonly long StartFrame;
/// <summary>Valid frames in this chunk. Frames past the end of the source are not included.</summary>
public readonly int FrameCount;
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
public readonly int Channels;
/// <summary>Index of the chunk in its source (used by <see cref="AudioClipSource.Release"/>).</summary>
public readonly int ChunkIndex;
/// <summary>Created by <see cref="AudioClipSource.TryAcquire"/>; default(ChunkView) is the invalid view.</summary>
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
{
Data = data;
StartFrame = startFrame;
FrameCount = frameCount;
Channels = channels;
ChunkIndex = chunkIndex;
}
/// <summary>True for a view that holds a chunk; false for default(ChunkView).</summary>
public bool IsValid => Data != null;
/// <summary>Source frame after the last valid frame (exclusive end).</summary>
public long EndFrame => StartFrame + FrameCount;
/// <summary>True when source frame <paramref name="frame"/> is one of the valid frames of this chunk.</summary>
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
/// <summary>
/// Sample of <paramref name="frame"/> mapped to output channel <paramref name="outChannel"/> of <paramref name="outChannels"/>.
/// Mapping: mono output averages all source channels; a mono source is copied to every output channel;
/// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0.
/// <paramref name="frame"/> must be inside this chunk.
/// </summary>
public float GetSample(long frame, int outChannel, int outChannels)
{
var baseIndex = (int)(frame - StartFrame) * Channels;
if (Channels == 1)
return Data[baseIndex];
if (outChannels == 1)
{
var sum = 0f;
for (var c = 0; c < Channels; c++)
sum += Data[baseIndex + c];
return sum / Channels;
}
return Data[baseIndex + (outChannel < Channels ? outChannel : 0)];
}
}
/// <summary>
/// How an <see cref="AudioClipSource"/> keeps its chunks.
/// </summary>
internal enum ChunkPolicy
{
/// <summary>
/// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor),
/// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand.
/// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk.
/// </summary>
Windowed,
/// <summary>
/// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed.
/// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less.
/// </summary>
Resident
}
/// <summary>
/// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks.
/// Channel count stays as in the clip.
///
/// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where
/// <see cref="FramesPerChunk"/> = <see cref="ChunkFloats"/> / Channels (rounded down; a frame never spans two chunks).
/// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length.
/// See <see cref="ChunkPolicy"/> for which chunks are kept.
///
/// Threads: cursors and TryAcquire/Release may be used from any thread.
/// Loading and freeing run only on the store's decoder thread (<see cref="AudioClipStore"/>), through
/// <see cref="FreeUnused"/>, <see cref="TryGetNextLoad"/> and <see cref="LoadChunk"/>.
/// </summary>
internal sealed class AudioClipSource
{
/// <summary>Floats per chunk array: 2^22 floats = 16 MiB.</summary>
public const int ChunkFloats = 1 << 22;
/// <summary>Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks.</summary>
public const int SlotCount = 3;
/// <summary>Load priority of a chunk a consumer is waiting for (lowest value is loaded first).</summary>
internal const int PriorityDemanded = 0;
/// <summary>Load priority of slot s of a cursor window is PriorityWindow + s.</summary>
internal const int PriorityWindow = 1;
/// <summary>Load priority of resident chunks nobody is waiting for.</summary>
internal const int PriorityResident = PriorityWindow + SlotCount;
/// <summary>
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/MP3 with the clip harness).
/// </summary>
public const int MaxDecodeStreams = 4;
/// <summary>Decode stream limit of this source (<see cref="MaxDecodeStreams"/> unless changed for tests).</summary>
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
private readonly AudioClipStore _store;
/// <summary>Reader passed to the constructor (the first decode stream); disposed again in <see cref="DisposeInternal"/>.</summary>
private readonly IPCMReader _firstReader;
/// <summary>Opens another reader for an additional decode stream; null when only the first reader can be used.</summary>
private readonly Func<IPCMReader?>? _openReader;
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
private readonly object _sync = new();
/// <summary>Sample array of each chunk, null while not loaded.</summary>
private readonly float[]?[] _chunks;
/// <summary>Number of consumers holding each chunk (TryAcquire minus Release); a held chunk is not freed.</summary>
private readonly int[] _holdCounts;
private readonly List<AudioCursor> _cursors = new();
/// <summary>Chunks a consumer asked for while they were not loaded; an index is removed when the chunk is acquired.</summary>
private readonly HashSet<int> _demanded = new();
/// <summary>Indices of the loaded chunks (non-null entries of <see cref="_chunks"/>).</summary>
private readonly HashSet<int> _loaded = new();
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
// Guarded by _loadLock: decode streams.
private readonly object _loadLock = new();
private readonly List<DecodeStream> _streams = new();
/// <summary>Incremented on every load; stored in <see cref="DecodeStream.LastUse"/> for least-recently-used selection.</summary>
private long _streamUseCounter;
/// <summary>True after opening an additional reader failed; no further readers are opened for this source.</summary>
private bool _openFailed;
private volatile bool _isFailed;
/// <summary>Key the source is registered under in the store (the game's audioLib key / AudioClip.name).</summary>
public string Key { get; }
/// <summary>Channel count of the stored samples (same as the clip).</summary>
public int Channels { get; }
/// <summary>Sample rate of the stored samples in Hz (the output sample rate).</summary>
public int Frequency { get; }
/// <summary>Frame count at <see cref="Frequency"/>: ceil(clip frames * Frequency / clip rate).</summary>
public long TotalFrames { get; }
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
public double Duration { get; }
/// <summary>Frames per chunk: <see cref="ChunkFloats"/> / <see cref="Channels"/> (rounded down).</summary>
public int FramesPerChunk { get; }
/// <summary>Number of chunks: ceil(<see cref="TotalFrames"/> / <see cref="FramesPerChunk"/>); 0 for an empty clip.</summary>
public int ChunkCount { get; }
/// <summary>
/// <see cref="ChunkPolicy.Resident"/> for forward-only readers and clips of at most one chunk, otherwise
/// <see cref="ChunkPolicy.Windowed"/>. Fixed at construction.
/// </summary>
public ChunkPolicy Policy { get; }
/// <summary>True when <see cref="Policy"/> is <see cref="ChunkPolicy.Resident"/>.</summary>
public bool IsResident => Policy == ChunkPolicy.Resident;
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
public bool IsFailed => _isFailed;
/// <summary>True when every chunk of a resident source is loaded.</summary>
public bool IsFullyLoaded
{
get
{
lock (_sync) return _loaded.Count == ChunkCount;
}
}
/// <summary>Number of chunk loads performed (for diagnostics).</summary>
public int LoadCount { get; private set; }
/// <summary>Decode streams opened so far (for diagnostics).</summary>
public int StreamCount
{
get
{
lock (_loadLock) return _streams.Count;
}
}
/// <param name="store">Store that owns this source.</param>
/// <param name="key">Clip name or path the source is registered under.</param>
/// <param name="reader">First reader; also defines the clip format.</param>
/// <param name="openReader">Opens another reader of the same clip for an additional decode stream, or null when not possible.</param>
/// <param name="outputRate">Output sample rate in Hz.</param>
internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func<IPCMReader?>? openReader, int outputRate)
{
if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader));
if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader));
if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader));
_store = store;
_firstReader = reader;
_openReader = openReader;
Key = key;
Channels = reader.Channels;
Frequency = outputRate;
Duration = (double)reader.TotalFrames / reader.SampleRate;
TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate);
FramesPerChunk = ChunkFloats / Channels;
ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk);
Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed;
_chunks = new float[ChunkCount][];
_holdCounts = new int[ChunkCount];
_streams.Add(new DecodeStream(reader, outputRate));
}
// ── Cursors ──
/// <summary>Registers a read position. The decoder thread loads the chunks it requires.</summary>
public AudioCursor AddCursor(long position, bool loop)
{
var cursor = new AudioCursor(this, position, loop);
lock (_sync)
{
_cursors.Add(cursor);
}
_store.RequestService();
return cursor;
}
/// <summary>Unregisters <paramref name="cursor"/>; its window chunks become freeable on the decoder thread's next pass.</summary>
public void RemoveCursor(AudioCursor cursor)
{
lock (_sync)
{
_cursors.Remove(cursor);
}
_store.RequestService();
}
/// <summary>Wakes the decoder thread after a cursor changed chunk or loop flag (resident sources ignore cursors).</summary>
internal void OnCursorMoved()
{
if (!IsResident) _store.RequestService();
}
// ── Consumer access ──
/// <summary>Chunk index containing <paramref name="frame"/> (0 ~ TotalFrames - 1).</summary>
public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk);
/// <summary>
/// Holds the chunk containing <paramref name="frame"/> when it is loaded.
/// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false.
/// Every successful call must be paired with <see cref="Release"/>.
/// </summary>
public bool TryAcquire(long frame, out ChunkView view)
{
view = default;
if (_isFailed || frame < 0 || frame >= TotalFrames) return false;
var index = ChunkIndexOf(frame);
lock (_sync)
{
var data = _chunks[index];
if (data != null)
{
_demanded.Remove(index);
_holdCounts[index]++;
view = CreateView(index, data);
return true;
}
_demanded.Add(index);
}
_store.RequestService();
return false;
}
/// <summary>Ends one hold of the chunk of <paramref name="view"/> taken by <see cref="TryAcquire"/>. An invalid view is ignored.</summary>
public void Release(in ChunkView view)
{
if (!view.IsValid) return;
lock (_sync)
{
if (_holdCounts[view.ChunkIndex] > 0)
_holdCounts[view.ChunkIndex]--;
}
}
/// <summary>View of chunk <paramref name="index"/>; the last chunk's frame count stops at <see cref="TotalFrames"/>.</summary>
private ChunkView CreateView(int index, float[] data)
{
var start = (long)index * FramesPerChunk;
var count = (int)Math.Min(FramesPerChunk, TotalFrames - start);
return new ChunkView(data, start, count, Channels, index);
}
// ── Decoder thread ──
/// <summary>
/// Windowed: frees loaded chunks that are not required (see <see cref="ChunkPolicy.Windowed"/>) and not held.
/// Resident: does nothing. Decoder thread only.
/// </summary>
internal void FreeUnused()
{
if (IsResident) return;
List<float[]>? freed = null;
lock (_sync)
{
if (_loaded.Count == 0) return;
List<int>? remove = null;
foreach (var index in _loaded)
{
if (_holdCounts[index] > 0 || IsRequired(index)) continue;
(remove ??= new List<int>()).Add(index);
}
if (remove == null) return;
foreach (var index in remove)
{
(freed ??= new List<float[]>()).Add(_chunks[index]!);
_chunks[index] = null;
_loaded.Remove(index);
}
}
foreach (var data in freed!)
_store.ReturnChunkArray(data);
}
/// <summary>
/// Next chunk to load and its priority (lower loads first), or false when nothing is missing.
/// Windowed: demanded chunks, then each cursor's window by slot.
/// Resident: the next chunk in order; priority is <see cref="PriorityDemanded"/> while a consumer is waiting on this source.
/// Decoder thread only.
/// </summary>
internal bool TryGetNextLoad(out int index, out int priority)
{
index = -1;
priority = int.MaxValue;
if (_isFailed) return false;
lock (_sync)
{
if (IsResident)
{
if (_residentLoadedCount >= ChunkCount) return false;
index = _residentLoadedCount;
priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident;
return true;
}
foreach (var demanded in _demanded)
{
if (_chunks[demanded] != null) continue;
index = demanded;
priority = PriorityDemanded;
return true;
}
// For each cursor, the first missing chunk of its window; the lowest slot over all cursors wins
// (ties go to the earlier cursor).
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (_chunks[candidate] != null) continue;
if (PriorityWindow + slot < priority)
{
index = candidate;
priority = PriorityWindow + slot;
}
break;
}
}
return index >= 0;
}
}
/// <summary>True when a demand or a cursor window covers <paramref name="index"/>. Caller holds _sync.</summary>
private bool IsRequired(int index)
{
if (_demanded.Contains(index)) return true;
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (candidate == index) return true;
}
}
return false;
}
/// <summary>
/// First chunk of the cursor's window: the chunk of its position (negative positions count as 0; positions past the end
/// wrap when looping). False when a non-looping cursor is past the end (no window). Caller holds _sync.
/// </summary>
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
{
var position = cursor.Position;
loop = cursor.Loop;
first = 0;
if (position < 0) position = 0;
if (position >= TotalFrames)
{
if (!loop || TotalFrames == 0) return false;
position %= TotalFrames;
}
first = ChunkIndexOf(position);
return true;
}
/// <summary>Chunk index of window slot <paramref name="slot"/> after <paramref name="first"/>; wraps when looping, false past the end otherwise.</summary>
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
{
index = first + slot;
if (index < ChunkCount) return true;
if (!loop) return false;
index %= ChunkCount;
return true;
}
/// <summary>
/// Loads chunk <paramref name="index"/> if it is not loaded. Resident sources load only the next chunk in order.
/// Decoder thread only.
/// </summary>
/// <returns>True when this call loaded the chunk.</returns>
internal bool LoadChunk(int index)
{
if (_isFailed || index < 0 || index >= ChunkCount) return false;
lock (_loadLock)
{
lock (_sync)
{
if (_chunks[index] != null) return false;
if (IsResident && index != _residentLoadedCount)
throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}.");
}
float[]? data = null;
try
{
var start = (long)index * FramesPerChunk;
var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start);
data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray();
var stream = SelectStream(start);
stream.LastUse = ++_streamUseCounter;
stream.Fill(start, frames, data);
}
// Out of memory fails the whole render (store fatal error); any other error makes only this source silent.
catch (OutOfMemoryException e)
{
ReturnArray(data);
Fail();
_store.ReportFatal(this, e);
return false;
}
catch (Exception e)
{
ReturnArray(data);
Fail();
_store.ReportSourceFailure(this, e);
return false;
}
lock (_sync)
{
_chunks[index] = data;
_loaded.Add(index);
if (IsResident) _residentLoadedCount++;
}
LoadCount++;
// A resident source never reads again after its last chunk; the reader releases its data now.
if (IsResident && _residentLoadedCount == ChunkCount)
DisposeStreams();
}
_store.NotifyChunkLoaded();
return true;
}
/// <summary>
/// Decode stream for a load starting at output frame <paramref name="start"/>:
/// the stream positioned at <paramref name="start"/>; otherwise a new stream while fewer than <see cref="DecodeStreamLimit"/>
/// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock.
/// </summary>
private DecodeStream SelectStream(long start)
{
DecodeStream? oldest = null;
foreach (var stream in _streams)
{
if (stream.NextOutFrame == start) return stream;
if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream;
}
if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit)
{
IPCMReader? reader = null;
try
{
reader = _openReader();
if (reader is { IsForwardOnly: true })
{
reader.Dispose();
reader = null;
}
if (reader != null)
{
var stream = new DecodeStream(reader, Frequency);
_streams.Add(stream);
return stream;
}
}
catch (Exception e)
{
reader?.Dispose();
Melon<RenderMod>.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}");
}
_openFailed = true;
}
return oldest!;
}
/// <summary>Marks the source failed (no more chunks are returned or loaded); a resident source also closes its readers now. Caller holds _loadLock.</summary>
private void Fail()
{
_isFailed = true;
if (IsResident) DisposeStreams();
}
/// <summary>Disposes every decode stream (reader and resampler) and clears the list. Caller holds _loadLock.</summary>
private void DisposeStreams()
{
foreach (var stream in _streams)
stream.Dispose();
_streams.Clear();
}
/// <summary>Gives a chunk array back to the store: exact arrays (resident) are uncounted, pooled arrays (windowed) are returned to the pool.</summary>
private void ReturnArray(float[]? data)
{
if (data == null) return;
if (IsResident) _store.FreeExactArray(data);
else _store.ReturnChunkArray(data);
}
/// <summary>Releases all chunk arrays and readers. Called by the store after the decoder thread stopped.</summary>
internal void DisposeInternal()
{
lock (_loadLock)
{
lock (_sync)
{
foreach (var index in _loaded)
{
ReturnArray(_chunks[index]);
_chunks[index] = null;
}
_loaded.Clear();
_cursors.Clear();
_demanded.Clear();
}
DisposeStreams();
_firstReader.Dispose();
}
}
/// <summary>ceil(a / b) for a ≥ 0, b &gt; 0.</summary>
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
}
+391
View File
@@ -0,0 +1,391 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread.
///
/// Threads:
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
/// Each step loads one chunk, the one with the lowest priority value over all sources
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
/// - Consumers (render thread, main Unity thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
///
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
/// An <see cref="OutOfMemoryException"/> while loading is kept in <see cref="FatalError"/> and rethrown by <see cref="Acquire"/>.
/// </summary>
internal sealed class AudioClipStore : IDisposable
{
/// <summary>Longest wait of the decoder thread between checks when nothing signals it, in milliseconds.</summary>
private const int DecoderIdleWaitMs = 50;
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
private const int ConsumerWaitMs = 50;
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
private const int DecoderStopTimeoutMs = 3000;
/// <summary>Sources by key; a null value marks a key whose reader could not be created. Also the lock of the source tables.</summary>
private readonly Dictionary<string, AudioClipSource?> _sources = new();
/// <summary>Non-null sources in creation order, scanned by the decoder thread. Guarded by the <see cref="_sources"/> lock.</summary>
private readonly List<AudioClipSource> _sourceList = new();
/// <summary>Free windowed chunk arrays (<see cref="AudioClipSource.ChunkFloats"/> floats each), guarded by <see cref="_poolLock"/>.</summary>
private readonly Stack<float[]> _chunkPool = new();
private readonly object _poolLock = new();
/// <summary>Monitor the decoder thread pulses after each load (or failure); consumers in <see cref="Acquire"/> wait on it.</summary>
private readonly object _signal = new();
/// <summary>Wakes the decoder thread when a cursor, demand or source changes (<see cref="RequestService"/>).</summary>
private readonly AutoResetEvent _serviceRequested = new(false);
private Thread? _decoderThread;
private volatile bool _stopRequested;
/// <summary>Bytes of allocated sample arrays (see <see cref="AllocatedBytes"/>), updated with Interlocked.</summary>
private long _allocatedBytes;
private long _peakBytes;
/// <summary>First fatal error (out of memory, or an exception that stopped the decoder thread); null while none.</summary>
private volatile Exception? _fatalError;
private volatile bool _disposed;
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
public int OutputRate { get; }
/// <summary>Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data).</summary>
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
public long PeakBytes => Interlocked.Read(ref _peakBytes);
/// <summary>
/// Out-of-memory error raised while opening or loading a clip, or the exception that stopped the decoder thread; null while none.
/// </summary>
public Exception? FatalError => _fatalError;
/// <param name="outputRate">Sample rate in Hz every source is resampled to (positive). Call <see cref="Start"/> to begin decoding.</param>
public AudioClipStore(int outputRate)
{
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
OutputRate = outputRate;
}
// ── Decoder thread ──
/// <summary>Starts the decoder thread. Called once.</summary>
public void Start()
{
if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started.");
_decoderThread = new Thread(DecoderLoop)
{
Name = "Audio Clip Decoder",
IsBackground = true
};
_decoderThread.Start();
}
/// <summary>
/// Decoder thread body: loads one chunk per iteration, or sleeps until <see cref="RequestService"/> (at most
/// <see cref="DecoderIdleWaitMs"/> ms) when nothing is missing. An unexpected exception ends the thread and is kept as the
/// fatal error, so waiting consumers fail instead of waiting forever.
/// </summary>
private void DecoderLoop()
{
try
{
while (!_stopRequested)
{
if (!LoadNext())
_serviceRequested.WaitOne(DecoderIdleWaitMs);
}
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}");
_fatalError ??= e;
NotifyChunkLoaded();
}
}
/// <summary>
/// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources.
/// Runs on the decoder thread (tests may call it directly while the thread is not started).
/// </summary>
/// <returns>True when a chunk was loaded or a load was attempted.</returns>
internal bool LoadNext()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return false;
sources = _sourceList.ToArray();
}
AudioClipSource? best = null;
var bestIndex = -1;
var bestPriority = int.MaxValue;
foreach (var source in sources)
{
source.FreeUnused();
if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue;
best = source;
bestIndex = index;
bestPriority = priority;
}
if (best == null) return false;
best.LoadChunk(bestIndex);
return true;
}
/// <summary>Wakes the decoder thread to re-evaluate what to free and load. Any thread.</summary>
internal void RequestService() => _serviceRequested.Set();
// ── Sources ──
/// <summary>
/// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> on first use.
/// A key whose reader could not be created is remembered as null and not retried.
/// For readers that can seek, additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>) are opened on the
/// decoder thread with <paramref name="openAdditionalReader"/>, or with <paramref name="createReader"/> when it is null.
/// </summary>
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader, Func<IPCMReader?>? openAdditionalReader = null)
{
lock (_sources)
{
if (_disposed) return null;
if (_sources.TryGetValue(key, out var existing)) return existing;
}
AudioClipSource? source = null;
try
{
var reader = createReader();
if (reader != null)
{
try
{
source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : openAdditionalReader ?? createReader, OutputRate);
}
catch
{
reader.Dispose();
throw;
}
}
}
catch (OutOfMemoryException e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}");
_fatalError ??= e;
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
}
// The reader is created outside the lock (it may decode or copy a whole clip). If another caller registered the
// same key meanwhile, its entry wins and this source is discarded.
lock (_sources)
{
if (_disposed || _sources.TryGetValue(key, out var raced))
{
source?.DisposeInternal();
return _disposed ? null : _sources[key];
}
_sources[key] = source;
if (source != null) _sourceList.Add(source);
}
if (source != null)
{
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}");
RequestService();
}
return source;
}
/// <summary>
/// True when <paramref name="key"/> has been registered; <paramref name="source"/> is then the source, or null when its reader
/// could not be created. Any thread.
/// </summary>
public bool TryGet(string key, out AudioClipSource? source)
{
lock (_sources)
{
return _sources.TryGetValue(key, out source);
}
}
// ── Consumers ──
/// <summary>
/// Holds the chunk containing <paramref name="frame"/>, waiting for the decoder thread until it is loaded.
/// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed.
/// </summary>
/// <exception cref="InvalidOperationException">Loading ran out of memory or the decoder thread stopped.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled while waiting.</exception>
public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default)
{
if (frame < 0 || frame >= source.TotalFrames) return default;
while (true)
{
ThrowIfFatal();
if (source.IsFailed || _disposed) return default;
if (source.TryAcquire(frame, out var view)) return view;
// Retry under the signal lock so a load completed between the first try and the wait is not missed;
// the timed wait also rechecks cancellation and failure states periodically.
lock (_signal)
{
if (source.TryAcquire(frame, out view)) return view;
if (source.IsFailed || _fatalError != null || _disposed) continue;
Monitor.Wait(_signal, ConsumerWaitMs);
}
cancellationToken.ThrowIfCancellationRequested();
}
}
/// <summary>Throws <see cref="InvalidOperationException"/> wrapping <see cref="FatalError"/> when one is set.</summary>
private void ThrowIfFatal()
{
var error = _fatalError;
if (error != null)
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
}
/// <summary>Wakes every consumer waiting in <see cref="Acquire"/> (after a load, a failure, or on dispose).</summary>
internal void NotifyChunkLoaded()
{
lock (_signal)
{
Monitor.PulseAll(_signal);
}
}
/// <summary>Logs a load error of one source (the source is already marked failed and plays silence) and wakes waiting consumers.</summary>
internal void ReportSourceFailure(AudioClipSource source, Exception error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
NotifyChunkLoaded();
}
/// <summary>Logs an out-of-memory error, keeps it as <see cref="FatalError"/> (first one wins) and wakes waiting consumers.</summary>
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}");
_fatalError ??= error;
NotifyChunkLoaded();
}
// ── Arrays ──
/// <summary>Takes a pooled array of <see cref="AudioClipSource.ChunkFloats"/> floats, or allocates (and counts) a new one. Contents are undefined.</summary>
internal float[] RentChunkArray()
{
lock (_poolLock)
{
if (_chunkPool.Count > 0) return _chunkPool.Pop();
}
var data = new float[AudioClipSource.ChunkFloats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
/// <summary>Puts a rented array back into the pool (it stays counted); after dispose it is dropped and uncounted.</summary>
internal void ReturnChunkArray(float[] data)
{
if (_disposed)
{
AddAllocated(-data.LongLength * sizeof(float));
return;
}
lock (_poolLock)
{
_chunkPool.Push(data);
}
}
/// <summary>Allocates a counted array of exactly <paramref name="floats"/> floats (resident chunks, copied Unity clip blocks).</summary>
internal float[] AllocateExactArray(int floats)
{
var data = new float[floats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
/// <summary>Uncounts an array from <see cref="AllocateExactArray"/>; the array itself is left to the garbage collector.</summary>
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
/// <summary>Adds <paramref name="bytes"/> (negative to subtract) to the allocation counter and raises the peak lock-free.</summary>
private void AddAllocated(long bytes)
{
var current = Interlocked.Add(ref _allocatedBytes, bytes);
long peak;
while (current > (peak = Interlocked.Read(ref _peakBytes)))
{
if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break;
}
}
/// <summary>Logs the source count and the current and peak sample memory in MiB.</summary>
public void LogMemoryUsage()
{
int sourceCount;
lock (_sources) sourceCount = _sourceList.Count;
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB");
}
/// <summary>Stops the decoder thread, then releases every source and pooled array.</summary>
public void Dispose()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return;
_disposed = true;
sources = _sourceList.ToArray();
}
_stopRequested = true;
_serviceRequested.Set();
NotifyChunkLoaded();
// A decoder thread blocked inside a read (for example a file on an unresponsive drive) is left running:
// it is a background thread, and its sources keep their readers so the blocked call does not touch freed FFmpeg contexts.
var decoderStopped = _decoderThread == null || _decoderThread.Join(DecoderStopTimeoutMs);
if (!decoderStopped)
Melon<RenderMod>.Logger.Warning($"[AudioClipStore] Decoder thread did not stop within {DecoderStopTimeoutMs} ms; its readers are left open.");
LogMemoryUsage();
lock (_sources)
{
_sourceList.Clear();
_sources.Clear();
}
if (decoderStopped)
{
foreach (var source in sources)
source.DisposeInternal();
}
lock (_poolLock)
{
while (_chunkPool.Count > 0)
AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float));
}
}
}
+204
View File
@@ -0,0 +1,204 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream.
/// Consecutive <see cref="Fill"/> calls whose ranges follow each other continue the stream (reader position and resampler state);
/// any other range starts a new stream near the requested position.
///
/// Restart: the input position is moved back by a preroll and down to a multiple of in_rate / gcd(in_rate, out_rate),
/// where input frame k * (in_rate / gcd) maps exactly to output frame k * (out_rate / gcd);
/// the resampler is reset and output before the requested frame is discarded.
/// Measured with the clip harness: output is bit-identical to one continuous conversion of the whole clip.
/// Used by one thread at a time (the store's decoder thread).
/// </summary>
internal sealed class DecodeStream : IDisposable
{
/// <summary>Input frames read from the reader per resampler call.</summary>
private const int ReadBlockFrames = 4096;
/// <summary>Resampler from the reader's rate to the output rate; null when the rates are equal (samples are copied).</summary>
private readonly PCMResampler? _resampler;
private readonly int _channels;
private readonly int _outputRate;
/// <summary>Input block of <see cref="ReadBlockFrames"/> frames, interleaved.</summary>
private readonly float[] _readBuffer;
/// <summary>Scratch output for frames discarded after a restart; allocated on first use.</summary>
private float[]? _discardBuffer;
/// <summary>True once the reader returned no more input and the resampler is being drained with Flush.</summary>
private bool _flushing;
/// <summary>True once the input and the resampler tail are exhausted; the stream must restart before producing more.</summary>
private bool _ended;
/// <summary>Input frames per alignment step: input rate / gcd (1 without resampling).</summary>
private readonly long _inputStep;
/// <summary>Output frames per alignment step: output rate / gcd (1 without resampling).</summary>
private readonly long _outputStep;
/// <summary>Input frames fed before the requested position on a restart, so the filter history matches a continuous run.</summary>
private readonly long _prerollInputFrames;
/// <summary>Reader of this stream (owned; disposed with the stream).</summary>
public IPCMReader Reader { get; }
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
public long NextOutFrame { get; private set; } = -1;
/// <summary>Value of the owner's use counter at the last <see cref="Fill"/> (for least-recently-used selection).</summary>
public long LastUse { get; set; }
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
public int RestartCount { get; private set; }
/// <param name="reader">Reader of the clip; ownership passes to the stream.</param>
/// <param name="outputRate">Output sample rate in Hz.</param>
public DecodeStream(IPCMReader reader, int outputRate)
{
Reader = reader;
_channels = reader.Channels;
_outputRate = outputRate;
_readBuffer = new float[ReadBlockFrames * _channels];
if (reader.SampleRate != outputRate)
{
_resampler = new PCMResampler(_channels, reader.SampleRate, outputRate);
var gcd = GCD(reader.SampleRate, outputRate);
_inputStep = reader.SampleRate / gcd;
_outputStep = outputRate / gcd;
// History fed before the first kept output frame when a stream starts mid-clip.
// The filter spans about filter_size input frames per output frame at ratios up to 1:1
// and filter_size * (input rate / output rate) input frames when downsampling.
// The preroll is four times that span plus one alignment step (the restart position is rounded down to a step).
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
}
else
{
_inputStep = 1;
_outputStep = 1;
}
}
/// <summary>
/// Writes output frames [start, start + frames) into <paramref name="data"/> and zeros the rest of the array.
/// Frames past the end of the decoded input are zero.
/// </summary>
public unsafe void Fill(long start, int frames, float[] data)
{
var channels = _channels;
var filled = 0;
// Same rate: output frame = input frame; seek only when the reader is not already at the start.
if (_resampler == null)
{
if (Reader.Position != start)
{
Reader.Seek(start);
RestartCount++;
}
while (filled < frames)
{
var request = Math.Min(ReadBlockFrames, frames - filled);
var read = Reader.Read(_readBuffer, request);
if (read <= 0) break;
Array.Copy(_readBuffer, 0, data, filled * channels, read * channels);
filled += read;
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = start + frames;
return;
}
long discard = 0;
if (NextOutFrame != start)
{
// Input frame of the requested output frame, moved back by the preroll and down to a multiple of _inputStep.
var inputFrame = start * Reader.SampleRate / _outputRate - _prerollInputFrames;
var step = inputFrame > 0 ? inputFrame / _inputStep : 0;
_resampler.Reset();
Reader.Seek(step * _inputStep);
_flushing = false;
_ended = false;
discard = start - step * _outputStep;
RestartCount++;
}
fixed (float* pIn = _readBuffer)
fixed (float* pOut = data)
{
// Produce and drop the output frames between the aligned restart position and the requested start.
while (discard > 0 && !_ended)
{
_discardBuffer ??= new float[ReadBlockFrames * channels];
fixed (float* pDiscard = _discardBuffer)
{
var capacity = (int)Math.Min(ReadBlockFrames, discard);
discard -= Pull(pIn, pDiscard, capacity);
}
}
while (filled < frames && !_ended)
filled += Pull(pIn, pOut + (long)filled * channels, frames - filled);
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = _ended ? -1 : start + frames;
}
/// <summary>
/// Writes 1 ~ <paramref name="capacity"/> resampled frames to <paramref name="output"/>, reading input as needed.
/// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted.
/// </summary>
private unsafe int Pull(float* input, float* output, int capacity)
{
var resampler = _resampler!;
if (!_flushing)
{
while (true)
{
var produced = resampler.Convert(input, 0, output, capacity);
if (produced > 0) return produced;
var read = Reader.Read(_readBuffer, ReadBlockFrames);
if (read <= 0)
{
_flushing = true;
break;
}
produced = resampler.Convert(input, read, output, capacity);
if (produced > 0) return produced;
}
}
// Input is exhausted: take the resampler's remaining tail until it returns 0.
var flushed = resampler.Flush(output, capacity);
if (flushed > 0) return flushed;
_ended = true;
return 0;
}
/// <summary>Releases the resampler and the reader.</summary>
public void Dispose()
{
_resampler?.Dispose();
Reader.Dispose();
}
/// <summary>Greatest common divisor of two positive integers (Euclid).</summary>
private static int GCD(int a, int b)
{
while (b != 0) (a, b) = (b, a % b);
return a;
}
}
+446
View File
@@ -0,0 +1,446 @@
using System;
using System.Runtime.InteropServices;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that decodes the best audio stream of a file with libavformat/libavcodec.
/// Output keeps the stream's sample rate and channel count; libswresample converts only the sample format to interleaved float.
///
/// Frame positions: frame 0 is the first sample the decoder outputs for the stream's start time.
/// The position of the first frame decoded after opening or seeking is taken from its timestamp
/// (best_effort_timestamp - stream start_time, in samples); following frames are counted sequentially.
/// Seek moves the demuxer to <see cref="SeekMarginSeconds"/> before the target and discards decoded samples before the target.
///
/// <see cref="TotalFrames"/> comes from the stream duration (or the container duration) and may differ from the decoded length;
/// reads stop at whichever ends first.
///
/// MP3: decoded with the game's timing (MP3Sharp, see <see cref="MP3HeaderFrame"/>): encoder delay and padding are kept
/// (AV_CODEC_FLAG2_SKIP_MANUAL), frame 0 is the first sample of the first audio frame, and one frame of silence is placed
/// before it when the file starts with a Xing/Info/VBRI header frame. <see cref="TotalFrames"/> is the sum of all packet
/// durations plus that silence, counted once when the file is opened.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API)
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame)
/// </summary>
internal sealed unsafe class FFmpegPCMReader : IPCMReader
{
/// <summary>
/// Time the demuxer is moved before a seek target, in seconds. Decoders that depend on previous packets
/// (for example the MP3 bit reservoir) produce settled output after this margin; the samples in it are discarded.
/// </summary>
private const double SeekMarginSeconds = 0.25;
// FFmpeg objects owned by this reader, freed by Dispose (null after).
private AVFormatContext* _format;
private AVCodecContext* _codec;
/// <summary>Sample format converter: decoder format to interleaved float, same rate and layout.</summary>
private SwrContext* _swr;
private AVPacket* _packet;
private AVFrame* _frame;
/// <summary>Index of the decoded audio stream in the container; every other stream is discarded by the demuxer.</summary>
private readonly int _streamIndex;
/// <summary>Time base of the stream's timestamps (seconds per tick).</summary>
private readonly AVRational _timeBase;
private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
private float[] _pending = Array.Empty<float>();
private int _pendingOffset;
private int _pendingCount;
private long _pendingStart;
private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially
private bool _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp
// _flushSent: the null packet (drain request) was sent at the end of the file.
// _endOfStream: the decoder returned EOF, or nothing more after the drain request.
private bool _flushSent;
private bool _endOfStream;
/// <summary>File path the reader was opened with.</summary>
public string Path { get; }
/// <inheritdoc/>
public int Channels { get; }
/// <inheritdoc/>
public int SampleRate { get; }
/// <inheritdoc/>
public long TotalFrames { get; }
/// <summary>False: the reader seeks within the file.</summary>
public bool IsForwardOnly => false;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
public bool IsMP3 { get; }
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
public int SeekCount { get; private set; }
/// <summary>
/// Opens <paramref name="path"/>, selects the best audio stream and opens its decoder. For MP3 files the whole file is
/// scanned once to count frames (see the class summary).
/// </summary>
/// <exception cref="InvalidOperationException">The file cannot be opened, has no decodable audio stream, has no duration
/// (non-MP3), or an FFmpeg setup call fails. Everything allocated so far is freed.</exception>
public FFmpegPCMReader(string path)
{
Path = path;
try
{
AVFormatContext* format = null;
var ret = ffmpeg.avformat_open_input(&format, path, null, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio file '{path}': {ErrorString(ret)}");
_format = format;
ret = ffmpeg.avformat_find_stream_info(_format, null);
if (ret < 0) throw new InvalidOperationException($"Failed to read stream info of '{path}': {ErrorString(ret)}");
AVCodec* decoder = null;
_streamIndex = ffmpeg.av_find_best_stream(_format, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
if (_streamIndex < 0 || decoder == null) throw new InvalidOperationException($"No decodable audio stream in '{path}': {ErrorString(_streamIndex)}");
for (var i = 0; i < (int)_format->nb_streams; i++)
{
if (i != _streamIndex) _format->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
}
var stream = _format->streams[_streamIndex];
_timeBase = stream->time_base;
_ptsOrigin = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0;
_codec = ffmpeg.avcodec_alloc_context3(decoder);
if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed.");
ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar);
if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}");
_codec->pkt_timebase = _timeBase;
IsMP3 = stream->codecpar->codec_id == AVCodecID.AV_CODEC_ID_MP3;
if (IsMP3)
{
// Keep the encoder delay and padding the decoder would otherwise trim (exported as side data instead).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html (AV_CODEC_FLAG2_SKIP_MANUAL)
_codec->flags2 |= ffmpeg.AV_CODEC_FLAG2_SKIP_MANUAL;
}
ret = ffmpeg.avcodec_open2(_codec, decoder, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}");
Channels = _codec->ch_layout.nb_channels;
SampleRate = _codec->sample_rate;
if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz.");
if (IsMP3)
{
_leadFrames = MP3HeaderFrame.SilentFrames(path);
ScanPackets(out _ptsOrigin, out var decodedFrames);
TotalFrames = _leadFrames + decodedFrames;
}
else
{
TotalFrames = EstimateTotalFrames(stream);
}
SwrContext* swr = null;
ret = ffmpeg.swr_alloc_set_opts2(&swr,
&_codec->ch_layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, SampleRate,
&_codec->ch_layout, _codec->sample_fmt, SampleRate, 0, null);
if (ret < 0 || swr == null) throw new InvalidOperationException($"swr_alloc_set_opts2 failed: {ErrorString(ret)}");
_swr = swr;
ret = ffmpeg.swr_init(_swr);
if (ret < 0) throw new InvalidOperationException($"swr_init failed: {ErrorString(ret)}");
_packet = ffmpeg.av_packet_alloc();
_frame = ffmpeg.av_frame_alloc();
if (_packet == null || _frame == null) throw new InvalidOperationException("Failed to allocate packet/frame.");
_syncToTimestamp = true;
}
catch
{
Dispose();
throw;
}
}
/// <summary>
/// Reads every packet of the audio stream once (no decoding) and returns the first packet's timestamp and the summed
/// packet durations in frames, then moves the demuxer back to the first packet. Used for MP3, where every packet decodes
/// to a full frame when trimming is off.
/// </summary>
private void ScanPackets(out long firstPts, out long frames)
{
firstPts = ffmpeg.AV_NOPTS_VALUE;
long durationTicks = 0;
var packet = ffmpeg.av_packet_alloc();
try
{
int ret;
while ((ret = ffmpeg.av_read_frame(_format, packet)) >= 0)
{
if (packet->stream_index == _streamIndex)
{
if (firstPts == ffmpeg.AV_NOPTS_VALUE && packet->pts != ffmpeg.AV_NOPTS_VALUE) firstPts = packet->pts;
durationTicks += packet->duration;
}
ffmpeg.av_packet_unref(packet);
}
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_free(&packet);
}
if (firstPts == ffmpeg.AV_NOPTS_VALUE) firstPts = 0;
frames = ffmpeg.av_rescale_q(durationTicks, _timeBase, new AVRational { num = 1, den = SampleRate });
var seek = ffmpeg.av_seek_frame(_format, _streamIndex, firstPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (seek < 0)
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
}
/// <summary>
/// Frame count from the stream duration, or from the container duration (AV_TIME_BASE units, microseconds) when the stream
/// has none.
/// </summary>
/// <exception cref="InvalidOperationException">Neither duration is known.</exception>
private long EstimateTotalFrames(AVStream* stream)
{
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
return ffmpeg.av_rescale_q(stream->duration, _timeBase, new AVRational { num = 1, den = SampleRate });
if (_format->duration != ffmpeg.AV_NOPTS_VALUE && _format->duration > 0)
return ffmpeg.av_rescale(_format->duration, SampleRate, ffmpeg.AV_TIME_BASE);
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
}
/// <summary>
/// Moves to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>). A target within the pending block or up to the seek margin
/// after it is reached by decoding forward; otherwise the demuxer seeks backward (AVSEEK_FLAG_BACKWARD) to target - margin
/// (or to the stream start if that fails) and the decoder is flushed. Decoded samples before the target are discarded by Read.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="frame"/> is outside 0 ~ TotalFrames.</exception>
/// <exception cref="InvalidOperationException">The demuxer cannot seek.</exception>
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame == Position) return;
var marginFrames = (long)(SeekMarginSeconds * SampleRate);
// Target inside the pending block or less than the seek margin after it: decode forward and discard.
if (!_syncToTimestamp && frame >= _pendingStart + _pendingOffset
&& frame <= _pendingStart + _pendingOffset + _pendingCount + marginFrames)
{
Position = frame;
return;
}
// Target timestamp in stream ticks; the lead (silent) frames have no packets, so they are subtracted first.
var targetTs = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _ptsOrigin, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}");
ffmpeg.avcodec_flush_buffers(_codec);
SeekCount++;
_pendingCount = 0;
_pendingOffset = 0;
_syncToTimestamp = true;
_flushSent = false;
_endOfStream = false;
Position = frame;
}
/// <inheritdoc/>
/// <remarks>
/// Frames before the first decoded frame (MP3 lead silence) and gaps between decoded timestamps are returned as zeros.
/// Returns fewer frames than requested when decoding ends before <see cref="TotalFrames"/>.
/// </remarks>
/// <exception cref="InvalidOperationException">Reading, decoding or sample conversion fails.</exception>
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
if (_pendingCount == 0 || Position >= _pendingStart + _pendingOffset + _pendingCount)
{
if (!DecodeNextFrame()) break;
continue;
}
var pendingPosition = _pendingStart + _pendingOffset;
if (Position > pendingPosition)
{
// Discard decoded samples before the read position (after a seek).
var skip = (int)Math.Min(Position - pendingPosition, _pendingCount);
_pendingOffset += skip;
_pendingCount -= skip;
continue;
}
if (Position < pendingPosition)
{
// A gap in the decoded timestamps: fill with silence up to the next decoded sample.
var gap = (int)Math.Min(pendingPosition - Position, limit - written);
Array.Clear(buffer, written * Channels, gap * Channels);
written += gap;
Position += gap;
continue;
}
var count = Math.Min(_pendingCount, limit - written);
Array.Copy(_pending, _pendingOffset * Channels, buffer, written * Channels, count * Channels);
_pendingOffset += count;
_pendingCount -= count;
written += count;
Position += count;
}
return written;
}
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
/// <remarks>
/// Send/receive loop: receive a frame; on EAGAIN read and send the next packet of the stream; at the end of the file send a
/// null packet to drain the decoder, then receive until EOF.
/// </remarks>
private bool DecodeNextFrame()
{
while (!_endOfStream)
{
var ret = ffmpeg.avcodec_receive_frame(_codec, _frame);
if (ret == 0)
{
StoreFrame();
ffmpeg.av_frame_unref(_frame);
return true;
}
if (ret == ffmpeg.AVERROR_EOF)
{
_endOfStream = true;
break;
}
if (ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Audio decoding failed in '{Path}': {ErrorString(ret)}");
if (_flushSent)
{
_endOfStream = true;
break;
}
ret = ffmpeg.av_read_frame(_format, _packet);
if (ret < 0)
{
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
ffmpeg.avcodec_send_packet(_codec, null);
_flushSent = true;
continue;
}
try
{
if (_packet->stream_index != _streamIndex) continue;
ret = ffmpeg.avcodec_send_packet(_codec, _packet);
// A corrupt packet is skipped; decoding continues with the next one.
if (ret < 0 && ret != ffmpeg.AVERROR_INVALIDDATA && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Failed to send audio packet of '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_unref(_packet);
}
}
return false;
}
/// <summary>
/// Converts the decoded frame to interleaved float into the pending buffer and assigns its frame position: from its timestamp
/// for the first frame after open/seek (the current position when it has none), sequential afterwards.
/// </summary>
private void StoreFrame()
{
var samples = _frame->nb_samples;
if (samples <= 0) return;
if (_syncToTimestamp)
{
var pts = _frame->best_effort_timestamp;
_nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE
? _leadFrames + ffmpeg.av_rescale_q(pts - _ptsOrigin, _timeBase, new AVRational { num = 1, den = SampleRate })
: Position;
_syncToTimestamp = false;
}
if (_pending.Length < samples * Channels)
_pending = new float[samples * Channels];
fixed (float* pPending = _pending)
{
var outPlane = (byte*)pPending;
var converted = ffmpeg.swr_convert(_swr, &outPlane, samples, _frame->extended_data, samples);
if (converted < 0)
throw new InvalidOperationException($"Sample format conversion failed in '{Path}': {ErrorString(converted)}");
samples = converted;
}
_pendingStart = _nextDecodedFrame;
_pendingOffset = 0;
_pendingCount = samples;
_nextDecodedFrame += samples;
}
/// <summary>Frees every FFmpeg object and closes the file. Safe to call more than once and on a partly constructed reader.</summary>
public void Dispose()
{
if (_frame != null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet != null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_swr != null)
{
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
if (_codec != null)
{
var codec = _codec;
ffmpeg.avcodec_free_context(&codec);
_codec = null;
}
if (_format != null)
{
var format = _format;
ffmpeg.avformat_close_input(&format);
_format = null;
}
}
/// <summary>FFmpeg error text for <paramref name="error"/> (av_strerror) followed by the numeric code.</summary>
private static string ErrorString(int error)
{
var buffer = stackalloc byte[1024];
ffmpeg.av_strerror(error, buffer, 1024);
return $"{Marshal.PtrToStringAnsi((IntPtr)buffer)} ({error})";
}
}
+42
View File
@@ -0,0 +1,42 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sequential reader of decoded PCM at the clip's own sample rate and channel count.
/// Samples are interleaved 32-bit float. Positions and counts are in frames (one frame = one sample per channel).
/// Used by one thread at a time (the store's decoder thread). <see cref="IDisposable.Dispose"/> may be called more than once.
/// </summary>
internal interface IPCMReader : IDisposable
{
/// <summary>Channel count of the decoded audio (1 or more).</summary>
int Channels { get; }
/// <summary>Sample rate of the decoded audio in Hz.</summary>
int SampleRate { get; }
/// <summary>Total frame count of the clip at <see cref="SampleRate"/>.</summary>
long TotalFrames { get; }
/// <summary>
/// True when the data can be read only once, from frame 0 to the end (data is released as it is read).
/// The source then keeps every chunk (<see cref="ChunkPolicy.Resident"/>).
/// </summary>
bool IsForwardOnly { get; }
/// <summary>Frame index that the next <see cref="Read"/> starts at.</summary>
long Position { get; }
/// <summary>
/// Moves the read position to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>).
/// A forward-only reader accepts only frames whose data has not been released.
/// </summary>
void Seek(long frame);
/// <summary>
/// Reads up to <paramref name="frameCount"/> frames into <paramref name="buffer"/> starting at index 0 and advances the position.
/// <paramref name="buffer"/> must hold at least frameCount * Channels floats.
/// </summary>
/// <returns>Frames read. 0 at the end of the clip.</returns>
int Read(float[] buffer, int frameCount);
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file.
///
/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling:
/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame
/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2):
/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists,
/// and identical in timing when it does not.
///
/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information,
/// VBRI tag 32 bytes after the frame header):
/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I),
/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono).
/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono).
/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5).
/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each).
/// </summary>
internal static class MP3HeaderFrame
{
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
private const int SyncSearchBytes = 64 * 1024;
/// <summary>
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
/// </summary>
public static int SilentFrames(string path)
{
// The game keeps the song open while it streams it (MP3Sharp.MP3Stream: new FileStream(path, FileMode.Open),
// read/write access with read sharing), so the file is opened with read/write/delete sharing.
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return SilentFrames(file);
}
/// <summary>
/// <see cref="SilentFrames(string)"/> for a seekable stream positioned anywhere (the stream is read from offset 0).
/// Only the first valid frame header within <see cref="SyncSearchBytes"/> after the ID3v2 tags is examined.
/// </summary>
public static int SilentFrames(Stream stream)
{
// Skip consecutive ID3v2 tags: 10-byte header + syncsafe size (+ 10-byte footer when flagged).
var tag = new byte[10];
long offset = 0;
while (true)
{
stream.Position = offset;
if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break;
var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F);
offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0);
}
stream.Position = offset;
var data = new byte[SyncSearchBytes];
var length = ReadFully(stream, data, data.Length);
for (var i = 0; i + 4 <= length; i++)
{
if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue;
// Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header.
if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info")
|| HasTag(data, length, i + 4 + 32, "VBRI"))
return samplesPerFrame;
return 0;
}
return 0;
}
/// <summary>
/// Parses the 4-byte frame header at <paramref name="i"/>. False for a missing sync or a reserved version, layer, bitrate
/// index (15) or sample rate index (3).
/// </summary>
/// <param name="data">Bytes to parse.</param>
/// <param name="i">Offset of the first header byte in <paramref name="data"/>.</param>
/// <param name="samplesPerFrame">Samples per channel in the frame (384, 576 or 1152).</param>
/// <param name="tagOffset">Offset of a Xing/Info tag from the header start: 4 + CRC (2) + Layer III side information (0 for Layers I/II).</param>
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
{
samplesPerFrame = 0;
tagOffset = 0;
if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false;
var version = (data[i + 1] >> 3) & 0x3;
var layer = (data[i + 1] >> 1) & 0x3;
var hasCrc = (data[i + 1] & 0x1) == 0;
var bitrateIndex = (data[i + 2] >> 4) & 0xF;
var sampleRateIndex = (data[i + 2] >> 2) & 0x3;
var mono = ((data[i + 3] >> 6) & 0x3) == 3;
if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false;
var mpeg1 = version == 3;
samplesPerFrame = layer switch
{
3 => 384,
2 => 1152,
_ => mpeg1 ? 1152 : 576
};
var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0;
tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo;
return true;
}
/// <summary>True when the ASCII string <paramref name="tag"/> is at <paramref name="at"/> within the first <paramref name="length"/> bytes.</summary>
private static bool HasTag(byte[] data, int length, int at, string tag)
{
if (at < 0 || at + tag.Length > length) return false;
for (var k = 0; k < tag.Length; k++)
if (data[at + k] != tag[k]) return false;
return true;
}
/// <summary>Reads until <paramref name="count"/> bytes or the end of the stream; returns the bytes read.</summary>
private static int ReadFully(Stream stream, byte[] buffer, int count)
{
var total = 0;
int read;
while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read;
return total;
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that reproduces the game's output for mono MP3 files.
///
/// MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.Buffer16BitStereo) writes every frame in stereo layout and fills only the
/// channel 0 slot for mono streams, while MP3Stream.ChannelCount reports 1. The game creates a 1-channel clip from that data,
/// so a mono MP3 plays as [s0, 0, s1, 0, ...]: twice as long, at half speed and one octave lower
/// (confirmed in game with TestMaps/mp3_mono, 2026-10-13).
///
/// Output: 1 channel at the inner reader's sample rate. Frame 2k is inner frame k, frame 2k + 1 is 0.
/// <see cref="TotalFrames"/> = 2 * inner TotalFrames.
/// </summary>
internal sealed class MP3MonoLayoutReader : IPCMReader
{
private readonly IPCMReader _inner;
/// <summary>Reused buffer for inner frames, grown to the largest request.</summary>
private float[] _innerBuffer = Array.Empty<float>();
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
public MP3MonoLayoutReader(IPCMReader inner)
{
if (inner.Channels != 1) throw new ArgumentException("Inner reader must be mono.", nameof(inner));
_inner = inner;
Position = inner.Position * 2;
}
/// <summary>Always 1.</summary>
public int Channels => 1;
/// <summary>The inner reader's sample rate (the game plays the doubled data at this rate).</summary>
public int SampleRate => _inner.SampleRate;
/// <summary>Twice the inner frame count.</summary>
public long TotalFrames => _inner.TotalFrames * 2;
/// <inheritdoc/>
public bool IsForwardOnly => _inner.IsForwardOnly;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
public void Seek(long frame)
{
_inner.Seek((frame + 1) / 2);
Position = frame;
}
/// <inheritdoc/>
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
// Odd position: the zero slot after an inner sample (also the first frame after a seek to an odd frame).
if ((Position & 1) == 1)
{
buffer[written++] = 0f;
Position++;
continue;
}
// Even position: read enough inner frames for the remaining output, then interleave each with a zero.
var want = (limit - written + 1) / 2;
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
var got = _inner.Read(_innerBuffer, want);
if (got == 0) break;
for (var i = 0; i < got && written < limit; i++)
{
buffer[written++] = _innerBuffer[i];
Position++;
if (written >= limit) break;
buffer[written++] = 0f;
Position++;
}
}
return written;
}
/// <summary>Disposes the inner reader.</summary>
public void Dispose() => _inner.Dispose();
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Forward-only <see cref="IPCMReader"/> over PCM held in memory as a list of blocks.
/// Block b holds frames [b * FramesPerBlock, (b + 1) * FramesPerBlock) interleaved; the last block may be shorter.
/// Each block is released (passed to the release callback and dropped) as soon as the read position passes its end,
/// and all remaining blocks are released on <see cref="Dispose"/>.
/// </summary>
internal sealed class MemoryPCMReader : IPCMReader
{
/// <summary>Block arrays; an entry is set to null when the block is released.</summary>
private readonly float[]?[] _blocks;
/// <summary>Called with each released block (for example to uncount its memory in the store); may be null.</summary>
private readonly Action<float[]>? _onBlockReleased;
private int _releasedCount; // Blocks [0, _releasedCount) are released
/// <inheritdoc/>
public int Channels { get; }
/// <inheritdoc/>
public int SampleRate { get; }
/// <inheritdoc/>
public long TotalFrames { get; }
/// <summary>True: blocks are released as they are read.</summary>
public bool IsForwardOnly => true;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>Frames per block (all blocks except the last).</summary>
public int FramesPerBlock { get; }
/// <param name="blocks">Interleaved blocks in frame order; the reader takes ownership of the arrays (the list is copied).</param>
/// <param name="framesPerBlock">Frames in every block except possibly the last (positive).</param>
/// <param name="channels">Channel count (positive).</param>
/// <param name="sampleRate">Sample rate in Hz.</param>
/// <param name="totalFrames">Frame count; the blocks must hold at least this many frames.</param>
/// <param name="onBlockReleased">Called with each block when it is released; may be null.</param>
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
Action<float[]>? onBlockReleased)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (framesPerBlock <= 0) throw new ArgumentOutOfRangeException(nameof(framesPerBlock));
if (totalFrames > (long)blocks.Count * framesPerBlock) throw new ArgumentException("Blocks hold fewer frames than totalFrames.", nameof(blocks));
_blocks = new float[blocks.Count][];
for (var i = 0; i < blocks.Count; i++) _blocks[i] = blocks[i];
_onBlockReleased = onBlockReleased;
FramesPerBlock = framesPerBlock;
Channels = channels;
SampleRate = sampleRate;
TotalFrames = totalFrames;
}
/// <inheritdoc/>
/// <exception cref="InvalidOperationException"><paramref name="frame"/> lies in a block that was already released.</exception>
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame < (long)_releasedCount * FramesPerBlock)
throw new InvalidOperationException($"Frame {frame} was already released by the forward-only reader.");
Position = frame;
}
/// <inheritdoc/>
/// <remarks>Blocks whose end the position has passed are released after each copy.</remarks>
public int Read(float[] buffer, int frameCount)
{
var total = (int)Math.Min(frameCount, TotalFrames - Position);
if (total <= 0) return 0;
var written = 0;
while (written < total)
{
var blockIndex = (int)(Position / FramesPerBlock);
var block = _blocks[blockIndex] ?? throw new InvalidOperationException($"Block {blockIndex} was already released.");
var offset = (int)(Position - (long)blockIndex * FramesPerBlock);
var count = Math.Min(total - written, FramesPerBlock - offset);
Array.Copy(block, offset * Channels, buffer, written * Channels, count * Channels);
written += count;
Position += count;
ReleaseBlocksBefore((int)(Position / FramesPerBlock));
}
return written;
}
/// <summary>Releases every block with an index below <paramref name="blockIndex"/> that is not released yet, in order.</summary>
private void ReleaseBlocksBefore(int blockIndex)
{
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
{
var block = _blocks[_releasedCount];
_blocks[_releasedCount] = null;
_releasedCount++;
if (block != null) _onBlockReleased?.Invoke(block);
}
}
/// <summary>Releases every remaining block. Safe to call more than once.</summary>
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sample rate converter for interleaved 32-bit float PCM (libswresample).
/// Input and output have the same channel count; only the sample rate changes.
/// One instance keeps filter state between calls, so consecutive <see cref="Convert"/> calls form one continuous stream.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
/// </summary>
internal sealed unsafe class PCMResampler : IDisposable
{
/// <summary>Resampler context owned by this instance; null after <see cref="Dispose"/>.</summary>
private SwrContext* _swr;
/// <summary>True after <see cref="Flush"/> until <see cref="Reset"/>.</summary>
private bool _flushed;
/// <summary>Channel count of input and output (interleaved).</summary>
public int Channels { get; }
/// <summary>Input sample rate in Hz.</summary>
public int InputRate { get; }
/// <summary>Output sample rate in Hz.</summary>
public int OutputRate { get; }
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
public int FilterSize { get; }
/// <summary>Creates and initializes a float-to-float resampler with the default channel layout for <paramref name="channels"/>.</summary>
/// <exception cref="ArgumentOutOfRangeException">A count or rate is not positive.</exception>
/// <exception cref="InvalidOperationException">libswresample cannot allocate or initialize the context.</exception>
public PCMResampler(int channels, int inputRate, int outputRate)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (inputRate <= 0) throw new ArgumentOutOfRangeException(nameof(inputRate));
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
Channels = channels;
InputRate = inputRate;
OutputRate = outputRate;
AVChannelLayout layout;
ffmpeg.av_channel_layout_default(&layout, channels);
SwrContext* swr = null;
var ret = ffmpeg.swr_alloc_set_opts2(
&swr,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, outputRate,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, inputRate,
0, null);
ffmpeg.av_channel_layout_uninit(&layout);
if (ret < 0 || swr == null)
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
_swr = swr;
// Falls back to 32, the libswresample default of "filter_size", when the option cannot be read.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libswresample/options.c (filter_size)
long filterSize;
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
Reset();
}
/// <summary>Discards all buffered input and filter history. The next call starts a new stream.</summary>
public void Reset()
{
// swr_init on an initialized context re-initializes it.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html (swr_init, swr_close)
var ret = ffmpeg.swr_init(_swr);
if (ret < 0)
throw new InvalidOperationException($"swr_init failed ({ret}).");
_flushed = false;
}
/// <summary>
/// Feeds <paramref name="inputFrames"/> frames and writes up to <paramref name="outputCapacity"/> frames.
/// Input that does not fit in the output is buffered inside the context and returned by later calls.
/// <paramref name="inputFrames"/> may be 0 to take only buffered output.
/// </summary>
/// <returns>Frames written to <paramref name="output"/>.</returns>
public int Convert(float* input, int inputFrames, float* output, int outputCapacity)
{
if (_flushed) throw new InvalidOperationException("Resampler was flushed. Call Reset first.");
// swr_convert treats a null input array as a flush request, so a non-null pointer is passed even for 0 frames.
float dummy = 0;
var inPlane = (byte*)(input != null ? input : &dummy);
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, &inPlane, inputFrames);
if (ret < 0)
throw new InvalidOperationException($"swr_convert failed ({ret}).");
return ret;
}
/// <summary>
/// Ends the input stream and writes up to <paramref name="outputCapacity"/> remaining frames.
/// Call repeatedly until it returns 0. <see cref="Convert"/> is not allowed afterwards until <see cref="Reset"/>.
/// </summary>
public int Flush(float* output, int outputCapacity)
{
_flushed = true;
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, null, 0);
if (ret < 0)
throw new InvalidOperationException($"swr_convert (flush) failed ({ret}).");
return ret;
}
/// <summary>Frees the context. Safe to call more than once.</summary>
public void Dispose()
{
if (_swr == null) return;
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
/// (built-in hit sounds from Resources, official/DLC level audio).
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main Unity thread only, so the whole clip is copied once on the main Unity thread
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
/// </summary>
internal static class UnityClipLoader
{
/// <summary>
/// Copies <paramref name="clip"/> in blocks of <see cref="AudioClipSource.ChunkFloats"/> floats or less
/// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only.
/// Block memory is counted in <paramref name="store"/> and released block by block as the decoder thread reads it.
/// </summary>
public static MemoryPCMReader Load(AudioClip clip, AudioClipStore store)
{
if (!clip) throw new ArgumentNullException(nameof(clip));
if (clip.loadType == AudioClipLoadType.Streaming)
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
// Clips with preloadAudioData off start unloaded; LoadAudioData loads the data synchronously, or asynchronously for
// clips with loadInBackground (GetData below then fails while the data is still loading).
// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.LoadAudioData.html
if (clip.loadState == AudioDataLoadState.Unloaded)
clip.LoadAudioData();
var channels = clip.channels;
var totalFrames = (long)clip.samples;
var framesPerBlock = AudioClipSource.ChunkFloats / channels;
var blocks = new List<float[]>();
try
{
for (long start = 0; start < totalFrames; start += framesPerBlock)
{
var frames = (int)Math.Min(framesPerBlock, totalFrames - start);
var block = store.AllocateExactArray(frames * channels);
blocks.Add(block);
// GetData fills the whole array; the offset is in frames (samples per channel).
if (!clip.GetData(block, (int)start))
throw new InvalidOperationException($"AudioClip.GetData failed for '{clip.name}' at frame {start} (load state {clip.loadState}).");
}
}
catch
{
foreach (var block in blocks) store.FreeExactArray(block);
throw;
}
return new MemoryPCMReader(blocks, framesPerBlock, channels, clip.frequency, totalFrames, store.FreeExactArray);
}
}
+13 -4
View File
@@ -2,22 +2,31 @@ using System;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Constant gain: every sample is multiplied by 10^(<see cref="Level"/> / 20). Stateless and without latency.
/// </summary>
internal class Gain : IAudioFilter
{
public float Level = 0f;
/// <summary>Gain in dB (0 = unchanged, -1 ≈ ×0.891, -6 ≈ ×0.501). Read on every sample, so changes apply immediately.</summary>
public double Level = 0;
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
/// <summary>Returns <paramref name="sample"/> × 10^(<see cref="Level"/> / 20); <paramref name="channel"/> is not used.</summary>
public double ProcessSample(double sample, int channel) =>
sample * Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
/// <inheritdoc/>
public void Reset() { }
/// <inheritdoc/>
public double Latency => 0.0;
}
+13 -3
View File
@@ -3,30 +3,40 @@ using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Hard clipper: limits every sample to [-<see cref="Threshold"/>, +<see cref="Threshold"/>]. Stateless and without latency.
/// In the chain of <see cref="OfflineAudioMixer"/> it catches the peaks the RMS-based limiter lets through before the final gain.
/// </summary>
internal class HardClip : IAudioFilter
{
/// <summary>Clip level as a linear amplitude (1.0 = full scale).</summary>
// ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public double Threshold = 1.0;
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels) { }
/// <summary>Returns <paramref name="sample"/> clamped to ±<see cref="Threshold"/>; <paramref name="channel"/> is not used.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
public double ProcessSample(double sample, int channel)
{
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
/// <inheritdoc/>
public void Reset() { }
/// <inheritdoc/>
public double Latency => 0.0;
}
+22 -2
View File
@@ -2,14 +2,34 @@ using System;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Stage of the output filter chain of <see cref="OfflineAudioMixer"/>. Samples are doubles at nominal full scale ±1.0.
/// A filter keeps its own state between calls, so consecutive blocks form one continuous signal.
/// Used by one thread at a time (the render thread during rendering).
/// </summary>
internal interface IAudioFilter
{
/// <summary>
/// Sets the sample rate (Hz) and interleaved channel count of the signal and allocates per-channel state.
/// Called once before the first sample; the state is cleared as by <see cref="Reset"/>.
/// </summary>
void Initialize(int sampleRate, int channels);
float ProcessSample(float sample, int channel);
void ProcessBlock(Span<float> buffer);
/// <summary>Processes one sample of channel <paramref name="channel"/> (0 ~ channels - 1) and returns the output sample.</summary>
double ProcessSample(double sample, int channel);
/// <summary>
/// Processes <paramref name="buffer"/> in place. The buffer holds interleaved frames starting at channel 0
/// (length = frames * channels of <see cref="Initialize"/>).
/// </summary>
void ProcessBlock(Span<double> buffer);
/// <summary>Clears the internal state (envelopes, delay lines) without changing the settings.</summary>
void Reset();
/// <summary>
/// Delay of the output relative to the input, in seconds (0 for filters without look-ahead).
/// <see cref="OfflineAudioMixer"/> sums the latencies of the chain and compensates them.
/// </summary>
double Latency { get; }
}
+99 -53
View File
@@ -3,91 +3,127 @@ using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Feed-forward RMS compressor with soft knee, attack/release ballistics in the dB domain and optional look-ahead.
/// Each channel is detected and gain-reduced independently (no stereo link).
///
/// Per sample of channel c:
/// 1. Mean square: ms[n] = r * ms[n-1] + (1 - r) * x[n]², r = exp(-1 / (sampleRate * <see cref="RmsWindowTime"/>));
/// level L = 10 * log10(ms + 1e-12) dB (the 1e-12 floor keeps silence finite, about -120 dB).
/// 2. Static curve (gain reduction G ≤ 0 dB, T = <see cref="ThresholdDb"/>, R = <see cref="Ratio"/>, W = <see cref="KneeDb"/>):
/// G = (1/R - 1) * (L - T) above T + W/2; G = (1/R - 1) * (L - T + W/2)² / (2W) inside the knee; 0 below T - W/2.
/// With W = 0 the knee is hard (G = (1/R - 1) * (L - T) above T). R = infinity gives G = T - L (a limiter at T).
/// 3. Ballistics: g[n] = k * g[n-1] + (1 - k) * G, with k = attack coefficient while the reduction grows (G &lt; g[n-1]) and
/// release coefficient otherwise; coefficient = exp(-1 / (sampleRate * time)) (one-pole smoothing, time constant in seconds).
/// 4. Output = x[n - lookAhead] * 10^(g[n] / 20): the gain computed from the current input is applied to the input delayed by
/// <see cref="LookAheadTime"/>, so the reduction starts before the peak reaches the output.
///
/// Properties may be set before <see cref="Initialize"/> (as in an object initializer); Initialize recomputes every
/// coefficient for the actual sample rate and reallocates the delay lines. Not thread-safe.
/// </summary>
// ReSharper disable once InconsistentNaming
internal class RMSCompressor : IAudioFilter
{
private float _thresholdDb = -20f;
private float _ratio = 4f;
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private double _thresholdDb = -20;
private double _ratio = 4;
private double _attackTime = 0.010; // in seconds
private double _releaseTime = 0.100; // in seconds
private double _kneeDb = 5; // in dB
private double _rmsWindowTime = 0.030; // in seconds
private double _lookAheadTime; // in seconds
/// <summary>Look-ahead delay in samples per channel: round(sampleRate * <see cref="LookAheadTime"/>); 0 disables the delay line.</summary>
private int _lookAheadSamples;
/// <summary>Sample rate in Hz; 44100 until <see cref="Initialize"/> sets the actual rate.</summary>
private int _sampleRate = 44100;
/// <summary>Interleaved channel count; 2 until <see cref="Initialize"/> sets the actual count.</summary>
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// One-pole smoothing coefficients (0 ~ 1) of attack, release and the mean-square detector: exp(-1 / (sampleRate * time)).
private double _attackCoef;
private double _releaseCoef;
private double _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private double[] _rmsState = Array.Empty<double>(); // running mean-square (MS) state
private double[] _gainState = Array.Empty<double>(); // smoothed gain reduction in dB
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
/// <summary>
/// Look-ahead delay in seconds, clamped to 0 ~ 1. Setting it reallocates (and clears) the delay lines.
/// Also reported as <see cref="Latency"/>.
/// </summary>
public double LookAheadTime
{
get => _lookAheadTime;
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
_lookAheadTime = Math.Max(0, Math.Min(1.0, value));
UpdateLookAheadSettings();
}
}
/// <summary>Output delay in seconds: <see cref="LookAheadTime"/>.</summary>
public double Latency => _lookAheadTime;
public float ThresholdDb
/// <summary>Threshold in dB relative to full scale (RMS level of a constant ±1.0 signal = 0 dB).</summary>
public double ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
/// <summary>Compression ratio (input dB above threshold : output dB above threshold), at least 1. Infinity limits at the threshold.</summary>
public double Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
set => _ratio = Math.Max(1, value);
}
public float AttackTime
/// <summary>Attack time constant in seconds, at least 0.0001 (smaller values, including 0, are raised to it).</summary>
public double AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
_attackTime = Math.Max(0.0001, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
/// <summary>Release time constant in seconds, at least 0.0001.</summary>
public double ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
_releaseTime = Math.Max(0.0001, value);
UpdateReleaseCoef();
}
}
public float KneeDb
/// <summary>Total knee width in dB, centered on the threshold, at least 0 (0 = hard knee).</summary>
public double KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
set => _kneeDb = Math.Max(0, value);
}
public float RmsWindowTime
/// <summary>Time constant of the mean-square detector in seconds, at least 0.0001.</summary>
public double RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
_rmsWindowTime = Math.Max(0.0001, value);
UpdateRmsCoef();
}
}
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
@@ -98,8 +134,8 @@ internal class RMSCompressor : IAudioFilter
UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
_rmsState = new double[channels];
_gainState = new double[channels];
Reset();
}
@@ -110,37 +146,42 @@ internal class RMSCompressor : IAudioFilter
ReallocateDelayBuffers();
}
/// <summary>Allocates one zeroed circular buffer of <see cref="_lookAheadSamples"/> samples per channel.</summary>
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayBuffers = new double[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayBuffers[i] = _lookAheadSamples > 0 ? new double[_lookAheadSamples] : Array.Empty<double>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
_attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
_releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
/// <summary>
/// Processes one sample of <paramref name="channel"/> (see the class summary). A channel outside 0 ~ channels - 1 is
/// returned unchanged and does not touch any state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
public double ProcessSample(double sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
@@ -149,54 +190,57 @@ internal class RMSCompressor : IAudioFilter
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
double sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0 - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
double rmsSq = Math.Max(0, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
double rmsDb = 10.0 * Math.Log10(rmsSq + 1e-12);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
double gStatic = 0;
if (_kneeDb > 0)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
if (rmsDb > _thresholdDb + _kneeDb * 0.5)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
// Above the knee: full ratio.
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
// Inside the knee: quadratic transition from 0 dB at T - W/2 to the full-ratio line at T + W/2.
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
double lastGain = _gainState[channel];
double targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
double coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
double currentGain = coef * lastGain + (1 - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
double linearGain = Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
// Circular delay line: read the sample written _lookAheadSamples calls ago, then overwrite it with the current one.
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
double delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
@@ -205,7 +249,8 @@ internal class RMSCompressor : IAudioFilter
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
@@ -214,6 +259,7 @@ internal class RMSCompressor : IAudioFilter
}
}
/// <summary>Clears the detector, the gain envelope and the delay lines (the delay outputs zeros for the next look-ahead samples).</summary>
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
+199
View File
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary>
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one sound effect), created by <see cref="VoiceMixer.Play"/>.
///
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample floor(time * OutputRate) (<see cref="VoiceMixer.ToSample"/>).
///
/// Playback model (output samples n, clip positions in frames of the source at the output sample rate):
/// - The voice is silent before its start sample and from its end sample on.
/// - Clip position at sample n = anchor position + (n - anchor sample) * pitch. A position or pitch change sets a new anchor
/// at the sample where it takes effect, so a pitch change keeps the position continuous.
/// - Looping: the position wraps at the clip length (<see cref="AudioClipSource.Duration"/> * output rate, fractional).
/// Not looping: the voice ends when the position reaches <see cref="AudioClipSource.TotalFrames"/>.
/// </summary>
internal sealed class Voice
{
/// <summary>Kind of a queued <see cref="Change"/>; the meaning of <see cref="Change.Value"/> depends on it.</summary>
internal enum ChangeKind
{
/// <summary>Value: playback rate, at least 0.</summary>
Pitch,
/// <summary>Value: linear gain.</summary>
Volume,
/// <summary>Value: 1 = loop on, 0 = loop off.</summary>
Loop,
/// <summary>Value: clip position in seconds, at least 0.</summary>
Position,
/// <summary>Value: end time in seconds relative to the render start.</summary>
EndTime,
/// <summary>Value: unused (0). Ends playback at the sample the change is applied at.</summary>
Stop
}
/// <summary>One timed change of a voice, queued by the main Unity thread and applied by the render thread.</summary>
internal readonly struct Change
{
public readonly ChangeKind Kind;
/// <summary>Output sample at which the change takes effect (<see cref="VoiceMixer.ToSample"/> of the change time).</summary>
public readonly long Sample;
/// <summary>Argument of the change (see <see cref="ChangeKind"/>).</summary>
public readonly double Value;
public Change(ChangeKind kind, long sample, double value)
{
Kind = kind;
Sample = sample;
Value = value;
}
}
/// <summary>Mixer that owns this voice; the Set methods queue their changes on it.</summary>
private readonly VoiceMixer _mixer;
// ── Render thread state ──
/// <summary>Changes not yet applied, in time order. Render thread only.</summary>
internal readonly Queue<Change> Pending = new();
/// <summary>First output sample of playback.</summary>
internal long StartSample;
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
internal long EndSample = long.MaxValue;
/// <summary>Output sample of the current anchor (see the class summary).</summary>
internal long AnchorSample;
/// <summary>Clip position at <see cref="AnchorSample"/>, in frames at the output sample rate (may be fractional).</summary>
internal double AnchorPosition;
/// <summary>Clip frames advanced per output sample (1 = original speed), at least 0.</summary>
internal double Pitch;
/// <summary>Linear gain applied to the clip samples.</summary>
internal double Volume;
/// <summary>True while the clip repeats at <see cref="Period"/>.</summary>
internal bool Loop;
/// <summary>Set by the render thread when playback has ended; the voice is then released and removed from the mixer.</summary>
internal bool Finished;
/// <summary>
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
/// the value can lag behind the render thread.
/// </summary>
public bool IsFinished => Volatile.Read(ref Finished);
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
internal double Period;
/// <summary>Read position registered on <see cref="Source"/> so the decoder thread keeps the chunks ahead loaded; null without a usable source.</summary>
internal AudioCursor? Cursor;
/// <summary>Chunk of <see cref="Source"/> currently held for reading (invalid when none). Render thread only.</summary>
internal ChunkView View;
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
{
_mixer = mixer;
Source = source;
Name = name;
}
/// <summary>Clip played by this voice; null plays silence (the clip could not be loaded).</summary>
public AudioClipSource? Source { get; }
/// <summary>Name for logs (the game's clip key).</summary>
public string Name { get; }
/// <summary>Sets the playback rate (1 = original speed). Negative values are treated as 0.</summary>
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
/// <summary>Sets the linear gain applied to the clip samples.</summary>
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
/// <summary>Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.</summary>
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
/// <summary>
/// Moves playback to <paramref name="clipSeconds"/> seconds into the clip.
/// Before the voice starts, this sets the position playback starts from.
/// </summary>
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
/// <summary>Stops playback at <paramref name="endTime"/> (seconds relative to the render start).</summary>
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
/// <summary>Stops playback at <paramref name="time"/>.</summary>
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
/// <summary>Unwrapped clip position at output sample <paramref name="sample"/> (sample at or after the anchor).</summary>
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
/// <summary>Clip position at <paramref name="sample"/>, wrapped into the loop when looping.</summary>
internal double WrappedPositionAt(long sample)
{
var position = PositionAt(sample);
if (Loop && Period > 0 && position >= Period) position %= Period;
return position;
}
/// <summary>
/// Applies <paramref name="change"/> at output sample <paramref name="sample"/> (its own sample, or a later one when the change
/// arrived after its sample was mixed). Before the start sample, changes replace the starting state. Render thread only.
/// </summary>
internal void Apply(in Change change, long sample, int outputRate)
{
// Anchor changes made before the start take effect at the start sample (playback starts from the new state).
// EndTime uses the change's own value; Stop ends at the sample it is applied at, so a Stop before the start
// ends the voice before it plays.
var at = Math.Max(sample, StartSample);
switch (change.Kind)
{
case ChangeKind.Pitch:
Reanchor(at);
Pitch = change.Value;
break;
case ChangeKind.Volume:
Volume = change.Value;
break;
case ChangeKind.Loop:
Reanchor(at);
Loop = change.Value != 0.0;
break;
case ChangeKind.Position:
AnchorSample = at;
AnchorPosition = change.Value * outputRate;
break;
case ChangeKind.EndTime:
EndSample = VoiceMixer.ToSample(change.Value, outputRate);
break;
case ChangeKind.Stop:
EndSample = Math.Min(EndSample, sample);
break;
}
}
/// <summary>Moves the anchor to <paramref name="sample"/>, keeping the (wrapped) position there, before a pitch or loop change.</summary>
private void Reanchor(long sample)
{
AnchorPosition = WrappedPositionAt(sample);
AnchorSample = sample;
}
}
+376
View File
@@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary>
/// Mixes every <see cref="Voice"/> into interleaved double samples at the output sample rate and channel count.
///
/// Time: output sample n is at n / OutputRate seconds relative to the render start (the same time base as the times passed
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample floor(t * OutputRate) (<see cref="ToSample"/>).
///
/// Threads:
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
/// non-decreasing time order per voice.
/// - Render thread: <see cref="Mix"/> takes the queued commands and mixes a block of samples. A command takes effect at its sample;
/// a command whose sample is before the block being mixed takes effect at the start of that block.
/// Chunks are read through <see cref="AudioClipStore.Acquire"/>, which waits for the decoder thread.
///
/// Sample reading: clip positions are in frames of the source at the output rate (sources are resampled when loaded).
/// An integer position copies the stored sample; a fractional position interpolates linearly between the frame and the next one
/// (the next frame of a looping voice after the last frame is frame 0; after the end of a non-looping clip it is 0).
/// Channel mapping follows <see cref="ChunkView.GetSample"/>.
/// </summary>
internal sealed class VoiceMixer : IDisposable
{
/// <summary>Kind of a queued main-thread command.</summary>
private enum CommandKind
{
/// <summary>Adds a new voice to the mixed set (created by <see cref="VoiceMixer.Play"/>).</summary>
Play,
/// <summary>Appends a <see cref="Voice.Change"/> to the voice's pending queue.</summary>
Change
}
/// <summary>Command queued by the main Unity thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
private readonly struct Command
{
public readonly CommandKind Kind;
public readonly Voice Voice;
/// <summary>The change for <see cref="CommandKind.Change"/>; default for <see cref="CommandKind.Play"/>.</summary>
public readonly Voice.Change Change;
public Command(CommandKind kind, Voice voice, Voice.Change change)
{
Kind = kind;
Voice = voice;
Change = change;
}
}
private readonly AudioClipStore _store;
private readonly CancellationToken _cancellationToken;
// Double-buffered command lists: the main Unity thread appends to _commands under _commandLock; the render thread swaps the
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
private readonly object _commandLock = new();
private List<Command> _commands = new();
private List<Command> _takenCommands = new();
// Render thread only.
private readonly List<Voice> _voices = new();
private bool _disposed;
/// <param name="store">Store the voices' sources belong to; its OutputRate is the mixer's sample rate.</param>
/// <param name="outputChannels">Interleaved channel count of the mixed output.</param>
/// <param name="cancellationToken">Cancels waits for chunks in <see cref="Mix"/>.</param>
public VoiceMixer(AudioClipStore store, int outputChannels, CancellationToken cancellationToken = default)
{
if (outputChannels < 1) throw new ArgumentOutOfRangeException(nameof(outputChannels));
_store = store;
OutputRate = store.OutputRate;
OutputChannels = outputChannels;
_cancellationToken = cancellationToken;
}
/// <summary>Output sample rate in Hz (the store's <see cref="AudioClipStore.OutputRate"/>).</summary>
public int OutputRate { get; }
/// <summary>Interleaved channel count of the mixed output (1 or more).</summary>
public int OutputChannels { get; }
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
public int ActiveVoiceCount => _voices.Count;
/// <summary>
/// Output sample containing <paramref name="seconds"/> (seconds × rate rounded down), the sample at which
/// AudioSource.PlayScheduled starts a sound scheduled between two output samples.
/// </summary>
public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate);
// ── Main thread ──
/// <summary>
/// Schedules <paramref name="source"/> to start at <paramref name="startTime"/>.
/// A start time before <paramref name="time"/> (the current time) starts the voice at <paramref name="time"/>.
/// </summary>
/// <param name="source">Clip to play; null plays silence.</param>
/// <param name="name">Name for logs.</param>
/// <param name="startTime">Start of playback, seconds relative to the render start.</param>
/// <param name="volume">Linear gain.</param>
/// <param name="pitch">Playback rate (1 = original speed); negative values are treated as 0.</param>
/// <param name="loop">Repeats the clip until the voice is stopped.</param>
/// <param name="time">Current time, seconds relative to the render start.</param>
public Voice Play(AudioClipSource? source, string name, double startTime, double volume, double pitch, bool loop, double time)
{
var voice = new Voice(this, source, name)
{
StartSample = Math.Max(ToSample(startTime, OutputRate), ToSample(time, OutputRate)),
Volume = volume,
Pitch = Math.Max(0.0, pitch),
Loop = loop,
Period = source != null ? source.Duration * OutputRate : 0.0
};
voice.AnchorSample = voice.StartSample;
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Play, voice, default));
}
return voice;
}
/// <summary>
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
/// Called by the Voice Set methods on the main Unity thread.
/// </summary>
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
{
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Change, voice, change));
}
}
// ── Render thread ──
/// <summary>
/// Adds the mix of output samples [<paramref name="firstSample"/>, firstSample + frames) to <paramref name="destination"/>
/// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order.
/// </summary>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
/// <exception cref="OperationCanceledException">The cancellation token was canceled while waiting for a chunk.</exception>
public void Mix(long firstSample, Span<double> destination)
{
if (_disposed) throw new ObjectDisposedException(nameof(VoiceMixer));
var frames = destination.Length / OutputChannels;
var endSample = firstSample + frames;
TakeCommands();
// Iterated backwards so finished voices can be removed in place.
for (var i = _voices.Count - 1; i >= 0; i--)
{
var voice = _voices[i];
var sample = firstSample;
// Split the block into segments at the samples of pending changes: each segment is mixed with constant
// voice state, and a change is applied when the segment reaches its sample (or at once when its sample is past).
while (!voice.Finished)
{
var segmentEnd = endSample;
if (voice.Pending.Count > 0)
{
var next = voice.Pending.Peek();
if (next.Sample <= sample)
{
voice.Apply(voice.Pending.Dequeue(), sample, OutputRate);
continue;
}
segmentEnd = Math.Min(segmentEnd, next.Sample);
}
if (segmentEnd > sample)
MixSegment(voice, sample, segmentEnd, destination.Slice((int)(sample - firstSample) * OutputChannels));
sample = segmentEnd;
if (sample >= endSample) break;
}
// The end sample is inside or before this block: nothing more will be mixed. Changes still pending for this voice
// (after the end) are dropped with it.
if (!voice.Finished && endSample >= voice.EndSample)
voice.Finished = true;
if (voice.Finished)
{
ReleaseVoice(voice);
_voices.RemoveAt(i);
}
}
}
/// <summary>
/// Takes every queued command: Play adds the voice (with a cursor at frame 0 when its source is usable), Change appends to
/// the voice's pending queue (dropped for finished voices). Render thread only.
/// </summary>
private void TakeCommands()
{
lock (_commandLock)
{
(_commands, _takenCommands) = (_takenCommands, _commands);
}
foreach (var command in _takenCommands)
{
var voice = command.Voice;
if (command.Kind == CommandKind.Play)
{
if (voice.Source is { IsFailed: false } source)
voice.Cursor = source.AddCursor(0, voice.Loop);
_voices.Add(voice);
}
else if (!voice.Finished)
{
voice.Pending.Enqueue(command.Change);
}
}
_takenCommands.Clear();
}
/// <summary>Mixes <paramref name="voice"/> over samples [from, to) with its current state into <paramref name="destination"/> (starting at sample from).</summary>
private void MixSegment(Voice voice, long from, long to, Span<double> destination)
{
var start = Math.Max(from, voice.StartSample);
var end = Math.Min(to, voice.EndSample);
if (start >= end) return;
// A voice whose source is missing or failed is silent; it finishes at its first segment inside [start, end).
var source = voice.Source;
if (source == null || source.IsFailed)
{
voice.Finished = true;
return;
}
var totalFrames = source.TotalFrames;
if (voice.Volume == 0.0)
{
// Nothing to add; a non-looping voice still ends at the end of the clip.
if (!voice.Loop && voice.PositionAt(end - 1) >= totalFrames) voice.Finished = true;
return;
}
var channels = OutputChannels;
var volume = voice.Volume;
var offset = (int)(start - from) * channels;
// Move the cursor to the segment start so the decoder thread keeps the chunks ahead of it loaded.
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
for (var n = start; n < end; n++, offset += channels)
{
var position = voice.WrappedPositionAt(n);
if (position >= totalFrames)
{
if (!voice.Loop)
{
voice.Finished = true;
return;
}
// Looping: a wrapped position is below Period, and Period (Duration * rate) is at most TotalFrames
// (TotalFrames rounds up), so this is a guard that reads the last frame.
position = totalFrames - 1;
}
var index = (long)position;
var fraction = position - index;
if (!EnsureView(voice, index)) return;
var view = voice.View;
// Integer position (pitch 1 with an integer anchor): copy the stored sample.
if (fraction == 0.0)
{
for (var c = 0; c < channels; c++)
destination[offset + c] += view.GetSample(index, c, channels) * volume;
continue;
}
// Linear interpolation; the next frame may lie in the following chunk, at the loop start or past the end.
var nextIndex = index + 1;
var nextInView = view.Contains(nextIndex);
for (var c = 0; c < channels; c++)
{
double low = view.GetSample(index, c, channels);
double high = nextInView ? view.GetSample(nextIndex, c, channels) : ReadOutside(voice, nextIndex, c);
destination[offset + c] += (low + fraction * (high - low)) * volume;
}
}
}
/// <summary>Sample of frame <paramref name="frame"/> outside the voice's current view (next chunk, loop start, or past the end).</summary>
private double ReadOutside(Voice voice, long frame, int channel)
{
var source = voice.Source!;
if (frame >= source.TotalFrames)
{
if (!voice.Loop) return 0.0;
frame = 0;
}
var view = _store.Acquire(source, frame, _cancellationToken);
if (!view.IsValid) return 0.0;
try
{
return view.GetSample(frame, channel, OutputChannels);
}
finally
{
source.Release(view);
}
}
/// <summary>Makes voice.View the chunk containing <paramref name="frame"/>. False when the source cannot provide it (voice finished).</summary>
private bool EnsureView(Voice voice, long frame)
{
if (voice.View.IsValid && voice.View.Contains(frame)) return true;
var source = voice.Source!;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
UpdateCursor(voice, frame);
voice.View = _store.Acquire(source, frame, _cancellationToken);
if (voice.View.IsValid) return true;
voice.Finished = true;
return false;
}
/// <summary>Moves the voice's cursor to <paramref name="frame"/> and copies the voice's loop flag (writes only on change).</summary>
private static void UpdateCursor(Voice voice, long frame)
{
var cursor = voice.Cursor;
if (cursor == null) return;
if (cursor.Loop != voice.Loop) cursor.Loop = voice.Loop;
if (cursor.Position != frame) cursor.Position = frame;
}
/// <summary>Releases the voice's held chunk and cursor and drops its pending changes. Render thread only.</summary>
private static void ReleaseVoice(Voice voice)
{
var source = voice.Source;
if (source == null) return;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
if (voice.Cursor != null)
{
source.RemoveCursor(voice.Cursor);
voice.Cursor = null;
}
voice.Pending.Clear();
}
/// <summary>Releases every voice's chunk and cursor. Render thread (or after the render thread stopped).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var voice in _voices)
ReleaseVoice(voice);
_voices.Clear();
lock (_commandLock)
{
_commands.Clear();
}
}
}
+364 -350
View File
@@ -1,416 +1,430 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
using ADOFAIRenderer.Util.Audio.Filter;
using ADOFAIRenderer.Util.Audio.Mixer;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
/// <summary>
/// Audio of one render: the song and sound effects as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
/// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain).
///
/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate.
/// The limiter delays its output by its look-ahead (<see cref="IAudioFilter.Latency"/>), so output sample n is mixed from
/// voices at sample n + latency, and the filters are primed with samples [0, latency) before the first block.
///
/// Threads:
/// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main Unity thread has committed the end of the samples it mixes,
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main Unity thread stops.
/// </summary>
internal sealed class OfflineAudioMixer : IDisposable
{
private readonly Dictionary<string, CachedClipData> _cache = new();
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
private const double ClipVolume = 0.5;
public class CachedClipData
{
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
/// <summary>Additional gain of sound effects (registered with <see cref="RegisterSoundEvent"/>).</summary>
private const double EffectVolume = 2.0 / 3.0;
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
/// <summary>
/// Smallest number of tracked effect voices at which finished ones are removed from the list used by <see cref="StopAllEffects"/>.
/// After each removal the next threshold is twice the remaining count (at least this value).
/// </summary>
private const int EffectVoicePruneCount = 1024;
lock (_cache)
{
if (_cache.TryGetValue(clipName, out var cached))
return cached;
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
private const int CommitWaitMs = 50;
if (!allowLoad) return null;
/// <summary>Output channel count (interleaved).</summary>
private readonly int _channels;
if (!AudioManager.Instance) return null;
/// <summary>Output sample rate in Hz.</summary>
private readonly int _sampleRate;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
private readonly AudioClipStore _store;
private readonly VoiceMixer _mixer;
try
{
var totalSamples = clip.samples;
var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
/// <summary>Canceled by <see cref="Cancel"/>/<see cref="Dispose"/>; ends commit waits and chunk waits on the render thread.</summary>
private readonly CancellationTokenSource _cancellation = new();
// Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
/// <summary>Filter chain applied in list order to every mixed block.</summary>
private readonly List<IAudioFilter> _filters = new();
var data = new CachedClipData
{
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
/// <summary>Sum of the filter latencies in output samples: round(sum of <see cref="IAudioFilter.Latency"/> * sample rate).</summary>
private readonly int _latencySamples;
_cache[clipName] = data;
return data;
}
catch (Exception e)
{
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
}
}
// Main thread.
public void Clear()
{
lock (_cache)
{
_cache.Clear();
}
}
}
/// <summary>
/// Mapping of game AudioSources to the state of the sound effect last started on them. Weak keys: a destroyed source
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
/// </summary>
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
internal class OfflineAudioMixer
{
public class SoundEvent
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
}
/// <summary>Effect voices started since the last <see cref="StopAllEffects"/> (finished ones are pruned periodically).</summary>
private readonly List<Voice> _effectVoices = new();
private readonly List<SoundEvent> _activeEvents = new();
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly object _lock = new();
/// <summary>Count of <see cref="_effectVoices"/> at which the next prune runs.</summary>
private int _effectVoicePruneAt = EffectVoicePruneCount;
// Target Audio Settings
private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
// Audio Filter Settings
private readonly LinkedList<IAudioFilter> _filters = new();
/// <summary>Song source, or null when there is no song or it could not be loaded.</summary>
private AudioClipSource? _bgmSource;
// BGM Cached Samples (Pre-resampled)
private float[]? _bgmSamples;
private double _bgmDuration;
/// <summary>audioLib key (AudioClip.name) of the song.</summary>
private string _bgmKey = "";
// Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
/// <summary>Current song voice, created by the first <see cref="UpdateBGM"/> and replaced when the start time changes.</summary>
private Voice? _bgmVoice;
// Values last sent to the song voice (start in seconds relative to the render start); NaN before the first update.
private double _bgmStart = double.NaN;
private double _bgmPitch = double.NaN;
private double _bgmVolume = double.NaN;
// Shared: committed output sample (voices are final before it), guarded by _commitLock.
private readonly object _commitLock = new();
private long _committedSample = long.MinValue;
// Render thread (and Dispose, under _renderLock).
private readonly object _renderLock = new();
/// <summary>Index of the next output sample <see cref="GenerateAudioForFrame"/> writes (frames per channel since the render start).</summary>
private long _nextOutputSample;
/// <summary>True once the filters have been fed samples [0, latency).</summary>
private bool _primed;
/// <summary>Reused interleaved mix buffer, grown to the largest block requested.</summary>
private double[] _mixBuffer = Array.Empty<double>();
private bool _disposed;
/// <summary>
/// Creates the clip store (and starts its decoder thread), the voice mixer and the filter chain.
/// Filter settings: RMS compressor (threshold -10 dB, ratio 2, attack/release 0.1 s, knee 10 dB, RMS window 0.02 s),
/// limiter (RMS compressor with threshold 0 dB, infinite ratio, attack 0 raised to the 0.0001 s minimum, release 0.05 s,
/// hard knee, RMS window 0.02 s, look-ahead 0.01 s), hard clip at ±1.0, then -1 dB gain.
/// </summary>
/// <param name="targetChannels">Output channel count (interleaved).</param>
/// <param name="targetSampleRate">Output sample rate in Hz; every clip is resampled to it.</param>
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
_channels = targetChannels;
_sampleRate = targetSampleRate;
_store = new AudioClipStore(targetSampleRate);
_mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token);
var rmsCompressor = new RMSCompressor
{
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
AttackTime = 0.1, ReleaseTime = 0.1,
KneeDb = 10, RmsWindowTime = 0.02
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var limiter = new RMSCompressor
{
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 0, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
ThresholdDb = 0, Ratio = double.PositiveInfinity,
AttackTime = 0, ReleaseTime = 0.05,
KneeDb = 0, RmsWindowTime = 0.02,
LookAheadTime = 0.01
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
_filters.Add(rmsCompressor);
_filters.Add(limiter);
_filters.Add(new HardClip());
_filters.Add(new Gain { Level = -1 });
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
// Total delay of the chain; only the limiter's look-ahead contributes (0.01 s).
double latency = 0;
foreach (var filter in _filters)
{
filter.Initialize(_sampleRate, _channels);
latency += filter.Latency;
}
_latencySamples = (int)Math.Round(latency * _sampleRate);
_store.Start();
}
/// <summary>Clip store of this render (output rate = mixer sample rate), also read by <see cref="AudioSpectrumGenerator"/>.</summary>
internal AudioClipStore Store => _store;
// ── Main thread ──
/// <summary>Registers the song clip. The song voice is created and kept in sync by <see cref="UpdateBGM"/>.</summary>
public void InitializeBGM(AudioClip? clip)
{
lock (_lock)
if (!clip)
{
if (!clip)
{
_bgmSamples = null;
_bgmDuration = 0;
return;
}
try
{
int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Pre-resample the BGM to the target format
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
_bgmSource = null;
return;
}
_bgmKey = clip!.name;
_bgmSource = AudioClipResolver.GetOrAdd(_store, _bgmKey, clip);
if (_bgmSource == null)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Song clip '{_bgmKey}' could not be loaded, the song will be silent.");
return;
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Song '{_bgmKey}': {_bgmSource.Channels} ch, {_bgmSource.Duration:F2} s, {_bgmSource.Policy}");
}
public void CacheClip(string clipName)
/// <summary>
/// Updates the song voice from the conductor (start time, pitch) and the level volume.
/// A changed start time restarts the song voice at position (now - start) * pitch; pitch and volume changes take effect at <paramref name="now"/>.
/// Start time: dspTimeSong + separate countdown (crotchetAtStart / pitch * adjustedCountdownTicks when separateCountdownTime),
/// the time scrConductor.StartMusicCo schedules the song at.
/// </summary>
/// <param name="now">Current time, seconds relative to the render start.</param>
/// <param name="dspTimeBase">AudioSettings.dspTime at the render start.</param>
public void UpdateBGM(double now, double dspTimeBase)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{
var ev = new SoundEvent
{
ClipName = clipName,
StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
if (_bgmSource == null) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
var start = conductor.dspTimeSong + countdown - dspTimeBase;
// levelData.volume is a percentage (100 = unchanged; scrConductor sets song.volume = volume * 0.01 the same way).
// Without a scnGame instance the level volume is 1.0.
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
var volume = levelVolume * ClipVolume;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
if (_bgmVoice == null || start != _bgmStart)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
// Song position at time t is (t - start) * pitch, also when the start is already past.
_bgmVoice?.Stop(now);
_bgmVoice = _mixer.Play(_bgmSource, _bgmKey, Math.Max(start, now), volume, pitch, false, now);
if (start < now) _bgmVoice.SetPosition((now - start) * pitch, now);
_bgmStart = start;
_bgmPitch = pitch;
_bgmVolume = volume;
return;
}
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = _bgmSamples[idxLow * _targetChannels + c];
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
* ClipVolume;
}
}
if (pitch != _bgmPitch)
{
_bgmVoice.SetPitch(pitch, now);
_bgmPitch = pitch;
}
if (volume != _bgmVolume)
{
_bgmVoice.SetVolume(volume, now);
_bgmVolume = volume;
}
}
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
/// <summary>Prepares the source of the game clip <paramref name="clipName"/> (audioLib key) so its first chunk starts loading.</summary>
public void CacheClip(string clipName)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
GetOrAddSource(clipName);
}
/// <summary>
/// Source of the game clip <paramref name="clipName"/> (audioLib key): an existing source, or one opened through
/// <see cref="AudioClipResolver"/> with the clip from AudioManager.FindOrLoadAudioClip. Null for an empty name or an
/// unreadable clip (the key is then remembered as missing by the store). Main thread only.
/// </summary>
private AudioClipSource? GetOrAddSource(string clipName)
{
if (string.IsNullOrEmpty(clipName)) return null;
if (_store.TryGet(clipName, out var existing)) return existing;
var clip = AudioManager.Instance ? AudioManager.Instance.FindOrLoadAudioClip(clipName) : null;
return AudioClipResolver.GetOrAdd(_store, clipName, clip);
}
/// <summary>
/// Starts a sound effect at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
/// </summary>
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
/// <param name="clipName">audioLib key of the clip. A clip that cannot be loaded gives a silent voice.</param>
/// <param name="startTime">Scheduled start, seconds relative to the render start (a start before <paramref name="now"/> starts at now).</param>
/// <param name="endTime">Loop end, seconds relative to the render start; negative for a sound that plays once.</param>
/// <param name="volume">Game volume (linear); multiplied by the clip and effect gains.</param>
/// <param name="pitch">Playback rate at the start.</param>
/// <param name="now">Current render time in seconds.</param>
/// <returns>State of the new sound, also used by the AudioSource patches.</returns>
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
{
var clipSource = GetOrAddSource(clipName);
var loop = endTime >= 0;
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
if (loop) voice.SetEndTime(endTime, now);
// Amortized pruning: the list is scanned only when it has doubled since the last prune.
if (_effectVoices.Count >= _effectVoicePruneAt)
{
var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null)
{
_activeEvents.RemoveAt(i);
continue;
}
_effectVoices.RemoveAll(v => v.IsFinished);
_effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2);
}
_effectVoices.Add(voice);
var isSoundFinished = false;
var totalSamples = clipData.Samples.Length / _targetChannels;
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
var state = new SourceState(voice, Math.Max(startTime, now), pitch, loop, loop ? endTime : double.PositiveInfinity,
clipSource?.Duration ?? 0.0, _sampleRate);
if (source) MapSource(source!, state);
return state;
}
for (var s = 0; s < samplesNeeded; s++)
{
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
/// <summary>
/// Maps <paramref name="source"/> to <paramref name="state"/> (replaces an earlier mapping) and takes the clip length and
/// sample rate from the clip of the source.
/// </summary>
public void MapSource(AudioSource source, SourceState state)
{
state.BindClip(source.clip);
_sourceStates.Remove(source);
_sourceStates.Add(source, state);
}
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{
isSoundFinished = true;
break;
}
/// <summary>State of the sound effect last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
public bool TryGetSourceState(AudioSource? source, out SourceState state)
{
state = null!;
return source != null && _sourceStates.TryGetValue(source, out state);
}
var elapsed = sampleTime - adjustedStartTime;
if (elapsed < 0) continue; // Sound hasn't started yet
/// <summary>
/// Stops every sound effect at <paramref name="now"/>, including ones scheduled to start later
/// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of
/// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing.
/// </summary>
public void StopAllEffects(double now)
{
foreach (var voice in _effectVoices)
voice.Stop(now);
_effectVoices.Clear();
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
}
var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{
if (ev.EndTime < 0) // One-shot
{
isSoundFinished = true;
break;
}
// Looping
soundTime %= clipData.Duration;
}
// Linear interpolation for pitch shifting in the mixer loop
double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
}
}
// Clean up finished sound events (both one-shot and looping)
if (isSoundFinished)
{
_activeEvents.RemoveAt(i);
}
/// <summary>
/// Marks every command with a time before <paramref name="time"/> as queued
/// (called after each frame with the time of the next frame).
/// </summary>
public void Commit(double time)
{
var sample = VoiceMixer.ToSample(time, _sampleRate);
lock (_commitLock)
{
if (sample <= _committedSample) return;
_committedSample = sample;
Monitor.PulseAll(_commitLock);
}
}
}
/// <summary>No more commands will be queued (render ending): mixing no longer waits for commits.</summary>
public void Complete()
{
lock (_commitLock)
{
_committedSample = long.MaxValue;
Monitor.PulseAll(_commitLock);
}
}
/// <summary>
/// Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.
/// May be called from any thread, more than once.
/// </summary>
public void Cancel()
{
_cancellation.Cancel();
Complete();
}
// ── Render thread ──
/// <summary>
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
/// Blocks until the main Unity thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
/// </summary>
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
{
lock (_renderLock)
{
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
// First call: run voice samples [0, latency) through the filters and discard the output, which is the
// zero-filled delay lines. Afterwards output sample n is the filtered voice sample n.
if (!_primed)
{
_primed = true;
if (_latencySamples > 0)
{
var prime = MixBlock(0, _latencySamples);
foreach (var filter in _filters)
filter.ProcessBlock(prime);
}
}
var block = MixBlock(_nextOutputSample + _latencySamples, samplesNeeded);
foreach (var filter in _filters)
filter.ProcessBlock(block);
_nextOutputSample += samplesNeeded;
for (var i = 0; i < block.Length; i++)
mixedBuffer[i] = (float)block[i];
}
}
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main Unity thread committed them.</summary>
private Span<double> MixBlock(long first, int frames)
{
WaitForCommit(first + frames);
var length = frames * _channels;
if (_mixBuffer.Length < length) _mixBuffer = new double[length];
var block = _mixBuffer.AsSpan(0, length);
block.Clear();
_mixer.Mix(first, block);
return block;
}
/// <summary>
/// Waits until the committed sample reaches <paramref name="endSample"/> (exclusive end of the block to mix), checking for
/// cancellation every <see cref="CommitWaitMs"/> ms.
/// </summary>
/// <exception cref="OperationCanceledException">The render was canceled.</exception>
private void WaitForCommit(long endSample)
{
lock (_commitLock)
{
while (_committedSample < endSample)
{
_cancellation.Token.ThrowIfCancellationRequested();
Monitor.Wait(_commitLock, CommitWaitMs);
}
}
_cancellation.Token.ThrowIfCancellationRequested();
}
/// <summary>
/// Cancels waits, then releases the voices, the clip store (decoder thread) and the filter state.
/// Waits for a running <see cref="GenerateAudioForFrame"/> to return.
/// </summary>
public void Dispose()
{
Cancel();
lock (_renderLock)
{
if (_disposed) return;
_disposed = true;
_mixer.Dispose();
_store.Dispose();
foreach (var filter in _filters)
filter.Reset();
}
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
_effectVoices.Clear();
}
}
+179
View File
@@ -0,0 +1,179 @@
using System;
using ADOFAIRenderer.Util.Audio.Mixer;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Playback state of a game AudioSource mapped to an effect <see cref="Voice"/>, as the AudioSource reports it to the game
/// (isPlaying, time, timeSamples, pitch). Main thread only. Times are seconds relative to the render start (the render clock that
/// also drives AudioSettings.dspTime during rendering); clip positions are seconds into the clip.
///
/// Reported values:
/// - Playing: from the scheduled start until Stop(), the scheduled end time, or the natural end of the clip (not looping).
/// Before the start, isPlaying is true and time is the position playback will start from.
/// - time while playing: start position + elapsed time * pitch since the last change, wrapped at the clip length when looping.
/// time is 0 while not playing.
/// - timeSamples while playing: floor(time * clip frequency). While not playing: the last value set through time or timeSamples,
/// reset to 0 by Stop().
/// - pitch: the last finite value set (NaN and infinity are ignored). Playback uses max(0, pitch).
/// Changes made while not playing are stored and do not reach the voice.
/// </summary>
internal sealed class SourceState
{
/// <summary>Start of playback, seconds relative to the render start.</summary>
private readonly double _start;
/// <summary>Render time of the last position or pitch change (at least <see cref="_start"/>), seconds.</summary>
private double _anchorTime;
/// <summary>Unwrapped clip position at <see cref="_anchorTime"/>, seconds into the clip.</summary>
private double _anchorPosition;
/// <summary>Last finite pitch set (reported as is; negative values play as 0).</summary>
private double _pitch;
private readonly bool _loop;
/// <summary>Scheduled end time, seconds relative to the render start; infinity when not set.</summary>
private double _endTime = double.PositiveInfinity;
/// <summary>True after <see cref="Stop"/>; playback does not resume.</summary>
private bool _stopped;
/// <summary>timeSamples reported while not playing: the last value set through time/timeSamples, 0 after Stop.</summary>
private int _offsetSamples;
/// <summary>Clip length in seconds.</summary>
private double _length;
/// <summary>Clip sample rate in Hz (at least 1), used to convert between time and timeSamples.</summary>
private int _frequency;
/// <param name="voice">Voice that plays the sound.</param>
/// <param name="start">Start of playback (seconds relative to the render start, not before the time it was scheduled).</param>
/// <param name="pitch">Pitch at the start.</param>
/// <param name="loop">Repeats the clip.</param>
/// <param name="endTime">Scheduled end time (seconds relative to the render start), or infinity.</param>
/// <param name="length">Clip length in seconds (replaced by the game clip in <see cref="BindClip"/>).</param>
/// <param name="frequency">Clip sample rate for timeSamples (replaced by the game clip in <see cref="BindClip"/>).</param>
public SourceState(Voice voice, double start, double pitch, bool loop, double endTime, double length, int frequency)
{
Voice = voice;
_start = start;
_anchorTime = start;
_pitch = pitch;
_loop = loop;
_endTime = endTime;
_length = length;
_frequency = Math.Max(1, frequency);
}
/// <summary>Voice of the <see cref="VoiceMixer"/> that plays the sound; changes made while playing are queued on it.</summary>
public Voice Voice { get; }
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
public void BindClip(AudioClip? clip)
{
if (!clip) return;
_length = clip!.length;
_frequency = Math.Max(1, clip.frequency);
}
/// <summary>Scheduled end time (see AudioSource.SetScheduledEndTime), seconds relative to the render start.</summary>
public void SetEndTime(double endTime, double now)
{
if (!IsPlaying(now)) return;
_endTime = endTime;
Voice.SetEndTime(endTime, now);
}
/// <summary>AudioSource.isPlaying at <paramref name="now"/>: not stopped and before both the scheduled end and the natural end.</summary>
public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd());
/// <summary>AudioSource.time at <paramref name="now"/>: clip position in seconds while playing, otherwise 0.</summary>
public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f;
/// <summary>AudioSource.timeSamples at <paramref name="now"/>: floor(position * frequency) while playing, otherwise the stored offset.</summary>
public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples;
/// <summary>AudioSource.pitch: the last finite value set.</summary>
public float GetPitch() => (float)_pitch;
/// <summary>
/// AudioSource.time setter: stores floor(seconds * frequency) as the offset and, while playing, moves the position to
/// <paramref name="seconds"/> (negative values become 0) from <paramref name="now"/> (or from the start when not started yet).
/// </summary>
public void SetTime(float seconds, double now)
{
_offsetSamples = (int)Math.Floor((double)seconds * _frequency);
Seek(seconds, now);
}
/// <summary>AudioSource.timeSamples setter: as <see cref="SetTime"/> with samples / frequency seconds.</summary>
public void SetTimeSamples(int samples, double now)
{
_offsetSamples = samples;
Seek((double)samples / _frequency, now);
}
/// <summary>
/// AudioSource.pitch setter: ignores NaN and infinity. While playing, the position up to <paramref name="now"/> is kept
/// (new anchor) and the voice changes rate at <paramref name="now"/>; otherwise only the reported value changes.
/// </summary>
public void SetPitch(float pitch, double now)
{
if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return;
var playing = IsPlaying(now);
if (playing) Reanchor(now);
_pitch = pitch;
if (playing) Voice.SetPitch(pitch, now);
}
/// <summary>AudioSource.Stop(): stops the voice at <paramref name="now"/> when playing, marks the state stopped and clears the offset.</summary>
public void Stop(double now)
{
if (IsPlaying(now)) Voice.Stop(now);
_stopped = true;
_offsetSamples = 0;
}
/// <summary>Rate used for playback: <see cref="_pitch"/> with negative values treated as 0 (as in <see cref="Voice.SetPitch"/>).</summary>
private double PlaybackPitch => Math.Max(0.0, _pitch);
/// <summary>
/// While playing, sets the clip position to <paramref name="seconds"/> (at least 0) from max(now, start) and queues the same
/// position change on the voice.
/// </summary>
private void Seek(double seconds, double now)
{
if (!IsPlaying(now)) return;
var position = Math.Max(0.0, seconds);
_anchorTime = Math.Max(now, _start);
_anchorPosition = position;
Voice.SetPosition(position, now);
}
/// <summary>Moves the anchor to max(now, start), keeping the current position, before a pitch change.</summary>
private void Reanchor(double now)
{
var time = Math.Max(now, _start);
_anchorPosition = PositionAt(time);
_anchorTime = time;
}
/// <summary>Clip position at <paramref name="now"/> (the start position before the start), wrapped when looping.</summary>
private double PositionAt(double now)
{
var position = _anchorPosition + (Math.Max(now, _anchorTime) - _anchorTime) * PlaybackPitch;
if (_loop && _length > 0 && position >= _length) position %= _length;
return position;
}
/// <summary>Time at which the clip position reaches the clip length (infinity when looping or at pitch 0).</summary>
private double NaturalEnd()
{
if (_loop || PlaybackPitch <= 0) return double.PositiveInfinity;
return _anchorTime + Math.Max(0.0, _length - _anchorPosition) / PlaybackPitch;
}
}
+17
View File
@@ -6,9 +6,16 @@ using UnityEngine;
namespace ADOFAIRenderer.Util;
/// <summary>
/// Loads the Unity asset bundles (fonts, shaders) embedded in the mod DLL as manifest resources. One bundle set is
/// embedded per platform (Bundle/Win64, Bundle/OSX, Bundle/Linux).
/// Main thread (AssetBundle loading).
/// </summary>
internal static class BundleLoader
{
/// <summary>Resource folder of the running platform: "Win64" on Windows, "OSX" on macOS, "Linux" otherwise.</summary>
private static readonly string BundlePlatform;
/// <summary>The mod assembly that contains the bundle resources.</summary>
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
@@ -22,6 +29,16 @@ internal static class BundleLoader
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
/// <summary>
/// Loads the bundle stored as manifest resource "ADOFAIRenderer.Bundle.&lt;platform&gt;.&lt;resourcePath&gt;" with
/// AssetBundle.LoadFromStream. The resource stream is disposed when this method returns. The caller owns the returned
/// bundle.
/// Reference: https://docs.unity3d.com/ScriptReference/AssetBundle.LoadFromStream.html
/// </summary>
/// <param name="resourcePath">Bundle name inside the platform folder, for example "fonts" or "shaders".</param>
/// <returns>The loaded bundle, or null when Unity fails to load it.</returns>
/// <exception cref="MissingManifestResourceException">The resource is not embedded in the DLL.</exception>
/// <exception cref="InvalidOperationException">The executing assembly is not available.</exception>
public static AssetBundle GetBundle(string resourcePath)
{
if (ExecutingAssembly != null)
+229 -28
View File
@@ -10,9 +10,19 @@ namespace ADOFAIRenderer.Util.FFmpeg;
/// <summary>
/// High-performance video decoder that directly invokes FFmpeg native C libraries via P/Invoke.
/// Supports both GPU hardware-accelerated decoding (D3D11VA, DXVA2, QSV, CUDA, D3D12VA) and
/// Supports both GPU hardware-accelerated decoding (device types of <see cref="PreferredHwDeviceTypes"/>: D3D11VA, DXVA2,
/// CUDA, D3D12VA on Windows; VAAPI, CUDA or VideoToolbox on other platforms; others the decoder reports) and
/// CPU multi-threaded software decoding (FF_THREAD_FRAME) with automatic fallback.
///
/// Used by VideoBGPlayer to decode the background video of a level. Only the first video stream is decoded;
/// audio and other streams are discarded by the demuxer.
///
/// Threading: every public member takes <c>_lock</c>, so calls from different threads are serialized.
/// Width, Height, FPS, Duration and TotalFrames are set once in the constructor and read without the lock.
///
/// Timestamps returned by the TryRead* methods are the presentation times of the stream in seconds
/// (PTS × stream time base), so a stream with a non-zero start time returns timestamps starting at that time.
///
/// ── Architecture &amp; Lifecycle Flow ──────────────────────────────────────────────
///
/// 1. Construction &amp; Demuxing:
@@ -42,6 +52,7 @@ namespace ADOFAIRenderer.Util.FFmpeg;
/// - TryReadFrame: sws_scale conversion to RGBA32 with optional vertical flip (vflip).
///
/// 4. Seeking &amp; Scrubbing (SeekToTime / SeekToFrame):
/// (Short forward distances skip a) and b) and only decode forward; see SeekToTime.)
/// a) av_seek_frame: Fast backward keyframe seek.
/// b) avcodec_flush_buffers: Clear internal decoder state.
/// c) Forward decoding loop: Discards non-target frames without GPU transfer overhead;
@@ -72,7 +83,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// </summary>
private record struct HwConfigCandidate
{
/// <summary>Hardware device type to create with av_hwdevice_ctx_create (AVCodecHWConfig.device_type).</summary>
public AVHWDeviceType DeviceType;
/// <summary>Hardware surface pixel format the decoder outputs with this device (AVCodecHWConfig.pix_fmt, e.g. AV_PIX_FMT_D3D11).</summary>
public AVPixelFormat PixelFormat;
}
@@ -80,9 +94,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Preferred hardware device types in priority order.
/// The candidates are the hardware configurations the selected decoder reports (avcodec_get_hw_config),
/// sorted by this list; configurations not in the list follow in reported order.
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Other platforms: VAAPI or VideoToolbox.
/// Windows: D3D11VA, then DXVA2 (DirectX 9), CUDA and D3D12VA. Linux: CUDA (NVIDIA), then VAAPI. macOS: VideoToolbox.
/// A device type that cannot be created on the machine (no driver, wrong platform) fails creation and is skipped.
/// The QSV entry matches only decoders that report a QSV configuration. The native decoders returned by
/// avcodec_find_decoder report DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan in the bundled FFmpeg 8 build;
/// avcodec_find_decoder report hwaccel device types such as DXVA2/D3D11VA/D3D12VA/CUDA/VAAPI/Vulkan (depending on the build);
/// the QSV decoders (h264_qsv, etc.) are separate decoders selected by name.
/// </summary>
private static readonly AVHWDeviceType[] PreferredHwDeviceTypes = new[]
@@ -116,6 +131,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reference-counted context for the GPU hardware device (D3D11, DXVA2, QSV, CUDA, etc.).
/// This class owns one reference; the codec context holds a second one (hw_device_ctx).
/// Both are released in Dispose (or when a hardware candidate fails). Null in software decoding.
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVHWDeviceContext.html
/// </summary>
private AVBufferRef* _hwDeviceCtx = null;
@@ -137,6 +154,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Frame structure holding converted pixel data produced by <c>sws_scale</c> (e.g., RGBA or fallback YUV).
/// Owns no pixel memory: during one conversion its data[]/linesize[] point to the caller's output buffers,
/// and they are cleared before and after (ClearConvertedFramePointers).
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVFrame.html
/// </summary>
private AVFrame* _pConvertedFrame = null;
@@ -147,6 +166,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVFrame.html
/// </summary>
private AVFrame* _pCachedFrame = null;
/// <summary>
/// Whether <see cref="_pCachedFrame"/> holds a frame that the next <c>TryRead*</c> call returns
/// before any new frame is decoded. Set by DecodeToTarget, cleared when the frame is consumed or dropped.
/// </summary>
private bool _hasCachedFrame;
/// <summary>
@@ -172,7 +196,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// </summary>
private AVCodecContext_get_format? _getFormatDelegate;
/// <summary>Whether hardware-accelerated decoding is currently active.</summary>
/// <summary>
/// Whether hardware-accelerated decoding is currently active.
/// Set when a hardware candidate is configured; cleared when it fails, or by GetFormat when the stream
/// cannot be decoded on the hardware surface format (the decoder then outputs software frames).
/// </summary>
private bool _isHwDecoding;
/// <summary>Hardware device type in use (e.g. D3D11VA, DXVA2, CUDA, QSV).</summary>
@@ -183,6 +211,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Pixel format of the frame after downloading from GPU surface to system RAM (typically NV12).
/// Assumed NV12 when hardware decoding is configured, then updated from every transferred frame
/// (e.g. P010 for 10-bit streams). AV_PIX_FMT_NONE in software decoding.
/// </summary>
private AVPixelFormat _downloadedPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
@@ -190,11 +220,28 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Playback, Stream &amp; Scaler Synchronization Fields
// ──────────────────────────────────────────────────────────────
/// <summary>Index of the decoded video stream in the format context. -1 until a video stream is found.</summary>
private int _videoStreamIndex = -1;
/// <summary>
/// Index of the frame most recently returned (or skipped while seeking): round(timestamp × FPS) for frames with a timestamp,
/// the previous value + 1 for frames without one. 0 after opening.
/// </summary>
private long _currentFrameIndex;
/// <summary>Time base of the video stream (seconds per PTS unit). Used to convert PTS to seconds and seek targets to PTS.</summary>
private AVRational _timeBase;
/// <summary>Whether Open completed. The read and seek methods do nothing until then and after Dispose.</summary>
private bool _isOpened;
/// <summary>Whether Dispose has run. Prevents duplicate cleanup.</summary>
private bool _disposed;
/// <summary>
/// Whether the end of the input has been reached and the decoder was put into draining mode
/// (avcodec_send_packet with null). Reset by a keyframe seek (after avcodec_flush_buffers).
/// </summary>
private bool _isDraining;
/// <summary>Number of video packets skipped because the decoder rejected them as invalid data.</summary>
@@ -203,12 +250,21 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>Number of skipped packets for which a warning is logged.</summary>
private const int MaxLoggedSkippedPackets = 5;
/// <summary>Lock object serializing all public members (decoder state and native resources).</summary>
private readonly object _lock = new();
// Fields to detect SwsContext updates
// Parameters the current _pSwsContext was created with; a frame with different values recreates the context.
/// <summary>Source pixel format of the current scaler context.</summary>
private AVPixelFormat _srcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
/// <summary>Destination pixel format of the current scaler context (RGBA, NV12 or YUV420P).</summary>
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
/// <summary>Source width of the current scaler context in pixels (the destination is always Width × Height).</summary>
private int _srcWidth;
/// <summary>Source height of the current scaler context in pixels.</summary>
private int _srcHeight;
/// <summary>YUV→RGB matrix id (<c>SWS_CS_*</c>) applied to the context. 0 when no matrix is set (non-RGBA output).</summary>
@@ -221,22 +277,31 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Public Properties
// ──────────────────────────────────────────────────────────────
/// <summary>Video width in pixels.</summary>
/// <summary>
/// Video width in pixels (codec context width after opening, or the stream parameters).
/// Every output of the TryRead* methods has this width; frames of another size are scaled to it.
/// </summary>
public int Width { get; private set; }
/// <summary>Video height in pixels.</summary>
/// <summary>Video height in pixels. Every output of the TryRead* methods has this height.</summary>
public int Height { get; private set; }
/// <summary>Frames per second.</summary>
/// <summary>
/// Frames per second: r_frame_rate of the stream, else avg_frame_rate, else 30.
/// For variable frame rate streams both values are approximations.
/// </summary>
public double FPS { get; private set; }
/// <summary>Total duration in seconds.</summary>
/// <summary>Total duration in seconds (stream duration, else container duration, else 0).</summary>
public double Duration { get; private set; }
/// <summary>Estimated total number of frames.</summary>
/// <summary>
/// Estimated total number of frames: the frame count of the stream header (nb_frames) when present,
/// otherwise round(duration × FPS); 0 when the duration is unknown.
/// </summary>
public long TotalFrames { get; private set; }
/// <summary>Index of the currently read frame.</summary>
/// <summary>Index of the currently read frame (the frame most recently returned or skipped; see _currentFrameIndex).</summary>
public long CurrentFrameIndex
{
get
@@ -248,7 +313,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
}
/// <summary>Current playback time in seconds.</summary>
/// <summary>Current playback time in seconds: CurrentFrameIndex / FPS (0 when FPS is not positive).</summary>
public double CurrentTime
{
get
@@ -266,6 +331,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// in system RAM (typically <see cref="AVPixelFormat.AV_PIX_FMT_NV12"/>) rather than the GPU surface format
/// (e.g., D3D11VA, DXVA2, CUDA), allowing seamless binding to Compute Shader NV12 pipelines.
/// In software mode, returns the codec context's pixel format (e.g., YUV420P).
/// Before the first hardware frame is transferred, NV12 is returned as the expected download format;
/// the actual format (e.g. P010 for 10-bit video) is known after the first transfer.
/// The TryRead* methods convert any decoded format to their output format, so this value only selects the fastest path.
/// </summary>
public AVPixelFormat PixelFormat
{
@@ -287,6 +355,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Color range of the video stream (MPEG limited [16-235] or JPEG full [0-255]).
/// Taken from the codec context, else from the stream parameters; AVCOL_RANGE_UNSPECIFIED when neither carries a tag.
/// This property reads the color_range tags only; it does not inspect the pixel format (YUVJ* formats are full range).
/// </summary>
public AVColorRange ColorRange
{
@@ -394,10 +464,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Initializes a new instance of <see cref="FFmpegNativeDecoder"/> and opens the specified video file.
/// Automatically attempts hardware acceleration (D3D11VA, DXVA2, QSV, CUDA), falling back
/// to multi-threaded software decoding if unavailable.
/// Automatically attempts hardware acceleration (device types reported by the decoder, in the order of
/// <see cref="PreferredHwDeviceTypes"/>), falling back to multi-threaded software decoding if unavailable.
/// </summary>
/// <param name="videoPath">Absolute or relative file path to the video file.</param>
/// <exception cref="Exception">
/// The file cannot be opened, has no video stream, or its codec has no decoder or cannot be opened.
/// All native resources allocated so far are released before the exception propagates.
/// </exception>
public FFmpegNativeDecoder(string videoPath)
{
Open(videoPath);
@@ -425,10 +499,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// 2. Retrieve stream information
// Reads (and buffers) the start of the file to fill in codec parameters, frame rates and durations.
if (ffmpeg.avformat_find_stream_info(_pFormatContext, null) < 0)
throw new Exception("Failed to retrieve stream info.");
// 3. Find primary video stream
// 3. Find primary video stream (the first stream whose codec type is video)
_videoStreamIndex = -1;
for (int i = 0; i < _pFormatContext->nb_streams; i++)
{
@@ -456,6 +531,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_timeBase = stream->time_base;
// 4. Calculate FPS
// r_frame_rate: lowest frame rate that represents all timestamps accurately (guessed by libavformat).
// avg_frame_rate: average frame rate. Both are approximations for variable frame rate streams.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVStream.html
if (stream->r_frame_rate.num > 0 && stream->r_frame_rate.den > 0)
{
FPS = ffmpeg.av_q2d(stream->r_frame_rate);
@@ -470,6 +548,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// 5. Find decoder by codec ID
// avcodec_find_decoder returns the default decoder registered for the ID (e.g. libdav1d for AV1
// when the build includes it, which reports no hardware configuration).
var codecId = stream->codecpar->codec_id;
var decoder = ffmpeg.avcodec_find_decoder(codecId);
if (decoder == null)
@@ -480,6 +560,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_pCodecContext == null)
throw new Exception("Failed to allocate codec context.");
// Copies the stream parameters (size, pixel format, extradata, color tags, ...) into the codec context.
if (ffmpeg.avcodec_parameters_to_context(_pCodecContext, stream->codecpar) < 0)
throw new Exception("Failed to copy codec parameters.");
@@ -492,6 +573,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_downloadedPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
// Multi-threading settings for improved software decoding performance (auto thread count)
// thread_type = FF_THREAD_FRAME selects frame threading only (several frames decoded in parallel,
// adds one frame of delay per thread); slice threading is not enabled.
// TryInitializeHwDecoder leaves a fresh codec context when every hardware candidate fails.
// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
_pCodecContext->thread_count = 0;
_pCodecContext->thread_type = ffmpeg.FF_THREAD_FRAME;
@@ -505,6 +589,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
Height = _pCodecContext->height > 0 ? _pCodecContext->height : stream->codecpar->height;
// 8. Calculate total frames and duration
// nb_frames is the frame count stored in the container (0 when unknown);
// otherwise the count is estimated from the stream or container duration and FPS.
if (stream->nb_frames > 0)
{
TotalFrames = stream->nb_frames;
@@ -531,6 +617,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
: _pFormatContext->duration > 0 ? _pFormatContext->duration / (double)ffmpeg.AV_TIME_BASE : 0;
// 9. Allocate frame and packet buffers
// Structures only; pixel buffers are attached by the decoder, av_hwframe_transfer_data or av_frame_move_ref.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__frame.html
_pDecodedFrame = ffmpeg.av_frame_alloc();
_pHwTransferFrame = ffmpeg.av_frame_alloc();
@@ -552,8 +639,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Searches for supported hardware device configurations via <c>avcodec_get_hw_config</c>,
/// creates the appropriate hardware device context (D3D11VA, DXVA2, QSV, CUDA),
/// creates the appropriate hardware device context (D3D11VA, DXVA2, QSV, CUDA, VAAPI, ...),
/// registers the <c>get_format</c> callback, and attempts to open the hardware decoder.
///
/// Candidates are tried in the order of <see cref="PreferredHwDeviceTypes"/>, then the remaining reported order.
/// When a candidate fails to open, its codec context and device are freed and a fresh codec context
/// (with the stream parameters) is allocated for the next candidate or for the software fallback.
/// On success, <see cref="_hwDeviceCtx"/>, <see cref="_hwPixFmt"/> and <see cref="_hwDeviceType"/> describe the device
/// and the codec context is open.
/// </summary>
/// <param name="decoder">Target decoder.</param>
/// <param name="stream">Source video stream.</param>
@@ -562,6 +655,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
{
// ReSharper disable once InconsistentNaming
// Collect all supported hardware configs from decoder
// AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX (0x01): the decoder supports the hardware format through hw_device_ctx,
// which has to be set on the codec context before avcodec_open2. Only these configurations are used here.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/codec.h
const int AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX = 0x01;
var candidates = new List<HwConfigCandidate>();
for (int i = 0; ; i++)
@@ -616,6 +712,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html
if (candidate.DeviceType == AVHWDeviceType.AV_HWDEVICE_TYPE_QSV)
{
// QSV: first with an explicit child device (D3D11VA on Windows, VAAPI elsewhere),
// then without options (FFmpeg selects the child device type).
var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
string childDeviceName = isWindows ? "d3d11va" : "vaapi";
AVDictionary* opts = null;
@@ -631,6 +729,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Default device of the type (device = null: the first / default adapter chosen by FFmpeg).
err = ffmpeg.av_hwdevice_ctx_create(&hwDeviceCtx, candidate.DeviceType, null, null, 0);
}
@@ -646,6 +745,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Assign hardware context and format selection callback
// _hwDeviceCtx takes over the reference created above; the codec context receives its own reference.
_hwDeviceCtx = hwDeviceCtx;
_hwPixFmt = candidate.PixelFormat;
_hwDeviceType = candidate.DeviceType;
@@ -663,6 +763,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
continue;
}
// The delegate is kept in a field so that it is not collected while native code can still call it
// (FFmpeg.AutoGen marshals it to a function pointer). With thread_count = 1 it is called on the calling
// thread inside avcodec_send_packet / avcodec_receive_frame, which run under _lock.
_getFormatDelegate = GetFormat;
_pCodecContext->get_format = _getFormatDelegate;
@@ -681,7 +784,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
return true;
}
// Failed to open codec with this hardware configuration, clean up and try next
// Failed to open codec with this hardware configuration, clean up and try next.
// Freeing the codec context releases its hw_device_ctx reference; _hwDeviceCtx holds the last one.
Melon<RenderMod>.Logger.Warning($"[FFmpegNativeDecoder] avcodec_open2 failed with HW device {candidate.DeviceType}: {GetFFmpegErrorString(openRet)}. Retrying next candidate...");
if (_pCodecContext != null)
@@ -717,8 +821,14 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Callback invoked by libavcodec during decoding to negotiate the output pixel format.
/// Confirms the hardware surface format (<see cref="_hwPixFmt"/>) if offered by the decoder,
/// or falls back to software pixel formats via <c>avcodec_default_get_format</c>.
/// Called at the start of decoding and again when stream parameters change (e.g. resolution).
/// The fallback clears <see cref="_isHwDecoding"/>; the decoder then outputs frames in system memory
/// with the thread count set for hardware decoding (1).
/// Reference: https://ffmpeg.org/doxygen/trunk/structAVCodecContext.html
/// </summary>
/// <param name="ctx">Codec context negotiating the format.</param>
/// <param name="pixFmts">Formats the decoder can output for the stream, terminated by AV_PIX_FMT_NONE (hardware formats first).</param>
/// <returns>The selected pixel format.</returns>
private AVPixelFormat GetFormat(AVCodecContext* ctx, AVPixelFormat* pixFmts)
{
for (var p = pixFmts; *p != AVPixelFormat.AV_PIX_FMT_NONE; p++)
@@ -744,9 +854,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// then sends it to the decoder via <c>avcodec_send_packet</c>.
/// A packet that the decoder rejects as invalid data (<c>AVERROR_INVALIDDATA</c>, e.g. a damaged packet)
/// is skipped and the next packet is read. Any other decoder error is thrown.
/// Packets of other streams are discarded (they are normally not returned, see AVDISCARD_ALL in Open).
/// Called only after avcodec_receive_frame returned EAGAIN, so avcodec_send_packet accepts the packet.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
/// </summary>
/// <returns>True if a video packet was sent; false on EOF.</returns>
/// <returns>True if a video packet was sent; false on EOF or on a read error (both end the stream).</returns>
private bool ReadAndSendPacket()
{
while (true)
@@ -757,6 +869,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
if (_pPacket->stream_index == _videoStreamIndex)
{
// avcodec_send_packet takes its own reference to (or copy of) the packet data,
// so the packet is unreferenced right away for the next av_read_frame.
ret = ffmpeg.avcodec_send_packet(_pCodecContext, _pPacket);
ffmpeg.av_packet_unref(_pPacket);
@@ -785,9 +899,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// If the frame is decoded on a GPU hardware surface (D3D11VA, DXVA2, CUDA, QSV),
/// it is automatically downloaded to system RAM via <c>av_hwframe_transfer_data</c> into <c>_pHwTransferFrame</c>,
/// preserving presentation timestamps via <c>av_frame_copy_props</c>.
///
/// Decode loop: avcodec_receive_frame → on EAGAIN read and send the next packet → retry.
/// When the input ends, a null packet switches the decoder to draining mode; the remaining delayed frames
/// are returned, then avcodec_receive_frame returns AVERROR_EOF.
/// The returned frame (<see cref="_pDecodedFrame"/> or <see cref="_pHwTransferFrame"/>) holds its data until
/// the caller unreferences it (av_frame_unref) or the next frame is received.
/// </summary>
/// <param name="outFrame">The software frame containing pixel data in system RAM.</param>
/// <returns>True if a frame was successfully decoded and available; false on EOF or draining.</returns>
/// <exception cref="Exception">Decoding, packet sending or the GPU-to-host transfer fails.</exception>
private bool TryReceiveFrame(out AVFrame* outFrame)
{
outFrame = null;
@@ -800,6 +921,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// Check if the received frame is on a hardware surface
if (_isHwDecoding && _pDecodedFrame->format == (int)_hwPixFmt)
{
// av_hwframe_transfer_data with an empty destination frame allocates a system memory buffer
// in the first format the device supports for download (NV12 for 8-bit 4:2:0 on the common APIs).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__hwcontext.html (av_hwframe_transfer_data)
ffmpeg.av_frame_unref(_pHwTransferFrame);
int transferRet = ffmpeg.av_hwframe_transfer_data(_pHwTransferFrame, _pDecodedFrame, 0);
if (transferRet < 0)
@@ -809,6 +933,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Copy presentation timestamp and metadata properties
// (pts, best_effort_timestamp, color tags; av_hwframe_transfer_data copies pixel data only).
// The hardware surface is then released back to the decoder's pool.
ffmpeg.av_frame_copy_props(_pHwTransferFrame, _pDecodedFrame);
ffmpeg.av_frame_unref(_pDecodedFrame);
@@ -822,6 +948,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
return true;
}
// EAGAIN: the decoder needs more input. Feed the next video packet, or start draining at the end of the input.
// The return value of the flush call (null packet) is not checked; a failure shows up as an error or EOF
// from the next avcodec_receive_frame.
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) return false;
@@ -832,6 +961,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
ffmpeg.avcodec_send_packet(_pCodecContext, null);
}
}
// EOF: every delayed frame has been returned after draining.
else if (ret == ffmpeg.AVERROR_EOF)
{
return false;
@@ -886,8 +1016,11 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Lazily initializes or updates the <see cref="SwsContext"/> when source/destination formats, dimensions,
/// or (for RGBA output) the colorspace / range of the source frame change.
/// For RGBA output the YUV→RGB matrix and the source range are taken from <paramref name="colorFrame"/>.
/// For NV12/YUV420P output no matrix is set; libswscale converts between the formats with its default range handling.
/// The destination size is always <see cref="Width"/> × <see cref="Height"/>; SWS_BILINEAR filters scaling and chroma resampling.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__libsws.html
/// </summary>
/// <exception cref="Exception">sws_getContext cannot create a context for the conversion.</exception>
/// <param name="srcFormat">Pixel format of the source frame.</param>
/// <param name="srcWidth">Width of the source frame in pixels.</param>
/// <param name="srcHeight">Height of the source frame in pixels.</param>
@@ -933,6 +1066,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
throw new Exception($"Failed to initialize SwsContext (src: {srcFormat} {srcWidth}x{srcHeight} -> dst: {dstFormat} {Width}x{Height}).");
}
// A new context uses the BT.601 matrix and the range implied by the source pixel format (full for YUVJ*,
// limited otherwise); set the frame's matrix and range for YUV → RGBA.
if (swsColorspace != 0)
{
ApplySwsColorspace(swsColorspace, fullRange);
@@ -952,13 +1087,15 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
int* currentTable;
int srcRange, dstRange, brightness, contrast, saturation;
// Returns a negative value when the context cannot change its colorspace details (e.g. YUV to YUV conversion).
// A negative return value means the details cannot be read; the context then keeps its default matrix and range.
if (ffmpeg.sws_getColorspaceDetails(_pSwsContext, &currentInvTable, &srcRange, &currentTable, &dstRange,
&brightness, &contrast, &saturation) < 0)
{
return;
}
// inv_table: coefficients of the source YUV space (used for YUV → RGB); table: coefficients of the destination.
// A failure of sws_setColorspaceDetails leaves the defaults of the context and is not reported.
var coefficients = ffmpeg.sws_getCoefficients(swsColorspace);
var invTable = new int_array4();
var table = new int_array4();
@@ -975,6 +1112,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Computes presentation timestamp (PTS) in seconds from frame metadata without modifying decoder state.
/// Used by seeking to inspect candidate frames without falsely incrementing frame indices.
/// Uses best_effort_timestamp (libavcodec's estimate from PTS/DTS), else pts, converted with the stream time base.
/// A frame without either timestamp gets <see cref="_currentFrameIndex"/> / FPS.
/// </summary>
private double GetFrameTimestamp(AVFrame* frame)
{
@@ -994,6 +1133,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Computes playback presentation timestamp (PTS) in seconds from frame metadata and updates the frame counter.
/// With a timestamp, <see cref="_currentFrameIndex"/> = round(timestamp × FPS); without one, it is incremented by 1.
/// </summary>
private double GetTimestampAndUpdateIndex(AVFrame* frame)
{
@@ -1016,6 +1156,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Resets all data plane pointers and strides in <see cref="_pConvertedFrame"/> to zero.
/// Prevents stale or dangling pointers from previous conversions being retained across frames.
/// The frame never owns buffers (buf[] stays empty), so av_frame_free in Dispose does not touch caller memory.
/// 8 = AV_NUM_DATA_POINTERS, the size of AVFrame.data / linesize.
/// </summary>
private void ClearConvertedFramePointers()
{
@@ -1034,12 +1176,15 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in 32-bit RGBA format using libswscale.
/// Safely processes both hardware-downloaded frames (NV12) and software-decoded frames (YUV420P).
/// The YUV→RGB matrix and range follow the frame's tags (see InitOrUpdateSwsContext). Frames of another size
/// are scaled to Width × Height. A frame cached by a seek is returned first.
/// </summary>
/// <param name="outBuffer">Pointer to destination memory storing RGBA data.</param>
/// <param name="outBuffer">Pointer to destination memory storing RGBA data (at least outStride × Height bytes).</param>
/// <param name="outStride">Byte stride per row of destination memory (Width * 4).</param>
/// <param name="vflip">Whether to vertically flip the output to match Unity texture coordinates.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
public bool TryReadFrame(byte* outBuffer, int outStride, bool vflip, out double timestamp)
{
timestamp = 0;
@@ -1063,6 +1208,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_RGBA, frame);
// Destination: the caller's buffer. vflip uses a negative stride starting at the last row,
// so sws_scale writes the rows bottom-up (Unity textures have their origin at the bottom-left).
ClearConvertedFramePointers();
if (vflip)
{
@@ -1082,6 +1229,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_pConvertedFrame->data, _pConvertedFrame->linesize
);
// Release the decoded (or cached) frame's buffers; the output is now in the caller's buffer.
ClearConvertedFramePointers();
ffmpeg.av_frame_unref(frame);
return true;
@@ -1090,6 +1238,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in RGBA format to an unmanaged memory pointer.
/// Same as the byte* overload.
/// </summary>
public bool TryReadFrame(IntPtr outBuffer, int outStride, bool vflip, out double timestamp)
{
@@ -1098,7 +1247,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies it in RGBA format to a Unity NativeArray.
/// The rows are tightly packed (stride Width × 4).
/// </summary>
/// <exception cref="ArgumentException">The array is not created or is smaller than Width × Height × 4 bytes.</exception>
public bool TryReadFrame(NativeArray<byte> outBuffer, bool vflip, out double timestamp)
{
if (!outBuffer.IsCreated)
@@ -1119,16 +1270,19 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its Y and interleaved UV channel data separately in NV12 format.
/// When hardware decoding is active (producing NV12 in RAM), CPU color conversion is completely bypassed,
/// enabling maximum memory copy throughput directly into Unity textures for Compute Shader processing.
/// When hardware decoding is active (producing NV12 in RAM), CPU color conversion is completely bypassed:
/// the planes are copied row by row into the caller's buffers (e.g. texture data for Compute Shader processing).
/// Other formats or mismatched dimensions are automatically converted and scaled to NV12 via libswscale.
/// The Y/UV values keep the range of the source (see <see cref="ColorRange"/>); no YUV→RGB conversion happens here.
/// A frame cached by a seek is returned first.
/// </summary>
/// <param name="yBuffer">Pointer to destination buffer for Y plane (Width * Height bytes).</param>
/// <param name="yBuffer">Pointer to destination buffer for Y plane (yStride × Height bytes; Width bytes per row are written).</param>
/// <param name="yStride">Row stride in bytes for Y plane.</param>
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (((Width + 1) / 2) * 2 * ((Height + 1) / 2) bytes).</param>
/// <param name="uvBuffer">Pointer to destination buffer for interleaved UV plane (uvStride × ceil(Height / 2) bytes; ceil(Width / 2) × 2 bytes per row are written).</param>
/// <param name="uvStride">Row stride in bytes for UV plane.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF.</returns>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
byte* yBuffer, int yStride,
@@ -1155,6 +1309,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
// Conversion path: any other format (e.g. YUV420P from software decoding, P010 from 10-bit hardware decoding)
// or a frame size different from Width × Height. sws_scale writes directly into the caller's planes.
if (frame->format != (int)AVPixelFormat.AV_PIX_FMT_NV12 || frame->width != Width || frame->height != Height)
{
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_NV12);
@@ -1175,12 +1331,16 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Direct copy path: NV12 frame of the output size.
// The UV plane has ceil(Height / 2) rows of ceil(Width / 2) U/V byte pairs.
int w = Width;
int h = Height;
int uvH = (h + 1) / 2;
int uvRowBytes = ((w + 1) / 2) * 2;
// Copy Y plane (row by row to safely skip linesize GPU padding)
// Buffer.MemoryCopy(source, destination, destinationSizeInBytes, bytesToCopy): the destination size
// argument is the stride, so a stride smaller than the row width throws instead of overrunning.
for (int y = 0; y < h; y++)
{
Buffer.MemoryCopy(
@@ -1208,6 +1368,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data to unmanaged memory pointers.
/// Same as the byte* overload.
/// </summary>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
@@ -1224,7 +1385,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its NV12 Y and interleaved UV channel data into Unity NativeArrays.
/// The planes are tightly packed: Y stride Width, UV stride ceil(Width / 2) × 2 with ceil(Height / 2) rows.
/// </summary>
/// <exception cref="ArgumentException">An array is not created or is smaller than its plane.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadNV12Frame(
NativeArray<byte> yBuffer,
@@ -1261,7 +1424,18 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// Reads the next video frame and copies its planar Y, U, and V channel data separately.
/// For native YUV420P sources, color conversion is bypassed and memory is copied directly.
/// Non-YUV420P frames (including NV12 from HW decoding) or mismatched dimensions are converted via libswscale.
/// YUVJ420P (full range YUV420P) is copied as well; the values keep the source range (see <see cref="ColorRange"/>).
/// A frame cached by a seek is returned first.
/// </summary>
/// <param name="yBuffer">Destination of the Y plane (yStride × Height bytes; Width bytes per row are written).</param>
/// <param name="yStride">Row stride in bytes of the Y plane.</param>
/// <param name="uBuffer">Destination of the U plane (uStride × ceil(Height / 2) bytes; ceil(Width / 2) bytes per row).</param>
/// <param name="uStride">Row stride in bytes of the U plane.</param>
/// <param name="vBuffer">Destination of the V plane (same size as U).</param>
/// <param name="vStride">Row stride in bytes of the V plane.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if a frame was successfully retrieved; false on EOF, before opening, after disposal or for a null buffer.</returns>
/// <exception cref="Exception">Decoding, transfer or scaler creation fails.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
byte* yBuffer, int yStride,
@@ -1289,6 +1463,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
timestamp = GetTimestampAndUpdateIndex(frame);
// Conversion path: any format other than YUV420P/YUVJ420P, or a frame size different from Width × Height.
if ((frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUV420P && frame->format != (int)AVPixelFormat.AV_PIX_FMT_YUVJ420P) || frame->width != Width || frame->height != Height)
{
InitOrUpdateSwsContext((AVPixelFormat)frame->format, frame->width, frame->height, AVPixelFormat.AV_PIX_FMT_YUV420P);
@@ -1311,6 +1486,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// Direct copy path: each plane row by row (source rows can be padded, linesize ≥ row width).
int w = Width;
int h = Height;
int uvW = (w + 1) / 2;
@@ -1354,6 +1530,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its planar Y, U, and V channel data to unmanaged memory pointers.
/// Same as the byte* overload.
/// </summary>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
@@ -1372,7 +1549,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Reads the next video frame and copies its planar Y, U, and V channel data into Unity NativeArrays.
/// The planes are tightly packed: Y stride Width, U/V stride ceil(Width / 2) with ceil(Height / 2) rows.
/// </summary>
/// <exception cref="ArgumentException">An array is not created or is smaller than its plane.</exception>
// ReSharper disable once InconsistentNaming
public bool TryReadYUV420PFrame(
NativeArray<byte> yBuffer,
@@ -1419,8 +1598,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// for the next <c>TryRead*</c> call.
/// Frames before the target are discarded without GPU-to-host memory transfer; only the target frame is transferred.
/// When the end of the stream is reached before the target, no frame is cached.
/// Every discarded frame updates <see cref="_currentFrameIndex"/>; the cached target frame updates it when it is read.
/// </summary>
/// <param name="timeSeconds">Target time in seconds.</param>
/// <exception cref="Exception">Decoding or the GPU-to-host transfer of the target frame fails.</exception>
private void DecodeToTarget(double timeSeconds)
{
while (true)
@@ -1451,6 +1632,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
else
{
// System memory frame: move the buffer references into the cache without copying
// (_pDecodedFrame is left blank for the next receive).
ffmpeg.av_frame_unref(_pCachedFrame);
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
}
@@ -1465,6 +1648,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
continue;
}
// Same input feeding and draining as TryReceiveFrame.
if (ret == ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
if (_isDraining) return;
@@ -1496,8 +1680,12 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// - Keyframe seek: all other cases (target before the current position, far ahead, or the target frame already read).
/// A backward keyframe seek is followed by a forward decode up to the target.
/// The target frame is transferred and preserved in <see cref="_pCachedFrame"/>; when it is already there, nothing is decoded.
/// Negative times are treated as 0. The time is in the timeline of the stream timestamps (a stream that starts at
/// a non-zero time has no frame before its start; the first frame is returned for earlier targets).
/// When the target is past the last frame, no frame is cached and the next TryRead* call returns false.
/// </summary>
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
/// <exception cref="Exception">Seeking (including the fallback seek to the start) or decoding fails.</exception>
public void SeekToTime(double timeSeconds)
{
lock (_lock)
@@ -1546,6 +1734,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
_hasCachedFrame = false;
}
// Keyframe seek. With a stream index, the timestamp is in the stream time base.
// AVSEEK_FLAG_BACKWARD: seek to the nearest keyframe at or before the target, so that decoding forward
// from there reaches the target frame.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_seek_frame)
long targetPts = (long)Math.Round(timeSeconds / ffmpeg.av_q2d(_timeBase));
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
@@ -1559,6 +1751,7 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
// Flush decoder internal buffers
// Drops the frames buffered from before the seek and ends draining mode, so new packets are accepted again.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__encdec.html
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
@@ -1569,9 +1762,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
/// <summary>
/// Seeks to a specific frame index.
/// Seeks to a specific frame index: <see cref="SeekToTime"/> with frameIndex / FPS seconds.
/// </summary>
/// <param name="frameIndex">Target frame index.</param>
/// <param name="frameIndex">Target frame index (0-based, in units of 1 / FPS).</param>
public void SeekToFrame(long frameIndex)
{
SeekToTime(frameIndex / FPS);
@@ -1582,7 +1775,8 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
// ──────────────────────────────────────────────────────────────
/// <summary>
/// Translates an FFmpeg integer error code to a readable string using <c>av_strerror</c>.
/// Translates an FFmpeg integer error code to a readable string using <c>av_strerror</c>
/// (1024-byte stack buffer; "Unknown error (Code: n)" when there is no description).
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavu__error.html
/// </summary>
private string GetFFmpegErrorString(int errnum)
@@ -1598,6 +1792,10 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
/// <summary>
/// Releases all native FFmpeg decoder resources, hardware contexts, frame buffers, and scaler structures.
/// Safe to call more than once; also called by the constructor on failure and by the finalizer.
/// Order: scaler → frames → packet → codec context (releases its hw_device_ctx reference)
/// → this object's device reference → input file and format context.
/// After Dispose, the read and seek methods return without doing anything.
/// </summary>
public void Dispose()
{
@@ -1681,6 +1879,9 @@ internal sealed unsafe class FFmpegNativeDecoder : IDisposable
}
}
/// <summary>
/// Finalizer. Releases the native resources when the decoder was not disposed (runs on the GC finalizer thread).
/// </summary>
~FFmpegNativeDecoder()
{
Dispose();
File diff suppressed because it is too large. Load diff
+77 -1
View File
@@ -6,52 +6,107 @@ using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util.FFmpeg;
/// <summary>
/// Family of a video encoder. Selects the encoder-specific configuration in <see cref="FFmpegNativeEncoder"/>
/// (input pixel format, hardware device setup, quality option names, speed preset).
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum EncoderKind
{
/// <summary>CPU encoder library linked into FFmpeg (libx264, libx265, libvpx-vp9, libsvtav1, libaom-av1, libvvenc).</summary>
Software,
/// <summary>NVIDIA NVENC hardware encoder (*_nvenc).</summary>
NVENC,
/// <summary>AMD Advanced Media Framework hardware encoder (*_amf).</summary>
AMF,
/// <summary>Intel Quick Sync Video hardware encoder (*_qsv), driven through a QSV hardware device context.</summary>
QSV,
/// <summary>Apple VideoToolbox hardware encoder (*_videotoolbox, macOS).</summary>
VideoToolbox
}
/// <summary>
/// Known FFmpeg video encoder: FFmpeg encoder name, output codec, GPU vendor and encoder family.
///
/// Instances exist only as the static fields below (the constructor is private); compare them with == or
/// <see cref="Equals(VideoEncoder)"/>, which compare <see cref="Name"/> case-insensitively.
/// <see cref="All"/> lists every encoder, and its order is the fallback order used by
/// GPUUtil.GetPrioritizedEncoders after the encoders of the active GPU vendor.
///
/// An entry does not mean that the encoder exists in the loaded FFmpeg build or works on the current machine:
/// FFmpegNativeEncoder.Start looks each one up with avcodec_find_encoder_by_name and opens it, and skips the
/// encoders that are missing or fail to open.
/// Reference: https://ffmpeg.org/ffmpeg-codecs.html (Video Encoders)
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
// Each field: FFmpeg encoder name, output codec, GPU vendor whose hardware runs it
// (Unknown for CPU encoders), encoder family.
// ── H.264 ──
/// <summary>NVIDIA NVENC hardware encoder for H.264 (FFmpeg encoder "h264_nvenc").</summary>
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for H.264 (FFmpeg encoder "h264_qsv").</summary>
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for H.264 (FFmpeg encoder "h264_amf").</summary>
public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>Apple VideoToolbox hardware encoder for H.264 (FFmpeg encoder "h264_videotoolbox").</summary>
public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
/// <summary>CPU software encoder for H.264 (FFmpeg encoder "libx264").</summary>
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
// ── H.265 (HEVC) ──
/// <summary>NVIDIA NVENC hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_nvenc").</summary>
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_qsv").</summary>
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_amf").</summary>
public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>Apple VideoToolbox hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_videotoolbox").</summary>
public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
/// <summary>CPU software encoder for H.265 (HEVC) (FFmpeg encoder "libx265").</summary>
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
// ── VP9 ──
// libavcodec has no "vp9_nvenc" encoder (NVENC does not encode VP9), so avcodec_find_encoder_by_name returns null
// for this entry and FFmpegNativeEncoder.Start skips it with a "not found" warning.
/// <summary>NVIDIA NVENC entry for VP9 (FFmpeg encoder name "vp9_nvenc"); never found in libavcodec, see above.</summary>
public static readonly VideoEncoder VP9_NVENC = new("vp9_nvenc", Codec.VP9, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for VP9 (FFmpeg encoder "vp9_qsv").</summary>
public static readonly VideoEncoder VP9_QSV = new("vp9_qsv", Codec.VP9, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>CPU software encoder for VP9 (FFmpeg encoder "libvpx-vp9").</summary>
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
// ── AV1 ──
/// <summary>NVIDIA NVENC hardware encoder for AV1 (FFmpeg encoder "av1_nvenc").</summary>
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for AV1 (FFmpeg encoder "av1_qsv").</summary>
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for AV1 (FFmpeg encoder "av1_amf").</summary>
public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>CPU software encoder for AV1 (FFmpeg encoder "libsvtav1").</summary>
public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>CPU software encoder for AV1 (FFmpeg encoder "libaom-av1").</summary>
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
// ── VVC ──
/// <summary>CPU software encoder for H.266 (VVC) (FFmpeg encoder "libvvenc").</summary>
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ReSharper restore MemberCanBePrivate.Global
/// <summary>
/// Every known encoder, grouped by codec. Within a codec the order is NVENC → QSV → AMF → VideoToolbox → software
/// (for the families the codec has); GPUUtil.GetPrioritizedEncoders keeps this order for the encoders that do not
/// belong to the active GPU vendor.
/// </summary>
public static readonly IReadOnlyList<VideoEncoder> All = new[]
{
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
@@ -61,15 +116,26 @@ internal sealed class VideoEncoder : IEquatable<VideoEncoder>
LibVvenc
};
/// <summary>Lookup table of <see cref="All"/> by <see cref="Name"/> (case-insensitive), used by <see cref="FromName"/>.</summary>
private static readonly Dictionary<string, VideoEncoder> ByName =
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
/// <summary>FFmpeg encoder name passed to avcodec_find_encoder_by_name (e.g. "h264_nvenc", "libx264").</summary>
public string Name { get; }
/// <summary>Video codec of the bitstream the encoder produces.</summary>
public Codec Codec { get; }
/// <summary>GPU vendor whose hardware runs the encoder. <see cref="GPUVendor.Unknown"/> for CPU (software) encoders.</summary>
public GPUVendor Vendor { get; }
/// <summary>Encoder family (software or hardware API).</summary>
public EncoderKind Kind { get; }
/// <summary>True for every family except <see cref="EncoderKind.Software"/>.</summary>
public bool IsHardware => Kind != EncoderKind.Software;
/// <summary>Creates an entry. Used only by the static fields of this class.</summary>
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
{
Name = name;
@@ -78,36 +144,46 @@ internal sealed class VideoEncoder : IEquatable<VideoEncoder>
Kind = kind;
}
/// <summary>
/// Returns the known encoder with the given FFmpeg name (case-insensitive),
/// or null when <paramref name="name"/> is null, empty or not in <see cref="All"/>.
/// </summary>
public static VideoEncoder? FromName(string name)
{
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
}
/// <summary>True when <paramref name="other"/> is the same entry or has the same <see cref="Name"/> (case-insensitive).</summary>
public bool Equals(VideoEncoder other)
{
if (ReferenceEquals(null, other)) return false;
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
}
/// <summary>True when <paramref name="obj"/> is a <see cref="VideoEncoder"/> equal to this one (see <see cref="Equals(VideoEncoder)"/>).</summary>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
}
/// <summary>Case-insensitive hash of <see cref="Name"/>, consistent with <see cref="Equals(VideoEncoder)"/>.</summary>
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
}
/// <summary>Value equality through <see cref="object.Equals(object, object)"/>; two null operands are equal.</summary>
public static bool operator ==(VideoEncoder left, VideoEncoder right)
{
return Equals(left, right);
}
/// <summary>Negation of the <c>==</c> operator.</summary>
public static bool operator !=(VideoEncoder left, VideoEncoder right)
{
return !Equals(left, right);
}
/// <summary>Returns the FFmpeg encoder name (<see cref="Name"/>).</summary>
public override string ToString() => Name;
}
+7
View File
@@ -2,8 +2,15 @@
namespace ADOFAIRenderer.Util;
/// <summary>File system helpers.</summary>
internal static class FileUtil
{
/// <summary>
/// Creates <paramref name="path"/> and its missing parent directories when it does not exist.
/// Exceptions of Directory.CreateDirectory are not caught (for example ArgumentException for an empty path,
/// IOException or UnauthorizedAccessException when the directory cannot be created).
/// </summary>
/// <param name="path">Directory path, absolute or relative to the current directory.</param>
public static void CreateDirectory(string path)
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+106
View File
@@ -13,25 +13,61 @@ using UnityEngine.Rendering;
namespace ADOFAIRenderer.Util
{
/// <summary>
/// GPU capture of rendered frames for the encoder. For each requested frame it optionally converts the RGBA texture to
/// the encoder's YUV 4:2:0 layout with a compute shader (ColorConversion.compute from the mod's asset bundle, kernels
/// CSMain_NV12 / CSMain_YUV420P), then reads the result back asynchronously (AsyncGPUReadback.RequestIntoNativeArray)
/// into a persistent native buffer and adds it to RenderManager.ImageQueue under its video frame index.
/// When the shader or the kernel for the pixel format is unavailable, the RGBA texture is read back unchanged and the
/// encoder converts it (IsColorConverted = false).
/// <para>
/// Each request uses a <see cref="ReadbackRequestState"/> from a pool (16 created up front, more allocated when the pool
/// is empty). A state stays out of the pool from the request until the render thread has sent its frame
/// (RenderManager.SendVideoFrame, then <see cref="RecycleState"/>) or the frame was dropped.
/// </para>
/// <para>
/// Threads: requests and readback completions run on the main Unity thread; <see cref="RecycleState"/> is also called by
/// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in
/// RenderManager.SetupPlay with the pixel format of the opened encoder.
/// </para>
/// </summary>
internal class FrameReader : IDisposable
{
// Property IDs of the compute shader parameters: source texture, output buffer and frame size in pixels.
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
/// <summary>Number of readback states created by InitializePool.</summary>
private const int InitialBufferSize = 16;
/// <summary>States that are free for a new request. Guarded by _poolLock.</summary>
private readonly Stack<ReadbackRequestState> _statePool = new();
/// <summary>Every state created by this reader (free, in flight or holding a queued frame), for Dispose. Guarded by _poolLock.</summary>
private readonly List<ReadbackRequestState> _allocatedStates = new();
/// <summary>Lock for _statePool, _allocatedStates, _isInitialized, _isDisposed and ReadbackRequestState.IsInFlight.</summary>
private readonly object _poolLock = new();
/// <summary>True after InitializePool; false again after Dispose (RecycleState then disposes returned states).</summary>
private bool _isInitialized;
/// <summary>Set by Dispose. Completions and recycles that arrive afterwards dispose their state.</summary>
private volatile bool _isDisposed;
/// <summary>Color conversion compute shader from the asset bundle (ModAssets.ColorConversionShader); null when not loaded.</summary>
private readonly ComputeShader? _computeShader;
/// <summary>Kernel index for the target pixel format; -1 when the format has no kernel or the kernel was not found.</summary>
private readonly int _kernelIndex = -1;
/// <summary>True when frames are converted to YUV on the GPU (shader and kernel available).</summary>
private readonly bool _useGPUConversion;
/// <summary>Pixel format of the read-back data: the target format with GPU conversion, AV_PIX_FMT_RGBA otherwise.</summary>
private readonly AVPixelFormat _expectedFormat;
/// <summary>
/// Selects the conversion kernel for <paramref name="targetFormat"/> and fills the state pool.
/// </summary>
/// <param name="targetFormat">
/// Pixel format of the encoder input (FFmpegNativeEncoder.SelectedPixelFormat). NV12 and YUV420P use GPU conversion;
/// any other format falls back to RGBA readback.
/// </param>
public FrameReader(AVPixelFormat targetFormat)
{
_computeShader = ModAssets.ColorConversionShader;
@@ -57,6 +93,7 @@ namespace ADOFAIRenderer.Util
InitializePool();
}
/// <summary>Creates InitialBufferSize states once. Their buffers are allocated on first use (ReadbackRequestState.Setup).</summary>
private void InitializePool()
{
lock (_poolLock)
@@ -83,6 +120,15 @@ namespace ADOFAIRenderer.Util
return _useGPUConversion ? pixels * 3 / 2 : pixels * 4;
}
/// <summary>
/// Requests the capture of <paramref name="texture"/> as video frame <paramref name="currentFrame"/>. Does nothing
/// outside the Render stage. Main thread.
/// With GPU conversion, the compute kernel writes the YUV data of the texture into the state's ComputeBuffer and that
/// buffer is read back; otherwise the texture itself is read back as RGBA. The buffers are sized from the texture
/// dimensions of this call. The result arrives in OnReadbackComplete.
/// </summary>
/// <param name="texture">Frame to capture (game frame or the output of the frame hooks).</param>
/// <param name="currentFrame">Video frame index; the key of the frame in ImageQueue.</param>
public void RequestFrameCapture(Texture texture, long currentFrame)
{
if (!RenderManager.IsCapturingActive) return;
@@ -113,6 +159,8 @@ namespace ADOFAIRenderer.Util
_computeShader.SetInt(Width, texture.width);
_computeShader.SetInt(Height, texture.height);
// Thread group counts cover the frame in tiles of 32 x 16 pixels (NV12) or 64 x 16 pixels (YUV420P), rounded up.
// The tile size is the area one thread group of the ColorConversion.compute kernels processes.
var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
@@ -120,6 +168,8 @@ namespace ADOFAIRenderer.Util
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
// The native buffer must not be disposed until the request completes; Dispose skips states that are in flight.
// Reference: https://docs.unity3d.com/ScriptReference/Rendering.AsyncGPUReadback.RequestIntoNativeArray.html
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
);
@@ -136,6 +186,17 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>
/// Completion of a readback request (through ReadbackRequestState.CachedCallback).
/// <list type="bullet">
/// <item>Reader disposed: the state is disposed.</item>
/// <item>Render aborted: the state is returned to the pool and nothing is queued.</item>
/// <item>Readback error: a black frame (managed buffer) is queued under the same index, so the render thread does not
/// wait forever for the missing index, and the state is returned to the pool.</item>
/// <item>Success: the state's native buffer is queued without copying; the state stays out of the pool until the
/// render thread has encoded the frame.</item>
/// </list>
/// </summary>
internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request)
{
var frame = state.FrameIndex;
@@ -227,6 +288,10 @@ namespace ADOFAIRenderer.Util
return frame;
}
/// <summary>
/// Returns a state to the pool after its frame was sent or dropped, or disposes it when the reader is disposed.
/// Called by the render thread (after sending) and by the main Unity thread (abort, errors, queue cleanup).
/// </summary>
internal void RecycleState(ReadbackRequestState state)
{
lock (_poolLock)
@@ -242,6 +307,10 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>
/// Adds the frame to RenderManager.ImageQueue under <paramref name="frame"/> (replacing a frame with the same index)
/// and wakes the render thread.
/// </summary>
private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame)
{
if (!RenderManager.Instance) return;
@@ -252,6 +321,11 @@ namespace ADOFAIRenderer.Util
RenderManager.Instance.FrameAvailableEvent.Set();
}
/// <summary>
/// Disposes every state that is not in flight and clears the pool. States in flight are disposed when their
/// readback completes. States still referenced by frames in ImageQueue are disposed too, so callers clear the queue
/// first (RenderManager.StopFFmpeg / RestoreRenderEnvironment). Main thread.
/// </summary>
public void Dispose()
{
lock (_poolLock)
@@ -269,20 +343,38 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>
/// One reusable capture slot of <see cref="FrameReader"/>: the persistent native buffer the readback writes into, the
/// ComputeBuffer the conversion kernel writes (GPU conversion only), and the metadata of the frame it currently holds.
/// The buffers are kept between frames and reallocated only when the frame size or conversion mode changes.
/// </summary>
internal class ReadbackRequestState : IDisposable
{
/// <summary>Reader that receives the completion and owns the pool.</summary>
private readonly FrameReader _owner;
/// <summary>Video frame index of the current request; -1 while the state is free.</summary>
public long FrameIndex { get; private set; }
/// <summary>
/// Persistent native buffer receiving the readback: Width x Height x 3 / 2 bytes of YUV 4:2:0 with GPU conversion,
/// Width x Height x 4 bytes of RGBA otherwise. Read by the render thread through CapturedFrame.NativePointer.
/// </summary>
public NativeArray<byte> NativeBuffer;
/// <summary>True between the request and its completion. Written under FrameReader._poolLock.</summary>
public bool IsInFlight { get; internal set; }
/// <summary>Raw ComputeBuffer (4-byte elements) the conversion kernel writes; null without GPU conversion.</summary>
public ComputeBuffer? GPUBuffer;
/// <summary>True when the current frame is converted to YUV on the GPU.</summary>
public bool IsGPUConversion { get; private set; }
/// <summary>Width of the current frame in pixels.</summary>
public int Width { get; private set; }
/// <summary>Height of the current frame in pixels.</summary>
public int Height { get; private set; }
/// <summary>Completion delegate created once per state, so a request does not allocate a new delegate.</summary>
public readonly Action<AsyncGPUReadbackRequest> CachedCallback;
/// <summary>Creates a free state without buffers.</summary>
public ReadbackRequestState(FrameReader owner)
{
_owner = owner;
@@ -290,6 +382,17 @@ namespace ADOFAIRenderer.Util
FrameIndex = -1;
}
/// <summary>
/// Prepares the state for a request: stores the frame metadata and (re)allocates the buffers when their size differs.
/// The native buffer holds width x height x 3 / 2 bytes with GPU conversion, otherwise <paramref name="bufferSize"/>.
/// The ComputeBuffer holds the same number of bytes as 4-byte elements (byte count / 4, rounded down).
/// </summary>
/// <param name="frameIndex">Video frame index of the request.</param>
/// <param name="bufferSize">Readback size in bytes for RGBA readback (width x height x 4).</param>
/// <param name="isGPUConversion">Whether the kernel converts the frame to YUV.</param>
/// <param name="pixelFormat">Pixel format of the read-back data (not used by this method).</param>
/// <param name="width">Frame width in pixels.</param>
/// <param name="height">Frame height in pixels.</param>
public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height)
{
FrameIndex = frameIndex;
@@ -320,16 +423,19 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>Readback completion; forwards to FrameReader.OnReadbackComplete.</summary>
private void OnComplete(AsyncGPUReadbackRequest request)
{
_owner.OnReadbackComplete(this, request);
}
/// <summary>Marks the state as free (FrameIndex -1). The buffers are kept for the next request.</summary>
public void Recycle()
{
FrameIndex = -1;
}
/// <summary>Releases the native buffer and the ComputeBuffer.</summary>
public void Dispose()
{
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
+42
View File
@@ -10,27 +10,48 @@ using UnityEngine;
namespace ADOFAIRenderer.Util;
/// <summary>
/// GPU vendor, used to try the hardware encoders of the active GPU first (VideoEncoder.Vendor). <c>Unknown</c> is
/// also the vendor of the software encoders.
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
/// <summary>
/// Detection of the active GPU vendor and the NVIDIA driver version, and the encoder try order derived from the vendor.
/// Results are cached for the game session. Main thread (SystemInfo is read on first use).
/// </summary>
internal static class GPUUtil
{
// Device name patterns (SystemInfo.graphicsDeviceName) of the GPU families recognized per vendor. A name that matches
// none of them gives GPUVendor.Unknown (Apple on macOS), which only affects the encoder try order.
// NVIDIA: "NVIDIA GeForce RTX/GTX <number>" with optional SUPER / Ti / Ti SUPER, memory size, "Laptop GPU" and a
// "/PCIe/SSE2" suffix.
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
// Intel: "Intel(R) UHD/HD Graphics <number>" or "Intel(R) Arc(TM) <A-C><number> Graphics", with an optional
// parenthesized suffix.
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// AMD: "AMD Radeon RX <number>" with an optional " XT".
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
@"^AMD Radeon RX [0-9]+(?> XT)?$";
/// <summary>Vendor found by the first GetActiveGPUVendor call.</summary>
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
/// <summary>True once _cachedVendor is set.</summary>
private static bool _hasCachedVendor;
/// <summary>
/// Vendor of the GPU Unity renders with: the device name is matched against the NVIDIA, Intel and AMD patterns in
/// that order; on macOS an unmatched name gives Apple; otherwise Unknown. Logs the device name once.
/// </summary>
private static GPUVendor GetActiveGPUVendor()
{
if (_hasCachedVendor) return _cachedVendor;
@@ -72,9 +93,21 @@ internal static class GPUUtil
return _cachedVendor;
}
/// <summary>Result of the first GetNvidiaDriverVersion call (null when not available).</summary>
private static double? _cachedNvidiaVersion;
/// <summary>True once _cachedNvidiaVersion is set.</summary>
private static bool _hasCachedNvidiaVersion;
/// <summary>
/// NVIDIA driver version in NVIDIA's numbering (for example 561.09), or null when the active GPU is not detected as
/// NVIDIA or no version can be found. The result is cached.
/// <para>
/// The version is derived from a four-part Windows driver version a.b.c.d as ((c mod 10) x 10000 + d) / 100,
/// for example 31.0.15.6109 gives 561.09. It is read from SystemInfo.graphicsDeviceVersion when that string contains a
/// four-part number, otherwise (Windows only) from the DriverVersion value of the first display adapter entry whose
/// ProviderName contains "NVIDIA" under the display adapter class key of the registry.
/// </para>
/// </summary>
public static double? GetNvidiaDriverVersion()
{
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
@@ -103,6 +136,9 @@ internal static class GPUUtil
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
// Device class key of display adapters (class GUID {4d36e968-e325-11ce-bfc1-08002be10318}); each numbered subkey is
// one installed adapter driver.
// Reference: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/system-defined-device-setup-classes-available-to-vendors
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
if (subkeys == null) return null;
@@ -130,6 +166,12 @@ internal static class GPUUtil
return null;
}
/// <summary>
/// Encoders for <paramref name="codec"/> in the order they are tried: the encoders of the active GPU vendor first,
/// then all others in VideoEncoder.All order (hardware encoders of other vendors and the software encoders).
/// </summary>
/// <param name="codec">Video codec.</param>
/// <param name="forceSoftware">When true, only the software encoders of the codec are returned.</param>
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
{
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
+167 -7
View File
@@ -10,12 +10,49 @@ using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Native "select folder" dialog used to choose the render output directory.
/// <para>
/// - <b>Windows</b>: the shell's <c>IFileOpenDialog</c> (COM, <c>FOS_PICKFOLDERS</c>) on a dedicated STA thread owned by the
/// game window; when it cannot be created or shown, the legacy <c>SHBrowseForFolderW</c> dialog.
/// </para>
/// <para>- <b>macOS</b>: <c>osascript</c> running AppleScript <c>choose folder</c>.</para>
/// <para>
/// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity is not installed or fails (exit code other than 0
/// or 1), <c>kdialog --getexistingdirectory</c>. A cancelled zenity dialog (exit code 1) ends the selection.
/// </para>
/// <para>
/// The dialog runs off the main Unity thread so the game keeps rendering frames while it is open. Only one dialog can be open
/// at a time. Failures are reported to the callback as null (same as cancel); exceptions are logged as warnings, except a
/// zenity start failure, which silently moves on to kdialog.
/// </para>
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
internal static class FolderDialogUtil
{
// 1 while a dialog is open, 0 otherwise. Set with Interlocked.CompareExchange in OpenFolderBrowser and reset by the
// dialog worker before it dispatches the result.
private static int _isDialogOpen;
/// <summary>
/// Opens the folder selection dialog asynchronously and returns immediately.
/// <para>
/// When a dialog is already open, logs a message and returns without opening another one; <paramref name="onSelected"/>
/// is not called in that case. Otherwise the dialog runs on a background thread (Windows: new STA thread
/// "FolderDialog-Win"; macOS/Linux: thread pool task) and <paramref name="onSelected"/> receives the result.
/// </para>
/// <para>
/// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (the main Unity thread has one), the callback
/// is posted to it and runs on the caller's thread; otherwise it runs on the dialog thread. Exceptions thrown by the
/// callback are caught and logged.
/// </para>
/// <para>On platforms other than Windows, macOS and Linux the callback receives null.</para>
/// </summary>
/// <param name="initialDirectory">Folder shown first; ignored when empty or not an existing directory.</param>
/// <param name="onSelected">
/// Receives the absolute path of the selected existing directory, or null when the user cancelled or the dialog failed.
/// </param>
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
{
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
@@ -24,12 +61,18 @@ internal static class FolderDialogUtil
return;
}
// Captured on the calling thread so the result can be posted back to it.
var syncContext = SynchronizationContext.Current;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Resolved on the calling thread: GetActiveWindow only returns a window attached to the calling thread's
// message queue.
var ownerHandle = GetGameWindowHandle();
// Both dialogs run on a single-threaded apartment (STA) thread with OLE initialized; SHBrowseForFolder with
// BIF_NEWDIALOGSTYLE fails in a multithreaded apartment.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow
var thread = new Thread(() =>
{
string? selected = null;
@@ -45,6 +88,8 @@ internal static class FolderDialogUtil
}
finally
{
// OleInitialize returns S_OK or S_FALSE (already initialized) on success; each successful call is
// balanced by one OleUninitialize.
if (oleHr >= 0)
{
try
@@ -98,6 +143,13 @@ internal static class FolderDialogUtil
}
}
/// <summary>
/// Delivers the dialog result: posts <paramref name="onSelected"/> to <paramref name="syncContext"/> when it is not null,
/// otherwise invokes it on the current (dialog) thread. Callback exceptions are caught and logged.
/// </summary>
/// <param name="syncContext">Synchronization context of the thread that opened the dialog, or null.</param>
/// <param name="onSelected">Result callback.</param>
/// <param name="selected">Selected directory path, or null.</param>
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{
if (syncContext != null)
@@ -129,20 +181,32 @@ internal static class FolderDialogUtil
#region Windows Implementation
// COM class ID of the shell's common file dialog (FileOpenDialog) and the interface IDs of IFileOpenDialog and
// IShellItem (shobjidl_core.h).
private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
private static readonly Guid IID_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE");
// HRESULT_FROM_WIN32(ERROR_CANCELLED) = 0x800704C7: IModalWindow::Show result when the user cancels the dialog.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-imodalwindow-show
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
// Active window attached to the calling thread's message queue, or NULL.
[DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow();
// Foreground window of the desktop (any process), or NULL.
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
// Thread ID that created the window; the process ID is returned through lpdwProcessId.
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
/// <summary>
/// Finds the game window to own the dialog, in order: the calling thread's active window, the foreground window when it
/// belongs to this process, then <see cref="Process.MainWindowHandle"/>.
/// </summary>
/// <returns>Window handle, or <see cref="IntPtr.Zero"/> when none is found or a call fails (the dialog is then unowned).</returns>
private static IntPtr GetGameWindowHandle()
{
try
@@ -175,6 +239,13 @@ internal static class FolderDialogUtil
return IntPtr.Zero;
}
/// <summary>
/// Shows the IFileOpenDialog folder picker; when it throws (cannot be created, or Show fails with an error other than
/// cancel), logs a warning and shows the legacy SHBrowseForFolder dialog. Runs on the STA dialog thread.
/// </summary>
/// <param name="initialDirectory">Folder shown first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory, or null on cancel.</returns>
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
{
try
@@ -188,16 +259,31 @@ internal static class FolderDialogUtil
}
}
/// <summary>
/// Shows the shell common file dialog in folder mode.
/// <para>
/// Options: <c>FOS_PICKFOLDERS</c> (choose folders), <c>FOS_FORCEFILESYSTEM</c> (file system items only) and
/// <c>FOS_PATHMUSTEXIST</c>. The initial folder is set with <c>SetFolder</c> and <c>SetDefaultFolder</c> when it exists.
/// When <c>Show</c> fails with an owner window (error other than cancel), it is retried once without an owner.
/// The result item is read as a file system path (<c>SIGDN_FILESYSPATH</c>); the returned string is freed with
/// <see cref="Marshal.FreeCoTaskMem"/> and the COM objects are released.
/// </para>
/// </summary>
/// <param name="initialDirectory">Folder shown first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory; null on cancel or when the result has no file system path.</returns>
/// <exception cref="COMException">The dialog cannot be created, or Show fails with an error other than cancel.</exception>
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
{
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration)
// 1. Create the dialog with ole32 CoCreateInstance (in-process server), marshalled directly to IFileOpenDialog.
var clsid = CLSID_FileOpenDialog;
var iid = IID_IFileOpenDialog;
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
if (createHr != 0)
{
// 2. Fallback to Activator via Type.GetTypeFromCLSID
// 2. On failure, create it through the runtime's COM interop (Type.GetTypeFromCLSID + Activator.CreateInstance).
// createHr keeps the CoCreateInstance error for the exception below.
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
if (comType != null)
{
@@ -223,6 +309,7 @@ internal static class FolderDialogUtil
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
// The dialog takes folders as IShellItem; SHCreateItemFromParsingName creates one from the path.
var shellItemGuid = IID_IShellItem;
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
if (itemHr == 0)
@@ -293,8 +380,19 @@ internal static class FolderDialogUtil
}
}
/// <summary>
/// Shows the legacy shell folder browser (<c>SHBrowseForFolderW</c>) with the resizable dialog style, an edit box, and
/// file system folders only. The initial folder is selected from the dialog callback (<c>BFFM_INITIALIZED</c> ->
/// <c>BFFM_SETSELECTIONW</c> with the path string). Cancel and failure both return null. Native buffers and the returned
/// item ID list are freed before returning.
/// </summary>
/// <param name="initialDirectory">Folder selected first, or empty.</param>
/// <param name="ownerHandle">Owner window, or <see cref="IntPtr.Zero"/>.</param>
/// <returns>Selected existing directory, or null.</returns>
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle)
{
// Display name buffer: MAX_PATH (260) UTF-16 characters, the size SHBrowseForFolder assumes.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow
IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
IntPtr initialDirPtr = IntPtr.Zero;
BrowseCallbackProc? callback = null;
@@ -318,6 +416,9 @@ internal static class FolderDialogUtil
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
// lpData = bi.lParam = the UTF-16 path; wParam 1 (TRUE) tells BFFM_SETSELECTION it is a string.
// The delegate is kept alive until the dialog returns (GC.KeepAlive in finally).
// Reference: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/bb762598(v=vs.85)
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
callback = (hwnd, uMsg, _, lpData) =>
{
@@ -340,12 +441,12 @@ internal static class FolderDialogUtil
pidl = SHBrowseForFolderW(ref bi);
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
{
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
// SHBrowseForFolderW returns NULL both on cancel and on failure, so no retry without the owner is made.
}
if (pidl != IntPtr.Zero)
{
// SHGetPathFromIDListW writes up to MAX_PATH (260) characters.
var sb = new StringBuilder(260);
if (SHGetPathFromIDListW(pidl, sb))
{
@@ -376,6 +477,10 @@ internal static class FolderDialogUtil
#region COM & Win32 Interop Definitions
/// <summary>
/// <c>FILEOPENDIALOGOPTIONS</c> flags passed to <c>IFileDialog::SetOptions</c> (subset; values from shobjidl_core.h).
/// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-_fileopendialogoptions
/// </summary>
[Flags]
private enum FOS : uint
{
@@ -386,11 +491,20 @@ internal static class FolderDialogUtil
FOS_FILEMUSTEXIST = 0x00001000,
}
/// <summary>
/// Display name form requested from <c>IShellItem::GetDisplayName</c>; <c>SIGDN_FILESYSPATH</c> is the item's file system
/// path (the call fails for items without one).
/// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/ne-shobjidl_core-sigdn
/// </summary>
private enum SIGDN : uint
{
SIGDN_FILESYSPATH = 0x80058000
}
/// <summary>
/// COM interop declaration of the shell <c>IShellItem</c> interface (vtable order of shobjidl_core.h). Only
/// <c>GetDisplayName</c> is called; it keeps its HRESULT return (<c>PreserveSig</c>).
/// </summary>
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
@@ -404,6 +518,11 @@ internal static class FolderDialogUtil
void Compare(IShellItem psi, uint hint, out int piOrder);
}
/// <summary>
/// COM interop declaration of <c>IFileOpenDialog</c>: the methods of <c>IModalWindow</c> (<c>Show</c>), <c>IFileDialog</c>
/// and <c>IFileOpenDialog</c> in vtable order. <c>Show</c> keeps its HRESULT return (<c>PreserveSig</c>); the other methods
/// throw <see cref="COMException"/> on a failure HRESULT.
/// </summary>
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
@@ -438,12 +557,15 @@ internal static class FolderDialogUtil
void GetSelectedItems(out IntPtr ppsai);
}
// Initializes COM as a single-threaded apartment plus OLE on the calling thread; returns an HRESULT (S_OK / S_FALSE on success).
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int OleInitialize(IntPtr pvReserved);
// Balances one successful OleInitialize on the same thread.
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern void OleUninitialize();
// Creates a COM object of class rclsid and returns interface riid; declared here with the IFileOpenDialog return type.
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int CoCreateInstance(
[In] ref Guid rclsid,
@@ -452,6 +574,7 @@ internal static class FolderDialogUtil
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
// Creates an IShellItem from a parsing name (file system path); returns an HRESULT (0 = S_OK).
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
@@ -459,14 +582,22 @@ internal static class FolderDialogUtil
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
// BROWSEINFO.ulFlags: file system directories only, resizable new-style dialog, edit box for typing a name.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/ns-shlobj_core-browseinfow
private const uint BIF_RETURNONLYFSDIRS = 0x0001;
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
private const uint BIF_EDITBOX = 0x0010;
// Browse dialog messages (shlobj_core.h): BFFM_INITIALIZED is sent to the callback when the dialog has finished
// initializing; BFFM_SETSELECTIONW (WM_USER + 103, Unicode) is sent to the dialog to select a folder. The reference
// describes the messages; the numeric values are the shlobj_core.h definitions.
// Reference:https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/bb762598(v=vs.85)
private const uint BFFM_INITIALIZED = 1;
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
// BFFCALLBACK: called by the browse dialog for dialog events; returns 0 except for BFFM_VALIDATEFAILED.
private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);
// Unicode BROWSEINFOW structure passed to SHBrowseForFolderW (field order and types of shlobj_core.h).
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct BROWSEINFO
{
@@ -480,13 +611,16 @@ internal static class FolderDialogUtil
public int iImage;
}
// Shows the modal browse dialog; returns the selected item's ID list (freed with CoTaskMemFree), or NULL on cancel or failure.
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
// Converts an item ID list to a file system path; false when the item is not part of the file system.
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
// Sends a window message and waits for it to be processed (used from the browse dialog callback).
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
@@ -496,10 +630,21 @@ internal static class FolderDialogUtil
#region macOS Implementation
/// <summary>
/// Runs <c>osascript -e</c> with the AppleScript <c>POSIX path of (choose folder with prompt ... [default location ...])</c>
/// and blocks until the process exits. The initial directory is passed as a POSIX file in an AppleScript string literal
/// (backslashes and double quotes escaped). On cancel osascript prints nothing to standard output.
/// </summary>
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
/// <returns>
/// The trimmed standard output when it is an existing directory; null on cancel, when the process cannot be started, or
/// on an exception (logged as a warning).
/// </returns>
private static string? OpenMacFolderBrowser(string initialDirectory)
{
try
{
// POSIX path of (...) converts the chosen folder alias to a slash-separated path.
string script;
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
@@ -541,6 +686,17 @@ internal static class FolderDialogUtil
#region Linux Implementation
/// <summary>
/// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> and blocks until it exits.
/// Exit code 0 returns the printed path when it is an existing directory, and exit code 1 (cancelled or closed) returns null
/// without opening a second dialog. When zenity cannot be started or exits with any other code, runs <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the
/// selected path to standard output.
/// </summary>
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
/// <returns>
/// The selected existing directory; null when the dialog is cancelled, when kdialog cannot be started, or on an
/// exception (logged as a warning).
/// </returns>
private static string? OpenLinuxFolderBrowser(string initialDirectory)
{
try
@@ -569,15 +725,19 @@ internal static class FolderDialogUtil
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
if (process.ExitCode == 0)
{
return output;
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
}
// zenity exit code 1: the dialog was cancelled or closed.
// Reference: https://gitlab.gnome.org/GNOME/zenity/-/raw/master/src/util.c (ZENITY_OK_DEFAULT 0, ZENITY_CANCEL_DEFAULT 1, ZENITY_ESC_DEFAULT 1, ZENITY_ERROR_DEFAULT -1).
if (process.ExitCode == 1) return null;
}
}
catch
{
// Fallback to kdialog if zenity fails to start
// Falls back to kdialog when zenity is not installed or cannot be started.
}
var kdialogInfo = new ProcessStartInfo
+120 -8
View File
@@ -13,26 +13,36 @@ namespace ADOFAIRenderer.Util.OS;
/// (WinRT <c>ToastNotificationManager</c>, script passed with <c>-EncodedCommand</c>).
/// With a folder path, the script also registers the <c>adofai-renderer://</c> URL protocol under
/// <c>HKCU\Software\Classes\adofai-renderer</c> (open command: <c>explorer.exe "&lt;folder&gt;"</c>)
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered.
/// so the toast button opens the folder; the key is overwritten on each notification and stays registered, so every
/// toast opens the folder of the most recent notification.
/// When PowerShell fails, a Win32 <c>Shell_NotifyIcon</c> balloon is shown via P/Invoke.
/// Runs on a dedicated background thread ("OSNotification-Win").
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c> (no folder action), started from the
/// calling thread without waiting.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) and, when it cannot be started, <c>gdbus</c> calling
/// <c>org.freedesktop.Notifications.Notify</c>. With a folder path, <c>notify-send --action</c> runs on a thread pool task
/// and <c>xdg-open</c> opens the folder when the action is chosen.
/// </para>
/// <para>Other platforms: nothing is shown. Failures are logged as warnings; no exception reaches the caller.</para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
/// the folder when the notification is clicked/dismissed.
/// Sends a native desktop notification and returns without waiting for it to be shown (see the class summary for the
/// thread used on each platform).
/// If <paramref name="folderPath"/> is provided, Windows and Linux offer to open the folder from the notification.
/// Exceptions thrown while starting the platform implementation are caught and logged as warnings.
/// </summary>
/// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param>
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
/// <param name="folderPath">
/// Optional folder path to open from the notification (Windows toast click or button / Windows balloon click / Linux action);
/// ignored on macOS.
/// </param>
public static void Show(string title, string message, string? folderPath = null)
{
try
@@ -65,6 +75,13 @@ internal static class OSNotification
/// </summary>
private static class WindowsNotification
{
/// <summary>
/// Starts a background thread ("OSNotification-Win") that tries the toast and shows the balloon when the toast fails.
/// Returns immediately. Exceptions on the thread are caught and logged.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
/// <param name="folderPath">Folder to open from the notification, or null.</param>
public static void Send(string title, string message, string? folderPath)
{
var thread = new Thread(() =>
@@ -95,11 +112,23 @@ internal static class OSNotification
/// With a folder path, registers the adofai-renderer:// protocol in HKCU first (see class summary).
/// Waits up to 5 seconds for the process. Returns true when it exits with code 0,
/// false when it exits with another code, does not exit within 5 seconds, or fails to start.
/// <para>
/// The toast XML uses the <c>ToastGeneric</c> template with the title and message as two text elements; with a folder
/// path it also sets <c>launch</c> and an "Open Render Folder" action to the <c>adofai-renderer://</c> URI with protocol
/// activation. The notifier is created with the application ID "ADOFAI Renderer". The script is passed as Base64 of its
/// UTF-16LE bytes (<c>-EncodedCommand</c>), so no command line quoting is needed.
/// </para>
/// </summary>
/// <param name="title">Notification title (XML-escaped into the toast).</param>
/// <param name="message">Notification body text (XML-escaped into the toast).</param>
/// <param name="folderPath">Folder opened by the protocol handler, or null for a toast without action.</param>
/// <returns>True when PowerShell exited with code 0 within 5 seconds; false otherwise (the stderr text or exception is logged).</returns>
private static bool TrySendToast(string title, string message, string? folderPath)
{
try
{
// SecurityElement.Escape replaces < > & " ' with XML entities, so the text also cannot end the
// single-quoted PowerShell string that holds the XML.
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
@@ -108,8 +137,13 @@ internal static class OSNotification
if (!string.IsNullOrEmpty(folderPath))
{
// A single quote is written as two single quotes inside a single-quoted PowerShell string.
var escapedFolder = folderPath.Replace("'", "''");
// Script: register HKCU\Software\Classes\adofai-renderer as a URL protocol ("URL Protocol" value, default
// value "URL:adofai-renderer Protocol") whose shell\open\command runs explorer.exe on the folder, then load
// the WinRT types and show the toast.
toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
@@ -148,6 +182,7 @@ internal static class OSNotification
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
// -EncodedCommand takes Base64 of the UTF-16LE script text.
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes);
@@ -166,7 +201,8 @@ internal static class OSNotification
process.Start();
process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure
// ExitCode throws InvalidOperationException while the process is still running (timeout), which the catch
// below reports as a failure. If PowerShell exited with error, treat as failure
if (process.ExitCode != 0)
{
string stderr = process.StandardError.ReadToEnd().Trim();
@@ -189,9 +225,14 @@ internal static class OSNotification
#region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming
// Shell_NotifyIcon messages: add / delete the notification area icon.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shell_notifyiconw
private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02;
// NOTIFYICONDATAW.uFlags: uCallbackMessage, hIcon, szTip and the balloon fields (szInfo, szInfoTitle, dwInfoFlags)
// are valid. NIIF_INFO: information icon in the balloon.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw
private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04;
@@ -199,18 +240,25 @@ internal static class OSNotification
private const int NIIF_INFO = 0x01;
// WM_USER (winuser.h) starts the private window message range; WM_TRAYICON is the application-defined callback
// message of the icon. NIN_BALLOONUSERCLICK (shellapi.h, WM_USER + 5) arrives as lParam of the callback message when
// the user clicks the balloon.
private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405;
// Window style of the hidden message window (winuser.h), WM_CLOSE message ID (winuser.h), and the application-defined
// icon ID (any value unique for the window).
private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
// Adds, modifies or deletes a notification area icon; returns false on failure.
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
// Creates the window that owns the icon and receives its callback messages; returns NULL on failure.
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
@@ -221,9 +269,12 @@ internal static class OSNotification
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
// Loads a predefined system icon when hInstance is NULL.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-loadiconw
[DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
// Message loop functions of the calling thread: GetMessageW blocks until a message arrives and returns false for WM_QUIT.
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
@@ -234,10 +285,13 @@ internal static class OSNotification
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
// Places a message in the window's thread queue and returns without waiting (callable from any thread).
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
// NOTIFYICONDATAW up to dwInfoFlags (without guidItem and hBalloonIcon); cbSize is the size of this layout.
// String fields hold at most SizeConst characters including the terminating null.
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW
{
@@ -259,6 +313,7 @@ internal static class OSNotification
public int dwInfoFlags;
}
// Win32 MSG structure filled by GetMessageW.
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
@@ -271,8 +326,22 @@ internal static class OSNotification
public int pt_y;
}
/// <summary>
/// Shows a notification area balloon through <c>Shell_NotifyIconW</c>, owned by a hidden message-only window.
/// <para>
/// Without a folder path the icon stays for 500 ms and is then removed. With a folder path the method runs a message
/// loop on the calling thread for up to 12 seconds (a timer posts <c>WM_CLOSE</c>); a balloon click
/// (<c>NIN_BALLOONUSERCLICK</c>) opens the folder through the shell (<c>UseShellExecute</c>) and ends the loop.
/// The icon is deleted and the window destroyed before returning. Errors are logged as warnings.
/// </para>
/// </summary>
/// <param name="title">Balloon title; truncated to 63 characters.</param>
/// <param name="message">Balloon text; truncated to 255 characters.</param>
/// <param name="folderPath">Folder opened on balloon click, or null.</param>
private static void SendBalloon(string title, string message, string? folderPath)
{
// szInfoTitle holds 64 and szInfo 256 characters including the terminating null.
// Reference: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/ns-shellapi-notifyicondataw
if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255);
@@ -281,6 +350,7 @@ internal static class OSNotification
try
{
// HWND_MESSAGE ((HWND)-3, winuser.h) as parent creates a message-only window of the "Static" class.
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
@@ -293,6 +363,7 @@ internal static class OSNotification
return;
}
// 32512 = IDI_APPLICATION (winuser.h), the default application icon.
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
@@ -305,6 +376,7 @@ internal static class OSNotification
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
// uTimeout in milliseconds; deprecated as of Windows Vista (display time follows the system settings).
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
@@ -327,6 +399,7 @@ internal static class OSNotification
if (msg.message == WM_TRAYICON)
{
// No NIM_SETVERSION call: lParam carries the notification event (NIN_*) directly.
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
@@ -388,6 +461,12 @@ internal static class OSNotification
/// </summary>
private static class MacNotification
{
/// <summary>
/// Starts <c>osascript -e "display notification ... with title ..."</c> without waiting. Exceptions from starting the
/// process (for example osascript missing) propagate to <see cref="OSNotification.Show"/>.
/// </summary>
/// <param name="title">Notification title.</param>
/// <param name="message">Notification body text.</param>
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
@@ -412,6 +491,9 @@ internal static class OSNotification
RunProcess(startInfo);
}
/// <summary>Escapes a value for use inside a double-quoted AppleScript string literal.</summary>
/// <param name="value">Raw text.</param>
/// <returns>The text with each backslash and double quote preceded by a backslash.</returns>
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
@@ -431,6 +513,13 @@ internal static class OSNotification
/// </summary>
private static class LinuxNotification
{
/// <summary>
/// Shows the notification with an "Open Render Folder" action when <paramref name="folderPath"/> is set (thread pool
/// task), otherwise a plain notification started from the calling thread.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder opened by the action, or null.</param>
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
@@ -443,6 +532,9 @@ internal static class OSNotification
}
}
/// <summary>Starts notify-send; when it cannot be started, starts gdbus. Neither process is waited for.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
private static void ShowSimple(string title, string message)
{
// Try notify-send first
@@ -453,6 +545,15 @@ internal static class OSNotification
TryGdbus(title, message);
}
/// <summary>
/// On a thread pool task, runs <c>notify-send -a "ADOFAI Renderer" --action=open_folder=Open Render Folder</c> and waits
/// for it to exit. notify-send prints the key of the chosen action ("open_folder") to standard output; in that case
/// <c>xdg-open &lt;folderPath&gt;</c> is started. When an exception occurs (for example notify-send missing), it is logged
/// and <see cref="ShowSimple"/> shows the notification without the action.
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <param name="folderPath">Folder passed to xdg-open.</param>
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
@@ -505,6 +606,10 @@ internal static class OSNotification
});
}
/// <summary>Starts <c>notify-send -a "ADOFAI Renderer" &lt;title&gt; &lt;message&gt;</c> without waiting.</summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
/// <returns>True when the process was started (its exit code is not checked); false when starting it threw.</returns>
private static bool TryNotifySend(string title, string message)
{
try
@@ -530,6 +635,13 @@ internal static class OSNotification
}
}
/// <summary>
/// Starts <c>gdbus call --session</c> to <c>org.freedesktop.Notifications.Notify</c> (arguments app_name, replaces_id,
/// app_icon, summary, body, actions, hints, expire_timeout) without waiting. Start failures are logged as warnings.
/// Reference: https://specifications.freedesktop.org/notification/latest/protocol.html
/// </summary>
/// <param name="title">Notification summary.</param>
/// <param name="message">Notification body.</param>
private static void TryGdbus(string title, string message)
{
try
+42
View File
@@ -2,16 +2,37 @@ using System;
namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Read-only access to <c>HKEY_LOCAL_MACHINE</c> (64-bit registry view) through reflection.
/// <para>
/// The mod targets netstandard2.1, which does not expose <c>Microsoft.Win32.RegistryKey</c>; the game runtime (Mono)
/// provides it in its core library. The types and methods are looked up once in the static constructor and invoked through
/// reflection.
/// </para>
/// <para>
/// Intended for Windows. On other platforms the result depends on the runtime's registry support; when the types or methods
/// are not found, or a call throws, every method returns null.
/// All failures (missing key, access denied, reflection errors) are caught and reported as null; nothing is logged.
/// Stateless and safe to call from any thread; each call opens and closes its own keys.
/// </para>
/// </summary>
internal static class RegistryUtil
{
// Reflected Microsoft.Win32 types; null when the runtime does not provide them.
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
// RegistryKey.OpenBaseKey(RegistryHive, RegistryView) (static), OpenSubKey(string) (read-only open),
// GetSubKeyNames() and GetValue(string).
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
/// <summary>
/// Resolves the registry types by name (<see cref="Type.GetType(string)"/>, then the core library that defines
/// <see cref="object"/>) and their methods. Leaves the fields null on any failure.
/// </summary>
static RegistryUtil()
{
try
@@ -42,6 +63,8 @@ internal static class RegistryUtil
}
}
/// <summary>Closes a reflected RegistryKey (Dispose, or Close when it is not IDisposable). Ignores null and errors.</summary>
/// <param name="key">RegistryKey instance returned by a reflected call, or null.</param>
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
@@ -63,6 +86,11 @@ internal static class RegistryUtil
}
}
/// <summary>
/// Returns the names of the direct subkeys of <c>HKEY_LOCAL_MACHINE\&lt;parentKeyPath&gt;</c> in the 64-bit registry view.
/// </summary>
/// <param name="parentKeyPath">Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.</param>
/// <returns>Subkey names, or null when the key does not exist, cannot be opened, or the registry is unavailable.</returns>
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
@@ -71,6 +99,10 @@ internal static class RegistryUtil
try
{
// RegistryHive.LocalMachine = 0x80000002 (-2147483646), RegistryView.Registry64 = 256.
// On a 32-bit OS a Registry64 request returns the 32-bit view.
// Reference: https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryhive
// Reference: https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.registryview
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
@@ -95,6 +127,15 @@ internal static class RegistryUtil
}
}
/// <summary>
/// Reads a string value of <c>HKEY_LOCAL_MACHINE\&lt;keyPath&gt;</c> in the 64-bit registry view.
/// </summary>
/// <param name="keyPath">Key path relative to HKEY_LOCAL_MACHINE, with backslash separators.</param>
/// <param name="valueName">Value name; an empty string reads the key's default value.</param>
/// <returns>
/// The value when it exists and is a string (REG_SZ / REG_EXPAND_SZ); null when the key or value does not exist, the value
/// has another type, or the registry is unavailable.
/// </returns>
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
@@ -103,6 +144,7 @@ internal static class RegistryUtil
try
{
// Same hive and view constants as GetHKLMSubKeyNames.
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
+12
View File
@@ -4,10 +4,22 @@ using UnityEngine;
namespace ADOFAIRenderer.Util
{
/// <summary>Particle system helpers.</summary>
internal static class ParticleUtil
{
/// <summary>
/// Renderer found for each particle system. Weak keys: an entry is dropped when its ParticleSystem object is collected.
/// Main thread.
/// </summary>
private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new();
/// <summary>
/// Returns the ParticleSystemRenderer that belongs to <paramref name="ps"/>. When a GameObject has several particle
/// systems and renderers, the renderer with the same component index as the particle system is used; otherwise the
/// first renderer of the GameObject. The result is cached per particle system. Main thread.
/// </summary>
/// <param name="ps">Particle system; null or destroyed returns null.</param>
/// <returns>The renderer, or null when the GameObject has none.</returns>
public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps)
{
if (!ps) return null;
+11
View File
@@ -2,8 +2,19 @@ using System;
namespace ADOFAIRenderer.Util
{
/// <summary>Conversion of floating-point rates (frame rates) to integer fractions.</summary>
internal static class RationalUtil
{
/// <summary>
/// Best rational approximation Numerator / Denominator of <paramref name="value"/> by continued fraction expansion:
/// the last convergent whose numerator fits in int and whose denominator does not exceed
/// <paramref name="maxDenominator"/>. Exact for values with a short expansion (for example 60 gives 60/1). The sign is
/// carried by the numerator; 0 gives 0/1. A magnitude above int.MaxValue has no valid convergent and gives 1/0.
/// Thread-safe (pure function).
/// </summary>
/// <param name="value">Value to convert, for example a frame rate in frames per second.</param>
/// <param name="maxDenominator">Largest allowed denominator (default 100000000).</param>
/// <returns>Numerator and denominator (positive for magnitudes up to int.MaxValue).</returns>
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
int maxDenominator = 100000000)
{
+192
View File
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util;
/// <summary>
/// Formats and sanitizes render output file names according to customizable template patterns.
/// </summary>
internal static class RenderFileNameUtil
{
private static readonly Regex RichTextRegex = new(@"<[^>]+>", RegexOptions.Compiled);
private static readonly Regex TokenRegex = new(@"\{([a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
private static readonly HashSet<string> ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
/// <summary>
/// Removes Unity rich text tags (such as &lt;color=...&gt;, &lt;b&gt;, etc.) from the given text.
/// </summary>
public static string StripRichText(string? input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
return RichTextRegex.Replace(input, string.Empty);
}
/// <summary>
/// Replaces invalid file name characters within a single token with an underscore.
/// </summary>
public static string SanitizeToken(string? token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
var invalidChars = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(token.Length);
foreach (var c in token)
{
sb.Append(invalidChars.Contains(c) ? '_' : c);
}
return sb.ToString().Trim(' ', '.');
}
/// <summary>
/// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names.
/// </summary>
public static string SanitizeFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return string.Empty;
var invalidChars = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(fileName.Length);
foreach (var c in fileName)
{
sb.Append(invalidChars.Contains(c) ? '_' : c);
}
var result = sb.ToString().Trim(' ', '.');
if (ReservedWindowsNames.Contains(result))
{
result = "_" + result;
}
return result;
}
/// <summary>
/// Formats a file name without extension by substituting template variables.
/// Available tokens: {song}, {title}, {artist}, {author}, {level}, {preset},
/// {date}, {time}, {year}, {month}, {day}, {hour}, {minute}, {second},
/// {resolution}, {width}, {height}, {fps}.
/// </summary>
public static string FormatFileName(
string? formatTemplate,
DateTime startTime,
RenderConfig config,
string? presetName = null,
string? levelPath = null)
{
var scn = scnGame.instance;
string rawSong = "";
string rawArtist = "";
string rawAuthor = "";
if (scn != null && scn.levelData != null)
{
rawSong = scn.levelData.song ?? "";
rawArtist = scn.levelData.artist ?? "";
rawAuthor = scn.levelData.author ?? "";
}
if (string.IsNullOrWhiteSpace(rawSong) && !string.IsNullOrWhiteSpace(levelPath))
{
var dir = Path.GetDirectoryName(levelPath);
rawSong = !string.IsNullOrWhiteSpace(dir)
? Path.GetFileName(dir)
: Path.GetFileNameWithoutExtension(levelPath);
}
var song = SanitizeToken(StripRichText(rawSong));
var artist = SanitizeToken(StripRichText(rawArtist));
var author = SanitizeToken(StripRichText(rawAuthor));
var level = !string.IsNullOrWhiteSpace(levelPath)
? SanitizeToken(Path.GetFileNameWithoutExtension(levelPath))
: song;
var preset = SanitizeToken(presetName ?? "");
var date = startTime.ToString("yyyy-MM-dd");
var time = startTime.ToString("HH-mm-ss");
var year = startTime.ToString("yyyy");
var month = startTime.ToString("MM");
var day = startTime.ToString("dd");
var hour = startTime.ToString("HH");
var minute = startTime.ToString("mm");
var second = startTime.ToString("ss");
var width = config.RenderWidth.ToString();
var height = config.RenderHeight.ToString();
var resolution = $"{config.RenderWidth}x{config.RenderHeight}";
var effectiveFps = Math.Min(config.VideoFPS, config.RenderFPS);
var fps = Math.Abs(effectiveFps % 1) < 0.001
? ((int)effectiveFps).ToString()
: effectiveFps.ToString("0.##");
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "song", song },
{ "title", song },
{ "artist", artist },
{ "author", author },
{ "level", level },
{ "preset", preset },
{ "date", date },
{ "time", time },
{ "year", year },
{ "month", month },
{ "day", day },
{ "hour", hour },
{ "minute", minute },
{ "second", second },
{ "width", width },
{ "height", height },
{ "resolution", resolution },
{ "fps", fps }
};
var template = string.IsNullOrWhiteSpace(formatTemplate)
? GlobalSettings.DefaultFileNameFormat
: formatTemplate;
var replaced = TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value;
return tokens.TryGetValue(key, out var val) ? val : match.Value;
});
var sanitized = SanitizeFileName(replaced);
return string.IsNullOrWhiteSpace(sanitized)
? startTime.ToString("yyyy-MM-dd HH-mm-ss")
: sanitized;
}
/// <summary>
/// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists.
/// </summary>
public static string GetUniqueFilePath(string directory, string fileNameWithoutExt, string ext)
{
var normalizedExt = "." + ext.TrimStart('.').ToLowerInvariant();
var fileName = $"{fileNameWithoutExt}{normalizedExt}";
var fullPath = Path.Combine(directory, fileName);
var counter = 1;
while (File.Exists(fullPath))
{
fileName = $"{fileNameWithoutExt} ({counter}){normalizedExt}";
fullPath = Path.Combine(directory, fileName);
counter++;
}
return fullPath;
}
}
+68
View File
@@ -9,9 +9,25 @@ using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util;
/// <summary>
/// Plays a custom level's background video with FFmpeg (FFmpegNativeDecoder) in place of Unity's VideoPlayer while a
/// render is active, so the video frame shown always matches the render clock. Attached to the scnGame.videoBG object
/// by scnGamePatch.ReplaceVideo, which disables the native VideoPlayer; removed again by that patch when no render is
/// active.
/// <para>
/// Every LateUpdate it computes the video time from the conductor (the same formula scrVfxPlus uses to start and
/// position the native video), decodes frames up to that time and draws the newest one either into the VideoPlayer's
/// target RenderTexture or onto a quad at the far plane of the background camera.
/// Decoded YUV frames are converted to RGBA on the GPU (ColorConversion.compute, kernels CSMain_NV12ToRGBA /
/// CSMain_YUV420PToRGBA); other pixel formats, or a missing shader, use the decoder's RGBA output (CPU conversion).
/// Main thread only.
/// </para>
/// </summary>
// ReSharper disable once InconsistentNaming
internal class VideoBGPlayer : MonoBehaviour
{
// Property IDs of the compute shader parameters: input planes, output texture, frame size in pixels,
// ColorRange (1 = full range, 0 = limited range) and ColorMatrix (FFmpegNativeDecoder.ColorMatrix).
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
@@ -22,6 +38,10 @@ internal class VideoBGPlayer : MonoBehaviour
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
/// <summary>
/// Conversion path of the current video: <c>None</c> = CPU RGBA texture, <c>YUV420P</c> = separate Y, U, V planes,
/// <c>NV12</c> = Y plane and interleaved UV plane.
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
@@ -30,6 +50,7 @@ internal class VideoBGPlayer : MonoBehaviour
NV12
}
/// <summary>Decoder of the current video; null when not initialized or after CleanUp.</summary>
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
@@ -47,16 +68,34 @@ internal class VideoBGPlayer : MonoBehaviour
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
/// <summary>Presentation time in seconds of the frame currently shown (video time, 0 = start of the file).</summary>
private double _currentFrameTimestamp;
/// <summary>Video time in seconds from which the next frame is decoded (current timestamp + 1 / FPS).</summary>
private double _nextFrameTimestamp;
/// <summary>Conversion path selected by Init.</summary>
private GPUMode _gpuMode = GPUMode.None;
/// <summary>Target RenderTexture of the native VideoPlayer; null when the video is shown on the background quad.</summary>
private RenderTexture? _targetTexture;
/// <summary>Whether the level loops the video (miscSettings "loopVideo").</summary>
private bool _isLooping;
/// <summary>True after a successful Init; set to false by CleanUp or a playback error (playback then stops).</summary>
private bool _isInitialized;
/// <summary>Background quad created by SetupBackgroundQuad when there is no target texture; hidden until the first frame.</summary>
private GameObject? _quad;
/// <summary>Material of the background quad; its main texture is the converted frame.</summary>
private Material? _material;
/// <summary>
/// Releases any previous video and opens <paramref name="videoPath"/>. Allocates the plane textures for the GPU path
/// of the decoder's pixel format (chroma planes are (width + 1) / 2 x (height + 1) / 2), or an RGBA texture for the
/// CPU path. Errors are logged; the player then stays uninitialized and shows nothing.
/// </summary>
/// <param name="videoPath">Full path of the video file.</param>
/// <param name="targetTexture">RenderTexture the native VideoPlayer renders into, or null to draw on a background quad.</param>
/// <param name="loop">Whether the video loops.</param>
/// <param name="targetCamera">Camera the background quad is attached to when <paramref name="targetTexture"/> is null.</param>
/// <param name="videoBGGo">VideoPlayer object; its layer is given to the background quad.</param>
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
@@ -169,12 +208,19 @@ internal class VideoBGPlayer : MonoBehaviour
}
}
/// <summary>Sets clamp wrapping and bilinear filtering.</summary>
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
/// <summary>
/// Main thread, every frame after the song has started. Computes the video time, seeks when it moved backwards or more
/// than 1 s ahead of the shown frame, decodes frames until the next frame lies after that time, and draws the last
/// decoded frame. Before the video starts (negative time) the target texture is cleared to black and the quad hidden.
/// When decoding fails at the end of the file, a looping video seeks to 0; otherwise the last frame stays.
/// </summary>
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
@@ -182,6 +228,9 @@ internal class VideoBGPlayer : MonoBehaviour
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
// Song position in seconds of song time, as scrConductor computes songposition_minusi from dspTime with the input
// offset calibration_i = 0 (the render sets the input offset to 0), minus the separate countdown length.
// scrVfxPlus starts the native video at songposition_minusi - countdown + vidOffset (scrVfxPlus.cs).
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
@@ -215,6 +264,7 @@ internal class VideoBGPlayer : MonoBehaviour
}
// scrVfxPlus hides the tutorial (editor) background when the native video starts (scrVfxPlus.cs).
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
@@ -222,6 +272,7 @@ internal class VideoBGPlayer : MonoBehaviour
try
{
// A seek is cheaper than decoding many frames when the time jumped forward by more than 1 s.
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
@@ -298,6 +349,10 @@ internal class VideoBGPlayer : MonoBehaviour
}
}
/// <summary>
/// Uploads the decoded planes, converts them to RGBA with the compute shader (or uploads the RGBA texture), and shows
/// the result: blitted into the target texture, or set as the quad's main texture (the quad is activated).
/// </summary>
private void RenderToTarget()
{
if (_decoder == null) return;
@@ -318,6 +373,7 @@ internal class VideoBGPlayer : MonoBehaviour
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
// One thread group per 8 x 8 output pixels, rounded up (group size expected by the ColorConversion.compute kernels).
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
@@ -353,6 +409,7 @@ internal class VideoBGPlayer : MonoBehaviour
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
// One thread group per 8 x 8 output pixels, rounded up (group size expected by the ColorConversion.compute kernels).
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
@@ -394,11 +451,16 @@ internal class VideoBGPlayer : MonoBehaviour
}
}
/// <summary>Releases the decoder and GPU resources when the component is destroyed.</summary>
private void OnDestroy()
{
CleanUp();
}
/// <summary>
/// Disposes the decoder and destroys the textures, the RenderTexture, the background quad and its material.
/// The player is uninitialized afterwards. Called by Init, on errors and on destroy.
/// </summary>
public void CleanUp()
{
_isInitialized = false;
@@ -446,6 +508,12 @@ internal class VideoBGPlayer : MonoBehaviour
}
}
/// <summary>
/// Creates a quad as a child of <paramref name="targetCamera"/>, 0.5 units in front of its far clip plane, scaled to
/// fill the view (orthographic size or field of view), on the layer of <paramref name="videoBGGo"/>. Its shader is
/// taken from scrCamera's camera quad material when available, otherwise the first of Sprites/Default, Unlit/Texture
/// and Standard that exists. The quad starts inactive.
/// </summary>
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{