43 Commits
Author SHA256 Message Date
copecone 4179ed6696 Add Segmented Control UI for Output Mode Option 2026-10-22 11:00:05 +09:00
copecone 5efe1d0dd2 Change Disable Tile Culling Default to False 2026-10-19 21:10:55 +09:00
copecone 71e86c0262 Adjust UI Layout 2026-10-19 21:10:33 +09:00
copecone f72a8acbac Upgrade Version 2026-10-19 20:06:47 +09:00
copecone d90d06c095 Add Customizable Output Filename Formatting 2026-10-19 18:14:02 +09:00
copecone b66d774cb9 Add Hide Failed Decorations Option 2026-10-19 18:14:02 +09:00
copecone d1ba5d5c18 Add Disable Tile Culling Option 2026-10-19 18:14:02 +09:00
copecone 7c6210d2c4 Cleanup Fix Custom FPS Render Timing Marker Comments 2026-10-19 18:14:01 +09:00
copecone 9e3941e3b5 Add Fix Custom FPS Render Timing Option 2026-10-18 01:01:30 +09:00
copecone 78ea42b368 Add High-Resolution Bloom Correction Option 2026-10-16 22:14:10 +09:00
copecone 064bd816e8 Fix NVENC Falling Back To VBR In CBR Mode 2026-10-16 16:28:49 +09:00
copecone 46fc00df9c Fix Case Insensitive Default Output Directory Match 2026-10-16 16:11:08 +09:00
copecone 769f368f66 Fix Linux Folder Dialog Reopening After Cancel 2026-10-16 16:11:03 +09:00
copecone ed6f990d3b Upgrade Version 2026-10-15 22:57:17 +09:00
copecone 151c91bd86 Merge pull request 'Refactor Comments' (#6) from refactor/comments into main
Reviewed-on: #6
2026-10-15 19:02:22 +09:00
copeconeandClaude Sonnet 5.5 961f78a332 Normalize Comment Terminology
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 18:29:22 +09:00
copecone 0fc01a84de Document UI And Localization 2026-10-15 16:17:14 +09:00
copecone a389283d33 Document OS Utilities 2026-10-15 16:17:13 +09:00
copecone d2bd655118 Document Harmony Patches 2026-10-15 16:17:13 +09:00
copecone 8deb64fd4d Document Render Core And API 2026-10-15 16:17:13 +09:00
copecone 354c42fdd9 Document FFmpeg Encoder And Decoder 2026-10-15 16:17:12 +09:00
copecone 19d0e258ac Document Audio Pipeline 2026-10-15 16:17:12 +09:00
copecone 7bb47e4974 Fix Zero Duration Of Last Video Frame 2026-10-15 11:27:58 +09:00
copecone fab189ff3b Merge pull request 'Rewrite Audio' (#5) from feature/audio_rewrite into main
Reviewed-on: #5
2026-10-15 01:29:30 +09:00
copecone abb6f729e4 Localize FFmpeg Download And Driver Warning Dialogs 2026-10-14 22:55:15 +09:00
copecone c56175bbc8 Fix Custom Preset Label Handling On Language Change 2026-10-14 22:55:00 +09:00
copecone ab8164a735 Deactivate Mapped Effect Sources During Render 2026-10-14 22:18:35 +09:00
copecone 69d098e04c Emulate AudioSource Playback State For Effect Sounds 2026-10-14 21:55:13 +09:00
copecone 8e0639aafa Fix Warning Dialogs Hidden Behind Render Config Window 2026-10-14 20:46:02 +09:00
copecone 1fdee67f9d Validate Even Render Resolution Before Starting 2026-10-14 20:20:16 +09:00
copecone a4dba82729 Fix Language Change And Name Collision In Presets 2026-10-14 20:20:06 +09:00
copecone 5136127e39 Round Voice Start Times Down To Output Samples 2026-10-13 19:20:59 +09:00
copecone 455436e744 Fix Audio Cleanup On Render Abort 2026-10-13 15:37:16 +09:00
copecone 89f15eca1e Read Spectrum Samples From Clip Store 2026-10-13 15:20:04 +09:00
copecone d0f6fa9ecd Fix Repeated Effect Voice List Scans 2026-10-13 12:02:48 +09:00
copecone 5055e4dfbb Apply AudioSource Playback Control To Voices 2026-10-13 11:57:07 +09:00
copecone cb0fd1bd96 Switch Render Audio To Voice Mixer 2026-10-13 11:30:57 +09:00
copecone 3d2ffe9dfe Fix MP3 Header Read While Game Streams Song 2026-10-13 11:30:42 +09:00
copecone a21d86d3a6 Add Voice Mixer With Timed Changes 2026-10-13 09:43:07 +09:00
copecone 7ea2d8fe3f Resolve Audio Clips From Level Files 2026-10-13 02:00:18 +09:00
copecone 35606d6e58 Match Game MP3 Decoding Timing 2026-10-13 01:59:53 +09:00
copecone 82776fc572 Add FFmpeg Audio File Reader 2026-10-13 00:30:07 +09:00
copecone 0a12c12ff5 Add Chunked Audio Clip Store 2026-10-13 00:30:02 +09:00
38 changed files with 1501 additions and 155 deletions

No files matched your search

+3 -3
View File
@@ -180,7 +180,7 @@ public class RenderSession
/// Sets the custom condition function that determines when the render session should end. /// Sets the custom condition function that determines when the render session should end.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The condition is polled on the main thread once per frame from the start of the countdown until the render ends. /// The condition is polled on the main Unity thread once per frame from the start of the countdown until the render ends.
/// When it first returns <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of /// When it first returns <c>true</c>, the render continues for <see cref="RenderConfig.DelayAfterEnd"/> seconds of
/// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is /// render time and then finishes successfully; it is not required to stay <c>true</c>. The default condition is
/// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again /// that the level is cleared (<c>scrController.instance.state == States.Won</c>). Calling this method again
@@ -231,7 +231,7 @@ public class RenderSession
/// Adds a callback action to be invoked when the render session completes or fails. /// Adds a callback action to be invoked when the render session completes or fails.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Called once per render on the main thread: on success after the output file has been finished, on cancel or /// Called once per render on the main Unity thread: on success after the output file has been finished, on cancel or
/// failure after the unfinished file has been deleted, and before the game returns to the level editor. /// failure after the unfinished file has been deleted, and before the game returns to the level editor.
/// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in /// It is not called when the render stops because the game quits. Multiple calls combine the callbacks (invoked in
/// the order added). The combined callback replaces the default one, which shows the desktop notification. /// the order added). The combined callback replaces the default one, which shows the desktop notification.
@@ -249,7 +249,7 @@ public class RenderSession
/// Adds a callback action to be invoked on the last update of each frame in the render session. /// Adds a callback action to be invoked on the last update of each frame in the render session.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Called on the main thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene /// Called on the main Unity thread every Unity frame after all other <c>LateUpdate</c> calls, from the moment the game scene
/// of the render has loaded until the render ends (including the countdown, before capture starts). /// of the render has loaded until the render ends (including the countdown, before capture starts).
/// Multiple calls combine the callbacks. An exception thrown by a callback is logged. /// Multiple calls combine the callbacks. An exception thrown by a callback is logged.
/// </remarks> /// </remarks>
+17
View File
@@ -17,6 +17,11 @@ internal class GlobalConfig
/// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory). /// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
/// </summary> /// </summary>
public string? OutputDirectory { get; set; } public string? OutputDirectory { get; set; }
/// <summary>
/// File name format template chosen in the render configuration window; null or empty selects
/// GlobalSettings.DefaultFileNameFormat ("{date} {time}").
/// </summary>
public string? FileNameFormat { get; set; }
/// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary> /// <summary>Name of the preset last selected or saved in the render configuration window; selected again when it opens.</summary>
public string? LastPresetName { get; set; } public string? LastPresetName { get; set; }
/// <summary> /// <summary>
@@ -72,6 +77,18 @@ internal static class GlobalSettings
} }
} }
/// <summary>Default file name format template: "{date} {time}".</summary>
public const string DefaultFileNameFormat = "{date} {time}";
/// <summary>
/// File name format used for renders without an explicit output path: Current.FileNameFormat when it is set,
/// otherwise <see cref="DefaultFileNameFormat"/>.
/// </summary>
public static string EffectiveFileNameFormat =>
string.IsNullOrWhiteSpace(Current.FileNameFormat)
? DefaultFileNameFormat
: Current.FileNameFormat;
static GlobalSettings() static GlobalSettings()
{ {
Load(); Load();
+1 -1
View File
@@ -39,7 +39,7 @@ public class RenderPreset
public static class PresetManager public static class PresetManager
{ {
/// <summary> /// <summary>
/// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 fps video. /// Built-in presets (H.264 CBR, 48 kHz stereo AAC at 256 kbps, mkv): 1080p, 1440p and 2160p at 60 FPS video.
/// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60. /// "(4x)" presets render 240 game frames per second (4 per video frame); "(1x)" renders 60.
/// Video bitrates are in kbps. /// Video bitrates are in kbps.
/// </summary> /// </summary>
+36 -17
View File
@@ -92,7 +92,7 @@ internal static class RenderContext
} }
}; };
/// <summary> /// <summary>
/// Called once on the main thread when the render finishes (success) or is cancelled or fails. /// Called once on the main Unity thread when the render finishes (success) or is cancelled or fails.
/// A session callback replaces the default one, so no notification is shown when a session sets OnComplete. /// A session callback replaces the default one, so no notification is shown when a session sets OnComplete.
/// Exceptions are caught and logged (RenderManager.TriggerOnComplete). /// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
/// </summary> /// </summary>
@@ -101,7 +101,7 @@ internal static class RenderContext
private static readonly Action OnLastUpdateDefault = () => { }; private static readonly Action OnLastUpdateDefault = () => { };
/// <summary> /// <summary>
/// Called on the main thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the /// Called on the main Unity thread every frame after all other LateUpdate calls, through PostRenderHook (attached when the
/// game scene of the render has loaded) while the stage is Load or later. /// game scene of the render has loaded) while the stage is Load or later.
/// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate). /// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
/// </summary> /// </summary>
@@ -154,7 +154,7 @@ internal class RenderManager : MonoBehaviour
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render; public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
/// <summary> /// <summary>
/// Maximum number of frames in ImageQueue. The main thread waits (throttles) while the queue holds more frames. /// Maximum number of frames in ImageQueue. The main Unity thread waits (throttles) while the queue holds more frames.
/// </summary> /// </summary>
public const int MaxQueuedFrames = 64; public const int MaxQueuedFrames = 64;
@@ -179,7 +179,7 @@ internal class RenderManager : MonoBehaviour
/// <summary> /// <summary>
/// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked), /// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
/// read with Interlocked.Read by the main thread (result, debug monitor). Reset in SetupPlay. /// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay.
/// </summary> /// </summary>
public long framesSent; public long framesSent;
@@ -276,14 +276,14 @@ internal class RenderManager : MonoBehaviour
/// <summary> /// <summary>
/// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render /// Configuration of the current render: a copy made by RenderSession.Start, so values adjusted during the render
/// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main thread, the render /// (the audio sample rate in SetupPlay) do not change the caller's object. Read by the main Unity thread, the render
/// thread (audio settings) and the patches. Null until the first render starts. /// thread (audio settings) and the patches. Null until the first render starts.
/// </summary> /// </summary>
public RenderConfig Configuration = null!; public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields) // Render Sessions State (OOP Instance Fields)
/// <summary>Current stage of the render. Written on the main thread; read by the render thread in TryDequeueNextFrame.</summary> /// <summary>Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame.</summary>
public RenderStage stage = RenderStage.None; public RenderStage stage = RenderStage.None;
/// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary> /// <summary>Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.</summary>
@@ -303,7 +303,7 @@ internal class RenderManager : MonoBehaviour
/// <summary> /// <summary>
/// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs. /// Offline mixer of the song and sound effects of this render; null when audio is not rendered or no render runs.
/// Created in SetupPlay. Commands are added on the main thread (OnPostFrame, audio patches), mixing runs on the /// Created in SetupPlay. Commands are added on the main Unity thread (OnPostFrame, audio patches), mixing runs on the
/// render thread (WriteAudioForFrame); the mixer synchronizes the two internally. /// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
/// </summary> /// </summary>
public OfflineAudioMixer? AudioMixer { get; private set; } public OfflineAudioMixer? AudioMixer { get; private set; }
@@ -392,10 +392,10 @@ internal class RenderManager : MonoBehaviour
public long lastVideoFrameIndex = -1; public long lastVideoFrameIndex = -1;
/// <summary> /// <summary>
/// Video frame index of the last capture request (-1 before the first). Written on the main thread, read by the /// Video frame index of the last capture request (-1 before the first). Written on the main Unity thread, read by the
/// render thread to know when every requested frame has been sent after endRequested. /// render thread to know when every requested frame has been sent after endRequested.
/// </summary> /// </summary>
// long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread) // long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread)
public long lastRequestedVideoFrameIndex = -1; public long lastRequestedVideoFrameIndex = -1;
/// <summary> /// <summary>
@@ -404,10 +404,10 @@ internal class RenderManager : MonoBehaviour
/// </summary> /// </summary>
public int readbackFailureCount; public int readbackFailureCount;
/// <summary>Number of frames on which the main thread had to wait for the render thread (throttle loop entries).</summary> /// <summary>Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries).</summary>
public int throttleCount; public int throttleCount;
/// <summary>True while the main thread is inside the throttle wait of OnPostFrame.</summary> /// <summary>True while the main Unity thread is inside the throttle wait of OnPostFrame.</summary>
public bool isThrottling; public bool isThrottling;
/// <summary> /// <summary>
@@ -438,7 +438,7 @@ internal class RenderManager : MonoBehaviour
/// <summary> /// <summary>
/// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order; /// Captured frames waiting for the render thread, keyed by video frame index. Readbacks may complete in any order;
/// the render thread takes them in index order (passCount). Every access locks the list itself. /// the render thread takes them in index order (passCount). Every access locks the list itself.
/// Frames are added by readback completions (main thread) and removed by the render thread, or released by the /// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the
/// cleanup in StopFFmpeg / RestoreRenderEnvironment. /// cleanup in StopFFmpeg / RestoreRenderEnvironment.
/// </summary> /// </summary>
internal SortedList<long, CapturedFrame> ImageQueue = new(); internal SortedList<long, CapturedFrame> ImageQueue = new();
@@ -788,7 +788,7 @@ internal class RenderManager : MonoBehaviour
if (!texture) return; if (!texture) return;
// [Backpressure Throttling] // [Backpressure Throttling]
// The main thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread // The main Unity thread waits while ImageQueue holds more than QueueFrameLimit frames. Only the render thread
// removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop // removes frames, and it signals FrameDequeuedEvent for each one. The wait has a timeout so that the loop
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds). // re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
isThrottling = false; isThrottling = false;
@@ -940,6 +940,14 @@ internal class RenderManager : MonoBehaviour
// Cancelled or failed during the countdown: do not start the encoder // Cancelled or failed during the countdown: do not start the encoder
if (stage != RenderStage.OnScene) yield break; if (stage != RenderStage.OnScene) yield break;
if (Configuration.AdvancedConfig.DisableTileCulling && scrLevelMaker.instance)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
frameCount = 0; framesSent = 0; frameCount = 0; framesSent = 0;
_renderStopwatch.Restart(); _renderStopwatch.Restart();
@@ -1045,9 +1053,20 @@ internal class RenderManager : MonoBehaviour
var startTime = DateTime.Now; var startTime = DateTime.Now;
if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath; if (RenderContext.OutputPath != null) CurrentOutPath = RenderContext.OutputPath;
else { else {
var fileName = startTime.ToString("yyyy-MM-dd HH-mm-ss") + var format = GlobalSettings.EffectiveFileNameFormat;
$".{Configuration.AdvancedConfig.FileExtension.TrimStart('.').ToLower()}"; var ext = Configuration.AdvancedConfig.FileExtension;
CurrentOutPath = Path.Combine(GlobalSettings.EffectiveOutputDirectory, fileName); var baseName = RenderFileNameUtil.FormatFileName(
format,
startTime,
Configuration,
GlobalSettings.Current.LastPresetName,
levelPath
);
CurrentOutPath = RenderFileNameUtil.GetUniqueFilePath(
GlobalSettings.EffectiveOutputDirectory,
baseName,
ext
);
} }
FFmpegNativeEncoder? videoFFmpeg = null; FFmpegNativeEncoder? videoFFmpeg = null;
@@ -1249,7 +1268,7 @@ internal class RenderManager : MonoBehaviour
/// <summary> /// <summary>
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder. /// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
/// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder /// The sample count per frame comes from SyncCalculator (sample rate / effective video FPS, with the remainder
/// carried to later frames). Mixing waits until the main thread has committed the audio commands up to that time. /// carried to later frames). Mixing waits until the main Unity thread has committed the audio commands up to that time.
/// Does nothing when audio rendering is off. Render thread. /// Does nothing when audio rendering is off. Render thread.
/// </summary> /// </summary>
/// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception> /// <exception cref="OperationCanceledException">The mixer was cancelled (render aborted) while waiting.</exception>
+39
View File
@@ -11,6 +11,11 @@ namespace ADOFAIRenderer.Data;
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")] [SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig public class RenderConfig
{ {
public enum RenderOutputMode
{
Video, PNGSequence
}
/// <summary> /// <summary>
/// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame). /// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
/// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured /// Values above <see cref="VideoFPS"/> render extra frames between video frames, which are not captured
@@ -44,6 +49,9 @@ public class RenderConfig
/// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary> /// <summary>Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.</summary>
public bool ShowJudgementText { get; set; } = false; public bool ShowJudgementText { get; set; } = false;
/// <summary>Determines whether to export the rendered output as an encoded video or a PNG image sequence.</summary>
public RenderOutputMode OutputMode { get; set; } = RenderOutputMode.Video;
/// <summary> /// <summary>
/// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value /// Copies every value of <paramref name="source"/> into this object; the nested configs are copied value by value
/// into the existing nested objects. Does nothing when <paramref name="source"/> is null. /// into the existing nested objects. Does nothing when <paramref name="source"/> is null.
@@ -57,6 +65,7 @@ public class RenderConfig
RenderHeight = source.RenderHeight; RenderHeight = source.RenderHeight;
DelayAfterEnd = source.DelayAfterEnd; DelayAfterEnd = source.DelayAfterEnd;
ShowJudgementText = source.ShowJudgementText; ShowJudgementText = source.ShowJudgementText;
OutputMode = source.OutputMode;
VideoConfig.CopyFrom(source.VideoConfig); VideoConfig.CopyFrom(source.VideoConfig);
AudioConfig.CopyFrom(source.AudioConfig); AudioConfig.CopyFrom(source.AudioConfig);
@@ -95,6 +104,32 @@ 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;
/// <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;
/// <summary>
/// When true, prevents floor tiles from being disabled when outside the camera frustum (scrFloor.OnBecameInvisible),
/// eliminating the 1-frame update lag/glitch when tiles enter the screen. Default false.
/// </summary>
public bool DisableTileCulling { get; set; } = false;
/// <summary>
/// When true, hides placeholder decorations (default tree-in-circle sprite) that failed to load or are empty.
/// Default false.
/// </summary>
public bool HideFailedDecorations { get; set; } = false;
/// <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 +155,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;
FixCustomFrameRate = source.FixCustomFrameRate;
DisableTileCulling = source.DisableTileCulling;
HideFailedDecorations = source.HideFailedDecorations;
FileExtension = source.FileExtension; FileExtension = source.FileExtension;
firePlanetColor = source.firePlanetColor; firePlanetColor = source.firePlanetColor;
icePlanetColor = source.icePlanetColor; icePlanetColor = source.icePlanetColor;
+10
View File
@@ -36,12 +36,22 @@
"ui.render_config.sample_rate": "Sample Rate (Hz)", "ui.render_config.sample_rate": "Sample Rate (Hz)",
"ui.render_config.channels": "Audio Channels", "ui.render_config.channels": "Audio Channels",
"ui.render_config.audio_bitrate": "Audio Bitrate (kbps)", "ui.render_config.audio_bitrate": "Audio Bitrate (kbps)",
"ui.render_config.output_mode": "Output Mode",
"ui.render_config.output_mode_video": "Video",
"ui.render_config.output_mode_png_sequence": "PNG Sequence",
"ui.render_config.output_dir": "Output Directory", "ui.render_config.output_dir": "Output Directory",
"ui.render_config.browse": "Browse...", "ui.render_config.browse": "Browse...",
"ui.render_config.reset_default": "Reset to Default", "ui.render_config.reset_default": "Reset to Default",
"ui.render_config.filename_format": "Filename Format",
"ui.render_config.filename_format_hint": "Tags: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
"ui.render_config.reset_filename_default": "Reset to Default",
"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.disable_tile_culling": "Disable Tile Culling",
"ui.render_config.hide_failed_decorations": "Hide Failed Decorations",
"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",
+10
View File
@@ -36,12 +36,22 @@
"ui.render_config.sample_rate": "샘플레이트 (Hz)", "ui.render_config.sample_rate": "샘플레이트 (Hz)",
"ui.render_config.channels": "오디오 채널", "ui.render_config.channels": "오디오 채널",
"ui.render_config.audio_bitrate": "오디오 비트레이트 (kbps)", "ui.render_config.audio_bitrate": "오디오 비트레이트 (kbps)",
"ui.render_config.output_mode": "출력 모드",
"ui.render_config.output_mode_video": "동영상",
"ui.render_config.output_mode_png_sequence": "PNG 시퀀스",
"ui.render_config.output_dir": "출력 디렉토리", "ui.render_config.output_dir": "출력 디렉토리",
"ui.render_config.browse": "찾아보기...", "ui.render_config.browse": "찾아보기...",
"ui.render_config.reset_default": "기본값으로 초기화", "ui.render_config.reset_default": "기본값으로 초기화",
"ui.render_config.filename_format": "파일 이름 형식",
"ui.render_config.filename_format_hint": "태그: {song}, {artist}, {author}, {date}, {time}, {resolution}, {fps}, {preset}",
"ui.render_config.reset_filename_default": "기본값으로 초기화",
"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.disable_tile_culling": "타일 컬링 비활성화",
"ui.render_config.hide_failed_decorations": "로딩 실패한 장식 숨기기",
"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 이상이어야 합니다.",
+1 -1
View File
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Localization;
/// </para> /// </para>
/// <para> /// <para>
/// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself. /// Lookup order in <see cref="Get(string)"/>: active locale, then English, then the key itself.
/// Intended for the Unity main thread; the dictionaries are filled once during initialization and only read afterwards. /// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards.
/// </para> /// </para>
/// </summary> /// </summary>
internal static class LocalizationManager internal static class LocalizationManager
+3 -3
View File
@@ -30,7 +30,7 @@ public static class AudioManagerFindOrLoadAudioClipExternalPatch
} }
/// <summary> /// <summary>
/// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled effect sound (hit sounds, PlaySound /// AudioManager._Play (private; AudioManager.Play forwards to it): registers each scheduled sound effect (hit sounds, PlaySound
/// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it. /// events, hold sounds without an end time) as an effect voice of the offline mixer and maps the returned AudioSource to it.
/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the /// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on. The original always runs: it creates the
/// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets /// AudioSource the game keeps using (its PlayScheduled call is blocked during rendering by another patch), and the game sets
@@ -46,7 +46,7 @@ public static class AudioManagerPlayPatch
{ {
if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio) if (RenderManager.IsCapturingActive && RenderManager.Instance.Configuration.AudioConfig.RenderAudio)
{ {
// Cache the clip on the main thread // Cache the clip on the main Unity thread
RenderManager.Instance.AudioMixer?.CacheClip(snd); RenderManager.Instance.AudioMixer?.CacheClip(snd);
} }
return true; // Let the original method execute to prevent NullReferenceException return true; // Let the original method execute to prevent NullReferenceException
@@ -104,7 +104,7 @@ public static class PlayWithEndTimePatch
var mixer = RenderManager.Instance.AudioMixer; var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null) if (mixer != null)
{ {
// Cache the clip on the main thread // Cache the clip on the main Unity thread
mixer.CacheClip(snd); mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start // Convert absolute DSP times to relative times in seconds from render start
+2 -2
View File
@@ -18,7 +18,7 @@ namespace ADOFAIRenderer.Patch.Engine;
/// (game and mod scripts). /// (game and mod scripts).
/// </para> /// </para>
/// <para> /// <para>
/// All prefixes run on the thread that calls the getter (normally the Unity main thread) and read <c>RenderManager</c> state /// All prefixes run on the thread that calls the getter (normally the main Unity thread) and read <c>RenderManager</c> state
/// without locks. /// without locks.
/// </para> /// </para>
/// </summary> /// </summary>
@@ -264,7 +264,7 @@ public static class RenderEnvironmentPatches
/// that loads an external audio file of the level into <c>audioLib</c>): from stage Load with /// that loads an external audio file of the level into <c>audioLib</c>): from stage Load with
/// <c>AdvancedConfig.ProcessAudio</c> on, forces <c>stream</c> to false. The game passes <c>stream</c> to /// <c>AdvancedConfig.ProcessAudio</c> on, forces <c>stream</c> to false. The game passes <c>stream</c> to
/// <c>DownloadHandlerAudioClip.streamAudio</c> for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming /// <c>DownloadHandlerAudioClip.streamAudio</c> for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming
/// clips; the mp3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>. /// clips; the MP3 path uses <c>mp3Streaming</c> and ignores <c>stream</c>.
/// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html /// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html
/// </summary> /// </summary>
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))] [HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
+113
View File
@@ -0,0 +1,113 @@
using System;
using System.Diagnostics.CodeAnalysis;
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <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();
}
}
+93
View File
@@ -0,0 +1,93 @@
using ADOFAIRenderer.Core;
using HarmonyLib;
using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
/// <summary>
/// Patches decoration rendering to hide decorations that failed to load (or are empty) and fell back to the default
/// placeholder sprite (tree inside a circle) during a render when <c>AdvancedConfig.HideFailedDecorations</c> is enabled.
/// </summary>
public static class DecorationPatch
{
/// <summary>
/// True while a render is loading or active and <c>HideFailedDecorations</c> is enabled in the configuration.
/// </summary>
public static bool IsEnabled =>
RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.HideFailedDecorations == true;
/// <summary>
/// Checks whether the given sprite is the default placeholder or not-found sprite of the decoration manager.
/// </summary>
public static bool IsPlaceholderSprite(Sprite? sprite, scrDecorationManager? manager = null)
{
if (!sprite) return false;
manager ??= scrDecorationManager.instance;
if (!manager) return false;
return sprite == manager.defaultSprite || (manager.notFoundSprite && sprite == manager.notFoundSprite);
}
/// <summary>
/// Sweeps all visual decorations in <c>scrDecorationManager.instance.allDecorations</c> and clears any placeholder sprites.
/// Called from <see cref="scnGamePatch.ProcessFloors"/> when <c>scnGame.Play</c> runs.
/// </summary>
public static void ProcessDecorations()
{
if (!IsEnabled) return;
var manager = scrDecorationManager.instance;
if (!manager || manager.allDecorations == null) return;
foreach (var dec in manager.allDecorations)
{
if (dec is scrVisualDecoration visualDec && visualDec.spriteRenderer)
{
if (IsPlaceholderSprite(visualDec.spriteRenderer.sprite, manager))
{
visualDec.SetSprite((Sprite)null!);
}
}
}
}
/// <summary>
/// Prefix of <c>scrVisualDecoration.SetSprite(Sprite)</c>: replaces placeholder default/not-found sprites with null
/// when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrVisualDecoration), nameof(scrVisualDecoration.SetSprite), typeof(Sprite))]
// ReSharper disable once InconsistentNaming
public static class VisualDecorationSetSpritePatch
{
[HarmonyPrefix]
public static void Prefix(scrVisualDecoration __instance, ref Sprite? sprite)
{
if (!IsEnabled || !sprite) return;
if (IsPlaceholderSprite(sprite, __instance.manager))
{
sprite = null;
}
}
}
/// <summary>
/// Prefix of <c>scrDecorationManager.ShowEmptyDecorations(bool)</c>: suppresses showing placeholder default sprites
/// during render when <see cref="IsEnabled"/> is true.
/// </summary>
[HarmonyPatch(typeof(scrDecorationManager), nameof(scrDecorationManager.ShowEmptyDecorations))]
public static class ShowEmptyDecorationsPatch
{
[HarmonyPrefix]
public static bool Prefix(bool show)
{
if (show && IsEnabled)
{
return false;
}
return true;
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ namespace ADOFAIRenderer.Patch.Game;
/// <c>Persistence.language</c> (it returns immediately when already initialized); and <c>RDString.ChangeLanguage(SystemLanguage)</c>, /// <c>Persistence.language</c> (it returns immediately when already initialized); and <c>RDString.ChangeLanguage(SystemLanguage)</c>,
/// which stores the language in <c>Persistence.language</c>, clears the initialized flag and calls Setup again. /// which stores the language in <c>Persistence.language</c>, clears the initialized flag and calls Setup again.
/// </para> /// </para>
/// <para>Always active (independent of the render stage). Runs on the Unity main thread.</para> /// <para>Always active (independent of the render stage). Runs on the main Unity thread.</para>
/// </summary> /// </summary>
[HarmonyPatch(typeof(RDString))] [HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch internal static class LanguageSyncPatch
+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
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.Patch.Game;
/// </para> /// </para>
/// <para> /// <para>
/// Always active except when the mod features are disabled (<see cref="RenderMod.IsFeatureDisabled"/>, set from the /// Always active except when the mod features are disabled (<see cref="RenderMod.IsFeatureDisabled"/>, set from the
/// driver warning dialog). Runs on the Unity main thread each time the editor scene starts. /// driver warning dialog). Runs on the main Unity thread each time the editor scene starts.
/// </para> /// </para>
/// </summary> /// </summary>
[HarmonyPatch(typeof(scnEditor))] [HarmonyPatch(typeof(scnEditor))]
+15 -2
View File
@@ -30,8 +30,21 @@ public static class scnGamePatch
[HarmonyPatch("Play")] [HarmonyPatch("Play")]
public static void ProcessFloors() public static void ProcessFloors()
{ {
var nonTile = RenderManager.Instance.stage >= RenderStage.Load && if (RenderManager.Instance &&
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
foreach (var floor in scrLevelMaker.instance.listFloors)
{
if (floor) floor.enabled = true;
}
}
DecorationPatch.ProcessDecorations();
var nonTile = RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration?.AdvancedConfig?.NonTile == true;
if (!nonTile) return; if (!nonTile) return;
foreach (var floor in scrLevelMaker.instance.listFloors) foreach (var floor in scrLevelMaker.instance.listFloors)
+1 -1
View File
@@ -8,7 +8,7 @@ namespace ADOFAIRenderer.Patch.Game;
/// already off. /// already off.
/// <para> /// <para>
/// Patched member: <c>scrCamera.SetCustomFrameRate(bool enable, float frame)</c> (level effect that updates the screen at a /// Patched member: <c>scrCamera.SetCustomFrameRate(bool enable, float frame)</c> (level effect that updates the screen at a
/// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> fps. /// lower frame rate). Enabling assigns a new RenderTexture to the camera quad material and refreshes it at <c>frame</c> FPS.
/// Disabling calls <c>Release()</c> on the quad material's current <c>mainTexture</c> and assigns <c>camRT</c> back. When /// Disabling calls <c>Release()</c> on the quad material's current <c>mainTexture</c> and assigns <c>camRT</c> back. When
/// the effect was never enabled, <c>mainTexture</c> is <c>camRT</c> itself, so the original releases the live camera /// the effect was never enabled, <c>mainTexture</c> is <c>camRT</c> itself, so the original releases the live camera
/// render texture. /// render texture.
+37
View File
@@ -33,4 +33,41 @@ public class scrFloorPatch
RenderManager.Instance.Configuration.AdvancedConfig.NonTile; RenderManager.Instance.Configuration.AdvancedConfig.NonTile;
return !nonTile; return !nonTile;
} }
/// <summary>
/// Postfix of <c>scrFloor.Start</c>.
/// The game sets <c>enabled = isVisible</c> in Start(), which disables all off-screen tiles right at spawn.
/// When <c>DisableTileCulling</c> is enabled, force every floor to remain enabled.
/// </summary>
[HarmonyPostfix]
[HarmonyPatch("Start")]
public static void ForceEnableOnStart(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
__instance.enabled = true;
}
}
/// <summary>
/// Prefix of <c>scrFloor.OnBecameInvisible</c>.
/// Skips disabling the floor component (<c>enabled = false</c>) while rendering with
/// <c>AdvancedConfig.DisableTileCulling</c> enabled, preventing the 1-frame update lag/glitch
/// when tiles enter the camera view.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch("OnBecameInvisible")]
// ReSharper disable once InconsistentNaming
public static bool PreventTileCulling(scrFloor __instance)
{
if (RenderManager.Instance &&
RenderManager.Instance.stage >= RenderStage.Load &&
RenderManager.Instance.Configuration.AdvancedConfig.DisableTileCulling)
{
return false;
}
return true;
}
} }
+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.4.0-BETA", "ADOFAI Editor Forum")] [assembly: MelonInfo(typeof(RenderMod), "ADOFAI Renderer", "0.3.3-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;
+3 -3
View File
@@ -21,7 +21,7 @@ internal class DialogButton
public string Text; public string Text;
/// <summary> /// <summary>
/// Action run on the Unity main thread when the button is clicked. The dialog is destroyed after the action /// Action run on the main Unity thread when the button is clicked. The dialog is destroyed after the action
/// returns, also when the action throws (the exception is logged). /// returns, also when the action throws (the exception is logged).
/// </summary> /// </summary>
public Action OnClick; public Action OnClick;
@@ -49,7 +49,7 @@ internal class DialogButton
/// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it. /// canvas: a translucent full-screen backdrop that blocks input to everything below, and an O5Kit window on top of it.
/// While the dialog exists, every enabled UI Toolkit root (<see cref="UIDocument"/>) is disabled so that it cannot /// While the dialog exists, every enabled UI Toolkit root (<see cref="UIDocument"/>) is disabled so that it cannot
/// receive input; the roots are enabled again in <see cref="OnDestroy"/>. Clicking any button runs its action and then /// receive input; the roots are enabled again in <see cref="OnDestroy"/>. Clicking any button runs its action and then
/// destroys the dialog. All members run on the Unity main thread. /// destroys the dialog. All members run on the main Unity thread.
/// </para> /// </para>
/// </summary> /// </summary>
internal class CommonWarningDialogUI : MonoBehaviour internal class CommonWarningDialogUI : MonoBehaviour
@@ -97,7 +97,7 @@ internal class CommonWarningDialogUI : MonoBehaviour
} }
/// <summary> /// <summary>
/// Creates and shows a new dialog. Must be called on the Unity main thread. /// Creates and shows a new dialog. Must be called on the main Unity thread.
/// </summary> /// </summary>
/// <param name="title">Window title text.</param> /// <param name="title">Window title text.</param>
/// <param name="description">Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.</param> /// <param name="description">Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.</param>
+2 -2
View File
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.UI;
/// <para> /// <para>
/// Created by <c>RenderManager.StartFFmpegThread</c> on a GameObject kept across scene loads, and reused for later /// Created by <c>RenderManager.StartFFmpegThread</c> on a GameObject kept across scene loads, and reused for later
/// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames. /// renders. The overlay is drawn on the game screen only; it is not part of the encoded video frames.
/// <see cref="Update"/> rebuilds the text every frame on the Unity main thread. /// <see cref="Update"/> rebuilds the text every frame on the main Unity thread.
/// </para> /// </para>
/// </summary> /// </summary>
internal class DebugMonitorUI : MonoBehaviour internal class DebugMonitorUI : MonoBehaviour
@@ -194,7 +194,7 @@ internal class DebugMonitorUI : MonoBehaviour
// Queue - frames waiting in ImageQueue for the render thread / queue frame limit // Queue - frames waiting in ImageQueue for the render thread / queue frame limit
// Rendered Frames - RenderManager.frameCount (frames produced by the game) // Rendered Frames - RenderManager.frameCount (frames produced by the game)
// Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread) // Frames Sent - RenderManager.framesSent (frames handed to the encoder; written by the render thread)
// Throttling - RenderManager.throttleCount (times the main thread started waiting because ImageQueue // Throttling - RenderManager.throttleCount (times the main Unity thread started waiting because ImageQueue
// held more frames than its limit) // held more frames than its limit)
// Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error) // Readback failures- RenderManager.readbackFailureCount (AsyncGPUReadback requests that completed with an error)
// Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection // Mono heap - GC.GetTotalMemory(false), managed heap size in MiB without forcing a collection
+1 -1
View File
@@ -14,7 +14,7 @@ namespace ADOFAIRenderer.UI;
/// (measured in real time with a <see cref="Stopwatch"/>, independent of the patched game time) calls /// (measured in real time with a <see cref="Stopwatch"/>, independent of the patched game time) calls
/// <c>RenderManager.CancelRender</c>. Releasing Escape earlier hides the panel and resets the countdown. /// <c>RenderManager.CancelRender</c>. Releasing Escape earlier hides the panel and resets the countdown.
/// The component destroys its GameObject as soon as <c>RenderManager.IsActive</c> is false (stage below OnScene). /// The component destroys its GameObject as soon as <c>RenderManager.IsActive</c> is false (stage below OnScene).
/// Runs on the Unity main thread. /// Runs on the main Unity thread.
/// </para> /// </para>
/// </summary> /// </summary>
internal class EscapeHandlerUI : MonoBehaviour internal class EscapeHandlerUI : MonoBehaviour
+8 -8
View File
@@ -25,9 +25,9 @@ namespace ADOFAIRenderer.UI;
/// <c>RenderMod.OnInitializeMelon</c>). /// <c>RenderMod.OnInitializeMelon</c>).
/// </para> /// </para>
/// <para> /// <para>
/// Threads: UI construction and all Unity object access happen on the Unity main thread. The download and extraction /// Threads: UI construction and all Unity object access happen on the main Unity thread. The download and extraction
/// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to /// run on thread-pool threads (<see cref="Task.Run(Func{Task})"/>); they post UI updates to
/// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main thread. /// <see cref="_mainThreadQueue"/>, which <see cref="Update"/> drains on the main Unity thread.
/// </para> /// </para>
/// <para> /// <para>
/// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract /// Flow: buttons -> (Download) progress bar + status text -> download to a temporary archive in UserLibs -> extract
@@ -70,7 +70,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
/// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary> /// <summary>Cancellation for the running download; created per download, cancelled by Cancel, disposed in OnDestroy.</summary>
private System.Threading.CancellationTokenSource? _cts; private System.Threading.CancellationTokenSource? _cts;
/// <summary>Actions posted from background threads, run in order on the main thread by <see cref="Update"/>.</summary> /// <summary>Actions posted from background threads, run in order on the main Unity thread by <see cref="Update"/>.</summary>
private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new(); private readonly System.Collections.Concurrent.ConcurrentQueue<Action> _mainThreadQueue = new();
/// <summary> /// <summary>
@@ -376,7 +376,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
/// <summary>Runs every action posted by the background download task, in posting order.</summary> /// <summary>Runs every action posted by the background download task, in posting order.</summary>
private void Update() private void Update()
{ {
// Execute queued actions on main thread // Execute queued actions on main Unity thread
while (_mainThreadQueue.TryDequeue(out var action)) while (_mainThreadQueue.TryDequeue(out var action))
{ {
action?.Invoke(); action?.Invoke();
@@ -384,7 +384,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
} }
/// <summary> /// <summary>
/// Posts an action to run on the main thread during the next <see cref="Update"/>. Safe to call from any thread. /// Posts an action to run on the main Unity thread during the next <see cref="Update"/>. Safe to call from any thread.
/// Actions posted after the dialog is destroyed are never run. /// Actions posted after the dialog is destroyed are never run.
/// </summary> /// </summary>
private void ExecuteOnMainThread(Action action) private void ExecuteOnMainThread(Action action)
@@ -458,7 +458,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
// Keep the "complete" message visible for 1000 ms before closing // Keep the "complete" message visible for 1000 ms before closing
await Task.Delay(1000); await Task.Delay(1000);
// Destroy UI on main thread. FirstUpdateHook then sees Instance == null and checks the libraries again. // Destroy UI on main Unity thread. FirstUpdateHook then sees Instance == null and checks the libraries again.
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
{ {
Destroy(gameObject); Destroy(gameObject);
@@ -513,7 +513,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
Destroy(gameObject); Destroy(gameObject);
} }
/// <summary>Posts a status text and color change to the main thread. Callable from any thread.</summary> /// <summary>Posts a status text and color change to the main Unity thread. Callable from any thread.</summary>
private void UpdateStatus(string message, Color color) private void UpdateStatus(string message, Color color)
{ {
ExecuteOnMainThread(() => ExecuteOnMainThread(() =>
@@ -526,7 +526,7 @@ internal class FFmpegDownloadUI : MonoBehaviour
}); });
} }
/// <summary>Posts a progress bar update to the main thread. Callable from any thread.</summary> /// <summary>Posts a progress bar update to the main Unity thread. Callable from any thread.</summary>
/// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param> /// <param name="percent">Filled fraction 0..1 (not 0..100); becomes the fill's <c>anchorMax.x</c>.</param>
/// <param name="text">Text shown on the bar.</param> /// <param name="text">Text shown on the bar.</param>
private void UpdateProgress(float percent, string text) private void UpdateProgress(float percent, string text)
+2 -2
View File
@@ -12,7 +12,7 @@ namespace ADOFAIRenderer.UI;
/// The text is set when the binding is made (<see cref="Bind(TextMeshProUGUI, string)"/>), when the component is enabled, /// The text is set when the binding is made (<see cref="Bind(TextMeshProUGUI, string)"/>), when the component is enabled,
/// and on every <see cref="LocalizationManager.OnLanguageChanged"/> event. A key and a dynamic provider are mutually /// and on every <see cref="LocalizationManager.OnLanguageChanged"/> event. A key and a dynamic provider are mutually
/// exclusive: setting one clears the other. With neither set, <see cref="Refresh"/> leaves the text unchanged. /// exclusive: setting one clears the other. With neither set, <see cref="Refresh"/> leaves the text unchanged.
/// Runs on the Unity main thread. /// Runs on the main Unity thread.
/// </para> /// </para>
/// </summary> /// </summary>
internal class LocalizedText : MonoBehaviour internal class LocalizedText : MonoBehaviour
@@ -44,7 +44,7 @@ internal class LocalizedText : MonoBehaviour
/// The provider is called on every refresh (enable and language change), so it can combine several keys or /// The provider is called on every refresh (enable and language change), so it can combine several keys or
/// runtime values, e.g. a label that depends on UI state. /// runtime values, e.g. a label that depends on UI state.
/// </summary> /// </summary>
/// <param name="provider">Function returning the text to display; called on the Unity main thread.</param> /// <param name="provider">Function returning the text to display; called on the main Unity thread.</param>
public void SetDynamic(Func<string> provider) public void SetDynamic(Func<string> provider)
{ {
_dynamicProvider = provider; _dynamicProvider = provider;
+1
View File
@@ -135,6 +135,7 @@ internal class RendererUIManager : MonoBehaviour
ObjectActiveBright = new Color(1.000f, 0.294f, 0.079f, 1f), ObjectActiveBright = new Color(1.000f, 0.294f, 0.079f, 1f),
ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f), ControlOutlineIdle = new Color(1f, 1f, 1f, 0.4f),
ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f), ButtonHover = new Color(0.133f, 0.133f, 0.133f, 1.0f),
ButtonPressed = new Color(0.133f, 0.133f, 0.133f, 1.0f),
ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f), ControlOutlineHover = new Color(1f, 1f, 1f, 0.4f),
CardHeader = new Color(0.157f, 0f, 0f, 1f), CardHeader = new Color(0.157f, 0f, 0f, 1f),
CardPanel = new Color(0, 0, 0, 0f), CardPanel = new Color(0, 0, 0, 0f),
+405
View File
@@ -0,0 +1,405 @@
using System;
using System.Collections.Generic;
using O5Kit.Behaviour;
using O5Kit.Core;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace ADOFAIRenderer.UI;
/// <summary>
/// Definition of a single option within a <see cref="SegmentedControl{T}"/>.
/// Supports static strings, dynamic providers, or localization keys.
/// </summary>
internal class SegmentOption<T>
{
public T Value { get; set; }
public string? LabelKey { get; set; }
public Func<string>? DynamicLabel { get; set; }
public string? StaticLabel { get; set; }
public SegmentOption(T value, string labelKey)
{
Value = value;
LabelKey = labelKey;
}
public SegmentOption(T value, Func<string> dynamicLabel)
{
Value = value;
DynamicLabel = dynamicLabel;
}
public SegmentOption(T value, string? labelKey = null, string? staticLabel = null)
{
Value = value;
LabelKey = labelKey;
StaticLabel = staticLabel;
}
public static implicit operator SegmentOption<T>((T value, string labelKey) tuple)
=> new(tuple.value, tuple.labelKey);
}
/// <summary>
/// A generalized rounded segmented button group / picker UI component.
/// Visual styling, colors, and dimensions are pulled directly from <see cref="O5Theme"/> (matching <see cref="O5Kit.Control.O5Button"/>).
/// Automatically updates localized labels via <see cref="LocalizedText"/>.
/// </summary>
internal class SegmentedControl<T> : O5Object
{
private readonly List<SegmentItem> _items = new();
private T _value;
private bool _blocked;
public T Value => _value;
public T DefaultValue { get; }
public IReadOnlyList<SegmentOption<T>> Options { get; }
public Action<T>? OnChanged { get; set; }
/// <summary>Resting background color for unselected segments (defaults to Theme.ObjectButton).</summary>
public Color NormalColor { get; set; }
/// <summary>Resting background color for the selected segment (defaults to Theme.ObjectActive).</summary>
public Color ActiveColor { get; set; }
/// <summary>Border and outline color (defaults to Theme.ControlOutlineIdle).</summary>
public Color BorderColor { get; set; }
private class SegmentItem
{
public SegmentOption<T> Option { get; }
public GameObject RootObject { get; }
public Image Background { get; }
public TextMeshProUGUI Label { get; }
public ITweenHandle? HoverTween { get; set; }
public bool Hovered { get; set; }
public bool Pressed { get; set; }
public SegmentItem(SegmentOption<T> option, GameObject root, Image bg, TextMeshProUGUI label)
{
Option = option;
RootObject = root;
Background = bg;
Label = label;
}
public void Tint(O5Context ctx, Color target, float duration)
{
HoverTween?.Kill();
var bg = Background;
HoverTween = ctx.Tween.TweenColor(() => bg.color, v => bg.color = v, target, duration);
}
}
public SegmentedControl(
O5Context ctx,
string id,
RectTransform rect,
T defaultValue,
T initialValue,
IReadOnlyList<SegmentOption<T>> options,
Action<T>? onChanged = null)
: base(ctx, id, rect)
{
DefaultValue = defaultValue;
_value = initialValue;
Options = options;
OnChanged = onChanged;
// Pull colors directly from O5Theme, identical to O5Button / O5Toggle
NormalColor = Ctx.Theme.ObjectButton;
ActiveColor = Ctx.Theme.ObjectActive;
BorderColor = Ctx.Theme.ControlOutlineIdle;
BuildSegments();
UpdateVisual(true);
}
/// <summary>
/// Creates and attaches a new rounded <see cref="SegmentedControl{T}"/> to <paramref name="parent"/>.
/// Height matches standard buttons and sliders (<see cref="O5Theme.ControlHeightFor"/>).
/// </summary>
public static SegmentedControl<T> Create(
O5Context ctx,
Transform parent,
string id,
T defaultValue,
T initialValue,
IReadOnlyList<SegmentOption<T>> options,
float? height = null,
Action<T>? onChanged = null)
{
var h = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
var controlObj = new GameObject($"SegmentedControl_{id}");
controlObj.transform.SetParent(parent, false);
var rect = controlObj.AddComponent<RectTransform>();
var le = controlObj.AddComponent<LayoutElement>();
le.minHeight = h;
le.preferredHeight = h;
le.flexibleWidth = 1f;
return new SegmentedControl<T>(ctx, id, rect, defaultValue, initialValue, options, onChanged);
}
private void BuildSegments()
{
var theme = Ctx.Theme;
// 1. Masked container for rounded corners
var maskObj = new GameObject("MaskContainer");
maskObj.transform.SetParent(Rect, false);
var maskRect = maskObj.AddComponent<RectTransform>();
maskRect.anchorMin = Vector2.zero;
maskRect.anchorMax = Vector2.one;
maskRect.offsetMin = Vector2.zero;
maskRect.offsetMax = Vector2.zero;
var maskImg = maskObj.AddComponent<Image>();
maskImg.sprite = Ctx.Sprites.RoundedControl;
maskImg.type = Image.Type.Sliced;
maskImg.color = NormalColor;
var mask = maskObj.AddComponent<Mask>();
mask.showMaskGraphic = true;
// Continuous horizontal layout with no middle divider line
var layout = maskObj.AddComponent<HorizontalLayoutGroup>();
layout.padding = new RectOffset(0, 0, 0, 0);
layout.spacing = 0f;
layout.childControlWidth = true;
layout.childControlHeight = true;
layout.childForceExpandWidth = true;
layout.childForceExpandHeight = true;
for (var i = 0; i < Options.Count; i++)
{
var opt = Options[i];
var segmentObj = new GameObject($"Segment_{i}_{opt.Value}");
segmentObj.transform.SetParent(maskRect, false);
var segRect = segmentObj.AddComponent<RectTransform>();
var segLE = segmentObj.AddComponent<LayoutElement>();
segLE.minWidth = 0f;
segLE.preferredWidth = 0f;
segLE.flexibleWidth = 1f;
var bgImage = segmentObj.AddComponent<Image>();
bgImage.color = NormalColor;
bgImage.raycastTarget = true;
var textObj = new GameObject("Text");
textObj.transform.SetParent(segmentObj.transform, false);
var textRect = textObj.AddComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
var tmp = textObj.AddComponent<TextMeshProUGUI>();
tmp.font = Ctx.Fonts.Regular;
tmp.fontSize = theme.FontSizeBody;
tmp.alignment = TextAlignmentOptions.Center;
tmp.characterSpacing = -1f;
tmp.raycastTarget = false;
// Bind localization
if (!string.IsNullOrEmpty(opt.LabelKey))
{
LocalizedText.Bind(tmp, opt.LabelKey);
}
else if (opt.DynamicLabel != null)
{
LocalizedText.Bind(tmp, opt.DynamicLabel);
}
else
{
tmp.text = opt.StaticLabel ?? opt.Value?.ToString() ?? string.Empty;
}
var item = new SegmentItem(opt, segmentObj, bgImage, tmp);
_items.Add(item);
// Wire pointer events like O5Button
var trigger = segmentObj.AddComponent<EventTrigger>();
UnityUtils.AddEvents(trigger,
(EventTriggerType.PointerEnter, _ => OnSegmentHoverEnter(item)),
(EventTriggerType.PointerExit, _ => OnSegmentHoverExit(item)),
(EventTriggerType.PointerDown, e =>
{
if (IsLeftPress(e))
{
OnSegmentPressEnter(item);
}
}),
(EventTriggerType.PointerUp, _ => OnSegmentPressExit(item)),
(EventTriggerType.PointerClick, e =>
{
if (IsLeftPress(e))
{
SelectOption(item.Option.Value);
}
})
);
}
// 2. Rounded outline overlay on top of masked container
var outlineObj = new GameObject("Outline");
outlineObj.transform.SetParent(Rect, false);
var outlineRect = outlineObj.AddComponent<RectTransform>();
outlineRect.anchorMin = Vector2.zero;
outlineRect.anchorMax = Vector2.one;
outlineRect.offsetMin = Vector2.zero;
outlineRect.offsetMax = Vector2.zero;
var outlineImg = outlineObj.AddComponent<Image>();
outlineImg.sprite = Ctx.Sprites.RoundedOutline;
outlineImg.type = Image.Type.Sliced;
outlineImg.color = BorderColor;
outlineImg.raycastTarget = false;
}
private static bool IsLeftPress(BaseEventData e) =>
e is PointerEventData ped && ped.button == PointerEventData.InputButton.Left;
private void OnSegmentHoverEnter(SegmentItem item)
{
if (IsDisposed || _blocked) return;
item.Hovered = true;
if (item.Pressed) return;
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover;
item.Tint(Ctx, targetColor, 0.12f);
}
private void OnSegmentHoverExit(SegmentItem item)
{
if (IsDisposed) return;
item.Hovered = false;
if (item.Pressed) return;
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
var targetColor = isSelected ? ActiveColor : NormalColor;
item.Tint(Ctx, targetColor, 0.12f);
}
private void OnSegmentPressEnter(SegmentItem item)
{
if (IsDisposed || _blocked) return;
item.Pressed = true;
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
var targetColor = isSelected ? Ctx.Theme.ObjectActiveBright : Ctx.Theme.ButtonHover;
item.Tint(Ctx, targetColor, 0.08f);
}
private void OnSegmentPressExit(SegmentItem item)
{
if (IsDisposed || !item.Pressed) return;
item.Pressed = false;
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
Color settle;
if (isSelected)
{
settle = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor;
}
else
{
settle = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor;
}
item.Tint(Ctx, settle, 0.12f);
}
public void SelectOption(T newValue, bool notify = true)
{
if (IsDisposed) return;
if (EqualityComparer<T>.Default.Equals(_value, newValue)) return;
_value = newValue;
UpdateVisual(false);
if (notify)
{
OnChanged?.Invoke(_value);
}
}
public void Set(T value, bool notify = true)
{
if (IsDisposed) return;
if (EqualityComparer<T>.Default.Equals(_value, value)) return;
_value = value;
UpdateVisual(false);
if (notify)
{
OnChanged?.Invoke(_value);
}
}
public void Reset(bool notify = true)
{
Set(DefaultValue, notify);
}
public override void SetBlocked(bool blocked, bool noAnimate = false)
{
_blocked = blocked;
base.SetBlocked(blocked, noAnimate);
}
public void UpdateVisual(bool noAnimate = false)
{
if (IsDisposed) return;
foreach (var item in _items)
{
item.Pressed = false;
var isSelected = EqualityComparer<T>.Default.Equals(item.Option.Value, _value);
Color targetBg;
if (isSelected)
{
targetBg = item.Hovered ? Ctx.Theme.ObjectActiveBright : ActiveColor;
item.Label.color = Ctx.Theme.Text;
item.Label.fontStyle = FontStyles.Bold;
}
else
{
targetBg = item.Hovered ? Ctx.Theme.ButtonHover : NormalColor;
item.Label.color = Ctx.Theme.TextDim;
item.Label.fontStyle = FontStyles.Normal;
}
if (noAnimate)
{
item.HoverTween?.Kill();
item.Background.color = targetBg;
}
else
{
item.Tint(Ctx, targetBg, 0.15f);
}
}
}
public override void Dispose()
{
if (IsDisposed) return;
foreach (var item in _items)
{
item.HoverTween?.Kill();
item.HoverTween = null;
}
OnChanged = null;
base.Dispose();
}
}
+399 -73
View File
@@ -13,6 +13,7 @@ using Newtonsoft.Json;
using O5Kit.Control; using O5Kit.Control;
using O5Kit.Core; using O5Kit.Core;
using O5Kit.Factory; using O5Kit.Factory;
using TMPro;
using UnityEngine; using UnityEngine;
using UnityEngine.UI; using UnityEngine.UI;
@@ -24,7 +25,7 @@ namespace ADOFAIRenderer.UI.Window;
/// <para> /// <para>
/// Opened from the level editor through <c>RenderManager</c> (after the unsaved-changes check) with /// Opened from the level editor through <c>RenderManager</c> (after the unsaved-changes check) with
/// <see cref="CreateWindow"/>. The window is built once and then hidden/shown; its state lives in static fields for the /// <see cref="CreateWindow"/>. The window is built once and then hidden/shown; its state lives in static fields for the
/// whole game session. All members run on the Unity main thread. /// whole game session. All members run on the main Unity thread.
/// </para> /// </para>
/// <para> /// <para>
/// Data flow: /// Data flow:
@@ -105,25 +106,34 @@ internal static class RenderConfigWindow
private static O5Slider? _renderHeightSlider; private static O5Slider? _renderHeightSlider;
private static O5Slider? _delayAfterEndSlider; private static O5Slider? _delayAfterEndSlider;
private static O5Toggle? _showJudgementToggle; private static O5Toggle? _showJudgementToggle;
private static SegmentedControl<RenderConfig.RenderOutputMode>? _outputModeControl;
private static O5Dropdown<Codec>? _codecDropdown; private static O5Dropdown<Codec>? _codecDropdown;
private static TextMeshProUGUI? _codecLabel;
private static ITweenHandle? _codecLabelTween;
private static O5Dropdown<RateControlMode>? _rateControlDropdown; private static O5Dropdown<RateControlMode>? _rateControlDropdown;
private static TextMeshProUGUI? _rateControlLabel;
private static ITweenHandle? _rateControlLabelTween;
// Bitrate / max bitrate / QP inputs keep their label and the running label color tween, so UpdateRateControlUI // Bitrate / max bitrate / QP inputs keep their label and the running label color tween, so UpdateRateControlUI
// can dim the label together with the blocked input (see SetFieldBlocked). // can dim the label together with the blocked input (see SetFieldBlocked).
private static O5InputField? _bitrateInput; private static O5InputField? _bitrateInput;
private static TMPro.TextMeshProUGUI? _bitrateLabel; private static TextMeshProUGUI? _bitrateLabel;
private static ITweenHandle? _bitrateLabelTween; private static ITweenHandle? _bitrateLabelTween;
private static O5InputField? _maxBitrateInput; private static O5InputField? _maxBitrateInput;
private static TMPro.TextMeshProUGUI? _maxBitrateLabel; private static TextMeshProUGUI? _maxBitrateLabel;
private static ITweenHandle? _maxBitrateLabelTween; private static ITweenHandle? _maxBitrateLabelTween;
private static O5InputField? _qpInput; private static O5InputField? _qpInput;
private static TMPro.TextMeshProUGUI? _qpLabel; private static TextMeshProUGUI? _qpLabel;
private static ITweenHandle? _qpLabelTween; private static ITweenHandle? _qpLabelTween;
private static O5Slider? _keyframeIntervalSlider;
private static O5Toggle? _forceSoftwareToggle; private static O5Toggle? _forceSoftwareToggle;
private static TextMeshProUGUI? _forceSoftwareLabel;
private static ITweenHandle? _forceSoftwareLabelTween;
private static O5Slider? _keyframeIntervalSlider;
private static O5Toggle? _renderAudioToggle; private static O5Toggle? _renderAudioToggle;
private static O5Dropdown<int>? _sampleRateDropdown; private static O5Dropdown<int>? _sampleRateDropdown;
@@ -135,10 +145,19 @@ internal static class RenderConfigWindow
/// </summary> /// </summary>
private static O5InputField? _outputDirInput; private static O5InputField? _outputDirInput;
/// <summary>
/// File name format template input. Bound to <c>GlobalSettings.Current.FileNameFormat</c>.
/// </summary>
private static O5InputField? _fileNameFormatInput;
private static O5Dropdown<string>? _fileExtensionDropdown; private static O5Dropdown<string>? _fileExtensionDropdown;
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;
private static O5Toggle? _fixCustomFrameRateToggle;
private static O5Toggle? _disableTileCullingToggle;
private static O5Toggle? _hideFailedDecorationsToggle;
// 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.
@@ -367,7 +386,8 @@ internal static class RenderConfigWindow
var redTheme = ctx.Theme with var redTheme = ctx.Theme with
{ {
ObjectButton = ctx.Theme.SoftRed, ObjectButton = ctx.Theme.SoftRed,
ButtonHover = ctx.Theme.SoftRed * 0.8f ButtonHover = ctx.Theme.SoftRed * 0.8f,
ButtonPressed = ctx.Theme.SoftRed * 0.8f
}; };
var redCtx = new O5Context( var redCtx = new O5Context(
config: ctx.Config, config: ctx.Config,
@@ -412,7 +432,7 @@ internal static class RenderConfigWindow
_renderFpsSlider = CreateSlider( _renderFpsSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topLeft), ctx: ctx, parent: CreateFieldSlot(topLeft),
labelKey: "ui.render_config.render_fps", id: "slider_render_fps", labelKey: "ui.render_config.render_fps", id: "slider_render_fps",
defaultValue: 60, min: 0.01, max: 3000, value: 60, defaultValue: 60, min: 0.001, max: 3000, value: 60,
format: "F3", clampMode: ClampMode.All, format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(renderFpsSnap, value), sliderFilter: value => Snap(renderFpsSnap, value),
onChanged: val => { config.RenderFPS = val; MarkConfigModified(); } onChanged: val => { config.RenderFPS = val; MarkConfigModified(); }
@@ -422,7 +442,7 @@ internal static class RenderConfigWindow
_videoFPSSlider = CreateSlider( _videoFPSSlider = CreateSlider(
ctx: ctx, parent: CreateFieldSlot(topRight), ctx: ctx, parent: CreateFieldSlot(topRight),
labelKey: "ui.render_config.video_fps", id: "slider_video_fps", labelKey: "ui.render_config.video_fps", id: "slider_video_fps",
defaultValue: 60, min: 0.01, max: 600, value: 60, defaultValue: 60, min: 0.001, max: 600, value: 60,
format: "F3", clampMode: ClampMode.All, format: "F3", clampMode: ClampMode.All,
sliderFilter: value => Snap(videoFpsSnap, value), sliderFilter: value => Snap(videoFpsSnap, value),
onChanged: val => { config.VideoFPS = val; MarkConfigModified(); } onChanged: val => { config.VideoFPS = val; MarkConfigModified(); }
@@ -470,6 +490,28 @@ internal static class RenderConfigWindow
generateLabel: false generateLabel: false
); );
// Output Mode (RenderConfig.OutputMode): Video vs PNG Sequence
_outputModeControl = CreateSegmentedField(
ctx: ctx,
parent: scrollContent,
labelKey: "ui.render_config.output_mode",
defaultValue: RenderConfig.RenderOutputMode.Video,
value: config.OutputMode,
options: new SegmentOption<RenderConfig.RenderOutputMode>[]
{
new(RenderConfig.RenderOutputMode.Video, "ui.render_config.output_mode_video"),
new(RenderConfig.RenderOutputMode.PNGSequence, "ui.render_config.output_mode_png_sequence")
},
id: "seg_output_mode",
onChanged: val =>
{
config.OutputMode = val;
UpdateUIBlock(config);
MarkConfigModified();
}
);
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Section 3: Video Encoding card (RenderConfig.VideoConfig) // Section 3: Video Encoding card (RenderConfig.VideoConfig)
// Codec | Rate Control Mode // Codec | Rate Control Mode
@@ -493,11 +535,11 @@ internal static class RenderConfigWindow
"ui.render_config.codec", "ui.render_config.codec",
Codec.H264, config.VideoConfig.Codec, Codec.H264, config.VideoConfig.Codec,
codecs, c => c.ToString(), codecs, c => c.ToString(),
"dd_video_codec", "dd_video_codec", out _codecLabel,
c => c =>
{ {
config.VideoConfig.Codec = c; config.VideoConfig.Codec = c;
UpdateVideoCodecUI(c, config); UpdateSampleRateUI(c, config);
MarkConfigModified(); MarkConfigModified();
}); });
@@ -508,7 +550,7 @@ internal static class RenderConfigWindow
"ui.render_config.bitrate", "ui.render_config.bitrate",
"30000", config.VideoConfig.Bitrate.ToString(), "30000", config.VideoConfig.Bitrate.ToString(),
"input_bitrate", out _bitrateLabel, "input_bitrate", out _bitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer, TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; MarkConfigModified(); } val => { if (int.TryParse(val, out var b) && b > 0) config.VideoConfig.Bitrate = b; MarkConfigModified(); }
); );
@@ -517,7 +559,7 @@ internal static class RenderConfigWindow
"ui.render_config.qp", "ui.render_config.qp",
"18", config.VideoConfig.QP.ToString(), "18", config.VideoConfig.QP.ToString(),
"input_qp", out _qpLabel, "input_qp", out _qpLabel,
TMPro.TMP_InputField.CharacterValidation.Integer, TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; MarkConfigModified(); } val => { if (int.TryParse(val, out var qp) && qp >= 0) config.VideoConfig.QP = qp; MarkConfigModified(); }
); );
@@ -527,11 +569,11 @@ internal static class RenderConfigWindow
"ui.render_config.rate_control", "ui.render_config.rate_control",
RateControlMode.CBR, config.VideoConfig.RateControlMode, RateControlMode.CBR, config.VideoConfig.RateControlMode,
rateModes, m => m.ToString(), rateModes, m => m.ToString(),
"dd_rate_control", "dd_rate_control", out _rateControlLabel,
m => m =>
{ {
config.VideoConfig.RateControlMode = m; config.VideoConfig.RateControlMode = m;
UpdateRateControlUI(m); UpdateUIBlock(config);
MarkConfigModified(); MarkConfigModified();
} }
); );
@@ -541,7 +583,7 @@ internal static class RenderConfigWindow
"ui.render_config.max_bitrate", "ui.render_config.max_bitrate",
"50000", config.VideoConfig.MaxBitrate.ToString(), "50000", config.VideoConfig.MaxBitrate.ToString(),
"input_max_bitrate", out _maxBitrateLabel, "input_max_bitrate", out _maxBitrateLabel,
TMPro.TMP_InputField.CharacterValidation.Integer, TMP_InputField.CharacterValidation.Integer,
val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; MarkConfigModified(); } val => { if (int.TryParse(val, out var mb) && mb > 0) config.VideoConfig.MaxBitrate = mb; MarkConfigModified(); }
); );
@@ -551,7 +593,7 @@ internal static class RenderConfigWindow
defaultValue: false, defaultValue: false,
value: config.VideoConfig.ForceSoftwareEncoder, value: config.VideoConfig.ForceSoftwareEncoder,
toggleTextKey: "ui.render_config.force_software", toggleTextKey: "ui.render_config.force_software",
id: "toggle_force_software", id: "toggle_force_software", out _forceSoftwareLabel,
onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); }, onChanged: val => { config.VideoConfig.ForceSoftwareEncoder = val; MarkConfigModified(); },
generateLabel: true generateLabel: true
); );
@@ -644,7 +686,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;
} }
@@ -658,7 +700,7 @@ internal static class RenderConfigWindow
var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent); var (globalLeft, globalRight) = CreateTwoColumnPanel(globalContent);
// Browse: opens the OS folder picker (FolderDialogUtil) at the current folder; a selected folder is written to // Browse: opens the OS folder picker (FolderDialogUtil) at the current folder; a selected folder is written to
// the input and saved to the global settings immediately. FolderDialogUtil runs the callback through the // the input and saved to the global settings immediately. FolderDialogUtil runs the callback through the
// caller's SynchronizationContext, i.e. on the Unity main thread; cancel or failure passes null (ignored here). // caller's SynchronizationContext, i.e. on the main Unity thread; cancel or failure passes null (ignored here).
CreateButton( CreateButton(
ctx, globalLeft, ctx, globalLeft,
"ui.render_config.browse", "ui.render_config.browse",
@@ -694,10 +736,48 @@ internal static class RenderConfigWindow
} }
); );
// Filename format template: typing updates GlobalSettings.Current.FileNameFormat in memory only.
_fileNameFormatInput = CreateFullWidthInputField(
ctx, globalContent,
"ui.render_config.filename_format",
GlobalSettings.DefaultFileNameFormat,
GlobalSettings.EffectiveFileNameFormat,
"input_filename_format",
val =>
{
var trimmed = val?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(trimmed) ||
string.Equals(trimmed, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
{
GlobalSettings.Current.FileNameFormat = null;
}
else
{
GlobalSettings.Current.FileNameFormat = trimmed;
}
}
);
var fnHint = CreateLabel(ctx, globalContent, "ui.render_config.filename_format_hint", 11f);
fnHint.color = new Color(ctx.Theme.TextDim.r, ctx.Theme.TextDim.g, ctx.Theme.TextDim.b, ctx.Theme.TextDim.a * 0.7f);
CreateButton(
ctx, globalContent,
"ui.render_config.reset_filename_default",
"btn_reset_filename_format",
() =>
{
_fileNameFormatInput.Set(GlobalSettings.DefaultFileNameFormat);
GlobalSettings.Current.FileNameFormat = null;
GlobalSettings.Save();
}
);
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// 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 +819,39 @@ 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
);
// Disable Tile Culling (AdvancedConfig.DisableTileCulling): prevents tiles from being disabled outside the frustum
_disableTileCullingToggle = CreateToggleField(
ctx, CreateFieldSlot(advLeft),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.DisableTileCulling,
toggleTextKey: "ui.render_config.disable_tile_culling",
id: "toggle_disable_tile_culling",
onChanged: val =>
{
config.AdvancedConfig.DisableTileCulling = 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(
@@ -746,7 +859,7 @@ internal static class RenderConfigWindow
"ui.render_config.random_seed", "ui.render_config.random_seed",
"", config.AdvancedConfig.RandomSeed?.ToString() ?? "", "", config.AdvancedConfig.RandomSeed?.ToString() ?? "",
"input_random_seed", "input_random_seed",
TMPro.TMP_InputField.CharacterValidation.Integer, TMP_InputField.CharacterValidation.Integer,
val => val =>
{ {
if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s; if (int.TryParse(val, out var s)) config.AdvancedConfig.RandomSeed = s;
@@ -772,6 +885,38 @@ internal static class RenderConfigWindow
generateLabel: false generateLabel: false
); );
// 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
);
// Hide Failed Decorations (AdvancedConfig.HideFailedDecorations): hides placeholder tree decorations when decoration images fail to load
_hideFailedDecorationsToggle = CreateToggleField(
ctx, CreateFieldSlot(advRight),
labelKey: "",
defaultValue: false,
value: config.AdvancedConfig.HideFailedDecorations,
toggleTextKey: "ui.render_config.hide_failed_decorations",
id: "toggle_hide_failed_decorations",
onChanged: val =>
{
config.AdvancedConfig.HideFailedDecorations = val;
MarkConfigModified();
},
generateLabel: false
);
// 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.
@@ -909,7 +1054,7 @@ internal static class RenderConfigWindow
// RenderMod.ExpirationTime (GetVersionText) // RenderMod.ExpirationTime (GetVersionText)
var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true); var versionLabel = O5Factory.ControlText(ctx, scrollContent, 12f, noPad: true);
LocalizedText.Bind(versionLabel, GetVersionText); LocalizedText.Bind(versionLabel, GetVersionText);
versionLabel.alignment = TMPro.TextAlignmentOptions.Center; versionLabel.alignment = TextAlignmentOptions.Center;
versionLabel.color = ctx.Theme.TextDim; versionLabel.color = ctx.Theme.TextDim;
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
@@ -959,6 +1104,7 @@ internal static class RenderConfigWindow
_renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true); _renderHeightSlider?.Set(config.RenderHeight, false, noFilter: true);
_delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true); _delayAfterEndSlider?.Set(config.DelayAfterEnd, false, noFilter: true);
_showJudgementToggle?.Set(config.ShowJudgementText, false); _showJudgementToggle?.Set(config.ShowJudgementText, false);
_outputModeControl?.Set(config.OutputMode, false);
_codecDropdown?.Set(config.VideoConfig.Codec, false); _codecDropdown?.Set(config.VideoConfig.Codec, false);
_rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false); _rateControlDropdown?.Set(config.VideoConfig.RateControlMode, false);
@@ -975,11 +1121,16 @@ internal static class RenderConfigWindow
_audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true); _audioBitrateSlider?.Set(config.AudioConfig.AudioBitrate / 1000.0, false, noFilter: true);
_outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false); _outputDirInput?.Set(GlobalSettings.EffectiveOutputDirectory, false);
_fileNameFormatInput?.Set(GlobalSettings.EffectiveFileNameFormat, false);
_fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false); _fileExtensionDropdown?.Set(config.AdvancedConfig.FileExtension, false);
_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);
_fixCustomFrameRateToggle?.Set(config.AdvancedConfig.FixCustomFrameRate, false);
_disableTileCullingToggle?.Set(config.AdvancedConfig.DisableTileCulling, false);
_hideFailedDecorationsToggle?.Set(config.AdvancedConfig.HideFailedDecorations, false);
// 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
@@ -1001,8 +1152,8 @@ internal static class RenderConfigWindow
// The sample rate list depends on the codec. UpdateVideoCodecUI writes the dropdown's resulting value back into // The sample rate list depends on the codec. UpdateVideoCodecUI writes the dropdown's resulting value back into
// the config: O5Dropdown.SetValues selects the first listed rate when the current one is not in the new list, // the config: O5Dropdown.SetValues selects the first listed rate when the current one is not in the new list,
// so a rate the codec does not support becomes the codec's first rate. // so a rate the codec does not support becomes the codec's first rate.
UpdateVideoCodecUI(config.VideoConfig.Codec, config); UpdateSampleRateUI(config.VideoConfig.Codec, config);
UpdateRateControlUI(config.VideoConfig.RateControlMode, noAnimate: true); UpdateUIBlock(config, noAnimate: true);
UpdatePresetLabel(config); UpdatePresetLabel(config);
} }
@@ -1074,7 +1225,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;
} }
@@ -1097,6 +1248,20 @@ internal static class RenderConfigWindow
} }
} }
if (_fileNameFormatInput != null)
{
var format = _fileNameFormatInput.Value.Trim();
if (string.IsNullOrWhiteSpace(format) ||
string.Equals(format, GlobalSettings.DefaultFileNameFormat, StringComparison.Ordinal))
{
GlobalSettings.Current.FileNameFormat = null;
}
else
{
GlobalSettings.Current.FileNameFormat = format;
}
}
GlobalSettings.Save(); GlobalSettings.Save();
SetWindowActive(false); SetWindowActive(false);
@@ -1121,6 +1286,7 @@ internal static class RenderConfigWindow
if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value); if (_renderHeightSlider != null) target.RenderHeight = (int)Math.Round(_renderHeightSlider.Value);
if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value; if (_delayAfterEndSlider != null) target.DelayAfterEnd = _delayAfterEndSlider.Value;
if (_showJudgementToggle != null) target.ShowJudgementText = _showJudgementToggle.Value; if (_showJudgementToggle != null) target.ShowJudgementText = _showJudgementToggle.Value;
if (_outputModeControl != null) target.OutputMode = _outputModeControl.Value;
if (_codecDropdown != null) target.VideoConfig.Codec = _codecDropdown.Value; if (_codecDropdown != null) target.VideoConfig.Codec = _codecDropdown.Value;
if (_rateControlDropdown != null) target.VideoConfig.RateControlMode = _rateControlDropdown.Value; if (_rateControlDropdown != null) target.VideoConfig.RateControlMode = _rateControlDropdown.Value;
@@ -1161,6 +1327,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;
if (_fixCustomFrameRateToggle != null) target.AdvancedConfig.FixCustomFrameRate = _fixCustomFrameRateToggle.Value;
if (_disableTileCullingToggle != null) target.AdvancedConfig.DisableTileCulling = _disableTileCullingToggle.Value;
if (_hideFailedDecorationsToggle != null) target.AdvancedConfig.HideFailedDecorations = _hideFailedDecorationsToggle.Value;
// Planet Colors // Planet Colors
target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null) target.AdvancedConfig.firePlanetColor = (_firePlanetToggle?.Value == true && _firePlanetPicker != null)
@@ -1586,10 +1756,8 @@ internal static class RenderConfigWindow
hLayout.spacing = spacing; hLayout.spacing = spacing;
hLayout.padding = new RectOffset(0, 0, 0, 0); hLayout.padding = new RectOffset(0, 0, 0, 0);
hLayout.childAlignment = TextAnchor.UpperCenter; hLayout.childAlignment = TextAnchor.UpperCenter;
hLayout.childControlWidth = true; hLayout.childControlWidth = hLayout.childControlHeight = true;
hLayout.childControlHeight = true; hLayout.childForceExpandWidth = true; hLayout.childForceExpandHeight = false;
hLayout.childForceExpandWidth = true;
hLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming // ReSharper disable once InconsistentNaming
var panelLE = panelObj.AddComponent<LayoutElement>(); var panelLE = panelObj.AddComponent<LayoutElement>();
@@ -1695,8 +1863,8 @@ internal static class RenderConfigWindow
showActiveToggle showActiveToggle
); );
var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent<TMPro.TextMeshProUGUI>() var titleTmp = card.Find("Header/TitleTrigger/TitleText")?.GetComponent<TextMeshProUGUI>()
?? card.GetComponentsInChildren<TMPro.TextMeshProUGUI>(true).FirstOrDefault(t => t.gameObject.name == "TitleText"); ?? card.GetComponentsInChildren<TextMeshProUGUI>(true).FirstOrDefault(t => t.gameObject.name == "TitleText");
if (titleTmp) if (titleTmp)
{ {
titleTmp.fontSize = 18f; titleTmp.fontSize = 18f;
@@ -1713,7 +1881,7 @@ internal static class RenderConfigWindow
/// <summary> /// <summary>
/// Creates a localized text label (default 13 pt, theme dim text color unless <paramref name="color"/> is given). /// Creates a localized text label (default 13 pt, theme dim text color unless <paramref name="color"/> is given).
/// </summary> /// </summary>
private static TMPro.TextMeshProUGUI CreateLabel( private static TextMeshProUGUI CreateLabel(
O5Context ctx, O5Context ctx,
Transform parent, Transform parent,
string textKey, string textKey,
@@ -1825,7 +1993,7 @@ internal static class RenderConfigWindow
string? defaultValue, string? defaultValue,
string value, string value,
string id, string id,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, TMP_InputField.CharacterValidation characterValidation = TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null) Action<string>? onChanged = null)
{ {
return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged); return CreateInputField(ctx, colParent, labelKey, defaultValue, value, id, out _, characterValidation, onChanged);
@@ -1852,8 +2020,8 @@ internal static class RenderConfigWindow
string? defaultValue, string? defaultValue,
string value, string value,
string id, string id,
out TMPro.TextMeshProUGUI label, out TextMeshProUGUI label,
TMPro.TMP_InputField.CharacterValidation characterValidation = TMPro.TMP_InputField.CharacterValidation.None, TMP_InputField.CharacterValidation characterValidation = TMP_InputField.CharacterValidation.None,
Action<string>? onChanged = null) Action<string>? onChanged = null)
{ {
// Labeled text input: an 18-unit label row (13 pt, dim) above an O5Kit input. The label key also serves as the // Labeled text input: an 18-unit label row (13 pt, dim) above an O5Kit input. The label key also serves as the
@@ -1892,19 +2060,9 @@ internal static class RenderConfigWindow
// Dependent field rules (rate control, codec) // Dependent field rules (rate control, codec)
// =================================================================================================== // ===================================================================================================
/// <summary>
/// Blocks or unblocks an input and dims its label: a blocked label uses the theme dim text color at 40% of its
/// alpha. The label color changes with a 0.2-second tween (any running tween for that label is killed first), or
/// immediately with <paramref name="noAnimate"/>.
/// </summary>
/// <param name="input">Input to block; may be null.</param>
/// <param name="label">Label above the input; may be null.</param>
/// <param name="tweenHandle">Stored tween handle of this label, replaced by the new tween.</param>
/// <param name="blocked">True to block (disable) the field.</param>
/// <param name="noAnimate">True to apply the change without animation.</param>
private static void SetFieldBlocked( private static void SetFieldBlocked(
O5InputField? input, O5InputField? input,
TMPro.TextMeshProUGUI? label, TextMeshProUGUI? label,
ref ITweenHandle? tweenHandle, ref ITweenHandle? tweenHandle,
bool blocked, bool blocked,
bool noAnimate = false) bool noAnimate = false)
@@ -1912,7 +2070,37 @@ internal static class RenderConfigWindow
input?.SetBlocked(blocked, noAnimate); input?.SetBlocked(blocked, noAnimate);
if (label == null) return; if (label == null) return;
UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate);
}
private static void SetToggleBlocked(
O5Toggle? toggle,
TextMeshProUGUI? label,
ref ITweenHandle? tweenHandle,
bool blocked,
bool noAnimate = false)
{
toggle?.SetBlocked(blocked, noAnimate);
if (label == null) return;
UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate);
}
private static void SetDropdownBlocked<T>(
O5Dropdown<T>? dropdown,
TextMeshProUGUI? label,
ref ITweenHandle? tweenHandle,
bool blocked,
bool noAnimate = false)
{
dropdown?.SetBlocked(blocked, noAnimate);
if (label == null) return;
UpdateLabelDim(label, ref tweenHandle, blocked, noAnimate);
}
private static void UpdateLabelDim(TextMeshProUGUI label, ref ITweenHandle? tweenHandle, bool blocked, bool noAnimate = false)
{
tweenHandle?.Kill(); tweenHandle?.Kill();
var ctx = RendererUIManager.Context; var ctx = RendererUIManager.Context;
var baseColor = ctx.Theme.TextDim; var baseColor = ctx.Theme.TextDim;
@@ -1920,41 +2108,20 @@ internal static class RenderConfigWindow
? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f) ? new Color(baseColor.r, baseColor.g, baseColor.b, baseColor.a * 0.4f)
: baseColor; : baseColor;
if (noAnimate) if (noAnimate) label.color = targetColor;
{ else tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f);
label.color = targetColor;
}
else
{
tweenHandle = ctx.Tween.TweenColor(label, targetColor, 0.2f);
}
} }
/// <summary> /// <summary>
/// Enables only the video fields used by the rate control mode: /// Replaces the sample rate choices with those supported by the audio codec paired with <paramref name="videoCodec"/>.
/// CBR - Bitrate; VBR - Bitrate and Max Bitrate; CQP - QP. Values of blocked fields stay in the config.
/// </summary>
private static void UpdateRateControlUI(RateControlMode mode, bool noAnimate = false)
{
bool blockBitrate = mode is RateControlMode.CQP;
bool blockMaxBitrate = mode is RateControlMode.CBR or RateControlMode.CQP;
bool blockQp = mode is RateControlMode.CBR or RateControlMode.VBR;
SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrate, noAnimate);
SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrate, noAnimate);
SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQp, noAnimate);
}
/// <summary>
/// Replaces the sample rate choices with those supported by the audio codec paired with <paramref name="codec"/>.
/// O5Dropdown.SetValues keeps the current rate when it is in the new list and otherwise selects the first entry; /// O5Dropdown.SetValues keeps the current rate when it is in the new list and otherwise selects the first entry;
/// the resulting rate is written to <paramref name="config"/> when one is given. /// the resulting rate is written to <paramref name="config"/> when one is given.
/// </summary> /// </summary>
private static void UpdateVideoCodecUI(Codec codec, RenderConfig? config = null) private static void UpdateSampleRateUI(Codec videoCodec, RenderConfig? config = null)
{ {
if (_sampleRateDropdown == null) return; if (_sampleRateDropdown == null) return;
_sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[codec]]); _sampleRateDropdown.SetValues(CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[videoCodec]]);
if (config != null && _sampleRateDropdown.Value is var currentRate) if (config != null && _sampleRateDropdown.Value is var currentRate)
{ {
@@ -1962,6 +2129,35 @@ internal static class RenderConfigWindow
} }
} }
private static void UpdateUIBlock(RenderConfig config, bool noAnimate = false)
{
var blockCodecDropdown = false;
var blockRateControlModeDropdown = false;
var blockBitrateInput = config.VideoConfig.RateControlMode is RateControlMode.CQP;
var blockMaxBitrateInput = config.VideoConfig.RateControlMode is RateControlMode.CBR or RateControlMode.CQP;
// ReSharper disable once InconsistentNaming
var blockQPInput = config.VideoConfig.RateControlMode is RateControlMode.CBR or RateControlMode.VBR;
var blockForceSoftwareToggle = false;
var blockKeyframeIntervalSlider = false;
var blockVideoEncodingSettings = config.OutputMode is RenderConfig.RenderOutputMode.PNGSequence;
blockCodecDropdown |= blockVideoEncodingSettings;
blockRateControlModeDropdown |= blockVideoEncodingSettings;
blockBitrateInput |= blockVideoEncodingSettings;
blockMaxBitrateInput |= blockVideoEncodingSettings;
blockQPInput |= blockVideoEncodingSettings;
blockForceSoftwareToggle |= blockVideoEncodingSettings;
blockKeyframeIntervalSlider |= blockVideoEncodingSettings;
SetDropdownBlocked(_codecDropdown, _codecLabel, ref _codecLabelTween, blockCodecDropdown, noAnimate);
SetDropdownBlocked(_rateControlDropdown, _rateControlLabel, ref _rateControlLabelTween, blockRateControlModeDropdown, noAnimate);
SetFieldBlocked(_bitrateInput, _bitrateLabel, ref _bitrateLabelTween, blockBitrateInput, noAnimate);
SetFieldBlocked(_maxBitrateInput, _maxBitrateLabel, ref _maxBitrateLabelTween, blockMaxBitrateInput, noAnimate);
SetFieldBlocked(_qpInput, _qpLabel, ref _qpLabelTween, blockQPInput, noAnimate);
SetToggleBlocked(_forceSoftwareToggle, _forceSoftwareLabel, ref _forceSoftwareLabelTween, blockForceSoftwareToggle, noAnimate);
_keyframeIntervalSlider?.SetBlocked(blockKeyframeIntervalSlider, noAnimate);
}
// =================================================================================================== // ===================================================================================================
// More control factory helpers // More control factory helpers
// =================================================================================================== // ===================================================================================================
@@ -2019,6 +2215,21 @@ internal static class RenderConfigWindow
return input; return input;
} }
/// <summary>Same as the CreateToggleField overload with an <c>out</c> label parameter, without returning the label.</summary>
private static O5Toggle CreateToggleField(
O5Context ctx,
Transform colParent,
string labelKey,
bool? defaultValue,
bool value,
string toggleTextKey,
string id,
Action<bool>? onChanged = null,
bool generateLabel = true)
{
return CreateToggleField(ctx, colParent, labelKey, defaultValue, value, toggleTextKey, id, out _, onChanged, generateLabel);
}
/// <summary> /// <summary>
/// Creates an O5Kit toggle whose text is localized with <paramref name="toggleTextKey"/>. With /// Creates an O5Kit toggle whose text is localized with <paramref name="toggleTextKey"/>. With
/// <paramref name="generateLabel"/> an 18-unit label row is added above it (text from <paramref name="labelKey"/>, /// <paramref name="generateLabel"/> an 18-unit label row is added above it (text from <paramref name="labelKey"/>,
@@ -2032,12 +2243,14 @@ internal static class RenderConfigWindow
bool value, bool value,
string toggleTextKey, string toggleTextKey,
string id, string id,
out TextMeshProUGUI? label,
Action<bool>? onChanged = null, Action<bool>? onChanged = null,
bool generateLabel = true) bool generateLabel = true)
{ {
label = null;
if (generateLabel) if (generateLabel)
{ {
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey); label.text = string.IsNullOrEmpty(labelKey) ? "" : LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim; label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey); LocalizedText.Bind(label, labelKey);
@@ -2066,6 +2279,21 @@ internal static class RenderConfigWindow
return toggle; return toggle;
} }
/// <summary>Same as the CreateDropdownField overload with an <c>out</c> label parameter, without returning the label.</summary>
private static O5Dropdown<T> CreateDropdownField<T>(
O5Context ctx,
Transform colParent,
string labelKey,
T defaultValue,
T value,
IReadOnlyList<T> values,
Func<T, string> display,
string id,
Action<T>? onChanged = null)
{
return CreateDropdownField(ctx, colParent, labelKey, defaultValue, value, values, display, id, out _, onChanged);
}
/// <summary> /// <summary>
/// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit /// Creates a labeled dropdown: an 18-unit label row and a container of preferred height 50 units holding the O5Kit
/// dropdown. <paramref name="display"/> converts values to their shown text. The change callback is not invoked for /// dropdown. <paramref name="display"/> converts values to their shown text. The change callback is not invoked for
@@ -2080,9 +2308,10 @@ internal static class RenderConfigWindow
IReadOnlyList<T> values, IReadOnlyList<T> values,
Func<T, string> display, Func<T, string> display,
string id, string id,
out TextMeshProUGUI label,
Action<T>? onChanged = null) Action<T>? onChanged = null)
{ {
var label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true); label = O5Factory.ControlText(ctx, colParent, 13f, noPad: true);
label.text = LocalizationManager.Get(labelKey); label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim; label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey); LocalizedText.Bind(label, labelKey);
@@ -2167,7 +2396,7 @@ internal static class RenderConfigWindow
if (header.Find("HexInput") is RectTransform hexInput) if (header.Find("HexInput") is RectTransform hexInput)
{ {
hexInput.anchorMin = new Vector2(0.32f, 0f); hexInput.anchorMin = new Vector2(0.32f, 0f);
var tmpInput = hexInput.GetComponentInChildren<TMPro.TMP_InputField>(); var tmpInput = hexInput.GetComponentInChildren<TMP_InputField>();
if (tmpInput != null && tmpInput.textComponent != null) if (tmpInput != null && tmpInput.textComponent != null)
{ {
tmpInput.textComponent.fontSize = 15f; tmpInput.textComponent.fontSize = 15f;
@@ -2178,6 +2407,103 @@ internal static class RenderConfigWindow
return picker; return picker;
} }
/// <summary>
/// Creates a labeled segmented control field. When <paramref name="horizontal"/> is true,
/// places the label on the left and the segmented control on the right; otherwise stacks them vertically.
/// Both the label and segment buttons automatically track language changes via <see cref="LocalizedText"/>.
/// </summary>
private static SegmentedControl<T> CreateSegmentedField<T>(
O5Context ctx,
Transform parent,
string? labelKey,
T defaultValue,
T value,
IReadOnlyList<SegmentOption<T>> options,
string id,
Action<T>? onChanged = null,
bool horizontal = true,
float? height = null)
{
var controlHeight = height ?? ctx.Theme.ControlHeightFor(ctx.Theme.FontSizeBody);
var fieldObj = new GameObject($"Field_Seg_{id}");
fieldObj.transform.SetParent(parent, false);
var fieldRect = fieldObj.AddComponent<RectTransform>();
if (horizontal)
{
var hLayout = fieldObj.AddComponent<HorizontalLayoutGroup>();
hLayout.spacing = 16f;
hLayout.childAlignment = TextAnchor.MiddleLeft;
hLayout.childControlWidth = true;
hLayout.childControlHeight = true;
hLayout.childForceExpandWidth = false;
hLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming
var fieldLE = fieldObj.AddComponent<LayoutElement>();
fieldLE.preferredHeight = controlHeight;
fieldLE.flexibleWidth = 1f;
if (!string.IsNullOrEmpty(labelKey))
{
var label = O5Factory.ControlText(ctx, fieldRect, ctx.Theme.FontSizeBody, noPad: true);
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
var csf = label.gameObject.AddComponent<ContentSizeFitter>();
csf.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
csf.verticalFit = ContentSizeFitter.FitMode.Unconstrained;
labelLE.flexibleWidth = 0f;
}
var segControl = SegmentedControl<T>.Create(ctx, fieldRect, id, defaultValue, value, options, controlHeight, onChanged);
// ReSharper disable once InconsistentNaming
var controlLE = segControl.Rect.GetComponent<LayoutElement>() ?? segControl.Rect.gameObject.AddComponent<LayoutElement>();
controlLE.flexibleWidth = 1f;
controlLE.preferredHeight = controlHeight;
return segControl;
}
else
{
var vLayout = fieldObj.AddComponent<VerticalLayoutGroup>();
vLayout.spacing = 3f;
vLayout.childControlWidth = true;
vLayout.childControlHeight = true;
vLayout.childForceExpandWidth = true;
vLayout.childForceExpandHeight = false;
// ReSharper disable once InconsistentNaming
var fieldLE = fieldObj.AddComponent<LayoutElement>();
fieldLE.flexibleWidth = 1f;
if (!string.IsNullOrEmpty(labelKey))
{
var label = O5Factory.ControlText(ctx, fieldRect, ctx.Theme.FontSizeBody, noPad: true);
label.text = LocalizationManager.Get(labelKey);
label.color = ctx.Theme.TextDim;
LocalizedText.Bind(label, labelKey);
// ReSharper disable once InconsistentNaming
var labelLE = label.gameObject.AddComponent<LayoutElement>();
labelLE.preferredHeight = 18f;
labelLE.minHeight = 18f;
}
var segControl = SegmentedControl<T>.Create(ctx, fieldRect, id, defaultValue, value, options, controlHeight, onChanged);
// ReSharper disable once InconsistentNaming
var controlLE = segControl.Rect.GetComponent<LayoutElement>() ?? segControl.Rect.gameObject.AddComponent<LayoutElement>();
controlLE.flexibleWidth = 1f;
controlLE.preferredHeight = controlHeight;
return segControl;
}
}
// =================================================================================================== // ===================================================================================================
// Window visibility // Window visibility
// =================================================================================================== // ===================================================================================================
+1 -1
View File
@@ -8,7 +8,7 @@ namespace ADOFAIRenderer.Util.Audio;
/// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>). /// The frame rate is converted to an exact fraction Numerator / Denominator (<see cref="RationalUtil.Double2MaxPrecisionInt"/>).
/// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples: /// Frame k receives floor((k + 1) * sampleRate * Denominator / Numerator) - floor(k * sampleRate * Denominator / Numerator) samples:
/// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact /// the remainder of each division is carried into the next frame, so the sum over any number of frames equals the exact
/// sample count of that duration rounded down (for example 44100 Hz at 60 fps gives 735 every frame; at 144 fps three frames of 306 /// sample count of that duration rounded down (for example 44100 Hz at 60 FPS gives 735 every frame; at 144 FPS three frames of 306
/// are followed by one of 307, 306.25 on average). /// are followed by one of 307, 306.25 on average).
/// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder). /// Used by the render thread only (RenderManager.WriteAudioForFrame, once per frame sent to the encoder).
/// </summary> /// </summary>
+1 -1
View File
@@ -175,7 +175,7 @@ internal sealed class AudioClipSource
/// <summary> /// <summary>
/// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start, /// Most decode streams (reader + resampler) a windowed source opens. A load continues the stream positioned at the chunk start,
/// otherwise opens another stream up to this count, otherwise restarts the least recently used stream. /// otherwise opens another stream up to this count, otherwise restarts the least recently used stream.
/// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/mp3 with the clip harness). /// Each stream holds one open reader (file handle and decoder state, measured 0.3 ~ 2.7 MiB for ogg/flac/MP3 with the clip harness).
/// </summary> /// </summary>
public const int MaxDecodeStreams = 4; public const int MaxDecodeStreams = 4;
+1 -1
View File
@@ -12,7 +12,7 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
/// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks. /// - Decoder thread (<see cref="Start"/>): the only thread that decodes, resamples, loads and frees chunks.
/// Each step loads one chunk, the one with the lowest priority value over all sources /// Each step loads one chunk, the one with the lowest priority value over all sources
/// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order). /// (a chunk a consumer waits for, then cursor windows by slot, then resident chunks in order).
/// - Consumers (render thread, main thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads. /// - Consumers (render thread, main Unity thread for the spectrum): <see cref="Acquire"/> waits for the decoder thread and never loads.
/// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data). /// - Main thread: <see cref="GetOrAdd"/> creates readers (for Unity clips this copies the clip data).
/// ///
/// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>. /// Memory has no limit; current and peak usage are tracked and logged by <see cref="LogMemoryUsage"/>.
+1 -1
View File
@@ -7,7 +7,7 @@ namespace ADOFAIRenderer.Util.Audio.Clip;
/// <summary> /// <summary>
/// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk /// Copies a Unity <see cref="AudioClip"/> into memory for clips without a file on disk
/// (built-in hit sounds from Resources, official/DLC level audio). /// (built-in hit sounds from Resources, official/DLC level audio).
/// <see cref="AudioClip.GetData(float[], int)"/> runs on the main thread only, so the whole clip is copied once on the main thread /// <see cref="AudioClip.GetData(float[], int)"/> runs on the main Unity thread only, so the whole clip is copied once on the main Unity thread
/// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>. /// and the decoder thread reads the copy through a <see cref="MemoryPCMReader"/>.
/// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them. /// Streaming clips (<see cref="AudioClipLoadType.Streaming"/>) are rejected because GetData does not read them.
/// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html /// Reference: https://docs.unity3d.com/ScriptReference/AudioClip.GetData.html
+2 -2
View File
@@ -6,7 +6,7 @@ using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer; namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary> /// <summary>
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one effect sound), created by <see cref="VoiceMixer.Play"/>. /// One playing sound of a <see cref="VoiceMixer"/> (the song, or one sound effect), created by <see cref="VoiceMixer.Play"/>.
/// ///
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start, /// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order. /// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
@@ -43,7 +43,7 @@ internal sealed class Voice
Stop Stop
} }
/// <summary>One timed change of a voice, queued by the main thread and applied by the render thread.</summary> /// <summary>One timed change of a voice, queued by the main Unity thread and applied by the render thread.</summary>
internal readonly struct Change internal readonly struct Change
{ {
public readonly ChangeKind Kind; public readonly ChangeKind Kind;
+3 -3
View File
@@ -35,7 +35,7 @@ internal sealed class VoiceMixer : IDisposable
Change Change
} }
/// <summary>Command queued by the main thread and taken by the render thread at the start of <see cref="Mix"/>.</summary> /// <summary>Command queued by the main Unity thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
private readonly struct Command private readonly struct Command
{ {
public readonly CommandKind Kind; public readonly CommandKind Kind;
@@ -55,7 +55,7 @@ internal sealed class VoiceMixer : IDisposable
private readonly AudioClipStore _store; private readonly AudioClipStore _store;
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
// Double-buffered command lists: the main thread appends to _commands under _commandLock; the render thread swaps the // Double-buffered command lists: the main Unity thread appends to _commands under _commandLock; the render thread swaps the
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap. // two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
private readonly object _commandLock = new(); private readonly object _commandLock = new();
private List<Command> _commands = new(); private List<Command> _commands = new();
@@ -126,7 +126,7 @@ internal sealed class VoiceMixer : IDisposable
/// <summary> /// <summary>
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>). /// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
/// Called by the Voice Set methods on the main thread. /// Called by the Voice Set methods on the main Unity thread.
/// </summary> /// </summary>
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value) internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
{ {
+10 -10
View File
@@ -11,7 +11,7 @@ using UnityEngine;
namespace ADOFAIRenderer.Util.Audio; namespace ADOFAIRenderer.Util.Audio;
/// <summary> /// <summary>
/// Audio of one render: the song and effect sounds as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an /// Audio of one render: the song and sound effects as <see cref="Voice"/>s of a <see cref="VoiceMixer"/>, clips from an
/// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain). /// <see cref="AudioClipStore"/>, followed by the filter chain (RMS compressor → look-ahead limiter → hard clip → -1 dB gain).
/// ///
/// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate. /// Time: seconds relative to the render start (RenderManager.dspTimeBase); output sample n is at n / sample rate.
@@ -21,15 +21,15 @@ namespace ADOFAIRenderer.Util.Audio;
/// Threads: /// Threads:
/// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>, /// - Main thread: <see cref="InitializeBGM"/>, <see cref="CacheClip"/>, <see cref="RegisterSoundEvent"/>, <see cref="UpdateBGM"/>,
/// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued). /// source/voice mapping, and <see cref="Commit"/> after each frame (all commands with an earlier time have been queued).
/// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main thread has committed the end of the samples it mixes, /// - Render thread: <see cref="GenerateAudioForFrame"/> waits until the main Unity thread has committed the end of the samples it mixes,
/// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main thread stops. /// so every command takes effect at its own sample. <see cref="Complete"/> ends the waiting when the main Unity thread stops.
/// </summary> /// </summary>
internal sealed class OfflineAudioMixer : IDisposable internal sealed class OfflineAudioMixer : IDisposable
{ {
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary> /// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
private const double ClipVolume = 0.5; private const double ClipVolume = 0.5;
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary> /// <summary>Additional gain of sound effects (registered with <see cref="RegisterSoundEvent"/>).</summary>
private const double EffectVolume = 2.0 / 3.0; private const double EffectVolume = 2.0 / 3.0;
/// <summary> /// <summary>
@@ -62,7 +62,7 @@ internal sealed class OfflineAudioMixer : IDisposable
// Main thread. // Main thread.
/// <summary> /// <summary>
/// Mapping of game AudioSources to the state of the effect sound last started on them. Weak keys: a destroyed source /// Mapping of game AudioSources to the state of the sound effect last started on them. Weak keys: a destroyed source
/// does not keep its state alive. Replaced by a new table to drop every mapping at once. /// does not keep its state alive. Replaced by a new table to drop every mapping at once.
/// </summary> /// </summary>
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new(); private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
@@ -240,7 +240,7 @@ internal sealed class OfflineAudioMixer : IDisposable
} }
/// <summary> /// <summary>
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then. /// Starts a sound effect at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource. /// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
/// </summary> /// </summary>
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param> /// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
@@ -283,7 +283,7 @@ internal sealed class OfflineAudioMixer : IDisposable
_sourceStates.Add(source, state); _sourceStates.Add(source, state);
} }
/// <summary>State of the effect sound last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary> /// <summary>State of the sound effect last started for <paramref name="source"/>. The mapping stays after the sound ends or stops.</summary>
public bool TryGetSourceState(AudioSource? source, out SourceState state) public bool TryGetSourceState(AudioSource? source, out SourceState state)
{ {
state = null!; state = null!;
@@ -291,7 +291,7 @@ internal sealed class OfflineAudioMixer : IDisposable
} }
/// <summary> /// <summary>
/// Stops every effect sound at <paramref name="now"/>, including ones scheduled to start later /// Stops every sound effect at <paramref name="now"/>, including ones scheduled to start later
/// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of /// (AudioManager.StopAllSounds destroys every AudioSource in liveSources, which holds the sources of
/// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing. /// AudioManager._Play and scrConductor.PlayWithEndTime). The song voice keeps playing.
/// </summary> /// </summary>
@@ -342,7 +342,7 @@ internal sealed class OfflineAudioMixer : IDisposable
/// <summary> /// <summary>
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call). /// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
/// Blocks until the main thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>). /// Blocks until the main Unity thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
/// </summary> /// </summary>
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param> /// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param> /// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
@@ -377,7 +377,7 @@ internal sealed class OfflineAudioMixer : IDisposable
} }
} }
/// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main thread committed them.</summary> /// <summary>Mixes voices at samples [first, first + frames) into the reused double buffer, after the main Unity thread committed them.</summary>
private Span<double> MixBlock(long first, int frames) private Span<double> MixBlock(long first, int frames)
{ {
WaitForCommit(first + frames); WaitForCommit(first + frames);
+10 -2
View File
@@ -34,7 +34,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
/// ///
/// ── Threading ──────────────────────────────────────────────────────── /// ── Threading ────────────────────────────────────────────────────────
/// Every public method takes <c>_lock</c>, so calls from different threads are serialized. /// Every public method takes <c>_lock</c>, so calls from different threads are serialized.
/// In the renderer, the main thread constructs and starts the encoder, and the render thread /// In the renderer, the main Unity thread constructs and starts the encoder, and the render thread
/// (RenderManager.RenderThread) sends the video frames and audio samples, calls End() and Dispose(). /// (RenderManager.RenderThread) sends the video frames and audio samples, calls End() and Dispose().
/// Dispose() may also be called from another thread to release an encoder whose render was stopped. /// Dispose() may also be called from another thread to release an encoder whose render was stopped.
/// After End() or Dispose(), the Write*/Send* methods return without doing anything. /// After End() or Dispose(), the Write*/Send* methods return without doing anything.
@@ -916,7 +916,7 @@ namespace ADOFAIRenderer.Util.FFmpeg
// Stream time base: unit of the timestamps stored in the container. // Stream time base: unit of the timestamps stored in the container.
// Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks. // Set to the codec time base (1 / framerate) so that every frame interval is a whole number of ticks.
// When it is left unset, the muxer uses 1/90000, in which the interval of rates such as // When it is left unset, the muxer uses 1/90000, in which the interval of rates such as
// 59.94 fps (1501.5 ticks), 23.976 fps (3753.75 ticks) or 165 fps (545.45 ticks) is not an integer, // 59.94 FPS (1501.5 ticks), 23.976 FPS (3753.75 ticks) or 165 FPS (545.45 ticks) is not an integer,
// and the frame durations alternate between two values. // and the frame durations alternate between two values.
// The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000); // The muxer may still adjust the value in avformat_write_header (e.g. mp4 scales it to at least 10000);
// packet timestamps are rescaled to the final value in SendFrameAndWritePackets. // packet timestamps are rescaled to the final value in SendFrameAndWritePackets.
@@ -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;
+2 -2
View File
@@ -26,7 +26,7 @@ namespace ADOFAIRenderer.Util
/// (RenderManager.SendVideoFrame, then <see cref="RecycleState"/>) or the frame was dropped. /// (RenderManager.SendVideoFrame, then <see cref="RecycleState"/>) or the frame was dropped.
/// </para> /// </para>
/// <para> /// <para>
/// Threads: requests and readback completions run on the main thread; <see cref="RecycleState"/> is also called by /// Threads: requests and readback completions run on the main Unity thread; <see cref="RecycleState"/> is also called by
/// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in /// the render thread. The pool and the disposed flag are guarded by _poolLock. One instance per render, created in
/// RenderManager.SetupPlay with the pixel format of the opened encoder. /// RenderManager.SetupPlay with the pixel format of the opened encoder.
/// </para> /// </para>
@@ -290,7 +290,7 @@ namespace ADOFAIRenderer.Util
/// <summary> /// <summary>
/// Returns a state to the pool after its frame was sent or dropped, or disposes it when the reader is disposed. /// Returns a state to the pool after its frame was sent or dropped, or disposes it when the reader is disposed.
/// Called by the render thread (after sending) and by the main thread (abort, errors, queue cleanup). /// Called by the render thread (after sending) and by the main Unity thread (abort, errors, queue cleanup).
/// </summary> /// </summary>
internal void RecycleState(ReadbackRequestState state) internal void RecycleState(ReadbackRequestState state)
{ {
+14 -10
View File
@@ -18,11 +18,11 @@ 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 Unity main 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
/// at a time. Failures are reported to the callback as null (same as cancel); exceptions are logged as warnings, except a /// at a time. Failures are reported to the callback as null (same as cancel); exceptions are logged as warnings, except a
/// zenity start failure, which silently moves on to kdialog. /// zenity start failure, which silently moves on to kdialog.
/// </para> /// </para>
@@ -43,7 +43,7 @@ internal static class FolderDialogUtil
/// "FolderDialog-Win"; macOS/Linux: thread pool task) and <paramref name="onSelected"/> receives the result. /// "FolderDialog-Win"; macOS/Linux: thread pool task) and <paramref name="onSelected"/> receives the result.
/// </para> /// </para>
/// <para> /// <para>
/// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (the Unity main thread has one), the callback /// Callback thread: when the caller has a <see cref="SynchronizationContext"/> (the main Unity thread has one), the callback
/// is posted to it and runs on the caller's thread; otherwise it runs on the dialog thread. Exceptions thrown by the /// is posted to it and runs on the caller's thread; otherwise it runs on the dialog thread. Exceptions thrown by the
/// callback are caught and logged. /// callback are caught and logged.
/// </para> /// </para>
@@ -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
+192
View File
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using ADOFAIRenderer.Core.Data;
using ADOFAIRenderer.Data;
namespace ADOFAIRenderer.Util;
/// <summary>
/// Formats and sanitizes render output file names according to customizable template patterns.
/// </summary>
internal static class RenderFileNameUtil
{
private static readonly Regex RichTextRegex = new(@"<[^>]+>", RegexOptions.Compiled);
private static readonly Regex TokenRegex = new(@"\{([a-zA-Z0-9_]+)\}", RegexOptions.Compiled);
private static readonly HashSet<string> ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase)
{
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
/// <summary>
/// Removes Unity rich text tags (such as &lt;color=...&gt;, &lt;b&gt;, etc.) from the given text.
/// </summary>
public static string StripRichText(string? input)
{
if (string.IsNullOrEmpty(input)) return string.Empty;
return RichTextRegex.Replace(input, string.Empty);
}
/// <summary>
/// Replaces invalid file name characters within a single token with an underscore.
/// </summary>
public static string SanitizeToken(string? token)
{
if (string.IsNullOrEmpty(token)) return string.Empty;
var invalidChars = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(token.Length);
foreach (var c in token)
{
sb.Append(invalidChars.Contains(c) ? '_' : c);
}
return sb.ToString().Trim(' ', '.');
}
/// <summary>
/// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names.
/// </summary>
public static string SanitizeFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return string.Empty;
var invalidChars = Path.GetInvalidFileNameChars();
var sb = new StringBuilder(fileName.Length);
foreach (var c in fileName)
{
sb.Append(invalidChars.Contains(c) ? '_' : c);
}
var result = sb.ToString().Trim(' ', '.');
if (ReservedWindowsNames.Contains(result))
{
result = "_" + result;
}
return result;
}
/// <summary>
/// Formats a file name without extension by substituting template variables.
/// Available tokens: {song}, {title}, {artist}, {author}, {level}, {preset},
/// {date}, {time}, {year}, {month}, {day}, {hour}, {minute}, {second},
/// {resolution}, {width}, {height}, {fps}.
/// </summary>
public static string FormatFileName(
string? formatTemplate,
DateTime startTime,
RenderConfig config,
string? presetName = null,
string? levelPath = null)
{
var scn = scnGame.instance;
string rawSong = "";
string rawArtist = "";
string rawAuthor = "";
if (scn != null && scn.levelData != null)
{
rawSong = scn.levelData.song ?? "";
rawArtist = scn.levelData.artist ?? "";
rawAuthor = scn.levelData.author ?? "";
}
if (string.IsNullOrWhiteSpace(rawSong) && !string.IsNullOrWhiteSpace(levelPath))
{
var dir = Path.GetDirectoryName(levelPath);
rawSong = !string.IsNullOrWhiteSpace(dir)
? Path.GetFileName(dir)
: Path.GetFileNameWithoutExtension(levelPath);
}
var song = SanitizeToken(StripRichText(rawSong));
var artist = SanitizeToken(StripRichText(rawArtist));
var author = SanitizeToken(StripRichText(rawAuthor));
var level = !string.IsNullOrWhiteSpace(levelPath)
? SanitizeToken(Path.GetFileNameWithoutExtension(levelPath))
: song;
var preset = SanitizeToken(presetName ?? "");
var date = startTime.ToString("yyyy-MM-dd");
var time = startTime.ToString("HH-mm-ss");
var year = startTime.ToString("yyyy");
var month = startTime.ToString("MM");
var day = startTime.ToString("dd");
var hour = startTime.ToString("HH");
var minute = startTime.ToString("mm");
var second = startTime.ToString("ss");
var width = config.RenderWidth.ToString();
var height = config.RenderHeight.ToString();
var resolution = $"{config.RenderWidth}x{config.RenderHeight}";
var effectiveFps = Math.Min(config.VideoFPS, config.RenderFPS);
var fps = Math.Abs(effectiveFps % 1) < 0.001
? ((int)effectiveFps).ToString()
: effectiveFps.ToString("0.##");
var tokens = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "song", song },
{ "title", song },
{ "artist", artist },
{ "author", author },
{ "level", level },
{ "preset", preset },
{ "date", date },
{ "time", time },
{ "year", year },
{ "month", month },
{ "day", day },
{ "hour", hour },
{ "minute", minute },
{ "second", second },
{ "width", width },
{ "height", height },
{ "resolution", resolution },
{ "fps", fps }
};
var template = string.IsNullOrWhiteSpace(formatTemplate)
? GlobalSettings.DefaultFileNameFormat
: formatTemplate;
var replaced = TokenRegex.Replace(template, match =>
{
var key = match.Groups[1].Value;
return tokens.TryGetValue(key, out var val) ? val : match.Value;
});
var sanitized = SanitizeFileName(replaced);
return string.IsNullOrWhiteSpace(sanitized)
? startTime.ToString("yyyy-MM-dd HH-mm-ss")
: sanitized;
}
/// <summary>
/// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists.
/// </summary>
public static string GetUniqueFilePath(string directory, string fileNameWithoutExt, string ext)
{
var normalizedExt = "." + ext.TrimStart('.').ToLowerInvariant();
var fileName = $"{fileNameWithoutExt}{normalizedExt}";
var fullPath = Path.Combine(directory, fileName);
var counter = 1;
while (File.Exists(fullPath))
{
fileName = $"{fileNameWithoutExt} ({counter}){normalizedExt}";
fullPath = Path.Combine(directory, fileName);
counter++;
}
return fullPath;
}
}