9 changed files with 269 additions and 11 deletions

No files matched your search

+20
View File
@@ -95,6 +95,22 @@ public class AdvancedConfig
/// </summary> /// </summary>
public bool NonTile { get; set; } = false; public bool NonTile { get; set; } = false;
/// <summary>
/// When true, the game's bloom (<c>VideoBloom</c>) computes its blur size as if the frame were 720 pixels high, so the
/// blur passes on the 198-pixel-high bloom buffer are the same as in a 720p frame at any render height
/// (VideoBloomPatch). Default false.
/// </summary>
public bool HighResBloomCorrection { get; set; } = false;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// When true, fixes in-game and render bugs during SetFramerate events (where objects disappear on the first frame
/// or parallax/tiles lag behind) by deferring manual camera rendering to the end of the frame after all LateUpdates.
/// Default true.
/// </summary>
public bool FixCustomFrameRate { get; set; } = true;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
/// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary> /// <summary>File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.</summary>
public string FileExtension { get; set; } = "mkv"; public string FileExtension { get; set; } = "mkv";
@@ -120,6 +136,10 @@ public class AdvancedConfig
RandomSeed = source.RandomSeed; RandomSeed = source.RandomSeed;
ProcessAudio = source.ProcessAudio; ProcessAudio = source.ProcessAudio;
NonTile = source.NonTile; NonTile = source.NonTile;
HighResBloomCorrection = source.HighResBloomCorrection;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
FixCustomFrameRate = source.FixCustomFrameRate;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
FileExtension = source.FileExtension; FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor; firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor; icePlanetColor = source.icePlanetColor;
+2
View File
@@ -42,6 +42,8 @@
"ui.render_config.file_extension": "File Extension", "ui.render_config.file_extension": "File Extension",
"ui.render_config.process_audio": "Process Audio", "ui.render_config.process_audio": "Process Audio",
"ui.render_config.non_tile": "Non-Tile Mode", "ui.render_config.non_tile": "Non-Tile Mode",
"ui.render_config.high_res_bloom_correction": "High-Resolution Bloom Correction",
"ui.render_config.fix_custom_frame_rate": "Fix Custom FPS Render Timing",
"ui.render_config.random_seed": "Random Seed (Optional)", "ui.render_config.random_seed": "Random Seed (Optional)",
"ui.render_config.validation_title": "Invalid Settings", "ui.render_config.validation_title": "Invalid Settings",
"ui.render_config.validation_error": "Please check your input values:\n- Width and Height must be integers > 0\n- Render FPS and Video FPS must be > 0\n- Delay After End must be >= 0", "ui.render_config.validation_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",
+2
View File
@@ -42,6 +42,8 @@
"ui.render_config.file_extension": "파일 확장자", "ui.render_config.file_extension": "파일 확장자",
"ui.render_config.process_audio": "오디오 처리", "ui.render_config.process_audio": "오디오 처리",
"ui.render_config.non_tile": "논타일 모드", "ui.render_config.non_tile": "논타일 모드",
"ui.render_config.high_res_bloom_correction": "고해상도 블룸 보정",
"ui.render_config.fix_custom_frame_rate": "커스텀 FPS 렌더 타이밍 보정",
"ui.render_config.random_seed": "랜덤 시드 (선택 사항)", "ui.render_config.random_seed": "랜덤 시드 (선택 사항)",
"ui.render_config.validation_title": "설정 오류", "ui.render_config.validation_title": "설정 오류",
"ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.", "ui.render_config.validation_error": "입력값을 확인해 주세요:\n- 너비와 높이는 0보다 큰 정수여야 합니다.\n- 렌더 FPS와 영상 FPS는 0보다 커야 합니다.\n- 종료 후 대기 시간은 0 이상이어야 합니다.",
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
/// <summary>
/// Fixes the in-game and render bug where objects (floors, decorations, planets) disappear on the first frame of
/// a SetFramerate event (due to camera Z=0 near clip culling) and where parallax/tiles lag behind on subsequent frames.
/// <para>
/// Defers the manual camera rendering (<c>scrCamera.UpdateCustomFrameRateScreen</c>) to the very end of the frame
/// (execution order <c>int.MaxValue - 1</c>), after all components (<c>scrCamera</c>, <c>scrFloor</c>,
/// <c>scrDecorationManager</c>, <c>scrPlanet</c>, <c>scrParallax</c>, and <c>scrController</c>) have finished their
/// <c>LateUpdate</c>.
/// </para>
/// </summary>
[HarmonyPatch]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class CustomFrameRateDeferPatch
{
private static bool _isExecutingDeferred;
private static scrCamera? _pendingCamera;
private static readonly Action<scrCamera>? UpdateCustomFrameRateScreenInvoker =
AccessTools.MethodDelegate<Action<scrCamera>>(AccessTools.Method(typeof(scrCamera), "UpdateCustomFrameRateScreen"));
/// <summary>
/// Checks whether the SetFramerate render timing fix is enabled.
/// Active if rendering session has the option enabled, or enabled by default during normal in-game play.
/// </summary>
public static bool IsEnabled
{
get
{
if (RenderManager.IsActive && RenderManager.Instance?.Configuration?.AdvancedConfig != null)
{
return RenderManager.Instance.Configuration.AdvancedConfig.FixCustomFrameRate;
}
return true;
}
}
/// <summary>
/// Intercepts manual camera render calls from SetCustomFrameRate (Update phase) or CheckUpdateFrameRateScreen
/// (LateUpdate phase). If not currently executing the deferred render pass, records the camera instance and
/// suppresses the premature render.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(scrCamera), "UpdateCustomFrameRateScreen")]
private static bool DeferManualRender(scrCamera __instance)
{
if (!IsEnabled) return true;
if (_isExecutingDeferred) return true;
_pendingCamera = __instance;
return false;
}
/// <summary>
/// Ensures that the runner component is attached to the camera when it starts.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch(typeof(scrCamera), "Start")]
private static void AttachRunner(scrCamera __instance)
{
if (!__instance) return;
if (!__instance.gameObject.GetComponent<CustomFrameRateDeferredRunner>())
{
__instance.gameObject.AddComponent<CustomFrameRateDeferredRunner>();
}
}
/// <summary>
/// Invoked at the very end of LateUpdate (Order int.MaxValue - 1). If a manual render was requested during the frame,
/// executes the camera's original UpdateCustomFrameRateScreen once with all objects fully prepared.
/// </summary>
internal static void ExecuteDeferredRender()
{
if (_pendingCamera == null) return;
var cam = _pendingCamera;
_pendingCamera = null;
if (!cam) return;
_isExecutingDeferred = true;
try
{
UpdateCustomFrameRateScreenInvoker?.Invoke(cam);
}
finally
{
_isExecutingDeferred = false;
}
}
}
/// <summary>
/// Runner component attached to scrCamera that executes deferred custom frame rate renders at the end of LateUpdate.
/// Execution order <c>int.MaxValue - 1</c> guarantees it runs after all normal game components (including
/// scrController at 400), but right before PostRenderHook at <c>int.MaxValue</c>.
/// </summary>
[DefaultExecutionOrder(int.MaxValue - 1)]
internal class CustomFrameRateDeferredRunner : MonoBehaviour
{
private void LateUpdate()
{
CustomFrameRateDeferPatch.ExecuteDeferredRender();
}
}
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
+59
View File
@@ -0,0 +1,59 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Makes the game's bloom compute its blur size as if the frame were 720 pixels high
/// ("High-Resolution Bloom Correction" option).
/// <para>
/// Patched member: <c>VideoBloom.OnRenderImage(RenderTexture source, RenderTexture destination)</c>. The game scales the
/// frame down to a bloom buffer 198 pixels high (<c>scale = 198 / source.height</c>) and sets the blur radius in bloom
/// buffer pixels to <c>KernelSize * MediumKernelScale * scale</c> (and <c>KernelSize * LargeKernelScale * scale</c>).
/// <c>BloomBlit</c> turns the radius into the number of 5-tap blur passes and the blend amount of the last pass.
/// </para>
/// <para>
/// While the render is active (<see cref="RenderManager.IsActive"/>) and <c>AdvancedConfig.HighResBloomCorrection</c> is
/// enabled, the prefix multiplies <c>KernelSize</c> by <c>source.height / 720</c> for the duration of the call, so the
/// radius equals <c>KernelSize * MediumKernelScale * 198 / 720</c> and the blur passes on the bloom buffer are the same
/// as in a 720p frame. The finalizer restores the original <c>KernelSize</c>. The bloom buffer size and the downscale
/// from the source frame keep the game's behavior.
/// </para>
/// </summary>
[HarmonyPatch(typeof(VideoBloom))]
public class VideoBloomPatch
{
/// <summary>Frame height in pixels the blur size is computed for.</summary>
private const float ReferenceHeight = 720f;
/// <summary>Prefix of <c>VideoBloom.OnRenderImage</c>.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="source">The camera frame; its height selects the multiplier.</param>
/// <param name="__state">Original <c>KernelSize</c>, or null when it was not changed.</param>
[HarmonyPrefix]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void ScaleKernelSize(VideoBloom __instance, RenderTexture source, out float? __state)
// ReSharper restore InconsistentNaming
{
__state = null;
if (!source || !RenderManager.IsActive || !RenderManager.Instance.Configuration.AdvancedConfig.HighResBloomCorrection)
return;
__state = __instance.KernelSize;
__instance.KernelSize *= source.height / ReferenceHeight;
}
/// <summary>Finalizer of <c>VideoBloom.OnRenderImage</c>: restores <c>KernelSize</c> when the prefix changed it.</summary>
/// <param name="__instance">The bloom component on the game camera.</param>
/// <param name="__state">Original <c>KernelSize</c> stored by the prefix, or null.</param>
[HarmonyFinalizer]
[HarmonyPatch("OnRenderImage")]
// ReSharper disable InconsistentNaming
public static void RestoreKernelSize(VideoBloom __instance, float? __state)
// ReSharper restore InconsistentNaming
{
if (__state.HasValue) __instance.KernelSize = __state.Value;
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ using MelonLoader.Utils;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; using Object = UnityEngine.Object;
[assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.1-INDEV", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.2-INDEV", "ADOFAI Editor Forum")]
[assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")] [assembly: MelonGame("7th Beat Games", "A Dance of Fire and Ice")]
namespace ADOFAIRenderer; namespace ADOFAIRenderer;
+50 -2
View File
@@ -139,6 +139,10 @@ internal static class RenderConfigWindow
private static O5InputField? _randomSeedInput; private static O5InputField? _randomSeedInput;
private static O5Toggle? _processAudioToggle; private static O5Toggle? _processAudioToggle;
private static O5Toggle? _nonTileToggle; private static O5Toggle? _nonTileToggle;
private static O5Toggle? _highResBloomCorrectionToggle;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
private static O5Toggle? _fixCustomFrameRateToggle;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
// Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores // Planet color overrides: the toggle enables the override, the picker holds the color. A disabled toggle stores
// null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker. // null in AdvancedConfig (no override is applied by PersistencePatch) and blocks the picker.
@@ -644,7 +648,7 @@ internal static class RenderConfigWindow
{ {
var trimmed = val?.Trim() ?? string.Empty; var trimmed = val?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(trimmed) || if (string.IsNullOrWhiteSpace(trimmed) ||
string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase)) string.Equals(trimmed, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal))
{ {
GlobalSettings.Current.OutputDirectory = null; GlobalSettings.Current.OutputDirectory = null;
} }
@@ -698,6 +702,7 @@ internal static class RenderConfigWindow
// Section 6: Advanced Settings card (RenderConfig.AdvancedConfig) // Section 6: Advanced Settings card (RenderConfig.AdvancedConfig)
// File Extension | Random Seed // File Extension | Random Seed
// Process Audio | Non-Tile Mode // Process Audio | Non-Tile Mode
// High-Resolution Bloom Correction |
// "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each) // "Planet Colors" label, then three columns: Fire | Ice | Wind (toggle + color picker each)
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// 7. Advanced Settings (Collapsible Card) // 7. Advanced Settings (Collapsible Card)
@@ -739,6 +744,23 @@ internal static class RenderConfigWindow
generateLabel: false generateLabel: false
); );
// High-Resolution Bloom Correction (AdvancedConfig.HighResBloomCorrection): the game's bloom computes its blur
// size as if the frame were 720 pixels high (VideoBloomPatch)
_highResBloomCorrectionToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.HighResBloomCorrection,
toggleTextKey: "ui.render_config.high_res_bloom_correction",
id: "toggle_high_res_bloom_correction",
onChanged: val =>
{
config.AdvancedConfig.HighResBloomCorrection = val;
MarkConfigModified();
},
generateLabel: false
);
// Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene // Random Seed (AdvancedConfig.RandomSeed): integer passed to UnityEngine.Random.InitState before the game scene
// loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value. // loads; empty text stores null (no seeding). Text that is neither empty nor an integer keeps the previous value.
_randomSeedInput = CreateInputField( _randomSeedInput = CreateInputField(
@@ -772,6 +794,24 @@ internal static class RenderConfigWindow
generateLabel: false generateLabel: false
); );
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
// Fix SetFramerate Render Timing (AdvancedConfig.FixCustomFrameRate): defers manual camera rendering to frame end
_fixCustomFrameRateToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: true,
value: config.AdvancedConfig.FixCustomFrameRate,
toggleTextKey: "ui.render_config.fix_custom_frame_rate",
id: "toggle_fix_custom_frame_rate",
onChanged: val =>
{
config.AdvancedConfig.FixCustomFrameRate = val;
MarkConfigModified();
},
generateLabel: false
);
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
// 7-1. Planet Color Settings (Optional). // 7-1. Planet Color Settings (Optional).
// For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker); // For each planet: the toggle turns the override on/off (off stores null and blocks/collapses the picker);
// the picker writes its color into the config only while the toggle is on. // the picker writes its color into the config only while the toggle is on.
@@ -980,6 +1020,10 @@ internal static class RenderConfigWindow
_randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false); _randomSeedInput?.Set(config.AdvancedConfig.RandomSeed?.ToString() ?? "", false);
_processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false); _processAudioToggle?.Set(config.AdvancedConfig.ProcessAudio, false);
_nonTileToggle?.Set(config.AdvancedConfig.NonTile, false); _nonTileToggle?.Set(config.AdvancedConfig.NonTile, false);
_highResBloomCorrectionToggle?.Set(config.AdvancedConfig.HighResBloomCorrection, false);
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
_fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false);
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
// Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is // Planet colors: toggle on when an override is stored; the picker shows the stored color or the default and is
// blocked without an override // blocked without an override
@@ -1074,7 +1118,7 @@ internal static class RenderConfigWindow
{ {
var path = _outputDirInput.Value.Trim(); var path = _outputDirInput.Value.Trim();
if (string.IsNullOrWhiteSpace(path) || if (string.IsNullOrWhiteSpace(path) ||
string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.OrdinalIgnoreCase)) string.Equals(path, GlobalSettings.DefaultOutputDirectory, StringComparison.Ordinal))
{ {
GlobalSettings.Current.OutputDirectory = null; GlobalSettings.Current.OutputDirectory = null;
} }
@@ -1161,6 +1205,10 @@ internal static class RenderConfigWindow
} }
if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value; if (_processAudioToggle != null) target.AdvancedConfig.ProcessAudio = _processAudioToggle.Value;
if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value; if (_nonTileToggle != null) target.AdvancedConfig.NonTile = _nonTileToggle.Value;
if (_highResBloomCorrectionToggle != null) target.AdvancedConfig.HighResBloomCorrection = _highResBloomCorrectionToggle.Value;
// === [MARKER: FIX_SET_FRAMERATE_BUG START] ===
if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value;
// === [MARKER: FIX_SET_FRAMERATE_BUG END] ===
// Planet Colors // Planet Colors
target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null) target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
+8
View File
@@ -1080,6 +1080,14 @@ namespace ADOFAIRenderer.Util.FFmpeg
{ {
TrySetOption("x265-params", "hrd=1:strict-cbr=1"); TrySetOption("x265-params", "hrd=1:strict-cbr=1");
} }
// NVENC: without "rc", nvenc_setup_rate_control selects CONSTQP when "qp" is set and VBR otherwise;
// bit_rate and rc_max_rate being equal does not select CBR. "rc=cbr" selects NV_ENC_PARAMS_RC_CBR.
// Reference: https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/nvenc.c (nvenc_setup_rate_control),
// https://github.com/FFmpeg/FFmpeg/blob/release/8.0/libavcodec/nvenc_h264.c ("rc" option, "cbr" constant)
else if (encoder.Kind == EncoderKind.NVENC)
{
TrySetOption("rc", "cbr");
}
} }
break; break;
+12 -8
View File
@@ -18,8 +18,8 @@ namespace ADOFAIRenderer.Util.OS;
/// </para> /// </para>
/// <para>- <b>macOS</b>: <c>osascript</c> running AppleScript <c>choose folder</c>.</para> /// <para>- <b>macOS</b>: <c>osascript</c> running AppleScript <c>choose folder</c>.</para>
/// <para> /// <para>
/// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity does not return an existing directory (not installed, /// - <b>Linux</b>: <c>zenity --file-selection --directory</c>; when zenity is not installed or fails (exit code other than 0
/// cancelled or failed), <c>kdialog --getexistingdirectory</c>. /// or 1), <c>kdialog --getexistingdirectory</c>. A cancelled zenity dialog (exit code 1) ends the selection.
/// </para> /// </para>
/// <para> /// <para>
/// The dialog runs off the main Unity thread so the game keeps rendering frames while it is open. Only one dialog can be open /// The dialog runs off the main Unity thread so the game keeps rendering frames while it is open. Only one dialog can be open
@@ -688,13 +688,13 @@ internal static class FolderDialogUtil
/// <summary> /// <summary>
/// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> and blocks until it exits. /// Runs <c>zenity --file-selection --directory --title=... [--filename=initialDirectory]</c> and blocks until it exits.
/// When zenity cannot be started, exits with a non-zero code (cancel or error) or prints a path that is not an existing /// Exit code 0 returns the printed path when it is an existing directory, and exit code 1 (cancelled or closed) returns null
/// directory, runs <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the /// without opening a second dialog. When zenity cannot be started or exits with any other code, runs <c>kdialog --getexistingdirectory [initialDirectory] --title ...</c> instead. Both commands print the
/// selected path to standard output. /// selected path to standard output.
/// </summary> /// </summary>
/// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param> /// <param name="initialDirectory">Folder shown first; used only when it is an existing directory.</param>
/// <returns> /// <returns>
/// The selected existing directory; null when both dialogs return nothing, when kdialog cannot be started, or on an /// The selected existing directory; null when the dialog is cancelled, when kdialog cannot be started, or on an
/// exception (logged as a warning). /// exception (logged as a warning).
/// </returns> /// </returns>
private static string? OpenLinuxFolderBrowser(string initialDirectory) private static string? OpenLinuxFolderBrowser(string initialDirectory)
@@ -725,15 +725,19 @@ internal static class FolderDialogUtil
var output = process.StandardOutput.ReadToEnd().Trim(); var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(); process.WaitForExit();
if (process.ExitCode == 0 && !string.IsNullOrEmpty(output) && Directory.Exists(output)) if (process.ExitCode == 0)
{ {
return output; return !string.IsNullOrEmpty(output) && Directory.Exists(output) ? output : null;
} }
// zenity exit code 1: the dialog was cancelled or closed.
// Reference: https://gitlab.gnome.org/GNOME/zenity/-/raw/master/src/util.c (ZENITY_OK_DEFAULT 0, ZENITY_CANCEL_DEFAULT 1, ZENITY_ESC_DEFAULT 1, ZENITY_ERROR_DEFAULT -1).
if (process.ExitCode == 1) return null;
} }
} }
catch catch
{ {
// Fallback to kdialog if zenity fails to start // Falls back to kdialog when zenity is not installed or cannot be started.
} }
var kdialogInfo = new ProcessStartInfo var kdialogInfo = new ProcessStartInfo