15 Commits
32 changed files with 705 additions and 584 deletions

No files matched your search

+3
View File
@@ -82,6 +82,9 @@
<Reference Include="Newtonsoft.Json">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="UniTask">
<HintPath>$(ADOFAI_PATH)\A Dance of Fire and Ice_Data\Managed\UniTask.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
@@ -4,7 +4,7 @@ using MelonLoader;
using MelonLoader.Utils;
using Newtonsoft.Json;
namespace ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Core.Data;
public class GlobalConfig
{
+10 -7
View File
@@ -1,5 +1,5 @@
using System.Collections;
using System.Reflection;
using Cysharp.Threading.Tasks;
using HarmonyLib;
using UnityEngine;
@@ -9,6 +9,7 @@ namespace ADOFAIRenderer.Core;
internal class PostRenderHook : MonoBehaviour
{
private FieldInfo? _camQuadMeshField;
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
{
@@ -20,15 +21,17 @@ internal class PostRenderHook : MonoBehaviour
if (RenderManager.Instance)
RenderManager.Instance.OnLastUpdate();
StartCoroutine(PostFrameCoroutine());
PostFrameAsync().Forget();
}
private IEnumerator PostFrameCoroutine()
private async UniTaskVoid PostFrameAsync()
{
yield return new WaitForEndOfFrame();
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
var val = (MeshRenderer)_camQuadMeshField!.GetValue(scrCamera.instance);
if (RenderManager.Instance)
RenderManager.Instance.OnPostFrame(val.sharedMaterial.mainTexture as RenderTexture);
if (!_camQuadMeshRenderer && scrCamera.instance)
_camQuadMeshRenderer = _camQuadMeshField?.GetValue(scrCamera.instance) as MeshRenderer;
if (RenderManager.Instance && _camQuadMeshRenderer)
RenderManager.Instance.OnPostFrame(_camQuadMeshRenderer.sharedMaterial.mainTexture as RenderTexture);
}
}
@@ -177,9 +177,9 @@ internal static class PresetManager
public static void DeletePreset(string name)
{
string dir = GetPresetsDirectory();
string safeName = GetSafeFileName(name);
string filePath = Path.Combine(dir, safeName + ".json");
var dir = GetPresetsDirectory();
var safeName = GetSafeFileName(name);
var filePath = Path.Combine(dir, safeName + ".json");
if (File.Exists(filePath))
{
+5 -12
View File
@@ -7,6 +7,7 @@ using System.Buffers;
using System.Diagnostics;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using ADOFAIRenderer.UI.Window;
@@ -364,9 +365,10 @@ internal class RenderManager : MonoBehaviour
private IEnumerator SetupPlay()
{
if (Configuration.AudioConfig.RenderAudio && Configuration.VideoConfig.Codec is Codec.VP9 or Codec.AV1 or Codec.VVC)
if (Configuration.AudioConfig.RenderAudio)
{
if (Configuration.AudioConfig.AudioSampleRate == 44100) Configuration.AudioConfig.AudioSampleRate = 48000;
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
Configuration.AudioConfig.AudioSampleRate = 48000; // most compatible value
}
for (; countdown > 0; countdown--)
@@ -463,7 +465,7 @@ internal class RenderManager : MonoBehaviour
try
{
var effectiveVideoFps = Math.Min(videoFPS, renderFPS);
var codecId = AudioCodecMatch[Configuration.VideoConfig.Codec];
var codecId = CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec];
var settings = new FFmpegNativeEncoder.EncoderSettings
{
Width = Configuration.RenderWidth,
@@ -816,13 +818,4 @@ internal class RenderManager : MonoBehaviour
Melon<RenderMod>.Logger.Error($"OnLastUpdate callback caused error: {err}");
}
}
private 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
};
}
+29
View File
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
internal static class CodecUtil
{
public static readonly Dictionary<Codec, AVCodecID> AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.H265] = AVCodecID.AV_CODEC_ID_AAC,
[Codec.VP9] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.AV1] = AVCodecID.AV_CODEC_ID_OPUS,
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
public static readonly Dictionary<AVCodecID, List<int>> AvailableSampleRate = new()
{
[AVCodecID.AV_CODEC_ID_AAC] = new List<int> { 44100, 48000, 96000 },
[AVCodecID.AV_CODEC_ID_OPUS] = new List<int> { 48000 }
};
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for double input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class DoubleLimit
{
/// <summary>
/// Validates the given double value against this limit.
/// </summary>
/// <param name="value">The parsed double value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(double value, out string errorMessage);
}
/// <summary>
/// Requires the double value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyDoubleLimit : DoubleLimit
{
public override bool Validate(double value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive number greater than 0.";
return false;
}
}
/// <summary>
/// Requires the double value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeDoubleLimit : DoubleLimit
{
// ReSharper disable MemberCanBePrivate.Global
public double? Min { get; }
public double? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeDoubleLimit(double? min, double? max)
{
Min = min;
Max = max;
}
public override bool Validate(double value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be a number between {Min.Value} and {Max.Value}."
: $"Value must be a number less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class DoubleInputValidator
{
private struct FieldEntry
{
public List<DoubleLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public DoubleInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="DoubleLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the DoubleField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public DoubleInputValidator AddLimits(string fieldName, params DoubleLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<DoubleLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<DoubleField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"DoubleField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-87
View File
@@ -1,87 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
/// <summary>
/// Binds a UI Toolkit <see cref="DropdownField"/> to an enum type.
/// Populates choices from enum names and provides typed access to the selected value.
/// </summary>
/// <typeparam name="T">The enum type to bind.</typeparam>
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
internal class EnumDropdownBinder<T> where T : struct, Enum
{
private readonly DropdownField? _dropdown;
private readonly List<T> _values;
private Action<T>? _onValueChanged;
/// <summary>
/// The currently selected enum value.
/// </summary>
public T Value => _dropdown is { index: >= 0 } && _dropdown.index < _values.Count
? _values[_dropdown.index]
: default;
/// <summary>
/// Queries a <see cref="DropdownField"/> by name from the root VisualElement,
/// populates it with enum names, and selects the default value.
/// </summary>
/// <param name="rootVisualElement">The root VisualElement loaded from the UXML bundle.</param>
/// <param name="fieldName">The name of the DropdownField in the UXML.</param>
/// <param name="defaultValue">The initial selected value. Defaults to the first enum member.</param>
public EnumDropdownBinder(VisualElement rootVisualElement, string fieldName, T? defaultValue = null)
{
_values = Enum.GetValues(typeof(T)).Cast<T>().ToList();
_dropdown = rootVisualElement.Q<DropdownField>(fieldName);
if (_dropdown == null)
{
Melon<RenderMod>.Logger.Error(
$"DropdownField '{fieldName}' not found.");
return;
}
_dropdown.choices = _values.Select(v => v.ToString()).ToList();
// Register the callback BEFORE setting index so the initial selection fires it too
_dropdown.RegisterValueChangedCallback(_ =>
{
_onValueChanged?.Invoke(Value);
});
var initial = defaultValue ?? _values[0];
_dropdown.index = _values.IndexOf(initial);
}
/// <summary>
/// Registers a callback that fires when the selection changes, providing the new enum value.
/// </summary>
public void OnValueChanged(Action<T> callback)
{
_onValueChanged = callback;
// Fire immediately with current value so the caller gets the initial state
if (_dropdown != null)
{
callback(Value);
}
}
/// <summary>
/// Sets the selected enum value and updates the dropdown index.
/// </summary>
/// <param name="value">The enum value to set.</param>
public void SetValue(T value)
{
if (_dropdown == null) return;
int idx = _values.IndexOf(value);
if (idx >= 0)
{
_dropdown.index = idx;
}
}
}
-76
View File
@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using MelonLoader;
using UnityEngine.UIElements;
namespace ADOFAIRenderer.Data.Input;
internal class IntegerInputValidator
{
private struct FieldEntry
{
public List<IntegerLimit> Limits;
}
private readonly VisualElement _rootVisualElement;
private readonly Dictionary<string, FieldEntry> _fields = new();
public IntegerInputValidator(VisualElement rootVisualElement)
{
_rootVisualElement = rootVisualElement;
}
/// <summary>
/// Registers one or more <see cref="IntegerLimit"/>s for the field with the given name.
/// Multiple limits can be added to the same field — they are all checked in order.
/// </summary>
/// <param name="fieldName">The name of the IntegerField in the UXML.</param>
/// <param name="limits">The limits to apply.</param>
public IntegerInputValidator AddLimits(string fieldName, params IntegerLimit[] limits)
{
if (!_fields.TryGetValue(fieldName, out var entry))
{
entry = new FieldEntry
{
Limits = new List<IntegerLimit>()
};
}
entry.Limits.AddRange(limits);
_fields[fieldName] = entry;
return this;
}
/// <summary>
/// Queries all registered fields from the root VisualElement and
/// registers value-changed callbacks that rollback invalid values.
/// </summary>
public void SetupValidation()
{
foreach (var (fieldName, entry) in _fields)
{
var field = _rootVisualElement.Q<IntegerField>(fieldName);
if (field == null)
{
Melon<RenderMod>.Logger.Warning(
$"IntegerField '{fieldName}' not found. Skipping validation.");
continue;
}
var limits = entry.Limits;
field.RegisterValueChangedCallback(evt =>
{
if (string.IsNullOrEmpty(field.text))
{
return;
}
if (limits.Any(limit => !limit.Validate(evt.newValue, out _)))
{
field.SetValueWithoutNotify(evt.previousValue);
}
});
}
}
}
-79
View File
@@ -1,79 +0,0 @@
namespace ADOFAIRenderer.Data;
/// <summary>
/// Base class for integer input validation limits.
/// Multiple limits can be composed on a single input field — all are checked in order.
/// </summary>
internal abstract class IntegerLimit
{
/// <summary>
/// Validates the given integer value against this limit.
/// </summary>
/// <param name="value">The parsed integer value to validate.</param>
/// <param name="errorMessage">
/// When validation fails, set to a human-readable error message.
/// When validation succeeds, set to null.
/// </param>
/// <returns>true if the value satisfies this limit; false otherwise.</returns>
public abstract bool Validate(int value, out string errorMessage);
}
/// <summary>
/// Requires the integer value to be strictly positive (> 0).
/// </summary>
internal class PositiveOnlyIntegerLimit : IntegerLimit
{
public override bool Validate(int value, out string errorMessage)
{
if (value > 0)
{
errorMessage = null!;
return true;
}
errorMessage = "Value must be a positive integer greater than 0.";
return false;
}
}
/// <summary>
/// Requires the integer value to be within an inclusive [Min, Max] range.
/// Either bound can be null to represent an unbounded side.
/// </summary>
internal class RangeIntegerLimit : IntegerLimit
{
// ReSharper disable MemberCanBePrivate.Global
public int? Min { get; }
public int? Max { get; }
// ReSharper restore MemberCanBePrivate.Global
/// <param name="min">Inclusive lower bound, or null for no lower bound.</param>
/// <param name="max">Inclusive upper bound, or null for no upper bound.</param>
public RangeIntegerLimit(int? min, int? max)
{
Min = min;
Max = max;
}
public override bool Validate(int value, out string errorMessage)
{
if (value < Min)
{
errorMessage = Max.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer greater than or equal to {Min.Value}.";
return false;
}
if (value > Max)
{
errorMessage = Min.HasValue
? $"Value must be an integer between {Min.Value} and {Max.Value}."
: $"Value must be an integer less than or equal to {Max.Value}.";
return false;
}
errorMessage = null!;
return true;
}
}
+11
View File
@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
+3 -4
View File
@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Util.FFmpeg;
using UnityEngine;
namespace ADOFAIRenderer.Data;
@@ -17,7 +16,7 @@ public class RenderConfig
public AudioEncodingConfig AudioConfig { get; set; } = new();
public AdvancedConfig AdvancedConfig { get; set; } = new();
public double DelayAfterEnd { get; set; }
public double DelayAfterEnd { get; set; } = 15;
public bool ShowJudgementText { get; set; } = false;
}
@@ -57,8 +56,8 @@ public class AudioEncodingConfig
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 44100 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 44100 : value;
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
public int AudioChannels
+36
View File
@@ -0,0 +1,36 @@
# ADOFAIRenderer API & Data Linking Exception
> **Additional Permission under GNU General Public License version 3 section 7**
---
## 1. Legal Terms (Official)
Linking this Program statically or dynamically with other modules is making a combined work based on this Program. Thus, the terms and conditions of the GNU General Public License cover the whole combination.
As a special exception, the copyright holders of this Program give you permission to combine this Program with independent modules that interact with or reference this Program solely through the public application programming interfaces (APIs), types, and data structures declared within the following namespaces:
- `ADOFAIRenderer.API`
- `ADOFAIRenderer.Data`
and to copy, convey, and distribute the resulting combined work under terms of your choice, without requiring such combined work or independent modules to be covered under the GNU General Public License version 3, provided that you also meet, for each linked independent module, the terms and conditions of the license of that module.
An **"independent module"** is a separate and distinct software module that is not derived from or based on this Program, other than referencing or communicating with the interfaces and types declared within the designated namespaces above.
This exception does not grant any permission to modify the source code of this Program itself outside the terms of the GNU General Public License version 3.
In accordance with section 7 of the GNU GPL version 3, if you modify this Program, you may extend this exception to your version of the Program, but you are not obligated to do so. If you do not wish to provide this exception in your derivative work, you may remove this exception statement from your version.
---
## 2. 조항 해설 (Korean Guide)
본 문서는 GNU General Public License 버전 3 (GPLv3) 제7조(추가적인 허가, Additional Permissions)에 따라 제공되는 공식 예외 조항입니다.
### 핵심 요약
- **외부 모드의 라이선스 자유**:
- 제3자 모드 또는 외부 프로그램이 `ADOFAIRenderer.API` 및 `ADOFAIRenderer.Data` 네임스페이스의 공개 API, 클래스, 데이터 구조를 참조·호출하여 연동하는 경우, 해당 모드는 **GPLv3의 카피레프트(전염성) 의무를 적용받지 않습니다.**
- 외부 모드 개발자는 자신의 모드를 MIT, Apache-2.0, 독점/상용 라이선스 등 원하는 라이선스로 자유롭게 배포할 수 있습니다.
- **ADOFAIRenderer 코어 보호**:
- ADOFAIRenderer 자체의 소스 코드(렌더링 파이프라인, 코어 패치, 내부 유틸리티 등)를 수정하거나 재배포하는 경우에는 본 예외 조항이 적용되지 않으며, 순수 GPLv3 라이선스 조건이 그대로 적용됩니다.
+2 -1
View File
@@ -31,5 +31,6 @@
"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.invalid_path": "Output directory path is invalid:\n{0}",
"ui.render_config.expired": "(Expired)"
}
+2 -1
View File
@@ -31,5 +31,6 @@
"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.invalid_path": "출력 디렉토리 경로가 올바르지 않습니다:\n{0}",
"ui.render_config.expired": "(만료됨)"
}
-1
View File
@@ -40,7 +40,6 @@ public static class EndscreenLanternsPatch
for (var i = 0; i < lampSequencesArray.Length; i++)
{
var item = lampSequencesArray.GetValue(i);
if (item == null) continue;
try
{
var killMethod = item.GetType().GetMethod("Kill", new[] { typeof(bool) });
+2 -2
View File
@@ -21,9 +21,9 @@ public static class PlanetRendererPatch
return;
void ApplyColor(ref PlanetColor planetColor, scrPlanet targetPlanet, Color? customColor)
void ApplyColor(ref PlanetColor planetColor, scrPlanet? targetPlanet, Color? customColor)
{
if (__instance == targetPlanet.planetRenderer && customColor != null)
if (targetPlanet != null && __instance == targetPlanet.planetRenderer && customColor != null)
planetColor = new PlanetColor(customColor.Value);
}
}
+4 -7
View File
@@ -78,10 +78,8 @@ public static class scnGamePatch
{
// Remove FFmpeg player if it was attached previously
var oldPlayer = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (oldPlayer != null)
{
Object.Destroy(oldPlayer);
}
if (oldPlayer) Object.Destroy(oldPlayer);
// Re-enable native player component
__instance.videoBG.enabled = true;
return true; // Execute original
@@ -125,8 +123,7 @@ public static class scnGamePatch
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
var player = __instance.videoBG.gameObject.GetComponent<FFmpegVideoBGPlayer>();
if (player == null)
if (!__instance.videoBG.gameObject.TryGetComponent<FFmpegVideoBGPlayer>(out var player))
{
player = __instance.videoBG.gameObject.AddComponent<FFmpegVideoBGPlayer>();
}
@@ -134,7 +131,7 @@ public static class scnGamePatch
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);
player.Init(videoPath, __instance.videoBG?.targetTexture, isLooping, targetCamera, __instance.videoBG.gameObject);
return false; // Skip original UpdateVideo
}
+3 -1
View File
@@ -90,4 +90,6 @@ The compiled mod will be output to `bin/Release/netstandard2.1/ADOFAIRenderer.dl
## 📄 License
This project is licensed under the GNU General Public License v3.0 (GPLv3). See `LICENSE` for details.
This project is licensed under the **GNU General Public License v3.0 with API & Data Linking Exception (GPLv3 with Exception)**.
- Third-party mods and external tools that reference or link to ADOFAIRenderer solely via the `ADOFAIRenderer.API` and `ADOFAIRenderer.Data` namespaces are exempt from GPLv3 copyleft requirements and may be distributed under terms of their author's choice.
- For full license terms and exception details, see [LICENSE](LICENSE) and [LICENSE-EXCEPTION.md](LICENSE-EXCEPTION.md).
+2 -1
View File
@@ -1,6 +1,7 @@
using System;
using ADOFAIRenderer;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.UI;
using FFmpeg.AutoGen;
@@ -9,7 +10,7 @@ using MelonLoader.Utils;
using UnityEngine;
using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.2.0-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;
+1 -41
View File
@@ -85,46 +85,6 @@ internal class FFmpegDownloadUI : MonoBehaviour
}
}
private static TMP_FontAsset GetNanumSquareNeoBdFontAsset()
{
TMP_FontAsset? font = null;
if (ModAssets.FontBundle)
{
try
{
font = ModAssets.FontBundle.LoadAsset<TMP_FontAsset>("NanumSquareNeoOTF-Bd SDF");
}
catch
{
// Loading failed
}
}
// Fallback processing
if (font == null)
{
try
{
font = RDString.fontData.fontTMP;
}
catch
{
// ignored
}
}
if (font == null)
{
// ReSharper disable once Unity.UnknownResource
font = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");
}
if (font) return font;
var fonts = Resources.FindObjectsOfTypeAll<TMP_FontAsset>();
if (fonts.Length > 0) font = fonts[0];
return font!;
}
private void Start()
{
// Set UI layer (5)
@@ -159,7 +119,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
var font = GetNanumSquareNeoBdFontAsset();
var font = ModAssets.NanumSquareNeoNormal;
// 2. Create modal container
var modalContainerObj = new GameObject("ModalContainer");
+1 -1
View File
@@ -89,7 +89,7 @@ internal class RendererUIManager : MonoBehaviour
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),
ObjectActiveBright = new Color(1.000f, 0.294f, 0.079f, 1f),
ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f),
ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f),
ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f),
+65 -16
View File
@@ -4,10 +4,12 @@ using System.IO;
using System.Linq;
using ADOFAIRenderer.API;
using ADOFAIRenderer.Core;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Localization;
using ADOFAIRenderer.Util;
using ADOFAIRenderer.Util.FFmpeg;
using HarmonyLib;
using MelonLoader;
using O5Kit.Control;
using O5Kit.Core;
@@ -57,9 +59,8 @@ internal static class RenderConfigWindow
{
Id = "RendererMod_RenderConfig",
Title = LocalizationManager.Get("ui.render_config.window_title"),
Size = new Vector2(580f, 480f),
Draggable = true,
Resizable = true,
Size = new Vector2(900, 600), TopBarHeight = 40,
Draggable = true, Resizable = true,
ShowCloseButton = true
});
@@ -82,7 +83,7 @@ internal static class RenderConfigWindow
private static void BuildUI(RectTransform content)
{
var ctx = RendererUIManager.Context;
var (viewport, scrollContent, scroller) =
var (_, scrollContent, _) =
O5Factory.ScrollView(ctx, content, spacing: 10f, padding: 8f);
// ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract
@@ -102,6 +103,20 @@ internal static class RenderConfigWindow
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);
@@ -110,7 +125,7 @@ internal static class RenderConfigWindow
defaultValue: 60, min: 0.01, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => renderFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(renderFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.RenderFPS = val; },
text: LocalizationManager.Get("ui.render_config.render_fps"),
id: "slider_render_fps", showFill: true
@@ -121,7 +136,7 @@ internal static class RenderConfigWindow
defaultValue: 60, min: 0.01, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All,
sliderFilter: value => videoFpsSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(videoFpsSnap, value),
inputFilter: null, onComplete: null, onChanged: val => { config.VideoFPS = val; },
text: LocalizationManager.Get("ui.render_config.video_fps"),
id: "slider_video_fps", showFill: true
@@ -132,7 +147,7 @@ internal static class RenderConfigWindow
defaultValue: 2560, min: 0, max: 15360, value: 2560,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Math.Max(value, 2), inputFilter: value => Math.Max(value, 2),
sliderFilter: value => Snap(renderWidthSnap, value), 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
@@ -143,7 +158,7 @@ internal static class RenderConfigWindow
defaultValue: 1440, min: 0, max: 8640, value: 1440,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => Math.Max(value, 2), inputFilter: value => Math.Max(value, 2),
sliderFilter: value => Snap(renderHeightSnap, value), 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
@@ -188,8 +203,12 @@ internal static class RenderConfigWindow
Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(),
"dd_video_codec",
c => config.VideoConfig.Codec = c
);
c =>
{
config.VideoConfig.Codec = c;
UpdateVideoCodec(c);
});
_bitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoLeft),
LocalizationManager.Get("ui.render_config.bitrate"),
@@ -198,6 +217,7 @@ internal static class RenderConfigWindow
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"),
@@ -216,6 +236,7 @@ internal static class RenderConfigWindow
"dd_rate_control",
m => config.VideoConfig.RateControlMode = m
);
_maxBitrateInput = CreateInputField(
ctx, CreateFieldSlot(videoRight),
LocalizationManager.Get("ui.render_config.max_bitrate"),
@@ -227,7 +248,7 @@ internal static class RenderConfigWindow
_forceSoftwareToggle = CreateToggleField(
ctx, CreateFieldSlot(videoRight), "",
true, config.VideoConfig.ForceSoftwareEncoder,
false, config.VideoConfig.ForceSoftwareEncoder,
LocalizationManager.Get("ui.render_config.force_software"),
"toggle_force_software",
val => config.AudioConfig.RenderAudio = val,
@@ -271,7 +292,7 @@ internal static class RenderConfigWindow
defaultValue: 256, min: 64, max: 384, value: 256,
format: "F0", clampMode: ClampMode.All,
sliderFilter: value => audioBitrateSnap.OrderBy(n => Math.Abs(n - value)).First(),
sliderFilter: value => Snap(audioBitrateSnap, value),
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
@@ -287,12 +308,11 @@ internal static class RenderConfigWindow
c => config.AudioConfig.AudioChannels = c
);
var sampleRates = new[] { 44100, 48000, 96000 };
_sampleRateDropdown = CreateDropdownField(
ctx, CreateFieldSlot(audioRight),
LocalizationManager.Get("ui.render_config.sample_rate"),
48000, config.AudioConfig.AudioSampleRate,
sampleRates, r => $"{r/1000.0} kHz",
CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[config.VideoConfig.Codec]], r => $"{r/1000.0} kHz",
"dd_sample_rate",
r => config.AudioConfig.AudioSampleRate = r
);
@@ -339,6 +359,7 @@ internal static class RenderConfigWindow
: _outputDirInput.Value;
FolderDialogUtil.OpenFolderBrowser(initial, selected =>
{
Melon<RenderMod>.Logger.Msg("Output Directory Selected: " + selected);
if (!string.IsNullOrWhiteSpace(selected))
{
_outputDirInput.Set(selected);
@@ -384,6 +405,7 @@ internal static class RenderConfigWindow
"dd_adv_file_extension",
e => { if (!string.IsNullOrEmpty(e)) config.AdvancedConfig.FileExtension = e; }
);
_processAudioToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft), "",
true, config.AdvancedConfig.ProcessAudio,
@@ -405,6 +427,7 @@ internal static class RenderConfigWindow
else if (string.IsNullOrWhiteSpace(val)) config.AdvancedConfig.RandomSeed = null;
}
);
_nonTileToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight), "",
false, config.AdvancedConfig.NonTile,
@@ -414,6 +437,8 @@ internal static class RenderConfigWindow
generateLabel: false
);
// TODO: Fire Planet, Ice Planet, Wind Planet Color Settings (Each Optional)
// 8. Go Render Button
O5Factory.Button(
ctx, scrollContent,
@@ -422,12 +447,36 @@ internal static class RenderConfigWindow
"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;
double Snap(double[] array, double value) =>
array.OrderBy(n => Math.Abs(n - value)).First();
void UpdateVideoCodec(Codec codec)
{
_sampleRateDropdown?.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]);
}
}
private static void EnsureDefaultConfig(RenderConfig config)
{
if (config.RenderWidth <= 0) config.RenderWidth = Screen.width > 0 ? Screen.width : 2560;
if (config.RenderHeight <= 0) config.RenderHeight = Screen.height > 0 ? Screen.height : 1440;
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;
-10
View File
@@ -1,10 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
}
+2 -3
View File
@@ -3,8 +3,8 @@ using FFmpeg.AutoGen;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
namespace ADOFAIRenderer.Util.FFmpeg
{
namespace ADOFAIRenderer.Util.FFmpeg;
/// <summary>
/// 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.
@@ -860,4 +860,3 @@ namespace ADOFAIRenderer.Util.FFmpeg
Dispose();
}
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
using System;
using System.Runtime.InteropServices;
using ADOFAIRenderer.Data;
using FFmpeg.AutoGen;
using MelonLoader;
using Unity.Collections;
@@ -1536,9 +1537,9 @@ namespace ADOFAIRenderer.Util.FFmpeg
if (_pAudioCodecContext == null)
throw new Exception("Failed to allocate audio codec context.");
var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 44100;
var sampleRate = audioSettings.SampleRate > 0 ? audioSettings.SampleRate : 48000;
var channels = audioSettings.Channels > 0 ? audioSettings.Channels : 2;
var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 128000;
var bitrate = audioSettings.Bitrate > 0 ? audioSettings.Bitrate : 256000;
// Select the first sample format supported by the encoder.
// Most encoders prefer AV_SAMPLE_FMT_FLTP (float planar).
-12
View File
@@ -1,12 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
CBR,
VBR,
CQP
}
}
+4 -4
View File
@@ -2,11 +2,12 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util.FFmpeg;
namespace ADOFAIRenderer.Util.FFmpeg
{
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum EncoderKind
internal enum EncoderKind
{
Software,
NVENC,
@@ -110,4 +111,3 @@ namespace ADOFAIRenderer.Util.FFmpeg
public override string ToString() => Name;
}
}
+508 -54
View File
@@ -2,6 +2,7 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using MelonLoader;
@@ -10,20 +11,68 @@ 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.Windows))
{
selected = OpenWindowsFolderBrowser(initialDirectory);
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
selected = OpenMacFolderBrowser(initialDirectory);
}
@@ -36,69 +85,431 @@ internal static class FolderDialogUtil
{
MelonLogger.Warning($"[FolderDialogUtil] Failed to open folder browser: {ex.Message}");
}
finally
{
Interlocked.Exchange(ref _isDialogOpen, 0);
}
if (syncContext != null)
{
syncContext.Post(_ => onSelected(selected), null);
}
else
{
onSelected(selected);
}
DispatchResult(syncContext, onSelected, selected);
});
}
}
private static string? OpenWindowsFolderBrowser(string initialDirectory)
private static void DispatchResult(SynchronizationContext? syncContext, Action<string?> onSelected, string? selected)
{
if (syncContext != null)
{
syncContext.Post(_ =>
{
try
{
var escapedDir = initialDirectory.Replace("'", "''");
var script = $@"
[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null
$f = New-Object System.Windows.Forms.FolderBrowserDialog
$f.Description = 'Select Output Directory'
if ([System.IO.Directory]::Exists('{escapedDir}')) {{
$f.SelectedPath = '{escapedDir}'
}}
$f.ShowNewFolderButton = $true
if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
[Console]::Write($f.SelectedPath)
}}";
var bytes = System.Text.Encoding.Unicode.GetBytes(script);
var base64 = Convert.ToBase64String(bytes);
var startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = $"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {base64}",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden
};
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;
onSelected(selected);
}
catch (Exception ex)
{
MelonLogger.Warning($"[FolderDialogUtil] Windows folder dialog error: {ex.Message}");
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
{
var escaped = initialDirectory.Replace("\"", "\\\"");
var script = $"POSIX path of (choose folder with prompt \"Select Output Directory\" default location \"{escaped}\")";
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
{
@@ -119,12 +530,17 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
}
catch
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
@@ -145,17 +561,55 @@ if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{
startInfo.ArgumentList.Add($"--filename={initialDirectory}");
}
try
{
using var process = Process.Start(startInfo);
if (process == null) return null;
if (process != null)
{
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
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
}
+3 -3
View File
@@ -2,12 +2,13 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Data;
using ADOFAIRenderer.Util.FFmpeg;
using MelonLoader;
using UnityEngine;
namespace ADOFAIRenderer.Util
{
namespace ADOFAIRenderer.Util;
[SuppressMessage("ReSharper", "InconsistentNaming")]
internal enum GPUVendor
{
@@ -150,4 +151,3 @@ namespace ADOFAIRenderer.Util
return prioritized;
}
}
}
+2
View File
@@ -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)