100 Commits
Author SHA256 Message Date
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
copecone c24f9afbae Translate UI Default Texts to English 2026-08-23 12:36:42 +09:00
copecone 25c38707c3 Translate Codebase Comments and Log Messages to English 2026-08-21 23:35:02 +09:00
copecone 111f5cf792 Add OnLastUpdate Callback to API 2026-08-20 10:50:02 +09:00
copecone 5ffd73052d Rename wonFrame to endFrame to Fit Codebase Semantics 2026-08-20 10:21:14 +09:00
copecone 003319fa26 Fix Render Display Text to Always be Visible 2026-08-20 10:14:26 +09:00
copecone 257592f3c1 Add Expiration Date 2026-08-20 09:06:05 +09:00
copecone f14942a287 Add Version Label 2026-08-20 08:39:17 +09:00
copecone 27be2141f7 Refactor Every namespace Keyword Usage 2026-08-19 15:40:00 +09:00
copecone 4e67cb9b09 Add RenderSession.OnComplete 2026-08-19 15:08:55 +09:00
copecone 6840395041 Upgrade Version 2026-08-19 08:13:48 +09:00
copecone 642265f2e7 Cleanup Documentation 2026-08-19 08:11:05 +09:00
copecone f81acd8152 Merge remote-tracking branch 'origin/main' 2026-08-19 08:01:56 +09:00
copecone 2d88a0d99c Suppress Unused Warning in API Classes 2026-08-19 07:57:14 +09:00
89 changed files with 13209 additions and 8684 deletions

No files matched your search

+10 -3
View File
@@ -32,9 +32,11 @@ Thumbs.db
desktop.ini desktop.ini
## Agent Metadata ## Agent Metadata
.antigravitycli/ /.antigravitycli/
.agent/ /.agent/
GEMINI.md /GEMINI.md
/.mcp.json
/CLAUDE.md
# Decompiled Game Code # Decompiled Game Code
*.cs.txt *.cs.txt
@@ -42,3 +44,8 @@ GEMINI.md
# Developer Description Files # Developer Description Files
/AsyncInputAutoplayFix.md /AsyncInputAutoplayFix.md
/AudioMixerExplanation.md /AudioMixerExplanation.md
/O5Theme.md
# Developer Utility Files
/REVIEW_CHECKLIST.md
/REVIEW_FFMPEG.md
+83 -28
View File
@@ -8,13 +8,17 @@
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries> <EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>10</LangVersion> <LangVersion>11</LangVersion>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Reference Include="0Harmony"> <Reference Include="0Harmony">
<HintPath>$(ADOFAI_PATH)\MelonLoader\net35\0Harmony.dll</HintPath> <HintPath>$(ADOFAI_PATH)\MelonLoader\net35\0Harmony.dll</HintPath>
</Reference> </Reference>
<Reference Include="O5Kit">
<HintPath>$(ADOFAI_PATH)\UserLibs\O5Kit.dll</HintPath>
</Reference>
<Reference Include="Assembly-CSharp"> <Reference Include="Assembly-CSharp">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Assembly-CSharp.dll</HintPath> <HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Assembly-CSharp.dll</HintPath>
</Reference> </Reference>
@@ -78,36 +82,87 @@
<Reference Include="Newtonsoft.Json"> <Reference Include="Newtonsoft.Json">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath> <HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath>
</Reference> </Reference>
</ItemGroup> <Reference Include="UniTask">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UniTask.dll</HintPath>
<ItemGroup> </Reference>
<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" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" /> <PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
</ItemGroup> </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>
<XmlFilePath ParameterType="System.String" Required="true" />
<TargetNamespace ParameterType="System.String" Required="true" />
</ParameterGroup>
<Task>
<Using Namespace="System" />
<Using Namespace="System.IO" />
<Using Namespace="System.Collections.Generic" />
<Using Namespace="System.Xml.Linq" />
<Code Type="Fragment" Language="cs">
<![CDATA[
if (!File.Exists(XmlFilePath)) return true;
try
{
var doc = XDocument.Load(XmlFilePath);
var members = doc.Root?.Element("members");
if (members != null)
{
var prefix = TargetNamespace + ".";
var toRemove = new List<XElement>();
foreach (var member in members.Elements("member"))
{
var nameAttr = member.Attribute("name")?.Value;
if (string.IsNullOrEmpty(nameAttr)) continue;
// XML 주석 형식: T:네임스페이스.타입, M:메서드, P:프로퍼티, F:필드/열거형, E:이벤트 등
int colonIndex = nameAttr.IndexOf(':');
string fullName = colonIndex >= 0 ? nameAttr.Substring(colonIndex + 1) : nameAttr;
if (!fullName.StartsWith(prefix, StringComparison.Ordinal) && !fullName.Equals(TargetNamespace, StringComparison.Ordinal))
{
toRemove.Add(member);
}
}
foreach (var elem in toRemove)
{
elem.Remove();
}
doc.Save(XmlFilePath);
Log.LogMessage(Microsoft.Build.Framework.MessageImportance.High, $"[FilterXmlDoc] Kept '{TargetNamespace}' namespace docs. Removed {toRemove.Count} other members.");
}
}
catch (Exception ex)
{
Log.LogWarning($"[FilterXmlDoc] XML filtering failed: {ex.Message}");
}
]]>
</Code>
</Task>
</UsingTask>
<Target Name="FilterDocumentationFile" AfterTargets="Build" Condition="'$(GenerateDocumentationFile)' == 'true'">
<PropertyGroup>
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(TargetDir)$(TargetName).xml')">$(TargetDir)$(TargetName).xml</_XmlDocPath>
<_XmlDocPath Condition="'$(_XmlDocPath)' == '' and Exists('$(DocumentationFile)')">$(DocumentationFile)</_XmlDocPath>
</PropertyGroup>
<FilterXmlDoc Condition="'$(_XmlDocPath)' != ''" XmlFilePath="$(_XmlDocPath)" TargetNamespace="ADOFAIRenderer.API" />
</Target>
</Project> </Project>
+302
View File
@@ -0,0 +1,302 @@
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>
/// </remarks>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
public class FrameHookContext
{
private RenderTexture _originalSource = null!;
private RenderTexture _pingRT = null!;
private RenderTexture _pongRT = null!;
private RenderTexture _currentSource = null!;
private RenderTexture _currentDestination = null!;
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.
/// </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 encoded.
/// </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.
/// </summary>
public long RenderFrameIndex { get; private set; }
/// <summary>
/// The timestamp of the current frame in seconds relative to video start.
/// </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.
/// </summary>
public int Width => _currentSource.width;
/// <summary>
/// The height of the frame in pixels.
/// </summary>
public int Height => _currentSource.height;
/// <summary>
/// The configured render frames per second.
/// </summary>
public double RenderFPS { get; private set; }
/// <summary>
/// The output video frames per second.
/// </summary>
public double VideoFPS { get; private set; }
/// <summary>
/// The number of successful modification passes completed for the current frame.
/// </summary>
public int PassCount { get; private set; }
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);
}
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>
/// <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>
/// <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.
/// </summary>
public void Copy()
{
Graphics.Blit(_currentSource, _currentDestination);
}
/// <summary>
/// Clears <see cref="Destination"/> with the specified color.
/// </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>
/// <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>
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>
/// <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++;
}
}
+24
View File
@@ -0,0 +1,24 @@
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.
/// </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>.
/// </remarks>
/// <param name="context">The frame hook context providing access to ping-pong textures and frame metadata.</param>
void OnRenderFrame(FrameHookContext context);
}
+110 -58
View File
@@ -1,70 +1,122 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
namespace ADOFAIRenderer.API namespace ADOFAIRenderer.API;
/// <summary>
/// Represents the result and metadata of a completed or canceled render session.
/// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderResult
{ {
/// <summary> /// <summary>
/// Represents the result and metadata of a completed or cancelled render session. /// Indicates whether the rendering session completed successfully.
/// </summary> /// </summary>
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.
/// </summary>
public string OutputPath { get; }
/// <summary>
/// Total number of video frames sent to the encoder.
/// </summary>
public long TotalFrames { get; }
/// <summary>
/// The calculated video duration in seconds (based on TotalFrames and FPS).
/// </summary>
public double VideoDuration { get; }
/// <summary>
/// Total wall-clock time spent rendering.
/// </summary>
public TimeSpan RenderDuration { get; }
/// <summary>
/// File size of the output video in bytes (0 if failed or file does not exist).
/// </summary>
public long FileSizeBytes { get; }
/// <summary>
/// Error message or reason if rendering failed or was canceled; <c>null</c> on success.
/// </summary>
public string? ErrorMessage { get; }
/// <summary>
/// Initializes a new instance of the <see cref="RenderResult"/> class.
/// </summary>
/// <param name="success">Whether the render session completed successfully.</param>
/// <param name="outputPath">The absolute output path of the rendered video file.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
/// <param name="videoDuration">Calculated video duration in seconds.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <param name="fileSizeBytes">File size of the output video in bytes.</param>
/// <param name="errorMessage">Error message or reason if failed or canceled; otherwise, <c>null</c>.</param>
public RenderResult(
bool success,
string outputPath,
long totalFrames,
double videoDuration,
TimeSpan renderDuration,
long fileSizeBytes = 0,
string? errorMessage = null)
{ {
/// <summary> Success = success;
/// Indicates whether the rendering session completed successfully. OutputPath = outputPath;
/// </summary> TotalFrames = totalFrames;
public bool Success { get; } VideoDuration = videoDuration;
RenderDuration = renderDuration;
FileSizeBytes = fileSizeBytes;
ErrorMessage = errorMessage;
}
/// <summary> /// <summary>
/// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created. /// Creates a successful <see cref="RenderResult"/> instance from the specified render metrics.
/// </summary> /// </summary>
public string OutputPath { get; } /// <param name="outputPath">The path to the rendered video file.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
/// <summary> /// <param name="fps">The effective frames per second used to compute video duration.</param>
/// Total number of video frames sent to the encoder. /// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// </summary> /// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
public long TotalFrames { get; } internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{
/// <summary> var fileSizeBytes = 0L;
/// The calculated video duration in seconds (based on TotalFrames and FPS). if (!string.IsNullOrEmpty(outputPath) && File.Exists(outputPath))
/// </summary>
public double VideoDuration { get; }
/// <summary>
/// Total wall-clock time spent rendering.
/// </summary>
public TimeSpan RenderDuration { get; }
/// <summary>
/// File size of the output video in bytes (0 if failed or file does not exist).
/// </summary>
public long FileSizeBytes { get; }
/// <summary>
/// Error message or reason if rendering failed or was cancelled; <c>null</c> on success.
/// </summary>
public string? ErrorMessage { get; }
public RenderResult(
bool success,
string outputPath,
long totalFrames,
double videoDuration,
TimeSpan renderDuration,
long fileSizeBytes = 0,
string? errorMessage = null)
{ {
Success = success; try { fileSizeBytes = new FileInfo(outputPath).Length; }
OutputPath = outputPath; catch { /* ignored */ }
TotalFrames = totalFrames;
VideoDuration = videoDuration;
RenderDuration = renderDuration;
FileSizeBytes = fileSizeBytes;
ErrorMessage = errorMessage;
} }
public override string ToString() var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
{ return new RenderResult(true, outputPath, totalFrames, videoDuration, renderDuration, fileSizeBytes);
return Success }
? $"[RenderResult: Success, Output={OutputPath}, Frames={TotalFrames}, VideoDuration={VideoDuration:F2}s, Time={RenderDuration.TotalSeconds:F1}s, Size={FileSizeBytes / 1024.0 / 1024.0:F2}MB]"
: $"[RenderResult: Failed/Cancelled, Error={ErrorMessage ?? "Unknown"}]"; /// <summary>
} /// Creates a failed or cancelled <see cref="RenderResult"/> instance.
/// </summary>
/// <param name="errorMessage">Error message or reason for failure/cancellation.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder prior to termination.</param>
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering prior to termination.</param>
/// <returns>A new <see cref="RenderResult"/> representing a failed or canceled render session.</returns>
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
{
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
return new RenderResult(false, string.Empty, totalFrames, videoDuration, renderDuration, 0, errorMessage ?? "Rendering failed or was cancelled.");
}
/// <summary>
/// Returns a string summarizing the render result metrics or failure reason.
/// </summary>
/// <returns>A string representation of the <see cref="RenderResult"/>.</returns>
public override string ToString()
{
return Success
? $"[RenderResult: Success, Output={OutputPath}, Frames={TotalFrames}, VideoDuration={VideoDuration:F2}s, Time={RenderDuration.TotalSeconds:F1}s, Size={FileSizeBytes / 1024.0 / 1024.0:F2}MB]"
: $"[RenderResult: Failed/Cancelled, Error={ErrorMessage ?? "Unknown"}]";
} }
} }
+423 -4
View File
@@ -1,16 +1,59 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using UnityEngine;
namespace ADOFAIRenderer.API; namespace ADOFAIRenderer.API;
/// <summary> /// <summary>
/// Fluent builder for configuring and starting a render session. /// Fluent builder for configuring and starting a render session.
/// </summary> /// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession public class RenderSession
{ {
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);
}
}
private sealed class SessionHookEntry
{
public object Key { get; }
public Action<FrameHookContext> Action { get; }
public SessionHookEntry(object key, Action<FrameHookContext> action)
{
Key = key;
Action = action;
}
}
private static readonly List<GlobalHookRegistration> GlobalFrameHooks = new();
private static readonly object GlobalHooksLock = new();
private static int _globalHooksVersion;
private readonly List<SessionHookEntry> _frameHooks = new();
private bool _auto = true; private bool _auto = true;
/// <summary> /// <summary>
@@ -31,10 +74,14 @@ public class RenderSession
private bool _timePatch = true; private bool _timePatch = true;
private bool _screenPatch = true; private bool _screenPatch = true;
private Func<bool> _endCondition = () => scrController.instance.state == States.Won;
private string? _outputPath; private string? _outputPath;
private bool _notification = true;
private Func<bool>? _endCondition;
private Action<RenderResult>? _onComplete;
private Action? _onLastUpdate;
/// <summary> /// <summary>
/// Sets whether auto mode is enabled. /// Sets whether auto mode is enabled.
/// </summary> /// </summary>
@@ -87,23 +134,395 @@ public class RenderSession
return this; return this;
} }
/// <summary>
/// Sets whether desktop notifications should be displayed when rendering completes.
/// </summary>
/// <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)
{
_notification = enabled;
return this;
}
/// <summary>
/// Adds a callback action to be invoked when the render session completes or fails.
/// </summary>
/// <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)
{
_onComplete += callback;
return this;
}
/// <summary>
/// Adds a callback action to be invoked on the last update of each frame in the render session.
/// </summary>
/// <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)
{
_onLastUpdate += callback;
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>
/// <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.
/// </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>
/// <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>
/// <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>
/// <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>
/// <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>
/// <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>
/// <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.
/// </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>
/// <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>
/// <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>
/// <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>
/// <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>
/// <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;
}
}
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Remove(registration))
{
_globalHooksVersion++;
}
}
}
/// <summary>
/// Clears all registered global frame modification hooks.
/// </summary>
public static void ClearGlobalFrameHooks()
{
lock (GlobalHooksLock)
{
if (GlobalFrameHooks.Count > 0)
{
GlobalFrameHooks.Clear();
_globalHooksVersion++;
}
}
}
/// <summary>
/// Indicates whether any global frame modification hooks are currently registered.
/// </summary>
public static bool HasGlobalFrameHooks
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count > 0;
}
}
}
/// <summary>
/// The number of currently registered global frame modification hooks.
/// </summary>
public static int GlobalFrameHookCount
{
get
{
lock (GlobalHooksLock)
{
return GlobalFrameHooks.Count;
}
}
}
internal static int GlobalHooksVersion
{
get
{
lock (GlobalHooksLock)
{
return _globalHooksVersion;
}
}
}
internal static void CopyGlobalFrameHooksTo(List<Action<FrameHookContext>> destination)
{
lock (GlobalHooksLock)
{
for (var i = 0; i < GlobalFrameHooks.Count; i++)
{
destination.Add(GlobalFrameHooks[i].Action);
}
}
}
/// <summary> /// <summary>
/// Applies the configured flags and initiates the render session. /// Applies the configured flags and initiates the render session.
/// </summary> /// </summary>
/// <param name="renderConfig">The configuration settings for the render session.</param> /// <param name="renderConfig">The configuration settings for the render session.</param>
/// <param name="levelPath">The file path of the level to render.</param> /// <param name="levelPath">The file path of the level to render.</param>
/// <exception cref="InvalidOperationException">Thrown when the output directory cannot be determined from the output path.</exception>
public void Start(RenderConfig renderConfig, string levelPath) public void Start(RenderConfig renderConfig, string levelPath)
{ {
RenderContext.Auto = _auto; RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch; RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch; RenderContext.ScreenPatch = _screenPatch;
RenderContext.EndCondition = _endCondition;
RenderContext.OutputPath = _outputPath; RenderContext.OutputPath = _outputPath;
RenderContext.Notification = _notification;
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.levelPath = levelPath;
RenderManager.Instance.Configuration = renderConfig; RenderManager.Instance.Configuration = renderConfig;
RenderManager.Instance.StartLoadingOverride(); RenderManager.Instance.stage = RenderStage.Load;
if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory(); if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory();
else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException()); 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.
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.IO;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data;
internal class GlobalConfig
{
public string? OutputDirectory { get; set; }
public string? LastPresetName { get; set; }
public bool DefaultPresetsInitialized { get; set; }
}
internal static class GlobalSettings
{
private static string GetGameRootDir()
{
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
private static string SettingsFilePath =>
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
public static GlobalConfig Current { get; private set; } = new();
public static string DefaultOutputDirectory =>
Path.Combine(GetGameRootDir(), "Render");
public static string EffectiveOutputDirectory
{
get
{
if (!string.IsNullOrWhiteSpace(Current.OutputDirectory) && Directory.Exists(Current.OutputDirectory))
{
return Current.OutputDirectory;
}
return DefaultOutputDirectory;
}
}
static GlobalSettings()
{
Load();
}
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();
}
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}");
}
}
}
+49 -50
View File
@@ -1,66 +1,65 @@
using ADOFAIRenderer.UI; using ADOFAIRenderer.UI;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Core namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{ {
[DefaultExecutionOrder(int.MinValue)] private bool _checkedFFmpeg;
internal class FirstUpdateHook : MonoBehaviour private bool _checkedNvidiaDriver;
private void Update()
{ {
private bool _checkedFFmpeg; if (!_checkedFFmpeg)
private bool _checkedNvidiaDriver;
private void Update()
{ {
if (!_checkedFFmpeg) if (FFmpegDownloadUI.Instance) return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{ {
if (FFmpegDownloadUI.Instance) return; FFmpegDownloadUI.ShowDownloadUI();
return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{
FFmpegDownloadUI.ShowDownloadUI();
return;
}
_checkedFFmpeg = true;
}
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
_checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreRenderUpdate();
} }
_checkedFFmpeg = true;
} }
private void CheckNvidiaDriverWarning() if (!_checkedNvidiaDriver)
{ {
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion(); if (CommonWarningDialogUI.Instance) return;
if (driverVer is null or >= 610.00f) return;
var currentVersion = driverVer.Value.ToString("F2"); _checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
CommonWarningDialogUI.ShowDialog( if (RenderManager.Instance)
"NVIDIA 그래픽 드라이버 업데이트 권장", {
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.", RenderManager.Instance.OnPreUpdate();
new DialogButton("드라이버 업데이트", () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("기능 비활성화", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("이번만 그냥 진행", () =>
{
// 아무 동작 안 함
})
);
} }
} }
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
if (driverVer is null or >= 610.00f) return;
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", () =>
{
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", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("Continue Anyway", () =>
{
// ignored
})
);
}
} }
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using ADOFAIRenderer.API;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Core;
internal class FrameHookPipeline : IDisposable
{
private RenderTexture? _pingRT;
private RenderTexture? _pongRT;
private RenderTexture? _lastOutputRT;
private FrameHookContext? _reusableContext;
private int _width;
private int _height;
private RenderTextureFormat _format;
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}");
}
}
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
}
finally
{
RenderTexture.active = prevActive;
}
}
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();
}
}
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;
}
public void Dispose()
{
ReleaseBuffers();
}
}
+24 -22
View File
@@ -1,35 +1,37 @@
using System.Collections;
using System.Reflection; using System.Reflection;
using Cysharp.Threading.Tasks;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Core namespace ADOFAIRenderer.Core;
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{ {
[DefaultExecutionOrder(int.MaxValue)] private FieldInfo? _camQuadMeshField;
internal class PostRenderHook : MonoBehaviour private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
{ {
private FieldInfo? _camQuadMeshField; _camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
private void Awake() private void LateUpdate()
{ {
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh"); if (RenderManager.Instance)
} RenderManager.Instance.OnLastUpdate();
private void LateUpdate() PostFrameAsync().Forget();
{ }
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine()); private async UniTaskVoid PostFrameAsync()
} {
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
private IEnumerator PostFrameCoroutine() if (!_camQuadMeshRenderer && scrCamera.instance)
{ _camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
yield return new WaitForEndOfFrame();
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance); if (RenderManager.Instance && _camQuadMeshRenderer)
if (RenderManager.Instance) RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.sharedMaterial.mainTexture as RenderTexture);
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
}
} }
} }
+382
View File
@@ -0,0 +1,382 @@
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;
public class RenderPreset
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
public RenderPreset Clone()
{
return new RenderPreset
{
Name = Name,
Config = Config.Clone()
};
}
}
public static class PresetManager
{
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" }
}
}
};
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");
}
private static string GetSafeFileName(string name)
{
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
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}");
}
}
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);
}
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;
}
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)
{
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
}
}
}
}
+938 -764
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
internal static class CodecUtil
{
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
};
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 }
};
}
+109
View File
@@ -0,0 +1,109 @@
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>
public class ColorJsonConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Color) || objectType == typeof(Color?);
}
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
var color = (Color)value;
writer.WriteValue(ToHex(color));
}
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);
}
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}";
}
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;
}
}
-78
View File
@@ -1,78 +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 = "값은 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
{
public double? Min { get; }
public double? Max { get; }
/// <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 (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Min.Value} 이상의 숫자여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 숫자여야 합니다."
: $"값은 {Max.Value} 이하의 숫자여야 합니다.";
return false;
}
errorMessage = null!;
return true;
}
}
}
-77
View File
@@ -1,77 +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}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
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);
}
});
}
}
}
}
-86
View File
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
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>
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}'을(를) 찾을 수 없습니다.");
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;
}
}
}
}
-77
View File
@@ -1,77 +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}'을(를) 찾을 수 없습니다. 유효성 검사를 건너뜁니다.");
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);
}
});
}
}
}
}
-78
View File
@@ -1,78 +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 = "값은 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
{
public int? Min { get; }
public int? Max { get; }
/// <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 (Min.HasValue && value < Min.Value)
{
errorMessage = Max.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Min.Value} 이상의 정수여야 합니다.";
return false;
}
if (Max.HasValue && value > Max.Value)
{
errorMessage = Min.HasValue
? $"값은 {Min.Value}에서 {Max.Value} 사이의 정수여야 합니다."
: $"값은 {Max.Value} 이하의 정수여야 합니다.";
return false;
}
errorMessage = null!;
return true;
}
}
}
-190
View File
@@ -1,190 +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("프리셋 이름은 비어 있을 수 없습니다.");
}
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($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}");
}
}
}
catch (Exception ex)
{
MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {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);
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
+146 -61
View File
@@ -1,71 +1,156 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg; using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{ {
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] public double RenderFPS { get; set; }
public class RenderConfig public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
public void CopyFrom(RenderConfig? source)
{ {
public double RenderFPS { get; set; } if (source == null) return;
public double VideoFPS { get; set; } RenderFPS = source.RenderFPS;
VideoFPS = source.VideoFPS;
RenderWidth = source.RenderWidth;
RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText;
public int RenderWidth { get; set; } VideoConfig.CopyFrom(source.VideoConfig);
public int RenderHeight { get; set; } AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
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 RenderConfig Clone()
public class AdvancedConfig
{ {
public int? RandomSeed { get; set; } = null; var clone = new RenderConfig();
public bool ProcessAudio { get; set; } = true; clone.CopyFrom(this);
public bool NonTile { get; set; } = false; return clone;
public string FileExtension { get; set; } = "mkv"; }
} }
public class VideoEncodingConfig [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
{ public class AdvancedConfig
public Codec Codec { get; set; } = Codec.H264; {
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public int Bitrate { get; set; } = 30000; public bool NonTile { get; set; } = false;
public int MaxBitrate { get; set; } = 50000; public string FileExtension { get; set; } = "mkv";
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18; [JsonConverter(typeof(ColorJsonConverter))]
public double KeyframeInterval { get; set; } = 2.0; public Color? firePlanetColor = null;
public bool ForceSoftwareEncoder { get; set; }
} [JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
public class AudioEncodingConfig
{ [JsonConverter(typeof(ColorJsonConverter))]
private int _audioSampleRate = 44100; public Color? windPlanetColor = null;
private int _audioChannels = 2;
private int _audioBitrate = 256000; public void CopyFrom(AdvancedConfig? source)
{
public bool RenderAudio { get; set; } = true; if (source == null) return;
RandomSeed = source.RandomSeed;
public int AudioSampleRate ProcessAudio = source.ProcessAudio;
{ NonTile = source.NonTile;
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate; FileExtension = source.FileExtension;
set => _audioSampleRate = value <= 0 ? 44100 : value; firePlanetColor = source.firePlanetColor;
} icePlanetColor = source.icePlanetColor;
windPlanetColor = source.windPlanetColor;
public int AudioChannels }
{
get => _audioChannels <= 0 ? 2 : _audioChannels; public AdvancedConfig Clone()
set => _audioChannels = value <= 0 ? 2 : value; {
} var clone = new AdvancedConfig();
clone.CopyFrom(this);
public int AudioBitrate return clone;
{ }
get => _audioBitrate <= 0 ? 256000 : _audioBitrate; }
set => _audioBitrate = value <= 0 ? 256000 : value;
} public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
public void CopyFrom(VideoEncodingConfig? source)
{
if (source == null) return;
Codec = source.Codec;
RateControlMode = source.RateControlMode;
Bitrate = source.Bitrate;
MaxBitrate = source.MaxBitrate;
QP = source.QP;
KeyframeInterval = source.KeyframeInterval;
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
}
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
RenderAudio = source.RenderAudio;
AudioSampleRate = source.AudioSampleRate;
AudioChannels = source.AudioChannels;
AudioBitrate = source.AudioBitrate;
}
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
clone.CopyFrom(this);
return clone;
} }
}␍ }␍
+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 Preamble
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:
The above copyright notice and this permission notice shall be included in all The GNU General Public License is a free, copyleft license for
copies or substantial portions of the Software. software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR The licenses for most software and other practical works are designed
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, to take away your freedom to share and change the works. By contrast,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE the GNU General Public License is intended to guarantee your freedom to
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER share and change all versions of a program--to make sure it remains free
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, software for all its users. We, the Free Software Foundation, use the
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE GNU General Public License for most of our software; it applies also to
SOFTWARE. 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>.
+42
View File
@@ -0,0 +1,42 @@
# ADOFAIRenderer API & Data Linking Exception (Dynamic Linking Only)
> **Additional Permission under GNU General Public License version 3 section 7**
---
## 1. Legal Terms (Official)
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
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.
---
## 2. 조항 해설 (Korean Guide)
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
### 핵심 요약
- **동적 링킹(Dynamic Linking)만 허용**:
- 본 예외 조항은 **동적 링킹(런타임 참조 및 어셈블리 동적 로드)**에만 배타적으로 적용됩니다.
- ADOFAIRenderer 또는 그 일부 코드를 대상 모드/프로그램에 직접 병합하거나 포함(ILMerge, Costura, 바이너리 직접 내장/정적 컴파일 등)하는 **정적 링킹(Static Linking)은 본 예외 대상에서 제외**되며, 순수 GPLv3의 카피레프트(전염성) 의무가 그대로 적용됩니다.
- **외부 모드의 라이선스 자유**:
- 제3자 모드 또는 외부 프로그램이 동적 링킹을 통해 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조만을 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트 의무를 적용받지 않습니다.**
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
- **ADOFAIRenderer 코어 보호**:
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
+54
View File
@@ -0,0 +1,54 @@
{
"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.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.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)"
}
+54
View File
@@ -0,0 +1,54 @@
{
"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.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류",
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
"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": "(만료됨)"
}
+246
View File
@@ -0,0 +1,246 @@
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.
/// </summary>
internal static class LocalizationManager
{
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
private static readonly string FallbackLocaleCode = "en";
/// <summary>
/// Event triggered when the active language changes.
/// </summary>
public static event Action<SystemLanguage>? OnLanguageChanged;
/// <summary>
/// Currently active SystemLanguage.
/// </summary>
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
/// <summary>
/// Currently active locale code (e.g. "en", "ko").
/// </summary>
public static string CurrentLocaleCode { get; private set; } = "en";
private static bool _initialized;
static LocalizationManager()
{
Initialize();
}
/// <summary>
/// Initializes the localization system and loads embedded language dictionaries.
/// </summary>
public static void Initialize()
{
if (_initialized) return;
_initialized = true;
LoadEmbeddedLocales();
// Detect initial language from game settings
var initialLang = DetectGameLanguage();
SetLanguage(initialLang, notify: false);
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
}
private static void LogMsg(string msg)
{
try { Melon<RenderMod>.Logger.Msg(msg); }
catch { Console.WriteLine(msg); }
}
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.
/// </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.
/// </summary>
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.
/// </summary>
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.
/// </summary>
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.
/// </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.
/// </summary>
private static SystemLanguage DetectGameLanguage()
{
try
{
// First attempt: RDString.language
return RDString.language;
}
catch
{
try
{
// Fallback attempt: Persistence.language
return Persistence.language;
}
catch
{
return SystemLanguage.English;
}
}
}
}
/// <summary>
/// Convenient shorthand alias for <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;
}
}
+12 -13
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{ {
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class AsyncInputPatches public static bool Prefix()
{ {
[HarmonyPrefix] if (!RenderManager.IsActive) return true;
// ReSharper disable once InconsistentNaming // Force offsetTick to match instantly without 1/100 damping division when rendering
public static bool Prefix() AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (long)(scrConductor.instance.dspTime * 10000000.0);
{ AsyncInputManager.offsetTickUpdated = true;
if (!RenderManager.IsActive) return true; return false; // Skip the original damped offset calculation
// Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation
}
} }
} }
+87 -88
View File
@@ -4,110 +4,109 @@ using HarmonyLib;
using UnityEngine; using UnityEngine;
using UnityEngine.Audio; using UnityEngine.Audio;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{ {
[HarmonyPatch(typeof(AudioManager), "_Play")] [HarmonyPrefix]
public static class AudioManagerPlayPatch public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{ {
[HarmonyPrefix] if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
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
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
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);
}
}
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{ {
// Cache the clip on the main thread // Cache the clip on the main thread
RenderManager.Instance.AudioMixer?.CacheClip(snd); mixer.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
[HarmonyPostfix] // Convert absolute DSP times to relative times in seconds from render start
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase; double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource // Register looping sound event to the offline mixer (default pitch = 1.0f)
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f); mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
} }
} }
return true; // Let the original method execute
} }
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")] [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class PlayWithEndTimePatch public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
{ {
[HarmonyPrefix] if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{ {
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{ {
var mixer = RenderManager.Instance.AudioMixer; soundEvent.Pitch = value;
if (mixer != null)
{
// Cache the clip on the main thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
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);
}
}
return true; // Let the original method execute
}
}
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(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;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(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;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
} }
} }
} }
} }
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(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;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
}
}
}
+117 -120
View File
@@ -1,168 +1,165 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine namespace ADOFAIRenderer.Patch.Engine;
public static class RenderEnvironmentPatches
{ {
public static class RenderEnvironmentPatches private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{ {
private const bool RenderPatch = true; [SuppressMessage("ReSharper", "InconsistentNaming")]
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS; public static bool Prefix(ref float __result)
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(TimeSpent + RenderManager.Instance.timeBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.timeBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)] [HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch public static class TimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.timeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.timeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch public static class UnscaledTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch public static class UnscaledTimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)] [HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch public static class DspTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref double __result) __result = TimeSpent + RenderManager.Instance.dspTimeBase;
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.dspTimeBase;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")] [HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch public static class PlayScheduledPatch
{
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{ {
// ReSharper disable once InconsistentNaming return !RenderManager.IsActive;
public static bool Prefix(AudioSource __instance, double time)
{
return !RenderManager.IsActive;
}
} }
}
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)] [HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch public static class FrameCountPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref int __result) __result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch public static class DeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
} }
}
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)] [HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch public static class UnscaledDeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
public static bool Prefix(ref float __result) __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
} }
}
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)] [HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch public static class ScreenWidthPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
public static bool Prefix(ref int __result) __result = RenderManager.Instance.Configuration.RenderWidth;
{ return false;
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderWidth;
return false;
}
} }
}
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)] [HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch public static class ScreenHeightPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
public static bool Prefix(ref int __result) __result = RenderManager.Instance.Configuration.RenderHeight;
{ return false;
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderHeight;
return false;
}
} }
}
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))] [HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch public static class GetSpectrumDataPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window) AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
{ return false;
if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
return false;
}
} }
}
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))] [HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch public static class FindOrLoadAudioClipExternalPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] if (RenderManager.Instance && RenderManager.Instance.stage >= RenderStage.Load && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
public static void Prefix(ref bool stream) stream = false;
{
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false;
}
} }
} }
} }
+55 -57
View File
@@ -2,73 +2,71 @@ using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(EndscreenLanterns))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{ {
[HarmonyPatch(typeof(EndscreenLanterns))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Show")]
public static class EndscreenLanternsPatch [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
{ {
[HarmonyPrefix] if (!RenderManager.IsActive) return true;
[HarmonyPatch("Show")] if (__instance != null)
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
{ {
if (!RenderManager.IsActive) return true; __instance.Hide();
if (__instance != null)
{
__instance.Hide();
}
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
} }
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
}
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("Hide")] [HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static void PrefixHide(EndscreenLanterns __instance) public static void PrefixHide(EndscreenLanterns __instance)
{
if (__instance == null) return;
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
{ {
if (__instance == null) return; for (var i = 0; i < lampSequencesArray.Length; i++)
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
{ {
for (var i = 0; i < lampSequencesArray.Length; i++) var item = lampSequencesArray.GetValue(i);
try
{ {
var item = lampSequencesArray.GetValue(i); var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
if (item == null) continue; killMethod?.Invoke(item, new object[] { false });
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(item, new object[] { false });
}
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
} }
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
} }
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
} }
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
} }
} }
+26
View File
@@ -0,0 +1,26 @@
using ADOFAIRenderer.Localization;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Synchronizes mod localization with ADOFAI's in-game language settings.
/// </summary>
[HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch
{
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
[HarmonyPostfix]
public static void OnChangeLanguage(SystemLanguage language)
{
LocalizationManager.SetLanguage(language);
}
[HarmonyPatch(nameof(RDString.Setup))]
[HarmonyPostfix]
public static void OnSetup()
{
LocalizationManager.SetLanguage(RDString.language);
}
}
+28 -13
View File
@@ -1,22 +1,37 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{ {
[HarmonyPatch(typeof(Persistence))] [HarmonyPostfix]
public static class PersistencePatch [HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
{ {
[HarmonyPostfix] if (RenderManager.IsActive &&
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)] scrConductor.instance != null &&
// ReSharper disable once InconsistentNaming !scrConductor.instance.hasSongStarted)
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
{ {
if (RenderManager.IsActive && __result = SkipIntroBehavior.Off;
scrConductor.instance != null &&
!scrConductor.instance.hasSongStarted)
{
__result = SkipIntroBehavior.Off;
}
} }
} }
[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
};
}
} }
+35
View File
@@ -0,0 +1,35 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
public static void OnInit(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
[HarmonyPostfix]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
public static void OnApplyMultiplanetColors(PlanetarySystem __instance)
{
ApplyWindPlanetColor(__instance);
}
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));
}
}
}
+37 -38
View File
@@ -4,48 +4,47 @@ using MelonLoader;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{ {
[HarmonyPatch(typeof(scnEditor))] [HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class scnEditorPatch public static void AddButtonRender(scnEditor __instance)
{ {
[HarmonyPostfix] if (RenderMod.IsFeatureDisabled) return;
[HarmonyPatch("Start")] if (!__instance || !__instance.buttonExit)
// ReSharper disable once InconsistentNaming
public static void AddButtonRender(scnEditor __instance)
{ {
if (RenderMod.IsFeatureDisabled) return; Melon<RenderMod>.Logger.Msg("Cannot create render button in scnEditor.");
if (!__instance || !__instance.buttonExit) return;
{
Melon<RenderMod>.Logger.Msg("scnEditor에 렌더 버튼을 만들 수 없습니다.");
return;
}
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// 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;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
} }
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// 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;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
} }
} }
+117 -121
View File
@@ -4,139 +4,135 @@ using ADOFAIRenderer.Util;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scnGame))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{ {
[HarmonyPatch(typeof(scnGame))] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Play")]
public static class scnGamePatch public static void ProcessFloors()
{ {
[HarmonyPostfix] var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
[HarmonyPatch("Play")] RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
public static void ProcessFloors()
if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors)
{ {
var nonTile = RenderManager.Instance.OverrideLoading && DisableRenderers(
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
floor.iconsprite, floor.outlineSprite,
floor.topGlow, floor.bottomGlow,
floor.multiplanetLine
);
if (!nonTile) return; if (floor.holdRenderer) DisableRenderer(floor.holdRenderer.m_meshRenderer);
foreach (var floor in scrLevelMaker.instance.listFloors)
{
DisableRenderers(
floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
floor.iconsprite, floor.outlineSprite,
floor.topGlow, floor.bottomGlow,
floor.multiplanetLine
);
if (floor.holdRenderer) DisableRenderer(floor.holdRenderer.m_meshRenderer); if (floor.dummyPlanets == null) continue;
foreach (var planet in floor.dummyPlanets)
if (floor.dummyPlanets == null) continue; DisablePlanetRenderer(planet);
foreach (var planet in floor.dummyPlanets)
DisablePlanetRenderer(planet);
}
var players = scrPlayerManager.instance.players;
if (players == null) return;
foreach (var player in players)
{
var planets = player.planetarySystem?.planetList;
if (planets == null) continue;
foreach (var planet in planets)
DisablePlanetRenderer(planet.planetRenderer);
}
return;
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
DisableRenderers(
planet.coreParticles.GetRenderer(), planet.tailParticles.GetRenderer(),
planet.tailParticlesCoop.GetRenderer(), planet.sparks.GetRenderer(), planet.ring, planet.glow,
planet.faceSprite, planet.faceDetails, planet.samuraiSprite
);
}
void DisableRenderers(params Renderer?[] renderers)
{
foreach (var renderer in renderers) DisableRenderer(renderer);
}
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
} }
[HarmonyPrefix] var players = scrPlayerManager.instance.players;
[HarmonyPatch("UpdateVideo")] if (players == null) return;
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance) foreach (var player in players)
{ {
// Only replace when rendering is active var planets = player.planetarySystem?.planetList;
if (!RenderManager.IsActive) if (planets == null) continue;
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
}
string text = __instance.levelData.miscSettings.GetString("bgVideo"); foreach (var planet in planets)
if (string.IsNullOrEmpty(text)) DisablePlanetRenderer(planet.planetRenderer);
{ }
__instance.videoBG.gameObject.SetActive(false); return;
return false; // Skip original
}
__instance.videoBG.gameObject.SetActive(true); void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
DisableRenderers(
planet.coreParticles.GetRenderer(), planet.tailParticles.GetRenderer(),
planet.tailParticlesCoop.GetRenderer(), planet.sparks.GetRenderer(), planet.ring, planet.glow,
planet.faceSprite, planet.faceDetails, planet.samuraiSprite
);
}
// We can only play external video files via FFmpegNativeDecoder. void DisableRenderers(params Renderer?[] renderers)
// Internal/Bundle level background videos are built-in VideoClips, so we fallback to native Unity VideoPlayer. {
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel) foreach (var renderer in renderers) DisableRenderer(renderer);
{ }
__instance.videoBG.enabled = true;
return true; // Execute original
}
// Custom level video path void DisableRenderer(Renderer? renderer)
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text); {
if (!File.Exists(videoPath)) if (renderer) renderer.enabled = false;
{
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
} }
} }
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance)
{
// Only replace when rendering is active
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
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
}
string text = __instance.levelData.miscSettings.GetString("bgVideo");
if (string.IsNullOrEmpty(text))
{
__instance.videoBG.gameObject.SetActive(false);
return false; // Skip original
}
__instance.videoBG.gameObject.SetActive(true);
// We can only play external video files via FFmpegNativeDecoder.
// Internal/Bundle level background videos are built-in VideoClips, so we fall back to native Unity VideoPlayer.
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
{
__instance.videoBG.enabled = true;
return true; // Execute original
}
// Custom level video path
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text);
if (!File.Exists(videoPath))
{
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
if (!__instance.videoBG.gameObject.TryGetComponent<VideoBGPlayer>(out var player))
{
player = __instance.videoBG.gameObject.AddComponent<VideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
}
} }
+16 -17
View File
@@ -1,23 +1,22 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
[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
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable; [HarmonyPatch(typeof(scrCamera))]
__instance.frameRate = frame; // ReSharper disable once InconsistentNaming
return false; public class scrCameraPatch
} {
[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
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
__instance.frameRate = frame;
return false;
} }
} }
+33 -34
View File
@@ -1,45 +1,44 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{ {
[HarmonyPatch(typeof(scrController))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("TogglePauseGame")]
public static class scrControllerPatch public static bool PreventPause()
{ {
[HarmonyPrefix] return !RenderManager.IsActive;
[HarmonyPatch("TogglePauseGame")] }
public static bool PreventPause()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")] [HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel() public static bool PreventPortalTravel()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("FailAction")] [HarmonyPatch("FailAction")]
public static bool PreventFailAction() public static bool PreventFailAction()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("Fail2Action")] [HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action() public static bool PreventFail2Action()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")] [HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied() public static bool PreventPlayerDied()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
}
} }
} }
-41
View File
@@ -1,41 +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;
}
}
}
+15 -16
View File
@@ -1,22 +1,21 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.OverrideLoading && [HarmonyPatch(typeof(scrFloor))]
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; // ReSharper disable once InconsistentNaming
return !nonTile; public class scrFloorPatch
} {
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
} }
} }
+7 -8
View File
@@ -1,15 +1,14 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{ {
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")] public static bool Prefix()
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{ {
public static bool Prefix() return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
{
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
}
} }
} }
+9 -10
View File
@@ -1,17 +1,16 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{ {
[HarmonyPatch(typeof(scrMistakesManager))] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("SaveCustom")]
public static class scrMistakesManagerPatch public static bool PreventSaveCustom()
{ {
[HarmonyPrefix] return !RenderManager.IsActive;
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
{
return !RenderManager.IsActive;
}
} }
} }
+14 -15
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0; [HarmonyPatch(typeof(scrPlayerManager))]
return false; // ReSharper disable once InconsistentNaming
} public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
return false;
} }
} }
+16 -17
View File
@@ -2,28 +2,27 @@ using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{ {
[HarmonyPatch(typeof(scrPortalParticles))] [HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch public static void ForceScaledTime(scrPortalParticles __instance)
{ {
[HarmonyPostfix] if (!RenderManager.Instance || RenderManager.Instance.stage < RenderStage.Load) return;
[HarmonyPatch("Start")] if (__instance == null) return;
// ReSharper disable once InconsistentNaming
public static void ForceScaledTime(scrPortalParticles __instance) var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particleSystems)
{ {
if (!RenderManager.Instance || !RenderManager.Instance.OverrideLoading) return; if (ps == null) continue;
if (__instance == null) return;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true); var main = ps.main;
foreach (var ps in particleSystems) main.useUnscaledTime = false;
{
if (ps == null) continue;
var main = ps.main;
main.useUnscaledTime = false;
}
} }
} }
} }
+13 -14
View File
@@ -1,20 +1,19 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
{
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false; [HarmonyPatch(typeof(scrPressToStart))]
return false; // ReSharper disable once InconsistentNaming
} public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false;
return false;
} }
} }
+22 -22
View File
@@ -4,34 +4,34 @@ using HarmonyLib;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrShowIfDebug))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{ {
[HarmonyPatch(typeof(scrShowIfDebug))] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming [HarmonyPatch("Update")]
public static class scrShowIfDebugPatch [SuppressMessage("ReSharper", "InconsistentNaming")]
private static void IgnoreInput(Text ___txt, Color ___defaultColor)
{ {
[HarmonyPostfix] if (!RenderManager.IsActive) return;
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")] ___txt.enabled = true;
private static void IgnoreInput(Text ___txt, Color ___defaultColor) ___txt.text = "Render";
if (RenderManager.Instance)
{ {
if (!RenderManager.IsActive) return; if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
___txt.text = "Render"; if (RenderManager.Instance.endFrame != -1)
if (RenderManager.Instance)
{ {
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s.."; var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.endFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (RenderManager.Instance.wonFrame != -1) if (remainingTime < 0) remainingTime = 0;
{ ___txt.text += $"\nWaiting for {remainingTime:F1}s..";
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (remainingTime < 0) remainingTime = 0;
___txt.text += $"\nWaiting for {remainingTime:F1}s..";
}
} }
___txt.color = ___defaultColor;
} }
___txt.color = ___defaultColor;
} }
} }
+10 -11
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using HarmonyLib; using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{ {
[HarmonyPatch(typeof(scrUIController))] [HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public class scrUIControllerPatch public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
{ {
[HarmonyPostfix] if (RenderManager.IsActive && __instance != null)
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
{ {
if (RenderManager.IsActive && __instance != null) __instance.HideEndscreenLanterns();
{
__instance.HideEndscreenLanterns();
}
} }
} }
} }
+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).
+32 -59
View File
@@ -1,75 +1,48 @@
using System;
using ADOFAIRenderer; using ADOFAIRenderer;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using MelonLoader; using MelonLoader;
using MelonLoader.Utils; using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using UnityEngine.EventSystems; using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.1.0-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.8-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer namespace ADOFAIRenderer;
internal class RenderMod : MelonMod
{ {
internal class RenderMod : MelonMod // ReSharper disable once MemberCanBePrivate.Global
public static bool IsFeatureDisabled = false;
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
public override void OnInitializeMelon()
{ {
public static AssetBundle FontBundle = null!; GlobalSettings.Load();
// ReSharper disable once MemberCanBePrivate.Global Localization.LocalizationManager.Initialize();
public static AssetBundle SceneBundle = null!; ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
// ReSharper disable once MemberCanBePrivate.Global
public static AssetBundle ShaderBundle = null!;
public static ComputeShader ColorConversionShader = null!;
public static bool IsFeatureDisabled = false;
public override void OnInitializeMelon() var renderManagerObj = new GameObject("Render Manager");
{ var updateHookerObj = new GameObject("Update Hooker");
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory; var uiManagerObj = new GameObject("Renderer UI Manager");
Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj);
Object.DontDestroyOnLoad(uiManagerObj);
var renderManagerObj = new GameObject("Render Manager"); renderManagerObj.AddComponent<RenderManager>();
var updateHookerObj = new GameObject("Update Hooker"); updateHookerObj.AddComponent<FirstUpdateHook>();
Object.DontDestroyOnLoad(renderManagerObj); uiManagerObj.AddComponent<RendererUIManager>();
Object.DontDestroyOnLoad(updateHookerObj); ModAssets.Load();
}
renderManagerObj.AddComponent<RenderManager>(); public override void OnApplicationQuit()
updateHookerObj.AddComponent<FirstUpdateHook>(); {
LoadBundles(); if (RenderManager.Instance == null) return;
} Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
RenderManager.Instance.CleanUpActiveRender();
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("컬러 변환 Compute Shader 로드 완료.");
}
catch (System.Exception ex)
{
Melon<RenderMod>.Logger.Warning($"Shader 에셋번들을 로드할 수 없습니다. GPU 컬러 변환이 비활성화되며 CPU로 폴백합니다. 에러: {ex.Message}");
}
}
public static void FixEventSystem()
{
if (EventSystem.current == null) return;
var currentModule = EventSystem.current.currentInputModule;
if (currentModule) return;
Melon<RenderMod>.Logger.Msg("Unity 6 인풋 모듈 이식.");
var newModule = EventSystem.current.gameObject.AddComponent<StandaloneInputModule>();
newModule.enabled = true;
}
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader 종료 감지: RenderManager의 활성 프로세스를 강제 수거 및 종료합니다.");
RenderManager.Instance.CleanUpActiveRender();
}
} }
} }
+166 -279
View File
@@ -1,262 +1,185 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using MelonLoader;
using O5Kit.Core;
using O5Kit.Factory;
using TMPro; using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
using UnityEngine.UIElements; using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
using Image = UnityEngine.UI.Image; using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
{
internal class DialogButton
{
public string Text;
public Action OnClick;
public Color? BackgroundColor;
public DialogButton(string text, Action onClick, Color? backgroundColor = null) internal class DialogButton
{
// ReSharper disable FieldCanBeMadeReadOnly.Global
public string Text;
public Action OnClick;
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
Text = text;
OnClick = onClick;
BackgroundColor = backgroundColor;
}
}
internal class CommonWarningDialogUI : MonoBehaviour
{
public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new();
private O5Window? _window;
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
_window?.Dispose();
_window = null;
foreach (var elem in _disabledElements)
{ {
Text = text; if (elem != null)
OnClick = onClick; {
BackgroundColor = backgroundColor; elem.SetEnabled(true);
}
}
_disabledElements.Clear();
if (Instance == this)
{
Instance = null;
} }
} }
internal class CommonWarningDialogUI : MonoBehaviour public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{ {
public static CommonWarningDialogUI? Instance { get; private set; } if (buttons.Length == 0) return;
private readonly List<VisualElement> _disabledElements = new();
private void Awake() var obj = new GameObject("CommonWarningDialogUI");
DontDestroyOnLoad(obj);
var dialog = obj.AddComponent<CommonWarningDialogUI>();
dialog.Initialize(title, description, buttons);
}
private void Initialize(string title, string description, DialogButton[] buttons)
{
if (!UnityEngine.EventSystems.EventSystem.current)
{ {
Instance = this; var eventSystemObj = new GameObject("EventSystem");
eventSystemObj.transform.SetParent(transform);
eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>();
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
} }
private void OnDestroy() foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{ {
foreach (var elem in _disabledElements) if (doc && doc.rootVisualElement is { enabledSelf: true })
{ {
if (elem != null) doc.rootVisualElement.SetEnabled(false);
{ _disabledElements.Add(doc.rootVisualElement);
elem.SetEnabled(true);
}
}
_disabledElements.Clear();
if (Instance == this)
{
Instance = null;
} }
} }
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset() // Set UI layer (5)
gameObject.layer = 5;
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766;
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>();
var ctx = RendererUIManager.Context;
// 1. Create full screen backdrop (scrim) to block background input
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);
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
// 2. Create actual native O5Window
_window = O5Window.Create(ctx, bgPanelObj.transform, new O5WindowOptions
{ {
TMP_FontAsset? font = null; Id = "RendererMod_WarningDialog",
if (RenderMod.FontBundle) Title = title,
{ Size = new Vector2(540, 240),
try Draggable = true,
{ Resizable = false,
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF"); ShowCloseButton = false,
} ShowOutline = true,
catch Padding = new RectOffset(16, 16, 16, 16)
{ });
// 로딩 실패
}
}
// 폴백 처리 // 3. Build content inside window.Content
if (!font) var content = _window.Content;
{
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 vLayout = content.gameObject.AddComponent<VerticalLayoutGroup>();
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>(); vLayout.spacing = 16f;
if (fonts.Length > 0) font = fonts[0]; vLayout.padding = new RectOffset(16, 16, 12, 12);
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
return font!; // Description text
} var descText = O5Factory.ControlText(ctx, content, 18f, noPad: true);
descText.text = description;
descText.alignment = TextAlignmentOptions.Center;
descText.textWrappingMode = TextWrappingModes.Normal;
descText.lineSpacing = 6;
descText.color = new Color(0.88f, 0.88f, 0.88f, 1f);
public static void ShowDialog(string title, string description, params DialogButton[] buttons) // ReSharper disable once InconsistentNaming
var descLE = descText.gameObject.AddComponent<LayoutElement>();
descLE.flexibleWidth = 1f;
descLE.flexibleHeight = 1f;
// Button action row
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;
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 (var i = 0; i < buttons.Length; i++)
{ {
if (buttons.Length == 0) return; var btnData = buttons[i];
var btn = O5Factory.Button(
var obj = new GameObject("CommonWarningDialogUI"); ctx: ctx,
DontDestroyOnLoad(obj); parent: btnRowRect,
var dialog = obj.AddComponent<CommonWarningDialogUI>(); onClick: () =>
dialog.Initialize(title, description, buttons);
}
private void Initialize(string title, string description, DialogButton[] buttons)
{
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
eventSystemObj.transform.SetParent(transform);
eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>();
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement != null && doc.rootVisualElement.enabledSelf)
{
doc.rootVisualElement.SetEnabled(false);
_disabledElements.Add(doc.rootVisualElement);
}
}
// UI 레이어(5) 설정
gameObject.layer = 5;
// Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래
// 해상도 대응 캔버스 스케일러 설정
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. 전체 화면 오버레이 배경 생성
var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
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. 모달 컨테이너 생성
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); // 기존 UI의 배경 톤과 동일
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성
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. 타이틀 텍스트 생성
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); // 경고 테마에 맞는 주황/황색 계열
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. 본문 설명 텍스트 생성
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
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. 버튼 컨테이너 생성
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);
// 6. 가변 버튼 동적 배치 및 스타일링
float totalWidth = 560f;
float spacing = 15f;
float buttonHeight = 45f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
float startX = numButtons == 1
? 0
: (-totalWidth / 2f) + (buttonWidth / 2f);
for (int i = 0; i < numButtons; i++)
{
var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}");
btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
// 기본 색상 정의
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값
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);
// 클릭 콜백 등록
btn.onClick.AddListener(() =>
{ {
try try
{ {
@@ -264,60 +187,24 @@ namespace ADOFAIRenderer.UI
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}"); Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
} }
Destroy(gameObject); Destroy(gameObject);
}); },
text: btnData.Text,
id: $"btn_dialog_{i}",
height: 40f
);
var btnRect = btnObj.GetComponent<RectTransform>(); if (btnData.BackgroundColor.HasValue)
btnRect.anchorMin = new Vector2(0.5f, 0.5f); {
btnRect.anchorMax = new Vector2(0.5f, 0.5f); btn.NormalColor = btnData.BackgroundColor.Value;
btnRect.pivot = new Vector2(0.5f, 0.5f); btn.UpdateVisual();
float posX = numButtons == 1
? 0
: startX + i * (buttonWidth + spacing);
btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
// 텍스트 라벨 추가
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;
} }
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor) var le = btn.Rect.gameObject.AddComponent<LayoutElement>();
{ le.flexibleWidth = 1f;
button.transition = Selectable.Transition.ColorTint; le.preferredHeight = 40f;
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);
} }
} }
} }
+175 -211
View File
@@ -6,229 +6,193 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class DebugMonitorUI : MonoBehaviour
{ {
internal class DebugMonitorUI : MonoBehaviour // --- Progress Bar UI Configuration Constants ---
private const float BarWidth = 800f;
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // Increased font size for visibility
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
private void Start()
{ {
// --- Progress Bar UI Configuration Constants --- // Set UI layer (5)
private const float BarWidth = 800f; gameObject.layer = 5;
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants --- // Canvas setup
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선 var canvas = gameObject.AddComponent<Canvas>();
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께 canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
private TextMeshProUGUI _text = null!; // Dynamic resolution scaling setup (Reference: 1920x1080)
private GameObject _progressBarObj = null!; var scaler = gameObject.AddComponent<CanvasScaler>();
private GameObject _progressBarFillObj = null!; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
private RectTransform _fillRect = null!; scaler.referenceResolution = new Vector2(1920, 1080);
private TextMeshProUGUI _progressText = null!; scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset() gameObject.AddComponent<GraphicRaycaster>();
// Create background panel
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Dark tone acrylic style
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// Create text
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16;
_text.alignment = TextAlignmentOptions.TopLeft;
// Prevent TMPro text clipping
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// Load and apply font
_text.font = ModAssets.NanumSquareNeoNormal;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // Padding margin
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// Add progress bar outline component
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_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 = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize;
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = ModAssets.NanumSquareNeoNormal;
_progressText.color = BarTextColor;
// Add progress bar text outline for readability
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
}
private void Update()
{
if (!_text) return;
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{ {
TMP_FontAsset? font = null; lock (RenderManager.Instance.ImageQueue)
if (RenderMod.FontBundle != null)
{ {
try queueCount = RenderManager.Instance.ImageQueue.Count;
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
} }
// 폴백 처리: 에셋 번들에서 실패 시 게임 자체 NanumSquareNeo 또는 내장 TMP SDF 폰트 사용
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!;
} }
private void Start() sb.AppendLine($"• Queue: <b>{queueCount}</b> / {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");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{ {
// UI 레이어(5) 설정 currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
gameObject.layer = 5; totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
// Canvas 설정 {
var canvas = gameObject.AddComponent<Canvas>(); progress = Mathf.Clamp01((float)currentFloor / totalFloors);
canvas.renderMode = RenderMode.ScreenSpaceOverlay; }
canvas.sortingOrder = 9999;
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080)
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>();
// 배경 패널 생성
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// 텍스트 생성
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16; // 13에서 16으로 증가
_text.alignment = TextAlignmentOptions.TopLeft;
// TMPro 텍스트 잘림 현상 예방
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용
var font = GetNanumSquareNeoBdFontAsset();
_text.font = font;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_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 = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font;
_progressText.color = BarTextColor;
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보)
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
} }
private void Update() if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
{ if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
if (!_text) return;
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{
lock (RenderManager.Instance.ImageQueue)
{
queueCount = RenderManager.Instance.ImageQueue.Count;
}
}
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {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
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{
currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
{
progress = Mathf.Clamp01((float)currentFloor / totalFloors);
}
}
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
}
} }
} }
+104 -106
View File
@@ -4,128 +4,126 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class EscapeHandlerUI : MonoBehaviour
{ {
internal class EscapeHandlerUI : MonoBehaviour private GameObject _containerObj = null!;
private TextMeshProUGUI _textComponent = null!;
private Stopwatch? _escapeStopwatch;
private void Start()
{ {
private GameObject _containerObj = null!; // Set UI layer (5)
private TextMeshProUGUI _textComponent = null!; gameObject.layer = 5;
private Stopwatch? _escapeStopwatch;
private void Start() // Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // Placed above debug monitor
// Responsive Canvas Scaler setup
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 container and background panel object
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>();
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f); // Dark tone acrylic style
var containerRect = _containerObj.GetComponent<RectTransform>();
containerRect.anchorMin = new Vector2(0.5f, 0.5f);
containerRect.anchorMax = new Vector2(0.5f, 0.5f);
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // Screen center
containerRect.sizeDelta = new Vector2(460, 160);
// 2. Create text object
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// Apply game's default font (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// Initial state disabled
_containerObj.SetActive(false);
}
private void Update()
{
// Destroy self if rendering is no longer active
if (!RenderManager.IsActive)
{ {
// UI 레이어(5) 설정 Destroy(gameObject);
gameObject.layer = 5; return;
}
// Canvas 설정 if (Input.GetKey(KeyCode.Escape))
var canvas = gameObject.AddComponent<Canvas>(); {
canvas.renderMode = RenderMode.ScreenSpaceOverlay; if (_escapeStopwatch == null)
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정 {
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
// 해상도 대응 캔버스 스케일러 설정 var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var scaler = gameObject.AddComponent<CanvasScaler>(); var remaining = Mathf.Max(0f, 3f - elapsed);
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>(); // Update remaining time text
_textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
// 1. 컨테이너 및 배경 패널 오브젝트 생성 // Micro pulse animation (container smoothly scales up while holding)
_containerObj = new GameObject("EscapeContainer"); var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.SetParent(transform, false); _containerObj.transform.localScale = new Vector3(scale, scale, 1f);
_containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>(); if (elapsed < 3f) return;
// 고급스러운 다크톤 아크릴 감성
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
var containerRect = _containerObj.GetComponent<RectTransform>(); _escapeStopwatch.Stop();
containerRect.anchorMin = new Vector2(0.5f, 0.5f); _escapeStopwatch = null;
containerRect.anchorMax = new Vector2(0.5f, 0.5f); _containerObj.transform.localScale = Vector3.one; // Reset scale
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙
containerRect.sizeDelta = new Vector2(460, 160);
// 2. 텍스트 오브젝트 생성
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// 초기 상태는 비활성화
_containerObj.SetActive(false); _containerObj.SetActive(false);
// Cancel rendering and exit scene
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
} }
else
private void Update()
{
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴
if (!RenderManager.IsActive)
{
Destroy(gameObject);
return;
}
if (Input.GetKey(KeyCode.Escape))
{
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
// 남은 시간 텍스트 업데이트
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>";
// 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐)
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
if (elapsed < 3f) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
// 렌더링 취소 및 씬 이탈
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
else
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
}
}
private void OnDestroy()
{ {
if (_escapeStopwatch == null) return; if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop(); _escapeStopwatch.Stop();
_escapeStopwatch = null; _escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false);
} }
} }
private void OnDestroy()
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
}
} }
+551 -650
View File
File diff suppressed because it is too large. Load diff
+112
View File
@@ -0,0 +1,112 @@
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.
/// </summary>
internal class LocalizedText : MonoBehaviour
{
private TextMeshProUGUI? _textComponent;
private string? _key;
private Func<string>? _dynamicProvider;
/// <summary>
/// Gets or sets the localization key. Setting this will clear any dynamic provider.
/// </summary>
public string? Key
{
get => _key;
set
{
_key = value;
_dynamicProvider = null;
Refresh();
}
}
/// <summary>
/// Sets a dynamic string provider function.
/// </summary>
public void SetDynamic(Func<string> provider)
{
_dynamicProvider = provider;
_key = null;
Refresh();
}
private void Awake()
{
EnsureComponent();
LocalizationManager.OnLanguageChanged += HandleLanguageChanged;
}
private void OnEnable()
{
Refresh();
}
private void OnDestroy()
{
LocalizationManager.OnLanguageChanged -= HandleLanguageChanged;
}
private void HandleLanguageChanged(SystemLanguage language)
{
Refresh();
}
private void EnsureComponent()
{
if (_textComponent == null)
{
_textComponent = GetComponent<TextMeshProUGUI>();
}
}
/// <summary>
/// Refreshes the text using either the dynamic provider or the static localization key.
/// </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.
/// </summary>
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.
/// </summary>
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
+109
View File
@@ -0,0 +1,109 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util;
using MelonLoader;
using O5Kit.Core;
using TMPro;
namespace ADOFAIRenderer.UI;
using UnityEngine;
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal static class ModAssets
{
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.");
}
public static AssetBundle FontBundle = null!;
public static AssetBundle ShaderBundle = null!;
public static TMP_FontAsset NanumSquareNeoNormal = null!;
public static TMP_FontAsset NanumSquareNeoBold = null!;
public static ComputeShader ColorConversionShader = null!;
}
internal sealed class RendererFontProvider : IFontProvider
{
private static readonly DefaultFontProvider DefaultFontProvider = new();
private bool _disposed;
public TMP_FontAsset Regular => ModAssets.NanumSquareNeoNormal;
public TMP_FontAsset Medium => ModAssets.NanumSquareNeoBold;
public TMP_FontAsset Monospace => DefaultFontProvider.Monospace;
public void Dispose()
{
if (_disposed) return;
_disposed = true;
}
}
internal class RendererUIManager : MonoBehaviour
{
public static RendererUIManager Instance = null!;
public static O5Context Context => Instance._context;
public O5WindowManager WindowManager => _windowManager;
private O5Context _context = null!;
private O5WindowManager _windowManager = null!;
private void Awake() {
Instance = this;
_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");
}
private void Update() {
O5Object.TickAll();
Context.Tooltip.Tick();
O5Kit.Input.O5ShortcutManager.HandleUpdate();
}
private void OnDestroy() {
_windowManager.Dispose();
_context.Dispose();
}
}
File diff suppressed because it is too large. Load diff
+80 -81
View File
@@ -1,101 +1,100 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{ {
internal static class AudioResampler public static float[] Resample(float[] inputSamples, int inputChannels, int inputFrequency, int targetChannels, int targetFrequency)
{ {
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)
{ {
// ── Safety Validation ── return Array.Empty<float>();
if (inputSamples.Length == 0 || }
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0) // 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
{ {
return Array.Empty<float>(); 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 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; if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
long idxLow = (long)Math.Floor(srcIndex); {
long idxHigh = idxLow + 1; float valLow = inputSamples[idxLow];
float t = (float)(srcIndex - 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);
if (idxHigh >= inputLength) idxHigh = inputLength - 1; float valLowR = inputSamples[idxLow * inputChannels + 1];
if (idxLow >= inputLength) idxLow = inputLength - 1; float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
if (targetChannels == 1) // 1. Mono Output }
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{ {
float valLow, valHigh; float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono if (c < inputChannels)
{ {
valLow = inputSamples[idxLow]; valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh]; valHigh = inputSamples[idxHigh * inputChannels + c];
} }
else // Multi -> Mono (Downmix: Average all channels) else
{ {
float sumLow = 0f; valLow = inputSamples[idxLow * inputChannels];
float sumHigh = 0f; valHigh = inputSamples[idxHigh * inputChannels];
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);
} }
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
} }
} }
return outputSamples;
} }
return outputSamples;
} }
} }
+209 -208
View File
@@ -3,238 +3,239 @@ using System.Collections.Generic;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal static class AudioSpectrumGenerator
{ {
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
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
{ {
public static float[]? AudioClipData { get; private set; } if (!clip)
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
{ {
if (!clip) Clear();
{ return;
Clear();
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)
{
AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
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;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
} }
public static void Clear() try
{ {
AudioClipData = null; Channels = clip.channels;
TotalSamples = 0; Frequency = clip.frequency;
Channels = 0; int samplesCount = clip.samples;
Frequency = 0;
lock (CachedSpectrums) float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
{ {
_cachedFrame = -1; AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
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;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
}
public static void Clear()
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
{
_cachedFrame = -1;
CachedSpectrums.Clear();
}
}
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
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = RenderManager.Instance ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear(); CachedSpectrums.Clear();
} }
}
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window) if (CachedSpectrums.TryGetValue(n, out var cached))
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{ {
Array.Clear(samples, 0, samples.Length); Array.Copy(cached, samples, n);
return; 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
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Copy(cached, samples, n);
return;
}
}
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
{
windowSamples[i] = AudioClipData[idx];
}
else
{
windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
} }
private static void CalculateFFT(float[] samples, float[] spectrum) var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{ {
var n = samples.Length; var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
{ {
var angle = 2.0 * Math.PI * i / (n - 1); windowSamples[i] = AudioClipData[idx];
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;
} }
else
// Cooley-Tukey Radix-2 FFT
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
{ {
if (i < j) windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
}
private static void CalculateFFT(float[] samples, float[] spectrum)
{
var n = samples.Length;
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
{
var angle = 2.0 * Math.PI * i / (n - 1);
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
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
{
if (i < j)
{
var temp = re[i];
re[i] = re[j];
re[j] = temp;
temp = im[i];
im[i] = im[j];
im[j] = temp;
}
var k = n / 2;
while (k <= j)
{
j -= k;
k /= 2;
}
j += k;
}
// FFT stages
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
var le2 = le / 2;
var ur = 1f;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{ {
var temp = re[i]; var ip = item + le2;
re[i] = re[j]; var tr = re[ip] * ur - im[ip] * ui;
re[j] = temp; var ti = re[ip] * ui + im[ip] * ur;
temp = im[i]; re[ip] = re[item] - tr;
im[i] = im[j]; im[ip] = im[item] - ti;
im[j] = temp; re[item] += tr;
im[item] += ti;
} }
var k = n / 2; var tempUr = ur;
while (k <= j) ur = tempUr * wr - ui * wi;
{ ui = tempUr * wi + ui * wr;
j -= k;
k /= 2;
}
j += k;
} }
}
// FFT stages // Normalize and fill spectrum
for (var stage = 1; stage <= m; stage++) var factor = 2.0f / n;
{ var outLen = Math.Min(spectrum.Length, n / 2);
var le = 1 << stage; for (var i = 0; i < outLen; i++)
var le2 = le / 2; {
var ur = 1f; spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{
var ip = item + le2;
var tr = re[ip] * ur - im[ip] * ui;
var ti = re[ip] * ui + im[ip] * ur;
re[ip] = re[item] - tr;
im[ip] = im[item] - ti;
re[item] += tr;
im[item] += ti;
}
var tempUr = ur;
ur = tempUr * wr - ui * wi;
ui = tempUr * wi + ui * wr;
}
}
// Normalize and fill spectrum
var factor = 2.0f / 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;
}
} }
} }
} }
+26 -32
View File
@@ -1,41 +1,35 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioSyncCalculator
{ {
internal class AudioSyncCalculator private readonly long _sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational;
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
private long _remainder;
public AudioSyncCalculator(long sampleRate, double fps)
{ {
private readonly long _sampleRate; _sampleRate = sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational; _fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
}
// ReSharper disable once NotAccessedField.Local public void Reset()
private long _currentSample; {
private long _remainder; _currentSample = 0;
_remainder = 0;
}
public AudioSyncCalculator(long sampleRate, double fps) public int GetNextFrameSamples()
{ {
_sampleRate = sampleRate; var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps); var samplesNeeded = numerator / _fpsRational.Numerator;
// NTSC _remainder = numerator % _fpsRational.Numerator;
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001); _currentSample += samplesNeeded;
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001); return (int)samplesNeeded;
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
}
public void Reset()
{
_currentSample = 0;
_remainder = 0;
}
public int GetNextFrameSamples()
{
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
_remainder = numerator % _fpsRational.Numerator;
_currentSample += samplesNeeded;
return (int)samplesNeeded;
}
} }
} }
+17 -18
View File
@@ -1,24 +1,23 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal class Gain : IAudioFilter
{ {
internal class Gain : IAudioFilter public float Level = 0f;
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{ {
public float Level = 0f; for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
public void Reset() { }
public double Latency => 0.0;
} }
+25 -26
View File
@@ -1,33 +1,32 @@
using System; using System;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal class HardClip : IAudioFilter
{ {
internal class HardClip : IAudioFilter // ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{ {
// ReSharper disable once MemberCanBePrivate.Global if (sample > Threshold) return Threshold;
// ReSharper disable once FieldCanBeMadeReadOnly.Global if (sample < -Threshold) return -Threshold;
// ReSharper disable once ConvertToConstant.Global return sample;
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
} }
+8 -9
View File
@@ -1,16 +1,15 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal interface IAudioFilter
{ {
internal interface IAudioFilter void Initialize(int sampleRate, int channels);
{
void Initialize(int sampleRate, int channels);
float ProcessSample(float sample, int channel); float ProcessSample(float sample, int channel);
void ProcessBlock(Span<float> buffer); void ProcessBlock(Span<float> buffer);
void Reset(); void Reset();
double Latency { get; } double Latency { get; }
}
} }
+218 -219
View File
@@ -1,229 +1,228 @@
using System; using System;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
// ReSharper disable once InconsistentNaming
internal class RMSCompressor : IAudioFilter
{ {
// ReSharper disable once InconsistentNaming private float _thresholdDb = -20f;
internal class RMSCompressor : IAudioFilter 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 int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _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 int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{ {
private float _thresholdDb = -20f; get => _lookAheadTime;
private float _ratio = 4f; set
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 int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _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 int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{ {
get => _lookAheadTime; _lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
UpdateLookAheadSettings();
}
}
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
UpdateLookAheadSettings(); UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// 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;
float rmsSq = Math.Max(0f, _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);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float 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;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
for (int i = 0; i < _delayBuffers.Length; i++)
{
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
} }
} }
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// 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;
float rmsSq = Math.Max(0f, _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);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float 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;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
foreach (var delayBuffer in _delayBuffers)
{
Array.Clear(delayBuffer, 0, delayBuffer.Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
}
} }
+332 -333
View File
@@ -5,413 +5,412 @@ using ADOFAIRenderer.Util.Audio.Filter;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
{ {
internal class AudioClipCache private readonly Dictionary<string, CachedClipData> _cache = new();
public class CachedClipData
{ {
private readonly Dictionary<string, CachedClipData> _cache = new(); public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
public class CachedClipData public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
lock (_cache)
{ {
public float[] Samples = null!; // Pre-resampled to target channels and sample rate if (_cache.TryGetValue(clipName, out var cached))
public double Duration; return cached;
}
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true) if (!allowLoad) return null;
{
if (string.IsNullOrEmpty(clipName)) return null;
lock (_cache) if (!AudioManager.Instance) return null;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
{ {
if (_cache.TryGetValue(clipName, out var cached)) var totalSamples = clip.samples;
return cached; var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
if (!allowLoad) return null; // Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
if (!AudioManager.Instance) return null; var data = new CachedClipData
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
{ {
var totalSamples = clip.samples; Samples = resampled,
var channels = clip.channels; Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
var rawSamples = new float[totalSamples * channels]; };
clip.GetData(rawSamples, 0);
// Auto-resample during cache generation _cache[clipName] = data;
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate); return data;
var data = new CachedClipData
{
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
_cache[clipName] = data;
return data;
}
catch (Exception e)
{
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
} }
} catch (Exception e)
public void Clear()
{
lock (_cache)
{ {
_cache.Clear(); MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
} }
} }
} }
internal class OfflineAudioMixer public void Clear()
{ {
public class SoundEvent lock (_cache)
{ {
public string ClipName = null!; _cache.Clear();
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
} }
}
}
private readonly List<SoundEvent> _activeEvents = new(); internal class OfflineAudioMixer
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new(); {
private readonly object _lock = new(); public class SoundEvent
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
}
// Target Audio Settings private readonly List<SoundEvent> _activeEvents = new();
private readonly int _targetChannels; private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly int _targetSampleRate; private readonly object _lock = new();
private const float ClipVolume = .5f;
// Audio Filter Settings // Target Audio Settings
private readonly LinkedList<IAudioFilter> _filters = new(); private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
// BGM Cached Samples (Pre-resampled) // Audio Filter Settings
private float[]? _bgmSamples; private readonly LinkedList<IAudioFilter> _filters = new();
private double _bgmDuration;
// Clip Cache // BGM Cached Samples (Pre-resampled)
private readonly AudioClipCache _clipCache = new(); private float[]? _bgmSamples;
private bool _isPrimed; private double _bgmDuration;
public OfflineAudioMixer(int targetChannels, int targetSampleRate) // Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
var rmsCompressor = new RMSCompressor
{ {
_targetChannels = targetChannels; ThresholdDb = -10, Ratio = 2,
_targetSampleRate = targetSampleRate; AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var rmsCompressor = new RMSCompressor var limiter = new RMSCompressor
{
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
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
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
}
public void InitializeBGM(AudioClip? clip)
{ {
lock (_lock) ThresholdDb = 0, Ratio = float.PositiveInfinity,
{ AttackTime = 0f, ReleaseTime = 0.05f,
if (!clip) KneeDb = 0, RmsWindowTime = 0.02f,
{ LookAheadTime = 0.01f
_bgmSamples = null; };
_bgmDuration = 0; limiter.Initialize(_targetSampleRate, _targetChannels);
return; _filters.AddLast(limiter);
}
try var hardClip = new HardClip();
{ hardClip.Initialize(_targetSampleRate, _targetChannels);
int originalSamples = clip.samples; _filters.AddLast(hardClip);
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Pre-resample the BGM to the target format var truePeakSafetyGain = new Gain { Level = -1f };
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate); truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate); _filters.AddLast(truePeakSafetyGain);
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}"); public void InitializeBGM(AudioClip? clip)
} {
catch (Exception e) lock (_lock)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
}
}
public void CacheClip(string clipName)
{ {
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true); if (!clip)
}
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; _bgmSamples = null;
_bgmDuration = 0; _bgmDuration = 0;
_clipCache.Clear(); return;
_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 try
{ {
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels); int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Mix starting from -totalLatency to 0 // Pre-resample the BGM to the target format
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency); _bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency); _bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
// Feed the buffer into filters to prime circular delay buffers Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
} }
finally catch (Exception e)
{ {
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer); Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
} }
} }
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase) public void CacheClip(string clipName)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{ {
// mixedBuffer size must be at least samplesNeeded * _targetChannels var ev = new SoundEvent
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{ {
double totalLatency = 0; ClipName = clipName,
foreach (var filter in _filters) StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// 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++)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
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++)
{ {
totalLatency += filter.Latency; 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)
// Prime filters on first frame execution if latency exists * ClipVolume;
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) private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
{ {
if (_bgmSamples == null || _bgmSamples.Length == 0) return; var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
var conductor = scrConductor.instance; if (clipData == null)
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// 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++)
{ {
double sampleTime = frameStartTime + (double)s / _targetSampleRate; _activeEvents.RemoveAt(i);
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch; continue;
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;
}
}
} }
}
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency) var isSoundFinished = false;
{ var totalSamples = clipData.Samples.Length / _targetChannels;
for (var i = _activeEvents.Count - 1; i >= 0; i--) double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++)
{ {
var ev = _activeEvents[i]; var sampleTime = frameStartTime + (double)s / _targetSampleRate;
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null) // If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{ {
_activeEvents.RemoveAt(i); isSoundFinished = true;
continue; break;
} }
var isSoundFinished = false; var elapsed = sampleTime - adjustedStartTime;
var totalSamples = clipData.Samples.Length / _targetChannels; if (elapsed < 0) continue; // Sound hasn't started yet
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++) var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{ {
var sampleTime = frameStartTime + (double)s / _targetSampleRate; if (ev.EndTime < 0) // One-shot
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{ {
isSoundFinished = true; isSoundFinished = true;
break; break;
} }
var elapsed = sampleTime - adjustedStartTime; // Looping
if (elapsed < 0) continue; // Sound hasn't started yet soundTime %= clipData.Duration;
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) // Linear interpolation for pitch shifting in the mixer loop
if (isSoundFinished) 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++)
{ {
_activeEvents.RemoveAt(i); 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);
}
} }
} }
} }
+26 -27
View File
@@ -4,41 +4,40 @@ using System.Resources;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util;
internal static class BundleLoader
{ {
internal static class BundleLoader private static readonly string BundlePlatform;
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
{ {
private static readonly string BundlePlatform; BundlePlatform = Application.platform switch
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
{ {
BundlePlatform = Application.platform switch RuntimePlatform.WindowsPlayer => "Win64",
{ RuntimePlatform.OSXPlayer => "OSX",
RuntimePlatform.WindowsPlayer => "Win64", _ => "Linux"
RuntimePlatform.OSXPlayer => "OSX", };
_ => "Linux" ExecutingAssembly = Assembly.GetExecutingAssembly();
}; }
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
public static AssetBundle GetBundle(string resourcePath) 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}"; return AssetBundle.LoadFromStream(resourceStream);
using var resourceStream = ExecutingAssembly.GetManifestResourceStream(bundlePath);
if (resourceStream != null)
{
return AssetBundle.LoadFromStream(resourceStream);
}
Melon<RenderMod>.Logger.Error($"DLL에서 번들 리소스를 찾을 수 없습니다.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
} }
Melon<RenderMod>.Logger.Error("실행 어셈블리가 제대로 설정되지 않았습니다."); Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
throw new InvalidOperationException("ExecutingAssembly has not been set."); 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
}
}
+106 -106
View File
@@ -2,112 +2,112 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util.FFmpeg namespace ADOFAIRenderer.Util.FFmpeg;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum EncoderKind
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] Software,
public enum EncoderKind NVENC,
{ AMF,
Software, QSV,
NVENC, VideoToolbox
AMF, }
QSV,
VideoToolbox [SuppressMessage("ReSharper", "InconsistentNaming")]
} internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
[SuppressMessage("ReSharper", "InconsistentNaming")] // ReSharper disable MemberCanBePrivate.Global
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{ // ── H.264 ──
// ReSharper disable MemberCanBePrivate.Global 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);
// ── H.264 ── public static readonly VideoEncoder H264_AMF = new("h264_amf", Codec.H264, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder H264_NVENC = new("h264_nvenc", Codec.H264, GPUVendor.NVIDIA, EncoderKind.NVENC); public static readonly VideoEncoder H264_VideoToolbox = new("h264_videotoolbox", Codec.H264, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder H264_QSV = new("h264_qsv", Codec.H264, GPUVendor.Intel, EncoderKind.QSV); public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software);
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); // ── H.265 (HEVC) ──
public static readonly VideoEncoder Libx264 = new("libx264", Codec.H264, GPUVendor.Unknown, EncoderKind.Software); 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);
// ── H.265 (HEVC) ── public static readonly VideoEncoder HEVC_AMF = new("hevc_amf", Codec.H265, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder HEVC_NVENC = new("hevc_nvenc", Codec.H265, GPUVendor.NVIDIA, EncoderKind.NVENC); public static readonly VideoEncoder HEVC_VideoToolbox = new("hevc_videotoolbox", Codec.H265, GPUVendor.Apple, EncoderKind.VideoToolbox);
public static readonly VideoEncoder HEVC_QSV = new("hevc_qsv", Codec.H265, GPUVendor.Intel, EncoderKind.QSV); public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software);
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); // ── VP9 ──
public static readonly VideoEncoder Libx265 = new("libx265", Codec.H265, GPUVendor.Unknown, EncoderKind.Software); 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);
// ── VP9 ── public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software);
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); // ── AV1 ──
public static readonly VideoEncoder LibvpxVP9 = new("libvpx-vp9", Codec.VP9, GPUVendor.Unknown, EncoderKind.Software); 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);
// ── AV1 ── public static readonly VideoEncoder AV1_AMF = new("av1_amf", Codec.AV1, GPUVendor.AMD, EncoderKind.AMF);
public static readonly VideoEncoder AV1_NVENC = new("av1_nvenc", Codec.AV1, GPUVendor.NVIDIA, EncoderKind.NVENC); public static readonly VideoEncoder LibSvtAv1 = new("libsvtav1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
public static readonly VideoEncoder AV1_QSV = new("av1_qsv", Codec.AV1, GPUVendor.Intel, EncoderKind.QSV); public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software);
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); // ── VVC ──
public static readonly VideoEncoder LibAomAv1 = new("libaom-av1", Codec.AV1, GPUVendor.Unknown, EncoderKind.Software); public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
// ── VVC ── // ReSharper restore MemberCanBePrivate.Global
public static readonly VideoEncoder LibVvenc = new("libvvenc", Codec.VVC, GPUVendor.Unknown, EncoderKind.Software);
public static readonly IReadOnlyList<VideoEncoder> All = new[]
// ReSharper restore MemberCanBePrivate.Global {
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264,
public static readonly IReadOnlyList<VideoEncoder> All = new[] HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265,
{ VP9_NVENC, VP9_QSV, LibvpxVP9,
H264_NVENC, H264_QSV, H264_AMF, H264_VideoToolbox, Libx264, AV1_NVENC, AV1_QSV, AV1_AMF, LibSvtAv1, LibAomAv1,
HEVC_NVENC, HEVC_QSV, HEVC_AMF, HEVC_VideoToolbox, Libx265, LibVvenc
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);
private static readonly Dictionary<string, VideoEncoder> ByName = public string Name { get; }
All.ToDictionary(e => e.Name, StringComparer.OrdinalIgnoreCase); public Codec Codec { get; }
public GPUVendor Vendor { get; }
public string Name { get; } public EncoderKind Kind { get; }
public Codec Codec { get; } public bool IsHardware => Kind != EncoderKind.Software;
public GPUVendor Vendor { get; }
public EncoderKind Kind { get; } private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind)
public bool IsHardware => Kind != EncoderKind.Software; {
Name = name;
private VideoEncoder(string name, Codec codec, GPUVendor vendor, EncoderKind kind) Codec = codec;
{ Vendor = vendor;
Name = name; Kind = kind;
Codec = codec; }
Vendor = vendor;
Kind = kind; public static VideoEncoder? FromName(string name)
} {
return string.IsNullOrEmpty(name) ? null : ByName.GetValueOrDefault(name);
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;
public bool Equals(VideoEncoder other) return ReferenceEquals(this, other) || string.Equals(Name, other.Name, StringComparison.OrdinalIgnoreCase);
{ }
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 bool Equals(object? obj) }
{
return ReferenceEquals(this, obj) || obj is VideoEncoder other && Equals(other); public override int GetHashCode()
} {
return StringComparer.OrdinalIgnoreCase.GetHashCode(Name);
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 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;
}
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] 비디오 파일이 존재하지 않습니다: {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] Compute Shader NV12 모드 초기화 완료");
}
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] Compute Shader YUV420P 모드 초기화 완료");
}
catch
{
// ignored
}
break;
default:
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 색공간 변환에 Compute Shader를 사용할 수 없습니다.");
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 가속 미지원 또는 로드 실패로 CPU 폴백(RGBA)으로 진행합니다.");
}
_currentFrameTimestamp = 0.0;
_nextFrameTimestamp = 0.0;
_isInitialized = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] 초기화 성공 - 해상도: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] 초기화 중 에러 발생: {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] 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] 디코더 해제 완료.");
}
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);
}
}
}
+53 -68
View File
@@ -3,6 +3,7 @@ using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using MelonLoader; using MelonLoader;
using Unity.Collections; using Unity.Collections;
@@ -26,11 +27,33 @@ namespace ADOFAIRenderer.Util
private bool _isInitialized; private bool _isInitialized;
private volatile bool _isDisposed; private volatile bool _isDisposed;
private readonly AVPixelFormat _targetFormat; private readonly ComputeShader? _computeShader;
private readonly int _kernelIndex = -1;
private readonly bool _useGPUConversion;
private readonly AVPixelFormat _expectedFormat;
public FrameReader(AVPixelFormat targetFormat) 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(); InitializePool();
} }
@@ -70,72 +93,35 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true; state.IsInFlight = true;
} }
// GPU 컬러 변환 활성화 여부 검사
bool useGPUConversion = RenderMod.ColorConversionShader;
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
if (useGPUConversion)
{
expectedFmt = _targetFormat;
// Compute Shader는 NV12와 YUV420P만 지원하므로 그 외 포맷은 CPU 폴백 처리
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 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; _computeShader.SetTexture(_kernelIndex, SourceTexture, texture);
if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12) _computeShader.SetBuffer(_kernelIndex, DestinationBuffer, state.GPUBuffer);
{ _computeShader.SetInt(Width, texture.width);
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_NV12"); _computeShader.SetInt(Height, texture.height);
}
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
{
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_YUV420P");
}
if (kernelIndex >= 0) var threadGroupsX = _expectedFormat == AVPixelFormat.AV_PIX_FMT_NV12
{ ? (texture.width + 31) / 32
RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture); : (texture.width + 63) / 64;
RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer); var threadGroupsY = (texture.height + 15) / 16;
RenderMod.ColorConversionShader.SetInt(Width, texture.width);
RenderMod.ColorConversionShader.SetInt(Height, texture.height);
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12 _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1); AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
AsyncGPUReadback.Request( );
state.GPUBuffer, /*AsyncGPUReadback.Request(
state.CachedCallback state.GPUBuffer,
); state.CachedCallback
} );*/
else
{
// 커널을 못 찾은 경우 기존 RGBA 텍스처 백업 기법으로 폴백
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
0,
state.CachedCallback
);
}
} }
else else
{ {
AsyncGPUReadback.RequestIntoNativeArray( AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, ref state.NativeBuffer, texture, 0, state.CachedCallback
texture,
0,
state.CachedCallback
); );
} }
} }
@@ -154,7 +140,7 @@ namespace ADOFAIRenderer.Util
} }
} }
if (RenderManager.Instance && RenderManager.Instance.IsRenderCancelled) if (RenderManager.Instance && RenderManager.Instance.IsRenderAborted)
{ {
RecycleState(state); RecycleState(state);
return; return;
@@ -168,16 +154,14 @@ namespace ADOFAIRenderer.Util
} }
Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame."); Melon<RenderMod>.Logger.Warning($"[OOM Prevent] GPU Readback failed for frame {frame}. Queue deadlock prevented by sending a black frame.");
var byteSize = state.IsGPUConversion var byteSize = state.Width * state.Height * 3 / 2;
? state.Width * state.Height * 3 / 2
: state.Width * state.Height * 4;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize); var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize); Array.Clear(blackFrame, 0, byteSize);
var capFrame = new RenderManager.CapturedFrame var capFrame = new RenderManager.CapturedFrame
{ {
ManagedBuffer = blackFrame, ManagedBuffer = blackFrame, Length = byteSize,
Length = byteSize IsColorConverted = true
}; };
PushToQueue(frame, capFrame); PushToQueue(frame, capFrame);
RecycleState(state); RecycleState(state);
@@ -186,19 +170,20 @@ namespace ADOFAIRenderer.Util
{ {
unsafe unsafe
{ {
/*
if (state.IsGPUConversion) if (state.IsGPUConversion)
{ {
var data = request.GetData<byte>(); var data = request.GetData<byte>();
// 비동기 요청 버퍼 데이터를 지속성(NativeBuffer) 메모리로 복사 // Copy async request buffer data to persistent NativeBuffer memory
NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length); NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length);
} }
*/
var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr(); var ptr = (IntPtr)state.NativeBuffer.GetUnsafeReadOnlyPtr();
var capFrame = new RenderManager.CapturedFrame var capFrame = new RenderManager.CapturedFrame
{ {
NativePointer = ptr, NativePointer = ptr, Length = state.NativeBuffer.Length,
Length = state.NativeBuffer.Length, ReadbackState = state, IsColorConverted = state.IsGPUConversion
ReadbackState = state
}; };
PushToQueue(frame, capFrame); PushToQueue(frame, capFrame);
} }
+121 -120
View File
@@ -2,152 +2,153 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using ADOFAIRenderer.Util.OS;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")] Unknown, NVIDIA, AMD, Intel, Apple
internal enum GPUVendor }
internal static class GPUUtil
{
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
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()
{ {
Unknown, NVIDIA, AMD, Intel, Apple if (_hasCachedVendor) return _cachedVendor;
}
internal static class GPUUtil var gpuName = SystemInfo.graphicsDeviceName;
{ Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}");
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
private const string IntelPattern = _cachedVendor = GPUVendor.Unknown;
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+$|Arc\(TM\) [A-C][0-9]+ Graphics)$"; foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
// 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; _cachedVendor = GPUVendor.NVIDIA;
break;
}
var gpuName = SystemInfo.graphicsDeviceName; if (_cachedVendor == GPUVendor.Unknown)
Melon<RenderMod>.Logger.Msg($"Active GPU: {gpuName}"); {
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
_cachedVendor = GPUVendor.Unknown;
foreach (Match unused in Regex.Matches(gpuName, NvidiaPattern))
{ {
_cachedVendor = GPUVendor.NVIDIA; _cachedVendor = GPUVendor.Intel;
break; break;
} }
if (_cachedVendor == GPUVendor.Unknown)
{
foreach (Match unused in Regex.Matches(gpuName, IntelPattern))
{
_cachedVendor = GPUVendor.Intel;
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; if (_cachedVendor == GPUVendor.Unknown)
private static bool _hasCachedNvidiaVersion;
public static double? GetNvidiaDriverVersion()
{ {
if (_hasCachedNvidiaVersion) return _cachedNvidiaVersion; foreach (Match unused in Regex.Matches(gpuName, AMDPattern))
_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"); _cachedVendor = GPUVendor.AMD;
if (match.Success) 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 (_hasCachedNvidiaVersion) return _cachedNvidiaVersion;
_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)
{
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;
var nvidiaVerInt = sub % 10 * 10000 + build; Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from SystemInfo: {_cachedNvidiaVersion:F2}");
_cachedNvidiaVersion = nvidiaVerInt / 100.0; return _cachedNvidiaVersion;
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;
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 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;
} }
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false) // 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;
foreach (var subkey in subkeys)
{ {
var encoders = VideoEncoder.All.Where(e => e.Codec == codec).ToList(); var subkeyPath = classKey + "\\" + subkey;
if (forceSoftware) var provider = RegistryUtil.GetHKLMValue(subkeyPath, "ProviderName");
{ if (provider == null || !provider.Contains("NVIDIA")) continue;
return encoders.Where(e => !e.IsHardware).ToList();
}
var vendor = GetActiveGPUVendor(); var driverVersionStr = RegistryUtil.GetHKLMValue(subkeyPath, "DriverVersion");
var prioritized = new List<VideoEncoder>(); if (string.IsNullOrEmpty(driverVersionStr)) continue;
var others = new List<VideoEncoder>();
foreach (var item in encoders) var regMatch = Regex.Match(driverVersionStr, @"\b(\d+)\.(\d+)\.(\d+)\.(\d+)\b");
{ if (!regMatch.Success) continue;
if (item.Vendor == vendor) prioritized.Add(item); if (!int.TryParse(regMatch.Groups[3].Value, out var sub) ||
else others.Add(item); !int.TryParse(regMatch.Groups[4].Value, out var build)) continue;
}
prioritized.AddRange(others); var nvidiaVerInt = sub % 10 * 10000 + build;
return prioritized; _cachedNvidiaVersion = nvidiaVerInt / 100.0;
Melon<RenderMod>.Logger.Msg($"Parsed NVIDIA Driver Version from Registry (RegistryUtil): {_cachedNvidiaVersion:F2}");
return _cachedNvidiaVersion;
} }
return null;
}
public static List<VideoEncoder> GetPrioritizedEncoders(Codec codec, bool forceSoftware = false)
{
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;
} }
} }
+615
View File
@@ -0,0 +1,615 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "UnusedMember.Local")]
internal static class FolderDialogUtil
{
private static int _isDialogOpen;
public static void OpenFolderBrowser(string initialDirectory, Action<string?> onSelected)
{
if (Interlocked.CompareExchange(ref _isDialogOpen, 1, 0) != 0)
{
MelonLogger.Msg("[FolderDialogUtil] Folder dialog is already open.");
return;
}
var syncContext = SynchronizationContext.Current;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var ownerHandle = GetGameWindowHandle();
var thread = new Thread(() =>
{
string? selected = null;
int oleHr = -1;
try
{
oleHr = OleInitialize(IntPtr.Zero);
selected = OpenWindowsFolderBrowser(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
}
finally
{
if (oleHr >= 0)
{
try
{
OleUninitialize();
}
catch
{
// Ignore uninitialize error
}
}
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
})
{
IsBackground = true,
Name = "FolderDialog-Win"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
}
else
{
Task.Run(() =>
{
string? selected = null;
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
selected = OpenMacFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
selected = OpenLinuxFolderBrowser(initialDirectory);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _isDialogOpen, 0);
}
DispatchResult(syncContext, onSelected, selected);
});
}
}
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{
if (syncContext != null)
{
syncContext.Post(_ =>
{
try
{
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}, null);
}
else
{
try
{
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Error in folder selected callback: {ex.Message}");
}
}
}
#region Windows Implementation
private static readonly Guid CLSID_FileOpenDialog = new("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7");
private static readonly Guid IID_IFileOpenDialog = new("d57c7288-d4ad-4768-be02-9d969532d960");
private static readonly Guid IID_IShellItem = new("43826D1E-E718-42EE-BC55-A1E261C37BFE");
private const int ERROR_CANCELLED_HR = unchecked((int)0x800704C7);
[DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow();
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
private static IntPtr GetGameWindowHandle()
{
try
{
var active = GetActiveWindow();
if (active != IntPtr.Zero) return active;
var currentPid = (uint)Process.GetCurrentProcess().Id;
var fg = GetForegroundWindow();
if (fg != IntPtr.Zero)
{
GetWindowThreadProcessId(fg, out var fgPid);
if (fgPid == currentPid)
{
return fg;
}
}
var mainHandle = Process.GetCurrentProcess().MainWindowHandle;
if (mainHandle != IntPtr.Zero)
{
return mainHandle;
}
}
catch
{
// Ignore failure to detect window handle
}
return IntPtr.Zero;
}
private static string? OpenWindowsFolderBrowser(string initialDirectory, IntPtr ownerHandle)
{
try
{
return OpenWindowsFolderBrowserModern(initialDirectory, ownerHandle);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Modern folder dialog failed ({ex.Message}), falling back to legacy dialog.");
return OpenWindowsFolderBrowserLegacy(initialDirectory, ownerHandle);
}
}
private static string? OpenWindowsFolderBrowserModern(string initialDirectory, IntPtr ownerHandle)
{
// 1. Try native CoCreateInstance first (works on Mono and .NET without class registration)
var clsid = CLSID_FileOpenDialog;
var iid = IID_IFileOpenDialog;
int createHr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /* CLSCTX_INPROC_SERVER */, ref iid, out var dialog);
if (createHr != 0)
{
// 2. Fallback to Activator via Type.GetTypeFromCLSID
var comType = Type.GetTypeFromCLSID(CLSID_FileOpenDialog);
if (comType != null)
{
var instance = Activator.CreateInstance(comType);
dialog = instance as IFileOpenDialog;
}
}
if (dialog == null)
{
throw new COMException("Failed to instantiate IFileOpenDialog via COM.", createHr);
}
try
{
dialog.SetOptions(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST);
dialog.SetTitle("Select Output Directory");
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
var shellItemGuid = IID_IShellItem;
int itemHr = SHCreateItemFromParsingName(normalized, IntPtr.Zero, ref shellItemGuid, out var initialItem);
if (itemHr == 0)
{
try
{
dialog.SetFolder(initialItem);
dialog.SetDefaultFolder(initialItem);
}
finally
{
Marshal.ReleaseComObject(initialItem);
}
}
}
}
catch
{
// Ignore failure to set initial folder
}
}
int hr = dialog.Show(ownerHandle);
if (hr != 0 && hr != ERROR_CANCELLED_HR && ownerHandle != IntPtr.Zero)
{
// If Show failed with ownerHandle (e.g. cross-thread or invalid handle), retry without owner
MelonLogger.Msg($"[FolderDialogUtil] dialog.Show(ownerHandle) returned 0x{hr:X8}, retrying with IntPtr.Zero.");
hr = dialog.Show(IntPtr.Zero);
}
if (hr == ERROR_CANCELLED_HR)
{
return null;
}
if (hr != 0)
{
throw new COMException("IFileOpenDialog.Show failed", hr);
}
dialog.GetResult(out var resultItem);
try
{
if (resultItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pszPath) == 0 && pszPath != IntPtr.Zero)
{
try
{
var path = Marshal.PtrToStringUni(pszPath);
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
finally
{
Marshal.FreeCoTaskMem(pszPath);
}
}
}
finally
{
Marshal.ReleaseComObject(resultItem);
}
return null;
}
finally
{
Marshal.ReleaseComObject(dialog);
}
}
private static string? OpenWindowsFolderBrowserLegacy(string initialDirectory, IntPtr ownerHandle)
{
IntPtr pszDisplayName = Marshal.AllocHGlobal(260 * sizeof(char));
IntPtr initialDirPtr = IntPtr.Zero;
BrowseCallbackProc? callback = null;
IntPtr pidl = IntPtr.Zero;
try
{
var bi = new BROWSEINFO
{
hwndOwner = ownerHandle,
pidlRoot = IntPtr.Zero,
pszDisplayName = pszDisplayName,
lpszTitle = "Select Output Directory",
ulFlags = BIF_RETURNONLYFSDIRS | BIF_NEWDIALOGSTYLE | BIF_EDITBOX
};
if (!string.IsNullOrWhiteSpace(initialDirectory))
{
try
{
var normalized = Path.GetFullPath(initialDirectory).TrimEnd('\\', '/');
if (Directory.Exists(normalized))
{
initialDirPtr = Marshal.StringToHGlobalUni(normalized);
callback = (hwnd, uMsg, _, lpData) =>
{
if (uMsg == BFFM_INITIALIZED && lpData != IntPtr.Zero)
{
SendMessage(hwnd, BFFM_SETSELECTIONW, (IntPtr)1, lpData);
}
return 0;
};
bi.lpfn = callback;
bi.lParam = initialDirPtr;
}
}
catch
{
// Ignore failure to prepare initial directory
}
}
pidl = SHBrowseForFolderW(ref bi);
if (pidl == IntPtr.Zero && ownerHandle != IntPtr.Zero)
{
// If it returned null with ownerHandle, check if retry with IntPtr.Zero is appropriate
// But note: cancelling also returns Zero. We do not redundantly re-prompt if cancelled.
}
if (pidl != IntPtr.Zero)
{
var sb = new StringBuilder(260);
if (SHGetPathFromIDListW(pidl, sb))
{
var path = sb.ToString();
return !string.IsNullOrEmpty(path) && Directory.Exists(path) ? path : null;
}
}
return null;
}
finally
{
if (pidl != IntPtr.Zero)
{
Marshal.FreeCoTaskMem(pidl);
}
if (initialDirPtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(initialDirPtr);
}
if (pszDisplayName != IntPtr.Zero)
{
Marshal.FreeHGlobal(pszDisplayName);
}
GC.KeepAlive(callback);
}
}
#region COM & Win32 Interop Definitions
[Flags]
private enum FOS : uint
{
FOS_NOCHANGEDIR = 0x00000008,
FOS_PICKFOLDERS = 0x00000020,
FOS_FORCEFILESYSTEM = 0x00000040,
FOS_PATHMUSTEXIST = 0x00000800,
FOS_FILEMUSTEXIST = 0x00001000,
}
private enum SIGDN : uint
{
SIGDN_FILESYSPATH = 0x80058000
}
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(IntPtr pbc, [In] ref Guid bhid, [In] ref Guid riid, out IntPtr ppv);
void GetParent(out IShellItem ppsi);
[PreserveSig]
int GetDisplayName(SIGDN sigdnName, out IntPtr ppszName);
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
void Compare(IShellItem psi, uint hint, out int piOrder);
}
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog
{
[PreserveSig]
int Show(IntPtr parent);
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
void SetFileTypeIndex(uint iFileType);
void GetFileTypeIndex(out uint piFileType);
void Advise(IntPtr pfde, out uint pdwCookie);
void Unadvise(uint dwCookie);
void SetOptions(FOS fos);
void GetOptions(out FOS pfos);
void SetDefaultFolder(IShellItem psi);
void SetFolder(IShellItem psi);
void GetFolder(out IShellItem ppsi);
void GetCurrentSelection(out IShellItem ppsi);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
void GetResult(out IShellItem ppsi);
void AddPlace(IShellItem psi, int fdap);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
void Close(int hr);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(IntPtr pFilter);
void GetResults(out IntPtr ppenum);
void GetSelectedItems(out IntPtr ppsai);
}
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int OleInitialize(IntPtr pvReserved);
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern void OleUninitialize();
[DllImport("ole32.dll", ExactSpelling = true)]
private static extern int CoCreateInstance(
[In] ref Guid rclsid,
IntPtr pUnkOuter,
uint dwClsContext,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IFileOpenDialog ppv);
[DllImport("shell32.dll", CharSet = CharSet.Unicode, PreserveSig = true)]
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath,
IntPtr pbc,
[In] ref Guid riid,
[Out, MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
private const uint BIF_RETURNONLYFSDIRS = 0x0001;
private const uint BIF_NEWDIALOGSTYLE = 0x0040;
private const uint BIF_EDITBOX = 0x0010;
private const uint BFFM_INITIALIZED = 1;
private const uint BFFM_SETSELECTIONW = 0x0400 + 103;
private delegate int BrowseCallbackProc(IntPtr hwnd, uint uMsg, IntPtr lParam, IntPtr lpData);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct BROWSEINFO
{
public IntPtr hwndOwner;
public IntPtr pidlRoot;
public IntPtr pszDisplayName;
public string lpszTitle;
public uint ulFlags;
public BrowseCallbackProc? lpfn;
public IntPtr lParam;
public int iImage;
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SHBrowseForFolderW(ref BROWSEINFO lpbi);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SHGetPathFromIDListW(IntPtr pidl, StringBuilder pszPath);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
#endregion
#endregion
#region macOS Implementation
private static string? OpenMacFolderBrowser(string initialDirectory)
{
try
{
string script;
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
var escaped = initialDirectory.Replace("\\", "\\\\").Replace("\"", "\\\"");
script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location (POSIX file \"{escaped}\"))";
}
else
{
script = "POSIX path of (choose folder with prompt \"Select Output Directory\")";
}
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
using var process = Process.Start(startInfo);
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Mac folder dialog error: {ex.Message}");
return null;
}
}
#endregion
#region Linux Implementation
private static string? OpenLinuxFolderBrowser(string initialDirectory)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "zenity",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("--file-selection");
startInfo.ArgumentList.Add("--directory");
startInfo.ArgumentList.Add("--title=Select Output Directory");
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
startInfo.ArgumentList.Add($"--filename={initialDirectory}");
}
try
{
using var process = Process.Start(startInfo);
if (process != null)
{
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output))
{
return output;
}
}
}
catch
{
// Fallback to kdialog if zenity fails to start
}
var kdialogInfo = new ProcessStartInfo
{
FileName = "kdialog",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
kdialogInfo.ArgumentList.Add("--getexistingdirectory");
if (!string.IsNullOrEmpty(initialDirectory) && Directory.Exists(initialDirectory))
{
kdialogInfo.ArgumentList.Add(initialDirectory);
}
kdialogInfo.ArgumentList.Add("--title");
kdialogInfo.ArgumentList.Add("Select Output Directory");
using var kdialogProcess = Process.Start(kdialogInfo);
if (kdialogProcess == null) return null;
var koutput = kdialogProcess.StandardOutput.ReadToEnd().Trim();
kdialogProcess.WaitForExit();
return !string.IsNullOrEmpty(koutput) && Directory.Exists(koutput) ? koutput : null;
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Linux folder dialog error: {ex.Message}");
return null;
}
}
#endregion
}
+577
View File
@@ -0,0 +1,577 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util.OS;
/// <summary>
/// Sends native desktop notifications on Windows, macOS, and Linux.
/// <para>
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
/// No PowerShell, no UWP, no WinRT dependencies.
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// </para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
/// the folder when the notification is clicked/dismissed.
/// </summary>
/// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param>
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
public static void Show(string title, string message, string? folderPath = null)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
WindowsNotification.Send(title, message, folderPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
MacNotification.Send(title, message);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
LinuxNotification.Send(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
}
}
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
/// <summary>
/// Windows notification with two strategies:
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
/// </summary>
private static class WindowsNotification
{
public static void Send(string title, string message, string? folderPath)
{
var thread = new Thread(() =>
{
try
{
if (!TrySendToast(title, message, folderPath))
{
SendBalloon(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
}
})
{
IsBackground = true,
Name = "OSNotification-Win"
};
thread.Start();
}
#region Toast via PowerShell
/// <summary>
/// Attempts to show a Windows Toast notification via PowerShell.
/// Returns true if the process launched successfully, false otherwise.
/// </summary>
private static bool TrySendToast(string title, string message, string? folderPath)
{
try
{
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
string toastXml;
string psScript;
if (!string.IsNullOrEmpty(folderPath))
{
var escapedFolder = folderPath.Replace("'", "''");
toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
psScript =
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
"$null = New-Item -Path $protocolPath -Force\n" +
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
"$null = New-Item -Path $cmdPath -Force\n" +
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
else
{
toastXml =
"<toast><visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual></toast>";
psScript =
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure
if (process.ExitCode != 0)
{
string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
return false;
}
return true;
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
return false;
}
}
#endregion
#region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming
private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02;
private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04;
private const int NIF_INFO = 0x10;
private const int NIIF_INFO = 0x01;
private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405;
private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uTimeoutOrVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int pt_x;
public int pt_y;
}
private static void SendBalloon(string title, string message, string? folderPath)
{
if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255);
IntPtr hWnd = IntPtr.Zero;
var nid = new NotifyIconDataW();
try
{
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
WS_OVERLAPPED, 0, 0, 0, 0,
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
if (hWnd == IntPtr.Zero)
{
MelonLogger.Warning("[OSNotification] Cannot create message window.");
return;
}
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
nid.hWnd = hWnd;
nid.uID = TRAY_ICON_UID;
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = hIcon;
nid.szTip = "ADOFAI Renderer";
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
if (!string.IsNullOrEmpty(folderPath))
{
var capturedFolder = folderPath;
var closeTimer = new System.Threading.Timer(_ =>
{
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
catch { /* ignored */ }
}, null, 12000, Timeout.Infinite);
try
{
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
{
if (msg.message == WM_CLOSE)
break;
if (msg.message == WM_TRAYICON)
{
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
try
{
var psi = new ProcessStartInfo();
psi.FileName = capturedFolder;
psi.UseShellExecute = true;
Process.Start(psi);
}
catch (Exception ex)
{
MelonLogger.Warning(
$"[OSNotification] Cannot open folder: {ex.Message}");
}
break;
}
}
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
finally
{
closeTimer.Dispose();
}
}
else
{
Thread.Sleep(500);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
}
finally
{
if (hWnd != IntPtr.Zero)
{
nid.uFlags = 0;
Shell_NotifyIconW(NIM_DELETE, ref nid);
DestroyWindow(hWnd);
}
}
}
#endregion
}
#endregion
#region macOS — osascript
/// <summary>
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
/// </summary>
private static class MacNotification
{
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
string escapedTitle = EscapeAppleScript(title);
string escapedMessage = EscapeAppleScript(message);
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
// Each argument is passed directly to the process without shell interpretation.
var script =
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
RunProcess(startInfo);
}
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
#endregion
#region Linux — notify-send / gdbus
/// <summary>
/// Linux notification using <c>notify-send</c> (libnotify CLI).
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
/// </summary>
private static class LinuxNotification
{
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
{
ShowWithAction(title, message, folderPath);
}
else
{
ShowSimple(title, message);
}
}
private static void ShowSimple(string title, string message)
{
// Try notify-send first
if (TryNotifySend(title, message))
return;
// Fallback to gdbus
TryGdbus(title, message);
}
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
// until the notification is dismissed or the action is clicked.
System.Threading.Tasks.Task.Run(() =>
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (output == "open_folder")
{
var openInfo = new ProcessStartInfo
{
FileName = "xdg-open",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
openInfo.ArgumentList.Add(folderPath);
RunProcess(openInfo);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
// Fallback: show without action
ShowSimple(title, message);
}
});
}
private static bool TryNotifySend(string title, string message)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
RunProcess(startInfo);
return true;
}
catch
{
return false;
}
}
private static void TryGdbus(string title, string message)
{
try
{
// Direct D-Bus call to org.freedesktop.Notifications
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("call");
startInfo.ArgumentList.Add("--session");
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
startInfo.ArgumentList.Add("0"); // replaces_id
startInfo.ArgumentList.Add(""); // app_icon
startInfo.ArgumentList.Add(title); // summary
startInfo.ArgumentList.Add(message); // body
startInfo.ArgumentList.Add("[]"); // actions
startInfo.ArgumentList.Add("{}"); // hints
startInfo.ArgumentList.Add("-1"); // expire_timeout
RunProcess(startInfo);
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
}
}
}
#endregion
#region Shared utility
/// <summary>
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
/// </summary>
private static void RunProcess(ProcessStartInfo startInfo)
{
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
}
#endregion
}
+129
View File
@@ -0,0 +1,129 @@
using System;
namespace ADOFAIRenderer.Util.OS;
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
static RegistryUtil()
{
try
{
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
{
var mscorlib = typeof(object).Assembly;
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
}
if (RegistryKeyType != null)
{
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
}
}
catch
{
// ignored
}
}
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
try
{
if (key is IDisposable disposable)
{
disposable.Dispose();
}
else
{
var closeMethod = RegistryKeyType.GetMethod("Close");
closeMethod?.Invoke(key, null);
}
}
catch
{
// ignored
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
CloseKey(key);
CloseKey(baseKey);
return subkeyNames;
}
catch
{
return null;
}
}
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
CloseKey(key);
CloseKey(baseKey);
return value;
}
catch
{
return null;
}
}
}
-578
View File
@@ -1,578 +0,0 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using MelonLoader;
namespace ADOFAIRenderer.Util
{
/// <summary>
/// Sends native desktop notifications on Windows, macOS, and Linux.
/// <para>
/// - <b>Windows</b>: Uses Win32 <c>Shell_NotifyIcon</c> (system tray balloon) via P/Invoke.
/// No PowerShell, no UWP, no WinRT dependencies.
/// </para>
/// <para>
/// - <b>macOS</b>: Uses <c>osascript</c> with AppleScript's <c>display notification</c>.
/// </para>
/// <para>
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// </para>
/// </summary>
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
/// If <paramref name="folderPath"/> is provided, Windows and Linux will attempt to open
/// the folder when the notification is clicked/dismissed.
/// </summary>
/// <param name="title">The notification title.</param>
/// <param name="message">The notification body text.</param>
/// <param name="folderPath">Optional folder path to open on click (Windows balloon click / Linux action).</param>
public static void Show(string title, string message, string? folderPath = null)
{
try
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
WindowsNotification.Send(title, message, folderPath);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
MacNotification.Send(title, message);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
LinuxNotification.Send(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] 알림을 보내지 못했습니다: {ex.Message}");
}
}
#region Windows — Toast (primary) + Shell_NotifyIcon (fallback)
/// <summary>
/// Windows notification with two strategies:
/// <para>1. <b>Toast</b> via PowerShell — persists in Action Center.</para>
/// <para>2. <b>Shell_NotifyIcon balloon</b> via P/Invoke — fallback if toast fails.</para>
/// </summary>
private static class WindowsNotification
{
public static void Send(string title, string message, string? folderPath)
{
var thread = new Thread(() =>
{
try
{
if (!TrySendToast(title, message, folderPath))
{
SendBalloon(title, message, folderPath);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Windows 알림 실패: {ex.Message}");
}
})
{
IsBackground = true,
Name = "OSNotification-Win"
};
thread.Start();
}
#region Toast via PowerShell
/// <summary>
/// Attempts to show a Windows Toast notification via PowerShell.
/// Returns true if the process launched successfully, false otherwise.
/// </summary>
private static bool TrySendToast(string title, string message, string? folderPath)
{
try
{
string xmlTitle = System.Security.SecurityElement.Escape(title) ?? title;
string xmlMessage = System.Security.SecurityElement.Escape(message) ?? message;
string toastXml;
string psScript;
if (!string.IsNullOrEmpty(folderPath))
{
var escapedFolder = folderPath.Replace("'", "''");
toastXml =
"<toast launch=\"adofai-renderer://\" activationType=\"protocol\">" +
"<visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"렌더 폴더 열기\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
psScript =
"$protocolPath = 'HKCU:\\Software\\Classes\\adofai-renderer'\n" +
"$null = New-Item -Path $protocolPath -Force\n" +
"$null = New-ItemProperty -Path $protocolPath -Name 'URL Protocol' -Value '' -PropertyType String -Force\n" +
"Set-Item -Path $protocolPath -Value 'URL:adofai-renderer Protocol' | Out-Null\n" +
"$cmdPath = \"$protocolPath\\shell\\open\\command\"\n" +
"$null = New-Item -Path $cmdPath -Force\n" +
$"Set-Item -Path $cmdPath -Value 'explorer.exe \"{escapedFolder}\"' | Out-Null\n" +
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
else
{
toastXml =
"<toast><visual><binding template=\"ToastGeneric\">" +
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual></toast>";
psScript =
"[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n" +
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data, ContentType = WindowsRuntime] | Out-Null\n" +
"$xml = [Windows.Data.Xml.Dom.XmlDocument]::new()\n" +
$"$xml.LoadXml('{toastXml}')\n" +
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ADOFAI Renderer').Show([Windows.UI.Notifications.ToastNotification]::new($xml))";
}
byte[] bytes = System.Text.Encoding.Unicode.GetBytes(psScript);
string base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden
};
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
process.WaitForExit(5000);
// If PowerShell exited with error, treat as failure
if (process.ExitCode != 0)
{
string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {stderr}");
return false;
}
return true;
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {ex.Message}");
return false;
}
}
#endregion
#region Balloon via Shell_NotifyIcon
// ReSharper disable InconsistentNaming
private const int NIM_ADD = 0x00;
private const int NIM_DELETE = 0x02;
private const int NIF_MESSAGE = 0x01;
private const int NIF_ICON = 0x02;
private const int NIF_TIP = 0x04;
private const int NIF_INFO = 0x10;
private const int NIIF_INFO = 0x01;
private const int WM_USER = 0x0400;
private const int WM_TRAYICON = WM_USER + 1;
private const int NIN_BALLOONUSERCLICK = 0x0405;
private const int WS_OVERLAPPED = 0x00000000;
private const uint WM_CLOSE = 0x0010;
private const int TRAY_ICON_UID = 29471;
// ReSharper restore InconsistentNaming
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIconW(int dwMessage, ref NotifyIconDataW lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr CreateWindowExW(
int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr LoadIconW(IntPtr hInstance, IntPtr lpIconName);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMessageW(out Msg lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
[DllImport("user32.dll")]
private static extern IntPtr TranslateMessage(ref Msg lpMsg);
[DllImport("user32.dll")]
private static extern IntPtr DispatchMessageW(ref Msg lpMsg);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconDataW
{
public int cbSize;
public IntPtr hWnd;
public int uID;
public int uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public int dwState;
public int dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
public int uTimeoutOrVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public int dwInfoFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct Msg
{
public IntPtr hwnd;
public uint message;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int pt_x;
public int pt_y;
}
private static void SendBalloon(string title, string message, string? folderPath)
{
if (title.Length > 63) title = title.Substring(0, 63);
if (message.Length > 255) message = message.Substring(0, 255);
IntPtr hWnd = IntPtr.Zero;
var nid = new NotifyIconDataW();
try
{
var hwndMessage = new IntPtr(-3);
hWnd = CreateWindowExW(
0, "Static", "ADOFAIRendererNotify",
WS_OVERLAPPED, 0, 0, 0, 0,
hwndMessage, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero);
if (hWnd == IntPtr.Zero)
{
MelonLogger.Warning("[OSNotification] 메시지 윈도우를 생성할 수 없습니다.");
return;
}
IntPtr hIcon = LoadIconW(IntPtr.Zero, (IntPtr)32512);
nid.cbSize = Marshal.SizeOf(typeof(NotifyIconDataW));
nid.hWnd = hWnd;
nid.uID = TRAY_ICON_UID;
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_INFO;
nid.uCallbackMessage = WM_TRAYICON;
nid.hIcon = hIcon;
nid.szTip = "ADOFAI Renderer";
nid.szInfo = message;
nid.szInfoTitle = title;
nid.dwInfoFlags = NIIF_INFO;
nid.uTimeoutOrVersion = 4;
Shell_NotifyIconW(NIM_ADD, ref nid);
if (!string.IsNullOrEmpty(folderPath))
{
var capturedFolder = folderPath;
var closeTimer = new System.Threading.Timer(_ =>
{
try { PostMessageW(hWnd, WM_CLOSE, IntPtr.Zero, IntPtr.Zero); }
catch { /* ignored */ }
}, null, 12000, Timeout.Infinite);
try
{
while (GetMessageW(out var msg, IntPtr.Zero, 0, 0))
{
if (msg.message == WM_CLOSE)
break;
if (msg.message == WM_TRAYICON)
{
int notifyMsg = (int)(long)msg.lParam;
if (notifyMsg == NIN_BALLOONUSERCLICK)
{
try
{
var psi = new ProcessStartInfo();
psi.FileName = capturedFolder;
psi.UseShellExecute = true;
Process.Start(psi);
}
catch (Exception ex)
{
MelonLogger.Warning(
$"[OSNotification] 폴더를 열 수 없습니다: {ex.Message}");
}
break;
}
}
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
finally
{
closeTimer.Dispose();
}
}
else
{
Thread.Sleep(500);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Balloon 알림 실패: {ex.Message}");
}
finally
{
if (hWnd != IntPtr.Zero)
{
nid.uFlags = 0;
Shell_NotifyIconW(NIM_DELETE, ref nid);
DestroyWindow(hWnd);
}
}
}
#endregion
}
#endregion
#region macOS — osascript
/// <summary>
/// macOS notification using <c>osascript -e 'display notification ...'</c>.
/// </summary>
private static class MacNotification
{
public static void Send(string title, string message)
{
// AppleScript string escaping: backslash and double-quote
string escapedTitle = EscapeAppleScript(title);
string escapedMessage = EscapeAppleScript(message);
// Use ProcessStartInfo with ArgumentList to avoid shell-level escaping issues.
// Each argument is passed directly to the process without shell interpretation.
var script =
$"display notification \"{escapedMessage}\" with title \"{escapedTitle}\"";
var startInfo = new ProcessStartInfo
{
FileName = "osascript",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-e");
startInfo.ArgumentList.Add(script);
RunProcess(startInfo);
}
private static string EscapeAppleScript(string value)
{
// In AppleScript string literals, escape backslash and double-quote
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
}
#endregion
#region Linux — notify-send / gdbus
/// <summary>
/// Linux notification using <c>notify-send</c> (libnotify CLI).
/// Falls back to <c>gdbus</c> (D-Bus direct call) if notify-send is unavailable.
/// </summary>
private static class LinuxNotification
{
public static void Send(string title, string message, string? folderPath)
{
if (!string.IsNullOrEmpty(folderPath))
{
ShowWithAction(title, message, folderPath);
}
else
{
ShowSimple(title, message);
}
}
private static void ShowSimple(string title, string message)
{
// Try notify-send first
if (TryNotifySend(title, message))
return;
// Fallback to gdbus
TryGdbus(title, message);
}
private static void ShowWithAction(string title, string message, string folderPath)
{
// Run on a background thread because notify-send with --action blocks
// until the notification is dismissed or the action is clicked.
System.Threading.Tasks.Task.Run(() =>
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add("--action=open_folder=렌더 폴더 열기");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
string output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (output == "open_folder")
{
var openInfo = new ProcessStartInfo
{
FileName = "xdg-open",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
openInfo.ArgumentList.Add(folderPath);
RunProcess(openInfo);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] 리눅스 알림 처리 실패: {ex.Message}");
// Fallback: show without action
ShowSimple(title, message);
}
});
}
private static bool TryNotifySend(string title, string message)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = "notify-send",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message);
RunProcess(startInfo);
return true;
}
catch
{
return false;
}
}
private static void TryGdbus(string title, string message)
{
try
{
// Direct D-Bus call to org.freedesktop.Notifications
var startInfo = new ProcessStartInfo
{
FileName = "gdbus",
CreateNoWindow = true,
UseShellExecute = false,
WindowStyle = ProcessWindowStyle.Hidden
};
startInfo.ArgumentList.Add("call");
startInfo.ArgumentList.Add("--session");
startInfo.ArgumentList.Add("--dest=org.freedesktop.Notifications");
startInfo.ArgumentList.Add("--object-path=/org/freedesktop/Notifications");
startInfo.ArgumentList.Add("--method=org.freedesktop.Notifications.Notify");
startInfo.ArgumentList.Add("ADOFAI Renderer"); // app_name
startInfo.ArgumentList.Add("0"); // replaces_id
startInfo.ArgumentList.Add(""); // app_icon
startInfo.ArgumentList.Add(title); // summary
startInfo.ArgumentList.Add(message); // body
startInfo.ArgumentList.Add("[]"); // actions
startInfo.ArgumentList.Add("{}"); // hints
startInfo.ArgumentList.Add("-1"); // expire_timeout
RunProcess(startInfo);
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] gdbus 알림 실패: {ex.Message}");
}
}
}
#endregion
#region Shared utility
/// <summary>
/// Starts a process fire-and-forget. Does not redirect output or wait for exit.
/// </summary>
private static void RunProcess(ProcessStartInfo startInfo)
{
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
}
#endregion
}
}
+12 -12
View File
@@ -1,4 +1,4 @@
using System; using System;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util
{ {
@@ -7,49 +7,49 @@ namespace ADOFAIRenderer.Util
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value, public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
int maxDenominator = 100000000) int maxDenominator = 100000000)
{ {
// 1. 예외 처리 및 부호 판별 // 1. Edge case handling and sign detection
if (value == 0.0) return (0, 1); if (value == 0.0) return (0, 1);
var isNegative = value < 0; var isNegative = value < 0;
var target = Math.Abs(value); var target = Math.Abs(value);
// 2. 스턴-브로코 / 연속분수 경계값 초기화 // 2. Stern-Brocot / continued fraction boundary initialization
// 아래 두 행의 분수 (0/1, 1/0) 사이에서 시작하여 타겟에 가까워집니다. // Starts between (0/1, 1/0) and converges towards the target value.
long m00 = 0, m01 = 1; long m00 = 0, m01 = 1;
long m10 = 1, m11 = 0; long m10 = 1, m11 = 0;
var x = target; var x = target;
// int 범위 오버플로우를 방지하면서 반복 처리 // Iterate while preventing integer overflow
while (true) while (true)
{ {
var ai = (long)Math.Floor(x); var ai = (long)Math.Floor(x);
// 다음 단계의 분자, 분모 계산 (오버플로우 방지를 위해 long으로 임시 계산) // Calculate next numerator and denominator (temporarily computed as long to prevent overflow)
var nextNumerator = ai * m10 + m00; var nextNumerator = ai * m10 + m00;
var nextDenominator = ai * m11 + m01; var nextDenominator = ai * m11 + m01;
// int 범위를 벗어나거나, 설정한 최대 분모를 초과하면 이전 단계의 최적값을 반환 // If exceeding integer range or specified maximum denominator, return previous optimal value
if (nextNumerator > int.MaxValue || nextDenominator > maxDenominator) if (nextNumerator > int.MaxValue || nextDenominator > maxDenominator)
{ {
break; break;
} }
// 행렬 업데이트 // Update matrix
m00 = m10; m01 = m11; m00 = m10; m01 = m11;
m10 = nextNumerator; m11 = nextDenominator; m10 = nextNumerator; m11 = nextDenominator;
// 오차가 거의 없으면 (정밀도 도달) 종료 // Terminate if error is negligible (precision limit reached)
if (Math.Abs(x - ai) < 1e-15) break; if (Math.Abs(x - ai) < 1e-15) break;
// 다음 연속분수 항 준비 // Prepare next continued fraction term
x = 1.0 / (x - ai); x = 1.0 / (x - ai);
} }
// 3. 최종 결정된 int 기반의 분자 분모 // 3. Final determined integer numerator and denominator
var finalNumerator = (int)m10; var finalNumerator = (int)m10;
var finalDenominator = (int)m11; var finalDenominator = (int)m11;
// 부호 복원 // Restore sign
if (isNegative) finalNumerator = -finalNumerator; if (isNegative) finalNumerator = -finalNumerator;
return (finalNumerator, finalDenominator); return (finalNumerator, finalDenominator);
-128
View File
@@ -1,128 +0,0 @@
using System;
namespace ADOFAIRenderer.Util
{
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
private static readonly Type? RegistryViewType;
private static readonly System.Reflection.MethodInfo? OpenBaseKeyMethod;
private static readonly System.Reflection.MethodInfo? OpenSubKeyMethod;
private static readonly System.Reflection.MethodInfo? GetSubKeyNamesMethod;
private static readonly System.Reflection.MethodInfo? GetValueMethod;
static RegistryUtil()
{
try
{
RegistryKeyType = Type.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = Type.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = Type.GetType("Microsoft.Win32.RegistryView");
if (RegistryKeyType == null || RegistryHiveType == null || RegistryViewType == null)
{
var mscorlib = typeof(object).Assembly;
RegistryKeyType = mscorlib.GetType("Microsoft.Win32.RegistryKey");
RegistryHiveType = mscorlib.GetType("Microsoft.Win32.RegistryHive");
RegistryViewType = mscorlib.GetType("Microsoft.Win32.RegistryView");
}
if (RegistryKeyType != null)
{
OpenBaseKeyMethod = RegistryKeyType.GetMethod("OpenBaseKey", new[] { RegistryHiveType!, RegistryViewType! });
OpenSubKeyMethod = RegistryKeyType.GetMethod("OpenSubKey", new[] { typeof(string) });
GetSubKeyNamesMethod = RegistryKeyType.GetMethod("GetSubKeyNames");
GetValueMethod = RegistryKeyType.GetMethod("GetValue", new[] { typeof(string) });
}
}
catch
{
// ignored
}
}
private static void CloseKey(object? key)
{
if (key == null || RegistryKeyType == null) return;
try
{
if (key is IDisposable disposable)
{
disposable.Dispose();
}
else
{
var closeMethod = RegistryKeyType.GetMethod("Close");
closeMethod?.Invoke(key, null);
}
}
catch
{
// ignored
}
}
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { parentKeyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string[]? subkeyNames = GetSubKeyNamesMethod.Invoke(key, null) as string[];
CloseKey(key);
CloseKey(baseKey);
return subkeyNames;
}
catch
{
return null;
}
}
public static string? GetHKLMValue(string keyPath, string valueName)
{
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
return null;
try
{
object hiveLocalMachine = Enum.ToObject(RegistryHiveType!, 0x80000002);
object viewRegistry64 = Enum.ToObject(RegistryViewType!, 256);
object? baseKey = OpenBaseKeyMethod.Invoke(null, new[] { hiveLocalMachine, viewRegistry64 });
if (baseKey == null) return null;
object? key = OpenSubKeyMethod.Invoke(baseKey, new object[] { keyPath });
if (key == null)
{
CloseKey(baseKey);
return null;
}
string? value = GetValueMethod.Invoke(key, new object[] { valueName }) as string;
CloseKey(key);
CloseKey(baseKey);
return value;
}
catch
{
return null;
}
}
}
}
-29
View File
@@ -1,29 +0,0 @@
using System;
using System.IO;
using MelonLoader;
namespace ADOFAIRenderer.Util
{
internal static class StreamUtil
{
public static void WriteNativePtrToStream(IntPtr nativePtr, int length, Stream destinationStream)
{
if (nativePtr == IntPtr.Zero)
throw new ArgumentNullException(nameof(nativePtr));
if (length <= 0) return;
unsafe
{
try
{
var span = new ReadOnlySpan<byte>((void*)nativePtr, length);
destinationStream.Write(span);
}
catch (IOException err)
{
Melon<RenderMod>.Logger.Error($"FFmpeg 파이프가 예상치 못하게 닫혔습니다: {err.Message}");
}
}
}
}
}
+528
View File
@@ -0,0 +1,528 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util;
// ReSharper disable once InconsistentNaming
internal class VideoBGPlayer : 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");
private static readonly int ColorMatrix = Shader.PropertyToID("ColorMatrix");
[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 = ModAssets.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;
// When looping is enabled, the playback position is the elapsed time wrapped into [0, Duration).
// When it is disabled, the position keeps increasing and the last frame stays on screen after the end.
var videoDuration = _decoder.Duration;
if (_isLooping && targetVideoTime >= 0 && videoDuration > 0)
{
targetVideoTime %= videoDuration;
}
if (targetVideoTime < 0)
{
if (_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);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
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);
_computeShader.SetInt(ColorMatrix, _decoder.ColorMatrix);
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);
}
}