136 Commits
Author SHA256 Message Date
copecone 9e3941e3b5 Add Fix Custom FPS Render Timing Option 2026-10-18 01:01:30 +09:00
copecone 78ea42b368 Add High-Resolution Bloom Correction Option 2026-10-16 22:14:10 +09:00
copecone 064bd816e8 Fix NVENC Falling Back To VBR In CBR Mode 2026-10-16 16:28:49 +09:00
copecone 46fc00df9c Fix Case Insensitive Default Output Directory Match 2026-10-16 16:11:08 +09:00
copecone 769f368f66 Fix Linux Folder Dialog Reopening After Cancel 2026-10-16 16:11:03 +09:00
copecone ed6f990d3b Upgrade Version 2026-10-15 22:57:17 +09:00
copecone 151c91bd86 Merge pull request 'Refactor Comments' (#6) from refactor/comments into main
Reviewed-on: #6
2026-10-15 19:02:22 +09:00
copeconeandClaude Sonnet 5.5 961f78a332 Normalize Comment Terminology
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:29:22 +09:00
copecone 0fc01a84de Document UI And Localization 2026-10-15 16:17:14 +09:00
copecone a389283d33 Document OS Utilities 2026-10-15 16:17:13 +09:00
copecone d2bd655118 Document Harmony Patches 2026-10-15 16:17:13 +09:00
copecone 8deb64fd4d Document Render Core And API 2026-10-15 16:17:13 +09:00
copecone 354c42fdd9 Document FFmpeg Encoder And Decoder 2026-10-15 16:17:12 +09:00
copecone 19d0e258ac Document Audio Pipeline 2026-10-15 16:17:12 +09:00
copecone 7bb47e4974 Fix Zero Duration Of Last Video Frame 2026-10-15 11:27:58 +09:00
copecone fab189ff3b Merge pull request 'Rewrite Audio' (#5) from feature/audio_rewrite into main
Reviewed-on: #5
2026-10-15 01:29:30 +09:00
copecone abb6f729e4 Localize FFmpeg Download And Driver Warning Dialogs 2026-10-14 22:55:15 +09:00
copecone c56175bbc8 Fix Custom Preset Label Handling On Language Change 2026-10-14 22:55:00 +09:00
copecone ab8164a735 Deactivate Mapped Effect Sources During Render 2026-10-14 22:18:35 +09:00
copecone 69d098e04c Emulate AudioSource Playback State For Effect Sounds 2026-10-14 21:55:13 +09:00
copecone 8e0639aafa Fix Warning Dialogs Hidden Behind Render Config Window 2026-10-14 20:46:02 +09:00
copecone 1fdee67f9d Validate Even Render Resolution Before Starting 2026-10-14 20:20:16 +09:00
copecone a4dba82729 Fix Language Change And Name Collision In Presets 2026-10-14 20:20:06 +09:00
copecone 27e7327c1e Upgrade Version 2026-10-13 20:32:23 +09:00
copecone 2152625e45 Fix Output Color Tags And CPU Conversion Matrix 2026-10-13 19:59:23 +09:00
copecone 5136127e39 Round Voice Start Times Down To Output Samples 2026-10-13 19:20:59 +09:00
copecone aefa1409c1 Replace Throttle Busy Wait With Dequeue Signal And Byte Limit 2026-10-13 19:05:39 +09:00
copecone e802b6a08a Fix Odd Resolution Video Background Decoding 2026-10-13 15:43:29 +09:00
copecone 455436e744 Fix Audio Cleanup On Render Abort 2026-10-13 15:37:16 +09:00
copecone c8050abbc9 Fix Cancelled Render Encoding Remaining Queued Frames 2026-10-13 15:35:10 +09:00
copecone 45a7637a36 Fix OnLastUpdate Callback Skipped Without NonTile 2026-10-13 15:22:18 +09:00
copecone 89f15eca1e Read Spectrum Samples From Clip Store 2026-10-13 15:20:04 +09:00
copecone d0f6fa9ecd Fix Repeated Effect Voice List Scans 2026-10-13 12:02:48 +09:00
copecone 5055e4dfbb Apply AudioSource Playback Control To Voices 2026-10-13 11:57:07 +09:00
copecone cb0fd1bd96 Switch Render Audio To Voice Mixer 2026-10-13 11:30:57 +09:00
copecone 3d2ffe9dfe Fix MP3 Header Read While Game Streams Song 2026-10-13 11:30:42 +09:00
copecone 6955762641 Fix Preset Label Not Matching Loaded Config Values 2026-10-13 11:01:37 +09:00
copecone a21d86d3a6 Add Voice Mixer With Timed Changes 2026-10-13 09:43:07 +09:00
copecone 7ea2d8fe3f Resolve Audio Clips From Level Files 2026-10-13 02:00:18 +09:00
copecone 35606d6e58 Match Game MP3 Decoding Timing 2026-10-13 01:59:53 +09:00
copecone 82776fc572 Add FFmpeg Audio File Reader 2026-10-13 00:30:07 +09:00
copecone 0a12c12ff5 Add Chunked Audio Clip Store 2026-10-13 00:30:02 +09:00
copecone ea8d4c91ac Fix Black Fallback Frame For Failed Readbacks 2026-10-12 23:04:24 +09:00
copecone 5080e7ea1d Harden Encoder Error Handling 2026-10-12 21:15:38 +09:00
copecone 4e556a4632 Cleanup Comments 2026-10-12 19:48:15 +09:00
copecone f1e926f758 Cleanup LICENSE-EXCEPTION.md 2026-10-12 10:25:34 +09:00
copecone c62723cf5c Hide Win Texts And Detailed Results During Render 2026-10-12 02:03:57 +09:00
copecone fee7077b16 Split Render Thread Body Into Methods 2026-10-12 02:03:45 +09:00
copecone 9fbf057677 Hide Difficulty Selector During Render 2026-10-12 02:03:29 +09:00
copecone f153bf70aa Upgrade Version 2026-10-12 00:10:50 +09:00
copecone 93539307b1 Fix Incomplete Output File Handling 2026-10-11 23:53:41 +09:00
copecone c0e902fc1a Reallocate Shared Video Frame Buffer Without Copying 2026-10-11 23:28:47 +09:00
copecone dd2585ee9f Prefer QSV System Memory Input With GPU Frame Pool Fallback 2026-10-11 23:12:57 +09:00
copecone 1a672cbf59 Fix Looping Video Background Seeking Every Frame 2026-10-11 22:51:02 +09:00
copecone 6d5880fe19 Fix YUV to RGB Color Matrix Selection 2026-10-11 22:46:31 +09:00
copecone 72d8d71928 Upgrade Version 2026-10-11 20:26:41 +09:00
copecone 413778e165 Fix Decoder Forward Seek Returning One Frame Ahead 2026-10-11 20:24:11 +09:00
copecone 36552511f5 Fix Decoder Invalid Packet Handling 2026-10-11 19:59:22 +09:00
copecone a1f9fa36ce Fix Video Stream Time Base Causing Frame Duration Jitter 2026-10-11 19:47:55 +09:00
copecone 00971a6730 Fix Encoder Quality Options Not Being Applied 2026-10-11 18:13:42 +09:00
copecone bd24152a2e Move NTSC Handling 2026-10-11 16:45:18 +09:00
copecone 7aa8ae4b54 Move OS-dependent Utilities Into OS Package 2026-10-11 16:44:04 +09:00
copecone dee3b2dbcf Fix Render State Cleanup on Initialization Failure 2026-10-11 16:08:00 +09:00
copecone 7eec3b4660 Fix Render Freeze on Encoder Thread Failure 2026-10-11 15:46:52 +09:00
copecone ce70d89cdb Fix Potential Floating Point Error in NVIDIA Graphics Driver Version Detection 2026-10-11 12:54:18 +09:00
copecone 7db1905906 Add .mcp.json to .gitignore 2026-10-10 22:30:26 +09:00
copecone bfc79ee4d3 Fix Label Generation Condition 2026-10-10 22:25:45 +09:00
copecone b4c950c9f7 Upgrade Version 2026-10-10 16:38:12 +09:00
copecone 723f4446f5 Add Two-Step Confirmation for Preset Deletion in RenderConfigWindow 2026-10-10 16:28:00 +09:00
copecone 6f4c632b41 Implement Dynamic Localization in RenderConfigWindow 2026-10-10 16:25:15 +09:00
copecone 863dcd2357 Utilize O5Kit System in Warning and Download Dialogs 2026-10-10 15:57:40 +09:00
copecone 00048b7b06 Upgrade Version 2026-10-10 02:01:12 +09:00
copecone d218bb835d Add Preset System 2026-10-10 01:51:38 +09:00
copecone 34d766cb4d Add Custom Frame Modification Hook API for Video Rendering 2026-10-09 20:31:23 +09:00
copecone 5d805b85ad Refactor FFmpegNativeDecoder 2026-10-09 20:24:28 +09:00
copecone ba83935190 Merge remote-tracking branch 'origin/main' 2026-10-09 16:01:34 +09:00
copecone d2390665af Upgrade Version 2026-10-09 16:01:23 +09:00
copecone ceb3190274 Add Hardware Decoding Prototype 2026-10-09 16:01:00 +09:00
copecone 08b13460ae Optimize FrameReader by Caching Color Conversion Kernel 2026-10-09 14:39:30 +09:00
copecone ed7dd5dba4 Add Generative AI Usage 2026-10-09 14:14:36 +09:00
copecone eb648bbe09 Upgrade Version 2026-10-08 18:33:11 +09:00
copecone f1d56f5f9d Add Planet Color Config 2026-10-08 18:32:44 +09:00
copecone ed61cef4bb Improve Video Encoding Options in RenderConfigWindow 2026-10-08 15:44:40 +09:00
copecone 2cfd14e348 Remove scrDecorationPatch 2026-10-08 15:32:22 +09:00
copecone daed6adcd7 Upgrade Version 2026-10-07 15:50:28 +09:00
copecone 86c8a87a0f Optimize Frame Readback and Encoding Pipeline 2026-10-07 15:48:09 +09:00
copecone 892f1ca7b9 Cleanup FolderDialogUtil 2026-10-07 15:47:31 +09:00
copecone e7ef081e55 Rename FFmpegVideoBGPlayer to VideoBGPlayer 2026-10-07 15:44:35 +09:00
copecone a523efd4fc Simplify Singleton in RendererUIManager 2026-10-07 11:47:40 +09:00
copecone b43c8be5dc Fix Render Height Binding in RenderConfigWindow 2026-10-07 11:47:40 +09:00
copecone 671bdda9a1 Refactor 2026-10-07 11:47:40 +09:00
copecone 8567878f07 Remove Legacy Resource Loading 2026-10-07 11:47:40 +09:00
copecone b900a5444e Finalize O5Kit Theme & Window Generation 2026-10-07 11:47:40 +09:00
copecone 2d70fb5ae8 Add License Exception 2026-10-07 11:47:36 +09:00
copecone 0d9cf44c77 Cleanup 2026-10-06 16:31:53 +09:00
copecone 51626f65c7 Add Version Label to Config Window 2026-10-06 15:30:20 +09:00
copecone 65669d5afd Remove 24kHz Sample Rate 2026-10-06 15:06:21 +09:00
copecone 5929b5ddbe Fix Folder Selection Browser 2026-10-06 15:06:05 +09:00
copecone a7c64e6c27 Add DelayAfterEnd Default Value 2026-10-06 14:01:06 +09:00
copecone 36c569057c Remove 96kHz for Opus 2026-10-06 14:00:54 +09:00
copecone b05e18c8bb Refactor RegistryUtil 2026-10-06 14:00:00 +09:00
copecone 888663aa86 Add Snap to Render Resolution Sliders 2026-10-06 13:53:11 +09:00
copecone 1a8fcaec7d Adjust Config Default Values 2026-10-06 13:52:45 +09:00
copecone 394fe00b25 Refactor Audio Codec & Sample Rate Detection 2026-10-06 13:51:52 +09:00
copecone 48a56cd3f2 Fix NullReferenceException from PlanetRendererPatch.OverrideColor 2026-10-06 01:09:05 +09:00
copecone 63f38883c8 Merge remote-tracking branch 'origin/main' 2026-10-05 23:46:00 +09:00
copecone 24ec6aa1d0 Upgrade Version 2026-10-05 23:45:51 +09:00
copecone 44caf92ada Merge pull request 'Apply O5Kit UI' (#4) from feature/o5kit_ui into main
Reviewed-on: #4
2026-10-05 23:45:15 +09:00
copecone b3e50cd50e Finalize First O5Kit Config UI Prototype 2026-10-05 23:37:11 +09:00
copecone 1d4b1cdd46 Remove RenderConfigUIManager 2026-10-05 23:35:31 +09:00
copecone 98cf00578d Merge pull request 'Fix Render Config UI Prototype Layout' (#3) from kyulio/ADOFAIRenderer:feature/o5kit_ui into feature/o5kit_ui
Reviewed-on: #3
2026-10-05 17:03:41 +09:00
kyulio b6f1c0e4d2 Fix Render Config UI Prototype Layout 2026-10-05 16:21:26 +09:00
copecone 477b46a8dc Add Config UI Prototype 2026-10-05 15:36:59 +09:00
copecone 6e348ecae3 Fix Font Load 2026-10-05 15:26:38 +09:00
copecone b15903f1e0 Refactor Asset Loading 2026-10-05 14:50:21 +09:00
copecone 6c0347609d Add Auto Localization Initialization 2026-10-05 14:30:01 +09:00
copecone 92a4654f14 Add Auto UI Initialization 2026-10-05 14:28:18 +09:00
copecone ec5b2f0ab6 Add Embedded Resource Config 2026-10-05 14:25:39 +09:00
copecone 85575d3185 Add Bundles 2026-10-05 14:24:18 +09:00
copecone 2277025829 Implement O5Kit 2026-10-05 13:49:02 +09:00
copecone 5e9c0272dd Add Planet Color Configuration 2026-10-02 20:16:30 +09:00
copecone e3cb2263d9 Delete Bundles 2026-09-30 20:29:04 +09:00
copecone 4c68b90f9d Update LICENSE 2026-09-29 14:04:59 +09:00
copecone e9b9bac300 Rename LICENSE.md to LICENSE 2026-09-29 13:06:25 +09:00
copecone 87fbcd70ae Relicense from MIT to GPLv3 2026-09-29 12:57:52 +09:00
copecone ea1a193937 Add README.md 2026-09-28 12:28:45 -04:00
copecone d42c434780 Apply Stage-based Condition Management 2026-09-28 21:15:02 +09:00
copecone 363130e87b Remove Useless Render Frame Skip Check 2026-09-17 10:41:26 +09:00
copecone 8014975c81 Update to Support 3.4.0 2026-09-17 10:33:56 +09:00
copecone f964b685c8 Merge remote-tracking branch 'origin/main' 2026-09-17 10:33:16 +09:00
copecone 573e540ba4 Refactor RenderMod 2026-09-16 20:52:16 +09:00
copecone a237814abc Upgrade Version 2026-09-06 14:30:10 +09:00
copecone 7100609809 Add Language System 2026-09-06 14:29:46 +09:00
copecone 43763b0b35 Add Output Path Config Prototype 2026-09-01 08:09:55 +09:00
copecone 4097e14e7a Fix Intel GPU Regex Pattern 2026-08-31 12:43:58 +09:00
copecone 463d95d16f Add Debug Log When Skipping Encoder 2026-08-31 12:43:38 +09:00
109 changed files with 18072 additions and 6153 deletions

No files matched your search

+11 -4
View File
@@ -32,13 +32,20 @@ Thumbs.db
desktop.ini
## Agent Metadata
.antigravitycli/
.agent/
GEMINI.md
/.antigravitycli/
/.agent/
/GEMINI.md
/.mcp.json
/CLAUDE.md
# Decompiled Game Code
*.cs.txt
# Developer Description Files
/AsyncInputAutoplayFix.md
/AudioMixerExplanation.md
/AudioMixerExplanation.md
/O5Theme.md
# Developer Utility Files
/REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+18 -27
View File
@@ -9,12 +9,16 @@
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>11</LangVersion>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<Reference Include="0Harmony">
<HintPath>$(ADOFAI_PATH)\MelonLoader\net35\0Harmony.dll</HintPath>
</Reference>
<Reference Include="O5Kit">
<HintPath>$(ADOFAI_PATH)\UserLibs\O5Kit.dll</HintPath>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Assembly-CSharp.dll</HintPath>
</Reference>
@@ -78,39 +82,26 @@
<Reference Include="Newtonsoft.Json">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\Win64\fonts" />
<EmbeddedResource Include="Bundle\Win64\fonts" />
<None Remove="Bundle\Win64\scenes" />
<EmbeddedResource Include="Bundle\Win64\scenes" />
<None Remove="Bundle\Win64\shaders" />
<EmbeddedResource Include="Bundle\Win64\shaders" />
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\Linux\fonts" />
<EmbeddedResource Include="Bundle\Linux\fonts" />
<None Remove="Bundle\Linux\scenes" />
<EmbeddedResource Include="Bundle\Linux\scenes" />
<None Remove="Bundle\Linux\shaders" />
<EmbeddedResource Include="Bundle\Linux\shaders" />
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\OSX\fonts" />
<EmbeddedResource Include="Bundle\OSX\fonts" />
<None Remove="Bundle\OSX\scenes" />
<EmbeddedResource Include="Bundle\OSX\scenes" />
<None Remove="Bundle\OSX\shaders" />
<EmbeddedResource Include="Bundle\OSX\shaders" />
<Reference Include="UniTask">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UniTask.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Localization/Locale/*.json" />
</ItemGroup>
<ItemGroup>
<Folder Include="Bundle/" />
<EmbeddedResource Include="Bundle/Linux/*" />
<EmbeddedResource Include="Bundle/Win64/*" />
<EmbeddedResource Include="Bundle/OSX/*" />
</ItemGroup>
<!-- Build 후 XML 문서에서 ADOFAIRenderer.API 네임스페이스만 남기고 나머지 제거 -->
<UsingTask TaskName="FilterXmlDoc" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
+362
View File
@@ -0,0 +1,362 @@
using System;
using System.Diagnostics.CodeAnalysis;
using UnityEngine;
using UnityEngine.Rendering;
namespace ADOFAIRenderer.API;
/// <summary>
/// Execution context provided to frame modification hooks, managing GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <para>
/// <b>Zero-Allocation Notice:</b> For maximum performance on the render hot path, the pipeline reuses context instances
/// across frames. Do not cache the context instance itself across frames.
/// </para>
/// <para>
/// <b>Execution Frequency Notice:</b> This hook is executed only when a frame is captured for video encoding.
/// When <c>RenderFPS</c> exceeds <c>VideoFPS</c>, intermediate game rendering frames are skipped and the hook
/// will <b>not</b> be invoked on every rendered game frame.
/// </para>
/// <para>
/// <b>Ping-pong model:</b> the renderer owns two RenderTextures with the size and format of the game frame.
/// <see cref="Source"/> is the current frame, <see cref="Destination"/> is the buffer the next pass writes.
/// A pass draws from <see cref="Source"/> into <see cref="Destination"/> and then calls <see cref="Swap"/>, which makes
/// the written buffer the new <see cref="Source"/>. <see cref="Blit(Material, int)"/>, and by default
/// <see cref="DrawToDestination"/> and <see cref="ExecuteCommandBuffer"/>, swap automatically. The texture in
/// <see cref="Current"/> after the last hook is encoded. When no hook swaps or replaces the frame, the game frame is
/// encoded unchanged. The first <see cref="Source"/> of every frame is the game's own frame texture
/// (<see cref="OriginalSource"/>); do not write into it.
/// </para>
/// <para>
/// <b>Threading and lifetime:</b> hooks run on the main Unity thread at the end of the frame, after the game has
/// rendered. The textures are valid only during the hook call: do not keep references to <see cref="Source"/>,
/// <see cref="Destination"/> or the context. <c>RenderTexture.active</c> is restored after all hooks have run.
/// </para>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
public class FrameHookContext
{
/// <summary>Game frame of this capture (camera quad texture); never written by the context.</summary>
private RenderTexture _originalSource = null!;
/// <summary>First ping-pong buffer, owned by FrameHookPipeline.</summary>
private RenderTexture _pingRT = null!;
/// <summary>Second ping-pong buffer, owned by FrameHookPipeline.</summary>
private RenderTexture _pongRT = null!;
/// <summary>Current frame: the original source, a ping-pong buffer, or a texture set by ReplaceCurrent.</summary>
private RenderTexture _currentSource = null!;
/// <summary>Buffer the next pass writes: always the ping or pong buffer.</summary>
private RenderTexture _currentDestination = null!;
/// <summary>True after the first Swap or ReplaceCurrent of this frame.</summary>
private bool _hasModified;
/// <summary>
/// The input <see cref="RenderTexture"/> containing the rendered frame before the current pass.
/// </summary>
public RenderTexture Source => _currentSource;
/// <summary>
/// The writable destination <see cref="RenderTexture"/> for the current pass.
/// </summary>
public RenderTexture Destination => _currentDestination;
/// <summary>
/// The active <see cref="RenderTexture"/> representing the current state of the modified frame.
/// Same texture as <see cref="Source"/>; after the last hook it is the frame that gets encoded.
/// </summary>
public RenderTexture Current => _currentSource;
/// <summary>
/// The original unmodified render texture captured from the game for the current frame before any hooks were applied.
/// </summary>
public RenderTexture OriginalSource => _originalSource;
/// <summary>
/// Indicates whether any modifications or swaps have been applied to this frame.
/// </summary>
public bool HasModified => _hasModified;
/// <summary>
/// The sequential 0-based index of the video frame currently being captured (its position in the output video).
/// </summary>
public long FrameIndex { get; private set; }
/// <summary>
/// Alias for <see cref="FrameIndex"/>. The sequential 0-based index of the video frame.
/// </summary>
public long VideoFrameIndex => FrameIndex;
/// <summary>
/// The sequential 0-based index of the game render frame, counted from the start of capture.
/// With <c>RenderFPS</c> above the video frame rate it advances by more than one between hook calls.
/// </summary>
public long RenderFrameIndex { get; private set; }
/// <summary>
/// The timestamp of the current frame in seconds relative to video start (<see cref="FrameIndex"/> / <see cref="VideoFPS"/>).
/// </summary>
public double VideoTime { get; private set; }
/// <summary>
/// The delta time duration in seconds of a single video frame (equivalent to 1.0 / VideoFPS).
/// </summary>
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
/// <summary>
/// The width of the frame in pixels (width of the current <see cref="Source"/>).
/// </summary>
public int Width => _currentSource.width;
/// <summary>
/// The height of the frame in pixels (height of the current <see cref="Source"/>).
/// </summary>
public int Height => _currentSource.height;
/// <summary>
/// The configured render frames per second (game frames simulated per second of render time).
/// </summary>
public double RenderFPS { get; private set; }
/// <summary>
/// The output video frames per second: the effective rate, min(configured <c>VideoFPS</c>, <see cref="RenderFPS"/>).
/// </summary>
public double VideoFPS { get; private set; }
/// <summary>
/// The number of modification passes completed for the current frame (calls of <see cref="Swap"/> and
/// <see cref="ReplaceCurrent"/>, including the automatic swaps of the helper methods). 0 at the start of every frame.
/// </summary>
public int PassCount { get; private set; }
/// <summary>Creates a context for the first frame; see <see cref="Reset"/> for the parameters.</summary>
internal FrameHookContext(
RenderTexture originalSource,
RenderTexture pingRT,
RenderTexture pongRT,
RenderTexture firstDestination,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double 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(
RenderTexture originalSource,
RenderTexture pingRT,
RenderTexture pongRT,
RenderTexture firstDestination,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double videoFps)
{
_originalSource = originalSource;
_pingRT = pingRT;
_pongRT = pongRT;
_currentSource = originalSource;
_currentDestination = firstDestination;
_hasModified = false;
PassCount = 0;
FrameIndex = frameIndex;
RenderFrameIndex = renderFrameIndex;
VideoTime = videoTime;
RenderFPS = renderFps;
VideoFPS = videoFps;
}
/// <summary>
/// Blits from <see cref="Source"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <remarks>
/// Uses <c>Graphics.Blit</c>, which binds <see cref="Source"/> as the material's <c>_MainTex</c> and changes
/// <c>RenderTexture.active</c>.
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
/// </remarks>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
public void Blit(Material? material = null, int pass = -1)
{
if (material != null)
{
if (pass >= 0)
Graphics.Blit(_currentSource, _currentDestination, material, pass);
else
Graphics.Blit(_currentSource, _currentDestination, material);
}
else
{
Graphics.Blit(_currentSource, _currentDestination);
}
Swap();
}
/// <summary>
/// Blits from a custom source <see cref="Texture"/> to <see cref="Destination"/> using the specified <see cref="Material"/> and automatically swaps ping-pong buffers.
/// </summary>
/// <remarks>
/// The current <see cref="Source"/> is not read; the result replaces the frame. <paramref name="customSource"/> is
/// stretched to the size of <see cref="Destination"/>.
/// </remarks>
/// <param name="customSource">The custom source texture to blit from.</param>
/// <param name="material">The material containing the shader to apply, or <c>null</c> for a standard copy.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customSource"/> is null.</exception>
public void Blit(Texture customSource, Material? material = null, int pass = -1)
{
if (customSource == null) throw new ArgumentNullException(nameof(customSource));
if (material != null)
{
if (pass >= 0)
Graphics.Blit(customSource, _currentDestination, material, pass);
else
Graphics.Blit(customSource, _currentDestination, material);
}
else
{
Graphics.Blit(customSource, _currentDestination);
}
Swap();
}
/// <summary>
/// Copies the contents of <see cref="Source"/> into <see cref="Destination"/> without swapping buffers.
/// Call this before drawing custom overlays into <see cref="Destination"/> if you wish to preserve the background.
/// Does not count as a modification; call <see cref="Swap"/> after drawing to make the result the current frame.
/// </summary>
public void Copy()
{
Graphics.Blit(_currentSource, _currentDestination);
}
/// <summary>
/// Clears <see cref="Destination"/> with the specified color (color only; no depth buffer is cleared).
/// Does not swap; the previous <c>RenderTexture.active</c> is restored.
/// </summary>
/// <param name="clearColor">The color to clear with, or <c>null</c> for <see cref="Color.clear"/>.</param>
public void ClearDestination(Color? clearColor = null)
{
var prev = RenderTexture.active;
try
{
RenderTexture.active = _currentDestination;
GL.Clear(false, true, clearColor ?? Color.clear);
}
finally
{
RenderTexture.active = prev;
}
}
/// <summary>
/// Temporarily activates <see cref="Destination"/> as <see cref="RenderTexture.active"/> to perform custom draw calls,
/// safely restoring the previous active RenderTexture afterwards.
/// </summary>
/// <param name="drawAction">The action that performs custom draw operations.</param>
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after drawing.</param>
/// <param name="preserveBackground">If <c>true</c>, copies <see cref="Source"/> into <see cref="Destination"/> before executing <paramref name="drawAction"/>.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="drawAction"/> is null.</exception>
public void DrawToDestination(Action drawAction, bool autoSwap = true, bool preserveBackground = false)
{
if (drawAction == null) throw new ArgumentNullException(nameof(drawAction));
if (preserveBackground)
{
Copy();
}
var prev = RenderTexture.active;
try
{
RenderTexture.active = _currentDestination;
drawAction();
}
finally
{
RenderTexture.active = prev;
}
if (autoSwap)
{
Swap();
}
}
/// <summary>
/// Executes a <see cref="CommandBuffer"/> against the GPU and optionally swaps buffers.
/// </summary>
/// <remarks>
/// The context does not set a render target for the command buffer: the commands must render into
/// <see cref="Destination"/> themselves (for example with <c>SetRenderTarget</c> or <c>Blit(Source, Destination)</c>),
/// so build or update the buffer inside the hook with the current <see cref="Source"/> and <see cref="Destination"/>.
/// </remarks>
/// <param name="commandBuffer">The command buffer to execute.</param>
/// <param name="autoSwap">If <c>true</c> (default), automatically calls <see cref="Swap"/> after execution.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="commandBuffer"/> is null.</exception>
public void ExecuteCommandBuffer(CommandBuffer commandBuffer, bool autoSwap = true)
{
if (commandBuffer == null) throw new ArgumentNullException(nameof(commandBuffer));
Graphics.ExecuteCommandBuffer(commandBuffer);
if (autoSwap)
{
Swap();
}
}
/// <summary>
/// Swaps the active <see cref="Source"/> and <see cref="Destination"/> ping-pong buffers.
/// Call this after manually writing or rendering into <see cref="Destination"/> so that subsequent hooks or the video encoder read the updated frame.
/// </summary>
/// <remarks>
/// After the call, <see cref="Source"/> is the buffer that was <see cref="Destination"/>, and <see cref="Destination"/>
/// is the other ping-pong buffer (its content is left over from an earlier pass or frame; the next pass overwrites it).
/// A texture set by <see cref="ReplaceCurrent"/> is dropped by a swap.
/// </remarks>
public void Swap()
{
_hasModified = true;
PassCount++;
var previousDestination = _currentDestination;
_currentSource = previousDestination;
_currentDestination = (previousDestination == _pingRT) ? _pongRT : _pingRT;
}
/// <summary>
/// Replaces the current modified frame texture with a custom <see cref="RenderTexture"/>.
/// </summary>
/// <remarks>
/// The texture becomes <see cref="Source"/> / <see cref="Current"/>; <see cref="Destination"/> is unchanged.
/// The texture is read back for encoding when it is still current after the last hook, so it must stay alive and
/// unchanged until the end of the hook chain, and it should have the frame's width, height and a format the capture
/// can read: the capture sizes its buffers from the texture, and the encoder expects the render resolution.
/// </remarks>
/// <param name="customTexture">The custom texture to set as the active frame.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="customTexture"/> is null.</exception>
public void ReplaceCurrent(RenderTexture customTexture)
{
_currentSource = customTexture ?? throw new ArgumentNullException(nameof(customTexture));
_hasModified = true;
PassCount++;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.API;
/// <summary>
/// Represents a custom frame modification hook that can modify rendered frames using GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <b>Execution Frequency Note:</b> Frame hooks are only invoked for frames that are actually encoded into the final video (<c>VideoFPS</c>).
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped and this hook
/// will <b>not</b> be called on every game frame.
/// Register an implementation per render with <see cref="RenderSession.AddFrameHook(IFrameHook)"/> or for every render
/// with <see cref="RenderSession.AddGlobalFrameHook(IFrameHook)"/>.
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
public interface IFrameHook
{
/// <summary>
/// Invoked for each captured video frame before it is encoded.
/// </summary>
/// <remarks>
/// This method is not called on every rendered game frame if <c>RenderFPS</c> exceeds <c>VideoFPS</c>.
/// It is called on the main Unity thread at the end of the frame. Draw from <see cref="FrameHookContext.Source"/>
/// into <see cref="FrameHookContext.Destination"/> and swap (see <see cref="FrameHookContext"/>); returning without
/// a swap leaves the frame unchanged. Do not keep the context or its textures after returning.
/// An exception thrown here is logged and the next hook still runs.
/// </remarks>
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
void OnRenderFrame(FrameHookContext context);
}
+14 -3
View File
@@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API;
/// <summary>
/// Represents the result and metadata of a completed or canceled render session.
/// </summary>
/// <remarks>
/// Passed to the completion callback registered with <see cref="RenderSession.OnComplete"/>. Immutable.
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderResult
@@ -17,7 +20,10 @@ public class RenderResult
public bool Success { get; }
/// <summary>
/// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created.
/// The output path of the rendered video file: the path given to <see cref="RenderSession.WithOutputPath"/> as passed
/// (relative if it was relative), or the path of the time-stamped file in the output directory chosen in the mod
/// settings (by default &lt;game root&gt;/Render).
/// Empty string if rendering failed or was cancelled (the unfinished file is deleted).
/// </summary>
public string OutputPath { get; }
@@ -27,12 +33,13 @@ public class RenderResult
public long TotalFrames { get; }
/// <summary>
/// The calculated video duration in seconds (based on TotalFrames and FPS).
/// The calculated video duration in seconds: <see cref="TotalFrames"/> divided by the effective video frame rate
/// (min of the configured VideoFPS and RenderFPS); 0 when the frame rate is not positive.
/// </summary>
public double VideoDuration { get; }
/// <summary>
/// Total wall-clock time spent rendering.
/// Total wall-clock time spent rendering, from the end of the countdown (start of capture) to the end of the render.
/// </summary>
public TimeSpan RenderDuration { get; }
@@ -43,6 +50,8 @@ public class RenderResult
/// <summary>
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
/// A cancelled render has "Rendering was cancelled by user."; a failed render has "Rendering failed: " followed by
/// the reason. Use <see cref="Success"/> to test the outcome; the texts are meant for display.
/// </summary>
public string? ErrorMessage { get; }
@@ -82,6 +91,7 @@ public class RenderResult
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
/// <remarks>The file size is read from disk; it is 0 when the file does not exist or cannot be read.</remarks>
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{
var fileSizeBytes = 0L;
@@ -103,6 +113,7 @@ public class RenderResult
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
/// <remarks>OutputPath is empty and FileSizeBytes is 0. A null message is replaced by a generic one.</remarks>
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
{
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
+558 -6
View File
@@ -1,18 +1,102 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util;
using UnityEngine;
namespace ADOFAIRenderer.API;
/// <summary>
/// Fluent builder for configuring and starting a render session.
/// Fluent builder for configuring and starting a render session, and the registry of global frame hooks.
/// </summary>
/// <remarks>
/// <para>
/// <b>Usage:</b> create an instance, configure it with the <c>With…</c>, <c>On…</c> and <c>AddFrameHook</c> methods,
/// then call <see cref="Start"/>. The builder's values are copied into the render when <see cref="Start"/> is called;
/// changing the builder afterwards does not affect the running render. A builder can be reused for later renders.
/// </para>
/// <para>
/// <b>Lifecycle of a render:</b> <see cref="Start"/> loads the game scene of the level. When the scene has loaded,
/// a 3-second countdown runs, then capture starts and the level plays with the game clock driven by the render
/// frame rate (offline, not real time). The render ends successfully <see cref="RenderConfig.DelayAfterEnd"/> seconds
/// (render time) after the end condition first holds, or is cancelled by the user (ESC), or fails. In every case
/// the completion callback receives a <see cref="RenderResult"/> and the game returns to the level editor.
/// </para>
/// <para>
/// <b>Threading:</b> every member must be called on the main Unity thread, except the global hook members, which are
/// thread-safe (they lock an internal object). All callbacks (end condition, completion, last update, frame hooks)
/// are invoked on the main Unity thread.
/// </para>
/// <para>
/// <b>Frame hooks</b> run on each captured video frame before it is encoded, in this order: session hooks in the order
/// they were added, then global hooks in registration order.
/// </para>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession
{
/// <summary>
/// Handle returned by AddGlobalFrameHook. Key is the object the caller registered (delegate, material or IFrameHook)
/// and is used by RemoveGlobalFrameHook; Action is the delegate the pipeline invokes.
/// Dispose removes this exact registration once; later calls do nothing.
/// </summary>
private sealed class GlobalHookRegistration : IDisposable
{
public object Key { get; }
public Action<FrameHookContext> Action { get; }
private bool _isDisposed;
public GlobalHookRegistration(object key, Action<FrameHookContext> action)
{
Key = key;
Action = action;
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
RemoveGlobalRegistration(this);
}
}
/// <summary>
/// Session hook added to this builder. Key is the object the caller passed (delegate, material or IFrameHook) and
/// is used by RemoveFrameHook; Action is the delegate copied to the render by Start.
/// </summary>
private sealed class SessionHookEntry
{
public object Key { get; }
public Action<FrameHookContext> Action { get; }
public SessionHookEntry(object key, Action<FrameHookContext> action)
{
Key = key;
Action = action;
}
}
/// <summary>Global hooks in registration order. Guarded by GlobalHooksLock.</summary>
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
/// <summary>Lock for GlobalFrameHooks and _globalHooksVersion (global hooks may be changed from any thread).</summary>
private static readonly object GlobalHooksLock = new();
/// <summary>
/// Incremented on every change of GlobalFrameHooks. RenderManager compares it each captured frame to rebuild its
/// cached hook list only after a change. Guarded by GlobalHooksLock.
/// </summary>
private static int _globalHooksVersion;
/// <summary>Session hooks of this builder in the order they were added.</summary>
private readonly List<SessionHookEntry> _frameHooks = new();
/// <summary>Autoplay setting copied to the render by Start (default true).</summary>
private bool _auto = true;
/// <summary>
@@ -22,27 +106,48 @@ public class RenderSession
{
/// <summary>
/// Time-related patch controlling frame timing and clock synchronization.
/// While capturing, <c>Time.time</c>, <c>Time.timeAsDouble</c>, <c>Time.unscaledTime</c>,
/// <c>Time.unscaledTimeAsDouble</c>, <c>Time.deltaTime</c>, <c>Time.unscaledDeltaTime</c>, <c>Time.frameCount</c>
/// and <c>AudioSettings.dspTime</c> return values of the render clock (one frame = 1 / RenderFPS seconds),
/// so the game advances exactly one render frame per Unity frame regardless of how long a frame takes.
/// Disabling it leaves these values to Unity.
/// </summary>
Time,
/// <summary>
/// Screen-related patch controlling screen resolution.
/// While the render is active (from the countdown on), <c>Screen.width</c> and <c>Screen.height</c> return
/// <see cref="RenderConfig.RenderWidth"/> and <see cref="RenderConfig.RenderHeight"/>, so the game creates its
/// camera RenderTexture at the render resolution.
/// </summary>
Screen
}
/// <summary>Time patch setting copied to the render by Start (default true).</summary>
private bool _timePatch = true;
/// <summary>Screen patch setting copied to the render by Start (default true).</summary>
private bool _screenPatch = true;
/// <summary>Custom output file path, or null for the default output directory.</summary>
private string? _outputPath;
/// <summary>Notification setting copied to the render by Start (default true).</summary>
private bool _notification = true;
/// <summary>Custom end condition, or null to keep the default (level cleared).</summary>
private Func<bool>? _endCondition;
/// <summary>Combined completion callbacks, or null to keep the default (notification).</summary>
private Action<RenderResult>? _onComplete;
/// <summary>Combined last-update callbacks, or null for none.</summary>
private Action? _onLastUpdate;
/// <summary>
/// Sets whether auto mode is enabled.
/// Sets whether auto mode (the game's autoplay, <c>RDC.auto</c>) is enabled during the render.
/// With auto mode off, the level is not played automatically; the render still enables the game's no-fail
/// settings (<c>scrController.noFail</c>, <c>scrController.noFailInfiniteMargin</c>).
/// </summary>
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
@@ -74,6 +179,13 @@ public class RenderSession
/// <summary>
/// Sets the custom condition function that determines when the render session should end.
/// </summary>
/// <remarks>
/// The condition is polled on the main Unity thread once per frame from the start of the countdown until the render ends.
/// When it first returns <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of
/// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is
/// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again
/// replaces the previous condition. Exceptions thrown by the condition are not caught.
/// </remarks>
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithEndCondition(Func<bool> endCondition)
@@ -85,6 +197,13 @@ public class RenderSession
/// <summary>
/// Sets the custom output path for the rendered file.
/// </summary>
/// <remarks>
/// The directory of the path is created by <see cref="Start"/> when it does not exist. An existing file at the path
/// is overwritten. The container format follows the file extension. With <c>null</c>, the file is named
/// <c>yyyy-MM-dd HH-mm-ss.&lt;ext&gt;</c> (local start time, extension from <see cref="AdvancedConfig.FileExtension"/>)
/// in the output directory chosen in the mod settings. When the render is cancelled or fails, the unfinished file
/// is deleted.
/// </remarks>
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithOutputPath(string? outputPath)
@@ -96,6 +215,10 @@ public class RenderSession
/// <summary>
/// Sets whether desktop notifications should be displayed when rendering completes.
/// </summary>
/// <remarks>
/// The notification is shown by the default completion callback only. When a callback is added with
/// <see cref="OnComplete"/>, it replaces the default callback and no notification is shown. Default is <c>true</c>.
/// </remarks>
/// <param name="enabled"><c>true</c> to enable desktop notifications; otherwise, <c>false</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithNotification(bool enabled)
@@ -107,6 +230,13 @@ public class RenderSession
/// <summary>
/// Adds a callback action to be invoked when the render session completes or fails.
/// </summary>
/// <remarks>
/// Called once per render on the main Unity thread: on success after the output file has been finished, on cancel or
/// failure after the unfinished file has been deleted, and before the game returns to the level editor.
/// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in
/// the order added). The combined callback replaces the default one, which shows the desktop notification.
/// An exception thrown by a callback is logged and stops the callbacks added after it.
/// </remarks>
/// <param name="callback">The callback action that receives the resulting <see cref="RenderResult"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession OnComplete(Action<RenderResult> callback)
@@ -118,6 +248,11 @@ public class RenderSession
/// <summary>
/// Adds a callback action to be invoked on the last update of each frame in the render session.
/// </summary>
/// <remarks>
/// Called on the main Unity thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene
/// of the render has loaded until the render ends (including the countdown, before capture starts).
/// Multiple calls combine the callbacks. An exception thrown by a callback is logged.
/// </remarks>
/// <param name="callback">The callback action to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession OnLastUpdate(Action callback)
@@ -126,14 +261,420 @@ public class RenderSession
return this;
}
/// <summary>
/// Adds a custom frame modification hook to be executed for each encoded video frame.
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
/// </summary>
/// <remarks>
/// <para>
/// <b>Execution Frequency Note:</b> This hook is invoked only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </para>
/// <para>
/// The hook is called on the main Unity thread at the end of the frame, after the game has rendered it and before
/// the GPU readback for encoding is requested. Write the result into <see cref="FrameHookContext.Destination"/> and
/// swap (or use <see cref="FrameHookContext.Blit(Material, int)"/>, which swaps); a hook that does not swap leaves
/// the frame unchanged. An exception thrown by the hook is logged and the next hook still runs.
/// Session hooks apply only to the render started by <see cref="Start"/> and are discarded when it ends.
/// The same delegate can be added more than once; it then runs once per addition.
/// </para>
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public RenderSession AddFrameHook(Action<FrameHookContext> hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
_frameHooks.Add(new SessionHookEntry(hook, hook));
return this;
}
/// <summary>
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
/// Automatically performs a GPU blit from source to destination and swaps buffers.
/// </summary>
/// <remarks>
/// Equivalent to a hook calling <see cref="FrameHookContext.Blit(Material, int)"/>: the current frame is bound as the
/// material's <c>_MainTex</c> by <c>Graphics.Blit</c>. The material is referenced, not copied, so property changes
/// made to it later apply to the following frames. Execution rules are the same as
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>.
/// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
/// </remarks>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
public RenderSession AddFrameHook(Material material, int pass = -1)
{
if (material is null) throw new ArgumentNullException(nameof(material));
_frameHooks.Add(new SessionHookEntry(material, context => context.Blit(material, pass)));
return this;
}
/// <summary>
/// Adds a custom frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>
/// <see cref="IFrameHook.OnRenderFrame"/> is invoked with the same rules as
/// <see cref="AddFrameHook(Action{FrameHookContext})"/>. Remove it with <see cref="RemoveFrameHook(IFrameHook)"/>.
/// </remarks>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public RenderSession AddFrameHook(IFrameHook hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
_frameHooks.Add(new SessionHookEntry(hook, hook.OnRenderFrame));
return this;
}
/// <summary>
/// Alias for <see cref="AddFrameHook(Action{FrameHookContext})"/>.
/// </summary>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(Action<FrameHookContext> hook) => AddFrameHook(hook);
/// <summary>
/// Alias for <see cref="AddFrameHook(Material, int)"/>.
/// </summary>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(Material material, int pass = -1) => AddFrameHook(material, pass);
/// <summary>
/// Alias for <see cref="AddFrameHook(IFrameHook)"/>.
/// </summary>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithFrameHook(IFrameHook hook) => AddFrameHook(hook);
/// <summary>
/// Removes a previously added frame modification hook callback from this session.
/// </summary>
/// <remarks>
/// Removes the first entry (in the order added) whose registered object is <paramref name="hook"/> or whose delegate
/// equals it. When the same delegate was added several times, one call removes one addition. Does nothing when no
/// entry matches. Affects only renders started after this call.
/// </remarks>
/// <param name="hook">The hook action to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Action<FrameHookContext> hook)
{
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook) || Equals(e.Action, hook));
if (idx >= 0) _frameHooks.RemoveAt(idx);
return this;
}
/// <summary>
/// Removes a previously added material-based post-processing pass from this session.
/// </summary>
/// <remarks>
/// Removes every pass added with <paramref name="material"/> (any pass index). Does nothing when the material is
/// null or destroyed. Affects only renders started after this call.
/// </remarks>
/// <param name="material">The material to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(Material material)
{
if (material)
{
for (var i = _frameHooks.Count - 1; i >= 0; i--)
{
if (ReferenceEquals(_frameHooks[i].Key, material))
{
_frameHooks.RemoveAt(i);
}
}
}
return this;
}
/// <summary>
/// Removes a previously added <see cref="IFrameHook"/> from this session.
/// </summary>
/// <remarks>
/// Removes the first entry added with this instance (reference equality). Affects only renders started after this call.
/// </remarks>
/// <param name="hook">The hook instance to remove.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession RemoveFrameHook(IFrameHook hook)
{
var idx = _frameHooks.FindIndex(e => ReferenceEquals(e.Key, hook));
if (idx >= 0) _frameHooks.RemoveAt(idx);
return this;
}
/// <summary>
/// Clears all frame modification hooks configured for this render session.
/// </summary>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession ClearFrameHooks()
{
_frameHooks.Clear();
return this;
}
/// <summary>
/// Registers a global frame modification hook that will execute on every render session.
/// </summary>
/// <remarks>
/// <para>
/// <b>Execution Frequency Note:</b> Global frame hooks are executed only for frames that are captured for the output video.
/// When <c>RenderFPS</c> is higher than <c>VideoFPS</c>, intermediate game render frames are skipped,
/// so this hook will <b>not</b> be called on every game frame.
/// </para>
/// <para>
/// The registration stays active for every following render (and a render already running, from its next
/// captured frame) until the returned handle is disposed or the hook is removed with
/// <see cref="RemoveGlobalFrameHook(Action{FrameHookContext})"/> or <see cref="ClearGlobalFrameHooks"/>.
/// Global hooks run after the session hooks, in registration order, on the main Unity thread; an exception thrown
/// by the hook is logged and the next hook still runs. This method is thread-safe. Registering the same delegate
/// twice creates two registrations.
/// </para>
/// </remarks>
/// <param name="hook">The callback action that receives a <see cref="FrameHookContext"/>.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(Action<FrameHookContext> hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
var reg = new GlobalHookRegistration(hook, hook);
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Registers a global material-based post-processing pass that will execute on every render session.
/// </summary>
/// <remarks>
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>; the pass is applied as
/// <see cref="FrameHookContext.Blit(Material, int)"/>. Thread-safe.
/// </remarks>
/// <param name="material">The material containing the shader to apply.</param>
/// <param name="pass">The shader pass index to use, or -1 to execute all passes.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="material"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(Material material, int pass = -1)
{
if (material is null) throw new ArgumentNullException(nameof(material));
var reg = new GlobalHookRegistration(material, context => context.Blit(material, pass));
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Registers a global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>
/// Same lifetime and ordering as <see cref="AddGlobalFrameHook(Action{FrameHookContext})"/>. Thread-safe.
/// </remarks>
/// <param name="hook">The hook instance to execute.</param>
/// <returns>An <see cref="IDisposable"/> registration handle that unregisters the hook when disposed.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="hook"/> is null.</exception>
public static IDisposable AddGlobalFrameHook(IFrameHook hook)
{
if (hook == null) throw new ArgumentNullException(nameof(hook));
var reg = new GlobalHookRegistration(hook, hook.OnRenderFrame);
lock (GlobalHooksLock)
{
GlobalFrameHooks.Add(reg);
_globalHooksVersion++;
}
return reg;
}
/// <summary>
/// Unregisters a previously registered global frame modification hook.
/// </summary>
/// <remarks>
/// Removes the first registration (in registration order) whose registered object is <paramref name="hook"/> or whose
/// delegate equals it. Thread-safe; a running render stops calling the hook from its next captured frame.
/// </remarks>
/// <param name="hook">The hook action to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Action<FrameHookContext> hook)
{
lock (GlobalHooksLock)
{
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook) || Equals(r.Action, hook));
if (idx >= 0)
{
GlobalFrameHooks.RemoveAt(idx);
_globalHooksVersion++;
return true;
}
return false;
}
}
/// <summary>
/// Unregisters a previously registered global material-based post-processing pass.
/// </summary>
/// <remarks>Removes every registration made with <paramref name="material"/> (any pass index). Thread-safe.</remarks>
/// <param name="material">The material to remove.</param>
/// <returns><c>true</c> if any hooks matching <paramref name="material"/> were removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(Material material)
{
lock (GlobalHooksLock)
{
var removed = false;
for (var i = GlobalFrameHooks.Count - 1; i >= 0; i--)
{
if (ReferenceEquals(GlobalFrameHooks[i].Key, material))
{
GlobalFrameHooks.RemoveAt(i);
removed = true;
}
}
if (removed)
{
_globalHooksVersion++;
}
return removed;
}
}
/// <summary>
/// Unregisters a previously registered global frame modification hook implementing <see cref="IFrameHook"/>.
/// </summary>
/// <remarks>Removes the first registration made with this instance (reference equality). Thread-safe.</remarks>
/// <param name="hook">The hook instance to remove.</param>
/// <returns><c>true</c> if the hook was successfully removed; otherwise, <c>false</c>.</returns>
public static bool RemoveGlobalFrameHook(IFrameHook hook)
{
lock (GlobalHooksLock)
{
var idx = GlobalFrameHooks.FindIndex(r => ReferenceEquals(r.Key, hook));
if (idx >= 0)
{
GlobalFrameHooks.RemoveAt(idx);
_globalHooksVersion++;
return true;
}
return false;
}
}
/// <summary>Removes one registration (called by its Dispose). Does nothing when it was already removed.</summary>
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Remove(registration))
{
_globalHooksVersion++;
}
}
}
/// <summary>
/// Clears all registered global frame modification hooks.
/// </summary>
/// <remarks>
/// Removes the registrations of every mod, not only the caller's. Handles returned earlier stay valid; disposing them
/// does nothing. Thread-safe.
/// </remarks>
public static void ClearGlobalFrameHooks()
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Count > 0)
{
GlobalFrameHooks.Clear();
_globalHooksVersion++;
}
}
}
/// <summary>
/// Indicates whether any global frame modification hooks are currently registered (by any mod). Thread-safe.
/// </summary>
public static bool HasGlobalFrameHooks
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count > 0;
}
}
}
/// <summary>
/// The number of currently registered global frame modification hooks (by any mod). Thread-safe.
/// </summary>
public static int GlobalFrameHookCount
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count;
}
}
}
/// <summary>Change counter of the global hook list (see _globalHooksVersion). Thread-safe.</summary>
internal static int GlobalHooksVersion
{
get
{
lock (GlobalHooksLock)
{
return _globalHooksVersion;
}
}
}
/// <summary>Appends the delegates of every global hook, in registration order, to <paramref name="destination"/>. Thread-safe.</summary>
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
{
lock (GlobalHooksLock)
{
for (var i = 0; i < GlobalFrameHooks.Count; i++)
{
destination.Add(GlobalFrameHooks[i].Action);
}
}
}
/// <summary>
/// Applies the configured flags and initiates the render session.
/// </summary>
/// <remarks>
/// <para>
/// Must be called on the main Unity thread while no render is running; this method does not check for a running
/// render. It copies the builder's options and hooks into the render, copies <paramref name="renderConfig"/>
/// (later changes to the caller's object do not affect the render), creates the output directory, and starts
/// loading the game scene (<c>scnGame</c>) with the level. It returns immediately; the render runs over the following
/// frames and reports its end through the <see cref="OnComplete"/> callback.
/// </para>
/// <para>
/// Options that were not set keep their defaults: auto mode on, both patches on, default output path, notification
/// on, end condition "level cleared", default completion callback (notification), no last-update callback.
/// </para>
/// </remarks>
/// <param name="renderConfig">The configuration settings for the render session.</param>
/// <param name="levelPath">The file path of the level to render.</param>
/// <param name="levelPath">The file path of the level to render (the .adofai file).</param>
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
/// <exception cref="ArgumentException">
/// Thrown by the directory creation when the output path has no directory part (for example a bare file name).
/// </exception>
public void Start(RenderConfig renderConfig, string levelPath)
{
// Callbacks not set on this builder keep the values RenderContext holds; RenderContext.Reset at the end of every
// render restores the defaults.
RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch;
@@ -143,11 +684,22 @@ public class RenderSession
if (_endCondition is not null) RenderContext.EndCondition = _endCondition;
if (_onComplete is not null) RenderContext.OnComplete = _onComplete;
if (_onLastUpdate is not null) RenderContext.OnLastUpdate = _onLastUpdate;
RenderContext.SessionFrameHooks.Clear();
if (_frameHooks.Count > 0)
{
foreach (var t in _frameHooks)
{
RenderContext.SessionFrameHooks.Add(t.Action);
}
}
RenderManager.Instance.levelPath = levelPath;
RenderManager.Instance.Configuration = renderConfig;
RenderManager.Instance.StartLoadingOverride();
// 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;
if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory();
else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException());
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
using System;
using System.IO;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data;
/// <summary>
/// Mod-wide settings that are not part of a render preset, stored as JSON in
/// &lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json (see <see cref="GlobalSettings"/>).
/// </summary>
internal class GlobalConfig
{
/// <summary>
/// Output directory chosen in the render configuration window; null or empty selects
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
/// </summary>
public string? OutputDirectory { get; set; }
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
public string? LastPresetName { get; set; }
/// <summary>
/// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user
/// deleted afterwards are not written again.
/// </summary>
public bool DefaultPresetsInitialized { get; set; }
}
/// <summary>
/// Loads and saves <see cref="GlobalConfig"/>. The file is read once by the static constructor and again by
/// RenderMod.OnInitializeMelon (Load), and written by Save. Read and write errors are logged as warnings and never
/// thrown; a file that cannot be read yields the default settings. Main thread.
/// </summary>
internal static class GlobalSettings
{
/// <summary>MelonLoader's game root directory, or the application base directory when it is empty.</summary>
private static string GetGameRootDir()
{
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
/// <summary>&lt;game root&gt;/UserData/ADOFAIRenderer/global_settings.json.</summary>
private static string SettingsFilePath =>
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
/// <summary>
/// Settings in memory. Changes are kept in memory until <see cref="Save"/> is called; <see cref="Load"/> replaces the
/// object.
/// </summary>
public static GlobalConfig Current { get; private set; } = new();
/// <summary>Output directory used when none is configured: &lt;game root&gt;/Render.</summary>
public static string DefaultOutputDirectory =>
Path.Combine(GetGameRootDir(), "Render");
/// <summary>
/// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and
/// exists, otherwise <see cref="DefaultOutputDirectory"/>. The default directory is not created here
/// (RenderManager.SetupCaptureDirectory creates it).
/// </summary>
public static string EffectiveOutputDirectory
{
get
{
if (!string.IsNullOrWhiteSpace(Current.OutputDirectory) && Directory.Exists(Current.OutputDirectory))
{
return Current.OutputDirectory;
}
return DefaultOutputDirectory;
}
}
static GlobalSettings()
{
Load();
}
/// <summary>
/// Replaces <see cref="Current"/> with the content of the settings file. A missing, empty or unreadable file gives the
/// default settings (an unreadable file is logged as a warning).
/// </summary>
public static void Load()
{
try
{
if (File.Exists(SettingsFilePath))
{
var json = File.ReadAllText(SettingsFilePath);
Current = JsonConvert.DeserializeObject<GlobalConfig>(json) ?? new GlobalConfig();
return;
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[GlobalSettings] Failed to load global settings: {ex.Message}");
}
Current = new GlobalConfig();
}
/// <summary>
/// Writes <see cref="Current"/> as indented JSON to the settings file, creating the directory when needed.
/// Errors are logged as warnings.
/// </summary>
public static void Save()
{
try
{
var dir = Path.GetDirectoryName(SettingsFilePath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonConvert.SerializeObject(Current, Formatting.Indented);
File.WriteAllText(SettingsFilePath, json);
}
catch (Exception ex)
{
MelonLogger.Warning($"[GlobalSettings] Failed to save global settings: {ex.Message}");
}
}
}
+30 -5
View File
@@ -1,16 +1,33 @@
using ADOFAIRenderer.Localization;
using ADOFAIRenderer.UI;
using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// MonoBehaviour that runs before every other script's Update (execution order int.MinValue; scripts run from the
/// lowest order to the highest). Lives on a DontDestroyOnLoad object created by RenderMod.
/// It performs the start-up checks (FFmpeg libraries present, NVIDIA driver version) and then advances the render
/// frame counter through RenderManager.OnPreUpdate once per Unity frame, so every other script of that frame sees
/// the same render time.
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
/// </summary>
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{
/// <summary>True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed).</summary>
private bool _checkedFFmpeg;
/// <summary>True once the NVIDIA driver version check ran (it runs once per game session).</summary>
private bool _checkedNvidiaDriver;
/// <summary>
/// Main thread, every frame. The checks are done in order and each one blocks the following steps while its dialog
/// is open: the frame counter is not advanced until both checks are done.
/// </summary>
private void Update()
{
// 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI.
if (!_checkedFFmpeg)
{
if (FFmpegDownloadUI.Instance) return;
@@ -23,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
_checkedFFmpeg = true;
}
// 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
@@ -31,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
CheckNvidiaDriverWarning();
}
// 3. Render frame counter (only counts while a render is in the OnScene stage or later).
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreUpdate();
}
}
/// <summary>
/// Shows a warning dialog when the active GPU is an NVIDIA GPU with a driver version below 610.00.
/// No dialog is shown when the GPU is not NVIDIA or the version cannot be determined (GPUUtil returns null).
/// Buttons: open the NVIDIA driver download page and quit the game, disable the render feature for this session
/// (RenderMod.IsFeatureDisabled), or continue.
/// </summary>
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
@@ -45,18 +70,18 @@ internal class FirstUpdateHook : MonoBehaviour
var currentVersion = driverVer.Value.ToString("F2");
CommonWarningDialogUI.ShowDialog(
"NVIDIA Graphics Driver Update Recommended",
$"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.",
new DialogButton("Update Driver", () =>
LocalizationManager.Get("ui.driver_warning.title"),
LocalizationManager.Get("ui.driver_warning.body", currentVersion),
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_update"), () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, 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;
}),
new DialogButton("Continue Anyway", () =>
new DialogButton(LocalizationManager.Get("ui.driver_warning.btn_continue"), () =>
{
// ignored
})
+243
View File
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.API;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// Runs the frame hooks (session and global, see RenderSession) on a captured frame before it is read back.
/// Owns two RenderTextures (ping and pong) with the size and format of the source frame; each hook pass reads the
/// current frame and writes the other buffer (see FrameHookContext). One instance per render, created in
/// RenderManager.SetupPlay and disposed when the render ends. Main thread only.
/// </summary>
internal class FrameHookPipeline : IDisposable
{
/// <summary>First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
private RenderTexture? _pingRT;
/// <summary>Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.</summary>
private RenderTexture? _pongRT;
/// <summary>
/// Buffer returned as the output of the previous Process call (ping or pong), or null when the previous frame was
/// not modified by any hook. Its readback may still be in flight, so the next frame starts writing the other buffer.
/// </summary>
private RenderTexture? _lastOutputRT;
/// <summary>Context object passed to the hooks; reset and reused for every frame (no allocation per frame).</summary>
private FrameHookContext? _reusableContext;
/// <summary>Width of the current buffers in pixels (0 when no buffers exist).</summary>
private int _width;
/// <summary>Height of the current buffers in pixels (0 when no buffers exist).</summary>
private int _height;
/// <summary>Format of the source texture the current buffers were created for.</summary>
private RenderTextureFormat _format;
/// <summary>
/// Runs <paramref name="hooks"/> in order on <paramref name="sourceTexture"/> and returns the resulting texture.
/// An exception thrown by a hook is logged and the remaining hooks still run.
/// RenderTexture.active is restored after the hooks.
/// </summary>
/// <param name="sourceTexture">Captured game frame. Hooks do not write into it (they write the ping-pong buffers).</param>
/// <param name="frameIndex">Video frame index (0-based) of this frame.</param>
/// <param name="renderFrameIndex">
/// Render frame index (0-based, RenderManager.frameCountForVideo): counts every game frame since capture started,
/// including game frames that were not captured because they fall in an already captured video frame.
/// </param>
/// <param name="videoTime">Time of this video frame in seconds from the start of the video.</param>
/// <param name="renderFps">Render frame rate (game frames per second).</param>
/// <param name="videoFps">Effective video frame rate (min(VideoFPS, RenderFPS)).</param>
/// <param name="hooks">Hooks to run, in order. Session hooks come before global hooks.</param>
/// <returns>
/// <paramref name="sourceTexture"/> when there are no hooks, the source is not created, the buffers could not be
/// created, or no hook modified the frame; otherwise the texture holding the final frame (a ping-pong buffer, or a
/// texture a hook passed to FrameHookContext.ReplaceCurrent).
/// </returns>
public RenderTexture? Process(
RenderTexture sourceTexture,
long frameIndex,
long renderFrameIndex,
double videoTime,
double renderFps,
double videoFps,
IReadOnlyList<Action<FrameHookContext>> hooks)
{
if (hooks.Count == 0 || !sourceTexture || !sourceTexture.IsCreated())
return sourceTexture;
EnsureBuffers(sourceTexture);
if (!_pingRT || !_pongRT || !_pingRT.IsCreated() || !_pongRT.IsCreated())
return sourceTexture;
var prevActive = RenderTexture.active;
try
{
// Inter-frame ping-pong safety: start with the buffer that was NOT used as the output
// of the previous frame, ensuring the buffer currently being read back is not immediately overwritten.
var firstDestination = _lastOutputRT == _pingRT ? _pongRT : _pingRT;
if (_reusableContext == null)
{
_reusableContext = new FrameHookContext(
sourceTexture,
_pingRT,
_pongRT,
firstDestination,
frameIndex,
renderFrameIndex,
videoTime,
renderFps,
videoFps
);
}
else
{
_reusableContext.Reset(
sourceTexture,
_pingRT,
_pongRT,
firstDestination,
frameIndex,
renderFrameIndex,
videoTime,
renderFps,
videoFps
);
}
for (var i = 0; i < hooks.Count; i++)
{
try
{
hooks[i](_reusableContext);
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FrameHook] Error executing frame hook #{i} on frame {frameIndex}: {ex}");
}
}
// Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in
// _lastOutputRT too; it then matches neither buffer and the next frame starts with ping.
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
}
finally
{
RenderTexture.active = prevActive;
}
}
/// <summary>
/// Creates the ping and pong buffers when they do not exist, were released, or the source size or format changed.
/// The buffers copy the source descriptor without depth, MSAA and mipmaps, and enable random write (UAV) when the
/// GPU supports compute shaders and random write for that format, so compute shaders can write them.
/// When creating them from the descriptor throws, ARGB32 buffers of the same size are created.
/// </summary>
private void EnsureBuffers(RenderTexture sourceTexture)
{
var width = sourceTexture.width;
var height = sourceTexture.height;
var format = sourceTexture.format;
if (_pingRT && _pongRT &&
_width == width && _height == height && _format == format &&
_pingRT.IsCreated() && _pongRT.IsCreated())
{
return;
}
ReleaseBuffers();
_width = width;
_height = height;
_format = format;
try
{
var desc = sourceTexture.descriptor;
desc.depthBufferBits = 0;
desc.msaaSamples = 1;
desc.useMipMap = false;
desc.autoGenerateMips = false;
var supportsUav = SystemInfo.supportsComputeShaders &&
SystemInfo.SupportsRandomWriteOnRenderTextureFormat(desc.colorFormat);
desc.enableRandomWrite = supportsUav;
_pingRT = new RenderTexture(desc)
{
name = "ADOFAIRenderer_PingBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pingRT.Create();
_pongRT = new RenderTexture(desc)
{
name = "ADOFAIRenderer_PongBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pongRT.Create();
}
catch (Exception ex)
{
ReleaseBuffers();
Melon<RenderMod>.Logger.Warning($"[FrameHook] Failed to create descriptor-based PingPong RTs: {ex.Message}. Falling back to standard ARGB32.");
_pingRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
{
name = "ADOFAIRenderer_PingBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pingRT.Create();
_pongRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32)
{
name = "ADOFAIRenderer_PongBuffer",
filterMode = sourceTexture.filterMode,
wrapMode = sourceTexture.wrapMode
};
_pongRT.Create();
}
}
/// <summary>
/// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again.
/// </summary>
public void ReleaseBuffers()
{
if (_pingRT)
{
if (_pingRT.IsCreated()) _pingRT.Release();
UnityEngine.Object.Destroy(_pingRT);
_pingRT = null;
}
if (_pongRT)
{
if (_pongRT.IsCreated()) _pongRT.Release();
UnityEngine.Object.Destroy(_pongRT);
_pongRT = null;
}
_width = 0;
_height = 0;
_lastOutputRT = null;
_reusableContext = null;
}
/// <summary>Same as <see cref="ReleaseBuffers"/>. Main thread (Object.Destroy is used).</summary>
public void Dispose()
{
ReleaseBuffers();
}
}
+32 -8
View File
@@ -1,34 +1,58 @@
using System.Collections;
using System.Reflection;
using Cysharp.Threading.Tasks;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Core;
/// <summary>
/// Per-frame hook attached to the scrCamera GameObject by RenderManager when the game scene of a render has loaded,
/// and destroyed again when a render is cancelled or fails.
/// Execution order int.MaxValue: its LateUpdate runs after every other script's LateUpdate of the frame.
/// Each frame it calls RenderManager.OnLastUpdate (LateUpdate) and, after the frame has been rendered
/// (end of frame), RenderManager.OnPostFrame with the texture shown on scrCamera's camera quad, which is the frame
/// that gets captured.
/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
/// </summary>
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{
/// <summary>Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.</summary>
private FieldInfo? _camQuadMeshField;
/// <summary>Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed.</summary>
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
{
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
/// <summary>
/// Main thread, once per frame after all other LateUpdate calls. Runs the last-update work of RenderManager
/// (OnLastUpdate callbacks of the session) and schedules the end-of-frame capture.
/// </summary>
private void LateUpdate()
{
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine());
PostFrameAsync().Forget();
}
private IEnumerator PostFrameCoroutine()
/// <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()
{
yield return new WaitForEndOfFrame();
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
if (!_camQuadMeshRenderer && scrCamera.instance)
_camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
if (RenderManager.Instance && _camQuadMeshRenderer)
RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.sharedMaterial.mainTexture as RenderTexture);
}
}
}
+432
View File
@@ -0,0 +1,432 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Core;
/// <summary>A named render configuration, stored as one JSON file in the presets directory.</summary>
public class RenderPreset
{
/// <summary>Display name of the preset. A file without a name uses the file name (without extension).</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Render configuration of the preset.</summary>
public RenderConfig Config { get; set; } = null!;
/// <summary>Returns a copy with a copied configuration.</summary>
public RenderPreset Clone()
{
return new RenderPreset
{
Name = Name,
Config = Config.Clone()
};
}
}
/// <summary>
/// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset
/// name) in &lt;game root&gt;/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use
/// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread.
/// </summary>
public static class PresetManager
{
/// <summary>
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 FPS video.
/// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60.
/// Video bitrates are in kbps.
/// </summary>
private static readonly List<RenderPreset> DefaultPresets = new()
{
new RenderPreset
{
Name = "FHD 60fps (1x)",
Config = new RenderConfig
{
RenderWidth = 1920,
RenderHeight = 1080,
RenderFPS = 60,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 20000, MaxBitrate = 35000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "FHD 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 1920,
RenderHeight = 1080,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 30000, MaxBitrate = 50000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "QHD 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 2560,
RenderHeight = 1440,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 35000, MaxBitrate = 60000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
},
new RenderPreset
{
Name = "4K 60fps (4x)",
Config = new RenderConfig
{
RenderWidth = 3840,
RenderHeight = 2160,
RenderFPS = 240,
VideoFPS = 60,
DelayAfterEnd = 15,
ShowJudgementText = false,
VideoConfig = new VideoEncodingConfig { Codec = Codec.H264, RateControlMode = RateControlMode.CBR, Bitrate = 60000, MaxBitrate = 100000, QP = 18, KeyframeInterval = 2.0 },
AudioConfig = new AudioEncodingConfig { RenderAudio = true, AudioSampleRate = 48000, AudioChannels = 2, AudioBitrate = 256000 },
AdvancedConfig = new AdvancedConfig { ProcessAudio = true, NonTile = false, FileExtension = "mkv" }
}
}
};
/// <summary>
/// &lt;root&gt;/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
/// base directory when that is unavailable.
/// </summary>
private static string GetPresetsDirectory()
{
string? root = null;
try
{
root = MelonEnvironment.GameRootDirectory;
}
catch
{
// Outside MelonLoader host environment (e.g. tests)
}
if (string.IsNullOrEmpty(root))
{
root = AppDomain.CurrentDomain.BaseDirectory;
}
return Path.Combine(root, "UserData", "ADOFAIRenderer", "Presets");
}
/// <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, '_'));
}
/// <summary>
/// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the
/// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown.
/// </summary>
public static void EnsureDefaultPresetsCreated()
{
try
{
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
if (!GlobalSettings.Current.DefaultPresetsInitialized)
{
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
settings.Converters.Add(new ColorJsonConverter());
foreach (var def in DefaultPresets)
{
var safeName = GetSafeFileName(def.Name);
var filePath = Path.Combine(dir, safeName + ".json");
if (!File.Exists(filePath))
{
var json = JsonConvert.SerializeObject(def, settings);
File.WriteAllText(filePath, json);
}
}
GlobalSettings.Current.DefaultPresetsInitialized = true;
GlobalSettings.Save();
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error initializing default presets: {ex.Message}");
}
}
/// <summary>
/// Saves a copy of <paramref name="config"/> as preset <paramref name="name"/>, overwriting the file of the same
/// safe file name (see GetSafeFileName).
/// </summary>
/// <exception cref="ArgumentException">The name is empty or white space.</exception>
/// <exception cref="IOException">The directory or file cannot be written (not caught).</exception>
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Preset name cannot be empty.");
}
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var preset = new RenderPreset
{
Name = name,
Config = config.Clone()
};
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented
};
settings.Converters.Add(new ColorJsonConverter());
string json = JsonConvert.SerializeObject(preset, settings);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".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()
{
EnsureDefaultPresetsCreated();
var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
foreach (var b in DefaultPresets)
{
presets.Add(b.Clone());
}
return presets;
}
try
{
var files = Directory.GetFiles(dir, "*.json");
var serializerSettings = new JsonSerializerSettings();
serializerSettings.Converters.Add(new ColorJsonConverter());
var serializer = JsonSerializer.Create(serializerSettings);
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
}
// 2. legacy top-level Codec -> VideoConfig.Codec
var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
}
}
var preset = jObject.ToObject<RenderPreset>(serializer);
if (preset != null)
{
var fileBaseName = Path.GetFileNameWithoutExtension(file);
if (string.IsNullOrWhiteSpace(preset.Name))
{
preset.Name = fileBaseName;
}
if (presets.Any(p => string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase)))
{
continue;
}
presets.Add(preset);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
}
}
}
catch (Exception ex)
{
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
}
return presets;
}
/// <summary>
/// Renames key <paramref name="keyBefore"/> to <paramref name="targetKey"/>: a non-null old value overwrites the target
/// key, and the old key is always removed.
/// </summary>
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
/// <summary>
/// Deletes the file of preset <paramref name="name"/>: first the file with the safe file name; otherwise the first
/// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file
/// matches.
/// </summary>
/// <exception cref="IOException">The file with the safe file name exists but cannot be deleted (not caught).</exception>
public static void DeletePreset(string name)
{
if (string.IsNullOrWhiteSpace(name)) return;
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return;
var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
return;
}
foreach (var file in Directory.GetFiles(dir, "*.json"))
{
try
{
var fileBaseName = Path.GetFileNameWithoutExtension(file);
if (string.Equals(fileBaseName, name, StringComparison.OrdinalIgnoreCase))
{
File.Delete(file);
return;
}
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
var pName = (string?)jObject?["Name"];
if (string.Equals(pName, name, StringComparison.OrdinalIgnoreCase))
{
File.Delete(file);
return;
}
}
catch
{
// Ignore read errors during cleanup search
}
}
}
}
+889 -258
View File
File diff suppressed because it is too large. Load diff
+41
View File
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Video codec of the output. The encoder implementation (hardware or software) is chosen at run time from the
/// encoders available for the codec (VideoEncoder, GPUUtil.GetPrioritizedEncoders).
/// Values: <c>H264</c> (H.264 / AVC), <c>H265</c> (H.265 / HEVC), <c>VP9</c>, <c>AV1</c>, <c>VVC</c> (H.266).
/// The audio codec paired with each value is given by CodecUtil.AudioCodecMatch.
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
/// <summary>Audio codec choice and supported sample rates per video codec.</summary>
internal static class CodecUtil
{
/// <summary>Audio codec encoded together with each video codec (AAC for H.264/H.265, Opus for the others).</summary>
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.AV1] = 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()
{
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
[AVCodecID.AV_CODEC_ID_OPUS] = new List<int> { 48000 }
};
}
+127
View File
@@ -0,0 +1,127 @@
using System;
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Converts Unity <see cref="Color"/> and nullable <see cref="Color"/> to and from hex string (#RRGGBBAA).
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
/// </summary>
/// <remarks>
/// Reading accepts "#RRGGBB" (alpha 1), "#RRGGBBAA" (the '#' is optional), an object {"r","g","b","a"} with 0–1 floats
/// (missing r/g/b = 0, missing a = 1), or null. Invalid or unsupported input reads as null for Color? and as
/// default(Color) (all components 0) for Color; no exception is thrown for an unparsable string.
/// </remarks>
public class ColorJsonConverter : JsonConverter
{
/// <summary>True for <see cref="Color"/> and <see cref="Nullable{Color}"/>.</summary>
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Color) || objectType == typeof(Color?);
}
/// <summary>Writes the color as "#RRGGBBAA" (see <see cref="ToHex"/>), or a JSON null for a null value.</summary>
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
var color = (Color)value;
writer.WriteValue(ToHex(color));
}
/// <summary>Reads a color in one of the accepted forms (see the class remarks).</summary>
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
if (reader.TokenType == JsonToken.Null)
{
return isNullable ? null : (object)default(Color);
}
if (reader.TokenType == JsonToken.String)
{
var str = (string?)reader.Value;
if (TryParseHex(str, out var parsedColor))
{
return parsedColor;
}
return isNullable ? null : (object)default(Color);
}
if (reader.TokenType == JsonToken.StartObject)
{
var obj = JObject.Load(reader);
float r = (float?)obj["r"] ?? 0f;
float g = (float?)obj["g"] ?? 0f;
float b = (float?)obj["b"] ?? 0f;
float a = (float?)obj["a"] ?? 1f;
return new Color(r, g, b, a);
}
return isNullable ? null : (object)default(Color);
}
/// <summary>
/// Formats the color as "#RRGGBBAA" with uppercase hex digits. Each component is scaled to 0–255, rounded and clamped.
/// The components are written as stored (no gamma conversion).
/// </summary>
public static string ToHex(Color color)
{
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
byte g = (byte)Math.Clamp((int)Math.Round(color.g * 255f), 0, 255);
byte b = (byte)Math.Clamp((int)Math.Round(color.b * 255f), 0, 255);
byte a = (byte)Math.Clamp((int)Math.Round(color.a * 255f), 0, 255);
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
}
/// <summary>
/// Parses "RRGGBB" or "RRGGBBAA" (optional leading '#', surrounding white space ignored, case-insensitive).
/// </summary>
/// <param name="hex">Text to parse.</param>
/// <param name="color">Parsed color (components / 255; alpha 1 for the 6-digit form), or default when parsing fails.</param>
/// <returns>True when the text was parsed.</returns>
public static bool TryParseHex(string? hex, out Color color)
{
color = default;
if (string.IsNullOrWhiteSpace(hex)) return false;
var str = hex.Trim();
if (str.StartsWith("#"))
{
str = str.Substring(1);
}
if (str.Length == 6)
{
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
{
color = new Color(r / 255f, g / 255f, b / 255f, 1f);
return true;
}
}
else if (str.Length == 8)
{
if (byte.TryParse(str.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) &&
byte.TryParse(str.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) &&
byte.TryParse(str.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) &&
byte.TryParse(str.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var a))
{
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
return true;
}
}
return false;
}
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class DoubleLimit
{
/// <summary>
/// Validates the given double value against this limit.
/// </summary>
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive number greater than 0.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
// ReSharper disable MemberCanBePrivate.Global
public double? Min { get; }
public double? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class DoubleInputValidator
{
private struct FieldEntry
{
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<DoubleLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-87
View File
@@ -1,87 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
/// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal class EnumDropdownBinder<T> where T : struct, Enum
{
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary>
/// The currently selected enum value.
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
_dropdown = rootVisualElement.Q<DropdownField>(fieldName);
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}' not found.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
}
}
/// <summary>
/// Sets the selected enum value and updates the dropdown index.
/// </summary>
/// <param name="value">The enum value to set.</param>
public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class IntegerInputValidator
{
private struct FieldEntry
{
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<IntegerLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class IntegerLimit
{
/// <summary>
/// Validates the given integer value against this limit.
/// </summary>
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive integer greater than 0.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
// ReSharper disable MemberCanBePrivate.Global
public int? Min { get; }
public int? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
-189
View File
@@ -1,189 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data;
internal class RenderPreset
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
internal static class PresetManager
{
private static string GetPresetsDirectory()
{
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
}
private static string GetSafeFileName(string name)
{
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Preset name cannot be empty.");
}
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
var preset = new RenderPreset
{
Name = name,
Config = config
};
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
public static List<RenderPreset> GetPresets()
{
var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory();
if (!Directory.Exists(dir)) return presets;
try
{
var files = Directory.GetFiles(dir, "*.json");
foreach (var file in files)
{
try
{
var json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject == null) continue;
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
configObj["VideoConfig"] ??= oldEncodingConfigObj;
configObj.Remove("EncodingConfig");
}
// 2. legacy top-level Codec -> VideoConfig.Codec
var legacyCodecToken = configObj["Codec"];
if (legacyCodecToken != null && legacyCodecToken.Type != JTokenType.Null)
{
if (configObj["VideoConfig"] == null || configObj["VideoConfig"]!.Type == JTokenType.Null)
{
configObj["VideoConfig"] = new JObject();
}
var videoConfigObj = (JObject)configObj["VideoConfig"]!;
videoConfigObj["Codec"] ??= legacyCodecToken;
configObj.Remove("Codec");
}
// 3. legacy top-level Audio properties -> AudioConfig
string[] legacyAudioProps = { "RenderAudio", "AudioSampleRate", "AudioChannels", "AudioBitrate" };
foreach (var prop in legacyAudioProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AudioConfig"] == null || configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj["AudioConfig"] = new JObject();
}
var audioConfigObj = (JObject)configObj["AudioConfig"]!;
audioConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AudioConfig"] != null && configObj["AudioConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AudioConfig");
}
// 4. legacy top-level Advanced properties -> AdvancedConfig
string[] legacyAdvancedProps = { "RandomSeed", "ProcessAudio", "NonTile" };
foreach (var prop in legacyAdvancedProps)
{
var legacyToken = configObj[prop];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
{
if (configObj["AdvancedConfig"] == null || configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj["AdvancedConfig"] = new JObject();
}
var advancedConfigObj = (JObject)configObj["AdvancedConfig"]!;
advancedConfigObj[prop] ??= legacyToken;
}
configObj.Remove(prop);
}
if (configObj["AdvancedConfig"] != null && configObj["AdvancedConfig"]!.Type == JTokenType.Null)
{
configObj.Remove("AdvancedConfig");
}
}
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
{
presets.Add(preset);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
}
}
}
catch (Exception ex)
{
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
}
return presets;
}
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Data;
/// <summary>Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings).</summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
/// <summary>Constant bitrate at VideoEncodingConfig.Bitrate (kbps).</summary>
CBR,
/// <summary>
/// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger,
/// otherwise at 1.5 × Bitrate.
/// </summary>
VBR,
/// <summary>Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder).</summary>
CQP
}
+262 -70
View File
@@ -1,70 +1,262 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
public double RenderFPS { get; set; }
public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public bool ShowJudgementText { get; set; } = false;
}
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 44100;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
}
using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data;
/// <summary>
/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
/// files (PresetManager). <see cref="ADOFAIRenderer.API.RenderSession.Start"/> renders with a copy (<see cref="Clone"/>).
/// </summary>
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
/// <summary>
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
/// (the game state then advances in smaller time steps).
/// </summary>
public double RenderFPS { get; set; }
/// <summary>
/// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
/// </summary>
public double VideoFPS { get; set; }
/// <summary>Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).</summary>
public int RenderWidth { get; set; }
/// <summary>Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).</summary>
public int RenderHeight { get; set; }
/// <summary>Video encoder settings.</summary>
public VideoEncodingConfig VideoConfig { get; set; } = new();
/// <summary>Audio rendering and encoder settings.</summary>
public AudioEncodingConfig AudioConfig { get; set; } = new();
/// <summary>Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).</summary>
public AdvancedConfig AdvancedConfig { get; set; } = new();
/// <summary>Seconds of render time recorded after the end condition first holds (default 15).</summary>
public double DelayAfterEnd { get; set; } = 15;
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
public bool ShowJudgementText { get; set; } = false;
/// <summary>
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
/// </summary>
public void CopyFrom(RenderConfig? source)
{
if (source == null) return;
RenderFPS = source.RenderFPS;
VideoFPS = source.VideoFPS;
RenderWidth = source.RenderWidth;
RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText;
VideoConfig.CopyFrom(source.VideoConfig);
AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
}
/// <summary>Returns a deep copy (nested configs are new objects).</summary>
public RenderConfig Clone()
{
var clone = new RenderConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>Advanced render options.</summary>
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
/// <summary>
/// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
/// null leaves the random state unchanged.
/// </summary>
public int? RandomSeed { get; set; } = null;
/// <summary>
/// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time
/// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true.
/// </summary>
public bool ProcessAudio { get; set; } = true;
/// <summary>
/// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the
/// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch,
/// RenderManager.OnLastUpdate). Default false.
/// </summary>
public bool NonTile { get; set; } = false;
/// <summary>
/// When true, the game's bloom (<c>VideoBloom</c>) computes its blur size as if the frame were 720 pixels high, so the
/// blur passes on the 198-pixel-high bloom buffer are the same as in a 720p frame at any render height
/// (VideoBloomPatch). Default false.
/// </summary>
public bool HighResBloomCorrection { get; set; } = false;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// When true, fixes in-game and render bugs during SetFramerate events (where objects disappear on the first frame
/// or parallax/tiles lag behind) by deferring manual camera rendering to the end of the frame after all LateUpdates.
/// Default true.
/// </summary>
public bool FixCustomFrameRate { get; set; } = true;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
public string FileExtension { get; set; } = "mkv";
/// <summary>Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null;
/// <summary>Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".</summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
/// <summary>
/// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
/// Serialized as "#RRGGBBAA".
/// </summary>
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null;
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(AdvancedConfig? source)
{
if (source == null) return;
RandomSeed = source.RandomSeed;
ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
HighResBloomCorrection = source.HighResBloomCorrection;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
FixCustomFrameRate = source.FixCustomFrameRate;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor;
windPlanetColor = source.windPlanetColor;
}
/// <summary>Returns a copy of this object.</summary>
public AdvancedConfig Clone()
{
var clone = new AdvancedConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).</summary>
public class VideoEncodingConfig
{
/// <summary>Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).</summary>
public Codec Codec { get; set; } = Codec.H264;
/// <summary>Bitrate control mode (default CBR).</summary>
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
/// <summary>Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).</summary>
public int Bitrate { get; set; } = 30000;
/// <summary>Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.</summary>
public int MaxBitrate { get; set; } = 50000;
/// <summary>
/// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
/// selects 18. Default 18.
/// </summary>
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
/// <summary>Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.</summary>
public double KeyframeInterval { get; set; } = 2.0;
/// <summary>When true, only software (CPU) encoders are tried; hardware encoders are skipped.</summary>
public bool ForceSoftwareEncoder { get; set; }
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(VideoEncodingConfig? source)
{
if (source == null) return;
Codec = source.Codec;
RateControlMode = source.RateControlMode;
Bitrate = source.Bitrate;
MaxBitrate = source.MaxBitrate;
QP = source.QP;
KeyframeInterval = source.KeyframeInterval;
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
}
/// <summary>Returns a copy of this object.</summary>
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
/// <summary>
/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
/// (48000 Hz, 2 channels, 256000 bps).
/// </summary>
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
/// <summary>When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.</summary>
public bool RenderAudio { get; set; } = true;
/// <summary>
/// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod
/// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts.
/// </summary>
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
/// <summary>Number of output audio channels (default 2).</summary>
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
/// <summary>Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).</summary>
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
/// <summary>Copies every value of <paramref name="source"/>; does nothing when it is null.</summary>
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
RenderAudio = source.RenderAudio;
AudioSampleRate = source.AudioSampleRate;
AudioChannels = source.AudioChannels;
AudioBitrate = source.AudioBitrate;
}
/// <summary>Returns a copy of this object.</summary>
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}␍
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 ADOFAI Editor Forum
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+22
View File
@@ -0,0 +1,22 @@
# ADOFAIRenderer API & Data Linking Exception (Dynamic Linking Only)
> **Additional Permission under GNU General Public License version 3 section 7**
---
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:
- `ADOFAIRenderer.API`
- `ADOFAIRenderer.Data`
and to copy, convey, and distribute the resulting combined work under terms of your choice, without requiring such combined work or independent modules to be covered under the GNU General Public License version 3, provided that you also meet, for each linked independent module, the terms and conditions of the license of that module.
This exception applies exclusively to dynamic linking. Static linking, including but not limited to combining, embedding, compiling this Program into another binary, or merging assemblies (such as via ILMerge or Costura), is explicitly excluded from this exception and remains fully subject to the terms and copyleft requirements of the GNU General Public License version 3.
An **"independent module"** is a separate and distinct software module that is not derived from or based on this Program, other than referencing or communicating with the interfaces and types declared within the designated namespaces above.
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.
+79
View File
@@ -0,0 +1,79 @@
{
"ui.render_config.window_title": "Render Config",
"ui.render_config.presets": "Presets",
"ui.render_config.preset_select": "Preset",
"ui.render_config.preset_name_placeholder": "New preset name...",
"ui.render_config.preset_save": "Save",
"ui.render_config.preset_delete": "Delete",
"ui.render_config.preset_delete_confirm": "Confirm?",
"ui.render_config.preset_overwrite_title": "Overwrite Preset",
"ui.render_config.preset_overwrite_desc": "A preset named '{0}' already exists. Do you want to overwrite it?",
"ui.render_config.preset_delete_title": "Delete Preset",
"ui.render_config.preset_delete_desc": "Are you sure you want to delete preset '{0}'?",
"ui.render_config.preset_name_empty": "Please enter a preset name.",
"ui.render_config.preset_custom": "(Modified)",
"ui.render_config.confirm": "Confirm",
"ui.render_config.cancel": "Cancel",
"ui.render_config.render_fps": "Render FPS",
"ui.render_config.video_fps": "Video FPS",
"ui.render_config.render_width": "Render Width",
"ui.render_config.render_height": "Render Height",
"ui.render_config.delay_after_end": "Delay After End (s)",
"ui.render_config.show_judgement": "Show Judgement Text",
"ui.render_config.video_encoding": "Video Encoding Config",
"ui.render_config.audio_encoding": "Audio Encoding Config",
"ui.render_config.global_settings": "Global Settings",
"ui.render_config.advanced_settings": "Advanced Settings",
"ui.render_config.go_render": "Go Render",
"ui.render_config.codec": "Codec",
"ui.render_config.rate_control": "Rate Control Mode",
"ui.render_config.bitrate": "Bitrate (kbps)",
"ui.render_config.max_bitrate": "Max Bitrate (kbps)",
"ui.render_config.qp": "Quality (QP)",
"ui.render_config.keyframe_interval": "Keyframe Interval (s)",
"ui.render_config.force_software": "Force Software Encoder",
"ui.render_config.render_audio": "Render Audio",
"ui.render_config.sample_rate": "Sample Rate (Hz)",
"ui.render_config.channels": "Audio Channels",
"ui.render_config.audio_bitrate": "Audio Bitrate (kbps)",
"ui.render_config.output_dir": "Output Directory",
"ui.render_config.browse": "Browse...",
"ui.render_config.reset_default": "Reset to Default",
"ui.render_config.file_extension": "File Extension",
"ui.render_config.process_audio": "Process Audio",
"ui.render_config.non_tile": "Non-Tile Mode",
"ui.render_config.high_res_bloom_correction": "High-Resolution Bloom Correction",
"ui.render_config.fix_custom_frame_rate": "Fix Custom FPS Render Timing",
"ui.render_config.random_seed": "Random Seed (Optional)",
"ui.render_config.validation_title": "Invalid Settings",
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0",
"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.planet_colors": "Planet Colors (Optional)",
"ui.render_config.fire_planet": "Fire Planet",
"ui.render_config.ice_planet": "Ice Planet",
"ui.render_config.wind_planet": "Wind Planet",
"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"
}
+79
View File
@@ -0,0 +1,79 @@
{
"ui.render_config.window_title": "렌더 설정",
"ui.render_config.presets": "프리셋",
"ui.render_config.preset_select": "프리셋 선택",
"ui.render_config.preset_name_placeholder": "새 프리셋 이름...",
"ui.render_config.preset_save": "저장",
"ui.render_config.preset_delete": "삭제",
"ui.render_config.preset_delete_confirm": "삭제 확인",
"ui.render_config.preset_overwrite_title": "프리셋 덮어쓰기",
"ui.render_config.preset_overwrite_desc": "'{0}' 프리셋이 이미 존재합니다. 덮어쓰시겠습니까?",
"ui.render_config.preset_delete_title": "프리셋 삭제",
"ui.render_config.preset_delete_desc": "'{0}' 프리셋을 정말 삭제하시겠습니까?",
"ui.render_config.preset_name_empty": "프리셋 이름을 입력해 주세요.",
"ui.render_config.preset_custom": "(수정됨)",
"ui.render_config.confirm": "확인",
"ui.render_config.cancel": "취소",
"ui.render_config.render_fps": "렌더링 FPS",
"ui.render_config.video_fps": "영상 FPS",
"ui.render_config.render_width": "렌더링 너비",
"ui.render_config.render_height": "렌더링 높이",
"ui.render_config.delay_after_end": "종료 후 대기 시간 (초)",
"ui.render_config.show_judgement": "판정 텍스트 표시",
"ui.render_config.video_encoding": "비디오 인코딩 설정",
"ui.render_config.audio_encoding": "오디오 인코딩 설정",
"ui.render_config.global_settings": "전역 설정",
"ui.render_config.advanced_settings": "고급 설정",
"ui.render_config.go_render": "렌더링하러 가다",
"ui.render_config.codec": "코덱",
"ui.render_config.rate_control": "비트레이트 제어 모드",
"ui.render_config.bitrate": "비트레이트 (kbps)",
"ui.render_config.max_bitrate": "최대 비트레이트 (kbps)",
"ui.render_config.qp": "품질 (QP)",
"ui.render_config.keyframe_interval": "키프레임 간격 (초)",
"ui.render_config.force_software": "강제 소프트웨어 인코더",
"ui.render_config.render_audio": "오디오 렌더링",
"ui.render_config.sample_rate": "샘플레이트 (Hz)",
"ui.render_config.channels": "오디오 채널",
"ui.render_config.audio_bitrate": "오디오 비트레이트 (kbps)",
"ui.render_config.output_dir": "출력 디렉토리",
"ui.render_config.browse": "찾아보기...",
"ui.render_config.reset_default": "기본값으로 초기화",
"ui.render_config.file_extension": "파일 확장자",
"ui.render_config.process_audio": "오디오 처리",
"ui.render_config.non_tile": "논타일 모드",
"ui.render_config.high_res_bloom_correction": "고해상도 블룸 보정",
"ui.render_config.fix_custom_frame_rate": "커스텀 FPS 렌더 타이밍 보정",
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류",
"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.planet_colors": "행성 색상 설정 (선택 사항)",
"ui.render_config.fire_planet": "불 행성",
"ui.render_config.ice_planet": "얼음 행성",
"ui.render_config.wind_planet": "바람 행성",
"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": "그래도 계속"
}
+297
View File
@@ -0,0 +1,297 @@
using System;
using System.Collections.Generic;
using System.IO;
using MelonLoader;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Localization;
/// <summary>
/// Provides access to localized strings and handles language synchronization with the game.
/// <para>
/// Locale files are flat JSON objects (<c>"key": "text"</c>) under <c>Localization/Locale/</c>, embedded in the mod
/// assembly as resources; the file name without extension is the locale code (<c>en.json</c> -> "en").
/// The active locale follows the game language: <c>LanguageSyncPatch</c> calls <see cref="SetLanguage"/> after the game's
/// <c>RDString.Setup</c> and <c>RDString.ChangeLanguage</c>. Only Korean maps to its own locale ("ko"); every other
/// language uses English ("en").
/// </para>
/// <para>
/// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself.
/// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards.
/// </para>
/// </summary>
internal static class LocalizationManager
{
/// <summary>Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive.</summary>
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Locale used when the language has no locale file and for keys missing in the active locale.</summary>
private static readonly string FallbackLocaleCode = "en";
/// <summary>
/// Event triggered when the active language changes.
/// Raised by <see cref="SetLanguage"/> with <c>notify: true</c> when the language or the resolved locale differs from
/// the current one; the argument is the new game language. Exceptions from handlers are caught and logged
/// (an exception stops the remaining handlers of that invocation).
/// </summary>
public static event Action<SystemLanguage>? OnLanguageChanged;
/// <summary>
/// Currently active SystemLanguage: the game language as passed to <see cref="SetLanguage"/>, also when it has no
/// locale file of its own (e.g. Japanese, which displays English text).
/// </summary>
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
/// <summary>
/// Currently active locale code (e.g. "en", "ko"): the locale whose strings <see cref="Get(string)"/> returns first.
/// Always a loaded locale or the fallback "en".
/// </summary>
public static string CurrentLocaleCode { get; private set; } = "en";
/// <summary>Set by the first <see cref="Initialize"/> call; later calls return immediately.</summary>
private static bool _initialized;
/// <summary>Initializes on first access to any member, so lookups work before <c>RenderMod</c> calls <see cref="Initialize"/>.</summary>
static LocalizationManager()
{
Initialize();
}
/// <summary>
/// Initializes the localization system and loads embedded language dictionaries.
/// Runs once; further calls do nothing. Selects the initial language without raising
/// <see cref="OnLanguageChanged"/>.
/// </summary>
public static void Initialize()
{
if (_initialized) return;
_initialized = true;
LoadEmbeddedLocales();
// Detect initial language from game settings.
// At mod initialization RDString.Setup has usually not run yet, so this reads the field's initial value
// (English); LanguageSyncPatch applies the real language when the game calls RDString.Setup.
var initialLang = DetectGameLanguage();
SetLanguage(initialLang, notify: false);
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
}
/// <summary>
/// Logs through the MelonLoader logger of the mod, or to the console when that logger is not available
/// (e.g. when this class is used outside the running mod).
/// </summary>
private static void LogMsg(string msg)
{
try { Melon<RenderMod>.Logger.Msg(msg); }
catch { Console.WriteLine(msg); }
}
/// <summary>Error counterpart of <see cref="LogMsg"/>; falls back to standard error.</summary>
private static void LogError(string msg)
{
try { Melon<RenderMod>.Logger.Error(msg); }
catch { Console.Error.WriteLine(msg); }
}
/// <summary>
/// Loads all *.json locale files embedded in this assembly.
/// A resource is used when its name ends with ".json" and contains "Localization.Locale" (the manifest name built
/// from the folder path, see the EmbeddedResource item in the .csproj). A file that fails to read or parse is
/// logged and skipped; a later resource with the same locale code replaces an earlier one.
/// </summary>
private static void LoadEmbeddedLocales()
{
var assembly = typeof(LocalizationManager).Assembly;
var resourceNames = assembly.GetManifestResourceNames();
foreach (var resourceName in resourceNames)
{
if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue;
if (!resourceName.Contains("Localization.Locale")) continue;
try
{
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) continue;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
if (dict != null)
{
// Extract locale code from resource name: e.g. "ADOFAIRenderer.Localization.Locale.ko.json" -> "ko"
var parts = resourceName.Split('.');
if (parts.Length >= 2)
{
var code = parts[^2].ToLowerInvariant();
Locales[code] = dict;
LogMsg($"[Localization] Loaded locale '{code}' ({dict.Count} keys).");
}
}
}
catch (Exception ex)
{
LogError($"[Localization] Failed to load locale resource '{resourceName}': {ex.Message}");
}
}
}
/// <summary>
/// Sets the active language and notifies subscribers if changed.
/// The locale is chosen by <see cref="LanguageToLocaleCode"/>; when that locale is not loaded, "en" is used.
/// </summary>
/// <param name="language">Game language (as used by <c>RDString.language</c>).</param>
/// <param name="notify">
/// Whether to raise <see cref="OnLanguageChanged"/> when the language or locale changes; <c>false</c> is used for
/// the initial selection.
/// </param>
public static void SetLanguage(SystemLanguage language, bool notify = true)
{
var localeCode = LanguageToLocaleCode(language);
if (!Locales.ContainsKey(localeCode))
{
// Fallback to English if translation for this language does not exist
localeCode = FallbackLocaleCode;
}
var isChanged = language != CurrentLanguage || localeCode != CurrentLocaleCode;
CurrentLanguage = language;
CurrentLocaleCode = localeCode;
if (isChanged && notify)
{
LogMsg($"[Localization] Language changed to: {CurrentLanguage} ({CurrentLocaleCode})");
try
{
OnLanguageChanged?.Invoke(CurrentLanguage);
}
catch (Exception ex)
{
LogError($"[Localization] Error during OnLanguageChanged notification: {ex.Message}");
}
}
}
/// <summary>
/// Retrieves a localized string for the specified key.
/// Order: active locale, then English, then the key itself (so a missing key is visible in the UI).
/// </summary>
/// <param name="key">Localization key, e.g. <c>"ui.render_config.window_title"</c>; case-sensitive.</param>
/// <returns>The localized text, the key when no locale has it, or an empty string for a null/empty key.</returns>
public static string Get(string key)
{
if (string.IsNullOrEmpty(key)) return string.Empty;
if (!_initialized) Initialize();
// 1. Try currently active locale
if (Locales.TryGetValue(CurrentLocaleCode, out var activeDict) && activeDict.TryGetValue(key, out var val))
{
return val;
}
// 2. Fallback to English locale
if (CurrentLocaleCode != FallbackLocaleCode &&
Locales.TryGetValue(FallbackLocaleCode, out var fallbackDict) &&
fallbackDict.TryGetValue(key, out var fallbackVal))
{
return fallbackVal;
}
// 3. Fallback to raw key if missing
return key;
}
/// <summary>
/// Retrieves a formatted localized string.
/// The text from <see cref="Get(string)"/> is used as a <see cref="string.Format(string, object[])"/> template
/// (<c>{0}</c>, <c>{1}</c>, ...), formatted with the current culture. When the template does not match the
/// arguments (<see cref="FormatException"/>), the unformatted template is returned.
/// </summary>
/// <param name="key">Localization key.</param>
/// <param name="args">Format arguments; with none, the template is returned as is.</param>
public static string Get(string key, params object[] args)
{
var template = Get(key);
if (args.Length == 0) return template;
try
{
return string.Format(template, args);
}
catch (FormatException)
{
return template;
}
}
/// <summary>
/// Maps Unity's SystemLanguage enum to an internal locale code.
/// Korean -> "ko"; every other language -> "en". A new locale file also needs an entry here.
/// </summary>
private static string LanguageToLocaleCode(SystemLanguage language)
{
return language switch
{
SystemLanguage.Korean => "ko",
_ => "en"
};
}
/// <summary>
/// Attempts to read the game's current language from RDString or Persistence.
/// Falls back to English when both reads throw.
/// </summary>
private static SystemLanguage DetectGameLanguage()
{
try
{
// First attempt: RDString.language, a public static field of the game (initial value English,
// assigned from Persistence.language in RDString.Setup)
return RDString.language;
}
catch
{
try
{
// Fallback attempt: Persistence.language, the saved "language" preference (defaults to
// Application.systemLanguage)
return Persistence.language;
}
catch
{
return SystemLanguage.English;
}
}
}
}
/// <summary>
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
/// Every member forwards to the member of the same name in <see cref="LocalizationManager"/>.
/// </summary>
// ReSharper disable once InconsistentNaming
internal static class I18n
{
/// <inheritdoc cref="LocalizationManager.Get(string)"/>
public static string Get(string key) => LocalizationManager.Get(key);
/// <inheritdoc cref="LocalizationManager.Get(string, object[])"/>
public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args);
/// <inheritdoc cref="LocalizationManager.CurrentLanguage"/>
public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage;
/// <inheritdoc cref="LocalizationManager.CurrentLocaleCode"/>
public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode;
/// <inheritdoc cref="LocalizationManager.OnLanguageChanged"/>
public static event Action<SystemLanguage>? OnLanguageChanged
{
add => LocalizationManager.OnLanguageChanged += value;
remove => LocalizationManager.OnLanguageChanged -= value;
}
}
+22 -1
View File
@@ -3,17 +3,38 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Makes the async input clock offset follow the render clock immediately.
/// <para>
/// Patched member: <c>AsyncInputUtils.UpdateOffsetTime(long fixDivider = 1)</c>. The async input system maps wall-clock ticks
/// (<c>AsyncInputManager.currFrameTick</c> = <c>DateTime.Now.Ticks</c>, 100 ns units) to conductor time with
/// <c>AsyncInputManager.offsetTick</c> = currFrameTick - <c>scrConductor.dspTime</c> * 10^7. With <c>fixDivider</c> 1 the
/// offset is set directly; otherwise it moves by 1 / fixDivider of the difference per call. <c>scrConductor</c> calls it every
/// frame with divider 100 (damped); <c>scrController</c> calls it with divider 1.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). From stage Render the conductor time follows the
/// patched <c>AudioSettings.dspTime</c> (offline render clock, a fixed step per frame independent of wall-clock time), so the
/// difference changes every frame; the damped update would lag behind it. The prefix always applies the divider-1 update.
/// </para>
/// <para>Reference: https://learn.microsoft.com/en-us/dotnet/api/system.datetime.ticks</para>
/// </summary>
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{
/// <summary>
/// Prefix of <c>AsyncInputUtils.UpdateOffsetTime</c>. While rendering, sets <c>offsetTick</c> to
/// currFrameTick - dspTime * 10^7 (10^7 = 100 ns ticks per second) and <c>offsetTickUpdated</c> to true, for any divider.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix()
{
if (!RenderManager.IsActive) return true;
// Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (long)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation
}
+282 -27
View File
@@ -1,25 +1,61 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.Audio.Clip;
using HarmonyLib;
using UnityEngine;
using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Records the file path of each external clip the game loads (always active, also outside rendering,
/// because levels load their song and PlaySound files before a render starts).
/// FindOrLoadAudioClipExternal returns the clip already in audioLib when its key exists, so the path is recorded
/// only when the key is missing and the file is about to be loaded.
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
public static class AudioManagerFindOrLoadAudioClipExternalPatch
{
/// <summary>Records <paramref name="path"/> in <see cref="AudioClipPaths"/> when its audioLib key is not loaded yet. Runs the original always.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Prefix(AudioManager __instance, string path)
{
if (string.IsNullOrEmpty(path)) return;
if (__instance.audioLib.ContainsKey(AudioClipPaths.KeyOf(path))) return;
AudioClipPaths.Record(path);
}
}
/// <summary>
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled sound effect (hit sounds, PlaySound
/// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it.
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the
/// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets
/// time/pitch/SetScheduledEndTime on it afterwards, which the <see cref="EffectSource"/> patches forward to the mapped state.
/// The game volume is passed through; the start pitch is 1 because _Play sets pitch = 1 before PlayScheduled.
/// </summary>
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{
/// <summary>Opens the clip's source so its first chunk starts loading. Returns true (the original runs).</summary>
[HarmonyPrefix]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
// Cache the clip on the main thread
// Cache the clip on the main Unity thread
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
/// <summary>
/// Registers the sound at <paramref name="time"/> (absolute DSP time, converted to render time) as a non-looping effect voice,
/// maps <paramref name="__result"/> to it and queues the source's game object for deactivation at the end of the frame.
/// </summary>
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
@@ -30,14 +66,36 @@ public static class AudioManagerPlayPatch
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null) return;
mixer.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f, RenderManager.Instance.AudioTime);
EffectSource.DeactivateAtFrameEnd(__result);
}
}
}
/// <summary>
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
/// AudioManager.MakeSource is mapped to that voice (<see cref="AudioManagerMakeSourcePatch"/>).
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs. The game calls it for
/// hold sounds with an end time above 0 (scrConductor hold sound scheduling); it sets loop = true, pitch = 1, PlayScheduled(time) and
/// SetScheduledEndTime(endTime) on the new source. pitch and SetScheduledEndTime reach the mapped state through the
/// <see cref="EffectSource"/> patches; PlayScheduled is blocked during rendering by another patch.
/// </summary>
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
/// <summary>State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.</summary>
internal static SourceState? PendingState;
/// <summary>Clears <see cref="PendingState"/> when PlayWithEndTime returns or throws, so it cannot map a later MakeSource call.</summary>
[HarmonyFinalizer]
public static void Finalizer() => PendingState = null;
/// <summary>
/// Opens the clip's source and registers a looping voice from <paramref name="time"/> to <paramref name="endTime"/>
/// (absolute DSP times, converted to render time); the state waits in <see cref="PendingState"/>. Returns true (the original runs).
/// </summary>
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
@@ -46,7 +104,7 @@ public static class PlayWithEndTimePatch
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{
// Cache the clip on the main thread
// Cache the clip on the main Unity thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
@@ -54,59 +112,256 @@ public static class PlayWithEndTimePatch
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register looping sound event to the offline mixer (default pitch = 1.0f)
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
PendingState = mixer.RegisterSoundEvent(null, snd, relativeStartTime, relativeEndTime, volume, 1.0f, RenderManager.Instance.AudioTime);
}
}
return true; // Let the original method execute
}
}
/// <summary>
/// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state (<see cref="PlayWithEndTimePatch.PendingState"/>).
/// Active only while a PlayWithEndTime call registered a state (capturing with RenderAudio on); every other MakeSource call
/// (including the one inside AudioManager._Play) is left unchanged.
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
public static class AudioManagerMakeSourcePatch
{
/// <summary>
/// Takes the pending state, maps <paramref name="__result"/> to it and queues the source's game object for deactivation at
/// the end of the frame.
/// </summary>
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result)
{
var state = PlayWithEndTimePatch.PendingState;
if (state == null || !__result) return;
PlayWithEndTimePatch.PendingState = null;
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null) return;
mixer.MapSource(__result, state);
EffectSource.DeactivateAtFrameEnd(__result);
}
}
/// <summary>
/// AudioSource members of an effect source mapped to a <see cref="SourceState"/> during rendering: the patches below skip the
/// Unity implementation and read or change the state instead (isPlaying, time, timeSamples, pitch, SetScheduledEndTime, Stop).
/// The patches apply only while <see cref="TryGet"/> returns true: rendering is capturing (RenderManager.IsCapturingActive,
/// stage Render), the render has an audio mixer (created when the render starts, cleared when it ends), and the source is mapped
/// in that mixer (mappings are created only when RenderAudio is on). Otherwise they run the Unity implementation, so sources
/// without a mapping (the song, menu sounds) and all sources outside rendering behave as in the game.
///
/// The game object of a mapped source is deactivated at the end of the frame it was mapped in: the mixer produces its sound and the
/// state answers every member above, so Unity does not process the source. The game activates it again when AudioManager.MakeSource
/// reuses it. Active AudioSources add main-thread time per frame in proportion to their count, and dense levels keep thousands of
/// scheduled effect sources alive.
/// </summary>
internal static class EffectSource
{
/// <summary>Sources mapped during the current frame, deactivated by <see cref="DeactivateMappedSources"/>. Main thread only.</summary>
private static readonly List<AudioSource> PendingDeactivation = new();
/// <summary>Deactivates the game object of <paramref name="source"/> at the end of the current frame (<see cref="DeactivateMappedSources"/>).</summary>
public static void DeactivateAtFrameEnd(AudioSource source) => PendingDeactivation.Add(source);
/// <summary>
/// Deactivates the game objects of the sources mapped this frame that are still mapped and not destroyed.
/// Called by RenderManager.OnPostFrame after the audio commands of the frame are committed, which runs only in stage Render with
/// an audio mixer; sources are added only by the render-time registrations (AudioManager._Play and scrConductor.PlayWithEndTime
/// patches with RenderAudio on).
/// </summary>
public static void DeactivateMappedSources(OfflineAudioMixer mixer)
{
foreach (var source in PendingDeactivation)
{
if (source && mixer.TryGetSourceState(source, out _)) source.gameObject.SetActive(false);
}
PendingDeactivation.Clear();
}
/// <summary>
/// State mapped to <paramref name="source"/> in the mixer of the current render, with the current render time in <paramref name="now"/>.
/// False outside rendering (stage below Render), without a mixer, or for a source that is not mapped.
/// </summary>
public static bool TryGet(AudioSource source, out SourceState state, out double now)
{
state = null!;
now = 0;
if (!RenderManager.IsCapturingActive) return false;
var mixer = RenderManager.Instance.AudioMixer;
if (mixer == null || !mixer.TryGetSourceState(source, out state)) return false;
now = RenderManager.Instance.AudioTime;
return true;
}
}
/// <summary>
/// AudioSource.isPlaying getter (<see cref="EffectSource"/>): true from the scheduling call until Stop(), the scheduled end time or
/// the end of the clip, including before the scheduled start. AudioManager.Update returns a source to its reusable pool once this is false.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
public static class AudioSourceIsPlayingPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref bool __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.IsPlaying(now);
return false;
}
}
/// <summary>AudioSource.time getter (<see cref="EffectSource"/>): clip position in seconds while playing, 0 while not playing.</summary>
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
public static class AudioSourceTimeGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.GetTime(now);
return false;
}
}
/// <summary>
/// AudioSource.time setter (<see cref="EffectSource"/>): moves the playback position (clip seconds) from the current render time.
/// Set before the scheduled start (ffxPlaySound sets it right after AudioManager.Play), it becomes the start position.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
public static class AudioSourceTimePatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, float value)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetTime(value, now);
return false;
}
}
/// <summary>
/// AudioSource.timeSamples getter (<see cref="EffectSource"/>): clip position in samples of the clip while playing; while not playing,
/// the last value set through time or timeSamples (0 after Stop()).
/// </summary>
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
public static class AudioSourceTimeSamplesGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref int __result)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
__result = state.GetTimeSamples(now);
return false;
}
}
/// <summary>AudioSource.timeSamples setter (<see cref="EffectSource"/>): moves the playback position (samples of the clip).</summary>
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
public static class AudioSourceTimeSamplesPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, int value)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetTimeSamples(value, now);
return false;
}
}
/// <summary>AudioSource.pitch getter (<see cref="EffectSource"/>): the last pitch set.</summary>
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
public static class AudioSourcePitchGetterPatch
{
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
{
if (!EffectSource.TryGet(__instance, out var state, out _)) return true;
__result = state.GetPitch();
return false;
}
}
/// <summary>AudioSource.pitch setter (<see cref="EffectSource"/>): changes the playback rate from the current render time.</summary>
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
public static bool Prefix(AudioSource __instance, float value)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.Pitch = value;
}
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetPitch(value, now);
return false;
}
}
/// <summary>AudioSource.SetScheduledEndTime (<see cref="EffectSource"/>): DSP time (absolute) at which playback stops.</summary>
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
public static bool Prefix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.SetEndTime(time - RenderManager.Instance.dspTimeBase, now);
return false;
}
}
/// <summary>
/// AudioSource.Stop() (<see cref="EffectSource"/>): stops playback at the current render time. The mapping stays, so the source
/// keeps reporting the stopped state. AudioManager.Update calls Stop() on sources it returns to the pool, which are no longer
/// playing, so their voices are left unchanged.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
/// <summary>When <see cref="EffectSource.TryGet"/> finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.</summary>
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
public static bool Prefix(AudioSource __instance)
{
if (!EffectSource.TryGet(__instance, out var state, out var now)) return true;
state.Stop(now);
return false;
}
}
/// <summary>
/// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources),
/// so every effect voice stops at the current render time, including ones scheduled to start later.
/// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine,
/// scrController.FailAction, scrController.DebugUpdate, scnGame.Play and PauseMenu.Choose.
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs (it destroys the sources).
/// </summary>
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
public static class AudioManagerStopAllSoundsPatch
{
/// <summary>Stops every effect voice while capturing with RenderAudio on. The original always runs (no return value).</summary>
[HarmonyPrefix]
public static void Prefix()
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
RenderManager.Instance.AudioMixer?.StopAllEffects(RenderManager.Instance.AudioTime);
}
}
}
+118 -1
View File
@@ -6,13 +6,40 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine;
/// <summary>
/// Unity engine patches that put the game on the offline render clock and the render resolution.
/// <para>
/// Render clock: while capturing (<see cref="RenderManager.IsCapturingActive"/>, stage &gt;= Render) and
/// <c>RenderContext.TimePatch</c> is on (default on; a <c>RenderSession</c> can turn it off), the Unity time getters return
/// the value captured at the capture start (<c>RenderManager.timeBase</c>, <c>unscaledTimeBase</c>, <c>dspTimeBase</c>,
/// <c>frameCountBase</c>, read in <c>SetupPlay</c>) plus the render time <see cref="TimeSpent"/>. Each rendered frame then
/// advances game time by exactly 1 / RenderFPS seconds, independent of how long the frame took to render and encode, and the
/// clocks continue from their real values without a jump. The patches only affect managed callers of these getters
/// (game and mod scripts).
/// </para>
/// <para>
/// All prefixes run on the thread that calls the getter (normally the main Unity thread) and read <c>RenderManager</c> state
/// without locks.
/// </para>
/// </summary>
public static class RenderEnvironmentPatches
{
/// <summary>
/// Render time of the current frame in seconds: <c>RenderManager.frameCount</c> / <c>RenderFPS</c>.
/// frameCount is reset to 0 when capture starts and incremented at the start of every frame (FirstUpdateHook).
/// </summary>
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
/// <summary>
/// <c>Time.time</c> (scaled game time in seconds, float): returns timeBase + <see cref="TimeSpent"/> while capturing with
/// the time patch on. <c>Time.timeScale</c> is not applied to the render time.
/// </summary>
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{
/// <summary>Prefix of the <c>Time.time</c> getter.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -22,9 +49,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.timeAsDouble</c> (double precision of <c>Time.time</c>): timeBase + <see cref="TimeSpent"/>.</summary>
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -34,9 +65,18 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.unscaledTime</c> (time in seconds independent of <c>Time.timeScale</c>, float): unscaledTimeBase +
/// <see cref="TimeSpent"/>. Unscaled time is not advanced by <c>Time.captureDeltaTime</c> (it follows real time), so it is
/// replaced as well.
/// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
/// </summary>
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -46,9 +86,15 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.unscaledTimeAsDouble</c> (double precision of <c>Time.unscaledTime</c>): unscaledTimeBase + <see cref="TimeSpent"/>.
/// </summary>
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -58,9 +104,17 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSettings.dspTime</c> (audio system clock in seconds, double): dspTimeBase + <see cref="TimeSpent"/>.
/// The game schedules the song and computes the song position from this clock (<c>scrConductor</c>), so the level advances
/// by one render frame per frame. The offline audio mixer uses the same base (<c>RenderManager.dspTimeBase</c>).
/// </summary>
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -70,9 +124,19 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSource.PlayScheduled(double time)</c> (start playback at a dspTime): blocked for every AudioSource while
/// <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), so Unity plays no scheduled sound in real time. During
/// capture the song and the sound effects are produced offline by <c>OfflineAudioMixer</c>; sound effects reach it through
/// the <c>AudioManager._Play</c> and <c>scrConductor.PlayWithEndTime</c> patches (AudioPatches.cs).
/// </summary>
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch
{
/// <summary>Prefix of <c>AudioSource.PlayScheduled</c>.</summary>
/// <param name="__instance">The AudioSource (unused).</param>
/// <param name="time">Scheduled start in dspTime seconds (unused).</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
@@ -80,9 +144,16 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Time.frameCount</c> (frames since startup): frameCountBase + <c>RenderManager.frameCount</c> while capturing
/// (stage &gt;= Render) with <c>RenderContext.TimePatch</c> on.
/// </summary>
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -92,9 +163,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.deltaTime</c> (seconds since the previous frame, float): 1 / RenderFPS.</summary>
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -104,9 +179,13 @@ public static class RenderEnvironmentPatches
}
}
/// <summary><c>Time.unscaledDeltaTime</c> (timeScale-independent frame time in seconds, float): 1 / RenderFPS.</summary>
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch
{
/// <summary>Prefix of the getter; returns the render clock value while capturing with the time patch on.</summary>
/// <param name="__result">Replacement value in seconds.</param>
/// <returns>True to run the original getter; false when the render clock value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -116,9 +195,18 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Screen.width</c> (window width in pixels): <c>RenderConfig.RenderWidth</c> while <see cref="RenderManager.IsActive"/>
/// (stage &gt;= OnScene) and <c>RenderContext.ScreenPatch</c> is on (default on). Game code that sizes render textures from
/// the screen size (for example <c>scrCamera</c> recreates <c>camRT</c> at Screen.width x Screen.height when the size
/// differs) then works at the output resolution independent of the window size.
/// </summary>
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -128,9 +216,15 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>Screen.height</c> (window height in pixels): <c>RenderConfig.RenderHeight</c>; same conditions as <see cref="ScreenWidthPatch"/>.
/// </summary>
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch
{
/// <summary>Prefix of the getter; returns the replacement value under the conditions in the type summary.</summary>
/// <param name="__result">Replacement value.</param>
/// <returns>True to run the original getter; false when the replacement value is returned.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -140,9 +234,22 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioSource.GetSpectrumData(float[] samples, int channel, FFTWindow window)</c> (FFT magnitudes of the source's current
/// output): while capturing (stage &gt;= Render) with <c>AdvancedConfig.ProcessAudio</c> on, fills <c>samples</c> with
/// <see cref="AudioSpectrumGenerator.GetSpectrumData"/> (spectrum of the song at the render time) for any AudioSource.
/// No AudioSource plays during capture, so the original would return silence. The game calls it from
/// <c>scrConductor</c>, <c>scrBarMaker</c> and the volume trackers for audio-reactive visuals.
/// </summary>
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch
{
/// <summary>Prefix of <c>AudioSource.GetSpectrumData</c>.</summary>
/// <param name="__instance">The AudioSource queried (not used; the song spectrum is returned for every source).</param>
/// <param name="samples">Output array; its length is the number of spectrum bins.</param>
/// <param name="channel">Channel index requested by the caller.</param>
/// <param name="window">FFT window type requested by the caller.</param>
/// <returns>True to run the original method; false when the generated spectrum was written.</returns>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{
@@ -152,13 +259,23 @@ public static class RenderEnvironmentPatches
}
}
/// <summary>
/// <c>AudioManager.FindOrLoadAudioClipExternal(string path, bool mp3Streaming, float length, bool stream)</c> (coroutine
/// that loads an external audio file of the level into <c>audioLib</c>): from stage Load with
/// <c>AdvancedConfig.ProcessAudio</c> on, forces <c>stream</c> to false. The game passes <c>stream</c> to
/// <c>DownloadHandlerAudioClip.streamAudio</c> for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming
/// clips; the MP3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>.
/// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html
/// </summary>
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch
{
/// <summary>Prefix of <c>AudioManager.FindOrLoadAudioClipExternal</c>; the original always runs.</summary>
/// <param name="stream">Streaming flag of the load; set to false under the conditions above.</param>
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
if (RenderManager.Instance && RenderManager.Instance.stage >= RenderStage.Load && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false;
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// Fixes the in-game and render bug where objects (floors, decorations, planets) disappear on the first frame of
/// a SetFramerate event (due to camera Z=0 near clip culling) and where parallax/tiles lag behind on subsequent frames.
/// <para>
/// Defers the manual camera rendering (<c>scrCamera.UpdateCustomFrameRateScreen</c>) to the very end of the frame
/// (execution order <c>int.MaxValue - 1</c>), after all components (<c>scrCamera</c>, <c>scrFloor</c>,
/// <c>scrDecorationManager</c>, <c>scrPlanet</c>, <c>scrParallax</c>, and <c>scrController</c>) have finished their
/// <c>LateUpdate</c>.
/// </para>
/// </summary>
[HarmonyPatch]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class CustomFrameRateDeferPatch
{
private static bool _isExecutingDeferred;
private static scrCamera? _pendingCamera;
private static readonly Action<scrCamera>? UpdateCustomFrameRateScreenInvoker =
AccessTools.MethodDelegate<Action<scrCamera>>(AccessTools.Method(typeof(scrCamera), "UpdateCustomFrameRateScreen"));
/// <summary>
/// Checks whether the SetFramerate render timing fix is enabled.
/// Active if rendering session has the option enabled, or enabled by default during normal in-game play.
/// </summary>
public static bool IsEnabled
{
get
{
if (RenderManager.IsActive && RenderManager.Instance?.Configuration?.AdvancedConfig != null)
{
return RenderManager.Instance.Configuration.AdvancedConfig.FixCustomFrameRate;
}
return true;
}
}
/// <summary>
/// Intercepts manual camera render calls from SetCustomFrameRate (Update phase) or CheckUpdateFrameRateScreen
/// (LateUpdate phase). If not currently executing the deferred render pass, records the camera instance and
/// suppresses the premature render.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(scrCamera), "UpdateCustomFrameRateScreen")]
private static bool DeferManualRender(scrCamera __instance)
{
if (!IsEnabled) return true;
if (_isExecutingDeferred) return true;
_pendingCamera = __instance;
return false;
}
/// <summary>
/// Ensures that the runner component is attached to the camera when it starts.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch(typeof(scrCamera), "Start")]
private static void AttachRunner(scrCamera __instance)
{
if (!__instance) return;
if (!__instance.gameObject.GetComponent<CustomFrameRateDeferredRunner>())
{
__instance.gameObject.AddComponent<CustomFrameRateDeferredRunner>();
}
}
/// <summary>
/// Invoked at the very end of LateUpdate (Order int.MaxValue - 1). If a manual render was requested during the frame,
/// executes the camera's original UpdateCustomFrameRateScreen once with all objects fully prepared.
/// </summary>
internal static void ExecuteDeferredRender()
{
if (_pendingCamera == null) return;
var cam = _pendingCamera;
_pendingCamera = null;
if (!cam) return;
_isExecutingDeferred = true;
try
{
UpdateCustomFrameRateScreenInvoker?.Invoke(cam);
}
finally
{
_isExecutingDeferred = false;
}
}
}
/// <summary>
/// Runner component attached to scrCamera that executes deferred custom frame rate renders at the end of LateUpdate.
/// Execution order <c>int.MaxValue - 1</c> guarantees it runs after all normal game components (including
/// scrController at 400), but right before PostRenderHook at <c>int.MaxValue</c>.
/// </summary>
[DefaultExecutionOrder(int.MaxValue - 1)]
internal class CustomFrameRateDeferredRunner : MonoBehaviour
{
private void LateUpdate()
{
CustomFrameRateDeferPatch.ExecuteDeferredRender();
}
}
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
+42
View File
@@ -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 &gt;= OnScene), so the panel does not appear in the rendered video
/// during the delay after the level end.
/// </para>
/// </summary>
[HarmonyPatch(typeof(DetailedResults))]
// ReSharper disable once InconsistentNaming
public static class DetailedResultsPatch
{
/// <summary>
/// Prefix of <c>DetailedResults.Show</c>. While rendering, disables the component and deactivates its GameObject.
/// </summary>
/// <param name="__instance">The DetailedResults component being shown.</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Show")]
// ReSharper disable once InconsistentNaming
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;
}
}
+34 -1
View File
@@ -4,10 +4,29 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Suppresses the end-screen lanterns (level complete / accuracy / speed trial lamps) shown after a clear.
/// <para>
/// Patched members: <c>EndscreenLanterns.Show(scrPlayer, float, int, bool)</c>, which activates the lantern set, sets the
/// lantern texts and starts DOTween sequences (<c>chainSequence</c> for the chain drop, <c>lampSequences[3]</c> for the lamp
/// effects), returning the number of lanterns shown; and <c>EndscreenLanterns.Hide()</c>, which deactivates the GameObject.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), so the lanterns and their tweens do not appear in
/// the rendered video during the delay after the level end. See also <see cref="scrUIControllerPatch"/>.
/// </para>
/// </summary>
[HarmonyPatch(typeof(EndscreenLanterns))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{
/// <summary>
/// Prefix of <c>EndscreenLanterns.Show</c>. While rendering, hides the lantern set (which also runs
/// <see cref="PrefixHide"/>) and reports zero lanterns shown.
/// </summary>
/// <param name="__instance">The lantern set being shown.</param>
/// <param name="__result">Return value of Show: number of lanterns shown; set to 0 when the original is skipped.</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -22,6 +41,21 @@ public static class EndscreenLanternsPatch
return false; // Skip original Show method
}
/// <summary>
/// Prefix of <c>EndscreenLanterns.Hide</c>. While rendering, releases the lantern tweens started by an earlier Show
/// before the original deactivates the GameObject.
/// <para>
/// The private fields <c>lampSequences</c> (DOTween <c>Sequence[]</c>) and <c>chainSequence</c> (<c>Sequence</c>) are read
/// through Harmony <see cref="Traverse"/>. For each sequence, a public instance method <c>Kill(bool)</c> is looked up on the
/// object's runtime type by reflection and invoked with <c>false</c> when found; then the field slot is set to null.
/// In the game's DOTween.dll, <c>Kill(this Tween, bool complete = false)</c> is an extension method of
/// <c>DG.Tweening.TweenExtensions</c>, so the lookup on <c>Sequence</c> returns null and no Kill is invoked; the effect is
/// that the fields stop referencing the sequences, while the sequences themselves keep running in DOTween.
/// Reflection failures on a single sequence (including a null array element) are ignored. The original Hide always runs
/// afterwards.
/// </para>
/// </summary>
/// <param name="__instance">The lantern set being hidden.</param>
[HarmonyPrefix]
[HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -40,7 +74,6 @@ public static class EndscreenLanternsPatch
for (var i = 0; i < lampSequencesArray.Length; i++)
{
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
+38
View File
@@ -0,0 +1,38 @@
using ADOFAIRenderer.Localization;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Keeps the mod UI language (<see cref="LocalizationManager"/>) equal to the game language.
/// <para>
/// Patched members: <c>RDString.Setup()</c>, which initializes the game's string table and sets <c>RDString.language</c> from
/// <c>Persistence.language</c> (it returns immediately when already initialized); and <c>RDString.ChangeLanguage(SystemLanguage)</c>,
/// which stores the language in <c>Persistence.language</c>, clears the initialized flag and calls Setup again.
/// </para>
/// <para>Always active (independent of the render stage). Runs on the main Unity thread.</para>
/// </summary>
[HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch
{
/// <summary>Postfix of <c>RDString.ChangeLanguage</c>: applies the selected language to the mod UI.</summary>
/// <param name="language">Language passed to ChangeLanguage.</param>
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
[HarmonyPostfix]
public static void OnChangeLanguage(SystemLanguage language)
{
LocalizationManager.SetLanguage(language);
}
/// <summary>
/// Postfix of <c>RDString.Setup</c>: applies <c>RDString.language</c> (the language resolved by the game) to the mod UI.
/// Runs on every Setup call, including the early-return calls made when the string table is already initialized.
/// </summary>
[HarmonyPatch(nameof(RDString.Setup))]
[HarmonyPostfix]
public static void OnSetup()
{
LocalizationManager.SetLanguage(RDString.language);
}
}
+42
View File
@@ -3,9 +3,23 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Overrides two player settings read from <c>Persistence</c> during a render: the "skip intro" option and the planet colors.
/// </summary>
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{
/// <summary>
/// Postfix of the <c>Persistence.skipIntroBehavior</c> getter (setting values Off / AfterFirstTry / Always).
/// <para>
/// <c>scrConductor.StartMusicCo</c> reads this setting when the song is scheduled and, for AfterFirstTry (after a death) or
/// Always (long intro, no checkpoint), sets <c>skipOffset</c>, which moves <c>dspTimeSong</c> back by the level offset and
/// starts the song part-way in. While <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene) and the song has not
/// started yet (<c>scrConductor.hasSongStarted</c> false), the getter returns Off, so <c>skipOffset</c> stays false and the
/// song always plays from its start. The mod's offline BGM mixing, spectrum and video background timing assume this.
/// </para>
/// </summary>
/// <param name="__result">Value returned by the getter; replaced with <c>SkipIntroBehavior.Off</c> under the conditions above.</param>
[HarmonyPostfix]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
@@ -18,4 +32,32 @@ public static class PersistencePatch
__result = SkipIntroBehavior.Off;
}
}
/// <summary>
/// Postfix of <c>Persistence.GetPlayerColor(bool red)</c>, which returns the player's saved planet color
/// (preset name or hex color from the general preferences; default red/blue otherwise). <c>PlanetRenderer</c> uses it to
/// color the fire (red) and ice (blue) planets.
/// <para>
/// Active from stage Load (render started, scene loading) until the stage returns to None. When the render configuration
/// sets <c>AdvancedConfig.firePlanetColor</c> (red) or <c>AdvancedConfig.icePlanetColor</c> (blue), that color replaces the
/// saved color; an unset (null) override keeps the game value. The saved preference itself is not changed.
/// </para>
/// </summary>
/// <param name="red">True for the fire (red) planet, false for the ice (blue) planet.</param>
/// <param name="__result">Color returned by the game; replaced by the configured override when set.</param>
[HarmonyPostfix]
[HarmonyPatch("GetPlayerColor")]
// ReSharper disable once InconsistentNaming
public static void OverridePlayerColor(bool red, ref PlanetColor __result)
{
if (!RenderManager.Instance || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
__result = red switch
{
true when advConf.firePlanetColor.HasValue => new PlanetColor(advConf.firePlanetColor.Value),
false when advConf.icePlanetColor.HasValue => new PlanetColor(advConf.icePlanetColor.Value),
_ => __result
};
}
}
+54
View File
@@ -0,0 +1,54 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Applies the configured color of the third (green, "wind") planet used by multi-planet levels.
/// <para>
/// Patched members: <c>PlanetarySystem.Init()</c>, which creates the planets including <c>planetGreen</c> (default color
/// RGB 0.3, 0.7, 0.0); and <c>PlanetarySystem.ApplyMultiplanetColors()</c>, which recolors the extra planets
/// (green, yellow, purple, ...) from the red/blue planet colors (skipped by the game when <c>GCS.staticPlanetColors</c> is set).
/// Both postfixes reapply the override after the game has set its color.
/// </para>
/// <para>Active from stage Load until the stage returns to None. The fire/ice colors are handled by <see cref="PersistencePatch"/>.</para>
/// </summary>
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
/// <summary>Postfix of <c>PlanetarySystem.Init</c>: applies the wind planet color override to the new planets.</summary>
/// <param name="__instance">The planetary system that was initialized.</param>
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
public static void OnInit(PlanetarySystem __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]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
public static void OnApplyMultiplanetColors(PlanetarySystem __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)
{
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
if (advConf.windPlanetColor.HasValue && planetarySystem.planetGreen)
{
planetarySystem.planetGreen.planetRenderer.SetColor(new PlanetColor(advConf.windPlanetColor.Value));
}
}
}
+59
View File
@@ -0,0 +1,59 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Makes the game's bloom compute its blur size as if the frame were 720 pixels high
/// ("High-Resolution Bloom Correction" option).
/// <para>
/// Patched member: <c>VideoBloom.OnRenderImage(RenderTexture source, RenderTexture destination)</c>. The game scales the
/// frame down to a bloom buffer 198 pixels high (<c>scale = 198 / source.height</c>) and sets the blur radius in bloom
/// buffer pixels to <c>KernelSize * MediumKernelScale * scale</c> (and <c>KernelSize * LargeKernelScale * scale</c>).
/// <c>BloomBlit</c> turns the radius into the number of 5-tap blur passes and the blend amount of the last pass.
/// </para>
/// <para>
/// While the render is active (<see cref="RenderManager.IsActive"/>) and <c>AdvancedConfig.HighResBloomCorrection</c> is
/// enabled, the prefix multiplies <c>KernelSize</c> by <c>source.height / 720</c> for the duration of the call, so the
/// radius equals <c>KernelSize * MediumKernelScale * 198 / 720</c> and the blur passes on the bloom buffer are the same
/// as in a 720p frame. The finalizer restores the original <c>KernelSize</c>. The bloom buffer size and the downscale
/// from the source frame keep the game's behavior.
/// </para>
/// </summary>
[HarmonyPatch(typeof(VideoBloom))]
public class VideoBloomPatch
{
/// <summary>Frame height in pixels the blur size is computed for.</summary>
private const float ReferenceHeight = 720f;
/// <summary>Prefix of <c>VideoBloom.OnRenderImage</c>.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="source">The camera frame; its height selects the multiplier.</param>
/// <param name="__state">Original <c>KernelSize</c>, or null when it was not changed.</param>
[HarmonyPrefix]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void ScaleKernelSize(VideoBloom __instance, RenderTexture source, out float? __state)
// ReSharper restore InconsistentNaming
{
__state = null;
if (!source || !RenderManager.IsActive || !RenderManager.Instance.Configuration.AdvancedConfig.HighResBloomCorrection)
return;
__state = __instance.KernelSize;
__instance.KernelSize *= source.height / ReferenceHeight;
}
/// <summary>Finalizer of <c>VideoBloom.OnRenderImage</c>: restores <c>KernelSize</c> when the prefix changed it.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="__state">Original <c>KernelSize</c> stored by the prefix, or null.</param>
[HarmonyFinalizer]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void RestoreKernelSize(VideoBloom __instance, float? __state)
// ReSharper restore InconsistentNaming
{
if (__state.HasValue) __instance.KernelSize = __state.Value;
}
}
+20 -1
View File
@@ -6,10 +6,28 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Adds the "Render" button to the level editor toolbar.
/// <para>
/// Patched member: <c>scnEditor.Start()</c>, the editor scene setup (it wires the toolbar buttons, including
/// <c>buttonExit</c>). The postfix clones the exit button next to it and makes the clone start a render.
/// </para>
/// <para>
/// Always active except when the mod features are disabled (<see cref="RenderMod.IsFeatureDisabled"/>, set from the
/// driver warning dialog). Runs on the main Unity thread each time the editor scene starts.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{
/// <summary>
/// Postfix of <c>scnEditor.Start</c>: instantiates a copy of <c>buttonExit</c> under the same parent, named
/// <c>buttonRender</c>, whose click handler is <see cref="RenderManager.StartRender"/>, and replaces its icon with the
/// editor's MoveCamera event sprite (24 x 24 units). Logs a message and adds nothing when the editor or exit button is missing.
/// When the icon child or the sprite is not found, the button keeps the exit icon.
/// </summary>
/// <param name="__instance">The editor scene component.</param>
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
@@ -38,8 +56,9 @@ public static class scnEditorPatch
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// images[0] is the background on the root, images[1] is the icon on the child.
// Sprite path: the icon of the editor's MoveCamera level event, loaded from the game's Resources.
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
+43 -10
View File
@@ -6,15 +6,31 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Patches of <c>scnGame</c> (custom level scene controller, also used by the editor's play mode): hides tiles and planets
/// for the "non-tile" render option and replaces the background video player during a render.
/// </summary>
[HarmonyPatch(typeof(scnGame))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{
/// <summary>
/// Postfix of <c>scnGame.Play(int seqID, bool remakeFloors)</c>, which starts (or restarts) play of the custom level:
/// it resets the conductor, camera and controller state for the given start tile.
/// <para>
/// Active from stage Load when <c>AdvancedConfig.NonTile</c> is enabled. Disables the renderers of every floor in
/// <c>scrLevelMaker.listFloors</c> (floor mesh, legacy sprite, icon, outline, top/bottom glow, multi-planet line,
/// hold mesh, dummy planets) and of every planet of every player (sprite mesh, core/tail/sparks particles, ring, glow,
/// face, samurai sprite), so only the background and decorations remain in the rendered video. The renderers are only
/// disabled; the objects keep running. Hold meshes re-enable themselves, so <c>RenderManager.OnLastUpdate</c> disables
/// them again every frame.
/// </para>
/// </summary>
[HarmonyPostfix]
[HarmonyPatch("Play")]
public static void ProcessFloors()
{
var nonTile = RenderManager.Instance.OverrideLoading &&
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
if (!nonTile) return;
@@ -47,6 +63,7 @@ public static class scnGamePatch
}
return;
// Disables every renderer that draws one planet (fire, ice, extra planets and floor dummy planets).
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
@@ -62,12 +79,29 @@ public static class scnGamePatch
foreach (var renderer in renderers) DisableRenderer(renderer);
}
// Sets Renderer.enabled = false; null or destroyed renderers are skipped (Unity null check).
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
}
/// <summary>
/// Prefix of <c>scnGame.UpdateVideo()</c>, which sets up the level's background video on the native Unity
/// <c>VideoPlayer</c> (<c>scnGame.videoBG</c>): it reads <c>miscSettings.bgVideo</c>, loads a <c>VideoClip</c> for internal
/// (Resources / Addressables) and bundle levels or sets <c>videoBG.url</c> to the file next to the custom level, then calls
/// <c>Stop()</c>, <c>Prepare()</c> and sets <c>isLooping</c> from <c>loopVideo</c>. The game calls it from
/// <c>scnGame.FinishCustomLevelLoading</c> and from <c>scnGame.ReloadAssets</c> when <c>force</c> is true.
/// <para>
/// While <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene), an external video file of a custom level is played by
/// <see cref="VideoBGPlayer"/> (FFmpeg decoder), which shows the frame for the render time of each frame. The native
/// <c>VideoPlayer</c> plays in real time and would drift from the offline render clock, so it is disabled for that case.
/// Internal and bundle levels (built-in <c>VideoClip</c> assets, not files) and a missing file use the original method.
/// </para>
/// <para>Outside a render, any <see cref="VideoBGPlayer"/> left on the video object is destroyed and the native player re-enabled.</para>
/// </summary>
/// <param name="__instance">The scene controller.</param>
/// <returns>True to run the original UpdateVideo; false when the mod handled the video (or there is none).</returns>
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
@@ -77,11 +111,9 @@ public static class scnGamePatch
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
var oldPlayer = __instance.videoBG.gameObject.GetComponent<VideoBGPlayer>();
if (oldPlayer) Object.Destroy(oldPlayer);
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
@@ -111,7 +143,8 @@ public static class scnGamePatch
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
// Camera for the CameraFarPlane render mode: the VideoPlayer's target camera, or the "BGStaticCam" scene object
// when none is assigned.
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
@@ -122,13 +155,13 @@ public static class scnGamePatch
}
}
// Disable the native VideoPlayer so it does not play the video in real time alongside VideoBGPlayer.
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
if (!__instance.videoBG.gameObject.TryGetComponent<VideoBGPlayer>(out var player))
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
player = __instance.videoBG.gameObject.AddComponent<VideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
+22 -1
View File
@@ -3,16 +3,37 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Protects the camera render texture <c>scrCamera.camRT</c> when the custom frame rate effect is turned off while it is
/// already off.
/// <para>
/// Patched member: <c>scrCamera.SetCustomFrameRate(bool enable, float frame)</c> (level effect that updates the screen at a
/// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> FPS.
/// Disabling calls <c>Release()</c> on the quad material's current <c>mainTexture</c> and assigns <c>camRT</c> back. When
/// the effect was never enabled, <c>mainTexture</c> is <c>camRT</c> itself, so the original releases the live camera
/// render texture.
/// </para>
/// <para>Always active (independent of the render stage).</para>
/// </summary>
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
/// <summary>
/// Prefix of <c>scrCamera.SetCustomFrameRate</c>. For a disable request while <c>enableCustomFPS</c> is already false,
/// stores the arguments in <c>enableCustomFPS</c> / <c>frameRate</c> and skips the original (no texture is released).
/// Enable requests and disable requests while the effect is on run the original.
/// </summary>
/// <param name="__instance">The camera.</param>
/// <param name="enable">True to turn the custom frame rate on, false to turn it off.</param>
/// <param name="frame">Custom frame rate in frames per second.</param>
/// <returns>True to run the original method; false to skip it.</returns>
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
// Disable request while the effect is off: the quad material's mainTexture is camRT, which the original would Release().
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
+81
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Keeps the game controller (<c>scrController</c>) in uninterrupted play during a render: no pause, no failure,
/// no scene change after the clear, and no clear texts.
/// <para>
/// All patches are active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). RenderManager also sets
/// <c>noFail</c>, <c>noFailInfiniteMargin</c> and <c>disableCongratsMessage</c> on the controller when the scene is ready.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{
/// <summary>
/// Prefix of <c>scrController.TogglePauseGame()</c> (pause menu toggle; sets <c>Time.timeScale</c> to 0 or 1 and shows the
/// pause menu; also called from the pause button, the pause key and <c>OnApplicationPauseCallback</c>). Skipped while
/// rendering, so the level cannot be paused. The skipped call returns
/// false (Harmony default for the unset bool result).
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("TogglePauseGame")]
public static bool PreventPause()
@@ -14,6 +29,12 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.PortalTravelAction(Portal)</c>, which leaves the level through a portal (next level,
/// level select, main menu, ...); the game calls it on entering the Won state for non-world levels and on input after a win.
/// Skipped while rendering, so the scene stays loaded for the delay after the level end.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel()
@@ -21,6 +42,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.FailAction(...)</c>, which enters the Fail state: stops the song and all sounds, pauses the
/// VFX video and shows the failure texts. Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("FailAction")]
public static bool PreventFailAction()
@@ -28,6 +54,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.Fail2Action()</c>, which moves from Fail to Fail2 (accuracy calculation, saving of the
/// best result for boss/custom boss levels, death counter). Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action()
@@ -35,10 +66,60 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
/// <summary>
/// Prefix of <c>scrController.OnPlayerDied(...)</c>, which kills the remaining players and calls FailAction when no other
/// player is alive. Skipped while rendering.
/// </summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied()
{
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);
}
}
}
}
-40
View File
@@ -1,40 +0,0 @@
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrDecoration))]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming
public static bool SetRotationPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetScale")]
// ReSharper disable once InconsistentNaming
public static bool SetScalePrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
[HarmonyPrefix]
[HarmonyPatch("SetOpacity")]
// ReSharper disable once InconsistentNaming
public static bool SetOpacityPrefix(scrDecoration __instance)
{
return __instance != null && __instance;
}
[HarmonyPrefix]
[HarmonyPatch("UpdatePosition")]
// ReSharper disable once InconsistentNaming
public static bool UpdatePositionPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
}
+16 -1
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Prevents the end portal from being spawned on level tiles for the "non-tile" render option.
/// <para>
/// Patched member: <c>scrFloor.SpawnPortalParticles()</c>, which instantiates the animated portal prefab
/// (<c>scrPortalParticles</c>: glow, particles, cap and icon) as a child of the floor. The game calls it from
/// <c>scrLevelMaker</c>, <c>scrController</c> and <c>scrFloor</c> for the portal floor.
/// </para>
/// <para>
/// Active from stage Load when <c>AdvancedConfig.NonTile</c> is enabled, and only for floors that belong to
/// <c>scrLevelMaker.listFloors</c> (the level's tiles). See <see cref="scnGamePatch.ProcessFloors"/> for the other tile renderers.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
/// <summary>Prefix of <c>scrFloor.SpawnPortalParticles</c>.</summary>
/// <param name="__instance">The floor that would receive the portal.</param>
/// <returns>True to run the original method (spawn the portal); false to skip it (non-tile render, level floor).</returns>
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
@@ -14,7 +29,7 @@ public class scrFloorPatch
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.OverrideLoading &&
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
}
+10
View File
@@ -3,10 +3,20 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the judgement texts (e.g. Perfect, Early, Late) during a render unless the render configuration enables them.
/// <para>
/// Patched member: <c>scrHitTextManager.ShowHitText(HitMargin, scrPlanet, float, int?)</c>, which shows the hit margin
/// text at the planet for each hit (the game itself skips it for no-HUD modes and hidden margins).
/// </para>
/// <para>Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene) and <c>RenderConfig.ShowJudgementText</c> is false.</para>
/// </summary>
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{
/// <summary>Prefix of <c>scrHitTextManager.ShowHitText</c>.</summary>
/// <returns>True to run the original method (not rendering, or judgement texts enabled); false to skip it.</returns>
public static bool Prefix()
{
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
+15
View File
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Keeps a render from writing custom level results to the player's save data.
/// <para>
/// Patched member: <c>scrMistakesManager.SaveCustom(string hash, bool wonLevel, float multiplier)</c>, which compares the
/// run with the stored completion, accuracy, speed trial and death records of the custom level (keyed by the level hash),
/// stores new bests through <c>Persistence</c> and returns an <c>EndLevelInfo</c>. The game calls it from
/// <c>scrController.OnLandOnPortal</c> (custom level win) and <c>Fail2Action</c> (custom boss level failure).
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene); the run of a render is not stored as a player
/// record. The skipped call returns a default <c>EndLevelInfo</c>.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{
/// <summary>Prefix of <c>scrMistakesManager.SaveCustom</c>.</summary>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
+17
View File
@@ -3,10 +3,27 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Replaces the "any valid input" check with a render-controlled value, so player input cannot affect the level during a render.
/// <para>
/// Patched member: <c>scrPlayerManager.AnyValidInputWasTriggered()</c>, true when any player pressed a valid key this frame.
/// <c>scrController</c> uses it to leave the press-to-start wait, to restart after a failure (<c>Fail2_Update</c>) and to
/// leave the level after a win (<c>Won_Update</c>).
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). The result is true only while
/// <c>RenderManager.countdown</c> is 0 and <c>RenderManager.frameCount</c> is 0, that is, after the 3 second countdown in
/// <c>SetupPlay</c> has ended and reset the frame counter and before the next frame increments it; it is false otherwise.
/// <c>SetupPlay</c> also sets <c>scrController.levelWasSkipped</c>, which ends the press-to-start wait independently of input.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
/// <summary>Prefix of <c>scrPlayerManager.AnyValidInputWasTriggered</c>.</summary>
/// <param name="__result">Return value used when the original is skipped (see class summary).</param>
/// <returns>True to run the original method (not rendering); false to skip it (rendering).</returns>
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
+17 -1
View File
@@ -4,16 +4,32 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Makes the end portal particle systems run on scaled game time during a render.
/// <para>
/// Patched member: <c>scrPortalParticles.Start()</c> (the animated portal spawned by <c>scrFloor.SpawnPortalParticles</c>;
/// Start pre-simulates its particle system for 1 second and plays it).
/// </para>
/// <para>
/// Active from stage Load. RenderManager sets <c>Time.captureDeltaTime</c> to 1 / RenderFPS, which advances scaled time by a
/// fixed step per frame; unscaled time is not affected by captureDeltaTime and follows real time. A particle system with
/// <c>main.useUnscaledTime</c> would therefore play at the real (slower or faster) frame rate of the offline render, so the
/// postfix sets it to false on every particle system under the portal (including inactive children).
/// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{
/// <summary>Postfix of <c>scrPortalParticles.Start</c>: sets <c>useUnscaledTime = false</c> on all child particle systems.</summary>
/// <param name="__instance">The portal that started.</param>
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
public static void ForceScaledTime(scrPortalParticles __instance)
{
if (!RenderManager.Instance || !RenderManager.Instance.OverrideLoading) return;
if (!RenderManager.Instance || RenderManager.Instance.stage < RenderStage.Load) return;
if (__instance == null) return;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
+22
View File
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the "press any key to begin" text of the first level start of a render.
/// <para>
/// Patched member: <c>scrPressToStart.ShowText()</c>, which sets the press/tap-to-begin text and shows the difficulty
/// selector (<c>scrUIController.ShowDifficultyContainer</c>). <c>scrController</c> calls it before waiting for the start input.
/// </para>
/// <para>
/// Active once per render: <c>RenderManager.skipPressToStart</c> is set when the render loads <c>scnGame</c> and cleared by
/// the first ShowText call (or when the render stops). Later ShowText calls run the original.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
/// <summary>
/// Prefix of <c>scrPressToStart.ShowText</c>. When <c>skipPressToStart</c> is set, clears it, hides the difficulty
/// selector and skips the original.
/// </summary>
/// <returns>True to run the original method; false to skip it (first ShowText of a render).</returns>
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
@@ -14,6 +30,12 @@ public class scrPressToStartPatch
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
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;
}
}
+21
View File
@@ -6,10 +6,31 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Replaces the game's status text (normally "Autoplay", "Auto tile" or "Debug Mode") with the render status.
/// <para>
/// Patched member: <c>scrShowIfDebug.Update()</c>, which each frame sets its UI <c>Text</c> (<c>txt</c>) to the autoplay /
/// auto-tile / debug label or hides it (no-HUD modes, recording mode), in <c>defaultColor</c> (green) or orange for auto tiles.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). The postfix overwrites whatever the original set,
/// every frame, so the text is shown even in the game's no-HUD modes.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrShowIfDebug))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{
/// <summary>
/// Postfix of <c>scrShowIfDebug.Update</c>. Shows, in the default (green) color:
/// <list type="bullet">
/// <item>"Render in Ns.." during the countdown (N = <c>RenderManager.countdown</c>, seconds), otherwise "Render";</item>
/// <item>after the end condition is met (<c>endFrame</c> != -1), a second line "Waiting for X.Xs.." with the remaining
/// part of <c>DelayAfterEnd</c> in render seconds ((frameCount - endFrame) / RenderFPS elapsed), clamped at 0.</item>
/// </list>
/// </summary>
/// <param name="___txt">Private field <c>txt</c>: the status Text component.</param>
/// <param name="___defaultColor">Private field <c>defaultColor</c>: the game's status text color.</param>
[HarmonyPostfix]
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
+16
View File
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Hides the end-screen lanterns again right after the game shows them.
/// <para>
/// Patched member: <c>scrUIController.ShowEndscreenLanterns()</c>, which calls <c>EndscreenLanterns.Show</c> for each player's
/// lantern set (and fades the end-screen flash in co-op). <c>scrController.OnLandOnPortal</c> calls it on a win.
/// </para>
/// <para>
/// Active while <see cref="RenderManager.IsActive"/> (stage &gt;= OnScene). Works together with <see cref="EndscreenLanternsPatch"/>,
/// which already skips <c>EndscreenLanterns.Show</c> during a render.
/// </para>
/// </summary>
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{
/// <summary>
/// Postfix of <c>scrUIController.ShowEndscreenLanterns</c>: calls <c>HideEndscreenLanterns()</c>, which runs
/// <c>EndscreenLanterns.Hide</c> on every lantern set.
/// </summary>
/// <param name="__instance">The UI controller.</param>
[HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
+97
View File
@@ -0,0 +1,97 @@
# ADOFAI Renderer
> **Lossless, stutter-free, and high-quality video rendering for *A Dance of Fire and Ice* — even on low-end PCs.**
> [!NOTE]
> This project is currently in active development. Features, APIs, and documentation are subject to change at any time.
---
## 📖 About The Project
**ADOFAIRenderer** was originally initiated by **square3ang** in 2024 and officially saw its first alpha release in 2025. Today, it is actively maintained under the **ADOFAI Editor Forum** as a support project dedicated to level creators.
By decoupling the game's simulation timestep from wall-clock time, ADOFAI Renderer enables creators to produce smooth, ultra-high-definition game videos with perfect audio sync, regardless of hardware limitations or level complexity.
> Generative AI technology was partially utilized in the development of this mod.
---
## ✨ Key Features
- **🎮 Low-Spec Friendly Offline Rendering**
- Eliminates lag, frame drops, and stuttering caused by complex visual effects and heavy custom levels from the final video.
- Guarantees deterministic, frame-perfect video capture.
- **🌐 Cross-Platform Support**
- Fully supports **Windows**, **macOS**, and **Linux** environments.
- **🎥 Fully Customizable Video Output**
- **Independent Frame Rates**: Configure `Render FPS` and `Video FPS` independently.
- **Arbitrary Resolutions**: Render in standard HD/4K or any custom resolution.
- **Encoding Options**: Supports modern codecs (AV1, etc.) with configurable Bitrate Control (CBR, VBR, QP/Lossless) and hardware acceleration (with software fallback).
- **🎵 Sample-Accurate Audio Rendering**
- Captures and synchronizes in-game music and sound effects directly with rendered video frames.
- Custom audio sample rate, channels (mono/stereo), and bitrate settings.
- **⚙️ Creator-Focused Workflow & UI**
- **In-Game Configuration**: Easily tune video, audio, and advanced rendering parameters directly inside ADOFAI.
- **Preset System**: Save and load custom render configurations for different use cases.
- **Real-Time Monitor**: Live HUD tracking render queue, sent frames, memory usage, and throttling.
- **Advanced Controls**: Toggle judgement texts, adjust post-level delay, configure random seeds, or enable Non-Tile mode.
- **📦 Built-in FFmpeg Integration**
- Integrated one-click in-game FFmpeg downloader and setup for seamless installation.
- **🔌 Developer API**
- Offers a fluent API (`RenderSession`) allowing other mods to programmatically configure, trigger, and control rendering sessions.
- Full API reference and usage details are documented directly in the codebase via XML documentation comments.
---
## 🛠️ Requirements
- **Operating System**: Windows, macOS, or Linux
- **A Dance of Fire and Ice**
- **[MelonLoader](https://melonwiki.xyz/)**
- **FFmpeg** (Can be downloaded automatically via the in-game popup)
---
## 🏗️ Building from Source
> [!WARNING]
> The build configuration (`ADOFAIRenderer.csproj`) is currently tailored specifically for **Windows**. Building on other operating systems (macOS, Linux) may fail or require manual adjustments to MSBuild tasks and assembly reference paths.
### Prerequisites
- **.NET SDK** (supporting .NET Standard 2.1, e.g., .NET 8.0 SDK or newer)
- **A Dance of Fire and Ice** with **[MelonLoader](https://melonwiki.xyz/)** (Mono runtime) installed.
- **Environment Variable**: Set `ADOFAI_PATH` to your game directory (e.g., `C:/Program Files (x86)/Steam/steamapps/common/A Dance of Fire and Ice`).
### Compile ADOFAIRenderer
Once `ADOFAI_PATH` is configured in your environment:
```bash
dotnet build ADOFAIRenderer.csproj -c Release
```
The compiled mod will be output to `bin/Release/netstandard2.1/ADOFAIRenderer.dll`.
---
## 👥 Credits & History
- **Original Author**: [square3ang](https://github.com/square3ang) (2024-2025)
- **Current Maintainer**: [ADOFAI Editor Forum](https://git.yunizun.org/ADOFAIEditorForum) (2026)
- A community-driven initiative supporting custom level designers and video creators.
---
## 📄 License
This project is licensed under the **GNU General Public License v3.0 with API & Data Linking Exception (GPLv3 with Exception, Dynamic Linking Only)**.
- Third-party mods and external tools that reference or dynamically link to ADOFAIRenderer solely via the `ADOFAIRenderer.API` and `ADOFAIRenderer.Data` namespaces are exempt from GPLv3 copyleft requirements and may be distributed under terms of their author's choice. (Static linking is excluded from this exception.)
- For full license terms and exception details, see [LICENSE](LICENSE) and [LICENSE-EXCEPTION.md](LICENSE-EXCEPTION.md).
+52 -60
View File
@@ -1,78 +1,70 @@
using System;
using ADOFAIRenderer;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
using MelonLoader;
using MelonLoader.Utils;
using UnityEngine;
using UnityEngine.EventSystems;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.2-BETA", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.2-INDEV", "ADOFAI Editor Forum")]
[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
public static bool IsFeatureDisabled = false;
/// <summary>
/// Expiration time of this build (UTC). After it, the render configuration window shows an "expired" note next to
/// the version text. It does not block rendering.
/// </summary>
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
/// <summary>
/// Called once by MelonLoader after the mod assembly is loaded.
/// The native FFmpeg libraries are loaded from MelonLoader's UserLibs directory.
/// The three manager GameObjects are marked DontDestroyOnLoad so they survive scene changes.
/// </summary>
public override void OnInitializeMelon()
{
public static AssetBundle FontBundle = null!;
// ReSharper disable once MemberCanBePrivate.Global
public static AssetBundle SceneBundle = null!;
// ReSharper disable once MemberCanBePrivate.Global
public static AssetBundle ShaderBundle = null!;
public static ComputeShader ColorConversionShader = null!;
public static bool IsFeatureDisabled = false;
public static DateTime ExpirationTime = new(2026, 09, 21, 0, 0, 0, DateTimeKind.Utc);
public override void OnInitializeMelon()
{
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
GlobalSettings.Load();
Localization.LocalizationManager.Initialize();
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
var renderManagerObj = new GameObject("Render Manager");
var updateHookerObj = new GameObject("Update Hooker");
Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj);
var renderManagerObj = new GameObject("Render Manager");
var updateHookerObj = new GameObject("Update Hooker");
var uiManagerObj = new GameObject("Renderer UI Manager");
Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj);
Object.DontDestroyOnLoad(uiManagerObj);
renderManagerObj.AddComponent<RenderManager>();
updateHookerObj.AddComponent<FirstUpdateHook>();
LoadBundles();
}
renderManagerObj.AddComponent<RenderManager>();
updateHookerObj.AddComponent<FirstUpdateHook>();
uiManagerObj.AddComponent<RendererUIManager>();
ModAssets.Load();
}
private static void LoadBundles()
{
FontBundle = BundleLoader.GetBundle("fonts");
SceneBundle = BundleLoader.GetBundle("scenes");
SceneBundle.GetAllScenePaths();
try
{
ShaderBundle = BundleLoader.GetBundle("shaders");
ColorConversionShader = ShaderBundle.LoadAsset<ComputeShader>("ColorConversion");
Melon<RenderMod>.Logger.Msg("Color conversion Compute Shader loaded successfully.");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Warning($"Failed to load Shader AssetBundle. GPU color conversion will be disabled and fallback to CPU. Error: {ex.Message}");
}
}
public static void FixEventSystem()
{
if (EventSystem.current == null) return;
var currentModule = EventSystem.current.currentInputModule;
if (currentModule) return;
Melon<RenderMod>.Logger.Msg("Ported Unity 6 Input Module.");
var newModule = EventSystem.current.gameObject.AddComponent<StandaloneInputModule>();
newModule.enabled = true;
}
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
RenderManager.Instance.CleanUpActiveRender();
}
/// <summary>
/// Called by MelonLoader when the game quits. Aborts an active render (encoders disposed, render thread joined,
/// unfinished output file deleted) through RenderManager.CleanUpActiveRender.
/// </summary>
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
RenderManager.Instance.CleanUpActiveRender();
}
}
+158 -210
View File
@@ -1,22 +1,39 @@
using System;
using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Description of one button of a <see cref="CommonWarningDialogUI"/> dialog: label, click action and optional color.
/// </summary>
internal class DialogButton
{
// ReSharper disable FieldCanBeMadeReadOnly.Global
/// <summary>Button label, shown as given (callers pass already localized text).</summary>
public string Text;
/// <summary>
/// Action run on the main Unity thread when the button is clicked. The dialog is destroyed after the action
/// returns, also when the action throws (the exception is logged).
/// </summary>
public Action OnClick;
/// <summary>Normal (idle) background color of the button; <c>null</c> keeps the O5Kit theme button color.</summary>
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
/// <summary>Creates a button description.</summary>
/// <param name="text">Button label.</param>
/// <param name="onClick">Action run on click, before the dialog closes.</param>
/// <param name="backgroundColor">Optional normal background color; <c>null</c> uses the theme color.</param>
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
Text = text;
@@ -25,18 +42,45 @@ internal class DialogButton
}
}
/// <summary>
/// Modal message dialog with a title, a description and one or more buttons.
/// <para>
/// Each <see cref="ShowDialog"/> call creates its own GameObject (kept across scene loads) with a screen-space overlay
/// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it.
/// While the dialog exists, every enabled UI Toolkit root (<see cref="UIDocument"/>) is disabled so that it cannot
/// receive input; the roots are enabled again in <see cref="OnDestroy"/>. Clicking any button runs its action and then
/// destroys the dialog. All members run on the main Unity thread.
/// </para>
/// </summary>
internal class CommonWarningDialogUI : MonoBehaviour
{
/// <summary>
/// The most recently created dialog, or <c>null</c> when that dialog has been destroyed.
/// <c>FirstUpdateHook</c> checks it to wait until a startup dialog is closed.
/// If several dialogs are open at once, this refers to the newest one only.
/// </summary>
public static CommonWarningDialogUI? Instance { get; private set; }
/// <summary>UI Toolkit root elements disabled by this dialog, enabled again when the dialog is destroyed.</summary>
private readonly List<VisualElement> _disabledElements = new();
/// <summary>O5Kit window holding the title, description and buttons; disposed in <see cref="OnDestroy"/>.</summary>
private O5Window? _window;
private void Awake()
{
Instance = this;
}
/// <summary>
/// Disposes the window, enables the UI Toolkit roots disabled in <see cref="Initialize"/> and clears
/// <see cref="Instance"/> if it still refers to this dialog.
/// </summary>
private void OnDestroy()
{
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements)
{
if (elem != null)
@@ -52,46 +96,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback processing
if (!font)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (!font)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
/// <summary>
/// Creates and shows a new dialog. Must be called on the main Unity thread.
/// </summary>
/// <param name="title">Window title text.</param>
/// <param name="description">Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.</param>
/// <param name="buttons">
/// Buttons laid out left to right with equal widths. With no buttons nothing is shown, because the dialog
/// has no close button and could not be dismissed.
/// </param>
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{
if (buttons.Length == 0) return;
@@ -102,8 +115,14 @@ internal class CommonWarningDialogUI : MonoBehaviour
dialog.Initialize(title, description, buttons);
}
/// <summary>
/// Builds the canvas, backdrop, window and contents. Layout sizes are in canvas units at the 1920x1080 reference
/// resolution of the <see cref="CanvasScaler"/>.
/// </summary>
private void Initialize(string title, string description, DialogButton[] buttons)
{
// uGUI buttons need an EventSystem to receive clicks. When the current scene has none, a child EventSystem
// is created; it is destroyed together with the dialog.
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
@@ -112,6 +131,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
// Disable every enabled UI Toolkit root so that it ignores input while the dialog is open.
// Roots that are already disabled are left alone and are not re-enabled in OnDestroy.
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement is { enabledSelf: true })
@@ -121,204 +142,131 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
// Canvas setup.
// Sorting order 32766 draws the dialog above the RendererUIManager window canvas (32000), the render overlays
// (DebugMonitorUI 9999, EscapeHandlerUI 10000) and below FFmpegDownloadUI (32767, the largest sorting order).
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
// Responsive Canvas Scaler setup
canvas.sortingOrder = 32766;
// Scale with screen size against a 1920x1080 reference; match 0.5 blends width and height scaling equally.
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop (scrim) to block background input.
// Black at 65% opacity, stretched over the whole canvas; raycastTarget makes it swallow clicks.
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(650, 360);
// 3. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = title;
titleText.fontSize = 24;
titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 4. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
// 2. Create the O5Kit window as a child of the backdrop: 540x240 units, draggable, fixed size,
// no close button (the dialog closes only through its buttons), 16-unit inner padding.
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_WarningDialog",
Title = title,
Size = new Vector2(540, 240),
Draggable = true,
Resizable = false,
ShowCloseButton = false,
ShowOutline = true,
Padding = new RectOffset(16, 16, 16, 16)
});
// 3. Build content inside window.Content: a vertical stack of the description (takes the remaining height)
// and the button row (40 units high), 16 units apart.
var content = _window.Content;
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
vLayout.spacing = 16f;
vLayout.padding = new RectOffset(16, 16, 12, 12);
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
// Description text: 18 pt, centered, word-wrapped, light gray (88% white)
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
descText.text = description;
descText.fontSize = 20;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 8;
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 6;
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
// 6. Dynamic button layout and styling
float totalWidth = 560f;
float spacing = 15f;
float buttonHeight = 45f;
int numButtons = buttons.Length;
// ReSharper disable once InconsistentNaming
var descLE = descText.gameObject.AddComponent<LayoutElement>();
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
float buttonWidth = numButtons == 1
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
// Button action row: buttons share the row width equally, 10 units apart
var btnRowObj = new GameObject("ButtonRow");
btnRowObj.transform.SetParent(content, false);
var btnRowRect = btnRowObj.AddComponent<RectTransform>();
var btnRowLE = btnRowObj.AddComponent<LayoutElement>();
btnRowLE.preferredHeight = 40f;
btnRowLE.flexibleWidth = 1f;
float startX = numButtons == 1
? 0
: (-totalWidth / 2f) + (buttonWidth / 2f);
var hLayout = btnRowObj.AddComponent<HorizontalLayoutGroup>();
hLayout.spacing = 10f;
hLayout.childAlignment = TextAnchor.MiddleCenter;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = true;
for (int i = 0; i < numButtons; i++)
for (var i = 0; i < buttons.Length; i++)
{
var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}");
btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
// The click handler runs the caller's action, logs any exception from it, and always destroys the dialog.
var btn = O5Factory.Button(
ctx: ctx,
parent: btnRowRect,
onClick: () =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
},
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
// Define default colors
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// Register click callback
btn.onClick.AddListener(() =>
if (btnData.BackgroundColor.HasValue)
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>();
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
btnRect.pivot = new Vector2(0.5f, 0.5f);
float posX = numButtons == 1
? 0
: startX + i * (buttonWidth + spacing);
btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
btn.NormalColor = btnData.BackgroundColor.Value;
btn.UpdateVisual();
}
// Add text label
var txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false);
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
le.flexibleWidth = 1f;
le.preferredHeight = 40f;
}
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
}
+65 -50
View File
@@ -8,72 +8,73 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// On-screen overlay shown while a render is running: a statistics panel in the top-right corner and a level progress
/// bar near the bottom of the screen.
/// <para>
/// Created by <c>RenderManager.StartFFmpegThread</c> on a GameObject kept across scene loads, and reused for later
/// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames.
/// <see cref="Update"/> rebuilds the text every frame on the main Unity thread.
/// </para>
/// </summary>
internal class DebugMonitorUI : MonoBehaviour
{
// --- Progress Bar UI Configuration Constants ---
// Sizes and positions are canvas units at the 1920x1080 reference resolution of the CanvasScaler.
/// <summary>Width of the progress bar; the fill width is <c>BarWidth * progress</c>.</summary>
private const float BarWidth = 800f;
/// <summary>Height of the progress bar.</summary>
private const float BarHeight = 28f;
/// <summary>Horizontal offset of the bar center from the screen center (0 = centered).</summary>
private const float BarPosX = 0f;
/// <summary>Vertical offset of the bar center from the screen center; -420 is 120 units above the bottom edge (540 - 420).</summary>
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
/// <summary>Font size of the percentage text drawn on the bar.</summary>
private const int BarTextFontSize = 16; // Increased font size for visibility
/// <summary>Unfilled bar color: opaque near-black with a slight blue tint.</summary>
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
/// <summary>Filled part color: opaque cyan (#00CCFF).</summary>
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
/// <summary>Percentage text color: opaque white.</summary>
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
/// <summary>Color of the uGUI <see cref="Outline"/> drawn around the bar background.</summary>
private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
/// <summary>
/// <c>Outline.effectDistance</c> of the bar outline: the background is redrawn shifted by +-3 units on both axes,
/// which shows as a border about 3 units wide.
/// </summary>
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
/// <summary>Statistics text inside the top-right panel.</summary>
private TextMeshProUGUI _text = null!;
/// <summary>Progress bar background object (parent of the fill and the percentage text).</summary>
private GameObject _progressBarObj = null!;
/// <summary>Progress bar fill object.</summary>
private GameObject _progressBarFillObj = null!;
/// <summary>Fill rect, anchored to the left edge of the bar; its width is set every frame.</summary>
private RectTransform _fillRect = null!;
/// <summary>Text on the bar: percentage and "current floor / last floor index".</summary>
private TextMeshProUGUI _progressText = null!;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle != null)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback: Use game's own font or built-in TMP SDF font if AssetBundle loading fails
if (font == null)
{
font = RDString.fontData.fontTMP;
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font != null) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
/// <summary>
/// Builds the overlay canvas, the statistics panel and the progress bar. Layout values are canvas units at the
/// 1920x1080 reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
// Canvas setup. Sorting order 9999 is below EscapeHandlerUI (10000) and the dialogs/windows of the mod.
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
// Dynamic resolution scaling setup (Reference: 1920x1080)
// Dynamic resolution scaling against a 1920x1080 reference resolution; match 0.5 blends width and height
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -82,7 +83,7 @@ internal class DebugMonitorUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// Create background panel
// Create background panel: 360x220 units, pivot and anchor at the top-right corner, inset 30 units from both edges
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
@@ -97,7 +98,7 @@ internal class DebugMonitorUI : MonoBehaviour
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// Create text
// Create text: 16 pt rich text, top-left aligned, filling the panel minus a 20-unit margin
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
@@ -107,13 +108,12 @@ internal class DebugMonitorUI : MonoBehaviour
_text.fontSize = 16;
_text.alignment = TextAlignmentOptions.TopLeft;
// Prevent TMPro text clipping
// Wrap long lines and let text that exceeds the panel height overflow instead of being truncated
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// Load and apply font
var font = GetNanumSquareNeoBdFontAsset();
_text.font = font;
// Apply the mod's bundled font (loaded by ModAssets.Load at mod initialization)
_text.font = ModAssets.NanumSquareNeoNormal;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
@@ -122,7 +122,7 @@ internal class DebugMonitorUI : MonoBehaviour
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
// 1. Progress Bar Container: centered anchor/pivot, placed at (BarPosX, BarPosY) from the screen center
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
@@ -142,7 +142,8 @@ internal class DebugMonitorUI : MonoBehaviour
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
// 2. Progress Bar Fill: anchored to the left edge and stretched vertically (anchor x 0..0, y 0..1).
// sizeDelta.x is the fill width in units; Update sets it to BarWidth * progress.
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
@@ -157,7 +158,7 @@ internal class DebugMonitorUI : MonoBehaviour
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
// 3. Progress Bar Text: centered over the whole bar
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
@@ -166,10 +167,10 @@ internal class DebugMonitorUI : MonoBehaviour
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize;
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font;
_progressText.font = ModAssets.NanumSquareNeoNormal;
_progressText.color = BarTextColor;
// Add progress bar text outline for readability
// Black outline (90% opacity, 1.5-unit offset) keeps the white text readable over both the fill and the background
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
@@ -181,10 +182,22 @@ internal class DebugMonitorUI : MonoBehaviour
textRectBar.offsetMax = Vector2.zero;
}
/// <summary>
/// Refreshes the statistics text and the progress bar from <see cref="RenderManager"/> and the game state.
/// Values fall back to 0 (and the queue limit to <c>RenderManager.MaxQueuedFrames</c>) when no RenderManager exists.
/// </summary>
private void Update()
{
if (!_text) return;
// Statistics lines:
// Queue - frames waiting in ImageQueue for the render thread / queue frame limit
// Rendered Frames - RenderManager.frameCount (frames produced by the game)
// Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread)
// Throttling - RenderManager.throttleCount (times the main Unity thread started waiting because ImageQueue
// held more frames than its limit)
// Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error)
// Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
@@ -192,27 +205,29 @@ internal class DebugMonitorUI : MonoBehaviour
var queueCount = 0;
if (RenderManager.Instance)
{
// ImageQueue is shared with the render thread, so its count is read under the same lock
lock (RenderManager.Instance.ImageQueue)
{
queueCount = RenderManager.Instance.ImageQueue.Count;
}
}
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($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
sb.AppendLine($"• Render Skip-Retry: {(RenderManager.Instance ? RenderManager.Instance.skipCount : 0)} / {RenderManager.MaxRenderRetry}");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
// Update Progress Bar.
// progress = seqID of the current floor / index of the last floor (listFloors.Count - 1), clamped to 0..1.
// The text shows the percentage with one decimal and "(current / last)".
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
+37 -9
View File
@@ -6,15 +6,33 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Hold-Escape-to-cancel handler shown during a render.
/// <para>
/// Created by <c>RenderManager</c> when the render reaches the OnScene stage, on a GameObject kept across scene loads.
/// While Escape is held, a centered panel shows a countdown from 3 seconds; holding it for the full 3 seconds
/// (measured in real time with a <see cref="Stopwatch"/>, independent of the patched game time) calls
/// <c>RenderManager.CancelRender</c>. Releasing Escape earlier hides the panel and resets the countdown.
/// The component destroys its GameObject as soon as <c>RenderManager.IsActive</c> is false (stage below OnScene).
/// Runs on the main Unity thread.
/// </para>
/// </summary>
internal class EscapeHandlerUI : MonoBehaviour
{
/// <summary>Centered countdown panel (background and text); inactive while Escape is not held.</summary>
private GameObject _containerObj = null!;
/// <summary>Countdown text inside <see cref="_containerObj"/>.</summary>
private TextMeshProUGUI _textComponent = null!;
/// <summary>Real-time hold duration of the current Escape press; <c>null</c> while Escape is not held.</summary>
private Stopwatch? _escapeStopwatch;
/// <summary>
/// Builds the overlay canvas and the hidden countdown panel. Layout values are canvas units at the 1920x1080
/// reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
@@ -22,7 +40,7 @@ internal class EscapeHandlerUI : MonoBehaviour
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // Placed above debug monitor
// Responsive Canvas Scaler setup
// Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -31,7 +49,7 @@ internal class EscapeHandlerUI : MonoBehaviour
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create container and background panel object
// 1. Create container and background panel object: 460x160 units at the screen center
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
@@ -46,7 +64,7 @@ internal class EscapeHandlerUI : MonoBehaviour
containerRect.anchoredPosition = Vector2.zero; // Screen center
containerRect.sizeDelta = new Vector2(460, 160);
// 2. Create text object
// 2. Create text object: 60 pt centered rich text, no wrapping, 10-unit margin inside the panel
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
@@ -57,7 +75,8 @@ internal class EscapeHandlerUI : MonoBehaviour
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// Apply game's default font (RDString.fontData.fontTMP)
// Apply the game's TextMeshPro font for the current language (RDString.fontData.fontTMP, assigned in
// RDString.Setup). It is null before RDString.Setup has run; TextMeshPro's default font is kept then.
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
@@ -71,9 +90,13 @@ internal class EscapeHandlerUI : MonoBehaviour
_containerObj.SetActive(false);
}
/// <summary>
/// Tracks the Escape key every frame: shows and updates the countdown while it is held, cancels the render after
/// 3 seconds of holding, and hides the panel when it is released.
/// </summary>
private void Update()
{
// Destroy self if rendering is no longer active
// Destroy self once the render is inactive (stage below OnScene: finished, cancelled or failed)
if (!RenderManager.IsActive)
{
Destroy(gameObject);
@@ -82,6 +105,7 @@ internal class EscapeHandlerUI : MonoBehaviour
if (Input.GetKey(KeyCode.Escape))
{
// First frame of the press: start timing and show the panel
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
@@ -92,13 +116,14 @@ internal class EscapeHandlerUI : MonoBehaviour
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
// Update remaining time text
// Update remaining time text (seconds, one decimal). The text is fixed English, not localized.
_textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
// Micro pulse animation (container smoothly scales up while holding)
// Grow animation: the panel scales linearly from 1.0 to 1.15 over the 3-second hold
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
// Held for the full 3 seconds: reset the panel, then cancel
if (elapsed < 3f) return;
_escapeStopwatch.Stop();
@@ -106,12 +131,14 @@ internal class EscapeHandlerUI : MonoBehaviour
_containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false);
// Cancel rendering and exit scene
// Cancel rendering: RenderManager.CancelRender resets the render state, stops the encoder as cancelled and
// ends the render. StopFFmpeg also destroys this handler's GameObject.
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
else
{
// Escape released before 3 seconds: stop timing and hide the panel
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
@@ -120,6 +147,7 @@ internal class EscapeHandlerUI : MonoBehaviour
}
}
/// <summary>Stops the hold stopwatch if a press is in progress.</summary>
private void OnDestroy()
{
if (_escapeStopwatch == null) return;
+298 -270
View File
@@ -1,37 +1,85 @@
using System;
using ADOFAIRenderer.Localization;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using MelonLoader;
using MelonLoader.Utils;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using MelonLoader;
using MelonLoader.Utils;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Modal dialog that installs the FFmpeg shared libraries into MelonLoader's UserLibs folder.
/// <para>
/// Shown by <c>FirstUpdateHook</c> when <see cref="CheckFFmpegExists"/> reports missing libraries. On Windows and Linux
/// the "Download" button downloads a prebuilt BtbN FFmpeg build (GPL, shared libraries) and extracts the libraries
/// into UserLibs; on macOS the button opens https://ffmpeg.org, because the dialog text tells the user to build FFmpeg
/// manually there. The libraries are found by FFmpeg.AutoGen through <c>ffmpeg.RootPath</c> (set to UserLibs in
/// <c>RenderMod.OnInitializeMelon</c>).
/// </para>
/// <para>
/// Threads: UI construction and all Unity object access happen on the main Unity thread. The download and extraction
/// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main Unity thread.
/// </para>
/// <para>
/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract
/// libraries -> "complete" for 1 second -> dialog destroyed. On failure the status shows the error and the buttons
/// return so the user can retry or cancel. Cancel destroys the dialog at once and cancels a running download.
/// </para>
/// </summary>
internal class FFmpegDownloadUI : MonoBehaviour
{
/// <summary>Row with the Download/Open Website and Cancel buttons; hidden while a download runs.</summary>
private GameObject _buttonContainerObj = null!;
/// <summary>Progress bar background (rounded, theme colors); shown when the download starts.</summary>
private GameObject _progressBarObj = null!;
/// <summary>Progress bar fill object.</summary>
private GameObject _progressBarFillObj = null!;
/// <summary>Fill rect; its <c>anchorMax.x</c> is the progress fraction 0..1 (see <see cref="UpdateProgress"/>).</summary>
private RectTransform _fillRect = null!;
/// <summary>Text centered on the progress bar (percentage or "Extracting...").</summary>
private TextMeshProUGUI _progressText = null!;
/// <summary>Status line below the progress bar; shown when the download starts.</summary>
private GameObject _statusTextObj = null!;
/// <summary>Status message, colored by state (gray starting, cyan downloading, yellow extracting, green done, red failed).</summary>
private TextMeshProUGUI _statusText = null!;
/// <summary>O5Kit window holding the dialog contents; disposed in <see cref="OnDestroy"/>.</summary>
private O5Window? _window;
/// <summary>True from the Download click until the download fails; prevents a second concurrent download.</summary>
private bool _isDownloading;
/// <summary>
/// Archive URL for the current platform: a fixed BtbN FFmpeg-Builds autobuild (2026-05-31, FFmpeg master
/// N-124714-g49a77d37be, GPL, shared libraries). Windows gets the win64 .zip; every other platform gets the
/// linux64 .tar.xz (macOS never downloads, see <see cref="Start"/>).
/// </summary>
private string DownloadUrl => Application.platform switch
{
RuntimePlatform.WindowsPlayer => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-win64-gpl-shared.zip",
_ => "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-N-124714-g49a77d37be-linux64-gpl-shared.tar.xz"
};
/// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary>
private System.Threading.CancellationTokenSource? _cts;
/// <summary>Actions posted from background threads, run in order on the main Unity thread by <see cref="Update"/>.</summary>
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
/// <summary>
/// Whether UserLibs contains at least one file for each of the five FFmpeg libraries the mod uses
/// (avcodec, avformat, avutil, swscale, swresample), matched by platform-specific file name patterns:
/// <c>avcodec-*.dll</c> on Windows, <c>libavcodec.*.dylib</c> on macOS, <c>libavcodec.so*</c> elsewhere.
/// Only file presence is checked, not the library version or whether it loads.
/// </summary>
/// <returns><c>false</c> when UserLibs does not exist or any library is missing.</returns>
public static bool CheckFFmpegExists()
{
var userLibs = MelonEnvironment.UserLibsDirectory;
@@ -64,6 +112,10 @@ internal class FFmpegDownloadUI : MonoBehaviour
return true;
}
/// <summary>
/// Creates the dialog on a new GameObject kept across scene loads, unless the libraries are already present.
/// Main thread only.
/// </summary>
public static void ShowDownloadUI()
{
@@ -74,8 +126,13 @@ internal class FFmpegDownloadUI : MonoBehaviour
obj.AddComponent<FFmpegDownloadUI>();
}
/// <summary>
/// The open dialog, or <c>null</c> after it is destroyed. <c>FirstUpdateHook</c> waits while it is set and checks
/// the libraries again after it closes.
/// </summary>
public static FFmpegDownloadUI? Instance { get; private set; }
/// <summary>Registers <see cref="Instance"/>; destroys the dialog again when the libraries already exist.</summary>
private void Awake()
{
Instance = this;
@@ -85,49 +142,13 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback processing
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
/// <summary>
/// Builds the canvas, backdrop and window: description, button row, and the hidden progress bar and status line.
/// Layout values are canvas units at the 1920x1080 reference resolution.
/// </summary>
private void Start()
{
// Set UI layer (5)
// Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
// Canvas setup
@@ -135,7 +156,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32767; // Topmost
// Responsive Canvas Scaler setup
// Canvas Scaler: 1920x1080 reference resolution, width and height matched equally (0.5)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
@@ -143,14 +164,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop: black at 65% opacity, blocks clicks to everything below
var bgPanelObj = new GameObject("Backdrop");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.color = new Color(0f, 0f, 0f, 0.65f);
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -159,270 +182,220 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(600, 320);
// 4. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = "FFmpeg Library Installation Required";
titleText.fontSize = 26;
titleText.font = font;
titleText.color = Color.white;
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 5. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
// 2. Create the O5Kit window: 580x270 units, draggable, fixed size, no close button (Cancel closes it).
// The title is localized once here; the dialog does not follow later language changes.
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{
Id = "RendererMod_FFmpegDownloadDialog",
Title = LocalizationManager.Get("ui.ffmpeg_download.title"),
Size = new Vector2(580, 270),
Draggable = true,
Resizable = false,
ShowCloseButton = false,
ShowOutline = true,
Padding = new RectOffset(16, 16, 16, 16)
});
// Vertical stack: description (takes the remaining height), buttons (40), progress bar (24), status (22),
// 14 units apart
var content = _window.Content;
var vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
vLayout.spacing = 14f;
vLayout.padding = new RectOffset(16, 16, 10, 10);
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
// 3. Description text: "required" line, then either the macOS build/website lines or the download/size lines
var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_required"));
if (Application.platform == RuntimePlatform.OSXPlayer)
{
descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_build"));
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_mac_open"));
}
else
{
descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_download"));
descStrBuilder.AppendLine(LocalizationManager.Get("ui.ffmpeg_download.desc_size"));
}
descText.text = descStrBuilder.ToString();
descText.fontSize = 16;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 10;
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100);
// 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
_buttonContainerObj.layer = 5;
var btnContainerRect = _buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5;
var downloadImg = downloadBtnObj.AddComponent<Image>();
downloadImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
downloadImg.raycastTarget = true;
var downloadBtn = downloadBtnObj.AddComponent<Button>();
SetupButtonStyle(downloadBtn, new Color(0.0f, 0.8f, 1.0f, 1.0f), new Color(0.2f, 0.9f, 1.0f, 1.0f), new Color(0.0f, 0.6f, 0.8f, 1.0f));
if (Application.platform != RuntimePlatform.OSXPlayer) downloadBtn.onClick.AddListener(StartDownloadProcess);
else
{
downloadBtn.onClick.AddListener(OpenFFmpegWebsite);
void OpenFFmpegWebsite() { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); }
}
descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 5;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
var downloadBtnRect = downloadBtnObj.GetComponent<RectTransform>();
downloadBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
downloadBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
downloadBtnRect.pivot = new Vector2(1f, 0.5f);
downloadBtnRect.anchoredPosition = new Vector2(-15, 0);
downloadBtnRect.sizeDelta = new Vector2(180, 45);
var downloadTextObj = new GameObject("DownloadText");
downloadTextObj.transform.SetParent(downloadBtnObj.transform, false);
downloadTextObj.layer = 5;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20;
downloadText.font = font;
downloadText.color = Color.white;
downloadText.alignment = TextAlignmentOptions.Center;
var downloadTextRect = downloadTextObj.GetComponent<RectTransform>();
downloadTextRect.anchorMin = Vector2.zero;
downloadTextRect.anchorMax = Vector2.one;
downloadTextRect.offsetMin = Vector2.zero;
downloadTextRect.offsetMax = Vector2.zero;
// 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton");
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
cancelBtnObj.layer = 5;
var cancelImg = cancelBtnObj.AddComponent<Image>();
cancelImg.color = new Color(0.2f, 0.2f, 0.23f, 1f);
cancelImg.raycastTarget = true;
var cancelBtn = cancelBtnObj.AddComponent<Button>();
SetupButtonStyle(cancelBtn, new Color(0.2f, 0.2f, 0.23f, 1f), new Color(0.25f, 0.25f, 0.28f, 1f), new Color(0.15f, 0.15f, 0.18f, 1f));
cancelBtn.onClick.AddListener(CancelProcess);
var cancelBtnRect = cancelBtnObj.GetComponent<RectTransform>();
cancelBtnRect.anchorMin = new Vector2(0.5f, 0.5f);
cancelBtnRect.anchorMax = new Vector2(0.5f, 0.5f);
cancelBtnRect.pivot = new Vector2(0f, 0.5f);
cancelBtnRect.anchoredPosition = new Vector2(15, 0);
cancelBtnRect.sizeDelta = new Vector2(180, 45);
var cancelTextObj = new GameObject("CancelText");
cancelTextObj.transform.SetParent(cancelBtnObj.transform, false);
cancelTextObj.layer = 5;
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
cancelText.text = "Cancel";
cancelText.fontSize = 20;
cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
cancelText.alignment = TextAlignmentOptions.Center;
var cancelTextRect = cancelTextObj.GetComponent<RectTransform>();
cancelTextRect.anchorMin = Vector2.zero;
cancelTextRect.anchorMax = Vector2.one;
cancelTextRect.offsetMin = Vector2.zero;
cancelTextRect.offsetMax = Vector2.zero;
// 9. Create progress bar container
var descLE = descText.gameObject.AddComponent<LayoutElement>();
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// 4. Button container: two equal-width 40-unit buttons, 10 units apart
_buttonContainerObj = new GameObject("ButtonContainer");
_buttonContainerObj.transform.SetParent(content, false);
_buttonContainerObj.layer = 5;
var btnContainerLE = _buttonContainerObj.AddComponent<LayoutElement>();
btnContainerLE.preferredHeight = 40f;
btnContainerLE.flexibleWidth = 1f;
var btnLayout = _buttonContainerObj.AddComponent<HorizontalLayoutGroup>();
btnLayout.spacing = 10f;
btnLayout.childAlignment = TextAnchor.MiddleCenter;
btnLayout.childControlWidth = true;
btnLayout.childControlHeight = true;
btnLayout.childForceExpandWidth = true;
btnLayout.childForceExpandHeight = true;
// Primary button: download and install (Windows/Linux), or open the FFmpeg website and close the dialog (macOS)
Action onDownloadClick = Application.platform != RuntimePlatform.OSXPlayer
? StartDownloadProcess
: () => { Application.OpenURL("https://ffmpeg.org"); Destroy(gameObject); };
var downloadBtn = O5Factory.Button(
ctx: ctx,
parent: _buttonContainerObj.transform,
onClick: onDownloadClick,
text: LocalizationManager.Get(Application.platform != RuntimePlatform.OSXPlayer ? "ui.ffmpeg_download.btn_download" : "ui.ffmpeg_download.btn_open_site"),
id: "btn_ffmpeg_download",
height: 40f
);
downloadBtn.NormalColor = ctx.Theme.ObjectActiveBright;
downloadBtn.UpdateVisual();
var downloadBtnLE = downloadBtn.Rect.gameObject.AddComponent<LayoutElement>();
downloadBtnLE.flexibleWidth = 1f;
downloadBtnLE.preferredHeight = 40f;
var cancelBtn = O5Factory.Button(
ctx: ctx,
parent: _buttonContainerObj.transform,
onClick: CancelProcess,
text: LocalizationManager.Get("ui.ffmpeg_download.btn_cancel"),
id: "btn_ffmpeg_cancel",
height: 40f
);
var cancelBtnLE = cancelBtn.Rect.gameObject.AddComponent<LayoutElement>();
cancelBtnLE.flexibleWidth = 1f;
cancelBtnLE.preferredHeight = 40f;
// 5. Progress bar container (hidden until the download starts): rounded background in the theme object color
// with a rounded outline drawn over it
_progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false);
_progressBarObj.transform.SetParent(content, false);
_progressBarObj.layer = 5;
_progressBarObj.SetActive(false);
var pbLE = _progressBarObj.AddComponent<LayoutElement>();
pbLE.preferredHeight = 24f;
pbLE.flexibleWidth = 1f;
var barBgImg = _progressBarObj.AddComponent<Image>();
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
var barBgOutline = _progressBarObj.AddComponent<Outline>();
barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0);
barRect.anchorMax = new Vector2(0.5f, 0);
barRect.pivot = new Vector2(0.5f, 0);
barRect.anchoredPosition = new Vector2(0, 75);
barRect.sizeDelta = new Vector2(480, 24);
// 10. Create progress bar fill image
barBgImg.sprite = ctx.Sprites.RoundedControl;
barBgImg.type = Image.Type.Sliced;
barBgImg.color = ctx.Theme.ObjectBG;
var barOutlineObj = new GameObject("BarOutline");
barOutlineObj.transform.SetParent(_progressBarObj.transform, false);
barOutlineObj.layer = 5;
var barOutlineImg = barOutlineObj.AddComponent<Image>();
barOutlineImg.sprite = ctx.Sprites.RoundedOutline;
barOutlineImg.type = Image.Type.Sliced;
barOutlineImg.color = ctx.Theme.ControlOutlineIdle;
barOutlineImg.raycastTarget = false;
var barOutlineRect = barOutlineObj.GetComponent<RectTransform>();
barOutlineRect.anchorMin = Vector2.zero;
barOutlineRect.anchorMax = Vector2.one;
barOutlineRect.offsetMin = Vector2.zero;
barOutlineRect.offsetMax = Vector2.zero;
// Progress bar fill image: anchored at the left edge with zero size offset, so its width is set entirely by
// anchorMax.x (0 = empty, 1 = full) in UpdateProgress
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>();
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
fillImg.sprite = ctx.Sprites.RoundedControl;
fillImg.type = Image.Type.Sliced;
fillImg.color = ctx.Theme.ObjectActiveBright;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero;
// 11. Create progress text
// Progress text: 13 pt white, centered over the bar
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16;
_progressText.font = font;
_progressText.fontSize = 13;
_progressText.font = ctx.Fonts.Regular;
_progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center;
var progressTextOutline = progressTextObj.AddComponent<Outline>();
progressTextOutline.effectColor = new Color(0, 0, 0, 0.8f);
progressTextOutline.effectDistance = new Vector2(1, -1);
_progressText.raycastTarget = false;
var progressTextRect = progressTextObj.GetComponent<RectTransform>();
progressTextRect.anchorMin = Vector2.zero;
progressTextRect.anchorMax = Vector2.one;
progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero;
// 12. Create status text
// 6. Status text (hidden until the download starts): 14 pt, centered
_statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false);
_statusTextObj.transform.SetParent(content, false);
_statusTextObj.layer = 5;
_statusTextObj.SetActive(false);
var statusLE = _statusTextObj.AddComponent<LayoutElement>();
statusLE.preferredHeight = 22f;
statusLE.flexibleWidth = 1f;
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16;
_statusText.font = font;
_statusText.fontSize = 14;
_statusText.font = ctx.Fonts.Regular;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center;
_statusText.raycastTarget = false;
var statusRect = _statusTextObj.GetComponent<RectTransform>();
statusRect.anchorMin = new Vector2(0, 0);
statusRect.anchorMax = new Vector2(1, 0);
statusRect.pivot = new Vector2(0.5f, 0);
statusRect.anchoredPosition = new Vector2(0, 35);
statusRect.sizeDelta = new Vector2(0, 30);
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
statusRect.anchorMin = Vector2.zero;
statusRect.anchorMax = Vector2.one;
statusRect.offsetMin = Vector2.zero;
statusRect.offsetMax = Vector2.zero;
}
/// <summary>Runs every action posted by the background download task, in posting order.</summary>
private void Update()
{
// Execute queued actions on main thread
// Execute queued actions on main Unity thread
while (_mainThreadQueue.TryDequeue(out var action))
{
action?.Invoke();
}
}
/// <summary>
/// Posts an action to run on the main Unity thread during the next <see cref="Update"/>. Safe to call from any thread.
/// Actions posted after the dialog is destroyed are never run.
/// </summary>
private void ExecuteOnMainThread(Action action)
{
_mainThreadQueue.Enqueue(action);
}
/// <summary>
/// Download button handler (Windows/Linux): switches the dialog to progress mode and starts the background task
/// that downloads the archive, extracts the libraries and closes the dialog. Ignored while a download runs.
/// </summary>
private void StartDownloadProcess()
{
if (_isDownloading) return;
@@ -435,13 +408,15 @@ internal class FFmpegDownloadUI : MonoBehaviour
_cts = new System.Threading.CancellationTokenSource();
// Start download in background thread
// Start download in background thread. Everything inside runs on thread-pool threads: Unity objects are only
// touched through UpdateStatus/UpdateProgress/ExecuteOnMainThread. The task is not awaited; its outcome is
// reported through the status text.
Task.Run(async () =>
{
string tempZipPath = "";
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%");
var userLibsDir = MelonEnvironment.UserLibsDirectory;
@@ -450,6 +425,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
Directory.CreateDirectory(userLibsDir);
}
// Temporary archive path in UserLibs: ffmpeg_temp.zip (Windows, macOS) or ffmpeg_temp.tar.xz (others).
// It is deleted after extraction (ExtractDllsAsync) or on cancellation.
string extension = Application.platform switch
{
RuntimePlatform.WindowsPlayer => ".zip",
@@ -458,27 +435,30 @@ internal class FFmpegDownloadUI : MonoBehaviour
};
tempZipPath = Path.Combine(userLibsDir, "ffmpeg_temp" + extension);
// percent is the downloaded fraction 0..1. Progress<T> created on a thread-pool thread (no
// SynchronizationContext) invokes this callback on thread-pool threads; it only posts UI updates.
var progressReporter = new Progress<float>(percent =>
{
UpdateProgress(percent, $"{percent * 100f:F1}%");
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);
// Extract after download complete
UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
UpdateProgress(0.99f, "Extracting...");
// Extract after download complete; the bar is held at 99% while extracting
UpdateStatus(LocalizationManager.Get("ui.ffmpeg_download.status_extracting"), new Color(0.9f, 0.9f, 0.2f));
UpdateProgress(0.99f, LocalizationManager.Get("ui.ffmpeg_download.progress_extracting"));
await ExtractDllsAsync(tempZipPath, userLibsDir);
// 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%");
// Keep the "complete" message visible for 1000 ms before closing
await Task.Delay(1000);
// Destroy UI on main thread
// Destroy UI on main Unity thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
ExecuteOnMainThread(() =>
{
Destroy(gameObject);
@@ -486,7 +466,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
catch (OperationCanceledException)
{
// Clean up temporary file on cancellation
// Clean up temporary file on cancellation. Cancellation comes from CancelProcess, which has already
// destroyed the dialog, so the status update below is posted but never shown.
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
{
try { File.Delete(tempZipPath); }
@@ -495,14 +476,16 @@ internal class FFmpegDownloadUI : MonoBehaviour
// 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)
{
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(() =>
{
_isDownloading = false;
@@ -510,13 +493,17 @@ internal class FFmpegDownloadUI : MonoBehaviour
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
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()
{
if (_isDownloading)
@@ -526,6 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
Destroy(gameObject);
}
/// <summary>Posts a status text and color change to the main Unity thread. Callable from any thread.</summary>
private void UpdateStatus(string message, Color color)
{
ExecuteOnMainThread(() =>
@@ -538,6 +526,9 @@ internal class FFmpegDownloadUI : MonoBehaviour
});
}
/// <summary>Posts a progress bar update to the main Unity thread. Callable from any thread.</summary>
/// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param>
/// <param name="text">Text shown on the bar.</param>
private void UpdateProgress(float percent, string text)
{
ExecuteOnMainThread(() =>
@@ -553,15 +544,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
});
}
/// <summary>
/// Streams the HTTP response body of <paramref name="url"/> to <paramref name="destinationPath"/> (created or
/// overwritten) in 8 KiB chunks. Runs on a thread-pool thread.
/// </summary>
/// <param name="url">Archive URL.</param>
/// <param name="destinationPath">Output file path.</param>
/// <param name="progress">
/// Receives the downloaded fraction 0..1 after every chunk; not reported when the server sends no Content-Length.
/// </param>
/// <param name="cancellationToken">Cancels the request and the copy loop (<see cref="OperationCanceledException"/>).</param>
/// <exception cref="HttpRequestException">The server returned a non-success status code.</exception>
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
{
using var client = new HttpClient();
// Add User-Agent header to prevent blocking
// Add User-Agent header to prevent blocking by servers that reject requests without one
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
// ResponseHeadersRead returns after the headers, so the body is streamed instead of buffered in memory
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
// Total size from Content-Length, or -1 when unknown. File stream: 8192-byte buffer, asynchronous I/O.
var totalBytes = response.Content.Headers.ContentLength ?? -1L;
await using var contentStream = await response.Content.ReadAsStreamAsync();
await using var fileStream = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None, 8192, true);
@@ -582,12 +586,28 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
/// <summary>
/// Extracts the shared libraries from the downloaded archive into <paramref name="outputDirectory"/>, overwriting
/// files of the same name, and deletes the archive afterwards (also on failure; a failed delete is logged as a
/// warning). Runs on thread-pool threads and is not cancellable.
/// <list type="bullet">
/// <item>Windows/macOS (.zip): every entry ending in .dll (Windows) or .dylib (macOS) is extracted flat, by its file
/// name only, ignoring its folder inside the archive.</item>
/// <item>Other platforms (.tar.xz): the archive is unpacked with the system <c>tar</c> command into the temporary
/// folder "ffmpeg_temp_extracted" in <paramref name="outputDirectory"/>; every file matching <c>*.so*</c> in any
/// subfolder is copied flat to <paramref name="outputDirectory"/>, then the temporary folder is deleted.</item>
/// </list>
/// </summary>
/// <param name="zipPath">Downloaded archive path (.zip or .tar.xz despite the parameter name).</param>
/// <param name="outputDirectory">UserLibs folder.</param>
/// <exception cref="Exception"><c>tar</c> exited with a non-zero code (message includes its standard error).</exception>
private async Task ExtractDllsAsync(string zipPath, string outputDirectory)
{
try
{
if (Application.platform == RuntimePlatform.OSXPlayer || Application.platform == RuntimePlatform.WindowsPlayer)
{
// .zip: extract matching library entries flat into outputDirectory
await Task.Run(() =>
{
var targetExtension = Application.platform == RuntimePlatform.OSXPlayer ? ".dylib" : ".dll";
@@ -621,6 +641,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
Directory.CreateDirectory(tempExtractDir);
// tar receives forward-slash paths, quoted for spaces: tar -xf "<archive>" -C "<temp dir>"
var archiveUnixPath = zipPath.Replace('\\', '/');
var tempExtractUnixDir = tempExtractDir.Replace('\\', '/');
@@ -644,7 +665,8 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
// Find .so files and copy to outputDirectory
// Find .so files (including versioned names such as libavcodec.so.<major>) and copy them flat to
// outputDirectory
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
foreach (var soFile in soFiles)
{
@@ -681,8 +703,14 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
/// <summary>
/// Disposes the window and the cancellation source and clears <see cref="Instance"/>. A download that was not
/// cancelled keeps running in the background; its UI updates are posted but no longer drained.
/// </summary>
private void OnDestroy()
{
_window?.Dispose();
_window = null;
_cts?.Dispose();
if (Instance == this)
{
+146
View File
@@ -0,0 +1,146 @@
using System;
using ADOFAIRenderer.Localization;
using TMPro;
using UnityEngine;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Component that binds a TextMeshProUGUI component to a localization key or dynamic string provider,
/// automatically updating the displayed text whenever the active language changes.
/// <para>
/// The text is set when the binding is made (<see cref="Bind(TextMeshProUGUI, string)"/>), when the component is enabled,
/// and on every <see cref="LocalizationManager.OnLanguageChanged"/> event. A key and a dynamic provider are mutually
/// exclusive: setting one clears the other. With neither set, <see cref="Refresh"/> leaves the text unchanged.
/// Runs on the main Unity thread.
/// </para>
/// </summary>
internal class LocalizedText : MonoBehaviour
{
/// <summary>Text component that receives the string; looked up on the same GameObject if not assigned by Bind.</summary>
private TextMeshProUGUI? _textComponent;
/// <summary>Localization key resolved through <see cref="LocalizationManager.Get(string)"/>; <c>null</c> when a provider is used.</summary>
private string? _key;
/// <summary>Function that returns the full text (already localized/formatted); <c>null</c> when a key is used.</summary>
private Func<string>? _dynamicProvider;
/// <summary>
/// Gets or sets the localization key. Setting this will clear any dynamic provider and refresh the text immediately.
/// A null or empty key leaves the current text as it is.
/// </summary>
public string? Key
{
get => _key;
set
{
_key = value;
_dynamicProvider = null;
Refresh();
}
}
/// <summary>
/// Sets a dynamic string provider function, clears the key and refreshes the text immediately.
/// The provider is called on every refresh (enable and language change), so it can combine several keys or
/// runtime values, e.g. a label that depends on UI state.
/// </summary>
/// <param name="provider">Function returning the text to display; called on the main Unity thread.</param>
public void SetDynamic(Func<string> provider)
{
_dynamicProvider = provider;
_key = null;
Refresh();
}
/// <summary>
/// Finds the text component and subscribes to language changes. The subscription lasts until <see cref="OnDestroy"/>,
/// also while the component is disabled (a refresh of a disabled object only changes its text).
/// </summary>
private void Awake()
{
EnsureComponent();
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
}
/// <summary>Refreshes the text each time the component is enabled.</summary>
private void OnEnable()
{
Refresh();
}
/// <summary>Unsubscribes from <see cref="LocalizationManager.OnLanguageChanged"/>.</summary>
private void OnDestroy()
{
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
}
/// <summary>Language change handler; the new language is read by <see cref="LocalizationManager.Get(string)"/>.</summary>
private void HandleLanguageChanged(SystemLanguage language)
{
Refresh();
}
/// <summary>Assigns <see cref="_textComponent"/> from the same GameObject if it is not set yet.</summary>
private void EnsureComponent()
{
if (_textComponent == null)
{
_textComponent = GetComponent<TextMeshProUGUI>();
}
}
/// <summary>
/// Refreshes the text using either the dynamic provider or the static localization key.
/// The provider takes precedence; with neither set, or without a text component, nothing changes.
/// A key that is missing in every locale is displayed as the key itself (see <see cref="LocalizationManager.Get(string)"/>).
/// </summary>
public void Refresh()
{
EnsureComponent();
if (_textComponent == null) return;
if (_dynamicProvider != null)
{
_textComponent.text = _dynamicProvider();
}
else if (!string.IsNullOrEmpty(_key))
{
_textComponent.text = LocalizationManager.Get(_key);
}
}
/// <summary>
/// Binds a TextMeshProUGUI component to a localization key.
/// Reuses the <see cref="LocalizedText"/> already on the target's GameObject or adds one, then sets the key,
/// which replaces any earlier binding and updates the text immediately.
/// </summary>
/// <param name="target">Text to localize.</param>
/// <param name="key">Localization key, e.g. <c>"ui.render_config.codec"</c>.</param>
/// <returns>The binding component.</returns>
/// <exception cref="ArgumentNullException"><paramref name="target"/> is null.</exception>
public static LocalizedText Bind(TextMeshProUGUI target, string key)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.Key = key;
return component;
}
/// <summary>
/// Binds a TextMeshProUGUI component to a dynamic string provider function.
/// Reuses or adds the component like <see cref="Bind(TextMeshProUGUI, string)"/> and updates the text immediately.
/// </summary>
/// <param name="target">Text to localize.</param>
/// <param name="provider">Function returning the text; called on every refresh.</param>
/// <returns>The binding component.</returns>
/// <exception cref="ArgumentNullException"><paramref name="target"/> is null.</exception>
public static LocalizedText Bind(TextMeshProUGUI target, Func<string> provider)
{
if (target == null) throw new ArgumentNullException(nameof(target));
var component = target.GetComponent<LocalizedText>() ?? target.gameObject.AddComponent<LocalizedText>();
component._textComponent = target;
component.SetDynamic(provider);
return component;
}
}
File diff suppressed because it is too large. Load diff
+178
View File
@@ -0,0 +1,178 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util;
using MelonLoader;
using O5Kit.Core;
using TMPro;
namespace ADOFAIRenderer.UI;
using UnityEngine;
/// <summary>
/// Assets loaded from the mod's embedded asset bundles (fonts and the color conversion compute shader).
/// <para>
/// <see cref="Load"/> is called once from <c>RenderMod.OnInitializeMelon</c>; the fields stay <c>null</c> until then.
/// Bundles are obtained from <c>BundleLoader.GetBundle</c> by name ("fonts", "shaders"). Main thread only.
/// </para>
/// </summary>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal static class ModAssets
{
/// <summary>
/// Loads the font and shader bundles and the assets inside them, logging after each bundle.
/// Exceptions from bundle or asset loading are not caught here and propagate to the caller.
/// </summary>
public static void Load()
{
FontBundle = BundleLoader.GetBundle("fonts");
NanumSquareNeoNormal = FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
NanumSquareNeoBold = FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Eb SDF");
Melon<RenderMod>.Logger.Msg("Fonts loaded successfully.");
ShaderBundle = BundleLoader.GetBundle("shaders");
ColorConversionShader = ShaderBundle.LoadAsset<ComputeShader>("ColorConversion");
Melon<RenderMod>.Logger.Msg("Shader loaded successfully.");
}
/// <summary>Asset bundle "fonts" holding the TextMeshPro font assets.</summary>
public static AssetBundle FontBundle = null!;
/// <summary>Asset bundle "shaders" holding the compute shaders.</summary>
public static AssetBundle ShaderBundle = null!;
/// <summary>
/// NanumSquare Neo, asset "NanumSquareNeoOTF-Bd SDF" (Bd = Bold weight). Used as the regular UI font of the mod
/// (O5Kit <see cref="RendererFontProvider.Regular"/>, DebugMonitorUI text).
/// </summary>
public static TMP_FontAsset NanumSquareNeoNormal = null!;
/// <summary>
/// NanumSquare Neo, asset "NanumSquareNeoOTF-Eb SDF" (Eb = ExtraBold weight). Used as the O5Kit medium font
/// (<see cref="RendererFontProvider.Medium"/>).
/// </summary>
public static TMP_FontAsset NanumSquareNeoBold = null!;
/// <summary>
/// Compute shader "ColorConversion" that converts captured frames to the encoder's YUV pixel format on the GPU
/// (used by FrameReader).
/// </summary>
public static ComputeShader ColorConversionShader = null!;
}
/// <summary>
/// O5Kit font provider for the mod's UI: the bundled NanumSquare Neo fonts for regular and medium text, and O5Kit's
/// default monospace font.
/// </summary>
internal sealed class RendererFontProvider : IFontProvider
{
/// <summary>O5Kit default provider, used only for <see cref="Monospace"/>.</summary>
private static readonly DefaultFontProvider DefaultFontProvider = new();
private bool _disposed;
/// <summary>Regular text font: <see cref="ModAssets.NanumSquareNeoNormal"/>.</summary>
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
/// <summary>Medium (emphasis) text font: <see cref="ModAssets.NanumSquareNeoBold"/>.</summary>
public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold;
/// <summary>Monospace font (e.g. hex color input): O5Kit's default monospace font.</summary>
public TMP_FontAsset Monospace => DefaultFontProvider.Monospace;
/// <summary>
/// Marks the provider disposed. The font assets belong to <see cref="ModAssets"/> and are not unloaded.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}
/// <summary>
/// Owner of the mod's O5Kit UI: the shared <see cref="O5Context"/> (config, theme, fonts) and the window manager
/// with its canvas.
/// <para>
/// Added once by <c>RenderMod.OnInitializeMelon</c> to a GameObject kept across scene loads. Other UI code reads
/// <see cref="Context"/> and <see cref="WindowManager"/> through the static <see cref="Instance"/>.
/// <see cref="Update"/> drives O5Kit's per-frame work (object ticks, tooltip, keyboard shortcuts). Main thread only.
/// </para>
/// </summary>
internal class RendererUIManager : MonoBehaviour
{
/// <summary>The single instance, assigned in <see cref="Awake"/>.</summary>
public static RendererUIManager Instance = null!;
/// <summary>Shared O5Kit context used to build every mod window and dialog.</summary>
public static O5Context Context => Instance._context;
/// <summary>Window manager whose canvas holds the mod windows (e.g. RenderConfigWindow).</summary>
public O5WindowManager WindowManager => _windowManager;
private O5Context _context = null!;
private O5WindowManager _windowManager = null!;
/// <summary>
/// Creates the O5Kit context with the mod theme and fonts, then the window manager and its canvas
/// ("RendererMod_Canvas"), and lowers that canvas's sorting order to <see cref="WindowCanvasSortingOrder"/>.
/// </summary>
private void Awake() {
Instance = this;
// O5Kit config: UI scale 1.0, middle click resets a control to its default value, slider drag sensitivity 1.0.
// Theme: O5Kit's dark theme with the mod colors. Colors are RGBA 0..1; the accent color
// (1.000, 0.294, 0.079) is orange-red (#FF4B14 approx.). Sizes are canvas units: corner radius 12,
// outline width 2, control height 40, body font 18, H1 font 48.
_context = new O5Context(
config: new O5Config {
UIScale = 1.0f,
MiddleClickToDefault = true,
SliderSensitivity = 1.0f
},
theme: O5Theme.Dark with
{
PanelBG = new Color(0.067f, 0.067f, 0.067f, 1f),
TopBar = new Color(0f, 0f, 0f, 0.6f),
MenuBG = new Color(0f, 0f, 0f, 0.6f),
MenuHover = new Color(1.000f, 0.294f, 0.079f, .6f),
ObjectBG = new Color(0f, 0f, 0f, 1f),
ObjectButton = new Color(0f, 0f, 0f, 1f),
ObjectActive = new Color(1.000f, 0.294f, 0.079f, 1f),
ObjectActiveBright = new Color(1.000f, 0.294f, 0.079f, 1f),
ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f),
ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f),
ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f),
CardHeader = new Color(0.157f, 0f, 0f, 1f),
CardPanel = new Color(0, 0, 0, 0f),
SoftRed = new Color(1.000f, 0.576f, 0.590f, 1f),
MathOk = new Color(0.408f, 0.827f, 0.569f, 1f),
MathWarn = new Color(0.964f, 0.647f, 0.315f, 1f),
MathErr = new Color(1.000f, 0.576f, 0.590f, 1f),
CornerRadius = 12, OutlineWidth = 2, ControlHeight = 40, FontSizeBody = 18, FontSizeH1 = 48,
Outline = new Color(1f, 1f, 1f, 0.2f),
TextDimAlpha = 1f
},
fonts: new RendererFontProvider()
);
_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() {
O5Object.TickAll();
Context.Tooltip.Tick();
O5Kit.Input.O5ShortcutManager.HandleUpdate();
}
/// <summary>Disposes the window manager, then the context.</summary>
private void OnDestroy() {
_windowManager.Dispose();
_context.Dispose();
}
}
File diff suppressed because it is too large. Load diff
-100
View File
@@ -1,100 +0,0 @@
using System;
namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{
public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
{
// ── Safety Validation ──
if (inputSamples.Length == 0 ||
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0)
{
return Array.Empty<float>();
}
// If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
{
double srcIndex = (double)i * inputFrequency / targetFrequency;
long idxLow = (long)Math.Floor(srcIndex);
long idxHigh = idxLow + 1;
float t = (float)(srcIndex - idxLow);
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
if (idxLow >= inputLength) idxLow = inputLength - 1;
if (targetChannels == 1) // 1. Mono Output
{
float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono
{
valLow = inputSamples[idxLow];
valHigh = inputSamples[idxHigh];
}
else // Multi -> Mono (Downmix: Average all channels)
{
float sumLow = 0f;
float sumHigh = 0f;
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
}
else if (targetChannels == 2) // 2. Stereo Output
{
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
{
float valLow = inputSamples[idxLow];
float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
float valLowR = inputSamples[idxLow * inputChannels + 1];
float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
}
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{
float valLow, valHigh;
if (c < inputChannels)
{
valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh * inputChannels + c];
}
else
{
valLow = inputSamples[idxLow * inputChannels];
valHigh = inputSamples[idxHigh * inputChannels];
}
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
}
}
}
return outputSamples;
}
}
+168 -107
View File
@@ -1,164 +1,223 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util.Audio.Clip;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Replacement for AudioSource.GetSpectrumData during rendering: the spectrum of the song as the song AudioSource would output it.
///
/// Signal: the song clip from an <see cref="AudioClipStore"/> at the output sample rate (AudioSettings.outputSampleRate),
/// played at conductor.song.pitch (linear interpolation between frames) and scaled by conductor.song.volume.
/// Unity analyzes the output signal of the source: bin spacing follows the output rate, pitch moves the frequencies,
/// and volume scales the magnitudes (measured with a Unity 6000.3.10f1 test project).
/// Channel: stereo clips use the requested channel (0 = left, 1 = right); a mono clip is scaled by <see cref="MonoGain"/>.
/// Window: twice the requested spectrum length in output samples, centered on the current render time.
///
/// The song source is shared with the <see cref="OfflineAudioMixer"/> store when its output rate matches; otherwise a separate
/// store is created. A cursor at the analysis position keeps the needed chunks loaded. Main thread only.
/// </summary>
internal static class AudioSpectrumGenerator
{
// ReSharper disable MemberCanBePrivate.Global
public static float[]? AudioClipData { get; private set; }
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
// ReSharper restore MemberCanBePrivate.Global
/// <summary>
/// Gain of a mono clip relative to one channel of a stereo clip with the same samples.
/// Measured with a Unity 6000.3.10f1 test project: the spectrum of a mono clip is about 0.70 times that of each stereo channel (1/√2).
/// </summary>
private const double MonoGain = 0.70710678118654752;
/// <summary>Store created by this class when the mixer store cannot be shared (disposed by <see cref="Clear"/>); otherwise null.</summary>
private static AudioClipStore? _ownStore;
/// <summary>Store the song source belongs to: the mixer store or <see cref="_ownStore"/>.</summary>
private static AudioClipStore? _store;
/// <summary>Song source at the output sample rate, or null when not initialized or the song could not be loaded.</summary>
private static AudioClipSource? _source;
/// <summary>Cursor on <see cref="_source"/> moved to each analysis window, so the decoder thread loads the chunks there.</summary>
private static AudioCursor? _cursor;
/// <summary>Chunk currently held for <see cref="ReadSample"/> (invalid when none).</summary>
private static ChunkView _view;
/// <summary>True after a chunk loading error was logged (logged once per render).</summary>
private static bool _loadErrorLogged;
/// <summary>RenderManager.frameCount of the spectra in <see cref="CachedSpectrums"/>; -1 when empty.</summary>
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
/// <summary>
/// Spectra computed in the current render frame, by spectrum length and source channel. Several game objects read the
/// spectrum every frame; within one frame the render time does not change, so they receive the same values.
/// </summary>
private static readonly Dictionary<(int Size, int Channel), float[]> CachedSpectrums = new();
/// <summary>Registers the song clip. Call after the <see cref="OfflineAudioMixer"/> is created (null when audio is not rendered).</summary>
public static void Initialize(AudioClip? clip, OfflineAudioMixer? mixer)
{
if (!clip)
{
Clear();
return;
}
Clear();
if (!clip) return;
try
{
Channels = clip.channels;
Frequency = clip.frequency;
int samplesCount = clip.samples;
float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
var rate = AudioSettings.outputSampleRate;
if (mixer != null && mixer.Store.OutputRate == rate)
{
AudioClipData = rawSamples;
TotalSamples = samplesCount;
_store = mixer.Store;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
_ownStore = new AudioClipStore(rate);
_ownStore.Start();
_store = _ownStore;
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
_source = AudioClipResolver.GetOrAdd(_store, clip!.name, clip);
if (_source == null)
{
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song clip '{clip.name}' could not be loaded, the spectrum will be empty.");
return;
}
_cursor = _source.AddCursor(0, false);
Melon<RenderMod>.Logger.Msg($"[AudioSpectrumGenerator] Song '{clip.name}': {_source.Channels} ch at {rate} Hz ({(_ownStore != null ? "own store" : "shared with the mixer")})");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Failed to initialize: {e}");
Clear();
}
}
/// <summary>Releases the chunk, the cursor and the own store. Call before the mixer store is disposed.</summary>
public static void Clear()
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
if (_source != null)
{
_cachedFrame = -1;
CachedSpectrums.Clear();
_source.Release(_view);
if (_cursor != null) _source.RemoveCursor(_cursor);
}
_view = default;
_cursor = null;
_source = null;
_store = null;
_ownStore?.Dispose();
_ownStore = null;
_loadErrorLogged = false;
_cachedFrame = -1;
CachedSpectrums.Clear();
}
/// <summary>
/// Fills <paramref name="samples"/> with the magnitude spectrum of the song at the current render time
/// (called by the AudioSource.GetSpectrumData patch while capturing with ProcessAudio on, for any AudioSource).
/// Bin k covers frequency k * outputRate / (2 * samples.Length) Hz in output time; values are 0 ~ 1.
/// Zeros are written when the song is not available, the conductor has not started the song, or the length is not a power of two.
/// <paramref name="window"/> is not read: the Blackman-Harris window is always used (every GetSpectrumData call in the game
/// passes FFTWindow.BlackmanHarris). Main thread only.
/// </summary>
/// <param name="samples">Destination; its length is the spectrum length (power of two).</param>
/// <param name="channel">Requested channel (0 = left, 1 = right), clamped to the song's channels; ignored for mono songs.</param>
/// <param name="window">Requested window (not used).</param>
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
var conductor = scrConductor.instance;
if (_source == null || _store == null || !conductor || !conductor!.hasSongStarted || !conductor.song
|| n == 0 || (n & (n - 1)) != 0)
{
Array.Clear(samples, 0, samples.Length);
Array.Clear(samples, 0, n);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
var render = RenderManager.Instance;
var currentFrame = render.frameCount;
if (currentFrame != _cachedFrame)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Copy(cached, samples, n);
return;
}
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
var sourceChannel = _source.Channels == 1 ? 0 : Math.Min(Math.Max(channel, 0), _source.Channels - 1);
if (CachedSpectrums.TryGetValue((n, sourceChannel), out var cached))
{
Array.Copy(cached, samples, n);
return;
}
// Song position (seconds of song time) at the current render time, as in scrConductor.StartMusicCo:
// the song starts at dspTimeSong + separate countdown (crotchetAtStart * adjustedCountdownTicks in song time).
double pitch = conductor.song.pitch;
double volume = conductor.song.volume;
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
var now = render.dspTimeBase + render.AudioTime;
var songSeconds = (now - conductor.dspTimeSong) * pitch - countdown;
// Window of 2n output samples centered on the current time: output sample i reads source frame first + i * pitch,
// so the window starts n output samples (n * pitch source frames) before the song position.
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var gain = volume * (_source.Channels == 1 ? MonoGain : 1.0);
var first = songSeconds * _store.OutputRate - n * pitch;
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
try
{
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
// Keep the cursor at the window start so the decoder thread loads (and keeps) the chunks the window reads.
_cursor!.Position = Math.Min(Math.Max((long)first, 0), _source.TotalFrames - 1);
for (var i = 0; i < fftSize; i++)
{
windowSamples[i] = AudioClipData[idx];
var position = first + i * pitch;
var index = (long)Math.Floor(position);
var fraction = position - index;
var s0 = ReadSample(index, sourceChannel);
var s1 = fraction > 0 ? ReadSample(index + 1, sourceChannel) : 0.0;
windowSamples[i] = (float)((s0 + (s1 - s0) * fraction) * gain);
}
else
}
catch (InvalidOperationException e)
{
if (!_loadErrorLogged)
{
windowSamples[i] = 0f;
_loadErrorLogged = true;
Melon<RenderMod>.Logger.Error($"[AudioSpectrumGenerator] Song samples are unavailable, the spectrum will be empty: {e.Message}");
}
Array.Clear(samples, 0, n);
return;
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
CachedSpectrums[(n, sourceChannel)] = spectrum;
Array.Copy(spectrum, samples, n);
}
/// <summary>Sample of source channel <paramref name="channel"/> at <paramref name="frame"/>; 0 outside the clip or when the source failed.</summary>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
private static double ReadSample(long frame, int channel)
{
var source = _source!;
if (frame < 0 || frame >= source.TotalFrames) return 0.0;
if (!_view.IsValid || !_view.Contains(frame))
{
source.Release(_view);
_view = default;
_view = _store!.Acquire(source, frame);
if (!_view.IsValid) return 0.0;
}
return _view.Data[(int)(frame - _view.StartFrame) * _view.Channels + channel];
}
/// <summary>
/// Magnitude spectrum of <paramref name="samples"/> (length n, a power of two) after the Blackman-Harris window:
/// writes min(n / 2, spectrum.Length) bins, bin k = min(|X[k]| * 2.5 / n, 1).
/// </summary>
private static void CalculateFFT(float[] samples, float[] spectrum)
{
var n = samples.Length;
// Apply Blackman-Harris window
// Blackman-Harris window, periodic form: w(i) = a0 - a1 cos(2πi/n) + a2 cos(4πi/n) - a3 cos(6πi/n), i = 0 ~ n - 1
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
@@ -169,16 +228,16 @@ internal static class AudioSpectrumGenerator
for (var i = 0; i < n; i++)
{
var angle = 2.0 * Math.PI * i / (n - 1);
var angle = 2.0 * Math.PI * i / n;
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
}
// Cooley-Tukey Radix-2 FFT
// Cooley-Tukey Radix-2 FFT (iterative, in place, decimation in time); m = log2(n) stages.
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
// Bit-reversal: reorder the input so element i moves to the index with the bits of i reversed (j tracks that index).
var j = 0;
for (var i = 0; i < n - 1; i++)
{
@@ -201,7 +260,8 @@ internal static class AudioSpectrumGenerator
j += k;
}
// FFT stages
// FFT stages: stage s combines pairs of transforms of length le/2 into length le = 2^s with butterflies.
// (ur, ui) is the twiddle factor exp(-i * pi * sub / le2), advanced by multiplying with (wr, wi) = exp(-i * pi / le2).
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
@@ -230,12 +290,13 @@ internal static class AudioSpectrumGenerator
}
}
// Normalize and fill spectrum
var factor = 2.0f / n;
// Magnitude scale 2.5 / n (n = FFT size, twice the spectrum length), clamped to 1.0: the scale of Unity GetSpectrumData.
// A sine of amplitude A centered on a bin gives A * a0 * 1.25 with the Blackman-Harris window.
var factor = 2.5f / n;
var outLen = Math.Min(spectrum.Length, n / 2);
for (var i = 0; i < outLen; i++)
{
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
spectrum[i] = Math.Min((float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor, 1f);
}
}
}
}
+21 -5
View File
@@ -2,34 +2,50 @@ using System;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Splits the audio sample stream into per-video-frame sample counts so that audio and video stay aligned without drift.
///
/// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>).
/// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples:
/// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact
/// sample count of that duration rounded down (for example 44100 Hz at 60 FPS gives 735 every frame; at 144 FPS three frames of 306
/// are followed by one of 307, 306.25 on average).
/// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder).
/// </summary>
internal class AudioSyncCalculator
{
/// <summary>Audio sample rate in Hz (samples per channel per second).</summary>
private readonly long _sampleRate;
/// <summary>Video frame rate as a fraction (frames per second = Numerator / Denominator).</summary>
private readonly (long Numerator, long Denominator) _fpsRational;
/// <summary>Total samples handed out since construction or <see cref="Reset"/> (kept for debugging, not read).</summary>
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
/// <summary>Remainder of the last division, in units of 1 / Numerator samples (0 ~ Numerator - 1).</summary>
private long _remainder;
/// <param name="sampleRate">Audio sample rate in Hz.</param>
/// <param name="fps">Video frame rate in frames per second (the encoded frame rate).</param>
public AudioSyncCalculator(long sampleRate, double fps)
{
_sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
// 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()
{
_currentSample = 0;
_remainder = 0;
}
/// <summary>Returns the sample count (frames per channel) of the next video frame and advances to the following frame.</summary>
public int GetNextFrameSamples()
{
// samplesNeeded = floor((sampleRate * Denominator + remainder) / Numerator); the new remainder carries the fraction.
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
+47
View File
@@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// File path of every external audio clip the game loads, keyed by the game's audioLib key.
///
/// The game loads external clips with AudioManager.FindOrLoadAudioClipExternal(path, ...) and stores them in audioLib under
/// Path.GetFileName(path) + "*external" (the same string becomes AudioClip.name). Recorded paths:
/// - Song: Path.Combine(Path.GetDirectoryName(levelPath), levelData.songFilename) (scnGame song loading).
/// - PlaySound event sound: Path.Combine(Path.GetDirectoryName(levelPath), hitsound) (scnGame.LoadSoundCo);
/// ffxPlaySound plays it as hitsound + "*external".
/// <see cref="Record"/> is called only when the game actually loads the file (the key is not in audioLib yet),
/// so the recorded path is the file of the clip audioLib holds under that key.
/// Thread-safe.
/// </summary>
internal static class AudioClipPaths
{
/// <summary>Suffix of the game's audioLib keys for external clips.</summary>
public const string ExternalSuffix = "*external";
/// <summary>File path by audioLib key; also the lock object. Entries are kept for the whole game session.</summary>
private static readonly Dictionary<string, string> Paths = new();
/// <summary>audioLib key of an external clip loaded from <paramref name="path"/>.</summary>
public static string KeyOf(string path) => Path.GetFileName(path) + ExternalSuffix;
/// <summary>Records <paramref name="path"/> under <see cref="KeyOf"/>(path), replacing an earlier path of the same key.</summary>
public static void Record(string path)
{
if (string.IsNullOrEmpty(path)) return;
lock (Paths)
{
Paths[KeyOf(path)] = path;
}
}
/// <summary>Recorded path for an audioLib key (or AudioClip.name) of an external clip.</summary>
public static bool TryGetPath(string key, out string path)
{
lock (Paths)
{
return Paths.TryGetValue(key, out path!);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
using System;
using System.IO;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Opens the <see cref="AudioClipSource"/> for a clip the game plays, keyed by the game's audioLib key (also AudioClip.name):
/// - Key recorded in <see cref="AudioClipPaths"/> and the file exists: decoded from the file with <see cref="FFmpegPCMReader"/>
/// (mono MP3 through <see cref="MP3MonoLayoutReader"/>)
/// (song files and PlaySound files of custom levels).
/// - Otherwise, or when FFmpeg cannot open the file: the Unity clip is copied with <see cref="UnityClipLoader"/>
/// (built-in hit sounds from Resources, official/DLC level songs from Addressables).
/// Main thread only (the Unity copy calls AudioClip.GetData).
/// </summary>
internal static class AudioClipResolver
{
/// <summary>
/// Returns the source registered under <paramref name="key"/> in <paramref name="store"/>, opening it on first use
/// (file first, then the Unity clip; see the class summary). A key that could not be opened is remembered as null.
/// </summary>
/// <param name="store">Store of the current render.</param>
/// <param name="key">audioLib key of the clip (for example "song.ogg*external" or "sndKick").</param>
/// <param name="clip">Loaded Unity clip for the key, used when the clip has no file; may be null when the key has a file.</param>
/// <returns>The source, or null when neither the file nor the clip can be read.</returns>
public static AudioClipSource? GetOrAdd(AudioClipStore store, string key, AudioClip? clip)
{
if (store.TryGet(key, out var existing)) return existing;
if (AudioClipPaths.TryGetPath(key, out var path) && File.Exists(path))
{
IPCMReader? fileReader = null;
try
{
fileReader = OpenFile(path);
if (fileReader is MP3MonoLayoutReader)
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] Mono MP3 '{path}' is rendered like the game: half speed, one octave lower.");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Warning($"[AudioClipResolver] FFmpeg could not open '{path}' for '{key}', using the Unity clip: {e.Message}");
}
// The reader opened here becomes the first decode stream; additional streams reopen the file with OpenFile.
// When no source results (store disposed, or the source could not be created), the reader is disposed here
// (readers allow Dispose more than once).
if (fileReader != null)
{
var source = store.GetOrAdd(key, () => fileReader, () => OpenFile(path));
if (source == null) fileReader.Dispose();
return source;
}
}
if (!clip) return store.GetOrAdd(key, () => null);
return store.GetOrAdd(key, () => UnityClipLoader.Load(clip!, store));
}
/// <summary>Opens <paramref name="path"/> with FFmpeg; mono MP3 files are wrapped in <see cref="MP3MonoLayoutReader"/> (game layout).</summary>
private static IPCMReader OpenFile(string path)
{
var reader = new FFmpegPCMReader(path);
if (!reader.IsMP3 || reader.Channels != 1) return reader;
return new MP3MonoLayoutReader(reader);
}
}
+667
View File
@@ -0,0 +1,667 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Read position registered on an <see cref="AudioClipSource"/> by one consumer (a playing voice or the spectrum generator).
/// For a windowed source, the chunk containing <see cref="Position"/> and the following chunks
/// (<see cref="AudioClipSource.SlotCount"/> chunks in total) are kept loaded.
/// </summary>
internal sealed class AudioCursor
{
private readonly AudioClipSource _source;
/// <summary>Backing value of <see cref="Position"/>, accessed with Interlocked (64-bit reads are not atomic on every platform).</summary>
private long _position;
private volatile bool _loop;
/// <summary>Created by <see cref="AudioClipSource.AddCursor"/>.</summary>
internal AudioCursor(AudioClipSource source, long position, bool loop)
{
_source = source;
_position = position;
_loop = loop;
}
/// <summary>
/// Frame in the source at the output sample rate (0 ~ TotalFrames - 1).
/// Written by the consumer that reads the source; read by the decoder thread.
/// Moving into another chunk wakes the decoder thread.
/// </summary>
public long Position
{
get => Interlocked.Read(ref _position);
set
{
var previous = Interlocked.Exchange(ref _position, value);
if (previous / _source.FramesPerChunk != value / _source.FramesPerChunk)
_source.OnCursorMoved();
}
}
/// <summary>
/// When true, the chunk after the last chunk is chunk 0 (the window wraps to the clip start), and a position past the end
/// wraps into the clip. Setting it wakes the decoder thread.
/// </summary>
public bool Loop
{
get => _loop;
set
{
_loop = value;
_source.OnCursorMoved();
}
}
}
/// <summary>
/// One loaded chunk of an <see cref="AudioClipSource"/>, held by a consumer between
/// <see cref="AudioClipSource.TryAcquire"/> and <see cref="AudioClipSource.Release"/>.
/// The chunk is not freed while held.
/// </summary>
internal readonly struct ChunkView
{
/// <summary>Interleaved samples. Frame f of the source is at (f - StartFrame) * Channels.</summary>
public readonly float[] Data;
/// <summary>Source frame of the first frame in <see cref="Data"/>.</summary>
public readonly long StartFrame;
/// <summary>Valid frames in this chunk. Frames past the end of the source are not included.</summary>
public readonly int FrameCount;
/// <summary>Channel count of the stored samples (same as the source clip).</summary>
public readonly int Channels;
/// <summary>Index of the chunk in its source (used by <see cref="AudioClipSource.Release"/>).</summary>
public readonly int ChunkIndex;
/// <summary>Created by <see cref="AudioClipSource.TryAcquire"/>; default(ChunkView) is the invalid view.</summary>
internal ChunkView(float[] data, long startFrame, int frameCount, int channels, int chunkIndex)
{
Data = data;
StartFrame = startFrame;
FrameCount = frameCount;
Channels = channels;
ChunkIndex = chunkIndex;
}
/// <summary>True for a view that holds a chunk; false for default(ChunkView).</summary>
public bool IsValid => Data != null;
/// <summary>Source frame after the last valid frame (exclusive end).</summary>
public long EndFrame => StartFrame + FrameCount;
/// <summary>True when source frame <paramref name="frame"/> is one of the valid frames of this chunk.</summary>
public bool Contains(long frame) => frame >= StartFrame && frame < StartFrame + FrameCount;
/// <summary>
/// Sample of <paramref name="frame"/> mapped to output channel <paramref name="outChannel"/> of <paramref name="outChannels"/>.
/// Mapping: mono output averages all source channels; a mono source is copied to every output channel;
/// otherwise output channel c takes source channel c, and output channels beyond the source take source channel 0.
/// <paramref name="frame"/> must be inside this chunk.
/// </summary>
public float GetSample(long frame, int outChannel, int outChannels)
{
var baseIndex = (int)(frame - StartFrame) * Channels;
if (Channels == 1)
return Data[baseIndex];
if (outChannels == 1)
{
var sum = 0f;
for (var c = 0; c < Channels; c++)
sum += Data[baseIndex + c];
return sum / Channels;
}
return Data[baseIndex + (outChannel < Channels ? outChannel : 0)];
}
}
/// <summary>
/// How an <see cref="AudioClipSource"/> keeps its chunks.
/// </summary>
internal enum ChunkPolicy
{
/// <summary>
/// Only the chunks required by registered cursors (current chunk and the next SlotCount - 1 chunks per cursor),
/// chunks requested by a waiting consumer, and chunks currently held are kept. Other chunks are freed and reloaded on demand.
/// Used for readers that can seek (files decoded with FFmpeg) when the clip is longer than one chunk.
/// </summary>
Windowed,
/// <summary>
/// All chunks are loaded once, in order from chunk 0, starting when the source is created, and kept until the store is disposed.
/// Used for forward-only readers (clip data copied from Unity, released as it is consumed) and for clips of one chunk or less.
/// </summary>
Resident
}
/// <summary>
/// Decoded audio of one clip, resampled to the output sample rate and stored in fixed-size chunks.
/// Channel count stays as in the clip.
///
/// Chunks: chunk k covers output frames [k * FramesPerChunk, (k + 1) * FramesPerChunk), where
/// <see cref="FramesPerChunk"/> = <see cref="ChunkFloats"/> / Channels (rounded down; a frame never spans two chunks).
/// Windowed chunks use pooled arrays of ChunkFloats floats; resident chunks use arrays of their exact length.
/// See <see cref="ChunkPolicy"/> for which chunks are kept.
///
/// Threads: cursors and TryAcquire/Release may be used from any thread.
/// Loading and freeing run only on the store's decoder thread (<see cref="AudioClipStore"/>), through
/// <see cref="FreeUnused"/>, <see cref="TryGetNextLoad"/> and <see cref="LoadChunk"/>.
/// </summary>
internal sealed class AudioClipSource
{
/// <summary>Floats per chunk array: 2^22 floats = 16 MiB.</summary>
public const int ChunkFloats = 1 << 22;
/// <summary>Chunks kept per cursor of a windowed source: the chunk containing the cursor and the next SlotCount - 1 chunks.</summary>
public const int SlotCount = 3;
/// <summary>Load priority of a chunk a consumer is waiting for (lowest value is loaded first).</summary>
internal const int PriorityDemanded = 0;
/// <summary>Load priority of slot s of a cursor window is PriorityWindow + s.</summary>
internal const int PriorityWindow = 1;
/// <summary>Load priority of resident chunks nobody is waiting for.</summary>
internal const int PriorityResident = PriorityWindow + SlotCount;
/// <summary>
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/MP3 with the clip harness).
/// </summary>
public const int MaxDecodeStreams = 4;
/// <summary>Decode stream limit of this source (<see cref="MaxDecodeStreams"/> unless changed for tests).</summary>
internal int DecodeStreamLimit { get; set; } = MaxDecodeStreams;
private readonly AudioClipStore _store;
/// <summary>Reader passed to the constructor (the first decode stream); disposed again in <see cref="DisposeInternal"/>.</summary>
private readonly IPCMReader _firstReader;
/// <summary>Opens another reader for an additional decode stream; null when only the first reader can be used.</summary>
private readonly Func<IPCMReader?>? _openReader;
// Guarded by _sync: chunk table, hold counts, cursors, demand set, loaded set.
private readonly object _sync = new();
/// <summary>Sample array of each chunk, null while not loaded.</summary>
private readonly float[]?[] _chunks;
/// <summary>Number of consumers holding each chunk (TryAcquire minus Release); a held chunk is not freed.</summary>
private readonly int[] _holdCounts;
private readonly List<AudioCursor> _cursors = new();
/// <summary>Chunks a consumer asked for while they were not loaded; an index is removed when the chunk is acquired.</summary>
private readonly HashSet<int> _demanded = new();
/// <summary>Indices of the loaded chunks (non-null entries of <see cref="_chunks"/>).</summary>
private readonly HashSet<int> _loaded = new();
private int _residentLoadedCount; // Resident: chunks [0, _residentLoadedCount) are loaded
// Guarded by _loadLock: decode streams.
private readonly object _loadLock = new();
private readonly List<DecodeStream> _streams = new();
/// <summary>Incremented on every load; stored in <see cref="DecodeStream.LastUse"/> for least-recently-used selection.</summary>
private long _streamUseCounter;
/// <summary>True after opening an additional reader failed; no further readers are opened for this source.</summary>
private bool _openFailed;
private volatile bool _isFailed;
/// <summary>Key the source is registered under in the store (the game's audioLib key / AudioClip.name).</summary>
public string Key { get; }
/// <summary>Channel count of the stored samples (same as the clip).</summary>
public int Channels { get; }
/// <summary>Sample rate of the stored samples in Hz (the output sample rate).</summary>
public int Frequency { get; }
/// <summary>Frame count at <see cref="Frequency"/>: ceil(clip frames * Frequency / clip rate).</summary>
public long TotalFrames { get; }
/// <summary>Clip length in seconds: clip frames / clip rate.</summary>
public double Duration { get; }
/// <summary>Frames per chunk: <see cref="ChunkFloats"/> / <see cref="Channels"/> (rounded down).</summary>
public int FramesPerChunk { get; }
/// <summary>Number of chunks: ceil(<see cref="TotalFrames"/> / <see cref="FramesPerChunk"/>); 0 for an empty clip.</summary>
public int ChunkCount { get; }
/// <summary>
/// <see cref="ChunkPolicy.Resident"/> for forward-only readers and clips of at most one chunk, otherwise
/// <see cref="ChunkPolicy.Windowed"/>. Fixed at construction.
/// </summary>
public ChunkPolicy Policy { get; }
/// <summary>True when <see cref="Policy"/> is <see cref="ChunkPolicy.Resident"/>.</summary>
public bool IsResident => Policy == ChunkPolicy.Resident;
/// <summary>True after loading failed. A failed source returns no chunks.</summary>
public bool IsFailed => _isFailed;
/// <summary>True when every chunk of a resident source is loaded.</summary>
public bool IsFullyLoaded
{
get
{
lock (_sync) return _loaded.Count == ChunkCount;
}
}
/// <summary>Number of chunk loads performed (for diagnostics).</summary>
public int LoadCount { get; private set; }
/// <summary>Decode streams opened so far (for diagnostics).</summary>
public int StreamCount
{
get
{
lock (_loadLock) return _streams.Count;
}
}
/// <param name="store">Store that owns this source.</param>
/// <param name="key">Clip name or path the source is registered under.</param>
/// <param name="reader">First reader; also defines the clip format.</param>
/// <param name="openReader">Opens another reader of the same clip for an additional decode stream, or null when not possible.</param>
/// <param name="outputRate">Output sample rate in Hz.</param>
internal AudioClipSource(AudioClipStore store, string key, IPCMReader reader, Func<IPCMReader?>? openReader, int outputRate)
{
if (reader.Channels <= 0) throw new ArgumentException("Reader channel count must be positive.", nameof(reader));
if (reader.SampleRate <= 0) throw new ArgumentException("Reader sample rate must be positive.", nameof(reader));
if (reader.TotalFrames < 0) throw new ArgumentException("Reader frame count must not be negative.", nameof(reader));
_store = store;
_firstReader = reader;
_openReader = openReader;
Key = key;
Channels = reader.Channels;
Frequency = outputRate;
Duration = (double)reader.TotalFrames / reader.SampleRate;
TotalFrames = CeilDiv(reader.TotalFrames * outputRate, reader.SampleRate);
FramesPerChunk = ChunkFloats / Channels;
ChunkCount = (int)CeilDiv(TotalFrames, FramesPerChunk);
Policy = reader.IsForwardOnly || ChunkCount <= 1 ? ChunkPolicy.Resident : ChunkPolicy.Windowed;
_chunks = new float[ChunkCount][];
_holdCounts = new int[ChunkCount];
_streams.Add(new DecodeStream(reader, outputRate));
}
// ── Cursors ──
/// <summary>Registers a read position. The decoder thread loads the chunks it requires.</summary>
public AudioCursor AddCursor(long position, bool loop)
{
var cursor = new AudioCursor(this, position, loop);
lock (_sync)
{
_cursors.Add(cursor);
}
_store.RequestService();
return cursor;
}
/// <summary>Unregisters <paramref name="cursor"/>; its window chunks become freeable on the decoder thread's next pass.</summary>
public void RemoveCursor(AudioCursor cursor)
{
lock (_sync)
{
_cursors.Remove(cursor);
}
_store.RequestService();
}
/// <summary>Wakes the decoder thread after a cursor changed chunk or loop flag (resident sources ignore cursors).</summary>
internal void OnCursorMoved()
{
if (!IsResident) _store.RequestService();
}
// ── Consumer access ──
/// <summary>Chunk index containing <paramref name="frame"/> (0 ~ TotalFrames - 1).</summary>
public int ChunkIndexOf(long frame) => (int)(frame / FramesPerChunk);
/// <summary>
/// Holds the chunk containing <paramref name="frame"/> when it is loaded.
/// When it is not loaded, records a load request (kept until the chunk is acquired), wakes the decoder thread and returns false.
/// Every successful call must be paired with <see cref="Release"/>.
/// </summary>
public bool TryAcquire(long frame, out ChunkView view)
{
view = default;
if (_isFailed || frame < 0 || frame >= TotalFrames) return false;
var index = ChunkIndexOf(frame);
lock (_sync)
{
var data = _chunks[index];
if (data != null)
{
_demanded.Remove(index);
_holdCounts[index]++;
view = CreateView(index, data);
return true;
}
_demanded.Add(index);
}
_store.RequestService();
return false;
}
/// <summary>Ends one hold of the chunk of <paramref name="view"/> taken by <see cref="TryAcquire"/>. An invalid view is ignored.</summary>
public void Release(in ChunkView view)
{
if (!view.IsValid) return;
lock (_sync)
{
if (_holdCounts[view.ChunkIndex] > 0)
_holdCounts[view.ChunkIndex]--;
}
}
/// <summary>View of chunk <paramref name="index"/>; the last chunk's frame count stops at <see cref="TotalFrames"/>.</summary>
private ChunkView CreateView(int index, float[] data)
{
var start = (long)index * FramesPerChunk;
var count = (int)Math.Min(FramesPerChunk, TotalFrames - start);
return new ChunkView(data, start, count, Channels, index);
}
// ── Decoder thread ──
/// <summary>
/// Windowed: frees loaded chunks that are not required (see <see cref="ChunkPolicy.Windowed"/>) and not held.
/// Resident: does nothing. Decoder thread only.
/// </summary>
internal void FreeUnused()
{
if (IsResident) return;
List<float[]>? freed = null;
lock (_sync)
{
if (_loaded.Count == 0) return;
List<int>? remove = null;
foreach (var index in _loaded)
{
if (_holdCounts[index] > 0 || IsRequired(index)) continue;
(remove ??= new List<int>()).Add(index);
}
if (remove == null) return;
foreach (var index in remove)
{
(freed ??= new List<float[]>()).Add(_chunks[index]!);
_chunks[index] = null;
_loaded.Remove(index);
}
}
foreach (var data in freed!)
_store.ReturnChunkArray(data);
}
/// <summary>
/// Next chunk to load and its priority (lower loads first), or false when nothing is missing.
/// Windowed: demanded chunks, then each cursor's window by slot.
/// Resident: the next chunk in order; priority is <see cref="PriorityDemanded"/> while a consumer is waiting on this source.
/// Decoder thread only.
/// </summary>
internal bool TryGetNextLoad(out int index, out int priority)
{
index = -1;
priority = int.MaxValue;
if (_isFailed) return false;
lock (_sync)
{
if (IsResident)
{
if (_residentLoadedCount >= ChunkCount) return false;
index = _residentLoadedCount;
priority = _demanded.Count > 0 ? PriorityDemanded : PriorityResident;
return true;
}
foreach (var demanded in _demanded)
{
if (_chunks[demanded] != null) continue;
index = demanded;
priority = PriorityDemanded;
return true;
}
// For each cursor, the first missing chunk of its window; the lowest slot over all cursors wins
// (ties go to the earlier cursor).
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (_chunks[candidate] != null) continue;
if (PriorityWindow + slot < priority)
{
index = candidate;
priority = PriorityWindow + slot;
}
break;
}
}
return index >= 0;
}
}
/// <summary>True when a demand or a cursor window covers <paramref name="index"/>. Caller holds _sync.</summary>
private bool IsRequired(int index)
{
if (_demanded.Contains(index)) return true;
foreach (var cursor in _cursors)
{
if (!TryGetWindowStart(cursor, out var first, out var loop)) continue;
for (var slot = 0; slot < SlotCount; slot++)
{
if (!TryWindowIndex(first, slot, loop, out var candidate)) break;
if (candidate == index) return true;
}
}
return false;
}
/// <summary>
/// First chunk of the cursor's window: the chunk of its position (negative positions count as 0; positions past the end
/// wrap when looping). False when a non-looping cursor is past the end (no window). Caller holds _sync.
/// </summary>
private bool TryGetWindowStart(AudioCursor cursor, out int first, out bool loop)
{
var position = cursor.Position;
loop = cursor.Loop;
first = 0;
if (position < 0) position = 0;
if (position >= TotalFrames)
{
if (!loop || TotalFrames == 0) return false;
position %= TotalFrames;
}
first = ChunkIndexOf(position);
return true;
}
/// <summary>Chunk index of window slot <paramref name="slot"/> after <paramref name="first"/>; wraps when looping, false past the end otherwise.</summary>
private bool TryWindowIndex(int first, int slot, bool loop, out int index)
{
index = first + slot;
if (index < ChunkCount) return true;
if (!loop) return false;
index %= ChunkCount;
return true;
}
/// <summary>
/// Loads chunk <paramref name="index"/> if it is not loaded. Resident sources load only the next chunk in order.
/// Decoder thread only.
/// </summary>
/// <returns>True when this call loaded the chunk.</returns>
internal bool LoadChunk(int index)
{
if (_isFailed || index < 0 || index >= ChunkCount) return false;
lock (_loadLock)
{
lock (_sync)
{
if (_chunks[index] != null) return false;
if (IsResident && index != _residentLoadedCount)
throw new InvalidOperationException($"Resident clip '{Key}' loads chunks in order: expected {_residentLoadedCount}, got {index}.");
}
float[]? data = null;
try
{
var start = (long)index * FramesPerChunk;
var frames = (int)Math.Min(FramesPerChunk, TotalFrames - start);
data = IsResident ? _store.AllocateExactArray(frames * Channels) : _store.RentChunkArray();
var stream = SelectStream(start);
stream.LastUse = ++_streamUseCounter;
stream.Fill(start, frames, data);
}
// Out of memory fails the whole render (store fatal error); any other error makes only this source silent.
catch (OutOfMemoryException e)
{
ReturnArray(data);
Fail();
_store.ReportFatal(this, e);
return false;
}
catch (Exception e)
{
ReturnArray(data);
Fail();
_store.ReportSourceFailure(this, e);
return false;
}
lock (_sync)
{
_chunks[index] = data;
_loaded.Add(index);
if (IsResident) _residentLoadedCount++;
}
LoadCount++;
// A resident source never reads again after its last chunk; the reader releases its data now.
if (IsResident && _residentLoadedCount == ChunkCount)
DisposeStreams();
}
_store.NotifyChunkLoaded();
return true;
}
/// <summary>
/// Decode stream for a load starting at output frame <paramref name="start"/>:
/// the stream positioned at <paramref name="start"/>; otherwise a new stream while fewer than <see cref="DecodeStreamLimit"/>
/// are open (windowed sources only); otherwise the least recently used stream. Caller holds _loadLock.
/// </summary>
private DecodeStream SelectStream(long start)
{
DecodeStream? oldest = null;
foreach (var stream in _streams)
{
if (stream.NextOutFrame == start) return stream;
if (oldest == null || stream.LastUse < oldest.LastUse) oldest = stream;
}
if (!IsResident && !_openFailed && _openReader != null && _streams.Count < DecodeStreamLimit)
{
IPCMReader? reader = null;
try
{
reader = _openReader();
if (reader is { IsForwardOnly: true })
{
reader.Dispose();
reader = null;
}
if (reader != null)
{
var stream = new DecodeStream(reader, Frequency);
_streams.Add(stream);
return stream;
}
}
catch (Exception e)
{
reader?.Dispose();
Melon<RenderMod>.Logger.Warning($"[AudioClipSource] Could not open another decode stream for '{Key}', reusing open streams: {e.Message}");
}
_openFailed = true;
}
return oldest!;
}
/// <summary>Marks the source failed (no more chunks are returned or loaded); a resident source also closes its readers now. Caller holds _loadLock.</summary>
private void Fail()
{
_isFailed = true;
if (IsResident) DisposeStreams();
}
/// <summary>Disposes every decode stream (reader and resampler) and clears the list. Caller holds _loadLock.</summary>
private void DisposeStreams()
{
foreach (var stream in _streams)
stream.Dispose();
_streams.Clear();
}
/// <summary>Gives a chunk array back to the store: exact arrays (resident) are uncounted, pooled arrays (windowed) are returned to the pool.</summary>
private void ReturnArray(float[]? data)
{
if (data == null) return;
if (IsResident) _store.FreeExactArray(data);
else _store.ReturnChunkArray(data);
}
/// <summary>Releases all chunk arrays and readers. Called by the store after the decoder thread stopped.</summary>
internal void DisposeInternal()
{
lock (_loadLock)
{
lock (_sync)
{
foreach (var index in _loaded)
{
ReturnArray(_chunks[index]);
_chunks[index] = null;
}
_loaded.Clear();
_cursors.Clear();
_demanded.Clear();
}
DisposeStreams();
_firstReader.Dispose();
}
}
/// <summary>ceil(a / b) for a ≥ 0, b &gt; 0.</summary>
private static long CeilDiv(long a, long b) => (a + b - 1) / b;
}
+391
View File
@@ -0,0 +1,391 @@
using System;
using System.Collections.Generic;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Clip sources of one render, keyed by clip name or path, with a shared pool of chunk arrays and one decoder thread.
///
/// Threads:
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
/// Each step loads one chunk, the one with the lowest priority value over all sources
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
/// - Consumers (render thread, main Unity thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
///
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
/// An <see cref="OutOfMemoryException"/> while loading is kept in <see cref="FatalError"/> and rethrown by <see cref="Acquire"/>.
/// </summary>
internal sealed class AudioClipStore : IDisposable
{
/// <summary>Longest wait of the decoder thread between checks when nothing signals it, in milliseconds.</summary>
private const int DecoderIdleWaitMs = 50;
/// <summary>Longest wait of a consumer between checks when no chunk is loaded, in milliseconds.</summary>
private const int ConsumerWaitMs = 50;
/// <summary>Longest wait for the decoder thread to stop in <see cref="Dispose"/>, in milliseconds.</summary>
private const int DecoderStopTimeoutMs = 3000;
/// <summary>Sources by key; a null value marks a key whose reader could not be created. Also the lock of the source tables.</summary>
private readonly Dictionary<string, AudioClipSource?> _sources = new();
/// <summary>Non-null sources in creation order, scanned by the decoder thread. Guarded by the <see cref="_sources"/> lock.</summary>
private readonly List<AudioClipSource> _sourceList = new();
/// <summary>Free windowed chunk arrays (<see cref="AudioClipSource.ChunkFloats"/> floats each), guarded by <see cref="_poolLock"/>.</summary>
private readonly Stack<float[]> _chunkPool = new();
private readonly object _poolLock = new();
/// <summary>Monitor the decoder thread pulses after each load (or failure); consumers in <see cref="Acquire"/> wait on it.</summary>
private readonly object _signal = new();
/// <summary>Wakes the decoder thread when a cursor, demand or source changes (<see cref="RequestService"/>).</summary>
private readonly AutoResetEvent _serviceRequested = new(false);
private Thread? _decoderThread;
private volatile bool _stopRequested;
/// <summary>Bytes of allocated sample arrays (see <see cref="AllocatedBytes"/>), updated with Interlocked.</summary>
private long _allocatedBytes;
private long _peakBytes;
/// <summary>First fatal error (out of memory, or an exception that stopped the decoder thread); null while none.</summary>
private volatile Exception? _fatalError;
private volatile bool _disposed;
/// <summary>Sample rate every source is resampled to, in Hz.</summary>
public int OutputRate { get; }
/// <summary>Bytes of sample arrays currently allocated (loaded chunks, pooled chunk arrays and copied Unity clip data).</summary>
public long AllocatedBytes => Interlocked.Read(ref _allocatedBytes);
/// <summary>Highest value of <see cref="AllocatedBytes"/> since creation.</summary>
public long PeakBytes => Interlocked.Read(ref _peakBytes);
/// <summary>
/// Out-of-memory error raised while opening or loading a clip, or the exception that stopped the decoder thread; null while none.
/// </summary>
public Exception? FatalError => _fatalError;
/// <param name="outputRate">Sample rate in Hz every source is resampled to (positive). Call <see cref="Start"/> to begin decoding.</param>
public AudioClipStore(int outputRate)
{
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
OutputRate = outputRate;
}
// ── Decoder thread ──
/// <summary>Starts the decoder thread. Called once.</summary>
public void Start()
{
if (_decoderThread != null) throw new InvalidOperationException("Decoder thread already started.");
_decoderThread = new Thread(DecoderLoop)
{
Name = "Audio Clip Decoder",
IsBackground = true
};
_decoderThread.Start();
}
/// <summary>
/// Decoder thread body: loads one chunk per iteration, or sleeps until <see cref="RequestService"/> (at most
/// <see cref="DecoderIdleWaitMs"/> ms) when nothing is missing. An unexpected exception ends the thread and is kept as the
/// fatal error, so waiting consumers fail instead of waiting forever.
/// </summary>
private void DecoderLoop()
{
try
{
while (!_stopRequested)
{
if (!LoadNext())
_serviceRequested.WaitOne(DecoderIdleWaitMs);
}
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Decoder thread stopped: {e}");
_fatalError ??= e;
NotifyChunkLoaded();
}
}
/// <summary>
/// Frees unused windowed chunks, then loads the single chunk with the lowest priority value over all sources.
/// Runs on the decoder thread (tests may call it directly while the thread is not started).
/// </summary>
/// <returns>True when a chunk was loaded or a load was attempted.</returns>
internal bool LoadNext()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return false;
sources = _sourceList.ToArray();
}
AudioClipSource? best = null;
var bestIndex = -1;
var bestPriority = int.MaxValue;
foreach (var source in sources)
{
source.FreeUnused();
if (!source.TryGetNextLoad(out var index, out var priority) || priority >= bestPriority) continue;
best = source;
bestIndex = index;
bestPriority = priority;
}
if (best == null) return false;
best.LoadChunk(bestIndex);
return true;
}
/// <summary>Wakes the decoder thread to re-evaluate what to free and load. Any thread.</summary>
internal void RequestService() => _serviceRequested.Set();
// ── Sources ──
/// <summary>
/// Returns the source registered under <paramref name="key"/>, creating it with <paramref name="createReader"/> on first use.
/// A key whose reader could not be created is remembered as null and not retried.
/// For readers that can seek, additional decode streams (see <see cref="AudioClipSource.MaxDecodeStreams"/>) are opened on the
/// decoder thread with <paramref name="openAdditionalReader"/>, or with <paramref name="createReader"/> when it is null.
/// </summary>
public AudioClipSource? GetOrAdd(string key, Func<IPCMReader?> createReader, Func<IPCMReader?>? openAdditionalReader = null)
{
lock (_sources)
{
if (_disposed) return null;
if (_sources.TryGetValue(key, out var existing)) return existing;
}
AudioClipSource? source = null;
try
{
var reader = createReader();
if (reader != null)
{
try
{
source = new AudioClipSource(this, key, reader, reader.IsForwardOnly ? null : openAdditionalReader ?? createReader, OutputRate);
}
catch
{
reader.Dispose();
throw;
}
}
}
catch (OutOfMemoryException e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while opening clip '{key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {e}");
_fatalError ??= e;
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to open clip '{key}': {e}");
}
// The reader is created outside the lock (it may decode or copy a whole clip). If another caller registered the
// same key meanwhile, its entry wins and this source is discarded.
lock (_sources)
{
if (_disposed || _sources.TryGetValue(key, out var raced))
{
source?.DisposeInternal();
return _disposed ? null : _sources[key];
}
_sources[key] = source;
if (source != null) _sourceList.Add(source);
}
if (source != null)
{
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Opened clip '{key}': {source.Channels}ch, {source.Duration:F3}s, {source.TotalFrames} frames at {OutputRate}Hz, {source.ChunkCount} chunk(s), {source.Policy}");
RequestService();
}
return source;
}
/// <summary>
/// True when <paramref name="key"/> has been registered; <paramref name="source"/> is then the source, or null when its reader
/// could not be created. Any thread.
/// </summary>
public bool TryGet(string key, out AudioClipSource? source)
{
lock (_sources)
{
return _sources.TryGetValue(key, out source);
}
}
// ── Consumers ──
/// <summary>
/// Holds the chunk containing <paramref name="frame"/>, waiting for the decoder thread until it is loaded.
/// Returns an invalid view when the source failed, the frame is outside the source, or the store was disposed.
/// </summary>
/// <exception cref="InvalidOperationException">Loading ran out of memory or the decoder thread stopped.</exception>
/// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was canceled while waiting.</exception>
public ChunkView Acquire(AudioClipSource source, long frame, CancellationToken cancellationToken = default)
{
if (frame < 0 || frame >= source.TotalFrames) return default;
while (true)
{
ThrowIfFatal();
if (source.IsFailed || _disposed) return default;
if (source.TryAcquire(frame, out var view)) return view;
// Retry under the signal lock so a load completed between the first try and the wait is not missed;
// the timed wait also rechecks cancellation and failure states periodically.
lock (_signal)
{
if (source.TryAcquire(frame, out view)) return view;
if (source.IsFailed || _fatalError != null || _disposed) continue;
Monitor.Wait(_signal, ConsumerWaitMs);
}
cancellationToken.ThrowIfCancellationRequested();
}
}
/// <summary>Throws <see cref="InvalidOperationException"/> wrapping <see cref="FatalError"/> when one is set.</summary>
private void ThrowIfFatal()
{
var error = _fatalError;
if (error != null)
throw new InvalidOperationException("Audio clip loading failed: " + error.Message, error);
}
/// <summary>Wakes every consumer waiting in <see cref="Acquire"/> (after a load, a failure, or on dispose).</summary>
internal void NotifyChunkLoaded()
{
lock (_signal)
{
Monitor.PulseAll(_signal);
}
}
/// <summary>Logs a load error of one source (the source is already marked failed and plays silence) and wakes waiting consumers.</summary>
internal void ReportSourceFailure(AudioClipSource source, Exception error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Failed to load clip '{source.Key}', it will be silent: {error}");
NotifyChunkLoaded();
}
/// <summary>Logs an out-of-memory error, keeps it as <see cref="FatalError"/> (first one wins) and wakes waiting consumers.</summary>
internal void ReportFatal(AudioClipSource source, OutOfMemoryException error)
{
Melon<RenderMod>.Logger.Error($"[AudioClipStore] Out of memory while loading clip '{source.Key}' ({AllocatedBytes / 1048576.0:F1} MiB allocated): {error}");
_fatalError ??= error;
NotifyChunkLoaded();
}
// ── Arrays ──
/// <summary>Takes a pooled array of <see cref="AudioClipSource.ChunkFloats"/> floats, or allocates (and counts) a new one. Contents are undefined.</summary>
internal float[] RentChunkArray()
{
lock (_poolLock)
{
if (_chunkPool.Count > 0) return _chunkPool.Pop();
}
var data = new float[AudioClipSource.ChunkFloats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
/// <summary>Puts a rented array back into the pool (it stays counted); after dispose it is dropped and uncounted.</summary>
internal void ReturnChunkArray(float[] data)
{
if (_disposed)
{
AddAllocated(-data.LongLength * sizeof(float));
return;
}
lock (_poolLock)
{
_chunkPool.Push(data);
}
}
/// <summary>Allocates a counted array of exactly <paramref name="floats"/> floats (resident chunks, copied Unity clip blocks).</summary>
internal float[] AllocateExactArray(int floats)
{
var data = new float[floats];
AddAllocated(data.LongLength * sizeof(float));
return data;
}
/// <summary>Uncounts an array from <see cref="AllocateExactArray"/>; the array itself is left to the garbage collector.</summary>
internal void FreeExactArray(float[] data) => AddAllocated(-data.LongLength * sizeof(float));
/// <summary>Adds <paramref name="bytes"/> (negative to subtract) to the allocation counter and raises the peak lock-free.</summary>
private void AddAllocated(long bytes)
{
var current = Interlocked.Add(ref _allocatedBytes, bytes);
long peak;
while (current > (peak = Interlocked.Read(ref _peakBytes)))
{
if (Interlocked.CompareExchange(ref _peakBytes, current, peak) == peak) break;
}
}
/// <summary>Logs the source count and the current and peak sample memory in MiB.</summary>
public void LogMemoryUsage()
{
int sourceCount;
lock (_sources) sourceCount = _sourceList.Count;
Melon<RenderMod>.Logger.Msg($"[AudioClipStore] Clips: {sourceCount}, sample memory now {AllocatedBytes / 1048576.0:F1} MiB, peak {PeakBytes / 1048576.0:F1} MiB");
}
/// <summary>Stops the decoder thread, then releases every source and pooled array.</summary>
public void Dispose()
{
AudioClipSource[] sources;
lock (_sources)
{
if (_disposed) return;
_disposed = true;
sources = _sourceList.ToArray();
}
_stopRequested = true;
_serviceRequested.Set();
NotifyChunkLoaded();
// A decoder thread blocked inside a read (for example a file on an unresponsive drive) is left running:
// it is a background thread, and its sources keep their readers so the blocked call does not touch freed FFmpeg contexts.
var decoderStopped = _decoderThread == null || _decoderThread.Join(DecoderStopTimeoutMs);
if (!decoderStopped)
Melon<RenderMod>.Logger.Warning($"[AudioClipStore] Decoder thread did not stop within {DecoderStopTimeoutMs} ms; its readers are left open.");
LogMemoryUsage();
lock (_sources)
{
_sourceList.Clear();
_sources.Clear();
}
if (decoderStopped)
{
foreach (var source in sources)
source.DisposeInternal();
}
lock (_poolLock)
{
while (_chunkPool.Count > 0)
AddAllocated(-_chunkPool.Pop().LongLength * sizeof(float));
}
}
}
+204
View File
@@ -0,0 +1,204 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// One reader plus its resampler, producing output-rate frames of a clip as a continuous stream.
/// Consecutive <see cref="Fill"/> calls whose ranges follow each other continue the stream (reader position and resampler state);
/// any other range starts a new stream near the requested position.
///
/// Restart: the input position is moved back by a preroll and down to a multiple of in_rate / gcd(in_rate, out_rate),
/// where input frame k * (in_rate / gcd) maps exactly to output frame k * (out_rate / gcd);
/// the resampler is reset and output before the requested frame is discarded.
/// Measured with the clip harness: output is bit-identical to one continuous conversion of the whole clip.
/// Used by one thread at a time (the store's decoder thread).
/// </summary>
internal sealed class DecodeStream : IDisposable
{
/// <summary>Input frames read from the reader per resampler call.</summary>
private const int ReadBlockFrames = 4096;
/// <summary>Resampler from the reader's rate to the output rate; null when the rates are equal (samples are copied).</summary>
private readonly PCMResampler? _resampler;
private readonly int _channels;
private readonly int _outputRate;
/// <summary>Input block of <see cref="ReadBlockFrames"/> frames, interleaved.</summary>
private readonly float[] _readBuffer;
/// <summary>Scratch output for frames discarded after a restart; allocated on first use.</summary>
private float[]? _discardBuffer;
/// <summary>True once the reader returned no more input and the resampler is being drained with Flush.</summary>
private bool _flushing;
/// <summary>True once the input and the resampler tail are exhausted; the stream must restart before producing more.</summary>
private bool _ended;
/// <summary>Input frames per alignment step: input rate / gcd (1 without resampling).</summary>
private readonly long _inputStep;
/// <summary>Output frames per alignment step: output rate / gcd (1 without resampling).</summary>
private readonly long _outputStep;
/// <summary>Input frames fed before the requested position on a restart, so the filter history matches a continuous run.</summary>
private readonly long _prerollInputFrames;
/// <summary>Reader of this stream (owned; disposed with the stream).</summary>
public IPCMReader Reader { get; }
/// <summary>Output frame the stream continues at, or -1 when the stream is not positioned.</summary>
public long NextOutFrame { get; private set; } = -1;
/// <summary>Value of the owner's use counter at the last <see cref="Fill"/> (for least-recently-used selection).</summary>
public long LastUse { get; set; }
/// <summary>Number of times the stream started anew (seek + resampler reset).</summary>
public int RestartCount { get; private set; }
/// <param name="reader">Reader of the clip; ownership passes to the stream.</param>
/// <param name="outputRate">Output sample rate in Hz.</param>
public DecodeStream(IPCMReader reader, int outputRate)
{
Reader = reader;
_channels = reader.Channels;
_outputRate = outputRate;
_readBuffer = new float[ReadBlockFrames * _channels];
if (reader.SampleRate != outputRate)
{
_resampler = new PCMResampler(_channels, reader.SampleRate, outputRate);
var gcd = GCD(reader.SampleRate, outputRate);
_inputStep = reader.SampleRate / gcd;
_outputStep = outputRate / gcd;
// History fed before the first kept output frame when a stream starts mid-clip.
// The filter spans about filter_size input frames per output frame at ratios up to 1:1
// and filter_size * (input rate / output rate) input frames when downsampling.
// The preroll is four times that span plus one alignment step (the restart position is rounded down to a step).
var ratio = Math.Max(1L, (reader.SampleRate + outputRate - 1L) / outputRate);
_prerollInputFrames = 4L * _resampler.FilterSize * ratio + _inputStep;
}
else
{
_inputStep = 1;
_outputStep = 1;
}
}
/// <summary>
/// Writes output frames [start, start + frames) into <paramref name="data"/> and zeros the rest of the array.
/// Frames past the end of the decoded input are zero.
/// </summary>
public unsafe void Fill(long start, int frames, float[] data)
{
var channels = _channels;
var filled = 0;
// Same rate: output frame = input frame; seek only when the reader is not already at the start.
if (_resampler == null)
{
if (Reader.Position != start)
{
Reader.Seek(start);
RestartCount++;
}
while (filled < frames)
{
var request = Math.Min(ReadBlockFrames, frames - filled);
var read = Reader.Read(_readBuffer, request);
if (read <= 0) break;
Array.Copy(_readBuffer, 0, data, filled * channels, read * channels);
filled += read;
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = start + frames;
return;
}
long discard = 0;
if (NextOutFrame != start)
{
// Input frame of the requested output frame, moved back by the preroll and down to a multiple of _inputStep.
var inputFrame = start * Reader.SampleRate / _outputRate - _prerollInputFrames;
var step = inputFrame > 0 ? inputFrame / _inputStep : 0;
_resampler.Reset();
Reader.Seek(step * _inputStep);
_flushing = false;
_ended = false;
discard = start - step * _outputStep;
RestartCount++;
}
fixed (float* pIn = _readBuffer)
fixed (float* pOut = data)
{
// Produce and drop the output frames between the aligned restart position and the requested start.
while (discard > 0 && !_ended)
{
_discardBuffer ??= new float[ReadBlockFrames * channels];
fixed (float* pDiscard = _discardBuffer)
{
var capacity = (int)Math.Min(ReadBlockFrames, discard);
discard -= Pull(pIn, pDiscard, capacity);
}
}
while (filled < frames && !_ended)
filled += Pull(pIn, pOut + (long)filled * channels, frames - filled);
}
Array.Clear(data, filled * channels, data.Length - filled * channels);
NextOutFrame = _ended ? -1 : start + frames;
}
/// <summary>
/// Writes 1 ~ <paramref name="capacity"/> resampled frames to <paramref name="output"/>, reading input as needed.
/// Returns 0 and sets _ended once the input and the resampler's flushed tail are exhausted.
/// </summary>
private unsafe int Pull(float* input, float* output, int capacity)
{
var resampler = _resampler!;
if (!_flushing)
{
while (true)
{
var produced = resampler.Convert(input, 0, output, capacity);
if (produced > 0) return produced;
var read = Reader.Read(_readBuffer, ReadBlockFrames);
if (read <= 0)
{
_flushing = true;
break;
}
produced = resampler.Convert(input, read, output, capacity);
if (produced > 0) return produced;
}
}
// Input is exhausted: take the resampler's remaining tail until it returns 0.
var flushed = resampler.Flush(output, capacity);
if (flushed > 0) return flushed;
_ended = true;
return 0;
}
/// <summary>Releases the resampler and the reader.</summary>
public void Dispose()
{
_resampler?.Dispose();
Reader.Dispose();
}
/// <summary>Greatest common divisor of two positive integers (Euclid).</summary>
private static int GCD(int a, int b)
{
while (b != 0) (a, b) = (b, a % b);
return a;
}
}
+446
View File
@@ -0,0 +1,446 @@
using System;
using System.Runtime.InteropServices;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that decodes the best audio stream of a file with libavformat/libavcodec.
/// Output keeps the stream's sample rate and channel count; libswresample converts only the sample format to interleaved float.
///
/// Frame positions: frame 0 is the first sample the decoder outputs for the stream's start time.
/// The position of the first frame decoded after opening or seeking is taken from its timestamp
/// (best_effort_timestamp - stream start_time, in samples); following frames are counted sequentially.
/// Seek moves the demuxer to <see cref="SeekMarginSeconds"/> before the target and discards decoded samples before the target.
///
/// <see cref="TotalFrames"/> comes from the stream duration (or the container duration) and may differ from the decoded length;
/// reads stop at whichever ends first.
///
/// MP3: decoded with the game's timing (MP3Sharp, see <see cref="MP3HeaderFrame"/>): encoder delay and padding are kept
/// (AV_CODEC_FLAG2_SKIP_MANUAL), frame 0 is the first sample of the first audio frame, and one frame of silence is placed
/// before it when the file starts with a Xing/Info/VBRI header frame. <see cref="TotalFrames"/> is the sum of all packet
/// durations plus that silence, counted once when the file is opened.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__decoding.html (send/receive API)
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lavf__decoding.html (av_read_frame, av_seek_frame)
/// </summary>
internal sealed unsafe class FFmpegPCMReader : IPCMReader
{
/// <summary>
/// Time the demuxer is moved before a seek target, in seconds. Decoders that depend on previous packets
/// (for example the MP3 bit reservoir) produce settled output after this margin; the samples in it are discarded.
/// </summary>
private const double SeekMarginSeconds = 0.25;
// FFmpeg objects owned by this reader, freed by Dispose (null after).
private AVFormatContext* _format;
private AVCodecContext* _codec;
/// <summary>Sample format converter: decoder format to interleaved float, same rate and layout.</summary>
private SwrContext* _swr;
private AVPacket* _packet;
private AVFrame* _frame;
/// <summary>Index of the decoded audio stream in the container; every other stream is discarded by the demuxer.</summary>
private readonly int _streamIndex;
/// <summary>Time base of the stream's timestamps (seconds per tick).</summary>
private readonly AVRational _timeBase;
private readonly long _ptsOrigin; // Stream timestamp of output frame _leadFrames
private readonly long _leadFrames; // Silent frames placed before the first decoded frame (MP3 header frame, see class summary)
// Decoded samples not yet returned: frames [_pendingStart, _pendingStart + _pendingCount) at _pending[_pendingOffset * Channels].
private float[] _pending = Array.Empty<float>();
private int _pendingOffset;
private int _pendingCount;
private long _pendingStart;
private long _nextDecodedFrame; // Frame index of the next decoded frame when counting sequentially
private bool _syncToTimestamp; // True until the first frame after open/seek sets _nextDecodedFrame from its timestamp
// _flushSent: the null packet (drain request) was sent at the end of the file.
// _endOfStream: the decoder returned EOF, or nothing more after the drain request.
private bool _flushSent;
private bool _endOfStream;
/// <summary>File path the reader was opened with.</summary>
public string Path { get; }
/// <inheritdoc/>
public int Channels { get; }
/// <inheritdoc/>
public int SampleRate { get; }
/// <inheritdoc/>
public long TotalFrames { get; }
/// <summary>False: the reader seeks within the file.</summary>
public bool IsForwardOnly => false;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>True when the stream is MP3 and is decoded with the game's MP3 timing (see class summary).</summary>
public bool IsMP3 { get; }
/// <summary>Number of demuxer seeks performed (for diagnostics).</summary>
public int SeekCount { get; private set; }
/// <summary>
/// Opens <paramref name="path"/>, selects the best audio stream and opens its decoder. For MP3 files the whole file is
/// scanned once to count frames (see the class summary).
/// </summary>
/// <exception cref="InvalidOperationException">The file cannot be opened, has no decodable audio stream, has no duration
/// (non-MP3), or an FFmpeg setup call fails. Everything allocated so far is freed.</exception>
public FFmpegPCMReader(string path)
{
Path = path;
try
{
AVFormatContext* format = null;
var ret = ffmpeg.avformat_open_input(&format, path, null, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio file '{path}': {ErrorString(ret)}");
_format = format;
ret = ffmpeg.avformat_find_stream_info(_format, null);
if (ret < 0) throw new InvalidOperationException($"Failed to read stream info of '{path}': {ErrorString(ret)}");
AVCodec* decoder = null;
_streamIndex = ffmpeg.av_find_best_stream(_format, AVMediaType.AVMEDIA_TYPE_AUDIO, -1, -1, &decoder, 0);
if (_streamIndex < 0 || decoder == null) throw new InvalidOperationException($"No decodable audio stream in '{path}': {ErrorString(_streamIndex)}");
for (var i = 0; i < (int)_format->nb_streams; i++)
{
if (i != _streamIndex) _format->streams[i]->discard = AVDiscard.AVDISCARD_ALL;
}
var stream = _format->streams[_streamIndex];
_timeBase = stream->time_base;
_ptsOrigin = stream->start_time != ffmpeg.AV_NOPTS_VALUE ? stream->start_time : 0;
_codec = ffmpeg.avcodec_alloc_context3(decoder);
if (_codec == null) throw new InvalidOperationException("avcodec_alloc_context3 failed.");
ret = ffmpeg.avcodec_parameters_to_context(_codec, stream->codecpar);
if (ret < 0) throw new InvalidOperationException($"avcodec_parameters_to_context failed: {ErrorString(ret)}");
_codec->pkt_timebase = _timeBase;
IsMP3 = stream->codecpar->codec_id == AVCodecID.AV_CODEC_ID_MP3;
if (IsMP3)
{
// Keep the encoder delay and padding the decoder would otherwise trim (exported as side data instead).
// Reference: https://ffmpeg.org/doxygen/trunk/group__lavc__core.html (AV_CODEC_FLAG2_SKIP_MANUAL)
_codec->flags2 |= ffmpeg.AV_CODEC_FLAG2_SKIP_MANUAL;
}
ret = ffmpeg.avcodec_open2(_codec, decoder, null);
if (ret < 0) throw new InvalidOperationException($"Failed to open audio decoder for '{path}': {ErrorString(ret)}");
Channels = _codec->ch_layout.nb_channels;
SampleRate = _codec->sample_rate;
if (Channels <= 0 || SampleRate <= 0) throw new InvalidOperationException($"Invalid audio format in '{path}': {Channels}ch {SampleRate}Hz.");
if (IsMP3)
{
_leadFrames = MP3HeaderFrame.SilentFrames(path);
ScanPackets(out _ptsOrigin, out var decodedFrames);
TotalFrames = _leadFrames + decodedFrames;
}
else
{
TotalFrames = EstimateTotalFrames(stream);
}
SwrContext* swr = null;
ret = ffmpeg.swr_alloc_set_opts2(&swr,
&_codec->ch_layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, SampleRate,
&_codec->ch_layout, _codec->sample_fmt, SampleRate, 0, null);
if (ret < 0 || swr == null) throw new InvalidOperationException($"swr_alloc_set_opts2 failed: {ErrorString(ret)}");
_swr = swr;
ret = ffmpeg.swr_init(_swr);
if (ret < 0) throw new InvalidOperationException($"swr_init failed: {ErrorString(ret)}");
_packet = ffmpeg.av_packet_alloc();
_frame = ffmpeg.av_frame_alloc();
if (_packet == null || _frame == null) throw new InvalidOperationException("Failed to allocate packet/frame.");
_syncToTimestamp = true;
}
catch
{
Dispose();
throw;
}
}
/// <summary>
/// Reads every packet of the audio stream once (no decoding) and returns the first packet's timestamp and the summed
/// packet durations in frames, then moves the demuxer back to the first packet. Used for MP3, where every packet decodes
/// to a full frame when trimming is off.
/// </summary>
private void ScanPackets(out long firstPts, out long frames)
{
firstPts = ffmpeg.AV_NOPTS_VALUE;
long durationTicks = 0;
var packet = ffmpeg.av_packet_alloc();
try
{
int ret;
while ((ret = ffmpeg.av_read_frame(_format, packet)) >= 0)
{
if (packet->stream_index == _streamIndex)
{
if (firstPts == ffmpeg.AV_NOPTS_VALUE && packet->pts != ffmpeg.AV_NOPTS_VALUE) firstPts = packet->pts;
durationTicks += packet->duration;
}
ffmpeg.av_packet_unref(packet);
}
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_free(&packet);
}
if (firstPts == ffmpeg.AV_NOPTS_VALUE) firstPts = 0;
frames = ffmpeg.av_rescale_q(durationTicks, _timeBase, new AVRational { num = 1, den = SampleRate });
var seek = ffmpeg.av_seek_frame(_format, _streamIndex, firstPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (seek < 0)
throw new InvalidOperationException($"Failed to rewind '{Path}': {ErrorString(seek)}");
}
/// <summary>
/// Frame count from the stream duration, or from the container duration (AV_TIME_BASE units, microseconds) when the stream
/// has none.
/// </summary>
/// <exception cref="InvalidOperationException">Neither duration is known.</exception>
private long EstimateTotalFrames(AVStream* stream)
{
if (stream->duration != ffmpeg.AV_NOPTS_VALUE && stream->duration > 0)
return ffmpeg.av_rescale_q(stream->duration, _timeBase, new AVRational { num = 1, den = SampleRate });
if (_format->duration != ffmpeg.AV_NOPTS_VALUE && _format->duration > 0)
return ffmpeg.av_rescale(_format->duration, SampleRate, ffmpeg.AV_TIME_BASE);
throw new InvalidOperationException($"Audio file '{Path}' has no duration.");
}
/// <summary>
/// Moves to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>). A target within the pending block or up to the seek margin
/// after it is reached by decoding forward; otherwise the demuxer seeks backward (AVSEEK_FLAG_BACKWARD) to target - margin
/// (or to the stream start if that fails) and the decoder is flushed. Decoded samples before the target are discarded by Read.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="frame"/> is outside 0 ~ TotalFrames.</exception>
/// <exception cref="InvalidOperationException">The demuxer cannot seek.</exception>
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame == Position) return;
var marginFrames = (long)(SeekMarginSeconds * SampleRate);
// Target inside the pending block or less than the seek margin after it: decode forward and discard.
if (!_syncToTimestamp && frame >= _pendingStart + _pendingOffset
&& frame <= _pendingStart + _pendingOffset + _pendingCount + marginFrames)
{
Position = frame;
return;
}
// Target timestamp in stream ticks; the lead (silent) frames have no packets, so they are subtracted first.
var targetTs = _ptsOrigin + ffmpeg.av_rescale_q(Math.Max(0, frame - _leadFrames - marginFrames), new AVRational { num = 1, den = SampleRate }, _timeBase);
var ret = ffmpeg.av_seek_frame(_format, _streamIndex, targetTs, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
ret = ffmpeg.av_seek_frame(_format, _streamIndex, _ptsOrigin, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
throw new InvalidOperationException($"Failed to seek '{Path}' to frame {frame}: {ErrorString(ret)}");
ffmpeg.avcodec_flush_buffers(_codec);
SeekCount++;
_pendingCount = 0;
_pendingOffset = 0;
_syncToTimestamp = true;
_flushSent = false;
_endOfStream = false;
Position = frame;
}
/// <inheritdoc/>
/// <remarks>
/// Frames before the first decoded frame (MP3 lead silence) and gaps between decoded timestamps are returned as zeros.
/// Returns fewer frames than requested when decoding ends before <see cref="TotalFrames"/>.
/// </remarks>
/// <exception cref="InvalidOperationException">Reading, decoding or sample conversion fails.</exception>
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
if (_pendingCount == 0 || Position >= _pendingStart + _pendingOffset + _pendingCount)
{
if (!DecodeNextFrame()) break;
continue;
}
var pendingPosition = _pendingStart + _pendingOffset;
if (Position > pendingPosition)
{
// Discard decoded samples before the read position (after a seek).
var skip = (int)Math.Min(Position - pendingPosition, _pendingCount);
_pendingOffset += skip;
_pendingCount -= skip;
continue;
}
if (Position < pendingPosition)
{
// A gap in the decoded timestamps: fill with silence up to the next decoded sample.
var gap = (int)Math.Min(pendingPosition - Position, limit - written);
Array.Clear(buffer, written * Channels, gap * Channels);
written += gap;
Position += gap;
continue;
}
var count = Math.Min(_pendingCount, limit - written);
Array.Copy(_pending, _pendingOffset * Channels, buffer, written * Channels, count * Channels);
_pendingOffset += count;
_pendingCount -= count;
written += count;
Position += count;
}
return written;
}
/// <summary>Decodes the next audio frame into the pending buffer. Returns false at the end of the stream.</summary>
/// <remarks>
/// Send/receive loop: receive a frame; on EAGAIN read and send the next packet of the stream; at the end of the file send a
/// null packet to drain the decoder, then receive until EOF.
/// </remarks>
private bool DecodeNextFrame()
{
while (!_endOfStream)
{
var ret = ffmpeg.avcodec_receive_frame(_codec, _frame);
if (ret == 0)
{
StoreFrame();
ffmpeg.av_frame_unref(_frame);
return true;
}
if (ret == ffmpeg.AVERROR_EOF)
{
_endOfStream = true;
break;
}
if (ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Audio decoding failed in '{Path}': {ErrorString(ret)}");
if (_flushSent)
{
_endOfStream = true;
break;
}
ret = ffmpeg.av_read_frame(_format, _packet);
if (ret < 0)
{
if (ret != ffmpeg.AVERROR_EOF)
throw new InvalidOperationException($"Failed to read '{Path}': {ErrorString(ret)}");
ffmpeg.avcodec_send_packet(_codec, null);
_flushSent = true;
continue;
}
try
{
if (_packet->stream_index != _streamIndex) continue;
ret = ffmpeg.avcodec_send_packet(_codec, _packet);
// A corrupt packet is skipped; decoding continues with the next one.
if (ret < 0 && ret != ffmpeg.AVERROR_INVALIDDATA && ret != ffmpeg.AVERROR(ffmpeg.EAGAIN))
throw new InvalidOperationException($"Failed to send audio packet of '{Path}': {ErrorString(ret)}");
}
finally
{
ffmpeg.av_packet_unref(_packet);
}
}
return false;
}
/// <summary>
/// Converts the decoded frame to interleaved float into the pending buffer and assigns its frame position: from its timestamp
/// for the first frame after open/seek (the current position when it has none), sequential afterwards.
/// </summary>
private void StoreFrame()
{
var samples = _frame->nb_samples;
if (samples <= 0) return;
if (_syncToTimestamp)
{
var pts = _frame->best_effort_timestamp;
_nextDecodedFrame = pts != ffmpeg.AV_NOPTS_VALUE
? _leadFrames + ffmpeg.av_rescale_q(pts - _ptsOrigin, _timeBase, new AVRational { num = 1, den = SampleRate })
: Position;
_syncToTimestamp = false;
}
if (_pending.Length < samples * Channels)
_pending = new float[samples * Channels];
fixed (float* pPending = _pending)
{
var outPlane = (byte*)pPending;
var converted = ffmpeg.swr_convert(_swr, &outPlane, samples, _frame->extended_data, samples);
if (converted < 0)
throw new InvalidOperationException($"Sample format conversion failed in '{Path}': {ErrorString(converted)}");
samples = converted;
}
_pendingStart = _nextDecodedFrame;
_pendingOffset = 0;
_pendingCount = samples;
_nextDecodedFrame += samples;
}
/// <summary>Frees every FFmpeg object and closes the file. Safe to call more than once and on a partly constructed reader.</summary>
public void Dispose()
{
if (_frame != null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_packet != null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_swr != null)
{
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
if (_codec != null)
{
var codec = _codec;
ffmpeg.avcodec_free_context(&codec);
_codec = null;
}
if (_format != null)
{
var format = _format;
ffmpeg.avformat_close_input(&format);
_format = null;
}
}
/// <summary>FFmpeg error text for <paramref name="error"/> (av_strerror) followed by the numeric code.</summary>
private static string ErrorString(int error)
{
var buffer = stackalloc byte[1024];
ffmpeg.av_strerror(error, buffer, 1024);
return $"{Marshal.PtrToStringAnsi((IntPtr)buffer)} ({error})";
}
}
+42
View File
@@ -0,0 +1,42 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sequential reader of decoded PCM at the clip's own sample rate and channel count.
/// Samples are interleaved 32-bit float. Positions and counts are in frames (one frame = one sample per channel).
/// Used by one thread at a time (the store's decoder thread). <see cref="IDisposable.Dispose"/> may be called more than once.
/// </summary>
internal interface IPCMReader : IDisposable
{
/// <summary>Channel count of the decoded audio (1 or more).</summary>
int Channels { get; }
/// <summary>Sample rate of the decoded audio in Hz.</summary>
int SampleRate { get; }
/// <summary>Total frame count of the clip at <see cref="SampleRate"/>.</summary>
long TotalFrames { get; }
/// <summary>
/// True when the data can be read only once, from frame 0 to the end (data is released as it is read).
/// The source then keeps every chunk (<see cref="ChunkPolicy.Resident"/>).
/// </summary>
bool IsForwardOnly { get; }
/// <summary>Frame index that the next <see cref="Read"/> starts at.</summary>
long Position { get; }
/// <summary>
/// Moves the read position to <paramref name="frame"/> (0 ~ <see cref="TotalFrames"/>).
/// A forward-only reader accepts only frames whose data has not been released.
/// </summary>
void Seek(long frame);
/// <summary>
/// Reads up to <paramref name="frameCount"/> frames into <paramref name="buffer"/> starting at index 0 and advances the position.
/// <paramref name="buffer"/> must hold at least frameCount * Channels floats.
/// </summary>
/// <returns>Frames read. 0 at the end of the clip.</returns>
int Read(float[] buffer, int frameCount);
}
+124
View File
@@ -0,0 +1,124 @@
using System;
using System.IO;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Detects the VBR/info header frame ("Xing", "Info" or "VBRI") at the start of an MP3 file.
///
/// The game decodes MP3 files with MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.MP3Stream), which has no header-frame handling:
/// it decodes that frame as one frame of silence and keeps the encoder delay. FFmpeg's MP3 demuxer consumes the header frame
/// without output. Measured with the clip harness (LAME 192k CBR, LAME VBR, 44.1/48 kHz, with and without ID3v2):
/// MP3Sharp output is FFmpeg's untrimmed output preceded by one frame of silence when the header frame exists,
/// and identical in timing when it does not.
///
/// Frame layout used here (ISO/IEC 11172-3 and 13818-3 frame header; Xing/Info tag after the Layer III side information,
/// VBRI tag 32 bytes after the frame header):
/// - Header: 11 sync bits, version (2 bits: 3 = MPEG-1, 2 = MPEG-2, 0 = MPEG-2.5), layer (2 bits: 1 = III, 2 = II, 3 = I),
/// protection bit (0 = 16-bit CRC follows), bitrate index, sample rate index, padding, private, channel mode (3 = mono).
/// - Layer III side information: MPEG-1 32 bytes (17 mono), MPEG-2/2.5 17 bytes (9 mono).
/// - Samples per frame: Layer I 384, Layer II 1152, Layer III 1152 (MPEG-1) or 576 (MPEG-2/2.5).
/// - ID3v2 tags before the first frame: "ID3", version (2 bytes), flags (bit 0x10 = 10-byte footer), size (4 bytes, 7 bits each).
/// </summary>
internal static class MP3HeaderFrame
{
/// <summary>Bytes searched for the first frame sync after the ID3v2 tags.</summary>
private const int SyncSearchBytes = 64 * 1024;
/// <summary>
/// Frames of silence MP3Sharp outputs for the header frame of <paramref name="path"/>:
/// the samples per frame of the first frame when it is a Xing/Info/VBRI header frame, otherwise 0.
/// </summary>
public static int SilentFrames(string path)
{
// The game keeps the song open while it streams it (MP3Sharp.MP3Stream: new FileStream(path, FileMode.Open),
// read/write access with read sharing), so the file is opened with read/write/delete sharing.
using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return SilentFrames(file);
}
/// <summary>
/// <see cref="SilentFrames(string)"/> for a seekable stream positioned anywhere (the stream is read from offset 0).
/// Only the first valid frame header within <see cref="SyncSearchBytes"/> after the ID3v2 tags is examined.
/// </summary>
public static int SilentFrames(Stream stream)
{
// Skip consecutive ID3v2 tags: 10-byte header + syncsafe size (+ 10-byte footer when flagged).
var tag = new byte[10];
long offset = 0;
while (true)
{
stream.Position = offset;
if (ReadFully(stream, tag, 10) < 10 || tag[0] != 'I' || tag[1] != 'D' || tag[2] != '3') break;
var size = (tag[6] & 0x7F) << 21 | (tag[7] & 0x7F) << 14 | (tag[8] & 0x7F) << 7 | (tag[9] & 0x7F);
offset += 10 + size + ((tag[5] & 0x10) != 0 ? 10 : 0);
}
stream.Position = offset;
var data = new byte[SyncSearchBytes];
var length = ReadFully(stream, data, data.Length);
for (var i = 0; i + 4 <= length; i++)
{
if (!TryParseHeader(data, i, out var samplesPerFrame, out var tagOffset)) continue;
// Xing/Info after the side information; VBRI at a fixed offset of 32 bytes after the 4-byte header.
if (HasTag(data, length, i + tagOffset, "Xing") || HasTag(data, length, i + tagOffset, "Info")
|| HasTag(data, length, i + 4 + 32, "VBRI"))
return samplesPerFrame;
return 0;
}
return 0;
}
/// <summary>
/// Parses the 4-byte frame header at <paramref name="i"/>. False for a missing sync or a reserved version, layer, bitrate
/// index (15) or sample rate index (3).
/// </summary>
/// <param name="data">Bytes to parse.</param>
/// <param name="i">Offset of the first header byte in <paramref name="data"/>.</param>
/// <param name="samplesPerFrame">Samples per channel in the frame (384, 576 or 1152).</param>
/// <param name="tagOffset">Offset of a Xing/Info tag from the header start: 4 + CRC (2) + Layer III side information (0 for Layers I/II).</param>
private static bool TryParseHeader(byte[] data, int i, out int samplesPerFrame, out int tagOffset)
{
samplesPerFrame = 0;
tagOffset = 0;
if (data[i] != 0xFF || (data[i + 1] & 0xE0) != 0xE0) return false;
var version = (data[i + 1] >> 3) & 0x3;
var layer = (data[i + 1] >> 1) & 0x3;
var hasCrc = (data[i + 1] & 0x1) == 0;
var bitrateIndex = (data[i + 2] >> 4) & 0xF;
var sampleRateIndex = (data[i + 2] >> 2) & 0x3;
var mono = ((data[i + 3] >> 6) & 0x3) == 3;
if (version == 1 || layer == 0 || bitrateIndex == 0xF || sampleRateIndex == 3) return false;
var mpeg1 = version == 3;
samplesPerFrame = layer switch
{
3 => 384,
2 => 1152,
_ => mpeg1 ? 1152 : 576
};
var sideInfo = layer == 1 ? (mpeg1 ? (mono ? 17 : 32) : (mono ? 9 : 17)) : 0;
tagOffset = 4 + (hasCrc ? 2 : 0) + sideInfo;
return true;
}
/// <summary>True when the ASCII string <paramref name="tag"/> is at <paramref name="at"/> within the first <paramref name="length"/> bytes.</summary>
private static bool HasTag(byte[] data, int length, int at, string tag)
{
if (at < 0 || at + tag.Length > length) return false;
for (var k = 0; k < tag.Length; k++)
if (data[at + k] != tag[k]) return false;
return true;
}
/// <summary>Reads until <paramref name="count"/> bytes or the end of the stream; returns the bytes read.</summary>
private static int ReadFully(Stream stream, byte[] buffer, int count)
{
var total = 0;
int read;
while (total < count && (read = stream.Read(buffer, total, count - total)) > 0) total += read;
return total;
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// <see cref="IPCMReader"/> that reproduces the game's output for mono MP3 files.
///
/// MP3Sharp (Assembly-CSharp-firstpass, MP3Sharp.Buffer16BitStereo) writes every frame in stereo layout and fills only the
/// channel 0 slot for mono streams, while MP3Stream.ChannelCount reports 1. The game creates a 1-channel clip from that data,
/// so a mono MP3 plays as [s0, 0, s1, 0, ...]: twice as long, at half speed and one octave lower
/// (confirmed in game with TestMaps/mp3_mono, 2026-10-13).
///
/// Output: 1 channel at the inner reader's sample rate. Frame 2k is inner frame k, frame 2k + 1 is 0.
/// <see cref="TotalFrames"/> = 2 * inner TotalFrames.
/// </summary>
internal sealed class MP3MonoLayoutReader : IPCMReader
{
private readonly IPCMReader _inner;
/// <summary>Reused buffer for inner frames, grown to the largest request.</summary>
private float[] _innerBuffer = Array.Empty<float>();
/// <param name="inner">Mono reader of the MP3 file with the game's MP3 timing (<see cref="FFmpegPCMReader.IsMP3"/>). Owned by this reader.</param>
public MP3MonoLayoutReader(IPCMReader inner)
{
if (inner.Channels != 1) throw new ArgumentException("Inner reader must be mono.", nameof(inner));
_inner = inner;
Position = inner.Position * 2;
}
/// <summary>Always 1.</summary>
public int Channels => 1;
/// <summary>The inner reader's sample rate (the game plays the doubled data at this rate).</summary>
public int SampleRate => _inner.SampleRate;
/// <summary>Twice the inner frame count.</summary>
public long TotalFrames => _inner.TotalFrames * 2;
/// <inheritdoc/>
public bool IsForwardOnly => _inner.IsForwardOnly;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>Moves to <paramref name="frame"/>; the inner reader moves to the inner frame of the next even position.</summary>
public void Seek(long frame)
{
_inner.Seek((frame + 1) / 2);
Position = frame;
}
/// <inheritdoc/>
public int Read(float[] buffer, int frameCount)
{
var limit = (int)Math.Min(frameCount, TotalFrames - Position);
var written = 0;
while (written < limit)
{
// Odd position: the zero slot after an inner sample (also the first frame after a seek to an odd frame).
if ((Position & 1) == 1)
{
buffer[written++] = 0f;
Position++;
continue;
}
// Even position: read enough inner frames for the remaining output, then interleave each with a zero.
var want = (limit - written + 1) / 2;
if (_innerBuffer.Length < want) _innerBuffer = new float[want];
var got = _inner.Read(_innerBuffer, want);
if (got == 0) break;
for (var i = 0; i < got && written < limit; i++)
{
buffer[written++] = _innerBuffer[i];
Position++;
if (written >= limit) break;
buffer[written++] = 0f;
Position++;
}
}
return written;
}
/// <summary>Disposes the inner reader.</summary>
public void Dispose() => _inner.Dispose();
}
+108
View File
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Forward-only <see cref="IPCMReader"/> over PCM held in memory as a list of blocks.
/// Block b holds frames [b * FramesPerBlock, (b + 1) * FramesPerBlock) interleaved; the last block may be shorter.
/// Each block is released (passed to the release callback and dropped) as soon as the read position passes its end,
/// and all remaining blocks are released on <see cref="Dispose"/>.
/// </summary>
internal sealed class MemoryPCMReader : IPCMReader
{
/// <summary>Block arrays; an entry is set to null when the block is released.</summary>
private readonly float[]?[] _blocks;
/// <summary>Called with each released block (for example to uncount its memory in the store); may be null.</summary>
private readonly Action<float[]>? _onBlockReleased;
private int _releasedCount; // Blocks [0, _releasedCount) are released
/// <inheritdoc/>
public int Channels { get; }
/// <inheritdoc/>
public int SampleRate { get; }
/// <inheritdoc/>
public long TotalFrames { get; }
/// <summary>True: blocks are released as they are read.</summary>
public bool IsForwardOnly => true;
/// <inheritdoc/>
public long Position { get; private set; }
/// <summary>Frames per block (all blocks except the last).</summary>
public int FramesPerBlock { get; }
/// <param name="blocks">Interleaved blocks in frame order; the reader takes ownership of the arrays (the list is copied).</param>
/// <param name="framesPerBlock">Frames in every block except possibly the last (positive).</param>
/// <param name="channels">Channel count (positive).</param>
/// <param name="sampleRate">Sample rate in Hz.</param>
/// <param name="totalFrames">Frame count; the blocks must hold at least this many frames.</param>
/// <param name="onBlockReleased">Called with each block when it is released; may be null.</param>
public MemoryPCMReader(IReadOnlyList<float[]> blocks, int framesPerBlock, int channels, int sampleRate, long totalFrames,
Action<float[]>? onBlockReleased)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (framesPerBlock <= 0) throw new ArgumentOutOfRangeException(nameof(framesPerBlock));
if (totalFrames > (long)blocks.Count * framesPerBlock) throw new ArgumentException("Blocks hold fewer frames than totalFrames.", nameof(blocks));
_blocks = new float[blocks.Count][];
for (var i = 0; i < blocks.Count; i++) _blocks[i] = blocks[i];
_onBlockReleased = onBlockReleased;
FramesPerBlock = framesPerBlock;
Channels = channels;
SampleRate = sampleRate;
TotalFrames = totalFrames;
}
/// <inheritdoc/>
/// <exception cref="InvalidOperationException"><paramref name="frame"/> lies in a block that was already released.</exception>
public void Seek(long frame)
{
if (frame < 0 || frame > TotalFrames) throw new ArgumentOutOfRangeException(nameof(frame));
if (frame < (long)_releasedCount * FramesPerBlock)
throw new InvalidOperationException($"Frame {frame} was already released by the forward-only reader.");
Position = frame;
}
/// <inheritdoc/>
/// <remarks>Blocks whose end the position has passed are released after each copy.</remarks>
public int Read(float[] buffer, int frameCount)
{
var total = (int)Math.Min(frameCount, TotalFrames - Position);
if (total <= 0) return 0;
var written = 0;
while (written < total)
{
var blockIndex = (int)(Position / FramesPerBlock);
var block = _blocks[blockIndex] ?? throw new InvalidOperationException($"Block {blockIndex} was already released.");
var offset = (int)(Position - (long)blockIndex * FramesPerBlock);
var count = Math.Min(total - written, FramesPerBlock - offset);
Array.Copy(block, offset * Channels, buffer, written * Channels, count * Channels);
written += count;
Position += count;
ReleaseBlocksBefore((int)(Position / FramesPerBlock));
}
return written;
}
/// <summary>Releases every block with an index below <paramref name="blockIndex"/> that is not released yet, in order.</summary>
private void ReleaseBlocksBefore(int blockIndex)
{
while (_releasedCount < blockIndex && _releasedCount < _blocks.Length)
{
var block = _blocks[_releasedCount];
_blocks[_releasedCount] = null;
_releasedCount++;
if (block != null) _onBlockReleased?.Invoke(block);
}
}
/// <summary>Releases every remaining block. Safe to call more than once.</summary>
public void Dispose() => ReleaseBlocksBefore(_blocks.Length);
}
+120
View File
@@ -0,0 +1,120 @@
using System;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Sample rate converter for interleaved 32-bit float PCM (libswresample).
/// Input and output have the same channel count; only the sample rate changes.
/// One instance keeps filter state between calls, so consecutive <see cref="Convert"/> calls form one continuous stream.
/// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html
/// </summary>
internal sealed unsafe class PCMResampler : IDisposable
{
/// <summary>Resampler context owned by this instance; null after <see cref="Dispose"/>.</summary>
private SwrContext* _swr;
/// <summary>True after <see cref="Flush"/> until <see cref="Reset"/>.</summary>
private bool _flushed;
/// <summary>Channel count of input and output (interleaved).</summary>
public int Channels { get; }
/// <summary>Input sample rate in Hz.</summary>
public int InputRate { get; }
/// <summary>Output sample rate in Hz.</summary>
public int OutputRate { get; }
/// <summary>Value of the SwrContext "filter_size" option (filter length per phase in taps at a 1:1 ratio).</summary>
public int FilterSize { get; }
/// <summary>Creates and initializes a float-to-float resampler with the default channel layout for <paramref name="channels"/>.</summary>
/// <exception cref="ArgumentOutOfRangeException">A count or rate is not positive.</exception>
/// <exception cref="InvalidOperationException">libswresample cannot allocate or initialize the context.</exception>
public PCMResampler(int channels, int inputRate, int outputRate)
{
if (channels <= 0) throw new ArgumentOutOfRangeException(nameof(channels));
if (inputRate <= 0) throw new ArgumentOutOfRangeException(nameof(inputRate));
if (outputRate <= 0) throw new ArgumentOutOfRangeException(nameof(outputRate));
Channels = channels;
InputRate = inputRate;
OutputRate = outputRate;
AVChannelLayout layout;
ffmpeg.av_channel_layout_default(&layout, channels);
SwrContext* swr = null;
var ret = ffmpeg.swr_alloc_set_opts2(
&swr,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, outputRate,
&layout, AVSampleFormat.AV_SAMPLE_FMT_FLT, inputRate,
0, null);
ffmpeg.av_channel_layout_uninit(&layout);
if (ret < 0 || swr == null)
throw new InvalidOperationException($"swr_alloc_set_opts2 failed ({ret}).");
_swr = swr;
// Falls back to 32, the libswresample default of "filter_size", when the option cannot be read.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/master/libswresample/options.c (filter_size)
long filterSize;
FilterSize = ffmpeg.av_opt_get_int(_swr, "filter_size", 0, &filterSize) >= 0 ? (int)filterSize : 32;
Reset();
}
/// <summary>Discards all buffered input and filter history. The next call starts a new stream.</summary>
public void Reset()
{
// swr_init on an initialized context re-initializes it.
// Reference: https://ffmpeg.org/doxygen/trunk/group__lswr.html (swr_init, swr_close)
var ret = ffmpeg.swr_init(_swr);
if (ret < 0)
throw new InvalidOperationException($"swr_init failed ({ret}).");
_flushed = false;
}
/// <summary>
/// Feeds <paramref name="inputFrames"/> frames and writes up to <paramref name="outputCapacity"/> frames.
/// Input that does not fit in the output is buffered inside the context and returned by later calls.
/// <paramref name="inputFrames"/> may be 0 to take only buffered output.
/// </summary>
/// <returns>Frames written to <paramref name="output"/>.</returns>
public int Convert(float* input, int inputFrames, float* output, int outputCapacity)
{
if (_flushed) throw new InvalidOperationException("Resampler was flushed. Call Reset first.");
// swr_convert treats a null input array as a flush request, so a non-null pointer is passed even for 0 frames.
float dummy = 0;
var inPlane = (byte*)(input != null ? input : &dummy);
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, &inPlane, inputFrames);
if (ret < 0)
throw new InvalidOperationException($"swr_convert failed ({ret}).");
return ret;
}
/// <summary>
/// Ends the input stream and writes up to <paramref name="outputCapacity"/> remaining frames.
/// Call repeatedly until it returns 0. <see cref="Convert"/> is not allowed afterwards until <see cref="Reset"/>.
/// </summary>
public int Flush(float* output, int outputCapacity)
{
_flushed = true;
var outPlane = (byte*)output;
var ret = ffmpeg.swr_convert(_swr, &outPlane, outputCapacity, null, 0);
if (ret < 0)
throw new InvalidOperationException($"swr_convert (flush) failed ({ret}).");
return ret;
}
/// <summary>Frees the context. Safe to call more than once.</summary>
public void Dispose()
{
if (_swr == null) return;
var swr = _swr;
ffmpeg.swr_free(&swr);
_swr = null;
}
}
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary>
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
/// (built-in hit sounds from Resources, official/DLC level audio).
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main Unity thread only, so the whole clip is copied once on the main Unity thread
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
/// </summary>
internal static class UnityClipLoader
{
/// <summary>
/// Copies <paramref name="clip"/> in blocks of <see cref="AudioClipSource.ChunkFloats"/> floats or less
/// (FramesPerBlock = ChunkFloats / channels frames per GetData call). Main thread only.
/// Block memory is counted in <paramref name="store"/> and released block by block as the decoder thread reads it.
/// </summary>
public static MemoryPCMReader Load(AudioClip clip, AudioClipStore store)
{
if (!clip) throw new ArgumentNullException(nameof(clip));
if (clip.loadType == AudioClipLoadType.Streaming)
throw new NotSupportedException($"AudioClip '{clip.name}' is a streaming clip; GetData cannot read it.");
// Clips with preloadAudioData off start unloaded; LoadAudioData loads the data synchronously, or asynchronously for
// clips with loadInBackground (GetData below then fails while the data is still loading).
// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.LoadAudioData.html
if (clip.loadState == AudioDataLoadState.Unloaded)
clip.LoadAudioData();
var channels = clip.channels;
var totalFrames = (long)clip.samples;
var framesPerBlock = AudioClipSource.ChunkFloats / channels;
var blocks = new List<float[]>();
try
{
for (long start = 0; start < totalFrames; start += framesPerBlock)
{
var frames = (int)Math.Min(framesPerBlock, totalFrames - start);
var block = store.AllocateExactArray(frames * channels);
blocks.Add(block);
// GetData fills the whole array; the offset is in frames (samples per channel).
if (!clip.GetData(block, (int)start))
throw new InvalidOperationException($"AudioClip.GetData failed for '{clip.name}' at frame {start} (load state {clip.loadState}).");
}
}
catch
{
foreach (var block in blocks) store.FreeExactArray(block);
throw;
}
return new MemoryPCMReader(blocks, framesPerBlock, channels, clip.frequency, totalFrames, store.FreeExactArray);
}
}
+13 -4
View File
@@ -2,22 +2,31 @@ using System;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Constant gain: every sample is multiplied by 10^(<see cref="Level"/> / 20). Stateless and without latency.
/// </summary>
internal class Gain : IAudioFilter
{
public float Level = 0f;
/// <summary>Gain in dB (0 = unchanged, -1 ≈ ×0.891, -6 ≈ ×0.501). Read on every sample, so changes apply immediately.</summary>
public double Level = 0;
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
/// <summary>Returns <paramref name="sample"/> × 10^(<see cref="Level"/> / 20); <paramref name="channel"/> is not used.</summary>
public double ProcessSample(double sample, int channel) =>
sample * Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
/// <inheritdoc/>
public void Reset() { }
/// <inheritdoc/>
public double Latency => 0.0;
}
+13 -3
View File
@@ -3,30 +3,40 @@ using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Hard clipper: limits every sample to [-<see cref="Threshold"/>, +<see cref="Threshold"/>]. Stateless and without latency.
/// In the chain of <see cref="OfflineAudioMixer"/> it catches the peaks the RMS-based limiter lets through before the final gain.
/// </summary>
internal class HardClip : IAudioFilter
{
/// <summary>Clip level as a linear amplitude (1.0 = full scale).</summary>
// ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public double Threshold = 1.0;
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels) { }
/// <summary>Returns <paramref name="sample"/> clamped to ±<see cref="Threshold"/>; <paramref name="channel"/> is not used.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
public double ProcessSample(double sample, int channel)
{
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
/// <inheritdoc/>
public void Reset() { }
/// <inheritdoc/>
public double Latency => 0.0;
}
+22 -2
View File
@@ -2,14 +2,34 @@ using System;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Stage of the output filter chain of <see cref="OfflineAudioMixer"/>. Samples are doubles at nominal full scale ±1.0.
/// A filter keeps its own state between calls, so consecutive blocks form one continuous signal.
/// Used by one thread at a time (the render thread during rendering).
/// </summary>
internal interface IAudioFilter
{
/// <summary>
/// Sets the sample rate (Hz) and interleaved channel count of the signal and allocates per-channel state.
/// Called once before the first sample; the state is cleared as by <see cref="Reset"/>.
/// </summary>
void Initialize(int sampleRate, int channels);
float ProcessSample(float sample, int channel);
void ProcessBlock(Span<float> buffer);
/// <summary>Processes one sample of channel <paramref name="channel"/> (0 ~ channels - 1) and returns the output sample.</summary>
double ProcessSample(double sample, int channel);
/// <summary>
/// Processes <paramref name="buffer"/> in place. The buffer holds interleaved frames starting at channel 0
/// (length = frames * channels of <see cref="Initialize"/>).
/// </summary>
void ProcessBlock(Span<double> buffer);
/// <summary>Clears the internal state (envelopes, delay lines) without changing the settings.</summary>
void Reset();
/// <summary>
/// Delay of the output relative to the input, in seconds (0 for filters without look-ahead).
/// <see cref="OfflineAudioMixer"/> sums the latencies of the chain and compensates them.
/// </summary>
double Latency { get; }
}
+99 -53
View File
@@ -3,91 +3,127 @@ using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter;
/// <summary>
/// Feed-forward RMS compressor with soft knee, attack/release ballistics in the dB domain and optional look-ahead.
/// Each channel is detected and gain-reduced independently (no stereo link).
///
/// Per sample of channel c:
/// 1. Mean square: ms[n] = r * ms[n-1] + (1 - r) * x[n]², r = exp(-1 / (sampleRate * <see cref="RmsWindowTime"/>));
/// level L = 10 * log10(ms + 1e-12) dB (the 1e-12 floor keeps silence finite, about -120 dB).
/// 2. Static curve (gain reduction G ≤ 0 dB, T = <see cref="ThresholdDb"/>, R = <see cref="Ratio"/>, W = <see cref="KneeDb"/>):
/// G = (1/R - 1) * (L - T) above T + W/2; G = (1/R - 1) * (L - T + W/2)² / (2W) inside the knee; 0 below T - W/2.
/// With W = 0 the knee is hard (G = (1/R - 1) * (L - T) above T). R = infinity gives G = T - L (a limiter at T).
/// 3. Ballistics: g[n] = k * g[n-1] + (1 - k) * G, with k = attack coefficient while the reduction grows (G &lt; g[n-1]) and
/// release coefficient otherwise; coefficient = exp(-1 / (sampleRate * time)) (one-pole smoothing, time constant in seconds).
/// 4. Output = x[n - lookAhead] * 10^(g[n] / 20): the gain computed from the current input is applied to the input delayed by
/// <see cref="LookAheadTime"/>, so the reduction starts before the peak reaches the output.
///
/// Properties may be set before <see cref="Initialize"/> (as in an object initializer); Initialize recomputes every
/// coefficient for the actual sample rate and reallocates the delay lines. Not thread-safe.
/// </summary>
// ReSharper disable once InconsistentNaming
internal class RMSCompressor : IAudioFilter
{
private float _thresholdDb = -20f;
private float _ratio = 4f;
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private double _thresholdDb = -20;
private double _ratio = 4;
private double _attackTime = 0.010; // in seconds
private double _releaseTime = 0.100; // in seconds
private double _kneeDb = 5; // in dB
private double _rmsWindowTime = 0.030; // in seconds
private double _lookAheadTime; // in seconds
/// <summary>Look-ahead delay in samples per channel: round(sampleRate * <see cref="LookAheadTime"/>); 0 disables the delay line.</summary>
private int _lookAheadSamples;
/// <summary>Sample rate in Hz; 44100 until <see cref="Initialize"/> sets the actual rate.</summary>
private int _sampleRate = 44100;
/// <summary>Interleaved channel count; 2 until <see cref="Initialize"/> sets the actual count.</summary>
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// One-pole smoothing coefficients (0 ~ 1) of attack, release and the mean-square detector: exp(-1 / (sampleRate * time)).
private double _attackCoef;
private double _releaseCoef;
private double _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private double[] _rmsState = Array.Empty<double>(); // running mean-square (MS) state
private double[] _gainState = Array.Empty<double>(); // smoothed gain reduction in dB
private double[][] _delayBuffers = Array.Empty<double[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
/// <summary>
/// Look-ahead delay in seconds, clamped to 0 ~ 1. Setting it reallocates (and clears) the delay lines.
/// Also reported as <see cref="Latency"/>.
/// </summary>
public double LookAheadTime
{
get => _lookAheadTime;
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
_lookAheadTime = Math.Max(0, Math.Min(1.0, value));
UpdateLookAheadSettings();
}
}
/// <summary>Output delay in seconds: <see cref="LookAheadTime"/>.</summary>
public double Latency => _lookAheadTime;
public float ThresholdDb
/// <summary>Threshold in dB relative to full scale (RMS level of a constant ±1.0 signal = 0 dB).</summary>
public double ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
/// <summary>Compression ratio (input dB above threshold : output dB above threshold), at least 1. Infinity limits at the threshold.</summary>
public double Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
set => _ratio = Math.Max(1, value);
}
public float AttackTime
/// <summary>Attack time constant in seconds, at least 0.0001 (smaller values, including 0, are raised to it).</summary>
public double AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
_attackTime = Math.Max(0.0001, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
/// <summary>Release time constant in seconds, at least 0.0001.</summary>
public double ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
_releaseTime = Math.Max(0.0001, value);
UpdateReleaseCoef();
}
}
public float KneeDb
/// <summary>Total knee width in dB, centered on the threshold, at least 0 (0 = hard knee).</summary>
public double KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
set => _kneeDb = Math.Max(0, value);
}
public float RmsWindowTime
/// <summary>Time constant of the mean-square detector in seconds, at least 0.0001.</summary>
public double RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
_rmsWindowTime = Math.Max(0.0001, value);
UpdateRmsCoef();
}
}
/// <inheritdoc/>
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
@@ -98,8 +134,8 @@ internal class RMSCompressor : IAudioFilter
UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
_rmsState = new double[channels];
_gainState = new double[channels];
Reset();
}
@@ -110,37 +146,42 @@ internal class RMSCompressor : IAudioFilter
ReallocateDelayBuffers();
}
/// <summary>Allocates one zeroed circular buffer of <see cref="_lookAheadSamples"/> samples per channel.</summary>
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayBuffers = new double[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayBuffers[i] = _lookAheadSamples > 0 ? new double[_lookAheadSamples] : Array.Empty<double>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
_attackCoef = Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
_releaseCoef = Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
_rmsCoef = Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
/// <summary>
/// Processes one sample of <paramref name="channel"/> (see the class summary). A channel outside 0 ~ channels - 1 is
/// returned unchanged and does not touch any state.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
public double ProcessSample(double sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
@@ -149,54 +190,57 @@ internal class RMSCompressor : IAudioFilter
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
double sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0 - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
double rmsSq = Math.Max(0, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
double rmsDb = 10.0 * Math.Log10(rmsSq + 1e-12);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
double gStatic = 0;
if (_kneeDb > 0)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
if (rmsDb > _thresholdDb + _kneeDb * 0.5)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
// Above the knee: full ratio.
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
// Inside the knee: quadratic transition from 0 dB at T - W/2 to the full-ratio line at T + W/2.
double diff = rmsDb - _thresholdDb + _kneeDb * 0.5;
gStatic = (1 / _ratio - 1) * diff * diff / (2 * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
gStatic = (1 / _ratio - 1) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
double lastGain = _gainState[channel];
double targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
double coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
double currentGain = coef * lastGain + (1 - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
double linearGain = Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
// Circular delay line: read the sample written _lookAheadSamples calls ago, then overwrite it with the current one.
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
double delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
@@ -205,7 +249,8 @@ internal class RMSCompressor : IAudioFilter
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
/// <inheritdoc/>
public void ProcessBlock(Span<double> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
@@ -214,6 +259,7 @@ internal class RMSCompressor : IAudioFilter
}
}
/// <summary>Clears the detector, the gain envelope and the delay lines (the delay outputs zeros for the next look-ahead samples).</summary>
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
+199
View File
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary>
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one sound effect), created by <see cref="VoiceMixer.Play"/>.
///
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample floor(time * OutputRate) (<see cref="VoiceMixer.ToSample"/>).
///
/// Playback model (output samples n, clip positions in frames of the source at the output sample rate):
/// - The voice is silent before its start sample and from its end sample on.
/// - Clip position at sample n = anchor position + (n - anchor sample) * pitch. A position or pitch change sets a new anchor
/// at the sample where it takes effect, so a pitch change keeps the position continuous.
/// - Looping: the position wraps at the clip length (<see cref="AudioClipSource.Duration"/> * output rate, fractional).
/// Not looping: the voice ends when the position reaches <see cref="AudioClipSource.TotalFrames"/>.
/// </summary>
internal sealed class Voice
{
/// <summary>Kind of a queued <see cref="Change"/>; the meaning of <see cref="Change.Value"/> depends on it.</summary>
internal enum ChangeKind
{
/// <summary>Value: playback rate, at least 0.</summary>
Pitch,
/// <summary>Value: linear gain.</summary>
Volume,
/// <summary>Value: 1 = loop on, 0 = loop off.</summary>
Loop,
/// <summary>Value: clip position in seconds, at least 0.</summary>
Position,
/// <summary>Value: end time in seconds relative to the render start.</summary>
EndTime,
/// <summary>Value: unused (0). Ends playback at the sample the change is applied at.</summary>
Stop
}
/// <summary>One timed change of a voice, queued by the main Unity thread and applied by the render thread.</summary>
internal readonly struct Change
{
public readonly ChangeKind Kind;
/// <summary>Output sample at which the change takes effect (<see cref="VoiceMixer.ToSample"/> of the change time).</summary>
public readonly long Sample;
/// <summary>Argument of the change (see <see cref="ChangeKind"/>).</summary>
public readonly double Value;
public Change(ChangeKind kind, long sample, double value)
{
Kind = kind;
Sample = sample;
Value = value;
}
}
/// <summary>Mixer that owns this voice; the Set methods queue their changes on it.</summary>
private readonly VoiceMixer _mixer;
// ── Render thread state ──
/// <summary>Changes not yet applied, in time order. Render thread only.</summary>
internal readonly Queue<Change> Pending = new();
/// <summary>First output sample of playback.</summary>
internal long StartSample;
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
internal long EndSample = long.MaxValue;
/// <summary>Output sample of the current anchor (see the class summary).</summary>
internal long AnchorSample;
/// <summary>Clip position at <see cref="AnchorSample"/>, in frames at the output sample rate (may be fractional).</summary>
internal double AnchorPosition;
/// <summary>Clip frames advanced per output sample (1 = original speed), at least 0.</summary>
internal double Pitch;
/// <summary>Linear gain applied to the clip samples.</summary>
internal double Volume;
/// <summary>True while the clip repeats at <see cref="Period"/>.</summary>
internal bool Loop;
/// <summary>Set by the render thread when playback has ended; the voice is then released and removed from the mixer.</summary>
internal bool Finished;
/// <summary>
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
/// the value can lag behind the render thread.
/// </summary>
public bool IsFinished => Volatile.Read(ref Finished);
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
internal double Period;
/// <summary>Read position registered on <see cref="Source"/> so the decoder thread keeps the chunks ahead loaded; null without a usable source.</summary>
internal AudioCursor? Cursor;
/// <summary>Chunk of <see cref="Source"/> currently held for reading (invalid when none). Render thread only.</summary>
internal ChunkView View;
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
{
_mixer = mixer;
Source = source;
Name = name;
}
/// <summary>Clip played by this voice; null plays silence (the clip could not be loaded).</summary>
public AudioClipSource? Source { get; }
/// <summary>Name for logs (the game's clip key).</summary>
public string Name { get; }
/// <summary>Sets the playback rate (1 = original speed). Negative values are treated as 0.</summary>
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
/// <summary>Sets the linear gain applied to the clip samples.</summary>
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
/// <summary>Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.</summary>
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
/// <summary>
/// Moves playback to <paramref name="clipSeconds"/> seconds into the clip.
/// Before the voice starts, this sets the position playback starts from.
/// </summary>
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
/// <summary>Stops playback at <paramref name="endTime"/> (seconds relative to the render start).</summary>
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
/// <summary>Stops playback at <paramref name="time"/>.</summary>
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
/// <summary>Unwrapped clip position at output sample <paramref name="sample"/> (sample at or after the anchor).</summary>
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
/// <summary>Clip position at <paramref name="sample"/>, wrapped into the loop when looping.</summary>
internal double WrappedPositionAt(long sample)
{
var position = PositionAt(sample);
if (Loop && Period > 0 && position >= Period) position %= Period;
return position;
}
/// <summary>
/// Applies <paramref name="change"/> at output sample <paramref name="sample"/> (its own sample, or a later one when the change
/// arrived after its sample was mixed). Before the start sample, changes replace the starting state. Render thread only.
/// </summary>
internal void Apply(in Change change, long sample, int outputRate)
{
// Anchor changes made before the start take effect at the start sample (playback starts from the new state).
// EndTime uses the change's own value; Stop ends at the sample it is applied at, so a Stop before the start
// ends the voice before it plays.
var at = Math.Max(sample, StartSample);
switch (change.Kind)
{
case ChangeKind.Pitch:
Reanchor(at);
Pitch = change.Value;
break;
case ChangeKind.Volume:
Volume = change.Value;
break;
case ChangeKind.Loop:
Reanchor(at);
Loop = change.Value != 0.0;
break;
case ChangeKind.Position:
AnchorSample = at;
AnchorPosition = change.Value * outputRate;
break;
case ChangeKind.EndTime:
EndSample = VoiceMixer.ToSample(change.Value, outputRate);
break;
case ChangeKind.Stop:
EndSample = Math.Min(EndSample, sample);
break;
}
}
/// <summary>Moves the anchor to <paramref name="sample"/>, keeping the (wrapped) position there, before a pitch or loop change.</summary>
private void Reanchor(long sample)
{
AnchorPosition = WrappedPositionAt(sample);
AnchorSample = sample;
}
}
+376
View File
@@ -0,0 +1,376 @@
using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary>
/// Mixes every <see cref="Voice"/> into interleaved double samples at the output sample rate and channel count.
///
/// Time: output sample n is at n / OutputRate seconds relative to the render start (the same time base as the times passed
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample floor(t * OutputRate) (<see cref="ToSample"/>).
///
/// Threads:
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
/// non-decreasing time order per voice.
/// - Render thread: <see cref="Mix"/> takes the queued commands and mixes a block of samples. A command takes effect at its sample;
/// a command whose sample is before the block being mixed takes effect at the start of that block.
/// Chunks are read through <see cref="AudioClipStore.Acquire"/>, which waits for the decoder thread.
///
/// Sample reading: clip positions are in frames of the source at the output rate (sources are resampled when loaded).
/// An integer position copies the stored sample; a fractional position interpolates linearly between the frame and the next one
/// (the next frame of a looping voice after the last frame is frame 0; after the end of a non-looping clip it is 0).
/// Channel mapping follows <see cref="ChunkView.GetSample"/>.
/// </summary>
internal sealed class VoiceMixer : IDisposable
{
/// <summary>Kind of a queued main-thread command.</summary>
private enum CommandKind
{
/// <summary>Adds a new voice to the mixed set (created by <see cref="VoiceMixer.Play"/>).</summary>
Play,
/// <summary>Appends a <see cref="Voice.Change"/> to the voice's pending queue.</summary>
Change
}
/// <summary>Command queued by the main Unity thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
private readonly struct Command
{
public readonly CommandKind Kind;
public readonly Voice Voice;
/// <summary>The change for <see cref="CommandKind.Change"/>; default for <see cref="CommandKind.Play"/>.</summary>
public readonly Voice.Change Change;
public Command(CommandKind kind, Voice voice, Voice.Change change)
{
Kind = kind;
Voice = voice;
Change = change;
}
}
private readonly AudioClipStore _store;
private readonly CancellationToken _cancellationToken;
// Double-buffered command lists: the main Unity thread appends to _commands under _commandLock; the render thread swaps the
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
private readonly object _commandLock = new();
private List<Command> _commands = new();
private List<Command> _takenCommands = new();
// Render thread only.
private readonly List<Voice> _voices = new();
private bool _disposed;
/// <param name="store">Store the voices' sources belong to; its OutputRate is the mixer's sample rate.</param>
/// <param name="outputChannels">Interleaved channel count of the mixed output.</param>
/// <param name="cancellationToken">Cancels waits for chunks in <see cref="Mix"/>.</param>
public VoiceMixer(AudioClipStore store, int outputChannels, CancellationToken cancellationToken = default)
{
if (outputChannels < 1) throw new ArgumentOutOfRangeException(nameof(outputChannels));
_store = store;
OutputRate = store.OutputRate;
OutputChannels = outputChannels;
_cancellationToken = cancellationToken;
}
/// <summary>Output sample rate in Hz (the store's <see cref="AudioClipStore.OutputRate"/>).</summary>
public int OutputRate { get; }
/// <summary>Interleaved channel count of the mixed output (1 or more).</summary>
public int OutputChannels { get; }
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
public int ActiveVoiceCount => _voices.Count;
/// <summary>
/// Output sample containing <paramref name="seconds"/> (seconds × rate rounded down), the sample at which
/// AudioSource.PlayScheduled starts a sound scheduled between two output samples.
/// </summary>
public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate);
// ── Main thread ──
/// <summary>
/// Schedules <paramref name="source"/> to start at <paramref name="startTime"/>.
/// A start time before <paramref name="time"/> (the current time) starts the voice at <paramref name="time"/>.
/// </summary>
/// <param name="source">Clip to play; null plays silence.</param>
/// <param name="name">Name for logs.</param>
/// <param name="startTime">Start of playback, seconds relative to the render start.</param>
/// <param name="volume">Linear gain.</param>
/// <param name="pitch">Playback rate (1 = original speed); negative values are treated as 0.</param>
/// <param name="loop">Repeats the clip until the voice is stopped.</param>
/// <param name="time">Current time, seconds relative to the render start.</param>
public Voice Play(AudioClipSource? source, string name, double startTime, double volume, double pitch, bool loop, double time)
{
var voice = new Voice(this, source, name)
{
StartSample = Math.Max(ToSample(startTime, OutputRate), ToSample(time, OutputRate)),
Volume = volume,
Pitch = Math.Max(0.0, pitch),
Loop = loop,
Period = source != null ? source.Duration * OutputRate : 0.0
};
voice.AnchorSample = voice.StartSample;
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Play, voice, default));
}
return voice;
}
/// <summary>
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
/// Called by the Voice Set methods on the main Unity thread.
/// </summary>
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
{
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Change, voice, change));
}
}
// ── Render thread ──
/// <summary>
/// Adds the mix of output samples [<paramref name="firstSample"/>, firstSample + frames) to <paramref name="destination"/>
/// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order.
/// </summary>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (see <see cref="AudioClipStore.Acquire"/>).</exception>
/// <exception cref="OperationCanceledException">The cancellation token was canceled while waiting for a chunk.</exception>
public void Mix(long firstSample, Span<double> destination)
{
if (_disposed) throw new ObjectDisposedException(nameof(VoiceMixer));
var frames = destination.Length / OutputChannels;
var endSample = firstSample + frames;
TakeCommands();
// Iterated backwards so finished voices can be removed in place.
for (var i = _voices.Count - 1; i >= 0; i--)
{
var voice = _voices[i];
var sample = firstSample;
// Split the block into segments at the samples of pending changes: each segment is mixed with constant
// voice state, and a change is applied when the segment reaches its sample (or at once when its sample is past).
while (!voice.Finished)
{
var segmentEnd = endSample;
if (voice.Pending.Count > 0)
{
var next = voice.Pending.Peek();
if (next.Sample <= sample)
{
voice.Apply(voice.Pending.Dequeue(), sample, OutputRate);
continue;
}
segmentEnd = Math.Min(segmentEnd, next.Sample);
}
if (segmentEnd > sample)
MixSegment(voice, sample, segmentEnd, destination.Slice((int)(sample - firstSample) * OutputChannels));
sample = segmentEnd;
if (sample >= endSample) break;
}
// The end sample is inside or before this block: nothing more will be mixed. Changes still pending for this voice
// (after the end) are dropped with it.
if (!voice.Finished && endSample >= voice.EndSample)
voice.Finished = true;
if (voice.Finished)
{
ReleaseVoice(voice);
_voices.RemoveAt(i);
}
}
}
/// <summary>
/// Takes every queued command: Play adds the voice (with a cursor at frame 0 when its source is usable), Change appends to
/// the voice's pending queue (dropped for finished voices). Render thread only.
/// </summary>
private void TakeCommands()
{
lock (_commandLock)
{
(_commands, _takenCommands) = (_takenCommands, _commands);
}
foreach (var command in _takenCommands)
{
var voice = command.Voice;
if (command.Kind == CommandKind.Play)
{
if (voice.Source is { IsFailed: false } source)
voice.Cursor = source.AddCursor(0, voice.Loop);
_voices.Add(voice);
}
else if (!voice.Finished)
{
voice.Pending.Enqueue(command.Change);
}
}
_takenCommands.Clear();
}
/// <summary>Mixes <paramref name="voice"/> over samples [from, to) with its current state into <paramref name="destination"/> (starting at sample from).</summary>
private void MixSegment(Voice voice, long from, long to, Span<double> destination)
{
var start = Math.Max(from, voice.StartSample);
var end = Math.Min(to, voice.EndSample);
if (start >= end) return;
// A voice whose source is missing or failed is silent; it finishes at its first segment inside [start, end).
var source = voice.Source;
if (source == null || source.IsFailed)
{
voice.Finished = true;
return;
}
var totalFrames = source.TotalFrames;
if (voice.Volume == 0.0)
{
// Nothing to add; a non-looping voice still ends at the end of the clip.
if (!voice.Loop && voice.PositionAt(end - 1) >= totalFrames) voice.Finished = true;
return;
}
var channels = OutputChannels;
var volume = voice.Volume;
var offset = (int)(start - from) * channels;
// Move the cursor to the segment start so the decoder thread keeps the chunks ahead of it loaded.
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
for (var n = start; n < end; n++, offset += channels)
{
var position = voice.WrappedPositionAt(n);
if (position >= totalFrames)
{
if (!voice.Loop)
{
voice.Finished = true;
return;
}
// Looping: a wrapped position is below Period, and Period (Duration * rate) is at most TotalFrames
// (TotalFrames rounds up), so this is a guard that reads the last frame.
position = totalFrames - 1;
}
var index = (long)position;
var fraction = position - index;
if (!EnsureView(voice, index)) return;
var view = voice.View;
// Integer position (pitch 1 with an integer anchor): copy the stored sample.
if (fraction == 0.0)
{
for (var c = 0; c < channels; c++)
destination[offset + c] += view.GetSample(index, c, channels) * volume;
continue;
}
// Linear interpolation; the next frame may lie in the following chunk, at the loop start or past the end.
var nextIndex = index + 1;
var nextInView = view.Contains(nextIndex);
for (var c = 0; c < channels; c++)
{
double low = view.GetSample(index, c, channels);
double high = nextInView ? view.GetSample(nextIndex, c, channels) : ReadOutside(voice, nextIndex, c);
destination[offset + c] += (low + fraction * (high - low)) * volume;
}
}
}
/// <summary>Sample of frame <paramref name="frame"/> outside the voice's current view (next chunk, loop start, or past the end).</summary>
private double ReadOutside(Voice voice, long frame, int channel)
{
var source = voice.Source!;
if (frame >= source.TotalFrames)
{
if (!voice.Loop) return 0.0;
frame = 0;
}
var view = _store.Acquire(source, frame, _cancellationToken);
if (!view.IsValid) return 0.0;
try
{
return view.GetSample(frame, channel, OutputChannels);
}
finally
{
source.Release(view);
}
}
/// <summary>Makes voice.View the chunk containing <paramref name="frame"/>. False when the source cannot provide it (voice finished).</summary>
private bool EnsureView(Voice voice, long frame)
{
if (voice.View.IsValid && voice.View.Contains(frame)) return true;
var source = voice.Source!;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
UpdateCursor(voice, frame);
voice.View = _store.Acquire(source, frame, _cancellationToken);
if (voice.View.IsValid) return true;
voice.Finished = true;
return false;
}
/// <summary>Moves the voice's cursor to <paramref name="frame"/> and copies the voice's loop flag (writes only on change).</summary>
private static void UpdateCursor(Voice voice, long frame)
{
var cursor = voice.Cursor;
if (cursor == null) return;
if (cursor.Loop != voice.Loop) cursor.Loop = voice.Loop;
if (cursor.Position != frame) cursor.Position = frame;
}
/// <summary>Releases the voice's held chunk and cursor and drops its pending changes. Render thread only.</summary>
private static void ReleaseVoice(Voice voice)
{
var source = voice.Source;
if (source == null) return;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
if (voice.Cursor != null)
{
source.RemoveCursor(voice.Cursor);
voice.Cursor = null;
}
voice.Pending.Clear();
}
/// <summary>Releases every voice's chunk and cursor. Render thread (or after the render thread stopped).</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var voice in _voices)
ReleaseVoice(voice);
_voices.Clear();
lock (_commandLock)
{
_commands.Clear();
}
}
}
+364 -350
View File
@@ -1,416 +1,430 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
using ADOFAIRenderer.Util.Audio.Filter;
using ADOFAIRenderer.Util.Audio.Mixer;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
/// <summary>
/// Audio of one render: the song and sound effects as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
/// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain).
///
/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate.
/// The limiter delays its output by its look-ahead (<see cref="IAudioFilter.Latency"/>), so output sample n is mixed from
/// voices at sample n + latency, and the filters are primed with samples [0, latency) before the first block.
///
/// Threads:
/// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main Unity thread has committed the end of the samples it mixes,
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main Unity thread stops.
/// </summary>
internal sealed class OfflineAudioMixer : IDisposable
{
private readonly Dictionary<string, CachedClipData> _cache = new();
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
private const double ClipVolume = 0.5;
public class CachedClipData
{
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
/// <summary>Additional gain of sound effects (registered with <see cref="RegisterSoundEvent"/>).</summary>
private const double EffectVolume = 2.0 / 3.0;
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
/// <summary>
/// Smallest number of tracked effect voices at which finished ones are removed from the list used by <see cref="StopAllEffects"/>.
/// After each removal the next threshold is twice the remaining count (at least this value).
/// </summary>
private const int EffectVoicePruneCount = 1024;
lock (_cache)
{
if (_cache.TryGetValue(clipName, out var cached))
return cached;
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
private const int CommitWaitMs = 50;
if (!allowLoad) return null;
/// <summary>Output channel count (interleaved).</summary>
private readonly int _channels;
if (!AudioManager.Instance) return null;
/// <summary>Output sample rate in Hz.</summary>
private readonly int _sampleRate;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
private readonly AudioClipStore _store;
private readonly VoiceMixer _mixer;
try
{
var totalSamples = clip.samples;
var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
/// <summary>Canceled by <see cref="Cancel"/>/<see cref="Dispose"/>; ends commit waits and chunk waits on the render thread.</summary>
private readonly CancellationTokenSource _cancellation = new();
// Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
/// <summary>Filter chain applied in list order to every mixed block.</summary>
private readonly List<IAudioFilter> _filters = new();
var data = new CachedClipData
{
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
/// <summary>Sum of the filter latencies in output samples: round(sum of <see cref="IAudioFilter.Latency"/> * sample rate).</summary>
private readonly int _latencySamples;
_cache[clipName] = data;
return data;
}
catch (Exception e)
{
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
}
}
// Main thread.
public void Clear()
{
lock (_cache)
{
_cache.Clear();
}
}
}
/// <summary>
/// Mapping of game AudioSources to the state of the sound effect last started on them. Weak keys: a destroyed source
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
/// </summary>
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
internal class OfflineAudioMixer
{
public class SoundEvent
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
}
/// <summary>Effect voices started since the last <see cref="StopAllEffects"/> (finished ones are pruned periodically).</summary>
private readonly List<Voice> _effectVoices = new();
private readonly List<SoundEvent> _activeEvents = new();
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly object _lock = new();
/// <summary>Count of <see cref="_effectVoices"/> at which the next prune runs.</summary>
private int _effectVoicePruneAt = EffectVoicePruneCount;
// Target Audio Settings
private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
// Audio Filter Settings
private readonly LinkedList<IAudioFilter> _filters = new();
/// <summary>Song source, or null when there is no song or it could not be loaded.</summary>
private AudioClipSource? _bgmSource;
// BGM Cached Samples (Pre-resampled)
private float[]? _bgmSamples;
private double _bgmDuration;
/// <summary>audioLib key (AudioClip.name) of the song.</summary>
private string _bgmKey = "";
// Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
/// <summary>Current song voice, created by the first <see cref="UpdateBGM"/> and replaced when the start time changes.</summary>
private Voice? _bgmVoice;
// Values last sent to the song voice (start in seconds relative to the render start); NaN before the first update.
private double _bgmStart = double.NaN;
private double _bgmPitch = double.NaN;
private double _bgmVolume = double.NaN;
// Shared: committed output sample (voices are final before it), guarded by _commitLock.
private readonly object _commitLock = new();
private long _committedSample = long.MinValue;
// Render thread (and Dispose, under _renderLock).
private readonly object _renderLock = new();
/// <summary>Index of the next output sample <see cref="GenerateAudioForFrame"/> writes (frames per channel since the render start).</summary>
private long _nextOutputSample;
/// <summary>True once the filters have been fed samples [0, latency).</summary>
private bool _primed;
/// <summary>Reused interleaved mix buffer, grown to the largest block requested.</summary>
private double[] _mixBuffer = Array.Empty<double>();
private bool _disposed;
/// <summary>
/// Creates the clip store (and starts its decoder thread), the voice mixer and the filter chain.
/// Filter settings: RMS compressor (threshold -10 dB, ratio 2, attack/release 0.1 s, knee 10 dB, RMS window 0.02 s),
/// limiter (RMS compressor with threshold 0 dB, infinite ratio, attack 0 raised to the 0.0001 s minimum, release 0.05 s,
/// hard knee, RMS window 0.02 s, look-ahead 0.01 s), hard clip at ±1.0, then -1 dB gain.
/// </summary>
/// <param name="targetChannels">Output channel count (interleaved).</param>
/// <param name="targetSampleRate">Output sample rate in Hz; every clip is resampled to it.</param>
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
_channels = targetChannels;
_sampleRate = targetSampleRate;
_store = new AudioClipStore(targetSampleRate);
_mixer = new VoiceMixer(_store, targetChannels, _cancellation.Token);
var rmsCompressor = new RMSCompressor
{
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
AttackTime = 0.1, ReleaseTime = 0.1,
KneeDb = 10, RmsWindowTime = 0.02
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var limiter = new RMSCompressor
{
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 0, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
ThresholdDb = 0, Ratio = double.PositiveInfinity,
AttackTime = 0, ReleaseTime = 0.05,
KneeDb = 0, RmsWindowTime = 0.02,
LookAheadTime = 0.01
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
_filters.Add(rmsCompressor);
_filters.Add(limiter);
_filters.Add(new HardClip());
_filters.Add(new Gain { Level = -1 });
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
// Total delay of the chain; only the limiter's look-ahead contributes (0.01 s).
double latency = 0;
foreach (var filter in _filters)
{
filter.Initialize(_sampleRate, _channels);
latency += filter.Latency;
}
_latencySamples = (int)Math.Round(latency * _sampleRate);
_store.Start();
}
/// <summary>Clip store of this render (output rate = mixer sample rate), also read by <see cref="AudioSpectrumGenerator"/>.</summary>
internal AudioClipStore Store => _store;
// ── Main thread ──
/// <summary>Registers the song clip. The song voice is created and kept in sync by <see cref="UpdateBGM"/>.</summary>
public void InitializeBGM(AudioClip? clip)
{
lock (_lock)
if (!clip)
{
if (!clip)
{
_bgmSamples = null;
_bgmDuration = 0;
return;
}
try
{
int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Pre-resample the BGM to the target format
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
_bgmSource = null;
return;
}
_bgmKey = clip!.name;
_bgmSource = AudioClipResolver.GetOrAdd(_store, _bgmKey, clip);
if (_bgmSource == null)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Song clip '{_bgmKey}' could not be loaded, the song will be silent.");
return;
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Song '{_bgmKey}': {_bgmSource.Channels} ch, {_bgmSource.Duration:F2} s, {_bgmSource.Policy}");
}
public void CacheClip(string clipName)
/// <summary>
/// Updates the song voice from the conductor (start time, pitch) and the level volume.
/// A changed start time restarts the song voice at position (now - start) * pitch; pitch and volume changes take effect at <paramref name="now"/>.
/// Start time: dspTimeSong + separate countdown (crotchetAtStart / pitch * adjustedCountdownTicks when separateCountdownTime),
/// the time scrConductor.StartMusicCo schedules the song at.
/// </summary>
/// <param name="now">Current time, seconds relative to the render start.</param>
/// <param name="dspTimeBase">AudioSettings.dspTime at the render start.</param>
public void UpdateBGM(double now, double dspTimeBase)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{
var ev = new SoundEvent
{
ClipName = clipName,
StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
if (_bgmSource == null) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
var start = conductor.dspTimeSong + countdown - dspTimeBase;
// levelData.volume is a percentage (100 = unchanged; scrConductor sets song.volume = volume * 0.01 the same way).
// Without a scnGame instance the level volume is 1.0.
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
var volume = levelVolume * ClipVolume;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
if (_bgmVoice == null || start != _bgmStart)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
// Song position at time t is (t - start) * pitch, also when the start is already past.
_bgmVoice?.Stop(now);
_bgmVoice = _mixer.Play(_bgmSource, _bgmKey, Math.Max(start, now), volume, pitch, false, now);
if (start < now) _bgmVoice.SetPosition((now - start) * pitch, now);
_bgmStart = start;
_bgmPitch = pitch;
_bgmVolume = volume;
return;
}
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = _bgmSamples[idxLow * _targetChannels + c];
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
* ClipVolume;
}
}
if (pitch != _bgmPitch)
{
_bgmVoice.SetPitch(pitch, now);
_bgmPitch = pitch;
}
if (volume != _bgmVolume)
{
_bgmVoice.SetVolume(volume, now);
_bgmVolume = volume;
}
}
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
/// <summary>Prepares the source of the game clip <paramref name="clipName"/> (audioLib key) so its first chunk starts loading.</summary>
public void CacheClip(string clipName)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
GetOrAddSource(clipName);
}
/// <summary>
/// Source of the game clip <paramref name="clipName"/> (audioLib key): an existing source, or one opened through
/// <see cref="AudioClipResolver"/> with the clip from AudioManager.FindOrLoadAudioClip. Null for an empty name or an
/// unreadable clip (the key is then remembered as missing by the store). Main thread only.
/// </summary>
private AudioClipSource? GetOrAddSource(string clipName)
{
if (string.IsNullOrEmpty(clipName)) return null;
if (_store.TryGet(clipName, out var existing)) return existing;
var clip = AudioManager.Instance ? AudioManager.Instance.FindOrLoadAudioClip(clipName) : null;
return AudioClipResolver.GetOrAdd(_store, clipName, clip);
}
/// <summary>
/// Starts a sound effect at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
/// </summary>
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
/// <param name="clipName">audioLib key of the clip. A clip that cannot be loaded gives a silent voice.</param>
/// <param name="startTime">Scheduled start, seconds relative to the render start (a start before <paramref name="now"/> starts at now).</param>
/// <param name="endTime">Loop end, seconds relative to the render start; negative for a sound that plays once.</param>
/// <param name="volume">Game volume (linear); multiplied by the clip and effect gains.</param>
/// <param name="pitch">Playback rate at the start.</param>
/// <param name="now">Current render time in seconds.</param>
/// <returns>State of the new sound, also used by the AudioSource patches.</returns>
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
{
var clipSource = GetOrAddSource(clipName);
var loop = endTime >= 0;
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
if (loop) voice.SetEndTime(endTime, now);
// Amortized pruning: the list is scanned only when it has doubled since the last prune.
if (_effectVoices.Count >= _effectVoicePruneAt)
{
var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null)
{
_activeEvents.RemoveAt(i);
continue;
}
_effectVoices.RemoveAll(v => v.IsFinished);
_effectVoicePruneAt = Math.Max(EffectVoicePruneCount, _effectVoices.Count * 2);
}
_effectVoices.Add(voice);
var isSoundFinished = false;
var totalSamples = clipData.Samples.Length / _targetChannels;
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
var state = new SourceState(voice, Math.Max(startTime, now), pitch, loop, loop ? endTime : double.PositiveInfinity,
clipSource?.Duration ?? 0.0, _sampleRate);
if (source) MapSource(source!, state);
return state;
}
for (var s = 0; s < samplesNeeded; s++)
{
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
/// <summary>
/// Maps <paramref name="source"/> to <paramref name="state"/> (replaces an earlier mapping) and takes the clip length and
/// sample rate from the clip of the source.
/// </summary>
public void MapSource(AudioSource source, SourceState state)
{
state.BindClip(source.clip);
_sourceStates.Remove(source);
_sourceStates.Add(source, state);
}
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{
isSoundFinished = true;
break;
}
/// <summary>State of the sound effect last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
public bool TryGetSourceState(AudioSource? source, out SourceState state)
{
state = null!;
return source != null && _sourceStates.TryGetValue(source, out state);
}
var elapsed = sampleTime - adjustedStartTime;
if (elapsed < 0) continue; // Sound hasn't started yet
/// <summary>
/// Stops every sound effect at <paramref name="now"/>, including ones scheduled to start later
/// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of
/// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing.
/// </summary>
public void StopAllEffects(double now)
{
foreach (var voice in _effectVoices)
voice.Stop(now);
_effectVoices.Clear();
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
}
var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{
if (ev.EndTime < 0) // One-shot
{
isSoundFinished = true;
break;
}
// Looping
soundTime %= clipData.Duration;
}
// Linear interpolation for pitch shifting in the mixer loop
double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
}
}
// Clean up finished sound events (both one-shot and looping)
if (isSoundFinished)
{
_activeEvents.RemoveAt(i);
}
/// <summary>
/// Marks every command with a time before <paramref name="time"/> as queued
/// (called after each frame with the time of the next frame).
/// </summary>
public void Commit(double time)
{
var sample = VoiceMixer.ToSample(time, _sampleRate);
lock (_commitLock)
{
if (sample <= _committedSample) return;
_committedSample = sample;
Monitor.PulseAll(_commitLock);
}
}
}
/// <summary>No more commands will be queued (render ending): mixing no longer waits for commits.</summary>
public void Complete()
{
lock (_commitLock)
{
_committedSample = long.MaxValue;
Monitor.PulseAll(_commitLock);
}
}
/// <summary>
/// Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.
/// May be called from any thread, more than once.
/// </summary>
public void Cancel()
{
_cancellation.Cancel();
Complete();
}
// ── Render thread ──
/// <summary>
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
/// Blocks until the main Unity thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
/// </summary>
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
{
lock (_renderLock)
{
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
// First call: run voice samples [0, latency) through the filters and discard the output, which is the
// zero-filled delay lines. Afterwards output sample n is the filtered voice sample n.
if (!_primed)
{
_primed = true;
if (_latencySamples > 0)
{
var prime = MixBlock(0, _latencySamples);
foreach (var filter in _filters)
filter.ProcessBlock(prime);
}
}
var block = MixBlock(_nextOutputSample + _latencySamples, samplesNeeded);
foreach (var filter in _filters)
filter.ProcessBlock(block);
_nextOutputSample += samplesNeeded;
for (var i = 0; i < block.Length; i++)
mixedBuffer[i] = (float)block[i];
}
}
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main Unity thread committed them.</summary>
private Span<double> MixBlock(long first, int frames)
{
WaitForCommit(first + frames);
var length = frames * _channels;
if (_mixBuffer.Length < length) _mixBuffer = new double[length];
var block = _mixBuffer.AsSpan(0, length);
block.Clear();
_mixer.Mix(first, block);
return block;
}
/// <summary>
/// Waits until the committed sample reaches <paramref name="endSample"/> (exclusive end of the block to mix), checking for
/// cancellation every <see cref="CommitWaitMs"/> ms.
/// </summary>
/// <exception cref="OperationCanceledException">The render was canceled.</exception>
private void WaitForCommit(long endSample)
{
lock (_commitLock)
{
while (_committedSample < endSample)
{
_cancellation.Token.ThrowIfCancellationRequested();
Monitor.Wait(_commitLock, CommitWaitMs);
}
}
_cancellation.Token.ThrowIfCancellationRequested();
}
/// <summary>
/// Cancels waits, then releases the voices, the clip store (decoder thread) and the filter state.
/// Waits for a running <see cref="GenerateAudioForFrame"/> to return.
/// </summary>
public void Dispose()
{
Cancel();
lock (_renderLock)
{
if (_disposed) return;
_disposed = true;
_mixer.Dispose();
_store.Dispose();
foreach (var filter in _filters)
filter.Reset();
}
_sourceStates = new ConditionalWeakTable<AudioSource, SourceState>();
_effectVoices.Clear();
}
}
+179
View File
@@ -0,0 +1,179 @@
using System;
using ADOFAIRenderer.Util.Audio.Mixer;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio;
/// <summary>
/// Playback state of a game AudioSource mapped to an effect <see cref="Voice"/>, as the AudioSource reports it to the game
/// (isPlaying, time, timeSamples, pitch). Main thread only. Times are seconds relative to the render start (the render clock that
/// also drives AudioSettings.dspTime during rendering); clip positions are seconds into the clip.
///
/// Reported values:
/// - Playing: from the scheduled start until Stop(), the scheduled end time, or the natural end of the clip (not looping).
/// Before the start, isPlaying is true and time is the position playback will start from.
/// - time while playing: start position + elapsed time * pitch since the last change, wrapped at the clip length when looping.
/// time is 0 while not playing.
/// - timeSamples while playing: floor(time * clip frequency). While not playing: the last value set through time or timeSamples,
/// reset to 0 by Stop().
/// - pitch: the last finite value set (NaN and infinity are ignored). Playback uses max(0, pitch).
/// Changes made while not playing are stored and do not reach the voice.
/// </summary>
internal sealed class SourceState
{
/// <summary>Start of playback, seconds relative to the render start.</summary>
private readonly double _start;
/// <summary>Render time of the last position or pitch change (at least <see cref="_start"/>), seconds.</summary>
private double _anchorTime;
/// <summary>Unwrapped clip position at <see cref="_anchorTime"/>, seconds into the clip.</summary>
private double _anchorPosition;
/// <summary>Last finite pitch set (reported as is; negative values play as 0).</summary>
private double _pitch;
private readonly bool _loop;
/// <summary>Scheduled end time, seconds relative to the render start; infinity when not set.</summary>
private double _endTime = double.PositiveInfinity;
/// <summary>True after <see cref="Stop"/>; playback does not resume.</summary>
private bool _stopped;
/// <summary>timeSamples reported while not playing: the last value set through time/timeSamples, 0 after Stop.</summary>
private int _offsetSamples;
/// <summary>Clip length in seconds.</summary>
private double _length;
/// <summary>Clip sample rate in Hz (at least 1), used to convert between time and timeSamples.</summary>
private int _frequency;
/// <param name="voice">Voice that plays the sound.</param>
/// <param name="start">Start of playback (seconds relative to the render start, not before the time it was scheduled).</param>
/// <param name="pitch">Pitch at the start.</param>
/// <param name="loop">Repeats the clip.</param>
/// <param name="endTime">Scheduled end time (seconds relative to the render start), or infinity.</param>
/// <param name="length">Clip length in seconds (replaced by the game clip in <see cref="BindClip"/>).</param>
/// <param name="frequency">Clip sample rate for timeSamples (replaced by the game clip in <see cref="BindClip"/>).</param>
public SourceState(Voice voice, double start, double pitch, bool loop, double endTime, double length, int frequency)
{
Voice = voice;
_start = start;
_anchorTime = start;
_pitch = pitch;
_loop = loop;
_endTime = endTime;
_length = length;
_frequency = Math.Max(1, frequency);
}
/// <summary>Voice of the <see cref="VoiceMixer"/> that plays the sound; changes made while playing are queued on it.</summary>
public Voice Voice { get; }
/// <summary>Takes the clip length and sample rate from the clip of the game AudioSource.</summary>
public void BindClip(AudioClip? clip)
{
if (!clip) return;
_length = clip!.length;
_frequency = Math.Max(1, clip.frequency);
}
/// <summary>Scheduled end time (see AudioSource.SetScheduledEndTime), seconds relative to the render start.</summary>
public void SetEndTime(double endTime, double now)
{
if (!IsPlaying(now)) return;
_endTime = endTime;
Voice.SetEndTime(endTime, now);
}
/// <summary>AudioSource.isPlaying at <paramref name="now"/>: not stopped and before both the scheduled end and the natural end.</summary>
public bool IsPlaying(double now) => !_stopped && now < Math.Min(_endTime, NaturalEnd());
/// <summary>AudioSource.time at <paramref name="now"/>: clip position in seconds while playing, otherwise 0.</summary>
public float GetTime(double now) => IsPlaying(now) ? (float)PositionAt(now) : 0f;
/// <summary>AudioSource.timeSamples at <paramref name="now"/>: floor(position * frequency) while playing, otherwise the stored offset.</summary>
public int GetTimeSamples(double now) => IsPlaying(now) ? (int)Math.Floor(PositionAt(now) * _frequency) : _offsetSamples;
/// <summary>AudioSource.pitch: the last finite value set.</summary>
public float GetPitch() => (float)_pitch;
/// <summary>
/// AudioSource.time setter: stores floor(seconds * frequency) as the offset and, while playing, moves the position to
/// <paramref name="seconds"/> (negative values become 0) from <paramref name="now"/> (or from the start when not started yet).
/// </summary>
public void SetTime(float seconds, double now)
{
_offsetSamples = (int)Math.Floor((double)seconds * _frequency);
Seek(seconds, now);
}
/// <summary>AudioSource.timeSamples setter: as <see cref="SetTime"/> with samples / frequency seconds.</summary>
public void SetTimeSamples(int samples, double now)
{
_offsetSamples = samples;
Seek((double)samples / _frequency, now);
}
/// <summary>
/// AudioSource.pitch setter: ignores NaN and infinity. While playing, the position up to <paramref name="now"/> is kept
/// (new anchor) and the voice changes rate at <paramref name="now"/>; otherwise only the reported value changes.
/// </summary>
public void SetPitch(float pitch, double now)
{
if (float.IsNaN(pitch) || float.IsInfinity(pitch)) return;
var playing = IsPlaying(now);
if (playing) Reanchor(now);
_pitch = pitch;
if (playing) Voice.SetPitch(pitch, now);
}
/// <summary>AudioSource.Stop(): stops the voice at <paramref name="now"/> when playing, marks the state stopped and clears the offset.</summary>
public void Stop(double now)
{
if (IsPlaying(now)) Voice.Stop(now);
_stopped = true;
_offsetSamples = 0;
}
/// <summary>Rate used for playback: <see cref="_pitch"/> with negative values treated as 0 (as in <see cref="Voice.SetPitch"/>).</summary>
private double PlaybackPitch => Math.Max(0.0, _pitch);
/// <summary>
/// While playing, sets the clip position to <paramref name="seconds"/> (at least 0) from max(now, start) and queues the same
/// position change on the voice.
/// </summary>
private void Seek(double seconds, double now)
{
if (!IsPlaying(now)) return;
var position = Math.Max(0.0, seconds);
_anchorTime = Math.Max(now, _start);
_anchorPosition = position;
Voice.SetPosition(position, now);
}
/// <summary>Moves the anchor to max(now, start), keeping the current position, before a pitch change.</summary>
private void Reanchor(double now)
{
var time = Math.Max(now, _start);
_anchorPosition = PositionAt(time);
_anchorTime = time;
}
/// <summary>Clip position at <paramref name="now"/> (the start position before the start), wrapped when looping.</summary>
private double PositionAt(double now)
{
var position = _anchorPosition + (Math.Max(now, _anchorTime) - _anchorTime) * PlaybackPitch;
if (_loop && _length > 0 && position >= _length) position %= _length;
return position;
}
/// <summary>Time at which the clip position reaches the clip length (infinity when looping or at pitch 0).</summary>
private double NaturalEnd()
{
if (_loop || PlaybackPitch <= 0) return double.PositiveInfinity;
return _anchorTime + Math.Max(0.0, _length - _anchorPosition) / PlaybackPitch;
}
}
+44 -28
View File
@@ -4,41 +4,57 @@ using System.Resources;
using MelonLoader;
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;
/// <summary>The mod assembly that contains the bundle resources.</summary>
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
{
private static readonly string BundlePlatform;
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
BundlePlatform = Application.platform switch
{
BundlePlatform = Application.platform switch
{
RuntimePlatform.WindowsPlayer => "Win64",
RuntimePlatform.OSXPlayer => "OSX",
_ => "Linux"
};
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
RuntimePlatform.WindowsPlayer => "Win64",
RuntimePlatform.OSXPlayer => "OSX",
_ => "Linux"
};
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
public static AssetBundle GetBundle(string resourcePath)
/// <summary>
/// Loads the bundle stored as manifest resource "ADOFAIRenderer.Bundle.&lt;platform&gt;.&lt;resourcePath&gt;" with
/// AssetBundle.LoadFromStream. The resource stream is disposed when this method returns. The caller owns the returned
/// bundle.
/// Reference: https://docs.unity3d.com/ScriptReference/AssetBundle.LoadFromStream.html
/// </summary>
/// <param name="resourcePath">Bundle name inside the platform folder, for example "fonts" or "shaders".</param>
/// <returns>The loaded bundle, or null when Unity fails to load it.</returns>
/// <exception cref="MissingManifestResourceException">The resource is not embedded in the DLL.</exception>
/// <exception cref="InvalidOperationException">The executing assembly is not available.</exception>
public static AssetBundle GetBundle(string resourcePath)
{
if (ExecutingAssembly != null)
{
if (ExecutingAssembly != null)
var bundlePath = $"ADOFAIRenderer.Bundle.{BundlePlatform}.{resourcePath}";
using var resourceStream = ExecutingAssembly.GetManifestResourceStream(bundlePath);
if (resourceStream != null)
{
var bundlePath = $"ADOFAIRenderer.Bundle.{BundlePlatform}.{resourcePath}";
using var resourceStream = ExecutingAssembly.GetManifestResourceStream(bundlePath);
if (resourceStream != null)
{
return AssetBundle.LoadFromStream(resourceStream);
}
Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
return AssetBundle.LoadFromStream(resourceStream);
}
Melon<RenderMod>.Logger.Error("Executing assembly has not been set properly.");
throw new InvalidOperationException("ExecutingAssembly has not been set.");
Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
}
Melon<RenderMod>.Logger.Error("Executing assembly has not been set properly.");
throw new InvalidOperationException("ExecutingAssembly has not been set.");
}
}
-10
View File
@@ -1,10 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-12
View File
@@ -1,12 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
}
+178 -102
View File
@@ -2,112 +2,188 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
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")]
internal enum EncoderKind
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum EncoderKind
{
Software,
NVENC,
AMF,
QSV,
VideoToolbox
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
// ── H.264 ──
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>CPU encoder library linked into FFmpeg (libx264, libx265, libvpx-vp9, libsvtav1, libaom-av1, libvvenc).</summary>
Software,
// ── H.265 (HEVC) ──
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>NVIDIA NVENC hardware encoder (*_nvenc).</summary>
NVENC,
// ── VP9 ──
public static readonly VideoEncoder VP9_NVENC = new("vp9_nvenc", Codec.VP9, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder VP9_QSV = new("vp9_qsv", Codec.VP9, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>AMD Advanced Media Framework hardware encoder (*_amf).</summary>
AMF,
// ── AV1 ──
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC);
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV);
public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>Intel Quick Sync Video hardware encoder (*_qsv), driven through a QSV hardware device context.</summary>
QSV,
// ── VVC ──
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ReSharper restore MemberCanBePrivate.Global
public static readonly IReadOnlyList<VideoEncoder> All = new[]
{
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265,
VP9_NVENC, VP9_QSV, LibvpxVP9,
AV1_NVENC, AV1_QSV, AV1_AMF, LibSvtAv1, LibAomAv1,
LibVvenc
};
private static readonly Dictionary<string, VideoEncoder> ByName =
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
public string Name { get; }
public Codec Codec { get; }
public GPUVendor Vendor { get; }
public EncoderKind Kind { get; }
public bool IsHardware => Kind != EncoderKind.Software;
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
{
Name = name;
Codec = codec;
Vendor = vendor;
Kind = kind;
}
public static VideoEncoder? FromName(string name)
{
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
}
public bool Equals(VideoEncoder other)
{
if (ReferenceEquals(null, other)) return false;
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
}
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
}
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
}
public static bool operator ==(VideoEncoder left, VideoEncoder right)
{
return Equals(left, right);
}
public static bool operator !=(VideoEncoder left, VideoEncoder right)
{
return !Equals(left, right);
}
public override string ToString() => Name;
}
/// <summary>Apple VideoToolbox hardware encoder (*_videotoolbox, macOS).</summary>
VideoToolbox
}
/// <summary>
/// Known FFmpeg video encoder: FFmpeg encoder name, output codec, GPU vendor and encoder family.
///
/// Instances exist only as the static fields below (the constructor is private); compare them with == or
/// <see cref="Equals(VideoEncoder)"/>, which compare <see cref="Name"/> case-insensitively.
/// <see cref="All"/> lists every encoder, and its order is the fallback order used by
/// GPUUtil.GetPrioritizedEncoders after the encoders of the active GPU vendor.
///
/// An entry does not mean that the encoder exists in the loaded FFmpeg build or works on the current machine:
/// FFmpegNativeEncoder.Start looks each one up with avcodec_find_encoder_by_name and opens it, and skips the
/// encoders that are missing or fail to open.
/// Reference: https://ffmpeg.org/ffmpeg-codecs.html (Video Encoders)
/// </summary>
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
// Each field: FFmpeg encoder name, output codec, GPU vendor whose hardware runs it
// (Unknown for CPU encoders), encoder family.
// ── H.264 ──
/// <summary>NVIDIA NVENC hardware encoder for H.264 (FFmpeg encoder "h264_nvenc").</summary>
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for H.264 (FFmpeg encoder "h264_qsv").</summary>
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for H.264 (FFmpeg encoder "h264_amf").</summary>
public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>Apple VideoToolbox hardware encoder for H.264 (FFmpeg encoder "h264_videotoolbox").</summary>
public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
/// <summary>CPU software encoder for H.264 (FFmpeg encoder "libx264").</summary>
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
// ── H.265 (HEVC) ──
/// <summary>NVIDIA NVENC hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_nvenc").</summary>
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_qsv").</summary>
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_amf").</summary>
public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>Apple VideoToolbox hardware encoder for H.265 (HEVC) (FFmpeg encoder "hevc_videotoolbox").</summary>
public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
/// <summary>CPU software encoder for H.265 (HEVC) (FFmpeg encoder "libx265").</summary>
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
// ── VP9 ──
// libavcodec has no "vp9_nvenc" encoder (NVENC does not encode VP9), so avcodec_find_encoder_by_name returns null
// for this entry and FFmpegNativeEncoder.Start skips it with a "not found" warning.
/// <summary>NVIDIA NVENC entry for VP9 (FFmpeg encoder name "vp9_nvenc"); never found in libavcodec, see above.</summary>
public static readonly VideoEncoder VP9_NVENC = new("vp9_nvenc", Codec.VP9, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for VP9 (FFmpeg encoder "vp9_qsv").</summary>
public static readonly VideoEncoder VP9_QSV = new("vp9_qsv", Codec.VP9, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>CPU software encoder for VP9 (FFmpeg encoder "libvpx-vp9").</summary>
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
// ── AV1 ──
/// <summary>NVIDIA NVENC hardware encoder for AV1 (FFmpeg encoder "av1_nvenc").</summary>
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC);
/// <summary>Intel Quick Sync Video hardware encoder for AV1 (FFmpeg encoder "av1_qsv").</summary>
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV);
/// <summary>AMD AMF hardware encoder for AV1 (FFmpeg encoder "av1_amf").</summary>
public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
/// <summary>CPU software encoder for AV1 (FFmpeg encoder "libsvtav1").</summary>
public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
/// <summary>CPU software encoder for AV1 (FFmpeg encoder "libaom-av1").</summary>
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
// ── VVC ──
/// <summary>CPU software encoder for H.266 (VVC) (FFmpeg encoder "libvvenc").</summary>
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ReSharper restore MemberCanBePrivate.Global
/// <summary>
/// Every known encoder, grouped by codec. Within a codec the order is NVENC → QSV → AMF → VideoToolbox → software
/// (for the families the codec has); GPUUtil.GetPrioritizedEncoders keeps this order for the encoders that do not
/// belong to the active GPU vendor.
/// </summary>
public static readonly IReadOnlyList<VideoEncoder> All = new[]
{
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265,
VP9_NVENC, VP9_QSV, LibvpxVP9,
AV1_NVENC, AV1_QSV, AV1_AMF, LibSvtAv1, LibAomAv1,
LibVvenc
};
/// <summary>Lookup table of <see cref="All"/> by <see cref="Name"/> (case-insensitive), used by <see cref="FromName"/>.</summary>
private static readonly Dictionary<string, VideoEncoder> ByName =
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase);
/// <summary>FFmpeg encoder name passed to avcodec_find_encoder_by_name (e.g. "h264_nvenc", "libx264").</summary>
public string Name { get; }
/// <summary>Video codec of the bitstream the encoder produces.</summary>
public Codec Codec { get; }
/// <summary>GPU vendor whose hardware runs the encoder. <see cref="GPUVendor.Unknown"/> for CPU (software) encoders.</summary>
public GPUVendor Vendor { get; }
/// <summary>Encoder family (software or hardware API).</summary>
public EncoderKind Kind { get; }
/// <summary>True for every family except <see cref="EncoderKind.Software"/>.</summary>
public bool IsHardware => Kind != EncoderKind.Software;
/// <summary>Creates an entry. Used only by the static fields of this class.</summary>
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
{
Name = name;
Codec = codec;
Vendor = vendor;
Kind = kind;
}
/// <summary>
/// Returns the known encoder with the given FFmpeg name (case-insensitive),
/// or null when <paramref name="name"/> is null, empty or not in <see cref="All"/>.
/// </summary>
public static VideoEncoder? FromName(string name)
{
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
}
/// <summary>True when <paramref name="other"/> is the same entry or has the same <see cref="Name"/> (case-insensitive).</summary>
public bool Equals(VideoEncoder other)
{
if (ReferenceEquals(null, other)) return false;
return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
}
/// <summary>True when <paramref name="obj"/> is a <see cref="VideoEncoder"/> equal to this one (see <see cref="Equals(VideoEncoder)"/>).</summary>
public override bool Equals(object? obj)
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other);
}
/// <summary>Case-insensitive hash of <see cref="Name"/>, consistent with <see cref="Equals(VideoEncoder)"/>.</summary>
public override int GetHashCode()
{
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
}
/// <summary>Value equality through <see cref="object.Equals(object, object)"/>; two null operands are equal.</summary>
public static bool operator ==(VideoEncoder left, VideoEncoder right)
{
return Equals(left, right);
}
/// <summary>Negation of the <c>==</c> operator.</summary>
public static bool operator !=(VideoEncoder left, VideoEncoder right)
{
return !Equals(left, right);
}
/// <summary>Returns the FFmpeg encoder name (<see cref="Name"/>).</summary>
public override string ToString() => Name;
}
-517
View File
@@ -1,517 +0,0 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util
{
// ReSharper disable once InconsistentNaming
internal class FFmpegVideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
private static readonly int Result = Shader.PropertyToID("_Result");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int UTex = Shader.PropertyToID("_UTex");
private static readonly int VTex = Shader.PropertyToID("_VTex");
private static readonly int ColorRange = Shader.PropertyToID("ColorRange");
[SuppressMessage("ReSharper", "InconsistentNaming")]
private enum GPUMode
{
None,
YUV420P,
NV12
}
private FFmpegNativeDecoder? _decoder;
// CPU fallback rendering
// ReSharper disable once InconsistentNaming
private Texture2D? _textureRGBA;
// GPU conversion textures
private Texture2D? _textureY;
private Texture2D? _textureU;
private Texture2D? _textureV;
private Texture2D? _textureUV;
// Compute Shader variables
private ComputeShader? _computeShader;
private RenderTexture? _renderTexture;
private int _kernelIndex = -1;
private double _currentFrameTimestamp;
private double _nextFrameTimestamp;
private GPUMode _gpuMode = GPUMode.None;
private RenderTexture? _targetTexture;
private bool _isLooping;
private bool _isInitialized;
private GameObject? _quad;
private Material? _material;
// ReSharper disable once InconsistentNaming
public void Init(string videoPath, RenderTexture? targetTexture, bool loop, Camera? targetCamera, GameObject videoBGGo)
{
CleanUp();
_targetTexture = targetTexture;
_isLooping = loop;
if (_targetTexture == null && targetCamera != null)
{
SetupBackgroundQuad(targetCamera, videoBGGo);
}
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = RenderMod.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
var pixFmt = _decoder.PixelFormat;
var gpuInitSuccess = false;
if (_computeShader != null)
{
switch (pixFmt)
{
case AVPixelFormat.AV_PIX_FMT_NV12:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_NV12ToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureUV = new Texture2D(w / 2, h / 2, TextureFormat.RG16, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureUV);
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
}
catch
{
// ignored
}
break;
case AVPixelFormat.AV_PIX_FMT_YUV420P:
case AVPixelFormat.AV_PIX_FMT_YUVJ420P:
try
{
_kernelIndex = _computeShader.FindKernel("CSMain_YUV420PToRGBA");
_textureY = new Texture2D(w, h, TextureFormat.R8, false);
_textureU = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
_textureV = new Texture2D(w / 2, h / 2, TextureFormat.R8, false);
ConfigureTexture(_textureY);
ConfigureTexture(_textureU);
ConfigureTexture(_textureV);
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
break;
}
}
if (gpuInitSuccess && _computeShader != null)
{
_renderTexture = new RenderTexture(w, h, 0, RenderTextureFormat.ARGB32)
{
enableRandomWrite = true
};
_renderTexture.Create();
}
else
{
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
Melon<RenderMod>.Logger.Warning("[FFmpegVideoBGPlayer] GPU acceleration unsupported or failed to load, falling back to CPU (RGBA).");
}
_currentFrameTimestamp = 0.0;
_nextFrameTimestamp = 0.0;
_isInitialized = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
CleanUp();
}
}
private static void ConfigureTexture(Texture2D tex)
{
tex.wrapMode = TextureWrapMode.Clamp;
tex.filterMode = FilterMode.Bilinear;
}
private void LateUpdate()
{
if (!_isInitialized || _decoder == null) return;
var cond = scrConductor.instance;
if (!cond || !cond.hasSongStarted) return;
var pitch = cond.song.pitch;
var separateCountdownOffset = cond.separateCountdownTime ?
cond.crotchetAtStart * cond.adjustedCountdownTicks : 0.0;
var songTime = (cond.dspTime - cond.dspTimeSong) * pitch - cond.addoffset - separateCountdownOffset;
var vidOffset = scrVfxPlus.instance ? scrVfxPlus.instance.vidOffset : 0.0;
var targetVideoTime = songTime + vidOffset;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}
}
+7
View File
@@ -2,8 +2,15 @@
namespace ADOFAIRenderer.Util;
/// <summary>File system helpers.</summary>
internal static class FileUtil
{
/// <summary>
/// Creates <paramref name="path"/> and its missing parent directories when it does not exist.
/// Exceptions of Directory.CreateDirectory are not caught (for example ArgumentException for an empty path,
/// IOException or UnauthorizedAccessException when the directory cannot be created).
/// </summary>
/// <param name="path">Directory path, absolute or relative to the current directory.</param>
public static void CreateDirectory(string path)
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
+199 -71
View File
@@ -3,6 +3,7 @@ using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
using MelonLoader;
using Unity.Collections;
@@ -12,28 +13,87 @@ using UnityEngine.Rendering;
namespace ADOFAIRenderer.Util
{
/// <summary>
/// GPU capture of rendered frames for the encoder. For each requested frame it optionally converts the RGBA texture to
/// the encoder's YUV 4:2:0 layout with a compute shader (ColorConversion.compute from the mod's asset bundle, kernels
/// CSMain_NV12 / CSMain_YUV420P), then reads the result back asynchronously (AsyncGPUReadback.RequestIntoNativeArray)
/// into a persistent native buffer and adds it to RenderManager.ImageQueue under its video frame index.
/// When the shader or the kernel for the pixel format is unavailable, the RGBA texture is read back unchanged and the
/// encoder converts it (IsColorConverted = false).
/// <para>
/// Each request uses a <see cref="ReadbackRequestState"/> from a pool (16 created up front, more allocated when the pool
/// is empty). A state stays out of the pool from the request until the render thread has sent its frame
/// (RenderManager.SendVideoFrame, then <see cref="RecycleState"/>) or the frame was dropped.
/// </para>
/// <para>
/// Threads: requests and readback completions run on the main Unity thread; <see cref="RecycleState"/> is also called by
/// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in
/// RenderManager.SetupPlay with the pixel format of the opened encoder.
/// </para>
/// </summary>
internal class FrameReader : IDisposable
{
// Property IDs of the compute shader parameters: source texture, output buffer and frame size in pixels.
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int Width = Shader.PropertyToID("Width");
private static readonly int DestinationBuffer = Shader.PropertyToID("DestinationBuffer");
private static readonly int SourceTexture = Shader.PropertyToID("SourceTexture");
/// <summary>Number of readback states created by InitializePool.</summary>
private const int InitialBufferSize = 16;
/// <summary>States that are free for a new request. Guarded by _poolLock.</summary>
private readonly Stack<ReadbackRequestState> _statePool = new();
/// <summary>Every state created by this reader (free, in flight or holding a queued frame), for Dispose. Guarded by _poolLock.</summary>
private readonly List<ReadbackRequestState> _allocatedStates = new();
/// <summary>Lock for _statePool, _allocatedStates, _isInitialized, _isDisposed and ReadbackRequestState.IsInFlight.</summary>
private readonly object _poolLock = new();
/// <summary>True after InitializePool; false again after Dispose (RecycleState then disposes returned states).</summary>
private bool _isInitialized;
/// <summary>Set by Dispose. Completions and recycles that arrive afterwards dispose their state.</summary>
private volatile bool _isDisposed;
private readonly AVPixelFormat _targetFormat;
/// <summary>Color conversion compute shader from the asset bundle (ModAssets.ColorConversionShader); null when not loaded.</summary>
private readonly ComputeShader? _computeShader;
/// <summary>Kernel index for the target pixel format; -1 when the format has no kernel or the kernel was not found.</summary>
private readonly int _kernelIndex = -1;
/// <summary>True when frames are converted to YUV on the GPU (shader and kernel available).</summary>
private readonly bool _useGPUConversion;
/// <summary>Pixel format of the read-back data: the target format with GPU conversion, AV_PIX_FMT_RGBA otherwise.</summary>
private readonly AVPixelFormat _expectedFormat;
/// <summary>
/// Selects the conversion kernel for <paramref name="targetFormat"/> and fills the state pool.
/// </summary>
/// <param name="targetFormat">
/// Pixel format of the encoder input (FFmpegNativeEncoder.SelectedPixelFormat). NV12 and YUV420P use GPU conversion;
/// any other format falls back to RGBA readback.
/// </param>
public FrameReader(AVPixelFormat targetFormat)
{
_targetFormat = targetFormat;
_computeShader = ModAssets.ColorConversionShader;
if (_computeShader)
{
var kernelName = targetFormat switch
{
AVPixelFormat.AV_PIX_FMT_NV12 => "CSMain_NV12",
AVPixelFormat.AV_PIX_FMT_YUV420P => "CSMain_YUV420P",
_ => null
};
if (kernelName != null)
{
try { _kernelIndex = _computeShader.FindKernel(kernelName); }
catch { _kernelIndex = -1; }
}
}
_useGPUConversion = _kernelIndex >= 0;
_expectedFormat = _useGPUConversion ? targetFormat : AVPixelFormat.AV_PIX_FMT_RGBA;
InitializePool();
}
/// <summary>Creates InitialBufferSize states once. Their buffers are allocated on first use (ReadbackRequestState.Setup).</summary>
private void InitializePool()
{
lock (_poolLock)
@@ -50,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)
{
if (!RenderManager.IsCapturingActive) return;
@@ -70,76 +149,54 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true;
}
// Check if GPU color conversion is enabled
bool useGPUConversion = RenderMod.ColorConversionShader;
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
if (useGPUConversion)
{
expectedFmt = _targetFormat;
// Compute Shader only supports NV12 and YUV420P; fallback to CPU for other formats
if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P)
{
useGPUConversion = false;
}
}
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
state.Setup(currentFrame, bufferSize, _useGPUConversion, _expectedFormat, texture.width, texture.height);
if (useGPUConversion && state.GPUBuffer != null && RenderMod.ColorConversionShader)
if (_useGPUConversion && state.GPUBuffer != null && _computeShader)
{
int kernelIndex = -1;
if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12)
{
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_NV12");
}
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
{
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_YUV420P");
}
_computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
_computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
_computeShader.SetInt(Width, texture.width);
_computeShader.SetInt(Height, texture.height);
if (kernelIndex >= 0)
{
RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
RenderMod.ColorConversionShader.SetInt(Width, texture.width);
RenderMod.ColorConversionShader.SetInt(Height, texture.height);
// Thread group counts cover the frame in tiles of 32 x 16 pixels (NV12) or 64 x 16 pixels (YUV420P), rounded up.
// The tile size is the area one thread group of the ColorConversion.compute kernels processes.
var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.Request(
state.GPUBuffer,
state.CachedCallback
);
}
else
{
// If kernel is not found, fallback to standard RGBA texture readback
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
);
}
// The native buffer must not be disposed until the request completes; Dispose skips states that are in flight.
// Reference: https://docs.unity3d.com/ScriptReference/Rendering.AsyncGPUReadback.RequestIntoNativeArray.html
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
);
/*AsyncGPUReadback.Request(
state.GPUBuffer,
state.CachedCallback
);*/
}
else
{
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
ref state.NativeBuffer, texture, 0, state.CachedCallback
);
}
}
/// <summary>
/// Completion of a readback request (through ReadbackRequestState.CachedCallback).
/// <list type="bullet">
/// <item>Reader disposed: the state is disposed.</item>
/// <item>Render aborted: the state is returned to the pool and nothing is queued.</item>
/// <item>Readback error: a black frame (managed buffer) is queued under the same index, so the render thread does not
/// wait forever for the missing index, and the state is returned to the pool.</item>
/// <item>Success: the state's native buffer is queued without copying; the state stays out of the pool until the
/// render thread has encoded the frame.</item>
/// </list>
/// </summary>
internal void OnReadbackComplete(ReadbackRequestState state, AsyncGPUReadbackRequest request)
{
var frame = state.FrameIndex;
@@ -154,7 +211,7 @@ namespace ADOFAIRenderer.Util
}
}
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled)
if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
{
RecycleState(state);
return;
@@ -168,16 +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.");
var byteSize = state.IsGPUConversion
? state.Width * state.Height * 3 / 2
: state.Width * state.Height * 4;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize);
var blackFrame = CreateBlackFrame(state, out var byteSize);
var capFrame = new RenderManager.CapturedFrame
{
ManagedBuffer = blackFrame,
Length = byteSize
ManagedBuffer = blackFrame, Length = byteSize,
IsColorConverted = state.IsGPUConversion
};
PushToQueue(frame, capFrame);
RecycleState(state);
@@ -186,25 +238,60 @@ namespace ADOFAIRenderer.Util
{
unsafe
{
/*
if (state.IsGPUConversion)
{
var data = request.GetData<byte>();
// Copy async request buffer data to persistent NativeBuffer memory
NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length);
}
*/
var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr();
var capFrame = new RenderManager.CapturedFrame
{
NativePointer = ptr,
Length = state.NativeBuffer.Length,
ReadbackState = state
NativePointer = ptr, Length = state.NativeBuffer.Length,
ReadbackState = state, IsColorConverted = state.IsGPUConversion
};
PushToQueue(frame, capFrame);
}
}
}
/// <summary>
/// Creates a black frame in the same format as the frames of this reader, from ArrayPool&lt;byte&gt;.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)
{
lock (_poolLock)
@@ -220,6 +307,10 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>
/// Adds the frame to RenderManager.ImageQueue under <paramref name="frame"/> (replacing a frame with the same index)
/// and wakes the render thread.
/// </summary>
private static void PushToQueue(long frame, RenderManager.CapturedFrame capturedFrame)
{
if (!RenderManager.Instance) return;
@@ -230,6 +321,11 @@ namespace ADOFAIRenderer.Util
RenderManager.Instance.FrameAvailableEvent.Set();
}
/// <summary>
/// Disposes every state that is not in flight and clears the pool. States in flight are disposed when their
/// readback completes. States still referenced by frames in ImageQueue are disposed too, so callers clear the queue
/// first (RenderManager.StopFFmpeg / RestoreRenderEnvironment). Main thread.
/// </summary>
public void Dispose()
{
lock (_poolLock)
@@ -247,20 +343,38 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>
/// One reusable capture slot of <see cref="FrameReader"/>: the persistent native buffer the readback writes into, the
/// ComputeBuffer the conversion kernel writes (GPU conversion only), and the metadata of the frame it currently holds.
/// The buffers are kept between frames and reallocated only when the frame size or conversion mode changes.
/// </summary>
internal class ReadbackRequestState : IDisposable
{
/// <summary>Reader that receives the completion and owns the pool.</summary>
private readonly FrameReader _owner;
/// <summary>Video frame index of the current request; -1 while the state is free.</summary>
public long FrameIndex { get; private set; }
/// <summary>
/// Persistent native buffer receiving the readback: Width x Height x 3 / 2 bytes of YUV 4:2:0 with GPU conversion,
/// Width x Height x 4 bytes of RGBA otherwise. Read by the render thread through CapturedFrame.NativePointer.
/// </summary>
public NativeArray<byte> NativeBuffer;
/// <summary>True between the request and its completion. Written under FrameReader._poolLock.</summary>
public bool IsInFlight { get; internal set; }
/// <summary>Raw ComputeBuffer (4-byte elements) the conversion kernel writes; null without GPU conversion.</summary>
public ComputeBuffer? GPUBuffer;
/// <summary>True when the current frame is converted to YUV on the GPU.</summary>
public bool IsGPUConversion { get; private set; }
/// <summary>Width of the current frame in pixels.</summary>
public int Width { get; private set; }
/// <summary>Height of the current frame in pixels.</summary>
public int Height { get; private set; }
/// <summary>Completion delegate created once per state, so a request does not allocate a new delegate.</summary>
public readonly Action<AsyncGPUReadbackRequest> CachedCallback;
/// <summary>Creates a free state without buffers.</summary>
public ReadbackRequestState(FrameReader owner)
{
_owner = owner;
@@ -268,6 +382,17 @@ namespace ADOFAIRenderer.Util
FrameIndex = -1;
}
/// <summary>
/// Prepares the state for a request: stores the frame metadata and (re)allocates the buffers when their size differs.
/// The native buffer holds width x height x 3 / 2 bytes with GPU conversion, otherwise <paramref name="bufferSize"/>.
/// The ComputeBuffer holds the same number of bytes as 4-byte elements (byte count / 4, rounded down).
/// </summary>
/// <param name="frameIndex">Video frame index of the request.</param>
/// <param name="bufferSize">Readback size in bytes for RGBA readback (width x height x 4).</param>
/// <param name="isGPUConversion">Whether the kernel converts the frame to YUV.</param>
/// <param name="pixelFormat">Pixel format of the read-back data (not used by this method).</param>
/// <param name="width">Frame width in pixels.</param>
/// <param name="height">Frame height in pixels.</param>
public void Setup(long frameIndex, int bufferSize, bool isGPUConversion, AVPixelFormat pixelFormat, int width, int height)
{
FrameIndex = frameIndex;
@@ -298,16 +423,19 @@ namespace ADOFAIRenderer.Util
}
}
/// <summary>Readback completion; forwards to FrameReader.OnReadbackComplete.</summary>
private void OnComplete(AsyncGPUReadbackRequest request)
{
_owner.OnReadbackComplete(this, request);
}
/// <summary>Marks the state as free (FrameIndex -1). The buffers are kept for the next request.</summary>
public void Recycle()
{
FrameIndex = -1;
}
/// <summary>Releases the native buffer and the ComputeBuffer.</summary>
public void Dispose()
{
if (NativeBuffer.IsCreated) NativeBuffer.Dispose();
+156 -113
View File
@@ -2,152 +2,195 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using MelonLoader;
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")]
internal enum GPUVendor
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
Unknown, NVIDIA, AMD, Intel, Apple
}
internal static class GPUUtil
/// <summary>
/// Detection of the active GPU vendor and the NVIDIA driver version, and the encoder try order derived from the vendor.
/// Results are cached for the game session. Main thread (SystemInfo is read on first use).
/// </summary>
internal static class GPUUtil
{
// Device name patterns (SystemInfo.graphicsDeviceName) of the GPU families recognized per vendor. A name that matches
// none of them gives GPUVendor.Unknown (Apple on macOS), which only affects the encoder try order.
// NVIDIA: "NVIDIA GeForce RTX/GTX <number>" with optional SUPER / Ti / Ti SUPER, memory size, "Laptop GPU" and a
// "/PCIe/SSE2" suffix.
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
// Intel: "Intel(R) UHD/HD Graphics <number>" or "Intel(R) Arc(TM) <A-C><number> Graphics", with an optional
// parenthesized suffix.
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// AMD: "AMD Radeon RX <number>" with an optional " XT".
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
@"^AMD Radeon RX [0-9]+(?> XT)?$";
/// <summary>Vendor found by the first GetActiveGPUVendor call.</summary>
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
/// <summary>True once _cachedVendor is set.</summary>
private static bool _hasCachedVendor;
/// <summary>
/// Vendor of the GPU Unity renders with: the device name is matched against the NVIDIA, Intel and AMD patterns in
/// that order; on macOS an unmatched name gives Apple; otherwise Unknown. Logs the device name once.
/// </summary>
private static GPUVendor GetActiveGPUVendor()
{
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
if (_hasCachedVendor) return _cachedVendor;
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+$|Arc\(TM\) [A-C][0-9]+ Graphics)$";
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
@"^AMD Radeon RX [0-9]+(?> XT)?$";
private static GPUVendor _cachedVendor = GPUVendor.Unknown;
private static bool _hasCachedVendor;
private static GPUVendor GetActiveGPUVendor()
{
if (_hasCachedVendor) return _cachedVendor;
var gpuName = SystemInfo.graphicsDeviceName;
Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}");
var gpuName = SystemInfo.graphicsDeviceName;
Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}");
_cachedVendor = GPUVendor.Unknown;
foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
_cachedVendor = GPUVendor.Unknown;
foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
{
_cachedVendor = GPUVendor.NVIDIA;
break;
}
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
{
_cachedVendor = GPUVendor.NVIDIA;
_cachedVendor = GPUVendor.Intel;
break;
}
}
if (_cachedVendor == GPUVendor.Unknown)
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, AMDPattern))
{
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
{
_cachedVendor = GPUVendor.Intel;
break;
}
_cachedVendor = GPUVendor.AMD;
break;
}
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, AMDPattern))
{
_cachedVendor = GPUVendor.AMD;
break;
}
}
if (_cachedVendor == GPUVendor.Unknown && Application.platform == RuntimePlatform.OSXPlayer)
{
_cachedVendor = GPUVendor.Apple;
}
_hasCachedVendor = true;
return _cachedVendor;
}
private static double? _cachedNvidiaVersion;
private static bool _hasCachedNvidiaVersion;
public static double? GetNvidiaDriverVersion()
if (_cachedVendor == GPUVendor.Unknown && Application.platform == RuntimePlatform.OSXPlayer)
{
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
_cachedVendor = GPUVendor.Apple;
}
_hasCachedVendor = true;
return _cachedVendor;
}
_hasCachedNvidiaVersion = true;
_cachedNvidiaVersion = null;
/// <summary>Result of the first GetNvidiaDriverVersion call (null when not available).</summary>
private static double? _cachedNvidiaVersion;
/// <summary>True once _cachedNvidiaVersion is set.</summary>
private static bool _hasCachedNvidiaVersion;
if (GetActiveGPUVendor() != GPUVendor.NVIDIA) return null;
/// <summary>
/// NVIDIA driver version in NVIDIA's numbering (for example 561.09), or null when the active GPU is not detected as
/// NVIDIA or no version can be found. The result is cached.
/// <para>
/// The version is derived from a four-part Windows driver version a.b.c.d as ((c mod 10) x 10000 + d) / 100,
/// for example 31.0.15.6109 gives 561.09. It is read from SystemInfo.graphicsDeviceVersion when that string contains a
/// four-part number, otherwise (Windows only) from the DriverVersion value of the first display adapter entry whose
/// ProviderName contains "NVIDIA" under the display adapter class key of the registry.
/// </para>
/// </summary>
public static double? GetNvidiaDriverVersion()
{
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
// 1. Try parsing from UnityEngine.SystemInfo.graphicsDeviceVersion
var deviceVersion = SystemInfo.graphicsDeviceVersion;
if (!string.IsNullOrEmpty(deviceVersion))
_hasCachedNvidiaVersion = true;
_cachedNvidiaVersion = null;
if (GetActiveGPUVendor() != GPUVendor.NVIDIA) return null;
// 1. Try parsing from UnityEngine.SystemInfo.graphicsDeviceVersion
var deviceVersion = SystemInfo.graphicsDeviceVersion;
if (!string.IsNullOrEmpty(deviceVersion))
{
var match = Regex.Match(deviceVersion, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (match.Success)
{
var match = Regex.Match(deviceVersion, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (match.Success)
if (int.TryParse(match.Groups[3].Value, out var sub) && int.TryParse(match.Groups[4].Value, out int build))
{
if (int.TryParse(match.Groups[3].Value, out var sub) && int.TryParse(match.Groups[4].Value, out int build))
{
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from SystemInfo: {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from SystemInfo: {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
}
}
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
if (subkeys == null) return null;
// 2. Fallback to Windows Registry (only on Windows NT platforms via RegistryUtil)
if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) return null;
// Device class key of display adapters (class GUID {4d36e968-e325-11ce-bfc1-08002be10318}); each numbered subkey is
// one installed adapter driver.
// Reference: https://learn.microsoft.com/en-us/windows-hardware/drivers/install/system-defined-device-setup-classes-available-to-vendors
const string classKey = @"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}";
var subkeys = RegistryUtil.GetHKLMSubKeyNames(classKey);
if (subkeys == null) return null;
foreach (var subkey in subkeys)
{
var subkeyPath = classKey + "\\" + subkey;
var provider = RegistryUtil.GetHKLMValue(subkeyPath, "ProviderName");
if (provider == null || !provider.Contains("NVIDIA")) continue;
foreach (var subkey in subkeys)
{
var subkeyPath = classKey + "\\" + subkey;
var provider = RegistryUtil.GetHKLMValue(subkeyPath, "ProviderName");
if (provider == null || !provider.Contains("NVIDIA")) continue;
var driverVersionStr = RegistryUtil.GetHKLMValue(subkeyPath, "DriverVersion");
if (string.IsNullOrEmpty(driverVersionStr)) continue;
var driverVersionStr = RegistryUtil.GetHKLMValue(subkeyPath, "DriverVersion");
if (string.IsNullOrEmpty(driverVersionStr)) continue;
var regMatch = Regex.Match(driverVersionStr, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (!regMatch.Success) continue;
if (!int.TryParse(regMatch.Groups[3].Value, out var sub) ||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var regMatch = Regex.Match(driverVersionStr, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
if (!regMatch.Success) continue;
if (!int.TryParse(regMatch.Groups[3].Value, out var sub) ||
!int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100f;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
return null;
var nvidiaVerInt = sub % 10 * 10000 + build;
_cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
}
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
return null;
}
/// <summary>
/// Encoders for <paramref name="codec"/> in the order they are tried: the encoders of the active GPU vendor first,
/// then all others in VideoEncoder.All order (hardware encoders of other vendors and the software encoders).
/// </summary>
/// <param name="codec">Video codec.</param>
/// <param name="forceSoftware">When true, only the software encoders of the codec are returned.</param>
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
{
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
if (forceSoftware)
{
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList();
if (forceSoftware)
{
return encoders.Where(e => !e.IsHardware).ToList();
}
var vendor = GetActiveGPUVendor();
var prioritized = new List<VideoEncoder>();
var others = new List<VideoEncoder>();
foreach (var item in encoders)
{
if (item.Vendor == vendor) prioritized.Add(item);
else others.Add(item);
}
prioritized.AddRange(others);
return prioritized;
return encoders.Where(e => !e.IsHardware).ToList();
}
var vendor = GetActiveGPUVendor();
var prioritized = new List<VideoEncoder>();
var others = new List<VideoEncoder>();
foreach (var item in encoders)
{
if (item.Vendor == vendor) prioritized.Add(item);
else others.Add(item);
}
prioritized.AddRange(others);
return prioritized;
}
}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more