91 Commits
Author SHA256 Message Date
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
copecone 400f952990 Add NonTile Mode Prototype 2026-08-10 03:08:42 +09:00
copecone cb0d9c1c9c Add Safety Checks and Exception Handling to Game Patches 2026-08-08 22:54:31 +09:00
78 changed files with 8118 additions and 6148 deletions

No files matched your search

+1
View File
@@ -42,3 +42,4 @@ GEMINI.md
# Developer Description Files
/AsyncInputAutoplayFix.md
/AudioMixerExplanation.md
/O5Theme.md
+82 -29
View File
@@ -3,17 +3,22 @@
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
<NoWarn>MSB3277</NoWarn>
<NoWarn>MSB3277;1591</NoWarn>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>9</LangVersion>
<LangVersion>11</LangVersion>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<Reference Include="0Harmony">
<HintPath>$(ADOFAI_PATH)\MelonLoader\net35\0Harmony.dll</HintPath>
</Reference>
<Reference Include="O5Kit">
<HintPath>$(ADOFAI_PATH)\UserLibs\O5Kit.dll</HintPath>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Assembly-CSharp.dll</HintPath>
</Reference>
@@ -79,34 +84,82 @@
</Reference>
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\Win64\fonts" />
<EmbeddedResource Include="Bundle\Win64\fonts" />
<None Remove="Bundle\Win64\scenes" />
<EmbeddedResource Include="Bundle\Win64\scenes" />
<None Remove="Bundle\Win64\shaders" />
<EmbeddedResource Include="Bundle\Win64\shaders" />
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\Linux\fonts" />
<EmbeddedResource Include="Bundle\Linux\fonts" />
<None Remove="Bundle\Linux\scenes" />
<EmbeddedResource Include="Bundle\Linux\scenes" />
<None Remove="Bundle\Linux\shaders" />
<EmbeddedResource Include="Bundle\Linux\shaders" />
</ItemGroup>
<ItemGroup>
<None Remove="Bundle\OSX\fonts" />
<EmbeddedResource Include="Bundle\OSX\fonts" />
<None Remove="Bundle\OSX\scenes" />
<EmbeddedResource Include="Bundle\OSX\scenes" />
<None Remove="Bundle\OSX\shaders" />
<EmbeddedResource Include="Bundle\OSX\shaders" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" Version="8.1.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Localization/Locale/*.json" />
</ItemGroup>
<ItemGroup>
<Folder Include="Bundle/" />
<EmbeddedResource Include="Bundle/Linux/*" />
<EmbeddedResource Include="Bundle/Win64/*" />
<EmbeddedResource Include="Bundle/OSX/*" />
</ItemGroup>
<!-- Build 후 XML 문서에서 ADOFAIRenderer.API 네임스페이스만 남기고 나머지 제거 -->
<UsingTask TaskName="FilterXmlDoc" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
<ParameterGroup>
<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>
+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"}]";
}
}
+157
View File
@@ -0,0 +1,157 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util;
namespace ADOFAIRenderer.API;
/// <summary>
/// Fluent builder for configuring and starting a render session.
/// </summary>
[SuppressMessage("ReSharper", "UnusedType.Global")]
public class RenderSession
{
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>
/// 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;
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.
+49 -50
View File
@@ -1,66 +1,65 @@
using ADOFAIRenderer.UI;
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 _checkedNvidiaDriver;
private void Update()
{
private bool _checkedFFmpeg;
private bool _checkedNvidiaDriver;
private void Update()
if (!_checkedFFmpeg)
{
if (!_checkedFFmpeg)
if (FFmpegDownloadUI.Instance) return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{
if (FFmpegDownloadUI.Instance) return;
if (!FFmpegDownloadUI.CheckFFmpegExists())
{
FFmpegDownloadUI.ShowDownloadUI();
return;
}
_checkedFFmpeg = true;
}
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
_checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreRenderUpdate();
FFmpegDownloadUI.ShowDownloadUI();
return;
}
_checkedFFmpeg = true;
}
private void CheckNvidiaDriverWarning()
if (!_checkedNvidiaDriver)
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
if (driverVer is null or >= 610.00f) return;
if (CommonWarningDialogUI.Instance) return;
var currentVersion = driverVer.Value.ToString("F2");
_checkedNvidiaDriver = true;
CheckNvidiaDriverWarning();
}
CommonWarningDialogUI.ShowDialog(
"NVIDIA 그래픽 드라이버 업데이트 권장",
$"현재 감지된 NVIDIA 그래픽 드라이버 버전: <b><color=#ff5555>{currentVersion}</color></b>\n\n하드웨어 가속(NVENC)을 사용하여 영상을 렌더링하려면 드라이버 버전 <b><color=#55ff55>610.00 이상</color></b>이 필요합니다.\n\n드라이버 버전이 낮으면 인코딩이 실패하거나 소프트웨어(CPU) 인코딩으로 대체되어 렌더링이 매우 느려집니다. 드라이버 업데이트를 진행해 주시기 바랍니다.",
new DialogButton("드라이버 업데이트", () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("기능 비활성화", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("이번만 그냥 진행", () =>
{
// 아무 동작 안 함
})
);
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreUpdate();
}
}
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
if (driverVer is null or >= 610.00f) return;
var currentVersion = driverVer.Value.ToString("F2");
CommonWarningDialogUI.ShowDialog(
"NVIDIA Graphics Driver Update Recommended",
$"Currently detected NVIDIA graphics driver version: <b><color=#ff5555>{currentVersion}</color></b>\n\nDriver version <b><color=#55ff55>610.00 or higher</color></b> is required to render video using hardware acceleration (NVENC).\n\nIf the driver version is lower, encoding may fail or fallback to software (CPU) encoding, significantly slowing down rendering. Please update your graphics driver.",
new DialogButton("Update Driver", () =>
{
Application.OpenURL("https://www.nvidia.com/Download/index.aspx");
Application.Quit();
}, new Color(0.0f, 0.8f, 1.0f, 1.0f)),
new DialogButton("Disable Feature", () =>
{
RenderMod.IsFeatureDisabled = true;
}),
new DialogButton("Continue Anyway", () =>
{
// ignored
})
);
}
}
+24 -16
View File
@@ -1,26 +1,34 @@
using System.Collections;
using System.Reflection;
using HarmonyLib;
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 void Awake()
{
private FieldInfo? _camQuadMeshField;
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
private void Awake()
{
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
private void LateUpdate()
{
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
private void LateUpdate()
{
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
{
RenderManager.Instance.OnPostRender(val.material.mainTexture as RenderTexture);
}
}
StartCoroutine(PostFrameCoroutine());
}
private IEnumerator PostFrameCoroutine()
{
yield return new WaitForEndOfFrame();
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
}
}
+752 -752
View File
File diff suppressed because it is too large. Load diff
-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);
}
});
}
}
}
}
+69 -68
View File
@@ -1,78 +1,79 @@
namespace ADOFAIRenderer.Data
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class DoubleLimit
{
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// Validates the given double value against this limit.
/// </summary>
public abstract class DoubleLimit
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{
/// <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)
{
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;
}
errorMessage = "Value must be a positive number greater than 0.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
// ReSharper disable MemberCanBePrivate.Global
public double? Min { get; }
public double? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
-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;
}
}
}
}
+79
View File
@@ -0,0 +1,79 @@
using System;
using System.IO;
using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Data;
public class GlobalConfig
{
public string? OutputDirectory { get; set; }
}
public static class GlobalSettings
{
private static string SettingsFilePath =>
Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "global_settings.json");
public static GlobalConfig Current { get; set; } = new();
public static string DefaultOutputDirectory =>
Path.Combine(MelonEnvironment.GameRootDirectory, "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}");
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class DoubleInputValidator
{
private struct FieldEntry
{
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<DoubleLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
+87
View File
@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
/// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal class EnumDropdownBinder<T> where T : struct, Enum
{
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary>
/// The currently selected enum value.
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
_dropdown = rootVisualElement.Q<DropdownField>(fieldName);
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}' not found.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
}
}
/// <summary>
/// Sets the selected enum value and updates the dropdown index.
/// </summary>
/// <param name="value">The enum value to set.</param>
public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class IntegerInputValidator
{
private struct FieldEntry
{
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<IntegerLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-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);
}
});
}
}
}
}
+69 -68
View File
@@ -1,78 +1,79 @@
namespace ADOFAIRenderer.Data
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class IntegerLimit
{
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// Validates the given integer value against this limit.
/// </summary>
public abstract class IntegerLimit
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{
/// <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)
{
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;
}
errorMessage = "Value must be a positive integer greater than 0.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
// ReSharper disable MemberCanBePrivate.Global
public int? Min { get; }
public int? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
+142 -135
View File
@@ -7,176 +7,183 @@ using MelonLoader.Utils;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Data
namespace ADOFAIRenderer.Data;
internal class RenderPreset
{
public class RenderPreset
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
}
internal static class PresetManager
{
private static string GetPresetsDirectory()
{
public string Name { get; set; } = string.Empty;
public RenderConfig Config { get; set; } = null!;
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
}
public static class PresetManager
private static string GetSafeFileName(string name)
{
private static string GetPresetsDirectory()
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
{
return Path.Combine(MelonEnvironment.GameRootDirectory, "UserData", "ADOFAIRenderer", "Presets");
throw new ArgumentException("Preset name cannot be empty.");
}
private static string GetSafeFileName(string name)
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
Directory.CreateDirectory(dir);
}
public static void SavePreset(string name, RenderConfig config)
var preset = new RenderPreset
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("프리셋 이름은 비어 있을 수 없습니다.");
}
Name = name,
Config = config
};
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
{
Directory.CreateDirectory(dir);
}
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
var preset = new RenderPreset
{
Name = name,
Config = config
};
public static List<RenderPreset> GetPresets()
{
var presets = new List<RenderPreset>();
var dir = GetPresetsDirectory();
string json = JsonConvert.SerializeObject(preset, Formatting.Indented);
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
File.WriteAllText(filePath, json);
}
if (!Directory.Exists(dir)) return presets;
public static List<RenderPreset> GetPresets()
try
{
var presets = new List<RenderPreset>();
string dir = GetPresetsDirectory();
if (!Directory.Exists(dir))
var files = Directory.GetFiles(dir, "*.json");
foreach (var file in files)
{
return presets;
}
try
{
string[] files = Directory.GetFiles(dir, "*.json");
foreach (string file in files)
try
{
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)
{
string json = File.ReadAllText(file);
var jObject = JsonConvert.DeserializeObject<JObject>(json);
if (jObject != null)
TryChangeKey(configObj, "DelayAfterLevelEnd", "DelayAfterEnd");
// 1. legacy EncodingConfig -> VideoConfig
if (configObj["EncodingConfig"] is JObject oldEncodingConfigObj)
{
var configToken = jObject["Config"];
if (configToken is JObject configObj)
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)
{
// 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");
}
configObj["VideoConfig"] = new JObject();
}
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
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)
{
presets.Add(preset);
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");
}
}
catch (Exception ex)
var preset = jObject.ToObject<RenderPreset>();
if (preset != null && !string.IsNullOrEmpty(preset.Name))
{
MelonLogger.Warning($"프리셋 파일 '{Path.GetFileName(file)}' 로드 중 오류 발생: {ex.Message}");
presets.Add(preset);
}
}
catch (Exception ex)
{
MelonLogger.Warning($"Error loading preset file '{Path.GetFileName(file)}': {ex.Message}");
}
}
catch (Exception ex)
{
MelonLogger.Error($"프리셋 목록을 불러오는 중 오류 발생: {ex.Message}");
}
return presets;
}
catch (Exception ex)
{
MelonLogger.Error($"Error retrieving preset list: {ex.Message}");
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
return presets;
}
if (File.Exists(filePath))
{
File.Delete(filePath);
}
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
if (legacyToken != null && legacyToken.Type != JTokenType.Null)
configObj[targetKey] = legacyToken;
configObj.Remove(keyBefore);
}
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
File.Delete(filePath);
}
}
}
+63 -55
View File
@@ -1,67 +1,75 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg;
using UnityEngine;
namespace ADOFAIRenderer.Data
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
public class RenderConfig
public double RenderFPS { get; set; }
public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
}
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false;
public string FileExtension { get; set; } = "mkv";
public Color? firePlanetColor = null;
public Color? icePlanetColor = null;
public Color? windPlanetColor = null;
}
public class VideoEncodingConfig
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; }
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
public double RenderFPS { get; set; }
public double VideoFPS { get; set; }
public int RenderWidth { get; set; }
public int RenderHeight { get; set; }
public VideoEncodingConfig VideoConfig { get; set; } = new();
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterLevelEnd { get; set; }
public bool ShowJudgementText { get; set; }
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public class AdvancedConfig
public int AudioChannels
{
public int? RandomSeed { get; set; } = null;
public bool ProcessAudio { get; set; } = true;
public bool NonTile { get; set; } = false;
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public class VideoEncodingConfig
public int AudioBitrate
{
public Codec Codec { get; set; } = Codec.H264;
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
public int Bitrate { get; set; } = 30000;
public int MaxBitrate { get; set; } = 50000;
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
public double KeyframeInterval { get; set; } = 2.0;
public bool ForceSoftwareEncoder { get; set; } = false;
}
public class AudioEncodingConfig
{
private int _audioSampleRate = 44100;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
public bool RenderAudio { get; set; } = true;
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value;
}
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
}
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 ADOFAI Editor Forum
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+36
View File
@@ -0,0 +1,36 @@
{
"ui.render_config.window_title": "Render Config",
"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.expired": "(Expired)"
}
+36
View File
@@ -0,0 +1,36 @@
{
"ui.render_config.window_title": "렌더 설정",
"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": "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": "렌더 시작",
"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.expired": "(만료됨)"
}
+246
View File
@@ -0,0 +1,246 @@
using System;
using System.Collections.Generic;
using System.IO;
using MelonLoader;
using Newtonsoft.Json;
using UnityEngine;
namespace ADOFAIRenderer.Localization;
/// <summary>
/// Provides access to localized strings and handles language synchronization with the game.
/// </summary>
internal static class LocalizationManager
{
private static readonly Dictionary<string, Dictionary<string, string>> Locales = new(StringComparer.OrdinalIgnoreCase);
private static readonly string FallbackLocaleCode = "en";
/// <summary>
/// Event triggered when the active language changes.
/// </summary>
public static event Action<SystemLanguage>? OnLanguageChanged;
/// <summary>
/// Currently active SystemLanguage.
/// </summary>
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
/// <summary>
/// Currently active locale code (e.g. "en", "ko").
/// </summary>
public static string CurrentLocaleCode { get; private set; } = "en";
private static bool _initialized;
static LocalizationManager()
{
Initialize();
}
/// <summary>
/// Initializes the localization system and loads embedded language dictionaries.
/// </summary>
public static void Initialize()
{
if (_initialized) return;
_initialized = true;
LoadEmbeddedLocales();
// Detect initial language from game settings
var initialLang = DetectGameLanguage();
SetLanguage(initialLang, notify: false);
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
}
private static void LogMsg(string msg)
{
try { Melon<RenderMod>.Logger.Msg(msg); }
catch { Console.WriteLine(msg); }
}
private static void LogError(string msg)
{
try { Melon<RenderMod>.Logger.Error(msg); }
catch { Console.Error.WriteLine(msg); }
}
/// <summary>
/// Loads all *.json locale files embedded in this assembly.
/// </summary>
private static void LoadEmbeddedLocales()
{
var assembly = typeof(LocalizationManager).Assembly;
var resourceNames = assembly.GetManifestResourceNames();
foreach (var resourceName in resourceNames)
{
if (!resourceName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) continue;
if (!resourceName.Contains("Localization.Locale")) continue;
try
{
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) continue;
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
if (dict != null)
{
// Extract locale code from resource name: e.g. "ADOFAIRenderer.Localization.Locale.ko.json" -> "ko"
var parts = resourceName.Split('.');
if (parts.Length >= 2)
{
var code = parts[^2].ToLowerInvariant();
Locales[code] = dict;
LogMsg($"[Localization] Loaded locale '{code}' ({dict.Count} keys).");
}
}
}
catch (Exception ex)
{
LogError($"[Localization] Failed to load locale resource '{resourceName}': {ex.Message}");
}
}
}
/// <summary>
/// Sets the active language and notifies subscribers if changed.
/// </summary>
public static void SetLanguage(SystemLanguage language, bool notify = true)
{
var localeCode = LanguageToLocaleCode(language);
if (!Locales.ContainsKey(localeCode))
{
// Fallback to English if translation for this language does not exist
localeCode = FallbackLocaleCode;
}
var isChanged = language != CurrentLanguage || localeCode != CurrentLocaleCode;
CurrentLanguage = language;
CurrentLocaleCode = localeCode;
if (isChanged && notify)
{
LogMsg($"[Localization] Language changed to: {CurrentLanguage} ({CurrentLocaleCode})");
try
{
OnLanguageChanged?.Invoke(CurrentLanguage);
}
catch (Exception ex)
{
LogError($"[Localization] Error during OnLanguageChanged notification: {ex.Message}");
}
}
}
/// <summary>
/// Retrieves a localized string for the specified key.
/// </summary>
public static string Get(string key)
{
if (string.IsNullOrEmpty(key)) return string.Empty;
if (!_initialized) Initialize();
// 1. Try currently active locale
if (Locales.TryGetValue(CurrentLocaleCode, out var activeDict) && activeDict.TryGetValue(key, out var val))
{
return val;
}
// 2. Fallback to English locale
if (CurrentLocaleCode != FallbackLocaleCode &&
Locales.TryGetValue(FallbackLocaleCode, out var fallbackDict) &&
fallbackDict.TryGetValue(key, out var fallbackVal))
{
return fallbackVal;
}
// 3. Fallback to raw key if missing
return key;
}
/// <summary>
/// Retrieves a formatted localized string.
/// </summary>
public static string Get(string key, params object[] args)
{
var template = Get(key);
if (args.Length == 0) return template;
try
{
return string.Format(template, args);
}
catch (FormatException)
{
return template;
}
}
/// <summary>
/// Maps Unity's SystemLanguage enum to an internal locale code.
/// </summary>
private static string LanguageToLocaleCode(SystemLanguage language)
{
return language switch
{
SystemLanguage.Korean => "ko",
_ => "en"
};
}
/// <summary>
/// Attempts to read the game's current language from RDString or Persistence.
/// </summary>
private static SystemLanguage DetectGameLanguage()
{
try
{
// First attempt: RDString.language
return RDString.language;
}
catch
{
try
{
// Fallback attempt: Persistence.language
return Persistence.language;
}
catch
{
return SystemLanguage.English;
}
}
}
}
/// <summary>
/// Convenient shorthand alias for <see cref="LocalizationManager"/>.
/// </summary>
// ReSharper disable once InconsistentNaming
internal static class I18n
{
/// <inheritdoc cref="LocalizationManager.Get(string)"/>
public static string Get(string key) => LocalizationManager.Get(key);
/// <inheritdoc cref="LocalizationManager.Get(string, object[])"/>
public static string Get(string key, params object[] args) => LocalizationManager.Get(key, args);
/// <inheritdoc cref="LocalizationManager.CurrentLanguage"/>
public static SystemLanguage CurrentLanguage => LocalizationManager.CurrentLanguage;
/// <inheritdoc cref="LocalizationManager.CurrentLocaleCode"/>
public static string CurrentLocaleCode => LocalizationManager.CurrentLocaleCode;
/// <inheritdoc cref="LocalizationManager.OnLanguageChanged"/>
public static event Action<SystemLanguage>? OnLanguageChanged
{
add => LocalizationManager.OnLanguageChanged += value;
remove => LocalizationManager.OnLanguageChanged -= value;
}
}
+12 -13
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine
namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
public static bool Prefix()
{
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix()
{
if (!RenderManager.IsActive) return true;
// Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (ulong)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation
}
if (!RenderManager.IsActive) return true;
// Force offsetTick to match instantly without 1/100 damping division when rendering
AsyncInputManager.offsetTick = AsyncInputManager.currFrameTick - (long)(scrConductor.instance.dspTime * 10000000.0);
AsyncInputManager.offsetTickUpdated = true;
return false; // Skip the original damped offset calculation
}
}
+87 -90
View File
@@ -4,112 +4,109 @@ using HarmonyLib;
using UnityEngine;
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]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{
[HarmonyPrefix]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
// Cache the clip on the main thread
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
}
}
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
{
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{
// Cache the clip on the main thread
RenderManager.Instance.AudioMixer?.CacheClip(snd);
}
return true; // Let the original method execute to prevent NullReferenceException
}
mixer.CacheClip(snd);
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio && __result != null)
{
// Convert absolute DSP time to relative time in seconds from render start
// Convert absolute DSP times to relative times in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register event to the offline mixer (default pitch = 1.0f) and map it to the AudioSource
RenderManager.Instance.AudioMixer?.RegisterSoundEvent(__result, snd, relativeStartTime, -1.0, volume, 1.0f);
// Register looping sound event to the offline mixer (default pitch = 1.0f)
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
}
}
return true; // Let the original method execute
}
}
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
{
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{
// Cache the clip on the main thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
double relativeStartTime = time - RenderManager.Instance.dspTimeBase;
double relativeEndTime = endTime - RenderManager.Instance.dspTimeBase;
// Register looping sound event to the offline mixer (default pitch = 1.0f)
mixer.RegisterSoundEvent(snd, relativeStartTime, relativeEndTime, volume, 1.0f);
}
}
return true; // Let the original method execute
}
}
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, float value)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.Pitch = value;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
soundEvent.Pitch = value;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance, double time)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null && mixer.TryGetSoundEvent(__instance, out var soundEvent))
{
soundEvent.EndTime = time - RenderManager.Instance.dspTimeBase;
}
}
}
}
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __instance)
{
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{
RenderManager.Instance.AudioMixer?.RemoveSourceMapping(__instance);
}
}
}
+117 -120
View File
@@ -1,168 +1,165 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.Audio;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine
namespace ADOFAIRenderer.Patch.Engine;
public static class RenderEnvironmentPatches
{
public static class RenderEnvironmentPatches
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{
private const bool RenderPatch = true;
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.timeBase);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.timeBase);
return false;
}
}
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = TimeSpent + RenderManager.Instance.timeBase;
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.timeBase;
return false;
}
}
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(TimeSpent + RenderManager.Instance.unscaledTimeBase);
return false;
}
}
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.unscaledTimeBase;
return false;
}
}
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = TimeSpent + RenderManager.Instance.dspTimeBase;
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = TimeSpent + RenderManager.Instance.dspTimeBase;
return false;
}
}
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch
{
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
return !RenderManager.IsActive || !RenderPatch;
}
return !RenderManager.IsActive;
}
}
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (int)(RenderManager.Instance.frameCount + RenderManager.Instance.frameCountBase);
return false;
}
}
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
}
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
if (!RenderManager.IsCapturingActive || !RenderPatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderContext.TimePatch) return true;
__result = (float)(1 / RenderManager.Instance.Configuration.RenderFPS);
return false;
}
}
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
if (!RenderManager.IsActive || !RenderPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderWidth;
return false;
}
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderWidth;
return false;
}
}
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
if (!RenderManager.IsActive || !RenderPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderHeight;
return false;
}
if (!RenderManager.IsActive || !RenderContext.ScreenPatch || !RenderManager.Instance) return true;
__result = RenderManager.Instance.Configuration.RenderHeight;
return false;
}
}
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{
if (!RenderManager.IsCapturingActive || !RenderPatch || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
return false;
}
if (!RenderManager.IsCapturingActive || !RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio) return true;
AudioSpectrumGenerator.GetSpectrumData(samples, channel, window);
return false;
}
}
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{
if (RenderManager.Instance && RenderManager.Instance.OverrideLoading && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false;
}
if (RenderManager.Instance && RenderManager.Instance.stage >= RenderStage.Load && RenderManager.Instance.Configuration.AdvancedConfig.ProcessAudio)
stream = false;
}
}
}
+56 -57
View File
@@ -2,73 +2,72 @@ using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
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]
[HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
{
[HarmonyPrefix]
[HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool ForceHide(EndscreenLanterns __instance, ref int __result)
if (!RenderManager.IsActive) return true;
if (__instance != null)
{
if (!RenderManager.IsActive) return true;
if (__instance != null)
{
__instance.Hide();
}
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
__instance.Hide();
}
__result = 0; // Return 0 lanterns shown
return false; // Skip original Show method
}
[HarmonyPrefix]
[HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void PrefixHide(EndscreenLanterns __instance)
[HarmonyPrefix]
[HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void PrefixHide(EndscreenLanterns __instance)
{
if (__instance == null) return;
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
{
if (__instance == null) return;
if (!RenderManager.IsActive) return;
var traverse = Traverse.Create(__instance);
// Kill lampSequences (private Sequence[] lampSequences) via reflection
var lampSequencesField = traverse.Field("lampSequences");
var lampSequencesObj = lampSequencesField.GetValue();
if (lampSequencesObj is System.Array lampSequencesArray)
for (var i = 0; i < lampSequencesArray.Length; i++)
{
for (var i = 0; i < lampSequencesArray.Length; i++)
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(item, new object[] { false });
}
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(item, new object[] { false });
}
catch
{
// Ignore reflection errors
}
lampSequencesArray.SetValue(null, i);
}
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
}
// Kill chainSequence (private Sequence chainSequence) via reflection
var chainSequenceField = traverse.Field("chainSequence");
var chainSequenceObj = chainSequenceField.GetValue();
if (chainSequenceObj == null) return;
try
{
var killMethod = chainSequenceObj.GetType().GetMethod("Kill", new[] { typeof(bool) });
killMethod?.Invoke(chainSequenceObj, new object[] { false });
}
catch
{
// Ignore reflection errors
}
chainSequenceField.SetValue(null);
}
}
+26
View File
@@ -0,0 +1,26 @@
using ADOFAIRenderer.Localization;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Synchronizes mod localization with ADOFAI's in-game language settings.
/// </summary>
[HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch
{
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
[HarmonyPostfix]
public static void OnChangeLanguage(SystemLanguage language)
{
LocalizationManager.SetLanguage(language);
}
[HarmonyPatch(nameof(RDString.Setup))]
[HarmonyPostfix]
public static void OnSetup()
{
LocalizationManager.SetLanguage(RDString.language);
}
}
+12 -13
View File
@@ -1,22 +1,21 @@
using ADOFAIRenderer.Core;
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]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
{
[HarmonyPostfix]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
public static void LockSkipIntroBehavior(ref SkipIntroBehavior __result)
if (RenderManager.IsActive &&
scrConductor.instance != null &&
!scrConductor.instance.hasSongStarted)
{
if (RenderManager.IsActive &&
scrConductor.instance != null &&
!scrConductor.instance.hasSongStarted)
{
__result = SkipIntroBehavior.Off;
}
__result = SkipIntroBehavior.Off;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(PlanetRenderer))]
public static class PlanetRendererPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetColor")]
// ReSharper disable once InconsistentNaming
private static void OverrideColor(PlanetRenderer __instance, ref PlanetColor planetColor)
{
if (RenderManager.Instance.stage == RenderStage.None) return;
var advConf = RenderManager.Instance.Configuration.AdvancedConfig;
ApplyColor(ref planetColor, scrController.instance.planetRed, advConf.firePlanetColor);
ApplyColor(ref planetColor, scrController.instance.planetBlue, advConf.icePlanetColor);
ApplyColor(ref planetColor, scrController.instance.planetGreen, advConf.windPlanetColor);
return;
void ApplyColor(ref PlanetColor planetColor, scrPlanet? targetPlanet, Color? customColor)
{
if (targetPlanet != null && __instance == targetPlanet.planetRenderer && customColor != null)
planetColor = new PlanetColor(customColor.Value);
}
}
}
+38 -33
View File
@@ -1,45 +1,50 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using MelonLoader;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{
[HarmonyPatch(typeof(scnEditor))]
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
public static void AddButtonRender(scnEditor __instance)
{
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
public static void AddButtonRender(scnEditor __instance)
if (RenderMod.IsFeatureDisabled) return;
if (!__instance || !__instance.buttonExit)
{
if (RenderMod.IsFeatureDisabled) return;
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
Melon<RenderMod>.Logger.Msg("Cannot create render button in scnEditor.");
return;
}
var cloneTarget = __instance.buttonExit!.gameObject;
var buttonContainer = cloneTarget.transform.parent!;
var buttonObj = Object.Instantiate(cloneTarget, buttonContainer)!;
buttonObj.name = "buttonRender";
var button = buttonObj.GetComponent<Button>()!;
// Clear persistent and non-persistent listeners from the original button
button.onClick = new Button.ButtonClickedEvent();
button.onClick.AddListener(RenderManager.StartRender);
// The button root has an Image component for the background (rounded box).
// We want to change the icon sprite, which is on a child GameObject.
var images = buttonObj.GetComponentsInChildren<Image>();
if (images.Length <= 1) return;
// ReSharper disable once Unity.UnknownResource
// images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load<Sprite>("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
var iconImage = images[1];
iconImage.sprite = sprite;
iconImage.rectTransform.sizeDelta = new Vector2(24, 24);
}
}
+121 -64
View File
@@ -4,81 +4,138 @@ using ADOFAIRenderer.Util;
using HarmonyLib;
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]
[HarmonyPatch("Play")]
public static void ProcessFloors()
{
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance)
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors)
{
// Only replace when rendering is active
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
}
DisableRenderers(
floor.floorRenderer.renderer, floor.legacyFloorSpriteRenderer,
floor.iconsprite, floor.outlineSprite,
floor.topGlow, floor.bottomGlow,
floor.multiplanetLine
);
string text = __instance.levelData.miscSettings.GetString("bgVideo");
if (string.IsNullOrEmpty(text))
{
__instance.videoBG.gameObject.SetActive(false);
return false; // Skip original
}
if (floor.holdRenderer) DisableRenderer(floor.holdRenderer.m_meshRenderer);
__instance.videoBG.gameObject.SetActive(true);
if (floor.dummyPlanets == null) continue;
foreach (var planet in floor.dummyPlanets)
DisablePlanetRenderer(planet);
}
// 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.
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
{
__instance.videoBG.enabled = true;
return true; // Execute original
}
var players = scrPlayerManager.instance.players;
if (players == null) return;
// Custom level video path
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text);
if (!File.Exists(videoPath))
{
return true; // Let the original print error
}
foreach (var player in players)
{
var planets = player.planetarySystem?.planetList;
if (planets == null) continue;
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
foreach (var planet in planets)
DisablePlanetRenderer(planet.planetRenderer);
}
return;
__instance.videoBG.enabled = false;
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
DisableRenderers(
planet.coreParticles.GetRenderer(), planet.tailParticles.GetRenderer(),
planet.tailParticlesCoop.GetRenderer(), planet.sparks.GetRenderer(), planet.ring, planet.glow,
planet.faceSprite, planet.faceDetails, planet.samuraiSprite
);
}
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
void DisableRenderers(params Renderer?[] renderers)
{
foreach (var renderer in renderers) DisableRenderer(renderer);
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
}
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
public static bool ReplaceVideo(scnGame __instance)
{
// Only replace when rendering is active
if (!RenderManager.IsActive)
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
}
string text = __instance.levelData.miscSettings.GetString("bgVideo");
if (string.IsNullOrEmpty(text))
{
__instance.videoBG.gameObject.SetActive(false);
return false; // Skip original
}
__instance.videoBG.gameObject.SetActive(true);
// We can only play external video files via FFmpegNativeDecoder.
// Internal/Bundle level background videos are built-in VideoClips, so we fall back to native Unity VideoPlayer.
if (ADOBase.isInternalLevel || ADOBase.isBundleLevel)
{
__instance.videoBG.enabled = true;
return true; // Execute original
}
// Custom level video path
var videoPath = Path.Combine(Path.GetDirectoryName(ADOBase.levelPath) ?? "", text);
if (!File.Exists(videoPath))
{
return true; // Let the original print error
}
// Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
var camGo = GameObject.Find("BGStaticCam");
if (camGo != null)
{
targetCamera = camGo.GetComponent<Camera>();
}
}
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
var isLooping = __instance.levelData.miscSettings.GetBool("loopVideo");
// Initialize player. If targetTexture is null (CameraFarPlane mode), it will build a quad in front of targetCamera.
player.Init(videoPath, __instance.videoBG.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
}
}
+16 -17
View File
@@ -1,23 +1,22 @@
using System.Diagnostics.CodeAnalysis;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
if (enable || __instance.enableCustomFPS != enable) return true;
namespace ADOFAIRenderer.Patch.Game;
__instance.enableCustomFPS = enable;
__instance.frameRate = frame;
return false;
}
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
// There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
__instance.frameRate = frame;
return false;
}
}
+33 -34
View File
@@ -1,45 +1,44 @@
using ADOFAIRenderer.Core;
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]
[HarmonyPatch("TogglePauseGame")]
public static bool PreventPause()
{
[HarmonyPrefix]
[HarmonyPatch("TogglePauseGame")]
public static bool PreventPause()
{
return !RenderManager.IsActive;
}
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("FailAction")]
public static bool PreventFailAction()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("FailAction")]
public static bool PreventFailAction()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied()
{
return !RenderManager.IsActive;
}
[HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied()
{
return !RenderManager.IsActive;
}
}
+30 -31
View File
@@ -1,41 +1,40 @@
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrDecoration))]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
{
[HarmonyPatch(typeof(scrDecoration))]
[HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming
public static class scrDecorationPatch
public static bool SetRotationPrefix(scrDecoration __instance)
{
[HarmonyPrefix]
[HarmonyPatch("SetRotation")]
// ReSharper disable once InconsistentNaming
public static bool SetRotationPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
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("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("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;
}
[HarmonyPrefix]
[HarmonyPatch("UpdatePosition")]
// ReSharper disable once InconsistentNaming
public static bool UpdatePositionPrefix(scrDecoration __instance)
{
return __instance != null && __instance && __instance.pivotTrans != null;
}
}
+21
View File
@@ -0,0 +1,21 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
public static bool SkipPortalSpawn(scrFloor __instance)
{
if (!scrLevelMaker.instance.listFloors.Contains(__instance)) return true;
var nonTile = RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile;
}
}
+7 -8
View File
@@ -1,15 +1,14 @@
using ADOFAIRenderer.Core;
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 } };
}
}
+9 -10
View File
@@ -1,17 +1,16 @@
using ADOFAIRenderer.Core;
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]
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
{
[HarmonyPrefix]
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
{
return !RenderManager.IsActive;
}
return !RenderManager.IsActive;
}
}
+14 -15
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
namespace ADOFAIRenderer.Patch.Game;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
return false;
}
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
private static bool IgnoreInput(ref bool __result)
{
if (!RenderManager.IsActive) return true;
__result = RenderManager.Instance && RenderManager.Instance.frameCount == 0 && RenderManager.Instance.countdown == 0;
return false;
}
}
+17 -16
View File
@@ -1,27 +1,28 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{
[HarmonyPatch(typeof(scrPortalParticles))]
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
public static void ForceScaledTime(scrPortalParticles __instance)
{
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
public static void ForceScaledTime(scrPortalParticles __instance)
if (!RenderManager.Instance || RenderManager.Instance.stage < RenderStage.Load) return;
if (__instance == null) return;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particleSystems)
{
if (__instance == null) return;
if (ps == null) continue;
var particleSystems = __instance.GetComponentsInChildren<ParticleSystem>(true);
foreach (var ps in particleSystems)
{
if (ps == null) continue;
var main = ps.main;
main.useUnscaledTime = false;
}
var main = ps.main;
main.useUnscaledTime = false;
}
}
}
+13 -14
View File
@@ -1,20 +1,19 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
{
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
namespace ADOFAIRenderer.Patch.Game;
RenderManager.Instance.skipPressToStart = false;
return false;
}
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
{
if (!RenderManager.Instance || !RenderManager.Instance.skipPressToStart) return true;
RenderManager.Instance.skipPressToStart = false;
return false;
}
}
+22 -22
View File
@@ -4,34 +4,34 @@ using HarmonyLib;
using UnityEngine;
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]
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static void IgnoreInput(Text ___txt, Color ___defaultColor)
{
[HarmonyPostfix]
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static void IgnoreInput(Text ___txt, Color ___defaultColor)
if (!RenderManager.IsActive) return;
___txt.enabled = true;
___txt.text = "Render";
if (RenderManager.Instance)
{
if (!RenderManager.IsActive) return;
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
___txt.text = "Render";
if (RenderManager.Instance)
if (RenderManager.Instance.endFrame != -1)
{
if (RenderManager.Instance.countdown > 0) ___txt.text = $"Render in {RenderManager.Instance.countdown}s..";
if (RenderManager.Instance.wonFrame != -1)
{
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.wonFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterLevelEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (remainingTime < 0) remainingTime = 0;
___txt.text += $"\nWaiting for {remainingTime:F1}s..";
}
var elapsedFrames = RenderManager.Instance.frameCount - RenderManager.Instance.endFrame;
var remainingTime = RenderManager.Instance.Configuration.DelayAfterEnd - elapsedFrames / RenderManager.Instance.Configuration.RenderFPS;
if (remainingTime < 0) remainingTime = 0;
___txt.text += $"\nWaiting for {remainingTime:F1}s..";
}
___txt.color = ___defaultColor;
}
___txt.color = ___defaultColor;
}
}
+10 -11
View File
@@ -1,21 +1,20 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game
namespace ADOFAIRenderer.Patch.Game;
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{
[HarmonyPatch(typeof(scrUIController))]
[HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
{
[HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
public static void PreventEndscreenLanternsPostfix(scrUIController __instance)
if (RenderManager.IsActive && __instance != null)
{
if (RenderManager.IsActive && __instance != null)
{
__instance.HideEndscreenLanterns();
}
__instance.HideEndscreenLanterns();
}
}
}
+93
View File
@@ -0,0 +1,93 @@
# 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.
---
## ✨ 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 (GPLv3). See `LICENSE` for details.
+32 -59
View File
@@ -1,75 +1,48 @@
using System;
using ADOFAIRenderer;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
using MelonLoader;
using MelonLoader.Utils;
using UnityEngine;
using UnityEngine.EventSystems;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.0.10-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.1-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer
namespace ADOFAIRenderer;
internal class RenderMod : MelonMod
{
public class RenderMod : MelonMod
// ReSharper disable once MemberCanBePrivate.Global
public static bool IsFeatureDisabled = false;
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
public override void OnInitializeMelon()
{
public static AssetBundle FontBundle = null!;
// 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;
GlobalSettings.Load();
Localization.LocalizationManager.Initialize();
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
public override void OnInitializeMelon()
{
ffmpeg.RootPath = MelonEnvironment.UserLibsDirectory;
var renderManagerObj = new GameObject("Render Manager");
var updateHookerObj = new GameObject("Update Hooker");
var uiManagerObj = new GameObject("Renderer UI Manager");
Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj);
Object.DontDestroyOnLoad(uiManagerObj);
var renderManagerObj = new GameObject("Render Manager");
var updateHookerObj = new GameObject("Update Hooker");
Object.DontDestroyOnLoad(renderManagerObj);
Object.DontDestroyOnLoad(updateHookerObj);
renderManagerObj.AddComponent<RenderManager>();
updateHookerObj.AddComponent<FirstUpdateHook>();
uiManagerObj.AddComponent<RendererUIManager>();
ModAssets.Load();
}
renderManagerObj.AddComponent<RenderManager>();
updateHookerObj.AddComponent<FirstUpdateHook>();
LoadBundles();
}
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()
{
if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader 종료 감지: RenderManager의 활성 프로세스를 강제 수거 및 종료합니다.");
RenderManager.Instance.CleanUpActiveRender();
}
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
Melon<RenderMod>.Logger.Msg("MelonLoader shutdown detected: Forcefully terminating and cleaning up active RenderManager processes.");
RenderManager.Instance.CleanUpActiveRender();
}
}
+271 -310
View File
@@ -7,317 +7,278 @@ using UnityEngine.UIElements;
using Button = UnityEngine.UI.Button;
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 Action OnClick;
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
public string Text;
public Action OnClick;
public Color? BackgroundColor;
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
Text = text;
OnClick = onClick;
BackgroundColor = backgroundColor;
}
}
public class CommonWarningDialogUI : MonoBehaviour
{
public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new();
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
foreach (var elem in _disabledElements)
{
if (elem != null)
{
elem.SetEnabled(true);
}
}
_disabledElements.Clear();
if (Instance == this)
{
Instance = null;
}
}
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)
{
if (buttons.Length == 0) return;
var obj = new GameObject("CommonWarningDialogUI");
DontDestroyOnLoad(obj);
var dialog = obj.AddComponent<CommonWarningDialogUI>();
dialog.Initialize(title, description, buttons);
}
private void Initialize(string title, string description, DialogButton[] buttons)
{
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
eventSystemObj.transform.SetParent(transform);
eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>();
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement != null && doc.rootVisualElement.enabledSelf)
{
doc.rootVisualElement.SetEnabled(false);
_disabledElements.Add(doc.rootVisualElement);
}
}
// UI 레이어(5) 설정
gameObject.layer = 5;
// Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // FFmpegDownloadUI(32767) 바로 아래
// 해상도 대응 캔버스 스케일러 설정
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. 전체 화면 오버레이 배경 생성
var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // 어두운 투명 오버레이
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
// 2. 모달 컨테이너 생성
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 기존 UI의 배경 톤과 동일
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // 기존 UI 외곽선 스타일 일관성
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(650, 360);
// 3. 타이틀 텍스트 생성
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = title;
titleText.fontSize = 24;
titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // 경고 테마에 맞는 주황/황색 계열
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 4. 본문 설명 텍스트 생성
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
descText.text = description;
descText.fontSize = 20;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 8;
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. 버튼 컨테이너 생성
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 6. 가변 버튼 동적 배치 및 스타일링
float totalWidth = 560f;
float spacing = 15f;
float buttonHeight = 45f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
float startX = numButtons == 1
? 0
: (-totalWidth / 2f) + (buttonWidth / 2f);
for (int i = 0; i < numButtons; i++)
{
var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}");
btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
// 기본 색상 정의
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // 커스텀 색상 또는 회색톤 기본값
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// 클릭 콜백 등록
btn.onClick.AddListener(() =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>();
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
btnRect.pivot = new Vector2(0.5f, 0.5f);
float posX = numButtons == 1
? 0
: startX + i * (buttonWidth + spacing);
btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
// 텍스트 라벨 추가
var txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false);
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
}
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
Text = text;
OnClick = onClick;
BackgroundColor = backgroundColor;
}
}
internal class CommonWarningDialogUI : MonoBehaviour
{
public static CommonWarningDialogUI? Instance { get; private set; }
private readonly List<VisualElement> _disabledElements = new();
private void Awake()
{
Instance = this;
}
private void OnDestroy()
{
foreach (var elem in _disabledElements)
{
if (elem != null)
{
elem.SetEnabled(true);
}
}
_disabledElements.Clear();
if (Instance == this)
{
Instance = null;
}
}
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{
if (buttons.Length == 0) return;
var obj = new GameObject("CommonWarningDialogUI");
DontDestroyOnLoad(obj);
var dialog = obj.AddComponent<CommonWarningDialogUI>();
dialog.Initialize(title, description, buttons);
}
private void Initialize(string title, string description, DialogButton[] buttons)
{
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
eventSystemObj.transform.SetParent(transform);
eventSystemObj.AddComponent<UnityEngine.EventSystems.EventSystem>();
eventSystemObj.AddComponent<UnityEngine.EventSystems.StandaloneInputModule>();
}
foreach (var doc in FindObjectsByType<UIDocument>(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement is { enabledSelf: true })
{
doc.rootVisualElement.SetEnabled(false);
_disabledElements.Add(doc.rootVisualElement);
}
}
// Set UI layer (5)
gameObject.layer = 5;
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 32766; // Just below FFmpegDownloadUI (32767)
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create full screen overlay background
var bgPanelObj = new GameObject("BackgroundPanel");
bgPanelObj.transform.SetParent(transform, false);
bgPanelObj.layer = 5;
var bgImage = bgPanelObj.AddComponent<Image>();
bgImage.color = new Color(0f, 0f, 0f, 0.65f); // Dark transparent overlay
bgImage.raycastTarget = true;
var bgRect = bgPanelObj.GetComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = ModAssets.NanumSquareNeoNormal;
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
modalContainerObj.transform.SetParent(bgPanelObj.transform, false);
modalContainerObj.layer = 5;
var modalImage = modalContainerObj.AddComponent<Image>();
modalImage.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Matching background tone of existing UI
var modalOutline = modalContainerObj.AddComponent<Outline>();
modalOutline.effectColor = Color.white;
modalOutline.effectDistance = new Vector2(3.0f, -3.0f); // Consistent with existing UI outline style
var modalRect = modalContainerObj.GetComponent<RectTransform>();
modalRect.anchorMin = new Vector2(0.5f, 0.5f);
modalRect.anchorMax = new Vector2(0.5f, 0.5f);
modalRect.pivot = new Vector2(0.5f, 0.5f);
modalRect.sizeDelta = new Vector2(650, 360);
// 3. Create title text
var titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(modalContainerObj.transform, false);
titleObj.layer = 5;
var titleText = titleObj.AddComponent<TextMeshProUGUI>();
titleText.text = title;
titleText.fontSize = 24;
titleText.font = font;
titleText.color = new Color(1.0f, 0.8f, 0.2f, 1.0f); // Orange/yellow tone matching warning theme
titleText.alignment = TextAlignmentOptions.Center;
var titleRect = titleObj.GetComponent<RectTransform>();
titleRect.anchorMin = new Vector2(0, 1);
titleRect.anchorMax = new Vector2(1, 1);
titleRect.pivot = new Vector2(0.5f, 1);
titleRect.anchoredPosition = new Vector2(0, -35);
titleRect.sizeDelta = new Vector2(0, 40);
// 4. Create description text
var descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(modalContainerObj.transform, false);
descObj.layer = 5;
var descText = descObj.AddComponent<TextMeshProUGUI>();
descText.text = description;
descText.fontSize = 20;
descText.font = font;
descText.color = new Color(0.85f, 0.85f, 0.85f, 1f);
descText.alignment = TextAlignmentOptions.Center;
descText.lineSpacing = 8;
var descRect = descObj.GetComponent<RectTransform>();
descRect.anchorMin = new Vector2(0, 0.5f);
descRect.anchorMax = new Vector2(1, 1);
descRect.pivot = new Vector2(0.5f, 0.5f);
descRect.anchoredPosition = new Vector2(0, -65);
descRect.sizeDelta = new Vector2(-40, 120);
// 5. Create button container
var buttonContainerObj = new GameObject("ButtonContainer", typeof(RectTransform));
buttonContainerObj.transform.SetParent(modalContainerObj.transform, false);
buttonContainerObj.layer = 5;
var btnContainerRect = buttonContainerObj.GetComponent<RectTransform>();
btnContainerRect.anchorMin = new Vector2(0, 0);
btnContainerRect.anchorMax = new Vector2(1, 0);
btnContainerRect.pivot = new Vector2(0.5f, 0);
btnContainerRect.anchoredPosition = new Vector2(0, 35);
btnContainerRect.sizeDelta = new Vector2(0, 50);
// 6. Dynamic button layout and styling
float totalWidth = 560f;
float spacing = 15f;
float buttonHeight = 45f;
int numButtons = buttons.Length;
float buttonWidth = numButtons == 1
? 200f
: (totalWidth - (spacing * (numButtons - 1))) / numButtons;
float startX = numButtons == 1
? 0
: (-totalWidth / 2f) + (buttonWidth / 2f);
for (int i = 0; i < numButtons; i++)
{
var btnData = buttons[i];
var btnObj = new GameObject($"Button_{i}");
btnObj.transform.SetParent(buttonContainerObj.transform, false);
btnObj.layer = 5;
// Define default colors
Color baseColor = btnData.BackgroundColor ?? new Color(0.2f, 0.2f, 0.23f, 1f); // Custom color or gray default
Color hoverColor = ApplyColorBrightness(baseColor, 1.2f);
Color pressedColor = ApplyColorBrightness(baseColor, 0.8f);
var btnImage = btnObj.AddComponent<Image>();
btnImage.color = baseColor;
btnImage.raycastTarget = true;
var btn = btnObj.AddComponent<Button>();
SetupButtonStyle(btn, baseColor, hoverColor, pressedColor);
// Register click callback
btn.onClick.AddListener(() =>
{
try
{
btnData.OnClick();
}
catch (Exception ex)
{
MelonLoader.Melon<RenderMod>.Logger.Error($"Dialog Button Click Event Error: {ex}");
}
Destroy(gameObject);
});
var btnRect = btnObj.GetComponent<RectTransform>();
btnRect.anchorMin = new Vector2(0.5f, 0.5f);
btnRect.anchorMax = new Vector2(0.5f, 0.5f);
btnRect.pivot = new Vector2(0.5f, 0.5f);
float posX = numButtons == 1
? 0
: startX + i * (buttonWidth + spacing);
btnRect.anchoredPosition = new Vector2(posX, 0);
btnRect.sizeDelta = new Vector2(buttonWidth, buttonHeight);
// Add text label
var txtObj = new GameObject("Text");
txtObj.transform.SetParent(btnObj.transform, false);
txtObj.layer = 5;
var text = txtObj.AddComponent<TextMeshProUGUI>();
text.text = btnData.Text;
text.fontSize = 20;
text.font = font;
text.color = Color.white;
text.alignment = TextAlignmentOptions.Center;
var txtRect = txtObj.GetComponent<RectTransform>();
txtRect.anchorMin = Vector2.zero;
txtRect.anchorMax = Vector2.one;
txtRect.offsetMin = Vector2.zero;
txtRect.offsetMax = Vector2.zero;
}
}
private void SetupButtonStyle(Button button, Color normalColor, Color hoverColor, Color pressedColor)
{
button.transition = Selectable.Transition.ColorTint;
var colors = button.colors;
colors.normalColor = normalColor;
colors.highlightedColor = hoverColor;
colors.pressedColor = pressedColor;
colors.disabledColor = new Color(0.3f, 0.3f, 0.3f, 1f);
button.colors = colors;
}
private Color ApplyColorBrightness(Color color, float factor)
{
float r = Mathf.Clamp01(color.r * factor);
float g = Mathf.Clamp01(color.g * factor);
float b = Mathf.Clamp01(color.b * factor);
return new Color(r, g, b, color.a);
}
}
+175 -211
View File
@@ -6,229 +6,193 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI
namespace ADOFAIRenderer.UI;
internal class DebugMonitorUI : MonoBehaviour
{
public class DebugMonitorUI : MonoBehaviour
// --- Progress Bar UI Configuration Constants ---
private const float BarWidth = 800f;
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // Increased font size for visibility
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // Contrasting white outline
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // Outline thickness
private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
private void Start()
{
// --- Progress Bar UI Configuration Constants ---
private const float BarWidth = 800f;
private const float BarHeight = 28f;
private const float BarPosX = 0f;
private const float BarPosY = -420f; // Y position relative to screen center (Reference: 1920x1080)
private const int BarTextFontSize = 16; // 눈에 띄게 증가된 텍스트 크기
private static readonly Color BarBackgroundColor = new(0.08f, 0.08f, 0.1f, 1.0f);
private static readonly Color BarFillColor = new(0.0f, 0.8f, 1.0f, 1.0f); // Sleek cyan/blue
private static readonly Color BarTextColor = new(1.0f, 1.0f, 1.0f, 1.0f);
// Set UI layer (5)
gameObject.layer = 5;
// --- Progress Bar Outline Configuration Constants ---
private static readonly Color BarOutlineColor = Color.white; // 대비되는 흰색 윤곽선
private static readonly Vector2 BarOutlineDistance = new(3.0f, -3.0f); // 3px의 굵고 직관적인 윤곽선 두께
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
private TextMeshProUGUI _text = null!;
private GameObject _progressBarObj = null!;
private GameObject _progressBarFillObj = null!;
private RectTransform _fillRect = null!;
private TextMeshProUGUI _progressText = null!;
// Dynamic resolution scaling setup (Reference: 1920x1080)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
gameObject.AddComponent<GraphicRaycaster>();
// Create background panel
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // Dark tone acrylic style
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// Create text
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16;
_text.alignment = TextAlignmentOptions.TopLeft;
// Prevent TMPro text clipping
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// Load and apply font
_text.font = ModAssets.NanumSquareNeoNormal;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // Padding margin
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// Add progress bar outline component
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize;
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = ModAssets.NanumSquareNeoNormal;
_progressText.color = BarTextColor;
// Add progress bar text outline for readability
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
}
private void Update()
{
if (!_text) return;
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{
TMP_FontAsset? font = null;
if (RenderMod.FontBundle != null)
lock (RenderManager.Instance.ImageQueue)
{
try
{
font = RenderMod.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// 로딩 실패
}
queueCount = RenderManager.Instance.ImageQueue.Count;
}
// 폴백 처리: 에셋 번들에서 실패 시 게임 자체 NanumSquareNeo 또는 내장 TMP SDF 폰트 사용
if (font == null)
{
font = RDString.fontData.fontTMP;
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font != null) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start()
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{
// UI 레이어(5) 설정
gameObject.layer = 5;
// Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 9999;
// 해상도 다이내믹 스케일링 설정 (Reference: 1920x1080)
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 배경 패널 생성
var panelObj = new GameObject("BackgroundPanel");
panelObj.transform.SetParent(transform, false);
panelObj.layer = 5;
var image = panelObj.AddComponent<Image>();
image.color = new Color(0.08f, 0.08f, 0.1f, 0.88f); // 약간 더 고급스러운 다크톤 아크릴 감성
var panelRect = panelObj.GetComponent<RectTransform>();
panelRect.anchorMin = new Vector2(1, 1);
panelRect.anchorMax = new Vector2(1, 1);
panelRect.pivot = new Vector2(1, 1);
panelRect.anchoredPosition = new Vector2(-30, -30);
panelRect.sizeDelta = new Vector2(360, 220);
// 텍스트 생성
var textObj = new GameObject("MonitorText");
textObj.transform.SetParent(panelObj.transform, false);
textObj.layer = 5;
_text = textObj.AddComponent<TextMeshProUGUI>();
_text.richText = true;
_text.fontSize = 16; // 13에서 16으로 증가
_text.alignment = TextAlignmentOptions.TopLeft;
// TMPro 텍스트 잘림 현상 예방
_text.textWrappingMode = TextWrappingModes.Normal;
_text.overflowMode = TextOverflowModes.Overflow;
// NanumSquareNeo-Bd SDF 폰트 로드 및 적용
var font = GetNanumSquareNeoBdFontAsset();
_text.font = font;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(20, 20); // 좀 더 넉넉하고 패딩 여백
textRect.offsetMax = new Vector2(-20, -20);
// --- Progress Bar UI Setup ---
// 1. Progress Bar Container
_progressBarObj = new GameObject("ProgressBar");
_progressBarObj.transform.SetParent(transform, false);
_progressBarObj.layer = 5;
var bgImage = _progressBarObj.AddComponent<Image>();
bgImage.color = BarBackgroundColor;
// 프로그레스 바 흰색 윤곽선 컴포넌트 추가
var bgOutline = _progressBarObj.AddComponent<Outline>();
bgOutline.effectColor = BarOutlineColor;
bgOutline.effectDistance = BarOutlineDistance;
var barRect = _progressBarObj.GetComponent<RectTransform>();
barRect.anchorMin = new Vector2(0.5f, 0.5f);
barRect.anchorMax = new Vector2(0.5f, 0.5f);
barRect.pivot = new Vector2(0.5f, 0.5f);
barRect.anchoredPosition = new Vector2(BarPosX, BarPosY);
barRect.sizeDelta = new Vector2(BarWidth, BarHeight);
// 2. Progress Bar Fill
_progressBarFillObj = new GameObject("ProgressBarFill");
_progressBarFillObj.transform.SetParent(_progressBarObj.transform, false);
_progressBarFillObj.layer = 5;
var fillImage = _progressBarFillObj.AddComponent<Image>();
fillImage.color = BarFillColor;
_fillRect = _progressBarFillObj.GetComponent<RectTransform>();
_fillRect.anchorMin = new Vector2(0, 0);
_fillRect.anchorMax = new Vector2(0, 1);
_fillRect.pivot = new Vector2(0, 0.5f);
_fillRect.anchoredPosition = Vector2.zero;
_fillRect.sizeDelta = new Vector2(0, 0); // width will be updated dynamically in Update()
// 3. Progress Bar Text
var progressTextObj = new GameObject("ProgressBarText");
progressTextObj.transform.SetParent(_progressBarObj.transform, false);
progressTextObj.layer = 5;
_progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
_progressText.richText = true;
_progressText.fontSize = BarTextFontSize; // 14에서 18로 증가
_progressText.alignment = TextAlignmentOptions.Center;
_progressText.font = font;
_progressText.color = BarTextColor;
// 프로그레스 바 내부 텍스트 아웃라인 추가 (진행율 가독성 확보)
var textOutline = progressTextObj.AddComponent<Outline>();
textOutline.effectColor = new Color(0f, 0f, 0f, 0.9f);
textOutline.effectDistance = new Vector2(1.5f, -1.5f);
var textRectBar = progressTextObj.GetComponent<RectTransform>();
textRectBar.anchorMin = Vector2.zero;
textRectBar.anchorMax = Vector2.one;
textRectBar.offsetMin = Vector2.zero;
textRectBar.offsetMax = Vector2.zero;
currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
{
progress = Mathf.Clamp01((float)currentFloor / totalFloors);
}
}
private void Update()
{
if (!_text) return;
var sb = new StringBuilder();
sb.AppendLine("<b><color=#00ccff>ADOFAI Renderer Realtime Monitor</color></b>");
sb.AppendLine();
var queueCount = 0;
if (RenderManager.Instance)
{
lock (RenderManager.Instance.ImageQueue)
{
queueCount = RenderManager.Instance.ImageQueue.Count;
}
}
sb.AppendLine($"• Queue: <b>{queueCount}</b> / {RenderManager.MaxQueuedFrames} frames");
sb.AppendLine($"• Rendered Frames: <b>{(RenderManager.Instance ? RenderManager.Instance.frameCount : 0)}</b>");
sb.AppendLine($"• Frames Sent: <b>{(RenderManager.Instance ? Interlocked.Read(ref RenderManager.Instance.framesSent) : 0)}</b>");
sb.AppendLine();
sb.AppendLine($"• Throttling Controls: {(RenderManager.Instance ? RenderManager.Instance.throttleCount : 0)} times");
sb.AppendLine($"• Readback Hard Failures: <color=#ff3333>{(RenderManager.Instance ? RenderManager.Instance.readbackFailureCount : 0)}</color> times");
sb.AppendLine($"• Render Skip-Retry: {(RenderManager.Instance ? RenderManager.Instance.skipCount : 0)} / {RenderManager.MaxRenderRetry}");
var memoryMb = GC.GetTotalMemory(false) / (1024.0 * 1024.0);
sb.AppendLine($"• Mono Heap Memory: {memoryMb:F2} MB");
_text.text = sb.ToString();
// Update Progress Bar
var progress = 0f;
var currentFloor = 0;
var totalFloors = 0;
if (scrController.instance && ADOBase.lm && ADOBase.lm.listFloors != null)
{
currentFloor = scrController.instance.currFloor ? scrController.instance.currFloor.seqID : 0;
totalFloors = ADOBase.lm.listFloors.Count - 1;
if (totalFloors > 0)
{
progress = Mathf.Clamp01((float)currentFloor / totalFloors);
}
}
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
}
if (_fillRect) _fillRect.sizeDelta = new Vector2(BarWidth * progress, 0);
if (_progressText) _progressText.text = $"{progress * 100f:F1}% ({currentFloor}/{totalFloors})";
}
}
+104 -106
View File
@@ -4,128 +4,126 @@ using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI
namespace ADOFAIRenderer.UI;
internal class EscapeHandlerUI : MonoBehaviour
{
public class EscapeHandlerUI : MonoBehaviour
private GameObject _containerObj = null!;
private TextMeshProUGUI _textComponent = null!;
private Stopwatch? _escapeStopwatch;
private void Start()
{
private GameObject _containerObj = null!;
private TextMeshProUGUI _textComponent = null!;
private Stopwatch? _escapeStopwatch;
// Set UI layer (5)
gameObject.layer = 5;
private void Start()
// Canvas setup
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // Placed above debug monitor
// Responsive Canvas Scaler setup
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
gameObject.AddComponent<GraphicRaycaster>();
// 1. Create container and background panel object
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
var bgImage = _containerObj.AddComponent<Image>();
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f); // Dark tone acrylic style
var containerRect = _containerObj.GetComponent<RectTransform>();
containerRect.anchorMin = new Vector2(0.5f, 0.5f);
containerRect.anchorMax = new Vector2(0.5f, 0.5f);
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // Screen center
containerRect.sizeDelta = new Vector2(460, 160);
// 2. Create text object
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// Apply game's default font (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// Initial state disabled
_containerObj.SetActive(false);
}
private void Update()
{
// Destroy self if rendering is no longer active
if (!RenderManager.IsActive)
{
// UI 레이어(5) 설정
gameObject.layer = 5;
Destroy(gameObject);
return;
}
// Canvas 설정
var canvas = gameObject.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 10000; // 디버그 모니터보다 위에 오도록 설정
if (Input.GetKey(KeyCode.Escape))
{
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
// 해상도 대응 캔버스 스케일러 설정
var scaler = gameObject.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
gameObject.AddComponent<GraphicRaycaster>();
// Update remaining time text
_textComponent.text = $"<color=#FF5252>Cancelling in {remaining:F1}s..</color>\n<size=40><color=#DDDDDD>Hold Esc to cancel render</color></size>";
// 1. 컨테이너 및 배경 패널 오브젝트 생성
_containerObj = new GameObject("EscapeContainer");
_containerObj.transform.SetParent(transform, false);
_containerObj.layer = 5;
// Micro pulse animation (container smoothly scales up while holding)
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
var bgImage = _containerObj.AddComponent<Image>();
// 고급스러운 다크톤 아크릴 감성
bgImage.color = new Color(0.05f, 0.05f, 0.07f, 0.85f);
if (elapsed < 3f) return;
var containerRect = _containerObj.GetComponent<RectTransform>();
containerRect.anchorMin = new Vector2(0.5f, 0.5f);
containerRect.anchorMax = new Vector2(0.5f, 0.5f);
containerRect.pivot = new Vector2(0.5f, 0.5f);
containerRect.anchoredPosition = Vector2.zero; // 화면 중앙
containerRect.sizeDelta = new Vector2(460, 160);
// 2. 텍스트 오브젝트 생성
var textObj = new GameObject("EscapeText");
textObj.transform.SetParent(_containerObj.transform, false);
textObj.layer = 5;
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
_textComponent.alignment = TextAlignmentOptions.Center;
_textComponent.fontSize = 60;
_textComponent.textWrappingMode = TextWrappingModes.NoWrap;
_textComponent.richText = true;
// 게임의 기본 폰트 적용 (RDString.fontData.fontTMP)
if (RDString.fontData.fontTMP != null)
_textComponent.font = RDString.fontData.fontTMP;
var textRect = textObj.GetComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = new Vector2(10, 10);
textRect.offsetMax = new Vector2(-10, -10);
// 초기 상태는 비활성화
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false);
// Cancel rendering and exit scene
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
private void Update()
{
// 렌더링이 더 이상 활성화되어 있지 않으면 자가 파괴
if (!RenderManager.IsActive)
{
Destroy(gameObject);
return;
}
if (Input.GetKey(KeyCode.Escape))
{
if (_escapeStopwatch == null)
{
_escapeStopwatch = new Stopwatch();
_escapeStopwatch.Start();
_containerObj.SetActive(true);
}
var elapsed = (float)_escapeStopwatch.Elapsed.TotalSeconds;
var remaining = Mathf.Max(0f, 3f - elapsed);
// 남은 시간 텍스트 업데이트
_textComponent.text = $"<color=#FF5252>탈출까지 {remaining:F1}초..</color>\n<size=40><color=#DDDDDD>Esc 키를 계속 누르고 계세요</color></size>";
// 마이크로 펄스 애니메이션 적용 (누르는 동안 컨테이너 전체 크기가 부드럽게 커짐)
var scale = 1.0f + elapsed / 3.0f * 0.15f;
_containerObj.transform.localScale = new Vector3(scale, scale, 1f);
if (elapsed < 3f) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
// 렌더링 취소 및 씬 이탈
if (RenderManager.Instance)
RenderManager.Instance.CancelRender();
}
else
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // 스케일 초기화
_containerObj.SetActive(false);
}
}
private void OnDestroy()
else
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
_containerObj.transform.localScale = Vector3.one; // Reset scale
_containerObj.SetActive(false);
}
}
private void OnDestroy()
{
if (_escapeStopwatch == null) return;
_escapeStopwatch.Stop();
_escapeStopwatch = null;
}
}
+653 -654
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+124
View File
@@ -0,0 +1,124 @@
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
{
private static RendererUIManager? _instance;
public static RendererUIManager Instance
{
get
{
if (_instance != null) return _instance;
var go = new GameObject("RendererUIManager");
DontDestroyOnLoad(go);
_instance = go.AddComponent<RendererUIManager>();
return _instance;
}
}
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(0.812f, 0.827f, 1.000f, 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();
if (_instance == this) _instance = null;
}
}
+824
View File
@@ -0,0 +1,824 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Localization;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.FFmpeg;
using HarmonyLib;
using MelonLoader;
using O5Kit.Control;
using O5Kit.Core;
using O5Kit.Factory;
using UnityEngine;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI.Window;
internal static class RenderConfigWindow
{
private static O5Window? _configWindow;
// Controls cache for validation & sync
private static O5Slider? _renderFpsSlider;
private static O5Slider? _videoFPSSlider;
private static O5Slider? _renderWidthSlider;
private static O5Slider? _renderHeightSlider;
private static O5Slider? _delayAfterEndSlider;
private static O5Toggle? _showJudgementToggle;
private static O5Dropdown<Codec>? _codecDropdown;
private static O5Dropdown<RateControlMode>? _rateControlDropdown;
private static O5InputField? _bitrateInput;
private static O5InputField? _maxBitrateInput;
private static O5InputField? _qpInput;
private static O5Slider? _keyframeIntervalSlider;
private static O5Toggle? _forceSoftwareToggle;
private static O5Toggle? _renderAudioToggle;
private static O5Dropdown<int>? _sampleRateDropdown;
private static O5Dropdown<int>? _channelsDropdown;
private static O5Slider? _audioBitrateSlider;
private static O5InputField? _outputDirInput;
private static O5Dropdown<string>? _fileExtensionDropdown;
private static O5InputField? _randomSeedInput;
private static O5Toggle? _processAudioToggle;
private static O5Toggle? _nonTileToggle;
public static void CreateWindow()
{
if (_configWindow == null)
{
_configWindow = RendererUIManager.Instance.WindowManager.Create(new O5WindowOptions
{
Id = "RendererMod_RenderConfig",
Title = LocalizationManager.Get("ui.render_config.window_title"),
Size = new Vector2(580f, 480f),
Draggable = true,
Resizable = true,
ShowCloseButton = true
});
_configWindow.CloseRequested += win =>
{
win.Rect.gameObject.SetActive(false);
};
BuildUI(_configWindow.Content);
}
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
ApplyConfigToUI(config);
SetWindowActive(true);
_configWindow.BringToFront();
}
private static void BuildUI(RectTransform content)
{
var ctx = RendererUIManager.Context;
var (viewport, scrollContent, scroller) =
O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f);
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
var config = RenderManager.Instance.Configuration ??= new RenderConfig();
EnsureDefaultConfig(config);
double[] renderFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 144,
120, 240, 360, 480, 600, 720, // +120
288, 576, 864, 1152, 1440, 1728, // +288
3000 // MAX
};
double[] videoFpsSnap = { 23.976, 24, 29.97, 30, 59.94, 60, 72,
120, 180, 240, 300, 360, 420, 480, 540, // +60
144, 216, 288, 432, 504, 576, // +72
600 // MAX
};
double[] audioBitrateSnap = { 64, 96, 128, 192, 256, 320, 384 };
double[] renderWidthSnap = {
256, 426, 640, 854, 960, 1280, 1366, 1600, 1920, 2048, 2560, 3200, 3840, 4096, 5120, 5760, 6016, 7680, 10240, 15360, // 16:9
230, 384, 576, 768, 1152, 1440, 1680, 2880, 3072, // 16:10
192, 320, 480, 800, 1024, 1400, 2732, 6400, 11520 // 4:3
};
double[] renderHeightSnap = {
144, 240, 360, 480, 540, 720, 768, 900, 1080, 1152, 1440, 1800, 2160, 2304, 2880, 3240, 3384, 4320, 5760, 8640, // 16:9
400, 600, 800, 1050, 1200, 1600, 1920, 2400, 3200, 4800, 9600, // 16:10
864, 960, 1536, 2048, 3072 // 4:3
};
// TODO: Presets
// 1-3. Top section: 좌/우 컨테이너 1개씩으로 3행을 쌓는다 (중앙선 1개)
var (topLeft, topRight) = CreateTwoColumnPanel(scrollContent, spacing: 8f, columnSpacing: 10f);
_renderFpsSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
defaultValue: 60, min: 0.01, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => renderFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; },
text: LocalizationManager.Get("ui.render_config.render_fps"),
id: "slider_render_fps", showFill: true
);
_videoFPSSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(topRight),
defaultValue: 60, min: 0.01, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => videoFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; },
text: LocalizationManager.Get("ui.render_config.video_fps"),
id: "slider_video_fps", showFill: true
);
_renderWidthSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
defaultValue: 2560, min: 0, max: 15360, value: 2560,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => renderWidthSnap.OrderBy(n => Math.Abs(n - value)).First(), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
text: LocalizationManager.Get("ui.render_config.render_width"),
id: "slider_render_width", showFill: true
);
_renderHeightSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(topRight),
defaultValue: 1440, min: 0, max: 8640, value: 1440,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => renderHeightSnap.OrderBy(n => Math.Abs(n - value)).First(), inputFilter: value => Math.Max(value, 2),
onComplete: null, onChanged: val => { config.RenderWidth = (int)Math.Round(val); },
text: LocalizationManager.Get("ui.render_config.render_height"),
id: "slider_render_height", showFill: true
);
_delayAfterEndSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(topLeft),
defaultValue: 15, min: 0, max: 9999.999, value: 15,
format: "F3", clampMode: ClampMode.All,
sliderFilter: null, inputFilter: null,
onComplete: null, onChanged: val => { config.DelayAfterEnd = val; },
text: LocalizationManager.Get("ui.render_config.delay_after_end"),
id: "slider_delay_after_end", showFill: true
);
_showJudgementToggle = CreateToggleField(
ctx, CreateFieldSlot(topRight), "",
false, config.ShowJudgementText,
LocalizationManager.Get("ui.render_config.show_judgement"),
"toggle_show_judgement",
val => config.ShowJudgementText = val,
generateLabel: false
);
// 4. Video Encoding Config (Collapsible Card)
var (_, videoContent) = O5Factory.Card(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.video_encoding"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
);
var (videoLeft, videoRight) = CreateTwoColumnPanel(videoContent);
var codecs = (Codec[])Enum.GetValues(typeof(Codec));
_codecDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.codec"),
Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(),
"dd_video_codec",
c =>
{
config.VideoConfig.Codec = c;
UpdateVideoCodec(c);
});
_bitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.bitrate"),
"30000", config.VideoConfig.Bitrate.ToString(),
"input_bitrate",
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; }
);
_qpInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.qp"),
"18", config.VideoConfig.QP.ToString(),
"input_qp",
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; }
);
var rateModes = (RateControlMode[])Enum.GetValues(typeof(RateControlMode));
_rateControlDropdown = CreateDropdownField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.rate_control"),
RateControlMode.CBR, config.VideoConfig.RateControlMode,
rateModes, m => m.ToString(),
"dd_rate_control",
m => config.VideoConfig.RateControlMode = m
);
_maxBitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.max_bitrate"),
"50000", config.VideoConfig.MaxBitrate.ToString(),
"input_max_bitrate",
TMPro.TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; }
);
_forceSoftwareToggle = CreateToggleField(
ctx, CreateFieldSlot(videoRight), "",
false, config.VideoConfig.ForceSoftwareEncoder,
LocalizationManager.Get("ui.render_config.force_software"),
"toggle_force_software",
val => config.AudioConfig.RenderAudio = val,
generateLabel: true
);
_keyframeIntervalSlider = O5Factory.Slider(
ctx: ctx, parent: videoContent,
defaultValue: 2, min: 0.1, max: 10, value: 2,
format: "F1", clampMode: ClampMode.All,
sliderFilter: null, inputFilter: null,
onComplete: null, onChanged: val => { config.VideoConfig.KeyframeInterval = val; },
text: LocalizationManager.Get("ui.render_config.keyframe_interval"),
id: "slider_keyframe_interval", showFill: true
);
// 5. Audio Encoding Config (Collapsible Card)
var (_, audioContent) = O5Factory.Card(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.audio_encoding"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
);
var (audioLeft, audioRight) = CreateTwoColumnPanel(audioContent);
_renderAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(audioLeft), "",
true, config.AudioConfig.RenderAudio,
LocalizationManager.Get("ui.render_config.render_audio"),
"toggle_render_audio",
val => config.AudioConfig.RenderAudio = val,
generateLabel: false
);
_audioBitrateSlider = O5Factory.Slider(
ctx: ctx, parent: CreateFieldSlot(audioRight),
defaultValue: 256, min: 64, max: 384, value: 256,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => audioBitrateSnap.OrderBy(n => Math.Abs(n - value)).First(),
inputFilter: null, onComplete: null, onChanged: val => { config.AudioConfig.AudioBitrate = (int)Math.Round(val) * 1000; },
text: LocalizationManager.Get("ui.render_config.audio_bitrate"),
id: "slider_audio_bitrate", showFill: true
);
var channels = new[] { 1, 2 };
_channelsDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioLeft),
LocalizationManager.Get("ui.render_config.channels"),
2, config.AudioConfig.AudioChannels,
channels, c => c == 1 ? "1 (Mono)" : "2 (Stereo)",
"dd_channels",
c => config.AudioConfig.AudioChannels = c
);
_sampleRateDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioRight),
LocalizationManager.Get("ui.render_config.sample_rate"),
48000, config.AudioConfig.AudioSampleRate,
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
"dd_sample_rate",
r => config.AudioConfig.AudioSampleRate = r
);
// 6. Global Settings (Collapsible Card)
var (_, globalContent) = O5Factory.Card(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.global_settings"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
);
_outputDirInput = CreateFullWidthInputField(
ctx, globalContent,
LocalizationManager.Get("ui.render_config.output_dir"),
GlobalSettings.DefaultOutputDirectory,
GlobalSettings.EffectiveOutputDirectory,
"input_output_dir",
val =>
{
var trimmed = val?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(trimmed) ||
string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
{
GlobalSettings.Current.OutputDirectory = null;
}
else
{
GlobalSettings.Current.OutputDirectory = trimmed;
}
}
);
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
O5Factory.Button(
ctx, globalLeft,
() =>
{
var initial = string.IsNullOrWhiteSpace(_outputDirInput.Value)
? GlobalSettings.DefaultOutputDirectory
: _outputDirInput.Value;
FolderDialogUtil.OpenFolderBrowser(initial, selected =>
{
Melon<RenderMod>.Logger.Msg("Output Directory Selected: " + selected);
if (!string.IsNullOrWhiteSpace(selected))
{
_outputDirInput.Set(selected);
GlobalSettings.Current.OutputDirectory = selected;
GlobalSettings.Save();
}
});
},
LocalizationManager.Get("ui.render_config.browse"),
"btn_browse_output"
);
O5Factory.Button(
ctx, globalRight,
() =>
{
_outputDirInput.Set(GlobalSettings.DefaultOutputDirectory);
GlobalSettings.Current.OutputDirectory = null;
GlobalSettings.Save();
},
LocalizationManager.Get("ui.render_config.reset_default"),
"btn_reset_output"
);
// 7. Advanced Settings (Collapsible Card)
var (_, advContent) = O5Factory.Card(
ctx, scrollContent,
LocalizationManager.Get("ui.render_config.advanced_settings"),
activeValue: true,
onActiveChanged: null,
onDeleteClick: null,
showDeleteButton: false,
showActiveToggle: false
);
var (advLeft, advRight) = CreateTwoColumnPanel(advContent);
var extensions = new[] { "mkv", "mp4", "mov", "webm", "avi" };
_fileExtensionDropdown = CreateDropdownField(
ctx, CreateFieldSlot(advLeft),
LocalizationManager.Get("ui.render_config.file_extension"),
"mkv", config.AdvancedConfig.FileExtension,
extensions, e => e,
"dd_adv_file_extension",
e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; }
);
_processAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft), "",
true, config.AdvancedConfig.ProcessAudio,
LocalizationManager.Get("ui.render_config.process_audio"),
"toggle_process_audio",
val => config.AdvancedConfig.ProcessAudio = val,
generateLabel: false
);
_randomSeedInput = CreateInputField(
ctx, CreateFieldSlot(advRight),
LocalizationManager.Get("ui.render_config.random_seed"),
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
"input_random_seed",
TMPro.TMP_InputField.CharacterValidation.Integer,
val =>
{
if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s;
else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null;
}
);
_nonTileToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight), "",
false, config.AdvancedConfig.NonTile,
LocalizationManager.Get("ui.render_config.non_tile"),
"toggle_non_tile",
val => config.AdvancedConfig.NonTile = val,
generateLabel: false
);
// TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional)
// 8. Go Render Button
O5Factory.Button(
ctx, scrollContent,
TryGoRender,
LocalizationManager.Get("ui.render_config.go_render"),
"btn_go_render",
height: 48f
);
// 9. Version Label
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
var versionText = $"ADOFAI Renderer v{Melon<RenderMod>.Instance.Info.Version}";
if (DateTime.UtcNow >= RenderMod.ExpirationTime) versionText += " " + LocalizationManager.Get("ui.render_config.expired");
versionLabel.text = versionText;
versionLabel.alignment = TMPro.TextAlignmentOptions.Center;
versionLabel.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var versionLE = versionLabel.gameObject.AddComponent<LayoutElement>();
versionLE.preferredHeight = 18f;
versionLE.minHeight = 18f;
return;
void UpdateVideoCodec(Codec codec)
{
_sampleRateDropdown?.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
}
}
private static void EnsureDefaultConfig(RenderConfig config)
{
if (config.RenderWidth <= 0) config.RenderWidth = 2560;
if (config.RenderHeight <= 0) config.RenderHeight = 1440;
if (config.RenderFPS <= 0) config.RenderFPS = 60.0;
if (config.VideoFPS <= 0) config.VideoFPS = 60.0;
if (config.DelayAfterEnd < 0) config.DelayAfterEnd = 15.0;
}
private static void ApplyConfigToUI(RenderConfig config)
{
EnsureDefaultConfig(config);
_renderFpsSlider?.Set((float) config.RenderFPS, false);
_videoFPSSlider?.Set((float) config.VideoFPS, false);
_renderWidthSlider?.Set(config.RenderWidth, false);
_renderHeightSlider?.Set(config.RenderHeight, false);
_delayAfterEndSlider?.Set((float) config.DelayAfterEnd, false);
_showJudgementToggle?.Set(config.ShowJudgementText, false);
_codecDropdown?.Set(config.VideoConfig.Codec, false);
_rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false);
_bitrateInput?.Set(config.VideoConfig.Bitrate.ToString(), false);
_maxBitrateInput?.Set(config.VideoConfig.MaxBitrate.ToString(), false);
_qpInput?.Set(config.VideoConfig.QP.ToString(), false);
_keyframeIntervalSlider?.Set(config.VideoConfig.KeyframeInterval, false);
_forceSoftwareToggle?.Set(config.VideoConfig.ForceSoftwareEncoder, false);
_renderAudioToggle?.Set(config.AudioConfig.RenderAudio, false);
_sampleRateDropdown?.Set(config.AudioConfig.AudioSampleRate, false);
_channelsDropdown?.Set(config.AudioConfig.AudioChannels, false);
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false);
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
_fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false);
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
}
private static void TryGoRender()
{
var config = RenderManager.Instance.Configuration;
// Sync Video Config
if (_bitrateInput != null && int.TryParse(_bitrateInput.Value, out var bitrate) && bitrate > 0)
{
config.VideoConfig.Bitrate = bitrate;
}
if (_maxBitrateInput != null && int.TryParse(_maxBitrateInput.Value, out var maxBitrate) && maxBitrate > 0)
{
config.VideoConfig.MaxBitrate = maxBitrate;
}
if (_qpInput != null && int.TryParse(_qpInput.Value, out var qp) && qp >= 0)
{
config.VideoConfig.QP = qp;
}
// Sync Advanced Config
if (_randomSeedInput != null)
{
if (int.TryParse(_randomSeedInput.Value, out var seed))
{
config.AdvancedConfig.RandomSeed = seed;
}
else if (string.IsNullOrWhiteSpace(_randomSeedInput.Value))
{
config.AdvancedConfig.RandomSeed = null;
}
}
// Sync Output Directory
if (_outputDirInput != null)
{
var path = _outputDirInput.Value.Trim();
if (string.IsNullOrWhiteSpace(path) ||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase))
{
GlobalSettings.Current.OutputDirectory = null;
}
else
{
try
{
var fullPath = Path.GetFullPath(path);
if (!Directory.Exists(fullPath))
{
Directory.CreateDirectory(fullPath);
}
GlobalSettings.Current.OutputDirectory = fullPath;
}
catch (Exception ex)
{
ShowValidationError($"Output directory path is invalid:\n{ex.Message}");
return;
}
}
}
GlobalSettings.Save();
SetWindowActive(false);
new RenderSession().Start(config, RenderManager.Instance.levelPath);
}
private static void ShowValidationError(string message)
{
Melon<RenderMod>.Logger.Error(message);
CommonWarningDialogUI.ShowDialog(
LocalizationManager.Get("ui.render_config.validation_title"),
message,
new DialogButton("OK", () => { })
);
}
// 카드(또는 상단 섹션)당 하나의 좌/우 컨테이너를 만들어
// 가로 한 줄 두 필드 사이 중앙이 행마다 어긋나지 않도록 한다.
// - 패널 1개 = 중앙선 1개 (행마다 HLG를 만들던 기존 방식의 P1-P2 어긋남 방지)
// - 좌/우 컬럼은 preferredWidth=0 + flexibleWidth=1 로 항상 50:50
// - 각 필드는 Field 슬롯(label 18 + 간격 3 + 컨트롤 50)으로 높이를 고정해
// 좌/우 스택을 써도 행 단위 Y 정렬이 유지되도록 한다.
private static (RectTransform left, RectTransform right) CreateTwoColumnPanel(
Transform parent, float spacing = 8f, float columnSpacing = 8f)
{
var panelObj = new GameObject("TwoColPanel");
panelObj.transform.SetParent(parent, false);
panelObj.AddComponent<RectTransform>();
var hLayout = panelObj.AddComponent<HorizontalLayoutGroup>();
hLayout.spacing = spacing;
hLayout.padding = new RectOffset(0, 0, 0, 0);
hLayout.childAlignment = TextAnchor.UpperCenter;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming
var panelLE = panelObj.AddComponent<LayoutElement>();
panelLE.flexibleWidth = 1f;
var left = CreateColumn(panelObj.transform, "LeftCol", columnSpacing);
var right = CreateColumn(panelObj.transform, "RightCol", columnSpacing);
return (left, right);
}
private static RectTransform CreateColumn(Transform parent, string name, float spacing)
{
var colObj = new GameObject(name);
colObj.transform.SetParent(parent, false);
var colRect = colObj.AddComponent<RectTransform>();
var colLayout = colObj.AddComponent<VerticalLayoutGroup>();
colLayout.spacing = spacing;
colLayout.padding = new RectOffset(0, 0, 0, 0);
colLayout.childControlWidth = true;
colLayout.childControlHeight = true;
colLayout.childForceExpandWidth = true;
colLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming
// 핵심: 좌/우 선호 너비를 0으로 강제해 항상 정확히 절반씩 나누도록 한다.
// (기존 행마다 HLG 방식은 라벨/값 텍스트 길이에 따라 preferred가 달라져
// 중앙이 (W + P1 - P2) / 2 로 행마다 틀어졌음)
var colLE = colObj.AddComponent<LayoutElement>();
colLE.minWidth = 0f;
colLE.preferredWidth = 0f;
colLE.flexibleWidth = 1f;
return colRect;
}
private static RectTransform CreateFieldSlot(Transform column)
{
var slotObj = new GameObject("Field");
slotObj.transform.SetParent(column, false);
var slotRect = slotObj.AddComponent<RectTransform>();
var slotLayout = slotObj.AddComponent<VerticalLayoutGroup>();
slotLayout.spacing = 3f;
slotLayout.padding = new RectOffset(0, 0, 0, 0);
slotLayout.childControlWidth = true;
slotLayout.childControlHeight = true;
slotLayout.childForceExpandWidth = true;
slotLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming
var slotLE = slotObj.AddComponent<LayoutElement>();
slotLE.flexibleWidth = 1f;
return slotRect;
}
private static O5InputField CreateInputField(
O5Context ctx,
Transform colParent,
string labelText,
string? defaultValue,
string value,
string id,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
var inputField = O5Factory.Input(
ctx: ctx,
parent: colParent,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: labelText,
icon: null,
id: id
);
inputField.InputField.characterValidation = characterValidation;
return inputField;
}
private static O5InputField CreateFullWidthInputField(
O5Context ctx,
Transform parent,
string labelText,
string? defaultValue,
string value,
string id,
Action<string>? onChanged = null)
{
var container = new GameObject($"FullWidth_{id}");
container.transform.SetParent(parent, false);
var containerRect = container.AddComponent<RectTransform>();
var layout = container.AddComponent<VerticalLayoutGroup>();
layout.spacing = 3f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = false;
var label = O5Factory.ControlText(ctx, containerRect, 13f, noPad: true);
label.text = labelText;
label.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
var input = O5Factory.Input(
ctx: ctx,
parent: containerRect,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
placeholder: labelText,
icon: null,
id: id
);
return input;
}
private static O5Toggle CreateToggleField(
O5Context ctx,
Transform colParent,
string labelText,
bool? defaultValue,
bool value,
string toggleText,
string id,
Action<bool>? onChanged = null,
bool generateLabel = true)
{
if (generateLabel)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
}
return O5Factory.Toggle(
ctx: ctx,
parent: colParent,
defaultValue: defaultValue,
value: value,
onChanged: onChanged,
text: toggleText,
id: id
);
}
private static O5Dropdown<T> CreateDropdownField<T>(
O5Context ctx,
Transform colParent,
string labelText,
T defaultValue,
T value,
IReadOnlyList<T> values,
Func<T, string> display,
string id,
Action<T>? onChanged = null)
{
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = labelText;
label.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
var ddContainer = new GameObject($"DD_{id}");
ddContainer.transform.SetParent(colParent, false);
// ReSharper disable once InconsistentNaming
var ddLE = ddContainer.AddComponent<LayoutElement>();
ddLE.preferredHeight = 50f;
ddLE.minHeight = 0f;
return O5Factory.DropDown(
ctx: ctx,
parent: ddContainer.transform,
defaultValue: defaultValue,
value: value,
values: values,
display: display,
onChanged: val => { if (val != null) onChanged?.Invoke(val); },
id: id
);
}
private static void SetWindowActive(bool enabled)
{
_configWindow?.Rect.gameObject.SetActive(enabled);
}
}
+80 -81
View File
@@ -1,101 +1,100 @@
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 ──
if (inputSamples.Length == 0 ||
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0)
{
// ── Safety Validation ──
if (inputSamples.Length == 0 ||
inputChannels <= 0 || targetChannels <= 0 ||
inputFrequency <= 0 || targetFrequency <= 0)
return Array.Empty<float>();
}
// If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
{
double srcIndex = (double)i * inputFrequency / targetFrequency;
long idxLow = (long)Math.Floor(srcIndex);
long idxHigh = idxLow + 1;
float t = (float)(srcIndex - idxLow);
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
if (idxLow >= inputLength) idxLow = inputLength - 1;
if (targetChannels == 1) // 1. Mono Output
{
return Array.Empty<float>();
float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono
{
valLow = inputSamples[idxLow];
valHigh = inputSamples[idxHigh];
}
else // Multi -> Mono (Downmix: Average all channels)
{
float sumLow = 0f;
float sumHigh = 0f;
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
}
// If format is identical, return original samples
if (inputFrequency == targetFrequency && inputChannels == targetChannels)
return inputSamples;
int inputLength = inputSamples.Length / inputChannels;
int targetLength = (int)Math.Floor((double)inputLength * targetFrequency / inputFrequency);
float[] outputSamples = new float[targetLength * targetChannels];
for (int i = 0; i < targetLength; i++)
else if (targetChannels == 2) // 2. Stereo Output
{
double srcIndex = (double)i * inputFrequency / targetFrequency;
long idxLow = (long)Math.Floor(srcIndex);
long idxHigh = idxLow + 1;
float t = (float)(srcIndex - idxLow);
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
{
float valLow = inputSamples[idxLow];
float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
if (idxHigh >= inputLength) idxHigh = inputLength - 1;
if (idxLow >= inputLength) idxLow = inputLength - 1;
if (targetChannels == 1) // 1. Mono Output
float valLowR = inputSamples[idxLow * inputChannels + 1];
float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
}
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{
float valLow, valHigh;
if (inputChannels == 1) // Mono -> Mono
if (c < inputChannels)
{
valLow = inputSamples[idxLow];
valHigh = inputSamples[idxHigh];
valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh * inputChannels + c];
}
else // Multi -> Mono (Downmix: Average all channels)
else
{
float sumLow = 0f;
float sumHigh = 0f;
for (int c = 0; c < inputChannels; c++)
{
sumLow += inputSamples[idxLow * inputChannels + c];
sumHigh += inputSamples[idxHigh * inputChannels + c];
}
valLow = sumLow / inputChannels;
valHigh = sumHigh / inputChannels;
}
outputSamples[i] = valLow + t * (valHigh - valLow);
}
else if (targetChannels == 2) // 2. Stereo Output
{
if (inputChannels == 1) // Mono -> Stereo (Duplicate to Left/Right)
{
float valLow = inputSamples[idxLow];
float valHigh = inputSamples[idxHigh];
float val = valLow + t * (valHigh - valLow);
outputSamples[i * 2] = val;
outputSamples[i * 2 + 1] = val;
}
else // Stereo/Multi -> Stereo (Map Left/Right, ignore extra channels)
{
float valLowL = inputSamples[idxLow * inputChannels];
float valHighL = inputSamples[idxHigh * inputChannels];
outputSamples[i * 2] = valLowL + t * (valHighL - valLowL);
float valLowR = inputSamples[idxLow * inputChannels + 1];
float valHighR = inputSamples[idxHigh * inputChannels + 1];
outputSamples[i * 2 + 1] = valLowR + t * (valHighR - valLowR);
}
}
else // 3. Multi-channel Output (e.g., 5.1 Surround)
{
for (int c = 0; c < targetChannels; c++)
{
float valLow, valHigh;
if (c < inputChannels)
{
valLow = inputSamples[idxLow * inputChannels + c];
valHigh = inputSamples[idxHigh * inputChannels + c];
}
else
{
valLow = inputSamples[idxLow * inputChannels];
valHigh = inputSamples[idxHigh * inputChannels];
}
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
valLow = inputSamples[idxLow * inputChannels];
valHigh = inputSamples[idxHigh * inputChannels];
}
outputSamples[i * targetChannels + c] = valLow + t * (valHigh - valLow);
}
}
return outputSamples;
}
return outputSamples;
}
}
+209 -208
View File
@@ -3,238 +3,239 @@ using System.Collections.Generic;
using ADOFAIRenderer.Core;
using 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 int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
// ReSharper restore MemberCanBePrivate.Global
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
{
public static float[]? AudioClipData { get; private set; }
public static int Channels { get; private set; }
public static int Frequency { get; private set; }
public static int TotalSamples { get; private set; }
private static long _cachedFrame = -1;
private static readonly Dictionary<int, float[]> CachedSpectrums = new();
public static void Initialize(AudioClip? clip)
if (!clip)
{
if (!clip)
{
Clear();
return;
}
try
{
Channels = clip.channels;
Frequency = clip.frequency;
int samplesCount = clip.samples;
float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
{
AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
Clear();
return;
}
public static void Clear()
try
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
Channels = clip.channels;
Frequency = clip.frequency;
int samplesCount = clip.samples;
float[] rawSamples = new float[samplesCount * Channels];
clip.GetData(rawSamples, 0);
if (Channels == 1)
{
_cachedFrame = -1;
AudioClipData = rawSamples;
TotalSamples = samplesCount;
}
else
{
AudioClipData = new float[samplesCount];
TotalSamples = samplesCount;
for (int i = 0; i < samplesCount; i++)
{
float sum = 0f;
for (int c = 0; c < Channels; c++)
{
sum += rawSamples[i * Channels + c];
}
AudioClipData[i] = sum / Channels;
}
}
MelonLoader.MelonLogger.Msg($"[AudioSpectrumGenerator] Cached audio clip: {clip.name}, Samples: {TotalSamples}, Frequency: {Frequency}Hz, Channels: {Channels}");
}
catch (Exception e)
{
MelonLoader.MelonLogger.Error($"[AudioSpectrumGenerator] Failed to initialize audio cache: {e}");
Clear();
}
}
public static void Clear()
{
AudioClipData = null;
TotalSamples = 0;
Channels = 0;
Frequency = 0;
lock (CachedSpectrums)
{
_cachedFrame = -1;
CachedSpectrums.Clear();
}
}
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
}
public static void GetSpectrumData(float[] samples, int channel, FFTWindow window)
{
if (AudioClipData == null || AudioClipData.Length == 0 || !scrConductor.instance || !scrConductor.instance.hasSongStarted || !scrConductor.instance.song)
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Clear(samples, 0, samples.Length);
Array.Copy(cached, samples, n);
return;
}
var conductor = scrConductor.instance;
var pitch = conductor.song.pitch;
var separateCountdownOffset = conductor.separateCountdownTime ?
conductor.crotchetAtStart * conductor.adjustedCountdownTicks : 0.0;
// Calculate the actual scheduled start time of the song
var songScheduledStartTime = conductor.skipOffset ?
conductor.dspTimeSong + conductor.addoffset / pitch :
conductor.dspTimeSong + separateCountdownOffset / pitch;
if (conductor.dspTime < songScheduledStartTime)
{
Array.Clear(samples, 0, samples.Length);
return;
}
var songTime = (conductor.dspTime - conductor.dspTimeSong) * pitch - separateCountdownOffset;
var n = samples.Length;
if (n == 0 || (n & (n - 1)) != 0) // Ensure it's a power of 2
{
Array.Clear(samples, 0, samples.Length);
return;
}
var currentFrame = (RenderManager.Instance != null) ? RenderManager.Instance.frameCount : -1;
lock (CachedSpectrums)
{
if (currentFrame != _cachedFrame)
{
_cachedFrame = currentFrame;
CachedSpectrums.Clear();
}
if (CachedSpectrums.TryGetValue(n, out var cached))
{
Array.Copy(cached, samples, n);
return;
}
}
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
{
windowSamples[i] = AudioClipData[idx];
}
else
{
windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
}
private static void CalculateFFT(float[] samples, float[] spectrum)
var fftSize = 2 * n;
var frequency = Frequency;
var totalSamples = TotalSamples;
var currentSampleIndex = (long)(songTime * frequency);
var windowSamples = new float[fftSize];
for (var i = 0; i < fftSize; i++)
{
var n = samples.Length;
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
var idx = currentSampleIndex - fftSize / 2 + i;
if (idx >= 0 && idx < totalSamples)
{
var angle = 2.0 * Math.PI * i / (n - 1);
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
windowSamples[i] = AudioClipData[idx];
}
// Cooley-Tukey Radix-2 FFT
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
else
{
if (i < j)
windowSamples[i] = 0f;
}
}
var spectrum = new float[n];
CalculateFFT(windowSamples, spectrum);
lock (CachedSpectrums)
{
CachedSpectrums[n] = spectrum;
Array.Copy(spectrum, samples, n);
}
}
private static void CalculateFFT(float[] samples, float[] spectrum)
{
var n = samples.Length;
// Apply Blackman-Harris window
const float a0 = 0.35875f;
const float a1 = 0.48829f;
const float a2 = 0.14128f;
const float a3 = 0.01168f;
var re = new float[n];
var im = new float[n];
for (var i = 0; i < n; i++)
{
var angle = 2.0 * Math.PI * i / (n - 1);
var w = (float)(a0 - a1 * Math.Cos(angle) + a2 * Math.Cos(2.0 * angle) - a3 * Math.Cos(3.0 * angle));
re[i] = samples[i] * w;
im[i] = 0f;
}
// Cooley-Tukey Radix-2 FFT
var m = (int)Math.Round(Math.Log(n, 2));
// Bit-reversal
var j = 0;
for (var i = 0; i < n - 1; i++)
{
if (i < j)
{
var temp = re[i];
re[i] = re[j];
re[j] = temp;
temp = im[i];
im[i] = im[j];
im[j] = temp;
}
var k = n / 2;
while (k <= j)
{
j -= k;
k /= 2;
}
j += k;
}
// FFT stages
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
var le2 = le / 2;
var ur = 1f;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{
var temp = re[i];
re[i] = re[j];
re[j] = temp;
var ip = item + le2;
var tr = re[ip] * ur - im[ip] * ui;
var ti = re[ip] * ui + im[ip] * ur;
temp = im[i];
im[i] = im[j];
im[j] = temp;
re[ip] = re[item] - tr;
im[ip] = im[item] - ti;
re[item] += tr;
im[item] += ti;
}
var k = n / 2;
while (k <= j)
{
j -= k;
k /= 2;
}
j += k;
var tempUr = ur;
ur = tempUr * wr - ui * wi;
ui = tempUr * wi + ui * wr;
}
}
// FFT stages
for (var stage = 1; stage <= m; stage++)
{
var le = 1 << stage;
var le2 = le / 2;
var ur = 1f;
var ui = 0f;
var wr = (float)Math.Cos(Math.PI / le2);
var wi = -(float)Math.Sin(Math.PI / le2);
for (var sub = 0; sub < le2; sub++)
{
for (var item = sub; item < n; item += le)
{
var ip = item + le2;
var tr = re[ip] * ur - im[ip] * ui;
var ti = re[ip] * ui + im[ip] * ur;
re[ip] = re[item] - tr;
im[ip] = im[item] - ti;
re[item] += tr;
im[item] += ti;
}
var tempUr = ur;
ur = tempUr * wr - ui * wi;
ui = tempUr * wi + ui * wr;
}
}
// Normalize and fill spectrum
var factor = 2.0f / n;
var outLen = Math.Min(spectrum.Length, n / 2);
for (var i = 0; i < outLen; i++)
{
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
}
// Normalize and fill spectrum
var factor = 2.0f / n;
var outLen = Math.Min(spectrum.Length, n / 2);
for (var i = 0; i < outLen; i++)
{
spectrum[i] = (float)Math.Sqrt(re[i] * re[i] + im[i] * im[i]) * factor;
}
}
}
+30 -31
View File
@@ -1,41 +1,40 @@
using System;
namespace ADOFAIRenderer.Util.Audio
namespace ADOFAIRenderer.Util.Audio;
internal class AudioSyncCalculator
{
public class AudioSyncCalculator
private readonly long _sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational;
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
private long _remainder;
public AudioSyncCalculator(long sampleRate, double fps)
{
private readonly long _sampleRate;
private readonly (long Numerator, long Denominator) _fpsRational;
_sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
// ReSharper disable once NotAccessedField.Local
private long _currentSample;
private long _remainder;
// NTSC
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
}
public AudioSyncCalculator(long sampleRate, double fps)
{
_sampleRate = sampleRate;
_fpsRational = RationalUtil.Double2MaxPrecisionInt(fps);
public void Reset()
{
_currentSample = 0;
_remainder = 0;
}
// NTSC
if (Math.Abs(fps - 29.97) < 0.001) _fpsRational = (30000, 1001);
if (Math.Abs(fps - 59.94) < 0.001) _fpsRational = (60000, 1001);
if (Math.Abs(fps - 23.976) < 0.001 || Math.Abs(fps - 23.98) < 0.001) _fpsRational = (24000, 1001);
}
public int GetNextFrameSamples()
{
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
public void Reset()
{
_currentSample = 0;
_remainder = 0;
}
public int GetNextFrameSamples()
{
var numerator = _sampleRate * _fpsRational.Denominator + _remainder;
var samplesNeeded = numerator / _fpsRational.Numerator;
_remainder = numerator % _fpsRational.Numerator;
_currentSample += samplesNeeded;
return (int)samplesNeeded;
}
_remainder = numerator % _fpsRational.Numerator;
_currentSample += samplesNeeded;
return (int)samplesNeeded;
}
}
+17 -18
View File
@@ -1,24 +1,23 @@
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 void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{
public float Level = 0f;
public void Initialize(int sampleRate, int channels) { }
public float ProcessSample(float sample, int channel) =>
sample * (float)Math.Pow(10, Level / 20);
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
}
+25 -26
View File
@@ -1,33 +1,32 @@
using System;
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 FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
// ReSharper disable once MemberCanBePrivate.Global
// ReSharper disable once FieldCanBeMadeReadOnly.Global
// ReSharper disable once ConvertToConstant.Global
public float Threshold = 1.0f;
public void Initialize(int sampleRate, int channels) { }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
if (sample > Threshold) return Threshold;
if (sample < -Threshold) return -Threshold;
return sample;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
buffer[i] = ProcessSample(buffer[i], 0);
}
public void Reset() { }
public double Latency => 0.0;
}
+8 -9
View File
@@ -1,16 +1,15 @@
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);
void ProcessBlock(Span<float> buffer);
float ProcessSample(float sample, int channel);
void ProcessBlock(Span<float> buffer);
void Reset();
void Reset();
double Latency { get; }
}
double Latency { get; }
}
+218 -219
View File
@@ -1,229 +1,228 @@
using System;
using System.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 _ratio = 4f;
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{
private float _thresholdDb = -20f;
private float _ratio = 4f;
private float _attackTime = 0.010f; // in seconds
private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private int _lookAheadSamples;
private int _sampleRate = 44100;
private int _channels = 2;
private float _attackCoef;
private float _releaseCoef;
private float _rmsCoef;
// Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
get => _lookAheadTime;
set
{
get => _lookAheadTime;
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
UpdateLookAheadSettings();
}
}
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
for (int i = 0; i < _delayBuffers.Length; i++)
{
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
}
}
public double Latency => _lookAheadTime;
public float ThresholdDb
{
get => _thresholdDb;
set => _thresholdDb = value;
}
public float Ratio
{
get => _ratio;
set => _ratio = Math.Max(1f, value);
}
public float AttackTime
{
get => _attackTime;
set
{
_attackTime = Math.Max(0.0001f, value);
UpdateAttackCoef();
}
}
public float ReleaseTime
{
get => _releaseTime;
set
{
_releaseTime = Math.Max(0.0001f, value);
UpdateReleaseCoef();
}
}
public float KneeDb
{
get => _kneeDb;
set => _kneeDb = Math.Max(0f, value);
}
public float RmsWindowTime
{
get => _rmsWindowTime;
set
{
_rmsWindowTime = Math.Max(0.0001f, value);
UpdateRmsCoef();
}
}
public void Initialize(int sampleRate, int channels)
{
_sampleRate = sampleRate;
_channels = channels;
UpdateAttackCoef();
UpdateReleaseCoef();
UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels];
_gainState = new float[channels];
Reset();
}
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef()
{
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
}
private void UpdateReleaseCoef()
{
_releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime));
}
private void UpdateRmsCoef()
{
_rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public float ProcessSample(float sample, int channel)
{
if (channel < 0 || channel >= _channels)
{
return sample;
}
// Calculate running Mean-Square (MS) level
// v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n]
float sq = sample * sample;
_rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq;
float rmsSq = Math.Max(0f, _rmsState[channel]);
// Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq))
float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f);
// Calculate static envelope curve (gain reduction in dB)
float gStatic = 0f;
if (_kneeDb > 0f)
{
if (rmsDb > _thresholdDb + _kneeDb * 0.5f)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f)
{
float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f;
gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb);
}
}
else
{
if (rmsDb > _thresholdDb)
{
gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb);
}
}
// Apply ballistics (attack and release) on the dB gain reduction
float lastGain = _gainState[channel];
float targetGain = gStatic;
// Note: Since targetGain and lastGain are <= 0 (gain reduction in dB),
// targetGain < lastGain means target gain reduction is larger (more negative),
// so we are in the attack phase.
float coef = targetGain < lastGain ? _attackCoef : _releaseCoef;
float currentGain = coef * lastGain + (1f - coef) * targetGain;
_gainState[channel] = currentGain;
// Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain;
}
public void ProcessBlock(Span<float> buffer)
{
for (var i = 0; i < buffer.Length; i++)
{
int channel = i % _channels;
buffer[i] = ProcessSample(buffer[i], channel);
}
}
public void Reset()
{
Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length);
foreach (var delayBuffer in _delayBuffers)
{
Array.Clear(delayBuffer, 0, delayBuffer.Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
}
}
+332 -333
View File
@@ -5,413 +5,412 @@ using ADOFAIRenderer.Util.Audio.Filter;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util.Audio
namespace ADOFAIRenderer.Util.Audio;
internal class AudioClipCache
{
public class AudioClipCache
private readonly Dictionary<string, CachedClipData> _cache = new();
public class CachedClipData
{
private readonly Dictionary<string, CachedClipData> _cache = new();
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
public class CachedClipData
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
lock (_cache)
{
public float[] Samples = null!; // Pre-resampled to target channels and sample rate
public double Duration;
}
if (_cache.TryGetValue(clipName, out var cached))
return cached;
public CachedClipData? GetOrCache(string clipName, int targetChannels, int targetSampleRate, bool allowLoad = true)
{
if (string.IsNullOrEmpty(clipName)) return null;
if (!allowLoad) return null;
lock (_cache)
if (!AudioManager.Instance) return null;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
{
if (_cache.TryGetValue(clipName, out var cached))
return cached;
var totalSamples = clip.samples;
var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
if (!allowLoad) return null;
// Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
if (!AudioManager.Instance) return null;
var clip = AudioManager.Instance.FindOrLoadAudioClip(clipName);
if (!clip) return null;
try
var data = new CachedClipData
{
var totalSamples = clip.samples;
var channels = clip.channels;
var rawSamples = new float[totalSamples * channels];
clip.GetData(rawSamples, 0);
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
// Auto-resample during cache generation
var resampled = AudioResampler.Resample(rawSamples, channels, clip.frequency, targetChannels, targetSampleRate);
var data = new CachedClipData
{
Samples = resampled,
Duration = (double)resampled.Length / (targetChannels * targetSampleRate)
};
_cache[clipName] = data;
return data;
}
catch (Exception e)
{
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
_cache[clipName] = data;
return data;
}
}
public void Clear()
{
lock (_cache)
catch (Exception e)
{
_cache.Clear();
MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}");
return null;
}
}
}
public class OfflineAudioMixer
public void Clear()
{
public class SoundEvent
lock (_cache)
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
_cache.Clear();
}
}
}
private readonly List<SoundEvent> _activeEvents = new();
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly object _lock = new();
internal class OfflineAudioMixer
{
public class SoundEvent
{
public string ClipName = null!;
public double StartTime; // In seconds relative to dspTimeBase
public double EndTime; // In seconds relative to dspTimeBase (-1 if one-shot)
public float Volume;
public float Pitch;
}
// Target Audio Settings
private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
private readonly List<SoundEvent> _activeEvents = new();
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
private readonly object _lock = new();
// Audio Filter Settings
private readonly LinkedList<IAudioFilter> _filters = new();
// Target Audio Settings
private readonly int _targetChannels;
private readonly int _targetSampleRate;
private const float ClipVolume = .5f;
// BGM Cached Samples (Pre-resampled)
private float[]? _bgmSamples;
private double _bgmDuration;
// Audio Filter Settings
private readonly LinkedList<IAudioFilter> _filters = new();
// Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
// BGM Cached Samples (Pre-resampled)
private float[]? _bgmSamples;
private double _bgmDuration;
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
// Clip Cache
private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
var rmsCompressor = new RMSCompressor
{
_targetChannels = targetChannels;
_targetSampleRate = targetSampleRate;
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var rmsCompressor = new RMSCompressor
{
ThresholdDb = -10, Ratio = 2,
AttackTime = 0.1f, ReleaseTime = 0.1f,
KneeDb = 10, RmsWindowTime = 0.02f
};
rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor);
var limiter = new RMSCompressor
{
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 0, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
}
public void InitializeBGM(AudioClip? clip)
var limiter = new RMSCompressor
{
lock (_lock)
{
if (!clip)
{
_bgmSamples = null;
_bgmDuration = 0;
return;
}
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 0, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
try
{
int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip);
// Pre-resample the BGM to the target format
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
var truePeakSafetyGain = new Gain { Level = -1f };
truePeakSafetyGain.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(truePeakSafetyGain);
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
}
catch (Exception e)
{
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
}
}
public void CacheClip(string clipName)
public void InitializeBGM(AudioClip? clip)
{
lock (_lock)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
if (!clip)
{
var ev = new SoundEvent
{
ClipName = clipName,
StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
return;
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
int originalSamples = clip.samples;
int originalChannels = clip.channels;
float[] rawSamples = new float[originalSamples * originalChannels];
clip.GetData(rawSamples, 0);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Pre-resample the BGM to the target format
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
_bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
Melon<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
}
finally
catch (Exception e)
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
_bgmSamples = null;
_bgmDuration = 0;
}
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
public void CacheClip(string clipName)
{
_clipCache.GetOrCache(clipName, _targetChannels, _targetSampleRate, allowLoad: true);
}
public void RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch)
{
lock (_lock)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
var ev = new SoundEvent
{
double totalLatency = 0;
foreach (var filter in _filters)
ClipName = clipName,
StartTime = startTime,
EndTime = endTime,
Volume = volume,
Pitch = pitch
};
_activeEvents.Add(ev);
if (!source) return;
_sourceMappings.Remove(source);
_sourceMappings.Add(source, ev);
}
}
public void RegisterSoundEvent(string clipName, double startTime, double endTime, float volume, float pitch)
{
RegisterSoundEvent(null, clipName, startTime, endTime, volume, pitch);
}
public bool TryGetSoundEvent(AudioSource? source, out SoundEvent ev)
{
if (source == null)
{
ev = null!;
return false;
}
lock (_lock)
{
return _sourceMappings.TryGetValue(source, out ev);
}
}
public bool RemoveSourceMapping(AudioSource source)
{
lock (_lock)
{
return _sourceMappings.Remove(source);
}
}
public void Reset()
{
lock (_lock)
{
_activeEvents.Clear();
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
_bgmSamples = null;
_bgmDuration = 0;
_clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
}
}
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase)
{
// mixedBuffer size must be at least samplesNeeded * _targetChannels
Array.Clear(mixedBuffer, 0, samplesNeeded * _targetChannels);
lock (_lock)
{
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
{
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
float valLow = _bgmSamples[idxLow * _targetChannels + c];
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
* ClipVolume;
}
}
}
}
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
{
if (_bgmSamples == null || _bgmSamples.Length == 0) return;
var conductor = scrConductor.instance;
if (!conductor) return;
double pitch = conductor.song.pitch;
var dspTimeSong = conductor.dspTimeSong;
var separateCountdownOffset = conductor.separateCountdownTime
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
: 0.0;
// BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++)
var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null)
{
double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
if (clipTime >= 0 && clipTime < _bgmDuration)
{
double sampleIdxDouble = clipTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = _bgmSamples[idxLow * _targetChannels + c];
float valHigh = _bgmSamples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * (scnGame.instance.levelData.volume * 0.01f)
* ClipVolume;
}
}
_activeEvents.RemoveAt(i);
continue;
}
}
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
{
for (var i = _activeEvents.Count - 1; i >= 0; i--)
var isSoundFinished = false;
var totalSamples = clipData.Samples.Length / _targetChannels;
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++)
{
var ev = _activeEvents[i];
var clipData = _clipCache.GetOrCache(ev.ClipName, _targetChannels, _targetSampleRate, allowLoad: false);
if (clipData == null)
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{
_activeEvents.RemoveAt(i);
continue;
isSoundFinished = true;
break;
}
var isSoundFinished = false;
var totalSamples = clipData.Samples.Length / _targetChannels;
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
var elapsed = sampleTime - adjustedStartTime;
if (elapsed < 0) continue; // Sound hasn't started yet
for (var s = 0; s < samplesNeeded; s++)
var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{
var sampleTime = frameStartTime + (double)s / _targetSampleRate;
// If it is a looping sound and has reached or exceeded its end time, stop playing.
if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
if (ev.EndTime < 0) // One-shot
{
isSoundFinished = true;
break;
}
var elapsed = sampleTime - adjustedStartTime;
if (elapsed < 0) continue; // Sound hasn't started yet
var soundTime = elapsed * ev.Pitch;
if (soundTime >= clipData.Duration)
{
if (ev.EndTime < 0) // One-shot
{
isSoundFinished = true;
break;
}
// Looping
soundTime %= clipData.Duration;
}
// Linear interpolation for pitch shifting in the mixer loop
double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
}
// Looping
soundTime %= clipData.Duration;
}
// Clean up finished sound events (both one-shot and looping)
if (isSoundFinished)
// Linear interpolation for pitch shifting in the mixer loop
double sampleIdxDouble = soundTime * _targetSampleRate;
long idxLow = (long)Math.Floor(sampleIdxDouble);
long idxHigh = idxLow + 1;
float t = (float)(sampleIdxDouble - idxLow);
if (idxHigh >= totalSamples) idxHigh = totalSamples - 1;
if (idxLow >= totalSamples) idxLow = totalSamples - 1;
for (var c = 0; c < _targetChannels; c++)
{
_activeEvents.RemoveAt(i);
float valLow = clipData.Samples[idxLow * _targetChannels + c];
float valHigh = clipData.Samples[idxHigh * _targetChannels + c];
mixedBuffer[s * _targetChannels + c] += (valLow + t * (valHigh - valLow)) * ev.Volume
* ClipVolume * 2 / 3;
}
}
// Clean up finished sound events (both one-shot and looping)
if (isSoundFinished)
{
_activeEvents.RemoveAt(i);
}
}
}
}
+26 -27
View File
@@ -4,41 +4,40 @@ using System.Resources;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util
namespace ADOFAIRenderer.Util;
internal static class BundleLoader
{
public static class BundleLoader
private static readonly string BundlePlatform;
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
{
private static string BundlePlatform;
private static readonly Assembly? ExecutingAssembly;
static BundleLoader()
BundlePlatform = Application.platform switch
{
BundlePlatform = Application.platform switch
{
RuntimePlatform.WindowsPlayer => "Win64",
RuntimePlatform.OSXPlayer => "OSX",
_ => "Linux"
};
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
RuntimePlatform.WindowsPlayer => "Win64",
RuntimePlatform.OSXPlayer => "OSX",
_ => "Linux"
};
ExecutingAssembly = Assembly.GetExecutingAssembly();
}
public static AssetBundle GetBundle(string resourcePath)
public static AssetBundle GetBundle(string resourcePath)
{
if (ExecutingAssembly != null)
{
if (ExecutingAssembly != null)
var bundlePath = $"ADOFAIRenderer.Bundle.{BundlePlatform}.{resourcePath}";
using var resourceStream = ExecutingAssembly.GetManifestResourceStream(bundlePath);
if (resourceStream != null)
{
var bundlePath = $"ADOFAIRenderer.Bundle.{BundlePlatform}.{resourcePath}";
using var resourceStream = ExecutingAssembly.GetManifestResourceStream(bundlePath);
if (resourceStream != null)
{
return AssetBundle.LoadFromStream(resourceStream);
}
Melon<RenderMod>.Logger.Error($"DLL에서 번들 리소스를 찾을 수 없습니다.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
return AssetBundle.LoadFromStream(resourceStream);
}
Melon<RenderMod>.Logger.Error("실행 어셈블리가 제대로 설정되지 않았습니다.");
throw new InvalidOperationException("ExecutingAssembly has not been set.");
Melon<RenderMod>.Logger.Error($"Could not find bundle resource in DLL.\n{bundlePath}");
throw new MissingManifestResourceException("Could not find bundle resource in DLL.");
}
Melon<RenderMod>.Logger.Error("Executing assembly has not been set properly.");
throw new InvalidOperationException("ExecutingAssembly has not been set.");
}
}
+21 -1
View File
@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util.FFmpeg
{
@@ -7,4 +9,22 @@ namespace ADOFAIRenderer.Util.FFmpeg
{
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 }
};
}
}
+70 -69
View File
@@ -6,10 +6,10 @@ using Unity.Collections.LowLevel.Unsafe;
namespace ADOFAIRenderer.Util.FFmpeg
{
/// <summary>
/// FFmpeg의 C 네이티브 API를 사용하여 비디오 파일을 스트리밍 방식으로 디코딩하는 클래스입니다.
/// 플레이어 형태가 아니며, 필요할 때 프레임을 순차적으로 읽거나 앞으로 탐색(Seek/Skip)할 수 있습니다.
/// Decodes video files in a streaming fashion using FFmpeg's native C API.
/// Unlike a standalone player, it allows reading frames sequentially or seeking/skipping forward on demand.
/// </summary>
public sealed unsafe class FFmpegNativeDecoder : IDisposable
internal sealed unsafe class FFmpegNativeDecoder : IDisposable
{
private AVFormatContext* _pFormatContext = null;
private AVCodecContext* _pCodecContext = null;
@@ -26,41 +26,41 @@ namespace ADOFAIRenderer.Util.FFmpeg
private bool _isDraining;
private readonly object _lock = new();
// 탐색(Seek) 후 매칭되는 프레임을 보관하기 위한 캐시 프레임
// Cached frame used to hold matching frames after seeking
private AVFrame* _pCachedFrame = null;
private bool _hasCachedFrame;
// SwsContext 갱신 감지용 필드
// Field to detect SwsContext updates
private AVPixelFormat _srcPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
private AVPixelFormat _dstPixFmt = AVPixelFormat.AV_PIX_FMT_NONE;
private int _srcWidth;
private int _srcHeight;
/// <summary>비디오 가로 해상도 (픽셀).</summary>
/// <summary>Video width in pixels.</summary>
public int Width { get; private set; }
/// <summary>비디오 세로 해상도 (픽셀).</summary>
/// <summary>Video height in pixels.</summary>
public int Height { get; private set; }
/// <summary>초당 프레임 수.</summary>
/// <summary>Frames per second.</summary>
public double FPS { get; private set; }
/// <summary>전체 재생 시간 (초).</summary>
/// <summary>Total duration in seconds.</summary>
public double Duration { get; private set; }
/// <summary>전체 프레임 수 (추정치).</summary>
/// <summary>Estimated total number of frames.</summary>
public long TotalFrames { get; private set; }
/// <summary>현재 읽은 프레임의 인덱스.</summary>
/// <summary>Index of the currently read frame.</summary>
public long CurrentFrameIndex => _currentFrameIndex;
/// <summary>현재 재생 시간 (초).</summary>
/// <summary>Current playback time in seconds.</summary>
public double CurrentTime => _currentFrameIndex / FPS;
/// <summary>비디오 원본 코덱의 픽셀 포맷.</summary>
/// <summary>Pixel format of the original video stream.</summary>
public AVPixelFormat PixelFormat => _pCodecContext != null ? _pCodecContext->pix_fmt : AVPixelFormat.AV_PIX_FMT_NONE;
/// <summary>비디오 원본 코덱의 색상 범위.</summary>
/// <summary>Color range of the original video stream.</summary>
public AVColorRange ColorRange => _pCodecContext != null ? _pCodecContext->color_range : AVColorRange.AVCOL_RANGE_UNSPECIFIED;
public FFmpegNativeDecoder(string videoPath)
@@ -80,11 +80,11 @@ namespace ADOFAIRenderer.Util.FFmpeg
{
int ret = ffmpeg.avformat_open_input(ppFormatCtx, videoPath, null, null);
if (ret < 0)
throw new Exception($"비디오 파일을 열 수 없습니다: {videoPath} ({GetFFmpegErrorString(ret)})");
throw new Exception($"Failed to open video file: {videoPath} ({GetFFmpegErrorString(ret)})");
}
if (ffmpeg.avformat_find_stream_info(_pFormatContext, null) < 0)
throw new Exception("스트림 정보를 읽을 수 없습니다.");
throw new Exception("Failed to retrieve stream info.");
_videoStreamIndex = -1;
for (int i = 0; i < _pFormatContext->nb_streams; i++)
@@ -97,12 +97,12 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
if (_videoStreamIndex == -1)
throw new Exception("비디오 스트림을 찾을 수 없습니다.");
throw new Exception("Failed to find video stream.");
var stream = _pFormatContext->streams[_videoStreamIndex];
_timeBase = stream->time_base;
// FPS 계산
// Calculate FPS
if (stream->r_frame_rate.num > 0 && stream->r_frame_rate.den > 0)
{
FPS = ffmpeg.av_q2d(stream->r_frame_rate);
@@ -113,32 +113,32 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
FPS = 30.0; // 기본값 폴백
FPS = 30.0; // Fallback default
}
var codecId = stream->codecpar->codec_id;
var decoder = ffmpeg.avcodec_find_decoder(codecId);
if (decoder == null)
throw new Exception($"지원하지 않는 비디오 코덱입니다: {codecId}");
throw new Exception($"Unsupported video codec: {codecId}");
_pCodecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (_pCodecContext == null)
throw new Exception("코덱 컨텍스트를 할당할 수 없습니다.");
throw new Exception("Failed to allocate codec context.");
if (ffmpeg.avcodec_parameters_to_context(_pCodecContext, stream->codecpar) < 0)
throw new Exception("코덱 파라미터를 복사할 수 없습니다.");
throw new Exception("Failed to copy codec parameters.");
// 디코딩 성능 향상을 위한 멀티스레딩 설정 (자동)
// Multi-threading settings for improved decoding performance (auto)
_pCodecContext->thread_count = 0;
_pCodecContext->thread_type = ffmpeg.FF_THREAD_FRAME;
if (ffmpeg.avcodec_open2(_pCodecContext, decoder, null) < 0)
throw new Exception("코덱을 열 수 없습니다.");
throw new Exception("Failed to open codec.");
Width = _pCodecContext->width;
Height = _pCodecContext->height;
// 총 프레임 수 및 재생 시간 계산
// Calculate total frames and duration
if (stream->nb_frames > 0)
{
TotalFrames = stream->nb_frames;
@@ -183,7 +183,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
{
ffmpeg.av_packet_unref(_pPacket);
int ret = ffmpeg.av_read_frame(_pFormatContext, _pPacket);
if (ret < 0) return false; // EOF 또는 에러
if (ret < 0) return false; // EOF or error
if (_pPacket->stream_index == _videoStreamIndex)
{
@@ -191,7 +191,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
ffmpeg.av_packet_unref(_pPacket);
if (ret < 0)
{
throw new Exception($"디코더에 패킷을 전송하는 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error sending packet to decoder: {GetFFmpegErrorString(ret)}");
}
return true;
}
@@ -224,7 +224,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (_pSwsContext == null)
{
throw new Exception("SwsContext를 초기화할 수 없습니다.");
throw new Exception("Failed to initialize SwsContext.");
}
}
@@ -251,13 +251,13 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
/// <summary>
/// 다음 비디오 프레임을 읽고 RGBA 형태로 복사합니다.
/// Reads the next video frame and copies it in RGBA format.
/// </summary>
/// <param name="outBuffer">RGBA 데이터를 저장할 버퍼의 포인터</param>
/// <param name="outStride">출력 버퍼의 가로 한 행의 바이트 크기 (일반적으로 Width * 4)</param>
/// <param name="vflip">Unity 텍스처 시스템에 맞게 상하 flip을 적용할지 여부</param>
/// <param name="timestamp">출력된 프레임의 타임스탬프 (초)</param>
/// <returns>프레임을 성공적으로 디코딩하여 복사했으면 true, 재생이 끝났거나 실패했으면 false</returns>
/// <param name="outBuffer">Pointer to the output buffer storing RGBA data.</param>
/// <param name="outStride">Byte stride per row of the output buffer (typically Width * 4).</param>
/// <param name="vflip">Whether to flip vertically to match Unity's texture coordinate system.</param>
/// <param name="timestamp">Timestamp of the output frame in seconds.</param>
/// <returns>True if the frame was successfully decoded and copied; false on EOF or failure.</returns>
private bool TryReadFrame(byte* outBuffer, int outStride, bool vflip, out double timestamp)
{
timestamp = 0;
@@ -339,7 +339,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
}
}
}
@@ -354,16 +354,17 @@ namespace ADOFAIRenderer.Util.FFmpeg
{
if (outBuffer.Length < Width * Height * 4)
{
throw new ArgumentException("출력 버퍼의 크기가 비디오 해상도보다 작습니다.", nameof(outBuffer));
throw new ArgumentException("Output buffer size is smaller than video resolution.", nameof(outBuffer));
}
int stride = Width * 4;
return TryReadFrame((byte*)outBuffer.GetUnsafePtr(), stride, vflip, out timestamp);
}
/// <summary>
/// 다음 비디오 프레임을 읽어 Y, U, V 채널 데이터를 각각 복사합니다.
/// 비디오 원본이 YUV420p 계열(YUV420P, YUVJ420P)이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다.
/// 그 외 포맷일 경우 자동으로 YUV420p로 변환 후 복사합니다.
/// Reads the next video frame and copies its Y, U, and V channel data separately.
/// If the source format is YUV420P family (YUV420P, YUVJ420P), CPU color conversion (sws_scale) is bypassed
/// and memory is copied directly for maximum performance.
/// Other formats are automatically converted to YUV420P before copying.
/// </summary>
// ReSharper disable once InconsistentNaming
private bool TryReadYUV420PFrame(
@@ -411,7 +412,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
}
}
}
@@ -445,7 +446,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
int uvW = w / 2;
int uvH = h / 2;
// Y plane 복사
// Copy Y plane
for (int y = 0; y < h; y++)
{
Buffer.MemoryCopy(
@@ -455,7 +456,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
);
}
// U plane 복사
// Copy U plane
for (int y = 0; y < uvH; y++)
{
Buffer.MemoryCopy(
@@ -465,7 +466,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
);
}
// V plane 복사
// Copy V plane
for (int y = 0; y < uvH; y++)
{
Buffer.MemoryCopy(
@@ -495,7 +496,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (yBuffer.Length < w * h || uBuffer.Length < uvW * uvH || vBuffer.Length < uvW * uvH)
{
throw new ArgumentException("버퍼 크기가 비디오 YUV 평면 해상도보다 작습니다.");
throw new ArgumentException("Buffer size is smaller than video YUV plane resolution.");
}
return TryReadYUV420PFrame(
@@ -507,9 +508,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
/// <summary>
/// 다음 비디오 프레임을 읽어 NV12 형식의 Y, UV 채널 데이터를 각각 복사합니다.
/// 비디오 원본이 NV12 계열이면 CPU 컬러 변환(sws_scale)을 우회하고 메모리를 다이렉트로 복사하여 속도가 매우 빠릅니다.
/// 그 외 포맷일 경우 자동으로 NV12로 변환 후 복사합니다.
/// Reads the next video frame and copies its NV12 format Y and UV channel data separately.
/// If the source format is NV12 family, CPU color conversion (sws_scale) is bypassed
/// and memory is copied directly for maximum performance.
/// Other formats are automatically converted to NV12 before copying.
/// </summary>
// ReSharper disable once InconsistentNaming
private bool TryReadNV12Frame(
@@ -556,7 +558,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
throw new Exception($"디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error during decoding: {GetFFmpegErrorString(ret)}");
}
}
}
@@ -587,7 +589,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
int h = frame->height;
int uvH = h / 2;
// Y plane 복사
// Copy Y plane
for (int y = 0; y < h; y++)
{
Buffer.MemoryCopy(
@@ -597,7 +599,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
);
}
// UV plane 복사
// Copy UV plane
for (int y = 0; y < uvH; y++)
{
Buffer.MemoryCopy(
@@ -625,7 +627,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (yBuffer.Length < w * h || uvBuffer.Length < w * uvH)
{
throw new ArgumentException("버퍼 크기가 비디오 NV12 평면 해상도보다 작습니다.");
throw new ArgumentException("Buffer size is smaller than video NV12 plane resolution.");
}
return TryReadNV12Frame(
@@ -636,10 +638,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
/// <summary>
/// 지정한 개수만큼 프레임을 디코딩 후 바로 버립니다. (빠른 전방 탐색용)
/// Decodes and discards a specified number of frames immediately (for fast forward skipping).
/// </summary>
/// <param name="count">버릴 프레임 개수</param>
/// <returns>실제로 건너뛴 프레임 개수</returns>
/// <param name="count">Number of frames to discard.</param>
private void SkipFrames(int count)
{
lock (_lock)
@@ -682,16 +683,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
throw new Exception($"프레임 건너뛰기 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error skipping frames: {GetFFmpegErrorString(ret)}");
}
}
}
}
/// <summary>
/// 특정 재생 시간(초)으로 탐색(Seek)합니다.
/// Seeks to a specific playback time in seconds.
/// </summary>
/// <param name="timeSeconds">탐색할 시간 (초)</param>
/// <param name="timeSeconds">Target time in seconds to seek to.</param>
public void SeekToTime(double timeSeconds)
{
lock (_lock)
@@ -701,7 +702,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (timeSeconds < 0) timeSeconds = 0;
double targetFrameIndex = timeSeconds * FPS;
// 이미 해당 인덱스이고 매우 인접한 미래라면 seek 대신 skip 처리 (I-프레임 찾기 오버헤드 방지)
// If already at target index or very close future, skip frames instead of seek to avoid I-frame overhead
bool useSkip = targetFrameIndex >= _currentFrameIndex && targetFrameIndex - _currentFrameIndex < 60;
if (useSkip)
{
@@ -719,19 +720,19 @@ namespace ADOFAIRenderer.Util.FFmpeg
int ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, targetPts, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
// 실패 시 파일 처음부터 탐색 시도
// Fallback to start of file on seek failure
ret = ffmpeg.av_seek_frame(_pFormatContext, _videoStreamIndex, 0, ffmpeg.AVSEEK_FLAG_BACKWARD);
if (ret < 0)
{
throw new Exception($"Seek 실패: {GetFFmpegErrorString(ret)}");
throw new Exception($"Seek failed: {GetFFmpegErrorString(ret)}");
}
}
// 디코더 내부 캐시 초기화
// Flush decoder internal cache
ffmpeg.avcodec_flush_buffers(_pCodecContext);
_isDraining = false;
// 지정된 시간 이후의 첫 번째 프레임을 찾을 때까지 디코딩을 진행하며 버립니다.
// Decode and discard frames until the first frame past the specified time is found
while (true)
{
ret = ffmpeg.avcodec_receive_frame(_pCodecContext, _pDecodedFrame);
@@ -739,10 +740,10 @@ namespace ADOFAIRenderer.Util.FFmpeg
{
double currentPts = GetTimestampAndUpdateIndex(_pDecodedFrame);
// 플로팅 포인트 오차 감안하여 하프 프레임 제외한 시간부터 체크
// Check from half frame earlier to account for floating point errors
if (currentPts >= timeSeconds - (0.5 / FPS))
{
// 매칭되는 첫 프레임을 캐시에 임시 저장하여 다음 TryReadFrame에서 소모하도록 유도
// Cache the first matching frame so it will be consumed by the next TryReadFrame call
ffmpeg.av_frame_move_ref(_pCachedFrame, _pDecodedFrame);
_hasCachedFrame = true;
break;
@@ -768,16 +769,16 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
else
{
throw new Exception($"Seek 디코딩 중 오류 발생: {GetFFmpegErrorString(ret)}");
throw new Exception($"Error during seek decoding: {GetFFmpegErrorString(ret)}");
}
}
}
}
/// <summary>
/// 특정 프레임 인덱스로 탐색(Seek)합니다.
/// Seeks to a specific frame index.
/// </summary>
/// <param name="frameIndex">탐색할 프레임 인덱스</param>
/// <param name="frameIndex">Target frame index to seek to.</param>
public void SeekToFrame(long frameIndex)
{
SeekToTime(frameIndex / FPS);
@@ -789,9 +790,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
byte* buffer = stackalloc byte[(int)bufferSize];
if (ffmpeg.av_strerror(errnum, buffer, bufferSize) >= 0)
{
return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"알 수 없는 에러 (Code: {errnum})";
return System.Runtime.InteropServices.Marshal.PtrToStringAnsi((IntPtr)buffer) ?? $"Unknown error (Code: {errnum})";
}
return $"알 수 없는 에러 (Code: {errnum})";
return $"Unknown error (Code: {errnum})";
}
public void Dispose()
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -16,7 +16,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public sealed class VideoEncoder : IEquatable<VideoEncoder>
internal sealed class VideoEncoder : IEquatable<VideoEncoder>
{
// ReSharper disable MemberCanBePrivate.Global
+13 -11
View File
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
@@ -9,7 +10,7 @@ using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Util
{
// ReSharper disable once InconsistentNaming
public class FFmpegVideoBGPlayer : MonoBehaviour
internal class FFmpegVideoBGPlayer : MonoBehaviour
{
private static readonly int YTex = Shader.PropertyToID("_YTex");
private static readonly int UVTex = Shader.PropertyToID("_UVTex");
@@ -70,14 +71,14 @@ namespace ADOFAIRenderer.Util
if (!File.Exists(videoPath))
{
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] 비디오 파일이 존재하지 않습니다: {videoPath}");
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] Video file does not exist: {videoPath}");
return;
}
try
{
_decoder = new FFmpegNativeDecoder(videoPath);
_computeShader = RenderMod.ColorConversionShader;
_computeShader = ModAssets.ColorConversionShader;
var w = _decoder.Width;
var h = _decoder.Height;
@@ -101,7 +102,7 @@ namespace ADOFAIRenderer.Util
_gpuMode = GPUMode.NV12;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader NV12 모드 초기화 완료");
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader NV12 mode successfully");
}
catch
{
@@ -124,7 +125,7 @@ namespace ADOFAIRenderer.Util
_gpuMode = GPUMode.YUV420P;
gpuInitSuccess = true;
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Compute Shader YUV420P 모드 초기화 완료");
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Initialized Compute Shader YUV420P mode successfully");
}
catch
{
@@ -133,7 +134,8 @@ namespace ADOFAIRenderer.Util
break;
default:
throw new ArgumentOutOfRangeException();
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Cannot use Compute Shader for colorspace conversion.");
break;
}
}
@@ -150,18 +152,18 @@ namespace ADOFAIRenderer.Util
_textureRGBA = new Texture2D(w, h, TextureFormat.RGBA32, false);
ConfigureTexture(_textureRGBA);
_gpuMode = GPUMode.None;
Melon<RenderMod>.Logger.Warning($"[FFmpegVideoBGPlayer] GPU 가속 미지원 또는 로드 실패로 CPU 폴백(RGBA)으로 진행합니다.");
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] 초기화 성공 - 해상도: {w}x{h}, FPS: {_decoder.FPS:F2}");
Melon<RenderMod>.Logger.Msg($"[FFmpegVideoBGPlayer] Successfully initialized - Resolution: {w}x{h}, FPS: {_decoder.FPS:F2}");
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] 초기화 중 에러 발생: {ex}");
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during initialization: {ex}");
CleanUp();
}
}
@@ -282,7 +284,7 @@ namespace ADOFAIRenderer.Util
}
catch (Exception ex)
{
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Update 재생 중 에러 발생: {ex}");
Melon<RenderMod>.Logger.Error($"[FFmpegVideoBGPlayer] Error during playback update: {ex}");
_isInitialized = false;
}
}
@@ -394,7 +396,7 @@ namespace ADOFAIRenderer.Util
{
_decoder.Dispose();
_decoder = null;
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] 디코더 해제 완료.");
Melon<RenderMod>.Logger.Msg("[FFmpegVideoBGPlayer] Decoder successfully disposed.");
}
if (_textureRGBA != null)
+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.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
namespace ADOFAIRenderer.Util;
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)
{
IFileOpenDialog? dialog = null;
// 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 dialog);
if (createHr != 0 || dialog == null)
{
// 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 && initialItem != null)
{
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);
if (resultItem == null) return null;
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, lParam, 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
}
+15 -14
View File
@@ -3,6 +3,7 @@ using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
using MelonLoader;
using Unity.Collections;
@@ -12,7 +13,7 @@ using UnityEngine.Rendering;
namespace ADOFAIRenderer.Util
{
public class FrameReader : IDisposable
internal class FrameReader : IDisposable
{
private static readonly int Height = Shader.PropertyToID("Height");
private static readonly int Width = Shader.PropertyToID("Width");
@@ -70,14 +71,14 @@ namespace ADOFAIRenderer.Util
state.IsInFlight = true;
}
// GPU 컬러 변환 활성화 여부 검사
bool useGPUConversion = RenderMod.ColorConversionShader;
// Check if GPU color conversion is enabled
bool useGPUConversion = ModAssets.ColorConversionShader;
AVPixelFormat expectedFmt = AVPixelFormat.AV_PIX_FMT_RGBA;
if (useGPUConversion)
{
expectedFmt = _targetFormat;
// Compute Shader는 NV12와 YUV420P만 지원하므로 그 외 포맷은 CPU 폴백 처리
// Compute Shader only supports NV12 and YUV420P; fallback to CPU for other formats
if (expectedFmt != AVPixelFormat.AV_PIX_FMT_NV12 && expectedFmt != AVPixelFormat.AV_PIX_FMT_YUV420P)
{
useGPUConversion = false;
@@ -87,31 +88,31 @@ namespace ADOFAIRenderer.Util
var bufferSize = texture.width * texture.height * 4; // RGBA = 4 channel
state.Setup(currentFrame, bufferSize, useGPUConversion, expectedFmt, texture.width, texture.height);
if (useGPUConversion && state.GPUBuffer != null && RenderMod.ColorConversionShader)
if (useGPUConversion && state.GPUBuffer != null && ModAssets.ColorConversionShader)
{
int kernelIndex = -1;
if (expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12)
{
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_NV12");
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_NV12");
}
else if (expectedFmt == AVPixelFormat.AV_PIX_FMT_YUV420P)
{
kernelIndex = RenderMod.ColorConversionShader.FindKernel("CSMain_YUV420P");
kernelIndex = ModAssets.ColorConversionShader.FindKernel("CSMain_YUV420P");
}
if (kernelIndex >= 0)
{
RenderMod.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
RenderMod.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
RenderMod.ColorConversionShader.SetInt(Width, texture.width);
RenderMod.ColorConversionShader.SetInt(Height, texture.height);
ModAssets.ColorConversionShader.SetTexture(kernelIndex, SourceTexture, texture);
ModAssets.ColorConversionShader.SetBuffer(kernelIndex, DestinationBuffer, state.GPUBuffer);
ModAssets.ColorConversionShader.SetInt(Width, texture.width);
ModAssets.ColorConversionShader.SetInt(Height, texture.height);
var threadGroupsX = expectedFmt == AVPixelFormat.AV_PIX_FMT_NV12
? (texture.width + 31) / 32
: (texture.width + 63) / 64;
var threadGroupsY = (texture.height + 15) / 16;
RenderMod.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
ModAssets.ColorConversionShader.Dispatch(kernelIndex, threadGroupsX, threadGroupsY, 1);
AsyncGPUReadback.Request(
state.GPUBuffer,
@@ -120,7 +121,7 @@ namespace ADOFAIRenderer.Util
}
else
{
// 커널을 못 찾은 경우 기존 RGBA 텍스처 백업 기법으로 폴백
// If kernel is not found, fallback to standard RGBA texture readback
AsyncGPUReadback.RequestIntoNativeArray(
ref state.NativeBuffer,
texture,
@@ -189,7 +190,7 @@ namespace ADOFAIRenderer.Util
if (state.IsGPUConversion)
{
var data = request.GetData<byte>();
// 비동기 요청 버퍼 데이터를 지속성(NativeBuffer) 메모리로 복사
// Copy async request buffer data to persistent NativeBuffer memory
NativeArray<byte>.Copy(data, state.NativeBuffer, data.Length);
}
+3 -3
View File
@@ -9,18 +9,18 @@ using UnityEngine;
namespace ADOFAIRenderer.Util
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum GPUVendor
internal enum GPUVendor
{
Unknown, NVIDIA, AMD, Intel, Apple
}
public static class GPUUtil
internal static class GPUUtil
{
private const string NvidiaPattern =
@"^NVIDIA GeForce [RG]TX [0-9]+(?> SUPER| Ti(?> SUPER)?)?(?> [0-9]+GB)?(?> Laptop GPU)?(?>/PCIe/SSE2)?$";
private const string IntelPattern =
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+$|Arc\(TM\) [A-C][0-9]+ Graphics)$";
@"^Intel(?>\(R\))? (?>U?HD Graphics [0-9]+|Arc\(TM\) [A-C][0-9]+ Graphics)(?> \([^)]+\))?$";
// ReSharper disable once InconsistentNaming
private const string AMDPattern =
+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.
/// </para>
/// </summary>
public static class OSNotification
internal static class OSNotification
{
/// <summary>
/// Sends a native desktop notification.
@@ -48,7 +48,7 @@ namespace ADOFAIRenderer.Util
}
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)
{
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>" +
"</binding></visual>" +
"<actions>" +
"<action content=\"렌더 폴더 열기\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"<action content=\"Open Render Folder\" arguments=\"adofai-renderer://\" activationType=\"protocol\"/>" +
"</actions></toast>";
psScript =
@@ -165,7 +165,7 @@ namespace ADOFAIRenderer.Util
{
string stderr = process.StandardError.ReadToEnd().Trim();
if (!string.IsNullOrEmpty(stderr))
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {stderr}");
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {stderr}");
return false;
}
@@ -173,7 +173,7 @@ namespace ADOFAIRenderer.Util
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Toast 실패 (fallback 사용): {ex.Message}");
MelonLogger.Warning($"[OSNotification] Toast failed (using fallback): {ex.Message}");
return false;
}
}
@@ -283,7 +283,7 @@ namespace ADOFAIRenderer.Util
if (hWnd == IntPtr.Zero)
{
MelonLogger.Warning("[OSNotification] 메시지 윈도우를 생성할 수 없습니다.");
MelonLogger.Warning("[OSNotification] Cannot create message window.");
return;
}
@@ -334,7 +334,7 @@ namespace ADOFAIRenderer.Util
catch (Exception ex)
{
MelonLogger.Warning(
$"[OSNotification] 폴더를 열 수 없습니다: {ex.Message}");
$"[OSNotification] Cannot open folder: {ex.Message}");
}
break;
@@ -357,7 +357,7 @@ namespace ADOFAIRenderer.Util
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] Balloon 알림 실패: {ex.Message}");
MelonLogger.Warning($"[OSNotification] Balloon notification failed: {ex.Message}");
}
finally
{
@@ -465,7 +465,7 @@ namespace ADOFAIRenderer.Util
};
startInfo.ArgumentList.Add("-a");
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(message);
@@ -491,7 +491,7 @@ namespace ADOFAIRenderer.Util
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] 리눅스 알림 처리 실패: {ex.Message}");
MelonLogger.Warning($"[OSNotification] Linux notification processing failed: {ex.Message}");
// Fallback: show without action
ShowSimple(title, message);
@@ -554,7 +554,7 @@ namespace ADOFAIRenderer.Util
}
catch (Exception ex)
{
MelonLogger.Warning($"[OSNotification] gdbus 알림 실패: {ex.Message}");
MelonLogger.Warning($"[OSNotification] gdbus notification failed: {ex.Message}");
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using System;
using System.Runtime.CompilerServices;
using UnityEngine;
namespace ADOFAIRenderer.Util
{
internal static class ParticleUtil
{
private static readonly ConditionalWeakTable<ParticleSystem, ParticleSystemRenderer> Cache = new();
public static ParticleSystemRenderer? GetRenderer(this ParticleSystem ps)
{
if (!ps) return null;
if (Cache.TryGetValue(ps, out var renderer) && renderer) return renderer;
var particleSystems = ps.GetComponents<ParticleSystem>();
var renderers = ps.GetComponents<ParticleSystemRenderer>();
var index = Array.IndexOf(particleSystems, ps);
renderer = index >= 0 && index < renderers.Length ? renderers[index] : ps.GetComponent<ParticleSystemRenderer>();
if (renderer) Cache.Add(ps, renderer);
return renderer;
}
}
}
+13 -14
View File
@@ -1,56 +1,55 @@
using System;
using System;
namespace ADOFAIRenderer.Util
{
public class RationalUtil
internal static class RationalUtil
{
public static (int Numerator, int Denominator) Double2MaxPrecisionInt(double value,
int maxDenominator = 100000000)
{
// 1. 예외 처리 및 부호 판별
// 1. Edge case handling and sign detection
if (value == 0.0) return (0, 1);
var isNegative = value < 0;
var sign = isNegative ? -1.0 : 1.0;
var target = Math.Abs(value);
// 2. 스턴-브로코 / 연속분수 경계값 초기화
// 아래 두 행의 분수 (0/1, 1/0) 사이에서 시작하여 타겟에 가까워집니다.
// 2. Stern-Brocot / continued fraction boundary initialization
// Starts between (0/1, 1/0) and converges towards the target value.
long m00 = 0, m01 = 1;
long m10 = 1, m11 = 0;
var x = target;
// int 범위 오버플로우를 방지하면서 반복 처리
// Iterate while preventing integer overflow
while (true)
{
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 nextDenominator = ai * m11 + m01;
// int 범위를 벗어나거나, 설정한 최대 분모를 초과하면 이전 단계의 최적값을 반환
// If exceeding integer range or specified maximum denominator, return previous optimal value
if (nextNumerator > int.MaxValue || nextDenominator > maxDenominator)
{
break;
}
// 행렬 업데이트
// Update matrix
m00 = m10; m01 = m11;
m10 = nextNumerator; m11 = nextDenominator;
// 오차가 거의 없으면 (정밀도 도달) 종료
// Terminate if error is negligible (precision limit reached)
if (Math.Abs(x - ai) < 1e-15) break;
// 다음 연속분수 항 준비
// Prepare next continued fraction term
x = 1.0 / (x - ai);
}
// 3. 최종 결정된 int 기반의 분자 분모
// 3. Final determined integer numerator and denominator
var finalNumerator = (int)m10;
var finalDenominator = (int)m11;
// 부호 복원
// Restore sign
if (isNegative) finalNumerator = -finalNumerator;
return (finalNumerator, finalDenominator);
+3 -1
View File
@@ -2,7 +2,7 @@ using System;
namespace ADOFAIRenderer.Util
{
public static class RegistryUtil
internal static class RegistryUtil
{
private static readonly Type? RegistryKeyType;
private static readonly Type? RegistryHiveType;
@@ -63,6 +63,7 @@ namespace ADOFAIRenderer.Util
}
}
// ReSharper disable once InconsistentNaming
public static string[]? GetHKLMSubKeyNames(string parentKeyPath)
{
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)
{
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}");
}
}
}
}
}