diff --git a/API/FrameHookContext.cs b/API/FrameHookContext.cs
index cec727f..ac34e82 100644
--- a/API/FrameHookContext.cs
+++ b/API/FrameHookContext.cs
@@ -18,6 +18,21 @@ namespace ADOFAIRenderer.API;
/// When RenderFPS exceeds VideoFPS, intermediate game rendering frames are skipped and the hook
/// will not be invoked on every rendered game frame.
///
+///
+/// Ping-pong model: the renderer owns two RenderTextures with the size and format of the game frame.
+/// is the current frame, is the buffer the next pass writes.
+/// A pass draws from into and then calls , which makes
+/// the written buffer the new . , and by default
+/// and , swap automatically. The texture in
+/// after the last hook is encoded. When no hook swaps or replaces the frame, the game frame is
+/// encoded unchanged. The first of every frame is the game's own frame texture
+/// (); do not write into it.
+///
+///
+/// Threading and lifetime: hooks run on the main Unity thread at the end of the frame, after the game has
+/// rendered. The textures are valid only during the hook call: do not keep references to ,
+/// or the context. RenderTexture.active is restored after all hooks have run.
+///
///
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
@@ -25,12 +40,22 @@ namespace ADOFAIRenderer.API;
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
public class FrameHookContext
{
+ /// Game frame of this capture (camera quad texture); never written by the context.
private RenderTexture _originalSource = null!;
+
+ /// First ping-pong buffer, owned by FrameHookPipeline.
private RenderTexture _pingRT = null!;
+
+ /// Second ping-pong buffer, owned by FrameHookPipeline.
private RenderTexture _pongRT = null!;
+ /// Current frame: the original source, a ping-pong buffer, or a texture set by ReplaceCurrent.
private RenderTexture _currentSource = null!;
+
+ /// Buffer the next pass writes: always the ping or pong buffer.
private RenderTexture _currentDestination = null!;
+
+ /// True after the first Swap or ReplaceCurrent of this frame.
private bool _hasModified;
///
@@ -45,6 +70,7 @@ public class FrameHookContext
///
/// The active representing the current state of the modified frame.
+ /// Same texture as ; after the last hook it is the frame that gets encoded.
///
public RenderTexture Current => _currentSource;
@@ -59,7 +85,7 @@ public class FrameHookContext
public bool HasModified => _hasModified;
///
- /// The sequential 0-based index of the video frame currently being encoded.
+ /// The sequential 0-based index of the video frame currently being captured (its position in the output video).
///
public long FrameIndex { get; private set; }
@@ -69,12 +95,13 @@ public class FrameHookContext
public long VideoFrameIndex => FrameIndex;
///
- /// The sequential 0-based index of the game render frame.
+ /// The sequential 0-based index of the game render frame, counted from the start of capture.
+ /// With RenderFPS above the video frame rate it advances by more than one between hook calls.
///
public long RenderFrameIndex { get; private set; }
///
- /// The timestamp of the current frame in seconds relative to video start.
+ /// The timestamp of the current frame in seconds relative to video start ( / ).
///
public double VideoTime { get; private set; }
@@ -84,30 +111,32 @@ public class FrameHookContext
public double DeltaTime => VideoFPS > 0 ? 1.0 / VideoFPS : 0.0;
///
- /// The width of the frame in pixels.
+ /// The width of the frame in pixels (width of the current ).
///
public int Width => _currentSource.width;
///
- /// The height of the frame in pixels.
+ /// The height of the frame in pixels (height of the current ).
///
public int Height => _currentSource.height;
///
- /// The configured render frames per second.
+ /// The configured render frames per second (game frames simulated per second of render time).
///
public double RenderFPS { get; private set; }
///
- /// The output video frames per second.
+ /// The output video frames per second: the effective rate, min(configured VideoFPS, ).
///
public double VideoFPS { get; private set; }
///
- /// The number of successful modification passes completed for the current frame.
+ /// The number of modification passes completed for the current frame (calls of and
+ /// , including the automatic swaps of the helper methods). 0 at the start of every frame.
///
public int PassCount { get; private set; }
+ /// Creates a context for the first frame; see for the parameters.
internal FrameHookContext(
RenderTexture originalSource,
RenderTexture pingRT,
@@ -122,6 +151,10 @@ public class FrameHookContext
Reset(originalSource, pingRT, pongRT, firstDestination, frameIndex, renderFrameIndex, videoTime, renderFps, videoFps);
}
+ ///
+ /// Prepares the context for a new frame: Source = , Destination =
+ /// (ping or pong), not modified, PassCount 0, and the frame metadata.
+ ///
internal void Reset(
RenderTexture originalSource,
RenderTexture pingRT,
@@ -152,6 +185,11 @@ public class FrameHookContext
///
/// Blits from to using the specified and automatically swaps ping-pong buffers.
///
+ ///
+ /// Uses Graphics.Blit, which binds as the material's _MainTex and changes
+ /// RenderTexture.active.
+ /// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
+ ///
/// The material containing the shader to apply, or null for a standard copy.
/// The shader pass index to use, or -1 to execute all passes.
public void Blit(Material? material = null, int pass = -1)
@@ -174,6 +212,10 @@ public class FrameHookContext
///
/// Blits from a custom source to using the specified and automatically swaps ping-pong buffers.
///
+ ///
+ /// The current is not read; the result replaces the frame. is
+ /// stretched to the size of .
+ ///
/// The custom source texture to blit from.
/// The material containing the shader to apply, or null for a standard copy.
/// The shader pass index to use, or -1 to execute all passes.
@@ -200,6 +242,7 @@ public class FrameHookContext
///
/// Copies the contents of into without swapping buffers.
/// Call this before drawing custom overlays into if you wish to preserve the background.
+ /// Does not count as a modification; call after drawing to make the result the current frame.
///
public void Copy()
{
@@ -207,7 +250,8 @@ public class FrameHookContext
}
///
- /// Clears with the specified color.
+ /// Clears with the specified color (color only; no depth buffer is cleared).
+ /// Does not swap; the previous RenderTexture.active is restored.
///
/// The color to clear with, or null for .
public void ClearDestination(Color? clearColor = null)
@@ -261,6 +305,11 @@ public class FrameHookContext
///
/// Executes a against the GPU and optionally swaps buffers.
///
+ ///
+ /// The context does not set a render target for the command buffer: the commands must render into
+ /// themselves (for example with SetRenderTarget or Blit(Source, Destination)),
+ /// so build or update the buffer inside the hook with the current and .
+ ///
/// The command buffer to execute.
/// If true (default), automatically calls after execution.
/// Thrown when is null.
@@ -278,6 +327,11 @@ public class FrameHookContext
/// Swaps the active and ping-pong buffers.
/// Call this after manually writing or rendering into so that subsequent hooks or the video encoder read the updated frame.
///
+ ///
+ /// After the call, is the buffer that was , and
+ /// is the other ping-pong buffer (its content is left over from an earlier pass or frame; the next pass overwrites it).
+ /// A texture set by is dropped by a swap.
+ ///
public void Swap()
{
_hasModified = true;
@@ -291,6 +345,12 @@ public class FrameHookContext
///
/// Replaces the current modified frame texture with a custom .
///
+ ///
+ /// The texture becomes / ; is unchanged.
+ /// The texture is read back for encoding when it is still current after the last hook, so it must stay alive and
+ /// unchanged until the end of the hook chain, and it should have the frame's width, height and a format the capture
+ /// can read: the capture sizes its buffers from the texture, and the encoder expects the render resolution.
+ ///
/// The custom texture to set as the active frame.
/// Thrown when is null.
public void ReplaceCurrent(RenderTexture customTexture)
diff --git a/API/IFrameHook.cs b/API/IFrameHook.cs
index 179547f..e3459a7 100644
--- a/API/IFrameHook.cs
+++ b/API/IFrameHook.cs
@@ -9,6 +9,8 @@ namespace ADOFAIRenderer.API;
/// Execution Frequency Note: Frame hooks are only invoked for frames that are actually encoded into the final video (VideoFPS).
/// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped and this hook
/// will not be called on every game frame.
+/// Register an implementation per render with or for every render
+/// with .
///
[SuppressMessage("ReSharper", "UnusedType.Global")]
public interface IFrameHook
@@ -18,6 +20,10 @@ public interface IFrameHook
///
///
/// This method is not called on every rendered game frame if RenderFPS exceeds VideoFPS.
+ /// It is called on the main Unity thread at the end of the frame. Draw from
+ /// into and swap (see ); returning without
+ /// a swap leaves the frame unchanged. Do not keep the context or its textures after returning.
+ /// An exception thrown here is logged and the next hook still runs.
///
/// The frame hook context providing access to ping-pong textures and frame metadata.
void OnRenderFrame(FrameHookContext context);
diff --git a/API/RenderResult.cs b/API/RenderResult.cs
index d1ade45..2dbece4 100644
--- a/API/RenderResult.cs
+++ b/API/RenderResult.cs
@@ -7,6 +7,9 @@ namespace ADOFAIRenderer.API;
///
/// Represents the result and metadata of a completed or canceled render session.
///
+///
+/// Passed to the completion callback registered with . Immutable.
+///
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderResult
@@ -17,7 +20,10 @@ public class RenderResult
public bool Success { get; }
///
- /// The absolute output path of the rendered video file. Empty string if rendering failed or output was not created.
+ /// The output path of the rendered video file: the path given to as passed
+ /// (relative if it was relative), or the path of the time-stamped file in the output directory chosen in the mod
+ /// settings (by default <game root>/Render).
+ /// Empty string if rendering failed or was cancelled (the unfinished file is deleted).
///
public string OutputPath { get; }
@@ -27,12 +33,13 @@ public class RenderResult
public long TotalFrames { get; }
///
- /// The calculated video duration in seconds (based on TotalFrames and FPS).
+ /// The calculated video duration in seconds: divided by the effective video frame rate
+ /// (min of the configured VideoFPS and RenderFPS); 0 when the frame rate is not positive.
///
public double VideoDuration { get; }
///
- /// Total wall-clock time spent rendering.
+ /// Total wall-clock time spent rendering, from the end of the countdown (start of capture) to the end of the render.
///
public TimeSpan RenderDuration { get; }
@@ -43,6 +50,8 @@ public class RenderResult
///
/// Error message or reason if rendering failed or was canceled; null on success.
+ /// A cancelled render has "Rendering was cancelled by user."; a failed render has "Rendering failed: " followed by
+ /// the reason. Use to test the outcome; the texts are meant for display.
///
public string? ErrorMessage { get; }
@@ -82,6 +91,7 @@ public class RenderResult
/// The effective frames per second used to compute video duration.
/// Total wall-clock time spent rendering.
/// A new representing a successful render session.
+ /// The file size is read from disk; it is 0 when the file does not exist or cannot be read.
internal static RenderResult CreateSuccess(string outputPath, long totalFrames, double fps, TimeSpan renderDuration)
{
var fileSizeBytes = 0L;
@@ -103,6 +113,7 @@ public class RenderResult
/// The effective frames per second used to compute video duration.
/// Total wall-clock time spent rendering prior to termination.
/// A new representing a failed or canceled render session.
+ /// OutputPath is empty and FileSizeBytes is 0. A null message is replaced by a generic one.
internal static RenderResult CreateFailure(string? errorMessage, long totalFrames, double fps, TimeSpan renderDuration)
{
var videoDuration = fps > 0 ? totalFrames / fps : 0.0;
diff --git a/API/RenderSession.cs b/API/RenderSession.cs
index 56ad5da..fea8d2c 100644
--- a/API/RenderSession.cs
+++ b/API/RenderSession.cs
@@ -10,13 +10,41 @@ using UnityEngine;
namespace ADOFAIRenderer.API;
///
-/// Fluent builder for configuring and starting a render session.
+/// Fluent builder for configuring and starting a render session, and the registry of global frame hooks.
///
+///
+///
+/// Usage: create an instance, configure it with the With…, On… and AddFrameHook methods,
+/// then call . The builder's values are copied into the render when is called;
+/// changing the builder afterwards does not affect the running render. A builder can be reused for later renders.
+///
+///
+/// Lifecycle of a render: loads the game scene of the level. When the scene has loaded,
+/// a 3-second countdown runs, then capture starts and the level plays with the game clock driven by the render
+/// frame rate (offline, not real time). The render ends successfully seconds
+/// (render time) after the end condition first holds, or is cancelled by the user (ESC), or fails. In every case
+/// the completion callback receives a and the game returns to the level editor.
+///
+///
+/// Threading: every member must be called on the main Unity thread, except the global hook members, which are
+/// thread-safe (they lock an internal object). All callbacks (end condition, completion, last update, frame hooks)
+/// are invoked on the main Unity thread.
+///
+///
+/// Frame hooks run on each captured video frame before it is encoded, in this order: session hooks in the order
+/// they were added, then global hooks in registration order.
+///
+///
[SuppressMessage("ReSharper", "UnusedType.Global")]
[SuppressMessage("ReSharper", "UnusedMember.Global")]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public class RenderSession
{
+ ///
+ /// Handle returned by AddGlobalFrameHook. Key is the object the caller registered (delegate, material or IFrameHook)
+ /// and is used by RemoveGlobalFrameHook; Action is the delegate the pipeline invokes.
+ /// Dispose removes this exact registration once; later calls do nothing.
+ ///
private sealed class GlobalHookRegistration : IDisposable
{
public object Key { get; }
@@ -37,6 +65,10 @@ public class RenderSession
}
}
+ ///
+ /// Session hook added to this builder. Key is the object the caller passed (delegate, material or IFrameHook) and
+ /// is used by RemoveFrameHook; Action is the delegate copied to the render by Start.
+ ///
private sealed class SessionHookEntry
{
public object Key { get; }
@@ -49,11 +81,22 @@ public class RenderSession
}
}
+ /// Global hooks in registration order. Guarded by GlobalHooksLock.
private static readonly List GlobalFrameHooks = new();
+
+ /// Lock for GlobalFrameHooks and _globalHooksVersion (global hooks may be changed from any thread).
private static readonly object GlobalHooksLock = new();
+
+ ///
+ /// Incremented on every change of GlobalFrameHooks. RenderManager compares it each captured frame to rebuild its
+ /// cached hook list only after a change. Guarded by GlobalHooksLock.
+ ///
private static int _globalHooksVersion;
+ /// Session hooks of this builder in the order they were added.
private readonly List _frameHooks = new();
+
+ /// Autoplay setting copied to the render by Start (default true).
private bool _auto = true;
///
@@ -63,27 +106,48 @@ public class RenderSession
{
///
/// Time-related patch controlling frame timing and clock synchronization.
+ /// While capturing, Time.time, Time.timeAsDouble, Time.unscaledTime,
+ /// Time.unscaledTimeAsDouble, Time.deltaTime, Time.unscaledDeltaTime, Time.frameCount
+ /// and AudioSettings.dspTime return values of the render clock (one frame = 1 / RenderFPS seconds),
+ /// so the game advances exactly one render frame per Unity frame regardless of how long a frame takes.
+ /// Disabling it leaves these values to Unity.
///
Time,
///
/// Screen-related patch controlling screen resolution.
+ /// While the render is active (from the countdown on), Screen.width and Screen.height return
+ /// and , so the game creates its
+ /// camera RenderTexture at the render resolution.
///
Screen
}
+ /// Time patch setting copied to the render by Start (default true).
private bool _timePatch = true;
+
+ /// Screen patch setting copied to the render by Start (default true).
private bool _screenPatch = true;
+
+ /// Custom output file path, or null for the default output directory.
private string? _outputPath;
+ /// Notification setting copied to the render by Start (default true).
private bool _notification = true;
+ /// Custom end condition, or null to keep the default (level cleared).
private Func? _endCondition;
+
+ /// Combined completion callbacks, or null to keep the default (notification).
private Action? _onComplete;
+
+ /// Combined last-update callbacks, or null for none.
private Action? _onLastUpdate;
///
- /// Sets whether auto mode is enabled.
+ /// Sets whether auto mode (the game's autoplay, RDC.auto) is enabled during the render.
+ /// With auto mode off, the level is not played automatically; the render still enables the game's no-fail
+ /// settings (scrController.noFail, scrController.noFailInfiniteMargin).
///
/// true to enable auto mode; otherwise, false. Default is true.
/// The current instance for method chaining.
@@ -115,6 +179,13 @@ public class RenderSession
///
/// Sets the custom condition function that determines when the render session should end.
///
+ ///
+ /// 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 true, the render continues for seconds of
+ /// render time and then finishes successfully; it is not required to stay true. The default condition is
+ /// that the level is cleared (scrController.instance.state == States.Won). Calling this method again
+ /// replaces the previous condition. Exceptions thrown by the condition are not caught.
+ ///
/// A delegate returning true when the session end condition is met.
/// The current instance for method chaining.
public RenderSession WithEndCondition(Func endCondition)
@@ -126,6 +197,13 @@ public class RenderSession
///
/// Sets the custom output path for the rendered file.
///
+ ///
+ /// The directory of the path is created by when it does not exist. An existing file at the path
+ /// is overwritten. The container format follows the file extension. With null, the file is named
+ /// yyyy-MM-dd HH-mm-ss.<ext> (local start time, extension from )
+ /// in the output directory chosen in the mod settings. When the render is cancelled or fails, the unfinished file
+ /// is deleted.
+ ///
/// The file path where the output will be saved, or null to use the default capture directory.
/// The current instance for method chaining.
public RenderSession WithOutputPath(string? outputPath)
@@ -137,6 +215,10 @@ public class RenderSession
///
/// Sets whether desktop notifications should be displayed when rendering completes.
///
+ ///
+ /// The notification is shown by the default completion callback only. When a callback is added with
+ /// , it replaces the default callback and no notification is shown. Default is true.
+ ///
/// true to enable desktop notifications; otherwise, false.
/// The current instance for method chaining.
public RenderSession WithNotification(bool enabled)
@@ -148,6 +230,13 @@ public class RenderSession
///
/// Adds a callback action to be invoked when the render session completes or fails.
///
+ ///
+ /// 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.
+ /// 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.
+ /// An exception thrown by a callback is logged and stops the callbacks added after it.
+ ///
/// The callback action that receives the resulting .
/// The current instance for method chaining.
public RenderSession OnComplete(Action callback)
@@ -159,6 +248,11 @@ public class RenderSession
///
/// Adds a callback action to be invoked on the last update of each frame in the render session.
///
+ ///
+ /// Called on the main Unity thread every Unity frame after all other LateUpdate calls, from the moment the game scene
+ /// 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.
+ ///
/// The callback action to execute.
/// The current instance for method chaining.
public RenderSession OnLastUpdate(Action callback)
@@ -172,9 +266,19 @@ public class RenderSession
/// The hook can modify the rendered frame using GPU ping-pong double buffering.
///
///
+ ///
/// Execution Frequency Note: This hook is invoked only for frames that are captured for the output video.
/// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped,
/// so this hook will not be called on every game frame.
+ ///
+ ///
+ /// The hook is called on the main Unity thread at the end of the frame, after the game has rendered it and before
+ /// the GPU readback for encoding is requested. Write the result into and
+ /// swap (or use , which swaps); a hook that does not swap leaves
+ /// the frame unchanged. An exception thrown by the hook is logged and the next hook still runs.
+ /// Session hooks apply only to the render started by and are discarded when it ends.
+ /// The same delegate can be added more than once; it then runs once per addition.
+ ///
///
/// The callback action that receives a .
/// The current instance for method chaining.
@@ -190,6 +294,13 @@ public class RenderSession
/// Adds a material-based post-processing pass to be executed for each encoded video frame.
/// Automatically performs a GPU blit from source to destination and swaps buffers.
///
+ ///
+ /// Equivalent to a hook calling : the current frame is bound as the
+ /// material's _MainTex by Graphics.Blit. The material is referenced, not copied, so property changes
+ /// made to it later apply to the following frames. Execution rules are the same as
+ /// .
+ /// Reference: https://docs.unity3d.com/ScriptReference/Graphics.Blit.html
+ ///
/// The material containing the shader to apply.
/// The shader pass index to use, or -1 to execute all passes.
/// The current instance for method chaining.
@@ -204,6 +315,10 @@ public class RenderSession
///
/// Adds a custom frame modification hook implementing .
///
+ ///
+ /// is invoked with the same rules as
+ /// . Remove it with .
+ ///
/// The hook instance to execute.
/// The current instance for method chaining.
/// Thrown when is null.
@@ -239,6 +354,11 @@ public class RenderSession
///
/// Removes a previously added frame modification hook callback from this session.
///
+ ///
+ /// Removes the first entry (in the order added) whose registered object is or whose delegate
+ /// equals it. When the same delegate was added several times, one call removes one addition. Does nothing when no
+ /// entry matches. Affects only renders started after this call.
+ ///
/// The hook action to remove.
/// The current instance for method chaining.
public RenderSession RemoveFrameHook(Action hook)
@@ -251,6 +371,10 @@ public class RenderSession
///
/// Removes a previously added material-based post-processing pass from this session.
///
+ ///
+ /// Removes every pass added with (any pass index). Does nothing when the material is
+ /// null or destroyed. Affects only renders started after this call.
+ ///
/// The material to remove.
/// The current instance for method chaining.
public RenderSession RemoveFrameHook(Material material)
@@ -271,6 +395,9 @@ public class RenderSession
///
/// Removes a previously added from this session.
///
+ ///
+ /// Removes the first entry added with this instance (reference equality). Affects only renders started after this call.
+ ///
/// The hook instance to remove.
/// The current instance for method chaining.
public RenderSession RemoveFrameHook(IFrameHook hook)
@@ -294,9 +421,19 @@ public class RenderSession
/// Registers a global frame modification hook that will execute on every render session.
///
///
+ ///
/// Execution Frequency Note: Global frame hooks are executed only for frames that are captured for the output video.
/// When RenderFPS is higher than VideoFPS, intermediate game render frames are skipped,
/// so this hook will not be called on every game frame.
+ ///
+ ///
+ /// The registration stays active for every following render (and a render already running, from its next
+ /// captured frame) until the returned handle is disposed or the hook is removed with
+ /// or .
+ /// Global hooks run after the session hooks, in registration order, on the main Unity thread; an exception thrown
+ /// by the hook is logged and the next hook still runs. This method is thread-safe. Registering the same delegate
+ /// twice creates two registrations.
+ ///
///
/// The callback action that receives a .
/// An registration handle that unregisters the hook when disposed.
@@ -316,6 +453,10 @@ public class RenderSession
///
/// Registers a global material-based post-processing pass that will execute on every render session.
///
+ ///
+ /// Same lifetime and ordering as ; the pass is applied as
+ /// . Thread-safe.
+ ///
/// The material containing the shader to apply.
/// The shader pass index to use, or -1 to execute all passes.
/// An registration handle that unregisters the hook when disposed.
@@ -335,6 +476,9 @@ public class RenderSession
///
/// Registers a global frame modification hook implementing .
///
+ ///
+ /// Same lifetime and ordering as . Thread-safe.
+ ///
/// The hook instance to execute.
/// An registration handle that unregisters the hook when disposed.
/// Thrown when is null.
@@ -353,6 +497,10 @@ public class RenderSession
///
/// Unregisters a previously registered global frame modification hook.
///
+ ///
+ /// Removes the first registration (in registration order) whose registered object is or whose
+ /// delegate equals it. Thread-safe; a running render stops calling the hook from its next captured frame.
+ ///
/// The hook action to remove.
/// true if the hook was successfully removed; otherwise, false.
public static bool RemoveGlobalFrameHook(Action hook)
@@ -373,6 +521,7 @@ public class RenderSession
///
/// Unregisters a previously registered global material-based post-processing pass.
///
+ /// Removes every registration made with (any pass index). Thread-safe.
/// The material to remove.
/// true if any hooks matching were removed; otherwise, false.
public static bool RemoveGlobalFrameHook(Material material)
@@ -399,6 +548,7 @@ public class RenderSession
///
/// Unregisters a previously registered global frame modification hook implementing .
///
+ /// Removes the first registration made with this instance (reference equality). Thread-safe.
/// The hook instance to remove.
/// true if the hook was successfully removed; otherwise, false.
public static bool RemoveGlobalFrameHook(IFrameHook hook)
@@ -416,6 +566,7 @@ public class RenderSession
}
}
+ /// Removes one registration (called by its Dispose). Does nothing when it was already removed.
private static void RemoveGlobalRegistration(GlobalHookRegistration registration)
{
lock (GlobalHooksLock)
@@ -430,6 +581,10 @@ public class RenderSession
///
/// Clears all registered global frame modification hooks.
///
+ ///
+ /// Removes the registrations of every mod, not only the caller's. Handles returned earlier stay valid; disposing them
+ /// does nothing. Thread-safe.
+ ///
public static void ClearGlobalFrameHooks()
{
lock (GlobalHooksLock)
@@ -443,7 +598,7 @@ public class RenderSession
}
///
- /// Indicates whether any global frame modification hooks are currently registered.
+ /// Indicates whether any global frame modification hooks are currently registered (by any mod). Thread-safe.
///
public static bool HasGlobalFrameHooks
{
@@ -457,7 +612,7 @@ public class RenderSession
}
///
- /// The number of currently registered global frame modification hooks.
+ /// The number of currently registered global frame modification hooks (by any mod). Thread-safe.
///
public static int GlobalFrameHookCount
{
@@ -470,6 +625,7 @@ public class RenderSession
}
}
+ /// Change counter of the global hook list (see _globalHooksVersion). Thread-safe.
internal static int GlobalHooksVersion
{
get
@@ -481,6 +637,7 @@ public class RenderSession
}
}
+ /// Appends the delegates of every global hook, in registration order, to . Thread-safe.
internal static void CopyGlobalFrameHooksTo(List> destination)
{
lock (GlobalHooksLock)
@@ -495,11 +652,29 @@ public class RenderSession
///
/// Applies the configured flags and initiates the render session.
///
+ ///
+ ///
+ /// Must be called on the main Unity thread while no render is running; this method does not check for a running
+ /// render. It copies the builder's options and hooks into the render, copies
+ /// (later changes to the caller's object do not affect the render), creates the output directory, and starts
+ /// loading the game scene (scnGame) with the level. It returns immediately; the render runs over the following
+ /// frames and reports its end through the callback.
+ ///
+ ///
+ /// Options that were not set keep their defaults: auto mode on, both patches on, default output path, notification
+ /// on, end condition "level cleared", default completion callback (notification), no last-update callback.
+ ///
+ ///
/// The configuration settings for the render session.
- /// The file path of the level to render.
+ /// The file path of the level to render (the .adofai file).
/// Thrown when the output directory cannot be determined from the output path.
+ ///
+ /// Thrown by the directory creation when the output path has no directory part (for example a bare file name).
+ ///
public void Start(RenderConfig renderConfig, string levelPath)
{
+ // Callbacks not set on this builder keep the values RenderContext holds; RenderContext.Reset at the end of every
+ // render restores the defaults.
RenderContext.Auto = _auto;
RenderContext.TimePatch = _timePatch;
RenderContext.ScreenPatch = _screenPatch;
diff --git a/Core/Data/GlobalSettings.cs b/Core/Data/GlobalSettings.cs
index 398f88e..f4bb28a 100644
--- a/Core/Data/GlobalSettings.cs
+++ b/Core/Data/GlobalSettings.cs
@@ -6,29 +6,59 @@ using Newtonsoft.Json;
namespace ADOFAIRenderer.Core.Data;
+///
+/// Mod-wide settings that are not part of a render preset, stored as JSON in
+/// <game root>/UserData/ADOFAIRenderer/global_settings.json (see ).
+///
internal class GlobalConfig
{
+ ///
+ /// Output directory chosen in the render configuration window; null or empty selects
+ /// GlobalSettings.DefaultOutputDirectory. Ignored while the directory does not exist (see EffectiveOutputDirectory).
+ ///
public string? OutputDirectory { get; set; }
+ /// Name of the preset last selected or saved in the render configuration window; selected again when it opens.
public string? LastPresetName { get; set; }
+ ///
+ /// True once PresetManager.EnsureDefaultPresetsCreated has written the built-in preset files. Built-in presets the user
+ /// deleted afterwards are not written again.
+ ///
public bool DefaultPresetsInitialized { get; set; }
}
+///
+/// Loads and saves . The file is read once by the static constructor and again by
+/// RenderMod.OnInitializeMelon (Load), and written by Save. Read and write errors are logged as warnings and never
+/// thrown; a file that cannot be read yields the default settings. Main thread.
+///
internal static class GlobalSettings
{
+ /// MelonLoader's game root directory, or the application base directory when it is empty.
private static string GetGameRootDir()
{
var root = MelonEnvironment.GameRootDirectory;
return string.IsNullOrEmpty(root) ? AppDomain.CurrentDomain.BaseDirectory : root;
}
+ /// <game root>/UserData/ADOFAIRenderer/global_settings.json.
private static string SettingsFilePath =>
Path.Combine(GetGameRootDir(), "UserData", "ADOFAIRenderer", "global_settings.json");
+ ///
+ /// Settings in memory. Changes are kept in memory until is called; replaces the
+ /// object.
+ ///
public static GlobalConfig Current { get; private set; } = new();
+ /// Output directory used when none is configured: <game root>/Render.
public static string DefaultOutputDirectory =>
Path.Combine(GetGameRootDir(), "Render");
+ ///
+ /// Directory where renders without an explicit output path are written: Current.OutputDirectory when it is set and
+ /// exists, otherwise . The default directory is not created here
+ /// (RenderManager.SetupCaptureDirectory creates it).
+ ///
public static string EffectiveOutputDirectory
{
get
@@ -47,6 +77,10 @@ internal static class GlobalSettings
Load();
}
+ ///
+ /// Replaces with the content of the settings file. A missing, empty or unreadable file gives the
+ /// default settings (an unreadable file is logged as a warning).
+ ///
public static void Load()
{
try
@@ -66,6 +100,10 @@ internal static class GlobalSettings
Current = new GlobalConfig();
}
+ ///
+ /// Writes as indented JSON to the settings file, creating the directory when needed.
+ /// Errors are logged as warnings.
+ ///
public static void Save()
{
try
diff --git a/Core/FirstUpdateHook.cs b/Core/FirstUpdateHook.cs
index 1f1debb..185e518 100644
--- a/Core/FirstUpdateHook.cs
+++ b/Core/FirstUpdateHook.cs
@@ -4,14 +4,30 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
+///
+/// MonoBehaviour that runs before every other script's Update (execution order int.MinValue; scripts run from the
+/// lowest order to the highest). Lives on a DontDestroyOnLoad object created by RenderMod.
+/// It performs the start-up checks (FFmpeg libraries present, NVIDIA driver version) and then advances the render
+/// frame counter through RenderManager.OnPreUpdate once per Unity frame, so every other script of that frame sees
+/// the same render time.
+/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
+///
[DefaultExecutionOrder(int.MinValue)]
internal class FirstUpdateHook : MonoBehaviour
{
+ /// True once the FFmpeg libraries were found in UserLibs (after the download UI, if it was needed).
private bool _checkedFFmpeg;
+
+ /// True once the NVIDIA driver version check ran (it runs once per game session).
private bool _checkedNvidiaDriver;
+ ///
+ /// Main thread, every frame. The checks are done in order and each one blocks the following steps while its dialog
+ /// is open: the frame counter is not advanced until both checks are done.
+ ///
private void Update()
{
+ // 1. FFmpeg libraries: while the download UI is open, wait. When the libraries are missing, open the download UI.
if (!_checkedFFmpeg)
{
if (FFmpegDownloadUI.Instance) return;
@@ -24,6 +40,7 @@ internal class FirstUpdateHook : MonoBehaviour
_checkedFFmpeg = true;
}
+ // 2. NVIDIA driver version: waits until no other warning dialog is open, then runs once.
if (!_checkedNvidiaDriver)
{
if (CommonWarningDialogUI.Instance) return;
@@ -32,12 +49,19 @@ internal class FirstUpdateHook : MonoBehaviour
CheckNvidiaDriverWarning();
}
+ // 3. Render frame counter (only counts while a render is in the OnScene stage or later).
if (RenderManager.Instance)
{
RenderManager.Instance.OnPreUpdate();
}
}
+ ///
+ /// Shows a warning dialog when the active GPU is an NVIDIA GPU with a driver version below 610.00.
+ /// No dialog is shown when the GPU is not NVIDIA or the version cannot be determined (GPUUtil returns null).
+ /// Buttons: open the NVIDIA driver download page and quit the game, disable the render feature for this session
+ /// (RenderMod.IsFeatureDisabled), or continue.
+ ///
private void CheckNvidiaDriverWarning()
{
var driverVer = Util.GPUUtil.GetNvidiaDriverVersion();
diff --git a/Core/FrameHookPipeline.cs b/Core/FrameHookPipeline.cs
index cf59da4..d25d9f4 100644
--- a/Core/FrameHookPipeline.cs
+++ b/Core/FrameHookPipeline.cs
@@ -6,16 +6,58 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
+///
+/// Runs the frame hooks (session and global, see RenderSession) on a captured frame before it is read back.
+/// Owns two RenderTextures (ping and pong) with the size and format of the source frame; each hook pass reads the
+/// current frame and writes the other buffer (see FrameHookContext). One instance per render, created in
+/// RenderManager.SetupPlay and disposed when the render ends. Main thread only.
+///
internal class FrameHookPipeline : IDisposable
{
+ /// First ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.
private RenderTexture? _pingRT;
+
+ /// Second ping-pong buffer. Created by EnsureBuffers, released by ReleaseBuffers.
private RenderTexture? _pongRT;
+
+ ///
+ /// Buffer returned as the output of the previous Process call (ping or pong), or null when the previous frame was
+ /// not modified by any hook. Its readback may still be in flight, so the next frame starts writing the other buffer.
+ ///
private RenderTexture? _lastOutputRT;
+
+ /// Context object passed to the hooks; reset and reused for every frame (no allocation per frame).
private FrameHookContext? _reusableContext;
+
+ /// Width of the current buffers in pixels (0 when no buffers exist).
private int _width;
+
+ /// Height of the current buffers in pixels (0 when no buffers exist).
private int _height;
+
+ /// Format of the source texture the current buffers were created for.
private RenderTextureFormat _format;
+ ///
+ /// Runs in order on and returns the resulting texture.
+ /// An exception thrown by a hook is logged and the remaining hooks still run.
+ /// RenderTexture.active is restored after the hooks.
+ ///
+ /// Captured game frame. Hooks do not write into it (they write the ping-pong buffers).
+ /// Video frame index (0-based) of this frame.
+ ///
+ /// Render frame index (0-based, RenderManager.frameCountForVideo): counts every game frame since capture started,
+ /// including game frames that were not captured because they fall in an already captured video frame.
+ ///
+ /// Time of this video frame in seconds from the start of the video.
+ /// Render frame rate (game frames per second).
+ /// Effective video frame rate (min(VideoFPS, RenderFPS)).
+ /// Hooks to run, in order. Session hooks come before global hooks.
+ ///
+ /// when there are no hooks, the source is not created, the buffers could not be
+ /// created, or no hook modified the frame; otherwise the texture holding the final frame (a ping-pong buffer, or a
+ /// texture a hook passed to FrameHookContext.ReplaceCurrent).
+ ///
public RenderTexture? Process(
RenderTexture sourceTexture,
long frameIndex,
@@ -81,6 +123,8 @@ internal class FrameHookPipeline : IDisposable
}
}
+ // Current is the source when nothing was modified. A texture set by ReplaceCurrent is stored in
+ // _lastOutputRT too; it then matches neither buffer and the next frame starts with ping.
var result = _reusableContext.Current;
_lastOutputRT = _reusableContext.HasModified ? result : null;
return result;
@@ -91,6 +135,12 @@ internal class FrameHookPipeline : IDisposable
}
}
+ ///
+ /// Creates the ping and pong buffers when they do not exist, were released, or the source size or format changed.
+ /// The buffers copy the source descriptor without depth, MSAA and mipmaps, and enable random write (UAV) when the
+ /// GPU supports compute shaders and random write for that format, so compute shaders can write them.
+ /// When creating them from the descriptor throws, ARGB32 buffers of the same size are created.
+ ///
private void EnsureBuffers(RenderTexture sourceTexture)
{
var width = sourceTexture.width;
@@ -160,6 +210,9 @@ internal class FrameHookPipeline : IDisposable
}
}
+ ///
+ /// Releases and destroys both buffers and drops the reusable context. The next Process call creates them again.
+ ///
public void ReleaseBuffers()
{
if (_pingRT)
@@ -182,6 +235,7 @@ internal class FrameHookPipeline : IDisposable
_reusableContext = null;
}
+ /// Same as . Main thread (Object.Destroy is used).
public void Dispose()
{
ReleaseBuffers();
diff --git a/Core/PostRenderHook.cs b/Core/PostRenderHook.cs
index e8e49a9..b6ac16c 100644
--- a/Core/PostRenderHook.cs
+++ b/Core/PostRenderHook.cs
@@ -5,10 +5,22 @@ using UnityEngine;
namespace ADOFAIRenderer.Core;
+///
+/// Per-frame hook attached to the scrCamera GameObject by RenderManager when the game scene of a render has loaded,
+/// and destroyed again when a render is cancelled or fails.
+/// Execution order int.MaxValue: its LateUpdate runs after every other script's LateUpdate of the frame.
+/// Each frame it calls RenderManager.OnLastUpdate (LateUpdate) and, after the frame has been rendered
+/// (end of frame), RenderManager.OnPostFrame with the texture shown on scrCamera's camera quad, which is the frame
+/// that gets captured.
+/// Reference: https://docs.unity3d.com/ScriptReference/DefaultExecutionOrder.html
+///
[DefaultExecutionOrder(int.MaxValue)]
internal class PostRenderHook : MonoBehaviour
{
+ /// Private field scrCamera.camQuadMesh: the MeshRenderer of the quad that displays the camera RenderTexture.
private FieldInfo? _camQuadMeshField;
+
+ /// Cached value of camQuadMesh for the current scrCamera instance. Looked up again while it is null or destroyed.
private MeshRenderer? _camQuadMeshRenderer;
private void Awake()
@@ -16,6 +28,10 @@ internal class PostRenderHook : MonoBehaviour
_camQuadMeshField = AccessTools.Field(typeof(scrCamera), "camQuadMesh");
}
+ ///
+ /// Main thread, once per frame after all other LateUpdate calls. Runs the last-update work of RenderManager
+ /// (OnLastUpdate callbacks of the session) and schedules the end-of-frame capture.
+ ///
private void LateUpdate()
{
if (RenderManager.Instance)
@@ -24,6 +40,11 @@ internal class PostRenderHook : MonoBehaviour
PostFrameAsync().Forget();
}
+ ///
+ /// Waits until the end of the current frame (after the cameras have rendered) and passes the camera quad's main
+ /// texture to RenderManager.OnPostFrame. The wait is cancelled when this component is destroyed.
+ /// The texture is null when the quad's material has no RenderTexture; OnPostFrame then only updates the audio.
+ ///
private async UniTaskVoid PostFrameAsync()
{
await UniTask.WaitForEndOfFrame(this, destroyCancellationToken);
diff --git a/Core/PresetManager.cs b/Core/PresetManager.cs
index eaa7e6a..97725a7 100644
--- a/Core/PresetManager.cs
+++ b/Core/PresetManager.cs
@@ -11,11 +11,16 @@ using Newtonsoft.Json.Linq;
namespace ADOFAIRenderer.Core;
+/// A named render configuration, stored as one JSON file in the presets directory.
public class RenderPreset
{
+ /// Display name of the preset. A file without a name uses the file name (without extension).
public string Name { get; set; } = string.Empty;
+
+ /// Render configuration of the preset.
public RenderConfig Config { get; set; } = null!;
+ /// Returns a copy with a copied configuration.
public RenderPreset Clone()
{
return new RenderPreset
@@ -26,8 +31,18 @@ public class RenderPreset
}
}
+///
+/// Loads, saves and deletes render presets. Presets are JSON files (one per preset, file name derived from the preset
+/// name) in <game root>/UserData/ADOFAIRenderer/Presets. The built-in presets are written once, on first use
+/// (GlobalConfig.DefaultPresetsInitialized), so the user can edit or delete them. Main thread.
+///
public static class PresetManager
{
+ ///
+ /// 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.
+ /// Video bitrates are in kbps.
+ ///
private static readonly List DefaultPresets = new()
{
new RenderPreset
@@ -96,6 +111,10 @@ public static class PresetManager
}
};
+ ///
+ /// <root>/UserData/ADOFAIRenderer/Presets, where root is MelonLoader's game root directory, or the application
+ /// base directory when that is unavailable.
+ ///
private static string GetPresetsDirectory()
{
string? root = null;
@@ -125,6 +144,10 @@ public static class PresetManager
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
}
+ ///
+ /// Creates the presets directory and, once per installation (GlobalConfig.DefaultPresetsInitialized), writes the
+ /// built-in presets whose files do not exist yet, then saves the flag. Errors are logged, not thrown.
+ ///
public static void EnsureDefaultPresetsCreated()
{
try
@@ -164,6 +187,12 @@ public static class PresetManager
}
}
+ ///
+ /// Saves a copy of as preset , overwriting the file of the same
+ /// safe file name (see GetSafeFileName).
+ ///
+ /// The name is empty or white space.
+ /// The directory or file cannot be written (not caught).
public static void SavePreset(string name, RenderConfig config)
{
if (string.IsNullOrWhiteSpace(name))
@@ -195,6 +224,13 @@ public static class PresetManager
File.WriteAllText(filePath, json);
}
+ ///
+ /// Loads every *.json preset file (after creating the built-in presets on first use). Files written by older
+ /// versions are migrated in memory (old key names and flat properties moved into the nested configs); the files are
+ /// not rewritten. A file that cannot be read is logged and skipped. When two files have the same preset name
+ /// (case-insensitive), the first one in directory order is used. Returns copies of the built-in presets when the
+ /// directory does not exist.
+ ///
public static List GetPresets()
{
EnsureDefaultPresetsCreated();
@@ -332,6 +368,10 @@ public static class PresetManager
return presets;
}
+ ///
+ /// Renames key to : a non-null old value overwrites the target
+ /// key, and the old key is always removed.
+ ///
private static void TryChangeKey(JObject configObj, string keyBefore, string targetKey)
{
var legacyToken = configObj[keyBefore];
@@ -341,6 +381,12 @@ public static class PresetManager
configObj.Remove(keyBefore);
}
+ ///
+ /// Deletes the file of preset : first the file with the safe file name; otherwise the first
+ /// file whose base name or stored "Name" matches (case-insensitive). Does nothing for an empty name or when no file
+ /// matches.
+ ///
+ /// The file with the safe file name exists but cannot be deleted (not caught).
public static void DeletePreset(string name)
{
if (string.IsNullOrWhiteSpace(name)) return;
diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs
index bb41f58..43ced1e 100644
--- a/Core/RenderManager.cs
+++ b/Core/RenderManager.cs
@@ -25,24 +25,61 @@ using ThreadPriority = System.Threading.ThreadPriority;
namespace ADOFAIRenderer.Core;
+///
+/// Progress of the current render (RenderManager.stage). Values are ordered: checks use comparisons such as
+/// stage >= OnScene.
+///
+/// - None: no render. Set at start-up and whenever a render ends, is cancelled or fails (ResetRenderState).
+/// - Load: RenderSession.Start was called; the game scene (scnGame) is being loaded.
+/// - OnScene: the game scene has loaded (RenderManager.Update set up the scene): the frame counter runs, the
+/// 3-second countdown is shown, input is ignored. Nothing is captured yet. RenderManager.IsActive is true from this stage.
+/// - Render: the encoder and render thread run and frames are captured (set by SetupPlay after the countdown).
+/// RenderManager.IsCapturingActive is true; the time patches replace Unity's clock with the render clock.
+///
+///
internal enum RenderStage
{
None, Load, OnScene, Render
}
+///
+/// Options of the current render, copied from a by RenderSession.Start and read by
+/// RenderManager and the Harmony patches. restores the defaults when the render environment is
+/// restored (RenderManager.RestoreRenderEnvironment). Main thread only.
+///
internal static class RenderContext
{
+ /// Value assigned to RDC.auto (the game's autoplay flag) when the game scene of the render has loaded.
public static bool Auto = true;
+
+ ///
+ /// Enables the time patches (Time.time / unscaledTime / deltaTime / frameCount, AudioSettings.dspTime) while capturing,
+ /// which drive the game by the render clock (frameCount / RenderFPS) instead of real time.
+ ///
public static bool TimePatch = true;
+
+ /// Enables the Screen.width / Screen.height patches (report the render resolution) while the render is active.
public static bool ScreenPatch = true;
+
+ /// Output file path of the render, or null for a time-stamped file in GlobalSettings.EffectiveOutputDirectory.
public static string? OutputPath;
+ /// Whether the default OnComplete callback shows an OS notification after a successful render.
public static bool Notification = true;
+
+ /// Frame hooks of the current session, run before the global hooks (see RenderSession.AddFrameHook).
public static readonly List> SessionFrameHooks = new();
+ /// Default end condition: the level is cleared (scrController state Won).
private static readonly Func EndConditionDefault = () => scrController.instance.state == States.Won;
+
+ ///
+ /// Polled by RenderManager.Update every frame from the OnScene stage. When it first returns true, the render
+ /// continues for RenderConfig.DelayAfterEnd seconds of render time and then ends successfully.
+ ///
public static Func EndCondition = EndConditionDefault;
+ /// Default completion callback: shows an OS notification for a successful render when Notification is true.
private static readonly Action OnCompleteDefault = result =>
{
if (result.Success && Notification)
@@ -54,11 +91,23 @@ internal static class RenderContext
OSNotification.Show("Rendering Complete!", $"{fileString} saved successfully.", renderFolder);
}
};
+ ///
+ /// 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.
+ /// Exceptions are caught and logged (RenderManager.TriggerOnComplete).
+ ///
public static Action OnComplete = OnCompleteDefault;
private static readonly Action OnLastUpdateDefault = () => { };
+
+ ///
+ /// 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.
+ /// Exceptions are caught and logged (RenderManager.TriggerOnLastUpdate).
+ ///
public static Action OnLastUpdate = OnLastUpdateDefault;
+ /// Restores every option to its default and clears the session frame hooks.
public static void Reset()
{
Auto = true;
@@ -74,15 +123,38 @@ internal static class RenderContext
}
}
+///
+/// Drives a render from scene setup to the finished video file. Singleton MonoBehaviour on a DontDestroyOnLoad object
+/// created by RenderMod.
+///
+/// Flow: RenderSession.Start (stage Load) → SetupGameScene loads scnGame → Update sets up the loaded scene
+/// (stage OnScene, 3-second countdown) → SetupPlay starts the encoder and the render thread (stage Render) →
+/// every frame OnPreUpdate advances frameCount, OnPostFrame queues the audio commands and requests the GPU capture of
+/// the frame (FrameReader, AsyncGPUReadback) → readback completions add frames to ImageQueue → the render thread
+/// (RunRenderThread) encodes them in frame order together with the audio of each frame.
+///
+/// End: success (EndCondition plus DelayAfterEnd) → StopFFmpeg(true) → StopRenderCoroutine waits for the render
+/// thread to finish the file → EndRender. Cancel (ESC) or failure → StopFFmpeg(false) → EndRender, which deletes the
+/// unfinished file. Encoder initialization failure → StopFFmpeg(false) → RestoreRenderEnvironment and an error dialog.
+///
+/// Threads: the main Unity thread runs every public method unless noted otherwise. The render thread reads
+/// ImageQueue (lock on ImageQueue), passCount (render thread only while it runs), endRequested / endSuccessful
+/// (volatile), the abort state (volatile, written under _abortStateLock) and lastRequestedVideoFrameIndex
+/// (Volatile.Read/Write). FrameAvailableEvent wakes the render thread, FrameDequeuedEvent wakes the throttle loop.
+///
internal class RenderManager : MonoBehaviour
{
- // Singleton Accessors
+ /// The single instance, set in Awake. Unity null checks (if (Instance)) are used before access.
public static RenderManager Instance { get; private set; } = null!;
+
+ /// True from the OnScene stage (scene set up, countdown running) until the render ends.
public static bool IsActive => Instance && Instance.stage >= RenderStage.OnScene;
+
+ /// True while frames are captured and encoded (Render stage). The time patches apply only then.
public static bool IsCapturingActive => Instance && Instance.stage >= RenderStage.Render;
///
- /// 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.
///
public const int MaxQueuedFrames = 64;
@@ -104,26 +176,66 @@ internal class RenderManager : MonoBehaviour
public int QueueFrameLimit { get; private set; } = MaxQueuedFrames;
// Statistics
+
+ ///
+ /// Number of video frames sent to the encoder in this render. Incremented by the render thread (Interlocked),
+ /// read with Interlocked.Read by the main Unity thread (result, debug monitor). Reset in SetupPlay.
+ ///
public long framesSent;
+
+ /// Wall-clock time of the render from the end of the countdown (SetupPlay) to BuildResult.
private readonly Stopwatch _renderStopwatch = new();
// Core Render Configuration and Path
+
+ ///
+ /// Seconds left in the countdown before capture starts (3 when the scene is set up, decremented once per real-time
+ /// second by SetupPlay). Shown by scrShowIfDebugPatch and read by scrPlayerManagerPatch.
+ ///
public int countdown;
+
+ ///
+ /// Render frame counter: incremented once per Unity frame by OnPreUpdate from the OnScene stage and reset to 0 when
+ /// capture starts (SetupPlay). The render clock is frameCount / RenderFPS seconds (AudioTime, time patches).
+ /// Main thread only.
+ ///
public long frameCount;
+
+ ///
+ /// Set by SetupGameScene; scrPressToStartPatch consumes it (sets it back to false) to skip the press-to-start
+ /// screen once. Cleared by StopFFmpeg.
+ ///
public bool skipPressToStart;
+
+ /// Path of the level file to render (ADOBase.levelPath of the editor, or the path given to RenderSession.Start).
public string levelPath = "";
+
+ /// Serializes changes of the abort state (_isRenderCancelled, _renderFailed, _failureReason).
private readonly object _abortStateLock = new();
+
+ /// The render was cancelled (ESC, or a non-successful stop). Written under _abortStateLock, read by any thread.
private volatile bool _isRenderCancelled;
+
+ /// The render failed (MarkFailed). Written under _abortStateLock, read by any thread.
private volatile bool _renderFailed;
+
+ /// Reason given to the first MarkFailed call of this render; null when the render has not failed.
private volatile string? _failureReason;
+ /// True when the render was cancelled. Thread-safe read.
// ReSharper disable once MemberCanBePrivate.Global
public bool IsRenderCancelled => _isRenderCancelled;
+
+ /// True when the render failed. Thread-safe read.
public bool IsRenderFailed => _renderFailed;
- // Cancelled and failed are mutually exclusive; this is true for either (render must stop).
+ ///
+ /// True when the render was cancelled or failed (the two are mutually exclusive) and must stop.
+ /// Read by the render thread before each frame and by readback completions. Thread-safe read.
+ ///
public bool IsRenderAborted => _isRenderCancelled || _renderFailed;
+ /// Marks the render as cancelled unless it has already failed (a failure keeps its reason).
private void MarkCancelled()
{
lock (_abortStateLock)
@@ -132,7 +244,10 @@ internal class RenderManager : MonoBehaviour
}
}
- // The first failure reason wins; ignored once the render is already cancelled or failed.
+ ///
+ /// Marks the render as failed with (shown in the result message as "Rendering failed: reason").
+ /// The first failure reason wins; ignored once the render is already cancelled or failed. Callable from any thread.
+ ///
private void MarkFailed(string reason)
{
lock (_abortStateLock)
@@ -143,6 +258,7 @@ internal class RenderManager : MonoBehaviour
}
}
+ /// Clears cancelled, failed and the failure reason. Called when a new encoder starts (StartFFmpegThread).
private void ClearAbortState()
{
lock (_abortStateLock)
@@ -153,30 +269,70 @@ internal class RenderManager : MonoBehaviour
}
}
+ /// Error message of the RenderResult of an aborted render (failure reason, or the cancel message).
private string BuildAbortMessage() => IsRenderFailed
? $"Rendering failed: {_failureReason ?? "unknown error"}"
: "Rendering was cancelled by user.";
+
+ ///
+ /// 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 Unity thread, the render
+ /// thread (audio settings) and the patches. Null until the first render starts.
+ ///
public RenderConfig Configuration = null!;
// Render Sessions State (OOP Instance Fields)
+
+ /// Current stage of the render. Written on the main Unity thread; read by the render thread in TryDequeueNextFrame.
public RenderStage stage = RenderStage.None;
+
+ /// Time.timeAsDouble when capture started. The time patch returns timeBase + frameCount / RenderFPS.
public double timeBase;
+
+ /// Time.unscaledTimeAsDouble when capture started. The time patch returns unscaledTimeBase + frameCount / RenderFPS.
public double unscaledTimeBase;
+
+ ///
+ /// AudioSettings.dspTime when capture started (seconds). The dspTime patch returns dspTimeBase + frameCount / RenderFPS;
+ /// audio command times are stored relative to it (scheduled dspTime - dspTimeBase).
+ ///
public double dspTimeBase;
+
+ /// Time.frameCount when capture started. The frameCount patch returns frameCountBase + frameCount.
public int frameCountBase;
+ ///
+ /// 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 Unity thread (OnPostFrame, audio patches), mixing runs on the
+ /// render thread (WriteAudioForFrame); the mixer synchronizes the two internally.
+ ///
public OfflineAudioMixer? AudioMixer { get; private set; }
/// Render time of the current frame in seconds (frameCount / RenderFPS), the time base of audio commands.
public double AudioTime => frameCount / Configuration.RenderFPS;
+ ///
+ /// Number of audio samples (per channel) to write after each video frame, so that the audio stays aligned with
+ /// the video frame times at the effective video frame rate. Created in SetupPlay when audio is rendered;
+ /// used by the render thread only.
+ ///
private AudioSyncCalculator? SyncCalculator { get; set; }
+ /// GPU capture of frames (color conversion and AsyncGPUReadback). Created in SetupPlay after the encoder started.
private FrameReader? _frameReader;
+
+ /// Frame hook runner of this render. Created in SetupPlay, disposed by ResetFrameHookState.
private FrameHookPipeline? _frameHookPipeline;
+
+ /// Session hooks followed by global hooks, rebuilt when the global hook version changes.
private readonly List> _activeHooksCache = new();
+
+ /// RenderSession.GlobalHooksVersion the cache was built for; -1 forces a rebuild.
private int _cachedGlobalHooksVersion = -1;
+
+ /// True when _activeHooksCache is not empty.
private bool _hasActiveHooksCached;
+ /// True when at least one session or global frame hook is registered. Main thread.
public bool HasActiveFrameHooks
{
get
@@ -186,6 +342,11 @@ internal class RenderManager : MonoBehaviour
}
}
+ ///
+ /// Rebuilds the active hook list when the global hook registrations changed since the last build.
+ /// Session hooks are only read on a rebuild; SetupPlay forces one (version -1) at the start of capture, so changes to
+ /// RenderContext.SessionFrameHooks after that are picked up only together with a global hook change.
+ ///
private void EnsureActiveHooksUpToDate()
{
var globalVersion = RenderSession.GlobalHooksVersion;
@@ -201,12 +362,14 @@ internal class RenderManager : MonoBehaviour
_hasActiveHooksCached = _activeHooksCache.Count > 0;
}
+ /// Returns the cached active hook list (session hooks first, then global hooks). The list is reused.
private List> GetActiveFrameHooks()
{
EnsureActiveHooksUpToDate();
return _activeHooksCache;
}
+ /// Disposes the frame hook pipeline (ping-pong buffers) and clears the hook cache.
private void ResetFrameHookState()
{
_frameHookPipeline?.Dispose();
@@ -216,46 +379,132 @@ internal class RenderManager : MonoBehaviour
_hasActiveHooksCached = false;
}
+ ///
+ /// Number of game frames offered for capture since capture started (OnPostFrame with a texture, after throttling).
+ /// The value before the increment is the render frame index of the current frame. Main thread only.
+ ///
public long frameCountForVideo;
+
+ ///
+ /// Video frame index of the last captured frame (-1 before the first). A game frame is captured only when its video
+ /// frame index (render frame index × effective video FPS / RenderFPS, rounded down) is greater. Main thread only.
+ ///
public long lastVideoFrameIndex = -1;
- // long cannot be volatile: access via Volatile.Read/Write (written on main thread, read on render thread)
+
+ ///
+ /// 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.
+ ///
+ // long cannot be volatile: access via Volatile.Read/Write (written on main Unity thread, read on render thread)
public long lastRequestedVideoFrameIndex = -1;
+
+ ///
+ /// Number of GPU readbacks that failed and were replaced by a black frame (shown by the debug monitor).
+ /// Incremented in the readback completion callback.
+ ///
public int readbackFailureCount;
+
+ /// Number of frames on which the main Unity thread had to wait for the render thread (throttle loop entries).
public int throttleCount;
+
+ /// True while the main Unity thread is inside the throttle wait of OnPostFrame.
public bool isThrottling;
+ ///
+ /// One frame waiting in ImageQueue: either a native readback buffer (owned by ReadbackState until it is recycled)
+ /// or a managed buffer rented from ArrayPool<byte>.Shared (black frame for a failed readback).
+ /// The data is YUV 4:2:0 in the encoder's pixel format when IsColorConverted, RGBA otherwise.
+ ///
internal struct CapturedFrame
{
+ /// Start of the native frame data (ReadbackState.NativeBuffer); zero for a managed frame.
public IntPtr NativePointer;
+
+ /// Number of valid bytes of the frame data.
public int Length;
+
+ /// Managed frame data rented from ArrayPool<byte>.Shared (may be longer than Length); null for a native frame.
public byte[]? ManagedBuffer; // Non-null if it's a managed buffer (e.g. black frame)
+
+ /// Readback state owning NativePointer; returned to the FrameReader pool after the frame is encoded or dropped.
public ReadbackRequestState? ReadbackState; // Associated state to recycle
+
+ /// True when the GPU converted the frame to the encoder's YUV format; false for RGBA data.
public bool IsColorConverted;
}
// Thread and Queue State
+
+ ///
+ /// 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.
+ /// Frames are added by readback completions (main Unity thread) and removed by the render thread, or released by the
+ /// cleanup in StopFFmpeg / RestoreRenderEnvironment.
+ ///
internal SortedList ImageQueue = new();
+
+ /// Set when a frame is added to ImageQueue or the end is requested; the render thread waits on it.
internal AutoResetEvent FrameAvailableEvent = new(false);
- // Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
+
+ /// Set by the render thread each time it takes a frame from ImageQueue; the throttle loop waits on it.
internal AutoResetEvent FrameDequeuedEvent = new(false);
+
+ /// The render thread of the current render (background, AboveNormal priority); null before the first render.
public Thread? RenderThread;
+
+ ///
+ /// Video frame index the render thread sends next. Reset by StartFFmpegThread before the thread starts; afterwards
+ /// read and written only by the render thread, under the ImageQueue lock.
+ ///
public long passCount;
+
+ /// Set by StopFFmpeg: no more frames will be captured; the render thread drains the queue and stops.
public volatile bool endRequested;
+
+ /// Set by the render thread after the output file was finished and the encoder released without an abort.
public volatile bool endSuccessful;
+
+ ///
+ /// frameCount at which EndCondition first returned true; -1 until then. The render ends DelayAfterEnd seconds of
+ /// render time later.
+ ///
public long endFrame = -1;
// Private Instance Fields
+
+ /// Encoder of the current render; null when none runs. Cleared when the render thread releases it.
private FFmpegNativeEncoder? _activeVideoFFmpeg;
+
+ ///
+ /// Every encoder created and not yet released, disposed by CleanUpActiveRender on exit. Locked on the list itself
+ /// (the render thread removes its encoder when it finishes).
+ ///
private readonly List _allVideoFFmpegInstances = new();
+
+ /// The user's input offset (scrConductor.currentPreset.inputOffset), set to 0 during the render and restored afterwards.
private int _originalInputOffset;
+
+ /// Debug monitor overlay object, created when the encoder starts and destroyed when the render ends.
private GameObject? _debugMonitorObj;
+
+ /// ESC handler overlay object (cancels the render), created when the scene is set up and destroyed by StopFFmpeg.
private GameObject? _escapeHandlerObj;
+
+ ///
+ /// Output file path of the current or last render. Set when the encoder starts; set to null when an aborted render's
+ /// file was deleted.
+ ///
internal string? CurrentOutPath;
// Output file of the current render that is not finished yet.
// Set just before the encoder opens the file, cleared by the render thread after the encoder finished the file (End).
// Only this file is deleted when a render is cancelled, fails or the game exits (DeleteIncompleteOutputFile).
private string? _incompleteOutputPath;
+
+ ///
+ /// True from SetupGameScene until Update has set up the loaded game scene (or the render stops). While true, Update
+ /// hides the difficulty UI and waits for scnGame to finish loading.
+ ///
private bool _renderSetup;
private void Awake()
@@ -268,6 +517,10 @@ internal class RenderManager : MonoBehaviour
CleanUpActiveRender();
}
+ ///
+ /// Sets the stage to None. true clears the cancelled flag; false marks the render as
+ /// cancelled (unless it already failed). Called at every end path before StopFFmpeg.
+ ///
public void ResetRenderState(bool success)
{
if (success) _isRenderCancelled = false;
@@ -275,6 +528,11 @@ internal class RenderManager : MonoBehaviour
stage = RenderStage.None;
}
+ ///
+ /// Emergency cleanup when the game quits or this object is destroyed: marks a running render as failed, releases
+ /// the audio, the frame hooks and every encoder, waits up to 200 ms for the render thread, and deletes the
+ /// unfinished output file. Does not restore the game scene or settings. Main thread.
+ ///
public void CleanUpActiveRender()
{
if (stage != RenderStage.None)
@@ -346,8 +604,16 @@ internal class RenderManager : MonoBehaviour
}
}
+ ///
+ /// Main thread, every frame.
+ /// 1. While the game scene of a render loads: hides the difficulty UI of the press-to-start screen.
+ /// 2. Once scnGame has finished loading: sets up the scene for rendering and starts SetupPlay (stage OnScene).
+ /// 3. From the OnScene stage: fails the render when the render thread died, the level failed or the controller is
+ /// gone; ends it successfully DelayAfterEnd seconds (render time) after EndCondition first returned true.
+ ///
private void Update()
{
+ // scrPressToStart reads lm.hideDifficultyUI to decide whether the difficulty UI is shown (scrPressToStart.cs).
if (_renderSetup && ADOBase.lm)
{
ADOBase.lm.hideDifficultyUI = true;
@@ -356,18 +622,24 @@ internal class RenderManager : MonoBehaviour
if (_renderSetup && scnGame.instance && !scnGame.instance.isLoading)
{
_renderSetup = false;
+ // scrCamera.Awake calls SetupRTCam(enable: true) when forceRTCam is set (scrCamera.cs), so the flag applies to
+ // the next scrCamera.Awake. scnGame also calls SetupRTCam(enable: true) when it starts playing (scnGame.cs).
scrCamera.instance.forceRTCam = true;
if (!scrCamera.instance.gameObject.GetComponent())
scrCamera.instance.gameObject.AddComponent();
+ // The input offset shifts the song position (calibration_i = inputOffset / 1000 in scrConductor);
+ // the render runs with 0 so audio and visuals follow the level timing exactly. Restored by RestoreRenderEnvironment.
_originalInputOffset = scrConductor.currentPreset.inputOffset;
scrConductor.currentPreset.inputOffset = 0;
+ // Autoplay; disableCongratsMessage clears the "congratulations" text shown on the win screen (scrController.cs).
RDC.auto = RenderContext.Auto;
AccessTools.Field(typeof(scrController), "disableCongratsMessage")
.SetValue(scrController.instance, true);
+ // No-fail with an infinite hit margin, so a missed hit does not fail the level during the render.
if (scrController.instance)
{
scrController.instance.noFail = true;
@@ -384,6 +656,9 @@ internal class RenderManager : MonoBehaviour
DontDestroyOnLoad(_escapeHandlerObj);
}
+ // While non-zero, Unity advances Time.time by exactly this interval per frame regardless of real time,
+ // so the game simulation steps 1 / RenderFPS seconds per frame. Reset to 0 by RestoreRenderEnvironment.
+ // Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
Time.captureDeltaTime = (float)(1 / Configuration.RenderFPS);
scrController.instance!.StartCoroutine(SetupPlay());
}
@@ -404,6 +679,8 @@ internal class RenderManager : MonoBehaviour
}
else if (RenderContext.EndCondition())
{
+ // The first frame the end condition holds starts the DelayAfterEnd period; afterwards the end condition is
+ // still polled every frame, but endFrame is kept.
if (endFrame == -1)
{
endFrame = frameCount;
@@ -417,6 +694,9 @@ internal class RenderManager : MonoBehaviour
}
}
+ ///
+ /// Called by FirstUpdateHook before every other Update of the frame. Advances frameCount by one from the OnScene stage.
+ ///
public void OnPreUpdate()
{
if (Instance.stage < RenderStage.OnScene) return;
@@ -428,6 +708,10 @@ internal class RenderManager : MonoBehaviour
private List? _holdFloorsSource;
private int _holdFloorsSourceCount = -1;
+ ///
+ /// Called by PostRenderHook after every other LateUpdate of the frame, from the Load stage.
+ /// Hides the hold meshes when AdvancedConfig.NonTile is set, then runs RenderContext.OnLastUpdate.
+ ///
public void OnLastUpdate()
{
if (Instance.stage < RenderStage.Load) return;
@@ -479,6 +763,15 @@ internal class RenderManager : MonoBehaviour
return (int)Math.Min(MaxQueuedFrames, Math.Max(4, MaxQueuedBytes / frameBytes));
}
+ ///
+ /// Called by PostRenderHook at the end of every frame (after rendering) with the frame texture. In the Render stage:
+ /// 1. Audio: updates the song voice, commits the audio commands of this frame, and deactivates the effect
+ /// AudioSources that were mapped to mixer voices.
+ /// 2. Throttle: blocks while ImageQueue holds more than QueueFrameLimit frames (render thread behind).
+ /// 3. Capture: when this game frame starts a new video frame, runs the frame hooks and requests the GPU readback.
+ /// With RenderFPS above the effective video FPS, only some game frames are captured.
+ ///
+ /// Frame shown by the camera quad; null skips throttling and capture.
// ReSharper disable once Unity.IncorrectMethodSignature
public void OnPostFrame(RenderTexture? texture)
{
@@ -495,7 +788,7 @@ internal class RenderManager : MonoBehaviour
if (!texture) return;
// [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
// re-checks the render thread state and pumps pending readback completions (see ThrottleWaitMilliseconds).
isThrottling = false;
@@ -532,10 +825,13 @@ internal class RenderManager : MonoBehaviour
var currentFrame = frameCountForVideo++;
+ // The video cannot have more frames per second than the game renders: effective video FPS = min(VideoFPS, RenderFPS).
var renderFPS = Configuration.RenderFPS;
var videoFps = Configuration.VideoFPS;
var effectiveVideoFps = Math.Min(videoFps, renderFPS);
+ // Video frame that contains this game frame's time (currentFrame / renderFPS seconds), rounded down.
+ // Only the first game frame of each video frame is captured.
var videoFrameIndex = (long)(currentFrame * effectiveVideoFps / renderFPS);
if (videoFrameIndex > lastVideoFrameIndex)
@@ -562,6 +858,13 @@ internal class RenderManager : MonoBehaviour
}
}
+ ///
+ /// Handler of the render button in the level editor (added by scnEditorPatch).
+ /// When the level has no file path, scnEditor.TryQuitToMenu is called and no render starts.
+ /// Otherwise, after the editor's unsaved-changes prompt, removes scnEditor.TryApplicationQuit from
+ /// Application.wantsToQuit (it would block quitting with an unsaved-changes prompt; scnEditor registers it again
+ /// when the editor starts) and opens the render configuration window. Main thread.
+ ///
public static void StartRender()
{
var targetMethod = typeof(scnEditor).GetMethod("TryApplicationQuit",
@@ -585,12 +888,18 @@ internal class RenderManager : MonoBehaviour
});
}
+ /// Creates the default output directory (GlobalSettings.EffectiveOutputDirectory) when it does not exist.
public void SetupCaptureDirectory()
{
var renderDir = GlobalSettings.EffectiveOutputDirectory;
FileUtil.CreateDirectory(renderDir);
}
+ ///
+ /// Loads scnGame with levelPath as the only custom level (checkpoint 0, speed trial multiplier 1) and arms the
+ /// press-to-start skip and the scene setup in Update. Seeds UnityEngine.Random when AdvancedConfig.RandomSeed is set,
+ /// so random effects repeat between renders. Called by RenderSession.Start.
+ ///
public void SetupGameScene()
{
GCS.customLevelPaths = new[] { levelPath };
@@ -605,8 +914,18 @@ internal class RenderManager : MonoBehaviour
_renderSetup = true;
}
+ ///
+ /// Coroutine started on scrController when the game scene is set up (stage OnScene).
+ /// Waits the countdown (real-time seconds), sets the time bases, starts the encoder, the render thread and the
+ /// frame reader, switches to the Render stage, starts the level without the press-to-start wait, and creates the
+ /// offline audio mixer and spectrum generator. Stops without starting when the render was aborted during the
+ /// countdown. On an encoder initialization error, the render fails, the environment is restored and an error
+ /// dialog returns to the editor.
+ ///
private IEnumerator SetupPlay()
{
+ // The audio sample rate must be one the audio codec paired with the video codec supports
+ // (CodecUtil.AudioCodecMatch, CodecUtil.AvailableSampleRate); otherwise 48000 Hz is used.
if (Configuration.AudioConfig.RenderAudio)
{
if (!CodecUtil.AvailableSampleRate[CodecUtil.AudioCodecMatch[Configuration.VideoConfig.Codec]].Contains(Configuration.AudioConfig.AudioSampleRate))
@@ -624,6 +943,8 @@ internal class RenderManager : MonoBehaviour
frameCount = 0; framesSent = 0;
_renderStopwatch.Restart();
+ // Clock values at the start of capture. The time patches are not active yet (stage OnScene), so these are
+ // Unity's own values; from the Render stage the patched clocks continue from them at frameCount / RenderFPS.
timeBase = Time.timeAsDouble;
unscaledTimeBase = Time.unscaledTimeAsDouble;
dspTimeBase = AudioSettings.dspTime;
@@ -635,6 +956,7 @@ internal class RenderManager : MonoBehaviour
_frameHookPipeline = new FrameHookPipeline();
_cachedGlobalHooksVersion = -1;
EnsureActiveHooksUpToDate();
+ // The frame reader converts on the GPU to the pixel format of the encoder that was actually opened.
try
{
StartFFmpegThread();
@@ -663,6 +985,7 @@ internal class RenderManager : MonoBehaviour
}
Instance.stage = RenderStage.Render;
+ // levelWasSkipped ends the press-to-start wait loop of scrController (scrController.cs, WaitForStartCo).
scrController.instance.paused = scrController.instance.audioPaused = false;
scrController.instance.levelWasSkipped = true;
@@ -681,10 +1004,18 @@ internal class RenderManager : MonoBehaviour
AudioSpectrumGenerator.Initialize(scrConductor.instance.song.clip, AudioMixer);
}
+ // The song is played by the offline mixer only: the clip is removed from the game's song AudioSource so Unity
+ // does not play it.
scrConductor.instance.song.clip = null;
scrConductor.instance.song.time = -1000;
}
+ ///
+ /// Resets the queue and render thread state, opens the encoder on the output file (RenderContext.OutputPath, or
+ /// "yyyy-MM-dd HH-mm-ss.ext" in the output directory with the configured extension), and starts the render thread.
+ /// The output path is registered as incomplete before the encoder opens the file.
+ ///
+ /// The encoder could not be created or started; it is disposed before the exception is rethrown.
private void StartFFmpegThread()
{
if (!_debugMonitorObj)
@@ -770,6 +1101,7 @@ internal class RenderManager : MonoBehaviour
throw;
}
+ // Background thread: it does not keep the process alive when the game quits.
var encoder = videoFFmpeg;
RenderThread = new Thread(() => RunRenderThread(encoder))
{
@@ -858,8 +1190,10 @@ internal class RenderManager : MonoBehaviour
}
///
- /// Removes the frame whose key equals passCount from ImageQueue and advances passCount.
- /// Before the Render stage, passCount jumps to the first queued key (the first captured frame index is not 0).
+ /// Removes the frame whose key equals passCount from ImageQueue and advances passCount. Render thread.
+ /// While the stage is below Render, passCount jumps forward to the first queued key when that key is greater:
+ /// frames are only captured in the Render stage, so this happens after the stage was set back to None at the end
+ /// of the render while the thread drains the queue, and a frame whose readback has not arrived yet is skipped.
/// Returns false when the queue is empty or the next frame has not arrived yet.
///
private bool TryDequeueNextFrame(out CapturedFrame frame)
@@ -886,7 +1220,7 @@ internal class RenderManager : MonoBehaviour
///
/// Sends one captured frame to the encoder and releases its buffer (managed: ArrayPool, native: readback state).
- /// A frame without data is skipped.
+ /// A frame without data is skipped (not counted in framesSent). Render thread.
///
private void SendVideoFrame(FFmpegNativeEncoder videoFFmpeg, in CapturedFrame frame)
{
@@ -914,8 +1248,11 @@ internal class RenderManager : MonoBehaviour
///
/// Mixes the audio of the frame that was just sent (index passCount - 1) and writes it to the encoder.
- /// Does nothing when audio rendering is off.
+ /// 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 Unity thread has committed the audio commands up to that time.
+ /// Does nothing when audio rendering is off. Render thread.
///
+ /// The mixer was cancelled (render aborted) while waiting.
private void WriteAudioForFrame(FFmpegNativeEncoder videoFFmpeg)
{
if (!Configuration.AudioConfig.RenderAudio || SyncCalculator is not { } syncCalculator) return;
@@ -945,6 +1282,7 @@ internal class RenderManager : MonoBehaviour
return endRequested && passCount > Volatile.Read(ref lastRequestedVideoFrameIndex);
}
+ /// Removes the encoder from the cleanup list and clears _activeVideoFFmpeg. Called by the render thread when it finishes.
private void UnregisterEncoder(FFmpegNativeEncoder videoFFmpeg)
{
lock (_allVideoFFmpegInstances)
@@ -954,6 +1292,11 @@ internal class RenderManager : MonoBehaviour
_activeVideoFFmpeg = null;
}
+ ///
+ /// Stops the render without finishing the file (cancelled, or failed when MarkFailed was called first), deletes
+ /// the unfinished file, reports the result through OnComplete, restores the environment and returns to the editor.
+ /// Called by the ESC handler and on failures. Main thread.
+ ///
public void CancelRender()
{
ResetRenderState(success: false);
@@ -962,6 +1305,15 @@ internal class RenderManager : MonoBehaviour
EndRender();
}
+ ///
+ /// Requests the end of encoding. Main thread.
+ /// true: completes the audio commands and starts StopRenderCoroutine, which waits for
+ /// the render thread to send the remaining frames and finish the file.
+ /// false: marks the render as cancelled (unless failed), cancels the audio waits, waits up
+ /// to 2 s for the render thread, disposes the encoder, releases the queued frames, the frame reader, the hooks and
+ /// the debug monitor, restores the camera and no-fail flags, deletes the unfinished file and calls OnComplete with
+ /// a failure result. Does not restore time, input offset or the scene (EndRender / RestoreRenderEnvironment do).
+ ///
private void StopFFmpeg(bool success)
{
endRequested = true;
@@ -993,7 +1345,8 @@ internal class RenderManager : MonoBehaviour
RenderThread.Join(2000);
}
- // 2. After ensuring thread termination, safely release FFmpeg native encoder resources
+ // 2. Release the encoder. The render thread normally released it already (_activeVideoFFmpeg is then null).
+ // When the thread is still running after the 2 s wait, the encoder is disposed while that thread may still use it.
if (_activeVideoFFmpeg != null)
{
_activeVideoFFmpeg.Dispose();
@@ -1055,6 +1408,11 @@ internal class RenderManager : MonoBehaviour
}
}
+ ///
+ /// Successful end: waits (one check per frame) until the render thread reports endSuccessful or has died.
+ /// On success calls OnComplete with a success result and EndRender; when the thread died without finishing
+ /// the file, the render fails and is cancelled (file deleted).
+ ///
private IEnumerator StopRenderCoroutine()
{
yield return new WaitUntil(() => endSuccessful || RenderThread is not { IsAlive: true });
@@ -1071,6 +1429,7 @@ internal class RenderManager : MonoBehaviour
EndRender();
}
+ /// Restores the render environment and loads the level editor (scnEditor).
private void EndRender()
{
RestoreRenderEnvironment();
@@ -1079,7 +1438,11 @@ internal class RenderManager : MonoBehaviour
Cursor.visible = true;
}
- // Undoes what the render setup changed (time, input offset, auto, queues, session hooks), without leaving the scene.
+ ///
+ /// Undoes what the render setup changed (time scale and capture delta time, input offset, autoplay, custom level
+ /// paths, queued frames, frame reader, frame hooks, audio mixer and spectrum, RenderContext options), without
+ /// leaving the scene.
+ ///
private void RestoreRenderEnvironment()
{
Time.timeScale = 1f;
@@ -1122,6 +1485,10 @@ internal class RenderManager : MonoBehaviour
}
+ ///
+ /// Stops the render stopwatch and creates the result: frames sent, video duration (frames / effective video FPS),
+ /// wall-clock render time, and the output path and file size on success.
+ ///
private RenderResult BuildResult(bool success, string? errorMessage = null)
{
_renderStopwatch.Stop();
@@ -1133,6 +1500,7 @@ internal class RenderManager : MonoBehaviour
: RenderResult.CreateFailure(errorMessage, sent, effectiveFps, _renderStopwatch.Elapsed);
}
+ /// Invokes RenderContext.OnComplete; an exception from the callback is logged.
private static void TriggerOnComplete(RenderResult result)
{
try
@@ -1145,6 +1513,7 @@ internal class RenderManager : MonoBehaviour
}
}
+ /// Invokes RenderContext.OnLastUpdate; an exception from the callback is logged.
private static void TriggerOnLastUpdate()
{
try
diff --git a/Data/Codec.cs b/Data/Codec.cs
index d9ee105..346785d 100644
--- a/Data/Codec.cs
+++ b/Data/Codec.cs
@@ -4,14 +4,22 @@ using FFmpeg.AutoGen;
namespace ADOFAIRenderer.Data;
+///
+/// Video codec of the output. The encoder implementation (hardware or software) is chosen at run time from the
+/// encoders available for the codec (VideoEncoder, GPUUtil.GetPrioritizedEncoders).
+/// Values: H264 (H.264 / AVC), H265 (H.265 / HEVC), VP9, AV1, VVC (H.266).
+/// The audio codec paired with each value is given by CodecUtil.AudioCodecMatch.
+///
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Codec
{
H264, H265, VP9, AV1, VVC
}
+/// Audio codec choice and supported sample rates per video codec.
internal static class CodecUtil
{
+ /// Audio codec encoded together with each video codec (AAC for H.264/H.265, Opus for the others).
public static readonly Dictionary AudioCodecMatch = new()
{
[Codec.H264] = AVCodecID.AV_CODEC_ID_AAC,
@@ -21,6 +29,10 @@ internal static class CodecUtil
[Codec.VVC] = AVCodecID.AV_CODEC_ID_OPUS
};
+ ///
+ /// Output sample rates (Hz) the mod allows per audio codec. RenderManager.SetupPlay replaces a configured rate that
+ /// is not in the list with 48000 Hz.
+ ///
public static readonly Dictionary> AvailableSampleRate = new()
{
[AVCodecID.AV_CODEC_ID_AAC] = new List { 44100, 48000, 96000 },
diff --git a/Data/ColorJsonConverter.cs b/Data/ColorJsonConverter.cs
index 374ff85..28c501d 100644
--- a/Data/ColorJsonConverter.cs
+++ b/Data/ColorJsonConverter.cs
@@ -10,13 +10,20 @@ namespace ADOFAIRenderer.Data;
/// Converts Unity and nullable to and from hex string (#RRGGBBAA).
/// Pure C# implementation to completely avoid Unity internal native ECalls (such as Color.linear or ColorUtility.TryParseHtmlString).
///
+///
+/// Reading accepts "#RRGGBB" (alpha 1), "#RRGGBBAA" (the '#' is optional), an object {"r","g","b","a"} with 0–1 floats
+/// (missing r/g/b = 0, missing a = 1), or null. Invalid or unsupported input reads as null for Color? and as
+/// default(Color) (all components 0) for Color; no exception is thrown for an unparsable string.
+///
public class ColorJsonConverter : JsonConverter
{
+ /// True for and .
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Color) || objectType == typeof(Color?);
}
+ /// Writes the color as "#RRGGBBAA" (see ), or a JSON null for a null value.
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
if (value == null)
@@ -29,6 +36,7 @@ public class ColorJsonConverter : JsonConverter
writer.WriteValue(ToHex(color));
}
+ /// Reads a color in one of the accepted forms (see the class remarks).
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
bool isNullable = Nullable.GetUnderlyingType(objectType) != null || !objectType.IsValueType;
@@ -62,6 +70,10 @@ public class ColorJsonConverter : JsonConverter
return isNullable ? null : (object)default(Color);
}
+ ///
+ /// Formats the color as "#RRGGBBAA" with uppercase hex digits. Each component is scaled to 0–255, rounded and clamped.
+ /// The components are written as stored (no gamma conversion).
+ ///
public static string ToHex(Color color)
{
byte r = (byte)Math.Clamp((int)Math.Round(color.r * 255f), 0, 255);
@@ -71,6 +83,12 @@ public class ColorJsonConverter : JsonConverter
return $"#{r:X2}{g:X2}{b:X2}{a:X2}";
}
+ ///
+ /// Parses "RRGGBB" or "RRGGBBAA" (optional leading '#', surrounding white space ignored, case-insensitive).
+ ///
+ /// Text to parse.
+ /// Parsed color (components / 255; alpha 1 for the 6-digit form), or default when parsing fails.
+ /// True when the text was parsed.
public static bool TryParseHex(string? hex, out Color color)
{
color = default;
diff --git a/Data/RateControlMode.cs b/Data/RateControlMode.cs
index 5e646fe..d4ea7cf 100644
--- a/Data/RateControlMode.cs
+++ b/Data/RateControlMode.cs
@@ -2,10 +2,19 @@
namespace ADOFAIRenderer.Data;
+/// Video bitrate control mode (see VideoEncodingConfig and FFmpegNativeEncoder.ApplyQualitySettings).
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum RateControlMode
{
+ /// Constant bitrate at VideoEncodingConfig.Bitrate (kbps).
CBR,
+
+ ///
+ /// Variable bitrate averaging VideoEncodingConfig.Bitrate (kbps), peaking at MaxBitrate (kbps) when it is larger,
+ /// otherwise at 1.5 × Bitrate.
+ ///
VBR,
+
+ /// Constant quality with VideoEncodingConfig.QP (lower is better; range depends on the encoder).
CQP
}
\ No newline at end of file
diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs
index 832db43..397c5ea 100644
--- a/Data/RenderConfig.cs
+++ b/Data/RenderConfig.cs
@@ -4,22 +4,50 @@ using UnityEngine;
namespace ADOFAIRenderer.Data;
+///
+/// Settings of one render: resolution, frame rates, encoding and advanced options. Serialized to JSON in the preset
+/// files (PresetManager). renders with a copy ().
+///
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class RenderConfig
{
+ ///
+ /// Game frames simulated per second of render time (the game clock advances 1 / RenderFPS seconds per frame).
+ /// Values above render extra frames between video frames, which are not captured
+ /// (the game state then advances in smaller time steps).
+ ///
public double RenderFPS { get; set; }
+
+ ///
+ /// Frame rate of the output video. The effective rate is min(VideoFPS, RenderFPS).
+ ///
public double VideoFPS { get; set; }
+ /// Width of the rendered frames and the output video in pixels (also reported by the Screen.width patch).
public int RenderWidth { get; set; }
+
+ /// Height of the rendered frames and the output video in pixels (also reported by the Screen.height patch).
public int RenderHeight { get; set; }
+ /// Video encoder settings.
public VideoEncodingConfig VideoConfig { get; set; } = new();
+
+ /// Audio rendering and encoder settings.
public AudioEncodingConfig AudioConfig { get; set; } = new();
+
+ /// Advanced options (random seed, spectrum processing, tile hiding, container, planet colors).
public AdvancedConfig AdvancedConfig { get; set; } = new();
+ /// Seconds of render time recorded after the end condition first holds (default 15).
public double DelayAfterEnd { get; set; } = 15;
+
+ /// Whether the judgement texts (hit accuracy texts) are shown during the render (scrHitTextManagerPatch). Default false.
public bool ShowJudgementText { get; set; } = false;
+ ///
+ /// Copies every value of into this object; the nested configs are copied value by value
+ /// into the existing nested objects. Does nothing when is null.
+ ///
public void CopyFrom(RenderConfig? source)
{
if (source == null) return;
@@ -35,6 +63,7 @@ public class RenderConfig
AdvancedConfig.CopyFrom(source.AdvancedConfig);
}
+ /// Returns a deep copy (nested configs are new objects).
public RenderConfig Clone()
{
var clone = new RenderConfig();
@@ -43,23 +72,48 @@ public class RenderConfig
}
}
+/// Advanced render options.
[SuppressMessage("ReSharper", "RedundantDefaultMemberInitializer")]
public class AdvancedConfig
{
+ ///
+ /// Seed for UnityEngine.Random set before the game scene loads, so random visual effects repeat between renders;
+ /// null leaves the random state unchanged.
+ ///
public int? RandomSeed { get; set; } = null;
+
+ ///
+ /// When true, AudioSource.GetSpectrumData is answered from the offline song audio at the render time
+ /// (AudioSpectrumGenerator), so audio-reactive visuals follow the rendered audio. Default true.
+ ///
public bool ProcessAudio { get; set; } = true;
+
+ ///
+ /// When true, the level's floor tiles (including hold meshes and the portal particles of the level's floors) and the
+ /// planets are hidden during the render, so only the background and decorations remain (scnGamePatch, scrFloorPatch,
+ /// RenderManager.OnLastUpdate). Default false.
+ ///
public bool NonTile { get; set; } = false;
+
+ /// File extension of the default output file name, without the dot (e.g. "mkv", "mp4"); selects the container.
public string FileExtension { get; set; } = "mkv";
+ /// Color of the fire (red) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".
[JsonConverter(typeof(ColorJsonConverter))]
public Color? firePlanetColor = null;
+ /// Color of the ice (blue) planet during the render; null keeps the user's setting. Serialized as "#RRGGBBAA".
[JsonConverter(typeof(ColorJsonConverter))]
public Color? icePlanetColor = null;
+ ///
+ /// Color of the third (wind, planetGreen) planet during the render (PlanetarySystemPatch); null keeps the game's color.
+ /// Serialized as "#RRGGBBAA".
+ ///
[JsonConverter(typeof(ColorJsonConverter))]
public Color? windPlanetColor = null;
+ /// Copies every value of ; does nothing when it is null.
public void CopyFrom(AdvancedConfig? source)
{
if (source == null) return;
@@ -72,6 +126,7 @@ public class AdvancedConfig
windPlanetColor = source.windPlanetColor;
}
+ /// Returns a copy of this object.
public AdvancedConfig Clone()
{
var clone = new AdvancedConfig();
@@ -80,18 +135,35 @@ public class AdvancedConfig
}
}
+/// Video encoder settings (passed to FFmpegNativeEncoder.EncoderSettings).
public class VideoEncodingConfig
{
+ /// Video codec (default H.264). Also selects the audio codec (CodecUtil.AudioCodecMatch).
public Codec Codec { get; set; } = Codec.H264;
+
+ /// Bitrate control mode (default CBR).
public RateControlMode RateControlMode { get; set; } = RateControlMode.CBR;
+ /// Target video bitrate in kbps for CBR and VBR (default 30000 = 30 Mbps).
public int Bitrate { get; set; } = 30000;
+
+ /// Peak video bitrate in kbps for VBR, used when larger than Bitrate (otherwise 1.5 × Bitrate). Default 50000.
public int MaxBitrate { get; set; } = 50000;
+
+ ///
+ /// Quality value for CQP: lower is higher quality. Clamped to the range of the selected encoder; a negative value
+ /// selects 18. Default 18.
+ ///
// ReSharper disable once InconsistentNaming
public int QP { get; set; } = 18;
+
+ /// Keyframe interval in seconds (GOP length = round(effective video FPS × interval) frames, at least 1). Default 2.
public double KeyframeInterval { get; set; } = 2.0;
+
+ /// When true, only software (CPU) encoders are tried; hardware encoders are skipped.
public bool ForceSoftwareEncoder { get; set; }
+ /// Copies every value of ; does nothing when it is null.
public void CopyFrom(VideoEncodingConfig? source)
{
if (source == null) return;
@@ -104,6 +176,7 @@ public class VideoEncodingConfig
ForceSoftwareEncoder = source.ForceSoftwareEncoder;
}
+ /// Returns a copy of this object.
public VideoEncodingConfig Clone()
{
var clone = new VideoEncodingConfig();
@@ -112,32 +185,44 @@ public class VideoEncodingConfig
}
}
+///
+/// Audio rendering and encoder settings. Non-positive values of the numeric settings read back as their defaults
+/// (48000 Hz, 2 channels, 256000 bps).
+///
public class AudioEncodingConfig
{
private int _audioSampleRate = 48000;
private int _audioChannels = 2;
private int _audioBitrate = 256000;
+ /// When true, the song and sound effects are mixed offline and encoded as an audio stream. Default true.
public bool RenderAudio { get; set; } = true;
+ ///
+ /// Output sample rate in Hz (default 48000). A rate the audio codec does not support in the mod
+ /// (CodecUtil.AvailableSampleRate) is replaced with 48000 when the render starts.
+ ///
public int AudioSampleRate
{
get => _audioSampleRate <= 0 ? 48000 : _audioSampleRate;
set => _audioSampleRate = value <= 0 ? 48000 : value;
}
+ /// Number of output audio channels (default 2).
public int AudioChannels
{
get => _audioChannels <= 0 ? 2 : _audioChannels;
set => _audioChannels = value <= 0 ? 2 : value;
}
+ /// Audio bitrate in bits per second (default 256000 = 256 kbps). Unit differs from the video bitrate (kbps).
public int AudioBitrate
{
get => _audioBitrate <= 0 ? 256000 : _audioBitrate;
set => _audioBitrate = value <= 0 ? 256000 : value;
}
+ /// Copies every value of ; does nothing when it is null.
public void CopyFrom(AudioEncodingConfig? source)
{
if (source == null) return;
@@ -147,6 +232,7 @@ public class AudioEncodingConfig
AudioBitrate = source.AudioBitrate;
}
+ /// Returns a copy of this object.
public AudioEncodingConfig Clone()
{
var clone = new AudioEncodingConfig();
diff --git a/Localization/LocalizationManager.cs b/Localization/LocalizationManager.cs
index 15bb674..e3c1db3 100644
--- a/Localization/LocalizationManager.cs
+++ b/Localization/LocalizationManager.cs
@@ -9,29 +9,49 @@ namespace ADOFAIRenderer.Localization;
///
/// Provides access to localized strings and handles language synchronization with the game.
+///
+/// Locale files are flat JSON objects ("key": "text") under Localization/Locale/, embedded in the mod
+/// assembly as resources; the file name without extension is the locale code (en.json -> "en").
+/// The active locale follows the game language: LanguageSyncPatch calls after the game's
+/// RDString.Setup and RDString.ChangeLanguage. Only Korean maps to its own locale ("ko"); every other
+/// language uses English ("en").
+///
+///
+/// Lookup order in : active locale, then English, then the key itself.
+/// Intended for the main Unity thread; the dictionaries are filled once during initialization and only read afterwards.
+///
///
internal static class LocalizationManager
{
+ /// Loaded locales: locale code (case-insensitive) -> key -> text. Keys are case-sensitive.
private static readonly Dictionary> Locales = new(StringComparer.OrdinalIgnoreCase);
+ /// Locale used when the language has no locale file and for keys missing in the active locale.
private static readonly string FallbackLocaleCode = "en";
///
/// Event triggered when the active language changes.
+ /// Raised by with notify: true when the language or the resolved locale differs from
+ /// the current one; the argument is the new game language. Exceptions from handlers are caught and logged
+ /// (an exception stops the remaining handlers of that invocation).
///
public static event Action? OnLanguageChanged;
///
- /// Currently active SystemLanguage.
+ /// Currently active SystemLanguage: the game language as passed to , also when it has no
+ /// locale file of its own (e.g. Japanese, which displays English text).
///
public static SystemLanguage CurrentLanguage { get; private set; } = SystemLanguage.English;
///
- /// Currently active locale code (e.g. "en", "ko").
+ /// Currently active locale code (e.g. "en", "ko"): the locale whose strings returns first.
+ /// Always a loaded locale or the fallback "en".
///
public static string CurrentLocaleCode { get; private set; } = "en";
+ /// Set by the first call; later calls return immediately.
private static bool _initialized;
+ /// Initializes on first access to any member, so lookups work before RenderMod calls .
static LocalizationManager()
{
Initialize();
@@ -39,6 +59,8 @@ internal static class LocalizationManager
///
/// Initializes the localization system and loads embedded language dictionaries.
+ /// Runs once; further calls do nothing. Selects the initial language without raising
+ /// .
///
public static void Initialize()
{
@@ -47,19 +69,26 @@ internal static class LocalizationManager
LoadEmbeddedLocales();
- // Detect initial language from game settings
+ // Detect initial language from game settings.
+ // At mod initialization RDString.Setup has usually not run yet, so this reads the field's initial value
+ // (English); LanguageSyncPatch applies the real language when the game calls RDString.Setup.
var initialLang = DetectGameLanguage();
SetLanguage(initialLang, notify: false);
LogMsg($"[Localization] Initialized. Active language: {CurrentLanguage} ({CurrentLocaleCode})");
}
+ ///
+ /// Logs through the MelonLoader logger of the mod, or to the console when that logger is not available
+ /// (e.g. when this class is used outside the running mod).
+ ///
private static void LogMsg(string msg)
{
try { Melon.Logger.Msg(msg); }
catch { Console.WriteLine(msg); }
}
+ /// Error counterpart of ; falls back to standard error.
private static void LogError(string msg)
{
try { Melon.Logger.Error(msg); }
@@ -68,6 +97,9 @@ internal static class LocalizationManager
///
/// Loads all *.json locale files embedded in this assembly.
+ /// A resource is used when its name ends with ".json" and contains "Localization.Locale" (the manifest name built
+ /// from the folder path, see the EmbeddedResource item in the .csproj). A file that fails to read or parse is
+ /// logged and skipped; a later resource with the same locale code replaces an earlier one.
///
private static void LoadEmbeddedLocales()
{
@@ -109,7 +141,13 @@ internal static class LocalizationManager
///
/// Sets the active language and notifies subscribers if changed.
+ /// The locale is chosen by ; when that locale is not loaded, "en" is used.
///
+ /// Game language (as used by RDString.language).
+ ///
+ /// Whether to raise when the language or locale changes; false is used for
+ /// the initial selection.
+ ///
public static void SetLanguage(SystemLanguage language, bool notify = true)
{
var localeCode = LanguageToLocaleCode(language);
@@ -140,7 +178,10 @@ internal static class LocalizationManager
///
/// Retrieves a localized string for the specified key.
+ /// Order: active locale, then English, then the key itself (so a missing key is visible in the UI).
///
+ /// Localization key, e.g. "ui.render_config.window_title"; case-sensitive.
+ /// The localized text, the key when no locale has it, or an empty string for a null/empty key.
public static string Get(string key)
{
if (string.IsNullOrEmpty(key)) return string.Empty;
@@ -166,7 +207,12 @@ internal static class LocalizationManager
///
/// Retrieves a formatted localized string.
+ /// The text from is used as a template
+ /// ({0}, {1}, ...), formatted with the current culture. When the template does not match the
+ /// arguments (), the unformatted template is returned.
///
+ /// Localization key.
+ /// Format arguments; with none, the template is returned as is.
public static string Get(string key, params object[] args)
{
var template = Get(key);
@@ -184,6 +230,7 @@ internal static class LocalizationManager
///
/// Maps Unity's SystemLanguage enum to an internal locale code.
+ /// Korean -> "ko"; every other language -> "en". A new locale file also needs an entry here.
///
private static string LanguageToLocaleCode(SystemLanguage language)
{
@@ -196,19 +243,22 @@ internal static class LocalizationManager
///
/// Attempts to read the game's current language from RDString or Persistence.
+ /// Falls back to English when both reads throw.
///
private static SystemLanguage DetectGameLanguage()
{
try
{
- // First attempt: RDString.language
+ // First attempt: RDString.language, a public static field of the game (initial value English,
+ // assigned from Persistence.language in RDString.Setup)
return RDString.language;
}
catch
{
try
{
- // Fallback attempt: Persistence.language
+ // Fallback attempt: Persistence.language, the saved "language" preference (defaults to
+ // Application.systemLanguage)
return Persistence.language;
}
catch
@@ -221,6 +271,7 @@ internal static class LocalizationManager
///
/// Convenient shorthand alias for .
+/// Every member forwards to the member of the same name in .
///
// ReSharper disable once InconsistentNaming
internal static class I18n
diff --git a/Patch/Engine/AsyncInputPatches.cs b/Patch/Engine/AsyncInputPatches.cs
index 0e9540a..a8944ab 100644
--- a/Patch/Engine/AsyncInputPatches.cs
+++ b/Patch/Engine/AsyncInputPatches.cs
@@ -3,10 +3,31 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Engine;
+///
+/// Makes the async input clock offset follow the render clock immediately.
+///
+/// Patched member: AsyncInputUtils.UpdateOffsetTime(long fixDivider = 1). The async input system maps wall-clock ticks
+/// (AsyncInputManager.currFrameTick = DateTime.Now.Ticks, 100 ns units) to conductor time with
+/// AsyncInputManager.offsetTick = currFrameTick - scrConductor.dspTime * 10^7. With fixDivider 1 the
+/// offset is set directly; otherwise it moves by 1 / fixDivider of the difference per call. scrConductor calls it every
+/// frame with divider 100 (damped); scrController calls it with divider 1.
+///
+///
+/// Active while (stage >= OnScene). From stage Render the conductor time follows the
+/// patched AudioSettings.dspTime (offline render clock, a fixed step per frame independent of wall-clock time), so the
+/// difference changes every frame; the damped update would lag behind it. The prefix always applies the divider-1 update.
+///
+/// Reference: https://learn.microsoft.com/en-us/dotnet/api/system.datetime.ticks
+///
[HarmonyPatch(typeof(AsyncInputUtils), "UpdateOffsetTime")]
// ReSharper disable once InconsistentNaming
public static class AsyncInputPatches
{
+ ///
+ /// Prefix of AsyncInputUtils.UpdateOffsetTime. While rendering, sets offsetTick to
+ /// currFrameTick - dspTime * 10^7 (10^7 = 100 ns ticks per second) and offsetTickUpdated to true, for any divider.
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix()
diff --git a/Patch/Engine/AudioPatches.cs b/Patch/Engine/AudioPatches.cs
index 9d9c2f1..5f33e9a 100644
--- a/Patch/Engine/AudioPatches.cs
+++ b/Patch/Engine/AudioPatches.cs
@@ -18,6 +18,7 @@ namespace ADOFAIRenderer.Patch.Engine;
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.FindOrLoadAudioClipExternal))]
public static class AudioManagerFindOrLoadAudioClipExternalPatch
{
+ /// Records in when its audioLib key is not loaded yet. Runs the original always.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static void Prefix(AudioManager __instance, string path)
@@ -28,20 +29,33 @@ public static class AudioManagerFindOrLoadAudioClipExternalPatch
}
}
+///
+/// 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.
+/// 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
+/// time/pitch/SetScheduledEndTime on it afterwards, which the patches forward to the mapped state.
+/// The game volume is passed through; the start pitch is 1 because _Play sets pitch = 1 before PlayScheduled.
+///
[HarmonyPatch(typeof(AudioManager), "_Play")]
public static class AudioManagerPlayPatch
{
+ /// Opens the clip's source so its first chunk starts loading. Returns true (the original runs).
[HarmonyPrefix]
public static bool Prefix(string snd, double time, AudioMixerGroup group, float volume, int priority)
{
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);
}
return true; // Let the original method execute to prevent NullReferenceException
}
+ ///
+ /// Registers the sound at (absolute DSP time, converted to render time) as a non-looping effect voice,
+ /// maps to it and queues the source's game object for deactivation at the end of the frame.
+ ///
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result, string snd, double time, AudioMixerGroup group, float volume, int priority)
@@ -63,6 +77,10 @@ public static class AudioManagerPlayPatch
///
/// scrConductor.PlayWithEndTime: registers a looping effect voice until the end time. The AudioSource it creates with
/// AudioManager.MakeSource is mapped to that voice ().
+/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs. The game calls it for
+/// hold sounds with an end time above 0 (scrConductor hold sound scheduling); it sets loop = true, pitch = 1, PlayScheduled(time) and
+/// SetScheduledEndTime(endTime) on the new source. pitch and SetScheduledEndTime reach the mapped state through the
+/// patches; PlayScheduled is blocked during rendering by another patch.
///
[HarmonyPatch(typeof(scrConductor), "PlayWithEndTime")]
public static class PlayWithEndTimePatch
@@ -70,9 +88,14 @@ public static class PlayWithEndTimePatch
/// State registered by the running PlayWithEndTime call, until its MakeSource returns. Main thread only.
internal static SourceState? PendingState;
+ /// Clears when PlayWithEndTime returns or throws, so it cannot map a later MakeSource call.
[HarmonyFinalizer]
public static void Finalizer() => PendingState = null;
+ ///
+ /// Opens the clip's source and registers a looping voice from to
+ /// (absolute DSP times, converted to render time); the state waits in . Returns true (the original runs).
+ ///
[HarmonyPrefix]
public static bool Prefix(string snd, double time, double endTime, float volume, int priority)
{
@@ -81,7 +104,7 @@ public static class PlayWithEndTimePatch
var mixer = RenderManager.Instance.AudioMixer;
if (mixer != null)
{
- // Cache the clip on the main thread
+ // Cache the clip on the main Unity thread
mixer.CacheClip(snd);
// Convert absolute DSP times to relative times in seconds from render start
@@ -96,10 +119,18 @@ public static class PlayWithEndTimePatch
}
}
-/// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state ().
+///
+/// Maps the AudioSource created inside scrConductor.PlayWithEndTime to its state ().
+/// Active only while a PlayWithEndTime call registered a state (capturing with RenderAudio on); every other MakeSource call
+/// (including the one inside AudioManager._Play) is left unchanged.
+///
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.MakeSource))]
public static class AudioManagerMakeSourcePatch
{
+ ///
+ /// Takes the pending state, maps to it and queues the source's game object for deactivation at
+ /// the end of the frame.
+ ///
[HarmonyPostfix]
// ReSharper disable once InconsistentNaming
public static void Postfix(AudioSource __result)
@@ -173,6 +204,7 @@ internal static class EffectSource
[HarmonyPatch(typeof(AudioSource), "isPlaying", MethodType.Getter)]
public static class AudioSourceIsPlayingPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref bool __result)
@@ -187,6 +219,7 @@ public static class AudioSourceIsPlayingPatch
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Getter)]
public static class AudioSourceTimeGetterPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
@@ -204,6 +237,7 @@ public static class AudioSourceTimeGetterPatch
[HarmonyPatch(typeof(AudioSource), "time", MethodType.Setter)]
public static class AudioSourceTimePatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, float value)
@@ -214,10 +248,14 @@ public static class AudioSourceTimePatch
}
}
-/// AudioSource.timeSamples getter (): clip position in samples of the clip.
+///
+/// AudioSource.timeSamples getter (): clip position in samples of the clip while playing; while not playing,
+/// the last value set through time or timeSamples (0 after Stop()).
+///
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Getter)]
public static class AudioSourceTimeSamplesGetterPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref int __result)
@@ -232,6 +270,7 @@ public static class AudioSourceTimeSamplesGetterPatch
[HarmonyPatch(typeof(AudioSource), "timeSamples", MethodType.Setter)]
public static class AudioSourceTimeSamplesPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, int value)
@@ -246,6 +285,7 @@ public static class AudioSourceTimeSamplesPatch
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Getter)]
public static class AudioSourcePitchGetterPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, ref float __result)
@@ -260,6 +300,7 @@ public static class AudioSourcePitchGetterPatch
[HarmonyPatch(typeof(AudioSource), "pitch", MethodType.Setter)]
public static class AudioSourcePitchPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, float value)
@@ -274,6 +315,7 @@ public static class AudioSourcePitchPatch
[HarmonyPatch(typeof(AudioSource), "SetScheduledEndTime", typeof(double))]
public static class AudioSourceSetScheduledEndTimePatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
@@ -292,6 +334,7 @@ public static class AudioSourceSetScheduledEndTimePatch
[HarmonyPatch(typeof(AudioSource), "Stop", new Type[0])]
public static class AudioSourceStopPatch
{
+ /// When finds a mapped state, uses it and returns false (Unity is skipped); otherwise returns true.
[HarmonyPrefix]
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance)
@@ -306,11 +349,13 @@ public static class AudioSourceStopPatch
/// AudioManager.StopAllSounds destroys every source in liveSources (all AudioManager._Play and scrConductor.PlayWithEndTime sources),
/// so every effect voice stops at the current render time, including ones scheduled to start later.
/// Callers include scrConductor.PlayHitTimes (from its second call), ScrubMusicToTile/Time, KillAllSoundsCoroutine,
-/// scrController.FailAction and scnGame.Play.
+/// scrController.FailAction, scrController.DebugUpdate, scnGame.Play and PauseMenu.Choose.
+/// Active while capturing (RenderManager.IsCapturingActive) with RenderAudio on; the original always runs (it destroys the sources).
///
[HarmonyPatch(typeof(AudioManager), nameof(AudioManager.StopAllSounds))]
public static class AudioManagerStopAllSoundsPatch
{
+ /// Stops every effect voice while capturing with RenderAudio on. The original always runs (no return value).
[HarmonyPrefix]
public static void Prefix()
{
diff --git a/Patch/Engine/RenderEnvironmentPatches.cs b/Patch/Engine/RenderEnvironmentPatches.cs
index 02856c4..d515ef9 100644
--- a/Patch/Engine/RenderEnvironmentPatches.cs
+++ b/Patch/Engine/RenderEnvironmentPatches.cs
@@ -6,13 +6,40 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Engine;
+///
+/// Unity engine patches that put the game on the offline render clock and the render resolution.
+///
+/// Render clock: while capturing (, stage >= Render) and
+/// RenderContext.TimePatch is on (default on; a RenderSession can turn it off), the Unity time getters return
+/// the value captured at the capture start (RenderManager.timeBase, unscaledTimeBase, dspTimeBase,
+/// frameCountBase, read in SetupPlay) plus the render time . Each rendered frame then
+/// advances game time by exactly 1 / RenderFPS seconds, independent of how long the frame took to render and encode, and the
+/// clocks continue from their real values without a jump. The patches only affect managed callers of these getters
+/// (game and mod scripts).
+///
+///
+/// All prefixes run on the thread that calls the getter (normally the main Unity thread) and read RenderManager state
+/// without locks.
+///
+///
public static class RenderEnvironmentPatches
{
+ ///
+ /// Render time of the current frame in seconds: RenderManager.frameCount / RenderFPS.
+ /// frameCount is reset to 0 when capture starts and incremented at the start of every frame (FirstUpdateHook).
+ ///
private static double TimeSpent => RenderManager.Instance.frameCount / RenderManager.Instance.Configuration.RenderFPS;
+ ///
+ /// Time.time (scaled game time in seconds, float): returns timeBase + while capturing with
+ /// the time patch on. Time.timeScale is not applied to the render time.
+ ///
[HarmonyPatch(typeof(Time), "time", MethodType.Getter)]
public static class TimePatch
{
+ /// Prefix of the Time.time getter.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -22,9 +49,13 @@ public static class RenderEnvironmentPatches
}
}
+ /// Time.timeAsDouble (double precision of Time.time): timeBase + .
[HarmonyPatch(typeof(Time), "timeAsDouble", MethodType.Getter)]
public static class TimeAsDoublePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -34,9 +65,18 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// Time.unscaledTime (time in seconds independent of Time.timeScale, float): unscaledTimeBase +
+ /// . Unscaled time is not advanced by Time.captureDeltaTime (it follows real time), so it is
+ /// replaced as well.
+ /// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
+ ///
[HarmonyPatch(typeof(Time), "unscaledTime", MethodType.Getter)]
public static class UnscaledTimePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -46,9 +86,15 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// Time.unscaledTimeAsDouble (double precision of Time.unscaledTime): unscaledTimeBase + .
+ ///
[HarmonyPatch(typeof(Time), "unscaledTimeAsDouble", MethodType.Getter)]
public static class UnscaledTimeAsDoublePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -58,9 +104,17 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// AudioSettings.dspTime (audio system clock in seconds, double): dspTimeBase + .
+ /// The game schedules the song and computes the song position from this clock (scrConductor), so the level advances
+ /// by one render frame per frame. The offline audio mixer uses the same base (RenderManager.dspTimeBase).
+ ///
[HarmonyPatch(typeof(AudioSettings), "dspTime", MethodType.Getter)]
public static class DspTimePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref double __result)
{
@@ -70,9 +124,19 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// AudioSource.PlayScheduled(double time) (start playback at a dspTime): blocked for every AudioSource while
+ /// (stage >= OnScene), so Unity plays no scheduled sound in real time. During
+ /// capture the song and the sound effects are produced offline by OfflineAudioMixer; sound effects reach it through
+ /// the AudioManager._Play and scrConductor.PlayWithEndTime patches (AudioPatches.cs).
+ ///
[HarmonyPatch(typeof(AudioSource), "PlayScheduled")]
public static class PlayScheduledPatch
{
+ /// Prefix of AudioSource.PlayScheduled.
+ /// The AudioSource (unused).
+ /// Scheduled start in dspTime seconds (unused).
+ /// True to run the original method (not rendering); false to skip it (rendering).
// ReSharper disable once InconsistentNaming
public static bool Prefix(AudioSource __instance, double time)
{
@@ -80,9 +144,16 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// Time.frameCount (frames since startup): frameCountBase + RenderManager.frameCount while capturing
+ /// (stage >= Render) with RenderContext.TimePatch on.
+ ///
[HarmonyPatch(typeof(Time), "frameCount", MethodType.Getter)]
public static class FrameCountPatch
{
+ /// Prefix of the getter; returns the replacement value under the conditions in the type summary.
+ /// Replacement value.
+ /// True to run the original getter; false when the replacement value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -92,9 +163,13 @@ public static class RenderEnvironmentPatches
}
}
+ /// Time.deltaTime (seconds since the previous frame, float): 1 / RenderFPS.
[HarmonyPatch(typeof(Time), "deltaTime", MethodType.Getter)]
public static class DeltaTimePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -104,9 +179,13 @@ public static class RenderEnvironmentPatches
}
}
+ /// Time.unscaledDeltaTime (timeScale-independent frame time in seconds, float): 1 / RenderFPS.
[HarmonyPatch(typeof(Time), "unscaledDeltaTime", MethodType.Getter)]
public static class UnscaledDeltaTimePatch
{
+ /// Prefix of the getter; returns the render clock value while capturing with the time patch on.
+ /// Replacement value in seconds.
+ /// True to run the original getter; false when the render clock value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref float __result)
{
@@ -116,9 +195,18 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// Screen.width (window width in pixels): RenderConfig.RenderWidth while
+ /// (stage >= OnScene) and RenderContext.ScreenPatch is on (default on). Game code that sizes render textures from
+ /// the screen size (for example scrCamera recreates camRT at Screen.width x Screen.height when the size
+ /// differs) then works at the output resolution independent of the window size.
+ ///
[HarmonyPatch(typeof(Screen), "width", MethodType.Getter)]
public static class ScreenWidthPatch
{
+ /// Prefix of the getter; returns the replacement value under the conditions in the type summary.
+ /// Replacement value.
+ /// True to run the original getter; false when the replacement value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -128,9 +216,15 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// Screen.height (window height in pixels): RenderConfig.RenderHeight; same conditions as .
+ ///
[HarmonyPatch(typeof(Screen), "height", MethodType.Getter)]
public static class ScreenHeightPatch
{
+ /// Prefix of the getter; returns the replacement value under the conditions in the type summary.
+ /// Replacement value.
+ /// True to run the original getter; false when the replacement value is returned.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(ref int __result)
{
@@ -140,9 +234,22 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// AudioSource.GetSpectrumData(float[] samples, int channel, FFTWindow window) (FFT magnitudes of the source's current
+ /// output): while capturing (stage >= Render) with AdvancedConfig.ProcessAudio on, fills samples with
+ /// (spectrum of the song at the render time) for any AudioSource.
+ /// No AudioSource plays during capture, so the original would return silence. The game calls it from
+ /// scrConductor, scrBarMaker and the volume trackers for audio-reactive visuals.
+ ///
[HarmonyPatch(typeof(AudioSource), "GetSpectrumData", typeof(float[]), typeof(int), typeof(FFTWindow))]
public static class GetSpectrumDataPatch
{
+ /// Prefix of AudioSource.GetSpectrumData.
+ /// The AudioSource queried (not used; the song spectrum is returned for every source).
+ /// Output array; its length is the number of spectrum bins.
+ /// Channel index requested by the caller.
+ /// FFT window type requested by the caller.
+ /// True to run the original method; false when the generated spectrum was written.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static bool Prefix(AudioSource __instance, float[] samples, int channel, FFTWindow window)
{
@@ -152,9 +259,19 @@ public static class RenderEnvironmentPatches
}
}
+ ///
+ /// AudioManager.FindOrLoadAudioClipExternal(string path, bool mp3Streaming, float length, bool stream) (coroutine
+ /// that loads an external audio file of the level into audioLib): from stage Load with
+ /// AdvancedConfig.ProcessAudio on, forces stream to false. The game passes stream to
+ /// DownloadHandlerAudioClip.streamAudio for ogg/wav/aif/aiff files, so these clips are loaded as non-streaming
+ /// clips; the MP3 path uses mp3Streaming and ignores stream.
+ /// Reference: https://docs.unity3d.com/ScriptReference/Networking.DownloadHandlerAudioClip-streamAudio.html
+ ///
[HarmonyPatch(typeof(AudioManager), "FindOrLoadAudioClipExternal", typeof(string), typeof(bool), typeof(float), typeof(bool))]
public static class FindOrLoadAudioClipExternalPatch
{
+ /// Prefix of AudioManager.FindOrLoadAudioClipExternal; the original always runs.
+ /// Streaming flag of the load; set to false under the conditions above.
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static void Prefix(ref bool stream)
{
diff --git a/Patch/Game/DetailedResultsPatch.cs b/Patch/Game/DetailedResultsPatch.cs
index d250ee2..9b6dcda 100644
--- a/Patch/Game/DetailedResultsPatch.cs
+++ b/Patch/Game/DetailedResultsPatch.cs
@@ -3,10 +3,27 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Hides the detailed results panel (per-judgement hit counts) that the game shows on the clear screen.
+///
+/// Patched member: DetailedResults.Show(). The game calls it from scrController.OnLandOnPortal when a level is won
+/// (and detailed results are enabled); it builds the result text per player, enables the component and activates
+/// scrController.detailedResults.
+///
+///
+/// Active while (stage >= OnScene), so the panel does not appear in the rendered video
+/// during the delay after the level end.
+///
+///
[HarmonyPatch(typeof(DetailedResults))]
// ReSharper disable once InconsistentNaming
public static class DetailedResultsPatch
{
+ ///
+ /// Prefix of DetailedResults.Show. While rendering, disables the component and deactivates its GameObject.
+ ///
+ /// The DetailedResults component being shown.
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("Show")]
// ReSharper disable once InconsistentNaming
diff --git a/Patch/Game/EndscreenLanternsPatch.cs b/Patch/Game/EndscreenLanternsPatch.cs
index 86e69aa..7193995 100644
--- a/Patch/Game/EndscreenLanternsPatch.cs
+++ b/Patch/Game/EndscreenLanternsPatch.cs
@@ -4,10 +4,29 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Suppresses the end-screen lanterns (level complete / accuracy / speed trial lamps) shown after a clear.
+///
+/// Patched members: EndscreenLanterns.Show(scrPlayer, float, int, bool), which activates the lantern set, sets the
+/// lantern texts and starts DOTween sequences (chainSequence for the chain drop, lampSequences[3] for the lamp
+/// effects), returning the number of lanterns shown; and EndscreenLanterns.Hide(), which deactivates the GameObject.
+///
+///
+/// Active while (stage >= OnScene), so the lanterns and their tweens do not appear in
+/// the rendered video during the delay after the level end. See also .
+///
+///
[HarmonyPatch(typeof(EndscreenLanterns))]
// ReSharper disable once InconsistentNaming
public static class EndscreenLanternsPatch
{
+ ///
+ /// Prefix of EndscreenLanterns.Show. While rendering, hides the lantern set (which also runs
+ /// ) and reports zero lanterns shown.
+ ///
+ /// The lantern set being shown.
+ /// Return value of Show: number of lanterns shown; set to 0 when the original is skipped.
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("Show")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
@@ -22,6 +41,21 @@ public static class EndscreenLanternsPatch
return false; // Skip original Show method
}
+ ///
+ /// Prefix of EndscreenLanterns.Hide. While rendering, releases the lantern tweens started by an earlier Show
+ /// before the original deactivates the GameObject.
+ ///
+ /// The private fields lampSequences (DOTween Sequence[]) and chainSequence (Sequence) are read
+ /// through Harmony . For each sequence, a public instance method Kill(bool) is looked up on the
+ /// object's runtime type by reflection and invoked with false when found; then the field slot is set to null.
+ /// In the game's DOTween.dll, Kill(this Tween, bool complete = false) is an extension method of
+ /// DG.Tweening.TweenExtensions, so the lookup on Sequence returns null and no Kill is invoked; the effect is
+ /// that the fields stop referencing the sequences, while the sequences themselves keep running in DOTween.
+ /// Reflection failures on a single sequence (including a null array element) are ignored. The original Hide always runs
+ /// afterwards.
+ ///
+ ///
+ /// The lantern set being hidden.
[HarmonyPrefix]
[HarmonyPatch("Hide")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
diff --git a/Patch/Game/LanguageSyncPatch.cs b/Patch/Game/LanguageSyncPatch.cs
index 88daa71..407509c 100644
--- a/Patch/Game/LanguageSyncPatch.cs
+++ b/Patch/Game/LanguageSyncPatch.cs
@@ -5,11 +5,19 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
///
-/// Synchronizes mod localization with ADOFAI's in-game language settings.
+/// Keeps the mod UI language () equal to the game language.
+///
+/// Patched members: RDString.Setup(), which initializes the game's string table and sets RDString.language from
+/// Persistence.language (it returns immediately when already initialized); and RDString.ChangeLanguage(SystemLanguage),
+/// which stores the language in Persistence.language, clears the initialized flag and calls Setup again.
+///
+/// Always active (independent of the render stage). Runs on the main Unity thread.
///
[HarmonyPatch(typeof(RDString))]
internal static class LanguageSyncPatch
{
+ /// Postfix of RDString.ChangeLanguage: applies the selected language to the mod UI.
+ /// Language passed to ChangeLanguage.
[HarmonyPatch(nameof(RDString.ChangeLanguage))]
[HarmonyPostfix]
public static void OnChangeLanguage(SystemLanguage language)
@@ -17,6 +25,10 @@ internal static class LanguageSyncPatch
LocalizationManager.SetLanguage(language);
}
+ ///
+ /// Postfix of RDString.Setup: applies RDString.language (the language resolved by the game) to the mod UI.
+ /// Runs on every Setup call, including the early-return calls made when the string table is already initialized.
+ ///
[HarmonyPatch(nameof(RDString.Setup))]
[HarmonyPostfix]
public static void OnSetup()
diff --git a/Patch/Game/PersistencePatch.cs b/Patch/Game/PersistencePatch.cs
index e27f468..15c1534 100644
--- a/Patch/Game/PersistencePatch.cs
+++ b/Patch/Game/PersistencePatch.cs
@@ -3,9 +3,23 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Overrides two player settings read from Persistence during a render: the "skip intro" option and the planet colors.
+///
[HarmonyPatch(typeof(Persistence))]
public static class PersistencePatch
{
+ ///
+ /// Postfix of the Persistence.skipIntroBehavior getter (setting values Off / AfterFirstTry / Always).
+ ///
+ /// scrConductor.StartMusicCo reads this setting when the song is scheduled and, for AfterFirstTry (after a death) or
+ /// Always (long intro, no checkpoint), sets skipOffset, which moves dspTimeSong back by the level offset and
+ /// starts the song part-way in. While (stage >= OnScene) and the song has not
+ /// started yet (scrConductor.hasSongStarted false), the getter returns Off, so skipOffset stays false and the
+ /// song always plays from its start. The mod's offline BGM mixing, spectrum and video background timing assume this.
+ ///
+ ///
+ /// Value returned by the getter; replaced with SkipIntroBehavior.Off under the conditions above.
[HarmonyPostfix]
[HarmonyPatch("skipIntroBehavior", MethodType.Getter)]
// ReSharper disable once InconsistentNaming
@@ -19,6 +33,18 @@ public static class PersistencePatch
}
}
+ ///
+ /// Postfix of Persistence.GetPlayerColor(bool red), which returns the player's saved planet color
+ /// (preset name or hex color from the general preferences; default red/blue otherwise). PlanetRenderer uses it to
+ /// color the fire (red) and ice (blue) planets.
+ ///
+ /// Active from stage Load (render started, scene loading) until the stage returns to None. When the render configuration
+ /// sets AdvancedConfig.firePlanetColor (red) or AdvancedConfig.icePlanetColor (blue), that color replaces the
+ /// saved color; an unset (null) override keeps the game value. The saved preference itself is not changed.
+ ///
+ ///
+ /// True for the fire (red) planet, false for the ice (blue) planet.
+ /// Color returned by the game; replaced by the configured override when set.
[HarmonyPostfix]
[HarmonyPatch("GetPlayerColor")]
// ReSharper disable once InconsistentNaming
diff --git a/Patch/Game/PlanetarySystemPatch.cs b/Patch/Game/PlanetarySystemPatch.cs
index 1189c27..70dc598 100644
--- a/Patch/Game/PlanetarySystemPatch.cs
+++ b/Patch/Game/PlanetarySystemPatch.cs
@@ -3,9 +3,21 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Applies the configured color of the third (green, "wind") planet used by multi-planet levels.
+///
+/// Patched members: PlanetarySystem.Init(), which creates the planets including planetGreen (default color
+/// RGB 0.3, 0.7, 0.0); and PlanetarySystem.ApplyMultiplanetColors(), which recolors the extra planets
+/// (green, yellow, purple, ...) from the red/blue planet colors (skipped by the game when GCS.staticPlanetColors is set).
+/// Both postfixes reapply the override after the game has set its color.
+///
+/// Active from stage Load until the stage returns to None. The fire/ice colors are handled by .
+///
[HarmonyPatch(typeof(PlanetarySystem))]
public static class PlanetarySystemPatch
{
+ /// Postfix of PlanetarySystem.Init: applies the wind planet color override to the new planets.
+ /// The planetary system that was initialized.
[HarmonyPostfix]
[HarmonyPatch("Init")]
// ReSharper disable once InconsistentNaming
@@ -14,6 +26,8 @@ public static class PlanetarySystemPatch
ApplyWindPlanetColor(__instance);
}
+ /// Postfix of PlanetarySystem.ApplyMultiplanetColors: reapplies the wind planet color override.
+ /// The planetary system whose extra planet colors were updated.
[HarmonyPostfix]
[HarmonyPatch("ApplyMultiplanetColors")]
// ReSharper disable once InconsistentNaming
@@ -22,6 +36,11 @@ public static class PlanetarySystemPatch
ApplyWindPlanetColor(__instance);
}
+ ///
+ /// Sets planetGreen's renderer color to AdvancedConfig.windPlanetColor when a render is in progress
+ /// (stage != None) and the override is set. Does nothing when the override is null or the planet does not exist.
+ ///
+ /// The planetary system to update.
private static void ApplyWindPlanetColor(PlanetarySystem planetarySystem)
{
if (RenderManager.Instance == null || RenderManager.Instance.stage == RenderStage.None) return;
diff --git a/Patch/Game/scnEditorPatch.cs b/Patch/Game/scnEditorPatch.cs
index 8698c0e..4092f3b 100644
--- a/Patch/Game/scnEditorPatch.cs
+++ b/Patch/Game/scnEditorPatch.cs
@@ -6,10 +6,28 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Adds the "Render" button to the level editor toolbar.
+///
+/// Patched member: scnEditor.Start(), the editor scene setup (it wires the toolbar buttons, including
+/// buttonExit). The postfix clones the exit button next to it and makes the clone start a render.
+///
+///
+/// Always active except when the mod features are disabled (, set from the
+/// driver warning dialog). Runs on the main Unity thread each time the editor scene starts.
+///
+///
[HarmonyPatch(typeof(scnEditor))]
// ReSharper disable once InconsistentNaming
public static class scnEditorPatch
{
+ ///
+ /// Postfix of scnEditor.Start: instantiates a copy of buttonExit under the same parent, named
+ /// buttonRender, whose click handler is , and replaces its icon with the
+ /// editor's MoveCamera event sprite (24 x 24 units). Logs a message and adds nothing when the editor or exit button is missing.
+ /// When the icon child or the sprite is not found, the button keeps the exit icon.
+ ///
+ /// The editor scene component.
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
@@ -38,8 +56,9 @@ public static class scnEditorPatch
var images = buttonObj.GetComponentsInChildren();
if (images.Length <= 1) return;
+ // images[0] is the background on the root, images[1] is the icon on the child.
+ // Sprite path: the icon of the editor's MoveCamera level event, loaded from the game's Resources.
// ReSharper disable once Unity.UnknownResource
- // images[0] is the background on the root, images[1] is the icon on the child
var sprite = Resources.Load("LevelEditor/LevelEvents/MoveCamera");
if (sprite == null) return;
diff --git a/Patch/Game/scnGamePatch.cs b/Patch/Game/scnGamePatch.cs
index 3d2d943..5861eb8 100644
--- a/Patch/Game/scnGamePatch.cs
+++ b/Patch/Game/scnGamePatch.cs
@@ -6,10 +6,26 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Patches of scnGame (custom level scene controller, also used by the editor's play mode): hides tiles and planets
+/// for the "non-tile" render option and replaces the background video player during a render.
+///
[HarmonyPatch(typeof(scnGame))]
// ReSharper disable once InconsistentNaming
public static class scnGamePatch
{
+ ///
+ /// Postfix of scnGame.Play(int seqID, bool remakeFloors), which starts (or restarts) play of the custom level:
+ /// it resets the conductor, camera and controller state for the given start tile.
+ ///
+ /// Active from stage Load when AdvancedConfig.NonTile is enabled. Disables the renderers of every floor in
+ /// scrLevelMaker.listFloors (floor mesh, legacy sprite, icon, outline, top/bottom glow, multi-planet line,
+ /// hold mesh, dummy planets) and of every planet of every player (sprite mesh, core/tail/sparks particles, ring, glow,
+ /// face, samurai sprite), so only the background and decorations remain in the rendered video. The renderers are only
+ /// disabled; the objects keep running. Hold meshes re-enable themselves, so RenderManager.OnLastUpdate disables
+ /// them again every frame.
+ ///
+ ///
[HarmonyPostfix]
[HarmonyPatch("Play")]
public static void ProcessFloors()
@@ -47,6 +63,7 @@ public static class scnGamePatch
}
return;
+ // Disables every renderer that draws one planet (fire, ice, extra planets and floor dummy planets).
void DisablePlanetRenderer(PlanetRenderer planet)
{
if (planet.sprite) DisableRenderer(planet.sprite.meshRenderer);
@@ -62,12 +79,29 @@ public static class scnGamePatch
foreach (var renderer in renderers) DisableRenderer(renderer);
}
+ // Sets Renderer.enabled = false; null or destroyed renderers are skipped (Unity null check).
void DisableRenderer(Renderer? renderer)
{
if (renderer) renderer.enabled = false;
}
}
+ ///
+ /// Prefix of scnGame.UpdateVideo(), which sets up the level's background video on the native Unity
+ /// VideoPlayer (scnGame.videoBG): it reads miscSettings.bgVideo, loads a VideoClip for internal
+ /// (Resources / Addressables) and bundle levels or sets videoBG.url to the file next to the custom level, then calls
+ /// Stop(), Prepare() and sets isLooping from loopVideo. The game calls it from
+ /// scnGame.FinishCustomLevelLoading and from scnGame.ReloadAssets when force is true.
+ ///
+ /// While (stage >= OnScene), an external video file of a custom level is played by
+ /// (FFmpeg decoder), which shows the frame for the render time of each frame. The native
+ /// VideoPlayer plays in real time and would drift from the offline render clock, so it is disabled for that case.
+ /// Internal and bundle levels (built-in VideoClip assets, not files) and a missing file use the original method.
+ ///
+ /// Outside a render, any left on the video object is destroyed and the native player re-enabled.
+ ///
+ /// The scene controller.
+ /// True to run the original UpdateVideo; false when the mod handled the video (or there is none).
[HarmonyPrefix]
[HarmonyPatch("UpdateVideo")]
// ReSharper disable once InconsistentNaming
@@ -109,7 +143,8 @@ public static class scnGamePatch
return true; // Let the original print error
}
- // Disable native VideoPlayer component (to prevent it from attempting real-time playback and sync errors)
+ // Camera for the CameraFarPlane render mode: the VideoPlayer's target camera, or the "BGStaticCam" scene object
+ // when none is assigned.
var targetCamera = __instance.videoBG.targetCamera;
if (!targetCamera)
{
@@ -120,6 +155,7 @@ public static class scnGamePatch
}
}
+ // Disable the native VideoPlayer so it does not play the video in real time alongside VideoBGPlayer.
__instance.videoBG.enabled = false;
// Add or retrieve our custom FFmpeg video background player
diff --git a/Patch/Game/scrCameraPatch.cs b/Patch/Game/scrCameraPatch.cs
index 3ba2c4d..a065741 100644
--- a/Patch/Game/scrCameraPatch.cs
+++ b/Patch/Game/scrCameraPatch.cs
@@ -3,16 +3,37 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Protects the camera render texture scrCamera.camRT when the custom frame rate effect is turned off while it is
+/// already off.
+///
+/// Patched member: scrCamera.SetCustomFrameRate(bool enable, float frame) (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 frame FPS.
+/// Disabling calls Release() on the quad material's current mainTexture and assigns camRT back. When
+/// the effect was never enabled, mainTexture is camRT itself, so the original releases the live camera
+/// render texture.
+///
+/// Always active (independent of the render stage).
+///
[HarmonyPatch(typeof(scrCamera))]
// ReSharper disable once InconsistentNaming
public class scrCameraPatch
{
+ ///
+ /// Prefix of scrCamera.SetCustomFrameRate. For a disable request while enableCustomFPS is already false,
+ /// stores the arguments in enableCustomFPS / frameRate and skips the original (no texture is released).
+ /// Enable requests and disable requests while the effect is on run the original.
+ ///
+ /// The camera.
+ /// True to turn the custom frame rate on, false to turn it off.
+ /// Custom frame rate in frames per second.
+ /// True to run the original method; false to skip it.
[HarmonyPrefix]
[HarmonyPatch("SetCustomFrameRate")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
private static bool FixCamRT(scrCamera __instance, bool enable, float frame)
{
- // There is an issue that `mainTexture.Release` triggers even if `mainTexture` is `camRT`, so mod tries to fix it
+ // Disable request while the effect is off: the quad material's mainTexture is camRT, which the original would Release().
if (enable || __instance.enableCustomFPS != enable) return true;
__instance.enableCustomFPS = enable;
diff --git a/Patch/Game/scrControllerPatch.cs b/Patch/Game/scrControllerPatch.cs
index cbd98a9..74ed665 100644
--- a/Patch/Game/scrControllerPatch.cs
+++ b/Patch/Game/scrControllerPatch.cs
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Keeps the game controller (scrController) in uninterrupted play during a render: no pause, no failure,
+/// no scene change after the clear, and no clear texts.
+///
+/// All patches are active while (stage >= OnScene). RenderManager also sets
+/// noFail, noFailInfiniteMargin and disableCongratsMessage on the controller when the scene is ready.
+///
+///
[HarmonyPatch(typeof(scrController))]
// ReSharper disable once InconsistentNaming
public static class scrControllerPatch
{
+ ///
+ /// Prefix of scrController.TogglePauseGame() (pause menu toggle; sets Time.timeScale to 0 or 1 and shows the
+ /// pause menu; also called from the pause button, the pause key and OnApplicationPauseCallback). Skipped while
+ /// rendering, so the level cannot be paused. The skipped call returns
+ /// false (Harmony default for the unset bool result).
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("TogglePauseGame")]
public static bool PreventPause()
@@ -14,6 +29,12 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
+ ///
+ /// Prefix of scrController.PortalTravelAction(Portal), which leaves the level through a portal (next level,
+ /// level select, main menu, ...); the game calls it on entering the Won state for non-world levels and on input after a win.
+ /// Skipped while rendering, so the scene stays loaded for the delay after the level end.
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("PortalTravelAction")]
public static bool PreventPortalTravel()
@@ -21,6 +42,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
+ ///
+ /// Prefix of scrController.FailAction(...), which enters the Fail state: stops the song and all sounds, pauses the
+ /// VFX video and shows the failure texts. Skipped while rendering.
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("FailAction")]
public static bool PreventFailAction()
@@ -28,6 +54,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
+ ///
+ /// Prefix of scrController.Fail2Action(), which moves from Fail to Fail2 (accuracy calculation, saving of the
+ /// best result for boss/custom boss levels, death counter). Skipped while rendering.
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("Fail2Action")]
public static bool PreventFail2Action()
@@ -35,6 +66,11 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
+ ///
+ /// Prefix of scrController.OnPlayerDied(...), which kills the remaining players and calls FailAction when no other
+ /// player is alive. Skipped while rendering.
+ ///
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("OnPlayerDied")]
public static bool PreventPlayerDied()
@@ -42,6 +78,17 @@ public static class scrControllerPatch
return !RenderManager.IsActive;
}
+ ///
+ /// Postfix of scrController.OnLandOnPortal(...), the win handler on the last tile: it sets and activates
+ /// txtCongrats / txtAprilCongrats / txtAllStrictClear, may show the detailed results and end-screen
+ /// lanterns, saves the result and changes the state to Won.
+ ///
+ /// While rendering, clears and deactivates the three clear texts and disables the detailed results panel after the
+ /// original has run, so they do not appear in the rendered video. The Won state itself is kept: the default render end
+ /// condition (RenderContext.EndCondition) waits for it.
+ ///
+ ///
+ /// The game controller.
[HarmonyPostfix]
[HarmonyPatch("OnLandOnPortal")]
public static void PreventWinTextsPostfix(scrController __instance)
diff --git a/Patch/Game/scrFloorPatch.cs b/Patch/Game/scrFloorPatch.cs
index 56c9be8..c95e26c 100644
--- a/Patch/Game/scrFloorPatch.cs
+++ b/Patch/Game/scrFloorPatch.cs
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Prevents the end portal from being spawned on level tiles for the "non-tile" render option.
+///
+/// Patched member: scrFloor.SpawnPortalParticles(), which instantiates the animated portal prefab
+/// (scrPortalParticles: glow, particles, cap and icon) as a child of the floor. The game calls it from
+/// scrLevelMaker, scrController and scrFloor for the portal floor.
+///
+///
+/// Active from stage Load when AdvancedConfig.NonTile is enabled, and only for floors that belong to
+/// scrLevelMaker.listFloors (the level's tiles). See for the other tile renderers.
+///
+///
[HarmonyPatch(typeof(scrFloor))]
// ReSharper disable once InconsistentNaming
public class scrFloorPatch
{
+ /// Prefix of scrFloor.SpawnPortalParticles.
+ /// The floor that would receive the portal.
+ /// True to run the original method (spawn the portal); false to skip it (non-tile render, level floor).
[HarmonyPrefix]
[HarmonyPatch("SpawnPortalParticles")]
// ReSharper disable once InconsistentNaming
diff --git a/Patch/Game/scrHitTextManagerPatch.cs b/Patch/Game/scrHitTextManagerPatch.cs
index fa52f66..2228ec0 100644
--- a/Patch/Game/scrHitTextManagerPatch.cs
+++ b/Patch/Game/scrHitTextManagerPatch.cs
@@ -3,10 +3,20 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Hides the judgement texts (e.g. Perfect, Early, Late) during a render unless the render configuration enables them.
+///
+/// Patched member: scrHitTextManager.ShowHitText(HitMargin, scrPlanet, float, int?), which shows the hit margin
+/// text at the planet for each hit (the game itself skips it for no-HUD modes and hidden margins).
+///
+/// Active while (stage >= OnScene) and RenderConfig.ShowJudgementText is false.
+///
[HarmonyPatch(typeof(scrHitTextManager), "ShowHitText")]
// ReSharper disable once InconsistentNaming
public class scrHitTextManagerPatch
{
+ /// Prefix of scrHitTextManager.ShowHitText.
+ /// True to run the original method (not rendering, or judgement texts enabled); false to skip it.
public static bool Prefix()
{
return !RenderManager.IsActive || RenderManager.Instance is { Configuration: { ShowJudgementText: true } };
diff --git a/Patch/Game/scrMistakesManagerPatch.cs b/Patch/Game/scrMistakesManagerPatch.cs
index 99e70e3..b0d5043 100644
--- a/Patch/Game/scrMistakesManagerPatch.cs
+++ b/Patch/Game/scrMistakesManagerPatch.cs
@@ -3,10 +3,25 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Keeps a render from writing custom level results to the player's save data.
+///
+/// Patched member: scrMistakesManager.SaveCustom(string hash, bool wonLevel, float multiplier), which compares the
+/// run with the stored completion, accuracy, speed trial and death records of the custom level (keyed by the level hash),
+/// stores new bests through Persistence and returns an EndLevelInfo. The game calls it from
+/// scrController.OnLandOnPortal (custom level win) and Fail2Action (custom boss level failure).
+///
+///
+/// Active while (stage >= OnScene); the run of a render is not stored as a player
+/// record. The skipped call returns a default EndLevelInfo.
+///
+///
[HarmonyPatch(typeof(scrMistakesManager))]
// ReSharper disable once InconsistentNaming
public static class scrMistakesManagerPatch
{
+ /// Prefix of scrMistakesManager.SaveCustom.
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("SaveCustom")]
public static bool PreventSaveCustom()
diff --git a/Patch/Game/scrPlayerManagerPatch.cs b/Patch/Game/scrPlayerManagerPatch.cs
index 5689d63..499b6cb 100644
--- a/Patch/Game/scrPlayerManagerPatch.cs
+++ b/Patch/Game/scrPlayerManagerPatch.cs
@@ -3,10 +3,27 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Replaces the "any valid input" check with a render-controlled value, so player input cannot affect the level during a render.
+///
+/// Patched member: scrPlayerManager.AnyValidInputWasTriggered(), true when any player pressed a valid key this frame.
+/// scrController uses it to leave the press-to-start wait, to restart after a failure (Fail2_Update) and to
+/// leave the level after a win (Won_Update).
+///
+///
+/// Active while (stage >= OnScene). The result is true only while
+/// RenderManager.countdown is 0 and RenderManager.frameCount is 0, that is, after the 3 second countdown in
+/// SetupPlay has ended and reset the frame counter and before the next frame increments it; it is false otherwise.
+/// SetupPlay also sets scrController.levelWasSkipped, which ends the press-to-start wait independently of input.
+///
+///
[HarmonyPatch(typeof(scrPlayerManager))]
// ReSharper disable once InconsistentNaming
public static class scrPlayerManagerPatch
{
+ /// Prefix of scrPlayerManager.AnyValidInputWasTriggered.
+ /// Return value used when the original is skipped (see class summary).
+ /// True to run the original method (not rendering); false to skip it (rendering).
[HarmonyPrefix]
[HarmonyPatch("AnyValidInputWasTriggered")]
// ReSharper disable once InconsistentNaming
diff --git a/Patch/Game/scrPortalParticlesPatch.cs b/Patch/Game/scrPortalParticlesPatch.cs
index 4cec411..fc559ba 100644
--- a/Patch/Game/scrPortalParticlesPatch.cs
+++ b/Patch/Game/scrPortalParticlesPatch.cs
@@ -4,10 +4,26 @@ using UnityEngine;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Makes the end portal particle systems run on scaled game time during a render.
+///
+/// Patched member: scrPortalParticles.Start() (the animated portal spawned by scrFloor.SpawnPortalParticles;
+/// Start pre-simulates its particle system for 1 second and plays it).
+///
+///
+/// Active from stage Load. RenderManager sets Time.captureDeltaTime to 1 / RenderFPS, which advances scaled time by a
+/// fixed step per frame; unscaled time is not affected by captureDeltaTime and follows real time. A particle system with
+/// main.useUnscaledTime would therefore play at the real (slower or faster) frame rate of the offline render, so the
+/// postfix sets it to false on every particle system under the portal (including inactive children).
+/// Reference: https://docs.unity3d.com/ScriptReference/Time-captureDeltaTime.html
+///
+///
[HarmonyPatch(typeof(scrPortalParticles))]
// ReSharper disable once InconsistentNaming
public static class scrPortalParticlesPatch
{
+ /// Postfix of scrPortalParticles.Start: sets useUnscaledTime = false on all child particle systems.
+ /// The portal that started.
[HarmonyPostfix]
[HarmonyPatch("Start")]
// ReSharper disable once InconsistentNaming
diff --git a/Patch/Game/scrPressToStartPatch.cs b/Patch/Game/scrPressToStartPatch.cs
index 122b264..2849351 100644
--- a/Patch/Game/scrPressToStartPatch.cs
+++ b/Patch/Game/scrPressToStartPatch.cs
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Hides the "press any key to begin" text of the first level start of a render.
+///
+/// Patched member: scrPressToStart.ShowText(), which sets the press/tap-to-begin text and shows the difficulty
+/// selector (scrUIController.ShowDifficultyContainer). scrController calls it before waiting for the start input.
+///
+///
+/// Active once per render: RenderManager.skipPressToStart is set when the render loads scnGame and cleared by
+/// the first ShowText call (or when the render stops). Later ShowText calls run the original.
+///
+///
[HarmonyPatch(typeof(scrPressToStart))]
// ReSharper disable once InconsistentNaming
public class scrPressToStartPatch
{
+ ///
+ /// Prefix of scrPressToStart.ShowText. When skipPressToStart is set, clears it, hides the difficulty
+ /// selector and skips the original.
+ ///
+ /// True to run the original method; false to skip it (first ShowText of a render).
[HarmonyPrefix]
[HarmonyPatch("ShowText")]
private static bool PreventShowText()
diff --git a/Patch/Game/scrShowIfDebugPatch.cs b/Patch/Game/scrShowIfDebugPatch.cs
index c6e996e..c82ffcb 100644
--- a/Patch/Game/scrShowIfDebugPatch.cs
+++ b/Patch/Game/scrShowIfDebugPatch.cs
@@ -6,10 +6,31 @@ using UnityEngine.UI;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Replaces the game's status text (normally "Autoplay", "Auto tile" or "Debug Mode") with the render status.
+///
+/// Patched member: scrShowIfDebug.Update(), which each frame sets its UI Text (txt) to the autoplay /
+/// auto-tile / debug label or hides it (no-HUD modes, recording mode), in defaultColor (green) or orange for auto tiles.
+///
+///
+/// Active while (stage >= OnScene). The postfix overwrites whatever the original set,
+/// every frame, so the text is shown even in the game's no-HUD modes.
+///
+///
[HarmonyPatch(typeof(scrShowIfDebug))]
// ReSharper disable once InconsistentNaming
public static class scrShowIfDebugPatch
{
+ ///
+ /// Postfix of scrShowIfDebug.Update. Shows, in the default (green) color:
+ ///
+ /// - "Render in Ns.." during the countdown (N = RenderManager.countdown, seconds), otherwise "Render";
+ /// - after the end condition is met (endFrame != -1), a second line "Waiting for X.Xs.." with the remaining
+ /// part of DelayAfterEnd in render seconds ((frameCount - endFrame) / RenderFPS elapsed), clamped at 0.
+ ///
+ ///
+ /// Private field txt: the status Text component.
+ /// Private field defaultColor: the game's status text color.
[HarmonyPostfix]
[HarmonyPatch("Update")]
[SuppressMessage("ReSharper", "InconsistentNaming")]
diff --git a/Patch/Game/scrUIControllerPatch.cs b/Patch/Game/scrUIControllerPatch.cs
index faf5c72..a10c1e8 100644
--- a/Patch/Game/scrUIControllerPatch.cs
+++ b/Patch/Game/scrUIControllerPatch.cs
@@ -3,10 +3,26 @@ using HarmonyLib;
namespace ADOFAIRenderer.Patch.Game;
+///
+/// Hides the end-screen lanterns again right after the game shows them.
+///
+/// Patched member: scrUIController.ShowEndscreenLanterns(), which calls EndscreenLanterns.Show for each player's
+/// lantern set (and fades the end-screen flash in co-op). scrController.OnLandOnPortal calls it on a win.
+///
+///
+/// Active while (stage >= OnScene). Works together with ,
+/// which already skips EndscreenLanterns.Show during a render.
+///
+///
[HarmonyPatch(typeof(scrUIController))]
// ReSharper disable once InconsistentNaming
public class scrUIControllerPatch
{
+ ///
+ /// Postfix of scrUIController.ShowEndscreenLanterns: calls HideEndscreenLanterns(), which runs
+ /// EndscreenLanterns.Hide on every lantern set.
+ ///
+ /// The UI controller.
[HarmonyPostfix]
[HarmonyPatch("ShowEndscreenLanterns")]
// ReSharper disable once InconsistentNaming
diff --git a/RenderMod.cs b/RenderMod.cs
index 5a6020b..9d8f0e7 100644
--- a/RenderMod.cs
+++ b/RenderMod.cs
@@ -14,12 +14,30 @@ using Object = UnityEngine.Object;
namespace ADOFAIRenderer;
+///
+/// MelonLoader entry point of the mod. Loads the settings and localization, points FFmpeg.AutoGen at the native
+/// FFmpeg libraries, and creates the persistent manager objects (RenderManager, FirstUpdateHook, RendererUIManager).
+///
internal class RenderMod : MelonMod
{
+ ///
+ /// When true, the render button is not added to the level editor (scnEditorPatch checks it in scnEditor.Start).
+ /// Set by the user from the NVIDIA driver warning dialog (FirstUpdateHook). Main thread only.
+ ///
// ReSharper disable once MemberCanBePrivate.Global
public static bool IsFeatureDisabled = false;
+
+ ///
+ /// Expiration time of this build (UTC). After it, the render configuration window shows an "expired" note next to
+ /// the version text. It does not block rendering.
+ ///
public static DateTime ExpirationTime = new(2026, 11, 21, 0, 0, 0, DateTimeKind.Utc);
+ ///
+ /// Called once by MelonLoader after the mod assembly is loaded.
+ /// The native FFmpeg libraries are loaded from MelonLoader's UserLibs directory.
+ /// The three manager GameObjects are marked DontDestroyOnLoad so they survive scene changes.
+ ///
public override void OnInitializeMelon()
{
GlobalSettings.Load();
@@ -39,6 +57,10 @@ internal class RenderMod : MelonMod
ModAssets.Load();
}
+ ///
+ /// Called by MelonLoader when the game quits. Aborts an active render (encoders disposed, render thread joined,
+ /// unfinished output file deleted) through RenderManager.CleanUpActiveRender.
+ ///
public override void OnApplicationQuit()
{
if (RenderManager.Instance == null) return;
diff --git a/UI/CommonWarningDialogUI.cs b/UI/CommonWarningDialogUI.cs
index 86a8b8b..7c30c77 100644
--- a/UI/CommonWarningDialogUI.cs
+++ b/UI/CommonWarningDialogUI.cs
@@ -11,14 +11,29 @@ using Image = UnityEngine.UI.Image;
namespace ADOFAIRenderer.UI;
+///
+/// Description of one button of a dialog: label, click action and optional color.
+///
internal class DialogButton
{
// ReSharper disable FieldCanBeMadeReadOnly.Global
+ /// Button label, shown as given (callers pass already localized text).
public string Text;
+
+ ///
+ /// 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).
+ ///
public Action OnClick;
+
+ /// Normal (idle) background color of the button; null keeps the O5Kit theme button color.
public Color? BackgroundColor;
// ReSharper restore FieldCanBeMadeReadOnly.Global
+ /// Creates a button description.
+ /// Button label.
+ /// Action run on click, before the dialog closes.
+ /// Optional normal background color; null uses the theme color.
public DialogButton(string text, Action onClick, Color? backgroundColor = null)
{
Text = text;
@@ -27,10 +42,29 @@ internal class DialogButton
}
}
+///
+/// Modal message dialog with a title, a description and one or more buttons.
+///
+/// Each call creates its own GameObject (kept across scene loads) with a screen-space overlay
+/// 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 () is disabled so that it cannot
+/// receive input; the roots are enabled again in . Clicking any button runs its action and then
+/// destroys the dialog. All members run on the main Unity thread.
+///
+///
internal class CommonWarningDialogUI : MonoBehaviour
{
+ ///
+ /// The most recently created dialog, or null when that dialog has been destroyed.
+ /// FirstUpdateHook checks it to wait until a startup dialog is closed.
+ /// If several dialogs are open at once, this refers to the newest one only.
+ ///
public static CommonWarningDialogUI? Instance { get; private set; }
+
+ /// UI Toolkit root elements disabled by this dialog, enabled again when the dialog is destroyed.
private readonly List _disabledElements = new();
+
+ /// O5Kit window holding the title, description and buttons; disposed in .
private O5Window? _window;
private void Awake()
@@ -38,6 +72,10 @@ internal class CommonWarningDialogUI : MonoBehaviour
Instance = this;
}
+ ///
+ /// Disposes the window, enables the UI Toolkit roots disabled in and clears
+ /// if it still refers to this dialog.
+ ///
private void OnDestroy()
{
_window?.Dispose();
@@ -58,6 +96,15 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
+ ///
+ /// Creates and shows a new dialog. Must be called on the main Unity thread.
+ ///
+ /// Window title text.
+ /// Body text, centered and word-wrapped; TextMeshPro rich text tags are allowed.
+ ///
+ /// Buttons laid out left to right with equal widths. With no buttons nothing is shown, because the dialog
+ /// has no close button and could not be dismissed.
+ ///
public static void ShowDialog(string title, string description, params DialogButton[] buttons)
{
if (buttons.Length == 0) return;
@@ -68,8 +115,14 @@ internal class CommonWarningDialogUI : MonoBehaviour
dialog.Initialize(title, description, buttons);
}
+ ///
+ /// Builds the canvas, backdrop, window and contents. Layout sizes are in canvas units at the 1920x1080 reference
+ /// resolution of the .
+ ///
private void Initialize(string title, string description, DialogButton[] buttons)
{
+ // uGUI buttons need an EventSystem to receive clicks. When the current scene has none, a child EventSystem
+ // is created; it is destroyed together with the dialog.
if (!UnityEngine.EventSystems.EventSystem.current)
{
var eventSystemObj = new GameObject("EventSystem");
@@ -78,6 +131,8 @@ internal class CommonWarningDialogUI : MonoBehaviour
eventSystemObj.AddComponent();
}
+ // Disable every enabled UI Toolkit root so that it ignores input while the dialog is open.
+ // Roots that are already disabled are left alone and are not re-enabled in OnDestroy.
foreach (var doc in FindObjectsByType(FindObjectsSortMode.None))
{
if (doc && doc.rootVisualElement is { enabledSelf: true })
@@ -87,14 +142,17 @@ internal class CommonWarningDialogUI : MonoBehaviour
}
}
- // Set UI layer (5)
+ // Set UI layer (5, Unity's built-in "UI" layer)
gameObject.layer = 5;
- // Canvas setup
+ // Canvas setup.
+ // Sorting order 32766 draws the dialog above the RendererUIManager window canvas (32000), the render overlays
+ // (DebugMonitorUI 9999, EscapeHandlerUI 10000) and below FFmpegDownloadUI (32767, the largest sorting order).
var canvas = gameObject.AddComponent