SHA256
Compare commits
62
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ed6f990d3b
|
||
|
|
151c91bd86 | ||
|
|
961f78a332 | ||
|
|
0fc01a84de | ||
|
|
a389283d33 | ||
|
|
d2bd655118 | ||
|
|
8deb64fd4d | ||
|
|
354c42fdd9 | ||
|
|
19d0e258ac | ||
|
|
7bb47e4974 | ||
|
|
fab189ff3b | ||
|
|
abb6f729e4 | ||
|
|
c56175bbc8 | ||
|
|
ab8164a735 | ||
|
|
69d098e04c | ||
|
|
8e0639aafa | ||
|
|
1fdee67f9d | ||
|
|
a4dba82729 | ||
|
|
27e7327c1e
|
||
|
|
2152625e45
|
||
|
|
5136127e39 | ||
|
|
aefa1409c1
|
||
|
|
e802b6a08a
|
||
|
|
455436e744 | ||
|
|
c8050abbc9
|
||
|
|
45a7637a36
|
||
|
|
89f15eca1e | ||
|
|
d0f6fa9ecd | ||
|
|
5055e4dfbb | ||
|
|
cb0fd1bd96 | ||
|
|
3d2ffe9dfe | ||
|
|
6955762641
|
||
|
|
a21d86d3a6 | ||
|
|
7ea2d8fe3f | ||
|
|
35606d6e58 | ||
|
|
82776fc572 | ||
|
|
0a12c12ff5 | ||
|
|
ea8d4c91ac
|
||
|
|
5080e7ea1d
|
||
|
|
4e556a4632
|
||
|
|
f1e926f758 | ||
|
|
c62723cf5c
|
||
|
|
fee7077b16
|
||
|
|
9fbf057677
|
||
|
|
f153bf70aa
|
||
|
|
93539307b1
|
||
|
|
c0e902fc1a
|
||
|
|
dd2585ee9f
|
||
|
|
1a672cbf59
|
||
|
|
6d5880fe19
|
||
|
|
72d8d71928
|
||
|
|
413778e165
|
||
|
|
36552511f5
|
||
|
|
a1f9fa36ce
|
||
|
|
00971a6730
|
||
|
|
bd24152a2e
|
||
|
|
7aa8ae4b54
|
||
|
|
dee3b2dbcf
|
||
|
|
7eec3b4660
|
||
|
|
ce70d89cdb
|
||
|
|
7db1905906
|
||
|
|
bfc79ee4d3
|
No files matched your search
+9
-3
@@ -32,9 +32,11 @@ Thumbs.db
|
|||||||
desktop.ini
|
desktop.ini
|
||||||
|
|
||||||
## Agent Metadata
|
## Agent Metadata
|
||||||
.antigravitycli/
|
/.antigravitycli/
|
||||||
.agent/
|
/.agent/
|
||||||
GEMINI.md
|
/GEMINI.md
|
||||||
|
/.mcp.json
|
||||||
|
/CLAUDE.md
|
||||||
|
|
||||||
# Decompiled Game Code
|
# Decompiled Game Code
|
||||||
*.cs.txt
|
*.cs.txt
|
||||||
@@ -43,3 +45,7 @@ GEMINI.md
|
|||||||
/AsyncInputAutoplayFix.md
|
/AsyncInputAutoplayFix.md
|
||||||
/AudioMixerExplanation.md
|
/AudioMixerExplanation.md
|
||||||
/O5Theme.md
|
/O5Theme.md
|
||||||
|
|
||||||
|
# Developer Utility Files
|
||||||
|
/REVIEW_CHECKLIST.md
|
||||||
|
/REVIEW_FFMPEG.md
|
||||||
+69
-9
@@ -18,6 +18,21 @@ namespace ADOFAIRenderer.API;
|
|||||||
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
|
/// 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.
|
/// will <b>not</b> be invoked on every rendered game frame.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
@@ -25,12 +40,22 @@ namespace ADOFAIRenderer.API;
|
|||||||
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
||||||
public class FrameHookContext
|
public class FrameHookContext
|
||||||
{
|
{
|
||||||
|
/// <summary>Game frame of this capture (camera quad texture); never written by the context.</summary>
|
||||||
private RenderTexture _originalSource = null!;
|
private RenderTexture _originalSource = null!;
|
||||||
|
|
||||||
|
/// <summary>First ping-pong buffer, owned by FrameHookPipeline.</summary>
|
||||||
private RenderTexture _pingRT = null!;
|
private RenderTexture _pingRT = null!;
|
||||||
|
|
||||||
|
/// <summary>Second ping-pong buffer, owned by FrameHookPipeline.</summary>
|
||||||
private RenderTexture _pongRT = null!;
|
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!;
|
private RenderTexture _currentSource = null!;
|
||||||
|
|
||||||
|
/// <summary>Buffer the next pass writes: always the ping or pong buffer.</summary>
|
||||||
private RenderTexture _currentDestination = null!;
|
private RenderTexture _currentDestination = null!;
|
||||||
|
|
||||||
|
/// <summary>True after the first Swap or ReplaceCurrent of this frame.</summary>
|
||||||
private bool _hasModified;
|
private bool _hasModified;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -45,6 +70,7 @@ public class FrameHookContext
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
|
/// 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>
|
/// </summary>
|
||||||
public RenderTexture Current => _currentSource;
|
public RenderTexture Current => _currentSource;
|
||||||
|
|
||||||
@@ -59,7 +85,7 @@ public class FrameHookContext
|
|||||||
public bool HasModified => _hasModified;
|
public bool HasModified => _hasModified;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public long FrameIndex { get; private set; }
|
public long FrameIndex { get; private set; }
|
||||||
|
|
||||||
@@ -69,12 +95,13 @@ public class FrameHookContext
|
|||||||
public long VideoFrameIndex => FrameIndex;
|
public long VideoFrameIndex => FrameIndex;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public long RenderFrameIndex { get; private set; }
|
public long RenderFrameIndex { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public double VideoTime { get; private set; }
|
public double VideoTime { get; private set; }
|
||||||
|
|
||||||
@@ -84,30 +111,32 @@ public class FrameHookContext
|
|||||||
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The width of the frame in pixels.
|
/// The width of the frame in pixels (width of the current <see cref="Source"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Width => _currentSource.width;
|
public int Width => _currentSource.width;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The height of the frame in pixels.
|
/// The height of the frame in pixels (height of the current <see cref="Source"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Height => _currentSource.height;
|
public int Height => _currentSource.height;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The configured render frames per second.
|
/// The configured render frames per second (game frames simulated per second of render time).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double RenderFPS { get; private set; }
|
public double RenderFPS { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public double VideoFPS { get; private set; }
|
public double VideoFPS { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public int PassCount { get; private set; }
|
public int PassCount { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Creates a context for the first frame; see <see cref="Reset"/> for the parameters.</summary>
|
||||||
internal FrameHookContext(
|
internal FrameHookContext(
|
||||||
RenderTexture originalSource,
|
RenderTexture originalSource,
|
||||||
RenderTexture pingRT,
|
RenderTexture pingRT,
|
||||||
@@ -122,6 +151,10 @@ public class FrameHookContext
|
|||||||
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
|
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(
|
internal void Reset(
|
||||||
RenderTexture originalSource,
|
RenderTexture originalSource,
|
||||||
RenderTexture pingRT,
|
RenderTexture pingRT,
|
||||||
@@ -152,6 +185,11 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
|
||||||
/// </summary>
|
/// </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="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>
|
/// <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)
|
public void Blit(Material? material = null, int pass = -1)
|
||||||
@@ -174,6 +212,10 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <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.
|
/// 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>
|
/// </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="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="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>
|
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
|
||||||
@@ -200,6 +242,7 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public void Copy()
|
public void Copy()
|
||||||
{
|
{
|
||||||
@@ -207,7 +250,8 @@ public class FrameHookContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
|
||||||
public void ClearDestination(Color? clearColor = null)
|
public void ClearDestination(Color? clearColor = null)
|
||||||
@@ -261,6 +305,11 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
|
||||||
/// </summary>
|
/// </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="commandBuffer">The command buffer to execute.</param>
|
||||||
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</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>
|
/// <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.
|
/// 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.
|
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
|
||||||
/// </summary>
|
/// </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()
|
public void Swap()
|
||||||
{
|
{
|
||||||
_hasModified = true;
|
_hasModified = true;
|
||||||
@@ -291,6 +345,12 @@ public class FrameHookContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="customTexture">The custom texture to set as the active frame.</param>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
|
||||||
public void ReplaceCurrent(RenderTexture customTexture)
|
public void ReplaceCurrent(RenderTexture customTexture)
|
||||||
|
|||||||
@@ -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>).
|
/// <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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
public interface IFrameHook
|
public interface IFrameHook
|
||||||
@@ -18,6 +20,10 @@ public interface IFrameHook
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
|
/// 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>
|
/// </remarks>
|
||||||
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
|
||||||
void OnRenderFrame(FrameHookContext context);
|
void OnRenderFrame(FrameHookContext context);
|
||||||
|
|||||||
+14
-3
@@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the result and metadata of a completed or canceled render session.
|
/// Represents the result and metadata of a completed or canceled render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Passed to the completion callback registered with <see cref="RenderSession.OnComplete"/>. Immutable.
|
||||||
|
/// </remarks>
|
||||||
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
[SuppressMessage("ReSharper", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
public class RenderResult
|
public class RenderResult
|
||||||
@@ -17,7 +20,10 @@ public class RenderResult
|
|||||||
public bool Success { get; }
|
public bool Success { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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 <game root>/Render).
|
||||||
|
/// Empty string if rendering failed or was cancelled (the unfinished file is deleted).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string OutputPath { get; }
|
public string OutputPath { get; }
|
||||||
|
|
||||||
@@ -27,12 +33,13 @@ public class RenderResult
|
|||||||
public long TotalFrames { get; }
|
public long TotalFrames { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public double VideoDuration { get; }
|
public double VideoDuration { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public TimeSpan RenderDuration { get; }
|
public TimeSpan RenderDuration { get; }
|
||||||
|
|
||||||
@@ -43,6 +50,8 @@ public class RenderResult
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
|
/// 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>
|
/// </summary>
|
||||||
public string? ErrorMessage { get; }
|
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="fps">The effective frames per second used to compute video duration.</param>
|
||||||
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
|
||||||
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
|
/// <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)
|
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
|
||||||
{
|
{
|
||||||
var fileSizeBytes = 0L;
|
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="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>
|
/// <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>
|
/// <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)
|
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
|
||||||
{
|
{
|
||||||
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
|
||||||
|
|||||||
+183
-6
@@ -10,13 +10,41 @@ using UnityEngine;
|
|||||||
namespace ADOFAIRenderer.API;
|
namespace ADOFAIRenderer.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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", "UnusedType.Global")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
[SuppressMessage("ReSharper", "UnusedMember.Global")]
|
||||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
public class RenderSession
|
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
|
private sealed class GlobalHookRegistration : IDisposable
|
||||||
{
|
{
|
||||||
public object Key { get; }
|
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
|
private sealed class SessionHookEntry
|
||||||
{
|
{
|
||||||
public object Key { get; }
|
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();
|
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();
|
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;
|
private static int _globalHooksVersion;
|
||||||
|
|
||||||
|
/// <summary>Session hooks of this builder in the order they were added.</summary>
|
||||||
private readonly List<SessionHookEntry> _frameHooks = new();
|
private readonly List<SessionHookEntry> _frameHooks = new();
|
||||||
|
|
||||||
|
/// <summary>Autoplay setting copied to the render by Start (default true).</summary>
|
||||||
private bool _auto = true;
|
private bool _auto = true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -63,27 +106,48 @@ public class RenderSession
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Time-related patch controlling frame timing and clock synchronization.
|
/// 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>
|
/// </summary>
|
||||||
Time,
|
Time,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Screen-related patch controlling screen resolution.
|
/// 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>
|
/// </summary>
|
||||||
Screen
|
Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Time patch setting copied to the render by Start (default true).</summary>
|
||||||
private bool _timePatch = true;
|
private bool _timePatch = true;
|
||||||
|
|
||||||
|
/// <summary>Screen patch setting copied to the render by Start (default true).</summary>
|
||||||
private bool _screenPatch = true;
|
private bool _screenPatch = true;
|
||||||
|
|
||||||
|
/// <summary>Custom output file path, or null for the default output directory.</summary>
|
||||||
private string? _outputPath;
|
private string? _outputPath;
|
||||||
|
|
||||||
|
/// <summary>Notification setting copied to the render by Start (default true).</summary>
|
||||||
private bool _notification = true;
|
private bool _notification = true;
|
||||||
|
|
||||||
|
/// <summary>Custom end condition, or null to keep the default (level cleared).</summary>
|
||||||
private Func<bool>? _endCondition;
|
private Func<bool>? _endCondition;
|
||||||
|
|
||||||
|
/// <summary>Combined completion callbacks, or null to keep the default (notification).</summary>
|
||||||
private Action<RenderResult>? _onComplete;
|
private Action<RenderResult>? _onComplete;
|
||||||
|
|
||||||
|
/// <summary>Combined last-update callbacks, or null for none.</summary>
|
||||||
private Action? _onLastUpdate;
|
private Action? _onLastUpdate;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
@@ -115,6 +179,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the custom condition function that determines when the render session should end.
|
/// Sets the custom condition function that determines when the render session should end.
|
||||||
/// </summary>
|
/// </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>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithEndCondition(Func<bool> endCondition)
|
public RenderSession WithEndCondition(Func<bool> endCondition)
|
||||||
@@ -126,6 +197,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the custom output path for the rendered file.
|
/// Sets the custom output path for the rendered file.
|
||||||
/// </summary>
|
/// </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.<ext></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>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithOutputPath(string? outputPath)
|
public RenderSession WithOutputPath(string? outputPath)
|
||||||
@@ -137,6 +215,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets whether desktop notifications should be displayed when rendering completes.
|
/// Sets whether desktop notifications should be displayed when rendering completes.
|
||||||
/// </summary>
|
/// </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>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession WithNotification(bool enabled)
|
public RenderSession WithNotification(bool enabled)
|
||||||
@@ -148,6 +230,13 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a callback action to be invoked when the render session completes or fails.
|
/// Adds a callback action to be invoked when the render session completes or fails.
|
||||||
/// </summary>
|
/// </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>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession OnComplete(Action<RenderResult> callback)
|
public RenderSession OnComplete(Action<RenderResult> callback)
|
||||||
@@ -159,6 +248,11 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
/// Adds a callback action to be invoked on the last update of each frame in the render session.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="callback">The callback action to execute.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession OnLastUpdate(Action callback)
|
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.
|
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
|
/// <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,
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <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.
|
/// 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.
|
/// Automatically performs a GPU blit from source to destination and swaps buffers.
|
||||||
/// </summary>
|
/// </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="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>
|
/// <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>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
@@ -204,6 +315,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="hook">The hook instance to execute.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||||
@@ -239,6 +354,11 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added frame modification hook callback from this session.
|
/// Removes a previously added frame modification hook callback from this session.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="hook">The hook action to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
|
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
|
||||||
@@ -251,6 +371,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added material-based post-processing pass from this session.
|
/// Removes a previously added material-based post-processing pass from this session.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="material">The material to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(Material material)
|
public RenderSession RemoveFrameHook(Material material)
|
||||||
@@ -271,6 +395,9 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a previously added <see cref="IFrameHook"/> from this session.
|
/// Removes a previously added <see cref="IFrameHook"/> from this session.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="hook">The hook instance to remove.</param>
|
||||||
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
|
||||||
public RenderSession RemoveFrameHook(IFrameHook hook)
|
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.
|
/// Registers a global frame modification hook that will execute on every render session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
|
/// <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,
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
|
/// <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>
|
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||||
@@ -316,6 +453,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a global material-based post-processing pass that will execute on every render session.
|
/// Registers a global material-based post-processing pass that will execute on every render session.
|
||||||
/// </summary>
|
/// </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="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>
|
/// <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>
|
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
||||||
@@ -335,6 +476,9 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>. Thread-safe.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="hook">The hook instance to execute.</param>
|
/// <param name="hook">The hook instance to execute.</param>
|
||||||
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
|
/// <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>
|
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
|
||||||
@@ -353,6 +497,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global frame modification hook.
|
/// Unregisters a previously registered global frame modification hook.
|
||||||
/// </summary>
|
/// </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>
|
/// <param name="hook">The hook action to remove.</param>
|
||||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
|
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
|
||||||
@@ -373,6 +521,7 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global material-based post-processing pass.
|
/// Unregisters a previously registered global material-based post-processing pass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>Removes every registration made with <paramref name="material"/> (any pass index). Thread-safe.</remarks>
|
||||||
/// <param name="material">The material to remove.</param>
|
/// <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>
|
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(Material material)
|
public static bool RemoveGlobalFrameHook(Material material)
|
||||||
@@ -399,6 +548,7 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
|
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>Removes the first registration made with this instance (reference equality). Thread-safe.</remarks>
|
||||||
/// <param name="hook">The hook instance to remove.</param>
|
/// <param name="hook">The hook instance to remove.</param>
|
||||||
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
|
||||||
public static bool RemoveGlobalFrameHook(IFrameHook hook)
|
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)
|
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -430,6 +581,10 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clears all registered global frame modification hooks.
|
/// Clears all registered global frame modification hooks.
|
||||||
/// </summary>
|
/// </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()
|
public static void ClearGlobalFrameHooks()
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -443,7 +598,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static bool HasGlobalFrameHooks
|
public static bool HasGlobalFrameHooks
|
||||||
{
|
{
|
||||||
@@ -457,7 +612,7 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static int GlobalFrameHookCount
|
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
|
internal static int GlobalHooksVersion
|
||||||
{
|
{
|
||||||
get
|
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)
|
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
|
||||||
{
|
{
|
||||||
lock (GlobalHooksLock)
|
lock (GlobalHooksLock)
|
||||||
@@ -495,11 +652,29 @@ public class RenderSession
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies the configured flags and initiates the render session.
|
/// Applies the configured flags and initiates the render session.
|
||||||
/// </summary>
|
/// </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="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="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)
|
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.Auto = _auto;
|
||||||
RenderContext.TimePatch = _timePatch;
|
RenderContext.TimePatch = _timePatch;
|
||||||
RenderContext.ScreenPatch = _screenPatch;
|
RenderContext.ScreenPatch = _screenPatch;
|
||||||
@@ -520,7 +695,9 @@ public class RenderSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderManager.Instance.levelPath = levelPath;
|
RenderManager.Instance.levelPath = levelPath;
|
||||||
RenderManager.Instance.Configuration = renderConfig;
|
// The render uses its own copy of the configuration: values adjusted during the render
|
||||||
|
// (e.g. the audio sample rate in SetupPlay) stay in this copy and do not change the caller's object.
|
||||||
|
RenderManager.Instance.Configuration = renderConfig.Clone();
|
||||||
|
|
||||||
RenderManager.Instance.stage = RenderStage.Load;
|
RenderManager.Instance.stage = RenderStage.Load;
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,29 +6,59 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core.Data;
|
namespace ADOFAIRenderer.Core.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mod-wide settings that are not part of a render preset, stored as JSON in
|
||||||
|
/// <game root>/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
|
||||||
|
/// </summary>
|
||||||
internal class GlobalConfig
|
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; }
|
public string? OutputDirectory { 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; }
|
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; }
|
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
|
internal static class GlobalSettings
|
||||||
{
|
{
|
||||||
|
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
|
||||||
private static string GetGameRootDir()
|
private static string GetGameRootDir()
|
||||||
{
|
{
|
||||||
var root = MelonEnvironment.GameRootDirectory;
|
var root = MelonEnvironment.GameRootDirectory;
|
||||||
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary><game root>/UserData/ADOFAIRenderer/global_settings.json.</summary>
|
||||||
private static string SettingsFilePath =>
|
private static string SettingsFilePath =>
|
||||||
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
|
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();
|
public static GlobalConfig Current { get; private set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Output directory used when none is configured: <game root>/Render.</summary>
|
||||||
public static string DefaultOutputDirectory =>
|
public static string DefaultOutputDirectory =>
|
||||||
Path.Combine(GetGameRootDir(), "Render");
|
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
|
public static string EffectiveOutputDirectory
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -47,6 +77,10 @@ internal static class GlobalSettings
|
|||||||
Load();
|
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()
|
public static void Load()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -66,6 +100,10 @@ internal static class GlobalSettings
|
|||||||
Current = new GlobalConfig();
|
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()
|
public static void Save()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+30
-5
@@ -1,16 +1,33 @@
|
|||||||
|
using ADOFAIRenderer.Localization;
|
||||||
using ADOFAIRenderer.UI;
|
using ADOFAIRenderer.UI;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
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)]
|
[DefaultExecutionOrder(int.MinValue)]
|
||||||
internal class FirstUpdateHook : MonoBehaviour
|
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;
|
private bool _checkedFFmpeg;
|
||||||
|
|
||||||
|
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
|
||||||
private bool _checkedNvidiaDriver;
|
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()
|
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 (!_checkedFFmpeg)
|
||||||
{
|
{
|
||||||
if (FFmpegDownloadUI.Instance) return;
|
if (FFmpegDownloadUI.Instance) return;
|
||||||
@@ -23,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
_checkedFFmpeg = true;
|
_checkedFFmpeg = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
|
||||||
if (!_checkedNvidiaDriver)
|
if (!_checkedNvidiaDriver)
|
||||||
{
|
{
|
||||||
if (CommonWarningDialogUI.Instance) return;
|
if (CommonWarningDialogUI.Instance) return;
|
||||||
@@ -31,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
CheckNvidiaDriverWarning();
|
CheckNvidiaDriverWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
|
||||||
if (RenderManager.Instance)
|
if (RenderManager.Instance)
|
||||||
{
|
{
|
||||||
RenderManager.Instance.OnPreUpdate();
|
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()
|
private void CheckNvidiaDriverWarning()
|
||||||
{
|
{
|
||||||
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
|
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
|
||||||
@@ -45,18 +70,18 @@ internal class FirstUpdateHook : MonoBehaviour
|
|||||||
var currentVersion = driverVer.Value.ToString("F2");
|
var currentVersion = driverVer.Value.ToString("F2");
|
||||||
|
|
||||||
CommonWarningDialogUI.ShowDialog(
|
CommonWarningDialogUI.ShowDialog(
|
||||||
"NVIDIA Graphics Driver Update Recommended",
|
LocalizationManager.Get("ui.driver_warning.title"),
|
||||||
$"Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{currentVersion}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
LocalizationManager.Get("ui.driver_warning.body", currentVersion),
|
||||||
new DialogButton("Update Driver", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_update"), () =>
|
||||||
{
|
{
|
||||||
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
|
||||||
Application.Quit();
|
Application.Quit();
|
||||||
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
|
||||||
new DialogButton("Disable Feature", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_disable"), () =>
|
||||||
{
|
{
|
||||||
RenderMod.IsFeatureDisabled = true;
|
RenderMod.IsFeatureDisabled = true;
|
||||||
}),
|
}),
|
||||||
new DialogButton("Continue Anyway", () =>
|
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_continue"), () =>
|
||||||
{
|
{
|
||||||
// ignored
|
// ignored
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,16 +6,58 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
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
|
internal class FrameHookPipeline : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||||
private RenderTexture? _pingRT;
|
private RenderTexture? _pingRT;
|
||||||
|
|
||||||
|
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
|
||||||
private RenderTexture? _pongRT;
|
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;
|
private RenderTexture? _lastOutputRT;
|
||||||
|
|
||||||
|
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
|
||||||
private FrameHookContext? _reusableContext;
|
private FrameHookContext? _reusableContext;
|
||||||
|
|
||||||
|
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||||
private int _width;
|
private int _width;
|
||||||
|
|
||||||
|
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
|
||||||
private int _height;
|
private int _height;
|
||||||
|
|
||||||
|
/// <summary>Format of the source texture the current buffers were created for.</summary>
|
||||||
private RenderTextureFormat _format;
|
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(
|
public RenderTexture? Process(
|
||||||
RenderTexture sourceTexture,
|
RenderTexture sourceTexture,
|
||||||
long frameIndex,
|
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;
|
var result = _reusableContext.Current;
|
||||||
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
_lastOutputRT = _reusableContext.HasModified ? result : null;
|
||||||
return result;
|
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)
|
private void EnsureBuffers(RenderTexture sourceTexture)
|
||||||
{
|
{
|
||||||
var width = sourceTexture.width;
|
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()
|
public void ReleaseBuffers()
|
||||||
{
|
{
|
||||||
if (_pingRT)
|
if (_pingRT)
|
||||||
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
|
|||||||
_reusableContext = null;
|
_reusableContext = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
ReleaseBuffers();
|
ReleaseBuffers();
|
||||||
|
|||||||
@@ -5,10 +5,22 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
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)]
|
[DefaultExecutionOrder(int.MaxValue)]
|
||||||
internal class PostRenderHook : MonoBehaviour
|
internal class PostRenderHook : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
|
||||||
private FieldInfo? _camQuadMeshField;
|
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 MeshRenderer? _camQuadMeshRenderer;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
|
|||||||
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
|
_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()
|
private void LateUpdate()
|
||||||
{
|
{
|
||||||
if (RenderManager.Instance)
|
if (RenderManager.Instance)
|
||||||
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
|
|||||||
PostFrameAsync().Forget();
|
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()
|
private async UniTaskVoid PostFrameAsync()
|
||||||
{
|
{
|
||||||
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
|
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
|
||||||
|
|||||||
+51
-1
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Core;
|
namespace ADOFAIRenderer.Core;
|
||||||
|
|
||||||
|
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
|
||||||
public class RenderPreset
|
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;
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Render configuration of the preset.</summary>
|
||||||
public RenderConfig Config { get; set; } = null!;
|
public RenderConfig Config { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>Returns a copy with a copied configuration.</summary>
|
||||||
public RenderPreset Clone()
|
public RenderPreset Clone()
|
||||||
{
|
{
|
||||||
return new RenderPreset
|
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 <game root>/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
|
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()
|
private static readonly List<RenderPreset> DefaultPresets = new()
|
||||||
{
|
{
|
||||||
new RenderPreset
|
new RenderPreset
|
||||||
@@ -96,6 +111,10 @@ public static class PresetManager
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <root>/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()
|
private static string GetPresetsDirectory()
|
||||||
{
|
{
|
||||||
string? root = null;
|
string? root = null;
|
||||||
@@ -116,11 +135,19 @@ public static class PresetManager
|
|||||||
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetSafeFileName(string name)
|
/// <summary>
|
||||||
|
/// File name (without extension) of the preset file for the given preset name: every character that is invalid
|
||||||
|
/// in a file name is replaced with '_'. Different preset names can map to the same file name (e.g. "a/b" and "a_b").
|
||||||
|
/// </summary>
|
||||||
|
internal static string GetSafeFileName(string name)
|
||||||
{
|
{
|
||||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
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()
|
public static void EnsureDefaultPresetsCreated()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -160,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)
|
public static void SavePreset(string name, RenderConfig config)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name))
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
@@ -191,6 +224,13 @@ public static class PresetManager
|
|||||||
File.WriteAllText(filePath, json);
|
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()
|
public static List<RenderPreset> GetPresets()
|
||||||
{
|
{
|
||||||
EnsureDefaultPresetsCreated();
|
EnsureDefaultPresetsCreated();
|
||||||
@@ -328,6 +368,10 @@ public static class PresetManager
|
|||||||
return presets;
|
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)
|
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
|
||||||
{
|
{
|
||||||
var legacyToken = configObj[keyBefore];
|
var legacyToken = configObj[keyBefore];
|
||||||
@@ -337,6 +381,12 @@ public static class PresetManager
|
|||||||
configObj.Remove(keyBefore);
|
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)
|
public static void DeletePreset(string name)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(name)) return;
|
if (string.IsNullOrWhiteSpace(name)) return;
|
||||||
|
|||||||
+789
-153
File diff suppressed because it is too large.
Load diff
@@ -4,14 +4,22 @@ using FFmpeg.AutoGen;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public enum Codec
|
public enum Codec
|
||||||
{
|
{
|
||||||
H264, H265, VP9, AV1, VVC
|
H264, H265, VP9, AV1, VVC
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Audio codec choice and supported sample rates per video codec.</summary>
|
||||||
internal static class CodecUtil
|
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()
|
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
|
||||||
{
|
{
|
||||||
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
|
||||||
@@ -21,6 +29,10 @@ internal static class CodecUtil
|
|||||||
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
|
[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()
|
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
|
||||||
{
|
{
|
||||||
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
|
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
|
||||||
|
|||||||
@@ -10,13 +10,20 @@ namespace ADOFAIRenderer.Data;
|
|||||||
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
|
/// 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).
|
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
|
||||||
/// </summary>
|
/// </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
|
public class ColorJsonConverter : JsonConverter
|
||||||
{
|
{
|
||||||
|
/// <summary>True for <see cref="Color"/> and <see cref="Nullable{Color}"/>.</summary>
|
||||||
public override bool CanConvert(Type objectType)
|
public override bool CanConvert(Type objectType)
|
||||||
{
|
{
|
||||||
return objectType == typeof(Color) || objectType == typeof(Color?);
|
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)
|
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||||
{
|
{
|
||||||
if (value == null)
|
if (value == null)
|
||||||
@@ -29,6 +36,7 @@ public class ColorJsonConverter : JsonConverter
|
|||||||
writer.WriteValue(ToHex(color));
|
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)
|
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||||
{
|
{
|
||||||
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
|
||||||
@@ -62,6 +70,10 @@ public class ColorJsonConverter : JsonConverter
|
|||||||
return isNullable ? null : (object)default(Color);
|
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)
|
public static string ToHex(Color color)
|
||||||
{
|
{
|
||||||
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
|
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}";
|
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)
|
public static bool TryParseHex(string? hex, out Color color)
|
||||||
{
|
{
|
||||||
color = default;
|
color = default;
|
||||||
|
|||||||
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
namespace ADOFAIRenderer.Data;
|
||||||
|
|
||||||
|
/// <summary>Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings).</summary>
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public enum RateControlMode
|
public enum RateControlMode
|
||||||
{
|
{
|
||||||
|
/// <summary>Constant bitrate at VideoEncodingConfig.Bitrate (kbps).</summary>
|
||||||
CBR,
|
CBR,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger,
|
||||||
|
/// otherwise at 1.5 × Bitrate.
|
||||||
|
/// </summary>
|
||||||
VBR,
|
VBR,
|
||||||
|
|
||||||
|
/// <summary>Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder).</summary>
|
||||||
CQP
|
CQP
|
||||||
}
|
}
|
||||||
@@ -4,22 +4,50 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Data;
|
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")]
|
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||||
public class RenderConfig
|
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; }
|
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; }
|
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; }
|
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; }
|
public int RenderHeight { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Video encoder settings.</summary>
|
||||||
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
public VideoEncodingConfig VideoConfig { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Audio rendering and encoder settings.</summary>
|
||||||
public AudioEncodingConfig AudioConfig { get; set; } = new();
|
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();
|
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;
|
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;
|
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)
|
public void CopyFrom(RenderConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -35,6 +63,7 @@ public class RenderConfig
|
|||||||
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
AdvancedConfig.CopyFrom(source.AdvancedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
|
||||||
public RenderConfig Clone()
|
public RenderConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new RenderConfig();
|
var clone = new RenderConfig();
|
||||||
@@ -43,23 +72,48 @@ public class RenderConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Advanced render options.</summary>
|
||||||
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
|
||||||
public class AdvancedConfig
|
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;
|
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;
|
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;
|
public bool NonTile { 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";
|
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))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? firePlanetColor = null;
|
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))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? icePlanetColor = null;
|
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))]
|
[JsonConverter(typeof(ColorJsonConverter))]
|
||||||
public Color? windPlanetColor = null;
|
public Color? windPlanetColor = null;
|
||||||
|
|
||||||
|
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
|
||||||
public void CopyFrom(AdvancedConfig? source)
|
public void CopyFrom(AdvancedConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -72,6 +126,7 @@ public class AdvancedConfig
|
|||||||
windPlanetColor = source.windPlanetColor;
|
windPlanetColor = source.windPlanetColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public AdvancedConfig Clone()
|
public AdvancedConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new AdvancedConfig();
|
var clone = new AdvancedConfig();
|
||||||
@@ -80,18 +135,35 @@ public class AdvancedConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
|
||||||
public class VideoEncodingConfig
|
public class VideoEncodingConfig
|
||||||
{
|
{
|
||||||
|
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
|
||||||
public Codec Codec { get; set; } = Codec.H264;
|
public Codec Codec { get; set; } = Codec.H264;
|
||||||
|
|
||||||
|
/// <summary>Bitrate control mode (default CBR).</summary>
|
||||||
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
|
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;
|
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;
|
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
|
// ReSharper disable once InconsistentNaming
|
||||||
public int QP { get; set; } = 18;
|
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;
|
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; }
|
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)
|
public void CopyFrom(VideoEncodingConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -104,6 +176,7 @@ public class VideoEncodingConfig
|
|||||||
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public VideoEncodingConfig Clone()
|
public VideoEncodingConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new VideoEncodingConfig();
|
var clone = new VideoEncodingConfig();
|
||||||
@@ -112,32 +185,44 @@ public class VideoEncodingConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
public class AudioEncodingConfig
|
||||||
{
|
{
|
||||||
private int _audioSampleRate = 48000;
|
private int _audioSampleRate = 48000;
|
||||||
private int _audioChannels = 2;
|
private int _audioChannels = 2;
|
||||||
private int _audioBitrate = 256000;
|
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;
|
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
|
public int AudioSampleRate
|
||||||
{
|
{
|
||||||
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
|
||||||
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
set => _audioSampleRate = value <= 0 ? 48000 : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of output audio channels (default 2).</summary>
|
||||||
public int AudioChannels
|
public int AudioChannels
|
||||||
{
|
{
|
||||||
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
get => _audioChannels <= 0 ? 2 : _audioChannels;
|
||||||
set => _audioChannels = value <= 0 ? 2 : value;
|
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
|
public int AudioBitrate
|
||||||
{
|
{
|
||||||
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
|
||||||
set => _audioBitrate = value <= 0 ? 256000 : value;
|
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)
|
public void CopyFrom(AudioEncodingConfig? source)
|
||||||
{
|
{
|
||||||
if (source == null) return;
|
if (source == null) return;
|
||||||
@@ -147,6 +232,7 @@ public class AudioEncodingConfig
|
|||||||
AudioBitrate = source.AudioBitrate;
|
AudioBitrate = source.AudioBitrate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns a copy of this object.</summary>
|
||||||
public AudioEncodingConfig Clone()
|
public AudioEncodingConfig Clone()
|
||||||
{
|
{
|
||||||
var clone = new AudioEncodingConfig();
|
var clone = new AudioEncodingConfig();
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Legal Terms (Official)
|
|
||||||
|
|
||||||
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
|
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
|
||||||
|
|
||||||
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
|
As a special exception, the copyright holders of this Program give you permission to dynamically link this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
|
||||||
@@ -22,21 +20,3 @@ An **"independent module"** is a separate and distinct software module that is n
|
|||||||
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
|
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
|
||||||
|
|
||||||
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 조항 해설 (Korean Guide)
|
|
||||||
|
|
||||||
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
|
|
||||||
|
|
||||||
### 핵심 요약
|
|
||||||
|
|
||||||
- **동적 링킹(Dynamic Linking)만 허용**:
|
|
||||||
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
|
|
||||||
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
|
|
||||||
- **외부 모드의 라이선스 자유**:
|
|
||||||
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
|
|
||||||
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
|
|
||||||
- **ADOFAIRenderer 코어 보호**:
|
|
||||||
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
|
|
||||||
|
|
||||||
@@ -45,10 +45,33 @@
|
|||||||
"ui.render_config.random_seed": "Random Seed (Optional)",
|
"ui.render_config.random_seed": "Random Seed (Optional)",
|
||||||
"ui.render_config.validation_title": "Invalid Settings",
|
"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",
|
"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",
|
||||||
|
"ui.render_config.validation_even_size": "Render width and height must be even numbers (current: {0} x {1}).",
|
||||||
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
"ui.render_config.invalid_path": "Output directory path is invalid:\n{0}",
|
||||||
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
"ui.render_config.planet_colors": "Planet Colors (Optional)",
|
||||||
"ui.render_config.fire_planet": "Fire Planet",
|
"ui.render_config.fire_planet": "Fire Planet",
|
||||||
"ui.render_config.ice_planet": "Ice Planet",
|
"ui.render_config.ice_planet": "Ice Planet",
|
||||||
"ui.render_config.wind_planet": "Wind Planet",
|
"ui.render_config.wind_planet": "Wind Planet",
|
||||||
"ui.render_config.expired": "(Expired)"
|
"ui.render_config.expired": "(Expired)",
|
||||||
|
"ui.ffmpeg_download.title": "FFmpeg Library Installation Required",
|
||||||
|
"ui.ffmpeg_download.desc_required": "FFmpeg shared libraries are required to render video.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_build": "For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_open": "Click the button below to open the official FFmpeg website.",
|
||||||
|
"ui.ffmpeg_download.desc_download": "Click the button below to automatically download and install from the internet.",
|
||||||
|
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>",
|
||||||
|
"ui.ffmpeg_download.btn_download": "Download & Install",
|
||||||
|
"ui.ffmpeg_download.btn_open_site": "Open Website",
|
||||||
|
"ui.ffmpeg_download.btn_cancel": "Cancel",
|
||||||
|
"ui.ffmpeg_download.btn_retry": "Retry",
|
||||||
|
"ui.ffmpeg_download.status_starting": "Starting download...",
|
||||||
|
"ui.ffmpeg_download.status_downloading": "Downloading... ({0}%)",
|
||||||
|
"ui.ffmpeg_download.status_extracting": "Extracting and copying library files...",
|
||||||
|
"ui.ffmpeg_download.progress_extracting": "Extracting...",
|
||||||
|
"ui.ffmpeg_download.status_complete": "FFmpeg installation complete!",
|
||||||
|
"ui.ffmpeg_download.status_cancelled": "Download was cancelled.",
|
||||||
|
"ui.ffmpeg_download.status_failed": "Installation failed: {0}",
|
||||||
|
"ui.driver_warning.title": "NVIDIA Graphics Driver Update Recommended",
|
||||||
|
"ui.driver_warning.body": "Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{0}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
|
||||||
|
"ui.driver_warning.btn_update": "Update Driver",
|
||||||
|
"ui.driver_warning.btn_disable": "Disable Feature",
|
||||||
|
"ui.driver_warning.btn_continue": "Continue Anyway"
|
||||||
}
|
}
|
||||||
@@ -45,10 +45,33 @@
|
|||||||
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
|
||||||
"ui.render_config.validation_title": "설정 오류",
|
"ui.render_config.validation_title": "설정 오류",
|
||||||
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
|
||||||
|
"ui.render_config.validation_even_size": "렌더 해상도의 가로와 세로는 짝수여야 해요. (현재: {0} x {1})",
|
||||||
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
"ui.render_config.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
|
||||||
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
"ui.render_config.planet_colors": "행성 색상 설정 (선택 사항)",
|
||||||
"ui.render_config.fire_planet": "불 행성",
|
"ui.render_config.fire_planet": "불 행성",
|
||||||
"ui.render_config.ice_planet": "얼음 행성",
|
"ui.render_config.ice_planet": "얼음 행성",
|
||||||
"ui.render_config.wind_planet": "바람 행성",
|
"ui.render_config.wind_planet": "바람 행성",
|
||||||
"ui.render_config.expired": "(만료됨)"
|
"ui.render_config.expired": "(만료됨)",
|
||||||
|
"ui.ffmpeg_download.title": "FFmpeg 라이브러리 설치 필요",
|
||||||
|
"ui.ffmpeg_download.desc_required": "영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요해요.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_build": "macOS는 신뢰할 수 있는 공개 빌드가 없어서 FFmpeg 8.1을 직접 빌드해야 해요.",
|
||||||
|
"ui.ffmpeg_download.desc_mac_open": "아래 버튼을 누르면 FFmpeg 공식 웹사이트가 열려요.",
|
||||||
|
"ui.ffmpeg_download.desc_download": "아래 버튼을 누르면 인터넷에서 자동으로 내려받아 설치해요.",
|
||||||
|
"ui.ffmpeg_download.desc_size": "<color=#aaaaaa><size=13>(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제)</size></color>",
|
||||||
|
"ui.ffmpeg_download.btn_download": "다운로드 및 설치",
|
||||||
|
"ui.ffmpeg_download.btn_open_site": "웹사이트 열기",
|
||||||
|
"ui.ffmpeg_download.btn_cancel": "취소",
|
||||||
|
"ui.ffmpeg_download.btn_retry": "다시 시도",
|
||||||
|
"ui.ffmpeg_download.status_starting": "다운로드를 시작하는 중...",
|
||||||
|
"ui.ffmpeg_download.status_downloading": "다운로드 중... ({0}%)",
|
||||||
|
"ui.ffmpeg_download.status_extracting": "라이브러리 파일을 압축 해제하고 복사하는 중...",
|
||||||
|
"ui.ffmpeg_download.progress_extracting": "압축 해제 중...",
|
||||||
|
"ui.ffmpeg_download.status_complete": "FFmpeg 설치가 끝났어요!",
|
||||||
|
"ui.ffmpeg_download.status_cancelled": "다운로드가 취소되었어요.",
|
||||||
|
"ui.ffmpeg_download.status_failed": "설치에 실패했어요: {0}",
|
||||||
|
"ui.driver_warning.title": "NVIDIA 그래픽 드라이버 업데이트 권장",
|
||||||
|
"ui.driver_warning.body": "현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{0}</color></b>\n\n하드웨어 가속(NVENC)으로 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요해요.\n\n드라이버 버전이 더 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 바뀌어 렌더링이 훨씬 느려질 수 있어요. 그래픽 드라이버를 업데이트해 주세요.",
|
||||||
|
"ui.driver_warning.btn_update": "드라이버 업데이트",
|
||||||
|
"ui.driver_warning.btn_disable": "기능 끄기",
|
||||||
|
"ui.driver_warning.btn_continue": "그래도 계속"
|
||||||
}
|
}
|
||||||
@@ -9,29 +9,49 @@ namespace ADOFAIRenderer.Localization;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides access to localized strings and handles language synchronization with the game.
|
/// 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>
|
/// </summary>
|
||||||
internal static class LocalizationManager
|
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);
|
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";
|
private static readonly string FallbackLocaleCode = "en";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Event triggered when the active language changes.
|
/// 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>
|
/// </summary>
|
||||||
public static event Action<SystemLanguage>? OnLanguageChanged;
|
public static event Action<SystemLanguage>? OnLanguageChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
|
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static string CurrentLocaleCode { get; private set; } = "en";
|
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;
|
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()
|
static LocalizationManager()
|
||||||
{
|
{
|
||||||
Initialize();
|
Initialize();
|
||||||
@@ -39,6 +59,8 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the localization system and loads embedded language dictionaries.
|
/// 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>
|
/// </summary>
|
||||||
public static void Initialize()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
@@ -47,19 +69,26 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
LoadEmbeddedLocales();
|
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();
|
var initialLang = DetectGameLanguage();
|
||||||
SetLanguage(initialLang, notify: false);
|
SetLanguage(initialLang, notify: false);
|
||||||
|
|
||||||
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
|
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)
|
private static void LogMsg(string msg)
|
||||||
{
|
{
|
||||||
try { Melon<RenderMod>.Logger.Msg(msg); }
|
try { Melon<RenderMod>.Logger.Msg(msg); }
|
||||||
catch { Console.WriteLine(msg); }
|
catch { Console.WriteLine(msg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Error counterpart of <see cref="LogMsg"/>; falls back to standard error.</summary>
|
||||||
private static void LogError(string msg)
|
private static void LogError(string msg)
|
||||||
{
|
{
|
||||||
try { Melon<RenderMod>.Logger.Error(msg); }
|
try { Melon<RenderMod>.Logger.Error(msg); }
|
||||||
@@ -68,6 +97,9 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads all *.json locale files embedded in this assembly.
|
/// 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>
|
/// </summary>
|
||||||
private static void LoadEmbeddedLocales()
|
private static void LoadEmbeddedLocales()
|
||||||
{
|
{
|
||||||
@@ -109,7 +141,13 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the active language and notifies subscribers if changed.
|
/// 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>
|
/// </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)
|
public static void SetLanguage(SystemLanguage language, bool notify = true)
|
||||||
{
|
{
|
||||||
var localeCode = LanguageToLocaleCode(language);
|
var localeCode = LanguageToLocaleCode(language);
|
||||||
@@ -140,7 +178,10 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a localized string for the specified key.
|
/// 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>
|
/// </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)
|
public static string Get(string key)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(key)) return string.Empty;
|
if (string.IsNullOrEmpty(key)) return string.Empty;
|
||||||
@@ -166,7 +207,12 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves a formatted localized string.
|
/// 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>
|
/// </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)
|
public static string Get(string key, params object[] args)
|
||||||
{
|
{
|
||||||
var template = Get(key);
|
var template = Get(key);
|
||||||
@@ -184,6 +230,7 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps Unity's SystemLanguage enum to an internal locale code.
|
/// 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>
|
/// </summary>
|
||||||
private static string LanguageToLocaleCode(SystemLanguage language)
|
private static string LanguageToLocaleCode(SystemLanguage language)
|
||||||
{
|
{
|
||||||
@@ -196,19 +243,22 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to read the game's current language from RDString or Persistence.
|
/// Attempts to read the game's current language from RDString or Persistence.
|
||||||
|
/// Falls back to English when both reads throw.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static SystemLanguage DetectGameLanguage()
|
private static SystemLanguage DetectGameLanguage()
|
||||||
{
|
{
|
||||||
try
|
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;
|
return RDString.language;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Fallback attempt: Persistence.language
|
// Fallback attempt: Persistence.language, the saved "language" preference (defaults to
|
||||||
|
// Application.systemLanguage)
|
||||||
return Persistence.language;
|
return Persistence.language;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -221,6 +271,7 @@ internal static class LocalizationManager
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
|
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
|
||||||
|
/// Every member forwards to the member of the same name in <see cref="LocalizationManager"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
internal static class I18n
|
internal static class I18n
|
||||||
|
|||||||
@@ -3,10 +3,31 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Engine;
|
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 >= 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")]
|
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class AsyncInputPatches
|
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]
|
[HarmonyPrefix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix()
|
public static bool Prefix()
|
||||||
|
|||||||
+282
-27
@@ -1,25 +1,61 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using HarmonyLib;
|
using HarmonyLib;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.Audio;
|
using UnityEngine.Audio;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Engine;
|
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")]
|
[HarmonyPatch(typeof(AudioManager), "_Play")]
|
||||||
public static class AudioManagerPlayPatch
|
public static class AudioManagerPlayPatch
|
||||||
{
|
{
|
||||||
|
/// <summary>Opens the clip's source so its first chunk starts loading. Returns true (the original runs).</summary>
|
||||||
[HarmonyPrefix]
|
[HarmonyPrefix]
|
||||||
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
|
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
|
||||||
{
|
{
|
||||||
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
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);
|
RenderManager.Instance.AudioMixer?.CacheClip(snd);
|
||||||
}
|
}
|
||||||
return true; // Let the original method execute to prevent NullReferenceException
|
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]
|
[HarmonyPostfix]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
|
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;
|
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
|
// 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")]
|
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
|
||||||
public static class PlayWithEndTimePatch
|
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]
|
[HarmonyPrefix]
|
||||||
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
|
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;
|
var mixer = RenderManager.Instance.AudioMixer;
|
||||||
if (mixer != null)
|
if (mixer != null)
|
||||||
{
|
{
|
||||||
// Cache the clip on the main thread
|
// Cache the clip on the main Unity thread
|
||||||
mixer.CacheClip(snd);
|
mixer.CacheClip(snd);
|
||||||
|
|
||||||
// Convert absolute DSP times to relative times in seconds from render start
|
// 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;
|
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
|
||||||
|
|
||||||
// Register looping sound event to the offline mixer (default pitch = 1.0f)
|
// 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
|
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)]
|
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
|
||||||
public static class AudioSourcePitchPatch
|
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
|
// 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)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetPitch(value, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.Pitch = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
|
||||||
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
|
||||||
public static class AudioSourceSetScheduledEndTimePatch
|
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
|
// 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)
|
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
|
||||||
{
|
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
|
||||||
var mixer = RenderManager.Instance.AudioMixer;
|
return false;
|
||||||
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
|
|
||||||
{
|
|
||||||
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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])]
|
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
|
||||||
public static class AudioSourceStopPatch
|
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
|
// 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)
|
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
|
||||||
{
|
{
|
||||||
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
|
RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,40 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Engine;
|
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 >= 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
|
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;
|
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)]
|
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
|
||||||
public static class TimePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref float __result)
|
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)]
|
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
|
||||||
public static class TimeAsDoublePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref double __result)
|
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)]
|
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
|
||||||
public static class UnscaledTimePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref float __result)
|
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)]
|
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
|
||||||
public static class UnscaledTimeAsDoublePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref double __result)
|
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)]
|
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
|
||||||
public static class DspTimePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref double __result)
|
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 >= 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")]
|
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
|
||||||
public static class PlayScheduledPatch
|
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
|
// ReSharper disable once InconsistentNaming
|
||||||
public static bool Prefix(AudioSource __instance, double time)
|
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 >= Render) with <c>RenderContext.TimePatch</c> on.
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
|
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
|
||||||
public static class FrameCountPatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref int __result)
|
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)]
|
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
|
||||||
public static class DeltaTimePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref float __result)
|
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)]
|
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
|
||||||
public static class UnscaledDeltaTimePatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref float __result)
|
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 >= 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)]
|
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
|
||||||
public static class ScreenWidthPatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref int __result)
|
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)]
|
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
|
||||||
public static class ScreenHeightPatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(ref int __result)
|
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 >= 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))]
|
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
|
||||||
public static class GetSpectrumDataPatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
|
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))]
|
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
|
||||||
public static class FindOrLoadAudioClipExternalPatch
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
public static void Prefix(ref bool stream)
|
public static void Prefix(ref bool stream)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using ADOFAIRenderer.Core;
|
||||||
|
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 >= 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
|
||||||
|
public static bool PreventShow(DetailedResults __instance)
|
||||||
|
{
|
||||||
|
if (!RenderManager.IsActive) return true;
|
||||||
|
|
||||||
|
if (__instance)
|
||||||
|
{
|
||||||
|
__instance.enabled = false;
|
||||||
|
if (__instance.gameObject) __instance.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,29 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= 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))]
|
[HarmonyPatch(typeof(EndscreenLanterns))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class EndscreenLanternsPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("Show")]
|
[HarmonyPatch("Show")]
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
@@ -22,6 +41,21 @@ public static class EndscreenLanternsPatch
|
|||||||
return false; // Skip original Show method
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("Hide")]
|
[HarmonyPatch("Hide")]
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ using UnityEngine;
|
|||||||
namespace ADOFAIRenderer.Patch.Game;
|
namespace ADOFAIRenderer.Patch.Game;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(RDString))]
|
[HarmonyPatch(typeof(RDString))]
|
||||||
internal static class LanguageSyncPatch
|
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))]
|
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
|
||||||
[HarmonyPostfix]
|
[HarmonyPostfix]
|
||||||
public static void OnChangeLanguage(SystemLanguage language)
|
public static void OnChangeLanguage(SystemLanguage language)
|
||||||
@@ -17,6 +25,10 @@ internal static class LanguageSyncPatch
|
|||||||
LocalizationManager.SetLanguage(language);
|
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))]
|
[HarmonyPatch(nameof(RDString.Setup))]
|
||||||
[HarmonyPostfix]
|
[HarmonyPostfix]
|
||||||
public static void OnSetup()
|
public static void OnSetup()
|
||||||
|
|||||||
@@ -3,9 +3,23 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(Persistence))]
|
||||||
public static class PersistencePatch
|
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 >= 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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
|
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
|
||||||
// ReSharper disable once InconsistentNaming
|
// 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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("GetPlayerColor")]
|
[HarmonyPatch("GetPlayerColor")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
|||||||
@@ -3,9 +3,21 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(PlanetarySystem))]
|
||||||
public static class PlanetarySystemPatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("Init")]
|
[HarmonyPatch("Init")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
@@ -14,6 +26,8 @@ public static class PlanetarySystemPatch
|
|||||||
ApplyWindPlanetColor(__instance);
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("ApplyMultiplanetColors")]
|
[HarmonyPatch("ApplyMultiplanetColors")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
@@ -22,6 +36,11 @@ public static class PlanetarySystemPatch
|
|||||||
ApplyWindPlanetColor(__instance);
|
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)
|
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
|
||||||
{
|
{
|
||||||
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
|
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
|
||||||
|
|||||||
@@ -6,10 +6,28 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scnEditor))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scnEditorPatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("Start")]
|
[HarmonyPatch("Start")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
@@ -38,8 +56,9 @@ public static class scnEditorPatch
|
|||||||
var images = buttonObj.GetComponentsInChildren<Image>();
|
var images = buttonObj.GetComponentsInChildren<Image>();
|
||||||
if (images.Length <= 1) return;
|
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
|
// 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");
|
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
|
||||||
if (sprite == null) return;
|
if (sprite == null) return;
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,26 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scnGame))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scnGamePatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("Play")]
|
[HarmonyPatch("Play")]
|
||||||
public static void ProcessFloors()
|
public static void ProcessFloors()
|
||||||
@@ -47,6 +63,7 @@ public static class scnGamePatch
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Disables every renderer that draws one planet (fire, ice, extra planets and floor dummy planets).
|
||||||
void DisablePlanetRenderer(PlanetRenderer planet)
|
void DisablePlanetRenderer(PlanetRenderer planet)
|
||||||
{
|
{
|
||||||
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
|
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
|
||||||
@@ -62,12 +79,29 @@ public static class scnGamePatch
|
|||||||
foreach (var renderer in renderers) DisableRenderer(renderer);
|
foreach (var renderer in renderers) DisableRenderer(renderer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets Renderer.enabled = false; null or destroyed renderers are skipped (Unity null check).
|
||||||
void DisableRenderer(Renderer? renderer)
|
void DisableRenderer(Renderer? renderer)
|
||||||
{
|
{
|
||||||
if (renderer) renderer.enabled = false;
|
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 >= 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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("UpdateVideo")]
|
[HarmonyPatch("UpdateVideo")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
@@ -109,7 +143,8 @@ public static class scnGamePatch
|
|||||||
return true; // Let the original print error
|
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;
|
var targetCamera = __instance.videoBG.targetCamera;
|
||||||
if (!targetCamera)
|
if (!targetCamera)
|
||||||
{
|
{
|
||||||
@@ -120,6 +155,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;
|
__instance.videoBG.enabled = false;
|
||||||
|
|
||||||
// Add or retrieve our custom FFmpeg video background player
|
// Add or retrieve our custom FFmpeg video background player
|
||||||
|
|||||||
@@ -3,16 +3,37 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scrCamera))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public class scrCameraPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("SetCustomFrameRate")]
|
[HarmonyPatch("SetCustomFrameRate")]
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
|
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;
|
if (enable || __instance.enableCustomFPS != enable) return true;
|
||||||
|
|
||||||
__instance.enableCustomFPS = enable;
|
__instance.enableCustomFPS = enable;
|
||||||
|
|||||||
@@ -3,10 +3,25 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= 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))]
|
[HarmonyPatch(typeof(scrController))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scrControllerPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("TogglePauseGame")]
|
[HarmonyPatch("TogglePauseGame")]
|
||||||
public static bool PreventPause()
|
public static bool PreventPause()
|
||||||
@@ -14,6 +29,12 @@ public static class scrControllerPatch
|
|||||||
return !RenderManager.IsActive;
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("PortalTravelAction")]
|
[HarmonyPatch("PortalTravelAction")]
|
||||||
public static bool PreventPortalTravel()
|
public static bool PreventPortalTravel()
|
||||||
@@ -21,6 +42,11 @@ public static class scrControllerPatch
|
|||||||
return !RenderManager.IsActive;
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("FailAction")]
|
[HarmonyPatch("FailAction")]
|
||||||
public static bool PreventFailAction()
|
public static bool PreventFailAction()
|
||||||
@@ -28,6 +54,11 @@ public static class scrControllerPatch
|
|||||||
return !RenderManager.IsActive;
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("Fail2Action")]
|
[HarmonyPatch("Fail2Action")]
|
||||||
public static bool PreventFail2Action()
|
public static bool PreventFail2Action()
|
||||||
@@ -35,10 +66,60 @@ public static class scrControllerPatch
|
|||||||
return !RenderManager.IsActive;
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("OnPlayerDied")]
|
[HarmonyPatch("OnPlayerDied")]
|
||||||
public static bool PreventPlayerDied()
|
public static bool PreventPlayerDied()
|
||||||
{
|
{
|
||||||
return !RenderManager.IsActive;
|
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)
|
||||||
|
{
|
||||||
|
if (!RenderManager.IsActive || __instance == null) return;
|
||||||
|
|
||||||
|
if (__instance.txtCongrats)
|
||||||
|
{
|
||||||
|
__instance.txtCongrats.text = string.Empty;
|
||||||
|
__instance.txtCongrats.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.txtAprilCongrats)
|
||||||
|
{
|
||||||
|
__instance.txtAprilCongrats.text = string.Empty;
|
||||||
|
__instance.txtAprilCongrats.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.txtAllStrictClear)
|
||||||
|
{
|
||||||
|
__instance.txtAllStrictClear.text = string.Empty;
|
||||||
|
__instance.txtAllStrictClear.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__instance.detailedResults)
|
||||||
|
{
|
||||||
|
__instance.detailedResults.enabled = false;
|
||||||
|
if (__instance.detailedResults.gameObject != null)
|
||||||
|
{
|
||||||
|
__instance.detailedResults.gameObject.SetActive(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,25 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scrFloor))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public class scrFloorPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("SpawnPortalParticles")]
|
[HarmonyPatch("SpawnPortalParticles")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
|||||||
@@ -3,10 +3,20 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= OnScene) and <c>RenderConfig.ShowJudgementText</c> is false.</para>
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
|
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public class scrHitTextManagerPatch
|
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()
|
public static bool Prefix()
|
||||||
{
|
{
|
||||||
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
|
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
|
||||||
|
|||||||
@@ -3,10 +3,25 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= 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))]
|
[HarmonyPatch(typeof(scrMistakesManager))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scrMistakesManagerPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("SaveCustom")]
|
[HarmonyPatch("SaveCustom")]
|
||||||
public static bool PreventSaveCustom()
|
public static bool PreventSaveCustom()
|
||||||
|
|||||||
@@ -3,10 +3,27 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= 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))]
|
[HarmonyPatch(typeof(scrPlayerManager))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scrPlayerManagerPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("AnyValidInputWasTriggered")]
|
[HarmonyPatch("AnyValidInputWasTriggered")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
|||||||
@@ -4,10 +4,26 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scrPortalParticles))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scrPortalParticlesPatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("Start")]
|
[HarmonyPatch("Start")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
|||||||
@@ -3,10 +3,26 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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))]
|
[HarmonyPatch(typeof(scrPressToStart))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public class scrPressToStartPatch
|
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]
|
[HarmonyPrefix]
|
||||||
[HarmonyPatch("ShowText")]
|
[HarmonyPatch("ShowText")]
|
||||||
private static bool PreventShowText()
|
private static bool PreventShowText()
|
||||||
@@ -14,6 +30,12 @@ public class scrPressToStartPatch
|
|||||||
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
|
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
|
||||||
|
|
||||||
RenderManager.Instance.skipPressToStart = false;
|
RenderManager.Instance.skipPressToStart = false;
|
||||||
|
|
||||||
|
// scrPressToStart.ShowText ends with ShowDifficultyContainer(mode). Skipping ShowText skips that call too,
|
||||||
|
// which leaves the difficulty selector active and clickable. DontShow deactivates difficultyContainer
|
||||||
|
// and makes the keyboard difficulty shortcuts (difficultyUIMode != DontShow checks) do nothing.
|
||||||
|
if (scrUIController.instance)
|
||||||
|
scrUIController.instance.ShowDifficultyContainer(DifficultyUIMode.DontShow);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,10 +6,31 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= 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))]
|
[HarmonyPatch(typeof(scrShowIfDebug))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public static class scrShowIfDebugPatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("Update")]
|
[HarmonyPatch("Update")]
|
||||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
|
|||||||
@@ -3,10 +3,26 @@ using HarmonyLib;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Patch.Game;
|
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 >= OnScene). Works together with <see cref="EndscreenLanternsPatch"/>,
|
||||||
|
/// which already skips <c>EndscreenLanterns.Show</c> during a render.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
[HarmonyPatch(typeof(scrUIController))]
|
[HarmonyPatch(typeof(scrUIController))]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
public class scrUIControllerPatch
|
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]
|
[HarmonyPostfix]
|
||||||
[HarmonyPatch("ShowEndscreenLanterns")]
|
[HarmonyPatch("ShowEndscreenLanterns")]
|
||||||
// ReSharper disable once InconsistentNaming
|
// ReSharper disable once InconsistentNaming
|
||||||
|
|||||||
+23
-1
@@ -9,17 +9,35 @@ using MelonLoader.Utils;
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Object = UnityEngine.Object;
|
using Object = UnityEngine.Object;
|
||||||
|
|
||||||
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.6-INDEV", "ADOFAI Editor Forum")]
|
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.1-INDEV", "ADOFAI Editor Forum")]
|
||||||
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
|
||||||
|
|
||||||
namespace ADOFAIRenderer;
|
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
|
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
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
public static bool IsFeatureDisabled = false;
|
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);
|
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()
|
public override void OnInitializeMelon()
|
||||||
{
|
{
|
||||||
GlobalSettings.Load();
|
GlobalSettings.Load();
|
||||||
@@ -39,6 +57,10 @@ internal class RenderMod : MelonMod
|
|||||||
ModAssets.Load();
|
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()
|
public override void OnApplicationQuit()
|
||||||
{
|
{
|
||||||
if (RenderManager.Instance == null) return;
|
if (RenderManager.Instance == null) return;
|
||||||
|
|||||||
@@ -11,14 +11,29 @@ using Image = UnityEngine.UI.Image;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.UI;
|
namespace ADOFAIRenderer.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Description of one button of a <see cref="CommonWarningDialogUI"/> dialog: label, click action and optional color.
|
||||||
|
/// </summary>
|
||||||
internal class DialogButton
|
internal class DialogButton
|
||||||
{
|
{
|
||||||
// ReSharper disable FieldCanBeMadeReadOnly.Global
|
// ReSharper disable FieldCanBeMadeReadOnly.Global
|
||||||
|
/// <summary>Button label, shown as given (callers pass already localized text).</summary>
|
||||||
public string Text;
|
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;
|
public Action OnClick;
|
||||||
|
|
||||||
|
/// <summary>Normal (idle) background color of the button; <c>null</c> keeps the O5Kit theme button color.</summary>
|
||||||
public Color? BackgroundColor;
|
public Color? BackgroundColor;
|
||||||
// ReSharper restore FieldCanBeMadeReadOnly.Global
|
// 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)
|
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
|
||||||
{
|
{
|
||||||
Text = text;
|
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
|
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; }
|
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();
|
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 O5Window? _window;
|
||||||
|
|
||||||
private void Awake()
|
private void Awake()
|
||||||
@@ -38,6 +72,10 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
Instance = this;
|
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()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
_window?.Dispose();
|
_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)
|
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
|
||||||
{
|
{
|
||||||
if (buttons.Length == 0) return;
|
if (buttons.Length == 0) return;
|
||||||
@@ -68,8 +115,14 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
dialog.Initialize(title, description, buttons);
|
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)
|
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)
|
if (!UnityEngine.EventSystems.EventSystem.current)
|
||||||
{
|
{
|
||||||
var eventSystemObj = new GameObject("EventSystem");
|
var eventSystemObj = new GameObject("EventSystem");
|
||||||
@@ -78,6 +131,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
|
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))
|
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
|
||||||
{
|
{
|
||||||
if (doc && doc.rootVisualElement is { enabledSelf: true })
|
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;
|
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>();
|
var canvas = gameObject.AddComponent<Canvas>();
|
||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 32766;
|
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>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -105,7 +163,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
|
|
||||||
var ctx = RendererUIManager.Context;
|
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");
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
@@ -120,7 +179,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = 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
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
{
|
{
|
||||||
Id = "RendererMod_WarningDialog",
|
Id = "RendererMod_WarningDialog",
|
||||||
@@ -133,7 +193,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
Padding = new RectOffset(16, 16, 16, 16)
|
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 content = _window.Content;
|
||||||
|
|
||||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
@@ -144,7 +205,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
vLayout.childForceExpandWidth = true;
|
vLayout.childForceExpandWidth = true;
|
||||||
vLayout.childForceExpandHeight = false;
|
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);
|
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
descText.text = description;
|
descText.text = description;
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
@@ -157,7 +218,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
descLE.flexibleWidth = 1f;
|
descLE.flexibleWidth = 1f;
|
||||||
descLE.flexibleHeight = 1f;
|
descLE.flexibleHeight = 1f;
|
||||||
|
|
||||||
// Button action row
|
// Button action row: buttons share the row width equally, 10 units apart
|
||||||
var btnRowObj = new GameObject("ButtonRow");
|
var btnRowObj = new GameObject("ButtonRow");
|
||||||
btnRowObj.transform.SetParent(content, false);
|
btnRowObj.transform.SetParent(content, false);
|
||||||
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
|
||||||
@@ -176,6 +237,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
|
|||||||
for (var i = 0; i < buttons.Length; i++)
|
for (var i = 0; i < buttons.Length; i++)
|
||||||
{
|
{
|
||||||
var btnData = buttons[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(
|
var btn = O5Factory.Button(
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
parent: btnRowRect,
|
parent: btnRowRect,
|
||||||
|
|||||||
+63
-13
@@ -8,39 +8,73 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.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
|
internal class DebugMonitorUI : MonoBehaviour
|
||||||
{
|
{
|
||||||
// --- Progress Bar UI Configuration Constants ---
|
// --- 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;
|
private const float BarWidth = 800f;
|
||||||
|
/// <summary>Height of the progress bar.</summary>
|
||||||
private const float BarHeight = 28f;
|
private const float BarHeight = 28f;
|
||||||
|
/// <summary>Horizontal offset of the bar center from the screen center (0 = centered).</summary>
|
||||||
private const float BarPosX = 0f;
|
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)
|
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
|
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);
|
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
|
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);
|
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
|
||||||
|
|
||||||
// --- Progress Bar Outline Configuration Constants ---
|
// --- 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
|
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
|
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!;
|
private TextMeshProUGUI _text = null!;
|
||||||
|
/// <summary>Progress bar background object (parent of the fill and the percentage text).</summary>
|
||||||
private GameObject _progressBarObj = null!;
|
private GameObject _progressBarObj = null!;
|
||||||
|
/// <summary>Progress bar fill object.</summary>
|
||||||
private GameObject _progressBarFillObj = null!;
|
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!;
|
private RectTransform _fillRect = null!;
|
||||||
|
/// <summary>Text on the bar: percentage and "current floor / last floor index".</summary>
|
||||||
private TextMeshProUGUI _progressText = null!;
|
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()
|
private void Start()
|
||||||
{
|
{
|
||||||
// Set UI layer (5)
|
// Set UI layer (5, Unity's built-in "UI" layer)
|
||||||
gameObject.layer = 5;
|
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>();
|
var canvas = gameObject.AddComponent<Canvas>();
|
||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 9999;
|
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>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -49,7 +83,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
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");
|
var panelObj = new GameObject("BackgroundPanel");
|
||||||
panelObj.transform.SetParent(transform, false);
|
panelObj.transform.SetParent(transform, false);
|
||||||
panelObj.layer = 5;
|
panelObj.layer = 5;
|
||||||
@@ -64,7 +98,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
panelRect.anchoredPosition = new Vector2(-30, -30);
|
panelRect.anchoredPosition = new Vector2(-30, -30);
|
||||||
panelRect.sizeDelta = new Vector2(360, 220);
|
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");
|
var textObj = new GameObject("MonitorText");
|
||||||
textObj.transform.SetParent(panelObj.transform, false);
|
textObj.transform.SetParent(panelObj.transform, false);
|
||||||
textObj.layer = 5;
|
textObj.layer = 5;
|
||||||
@@ -74,11 +108,11 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
_text.fontSize = 16;
|
_text.fontSize = 16;
|
||||||
_text.alignment = TextAlignmentOptions.TopLeft;
|
_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.textWrappingMode = TextWrappingModes.Normal;
|
||||||
_text.overflowMode = TextOverflowModes.Overflow;
|
_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;
|
_text.font = ModAssets.NanumSquareNeoNormal;
|
||||||
|
|
||||||
var textRect = textObj.GetComponent<RectTransform>();
|
var textRect = textObj.GetComponent<RectTransform>();
|
||||||
@@ -88,7 +122,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
textRect.offsetMax = new Vector2(-20, -20);
|
textRect.offsetMax = new Vector2(-20, -20);
|
||||||
|
|
||||||
// --- Progress Bar UI Setup ---
|
// --- 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 = new GameObject("ProgressBar");
|
||||||
_progressBarObj.transform.SetParent(transform, false);
|
_progressBarObj.transform.SetParent(transform, false);
|
||||||
_progressBarObj.layer = 5;
|
_progressBarObj.layer = 5;
|
||||||
@@ -108,7 +142,8 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
|
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
|
||||||
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
|
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 = new GameObject("ProgressBarFill");
|
||||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
_progressBarFillObj.layer = 5;
|
_progressBarFillObj.layer = 5;
|
||||||
@@ -123,7 +158,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
_fillRect.anchoredPosition = Vector2.zero;
|
_fillRect.anchoredPosition = Vector2.zero;
|
||||||
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
|
_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");
|
var progressTextObj = new GameObject("ProgressBarText");
|
||||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
progressTextObj.layer = 5;
|
progressTextObj.layer = 5;
|
||||||
@@ -135,7 +170,7 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
_progressText.font = ModAssets.NanumSquareNeoNormal;
|
_progressText.font = ModAssets.NanumSquareNeoNormal;
|
||||||
_progressText.color = BarTextColor;
|
_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>();
|
var textOutline = progressTextObj.AddComponent<Outline>();
|
||||||
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
|
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
|
||||||
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
|
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
|
||||||
@@ -147,10 +182,22 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
textRectBar.offsetMax = Vector2.zero;
|
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()
|
private void Update()
|
||||||
{
|
{
|
||||||
if (!_text) return;
|
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();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
|
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
@@ -158,13 +205,14 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
var queueCount = 0;
|
var queueCount = 0;
|
||||||
if (RenderManager.Instance)
|
if (RenderManager.Instance)
|
||||||
{
|
{
|
||||||
|
// ImageQueue is shared with the render thread, so its count is read under the same lock
|
||||||
lock (RenderManager.Instance.ImageQueue)
|
lock (RenderManager.Instance.ImageQueue)
|
||||||
{
|
{
|
||||||
queueCount = RenderManager.Instance.ImageQueue.Count;
|
queueCount = RenderManager.Instance.ImageQueue.Count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
|
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {(RenderManager.Instance ? RenderManager.Instance.QueueFrameLimit : RenderManager.MaxQueuedFrames)} frames");
|
||||||
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
|
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
|
||||||
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
|
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
|
||||||
|
|
||||||
@@ -177,7 +225,9 @@ internal class DebugMonitorUI : MonoBehaviour
|
|||||||
|
|
||||||
_text.text = sb.ToString();
|
_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 progress = 0f;
|
||||||
var currentFloor = 0;
|
var currentFloor = 0;
|
||||||
var totalFloors = 0;
|
var totalFloors = 0;
|
||||||
|
|||||||
+37
-9
@@ -6,15 +6,33 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.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
|
internal class EscapeHandlerUI : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>Centered countdown panel (background and text); inactive while Escape is not held.</summary>
|
||||||
private GameObject _containerObj = null!;
|
private GameObject _containerObj = null!;
|
||||||
|
/// <summary>Countdown text inside <see cref="_containerObj"/>.</summary>
|
||||||
private TextMeshProUGUI _textComponent = null!;
|
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;
|
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()
|
private void Start()
|
||||||
{
|
{
|
||||||
// Set UI layer (5)
|
// Set UI layer (5, Unity's built-in "UI" layer)
|
||||||
gameObject.layer = 5;
|
gameObject.layer = 5;
|
||||||
|
|
||||||
// Canvas setup
|
// Canvas setup
|
||||||
@@ -22,7 +40,7 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 10000; // Placed above debug monitor
|
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>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -31,7 +49,7 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
|
|
||||||
gameObject.AddComponent<GraphicRaycaster>();
|
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 = new GameObject("EscapeContainer");
|
||||||
_containerObj.transform.SetParent(transform, false);
|
_containerObj.transform.SetParent(transform, false);
|
||||||
_containerObj.layer = 5;
|
_containerObj.layer = 5;
|
||||||
@@ -46,7 +64,7 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
containerRect.anchoredPosition = Vector2.zero; // Screen center
|
containerRect.anchoredPosition = Vector2.zero; // Screen center
|
||||||
containerRect.sizeDelta = new Vector2(460, 160);
|
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");
|
var textObj = new GameObject("EscapeText");
|
||||||
textObj.transform.SetParent(_containerObj.transform, false);
|
textObj.transform.SetParent(_containerObj.transform, false);
|
||||||
textObj.layer = 5;
|
textObj.layer = 5;
|
||||||
@@ -57,7 +75,8 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
|
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
|
||||||
_textComponent.richText = true;
|
_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)
|
if (RDString.fontData.fontTMP != null)
|
||||||
_textComponent.font = RDString.fontData.fontTMP;
|
_textComponent.font = RDString.fontData.fontTMP;
|
||||||
|
|
||||||
@@ -71,9 +90,13 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
_containerObj.SetActive(false);
|
_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()
|
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)
|
if (!RenderManager.IsActive)
|
||||||
{
|
{
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
@@ -82,6 +105,7 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
|
|
||||||
if (Input.GetKey(KeyCode.Escape))
|
if (Input.GetKey(KeyCode.Escape))
|
||||||
{
|
{
|
||||||
|
// First frame of the press: start timing and show the panel
|
||||||
if (_escapeStopwatch == null)
|
if (_escapeStopwatch == null)
|
||||||
{
|
{
|
||||||
_escapeStopwatch = new Stopwatch();
|
_escapeStopwatch = new Stopwatch();
|
||||||
@@ -92,13 +116,14 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
|
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
|
||||||
var remaining = Mathf.Max(0f, 3f - elapsed);
|
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>";
|
_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;
|
var scale = 1.0f + elapsed / 3.0f * 0.15f;
|
||||||
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
|
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
|
||||||
|
|
||||||
|
// Held for the full 3 seconds: reset the panel, then cancel
|
||||||
if (elapsed < 3f) return;
|
if (elapsed < 3f) return;
|
||||||
|
|
||||||
_escapeStopwatch.Stop();
|
_escapeStopwatch.Stop();
|
||||||
@@ -106,12 +131,14 @@ internal class EscapeHandlerUI : MonoBehaviour
|
|||||||
_containerObj.transform.localScale = Vector3.one; // Reset scale
|
_containerObj.transform.localScale = Vector3.one; // Reset scale
|
||||||
_containerObj.SetActive(false);
|
_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)
|
if (RenderManager.Instance)
|
||||||
RenderManager.Instance.CancelRender();
|
RenderManager.Instance.CancelRender();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Escape released before 3 seconds: stop timing and hide the panel
|
||||||
if (_escapeStopwatch == null) return;
|
if (_escapeStopwatch == null) return;
|
||||||
_escapeStopwatch.Stop();
|
_escapeStopwatch.Stop();
|
||||||
_escapeStopwatch = null;
|
_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()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
if (_escapeStopwatch == null) return;
|
if (_escapeStopwatch == null) return;
|
||||||
|
|||||||
+160
-34
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using ADOFAIRenderer.Localization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
@@ -14,27 +15,71 @@ using UnityEngine.UI;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.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
|
internal class FFmpegDownloadUI : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>Row with the Download/Open Website and Cancel buttons; hidden while a download runs.</summary>
|
||||||
private GameObject _buttonContainerObj = null!;
|
private GameObject _buttonContainerObj = null!;
|
||||||
|
/// <summary>Progress bar background (rounded, theme colors); shown when the download starts.</summary>
|
||||||
private GameObject _progressBarObj = null!;
|
private GameObject _progressBarObj = null!;
|
||||||
|
/// <summary>Progress bar fill object.</summary>
|
||||||
private GameObject _progressBarFillObj = null!;
|
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!;
|
private RectTransform _fillRect = null!;
|
||||||
|
/// <summary>Text centered on the progress bar (percentage or "Extracting...").</summary>
|
||||||
private TextMeshProUGUI _progressText = null!;
|
private TextMeshProUGUI _progressText = null!;
|
||||||
|
/// <summary>Status line below the progress bar; shown when the download starts.</summary>
|
||||||
private GameObject _statusTextObj = null!;
|
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!;
|
private TextMeshProUGUI _statusText = null!;
|
||||||
|
|
||||||
|
/// <summary>O5Kit window holding the dialog contents; disposed in <see cref="OnDestroy"/>.</summary>
|
||||||
private O5Window? _window;
|
private O5Window? _window;
|
||||||
|
/// <summary>True from the Download click until the download fails; prevents a second concurrent download.</summary>
|
||||||
private bool _isDownloading;
|
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
|
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",
|
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"
|
_ => "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;
|
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();
|
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()
|
public static bool CheckFFmpegExists()
|
||||||
{
|
{
|
||||||
var userLibs = MelonEnvironment.UserLibsDirectory;
|
var userLibs = MelonEnvironment.UserLibsDirectory;
|
||||||
@@ -67,6 +112,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
return true;
|
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()
|
public static void ShowDownloadUI()
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -77,8 +126,13 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
obj.AddComponent<FFmpegDownloadUI>();
|
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; }
|
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()
|
private void Awake()
|
||||||
{
|
{
|
||||||
Instance = this;
|
Instance = this;
|
||||||
@@ -88,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()
|
private void Start()
|
||||||
{
|
{
|
||||||
// Set UI layer (5)
|
// Set UI layer (5, Unity's built-in "UI" layer)
|
||||||
gameObject.layer = 5;
|
gameObject.layer = 5;
|
||||||
|
|
||||||
// Canvas setup
|
// Canvas setup
|
||||||
@@ -98,7 +156,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||||
canvas.sortingOrder = 32767; // Topmost
|
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>();
|
var scaler = gameObject.AddComponent<CanvasScaler>();
|
||||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||||
@@ -109,7 +167,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
var ctx = RendererUIManager.Context;
|
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");
|
var bgPanelObj = new GameObject("Backdrop");
|
||||||
bgPanelObj.transform.SetParent(transform, false);
|
bgPanelObj.transform.SetParent(transform, false);
|
||||||
bgPanelObj.layer = 5;
|
bgPanelObj.layer = 5;
|
||||||
@@ -124,11 +182,12 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
bgRect.offsetMin = Vector2.zero;
|
bgRect.offsetMin = Vector2.zero;
|
||||||
bgRect.offsetMax = 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
|
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
|
||||||
{
|
{
|
||||||
Id = "RendererMod_FFmpegDownloadDialog",
|
Id = "RendererMod_FFmpegDownloadDialog",
|
||||||
Title = "FFmpeg Library Installation Required",
|
Title = LocalizationManager.Get("ui.ffmpeg_download.title"),
|
||||||
Size = new Vector2(580, 270),
|
Size = new Vector2(580, 270),
|
||||||
Draggable = true,
|
Draggable = true,
|
||||||
Resizable = false,
|
Resizable = false,
|
||||||
@@ -137,6 +196,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
Padding = new RectOffset(16, 16, 16, 16)
|
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 content = _window.Content;
|
||||||
|
|
||||||
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
|
||||||
@@ -147,19 +208,19 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
vLayout.childForceExpandWidth = true;
|
vLayout.childForceExpandWidth = true;
|
||||||
vLayout.childForceExpandHeight = false;
|
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 descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
|
||||||
var descStrBuilder = new StringBuilder();
|
var descStrBuilder = new StringBuilder();
|
||||||
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
|
||||||
if (Application.platform == RuntimePlatform.OSXPlayer)
|
if (Application.platform == RuntimePlatform.OSXPlayer)
|
||||||
{
|
{
|
||||||
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build"));
|
||||||
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open"));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_download"));
|
||||||
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
|
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size"));
|
||||||
}
|
}
|
||||||
descText.text = descStrBuilder.ToString();
|
descText.text = descStrBuilder.ToString();
|
||||||
descText.alignment = TextAlignmentOptions.Center;
|
descText.alignment = TextAlignmentOptions.Center;
|
||||||
@@ -171,7 +232,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
descLE.flexibleWidth = 1f;
|
descLE.flexibleWidth = 1f;
|
||||||
descLE.flexibleHeight = 1f;
|
descLE.flexibleHeight = 1f;
|
||||||
|
|
||||||
// 4. Button container
|
// 4. Button container: two equal-width 40-unit buttons, 10 units apart
|
||||||
_buttonContainerObj = new GameObject("ButtonContainer");
|
_buttonContainerObj = new GameObject("ButtonContainer");
|
||||||
_buttonContainerObj.transform.SetParent(content, false);
|
_buttonContainerObj.transform.SetParent(content, false);
|
||||||
_buttonContainerObj.layer = 5;
|
_buttonContainerObj.layer = 5;
|
||||||
@@ -188,6 +249,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
btnLayout.childForceExpandWidth = true;
|
btnLayout.childForceExpandWidth = true;
|
||||||
btnLayout.childForceExpandHeight = 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
|
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
|
||||||
? StartDownloadProcess
|
? StartDownloadProcess
|
||||||
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
|
||||||
@@ -196,7 +258,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
parent: _buttonContainerObj.transform,
|
parent: _buttonContainerObj.transform,
|
||||||
onClick: onDownloadClick,
|
onClick: onDownloadClick,
|
||||||
text: Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website",
|
text: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"),
|
||||||
id: "btn_ffmpeg_download",
|
id: "btn_ffmpeg_download",
|
||||||
height: 40f
|
height: 40f
|
||||||
);
|
);
|
||||||
@@ -211,7 +273,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
parent: _buttonContainerObj.transform,
|
parent: _buttonContainerObj.transform,
|
||||||
onClick: CancelProcess,
|
onClick: CancelProcess,
|
||||||
text: "Cancel",
|
text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"),
|
||||||
id: "btn_ffmpeg_cancel",
|
id: "btn_ffmpeg_cancel",
|
||||||
height: 40f
|
height: 40f
|
||||||
);
|
);
|
||||||
@@ -219,7 +281,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
cancelBtnLE.flexibleWidth = 1f;
|
cancelBtnLE.flexibleWidth = 1f;
|
||||||
cancelBtnLE.preferredHeight = 40f;
|
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 = new GameObject("ProgressBarContainer");
|
||||||
_progressBarObj.transform.SetParent(content, false);
|
_progressBarObj.transform.SetParent(content, false);
|
||||||
_progressBarObj.layer = 5;
|
_progressBarObj.layer = 5;
|
||||||
@@ -250,7 +313,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
barOutlineRect.offsetMin = Vector2.zero;
|
barOutlineRect.offsetMin = Vector2.zero;
|
||||||
barOutlineRect.offsetMax = 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 = new GameObject("ProgressBarFill");
|
||||||
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
_progressBarFillObj.layer = 5;
|
_progressBarFillObj.layer = 5;
|
||||||
@@ -267,7 +331,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
_fillRect.anchoredPosition = Vector2.zero;
|
_fillRect.anchoredPosition = Vector2.zero;
|
||||||
_fillRect.sizeDelta = Vector2.zero;
|
_fillRect.sizeDelta = Vector2.zero;
|
||||||
|
|
||||||
// Progress text
|
// Progress text: 13 pt white, centered over the bar
|
||||||
var progressTextObj = new GameObject("ProgressBarText");
|
var progressTextObj = new GameObject("ProgressBarText");
|
||||||
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
|
||||||
progressTextObj.layer = 5;
|
progressTextObj.layer = 5;
|
||||||
@@ -285,7 +349,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
progressTextRect.offsetMin = Vector2.zero;
|
progressTextRect.offsetMin = Vector2.zero;
|
||||||
progressTextRect.offsetMax = 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 = new GameObject("StatusText");
|
||||||
_statusTextObj.transform.SetParent(content, false);
|
_statusTextObj.transform.SetParent(content, false);
|
||||||
_statusTextObj.layer = 5;
|
_statusTextObj.layer = 5;
|
||||||
@@ -309,20 +373,29 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
statusRect.offsetMax = Vector2.zero;
|
statusRect.offsetMax = Vector2.zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs every action posted by the background download task, in posting order.</summary>
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
// Execute queued actions on main thread
|
// Execute queued actions on main Unity thread
|
||||||
while (_mainThreadQueue.TryDequeue(out var action))
|
while (_mainThreadQueue.TryDequeue(out var action))
|
||||||
{
|
{
|
||||||
action?.Invoke();
|
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)
|
private void ExecuteOnMainThread(Action action)
|
||||||
{
|
{
|
||||||
_mainThreadQueue.Enqueue(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()
|
private void StartDownloadProcess()
|
||||||
{
|
{
|
||||||
if (_isDownloading) return;
|
if (_isDownloading) return;
|
||||||
@@ -335,13 +408,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
|
|
||||||
_cts = new System.Threading.CancellationTokenSource();
|
_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 () =>
|
Task.Run(async () =>
|
||||||
{
|
{
|
||||||
string tempZipPath = "";
|
string tempZipPath = "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_starting"), new Color(0.8f, 0.8f, 0.8f));
|
||||||
UpdateProgress(0f, "0%");
|
UpdateProgress(0f, "0%");
|
||||||
|
|
||||||
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
var userLibsDir = MelonEnvironment.UserLibsDirectory;
|
||||||
@@ -350,6 +425,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
Directory.CreateDirectory(userLibsDir);
|
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
|
string extension = Application.platform switch
|
||||||
{
|
{
|
||||||
RuntimePlatform.WindowsPlayer => ".zip",
|
RuntimePlatform.WindowsPlayer => ".zip",
|
||||||
@@ -358,27 +435,30 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
};
|
};
|
||||||
tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension);
|
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 =>
|
var progressReporter = new Progress<float>(percent =>
|
||||||
{
|
{
|
||||||
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
UpdateProgress(percent, $"{percent * 100f:F1}%");
|
||||||
UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_downloading", $"{percent * 100f:F1}"), new Color(0f, 0.8f, 1f));
|
||||||
});
|
});
|
||||||
|
|
||||||
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
|
||||||
|
|
||||||
// Extract after download complete
|
// Extract after download complete; the bar is held at 99% while extracting
|
||||||
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
|
||||||
UpdateProgress(0.99f, "Extracting...");
|
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
|
||||||
|
|
||||||
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
await ExtractDllsAsync(tempZipPath, userLibsDir);
|
||||||
|
|
||||||
// Update completion status
|
// Update completion status
|
||||||
UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_complete"), new Color(0.2f, 0.9f, 0.2f));
|
||||||
UpdateProgress(1f, "100%");
|
UpdateProgress(1f, "100%");
|
||||||
|
|
||||||
|
// Keep the "complete" message visible for 1000 ms before closing
|
||||||
await Task.Delay(1000);
|
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(() =>
|
ExecuteOnMainThread(() =>
|
||||||
{
|
{
|
||||||
Destroy(gameObject);
|
Destroy(gameObject);
|
||||||
@@ -386,7 +466,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
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))
|
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
|
||||||
{
|
{
|
||||||
try { File.Delete(tempZipPath); }
|
try { File.Delete(tempZipPath); }
|
||||||
@@ -395,14 +476,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
|
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_cancelled"), new Color(0.9f, 0.5f, 0.2f));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
|
||||||
UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
|
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(() =>
|
ExecuteOnMainThread(() =>
|
||||||
{
|
{
|
||||||
_isDownloading = false;
|
_isDownloading = false;
|
||||||
@@ -410,13 +493,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
|
||||||
if (downloadBtnText)
|
if (downloadBtnText)
|
||||||
{
|
{
|
||||||
downloadBtnText.text = "Retry";
|
downloadBtnText.text = LocalizationManager.Get("ui.ffmpeg_download.btn_retry");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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()
|
private void CancelProcess()
|
||||||
{
|
{
|
||||||
if (_isDownloading)
|
if (_isDownloading)
|
||||||
@@ -426,6 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
Destroy(gameObject);
|
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)
|
private void UpdateStatus(string message, Color color)
|
||||||
{
|
{
|
||||||
ExecuteOnMainThread(() =>
|
ExecuteOnMainThread(() =>
|
||||||
@@ -438,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)
|
private void UpdateProgress(float percent, string text)
|
||||||
{
|
{
|
||||||
ExecuteOnMainThread(() =>
|
ExecuteOnMainThread(() =>
|
||||||
@@ -453,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)
|
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var client = new HttpClient();
|
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");
|
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);
|
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
response.EnsureSuccessStatusCode();
|
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;
|
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
|
||||||
await using var contentStream = await response.Content.ReadAsStreamAsync();
|
await using var contentStream = await response.Content.ReadAsStreamAsync();
|
||||||
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
|
||||||
@@ -482,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)
|
private async Task ExtractDllsAsync(string zipPath, string outputDirectory)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
|
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
|
||||||
{
|
{
|
||||||
|
// .zip: extract matching library entries flat into outputDirectory
|
||||||
await Task.Run(() =>
|
await Task.Run(() =>
|
||||||
{
|
{
|
||||||
var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll";
|
var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll";
|
||||||
@@ -521,6 +641,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
|
|||||||
}
|
}
|
||||||
Directory.CreateDirectory(tempExtractDir);
|
Directory.CreateDirectory(tempExtractDir);
|
||||||
|
|
||||||
|
// tar receives forward-slash paths, quoted for spaces: tar -xf "<archive>" -C "<temp dir>"
|
||||||
var archiveUnixPath = zipPath.Replace('\\', '/');
|
var archiveUnixPath = zipPath.Replace('\\', '/');
|
||||||
var tempExtractUnixDir = tempExtractDir.Replace('\\', '/');
|
var tempExtractUnixDir = tempExtractDir.Replace('\\', '/');
|
||||||
|
|
||||||
@@ -544,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);
|
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
|
||||||
foreach (var soFile in soFiles)
|
foreach (var soFile in soFiles)
|
||||||
{
|
{
|
||||||
@@ -581,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()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
_window?.Dispose();
|
_window?.Dispose();
|
||||||
|
|||||||
+36
-2
@@ -8,15 +8,25 @@ namespace ADOFAIRenderer.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
|
||||||
/// automatically updating the displayed text whenever the active language changes.
|
/// 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>
|
/// </summary>
|
||||||
internal class LocalizedText : MonoBehaviour
|
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;
|
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;
|
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;
|
private Func<string>? _dynamicProvider;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public string? Key
|
public string? Key
|
||||||
{
|
{
|
||||||
@@ -30,8 +40,11 @@ internal class LocalizedText : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
|
/// <param name="provider">Function returning the text to display; called on the main Unity thread.</param>
|
||||||
public void SetDynamic(Func<string> provider)
|
public void SetDynamic(Func<string> provider)
|
||||||
{
|
{
|
||||||
_dynamicProvider = provider;
|
_dynamicProvider = provider;
|
||||||
@@ -39,27 +52,35 @@ internal class LocalizedText : MonoBehaviour
|
|||||||
Refresh();
|
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()
|
private void Awake()
|
||||||
{
|
{
|
||||||
EnsureComponent();
|
EnsureComponent();
|
||||||
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Refreshes the text each time the component is enabled.</summary>
|
||||||
private void OnEnable()
|
private void OnEnable()
|
||||||
{
|
{
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Unsubscribes from <see cref="LocalizationManager.OnLanguageChanged"/>.</summary>
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Language change handler; the new language is read by <see cref="LocalizationManager.Get(string)"/>.</summary>
|
||||||
private void HandleLanguageChanged(SystemLanguage language)
|
private void HandleLanguageChanged(SystemLanguage language)
|
||||||
{
|
{
|
||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Assigns <see cref="_textComponent"/> from the same GameObject if it is not set yet.</summary>
|
||||||
private void EnsureComponent()
|
private void EnsureComponent()
|
||||||
{
|
{
|
||||||
if (_textComponent == null)
|
if (_textComponent == null)
|
||||||
@@ -70,6 +91,8 @@ internal class LocalizedText : MonoBehaviour
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Refreshes the text using either the dynamic provider or the static localization key.
|
/// 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>
|
/// </summary>
|
||||||
public void Refresh()
|
public void Refresh()
|
||||||
{
|
{
|
||||||
@@ -88,7 +111,13 @@ internal class LocalizedText : MonoBehaviour
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds a TextMeshProUGUI component to a localization key.
|
/// 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>
|
/// </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)
|
public static LocalizedText Bind(TextMeshProUGUI target, string key)
|
||||||
{
|
{
|
||||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
@@ -100,7 +129,12 @@ internal class LocalizedText : MonoBehaviour
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
|
/// 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>
|
/// </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)
|
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
|
||||||
{
|
{
|
||||||
if (target == null) throw new ArgumentNullException(nameof(target));
|
if (target == null) throw new ArgumentNullException(nameof(target));
|
||||||
|
|||||||
@@ -8,9 +8,20 @@ namespace ADOFAIRenderer.UI;
|
|||||||
|
|
||||||
using UnityEngine;
|
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")]
|
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||||
internal static class ModAssets
|
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()
|
public static void Load()
|
||||||
{
|
{
|
||||||
FontBundle = BundleLoader.GetBundle("fonts");
|
FontBundle = BundleLoader.GetBundle("fonts");
|
||||||
@@ -23,24 +34,49 @@ internal static class ModAssets
|
|||||||
Melon<RenderMod>.Logger.Msg("Shader loaded successfully.");
|
Melon<RenderMod>.Logger.Msg("Shader loaded successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Asset bundle "fonts" holding the TextMeshPro font assets.</summary>
|
||||||
public static AssetBundle FontBundle = null!;
|
public static AssetBundle FontBundle = null!;
|
||||||
|
/// <summary>Asset bundle "shaders" holding the compute shaders.</summary>
|
||||||
public static AssetBundle ShaderBundle = null!;
|
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!;
|
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!;
|
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!;
|
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
|
internal sealed class RendererFontProvider : IFontProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>O5Kit default provider, used only for <see cref="Monospace"/>.</summary>
|
||||||
private static readonly DefaultFontProvider DefaultFontProvider = new();
|
private static readonly DefaultFontProvider DefaultFontProvider = new();
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
/// <summary>Regular text font: <see cref="ModAssets.NanumSquareNeoNormal"/>.</summary>
|
||||||
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
|
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
|
||||||
|
/// <summary>Medium (emphasis) text font: <see cref="ModAssets.NanumSquareNeoBold"/>.</summary>
|
||||||
public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold;
|
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;
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (_disposed) return;
|
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
|
internal class RendererUIManager : MonoBehaviour
|
||||||
{
|
{
|
||||||
|
/// <summary>The single instance, assigned in <see cref="Awake"/>.</summary>
|
||||||
public static RendererUIManager Instance = null!;
|
public static RendererUIManager Instance = null!;
|
||||||
|
|
||||||
|
/// <summary>Shared O5Kit context used to build every mod window and dialog.</summary>
|
||||||
public static O5Context Context => Instance._context;
|
public static O5Context Context => Instance._context;
|
||||||
|
/// <summary>Window manager whose canvas holds the mod windows (e.g. RenderConfigWindow).</summary>
|
||||||
public O5WindowManager WindowManager => _windowManager;
|
public O5WindowManager WindowManager => _windowManager;
|
||||||
|
|
||||||
private O5Context _context = null!;
|
private O5Context _context = null!;
|
||||||
private O5WindowManager _windowManager = 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() {
|
private void Awake() {
|
||||||
Instance = this;
|
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(
|
_context = new O5Context(
|
||||||
config: new O5Config {
|
config: new O5Config {
|
||||||
UIScale = 1.0f,
|
UIScale = 1.0f,
|
||||||
@@ -94,14 +150,27 @@ internal class RendererUIManager : MonoBehaviour
|
|||||||
);
|
);
|
||||||
|
|
||||||
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
_windowManager = O5WindowManager.Create(_context, transform, "RendererMod_Canvas");
|
||||||
|
|
||||||
|
// The O5Kit canvas is created with sortingOrder 32767. It is lowered so that the dialogs of
|
||||||
|
// CommonWarningDialogUI (32766) are drawn above the windows of this manager.
|
||||||
|
var windowCanvas = transform.Find("RendererMod_Canvas")?.GetComponent<Canvas>();
|
||||||
|
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() {
|
private void Update() {
|
||||||
O5Object.TickAll();
|
O5Object.TickAll();
|
||||||
Context.Tooltip.Tick();
|
Context.Tooltip.Tick();
|
||||||
O5Kit.Input.O5ShortcutManager.HandleUpdate();
|
O5Kit.Input.O5ShortcutManager.HandleUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Disposes the window manager, then the context.</summary>
|
||||||
private void OnDestroy() {
|
private void OnDestroy() {
|
||||||
_windowManager.Dispose();
|
_windowManager.Dispose();
|
||||||
_context.Dispose();
|
_context.Dispose();
|
||||||
|
|||||||
+571
-46
File diff suppressed because it is too large.
Load diff
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +1,223 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ADOFAIRenderer.Core;
|
using ADOFAIRenderer.Core;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
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
|
internal static class AudioSpectrumGenerator
|
||||||
{
|
{
|
||||||
// ReSharper disable MemberCanBePrivate.Global
|
/// <summary>
|
||||||
public static float[]? AudioClipData { get; private set; }
|
/// Gain of a mono clip relative to one channel of a stereo clip with the same samples.
|
||||||
public static int Channels { get; private set; }
|
/// 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).
|
||||||
public static int Frequency { get; private set; }
|
/// </summary>
|
||||||
public static int TotalSamples { get; private set; }
|
private const double MonoGain = 0.70710678118654752;
|
||||||
// ReSharper restore MemberCanBePrivate.Global
|
|
||||||
|
|
||||||
|
/// <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 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();
|
||||||
{
|
if (!clip) return;
|
||||||
Clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Channels = clip.channels;
|
var rate = AudioSettings.outputSampleRate;
|
||||||
Frequency = clip.frequency;
|
if (mixer != null && mixer.Store.OutputRate == rate)
|
||||||
int samplesCount = clip.samples;
|
|
||||||
|
|
||||||
float[] rawSamples = new float[samplesCount * Channels];
|
|
||||||
clip.GetData(rawSamples, 0);
|
|
||||||
|
|
||||||
if (Channels == 1)
|
|
||||||
{
|
{
|
||||||
AudioClipData = rawSamples;
|
_store = mixer.Store;
|
||||||
TotalSamples = samplesCount;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
AudioClipData = new float[samplesCount];
|
_ownStore = new AudioClipStore(rate);
|
||||||
TotalSamples = samplesCount;
|
_ownStore.Start();
|
||||||
for (int i = 0; i < samplesCount; i++)
|
_store = _ownStore;
|
||||||
{
|
|
||||||
float sum = 0f;
|
|
||||||
for (int c = 0; c < Channels; c++)
|
|
||||||
{
|
|
||||||
sum += rawSamples[i * Channels + c];
|
|
||||||
}
|
|
||||||
AudioClipData[i] = sum / Channels;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
|
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}");
|
||||||
Clear();
|
Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the chunk, the cursor and the own store. Call before the mixer store is disposed.</summary>
|
||||||
public static void Clear()
|
public static void Clear()
|
||||||
{
|
{
|
||||||
AudioClipData = null;
|
if (_source != null)
|
||||||
TotalSamples = 0;
|
|
||||||
Channels = 0;
|
|
||||||
Frequency = 0;
|
|
||||||
lock (CachedSpectrums)
|
|
||||||
{
|
{
|
||||||
_cachedFrame = -1;
|
_source.Release(_view);
|
||||||
CachedSpectrums.Clear();
|
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)
|
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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
|
var render = RenderManager.Instance;
|
||||||
|
var currentFrame = render.frameCount;
|
||||||
lock (CachedSpectrums)
|
if (currentFrame != _cachedFrame)
|
||||||
{
|
{
|
||||||
if (currentFrame != _cachedFrame)
|
_cachedFrame = currentFrame;
|
||||||
{
|
CachedSpectrums.Clear();
|
||||||
_cachedFrame = currentFrame;
|
|
||||||
CachedSpectrums.Clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CachedSpectrums.TryGetValue(n, out var cached))
|
|
||||||
{
|
|
||||||
Array.Copy(cached, samples, n);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 fftSize = 2 * n;
|
||||||
var frequency = Frequency;
|
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
|
||||||
var totalSamples = TotalSamples;
|
var first = songSeconds * _store.OutputRate - n * pitch;
|
||||||
var currentSampleIndex = (long)(songTime * frequency);
|
|
||||||
|
|
||||||
var windowSamples = new float[fftSize];
|
var windowSamples = new float[fftSize];
|
||||||
for (var i = 0; i < fftSize; i++)
|
try
|
||||||
{
|
{
|
||||||
var idx = currentSampleIndex - fftSize / 2 + i;
|
// Keep the cursor at the window start so the decoder thread loads (and keeps) the chunks the window reads.
|
||||||
if (idx >= 0 && idx < totalSamples)
|
_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];
|
var spectrum = new float[n];
|
||||||
CalculateFFT(windowSamples, spectrum);
|
CalculateFFT(windowSamples, spectrum);
|
||||||
|
CachedSpectrums[(n, sourceChannel)] = spectrum;
|
||||||
lock (CachedSpectrums)
|
Array.Copy(spectrum, samples, n);
|
||||||
{
|
|
||||||
CachedSpectrums[n] = 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)
|
private static void CalculateFFT(float[] samples, float[] spectrum)
|
||||||
{
|
{
|
||||||
var n = samples.Length;
|
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 a0 = 0.35875f;
|
||||||
const float a1 = 0.48829f;
|
const float a1 = 0.48829f;
|
||||||
const float a2 = 0.14128f;
|
const float a2 = 0.14128f;
|
||||||
@@ -169,16 +228,16 @@ internal static class AudioSpectrumGenerator
|
|||||||
|
|
||||||
for (var i = 0; i < n; i++)
|
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));
|
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;
|
re[i] = samples[i] * w;
|
||||||
im[i] = 0f;
|
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));
|
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;
|
var j = 0;
|
||||||
for (var i = 0; i < n - 1; i++)
|
for (var i = 0; i < n - 1; i++)
|
||||||
{
|
{
|
||||||
@@ -201,7 +260,8 @@ internal static class AudioSpectrumGenerator
|
|||||||
j += k;
|
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++)
|
for (var stage = 1; stage <= m; stage++)
|
||||||
{
|
{
|
||||||
var le = 1 << stage;
|
var le = 1 << stage;
|
||||||
@@ -230,12 +290,13 @@ internal static class AudioSpectrumGenerator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and fill spectrum
|
// Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData.
|
||||||
var factor = 2.0f / n;
|
// 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);
|
var outLen = Math.Min(spectrum.Length, n / 2);
|
||||||
for (var i = 0; i < outLen; i++)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,34 +2,50 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
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
|
internal class AudioSyncCalculator
|
||||||
{
|
{
|
||||||
|
/// <summary>Audio sample rate in Hz (samples per channel per second).</summary>
|
||||||
private readonly long _sampleRate;
|
private readonly long _sampleRate;
|
||||||
|
|
||||||
|
/// <summary>Video frame rate as a fraction (frames per second = Numerator / Denominator).</summary>
|
||||||
private readonly (long Numerator, long Denominator) _fpsRational;
|
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
|
// ReSharper disable once NotAccessedField.Local
|
||||||
private long _currentSample;
|
private long _currentSample;
|
||||||
|
|
||||||
|
/// <summary>Remainder of the last division, in units of 1 / Numerator samples (0 ~ Numerator - 1).</summary>
|
||||||
private long _remainder;
|
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)
|
public AudioSyncCalculator(long sampleRate, double fps)
|
||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
|
||||||
|
|
||||||
// NTSC
|
|
||||||
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
|
|
||||||
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
|
|
||||||
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Restarts the sequence at frame 0 (the remainder is cleared).</summary>
|
||||||
public void Reset()
|
public void Reset()
|
||||||
{
|
{
|
||||||
_currentSample = 0;
|
_currentSample = 0;
|
||||||
_remainder = 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()
|
public int GetNextFrameSamples()
|
||||||
{
|
{
|
||||||
|
// samplesNeeded = floor((sampleRate * Denominator + remainder) / Numerator); the new remainder carries the fraction.
|
||||||
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
|
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
|
||||||
var samplesNeeded = numerator / _fpsRational.Numerator;
|
var samplesNeeded = numerator / _fpsRational.Numerator;
|
||||||
|
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 > 0.</summary>
|
||||||
|
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,22 +2,31 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
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
|
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 void Initialize(int sampleRate, int channels) { }
|
||||||
|
|
||||||
public float ProcessSample(float sample, int channel) =>
|
/// <summary>Returns <paramref name="sample"/> × 10^(<see cref="Level"/> / 20); <paramref name="channel"/> is not used.</summary>
|
||||||
sample * (float)Math.Pow(10, Level / 20);
|
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++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public double Latency => 0.0;
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
@@ -3,30 +3,40 @@ using System.Runtime.CompilerServices;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
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
|
internal class HardClip : IAudioFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>Clip level as a linear amplitude (1.0 = full scale).</summary>
|
||||||
// ReSharper disable once MemberCanBePrivate.Global
|
// ReSharper disable once MemberCanBePrivate.Global
|
||||||
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
// ReSharper disable once FieldCanBeMadeReadOnly.Global
|
||||||
// ReSharper disable once ConvertToConstant.Global
|
// ReSharper disable once ConvertToConstant.Global
|
||||||
public float Threshold = 1.0f;
|
public double Threshold = 1.0;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Initialize(int sampleRate, int channels) { }
|
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)]
|
[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;
|
||||||
if (sample < -Threshold) return -Threshold;
|
if (sample < -Threshold) return -Threshold;
|
||||||
return sample;
|
return sample;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
/// <inheritdoc/>
|
||||||
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
buffer[i] = ProcessSample(buffer[i], 0);
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public double Latency => 0.0;
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,34 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
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
|
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);
|
void Initialize(int sampleRate, int channels);
|
||||||
|
|
||||||
float ProcessSample(float sample, int channel);
|
/// <summary>Processes one sample of channel <paramref name="channel"/> (0 ~ channels - 1) and returns the output sample.</summary>
|
||||||
void ProcessBlock(Span<float> buffer);
|
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();
|
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; }
|
double Latency { get; }
|
||||||
}
|
}
|
||||||
@@ -3,91 +3,127 @@ using System.Runtime.CompilerServices;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio.Filter;
|
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 < 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
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class RMSCompressor : IAudioFilter
|
internal class RMSCompressor : IAudioFilter
|
||||||
{
|
{
|
||||||
private float _thresholdDb = -20f;
|
private double _thresholdDb = -20;
|
||||||
private float _ratio = 4f;
|
private double _ratio = 4;
|
||||||
private float _attackTime = 0.010f; // in seconds
|
private double _attackTime = 0.010; // in seconds
|
||||||
private float _releaseTime = 0.100f; // in seconds
|
private double _releaseTime = 0.100; // in seconds
|
||||||
private float _kneeDb = 5f; // in dB
|
private double _kneeDb = 5; // in dB
|
||||||
private float _rmsWindowTime = 0.030f; // in seconds
|
private double _rmsWindowTime = 0.030; // in seconds
|
||||||
private float _lookAheadTime; // 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;
|
private int _lookAheadSamples;
|
||||||
|
|
||||||
|
/// <summary>Sample rate in Hz; 44100 until <see cref="Initialize"/> sets the actual rate.</summary>
|
||||||
private int _sampleRate = 44100;
|
private int _sampleRate = 44100;
|
||||||
|
|
||||||
|
/// <summary>Interleaved channel count; 2 until <see cref="Initialize"/> sets the actual count.</summary>
|
||||||
private int _channels = 2;
|
private int _channels = 2;
|
||||||
|
|
||||||
private float _attackCoef;
|
// One-pole smoothing coefficients (0 ~ 1) of attack, release and the mean-square detector: exp(-1 / (sampleRate * time)).
|
||||||
private float _releaseCoef;
|
private double _attackCoef;
|
||||||
private float _rmsCoef;
|
private double _releaseCoef;
|
||||||
|
private double _rmsCoef;
|
||||||
|
|
||||||
// Per-channel state
|
// Per-channel state
|
||||||
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
|
private double[] _rmsState = Array.Empty<double>(); // running mean-square (MS) state
|
||||||
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
|
private double[] _gainState = Array.Empty<double>(); // smoothed gain reduction in dB
|
||||||
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
|
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
|
||||||
private int[] _delayWriteIndices = Array.Empty<int>();
|
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;
|
get => _lookAheadTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
|
_lookAheadTime = Math.Max(0, Math.Min(1.0, value));
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Output delay in seconds: <see cref="LookAheadTime"/>.</summary>
|
||||||
public double Latency => _lookAheadTime;
|
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;
|
get => _thresholdDb;
|
||||||
set => _thresholdDb = value;
|
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;
|
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;
|
get => _attackTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_attackTime = Math.Max(0.0001f, value);
|
_attackTime = Math.Max(0.0001, value);
|
||||||
UpdateAttackCoef();
|
UpdateAttackCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public float ReleaseTime
|
/// <summary>Release time constant in seconds, at least 0.0001.</summary>
|
||||||
|
public double ReleaseTime
|
||||||
{
|
{
|
||||||
get => _releaseTime;
|
get => _releaseTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_releaseTime = Math.Max(0.0001f, value);
|
_releaseTime = Math.Max(0.0001, value);
|
||||||
UpdateReleaseCoef();
|
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;
|
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;
|
get => _rmsWindowTime;
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_rmsWindowTime = Math.Max(0.0001f, value);
|
_rmsWindowTime = Math.Max(0.0001, value);
|
||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
public void Initialize(int sampleRate, int channels)
|
public void Initialize(int sampleRate, int channels)
|
||||||
{
|
{
|
||||||
_sampleRate = sampleRate;
|
_sampleRate = sampleRate;
|
||||||
@@ -98,8 +134,8 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
UpdateRmsCoef();
|
UpdateRmsCoef();
|
||||||
UpdateLookAheadSettings();
|
UpdateLookAheadSettings();
|
||||||
|
|
||||||
_rmsState = new float[channels];
|
_rmsState = new double[channels];
|
||||||
_gainState = new float[channels];
|
_gainState = new double[channels];
|
||||||
|
|
||||||
Reset();
|
Reset();
|
||||||
}
|
}
|
||||||
@@ -110,37 +146,42 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
ReallocateDelayBuffers();
|
ReallocateDelayBuffers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Allocates one zeroed circular buffer of <see cref="_lookAheadSamples"/> samples per channel.</summary>
|
||||||
private void ReallocateDelayBuffers()
|
private void ReallocateDelayBuffers()
|
||||||
{
|
{
|
||||||
if (_channels <= 0) return;
|
if (_channels <= 0) return;
|
||||||
|
|
||||||
_delayBuffers = new float[_channels][];
|
_delayBuffers = new double[_channels][];
|
||||||
_delayWriteIndices = new int[_channels];
|
_delayWriteIndices = new int[_channels];
|
||||||
|
|
||||||
for (int i = 0; i < _channels; i++)
|
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;
|
_delayWriteIndices[i] = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateAttackCoef()
|
private void UpdateAttackCoef()
|
||||||
{
|
{
|
||||||
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
_attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateReleaseCoef()
|
private void UpdateReleaseCoef()
|
||||||
{
|
{
|
||||||
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
_releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateRmsCoef()
|
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)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public float ProcessSample(float sample, int channel)
|
public double ProcessSample(double sample, int channel)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= _channels)
|
if (channel < 0 || channel >= _channels)
|
||||||
{
|
{
|
||||||
@@ -149,54 +190,57 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
|
|
||||||
// Calculate running Mean-Square (MS) level
|
// Calculate running Mean-Square (MS) level
|
||||||
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
|
||||||
float sq = sample * sample;
|
double sq = sample * sample;
|
||||||
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
|
_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))
|
// 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)
|
// Calculate static envelope curve (gain reduction in dB)
|
||||||
float gStatic = 0f;
|
double gStatic = 0;
|
||||||
if (_kneeDb > 0f)
|
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;
|
// Inside the knee: quadratic transition from 0 dB at T - W/2 to the full-ratio line at T + W/2.
|
||||||
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
|
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
|
||||||
|
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (rmsDb > _thresholdDb)
|
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
|
// Apply ballistics (attack and release) on the dB gain reduction
|
||||||
float lastGain = _gainState[channel];
|
double lastGain = _gainState[channel];
|
||||||
float targetGain = gStatic;
|
double targetGain = gStatic;
|
||||||
|
|
||||||
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
|
||||||
// targetGain < lastGain means target gain reduction is larger (more negative),
|
// targetGain < lastGain means target gain reduction is larger (more negative),
|
||||||
// so we are in the attack phase.
|
// so we are in the attack phase.
|
||||||
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
double coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
|
||||||
float currentGain = coef * lastGain + (1f - coef) * targetGain;
|
double currentGain = coef * lastGain + (1 - coef) * targetGain;
|
||||||
_gainState[channel] = currentGain;
|
_gainState[channel] = currentGain;
|
||||||
|
|
||||||
// Convert back to linear gain multiplier
|
// 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)
|
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];
|
var buffer = _delayBuffers[channel];
|
||||||
int writeIdx = _delayWriteIndices[channel];
|
int writeIdx = _delayWriteIndices[channel];
|
||||||
float delayedSample = buffer[writeIdx];
|
double delayedSample = buffer[writeIdx];
|
||||||
buffer[writeIdx] = sample;
|
buffer[writeIdx] = sample;
|
||||||
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
|
||||||
return delayedSample * linearGain;
|
return delayedSample * linearGain;
|
||||||
@@ -205,7 +249,8 @@ internal class RMSCompressor : IAudioFilter
|
|||||||
return sample * linearGain;
|
return sample * linearGain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ProcessBlock(Span<float> buffer)
|
/// <inheritdoc/>
|
||||||
|
public void ProcessBlock(Span<double> buffer)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < buffer.Length; i++)
|
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()
|
public void Reset()
|
||||||
{
|
{
|
||||||
Array.Clear(_rmsState, 0, _rmsState.Length);
|
Array.Clear(_rmsState, 0, _rmsState.Length);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+359
-345
@@ -1,416 +1,430 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Clip;
|
||||||
using ADOFAIRenderer.Util.Audio.Filter;
|
using ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Mixer;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio;
|
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
|
/// <summary>Additional gain of sound effects (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||||
{
|
private const double EffectVolume = 2.0 / 3.0;
|
||||||
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
|
|
||||||
public double Duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
|
/// <summary>
|
||||||
{
|
/// Smallest number of tracked effect voices at which finished ones are removed from the list used by <see cref="StopAllEffects"/>.
|
||||||
if (string.IsNullOrEmpty(clipName)) return null;
|
/// After each removal the next threshold is twice the remaining count (at least this value).
|
||||||
|
/// </summary>
|
||||||
|
private const int EffectVoicePruneCount = 1024;
|
||||||
|
|
||||||
lock (_cache)
|
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
||||||
{
|
private const int CommitWaitMs = 50;
|
||||||
if (_cache.TryGetValue(clipName, out var cached))
|
|
||||||
return cached;
|
|
||||||
|
|
||||||
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);
|
private readonly AudioClipStore _store;
|
||||||
if (!clip) return null;
|
private readonly VoiceMixer _mixer;
|
||||||
|
|
||||||
try
|
/// <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();
|
||||||
var totalSamples = clip.samples;
|
|
||||||
var channels = clip.channels;
|
|
||||||
var rawSamples = new float[totalSamples * channels];
|
|
||||||
clip.GetData(rawSamples, 0);
|
|
||||||
|
|
||||||
// Auto-resample during cache generation
|
/// <summary>Filter chain applied in list order to every mixed block.</summary>
|
||||||
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
|
private readonly List<IAudioFilter> _filters = new();
|
||||||
|
|
||||||
var data = new CachedClipData
|
/// <summary>Sum of the filter latencies in output samples: round(sum of <see cref="IAudioFilter.Latency"/> * sample rate).</summary>
|
||||||
{
|
private readonly int _latencySamples;
|
||||||
Samples = resampled,
|
|
||||||
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
|
|
||||||
};
|
|
||||||
|
|
||||||
_cache[clipName] = data;
|
// Main thread.
|
||||||
return data;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
/// <summary>
|
||||||
{
|
/// Mapping of game AudioSources to the state of the sound effect last started on them. Weak keys: a destroyed source
|
||||||
lock (_cache)
|
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
|
||||||
{
|
/// </summary>
|
||||||
_cache.Clear();
|
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class OfflineAudioMixer
|
/// <summary>Effect voices started since the last <see cref="StopAllEffects"/> (finished ones are pruned periodically).</summary>
|
||||||
{
|
private readonly List<Voice> _effectVoices = new();
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly List<SoundEvent> _activeEvents = new();
|
/// <summary>Count of <see cref="_effectVoices"/> at which the next prune runs.</summary>
|
||||||
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
|
private int _effectVoicePruneAt = EffectVoicePruneCount;
|
||||||
private readonly object _lock = new();
|
|
||||||
|
|
||||||
// Target Audio Settings
|
/// <summary>Song source, or null when there is no song or it could not be loaded.</summary>
|
||||||
private readonly int _targetChannels;
|
private AudioClipSource? _bgmSource;
|
||||||
private readonly int _targetSampleRate;
|
|
||||||
private const float ClipVolume = .5f;
|
|
||||||
|
|
||||||
// Audio Filter Settings
|
/// <summary>audioLib key (AudioClip.name) of the song.</summary>
|
||||||
private readonly LinkedList<IAudioFilter> _filters = new();
|
private string _bgmKey = "";
|
||||||
|
|
||||||
// BGM Cached Samples (Pre-resampled)
|
/// <summary>Current song voice, created by the first <see cref="UpdateBGM"/> and replaced when the start time changes.</summary>
|
||||||
private float[]? _bgmSamples;
|
private Voice? _bgmVoice;
|
||||||
private double _bgmDuration;
|
|
||||||
|
|
||||||
// Clip Cache
|
// Values last sent to the song voice (start in seconds relative to the render start); NaN before the first update.
|
||||||
private readonly AudioClipCache _clipCache = new();
|
private double _bgmStart = double.NaN;
|
||||||
private bool _isPrimed;
|
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)
|
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
||||||
{
|
{
|
||||||
_targetChannels = targetChannels;
|
_channels = targetChannels;
|
||||||
_targetSampleRate = targetSampleRate;
|
_sampleRate = targetSampleRate;
|
||||||
|
_store = new AudioClipStore(targetSampleRate);
|
||||||
|
_mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token);
|
||||||
|
|
||||||
var rmsCompressor = new RMSCompressor
|
var rmsCompressor = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = -10, Ratio = 2,
|
ThresholdDb = -10, Ratio = 2,
|
||||||
AttackTime = 0.1f, ReleaseTime = 0.1f,
|
AttackTime = 0.1, ReleaseTime = 0.1,
|
||||||
KneeDb = 10, RmsWindowTime = 0.02f
|
KneeDb = 10, RmsWindowTime = 0.02
|
||||||
};
|
};
|
||||||
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(rmsCompressor);
|
|
||||||
|
|
||||||
var limiter = new RMSCompressor
|
var limiter = new RMSCompressor
|
||||||
{
|
{
|
||||||
ThresholdDb = 0, Ratio = float.PositiveInfinity,
|
ThresholdDb = 0, Ratio = double.PositiveInfinity,
|
||||||
AttackTime = 0f, ReleaseTime = 0.05f,
|
AttackTime = 0, ReleaseTime = 0.05,
|
||||||
KneeDb = 0, RmsWindowTime = 0.02f,
|
KneeDb = 0, RmsWindowTime = 0.02,
|
||||||
LookAheadTime = 0.01f
|
LookAheadTime = 0.01
|
||||||
};
|
};
|
||||||
limiter.Initialize(_targetSampleRate, _targetChannels);
|
_filters.Add(rmsCompressor);
|
||||||
_filters.AddLast(limiter);
|
_filters.Add(limiter);
|
||||||
|
_filters.Add(new HardClip());
|
||||||
|
_filters.Add(new Gain { Level = -1 });
|
||||||
|
|
||||||
var hardClip = new HardClip();
|
// Total delay of the chain; only the limiter's look-ahead contributes (0.01 s).
|
||||||
hardClip.Initialize(_targetSampleRate, _targetChannels);
|
double latency = 0;
|
||||||
_filters.AddLast(hardClip);
|
foreach (var filter in _filters)
|
||||||
|
{
|
||||||
|
filter.Initialize(_sampleRate, _channels);
|
||||||
|
latency += filter.Latency;
|
||||||
|
}
|
||||||
|
_latencySamples = (int)Math.Round(latency * _sampleRate);
|
||||||
|
|
||||||
var truePeakSafetyGain = new Gain { Level = -1f };
|
_store.Start();
|
||||||
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
|
|
||||||
_filters.AddLast(truePeakSafetyGain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
public void InitializeBGM(AudioClip? clip)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
if (!clip)
|
||||||
{
|
{
|
||||||
if (!clip)
|
_bgmSource = null;
|
||||||
{
|
return;
|
||||||
_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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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);
|
if (_bgmSource == null) return;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
var conductor = scrConductor.instance;
|
var conductor = scrConductor.instance;
|
||||||
if (!conductor) return;
|
if (!conductor) return;
|
||||||
|
|
||||||
double pitch = conductor.song.pitch;
|
double pitch = conductor.song.pitch;
|
||||||
var dspTimeSong = conductor.dspTimeSong;
|
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
||||||
var separateCountdownOffset = conductor.separateCountdownTime
|
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
||||||
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
|
// levelData.volume is a percentage (100 = unchanged; scrConductor sets song.volume = volume * 0.01 the same way).
|
||||||
: 0.0;
|
// 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
|
if (_bgmVoice == null || start != _bgmStart)
|
||||||
var time1 = dspTimeSong + separateCountdownOffset;
|
|
||||||
var relativeTime1 = time1 - dspTimeBase;
|
|
||||||
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
|
|
||||||
|
|
||||||
var totalSamples = _bgmSamples.Length / _targetChannels;
|
|
||||||
|
|
||||||
for (int s = 0; s < samplesNeeded; s++)
|
|
||||||
{
|
{
|
||||||
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
// Song position at time t is (t - start) * pitch, also when the start is already past.
|
||||||
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
|
_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)
|
if (pitch != _bgmPitch)
|
||||||
{
|
{
|
||||||
double sampleIdxDouble = clipTime * _targetSampleRate;
|
_bgmVoice.SetPitch(pitch, now);
|
||||||
long idxLow = (long)Math.Floor(sampleIdxDouble);
|
_bgmPitch = pitch;
|
||||||
long idxHigh = idxLow + 1;
|
}
|
||||||
float t = (float)(sampleIdxDouble - idxLow);
|
if (volume != _bgmVolume)
|
||||||
|
{
|
||||||
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
|
_bgmVoice.SetVolume(volume, now);
|
||||||
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
|
_bgmVolume = volume;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
_effectVoices.RemoveAll(v => v.IsFinished);
|
||||||
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
|
_effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2);
|
||||||
if (clipData == null)
|
}
|
||||||
|
_effectVoices.Add(voice);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
{
|
{
|
||||||
_activeEvents.RemoveAt(i);
|
_primed = true;
|
||||||
continue;
|
if (_latencySamples > 0)
|
||||||
}
|
|
||||||
|
|
||||||
var isSoundFinished = false;
|
|
||||||
var totalSamples = clipData.Samples.Length / _targetChannels;
|
|
||||||
double adjustedStartTime = ev.StartTime - totalLatency;
|
|
||||||
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
|
|
||||||
|
|
||||||
for (var s = 0; s < samplesNeeded; s++)
|
|
||||||
{
|
|
||||||
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
|
|
||||||
|
|
||||||
// If it is a looping sound and has reached or exceeded its end time, stop playing.
|
|
||||||
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
|
|
||||||
{
|
{
|
||||||
isSoundFinished = true;
|
var prime = MixBlock(0, _latencySamples);
|
||||||
break;
|
foreach (var filter in _filters)
|
||||||
}
|
filter.ProcessBlock(prime);
|
||||||
|
|
||||||
var elapsed = sampleTime - adjustedStartTime;
|
|
||||||
if (elapsed < 0) continue; // Sound hasn't started yet
|
|
||||||
|
|
||||||
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)
|
var block = MixBlock(_nextOutputSample + _latencySamples, samplesNeeded);
|
||||||
if (isSoundFinished)
|
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)
|
||||||
{
|
{
|
||||||
_activeEvents.RemoveAt(i);
|
_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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,16 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
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
|
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;
|
private static readonly string BundlePlatform;
|
||||||
|
/// <summary>The mod assembly that contains the bundle resources.</summary>
|
||||||
private static readonly Assembly? ExecutingAssembly;
|
private static readonly Assembly? ExecutingAssembly;
|
||||||
|
|
||||||
static BundleLoader()
|
static BundleLoader()
|
||||||
@@ -22,6 +29,16 @@ internal static class BundleLoader
|
|||||||
ExecutingAssembly = Assembly.GetExecutingAssembly();
|
ExecutingAssembly = Assembly.GetExecutingAssembly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the bundle stored as manifest resource "ADOFAIRenderer.Bundle.<platform>.<resourcePath>" 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)
|
public static AssetBundle GetBundle(string resourcePath)
|
||||||
{
|
{
|
||||||
if (ExecutingAssembly != null)
|
if (ExecutingAssembly != null)
|
||||||
|
|||||||
+545
-160
File diff suppressed because it is too large.
Load diff
+858
-264
File diff suppressed because it is too large.
Load diff
@@ -6,52 +6,107 @@ using ADOFAIRenderer.Data;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util.FFmpeg;
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
internal enum EncoderKind
|
internal enum EncoderKind
|
||||||
{
|
{
|
||||||
|
/// <summary>CPU encoder library linked into FFmpeg (libx264, libx265, libvpx-vp9, libsvtav1, libaom-av1, libvvenc).</summary>
|
||||||
Software,
|
Software,
|
||||||
|
|
||||||
|
/// <summary>NVIDIA NVENC hardware encoder (*_nvenc).</summary>
|
||||||
NVENC,
|
NVENC,
|
||||||
|
|
||||||
|
/// <summary>AMD Advanced Media Framework hardware encoder (*_amf).</summary>
|
||||||
AMF,
|
AMF,
|
||||||
|
|
||||||
|
/// <summary>Intel Quick Sync Video hardware encoder (*_qsv), driven through a QSV hardware device context.</summary>
|
||||||
QSV,
|
QSV,
|
||||||
|
|
||||||
|
/// <summary>Apple VideoToolbox hardware encoder (*_videotoolbox, macOS).</summary>
|
||||||
VideoToolbox
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
|
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
|
||||||
{
|
{
|
||||||
// ReSharper disable MemberCanBePrivate.Global
|
// 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 ──
|
// ── 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);
|
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);
|
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);
|
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);
|
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);
|
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
|
||||||
|
|
||||||
// ── H.265 (HEVC) ──
|
// ── 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);
|
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);
|
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);
|
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);
|
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);
|
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
|
||||||
|
|
||||||
// ── VP9 ──
|
// ── 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);
|
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);
|
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);
|
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
|
||||||
|
|
||||||
// ── AV1 ──
|
// ── 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);
|
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);
|
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);
|
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);
|
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);
|
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
|
||||||
|
|
||||||
// ── VVC ──
|
// ── 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);
|
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
|
||||||
|
|
||||||
// ReSharper restore MemberCanBePrivate.Global
|
// 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[]
|
public static readonly IReadOnlyList<VideoEncoder> All = new[]
|
||||||
{
|
{
|
||||||
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
|
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
|
||||||
@@ -61,15 +116,26 @@ internal sealed class VideoEncoder : IEquatable<VideoEncoder>
|
|||||||
LibVvenc
|
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 =
|
private static readonly Dictionary<string, VideoEncoder> ByName =
|
||||||
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
|
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; }
|
public string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>Video codec of the bitstream the encoder produces.</summary>
|
||||||
public Codec Codec { get; }
|
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; }
|
public GPUVendor Vendor { get; }
|
||||||
|
|
||||||
|
/// <summary>Encoder family (software or hardware API).</summary>
|
||||||
public EncoderKind Kind { get; }
|
public EncoderKind Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>True for every family except <see cref="EncoderKind.Software"/>.</summary>
|
||||||
public bool IsHardware => Kind != EncoderKind.Software;
|
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)
|
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
@@ -78,36 +144,46 @@ internal sealed class VideoEncoder : IEquatable<VideoEncoder>
|
|||||||
Kind = kind;
|
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)
|
public static VideoEncoder? FromName(string name)
|
||||||
{
|
{
|
||||||
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(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)
|
public bool Equals(VideoEncoder other)
|
||||||
{
|
{
|
||||||
if (ReferenceEquals(null, other)) return false;
|
if (ReferenceEquals(null, other)) return false;
|
||||||
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
|
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)
|
public override bool Equals(object? obj)
|
||||||
{
|
{
|
||||||
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
|
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()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
|
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)
|
public static bool operator ==(VideoEncoder left, VideoEncoder right)
|
||||||
{
|
{
|
||||||
return Equals(left, right);
|
return Equals(left, right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Negation of the <c>==</c> operator.</summary>
|
||||||
public static bool operator !=(VideoEncoder left, VideoEncoder right)
|
public static bool operator !=(VideoEncoder left, VideoEncoder right)
|
||||||
{
|
{
|
||||||
return !Equals(left, right);
|
return !Equals(left, right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the FFmpeg encoder name (<see cref="Name"/>).</summary>
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,15 @@
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
namespace ADOFAIRenderer.Util;
|
||||||
|
|
||||||
|
/// <summary>File system helpers.</summary>
|
||||||
internal static class FileUtil
|
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)
|
public static void CreateDirectory(string path)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
|
||||||
|
|||||||
+149
-6
@@ -13,25 +13,61 @@ using UnityEngine.Rendering;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
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
|
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 Height = Shader.PropertyToID("Height");
|
||||||
private static readonly int Width = Shader.PropertyToID("Width");
|
private static readonly int Width = Shader.PropertyToID("Width");
|
||||||
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
|
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
|
||||||
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
|
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
|
||||||
|
/// <summary>Number of readback states created by InitializePool.</summary>
|
||||||
private const int InitialBufferSize = 16;
|
private const int InitialBufferSize = 16;
|
||||||
|
|
||||||
|
/// <summary>States that are free for a new request. Guarded by _poolLock.</summary>
|
||||||
private readonly Stack<ReadbackRequestState> _statePool = new();
|
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();
|
private readonly List<ReadbackRequestState> _allocatedStates = new();
|
||||||
|
/// <summary>Lock for _statePool, _allocatedStates, _isInitialized, _isDisposed and ReadbackRequestState.IsInFlight.</summary>
|
||||||
private readonly object _poolLock = new();
|
private readonly object _poolLock = new();
|
||||||
|
/// <summary>True after InitializePool; false again after Dispose (RecycleState then disposes returned states).</summary>
|
||||||
private bool _isInitialized;
|
private bool _isInitialized;
|
||||||
|
/// <summary>Set by Dispose. Completions and recycles that arrive afterwards dispose their state.</summary>
|
||||||
private volatile bool _isDisposed;
|
private volatile bool _isDisposed;
|
||||||
|
|
||||||
|
/// <summary>Color conversion compute shader from the asset bundle (ModAssets.ColorConversionShader); null when not loaded.</summary>
|
||||||
private readonly ComputeShader? _computeShader;
|
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;
|
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;
|
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;
|
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)
|
public FrameReader(AVPixelFormat targetFormat)
|
||||||
{
|
{
|
||||||
_computeShader = ModAssets.ColorConversionShader;
|
_computeShader = ModAssets.ColorConversionShader;
|
||||||
@@ -57,6 +93,7 @@ namespace ADOFAIRenderer.Util
|
|||||||
InitializePool();
|
InitializePool();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates InitialBufferSize states once. Their buffers are allocated on first use (ReadbackRequestState.Setup).</summary>
|
||||||
private void InitializePool()
|
private void InitializePool()
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -73,6 +110,25 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bytes of the readback buffer of one frame: Width x Height x 3 / 2 when the GPU conversion to NV12 / YUV420P
|
||||||
|
/// is used, Width x Height x 4 (RGBA) otherwise. The same size as ReadbackRequestState.Setup uses.
|
||||||
|
/// </summary>
|
||||||
|
public long GetFrameBufferBytes(int width, int height)
|
||||||
|
{
|
||||||
|
var pixels = (long)width * height;
|
||||||
|
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)
|
public void RequestFrameCapture(Texture texture, long currentFrame)
|
||||||
{
|
{
|
||||||
if (!RenderManager.IsCapturingActive) return;
|
if (!RenderManager.IsCapturingActive) return;
|
||||||
@@ -103,6 +159,8 @@ namespace ADOFAIRenderer.Util
|
|||||||
_computeShader.SetInt(Width, texture.width);
|
_computeShader.SetInt(Width, texture.width);
|
||||||
_computeShader.SetInt(Height, texture.height);
|
_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
|
var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
|
||||||
? (texture.width + 31) / 32
|
? (texture.width + 31) / 32
|
||||||
: (texture.width + 63) / 64;
|
: (texture.width + 63) / 64;
|
||||||
@@ -110,6 +168,8 @@ namespace ADOFAIRenderer.Util
|
|||||||
|
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_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(
|
AsyncGPUReadback.RequestIntoNativeArray(
|
||||||
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
|
||||||
);
|
);
|
||||||
@@ -126,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)
|
internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request)
|
||||||
{
|
{
|
||||||
var frame = state.FrameIndex;
|
var frame = state.FrameIndex;
|
||||||
@@ -140,7 +211,7 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
|
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
|
||||||
{
|
{
|
||||||
RecycleState(state);
|
RecycleState(state);
|
||||||
return;
|
return;
|
||||||
@@ -154,14 +225,11 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
|
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
|
||||||
|
|
||||||
var byteSize = state.Width * state.Height * 3 / 2;
|
var blackFrame = CreateBlackFrame(state, out var byteSize);
|
||||||
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
|
|
||||||
Array.Clear(blackFrame, 0, byteSize);
|
|
||||||
|
|
||||||
var capFrame = new RenderManager.CapturedFrame
|
var capFrame = new RenderManager.CapturedFrame
|
||||||
{
|
{
|
||||||
ManagedBuffer = blackFrame, Length = byteSize,
|
ManagedBuffer = blackFrame, Length = byteSize,
|
||||||
IsColorConverted = true
|
IsColorConverted = state.IsGPUConversion
|
||||||
};
|
};
|
||||||
PushToQueue(frame, capFrame);
|
PushToQueue(frame, capFrame);
|
||||||
RecycleState(state);
|
RecycleState(state);
|
||||||
@@ -190,6 +258,40 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a black frame in the same format as the frames of this reader, from ArrayPool<byte>.Shared
|
||||||
|
/// (returned to the pool by the render thread after encoding).
|
||||||
|
///
|
||||||
|
/// GPU conversion (NV12 / YUV420P, full range): Width × Height × 3 / 2 bytes.
|
||||||
|
/// Y plane (Width × Height bytes) = 0, U/V data (the rest) = 128.
|
||||||
|
/// 128 is the zero chroma value of the 8-bit full range: the U/V offset 0.5 of RGB2YUV_BT709_Full
|
||||||
|
/// in ColorConversion.compute, which the encoder input of this path uses.
|
||||||
|
/// Both NV12 (interleaved UV) and YUV420P (U plane, then V plane) store all chroma after the Y plane,
|
||||||
|
/// so the same fill applies to both.
|
||||||
|
/// No GPU conversion (RGBA input, converted by the encoder): Width × Height × 4 bytes, all 0.
|
||||||
|
/// </summary>
|
||||||
|
private static byte[] CreateBlackFrame(ReadbackRequestState state, out int byteSize)
|
||||||
|
{
|
||||||
|
var lumaSize = state.Width * state.Height;
|
||||||
|
byteSize = state.IsGPUConversion ? lumaSize * 3 / 2 : lumaSize * 4;
|
||||||
|
|
||||||
|
var frame = ArrayPool<byte>.Shared.Rent(byteSize);
|
||||||
|
if (state.IsGPUConversion)
|
||||||
|
{
|
||||||
|
Array.Clear(frame, 0, lumaSize);
|
||||||
|
frame.AsSpan(lumaSize, byteSize - lumaSize).Fill(128);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Array.Clear(frame, 0, byteSize);
|
||||||
|
}
|
||||||
|
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)
|
internal void RecycleState(ReadbackRequestState state)
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -205,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)
|
private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame)
|
||||||
{
|
{
|
||||||
if (!RenderManager.Instance) return;
|
if (!RenderManager.Instance) return;
|
||||||
@@ -215,6 +321,11 @@ namespace ADOFAIRenderer.Util
|
|||||||
RenderManager.Instance.FrameAvailableEvent.Set();
|
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()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
lock (_poolLock)
|
lock (_poolLock)
|
||||||
@@ -232,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
|
internal class ReadbackRequestState : IDisposable
|
||||||
{
|
{
|
||||||
|
/// <summary>Reader that receives the completion and owns the pool.</summary>
|
||||||
private readonly FrameReader _owner;
|
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; }
|
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;
|
public NativeArray<byte> NativeBuffer;
|
||||||
|
/// <summary>True between the request and its completion. Written under FrameReader._poolLock.</summary>
|
||||||
public bool IsInFlight { get; internal set; }
|
public bool IsInFlight { get; internal set; }
|
||||||
|
|
||||||
|
/// <summary>Raw ComputeBuffer (4-byte elements) the conversion kernel writes; null without GPU conversion.</summary>
|
||||||
public ComputeBuffer? GPUBuffer;
|
public ComputeBuffer? GPUBuffer;
|
||||||
|
/// <summary>True when the current frame is converted to YUV on the GPU.</summary>
|
||||||
public bool IsGPUConversion { get; private set; }
|
public bool IsGPUConversion { get; private set; }
|
||||||
|
/// <summary>Width of the current frame in pixels.</summary>
|
||||||
public int Width { get; private set; }
|
public int Width { get; private set; }
|
||||||
|
/// <summary>Height of the current frame in pixels.</summary>
|
||||||
public int Height { get; private set; }
|
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;
|
public readonly Action<AsyncGPUReadbackRequest> CachedCallback;
|
||||||
|
|
||||||
|
/// <summary>Creates a free state without buffers.</summary>
|
||||||
public ReadbackRequestState(FrameReader owner)
|
public ReadbackRequestState(FrameReader owner)
|
||||||
{
|
{
|
||||||
_owner = owner;
|
_owner = owner;
|
||||||
@@ -253,6 +382,17 @@ namespace ADOFAIRenderer.Util
|
|||||||
FrameIndex = -1;
|
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)
|
public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height)
|
||||||
{
|
{
|
||||||
FrameIndex = frameIndex;
|
FrameIndex = frameIndex;
|
||||||
@@ -283,16 +423,19 @@ namespace ADOFAIRenderer.Util
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Readback completion; forwards to FrameReader.OnReadbackComplete.</summary>
|
||||||
private void OnComplete(AsyncGPUReadbackRequest request)
|
private void OnComplete(AsyncGPUReadbackRequest request)
|
||||||
{
|
{
|
||||||
_owner.OnReadbackComplete(this, 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()
|
public void Recycle()
|
||||||
{
|
{
|
||||||
FrameIndex = -1;
|
FrameIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the native buffer and the ComputeBuffer.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
|
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
|
||||||
|
|||||||
+44
-1
@@ -4,32 +4,54 @@ using System.Linq;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using ADOFAIRenderer.Data;
|
using ADOFAIRenderer.Data;
|
||||||
using ADOFAIRenderer.Util.FFmpeg;
|
using ADOFAIRenderer.Util.FFmpeg;
|
||||||
|
using ADOFAIRenderer.Util.OS;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
internal enum GPUVendor
|
internal enum GPUVendor
|
||||||
{
|
{
|
||||||
Unknown, NVIDIA, AMD, Intel, Apple
|
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
|
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 =
|
private const string NvidiaPattern =
|
||||||
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
|
@"^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 =
|
private const string IntelPattern =
|
||||||
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
|
@"^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
|
// ReSharper disable once InconsistentNaming
|
||||||
private const string AMDPattern =
|
private const string AMDPattern =
|
||||||
@"^AMD Radeon RX [0-9]+(?> XT)?$";
|
@"^AMD Radeon RX [0-9]+(?> XT)?$";
|
||||||
|
|
||||||
|
/// <summary>Vendor found by the first GetActiveGPUVendor call.</summary>
|
||||||
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
|
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
|
||||||
|
/// <summary>True once _cachedVendor is set.</summary>
|
||||||
private static bool _hasCachedVendor;
|
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()
|
private static GPUVendor GetActiveGPUVendor()
|
||||||
{
|
{
|
||||||
if (_hasCachedVendor) return _cachedVendor;
|
if (_hasCachedVendor) return _cachedVendor;
|
||||||
@@ -71,9 +93,21 @@ internal static class GPUUtil
|
|||||||
return _cachedVendor;
|
return _cachedVendor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Result of the first GetNvidiaDriverVersion call (null when not available).</summary>
|
||||||
private static double? _cachedNvidiaVersion;
|
private static double? _cachedNvidiaVersion;
|
||||||
|
/// <summary>True once _cachedNvidiaVersion is set.</summary>
|
||||||
private static bool _hasCachedNvidiaVersion;
|
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()
|
public static double? GetNvidiaDriverVersion()
|
||||||
{
|
{
|
||||||
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
|
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
|
||||||
@@ -102,6 +136,9 @@ internal static class GPUUtil
|
|||||||
|
|
||||||
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
|
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
|
||||||
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
|
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}";
|
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
|
||||||
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
|
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
|
||||||
if (subkeys == null) return null;
|
if (subkeys == null) return null;
|
||||||
@@ -121,7 +158,7 @@ internal static class GPUUtil
|
|||||||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
|
||||||
|
|
||||||
var nvidiaVerInt = sub % 10 * 10000 + build;
|
var nvidiaVerInt = sub % 10 * 10000 + build;
|
||||||
_cachedNvidiaVersion = nvidiaVerInt / 100f;
|
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
|
||||||
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
|
||||||
return _cachedNvidiaVersion;
|
return _cachedNvidiaVersion;
|
||||||
}
|
}
|
||||||
@@ -129,6 +166,12 @@ internal static class GPUUtil
|
|||||||
return null;
|
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)
|
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
|
||||||
{
|
{
|
||||||
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
|
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
|
||||||
|
|||||||
@@ -8,14 +8,51 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MelonLoader;
|
using MelonLoader;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
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 does not return an existing directory (not installed,
|
||||||
|
/// cancelled or failed), <c>kdialog --getexistingdirectory</c>.
|
||||||
|
/// </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", "InconsistentNaming")]
|
||||||
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
[SuppressMessage("ReSharper", "UnusedMember.Local")]
|
||||||
internal static class FolderDialogUtil
|
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;
|
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)
|
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
|
||||||
{
|
{
|
||||||
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
|
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
|
||||||
@@ -24,12 +61,18 @@ internal static class FolderDialogUtil
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captured on the calling thread so the result can be posted back to it.
|
||||||
var syncContext = SynchronizationContext.Current;
|
var syncContext = SynchronizationContext.Current;
|
||||||
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
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();
|
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(() =>
|
var thread = new Thread(() =>
|
||||||
{
|
{
|
||||||
string? selected = null;
|
string? selected = null;
|
||||||
@@ -45,6 +88,8 @@ internal static class FolderDialogUtil
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
// OleInitialize returns S_OK or S_FALSE (already initialized) on success; each successful call is
|
||||||
|
// balanced by one OleUninitialize.
|
||||||
if (oleHr >= 0)
|
if (oleHr >= 0)
|
||||||
{
|
{
|
||||||
try
|
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)
|
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
|
||||||
{
|
{
|
||||||
if (syncContext != null)
|
if (syncContext != null)
|
||||||
@@ -129,20 +181,32 @@ internal static class FolderDialogUtil
|
|||||||
|
|
||||||
#region Windows Implementation
|
#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 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_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
|
||||||
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE");
|
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);
|
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
|
||||||
|
|
||||||
|
// Active window attached to the calling thread's message queue, or NULL.
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr GetActiveWindow();
|
private static extern IntPtr GetActiveWindow();
|
||||||
|
|
||||||
|
// Foreground window of the desktop (any process), or NULL.
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern IntPtr GetForegroundWindow();
|
private static extern IntPtr GetForegroundWindow();
|
||||||
|
|
||||||
|
// Thread ID that created the window; the process ID is returned through lpdwProcessId.
|
||||||
[DllImport("user32.dll")]
|
[DllImport("user32.dll")]
|
||||||
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
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()
|
private static IntPtr GetGameWindowHandle()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -175,6 +239,13 @@ internal static class FolderDialogUtil
|
|||||||
return IntPtr.Zero;
|
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)
|
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
|
||||||
{
|
{
|
||||||
try
|
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)
|
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 clsid = CLSID_FileOpenDialog;
|
||||||
var iid = IID_IFileOpenDialog;
|
var iid = IID_IFileOpenDialog;
|
||||||
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
|
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
|
||||||
|
|
||||||
if (createHr != 0)
|
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);
|
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
|
||||||
if (comType != null)
|
if (comType != null)
|
||||||
{
|
{
|
||||||
@@ -223,6 +309,7 @@ internal static class FolderDialogUtil
|
|||||||
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
||||||
if (Directory.Exists(normalized))
|
if (Directory.Exists(normalized))
|
||||||
{
|
{
|
||||||
|
// The dialog takes folders as IShellItem; SHCreateItemFromParsingName creates one from the path.
|
||||||
var shellItemGuid = IID_IShellItem;
|
var shellItemGuid = IID_IShellItem;
|
||||||
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
|
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
|
||||||
if (itemHr == 0)
|
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)
|
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 pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
|
||||||
IntPtr initialDirPtr = IntPtr.Zero;
|
IntPtr initialDirPtr = IntPtr.Zero;
|
||||||
BrowseCallbackProc? callback = null;
|
BrowseCallbackProc? callback = null;
|
||||||
@@ -318,6 +416,9 @@ internal static class FolderDialogUtil
|
|||||||
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
|
||||||
if (Directory.Exists(normalized))
|
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);
|
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
|
||||||
callback = (hwnd, uMsg, _, lpData) =>
|
callback = (hwnd, uMsg, _, lpData) =>
|
||||||
{
|
{
|
||||||
@@ -340,12 +441,12 @@ internal static class FolderDialogUtil
|
|||||||
pidl = SHBrowseForFolderW(ref bi);
|
pidl = SHBrowseForFolderW(ref bi);
|
||||||
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
|
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate
|
// SHBrowseForFolderW returns NULL both on cancel and on failure, so no retry without the owner is made.
|
||||||
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pidl != IntPtr.Zero)
|
if (pidl != IntPtr.Zero)
|
||||||
{
|
{
|
||||||
|
// SHGetPathFromIDListW writes up to MAX_PATH (260) characters.
|
||||||
var sb = new StringBuilder(260);
|
var sb = new StringBuilder(260);
|
||||||
if (SHGetPathFromIDListW(pidl, sb))
|
if (SHGetPathFromIDListW(pidl, sb))
|
||||||
{
|
{
|
||||||
@@ -376,6 +477,10 @@ internal static class FolderDialogUtil
|
|||||||
|
|
||||||
#region COM & Win32 Interop Definitions
|
#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]
|
[Flags]
|
||||||
private enum FOS : uint
|
private enum FOS : uint
|
||||||
{
|
{
|
||||||
@@ -386,11 +491,20 @@ internal static class FolderDialogUtil
|
|||||||
FOS_FILEMUSTEXIST = 0x00001000,
|
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
|
private enum SIGDN : uint
|
||||||
{
|
{
|
||||||
SIGDN_FILESYSPATH = 0x80058000
|
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]
|
[ComImport]
|
||||||
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
|
||||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
@@ -404,6 +518,11 @@ internal static class FolderDialogUtil
|
|||||||
void Compare(IShellItem psi, uint hint, out int piOrder);
|
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]
|
[ComImport]
|
||||||
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
|
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
|
||||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
@@ -438,12 +557,15 @@ internal static class FolderDialogUtil
|
|||||||
void GetSelectedItems(out IntPtr ppsai);
|
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)]
|
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||||
private static extern int OleInitialize(IntPtr pvReserved);
|
private static extern int OleInitialize(IntPtr pvReserved);
|
||||||
|
|
||||||
|
// Balances one successful OleInitialize on the same thread.
|
||||||
[DllImport("ole32.dll", ExactSpelling = true)]
|
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||||
private static extern void OleUninitialize();
|
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)]
|
[DllImport("ole32.dll", ExactSpelling = true)]
|
||||||
private static extern int CoCreateInstance(
|
private static extern int CoCreateInstance(
|
||||||
[In] ref Guid rclsid,
|
[In] ref Guid rclsid,
|
||||||
@@ -452,6 +574,7 @@ internal static class FolderDialogUtil
|
|||||||
[In] ref Guid riid,
|
[In] ref Guid riid,
|
||||||
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
|
[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)]
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
|
||||||
private static extern int SHCreateItemFromParsingName(
|
private static extern int SHCreateItemFromParsingName(
|
||||||
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
|
||||||
@@ -459,14 +582,22 @@ internal static class FolderDialogUtil
|
|||||||
[In] ref Guid riid,
|
[In] ref Guid riid,
|
||||||
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
|
[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_RETURNONLYFSDIRS = 0x0001;
|
||||||
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
|
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
|
||||||
private const uint BIF_EDITBOX = 0x0010;
|
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_INITIALIZED = 1;
|
||||||
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
|
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);
|
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)]
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
private struct BROWSEINFO
|
private struct BROWSEINFO
|
||||||
{
|
{
|
||||||
@@ -480,13 +611,16 @@ internal static class FolderDialogUtil
|
|||||||
public int iImage;
|
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)]
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
|
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)]
|
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
|
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)]
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||||
|
|
||||||
@@ -496,10 +630,21 @@ internal static class FolderDialogUtil
|
|||||||
|
|
||||||
#region macOS Implementation
|
#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)
|
private static string? OpenMacFolderBrowser(string initialDirectory)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// POSIX path of (...) converts the chosen folder alias to a slash-separated path.
|
||||||
string script;
|
string script;
|
||||||
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
|
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
|
||||||
{
|
{
|
||||||
@@ -541,6 +686,17 @@ internal static class FolderDialogUtil
|
|||||||
|
|
||||||
#region Linux Implementation
|
#region Linux Implementation
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> and blocks until it exits.
|
||||||
|
/// When zenity cannot be started, exits with a non-zero code (cancel or error) or prints a path that is not an existing
|
||||||
|
/// directory, 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 both dialogs return nothing, when kdialog cannot be started, or on an
|
||||||
|
/// exception (logged as a warning).
|
||||||
|
/// </returns>
|
||||||
private static string? OpenLinuxFolderBrowser(string initialDirectory)
|
private static string? OpenLinuxFolderBrowser(string initialDirectory)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Threading;
|
||||||
|
using MelonLoader;
|
||||||
|
|
||||||
|
namespace ADOFAIRenderer.Util.OS;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
||||||
|
/// <para>
|
||||||
|
/// - <b>Windows</b>: Toast notification shown by a hidden <c>powershell.exe</c> process
|
||||||
|
/// (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 "<folder>"</c>)
|
||||||
|
/// 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> (no folder action), started from the
|
||||||
|
/// calling thread without waiting.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// - <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 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 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
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
WindowsNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
MacNotification.Send(title, message);
|
||||||
|
}
|
||||||
|
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
LinuxNotification.Send(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Windows notification with two strategies:
|
||||||
|
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
||||||
|
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
||||||
|
/// </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(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!TrySendToast(title, message, folderPath))
|
||||||
|
{
|
||||||
|
SendBalloon(title, message, folderPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
{
|
||||||
|
IsBackground = true,
|
||||||
|
Name = "OSNotification-Win"
|
||||||
|
};
|
||||||
|
thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Toast via PowerShell
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attempts to show a Windows Toast notification via PowerShell.
|
||||||
|
/// 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;
|
||||||
|
|
||||||
|
string toastXml;
|
||||||
|
string psScript;
|
||||||
|
|
||||||
|
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\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual>" +
|
||||||
|
"<actions>" +
|
||||||
|
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
||||||
|
"</actions></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
||||||
|
"$null = New-Item -Path $protocolPath -Force\n" +
|
||||||
|
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
||||||
|
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
||||||
|
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
||||||
|
"$null = New-Item -Path $cmdPath -Force\n" +
|
||||||
|
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
toastXml =
|
||||||
|
"<toast><visual><binding template=\"ToastGeneric\">" +
|
||||||
|
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
||||||
|
"</binding></visual></toast>";
|
||||||
|
|
||||||
|
psScript =
|
||||||
|
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
||||||
|
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
||||||
|
$"$xml.LoadXml('{toastXml}')\n" +
|
||||||
|
"[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);
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
process.WaitForExit(5000);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
if (!string.IsNullOrEmpty(stderr))
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#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;
|
||||||
|
private const int NIF_INFO = 0x10;
|
||||||
|
|
||||||
|
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,
|
||||||
|
int dwStyle, int x, int y, int nWidth, int nHeight,
|
||||||
|
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
[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);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
||||||
|
|
||||||
|
[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
|
||||||
|
{
|
||||||
|
public int cbSize;
|
||||||
|
public IntPtr hWnd;
|
||||||
|
public int uID;
|
||||||
|
public int uFlags;
|
||||||
|
public int uCallbackMessage;
|
||||||
|
public IntPtr hIcon;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
||||||
|
public string szTip;
|
||||||
|
public int dwState;
|
||||||
|
public int dwStateMask;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
||||||
|
public string szInfo;
|
||||||
|
public int uTimeoutOrVersion;
|
||||||
|
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||||
|
public string szInfoTitle;
|
||||||
|
public int dwInfoFlags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win32 MSG structure filled by GetMessageW.
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct Msg
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public int pt_x;
|
||||||
|
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);
|
||||||
|
|
||||||
|
IntPtr hWnd = IntPtr.Zero;
|
||||||
|
var nid = new NotifyIconDataW();
|
||||||
|
|
||||||
|
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",
|
||||||
|
WS_OVERLAPPED, 0, 0, 0, 0,
|
||||||
|
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
||||||
|
|
||||||
|
if (hWnd == IntPtr.Zero)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 32512 = IDI_APPLICATION (winuser.h), the default application icon.
|
||||||
|
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
||||||
|
|
||||||
|
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
||||||
|
nid.hWnd = hWnd;
|
||||||
|
nid.uID = TRAY_ICON_UID;
|
||||||
|
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
||||||
|
nid.uCallbackMessage = WM_TRAYICON;
|
||||||
|
nid.hIcon = hIcon;
|
||||||
|
nid.szTip = "ADOFAI Renderer";
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
{
|
||||||
|
var capturedFolder = folderPath;
|
||||||
|
var closeTimer = new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
||||||
|
catch { /* ignored */ }
|
||||||
|
}, null, 12000, Timeout.Infinite);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
||||||
|
{
|
||||||
|
if (msg.message == WM_CLOSE)
|
||||||
|
break;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var psi = new ProcessStartInfo();
|
||||||
|
psi.FileName = capturedFolder;
|
||||||
|
psi.UseShellExecute = true;
|
||||||
|
Process.Start(psi);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning(
|
||||||
|
$"[OSNotification] Cannot open folder: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TranslateMessage(ref msg);
|
||||||
|
DispatchMessageW(ref msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
closeTimer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Thread.Sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (hWnd != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
nid.uFlags = 0;
|
||||||
|
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
||||||
|
DestroyWindow(hWnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region macOS — osascript
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
||||||
|
/// </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
|
||||||
|
string escapedTitle = EscapeAppleScript(title);
|
||||||
|
string escapedMessage = EscapeAppleScript(message);
|
||||||
|
|
||||||
|
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
||||||
|
// Each argument is passed directly to the process without shell interpretation.
|
||||||
|
var script =
|
||||||
|
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "osascript",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-e");
|
||||||
|
startInfo.ArgumentList.Add(script);
|
||||||
|
|
||||||
|
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
|
||||||
|
return value
|
||||||
|
.Replace("\\", "\\\\")
|
||||||
|
.Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Linux — notify-send / gdbus
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
||||||
|
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
||||||
|
/// </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))
|
||||||
|
{
|
||||||
|
ShowWithAction(title, message, folderPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
if (TryNotifySend(title, message))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Fallback to gdbus
|
||||||
|
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 <folderPath></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
|
||||||
|
// until the notification is dismissed or the action is clicked.
|
||||||
|
System.Threading.Tasks.Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
string output = process.StandardOutput.ReadToEnd().Trim();
|
||||||
|
process.WaitForExit();
|
||||||
|
|
||||||
|
if (output == "open_folder")
|
||||||
|
{
|
||||||
|
var openInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "xdg-open",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
openInfo.ArgumentList.Add(folderPath);
|
||||||
|
|
||||||
|
RunProcess(openInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
||||||
|
|
||||||
|
// Fallback: show without action
|
||||||
|
ShowSimple(title, message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts <c>notify-send -a "ADOFAI Renderer" <title> <message></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
|
||||||
|
{
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "notify-send",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("-a");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
||||||
|
startInfo.ArgumentList.Add(title);
|
||||||
|
startInfo.ArgumentList.Add(message);
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
{
|
||||||
|
// Direct D-Bus call to org.freedesktop.Notifications
|
||||||
|
var startInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "gdbus",
|
||||||
|
CreateNoWindow = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add("call");
|
||||||
|
startInfo.ArgumentList.Add("--session");
|
||||||
|
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
||||||
|
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
||||||
|
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
||||||
|
startInfo.ArgumentList.Add("0"); // replaces_id
|
||||||
|
startInfo.ArgumentList.Add(""); // app_icon
|
||||||
|
startInfo.ArgumentList.Add(title); // summary
|
||||||
|
startInfo.ArgumentList.Add(message); // body
|
||||||
|
startInfo.ArgumentList.Add("[]"); // actions
|
||||||
|
startInfo.ArgumentList.Add("{}"); // hints
|
||||||
|
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
||||||
|
|
||||||
|
RunProcess(startInfo);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Shared utility
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
||||||
|
/// </summary>
|
||||||
|
private static void RunProcess(ProcessStartInfo startInfo)
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
|
||||||
|
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
||||||
|
{
|
||||||
|
var mscorlib = typeof(object).Assembly;
|
||||||
|
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
||||||
|
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
||||||
|
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RegistryKeyType != null)
|
||||||
|
{
|
||||||
|
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
||||||
|
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
||||||
|
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
||||||
|
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (key is IDisposable disposable)
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var closeMethod = RegistryKeyType.GetMethod("Close");
|
||||||
|
closeMethod?.Invoke(key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the names of the direct subkeys of <c>HKEY_LOCAL_MACHINE\<parentKeyPath></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)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return subkeyNames;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a string value of <c>HKEY_LOCAL_MACHINE\<keyPath></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)
|
||||||
|
{
|
||||||
|
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Same hive and view constants as GetHKLMSubKeyNames.
|
||||||
|
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
||||||
|
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
||||||
|
|
||||||
|
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
||||||
|
if (baseKey == null) return null;
|
||||||
|
|
||||||
|
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
||||||
|
if (key == null)
|
||||||
|
{
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
||||||
|
CloseKey(key);
|
||||||
|
CloseKey(baseKey);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,578 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Threading;
|
|
||||||
using MelonLoader;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Sends native desktop notifications on Windows, macOS, and Linux.
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
|
|
||||||
/// No PowerShell, no UWP, no WinRT dependencies.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
|
|
||||||
/// </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.
|
|
||||||
/// </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>
|
|
||||||
public static void Show(string title, string message, string? folderPath = null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
|
||||||
{
|
|
||||||
WindowsNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
|
||||||
{
|
|
||||||
MacNotification.Send(title, message);
|
|
||||||
}
|
|
||||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
|
||||||
{
|
|
||||||
LinuxNotification.Send(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Windows notification with two strategies:
|
|
||||||
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
|
|
||||||
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
|
|
||||||
/// </summary>
|
|
||||||
private static class WindowsNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
var thread = new Thread(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!TrySendToast(title, message, folderPath))
|
|
||||||
{
|
|
||||||
SendBalloon(title, message, folderPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
{
|
|
||||||
IsBackground = true,
|
|
||||||
Name = "OSNotification-Win"
|
|
||||||
};
|
|
||||||
thread.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Toast via PowerShell
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attempts to show a Windows Toast notification via PowerShell.
|
|
||||||
/// Returns true if the process launched successfully, false otherwise.
|
|
||||||
/// </summary>
|
|
||||||
private static bool TrySendToast(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
|
|
||||||
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
|
|
||||||
|
|
||||||
string toastXml;
|
|
||||||
string psScript;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var escapedFolder = folderPath.Replace("'", "''");
|
|
||||||
|
|
||||||
toastXml =
|
|
||||||
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
|
|
||||||
"<visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual>" +
|
|
||||||
"<actions>" +
|
|
||||||
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
|
|
||||||
"</actions></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
|
|
||||||
"$null = New-Item -Path $protocolPath -Force\n" +
|
|
||||||
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
|
|
||||||
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
|
|
||||||
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
|
|
||||||
"$null = New-Item -Path $cmdPath -Force\n" +
|
|
||||||
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toastXml =
|
|
||||||
"<toast><visual><binding template=\"ToastGeneric\">" +
|
|
||||||
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
|
|
||||||
"</binding></visual></toast>";
|
|
||||||
|
|
||||||
psScript =
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
|
|
||||||
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
|
|
||||||
$"$xml.LoadXml('{toastXml}')\n" +
|
|
||||||
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
|
|
||||||
string base64 = Convert.ToBase64String(bytes);
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "powershell.exe",
|
|
||||||
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardError = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
process.WaitForExit(5000);
|
|
||||||
|
|
||||||
// If PowerShell exited with error, treat as failure
|
|
||||||
if (process.ExitCode != 0)
|
|
||||||
{
|
|
||||||
string stderr = process.StandardError.ReadToEnd().Trim();
|
|
||||||
if (!string.IsNullOrEmpty(stderr))
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Balloon via Shell_NotifyIcon
|
|
||||||
|
|
||||||
// ReSharper disable InconsistentNaming
|
|
||||||
private const int NIM_ADD = 0x00;
|
|
||||||
private const int NIM_DELETE = 0x02;
|
|
||||||
|
|
||||||
private const int NIF_MESSAGE = 0x01;
|
|
||||||
private const int NIF_ICON = 0x02;
|
|
||||||
private const int NIF_TIP = 0x04;
|
|
||||||
private const int NIF_INFO = 0x10;
|
|
||||||
|
|
||||||
private const int NIIF_INFO = 0x01;
|
|
||||||
|
|
||||||
private const int WM_USER = 0x0400;
|
|
||||||
private const int WM_TRAYICON = WM_USER + 1;
|
|
||||||
private const int NIN_BALLOONUSERCLICK = 0x0405;
|
|
||||||
|
|
||||||
private const int WS_OVERLAPPED = 0x00000000;
|
|
||||||
private const uint WM_CLOSE = 0x0010;
|
|
||||||
private const int TRAY_ICON_UID = 29471;
|
|
||||||
// ReSharper restore InconsistentNaming
|
|
||||||
|
|
||||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
|
|
||||||
|
|
||||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
private static extern IntPtr CreateWindowExW(
|
|
||||||
int dwExStyle, string lpClassName, string lpWindowName,
|
|
||||||
int dwStyle, int x, int y, int nWidth, int nHeight,
|
|
||||||
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool DestroyWindow(IntPtr hWnd);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
|
|
||||||
|
|
||||||
[DllImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
||||||
private struct NotifyIconDataW
|
|
||||||
{
|
|
||||||
public int cbSize;
|
|
||||||
public IntPtr hWnd;
|
|
||||||
public int uID;
|
|
||||||
public int uFlags;
|
|
||||||
public int uCallbackMessage;
|
|
||||||
public IntPtr hIcon;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
|
|
||||||
public string szTip;
|
|
||||||
public int dwState;
|
|
||||||
public int dwStateMask;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
|
|
||||||
public string szInfo;
|
|
||||||
public int uTimeoutOrVersion;
|
|
||||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
|
||||||
public string szInfoTitle;
|
|
||||||
public int dwInfoFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
[StructLayout(LayoutKind.Sequential)]
|
|
||||||
private struct Msg
|
|
||||||
{
|
|
||||||
public IntPtr hwnd;
|
|
||||||
public uint message;
|
|
||||||
public IntPtr wParam;
|
|
||||||
public IntPtr lParam;
|
|
||||||
public uint time;
|
|
||||||
public int pt_x;
|
|
||||||
public int pt_y;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SendBalloon(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (title.Length > 63) title = title.Substring(0, 63);
|
|
||||||
if (message.Length > 255) message = message.Substring(0, 255);
|
|
||||||
|
|
||||||
IntPtr hWnd = IntPtr.Zero;
|
|
||||||
var nid = new NotifyIconDataW();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var hwndMessage = new IntPtr(-3);
|
|
||||||
hWnd = CreateWindowExW(
|
|
||||||
0, "Static", "ADOFAIRendererNotify",
|
|
||||||
WS_OVERLAPPED, 0, 0, 0, 0,
|
|
||||||
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
|
|
||||||
|
|
||||||
if (hWnd == IntPtr.Zero)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning("[OSNotification] Cannot create message window.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
|
|
||||||
|
|
||||||
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
|
|
||||||
nid.hWnd = hWnd;
|
|
||||||
nid.uID = TRAY_ICON_UID;
|
|
||||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
|
|
||||||
nid.uCallbackMessage = WM_TRAYICON;
|
|
||||||
nid.hIcon = hIcon;
|
|
||||||
nid.szTip = "ADOFAI Renderer";
|
|
||||||
nid.szInfo = message;
|
|
||||||
nid.szInfoTitle = title;
|
|
||||||
nid.dwInfoFlags = NIIF_INFO;
|
|
||||||
nid.uTimeoutOrVersion = 4;
|
|
||||||
|
|
||||||
Shell_NotifyIconW(NIM_ADD, ref nid);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
var capturedFolder = folderPath;
|
|
||||||
var closeTimer = new System.Threading.Timer(_ =>
|
|
||||||
{
|
|
||||||
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
|
|
||||||
catch { /* ignored */ }
|
|
||||||
}, null, 12000, Timeout.Infinite);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
|
|
||||||
{
|
|
||||||
if (msg.message == WM_CLOSE)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (msg.message == WM_TRAYICON)
|
|
||||||
{
|
|
||||||
int notifyMsg = (int)(long)msg.lParam;
|
|
||||||
if (notifyMsg == NIN_BALLOONUSERCLICK)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var psi = new ProcessStartInfo();
|
|
||||||
psi.FileName = capturedFolder;
|
|
||||||
psi.UseShellExecute = true;
|
|
||||||
Process.Start(psi);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning(
|
|
||||||
$"[OSNotification] Cannot open folder: {ex.Message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TranslateMessage(ref msg);
|
|
||||||
DispatchMessageW(ref msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
closeTimer.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Thread.Sleep(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (hWnd != IntPtr.Zero)
|
|
||||||
{
|
|
||||||
nid.uFlags = 0;
|
|
||||||
Shell_NotifyIconW(NIM_DELETE, ref nid);
|
|
||||||
DestroyWindow(hWnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region macOS — osascript
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
|
|
||||||
/// </summary>
|
|
||||||
private static class MacNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message)
|
|
||||||
{
|
|
||||||
// AppleScript string escaping: backslash and double-quote
|
|
||||||
string escapedTitle = EscapeAppleScript(title);
|
|
||||||
string escapedMessage = EscapeAppleScript(message);
|
|
||||||
|
|
||||||
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
|
|
||||||
// Each argument is passed directly to the process without shell interpretation.
|
|
||||||
var script =
|
|
||||||
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "osascript",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-e");
|
|
||||||
startInfo.ArgumentList.Add(script);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string EscapeAppleScript(string value)
|
|
||||||
{
|
|
||||||
// In AppleScript string literals, escape backslash and double-quote
|
|
||||||
return value
|
|
||||||
.Replace("\\", "\\\\")
|
|
||||||
.Replace("\"", "\\\"");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Linux — notify-send / gdbus
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Linux notification using <c>notify-send</c> (libnotify CLI).
|
|
||||||
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
|
|
||||||
/// </summary>
|
|
||||||
private static class LinuxNotification
|
|
||||||
{
|
|
||||||
public static void Send(string title, string message, string? folderPath)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(folderPath))
|
|
||||||
{
|
|
||||||
ShowWithAction(title, message, folderPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowSimple(string title, string message)
|
|
||||||
{
|
|
||||||
// Try notify-send first
|
|
||||||
if (TryNotifySend(title, message))
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Fallback to gdbus
|
|
||||||
TryGdbus(title, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ShowWithAction(string title, string message, string folderPath)
|
|
||||||
{
|
|
||||||
// Run on a background thread because notify-send with --action blocks
|
|
||||||
// until the notification is dismissed or the action is clicked.
|
|
||||||
System.Threading.Tasks.Task.Run(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
RedirectStandardOutput = true,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
string output = process.StandardOutput.ReadToEnd().Trim();
|
|
||||||
process.WaitForExit();
|
|
||||||
|
|
||||||
if (output == "open_folder")
|
|
||||||
{
|
|
||||||
var openInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "xdg-open",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
openInfo.ArgumentList.Add(folderPath);
|
|
||||||
|
|
||||||
RunProcess(openInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
|
|
||||||
|
|
||||||
// Fallback: show without action
|
|
||||||
ShowSimple(title, message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool TryNotifySend(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "notify-send",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("-a");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer");
|
|
||||||
startInfo.ArgumentList.Add(title);
|
|
||||||
startInfo.ArgumentList.Add(message);
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void TryGdbus(string title, string message)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Direct D-Bus call to org.freedesktop.Notifications
|
|
||||||
var startInfo = new ProcessStartInfo
|
|
||||||
{
|
|
||||||
FileName = "gdbus",
|
|
||||||
CreateNoWindow = true,
|
|
||||||
UseShellExecute = false,
|
|
||||||
WindowStyle = ProcessWindowStyle.Hidden
|
|
||||||
};
|
|
||||||
startInfo.ArgumentList.Add("call");
|
|
||||||
startInfo.ArgumentList.Add("--session");
|
|
||||||
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
|
|
||||||
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
|
|
||||||
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
|
|
||||||
startInfo.ArgumentList.Add("0"); // replaces_id
|
|
||||||
startInfo.ArgumentList.Add(""); // app_icon
|
|
||||||
startInfo.ArgumentList.Add(title); // summary
|
|
||||||
startInfo.ArgumentList.Add(message); // body
|
|
||||||
startInfo.ArgumentList.Add("[]"); // actions
|
|
||||||
startInfo.ArgumentList.Add("{}"); // hints
|
|
||||||
startInfo.ArgumentList.Add("-1"); // expire_timeout
|
|
||||||
|
|
||||||
RunProcess(startInfo);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Shared utility
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
|
|
||||||
/// </summary>
|
|
||||||
private static void RunProcess(ProcessStartInfo startInfo)
|
|
||||||
{
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = startInfo;
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,22 @@ using UnityEngine;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
namespace ADOFAIRenderer.Util
|
||||||
{
|
{
|
||||||
|
/// <summary>Particle system helpers.</summary>
|
||||||
internal static class ParticleUtil
|
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();
|
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)
|
public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps)
|
||||||
{
|
{
|
||||||
if (!ps) return null;
|
if (!ps) return null;
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ using System;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
namespace ADOFAIRenderer.Util
|
||||||
{
|
{
|
||||||
|
/// <summary>Conversion of floating-point rates (frame rates) to integer fractions.</summary>
|
||||||
internal static class RationalUtil
|
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,
|
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
|
||||||
int maxDenominator = 100000000)
|
int maxDenominator = 100000000)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util
|
|
||||||
{
|
|
||||||
internal static class RegistryUtil
|
|
||||||
{
|
|
||||||
private static readonly Type? RegistryKeyType;
|
|
||||||
private static readonly Type? RegistryHiveType;
|
|
||||||
private static readonly Type? RegistryViewType;
|
|
||||||
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;
|
|
||||||
|
|
||||||
static RegistryUtil()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
|
|
||||||
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
|
|
||||||
{
|
|
||||||
var mscorlib = typeof(object).Assembly;
|
|
||||||
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
|
|
||||||
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
|
|
||||||
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RegistryKeyType != null)
|
|
||||||
{
|
|
||||||
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
|
|
||||||
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
|
|
||||||
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
|
|
||||||
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void CloseKey(object? key)
|
|
||||||
{
|
|
||||||
if (key == null || RegistryKeyType == null) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (key is IDisposable disposable)
|
|
||||||
{
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var closeMethod = RegistryKeyType.GetMethod("Close");
|
|
||||||
closeMethod?.Invoke(key, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return subkeyNames;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReSharper disable once InconsistentNaming
|
|
||||||
public static string? GetHKLMValue(string keyPath, string valueName)
|
|
||||||
{
|
|
||||||
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
|
|
||||||
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
|
|
||||||
|
|
||||||
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
|
|
||||||
if (baseKey == null) return null;
|
|
||||||
|
|
||||||
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
|
|
||||||
if (key == null)
|
|
||||||
{
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
|
|
||||||
CloseKey(key);
|
|
||||||
CloseKey(baseKey);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+82
-3
@@ -9,9 +9,25 @@ using FFmpeg.AutoGen;
|
|||||||
|
|
||||||
namespace ADOFAIRenderer.Util;
|
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
|
// ReSharper disable once InconsistentNaming
|
||||||
internal class VideoBGPlayer : MonoBehaviour
|
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 YTex = Shader.PropertyToID("_YTex");
|
||||||
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
|
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
|
||||||
private static readonly int Result = Shader.PropertyToID("_Result");
|
private static readonly int Result = Shader.PropertyToID("_Result");
|
||||||
@@ -20,7 +36,12 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
private static readonly int UTex = Shader.PropertyToID("_UTex");
|
||||||
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
private static readonly int VTex = Shader.PropertyToID("_VTex");
|
||||||
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
|
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")]
|
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||||
private enum GPUMode
|
private enum GPUMode
|
||||||
{
|
{
|
||||||
@@ -29,6 +50,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
NV12
|
NV12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Decoder of the current video; null when not initialized or after CleanUp.</summary>
|
||||||
private FFmpegNativeDecoder? _decoder;
|
private FFmpegNativeDecoder? _decoder;
|
||||||
|
|
||||||
// CPU fallback rendering
|
// CPU fallback rendering
|
||||||
@@ -46,16 +68,34 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
private RenderTexture? _renderTexture;
|
private RenderTexture? _renderTexture;
|
||||||
private int _kernelIndex = -1;
|
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;
|
private double _currentFrameTimestamp;
|
||||||
|
/// <summary>Video time in seconds from which the next frame is decoded (current timestamp + 1 / FPS).</summary>
|
||||||
private double _nextFrameTimestamp;
|
private double _nextFrameTimestamp;
|
||||||
|
/// <summary>Conversion path selected by Init.</summary>
|
||||||
private GPUMode _gpuMode = GPUMode.None;
|
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;
|
private RenderTexture? _targetTexture;
|
||||||
|
/// <summary>Whether the level loops the video (miscSettings "loopVideo").</summary>
|
||||||
private bool _isLooping;
|
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;
|
private bool _isInitialized;
|
||||||
|
|
||||||
|
/// <summary>Background quad created by SetupBackgroundQuad when there is no target texture; hidden until the first frame.</summary>
|
||||||
private GameObject? _quad;
|
private GameObject? _quad;
|
||||||
|
/// <summary>Material of the background quad; its main texture is the converted frame.</summary>
|
||||||
private Material? _material;
|
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
|
// ReSharper disable once InconsistentNaming
|
||||||
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
|
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
|
||||||
{
|
{
|
||||||
@@ -96,7 +136,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
|
||||||
|
|
||||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||||
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
|
_textureUV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.RG16, false);
|
||||||
ConfigureTexture(_textureY);
|
ConfigureTexture(_textureY);
|
||||||
ConfigureTexture(_textureUV);
|
ConfigureTexture(_textureUV);
|
||||||
|
|
||||||
@@ -117,8 +157,8 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
|
||||||
|
|
||||||
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
|
||||||
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
_textureU = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||||
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
|
_textureV = new Texture2D((w + 1) / 2, (h + 1) / 2, TextureFormat.R8, false);
|
||||||
ConfigureTexture(_textureY);
|
ConfigureTexture(_textureY);
|
||||||
ConfigureTexture(_textureU);
|
ConfigureTexture(_textureU);
|
||||||
ConfigureTexture(_textureV);
|
ConfigureTexture(_textureV);
|
||||||
@@ -168,12 +208,19 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets clamp wrapping and bilinear filtering.</summary>
|
||||||
private static void ConfigureTexture(Texture2D tex)
|
private static void ConfigureTexture(Texture2D tex)
|
||||||
{
|
{
|
||||||
tex.wrapMode = TextureWrapMode.Clamp;
|
tex.wrapMode = TextureWrapMode.Clamp;
|
||||||
tex.filterMode = FilterMode.Bilinear;
|
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()
|
private void LateUpdate()
|
||||||
{
|
{
|
||||||
if (!_isInitialized || _decoder == null) return;
|
if (!_isInitialized || _decoder == null) return;
|
||||||
@@ -181,6 +228,9 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
var cond = scrConductor.instance;
|
var cond = scrConductor.instance;
|
||||||
if (!cond || !cond.hasSongStarted) return;
|
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 pitch = cond.song.pitch;
|
||||||
var separateCountdownOffset = cond.separateCountdownTime ?
|
var separateCountdownOffset = cond.separateCountdownTime ?
|
||||||
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
|
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
|
||||||
@@ -189,6 +239,14 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
|
||||||
var targetVideoTime = songTime + vidOffset;
|
var targetVideoTime = songTime + vidOffset;
|
||||||
|
|
||||||
|
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
|
||||||
|
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
|
||||||
|
var videoDuration = _decoder.Duration;
|
||||||
|
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
|
||||||
|
{
|
||||||
|
targetVideoTime %= videoDuration;
|
||||||
|
}
|
||||||
|
|
||||||
if (targetVideoTime < 0)
|
if (targetVideoTime < 0)
|
||||||
{
|
{
|
||||||
if (_targetTexture)
|
if (_targetTexture)
|
||||||
@@ -206,6 +264,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// scrVfxPlus hides the tutorial (editor) background when the native video starts (scrVfxPlus.cs).
|
||||||
if (ADOBase.customLevel)
|
if (ADOBase.customLevel)
|
||||||
{
|
{
|
||||||
ADOBase.customLevel.ShowTutorialBackground(false);
|
ADOBase.customLevel.ShowTutorialBackground(false);
|
||||||
@@ -213,6 +272,7 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
|
|
||||||
try
|
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)
|
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
|
||||||
{
|
{
|
||||||
_decoder.SeekToTime(targetVideoTime);
|
_decoder.SeekToTime(targetVideoTime);
|
||||||
@@ -289,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()
|
private void RenderToTarget()
|
||||||
{
|
{
|
||||||
if (_decoder == null) return;
|
if (_decoder == null) return;
|
||||||
@@ -307,7 +371,9 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_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 threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||||
@@ -341,7 +407,9 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
_computeShader.SetInt(Width, w);
|
_computeShader.SetInt(Width, w);
|
||||||
_computeShader.SetInt(Height, h);
|
_computeShader.SetInt(Height, h);
|
||||||
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
|
_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 threadGroupsX = (w + 7) / 8;
|
||||||
int threadGroupsY = (h + 7) / 8;
|
int threadGroupsY = (h + 7) / 8;
|
||||||
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
|
||||||
@@ -383,11 +451,16 @@ internal class VideoBGPlayer : MonoBehaviour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Releases the decoder and GPU resources when the component is destroyed.</summary>
|
||||||
private void OnDestroy()
|
private void OnDestroy()
|
||||||
{
|
{
|
||||||
CleanUp();
|
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()
|
public void CleanUp()
|
||||||
{
|
{
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
@@ -435,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
|
// ReSharper disable once InconsistentNaming
|
||||||
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
|
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user