112 Commits
Author SHA256 Message Date
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
copecone 2c334edec6 Merge remote-tracking branch 'origin/main' 2026-08-19 07:56:04 +09:00
copecone 7e5a92162b Fix ADOFAIRenderer.csproj to Generate Documentation File 2026-08-19 07:55:48 +09:00
copecone 838b0fccfd Merge remote-tracking branch 'origin/main' 2026-08-18 14:16:52 +09:00
copecone 72ba78579a Add RenderResult 2026-08-18 14:16:40 +09:00
copecone 71c7fa0016 Document RenderSession.WithOutputPath 2026-08-18 11:46:52 +09:00
copecone 41a961ce1e Remove Redundant Directory Init 2026-08-18 11:39:51 +09:00
copecone 5ef14680ba Refactor Config UI Codes 2026-08-18 11:31:52 +09:00
copecone 9b432eae8f Rename AdvancedConfig.Extension to FileExtension 2026-08-18 11:22:43 +09:00
copecone c3de49fff5 Add Custom Extension in Advanced Config 2026-08-18 11:02:08 +09:00
copecone bdc8b4abb0 Add Custom Output Path API 2026-08-18 11:02:08 +09:00
copecone 2a5ceb4889 Add RenderSession.WithOutputPath 2026-08-18 11:02:08 +09:00
copecone d8712c971e Refactor FFmpegDownloadUI 2026-08-18 11:02:08 +09:00
copecone a47cb39f17 Upgrade Version 2026-08-18 00:57:29 +09:00
copecone c99f98a633 Document RenderSession.WithEndCondition 2026-08-18 00:23:34 +09:00
copecone 05d43f3bc2 Add WithEndCondition to RenderSession 2026-08-18 00:19:04 +09:00
copecone 437a8b598e Add Level Loading Override in RenderSession.Start 2026-08-18 00:00:29 +09:00
copecone d7b3f48d32 Add RenderContext Reset Mechanism 2026-08-17 21:46:53 +09:00
copecone 1fda8b09a7 Document RenderSession 2026-08-17 21:46:14 +09:00
copecone a98617b7a2 Fix Bundles 2026-08-17 21:40:17 +09:00
copecone 3b06343c19 Update Bundles 2026-08-17 21:29:12 +09:00
copecone fa0f175bae Create RenderSession 2026-08-17 21:17:15 +09:00
copecone ab19a3a777 Make RenderConfig Related Classes Public 2026-08-17 21:16:52 +09:00
copecone e210da9bb0 Add RenderContext 2026-08-17 21:15:54 +09:00
copecone 5316840811 Migrate UI Names 2026-08-17 21:15:28 +09:00
copecone 6d2d7785b9 Change RenderConfig.DelayAfterLevelEnd to DelayAfterEnd 2026-08-17 21:14:23 +09:00
copecone c211efb816 Separate Render Flags 2026-08-17 15:53:30 +09:00
copecone d057f70553 Adjust EnumDropdownBinder Condition 2026-08-17 15:50:28 +09:00
copecone 485ebffbd3 Make Every Class Internal 2026-08-17 03:16:19 +09:00
copecone 54b7eb7466 Move Input Validators 2026-08-17 03:05:01 +09:00
copecone fb231b65d3 Fix Portal Particle Patch to Use Better Condition 2026-08-12 20:15:10 +09:00
copecone a322b6c206 Upgrade Version 2026-08-12 19:36:23 +09:00
copecone 2fced0fedb Hide Hold Line Every Frame 2026-08-11 12:21:03 +09:00
copecone 9ff7632f04 Fix Single Frame Delay in Capture 2026-08-11 03:24:28 +09:00
copecone da8fcd0190 Fix NonTile Mode Skip Decoration Portal Behavior 2026-08-10 20:52:02 +09:00
copecone 5d1ce441cb Add Player Planet Hide Feature to NonTile Mode 2026-08-10 20:51:32 +09:00
85 changed files with 8170 additions and 4093 deletions

No files matched your search

+1
View File
@@ -42,3 +42,4 @@ GEMINI.md
# Developer Description Files # Developer Description Files
/AsyncInputAutoplayFix.md /AsyncInputAutoplayFix.md
/AudioMixerExplanation.md /AudioMixerExplanation.md
/O5Theme.md
+85 -29
View File
@@ -3,17 +3,22 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework> <TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<NoWarn>MSB3277</NoWarn> <NoWarn>MSB3277;1591</NoWarn>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries> <EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>9</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>
@@ -77,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);
}
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
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>
/// Indicates whether the rendering session completed successfully.
/// </summary>
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)
{
Success = success;
OutputPath = outputPath;
TotalFrames = totalFrames;
VideoDuration = videoDuration;
RenderDuration = renderDuration;
FileSizeBytes = fileSizeBytes;
ErrorMessage = errorMessage;
}
/// <summary>
/// Creates a successful <see cref="RenderResult"/> instance from the specified render metrics.
/// </summary>
/// <param name="outputPath">The path to the rendered video file.</param>
/// <param name="totalFrames">Total number of video frames sent to the encoder.</param>
/// <param name="fps">The effective frames per second used to compute video duration.</param>
/// <param name="renderDuration">Total wall-clock time spent rendering.</param>
/// <returns>A new <see cref="RenderResult"/> representing a successful render session.</returns>
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{
var fileSizeBytes = 0L;
if (!string.IsNullOrEmpty(outputPath) && File.Exists(outputPath))
{
try { fileSizeBytes = new FileInfo(outputPath).Length; }
catch { /* ignored */ }
}
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
return new RenderResult(true, outputPath, totalFrames, videoDuration, renderDuration, fileSizeBytes);
}
/// <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"}]";
}
}
+532
View File
@@ -0,0 +1,532 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util;
using UnityEngine;
namespace ADOFAIRenderer.API;
/// <summary>
/// Fluent builder for configuring and starting a render session.
/// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
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;
/// <summary>
/// Specifies the types of patches that can be configured for a render session.
/// </summary>
public enum PatchType
{
/// <summary>
/// Time-related patch controlling frame timing and clock synchronization.
/// </summary>
Time,
/// <summary>
/// Screen-related patch controlling screen resolution.
/// </summary>
Screen
}
private bool _timePatch = true;
private bool _screenPatch = true;
private string? _outputPath;
private bool _notification = true;
private Func<bool>? _endCondition;
private Action<RenderResult>? _onComplete;
private Action? _onLastUpdate;
/// <summary>
/// Sets whether auto mode is enabled.
/// </summary>
/// <param name="enabled"><c>true</c> to enable auto mode; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithAuto(bool enabled = true)
{
_auto = enabled;
return this;
}
/// <summary>
/// Sets whether a specific patch type is enabled for the render session.
/// </summary>
/// <param name="patchType">The <see cref="PatchType"/> to configure.</param>
/// <param name="enabled"><c>true</c> to enable the specified patch; otherwise, <c>false</c>. Default is <c>true</c>.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when an unsupported <see cref="PatchType"/> is provided.</exception>
public RenderSession WithPatch(PatchType patchType, bool enabled = true)
{
switch (patchType)
{
case PatchType.Time: _timePatch = enabled; break;
case PatchType.Screen: _screenPatch = enabled; break;
default: throw new ArgumentOutOfRangeException(nameof(patchType), patchType, null);
}
return this;
}
/// <summary>
/// Sets the custom condition function that determines when the render session should end.
/// </summary>
/// <param name="endCondition">A delegate returning <c>true</c> when the session end condition is met.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithEndCondition(Func<bool> endCondition)
{
_endCondition = endCondition;
return this;
}
/// <summary>
/// Sets the custom output path for the rendered file.
/// </summary>
/// <param name="outputPath">The file path where the output will be saved, or <c>null</c> to use the default capture directory.</param>
/// <returns>The current <see cref="RenderSession"/> instance for method chaining.</returns>
public RenderSession WithOutputPath(string? outputPath)
{
_outputPath = outputPath;
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>
/// Applies the configured flags and initiates the render session.
/// </summary>
/// <param name="renderConfig">The configuration settings for the render session.</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)
{
RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch;
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.Configuration = renderConfig;
RenderManager.Instance.stage = RenderStage.Load;
if (_outputPath is null) RenderManager.Instance.SetupCaptureDirectory();
else FileUtil.CreateDirectory(Path.GetDirectoryName(_outputPath) ?? throw new InvalidOperationException());
RenderManager.Instance.SetupGameScene();
}
}
Binary file not shown.
BIN
View File
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}");
}
}
}
+11 -12
View File
@@ -1,11 +1,11 @@
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)]
public class FirstUpdateHook : MonoBehaviour
{
private bool _checkedFFmpeg; private bool _checkedFFmpeg;
private bool _checkedNvidiaDriver; private bool _checkedNvidiaDriver;
@@ -33,7 +33,7 @@ namespace ADOFAIRenderer.Core
if (RenderManager.Instance) if (RenderManager.Instance)
{ {
RenderManager.Instance.OnPreRenderUpdate(); RenderManager.Instance.OnPreUpdate();
} }
} }
@@ -45,22 +45,21 @@ namespace ADOFAIRenderer.Core
var currentVersion = driverVer.Value.ToString("F2"); var currentVersion = driverVer.Value.ToString("F2");
CommonWarningDialogUI.ShowDialog( CommonWarningDialogUI.ShowDialog(
"NVIDIA 그래픽 드라이버 업데이트 권장", "NVIDIA Graphics Driver Update Recommended",
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.", $"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("드라이버 업데이트", () => new DialogButton("Update Driver", () =>
{ {
Application.OpenURL("https://www.nvidia.com/Download/index.aspx"); Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit(); Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)), }, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("기능 비활성화", () => new DialogButton("Disable Feature", () =>
{ {
RenderMod.IsFeatureDisabled = true; RenderMod.IsFeatureDisabled = true;
}), }),
new DialogButton("이번만 그냥 진행", () => 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();
}
}
+19 -8
View File
@@ -1,13 +1,15 @@
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)]
public class PostRenderHook : MonoBehaviour
{
private FieldInfo? _camQuadMeshField; private FieldInfo? _camQuadMeshField;
private MeshRenderer? _camQuadMeshRenderer;
private void Awake() private void Awake()
{ {
@@ -16,11 +18,20 @@ namespace ADOFAIRenderer.Core
private void LateUpdate() private void LateUpdate()
{ {
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance) if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
PostFrameAsync().Forget();
}
private async UniTaskVoid PostFrameAsync()
{ {
RenderManager.Instance.OnPostRender(val.material.mainTexture as RenderTexture); await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
}
} if (!_camQuadMeshRenderer && scrCamera.instance)
_camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
if (RenderManager.Instance && _camQuadMeshRenderer)
RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.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
}
}
}
}
+242 -170
View File
@@ -4,34 +4,87 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Buffers; using System.Buffers;
using System.Diagnostics;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data; using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI; using ADOFAIRenderer.UI;
using ADOFAIRenderer.UI.Window;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio; using ADOFAIRenderer.Util.Audio;
using ADOFAIRenderer.Util.FFmpeg; using ADOFAIRenderer.Util.FFmpeg;
using FFmpeg.AutoGen; using FFmpeg.AutoGen;
using HarmonyLib; using HarmonyLib;
using MelonLoader; using MelonLoader;
using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using UnityEngine.Rendering; using UnityEngine.Rendering;
using Random = UnityEngine.Random; using Random = UnityEngine.Random;
using ThreadPriority = System.Threading.ThreadPriority; using ThreadPriority = System.Threading.ThreadPriority;
namespace ADOFAIRenderer.Core namespace ADOFAIRenderer.Core;
internal enum RenderStage
{ {
public class RenderManager : MonoBehaviour None, Load, OnScene, Render
}
internal static class RenderContext
{
public static bool Auto = true;
public static bool TimePatch = true;
public static bool ScreenPatch = true;
public static string? OutputPath;
public static bool Notification = true;
public static readonly List<Action<FrameHookContext>> SessionFrameHooks = new();
private static readonly Func<bool> EndConditionDefault = () => scrController.instance.state == States.Won;
public static Func<bool> EndCondition = EndConditionDefault;
private static readonly Action<RenderResult> OnCompleteDefault = result =>
{ {
if (result.Success && Notification)
{
var fileString = !string.IsNullOrEmpty(RenderManager.Instance.CurrentOutPath) ?
$"'{Path.GetFileName(RenderManager.Instance.CurrentOutPath)}'" : "Video";
var renderFolder = Path.GetDirectoryName(RenderManager.Instance.CurrentOutPath);
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
}
};
public static Action<RenderResult> OnComplete = OnCompleteDefault;
private static readonly Action OnLastUpdateDefault = () => { };
public static Action OnLastUpdate = OnLastUpdateDefault;
public static void Reset()
{
Auto = true;
TimePatch = true; ScreenPatch = true;
OutputPath = null;
Notification = true;
SessionFrameHooks.Clear();
EndCondition = EndConditionDefault;
OnComplete = OnCompleteDefault;
OnLastUpdate = OnLastUpdateDefault;
}
}
internal class RenderManager : MonoBehaviour
{
// Singleton Accessors // Singleton Accessors
public static RenderManager Instance { get; private set; } = null!; public static RenderManager Instance { get; private set; } = null!;
public static bool IsActive => Instance && Instance.IsRendering; public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
public static bool IsCapturingActive => Instance && Instance.IsCapturing; public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
public const int MaxQueuedFrames = 64; public const int MaxQueuedFrames = 64;
public const int MaxRenderRetry = 64;
// Statistics // Statistics
public long framesSent; public long framesSent;
private readonly Stopwatch _renderStopwatch = new();
// Core Render Configuration and Path // Core Render Configuration and Path
public int countdown; public int countdown;
@@ -42,20 +95,59 @@ namespace ADOFAIRenderer.Core
public RenderConfig Configuration = null!; public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields) // Render Sessions State (OOP Instance Fields)
public bool OverrideLoading { get; private set; } public RenderStage stage = RenderStage.None;
private bool IsRendering { get; set; }
private bool IsCapturing { get; set; }
public double timeBase; public double timeBase;
public double unscaledTimeBase; public double unscaledTimeBase;
public double dspTimeBase; public double dspTimeBase;
public int frameCountBase; public int frameCountBase;
public OfflineAudioMixer? AudioMixer { get; private set; } public OfflineAudioMixer? AudioMixer { get; private set; }
public AudioSyncCalculator SyncCalculator { get; private set; } = null!; private AudioSyncCalculator SyncCalculator { get; set; } = null!;
private FrameReader? _frameReader; private FrameReader? _frameReader;
public bool renderCheck = true; private FrameHookPipeline? _frameHookPipeline;
public int skipCount; private readonly List<Action<FrameHookContext>> _activeHooksCache = new();
private int _cachedGlobalHooksVersion = -1;
private bool _hasActiveHooksCached;
public bool HasActiveFrameHooks
{
get
{
EnsureActiveHooksUpToDate();
return _hasActiveHooksCached;
}
}
private void EnsureActiveHooksUpToDate()
{
var globalVersion = RenderSession.GlobalHooksVersion;
if (_cachedGlobalHooksVersion == globalVersion) return;
_cachedGlobalHooksVersion = globalVersion;
_activeHooksCache.Clear();
if (RenderContext.SessionFrameHooks.Count > 0)
{
_activeHooksCache.AddRange(RenderContext.SessionFrameHooks);
}
RenderSession.CopyGlobalFrameHooksTo(_activeHooksCache);
_hasActiveHooksCached = _activeHooksCache.Count > 0;
}
private List<Action<FrameHookContext>> GetActiveFrameHooks()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
_frameHookPipeline = null;
_activeHooksCache.Clear();
_cachedGlobalHooksVersion = -1;
_hasActiveHooksCached = false;
}
public long frameCountForVideo; public long frameCountForVideo;
public long lastVideoFrameIndex = -1; public long lastVideoFrameIndex = -1;
@@ -70,6 +162,7 @@ namespace ADOFAIRenderer.Core
public int Length; public int Length;
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame) public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
public ReadbackRequestState? ReadbackState; // Associated state to recycle public ReadbackRequestState? ReadbackState; // Associated state to recycle
public bool IsColorConverted;
} }
// Thread and Queue State // Thread and Queue State
@@ -79,7 +172,7 @@ namespace ADOFAIRenderer.Core
public long passCount; public long passCount;
public bool endRequested; public bool endRequested;
public bool endSuccessful; public bool endSuccessful;
public long wonFrame = -1; public long endFrame = -1;
// Private Instance Fields // Private Instance Fields
private FFmpegNativeEncoder? _activeVideoFFmpeg; private FFmpegNativeEncoder? _activeVideoFFmpeg;
@@ -87,7 +180,7 @@ namespace ADOFAIRenderer.Core
private int _originalInputOffset; private int _originalInputOffset;
private GameObject? _debugMonitorObj; private GameObject? _debugMonitorObj;
private GameObject? _escapeHandlerObj; private GameObject? _escapeHandlerObj;
private string? _currentOutPath; internal string? CurrentOutPath;
private bool _renderSetup; private bool _renderSetup;
private void Awake() private void Awake()
@@ -100,40 +193,10 @@ namespace ADOFAIRenderer.Core
CleanUpActiveRender(); CleanUpActiveRender();
} }
/// <summary>
/// Sets overrideLoading to true when starting to load the render scene.
/// </summary>
public void StartLoadingOverride()
{
OverrideLoading = true;
}
/// <summary>
/// Sets isRendering to true when starting the rendering setup.
/// </summary>
public void StartRendering()
{
IsRendering = true;
}
/// <summary>
/// Sets isCapturing to true and resets the cancellation flag when starting the actual frame capture.
/// </summary>
public void StartCapturing()
{
IsCapturing = true;
IsRenderCancelled = false;
}
/// <summary>
/// Resets all rendering-related states.
/// </summary>
public void ResetRenderState(bool success) public void ResetRenderState(bool success)
{ {
IsRendering = false;
IsCapturing = false;
OverrideLoading = false;
IsRenderCancelled = !success; IsRenderCancelled = !success;
stage = RenderStage.None;
} }
public void CleanUpActiveRender() public void CleanUpActiveRender()
@@ -142,9 +205,11 @@ namespace ADOFAIRenderer.Core
AudioSpectrumGenerator.Clear(); AudioSpectrumGenerator.Clear();
ResetFrameHookState();
if (_activeVideoFFmpeg != null) if (_activeVideoFFmpeg != null)
{ {
Melon<RenderMod>.Logger.Msg("게임 종료 또는 RenderManager 파괴 감지: FFmpeg 네이티브 인코더 리소스를 해제합니다."); Melon<RenderMod>.Logger.Msg("Game exit or RenderManager destruction detected: Disposing FFmpeg native encoder resources.");
_activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg.Dispose();
_activeVideoFFmpeg = null; _activeVideoFFmpeg = null;
} }
@@ -153,7 +218,7 @@ namespace ADOFAIRenderer.Core
{ {
if (_allVideoFFmpegInstances.Count > 0) if (_allVideoFFmpegInstances.Count > 0)
{ {
Melon<RenderMod>.Logger.Msg($"게임 종료 감지: 생성된 모든 FFmpeg 인코더 인스턴스({_allVideoFFmpegInstances.Count}개)를 수거 및 해제합니다."); Melon<RenderMod>.Logger.Msg($"Game exit detected: Cleaning up and disposing all created FFmpeg encoder instances ({_allVideoFFmpegInstances.Count}).");
foreach (var instance in _allVideoFFmpegInstances) foreach (var instance in _allVideoFFmpegInstances)
{ {
try try
@@ -162,7 +227,7 @@ namespace ADOFAIRenderer.Core
} }
catch catch
{ {
// Ignore // ignored
} }
} }
_allVideoFFmpegInstances.Clear(); _allVideoFFmpegInstances.Clear();
@@ -193,7 +258,7 @@ namespace ADOFAIRenderer.Core
_originalInputOffset = scrConductor.currentPreset.inputOffset; _originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0; scrConductor.currentPreset.inputOffset = 0;
RDC.auto = true; RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage") AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true); .SetValue(scrController.instance, true);
@@ -203,9 +268,8 @@ namespace ADOFAIRenderer.Core
scrController.instance.noFailInfiniteMargin = true; scrController.instance.noFailInfiniteMargin = true;
} }
StartRendering(); stage = RenderStage.OnScene;
countdown = 3; countdown = 3; endFrame = -1;
wonFrame = -1;
if (!_escapeHandlerObj) if (!_escapeHandlerObj)
{ {
@@ -218,91 +282,56 @@ namespace ADOFAIRenderer.Core
scrController.instance!.StartCoroutine(SetupPlay()); scrController.instance!.StartCoroutine(SetupPlay());
} }
if (!IsRendering) return; if (Instance.stage < RenderStage.OnScene) return;
if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2) if (!scrController.instance || scrController.instance.state == States.Fail || scrController.instance.state == States.Fail2)
{ {
ResetRenderState(success: false); ResetRenderState(success: false);
StopFFmpeg(false); StopFFmpeg(false);
EndRender(); EndRender();
} }
else if (scrController.instance.state == States.Won) else if (RenderContext.EndCondition())
{ {
if (wonFrame == -1) if (endFrame == -1)
{ {
wonFrame = frameCount; endFrame = frameCount;
} }
var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterLevelEnd * Configuration.RenderFPS); var extraFrames = (long)Math.Ceiling(Configuration.DelayAfterEnd * Configuration.RenderFPS);
if (frameCount - wonFrame < extraFrames) return; if (frameCount - endFrame < extraFrames) return;
ResetRenderState(success: true); ResetRenderState(success: true);
StopFFmpeg(true); StopFFmpeg(true);
} }
} }
public void OnPreRenderUpdate() public void OnPreUpdate()
{ {
if (!IsRendering) return; if (Instance.stage < RenderStage.OnScene) return;
// [렉으로 인한 프레임 렌더링 스킵 시 재시도 로직 (원본 모드 이식)]
if (renderCheck && IsCapturing)
{
if (skipCount < MaxRenderRetry)
{
Melon<RenderMod>.Logger.Warning($"[Render Skipped] Frame {frameCount} rendering missed. Retrying... (Attempt {skipCount + 1}/{MaxRenderRetry})");
skipCount++;
Time.timeScale = 0f; // 진행 시간 멈추기 (재시도)
return; // 업데이트 진행하지 않고 대기
}
// 60회 초과 시 최후의 블랙 프레임 삽입하여 전체 파이프라인 마비 방지
Melon<RenderMod>.Logger.Error($"[Render Skipped] Frame {frameCount} rendering failed after 60 retries. Supplying black frame.");
var currentFrame = frameCountForVideo++;
var renderFPS = Configuration.RenderFPS;
var videoFPS = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
{
lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex;
var byteSize = Configuration.RenderWidth * Configuration.RenderHeight * 4;
var blackFrame = ArrayPool<byte>.Shared.Rent(byteSize);
Array.Clear(blackFrame, 0, byteSize);
lock (ImageQueue)
{
ImageQueue[videoFrameIndex] = new CapturedFrame
{
ManagedBuffer = blackFrame,
Length = byteSize
};
}
FrameAvailableEvent.Set();
}
renderCheck = false; // 진행 허용
}
skipCount = 0;
renderCheck = true; // 다음 프레임 검사용으로 초기화
frameCount++; frameCount++;
} }
// ReSharper disable once Unity.IncorrectMethodSignature public void OnLastUpdate()
public void OnPostRender(RenderTexture? texture)
{ {
if (!IsCapturing || !texture) return; if (Instance.stage < RenderStage.Load) return;
if (!Configuration.AdvancedConfig.NonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors
.Where(floor => floor.holdRenderer && floor.holdRenderer.m_meshRenderer.enabled))
{
floor.holdRenderer.m_meshRenderer.enabled = false;
}
renderCheck = false; // 이번 프레임 정상 렌더링 완료 TriggerOnLastUpdate();
}
// [Backpressure Throttling (2번 작업)] // ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
if (Instance.stage < RenderStage.Render || !texture) return;
// [Backpressure Throttling]
isThrottling = false; isThrottling = false;
while (IsCapturing && IsRendering) while (Instance.stage >= RenderStage.Render)
{ {
int count; int count;
lock (ImageQueue) lock (ImageQueue)
@@ -334,7 +363,23 @@ namespace ADOFAIRenderer.Core
{ {
lastVideoFrameIndex = videoFrameIndex; lastVideoFrameIndex = videoFrameIndex;
lastRequestedVideoFrameIndex = videoFrameIndex; lastRequestedVideoFrameIndex = videoFrameIndex;
_frameReader?.RequestFrameCapture(texture, videoFrameIndex);
var targetTexture = texture;
if (HasActiveFrameHooks && _frameHookPipeline != null)
{
var videoTime = effectiveVideoFps > 0 ? videoFrameIndex / effectiveVideoFps : 0.0;
targetTexture = _frameHookPipeline.Process(
texture,
videoFrameIndex,
currentFrame,
videoTime,
renderFPS,
effectiveVideoFps,
GetActiveFrameHooks()
) ?? texture;
}
_frameReader?.RequestFrameCapture(targetTexture, videoFrameIndex);
} }
} }
@@ -356,19 +401,16 @@ namespace ADOFAIRenderer.Core
var quitDelegate = var quitDelegate =
(Func<bool>)Delegate.CreateDelegate(typeof(Func<bool>), scnEditor.instance, targetMethod); (Func<bool>)Delegate.CreateDelegate(typeof(Func<bool>), scnEditor.instance, targetMethod);
if (Instance) Instance.StartLoadingOverride();
Application.wantsToQuit -= quitDelegate; Application.wantsToQuit -= quitDelegate;
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity"); RenderConfigWindow.CreateWindow();
// ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "Assets/Scenes/RenderSettingsScene.unity");
}); });
} }
public void SetupCaptureDirectory() public void SetupCaptureDirectory()
{ {
var renderDir = Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); var renderDir = GlobalSettings.EffectiveOutputDirectory;
if (!Directory.Exists(renderDir)) FileUtil.CreateDirectory(renderDir);
{
Directory.CreateDirectory(renderDir);
}
} }
public void SetupGameScene() public void SetupGameScene()
@@ -387,9 +429,10 @@ namespace ADOFAIRenderer.Core
private IEnumerator SetupPlay() private IEnumerator SetupPlay()
{ {
if (Configuration.AudioConfig.RenderAudio && GetOutputExtension(Configuration.VideoConfig.Codec) != ".mp4") if (Configuration.AudioConfig.RenderAudio)
{ {
Configuration.AudioConfig.AudioSampleRate = 48000; if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value
} }
for (; countdown > 0; countdown--) for (; countdown > 0; countdown--)
@@ -397,8 +440,9 @@ namespace ADOFAIRenderer.Core
yield return new WaitForSecondsRealtime(1); yield return new WaitForSecondsRealtime(1);
} }
frameCount = 0; frameCount = 0; framesSent = 0;
framesSent = 0; _renderStopwatch.Restart();
timeBase = Time.timeAsDouble; timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble; unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime; dspTimeBase = AudioSettings.dspTime;
@@ -406,6 +450,10 @@ namespace ADOFAIRenderer.Core
_frameReader?.Dispose(); _frameReader?.Dispose();
_frameReader = null; _frameReader = null;
_frameHookPipeline?.Dispose();
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
try try
{ {
StartFFmpegThread(); StartFFmpegThread();
@@ -413,11 +461,12 @@ namespace ADOFAIRenderer.Core
} }
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"인코더 초기화 에러: {ex}"); Melon<RenderMod>.Logger.Error($"Encoder initialization error: {ex}");
ResetFrameHookState();
CommonWarningDialogUI.ShowDialog( CommonWarningDialogUI.ShowDialog(
"인코더 오류", "Encoder Error",
$"비디오 인코더를 초기화할 수 없습니다.\n오류: {ex.Message}", $"Failed to initialize video encoder.\nError: {ex.Message}",
new DialogButton("확인", () => new DialogButton("OK", () =>
{ {
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
}) })
@@ -425,7 +474,7 @@ namespace ADOFAIRenderer.Core
yield break; yield break;
} }
StartCapturing(); Instance.stage = RenderStage.Render;
scrController.instance.paused = scrController.instance.audioPaused = false; scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true; scrController.instance.levelWasSkipped = true;
@@ -466,8 +515,6 @@ namespace ADOFAIRenderer.Core
frameCountForVideo = 0; frameCountForVideo = 0;
lastVideoFrameIndex = -1; lastVideoFrameIndex = -1;
lastRequestedVideoFrameIndex = -1; lastRequestedVideoFrameIndex = -1;
renderCheck = true;
skipCount = 0;
endRequested = false; endRequested = false;
endSuccessful = false; endSuccessful = false;
FrameAvailableEvent.Reset(); FrameAvailableEvent.Reset();
@@ -475,24 +522,25 @@ namespace ADOFAIRenderer.Core
var renderFPS = Configuration.RenderFPS; var renderFPS = Configuration.RenderFPS;
var videoFPS = Configuration.VideoFPS; var videoFPS = Configuration.VideoFPS;
var selectedCodec = Configuration.VideoConfig.Codec;
var ext = GetOutputExtension(selectedCodec);
var startTime = DateTime.Now; var startTime = DateTime.Now;
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + ext; if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
_currentOutPath = Path.Combine(MelonEnvironment.GameRootDirectory, "Render", fileName); else {
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") +
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}";
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName);
}
FFmpegNativeEncoder? videoFFmpeg = null; FFmpegNativeEncoder? videoFFmpeg = null;
try try
{ {
var effectiveVideoFps = Math.Min(videoFPS, renderFPS); var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var codecId = GetAudioCodecId(ext); var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
var settings = new FFmpegNativeEncoder.EncoderSettings var settings = new FFmpegNativeEncoder.EncoderSettings
{ {
Width = Configuration.RenderWidth, Width = Configuration.RenderWidth,
Height = Configuration.RenderHeight, Height = Configuration.RenderHeight,
FPS = effectiveVideoFps, FPS = effectiveVideoFps,
Codec = selectedCodec, Codec = Configuration.VideoConfig.Codec,
Mode = Configuration.VideoConfig.RateControlMode, Mode = Configuration.VideoConfig.RateControlMode,
QualityValue = Configuration.VideoConfig.QP, QualityValue = Configuration.VideoConfig.QP,
TargetBitrate = Configuration.VideoConfig.Bitrate, TargetBitrate = Configuration.VideoConfig.Bitrate,
@@ -511,14 +559,12 @@ namespace ADOFAIRenderer.Core
videoFFmpeg = new FFmpegNativeEncoder(settings); videoFFmpeg = new FFmpegNativeEncoder(settings);
SetupCaptureDirectory();
_activeVideoFFmpeg = videoFFmpeg; _activeVideoFFmpeg = videoFFmpeg;
lock (_allVideoFFmpegInstances) lock (_allVideoFFmpegInstances)
{ {
_allVideoFFmpegInstances.Add(videoFFmpeg); _allVideoFFmpegInstances.Add(videoFFmpeg);
} }
videoFFmpeg.Start(_currentOutPath); videoFFmpeg.Start(CurrentOutPath);
} }
catch (Exception) catch (Exception)
{ {
@@ -548,7 +594,7 @@ namespace ADOFAIRenderer.Core
{ {
var firstKey = ImageQueue.Keys[0]; var firstKey = ImageQueue.Keys[0];
if (firstKey > passCount && !IsCapturing) if (firstKey > passCount && Instance.stage < RenderStage.Render)
{ {
passCount = firstKey; passCount = firstKey;
} }
@@ -569,7 +615,7 @@ namespace ADOFAIRenderer.Core
{ {
if (frame.ManagedBuffer.Length > 0) if (frame.ManagedBuffer.Length > 0)
{ {
videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length); videoFFmpeg.SendDataFromBuffer(frame.ManagedBuffer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent); Interlocked.Increment(ref framesSent);
ArrayPool<byte>.Shared.Return(frame.ManagedBuffer); ArrayPool<byte>.Shared.Return(frame.ManagedBuffer);
@@ -577,7 +623,7 @@ namespace ADOFAIRenderer.Core
} }
else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0) else if (frame.NativePointer != IntPtr.Zero && frame.Length > 0)
{ {
videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length); videoFFmpeg.SendDataFromNativeBuffer(frame.NativePointer, frame.Length, frame.IsColorConverted);
Interlocked.Increment(ref framesSent); Interlocked.Increment(ref framesSent);
if (frame.ReadbackState != null) if (frame.ReadbackState != null)
@@ -632,7 +678,7 @@ namespace ADOFAIRenderer.Core
} }
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"FFmpeg 렌더링 스레드 오류: {ex}"); Melon<RenderMod>.Logger.Error($"FFmpeg rendering thread error: {ex}");
videoFFmpeg.Dispose(); videoFFmpeg.Dispose();
lock (_allVideoFFmpegInstances) lock (_allVideoFFmpegInstances)
{ {
@@ -676,22 +722,22 @@ namespace ADOFAIRenderer.Core
if (success) StartCoroutine(StopRenderCoroutine()); if (success) StartCoroutine(StopRenderCoroutine());
else else
{ {
Melon<RenderMod>.Logger.Error("렌더링이 실패했거나 취소되었습니다. 상태 및 리소스를 안전하게 청소합니다."); Melon<RenderMod>.Logger.Error("Rendering failed or was cancelled. Safely cleaning up state and resources.");
// 1. 렌더링 백그라운드 스레드가 큐에 유입된 잔여 프레임 처리를 마무리하고 종료될 때까지 대기 // 1. Wait for render background thread to finish processing remaining queued frames and terminate
if (RenderThread is { IsAlive: true }) if (RenderThread is { IsAlive: true })
{ {
RenderThread.Join(2000); RenderThread.Join(2000);
} }
// 2. 스레드 종료 보장 후, 안전하게 FFmpeg 네이티브 인코더 리소스 해제 // 2. After ensuring thread termination, safely release FFmpeg native encoder resources
if (_activeVideoFFmpeg != null) if (_activeVideoFFmpeg != null)
{ {
_activeVideoFFmpeg.Dispose(); _activeVideoFFmpeg.Dispose();
_activeVideoFFmpeg = null; _activeVideoFFmpeg = null;
} }
// 3. 메모리 누수 방지: 큐에 렌트된 임시 프레임 버퍼 풀 반환 및 클리어 // 3. Memory leak prevention: Return rented temporary frame buffer pool in queue and clear
lock (ImageQueue) lock (ImageQueue)
{ {
foreach (var kvp in ImageQueue.Values) foreach (var kvp in ImageQueue.Values)
@@ -709,15 +755,16 @@ namespace ADOFAIRenderer.Core
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
// 디버그 모니터 UI 정리 // Clean up Debug Monitor UI
if (_debugMonitorObj) if (_debugMonitorObj)
{ {
Destroy(_debugMonitorObj); Destroy(_debugMonitorObj);
_debugMonitorObj = null; _debugMonitorObj = null;
} }
// 4. 인게임 카메라 및 렌더링용 RTCam 상태 안전 원복 // 4. Safely restore in-game camera and RTCam state for rendering
if (scrCamera.instance) if (scrCamera.instance)
{ {
scrCamera.instance.forceRTCam = false; scrCamera.instance.forceRTCam = false;
@@ -727,37 +774,40 @@ namespace ADOFAIRenderer.Core
if (hook) Destroy(hook); if (hook) Destroy(hook);
} }
// 5. 인게임 실패 방지 플래그 원상 복구 // 5. Restore in-game no-fail flags
if (scrController.instance) if (scrController.instance)
{ {
scrController.instance.noFail = false; scrController.instance.noFail = false;
scrController.instance.noFailInfiniteMargin = false; scrController.instance.noFailInfiniteMargin = false;
} }
// 6. 취소된 미완성 임시 동영상 파일 즉각 삭제 // 6. Immediately delete cancelled incomplete temporary video file
if (!string.IsNullOrEmpty(_currentOutPath) && File.Exists(_currentOutPath)) if (!string.IsNullOrEmpty(CurrentOutPath) && File.Exists(CurrentOutPath))
{ {
try try
{ {
File.Delete(_currentOutPath); File.Delete(CurrentOutPath);
Melon<RenderMod>.Logger.Msg($"[File Cleanup] 미완성 동영상 삭제 완료: {_currentOutPath}"); Melon<RenderMod>.Logger.Msg($"[File Cleanup] Deleted incomplete video: {CurrentOutPath}");
} }
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"[File Cleanup] 임시 동영상 삭제 실패: {ex.Message}"); Melon<RenderMod>.Logger.Error($"[File Cleanup] Failed to delete temporary video: {ex.Message}");
} }
_currentOutPath = null; CurrentOutPath = null;
} }
var result = BuildResult(false,
IsRenderCancelled ? "Rendering was cancelled by user." : "Rendering failed.");
TriggerOnComplete(result);
} }
} }
private IEnumerator StopRenderCoroutine() private IEnumerator StopRenderCoroutine()
{ {
yield return new WaitUntil(() => endSuccessful); yield return new WaitUntil(() => endSuccessful);
var fileString = !string.IsNullOrEmpty(_currentOutPath) ? $"'{Path.GetFileName(_currentOutPath)}'이(가)" : "동영상이";
var renderFolder = Path.Combine(MelonEnvironment.GameRootDirectory, "Render"); var result = BuildResult(success: true);
OSNotification.Show("렌더링 완료!", $"{fileString} 성공적으로 저장되었습니다.", renderFolder); TriggerOnComplete(result);
EndRender(); EndRender();
} }
@@ -795,26 +845,48 @@ namespace ADOFAIRenderer.Core
} }
_frameReader?.Dispose(); _frameReader?.Dispose();
ResetFrameHookState();
AudioMixer?.Reset(); AudioMixer = null; AudioMixer?.Reset(); AudioMixer = null;
SyncCalculator.Reset(); SyncCalculator.Reset();
RenderContext.Reset();
ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor"); ADOBase.loader.LoadSceneWithTransition(WipeDirection.StartsFromRight, "scnEditor");
Cursor.visible = true; Cursor.visible = true;
} }
private string GetOutputExtension(Codec codec) private RenderResult BuildResult(bool success, string? errorMessage = null)
{ {
return codec switch _renderStopwatch.Stop();
{ var effectiveFps = Math.Min(Configuration.VideoFPS, Configuration.RenderFPS);
Codec.AV1 or Codec.VP9 => ".webm", var sent = Interlocked.Read(ref framesSent);
Codec.VVC => ".mkv",
_ => ".mp4" return success
}; ? RenderResult.CreateSuccess(CurrentOutPath ?? string.Empty, sent, effectiveFps, _renderStopwatch.Elapsed)
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
} }
private AVCodecID GetAudioCodecId(string ext) private static void TriggerOnComplete(RenderResult result)
{ {
return ext == ".mp4" ? AVCodecID.AV_CODEC_ID_AAC : AVCodecID.AV_CODEC_ID_OPUS; try
{
RenderContext.OnComplete.Invoke(result);
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnComplete callback caused error: {err}");
}
}
private static void TriggerOnLastUpdate()
{
try
{
RenderContext.OnLastUpdate.Invoke();
}
catch (Exception err)
{
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
} }
} }
} }
+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;
}
}
-77
View File
@@ -1,77 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data
{
public 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);
}
});
}
}
}
}
-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>
public 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>
public 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>
public 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;
}
}
}
-86
View File
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data
{
/// <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>
public 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 != null && _dropdown.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
{
public 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>
public 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>
public 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>
public 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;
}
}
}
-182
View File
@@ -1,182 +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
{
public class RenderPreset
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
public 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>();
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
return presets;
}
try
{
string[] files = Directory.GetFiles(dir, "*.json");
foreach (string file in files)
{
try
{
string json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject != null)
{
var configToken = jObject["Config"];
if (configToken is JObject configObj)
{
// 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;
}
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
}
+102 -13
View File
@@ -1,9 +1,12 @@
using ADOFAIRenderer.Util.FFmpeg; using System.Diagnostics.CodeAnalysis;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Data namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{ {
public class RenderConfig
{
public double RenderFPS { get; set; } public double RenderFPS { get; set; }
public double VideoFPS { get; set; } public double VideoFPS { get; set; }
@@ -14,19 +17,71 @@ namespace ADOFAIRenderer.Data
public AudioEncodingConfig AudioConfig { get; set; } = new(); public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new(); public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterLevelEnd { get; set; } public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } public bool ShowJudgementText { get; set; } = false;
public void CopyFrom(RenderConfig? source)
{
if (source == null) return;
RenderFPS = source.RenderFPS;
VideoFPS = source.VideoFPS;
RenderWidth = source.RenderWidth;
RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText;
VideoConfig.CopyFrom(source.VideoConfig);
AudioConfig.CopyFrom(source.AudioConfig);
AdvancedConfig.CopyFrom(source.AdvancedConfig);
} }
public class AdvancedConfig public RenderConfig Clone()
{ {
var clone = new RenderConfig();
clone.CopyFrom(this);
return clone;
}
}
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
public int? RandomSeed { get; set; } = null; public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true; public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false; public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null;
public void CopyFrom(AdvancedConfig? source)
{
if (source == null) return;
RandomSeed = source.RandomSeed;
ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile;
FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor;
windPlanetColor = source.windPlanetColor;
} }
public class VideoEncodingConfig public AdvancedConfig Clone()
{ {
var clone = new AdvancedConfig();
clone.CopyFrom(this);
return clone;
}
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264; public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR; public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
@@ -35,12 +90,31 @@ namespace ADOFAIRenderer.Data
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18; public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0; public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; } = false; 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 class AudioEncodingConfig public VideoEncodingConfig Clone()
{ {
private int _audioSampleRate = 44100; var clone = new VideoEncodingConfig();
clone.CopyFrom(this);
return clone;
}
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2; private int _audioChannels = 2;
private int _audioBitrate = 256000; private int _audioBitrate = 256000;
@@ -48,8 +122,8 @@ namespace ADOFAIRenderer.Data
public int AudioSampleRate public int AudioSampleRate
{ {
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate; get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value; set => _audioSampleRate = value <= 0 ? 48000 : value;
} }
public int AudioChannels public int AudioChannels
@@ -63,5 +137,20 @@ namespace ADOFAIRenderer.Data
get => _audioBitrate <= 0 ? 256000 : _audioBitrate; get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value; 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 라이선스 조건이 그대로 적용됩니다.
+53
View File
@@ -0,0 +1,53 @@
{
"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_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)"
}
+53
View File
@@ -0,0 +1,53 @@
{
"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_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;
}
}
+6 -7
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")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{
[HarmonyPrefix] [HarmonyPrefix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static bool Prefix() public static bool Prefix()
{ {
if (!RenderManager.IsActive) return true; if (!RenderManager.IsActive) return true;
// Force offsetTick to match instantly without 1/100 damping division when rendering // Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0); AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (long)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true; AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation return false; // Skip the original damped offset calculation
} }
}
} }
+20 -23
View File
@@ -4,11 +4,11 @@ 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")]
public static class AudioManagerPlayPatch
{
[HarmonyPrefix] [HarmonyPrefix]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority) public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{ {
@@ -33,11 +33,11 @@ namespace ADOFAIRenderer.Patch.Engine
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f); RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
} }
} }
} }
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")] [HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch public static class PlayWithEndTimePatch
{ {
[HarmonyPrefix] [HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority) public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{ {
@@ -59,11 +59,11 @@ namespace ADOFAIRenderer.Patch.Engine
} }
return true; // Let the original method execute return true; // Let the original method execute
} }
} }
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)] [HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch public static class AudioSourcePitchPatch
{ {
[HarmonyPostfix] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value) public static void Postfix(AudioSource __instance, float value)
@@ -77,13 +77,11 @@ namespace ADOFAIRenderer.Patch.Engine
} }
} }
} }
} }
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))] {
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time) public static void Postfix(AudioSource __instance, double time)
@@ -97,11 +95,11 @@ namespace ADOFAIRenderer.Patch.Engine
} }
} }
} }
} }
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])] [HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch public static class AudioSourceStopPatch
{ {
[HarmonyPostfix] [HarmonyPostfix]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance) public static void Postfix(AudioSource __instance)
@@ -111,5 +109,4 @@ namespace ADOFAIRenderer.Patch.Engine
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance); RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
} }
} }
}
} }
+16 -19
View File
@@ -1,15 +1,13 @@
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 const bool RenderPatch = true;
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS; private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)] [HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
@@ -18,7 +16,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result) public static bool Prefix(ref float __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.timeBase); __result = (float)(TimeSpent + RenderManager.Instance.timeBase);
return false; return false;
} }
@@ -30,7 +28,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result) public static bool Prefix(ref double __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.timeBase; __result = TimeSpent + RenderManager.Instance.timeBase;
return false; return false;
} }
@@ -42,7 +40,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result) public static bool Prefix(ref float __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase); __result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
return false; return false;
} }
@@ -54,7 +52,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result) public static bool Prefix(ref double __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.unscaledTimeBase; __result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
return false; return false;
} }
@@ -66,7 +64,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result) public static bool Prefix(ref double __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.dspTimeBase; __result = TimeSpent + RenderManager.Instance.dspTimeBase;
return false; return false;
} }
@@ -78,7 +76,7 @@ namespace ADOFAIRenderer.Patch.Engine
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time) public static bool Prefix(AudioSource __instance, double time)
{ {
return !RenderManager.IsActive || !RenderPatch; return !RenderManager.IsActive;
} }
} }
@@ -88,7 +86,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result) public static bool Prefix(ref int __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase); __result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
return false; return false;
} }
@@ -100,7 +98,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result) public static bool Prefix(ref float __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS); __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false; return false;
} }
@@ -112,7 +110,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result) public static bool Prefix(ref float __result)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch) return true; if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS); __result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false; return false;
} }
@@ -124,7 +122,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result) public static bool Prefix(ref int __result)
{ {
if (!RenderManager.IsActive || !RenderPatch || !RenderManager.Instance) return true; if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderWidth; __result = RenderManager.Instance.Configuration.RenderWidth;
return false; return false;
} }
@@ -136,7 +134,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result) public static bool Prefix(ref int __result)
{ {
if (!RenderManager.IsActive || !RenderPatch || !RenderManager.Instance) return true; if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderHeight; __result = RenderManager.Instance.Configuration.RenderHeight;
return false; return false;
} }
@@ -148,7 +146,7 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window) public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{ {
if (!RenderManager.IsCapturingActive || !RenderPatch || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true; if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
AudioSpectrumGenerator.GetSpectrumData(samples, channel, window); AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
return false; return false;
} }
@@ -160,9 +158,8 @@ namespace ADOFAIRenderer.Patch.Engine
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream) public static void Prefix(ref bool stream)
{ {
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) if (RenderManager.Instance && RenderManager.Instance.stage >= RenderStage.Load && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false; stream = false;
} }
} }
}
} }
+5 -7
View File
@@ -2,12 +2,12 @@ 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))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("Show")] [HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -40,7 +40,6 @@ namespace ADOFAIRenderer.Patch.Game
for (var i = 0; i < lampSequencesArray.Length; i++) for (var i = 0; i < lampSequencesArray.Length; i++)
{ {
var item = lampSequencesArray.GetValue(i); var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try try
{ {
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) }); var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
@@ -70,5 +69,4 @@ namespace ADOFAIRenderer.Patch.Game
} }
chainSequenceField.SetValue(null); 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);
}
}
+19 -4
View File
@@ -1,11 +1,11 @@
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))]
public static class PersistencePatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)] [HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
@@ -18,5 +18,20 @@ namespace ADOFAIRenderer.Patch.Game
__result = SkipIntroBehavior.Off; __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));
}
}
}
+6 -7
View File
@@ -4,12 +4,12 @@ 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))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("Start")] [HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Patch.Game
if (RenderMod.IsFeatureDisabled) return; if (RenderMod.IsFeatureDisabled) return;
if (!__instance || !__instance.buttonExit) if (!__instance || !__instance.buttonExit)
{ {
Melon<RenderMod>.Logger.Msg("scnEditor에 렌더 버튼을 만들 수 없습니다."); Melon<RenderMod>.Logger.Msg("Cannot create render button in scnEditor.");
return; return;
} }
@@ -47,5 +47,4 @@ namespace ADOFAIRenderer.Patch.Game
iconImage.sprite = sprite; iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24); iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
} }
}
} }
+24 -19
View File
@@ -2,24 +2,23 @@ using System.IO;
using ADOFAIRenderer.Core; using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util; using ADOFAIRenderer.Util;
using HarmonyLib; using HarmonyLib;
using MelonLoader;
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))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("Play")] [HarmonyPatch("Play")]
public static void ProcessFloors() public static void ProcessFloors()
{ {
var nonTile = RenderManager.Instance.OverrideLoading && var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
if (!nonTile) return; if (!nonTile) return;
foreach (var floor in ADOBase.lm.listFloors) foreach (var floor in scrLevelMaker.instance.listFloors)
{ {
DisableRenderers( DisableRenderers(
floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer, floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
@@ -35,7 +34,17 @@ namespace ADOFAIRenderer.Patch.Game
DisablePlanetRenderer(planet); DisablePlanetRenderer(planet);
} }
Melon<RenderMod>.Logger.Msg("Tile Hide"); 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; return;
void DisablePlanetRenderer(PlanetRenderer planet) void DisablePlanetRenderer(PlanetRenderer planet)
@@ -68,11 +77,9 @@ namespace ADOFAIRenderer.Patch.Game
if (!RenderManager.IsActive) if (!RenderManager.IsActive)
{ {
// Remove FFmpeg player if it was attached previously // Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>(); var oldPlayer = __instance.videoBG.gameObject.GetComponent<VideoBGPlayer>();
if (oldPlayer != null) if (oldPlayer) Object.Destroy(oldPlayer);
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component // Re-enable native player component
__instance.videoBG.enabled = true; __instance.videoBG.enabled = true;
return true; // Execute original return true; // Execute original
@@ -88,7 +95,7 @@ namespace ADOFAIRenderer.Patch.Game
__instance.videoBG.gameObject.SetActive(true); __instance.videoBG.gameObject.SetActive(true);
// We can only play external video files via FFmpegNativeDecoder. // We can only play external video files via FFmpegNativeDecoder.
// Internal/Bundle level background videos are built-in VideoClips, so we fallback to native Unity VideoPlayer. // Internal/Bundle level background videos are built-in VideoClips, so we fall back to native Unity VideoPlayer.
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel) if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
{ {
__instance.videoBG.enabled = true; __instance.videoBG.enabled = true;
@@ -116,10 +123,9 @@ namespace ADOFAIRenderer.Patch.Game
__instance.videoBG.enabled = false; __instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player // Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>(); if (!__instance.videoBG.gameObject.TryGetComponent<VideoBGPlayer>(out var player))
if (player == null)
{ {
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>(); player = __instance.videoBG.gameObject.AddComponent<VideoBGPlayer>();
} }
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo"); var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
@@ -129,5 +135,4 @@ namespace ADOFAIRenderer.Patch.Game
return false; // Skip original UpdateVideo return false; // Skip original UpdateVideo
} }
}
} }
+5 -6
View File
@@ -1,12 +1,12 @@
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
{ {
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")] [HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -19,5 +19,4 @@ namespace ADOFAIRenderer.Patch.Game
__instance.frameRate = frame; __instance.frameRate = frame;
return false; return false;
} }
}
} }
+5 -6
View File
@@ -1,12 +1,12 @@
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))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("TogglePauseGame")] [HarmonyPatch("TogglePauseGame")]
public static bool PreventPause() public static bool PreventPause()
@@ -41,5 +41,4 @@ namespace ADOFAIRenderer.Patch.Game
{ {
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;
}
}
}
+10 -8
View File
@@ -1,19 +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
{ {
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")] [HarmonyPatch("SpawnPortalParticles")]
public static bool SkipPortalSpawn() // ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{ {
var nonTile = RenderManager.Instance.OverrideLoading && if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile; return !nonTile;
} }
}
} }
+5 -6
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")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{
public static bool Prefix() public static bool Prefix()
{ {
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } }; return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
} }
}
} }
+5 -6
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))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("SaveCustom")] [HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom() public static bool PreventSaveCustom()
{ {
return !RenderManager.IsActive; return !RenderManager.IsActive;
} }
}
} }
+5 -6
View File
@@ -1,12 +1,12 @@
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
{ {
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")] [HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
@@ -17,5 +17,4 @@ namespace ADOFAIRenderer.Patch.Game
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0; __result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
return false; return false;
} }
}
} }
+6 -7
View File
@@ -2,18 +2,18 @@ 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))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("Start")] [HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
public static void ForceScaledTime(scrPortalParticles __instance) public static void ForceScaledTime(scrPortalParticles __instance)
{ {
if (!RenderManager.IsActive) return; if (!RenderManager.Instance || RenderManager.Instance.stage < RenderStage.Load) return;
if (__instance == null) return; if (__instance == null) return;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true); var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
@@ -25,5 +25,4 @@ namespace ADOFAIRenderer.Patch.Game
main.useUnscaledTime = false; main.useUnscaledTime = false;
} }
} }
}
} }
+5 -6
View File
@@ -1,12 +1,12 @@
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
{ {
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
[HarmonyPrefix] [HarmonyPrefix]
[HarmonyPatch("ShowText")] [HarmonyPatch("ShowText")]
private static bool PreventShowText() private static bool PreventShowText()
@@ -16,5 +16,4 @@ namespace ADOFAIRenderer.Patch.Game
RenderManager.Instance.skipPressToStart = false; RenderManager.Instance.skipPressToStart = false;
return false; return false;
} }
}
} }
+9 -9
View File
@@ -4,12 +4,12 @@ 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))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("Update")] [HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -17,15 +17,16 @@ namespace ADOFAIRenderer.Patch.Game
{ {
if (!RenderManager.IsActive) return; if (!RenderManager.IsActive) return;
___txt.enabled = true;
___txt.text = "Render"; ___txt.text = "Render";
if (RenderManager.Instance) if (RenderManager.Instance)
{ {
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s.."; if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
if (RenderManager.Instance.wonFrame != -1) if (RenderManager.Instance.endFrame != -1)
{ {
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame; var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.endFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterLevelEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS; var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (remainingTime < 0) remainingTime = 0; if (remainingTime < 0) remainingTime = 0;
___txt.text += $"\nWaiting for {remainingTime:F1}s.."; ___txt.text += $"\nWaiting for {remainingTime:F1}s..";
} }
@@ -33,5 +34,4 @@ namespace ADOFAIRenderer.Patch.Game
___txt.color = ___defaultColor; ___txt.color = ___defaultColor;
} }
}
} }
+5 -6
View File
@@ -1,12 +1,12 @@
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))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{
[HarmonyPostfix] [HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")] [HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
@@ -17,5 +17,4 @@ namespace ADOFAIRenderer.Patch.Game
__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).
+16 -43
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.0.11-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.4-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
{ {
public class RenderMod : MelonMod
{
public static AssetBundle FontBundle = null!;
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
public static AssetBundle SceneBundle = null!;
// ReSharper disable once MemberCanBePrivate.Global
public static AssetBundle ShaderBundle = null!;
public static ComputeShader ColorConversionShader = null!;
public static bool IsFeatureDisabled = false; public static bool IsFeatureDisabled = false;
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
public override void OnInitializeMelon() public override void OnInitializeMelon()
{ {
GlobalSettings.Load();
Localization.LocalizationManager.Initialize();
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory; ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
var renderManagerObj = new GameObject("Render Manager"); var renderManagerObj = new GameObject("Render Manager");
var updateHookerObj = new GameObject("Update Hooker"); var updateHookerObj = new GameObject("Update Hooker");
var uiManagerObj = new GameObject("Renderer UI Manager");
Object.DontDestroyOnLoad(renderManagerObj); Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj); Object.DontDestroyOnLoad(updateHookerObj);
Object.DontDestroyOnLoad(uiManagerObj);
renderManagerObj.AddComponent<RenderManager>(); renderManagerObj.AddComponent<RenderManager>();
updateHookerObj.AddComponent<FirstUpdateHook>(); updateHookerObj.AddComponent<FirstUpdateHook>();
LoadBundles(); uiManagerObj.AddComponent<RendererUIManager>();
} ModAssets.Load();
private static void LoadBundles()
{
FontBundle = BundleLoader.GetBundle("fonts");
SceneBundle = BundleLoader.GetBundle("scenes");
SceneBundle.GetAllScenePaths();
try
{
ShaderBundle = BundleLoader.GetBundle("shaders");
ColorConversionShader = ShaderBundle.LoadAsset<ComputeShader>("ColorConversion");
Melon<RenderMod>.Logger.Msg("컬러 변환 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 != null) return;
Melon<RenderMod>.Logger.Msg("Unity 6 인풋 모듈 이식.");
var newModule = EventSystem.current.gameObject.AddComponent<StandaloneInputModule>();
newModule.enabled = true;
} }
public override void OnApplicationQuit() public override void OnApplicationQuit()
{ {
if (RenderManager.Instance == null) return; if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader 종료 감지: RenderManager의 활성 프로세스를 강제 수거 및 종료합니다."); Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
RenderManager.Instance.CleanUpActiveRender(); RenderManager.Instance.CleanUpActiveRender();
} }
}
} }
+28 -67
View File
@@ -7,13 +7,15 @@ using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button; 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 class DialogButton // ReSharper disable FieldCanBeMadeReadOnly.Global
{
public string Text; public string Text;
public Action OnClick; public Action OnClick;
public Color? BackgroundColor; public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
public DialogButton(string text, Action onClick, Color? backgroundColor = null) public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{ {
@@ -21,10 +23,10 @@ namespace ADOFAIRenderer.UI
OnClick = onClick; OnClick = onClick;
BackgroundColor = backgroundColor; BackgroundColor = backgroundColor;
} }
} }
public class CommonWarningDialogUI : MonoBehaviour internal class CommonWarningDialogUI : MonoBehaviour
{ {
public static CommonWarningDialogUI? Instance { get; private set; } public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new(); private readonly List<VisualElement> _disabledElements = new();
@@ -50,46 +52,6 @@ namespace ADOFAIRenderer.UI
} }
} }
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
}
// 폴백 처리
if (!font)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (!font)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
public static void ShowDialog(string title, string description, params DialogButton[] buttons) public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{ {
if (buttons.Length == 0) return; if (buttons.Length == 0) return;
@@ -112,22 +74,22 @@ namespace ADOFAIRenderer.UI
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None)) foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{ {
if (doc && doc.rootVisualElement != null && doc.rootVisualElement.enabledSelf) if (doc && doc.rootVisualElement is { enabledSelf: true })
{ {
doc.rootVisualElement.SetEnabled(false); doc.rootVisualElement.SetEnabled(false);
_disabledElements.Add(doc.rootVisualElement); _disabledElements.Add(doc.rootVisualElement);
} }
} }
// UI 레이어(5) 설정 // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// Canvas 설정 // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래 canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
// 해상도 대응 캔버스 스케일러 설정 // Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
@@ -136,13 +98,13 @@ namespace ADOFAIRenderer.UI
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. 전체 화면 오버레이 배경 생성 // 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel"); var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이 bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.raycastTarget = true; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -151,19 +113,19 @@ namespace ADOFAIRenderer.UI
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero; bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset(); var font = ModAssets.NanumSquareNeoNormal;
// 2. 모달 컨테이너 생성 // 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer"); var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5; modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>(); var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 기존 UI의 배경 톤과 동일 modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
var modalOutline = modalContainerObj.AddComponent<Outline>(); var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white; modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성 modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
var modalRect = modalContainerObj.GetComponent<RectTransform>(); var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f); modalRect.anchorMin = new Vector2(0.5f, 0.5f);
@@ -171,7 +133,7 @@ namespace ADOFAIRenderer.UI
modalRect.pivot = new Vector2(0.5f, 0.5f); modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(650, 360); modalRect.sizeDelta = new Vector2(650, 360);
// 3. 타이틀 텍스트 생성 // 3. Create title text
var titleObj = new GameObject("TitleText"); var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false); titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5; titleObj.layer = 5;
@@ -180,7 +142,7 @@ namespace ADOFAIRenderer.UI
titleText.text = title; titleText.text = title;
titleText.fontSize = 24; titleText.fontSize = 24;
titleText.font = font; titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // 경고 테마에 맞는 주황/황색 계열 titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
titleText.alignment = TextAlignmentOptions.Center; titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>(); var titleRect = titleObj.GetComponent<RectTransform>();
@@ -190,7 +152,7 @@ namespace ADOFAIRenderer.UI
titleRect.anchoredPosition = new Vector2(0, -35); titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40); titleRect.sizeDelta = new Vector2(0, 40);
// 4. 본문 설명 텍스트 생성 // 4. Create description text
var descObj = new GameObject("DescriptionText"); var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false); descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5; descObj.layer = 5;
@@ -210,7 +172,7 @@ namespace ADOFAIRenderer.UI
descRect.anchoredPosition = new Vector2(0, -65); descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120); descRect.sizeDelta = new Vector2(-40, 120);
// 5. 버튼 컨테이너 생성 // 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform)); var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false); buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5; buttonContainerObj.layer = 5;
@@ -222,7 +184,7 @@ namespace ADOFAIRenderer.UI
btnContainerRect.anchoredPosition = new Vector2(0, 35); btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50); btnContainerRect.sizeDelta = new Vector2(0, 50);
// 6. 가변 버튼 동적 배치 및 스타일링 // 6. Dynamic button layout and styling
float totalWidth = 560f; float totalWidth = 560f;
float spacing = 15f; float spacing = 15f;
float buttonHeight = 45f; float buttonHeight = 45f;
@@ -243,8 +205,8 @@ namespace ADOFAIRenderer.UI
btnObj.transform.SetParent(buttonContainerObj.transform, false); btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5; btnObj.layer = 5;
// 기본 색상 정의 // Define default colors
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값 Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f); Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f); Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
@@ -255,7 +217,7 @@ namespace ADOFAIRenderer.UI
var btn = btnObj.AddComponent<Button>(); var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor); SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// 클릭 콜백 등록 // Register click callback
btn.onClick.AddListener(() => btn.onClick.AddListener(() =>
{ {
try try
@@ -281,7 +243,7 @@ namespace ADOFAIRenderer.UI
btnRect.anchoredPosition = new Vector2(posX, 0); btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight); btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
// 텍스트 라벨 추가 // Add text label
var txtObj = new GameObject("Text"); var txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false); txtObj.transform.SetParent(btnObj.transform, false);
txtObj.layer = 5; txtObj.layer = 5;
@@ -319,5 +281,4 @@ namespace ADOFAIRenderer.UI
float b = Mathf.Clamp01(color.b * factor); float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a); return new Color(r, g, b, color.a);
} }
}
} }
+21 -57
View File
@@ -6,23 +6,23 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class DebugMonitorUI : MonoBehaviour
{ {
public class DebugMonitorUI : MonoBehaviour
{
// --- Progress Bar UI Configuration Constants --- // --- Progress Bar UI Configuration Constants ---
private const float BarWidth = 800f; private const float BarWidth = 800f;
private const float BarHeight = 28f; private const float BarHeight = 28f;
private const float BarPosX = 0f; private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080) private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기 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 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 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); private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants --- // --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선 private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께 private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
private TextMeshProUGUI _text = null!; private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!; private GameObject _progressBarObj = null!;
@@ -30,50 +30,17 @@ namespace ADOFAIRenderer.UI
private RectTransform _fillRect = null!; private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!; private TextMeshProUGUI _progressText = null!;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle != null)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
}
// 폴백 처리: 에셋 번들에서 실패 시 게임 자체 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() private void Start()
{ {
// UI 레이어(5) 설정 // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// Canvas 설정 // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999; canvas.sortingOrder = 9999;
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080) // Dynamic resolution scaling setup (Reference: 1920x1080)
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
@@ -82,13 +49,13 @@ namespace ADOFAIRenderer.UI
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 배경 패널 생성 // Create background panel
var panelObj = new GameObject("BackgroundPanel"); var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false); panelObj.transform.SetParent(transform, false);
panelObj.layer = 5; panelObj.layer = 5;
var image = panelObj.AddComponent<Image>(); var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성 image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Dark tone acrylic style
var panelRect = panelObj.GetComponent<RectTransform>(); var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1); panelRect.anchorMin = new Vector2(1, 1);
@@ -97,28 +64,27 @@ namespace ADOFAIRenderer.UI
panelRect.anchoredPosition = new Vector2(-30, -30); panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220); panelRect.sizeDelta = new Vector2(360, 220);
// 텍스트 생성 // Create text
var textObj = new GameObject("MonitorText"); var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false); textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5; textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>(); _text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true; _text.richText = true;
_text.fontSize = 16; // 13에서 16으로 증가 _text.fontSize = 16;
_text.alignment = TextAlignmentOptions.TopLeft; _text.alignment = TextAlignmentOptions.TopLeft;
// TMPro 텍스트 잘림 현상 예방 // Prevent TMPro text clipping
_text.textWrappingMode = TextWrappingModes.Normal; _text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow; _text.overflowMode = TextOverflowModes.Overflow;
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용 // Load and apply font
var font = GetNanumSquareNeoBdFontAsset(); _text.font = ModAssets.NanumSquareNeoNormal;
_text.font = font;
var textRect = textObj.GetComponent<RectTransform>(); var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero; textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one; textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백 textRect.offsetMin = new Vector2(20, 20); // Padding margin
textRect.offsetMax = new Vector2(-20, -20); textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup --- // --- Progress Bar UI Setup ---
@@ -130,7 +96,7 @@ namespace ADOFAIRenderer.UI
var bgImage = _progressBarObj.AddComponent<Image>(); var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor; bgImage.color = BarBackgroundColor;
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가 // Add progress bar outline component
var bgOutline = _progressBarObj.AddComponent<Outline>(); var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor; bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance; bgOutline.effectDistance = BarOutlineDistance;
@@ -164,12 +130,12 @@ namespace ADOFAIRenderer.UI
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>(); _progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true; _progressText.richText = true;
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가 _progressText.fontSize = BarTextFontSize;
_progressText.alignment = TextAlignmentOptions.Center; _progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font; _progressText.font = ModAssets.NanumSquareNeoNormal;
_progressText.color = BarTextColor; _progressText.color = BarTextColor;
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보) // Add progress bar text outline for readability
var textOutline = progressTextObj.AddComponent<Outline>(); var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f); textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f); textOutline.effectDistance = new Vector2(1.5f, -1.5f);
@@ -205,7 +171,6 @@ namespace ADOFAIRenderer.UI
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times"); 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($"• 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); var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB"); sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
@@ -230,5 +195,4 @@ namespace ADOFAIRenderer.UI
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0); if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})"; if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
} }
}
} }
+20 -22
View File
@@ -4,25 +4,25 @@ using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class EscapeHandlerUI : MonoBehaviour
{ {
public class EscapeHandlerUI : MonoBehaviour
{
private GameObject _containerObj = null!; private GameObject _containerObj = null!;
private TextMeshProUGUI _textComponent = null!; private TextMeshProUGUI _textComponent = null!;
private Stopwatch? _escapeStopwatch; private Stopwatch? _escapeStopwatch;
private void Start() private void Start()
{ {
// UI 레이어(5) 설정 // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// Canvas 설정 // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정 canvas.sortingOrder = 10000; // Placed above debug monitor
// 해상도 대응 캔버스 스케일러 설정 // Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
@@ -31,23 +31,22 @@ namespace ADOFAIRenderer.UI
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. 컨테이너 및 배경 패널 오브젝트 생성 // 1. Create container and background panel object
_containerObj = new GameObject("EscapeContainer"); _containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false); _containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5; _containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>(); var bgImage = _containerObj.AddComponent<Image>();
// 고급스러운 다크톤 아크릴 감성 bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f); // Dark tone acrylic style
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
var containerRect = _containerObj.GetComponent<RectTransform>(); var containerRect = _containerObj.GetComponent<RectTransform>();
containerRect.anchorMin = new Vector2(0.5f, 0.5f); containerRect.anchorMin = new Vector2(0.5f, 0.5f);
containerRect.anchorMax = new Vector2(0.5f, 0.5f); containerRect.anchorMax = new Vector2(0.5f, 0.5f);
containerRect.pivot = new Vector2(0.5f, 0.5f); containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙 containerRect.anchoredPosition = Vector2.zero; // Screen center
containerRect.sizeDelta = new Vector2(460, 160); containerRect.sizeDelta = new Vector2(460, 160);
// 2. 텍스트 오브젝트 생성 // 2. Create text object
var textObj = new GameObject("EscapeText"); var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false); textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5; textObj.layer = 5;
@@ -58,7 +57,7 @@ namespace ADOFAIRenderer.UI
_textComponent.textWrappingMode = TextWrappingModes.NoWrap; _textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true; _textComponent.richText = true;
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP) // Apply game's default font (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null) if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP; _textComponent.font = RDString.fontData.fontTMP;
@@ -68,13 +67,13 @@ namespace ADOFAIRenderer.UI
textRect.offsetMin = new Vector2(10, 10); textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10); textRect.offsetMax = new Vector2(-10, -10);
// 초기 상태는 비활성화 // Initial state disabled
_containerObj.SetActive(false); _containerObj.SetActive(false);
} }
private void Update() private void Update()
{ {
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴 // Destroy self if rendering is no longer active
if (!RenderManager.IsActive) if (!RenderManager.IsActive)
{ {
Destroy(gameObject); Destroy(gameObject);
@@ -93,10 +92,10 @@ namespace ADOFAIRenderer.UI
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds; var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed); var remaining = Mathf.Max(0f, 3f - elapsed);
// 남은 시간 텍스트 업데이트 // Update remaining time text
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>"; _textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
// 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐) // Micro pulse animation (container smoothly scales up while holding)
var scale = 1.0f + elapsed / 3.0f * 0.15f; var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f); _containerObj.transform.localScale = new Vector3(scale, scale, 1f);
@@ -104,10 +103,10 @@ namespace ADOFAIRenderer.UI
_escapeStopwatch.Stop(); _escapeStopwatch.Stop();
_escapeStopwatch = null; _escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화 _containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false); _containerObj.SetActive(false);
// 렌더링 취소 및 씬 이탈 // Cancel rendering and exit scene
if (RenderManager.Instance) if (RenderManager.Instance)
RenderManager.Instance.CancelRender(); RenderManager.Instance.CancelRender();
} }
@@ -116,7 +115,7 @@ namespace ADOFAIRenderer.UI
if (_escapeStopwatch == null) return; if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop(); _escapeStopwatch.Stop();
_escapeStopwatch = null; _escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화 _containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false); _containerObj.SetActive(false);
} }
} }
@@ -127,5 +126,4 @@ namespace ADOFAIRenderer.UI
_escapeStopwatch.Stop(); _escapeStopwatch.Stop();
_escapeStopwatch = null; _escapeStopwatch = null;
} }
}
} }
+64 -105
View File
@@ -10,10 +10,10 @@ using UnityEngine.UI;
using MelonLoader; using MelonLoader;
using MelonLoader.Utils; using MelonLoader.Utils;
namespace ADOFAIRenderer.UI namespace ADOFAIRenderer.UI;
internal class FFmpegDownloadUI : MonoBehaviour
{ {
public class FFmpegDownloadUI : MonoBehaviour
{
private GameObject _buttonContainerObj = null!; private GameObject _buttonContainerObj = null!;
private GameObject _progressBarObj = null!; private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!; private GameObject _progressBarFillObj = null!;
@@ -85,57 +85,17 @@ namespace ADOFAIRenderer.UI
} }
} }
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle != null)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
}
// 폴백 처리
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font != null) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start() private void Start()
{ {
// UI 레이어(5) 설정 // Set UI layer (5)
gameObject.layer = 5; gameObject.layer = 5;
// Canvas 설정 // Canvas setup
var canvas = gameObject.AddComponent<Canvas>(); var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32767; // 최상위 canvas.sortingOrder = 32767; // Topmost
// 해상도 대응 캔버스 스케일러 설정 // Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>(); var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080); scaler.referenceResolution = new Vector2(1920, 1080);
@@ -144,13 +104,13 @@ namespace ADOFAIRenderer.UI
gameObject.AddComponent<GraphicRaycaster>(); gameObject.AddComponent<GraphicRaycaster>();
// 1. 전체 화면 오버레이 배경 생성 // 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel"); var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false); bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5; bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>(); var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이 bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.raycastTarget = true; bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>(); var bgRect = bgPanelObj.GetComponent<RectTransform>();
@@ -159,18 +119,18 @@ namespace ADOFAIRenderer.UI
bgRect.offsetMin = Vector2.zero; bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero; bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset(); var font = ModAssets.NanumSquareNeoNormal;
// 2. 모달 컨테이너 생성 (다른 UI들과 디자인 일관성을 위해 동일한 아크릴 다크톤과 흰색 굵은 외곽선 적용) // 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer"); var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false); modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5; modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>(); var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 다른 UI의 배경 톤과 동일 modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f);
var modalOutline = modalContainerObj.AddComponent<Outline>(); var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white; // DebugMonitorUI와 일치하는 흰색 외곽선 modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); modalOutline.effectDistance = new Vector2(3.0f, -3.0f);
var modalRect = modalContainerObj.GetComponent<RectTransform>(); var modalRect = modalContainerObj.GetComponent<RectTransform>();
@@ -179,13 +139,13 @@ namespace ADOFAIRenderer.UI
modalRect.pivot = new Vector2(0.5f, 0.5f); modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(600, 320); modalRect.sizeDelta = new Vector2(600, 320);
// 4. 타이틀 텍스트 생성 // 4. Create title text
var titleObj = new GameObject("TitleText"); var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false); titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5; titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>(); var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = "FFmpeg 라이브러리 설치 필요"; titleText.text = "FFmpeg Library Installation Required";
titleText.fontSize = 26; titleText.fontSize = 26;
titleText.font = font; titleText.font = font;
titleText.color = Color.white; titleText.color = Color.white;
@@ -198,23 +158,23 @@ namespace ADOFAIRenderer.UI
titleRect.anchoredPosition = new Vector2(0, -35); titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40); titleRect.sizeDelta = new Vector2(0, 40);
// 5. 본문 설명 텍스트 생성 // 5. Create description text
var descObj = new GameObject("DescriptionText"); var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false); descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5; descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>(); var descText = descObj.AddComponent<TextMeshProUGUI>();
var descStrBuilder = new StringBuilder(); var descStrBuilder = new StringBuilder();
descStrBuilder.AppendLine("동영상을 렌더링하려면 FFmpeg 공유 라이브러리가 필요합니다."); descStrBuilder.AppendLine("FFmpeg shared libraries are required to render video.");
if (Application.platform == RuntimePlatform.OSXPlayer) if (Application.platform == RuntimePlatform.OSXPlayer)
{ {
descStrBuilder.AppendLine("OSX 플랫폼의 경우 신뢰 가능한 공개 빌드가 없어 수동으로 FFmpeg 8.1을 빌드하여야 합니다."); descStrBuilder.AppendLine("For macOS, reliable public builds are unavailable, so FFmpeg 8.1 must be built manually.");
descStrBuilder.AppendLine("아래 버튼을 눌러 ffmpeg 공식 웹사이트를 열 수 있습니다."); descStrBuilder.AppendLine("Click the button below to open the official FFmpeg website.");
} }
else else
{ {
descStrBuilder.AppendLine("아래 버튼을 눌러 인터넷에서 자동으로 다운로드하여 설치합니다."); descStrBuilder.AppendLine("Click the button below to automatically download and install from the internet.");
descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(다운로드 크기: 약 70MB, UserLibs 폴더에 압축 해제됨)</size></color>"); descStrBuilder.AppendLine("<color=#aaaaaa><size=13>(Download size: ~70MB, extracted to UserLibs folder)</size></color>");
} }
descText.text = descStrBuilder.ToString(); descText.text = descStrBuilder.ToString();
@@ -231,7 +191,7 @@ namespace ADOFAIRenderer.UI
descRect.anchoredPosition = new Vector2(0, -60); descRect.anchoredPosition = new Vector2(0, -60);
descRect.sizeDelta = new Vector2(-40, 100); descRect.sizeDelta = new Vector2(-40, 100);
// 6. 버튼 컨테이너 생성 // 6. Create button container
_buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform)); _buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
_buttonContainerObj.transform.SetParent(modalContainerObj.transform, false); _buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
_buttonContainerObj.layer = 5; _buttonContainerObj.layer = 5;
@@ -243,7 +203,7 @@ namespace ADOFAIRenderer.UI
btnContainerRect.anchoredPosition = new Vector2(0, 35); btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50); btnContainerRect.sizeDelta = new Vector2(0, 50);
// 7. 다운로드 버튼 생성 // 7. Create download button
var downloadBtnObj = new GameObject("DownloadButton"); var downloadBtnObj = new GameObject("DownloadButton");
downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false); downloadBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
downloadBtnObj.layer = 5; downloadBtnObj.layer = 5;
@@ -273,7 +233,7 @@ namespace ADOFAIRenderer.UI
downloadTextObj.layer = 5; downloadTextObj.layer = 5;
var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>(); var downloadText = downloadTextObj.AddComponent<TextMeshProUGUI>();
downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "다운로드 및 설치" : "사이트 열기"; downloadText.text = Application.platform != RuntimePlatform.OSXPlayer ? "Download & Install" : "Open Website";
downloadText.fontSize = 20; downloadText.fontSize = 20;
downloadText.font = font; downloadText.font = font;
downloadText.color = Color.white; downloadText.color = Color.white;
@@ -285,7 +245,7 @@ namespace ADOFAIRenderer.UI
downloadTextRect.offsetMin = Vector2.zero; downloadTextRect.offsetMin = Vector2.zero;
downloadTextRect.offsetMax = Vector2.zero; downloadTextRect.offsetMax = Vector2.zero;
// 8. 취소 버튼 생성 // 8. Create cancel button
var cancelBtnObj = new GameObject("CancelButton"); var cancelBtnObj = new GameObject("CancelButton");
cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false); cancelBtnObj.transform.SetParent(_buttonContainerObj.transform, false);
cancelBtnObj.layer = 5; cancelBtnObj.layer = 5;
@@ -310,7 +270,7 @@ namespace ADOFAIRenderer.UI
cancelTextObj.layer = 5; cancelTextObj.layer = 5;
var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>(); var cancelText = cancelTextObj.AddComponent<TextMeshProUGUI>();
cancelText.text = "취소"; cancelText.text = "Cancel";
cancelText.fontSize = 20; cancelText.fontSize = 20;
cancelText.font = font; cancelText.font = font;
cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f); cancelText.color = new Color(0.9f, 0.9f, 0.9f, 1f);
@@ -322,18 +282,18 @@ namespace ADOFAIRenderer.UI
cancelTextRect.offsetMin = Vector2.zero; cancelTextRect.offsetMin = Vector2.zero;
cancelTextRect.offsetMax = Vector2.zero; cancelTextRect.offsetMax = Vector2.zero;
// 9. 프로그레스 바 컨테이너 생성 // 9. Create progress bar container
_progressBarObj = new GameObject("ProgressBarContainer"); _progressBarObj = new GameObject("ProgressBarContainer");
_progressBarObj.transform.SetParent(modalContainerObj.transform, false); _progressBarObj.transform.SetParent(modalContainerObj.transform, false);
_progressBarObj.layer = 5; _progressBarObj.layer = 5;
_progressBarObj.SetActive(false); _progressBarObj.SetActive(false);
var barBgImg = _progressBarObj.AddComponent<Image>(); var barBgImg = _progressBarObj.AddComponent<Image>();
barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f); // DebugMonitorUI의 BarBackgroundColor와 동일 barBgImg.color = new Color(0.08f, 0.08f, 0.1f, 1.0f);
var barBgOutline = _progressBarObj.AddComponent<Outline>(); var barBgOutline = _progressBarObj.AddComponent<Outline>();
barBgOutline.effectColor = Color.white; // DebugMonitorUI의 BarOutlineColor와 동일 barBgOutline.effectColor = Color.white;
barBgOutline.effectDistance = new Vector2(3.0f, -3.0f); // DebugMonitorUI의 BarOutlineDistance와 동일 barBgOutline.effectDistance = new Vector2(3.0f, -3.0f);
var barRect = _progressBarObj.GetComponent<RectTransform>(); var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0); barRect.anchorMin = new Vector2(0.5f, 0);
@@ -342,28 +302,28 @@ namespace ADOFAIRenderer.UI
barRect.anchoredPosition = new Vector2(0, 75); barRect.anchoredPosition = new Vector2(0, 75);
barRect.sizeDelta = new Vector2(480, 24); barRect.sizeDelta = new Vector2(480, 24);
// 10. 프로그레스 바 채우기 이미지 생성 // 10. Create progress bar fill image
_progressBarFillObj = new GameObject("ProgressBarFill"); _progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false); _progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5; _progressBarFillObj.layer = 5;
var fillImg = _progressBarFillObj.AddComponent<Image>(); var fillImg = _progressBarFillObj.AddComponent<Image>();
fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f); // DebugMonitorUI의 BarFillColor와 동일 fillImg.color = new Color(0.0f, 0.8f, 1.0f, 1.0f);
_fillRect = _progressBarFillObj.GetComponent<RectTransform>(); _fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0); _fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1); // anchorMax.x가 동적으로 변경됨 _fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f); _fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero; _fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = Vector2.zero; _fillRect.sizeDelta = Vector2.zero;
// 11. 프로그레스 텍스트 생성 (진행률 표시) // 11. Create progress text
var progressTextObj = new GameObject("ProgressBarText"); var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false); progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5; progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>(); _progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.fontSize = 16; // DebugMonitorUI의 BarTextFontSize와 일치 _progressText.fontSize = 16;
_progressText.font = font; _progressText.font = font;
_progressText.color = Color.white; _progressText.color = Color.white;
_progressText.alignment = TextAlignmentOptions.Center; _progressText.alignment = TextAlignmentOptions.Center;
@@ -378,14 +338,14 @@ namespace ADOFAIRenderer.UI
progressTextRect.offsetMin = Vector2.zero; progressTextRect.offsetMin = Vector2.zero;
progressTextRect.offsetMax = Vector2.zero; progressTextRect.offsetMax = Vector2.zero;
// 12. 상태 텍스트 생성 // 12. Create status text
_statusTextObj = new GameObject("StatusText"); _statusTextObj = new GameObject("StatusText");
_statusTextObj.transform.SetParent(modalContainerObj.transform, false); _statusTextObj.transform.SetParent(modalContainerObj.transform, false);
_statusTextObj.layer = 5; _statusTextObj.layer = 5;
_statusTextObj.SetActive(false); _statusTextObj.SetActive(false);
_statusText = _statusTextObj.AddComponent<TextMeshProUGUI>(); _statusText = _statusTextObj.AddComponent<TextMeshProUGUI>();
_statusText.fontSize = 16; // DebugMonitorUI의 텍스트 크기와 일치 _statusText.fontSize = 16;
_statusText.font = font; _statusText.font = font;
_statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f); _statusText.color = new Color(0.8f, 0.8f, 0.8f, 1f);
_statusText.alignment = TextAlignmentOptions.Center; _statusText.alignment = TextAlignmentOptions.Center;
@@ -411,7 +371,7 @@ namespace ADOFAIRenderer.UI
private void Update() private void Update()
{ {
// 백그라운드 스레드에서 메인 스레드로 넘겨진 작업 순차 실행 // Execute queued actions on main thread
while (_mainThreadQueue.TryDequeue(out var action)) while (_mainThreadQueue.TryDequeue(out var action))
{ {
action?.Invoke(); action?.Invoke();
@@ -428,20 +388,20 @@ namespace ADOFAIRenderer.UI
if (_isDownloading) return; if (_isDownloading) return;
_isDownloading = true; _isDownloading = true;
// 버튼 숨기고 프로그레스 바 및 상태 텍스트 표시 // Hide buttons and show progress bar & status text
_buttonContainerObj.SetActive(false); _buttonContainerObj.SetActive(false);
_progressBarObj.SetActive(true); _progressBarObj.SetActive(true);
_statusTextObj.SetActive(true); _statusTextObj.SetActive(true);
_cts = new System.Threading.CancellationTokenSource(); _cts = new System.Threading.CancellationTokenSource();
// 백그라운드 스레드에서 다운로드 시작 // Start download in background thread
Task.Run(async () => Task.Run(async () =>
{ {
string tempZipPath = ""; string tempZipPath = "";
try try
{ {
UpdateStatus("다운로드 시작하는 중...", new Color(0.8f, 0.8f, 0.8f)); UpdateStatus("Starting download...", new Color(0.8f, 0.8f, 0.8f));
UpdateProgress(0f, "0%"); UpdateProgress(0f, "0%");
var userLibsDir = MelonEnvironment.UserLibsDirectory; var userLibsDir = MelonEnvironment.UserLibsDirectory;
@@ -461,24 +421,24 @@ namespace ADOFAIRenderer.UI
var progressReporter = new Progress<float>(percent => var progressReporter = new Progress<float>(percent =>
{ {
UpdateProgress(percent, $"{percent * 100f:F1}%"); UpdateProgress(percent, $"{percent * 100f:F1}%");
UpdateStatus($"다운로드 중... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f)); UpdateStatus($"Downloading... ({(percent * 100f):F1}%)", new Color(0f, 0.8f, 1f));
}); });
await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token); await DownloadFileAsync(DownloadUrl, tempZipPath, progressReporter, _cts.Token);
// 다운로드 완료 후 압축 해제 // Extract after download complete
UpdateStatus("압축 해제 및 DLL 파일 복사 중...", new Color(0.9f, 0.9f, 0.2f)); UpdateStatus("Extracting and copying library files...", new Color(0.9f, 0.9f, 0.2f));
UpdateProgress(0.99f, "압축 해제 중..."); UpdateProgress(0.99f, "Extracting...");
await ExtractDllsAsync(tempZipPath, userLibsDir); await ExtractDllsAsync(tempZipPath, userLibsDir);
// 완료 상태 업데이트 // Update completion status
UpdateStatus("FFmpeg 설치 완료!", new Color(0.2f, 0.9f, 0.2f)); UpdateStatus("FFmpeg installation complete!", new Color(0.2f, 0.9f, 0.2f));
UpdateProgress(1f, "100%"); UpdateProgress(1f, "100%");
await Task.Delay(1000); await Task.Delay(1000);
// 메인 스레드에서 UI를 삭제 // Destroy UI on main thread
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
{ {
Destroy(gameObject); Destroy(gameObject);
@@ -486,7 +446,7 @@ namespace ADOFAIRenderer.UI
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
// 취소 시 임시 파일 정리 // Clean up temporary file on cancellation
if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath)) if (!string.IsNullOrEmpty(tempZipPath) && File.Exists(tempZipPath))
{ {
try { File.Delete(tempZipPath); } try { File.Delete(tempZipPath); }
@@ -495,22 +455,22 @@ namespace ADOFAIRenderer.UI
// ignored // ignored
} }
} }
UpdateStatus("다운로드가 취소되었습니다.", new Color(0.9f, 0.5f, 0.2f)); UpdateStatus("Download was cancelled.", new Color(0.9f, 0.5f, 0.2f));
} }
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Error($"FFmpeg 다운로드/설치 중 에러 발생: {ex}"); Melon<RenderMod>.Logger.Error($"Error during FFmpeg download/installation: {ex}");
UpdateStatus($"설치 실패: {ex.Message}", new Color(1f, 0.3f, 0.3f)); UpdateStatus($"Installation failed: {ex.Message}", new Color(1f, 0.3f, 0.3f));
// 실패 시 버튼 재설정 // Reset buttons on failure
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
{ {
_isDownloading = false; _isDownloading = false;
_buttonContainerObj.SetActive(true); _buttonContainerObj.SetActive(true);
var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>(); var downloadBtnText = _buttonContainerObj.transform.Find("DownloadButton/DownloadText")?.GetComponent<TextMeshProUGUI>();
if (downloadBtnText != null) if (downloadBtnText)
{ {
downloadBtnText.text = "재시도 (Retry)"; downloadBtnText.text = "Retry";
} }
}); });
} }
@@ -530,7 +490,7 @@ namespace ADOFAIRenderer.UI
{ {
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
{ {
if (_statusText != null) if (_statusText)
{ {
_statusText.text = message; _statusText.text = message;
_statusText.color = color; _statusText.color = color;
@@ -542,11 +502,11 @@ namespace ADOFAIRenderer.UI
{ {
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
{ {
if (_fillRect != null) if (_fillRect)
{ {
_fillRect.anchorMax = new Vector2(percent, 1f); _fillRect.anchorMax = new Vector2(percent, 1f);
} }
if (_progressText != null) if (_progressText)
{ {
_progressText.text = text; _progressText.text = text;
} }
@@ -556,7 +516,7 @@ namespace ADOFAIRenderer.UI
private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken) private async Task DownloadFileAsync(string url, string destinationPath, IProgress<float> progress, System.Threading.CancellationToken cancellationToken)
{ {
using var client = new HttpClient(); using var client = new HttpClient();
// User-Agent 헤더 추가하여 차단 방지 // Add User-Agent header to prevent blocking
client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0"); client.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ADOFAIRenderer/1.0");
using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
@@ -611,7 +571,7 @@ namespace ADOFAIRenderer.UI
} }
else else
{ {
// 리눅스 (또는 기타 플랫폼) - tar.xz 해제 // Linux (or other platforms) - extract tar.xz
await Task.Run(() => await Task.Run(() =>
{ {
var tempExtractDir = Path.Combine(outputDirectory, "ffmpeg_temp_extracted"); var tempExtractDir = Path.Combine(outputDirectory, "ffmpeg_temp_extracted");
@@ -640,11 +600,11 @@ namespace ADOFAIRenderer.UI
if (process.ExitCode != 0) if (process.ExitCode != 0)
{ {
var error = process.StandardError.ReadToEnd(); var error = process.StandardError.ReadToEnd();
throw new Exception($"tar 압축 해제 실패 (ExitCode: {process.ExitCode}): {error}"); throw new Exception($"tar extraction failed (ExitCode: {process.ExitCode}): {error}");
} }
} }
// 압축이 풀린 파일 중에서 .so 파일들을 찾아서 outputDirectory로 이동 // Find .so files and copy to outputDirectory
var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories); var soFiles = Directory.GetFiles(tempExtractDir, "*.so*", SearchOption.AllDirectories);
foreach (var soFile in soFiles) foreach (var soFile in soFiles)
{ {
@@ -653,7 +613,7 @@ namespace ADOFAIRenderer.UI
File.Copy(soFile, targetPath, true); File.Copy(soFile, targetPath, true);
} }
// 임시 디렉토리 정리 // Clean up temporary directory
try try
{ {
Directory.Delete(tempExtractDir, true); Directory.Delete(tempExtractDir, true);
@@ -675,7 +635,7 @@ namespace ADOFAIRenderer.UI
} }
catch (Exception ex) catch (Exception ex)
{ {
Melon<RenderMod>.Logger.Warning($"임시 ZIP 파일 삭제 실패: {ex.Message}"); Melon<RenderMod>.Logger.Warning($"Failed to delete temporary zip file: {ex.Message}");
} }
} }
} }
@@ -689,5 +649,4 @@ namespace ADOFAIRenderer.UI
Instance = null; Instance = null;
} }
} }
}
} }
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
+3 -4
View File
@@ -1,9 +1,9 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal static class AudioResampler
{ {
public 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 ── // ── Safety Validation ──
@@ -97,5 +97,4 @@ namespace ADOFAIRenderer.Util.Audio
return outputSamples; return outputSamples;
} }
}
} }
+5 -4
View File
@@ -3,14 +3,16 @@ 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
{ {
public static class AudioSpectrumGenerator // ReSharper disable MemberCanBePrivate.Global
{
public static float[]? AudioClipData { get; private set; } public static float[]? AudioClipData { get; private set; }
public static int Channels { get; private set; } public static int Channels { get; private set; }
public static int Frequency { get; private set; } public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; } public static int TotalSamples { get; private set; }
// ReSharper restore MemberCanBePrivate.Global
private static long _cachedFrame = -1; private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new(); private static readonly Dictionary<int, float[]> CachedSpectrums = new();
@@ -236,5 +238,4 @@ namespace ADOFAIRenderer.Util.Audio
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor; spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
} }
} }
}
} }
+3 -4
View File
@@ -1,9 +1,9 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioSyncCalculator
{ {
public class AudioSyncCalculator
{
private readonly long _sampleRate; private readonly long _sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational; private readonly (long Numerator, long Denominator) _fpsRational;
@@ -37,5 +37,4 @@ namespace ADOFAIRenderer.Util.Audio
_currentSample += samplesNeeded; _currentSample += samplesNeeded;
return (int)samplesNeeded; return (int)samplesNeeded;
} }
}
} }
+3 -4
View File
@@ -1,9 +1,9 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal class Gain : IAudioFilter
{ {
public class Gain : IAudioFilter
{
public float Level = 0f; public float Level = 0f;
public void Initialize(int sampleRate, int channels) { } public void Initialize(int sampleRate, int channels) { }
@@ -20,5 +20,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
public void Reset() { } public void Reset() { }
public double Latency => 0.0; public double Latency => 0.0;
}
} }
+3 -4
View File
@@ -1,10 +1,10 @@
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
{ {
public class HardClip : IAudioFilter
{
// ReSharper disable once MemberCanBePrivate.Global // ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global // ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global // ReSharper disable once ConvertToConstant.Global
@@ -29,5 +29,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
public void Reset() { } public void Reset() { }
public double Latency => 0.0; public double Latency => 0.0;
}
} }
+3 -4
View File
@@ -1,9 +1,9 @@
using System; using System;
namespace ADOFAIRenderer.Util.Audio.Filter namespace ADOFAIRenderer.Util.Audio.Filter;
internal interface IAudioFilter
{ {
public 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);
@@ -12,5 +12,4 @@ namespace ADOFAIRenderer.Util.Audio.Filter
void Reset(); void Reset();
double Latency { get; } double Latency { get; }
}
} }
+6 -7
View File
@@ -1,11 +1,11 @@
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
public class RMSCompressor : IAudioFilter
{
private float _thresholdDb = -20f; private float _thresholdDb = -20f;
private float _ratio = 4f; private float _ratio = 4f;
private float _attackTime = 0.010f; // in seconds private float _attackTime = 0.010f; // in seconds
@@ -219,11 +219,10 @@ namespace ADOFAIRenderer.Util.Audio.Filter
Array.Clear(_rmsState, 0, _rmsState.Length); Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length); Array.Clear(_gainState, 0, _gainState.Length);
for (int i = 0; i < _delayBuffers.Length; i++) foreach (var delayBuffer in _delayBuffers)
{ {
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length); Array.Clear(delayBuffer, 0, delayBuffer.Length);
} }
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length); Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
} }
}
} }
+6 -7
View File
@@ -5,10 +5,10 @@ using ADOFAIRenderer.Util.Audio.Filter;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util.Audio namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
{ {
public class AudioClipCache
{
private readonly Dictionary<string, CachedClipData> _cache = new(); private readonly Dictionary<string, CachedClipData> _cache = new();
public class CachedClipData public class CachedClipData
@@ -67,10 +67,10 @@ namespace ADOFAIRenderer.Util.Audio
_cache.Clear(); _cache.Clear();
} }
} }
} }
public class OfflineAudioMixer internal class OfflineAudioMixer
{ {
public class SoundEvent public class SoundEvent
{ {
public string ClipName = null!; public string ClipName = null!;
@@ -413,5 +413,4 @@ namespace ADOFAIRenderer.Util.Audio
} }
} }
} }
}
} }
+6 -7
View File
@@ -4,11 +4,11 @@ using System.Resources;
using MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util;
internal static class BundleLoader
{ {
public static class BundleLoader private static readonly string BundlePlatform;
{
private static string BundlePlatform;
private static readonly Assembly? ExecutingAssembly; private static readonly Assembly? ExecutingAssembly;
static BundleLoader() static BundleLoader()
@@ -33,12 +33,11 @@ namespace ADOFAIRenderer.Util
return AssetBundle.LoadFromStream(resourceStream); return AssetBundle.LoadFromStream(resourceStream);
} }
Melon<RenderMod>.Logger.Error($"DLL에서 번들 리소스를 찾을 수 없습니다.\n{bundlePath}"); Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL."); throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
} }
Melon<RenderMod>.Logger.Error("실행 어셈블리가 제대로 설정되지 않았습니다."); Melon<RenderMod>.Logger.Error("Executing assembly has not been set properly.");
throw new InvalidOperationException("ExecutingAssembly has not been set."); 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
}
}
+9 -9
View File
@@ -2,22 +2,23 @@ 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")]
public enum EncoderKind
{
Software, Software,
NVENC, NVENC,
AMF, AMF,
QSV, QSV,
VideoToolbox VideoToolbox
} }
[SuppressMessage("ReSharper", "InconsistentNaming")] [SuppressMessage("ReSharper", "InconsistentNaming")]
public sealed class VideoEncoder : IEquatable<VideoEncoder> internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{ {
// ReSharper disable MemberCanBePrivate.Global // ReSharper disable MemberCanBePrivate.Global
// ── H.264 ── // ── H.264 ──
@@ -109,5 +110,4 @@ namespace ADOFAIRenderer.Util.FFmpeg
} }
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
public 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);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.IO;
namespace ADOFAIRenderer.Util;
internal static class FileUtil
{
public static void CreateDirectory(string path)
{
if (!Directory.Exists(path)) Directory.CreateDirectory(path);
}
}
+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;
[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
}
+48 -63
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;
@@ -12,7 +13,7 @@ using UnityEngine.Rendering;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util
{ {
public class FrameReader : IDisposable internal class FrameReader : IDisposable
{ {
private static readonly int Height = Shader.PropertyToID("Height"); private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int Width = Shader.PropertyToID("Width"); private static readonly int Width = Shader.PropertyToID("Width");
@@ -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
{
RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
RenderMod.ColorConversionShader.SetInt(Width, texture.width);
RenderMod.ColorConversionShader.SetInt(Height, texture.height);
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32 ? (texture.width + 31) / 32
: (texture.width + 63) / 64; : (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16; var threadGroupsY = (texture.height + 15) / 16;
RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1); _computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.Request( AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer, state.GPUBuffer, state.CachedCallback
);
/*AsyncGPUReadback.Request(
state.GPUBuffer, state.GPUBuffer,
state.CachedCallback 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
); );
} }
} }
@@ -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);
} }
+11 -11
View File
@@ -2,25 +2,26 @@ 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 MelonLoader; using MelonLoader;
using UnityEngine; using UnityEngine;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util;
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
public static class GPUUtil [SuppressMessage("ReSharper", "InconsistentNaming")]
{ internal enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
internal static class GPUUtil
{
private const string NvidiaPattern = private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$"; @"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
private const string IntelPattern = private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+$|Arc\(TM\) [A-C][0-9]+ Graphics)$"; @"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
private const string AMDPattern = private const string AMDPattern =
@@ -149,5 +150,4 @@ namespace ADOFAIRenderer.Util
prioritized.AddRange(others); prioritized.AddRange(others);
return prioritized; return prioritized;
} }
}
} }
+12 -12
View File
@@ -19,7 +19,7 @@ namespace ADOFAIRenderer.Util
/// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions. /// - <b>Linux</b>: Uses <c>notify-send</c> (libnotify) with <c>xdg-open</c> for folder actions.
/// </para> /// </para>
/// </summary> /// </summary>
public static class OSNotification internal static class OSNotification
{ {
/// <summary> /// <summary>
/// Sends a native desktop notification. /// Sends a native desktop notification.
@@ -48,7 +48,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] 알림을 보내지 못했습니다: {ex.Message}"); MelonLogger.Warning($"[OSNotification] Failed to send notification: {ex.Message}");
} }
} }
@@ -74,7 +74,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] Windows 알림 실패: {ex.Message}"); MelonLogger.Warning($"[OSNotification] Windows notification failed: {ex.Message}");
} }
}) })
{ {
@@ -110,7 +110,7 @@ namespace ADOFAIRenderer.Util
$"<text>{xmlTitle}</text><text>{xmlMessage}</text>" + $"<text>{xmlTitle}</text><text>{xmlMessage}</text>" +
"</binding></visual>" + "</binding></visual>" +
"<actions>" + "<actions>" +
"<action content=\"렌더 폴더 열기\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" + "<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>"; "</actions></toast>";
psScript = psScript =
@@ -165,7 +165,7 @@ namespace ADOFAIRenderer.Util
{ {
string stderr = process.StandardError.ReadToEnd().Trim(); string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr)) if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {stderr}"); MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
return false; return false;
} }
@@ -173,7 +173,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {ex.Message}"); MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
return false; return false;
} }
} }
@@ -283,7 +283,7 @@ namespace ADOFAIRenderer.Util
if (hWnd == IntPtr.Zero) if (hWnd == IntPtr.Zero)
{ {
MelonLogger.Warning("[OSNotification] 메시지 윈도우를 생성할 수 없습니다."); MelonLogger.Warning("[OSNotification] Cannot create message window.");
return; return;
} }
@@ -334,7 +334,7 @@ namespace ADOFAIRenderer.Util
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning( MelonLogger.Warning(
$"[OSNotification] 폴더를 열 수 없습니다: {ex.Message}"); $"[OSNotification] Cannot open folder: {ex.Message}");
} }
break; break;
@@ -357,7 +357,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] Balloon 알림 실패: {ex.Message}"); MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
} }
finally finally
{ {
@@ -465,7 +465,7 @@ namespace ADOFAIRenderer.Util
}; };
startInfo.ArgumentList.Add("-a"); startInfo.ArgumentList.Add("-a");
startInfo.ArgumentList.Add("ADOFAI Renderer"); startInfo.ArgumentList.Add("ADOFAI Renderer");
startInfo.ArgumentList.Add("--action=open_folder=렌더 폴더 열기"); startInfo.ArgumentList.Add("--action=open_folder=Open Render Folder");
startInfo.ArgumentList.Add(title); startInfo.ArgumentList.Add(title);
startInfo.ArgumentList.Add(message); startInfo.ArgumentList.Add(message);
@@ -491,7 +491,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] 리눅스 알림 처리 실패: {ex.Message}"); MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
// Fallback: show without action // Fallback: show without action
ShowSimple(title, message); ShowSimple(title, message);
@@ -554,7 +554,7 @@ namespace ADOFAIRenderer.Util
} }
catch (Exception ex) catch (Exception ex)
{ {
MelonLogger.Warning($"[OSNotification] gdbus 알림 실패: {ex.Message}"); MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
} }
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@ using UnityEngine;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util
{ {
public static class ParticleUtil internal static class ParticleUtil
{ {
private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new(); private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new();
+13 -14
View File
@@ -1,56 +1,55 @@
using System; using System;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util
{ {
public class RationalUtil internal static class RationalUtil
{ {
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 sign = isNegative ? -1.0 : 1.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);
+3 -1
View File
@@ -2,7 +2,7 @@ using System;
namespace ADOFAIRenderer.Util namespace ADOFAIRenderer.Util
{ {
public static class RegistryUtil internal static class RegistryUtil
{ {
private static readonly Type? RegistryKeyType; private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType; private static readonly Type? RegistryHiveType;
@@ -63,6 +63,7 @@ namespace ADOFAIRenderer.Util
} }
} }
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath) public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{ {
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null) if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetSubKeyNamesMethod == null)
@@ -94,6 +95,7 @@ namespace ADOFAIRenderer.Util
} }
} }
// ReSharper disable once InconsistentNaming
public static string? GetHKLMValue(string keyPath, string valueName) public static string? GetHKLMValue(string keyPath, string valueName)
{ {
if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null) if (RegistryKeyType == null || OpenBaseKeyMethod == null || OpenSubKeyMethod == null || GetValueMethod == null)
-29
View File
@@ -1,29 +0,0 @@
using System;
using System.IO;
using MelonLoader;
namespace ADOFAIRenderer.Util
{
public 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}");
}
}
}
}
}
+517
View File
@@ -0,0 +1,517 @@
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");
[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;
if (targetVideoTime < 0)
{
if (_targetTexture)
{
var active = RenderTexture.active;
RenderTexture.active = _targetTexture;
GL.Clear(true, true, Color.black);
RenderTexture.active = active;
}
if (_quad)
{
_quad.SetActive(false);
}
return;
}
if (ADOBase.customLevel)
{
ADOBase.customLevel.ShowTutorialBackground(false);
}
try
{
if (targetVideoTime < _currentFrameTimestamp || targetVideoTime - _currentFrameTimestamp > 1.0)
{
_decoder.SeekToTime(targetVideoTime);
_currentFrameTimestamp = targetVideoTime;
_nextFrameTimestamp = targetVideoTime;
}
bool frameUpdated = false;
while (targetVideoTime >= _nextFrameTimestamp)
{
double timestamp = 0.0;
bool success = false;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV)
{
success = _decoder.TryReadNV12Frame(
_textureY.GetRawTextureData<byte>(),
_textureUV.GetRawTextureData<byte>(),
out timestamp
);
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV)
{
success = _decoder.TryReadYUV420PFrame(
_textureY.GetRawTextureData<byte>(),
_textureU.GetRawTextureData<byte>(),
_textureV.GetRawTextureData<byte>(),
out timestamp
);
}
else
{
if (_textureRGBA != null)
{
success = _decoder.TryReadFrame(
_textureRGBA.GetRawTextureData<byte>(),
vflip: true,
out timestamp
);
}
}
if (success)
{
_currentFrameTimestamp = timestamp;
_nextFrameTimestamp = timestamp + (1.0 / _decoder.FPS);
frameUpdated = true;
}
else
{
if (_isLooping)
{
_decoder.SeekToTime(0);
_currentFrameTimestamp = 0;
_nextFrameTimestamp = 1.0 / _decoder.FPS;
break;
}
else
{
break;
}
}
}
if (frameUpdated)
{
RenderToTarget();
}
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
private void RenderToTarget()
{
if (_decoder == null) return;
int w = _decoder.Width;
int h = _decoder.Height;
if (_gpuMode == GPUMode.NV12 && _textureY && _textureUV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureUV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UVTex, _textureUV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_gpuMode == GPUMode.YUV420P && _textureY && _textureU && _textureV && _computeShader && _renderTexture)
{
_textureY.Apply();
_textureU.Apply();
_textureV.Apply();
_computeShader.SetTexture(_kernelIndex, YTex, _textureY);
_computeShader.SetTexture(_kernelIndex, UTex, _textureU);
_computeShader.SetTexture(_kernelIndex, VTex, _textureV);
_computeShader.SetTexture(_kernelIndex, Result, _renderTexture);
_computeShader.SetInt(Width, w);
_computeShader.SetInt(Height, h);
_computeShader.SetInt(ColorRange, _decoder.ColorRange == AVColorRange.AVCOL_RANGE_JPEG ? 1 : 0);
int threadGroupsX = (w + 7) / 8;
int threadGroupsY = (h + 7) / 8;
_computeShader.Dispatch(_kernelIndex, threadGroupsX, threadGroupsY, 1);
if (_targetTexture)
{
Graphics.Blit(_renderTexture, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _renderTexture)
{
_material.mainTexture = _renderTexture;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
else if (_textureRGBA)
{
_textureRGBA.Apply();
if (_targetTexture)
{
Graphics.Blit(_textureRGBA, _targetTexture);
}
else if (_material)
{
if (_material.mainTexture != _textureRGBA)
{
_material.mainTexture = _textureRGBA;
}
if (_quad != null && !_quad.activeSelf)
{
_quad.SetActive(true);
}
}
}
}
private void OnDestroy()
{
CleanUp();
}
public void CleanUp()
{
_isInitialized = false;
if (_decoder != null)
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
{
Destroy(_textureRGBA);
_textureRGBA = null;
}
if (_textureY != null) Destroy(_textureY);
if (_textureU != null) Destroy(_textureU);
if (_textureV != null) Destroy(_textureV);
if (_textureUV != null) Destroy(_textureUV);
_textureY = null;
_textureU = null;
_textureV = null;
_textureUV = null;
if (_renderTexture != null)
{
_renderTexture.Release();
Destroy(_renderTexture);
_renderTexture = null;
}
if (_quad != null)
{
Destroy(_quad);
_quad = null;
}
if (_material != null)
{
Destroy(_material);
_material = null;
}
}
// ReSharper disable once InconsistentNaming
private void SetupBackgroundQuad(Camera targetCamera, GameObject videoBGGo)
{
_quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
_quad.name = "FFmpegVideoBGQuad";
// ReSharper disable once Unity.UnresolvedComponentOrScriptableObject
var col = _quad.GetComponent("MeshCollider");
if (col) Destroy(col);
_quad.transform.parent = targetCamera.transform;
var zPos = targetCamera.farClipPlane - 0.5f;
_quad.transform.localPosition = new Vector3(0f, 0f, zPos);
_quad.transform.localRotation = Quaternion.identity;
float height;
float width;
if (targetCamera.orthographic)
{
height = 2.0f * targetCamera.orthographicSize;
width = height * targetCamera.aspect;
}
else
{
height = 2.0f * zPos * Mathf.Tan(targetCamera.fieldOfView * 0.5f * Mathf.Deg2Rad);
width = height * targetCamera.aspect;
}
_quad.transform.localScale = new Vector3(width, height, 1.0f);
_quad.layer = videoBGGo.layer;
// Set vertex colors to white so Sprites/Default works properly (otherwise it may draw transparently)
var meshFilter = _quad.GetComponent<MeshFilter>();
if (meshFilter != null && meshFilter.mesh != null)
{
var mesh = meshFilter.mesh;
var colors = new Color[mesh.vertexCount];
for (var i = 0; i < colors.Length; i++)
{
colors[i] = Color.white;
}
mesh.colors = colors;
}
Shader? shader = null;
if (scrCamera.instance != null)
{
try
{
var camQuadMeshField = typeof(scrCamera).GetField("camQuadMesh", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (camQuadMeshField != null)
{
var renderer = camQuadMeshField.GetValue(scrCamera.instance) as MeshRenderer;
if (renderer != null && renderer.sharedMaterial != null)
{
shader = renderer.sharedMaterial.shader;
}
}
}
catch (Exception)
{
// ignored
}
}
if (shader == null) shader = Shader.Find("Sprites/Default");
if (shader == null) shader = Shader.Find("Unlit/Texture");
if (shader == null) shader = Shader.Find("Standard");
_material = new Material(shader)
{
color = Color.white
};
_quad.GetComponent<MeshRenderer>().material = _material;
// Set inactive initially to prevent white flash
_quad.SetActive(false);
}
}